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")