From 3117b46349054b5485184d8f69ca9cd36971e487 Mon Sep 17 00:00:00 2001 From: Jan Egil Ring Date: Thu, 1 Oct 2026 05:27:10 +0000 Subject: [PATCH] feat: Implement jq bootstrap installation and validation in console automation Signed-off-by: Jan Egil Ring --- .../04-adaptive-apps/labautomation/README.md | 14 +++- .../labautomation/console-helpers.ps1 | 37 ++++++++++ .../labautomation/deploy-lab.ps1 | 1 + .../labautomation/lab-defaults.json | 2 +- .../labautomation/tests/console.Tests.ps1 | 67 +++++++++++++++++++ 5 files changed, 118 insertions(+), 3 deletions(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md index b99ef2391..4dcf51334 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md @@ -22,8 +22,13 @@ Organizers may override the ordered regions before creating lab scopes; the shared preflight must pass for that subscription and region. Every participant gets a dedicated two-node AKS cluster and a separate private K3s VM. The organizer needs a **Linux runner**, PowerShell 7, current Az.Accounts/Az.Resources/Az.Network, -Azure CLI, a Bicep compiler usable by Az deployments, Bash, curl, tar, jq, awk, -sha256sum and standard Linux utilities. The explicit +Azure CLI, a Bicep compiler usable by Az deployments, Bash, curl, tar, awk, +sha256sum and standard Linux utilities. The PowerShell hook downloads pinned +**jq 1.8.2** for Linux x64/ARM64 into its private `$HOME/.local/bin`, checks its +SHA-256 against the pinned release hash and verifies the executable before ARM +provisioning. It requires neither a system jq installation nor sudo/package-manager +access. The private tool directory is placed first on PATH for every Bash phase. +The explicit downloaded `resources/install-console-tools.sh` installs the versioned client toolchain and Bastion extension before any bootstrap phase. It does not authenticate. Outbound access to Azure, GitHub, Kubernetes/Helm/K3s release endpoints and @@ -34,6 +39,11 @@ only the required files from `microsoft/MicroHack`, using the immutable commit and per-file SHA-256 hashes in `bootstrap-source.json`. No Git installation or GitHub credential is required. +The jq bootstrap lives in the automation folder, so it also supplies the jq +prerequisite of the already-pinned Bash installer without modifying downloaded +sources or requiring a new `bootstrap-source.json` pin. A failed jq download, +hash or executable check stops before Azure provisioning and cleans up scratch. + Successful provisioning prepares **platform state through challenge 05**. Participants still complete **challenge 01's workstation setup** before connecting: diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/console-helpers.ps1 b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/console-helpers.ps1 index c2b292d45..80606ae83 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/console-helpers.ps1 +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/console-helpers.ps1 @@ -72,6 +72,43 @@ function Invoke-AdaptiveBash { } } +function Install-AdaptiveJq { + param([Parameter(Mandatory)][string]$BinDirectory) + if (-not $IsLinux) { throw 'The Console jq bootstrap requires Linux.' } + $version = '1.8.2' + switch ([Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()) { + 'X64' { + $asset = 'jq-linux-amd64' + $expectedHash = 'b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f' + } + 'Arm64' { + $asset = 'jq-linux-arm64' + $expectedHash = '8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309' + } + default { throw "Unsupported jq bootstrap architecture: $([Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)." } + } + New-Item -ItemType Directory -Path $BinDirectory -Force -ErrorAction Stop | Out-Null + $download = Join-Path $BinDirectory ".jq-$([guid]::NewGuid().ToString('N'))" + $target = Join-Path $BinDirectory 'jq' + try { + Invoke-WebRequest -Uri "https://github.com/jqlang/jq/releases/download/jq-$version/$asset" ` + -OutFile $download -TimeoutSec 60 -MaximumRetryCount 3 -RetryIntervalSec 2 -ErrorAction Stop + if ((Get-FileHash -LiteralPath $download -Algorithm SHA256 -ErrorAction Stop).Hash -ne $expectedHash) { + throw "SHA-256 verification failed for $asset (jq $version). The downloaded file will not run." + } + & chmod 700 $download + if ($LASTEXITCODE -ne 0) { throw 'Unable to make the private jq binary executable.' } + $installedVersion = & $download --version + if ($LASTEXITCODE -ne 0 -or $installedVersion -cne "jq-$version") { + throw "The verified jq $version binary failed its version check." + } + Move-Item -LiteralPath $download -Destination $target -Force -ErrorAction Stop + Write-Host "Installed verified jq $version in the private Console tool directory." + } finally { + if (Test-Path -LiteralPath $download) { Remove-Item -LiteralPath $download -Force -ErrorAction Stop } + } +} + function Get-AdaptiveBootstrapFiles { @( 'resources/install-console-tools.sh', 'resources/bootstrap-console.sh', diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/deploy-lab.ps1 b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/deploy-lab.ps1 index 57fe879f3..420f2b798 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/deploy-lab.ps1 @@ -32,6 +32,7 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Unable to secure isolated Console HOME.' } New-Item -ItemType Directory -Path $workingRoot | Out-Null Receive-AdaptiveBootstrap -Manifest $sourceManifest -Destination $workingRoot + Install-AdaptiveJq -BinDirectory (Join-Path $isolatedHome '.local/bin') Update-MhhToken | Out-Null Write-Host 'Validating participant Azure contexts and shared region metadata...' diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/lab-defaults.json b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/lab-defaults.json index 063d8cf32..e9c6f896c 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/lab-defaults.json +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/lab-defaults.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/microsoft/MicroHack/refs/heads/main/lab-defaults-schema.json", "deploymentType": "resourcegroup", "labsPerSubscription": 5, - "preferredLocation": "swedencentral,spaincentral", + "preferredLocation": "spaincentral,swedencentral", "estimatedDailyCostsUsd": 35.0, "estimatedSharedDeploymentDailyCostsUsd": 0.0 } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 index a31e3a630..69c002983 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 @@ -201,6 +201,56 @@ Describe 'Shared hook and hosted policy verification' { } } +Describe 'Private jq bootstrap' { + BeforeEach { + $script:jqBin = Join-Path $TestDrive 'bin' + $script:jqFault = '' + $script:jqAsset = if ([Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq 'Arm64') { 'jq-linux-arm64' } else { 'jq-linux-amd64' } + $script:jqHash = if ($script:jqAsset -eq 'jq-linux-arm64') { + '8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309' + } else { + 'b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f' + } + Mock Invoke-WebRequest { + $Uri | Should -Be "https://github.com/jqlang/jq/releases/download/jq-1.8.2/$script:jqAsset" + $MaximumRetryCount | Should -Be 3 + $RetryIntervalSec | Should -Be 2 + $timeout = if ($null -ne $TimeoutSec) { $TimeoutSec } else { $ConnectionTimeoutSeconds } + $timeout | Should -Be 60 + $version = if ($script:jqFault -eq 'version') { 'wrong-version' } else { 'jq-1.8.2' } + Set-Content -LiteralPath $OutFile -Encoding utf8 -Value @('#!/bin/sh', "echo $version") + if ($script:jqFault -eq 'download') { throw 'Simulated jq download failure' } + } + Mock Get-FileHash { + $Algorithm | Should -Be 'SHA256' + @{ Hash = if ($script:jqFault -eq 'hash') { '0' * 64 } else { $script:jqHash } } + } + } + AfterEach { + @(Get-ChildItem -LiteralPath $script:jqBin -Force | Where-Object Name -like '.jq-*') | Should -HaveCount 0 + } + It 'installs the verified executable into the private directory' { + Install-AdaptiveJq -BinDirectory $script:jqBin + $target = Join-Path $script:jqBin 'jq' + & $target --version | Should -Be 'jq-1.8.2' + [IO.File]::GetUnixFileMode($target) | + Should -Be ([IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute) + Should -Invoke Invoke-WebRequest -Times 1 -Exactly + } + It 'rejects failure and keeps an existing executable untouched' -ForEach @( + @{ Fault = 'download'; Error = '*Simulated jq download failure*' } + @{ Fault = 'hash'; Error = '*SHA-256 verification failed*' } + @{ Fault = 'version'; Error = '*failed its version check*' } + ) { + New-Item -ItemType Directory -Path $script:jqBin -Force | Out-Null + $target = Join-Path $script:jqBin 'jq' + Set-Content -LiteralPath $target -Value 'Existing executable' + $script:jqFault = $Fault + { Install-AdaptiveJq -BinDirectory $script:jqBin } | Should -Throw $Error + (Get-Content -LiteralPath $target -Raw).Trim() | Should -Be 'Existing executable' + } +} + Describe 'Participant hook orchestration' { BeforeEach { $script:calls = [Collections.Generic.List[string]]::new() @@ -211,6 +261,7 @@ Describe 'Participant hook orchestration' { $script:failedDownload = $false $script:tamperedDownload = $false $script:failedPhase = '' + $script:jqInstalled = $false $script:location = 'westeurope' $script:groupTags = @{ adaptiveAppsReady = 'true' } $script:fallbackLocation = 'northeurope' @@ -241,6 +292,10 @@ Describe 'Participant hook orchestration' { if ($script:tamperedDownload) { Set-Content -LiteralPath $OutFile -Value 'Unexpected content' } } Mock Update-MhhToken { $script:calls.Add('refresh') } + Mock Install-AdaptiveJq { + $BinDirectory | Should -Be (Join-Path $script:observedHome '.local/bin') + $script:jqInstalled = $true + } Mock Get-AzContext { @{ Subscription = @{ Id = $subscription } } } Mock Get-AzResourceGroup { @{ Location = $script:location; ResourceId = "/subscriptions/$subscription/resourceGroups/lab-one"; Tags = $script:groupTags } @@ -264,6 +319,7 @@ Describe 'Participant hook orchestration' { } Mock New-MhhStablePassword { 'TestOnlyStablePassword123' } Mock New-AzResourceGroupDeployment { + $script:jqInstalled | Should -BeTrue $Mode | Should -Be 'Incremental' $TemplateParameterObject | Should -BeNullOrEmpty $TemplateParameterFile | Should -Not -BeNullOrEmpty @@ -337,6 +393,7 @@ Describe 'Participant hook orchestration' { } } Mock bash { + $script:jqInstalled | Should -BeTrue Test-Path -LiteralPath $script:observedParameterFile | Should -BeFalse $script:calls[-1] | Should -Be 'refresh' $script:calls.Add("bash $args") @@ -385,6 +442,16 @@ Describe 'Participant hook orchestration' { $result.HackboxCredential.name | Should -Contain 'Adaptive Apps Connect' ($result | ConvertTo-Json -Depth 5) | Should -Not -Match 'TestOnlyStablePassword|client-key-data|token:' Should -Invoke Invoke-WebRequest -Times 20 -Exactly + Should -Invoke Install-AdaptiveJq -Times 1 -Exactly + } + It 'stops before Azure provisioning and Bash when jq bootstrap fails' { + Mock Install-AdaptiveJq { throw 'Simulated jq installation failure' } + { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | + Should -Throw '*Simulated jq installation failure*' + Should -Invoke Update-MhhToken -Times 0 + Should -Invoke New-AzResourceGroupDeployment -Times 0 + Should -Invoke Invoke-MhhDeploymentWithRegionFallback -Times 0 + Should -Invoke bash -Times 0 } It 'does not mark ready or emit credentials when Bash fails and still removes its HOME' { $script:failedPhase = 'recipes'