orphan disk search update

This commit is contained in:
David Schroeder
2026-09-22 22:17:39 -05:00
parent 431eee5b72
commit cce22283d5
4 changed files with 45 additions and 7 deletions
+3 -1
View File
@@ -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
+1 -1
View File
@@ -3,7 +3,7 @@
action="${1:-}"
FOLDER='/opt/idssys/ta-proxmenu'
VERS='2026.9.22-6'
VERS='2026.9.22-7'
noupdate=' '
+20 -5
View File
@@ -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)})
+21
View File
@@ -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':