From b8a1317205f6ba18e34b417148dac720941f3db5 Mon Sep 17 00:00:00 2001 From: Jan Egil Ring Date: Wed, 30 Sep 2026 18:04:44 +0000 Subject: [PATCH] feat: Update README and scripts for Adaptive Apps lab automation - Increased the default number of labs per subscription from 2 to 5 in the README. - Enhanced the bootstrap source management by adding a new `bootstrap-source.json` file to track additional scripts and their hashes. - Refactored `console-helpers.ps1` to include functions for downloading and verifying bootstrap files. - Modified `deploy-lab.ps1` to utilize the new bootstrap management functions, ensuring proper file downloads and hash verification before provisioning. - Updated tests in `console.Tests.ps1` to cover new download and verification logic, including handling download failures and hash mismatches. - Added a new script `update-bootstrap-source.ps1` to pin published workshop sources and check local sources against the pin. Signed-off-by: Jan Egil Ring --- .../04-adaptive-apps/labautomation/README.md | 60 ++++-- .../labautomation/bootstrap-source.json | 25 +++ .../labautomation/console-helpers.ps1 | 70 ++++++- .../labautomation/deploy-lab.ps1 | 173 +++++++++--------- .../labautomation/tests/console.Tests.ps1 | 93 ++++++++-- .../labautomation/update-bootstrap-source.ps1 | 48 +++++ 6 files changed, 340 insertions(+), 129 deletions(-) create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/bootstrap-source.json create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/update-bootstrap-source.ps1 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 c97d4bd69..00baba9b1 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 @@ -16,19 +16,22 @@ region, and participant object IDs. The participant already has Owner on that group. There is no interactive login, Graph application/service-principal creation, confidential computing, Azure Local, or shared participant Kubernetes cluster here. -Default scope: **resourcegroup**, **two labs per subscription**, in **Sweden Central** +Default scope: **resourcegroup**, **five labs per subscription**, in **Sweden Central** (`swedencentral`). Organizers may override the event region 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 -`../resources/install-console-tools.sh` installs the versioned client toolchain +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 -container registries is required. Package the **whole MicroHack**, including -`resources/`, `iac/` (including `iac/bicepconfig.json` and its referenced files), -not only this folder. +container registries is required, including HTTPS access to +`raw.githubusercontent.com`. Console can publish this automation folder as its +`lab/` folder without sibling workshop source directories. The hook downloads +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. Successful provisioning prepares **platform state through challenge 05**. Participants still complete **challenge 01's workstation setup** before connecting: @@ -65,8 +68,8 @@ prerequisites. No GitHub or AI API credentials are provisioned by these hooks. without a **Location** restriction, and for available family and total regional compute quota. A Zone restriction does not exclude this non-zonal topology. 4. Require **16 free vCPUs per lab**: 8 for the AKS nodes, 4 for the K3s VM and - 4 for one AKS upgrade surge node. Two new labs therefore require **32 free - vCPUs**, although their steady-state baseline is 24. Both `standardDSv5Family` + 4 for one AKS upgrade surge node. Five new labs therefore require **80 free + vCPUs**, although their steady-state baseline is 60. Both `standardDSv5Family` and `cores` are checked after subtracting existing usage. The shared check intentionally budgets the entire requested fan-out as new capacity, including on reruns; arrange sufficient free quota before repeating it. It does not @@ -128,13 +131,15 @@ Console mounts the source content **read-only**. The hook uses a private, GUID-named HOME under the worker's writable `[IO.Path]::GetTempPath()` for `.kube`, `.rad`, `.ssh`, downloaded clients and scratch files; it never creates directories under the mounted source. The platform's **absolute, -isolated `AZURE_CONFIG_DIR` is preserved**. The complete `resources/` and `iac/` -directories (including `iac/bicepconfig.json` and hidden files) are copied into -a private working lab root beneath that HOME. All Bash phases run from this copy, -whose owner-write permissions are enabled without modifying the read-only source, -so generated `artifacts/types.tgz` and recipe artifacts cannot race with another -participant or modify the source checkout. Existing source `artifacts/` are not -copied. There is no root-level Bicep configuration in the package. +isolated `AZURE_CONFIG_DIR` is preserved**. Before participant provisioning, the +hook downloads the allowlisted `resources/` and `iac/` files into a private working +lab root beneath that HOME and verifies every hash. Downloads have bounded +timeouts and retries. A missing file, network failure or hash mismatch stops the +hook before ARM deployment or Bash execution; it never falls back to `main`. +All six phases reuse the verified files. Generated `artifacts/types.tgz` and +recipe artifacts cannot race with another participant or modify the source +checkout. No credentials or local configuration are downloaded, and all temporary +files are removed on success or failure. There is no root-level Bicep configuration. A host-assigned ephemeral loopback port is passed as `K3S_LOCAL_PORT`; tunnel startup still detects a port collision and fails rather than taking another lab's tunnel. HOME/PATH/environment are restored @@ -166,7 +171,7 @@ bearer token, registry password or Kubernetes private key is emitted. ## Rough costs and cleanup -Budget **US$35 per lab per 24 hours**, or about **US$70/day for the default two +Budget **US$35 per lab per 24 hours**, or about **US$175/day for the default five labs**, before optional exercises and significant traffic. This is an approximate pay-as-you-go planning allowance, not a quote: @@ -190,6 +195,28 @@ remove them when releasing a dedicated subscription, but must not remove an initiative still used by another hosted event. No cleanup hook deletes resources automatically on deployment failure. +## Maintaining the bootstrap source pin + +The workshop's `resources/` and `iac/` are the only editable source copies. +`bootstrap-source.json` contains only a commit identifier and file hashes, not +duplicate code. When bootstrap inputs change, publish them to `microsoft/MicroHack` +first, then update the pin to that published commit: + +```powershell +pwsh -File "./03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/update-bootstrap-source.ps1" -Commit "" +``` + +The updater downloads that revision and verifies it matches the local sources +before writing the manifest. Use `-Check` instead of `-Commit` for an offline +local-source/hash check. Tests reject a stale pin, so source edits deliberately +require refreshing the pin before publishing the automation update. + +This is a two-step release when source changes are not yet published: first +publish the canonical inputs, then publish the verified manifest update. If an +input file is added, update the allowlist in `Get-AdaptiveBootstrapFiles` as well. +After publishing the automation update, refresh/reimport the content in Console +before retrying. A pin update is unnecessary for documentation-only changes. + ## Offline tests and live acceptance With PowerShell 7, Pester 5 and Bicep installed, from the repository root: @@ -203,7 +230,8 @@ tools outside PATH. The runner confines Pester scratch files to a unique directo under the current checkout and removes it on exit. Tests mock Azure and Console commands; they cover quota boundaries, PostgreSQL capability schemas, failure propagation, packaging, region consistency, token refresh, phase ordering, output -hygiene, read-only source compatibility, private-directory cleanup, and compiled +hygiene, a read-only lab-only Console layout with no sibling sources, immutable +source pins, hash mismatches, download failures, private-directory cleanup, and compiled ARM resource properties. Only the local test runner puts its test scratch beneath a writable checkout; the production hook uses writable worker scratch instead. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/bootstrap-source.json b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/bootstrap-source.json new file mode 100644 index 000000000..ed4d07181 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/bootstrap-source.json @@ -0,0 +1,25 @@ +{ + "commit": "98b452ef419fcb1f94c78c2a6d18b125004e9bea", + "files": { + "resources/install-console-tools.sh": "dd0a4e8fd762d469e74797086164ba20ac76af5bb216bbbabdd2aefae7a61334", + "resources/bootstrap-console.sh": "3785f5120af2a817485e4930e476023b41ae874bfa70438b70d43a5a775b809c", + "resources/azure-session.sh": "31cf81870be4d08cb73da18929e49dbce1543fbd31f198b17ee281ba40bb8eca", + "resources/console-session.sh": "6a5af1db76fdb611882caa057d3e0d5abd3a443d5d70eaf93e7dc11a5a3a427c", + "resources/verify-recipes.sh": "cd0bd7a4d08473be9b2c71b94973c4eda4dc64c5477343a003e0cd83b4ff6210", + "resources/connect-console.sh": "2dca1544de5726dc4ff1d636566ad6f3ccf8ee1beff919532a32c0a55649c246", + "resources/prepare-k3s-azure-vm.sh": "ac17a02d6aa15ad99d086580afd3c3bd0da0fe86b2f3bc64e51ae4f2e27fe949", + "resources/deploy-radius-aks.sh": "57b85557d839702e4222f67c62b25cfb52846d64e9f3a58e7d52eea23d209f63", + "resources/deploy-radius-k3s.sh": "2abe585c081466b40b4c5934aa5401086d486588aeb94f5df9a53b16dedcff96", + "resources/configure-resource-types-aks.sh": "880fdc356ee9c253bea6a2c27a9f33e5c1aae01724cdad4cbc08dc0ce3a6a274", + "resources/configure-resource-types-k3s.sh": "0135477202c347c284f9978556297eb13d7bb99d2ebbe1d24d6fb86e3bd9305c", + "resources/configure-recipes.sh": "d1591583364163722a6fa9c076be1c0ecbf104bc19cd00188bc38f5b00abcaf2", + "iac/bicepconfig.json": "3f75d2cfc9f3938dad9f0db5e90d62332aab5861e3f13326fcb32c1f626051ee", + "iac/aks-env.bicep": "7fb58291e1380bcf4a8e1a889b1b3c7b0eb8749da98f764a6f7f42edfcb12f80", + "iac/local-env.bicep": "7ea33c8764e6e2d1d6475745b9b8c0fb5af94ef8c47ce90f846fe2c117f02daa", + "iac/sql-databases.yaml": "5b6bdbaa193f407541e81a7b148ef9b4c4886b7f5bf75256d1eb642d4a6ddf66", + "iac/recipes/postgres-azure-flex.bicep": "d4ab3daa3d32e41bd1403aea8425d938ceda0575d63227977272ade389819d73", + "iac/recipes/postgres-kubernetes.bicep": "4a4fd0991bcf5ea99e8a4e5d5922fea15d4afcd166879db00971eaf0c7d3477f", + "iac/recipes/sql-server.bicep": "08792c840a9dd237273a11124dcece1759c8d0dbdce7c284f63dfe4f6ec9e31c", + "iac/recipes/trading-schema.sql": "b9d3fa06d9c973daf6bb268effa0e94d43ad812b830a461932fa24e6817477b4" + } +} 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 049390349..c2b292d45 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,9 +72,8 @@ function Invoke-AdaptiveBash { } } -function Assert-AdaptivePackage { - param([Parameter(Mandatory)][string]$LabRoot) - foreach ($path in @( +function Get-AdaptiveBootstrapFiles { + @( 'resources/install-console-tools.sh', 'resources/bootstrap-console.sh', 'resources/azure-session.sh', 'resources/console-session.sh', 'resources/verify-recipes.sh', 'resources/connect-console.sh', 'resources/prepare-k3s-azure-vm.sh', @@ -84,9 +83,70 @@ function Assert-AdaptivePackage { 'iac/aks-env.bicep', 'iac/local-env.bicep', 'iac/sql-databases.yaml', 'iac/recipes/postgres-azure-flex.bicep', 'iac/recipes/postgres-kubernetes.bicep', 'iac/recipes/sql-server.bicep', 'iac/recipes/trading-schema.sql' - )) { + ) +} + +function Assert-AdaptivePackage { + param([Parameter(Mandatory)][string]$LabRoot) + foreach ($path in Get-AdaptiveBootstrapFiles) { if (-not (Test-Path -LiteralPath (Join-Path $LabRoot $path))) { - throw "Incomplete Adaptive Apps package: missing '$path'. Package the entire MicroHack, including resources/ and iac/, not just labautomation/." + throw "Incomplete Adaptive Apps sources: missing '$path'. Verify the pinned source revision and refresh the Console content before retrying." + } + } +} + +function Read-AdaptiveSourceManifest { + param([Parameter(Mandatory)][string]$Path) + $manifest = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop + if ($manifest.commit -isnot [string] -or $manifest.commit -cnotmatch '^[0-9a-f]{40}$') { + throw 'Bootstrap source must be pinned to a full, lowercase Git commit SHA; branches and tags are not accepted.' + } + $paths = @(Get-AdaptiveBootstrapFiles) + if ($manifest.files -isnot [Collections.IDictionary] -or $manifest.files.Count -ne $paths.Count) { + throw 'Bootstrap source manifest must contain exactly the required file allowlist.' + } + foreach ($path in $paths) { + if ($manifest.files[$path] -isnot [string] -or $manifest.files[$path] -notmatch '^[0-9a-f]{64}$') { + throw "Bootstrap source manifest is missing a valid SHA-256 hash for '$path'." + } + } + return $manifest +} + +function Save-AdaptiveSourceFile { + param( + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$Commit, + [Parameter(Mandatory)][string]$RelativePath, + [Parameter(Mandatory)][string]$Destination + ) + if ($RelativePath -cnotin @(Get-AdaptiveBootstrapFiles)) { + throw "Bootstrap download path '$RelativePath' is not allowlisted." + } + $repositoryPath = "03-Azure/01-01-App Innovation/04-adaptive-apps/$RelativePath" + $encodedPath = ($repositoryPath.Split('/') | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/' + $uri = "https://raw.githubusercontent.com/microsoft/MicroHack/$Commit/$encodedPath" + New-Item -ItemType Directory -Path (Split-Path $Destination -Parent) -Force -ErrorAction Stop | Out-Null + try { + Invoke-WebRequest -Uri $uri -OutFile $Destination -TimeoutSec 60 ` + -MaximumRetryCount 3 -RetryIntervalSec 2 -ErrorAction Stop + } catch { + throw "Unable to download '$RelativePath' at commit '$Commit': $($_.Exception.Message)" + } +} + +function Receive-AdaptiveBootstrap { + param( + [Parameter(Mandatory)][hashtable]$Manifest, + [Parameter(Mandatory)][string]$Destination + ) + foreach ($path in Get-AdaptiveBootstrapFiles) { + $target = Join-Path $Destination $path + Save-AdaptiveSourceFile -Commit $Manifest.commit -RelativePath $path -Destination $target + $hash = (Get-FileHash -LiteralPath $target -Algorithm SHA256 -ErrorAction Stop).Hash + if ($hash -ne $Manifest.files[$path]) { + throw "SHA-256 verification failed for bootstrap file '$path' at commit '$($Manifest.commit)'. No bootstrap commands will run." } } + Assert-AdaptivePackage -LabRoot $Destination + Write-Host "Verified all $($Manifest.files.Count) bootstrap files from microsoft/MicroHack commit $($Manifest.commit)." } 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 64169763a..3a76a9960 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 @@ -18,104 +18,101 @@ if ($DeploymentType -ne 'resourcegroup' -or -not $ResourceGroupName) { throw 'This hook requires a Console-provisioned resourcegroup scope.' } if ($AllowedEntraUserIds.Count -eq 0) { throw 'Console must supply participant object IDs for node resource group Reader access.' } -$labRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -Assert-AdaptivePackage -LabRoot $labRoot +$sourceManifest = Read-AdaptiveSourceManifest -Path (Join-Path $PSScriptRoot 'bootstrap-source.json') if (-not $env:AZURE_CONFIG_DIR -or -not [IO.Path]::IsPathRooted($env:AZURE_CONFIG_DIR)) { throw 'Console must supply an absolute, isolated AZURE_CONFIG_DIR before HOME can be isolated.' } -Update-MhhToken | Out-Null -Assert-AdaptiveContext -SubscriptionId $SubscriptionId -$group = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction Stop -$region = $group.Location.ToLowerInvariant() -$metadata = (Get-AzTag -ResourceId "/subscriptions/$SubscriptionId" -ErrorAction Stop).Properties.TagsProperty -if (-not $metadata -or $region -notin ($metadata['microhack-adaptive-regions'] -split ',')) { - throw "RG location '$region' was not validated by shared-deploy-lab.ps1. Run the shared hook for this region; do not relocate resources independently of their RG." -} -Update-AzTag -ResourceId $group.ResourceId -Operation Merge -Tag @{ - adaptiveAppsReady = 'false' - SecurityControl = 'Ignore' - CostControl = 'Ignore' -} -ErrorAction Stop | Out-Null - -$password = New-MhhStablePassword -Purpose 'adaptive-apps-k3s-admin' -Length 24 -$deployment = New-AzResourceGroupDeployment -Name 'adaptive-apps-console' ` - -ResourceGroupName $ResourceGroupName -TemplateFile (Join-Path $PSScriptRoot 'main.bicep') ` - -TemplateParameterObject @{ - location = $region - adminPassword = (ConvertTo-SecureString $password -AsPlainText -Force) - } -Mode Incremental -ErrorAction Stop -if ($deployment.ProvisioningState -ne 'Succeeded') { - throw "Infrastructure deployment failed: $($deployment.ProvisioningState). Resources are retained for diagnosis; no destructive fallback is attempted." -} -$acrName = $deployment.Outputs.acrName.Value -$nodeGroup = $deployment.Outputs.nodeResourceGroup.Value -if (-not $acrName -or -not $nodeGroup) { throw 'Infrastructure deployment omitted ACR or AKS node resource group outputs.' } -Update-MhhToken | Out-Null -$nodeScope = "/subscriptions/$SubscriptionId/resourceGroups/$nodeGroup" -$readerId = 'acdd72a7-3385-48ef-bd42-f606fba81ae7' -foreach ($objectId in ($AllowedEntraUserIds | Select-Object -Unique)) { - # --assignee-object-id and --assignee-principal-type avoid all Microsoft Graph lookups. - $assignments = @(Invoke-AdaptiveAz -Arguments @('role', 'assignment', 'list', '--scope', $nodeScope, '--all', '--fill-principal-name', 'false')) - $exists = $assignments | Where-Object { - $_.principalId -eq $objectId -and $_.roleDefinitionId -like "*/$readerId" -and $_.scope -ieq $nodeScope - } - if (-not $exists) { - Invoke-AdaptiveAz -Arguments @('role', 'assignment', 'create', '--assignee-object-id', $objectId, - '--assignee-principal-type', 'User', '--role', $readerId, '--scope', $nodeScope) | Out-Null - } -} -Update-MhhToken | Out-Null -$guest = Invoke-AdaptiveAz -Arguments @('vm', 'run-command', 'invoke', '--resource-group', $ResourceGroupName, - '--name', 'vm-adaptive-apps-k3s', '--command-id', 'RunShellScript', - '--scripts', "@$(Join-Path $PSScriptRoot 'install-k3s.sh')") -if (($guest.value.message -join "`n") -notmatch '(?m)^ADAPTIVE_K3S_READY\r?$') { - throw 'K3s Run Command did not return its readiness marker. Inspect VM Run Command and NAT outbound; credentials were not emitted.' -} -Update-AzTag -ResourceId $group.ResourceId -Operation Merge -Tag @{ - adaptiveAppsAcr = $acrName -} -ErrorAction Stop | Out-Null - -$environment = @{ - AZURE_SUBSCRIPTION = $SubscriptionId - RESOURCE_GROUP = $ResourceGroupName - REGION = $region - AZURE_LOCATION = $region - ACR_NAME = $acrName - AKS_CLUSTER = 'aks-adaptive-apps' - AKS_CLUSTER_NAME = 'aks-adaptive-apps' - AKS_CONTEXT = 'aks-adaptive-apps' - K3S_VM_NAME = 'vm-adaptive-apps-k3s' - K3S_CONTEXT = 'k3s-azure-vm' - RADIUS_GROUP = 'rg-trading' - BASTION_NAME = 'bas-adaptive-apps' - KUBECONFIG = $null - RAD_WORKSPACE = $null -} $saved = @{} $isolatedHome = Join-Path ([IO.Path]::GetTempPath()) "adaptive-console-$([guid]::NewGuid().ToString('N'))" $workingRoot = Join-Path $isolatedHome 'work' -$environment.HOME = $isolatedHome -$environment.PATH = "$(Join-Path $isolatedHome '.local/bin'):$env:PATH" -$environment.TMPDIR = Join-Path $isolatedHome 'scratch' -$environment.K3S_KUBECONFIG = Join-Path $isolatedHome '.kube/adaptive-apps-k3s.yaml' -$environment.K3S_TUNNEL_STATE_DIR = Join-Path $isolatedHome '.kube/adaptive-apps-bastion' -$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) try { - $listener.Start() - $environment.K3S_LOCAL_PORT = "$($listener.LocalEndpoint.Port)" -} finally { - $listener.Stop() -} -try { - New-Item -ItemType Directory -Path $environment.TMPDIR -Force | Out-Null - & chmod 700 $isolatedHome $environment.TMPDIR + $scratch = Join-Path $isolatedHome 'scratch' + New-Item -ItemType Directory -Path $scratch -Force | Out-Null + & chmod 700 $isolatedHome $scratch if ($LASTEXITCODE -ne 0) { throw 'Unable to secure isolated Console HOME.' } New-Item -ItemType Directory -Path $workingRoot | Out-Null - foreach ($directory in @('resources', 'iac')) { - Copy-Item -LiteralPath (Join-Path $labRoot $directory) -Destination $workingRoot -Recurse -Force + Receive-AdaptiveBootstrap -Manifest $sourceManifest -Destination $workingRoot + + Update-MhhToken | Out-Null + Assert-AdaptiveContext -SubscriptionId $SubscriptionId + $group = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction Stop + $region = $group.Location.ToLowerInvariant() + $metadata = (Get-AzTag -ResourceId "/subscriptions/$SubscriptionId" -ErrorAction Stop).Properties.TagsProperty + if (-not $metadata -or $region -notin ($metadata['microhack-adaptive-regions'] -split ',')) { + throw "RG location '$region' was not validated by shared-deploy-lab.ps1. Run the shared hook for this region; do not relocate resources independently of their RG." + } + Update-AzTag -ResourceId $group.ResourceId -Operation Merge -Tag @{ + adaptiveAppsReady = 'false' + SecurityControl = 'Ignore' + CostControl = 'Ignore' + } -ErrorAction Stop | Out-Null + + $password = New-MhhStablePassword -Purpose 'adaptive-apps-k3s-admin' -Length 24 + $deployment = New-AzResourceGroupDeployment -Name 'adaptive-apps-console' ` + -ResourceGroupName $ResourceGroupName -TemplateFile (Join-Path $PSScriptRoot 'main.bicep') ` + -TemplateParameterObject @{ + location = $region + adminPassword = (ConvertTo-SecureString $password -AsPlainText -Force) + } -Mode Incremental -ErrorAction Stop + if ($deployment.ProvisioningState -ne 'Succeeded') { + throw "Infrastructure deployment failed: $($deployment.ProvisioningState). Resources are retained for diagnosis; no destructive fallback is attempted." + } + $acrName = $deployment.Outputs.acrName.Value + $nodeGroup = $deployment.Outputs.nodeResourceGroup.Value + if (-not $acrName -or -not $nodeGroup) { throw 'Infrastructure deployment omitted ACR or AKS node resource group outputs.' } + Update-MhhToken | Out-Null + $nodeScope = "/subscriptions/$SubscriptionId/resourceGroups/$nodeGroup" + $readerId = 'acdd72a7-3385-48ef-bd42-f606fba81ae7' + foreach ($objectId in ($AllowedEntraUserIds | Select-Object -Unique)) { + # --assignee-object-id and --assignee-principal-type avoid all Microsoft Graph lookups. + $assignments = @(Invoke-AdaptiveAz -Arguments @('role', 'assignment', 'list', '--scope', $nodeScope, '--all', '--fill-principal-name', 'false')) + $exists = $assignments | Where-Object { + $_.principalId -eq $objectId -and $_.roleDefinitionId -like "*/$readerId" -and $_.scope -ieq $nodeScope + } + if (-not $exists) { + Invoke-AdaptiveAz -Arguments @('role', 'assignment', 'create', '--assignee-object-id', $objectId, + '--assignee-principal-type', 'User', '--role', $readerId, '--scope', $nodeScope) | Out-Null + } + } + Update-MhhToken | Out-Null + $guest = Invoke-AdaptiveAz -Arguments @('vm', 'run-command', 'invoke', '--resource-group', $ResourceGroupName, + '--name', 'vm-adaptive-apps-k3s', '--command-id', 'RunShellScript', + '--scripts', "@$(Join-Path $PSScriptRoot 'install-k3s.sh')") + if (($guest.value.message -join "`n") -notmatch '(?m)^ADAPTIVE_K3S_READY\r?$') { + throw 'K3s Run Command did not return its readiness marker. Inspect VM Run Command and NAT outbound; credentials were not emitted.' + } + Update-AzTag -ResourceId $group.ResourceId -Operation Merge -Tag @{ + adaptiveAppsAcr = $acrName + } -ErrorAction Stop | Out-Null + + $environment = @{ + AZURE_SUBSCRIPTION = $SubscriptionId + RESOURCE_GROUP = $ResourceGroupName + REGION = $region + AZURE_LOCATION = $region + ACR_NAME = $acrName + AKS_CLUSTER = 'aks-adaptive-apps' + AKS_CLUSTER_NAME = 'aks-adaptive-apps' + AKS_CONTEXT = 'aks-adaptive-apps' + K3S_VM_NAME = 'vm-adaptive-apps-k3s' + K3S_CONTEXT = 'k3s-azure-vm' + RADIUS_GROUP = 'rg-trading' + BASTION_NAME = 'bas-adaptive-apps' + KUBECONFIG = $null + RAD_WORKSPACE = $null + HOME = $isolatedHome + PATH = "$(Join-Path $isolatedHome '.local/bin'):$env:PATH" + TMPDIR = $scratch + K3S_KUBECONFIG = Join-Path $isolatedHome '.kube/adaptive-apps-k3s.yaml' + K3S_TUNNEL_STATE_DIR = Join-Path $isolatedHome '.kube/adaptive-apps-bastion' + } + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + $environment.K3S_LOCAL_PORT = "$($listener.LocalEndpoint.Port)" + } finally { + $listener.Stop() } - & chmod -R u+rwX $workingRoot - if ($LASTEXITCODE -ne 0) { throw 'Unable to make the private Console working copy writable.' } foreach ($key in $environment.Keys) { $saved[$key] = [Environment]::GetEnvironmentVariable($key) [Environment]::SetEnvironmentVariable($key, $environment[$key]) 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 6ae084bf5..fdbf67ab5 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 @@ -157,6 +157,9 @@ Describe 'Participant hook orchestration' { $script:observedHome = '' $script:observedWorkingRoot = '' $script:sourceRoot = Split-Path $automation -Parent + $script:downloadRoot = '' + $script:failedDownload = $false + $script:tamperedDownload = $false $script:failedPhase = '' $script:location = 'westeurope' $script:guestMarker = 'ADAPTIVE_K3S_READY' @@ -166,6 +169,22 @@ Describe 'Participant hook orchestration' { $script:originalHome = $env:HOME $script:originalPath = $env:PATH $env:AZURE_CONFIG_DIR = Join-Path $TestDrive 'platform-azure' + Mock Invoke-WebRequest { + $MaximumRetryCount | Should -Be 3 + $timeout = if ($null -ne $TimeoutSec) { $TimeoutSec } else { $ConnectionTimeoutSeconds } + $timeout | Should -Be 60 + $address = [Uri]$Uri + $address.Host | Should -Be 'raw.githubusercontent.com' + $address.AbsolutePath | Should -Match '^/microsoft/MicroHack/[0-9a-f]{40}/03-Azure/01-01-App%20Innovation/04-adaptive-apps/' + if (-not $script:downloadRoot) { + $script:downloadRoot = Split-Path (Split-Path $OutFile -Parent) -Parent + $script:observedHome = Split-Path $script:downloadRoot -Parent + } + if ($script:failedDownload) { throw 'Simulated download failure' } + $relative = [Uri]::UnescapeDataString(($address.AbsolutePath -replace '^.*/04-adaptive-apps/', '')) + Copy-Item -LiteralPath (Join-Path $script:sourceRoot $relative) -Destination $OutFile + if ($script:tamperedDownload) { Set-Content -LiteralPath $OutFile -Value 'Unexpected content' } + } Mock Update-MhhToken { $script:calls.Add('refresh') } Mock Get-AzContext { @{ Subscription = @{ Id = $subscription } } } Mock Get-AzResourceGroup { @@ -186,8 +205,6 @@ Describe 'Participant hook orchestration' { } } } - # Packaging is tested separately; no dependency on work-in-progress parent Bash files. - Mock Test-Path { $true } -ParameterFilter { $LiteralPath -match '/(resources|iac)(/|$)' } Mock az { $global:LASTEXITCODE = 0 $script:calls.Add("az $args") @@ -254,26 +271,28 @@ Describe 'Participant hook orchestration' { $script:tagWrites[-1].adaptiveAppsReady | Should -Be 'true' $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 } It 'does not mark ready or emit credentials when Bash fails and still removes its HOME' { $script:failedPhase = 'recipes' { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | Should -Throw '*exit code 13*' @($script:tagWrites | Where-Object { $_.adaptiveAppsReady -eq 'true' }) | Should -HaveCount 0 } - It 'supports a read-only source package without creating or modifying source files' { + It 'deploys from only a read-only Console lab folder without sibling workshop sources' { $readOnlyRoot = Join-Path $TestDrive 'read-only-source' New-Item -ItemType Directory -Path $readOnlyRoot | Out-Null - foreach ($directory in @('resources', 'iac', 'labautomation')) { - Copy-Item -LiteralPath (Join-Path $script:sourceRoot $directory) -Destination $readOnlyRoot -Recurse -Force - } - $script:sourceRoot = $readOnlyRoot + $consoleLab = Join-Path $readOnlyRoot 'lab' + Copy-Item -LiteralPath $automation -Destination $consoleLab -Recurse -Force + Test-Path (Join-Path $readOnlyRoot 'resources') | Should -BeFalse + Test-Path (Join-Path $readOnlyRoot 'iac') | Should -BeFalse + Test-Path (Join-Path $consoleLab 'bootstrap') | Should -BeFalse $before = @(Get-ChildItem $readOnlyRoot -Recurse -Force -File | Sort-Object FullName | Get-FileHash) $writeBits = [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::GroupWrite -bor [IO.UnixFileMode]::OtherWrite try { & chmod -R a-w $readOnlyRoot $LASTEXITCODE | Should -Be 0 ([IO.File]::GetUnixFileMode($readOnlyRoot) -band $writeBits) | Should -Be 0 - $result = . (Join-Path $readOnlyRoot 'labautomation/deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') + $result = . (Join-Path $consoleLab 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') $result.HackboxCredential.name | Should -Contain 'Adaptive Apps Connect' ([IO.File]::GetUnixFileMode($readOnlyRoot) -band $writeBits) | Should -Be 0 $after = @(Get-ChildItem $readOnlyRoot -Recurse -Force -File | Sort-Object FullName | Get-FileHash) @@ -289,6 +308,22 @@ Describe 'Participant hook orchestration' { { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | Should -Throw '*was not validated*' Should -Invoke New-AzResourceGroupDeployment -Times 0 } + It 'fails before Azure calls and cleans scratch when downloading fails' { + $script:failedDownload = $true + { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | + Should -Throw '*Unable to download*Simulated download failure*' + Should -Invoke Update-MhhToken -Times 0 + Should -Invoke New-AzResourceGroupDeployment -Times 0 + Should -Invoke bash -Times 0 + } + It 'rejects a hash mismatch before Azure provisioning or Bash execution' { + $script:tamperedDownload = $true + { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | + Should -Throw '*SHA-256 verification failed*' + Should -Invoke New-AzResourceGroupDeployment -Times 0 + Should -Invoke Update-AzTag -Times 0 + Should -Invoke bash -Times 0 + } It 'stops on ARM failure without fallback or bootstrap' { $script:deploymentState = 'Failed' { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | Should -Throw '*no destructive fallback*' @@ -304,20 +339,38 @@ Describe 'Participant hook orchestration' { { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | Should -Throw '*absolute, isolated AZURE_CONFIG_DIR*' Should -Invoke New-AzResourceGroupDeployment -Times 0 } - It 'cleans the private working directory if packaging the copy fails' { - Mock Copy-Item { - $script:observedHome = Split-Path $Destination -Parent - throw 'Copy failed' - } - { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | Should -Throw '*Copy failed*' - Should -Invoke bash -Times 0 - @($script:tagWrites | Where-Object { $_.adaptiveAppsReady -eq 'true' }) | Should -HaveCount 0 - } } Describe 'Portable packaging and source contracts' { It 'fails with an actionable packaging error' { - { Assert-AdaptivePackage -LabRoot $TestDrive } | Should -Throw '*Package the entire MicroHack*' + { Assert-AdaptivePackage -LabRoot $TestDrive } | Should -Throw '*Verify the pinned source revision*' + } + It 'keeps the pinned hashes identical to the canonical workshop sources' { + { & (Join-Path $automation 'update-bootstrap-source.ps1') -Check } | Should -Not -Throw + } + It 'rejects a source pin that has drifted from the workshop sources' { + $changedSource = Join-Path $TestDrive 'changed-source' + New-Item -ItemType Directory -Path $changedSource | Out-Null + foreach ($directory in @('resources', 'iac')) { + Copy-Item -LiteralPath (Join-Path (Split-Path $automation -Parent) $directory) -Destination (Join-Path $changedSource $directory) -Recurse -Force + } + Add-Content -LiteralPath (Join-Path $changedSource 'resources/bootstrap-console.sh') -Value '# Changed workshop source' + { & (Join-Path $automation 'update-bootstrap-source.ps1') -SourceRoot $changedSource -Check } | Should -Throw '*Bootstrap source pin is stale*' + } + It 'rejects moving branches and incomplete or unsafe source manifests' { + $path = Join-Path $TestDrive 'bad-source.json' + $manifest = Get-Content (Join-Path $automation 'bootstrap-source.json') -Raw | ConvertFrom-Json -AsHashtable + $manifest.commit = 'main' + $manifest | ConvertTo-Json -Depth 3 | Set-Content $path + { Read-AdaptiveSourceManifest -Path $path } | Should -Throw '*full, lowercase Git commit SHA*' + $manifest.commit = 'a' * 40 + $manifest.files['../outside.sh'] = 'a' * 64 + $manifest | ConvertTo-Json -Depth 3 | Set-Content $path + { Read-AdaptiveSourceManifest -Path $path } | Should -Throw '*exactly the required file allowlist*' + $manifest.files.Remove('../outside.sh') + $manifest.files['resources/bootstrap-console.sh'] = 'not-a-hash' + $manifest | ConvertTo-Json -Depth 3 | Set-Content $path + { Read-AdaptiveSourceManifest -Path $path } | Should -Throw '*valid SHA-256 hash*' } It 'rejects missing recipe input before provisioning' -ForEach @( @{ Path = 'iac/sql-databases.yaml' } @@ -337,10 +390,10 @@ Describe 'Portable packaging and source contracts' { $errors | Should -BeNullOrEmpty } } - It 'uses conservative platform defaults and shared first-region gating' { + It 'uses configured platform defaults and shared first-region gating' { $defaults = Get-Content (Join-Path $automation 'lab-defaults.json') -Raw | ConvertFrom-Json $defaults.deploymentType | Should -Be 'resourcegroup' - $defaults.labsPerSubscription | Should -Be 2 + $defaults.labsPerSubscription | Should -Be 5 $defaults.preferredLocation | Should -Be 'swedencentral' $shared = Get-Content (Join-Path $automation 'shared-deploy-lab.ps1') -Raw $shared | Should -Match '\$locations\[0\] -notin \$ready' diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/update-bootstrap-source.ps1 b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/update-bootstrap-source.ps1 new file mode 100644 index 000000000..6a75e6592 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/update-bootstrap-source.ps1 @@ -0,0 +1,48 @@ +<# +.SYNOPSIS +Pin published workshop sources, or check that local sources still match the pin. +#> +param( + [string]$Commit, + [switch]$Check, + [string]$SourceRoot = (Split-Path $PSScriptRoot -Parent) +) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'console-helpers.ps1') +$manifestPath = Join-Path $PSScriptRoot 'bootstrap-source.json' +Assert-AdaptivePackage -LabRoot $SourceRoot + +if ($Check) { + $manifest = Read-AdaptiveSourceManifest -Path $manifestPath + foreach ($path in Get-AdaptiveBootstrapFiles) { + $hash = (Get-FileHash -LiteralPath (Join-Path $SourceRoot $path) -Algorithm SHA256).Hash + if ($hash -ne $manifest.files[$path]) { + throw "Bootstrap source pin is stale for '$path'. Publish the source changes, then run update-bootstrap-source.ps1 -Commit ." + } + } + Write-Host 'Local bootstrap sources match the pinned manifest.' + return +} +if ($Commit -cnotmatch '^[0-9a-f]{40}$') { + throw 'Specify -Commit with a full, lowercase commit SHA published in microsoft/MicroHack, or use -Check.' +} + +$scratch = Join-Path ([IO.Path]::GetTempPath()) "adaptive-source-pin-$([guid]::NewGuid().ToString('N'))" +try { + $files = [ordered]@{} + foreach ($path in Get-AdaptiveBootstrapFiles) { + $download = Join-Path $scratch $path + Save-AdaptiveSourceFile -Commit $Commit -RelativePath $path -Destination $download + $hash = (Get-FileHash -LiteralPath $download -Algorithm SHA256).Hash + $localHash = (Get-FileHash -LiteralPath (Join-Path $SourceRoot $path) -Algorithm SHA256).Hash + if ($hash -ne $localHash) { + throw "Published commit '$Commit' does not match local '$path'. The existing source pin was not changed." + } + $files[$path] = $hash.ToLowerInvariant() + } + [ordered]@{ commit = $Commit; files = $files } | ConvertTo-Json -Depth 3 | + Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM + Write-Host "Pinned $($files.Count) verified source files to microsoft/MicroHack commit $Commit." +} finally { + if (Test-Path -LiteralPath $scratch) { Remove-Item -LiteralPath $scratch -Recurse -Force } +}