test harness: Path B (manifest-engine) for Standard-Machine

Initial harness scaffolding per SCOPE.md. Drives the win11 analyzer VM
via qemu-guest-agent (runs as NT AUTHORITY\SYSTEM, same context as
GE-Enforce in production - see reference-vm-qga-as-system memory note
for why this is preferred over WinRM).

Pieces:

- lib/qga.sh - host-side helpers (qga round-trip, snapshot revert,
  share mount via cmdkey + net use, file upload). Source from any
  harness script.
- lib/verify-state.ps1 - VM-side detection runner. Parses matrix.json,
  walks each app's verify block, prints PASS/FAIL with detail, exits
  0 only if every check passes. Methods: Registry, File, FileVersion,
  Hash, FileGrep.
- matrix.json - PC-type matrix data. Currently only Standard/Machine
  rows populated (apps + drift scenarios). Extending to other PC types
  is just adding rows.
- B-enforce/run.sh - 5-phase orchestrator (stage / baseline / tamper /
  heal / idempotent). Defaults to Standard/Machine. SKIP_REVERT=1 for
  faster iteration without burning the snapshot revert.
- B-enforce/tamper.ps1 - applies driftScenarios from matrix.json.
  Methods: RegRemove, RegSet, FileDelete, FileOverwrite, FileGrepDelete.

