运维:固定虚拟PCI设备并补充过夜观察与电源策略

This commit is contained in:
songyiming committed 2026-11-05 19:15:31 +08:00
1 parent 6f2ff67300
commit 65f92df8b7
42 files changed
+1705

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Apply/query/restore power-menu policy via the existing PVE guest agent.
Uses the repository's existing PVE authentication. Never powers off/reboots a VM.
"""
import argparse
import base64
import json
from pathlib import Path
import runpy
import sys
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['apply', 'query', 'restore'])
parser.add_argument('--vm', type=int, required=True, choices=[100, 101, 102])
parser.add_argument('--user', help='Required for Linux guests')
parser.add_argument('--evidence', type=Path, help='Save the guest-agent result as JSON')
args = parser.parse_args()
root = Path(__file__).resolve().parent
api = runpy.run_path(str(root / 'pve_vm.py'))['api']
ticket, csrf = runpy.run_path(str(root / 'pve_term_exec.py'))['login']()
base = f'/api2/json/nodes/starhe/qemu/{args.vm}'
if api(base + '/status/current', ticket=ticket).get('status') != 'running':
raise SystemExit('Guest is not running; nothing changed')
if args.vm == 100:
source = (root / 'set-guest-power-policy.ps1').read_text(encoding='utf-8')
source = "$ProgressPreference='SilentlyContinue'\n& {\n" + source + "\n} -Action " + args.action.capitalize()
encoded = base64.b64encode(source.encode('utf-16le')).decode()
command = ['powershell.exe', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded]
else:
if not args.user:
parser.error('--user is required for Linux')
source = (root / 'set-guest-power-policy.py').read_text(encoding='utf-8')
command = ['python3', '-c', source, args.action, '--user', args.user]
pid = api(base + '/agent/exec', json_body={'command': command}, ticket=ticket, csrf=csrf)['pid']
for _ in range(60):
time.sleep(1)
result = api(base + f'/agent/exec-status?pid={pid}', ticket=ticket)
if result.get('exited'):
report = json.dumps({'vm': args.vm, **result}, ensure_ascii=True)
if args.evidence:
args.evidence.parent.mkdir(parents=True, exist_ok=True)
args.evidence.write_text(report + '\n', encoding='utf-8')
print(report)
if result.get('exitcode') or result.get('out-truncated') or result.get('err-truncated'):
raise SystemExit(1)
return
raise SystemExit('Guest operation timed out; query before retrying')
if __name__ == '__main__':
main()
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
box="${1:-armbian@192.168.1.188}"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ssh "$box" 'set -eu
backup="$HOME/vdi-terminal/backups/overnight-observer-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup" "$HOME/vdi-terminal/bin" "$HOME/vdi-terminal/logs" "$HOME/vdi-terminal/state"
chmod 700 "$backup"
for file in /etc/systemd/system/vdi-overnight-observer.service /etc/systemd/journald.conf.d/70-vdi-observer-journal.conf /usr/local/libexec/vdi-observer-pstore "$HOME/vdi-terminal/bin/vdi-overnight-observer"; do
if test -f "$file"; then sudo -n cp -a "$file" "$backup/$(basename "$file")"; fi
done
echo "backup=$backup"'
scp "$repo_root/terminal/kiosk-shell/scripts/vdi-overnight-observer" "$box:/tmp/vdi-overnight-observer"
scp "$repo_root/terminal/kiosk-shell/etc/vdi-overnight-observer.service" "$box:/tmp/vdi-overnight-observer.service"
scp "$repo_root/terminal/kiosk-shell/etc/70-vdi-observer-journal.conf" "$box:/tmp/70-vdi-observer-journal.conf"
scp "$repo_root/terminal/kiosk-shell/scripts/vdi-observer-pstore" "$box:/tmp/vdi-observer-pstore"
ssh "$box" 'set -eu
python3 -c "import ast,pathlib; ast.parse(pathlib.Path(\"/tmp/vdi-overnight-observer\").read_text())"
sudo -n systemctl stop vdi-overnight-observer.service 2>/dev/null || true
install -m 0755 /tmp/vdi-overnight-observer /home/armbian/vdi-terminal/bin/vdi-overnight-observer
sudo -n install -D -o root -g root -m 0755 /tmp/vdi-observer-pstore /usr/local/libexec/vdi-observer-pstore
sudo -n install -m 0644 /tmp/vdi-overnight-observer.service /etc/systemd/system/vdi-overnight-observer.service
sudo -n install -D -m 0644 /tmp/70-vdi-observer-journal.conf /etc/systemd/journald.conf.d/70-vdi-observer-journal.conf
# Armbian makes /var/log a RAM disk and journal a symlink to /var/log.hdd.
# Work on its existing disk target; tmpfiles rejects file flags on the symlink.
journal_root="$(readlink -f /var/log/journal)"
test -n "$journal_root"
sudo -n install -d -m 2755 -o root -g systemd-journal "$journal_root"
sudo -n systemd-analyze verify /etc/systemd/system/vdi-overnight-observer.service
sudo -n systemctl daemon-reload
sudo -n systemctl restart systemd-journald.service
sudo -n journalctl --flush
sudo -n journalctl --sync
sudo -n systemctl enable --now vdi-overnight-observer.service
sleep 2
systemctl is-active vdi-overnight-observer.service
systemctl is-enabled vdi-overnight-observer.service
cat /home/armbian/vdi-terminal/logs/overnight-summary.txt
sudo -n journalctl --directory /var/log/journal -n 3 --no-pager
rm -f /tmp/vdi-overnight-observer /tmp/vdi-overnight-observer.service /tmp/70-vdi-observer-journal.conf /tmp/vdi-observer-pstore'
+121
View File
@@ -0,0 +1,121 @@
#!/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()
+86
View File
@@ -0,0 +1,86 @@
param(
[ValidateSet('Apply','Query','Restore')][string]$Action = 'Query',
[string]$BackupDirectory = 'C:\ProgramData\VDI\power-policy'
)
# Run elevated inside the Windows guest. No reboot, logout or Explorer restart.
$ErrorActionPreference = 'Stop'
$user = (Get-CimInstance Win32_ComputerSystem).UserName
if (-not $user) { throw 'No interactive desktop user' }
$sid = (New-Object Security.Principal.NTAccount($user)).Translate([Security.Principal.SecurityIdentifier]).Value
if (-not (Test-Path "Registry::HKEY_USERS\$sid")) { throw 'User hive is not loaded' }
$path = "Registry::HKEY_USERS\$sid\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer"
$name = 'NoClose'
$backup = Join-Path $BackupDirectory 'original.clixml'
if ($Action -eq 'Apply') {
New-Item -ItemType Directory -Force $BackupDirectory | Out-Null
if (-not (Test-Path $backup)) {
$key = Get-Item -LiteralPath $path -ErrorAction SilentlyContinue
$exists = $key -and ($key.GetValueNames() -contains $name)
[pscustomobject]@{Sid=$sid; Path=$path; Exists=[bool]$exists;
Value=$(if($exists){$key.GetValue($name)}else{$null});
Kind=$(if($exists){$key.GetValueKind($name).ToString()}else{$null})} | Export-Clixml $backup
}
if ((Import-Clixml $backup).Sid -ne $sid) { throw 'Backup belongs to another user' }
New-Item -Path $path -Force | Out-Null
New-ItemProperty -LiteralPath $path -Name $name -PropertyType DWord -Value 1 -Force | Out-Null
} elseif ($Action -eq 'Restore') {
$saved = Import-Clixml $backup
if ($saved.Sid -ne $sid -or $saved.Path -ne $path) { throw 'Backup belongs to another user' }
if ($saved.Exists) {
New-ItemProperty -LiteralPath $path -Name $name -PropertyType $saved.Kind -Value $saved.Value -Force | Out-Null
} else {
Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction SilentlyContinue
}
}
$key = Get-Item -LiteralPath $path -ErrorAction SilentlyContinue
$value = if ($key) { $key.GetValue($name) } else { $null }
if ($Action -eq 'Apply' -and $value -ne 1) { throw 'NoClose readback failed' }
# Notify and query the shell in the actual desktop user's session. A SYSTEM
# guest-agent process sees a different HKCU and cannot verify this restriction.
$runtime = Join-Path $BackupDirectory 'runtime'
New-Item -ItemType Directory -Force $runtime | Out-Null
$acl = Get-Acl $runtime
$acl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($user,'Modify','ContainerInherit,ObjectInherit','None','Allow')))
Set-Acl $runtime $acl
$resultPath = Join-Path $runtime 'restriction.txt'
Remove-Item $resultPath -ErrorAction SilentlyContinue
$probe = @'
$ErrorActionPreference='Stop'
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class VDIPowerShellPolicy {
[DllImport("shell32.dll")] public static extern uint SHRestricted(uint restriction);
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
public static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wparam, string lparam, uint flags, uint timeout, out UIntPtr result);
}
"@
$sent=[UIntPtr]::Zero
[void][VDIPowerShellPolicy]::SendMessageTimeout([IntPtr]0xffff,0x1a,[UIntPtr]::Zero,'Policy',2,2000,[ref]$sent)
[VDIPowerShellPolicy]::SHRestricted(2) | Set-Content -Encoding ascii '__RESULT__'
'@
$probe = $probe.Replace('__RESULT__', $resultPath.Replace("'", "''"))
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($probe))
$task = 'VDIPowerPolicyProbe'
if (Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue) { throw 'Probe task already exists' }
$taskAction = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -EncodedCommand $encoded"
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName $task -Action $taskAction -Principal $principal -Settings $settings | Out-Null
try {
Start-ScheduledTask -TaskName $task
for ($i=0; $i -lt 20; $i++) {
Start-Sleep -Seconds 1
if (Test-Path $resultPath) { break }
}
if (-not (Test-Path $resultPath)) { throw 'Interactive policy probe timed out' }
$effective = [int](Get-Content $resultPath -Raw)
if ($Action -eq 'Apply' -and $effective -ne 1) { throw 'Interactive shell restriction was not enabled' }
} finally {
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $task -Confirm:$false
}
[pscustomobject]@{Action=$Action; User=$user; Sid=$sid; NoClose=$value; Backup=$backup;
EffectiveShellNoClose=$effective;
LastBoot=(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToString('o');
Scope='Desktop power menus only; privileged administration remains available'} | ConvertTo-Json -Compress
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Deny desktop-user power actions on Ubuntu/Kylin; run as root in the guest.
Writes account-scoped polkit and D-Bus rules. Never sends a valid power request.
Root/PVE maintenance remains available. Query uses login1 Can* methods only.
"""
import argparse
import json
import os
from pathlib import Path
import pwd
import re
import shutil
import subprocess
import time
from xml.sax.saxutils import quoteattr
def run(command):
return subprocess.run(command, capture_output=True, text=True, timeout=20,
env={**os.environ, 'LC_ALL': 'C', 'SYSTEMD_COLORS': '0'})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['apply', 'query', 'restore'])
parser.add_argument('--user', required=True)
parser.add_argument('--backup', default='/var/lib/vdi/power-policy')
args = parser.parse_args()
if os.geteuid() != 0:
parser.error('Run as root in the guest, not on the terminal or PVE node')
if not re.fullmatch(r'[a-z_][a-z0-9_-]*', args.user):
parser.error('Invalid desktop user')
account = pwd.getpwnam(args.user)
if account.pw_uid == 0:
parser.error('Refusing to restrict root')
backend = 'pkla' if run(['pkaction', '--version']).stdout.strip().endswith('0.105') else 'rules'
if backend == 'pkla':
target = Path('/etc/polkit-1/localauthority/90-mandatory.d/99-vdi-power.pkla')
content = ('[VDI desktop power restriction]\nIdentity=unix-user:' + args.user + '\n'
'Action=org.freedesktop.login1.power-off*;org.freedesktop.login1.reboot*;'
'org.freedesktop.login1.halt*;org.freedesktop.login1.suspend*;'
'org.freedesktop.login1.hibernate*;org.freedesktop.login1.hybrid-sleep*;'
'org.freedesktop.consolekit.system.stop*;org.freedesktop.consolekit.system.restart*;'
'org.freedesktop.upower.suspend;org.freedesktop.upower.hibernate\n'
'ResultAny=no\nResultInactive=no\nResultActive=no\n')
else:
target = Path('/etc/polkit-1/rules.d/00-vdi-power.rules')
content = '''polkit.addRule(function(action, subject) {
if (subject.user === %s &&
/^org\\.freedesktop\\.login1\\.(power-off|reboot|halt|suspend|hibernate|hybrid-sleep)(-|$)/.test(action.id)) {
return polkit.Result.NO;
}
});
''' % json.dumps(args.user)
backup = Path(args.backup)
manifest = backup / 'manifest.json'
dbus_path = Path('/etc/dbus-1/system.d/zz-vdi-desktop-power.conf')
methods = ('PowerOff', 'Reboot', 'Halt', 'Suspend', 'Hibernate', 'HybridSleep',
'SuspendThenHibernate', 'ScheduleShutdown')
capability_methods = ('CanPowerOff', 'CanReboot', 'CanHalt', 'CanSuspend',
'CanHibernate', 'CanHybridSleep', 'CanSuspendThenHibernate')
members = [m for name in methods for m in (name, name + 'WithFlags')] + list(capability_methods)
dbus_content = ('<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"\n'
' "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">\n'
'<busconfig>\n <policy user=' + quoteattr(args.user) + '>\n' + ''.join(
' <deny send_destination="org.freedesktop.login1" '
'send_interface="org.freedesktop.login1.Manager" send_member="' + m + '"/>\n'
for m in members) + ' </policy>\n</busconfig>\n')
dbus_manifest = backup / 'dbus-manifest.json'
if args.action == 'apply':
backup.mkdir(parents=True, exist_ok=True, mode=0o700)
if not manifest.exists():
exists = target.exists()
if exists:
shutil.copy2(target, backup / 'original')
manifest.write_text(json.dumps({'user': args.user, 'path': str(target), 'existed': exists}))
saved = json.loads(manifest.read_text())
if saved['user'] != args.user or saved['path'] != str(target):
raise RuntimeError('Backup belongs to a different target')
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(target.suffix + '.tmp')
temporary.write_text(content)
temporary.chmod(0o644)
temporary.replace(target)
# Active-session authorization can still grant Reboot. Enforce the
# boundary at the bus, including the menu's capability queries.
if not dbus_manifest.exists():
if dbus_path.exists():
shutil.copy2(dbus_path, backup / 'dbus-original')
dbus_manifest.write_text(json.dumps({'user': args.user, 'existed': dbus_path.exists()}))
if json.loads(dbus_manifest.read_text())['user'] != args.user:
raise RuntimeError('D-Bus backup belongs to a different user')
dbus_tmp = dbus_path.with_suffix('.conf.tmp')
dbus_tmp.write_text(dbus_content)
dbus_tmp.chmod(0o644)
dbus_tmp.replace(dbus_path)
reload_result = run(['busctl', '--system', 'call', 'org.freedesktop.DBus',
'/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ReloadConfig'])
if reload_result.returncode:
raise RuntimeError('D-Bus reload failed: ' + reload_result.stderr)
time.sleep(2) # Both polkit backends monitor their policy directories.
elif args.action == 'restore':
saved = json.loads(manifest.read_text())
if saved['user'] != args.user or saved['path'] != str(target):
raise RuntimeError('Backup belongs to a different target')
dbus_saved = json.loads(dbus_manifest.read_text())
if dbus_saved['user'] != args.user:
raise RuntimeError('D-Bus backup belongs to another user')
if saved['existed']:
shutil.copy2(backup / 'original', target)
elif target.exists():
target.unlink()
if dbus_saved['existed']:
shutil.copy2(backup / 'dbus-original', dbus_path)
elif dbus_path.exists():
dbus_path.unlink()
reload_result = run(['busctl', '--system', 'call', 'org.freedesktop.DBus',
'/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ReloadConfig'])
if reload_result.returncode:
raise RuntimeError('D-Bus reload failed: ' + reload_result.stderr)
time.sleep(2)
capabilities = {}
for method in capability_methods:
result = run(['runuser', '-u', args.user, '--', 'busctl', '--system', 'call',
'org.freedesktop.login1', '/org/freedesktop/login1',
'org.freedesktop.login1.Manager', method])
capabilities[method] = {'code': result.returncode, 'value': result.stdout.strip(),
'error': result.stderr.strip()}
active = target.exists() and target.read_text() == content
dbus_active = dbus_path.exists() and dbus_path.read_text() == dbus_content
bus_denials = {}
if args.action == 'apply':
# Deliberately use a STRING argument. Every real power method here
# requires a boolean, flags, or a different signature, so even if the
# bus rule failed, logind rejects this before any power action runs.
# AccessDenied (rather than InvalidArgs) proves the bus blocks delivery.
for method in methods:
result = run(['runuser', '-u', args.user, '--', 'busctl', '--system', 'call',
'org.freedesktop.login1', '/org/freedesktop/login1',
'org.freedesktop.login1.Manager', method, 's', 'VDI_INVALID_SIGNATURE'])
bus_denials[method] = {'code': result.returncode, 'error': result.stderr.strip()}
print(json.dumps({'action': args.action, 'user': args.user, 'backend': backend,
'path': str(target), 'backup': str(backup), 'policy_matches': active,
'dbus_policy_matches': dbus_active, 'dbus_denials': bus_denials,
'boot_id': Path('/proc/sys/kernel/random/boot_id').read_text().strip(),
'capabilities': capabilities}))
if args.action == 'apply' and (not active or not dbus_active or any(
not (entry['code'] and 'Access denied' in entry['error'])
for entry in capabilities.values()) or any(
not entry['code'] or not any(message in entry['error'] for message in
('Rejected send message', 'Access denied'))
for entry in bus_denials.values())):
raise SystemExit('Policy installed but denial verification failed; inspect capabilities')
if __name__ == '__main__':
main()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Isolated PVE Q35 smoke test: no guest disk, network backend or passed GPU.
Runs a paused TCG QEMU process and checks PCI properties and usb-redir hotplug.
It never connects to a managed VM's QMP socket or changes PVE configuration.
"""
import importlib.util
import json
from pathlib import Path
import socket
import subprocess
import tempfile
import time
spec = importlib.util.spec_from_file_location('policy', Path(__file__).with_name('set-fixed-pci.py'))
policy = importlib.util.module_from_spec(spec)
spec.loader.exec_module(policy)
def main():
with tempfile.TemporaryDirectory(prefix='vdi-fixed-pci-smoke-') as tmp:
path = str(Path(tmp) / 'qmp.sock')
flags = [part for value in policy.FIXED_GLOBALS for part in ('-global', value)]
cmd = ['/usr/bin/kvm', '-machine', 'pc-q35-11.0+pve0,accel=tcg', '-m', '128',
'-nodefaults', '-display', 'none', '-S',
'-readconfig', '/usr/share/qemu-server/pve-q35-4.0.cfg',
'-qmp', 'unix:' + path + ',server=on,wait=off', *flags,
'-device', 'virtio-net-pci,id=diagnet,bus=pci.0,addr=0x12',
'-device', 'virtio-serial,id=diagserial,bus=pci.0,addr=0x8',
'-device', 'ahci,id=diagahci,bus=pci.0,addr=0x7']
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
deadline = time.monotonic() + 15
while not Path(path).exists():
if proc.poll() is not None:
raise RuntimeError(proc.stderr.read().decode())
if time.monotonic() > deadline:
raise RuntimeError('QEMU startup timed out')
time.sleep(.1)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(5)
sock.connect(path)
with sock.makefile('rwb', buffering=0) as stream:
print('QEMU_VERSION', json.loads(stream.readline())['QMP']['version'])
sequence = 0
def call(name, arguments=None):
nonlocal sequence
sequence += 1
ident = sequence
obj = {'execute': name, 'id': ident}
if arguments is not None:
obj['arguments'] = arguments
stream.write(json.dumps(obj).encode() + b'\n')
while True:
line = stream.readline()
if not line:
raise RuntimeError('QMP disconnected')
obj = json.loads(line)
if obj.get('id') == ident:
if 'error' in obj:
raise RuntimeError(str(obj['error']))
return obj['return']
call('qmp_capabilities')
for port in range(1, 5):
value = call('qom-get', {'path': f'/machine/peripheral/ich9-pcie-port-{port}',
'property': 'hotplug'})
if value is not False:
raise RuntimeError('Root port remains hotpluggable')
print('PCIE_PORT_HOTPLUG_OFF: 4/4')
for bridge in range(4):
value = call('qom-get', {'path': f'/machine/peripheral/pci.{bridge}',
'property': 'shpc'})
if value is not False:
raise RuntimeError('SHPC remains enabled')
print('PCI_BRIDGE_SHPC_OFF: 4/4')
lpc = [p for p in call('qom-list', {'path': '/machine/unattached'})
if p['type'] == 'child<ICH9-LPC>']
if len(lpc) != 1:
raise RuntimeError('Cannot uniquely identify ICH9 LPC')
value = call('qom-get', {'path': '/machine/unattached/' + lpc[0]['name'],
'property': 'acpi-pci-hotplug-with-bridge-support'})
if value is not False:
raise RuntimeError('ACPI PCI hotplug remains enabled')
print('ACPI_PCI_HOTPLUG_OFF')
call('chardev-add', {'id': 'diag-channel',
'backend': {'type': 'null', 'data': {}}})
call('device_add', {'driver': 'usb-redir', 'id': 'diag-usb',
'chardev': 'diag-channel', 'bus': 'ehci-2.0',
'filter': '0x08:-1:-1:-1:1|-1:-1:-1:-1:0'})
if 'diag-usb' not in call('human-monitor-command', {'command-line': 'info usb'}):
raise RuntimeError('USB device not attached')
print('USB_REDIR_ADD_OK')
call('device_del', {'id': 'diag-usb'})
deadline = time.monotonic() + 5
while any(p['name'] == 'diag-usb' for p in
call('qom-list', {'path': '/machine/peripheral'})):
if time.monotonic() > deadline:
raise RuntimeError('USB removal pending')
time.sleep(.1)
# QEMU 11 can remove the anonymous backend with the device.
if any(p['label'] == 'diag-channel' for p in call('query-chardev')):
call('chardev-remove', {'id': 'diag-channel'})
print('USB_REDIR_REMOVE_OK')
call('quit')
proc.wait(timeout=5)
print('ISOLATED_SMOKE_PASS (Windows UI and physical USB not tested)')
finally:
if proc.poll() is None:
proc.terminate()
proc.wait(timeout=5)
print('QEMU_STDERR:', proc.stderr.read().decode())
if __name__ == '__main__':
main()
+37
View File
@@ -0,0 +1,37 @@
import importlib.util
from pathlib import Path
import shlex
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("fixed_pci", Path(__file__).parents[1] / "set-fixed-pci.py")
policy = importlib.util.module_from_spec(spec)
spec.loader.exec_module(policy)
class FixedPciPolicyTests(unittest.TestCase):
def test_keeps_gpu_identity_and_quoted_arguments(self):
old = '-set device.hostpci0.x-pci-sub-vendor-id=0x1558 -set device.hostpci0.x-pci-sub-device-id=0x6557 -name "desktop test"'
updated = policy.fixed_args(old)
self.assertEqual(shlex.split(updated)[:len(shlex.split(old))], shlex.split(old))
self.assertTrue(updated.startswith(old))
self.assertEqual(policy.fixed_args(updated), updated)
self.assertFalse(any("usb" in token.lower() for token in shlex.split(updated)))
def test_conflicting_explicit_hotplug_setting_requires_review(self):
with self.assertRaisesRegex(ValueError, "Conflicting"):
policy.fixed_args('-global pcie-root-port.hotplug=on')
def test_online_vm_cannot_be_changed(self):
with patch.object(policy, "command", return_value="status: running") as call:
with self.assertRaisesRegex(RuntimeError, "normally shut down"):
policy.require_stopped(100)
call.assert_called_once_with(["qm", "status", "100"])
def test_stopped_vm_can_proceed(self):
with patch.object(policy, "command", return_value="status: stopped"):
policy.require_stopped(100)
if __name__ == '__main__':
unittest.main()
+76
View File
@@ -0,0 +1,76 @@
"""Reboot evidence/state tests without restarting or changing a real box."""
import importlib.util
from importlib.machinery import SourceFileLoader
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
script = Path(__file__).resolve().parents[2]/"terminal/kiosk-shell/scripts/vdi-overnight-observer"
spec = importlib.util.spec_from_loader("observer", SourceFileLoader("observer", str(script)))
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
class RebootEvidence(unittest.TestCase):
def baseline(self): return m.transition(None, "boot-a", "start")[0]
def test_baseline_zero_reboots(self):
state,event=m.transition(None,"boot-a","start")
self.assertEqual(event,"boot_baseline"); self.assertEqual(state["reboots"],0)
def test_service_restart_is_not_box_reboot(self):
before=self.baseline(); after,event=m.transition(before,"boot-a","later")
self.assertEqual(event,"observer_restart_same_boot"); self.assertEqual(after["reboots"],0)
self.assertEqual(after["started_at"],"start")
def test_boot_change_preserves_last_heartbeat(self):
before=self.baseline(); before.update(last_seen="last",uptime_seconds=100)
after,event=m.transition(before,"boot-b","next")
self.assertEqual(event,"boot_change"); self.assertEqual(after["reboots"],1)
self.assertEqual(after["history"][-1]["previous_last_seen"],"last")
self.assertEqual(before["reboots"],0)
def test_unknown_reason_is_not_reported_as_power_loss(self):
text=m.classify({})
self.assertIn("未确认",text); self.assertIn("不能区分",text)
def test_orderly_shutdown_requires_real_markers(self):
self.assertIn("正常关机",m.classify({"tail":{"text":"systemd-shutdown[1]: Syncing filesystems"}}))
self.assertIn("未确认",m.classify({"tail":{"text":"USB power management enabled; watchdog capability present"}}))
def test_oom_is_a_clue_not_a_proven_reboot_cause(self):
text=m.classify({"warnings":{"text":"Out of memory: Killed process 123"}})
self.assertIn("不能单独认定",text)
def test_only_changed_pstore_is_a_new_clue(self):
archive={"text":"Kernel panic - not syncing: example"}
self.assertIn("未确认",m.classify({"old_pstore":archive}))
self.assertIn("不能单独认定",m.classify({"pstore_new":archive}))
def test_invalid_state_preserved(self):
for value in ({"version":2,"reboots":0},{"version":1,"reboots":True}):
with self.assertRaises(ValueError): m.transition(value,"new","now")
def test_multiple_boots_and_history_bound(self):
state=self.baseline()
for i in range(40): state,event=m.transition(state,"next-"+str(i),"now")
self.assertEqual(state["reboots"],40); self.assertEqual(len(state["history"]),32)
def test_durable_state_and_chinese_report(self):
with tempfile.TemporaryDirectory() as d:
o=m.Observer(Path(d)/"logs",Path(d)/"state"); o.state=self.baseline(); o.save()
self.assertEqual(json.loads(o.state_file.read_text())["boot_id"],"boot-a")
self.assertIn("重启次数:0",(o.logs/"overnight-summary.txt").read_text(encoding="utf-8"))
def test_daily_rollover_uses_each_event_date(self):
from datetime import datetime,timezone
with tempfile.TemporaryDirectory() as d:
o=m.Observer(Path(d)/"logs",Path(d)/"state")
for day in (4,5):
with patch.object(m,"datetime") as clock:
clock.now.return_value=datetime(2026,11,day,0,0,tzinfo=timezone.utc)
o.log("sample")
self.assertEqual(len(list(o.logs.glob("*.log"))),2)
if __name__=="__main__": unittest.main()