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,
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 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
`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.
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-7'
VERS='2026.9.22-8'
noupdate=' '
+11 -6
View File
@@ -10,7 +10,7 @@ import subprocess
import sys
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):
@@ -147,7 +147,12 @@ 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'), *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.
_, after_errors, after = references(root, nodes, guests)
if after != before or after_errors:
@@ -157,7 +162,7 @@ def scan(root=Path('/etc/pve/nodes'), get=api):
return rows, warnings, coverage
def format_size(size_bytes):
def format_size(size_bytes, approximate=False):
try:
size = float(size_bytes)
except (TypeError, ValueError):
@@ -166,7 +171,7 @@ def format_size(size_bytes):
return 'Unknown'
for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'):
if size < 1024 or unit == 'PiB':
return f'{size:.2f} {unit}'
return f"{'~' if approximate else ''}{size:.2f} {unit}"
size /= 1024
@@ -183,7 +188,7 @@ def render(rows, warnings, coverage):
if group != (row['node'], row['storage']):
group = (row['node'], row['storage'])
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'\n{len(rows)} volume(s) reported.')
if not rows:
@@ -196,7 +201,7 @@ def export_csv(path, rows, warnings):
writer = csv.DictWriter(handle, fieldnames=FIELDS + ['disk_size', 'coverage_warnings'])
writer.writeheader()
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)))
if warnings and not rows:
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['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 get(path, *options):
if path == '/nodes':