Files
story/scripts/smoke-firmware-rollout.ps1
T

275 lines
14 KiB
PowerShell
Raw Normal View History

2026-08-26 06:41:07 +08:00
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$api = 'http://localhost:58080'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$householdId = '10000000-0000-0000-0000-000000000211'
$deviceId = '10000000-0000-0000-0000-000000000511'
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$tempFile = Join-Path ([IO.Path]::GetTempPath()) ('firmware-smoke-' + [Guid]::NewGuid().ToString('N') + '.bin')
$downloadFile = $tempFile + '.download'
$keyIds = @()
$objectRefs = @()
function Get-AccessToken([string]$UserName) {
return (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
grant_type = 'password'
client_id = 'voice-platform-web'
username = $UserName
password = 'LocalOnly123!'
scope = 'openid'
}).access_token
}
function Get-Sha256Hex([byte[]]$Bytes) {
$sha = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
}
finally {
$sha.Dispose()
}
}
function Invoke-DeviceRequest([string]$Method, [string]$Path, [string]$Body = '') {
$bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body)
$digest = Get-Sha256Hex $bodyBytes
$timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
$nonce = [Guid]::NewGuid().ToString('N')
$canonical = "$Method`n$Path`n$timestamp`n$nonce`n$digest"
$hmac = New-Object Security.Cryptography.HMACSHA256
$hmac.Key = $deviceSecret
try {
$signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}
finally {
$hmac.Dispose()
}
$headers = @{
'X-Device-Id' = $deviceId
'X-Device-Timestamp' = $timestamp
'X-Device-Nonce' = $nonce
'X-Device-Content-SHA256' = $digest
'X-Device-Signature' = $signature
}
$arguments = @{
UseBasicParsing = $true
Method = $Method
Uri = $api + $Path
Headers = $headers
}
if ($Body) {
$arguments.ContentType = 'application/json'
$arguments.Body = $Body
}
return Invoke-WebRequest @arguments
}
function Assert-HttpStatus([scriptblock]$Action, [int]$ExpectedStatus) {
try {
$response = & $Action
if ($response.StatusCode -ne $ExpectedStatus) {
throw "Expected HTTP $ExpectedStatus but received $($response.StatusCode)"
}
}
catch {
$actualStatus = $_.Exception.Response.StatusCode.value__
if ($actualStatus -ne $ExpectedStatus) {
throw
}
}
}
function Register-SigningKey([string]$KeyId, [string]$PublicKey, [hashtable]$Headers) {
$body = @{ key_id = $KeyId; public_key_base64 = $PublicKey } | ConvertTo-Json -Compress
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-signing-keys" -Headers $Headers -ContentType 'application/json' -Body $body | Out-Null
$script:keyIds += $KeyId
}
function Prepare-Release([object]$Signed, [string]$Version, [string]$KeyId, [hashtable]$Headers, [string]$Signature) {
$body = @{
model = 'simulated-terminal'
hardware_revision = 'sim-rev-a'
version = $Version
expected_size = [int64]$Signed.size
expected_digest = $Signed.sha256
signature = $Signature
signing_key_id = $KeyId
minimum_security_version = '1.0.0'
release_notes = 'synthetic smoke firmware'
} | ConvertTo-Json -Compress
$response = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-releases" -Headers $Headers -ContentType 'application/json' -Body $body
$prepared = $response.Content | ConvertFrom-Json
if (-not $prepared.data.upload_url) {
throw 'Firmware upload session was not created'
}
$preflight = Invoke-WebRequest -UseBasicParsing -Method Options -Uri $prepared.data.upload_url -Headers @{
Origin = 'http://localhost:3100'
'Access-Control-Request-Method' = 'PUT'
'Access-Control-Request-Headers' = 'content-type'
}
if (-not $preflight.Headers['Access-Control-Allow-Origin']) {
throw 'Object storage did not allow the web origin for direct firmware upload'
}
Invoke-WebRequest -UseBasicParsing -Method Put -Uri $prepared.data.upload_url -InFile $tempFile -ContentType 'application/octet-stream' | Out-Null
return $prepared.data
}
function Finalize-Release([string]$ReleaseId, [hashtable]$Headers) {
$response = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-releases/$ReleaseId/finalize" -Headers $Headers
return ($response.Content | ConvertFrom-Json).data
}
function Assert-AssignmentProgress([string]$RolloutId, [hashtable]$Headers, [string]$ExpectedStatus, [int]$ExpectedProgress) {
$response = Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/firmware-rollouts/$RolloutId" -Headers $Headers
$detail = ($response.Content | ConvertFrom-Json).data
$assignment = $detail.assignments | Where-Object { $_.device_id -eq $deviceId } | Select-Object -First 1
if (-not $assignment) {
throw "Rollout $RolloutId did not expose the target assignment"
}
if ($assignment.status -ne $ExpectedStatus -or [int]$assignment.progress_percent -ne $ExpectedProgress) {
throw "Rollout assignment was $($assignment.status)/$($assignment.progress_percent), expected $ExpectedStatus/$ExpectedProgress"
}
}
try {
[IO.File]::WriteAllBytes($tempFile, [Text.Encoding]::UTF8.GetBytes('synthetic-firmware-' + [Guid]::NewGuid().ToString('N')))
$ownerToken = Get-AccessToken 'owner'
$editorToken = Get-AccessToken 'editor'
$ownerHeaders = @{ Authorization = "Bearer $ownerToken" }
$editorHeaders = @{ Authorization = "Bearer $editorToken" }
$newVersion = '9.9.' + (Get-Random -Minimum 1000 -Maximum 9999)
$newSigned = go run ./cmd/firmware-sign simulated-terminal sim-rev-a $newVersion $tempFile | ConvertFrom-Json
$newKeyId = 'smoke-new-' + [Guid]::NewGuid().ToString('N')
$newKeyBody = @{ key_id = $newKeyId; public_key_base64 = $newSigned.public_key_base64 } | ConvertTo-Json -Compress
Assert-HttpStatus {
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-signing-keys" -Headers $editorHeaders -ContentType 'application/json' -Body $newKeyBody
} 403
Register-SigningKey $newKeyId $newSigned.public_key_base64 $ownerHeaders
$badVersion = $newVersion + '-bad-signature'
$badRelease = Prepare-Release $newSigned $badVersion $newKeyId $ownerHeaders $newSigned.signature_base64
Assert-HttpStatus {
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-releases/$($badRelease.id)/finalize" -Headers $ownerHeaders
} 409
$newRelease = Prepare-Release $newSigned $newVersion $newKeyId $ownerHeaders $newSigned.signature_base64
$newReady = Finalize-Release $newRelease.id $ownerHeaders
if ($newReady.status -ne 'READY') {
throw 'Valid signed firmware did not become READY'
}
$releaseList = (Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/households/$householdId/firmware-releases" -Headers $ownerHeaders).Content | ConvertFrom-Json
if (-not ($releaseList.data.items | Where-Object { $_.id -eq $newRelease.id -and $_.status -eq 'READY' })) {
throw 'Firmware release list did not expose the verified release'
}
$oldVersion = '9.8.' + (Get-Random -Minimum 1000 -Maximum 9999)
$oldSigned = go run ./cmd/firmware-sign simulated-terminal sim-rev-a $oldVersion $tempFile | ConvertFrom-Json
$oldKeyId = 'smoke-old-' + [Guid]::NewGuid().ToString('N')
Register-SigningKey $oldKeyId $oldSigned.public_key_base64 $ownerHeaders
$oldRelease = Prepare-Release $oldSigned $oldVersion $oldKeyId $ownerHeaders $oldSigned.signature_base64
$oldReady = Finalize-Release $oldRelease.id $ownerHeaders
if ($oldReady.status -ne 'READY') {
throw 'Rollback firmware did not become READY'
}
$rolloutBody = @{
release_id = $newRelease.id
name = 'synthetic canary'
target_percentage = 100
device_ids = @($deviceId)
} | ConvertTo-Json -Compress
$rolloutResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-rollouts" -Headers $ownerHeaders -ContentType 'application/json' -Body $rolloutBody
$rollout = ($rolloutResponse.Content | ConvertFrom-Json).data
$rolloutList = (Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/households/$householdId/firmware-rollouts" -Headers $ownerHeaders).Content | ConvertFrom-Json
if (-not ($rolloutList.data.items | Where-Object { $_.id -eq $rollout.id -and $_.status -eq 'RUNNING' })) {
throw 'Firmware rollout list did not expose the running rollout'
}
$pausedResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-rollouts/$($rollout.id)/pause" -Headers $ownerHeaders
$paused = ($pausedResponse.Content | ConvertFrom-Json).data
if ($paused.status -ne 'PAUSED') {
throw 'Firmware rollout did not pause'
}
$cancelledState = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT dc.status FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE ro.public_id='$($rollout.id)'::uuid" | Out-String).Trim()
if ($cancelledState -ne 'CANCELLED') {
throw "Queued firmware command was not cancelled: $cancelledState"
}
Assert-HttpStatus {
Invoke-DeviceRequest 'GET' "/device/v2/firmware-releases/$($newRelease.id)/download"
} 403
$rollbackBody = @{ rollback_release_id = $oldRelease.id } | ConvertTo-Json -Compress
$rollbackResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-rollouts/$($rollout.id)/rollback" -Headers $ownerHeaders -ContentType 'application/json' -Body $rollbackBody
$rollback = ($rollbackResponse.Content | ConvertFrom-Json).data
$pulledResponse = Invoke-DeviceRequest 'GET' '/device/v2/commands?limit=20'
$pulled = $pulledResponse.Content | ConvertFrom-Json
$command = $pulled.data.items | Where-Object { $_.type -eq 'firmware_update' -and $_.payload.release_id -eq $oldRelease.id }
if (-not $command) {
throw 'Device did not receive the rollback firmware command'
}
$downloadResponse = Invoke-DeviceRequest 'GET' "/device/v2/firmware-releases/$($oldRelease.id)/download"
$download = $downloadResponse.Content | ConvertFrom-Json
Invoke-WebRequest -UseBasicParsing -Uri $download.data.download_url -OutFile $downloadFile
$actualDigest = (Get-FileHash -LiteralPath $downloadFile -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualDigest -ne $oldSigned.sha256) {
throw 'Downloaded rollback firmware digest does not match'
}
$downloadAck = @{ status = 'ACKNOWLEDGED'; result_summary = @{ phase = 'downloading'; progress_percent = 25 } } | ConvertTo-Json -Compress
Invoke-DeviceRequest 'POST' "/device/v2/commands/$($command.id)/ack" $downloadAck | Out-Null
Assert-AssignmentProgress $rollback.id $ownerHeaders 'DOWNLOADING' 25
$installAck = @{ status = 'ACKNOWLEDGED'; result_summary = @{ phase = 'installing'; progress_percent = 80 } } | ConvertTo-Json -Compress
Invoke-DeviceRequest 'POST' "/device/v2/commands/$($command.id)/ack" $installAck | Out-Null
Assert-AssignmentProgress $rollback.id $ownerHeaders 'INSTALLING' 80
$ackBody = @{ status = 'SUCCEEDED'; result_summary = @{ installed_version = $oldVersion } } | ConvertTo-Json -Compress
Invoke-DeviceRequest 'POST' "/device/v2/commands/$($command.id)/ack" $ackBody | Out-Null
Assert-AssignmentProgress $rollback.id $ownerHeaders 'CONFIRMED' 100
Start-Sleep -Milliseconds 300
$state = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT ro.status||'|'||fa.status FROM firmware_rollout ro JOIN firmware_assignment fa ON fa.rollout_id=ro.id WHERE ro.public_id='$($rollback.id)'::uuid" | Out-String).Trim()
if ($state -ne 'COMPLETED|CONFIRMED') {
throw "Rollback rollout did not converge: $state"
}
Write-Host "Firmware signature, rejection, pause, and rollback smoke passed: $($rollout.id) -> $($rollback.id)"
}
finally {
if ($keyIds.Count -gt 0) {
$keyList = ($keyIds | ForEach-Object { "'$_'" }) -join ','
$refs = docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT object_ref FROM firmware_release WHERE signing_key_id IN ($keyList)" | Out-String
$objectRefs = $refs -split "`r?`n" | Where-Object { $_.Trim() }
$sql = @"
BEGIN;
CREATE TEMP TABLE smoke_releases AS SELECT id,public_id FROM firmware_release WHERE signing_key_id IN($keyList);
CREATE TEMP TABLE smoke_rollouts AS SELECT id,public_id FROM firmware_rollout WHERE release_id IN(SELECT id FROM smoke_releases);
CREATE TEMP TABLE smoke_commands AS SELECT dc.id,dc.public_id FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id WHERE fa.rollout_id IN(SELECT id FROM smoke_rollouts);
DELETE FROM audit_event WHERE resource_id IN(SELECT public_id FROM smoke_releases UNION SELECT public_id FROM smoke_rollouts UNION SELECT public_id FROM smoke_commands);
DELETE FROM web_event WHERE resource_public_id IN(SELECT public_id FROM smoke_commands);
DELETE FROM firmware_assignment WHERE rollout_id IN(SELECT id FROM smoke_rollouts);
DELETE FROM firmware_rollout WHERE id IN(SELECT id FROM smoke_rollouts);
DELETE FROM device_command WHERE id IN(SELECT id FROM smoke_commands);
DELETE FROM firmware_release WHERE id IN(SELECT id FROM smoke_releases);
DELETE FROM firmware_signing_key WHERE key_id IN($keyList);
COMMIT;
"@
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $sql | Out-Null
}
if ($objectRefs.Count -gt 0) {
docker compose exec -T minio mc alias set smoke http://localhost:9000 local_minio local_minio_password | Out-Null
foreach ($objectRef in $objectRefs) {
docker compose exec -T minio mc rm --force "smoke/private-exports/$($objectRef.Trim())" | Out-Null
}
}
if (Test-Path -LiteralPath $downloadFile) {
Remove-Item -LiteralPath $downloadFile -Force
}
if (Test-Path -LiteralPath $tempFile) {
Remove-Item -LiteralPath $tempFile -Force
}
}