test harness: smoke-pass B-enforce, fix four issues

Harness now passes 9/9 across baseline + heal + idempotent phases on the
win11 VM (Standard/Machine), with 6 drift scenarios applied + healed
between the baseline and heal cycles in ~30s total.

Fixes:

1. lib/qga-run.py - extracted the qga round-trip out of an inline
   `python3 - <<PY` heredoc. The inline form clobbered stdin (heredoc
   replaces stdin to feed python the script, leaving sys.stdin empty
   for the PowerShell snippet the function caller piped in).
2. lib/qga.sh - dropped `set -euo pipefail`. When sourced, it leaked
   into the harness shell. Then any captured `out=$(qga_run_ps ...)`
   that exited non-zero (verify-state.ps1 returns 1 on any FAIL,
   normal during drift phases) would silently abort the harness.
   Callers handle non-zero with `|| rc=$?`.
3. B-enforce/run.sh do_verify - rewritten to capture rc, parse summary
   line, distinguish expect_pass=true vs false, route to ok / fail
   helper without aborting the harness on a normal non-zero verify.
4. matrix.json WJF Defect Tracker entry - switched detection from File
   to Registry (uninstall key DisplayVersion). The MSI does not drop
   the Defect_Tracker.exe artifact at the documented path even though
   the manifest's File detection treats it as installed; the uninstall
   reg entry is the reliable install marker. v2 manifest's File
   detection path may also need fixing, separate task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-02 17:45:06 -04:00
parent db1cdf7aee
commit eaf2dbf167
4 changed files with 79 additions and 51 deletions

View File

@@ -114,30 +114,32 @@ if ($f) { Get-Content $f.FullName -Tail 25 } else { "no enforce log" }
EOF EOF
} }
run_verify() { # Verify wrapper: capture output, parse summary line. PS verify returns
local phase_label="$1" # rc=1 when any check fails - capture that without aborting the harness.
log "verify ($phase_label)..."
if qga_run_ps <<'EOF' | tee /dev/stderr | grep -q '=== verify summary' && qga_run_ps <<'EOF2' | grep -qE '0/[0-9]+ passed|^=== verify summary: ([0-9]+)/\1 passed'; then :; fi
& 'C:\Tools\test-harness\verify-state.ps1' -MatrixPath 'C:\Tools\test-harness\matrix.json' -PCType '$PCTYPE' -PCSubType '$PCSUBTYPE'
EOF
EOF2
}
# Simpler verify wrapper: capture output, parse summary line
do_verify() { do_verify() {
local phase_label="$1" expect_pass="$2" local phase_label="$1" expect_pass="$2"
log "verify ($phase_label, expect_pass=$expect_pass)" log "verify ($phase_label, expect_pass=$expect_pass)"
local out local out rc=0
out=$(qga_run_ps <<EOF out=$(qga_run_ps <<EOF
& 'C:\\Tools\\test-harness\\verify-state.ps1' -MatrixPath 'C:\\Tools\\test-harness\\matrix.json' -PCType '$PCTYPE' -PCSubType '$PCSUBTYPE' & 'C:\\Tools\\test-harness\\verify-state.ps1' -MatrixPath 'C:\\Tools\\test-harness\\matrix.json' -PCType '$PCTYPE' -PCSubType '$PCSUBTYPE'
EOF EOF
) ) || rc=$?
echo "$out" echo "$out"
if echo "$out" | grep -qE 'verify summary: [0-9]+/[0-9]+ passed' && echo "$out" | grep -qE '\[FAIL\]'; then local summary
# there are failures summary=$(echo "$out" | grep -oE 'verify summary: [0-9]+/[0-9]+ passed' | head -1)
if [[ "$expect_pass" == "true" ]]; then fail "$phase_label: had failures, expected all pass"; fi [[ -z "$summary" ]] && summary='(no summary line)'
elif echo "$out" | grep -qE 'verify summary: [0-9]+/[0-9]+ passed' && ! echo "$out" | grep -qE '\[FAIL\]'; then if echo "$out" | grep -qE '\[FAIL\]'; then
if [[ "$expect_pass" == "true" ]]; then ok "$phase_label: all pass"; fi if [[ "$expect_pass" == "true" ]]; then
fail "$phase_label: $summary - failures present, expected all pass"
else
ok "$phase_label: $summary - failures present (expected)"
fi
else
if [[ "$expect_pass" == "true" ]]; then
ok "$phase_label: $summary"
else
fail "$phase_label: $summary - all pass, expected failures (drift not applied?)"
fi
fi fi
} }

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# qga-run.py - run a PowerShell snippet inside the win11 VM via qemu-guest-agent.
# Stdin = PS snippet. Stdout = combined stdout, then optional STDERR block,
# then "--- exit N ---". Exit code 0 on PS rc 0, 1 otherwise.
import base64, json, os, subprocess, sys, time
DOMAIN = os.environ.get('VM_DOMAIN', 'win11')
TIMEOUT = int(os.environ.get('QGA_TIMEOUT', '300'))
def virsh(cmd):
p = subprocess.run(
['virsh', '-c', 'qemu:///system', 'qemu-agent-command', DOMAIN, json.dumps(cmd)],
capture_output=True, text=True, timeout=120,
)
if p.returncode != 0:
sys.exit(f"virsh err: {p.stderr.strip()}")
return json.loads(p.stdout)['return']
snippet = sys.stdin.read()
if not snippet.strip():
sys.exit("qga-run.py: empty PowerShell snippet on stdin")
args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', snippet]
pid = virsh({'execute': 'guest-exec',
'arguments': {'path': 'powershell.exe', 'arg': args, 'capture-output': True}})['pid']
deadline = time.time() + TIMEOUT
while time.time() < deadline:
st = virsh({'execute': 'guest-exec-status', 'arguments': {'pid': pid}})
if st.get('exited'):
out = base64.b64decode(st.get('out-data', '')).decode('utf-8', 'replace')
err = base64.b64decode(st.get('err-data', '')).decode('utf-8', 'replace')
rc = st.get('exitcode')
sys.stdout.write(out)
if err:
sys.stdout.write('\n--- STDERR ---\n')
sys.stdout.write(err)
sys.stdout.write(f'\n--- exit {rc} ---\n')
sys.exit(0 if rc == 0 else 1)
time.sleep(0.5)
sys.exit(f"timeout waiting for pid {pid}")

