From ca756b133d4651a14f39004f3ba15d1672947e92 Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Fri, 17 Nov 2023 15:45:19 +0100 Subject: [PATCH] Implement deletion and fix creation without a mount point --- pkg/storaged/client.js | 63 +++++++ pkg/storaged/fsys-tab.jsx | 42 +++-- pkg/storaged/pages.jsx | 1 + pkg/storaged/pages/btrfs.jsx | 315 ++++++++++++++++++++++++++++++----- pkg/storaged/utils.js | 23 ++- 5 files changed, 388 insertions(+), 56 deletions(-) diff --git a/pkg/storaged/client.js b/pkg/storaged/client.js index 730df0bd588..fde73ba81c0 100644 --- a/pkg/storaged/client.js +++ b/pkg/storaged/client.js @@ -208,6 +208,50 @@ function is_multipath_master(block) { return false; } +export function btrfs_poll() { + if (!client.uuids_btrfs_subvols) + client.uuids_btrfs_subvols = { }; + if (!client.uuids_btrfs_volume) + 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]; + if (mp) { + 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; + }); + } else { + uuids_subvols[uuid] = null; + return Promise.resolve(); + } + })) + .then(() => { + // deep-equal? + if (JSON.stringify(client.uuids_btrfs_subvols) != JSON.stringify(uuids_subvols)) { + console.log("SUBVOLS", uuids_subvols); + client.uuids_btrfs_subvols = uuids_subvols; + client.update(); + } + }); +} + +function btrfs_start_polling() { + window.setInterval(btrfs_poll, 5000); + client.uuids_btrfs_subvols = { }; + btrfs_poll(); +} + function update_indices() { let path, block, mdraid, vgroup, pvol, lvol, pool, blockdev, fsys, part, i; @@ -547,15 +591,31 @@ function update_indices() { // UDisks API does provide a btrfs volume abstraction so we keep track of // volume's by uuid in an object. uuid => [org.freedesktop.UDisks2.Filesystem.BTRFS] + const old_uuids = client.uuids_btrfs_volume; + let need_poll = false; + client.uuids_btrfs_volume = { }; client.uuids_btrfs_blocks = { }; for (const p in client.blocks_fsys_btrfs) { const bfs = client.blocks_fsys_btrfs[p]; const uuid = bfs.data.uuid; + const block_fsys = client.blocks_fsys[p]; + if ((block_fsys && block_fsys.MountPoints.length > 0) || !client.uuids_btrfs_volume[uuid]) { + client.uuids_btrfs_volume[uuid] = bfs; + if (!old_uuids || !old_uuids[uuid]) + need_poll = true; + } if (!client.uuids_btrfs_blocks[uuid]) client.uuids_btrfs_blocks[uuid] = []; client.uuids_btrfs_blocks[uuid].push(client.blocks[p]); } + + if (need_poll) { + console.log('polling btrfs'); + btrfs_poll(); + } + console.log("uuids_btrfs_blocks", client.uuids_btrfs_blocks); + console.log("uuids_btrfs_subvolumes", client.uuids_btrfs_volumes); client.blocks_cleartext = { }; for (path in client.blocks) { @@ -628,6 +688,7 @@ client.update = (first_time) => { if (client.ready) { update_indices(); client.path_warnings = find_warnings(client); + console.log('create pages'); create_pages(); client.dispatchEvent("changed"); } @@ -657,6 +718,8 @@ function init_model(callback) { client.features.iscsi = (client.manager_iscsi.valid && client.manager_iscsi.SessionsSupported !== false); client.features.btrfs = client.manager_btrfs.valid; + if (client.features.btrfs) + btrfs_start_polling(); }); }, function(error) { console.warn("Can't enable storaged modules", error.toString()); diff --git a/pkg/storaged/fsys-tab.jsx b/pkg/storaged/fsys-tab.jsx index 6b4fe228d3c..e648b93d3b2 100644 --- a/pkg/storaged/fsys-tab.jsx +++ b/pkg/storaged/fsys-tab.jsx @@ -38,21 +38,18 @@ import client from "./client.js"; const _ = cockpit.gettext; -export function is_mounted(client, block) { +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 = utils.decode_filename(config[1].dir.v); - if (dir[0] != "/") - dir = "/" + dir; + const [, dir] = get_fstab_config(block, false, subvol); + if (dir) { return mounted_at.map(utils.decode_filename).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) { @@ -155,9 +152,9 @@ export function check_mismounted_fsys(client, path, enter_warning) { enter_warning(path, { warning: "mismounted-fsys", type, other: other_mounts[0] }); } -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 split_options = parse_options(options); @@ -169,9 +166,13 @@ export function mounting_dialog(client, block, mode, forced_options) { if (forced_options) for (const opt of forced_options) extract_option(split_options, opt); + if (subvol) { + extract_option(split_options, "subvol=" + subvol.pathname); + extract_option(split_options, "subvolid=" + subvol.id); + } 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; @@ -385,6 +386,8 @@ export function mounting_dialog(client, block, mode, forced_options) { opts.push("_netdev"); if (forced_options) opts = opts.concat(forced_options); + if (subvol) + opts.push("subvol=" + subvol.pathname); if (extra_options) opts = opts.concat(extra_options); return (maybe_set_crypto_options(null, false, null, null) @@ -431,6 +434,8 @@ export function mounting_dialog(client, block, mode, forced_options) { opts.push("_netdev"); if (forced_options) opts = opts.concat(forced_options); + if (subvol) + opts.push("subvol=" + subvol.pathname); if (vals.mount_options.extra !== false) opts = opts.concat(parse_options(vals.mount_options.extra)); return (maybe_update_config(vals.mount_point, unparse_options(opts), @@ -473,6 +478,7 @@ export class FilesystemTab extends React.Component { render() { const self = this; const block = self.props.block; + const subvol = self.props.subvol; const forced_options = self.props.forced_options; const is_locked = block && block.IdUsage == 'crypto'; const block_fsys = block && self.props.client.blocks_fsys[block.path]; @@ -499,8 +505,8 @@ export class FilesystemTab extends React.Component { }); } - const is_filesystem_mounted = is_mounted(self.props.client, block); - const [old_config, old_dir, old_opts, old_parents] = get_fstab_config(block, true); + const is_filesystem_mounted = is_mounted(self.props.client, block, subvol); + const [old_config, old_dir, old_opts, old_parents] = get_fstab_config(block, true, subvol); const split_options = parse_options(old_opts); extract_option(split_options, "noauto"); const opt_ro = extract_option(split_options, "ro"); @@ -511,6 +517,10 @@ export class FilesystemTab extends React.Component { if (forced_options) for (const opt of forced_options) extract_option(split_options, opt); + if (subvol) { + extract_option(split_options, "subvol=" + subvol.pathname); + extract_option(split_options, "subvolid=" + subvol.id); + } let mount_point_text = null; if (old_dir) { @@ -621,7 +631,7 @@ export class FilesystemTab extends React.Component { function do_mount() { if (crypto_backing == block) - mounting_dialog(client, block, "mount", forced_options); + mounting_dialog(client, block, "mount", forced_options, subvol); else return client.mount_at(block, old_dir); } @@ -695,7 +705,7 @@ export class FilesystemTab extends React.Component { return (
- { !stratis_fsys && + { !(stratis_fsys || subvol) && {_("Name")} @@ -718,7 +728,7 @@ export class FilesystemTab extends React.Component { { mount_point_text } mounting_dialog(self.props.client, block, "update", - forced_options)}> + forced_options, subvol)}> {_("edit")} diff --git a/pkg/storaged/pages.jsx b/pkg/storaged/pages.jsx index 728a003c4e5..d137abbd26d 100644 --- a/pkg/storaged/pages.jsx +++ b/pkg/storaged/pages.jsx @@ -152,6 +152,7 @@ export function get_page_from_location(location) { export function navigate_away_from_page(page) { const loc = cockpit.location; + console.log('navigate away'); if (page.parent && JSON.stringify(loc.path) == JSON.stringify(page.location)) loc.go(page.parent.location); } diff --git a/pkg/storaged/pages/btrfs.jsx b/pkg/storaged/pages/btrfs.jsx index 61132d9bd7b..dccc107e148 100644 --- a/pkg/storaged/pages/btrfs.jsx +++ b/pkg/storaged/pages/btrfs.jsx @@ -19,7 +19,7 @@ import cockpit from "cockpit"; import React from "react"; -import client from "../client"; +import client, { btrfs_poll } from "../client"; import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; @@ -27,38 +27,78 @@ import { DescriptionList } from "@patternfly/react-core/dist/esm/components/Desc import { SCard } from "../utils/card.jsx"; import { SDesc } from "../utils/desc.jsx"; -import { PageChildrenCard, PageCrossrefCard, ActionButtons, new_page, page_type, get_crossrefs } from "../pages.jsx"; -import { fmt_size, fmt_size_long } from "../utils.js"; +import { PageChildrenCard, PageContainerStackItems, ParentPageLink, PageCrossrefCard, ActionButtons, new_page, page_type, get_crossrefs, register_crossref, navigate_away_from_page } from "../pages.jsx"; +import { encode_filename, decode_filename, fmt_size, fmt_size_long, get_fstab_config_with_client, reload_systemd, for_each_async, flatten, teardown_active_usage } from "../utils.js"; +import { is_mounted, mounting_dialog, is_valid_mount_point, get_fstab_config } from "../fsys-tab.jsx"; // TODO: also use is_mounted? +import { dialog_open, TextInput, CheckBoxes, TeardownMessage, init_active_usage_processes } from "../dialog.jsx"; const _ = cockpit.gettext; function btrfs_usage(uuid) { const block_devices = client.uuids_btrfs_blocks[uuid]; - console.log(block_devices); let size = 0; for (const block_device of block_devices) { size += client.blocks[block_device.path].Size; } const used = client.blocks_fsys_btrfs[block_devices[0].path].data.used; - - // 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) { - // const blocks = []; - // Object.keys(client.blocks_fsys_btrfs).forEach(obj_path => { - // const blk = client.blocks_fsys_btrfs[obj_path]; - // if (blk.data.uuid === volume.data.uuid) { - // blocks.push(client.blocks[obj_path]); - // } - // }); - // use = [volume.data.used, blocks.reduce((sum, b) => sum + b.Size, 0)]; - // } - // console.log(use); return [used, size]; } +function get_mount_point_in_parent(block, subvol, subvols) { + for (const p of subvols) { + if ((p.pathname == "/" || (subvol.pathname.substring(0, p.pathname.length) == p.pathname && + subvol.pathname[p.pathname.length] == "/")) && + is_mounted(client, block, p)) { + const [, pmp] = get_fstab_config(block, false, p); + if (p.pathname == "/") + return pmp + "/" + subvol.pathname; + else + return pmp + subvol.pathname.substring(p.pathname.length); + } + } + return null; +} + +function set_mount_options(block, block_fsys, subvol, vals) { + const mount_options = []; + + if (!vals.mount_options.auto || vals.mount_options.never_auto) + mount_options.push("noauto"); + if (vals.mount_options.ro) + mount_options.push("ro"); + if (vals.mount_options.never_auto) + mount_options.push("x-cockpit-never-auto"); + const name = (subvol.pathname == "/" ? vals.name : subvol.pathname + "/" + vals.name); + mount_options.push("subvol=" + name); + if (vals.mount_options.extra) + mount_options.push(vals.mount_options.extra); + + let mount_point = vals.mount_point; + if (mount_point[0] != "/") + mount_point = "/" + mount_point; + + const config = + ["fstab", + { + dir: { t: 'ay', v: encode_filename(mount_point) }, + type: { t: 'ay', v: encode_filename("auto") }, + opts: { t: 'ay', v: encode_filename(mount_options.join(",") || "defaults") }, + freq: { t: 'i', v: 0 }, + passno: { t: 'i', v: 0 }, + } + ]; + + return block.AddConfigurationItem(config, {}) + .then(reload_systemd) + .then(() => { + if (vals.mount_options.auto) { + return client.mount_at(block, mount_point); + } else + return Promise.resolve(); + }); +} + /* * 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. @@ -66,9 +106,9 @@ function btrfs_usage(uuid) { export function make_btrfs_volume_page(parent, uuid) { const block_devices = client.uuids_btrfs_blocks[uuid]; - const device = client.blocks_fsys_btrfs[block_devices[0].path]; + const block_btrfs = client.blocks_fsys_btrfs[block_devices[0].path]; // TODO: label is optional, but do we want to show uuid then? - const name = device.data.label || uuid; + const name = block_btrfs.data.label || uuid; const total_capacity = btrfs_usage(uuid)[1]; const btrfs_volume_page = new_page({ location: ["btrfs-volume", name], @@ -80,37 +120,236 @@ export function make_btrfs_volume_page(parent, uuid) { fmt_size(total_capacity), ], component: BtrfsVolumePage, - props: { block_devices, name: device.data.label, uuid: device.data.uuid, total_capacity }, - actions: [], + props: { block_devices, name: block_btrfs.data.label, uuid: block_btrfs.data.uuid, total_capacity }, + actions: [{ title: "TEST", action: () => console.log('TEST') }], }); - for (const blk of block_devices) { - const device = client.blocks_fsys_btrfs[blk.path]; - device.GetSubvolumes(false, {}).then(subvolumes => { - for (const subvolume of subvolumes) { - make_btrfs_volume_subvolume(btrfs_volume_page, uuid, subvolume); + if (client.uuids_btrfs_subvols) { + const subvolumes = client.uuids_btrfs_subvols[uuid]; + if (subvolumes) { + for (const subvolume of client.uuids_btrfs_subvols[uuid]) { + make_btrfs_volume_subvolume(btrfs_volume_page, uuid, subvolume, block_btrfs, subvolumes); } - }); + } } } -function make_btrfs_volume_subvolume(parent, uuid, subvol) { - const [id, parent_id, path] = subvol; - new_page({ - location: ["btrfs-volume", uuid, id], +function make_btrfs_volume_subvolume(parent, uuid, subvol, block_btrfs, subvolumes) { + const block = client.blocks[block_btrfs.path]; + console.log("subvolume", parent, uuid, subvol, block, block_btrfs, subvolumes); + const [, mount_point, _options] = get_fstab_config_with_client(client, block, false, subvol); + const block_fsys = client.blocks_fsys[block.path]; + const mount_points = block_fsys.MountPoints.map(decode_filename); + const is_subvolume_mounted = mount_points.indexOf(mount_point) >= 0; + // TODO: discover if mounted.. without fstab?? + // mount -o subvol=home/admin/banan /dev/vda5 /mnt/disk/ + + function get_direct_subvol_children(subvol) { + function is_direct_parent(sv) { + return (sv.pathname.length > subvol.pathname.length && + sv.pathname.substring(0, subvol.pathname.length) == subvol.pathname && + sv.pathname[subvol.pathname.length] == "/" && + sv.pathname.substring(subvol.pathname.length + 1).indexOf("/") == -1); + } + + return subvolumes.filter(is_direct_parent); + } + + function get_subvol_children(subvol) { + // The deepest nested children must come first + const direct_children = get_direct_subvol_children(subvol); + return flatten(direct_children.map(get_subvol_children)).concat(direct_children); + } + + function mount() { + return mounting_dialog(client, block, "mount", null, subvol); + } + + function unmount() { + return mounting_dialog(client, block, "unmount", null, subvol); + } + + function validate_mount_point(val) { + if (val === "") + return null; + return is_valid_mount_point(client, null, val); + } + + function create_subvolume() { + const mount_point_in_parent = get_mount_point_in_parent(block, subvol, subvolumes); + console.log("MPP", subvol.pathname, mount_point_in_parent); + + if (!is_subvolume_mounted && !mount_point_in_parent) { + dialog_open({ + Title: cockpit.format(_("Can't Create Subvolume of $0"), subvol.pathname), + Body: _("Either this subvolume or one of its parents needs to be mounted") + }); + return; + } + + dialog_open({ + Title: cockpit.format(_("Create Subvolume of $0"), subvol.pathname), + Fields: [ + TextInput("name", _("Name"), + { + }), + TextInput("mount_point", _("Mount Point"), + { + validate: val => validate_mount_point(val) + }), + CheckBoxes("mount_options", _("Mount Options"), + { + value: { + auto: false, + ro: false, + never_auto: false, + extra: false + }, + fields: [ + { title: _("Mount now"), tag: "auto" }, + { title: _("Mount read only"), tag: "ro" }, + { + title: _("Never mount at boot"), + tag: "never_auto", + tooltip: "" // never_auto_explanation, + }, + { title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" }, + ] + }), + ], + Action: { + Title: _("Create"), + action: function (vals) { + const parent_dir = is_subvolume_mounted ? mount_point : mount_point_in_parent; + return cockpit.spawn(["btrfs", "subvolume", "create", parent_dir + "/" + vals.name], + { superuser: true, err: "message" }) + .then(() => { + btrfs_poll(); + // A BTRFS subvolume is just a directory it doesn't have to be mounted per se + if (vals.mount_point !== "") { + return set_mount_options(block, block_fsys, subvol, vals); + } + }); + } + } + }); + } + + function delete_subvolume() { + const mount_point_in_parent = get_mount_point_in_parent(block, subvol, subvolumes); + console.log("MPP", subvol.pathname, mount_point_in_parent); + + if (!mount_point_in_parent) { + dialog_open({ + Title: cockpit.format(_("Can't delete subvolume $0"), subvol.pathname), + Body: _("One of the parents of this subvolume needs to be mounted") + }); + return; + } + + const all_subvols = get_subvol_children(subvol).concat([subvol]); + + const usage = []; + const configs_to_remove = []; + const paths_to_delete = []; + + for (const sv of all_subvols) { + const [config, mount_point] = get_fstab_config(block, false, sv); + const fs_is_mounted = is_mounted(client, block, sv); + + if (fs_is_mounted) { + usage.push({ + level: 0, + usage: 'mounted', + block, + name: sv.pathname, + location: mount_point, + actions: [_("unmount"), _("delete")], + blocking: false, + }); + } + + if (config) + configs_to_remove.push(config); + + paths_to_delete.push(mount_point_in_parent + sv.pathname.substring(subvol.pathname.length)); + } + + function remove_configs() { + return for_each_async(configs_to_remove, c => block.RemoveConfigurationItem(c, {})); + } + + function delete_all_subvols() { + return cockpit.spawn(["btrfs", "subvolume", "delete"].concat(paths_to_delete), + { superuser: true, err: "message" }); + } + + dialog_open({ + Title: cockpit.format(_("Permanently delete subvolume $0?"), subvol.pathname), + Teardown: TeardownMessage(usage), + Action: { + Title: _("Delete"), + Danger: _("Deleting erases all data on a btrfs subvolume."), + action: async function () { + await teardown_active_usage(client, usage); + await remove_configs(); + await delete_all_subvols(); + await btrfs_poll(); + navigate_away_from_page(subvolume_page); + } + }, + Inits: [ + init_active_usage_processes(client, usage) + ] + }); + } + + const key = `${block_btrfs.data.label || uuid}-${subvol.id}`; + const subvolume_page = new_page({ + location: ["btrfs-volume", key], parent, - name: path, + name: subvol.pathname, + component: BtrfsSubvolumePage, columns: [ _("Btrfs subvolume"), - id, - parent_id, + mount_point, ], - component: BtrfsVolumePage, - props: { uuid, id, parent_id, path }, + props: { uuid, id: subvol.id, name: subvol.pathname }, + actions: [ + (is_subvolume_mounted ? { title: _("Unmount"), action: unmount } : { title: _("Mount"), action: mount }), + { title: _("Create subvolume"), action: create_subvolume }, + { title: _("Delete"), action: delete_subvolume, danger: true }, + ], + }); + + register_crossref({ + key, + page: subvolume_page, actions: [], }); } +const BtrfsSubvolumePage = ({ page, block_devices, name, uuid, total_capacity, subvolumes }) => { + return ( + + + }> + + + + {name} + + + + + + + + + + ); +}; + const BtrfsVolumePage = ({ page, block_devices, name, uuid, total_capacity, subvolumes }) => { let crossrefs = []; for (const blk of block_devices) { diff --git a/pkg/storaged/utils.js b/pkg/storaged/utils.js index c9b5ac299dc..e36c22aabe3 100644 --- a/pkg/storaged/utils.js +++ b/pkg/storaged/utils.js @@ -766,8 +766,27 @@ 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) { + const default_subvol = "/"; // XXX - get this from btrfs + + if (c[0] != "fstab") + return false; + if (subvol !== undefined) { + 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; + if (subvol.pathname == default_subvol && + !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");