diff --git a/quota/linux-lib.pl b/quota/linux-lib.pl index a0cf7d08d..c9a3a3bc5 100755 --- a/quota/linux-lib.pl +++ b/quota/linux-lib.pl @@ -1084,6 +1084,393 @@ my $out = &backquote_logged( &error($out) if ($?); } +=head2 is_btrfs_fs(path) + +Returns 1 if a path is on a mounted Btrfs filesystem, 0 otherwise. + +=cut +sub is_btrfs_fs +{ +my ($path) = @_; +return 0 if (!&valid_btrfs_path($path)); + +# Btrfs subvolumes can have a different st_dev from the filesystem mount, so +# select the longest containing mount path instead of comparing device numbers. +my $best; +foreach my $m (&mount::list_mounted()) { + next if (!defined($m->[0]) || !defined($m->[2])); + next if (!&is_under_directory($m->[0], $path)); + $best = $m if (!$best || length($m->[0]) > length($best->[0])); + } +return $best && $best->[2] eq "btrfs" ? 1 : 0; +} + +# valid_btrfs_path(path) +# Returns 1 for an absolute path that is safe to pass to Btrfs tools. +sub valid_btrfs_path +{ +my ($path) = @_; +return defined($path) && $path =~ /^\// && $path !~ /[\r\n\0]/ ? 1 : 0; +} + +# run_btrfs_command(logged, arg, ...) +# Runs a Btrfs command with a clean locale. Returns the output and undef on +# success, or undef and an error message on failure. +sub run_btrfs_command +{ +my ($logged, @args) = @_; +my $btrfs = &has_command("btrfs"); +return (undef, "The btrfs command was not found") if (!$btrfs); +my $cmd = quotemeta($btrfs)." ". + join(" ", map { quotemeta($_) } @args)." 2>&1"; +&clean_language(); +my $out = $logged ? &backquote_logged($cmd) : &backquote_command($cmd); +my $ex = $?; +&reset_environment(); +$out =~ s/\s+$//; +return $ex ? (undef, $out || "The btrfs command failed") : ($out, undef); +} + +=head2 parse_btrfs_quota_status(output) + +Parses output from C and returns a hash reference with +enabled, mode, inconsistent and other status fields, or undef for invalid +output. This function is mainly intended for internal use. + +=cut +sub parse_btrfs_quota_status +{ +my ($out) = @_; +my %rv; +return undef if ($out !~ /^\s*Enabled:\s*(yes|no)\s*$/mi); +$rv{'enabled'} = lc($1) eq "yes" ? 1 : 0; +if ($out =~ /^\s*Mode:\s*(\S+)(?:\s+\(([^\)]*)\))?\s*$/mi) { + $rv{'mode'} = lc($1); + $rv{'mode_description'} = $2 if (defined($2)); + } +if ($out =~ /^\s*Inconsistent:\s*(yes|no)\s*$/mi) { + $rv{'inconsistent'} = lc($1) eq "yes" ? 1 : 0; + } +if ($out =~ /^\s*Override limits:\s*(yes|no)\s*$/mi) { + $rv{'override_limits'} = lc($1) eq "yes" ? 1 : 0; + } +if ($out =~ /^\s*Drop subtree threshold:\s*(\d+)\s*$/mi) { + $rv{'drop_subtree_threshold'} = int($1); + } +if ($out =~ /^\s*Total count:\s*(\d+)\s*$/mi) { + $rv{'total_count'} = int($1); + } +my %levels; +while($out =~ /^\s*Level\s+(\d+):\s*(\d+)\s*$/gmi) { + $levels{$1} = int($2); + } +$rv{'levels'} = \%levels if (%levels); +return \%rv; +} + +=head2 btrfs_quota_status(path) + +Returns a hash reference describing the Btrfs quota status for a path. The +hash always contains C, and may contain C, C, +C and C. Returns undef when the path is not on Btrfs. +Older btrfs-progs releases without C are supported using +C as a fallback. + +=cut +sub btrfs_quota_status +{ +my ($path) = @_; +return undef if (!&is_btrfs_fs($path)); +my ($out, $err) = &run_btrfs_command(0, "quota", "status", $path); +if (defined($out)) { + my $rv = &parse_btrfs_quota_status($out); + if ($rv) { + $rv->{'supported'} = 1; + return $rv; + } + } + +# Older versions have no quota status command. qgroup show succeeds when +# quotas are enabled, and reports a missing quota root when disabled. +my ($qout, $qerr) = &run_btrfs_command(0, "qgroup", "show", "--raw", $path); +if (defined($qout)) { + return { 'supported' => 1, + 'enabled' => 1 }; + } +elsif ($qerr =~ /(?:quota root does not exist|quotas? (?:are |is )?not enabled)/i) { + return { 'supported' => 1, + 'enabled' => 0 }; + } +return { 'supported' => 1, + 'error' => $qerr || $err || "Unable to read Btrfs quota status" }; +} + +=head2 parse_btrfs_qgroup_output(output) + +Parses raw output from C and returns an array +reference of qgroup hashes. Numeric sizes are returned in bytes, missing +limits are undef, and parent and child qgroups are returned as array refs. +This function is mainly intended for internal use. + +=cut +sub parse_btrfs_qgroup_output +{ +my ($out) = @_; +my @rv; +foreach my $line (split(/\r?\n/, $out)) { + $line =~ s/^\s+//; + $line =~ s/\s+$//; + next if ($line !~ /^(\d+\/\d+)\s+/); + my @cols = split(/\s+/, $line, 8); + next if (@cols < 7); + my ($id, $referenced, $exclusive, $max_referenced, $max_exclusive, + $parents, $children, $path) = @cols; + push(@rv, { + 'id' => $id, + 'referenced' => int($referenced), + 'exclusive' => int($exclusive), + 'max_referenced' => $max_referenced eq "none" ? undef : + int($max_referenced), + 'max_exclusive' => $max_exclusive eq "none" ? undef : + int($max_exclusive), + 'parents' => $parents =~ /^-+$/ ? [ ] : + [ split(/,/, $parents) ], + 'children' => $children =~ /^-+$/ ? [ ] : + [ split(/,/, $children) ], + 'path' => defined($path) ? $path : "", + }); + } +return \@rv; +} + +=head2 list_btrfs_qgroups(path, [sync], [&error]) + +Returns an array reference containing all Btrfs qgroups on the filesystem +that contains path. Each entry contains id, referenced and exclusive usage, +limits, parents, children and path. Returns undef on failure and optionally +saves the error message to the final scalar reference. If sync is true, the +filesystem is synchronized before usage is read. + +=cut +sub list_btrfs_qgroups +{ +my ($path, $sync, $errref) = @_; +if (!&valid_btrfs_path($path)) { + $$errref = "Invalid Btrfs path" if ($errref); + return undef; + } +my @args = ( "qgroup", "show", "--raw", "-r", "-e", "-p", "-c" ); +push(@args, "--sync") if ($sync); +push(@args, $path); +my ($out, $err) = &run_btrfs_command(0, @args); +if (!defined($out)) { + $$errref = $err if ($errref); + return undef; + } +my $rv = &parse_btrfs_qgroup_output($out); +if (!@$rv && $out =~ /\S/) { + $$errref = "Unable to parse Btrfs qgroup output" if ($errref); + return undef; + } +$$errref = undef if ($errref); +return $rv; +} + +=head2 btrfs_subvolume_id(path, [&error]) + +Returns the numeric Btrfs subvolume ID for path, or undef if the path is not +a subvolume or the ID cannot be read. The optional scalar reference receives +the command error. + +=cut +sub btrfs_subvolume_id +{ +my ($path, $errref) = @_; +if (!&valid_btrfs_path($path)) { + $$errref = "Invalid Btrfs path" if ($errref); + return undef; + } +my ($out, $err) = &run_btrfs_command(0, "subvolume", "show", $path); +if (defined($out) && $out =~ /^\s*Subvolume ID:\s*(\d+)\s*$/mi) { + $$errref = undef if ($errref); + return int($1); + } +$$errref = $err || "Unable to read the Btrfs subvolume ID" if ($errref); +return undef; +} + +=head2 get_btrfs_qgroup(path, [sync], [&error]) + +Returns the level-0 qgroup for a Btrfs subvolume path, or undef on failure. +The returned hash has the same fields as entries from list_btrfs_qgroups. + +=cut +sub get_btrfs_qgroup +{ +my ($path, $sync, $errref) = @_; +my $id = &btrfs_subvolume_id($path, $errref); +return undef if (!defined($id)); +my $qgroups = &list_btrfs_qgroups($path, $sync, $errref); +return undef if (!$qgroups); +foreach my $q (@$qgroups) { + return $q if ($q->{'id'} eq "0/$id"); + } +$$errref = "No Btrfs qgroup exists for subvolume $id" if ($errref); +return undef; +} + +=head2 enable_btrfs_quotas(path, [simple]) + +Enables Btrfs quotas on the filesystem containing path. If simple is true, +simple quotas (squotas) are requested. Returns undef on success or an error +message on failure. + +=cut +sub enable_btrfs_quotas +{ +my ($path, $simple) = @_; +return "Invalid Btrfs path" if (!&valid_btrfs_path($path)); +my @args = ( "quota", "enable" ); +push(@args, "--simple") if ($simple); +push(@args, $path); +my ($out, $err) = &run_btrfs_command(1, @args); +return $err; +} + +=head2 disable_btrfs_quotas(path) + +Disables Btrfs quotas on the filesystem containing path. This removes all +qgroup configuration. Returns undef on success or an error message on +failure. + +=cut +sub disable_btrfs_quotas +{ +my ($path) = @_; +return "Invalid Btrfs path" if (!&valid_btrfs_path($path)); +my ($out, $err) = &run_btrfs_command(1, "quota", "disable", $path); +return $err; +} + +=head2 rescan_btrfs_quotas(path, [wait]) + +Starts a Btrfs quota rescan. If wait is true, waits for the rescan to finish. +Returns undef on success or an error message on failure. + +=cut +sub rescan_btrfs_quotas +{ +my ($path, $wait) = @_; +return "Invalid Btrfs path" if (!&valid_btrfs_path($path)); +my @args = ( "quota", "rescan" ); +push(@args, "-w") if ($wait); +push(@args, $path); +my ($out, $err) = &run_btrfs_command(1, @args); +return $err; +} + +# valid_btrfs_qgroup_id(id) +# Returns 1 for a syntactically valid qgroup ID. +sub valid_btrfs_qgroup_id +{ +return defined($_[0]) && $_[0] =~ /^\d+\/\d+$/ ? 1 : 0; +} + +=head2 set_btrfs_qgroup_limit(path, [qgroup], [bytes], [exclusive]) + +Sets the referenced or exclusive byte limit for a Btrfs qgroup. If qgroup is +undef, path must be a subvolume and its level-0 qgroup is changed. If bytes +is undef, the limit is removed. Returns undef on success or an error message +on failure. + +=cut +sub set_btrfs_qgroup_limit +{ +my ($path, $qgroup, $bytes, $exclusive) = @_; +return "Invalid Btrfs path" if (!&valid_btrfs_path($path)); +return "Invalid Btrfs qgroup ID" + if (defined($qgroup) && !&valid_btrfs_qgroup_id($qgroup)); +return "Invalid Btrfs qgroup limit" + if (defined($bytes) && $bytes !~ /^\d+$/); +my @args = ( "qgroup", "limit" ); +push(@args, "-e") if ($exclusive); +push(@args, defined($bytes) ? $bytes : "none"); +push(@args, $qgroup) if (defined($qgroup)); +push(@args, $path); +my ($out, $err) = &run_btrfs_command(1, @args); +return $err; +} + +=head2 create_btrfs_qgroup(path, qgroup) + +Creates a Btrfs qgroup on the filesystem containing path. Returns undef on +success or an error message on failure. + +=cut +sub create_btrfs_qgroup +{ +my ($path, $qgroup) = @_; +return "Invalid Btrfs path" if (!&valid_btrfs_path($path)); +return "Invalid Btrfs qgroup ID" if (!&valid_btrfs_qgroup_id($qgroup)); +my ($out, $err) = &run_btrfs_command( + 1, "qgroup", "create", $qgroup, $path); +return $err; +} + +=head2 delete_btrfs_qgroup(path, qgroup) + +Deletes an unassigned Btrfs qgroup. Returns undef on success or an error +message on failure. + +=cut +sub delete_btrfs_qgroup +{ +my ($path, $qgroup) = @_; +return "Invalid Btrfs path" if (!&valid_btrfs_path($path)); +return "Invalid Btrfs qgroup ID" if (!&valid_btrfs_qgroup_id($qgroup)); +my ($out, $err) = &run_btrfs_command( + 1, "qgroup", "destroy", $qgroup, $path); +return $err; +} + +=head2 assign_btrfs_qgroup(path, child, parent) + +Assigns a child Btrfs qgroup to a parent qgroup. Returns undef on success or +an error message on failure. + +=cut +sub assign_btrfs_qgroup +{ +my ($path, $child, $parent) = @_; +return "Invalid Btrfs path" if (!&valid_btrfs_path($path)); +return "Invalid child Btrfs qgroup ID" + if (!&valid_btrfs_qgroup_id($child)); +return "Invalid parent Btrfs qgroup ID" + if (!&valid_btrfs_qgroup_id($parent)); +my ($out, $err) = &run_btrfs_command( + 1, "qgroup", "assign", $child, $parent, $path); +return $err; +} + +=head2 unassign_btrfs_qgroup(path, child, parent) + +Removes a child Btrfs qgroup from a parent qgroup. Returns undef on success +or an error message on failure. + +=cut +sub unassign_btrfs_qgroup +{ +my ($path, $child, $parent) = @_; +return "Invalid Btrfs path" if (!&valid_btrfs_path($path)); +return "Invalid child Btrfs qgroup ID" + if (!&valid_btrfs_qgroup_id($child)); +return "Invalid parent Btrfs qgroup ID" + if (!&valid_btrfs_qgroup_id($parent)); +my ($out, $err) = &run_btrfs_command( + 1, "qgroup", "remove", $child, $parent, $path); +return $err; +} + =head2 can_quotacheck(fs) Returns 1 if some FS supports quota checking diff --git a/quota/t/run-tests.t b/quota/t/run-tests.t new file mode 100644 index 000000000..361fd30fb --- /dev/null +++ b/quota/t/run-tests.t @@ -0,0 +1,247 @@ +#!/usr/bin/perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use Cwd qw(abs_path); +use File::Basename qw(dirname); + +my $root = abs_path(dirname(__FILE__)."/../..") or die "rootdir: $!"; +my @commands; +my @responses; +our @mounted = ( + [ "/", "/dev/root", "ext4", "rw" ], + [ "/srv/btrfs", "/dev/loop0", "btrfs", "rw" ], + [ "/srv/btrfs/external", "/dev/loop1", "ext4", "rw" ], + ); + +sub has_command +{ +return $_[0] eq "btrfs" ? "/usr/bin/btrfs" : undef; +} + +sub clean_language { } +sub reset_environment { } + +sub is_under_directory +{ +my ($dir, $path) = @_; +return 1 if ($dir eq "/"); +$dir =~ s/\/*$/\//; +return $path eq substr($dir, 0, -1) || index($path, $dir) == 0; +} + +sub next_response +{ +my ($cmd) = @_; +push(@commands, $cmd); +my $response = shift(@responses) || { 'out' => "", 'status' => 0 }; +$? = $response->{'status'}; +return $response->{'out'}; +} + +sub backquote_command +{ +return &next_response($_[0]); +} + +sub backquote_logged +{ +return &next_response($_[0]); +} + +sub error +{ +die join("", @_); +} + +{ +package mount; +sub list_mounted +{ +return @main::mounted; +} +sub filesystem_for_dir +{ +# Btrfs subvolumes can have a different st_dev from their containing mount, +# which prevents filesystem_for_dir from finding the mount by device number. +return @{$main::mounted[0]}; +} +} + +do "$root/quota/linux-lib.pl" or die "linux-lib.pl: $@ $!"; + +ok(main::is_btrfs_fs("/srv/btrfs"), + "Btrfs mount point is detected"); +ok(main::is_btrfs_fs("/srv/btrfs/domain1"), + "path inside Btrfs is detected"); +ok(!main::is_btrfs_fs("/srv/btrfs/external/file"), + "nested non-Btrfs mount takes precedence"); +ok(!main::is_btrfs_fs("/"), + "non-Btrfs path is rejected"); +ok(!defined(main::btrfs_quota_status("/")), + "quota status is unavailable for non-Btrfs paths"); + +my $status_text = <<'EOF'; +Quotas on /srv/btrfs: + Enabled: yes + Mode: qgroup (full accounting) + Inconsistent: no + Override limits: no + Drop subtree threshold: 3 + Total count: 4 + Level 0: 3 + Level 1: 1 +EOF +my $status = main::parse_btrfs_quota_status($status_text); +is_deeply($status, { + 'enabled' => 1, + 'mode' => 'qgroup', + 'mode_description' => 'full accounting', + 'inconsistent' => 0, + 'override_limits' => 0, + 'drop_subtree_threshold' => 3, + 'total_count' => 4, + 'levels' => { 0 => 3, 1 => 1 }, + }, "full Btrfs quota status is parsed"); + +my $simple_status = main::parse_btrfs_quota_status(<<'EOF'); +Quotas on /srv/btrfs: + Enabled: yes + Mode: squota (simple accounting) + Inconsistent: yes +EOF +is($simple_status->{'mode'}, "squota", "simple quota mode is parsed"); +ok($simple_status->{'inconsistent'}, "inconsistent status is parsed"); +is_deeply(main::parse_btrfs_quota_status(" Enabled: no\n"), + { 'enabled' => 0 }, "disabled status is parsed"); +ok(!defined(main::parse_btrfs_quota_status("invalid output\n")), + "invalid status output is rejected"); + +@commands = ( ); +@responses = ({ 'out' => $status_text, 'status' => 0 }); +$status = main::btrfs_quota_status("/srv/btrfs"); +ok($status->{'supported'} && $status->{'enabled'}, + "status command reports enabled quotas"); +is(scalar(@commands), 1, "successful status does not run fallback"); + +@responses = ( + { 'out' => "ERROR: unknown token 'status'\n", 'status' => 1 }, + { 'out' => "qgroupid rfer excl\n0/5 16384 16384\n", 'status' => 0 }, + ); +$status = main::btrfs_quota_status("/srv/btrfs"); +ok($status->{'enabled'}, "legacy qgroup fallback detects enabled quotas"); + +@responses = ( + { 'out' => "ERROR: unknown token 'status'\n", 'status' => 1 }, + { 'out' => "ERROR: quota root does not exist\n", 'status' => 1 }, + ); +$status = main::btrfs_quota_status("/srv/btrfs"); +is($status->{'enabled'}, 0, "legacy fallback detects disabled quotas"); + +my $qgroup_text = <<'EOF'; +Qgroupid Referenced Exclusive Max referenced Max exclusive Parent Child Path +-------- ---------- --------- -------------- ------------- ------ ----- ---- +0/5 16384 16384 none none --- --- +0/256 16384 16384 67108864 none 1/100 - domain1 +0/257 0 0 none 33554432 1/100 - domain two +1/100 16384 16384 100663296 none - 0/256,0/257 <0 member qgroups> +EOF +my $qgroups = main::parse_btrfs_qgroup_output($qgroup_text); +is(scalar(@$qgroups), 4, "all qgroups are parsed"); +is_deeply($qgroups->[0]->{'parents'}, [ ], + "legacy empty parent marker is parsed"); +is_deeply($qgroups->[0]->{'children'}, [ ], + "legacy empty child marker is parsed"); +is($qgroups->[1]->{'max_referenced'}, 67108864, + "referenced limit is parsed as bytes"); +ok(!defined($qgroups->[1]->{'max_exclusive'}), + "missing exclusive limit is undef"); +is_deeply($qgroups->[1]->{'parents'}, [ "1/100" ], + "parent qgroup is parsed"); +is($qgroups->[2]->{'path'}, "domain two", + "subvolume paths containing spaces are preserved"); +is_deeply($qgroups->[3]->{'children'}, [ "0/256", "0/257" ], + "multiple child qgroups are parsed"); + +@commands = ( ); +@responses = ({ 'out' => $qgroup_text, 'status' => 0 }); +my $list_error; +$qgroups = main::list_btrfs_qgroups("/srv/btrfs", 1, \$list_error); +is(scalar(@$qgroups), 4, "qgroup list command output is returned"); +ok(!defined($list_error), "successful qgroup list clears the error"); +like($commands[0], qr/qgroup show .*\\-\\-sync .*srv.*btrfs/, + "synchronized qgroup listing requests --sync"); + +@responses = ({ 'out' => "ERROR: quotas not enabled\n", 'status' => 1 }); +$qgroups = main::list_btrfs_qgroups("/srv/btrfs", 0, \$list_error); +ok(!defined($qgroups), "failed qgroup listing returns undef"); +like($list_error, qr/quotas not enabled/, + "failed qgroup listing returns the command error"); +@responses = ({ 'out' => "unexpected output\n", 'status' => 0 }); +$qgroups = main::list_btrfs_qgroups("/srv/btrfs", 0, \$list_error); +ok(!defined($qgroups), "unparseable qgroup listing returns undef"); +is($list_error, "Unable to parse Btrfs qgroup output", + "unparseable qgroup output is reported"); + +@responses = ({ + 'out' => "domain1\n\tSubvolume ID:\t\t256\n", + 'status' => 0, + }); +is(main::btrfs_subvolume_id("/srv/btrfs/domain1"), 256, + "subvolume ID is parsed"); + +@responses = ( + { 'out' => "domain1\n\tSubvolume ID:\t\t256\n", 'status' => 0 }, + { 'out' => $qgroup_text, 'status' => 0 }, + ); +my $qgroup = main::get_btrfs_qgroup("/srv/btrfs/domain1", 0); +is($qgroup->{'id'}, "0/256", "subvolume qgroup is selected by ID"); + +@commands = ( ); +@responses = map { { 'out' => "", 'status' => 0 } } 1 .. 9; +is(main::enable_btrfs_quotas("/srv/btrfs", 1), undef, + "simple quotas can be enabled"); +is(main::set_btrfs_qgroup_limit("/srv/btrfs", "1/100", 1048576, 0), + undef, "referenced qgroup limit can be set"); +is(main::set_btrfs_qgroup_limit("/srv/btrfs/domain1", undef, undef, 1), + undef, "exclusive subvolume limit can be removed"); +is(main::create_btrfs_qgroup("/srv/btrfs", "1/101"), undef, + "parent qgroup can be created"); +is(main::assign_btrfs_qgroup("/srv/btrfs", "0/256", "1/101"), undef, + "child qgroup can be assigned"); +is(main::unassign_btrfs_qgroup("/srv/btrfs", "0/256", "1/101"), undef, + "child qgroup can be unassigned"); +is(main::delete_btrfs_qgroup("/srv/btrfs", "1/101"), undef, + "parent qgroup can be deleted"); +is(main::rescan_btrfs_quotas("/srv/btrfs", 1), undef, + "quota rescan can run and wait"); +is(main::disable_btrfs_quotas("/srv/btrfs"), undef, + "Btrfs quotas can be disabled"); +like($commands[0], qr/quota enable .*\\-\\-simple/, + "simple enable command uses supported long option"); +like($commands[1], qr/qgroup limit .*1048576 .*1\\\/100/, + "referenced limit command contains size and qgroup"); +like($commands[2], qr/qgroup limit .*\-e .*none/, + "exclusive limit removal uses -e and none"); +like($commands[7], qr/quota rescan .*\\-w/, + "quota rescan uses the portable short wait option"); + +is(main::set_btrfs_qgroup_limit("/srv/btrfs", "bad", 1024), + "Invalid Btrfs qgroup ID", "invalid qgroup IDs are rejected"); +is(main::disable_btrfs_quotas("relative/path"), + "Invalid Btrfs path", "relative paths are rejected"); +is(main::disable_btrfs_quotas("/srv/btrfs\n/etc"), + "Invalid Btrfs path", "paths with control characters are rejected"); +ok(!main::is_btrfs_fs("/srv/btrfs\n/etc"), + "paths with control characters are not detected as Btrfs"); +is(main::set_btrfs_qgroup_limit("/srv/btrfs", "1/100", "1M"), + "Invalid Btrfs qgroup limit", "non-byte limits are rejected"); +is(main::assign_btrfs_qgroup("/srv/btrfs", "bad", "1/100"), + "Invalid child Btrfs qgroup ID", "invalid child assignment is rejected"); + +@responses = ({ 'out' => "ERROR: qgroup exists\n", 'status' => 1 }); +is(main::create_btrfs_qgroup("/srv/btrfs", "1/100"), + "ERROR: qgroup exists", "Btrfs command errors are returned to callers"); + +done_testing();