diff --git a/pkg/storaged/btrfs/subvolume.jsx b/pkg/storaged/btrfs/subvolume.jsx index 4730779bbd07..b7866597b045 100644 --- a/pkg/storaged/btrfs/subvolume.jsx +++ b/pkg/storaged/btrfs/subvolume.jsx @@ -25,12 +25,15 @@ import { DescriptionList } from "@patternfly/react-core/dist/esm/components/Desc import { StorageCard, StorageDescription, new_card, new_page } from "../pages.jsx"; import { StorageUsageBar } from "../storage-controls.jsx"; -import { get_fstab_config_with_client } from "../utils.js"; -import { btrfs_usage } from "./utils.jsx"; -import { mounting_dialog } from "../filesystem/mounting-dialog.jsx"; +import { encode_filename, get_fstab_config_with_client, reload_systemd } from "../utils.js"; +import { btrfs_usage, validate_subvolume_name } from "./utils.jsx"; +import { at_boot_input, mounting_dialog, mount_options } from "../filesystem/mounting-dialog.jsx"; +import { + dialog_open, TextInput, +} from "../dialog.jsx"; import { check_mismounted_fsys, MismountAlert } from "../filesystem/mismounting.jsx"; -import { is_mounted, mount_point_text, MountPoint } from "../filesystem/utils.jsx"; -import client from "../client.js"; +import { is_mounted, is_valid_mount_point, mount_point_text, MountPoint } from "../filesystem/utils.jsx"; +import client, { btrfs_poll } from "../client.js"; const _ = cockpit.gettext; @@ -44,6 +47,107 @@ function subvolume_mount(volume, subvol, forced_options) { mounting_dialog(client, block, "mount", forced_options, subvol); } +function get_mount_point_in_parent(volume, subvol) { + const block = client.blocks[volume.path]; + const subvols = client.uuids_btrfs_subvols[volume.data.uuid]; + if (!subvols) + return null; + + for (const p of subvols) { + if ((p.pathname == "/" || (subvol.pathname.substring(0, p.pathname.length) == p.pathname && + subvol.pathname[p.pathname.length] == "/")) && + is_mounted(client, block, p)) { + const [, pmp] = get_fstab_config_with_client(client, block, false, p); + if (p.pathname == "/") + return pmp + "/" + subvol.pathname; + else + return pmp + subvol.pathname.substring(p.pathname.length); + } + } + return null; +} + +function set_mount_options(subvol, block, vals) { + const mount_options = []; + + if (vals.mount_options.ro) + mount_options.push("ro"); + if (vals.at_boot == "never") + mount_options.push("x-cockpit-never-auto"); + if (vals.at_boot == "nofail") + mount_options.push("nofail"); + if (vals.at_boot == "netdev") + mount_options.push("_netdev"); + if (!vals.mount_options.auto || vals.mount_options.never_auto) + mount_options.push("noauto"); + + const name = (subvol.pathname == "/" ? vals.name : subvol.pathname + "/" + vals.name); + mount_options.push("subvol=" + name); + if (vals.mount_options.extra) + mount_options.push(vals.mount_options.extra); + + let mount_point = vals.mount_point; + if (mount_point[0] != "/") + mount_point = "/" + mount_point; + + const config = + ["fstab", + { + dir: { t: 'ay', v: encode_filename(mount_point) }, + type: { t: 'ay', v: encode_filename("btrfs") }, + opts: { t: 'ay', v: encode_filename(mount_options.join(",") || "defaults") }, + freq: { t: 'i', v: 0 }, + passno: { t: 'i', v: 0 }, + } + ]; + + return block.AddConfigurationItem(config, {}) + .then(reload_systemd) + .then(() => { + if (vals.mount_options.auto) { + return client.mount_at(block, mount_point); + } else + return Promise.resolve(); + }); +} + +function subvolume_create(volume, subvol, forced_options, parent_dir) { + const block = client.blocks[volume.path]; + dialog_open({ + Title: _("Create subvolume"), + Fields: [ + TextInput("name", _("Name"), + { + validate: name => validate_subvolume_name(name) + }), + TextInput("mount_point", _("Mount Point"), + { + validate: val => { + // Mount points are optional for subvolumes + if (val !== "") + return is_valid_mount_point(client, block, client.add_mount_point_prefix(val), false, true, subvol); + } + }), + mount_options(false, false), + at_boot_input("local"), + ], + Action: { + Title: _("Create"), + action: function (vals) { + // HACK: cannot use block_btrfs.CreateSubvolume as it always creates a subvolume relative to MountPoints[0] which + // makes it impossible to handle a situation where we have multiple subvolumes mounted. + // https://github.com/storaged-project/udisks/issues/1242 + cockpit.spawn(["btrfs", "subvolume", "create", `${parent_dir}/${vals.name}`], { superuser: "require", err: "message" }).then(() => { + btrfs_poll(); + if (vals.mount_point !== "") { + return set_mount_options(subvol, block, vals); + } + }); + } + } + }); +} + export function make_btrfs_subvolume_page(parent, volume, subvol) { const actions = []; @@ -57,6 +161,7 @@ export function make_btrfs_subvolume_page(parent, volume, subvol) { if (mp_text == null) return null; const forced_options = [`subvol=${subvol.pathname}`]; + const mount_point_in_parent = get_mount_point_in_parent(volume, subvol); if (mounted) { actions.push({ @@ -70,6 +175,14 @@ export function make_btrfs_subvolume_page(parent, volume, subvol) { }); } + if (mounted || mount_point_in_parent) { + const parent_dir = mounted ? mount_point : mount_point_in_parent; + actions.push({ + title: _("Create subvolume"), + action: () => subvolume_create(volume, subvol, forced_options, parent_dir), + }); + } + const card = new_card({ title: _("btrfs subvolume"), next: null, diff --git a/pkg/storaged/btrfs/utils.jsx b/pkg/storaged/btrfs/utils.jsx index ec96836e6a76..d5b2405911cd 100644 --- a/pkg/storaged/btrfs/utils.jsx +++ b/pkg/storaged/btrfs/utils.jsx @@ -16,8 +16,12 @@ * You should have received a copy of the GNU Lesser General Public License * along with Cockpit; If not, see . */ +import cockpit from "cockpit"; + import { decode_filename } from "../utils.js"; +const _ = cockpit.gettext; + /* * Calculate the usage based on the data from `btrfs filesystem show` which has * been made available to client.uuids_btrfs_usage. The size/usage is provided @@ -75,3 +79,12 @@ export function parse_subvol_from_options(options) { else return null; } + +export function validate_subvolume_name(name) { + if (name === "") + return _("Name cannot be empty."); + if (name.length > 255) + return _("Name cannot be longer than 255 characters."); + if (name.includes('/')) + return cockpit.format(_("Name cannot contain the character '/'")); +} diff --git a/test/verify/check-storage-btrfs b/test/verify/check-storage-btrfs index 84563cd3ddc5..405a4a383841 100755 --- a/test/verify/check-storage-btrfs +++ b/test/verify/check-storage-btrfs @@ -122,6 +122,59 @@ class TestStorageBtrfs(storagelib.StorageCase): self.dialog_cancel() self.dialog_wait_close() + def testCreateSubvolume(self): + m = self.machine + b = self.browser + + self.login_and_go("/storage") + + disk1 = self.add_ram_disk(size=140) + label = "test_subvol" + mount_point = "/run/butter" + subvol = "cake" + + m.execute(f"mkfs.btrfs -L {label} {disk1}") + self.login_and_go("/storage") + + # creation of btrfs partition can take a while on TF. + with b.wait_timeout(30): + b.wait_visible(self.card_row("Storage", name=label)) + + root_sel = self.card_row("Storage", name=label) + " + tr" + b.wait_not_present(f"{root_sel}:contains('Create subvolume')") + self.click_dropdown(self.card_row("Storage", name=label) + " + tr", "Mount") + self.dialog({"mount_point": mount_point}) + self.addCleanup(self.machine.execute, f"umount {mount_point} || true") + b.wait_in_text(self.card_row("Storage", location=mount_point), "btrfs subvolume") + self.click_dropdown(self.card_row("Storage", location=mount_point), + "Create subvolume") + # Without mount point + self.dialog_wait_open() + self.dialog({"name": subvol}) + b.wait_visible(self.card_row("Storage", name=subvol)) + + subvol_mount = "quiche" + subvol_mount_point = "/run/oven" + self.click_dropdown(self.card_row("Storage", location=mount_point), "Create subvolume") + + # With mount point + self.dialog_wait_open() + self.dialog({"name": subvol_mount, "mount_point": subvol_mount_point}) + b.wait_in_text(self.card_row("Storage", location=f"{subvol_mount_point} (not mounted)"), "btrfs subvolume") + + self.addCleanup(self.machine.execute, f"umount {subvol_mount_point} || true") + self.click_dropdown(self.card_row("Storage", location=f"{subvol_mount_point} (not mounted)"), "Mount") + self.confirm() + + b.wait_in_text(self.card_row("Storage", location=subvol_mount_point), "btrfs subvolume") + + # Finding the correct subvolume parent from a non-mounted subvolume + m.execute(f"btrfs subvolume create {subvol_mount_point}/pizza") + self.click_dropdown(self.card_row("Storage", name=f"{subvol_mount}/pizza"), "Create subvolume") + self.dialog_wait_open() + self.dialog({"name": "pineapple"}) + b.wait_in_text(self.card_row("Storage", name=f"{subvol_mount}/pizza/pineapple"), "btrfs subvolume") + def testMultiDevice(self): m = self.machine b = self.browser