diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md index f02175ad9..dde53ac66 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md @@ -100,19 +100,17 @@ challenges. Both start at Challenge 01. | Audience | Path | What you do | | --- | --- | --- | | **Platform engineer** | Challenges **02 - 06** | Prepare the Kubernetes platforms, install and operate Radius, author the resource-type contracts and the recipes behind them, then verify that an application deploys unchanged to every environment. | -| **Application developer** | Challenge **06**, then optional **07, 09, 10** | Connect to a developer-ready hosted lab, deploy and port the application, then explore identity, AI, and Radius Canvas with their separate prerequisites. | +| **Application developer** | Challenge **06**, then optional **07, 09, 10** | Start after your team completes Challenges 03-05, deploy and port the application, then explore identity, AI, and Radius Canvas with their separate prerequisites. | Challenges 05 and 06 are the deliberate handover point: the platform engineer finishes by proving the contracts work, and the application developer starts from the same place. > [!TIP] -> **A ready hosted Console lab covers Challenges 02-05**, including recipe -> publication and registration. Complete the hosted section of Challenge 01 and -> [connect your workstation](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md) -> before starting Challenge 06. Provisioned infrastructure does not supply your -> local Kubernetes/Radius state. Platform engineers retain the manual 01-06 path. -> The hosted connection flow still needs live workshop validation. +> **Using a hosted Console lab?** Complete the workstation setup in Challenge 01 +> and follow [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md). +> Your infrastructure is supplied: explore it in Challenge 02, then install Radius, +> define resource types, and publish recipes in **03-05** before deploying the app in **06**. ```mermaid flowchart LR @@ -123,7 +121,7 @@ flowchart LR C711["Optional 07, 09, 10
Identity, AI, Radius Canvas"] C1 --> C24 --> C5 --> C6 --> C711 - C1 -. "Ready Console lab: 02 - 05 + local connection" .-> C6 + C1 -. "Console: connect, validate 02, then install in 03 - 05" .-> C24 classDef platform fill:#dbeafe,stroke:#1d4ed8,color:#0f172a classDef developer fill:#dcfce7,stroke:#15803d,color:#0f172a @@ -161,7 +159,8 @@ published and are excluded from the current participant path. > The challenge sequence is completed incrementally. Follow the links below for > the currently published student and coach material. Each bucket is labelled > with its primary audience, so you can follow either the platform engineer path -> (Challenges 02 - 06) or the hosted developer path (06, then optional 07, 09, 10). +> (Challenges 02 - 06) or the hosted path (validate 02, complete 03-06, then +> optional 07, 09, 10). ### General prerequisites @@ -188,8 +187,9 @@ provisioning. For the manual bring-your-own-subscription path, have: - [Visual Studio Code](https://code.visualstudio.com/) - Bash or PowerShell 7; Windows users can also use WSL 2 -For a ready hosted lab, cluster capacity, the registry, and recipes are supplied. -You still need the tools above and the non-provisioning connection helper. +For an infrastructure-ready hosted lab, cluster capacity and the registry are supplied; +Radius, abstractions, and recipes are not. You still need the tools above, a full +repository checkout, and the non-provisioning connection helper. Use Bash for hosted instructions, including in containers with PowerShell. The recommended option is the repository @@ -229,8 +229,9 @@ which audience path you follow. Radius control plane. > [!NOTE] -> Ready Console labs include Challenges 02-05. Hosted developers connect after -> Challenge 01 and start 06; do not rerun the provisioning/install steps below. +> Console labs supply infrastructure only. Connect after Challenge 01, validate +> and explore 02 without reprovisioning, then install Radius, abstractions, and +> recipes in 03-05 before starting 06. > Platform engineers work through these steps manually on their own lab. - [Challenge 02 - Prepare the platforms](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/challenges/challenge-02.md) @@ -244,8 +245,8 @@ implement them with recipes. - [Challenge 04 - Build the platform abstractions](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/challenges/challenge-04.md) - [Challenge 05 - Build the platform abstractions with recipes](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/challenges/challenge-05.md) - - *Required on the manual setup path; supplied by the ready hosted baseline. - Hosted developers may read it without republishing or reinstalling recipes.* + - *Required for both BYOS and hosted teams. Console supplies ACR only; + participants publish and register recipes in this challenge.* ### Bucket 3: Portable apps across platforms diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-01.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-01.md index 9d14bdeb7..4d9122526 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-01.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-01.md @@ -16,12 +16,9 @@ any bucket. Complete Tasks **2-4** for workstation setup and tools, then follow [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md) -to sign in and connect from a **Bash terminal**. Skip the manual access/capacity, -provider-registration, extra-resource-group preflight, and platform setup below. -The helper reconstructs your local kubeconfig, Radius workspaces, and Bicep -extension using your own credentials. Source `artifacts/console-env.sh` in every -new Bash terminal and start **Challenge 06**, not provisioning Challenges 02-05. -The manual bring-your-own-subscription path below is unchanged. +to sign in and connect from a **Bash terminal**. Skip the remaining setup tasks; +your infrastructure is already supplied. Explore and validate it in **Challenge 02**, +then continue to **Challenge 03** to install Radius. ## Learning goals @@ -53,7 +50,9 @@ Confirm that your workshop team has: - Permission to create a resource group, AKS cluster, Linux VM, networking resources, Azure Bastion Standard, its managed public IP, a NAT gateway and its public IP, VM Run Command, and role assignments -- Permission to create a Microsoft Entra application and service principal +- Permission to create a user-assigned managed identity and role assignments in the + lab resource group, or tenant permission to create a Microsoft Entra application + and service principal for the optional application identity path - Regional quota for the AKS nodes, K3s VM, Azure Bastion, and two Standard public IP addresses - Contributor or equivalent resource permissions, Network Contributor where networking @@ -71,7 +70,7 @@ deployed these services silently fails later with provider or feature errors: az account set --subscription "" for provider in Microsoft.Network Microsoft.Compute Microsoft.ContainerService \ - Microsoft.ContainerRegistry Microsoft.KeyVault Microsoft.Storage; do + Microsoft.ContainerRegistry Microsoft.ManagedIdentity Microsoft.KeyVault Microsoft.Storage; do az provider register --namespace "$provider" --wait done @@ -235,7 +234,8 @@ Radius CLI, PowerShell 7, Git, `jq`, `yq`, `curl`, SSH, and `tar`. These tools c every command in Challenges 01-06. The container is only a workstation: AKS, the Linux VM, K3s, Radius control planes, portfolio workloads, and recipes are still created on the remote Azure and Kubernetes -targets by the manual commands or the organizer's hosted setup. +targets. Hosted setup supplies only AKS, the private VM with K3s, networking/NAT/Bastion, +and ACR; participants install Radius, portfolio workloads, and recipes in 03-05. Sign in to Azure from inside the container. If the container cannot open a browser, use the device-code flow: @@ -380,7 +380,9 @@ kubeconfig or SSH private key into the Git workspace. ## Success criteria For hosted labs, the success criteria are working local tools and a successful -Console connection with both contexts/workspaces and `artifacts/types.tgz`. +Console connection with both Kubernetes contexts, Ready nodes, a working private +API tunnel, and `artifacts/console-env.sh`. Workspaces and `artifacts/types.tgz` +are created by participants in Challenges 03 and 04, not by the connection helper. The capacity and subscription-wide checks below apply to manual setup only. You are ready to continue when: diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-02.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-02.md index 985b3d83b..d79ec0997 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-02.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-02.md @@ -8,7 +8,9 @@ Estimated time: 30-60 minutes | Difficulty: intermediate **Hosted Console:** this platform setup is already supplied in a ready lab. Follow [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md) -and start 06; read this manual path for context without rerunning provisioning. +after Challenge 01. Skip provisioning, but complete the read-only validation and +context-switching exercises below. Then start **Challenge 03** to install Radius. +Keep the supplied resource group, region, and names from `artifacts/console-env.sh`. Prepare and validate the two Kubernetes platforms used throughout the MicroHack: @@ -37,6 +39,8 @@ portability boundary explored in later challenges. ### Task 1: Plan the environment +Hosted teams inspect and record the supplied values instead of choosing replacements. + Agree on names and values for: - Azure subscription and location @@ -60,6 +64,8 @@ Azure Bastion: bas-adaptive-apps ### Task 2: Provision AKS +**Hosted:** skip creation and proceed to Task 4 for validation. + Create a two-node AKS cluster with: - OIDC issuer enabled @@ -73,6 +79,9 @@ and cluster values explicitly, then validate the result independently. ### Task 3: Provision K3s on an Azure VM +**Hosted:** skip VM/network creation and K3s installation. The connection helper +supplies the kubeconfig/tunnel; proceed to Tasks 4-5 for validation and exploration. + Create an Ubuntu VM with: - A private NIC and no VM public IP @@ -124,7 +133,7 @@ Record the active kubeconfig and context whenever you switch platforms. - Every node reports `Ready`. - The team can identify and switch between the AKS and K3s kubeconfig/context pairs. - K3s management access is restricted to `AzureBastionSubnet`. -- No Radius control plane, portfolio, recipe, or application workload is installed. +- The platforms are ready for Radius installation in Challenge 03. ## Hints diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-03.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-03.md index 6f713cf3a..16a7f430f 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-03.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-03.md @@ -6,9 +6,9 @@ Estimated time: 30-45 minutes per environment | Difficulty: intermediate ## Challenge objective -**Hosted Console:** Radius is already installed in a ready lab. Use -[Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md) -to create your local workspaces and start 06; do not repeat this manual installation. +**Hosted Console:** source `artifacts/console-env.sh` from your lab directory +and use the supplied resource names. Complete the installation below using the +**managed identity** option. Install and explore independent Radius control planes on both default platforms. Configure the matching local CLI workspaces, Radius environments, and groups: @@ -30,11 +30,13 @@ control plane and can operate independently. - Configure Azure workload identity for the AKS Radius control plane. - Validate Radius through Kubernetes, the CLI, and the dashboard. -Before the first K3s command in this challenge, re-establish the private API tunnel if -the devcontainer was reopened or rebuilt: +Before the first K3s command, re-establish the private API tunnel if the devcontainer +was reopened or rebuilt. Hosted participants rerun `bash resources/connect-console.sh` +and source `artifacts/console-env.sh`, then select the dedicated K3s kubeconfig. +The following subscription placeholder is for the bring-your-own-subscription path: ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" ``` @@ -59,9 +61,12 @@ governance trade-offs. Before installation: 1. Select kube context `aks-adaptive-apps`. -2. Create a Microsoft Entra application and service principal for Radius. +2. **Hosted:** create a user-assigned managed identity in the supplied lab resource + group. **Bring your own subscription:** use that same path, or create a Microsoft + Entra application and service principal if you have tenant permissions. 3. Use the AKS OIDC issuer to create federated credentials for the Radius service - accounts in namespace `radius-system`. + accounts in namespace `radius-system`: `applications-rp`, `bicep-de`, `ucp`, + and `dynamic-rp`. Do not configure only the first service account. 4. Grant the Radius identity the required role on the isolated lab resource group. Install Radius with Azure workload identity enabled. Only one team member should run @@ -76,6 +81,11 @@ Create and select: Configure the environment's Azure subscription and resource group, then register the Azure workload-identity credential. +The [manual walkthrough](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md#2-prepare-azure-workload-identity) +contains both identity options and the common installation steps. The managed +identity and its role assignment stay within the lab resource group; no tenant +app registration or service-principal creation command is required. + ### Task 3: Install Radius manually on K3s Select the dedicated K3s kubeconfig and context `k3s-azure-vm`, then install a second, @@ -150,6 +160,15 @@ recover the exercise, these scripts perform the same setup: - `resources/deploy-radius-aks.sh` - `resources/deploy-radius-k3s.sh` +For hosted AKS recovery, first source `artifacts/console-env.sh`, unset `KUBECONFIG`, +and select `aks-adaptive-apps`, then explicitly run +`RADIUS_IDENTITY_MODE=managedidentity RADIUS_REINSTALL=false bash resources/deploy-radius-aks.sh`. +The sourced Console configuration also exports these values. Keep +`RADIUS_REINSTALL=false` to preserve an existing installation and coordinate +recovery with the team. Without these overrides the script defaults to the +application identity path and reinstall behavior for BYOS; do not use those defaults +with hosted RG-only permissions. + If Azure Local or an existing Arc-enabled cluster replaces K3s, use its active context, create a distinct Radius workspace and environment, and decide explicitly whether it needs an Azure provider. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-04.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-04.md index 6fe6fa9e9..bae3a1d05 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-04.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-04.md @@ -6,9 +6,10 @@ Estimated time: 45-75 minutes | Difficulty: intermediate ## Challenge objective -**Hosted Console:** the portfolio and types are already installed in a ready lab. +**Hosted Console:** complete Challenge 03 first, then perform this exercise. [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md) -regenerates your local `artifacts/types.tgz`; start 06 without reinstalling this baseline. +only connects the supplied platforms; it does not install the portfolio/types or +generate `artifacts/types.tgz`. You create those here, then continue at Challenge 05. Install the Adaptive Apps capability portfolio, then define the portable contracts that application developers use in later challenges. Create one resource type manually @@ -33,7 +34,7 @@ Before the first K3s command, restore the localhost API tunnel after any contain restart: ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" ``` diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md index 83af7a980..cbf3fbf4f 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md @@ -6,9 +6,9 @@ Estimated time: 60-90 minutes | Difficulty: advanced ## Challenge objective -**Hosted Console:** a ready lab includes published and registered recipes. -Follow [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md) -and start 06. Read this manual exercise without recreating the registry or recipes. +**Hosted Console:** complete Challenges 03-04 first. Source +`artifacts/console-env.sh` and use the supplied `ACR_NAME`, resource group, and +region. Skip registry creation; complete the recipe exercises below. Implement the portable resource-type contracts from Challenge 04. Manually author, publish, and register an Azure SQL recipe before registering the complete @@ -29,7 +29,7 @@ Before the first K3s command, restore the localhost API tunnel after any contain restart: ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" ``` @@ -60,7 +60,8 @@ administrator password. While targeting AKS and workspace `ws-azure-prod`: -1. Create or select a globally unique, lowercase Azure Container Registry. +1. Hosted: use the supplied `ACR_NAME` from `artifacts/console-env.sh` without + recreating it. BYOS: create or select a globally unique, lowercase registry. 2. Authenticate to the registry. 3. Publish `iac/recipes/sql-server.bicep` and the workshop-owned Azure and Kubernetes PostgreSQL recipes as version `1.0.0`. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md index 8627ff012..39c2e6fb0 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md @@ -6,10 +6,12 @@ Estimated time: 45-75 minutes | Difficulty: intermediate to advanced ## Challenge objective -**Hosted entry point:** after the workstation portion of Challenge 01, complete +**Hosted prerequisites:** after the workstation portion of Challenge 01, complete [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md). -It supplies local contexts, workspaces, and `artifacts/types.tgz`; the ready lab -already includes Challenges 02-05. Use Bash and source `artifacts/console-env.sh` +Validate/explore the supplied platforms in 02, then complete **Challenges 03-05**: +install Radius, create workspaces and abstractions (including `artifacts/types.tgz`), +and publish/register recipes. Console supplies infrastructure only. +Use Bash and source `artifacts/console-env.sh` in each new terminal. Do not rerun platform provisioning to obtain local state. Prove that the same application model can be deployed to the private K3s platform and diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/console-setup.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/console-setup.md index 41f0849d1..ef4618a8e 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/console-setup.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/console-setup.md @@ -2,27 +2,23 @@ [Home](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/Readme.md) -Use this path only when the organizer has supplied a **ready, developer-ready** -Adaptive Apps lab. Console setup covers Challenges **02-05**, including recipe -publication and registration. It does not deploy the Challenge 06 application. -This connection procedure requires workshop validation; it is not a claim of a -completed live Console test. +Console supplies AKS, a private K3s VM, networking, Bastion, and a container +registry (ACR). You install and explore Radius, resource types, and recipes in +**Challenges 03-05**, then deploy the application in **Challenge 06**. ## 1. Prepare your workstation Complete **Task 2 (workstation), Task 3 (local state), and Task 4 (tool checks)** in [Challenge 01](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/challenges/challenge-01.md). -Use the canonical `microsoft/MicroHack` repository on `main`. The validated option +Use a **full checkout** of the canonical `microsoft/MicroHack` repository on `main`; +the challenges need its `iac`, `resources`, and application files, not only a +downloaded connection script. The validated workstation option remains a **local VS Code devcontainer**; a manual host toolchain is also supported. Use a **Bash terminal**, even when PowerShell is installed in the container. -Hosted participants skip subscription provider/feature registration, the extra -preflight resource group/public IP, capacity provisioning, and the installation -commands in Challenges 02-05. The organizer supplies **Owner on your lab resource -group** and **Reader on its AKS node resource group**; these are not subscription- -or tenant-administrator permissions. Do not request broader permissions just to -repeat the manual setup. Bring-your-own-subscription participants continue with -the original manual path. +Skip subscription setup and infrastructure provisioning. In Challenge 02, +explore and validate the supplied platforms instead. Use the **managed identity** +option in Challenge 03; it works with your assigned lab permissions. ## 2. Connect with your own identity @@ -42,27 +38,21 @@ bash resources/connect-console.sh source artifacts/console-env.sh ``` -Only `AZURE_SUBSCRIPTION` and `RESOURCE_GROUP` are required inputs. The helper checks -the lab ready flag, discovers `ACR_NAME` from the resource group's `adaptiveAppsAcr` -tag, obtains your cluster connections, creates the local Radius workspaces, and -regenerates `artifacts/types.tgz`. It **does not provision infrastructure** and uses -your own Azure CLI credentials, not credentials copied from an automation runner. -If the lab is not ready or access is denied, stop and contact the organizer rather -than rerunning installation scripts. +The helper discovers your lab settings, connects to both clusters, checks their +nodes, and saves the configuration to `artifacts/console-env.sh`. It uses your +Azure sign-in. If the lab is not ready or access is denied, contact the organizer. -| Target | Local Kubernetes context | Local Radius workspace | +| Target | Connected Kubernetes context | Workspace you create in Challenge 03 | | --- | --- | --- | | AKS | `aks-adaptive-apps` | `ws-azure-prod` | | Private K3s | `k3s-azure-vm` | `ws-local-prod` | -The environments remain `env-azure-prod` and `env-local-prod`, each with Radius -group `rg-trading`. Keep the discovered Console resource names; do not replace them +In Challenge 03 create environments `env-azure-prod` and `env-local-prod`, each +with Radius group `rg-trading`. Keep the discovered Console resource names; do not replace them with manual examples such as `rg-adaptive-apps`. -The default private VM is `vm-adaptive-apps-k3s` and its Bastion host is -`bas-adaptive-apps`. The hosted region is fixed by the supplied resource group's -location. If setup rejects that region as unsuitable, ask the organizer to correct -the lab assignment; do not change regions or delete/recreate the supplied group. +Keep the supplied resource group and region. If setup reports a problem with +either, contact the organizer rather than changing or recreating them. ## 3. Every new Bash terminal @@ -72,17 +62,18 @@ From the lab root, restore the generated shell assignments: source artifacts/console-env.sh ``` -The file uses safe shell assignments for your lab configuration. Do not commit it -or share local credential directories. The helper leaves the Bastion API tunnel -running in the background and prints reconnect/disconnect instructions. Follow -those instructions after a workstation/container restart; sourcing the file alone -does not restart a tunnel. Application and dashboard port-forwards are separate -and must stay running while you browse their localhost URLs. - -Continue at [Challenge 06](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/challenges/challenge-06.md). -Read Challenges 02-05 for context, but do not reprovision or reinstall their shared -components. The hosted control-tag policies are organizer-managed; participants -do not need to add policy/tag checks or change those controls. +Do not commit the generated file or share local credential directories. +After a workstation/container restart, run `bash resources/connect-console.sh` +again and source the file to reconnect. Sourcing the file alone does not restart +the private API tunnel. Keep application and dashboard port-forwards running +while you browse their localhost URLs. + +Validate/explore the supplied platforms using the read-only checks in +[Challenge 02](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/challenges/challenge-02.md), +then continue at [Challenge 03](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/challenges/challenge-03.md). +Complete the Radius, abstraction, and recipe exercises in Challenges 03-05. +Reuse the supplied ACR in Challenge 05; do not recreate it. The Bicep extension +`artifacts/types.tgz` is generated in Challenge 04, not during connection. ## Optional extensions and hosted browsers 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 4dcf51334..6dc7f34bb 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 @@ -1,307 +1,209 @@ # Adaptive Apps Console automation -**Microsoft-hosted, dedicated lab subscriptions only.** These hooks intentionally -assign a subscription-wide policy initiative that applies `SecurityControl=Ignore` -and `CostControl=Ignore` on new/updated resources and resource groups. They are -**not BYOS preparation scripts** and must not be run against production, customer, -or shared organizational subscriptions. BYOS participants use the manual scripts -under `../resources/` and the preparation documentation instead. - -## Organizer contract and readiness +**Microsoft-hosted, dedicated lab subscriptions only.** Shared setup assigns a +subscription-wide policy initiative applying `SecurityControl=Ignore` and +`CostControl=Ignore` to resources and resource groups. These hooks are **not BYOS +preparation scripts** and must not run against production, customer, or shared +organizational subscriptions. BYOS participants follow the manual workshop path. + +## Provisioning boundary + +The active Console path is the **platform-engineer scenario**: it supplies the +**Challenge 02 infrastructure only**. Participants complete +Challenge 01 workstation setup, connect using [Console setup](../docs/console-setup.md), +validate and explore the supplied platforms, then perform Challenges **03-05**: + +- Install Radius on AKS and K3s, with their own workspaces and environments. +- Create the Radius managed identity and federated credentials in Challenge 03. +- Install the capability portfolio and register resource types in Challenge 04. +- Publish and register recipes in Challenge 05. + +Console does **not** install Radius, Contour, capability portfolios, types or +recipes; create Radius identities, credentials or workspaces; or deploy the +Challenge 06 application. It never uses the worker's Kubernetes context or +in-cluster service account. No Radius, Helm, kubectl, jq, source downloads, or +participant checkout is needed on the worker. The [Console contract](../../../../99-MicroHack-Template/labautomation/README.md) -supplies authenticated Az PowerShell and Azure CLI sessions, subscription Owner -for the deployment identity, an already-created participant resource group, its -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**, **five labs per subscription**, with **Sweden Central** -first and **Spain Central** as fallback (`swedencentral,spaincentral`). -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, 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 -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. - -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: - -- AKS with OIDC, workload identity and managed Istio, plus Radius configured with - the lab-scoped user-assigned managed identity created by the Bash bootstrap. -- K3s installed through VM Run Command, Radius, both Radius workspaces and - environments, custom resource types and the recipe registries. -- Verification of the bootstrap workloads, types and recipe registrations on both - clusters; it is not a claim that optional applications have been deployed. - -After workstation setup, participants reconnect with the Console credential -command and begin at challenge 06. They do not rerun manual infrastructure -provisioning. Challenge 07 still needs -Entra enterprise-application/SAML permissions, test users and browser callback -access; challenge 09 needs an approved existing Azure OpenAI deployment, quota -and inference-role assignment permission (plus the local Ollama model download). -Challenge 10 needs the host-only GitHub Copilot desktop app/Radius Canvas, a writable -GitHub repository with Actions/packages access, and separately arranged -tenant/federation/Azure permissions for its workflow deployment path. -These are not supplied by the Console baseline; follow each challenge's documented -prerequisites. No GitHub or AI API credentials are provisioned by these hooks. - -## Shared stage: service, quota and region checks +supplies authenticated Az PowerShell and Azure CLI, subscription Owner for the +deployment identity, participant object IDs, and a precreated participant RG. +Participants have Owner on that RG. The hooks grant Reader on the AKS node RG for +the later recipe's egress discovery; no subscription-wide participant role or +tenant/Graph permissions are granted. Hosted participants use a resource-group +scoped managed identity rather than creating an Entra application. + +The worker requires Linux, PowerShell 7, Az.Accounts/Az.Resources/Az.Network, +Azure CLI with AKS Run Command support, a Bicep compiler usable by Az deployments, +and `chmod`. VM Run Command installs K3s on the guest using its outbound NAT. +The worker needs Azure API access; the VM and clusters need access to their +installation and container image endpoints. Console can mount just this +`labautomation/` folder read-only; no sibling source folders or bootstrap pin +manifest are needed. + +## Retained developer-scenario bootstrap + +Full-bootstrap artifacts are retained in their original locations for possible +future **application-developer scenario** support. They are not invoked by the +active Console hook, and there is no enabled or validated full-bootstrap toggle. +Keeping the artifacts does not change the platform-engineer handoff above. + +- [Bootstrap phases](../resources/bootstrap-console.sh) retain Radius installation, + resource-type setup, recipe publishing and verification. +- [Tool installer](../resources/install-console-tools.sh) and + [PowerShell helpers](console-helpers.ps1) retain the private tool/jq installation, + token-refresh wrapper, allowlisted source downloads and SHA-256 verification. +- [Session helpers](../resources/console-session.sh), the individual Radius/type/ + recipe scripts, and [IaC sources](../iac/) remain available without duplicate copies. +- [Source manifest](bootstrap-source.json) and [pin updater](update-bootstrap-source.ps1) + retain the previous immutable published snapshot. The pin is historical: + participant connection changes mean it no longer matches all current sources. + `update-bootstrap-source.ps1 -Check` reports that drift; the active hook does + not read or validate this unused manifest. + +Before enabling full bootstrapping, scenario owners must agree on the exercise +boundary, wire an explicit scenario selection and developer-ready handoff, +publish and re-pin the intended sources, and validate the complete flow in the +Console. In particular, resolve the observed Radius installation using the +worker's `jobrunner-sa` identity by ensuring the intended lab cluster context and +credentials are used. Retaining these scripts does not resolve that failure. +Worker tool setup, isolated HOME/kubeconfig, token refresh, parallel-job tunnel +isolation, and full Radius/type/recipe readiness must be included in that work. + +## Shared setup + +Defaults remain **five labs per subscription**, with **Spain Central** first and +**Sweden Central** as fallback (`spaincentral,swedencentral`). Organizers can +override the ordered regions before provisioning. `shared-deploy-lab.ps1` runs once per subscription: 1. Verify both authenticated contexts target the supplied subscription. -2. Register `Microsoft.Network/AllowBringYourOwnPublicIpAddress`, as required - by these hosted workshop subscriptions for public IP allocation (also used by - LocalBox). Wait up to 15 minutes for `Registered`; pending approval, failure, - or timeout blocks participant provisioning. Then re-register Network so the - feature takes effect. Register and wait for Compute, Network, ContainerService, ContainerRegistry, +2. Register `Microsoft.Network/AllowBringYourOwnPublicIpAddress`, as also required + by LocalBox in these hosted subscriptions. Wait up to 15 minutes for + `Registered`; pending approval, failure or timeout blocks participant jobs. + Re-register Microsoft.Network afterward so the feature takes effect. +3. Register and wait for Compute, Network, ContainerService, ContainerRegistry, ManagedIdentity, DBforPostgreSQL, OperationalInsights, PolicyInsights, Sql, - KeyVault and Storage. SQL supports the published SQL recipe; Key Vault and - Storage preserve the manual challenge 01 subscription prerequisites. -3. Check each candidate region for a subscription-exposed `Standard_D4s_v5` - 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. 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 - submit quota increases or delete old labs. -5. Check PostgreSQL Flexible Server capabilities for the default recipe's - PostgreSQL 16 / Burstable `Standard_B1ms`, rejecting offer restrictions. - Other recipe sizes may require additional regional SKU availability. -6. Assign and actively verify the hosted control-tag initiative with a short-lived - probe RG and NSG. Both tags must read back as `Ignore`, while the probe's own - tag must remain intact. The probe RG is removed in `finally`. -7. Merge subscription tags `microhack-adaptive-location`, - `microhack-adaptive-regions`, and `microhack-adaptive-lab-count` for the - participant processes. No local shared state file is needed. - -**SKU exposure and quota are not a capacity reservation.** Azure allocation, -regional service limits, policy and actual SKU capacity can still fail deployment. -The stage fails explicitly rather than silently choosing a different VM size. -Quota failure messages identify the required capacity and region. - -Console creates participant groups in the **first preferred region**. Shared -preflight retains eligible regions in preference order and fails if none pass. -For **initial infrastructure provisioning only**, the participant hook uses -`Invoke-MhhDeploymentWithRegionFallback` with those validated regions. The helper -classifies deployment failures, retries supported capacity/region failures, and -fails immediately for fatal errors rather than treating all errors as retryable. -It can **delete the participant RG and all its contents**, retry the same region, -or recreate it in the next region. Participant Owner access is restored on every -attempt. Use initial provisioning only in dedicated, disposable lab groups. - -After infrastructure succeeds, `adaptiveAppsPreserve=true` prevents subsequent -runs from invoking destructive fallback, including when Bash bootstrap failed. -Existing labs with `adaptiveAppsReady=true` also receive this persistent guard -before readiness is cleared. Do not remove the preservation tag to troubleshoot -a working lab. Such labs receive incremental deployment in place; if their region -fails preflight, the hook stops rather than relocating them. - -The parameter file deliberately omits `location`, letting the template follow -`resourceGroup().location`. After fallback the hook re-reads the RG location for -all bootstrap phases and credentials, keeping the PostgreSQL recipe in the same -region. These checks do not guarantee AKS capacity or region eligibility. - -## Participant infrastructure and execution - -`main.bicep` is the ARM infrastructure topology. Files under `../iac/` are Radius -application/recipe artifacts, **not** alternative ARM infrastructure templates. - -| Resource | Configuration | -| --- | --- | -| `aks-adaptive-apps` | Two `Standard_D4s_v5` Ubuntu nodes, managed disks, free control plane tier, one-node maximum surge, OIDC/workload identity, managed Istio, standard load-balancer egress | -| `vm-adaptive-apps-k3s` | `Standard_D4s_v5`, Ubuntu 22.04, 64-GiB Standard SSD, no public NIC/IP | -| `vnet-adaptive-apps` | K3s subnet `10.42.0.0/24`, Bastion subnet `10.42.1.0/26` | -| `bas-adaptive-apps` | Standard Bastion with native tunneling, two scale units, static Standard public IP; name matches existing scripts | -| `natgw-adaptive-apps` | Explicit private-subnet outbound via a static Standard public IP | -| `nsg-adaptive-apps-k3s` | Inbound SSH/API only from the Bastion subnet; other inbound traffic denied | -| `acad` | Globally unique ACR name derived from subscription/RG, Standard, anonymous recipe pull, admin credentials disabled | - -The K3s pod/service ranges are `10.52.0.0/16` and `10.53.0.0/16`, avoiding the -Azure VNet range. Run Command installs K3s only when absent and waits for API -readiness. An existing installation is started, not wiped/reinstalled. The VM -admin password comes from `New-MhhStablePassword`, so retries do not silently -rotate it. It is never printed or returned to participants: connect uses Run -Command for kubeconfig retrieval and Bastion for the API, not SSH credentials. -Organizer troubleshooting can use Run Command. - -The deployment receives a temporary ARM parameter file rather than a -`SecureString` embedded in PowerShell command arguments, avoiding secure-string -serialization at the Console job boundary. The file contains the password in -plaintext inside the job's private `0700` scratch directory, with file permissions -`0600`; it is deleted as soon as the deployment command returns or throws. -The template still declares `adminPassword` as `@secure()`, so ARM does not -record its value in deployment history. Do not print or publish the parameter -file. Stage messages identify context validation, parameter preparation and -deployment submission without including credentials. - -The participant hook uses an **incremental deployment**; initial-provisioning -fallback may recycle failed attempts as described above. Protected labs retain -resources and failed deployment records for diagnosis. It grants each supplied participant -**Reader on this AKS node RG only**, so the PostgreSQL recipe can discover AKS -egress IPs. Role operations use object IDs and explicitly disable Graph name -resolution. No subscription-wide participant role is added. - -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**. 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 -and the exact private directory removed in `finally`. - -The Bash interface, run from that isolated lab root, is: - -```text -bash resources/install-console-tools.sh -bash resources/bootstrap-console.sh aks-radius -bash resources/bootstrap-console.sh k3s-radius -bash resources/bootstrap-console.sh aks-types -bash resources/bootstrap-console.sh k3s-types -bash resources/bootstrap-console.sh recipes -bash resources/bootstrap-console.sh verify -``` - -Every invocation follows `Update-MhhToken`; every nonzero exit throws. Each -bootstrap phase obtains fresh local context and owns its tunnel cleanup. -`AZURE_SUBSCRIPTION`, `RESOURCE_GROUP`, `REGION`, `AZURE_LOCATION`, `ACR_NAME`, -`AKS_CLUSTER`, `AKS_CLUSTER_NAME`, `AKS_CONTEXT`, `K3S_VM_NAME`, `K3S_CONTEXT`, -`BASTION_NAME`, `K3S_LOCAL_PORT` and isolated K3s paths are supplied explicitly. - -The group is marked `adaptiveAppsReady=false` before changes. `adaptiveAppsAcr` -is written after infrastructure exists; **`adaptiveAppsReady=true` is written -only after the verify phase succeeds**. Only then are identifiers and the minimal -connect command emitted as Console `HackboxCredential` hashtables. No kubeconfig, -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$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: - -- Three continuously running D4s v5 VMs: roughly US$14–20/day by region. -- Standard Bastion: roughly US$7–10/day. -- Managed disks, NAT, Standard IPs, load balancer and ACR: roughly US$4–7/day. -- A small PostgreSQL server created during challenge 06 adds roughly US$1–3/day; - larger sizes, AI models, log ingestion, bandwidth and optional runners add more. - -Use the [Azure pricing calculator](https://azure.microsoft.com/pricing/calculator/) -for the event's actual region/offer. Shared recurring infrastructure cost is -effectively zero; the short-lived policy probe costs are negligible. AKS surge -temporarily increases compute cost. VM deallocation does **not** stop Bastion, -NAT, registry or disk charges. - -After the event, use Console's scope cleanup to delete the participant RGs. -Deleting AKS normally removes its managed node RG; **verify node RG deletion** and -check for orphaned disks/IPs. The shared policy assignment/definition, its managed -identity and subscription metadata are outside those groups. The organizer must -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 "" -``` + KeyVault and Storage. +4. Check each candidate's `Standard_D4s_v5` exposure and **16 free vCPUs per lab** + in both regional and DSv5-family quota: 12 baseline plus 4 for AKS surge. + Five labs require 80 free vCPUs (60 steady-state). The check budgets the entire + fan-out as new capacity, including on reruns. +5. Check PostgreSQL 16 / Burstable `Standard_B1ms` availability for the later + participant recipe; this does not provision a database. +6. Apply the hosted tag initiative and verify inheritance with a temporary RG/NSG. +7. Persist eligible regions and lab count as subscription tags. -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. +Preflight fails if no candidate passes. **SKU and quota checks do not reserve +capacity or guarantee region eligibility.** Other recipe sizes need their own +availability checks. Shared setup does not request quota increases. -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. +## Infrastructure and readiness -## Offline tests and live acceptance +`main.bicep` supplies: -With PowerShell 7, Pester 5 and Bicep installed, from the repository root: +| Resource | Configuration | +| --- | --- | +| `aks-adaptive-apps` | Two D4s v5 Ubuntu nodes, OIDC/workload identity, managed Istio, free control-plane tier, one surge node and standard load-balancer egress | +| `vm-adaptive-apps-k3s` | Private D4s v5 Ubuntu 22.04 VM, 64-GiB Standard SSD, K3s | +| `vnet-adaptive-apps` | K3s subnet `10.42.0.0/24`, Bastion subnet `10.42.1.0/26` | +| `bas-adaptive-apps` | Standard Bastion, native tunneling, two scale units, static Standard public IP | +| `natgw-adaptive-apps` | Explicit outbound connectivity through a static Standard public IP | +| `nsg-adaptive-apps-k3s` | Inbound SSH/API restricted to the Bastion subnet | +| `acad` | Unique Standard ACR, anonymous recipe pull enabled, admin credentials disabled; no recipes published | + +K3s uses pod/service CIDRs `10.52.0.0/16` and `10.53.0.0/16`, avoiding the +VNet range. Run Command starts an existing K3s installation rather than wiping it. +The VM password uses `New-MhhStablePassword`, so reruns do not silently rotate it. +It is never returned to participants; kubeconfig retrieval uses VM Run Command +and Kubernetes API access uses Bastion. + +The hook passes the password through a temporary ARM parameter file in a private +`0700` directory, with file permissions `0600`, rather than a `SecureString` +inside command arguments. The file is removed as soon as deployment returns or +throws, and scratch is removed in `finally`. Bicep retains `@secure()`. No password, +kubeconfig, token or Kubernetes private key is emitted. The worker's HOME, PATH, +KUBECONFIG and isolated `AZURE_CONFIG_DIR` are not changed. + +After deployment and node-RG Reader assignments, readiness checks require: + +- K3s API and nodes ready, verified inside the VM by Run Command. +- AKS nodes ready, verified by `az aks command invoke` against the named lab AKS + cluster, with both successful operation state and exit code zero. This uses + AKS's temporary command pod, not a local worker kubeconfig. +- Bastion provisioned with Standard/Premium SKU and native tunneling enabled. +- ACR provisioned with anonymous pull enabled. + +The hook clears `adaptiveAppsInfrastructureReady` before changes and sets it +to `true` only after all checks pass. `adaptiveAppsAcr` records the registry name. +The obsolete developer-ready tag `adaptiveAppsReady` is set to `false` so old +connection scripts cannot mistake this for a lab prepared through Challenge 05. +Credentials emitted on success hand participants off to **Challenge 03**. + +`resources/connect-console.sh` runs on the participant workstation. It checks +the new readiness tag, obtains fresh credentials, validates both clusters and +the Bastion tunnel, and writes `artifacts/console-env.sh`. It neither invokes +Radius nor requires installed Radius control planes, types or recipes. +Participants must use the updated checkout when reconnecting. + +## Fallback and existing labs + +For initial provisioning, `Invoke-MhhDeploymentWithRegionFallback` receives only +validated regions. It can **delete the participant RG and all its contents**, +retry in the same region, or recreate it in the next region, restoring participant +Owner access. Initial provisioning is only for dedicated, disposable lab groups. +The parameter file omits `location`; all resources and later database recipes +follow `resourceGroup().location`. The hook re-reads the group after fallback. + +After ARM succeeds, `adaptiveAppsPreserve=true` prevents destructive fallback +on subsequent runs, even when K3s or another readiness check failed. Existing +labs with either readiness tag set also receive this persistent guard before +readiness is cleared. Protected labs receive incremental deployment in place; +an unvalidated region stops the hook rather than relocating the lab. + +Rerunning this infrastructure-only hook **does not uninstall** Radius or any +participant workloads already present, including partial installs from earlier +Console attempts. Inspect reused labs before starting the exercises. Do not +remove the preservation tag merely to troubleshoot them. + +Fallback depends on the Console's error classifier. Versions that classify +`AKSCapacityHeavyUsage` as `UnknownAssumedFatal` will not try the next region; +update the Console classifier or select a working primary region explicitly. +If public-IP creation still reports `AllowBringYourOwnPublicIpAddress`, verify +both feature registration and subsequent Network provider registration completed. + +## Costs and cleanup + +Budget approximately **US$35 per lab per 24 hours** (US$175/day for five), +before optional exercises and significant traffic. This is a planning allowance, +not a quote; use the [Azure pricing calculator](https://azure.microsoft.com/pricing/calculator/). +VM deallocation does not stop Bastion, NAT, registry or disk charges. Databases +created during later challenges add cost. + +Use Console scope cleanup after the event. Verify AKS's managed node RG is +removed and check for orphaned disks/IPs. Shared policy resources and subscription +tags live outside participant RGs and must be removed when releasing the +subscription, but not while another hosted event still uses them. + +## Validation + +Run offline tests from the repository root: ```bash bash "03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/run-offline-tests.sh" +python3 -m unittest discover -s "03-Azure/01-01-App Innovation/04-adaptive-apps/tests" -p test_console_helpers.py ``` -Set `PWSH_COMMAND`, `BICEP_COMMAND`, or `PESTER_MODULE` to use already-installed -tools outside PATH. The runner confines Pester scratch files to a unique directory -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, 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. - -For the companion Bash helper tests, from this `labautomation/` directory: - -```bash -python3 -m unittest discover -s ../tests -p test_console_helpers.py -``` +The first runner requires PowerShell 7, Pester 5 and Bicep; set `PWSH_COMMAND`, +`BICEP_COMMAND`, or `PESTER_MODULE` for tools outside PATH. The Python tests need +Bash, jq and kubectl and exercise real local kubeconfig operations. Both suites +mock cloud calls. Tests cover feature registration, quota, fallback protection, +read-only packaging, secure parameters and cleanup, infrastructure readiness +failures, and connecting before Radius installation without changing Radius state. -These require Python 3, Bash and `kubectl` on PATH. They exercise real local -`kubectl config` operations to verify kubeconfig preservation; Azure, Radius and -Kubernetes network calls are mocked, so no cloud resources are accessed. - -**Offline tests are not Azure deployment verification.** Organizer live testing -must still prove policy propagation, actual allocation, managed Istio, K3s -installation/Bastion, image access, Radius identity federation, recipe execution -and participant reconnect/permissions in Console before an event. No live Azure -deployment success should be inferred from a passing offline test or ARM -validation alone. - -The AKS template supplies an explicit empty `serviceMeshProfile.istio` object: -the managed-cluster API requires the profile when `mode` is `Istio`, and Azure -selects its default supported revision when no revision is specified. - -If a live deployment reports `AKSCapacityHeavyUsage`, quota/SKU validation has -not reserved AKS capacity. Initial provisioning can use the validated fallback; -protected labs require organizer investigation. If -`SubscriptionNotRegisteredForFeature` still mentions -`AllowBringYourOwnPublicIpAddress`, verify shared setup completed both feature -registration and subsequent Network provider registration. The template itself -does not request BYOIP or a custom IP prefix. +Offline success is not live acceptance. Verify actual allocation, K3s and AKS node +health, participant Bastion access and permissions, then run the participant +Radius/identity/recipe exercises before the event. ## Hosted policy provenance @@ -311,5 +213,3 @@ at commit `d3e446889384325605e5db7e4e61ab1ed8af2cfd`. Their functional bodies, verification, cleanup and existing `sov-hosted-control-tags` / `sovereign-hosted-control-tags` names are unchanged. Only provenance comments were added; there is no runtime dependency on the Sovereign MicroHack folder. -The retained names deliberately reuse an already-present hosted initiative -instead of creating competing assignments. 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 420f2b798..54ef277d3 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 @@ -1,6 +1,6 @@ <# .SYNOPSIS -Provision one dedicated Microsoft-hosted Adaptive Apps lab through challenge 05. +Provision the infrastructure for one Microsoft-hosted Adaptive Apps lab. #> param( [Parameter(Mandatory)] @@ -13,26 +13,19 @@ param( ) $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'console-helpers.ps1') -if (-not $IsLinux) { throw 'Adaptive Apps Console automation requires a Linux runner (Bash and native Bastion tunneling).' } +if (-not $IsLinux) { throw 'Adaptive Apps Console automation requires a Linux runner.' } 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.' } -$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.' + throw 'Console must supply an absolute, isolated AZURE_CONFIG_DIR.' } -$saved = @{} -$isolatedHome = Join-Path ([IO.Path]::GetTempPath()) "adaptive-console-$([guid]::NewGuid().ToString('N'))" -$workingRoot = Join-Path $isolatedHome 'work' +$scratch = Join-Path ([IO.Path]::GetTempPath()) "adaptive-console-$([guid]::NewGuid().ToString('N'))" try { - $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 - Receive-AdaptiveBootstrap -Manifest $sourceManifest -Destination $workingRoot - Install-AdaptiveJq -BinDirectory (Join-Path $isolatedHome '.local/bin') + & chmod 700 $scratch + if ($LASTEXITCODE -ne 0) { throw 'Unable to secure private deployment scratch directory.' } Update-MhhToken | Out-Null Write-Host 'Validating participant Azure contexts and shared region metadata...' @@ -42,12 +35,14 @@ try { $metadata = (Get-AzTag -ResourceId "/subscriptions/$SubscriptionId" -ErrorAction Stop).Properties.TagsProperty if (-not $metadata) { throw 'Shared region metadata is missing. Run shared-deploy-lab.ps1 first.' } $regions = @($metadata['microhack-adaptive-regions'] -split ',' | Where-Object { $_ }) - $preserve = $group.Tags -and ($group.Tags['adaptiveAppsReady'] -eq 'true' -or $group.Tags['adaptiveAppsPreserve'] -eq 'true') + $preserve = $group.Tags -and ($group.Tags['adaptiveAppsReady'] -eq 'true' -or + $group.Tags['adaptiveAppsInfrastructureReady'] -eq 'true' -or $group.Tags['adaptiveAppsPreserve'] -eq 'true') if ($regions.Count -eq 0 -or ($preserve -and $region -notin $regions)) { throw "No validated region is available for this lab. Run shared-deploy-lab.ps1 for the lab's region; an established lab will not be relocated." } $deploymentTags = @{ adaptiveAppsReady = 'false' + adaptiveAppsInfrastructureReady = 'false' SecurityControl = 'Ignore' CostControl = 'Ignore' } @@ -84,7 +79,7 @@ try { Remove-Item -LiteralPath $parameterFile -Force } if ($deployment.ProvisioningState -ne 'Succeeded') { - throw "Infrastructure deployment failed: $($deployment.ProvisioningState). Bootstrap will not run." + throw "Infrastructure deployment failed: $($deployment.ProvisioningState). Infrastructure will not be marked ready." } $group = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction Stop $region = $group.Location.ToLowerInvariant() @@ -109,6 +104,7 @@ try { } } Update-MhhToken | Out-Null + Write-Host 'Installing and verifying K3s through VM Run Command...' $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')") @@ -118,55 +114,28 @@ try { 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() + Update-MhhToken | Out-Null + Write-Host 'Verifying AKS nodes through Azure Run Command (no worker Kubernetes context)...' + $aks = Invoke-AdaptiveAz -Arguments @('aks', 'command', 'invoke', '--resource-group', $ResourceGroupName, + '--name', 'aks-adaptive-apps', '--command', 'kubectl wait --for=condition=Ready nodes --all --timeout=5m') + if ($aks.provisioningState -ne 'Succeeded' -or $null -eq $aks.exitCode -or $aks.exitCode -ne 0) { + throw 'AKS node readiness failed. Inspect AKS Run Command; infrastructure was not marked ready.' } - foreach ($key in $environment.Keys) { - $saved[$key] = [Environment]::GetEnvironmentVariable($key) - [Environment]::SetEnvironmentVariable($key, $environment[$key]) + Update-MhhToken | Out-Null + $bastion = Invoke-AdaptiveAz -Arguments @('network', 'bastion', 'show', '--resource-group', $ResourceGroupName, + '--name', 'bas-adaptive-apps', '--query', '{state:provisioningState,sku:sku.name,tunneling:enableTunneling}') + if ($bastion.state -ne 'Succeeded' -or $bastion.sku -notin @('Standard', 'Premium') -or $bastion.tunneling -ne $true) { + throw 'Bastion must be provisioned with native tunneling enabled before infrastructure is ready.' } - Push-Location $workingRoot - try { - Invoke-AdaptiveBash -Script 'resources/install-console-tools.sh' - foreach ($phase in @('aks-radius', 'k3s-radius', 'aks-types', 'k3s-types', 'recipes', 'verify')) { - Write-Host "Bootstrapping Adaptive Apps: $phase" - Invoke-AdaptiveBash -Script 'resources/bootstrap-console.sh' -Arguments @($phase) - } - } finally { - Pop-Location + $registry = Invoke-AdaptiveAz -Arguments @('acr', 'show', '--resource-group', $ResourceGroupName, '--name', $acrName) + if ($registry.provisioningState -ne 'Succeeded' -or $registry.anonymousPullEnabled -ne $true) { + throw 'The recipe registry must be provisioned with anonymous pull enabled before infrastructure is ready.' } } finally { - foreach ($key in $saved.Keys) { [Environment]::SetEnvironmentVariable($key, $saved[$key]) } - if (Test-Path -LiteralPath $isolatedHome) { Remove-Item -LiteralPath $isolatedHome -Recurse -Force } + if (Test-Path -LiteralPath $scratch) { Remove-Item -LiteralPath $scratch -Recurse -Force } } Update-MhhToken | Out-Null -Update-AzTag -ResourceId $group.ResourceId -Operation Merge -Tag @{ adaptiveAppsReady = 'true' } -ErrorAction Stop | Out-Null +Update-AzTag -ResourceId $group.ResourceId -Operation Merge -Tag @{ adaptiveAppsInfrastructureReady = 'true' } -ErrorAction Stop | Out-Null @{ HackboxCredential = @{ name = 'Adaptive Apps Region'; value = $region; note = 'All resources and recipes use the supplied resource group location.' } } @{ HackboxCredential = @{ name = 'Adaptive Apps Registry'; value = "$acrName.azurecr.io"; note = 'Anonymous recipe pull is enabled; do not publish sensitive artifacts.' } } @{ HackboxCredential = @{ name = 'Adaptive Apps AKS'; value = 'aks-adaptive-apps'; note = "Node RG: $nodeGroup (Reader granted for recipe egress discovery)." } } @@ -174,5 +143,5 @@ Update-AzTag -ResourceId $group.ResourceId -Operation Merge -Tag @{ adaptiveApps @{ HackboxCredential = @{ name = 'Adaptive Apps Connect' value = "AZURE_SUBSCRIPTION='$SubscriptionId' RESOURCE_GROUP='$ResourceGroupName' bash resources/connect-console.sh" - note = 'Run from the complete Adaptive Apps checkout after signing into your own participant Azure CLI account. Ready through challenge 05; start challenge 06.' + note = 'Run from the complete Adaptive Apps checkout after signing into your own participant Azure CLI account. Infrastructure only: install Radius in Challenge 03, then complete Challenges 04-05.' } } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/install-k3s.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/install-k3s.sh index 25bf569aa..99f76eabc 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/install-k3s.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/install-k3s.sh @@ -20,6 +20,7 @@ fi systemctl enable --now k3s for attempt in $(seq 1 60); do if k3s kubectl get --raw=/readyz >/dev/null 2>&1; then + k3s kubectl wait --for=condition=Ready nodes --all --timeout=5m printf 'ADAPTIVE_K3S_READY\n' exit 0 fi 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 69c002983..9a75fa14a 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 @@ -21,7 +21,9 @@ BeforeAll { function Get-AzNetworkSecurityGroup { [CmdletBinding()] param($Name, $ResourceGroupName) } function Remove-AzResourceGroup { [CmdletBinding()] param($Name, [switch]$Force) } function az { $global:LASTEXITCODE = 0 } - function bash { $global:LASTEXITCODE = 0 } + function bash { } + function kubectl { } + function rad { } $subscription = '11111111-1111-1111-1111-111111111111' $postgresCurrent = '[{"supportedFeatures":[{"name":"OfferRestricted","status":"Disabled"}],"supportedServerVersions":[{"name":"16"}],"supportedServerEditions":[{"name":"Burstable","supportedServerSkus":[{"name":"Standard_B1ms"}]}]}]' $postgresLegacy = '[{"status":"Available","supportedFlexibleServerEditions":[{"name":"Burstable","supportedServerVersions":[{"name":"16","supportedVcores":[{"name":"Standard_B1ms"}]}]}]}]' @@ -38,19 +40,17 @@ Describe 'Regional preflight' { $global:LASTEXITCODE = 0 switch ("$args") { { $_ -like 'vm list-skus *' } { - "[{`"name`":`"Standard_D4s_v5`",`"restrictions`":[{`"type`":`"$script:restriction`"}]}]" - break + "[{`"name`":`"Standard_D4s_v5`",`"restrictions`":[{`"type`":`"$script:restriction`"}]}]"; break } { $_ -like 'vm list-usage *' } { - "[{`"name`":{`"value`":`"cores`"},`"limit`":$script:regionalLimit,`"currentValue`":$script:used},{`"name`":{`"value`":`"standardDSv5Family`"},`"limit`":$script:familyLimit,`"currentValue`":$script:used}]" - break + "[{`"name`":{`"value`":`"cores`"},`"limit`":$script:regionalLimit,`"currentValue`":$script:used},{`"name`":{`"value`":`"standardDSv5Family`"},`"limit`":$script:familyLimit,`"currentValue`":$script:used}]"; break } { $_ -like 'postgres flexible-server list-skus *' } { $script:postgres; break } default { throw "Unexpected az call: $args" } } } } - It 'requires aggregate baseline plus one D4s surge per lab, allowing regional nonzonal SKUs' { + It 'requires baseline plus surge per lab and allows regional nonzonal SKUs' { Test-AdaptiveRegion -Location westeurope -LabCount 2 | Should -Be 'westeurope' } It 'rejects quota sufficient only for baseline' { @@ -136,15 +136,14 @@ Describe 'Shared hook and hosted policy verification' { break } { $_ -like 'vm list-usage *' } { - '[{"name":{"value":"cores"},"limit":32,"currentValue":0},{"name":{"value":"standardDSv5Family"},"limit":32,"currentValue":0}]' - break + '[{"name":{"value":"cores"},"limit":32,"currentValue":0},{"name":{"value":"standardDSv5Family"},"limit":32,"currentValue":0}]'; break } { $_ -like 'postgres flexible-server list-skus *' } { $postgresCurrent; break } default { throw "Unexpected shared Azure call: $args" } } } } - It 'registers providers, verifies inherited tags and persists eligible subscription regions' { + It 'registers providers, verifies inherited tags and persists eligible regions' { . (Join-Path $automation 'shared-deploy-lab.ps1') -SubscriptionId $subscription -PreferredLocation @('westeurope,northeurope') -AllowedEntraUserIds @('one', 'two', 'one') $script:sharedTags['microhack-adaptive-location'] | Should -Be 'westeurope' $script:sharedTags['microhack-adaptive-regions'] | Should -Be 'westeurope,northeurope' @@ -155,10 +154,9 @@ Describe 'Shared hook and hosted policy verification' { Should -Invoke New-AzSubscriptionDeployment -Times 1 Should -Invoke Remove-AzResourceGroup -Times 1 } - It 'allows initial provisioning in the validated alternative when the primary fails' { + It 'allows a validated alternative when the primary fails' { $script:blockedRegion = 'westeurope' . (Join-Path $automation 'shared-deploy-lab.ps1') -SubscriptionId $subscription -PreferredLocation @('westeurope,northeurope') -AllowedEntraUserIds @('one', 'two') - $script:sharedTags['microhack-adaptive-location'] | Should -Be 'northeurope' $script:sharedTags['microhack-adaptive-regions'] | Should -Be 'northeurope' } It 'fails when no region passes preflight' { @@ -190,18 +188,18 @@ Describe 'Shared hook and hosted policy verification' { { . (Join-Path $automation 'shared-deploy-lab.ps1') -SubscriptionId $subscription -PreferredLocation @('westeurope') -AllowedEntraUserIds @('one') } | Should -Throw $Error $script:registeredProviders | Should -HaveCount 0 - Should -Invoke New-AzSubscriptionDeployment -Times 0 Should -Invoke Update-AzTag -Times 0 } It 'stops fanout when resource tags do not propagate and cleans the probe' { $script:policyTagsValid = $false - { . (Join-Path $automation 'shared-deploy-lab.ps1') -SubscriptionId $subscription -PreferredLocation @('westeurope') -AllowedEntraUserIds @('one', 'two') } | Should -Throw '*verification failed after 30 attempts*' + { . (Join-Path $automation 'shared-deploy-lab.ps1') -SubscriptionId $subscription -PreferredLocation @('westeurope') -AllowedEntraUserIds @('one', 'two') } | + Should -Throw '*verification failed after 30 attempts*' Should -Invoke Remove-AzResourceGroup -Times 1 Should -Invoke Update-AzTag -Times 0 } } -Describe 'Private jq bootstrap' { +Describe 'Retained developer-scenario jq bootstrap' { BeforeEach { $script:jqBin = Join-Path $TestDrive 'bin' $script:jqFault = '' @@ -251,21 +249,55 @@ Describe 'Private jq bootstrap' { } } -Describe 'Participant hook orchestration' { +Describe 'Retained developer-scenario source artifacts' { + It 'retains the complete allowlisted package and valid historical manifest' { + { Assert-AdaptivePackage -LabRoot (Split-Path $automation -Parent) } | Should -Not -Throw + $manifest = Read-AdaptiveSourceManifest -Path (Join-Path $automation 'bootstrap-source.json') + $manifest.files.Count | Should -Be 20 + } + It 'fails with an actionable packaging error' { + { Assert-AdaptivePackage -LabRoot $TestDrive } | Should -Throw '*Verify the pinned source revision*' + } + It 'reports historical pin drift rather than silently accepting changed participant sources' { + { & (Join-Path $automation 'update-bootstrap-source.ps1') -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 ' -ForEach @( + @{ Path = 'iac/sql-databases.yaml' } + @{ Path = 'iac/recipes/sql-server.bicep' } + @{ Path = 'iac/recipes/postgres-kubernetes.bicep' } + @{ Path = 'iac/recipes/trading-schema.sql' } + ) { + $script:missingPackagePath = Join-Path $TestDrive $Path + Mock Test-Path { $LiteralPath -ne $script:missingPackagePath } + { Assert-AdaptivePackage -LabRoot $TestDrive } | Should -Throw "*missing '$Path'*" + } +} + +Describe 'Infrastructure-only participant hook' { BeforeEach { $script:calls = [Collections.Generic.List[string]]::new() - $script:observedHome = '' - $script:observedWorkingRoot = '' - $script:sourceRoot = Split-Path $automation -Parent - $script:downloadRoot = '' - $script:failedDownload = $false - $script:tamperedDownload = $false - $script:failedPhase = '' - $script:jqInstalled = $false $script:location = 'westeurope' - $script:groupTags = @{ adaptiveAppsReady = 'true' } + $script:groupTags = @{ adaptiveAppsInfrastructureReady = 'true' } $script:fallbackLocation = 'northeurope' $script:guestMarker = 'ADAPTIVE_K3S_READY' + $script:aksResult = '{"provisioningState":"Succeeded","exitCode":0}' + $script:bastionResult = '{"state":"Succeeded","sku":"Standard","tunneling":true}' + $script:acrResult = '{"provisioningState":"Succeeded","anonymousPullEnabled":true}' $script:deploymentState = 'Succeeded' $script:deploymentThrows = $false $script:useDeploymentJob = $false @@ -274,28 +306,14 @@ Describe 'Participant hook orchestration' { $script:oldAzureConfig = $env:AZURE_CONFIG_DIR $script:originalHome = $env:HOME $script:originalPath = $env:PATH + $script:originalKubeconfig = $env:KUBECONFIG $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' } - } + $env:KUBECONFIG = '/worker-cluster/must-not-use' + Mock Invoke-WebRequest { throw 'No source or tool downloads are permitted.' } + Mock bash { throw 'No local Bash bootstrap is permitted.' } + Mock kubectl { throw 'The worker Kubernetes context must not be used.' } + Mock rad { throw 'Radius belongs to participants.' } 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 } @@ -305,37 +323,21 @@ Describe 'Participant hook orchestration' { $script:tagWrites.Add($Tag.Clone()) foreach ($key in $Tag.Keys) { $script:groupTags[$key] = $Tag[$key] } } - Mock Invoke-MhhDeploymentWithRegionFallback { - ($PreferredLocations -join ',') | Should -Be 'westeurope,northeurope' - $RgOwnerEntraObjectIds | Should -Not -BeNullOrEmpty - $Tag.adaptiveAppsReady | Should -Be 'false' - $Tag.SecurityControl | Should -Be 'Ignore' - $Tag.CostControl | Should -Be 'Ignore' - $script:location = $script:fallbackLocation - $script:groupTags = $Tag.Clone() - $deployment = New-AzResourceGroupDeployment -Name $DeploymentNamePrefix -ResourceGroupName $ResourceGroupName ` - -TemplateFile $TemplateFile -TemplateParameterFile $TemplateParameterFile -Mode Incremental - @{ Success = $true; LocationUsed = $script:location; DeploymentResult = $deployment } - } Mock New-MhhStablePassword { 'TestOnlyStablePassword123' } Mock New-AzResourceGroupDeployment { - $script:jqInstalled | Should -BeTrue $Mode | Should -Be 'Incremental' $TemplateParameterObject | Should -BeNullOrEmpty - $TemplateParameterFile | Should -Not -BeNullOrEmpty $script:observedParameterFile = $TemplateParameterFile - $TemplateParameterFile | Should -Be (Join-Path $script:observedHome 'scratch/main.parameters.json') [IO.File]::GetUnixFileMode($TemplateParameterFile) | Should -Be ([IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite) [IO.File]::GetUnixFileMode((Split-Path $TemplateParameterFile -Parent)) | Should -Be ([IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute) $parameters = Get-Content -LiteralPath $TemplateParameterFile -Raw | ConvertFrom-Json $parameters.'$schema' | Should -Be 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' - $parameters.contentVersion | Should -Be '1.0.0.0' $parameters.parameters.PSObject.Properties.Name | Should -Not -Contain 'location' $parameters.parameters.adminPassword.value | Should -BeExactly 'TestOnlyStablePassword123' - $serialized = [Management.Automation.PSSerializer]::Serialize($PesterBoundParameters, 10) - $serialized | Should -Not -Match 'TestOnlyStablePassword|]' + [Management.Automation.PSSerializer]::Serialize($PesterBoundParameters, 10) | + Should -Not -Match 'TestOnlyStablePassword|]' if ($script:useDeploymentJob) { $job = Start-Job -ArgumentList $PesterBoundParameters, $script:deploymentThrows -ScriptBlock { param($DeploymentArguments, $Fail) @@ -352,12 +354,9 @@ Describe 'Participant hook orchestration' { Invoke-TestDeployment @DeploymentArguments } try { - $completed = $job | Wait-Job -Timeout 30 - if (-not $completed) { throw 'Deployment serialization test job timed out.' } + if (-not ($job | Wait-Job -Timeout 30)) { throw 'Deployment serialization test job timed out.' } Receive-Job $job -ErrorAction Stop | Should -Be 'Parameter file read successfully in child job' - } finally { - Remove-Job $job -Force - } + } finally { Remove-Job $job -Force } } elseif ($script:deploymentThrows) { throw 'Simulated ARM deployment exception' } @@ -369,6 +368,17 @@ Describe 'Participant hook orchestration' { } } } + Mock Invoke-MhhDeploymentWithRegionFallback { + ($PreferredLocations -join ',') | Should -Be 'westeurope,northeurope' + $RgOwnerEntraObjectIds | Should -Contain 'user' + $Tag.adaptiveAppsInfrastructureReady | Should -Be 'false' + $Tag.SecurityControl | Should -Be 'Ignore' + $script:location = $script:fallbackLocation + $script:groupTags = $Tag.Clone() + $deployment = New-AzResourceGroupDeployment -Name $DeploymentNamePrefix -ResourceGroupName $ResourceGroupName ` + -TemplateFile $TemplateFile -TemplateParameterFile $TemplateParameterFile -Mode Incremental + @{ Success = $true; LocationUsed = $script:location; DeploymentResult = $deployment } + } Mock az { $global:LASTEXITCODE = 0 $script:calls.Add("az $args") @@ -376,7 +386,7 @@ Describe 'Participant hook orchestration' { { $_ -like 'account show *' } { "{`"id`":`"$subscription`"}"; break } { $_ -like 'role assignment list *' } { "$args" | Should -Match '--fill-principal-name false' - "$args" | Should -Match '--scope ' + "$args" | Should -Match "--scope /subscriptions/$subscription/resourceGroups/MC_lab-one_aks-adaptive-apps_$script:location" "$args" | Should -Not -Match '--all\b' '[]'; break } @@ -386,244 +396,158 @@ Describe 'Participant hook orchestration' { '{}'; break } { $_ -like 'vm run-command invoke *' } { - "{`"value`":[{`"message`":`"[stdout]\n$script:guestMarker\n`"}]}" - break + Test-Path -LiteralPath $script:observedParameterFile | Should -BeFalse + "$args" | Should -Match '--name vm-adaptive-apps-k3s' + "{`"value`":[{`"message`":`"[stdout]\n$script:guestMarker\n`"}]}"; break } + { $_ -like 'aks command invoke *' } { + "$args" | Should -Match '--resource-group lab-one --name aks-adaptive-apps' + "$args" | Should -Match 'kubectl wait --for=condition=Ready nodes --all --timeout=5m' + $script:aksResult; break + } + { $_ -like 'network bastion show *' } { $script:bastionResult; break } + { $_ -like 'acr show *' } { $script:acrResult; break } default { throw "Unexpected Azure call: $args" } } } - Mock bash { - $script:jqInstalled | Should -BeTrue - Test-Path -LiteralPath $script:observedParameterFile | Should -BeFalse - $script:calls[-1] | Should -Be 'refresh' - $script:calls.Add("bash $args") - $script:observedHome = $env:HOME - $env:HOME.StartsWith("$script:sourceRoot/") | Should -BeFalse - $workingPath = (Get-Location).Path - $workingPath | Should -Be (Join-Path $env:HOME 'work') - $workingPath | Should -Not -Be $script:sourceRoot - if ($script:observedWorkingRoot) { $workingPath | Should -Be $script:observedWorkingRoot } - $script:observedWorkingRoot = $workingPath - [IO.File]::Exists((Join-Path $workingPath 'resources/bootstrap-console.sh')) | Should -BeTrue - [IO.File]::Exists((Join-Path $workingPath 'iac/bicepconfig.json')) | Should -BeTrue - ([IO.File]::GetUnixFileMode((Join-Path $workingPath 'iac/bicepconfig.json')) -band [IO.UnixFileMode]::UserWrite) | Should -Not -Be 0 - (Get-FileHash (Join-Path $workingPath 'iac/bicepconfig.json')).Hash | - Should -Be (Get-FileHash (Join-Path $script:sourceRoot 'iac/bicepconfig.json')).Hash - New-Item -ItemType Directory -Path 'artifacts' -Force | Out-Null - Set-Content -LiteralPath 'artifacts/console-mock-marker' -Value 'Generated only in private working copy' - $env:HOME | Should -Not -Be $script:originalHome - $env:PATH | Should -BeLike "$env:HOME/.local/bin:*" - $env:AZURE_CONFIG_DIR | Should -Be (Join-Path $TestDrive 'platform-azure') - $env:REGION | Should -Be $script:location - $env:AZURE_LOCATION | Should -Be $script:location - $env:BASTION_NAME | Should -Be 'bas-adaptive-apps' - [int]$env:K3S_LOCAL_PORT | Should -BeGreaterThan 0 - $env:K3S_KUBECONFIG | Should -BeLike "$env:HOME/*" - $env:K3S_TUNNEL_STATE_DIR | Should -BeLike "$env:HOME/*" - $global:LASTEXITCODE = if ($script:failedPhase -and "$args" -like "*$script:failedPhase") { 13 } else { 0 } - } } AfterEach { $env:HOME | Should -Be $script:originalHome $env:PATH | Should -Be $script:originalPath - if ($script:observedHome) { Test-Path -LiteralPath $script:observedHome | Should -BeFalse } - if ($script:observedParameterFile) { Test-Path -LiteralPath $script:observedParameterFile | Should -BeFalse } - [IO.File]::Exists((Join-Path $script:sourceRoot 'artifacts/console-mock-marker')) | Should -BeFalse + $env:KUBECONFIG | Should -Be '/worker-cluster/must-not-use' + if ($script:observedParameterFile) { + Test-Path -LiteralPath $script:observedParameterFile | Should -BeFalse + Test-Path -LiteralPath (Split-Path $script:observedParameterFile -Parent) | Should -BeFalse + } + Should -Invoke Invoke-WebRequest -Times 0 + Should -Invoke bash -Times 0 + Should -Invoke kubectl -Times 0 + Should -Invoke rad -Times 0 $env:AZURE_CONFIG_DIR = $script:oldAzureConfig + $env:KUBECONFIG = $script:originalKubeconfig } - It 'installs tools then refreshes before all six phases and marks ready only after verify' { - $result = . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('22222222-2222-2222-2222-222222222222') - $phases = @($script:calls | Where-Object { $_ -like 'bash *' }) - $phases | Should -HaveCount 7 - $phases[0] | Should -Be 'bash resources/install-console-tools.sh' - ($phases[1..6] -join ',') | Should -Be 'bash resources/bootstrap-console.sh aks-radius,bash resources/bootstrap-console.sh k3s-radius,bash resources/bootstrap-console.sh aks-types,bash resources/bootstrap-console.sh k3s-types,bash resources/bootstrap-console.sh recipes,bash resources/bootstrap-console.sh verify' - $script:tagWrites[0].adaptiveAppsReady | Should -Be 'false' - $script:tagWrites[-1].adaptiveAppsReady | Should -Be 'true' - $result.HackboxCredential.name | Should -Contain 'Adaptive Apps Connect' + It 'verifies infrastructure without Radius and hands participants off to Challenge 03' { + $result = . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') + $script:tagWrites[0].adaptiveAppsInfrastructureReady | Should -Be 'false' + $script:tagWrites[-1].adaptiveAppsInfrastructureReady | Should -Be 'true' + $script:groupTags.adaptiveAppsReady | Should -Be 'false' + $script:groupTags.adaptiveAppsPreserve | Should -Be 'true' + ($result.HackboxCredential | Where-Object name -eq 'Adaptive Apps Connect').note | Should -Match 'Challenge 03' ($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' - { . (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 'uses validated fallback regions only for initial infrastructure and bootstraps in the resulting RG region' { + It 'uses the fallback RG region without hardcoding template location' { $script:groupTags = @{} $result = . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') - Should -Invoke Invoke-MhhDeploymentWithRegionFallback -Times 1 -Exactly -ParameterFilter { - $RgOwnerEntraObjectIds -contains 'user' -and $DeploymentNamePrefix -eq 'adaptive-apps-console' - } + Should -Invoke Invoke-MhhDeploymentWithRegionFallback -Times 1 -Exactly ($result.HackboxCredential | Where-Object name -eq 'Adaptive Apps Region').value | Should -Be 'northeurope' - $script:groupTags.adaptiveAppsPreserve | Should -Be 'true' } - It 'never invokes destructive fallback for a previously ready lab even after a failed retry' { + It 'preserves old developer-ready labs and keeps protection after a failed retry' { + $script:groupTags = @{ adaptiveAppsReady = 'true' } $script:deploymentThrows = $true - { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | - Should -Throw '*Simulated ARM deployment exception*' + 1..2 | ForEach-Object { + { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | + Should -Throw '*Simulated ARM deployment exception*' + } $script:groupTags.adaptiveAppsReady | Should -Be 'false' $script:groupTags.adaptiveAppsPreserve | Should -Be 'true' - $script:downloadRoot = '' - { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | - Should -Throw '*Simulated ARM deployment exception*' Should -Invoke Invoke-MhhDeploymentWithRegionFallback -Times 0 } - It 'preserves initial infrastructure after a bootstrap failure on subsequent retries' { + It 'preserves newly deployed infrastructure after readiness failure on later retries' { $script:groupTags = @{} - $script:fallbackLocation = 'westeurope' - $script:failedPhase = 'recipes' + $script:guestMarker = 'not-ready' { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | - Should -Throw '*exit code 13*' + Should -Throw '*readiness marker*' $script:groupTags.adaptiveAppsPreserve | Should -Be 'true' - $script:downloadRoot = '' - $script:observedWorkingRoot = '' - $script:failedPhase = '' + $script:guestMarker = 'ADAPTIVE_K3S_READY' . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') | Out-Null Should -Invoke Invoke-MhhDeploymentWithRegionFallback -Times 1 -Exactly } - It 'propagates exhausted fallback without bootstrap or readiness' { - $script:groupTags = @{} - Mock Invoke-MhhDeploymentWithRegionFallback { throw 'RegionFallbackExhausted' } + It 'fails closed on without declaring infrastructure ready' -ForEach @( + @{ Failure = 'K3s'; Error = '*readiness marker*' } + @{ Failure = 'AKS exit'; Error = '*AKS node readiness failed*' } + @{ Failure = 'AKS missing exit'; Error = '*AKS node readiness failed*' } + @{ Failure = 'AKS state'; Error = '*AKS node readiness failed*' } + @{ Failure = 'Bastion'; Error = '*Bastion must be provisioned*' } + @{ Failure = 'ACR'; Error = '*recipe registry must be provisioned*' } + ) { + switch ($Failure) { + 'K3s' { $script:guestMarker = 'not-ready' } + 'AKS exit' { $script:aksResult = '{"provisioningState":"Succeeded","exitCode":1}' } + 'AKS missing exit' { $script:aksResult = '{"provisioningState":"Succeeded"}' } + 'AKS state' { $script:aksResult = '{"provisioningState":"Failed","exitCode":0}' } + 'Bastion' { $script:bastionResult = '{"state":"Succeeded","sku":"Standard","tunneling":false}' } + 'ACR' { $script:acrResult = '{"provisioningState":"Failed"}' } + } { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | - Should -Throw '*RegionFallbackExhausted*' - Should -Invoke bash -Times 0 - $script:groupTags.adaptiveAppsReady | Should -Be 'false' + Should -Throw $Error + $script:groupTags.adaptiveAppsInfrastructureReady | Should -Be 'false' } - It 'passes deployment arguments through a real child job without serializing a SecureString' { + It 'preserves ARM exceptions and private file cleanup across a real child job' -ForEach @( + @{ Fail = $false } + @{ Fail = $true } + ) { $script:useDeploymentJob = $true - $result = . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') - $result.HackboxCredential.name | Should -Contain 'Adaptive Apps Connect' - ($result | ConvertTo-Json -Depth 5) | Should -Not -Match 'TestOnlyStablePassword' - Should -Invoke New-AzResourceGroupDeployment -Times 1 -Exactly + $script:deploymentThrows = $Fail + if ($Fail) { + { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | + Should -Throw '*Simulated ARM deployment exception*' + } else { + . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') | Out-Null + } } - It 'preserves deployment exceptions and removes private parameters on failure' -ForEach @( - @{ ChildJob = $false } - @{ ChildJob = $true } - ) { - $script:useDeploymentJob = $ChildJob - $script:deploymentThrows = $true + It 'propagates exhausted fallback without readiness checks' { + $script:groupTags = @{} + Mock Invoke-MhhDeploymentWithRegionFallback { throw 'RegionFallbackExhausted' } { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | - Should -Throw '*Simulated ARM deployment exception*' - $script:observedParameterFile | Should -Not -BeNullOrEmpty - Test-Path -LiteralPath $script:observedParameterFile | Should -BeFalse - @($script:tagWrites | Where-Object { $_.adaptiveAppsReady -eq 'true' }) | Should -HaveCount 0 - Should -Invoke bash -Times 0 + Should -Throw '*RegionFallbackExhausted*' + @($script:calls | Where-Object { $_ -like 'az vm run-command *' }) | Should -HaveCount 0 } - 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 - $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 $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) - ($after.Path -join "`n") | Should -Be ($before.Path -join "`n") - ($after.Hash -join "`n") | Should -Be ($before.Hash -join "`n") - } finally { - & chmod -R u+rwX $readOnlyRoot - if ($LASTEXITCODE -ne 0) { throw 'Unable to restore test fixture permissions for cleanup.' } - } + It 'stops on failed ARM state' { + $script:deploymentState = 'Failed' + { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | + Should -Throw '*Infrastructure deployment failed*' + $script:groupTags.adaptiveAppsInfrastructureReady | Should -Be 'false' } - It 'rejects unvalidated RG region before deployment without deleting the scope' { + It 'rejects unvalidated regions for protected labs without deleting them' { $script:location = 'eastus' - { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | Should -Throw '*No validated region*' - Should -Invoke New-AzResourceGroupDeployment -Times 0 - Should -Invoke Invoke-MhhDeploymentWithRegionFallback -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 -Throw '*No validated region*' + Should -Invoke Invoke-MhhDeploymentWithRegionFallback -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 + It 'requires an absolute isolated Azure CLI profile' { + $env:AZURE_CONFIG_DIR = 'relative-profile' { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | - Should -Throw '*SHA-256 verification failed*' + Should -Throw '*absolute, isolated AZURE_CONFIG_DIR*' 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 '*Bootstrap will not run*' - Should -Invoke Invoke-MhhDeploymentWithRegionFallback -Times 0 - Should -Invoke bash -Times 0 - } - It 'requires guest readiness, not just Run Command HTTP success' { - $script:guestMarker = 'failed' - { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | Should -Throw '*readiness marker*' - Should -Invoke bash -Times 0 } - It 'requires an absolute platform Azure configuration directory' { - $env:AZURE_CONFIG_DIR = 'relative-profile' - { . (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 'runs from a read-only lab-only folder without a checkout or source downloads' { + $consoleLab = Join-Path $TestDrive 'lab' + Copy-Item -LiteralPath $automation -Destination $consoleLab -Recurse + $before = @(Get-ChildItem $consoleLab -Recurse -File | Sort-Object FullName | Get-FileHash) + try { + & chmod -R a-w $consoleLab + if ($LASTEXITCODE -ne 0) { throw 'Unable to protect test fixture.' } + . (Join-Path $consoleLab 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') | Out-Null + $after = @(Get-ChildItem $consoleLab -Recurse -File | Sort-Object FullName | Get-FileHash) + ($after.Path -join "`n") | Should -Be ($before.Path -join "`n") + ($after.Hash -join "`n") | Should -Be ($before.Hash -join "`n") + } finally { + & chmod -R u+rwX $consoleLab + if ($LASTEXITCODE -ne 0) { throw 'Unable to restore test fixture.' } + } } } -Describe 'Portable packaging and source contracts' { - It 'fails with an actionable packaging error' { - { 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' } - @{ Path = 'iac/recipes/sql-server.bicep' } - @{ Path = 'iac/recipes/postgres-kubernetes.bicep' } - @{ Path = 'iac/recipes/trading-schema.sql' } - ) { - $script:missingPackagePath = Join-Path $TestDrive $Path - Mock Test-Path { $LiteralPath -ne $script:missingPackagePath } - { Assert-AdaptivePackage -LabRoot $TestDrive } | Should -Throw "*missing '$Path'*" +Describe 'Source and infrastructure contracts' { + BeforeAll { + $bicep = if ($env:BICEP_COMMAND) { $env:BICEP_COMMAND } else { (Get-Command bicep -ErrorAction Stop).Source } + $templateText = & $bicep build (Join-Path $automation 'main.bicep') --stdout + if ($LASTEXITCODE -ne 0) { throw 'Bicep compilation failed.' } + $template = ($templateText -join "`n") | ConvertFrom-Json + $policyText = & $bicep build (Join-Path $automation 'infra/hosted-tag-policy.bicep') --stdout + if ($LASTEXITCODE -ne 0) { throw 'Hosted policy Bicep compilation failed.' } + $policy = ($policyText -join "`n") | ConvertFrom-Json } It 'parses all PowerShell entry points' { Get-ChildItem $automation -Filter '*.ps1' | ForEach-Object { @@ -633,27 +557,12 @@ Describe 'Portable packaging and source contracts' { $errors | Should -BeNullOrEmpty } } - It 'uses configured platform defaults and fails when no region is ready' { + It 'keeps defaults and no-region preflight guard' { $defaults = Get-Content (Join-Path $automation 'lab-defaults.json') -Raw | ConvertFrom-Json $defaults.deploymentType | Should -Be 'resourcegroup' $defaults.labsPerSubscription | Should -Be 5 - $defaults.preferredLocation | Should -Be 'swedencentral,spaincentral' - $shared = Get-Content (Join-Path $automation 'shared-deploy-lab.ps1') -Raw - $shared | Should -Match '\$ready.Count -eq 0' - $shared | Should -Match 'microhack-adaptive-regions' - $shared | Should -Not -Match 'az ad |AzureStackHCI|LocalBox|Confidential' - } -} - -Describe 'Bicep offline validation' { - BeforeAll { - $bicep = if ($env:BICEP_COMMAND) { $env:BICEP_COMMAND } else { (Get-Command bicep -ErrorAction Stop).Source } - $templateText = & $bicep build (Join-Path $automation 'main.bicep') --stdout - if ($LASTEXITCODE -ne 0) { throw 'Bicep compilation failed.' } - $template = ($templateText -join "`n") | ConvertFrom-Json - $policyText = & $bicep build (Join-Path $automation 'infra/hosted-tag-policy.bicep') --stdout - if ($LASTEXITCODE -ne 0) { throw 'Hosted policy Bicep compilation failed.' } - $policy = ($policyText -join "`n") | ConvertFrom-Json + $defaults.preferredLocation | Should -Be 'spaincentral,swedencentral' + Get-Content (Join-Path $automation 'shared-deploy-lab.ps1') -Raw | Should -Match '\$ready.Count -eq 0' } It 'keeps two dedicated D4s nodes, one D4s VM, managed Istio and workload identity' { $aks = $template.resources | Where-Object type -eq 'Microsoft.ContainerService/managedClusters' @@ -663,11 +572,10 @@ Describe 'Bicep offline validation' { $aks.properties.oidcIssuerProfile.enabled | Should -BeTrue $aks.properties.securityProfile.workloadIdentity.enabled | Should -BeTrue $aks.properties.serviceMeshProfile.mode | Should -Be 'Istio' - $aks.properties.serviceMeshProfile.PSObject.Properties.Name | Should -Contain 'istio' $aks.properties.serviceMeshProfile.istio | Should -BeOfType [pscustomobject] - $vm = $template.resources | Where-Object type -eq 'Microsoft.Compute/virtualMachines' - $vm.properties.hardwareProfile.vmSize | Should -Be 'Standard_D4s_v5' + ($template.resources | Where-Object type -eq 'Microsoft.Compute/virtualMachines').properties.hardwareProfile.vmSize | Should -Be 'Standard_D4s_v5' $template.parameters.adminPassword.type | Should -Be 'securestring' + $template.parameters.location.defaultValue | Should -Be '[resourceGroup().location]' } It 'provides private NIC, explicit NAT, and Standard tunneling Bastion' { $nic = $template.resources | Where-Object type -eq 'Microsoft.Network/networkInterfaces' @@ -676,7 +584,6 @@ Describe 'Bicep offline validation' { $vnet.properties.subnets[0].properties.defaultOutboundAccess | Should -BeFalse $vnet.properties.subnets[0].properties.natGateway.id | Should -Not -BeNullOrEmpty $bastion = $template.resources | Where-Object type -eq 'Microsoft.Network/bastionHosts' - $bastion.name | Should -Be 'bas-adaptive-apps' $bastion.sku.name | Should -Be 'Standard' $bastion.properties.enableTunneling | Should -BeTrue } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/connect-console.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/connect-console.sh index 2f3a76619..b384add1e 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/connect-console.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/connect-console.sh @@ -27,15 +27,15 @@ cleanup() { exit "$result" } trap cleanup EXIT -for command_name in kubectl rad jq curl; do +for command_name in kubectl jq; do command -v "$command_name" >/dev/null || { echo "Missing ${command_name}; complete Challenge 1 workstation setup." >&2 exit 1 } done group="$(az group show --name "$RESOURCE_GROUP" --output json)" -if [[ "$(jq -r '.tags.adaptiveAppsReady' <<<"$group")" != "true" ]]; then - echo "This lab has not completed Console bootstrap. Ask the organizer to check the deployment." >&2 +if [[ "$(jq -r '.tags.adaptiveAppsInfrastructureReady' <<<"$group")" != "true" ]]; then + echo "This lab has not completed Console infrastructure verification. Ask the organizer to check the deployment." >&2 exit 1 fi ACR_NAME="$(jq -er '.tags.adaptiveAppsAcr | select(type == "string" and length > 0)' <<<"$group")" @@ -44,26 +44,22 @@ export AZURE_LOCATION AZURE_LOCATION="$(jq -er '.location' <<<"$group")" export K3S_LOCAL_PORT="${K3S_LOCAL_PORT:-16443}" export K3S_REFRESH_KUBECONFIG=true +export RADIUS_IDENTITY_MODE=managedidentity +export RADIUS_REINSTALL=false mkdir -p artifacts rm -f artifacts/console-env.sh connect_aks -select_workspace ws-azure-prod "$AKS_CONTEXT" env-azure-prod -verify_environment ws-azure-prod env-azure-prod tunnel_attempted=true connect_k3s -select_workspace ws-local-prod "$K3S_CONTEXT" env-local-prod -verify_environment ws-local-prod env-local-prod -build_local_types unset KUBECONFIG kubectl config use-context "$AKS_CONTEXT" >/dev/null -rad workspace switch ws-azure-prod environment_file="$(mktemp artifacts/console-env.XXXXXX)" printf 'unset KUBECONFIG\n' >"$environment_file" for name in AZURE_SUBSCRIPTION RESOURCE_GROUP AZURE_LOCATION ACR_NAME AKS_CLUSTER AKS_CLUSTER_NAME \ AKS_CONTEXT K3S_CONTEXT K3S_VM_NAME BASTION_NAME K3S_KUBECONFIG K3S_LOCAL_PORT \ - K3S_TUNNEL_STATE_DIR RADIUS_GROUP; do + K3S_TUNNEL_STATE_DIR RADIUS_GROUP RADIUS_IDENTITY_MODE RADIUS_REINSTALL; do declare -p "$name" | sed 's/^declare -x /export /; s/^declare -- /export /' >>"$environment_file" done mv "$environment_file" artifacts/console-env.sh @@ -71,4 +67,5 @@ echo "Connected to both platforms. The Bastion API tunnel is running in the back echo "In each new Bash terminal: source artifacts/console-env.sh" echo "Reconnect after restarting your workstation: bash resources/connect-console.sh" echo "Stop the tunnel: bash resources/prepare-k3s-azure-vm.sh disconnect" -echo "Continue with Challenge 06. No platform resources or recipes were redeployed." +echo "Continue with Challenge 03 to install Radius, then complete Challenges 04-05." +echo "No Radius control planes, workspaces, resource types or recipes were installed or changed." diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/tests/test_console_helpers.py b/03-Azure/01-01-App Innovation/04-adaptive-apps/tests/test_console_helpers.py index 6233bbb79..6f41422ce 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/tests/test_console_helpers.py +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/tests/test_console_helpers.py @@ -61,7 +61,8 @@ def kubeconfig(name): sys.exit(90) elif args[:2] == ["group", "show"]: print(json.dumps({"location": "westeurope", "tags": { - "adaptiveAppsReady": os.environ.get("READY", "true"), + "adaptiveAppsInfrastructureReady": os.environ.get("READY", "true"), + "adaptiveAppsReady": os.environ.get("LEGACY_READY", "false"), "adaptiveAppsAcr": "acrtest"}})) elif args[:2] == ["aks", "get-credentials"]: Path(arg("--file")).write_text(json.dumps(kubeconfig("aks-adaptive-apps"))) @@ -182,7 +183,7 @@ def assert_no_provisioning(self): self.assertNotIn(command[1], ("install", "deploy")) self.assertNotEqual(command[1:3], ["resource-type", "create"]) - def test_fresh_participant_gets_both_contexts_local_types_and_quoted_environment(self): + def test_fresh_participant_gets_both_contexts_and_quoted_environment_without_radius(self): self.env["RESOURCE_GROUP"] = "rg-test-$HOME" result = self.run_script("connect-console.sh") self.assert_no_provisioning() @@ -192,7 +193,14 @@ def test_fresh_participant_gets_both_contexts_local_types_and_quoted_environment ["bash", "-c", 'source artifacts/console-env.sh; printf "%s" "$RESOURCE_GROUP"'], cwd=self.lab, env=self.env, text=True) self.assertEqual(value, "rg-test-$HOME") - self.assertTrue((self.lab / "artifacts/types.tgz").is_file()) + self.assertFalse((self.lab / "artifacts/types.tgz").exists()) + self.assertFalse(any(c[0] == "rad" for c in self.commands())) + self.assertFalse(any("radius-system" in c or "core" in c for c in self.commands())) + self.assertIn("Challenge 03", result.stdout) + identity_mode = subprocess.check_output( + ["bash", "-c", 'source artifacts/console-env.sh; printf "%s:%s" "$RADIUS_IDENTITY_MODE" "$RADIUS_REINSTALL"'], + cwd=self.lab, env=self.env, text=True) + self.assertEqual(identity_mode, "managedidentity:false") merged = subprocess.check_output( [KUBECTL, "config", "view", "--raw", "--output", "json"], env={**self.env, "KUBECONFIG": str(self.home / ".kube/config")}, text=True) @@ -210,12 +218,15 @@ def test_failed_authentication_never_opens_interactive_login(self): self.run_script("connect-console.sh", success=False, AUTH_FAIL="true") self.assertFalse(any(c[:2] == ["az", "login"] for c in self.commands())) - def test_missing_default_recipe_fails_without_publishing_environment(self): - self.run_script("connect-console.sh", success=False, RECIPE_NAME="not-default") - self.assertFalse((self.lab / "artifacts/console-env.sh").exists()) + def test_old_ready_tag_does_not_substitute_for_infrastructure_verification(self): + self.run_script("connect-console.sh", success=False, READY="false", LEGACY_READY="true") + self.assertFalse(any(c[1:3] == ["aks", "get-credentials"] for c in self.commands())) - def test_empty_core_portfolio_fails(self): - self.run_script("connect-console.sh", success=False, CORE_OBJECTS="") + def test_reconnect_preserves_existing_radius_state_and_does_not_require_recipes(self): + self.run_script("connect-console.sh", CORE_OBJECTS="", RECIPE_JSON="[]", + FAIL_COMMAND="rad workspace") + self.assertFalse(any(c[0] == "rad" for c in self.commands())) + self.assertTrue((self.lab / "artifacts/console-env.sh").is_file()) def test_recipe_verifier_rejects_empty_wrong_name_and_invalid_json(self): for payload in ("[]", "null", "invalid", '[{"resourceType":"Radius.Resources/mqttBrokers"}]'): @@ -227,50 +238,73 @@ def test_valid_default_recipe_passes(self): self.run_script("verify-recipes.sh", "ws-local-prod", "env-local-prod", "Radius.Resources/mqttBrokers") - def test_unknown_phase_is_rejected_before_azure_calls(self): + def test_unknown_bootstrap_phase_is_rejected_before_azure_calls(self): self.run_script("bootstrap-console.sh", "wrong", success=False) self.assertEqual(self.commands(), []) - def test_managed_identity_bootstrap_never_uses_graph_or_reinstalls_existing_radius(self): + def test_retained_aks_bootstrap_preserves_existing_radius_without_graph(self): self.run_script("bootstrap-console.sh", "aks-radius") commands = self.commands() self.assertFalse(any(c[:2] == ["az", "ad"] for c in commands)) self.assertFalse(any(c[:2] == ["rad", "install"] for c in commands)) self.assertTrue(any(c[1:3] == ["identity", "federated-credential"] for c in commands)) + + def test_retained_k3s_bootstrap_refreshes_kubeconfig_and_cleans_tunnel(self): + self.run_script("bootstrap-console.sh", "k3s-radius") + self.assertTrue((self.home / ".kube/adaptive-apps-k3s.yaml").is_file()) + self.assertFalse((self.home / ".kube/adaptive-apps-bastion/tunnel.pid").exists()) + self.assertFalse(any(c[:2] == ["rad", "install"] for c in self.commands())) + + def test_managed_identity_bootstrap_never_uses_graph_or_reinstalls_existing_radius(self): + self.run_script("connect-console.sh") + self.run_script("deploy-radius-aks.sh", RADIUS_REINSTALL="false") + commands = self.commands() + self.assertFalse(any(c[:2] == ["az", "ad"] for c in commands)) + self.assertFalse(any(c[:2] == ["rad", "install"] for c in commands)) + self.assertTrue(any(c[1:3] == ["identity", "federated-credential"] for c in commands)) self.assertTrue(any(c[1:4] == ["credential", "register", "azure"] for c in commands)) def test_missing_radius_is_installed_on_first_run(self): - self.run_script("bootstrap-console.sh", "aks-radius", MOCK_RADIUS_NAMESPACE="") + self.run_script("connect-console.sh") + self.run_script("deploy-radius-aks.sh", MOCK_RADIUS_NAMESPACE="", RADIUS_REINSTALL="false") installs = [c for c in self.commands() if c[:2] == ["rad", "install"]] self.assertEqual(len(installs), 1) self.assertNotIn("--reinstall", installs[0]) def test_namespace_lookup_error_is_not_interpreted_as_absence(self): - self.run_script("bootstrap-console.sh", "aks-radius", success=False, + self.run_script("connect-console.sh") + self.run_script("deploy-radius-aks.sh", success=False, RADIUS_REINSTALL="false", FAIL_COMMAND="kubectl get namespace") self.assertFalse(any(c[:2] == ["rad", "install"] for c in self.commands())) - def test_k3s_bootstrap_refreshes_missing_kubeconfig_and_cleans_tunnel(self): - self.run_script("bootstrap-console.sh", "k3s-radius") + def test_k3s_connection_refreshes_missing_kubeconfig_without_installing_radius(self): + self.run_script("connect-console.sh") self.assertTrue((self.home / ".kube/adaptive-apps-k3s.yaml").is_file()) + self.run_script("prepare-k3s-azure-vm.sh", "disconnect") self.assertFalse((self.home / ".kube/adaptive-apps-bastion/tunnel.pid").exists()) self.assertFalse(any(c[:2] == ["rad", "install"] for c in self.commands())) def test_refresh_replaces_stale_default_context_credentials(self): - self.run_script("bootstrap-console.sh", "k3s-radius") + self.run_script("connect-console.sh") default_file = self.home / ".kube/config" default_file.write_text(default_file.read_text().replace("mock-token-not-a-credential", "old-token")) self.run_script("prepare-k3s-azure-vm.sh", "connect", K3S_REFRESH_KUBECONFIG="true") - self.assertNotIn("old-token", default_file.read_text()) - self.assertIn("mock-token-not-a-credential", default_file.read_text()) + config = json.loads(subprocess.check_output( + [KUBECTL, "config", "view", "--raw", "--output", "json"], + env={**self.env, "KUBECONFIG": str(default_file)}, text=True)) + users = {user["name"]: user["user"]["token"] for user in config["users"]} + self.assertEqual(users["default"], "mock-token-not-a-credential") + self.assertEqual(users["aks-adaptive-apps"], "old-token") def test_failure_after_tunnel_start_stops_only_recorded_tunnel(self): - self.run_script("connect-console.sh", success=False, FAIL_COMMAND="rad bicep publish-extension") + self.run_script("connect-console.sh", success=False, FAIL_COMMAND="kubectl --request-timeout=15s get") self.assertFalse((self.lab / "artifacts/console-env.sh").exists()) self.assertFalse((self.home / ".kube/adaptive-apps-bastion/tunnel.pid").exists()) def test_managed_identity_lookup_error_is_not_interpreted_as_absence(self): - self.run_script("bootstrap-console.sh", "aks-radius", success=False, FAIL_COMMAND="az identity list") + self.run_script("connect-console.sh") + self.run_script("deploy-radius-aks.sh", success=False, RADIUS_REINSTALL="false", + FAIL_COMMAND="az identity list") self.assertFalse(any(c[:3] == ["az", "identity", "create"] for c in self.commands())) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-01/solution-01.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-01/solution-01.md index 20339b121..800103779 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-01/solution-01.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-01/solution-01.md @@ -4,17 +4,14 @@ ## Coach notes -**Hosted path:** use Tasks 2-4 in the student challenge, then +**Hosted path:** complete Tasks 2-4 in the student challenge, then follow [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md). -Participants use Bash and their own Azure sign-in; skip provider registration, -extra-RG preflight, and provisioning/install steps 02-05. A ready hosted lab -includes recipes, but each participant still needs the connection helper's local -contexts, workspaces, and `artifacts/types.tgz`. Source `artifacts/console-env.sh` -in every new Bash terminal, then start 06. The remaining setup is the manual path. +Validate the supplied platforms in Challenge 02 instead of provisioning them, +then continue with Challenges 03-06. Challenge 01 verifies prerequisites. Participants should arrive with most tools installed or be prepared to install them quickly. Coaches should unblock environment -issues so teams can proceed to Challenge 02, where they provision AKS and K3s. +issues so teams can proceed to Challenge 02 to provision or explore AKS and K3s. Share these instructions one or two weeks before the MicroHack when possible. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md index 0a5a158b6..fadbc3100 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md @@ -8,7 +8,9 @@ Duration: 30-60 minutes **Hosted Console:** do not run this provisioning walkthrough on a ready lab. Use [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md) -after Challenge 01 and continue at 06. The steps below are the manual platform path. +after Challenge 01. Complete the read-only platform validation and context-switching +checks, then continue at **03** to install Radius. Skip all infrastructure creation +and K3s installation steps; these are supplied. Keep Console's resource names/region. Complete [Challenge 01](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/walkthrough/challenge-01/solution-01.md) and the [general prerequisites](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/Readme.md#general-prerequisites) before starting. @@ -202,7 +204,9 @@ Each script exits with a nonzero status when its environment is unhealthy. Check cover Azure provisioning, the active context, Kubernetes API readiness, node readiness, AKS identity features, and the K3s service. -Repeat the Kubernetes checks manually: +Hosted participants source `artifacts/console-env.sh` first; do not replace its +resource names with the provisioning examples above. Repeat the Kubernetes checks +manually: ```bash # AKS @@ -213,7 +217,7 @@ kubectl wait --for=condition=Ready nodes --all --timeout=10m kubectl get nodes -o wide # K3s -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm @@ -238,8 +242,9 @@ into Azure management; it does not create the cluster and is not required by Rad ## Completion criteria -- Both scripts finish successfully. +- BYOS: both provisioning scripts finish successfully. Hosted: the connection helper + succeeds without reprovisioning the supplied infrastructure. - Coaches can switch explicitly between AKS and K3s. - Both Kubernetes APIs and all nodes are healthy. - The K3s VM is private and reachable only through the localhost Bastion tunnel. -- No Radius control plane, portfolio, recipes, or application workloads are installed. +- The platforms are ready for Radius installation in Challenge 03. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md index c1cc47dda..9ea19ef85 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md @@ -6,10 +6,9 @@ Duration: 30-45 minutes per environment ## Coach notes -**Hosted Console:** Radius and its identities are already configured. Use -[Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md) -for participant-local workspaces; do not repeat the installation or tenant app setup -below. Continue at 06; these steps remain the manual platform-engineer path. +**Hosted Console:** source `artifacts/console-env.sh` and use the supplied +resource names. Install Radius below, selecting **Option A: managed identity** +for Azure workload identity. - The goal is to install, configure, and explore Radius on both default platforms. - The default uses a **federated model**: one control plane on AKS and one on K3s. @@ -21,7 +20,7 @@ below. Continue at 06; these steps remain the manual platform-engineer path. Before any K3s work after a devcontainer restart, restore the private API tunnel: ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" ``` @@ -136,16 +135,19 @@ unset KUBECONFIG kubectl config use-context aks-adaptive-apps kubectl config current-context kubectl get nodes +kubectl get namespace radius-system --ignore-not-found ``` ### 2. Prepare Azure workload identity -Set the Azure values: +Hosted participants first source `artifacts/console-env.sh`; the defaults below +preserve those supplied values. BYOS participants set their own subscription and +resource group. Do not replace a Console resource group with `rg-adaptive-apps`. ```bash -export AZURE_SUBSCRIPTION="" -export RESOURCE_GROUP="rg-adaptive-apps" -export AKS_CLUSTER="aks-adaptive-apps" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +export AKS_CLUSTER="${AKS_CLUSTER:-aks-adaptive-apps}" export RADIUS_APP_NAME="${AKS_CLUSTER}-radius-app" az account set --subscription "$AZURE_SUBSCRIPTION" @@ -157,7 +159,55 @@ export OIDC_ISSUER="$(az aks show \ export TENANT_ID="$(az account show --query tenantId --output tsv)" ``` -Create an Entra application and service principal: +Choose **one** identity option. Both use AKS OIDC federation with no client secret. +See Microsoft's [AKS workload identity guide](https://learn.microsoft.com/azure/aks/workload-identity-deploy-cluster) +for the managed identity and federated credential commands. + +#### Option A: user-assigned managed identity (required for hosted Console) + +Create the identity inside the supplied lab resource group. These are Azure Resource +Manager operations, not tenant app/SP creation. The explicit principal object ID +in the role assignment below avoids a Microsoft Graph lookup. + +```bash +export RADIUS_MANAGED_IDENTITY_NAME="${AKS_CLUSTER}-radius" +export AZURE_LOCATION="$(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" --query location --output tsv)" + +az identity create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RADIUS_MANAGED_IDENTITY_NAME" \ + --location "$AZURE_LOCATION" --output none + +export IDENTITY_CLIENT_ID="$(az identity show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RADIUS_MANAGED_IDENTITY_NAME" --query clientId --output tsv)" +export IDENTITY_PRINCIPAL_ID="$(az identity show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RADIUS_MANAGED_IDENTITY_NAME" --query principalId --output tsv)" + +for service_account in applications-rp bicep-de ucp dynamic-rp; do + az identity federated-credential create \ + --resource-group "$RESOURCE_GROUP" \ + --identity-name "$RADIUS_MANAGED_IDENTITY_NAME" \ + --name "radius-${service_account}" \ + --issuer "$OIDC_ISSUER" \ + --subject "system:serviceaccount:radius-system:${service_account}" \ + --audiences api://AzureADTokenExchange --output none +done +``` + +Configure trust for all four Radius service accounts, including **both +`applications-rp` and `bicep-de`**, plus `ucp` and `dynamic-rp`, matching the recovery +script. If an identity/credential already exists, inspect it and update a mismatched +issuer or subject rather than deleting shared state. Then skip Option B and continue +at **Grant the identity access**. + +#### Option B: Entra application (BYOS with tenant permissions only) + +Create an Entra application and service principal. Do not run this option in a +hosted RG-only lab: ```bash export APPLICATION_CLIENT_ID="$(az ad app create \ @@ -170,6 +220,10 @@ export APPLICATION_OBJECT_ID="$(az ad app show \ --output tsv)" az ad sp create --id "$APPLICATION_CLIENT_ID" + +export IDENTITY_CLIENT_ID="$APPLICATION_CLIENT_ID" +export IDENTITY_PRINCIPAL_ID="$(az ad sp show \ + --id "$APPLICATION_CLIENT_ID" --query id --output tsv)" ``` For each Radius service account, create a federated credential. The example below is @@ -192,11 +246,16 @@ az ad app federated-credential create \ --parameters @radius-applications-rp.json ``` -Grant the Radius identity access to the lab resource group: +#### Grant the identity access (both options) + +Grant the Radius identity Owner on the isolated lab resource group only, because +recipes create role assignments. Allow identity/federation/RBAC propagation before +testing Radius; a newly created principal can take time to become available. ```bash az role assignment create \ - --assignee "$APPLICATION_CLIENT_ID" \ + --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \ + --assignee-principal-type ServicePrincipal \ --role Owner \ --scope "/subscriptions/${AZURE_SUBSCRIPTION}/resourceGroups/${RESOURCE_GROUP}" ``` @@ -238,7 +297,7 @@ rad env update env-azure-prod \ rad env switch env-azure-prod rad credential register azure wi \ - --client-id "$APPLICATION_CLIENT_ID" \ + --client-id "$IDENTITY_CLIENT_ID" \ --tenant-id "$TENANT_ID" ``` @@ -247,12 +306,13 @@ rad credential register azure wi \ ### 1. Select and verify K3s ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm kubectl config current-context kubectl get nodes +kubectl get namespace radius-system --ignore-not-found ``` ### 2. Install Radius on K3s @@ -331,12 +391,28 @@ who choose to skip the manual tutorial. ### AKS +For hosted recovery, use the supplied configuration and explicitly select managed +identity mode. Do not use the application-mode default: + +```bash +source artifacts/console-env.sh +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +RADIUS_IDENTITY_MODE=managedidentity RADIUS_REINSTALL=false bash resources/deploy-radius-aks.sh +``` + +The sourced Console configuration exports both settings as well. Keep +`RADIUS_REINSTALL=false` to preserve an existing Radius installation and coordinate +recovery with the team. The following is the BYOS application-mode alternative +(requires tenant permissions); without overrides its script defaults can reinstall +Radius: + ```bash unset KUBECONFIG kubectl config use-context aks-adaptive-apps -export AZURE_SUBSCRIPTION="" -export RESOURCE_GROUP="rg-adaptive-apps" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" export AKS_CLUSTER="aks-adaptive-apps" # Run from the Adaptive Apps MicroHack root. @@ -346,7 +422,7 @@ bash resources/deploy-radius-aks.sh ### K3s ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-04/solution-04.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-04/solution-04.md index 3057b1299..28193129b 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-04/solution-04.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-04/solution-04.md @@ -6,10 +6,11 @@ Duration: 45-75 minutes ## Coach notes -**Hosted Console:** the portfolio and resource types already exist. Use +**Hosted Console:** complete Challenge 03, then install the portfolio and resource +types with the exercises below. [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md) -to regenerate the participant's local `artifacts/types.tgz`, then continue at 06. -Do not rerun the installation/import steps below on the shared hosted baseline. +supplies cluster connections only. Generate each participant's local +`artifacts/types.tgz` here; it is not generated by the connection helper. This challenge makes the platform-engineering story concrete. Teams define the vocabulary that application developers use in later challenges. @@ -31,7 +32,7 @@ registration for participants who need an automated setup. Before any K3s work after a devcontainer restart, restore the localhost API tunnel: ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" ``` @@ -84,7 +85,7 @@ helm upgrade --install "$PORTFOLIO" \ ### Install manually on K3s ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm @@ -297,7 +298,7 @@ For K3s, restore the localhost Bastion API tunnel first. The dedicated kubeconfi to that local endpoint; it does not expose K3s publicly. ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm @@ -511,7 +512,7 @@ bash resources/configure-resource-types-aks.sh ### K3s ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md index c0bc0379f..3591f5687 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md @@ -6,10 +6,9 @@ Duration: 60-90 minutes ## Coach notes -**Hosted Console:** a ready lab already includes published and registered recipes. -Use [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md) -and start 06; do not recreate the ACR or repeat recipe registration. The teaching -sequence below applies to the manual platform-engineer path. +**Hosted Console:** after Challenges 03-04, source `artifacts/console-env.sh`. +Use the supplied ACR, resource group, and region. Skip registry creation; +complete the recipe exercises below. Challenge 04 defined contracts. Challenge 05 implements them. @@ -24,7 +23,7 @@ for setup recovery and participants who explicitly choose not to follow the tuto Before any K3s work after a devcontainer restart, restore the localhost API tunnel: ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" ``` @@ -238,12 +237,21 @@ rad env switch env-azure-prod rad group switch rg-trading ``` -Create an ACR. Its name must be globally unique and lowercase: +**Hosted Console:** reuse the supplied registry; do not run the creation block. +Source and verify your supplied values: ```bash -export AZURE_SUBSCRIPTION="" -export RESOURCE_GROUP="rg-adaptive-apps" -export ACR_NAME="" +source artifacts/console-env.sh +az acr show --resource-group "$RESOURCE_GROUP" --name "$ACR_NAME" \ + --query '{name:name,location:location,sku:sku.name,anonymousPull:anonymousPullEnabled}' +``` + +**BYOS only:** create an ACR. Its name must be globally unique and lowercase: + +```bash +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +export ACR_NAME="${ACR_NAME:-}" az account set --subscription "$AZURE_SUBSCRIPTION" az acr show \ @@ -253,7 +261,11 @@ az acr show \ --resource-group "$RESOURCE_GROUP" \ --name "$ACR_NAME" \ --sku Standard +``` + +**Both paths:** allow anonymous pull of the non-secret recipe artifacts: +```bash az acr update \ --resource-group "$RESOURCE_GROUP" \ --name "$ACR_NAME" \ @@ -494,7 +506,7 @@ rad deploy iac/aks-env.bicep ` Switch both kube context and Radius workspace: ```bash -export AZURE_SUBSCRIPTION="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm @@ -598,9 +610,9 @@ consistently. The manual tutorial is the intended learning path. To automate the same configuration: ```bash -export AZURE_SUBSCRIPTION="" -export RESOURCE_GROUP="rg-adaptive-apps" -export ACR_NAME="" +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +export ACR_NAME="${ACR_NAME:-}" # Configure both defaults: bash resources/configure-recipes.sh all @@ -615,4 +627,3 @@ two corrected PostgreSQL recipes plus the custom SQL recipe, deploys the appropr environment definition, and verifies the registered recipes. `k3s` also needs `AZURE_SUBSCRIPTION`, `RESOURCE_GROUP`, and `ACR_NAME` because it republishes the pinned workshop recipe before registration. - diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md index 2fce72d7d..1cf101f59 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md @@ -6,10 +6,11 @@ Duration: 45-75 minutes ## Coach notes -**Hosted entry point:** complete the workstation portion of Challenge 01 and +**Hosted prerequisites:** complete the workstation portion of Challenge 01 and [Console setup](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-01-App%20Innovation/04-adaptive-apps/docs/console-setup.md). -The ready baseline covers 02-05; the connection helper creates participant-local -contexts/workspaces and `artifacts/types.tgz` without reinstalling the platform. +Console supplies infrastructure only. Validate/explore 02 without reprovisioning, +then complete 03-05 to install Radius, create workspaces and abstractions (including +`artifacts/types.tgz`), and publish/register recipes before this application exercise. Use the **Bash** examples and source `artifacts/console-env.sh` in each new terminal. Keep its discovered names; the PowerShell examples are the manual path.