Line data Source code
1 : /*
2 : * Copyright (C) 2023 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 113 : import cockpit from "cockpit";
7 113 : import React from "react";
8 :
9 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
10 : import { CardBody, CardHeader } from "@patternfly/react-core/dist/esm/components/Card/index.js";
11 : import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
12 :
13 : import { dirname } from "cockpit-path";
14 :
15 : import {
16 : PageTable, StorageCard, StorageDescription, ChildrenTable,
17 : new_card, new_page, navigate_away_from_card, register_crossref, get_crossrefs,
18 : } from "../pages.jsx";
19 : import { StorageUsageBar } from "../storage-controls.jsx";
20 : import {
21 : encode_filename, decode_filename,
22 : get_fstab_config_with_client, reload_systemd,
23 : flatten, teardown_active_usage,
24 : } from "../utils.js";
25 : import { btrfs_usage, validate_subvolume_name, parse_subvol_from_options } from "./utils.jsx";
26 : import { at_boot_input, update_at_boot_input, mounting_dialog, mount_options } from "../filesystem/mounting-dialog.jsx";
27 : import {
28 : dialog_open, TextInput,
29 : TeardownMessage, init_teardown_usage,
30 : } from "../dialog.jsx";
31 : import { check_mismounted_fsys, MismountAlert } from "../filesystem/mismounting.jsx";
32 : import {
33 : is_mounted, is_valid_mount_point, mount_point_text, MountPoint, edit_mount_point
34 : } from "../filesystem/utils.jsx";
35 : import client, { btrfs_poll, btrfs_tool } from "../client.js";
36 :
37 113 : const _ = cockpit.gettext;
38 :
39 6 : function subvolume_unmount(volume, subvol, forced_options) {
40 6 : const block = client.blocks[volume.path];
41 6 : mounting_dialog(client, block, "unmount", forced_options, subvol);
42 6 : }
43 :
44 8 : function subvolume_mount(volume, subvol, forced_options) {
45 8 : const block = client.blocks[volume.path];
46 8 : mounting_dialog(client, block, "mount", forced_options, subvol);
47 8 : }
48 :
49 4 : function get_rw_mount_point(volume, subvol) {
50 4 : const mount_points = client.btrfs_mounts[volume.data.uuid];
51 3 : return mount_points?.[subvol.id]?.rw_mount_points?.[0];
52 4 : }
53 :
54 3 : function get_rw_mount_point_in_parent(volume, subvol) {
55 3 : const subvols = client.uuids_btrfs_subvols[volume.data.uuid];
56 :
57 3 : if (!subvols)
58 0 : return null;
59 :
60 3 : for (const p of subvols) {
61 3 : const has_parent_subvol = (p.pathname == "/" && subvol.pathname !== "/") ||
62 3 : (subvol.pathname.substring(0, p.pathname.length) == p.pathname &&
63 3 : subvol.pathname[p.pathname.length] == "/");
64 3 : const parent_rw_mp = get_rw_mount_point(volume, p);
65 2 : if (has_parent_subvol && parent_rw_mp) {
66 2 : if (p.pathname == "/") {
67 2 : return parent_rw_mp + "/" + subvol.pathname;
68 1 : } else {
69 1 : return parent_rw_mp + subvol.pathname.substring(p.pathname.length);
70 1 : }
71 2 : }
72 3 : }
73 3 : return null;
74 3 : }
75 :
76 4 : function set_mount_options(subvol, block, vals) {
77 4 : const mount_options = [];
78 4 : const mount_now = vals.variant != "nomount";
79 :
80 0 : if (!mount_now || vals.at_boot == "never") {
81 2 : mount_options.push("noauto");
82 2 : }
83 3 : if (vals.mount_options?.ro)
84 2 : mount_options.push("ro");
85 4 : if (vals.at_boot == "never")
86 0 : mount_options.push("x-cockpit-never-auto");
87 4 : if (vals.at_boot == "nofail")
88 3 : mount_options.push("nofail");
89 4 : if (vals.at_boot == "netdev")
90 0 : mount_options.push("_netdev");
91 :
92 0 : const name = (subvol.pathname == "/" ? vals.name : subvol.pathname + "/" + vals.name);
93 4 : mount_options.push("subvol=" + name);
94 3 : if (vals.mount_options?.extra)
95 0 : mount_options.push(vals.mount_options.extra);
96 :
97 4 : let mount_point = vals.mount_point;
98 4 : if (mount_point[0] != "/")
99 0 : mount_point = "/" + mount_point;
100 4 : mount_point = client.add_mount_point_prefix(mount_point);
101 :
102 4 : const config =
103 4 : ["fstab",
104 4 : {
105 4 : dir: { t: 'ay', v: encode_filename(mount_point) },
106 4 : type: { t: 'ay', v: encode_filename("btrfs") },
107 0 : opts: { t: 'ay', v: encode_filename(mount_options.join(",") || "defaults") },
108 4 : freq: { t: 'i', v: 0 },
109 4 : passno: { t: 'i', v: 0 },
110 4 : "track-parents": { t: 'b', v: true }
111 4 : }
112 4 : ];
113 :
114 4 : return block.AddConfigurationItem(config, {})
115 4 : .then(reload_systemd)
116 4 : .then(() => {
117 2 : if (mount_now) {
118 2 : return client.mount_at(block, mount_point);
119 2 : } else
120 2 : return Promise.resolve();
121 4 : });
122 4 : }
123 :
124 4 : function subvolume_create(volume, subvol) {
125 4 : const block = client.blocks[volume.path];
126 4 : const parent_dir = (get_rw_mount_point(volume, subvol) ||
127 3 : get_rw_mount_point_in_parent(volume, subvol));
128 :
129 4 : let action_variants = [
130 4 : { tag: null, Title: _("Create and mount") },
131 4 : { tag: "nomount", Title: _("Create only") }
132 4 : ];
133 :
134 1 : if (client.in_anaconda_mode()) {
135 1 : action_variants = [
136 1 : { tag: "nomount", Title: _("Create") }
137 1 : ];
138 1 : }
139 :
140 4 : dialog_open({
141 4 : Title: _("Create subvolume"),
142 4 : Fields: [
143 4 : TextInput("name", _("Name"),
144 4 : {
145 4 : validate: name => validate_subvolume_name(name)
146 4 : }),
147 4 : TextInput("mount_point", _("Mount Point"),
148 4 : {
149 4 : validate: (val, _values, variant) => {
150 4 : return is_valid_mount_point(client,
151 4 : block,
152 4 : client.add_mount_point_prefix(val),
153 4 : variant == "nomount");
154 4 : }
155 4 : }),
156 4 : mount_options(false, false),
157 4 : at_boot_input(),
158 4 : ],
159 4 : update: update_at_boot_input,
160 4 : Action: {
161 4 : Variants: action_variants,
162 4 : action: async function (vals) {
163 : // HACK: cannot use block_btrfs.CreateSubvolume as it always creates a subvolume relative to MountPoints[0] which
164 : // makes it impossible to handle a situation where we have multiple subvolumes mounted.
165 : // https://github.com/storaged-project/udisks/issues/1242
166 4 : if (parent_dir)
167 2 : await cockpit.spawn(["btrfs", "subvolume", "create", `${parent_dir}/${vals.name}`], { superuser: "require", err: "message" });
168 3 : else {
169 3 : await btrfs_tool(["do", volume.data.uuid,
170 3 : "btrfs", "subvolume", "create",
171 1 : subvol.pathname == "/" ? vals.name : subvol.pathname + "/" + vals.name
172 3 : ]);
173 3 : }
174 4 : await btrfs_poll();
175 4 : if (vals.mount_point !== "") {
176 4 : await set_mount_options(subvol, block, vals);
177 4 : }
178 4 : }
179 4 : }
180 4 : });
181 4 : }
182 :
183 1 : function subvolume_delete(volume, subvol, card) {
184 1 : const block = client.blocks[volume.path];
185 1 : const subvols = client.uuids_btrfs_subvols[volume.data.uuid];
186 1 : const mount_point_in_parent = get_rw_mount_point_in_parent(volume, subvol);
187 :
188 1 : function get_direct_subvol_children(subvol) {
189 1 : function is_direct_parent(sv) {
190 1 : return (sv.pathname.length > subvol.pathname.length &&
191 1 : sv.pathname.substring(0, subvol.pathname.length) == subvol.pathname &&
192 1 : sv.pathname[subvol.pathname.length] == "/" &&
193 1 : sv.pathname.substring(subvol.pathname.length + 1).indexOf("/") == -1);
194 1 : }
195 :
196 1 : return subvols.filter(is_direct_parent);
197 1 : }
198 :
199 1 : function get_subvol_children(subvol) {
200 : // The deepest nested children must come first
201 1 : const direct_children = get_direct_subvol_children(subvol);
202 1 : return flatten(direct_children.map(get_subvol_children)).concat(direct_children);
203 1 : }
204 :
205 1 : const all_subvols = get_subvol_children(subvol).concat([subvol]);
206 1 : const configs_to_remove = [];
207 1 : const paths_to_delete = [];
208 1 : const usage = [];
209 :
210 1 : usage.Teardown = true;
211 1 : for (const sv of all_subvols) {
212 1 : const [config, mount_point] = get_fstab_config_with_client(client, block, false, sv);
213 1 : const fs_is_mounted = is_mounted(client, block, sv);
214 :
215 1 : usage.push({
216 1 : level: 0,
217 1 : usage: fs_is_mounted ? 'mounted' : 'none',
218 1 : block,
219 1 : name: sv.pathname,
220 1 : location: mount_point,
221 1 : actions: fs_is_mounted ? [_("unmount"), _("delete")] : [_("delete")],
222 1 : blocking: false,
223 1 : });
224 :
225 1 : if (config)
226 1 : configs_to_remove.push(config);
227 :
228 1 : paths_to_delete.push(sv.pathname);
229 1 : }
230 :
231 1 : function move_to_parent(pathname) {
232 1 : return mount_point_in_parent + pathname.substring(subvol.pathname.length);
233 1 : }
234 :
235 1 : dialog_open({
236 1 : Title: cockpit.format(_("Permanently delete subvolume $0?"), subvol.pathname),
237 1 : Teardown: TeardownMessage(usage),
238 1 : Action: {
239 1 : Title: _("Delete"),
240 1 : Danger: _("Deleting erases all data on this subvolume and all its children."),
241 1 : action: async function () {
242 1 : await teardown_active_usage(client, usage);
243 1 : for (const c of configs_to_remove)
244 1 : await block.RemoveConfigurationItem(c, {});
245 1 : if (mount_point_in_parent) {
246 1 : await cockpit.spawn(["btrfs", "subvolume", "delete",
247 1 : ...paths_to_delete.map(move_to_parent)
248 1 : ],
249 1 : { superuser: "require", err: "message" });
250 1 : } else {
251 1 : await btrfs_tool(["do", volume.data.uuid,
252 1 : "btrfs", "subvolume", "delete", ...paths_to_delete]);
253 1 : }
254 1 : await btrfs_poll();
255 1 : navigate_away_from_card(card);
256 1 : }
257 1 : },
258 1 : Inits: [
259 1 : init_teardown_usage(client, usage)
260 1 : ]
261 1 : });
262 1 : }
263 :
264 103 : export function make_btrfs_subvolume_pages(parent, volume) {
265 103 : let subvols = client.uuids_btrfs_subvols[volume.data.uuid];
266 22 : if (!subvols) {
267 22 : const block = client.blocks[volume.path];
268 : /*
269 : * Try to show subvolumes based on fstab entries. We collect
270 : * all subvolumes that are mentioned in fstab entries so that
271 : * the user can at least mount those.
272 : *
273 : * The real subvolume data structure has "id" fields and
274 : * "parent" fields that refer to the ids to form a tree. We
275 : * want to do the same here, and we give fake ids to our fake
276 : * subvolumes for this reason. We don't store these fake ids
277 : * in the "id" field since we don't want them to be taken
278 : * seriously by the rest of the code.
279 : */
280 22 : let fake_id = 5;
281 22 : subvols = [{ pathname: "/", id: 5, fake_id: fake_id++ }];
282 22 : const subvols_by_pathname = { };
283 20 : for (const config of block.Configuration) {
284 20 : if (config[0] == "fstab") {
285 20 : const opts = config[1].opts;
286 20 : if (!opts)
287 20 : continue;
288 :
289 20 : const fstab_subvol = parse_subvol_from_options(decode_filename(opts.v));
290 :
291 13 : if (fstab_subvol && fstab_subvol.pathname && fstab_subvol.pathname !== "/") {
292 13 : fstab_subvol.fake_id = fake_id++;
293 13 : subvols_by_pathname[fstab_subvol.pathname] = fstab_subvol;
294 13 : subvols.push(fstab_subvol);
295 13 : }
296 20 : }
297 20 : }
298 :
299 : // Find parents
300 13 : for (const pn in subvols_by_pathname) {
301 13 : let dn = pn;
302 13 : while (true) {
303 13 : dn = dirname(dn);
304 13 : if (dn == "." || dn == "/") {
305 13 : subvols_by_pathname[pn].parent = 5;
306 13 : break;
307 13 : } else if (subvols_by_pathname[dn]) {
308 13 : subvols_by_pathname[pn].parent = subvols_by_pathname[dn].fake_id;
309 13 : break;
310 13 : }
311 13 : }
312 13 : }
313 22 : }
314 :
315 103 : const root = subvols.find(s => s.id == 5);
316 103 : if (root)
317 103 : make_btrfs_subvolume_page(parent, volume, root, "", subvols);
318 103 : }
319 :
320 103 : function make_btrfs_subvolume_page(parent, volume, subvol, path_prefix, subvols) {
321 103 : const actions = [];
322 :
323 103 : const use = btrfs_usage(client, volume);
324 103 : const block = client.blocks[volume.path];
325 103 : const block_fsys = client.blocks_fsys[volume.path];
326 103 : const fstab_config = get_fstab_config_with_client(client, block, false, subvol);
327 103 : const [, mount_point] = fstab_config;
328 103 : const mismount_warning = check_mismounted_fsys(block, block, fstab_config, subvol);
329 103 : const mounted = is_mounted(client, block, subvol);
330 103 : const mp_text = mount_point_text(mount_point, mounted);
331 103 : if (mp_text == null)
332 12 : return null;
333 103 : const forced_options = [`subvol=${subvol.pathname}`];
334 :
335 14 : if (client.in_anaconda_mode()) {
336 14 : actions.push({
337 14 : title: _("Edit mount point"),
338 0 : action: () => edit_mount_point(block, forced_options, subvol),
339 14 : });
340 14 : }
341 :
342 101 : if (mounted) {
343 101 : actions.push({
344 101 : title: _("Unmount"),
345 6 : action: () => subvolume_unmount(volume, subvol, forced_options),
346 101 : });
347 101 : } else if (!client.in_anaconda_mode()) {
348 101 : actions.push({
349 101 : title: _("Mount"),
350 8 : action: () => subvolume_mount(volume, subvol, forced_options),
351 101 : });
352 101 : }
353 :
354 : // If the filesystem is mounted anywhere, we know that we are
355 : // showing the real list of subvolumes. (Otherwise only those in
356 : // fstab are shown.) If so, we allow creating new ones and
357 : // deleting existing ones, because we know that those changes will
358 : // be reflected in the UI. However, we don't allow deleting the
359 : // last mounted subvolume, since that would also break the
360 : // subvolume listing.
361 : //
362 : // In Anaconda mode, however, we always have a proper list of
363 : // subvolumes, so we always allow creation and deletion.
364 :
365 103 : let create_excuse = "";
366 103 : let delete_excuse = "";
367 101 : if (!client.in_anaconda_mode()) {
368 101 : if (!block_fsys || block_fsys.MountPoints.length == 0)
369 20 : create_excuse = delete_excuse = _("At least one subvolume needs to be mounted");
370 101 : else if (block_fsys && block_fsys.MountPoints.length == 1 &&
371 21 : decode_filename(block_fsys.MountPoints[0]) == mount_point) {
372 21 : delete_excuse = _("The last mounted subvolume can not be deleted");
373 21 : }
374 101 : }
375 :
376 103 : actions.push({
377 103 : title: _("Create subvolume"),
378 103 : excuse: create_excuse,
379 4 : action: () => subvolume_create(volume, subvol),
380 103 : });
381 :
382 : // Don't show deletion for the root subvolume as it can never be deleted.
383 102 : if (subvol.id !== 5 && subvol.pathname !== "/")
384 102 : actions.push({
385 102 : danger: true,
386 102 : title: _("Delete"),
387 102 : excuse: delete_excuse,
388 1 : action: () => subvolume_delete(volume, subvol, card),
389 102 : });
390 :
391 102 : function strip_prefix(str, prefix) {
392 102 : if (str.startsWith(prefix))
393 101 : return str.slice(prefix.length);
394 : else
395 102 : return str;
396 102 : }
397 :
398 : // Show the hidden "root" of a btrfs filesystem as "top-level" as "/" can
399 : // be confused with the root filesystem.
400 : // https://btrfs.readthedocs.io/en/latest/Subvolumes.html
401 103 : function subvol_name(subvol, path_prefix) {
402 103 : if (subvol.id === 5) {
403 103 : return "top-level";
404 103 : }
405 102 : return strip_prefix(subvol.pathname, path_prefix);
406 103 : }
407 :
408 103 : let snapshot_origin = null;
409 14 : if (subvol.id !== 5 && subvol.parent_uuid !== null) {
410 14 : for (const sv of subvols) {
411 14 : if (sv.uuid === subvol.parent_uuid) {
412 14 : snapshot_origin = sv;
413 14 : break;
414 14 : }
415 14 : }
416 14 : }
417 :
418 103 : const card = new_card({
419 103 : title: _("btrfs subvolume"),
420 103 : next: null,
421 103 : page_location: ["btrfs", volume.data.uuid, subvol.pathname],
422 103 : page_name: subvol_name(subvol, path_prefix),
423 101 : page_size: mounted && <StorageUsageBar stats={use} short />,
424 103 : location: mp_text,
425 103 : component: BtrfsSubvolumeCard,
426 103 : has_warning: !!mismount_warning,
427 103 : props: { volume, subvol, snapshot_origin, mount_point, mismount_warning, block, fstab_config, forced_options },
428 103 : actions,
429 103 : });
430 :
431 102 : if (subvol.id !== 5 && subvol.parent_uuid !== null)
432 14 : register_crossref({
433 14 : key: subvol.parent_uuid,
434 14 : card,
435 12 : size: mounted && <StorageUsageBar stats={use} short />,
436 14 : });
437 :
438 103 : const page = new_page(parent, card);
439 103 : for (const sv of subvols) {
440 102 : if (sv.parent && (sv.parent === subvol.id || sv.parent === subvol.fake_id)) {
441 102 : make_btrfs_subvolume_page(page, volume, sv, subvol.pathname + "/", subvols);
442 102 : }
443 103 : }
444 103 : }
445 :
446 5 : const BtrfsSubvolumeCard = ({ card, volume, subvol, snapshot_origin, mismount_warning, block, fstab_config, forced_options }) => {
447 5 : const crossrefs = get_crossrefs(subvol.uuid);
448 :
449 5 : return (
450 5 : <StorageCard card={card} alert={mismount_warning &&
451 2 : <MismountAlert warning={mismount_warning}
452 2 : fstab_config={fstab_config}
453 2 : backing_block={block} content_block={block} subvol={subvol} />}>
454 5 : <CardBody>
455 5 : <DescriptionList className="pf-m-horizontal-on-sm">
456 0 : <StorageDescription title={_("Name")} value={subvol.id === 5 ? "top-level" : subvol.pathname} />
457 5 : <StorageDescription title={_("ID")} value={subvol.id} />
458 5 : {snapshot_origin !== null &&
459 1 : <StorageDescription title={_("Snapshot origin")}>
460 1 : <Button variant="link" isInline role="link"
461 1 : onClick={() => cockpit.location.go(["btrfs", volume.data.uuid, snapshot_origin.pathname])}>
462 1 : {snapshot_origin.pathname}
463 1 : </Button>
464 1 : </StorageDescription>
465 : }
466 5 : <StorageDescription title={_("Mount point")}>
467 5 : <MountPoint fstab_config={fstab_config}
468 5 : backing_block={block} content_block={block}
469 5 : forced_options={forced_options} subvol={subvol} />
470 5 : </StorageDescription>
471 5 : </DescriptionList>
472 5 : </CardBody>
473 5 : <ChildrenTable
474 5 : emptyCaption={_("No subvolumes")}
475 5 : aria-label={_("btrfs subvolumes")}
476 5 : page={card.page} />
477 5 : {crossrefs &&
478 1 : <>
479 1 : <CardHeader><strong>{_("Snapshots")}</strong></CardHeader>
480 1 : <PageTable
481 1 : emptyCaption={_("No snapshots found")}
482 1 : aria-label={_("snapshot")}
483 1 : crossrefs={crossrefs} />
484 1 : </>
485 : }
486 5 : </StorageCard>);
487 5 : };
|