orphan disk search update
This commit is contained in:
@@ -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
@@ -3,7 +3,7 @@
|
||||
|
||||
action="${1:-}"
|
||||
FOLDER='/opt/idssys/ta-proxmenu'
|
||||
VERS='2026.9.22-6'
|
||||
VERS='2026.9.22-7'
|
||||
|
||||
noupdate=' '
|
||||
|
||||
|
||||
+20
-5
@@ -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)})
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user