Files
TA-ProxMenu/inc/orphan-disks.py
T
David Schroeder 431eee5b72 update
2026-09-22 22:12:46 -05:00

213 lines
9.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Read-only Proxmox storage/reference inventory; no third-party dependencies."""
import argparse
import csv
import json
from pathlib import Path
import re
import subprocess
import sys
VOLUME = re.compile(r"(?<![\w.-])([\w.-]+:[^\s,;\"']+)")
FIELDS = ['node', 'storage', 'volume', 'size_bytes', 'status', 'detail']
def api(path, *options):
result = subprocess.run(['pvesh', 'get', path, *options, '--output-format', 'json'],
capture_output=True, text=True, timeout=90)
if result.returncode:
raise RuntimeError(result.stderr.strip() or 'pvesh failed')
data = json.loads(result.stdout)
if not isinstance(data, list):
raise ValueError('expected an API array')
return data
def enabled(value):
return value in (1, True, '1')
def references(root, nodes, guests):
"""Read pmxcfs on this host, including offline guests and all config sections."""
refs, errors, seen, contents = {}, [], set(), {}
guest_names = {(guest['type'], str(guest['vmid'])): guest.get('name')
for guest in guests}
for node in nodes:
for kind, directory in [('qemu', 'qemu-server'), ('lxc', 'lxc')]:
folder = root / node['node'] / directory
try:
paths = list(folder.iterdir())
except OSError as exc:
errors.append(f'Cannot read {folder}: {exc}')
continue
for path in paths:
if path.suffix != '.conf':
continue
try:
content = path.read_text()
except OSError as exc:
errors.append(f'Cannot read {path}: {exc}')
continue
contents[str(path)] = content
seen.add((node['node'], kind, path.stem))
name = guest_names.get((kind, path.stem))
name_key = 'name' if kind == 'qemu' else 'hostname'
for line in content.splitlines():
line = line.strip()
if line.startswith('['):
break
key, sep, value = line.partition(':')
if sep and key == name_key and value.strip():
name = value.strip()
break
guest_id = f"{'VM' if kind == 'qemu' else 'LXC'} {path.stem}"
guest_label = f'{name} ({guest_id})' if name else f'{guest_id} (name unavailable)'
section = 'current'
for line in content.splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
if line.startswith('['):
section = line
continue
key, sep, value = line.partition(':')
if not sep or key in ('description', 'name'):
continue
for volume in VOLUME.findall(value):
refs.setdefault(volume, []).append({
'node': node['node'], 'guest': guest_label,
'unused': bool(re.fullmatch(r'unused\d+', key)) and section == 'current',
'section': section,
})
for guest in guests:
identity = (guest['node'], guest['type'], str(guest['vmid']))
if identity not in seen:
errors.append(f'Missing guest configuration: {identity}')
return refs, errors, contents
def classify(volume, node, shared, refs, incomplete):
all_refs = refs.get(volume['volid'], [])
local_refs = [r for r in all_refs if shared or r['node'] == node]
if any(not r['unused'] for r in local_refs):
return None
if incomplete:
return 'Needs review', 'Guest reference inventory incomplete'
if local_refs:
return 'Unused disk', ', '.join(sorted({r['guest'] for r in local_refs}))
if all_refs:
return 'Needs review', 'Referenced on another node; possible replication copy: ' + ', '.join(
sorted({r['node'] + '/' + r['guest'] for r in all_refs}))
if re.search(r'(^|[/ :])base[-_]', volume['volid']) or volume.get('parent'):
return 'Needs review', 'Possible template/clone backing relationship'
return 'Orphan candidate', 'No guest configuration reference; verify replication and backing dependencies'
def scan(root=Path('/etc/pve/nodes'), get=api):
warnings, rows, coverage = [], [], []
nodes = get('/nodes')
if not nodes:
raise RuntimeError('No nodes returned; cannot inventory cluster')
guests = get('/cluster/resources', '--type', 'vm')
refs, errors, before = references(root, nodes, guests)
warnings.extend(errors)
shared_done = set()
for node in sorted(nodes, key=lambda item: item['node']):
name = node['node']
if node.get('status') != 'online':
warnings.append(f'{name}: offline; storage not scanned')
continue
print(f'Scanning {name} storage...', file=sys.stderr, flush=True)
try:
storages = get(f'/nodes/{name}/storage')
except (RuntimeError, ValueError, OSError, subprocess.TimeoutExpired) as exc:
warnings.append(f'{name}: storage discovery failed: {exc}')
continue
for storage in sorted(storages, key=lambda item: item['storage']):
sid = storage['storage']
if not {'images', 'rootdir'} & set(storage.get('content', '').split(',')):
continue
shared = enabled(storage.get('shared', 0))
if shared and sid in shared_done:
continue
if not enabled(storage.get('active', 0)) or not enabled(storage.get('enabled', 1)):
warnings.append(f'{name}/{sid}: inactive or disabled; not scanned')
continue
try:
volumes = get(f'/nodes/{name}/storage/{sid}/content')
except (RuntimeError, ValueError, OSError, subprocess.TimeoutExpired) as exc:
warnings.append(f'{name}/{sid}: content listing failed: {exc}')
continue
if shared:
shared_done.add(sid)
label = 'shared (via ' + name + ')' if shared else name
disks = [v for v in volumes if v.get('content') in ('images', 'rootdir')]
coverage.append(f'{label}/{sid}: {len(disks)} disk volume(s) scanned')
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])))
# Detect migrations/config changes during this non-atomic inventory.
_, after_errors, after = references(root, nodes, guests)
if after != before or after_errors:
warnings.append('Guest configurations changed or became unreadable during scan; rerun report')
for row in rows:
row.update(status='Needs review', detail='Guest configurations changed during scan')
return rows, warnings, coverage
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'))
print('Candidates are not proof of safe deletion. Storage-managed volumes only; excludes backups, ISOs and passthrough disks.')
for entry in coverage:
print(' ' + entry)
for warning in warnings:
print('WARNING: ' + warning)
group = None
for row in sorted(rows, key=lambda r: (r['node'], r['storage'], r['volume'])):
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']}")
print(f'\n{len(rows)} volume(s) reported.')
if not rows:
print('No unreferenced or unused volumes found in the successfully scanned storage.')
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.writeheader()
for row in rows:
writer.writerow(dict(row, coverage_warnings='; '.join(warnings)))
if warnings and not rows:
writer.writerow({'status': 'Incomplete scan', 'coverage_warnings': '; '.join(warnings)})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--csv', metavar='PATH', help='export to a new CSV file')
parser.add_argument('--interactive', action='store_true', help='offer CSV export after the report')
# The TAPM preloader pads its forwarded arguments with empty strings.
args = parser.parse_args([arg for arg in sys.argv[1:] if arg])
try:
rows, warnings, coverage = scan()
render(rows, warnings, coverage)
destination = args.csv
if args.interactive and sys.stdin.isatty() and not destination:
destination = input('\nCSV export path (Enter to skip): ').strip()
if destination:
export_csv(destination, rows, warnings)
print('CSV saved: ' + destination)
return 2 if warnings else 0
except (OSError, RuntimeError, ValueError, subprocess.TimeoutExpired) as exc:
print(f'Disk report failed: {exc}', file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())