Add Btrfs subvolume quota management

Add Btrfs subvolume quota support to the Disk Quotas module, including full and simple accounting modes, quota lifecycle actions, and qgroup usage and limit management.

Includes ACL enforcement, compatibility with older btrfs-progs versions, destructive-action confirmation, top-level qgroup protection, help, and tests.
This commit is contained in:
Ilia Ross
2026-08-04 01:19:45 +02:00
parent b52cbb98d4
commit 9be5dc31eb
16 changed files with 789 additions and 13 deletions
+1
View File
@@ -1,6 +1,7 @@
## Changelog
#### 2.654 (August, 2026)
* Add incremental ban time options to the Fail2Ban module
* Add Btrfs subvolume quota management to the Disk Quotas module, with full and simple accounting modes
* Fix to ignore failures when adding IPv6 link-local (fe80::) addresses that may already be configured automatically
* Update the Authentic theme to the latest version with various improvements:
- Fix disallowed entry handling in File Manager [forum.virtualmin.com/t/137654](https://forum.virtualmin.com/t/extra-admin-file-manager-permissions/137654?u=ilia)
+61
View File
@@ -0,0 +1,61 @@
#!/usr/local/bin/perl
# Enable, disable or rescan Btrfs quotas
require './quota-lib.pl';
&ReadParse();
$dir = $in{'dir'};
# Require quota activation access and a valid mounted Btrfs filesystem before
# running any command that can change filesystem quota state.
&can_edit_btrfs_filesys($dir) && $access{'enable'} && !$access{'ro'} ||
&error($text{'btrfs_eenable'});
defined(&btrfs_quota_status) && &is_btrfs_fs($dir) ||
&error($text{'btrfs_enotbtrfs'});
# Accept only the three operations implemented by this handler.
$in{'action'} =~ /^(enable|disable|rescan)$/ ||
&error($text{'btrfs_eaction'});
# Disabling Btrfs quotas removes every qgroup and limit, so require an explicit
# confirmation before performing this destructive operation.
if ($in{'action'} eq "disable" && !$in{'confirm'}) {
# Mark the filesystem and qgroup terms as literal technical values.
my $dir_label = &ui_tag("tt", &html_escape($dir));
my $qgroup_label = &ui_tag("tt", "qgroup");
chomp($dir_label);
chomp($qgroup_label);
# Display the destructive warning inside the confirmation form.
&ui_print_header(undef, $text{'btrfs_disable'}, "", "btrfs");
print &ui_confirmation_form(
"btrfs_action.cgi",
&text('btrfs_disable_confirm', $dir_label),
[ [ "dir", $dir ], [ "action", "disable" ] ],
[ [ "confirm", $text{'btrfs_disable'} ] ],
&ui_alert_box(&text('btrfs_disable_warning', $qgroup_label), "warn",
undef, undef, ""));
&ui_print_footer("", $text{'index_return'});
exit;
}
&error_setup($text{'btrfs_efailed'});
# Enable quotas using the accounting mode selected in the module configuration.
if ($in{'action'} eq "enable") {
$err = &enable_btrfs_quotas($dir,
$config{'btrfs_mode'} eq "simple" ? 1 : 0);
}
# Disable quotas after the confirmation branch above has been completed.
elsif ($in{'action'} eq "disable") {
$err = &disable_btrfs_quotas($dir);
}
# The remaining valid action starts a full-accounting quota rescan.
else {
$err = &rescan_btrfs_quotas($dir, 0);
}
# Report command failures, record successful changes, and return to the most
# relevant page for the completed action.
&error($err) if ($err);
&webmin_log($in{'action'}, "btrfs", $dir, \%in);
&redirect($in{'action'} eq "rescan" ?
"list_btrfs.cgi?dir=".&urlize($dir) : "");
+1
View File
@@ -23,3 +23,4 @@ show_grace=1
email_msg=Disk usage for user ${USER} on filesystem ${FS} has reached ${PERCENT}% of the allowed quota. ${USED} of disk space is being used, out of a maximum of ${QUOTA}.
pc_show=1
hide_uids=0
btrfs_mode=full
+1
View File
@@ -5,6 +5,7 @@ pc_show=Usage percentages to show,1,2-Hard and soft,1-Hard only,0-Soft only
sort_mode=Sort users and groups by,1,0-Blocks used,2-Name,1-Order from repquota,3-Hard block quota,4-Soft block quota,5-Percent of hard quota used,6-Percent of soft quota used
block_mode=Show quotas in,1,1-Kilobytes (where possible),0-Blocks
hide_uids=Show deleted users?,1,0-Yes,1-No
btrfs_mode=Btrfs accounting mode when enabling quotas,15,btrfs_mode
line1.1=Quota email messages,11
email_msg=Email message for users over quota,9,80,5,\t
email_subject=Subject for email message to users,3,Default
+36
View File
@@ -0,0 +1,36 @@
# Build and parse the Btrfs-specific module configuration field.
require './quota-lib.pl';
# config_pre_load(info, [order])
# Hide Btrfs-specific settings unless both a mounted Btrfs filesystem and the
# command-line tool needed to manage it are available.
sub config_pre_load
{
my ($info, $order) = @_;
my @btrfs = &list_btrfs_filesystems();
return if (@btrfs && &has_command("btrfs"));
# Remove the field from both the configuration metadata and display order.
delete($info->{'btrfs_mode'});
@$order = grep { $_ ne "btrfs_mode" } @$order if ($order);
}
# show_btrfs_mode(mode)
# Display the accounting mode selector, defaulting unknown values to full mode.
sub show_btrfs_mode
{
my ($mode) = @_;
$mode = "full" if ($mode ne "simple");
return &ui_radio("btrfs_mode", $mode,
[ [ "full", $text{'config_btrfs_full'} ],
[ "simple", $text{'config_btrfs_simple'} ] ]);
}
# parse_btrfs_mode()
# Store only a supported mode and fall back to full accounting otherwise.
sub parse_btrfs_mode
{
return $in{'btrfs_mode'} eq "simple" ? "simple" : "full";
}
1;
+54
View File
@@ -0,0 +1,54 @@
#!/usr/local/bin/perl
# Edit the limits for a Btrfs qgroup
require './quota-lib.pl';
&ReadParse();
$dir = $in{'dir'};
# Limit editing requires write access to an allowed mounted Btrfs filesystem
# and a syntactically valid qgroup ID.
$access{'ro'} && &error($text{'btrfs_eedit'});
&can_edit_btrfs_filesys($dir) || &error($text{'btrfs_eallow'});
defined(&btrfs_quota_status) && &is_btrfs_fs($dir) ||
&error($text{'btrfs_enotbtrfs'});
&valid_btrfs_qgroup_id($in{'qgroup'}) || &error($text{'btrfs_eqgroup'});
$in{'qgroup'} eq "0/5" && &error($text{'btrfs_etoplevel'});
# Load the current qgroups and ensure the requested ID still exists.
$qgroups = &list_btrfs_qgroups($dir, 0, \$listerr);
&error($listerr) if (!$qgroups);
($qgroup) = grep { $_->{'id'} eq $in{'qgroup'} } @$qgroups;
$qgroup || &error($text{'btrfs_eqgroup'});
# Start a form bound to the selected filesystem and qgroup.
&ui_print_header(undef, $text{'btrfs_edit_title'}, "", "btrfs");
print "<p>$text{'btrfs_edit_info'}</p>\n";
print &ui_form_start("save_btrfs.cgi", "post");
print &ui_hidden("dir", $dir);
print &ui_hidden("qgroup", $qgroup->{'id'});
print &ui_table_start(&text('btrfs_edit_header',
&html_escape($qgroup->{'id'}), &html_escape($dir)), "width=100%", 2);
# Show the current path and accounted usage as read-only values.
print &ui_table_row($text{'btrfs_path'},
$qgroup->{'path'} ne "" ? &html_escape($qgroup->{'path'}) : "-");
print &ui_table_row($text{'btrfs_referenced'},
&nice_size($qgroup->{'referenced'}));
print &ui_table_row($text{'btrfs_exclusive'},
&nice_size($qgroup->{'exclusive'}));
print &ui_table_hr();
# Allow referenced and exclusive limits to be changed independently.
print &ui_table_row($text{'btrfs_max_referenced'},
&quota_input("max_referenced",
defined($qgroup->{'max_referenced'}) ?
$qgroup->{'max_referenced'} : 0, 1));
print &ui_table_row($text{'btrfs_max_exclusive'},
&quota_input("max_exclusive",
defined($qgroup->{'max_exclusive'}) ?
$qgroup->{'max_exclusive'} : 0, 1));
print &ui_table_end();
print &ui_form_end([ [ undef, $text{'btrfs_update'} ] ]);
# Return to the qgroup list for this filesystem.
&ui_print_footer("list_btrfs.cgi?dir=".&urlize($dir), $text{'btrfs_title'});
+53
View File
@@ -0,0 +1,53 @@
<header>Btrfs Subvolume Quotas</header>
<h3>Introduction</h3>
Btrfs quotas control disk usage for subvolumes through quota groups, usually
called <tt>qgroups</tt>. Unlike traditional Unix quotas, they do not limit an
individual user or group and do not provide soft limits, grace periods, or
file-count limits. <p>
Each Btrfs subvolume has a level-0 <tt>qgroup</tt>. The module displays the
following usage and limit values for each <tt>qgroup</tt> :
<dl>
<dt><b>Referenced</b>
<dd>All data reachable from the subvolume, including data shared with other
subvolumes or snapshots.
<dt><b>Exclusive</b>
<dd>Data used only by the subvolume, which would be freed if it were deleted.
<dt><b>Referenced limit</b>
<dd>The maximum referenced space that the <tt>qgroup</tt> may use.
<dt><b>Exclusive limit</b>
<dd>The maximum exclusive space that the <tt>qgroup</tt> may use.
</dl>
<h3>Accounting Modes</h3>
When Btrfs quotas are enabled, the accounting mode configured in the module
settings is used :
<dl>
<dt><b>Full accounting</b>
<dd>Tracks shared space between subvolumes and snapshots. This is the
recommended mode when accurate referenced and exclusive usage is required.
<dt><b>Simple accounting</b>
<dd>Tracks original ownership with lower overhead, but does not fully track
space shared between subvolumes and snapshots.
</dl>
Changing the module setting does not convert an already-enabled filesystem.
The selected mode is used the next time quotas are enabled. <p>
<h3>Managing Btrfs Quotas</h3>
The main module page shows each mounted Btrfs filesystem, its accounting mode,
consistency state, and an action to enable or disable quotas. Click a filesystem
path to view its <tt>qgroups</tt>, usage, and limits. Click a <tt>qgroup</tt> ID
to edit its referenced and exclusive limits. <p>
Because quota state and <tt>qgroup</tt> IDs apply to the whole underlying Btrfs
filesystem, delegated Webmin users must be allowed to manage all filesystems to
access these controls. A permission scoped to one mounted subvolume is not
sufficient. <p>
Full accounting also provides a rescan action for rebuilding <tt>qgroup</tt>
accounting in the background. Disabling Btrfs quotas removes all
<tt>qgroup</tt> configuration and limits on the filesystem, so the module
always requests confirmation first. <p>
<hr>
+88 -7
View File
@@ -1,21 +1,31 @@
#!/usr/local/bin/perl
# index.cgi
# Display a list of all local filesystems, and allow editing of quotas
# on those which have quotas turned on. The actual turning on of quotas must
# be done in the mount module first.
# on those which have quotas turned on. Traditional quota mount options are
# configured in the mount module, while Btrfs quotas are managed here.
require './quota-lib.pl';
&ui_print_header(undef, $text{'index_title'}, "", "intro", 1, 1, 0,
# Discover allowed Btrfs mounts independently of the traditional quota tools.
@btrfs = grep { &can_edit_btrfs_filesys($_->[0]) } &list_btrfs_filesystems();
$err = &quotas_init();
# Traditional filesystems are unavailable when quota-tools initialization fails.
@list = $err ? ( ) : &list_filesystems();
# Use focused Btrfs help when it is the only quota model shown on this page.
$help = @btrfs && !@list ? "btrfs" : "intro";
&ui_print_header(undef, $text{'index_title'}, "", $help, 1, 1, 0,
&help_search_link("quota", "man", "howto"));
$err = &quotas_init();
if ($err) {
# Stop only when neither traditional quota tools nor Btrfs tools can provide a
# usable filesystem list.
if ($err && (!@btrfs || !&has_command("btrfs"))) {
print "<p><b>$err</b><p>\n";
&ui_print_footer("/", $text{'index_return'});
exit;
}
@list = &list_filesystems();
if (@list) {
print &ui_columns_start([
$text{'index_fs'},
@@ -97,13 +107,84 @@ if (@list) {
}
print &ui_columns_end();
}
else {
# Report no support only when neither traditional nor Btrfs filesystems exist.
elsif (!@btrfs) {
print "<b>$text{'index_nosupport'}</b><p>\n";
if (&foreign_available("mount")) {
print &text('index_mountmod', "../mount/"),"<p>\n";
}
}
# Btrfs subvolume quotas use qgroups instead of Unix users and groups, so they
# are shown separately from the traditional quota filesystems above.
if (@btrfs) {
# Activation controls require both enable permission and write access.
$btrfs_canactivate = $access{'enable'} && !$access{'ro'};
# Start a table with an action column only for users who can change state.
print &ui_columns_start([
$text{'index_fs'},
$text{'index_type'},
$text{'index_mount'},
$text{'index_status'},
$btrfs_canactivate ? ( $text{'index_action'} ) : (),
], 100, 0, undef, &hlink($text{'index_btrfs_title'}, "btrfs"));
foreach $f (@btrfs) {
# Query each mount independently so failures remain visible per row.
undef($action);
$status = &btrfs_quota_status($f->[0]);
# The OS library could not identify this path as manageable Btrfs.
if (!$status) {
$msg = $text{'index_btrfs_unavailable'};
}
# Surface command or parsing errors without offering a state change.
elsif ($status->{'error'}) {
$msg = &text('index_btrfs_error',
&html_escape($status->{'error'}));
}
# Disabled filesystems can be enabled using the configured mode.
elsif (!$status->{'enabled'}) {
$msg = $text{'index_btrfs_disabled'};
$action = "enable";
}
# Enabled filesystems expose their accounting and consistency state.
else {
$mode = $status->{'mode'} eq "squota" ?
$text{'index_btrfs_simple'} :
$status->{'mode'} eq "qgroup" ?
$text{'index_btrfs_full'} :
$text{'index_btrfs_unknown'};
$msg = &text('index_btrfs_enabled', $mode);
$msg .= ", $text{'index_btrfs_inconsistent'}"
if ($status->{'inconsistent'});
$action = "disable";
}
# Build the common filesystem, type, source and status columns.
local @cols = (
&ui_link("list_btrfs.cgi?dir=".&urlize($f->[0]),
&html_escape($f->[0])),
&foreign_call("mount", "fstype_name", $f->[2]),
&foreign_call("mount", "device_name", $f->[1]),
$msg,
);
# Add the state-changing link only when the ACL allows it.
if ($btrfs_canactivate) {
push(@cols, $action ?
&ui_link("btrfs_action.cgi?dir=".&urlize($f->[0]).
"&action=$action",
$action eq "enable" ? $text{'index_enable'} :
$text{'index_disable'}) : "-");
}
print &ui_columns_row(\@cols);
}
# Close the separately titled Btrfs filesystem table.
print &ui_columns_end();
}
# Buttons to edit and specific user or group
if ($useractive || $groupactive) {
print &ui_hr();
+7 -1
View File
@@ -8,9 +8,15 @@ do 'quota-lib.pl';
# For mode 0, returns 1 if installed, 0 if not
sub is_installed
{
# Check the traditional quota-tools dependency when this OS implements it.
if (defined(&quotas_init)) {
local $err = &quotas_init();
return 0 if ($err);
# A usable Btrfs mount and command provide an alternative when the
# traditional quota-tools package is not installed.
if ($err) {
local @btrfs = &list_btrfs_filesystems();
return 0 if (!@btrfs || !&has_command("btrfs"));
}
}
return $_[0] ? 2 : 1;
}
+53
View File
@@ -167,6 +167,59 @@ index_egroup=Edit Group Quotas:
index_egroupdesc=Enter or select a group, and click this button to view its quotas on all filesystems.
index_nosupport=No local filesystems can support quotas.
index_mountmod=You can enable quotas for a filesystem in the <a href='$1'>Disk and Network Filesystems</a> module.
index_btrfs_title=Btrfs Subvolume Quotas
index_btrfs_enabled=Enabled, $1
index_btrfs_disabled=Disabled
index_btrfs_full=full accounting
index_btrfs_simple=simple accounting
index_btrfs_unknown=accounting mode unavailable
index_btrfs_inconsistent=inconsistent
index_btrfs_unavailable=Unavailable
index_btrfs_error=Error: $1
config_btrfs_full=Full accounting (recommended; tracks shared space and snapshots)
config_btrfs_simple=Simple accounting (lower overhead; tracks original ownership only)
btrfs_title=Btrfs Subvolume Quotas
btrfs_return=filesystem list
btrfs_status_header=Quota status for $1
btrfs_status=Status
btrfs_enabled=Enabled
btrfs_disabled=Disabled
btrfs_mode=Accounting mode
btrfs_full=Full accounting
btrfs_simple=Simple accounting
btrfs_unknown=Unavailable from this version of <tt>btrfs-progs</tt>
btrfs_consistency=Accounting state
btrfs_consistent=Consistent
btrfs_inconsistent=Inconsistent - a rescan is recommended
btrfs_qgroups=Subvolume quota groups
btrfs_qgroup=<tt>qgroup</tt>
btrfs_path=Subvolume path
btrfs_referenced=Referenced usage
btrfs_exclusive=Exclusive usage
btrfs_max_referenced=Referenced limit
btrfs_max_exclusive=Exclusive limit
btrfs_disable=Disable quotas
btrfs_disable_confirm=Disable Btrfs quotas on $1?
btrfs_disable_warning=All $1 configuration and limits on this filesystem will be removed!
btrfs_rescan=Rescan quotas
btrfs_rescan_desc=Rebuild full <tt>qgroup</tt> accounting in the background.
btrfs_edit_title=Edit Btrfs Quota
btrfs_edit_header=Limits for <tt>qgroup</tt> <tt>$1</tt> on <tt>$2</tt>
btrfs_edit_info=Referenced usage includes shared data reachable from the subvolume. Exclusive usage is the space that would be freed with it.
btrfs_update=Update
btrfs_eallow=Btrfs quota management requires permission to manage all filesystems because qgroups apply to the whole underlying filesystem
btrfs_eenable=You are not allowed to enable or disable quotas on this filesystem
btrfs_eedit=You are not allowed to edit Btrfs quota limits
btrfs_enotbtrfs=The selected path is not on a mounted Btrfs filesystem
btrfs_eqgroup=The selected Btrfs <tt>qgroup</tt> does not exist
btrfs_etoplevel=The top-level Btrfs <tt>qgroup</tt> cannot be limited because doing so can block the entire filesystem
btrfs_eaction=Invalid Btrfs quota action
btrfs_efailed=Failed to manage Btrfs quotas
btrfs_esave=Failed to save the Btrfs quota
btrfs_elimit=Quota limits must be positive numbers
btrfs_emax=You are not allowed to grant limits above $1
lgroups_failed=Failed to list groups
lgroups_tablist=Group list
+144 -4
View File
@@ -82,6 +82,12 @@ the following :
=cut
sub quota_can
{
my ($mnttab) = @_;
# The quota-tools commands used by this module cannot reliably manage tmpfs
# mounts, even when they expose usrquota or grpquota mount options.
return 0 if ($mnttab->[2] eq "tmpfs");
my %exclude_mounts;
if (&has_command("findmnt")) {
%exclude_mounts = map { $_ => 1 } split( /\n/m, backquote_command('findmnt -r | grep -oP \'^(\S+)(?=.*\[\/)\'') );
@@ -1168,6 +1174,53 @@ $rv{'levels'} = \%levels if (%levels);
return \%rv;
}
# btrfs_filesystem_uuid(path)
# Returns the UUID of the Btrfs filesystem containing a path.
sub btrfs_filesystem_uuid
{
my ($path) = @_;
my ($out, $err) = &run_btrfs_command(
0, "filesystem", "show", "--raw", $path);
return undef if (!defined($out) ||
$out !~ /^\s*Label:.*\buuid:\s*([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\s*$/mi);
return lc($1);
}
# btrfs_sysfs_quota_status(path)
# Reads quota state exported by the kernel. This preserves accounting mode and
# consistency information on btrfs-progs releases older than `quota status`.
sub btrfs_sysfs_quota_status
{
my ($path) = @_;
my $uuid = &btrfs_filesystem_uuid($path);
return undef if (!$uuid);
my $sysfs = $btrfs_sysfs_root || "/sys/fs/btrfs";
my $qdir = "$sysfs/$uuid/qgroups";
return undef if (!-d $qdir);
my %rv = ( 'supported' => 1, 'enabled' => 1 );
foreach my $field (qw(enabled mode inconsistent)) {
my $file = "$qdir/$field";
next if (!-r $file);
open(my $fh, "<", $file) || next;
my $value = <$fh>;
close($fh);
next if (!defined($value));
$value =~ s/^\s+|\s+$//g;
if ($field eq "mode" && $value =~ /^(qgroup|squota)$/) {
$rv{$field} = $value;
}
elsif ($field ne "mode" && $value =~ /^([01])$/) {
$rv{$field} = int($1);
}
}
# Kernels predating simple quotas expose the qgroups directory without a mode
# file. Their only possible accounting mode is full qgroups.
$rv{'mode'} = "qgroup" if (!defined($rv{'mode'}) && !-e "$qdir/mode");
return \%rv;
}
=head2 btrfs_quota_status(path)
Returns a hash reference describing the Btrfs quota status for a path. The
@@ -1194,8 +1247,20 @@ if (defined($out)) {
# 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 };
my $rv = &btrfs_sysfs_quota_status($path) ||
{ 'supported' => 1, 'enabled' => 1 };
# Old qgroup-show versions warn on stdout when the counters are
# inconsistent. Retain that signal if sysfs did not provide the flag.
if (!defined($rv->{'inconsistent'})) {
$rv->{'inconsistent'} =
$qout =~ /^\s*(?:warning|error):.*qgroup.*inconsistent/mi ?
1 : 0;
}
# A simple-quota space holder is definitive even when sysfs is unavailable.
$rv->{'mode'} = "squota"
if (!defined($rv->{'mode'}) &&
$qout =~ /<squota space holder>/i);
return $rv;
}
elsif ($qerr =~ /(?:quota root does not exist|quotas? (?:are |is )?not enabled)/i) {
return { 'supported' => 1,
@@ -1243,6 +1308,27 @@ foreach my $line (split(/\r?\n/, $out)) {
return \@rv;
}
=head2 parse_btrfs_subvolume_list_output(output)
Parses raw output from C<btrfs subvolume list> and returns a hash reference
mapping numeric subvolume IDs to filesystem-relative paths. This is used to
fill qgroup paths on btrfs-progs versions older than 6.0.1.
=cut
sub parse_btrfs_subvolume_list_output
{
my ($out) = @_;
my %rv;
foreach my $line (split(/\r?\n/, $out)) {
# The default output ends in "path <path relative to top level>".
# Keep the final field intact because Btrfs paths may contain spaces.
if ($line =~ /^ID\s+(\d+)\s+.*?\s+path\s+(.*)$/) {
$rv{int($1)} = $2;
}
}
return \%rv;
}
=head2 list_btrfs_qgroups(path, [sync], [&error])
Returns an array reference containing all Btrfs qgroups on the filesystem
@@ -1272,6 +1358,22 @@ if (!@$rv && $out =~ /\S/) {
$$errref = "Unable to parse Btrfs qgroup output" if ($errref);
return undef;
}
# qgroup paths were not printed by default until btrfs-progs 6.0.1. Populate
# missing level-0 paths from the long-established subvolume-list output so
# callers can keep identifying subvolumes by path on supported older systems.
if (grep { $_->{'id'} =~ /^0\/(\d+)$/ && $_->{'path'} eq '' } @$rv) {
my ($subvolout) = &run_btrfs_command(
0, "subvolume", "list", $path);
if (defined($subvolout)) {
my $paths = &parse_btrfs_subvolume_list_output($subvolout);
foreach my $q (@$rv) {
if ($q->{'id'} =~ /^0\/(\d+)$/ && $q->{'path'} eq '' &&
defined($paths->{$1})) {
$q->{'path'} = $paths->{$1};
}
}
}
}
$$errref = undef if ($errref);
return $rv;
}
@@ -1380,8 +1482,9 @@ return defined($_[0]) && $_[0] =~ /^\d+\/\d+$/ ? 1 : 0;
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.
is undef, the limit is removed. If an exceeded limit blocks the update, quota
enforcement is temporarily overridden for one retry. Returns undef on success
or an error message on failure.
=cut
sub set_btrfs_qgroup_limit
@@ -1398,6 +1501,43 @@ push(@args, defined($bytes) ? $bytes : "none");
push(@args, $qgroup) if (defined($qgroup));
push(@args, $path);
my ($out, $err) = &run_btrfs_command(1, @args);
# An exceeded qgroup can block the metadata write needed to raise or remove its
# own limit. Retry once with the kernel's administrative override, preserving
# the previous state and restoring enforcement immediately after the command.
if ($err && $err =~ /disk quota exceeded/i) {
my $uuid = &btrfs_filesystem_uuid($path);
my $sysfs = $btrfs_sysfs_root || "/sys/fs/btrfs";
my $override = $uuid ? "$sysfs/$uuid/quota_override" : undef;
my $current;
if ($override && open(my $fh, "<", $override)) {
$current = <$fh>;
close($fh);
$current =~ s/\s+//g if (defined($current));
}
# Only change a known disabled override; an active or unreadable setting
# belongs to the administrator and must not be altered here.
if (defined($current) && $current eq "0") {
my $enabled;
if (open(my $fh, ">", $override)) {
my $written = syswrite($fh, "1\n");
$enabled = defined($written) && $written == 2;
close($fh);
}
if ($enabled) {
($out, $err) = &run_btrfs_command(1, @args);
my $restored;
if (open(my $fh, ">", $override)) {
my $written = syswrite($fh, "0\n");
$restored = defined($written) && $written == 2;
close($fh);
}
return "Failed to restore Btrfs quota enforcement"
if (!$restored);
}
}
}
return $err;
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/local/bin/perl
# Display Btrfs quota status and subvolume qgroups
require './quota-lib.pl';
&ReadParse();
$dir = $in{'dir'};
# Restrict the page to allowed paths on mounted Btrfs filesystems.
&can_edit_btrfs_filesys($dir) || &error($text{'btrfs_eallow'});
defined(&btrfs_quota_status) && &is_btrfs_fs($dir) ||
&error($text{'btrfs_enotbtrfs'});
&error_setup($text{'btrfs_efailed'});
# Read quota status before building the status and qgroup tables.
$status = &btrfs_quota_status($dir);
$status || &error($text{'btrfs_enotbtrfs'});
&error($status->{'error'}) if ($status->{'error'});
&ui_print_header(undef, $text{'btrfs_title'}, "", "btrfs");
# Map the command's accounting mode to a user-facing label.
$mode = $status->{'mode'} eq "squota" ? $text{'btrfs_simple'} :
$status->{'mode'} eq "qgroup" ? $text{'btrfs_full'} :
$text{'btrfs_unknown'};
# Display the current enablement, accounting mode and consistency state.
print &ui_table_start(&text('btrfs_status_header', &html_escape($dir)),
"width=100%", 2);
print &ui_table_row($text{'btrfs_status'},
$status->{'enabled'} ? $text{'btrfs_enabled'} : $text{'btrfs_disabled'});
print &ui_table_row($text{'btrfs_mode'}, $mode) if ($status->{'enabled'});
# Show consistency only when btrfs-progs or the kernel reports it.
if (defined($status->{'inconsistent'})) {
print &ui_table_row($text{'btrfs_consistency'},
$status->{'inconsistent'} ? $text{'btrfs_inconsistent'} :
$text{'btrfs_consistent'});
}
print &ui_table_end();
# A disabled filesystem has no qgroups to list or edit.
if (!$status->{'enabled'}) {
&ui_print_footer("", $text{'btrfs_return'});
exit;
}
# Load all qgroups and start the usage and limit table.
$qgroups = &list_btrfs_qgroups($dir, 0, \$listerr);
&error($listerr) if (!$qgroups);
print &ui_columns_start([
$text{'btrfs_qgroup'},
$text{'btrfs_path'},
$text{'btrfs_referenced'},
$text{'btrfs_exclusive'},
$text{'btrfs_max_referenced'},
$text{'btrfs_max_exclusive'},
], 100, 0, undef, $text{'btrfs_qgroups'});
foreach $q (@$qgroups) {
# Read-only users and the filesystem-wide top-level qgroup get no edit
# link. Limiting 0/5 can block Webmin from changing the limit back.
$qid = &ui_tag("tt", &html_escape($q->{'id'}));
chomp($qid);
if (!$access{'ro'} && $q->{'id'} ne "0/5") {
$qid = &ui_link("edit_btrfs.cgi?dir=".&urlize($dir).
"&qgroup=".&urlize($q->{'id'}), $qid);
}
# Display usage and use the standard unlimited label for missing limits.
print &ui_columns_row([
$qid,
$q->{'path'} ne "" ? &html_escape($q->{'path'}) : "-",
&nice_size($q->{'referenced'}),
&nice_size($q->{'exclusive'}),
defined($q->{'max_referenced'}) ?
&nice_size($q->{'max_referenced'}) : $text{'quota_unlimited'},
defined($q->{'max_exclusive'}) ?
&nice_size($q->{'max_exclusive'}) : $text{'quota_unlimited'},
]);
}
print &ui_columns_end();
# Full accounting supports rescanning; simple accounting deliberately hides it.
if (!$access{'ro'} && $access{'enable'} && $status->{'mode'} ne "squota") {
print &ui_hr();
print &ui_buttons_start();
print &ui_buttons_row("btrfs_action.cgi", $text{'btrfs_rescan'},
$text{'btrfs_rescan_desc'},
[ [ "dir", $dir ], [ "action", "rescan" ] ]);
print &ui_buttons_end();
}
&ui_print_footer("", $text{'btrfs_return'});
+1 -1
View File
@@ -3,5 +3,5 @@ category=system
os_support=solaris *-linux hpux freebsd unixware openbsd irix netbsd macos
desc=Disk Quotas
depends=mount
longdesc=Setup and edit user or group disk quotas for local filesystems.
longdesc=Setup and edit user, group and Btrfs subvolume quotas for local filesystems.
readonly=1
+40
View File
@@ -82,6 +82,32 @@ if (defined(&quota_possible)) {
return grep { $_->[4] || $_->[6] } @mtab;
}
=head2 list_btrfs_filesystems
Returns one entry for each mounted Btrfs filesystem when the OS library
provides the Btrfs quota API. Command availability and quota status are checked
separately by callers; on systems without the API, this function returns an
empty list.
=cut
sub list_btrfs_filesystems
{
# The OS-specific library determines whether Btrfs quota operations exist.
return ( ) if (!defined(&btrfs_quota_status));
# Separately mounted subvolumes share quota state, so keep only the first mount
# for each underlying source while preserving the original display order.
my %seen;
my @filesystems;
foreach my $fs (&mount::list_mounted()) {
next if ($fs->[2] ne "btrfs");
(my $source = $fs->[1]) =~ s/\[[^\]]*\]$//;
next if ($seen{$source}++);
push(@filesystems, $fs);
}
return @filesystems;
}
=head2 parse_options(type, options)
Convert an options string for some filesystem into the global hash %options.
@@ -412,6 +438,20 @@ foreach $fs (split(/\s+/, $access{'filesys'})) {
return 0;
}
=head2 can_edit_btrfs_filesys(filesys)
Returns 1 if the current Webmin user can manage Btrfs quotas. Btrfs quota state
and qgroup IDs belong to the whole underlying filesystem, so a mount-path ACL
cannot safely confine access to one separately mounted subvolume.
=cut
sub can_edit_btrfs_filesys
{
my ($filesys) = @_;
return 0 if (!&can_edit_filesys($filesys));
return scalar(grep { $_ eq "*" } split(/\s+/, $access{'filesys'})) ? 1 : 0;
}
=head2 can_edit_user(user)
Returns 1 if the current Webmin user can manage quotas for some Unix user.
+86
View File
@@ -0,0 +1,86 @@
#!/usr/local/bin/perl
# Save the limits for a Btrfs qgroup
require './quota-lib.pl';
&ReadParse();
$dir = $in{'dir'};
# Require write access to an allowed mounted Btrfs filesystem and reject
# malformed qgroup IDs before parsing or applying limits.
$access{'ro'} && &error($text{'btrfs_eedit'});
&can_edit_btrfs_filesys($dir) || &error($text{'btrfs_eallow'});
defined(&btrfs_quota_status) && &is_btrfs_fs($dir) ||
&error($text{'btrfs_enotbtrfs'});
&valid_btrfs_qgroup_id($in{'qgroup'}) || &error($text{'btrfs_eqgroup'});
$in{'qgroup'} eq "0/5" && &error($text{'btrfs_etoplevel'});
&error_setup($text{'btrfs_esave'});
# parse_limit(name)
# Parse one optional byte limit from quota_input and validate its unit factor.
sub parse_limit
{
my ($name) = @_;
# A selected default means that this limit should be removed.
return undef if ($in{$name."_def"});
# Accept only positive decimal values and units offered by ui_bytesbox.
$in{$name} =~ /^\d+(?:\.\d+)?$/ && $in{$name} > 0 ||
&error($text{'btrfs_elimit'});
local %units = map { $_, 1 } ( 1, 1024, 1024**2, 1024**3,
1024**4, 1024**5 );
$units{$in{$name."_units"}} || &error($text{'btrfs_elimit'});
return int($in{$name} * $in{$name."_units"});
}
# Parse both limits completely before making either filesystem change.
$max_referenced = &parse_limit("max_referenced");
$max_exclusive = &parse_limit("max_exclusive");
# Apply the existing ACL ceiling, converting its KiB value to bytes.
if ($access{'maxblocks'}) {
$maxbytes = $access{'maxblocks'} * 1024;
defined($max_referenced) && $max_referenced <= $maxbytes &&
defined($max_exclusive) && $max_exclusive <= $maxbytes ||
&error(&text('btrfs_emax', &nice_size($maxbytes)));
}
# Refresh the selected qgroup so unchanged limits are not re-applied. This
# lookup only needs the stored limit values, so no filesystem sync is needed.
$qgroups = &list_btrfs_qgroups($dir, 0, \$listerr);
&error($listerr) if (!$qgroups);
($qgroup) = grep { $_->{'id'} eq $in{'qgroup'} } @$qgroups;
$qgroup || &error($text{'btrfs_eqgroup'});
# same_limit(first, second)
# Returns true when two optional byte limits are identical.
sub same_limit
{
my ($first, $second) = @_;
return !defined($first) && !defined($second) ||
defined($first) && defined($second) && $first == $second;
}
# Compare the submitted referenced and exclusive limits with their current
# values before running either Btrfs command.
$same_referenced = &same_limit(
$max_referenced, $qgroup->{'max_referenced'});
$same_exclusive = &same_limit(
$max_exclusive, $qgroup->{'max_exclusive'});
# Apply only changed limits, keeping an unrelated existing over-limit setting
# from causing Btrfs to reject an otherwise valid update.
if (!$same_referenced) {
$err = &set_btrfs_qgroup_limit(
$dir, $in{'qgroup'}, $max_referenced, 0);
&error($err) if ($err);
}
if (!$same_exclusive) {
$err = &set_btrfs_qgroup_limit(
$dir, $in{'qgroup'}, $max_exclusive, 1);
&error($err) if ($err);
}
# Log the completed update and return to the qgroup list.
&webmin_log("save", "btrfs", $in{'qgroup'}, \%in);
&redirect("list_btrfs.cgi?dir=".&urlize($dir));
+72
View File
@@ -5,6 +5,8 @@ no warnings 'once';
use Test::More;
use Cwd qw(abs_path);
use File::Basename qw(dirname);
use File::Path qw(make_path);
use File::Temp qw(tempdir);
my $root = abs_path(dirname(__FILE__)."/../..") or die "rootdir: $!";
my @commands;
@@ -71,6 +73,9 @@ return @{$main::mounted[0]};
do "$root/quota/linux-lib.pl" or die "linux-lib.pl: $@ $!";
# Device-less tmpfs quota options must not create unusable filesystem rows.
is(main::quota_can([ "/tmp", "tmpfs", "tmpfs", "rw,usrquota" ], undef),
0, "tmpfs quota mount options are ignored");
ok(main::is_btrfs_fs("/srv/btrfs"),
"Btrfs mount point is detected");
ok(main::is_btrfs_fs("/srv/btrfs/domain1"),
@@ -132,6 +137,27 @@ is(scalar(@commands), 1, "successful status does not run fallback");
$status = main::btrfs_quota_status("/srv/btrfs");
ok($status->{'enabled'}, "legacy qgroup fallback detects enabled quotas");
my $sysfs = tempdir(CLEANUP => 1);
my $fsuuid = "12345678-1234-1234-1234-123456789abc";
make_path("$sysfs/$fsuuid/qgroups");
foreach my $pair ([ 'enabled', 1 ], [ 'mode', 'squota' ],
[ 'inconsistent', 1 ]) {
open(my $fh, '>', "$sysfs/$fsuuid/qgroups/$pair->[0]") or die $!;
print {$fh} "$pair->[1]\n";
close($fh);
}
local $main::btrfs_sysfs_root = $sysfs;
@responses = (
{ 'out' => "ERROR: unknown token 'status'\n", 'status' => 1 },
{ 'out' => "qgroupid rfer excl\n0/5 16384 16384\n", 'status' => 0 },
{ 'out' => "Label: none uuid: $fsuuid\n", 'status' => 0 },
);
$status = main::btrfs_quota_status("/srv/btrfs");
is($status->{'mode'}, 'squota',
"legacy fallback reads simple-quota mode from sysfs");
ok($status->{'inconsistent'},
"legacy fallback reads inconsistent accounting from sysfs");
@responses = (
{ 'out' => "ERROR: unknown token 'status'\n", 'status' => 1 },
{ 'out' => "ERROR: quota root does not exist\n", 'status' => 1 },
@@ -173,6 +199,27 @@ ok(!defined($list_error), "successful qgroup list clears the error");
like($commands[0], qr/qgroup show .*\\-\\-sync .*srv.*btrfs/,
"synchronized qgroup listing requests --sync");
my $legacy_qgroup_text = <<'EOF';
Qgroupid Referenced Exclusive Max_referenced Max_exclusive Parent Child
0/256 16384 16384 67108864 none 1/100 -
0/257 0 0 none 33554432 1/100 -
1/100 16384 16384 100663296 none - 0/256,0/257
EOF
@commands = ( );
@responses = (
{ 'out' => $legacy_qgroup_text, 'status' => 0 },
{ 'out' => "ID 256 gen 10 top level 5 path domain1\n".
"ID 257 gen 11 top level 5 path domain path two\n",
'status' => 0 },
);
$qgroups = main::list_btrfs_qgroups("/srv/btrfs", 0, \$list_error);
is($qgroups->[0]->{'path'}, "domain1",
"legacy qgroup rows gain paths from the subvolume list");
is($qgroups->[1]->{'path'}, "domain path two",
"legacy subvolume paths containing spaces and path are preserved");
like($commands[1], qr/subvolume list .*srv.*btrfs/,
"legacy qgroup output triggers one compatibility lookup");
@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");
@@ -240,6 +287,31 @@ is(main::set_btrfs_qgroup_limit("/srv/btrfs", "1/100", "1M"),
is(main::assign_btrfs_qgroup("/srv/btrfs", "bad", "1/100"),
"Invalid child Btrfs qgroup ID", "invalid child assignment is rejected");
# An existing over-limit qgroup must not prevent an administrator from raising
# or removing its limit. The kernel override is restored after the retry.
my $override_root = tempdir(CLEANUP => 1);
my $override_uuid = "abcdef01-2345-6789-abcd-ef0123456789";
make_path("$override_root/$override_uuid");
open(my $override_fh, '>',
"$override_root/$override_uuid/quota_override") or die $!;
print {$override_fh} "0\n";
close($override_fh);
local $main::btrfs_sysfs_root = $override_root;
@commands = ( );
@responses = (
{ 'out' => "ERROR: unable to limit requested quota group: ".
"Disk quota exceeded\n", 'status' => 1 },
{ 'out' => "Label: none uuid: $override_uuid\n", 'status' => 0 },
{ 'out' => "", 'status' => 0 },
);
is(main::set_btrfs_qgroup_limit("/srv/btrfs", "1/100", 2097152),
undef, "over-limit qgroup updates retry with the administrative override");
open($override_fh, '<', "$override_root/$override_uuid/quota_override")
or die $!;
is(<$override_fh>, "0\n", "quota enforcement is restored after the retry");
close($override_fh);
is(scalar(@commands), 3, "one status lookup and one limit retry are run");
@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");