diff --git a/README.md b/README.md index bf03ccd..0c60c3b 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,42 @@ cpu Detect and optionally apply a migration-safe CPU model maintenance Toggle local HA maintenance mode keepalived Deploy Keepalived across the cluster iso-nfs Create an LXC NFS server and cluster-wide shared ISO storage +orphan-disks Report cluster-wide orphan candidates and unused disks (read-only) ``` +## Orphaned and unused disks + +Open **Cluster & Maintenance → Find orphaned / unused disks (read-only)**. +The menu offers CSV export after displaying the report. Direct commands: + +```bash +tapm orphan-disks +tapm orphan-disks --csv /root/orphan-disks.csv +``` + +Run as root on a Proxmox node. The report uses `pvesh` and Python 3, inventories +online nodes' storage-managed VM and container volumes, and compares them with +cluster guest configurations in `/etc/pve/nodes`, including offline guests, +snapshots, pending changes, EFI/TPM disks, and detached `unusedN` entries. +Shared storage IDs are scanned once; local storage remains scoped to its node. +Results are grouped by node and storage, with volume IDs and provisioned sizes. +Backups, ISOs, unmanaged files, and physical/passthrough disks are excluded. + +An **Orphan candidate** has no configuration reference. **Unused disk** means +only current `unusedN` entries reference it. Remote-node references and possible +template/clone backing relationships are marked **Needs review**. This is an +inventory, not proof that a volume can be deleted: replication copies, storage +aliases, and backend-specific backing dependencies require review. No guest or +storage changes are made. + +Offline nodes, inactive storage, and failed reads are reported as incomplete +coverage; missing guest configurations prevent orphan classification. Changes +to guest configurations during the scan require a rerun. Scan when migration, +replication, and disk operations are idle because the inventory is not atomic. +CSV export includes coverage warnings and refuses to overwrite an existing file. +Exit status is 0 for a completed scan, 2 for incomplete coverage or changed +configurations, and 1 for a fatal scan/export error. + ## Companion files Large installer artifacts used by this project are stored in the private diff --git a/inc/orphan-disks.py b/inc/orphan-disks.py new file mode 100644 index 0000000..3b0eaab --- /dev/null +++ b/inc/orphan-disks.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Read-only Proxmox storage/reference inventory; no third-party dependencies.""" +import argparse +import csv +import json +from pathlib import Path +import re +import subprocess +import sys + +VOLUME = re.compile(r"(?/dev/null || ! command -v pvesh >/dev/null; then + printf 'Disk reporting requires python3 and pvesh on a Proxmox host.\n' + FINISH_FAILED_ACTION + return 1 + fi + python3 /opt/idssys/ta-proxmenu/inc/orphan-disks.py "$@" + result=$? + if (( ACTION_REQUESTED == 1 )); then + exit "$result" + fi + ENTER2CONTINUE + return "$result" +} + CLUSTER_MENU() { local -a labels - local -a values=("maintenance" "services" "keepalived" "iso_nfs") + local -a values=("maintenance" "services" "keepalived" "iso_nfs" "orphan_disks") local maintenance_status local node @@ -2470,12 +2486,14 @@ CLUSTER_MENU() { labels+=("Deploy/reconfigure Keepalived (installed locally)") || labels+=("Deploy Keepalived on all cluster hosts") labels+=("Create shared ISO storage using an LXC NFS server") + labels+=("Find orphaned / unused disks (read-only)") SELECT_MENU "Cluster & Maintenance" labels values case "$MENU_SELECTION" in maintenance) MAINTENANCE_MODE;; services) SERVICE_RECOVERY_MENU;; keepalived) INSTALL_KEEPALIVE;; + orphan_disks) TAPM_ORPHAN_DISK_REPORT --interactive;; iso_nfs) TAPM_DEPLOY_ISO_NFS_LXC FINISH_ACTION @@ -2763,6 +2781,10 @@ if (( ACTION_REQUESTED == 1 )); then maintenance|mm) MAINTENANCE_MODE;; keepalived) INSTALL_KEEPALIVE;; iso-nfs|iso_nfs) TAPM_DEPLOY_ISO_NFS_LXC;; + orphan-disks|orphan_disks) + shift + TAPM_ORPHAN_DISK_REPORT "${@}" + ;; *) MAIN_MENU;; esac else diff --git a/tests/test-orphan-disks.py b/tests/test-orphan-disks.py new file mode 100644 index 0000000..ff4d72c --- /dev/null +++ b/tests/test-orphan-disks.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +import contextlib +import importlib.util +import io +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('disks', Path(__file__).resolve().parents[1] / 'inc/orphan-disks.py') +disks = importlib.util.module_from_spec(spec) +spec.loader.exec_module(disks) + + +class ReportTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + for node in ('a', 'b'): + for kind in ('qemu-server', 'lxc'): + (self.root / node / kind).mkdir(parents=True) + (self.root / 'a/qemu-server/100.conf').write_text( + 'scsi0: local:vm-100-disk-0,size=10G\n' + 'unused0: local:vm-100-disk-1\n' + 'efidisk0: shared:vm-100-efi\n' + 'tpmstate0: shared:vm-100-tpm\n' + '[snap]\nscsi0: local:vm-100-old\n' + '[PENDING]\nscsi1: local:vm-100-pending\n') + (self.root / 'b/lxc/200.conf').write_text('rootfs: local:subvol-200-disk-0\nmp0: shared:subvol-200-disk-1,mp=/data\n') + self.nodes = [{'node': n, 'status': 'online'} for n in ('a', 'b')] + self.guests = [{'node': 'a', 'type': 'qemu', 'vmid': 100}, {'node': 'b', 'type': 'lxc', 'vmid': 200}] + self.refs, self.errors, _ = disks.references(self.root, self.nodes, self.guests) + + def result(self, vol, node='a', shared=False, incomplete=False): + return disks.classify({'volid': vol}, node, shared, self.refs, incomplete) + + def test_current_snapshots_pending_and_containers_are_protected(self): + self.assertEqual(self.errors, []) + for vol in ('vm-100-disk-0', 'vm-100-old', 'vm-100-pending'): + self.assertIsNone(self.result('local:' + vol)) + for vol in ('vm-100-efi', 'vm-100-tpm', 'subvol-200-disk-1'): + self.assertIsNone(self.result('shared:' + vol, shared=True)) + self.assertIsNone(self.result('local:subvol-200-disk-0', node='b')) + + def test_candidates_unused_replication_and_backing(self): + self.assertEqual(self.result('local:vm-999-disk-0')[0], 'Orphan candidate') + self.assertEqual(self.result('local:vm-100-disk-1')[0], 'Unused disk') + self.assertEqual(self.result('local:vm-100-disk-0', node='b')[0], 'Needs review') + self.assertEqual(self.result('local:base-900-disk-0')[0], 'Needs review') + self.assertEqual(self.result('local:999/base-999-disk-0.qcow2')[0], 'Needs review') + self.assertEqual(self.result('local:vm-999-disk-0', incomplete=True)[0], 'Needs review') + + def test_missing_config_is_incomplete(self): + (self.root / 'a/qemu-server/100.conf').unlink() + _, errors, _ = disks.references(self.root, self.nodes, self.guests) + self.assertTrue(errors) + + def test_scan_deduplicates_shared_but_not_local_and_reports_failures(self): + calls = [] + def get(path, *options): + calls.append(path) + if path == '/nodes': + return self.nodes + if path == '/cluster/resources': + return self.guests + if path.endswith('/storage'): + return [{'storage': sid, 'shared': shared, 'active': 1, 'content': 'images,rootdir'} + for sid, shared in [('shared', 1), ('local', 0), ('broken', 0)]] + if '/broken/' in path: + raise RuntimeError('unavailable') + sid = path.split('/')[-2] + return [{'volid': sid + ':vm-999-disk-0', 'content': 'images', 'size': 1024}, + {'volid': sid + ':iso/test.iso', 'content': 'iso'}] + with contextlib.redirect_stderr(io.StringIO()): + rows, warnings, coverage = disks.scan(self.root, get) + self.assertEqual(len(rows), 3) + self.assertEqual(len(warnings), 2) + self.assertEqual(len(coverage), 3) + self.assertNotIn('/nodes/b/storage/shared/content', calls) + + def test_csv_preserves_warnings_and_refuses_overwrite(self): + path = self.root / 'report.csv' + disks.export_csv(path, [], ['node offline']) + self.assertIn('node offline', path.read_text()) + with self.assertRaises(FileExistsError): + disks.export_csv(path, [], []) + + def test_offline_node_and_config_change(self): + def get(path, *options): + if path == '/nodes': + return [self.nodes[0], {'node': 'b', 'status': 'offline'}] + if path == '/cluster/resources': + return self.guests + if path.endswith('/storage'): + return [{'storage': 'local', 'content': 'images', 'active': 1}] + (self.root / 'a/qemu-server/100.conf').write_text('scsi0: local:vm-999-disk-0\n') + return [{'volid': 'local:vm-999-disk-0', 'content': 'images'}] + with contextlib.redirect_stderr(io.StringIO()): + rows, warnings, _ = disks.scan(self.root, get) + self.assertEqual(rows[0]['status'], 'Needs review') + self.assertTrue(any('offline' in warning for warning in warnings)) + self.assertTrue(any('changed' in warning for warning in warnings)) + + def test_launcher_padded_arguments(self): + from unittest.mock import patch + with patch.object(disks.sys, 'argv', ['orphan-disks.py', '', '', '']), \ + patch.object(disks, 'scan', return_value=([], [], [])), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(disks.main(), 0) + + def test_api_is_read_only(self): + from unittest.mock import patch + with patch.object(disks.subprocess, 'run') as run: + run.return_value.returncode = 0 + run.return_value.stdout = '[]' + disks.api('/nodes') + self.assertEqual(run.call_args.args[0], ['pvesh', 'get', '/nodes', '--output-format', 'json']) + + +if __name__ == '__main__': + unittest.main()