Skip to content

Commit

Permalink
storaged: btrfs: allow btrfs subvolumes to be deleted
Browse files Browse the repository at this point in the history
Deletion is mostly similar to creation, except we allow a user to
delete a parent and it's children from the UI unlike creation where we
only allow a child to be created.
  • Loading branch information
jelly authored and mvollmer committed Jan 30, 2024
1 parent 631ea06 commit ac54438
Show file tree
Hide file tree
Showing 3 changed files with 240 additions and 9 deletions.
87 changes: 86 additions & 1 deletion pkg/storaged/btrfs/subvolume.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ import { DescriptionList } from "@patternfly/react-core/dist/esm/components/Desc

import { StorageCard, StorageDescription, new_card, new_page } from "../pages.jsx";
import { StorageUsageBar } from "../storage-controls.jsx";
import { encode_filename, get_fstab_config_with_client, reload_systemd, extract_option, parse_options } from "../utils.js";
import {
encode_filename, get_fstab_config_with_client, reload_systemd, extract_option, parse_options,
flatten, teardown_active_usage,
} from "../utils.js";
import { btrfs_usage, validate_subvolume_name } from "./utils.jsx";
import { at_boot_input, mounting_dialog, mount_options } from "../filesystem/mounting-dialog.jsx";
import {
dialog_open, TextInput,
TeardownMessage, init_active_usage_processes,
} from "../dialog.jsx";
import { check_mismounted_fsys, MismountAlert } from "../filesystem/mismounting.jsx";
import { is_mounted, is_valid_mount_point, mount_point_text, MountPoint } from "../filesystem/utils.jsx";
Expand Down Expand Up @@ -162,6 +166,73 @@ function subvolume_create(volume, subvol, parent_dir) {
});
}

function subvolume_delete(volume, subvol, mount_point_in_parent) {
const block = client.blocks[volume.path];
const subvols = client.uuids_btrfs_subvols[volume.data.uuid];

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 subvols.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);
}

const all_subvols = get_subvol_children(subvol).concat([subvol]);
const configs_to_remove = [];
const paths_to_delete = [];
const usage = [];

for (const sv of all_subvols) {
const [config, mount_point] = get_fstab_config_with_client(client, block, false, sv);
const fs_is_mounted = is_mounted(client, block, sv);

usage.push({
level: 0,
usage: fs_is_mounted ? 'mounted' : 'none',
block,
name: sv.pathname,
location: mount_point,
actions: fs_is_mounted ? [_("unmount"), _("delete")] : [_("delete")],
blocking: false,
});

if (config)
configs_to_remove.push(config);

paths_to_delete.push(mount_point_in_parent + sv.pathname.substring(subvol.pathname.length));
}

dialog_open({
Title: cockpit.format(_("Permanently delete subvolume $0?"), subvol.pathname),
Teardown: TeardownMessage(usage),
Action: {
Title: _("Delete"),
Danger: _("Deleting erases all data on this subvolume and all it's children."),
action: async function () {
await teardown_active_usage(client, usage);
for (const c of configs_to_remove)
await block.RemoveConfigurationItem(c, {});
await cockpit.spawn(["btrfs", "subvolume", "delete"].concat(paths_to_delete),
{ superuser: true, err: "message" });
await btrfs_poll();
}
},
Inits: [
init_active_usage_processes(client, usage)
]
});
}

export function make_btrfs_subvolume_page(parent, volume, subvol) {
const actions = [];

Expand Down Expand Up @@ -206,6 +277,20 @@ export function make_btrfs_subvolume_page(parent, volume, subvol) {
action: () => subvolume_create(volume, subvol, (mounted && !opt_ro) ? mount_point : mount_point_in_parent),
});

let delete_excuse = "";
if (!mount_point_in_parent) {
delete_excuse = _("At least one parent needs to be mounted writable");
}

// Don't show deletion for the root subvolume as it can never be deleted.
if (subvol.id !== 5)
actions.push({
danger: true,
title: _("Delete"),
excuse: delete_excuse,
action: () => subvolume_delete(volume, subvol, mount_point_in_parent),
});

