From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH v3 storage 28/76] multipath: add a cluster-wide configuration
Date: Fri, 6 Nov 2026 00:31:03 +0100 [thread overview]
Message-ID: <20261105233336.2178064-29-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com>
Per-node multipath configuration can drift, leaving some nodes using
individual paths while others use maps. Keep the allow-list, map names,
and daemon settings in the cluster file system instead.
Provide global defaults, per-LUN entries with aliases and node
restrictions, and a verbatim file for hardware-specific settings.
Use find_multipaths strict to map only allow-listed LUNs, WWID-based
names for consistency across nodes, and auto_resize grow_only so maps
follow target-side LUN growth before the PV is enlarged.
Place global settings also supplied by device profiles, such as
no_path_retry, in an overrides section so the profiles cannot supersede
them. Applying the configuration is the generator's responsibility.
Refuse entries whose WWID multipath would read as an option or a
device number, also in a hand-edited file.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
src/PVE/Makefile | 2 +
src/PVE/Multipath/ClusterConfig.pm | 64 ++++
src/PVE/Multipath/Config.pm | 451 +++++++++++++++++++++++++++++
src/test/run_multipath_tests.pl | 255 ++++++++++++++++
src/test/run_san_library_tests.pl | 36 +++
5 files changed, 808 insertions(+)
create mode 100644 src/PVE/Multipath/ClusterConfig.pm
create mode 100644 src/PVE/Multipath/Config.pm
diff --git a/src/PVE/Makefile b/src/PVE/Makefile
index 91f22282..b92059bd 100644
--- a/src/PVE/Makefile
+++ b/src/PVE/Makefile
@@ -5,6 +5,8 @@ install:
install -D -m 0644 Storage.pm ${DESTDIR}${PERLDIR}/PVE/Storage.pm
install -D -m 0644 Diskmanage.pm ${DESTDIR}${PERLDIR}/PVE/Diskmanage.pm
install -D -m 0644 Multipath.pm ${DESTDIR}${PERLDIR}/PVE/Multipath.pm
+ install -D -m 0644 Multipath/Config.pm ${DESTDIR}${PERLDIR}/PVE/Multipath/Config.pm
+ install -D -m 0644 Multipath/ClusterConfig.pm ${DESTDIR}${PERLDIR}/PVE/Multipath/ClusterConfig.pm
install -D -m 0644 CephConfig.pm ${DESTDIR}${PERLDIR}/PVE/CephConfig.pm
install -D -m 0644 GuestImport.pm ${DESTDIR}${PERLDIR}/PVE/GuestImport.pm
make -C Storage install
diff --git a/src/PVE/Multipath/ClusterConfig.pm b/src/PVE/Multipath/ClusterConfig.pm
new file mode 100644
index 00000000..5d5fa765
--- /dev/null
+++ b/src/PVE/Multipath/ClusterConfig.pm
@@ -0,0 +1,64 @@
+package PVE::Multipath::ClusterConfig;
+
+use v5.36;
+
+use Digest::SHA ();
+
+use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
+
+use PVE::Multipath::Config;
+
+my $FILENAME = 'multipath.cfg';
+my $OVERRIDES_FILENAME = 'multipath-overrides.conf';
+
+cfs_register_file(
+ $FILENAME,
+ sub($filename, $data) { PVE::Multipath::Config->parse_config($filename, $data); },
+ sub($filename, $data) { PVE::Multipath::Config->write_config($filename, $data); },
+);
+
+cfs_register_file(
+ $OVERRIDES_FILENAME,
+ \&PVE::Multipath::Config::parse_overrides,
+ \&PVE::Multipath::Config::write_overrides,
+);
+
+sub read_config() {
+ return cfs_read_file($FILENAME);
+}
+
+sub write_config($cfg) {
+ cfs_write_file($FILENAME, $cfg);
+}
+
+sub read_overrides() {
+ return cfs_read_file($OVERRIDES_FILENAME);
+}
+
+sub write_overrides($text) {
+ cfs_write_file($OVERRIDES_FILENAME, $text);
+}
+
+# Digest over the raw override text, to detect concurrent modifications; the SectionConfig digest
+# of multipath.cfg does not cover this separate file.
+sub overrides_digest($text) {
+ return Digest::SHA::sha1_hex($text // '');
+}
+
+# Preserve exception objects so parameter errors retain their API error type.
+my sub rethrow($err, $errmsg) {
+ die $err if ref($err) || !$errmsg;
+ die "$errmsg - $err";
+}
+
+sub lock_config($code, $errmsg = undef) {
+ cfs_lock_file($FILENAME, undef, $code);
+ rethrow($@, $errmsg) if $@;
+}
+
+sub lock_overrides($code, $errmsg = undef) {
+ cfs_lock_file($OVERRIDES_FILENAME, undef, $code);
+ rethrow($@, $errmsg) if $@;
+}
+
+1;
diff --git a/src/PVE/Multipath/Config.pm b/src/PVE/Multipath/Config.pm
new file mode 100644
index 00000000..4d9901e0
--- /dev/null
+++ b/src/PVE/Multipath/Config.pm
@@ -0,0 +1,451 @@
+package PVE::Multipath::Config;
+
+use v5.36;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::SectionConfig;
+use PVE::Tools;
+
+use base qw(PVE::SectionConfig);
+
+# Strict discovery leaves unrelated and boot disks alone; WWID names avoid node-local bindings.
+# Grow-only resizing follows target expansion without shrinking a map under its consumers.
+my $MANAGED_DEFAULTS = {
+ 'find-multipaths' => 'strict',
+ 'user-friendly-names' => 'no',
+ 'polling-interval' => 5,
+ 'auto-resize' => 'grow_only',
+};
+
+sub managed_defaults() { return { $MANAGED_DEFAULTS->%* }; }
+
+# multipath reads an argument starting with '-' as an option and one starting with
+# '<major>:<minor>' as a device number, so a WWID must not start with either.
+our $WWID_PATTERN = '(?!-|\d+:-?\d)[a-zA-Z0-9._:-]+';
+our $WWID_RE = qr/^(?:$WWID_PATTERN)\z/;
+
+my $shared_knobs = {
+ 'no-path-retry' => {
+ type => 'string',
+ pattern => '(?:queue|fail|\d+)',
+ typetext => 'queue|fail|<count>',
+ description =>
+ "How to react when all paths are down: keep queuing, fail at once, or retry"
+ . " for the given number of polling intervals.",
+ optional => 1,
+ },
+ 'path-grouping-policy' => {
+ type => 'string',
+ enum =>
+ [qw(failover multibus group_by_serial group_by_prio group_by_node_name group_by_tpg)],
+ description => "How paths are grouped into priority groups. 'group_by_tpg' groups by"
+ . " the ALUA target port group and needs the alua prioritizer, which the hardware"
+ . " table of multipath-tools selects for arrays that support it.",
+ optional => 1,
+ },
+ failback => {
+ type => 'string',
+ pattern => '(?:manual|immediate|followover|\d+)',
+ typetext => 'manual|immediate|followover|<seconds>',
+ description => "When to fail back to a restored higher-priority path group.",
+ optional => 1,
+ },
+ 'path-selector' => {
+ type => 'string',
+ pattern =>
+ '(?:round-robin|queue-length|service-time|historical-service-time|io-affinity) \d+',
+ typetext => '<selector> <number>',
+ description => "Path selector algorithm used within a priority group, for example"
+ . " 'service-time 0'.",
+ optional => 1,
+ },
+};
+
+my $defaults_only_knobs = {
+ 'find-multipaths' => {
+ type => 'string',
+ enum => [qw(yes no strict greedy smart)],
+ default => 'strict',
+ description => "Which devices multipathd assembles into a map. 'strict' only uses WWIDs"
+ . " in the node's allow-list, including those added for multipath-enabled storages.",
+ optional => 1,
+ },
+ 'user-friendly-names' => {
+ type => 'string',
+ enum => [qw(yes no)],
+ default => 'no',
+ description => "Whether to use node-local mpathN names. Keep 'no' for stable WWID-based"
+ . " names across the cluster.",
+ optional => 1,
+ },
+ 'polling-interval' => {
+ type => 'integer',
+ minimum => 1,
+ default => 5,
+ description => "Interval between path checks, in seconds.",
+ optional => 1,
+ },
+ 'auto-resize' => {
+ type => 'string',
+ enum => [qw(never grow_only grow_shrink)],
+ default => 'grow_only',
+ description => "Whether multipathd resizes a map once all its paths report a new size."
+ . " 'grow_only' follows a LUN grown on the target, 'never' leaves it to"
+ . " 'multipathd resize map'.",
+ optional => 1,
+ },
+};
+
+my $wwid_only_knobs = {
+ alias => {
+ type => 'string',
+ pattern => '[a-zA-Z0-9][a-zA-Z0-9._-]*',
+ maxLength => 64,
+ description => "Human-readable name that multipathd uses for this WWID's map.",
+ optional => 1,
+ },
+ 'rr-min-io-rq' => {
+ type => 'integer',
+ minimum => 1,
+ description => "Number of I/O requests to send through a path before switching to"
+ . " another path, for request-based multipath devices.",
+ optional => 1,
+ },
+ 'rr-weight' => {
+ type => 'string',
+ enum => [qw(priorities uniform)],
+ description => "Whether to weight paths by priority when balancing I/O.",
+ optional => 1,
+ },
+};
+
+# Per-LUN settings that are not multipathd knobs and stay out of the rendered configuration.
+my $wwid_only_props = {
+ nodes => get_standard_option(
+ 'pve-node-list',
+ {
+ description => "The nodes that see this LUN and are expected to assemble its map."
+ . " Other nodes leave it off their allow-list. Unset means every node.",
+ optional => 1,
+ },
+ ),
+};
+
+my $defaultData = {
+ propertyList => {
+ type => { description => "Section type ('defaults' or 'wwid')." },
+ id => {
+ type => 'string',
+ description =>
+ "Section ID: the literal 'defaults', or a LUN WWID for 'wwid' sections.",
+ pattern => $WWID_PATTERN,
+ maxLength => 128,
+ },
+ $shared_knobs->%*,
+ $defaults_only_knobs->%*,
+ $wwid_only_knobs->%*,
+ $wwid_only_props->%*,
+ },
+};
+
+sub private($class) { return $defaultData; }
+
+# The file can be edited by hand; skip WWID sections whose ID multipath would misread.
+sub parse_section_header($class, $line) {
+ my ($type, $id, $errmsg, $config) = $class->SUPER::parse_section_header($line);
+ return undef if !defined($type);
+ $errmsg //= "invalid WWID '$id'" if $type eq 'wwid' && $id !~ $WWID_RE;
+ return ($type, $id, $errmsg, $config);
+}
+
+sub decode_value($class, $type, $key, $value) {
+ if ($key eq 'nodes') {
+ my $res = {};
+ for my $node (PVE::Tools::split_list($value)) {
+ $res->{$node} = 1 if PVE::JSONSchema::pve_verify_node_name($node);
+ }
+ return $res;
+ }
+ return $value;
+}
+
+sub encode_value($class, $type, $key, $value) {
+ return join(',', sort keys %$value) if $key eq 'nodes';
+ return $value;
+}
+
+package PVE::Multipath::Config::Defaults;
+
+use base qw(PVE::Multipath::Config);
+
+sub type($class) { return 'defaults'; }
+
+sub options($class) {
+ return {
+ 'find-multipaths' => { optional => 1 },
+ 'user-friendly-names' => { optional => 1 },
+ 'polling-interval' => { optional => 1 },
+ 'auto-resize' => { optional => 1 },
+ 'no-path-retry' => { optional => 1 },
+ 'path-grouping-policy' => { optional => 1 },
+ failback => { optional => 1 },
+ 'path-selector' => { optional => 1 },
+ };
+}
+
+__PACKAGE__->register();
+
+package PVE::Multipath::Config::Wwid;
+
+use base qw(PVE::Multipath::Config);
+
+sub type($class) { return 'wwid'; }
+
+sub options($class) {
+ return {
+ alias => { optional => 1 },
+ nodes => { optional => 1 },
+ 'no-path-retry' => { optional => 1 },
+ 'path-grouping-policy' => { optional => 1 },
+ failback => { optional => 1 },
+ 'path-selector' => { optional => 1 },
+ 'rr-min-io-rq' => { optional => 1 },
+ 'rr-weight' => { optional => 1 },
+ };
+}
+
+__PACKAGE__->register();
+
+package PVE::Multipath::Config;
+
+__PACKAGE__->init();
+
+# multipaths is generated from WWID sections and must not also occur in overrides.
+my $OVERRIDE_KEYWORDS =
+ { devices => 1, overrides => 1, defaults => 1, blacklist => 1, blacklist_exceptions => 1 };
+
+# This is a structural guard, not a complete multipath.conf parser.
+sub check_overrides($text) {
+ return if !defined($text) || $text !~ /\S/;
+
+ # walk every block opener and brace in order, so a section keyword after a closing brace on
+ # the same line is still checked at its real depth
+ my $depth = 0;
+ for my $line (split(/\n/, $text)) {
+ next if $line =~ /^\s*#/;
+ while ($line =~ /(\w+)\s*\{|([{}])/g) {
+ if (defined($1)) {
+ my $kw = $1;
+ die "multipath overrides: 'multipaths' sections are generated from the multipath"
+ . " entries, do not set them here\n"
+ if $kw eq 'multipaths';
+ die "multipath overrides: unknown top-level section '$kw'\n"
+ if $depth == 0 && !$OVERRIDE_KEYWORDS->{$kw};
+ $depth++;
+ } elsif ($2 eq '{') {
+ $depth++;
+ } else {
+ die "multipath overrides: unbalanced braces\n" if $depth == 0;
+ $depth--;
+ }
+ }
+ }
+ die "multipath overrides: unbalanced braces\n" if $depth != 0;
+
+ return;
+}
+
+sub parse_overrides($filename, $raw) {
+ return $raw // '';
+}
+
+sub write_overrides($filename, $text) {
+ $text //= '';
+ $text =~ s/\s+$//;
+ return length($text) ? "$text\n" : '';
+}
+
+my $MANAGED_HEADER =
+ "# This file is managed by Proxmox VE - do not edit by hand.\n"
+ . "# Hardware-/node-specific overrides belong in the override config.\n";
+
+# multipath.conf requires quotes around whitespace; schemas exclude embedded quotes.
+my sub render_value($value) {
+ return $value =~ /\s/ ? "\"$value\"" : $value;
+}
+
+my sub render_section($name, $kv) {
+ my $out = "$name {\n";
+ for my $key (sort keys $kv->%*) {
+ (my $keyword = $key) =~ tr/-/_/;
+ $out .= "\t$keyword " . render_value($kv->{$key}) . "\n";
+ }
+ $out .= "}\n";
+ return $out;
+}
+
+# Device profiles override defaults, so these global settings need an overrides section.
+# Per-LUN settings still take precedence. Admin overrides use a separate file to avoid duplicate
+# section keywords in one file.
+my $OVERRIDE_KNOBS = {
+ 'no-path-retry' => 1,
+ 'path-grouping-policy' => 1,
+ failback => 1,
+ 'path-selector' => 1,
+};
+
+sub generate_managed_conf($defaults = undef) {
+ $defaults //= managed_defaults();
+
+ my ($global, $overrides) = ({}, {});
+ ($OVERRIDE_KNOBS->{$_} ? $overrides : $global)->{$_} = $defaults->{$_} for keys %$defaults;
+ # the path grouping that multipathd detects for ALUA arrays overrides even the policy of a
+ # single LUN, so turn the detection off when a policy is configured
+ $overrides->{'detect-pgpolicy'} = 'no' if defined($overrides->{'path-grouping-policy'});
+
+ my $conf = $MANAGED_HEADER . "\n" . render_section('defaults', $global);
+ $conf .= "\n" . render_section('overrides', $overrides) if %$overrides;
+ return $conf;
+}
+
+# The WWID allow-list file (/etc/multipath/wwids) holds one '/<wwid>/' per line.
+sub parse_wwids($text) {
+ my $wwids = [];
+ for my $line (split(/\n/, $text // '')) {
+ next if $line =~ /^\s*#/;
+ next if $line =~ /^\s*$/;
+ if ($line =~ m{^/(.+)/\s*$}) {
+ push $wwids->@*, $1;
+ }
+ }
+ return $wwids;
+}
+
+sub parse_managed_wwids($text) {
+ my $record = {};
+ for my $line (split(/\n/, $text // '')) {
+ next if $line =~ /^\s*#/;
+ next if $line !~ m/^\s*(\S+)\s+(\S+)\s*$/;
+ my ($wwid, $sources) = ($1, $2);
+ my @sources = grep { m/^(?:config|storage:.+)$/ } split(/,/, $sources);
+ $record->{$wwid} = [sort @sources] if scalar(@sources);
+ }
+ return $record;
+}
+
+sub format_managed_wwids($record) {
+ my $out = "# Multipath WWIDs added by Proxmox VE, with the reasons each was added\n";
+ $out .= "$_ " . join(',', sort $record->{$_}->@*) . "\n" for sort keys %$record;
+ return $out;
+}
+
+sub build_multipaths_block($wwid_opts) {
+ my @entries = grep { %{ $wwid_opts->{$_} } } sort keys %$wwid_opts;
+ return '' if !@entries;
+
+ my $out = "multipaths {\n";
+ for my $wwid (@entries) {
+ my $opts = $wwid_opts->{$wwid};
+ $out .= "\tmultipath {\n";
+ $out .= "\t\twwid $wwid\n";
+ for my $key (sort keys %$opts) {
+ (my $keyword = $key) =~ tr/-/_/;
+ $out .= "\t\t$keyword " . render_value($opts->{$key}) . "\n";
+ }
+ $out .= "\t}\n";
+ }
+ $out .= "}\n";
+ return $out;
+}
+
+sub apply_section_update($section, $param, $delete, $settable) {
+ my @delete = PVE::Tools::split_list($delete // '');
+ for my $key (@delete) {
+ die "'$key' is not a settable property\n" if !$settable->{$key};
+ die "cannot set and delete property '$key' at the same time\n" if defined($param->{$key});
+ }
+ for my $key (sort keys %$param) {
+ die "'$key' is not a settable property\n" if !$settable->{$key};
+ $section->{$key} = __PACKAGE__->decode_value($section->{type}, $key, $param->{$key});
+ }
+ delete $section->{$_} for @delete;
+ return;
+}
+
+# Omitted update parameters must remain unchanged, not inherit schema defaults.
+my sub api_schema($props) {
+ my $res = {};
+ for my $key (keys %$props) {
+ $res->{$key} = { $props->{$key}->%* };
+ delete $res->{$key}->{default};
+ }
+ return $res;
+}
+
+sub defaults_api_schema() {
+ return api_schema({ $shared_knobs->%*, $defaults_only_knobs->%* });
+}
+
+sub wwid_api_schema() {
+ return api_schema({ $shared_knobs->%*, $wwid_only_knobs->%*, $wwid_only_props->%* });
+}
+
+sub effective_defaults($cfg) {
+ my $defaults = managed_defaults();
+ if (my $section = $cfg->{ids}->{defaults}) {
+ $defaults->{$_} = $section->{$_} for grep { $_ ne 'type' } keys %$section;
+ }
+ return $defaults;
+}
+
+sub wwid_list($cfg) {
+ return [sort grep { ($cfg->{ids}->{$_}->{type} // '') eq 'wwid' } keys $cfg->{ids}->%*];
+}
+
+sub wwids_for_node($cfg, $node) {
+ return [
+ grep {
+ my $nodes = $cfg->{ids}->{$_}->{nodes};
+ !$nodes || !%$nodes || $nodes->{$node}
+ } wwid_list($cfg)->@*
+ ];
+}
+
+# The per-WWID sections as the API reports them, { wwid => { alias?, nodes?, knob => value } },
+# with the node restriction as a list.
+sub wwid_sections($cfg) {
+ my $res = {};
+ for my $wwid (wwid_list($cfg)->@*) {
+ my $section = $cfg->{ids}->{$wwid};
+ my $entry = { map { $_ => $section->{$_} } grep { $_ ne 'type' } keys %$section };
+ $entry->{nodes} = __PACKAGE__->encode_value('wwid', 'nodes', $entry->{nodes})
+ if $entry->{nodes};
+ $res->{$wwid} = $entry;
+ }
+ return $res;
+}
+
+# { wwid => alias } for the WWIDs that have one.
+sub aliases($cfg) {
+ my $res = {};
+ for my $wwid (keys $cfg->{ids}->%*) {
+ my $section = $cfg->{ids}->{$wwid};
+ next if ($section->{type} // '') ne 'wwid';
+ $res->{$wwid} = $section->{alias} if defined($section->{alias});
+ }
+ return $res;
+}
+
+sub wwid_opts($cfg) {
+ my $res = {};
+ for my $wwid (wwid_list($cfg)->@*) {
+ my $section = $cfg->{ids}->{$wwid};
+ $res->{$wwid} = {
+ map { $_ => $section->{$_} }
+ grep { $_ ne 'type' && !$wwid_only_props->{$_} } keys %$section
+ };
+ }
+ return $res;
+}
+
+1;
diff --git a/src/test/run_multipath_tests.pl b/src/test/run_multipath_tests.pl
index 484e9348..0e247284 100755
--- a/src/test/run_multipath_tests.pl
+++ b/src/test/run_multipath_tests.pl
@@ -7,6 +7,7 @@ use Test::More;
use lib ('.', '..');
use PVE::Multipath;
+use PVE::Multipath::Config;
# A recorded `multipathd show maps json` reply with three maps exercising each
# health state: an all-active map, a partially-failed map and an all-failed map.
@@ -130,4 +131,258 @@ is_deeply($empty, [], 'no maps parses to empty list');
eval { PVE::Multipath::parse_maps_json('not json') };
ok($@ =~ m/could not parse multipathd maps JSON/, 'invalid JSON raises a clear error');
+# --- config generation / WWID allow-list ---
+my $conf = PVE::Multipath::Config::generate_managed_conf();
+like($conf, qr/managed by Proxmox VE/, 'managed conf carries the managed header');
+like($conf, qr/user_friendly_names no/, 'baseline sets user_friendly_names no');
+like($conf, qr/find_multipaths strict/, 'baseline opts in explicitly via find_multipaths strict');
+is(
+ scalar(() = $conf =~ /^defaults \{/mg),
+ 1,
+ 'baseline has exactly one defaults block (a second would be a duplicate-keyword error)',
+);
+
+like($conf, qr/^\tauto_resize grow_only$/m, 'baseline lets maps grow with their LUN');
+unlike($conf, qr/^overrides \{/m, 'baseline overrides nothing of the device profiles');
+
+# multipath.conf only allows whitespace inside double-quoted values
+like(
+ PVE::Multipath::Config::generate_managed_conf({
+ PVE::Multipath::Config::managed_defaults()->%*, 'path-selector' => 'service-time 0',
+ }),
+ qr/^\tpath_selector "service-time 0"$/m,
+ 'a value containing whitespace renders double-quoted, single words stay bare',
+);
+
+# what the device profiles set too goes into overrides, or the profiles would beat it
+my $with_policy = PVE::Multipath::Config::generate_managed_conf({
+ PVE::Multipath::Config::managed_defaults()->%*,
+ 'no-path-retry' => 'fail',
+ 'path-grouping-policy' => 'multibus',
+});
+my ($defaults_block) = $with_policy =~ m/^(defaults \{.*?^\})/ms;
+my ($overrides_block) = $with_policy =~ m/^(overrides \{.*?^\})/ms;
+unlike($defaults_block, qr/no_path_retry|path_grouping_policy/, 'device knobs: not in defaults');
+like($overrides_block, qr/^\tno_path_retry fail$/m, 'device knobs: in overrides');
+like(
+ $overrides_block,
+ qr/^\tdetect_pgpolicy no$/m,
+ 'device knobs: a grouping policy turns off the detected grouping',
+);
+
+my $wwids = PVE::Multipath::Config::parse_wwids("# Multipath wwids\n/3600abc/\n/3600def/\n");
+is_deeply($wwids, ['3600abc', '3600def'], 'parse_wwids extracts the wwids');
+
+# --- cluster config (pmxcfs source of truth): SectionConfig parse/write ---
+my $raw =
+ "defaults: defaults\n\tfind-multipaths strict\n\tno-path-retry queue\n\n"
+ . "wwid: 3600def\n\talias san-b-lun0\n\n"
+ . "wwid: 3600abc\n\talias san-a-lun0\n\tno-path-retry 18\n";
+my $cc = PVE::Multipath::Config->parse_config('multipath.cfg', $raw);
+is_deeply(
+ PVE::Multipath::Config::wwid_list($cc),
+ ['3600abc', '3600def'],
+ 'wwid sections become the allow-list (sorted)',
+);
+is_deeply(
+ PVE::Multipath::Config::aliases($cc),
+ { '3600abc' => 'san-a-lun0', '3600def' => 'san-b-lun0' },
+ 'aliases read from the wwid sections',
+);
+is(
+ PVE::Multipath::Config::effective_defaults($cc)->{'no-path-retry'},
+ 'queue',
+ 'defaults section knob is read',
+);
+is(
+ PVE::Multipath::Config::effective_defaults($cc)->{'user-friendly-names'},
+ 'no',
+ 'an unset defaults knob falls back to the managed default',
+);
+
+my $written = PVE::Multipath::Config->write_config('multipath.cfg', $cc);
+my $cc2 = PVE::Multipath::Config->parse_config('multipath.cfg', $written);
+is_deeply(
+ PVE::Multipath::Config::wwid_list($cc2),
+ ['3600abc', '3600def'],
+ 'wwids survive the SectionConfig round-trip',
+);
+is_deeply(
+ PVE::Multipath::Config::aliases($cc2),
+ PVE::Multipath::Config::aliases($cc),
+ 'aliases survive the round-trip',
+);
+is($cc2->{ids}->{'3600abc'}->{'no-path-retry'}, 18, 'a per-WWID knob survives the round-trip');
+
+is_deeply(
+ PVE::Multipath::Config::wwid_list(PVE::Multipath::Config->parse_config('multipath.cfg', '')),
+ [],
+ 'an empty cluster config has no WWIDs',
+);
+{
+ my @warnings;
+ local $SIG{__WARN__} = sub { push @warnings, @_ };
+ my $cfg = PVE::Multipath::Config->parse_config(
+ 'multipath.cfg',
+ "wwid: -W\n\nwwid: 8:0\n\nwwid: 3600abc\n\nwwid: 36001405:ab\n",
+ );
+ is_deeply(
+ PVE::Multipath::Config::wwid_list($cfg),
+ ['36001405:ab', '3600abc'],
+ 'a WWID that multipath would read as an option or a device number is no section',
+ );
+ is(scalar(@warnings), 2, 'both rejected sections are reported');
+}
+
+# a node restriction stays out of the rendered multipathd settings and filters per node
+my $restricted = PVE::Multipath::Config->parse_config(
+ 'multipath.cfg',
+ "wwid: 3600abc\n\talias fc-lun0\n\tnodes n2,n1\n\nwwid: 3600def\n",
+);
+is_deeply(
+ $restricted->{ids}->{'3600abc'}->{nodes},
+ { n1 => 1, n2 => 1 },
+ 'the node list is decoded into a set',
+);
+is_deeply(
+ PVE::Multipath::Config::wwid_opts($restricted)->{'3600abc'},
+ { alias => 'fc-lun0' },
+ 'the rendered per-WWID options omit the node restriction',
+);
+is_deeply(
+ PVE::Multipath::Config::wwid_sections($restricted)->{'3600abc'},
+ { alias => 'fc-lun0', nodes => 'n1,n2' },
+ 'the API view lists the node restriction as a sorted list',
+);
+is_deeply(
+ PVE::Multipath::Config::wwids_for_node($restricted, 'n1'),
+ ['3600abc', '3600def'],
+ 'a listed node carries the restricted and the unrestricted WWID',
+);
+is_deeply(
+ PVE::Multipath::Config::wwids_for_node($restricted, 'n3'),
+ ['3600def'],
+ 'an unlisted node carries only the unrestricted WWID',
+);
+like(
+ PVE::Multipath::Config->write_config('multipath.cfg', $restricted),
+ qr/^\tnodes n1,n2$/m,
+ 'the node list is written back as a list',
+);
+
+# --- API updates land in the config decoded, guarded to the settable properties ---
+{
+ my $settable = PVE::Multipath::Config::wwid_api_schema();
+ my $section = { type => 'wwid', alias => 'old', 'no-path-retry' => 'queue' };
+ PVE::Multipath::Config::apply_section_update(
+ $section,
+ { nodes => 'n2,n1', alias => 'new' },
+ 'no-path-retry',
+ $settable,
+ );
+ is_deeply(
+ $section,
+ { type => 'wwid', alias => 'new', nodes => { n1 => 1, n2 => 1 } },
+ 'a node list is decoded into the set the config layer works with, deletes are applied',
+ );
+ like(
+ PVE::Multipath::Config->write_config('multipath.cfg', { ids => { w => $section } }),
+ qr/^\tnodes n1,n2$/m,
+ 'the updated section writes back as a config',
+ );
+
+ eval {
+ PVE::Multipath::Config::apply_section_update(
+ $section,
+ { type => 'defaults' },
+ undef,
+ $settable,
+ );
+ };
+ like($@, qr/not a settable property/, 'the section type cannot be set through an update');
+ eval { PVE::Multipath::Config::apply_section_update($section, {}, 'type', $settable) };
+ like($@, qr/not a settable property/, 'the section type cannot be deleted through an update');
+ eval {
+ PVE::Multipath::Config::apply_section_update(
+ $section,
+ { alias => 'x' },
+ 'alias',
+ $settable,
+ );
+ };
+ like($@, qr/set and delete/, 'a property is never set and deleted at once');
+}
+
+# --- multipaths{} block (alias plus per-WWID knobs) ---
+my $block = PVE::Multipath::Config::build_multipaths_block({
+ '3600def' => { alias => 'san-b-lun0' },
+ '3600abc' => { alias => 'san-a-lun0', 'no-path-retry' => 18 },
+ '3600nul' => {},
+});
+like($block, qr/^multipaths \{/m, 'block opens with multipaths {');
+is(
+ scalar(() = $block =~ /^\tmultipath \{/mg),
+ 2,
+ 'one multipath{} per WWID that has an alias or a knob (the empty WWID is skipped)',
+);
+like(
+ $block,
+ qr/wwid 3600abc.*?alias san-a-lun0.*?no_path_retry 18/s,
+ 'block carries the alias and the per-WWID knob',
+);
+my $abc_pos = index($block, 'wwid 3600abc');
+my $def_pos = index($block, 'wwid 3600def');
+ok($abc_pos < $def_pos, 'block emits entries in WWID-sorted order');
+is(PVE::Multipath::Config::build_multipaths_block({}), '', 'no WWIDs render to the empty string');
+like(
+ PVE::Multipath::Config::build_multipaths_block({
+ '3600abc' => { 'path-selector' => 'round-robin 0' },
+ }),
+ qr/^\t\tpath_selector "round-robin 0"$/m,
+ 'a per-WWID value containing whitespace renders double-quoted',
+);
+
+# --- ownership record file format ---
+is_deeply(
+ PVE::Multipath::Config::parse_managed_wwids(
+ "# header\n3600abc config\n3600def storage:san1,config\nbogus line here\n/3600old/\n"
+ . "3600bad nonsense\n"
+ ),
+ { '3600abc' => ['config'], '3600def' => ['config', 'storage:san1'] },
+ 'parse_managed_wwids reads wwid and reasons, ignores what does not fit',
+);
+is(
+ PVE::Multipath::Config::format_managed_wwids({
+ '3600def' => ['storage:san1', 'config'],
+ '3600abc' => ['config'],
+ }),
+ "# Multipath WWIDs added by Proxmox VE, with the reasons each was added\n"
+ . "3600abc config\n3600def config,storage:san1\n",
+ 'format_managed_wwids sorts and round-trips',
+);
+# --- override guard ---
+eval { PVE::Multipath::Config::check_overrides("devices {\n\tdevice {\n\t\tvendor X\n\t}\n}\n") };
+is($@, '', 'a well-formed devices{} block passes the guard');
+eval { PVE::Multipath::Config::check_overrides("multipaths {\n}\n") };
+like($@, qr/generated from the multipath/, 'a multipaths{} block is rejected, it is generated');
+eval { PVE::Multipath::Config::check_overrides("devices {\n") };
+like($@, qr/unbalanced braces/, 'unbalanced braces are rejected');
+eval { PVE::Multipath::Config::check_overrides("frobnicate {\n}\n") };
+like($@, qr/unknown top-level section/, 'an unknown top-level section is rejected');
+eval { PVE::Multipath::Config::check_overrides("blacklist { } multipaths {\n}\n") };
+like(
+ $@,
+ qr/generated from the multipath/,
+ 'a multipaths{} block cannot hide behind a same-line close',
+);
+eval { PVE::Multipath::Config::check_overrides("blacklist { } frobnicate {\n}\n") };
+like($@, qr/unknown top-level section/, 'an unknown section cannot hide behind a same-line close');
+eval { PVE::Multipath::Config::check_overrides("}\ndevices {\n") };
+like($@, qr/unbalanced braces/, 'a closing brace before any open is rejected');
+is(
+ PVE::Multipath::Config::write_overrides('x', "text \n\n"),
+ "text\n",
+ 'the overrides writer trims trailing whitespace',
+);
+
done_testing();
diff --git a/src/test/run_san_library_tests.pl b/src/test/run_san_library_tests.pl
index 6d509979..84a8539c 100755
--- a/src/test/run_san_library_tests.pl
+++ b/src/test/run_san_library_tests.pl
@@ -11,6 +11,42 @@ use lib ('.', '..');
use PVE::Cluster;
use PVE::Storage::StatusCommand;
+my %registered;
+my $cluster = Test::MockModule->new('PVE::Cluster');
+$cluster->mock(cfs_register_file => sub { $registered{ $_[0] } = [@_[1, 2]] });
+require PVE::Multipath::ClusterConfig;
+
+subtest 'CFS callbacks preserve filenames, data and method receivers' => sub {
+ my ($parse, $write) = $registered{'multipath.cfg'}->@*;
+ my $cfg = $parse->('multipath.cfg', undef);
+ is_deeply($cfg->{ids}, {}, 'an absent configuration parses as empty');
+ $cfg = $parse->('multipath.cfg', "wwid: 3600abc\n\talias san-lun\n\tnodes n2,n1\n");
+ is_deeply($cfg->{ids}->{'3600abc'}->{nodes}, { n1 => 1, n2 => 1 }, 'nodes are decoded');
+ my $again = $parse->('multipath.cfg', $write->('multipath.cfg', $cfg));
+ is_deeply($again->{ids}, $cfg->{ids}, 'sections survive the registered writer and parser');
+ ($parse, $write) = $registered{'multipath-overrides.conf'}->@*;
+ is($parse->('multipath-overrides.conf', undef), '', 'absent overrides parse as empty');
+ is($write->('multipath-overrides.conf', undef), '', 'undefined overrides write as empty');
+ is($write->('multipath-overrides.conf', "devices { } \n"), "devices { }\n",
+ 'writer trims');
+};
+
+subtest 'omitted optional parameters retain explicit undef behavior' => sub {
+ my $config = Test::MockModule->new('PVE::Multipath::ClusterConfig');
+ $config->mock(cfs_lock_file => sub { $@ = "lock unavailable\n" });
+ for my $lock (
+ \&PVE::Multipath::ClusterConfig::lock_config,
+ \&PVE::Multipath::ClusterConfig::lock_overrides,
+ ) {
+ for my $args ([], [undef]) {
+ eval {
+ $lock->(sub { die "unexpected callback\n" }, @$args);
+ };
+ is($@, "lock unavailable\n", 'an omitted error prefix leaves the error intact');
+ }
+ }
+};
+
subtest 'shared status retains callback context and named command parameters' => sub {
for my $cache (undef, { status_only => 1 }) {
my $code = sub { return wantarray ? ('a', 'b') : 'scalar' };
--
2.47.3
next prev parent reply other threads:[~2026-11-06 0:56 UTC|newest]
Thread overview: 77+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-11-05 23:30 [PATCH v3 proxmox,proxmox-perl-rs,cluster,storage,docs,manager 00/76] SAN: cluster-wide multipath, SAN views and a guided storage setup Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox 01/76] add proxmox-multipath crate Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox-perl-rs 02/76] pve-rs: add PVE::RS::Multipath bindings for multipathd status parsing Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 cluster 03/76] cfs: observe the multipath configuration files Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 04/76] storage: distinguish status-only activation in plugins Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 05/76] lvm: find a shared volume group created by another node Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 06/76] storage: bound LVM status commands Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 07/76] iscsi: rate-limit session rescans per target Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 08/76] iscsi: list the LUNs of each session, not of its SCSI host Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 09/76] lvm: refuse to create a volume group on a device that holds data Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 10/76] lvm: refuse a base volume of a target that serves disk images Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 11/76] iscsi: refuse virtual devices other than multipath maps as volumes Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 12/76] diskmanage: collect disk transport type from lsblk Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 13/76] diskmanage: add helper to list multipath devices Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 14/76] diskmanage: qualify NVMe over fabrics transport Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 15/76] disks: list: add include-remote parameter Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 16/76] diskmanage: include iSCSI session devices in disk enumeration Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 17/76] diskmanage: link multipath member disks to their map device Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 18/76] iscsi: factor out session device map from device list Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 19/76] api: scan: add a SAN LUN inventory Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 20/76] pvesm: expose the " Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 21/76] disks: lvm: allow volume groups on SAN LUNs and multipath maps Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 22/76] multipath: add a library around multipathd Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 23/76] diskmanage: collect native NVMe multipath path state Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 24/76] api: scan: san-luns: report " Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 25/76] diskmanage: collect local block device users Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 26/76] api: add node-level iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 27/76] api: iscsi: add session logout with last-path protection Thomas Lamprecht
2026-11-05 23:31 ` Thomas Lamprecht [this message]
2026-11-05 23:31 ` [PATCH v3 storage 29/76] iscsi: add an option to manage storage LUNs with multipath Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 30/76] lvm: activate a missing base volume before using the volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 31/76] multipath: apply the configuration on every node Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 32/76] iscsi: share the initiator identity reader Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 33/76] multipath: publish per-node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 34/76] api: add endpoints for the cluster-wide multipath configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 35/76] api: multipath: add the cluster-wide health status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 36/76] multipath: collect identities of non-iSCSI SCSI paths Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 37/76] api: scan: san-luns: report the multipath state of each LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 38/76] api: scan: flag duplicate physical volumes on distinct LUNs Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 39/76] api: disks: recheck local users and retain wipe device references Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 40/76] iscsi: name plain LUNs after their identity link Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 41/76] api: disks: guard SAN wipes with stable identities and storage checks Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 42/76] api: scan: refresh SAN usage with bounded probes Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 43/76] storage: lvm: note the rescan after extending a shared volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 44/76] storage: document the cluster-wide multipath management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 45/76] api: ceph: osd: keep iSCSI LUNs out of OSD creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 46/76] pvestatd: track pending storage status commands across re-exec Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 47/76] ui: storage: factor out the scan picker error mask Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 48/76] ui: storage: nfs: show scan errors and empty results Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 49/76] ui: storage: allow changing the node for NFS/CIFS scans Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 50/76] ui: storage: add guided remote storage wizard with NFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 51/76] ui: storage wizard: add SMB/CIFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 52/76] ui: storage wizard: preserve settings after partial setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 53/76] ui: storage wizard: add Fibre Channel shared LVM setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 54/76] ui: storage wizard: reconcile unanswered storage creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 55/76] ui: storage wizard: add ZFS over iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 56/76] ui: dc: storage: add remote storage wizard entry to the add menu Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 57/76] ui: node: add SAN LUNs panel Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 58/76] ui: san luns: show multipath path state Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 59/76] api: nodes: expose iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 60/76] pvenode: add iscsi commands Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 61/76] ui: node: san: add iSCSI target and session management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 62/76] pvestatd: apply the multipath configuration and report the node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 63/76] api: cluster: add the multipath endpoints Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 64/76] ui: dc: add a cluster-wide multipath view Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 65/76] ui: storage: iscsi: add the multipath option Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 66/76] ui: san luns: show multipath state and manage LUN entries Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 67/76] ui: storage wizard: add iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 68/76] ui: storage wizard: iscsi: show cluster initiator names Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 69/76] ui: storage wizard: iscsi: enable multipath for shared LVM Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 70/76] ui: node: san: wipe a LUN that no storage uses Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 71/76] ui: storage wizard: iscsi: reuse existing target connections Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 72/76] ui: storage wizard: fibre channel: enable multipath for a LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 73/76] ui: storage wizard: let root@pam wipe a LUN with data Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 74/76] ui: storage wizard: do not offer LUNs in use for a new volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 75/76] ui: san: show the local users of a LUN in its usage tooltip Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 76/76] ui: san: check the other nodes for users before wiping a LUN Thomas Lamprecht
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261105233336.2178064-29-t.lamprecht@proxmox.com \
--to=t.lamprecht@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.