From ce9953019353c06008efcb2826058eeaceea8178 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Tue, 19 Sep 2023 15:35:01 +0300 Subject: [PATCH] WIP --- doc/anaconda.md | 84 ++++++++++++++++++++++++++++++++ pkg/storaged/anaconda.jsx | 35 +++++++++++++ pkg/storaged/client.js | 66 +++++++++++++++++++++++++ pkg/storaged/content-views.jsx | 16 +++++- pkg/storaged/details.jsx | 9 ++++ pkg/storaged/dialog.jsx | 11 ++++- pkg/storaged/drives-panel.jsx | 3 +- pkg/storaged/format-dialog.jsx | 29 +++++++---- pkg/storaged/fsys-panel.jsx | 23 ++++++--- pkg/storaged/fsys-tab.jsx | 20 +++++--- pkg/storaged/overview.jsx | 67 ++++++++++++++++--------- pkg/storaged/side-panel.jsx | 5 +- pkg/storaged/stratis-details.jsx | 20 +++++--- pkg/storaged/utils.js | 12 +++-- 14 files changed, 339 insertions(+), 61 deletions(-) create mode 100644 doc/anaconda.md create mode 100644 pkg/storaged/anaconda.jsx diff --git a/doc/anaconda.md b/doc/anaconda.md new file mode 100644 index 00000000000..eea9cc67d32 --- /dev/null +++ b/doc/anaconda.md @@ -0,0 +1,84 @@ +Cockpit Storage in Anaconda Mode +================================ + +Anaconda (the OS Installer) can open the Cockpit "storaged" page for +advanced setup of the target storage devices. When this is done, +storaged is in a special "Anaconda mode" and behaves significantly +different. + +In essence, the storaged page restricts itself to working with the +target environment. It will hide the real root filesystem (on the USB +stick that the Live environment was booted from, say), but let the +user create a "fake" root filesystem on some block device. + +Entering Anaconda mode +---------------------- + +The "storaged" page is put into Anaconda mode by storing a +"cockpit_anaconda" item in its `window.localStorage`. The value +should be a JSON encoded object, the details of which are explained +below. + +Since both Anaconda and the storaged page are served from the same +origin, Anaconda can just execute something like this: + +``` + window.localStorage.setItem("cockpit_anaconda", + JSON.stringify({ + "mount_point_prefix": "/sysroot", + "ignore_devices": [ "/dev/sr0", "/dev/loop0" ] + })); + window.open("/cockpit/@localhost/storage/index.html", "storage-tab"); +``` + +Ignoring storage devices +------------------------ + +Anaconda needs to tell Cockpit which devices can not be used to +install the OS on. This is done with the "ignore_devices" entry, which +is a array of strings. + +``` +{ + "ignore_devices": [ "/dev/sda" ] +} +``` + +Entries in that array can refer to block devices, LVM2 volume groups +(/dev/vgroup-name/), and Stratis pools (/dev/stratis/pool-name/). + +Mount point prefix +------------------ + +Cockpit can be put into a kind of "chroot" environment by giving it a +mount point prefix like so: + +``` +{ + "mount_point_prefix": "/sysroot" +} +``` + +This works at the UI level: filesystems that have mount points outside +of "/sysroot" are hidden from the user, and when letting the user work +with mount points below "/sysroot", the "/sysroot" prefix is +omitted. So when the user says to create a filesystem on "/var", they +are actually creating one on "/sysroot/var". + +However, Cockpit (via UDisks2) will still write the new mount point +configuration into the real /etc/fstab (_not_ /sysroot/etc/fstab). + +In addition to that, Cockpit will also store the mount points in the +`"cockpit_mount_points"` item in `window.localStorage`, as a JSON +encoded object, for the benefit of Anaconda. + +This is a simple map from mount point to block device, like + +``` +{ + "/boot": "/dev/vda1", + "/": "/dev/vda2" +} +``` + +The mount points do not include the mount point prefix. diff --git a/pkg/storaged/anaconda.jsx b/pkg/storaged/anaconda.jsx new file mode 100644 index 00000000000..2c6b43c56ed --- /dev/null +++ b/pkg/storaged/anaconda.jsx @@ -0,0 +1,35 @@ +/* + * 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 { Alert, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; +import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js"; + +const _ = cockpit.gettext; + +export const AnacondaAdvice = ({ client }) => { + return ( + +

Anaconda will tell us here what is wrong with the current config.

+
+ ); +} diff --git a/pkg/storaged/client.js b/pkg/storaged/client.js index 2ce4cbdcc7a..0b9808b6d1b 100644 --- a/pkg/storaged/client.js +++ b/pkg/storaged/client.js @@ -479,6 +479,7 @@ function update_indices() { client.update = () => { update_indices(); client.path_warnings = find_warnings(client); + client.export_mount_point_mapping(); client.dispatchEvent("changed"); }; @@ -628,6 +629,15 @@ function init_model(callback) { } } + try { + client.anaconda = JSON.parse(window.localStorage.getItem("cockpit_anaconda")); + } catch { + console.warn("Can't parse cockpit_anaconda configuration as JSON"); + client.anaconda = null; + } + + console.log("ANACONDA", client.anaconda); + pull_time().then(() => { read_os_release().then(os_release => { client.os_release = os_release; @@ -1371,4 +1381,60 @@ client.get_config = (name, def) => { } }; +client.in_anaconda_mode = () => !!client.anaconda; + +client.strip_mount_point_prefix = (dir) => { + const mpp = client.anaconda?.mount_point_prefix; + + if (dir && mpp) { + if (dir.indexOf(mpp) != 0) + return false; + + dir = dir.substr(mpp.length); + if (dir == "") + dir = "/"; + } + + return dir; +} + +client.add_mount_point_prefix = (dir) => { + const mpp = client.anaconda?.mount_point_prefix; + if (mpp) { + if (dir == "/") + dir = mpp; + else + dir = mpp + dir; + } + return dir; +} + +client.should_ignore_device = (devname) => { + return client.anaconda?.ignore_devices && client.anaconda.ignore_devices.indexOf(devname) != -1; +}; + +client.should_ignore_block = (block) => { + return client.should_ignore_device(utils.decode_filename(block.PreferredDevice)); +}; + +client.export_mount_point_mapping = () => { + console.log("EXPORT"); + + const mpm = { }; + for (const p in client.blocks) { + const b = client.blocks[p]; + for (const c of b.Configuration) { + if (c[0] == "fstab") { + const dir = client.strip_mount_point_prefix(utils.decode_filename(c[1].dir.v)); + if (dir) { + console.log("MPM", utils.decode_filename(b.PreferredDevice), dir); + mpm[dir] = utils.decode_filename(b.PreferredDevice); + } + } + } + } + + window.localStorage.setItem("cockpit_mount_points", JSON.stringify(mpm)); +}; + export default client; diff --git a/pkg/storaged/content-views.jsx b/pkg/storaged/content-views.jsx index 23531db5c8e..ec4b2015d3f 100644 --- a/pkg/storaged/content-views.jsx +++ b/pkg/storaged/content-views.jsx @@ -467,7 +467,7 @@ function block_description(client, block, options) { type = cockpit.format(C_("storage-id-desc", "$0 filesystem"), block.IdType); if (client.fsys_sizes.data[mount_point]) size = client.fsys_sizes.data[mount_point]; - used_for = mount_point; + used_for = client.strip_mount_point_prefix(mount_point); } else if (block.IdUsage == "raid") { if (block_pvol && client.vgroups[block_pvol.VolumeGroup]) { const vgroup = client.vgroups[block_pvol.VolumeGroup]; @@ -559,6 +559,18 @@ function append_row(client, rows, level, key, name, desc, tabs, job_object, opti if (info) info = <>{"\n"}{info}; + let location; + if (desc.used_for === false) { + // XXX - urks + location = _("(Not part of target)"); + menu = null; + tabs.actions = null; + tabs.renderers = []; + } else if (desc.link) + location = ; + else + location = desc.used_for; + const cols = [ { title: ( @@ -568,7 +580,7 @@ function append_row(client, rows, level, key, name, desc, tabs, job_object, opti ) }, { title: desc.type }, - { title: desc.link ? : desc.used_for }, + { title: location }, { title: desc.size.length ? diff --git a/pkg/storaged/details.jsx b/pkg/storaged/details.jsx index a9723443afd..4116ce0a387 100644 --- a/pkg/storaged/details.jsx +++ b/pkg/storaged/details.jsx @@ -35,12 +35,21 @@ import { VDODetails } from "./vdo-details.jsx"; import { NFSDetails } from "./nfs-details.jsx"; import { StratisPoolDetails, StratisStoppedPoolDetails } from "./stratis-details.jsx"; import { JobsPanel } from "./jobs-panel.jsx"; +import { AnacondaAdvice } from "./anaconda.jsx"; const _ = cockpit.gettext; export const StdDetailsLayout = ({ client, alerts, header, content, sidebar }) => { const top = <> { (alerts || []).filter(a => !!a).map((a, i) => {a}) } + { client.in_anaconda_mode() + ? + + + + + : null + } { header } diff --git a/pkg/storaged/dialog.jsx b/pkg/storaged/dialog.jsx index 3bc707515d1..41f158e6321 100644 --- a/pkg/storaged/dialog.jsx +++ b/pkg/storaged/dialog.jsx @@ -1069,7 +1069,8 @@ export const BlockingMessage = (usage) => { pvol: _("physical volume of LVM2 volume group"), mdraid: _("member of RAID device"), vdo: _("backing device for VDO device"), - "stratis-pool-member": _("member of Stratis pool") + "stratis-pool-member": _("member of Stratis pool"), + mounted: _("Filesystem outside the target"), }; const rows = []; @@ -1151,9 +1152,15 @@ export const TeardownMessage = (usage) => { const name = (fsys ? fsys.Devnode : block_name(client.blocks[use.block.CryptoBackingDevice] || use.block)); + let location = use.location; + if (use.usage == "mounted") { + location = client.strip_mount_point_prefix(location); + if (location === false) + location = _("(Not part of target)"); + } rows.push({ columns: [name, - use.location || "-", + location || "-", use.actions.length ? use.actions.join(", ") : "-", { title: , diff --git a/pkg/storaged/drives-panel.jsx b/pkg/storaged/drives-panel.jsx index d60a887a739..a529c211614 100644 --- a/pkg/storaged/drives-panel.jsx +++ b/pkg/storaged/drives-panel.jsx @@ -105,7 +105,8 @@ export class DrivesPanel extends React.Component { const drives = drive_rows(client); return ( - { - // "nofail" is the default for new filesystems with Cockpit so - // that a failure to mount one of them will not prevent - // Cockpit from starting. This allows people to debug and fix - // these failures with Cockpit itself. - // + // "nofail" is the default for new filesystems with Cockpit so + // that a failure to mount one of them will not prevent + // Cockpit from starting. This allows people to debug and fix + // these failures with Cockpit itself. + // + // In Anaconda mode however, we don't make "nofail" the + // default since people will be creating the core filesystems + // like "/", "/var", etc. + + if (!client.in_anaconda_mode()) options.nofail = true; + utils.get_parent_blocks(client, block.path).forEach(p => { + if (utils.is_netdev(client, p)) { options._netdev = true; } @@ -160,10 +166,10 @@ export function format_dialog(client, path, start, size, enable_dos_extended) { return false; }) .then(version => { - format_dialog_internal(client, path, start, size, enable_dos_extended, version); + return format_dialog_internal(client, path, start, size, enable_dos_extended, version); }); } else { - format_dialog_internal(client, path, start, size, enable_dos_extended); + return format_dialog_internal(client, path, start, size, enable_dos_extended); } } @@ -260,6 +266,10 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, if (old_opts == undefined) old_opts = initial_mount_options(client, block); + old_dir = client.strip_mount_point_prefix(old_dir); + if (old_dir === false) + return Promise.reject(_("This device can not be used for the installation target.")); + const split_options = parse_options(old_opts); extract_option(split_options, "noauto"); const opt_ro = extract_option(split_options, "ro"); @@ -298,7 +308,7 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, value: old_dir || "", validate: (val, values, variant) => { if (variant !== "nomount") - return is_valid_mount_point(client, block, val); + return is_valid_mount_point(client, block, client.add_mount_point_prefix(val)); } }), SelectOne("type", _("Type"), @@ -492,6 +502,7 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, if (mount_point != "") { if (mount_point[0] != "/") mount_point = "/" + mount_point; + mount_point = client.add_mount_point_prefix(mount_point); config_items.push(["fstab", { dir: { t: 'ay', v: utils.encode_filename(mount_point) }, diff --git a/pkg/storaged/fsys-panel.jsx b/pkg/storaged/fsys-panel.jsx index fad062456c6..56e3d821570 100644 --- a/pkg/storaged/fsys-panel.jsx +++ b/pkg/storaged/fsys-panel.jsx @@ -49,6 +49,9 @@ export class FilesystemsPanel extends React.Component { function is_mount(path) { const block = client.blocks[path]; + if (client.should_ignore_block(block)) + return false; + // Stratis filesystems are handled separately if (client.blocks_stratis_fsys[path]) return false; @@ -72,7 +75,7 @@ export class FilesystemsPanel extends React.Component { function make_mount(path) { const block = client.blocks[path]; - const [, mount_point] = get_fstab_config(block, true); + let [, mount_point] = get_fstab_config(block, true); const fsys_size = client.fsys_sizes.data[mount_point]; const backing_block = client.blocks[block.CryptoBackingDevice] || block; const block_lvm2 = client.blocks_lvm2[backing_block.path]; @@ -80,6 +83,10 @@ export class FilesystemsPanel extends React.Component { const vgroup = lvol && client.vgroups[lvol.VolumeGroup]; let name = null; + mount_point = client.strip_mount_point_prefix(mount_point); + if (mount_point === false) + return null; + if (vgroup) name = vgroup.Name + "/" + lvol.Name; @@ -106,7 +113,7 @@ export class FilesystemsPanel extends React.Component { } const mounts = Object.keys(client.blocks).filter(is_mount) - .map(make_mount); + .map(make_mount).filter(m => m != null); function has_filesystems(path) { return client.stratis_pool_filesystems[path].length > 0; @@ -132,8 +139,11 @@ export class FilesystemsPanel extends React.Component { let mount = "-"; if (block) { const [, mp] = get_fstab_config(block, true); - if (mp) - mount = mp; + if (mp) { + mount = client.strip_mount_point_prefix(mp); + if (mount === false) + return null; + } } return { props: { path, client, key: fs.path }, @@ -152,11 +162,11 @@ export class FilesystemsPanel extends React.Component { } ] }; - }); + }).filter(m => m != null); } const pools = Object.keys(client.stratis_pools).filter(has_filesystems) - .map(make_pool); + .map(make_pool); function onRowClick(event, row) { if (!event || event.button !== 0) @@ -177,6 +187,7 @@ export class FilesystemsPanel extends React.Component { sortBy={{ index: 0, direction: SortByDirection.asc }} aria-label={_("Filesystems")} onRowClick={onRowClick} + emptyCaption={_("No filesystems")} columns={[ { title: _("Source"), sortable: true }, { title: _("Type"), sortable: true }, diff --git a/pkg/storaged/fsys-tab.jsx b/pkg/storaged/fsys-tab.jsx index bc247d98fb4..08585d38f35 100644 --- a/pkg/storaged/fsys-tab.jsx +++ b/pkg/storaged/fsys-tab.jsx @@ -170,6 +170,10 @@ export function mounting_dialog(client, block, mode, forced_options) { const [old_config, old_dir, old_opts, old_parents] = get_fstab_config(block, true); const options = old_config ? old_opts : initial_tab_options(client, block, true); + const old_dir_for_display = client.strip_mount_point_prefix(old_dir); + if (old_dir_for_display === false) + return Promise.reject(_("This device can not be used for the installation target.")); + const split_options = parse_options(options); extract_option(split_options, "noauto"); const opt_never_auto = extract_option(split_options, "x-cockpit-never-auto"); @@ -329,8 +333,8 @@ export function mounting_dialog(client, block, mode, forced_options) { fields = [ TextInput("mount_point", _("Mount point"), { - value: old_dir, - validate: val => is_valid_mount_point(client, block, val) + value: old_dir_for_display, + validate: val => is_valid_mount_point(client, block, client.add_mount_point_prefix(val)) }), CheckBoxes("mount_options", _("Mount options"), { @@ -460,7 +464,8 @@ export function mounting_dialog(client, block, mode, forced_options) { opts = opts.concat(forced_options); 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), + return (maybe_update_config(client.add_mount_point_prefix(vals.mount_point), + unparse_options(opts), vals.passphrase, passphrase_type) .then(() => maybe_set_crypto_options(vals.mount_options.ro, opts.indexOf("noauto") == -1, @@ -553,6 +558,7 @@ export class FilesystemTab extends React.Component { let mount_point_text = null; if (old_dir) { + mount_point_text = client.strip_mount_point_prefix(old_dir); let opt_texts = []; if (opt_ro) opt_texts.push(_("read only")); @@ -564,11 +570,13 @@ export class FilesystemTab extends React.Component { opt_texts.push(_("ignore failure")); else opt_texts.push(_("stop boot on failure")); + if (mount_point_text === false) { + mount_point_text = ""; + opt_texzs.push(_("not part of target")); + } opt_texts = opt_texts.concat(split_options); if (opt_texts.length) { - mount_point_text = cockpit.format("$0 ($1)", old_dir, opt_texts.join(", ")); - } else { - mount_point_text = old_dir; + mount_point_text = cockpit.format("$0 ($1)", mount_point_text, opt_texts.join(", ")); } } diff --git a/pkg/storaged/overview.jsx b/pkg/storaged/overview.jsx index eeab02bf1ce..04ebfff8e0c 100644 --- a/pkg/storaged/overview.jsx +++ b/pkg/storaged/overview.jsx @@ -22,7 +22,7 @@ import React from "react"; import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js"; import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; import { Card, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; -import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; +import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; import { StoragePlots } from "./plot.jsx"; @@ -36,35 +36,56 @@ import { OthersPanel } from "./others-panel.jsx"; import { JobsPanel } from "./jobs-panel.jsx"; import { StorageLogsPanel } from "./logs-panel.jsx"; +import { AnacondaAdvice } from "./anaconda.jsx"; export const Overview = ({ client, plot_state }) => { return ( - - - + + { client.in_anaconda_mode() + ? - - - + - - - - - - - - - - - - - - - - + + : null + } + + + + + { !client.in_anaconda_mode() + ? + + + + + : null + } + + + { !client.in_anaconda_mode() + ? <> + + + + + : null + } + + + + + + + + + + + + + ); diff --git a/pkg/storaged/side-panel.jsx b/pkg/storaged/side-panel.jsx index 93357f26e0c..558536ffa15 100644 --- a/pkg/storaged/side-panel.jsx +++ b/pkg/storaged/side-panel.jsx @@ -41,7 +41,8 @@ export class SidePanel extends React.Component { render() { let show_all_button = null; - let rows = this.props.rows.filter(row => !!row); + const client = this.props.client; + let rows = this.props.rows.filter(row => !!row && !(client && client.should_ignore_device(row.devname))); // Find new items for animations const current_keys = rows.map(row => row.key); @@ -83,7 +84,7 @@ export class SidePanel extends React.Component { feature={this.props.feature} not_installed_text={this.props.not_installed_text} install_title={this.props.install_title}> - { this.props.rows.length > 0 + { rows.length > 0 ? { children } diff --git a/pkg/storaged/stratis-details.jsx b/pkg/storaged/stratis-details.jsx index 52acdf13724..2e470cadd2d 100644 --- a/pkg/storaged/stratis-details.jsx +++ b/pkg/storaged/stratis-details.jsx @@ -120,6 +120,7 @@ function set_mount_options(client, path, vals, forced_options) { return Promise.resolve(); if (mount_point[0] != "/") mount_point = "/" + mount_point; + mount_point = client.add_mount_point_prefix(mount_point); const config = ["fstab", @@ -277,7 +278,7 @@ export function stratis_content_rows(client, pool, options) { }; } - const [, mount_point] = get_fstab_config(block); + let [, mount_point] = get_fstab_config(block); const fs_is_mounted = is_mounted(client, block); function mount() { @@ -345,7 +346,7 @@ export function stratis_content_rows(client, pool, options) { }), SelectOne("at_boot", _("At boot"), { - value: "nofail", + value: client.in_anaconda_mode()? "local" : "nofail", explanation: mount_explanation.nofail, choices: [ { @@ -428,7 +429,7 @@ export function stratis_content_rows(client, pool, options) { if (info) info = <>{"\n"}{info}; - const tabs = [ + let tabs = [ { name, renderer: FilesystemTab, @@ -441,7 +442,7 @@ export function stratis_content_rows(client, pool, options) { } ]; - const actions = []; + let actions = []; const menuitems = []; if (!fs_is_mounted) { @@ -455,6 +456,13 @@ export function stratis_content_rows(client, pool, options) { menuitems.push({_("Snapshot")}); menuitems.push({_("Delete")}); + mount_point = client.strip_mount_point_prefix(mount_point); + if (mount_point === false) { + mount_point = _("(Not part of target)"); + actions = null; + tabs = null; + } + const cols = [ { title: ( @@ -484,7 +492,7 @@ export function stratis_content_rows(client, pool, options) { return { props: { key: fsys.Name }, columns: cols, - expandedContent: + expandedContent: tabs ? : null }; } @@ -515,7 +523,7 @@ function create_fs(client, pool) { { validate: (val, values, variant) => { if (variant !== "nomount") - return is_valid_mount_point(client, null, val); + return is_valid_mount_point(client, null, client.add_mount_point_prefix(val)); } }), CheckBoxes("mount_options", _("Mount options"), diff --git a/pkg/storaged/utils.js b/pkg/storaged/utils.js index b94b0a38730..6936e792791 100644 --- a/pkg/storaged/utils.js +++ b/pkg/storaged/utils.js @@ -416,7 +416,9 @@ export function get_available_spaces(client) { !is_vdo_backing_dev() && !is_swap() && !block_ptable && - !(block_part && block_part.IsContainer)); + !(block_part && block_part.IsContainer) && + !is_snap(client, block) && + !client.should_ignore_device(decode_filename(block.PreferredDevice))); } function make(path) { @@ -488,7 +490,8 @@ export function get_other_devices(client) { block.Size > 0 && !client.legacy_vdo_overlay.find_by_block(block) && !client.blocks_stratis_fsys[block.path] && - !is_snap(client, block)); + !is_snap(client, block) && + !client.should_ignore_block(block)); }); } @@ -684,13 +687,14 @@ export function get_active_usage(client, path, top_action, child_action) { if (fsys && fsys.MountPoints.length > 0) { fsys.MountPoints.forEach(mp => { + const mpd = decode_filename(mp); usage.push({ level, usage: 'mounted', block, - location: decode_filename(mp), + location: mpd, actions: get_actions(_("unmount")), - blocking: false, + blocking: client.strip_mount_point_prefix(mpd) === false, }); }); } else if (mdraid) {