From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 165041FF0AF for ; Fri, 06 Nov 2026 01:59:14 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id DC7A321EC6; Fri, 06 Nov 2026 01:51:22 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 manager 69/76] ui: storage wizard: iscsi: enable multipath for shared LVM Date: Fri, 6 Nov 2026 00:31:44 +0100 Message-ID: <20261105233336.2178064-70-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com> References: <20261105233336.2178064-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793926182365 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.297 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: A5BWTY6A55XRSTZRHKA4IWLXOGUDBENT X-Message-ID-Hash: A5BWTY6A55XRSTZRHKA4IWLXOGUDBENT X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: A target with several portals exposes the same physical volume on several devices unless multipath combines them. Offer multipath and enable it by default for such targets. Refuse shared LVM without it when several portals are known. Pass the option to the connection storage and wait for a usable map in the LUN selector. Pin the map identity when creating the volume group, and explain single-path use when direct LUN access leaves multipath disabled. Signed-off-by: Thomas Lamprecht --- test/StorageWizard_test.js | 11 +++ www/manager6/storage/wizard/IScsi.js | 131 ++++++++++++++++++++++++-- www/manager6/storage/wizard/SanLvm.js | 7 ++ 3 files changed, 143 insertions(+), 6 deletions(-) diff --git a/test/StorageWizard_test.js b/test/StorageWizard_test.js index 48305ccb3..05201e572 100644 --- a/test/StorageWizard_test.js +++ b/test/StorageWizard_test.js @@ -86,15 +86,26 @@ function submit({ name, multipath = false, freshWWID = selectedWWID, lun = 7 }) for (const [label, params, accepted] of [ ['plain path with the selected identity', { name: `scsi-${selectedWWID}` }, true], ['unmanaged map with the selected identity', { name: `dm-uuid-mpath-${selectedWWID}` }, true], + [ + 'managed map with the selected identity', + { name: `dm-uuid-mpath-${selectedWWID}`, multipath: true }, + true, + ], [ 'different content identity despite a matching fresh scan', { name: `scsi-${otherWWID}` }, false, ], ['different unmanaged map identity', { name: `dm-uuid-mpath-${otherWWID}` }, false], + [ + 'different managed map identity', + { name: `dm-uuid-mpath-${otherWWID}`, multipath: true }, + false, + ], ['opaque alias', { name: 'dm-name-array-lun' }, false], ['partition of the selected LUN', { name: `scsi-${selectedWWID}-part1` }, false], ['different LUN number', { name: `scsi-${selectedWWID}`, lun: 8 }, false], + ['path rather than a managed map', { name: `scsi-${selectedWWID}`, multipath: true }, false], [ 'identity changed in the fresh scan', { name: `scsi-${selectedWWID}`, freshWWID: otherWWID }, diff --git a/www/manager6/storage/wizard/IScsi.js b/www/manager6/storage/wizard/IScsi.js index 57cbe3243..9bab76cd0 100644 --- a/www/manager6/storage/wizard/IScsi.js +++ b/www/manager6/storage/wizard/IScsi.js @@ -4,6 +4,44 @@ Ext.define('PVE.storage.wizard.IScsiTarget', { onlineHelp: 'storage_open_iscsi', + // set once the user picked a multipath setting, the default follows the discovery until then + multipathChosen: false, + + targetPortals: undefined, + + updateMultipathDefault: function (targetField, target) { + let me = this; + let rec = target + ? targetField.getStore().findRecord('target', target, 0, false, true, true) + : undefined; + let portals = rec + ? new Set( + `${rec.get('portal') ?? ''}` + .split(',') + .map((portal) => portal.trim()) + .filter((portal) => portal !== ''), + ).size + : 0; + me.down('field[name=multipath]').setBoxLabel( + portals > 1 + ? Ext.String.format( + ngettext( + 'Recommended: the target has {0} portal', + 'Recommended: the target has {0} portals', + portals, + ), + portals, + ) + : '', + ); + me.targetPortals = portals; + if (!me.multipathChosen) { + me.settingMultipathDefault = true; + me.down('field[name=multipath]').setValue(portals > 1); + me.settingMultipathDefault = false; + } + }, + initComponent: function () { let me = this; @@ -31,10 +69,32 @@ Ext.define('PVE.storage.wizard.IScsiTarget', { fieldLabel: gettext('Target'), disabled: true, showNodeSelector: false, + listeners: { + change: (f, value) => me.updateMultipathDefault(f, value), + }, }, ]; me.columnB = [ + { + xtype: 'proxmoxcheckbox', + name: 'multipath', + uncheckedValue: 0, + fieldLabel: gettext('Multipath'), + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Combine the paths of each LUN into one device on every node using the storage.', + ), + }, + listeners: { + change: function () { + if (!me.settingMultipathDefault) { + me.multipathChosen = true; + } + }, + }, + }, { xtype: 'pveStorageWizardNodeIdentities', kind: 'iscsi', @@ -80,7 +140,17 @@ Ext.define('PVE.storage.wizard.IScsiUsage', { action: () => wizard.down('pveStorageScanNodeSelector')?.setValue(Proxmox.NodeName), }; } - return undefined; + // without multipath LVM sees the PV once per portal and ignores the volume group + let field = wizard.down('field[name=multipath]'); + let portals = wizard.down('pveStorageWizardIScsiTarget').targetPortals; + if (portals < 2 || field.getValue()) { + return undefined; + } + return { + text: gettext('Shared LVM on a target with several portals needs multipath.'), + actionText: gettext('Enable Multipath'), + action: () => field.setValue(true), + }; }, validator: function () { @@ -303,6 +373,14 @@ PVE.storage.wizard.types.iscsi = { ), ]; } + let portals = wizard.down('pveStorageWizardIScsiTarget').targetPortals; + if (!values.multipath && portals > 1 && values.usage !== 'lvm') { + return [ + gettext( + 'Without multipath, every LUN appears once per portal and a disk image uses only one of its paths.', + ), + ]; + } return []; }, @@ -313,14 +391,28 @@ PVE.storage.wizard.types.iscsi = { if (usage === 'direct') { delete values['base-storage']; + if (!values.multipath) { + delete values.multipath; + } values.type = 'iscsi'; values.content = 'images'; wizard.runSubmitChain([(next) => wizard.createStorage(values, next)]); return; } + let multipath = !!values.multipath; + let portals = wizard.down('pveStorageWizardIScsiTarget').targetPortals; PVE.storage.wizard.submitSanLvm(wizard, values, { sameIdText: gettext('The iSCSI storage and the LVM storage need different IDs.'), + refusal: + !multipath && portals > 1 + ? gettext( + 'The target has several portals, so shared LVM on it needs multipath.', + ) + : undefined, + baseExistsText: gettext( + 'The iSCSI storage "{0}" exists. Press {1} to retry the remaining steps, or complete the setup under Datacenter > Storage > Add > LVM.', + ), volumeGroupText: gettext( 'Press {2} to retry the remaining steps. If the volume group "{0}" already exists on the LUN, you can also complete the setup under Datacenter > Storage > Add > LVM, with base volume "{1}" and volume group "{0}".', ), @@ -349,6 +441,8 @@ PVE.storage.wizard.types.iscsi = { target: values.target, content: 'none', nodes: values.nodes, + // unset rather than 0, a node with an older release does not know it + multipath: multipath ? 1 : undefined, }), lunView: (node, baseId) => ({ xtype: 'pveStorageWizardLunSelect', @@ -358,6 +452,7 @@ PVE.storage.wizard.types.iscsi = { ), enc(baseId), ), + multipath: multipath, belongs: (row) => row['iscsi-target'] === values.target, connectionUrl: `/nodes/${node}/iscsi/target`, connections: (rows) => @@ -370,7 +465,7 @@ PVE.storage.wizard.types.iscsi = { rescanUrl: `/nodes/${node}/iscsi/rescan`, rescanParam: 'session-id', }), - verifyVolume: function ({ node, baseId, lun, pin }) { + verifyVolume: function ({ node, baseId, lun, retryHint, pin }) { let view = wizard.lunView; let scan = view.scanGeneration; let selection = view.selectionGeneration; @@ -403,11 +498,18 @@ PVE.storage.wizard.types.iscsi = { url: `/nodes/${node}/storage/${baseId}/content`, method: 'GET', params: { content: 'images' }, - failure: () => { + failure: (response) => { if (!current()) { return; } - unverified(false); + if (!multipath) { + unverified(false); + } else { + Ext.Msg.alert( + gettext('Error'), + `${response.htmlStatus}

${retryHint()}`, + ); + } }, success: function (response) { if (!current()) { @@ -423,11 +525,28 @@ PVE.storage.wizard.types.iscsi = { return ( Number(number) === lun['iscsi-lun'] && (name === `dm-uuid-mpath-${lun.wwid}` || - name === `scsi-${lun.wwid}`) + (!multipath && name === `scsi-${lun.wwid}`)) ); }); if (!volume) { - unverified(false); + if (!multipath) { + unverified(false); + return; + } + Ext.Msg.alert( + gettext('Error'), + Ext.String.format( + gettext( + 'The iSCSI storage "{0}" does not provide a volume for the selected LUN ({1}).', + ), + enc(baseId), + enc(lun.wwid), + ) + `

${retryHint()}`, + ); + return; + } + if (multipath) { + pin(volume); return; } wizard.request({ diff --git a/www/manager6/storage/wizard/SanLvm.js b/www/manager6/storage/wizard/SanLvm.js index 85939c3b2..054725753 100644 --- a/www/manager6/storage/wizard/SanLvm.js +++ b/www/manager6/storage/wizard/SanLvm.js @@ -6,6 +6,10 @@ PVE.storage.wizard.submitSanLvm = function (wizard, values, transport) { Ext.Msg.alert(gettext('Error'), transport.sameIdText); return; } + if (transport.refusal) { + Ext.Msg.alert(gettext('Error'), transport.refusal); + return; + } // the storage API creates the volume group on the node it runs on let node = Proxmox.NodeName; @@ -24,6 +28,8 @@ PVE.storage.wizard.submitSanLvm = function (wizard, values, transport) { return; } + let retryHint = () => + Ext.String.format(transport.baseExistsText, enc(baseId), enc(wizard.actionText())); let volumeGroupHint = () => Ext.String.format( transport.volumeGroupText, @@ -88,6 +94,7 @@ PVE.storage.wizard.submitSanLvm = function (wizard, values, transport) { node, baseId, lun: selected, + retryHint, pin: function (volume) { created.baseVolume = volume.volid; created.lun = Ext.clone(selected); -- 2.47.3