chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
$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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user