vdi-cloud/tools/set-fixed-pci.py

122 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""Prepare/apply/restore fixed PCI hardware for a PVE Q35 VM.
Only adjusts QEMU args. Never stops/starts a VM or restarts its USB tunnel.
Apply/restore require a stopped VM so the next cold start uses the new ACPI.
"""
import argparse
import datetime
import json
from pathlib import Path
import shlex
import subprocess
FIXED_GLOBALS = (
"ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off",
"pci-bridge.shpc=off",
"pcie-root-port.hotplug=off",
)
def fixed_args(original):
tokens = shlex.split(original)
additions = []
for setting in FIXED_GLOBALS:
prop = setting.split("=", 1)[0]
for i, token in enumerate(tokens[:-1]):
if token == "-global" and tokens[i + 1].startswith(prop + "="):
if tokens[i + 1] != setting:
raise ValueError("Conflicting existing setting: " + tokens[i + 1])
break
else:
additions.extend(["-global", setting])
# Keep the original text, quoting and GPU arguments intact.
return " ".join(x for x in (original.strip(), shlex.join(additions)) if x)
def command(args):
return subprocess.run(args, check=True, capture_output=True, text=True,
timeout=40).stdout.strip()
def config(vm):
text = command(["qm", "config", str(vm), "--current", "1"])
result = {}
for line in text.splitlines():
if ": " in line:
name, value = line.split(": ", 1)
result[name] = value
return text, result
def require_stopped(vm):
if command(["qm", "status", str(vm)]) != "status: stopped":
raise RuntimeError("VM must be normally shut down first; no live hardware changes made")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["prepare", "apply", "restore"])
parser.add_argument("--vm", required=True, type=int)
parser.add_argument("--backup", required=True, type=Path,
help="dedicated backup directory, reused for apply/restore")
args = parser.parse_args()
if args.vm < 100:
parser.error("Invalid VM ID")
text, conf = config(args.vm)
if "q35" not in conf.get("machine", ""):
raise RuntimeError("Only an explicitly configured Q35 machine is supported")
manifest = args.backup / "plan.json"
if args.action == "prepare":
if manifest.exists():
raise RuntimeError("Backup already exists; use a new directory")
# Pending changes may otherwise be applied unexpectedly at cold start.
pending = command(["qm", "pending", str(args.vm)])
if any(not line.startswith("cur ") for line in pending.splitlines() if line):
raise RuntimeError("VM has pending changes; review them before preparing")
proposed = fixed_args(conf.get("args", ""))
args.backup.mkdir(parents=True, exist_ok=True, mode=0o700)
(args.backup / "vm.conf.before").write_text(text + "\n")
plan = {"vm": args.vm, "created": datetime.datetime.now().astimezone().isoformat(),
"original_args": conf.get("args"), "proposed_args": proposed,
"original_config": conf, "status": "prepared; not applied"}
manifest.write_text(json.dumps(plan, indent=2) + "\n")
manifest.chmod(0o600)
print(json.dumps(plan, indent=2))
return
plan = json.loads(manifest.read_text())
if plan["vm"] != args.vm:
raise RuntimeError("Backup belongs to another VM")
require_stopped(args.vm)
original = plan["original_args"]
proposed = plan["proposed_args"]
current = conf.get("args")
expected = original if args.action == "apply" else proposed
desired = proposed if args.action == "apply" else original
if current != expected:
if current == desired:
print("Already in requested state")
return
raise RuntimeError("QEMU args changed since preparation; refusing to overwrite")
if args.action == "apply" and conf != plan["original_config"]:
raise RuntimeError("VM config changed since preparation; prepare a fresh plan")
if desired is None:
command(["qm", "set", str(args.vm), "--delete", "args"])
else:
command(["qm", "set", str(args.vm), "--args", desired])
# The script never replaces the full VM config, preserving other settings.
_, after = config(args.vm)
if after.get("args") != desired:
raise RuntimeError("Configuration readback differs; inspect before starting the VM")
before_other = {k: v for k, v in conf.items() if k != "args"}
after_other = {k: v for k, v in after.items() if k != "args"}
if before_other != after_other:
raise RuntimeError("Other settings changed concurrently; inspect before starting the VM")
print(json.dumps({"vm": args.vm, "action": args.action,
"args": desired, "next_step": "cold start and verify Windows"}))
if __name__ == "__main__":
main()