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, cluster guest configurations in `/etc/pve/nodes`, including offline guests,
snapshots, pending changes, EFI/TPM disks, and detached `unusedN` entries. snapshots, pending changes, EFI/TPM disks, and detached `unusedN` entries.
Shared storage IDs are scanned once; local storage remains scoped to its node. 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. Backups, ISOs, unmanaged files, and physical/passthrough disks are excluded.
An **Orphan candidate** has no configuration reference. **Unused disk** means An **Orphan candidate** has no configuration reference. **Unused disk** means
+1 -1
View File
@@ -3,7 +3,7 @@
action="${1:-}" action="${1:-}"
FOLDER='/opt/idssys/ta-proxmenu' FOLDER='/opt/idssys/ta-proxmenu'
VERS='2026.9.22-6' VERS='2026.9.22-7'
noupdate=' ' noupdate=' '
+20 -5
View File
@@ -3,6 +3,7 @@
import argparse import argparse
import csv import csv
import json import json
import math
from pathlib import Path from pathlib import Path
import re import re
import subprocess import subprocess
@@ -146,7 +147,7 @@ def scan(root=Path('/etc/pve/nodes'), get=api):
for volume in disks: for volume in disks:
result = classify(volume, name, shared, refs, bool(errors)) result = classify(volume, name, shared, refs, bool(errors))
if result: 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. # Detect migrations/config changes during this non-atomic inventory.
_, after_errors, after = references(root, nodes, guests) _, after_errors, after = references(root, nodes, guests)
if after != before or after_errors: if after != before or after_errors:
@@ -156,6 +157,19 @@ def scan(root=Path('/etc/pve/nodes'), get=api):
return rows, warnings, coverage 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): def render(rows, warnings, coverage):
print('\nCluster orphaned / unused disk report (read-only)') print('\nCluster orphaned / unused disk report (read-only)')
print('Coverage: ' + ('INCOMPLETE / NEEDS REVIEW' if warnings else 'all discovered disk storage scanned')) 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']): if group != (row['node'], row['storage']):
group = (row['node'], row['storage']) group = (row['node'], row['storage'])
print(f'\n{group[0]} / {group[1]}') print(f'\n{group[0]} / {group[1]}')
size = float(row['size_bytes'] or 0) / (1024 ** 3) size = format_size(row['size_bytes'])
print(f" {row['volume']} {size:.2f} GiB {row['status']}\n {row['detail']}") print(f" {row['volume']} Disk size: {size} {row['status']}\n {row['detail']}")
print(f'\n{len(rows)} volume(s) reported.') print(f'\n{len(rows)} volume(s) reported.')
if not rows: if not rows:
print('No unreferenced or unused volumes found in the successfully scanned storage.') 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): def export_csv(path, rows, warnings):
# Exclusive creation avoids overwriting existing files, including symlink targets. # Exclusive creation avoids overwriting existing files, including symlink targets.
with open(path, 'x', newline='') as handle: 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() writer.writeheader()
for row in rows: 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: if warnings and not rows:
writer.writerow({'status': 'Incomplete scan', 'coverage_warnings': '; '.join(warnings)}) writer.writerow({'status': 'Incomplete scan', 'coverage_warnings': '; '.join(warnings)})
+21
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import contextlib import contextlib
import csv
import importlib.util import importlib.util
import io import io
from pathlib import Path from pathlib import Path
@@ -88,6 +89,26 @@ class ReportTests(unittest.TestCase):
with self.assertRaises(FileExistsError): with self.assertRaises(FileExistsError):
disks.export_csv(path, [], []) 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 test_offline_node_and_config_change(self):
def get(path, *options): def get(path, *options):
if path == '/nodes': if path == '/nodes':