orphan disk update
This commit is contained in:
@@ -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
@@ -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
@@ -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)})
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user