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 387911FF0AF for ; Fri, 06 Nov 2026 02:00:47 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id C716D2201D; Fri, 06 Nov 2026 01:51:26 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 manager 70/76] ui: node: san: wipe a LUN that no storage uses Date: Fri, 6 Nov 2026 00:31:45 +0100 Message-ID: <20261105233336.2178064-71-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: 1793926182428 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.291 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: WWVEAV7RFYYMPJG3OPZK6H2IUSDBRVXM X-Message-ID-Hash: WWVEAV7RFYYMPJG3OPZK6H2IUSDBRVXM 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: Offer root@pam a wipe only when visible storage references, local usage, and paths allow it. Missing volume-group metadata does not prove a PV is orphaned. Show refusal reasons and confirm device identity, data loss, and the need to check other hosts. The backend repeats the authoritative checks before writing. The node view records the operation before submission, tracks its task, and refreshes after every outcome. An unanswered request blocks another wipe until the administrator checks task history and dismisses it. Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 1 + www/manager6/SANUtils.js | 2 + www/manager6/node/SANLuns.js | 145 ++++++++++++++++ www/manager6/window/SANLunWipe.js | 273 ++++++++++++++++++++++++++++++ 4 files changed, 421 insertions(+) create mode 100644 www/manager6/window/SANLunWipe.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index cd64e2366..8717d041a 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -138,6 +138,7 @@ JSSRC= \ window/Restore.js \ window/SafeDestroyGuest.js \ window/SafeDestroyStorage.js \ + window/SANLunWipe.js \ window/Settings.js \ window/Snapshot.js \ window/StartupEdit.js \ diff --git a/www/manager6/SANUtils.js b/www/manager6/SANUtils.js index bd8115e31..7bbb287e2 100644 --- a/www/manager6/SANUtils.js +++ b/www/manager6/SANUtils.js @@ -18,6 +18,8 @@ Ext.define('PVE.SANUtils', { 'multipath', 'multipath-storage', 'duplicate-pv', + 'in-use', + 'in-use-by', 'by-id', ], diff --git a/www/manager6/node/SANLuns.js b/www/manager6/node/SANLuns.js index 01c21f2b7..663acdea5 100644 --- a/www/manager6/node/SANLuns.js +++ b/www/manager6/node/SANLuns.js @@ -178,6 +178,119 @@ Ext.define('PVE.node.SANLunList', { me.getSelectionModel().deselectAll(); me.getStore().load(); me.loadNodeStatus(); + me.loadStorageConfig(); + }, + + loadStorageConfig: function () { + let me = this; + let token = (me.storageConfigToken = (me.storageConfigToken ?? 0) + 1); + let apply = function (storages) { + if (me.destroyed || token !== me.storageConfigToken) { + return; + } + me.storageConfig = storages; + me.getSelectionModel().fireEvent( + 'selectionchange', + me.getSelectionModel(), + me.getSelection(), + ); + }; + apply(undefined); + Proxmox.Utils.API2Request({ + url: '/storage', + method: 'GET', + failure: () => apply(undefined), + success: (response) => apply(response.result.data || []), + }); + }, + + wipeReason: function (rec) { + let me = this; + if (me.wipeOperation) { + return gettext( + 'Wait for the previous wipe to finish. If its outcome is unknown, check the task history of the node, then press Dismiss.', + ); + } + if (!rec) { + return gettext('Select a device to wipe.'); + } + if (!me.storageConfig) { + return gettext('The storage configuration could not be read.'); + } + return PVE.window.SANLunWipe.reason(rec.data, me.storageConfig); + }, + + updateWipeButton: function () { + let button = this.down('#wipeButton'); + if (!button) { + return; + } + let reason = this.wipeReason(this.getSelection()[0]); + button.setDisabled(!!reason); + button.setTooltip(reason ? Ext.htmlEncode(reason) : ''); + }, + + wipeLun: function (rec) { + let me = this; + if (me.wipeReason(rec)) { + return; + } + let refresh = () => !me.destroyed && me.reload(); + Ext.create('PVE.window.SANLunWipe', { + node: me.nodename, + lun: rec.data, + autoShow: true, + onSubmit: function (op) { + me.wipeOperation = op; + me.updateWipeButton(); + }, + onClose: function (op) { + if (me.destroyed) { + return; + } + if (op.state === 'unknown') { + me.down('#wipeProblem').setLines([ + Ext.htmlEncode( + Ext.String.format( + PVE.window.SANLunWipe.texts(op.lun).unknownRequest, + op.node, + ), + ), + ]); + me.down('#clearWipe').show(); + } else { + delete me.wipeOperation; + } + refresh(); + }, + callback: function (op) { + Ext.create('Proxmox.window.TaskProgress', { + upid: op.upid, + autoShow: true, + taskDone: function () { + if (!me.destroyed && me.wipeOperation === op) { + delete me.wipeOperation; + me.down('#wipeProblem').hide(); + me.down('#clearWipe').hide(); + } + refresh(); + }, + listeners: { + destroy: function () { + if (!me.destroyed && me.wipeOperation === op) { + me.down('#wipeProblem').setLines([ + gettext( + 'The wipe task may still be running. Check its task log and reload the list before using the device.', + ), + ]); + me.down('#clearWipe').show(); + } + refresh(); + }, + }, + }); + }, + }); }, loadNodeStatus: function () { @@ -592,6 +705,37 @@ Ext.define('PVE.node.SANLunList', { }); } + if (Proxmox.UserName === 'root@pam') { + items.push({ + xtype: 'proxmoxButton', + text: gettext('Wipe'), + itemId: 'wipeButton', + // Disabled buttons still need hover events to explain why wiping is blocked. + style: { pointerEvents: 'auto' }, + iconCls: 'fa fa-eraser', + selModel: sm, + disabled: true, + dangerous: true, + enableFn: (rec) => !me.wipeReason(rec), + handler: (btn, event, rec) => me.wipeLun(rec), + listeners: { + afterrender: () => me.updateWipeButton(), + }, + }); + sm.on('selectionchange', () => me.updateWipeButton()); + } + + items.push({ + text: gettext('Dismiss'), + itemId: 'clearWipe', + hidden: true, + handler: function () { + delete me.wipeOperation; + me.down('#wipeProblem').hide(); + me.down('#clearWipe').hide(); + me.reload(); + }, + }); items.push('->', { xtype: 'proxmoxcheckbox', boxLabel: gettext('Show all devices'), @@ -614,6 +758,7 @@ Ext.define('PVE.node.SANLunList', { me.tbar = items; me.dockedItems = [ + { xtype: 'pveSANNotice', itemId: 'wipeProblem', kind: 'warning' }, { xtype: 'pveSANNotice', itemId: 'nodeProblems', kind: 'warning' }, { xtype: 'pveSANNotice', itemId: 'nodeNotes' }, ]; diff --git a/www/manager6/window/SANLunWipe.js b/www/manager6/window/SANLunWipe.js new file mode 100644 index 000000000..ec8c8fe77 --- /dev/null +++ b/www/manager6/window/SANLunWipe.js @@ -0,0 +1,273 @@ +// The node API (root@pam only) re-checks users, storage references and device identity. The caller +// keeps the operation record past this window and owns its task. +Ext.define('PVE.window.SANLunWipe', { + extend: 'Ext.window.Window', + + modal: true, + width: 500, + bodyPadding: 10, + layout: { type: 'vbox', align: 'stretch' }, + // config: node, lun (scan row), and the callbacks, each with the operation record { node, lun, + // identity, state, upid }: onSubmit(op) before the PUT, callback(op) once the task runs and + // onClose(op) after a submit that got no task, with state unknown or refused + node: undefined, + lun: undefined, + callback: undefined, + + statics: { + texts: function (lun) { + return { + title: gettext('Wipe LUN'), + onlyRoot: gettext('Only root@pam can wipe a LUN.'), + nothing: gettext('The LUN holds nothing to wipe.'), + unknown: gettext('Could not check this LUN for data: {0}'), + unknownVG: gettext( + 'The LUN is an LVM physical volume, but its volume group is not known on this node.', + ), + duplicate: gettext('Another LUN has an LVM physical volume with the same UUID.'), + unchecked: gettext('Could not check whether this node uses the LUN.'), + noPath: gettext('The LUN has no usable path.'), + noName: gettext('Wiping needs a stable device name, but the LUN has none.'), + usedBy: gettext('Storage "{0}" uses the LUN.'), + confirm: gettext('LUN {0} ({1}, {2}) on node "{4}" contains data: {3}.'), + consequence: gettext( + 'Wiping destroys that data. No configured storage was found to use this LUN for data.', + ), + ownership: gettext( + 'The node checks again for local use before wiping, but other nodes, clusters, or hosts may still use the LUN. Confirm that it is no longer needed anywhere.', + ), + unknownRequest: gettext( + 'The outcome of the wipe request is unknown. Check the task history of node "{0}" before you use this LUN.', + ), + unknownTask: gettext( + 'The outcome of wiping LUN {0} ({1} {2}) is unknown. Do not use this LUN until the outcome is known.', + ), + wiping: gettext('Wiping LUN "{0}" on node "{1}"...'), + }; + }, + + holdsText: function (lun) { + if (lun.vgname) { + return Ext.String.format(gettext('volume group "{0}"'), lun.vgname); + } + if (lun.usage === 'lvm') { + return gettext('LVM physical volume'); + } + return ( + { + partitions: gettext('partition table'), + mounted: gettext('mounted file system'), + LVM: gettext('LVM physical volume'), + LVM2_member: gettext('LVM physical volume'), + ZFS: gettext('ZFS pool'), + // a map whose only holders are the partition maps kpartx made of it, holders + // of any other kind make it in use and not wipeable + 'Device Mapper': gettext('partition table'), + }[lun.detail] ?? + (lun.detail + ? Ext.String.format(gettext('signature "{0}"'), lun.detail) + : gettext('unknown signature')) + ); + }, + + // undefined if wipeable; storages is undefined if unreadable; the node checks again + reason: function (lun, storages) { + let t = PVE.window.SANLunWipe.texts(lun); + if (Proxmox.UserName !== 'root@pam') { + return t.onlyRoot; + } + if (lun.usage === 'unknown') { + return Ext.String.format(t.unknown, lun.detail || gettext('unknown')); + } + if ( + (lun.usage === 'lvm' || (lun.usage === 'other' && lun.detail === 'LVM2_member')) && + !lun.vgname && + ![0, '0', false].includes(lun['pv-in-use']) + ) { + return t.unknownVG; + } + if (!['lvm', 'other'].includes(lun.usage)) { + return t.nothing; + } + if (lun['duplicate-pv']) { + return t.duplicate; + } + if (lun['in-use'] === 'yes') { + return Ext.String.format( + gettext('In use on this node: {0}'), + lun['in-use-by'] ?? gettext('unknown'), + ); + } + if (lun['in-use'] !== 'no') { + return t.unchecked; + } + if ((lun['path-devices'] ?? []).length > 1) { + return gettext( + 'Enable multipath for the LUN first. Until then, its paths are separate devices.', + ); + } + let mapped = ['storage', 'manual', 'kept', 'unmanaged'].includes(lun.multipath); + if (mapped && !(lun['paths-active'] > 0)) { + return t.noPath; + } + if (!lun['by-id']) { + return t.noName; + } + if (!storages) { + return gettext('The storage configuration could not be read.'); + } + for (const scfg of storages) { + let lvm = scfg.type === 'lvm' || scfg.type === 'lvmthin'; + let base = `${scfg.base ?? ''}`; + if ( + lvm && + ((lun.vgname && scfg.vgname === lun.vgname) || + (lun.wwid && base.includes(lun.wwid))) + ) { + return Ext.String.format(t.usedBy, scfg.storage); + } + } + // a connection-only storage leaves its LUNs free, a serving one may hold guest disks + // without a signature + let sid = lun['used-by-storage']; + if (sid) { + let scfg = storages.find((entry) => entry.storage === sid); + let connects = scfg?.type === 'iscsi' && `${scfg.content}` === 'none'; + if (!connects) { + return Ext.String.format(t.usedBy, sid); + } + } + return undefined; + }, + }, + + submitted: false, + pending: false, + requestState: 'cancelled', + + initComponent: function () { + let me = this; + let lun = me.lun; + let enc = Ext.htmlEncode; + let number = lun['iscsi-lun'] ?? lun.devpath; + let t = PVE.window.SANLunWipe.texts(lun); + me.title = t.title; + + let text = Ext.String.format( + t.confirm, + enc(number), + enc(Proxmox.Utils.format_size(lun.size)), + enc(lun.wwid ?? lun.wwn ?? lun.devpath), + enc(PVE.window.SANLunWipe.holdsText(lun)), + enc(me.node), + ); + + me.items = [ + { xtype: 'displayfield', value: text }, + { xtype: 'displayfield', value: t.consequence }, + { xtype: 'displayfield', value: t.ownership }, + { xtype: 'displayfield', itemId: 'errorLine', hidden: true }, + { + xtype: 'textfield', + itemId: 'confirmField', + hidden: !lun.vgname, + fieldLabel: gettext('Type the volume group name to confirm'), + labelAlign: 'top', + emptyText: lun.vgname, + listeners: { + change: (field, value) => + me.down('#wipeButton').setDisabled(me.submitted || value !== lun.vgname), + specialkey: function (field, event) { + if ( + event.getKey() === event.ENTER && + !me.down('#wipeButton').isDisabled() + ) { + me.startWipe(); + } + }, + }, + }, + ]; + + me.buttons = [ + { + text: gettext('Wipe'), + itemId: 'wipeButton', + cls: 'pve-dangerous-button', + disabled: !!lun.vgname, + handler: () => me.startWipe(), + }, + { + text: gettext('Cancel'), + itemId: 'cancelButton', + handler: () => me.close(), + }, + ]; + + me.callParent(); + + me.on('beforeclose', () => !me.pending); + me.on('close', function () { + let op = me.operation; + if (op && me.requestState !== 'accepted') { + op.state = me.requestState; + me.onClose?.(op); + } + }); + }, + + startWipe: function () { + let me = this; + if (me.submitted || me.down('#wipeButton').isDisabled()) { + return; + } + me.submitted = true; + me.pending = true; + let op = (me.operation = { + node: me.node, + lun: Ext.clone(me.lun), + identity: me.lun.wwid || me.lun.wwn, + state: 'pending', + }); + me.onSubmit?.(op); + me.down('#confirmField').disable(); + me.down('#wipeButton').setDisabled(true); + me.down('#cancelButton').setDisabled(true); + me.setLoading(gettext('Wiping...')); + Proxmox.Utils.API2Request({ + url: `/nodes/${me.node}/disks/wipedisk`, + method: 'PUT', + params: { disk: me.lun['by-id'] }, + failure: function (response) { + me.pending = false; + me.setLoading(false); + me.down('#cancelButton').setDisabled(false); + if (PVE.SANUtils.request_uncertain(response)) { + me.requestState = 'unknown'; + me.close(); + return; + } + me.requestState = 'refused'; + me.down('#errorLine').setValue(response.htmlStatus); + me.down('#errorLine').show(); + }, + success: function (response) { + me.pending = false; + let upid = response.result.data; + try { + Proxmox.Utils.parse_task_upid(upid); + } catch (_error) { + me.requestState = 'unknown'; + me.close(); + return; + } + me.requestState = 'accepted'; + op.upid = upid; + op.state = 'waiting'; + let callback = me.callback; + me.close(); + callback?.(op); + }, + }); + }, +}); -- 2.47.3