diff --git a/pkg/storaged/client.js b/pkg/storaged/client.js
index 2e409098379a..dc31ccc12153 100644
--- a/pkg/storaged/client.js
+++ b/pkg/storaged/client.js
@@ -331,6 +331,93 @@ function update_indices() {
client.lvols_pool_members[path].sort(function (a, b) { return a.Name.localeCompare(b.Name) });
}
+ function summarize_stripe(lv_size, segments) {
+ const pvs = { };
+ let total_size = 0;
+ for (const [, size, pv] of segments) {
+ if (!pvs[pv])
+ pvs[pv] = 0;
+ pvs[pv] += size;
+ total_size += size;
+ }
+ if (total_size < lv_size)
+ pvs["/"] = lv_size - total_size;
+ return pvs;
+ }
+
+ client.lvols_stripe_summary = { };
+ client.lvols_status = { };
+ for (path in client.lvols) {
+ const struct = client.lvols[path].Structure;
+ const lvol = client.lvols[path];
+
+ let summary;
+ let status = "";
+ if (lvol.Layout != "thin" && struct && struct.segments) {
+ summary = summarize_stripe(struct.size.v, struct.segments.v);
+ if (summary["/"])
+ status = "partial";
+ } else if (struct && struct.data && struct.metadata &&
+ (struct.data.v.length == struct.metadata.v.length || struct.metadata.v.length == 0)) {
+ summary = [];
+ const n_total = struct.data.v.length;
+ let n_missing = 0;
+ for (let i = 0; i < n_total; i++) {
+ const data_lv = struct.data.v[i];
+ const metadata_lv = struct.metadata.v[i] || { size: { v: 0 }, segments: { v: [] } };
+
+ if (!data_lv.segments || (metadata_lv && !metadata_lv.segments)) {
+ summary = undefined;
+ break;
+ }
+
+ const s = summarize_stripe(data_lv.size.v + metadata_lv.size.v,
+ data_lv.segments.v.concat(metadata_lv.segments.v));
+ if (s["/"])
+ n_missing += 1;
+
+ summary.push(s);
+ }
+ if (n_missing > 0) {
+ status = "partial";
+ if (lvol.Layout == "raid1") {
+ if (n_total - n_missing >= 1)
+ status = "degraded";
+ }
+ if (lvol.Layout == "raid10") {
+ // This is correct for two-way mirroring, which is
+ // the only setup supported by lvm2.
+ if (n_missing > n_total / 2) {
+ // More than half of the PVs are gone -> at
+ // least one mirror has definitely lost both
+ // halves.
+ status = "partial";
+ } else if (n_missing > 1) {
+ // Two or more PVs are lost -> one mirror
+ // might have lost both halves
+ status = "degraded-maybe-partial";
+ } else {
+ // Only one PV is missing -> no mirror has
+ // lost both halves.
+ status = "degraded";
+ }
+ }
+ if (lvol.Layout == "raid4" || lvol.Layout == "raid5") {
+ if (n_missing <= 1)
+ status = "degraded";
+ }
+ if (lvol.Layout == "raid6") {
+ if (n_missing <= 2)
+ status = "degraded";
+ }
+ }
+ }
+ if (summary) {
+ client.lvols_stripe_summary[path] = summary;
+ client.lvols_status[path] = status;
+ }
+ }
+
client.stratis_poolnames_pool = { };
for (path in client.stratis_pools) {
pool = client.stratis_pools[path];
diff --git a/pkg/storaged/content-views.jsx b/pkg/storaged/content-views.jsx
index 4a41564766c0..c88ec2865c67 100644
--- a/pkg/storaged/content-views.jsx
+++ b/pkg/storaged/content-views.jsx
@@ -19,8 +19,8 @@
import cockpit from "cockpit";
import {
- dialog_open, TextInput, PassInput, SelectOne, SizeSlider, CheckBoxes,
- BlockingMessage, TeardownMessage, Message,
+ dialog_open, TextInput, PassInput, SelectOne, SelectOneRadioVertical, SizeSlider, CheckBoxes,
+ SelectSpaces, BlockingMessage, TeardownMessage, Message,
init_active_usage_processes
} from "./dialog.jsx";
import * as utils from "./utils.js";
@@ -32,7 +32,6 @@ import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/inde
import {
DropdownSeparator
} from '@patternfly/react-core/dist/esm/deprecated/components/Dropdown/index.js';
-import { ExclamationTriangleIcon } from "@patternfly/react-icons";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { ListingTable } from "cockpit-components-table.jsx";
@@ -49,6 +48,7 @@ import { BlockVolTab, PoolVolTab, VDOPoolTab } from "./lvol-tabs.jsx";
import { PartitionTab } from "./part-tab.jsx";
import { SwapTab } from "./swap-tab.jsx";
import { UnrecognizedTab } from "./unrecognized-tab.jsx";
+import { warnings_icon } from "./warnings.jsx";
const _ = cockpit.gettext;
@@ -74,6 +74,15 @@ function next_default_logical_volume_name(client, vgroup, prefix) {
return name;
}
+export function pvs_to_spaces(client, pvs) {
+ return pvs.map(pvol => {
+ const block = client.blocks[pvol.path];
+ const parts = utils.get_block_link_parts(client, pvol.path);
+ const text = cockpit.format(parts.format, parts.link);
+ return { type: 'block', block, size: pvol.FreeSize, desc: text, pvol };
+ });
+}
+
function create_tabs(client, target, options) {
function endsWith(str, suffix) {
return str.indexOf(suffix, str.length - suffix.length) !== -1;
@@ -106,19 +115,29 @@ function create_tabs(client, target, options) {
let warnings = client.path_warnings[target.path] || [];
if (content_block)
warnings = warnings.concat(client.path_warnings[content_block.path] || []);
+ if (lvol)
+ warnings = warnings.concat(client.path_warnings[lvol.path] || []);
const tab_actions = [];
const tab_menu_actions = [];
const tab_menu_danger_actions = [];
function add_action(title, func) {
- tab_actions.push({title} );
- tab_menu_actions.push({ title, func, only_narrow: true });
+ if (tab_actions.length == 0) {
+ tab_actions.push({title} );
+ tab_menu_actions.push({ title, func, only_narrow: true });
+ } else {
+ add_menu_action(title, func);
+ }
}
function add_danger_action(title, func) {
- tab_actions.push({title} );
- tab_menu_danger_actions.push({ title, func, only_narrow: true });
+ if (tab_actions.length == 0) {
+ tab_actions.push({title} );
+ tab_menu_danger_actions.push({ title, func, only_narrow: true });
+ } else {
+ add_menu_danger_action(title, func);
+ }
}
function add_menu_action(title, func) {
@@ -136,7 +155,7 @@ function create_tabs(client, target, options) {
if (associated_warnings)
tab_warnings = warnings.filter(w => associated_warnings.indexOf(w.warning) >= 0);
if (tab_warnings.length > 0)
- name =
{name}
;
+ name = {warnings_icon(tab_warnings)} {name}
;
tabs.push(
{
name,
@@ -185,7 +204,7 @@ function create_tabs(client, target, options) {
add_tab(_("Pool"), PoolVolTab);
add_action(_("Create thin volume"), create_thin);
} else {
- add_tab(_("Volume"), BlockVolTab, false, ["unused-space"]);
+ add_tab(_("Volume"), BlockVolTab, false, ["unused-space", "partial-lvol"]);
if (client.vdo_vols[lvol.path])
add_tab(_("VDO pool"), VDOPoolTab);
@@ -294,7 +313,63 @@ function create_tabs(client, target, options) {
});
}
+ function repair() {
+ const vgroup = lvol && client.vgroups[lvol.VolumeGroup];
+ if (!vgroup)
+ return;
+
+ const summary = client.lvols_stripe_summary[lvol.path];
+ const missing = summary.reduce((sum, sub) => sum + (sub["/"] ?? 0), 0);
+
+ function usable(pvol) {
+ // must have some free space and not already used for a
+ // subvolume other than those that need to be repaired.
+ return pvol.FreeSize > 0 && !summary.some(sub => !sub["/"] && sub[pvol.path]);
+ }
+
+ const pvs_as_spaces = pvs_to_spaces(client, client.vgroups_pvols[vgroup.path].filter(usable));
+ const available = pvs_as_spaces.reduce((sum, spc) => sum + spc.size, 0);
+
+ if (available < missing) {
+ dialog_open({
+ Title: cockpit.format(_("Unable to repair logical volume $0"), lvol.Name),
+ Body: {cockpit.format(_("There is not enough space available that could be used for a repair. At least $0 are needed on physical volumes that are not already used for this logical volume."),
+ utils.fmt_size(missing))}
+ });
+ return;
+ }
+
+ function enough_space(pvs) {
+ const selected = pvs.reduce((sum, pv) => sum + pv.size, 0);
+ if (selected < missing)
+ return cockpit.format(_("An additional $0 must be selected"), utils.fmt_size(missing - selected));
+ }
+
+ dialog_open({
+ Title: cockpit.format(_("Repair logical volume $0"), lvol.Name),
+ Body: {cockpit.format(_("Select the physical volumes that should be used to repair the logical volume. At leat $0 are needed."),
+ utils.fmt_size(missing))}
,
+ Fields: [
+ SelectSpaces("pvs", _("Physical Volumes"),
+ {
+ spaces: pvs_as_spaces,
+ validate: enough_space
+ }),
+ ],
+ Action: {
+ Title: _("Repair"),
+ action: function (vals) {
+ return lvol.Repair(vals.pvs.map(spc => spc.block.path), { });
+ }
+ }
+ });
+ }
+
if (lvol) {
+ const status_code = client.lvols_status[lvol.path];
+ if (status_code == "degraded" || status_code == "degraded-maybe-partial")
+ add_action(_("Repair"), repair);
+
if (lvol.Type != "pool") {
if (lvol.Active) {
add_menu_action(_("Deactivate"), deactivate);
@@ -399,7 +474,7 @@ function create_tabs(client, target, options) {
actions: tab_actions,
menu_actions: tab_menu_actions,
menu_danger_actions: tab_menu_danger_actions,
- has_warnings: warnings.length > 0
+ warnings
};
}
@@ -521,8 +596,8 @@ function append_row(client, rows, level, key, name, desc, tabs, job_object, opti
let info = null;
if (job_object && client.path_jobs[job_object])
info = ;
- if (tabs.has_warnings)
- info = <>{info} >;
+ if (tabs.warnings.length > 0)
+ info = <>{info}{warnings_icon(tabs.warnings)}>;
if (info)
info = <>{"\n"}{info}>;
@@ -819,6 +894,10 @@ function create_logical_volume(client, vgroup) {
if (vgroup.FreeSize == 0)
return;
+ const pvs_as_spaces = pvs_to_spaces(client, client.vgroups_pvols[vgroup.path].filter(pvol => pvol.FreeSize > 0));
+
+ const can_do_layouts = !!vgroup.CreatePlainVolumeWithLayout && pvs_as_spaces.length > 1;
+
const purposes = [
{
value: "block",
@@ -830,12 +909,105 @@ function create_logical_volume(client, vgroup) {
*/
];
+ const layouts = [
+ {
+ value: "linear",
+ title: _("Linear"),
+ min_pvs: 1,
+ },
+ {
+ value: "raid0",
+ title: _("Striped (RAID 0)"),
+ min_pvs: 2,
+ },
+ {
+ value: "raid1",
+ title: _("Mirrored (RAID 1)"),
+ min_pvs: 2,
+ },
+ {
+ value: "raid10",
+ title: _("Striped and mirrored (RAID 10)"),
+ min_pvs: 4,
+ },
+ {
+ value: "raid5",
+ title: _("Distributed parity (RAID 5)"),
+ min_pvs: 3,
+ },
+ {
+ value: "raid6",
+ title: _("Double distributed parity (RAID 6)"),
+ min_pvs: 5,
+ }
+ ];
+
const vdo_package = client.get_config("vdo_package", null);
const need_vdo_install = vdo_package && !(client.features.lvm_create_vdo || client.features.legacy_vdo);
if (client.features.lvm_create_vdo || client.features.legacy_vdo || vdo_package)
purposes.push({ value: "vdo", title: _("VDO filesystem volume (compression/deduplication)") });
+ /* For layouts with redundancy, CreatePlainVolumeWithLayout will
+ * create as many subvolumes as there are selected PVs. This has
+ * the nice effect of making the calculation of the maximum size of
+ * such a volume trivial.
+ */
+
+ function max_size(vals) {
+ const layout = vals.layout;
+ const pvs = vals.pvs.map(s => s.pvol);
+ const n_pvs = pvs.length;
+ const sum = pvs.reduce((sum, pv) => sum + pv.FreeSize, 0);
+ const min = Math.min.apply(null, pvs.map(pv => pv.FreeSize));
+
+ function metasize(datasize) {
+ const default_regionsize = 2 * 1024 * 1024;
+ const regions = Math.ceil(datasize / default_regionsize);
+ const bytes = 2 * 4096 + Math.ceil(regions / 8);
+ return vgroup.ExtentSize * Math.ceil(bytes / vgroup.ExtentSize);
+ }
+
+ if (layout == "linear") {
+ return sum;
+ } else if (layout == "raid0" && n_pvs >= 2) {
+ return n_pvs * min;
+ } else if (layout == "raid1" && n_pvs >= 2) {
+ return min - metasize(min);
+ } else if (layout == "raid10" && n_pvs >= 4) {
+ return Math.floor(n_pvs / 2) * (min - metasize(min));
+ } else if ((layout == "raid4" || layout == "raid5") && n_pvs >= 3) {
+ return (n_pvs - 1) * (min - metasize(min));
+ } else if (layout == "raid6" && n_pvs >= 5) {
+ return (n_pvs - 2) * (min - metasize(min));
+ } else
+ return 0; // not-covered: internal error
+ }
+
+ const layout_descriptions = {
+ linear: _("Data will be stored on the selected physical volumes without any additional redundancy or performance improvements."),
+ raid0: _("Data will be stored on the selected physical volumes in an alternating fashion to improve performance. At least two volumes need to be selected."),
+ raid1: _("Data will be stored as two or more copies on the selected physical volumes, to improve reliability. At least two volumes need to be selected."),
+ raid10: _("Data will be stored as two copies and also in an alternating fashion on the selected physical volumes, to improve both reliability and performance. At least four volumes need to be selected."),
+ raid4: _("Data will be stored on the selected physical volumes so that one of them can be lost without affecting the data. At least three volumes need to be selected."),
+ raid5: _("Data will be stored on the selected physical volumes so that one of them can be lost without affecting the data. Data is also stored in an alternating fashion to improve performance. At least three volumes need to be selected."),
+ raid6: _("Data will be stored on the selected physical volumes so that up to two of them can be lost at the same time without affecting the data. Data is also stored in an alternating fashion to improve performance. At least five volumes need to be selected."),
+ };
+
+ function compute_layout_choices(pvs) {
+ return layouts.filter(l => l.min_pvs <= pvs.length);
+ }
+
+ for (const lay of layouts)
+ lay.disabled = pvs_as_spaces.length < lay.min_pvs;
+
+ function min_pvs_explanation(pvs, min) {
+ if (pvs.length <= min)
+ return cockpit.format(_("All $0 selected physical volumes are needed for the choosen layout."),
+ pvs.length);
+ return null;
+ }
+
dialog_open({
Title: _("Create logical volume"),
Fields: [
@@ -853,42 +1025,31 @@ function create_logical_volume(client, vgroup) {
{
visible: vals => vals.purpose === 'vdo' && need_vdo_install,
}),
-
- /* Not Implemented
- { SelectOne: "layout",
- Title: _("Layout"),
- Options: [
- { value: "linear", Title: _("Linear"),
- selected: true
- },
- { value: "striped", Title: _("Striped (RAID 0)"),
- enabled: raid_is_possible
- },
- { value: "raid1", Title: _("Mirrored (RAID 1)"),
- enabled: raid_is_possible
- },
- { value: "raid10", Title: _("Striped and mirrored (RAID 10)"),
- enabled: raid_is_possible
- },
- { value: "raid4", Title: _("With dedicated parity (RAID 4)"),
- enabled: raid_is_possible
- },
- { value: "raid5", Title: _("With distributed parity (RAID 5)"),
- enabled: raid_is_possible
- },
- { value: "raid6", Title: _("With double distributed parity (RAID 6)"),
- enabled: raid_is_possible
- }
- ],
- },
- */
+ SelectSpaces("pvs", _("Physical Volumes"),
+ {
+ spaces: pvs_as_spaces,
+ value: pvs_as_spaces,
+ visible: vals => can_do_layouts && vals.purpose === 'block',
+ min_selected: 1,
+ validate: (val, vals) => {
+ if (vals.layout == "raid10" && (vals.pvs.length % 2) !== 0)
+ return _("RAID10 needs an even number of physical volumes");
+ },
+ explanation: min_pvs_explanation(pvs_as_spaces, 1)
+ }),
+ SelectOneRadioVertical("layout", _("Layout"),
+ {
+ value: "linear",
+ choices: compute_layout_choices(pvs_as_spaces),
+ visible: vals => can_do_layouts && vals.purpose === 'block',
+ explanation: layout_descriptions.linear
+ }),
SizeSlider("size", _("Size"),
{
visible: vals => vals.purpose !== 'vdo',
max: vgroup.FreeSize,
round: vgroup.ExtentSize
}),
-
/* VDO parameters */
SizeSlider("vdo_psize", _("Size"),
{
@@ -928,12 +1089,42 @@ function create_logical_volume(client, vgroup) {
}
}),
],
+ update: (dlg, vals, trigger) => {
+ if (vals.purpose == 'block' && (trigger == "layout" || trigger == "pvs" || trigger == "purpose")) {
+ for (const lay of layouts) {
+ if (lay.value == vals.layout) {
+ dlg.set_options("pvs", {
+ min_selected: lay.min_pvs,
+ explanation: min_pvs_explanation(vals.pvs, lay.min_pvs)
+ });
+ }
+ }
+ dlg.set_options("layout",
+ {
+ choices: compute_layout_choices(vals.pvs),
+ explanation: layout_descriptions[vals.layout]
+ });
+ const max = max_size(vals);
+ const old_max = dlg.get_options("size").max;
+ if (vals.size > max || vals.size == old_max)
+ dlg.set_values({ size: max });
+ dlg.set_options("size", { max });
+ } else if (trigger == "purpose") {
+ dlg.set_options("size", { max: vgroup.FreeSize });
+ }
+ },
Action: {
Title: _("Create"),
action: (vals, progress) => {
- if (vals.purpose == "block")
- return vgroup.CreatePlainVolume(vals.name, vals.size, { });
- else if (vals.purpose == "pool")
+ if (vals.purpose == "block") {
+ if (!can_do_layouts)
+ return vgroup.CreatePlainVolume(vals.name, vals.size, { });
+ else {
+ return vgroup.CreatePlainVolumeWithLayout(vals.name, vals.size, vals.layout,
+ vals.pvs.map(spc => spc.block.path),
+ { });
+ }
+ } else if (vals.purpose == "pool")
return vgroup.CreateThinPoolVolume(vals.name, vals.size, { });
else if (vals.purpose == "vdo") {
return (need_vdo_install ? install_package(vdo_package, progress) : Promise.resolve())
@@ -972,7 +1163,11 @@ export class VGroup extends React.Component {
const vgroup = this.props.vgroup;
const client = this.props.client;
- const excuse = vgroup.FreeSize == 0 && _("No free space");
+ let excuse = null;
+ if (vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0)
+ excuse = _("New logical volumes can not be created while a volume group is missing physical volumes.");
+ else if (vgroup.FreeSize == 0)
+ excuse = _("No free space");
const new_volume_link = (
create_logical_volume(client, vgroup)}
diff --git a/pkg/storaged/dialog.jsx b/pkg/storaged/dialog.jsx
index ba072fca4fe8..26744df0564f 100644
--- a/pkg/storaged/dialog.jsx
+++ b/pkg/storaged/dialog.jsx
@@ -505,6 +505,14 @@ export const dialog_open = (def) => {
update();
},
+ get_options: (tag) => {
+ for (const f of fields) {
+ if (f.tag == tag) {
+ return f.options;
+ }
+ }
+ },
+
set_options: (tag, new_options) => {
fields.forEach(f => {
if (f.tag == tag) {
@@ -680,6 +688,28 @@ export const SelectOneRadio = (tag, title, options) => {
};
};
+export const SelectOneRadioVertical = (tag, title, options) => {
+ return {
+ tag,
+ title,
+ options,
+ initial_value: options.value || options.choices[0].value,
+ hasNoPaddingTop: true,
+
+ render: (val, change) => {
+ return (
+
+ { options.choices.map(c => (
+ change(c.value)} label={c.title} />))
+ }
+
+ );
+ }
+ };
+};
+
export const SelectRow = (tag, headers, options) => {
return {
tag,
@@ -720,8 +750,9 @@ export const SelectSpaces = (tag, title, options) => {
tag,
title,
options,
- initial_value: [],
+ initial_value: options.value || [],
hasNoPaddingTop: options.spaces.length == 0,
+
render: (val, change) => {
if (options.spaces.length === 0)
return {options.empty_warning} ;
@@ -746,6 +777,8 @@ export const SelectSpaces = (tag, title, options) => {
diff --git a/pkg/storaged/lvol-tabs.jsx b/pkg/storaged/lvol-tabs.jsx
index 0ffa303dc043..4c42368b1953 100644
--- a/pkg/storaged/lvol-tabs.jsx
+++ b/pkg/storaged/lvol-tabs.jsx
@@ -24,12 +24,23 @@ import React from "react";
import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { ExclamationTriangleIcon, ExclamationCircleIcon } from "@patternfly/react-icons";
import { StorageButton, StorageLink, StorageOnOff } from "./storage-controls.jsx";
import { dialog_open, TextInput } from "./dialog.jsx";
import { get_resize_info, grow_dialog, shrink_dialog } from "./resize.jsx";
+import { fmt_size } from "./utils.js";
const _ = cockpit.gettext;
+export function check_partial_lvols(client, path, enter_warning) {
+ if (client.lvols_status[path] && client.lvols_status[path] != "") {
+ enter_warning(path, {
+ warning: "partial-lvol",
+ danger: client.lvols_status[path] != "degraded"
+ });
+ }
+}
+
function lvol_rename(lvol) {
dialog_open({
Title: _("Rename logical volume"),
@@ -46,6 +57,122 @@ function lvol_rename(lvol) {
});
}
+const StructureDescription = ({ client, lvol }) => {
+ const vgroup = client.vgroups[lvol.VolumeGroup];
+ const pvs = (vgroup && client.vgroups_pvols[vgroup.path]) || [];
+
+ if (!lvol.Structure || pvs.length <= 1)
+ return null;
+
+ let status = null;
+ const status_code = client.lvols_status[lvol.path];
+ if (status_code == "partial") {
+ status = _("This logical volume has lost some of its physical volumes and can no longer be used. You need to delete it and create a new one to take its place.");
+ } else if (status_code == "degraded") {
+ status = _("This logical volume has lost some of its physical volumes but has not lost any data yet. You should repair it to restore its original redundancy.");
+ } else if (status_code == "degraded-maybe-partial") {
+ status = _("This logical volume has lost some of its physical volumes but might not have lost any data yet. You might be able to repair it.");
+ }
+
+ function nice_block_name(block) {
+ return utils.block_name(client.blocks[block.CryptoBackingDevice] || block);
+ }
+
+ function pvs_box(used, block_path) {
+ if (block_path != "/") {
+ const block = client.blocks[block_path];
+ return
+
+ {block ? nice_block_name(block).replace("/dev/", "") : "???"}
+
+
{fmt_size(used)}
+
;
+ } else {
+ return
+
+ { status_code == "degraded"
+ ?
+ :
+ }
+
+
{fmt_size(used)}
+
;
+ }
+ }
+
+ if (lvol.Layout == "linear") {
+ const pvs = client.lvols_stripe_summary[lvol.path];
+ if (!pvs)
+ return null;
+
+ const stripe = Object.keys(pvs).map((path, i) =>
+
+ {pvs_box(pvs[path], path)}
+ );
+
+ return (
+
+ {_("Physical volumes")}
+
+
+ {stripe}
+
+ {status}
+
+ );
+ }
+
+ function stripe_box(used, block_path) {
+ if (block_path != "/") {
+ const block = client.blocks[block_path];
+ return
+
+ {block ? nice_block_name(block).replace("/dev/", "") : "???"}
+
+
{fmt_size(used)}
+
;
+ } else {
+ return
+
+ { status_code == "degraded"
+ ?
+ :
+ }
+
+
{fmt_size(used)}
+
;
+ }
+ }
+
+ if (lvol.Layout == "mirror" || lvol.Layout.indexOf("raid") == 0) {
+ const summary = client.lvols_stripe_summary[lvol.path];
+ if (!summary)
+ return null;
+
+ const stripes = summary.map((pvs, i) =>
+
+ {Object.keys(pvs).map(path => stripe_box(pvs[path], path))}
+ );
+
+ return (
+ <>
+
+ {_("Stripes")}
+
+ {stripes}
+ {status}
+ {lvol.SyncRatio != 1.0
+ ? {cockpit.format(_("$0 synchronized"), lvol.SyncRatio * 100 + "%")}
+ : null}
+
+
+ >);
+ }
+
+ return null;
+};
+
export class BlockVolTab extends React.Component {
render() {
const self = this;
@@ -81,6 +208,17 @@ export class BlockVolTab extends React.Component {
return grow_dialog(client, lvol, info, unused_space);
}
+ const layout_desc = {
+ raid0: _("Striped (RAID 0)"),
+ raid1: _("Mirrored (RAID 1)"),
+ raid10: _("Striped and mirrored (RAID 10)"),
+ raid4: _("Dedicated parity (RAID 4)"),
+ raid5: _("Distributed parity (RAID 5)"),
+ raid6: _("Double distributed parity (RAID 6)")
+ };
+
+ const layout = this.props.lvol.Layout;
+
return (
@@ -93,6 +231,17 @@ export class BlockVolTab extends React.Component {
+ { (layout && layout != "linear") &&
+
+ {_("Layout")}
+
+
+ {layout_desc[layout] || layout}
+
+
+
+ }
+
{ !unused_space &&
{_("Size")}
diff --git a/pkg/storaged/resize.jsx b/pkg/storaged/resize.jsx
index c9919ab73738..ff2cb3401dfe 100644
--- a/pkg/storaged/resize.jsx
+++ b/pkg/storaged/resize.jsx
@@ -17,6 +17,7 @@
* along with Cockpit; If not, see .
*/
+import React from "react";
import cockpit from "cockpit";
import {
block_name, get_active_usage, teardown_active_usage,
@@ -27,14 +28,15 @@ import {
request_passphrase_on_error_handler
} from "./crypto-keyslots.jsx";
import {
- dialog_open, SizeSlider, BlockingMessage, TeardownMessage,
+ dialog_open, SizeSlider, BlockingMessage, TeardownMessage, SelectSpaces,
init_active_usage_processes
} from "./dialog.jsx";
import { std_reply } from "./stratis-utils.js";
+import { pvs_to_spaces } from "./content-views.jsx";
const _ = cockpit.gettext;
-function lvol_or_part_and_fsys_resize(client, lvol_or_part, size, offline, passphrase) {
+function lvol_or_part_and_fsys_resize(client, lvol_or_part, size, offline, passphrase, pvs) {
let fsys;
let crypto_overhead;
let vdo;
@@ -145,7 +147,7 @@ function lvol_or_part_and_fsys_resize(client, lvol_or_part, size, offline, passp
if (size != orig_size) {
// Both LogicalVolume and Partition have a Resize method
// with the same signature, so this will work on both.
- return lvol_or_part.Resize(size, { });
+ return lvol_or_part.Resize(size, { pvs: pvs ? { t: 'ao', v: pvs } : undefined });
} else
return Promise.resolve();
}
@@ -245,19 +247,74 @@ export function free_space_after_part(client, part) {
export function grow_dialog(client, lvol_or_part, info, to_fit) {
let title, block, name, orig_size, max_size, allow_infinite, round_size;
+ let has_subvols, subvols, pvs_as_spaces, initial_pvs;
+
+ function compute_max_size(spaces) {
+ const layout = lvol_or_part.Layout;
+ const pvs = spaces.map(s => s.pvol);
+ const n_pvs = pvs.length;
+ const sum = pvs.reduce((sum, pv) => sum + pv.FreeSize, 0);
+ const min = Math.min.apply(null, pvs.map(pv => pv.FreeSize));
+
+ if (!has_subvols) {
+ return sum;
+ } else if (layout == "raid0") {
+ return n_pvs * min;
+ } else if (layout == "raid1") {
+ return min;
+ } else if (layout == "raid10") {
+ return (n_pvs / 2) * min;
+ } else if ((layout == "raid4" || layout == "raid5")) {
+ return (n_pvs - 1) * min;
+ } else if (layout == "raid6") {
+ return (n_pvs - 2) * min;
+ } else
+ return 0; // not-covered: internal error
+ }
if (lvol_or_part.iface == "org.freedesktop.UDisks2.LogicalVolume") {
const vgroup = client.vgroups[lvol_or_part.VolumeGroup];
const pool = client.lvols[lvol_or_part.ThinPool];
+ pvs_as_spaces = pvs_to_spaces(client, client.vgroups_pvols[vgroup.path].filter(pvol => pvol.FreeSize > 0));
+ subvols = client.lvols_stripe_summary[lvol_or_part.path];
+ has_subvols = subvols && (lvol_or_part.Layout == "mirror" || lvol_or_part.Layout.indexOf("raid") == 0);
+
+ if (!has_subvols)
+ initial_pvs = pvs_as_spaces;
+ else {
+ initial_pvs = [];
+
+ // Step 1: Find the spaces that are already used for a
+ // subvolume. If a subvolume uses more than one, prefer the
+ // one with more available space.
+ for (const sv of subvols) {
+ let sel = null;
+ for (const p in sv) {
+ for (const spc of pvs_as_spaces)
+ if (spc.block.path == p && (!sel || sel.size < spc.size))
+ sel = spc;
+ }
+ if (sel)
+ initial_pvs.push(sel);
+ }
+
+ // Step 2: Select missing one randomly.
+ for (const pv of pvs_as_spaces) {
+ if (initial_pvs.indexOf(pv) == -1 && initial_pvs.length < subvols.length)
+ initial_pvs.push(pv);
+ }
+ }
+
title = _("Grow logical volume");
block = client.lvols_block[lvol_or_part.path];
name = lvol_or_part.Name;
orig_size = lvol_or_part.Size;
- max_size = pool ? pool.Size * 3 : lvol_or_part.Size + vgroup.FreeSize;
+ max_size = pool ? pool.Size * 3 : lvol_or_part.Size + compute_max_size(initial_pvs);
allow_infinite = !!pool;
round_size = vgroup.ExtentSize;
} else {
+ has_subvols = false;
title = _("Grow partition");
block = client.blocks[lvol_or_part.path];
name = block_name(block);
@@ -281,9 +338,22 @@ export function grow_dialog(client, lvol_or_part, info, to_fit) {
}
let grow_size;
- let size_fields = [];
+ const size_fields = [];
if (!to_fit) {
- size_fields = [
+ if ((has_subvols || lvol_or_part.Layout == "linear") && pvs_as_spaces.length > 1)
+ size_fields.push(
+ SelectSpaces("pvs", _("Physical Volumes"),
+ {
+ spaces: pvs_as_spaces,
+ value: initial_pvs,
+ min_selected: subvols.length,
+ validate: val => {
+ if (has_subvols && subvols.length != val.length)
+ return cockpit.format(_("Exactly $0 physical volumes must be selected"),
+ subvols.length);
+ }
+ }));
+ size_fields.push(
SizeSlider("size", _("Size"),
{
value: orig_size,
@@ -291,8 +361,7 @@ export function grow_dialog(client, lvol_or_part, info, to_fit) {
max: max_size,
allow_infinite,
round: round_size,
- })
- ];
+ }));
} else {
grow_size = block.Size;
}
@@ -302,14 +371,61 @@ export function grow_dialog(client, lvol_or_part, info, to_fit) {
if (block && block.IdType == "crypto_LUKS" && block.IdVersion == 2)
passphrase_fields = existing_passphrase_fields(_("Resizing an encrypted filesystem requires unlocking the disk. Please provide a current disk passphrase."));
+ function prepare_pvs(pvs) {
+ if (!pvs)
+ return pvs;
+
+ pvs = pvs.map(spc => spc.block.path);
+
+ if (!has_subvols)
+ return pvs;
+
+ const subvol_pvs = [];
+
+ // Step 1: Find PVs that are already used by a subvolume
+ subvols.forEach((sv, idx) => {
+ subvol_pvs[idx] = null;
+ for (const pv in sv) {
+ if (pvs.indexOf(pv) >= 0 && subvol_pvs.indexOf(pv) == -1) {
+ subvol_pvs[idx] = pv;
+ break;
+ }
+ }
+ });
+
+ // Step 2: Use the rest for the leftover subvolumes
+ subvols.forEach((sv, idx) => {
+ if (!subvol_pvs[idx]) {
+ for (const pv of pvs) {
+ if (subvol_pvs.indexOf(pv) == -1) {
+ subvol_pvs[idx] = pv;
+ break;
+ }
+ }
+ }
+ });
+
+ return subvol_pvs;
+ }
+
if (!usage.Teardown && size_fields.length + passphrase_fields.length === 0) {
- return lvol_or_part_and_fsys_resize(client, lvol_or_part, grow_size, info.grow_needs_unmount, null);
+ return lvol_or_part_and_fsys_resize(client, lvol_or_part, grow_size, info.grow_needs_unmount,
+ null, prepare_pvs(initial_pvs));
}
const dlg = dialog_open({
Title: title,
Teardown: TeardownMessage(usage),
+ Body: has_subvols && {cockpit.format(_("Exactly $0 physical volumes need to be selected, one for each stripe of the logical volume."), subvols.length)}
,
Fields: size_fields.concat(passphrase_fields),
+ update: (dlg, vals, trigger) => {
+ if (vals.pvs) {
+ const max = lvol_or_part.Size + compute_max_size(vals.pvs);
+ if (vals.size > max)
+ dlg.set_values({ size: max });
+ dlg.set_options("size", { max });
+ }
+ },
Action: {
Title: _("Grow"),
action: function (vals) {
@@ -318,7 +434,8 @@ export function grow_dialog(client, lvol_or_part, info, to_fit) {
return (lvol_or_part_and_fsys_resize(client, lvol_or_part,
to_fit ? grow_size : vals.size,
info.grow_needs_unmount,
- vals.passphrase || recovered_passphrase)
+ vals.passphrase || recovered_passphrase,
+ prepare_pvs(vals.pvs))
.then(() => undo_temporary_teardown(client, usage))
.catch(request_passphrase_on_error_handler(dlg, vals, recovered_passphrase, block)));
});
diff --git a/pkg/storaged/side-panel.jsx b/pkg/storaged/side-panel.jsx
index ece48fd54762..1bdb1684fc81 100644
--- a/pkg/storaged/side-panel.jsx
+++ b/pkg/storaged/side-panel.jsx
@@ -27,7 +27,7 @@ import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
import { EmptyState, EmptyStateBody, EmptyStateVariant } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
-import { ExclamationTriangleIcon } from '@patternfly/react-icons';
+import { warnings_icon } from "./warnings.jsx";
const _ = cockpit.gettext;
@@ -136,7 +136,7 @@ class SidePanelRow extends React.Component {
else if (client.path_jobs[job_path])
decoration = ;
else if (client.path_warnings[job_path])
- decoration = ;
+ decoration = warnings_icon(client.path_warnings[job_path]);
return (
0) {
+ remove_excuse = _("Physical volumes can not be removed while a volume group is missing physical volumes.");
+ } else if (pvols.length === 1) {
remove_excuse = _("The last physical volume of a volume group cannot be removed.");
} else if (pvol.FreeSize < pvol.Size) {
if (pvol.Size <= vgroup.FreeSize)
@@ -218,7 +221,10 @@ export class VGroupDetails extends React.Component {
- vgroup_rename(client, vgroup)}>{_("Rename")}
+ 0
+ ? _("A volume group with missing physical volumes can not be renamed.")
+ : null }
+ onClick={() => vgroup_rename(client, vgroup)}>{_("Rename")}
{ "\n" }
vgroup_delete(client, vgroup)}>{_("Delete")}
>
@@ -242,10 +248,78 @@ export class VGroupDetails extends React.Component {
);
+ function is_partial_linear_lvol(block) {
+ const lvm2 = client.blocks_lvm2[block.path];
+ const lvol = lvm2 && client.lvols[lvm2.LogicalVolume];
+ return lvol && lvol.Layout == "linear" && client.lvols_status[lvol.path] == "partial";
+ }
+
+ function remove_missing() {
+ /* Calling vgroup.RemoveMissingPhysicalVolumes will
+ implicitly delete all partial, linear logical volumes.
+ Instead of allowing this, we explicitly delete these
+ volumes before calling RemoveMissingPhysicalVolumes.
+ This allows us to kill processes that keep them busy
+ and remove their fstab entries.
+
+ RemoveMissingPhysicalVolumes leaves non-linear volumes
+ alone, even if they can't be repaired anymore. This is
+ a bit inconsistent, but *shrug*.
+ */
+
+ let usage = utils.get_active_usage(client, vgroup.path, _("delete"));
+ usage = usage.filter(u => u.block && is_partial_linear_lvol(u.block));
+
+ if (usage.Blocking) {
+ dialog_open({
+ Title: cockpit.format(_("$0 is in use"),
+ vgroup.Name),
+ Body: BlockingMessage(usage)
+ });
+ return;
+ }
+
+ dialog_open({
+ Title: _("Remove missing physical volumes?"),
+ Teardown: TeardownMessage(usage),
+ Action: {
+ Title: _("Remove"),
+ action: function () {
+ return utils.teardown_active_usage(client, usage)
+ .then(function () {
+ return utils.for_each_async(usage,
+ u => {
+ const lvm2 = client.blocks_lvm2[u.block.path];
+ const lvol = lvm2 && client.lvols[lvm2.LogicalVolume];
+ return lvol.Delete({ 'tear-down': { t: 'b', v: true } });
+ })
+ .then(() => vgroup.RemoveMissingPhysicalVolumes({}));
+ });
+ }
+ },
+ Inits: [
+ init_active_usage_processes(client, usage)
+ ]
+ });
+ }
+
+ let alert = null;
+ if (vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0)
+ alert = (
+ {_("Dismiss")}}
+ title={_("This volume group is missing some physical volumes.")}>
+ {vgroup.MissingPhysicalVolumes.map(uuid => {uuid}
)}
+ );
+
const sidebar = ;
const content = ;
- return ;
+ return ;
}
}
diff --git a/pkg/storaged/warnings.jsx b/pkg/storaged/warnings.jsx
index 3e50bd1cfe2a..bb8795257cb7 100644
--- a/pkg/storaged/warnings.jsx
+++ b/pkg/storaged/warnings.jsx
@@ -17,9 +17,13 @@
* along with Cockpit; If not, see .
*/
+import React from "react";
+import { ExclamationTriangleIcon, ExclamationCircleIcon } from "@patternfly/react-icons";
+
import { get_parent } from "./utils.js";
import { check_mismounted_fsys } from "./fsys-tab.jsx";
import { check_stratis_warnings } from "./stratis-details.jsx";
+import { check_partial_lvols } from "./lvol-tabs.jsx";
export function find_warnings(client) {
const path_warnings = { };
@@ -116,5 +120,16 @@ export function find_warnings(client) {
check_stratis_warnings(client, enter_warning);
+ for (const path in client.lvols) {
+ check_partial_lvols(client, path, enter_warning);
+ }
+
return path_warnings;
}
+
+export function warnings_icon(warnings) {
+ if (warnings.some(w => w.danger))
+ return ;
+ else
+ return ;
+}
diff --git a/test/common/storagelib.py b/test/common/storagelib.py
index c8e178d11a6b..4b45a6933dce 100644
--- a/test/common/storagelib.py
+++ b/test/common/storagelib.py
@@ -92,6 +92,28 @@ def add_loopback_disk(self, size=50, name=None):
self.addCleanup(self.machine.execute, f"umount {dev} || true; rm $(losetup -n -O BACK-FILE -l {dev}); until losetup -d {dev}; do sleep 1; done", timeout=10)
return dev
+ def add_targetd_loopback_disk(self, index, size=50):
+ """Add per-test loopback device that can be forcefully removed.
+ """
+
+ m = self.machine
+ model = f"disk{index}"
+ wwn = f"naa.5000{index:012x}"
+
+ m.execute(f"rm -f /var/tmp/targetd.{model}")
+ m.execute(f"targetcli /backstores/fileio create name={model} size={size}M file_or_dev=/var/tmp/targetd.{model}")
+ m.execute(f"targetcli /loopback create {wwn}")
+ m.execute(f"targetcli /loopback/{wwn}/luns create /backstores/fileio/{model}")
+
+ self.addCleanup(m.execute, f"targetcli /loopback delete {wwn}")
+ self.addCleanup(m.execute, f"targetcli /backstores/fileio delete {model}")
+ self.addCleanup(m.execute, f"rm -f /var/tmp/targetd.{model}")
+
+ dev = m.execute(f'for dev in /sys/block/*; do if [ -f $dev/device/model ] && [ "$(cat $dev/device/model | tr -d [:space:])" == "{model}" ]; then echo /dev/$(basename $dev); fi; done').strip()
+ if dev == "":
+ raise Error("Device not found")
+ return dev
+
def force_remove_disk(self, device):
"""Act like the given device gets physically removed.
diff --git a/test/verify/check-storage-lvm2 b/test/verify/check-storage-lvm2
index 75da748797ac..308d58a86400 100755
--- a/test/verify/check-storage-lvm2
+++ b/test/verify/check-storage-lvm2
@@ -17,13 +17,29 @@
# You should have received a copy of the GNU Lesser General Public License
# along with Cockpit; If not, see .
+import math
+import os
+import unittest
+
import storagelib
import testlib
+# We use basename a lot, let's reduce the noise
+def bn(path):
+ return os.path.basename(path)
+
+
@testlib.nondestructive
class TestStorageLvm2(storagelib.StorageCase):
+ def can_do_layouts(self):
+ return self.storaged_version >= [2, 10]
+
+ def skip_if_no_layouts(self):
+ if not self.can_do_layouts():
+ raise unittest.SkipTest("raid layouts not supported")
+
def testLvm(self):
m = self.machine
b = self.browser
@@ -143,6 +159,10 @@ class TestStorageLvm2(storagelib.StorageCase):
self.dialog({"name": "lvol1"})
self.content_row_wait_in_col(1, 1, "lvol1")
+ if self.can_do_layouts():
+ # check that it is stored on dev_2
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", bn(dev_2))
+
# grow it
self.content_tab_action(1, 1, "Grow")
self.dialog({"size": 30})
@@ -340,6 +360,530 @@ class TestStorageLvm2(storagelib.StorageCase):
# FIXME: Grow/Shrink buttons are in a dd > div > dd without dl wrapper
self.allow_browser_errors("validateDOMNesting.*cannot appear as a descendant.* dd")
+ def testRaid(self):
+ m = self.machine
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_ram_disk()
+ disk2 = self.add_loopback_disk()
+ disk3 = self.add_loopback_disk()
+ disk4 = self.add_loopback_disk()
+
+ # Make a volume group with four physical volumes
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_is_present('disks', disk4) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True,
+ disk4: True}})
+
+ self.addCleanup(m.execute, "vgremove --force vgroup0 2>/dev/null || true")
+ b.wait_in_text("#devices", "vgroup0")
+
+ # Make a raid5 on three PVs, using about half of each
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+ b.click("button:contains(Create new logical volume)")
+ self.dialog_wait_open()
+ self.dialog_wait_val("name", "lvol0")
+ self.dialog_set_val("purpose", "block")
+ self.dialog_set_val("layout", "raid5")
+ self.dialog_set_val("pvs", {disk1: False})
+ self.dialog_set_val("size", 40)
+ b.assert_pixels("#dialog", "create-raid5")
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ self.content_row_wait_in_col(1, 1, "lvol0")
+
+ self.content_tab_wait_in_info(1, 1, "Layout", "Distributed parity (RAID 5)")
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk2))
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk3))
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk4))
+
+ tab = self.content_tab_expand(1, 1)
+ b.assert_pixels(tab, "raid5-tab")
+
+ # Make linear volume to fully use second PV
+
+ b.click("button:contains(Create new logical volume)")
+ self.dialog(expect={"name": "lvol1"},
+ values={"purpose": "block",
+ "layout": "linear",
+ "pvs": {disk1: False, disk3: False, disk4: False}})
+ self.content_row_wait_in_col(2, 1, "lvol1")
+ self.content_tab_wait_in_info(2, 1, "Physical volumes", bn(disk2))
+
+ # Grow raid5 to about maximum
+ self.content_tab_action(1, 1, "Grow")
+ self.dialog_wait_open()
+ self.dialog_set_val("size", 80)
+ b.assert_pixels("#dialog", "grow-raid5")
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk1))
+
+ # Check that each PV is used exactly once.
+ tab = self.content_tab_expand(1, 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk2)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk3)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk4)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk1)}')", 1)
+ b.assert_pixels(tab, "raid5-tab2")
+
+ def testRaidRepair(self):
+ m = self.machine
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_ram_disk()
+ disk2 = self.add_loopback_disk()
+ disk3 = self.add_loopback_disk()
+ disk4 = self.add_loopback_disk()
+
+ # Make a volume group with three physical volumes
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True}})
+
+ self.addCleanup(m.execute, "vgremove --force vgroup0 2>/dev/null || true")
+ b.wait_in_text("#devices", "vgroup0")
+
+ # Make a raid5 on the three PVs
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+ b.click("button:contains(Create new logical volume)")
+ self.dialog(expect={"name": "lvol0"},
+ values={"purpose": "block",
+ "layout": "raid5"})
+ self.content_row_wait_in_col(1, 1, "lvol0")
+
+ self.content_tab_wait_in_info(1, 1, "Layout", "Distributed parity (RAID 5)")
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk1))
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk2))
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk3))
+
+ # Kill one PV
+
+ self.force_remove_disk(disk1)
+
+ b.wait_in_text(".pf-v5-c-alert", "This volume group is missing some physical volumes.")
+ b.wait_visible("button:contains(Rename):disabled")
+
+ self.content_tab_wait_in_info(1, 1, "Stripes", "This logical volume has lost some of its physical volumes but has not lost any data yet.")
+ self.content_tab_wait_in_info(1, 1, "Stripes", cond=lambda sel: bn(disk1) not in b.text(sel))
+
+ # Repair with fourth
+
+ self.content_row_action(1, "Repair")
+ self.dialog_wait_open()
+ b.wait_in_text("#dialog", "There is not enough space available")
+ self.dialog_cancel()
+ self.dialog_wait_close()
+
+ b.click('#detail-sidebar .pf-v5-c-card__header button')
+ self.dialog_wait_open()
+ self.dialog_set_val('disks', {disk4: True})
+ self.dialog_apply()
+ self.dialog_wait_close()
+ b.wait_in_text("#detail-sidebar", disk4)
+
+ self.content_row_action(1, "Repair")
+ self.dialog_wait_open()
+ self.dialog_apply()
+ self.dialog_wait_error("pvs", "An additional 46.1 MB must be selected")
+ self.dialog_set_val("pvs", {disk4: True})
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ self.content_tab_wait_in_info(1, 1, "Stripes",
+ cond=lambda sel: "This logical volume has lost some" not in b.text(sel))
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk4))
+
+ # Dismiss alert
+
+ b.click(".pf-v5-c-alert button:contains(Dismiss)")
+ self.dialog({})
+ b.wait_not_present(".pf-v5-c-alert")
+
+ def testBrokenLinear(self):
+ m = self.machine
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_ram_disk()
+ disk2 = self.add_loopback_disk()
+ disk3 = self.add_loopback_disk()
+
+ # Make a volume group with three physical volumes
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True}})
+
+ self.addCleanup(m.execute, "vgremove --force vgroup0 2>/dev/null || true")
+ b.wait_in_text("#devices", "vgroup0")
+
+ # Make a linear volume on two of them
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+ b.click("button:contains(Create new logical volume)")
+ self.dialog(expect={"name": "lvol0"},
+ values={"purpose": "block",
+ "layout": "linear",
+ "pvs": {disk2: False}})
+ self.content_row_wait_in_col(1, 1, "lvol0")
+
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", bn(disk1))
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", bn(disk3))
+
+ # Kill one PV
+
+ self.force_remove_disk(disk1)
+ b.wait_in_text(".pf-v5-c-alert", "This volume group is missing some physical volumes.")
+ b.wait_visible("button:contains(Rename):disabled")
+
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", "This logical volume has lost some of its physical volumes and can no longer be used.")
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", cond=lambda sel: bn(disk1) not in b.text(sel))
+
+ # Dismiss alert, this will delete the volume
+
+ b.click(".pf-v5-c-alert button:contains(Dismiss)")
+ self.dialog_wait_open()
+ b.wait_in_text("#dialog", "/dev/vgroup0/lvol0")
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ b.wait_not_present(".pf-v5-c-alert")
+ b.wait_in_text("#detail-content", "No logical volumes")
+
+ def testMaxLayoutSizes(self):
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ # Make sure that Cockpit gets the computation of the maximum
+ # size right.
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_loopback_disk()
+ disk2 = self.add_loopback_disk()
+ disk3 = self.add_loopback_disk()
+ disk4 = self.add_loopback_disk()
+ disk5 = self.add_loopback_disk()
+ disk6 = self.add_loopback_disk()
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_is_present('disks', disk4) and
+ self.dialog_is_present('disks', disk5) and
+ self.dialog_is_present('disks', disk6) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True,
+ disk4: True,
+ disk5: True,
+ disk6: True}})
+ self.addCleanup(self.machine.execute, "vgremove --force vgroup0 2>/dev/null || true")
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+
+ def mb(size):
+ if size < 100e6:
+ return str(round(size / 1e6, 1)) + " MB"
+ else:
+ return str(round(size / 1e6)) + " MB"
+
+ def test(layout, expected_size):
+ b.click("button:contains(Create new logical volume)")
+ self.dialog_wait_open()
+ self.dialog_wait_val("name", "lvol0")
+ self.dialog_set_val("purpose", "block")
+ self.dialog_set_val("layout", layout)
+ if layout == "raid10":
+ self.dialog_set_val("pvs", {disk6: False})
+ self.dialog_apply()
+ self.dialog_wait_error("pvs", "an even number")
+ self.dialog_set_val("pvs", {disk6: True})
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ self.content_row_wait_in_col(1, 1, "lvol0")
+
+ field = "Physical volumes" if layout.startswith("linear") else "Stripes"
+
+ self.content_tab_wait_in_info(1, 1, field, bn(disk1))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk2))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk3))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk4))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk5))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk6))
+
+ self.content_tab_wait_in_info(1, 1, "Size", mb(expected_size))
+
+ self.content_dropdown_action(1, "Delete")
+ self.confirm()
+ b.wait_not_in_text("#detail-content", "/dev/vgroup0/lvol0")
+
+ ext_size = 4 * 1024 * 1024
+ pv_size = math.floor(50e6 / ext_size) * ext_size
+ n_pvs = 6
+
+ test("linear", n_pvs * pv_size)
+ test("raid0", n_pvs * pv_size)
+ test("raid1", pv_size - ext_size)
+ test("raid10", (n_pvs / 2) * (pv_size - ext_size))
+ test("raid5", (n_pvs - 1) * (pv_size - ext_size))
+ test("raid6", (n_pvs - 2) * (pv_size - ext_size))
+
+ def testMaxLayoutGrowth(self):
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ # Make sure that Cockpit gets the computation of the maximum
+ # size right when growing a logical volume.
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_loopback_disk()
+ disk2 = self.add_loopback_disk()
+ disk3 = self.add_loopback_disk()
+ disk4 = self.add_loopback_disk()
+ disk5 = self.add_loopback_disk()
+ disk6 = self.add_loopback_disk()
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_is_present('disks', disk4) and
+ self.dialog_is_present('disks', disk5) and
+ self.dialog_is_present('disks', disk6) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True,
+ disk4: True,
+ disk5: True,
+ disk6: True}})
+ self.addCleanup(self.machine.execute, "vgremove --force vgroup0 2>/dev/null || true")
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+
+ def mb(size):
+ if size < 100e6:
+ return str(round(size / 1e6, 1)) + " MB"
+ else:
+ return str(round(size / 1e6)) + " MB"
+
+ def test(layout, expected_size):
+ b.click("button:contains(Create new logical volume)")
+ self.dialog(expect={"name": "lvol0"},
+ values={"purpose": "block",
+ "layout": layout,
+ "size": round(expected_size / 2e6)})
+
+ self.content_row_wait_in_col(1, 1, "lvol0")
+
+ field = "Stripes"
+
+ self.content_tab_wait_in_info(1, 1, field, bn(disk1))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk2))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk3))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk4))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk5))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk6))
+
+ # Grow to max with default pvs
+ self.content_tab_action(1, 1, "Grow")
+ self.dialog_wait_open()
+ slider = self.dialog_field("size") + " .pf-v5-c-slider .pf-v5-c-slider__rail"
+ width = b.call_js_func('(function (sel) { return ph_find(sel).offsetWidth; })', slider)
+ b.mouse(slider, "click", width, 0)
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ tab = self.content_tab_expand(1, 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk1)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk2)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk3)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk4)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk5)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk6)}')", 1)
+
+ self.content_tab_wait_in_info(1, 1, "Size", mb(expected_size))
+
+ self.content_dropdown_action(1, "Delete")
+ self.confirm()
+ b.wait_not_in_text("#detail-content", "/dev/vgroup0/lvol0")
+
+ ext_size = 4 * 1024 * 1024
+ pv_size = math.floor(50e6 / ext_size) * ext_size
+ n_pvs = 6
+
+ test("raid0", n_pvs * pv_size)
+ test("raid1", pv_size - ext_size)
+ test("raid10", (n_pvs / 2) * (pv_size - ext_size))
+ test("raid5", (n_pvs - 1) * (pv_size - ext_size))
+ test("raid6", (n_pvs - 2) * (pv_size - ext_size))
+
+ @testlib.skipImage("No targetd in Arch Linux", "arch")
+ def testDegradation(self):
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ # Make one (very small) logical volume for each RAID type and
+ # then break them.
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_targetd_loopback_disk(index=1, size=100)
+ disk2 = self.add_targetd_loopback_disk(index=2, size=100)
+ disk3 = self.add_targetd_loopback_disk(index=3, size=100)
+ disk4 = self.add_targetd_loopback_disk(index=4, size=100)
+ disk5 = self.add_targetd_loopback_disk(index=5, size=100)
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_is_present('disks', disk4) and
+ self.dialog_is_present('disks', disk5) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True,
+ disk4: True,
+ disk5: True}})
+ self.addCleanup(self.machine.execute, "vgremove --force vgroup0 2>/dev/null || true")
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+
+ def create(row, layout, expected_name):
+ b.click("button:contains(Create new logical volume)")
+ self.dialog_wait_open()
+ self.dialog_wait_val("name", expected_name)
+ self.dialog_set_val("layout", layout)
+ if layout == "raid10":
+ self.dialog_set_val("pvs", {disk5: False})
+ elif layout == "linear":
+ self.dialog_set_val("pvs", {disk2: False, disk3: False, disk4: False, disk5: False})
+ self.dialog_set_val("size", 10)
+ self.dialog_apply()
+ self.dialog_wait_close()
+ self.content_row_wait_in_col(row, 1, expected_name)
+
+ create(1, "linear", "lvol0")
+ create(2, "raid0", "lvol1")
+ create(3, "raid1", "lvol2")
+ create(4, "raid10", "lvol3")
+ create(5, "raid5", "lvol4")
+ create(6, "raid6", "lvol5")
+
+ def wait_msg(row, msg):
+ self.content_tab_wait_in_info(row, 1, "Physical volumes" if row == 1 else "Stripes", msg)
+
+ def wait_partial(row):
+ wait_msg(row, "This logical volume has lost some of its physical volumes and can no longer be used.")
+
+ def wait_degraded(row):
+ wait_msg(row, "This logical volume has lost some of its physical volumes but has not lost any data yet.")
+
+ def wait_maybe_partial(row):
+ wait_msg(row, "This logical volume has lost some of its physical volumes but might not have lost any data yet.")
+
+ self.force_remove_disk(disk1)
+
+ wait_partial(1) # linear is broken now
+ wait_partial(2) # striped as well
+ wait_degraded(3) # mirror is fine
+ wait_degraded(4) # striped mirror as well
+ wait_degraded(5) # raid5 and ...
+ wait_degraded(6) # ... raid6 are doing their job
+
+ self.force_remove_disk(disk2)
+
+ wait_degraded(3) # mirror is still fine
+ wait_maybe_partial(4) # striped mirror is not sure anymore
+ wait_partial(5) # raid5 is now toast
+ wait_degraded(6) # but raid6 still hangs on
+
+ self.force_remove_disk(disk3)
+
+ wait_degraded(3) # mirror is _still_ fine
+ wait_partial(4) # striped mirror has lost more than half and is kaputt
+ wait_partial(6) # raid6 is finally toast as well
+
+ def testLvmOnLuks(self):
+ b = self.browser
+ m = self.machine
+
+ self.skip_if_no_layouts()
+
+ # Make sure that Cockpit gets the layout description right for
+ # encrypted physical volumes
+
+ self.login_and_go("/storage")
+
+ disk = self.add_loopback_disk()
+ b.wait_in_text("#others", disk)
+ m.execute(f"echo einszweidrei | cryptsetup luksFormat --pbkdf-memory 32768 {disk}")
+ m.execute(f"echo einszweidrei | cryptsetup luksOpen {disk} dm-test")
+ self.addCleanup(m.execute, "cryptsetup close dm-test || true")
+ m.execute("vgcreate vgroup0 /dev/mapper/dm-test")
+ self.addCleanup(m.execute, "vgremove --force vgroup0 2>/dev/null || true")
+ m.execute("lvcreate vgroup0 -n lvol0 -l100%FREE")
+
+ b.wait_in_text("#devices", "vgroup0")
+ b.click('#devices .sidepanel-row:contains("vgroup0")')
+
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", bn(disk))
+
if __name__ == '__main__':
testlib.test_main()