From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id EBD061FF0AF for ; Fri, 06 Nov 2026 01:56:41 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 2954A21C67; Fri, 06 Nov 2026 01:51:16 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 manager 54/76] ui: storage wizard: reconcile unanswered storage creation Date: Fri, 6 Nov 2026 00:31:29 +0100 Message-ID: <20261105233336.2178064-55-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: 1793926181212 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.159 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) POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes 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: OZE6KHLOK6S3HJIUWOKYQBCHRHKFD6VP X-Message-ID-Hash: OZE6KHLOK6S3HJIUWOKYQBCHRHKFD6VP 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 lost POST response does not prove creation failed. Keep the submitted settings fixed and check the storage before retrying: accept matching normalized settings, report conflicting entries, and retry creation only if the entry is absent. The storage API serializes ID checks with the write. Label the recovery action explicitly so users know it checks the outcome before continuing. Signed-off-by: Thomas Lamprecht --- www/manager6/storage/wizard/Wizard.js | 126 +++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 2 deletions(-) diff --git a/www/manager6/storage/wizard/Wizard.js b/www/manager6/storage/wizard/Wizard.js index b4978d296..ce0eccb73 100644 --- a/www/manager6/storage/wizard/Wizard.js +++ b/www/manager6/storage/wizard/Wizard.js @@ -42,6 +42,47 @@ PVE.storage.wizard.lvmNodesValidator = function () { ); }; +// per submitted storage type, the keys besides type, content, nodes and disable that an existing +// storage must match, and how to compare them +PVE.storage.wizard.reconcile = { + lvm: { keys: ['vgname', 'base', 'shared', 'saferemove'], booleans: ['shared', 'saferemove'] }, +}; + +PVE.storage.wizard.storageMatches = function (wanted, actual) { + let rule = PVE.storage.wizard.reconcile[wanted.type]; + if (!rule) { + return false; + } + let booleans = ['disable', ...(rule.booleans ?? [])]; + let normalize = function (config, key) { + let value = config[key]; + if (booleans.includes(key)) { + return value === true || value === 1 || value === '1'; + } + if (key === 'content' && (value === undefined || value === '')) { + value = config.type === 'lvm' ? 'images,rootdir' : 'images'; + } + if (['nodes', 'content'].includes(key)) { + return [ + ...new Set( + [] + .concat(value ?? '') + .join(',') + .split(',') + .map((v) => v.trim()) + .filter(Boolean), + ), + ] + .sort() + .join(','); + } + return String(value === undefined || value === null || value === '' ? '' : value); + }; + return ['type', 'content', 'nodes', 'disable', ...rule.keys].every( + (key) => normalize(wanted, key) === normalize(actual, key), + ); +}; + Ext.define('PVE.storage.wizard.RemoteStorage', { extend: 'PVE.window.Wizard', alias: 'widget.pveRemoteStorageWizard', @@ -121,6 +162,10 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { }, actionText: function () { + let me = this; + if (me.unconfirmedStorage) { + return gettext('Check and Retry'); + } return gettext('Finish'); }, @@ -349,6 +394,17 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { }); }, + storageUnknownText: function (html = true) { + let encode = html ? Ext.htmlEncode : (value) => value; + return Ext.String.format( + gettext( + 'The creation of storage "{0}" is not confirmed. Press {1} to check for it and to create it again if it is missing.', + ), + encode(this.unconfirmedStorage.params.storage), + encode(this.actionText()), + ); + }, + showOperationNotice: function (text) { let notice = this.down('#operationNotice'); notice.setValue(text ? ` ${text}` : ''); @@ -357,8 +413,54 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { this.updateActions(); }, + reconcileStorage: function () { + let me = this; + let pending = me.unconfirmedStorage; + if (!pending || me.busy) { + return; + } + let fail = (msg) => { + me.showOperationNotice(msg); + Ext.Msg.alert(gettext('Error'), msg); + }; + let absent = function () { + // Creation checks the ID under the storage configuration lock and writes it last. + me.createStorage(pending.params, pending.next, pending.errorHtml, true); + }; + me.request({ + url: `/storage/${encodeURIComponent(pending.params.storage)}`, + method: 'GET', + success: function (response) { + if ( + !PVE.storage.wizard.storageMatches(pending.params, response.result.data || {}) + ) { + fail( + Ext.String.format( + gettext('Storage ID "{0}" is already in use with different settings.'), + Ext.htmlEncode(pending.params.storage), + ), + ); + return; + } + delete me.unconfirmedStorage; + me.showOperationNotice(); + pending.next(); + }, + failure: function (response) { + let missing = + response.result?.message?.trim() === + `storage '${pending.params.storage}' does not exist`; + if (!PVE.SANUtils.request_uncertain(response) && missing) { + absent(); + } else { + fail(me.storageUnknownText() + '

' + response.htmlStatus); + } + }, + }); + }, + // errorHtml (safe HTML) is prepended to an error - createStorage: function (params, next, errorHtml) { + createStorage: function (params, next, errorHtml, reconciled) { let me = this; params = Ext.apply({}, params); Ext.Object.each(params, function (key, value) { @@ -370,8 +472,24 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { url: '/storage', method: 'POST', params: params, - success: () => next(), + success: function () { + delete me.unconfirmedStorage; + me.showOperationNotice(); + next(); + }, failure: function (response) { + let uncertain = PVE.SANUtils.request_uncertain(response); + let exists = /already defined/i.test(response.result?.message || ''); + if (PVE.storage.wizard.reconcile[params.type] && (uncertain || exists)) { + me.unconfirmedStorage = { params: Ext.clone(params), next, errorHtml }; + me.freeze(); + me.updateActions(); + me.showOperationNotice(me.storageUnknownText()); + if (exists && !reconciled) { + me.reconcileStorage(); + } + return; + } let msg = response.htmlStatus; if (errorHtml) { msg = `${errorHtml}

${msg}`; @@ -596,6 +714,10 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { if (!wizard.canSubmit()) { return; } + if (wizard.unconfirmedStorage) { + wizard.reconcileStorage(); + return; + } let values = wizard.getValues(); delete values.delete; let typeEntry = PVE.storage.wizard.types[wizard.wizardType]; -- 2.47.3