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:
168
playbook/shopfloor-setup/common/test/B-enforce/run.sh
Executable file
168
playbook/shopfloor-setup/common/test/B-enforce/run.sh
Executable 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
|
||||
59
playbook/shopfloor-setup/common/test/B-enforce/tamper.ps1
Normal file
59
playbook/shopfloor-setup/common/test/B-enforce/tamper.ps1
Normal 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): $_"
|
||||
}
|
||||
}
|
||||
117
playbook/shopfloor-setup/common/test/lib/qga.sh
Executable file
117
playbook/shopfloor-setup/common/test/lib/qga.sh
Executable file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# qga.sh - host-side helpers for driving the win11 VM via qemu-guest-agent.
|
||||
#
|
||||
# Source this from harness scripts: `source "$(dirname "$0")/../lib/qga.sh"`
|
||||
#
|
||||
# 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
|
||||
# preferred over WinRM for dispatcher / manifest-engine tests.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VM_DOMAIN="${VM_DOMAIN:-win11}"
|
||||
VM_IP="${VM_IP:-192.168.122.225}"
|
||||
HOST_SAMBA_USER="${HOST_SAMBA_USER:-camp}"
|
||||
HOST_SAMBA_PASS="${HOST_SAMBA_PASS:-vos313}"
|
||||
HOST_SAMBA_HOST="${HOST_SAMBA_HOST:-192.168.122.1}"
|
||||
|
||||
# Helper: fail loud
|
||||
die() { echo "[ERROR] $*" >&2; exit 1; }
|
||||
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
|
||||
|
||||
# Send a JSON command to qemu-ga, return parsed return field
|
||||
qga() {
|
||||
local payload="$1"
|
||||
virsh -c qemu:///system qemu-agent-command "$VM_DOMAIN" "$payload" 2>/dev/null \
|
||||
|| die "qga call failed: $payload"
|
||||
}
|
||||
|
||||
# Run a PowerShell snippet inside the VM as SYSTEM. Stdin = snippet,
|
||||
# stdout = exit-decorated combined stdout+stderr from the PS process.
|
||||
qga_run_ps() {
|
||||
python3 - <<'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
|
||||
vm_wait_for_ready() {
|
||||
local max=${1:-90}
|
||||
local i=0
|
||||
while ! virsh -c qemu:///system qemu-agent-command "$VM_DOMAIN" '{"execute":"guest-ping"}' >/dev/null 2>&1; do
|
||||
((i++))
|
||||
if (( i > max )); then die "VM never became ready ($max attempts)"; fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# Make sure VM is up and qga is alive. Start if not.
|
||||
vm_start_if_needed() {
|
||||
local state
|
||||
state=$(virsh -c qemu:///system domstate "$VM_DOMAIN" 2>/dev/null || echo unknown)
|
||||
if [[ "$state" != "running" ]]; then
|
||||
log "VM is $state - starting"
|
||||
virsh -c qemu:///system start "$VM_DOMAIN" >/dev/null
|
||||
fi
|
||||
vm_wait_for_ready 90
|
||||
}
|
||||
|
||||
# Mount the host samba share inside the VM as SYSTEM. Idempotent.
|
||||
# After this call Z:\ inside the VM points at /home/camp/pxe-images/.
|
||||
vm_mount_share() {
|
||||
qga_run_ps <<PS
|
||||
cmdkey /add:$HOST_SAMBA_HOST /user:$HOST_SAMBA_USER /pass:$HOST_SAMBA_PASS | Out-Null
|
||||
net use Z: \\\\$HOST_SAMBA_HOST\\pxe-images /persistent:no 2>&1 | Out-Null
|
||||
"Z reachable: \$(Test-Path 'Z:\\')"
|
||||
PS
|
||||
}
|
||||
|
||||
# Upload a single file from host to VM by reading it into VM via WriteAllBytes.
|
||||
# Best for tiny files (config, single ps1). For bigger files use vm_mount_share + Copy-Item.
|
||||
vm_put_file() {
|
||||
local local_path="$1" remote_path="$2"
|
||||
[[ -f "$local_path" ]] || die "no such file: $local_path"
|
||||
local b64
|
||||
b64=$(base64 -w0 < "$local_path")
|
||||
qga_run_ps <<PS
|
||||
\$bytes = [System.Convert]::FromBase64String('$b64')
|
||||
\$dst = '$remote_path'
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path \$dst -Parent) -ErrorAction SilentlyContinue | Out-Null
|
||||
[System.IO.File]::WriteAllBytes(\$dst, \$bytes)
|
||||
"wrote \$dst (\$(\$bytes.Length) bytes)"
|
||||
PS
|
||||
}
|
||||
|
||||
# Revert the VM to the blank-slate snapshot, then start + wait.
|
||||
vm_revert_to_blank_slate() {
|
||||
log "reverting to blank-slate snapshot..."
|
||||
sudo virsh -c qemu:///system snapshot-revert "$VM_DOMAIN" blank-slate \
|
||||
|| die "snapshot-revert failed (need sudo without password, or run script as root)"
|
||||
vm_start_if_needed
|
||||
}
|
||||
96
playbook/shopfloor-setup/common/test/lib/verify-state.ps1
Normal file
96
playbook/shopfloor-setup/common/test/lib/verify-state.ps1
Normal file
@@ -0,0 +1,96 @@
|
||||
# verify-state.ps1 - VM-side detection runner. Reads the harness matrix.json
|
||||
# from the path given via -MatrixPath, runs the verify block of each app under
|
||||
# the requested -PCType / -PCSubType, prints per-app PASS / FAIL / WARN, and
|
||||
# exits 0 only if every check passes.
|
||||
#
|
||||
# Detection methods supported:
|
||||
# Registry -> Get-ItemProperty $path[$name] -eq $value
|
||||
# File -> Test-Path $path
|
||||
# FileVersion -> (Get-Item $path).VersionInfo.FileVersion -eq $value
|
||||
# Hash -> Get-FileHash SHA256 -eq $value
|
||||
# FileGrep -> Get-Content $path -match $pattern (regex)
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] [string]$MatrixPath,
|
||||
[Parameter(Mandatory=$true)] [string]$PCType,
|
||||
[string]$PCSubType
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $MatrixPath)) {
|
||||
Write-Host "[FAIL] matrix not found at $MatrixPath"
|
||||
exit 1
|
||||
}
|
||||
$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=$PCType PCSubType=$PCSubType"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Test-AppState {
|
||||
param($app)
|
||||
$v = $app.verify
|
||||
switch ($v.method) {
|
||||
'Registry' {
|
||||
if (-not (Test-Path -LiteralPath $v.path)) { return @{ pass=$false; detail="reg path missing: $($v.path)" } }
|
||||
$val = (Get-ItemProperty -LiteralPath $v.path -Name $v.name -ErrorAction SilentlyContinue).$($v.name)
|
||||
if ($null -eq $val) { return @{ pass=$false; detail="reg name $($v.name) not present" } }
|
||||
if ($v.value -and $val -ne $v.value) {
|
||||
return @{ pass=$false; detail="reg $($v.name) = '$val' (expected '$($v.value)')" }
|
||||
}
|
||||
return @{ pass=$true; detail="reg $($v.name) = '$val'" }
|
||||
}
|
||||
'File' {
|
||||
if (Test-Path -LiteralPath $v.path) { return @{ pass=$true; detail="exists: $($v.path)" } }
|
||||
return @{ pass=$false; detail="missing: $($v.path)" }
|
||||
}
|
||||
'FileVersion' {
|
||||
if (-not (Test-Path -LiteralPath $v.path)) { return @{ pass=$false; detail="missing: $($v.path)" } }
|
||||
$ver = (Get-Item -LiteralPath $v.path).VersionInfo.FileVersion
|
||||
if ($v.value -and $ver -ne $v.value) {
|
||||
return @{ pass=$false; detail="version $ver (expected $($v.value))" }
|
||||
}
|
||||
return @{ pass=$true; detail="version $ver" }
|
||||
}
|
||||
'Hash' {
|
||||
if (-not (Test-Path -LiteralPath $v.path)) { return @{ pass=$false; detail="missing: $($v.path)" } }
|
||||
$h = (Get-FileHash -LiteralPath $v.path -Algorithm SHA256).Hash
|
||||
if ($v.value -and $h -ne $v.value) {
|
||||
return @{ pass=$false; detail="hash $h (expected $($v.value))" }
|
||||
}
|
||||
return @{ pass=$true; detail="hash matches" }
|
||||
}
|
||||
'FileGrep' {
|
||||
if (-not (Test-Path -LiteralPath $v.path)) { return @{ pass=$false; detail="missing: $($v.path)" } }
|
||||
$hit = Get-Content -LiteralPath $v.path | Select-String -Pattern $v.pattern -Quiet
|
||||
if ($hit) { return @{ pass=$true; detail="pattern matched: $($v.pattern)" } }
|
||||
return @{ pass=$false; detail="pattern not found: $($v.pattern)" }
|
||||
}
|
||||
default { return @{ pass=$false; detail="unknown method: $($v.method)" } }
|
||||
}
|
||||
}
|
||||
|
||||
$total = 0; $passed = 0; $failed = @()
|
||||
foreach ($app in $entry.apps) {
|
||||
$total++
|
||||
$r = Test-AppState -app $app
|
||||
if ($r.pass) {
|
||||
Write-Host " [PASS] $($app.name) - $($r.detail)"
|
||||
$passed++
|
||||
} else {
|
||||
Write-Host " [FAIL] $($app.name) - $($r.detail)"
|
||||
$failed += $app.name
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== verify summary: $passed/$total passed ==="
|
||||
if ($failed.Count -gt 0) {
|
||||
Write-Host "failed: $($failed -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
30
playbook/shopfloor-setup/common/test/matrix.json
Normal file
30
playbook/shopfloor-setup/common/test/matrix.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"_comment": "Test matrix for shopfloor harness. Each PC-type entry lists apps to verify + drift scenarios for Path B's tamper+heal phase. Verify methods mirror the v2 manifest's DetectionMethod so harness verification == GE-Enforce detection.",
|
||||
|
||||
"pcTypes": [
|
||||
{
|
||||
"PCType": "Standard",
|
||||
"PCSubType": "Machine",
|
||||
"scopes": ["common", "standard-machine"],
|
||||
"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": "WJF Defect Tracker", "verify": { "method": "File", "path": "C:\\Program Files (x86)\\WJF_Defect_Tracker\\Defect_Tracker.exe" } },
|
||||
{ "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": "OpenText HostExplorer", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\GE\\OpenText", "name": "Installed", "value": "15.0.SP1.2" } },
|
||||
{ "name": "FMS hosts pin", "verify": { "method": "FileGrep", "path": "C:\\Windows\\System32\\drivers\\etc\\hosts", "pattern": "10\\.233\\.112\\.158\\s+wjfms3\\.ae\\.ge\\.com" } },
|
||||
{ "name": "FMS Primary host", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "name": "FMSHostPrimary", "value": "wjfms3.ae.ge.com" } },
|
||||
{ "name": "FMS Secondary host", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "name": "FMSHostSecondary", "value": "10.233.112.158" } },
|
||||
{ "name": "eDNC bundles NTLARS", "verify": { "method": "FileVersion", "path": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe", "value": "6.4.5.0" } }
|
||||
],
|
||||
"driftScenarios": [
|
||||
{ "name": "OpenText marker reset", "tamper": { "method": "RegRemove", "path": "HKLM:\\SOFTWARE\\GE\\OpenText", "regName": "Installed" }, "expectedHeal": "OpenText HostExplorer" },
|
||||
{ "name": "Hosts pin removed", "tamper": { "method": "FileGrepDelete", "path": "C:\\Windows\\System32\\drivers\\etc\\hosts", "pattern": "wjfms3\\.ae\\.ge\\.com" }, "expectedHeal": "FMS hosts pin" },
|
||||
{ "name": "FMS Primary clobbered", "tamper": { "method": "RegSet", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "regName": "FMSHostPrimary", "value": "WJFMS3" }, "expectedHeal": "FMS Primary host" },
|
||||
{ "name": "FMS Secondary clobbered", "tamper": { "method": "RegSet", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "regName": "FMSHostSecondary", "value": "WJFMS4" }, "expectedHeal": "FMS Secondary host" },
|
||||
{ "name": "Edge IE site list overwrite", "tamper": { "method": "FileOverwrite", "path": "C:\\ProgramData\\Edge\\enterprise-mode-site-list.xml", "content": "<!--tampered-->" }, "expectedHeal": "Edge IE-Mode site list" },
|
||||
{ "name": "3OF9 font deleted", "tamper": { "method": "FileDelete", "path": "C:\\Windows\\Fonts\\3OF9.ttf" }, "expectedHeal": "3OF9 barcode font" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user