diff --git a/pkg/storaged/block/create-pages.jsx b/pkg/storaged/block/create-pages.jsx
index 21257c0a0f5..aea381d798b 100644
--- a/pkg/storaged/block/create-pages.jsx
+++ b/pkg/storaged/block/create-pages.jsx
@@ -33,6 +33,7 @@ import { make_mdraid_disk_card } from "../mdraid/mdraid-disk.jsx";
import { make_stratis_blockdev_card } from "../stratis/blockdev.jsx";
import { make_swap_card } from "../swap/swap.jsx";
import { make_encryption_card } from "../crypto/encryption.jsx";
+import { make_btrfs_device_card } from "../btrfs/device.jsx";
import { new_page } from "../pages.jsx";
@@ -52,6 +53,9 @@ export function make_block_page(parent, block, card) {
(block_stratis_blockdev && client.stratis_pools[block_stratis_blockdev.Pool]) ||
block_stratis_stopped_pool);
+ const is_btrfs = (fstab_config.length > 0 &&
+ (fstab_config[2].indexOf("subvol=") >= 0 || fstab_config[2].indexOf("subvolid=") >= 0));
+
if (client.blocks_ptable[block.path]) {
make_partition_table_page(parent, block, card);
return;
@@ -76,7 +80,7 @@ export function make_block_page(parent, block, card) {
// can not happen unless there is a bug in the code above.
console.error("Assertion failure: is_crypto == false");
}
- if (fstab_config.length > 0) {
+ if (fstab_config.length > 0 && !is_btrfs) {
card = make_filesystem_card(card, block, null, fstab_config);
} else {
card = make_locked_encrypted_data_card(card, block);
@@ -85,8 +89,11 @@ export function make_block_page(parent, block, card) {
const is_filesystem = content_block.IdUsage == 'filesystem';
const block_pvol = client.blocks_pvol[content_block.path];
const block_swap = client.blocks_swap[content_block.path];
+ const block_btrfs_blockdev = client.blocks_fsys_btrfs[content_block.path];
- if (is_filesystem) {
+ if (block_btrfs_blockdev) {
+ card = make_btrfs_device_card(card, block, content_block, block_btrfs_blockdev);
+ } else if (is_filesystem) {
card = make_filesystem_card(card, block, content_block, fstab_config);
} else if ((content_block.IdUsage == "raid" && content_block.IdType == "LVM2_member") ||
(block_pvol && client.vgroups[block_pvol.VolumeGroup])) {
diff --git a/pkg/storaged/block/format-dialog.jsx b/pkg/storaged/block/format-dialog.jsx
index bdff3bc1875..b30edfbe3dc 100644
--- a/pkg/storaged/block/format-dialog.jsx
+++ b/pkg/storaged/block/format-dialog.jsx
@@ -258,7 +258,8 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended,
if (old_dir === false)
return Promise.reject(_("This device can not be used for the installation target."));
- const split_options = parse_options(old_opts);
+ // Strip out btrfs subvolume mount options
+ const split_options = parse_options(old_opts).filter(opt => !(opt.startsWith('subvol=') || opt.startsWith('subvolid=')));
extract_option(split_options, "noauto");
const opt_ro = extract_option(split_options, "ro");
const opt_never_auto = extract_option(split_options, "x-cockpit-never-auto");
diff --git a/pkg/storaged/btrfs/device.jsx b/pkg/storaged/btrfs/device.jsx
new file mode 100644
index 00000000000..7e6dbb33f13
--- /dev/null
+++ b/pkg/storaged/btrfs/device.jsx
@@ -0,0 +1,90 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import { StorageCard, StorageDescription, new_card, register_crossref } from "../pages.jsx";
+import { StorageUsageBar } from "../storage-controls.jsx";
+import { std_lock_action } from "../crypto/actions.jsx";
+import { format_dialog } from "../block/format-dialog.jsx";
+import { btrfs_device_usage } from "./utils.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_btrfs_device_card(next, backing_block, content_block, block_btrfs) {
+ const label = block_btrfs && block_btrfs.data.label;
+ const uuid = block_btrfs && block_btrfs.data.uuid;
+ const use = btrfs_device_usage(client, uuid, block_btrfs.path);
+
+ const btrfs_card = new_card({
+ title: _("btrfs device"),
+ location: label || uuid,
+ next,
+ component: BtrfsDeviceCard,
+ props: { backing_block, content_block },
+ actions: [
+ std_lock_action(backing_block, content_block),
+ { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true },
+ ],
+ });
+
+ register_crossref({
+ key: uuid,
+ card: btrfs_card,
+ size: ,
+ });
+
+ return btrfs_card;
+}
+
+export const BtrfsDeviceCard = ({ card, backing_block, content_block }) => {
+ const block_btrfs = client.blocks_fsys_btrfs[content_block.path];
+ const uuid = block_btrfs && block_btrfs.data.uuid;
+ const label = block_btrfs && block_btrfs.data.label;
+ const use = btrfs_device_usage(client, uuid, block_btrfs.path);
+
+ return (
+
+
+
+
+ {uuid
+ ?
+ : "-"
+ }
+
+
+ { block_btrfs &&
+
+
+
+ }
+
+
+ );
+};
diff --git a/pkg/storaged/btrfs/subvolume.jsx b/pkg/storaged/btrfs/subvolume.jsx
new file mode 100644
index 00000000000..4730779bbd0
--- /dev/null
+++ b/pkg/storaged/btrfs/subvolume.jsx
@@ -0,0 +1,106 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+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 { check_mismounted_fsys, MismountAlert } from "../filesystem/mismounting.jsx";
+import { is_mounted, mount_point_text, MountPoint } from "../filesystem/utils.jsx";
+import client from "../client.js";
+
+const _ = cockpit.gettext;
+
+function subvolume_unmount(volume, subvol, forced_options) {
+ const block = client.blocks[volume.path];
+ mounting_dialog(client, block, "unmount", forced_options, subvol);
+}
+
+function subvolume_mount(volume, subvol, forced_options) {
+ const block = client.blocks[volume.path];
+ mounting_dialog(client, block, "mount", forced_options, subvol);
+}
+
+export function make_btrfs_subvolume_page(parent, volume, subvol) {
+ const actions = [];
+
+ const use = btrfs_usage(client, volume);
+ const block = client.blocks[volume.path];
+ const fstab_config = get_fstab_config_with_client(client, block, false, subvol);
+ const [, mount_point] = fstab_config;
+ const mismount_warning = check_mismounted_fsys(block, block, fstab_config, subvol);
+ const mounted = is_mounted(client, block, subvol);
+ const mp_text = mount_point_text(mount_point, mounted);
+ if (mp_text == null)
+ return null;
+ const forced_options = [`subvol=${subvol.pathname}`];
+
+ if (mounted) {
+ actions.push({
+ title: _("Unmount"),
+ action: () => subvolume_unmount(volume, subvol, forced_options),
+ });
+ } else {
+ actions.push({
+ title: _("Mount"),
+ action: () => subvolume_mount(volume, subvol, forced_options),
+ });
+ }
+
+ const card = new_card({
+ title: _("btrfs subvolume"),
+ next: null,
+ page_location: ["btrfs", volume.data.uuid, subvol.pathname],
+ page_name: subvol.pathname,
+ page_size: is_mounted && ,
+ location: mp_text,
+ component: BtrfsSubvolumeCard,
+ has_warning: !!mismount_warning,
+ props: { subvol, mount_point, mismount_warning, block, fstab_config, forced_options },
+ actions,
+ });
+ new_page(parent, card);
+}
+
+const BtrfsSubvolumeCard = ({ card, subvol, mismount_warning, block, fstab_config, forced_options }) => {
+ return (
+ }>
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/btrfs/utils.jsx b/pkg/storaged/btrfs/utils.jsx
new file mode 100644
index 00000000000..ec96836e6a7
--- /dev/null
+++ b/pkg/storaged/btrfs/utils.jsx
@@ -0,0 +1,77 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+import { decode_filename } from "../utils.js";
+
+/*
+ * 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
+ * per block device.
+ */
+export function btrfs_device_usage(client, uuid, path) {
+ const block = client.blocks[path];
+ const device = block && block.Device;
+ const uuid_usage = client.uuids_btrfs_usage[uuid];
+ if (uuid_usage && device) {
+ const usage = uuid_usage[decode_filename(device)];
+ if (usage) {
+ return [usage, block.Size];
+ }
+ }
+ return [0, block.Size];
+}
+
+/**
+ * Calculate the overal btrfs "volume" usage. UDisks only knows the usage per block.
+ */
+export function btrfs_usage(client, volume) {
+ const block_fsys = client.blocks_fsys[volume.path];
+ const mount_point = block_fsys && block_fsys.MountPoints[0];
+ let use = mount_point && client.fsys_sizes.data[decode_filename(mount_point)];
+ if (!use)
+ use = [volume.data.used, client.uuids_btrfs_blocks[volume.data.uuid].reduce((sum, b) => sum + b.Size, 0)];
+ return use;
+}
+
+/**
+ * Is the btrfs volume mounted anywhere
+ */
+export function btrfs_is_volume_mounted(client, block_devices) {
+ for (const block_device of block_devices) {
+ const block_fs = client.blocks_fsys[block_device.path];
+ if (block_fs && block_fs.MountPoints.length > 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+export function parse_subvol_from_options(options) {
+ const subvol = { };
+ const subvolid_match = options.match(/subvolid=(?\d+)/);
+ const subvol_match = options.match(/subvol=(?[\w\\/]+)/);
+ if (subvolid_match)
+ subvol.id = subvolid_match.groups.subvolid;
+ if (subvol_match)
+ subvol.pathname = subvol_match.groups.subvol;
+
+ if (subvolid_match || subvol_match)
+ return subvol;
+ else
+ return null;
+}
diff --git a/pkg/storaged/btrfs/volume.jsx b/pkg/storaged/btrfs/volume.jsx
new file mode 100644
index 00000000000..f846338c16f
--- /dev/null
+++ b/pkg/storaged/btrfs/volume.jsx
@@ -0,0 +1,202 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hopeg that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { CardHeader, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+import { VolumeIcon } from "../icons/gnome-icons.jsx";
+
+import {
+ new_card, new_page, PAGE_CATEGORY_VIRTUAL,
+ get_crossrefs, ChildrenTable, PageTable, StorageCard, StorageDescription
+} from "../pages.jsx";
+import { StorageUsageBar, StorageLink } from "../storage-controls.jsx";
+import { fmt_size_long, validate_fsys_label, decode_filename, should_ignore } from "../utils.js";
+import { btrfs_usage, btrfs_is_volume_mounted, parse_subvol_from_options } from "./utils.jsx";
+import { dialog_open, TextInput } from "../dialog.jsx";
+import { make_btrfs_subvolume_page } from "./subvolume.jsx";
+
+const _ = cockpit.gettext;
+
+/*
+ * Udisks is a disk/block library so it manages that, btrfs turns this a bit
+ * around and has one "volume" which can have multiple blocks by a unique uuid.
+ *
+ * Cockpit shows Btrfs as following:
+ *
+ * -> btrfs subvolume
+ * -> btrfs volume
+ * -> btrfs device
+ * -> block device
+ */
+export function make_btrfs_volume_page(parent, uuid) {
+ const block_devices = client.uuids_btrfs_blocks[uuid];
+ const block_btrfs = client.blocks_fsys_btrfs[block_devices[0].path];
+ const volume = client.uuids_btrfs_volume[uuid];
+ const use = btrfs_usage(client, volume);
+
+ if (block_devices.some(blk => should_ignore(client, blk.path)))
+ return;
+
+ const name = block_btrfs.data.label || uuid;
+ const btrfs_volume_card = new_card({
+ title: _("btrfs volume"),
+ next: null,
+ page_location: ["btrfs-volume", uuid],
+ page_name: name,
+ page_icon: VolumeIcon,
+ page_category: PAGE_CATEGORY_VIRTUAL,
+ page_size: use[1],
+ component: BtrfsVolumeCard,
+ props: { block_devices, uuid, use },
+ });
+
+ const subvolumes_card = new_card({
+ title: _("btrfs subvolumes"),
+ next: btrfs_volume_card,
+ component: BtrfsSubVolumesCard,
+ props: { volume },
+ });
+
+ const subvolumes_page = new_page(parent, subvolumes_card);
+ make_btrfs_subvolume_pages(subvolumes_page, volume);
+}
+
+const BtrfsVolumeCard = ({ card, block_devices, uuid, use }) => {
+ const block_btrfs = client.blocks_fsys_btrfs[block_devices[0].path];
+ const label = block_btrfs.data.label || "-";
+
+ // Changing the label is only supported when the device is not mounted
+ // otherwise we will get btrfs filesystem error ERROR: device /dev/vda5 is
+ // mounted, use mount point. This is a libblockdev/udisks limitation as it
+ // only passes the device and not the mountpoint when the device is mounted.
+ // https://github.com/storaged-project/libblockdev/issues/966
+ const is_mounted = btrfs_is_volume_mounted(client, block_devices);
+
+ function rename_dialog() {
+ dialog_open({
+ Title: _("Change label"),
+ Fields: [
+ TextInput("name", _("Name"),
+ {
+ validate: name => validate_fsys_label(name, "btrfs"),
+ value: label
+ })
+ ],
+ Action: {
+ Title: _("Save"),
+ action: function (vals) {
+ return block_btrfs.SetLabel(vals.name, {});
+ }
+ }
+ });
+ }
+
+ return (
+
+
+
+
+ {_("edit")}
+ }
+ />
+
+
+
+
+
+
+
+ {_("btrfs devices")}
+
+
+
+
+ );
+};
+
+const BtrfsSubVolumesCard = ({ card, volume }) => {
+ return (
+
+
+
+
+
+ );
+};
+
+function make_btrfs_subvolume_pages(parent, volume) {
+ const subvols = client.uuids_btrfs_subvols[volume.data.uuid];
+ if (subvols) {
+ for (const subvol of subvols) {
+ make_btrfs_subvolume_page(parent, volume, subvol);
+ }
+ } else {
+ const block = client.blocks[volume.path];
+ /*
+ * Try to show subvolumes based on fstab entries, this is a bit tricky
+ * as mounts where subvolid cannot be shown userfriendly.
+ */
+ let has_root = false;
+ for (const config of block.Configuration) {
+ if (config[0] == "fstab") {
+ const fstab_subvol = {};
+ let opts = config[1].opts;
+ if (!opts)
+ continue;
+
+ opts = decode_filename(opts.v).split(",");
+ for (const opt of opts) {
+ const fstab_subvol = parse_subvol_from_options(opt);
+
+ if (!fstab_subvol)
+ continue;
+
+ if (fstab_subvol.id && fstab_subvol.pathname) {
+ break;
+ }
+ }
+
+ if (fstab_subvol && fstab_subvol.pathname === "/") {
+ has_root = true;
+ }
+
+ if (fstab_subvol.pathname) {
+ make_btrfs_subvolume_page(parent, volume, fstab_subvol);
+ }
+ }
+ }
+
+ if (!has_root) {
+ // Always show the root subvolume even when the volume is not mounted.
+ make_btrfs_subvolume_page(parent, volume, { pathname: "/", id: 5 });
+ }
+ }
+}
diff --git a/pkg/storaged/client.js b/pkg/storaged/client.js
index ae12d008033..c937703807d 100644
--- a/pkg/storaged/client.js
+++ b/pkg/storaged/client.js
@@ -214,59 +214,204 @@ function is_multipath_master(block) {
return false;
}
-export function btrfs_poll() {
+export async function btrfs_poll() {
+ const usage_regex = /used\s+(?\d+)\s+path\s+(?[\w/]+)/;
if (!client.uuids_btrfs_subvols)
client.uuids_btrfs_subvols = { };
+ if (!client.uuids_btrfs_usage)
+ client.uuids_btrfs_usage = { };
+ if (!client.uuids_btrfs_default_subvol)
+ client.uuids_btrfs_default_subvol = { };
if (!client.uuids_btrfs_volume)
return;
+ if (!client.superuser.allowed) {
+ return;
+ }
+
const uuids_subvols = { };
- return Promise.all(Object.keys(client.uuids_btrfs_volume).map(uuid => {
- const block = client.uuids_btrfs_blocks[uuid][0];
- const block_fsys = client.blocks_fsys[block.path];
- const mp = block_fsys.MountPoints[0];
+ const uuids_usage = { };
+ const btrfs_default_subvol = { };
+ for (const uuid of Object.keys(client.uuids_btrfs_volume)) {
+ const blocks = client.uuids_btrfs_blocks[uuid];
+ if (!blocks)
+ continue;
+
+ // In multi device setups MountPoints can be on either of the block devices, so try them all.
+ const MountPoints = blocks.map(block => {
+ return client.blocks_fsys[block.path];
+ }).map(block_fsys => block_fsys.MountPoints).reduce((accum, current) => accum.concat(current));
+ const mp = MountPoints[0];
if (mp) {
- // HACK: UDisks GetSubvolumes method uses `subvolume list -p` which
- // does not show the full subvolume path which we want to show in the UI
- //
- // $ btrfs subvolume list -p /run/butter
- // ID 256 gen 7 parent 5 top level 5 path one
- // ID 257 gen 7 parent 256 top level 256 path two
- // ID 258 gen 7 parent 257 top level 257 path two/three/four
- //
- // $ btrfs subvolume list -ap /run/butter
- // ID 256 gen 7 parent 5 top level 5 path /one
- // ID 257 gen 7 parent 256 top level 256 path one/two
- // ID 258 gen 7 parent 257 top level 257 path /one/two/three/four
- return cockpit.spawn(["btrfs", "subvolume", "list", "-ap", utils.decode_filename(mp)],
- { superuser: true, err: "message" })
- .then(output => {
- const subvols = [{ pathname: "/", id: 5 }];
- for (const line of output.split("\n")) {
- const m = line.match(/ID (\d+).*parent (\d+).*path (\/)?(.*)/);
- if (m)
- subvols.push({ pathname: m[4], id: Number(m[1]) });
- }
- uuids_subvols[uuid] = subvols;
- });
+ let output;
+ const mount_point = utils.decode_filename(mp);
+ try {
+ // HACK: UDisks GetSubvolumes method uses `subvolume list -p` which
+ // does not show the full subvolume path which we want to show in the UI
+ //
+ // $ btrfs subvolume list -p /run/butter
+ // ID 256 gen 7 parent 5 top level 5 path one
+ // ID 257 gen 7 parent 256 top level 256 path two
+ // ID 258 gen 7 parent 257 top level 257 path two/three/four
+ //
+ // $ btrfs subvolume list -ap /run/butter
+ // ID 256 gen 7 parent 5 top level 5 path /one
+ // ID 257 gen 7 parent 256 top level 256 path one/two
+ // ID 258 gen 7 parent 257 top level 257 path /one/two/three/four
+ output = await cockpit.spawn(["btrfs", "subvolume", "list", "-ap", mount_point], { superuser: true, err: "message" });
+ } catch (err) {
+ console.error(`unable to obtain subvolumes for mount point ${mount_point}`, err);
+ return;
+ }
+ const subvols = [{ pathname: "/", id: 5 }];
+ for (const line of output.split("\n")) {
+ const m = line.match(/ID (\d+).*parent (\d+).*path (\/)?(.*)/);
+ if (m)
+ subvols.push({ pathname: m[4], id: Number(m[1]) });
+ }
+ uuids_subvols[uuid] = subvols;
+
+ // HACK: Obtain the default subvolume, required for mounts in which do not specify a subvol and subvolid.
+ // In the future can be obtained via UDisks, it requires the btrfs partition to be mounted somewhere.
+ // https://github.com/storaged-project/udisks/commit/b6966b7076cd837f9d307eef64beedf01bc863ae
+ try {
+ output = await cockpit.spawn(["btrfs", "subvolume", "get-default", mount_point], { superuser: true, err: "message" });
+ const id_match = output.match(/ID (\d+).*/);
+ if (id_match)
+ btrfs_default_subvol[uuid] = Number(id_match[1]);
+ } catch (err) {
+ console.error(`unable to obtain default subvolume for mount point ${mount_point}`, err);
+ }
+
+ // HACK: UDisks should expose a better btrfs API with btrfs device information
+ // https://github.com/storaged-project/udisks/issues/1232
+ // TODO: optimise into just parsing one `btrfs filesystem show`?
+ const usages = {};
+ let usage_output;
+ try {
+ usage_output = await cockpit.spawn(["btrfs", "filesystem", "show", "--raw", uuid], { superuser: true, err: "message" });
+ } catch (err) {
+ console.error(`btrfs filesystem show ${uuid}`, err);
+ return;
+ }
+ for (const line of usage_output.split("\n")) {
+ const match = usage_regex.exec(line);
+ if (match) {
+ const { used, device } = match.groups;
+ usages[device] = used;
+ }
+ }
+ uuids_usage[uuid] = usages;
} else {
uuids_subvols[uuid] = null;
- return Promise.resolve();
+ uuids_usage[uuid] = null;
}
- }))
- .then(() => {
- if (!deep_equal(client.uuids_btrfs_subvols, uuids_subvols)) {
- debug("btrfs_pol new subvols:", uuids_subvols);
- client.uuids_btrfs_subvols = uuids_subvols;
- client.update();
+ }
+
+ if (!deep_equal(client.uuids_btrfs_subvols, uuids_subvols) || !deep_equal(client.uuids_btrfs_usage, uuids_usage) ||
+ !deep_equal(client.uuids_btrfs_default_subvol, btrfs_default_subvol)) {
+ debug("btrfs_pol new subvols:", uuids_subvols);
+ client.uuids_btrfs_subvols = uuids_subvols;
+ client.uuids_btrfs_usage = uuids_usage;
+ debug("btrfs_pol usage:", uuids_usage);
+ client.uuids_btrfs_default_subvol = btrfs_default_subvol;
+ debug("btrfs_pol default subvolumes:", btrfs_default_subvol);
+ client.update();
+ }
+}
+
+function btrfs_findmnt_poll() {
+ if (!client.btrfs_mounts)
+ client.btrfs_mounts = { };
+
+ const update_btrfs_mounts = output => {
+ const btrfs_mounts = {};
+ try {
+ // Extract the data into a { uuid: { subvolid: { subvol, target } } }
+ const mounts = JSON.parse(output);
+ if ("filesystems" in mounts) {
+ for (const fs of mounts.filesystems) {
+ const subvolid_match = fs.options.match(/subvolid=(?\d+)/);
+ const subvol_match = fs.options.match(/subvol=(?[\w\\/]+)/);
+ if (!subvolid_match && !subvol_match) {
+ console.warn("findmnt entry without subvol and subvolid", fs);
+ break;
+ }
+
+ const { subvolid } = subvolid_match.groups;
+ const { subvol } = subvol_match.groups;
+ const subvolume = {
+ pathname: subvol,
+ id: subvolid,
+ mount_points: [fs.target],
+ };
+
+ if (!(fs.uuid in btrfs_mounts)) {
+ btrfs_mounts[fs.uuid] = { };
+ }
+
+ // We need to handle multiple mounts, they are listed seperate.
+ if (subvolid in btrfs_mounts[fs.uuid]) {
+ btrfs_mounts[fs.uuid][subvolid].mount_points.push(fs.target);
+ } else {
+ btrfs_mounts[fs.uuid][subvolid] = subvolume;
+ }
+ }
+ }
+ } catch (exc) {
+ if (exc.message)
+ console.error("unable to parse findmnt JSON output", exc);
+ }
+
+ // Update client state
+ if (!deep_equal(client.btrfs_mounts, btrfs_mounts)) {
+ client.btrfs_mounts = btrfs_mounts;
+ debug("btrfs_findmnt_poll mounts:", client.btrfs_mounts);
+ client.update();
+ }
+ };
+
+ const findmnt_poll = () => {
+ return cockpit.spawn(["findmnt", "--type", "btrfs", "--mtab", "--poll"], { superuser: "try", err: "message" }).stream(() => {
+ cockpit.spawn(["findmnt", "--type", "btrfs", "--mtab", "-o", "UUID,OPTIONS,TARGET", "--json"],
+ { superuser: "try", err: "message" }).then(output => update_btrfs_mounts(output)).catch(err => {
+ // When there are no btrfs filesystems left this can fail and thus we need to manually reset the mount info.
+ client.btrfs_mounts = {};
+ client.update();
+ if (err.message) {
+ console.error("findmnt exited with an error", err);
}
});
+ }).catch(err => {
+ console.error("findmnt --poll exited with an error", err);
+ throw new Error("findmnt --poll stopped working");
+ });
+ };
+
+ // This fails when no btrfs filesystem is found with the --mtab option and exits with 1, so that is kinda useless, however without --mtab
+ // we don't get a nice flat structure. So we ignore the errors
+ cockpit.spawn(["findmnt", "--type", "btrfs", "--mtab", "-o", "UUID,OPTIONS,SOURCE,TARGET", "--json"],
+ { superuser: "try", err: "message" }).then(output => {
+ update_btrfs_mounts(output);
+ findmnt_poll();
+ }).catch(err => {
+ // only log error when there is a real issue.
+ if (client.superuser.allowed && err.message) {
+ console.error(`unable to run findmnt ${err}`);
+ }
+ findmnt_poll();
+ });
}
function btrfs_start_polling() {
+ debug("starting polling for btrfs subvolumes");
window.setInterval(btrfs_poll, 5000);
client.uuids_btrfs_subvols = { };
+ client.uuids_btrfs_usage = { };
+ client.uuids_btrfs_default_subvol = { };
+ client.btrfs_mounts = { };
btrfs_poll();
+ btrfs_findmnt_poll();
}
function update_indices() {
diff --git a/pkg/storaged/dialog.jsx b/pkg/storaged/dialog.jsx
index 926031b2841..696f41f4b6f 100644
--- a/pkg/storaged/dialog.jsx
+++ b/pkg/storaged/dialog.jsx
@@ -1112,6 +1112,7 @@ export const BlockingMessage = (usage) => {
vdo: _("backing device for VDO device"),
"stratis-pool-member": _("member of Stratis pool"),
mounted: _("Filesystem outside the target"),
+ "btrfs-device": _("device of btrfs volume"),
};
const rows = [];
diff --git a/pkg/storaged/filesystem/mismounting.jsx b/pkg/storaged/filesystem/mismounting.jsx
index 82723c8df0e..55a63c972db 100644
--- a/pkg/storaged/filesystem/mismounting.jsx
+++ b/pkg/storaged/filesystem/mismounting.jsx
@@ -24,9 +24,9 @@ import client from "../client.js";
import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import {
- decode_filename, encode_filename,
+ encode_filename,
parse_options, unparse_options, extract_option, reload_systemd,
- set_crypto_auto_option,
+ set_crypto_auto_option, get_mount_points,
} from "../utils.js";
import { StorageButton } from "../storage-controls.jsx";
@@ -35,14 +35,14 @@ import { get_cryptobacking_noauto } from "./utils.jsx";
const _ = cockpit.gettext;
-export function check_mismounted_fsys(backing_block, content_block, fstab_config) {
+export function check_mismounted_fsys(backing_block, content_block, fstab_config, subvol) {
const block_fsys = content_block && client.blocks_fsys[content_block.path];
const [, dir, opts] = fstab_config;
if (!(block_fsys || dir))
return;
- const mounted_at = block_fsys ? block_fsys.MountPoints.map(decode_filename) : [];
+ const mounted_at = get_mount_points(client, block_fsys, subvol);
const split_options = parse_options(opts);
const opt_noauto = extract_option(split_options, "noauto");
const opt_noauto_intent = extract_option(split_options, "x-cockpit-never-auto");
@@ -75,7 +75,7 @@ export function check_mismounted_fsys(backing_block, content_block, fstab_config
return { warning: "mismounted-fsys", type, other: other_mounts[0] };
}
-export const MismountAlert = ({ warning, fstab_config, forced_options, backing_block, content_block }) => {
+export const MismountAlert = ({ warning, fstab_config, forced_options, backing_block, content_block, subvol }) => {
if (!warning)
return null;
@@ -105,6 +105,9 @@ export const MismountAlert = ({ warning, fstab_config, forced_options, backing_b
opts.push("nofail");
if (opt_netdev)
opts.push("_netdev");
+ if (subvol) {
+ opts.push(`subvol=${subvol.pathname}`);
+ }
// Add the forced options, but only to new entries. We
// don't want to modify existing entries beyond what we
diff --git a/pkg/storaged/filesystem/mounting-dialog.jsx b/pkg/storaged/filesystem/mounting-dialog.jsx
index b4663b27151..d73283e1882 100644
--- a/pkg/storaged/filesystem/mounting-dialog.jsx
+++ b/pkg/storaged/filesystem/mounting-dialog.jsx
@@ -42,9 +42,9 @@ import {
const _ = cockpit.gettext;
-export function mounting_dialog(client, block, mode, forced_options) {
+export function mounting_dialog(client, block, mode, forced_options, subvol) {
const block_fsys = client.blocks_fsys[block.path];
- const [old_config, old_dir, old_opts, old_parents] = get_fstab_config(block, true);
+ const [old_config, old_dir, old_opts, old_parents] = get_fstab_config(block, true, subvol);
const options = old_config ? old_opts : initial_tab_options(client, block, true);
const old_dir_for_display = client.strip_mount_point_prefix(old_dir);
@@ -62,7 +62,7 @@ export function mounting_dialog(client, block, mode, forced_options) {
extract_option(split_options, opt);
const extra_options = unparse_options(split_options);
- const is_filesystem_mounted = is_mounted(client, block);
+ const is_filesystem_mounted = is_mounted(client, block, subvol);
function maybe_update_config(new_dir, new_opts, passphrase, passphrase_type) {
let new_config = null;
@@ -152,7 +152,7 @@ export function mounting_dialog(client, block, mode, forced_options) {
}
function maybe_lock() {
- if (mode == "unmount") {
+ if (mode == "unmount" && !subvol) {
const crypto_backing = client.blocks[block.CryptoBackingDevice];
const crypto_backing_crypto = crypto_backing && client.blocks_crypto[crypto_backing.path];
if (crypto_backing_crypto) {
@@ -207,7 +207,8 @@ export function mounting_dialog(client, block, mode, forced_options) {
block,
client.add_mount_point_prefix(val),
mode == "update" && !is_filesystem_mounted,
- true)
+ true,
+ subvol)
}),
CheckBoxes("mount_options", _("Mount options"),
{
@@ -297,7 +298,7 @@ export function mounting_dialog(client, block, mode, forced_options) {
return Promise.resolve();
}
- const usage = get_active_usage(client, block.path);
+ const usage = get_active_usage(client, block.path, null, null, false, subvol);
const dlg = dialog_open({
Title: cockpit.format(mode_title[mode], old_dir_for_display),
diff --git a/pkg/storaged/filesystem/utils.jsx b/pkg/storaged/filesystem/utils.jsx
index a2c0801f5c1..71247c9a193 100644
--- a/pkg/storaged/filesystem/utils.jsx
+++ b/pkg/storaged/filesystem/utils.jsx
@@ -28,62 +28,97 @@ import {
parse_options, extract_option,
get_fstab_config_with_client,
find_children_for_mount_point,
+ get_mount_points,
} from "../utils.js";
+import { parse_subvol_from_options } from "../btrfs/utils.jsx";
import { StorageLink } from "../storage-controls.jsx";
import { mounting_dialog } from "./mounting-dialog.jsx";
const _ = cockpit.gettext;
-export function is_mounted(client, block) {
+/* is mounted with an entry in fstab */
+export function is_mounted(client, block, subvol) {
const block_fsys = client.blocks_fsys[block.path];
- const mounted_at = block_fsys ? block_fsys.MountPoints : [];
- const config = block.Configuration.find(c => c[0] == "fstab");
- if (config && config[1].dir.v) {
- let dir = decode_filename(config[1].dir.v);
- if (dir[0] != "/")
- dir = "/" + dir;
- return mounted_at.map(decode_filename).indexOf(dir) >= 0;
+ const mounted_at = get_mount_points(client, block_fsys, subvol);
+ const [, dir] = get_fstab_config(block, false, subvol);
+ if (dir) {
+ return mounted_at.indexOf(dir) >= 0;
} else
return null;
}
-export function get_fstab_config(block, also_child_config) {
- return get_fstab_config_with_client(client, block, also_child_config);
+export function get_fstab_config(block, also_child_config, subvol) {
+ return get_fstab_config_with_client(client, block, also_child_config, subvol);
}
-function find_blocks_for_mount_point(client, mount_point, self) {
- const blocks = [];
+function nice_block_name(block) {
+ return block_name(client.blocks[block.CryptoBackingDevice] || block);
+}
+
+function find_blocks_for_mount_point(client, mount_point, self_block, self_subvol) {
+ function same_btrfs_volume(a, b) {
+ return (client.blocks_fsys_btrfs[a.path] &&
+ client.blocks_fsys_btrfs[b.path] &&
+ client.blocks_fsys_btrfs[a.path].data.uuid == client.blocks_fsys_btrfs[b.path].data.uuid);
+ }
+
+ function same_btrfs_subvol(a, b) {
+ return (a && b &&
+ ((a.pathname && a.pathname == b.pathname) ||
+ (a.id && a.id == b.id)));
+ }
+
+ function is_self(b, subvol) {
+ if (self_subvol)
+ return same_btrfs_volume(b, self_block) && same_btrfs_subvol(subvol, self_subvol);
+ else
+ return self_block && (b == self_block || client.blocks[b.CryptoBackingDevice] == self_block);
+ }
- function is_self(b) {
- return self && (b == self || client.blocks[b.CryptoBackingDevice] == self);
+ function fmt_block_and_subvol(block, subvol) {
+ if (subvol)
+ return cockpit.format(_("btrfs subvolume $0 of $1"),
+ subvol.pathname || subvol.id,
+ block.IdLabel || block.IdUUID);
+ else
+ return nice_block_name(block);
}
+ const blocks = [];
+ const seen_uuids = {};
+
for (const p in client.blocks) {
const b = client.blocks[p];
- const [, dir] = get_fstab_config(b);
- if (dir == mount_point && !is_self(b))
- blocks.push(b);
+ for (const c of b.Configuration) {
+ if (c[0] == "fstab") {
+ let dir = decode_filename(c[1].dir.v);
+ if (dir[0] != "/")
+ dir = "/" + dir;
+ const subvol = parse_subvol_from_options(decode_filename(c[1].opts.v));
+ if (dir == mount_point && !is_self(b, subvol)) {
+ if (!seen_uuids[b.IdUUID]) {
+ seen_uuids[b.IdUUID] = true;
+ blocks.push(fmt_block_and_subvol(b, subvol));
+ }
+ }
+ }
+ }
}
return blocks;
}
-function nice_block_name(block) {
- return block_name(client.blocks[block.CryptoBackingDevice] || block);
-}
-
-export function is_valid_mount_point(client, block, val, format_only, for_fstab) {
+export function is_valid_mount_point(client, block, val, format_only, for_fstab, subvol) {
if (val === "") {
if (!format_only || for_fstab)
return _("Mount point cannot be empty");
return null;
}
- const other_blocks = find_blocks_for_mount_point(client, val, block);
+ const other_blocks = find_blocks_for_mount_point(client, val, block, subvol);
if (other_blocks.length > 0)
- return cockpit.format(_("Mount point is already used for $0"),
- other_blocks.map(nice_block_name).join(", "));
+ return cockpit.format(_("Mount point is already used for $0"), other_blocks.join(", "));
if (!format_only) {
const children = find_children_for_mount_point(client, val, block);
@@ -113,8 +148,8 @@ export function get_cryptobacking_noauto(client, block) {
return crypto_options.indexOf("noauto") >= 0;
}
-export const MountPoint = ({ fstab_config, forced_options, backing_block, content_block }) => {
- const is_filesystem_mounted = content_block && is_mounted(client, content_block);
+export const MountPoint = ({ fstab_config, forced_options, backing_block, content_block, subvol }) => {
+ const is_filesystem_mounted = content_block && is_mounted(client, content_block, subvol);
const [, old_dir, old_opts] = fstab_config;
const split_options = parse_options(old_opts);
extract_option(split_options, "noauto");
@@ -180,7 +215,7 @@ export const MountPoint = ({ fstab_config, forced_options, backing_block, conten
mounting_dialog(client,
content_block || backing_block,
"update",
- forced_options)}>
+ forced_options, subvol)}>
{_("edit")}
diff --git a/pkg/storaged/overview/overview.jsx b/pkg/storaged/overview/overview.jsx
index e4d59ffb8c3..839e9b25151 100644
--- a/pkg/storaged/overview/overview.jsx
+++ b/pkg/storaged/overview/overview.jsx
@@ -47,6 +47,7 @@ import { make_stratis_stopped_pool_page } from "../stratis/stopped-pool.jsx";
import { make_nfs_page, nfs_fstab_dialog } from "../nfs/nfs.jsx";
import { make_iscsi_session_page } from "../iscsi/session.jsx";
import { make_other_page } from "../block/other.jsx";
+import { make_btrfs_volume_page } from "../btrfs/volume.jsx";
const _ = cockpit.gettext;
@@ -71,6 +72,7 @@ export function make_overview_page() {
Object.keys(client.stratis_manager.StoppedPools).map(uuid => make_stratis_stopped_pool_page(overview_page, uuid));
client.nfs.entries.forEach(e => make_nfs_page(overview_page, e));
get_other_devices(client).map(p => make_other_page(overview_page, client.blocks[p]));
+ Object.keys(client.uuids_btrfs_volume).forEach(uuid => make_btrfs_volume_page(overview_page, uuid));
}
const OverviewCard = ({ card, plot_state }) => {
diff --git a/pkg/storaged/utils.js b/pkg/storaged/utils.js
index 3df3f78b45a..cfa95d2e55b 100644
--- a/pkg/storaged/utils.js
+++ b/pkg/storaged/utils.js
@@ -790,8 +790,32 @@ export function find_children_for_mount_point(client, mount_point, self) {
return children;
}
-export function get_fstab_config_with_client(client, block, also_child_config) {
- let config = block.Configuration.find(c => c[0] == "fstab");
+export function get_fstab_config_with_client(client, block, also_child_config, subvol) {
+ function match(c) {
+ if (c[0] != "fstab")
+ return false;
+ if (subvol !== undefined) {
+ if (!c[1].opts)
+ return false;
+
+ const opts = decode_filename(c[1].opts.v).split(",");
+ if (opts.indexOf("subvolid=" + subvol.id) >= 0)
+ return true;
+ if (opts.indexOf("subvol=" + subvol.pathname) >= 0)
+ return true;
+
+ // btrfs mounted without subvol argument.
+ const btrfs_volume = client.blocks_fsys_btrfs[block.path];
+ const default_subvolid = client.uuids_btrfs_default_subvol[btrfs_volume.data.uuid];
+ if (default_subvolid === subvol.id && !opts.find(o => o.indexOf("subvol=") >= 0 || o.indexOf("subvolid=") >= 0))
+ return true;
+
+ return false;
+ }
+ return true;
+ }
+
+ let config = block.Configuration.find(match);
if (!config && also_child_config && client.blocks_crypto[block.path])
config = client.blocks_crypto[block.path]?.ChildConfiguration.find(c => c[0] == "fstab");
@@ -814,7 +838,7 @@ export function get_fstab_config_with_client(client, block, also_child_config) {
return [];
}
-export function get_active_usage(client, path, top_action, child_action, is_temporary) {
+export function get_active_usage(client, path, top_action, child_action, is_temporary, subvol) {
function get_usage(usage, path, level) {
const block = client.blocks[path];
const fsys = client.blocks_fsys[path];
@@ -824,6 +848,7 @@ export function get_active_usage(client, path, top_action, child_action, is_temp
const vdo = block && client.legacy_vdo_overlay.find_by_backing_block(block);
const stratis_blockdev = block && client.blocks_stratis_blockdev[path];
const stratis_pool = stratis_blockdev && client.stratis_pools[stratis_blockdev.Pool];
+ const btrfs_volume = client.blocks_fsys_btrfs[path];
get_children_for_teardown(client, path).map(p => get_usage(usage, p, level + 1));
@@ -862,13 +887,28 @@ export function get_active_usage(client, path, top_action, child_action, is_temp
});
}
- if (fsys && fsys.MountPoints.length > 0) {
- fsys.MountPoints.forEach(mp => {
- const mpd = decode_filename(mp);
- const children = find_children_for_mount_point(client, mpd, null);
+ // HACK: get_active_usage is used for mounting and formatting so we use the absence of the subvol argument
+ // to figure out that we want to format this device.
+ // This is separate from the if's below as we also always have to umount the filesystem.
+ if (btrfs_volume && !subvol) {
+ usage.push({
+ level,
+ usage: 'btrfs-device',
+ block,
+ btrfs_volume,
+ location: btrfs_volume.data.label || btrfs_volume.data.uuid,
+ actions: get_actions(_("remove from btrfs volume")),
+ blocking: true
+ });
+ }
+
+ const mount_points = get_mount_points(client, fsys, subvol);
+ if (mount_points.length > 0) {
+ mount_points.forEach(mp => {
+ const children = find_children_for_mount_point(client, mp, null);
for (const c in children)
enter_unmount(children[c], c, false);
- enter_unmount(block, mpd, true);
+ enter_unmount(block, mp, true);
});
} else if (mdraid) {
const active_state = mdraid.ActiveDevices.find(as => as[0] == block.path);
@@ -1050,3 +1090,31 @@ export function is_mounted_synch(block) {
export function for_each_async(arr, func) {
return arr.reduce((promise, elt) => promise.then(() => func(elt)), Promise.resolve());
}
+
+/*
+ * Get mount points for a given org.freedesktop.UDisks2.Filesystem object
+ *
+ * This generalises getting the given MountPoints of a Filesystem for btrfs and
+ * other filesystems, for btrfs we want to know if a subvolume is mounted
+ * anywhere. UDisks is currently not aware of subvolumes and it's Filesystem
+ * object gives us the MountPoints for all subvolumes while we want it per
+ * subvolume.
+ *
+ * @param {Object} block_fsys
+ * @param {Object|null} subvol
+ * @returns {Array} an array of MountPoints
+ */
+export function get_mount_points(client, block_fsys, subvol) {
+ let mounted_at = [];
+
+ if (subvol && block_fsys) {
+ const btrfs_volume = client.blocks_fsys_btrfs[block_fsys.path];
+ const volume_mounts = client.btrfs_mounts[btrfs_volume.data.uuid];
+ if (volume_mounts)
+ mounted_at = subvol.id in volume_mounts ? volume_mounts[subvol.id].mount_points : [];
+ } else {
+ mounted_at = block_fsys ? block_fsys.MountPoints.map(decode_filename) : [];
+ }
+
+ return mounted_at;
+}
diff --git a/test/common/storagelib.py b/test/common/storagelib.py
index d13a3ecd45a..0844788f1fa 100644
--- a/test/common/storagelib.py
+++ b/test/common/storagelib.py
@@ -137,6 +137,9 @@ def dialog_wait_open(self):
def dialog_wait_alert(self, text):
self.browser.wait_in_text('#dialog .pf-v5-c-alert__title', text)
+ def dialog_wait_title(self, text):
+ self.browser.wait_in_text('#dialog .pf-v5-c-modal-box__title', text)
+
def dialog_field(self, field):
return f'#dialog [data-field="{field}"]'
diff --git a/test/reference b/test/reference
index f2f10cb3aab..de57c628b9d 160000
--- a/test/reference
+++ b/test/reference
@@ -1 +1 @@
-Subproject commit f2f10cb3aab167098da38239a14d336e1550cdb3
+Subproject commit de57c628b9dd1aef3fe5857c80a286319b7422c6
diff --git a/test/verify/check-storage-basic b/test/verify/check-storage-basic
index a8ba7f36a92..9f02f93a4c7 100755
--- a/test/verify/check-storage-basic
+++ b/test/verify/check-storage-basic
@@ -29,6 +29,7 @@ class TestStorageBasic(storagelib.StorageCase):
b = self.browser
self.login_and_go("/storage", superuser=False)
+ self.allow_browser_errors("error: findmnt.*")
create_dropdown = self.dropdown_toggle(self.card_header("Storage"))
diff --git a/test/verify/check-storage-btrfs b/test/verify/check-storage-btrfs
new file mode 100755
index 00000000000..84563cd3ddc
--- /dev/null
+++ b/test/verify/check-storage-btrfs
@@ -0,0 +1,469 @@
+#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/../common/pywrap", sys.argv)
+
+# This file is part of Cockpit.
+#
+# Copyright (C) 2023 Red Hat, Inc.
+#
+# Cockpit is free software; you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation; either version 2.1 of the License, or
+# (at your option) any later version.
+#
+# Cockpit is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with Cockpit; If not, see .
+
+import os
+import os.path
+
+import storagelib
+import testlib
+
+
+@testlib.nondestructive
+@testlib.skipImage('no btrfs support', 'rhel-*', 'centos-*')
+class TestStorageBtrfs(storagelib.StorageCase):
+ def setUp(self):
+ super().setUp()
+ self.allow_browser_errors("unable to obtain subvolumes for mount point.*")
+ self.allow_browser_errors("unable to obtain default subvolume for mount point.*")
+ self.allow_browser_errors("error: unable to run findmnt.*")
+ self.allow_browser_errors("error: findmnt.*")
+
+ def testBasic(self):
+ m = self.machine
+ b = self.browser
+
+ mount_point = "/run/butter"
+ # btrfs requires a 128 MB
+ dev_1 = self.add_ram_disk(size=128)
+ uuid = "a32d61d6-9d75-4327-9bb9-3fa3627300ae"
+ m.execute(f"mkfs.btrfs -U {uuid} {dev_1}")
+
+ 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=uuid))
+ b.wait_in_text(self.card_row("Storage", name="sda"), "btrfs device")
+ self.click_card_row("Storage", name=uuid)
+
+ b.wait_text(self.card_desc("btrfs volume", "UUID"), uuid)
+ # disk(s) are part of the volume card
+ b.wait_visible(self.card_row("btrfs volume", name=dev_1))
+
+ label = "butter"
+ b.click(self.card_desc_action("btrfs volume", "Label"))
+ self.dialog({"name": label})
+ b.wait_text(self.card_desc("btrfs volume", "Label"), label)
+
+ self.click_dropdown(self.card_row("btrfs subvolumes", name="/"), "Mount")
+ self.dialog({"mount_point": mount_point})
+ b.wait_visible(self.card_row("btrfs subvolumes", location=mount_point))
+ # UDisks does not allow us to change the label of a mounted FS
+ b.wait_visible(self.card_desc_action("btrfs volume", "Label") + ":disabled")
+
+ # detect new subvolume
+ subvol = "/run/butter/cake"
+ m.execute(f"btrfs subvolume create {subvol}")
+ b.wait_visible(self.card_row("btrfs subvolumes", name=os.path.basename(subvol)))
+
+ self.click_dropdown(self.card_row("btrfs subvolumes", location=mount_point), "Unmount")
+ self.confirm()
+
+ b.wait_visible(self.card_row("btrfs subvolumes", location=f"{mount_point} (not mounted)"))
+ self.click_dropdown(self.card_row("btrfs subvolumes", name="/"), "Mount")
+ self.confirm()
+
+ b.wait_visible(self.card_row("btrfs subvolumes", location=mount_point))
+ # try to mount a subvol
+ subvol_mount_point = "/run/kitchen"
+ self.click_dropdown(self.card_row("btrfs subvolumes", name=os.path.basename(subvol)), "Mount")
+ self.dialog({"mount_point": subvol_mount_point})
+
+ b.wait_in_text(self.card_row("btrfs subvolumes", location=subvol_mount_point), "cake")
+ b.wait_visible(self.card_row("btrfs subvolumes", location=mount_point))
+
+ b.go("#/")
+ b.wait_visible(self.card("Storage"))
+
+ # btrfs device page
+ self.click_card_row("Storage", name="sda")
+ b.wait_text(self.card_desc("btrfs device", "btrfs volume"), label)
+ b.wait_in_text(self.card_desc("Solid State Drive", "Device file"), dev_1)
+
+ # test link to volume
+ b.click(self.card_button("btrfs device", label))
+ b.wait_text(self.card_desc("btrfs volume", "Label"), label)
+
+ # Format the btrfs device
+ b.go("#/")
+ b.wait_visible(self.card("Storage"))
+ self.click_dropdown(self.card_row("Storage", name="sda"), "Format")
+ self.dialog_wait_open()
+ self.dialog_wait_title(f"{dev_1} is in use")
+ self.dialog_cancel()
+ self.dialog_wait_close()
+
+ self.click_dropdown(self.card_row("Storage", location=subvol_mount_point), "Unmount")
+ self.confirm()
+
+ self.click_dropdown(self.card_row("Storage", location=mount_point), "Unmount")
+ self.confirm()
+ b.wait_visible(self.card_row("Storage", location=f"{mount_point} (not mounted)"))
+
+ # Also when not mounted
+ self.click_dropdown(self.card_row("Storage", name="sda"), "Format")
+ self.dialog_wait_open()
+ self.dialog_wait_title(f"{dev_1} is in use")
+ self.dialog_cancel()
+ self.dialog_wait_close()
+
+ def testMultiDevice(self):
+ m = self.machine
+ b = self.browser
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_ram_disk(size=140)
+ disk2 = self.add_loopback_disk(size=140)
+ label = "raid1"
+ mount_point = "/run/butter"
+ subvol_mount_point = "/run/cake"
+ subvol = "/run/butter/cake"
+ subvol2 = "/run/butter/bread"
+ subvol_name = os.path.basename(subvol)
+
+ m.execute(f"mkfs.btrfs -L {label} -d raid1 {disk1} {disk2}")
+ 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))
+
+ b.wait_in_text(self.card_row("Storage", name=os.path.basename(disk1)), "btrfs device")
+ b.wait_in_text(self.card_row("Storage", name=os.path.basename(disk2)), "btrfs device")
+
+ # mount /
+ self.click_dropdown(self.card_row("Storage", name=label) + " + tr", "Mount")
+ self.dialog({"mount_point": mount_point})
+ b.wait_visible(self.card_row("Storage", location=mount_point))
+
+ # create subvolume
+ m.execute(f"""
+ btrfs subvolume create {subvol}
+ btrfs subvolume create {subvol2}
+ """)
+ b.wait_visible(self.card_row("Storage", name=os.path.basename(subvol)))
+
+ self.click_dropdown(self.card_row("Storage", name=os.path.basename(subvol)), "Mount")
+ self.dialog({"mount_point": subvol_mount_point})
+ b.wait_visible(self.card_row("Storage", location=subvol_mount_point))
+
+ # devices overview
+ self.click_card_row("Storage", name=label)
+ b.wait_visible(self.card_row("btrfs volume", name=disk1))
+ b.wait_visible(self.card_row("btrfs volume", name=disk2))
+
+ # unmount via main page
+ b.go("#/")
+ b.wait_visible(self.card("Storage"))
+
+ self.click_dropdown(self.card_row("Storage", location=subvol_mount_point), "Unmount")
+ self.confirm()
+ b.wait_visible(self.card_row("Storage", location=f"{subvol_mount_point} (not mounted)"))
+
+ self.click_dropdown(self.card_row("Storage", name=os.path.basename(subvol)), "Mount")
+ self.dialog({"mount_point": subvol_mount_point})
+ b.wait_visible(self.card_row("Storage", location=subvol_mount_point))
+
+ mount_options = m.execute(f"findmnt --fstab -n -o OPTIONS {subvol_mount_point}").strip()
+ self.assertIn(f"subvol={subvol_name}", mount_options)
+ self.assertEqual(mount_options.count(subvol_name), 1)
+
+ self.click_dropdown(self.card_row("Storage", name=os.path.basename(subvol2)), "Mount")
+ self.dialog_wait_open()
+ self.dialog_set_val("mount_point", subvol_mount_point)
+ self.dialog_apply()
+ self.dialog_wait_error("mount_point", f"Mount point is already used for btrfs subvolume {os.path.basename(subvol)} of raid1")
+ self.dialog_cancel()
+
+ self.click_dropdown(self.card_row("Storage", location=subvol_mount_point), "Unmount")
+ self.confirm()
+ b.wait_visible(self.card_row("Storage", location=f"{subvol_mount_point} (not mounted)"))
+
+ def testDefaultSubvolume(self):
+ m = self.machine
+ b = self.browser
+
+ disk1 = self.add_ram_disk(size=140)
+ label = "test_subvol"
+ mount_point = "/run/butter"
+ subvol = "cake"
+ subvol_path = f"{mount_point}/{subvol}"
+
+ 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))
+
+ b.wait_in_text(self.card_row("Storage", name=os.path.basename(disk1)), "btrfs device")
+
+ # Create a new btrfs subvolume and set it as default and mount it.
+ m.execute(f"""
+ mkdir -p {mount_point}
+ mount {disk1} {mount_point}
+ btrfs subvolume create {subvol_path}
+ btrfs subvolume set-default {subvol_path}
+ umount {mount_point}
+ mount {disk1} {mount_point}
+ """)
+
+ # Show a warning for mismounting in details.
+ b.wait_visible(self.card_row("Storage", name=subvol))
+ b.wait_visible(self.card_row("Storage", name=subvol) + ' .ct-icon-exclamation-triangle')
+ self.click_card_row("Storage", name=subvol)
+ b.wait_text(self.card_desc("btrfs subvolume", "Name"), subvol)
+
+ # Mount automatically on /run/butter on boot
+ b.click(self.card_button("btrfs subvolume", f"Mount automatically on {mount_point} on boot"))
+ b.wait_not_present(self.card_button("btrfs subvolume", f"Mount automatically on {mount_point} on boot"))
+
+ # No warnings on main page for either subvolumes
+ b.go("#/")
+ b.wait_visible(self.card("Storage"))
+ b.wait_not_present(self.card_row("Storage", name=subvol) + ' .ct-icon-exclamation-triangle')
+ b.wait_not_present(self.card_row("Storage", name="/") + ' .ct-icon-exclamation-triangle')
+
+ def testMountingHelp(self):
+ m = self.machine
+ b = self.browser
+
+ self.login_and_go("/storage")
+
+ disk = self.add_ram_disk(size=128)
+ uuid = "a32d61d6-9d75-4327-9bb9-3fa3627300ae"
+ mount_point = "/run/butter"
+
+ m.execute(f"mkfs.btrfs -U {uuid} {disk}")
+
+ # creation of btrfs partition can take a while on TF.
+ with b.wait_timeout(30):
+ b.wait_visible(self.card_row("Storage", name=uuid))
+ self.click_card_row("Storage", name=uuid)
+
+ self.click_dropdown(self.card_row("btrfs subvolumes", name="/"), "Mount")
+ self.dialog({"mount_point": mount_point})
+ b.wait_visible(self.card_row("btrfs subvolumes", location=mount_point))
+ self.click_card_row("btrfs subvolumes", location=mount_point)
+
+ filesystem = "btrfs subvolume"
+
+ # Unmount externally, remount with Cockpit
+ m.execute(f"umount {mount_point}")
+ b.click(self.card_button(filesystem, "Mount now"))
+ b.wait_not_present(self.card_button(filesystem, "Mount now"))
+ b.wait_not_in_text(self.card_desc(filesystem, "Mount point"), "The filesystem is not mounted")
+
+ # Unmount externally, adjust fstab with Cockpit
+
+ m.execute(f"umount {mount_point}")
+ b.click(self.card_button(filesystem, "Do not mount automatically on boot"))
+ b.wait_not_present(self.card_button(filesystem, "Do not mount automatically on boot"))
+
+ # Mount somewhere else externally while "noauto", unmount with Cockpit
+
+ m.execute(f"mkdir -p /run/bar; mount {disk} /run/bar")
+ b.click(self.card_button(filesystem, "Unmount now"))
+ b.wait_not_present(self.card_button(filesystem, "Unmount now"))
+
+ # Mount externally, unmount with Cockpit
+
+ m.execute(f"mount {mount_point}")
+ b.click(self.card_button(filesystem, "Unmount now"))
+ b.wait_not_present(self.card_button(filesystem, "Unmount now"))
+
+ # Mount externally, adjust fstab with Cockpit
+
+ m.execute(f"mount {mount_point}")
+ b.click(self.card_button(filesystem, "Mount also automatically on boot"))
+ b.wait_not_present(self.card_button(filesystem, "Mount also automatically on boot"))
+
+ # Move mount point externally, move back with Cockpit
+
+ m.execute(f"umount {mount_point}")
+ m.execute(f"mkdir -p /run/bar; mount {disk} /run/bar")
+ b.click(self.card_button(filesystem, f"Mount on {mount_point} now"))
+ b.wait_not_present(self.card_button(filesystem, f"Mount on {mount_point} now"))
+
+ # Move mount point externally, adjust fstab with Cockpit
+
+ m.execute(f"umount {mount_point}")
+ m.execute(f"mkdir -p /run/bar; mount {disk} /run/bar")
+ b.click(self.card_button(filesystem, "Mount automatically on /run/bar on boot"))
+ b.wait_not_present(self.card_button(filesystem, "Mount automatically on /run/bar on boot"))
+
+ # Using noauto,x-systemd.automount should not show a warning
+ m.execute("sed -i -e 's/auto nofail/auto nofail,noauto/' /etc/fstab")
+ b.wait_visible(self.card_button(filesystem, "Mount also automatically on boot"))
+ m.execute("sed -i -e 's/noauto/noauto,x-systemd.automount/' /etc/fstab")
+ b.wait_not_present(self.card_button(filesystem, "Mount also automatically on boot"))
+
+ # Without fstab entry, mount and try to unmount
+ m.execute("sed -i '/run\\/bar/d' /etc/fstab")
+ b.wait_visible(self.card_button(filesystem, "Mount automatically on /run/bar on boot"))
+ b.click(self.card_button(filesystem, "Unmount now"))
+ b.wait_not_present(self.card_button(filesystem, "Mount automatically on /run/bar on boot"))
+
+ def _create_btrfs_device(self, disk, label):
+ m = self.machine
+
+ self.click_card_row("Storage", name=disk)
+ m.execute(f"mkfs.btrfs -L {label} {disk}")
+
+ def _navigate_root_subvolume(self, label):
+ b = self.browser
+
+ b.wait_visible(self.card("btrfs device"))
+ b.click(self.card_button("btrfs device", label))
+ b.wait_visible(self.card("btrfs volume"))
+ self.click_card_row("btrfs subvolumes", name="/")
+ b.wait_visible(self.card("btrfs subvolume"))
+
+ def testMounting(self):
+ m = self.machine
+ b = self.browser
+ label = "butter"
+ mount_point_foo = "/run/foo"
+ mount_point_bar = "/run/bar"
+ filesystem = "btrfs subvolume"
+
+ self.login_and_go("/storage")
+ disk = self.add_ram_disk(size=128)
+ self._create_btrfs_device(disk, label)
+ self._navigate_root_subvolume(label)
+
+ m.execute(f"""
+ echo 'LABEL={label} {mount_point_foo} btrfs subvol=/ 0 0\n' >> /etc/fstab
+ mkdir -p {mount_point_foo}
+ mount {mount_point_foo}
+ """)
+ self.addCleanup(m.execute, f"umount {mount_point_foo} || true")
+
+ b.wait_in_text(self.card_desc("btrfs subvolume", "Mount point"), f"{mount_point_foo} (stop boot on failure)")
+
+ # Keep the mount point busy
+ sleep_pid = m.spawn(f"cd {mount_point_foo}; sleep infinity", "sleep")
+ self.write_file("/etc/systemd/system/keep-mnt-busy.service",
+ f"""
+[Unit]
+Description=Test Service
+
+[Service]
+WorkingDirectory={mount_point_foo}
+ExecStart=/usr/bin/sleep infinity
+""")
+ m.execute("systemctl start keep-mnt-busy")
+ m.execute("until systemctl is-active keep-mnt-busy; do sleep 1; done")
+
+ # import time
+ # time.sleep(3)
+ b.click(self.card_button(filesystem, "Unmount"))
+ b.wait_in_text("#dialog", str(sleep_pid))
+ b.wait_in_text("#dialog", "sleep infinity")
+ b.wait_in_text("#dialog", "keep-mnt-busy")
+ b.wait_in_text("#dialog", "Test Service")
+ b.wait_in_text("#dialog", "/usr/bin/sleep infinity")
+ b.wait_in_text("#dialog", "The listed processes and services will be forcefully stopped.")
+ b.assert_pixels("#dialog", "busy-unmount", mock={"td[data-label='PID']": "1234",
+ "td[data-label='Runtime']": "a little while"})
+ self.confirm()
+ b.wait_in_text(self.card_desc(filesystem, "Mount point"), "The filesystem is not mounted")
+
+ m.execute("! systemctl --quiet is-active keep-mnt-busy")
+
+ b.click(self.card_desc(filesystem, "Mount point") + " button")
+ self.dialog(expect={"mount_point": mount_point_foo},
+ values={"mount_point": mount_point_bar})
+ self.assert_in_configuration("/dev/sda", "fstab", "dir", mount_point_bar)
+ b.wait_in_text(self.card_desc(filesystem, "Mount point"), mount_point_bar)
+
+ b.click(self.card_button(filesystem, "Mount"))
+ self.confirm()
+ b.wait_not_in_text(self.card_desc(filesystem, "Mount point"), "The filesystem is not mounted")
+
+ # Set the "Never unlock at boot option"
+ b.click(self.card_desc(filesystem, "Mount point") + " button")
+ self.dialog({"at_boot": "never"})
+ self.assertIn("noauto", m.execute(f"findmnt -s -n -o OPTIONS {mount_point_bar}"))
+ self.assertIn("x-cockpit-never-auto", m.execute(f"findmnt -s -n -o OPTIONS {mount_point_bar}"))
+ m.execute(f"umount {mount_point_bar} || true")
+
+ def testFstabOption(self):
+ m = self.machine
+ b = self.browser
+ label = "butter"
+
+ self.login_and_go("/storage")
+ disk = self.add_ram_disk(size=128)
+ self._create_btrfs_device(disk, label)
+ self._navigate_root_subvolume(label)
+
+ m.execute("! grep /run/data /etc/fstab")
+ b.click(self.card_button("btrfs subvolume", "Mount"))
+ self.dialog({"mount_point": "/run/data",
+ "mount_options.extra": "x-foo"})
+ m.execute("grep /run/data /etc/fstab")
+ m.execute("grep 'x-foo' /etc/fstab")
+
+ b.wait_in_text(self.card_desc("btrfs subvolume", "Mount point"), "/run/data (ignore failure, x-foo)")
+
+ # absent mntopts and fsck columns implies "defaults"
+
+ m.execute(r"sed -i '/run\/data/ s/auto.*$/auto subvol=\//' /etc/fstab")
+ b.wait_in_text(self.card_desc("btrfs subvolume", "Mount point"), "/run/data (stop boot on failure)")
+
+ def testLuksEncrypted(self):
+ m = self.machine
+ b = self.browser
+
+ disk = self.add_ram_disk(size=128)
+ label = "butter"
+ mount_point = "/run/butter"
+ passphrase = "einszweidrei"
+
+ m.execute(f"""
+ echo {passphrase} | cryptsetup luksFormat --pbkdf-memory 32768 {disk}
+ echo {passphrase} | cryptsetup luksOpen {disk} btrfs-test
+ mkfs.btrfs -L {label} /dev/mapper/btrfs-test
+ """)
+
+ 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))
+ b.wait_in_text(self.card_row("Storage", name="sda"), "btrfs device (encrypted)")
+ self.click_dropdown(self.card_row("Storage", name=label) + " + tr", "Mount")
+ self.dialog({"mount_point": mount_point})
+
+ m.execute(f"""
+ umount {mount_point}
+ cryptsetup luksClose /dev/mapper/btrfs-test
+ """)
+ b.wait_in_text(self.card_row("Storage", name="sda"), "Locked data (encrypted)")
+ self.click_dropdown(self.card_row("Storage", name="sda"), "Unlock")
+ self.dialog({"passphrase": "einszweidrei"})
+ b.wait_in_text(self.card_row("Storage", name="sda"), "btrfs device (encrypted)")
+
+ self.click_dropdown(self.card_row("Storage", name=label) + " + tr", "Mount")
+ self.confirm()
+ b.wait_in_text(self.card_row("Storage", location=mount_point), "btrfs subvolume")
+
+
+if __name__ == '__main__':
+ testlib.test_main()
diff --git a/test/verify/check-storage-scaling b/test/verify/check-storage-scaling
index 175b69425f3..9b4043f3deb 100755
--- a/test/verify/check-storage-scaling
+++ b/test/verify/check-storage-scaling
@@ -33,6 +33,11 @@ class TestStorageScaling(storagelib.StorageCase):
return b.call_js_func('ph_count', self.card("Storage") + " tbody tr")
b.wait_visible(self.card_row("Storage", name="/dev/vda"))
+ # Wait on btrfs subvolumes on OS'es with the install on btrfs
+ if m.image.startswith('fedora'):
+ b.wait_in_text(self.card_row("Storage", name="root/var/lib/machines"), "btrfs subvolume")
+ elif m.image == "arch":
+ b.wait_in_text(self.card_row("Storage", name="swap"), "btrfs subvolume")
n_rows = count_rows()
m.execute("modprobe scsi_debug num_tgts=200")