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 F40491FF0AF for ; Fri, 06 Nov 2026 02:00:29 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 209E721FC6; Fri, 06 Nov 2026 01:51:26 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 manager 76/76] ui: san: check the other nodes for users before wiping a LUN Date: Fri, 6 Nov 2026 00:31:51 +0100 Message-ID: <20261105233336.2178064-77-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: 1793926182805 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.292 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: LBKJDHFJ37IEW4H4TZSIJ7ALJGNBE4KI X-Message-ID-Hash: LBKJDHFJ37IEW4H4TZSIJ7ALJGNBE4KI 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: Wiping a LUN checked the storage configuration of the cluster and the users on the node that wipes, but not what other nodes use, for example a logical volume of a hand-made volume group active there. One confirmation could destroy data that another node still uses. When the wipe dialog opens, ask every other node what uses the LUN. Keep Wipe disabled until they answered and if another node reports a user, naming the node and the user. List the nodes that could not be checked, such as offline ones, without refusing the wipe. Like the check of the local node, this sees mounts and devices built on the LUN, not programs that open it directly, which the dialog still asks to rule out. The check runs in the web UI; the wipe API checks the node that wipes. Signed-off-by: Thomas Lamprecht --- test/SANUtils_test.js | 57 ++++++++++++++++++++++++++ www/manager6/SANUtils.js | 23 ++++++++++- www/manager6/window/SANLunWipe.js | 66 +++++++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/test/SANUtils_test.js b/test/SANUtils_test.js index f60e23b73..1ecc01904 100644 --- a/test/SANUtils_test.js +++ b/test/SANUtils_test.js @@ -8,6 +8,16 @@ const { test } = require('node:test'); function sanUtils(translations = {}) { const sandbox = { PVE: { + data: { + ResourceStore: { + getNodes: () => [ + { node: 'n1', status: 'online' }, + { node: 'n2', status: 'online' }, + { node: 'n3', status: 'online' }, + { node: 'n4', status: 'offline' }, + ], + }, + }, window: { SANLunWipe: { texts: () => ({ unchecked: 'Could not check whether this node uses the LUN.' }), @@ -145,3 +155,50 @@ test('local users of a LUN in its usage tooltip', () => { 'not again next to the refusal to wipe that names the users', ); }); + +test('users of a LUN on other nodes', () => { + const utils = sanUtils(); + const id = '3600a0980383235'; + const scans = { + n1: [{ devpath: '/dev/mapper/x', wwid: id, multipath: 'storage', 'in-use': 'no' }], + n2: [ + { + devpath: '/dev/sdb', + wwid: id, + multipath: 'none', + 'in-use': 'yes', + 'in-use-by': 'sdb held by vg-lv', + }, + { devpath: '/dev/sdc', wwid: id, multipath: 'none', 'in-use': 'no' }, + { + devpath: '/dev/nvme0n1', + wwn: 'uuid.1', + multipath: 'native', + 'in-use': 'yes', + 'in-use-by': 'nvme0n1 held by nv-lv', + }, + ], + n3: [{ devpath: '/dev/mapper/y', wwid: id, multipath: 'storage', 'in-use': 'unknown' }], + }; + let result; + utils.scan_nodes_for_luns.call( + utils, + (res) => (result = res), + (options) => { + const node = options.url.split('/')[2]; + options.success({ result: { data: scans[node] } }); + }, + ); + assert.equal( + JSON.stringify(result.users(id)), + JSON.stringify([{ node: 'n2', users: 'sdb held by vg-lv' }]), + 'a path user on another node', + ); + assert.equal( + JSON.stringify(result.users('uuid.1')), + JSON.stringify([{ node: 'n2', users: 'nvme0n1 held by nv-lv' }]), + 'namespaces by their identity', + ); + assert.equal(result.uncheckedUse(id).join(' '), 'n3', 'a node that could not check'); + assert.equal(result.unchecked.join(' '), 'n4', 'an offline node'); +}); diff --git a/www/manager6/SANUtils.js b/www/manager6/SANUtils.js index dbef08c84..cbe0887e8 100644 --- a/www/manager6/SANUtils.js +++ b/www/manager6/SANUtils.js @@ -246,14 +246,17 @@ Ext.define('PVE.SANUtils', { return res; }, - // Unchecked nodes (offline, failed or timed out) may still see a LUN. pathUsers misses process - // opens, so an empty list proves nothing. + // Unchecked nodes (offline, failed or timed out) may still see a LUN. pathUsers and users miss + // process opens, so an empty list proves nothing. users(id) lists what each node reports using + // the LUN of that WWID or namespace identity, uncheckedUse(id) the nodes that could not tell. scan_nodes_for_luns: function ( callback, request = (options) => Proxmox.Utils.API2Request(options), ) { let wwids = {}; let pathUsers = {}; + let lunUsers = {}; + let unknownUse = {}; let unchecked = []; let nodes = PVE.data.ResourceStore.getNodes(); let pending = nodes.length; @@ -268,6 +271,15 @@ Ext.define('PVE.SANUtils', { .sort() .filter((node) => pathUsers[node][wwid]) .map((node) => ({ node, users: pathUsers[node][wwid].join(', ') })), + users: (id) => + Object.keys(lunUsers) + .sort() + .filter((node) => lunUsers[node][id]) + .map((node) => ({ node, users: lunUsers[node][id].join(', ') })), + uncheckedUse: (id) => + Object.keys(unknownUse) + .filter((node) => unknownUse[node][id]) + .sort(), unchecked: unchecked.sort(), }); }; @@ -303,6 +315,13 @@ Ext.define('PVE.SANUtils', { } wwids[node.node] = new Set(rows.map((row) => row.wwid).filter((w) => w)); for (const row of rows) { + let id = row.wwid ?? row.wwn; + if (id && row['in-use'] === 'yes') { + let list = ((lunUsers[node.node] ??= {})[id] ??= []); + list.push(row['in-use-by'] || row.devpath); + } else if (id && row['in-use'] !== 'no') { + (unknownUse[node.node] ??= {})[id] = true; + } let onPath = ['none', 'pending'].includes(row.multipath); if (!row.wwid || !onPath || !['yes', 'unknown'].includes(row['in-use'])) { continue; diff --git a/www/manager6/window/SANLunWipe.js b/www/manager6/window/SANLunWipe.js index ec8c8fe77..6bd2b58a9 100644 --- a/www/manager6/window/SANLunWipe.js +++ b/www/manager6/window/SANLunWipe.js @@ -34,8 +34,12 @@ Ext.define('PVE.window.SANLunWipe', { '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.', + 'The node checks again for local use before wiping. Other clusters and hosts are not checked, nor programs on other nodes that open the LUN directly. Confirm that it is no longer needed anywhere.', ), + checking: gettext('Checking the other nodes for users of the LUN...'), + otherUsers: gettext('Another node uses the LUN. Stop that use before wiping it.'), + otherUnchecked: gettext('Could not check these nodes for users of the LUN: {0}'), + otherNone: gettext('The other nodes report no users of the LUN.'), unknownRequest: gettext( 'The outcome of the wipe request is unknown. Check the task history of node "{0}" before you use this LUN.', ), @@ -166,6 +170,7 @@ Ext.define('PVE.window.SANLunWipe', { { xtype: 'displayfield', value: text }, { xtype: 'displayfield', value: t.consequence }, { xtype: 'displayfield', value: t.ownership }, + { xtype: 'displayfield', itemId: 'nodesLine', value: t.checking }, { xtype: 'displayfield', itemId: 'errorLine', hidden: true }, { xtype: 'textfield', @@ -175,8 +180,7 @@ Ext.define('PVE.window.SANLunWipe', { labelAlign: 'top', emptyText: lun.vgname, listeners: { - change: (field, value) => - me.down('#wipeButton').setDisabled(me.submitted || value !== lun.vgname), + change: () => me.updateWipeButton(), specialkey: function (field, event) { if ( event.getKey() === event.ENTER && @@ -194,7 +198,7 @@ Ext.define('PVE.window.SANLunWipe', { text: gettext('Wipe'), itemId: 'wipeButton', cls: 'pve-dangerous-button', - disabled: !!lun.vgname, + disabled: true, handler: () => me.startWipe(), }, { @@ -207,6 +211,7 @@ Ext.define('PVE.window.SANLunWipe', { me.callParent(); me.on('beforeclose', () => !me.pending); + me.checkOtherNodes(); me.on('close', function () { let op = me.operation; if (op && me.requestState !== 'accepted') { @@ -216,6 +221,59 @@ Ext.define('PVE.window.SANLunWipe', { }); }, + // The node that wipes checks itself; ask the others what they see using the LUN. + checkOtherNodes: function () { + let me = this; + let enc = Ext.htmlEncode; + let t = PVE.window.SANLunWipe.texts(me.lun); + let id = me.lun.wwid || me.lun.wwn; + PVE.SANUtils.scan_nodes_for_luns(function (result) { + if (me.destroyed) { + return; + } + let others = (node) => node !== me.node; + let users = id ? result.users(id).filter((entry) => others(entry.node)) : []; + let unchecked = [ + ...new Set([...result.unchecked, ...(id ? result.uncheckedUse(id) : [])]), + ].filter(others); + if (!id) { + unchecked = PVE.data.ResourceStore.getNodes() + .map((node) => node.node) + .filter(others); + } + let lines; + if (users.length) { + lines = users.map((entry) => + enc( + Ext.String.format( + gettext('In use on node "{0}": {1}'), + entry.node, + entry.users, + ), + ), + ); + lines.push(enc(t.otherUsers)); + } else if (unchecked.length) { + lines = [enc(Ext.String.format(t.otherUnchecked, unchecked.join(', ')))]; + } else { + lines = [enc(t.otherNone)]; + } + me.down('#nodesLine').setValue(lines.join('
')); + me.otherUsers = users.length > 0; + me.nodesChecked = true; + me.updateWipeButton(); + }); + }, + + updateWipeButton: function () { + let me = this; + let vgname = me.lun.vgname; + let typed = !vgname || me.down('#confirmField').getValue() === vgname; + me.down('#wipeButton').setDisabled( + me.submitted || !me.nodesChecked || me.otherUsers || !typed, + ); + }, + startWipe: function () { let me = this; if (me.submitted || me.down('#wipeButton').isDisabled()) { -- 2.47.3