View File

@@ -6,8 +6,13 @@
# All commands run as NT AUTHORITY\SYSTEM inside the VM (qemu-ga's service # All commands run as NT AUTHORITY\SYSTEM inside the VM (qemu-ga's service
# context). See reference-vm-qga-as-system memory note for why this is # context). See reference-vm-qga-as-system memory note for why this is
# preferred over WinRM for dispatcher / manifest-engine tests. # preferred over WinRM for dispatcher / manifest-engine tests.
#
set -euo pipefail # Sourced by harness scripts. Deliberately does NOT enable set -e because
# qga_run_ps returns non-zero whenever the inner PowerShell exits non-zero
# (expected during drift/verify phases), and a sourced set -e would silently
# abort the calling shell on every $(qga_run_ps ...) capture of a failing run.
# Callers that want strict mode should set it themselves AND wrap qga_run_ps
# captures with `|| rc=$?` so the non-zero exit does not propagate.
VM_DOMAIN="${VM_DOMAIN:-win11}" VM_DOMAIN="${VM_DOMAIN:-win11}"
VM_IP="${VM_IP:-192.168.122.225}" VM_IP="${VM_IP:-192.168.122.225}"
@@ -26,38 +31,17 @@ qga() {
|| die "qga call failed: $payload" || die "qga call failed: $payload"
} }
# Run a PowerShell snippet inside the VM as SYSTEM. Stdin = snippet, # Run a PowerShell snippet inside the VM as SYSTEM. Stdin = snippet.
# stdout = exit-decorated combined stdout+stderr from the PS process. # Stdout = combined PS stdout, then optional "--- STDERR ---" block,
# then "--- exit N ---". Returns 0 iff the PS process returned 0.
#
# Implementation note: keep the python script in a sibling file rather
# than inlined via heredoc. An inline `python3 - <<PY ... PY` redirects
# stdin to the heredoc, which clobbers the PowerShell snippet the
# caller piped in.
QGA_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
qga_run_ps() { qga_run_ps() {
python3 - <<'PY' python3 "$QGA_LIB_DIR/qga-run.py"
import base64, json, subprocess, sys, time, os
DOMAIN = os.environ.get('VM_DOMAIN', 'win11')
TIMEOUT = int(os.environ.get('QGA_TIMEOUT', '300'))
def virsh(cmd):
p = subprocess.run(['virsh','-c','qemu:///system','qemu-agent-command',DOMAIN, json.dumps(cmd)],
capture_output=True, text=True, timeout=120)
if p.returncode != 0:
sys.exit(f"virsh err: {p.stderr.strip()}")
return json.loads(p.stdout)['return']
snippet = sys.stdin.read()
args = ['-NoProfile','-ExecutionPolicy','Bypass','-Command', snippet]
pid = virsh({'execute':'guest-exec','arguments':{'path':'powershell.exe','arg':args,'capture-output':True}})['pid']
deadline = time.time() + TIMEOUT
while time.time() < deadline:
st = virsh({'execute':'guest-exec-status','arguments':{'pid':pid}})
if st.get('exited'):
out = base64.b64decode(st.get('out-data','')).decode('utf-8','replace')
err = base64.b64decode(st.get('err-data','')).decode('utf-8','replace')
rc = st.get('exitcode')
sys.stdout.write(out)
if err:
sys.stdout.write('\n--- STDERR ---\n')
sys.stdout.write(err)
sys.stdout.write(f'\n--- exit {rc} ---\n')
sys.exit(0 if rc == 0 else 1)
time.sleep(0.5)
sys.exit(f"timeout waiting for pid {pid}")
PY
} }
# Wait until qemu-ga responds to guest-ping # Wait until qemu-ga responds to guest-ping

