scripts/ci-qa-windows-e2e.ps1
Rama main · réplica de lectura del repositorio original.
<#
.SYNOPSIS
Windows E2E: G3 capability gate, source deployment, G6 service shell, browser/API QA, and nested guest boot.
.DESCRIPTION
Runs on a Windows host whose `firecrab service doctor` reports ready. The gate
installs microManager with -Cli. With -Source, it then builds and deploys that
checkout using service dev before running QA. The shell phase checks G6 from Windows. The api, nginx, and guest phases run the shared
Linux QA scripts inside the managed distribution, which is the Firecrab host,
the same way Linux CI runs them on its own host. After successful prerequisites,
all runs every test phase and returns failure if any fails. Results and browser
artifacts are retained on Windows. Test-created resources are cleaned up by those
scripts; the existing distribution and user data are preserved.
.EXAMPLE
scripts\ci-qa-windows-e2e.ps1 -Phase all -Cli target\debug\firecrab.exe
.EXAMPLE
scripts\ci-qa-windows-e2e.ps1 -Phase all -Cli target\debug\firecrab.exe -Source .
#>
[CmdletBinding()]
param(
[ValidateSet("gate", "shell", "browser", "api", "nginx", "guest", "lifetime", "all")]
[string]$Phase = "all",
# The Windows CLI under test; the gate installs microManager with it.
[string]$Cli = "firecrab.exe",
# Deploy API + net-helper from this checkout instead of testing the pinned release.
[string]$Source = "",
[switch]$Release,
# Optional Linux firecrab binary put first on the QA PATH, like macOS E2E
# uses the checkout's CLI. Without it the release CLI in the guest runs.
[string]$LinuxCli = "",
# Multiplies nginx/SSH waits, not package-install or browser deadlines.
[ValidateRange(1, 100)]
[int]$WaitFactor = 1,
# Retain the transcript, phase summary, and browser traces on Windows.
[string]$ResultsDir = ""
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$distro = "firecrab-debian"
$api = "http://127.0.0.1:5523"
$runId = (Get-Date -Format "yyyyMMdd-HHmmss") + "-" + [guid]::NewGuid().ToString("N").Substring(0, 8)
$qaRoot = "/root/firecrab-qa/$runId"
if (-not $ResultsDir) { $ResultsDir = Join-Path $root "target\qa\windows\$runId" }
$ResultsDir = [IO.Path]::GetFullPath($ResultsDir)
New-Item -ItemType Directory -Force -Path $ResultsDir | Out-Null
$results = New-Object 'Collections.Generic.List[object]'
$overallExit = 0
$phaseSucceeded = $false
$runCompleted = $false
# `/tmp` in the distribution is a tmpfs sized from WSL's memory, firecrab-api
# may only create directories under /var/lib/firecrab, and Firecracker's API
# socket lives under the storage root, within the Unix socket path limit.
$storage = "/var/lib/firecrab/q"
function Fail([string]$Message, [int]$Code = 1) {
$failure = [Exception]::new($Message)
$failure.Data["exitCode"] = $Code
throw $failure
}
function Quote-Shell([string]$Value) {
"'" + $Value.Replace("'", "'" + [char]92 + "''") + "'"
}
function Save-Summary {
[ordered]@{
schemaVersion = 1
phase = $Phase
cli = $Cli
source = $Source
release = [bool]$Release
waitFactor = $WaitFactor
guestResultsPath = $qaRoot
status = if (-not $script:runCompleted) { "RUNNING" } elseif ($script:overallExit -eq 0) { "PASS" } else { "FAILED" }
exitCode = if ($script:runCompleted) { $script:overallExit } else { $null }
results = @($results.ToArray())
} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $ResultsDir "summary.json") -Encoding UTF8
}
function Invoke-QaPhase([string]$Name, [scriptblock]$Action) {
$entry = [ordered]@{ phase = $Name; status = "RUNNING"; exitCode = $null; detail = "" }
$results.Add($entry)
$script:phaseSucceeded = $false
Save-Summary
try {
& $Action | Out-Host
$entry.status = "PASS"
$entry.exitCode = 0
$script:phaseSucceeded = $true
} catch {
$entry.status = "FAILED"
$entry.exitCode = 1
$entry.detail = $_.Exception.Message
if ($_.Exception.Data.Contains("exitCode")) { $entry.exitCode = [int]$_.Exception.Data["exitCode"] }
if ($script:overallExit -eq 0) {
$script:overallExit = if ($Phase -eq "all") { 1 } else { $entry.exitCode }
}
[Console]::Error.WriteLine("FAILED ${Name}: $($entry.detail)")
}
Save-Summary
}
function Assert-Api {
try {
Invoke-WebRequest -UseBasicParsing "$api/api/host" -TimeoutSec 5 -ErrorAction Stop | Out-Null
} catch {
Fail "management API is not reachable at $api; run the gate phase first"
}
}
# Windows PowerShell turns a native program's stderr into error records once
# the output is redirected, and "Stop" would abort on the first such line, so
# every function that runs wsl.exe or the CLI relaxes it for its own scope.
# PowerShell 5.1 mangles double quotes in native arguments, so a script goes
# to the distribution as an LF-only file rather than as a `bash -c` argument.
function Invoke-InDistro([string]$Script) {
$ErrorActionPreference = "Continue"
$file = Join-Path ([IO.Path]::GetTempPath()) ("firecrab-qa-" + [guid]::NewGuid() + ".sh")
[IO.File]::WriteAllText($file, ($Script -replace "`r`n", "`n"), (New-Object Text.UTF8Encoding $false))
try {
$guestFile = (& wsl.exe -d $distro -u root --exec wslpath -a $file | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or -not $guestFile) { Fail "wslpath could not translate the QA script path" }
& wsl.exe -d $distro -u root --exec bash $guestFile | Out-Host
return $LASTEXITCODE
} finally {
Remove-Item -LiteralPath $file -ErrorAction SilentlyContinue
}
}
function ConvertTo-GuestPath([string]$Path) {
$ErrorActionPreference = "Continue"
$translated = (& wsl.exe -d $distro -u root --exec wslpath -a $Path | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or -not $translated) { Fail "wslpath could not translate $Path" }
$translated
}
function Invoke-Gate {
$ErrorActionPreference = "Continue"
Write-Output "G3 doctor"
$json = & $Cli service doctor --json | Out-String
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 1) { Fail "service doctor exited with $LASTEXITCODE" }
if (-not ($json | ConvertFrom-Json).ready) {
Write-Output $json
Fail "WSL2 nested virtualization capability is not ready"
}
Write-Output "G3 install"
& $Cli service install --yes
if ($LASTEXITCODE -ne 0) { Fail "service install exited with $LASTEXITCODE" }
if ($Source) {
Write-Output "G3 deploy source checkout"
$devArgs = @("service", "dev", "--source", (Resolve-Path -LiteralPath $Source).Path, "--yes")
if ($Release) { $devArgs += "--release" }
& $Cli @devArgs
if ($LASTEXITCODE -ne 0) { Fail "service dev exited with $LASTEXITCODE" }
}
& $Cli service status
if ($LASTEXITCODE -ne 0) { Fail "service status exited with $LASTEXITCODE" }
Assert-Api
Write-Output "PASS G3 capability, install, service status, and localhost API"
}
# G6: `service shell` runs a command in the distribution as root, passes each
# argument through unchanged, and returns the command's exit code. The CLI
# takes every word after `shell` as the command.
function Invoke-Shell {
$ErrorActionPreference = "Continue"
$user = (& $Cli service shell id -un | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $user -ne "root") { Fail "commands run as '$user' (exit $LASTEXITCODE), expected root" }
$state = (& $Cli service shell systemctl is-active firecrab-api | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $state -ne "active") { Fail "firecrab-api is '$state' in $distro, expected active" }
$output = ((& $Cli service shell printf '[%s]\n' "it's here" 'a b' '$HOME' | Out-String) -replace "`r", "").TrimEnd([char]10)
if ($LASTEXITCODE -ne 0 -or $output -ne "[it's here]`n[a b]`n[`$HOME]") { Fail "arguments arrived as: $output (exit $LASTEXITCODE)" }
& $Cli service shell sh -c 'exit 7' | Out-Null
if ($LASTEXITCODE -ne 7) { Fail "a command that exits 7 returned $LASTEXITCODE" }
Write-Output "PASS G6 service shell runs as root, keeps arguments, and returns exit codes"
}
# Copies the checkout's QA scripts into the distribution and installs the few
# tools they call that the managed guest does not ship.
function Initialize-Qa {
$scripts = ConvertTo-GuestPath (Join-Path $root "scripts")
$linuxCli = if ($LinuxCli) { ConvertTo-GuestPath (Resolve-Path -LiteralPath $LinuxCli).Path } else { "" }
$setup = @'
set -eu
exec 2>&1
mkdir -p __QA__/bin
cp -r __SCRIPTS__ __QA__/scripts
# A Windows checkout may carry CRLF endings, which bash rejects.
find __QA__/scripts -type f -name '*.sh' -exec sed -i 's/\r$//' {} +
if [ -n __LINUX_CLI__ ]; then install -m 0755 __LINUX_CLI__ __QA__/bin/firecrab; fi
missing=
command -v python3 >/dev/null || missing="$missing python3"
command -v ssh >/dev/null || missing="$missing openssh-client"
if [ -n "$missing" ]; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq $missing >/dev/null
fi
'@
$setup = $setup.Replace("__QA__", (Quote-Shell $qaRoot)).Replace("__SCRIPTS__", (Quote-Shell $scripts)).Replace("__LINUX_CLI__", (Quote-Shell $linuxCli))
if ((Invoke-InDistro $setup) -ne 0) { Fail "could not stage the QA scripts in $distro" }
}
function Invoke-Qa([string]$Script, [string]$Arguments) {
$powershell = ConvertTo-GuestPath (Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe")
$run = @'
set -eu
exec 2>&1
cd __QA__
export PATH=__QA__/bin:$PATH FIRECRAB_API='__API__'
export FIRECRAB_QA_STORAGE_PATH='__STORAGE__' FIRECRAB_QA_WAIT_FACTOR='__WAIT__'
export FIRECRAB_QA_WINDOWS_POWERSHELL=__POWERSHELL__
exec bash 'scripts/__SCRIPT__' __ARGS__
'@
$run = $run.Replace("__QA__", (Quote-Shell $qaRoot)).Replace("__API__", $api).Replace("__STORAGE__", $storage)
$run = $run.Replace("__WAIT__", [string]$WaitFactor).Replace("__SCRIPT__", $Script).Replace("__ARGS__", $Arguments)
$run = $run.Replace("__POWERSHELL__", (Quote-Shell $powershell))
$code = Invoke-InDistro $run
if ($code -ne 0) {
Fail "$Script exited with $code" $code
}
}
# The browser and local OCI fixture share the API's Linux loopback, including
# direct IPv6 SSH to the workload. Windows node_modules cannot be reused here.
function Invoke-BrowserQa {
$checkout = ConvertTo-GuestPath $root
$run = @'
set -euo pipefail
exec 2>&1
cd __QA__
tar -C __CHECKOUT__ --exclude=node_modules --exclude=test-results --exclude=playwright-report \
-cf - firecrab-e2e firecrab-frontend firecrab-api-types rust-toolchain.toml | tar -xf -
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends nodejs npm openssh-server curl build-essential
if ! command -v rustup >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none
fi
export PATH="$HOME/.cargo/bin:$PATH"
npm ci --prefix firecrab-e2e
npm ci --prefix firecrab-frontend
./firecrab-e2e/node_modules/.bin/playwright install --with-deps chromium
export FIRECRAB_E2E_REUSE_SERVER=1
export FIRECRAB_E2E_REQUIRE_GUEST_BOOT=1
export FIRECRAB_E2E_REQUIRE_RUNNING_API=1
unset FIRECRAB_E2E_SKIP_GUEST_BOOT FIRECRAB_QA_MANAGER_HOST FIRECRAB_QA_MANAGER_KEY
npm test --prefix firecrab-e2e
'@
$run = $run.Replace("__QA__", (Quote-Shell $qaRoot)).Replace("__CHECKOUT__", (Quote-Shell $checkout))
$code = Invoke-InDistro $run
if ($code -ne 0) { Fail "browser E2E exited with $code" $code }
}
function Save-BrowserResults {
$destination = ConvertTo-GuestPath (Join-Path $ResultsDir "browser-results.tar.gz")
$save = @'
set -euo pipefail
cd __QA__
files=()
for path in firecrab-e2e/test-results firecrab-e2e/playwright-report; do
if [ -d "$path" ]; then files+=("$path"); fi
done
if [ "${#files[@]}" -gt 0 ]; then tar -czf __DESTINATION__ "${files[@]}"; fi
'@
$save = $save.Replace("__QA__", (Quote-Shell $qaRoot)).Replace("__DESTINATION__", (Quote-Shell $destination))
if ((Invoke-InDistro $save) -ne 0) { Fail "could not retain browser results on Windows" }
}
$staged = $false
Start-Transcript -LiteralPath (Join-Path $ResultsDir "run.log") | Out-Null
try {
if ($Release -and -not $Source) { Fail "-Release requires -Source" }
if ($Source -or $Phase -eq "gate" -or $Phase -eq "all") {
Invoke-QaPhase "gate" { Invoke-Gate }
if (-not $phaseSucceeded) { Fail "runtime gate failed; QA phases were not run" $overallExit }
}
if ($Phase -ne "gate") {
Invoke-QaPhase "setup" { Assert-Api; Initialize-Qa }
if (-not $phaseSucceeded) { Fail "QA setup failed; test phases were not run" $overallExit }
$staged = $true
$phases = if ($Phase -eq "all") { @("shell", "api", "nginx", "guest", "lifetime", "browser") } else { @($Phase) }
foreach ($item in $phases) {
switch ($item) {
"shell" { Invoke-QaPhase "shell" { Invoke-Shell } }
"api" { Invoke-QaPhase "api" { Invoke-Qa "ci-qa-api.sh" "" } }
"nginx" { Invoke-QaPhase "nginx" { Invoke-Qa "ci-qa-nginx.sh" "nginx:1.27-alpine" } }
"guest" { Invoke-QaPhase "guest" { Invoke-Qa "ci-qa-guest.sh" "alpine:3.21 ubuntu:24.04 fedora:42" } }
"lifetime" { Invoke-QaPhase "lifetime" { Invoke-Qa "ci-qa-lifetime.sh" "alpine:3.21" } }
"browser" { Invoke-QaPhase "browser" { Invoke-BrowserQa } }
}
}
}
} catch {
if ($overallExit -eq 0) {
$overallExit = 1
$results.Add([ordered]@{ phase = "setup"; status = "FAILED"; exitCode = 1; detail = $_.Exception.Message })
}
[Console]::Error.WriteLine("FAILED: $($_.Exception.Message)")
} finally {
$expectedPhases = if ($Phase -eq "all") { @("shell", "api", "nginx", "guest", "lifetime", "browser") } else { @($Phase) }
foreach ($expected in $expectedPhases) {
if (@($results | Where-Object { $_.phase -eq $expected }).Count -eq 0) {
$results.Add([ordered]@{ phase = $expected; status = "WARNING"; exitCode = $null; detail = "not run: prerequisite failed" })
if ($overallExit -eq 0) { $overallExit = 1 }
}
}
foreach ($entry in $results) {
if ($entry.status -eq "RUNNING") {
$entry.status = "WARNING"
$entry.detail = "interrupted before phase completion"
if ($overallExit -eq 0) { $overallExit = 1 }
}
}
if ($staged) { Invoke-QaPhase "artifacts" { Save-BrowserResults } }
$runCompleted = $true
Save-Summary
Stop-Transcript | Out-Null
Write-Output "QA results: $ResultsDir"
}
exit $overallExit