LCOV - code coverage report
Current view: top level - pkg/storaged/btrfs - subvolume.jsx Coverage Total Hit
Test: cockpit Lines: 97.3 % 371 361
Test Date: 2026-07-17 07:33:01

            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           12 :                 if (dn == "." || dn == "/") {
     305           13 :                     subvols_by_pathname[pn].parent = 5;
     306           13 :                     break;
     307           12 :                 } else if (subvols_by_pathname[dn]) {
     308           12 :                     subvols_by_pathname[pn].parent = subvols_by_pathname[dn].fake_id;
     309           12 :                     break;
     310           12 :                 }
     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           11 :         return null;
     333          103 :     const forced_options = [`subvol=${subvol.pathname}`];
     334              : 
     335           13 :     if (client.in_anaconda_mode()) {
     336           13 :         actions.push({
     337           13 :             title: _("Edit mount point"),
     338            0 :             action: () => edit_mount_point(block, forced_options, subvol),
     339           13 :         });
     340           13 :     }
     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           11 :             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 : };
        

Generated by: LCOV version 2.0-1