View File

@@ -8,7 +8,7 @@
"scopes": ["common", "standard-machine"], "scopes": ["common", "standard-machine"],
"apps": [ "apps": [
{ "name": "Adobe Acrobat Reader DC", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{AC76BA86-7AD7-1033-7B44-AC0F074E4100}", "name": "DisplayVersion", "value": "25.001.20531" } }, { "name": "Adobe Acrobat Reader DC", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{AC76BA86-7AD7-1033-7B44-AC0F074E4100}", "name": "DisplayVersion", "value": "25.001.20531" } },
{ "name": "WJF Defect Tracker", "verify": { "method": "File", "path": "C:\\Program Files (x86)\\WJF_Defect_Tracker\\Defect_Tracker.exe" } }, { "name": "WJF Defect Tracker", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{CC1B4D32-1606-4A3F-8F24-31312F723D5C}", "name": "DisplayVersion", "value": "01.00.0102" } },
{ "name": "3OF9 barcode font", "verify": { "method": "File", "path": "C:\\Windows\\Fonts\\3OF9.ttf" } }, { "name": "3OF9 barcode font", "verify": { "method": "File", "path": "C:\\Windows\\Fonts\\3OF9.ttf" } },
{ "name": "Edge IE-Mode site list", "verify": { "method": "Hash", "path": "C:\\ProgramData\\Edge\\enterprise-mode-site-list.xml", "value": "16F2A6E45EFA19ED7B1C54B264D6B33597678D3A5303255BC7CEB7E8510C60FC" } }, { "name": "Edge IE-Mode site list", "verify": { "method": "Hash", "path": "C:\\ProgramData\\Edge\\enterprise-mode-site-list.xml", "value": "16F2A6E45EFA19ED7B1C54B264D6B33597678D3A5303255BC7CEB7E8510C60FC" } },
{ "name": "OpenText HostExplorer", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\GE\\OpenText", "name": "Installed", "value": "15.0.SP1.2" } }, { "name": "OpenText HostExplorer", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\GE\\OpenText", "name": "Installed", "value": "15.0.SP1.2" } },