125 lines
4.4 KiB
Python
125 lines
4.4 KiB
Python
|
|
#!/usr/bin/python3
|
||
|
|
"""Host-specific PVE hook: re-enumerate the idle GTX 965M before guest start.
|
||
|
|
|
||
|
|
Install on starhe as local:snippets/vdi-shared-gpu-reset.py for VMs 100-102.
|
||
|
|
An incomplete preparation stays reserved until an administrator inspects it.
|
||
|
|
"""
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import socket
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
|
||
|
|
MEMBERS = (100, 101, 102)
|
||
|
|
PCI = Path('/sys/bus/pci/devices')
|
||
|
|
GPU = PCI / '0000:01:00.0'
|
||
|
|
BRIDGE = PCI / '0000:00:01.0'
|
||
|
|
STATE_DIR = Path('/run/vdi-shared-gpu')
|
||
|
|
BOOT_ID = Path('/proc/sys/kernel/random/boot_id')
|
||
|
|
|
||
|
|
|
||
|
|
def stopped():
|
||
|
|
for vmid in MEMBERS:
|
||
|
|
status = subprocess.check_output(
|
||
|
|
['qm', 'status', str(vmid)], text=True, timeout=10).strip()
|
||
|
|
if status != 'status: stopped':
|
||
|
|
raise RuntimeError(f'VM {vmid} is not stopped; refusing GPU reset')
|
||
|
|
|
||
|
|
|
||
|
|
def unused_gpu():
|
||
|
|
parent = BRIDGE.resolve(strict=True)
|
||
|
|
children = sorted(p.name for p in parent.glob('0000:*')
|
||
|
|
if (p / 'vendor').is_file())
|
||
|
|
if children != [GPU.name]:
|
||
|
|
raise RuntimeError(f'unexpected PCI bridge devices: {children}')
|
||
|
|
if ((GPU / 'vendor').read_text().strip() != '0x10de'
|
||
|
|
or (GPU / 'device').read_text().strip() != '0x13d9'
|
||
|
|
or (GPU / 'driver').resolve(strict=True).name != 'vfio-pci'):
|
||
|
|
raise RuntimeError('unexpected GPU identity or driver')
|
||
|
|
group = (GPU / 'iommu_group').resolve(strict=True)
|
||
|
|
devices = sorted(p.name for p in (group / 'devices').iterdir())
|
||
|
|
if devices != [BRIDGE.name, GPU.name]:
|
||
|
|
raise RuntimeError(f'unexpected IOMMU group: {devices}')
|
||
|
|
result = subprocess.run(['fuser', '/dev/vfio/' + group.name],
|
||
|
|
capture_output=True, timeout=10)
|
||
|
|
if result.returncode != 1:
|
||
|
|
raise RuntimeError('GPU group is in use or usage could not be checked')
|
||
|
|
|
||
|
|
|
||
|
|
def reenumerate():
|
||
|
|
(GPU / 'power/control').write_text('on')
|
||
|
|
(GPU / 'remove').write_text('1')
|
||
|
|
time.sleep(2)
|
||
|
|
(BRIDGE / 'rescan').write_text('1')
|
||
|
|
for _ in range(20):
|
||
|
|
if GPU.exists() and (GPU / 'driver').exists():
|
||
|
|
break
|
||
|
|
time.sleep(.5)
|
||
|
|
unused_gpu()
|
||
|
|
(GPU / 'power/control').write_text('on')
|
||
|
|
if (GPU / 'power_state').read_text().strip() != 'D0':
|
||
|
|
raise RuntimeError('GPU did not return to D0')
|
||
|
|
|
||
|
|
|
||
|
|
def save(state):
|
||
|
|
temporary = STATE_DIR / 'reservation.tmp'
|
||
|
|
temporary.write_text(json.dumps(state) + '\n')
|
||
|
|
temporary.replace(STATE_DIR / 'reservation.json')
|
||
|
|
|
||
|
|
|
||
|
|
def handle(vmid, phase):
|
||
|
|
path = STATE_DIR / 'reservation.json'
|
||
|
|
boot = BOOT_ID.read_text().strip()
|
||
|
|
state = json.loads(path.read_text()) if path.exists() else None
|
||
|
|
if state and state.get('boot') != boot:
|
||
|
|
state = None
|
||
|
|
if phase == 'pre-start':
|
||
|
|
stopped()
|
||
|
|
unused_gpu()
|
||
|
|
if state and state.get('phase') != 'running':
|
||
|
|
raise RuntimeError('previous GPU preparation is unconfirmed; inspect '
|
||
|
|
'/run/vdi-shared-gpu/reservation.json before retry')
|
||
|
|
state = dict(vmid=vmid, phase='preparing', boot=boot)
|
||
|
|
save(state)
|
||
|
|
try:
|
||
|
|
reenumerate()
|
||
|
|
except Exception:
|
||
|
|
save(dict(state, phase='failed'))
|
||
|
|
raise
|
||
|
|
save(dict(state, phase='prepared'))
|
||
|
|
print(f'VDI GPU: exclusive re-enumeration complete for VM {vmid}', flush=True)
|
||
|
|
elif phase == 'post-start':
|
||
|
|
if not state or state.get('vmid') != vmid or state.get('phase') != 'prepared':
|
||
|
|
raise RuntimeError('GPU start reservation does not match this VM')
|
||
|
|
save(dict(state, phase='running'))
|
||
|
|
elif phase == 'post-stop':
|
||
|
|
if state and state.get('vmid') == vmid and state.get('phase') in ('prepared', 'running'):
|
||
|
|
stopped()
|
||
|
|
unused_gpu()
|
||
|
|
path.unlink()
|
||
|
|
elif phase != 'pre-stop':
|
||
|
|
raise RuntimeError('unsupported hook phase')
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
import fcntl
|
||
|
|
|
||
|
|
if os.geteuid() != 0 or socket.gethostname().split('.')[0] != 'starhe':
|
||
|
|
raise RuntimeError('this hook is restricted to root on starhe')
|
||
|
|
if len(sys.argv) != 3 or int(sys.argv[1]) not in MEMBERS:
|
||
|
|
raise RuntimeError('unexpected VM or hook arguments')
|
||
|
|
STATE_DIR.mkdir(mode=0o700, exist_ok=True)
|
||
|
|
with (STATE_DIR / 'lock').open('a') as lock:
|
||
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||
|
|
handle(int(sys.argv[1]), sys.argv[2])
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
try:
|
||
|
|
main()
|
||
|
|
except Exception as error:
|
||
|
|
print(f'VDI GPU preparation blocked: {error}', file=sys.stderr, flush=True)
|
||
|
|
sys.exit(1)
|