const card = new_card({
title: _("btrfs subvolume"),
next: null,
Expand Down
25 changes: 17 additions & 8 deletions pkg/storaged/dialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1118,10 +1118,7 @@ export const BlockingMessage = (usage) => {
const rows = [];
usage.forEach(use => {
if (use.blocking && use.block) {
const fsys = client.blocks_stratis_fsys[use.block.path];
const name = (fsys
? fsys.Devnode
: block_name(client.blocks[use.block.CryptoBackingDevice] || use.block));
const name = teardown_block_name(use);
rows.push({
columns: [name, use.location || "-", usage_desc[use.usage] || "-"]
});
Expand Down Expand Up @@ -1188,6 +1185,21 @@ function is_expected_unmount(usage, expect_single_unmount) {
usage[0].usage == "mounted" && usage[0].location == expect_single_unmount);
}

const teardown_block_name = use => {
const block_stratis = client.blocks_stratis_fsys[use.block.path];
const block_btrfs = client.blocks_fsys_btrfs[use.block.path];
let name;
if (block_stratis) {
name = block_stratis.Devnode;
} else if (block_btrfs && use.name) {
name = use.name;
} else {
name = block_name(client.blocks[use.block.CryptoBackingDevice] || use.block);
}

return name;
};

export const TeardownMessage = (usage, expect_single_unmount) => {
if (usage.length == 0)
return null;
Expand All @@ -1198,10 +1210,7 @@ export const TeardownMessage = (usage, expect_single_unmount) => {
const rows = [];
usage.forEach((use, index) => {
if (use.block) {
const fsys = client.blocks_stratis_fsys[use.block.path];
const name = (fsys
? fsys.Devnode
: block_name(client.blocks[use.block.CryptoBackingDevice] || use.block));
const name = teardown_block_name(use);
let location = use.location;
if (use.usage == "mounted") {
location = client.strip_mount_point_prefix(location);
Expand Down
137 changes: 137 additions & 0 deletions test/verify/check-storage-btrfs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,143 @@ class TestStorageBtrfs(storagelib.StorageCase):
subvol_loc = f"{os.path.basename(ro_subvol)}/readonly"
self.check_dropdown_action_disabled(self.card_row("Storage", name=subvol_loc), "Create subvolume", "Subvolume needs to be mounted")

def testDeleteSubvolume(self):
m = self.machine
b = self.browser

def checkTeardownAction(row, label, text):
b.wait_in_text(f".modal-footer-teardown tbody:nth-of-type({row}) td[data-label='{label}']", text)

self.login_and_go("/storage")

disk1 = self.add_ram_disk(size=140)
label = "test_subvol"
mount_point = "/run/butter"
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))

self.click_dropdown(self.card_row("Storage", name=label) + " + tr", "Mount")
self.dialog({"mount_point": mount_point})
self.addCleanup(self.machine.execute, f"umount {mount_point} || true")

# No Delete button for the root subvolume
root_sel = self.card_row("Storage", name=label) + " + tr"
b.click(self.dropdown_toggle(root_sel))
b.wait_not_present(self.dropdown_action(root_sel, "Delete"))
b.click(self.dropdown_toggle(root_sel))

# Delete subvolume
subvol = "subvol"
self.click_dropdown(self.card_row("Storage", location=mount_point), "Create subvolume")
self.dialog({"name": subvol}, secondary=True)
b.wait_visible(self.card_row("Storage", name=subvol))

self.click_dropdown(self.card_row("Storage", name=subvol), "Delete")
checkTeardownAction(1, "Device", subvol)
checkTeardownAction(1, "Action", "delete")
self.confirm()
b.wait_not_present(self.card_row("Storage", name=subvol))

# Delete with subvolume with children
child_subvol = "child-subvol"
self.click_dropdown(self.card_row("Storage", location=mount_point), "Create subvolume")
self.dialog({"name": subvol}, secondary=True)
b.wait_visible(self.card_row("Storage", name=subvol))

self.click_dropdown(self.card_row("Storage", name=subvol), "Create subvolume")
self.dialog({"name": child_subvol}, secondary=True)
b.wait_visible(self.card_row("Storage", name=f"{subvol}/{child_subvol}"))

self.click_dropdown(self.card_row("Storage", name=subvol), "Delete")
checkTeardownAction(1, "Device", f"{subvol}/{child_subvol}")
checkTeardownAction(1, "Action", "delete")
checkTeardownAction(2, "Device", subvol)
checkTeardownAction(2, "Action", "delete")
self.confirm()

b.wait_not_present(self.card_row("Storage", name=f"{subvol}/{child_subvol}"))
b.wait_not_present(self.card_row("Storage", name=subvol))

# Delete with subvolume with children and self mounted
child_subvol = "child-subvol"
subvol_mount_point = "/run/delete"
self.click_dropdown(self.card_row("Storage", location=mount_point), "Create subvolume")
self.dialog({"name": subvol, "mount_point": subvol_mount_point})
b.wait_visible(self.card_row("Storage", location=subvol_mount_point))

self.click_dropdown(self.card_row("Storage", name=subvol), "Create subvolume")
self.dialog({"name": child_subvol}, secondary=True)
b.wait_visible(self.card_row("Storage", name=f"{subvol}/{child_subvol}"))

self.click_dropdown(self.card_row("Storage", name=subvol), "Delete")
checkTeardownAction(1, "Device", f"{subvol}/{child_subvol}")
checkTeardownAction(1, "Action", "delete")
checkTeardownAction(1, "Device", subvol)
checkTeardownAction(2, "Location", subvol_mount_point)
checkTeardownAction(2, "Action", "unmount, delete")
self.confirm()

b.wait_not_present(self.card_row("Storage", location=subvol_mount_point))
b.wait_not_present(self.card_row("Storage", name=subvol))

# Delete with subvolume which is mounted and busy
self.click_dropdown(self.card_row("Storage", location=mount_point), "Create subvolume")
self.dialog({"name": subvol, "mount_point": subvol_mount_point})
b.wait_visible(self.card_row("Storage", location=subvol_mount_point))
sleep_pid = m.spawn(f"cd {subvol_mount_point}; sleep infinity", "sleep")

self.click_dropdown(self.card_row("Storage", location=subvol_mount_point), "Delete")
checkTeardownAction(1, "Location", subvol_mount_point)
checkTeardownAction(1, "Action", "unmount, delete")
b.wait_in_text(".modal-footer-teardown tbody:nth-of-type(1)", "Currently in use")
b.click(".modal-footer-teardown tbody:nth-of-type(1) button")
b.wait_in_text(".pf-v5-c-popover", str(sleep_pid))

self.confirm()
b.wait_not_present(self.card_row("Storage", location=subvol_mount_point))

# Cannot delete subvolume when no parent is mounted
subvol = "new-subvol"
self.click_dropdown(self.card_row("Storage", location=mount_point), "Create subvolume")
self.dialog({"name": subvol, "mount_point": subvol_mount_point})
self.click_dropdown(self.card_row("Storage", name=label) + " + tr", "Unmount")
self.confirm()

self.check_dropdown_action_disabled(self.card_row("Storage", location=subvol_mount_point), "Delete", "At least one parent needs to be mounted writable")
self.click_dropdown(self.card_row("Storage", name=label) + " + tr", "Mount")
self.confirm()
b.wait_visible(self.card_row("Storage", location=mount_point))

self.click_dropdown(self.card_row("Storage", location=subvol_mount_point), "Delete")
self.confirm()

# Cannot delete read only mounted subvolume children and itself
child_subvol = "child-subvol"
self.click_dropdown(self.card_row("Storage", location=mount_point), "Create subvolume")
self.dialog({"name": subvol, "mount_point": subvol_mount_point, "mount_options.ro": True})
b.wait_visible(self.card_row("Storage", location=subvol_mount_point))
self.assertIn("ro", m.execute(f"findmnt -s -n -o OPTIONS {subvol_mount_point}"))

self.click_dropdown(self.card_row("Storage", name=subvol), "Create subvolume")
self.dialog({"name": child_subvol}, secondary=True)
b.wait_visible(self.card_row("Storage", name=f"{subvol}/{child_subvol}"))

# Allowed as root is mounted
self.click_dropdown(self.card_row("Storage", name=f"{subvol}/{child_subvol}"), "Delete")
self.dialog_wait_open()
self.dialog_cancel()

# Unmount root as we can otherwise delete via root
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)"))

self.check_dropdown_action_disabled(self.card_row("Storage", name=f"{subvol}/{child_subvol}"), "Delete", "At least one parent needs to be mounted writable")

def testMultiDevice(self):
m = self.machine
b = self.browser
Expand Down

0 comments on commit ac54438

Please sign in to comment.