From cce22283d5071f96bb00767277f159be2277a702 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 22 Sep 2026 22:17:39 -0500 Subject: [PATCH] orphan disk search update --- README.md | 4 +++- defaults.inc | 2 +- inc/orphan-disks.py | 25 ++++++++++++++++++++----- tests/test-orphan-disks.py | 21 +++++++++++++++++++++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3a52929..156d355 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ 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. +Results are grouped by node and storage, with volume IDs and provisioned disk +sizes in readable units (MiB, GiB, or TiB). CSV includes both `disk_size` and +`size_bytes`; unavailable sizes are displayed as `Unknown`. Backups, ISOs, unmanaged files, and physical/passthrough disks are excluded. An **Orphan candidate** has no configuration reference. **Unused disk** means diff --git a/defaults.inc b/defaults.inc index f7efde9..31dc224 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.9.22-6' +VERS='2026.9.22-7' noupdate=' ' diff --git a/inc/orphan-disks.py b/inc/orphan-disks.py index bfc35e7..3c7fa27 100755 --- a/inc/orphan-disks.py +++ b/inc/orphan-disks.py @@ -3,6 +3,7 @@ import argparse import csv import json +import math from pathlib import Path import re import subprocess @@ -146,7 +147,7 @@ def scan(root=Path('/etc/pve/nodes'), get=api): for volume in disks: result = classify(volume, name, shared, refs, bool(errors)) if result: - rows.append(dict(zip(FIELDS, [label, sid, volume['volid'], volume.get('size', 0), *result]))) + rows.append(dict(zip(FIELDS, [label, sid, volume['volid'], volume.get('size'), *result]))) # Detect migrations/config changes during this non-atomic inventory. _, after_errors, after = references(root, nodes, guests) if after != before or after_errors: @@ -156,6 +157,19 @@ def scan(root=Path('/etc/pve/nodes'), get=api): return rows, warnings, coverage +def format_size(size_bytes): + try: + size = float(size_bytes) + except (TypeError, ValueError): + return 'Unknown' + if not math.isfinite(size) or size < 0: + return 'Unknown' + for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'): + if size < 1024 or unit == 'PiB': + return f'{size:.2f} {unit}' + size /= 1024 + + def render(rows, warnings, coverage): print('\nCluster orphaned / unused disk report (read-only)') print('Coverage: ' + ('INCOMPLETE / NEEDS REVIEW' if warnings else 'all discovered disk storage scanned')) @@ -169,8 +183,8 @@ def render(rows, warnings, coverage): if group != (row['node'], row['storage']): group = (row['node'], row['storage']) print(f'\n{group[0]} / {group[1]}') - size = float(row['size_bytes'] or 0) / (1024 ** 3) - print(f" {row['volume']} {size:.2f} GiB {row['status']}\n {row['detail']}") + size = format_size(row['size_bytes']) + print(f" {row['volume']} Disk size: {size} {row['status']}\n {row['detail']}") print(f'\n{len(rows)} volume(s) reported.') if not rows: print('No unreferenced or unused volumes found in the successfully scanned storage.') @@ -179,10 +193,11 @@ def render(rows, warnings, coverage): def export_csv(path, rows, warnings): # Exclusive creation avoids overwriting existing files, including symlink targets. with open(path, 'x', newline='') as handle: - writer = csv.DictWriter(handle, fieldnames=FIELDS + ['coverage_warnings']) + writer = csv.DictWriter(handle, fieldnames=FIELDS + ['disk_size', 'coverage_warnings']) writer.writeheader() for row in rows: - writer.writerow(dict(row, coverage_warnings='; '.join(warnings))) + writer.writerow(dict(row, disk_size=format_size(row['size_bytes']), + coverage_warnings='; '.join(warnings))) if warnings and not rows: writer.writerow({'status': 'Incomplete scan', 'coverage_warnings': '; '.join(warnings)}) diff --git a/tests/test-orphan-disks.py b/tests/test-orphan-disks.py index 15cc907..f248ff2 100644 --- a/tests/test-orphan-disks.py +++ b/tests/test-orphan-disks.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import contextlib +import csv import importlib.util import io from pathlib import Path @@ -88,6 +89,26 @@ class ReportTests(unittest.TestCase): with self.assertRaises(FileExistsError): disks.export_csv(path, [], []) + def test_disk_sizes_in_display_and_csv(self): + for index, (size, expected) in enumerate([ + (4 * 1024 ** 2, '4.00 MiB'), + (100 * 1024 ** 3, '100.00 GiB'), + (2 * 1024 ** 4, '2.00 TiB'), + (0, '0.00 B'), (None, 'Unknown')]): + with self.subTest(size=size): + row = dict(zip(disks.FIELDS, ['a', 'local', 'local:vm-100-disk-1', + size, 'Unused disk', 'app-server (VM 100)'])) + output = io.StringIO() + with contextlib.redirect_stdout(output): + disks.render([row], [], []) + self.assertIn('Disk size: ' + expected, output.getvalue()) + path = self.root / f'sizes-{index}.csv' + disks.export_csv(path, [row], []) + with path.open(newline='') as handle: + exported = next(csv.DictReader(handle)) + self.assertEqual(exported['disk_size'], expected) + self.assertEqual(exported['size_bytes'], '' if size is None else str(size)) + def test_offline_node_and_config_change(self): def get(path, *options): if path == '/nodes':