122 lines
4.9 KiB
Python
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()
|