orphan disk update

This commit is contained in:
David Schroeder
2026-09-22 22:22:07 -05:00
parent cce22283d5
commit 3ecc338466
4 changed files with 50 additions and 9 deletions
+4 -2
View File
@@ -72,9 +72,11 @@ 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 disk Results are grouped by node and storage, with volume IDs and disk
sizes in readable units (MiB, GiB, or TiB). CSV includes both `disk_size` and sizes in readable units (MiB, GiB, or TiB). CSV includes both `disk_size` and
`size_bytes`; unavailable sizes are displayed as `Unknown`. `size_bytes`. When Proxmox supplies only `approximate-size`, the display and CSV
show a `~` prefix and CSV sets `size_is_approximate` to `True`. Exact sizes take
precedence; 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-7' VERS='2026.9.22-8'
noupdate=' ' noupdate=' '
+11 -6
View File
@@ -10,7 +10,7 @@ import subprocess
import sys import sys
VOLUME = re.compile(r"(?<![\w.-])([\w.-]+:[^\s,;\"']+)") VOLUME = re.compile(r"(?<![\w.-])([\w.-]+:[^\s,;\"']+)")
FIELDS = ['node', 'storage', 'volume', 'size_bytes', 'status', 'detail'] FIELDS = ['node', 'storage', 'volume', 'size_bytes', 'status', 'detail', 'size_is_approximate']
def api(path, *options): def api(path, *options):
@@ -147,7 +147,12 @@ 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'), *result]))) size = volume.get('size')
approximate = False
if format_size(size) == 'Unknown':
size = volume.get('approximate-size')
approximate = format_size(size) != 'Unknown'
rows.append(dict(zip(FIELDS, [label, sid, volume['volid'], size, *result, approximate])))
# 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:
@@ -157,7 +162,7 @@ def scan(root=Path('/etc/pve/nodes'), get=api):
return rows, warnings, coverage return rows, warnings, coverage
def format_size(size_bytes): def format_size(size_bytes, approximate=False):
try: try:
size = float(size_bytes) size = float(size_bytes)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -166,7 +171,7 @@ def format_size(size_bytes):
return 'Unknown' return 'Unknown'
for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'): for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'):
if size < 1024 or unit == 'PiB': if size < 1024 or unit == 'PiB':
return f'{size:.2f} {unit}' return f"{'~' if approximate else ''}{size:.2f} {unit}"
size /= 1024 size /= 1024
@@ -183,7 +188,7 @@ 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 = format_size(row['size_bytes']) size = format_size(row['size_bytes'], row.get('size_is_approximate', False))
print(f" {row['volume']} Disk size: {size} {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:
@@ -196,7 +201,7 @@ def export_csv(path, rows, warnings):
writer = csv.DictWriter(handle, fieldnames=FIELDS + ['disk_size', '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, disk_size=format_size(row['size_bytes']), writer.writerow(dict(row, disk_size=format_size(row['size_bytes'], row.get('size_is_approximate', False)),
coverage_warnings='; '.join(warnings))) 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)})
+34
View File
@@ -109,6 +109,40 @@ class ReportTests(unittest.TestCase):
self.assertEqual(exported['disk_size'], expected) self.assertEqual(exported['disk_size'], expected)
self.assertEqual(exported['size_bytes'], '' if size is None else str(size)) self.assertEqual(exported['size_bytes'], '' if size is None else str(size))
def test_approximate_storage_sizes_match_proxmox_display(self):
approximate = round(25.91 * 1024 ** 3)
volumes = [
{'volid': 'local:vm-100-disk-1', 'approximate-size': approximate},
{'volid': 'local:vm-999-exact', 'size': 30 * 1024 ** 3, 'approximate-size': approximate},
{'volid': 'local:vm-999-zero', 'size': 0, 'approximate-size': approximate},
{'volid': 'local:vm-999-null', 'size': None, 'approximate-size': approximate},
{'volid': 'local:vm-999-unknown'},
]
def get(path, *options):
if path == '/nodes':
return self.nodes
if path == '/cluster/resources':
return self.guests
if path.endswith('/storage'):
return [{'storage': 'local', 'content': 'images', 'active': 1}] if '/a/' in path else []
return [dict(volume, content='images') for volume in volumes]
with contextlib.redirect_stderr(io.StringIO()):
rows, warnings, coverage = disks.scan(self.root, get)
self.assertEqual(warnings, [])
self.assertEqual([row['size_is_approximate'] for row in rows], [True, False, False, True, False])
output = io.StringIO()
with contextlib.redirect_stdout(output):
disks.render(rows, warnings, coverage)
self.assertIn('Disk size: ~25.91 GiB', output.getvalue())
path = self.root / 'approximate.csv'
disks.export_csv(path, rows, warnings)
with path.open(newline='') as handle:
exported = list(csv.DictReader(handle))
self.assertEqual([row['disk_size'] for row in exported],
['~25.91 GiB', '30.00 GiB', '0.00 B', '~25.91 GiB', 'Unknown'])
self.assertEqual(exported[0]['size_bytes'], str(approximate))
self.assertEqual(exported[0]['size_is_approximate'], 'True')
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':