Path A (imaging-time install) and remaining 8 PC-type rows are next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-02 17:15:37 -04:00
parent 26bc1720af
commit db1cdf7aee
5 changed files with 470 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# B-enforce/run.sh - exercise the manifest engine (GE-Enforce + Install-FromManifest)
# end-to-end on the win11 VM as a synthetic shopfloor PC.
#
# Phases:
# 1. stage: copy enforcer + lib + matrix into the VM, mount the v2 share
# 2. baseline: run dispatcher once, verify expected state
# 3. tamper: apply driftScenarios from matrix.json
# 4. heal: re-run dispatcher, verify drift went away
# 5. idempotent:re-run dispatcher, verify still clean (every entry skipped)
#
# Usage:
# ./run.sh # defaults to Standard / Machine
# ./run.sh CMM "" # CMM (no subtype)
# ./run.sh Standard Timeclock # explicit
#
# Environment:
# VM_DOMAIN override VM name (default win11)
# SKIP_REVERT set to skip blank-slate snapshot revert (faster iteration)
set -euo pipefail
PCTYPE="${1:-Standard}"
PCSUBTYPE="${2:-Machine}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEST_ROOT="$(cd "$HERE/.." && pwd)"
REPO_ROOT="$(cd "$TEST_ROOT/../../../.." && pwd)"
source "$TEST_ROOT/lib/qga.sh"
# ANSI color (auto-disable if not on a tty)
if [[ -t 1 ]]; then
G='\033[32m'; R='\033[31m'; Y='\033[33m'; D='\033[0m'
else
G=''; R=''; Y=''; D=''
fi
phase() { printf "\n${Y}=== %s ===${D}\n" "$*"; }
ok() { printf "${G}[ OK ]${D} %s\n" "$*"; }
fail() { printf "${R}[FAIL]${D} %s\n" "$*"; FAILED=1; }
FAILED=0
phase "B-enforce.run for PCType=$PCTYPE PCSubType=$PCSUBTYPE"
if [[ "${SKIP_REVERT:-}" != "1" ]]; then
vm_revert_to_blank_slate
else
log "skipping snapshot revert (SKIP_REVERT=1)"
vm_start_if_needed
fi
phase "1. stage enforcer + lib + matrix into VM"
# Stage the enforcer + lib in a host samba-accessible dir so the VM can pull
# them across via Z:. /home/camp/pxe-images/ is mapped to the share.
ENFORCER_STAGE="/home/camp/pxe-images/test-stage"
mkdir -p "$ENFORCER_STAGE/lib" "$ENFORCER_STAGE/test/B-enforce"
cp "$REPO_ROOT/playbook/shopfloor-setup/common/GE-Enforce.ps1" "$ENFORCER_STAGE/"
cp "$REPO_ROOT/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1" "$ENFORCER_STAGE/lib/"
cp "$TEST_ROOT/matrix.json" "$ENFORCER_STAGE/test/"
cp "$TEST_ROOT/lib/verify-state.ps1" "$ENFORCER_STAGE/test/"
cp "$HERE/tamper.ps1" "$ENFORCER_STAGE/test/B-enforce/"
vm_mount_share
# Inside VM: copy from share to local C:\Tools\, prep enrollment stubs +
# fake SFLD creds in HKLM, point shopfloorShareRoot at the v2 mirror.
qga_run_ps <<EOF
\$ErrorActionPreference='Continue'
# Clean prior state
Get-Process powershell -ErrorAction SilentlyContinue | Where-Object { \$_.Id -ne \$PID } | Stop-Process -Force -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force 'C:\\Program Files\\GE\\Shopfloor' -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force 'C:\\Logs\\Shopfloor' -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force 'C:\\Tools\\test-harness' -ErrorAction SilentlyContinue
# Stage enforcer
New-Item -ItemType Directory -Force -Path 'C:\\Program Files\\GE\\Shopfloor\\lib' | Out-Null
[System.IO.File]::WriteAllBytes('C:\\Program Files\\GE\\Shopfloor\\GE-Enforce.ps1', [System.IO.File]::ReadAllBytes('Z:\\test-stage\\GE-Enforce.ps1'))
[System.IO.File]::WriteAllBytes('C:\\Program Files\\GE\\Shopfloor\\lib\\Install-FromManifest.ps1', [System.IO.File]::ReadAllBytes('Z:\\test-stage\\lib\\Install-FromManifest.ps1'))
# Stage harness assets (matrix + verify + tamper)
New-Item -ItemType Directory -Force -Path 'C:\\Tools\\test-harness\\B-enforce' | Out-Null
[System.IO.File]::WriteAllBytes('C:\\Tools\\test-harness\\matrix.json', [System.IO.File]::ReadAllBytes('Z:\\test-stage\\test\\matrix.json'))
[System.IO.File]::WriteAllBytes('C:\\Tools\\test-harness\\verify-state.ps1', [System.IO.File]::ReadAllBytes('Z:\\test-stage\\test\\verify-state.ps1'))
[System.IO.File]::WriteAllBytes('C:\\Tools\\test-harness\\B-enforce\\tamper.ps1', [System.IO.File]::ReadAllBytes('Z:\\test-stage\\test\\B-enforce\\tamper.ps1'))
# Enrollment stubs
New-Item -ItemType Directory -Force -Path 'C:\\Enrollment' | Out-Null
'$PCTYPE' | Set-Content -Path 'C:\\Enrollment\\pc-type.txt' -Encoding ascii
'$PCSUBTYPE' | Set-Content -Path 'C:\\Enrollment\\pc-subtype.txt' -Encoding ascii
@{ shopfloorShareRoot = '\\\\$HOST_SAMBA_HOST\\pxe-images\\tsgwp00525-v2\\shared\\dt\\shopfloor'; siteName = 'WJ (test)' } | ConvertTo-Json | Set-Content -Path 'C:\\Enrollment\\site-config.json' -Encoding ascii
# SFLD cred for the dispatcher's Get-SFLDCredential lookup
\$credKey = 'HKLM:\\SOFTWARE\\GE\\SFLD\\Credentials\\samba'
New-Item -Path \$credKey -Force | Out-Null
New-ItemProperty -Path \$credKey -Name TargetHost -Value '$HOST_SAMBA_HOST' -PropertyType String -Force | Out-Null
New-ItemProperty -Path \$credKey -Name Username -Value '$HOST_SAMBA_USER' -PropertyType String -Force | Out-Null
New-ItemProperty -Path \$credKey -Name Password -Value '$HOST_SAMBA_PASS' -PropertyType String -Force | Out-Null
"staged"
EOF
run_dispatcher() {
local label="$1"
log "running GE-Enforce ($label)..."
qga_run_ps <<'EOF' 2>&1 | tail -30
& 'C:\Program Files\GE\Shopfloor\GE-Enforce.ps1' 2>&1 | Out-Null
"--- last 25 enforce log lines ---"
$f = Get-ChildItem 'C:\Logs\Shopfloor\enforce-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($f) { Get-Content $f.FullName -Tail 25 } else { "no enforce log" }
EOF
}
run_verify() {
local phase_label="$1"
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() {
local phase_label="$1" expect_pass="$2"
log "verify ($phase_label, expect_pass=$expect_pass)"
local out
out=$(qga_run_ps <<EOF
& 'C:\\Tools\\test-harness\\verify-state.ps1' -MatrixPath 'C:\\Tools\\test-harness\\matrix.json' -PCType '$PCTYPE' -PCSubType '$PCSUBTYPE'
EOF
)
echo "$out"
if echo "$out" | grep -qE 'verify summary: [0-9]+/[0-9]+ passed' && echo "$out" | grep -qE '\[FAIL\]'; then
# there are failures
if [[ "$expect_pass" == "true" ]]; then fail "$phase_label: had failures, expected all pass"; fi
elif echo "$out" | grep -qE 'verify summary: [0-9]+/[0-9]+ passed' && ! echo "$out" | grep -qE '\[FAIL\]'; then
if [[ "$expect_pass" == "true" ]]; then ok "$phase_label: all pass"; fi
fi
}
phase "2. baseline (clean dispatcher run)"
run_dispatcher "baseline"
do_verify "baseline" "true"
phase "3. tamper drift"
qga_run_ps <<EOF
& 'C:\\Tools\\test-harness\\B-enforce\\tamper.ps1' -MatrixPath 'C:\\Tools\\test-harness\\matrix.json' -PCType '$PCTYPE' -PCSubType '$PCSUBTYPE'
EOF
phase "4. heal (dispatcher re-run after drift)"
run_dispatcher "heal"
do_verify "heal" "true"
phase "5. idempotent (no-op cycle, all should skip)"
run_dispatcher "idempotent"
do_verify "idempotent" "true"
phase "result"
if [[ "$FAILED" -eq 0 ]]; then
ok "B-enforce: all phases passed for $PCTYPE/$PCSUBTYPE"
exit 0
else
fail "B-enforce: at least one phase failed for $PCTYPE/$PCSUBTYPE"
exit 1
fi

View File

@@ -0,0 +1,59 @@
# tamper.ps1 - VM-side: apply each driftScenarios entry from matrix.json
# for the requested PCType/PCSubType, then print what was tampered. Used
# by the harness between cycle-1 (clean run) and cycle-2 (heal).
#
# Methods supported:
# RegRemove -> remove a registry value
# RegSet -> set a registry value to a wrong value
# FileDelete -> delete a file
# FileOverwrite -> rewrite a file with bogus content
# FileGrepDelete -> drop matching lines from a text file (e.g. hosts)
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)] [string]$MatrixPath,
[Parameter(Mandatory=$true)] [string]$PCType,
[string]$PCSubType
)
$ErrorActionPreference = 'Continue'
$matrix = Get-Content -LiteralPath $MatrixPath -Raw | ConvertFrom-Json
$entry = $matrix.pcTypes | Where-Object { $_.PCType -eq $PCType -and ($_.PCSubType -eq $PCSubType -or [string]::IsNullOrEmpty($_.PCSubType)) } | Select-Object -First 1
if (-not $entry) { Write-Host "[FAIL] no matrix entry for $PCType/$PCSubType"; exit 1 }
foreach ($d in $entry.driftScenarios) {
$t = $d.tamper
try {
switch ($t.method) {
'RegRemove' {
if (Test-Path -LiteralPath $t.path) {
Remove-ItemProperty -LiteralPath $t.path -Name $t.regName -Force -ErrorAction Stop
}
Write-Host "[TAMPER] $($d.name) - removed reg $($t.path)\$($t.regName)"
}
'RegSet' {
if (-not (Test-Path -LiteralPath $t.path)) {
New-Item -Path $t.path -Force | Out-Null
}
Set-ItemProperty -LiteralPath $t.path -Name $t.regName -Value $t.value -Force
Write-Host "[TAMPER] $($d.name) - set reg $($t.path)\$($t.regName) = $($t.value)"
}
'FileDelete' {
if (Test-Path -LiteralPath $t.path) { Remove-Item -LiteralPath $t.path -Force -ErrorAction Stop }
Write-Host "[TAMPER] $($d.name) - deleted $($t.path)"
}
'FileOverwrite' {
Set-Content -LiteralPath $t.path -Value $t.content -Encoding ascii -Force
Write-Host "[TAMPER] $($d.name) - overwrote $($t.path)"
}
'FileGrepDelete' {
$kept = Get-Content -LiteralPath $t.path | Where-Object { $_ -notmatch $t.pattern }
Set-Content -LiteralPath $t.path -Value $kept -Encoding ascii -Force
Write-Host "[TAMPER] $($d.name) - dropped lines matching $($t.pattern) from $($t.path)"
}
default { Write-Host "[WARN] unknown tamper method: $($t.method)" }
}
} catch {
Write-Host "[WARN] tamper failed for $($d.name): $_"
}
}