From 19047de5252e9a801b05da4b9236ecb3e55963fd Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Thu, 30 Nov 2023 14:15:10 +0100 Subject: [PATCH] storaged: add btrfs volume integration Btrfs is a filesystem which can span over multiple devices a bit similar to LVM but not 100%. On a standard Fedora server image we have one partition which holds a btrfs filesystem with two subvolumes for / and /home. In Cockpit we represent this as: -> btrfs subvolume -> btrfs volume (the filesystem) -> btrfs device (backing storage) -> block device A btrfs volume can have one or more devices and supports multiple raid configurations. In Cockpit we show a btrfs volume with the devices it is uses a backing and the space usage. In UDisks a "volume" does not exist but only an org.freedesktop.UDisks2.Filesystem.BTRFS object which per btrfs device, setting the label or retrieving the subvolumes on one of these objects whcih are part of the same volume affects the whole group. This initial commit lacks support for Btrfs subvolumes. The UDisks API lacks a few data points which would be useful for in Cockpit such as the space usage per btrfs device, showing the health status of a btrfs filesystem when one device is missing, balancing of btrfs devices. The space use per btrfs device has been implemented using btrfs-progs in Cockpit, health warnings are not yet shown in Cockpit as it for now purely focuses on a single btrfs device volume. --- pkg/storaged/block/create-pages.jsx | 11 +- pkg/storaged/block/format-dialog.jsx | 3 +- pkg/storaged/btrfs/device.jsx | 90 ++++ pkg/storaged/btrfs/subvolume.jsx | 106 +++++ pkg/storaged/btrfs/utils.jsx | 77 ++++ pkg/storaged/btrfs/volume.jsx | 202 +++++++++ pkg/storaged/client.js | 215 +++++++-- pkg/storaged/dialog.jsx | 1 + pkg/storaged/filesystem/mismounting.jsx | 13 +- pkg/storaged/filesystem/mounting-dialog.jsx | 13 +- pkg/storaged/filesystem/utils.jsx | 91 ++-- pkg/storaged/overview/overview.jsx | 2 + pkg/storaged/utils.js | 84 +++- test/common/storagelib.py | 3 + test/reference | 2 +- test/verify/check-storage-basic | 1 + test/verify/check-storage-btrfs | 469 ++++++++++++++++++++ test/verify/check-storage-scaling | 5 + 18 files changed, 1302 insertions(+), 86 deletions(-) create mode 100644 pkg/storaged/btrfs/device.jsx create mode 100644 pkg/storaged/btrfs/subvolume.jsx create mode 100644 pkg/storaged/btrfs/utils.jsx create mode 100644 pkg/storaged/btrfs/volume.jsx create mode 100755 test/verify/check-storage-btrfs 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")