From f7c7193b5bb7755365377ecd668f866ff9b634e9 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Mon, 2 Oct 2023 15:21:22 +0300 Subject: [PATCH] storage: Support LVM2 RAID configurations --- pkg/storaged/client.js | 87 ++++++ pkg/storaged/content-views.jsx | 282 ++++++++++++++--- pkg/storaged/dialog.jsx | 13 +- pkg/storaged/lvol-tabs.jsx | 144 +++++++++ pkg/storaged/resize.jsx | 136 ++++++++- pkg/storaged/side-panel.jsx | 4 +- pkg/storaged/storage.scss | 54 ++++ pkg/storaged/vgroup-details.jsx | 82 ++++- pkg/storaged/warnings.jsx | 15 + test/common/storagelib.py | 22 ++ test/verify/check-storage-lvm2 | 518 ++++++++++++++++++++++++++++++++ 11 files changed, 1295 insertions(+), 62 deletions(-) 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..2489c12662eb 100644 --- a/pkg/storaged/content-views.jsx +++ b/pkg/storaged/content-views.jsx @@ -20,7 +20,7 @@ import cockpit from "cockpit"; import { dialog_open, TextInput, PassInput, SelectOne, SizeSlider, CheckBoxes, - BlockingMessage, TeardownMessage, Message, + 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,8 @@ function create_logical_volume(client, vgroup) { if (vgroup.FreeSize == 0) return; + const can_do_layouts = !!vgroup.CreatePlainVolumeWithLayout; + const purposes = [ { value: "block", @@ -830,12 +907,103 @@ function create_logical_volume(client, vgroup) { */ ]; + const layouts = [ + { + value: "linear", + title: _("Linear (at least one physical volume)"), + min_pvs: 1, + }, + { + value: "raid0", + title: _("Striped (RAID 0, at least two physical volumes)"), + min_pvs: 2, + }, + { + value: "raid1", + title: _("Mirrored (RAID 1, at least two physical volumes)"), + min_pvs: 2, + }, + { + value: "raid10", + title: _("Striped and mirrored (RAID 10, at least four physical volumes, even number)"), + min_pvs: 4, + }, + { + value: "raid5", + title: _("Distributed parity (RAID 5, at least three physical volumes)"), + min_pvs: 3, + }, + { + value: "raid6", + title: _("Double distributed parity (RAID 6, at least five physical volumes)"), + 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)") }); + const pvs_as_spaces = pvs_to_spaces(client, client.vgroups_pvols[vgroup.path].filter(pvol => pvol.FreeSize > 0)); + + /* 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."), + }; + + 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 +1021,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) + }), + SelectOne("layout", _("Layout"), + { + value: "linear", + choices: layouts, + 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 +1085,43 @@ 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) { + lay.disabled = (vals.pvs.length < lay.min_pvs); + 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: layouts, + 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 +1160,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 50379b3121ff..d861fc7ed324 100644 --- a/pkg/storaged/dialog.jsx +++ b/pkg/storaged/dialog.jsx @@ -507,6 +507,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) { @@ -722,8 +730,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}; @@ -748,6 +757,8 @@ export const SelectSpaces = (tag, title, options) => {