From ce4a8184735ff06b19a80fac7e11502c87fc6119 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 19 Aug 2026 13:20:47 +0200 Subject: [PATCH 01/29] Add Adaptive Apps MicroHack foundation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../04-adaptive-apps/Readme.md | 132 +++++++ .../challenges/challenge-00.md | 152 ++++++++ .../challenges/challenge-01.md | 146 ++++++++ .../challenges/challenge-02.md | 150 ++++++++ .../challenges/challenge-03.md | 150 ++++++++ .../challenges/challenge-04.md | 145 +++++++ .../challenges/challenge-05.md | 7 + .../challenges/challenge-06.md | 7 + .../challenges/challenge-07.md | 7 + .../challenges/challenge-08.md | 7 + .../challenges/challenge-09.md | 7 + .../challenges/challenge-10.md | 7 + .../04-adaptive-apps/docs/prepare-aks.md | 72 ++++ .../04-adaptive-apps/docs/prepare-arc.md | 24 ++ .../docs/prepare-azure-local.md | 20 + .../04-adaptive-apps/docs/prepare-k3s.md | 42 +++ .../04-adaptive-apps/iac/aks-env.bicep | 95 +++++ .../04-adaptive-apps/iac/bicepconfig.json | 8 + .../04-adaptive-apps/iac/local-env.bicep | 76 ++++ .../iac/recipes/sql-server.bicep | 54 +++ .../04-adaptive-apps/iac/sql-databases.yaml | 45 +++ .../04-adaptive-apps/images/.gitkeep | 0 .../resources/configure-recipes.sh | 130 +++++++ .../resources/configure-resource-types-aks.sh | 65 ++++ .../resources/configure-resource-types-k3s.sh | 66 ++++ .../resources/deploy-radius-aks.sh | 190 ++++++++++ .../resources/deploy-radius-k3s.sh | 55 +++ .../04-adaptive-apps/resources/prepare-aks.sh | 92 +++++ .../resources/prepare-k3s-azure-vm.sh | 143 +++++++ .../walkthrough/challenge-00/solution-00.md | 281 ++++++++++++++ .../walkthrough/challenge-01/solution-01.md | 159 ++++++++ .../walkthrough/challenge-02/solution-02.md | 338 +++++++++++++++++ .../walkthrough/challenge-03/solution-03.md | 329 ++++++++++++++++ .../walkthrough/challenge-04/solution-04.md | 354 ++++++++++++++++++ 34 files changed, 3555 insertions(+) create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-01.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-02.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-03.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-04.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-07.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-aks.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-arc.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-azure-local.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/local-env.bicep create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/sql-databases.yaml create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/images/.gitkeep create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-01/solution-01.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-04/solution-04.md 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 new file mode 100644 index 000000000..11607e413 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md @@ -0,0 +1,132 @@ +# **Adaptive Apps MicroHack** + +- [MicroHack introduction](#microhack-introduction) +- [MicroHack context](#microhack-context) +- [Objectives](#objectives) +- [MicroHack challenges](#microhack-challenges) +- [Contributors](#contributors) + +## MicroHack introduction + +Modern organizations increasingly need to run the same application in very +different places: a public cloud region, an on-premises datacenter, an edge +location, or a fully disconnected site. They need to do this without rebuilding +the application for every environment. + +This MicroHack uses [Radius](https://radapp.io), an open-source, cloud-native +application platform, to make a single application adaptive. The application +declares the capabilities it needs, such as a database, message broker, +workload identity, or AI model, while each environment decides how those +capabilities are provided. + +Across the challenges, you take the role of a platform engineering team. You +prepare target platforms, install Radius, define reusable platform abstractions +with resource types and recipes, and prove portability by deploying a sample +stock-trading application across multiple environments without changing its +application model. Advanced challenges apply the same approach to AI services, +custom resources, and brownfield modernization. + +This MicroHack is not a complete explanation of Radius. The following articles +provide useful background: + +- [What is Radius?](https://docs.radapp.io/concepts/overview/) +- [Radius application model](https://docs.radapp.io/guides/author-apps/application/overview/) +- [Radius environments](https://docs.radapp.io/guides/deploy-apps/environments/overview/) +- [Radius recipes](https://docs.radapp.io/guides/recipes/overview/) + +## MicroHack context + +The scenario follows a trading firm whose stock-trading application must run +across cloud, on-premises, and edge environments. Some sites may need to keep +operating when disconnected from the cloud, while connected sites should use +managed Azure services where appropriate. + +Instead of maintaining a different deployment definition for every site, the +platform team wants one application definition with environment-specific +implementations of the same capability contracts. Radius resource types define +those contracts, recipes provide their implementations, and Radius +environments determine which implementation is used at each location. + +## Objectives + +After completing this MicroHack, you will: + +- Know how to prepare Kubernetes target platforms and install Radius. +- Understand how Radius resource types and recipes define reusable platform + capabilities. +- Deploy one application model across multiple environments with minimal + environment-only configuration changes. +- Adapt identity, secure service communication, and AI services without + changing the application model. +- Apply the Adaptive Apps approach to an existing containerized application. + +## MicroHack challenges + +> [!NOTE] +> This initial scaffold establishes the challenge sequence and navigation. +> Detailed challenge and coach walkthrough content will be added incrementally. + +### General prerequisites + +To use the MicroHack time effectively, have the following available: + +- An Azure subscription with **Owner** access to the lab resource group +- Capacity to create the default two-environment topology: one Azure Kubernetes + Service cluster and one Linux VM hosting self-managed K3s +- Alternatively, an existing Azure Local or Arc-enabled Kubernetes environment to + use in place of K3s +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) +- [kubectl](https://kubernetes.io/docs/tasks/tools/) +- [Helm](https://helm.sh/docs/intro/install/) +- [Radius CLI (`rad`)](https://docs.radapp.io/getting-started/install/) +- [Visual Studio Code](https://code.visualstudio.com/) +- Bash or PowerShell 7; Windows users can also use WSL 2 + +### Challenge 00: universal prerequisite + +Complete Challenge 00 before starting a challenge in any bucket. + +- [Challenge 00 - Prerequisites: ready, set, go](challenges/challenge-00.md) + **<- Start here** + +### Bucket 1: Infrastructure setup + +Prepare the target platforms and deploy the Radius control plane. + +- [Challenge 01 - Prepare the platforms](challenges/challenge-01.md) +- [Challenge 02 - Deploy and explore Radius](challenges/challenge-02.md) + +### Bucket 2: Exploring Radius + +Define portable platform capabilities and implement them with recipes. + +- [Challenge 03 - Build the platform abstractions](challenges/challenge-03.md) +- [Challenge 04 - Build the platform abstractions with recipes](challenges/challenge-04.md) + +### Bucket 3: Portable apps across platforms + +Deploy the application across environments and adapt its identity, +communication, and AI capabilities. + +- [Challenge 05 - Port the app across environments](challenges/challenge-05.md) +- [Challenge 06 - Adapt identity services](challenges/challenge-06.md) +- [Challenge 07 - Secure service communication](challenges/challenge-07.md) +- [Challenge 08 - Adapt AI services](challenges/challenge-08.md) + +### Bucket 4: Advanced challenges + +Extend the application model and apply the Adaptive Apps approach to an +existing application. + +- [Challenge 09 - Extend the application model with custom Radius resources](challenges/challenge-09.md) +- [Challenge 10 - Modernize a brownfield application](challenges/challenge-10.md) + +## Contributors + +- The Adaptive Apps team at Microsoft +- Dylan de Jong +- Jan Egil Ring +- Wesley Backelant + +Contributions to the upstream project are welcome in the +[microsoft/adaptive-apps repository](https://github.com/microsoft/adaptive-apps). diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md new file mode 100644 index 000000000..aef5be5ce --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md @@ -0,0 +1,152 @@ +# Challenge 00 - Prerequisites: ready, set, go + +**[Home](../Readme.md)** - [Next Challenge >](challenge-01.md) + +Estimated time: 30-60 minutes | Difficulty: beginner + +## Challenge objective + +Prepare and validate the workstation, Azure access, permissions, and regional capacity +required by every challenge in this MicroHack. + +Challenge 00 is a universal prerequisite. Complete it before starting a challenge in +any bucket. + +## Learning goals + +- Establish a consistent command-line toolchain. +- Select the correct Azure subscription and confirm the required access. +- Verify that the target region can host the default workshop topology. +- Understand where Kubernetes credentials and Radius workspace configuration will be + stored. + +## Default workshop topology + +Later challenges use two independent Kubernetes environments: + +| Logical environment | Default platform | Default size | +| --- | --- | --- | +| Azure | Two-node Azure Kubernetes Service (AKS) cluster | `Standard_D4s_v5` nodes | +| Local | Single-node K3s cluster on an Azure Linux VM | `Standard_D4s_v5` VM | + +Both platforms run in Azure for workshop convenience. K3s remains a self-managed +Kubernetes distribution and represents a local or edge environment. + +## Tasks + +### Task 1: Confirm access and capacity + +Confirm that your workshop team has: + +- An Azure subscription +- Permission to create a resource group, AKS cluster, Linux VM, networking resources, + public IP addresses, and role assignments +- Permission to create a Microsoft Entra application and service principal +- Regional quota for the AKS nodes, K3s VM, and standard public IP addresses +- A known public egress CIDR that can be used to restrict SSH and Kubernetes API access + to the K3s VM + +> [!IMPORTANT] +> Later challenges grant a Radius identity `Owner` on the dedicated lab resource group +> because recipes can create role assignments. Use an isolated workshop subscription +> or resource group rather than a production scope. + +### Task 2: Install the workstation tools + +Install the following tools in one consistent shell environment: + +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) +- [`kubectl`](https://kubernetes.io/docs/tasks/tools/) +- [Helm 3](https://helm.sh/docs/intro/install/) +- [Radius CLI (`rad`)](https://docs.radapp.io/getting-started/install/) +- [Git](https://git-scm.com/downloads) +- `curl`, OpenSSH client, and `tar` +- [Visual Studio Code](https://code.visualstudio.com/) + +Windows participants should use WSL 2 for the Bash automation supplied with this +MicroHack. Install the complete toolchain inside WSL rather than mixing WSL and native +Windows executables or configuration files. + +### Task 3: Verify the toolchain + +Run the appropriate version command for every required tool: + +```bash +az --version +kubectl version --client +helm version +rad version +git --version +curl --version +ssh -V +tar --version +code --version +``` + +Resolve missing commands and `PATH` problems before continuing. + +### Task 4: Select and validate the Azure subscription + +Sign in, select the intended subscription, and verify the active account: + +```bash +az login +az account list --output table +az account set --subscription "" +az account show --output table +``` + +Confirm that these resource providers are registered or can be registered: + +```bash +az provider register --namespace Microsoft.ContainerService +az provider register --namespace Microsoft.Compute +az provider register --namespace Microsoft.Network +``` + +Provider registration is asynchronous. Inspect the registration state instead of +repeatedly submitting the registration command. + +### Task 5: Agree on local configuration locations + +Before sharing access with teammates, decide: + +- Which shell environment will run the MicroHack commands +- Where the default kubeconfig will be stored +- Where the dedicated K3s kubeconfig will be stored +- Who will provision each shared platform +- How the team will transfer kubeconfig files securely + +No cluster connection is required yet. Challenge 01 creates the default platforms. + +## Success criteria + +You are ready to continue when: + +- Every required tool returns a version successfully. +- Azure CLI targets the intended subscription. +- The team has the permissions and quota needed for AKS, the K3s VM, networking, and + later identity configuration. +- The required Azure resource providers are registered or registration is underway. +- The workshop's public egress CIDR is known. +- Windows participants have a complete toolchain in one shell environment. +- The team knows where its Kubernetes and Radius CLI configuration will be stored. + +## Hints + +- If a command is not found immediately after installation, restart the shell and + inspect `PATH`. +- If Azure CLI uses the wrong subscription, list the available subscriptions and set + the intended subscription explicitly. +- Azure Cloud Shell can help inspect Azure resources, but it does not replace the + consistent local environment needed for K3s kubeconfig files and later authoring. +- Resolve quota or role-assignment blockers before the event; they are difficult to + fix within a timed MicroHack. + +## Learning resources + +- [Azure CLI installation](https://learn.microsoft.com/cli/azure/install-azure-cli) +- [Install and set up kubectl](https://kubernetes.io/docs/tasks/tools/) +- [Helm installation](https://helm.sh/docs/intro/install/) +- [Install the Radius CLI](https://docs.radapp.io/getting-started/install/) +- [Install WSL](https://learn.microsoft.com/windows/wsl/install) 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 new file mode 100644 index 000000000..27d9173e9 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-01.md @@ -0,0 +1,146 @@ +# Challenge 01 - Prepare the platforms + +[< Previous Challenge](challenge-00.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-02.md) + +Estimated time: 30-60 minutes | Difficulty: intermediate + +## Challenge objective + +Prepare and validate the two Kubernetes platforms used throughout the MicroHack: + +| Logical environment | Platform | Purpose | +| --- | --- | --- | +| Azure | Azure Kubernetes Service (AKS) | Azure-managed Kubernetes with workload identity and managed Istio | +| Local | K3s on an Azure Linux VM | Self-managed Kubernetes representing an on-premises or edge platform | + +Both environments run in Azure for workshop convenience, but only one is AKS. Their +different identity, networking, registry, and managed-service capabilities create the +portability boundary explored in later challenges. + +> [!IMPORTANT] +> This challenge provisions Kubernetes only. Do not install Radius, the Adaptive Apps +> portfolio, recipes, or application workloads. + +## Learning goals + +- Prepare Azure-managed and self-managed Kubernetes platforms. +- Configure AKS features needed by later identity and service-mesh exercises. +- Secure access to a K3s API running on an Azure VM. +- Switch deliberately between kubeconfig files and Kubernetes contexts. +- Establish a healthy baseline before adding platform components. + +## Tasks + +### Task 1: Plan the environment + +Agree on names and values for: + +- Azure subscription and location +- Lab resource group +- AKS cluster +- K3s VM +- AKS node and K3s VM sizes +- Public administrator CIDR for K3s management access + +The local helper scripts use these defaults: + +```text +Resource group: rg-adaptive-apps +AKS context: aks-adaptive-apps +K3s VM: vm-adaptive-apps-k3s +K3s context: k3s-azure-vm +K3s kubeconfig: ~/.kube/adaptive-apps-k3s.yaml +``` + +### Task 2: Provision AKS + +Create a two-node AKS cluster with: + +- OIDC issuer enabled +- Microsoft Entra workload identity enabled +- Managed Istio enabled +- Credentials merged into the default kubeconfig + +The repository includes `resources/prepare-aks.sh` as the supported provisioning path. +Review its inputs before running it, set the subscription, location, resource-group, +and cluster values explicitly, then validate the result independently. + +### Task 3: Provision K3s on an Azure VM + +Create an Ubuntu VM with: + +- A static public IP +- SSH and Kubernetes API access restricted to the workshop administrator CIDR +- HTTP and HTTPS open for later application exercises +- K3s installed without its bundled Traefik ingress controller +- A dedicated local kubeconfig using context `k3s-azure-vm` + +The repository includes `resources/prepare-k3s-azure-vm.sh`. Review its network and +kubeconfig behavior before running it. Treat the generated kubeconfig as a credential. + +### Task 4: Validate both platforms + +For each platform, verify: + +```bash +kubectl config current-context +kubectl get --raw="/readyz" +kubectl wait --for=condition=Ready nodes --all --timeout=10m +kubectl get nodes -o wide +``` + +For AKS, also confirm that OIDC issuer, workload identity, and managed Istio are +enabled. For K3s, confirm that the K3s service is active on the VM and that the API is +reachable only from an approved network. + +### Task 5: Practice explicit context switching + +Demonstrate that your team can: + +1. Use the default kubeconfig and select `aks-adaptive-apps`. +2. Set `KUBECONFIG` to the dedicated K3s file and select `k3s-azure-vm`. +3. Return to AKS without accidentally continuing to target K3s. + +Record the active kubeconfig and context whenever you switch platforms. + +## Success criteria + +- AKS provisioning state is `Succeeded`. +- AKS has OIDC issuer, workload identity, and managed Istio enabled. +- The K3s VM is running and its K3s service is active. +- Both Kubernetes APIs return `ok`. +- Every node reports `Ready`. +- The team can identify and switch between the AKS and K3s kubeconfig/context pairs. +- K3s management access is restricted to the intended administrator CIDR. +- No Radius control plane, portfolio, recipe, or application workload is installed. + +## Hints + +- If Azure cannot allocate a requested VM size, select another size or region rather + than repeatedly retrying the same deployment. +- Check AKS provisioning state and node readiness before rerunning provisioning. +- A K3s API timeout often means the current public address is not included in the + network security rule. +- `KUBECONFIG` can override the default configuration even when the context name looks + familiar. Check both before every platform operation. +- Arc enablement projects an existing cluster into Azure management; it does not create + the Kubernetes cluster and is not required for the default K3s path. + +## Optional platform alternatives + +If the workshop already provides suitable infrastructure, Azure Local or an existing +Arc-enabled Kubernetes cluster can replace K3s: + +- [Prepare Azure Local](../docs/prepare-azure-local.md) +- [Prepare Azure Arc-enabled Kubernetes](../docs/prepare-arc.md) + +These are optional manual alternatives. Keep distinct kubeconfig, context, and logical +environment names when substituting a platform. + +## Learning resources + +- [Azure Kubernetes Service documentation](https://learn.microsoft.com/azure/aks/) +- [K3s documentation](https://docs.k3s.io/) +- [Microsoft Entra workload identity on AKS](https://learn.microsoft.com/azure/aks/workload-identity-overview) +- [Managed Istio on AKS](https://learn.microsoft.com/azure/aks/istio-about) +- [Azure Arc-enabled Kubernetes overview](https://learn.microsoft.com/azure/azure-arc/kubernetes/overview) 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 new file mode 100644 index 000000000..9b173a044 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-02.md @@ -0,0 +1,150 @@ +# Challenge 02 - Deploy and explore Radius + +[< Previous Challenge](challenge-01.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-03.md) + +Estimated time: 30-45 minutes per environment | Difficulty: intermediate + +## Challenge objective + +Install and explore independent Radius control planes on both default platforms. +Configure the matching local CLI workspaces, Radius environments, and groups: + +| Platform | Workspace | Environment | Group | +| --- | --- | --- | --- | +| AKS | `ws-azure-prod` | `env-azure-prod` | `rg-trading` | +| K3s on an Azure VM | `ws-local-prod` | `env-local-prod` | `rg-trading` | + +The default workshop architecture is federated: each platform has its own Radius +control plane and can operate independently. + +## Learning goals + +- Compare centralized and federated Radius control-plane models. +- Install Radius manually on Azure-managed and self-managed Kubernetes. +- Distinguish a control plane, local CLI workspace, environment, group, and + application. +- Configure Azure workload identity for the AKS Radius control plane. +- Validate Radius through Kubernetes, the CLI, and the dashboard. + +## Tasks + +### Task 1: Select the control-plane model + +Compare these approaches: + +- **Centralized:** one management control plane governs multiple execution + environments. +- **Federated:** each site runs an independent control plane and receives configuration + from shared source control. + +Document why the federated model is appropriate for a scenario that includes an +intermittently connected or disconnected site. Identify the availability and +governance trade-offs. + +### Task 2: Install Radius manually on AKS + +Before installation: + +1. Select kube context `aks-adaptive-apps`. +2. Create a Microsoft Entra application and service principal for Radius. +3. Use the AKS OIDC issuer to create federated credentials for the Radius service + accounts in namespace `radius-system`. +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 +the control-plane installation. + +Create and select: + +- Workspace `ws-azure-prod`, targeting context `aks-adaptive-apps` +- Group `rg-trading` +- Environment `env-azure-prod`, using Kubernetes namespace `env-azure-prod` + +Configure the environment's Azure subscription and resource group, then register the +Azure workload-identity credential. + +### Task 3: Install Radius manually on K3s + +Select the dedicated K3s kubeconfig and context `k3s-azure-vm`, then install a second, +independent Radius control plane. + +Create and select: + +- Workspace `ws-local-prod`, targeting context `k3s-azure-vm` +- Group `rg-trading` +- Environment `env-local-prod`, using Kubernetes namespace `env-local-prod` + +Do not register Azure credentials or configure an Azure provider for K3s. + +### Task 4: Validate and explore both installations + +For each platform, activate the matching Kubernetes context and Radius workspace, then +verify: + +```bash +kubectl wait --for=condition=Available deployments --all \ + --namespace radius-system \ + --timeout=10m +kubectl get pods --namespace radius-system +kubectl get crd applications.radapp.io + +rad workspace list +rad env list +rad group list +``` + +Port-forward the Radius dashboard service in `radius-system`, explore the active +environment, and then repeat with the other platform. + +Prepare a short team explanation of: + +- Which objects live in Kubernetes and which configuration is local to a workstation +- Why both control planes can use a group named `rg-trading` without sharing one group +- Which environment can provision Azure-managed resources +- What happens to applications on K3s if AKS is unavailable + +## Success criteria + +- The team can explain why the default topology uses federated control planes. +- Radius deployments in `radius-system` are healthy on AKS and K3s. +- Radius CRDs exist on both clusters. +- `ws-azure-prod` targets AKS and exposes `env-azure-prod` and `rg-trading`. +- `ws-local-prod` targets K3s and exposes `env-local-prod` and `rg-trading`. +- AKS has a Radius Azure workload-identity credential and Azure provider settings. +- K3s has neither Azure credentials nor an Azure provider. +- The dashboard is accessible for each control plane. +- No capability portfolio, resource type, recipe, or application deployment has + started. + +## Hints + +- Check both `kubectl config current-context` and the selected Radius workspace before + every operation. +- Radius startup can take 5-10 minutes. Inspect pod state before rerunning an install. +- A workspace is local CLI configuration in `~/.rad/config.yaml`; it is not stored in + the cluster. +- The environment points applications at a Kubernetes namespace and can hold provider + and recipe configuration. +- Microsoft Entra and Azure role assignments can take time to propagate. +- If GHCR returns HTTP 403, clear stale registry credentials before retrying the + Radius installation. + +## Optional automation and platforms + +Manual installation is the intended learning path. If a participant must skip or +recover the exercise, these scripts perform the same setup: + +- `resources/deploy-radius-aks.sh` +- `resources/deploy-radius-k3s.sh` + +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. + +## Learning resources + +- [What is Radius?](https://docs.radapp.io/concepts/) +- [Install Radius on Kubernetes](https://docs.radapp.io/guides/operations/kubernetes/install/) +- [Radius workspaces](https://docs.radapp.io/guides/operations/workspaces/overview/) +- [Radius environments](https://docs.radapp.io/guides/deploy-apps/environments/overview/) +- [Radius dashboard](https://docs.radapp.io/guides/tooling/dashboard/) 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 new file mode 100644 index 000000000..2481089a9 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-03.md @@ -0,0 +1,150 @@ +# Challenge 03 - Build the platform abstractions + +[< Previous Challenge](challenge-02.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-04.md) + +Estimated time: 45-75 minutes | Difficulty: intermediate + +## Challenge objective + +Install the Adaptive Apps capability portfolio, then define the portable contracts that +application developers use in later challenges. Create one resource type manually +before importing and inspecting the complete catalog on both Radius control planes. + +The sequence is deliberate: + +1. Establish the platform capability baseline. +2. Define and register a resource-type contract. +3. Import the shared contract catalog. +4. Implement the contracts with recipes in Challenge 04. + +## Learning goals + +- Explain the difference between a resource type and a recipe. +- Install the same capability portfolio across Azure and Local platforms. +- Model developer inputs, read-only outputs, and secrets in a resource type. +- Register resource types independently with each Radius control plane. +- Generate a Bicep extension from a Radius resource-type catalog. + +## Tasks + +### Task 1: Install the `core` capability portfolio + +Use the Adaptive Apps source pinned by the coach and install the `core` profile on both +platforms. + +On AKS: + +- Use context `aks-adaptive-apps`. +- Install release `core` in namespace `core`. +- Reuse the AKS managed Istio add-on rather than installing Istio from the portfolio. + +On K3s: + +- Use the dedicated K3s kubeconfig and context `k3s-azure-vm`. +- Install release `core` in namespace `core`. +- Allow the portfolio to install its own Istio components. + +Validate the Helm release, deployments, stateful sets, and pods on each platform. Do +not proceed while a portfolio workload is unhealthy. + +### Task 2: Design a portable SQL database contract + +Create `iac/sql-databases.yaml` with a resource type named +`Radius.Resources/sqlDatabases`. + +The contract must: + +- Belong to the `Radius.Resources` namespace. +- Use API version `2025-08-01-preview`. +- Require Radius environment and application identifiers. +- Accept a size input with tiers `S`, `M`, and `L`. +- Return host, port, database, and username as read-only values. +- Return the database password through a read-only `secrets` object. + +Before registration, explain which properties an application developer supplies and +which properties a recipe supplies. + +### Task 3: Register the custom type on both control planes + +Register `iac/sql-databases.yaml` separately: + +- In workspace `ws-azure-prod` while targeting AKS +- In workspace `ws-local-prod` while targeting K3s + +Inspect `Radius.Resources/sqlDatabases` after each registration and confirm that its +schema matches the intended contract. + +### Task 4: Import the shared resource-type catalog + +Download the catalog from the pinned Adaptive Apps source, review it, and register it +on both Radius control planes. + +The catalog should provide these portable types: + +```text +Radius.Resources/agentGuardrails +Radius.Resources/aiModels +Radius.Resources/governance +Radius.Resources/idProviders +Radius.Resources/mqttBrokers +Radius.Resources/postgreSqlDatabases +Radius.Resources/sqlDatabases +Radius.Resources/workloadIdentities +``` + +Generate a local Bicep extension package at `artifacts/types.tgz` from the same catalog. +This artifact is generated once per workstation. + +### Task 5: Explore the contracts + +Use the Radius CLI and dashboard on both platforms to inspect: + +- Required developer inputs +- Read-only outputs +- Secret properties +- API versions + +Prepare a short explanation of how the same resource type can use an Azure-managed +implementation on AKS and an in-cluster implementation on K3s without changing the +application declaration. + +## Success criteria + +- The `core` Helm release is healthy on AKS and K3s. +- `Radius.Resources/sqlDatabases` exists in both Radius installations. +- The complete Adaptive Apps resource-type catalog exists in both installations. +- The team can distinguish developer inputs, recipe outputs, and secrets. +- `artifacts/types.tgz` is generated successfully. +- The team can explain why a resource type is platform-independent. +- No recipes are registered yet. + +## Hints + +- A resource type is a versioned schema and contract; it has no implementation by + itself. +- Use `readOnly` for properties returned by a recipe. +- Keep sensitive output under `secrets` rather than exposing it as an ordinary value. +- Resource types are stored in the selected control plane, so repeat registration in + both workspaces. +- The Bicep extension package is local to the workstation and does not need to be + generated once per cluster. +- Verify both Kubernetes context and Radius workspace before every registration. + +## Optional automation and platforms + +Manual portfolio installation and type registration are the intended learning path. +For setup recovery or an explicit skip, these scripts perform the same sequence for one +environment: + +- `resources/configure-resource-types-aks.sh` +- `resources/configure-resource-types-k3s.sh` + +If Azure Local or Arc-enabled Kubernetes replaces K3s, register the same +platform-independent schemas with that platform's Radius workspace. + +## Learning resources + +- [Radius resource types](https://docs.radapp.io/guides/author-apps/custom-resources/) +- [Resource type CLI reference](https://docs.radapp.io/reference/cli/rad_resource-type/) +- [Radius recipes](https://docs.radapp.io/guides/recipes/overview/) +- [Bicep extensions](https://learn.microsoft.com/azure/azure-resource-manager/bicep/bicep-extension) 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 new file mode 100644 index 000000000..d113b525d --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-04.md @@ -0,0 +1,145 @@ +# Challenge 04 - Implement the platform abstractions with recipes + +[< Previous Challenge](challenge-03.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-05.md) + +Estimated time: 60-90 minutes | Difficulty: advanced + +## Challenge objective + +Implement the portable resource-type contracts from Challenge 03. Manually author, +publish, and register an Azure SQL recipe before registering the complete +environment-specific recipe sets for AKS and K3s. + +AKS will use Azure-backed implementations where appropriate. K3s will use in-cluster +implementations. + +## Learning goals + +- Explain how a recipe implements a resource-type contract. +- Use Radius recipe `context` without coupling an application to a platform. +- Return ordinary values, secrets, and managed resource identifiers correctly. +- Publish a Bicep recipe to an OCI registry and register it with an environment. +- Compare recipe mappings for the same types across Azure and Local environments. + +## Tasks + +### Task 1: Design the custom Azure SQL recipe + +Author `iac/recipes/sql-server.bicep` to implement +`Radius.Resources/sqlDatabases`. + +Your recipe should: + +- Accept the Radius-provided `context` object. +- Read the requested size from `context.resource.properties`. +- Map `S`, `M`, and `L` to appropriate SQL SKU values. +- Create deterministic resource names from the Radius resource ID. +- Use an [Azure Verified Module](https://aka.ms/avm) for Azure SQL. +- Add tags that identify the Radius environment, resource, and application. +- Return the SQL server resource ID in `result.resources`. +- Return host, port, database, and username in `result.values`. +- Return the administrator password in `result.secrets`. + +Explain why the application developer does not provide an Azure resource group or +administrator password. + +### Task 2: Publish and register the custom recipe on AKS + +While targeting AKS and workspace `ws-azure-prod`: + +1. Create or select a globally unique, lowercase Azure Container Registry. +2. Authenticate to the registry. +3. Publish `iac/recipes/sql-server.bicep` as version `1.0.0` under + `recipes/sql-server`. +4. Register it as the default recipe for `Radius.Resources/sqlDatabases` in + `env-azure-prod`. +5. Verify that the registration refers to the expected OCI artifact. + +If Docker is unavailable, use a temporary OCI authentication configuration rather than +putting a registry token in source files. + +### Task 3: Register the complete AKS recipe set + +Deploy `iac/aks-env.bicep` as environment-as-code for: + +- Workspace `ws-azure-prod` +- Group `rg-trading` +- Environment `env-azure-prod` +- Kubernetes namespace `env-azure-prod` + +Provide the Azure subscription, lab resource group, managed Istio revision, and custom +SQL recipe path as parameters. + +The resulting mappings should include Azure-backed PostgreSQL, MQTT, workload +identity, and AI implementations; in-cluster identity, governance, and guardrail +components; and the custom Azure SQL recipe. + +### Task 4: Register the complete K3s recipe set + +Switch both the Kubernetes context and Radius workspace to K3s, then deploy +`iac/local-env.bicep` for: + +- Workspace `ws-local-prod` +- Group `rg-trading` +- Environment `env-local-prod` +- Kubernetes namespace `env-local-prod` + +The Local mappings should use in-cluster implementations for PostgreSQL, MQTT, +identity, workload identity, AI, governance, and agent guardrails. + +Registering an AI recipe does not deploy a model. GPU capacity is needed only when the +recipe is exercised in a later challenge. + +### Task 5: Validate and compare + +List the recipes in `env-azure-prod` and `env-local-prod`, then inspect them in the +dashboard for each control plane. + +Prepare a comparison that answers: + +- Which types resolve to Azure-managed services on AKS? +- Which types resolve to containers or Kubernetes resources on K3s? +- How do `result.values` and `result.secrets` satisfy the contract from Challenge 03? +- Why can the application declaration remain unchanged? + +## Success criteria + +- The custom SQL recipe is published to ACR at version `1.0.0`. +- The recipe is registered as the default `Radius.Resources/sqlDatabases` + implementation in `env-azure-prod`. +- AKS has the expected Azure-backed and in-cluster recipe mappings. +- K3s has the expected in-cluster recipe mappings. +- Both environments expose their recipe registrations through the CLI and dashboard. +- The team can explain `context`, `result.resources`, `result.values`, and + `result.secrets`. +- No application workload has been deployed yet. + +## Hints + +- Start from the resource-type schema. Every recipe output must satisfy that contract. +- Radius injects `context`; application developers do not pass it. +- Publishing an artifact and registering a recipe are separate steps. Validate each + one before continuing. +- ACR names must be globally unique and lowercase. +- Check Kubernetes context, Radius workspace, group, and environment before each + deployment. +- The Azure environment needs an Azure provider scope; the K3s environment does not. +- Environment-as-code makes a complete mapping repeatable, but author and register the + custom recipe manually first. + +## Optional automation and platforms + +Manual authoring, publishing, and registration are the intended learning path. For +setup recovery or an explicit skip, `resources/configure-recipes.sh` can configure AKS, +K3s, or both after its required Azure values are supplied. + +If Azure Local or Arc-enabled Kubernetes replaces K3s, start with +`iac/local-env.bicep` for in-cluster implementations unless the platform team +intentionally offers managed services. + +## Learning resources + +- [Radius recipes](https://docs.radapp.io/guides/recipes/overview/) +- [Author Bicep recipes](https://docs.radapp.io/guides/recipes/author-recipes/bicep/) +- [Azure Verified Modules](https://aka.ms/avm) +- [Publish Bicep to an OCI registry](https://docs.radapp.io/reference/cli/rad_bicep_publish/) 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 new file mode 100644 index 000000000..139b814e5 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md @@ -0,0 +1,7 @@ +# Challenge 05 - Port the app across environments + +> [!NOTE] +> This file is an authoring scaffold. Detailed application portability +> instructions will be added in a follow-up contribution. + +[Back to the Adaptive Apps MicroHack](../Readme.md) 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 new file mode 100644 index 000000000..1f3207903 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md @@ -0,0 +1,7 @@ +# Challenge 06 - Adapt identity services + +> [!NOTE] +> This file is an authoring scaffold. Detailed identity instructions will be +> added in a follow-up contribution. + +[Back to the Adaptive Apps MicroHack](../Readme.md) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-07.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-07.md new file mode 100644 index 000000000..d1f4cab62 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-07.md @@ -0,0 +1,7 @@ +# Challenge 07 - Secure service communication + +> [!NOTE] +> This file is an authoring scaffold. Detailed service communication +> instructions will be added in a follow-up contribution. + +[Back to the Adaptive Apps MicroHack](../Readme.md) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md new file mode 100644 index 000000000..51cce5306 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md @@ -0,0 +1,7 @@ +# Challenge 08 - Adapt AI services + +> [!NOTE] +> This file is an authoring scaffold. Detailed AI service instructions will be +> added in a follow-up contribution. + +[Back to the Adaptive Apps MicroHack](../Readme.md) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md new file mode 100644 index 000000000..25029ea61 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md @@ -0,0 +1,7 @@ +# Challenge 09 - Extend the application model with custom Radius resources + +> [!NOTE] +> This file is an authoring scaffold. Detailed custom resource instructions +> will be added in a follow-up contribution. + +[Back to the Adaptive Apps MicroHack](../Readme.md) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md new file mode 100644 index 000000000..6504e19b5 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md @@ -0,0 +1,7 @@ +# Challenge 10 - Modernize a brownfield application + +> [!NOTE] +> This file is an authoring scaffold. Detailed brownfield modernization +> instructions will be added in a follow-up contribution. + +[Back to the Adaptive Apps MicroHack](../Readme.md) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-aks.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-aks.md new file mode 100644 index 000000000..bf7d77f74 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-aks.md @@ -0,0 +1,72 @@ +# Prepare the AKS environment + +The default Azure environment is AKS with OIDC issuer, workload identity, and the +managed Istio add-on enabled. Challenge 01 provisions Kubernetes only. + +## Automated preparation + +```bash +export AZURE_SUBSCRIPTION="" +export AZURE_LOCATION="westeurope" +export RESOURCE_GROUP="rg-adaptive-apps" +export AKS_CLUSTER="aks-adaptive-apps" + +chmod +x resources/prepare-aks.sh +./resources/prepare-aks.sh +``` + +Optional settings are `AKS_NODE_COUNT` and `AKS_NODE_VM_SIZE`. + +## Manual equivalent + +```bash +az account set --subscription "$AZURE_SUBSCRIPTION" +az group create --name "$RESOURCE_GROUP" --location "$AZURE_LOCATION" + +az aks create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --location "$AZURE_LOCATION" \ + --node-count 2 \ + --node-vm-size Standard_D4s_v5 \ + --node-osdisk-type Managed \ + --enable-oidc-issuer \ + --enable-workload-identity \ + --enable-asm \ + --generate-ssh-keys + +az aks get-credentials \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --overwrite-existing +``` + +For an existing cluster, enable the required features: + +```bash +az aks update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --enable-oidc-issuer \ + --enable-workload-identity + +az aks mesh enable \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" +``` + +## Health check + +```bash +kubectl config use-context "$AKS_CLUSTER" +kubectl get --raw="/readyz" +kubectl wait --for=condition=Ready nodes --all --timeout=10m +kubectl get nodes -o wide + +az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --query "{state:provisioningState,oidc:oidcIssuerProfile.enabled,workloadIdentity:securityProfile.workloadIdentity.enabled,serviceMesh:serviceMeshProfile.mode}" +``` + +Radius is installed in Challenge 02. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-arc.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-arc.md new file mode 100644 index 000000000..52d3bd6a0 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-arc.md @@ -0,0 +1,24 @@ +# Prepare an Azure Arc-enabled Kubernetes cluster + +Use this optional path when the workshop already has a CNCF-conformant Kubernetes +cluster reachable from the coach workstation. Arc does not create the cluster. + +Follow the current Microsoft documentation: + +1. Review the [Azure Arc-enabled Kubernetes prerequisites](https://learn.microsoft.com/azure/azure-arc/kubernetes/quickstart-connect-cluster#prerequisites). +2. Review the [network requirements](https://learn.microsoft.com/azure/azure-arc/kubernetes/network-requirements). +3. [Connect the existing cluster to Azure Arc](https://learn.microsoft.com/azure/azure-arc/kubernetes/quickstart-connect-cluster). +4. Confirm `connectivityStatus` is `Connected` and all `azure-arc` pods are healthy. + +```bash +az connectedk8s show \ + --resource-group "" \ + --name "" \ + --query connectivityStatus \ + --output tsv + +kubectl get nodes +kubectl get pods -n azure-arc +``` + +Radius is installed separately in Challenge 02. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-azure-local.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-azure-local.md new file mode 100644 index 000000000..898669b88 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-azure-local.md @@ -0,0 +1,20 @@ +# Prepare Azure Local + +Use this optional path when the workshop already has Azure Local and an AKS enabled by +Azure Arc cluster available. Azure Local provisioning is environment-specific and is +not automated by this MicroHack. + +Follow the current Microsoft documentation: + +1. Review [AKS enabled by Azure Arc overview](https://learn.microsoft.com/azure/aks/aksarc/aks-overview). +2. [Create a Kubernetes cluster on Azure Local](https://learn.microsoft.com/azure/aks/aksarc/aks-create-clusters-cli). +3. Obtain credentials and confirm the cluster is reachable from the coach workstation. + +```bash +kubectl config current-context +kubectl get --raw="/readyz" +kubectl wait --for=condition=Ready nodes --all --timeout=10m +kubectl get nodes -o wide +``` + +Radius is installed separately in Challenge 02. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md new file mode 100644 index 000000000..1d781aff5 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md @@ -0,0 +1,42 @@ +# Prepare K3s on an Azure VM + +The default local environment is single-node K3s on an Azure Linux VM. It represents +self-managed on-premises or edge Kubernetes without requiring participant compute. +The VM is not AKS, and Arc enablement is optional. + +## Automated preparation + +```bash +export AZURE_SUBSCRIPTION="" +export AZURE_LOCATION="westeurope" +export RESOURCE_GROUP="rg-adaptive-apps" +export K3S_VM_NAME="vm-adaptive-apps-k3s" +export ADMIN_CIDR="/32" + +chmod +x resources/prepare-k3s-azure-vm.sh +./resources/prepare-k3s-azure-vm.sh +``` + +Optional settings are `K3S_VM_SIZE`, `K3S_ADMIN_USERNAME`, and `K3S_KUBECONFIG`. +If `ADMIN_CIDR` is omitted, the script detects the caller's public IP through +`https://api.ipify.org` and applies a `/32` rule. + +The script creates the VM and network rules, installs K3s, writes a dedicated +kubeconfig, and runs platform health checks. + +## Use and validate the cluster + +```bash +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +kubectl get --raw="/readyz" +kubectl wait --for=condition=Ready nodes --all --timeout=10m +kubectl get nodes -o wide +``` + +> [!IMPORTANT] +> The K3s kubeconfig contains cluster credentials. Store and share it securely. + +K3s can optionally be connected to Azure Arc by following +[Prepare an Azure Arc-enabled cluster](prepare-arc.md). Radius is installed in +Challenge 02. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep new file mode 100644 index 000000000..dca619f8c --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep @@ -0,0 +1,95 @@ +extension radius +extension kubernetes with { + namespace: 'default' + kubeConfig: '' +} as k8s + +param namespace string = 'env-azure-prod' +param environmentName string = namespace +param recipeRegistry string = 'ghcr.io/microsoft/adaptive-apps/recipes' +param sqlDatabasesRecipeTemplatePath string = '' +param istioRevision string +param azureSubscriptionId string +param azureResourceGroup string + +resource appNamespace 'core/Namespace@v1' = { + metadata: { + name: namespace + labels: { + 'istio.io/rev': istioRevision + } + } +} + +var baseRecipes = { + 'Radius.Resources/postgreSqlDatabases': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/postgres-azure-flex:latest' + } + } + 'Radius.Resources/mqttBrokers': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/mqtt-azure-event-grid:latest' + } + } + 'Radius.Resources/idProviders': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/idp-keycloak:latest' + } + } + 'Radius.Resources/workloadIdentities': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/workload-identity-azure:latest' + } + } + 'Radius.Resources/aiModels': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/ai-agent-azure-openai:latest' + } + } + 'Radius.Resources/governance': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/governance-opa:latest' + } + } + 'Radius.Resources/agentGuardrails': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/agent-guardrails-agt:latest' + } + } +} + +var customSqlRecipes = empty(sqlDatabasesRecipeTemplatePath) ? {} : { + 'Radius.Resources/sqlDatabases': { + default: { + templateKind: 'bicep' + templatePath: sqlDatabasesRecipeTemplatePath + } + } +} + +resource aksEnvironment 'Applications.Core/environments@2023-10-01-preview' = { + name: environmentName + dependsOn: [ + appNamespace + ] + properties: { + compute: { + kind: 'kubernetes' + namespace: namespace + } + providers: { + azure: { + scope: '/subscriptions/${azureSubscriptionId}/resourceGroups/${azureResourceGroup}' + } + } + recipes: union(baseRecipes, customSqlRecipes) + } +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json new file mode 100644 index 000000000..3a2b5587b --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json @@ -0,0 +1,8 @@ +{ + "extensions": { + "radius": "br:biceptypes.azurecr.io/radius:latest" + }, + "implicitExtensions": [ + "az" + ] +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/local-env.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/local-env.bicep new file mode 100644 index 000000000..c2b2b9468 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/local-env.bicep @@ -0,0 +1,76 @@ +extension radius +extension kubernetes with { + namespace: 'default' + kubeConfig: '' +} as k8s + +param namespace string = 'env-local-prod' +param environmentName string = namespace +param recipeRegistry string = 'ghcr.io/microsoft/adaptive-apps/recipes' + +resource appNamespace 'core/Namespace@v1' = { + metadata: { + name: namespace + labels: { + 'istio-injection': 'enabled' + } + } +} + +resource localEnvironment 'Applications.Core/environments@2023-10-01-preview' = { + name: environmentName + dependsOn: [ + appNamespace + ] + properties: { + compute: { + kind: 'kubernetes' + namespace: namespace + } + providers: {} + recipes: { + 'Radius.Resources/postgreSqlDatabases': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/postgres:latest' + } + } + 'Radius.Resources/mqttBrokers': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/mqtt:latest' + } + } + 'Radius.Resources/idProviders': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/idp-keycloak:latest' + } + } + 'Radius.Resources/workloadIdentities': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/workload-identity-local:latest' + } + } + 'Radius.Resources/aiModels': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/ai-agent-kaito:latest' + } + } + 'Radius.Resources/governance': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/governance-opa:latest' + } + } + 'Radius.Resources/agentGuardrails': { + default: { + templateKind: 'bicep' + templatePath: '${recipeRegistry}/agent-guardrails-agt:latest' + } + } + } + } +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep new file mode 100644 index 000000000..cea2feb84 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep @@ -0,0 +1,54 @@ +@description('Injected by Radius. Contains resource identity, environment scope, and developer input properties.') +param context object + +@description('Database name. Defaults to the Radius resource name.') +param databaseName string = context.resource.name + +var skuMap = { + S: { name: 'GP_Gen5', capacity: 2 } + M: { name: 'GP_Gen5', capacity: 4 } + L: { name: 'GP_Gen5', capacity: 8 } +} +var sizeKey = context.resource.properties.?size ?? 'S' +var sku = skuMap[sizeKey] +var seed = uniqueString(context.resource.id) +var serverName = 'sql-${take(seed, 10)}' +var adminPassword = '${uniqueString(context.resource.id)}Aa1!' + +module sqlServer 'br/public:avm/res/sql/server:0.12.0' = { + name: 'sql-server-${seed}' + params: { + name: serverName + location: resourceGroup().location + administratorLogin: 'sqladmin' + administratorLoginPassword: adminPassword + databases: [ + { + name: databaseName + sku: { + name: '${sku.name}_${sku.capacity}' + } + } + ] + tags: { + 'radapp.io/environment': context.environment.id + 'radapp.io/resource': context.resource.id + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } +} + +output result object = { + resources: [ + sqlServer.outputs.resourceId + ] + values: { + host: sqlServer.outputs.fullyQualifiedDomainName + port: 1433 + database: databaseName + username: 'sqladmin' + } + secrets: { + password: adminPassword + } +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/sql-databases.yaml b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/sql-databases.yaml new file mode 100644 index 000000000..4f245df04 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/sql-databases.yaml @@ -0,0 +1,45 @@ +namespace: Radius.Resources +types: + sqlDatabases: + description: A portable Microsoft SQL Server database resource. + apiVersions: + '2025-08-01-preview': + schema: + type: object + properties: + environment: + type: string + description: (Required) The Radius Environment ID. + application: + type: string + description: (Required) The Radius Application ID. + size: + type: string + enum: [S, M, L] + description: (Optional) Size tier. + host: + type: string + readOnly: true + description: SQL Server hostname or IP. + port: + type: integer + readOnly: true + description: SQL Server port. + database: + type: string + readOnly: true + description: Database name. + username: + type: string + readOnly: true + description: Login username. + secrets: + type: object + readOnly: true + properties: + password: + type: string + readOnly: true + description: Login password. + required: [password] + required: [environment, application] diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/images/.gitkeep b/03-Azure/01-01-App Innovation/04-adaptive-apps/images/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh new file mode 100644 index 000000000..c5445fdfd --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_PLATFORM="${1:-all}" +if [[ "$TARGET_PLATFORM" != "aks" && + "$TARGET_PLATFORM" != "k3s" && + "$TARGET_PLATFORM" != "all" ]]; then + echo "Usage: $0 [aks|k3s|all]" >&2 + exit 1 +fi + +for command_name in az base64 kubectl mktemp rad tr; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "Required command not found: $command_name" >&2 + exit 1 + } +done + +AKS_CONTEXT="${AKS_CONTEXT:-aks-adaptive-apps}" +K3S_CONTEXT="${K3S_CONTEXT:-k3s-azure-vm}" +K3S_KUBECONFIG="${K3S_KUBECONFIG:-$HOME/.kube/adaptive-apps-k3s.yaml}" +AKS_WORKSPACE="${AKS_WORKSPACE:-ws-azure-prod}" +K3S_WORKSPACE="${K3S_WORKSPACE:-ws-local-prod}" +AKS_ENVIRONMENT="${AKS_ENVIRONMENT:-env-azure-prod}" +K3S_ENVIRONMENT="${K3S_ENVIRONMENT:-env-local-prod}" +RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" +RECIPE_REGISTRY="${RECIPE_REGISTRY:-ghcr.io/microsoft/adaptive-apps/recipes}" +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +configure_aks() { + : "${AZURE_SUBSCRIPTION:?Set AZURE_SUBSCRIPTION for the AKS recipe configuration.}" + : "${RESOURCE_GROUP:?Set RESOURCE_GROUP for the AKS recipe configuration.}" + : "${ACR_NAME:?Set a globally unique lowercase ACR_NAME.}" + + unset KUBECONFIG + kubectl config use-context "$AKS_CONTEXT" >/dev/null + az account show >/dev/null 2>&1 || az login + az account set --subscription "$AZURE_SUBSCRIPTION" + + if ! az acr show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$ACR_NAME" >/dev/null 2>&1; then + az acr create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$ACR_NAME" \ + --sku Basic \ + --output none + fi + + local token + local auth + token="$(az acr login \ + --name "$ACR_NAME" \ + --expose-token \ + --query accessToken \ + --output tsv)" + auth="$(printf '00000000-0000-0000-0000-000000000000:%s' "$token" | + base64 | tr -d '\n')" + mkdir -p "${TEMP_DIR}/docker" + cat >"${TEMP_DIR}/docker/config.json" <&2 + exit 1 + } + + rad deploy iac/aks-env.bicep \ + --workspace "$AKS_WORKSPACE" \ + --group "$RADIUS_GROUP" \ + --environment "$AKS_ENVIRONMENT" \ + --parameters "environmentName=${AKS_ENVIRONMENT}" \ + --parameters "namespace=${AKS_ENVIRONMENT}" \ + --parameters "azureSubscriptionId=${AZURE_SUBSCRIPTION}" \ + --parameters "azureResourceGroup=${RESOURCE_GROUP}" \ + --parameters "istioRevision=${istio_revision}" \ + --parameters "recipeRegistry=${RECIPE_REGISTRY}" \ + --parameters "sqlDatabasesRecipeTemplatePath=${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.0" + + rad recipe list \ + --workspace "$AKS_WORKSPACE" \ + --group "$RADIUS_GROUP" \ + --environment "$AKS_ENVIRONMENT" +} + +configure_k3s() { + export KUBECONFIG="$K3S_KUBECONFIG" + kubectl config use-context "$K3S_CONTEXT" >/dev/null + rad workspace switch "$K3S_WORKSPACE" + + rad deploy iac/local-env.bicep \ + --workspace "$K3S_WORKSPACE" \ + --group "$RADIUS_GROUP" \ + --environment "$K3S_ENVIRONMENT" \ + --parameters "environmentName=${K3S_ENVIRONMENT}" \ + --parameters "namespace=${K3S_ENVIRONMENT}" \ + --parameters "recipeRegistry=${RECIPE_REGISTRY}" + + rad recipe list \ + --workspace "$K3S_WORKSPACE" \ + --group "$RADIUS_GROUP" \ + --environment "$K3S_ENVIRONMENT" +} + +if [[ "$TARGET_PLATFORM" == "aks" || "$TARGET_PLATFORM" == "all" ]]; then + configure_aks +fi +if [[ "$TARGET_PLATFORM" == "k3s" || "$TARGET_PLATFORM" == "all" ]]; then + configure_k3s +fi + +echo "Recipe configuration completed for ${TARGET_PLATFORM}." diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh new file mode 100644 index 000000000..a6209fbcd --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +for command_name in curl helm kubectl mktemp rad tar; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "Required command not found: $command_name" >&2 + exit 1 + } +done + +AKS_CONTEXT="${AKS_CONTEXT:-aks-adaptive-apps}" +RADIUS_WORKSPACE="${RADIUS_WORKSPACE:-ws-azure-prod}" +SQL_TYPE_FILE="${SQL_TYPE_FILE:-iac/sql-databases.yaml}" +TYPES_URL="${TYPES_URL:-https://raw.githubusercontent.com/microsoft/adaptive-apps/885627980684e5bcc6fe4bbd2848c1ec247b0a0b/radius/resource-types/types.yaml}" +BICEP_EXTENSION_TARGET="${BICEP_EXTENSION_TARGET:-artifacts/types.tgz}" +PORTFOLIO="${PORTFOLIO:-core}" +SOURCE_COMMIT="${SOURCE_COMMIT:-885627980684e5bcc6fe4bbd2848c1ec247b0a0b}" + +[[ "$(kubectl config current-context)" == "$AKS_CONTEXT" ]] || { + echo "Select AKS context $AKS_CONTEXT before running this script." >&2 + exit 1 +} +[[ -f "$SQL_TYPE_FILE" ]] || { + echo "Resource type file not found: $SQL_TYPE_FILE" >&2 + exit 1 +} + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT +CATALOG_FILE="${TEMP_DIR}/types.yaml" +SOURCE_ARCHIVE="${TEMP_DIR}/adaptive-apps.tar.gz" +SOURCE_ROOT="${TEMP_DIR}/adaptive-apps-${SOURCE_COMMIT}" + +curl --fail --location "$TYPES_URL" --output "$CATALOG_FILE" +curl --fail --location \ + "https://github.com/microsoft/adaptive-apps/archive/${SOURCE_COMMIT}.tar.gz" \ + --output "$SOURCE_ARCHIVE" +tar --extract --gzip --file "$SOURCE_ARCHIVE" --directory "$TEMP_DIR" + +helm upgrade --install "$PORTFOLIO" \ + "${SOURCE_ROOT}/charts/adaptive-apps" \ + --values "${SOURCE_ROOT}/charts/adaptive-apps/profiles/${PORTFOLIO}.yaml" \ + --set features.istio.install=false \ + --set istio.namespace=aks-istio-system \ + --namespace "$PORTFOLIO" \ + --create-namespace +kubectl rollout status deployment --all --namespace "$PORTFOLIO" --timeout=15m +kubectl rollout status statefulset --all --namespace "$PORTFOLIO" --timeout=15m + +rad workspace switch "$RADIUS_WORKSPACE" +rad resource-type create --workspace "$RADIUS_WORKSPACE" --from-file "$SQL_TYPE_FILE" +rad resource-type create --workspace "$RADIUS_WORKSPACE" --from-file "$CATALOG_FILE" + +mkdir -p "$(dirname "$BICEP_EXTENSION_TARGET")" +rad bicep publish-extension \ + --from-file "$CATALOG_FILE" \ + --target "$BICEP_EXTENSION_TARGET" \ + --force + +rad resource-type show Radius.Resources/sqlDatabases >/dev/null +rad resource-type show Radius.Resources/postgreSqlDatabases >/dev/null +rad resource-type show Radius.Resources/aiModels >/dev/null +rad resource-type list + +echo "Portfolio and resource types configured on AKS workspace ${RADIUS_WORKSPACE}." diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh new file mode 100644 index 000000000..a62c6cbd8 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +for command_name in curl helm kubectl mktemp rad tar; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "Required command not found: $command_name" >&2 + exit 1 + } +done + +K3S_KUBECONFIG="${K3S_KUBECONFIG:-$HOME/.kube/adaptive-apps-k3s.yaml}" +K3S_CONTEXT="${K3S_CONTEXT:-k3s-azure-vm}" +RADIUS_WORKSPACE="${RADIUS_WORKSPACE:-ws-local-prod}" +SQL_TYPE_FILE="${SQL_TYPE_FILE:-iac/sql-databases.yaml}" +TYPES_URL="${TYPES_URL:-https://raw.githubusercontent.com/microsoft/adaptive-apps/885627980684e5bcc6fe4bbd2848c1ec247b0a0b/radius/resource-types/types.yaml}" +BICEP_EXTENSION_TARGET="${BICEP_EXTENSION_TARGET:-artifacts/types.tgz}" +PORTFOLIO="${PORTFOLIO:-core}" +SOURCE_COMMIT="${SOURCE_COMMIT:-885627980684e5bcc6fe4bbd2848c1ec247b0a0b}" + +export KUBECONFIG="${KUBECONFIG:-$K3S_KUBECONFIG}" + +[[ "$(kubectl config current-context)" == "$K3S_CONTEXT" ]] || { + echo "Select K3s context $K3S_CONTEXT before running this script." >&2 + exit 1 +} +[[ -f "$SQL_TYPE_FILE" ]] || { + echo "Resource type file not found: $SQL_TYPE_FILE" >&2 + exit 1 +} + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT +CATALOG_FILE="${TEMP_DIR}/types.yaml" +SOURCE_ARCHIVE="${TEMP_DIR}/adaptive-apps.tar.gz" +SOURCE_ROOT="${TEMP_DIR}/adaptive-apps-${SOURCE_COMMIT}" + +curl --fail --location "$TYPES_URL" --output "$CATALOG_FILE" +curl --fail --location \ + "https://github.com/microsoft/adaptive-apps/archive/${SOURCE_COMMIT}.tar.gz" \ + --output "$SOURCE_ARCHIVE" +tar --extract --gzip --file "$SOURCE_ARCHIVE" --directory "$TEMP_DIR" + +helm upgrade --install "$PORTFOLIO" \ + "${SOURCE_ROOT}/charts/adaptive-apps" \ + --values "${SOURCE_ROOT}/charts/adaptive-apps/profiles/${PORTFOLIO}.yaml" \ + --namespace "$PORTFOLIO" \ + --create-namespace +kubectl rollout status deployment --all --namespace "$PORTFOLIO" --timeout=15m +kubectl rollout status statefulset --all --namespace "$PORTFOLIO" --timeout=15m + +rad workspace switch "$RADIUS_WORKSPACE" +rad resource-type create --workspace "$RADIUS_WORKSPACE" --from-file "$SQL_TYPE_FILE" +rad resource-type create --workspace "$RADIUS_WORKSPACE" --from-file "$CATALOG_FILE" + +mkdir -p "$(dirname "$BICEP_EXTENSION_TARGET")" +rad bicep publish-extension \ + --from-file "$CATALOG_FILE" \ + --target "$BICEP_EXTENSION_TARGET" \ + --force + +rad resource-type show Radius.Resources/sqlDatabases >/dev/null +rad resource-type show Radius.Resources/postgreSqlDatabases >/dev/null +rad resource-type show Radius.Resources/aiModels >/dev/null +rad resource-type list + +echo "Portfolio and resource types configured on K3s workspace ${RADIUS_WORKSPACE}." diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh new file mode 100644 index 000000000..e3bbc2960 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +set -euo pipefail + +for command_name in az grep kubectl mktemp rad; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "Required command not found: $command_name" >&2 + exit 1 + } +done + +: "${AZURE_SUBSCRIPTION:?Set AZURE_SUBSCRIPTION to the target subscription ID.}" +: "${RESOURCE_GROUP:?Set RESOURCE_GROUP to the Azure resource group used by Radius.}" + +AKS_CLUSTER="${AKS_CLUSTER:-aks-adaptive-apps}" +RADIUS_WORKSPACE="${RADIUS_WORKSPACE:-ws-azure-prod}" +RADIUS_ENVIRONMENT="${RADIUS_ENVIRONMENT:-env-azure-prod}" +RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" +RADIUS_NAMESPACE="${RADIUS_NAMESPACE:-env-azure-prod}" +RADIUS_AZURE_ROLE="${RADIUS_AZURE_ROLE:-Owner}" +RADIUS_APP_NAME="${RADIUS_APP_NAME:-${AKS_CLUSTER}-radius-app}" + +az account show >/dev/null 2>&1 || az login +az account set --subscription "$AZURE_SUBSCRIPTION" + +CURRENT_CONTEXT="$(kubectl config current-context)" +if [[ "$CURRENT_CONTEXT" != "$AKS_CLUSTER" ]]; then + echo "Current context is $CURRENT_CONTEXT; expected $AKS_CLUSTER." >&2 + exit 1 +fi + +OIDC_ISSUER="$(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --query oidcIssuerProfile.issuerUrl \ + --output tsv)" +[[ -n "$OIDC_ISSUER" ]] || { + echo "AKS OIDC issuer is not enabled." >&2 + exit 1 +} + +APPLICATION_CLIENT_ID="$(az ad app list \ + --filter "displayName eq '${RADIUS_APP_NAME}'" \ + --query "[0].appId" \ + --output tsv)" + +if [[ -z "$APPLICATION_CLIENT_ID" ]]; then + APPLICATION_CLIENT_ID="$(az ad app create \ + --display-name "$RADIUS_APP_NAME" \ + --query appId \ + --output tsv)" +fi + +APPLICATION_OBJECT_ID="$(az ad app show \ + --id "$APPLICATION_CLIENT_ID" \ + --query id \ + --output tsv)" + +if ! az ad sp show --id "$APPLICATION_CLIENT_ID" >/dev/null 2>&1; then + az ad sp create --id "$APPLICATION_CLIENT_ID" --output none + for attempt in {1..30}; do + az ad sp show --id "$APPLICATION_CLIENT_ID" >/dev/null 2>&1 && break + [[ "$attempt" -lt 30 ]] || { + echo "Timed out waiting for the Radius service principal." >&2 + exit 1 + } + sleep 5 + done +fi + +SERVICE_PRINCIPAL_OBJECT_ID="$(az ad sp show \ + --id "$APPLICATION_CLIENT_ID" \ + --query id \ + --output tsv)" + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +ensure_federated_credential() { + local name="$1" + local service_account="$2" + local credential_id + local current_issuer + local current_subject + + credential_id="$(az ad app federated-credential list \ + --id "$APPLICATION_OBJECT_ID" \ + --query "[?name=='${name}'].id | [0]" \ + --output tsv)" + + if [[ -n "$credential_id" ]]; then + current_issuer="$(az ad app federated-credential show \ + --id "$APPLICATION_OBJECT_ID" \ + --federated-credential-id "$credential_id" \ + --query issuer \ + --output tsv)" + current_subject="$(az ad app federated-credential show \ + --id "$APPLICATION_OBJECT_ID" \ + --federated-credential-id "$credential_id" \ + --query subject \ + --output tsv)" + if [[ "$current_issuer" == "$OIDC_ISSUER" && + "$current_subject" == "system:serviceaccount:radius-system:${service_account}" ]]; then + return + fi + az ad app federated-credential delete \ + --id "$APPLICATION_OBJECT_ID" \ + --federated-credential-id "$credential_id" + fi + + cat >"${TEMP_DIR}/${name}.json" </dev/null 2>&1; then + INSTALL_ARGS+=(--reinstall) +fi +rad install "${INSTALL_ARGS[@]}" + +rad workspace create kubernetes "$RADIUS_WORKSPACE" \ + --context "$CURRENT_CONTEXT" \ + --force +rad workspace switch "$RADIUS_WORKSPACE" + +rad group show "$RADIUS_GROUP" >/dev/null 2>&1 || + rad group create "$RADIUS_GROUP" +rad group switch "$RADIUS_GROUP" + +rad env show "$RADIUS_ENVIRONMENT" >/dev/null 2>&1 || + rad env create "$RADIUS_ENVIRONMENT" \ + --group "$RADIUS_GROUP" \ + --kubernetes-namespace "$RADIUS_NAMESPACE" +rad env update "$RADIUS_ENVIRONMENT" \ + --azure-subscription-id "$AZURE_SUBSCRIPTION" \ + --azure-resource-group "$RESOURCE_GROUP" +rad env switch "$RADIUS_ENVIRONMENT" + +TENANT_ID="$(az account show --query tenantId --output tsv)" +rad credential register azure wi \ + --client-id "$APPLICATION_CLIENT_ID" \ + --tenant-id "$TENANT_ID" + +kubectl wait --for=condition=Available deployments --all \ + --namespace radius-system \ + --timeout=10m +kubectl get crd applications.radapp.io >/dev/null +rad env show "$RADIUS_ENVIRONMENT" >/dev/null +rad group show "$RADIUS_GROUP" >/dev/null +rad credential show azure >/dev/null +kubectl get pods --namespace radius-system + +echo "Radius on AKS is healthy in workspace ${RADIUS_WORKSPACE}." diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh new file mode 100644 index 000000000..eb6864d85 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +for command_name in kubectl rad; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "Required command not found: $command_name" >&2 + exit 1 + } +done + +K3S_KUBECONFIG="${K3S_KUBECONFIG:-$HOME/.kube/adaptive-apps-k3s.yaml}" +K3S_CONTEXT="${K3S_CONTEXT:-k3s-azure-vm}" +RADIUS_WORKSPACE="${RADIUS_WORKSPACE:-ws-local-prod}" +RADIUS_ENVIRONMENT="${RADIUS_ENVIRONMENT:-env-local-prod}" +RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" +RADIUS_NAMESPACE="${RADIUS_NAMESPACE:-env-local-prod}" + +export KUBECONFIG="${KUBECONFIG:-$K3S_KUBECONFIG}" + +CURRENT_CONTEXT="$(kubectl config current-context)" +if [[ "$CURRENT_CONTEXT" != "$K3S_CONTEXT" ]]; then + echo "Current context is $CURRENT_CONTEXT; expected $K3S_CONTEXT." >&2 + exit 1 +fi + +INSTALL_ARGS=(kubernetes --kubecontext "$CURRENT_CONTEXT") +if kubectl get namespace radius-system >/dev/null 2>&1; then + INSTALL_ARGS+=(--reinstall) +fi +rad install "${INSTALL_ARGS[@]}" + +rad workspace create kubernetes "$RADIUS_WORKSPACE" \ + --context "$CURRENT_CONTEXT" \ + --force +rad workspace switch "$RADIUS_WORKSPACE" + +rad group show "$RADIUS_GROUP" >/dev/null 2>&1 || + rad group create "$RADIUS_GROUP" +rad group switch "$RADIUS_GROUP" + +rad env show "$RADIUS_ENVIRONMENT" >/dev/null 2>&1 || + rad env create "$RADIUS_ENVIRONMENT" \ + --group "$RADIUS_GROUP" \ + --kubernetes-namespace "$RADIUS_NAMESPACE" +rad env switch "$RADIUS_ENVIRONMENT" + +kubectl wait --for=condition=Available deployments --all \ + --namespace radius-system \ + --timeout=10m +kubectl get crd applications.radapp.io >/dev/null +rad env show "$RADIUS_ENVIRONMENT" >/dev/null +rad group show "$RADIUS_GROUP" >/dev/null +kubectl get pods --namespace radius-system + +echo "Radius on K3s is healthy in workspace ${RADIUS_WORKSPACE}." diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh new file mode 100644 index 000000000..62baa81b2 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +# AKS credentials belong in the default kubeconfig, not a previously selected K3s file. +unset KUBECONFIG + +for command_name in az grep kubectl; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "Required command not found: $command_name" >&2 + exit 1 + } +done + +: "${AZURE_SUBSCRIPTION:?Set AZURE_SUBSCRIPTION to the target subscription ID.}" +AZURE_LOCATION="${AZURE_LOCATION:-westeurope}" +RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +AKS_CLUSTER="${AKS_CLUSTER:-aks-adaptive-apps}" +AKS_NODE_COUNT="${AKS_NODE_COUNT:-2}" +AKS_NODE_VM_SIZE="${AKS_NODE_VM_SIZE:-Standard_D4s_v5}" + +az account show >/dev/null 2>&1 || az login +az account set --subscription "$AZURE_SUBSCRIPTION" +az group create --name "$RESOURCE_GROUP" --location "$AZURE_LOCATION" --output none + +if az aks show --resource-group "$RESOURCE_GROUP" --name "$AKS_CLUSTER" >/dev/null 2>&1; then + az aks update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --enable-oidc-issuer \ + --enable-workload-identity \ + --output none + EXISTING_SERVICE_MESH_MODE="$(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --query serviceMeshProfile.mode \ + --output tsv)" + if [[ "$EXISTING_SERVICE_MESH_MODE" != "Istio" ]]; then + az aks mesh enable \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --output none + fi +else + az aks create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --location "$AZURE_LOCATION" \ + --node-count "$AKS_NODE_COUNT" \ + --node-vm-size "$AKS_NODE_VM_SIZE" \ + --node-osdisk-type Managed \ + --enable-oidc-issuer \ + --enable-workload-identity \ + --enable-asm \ + --generate-ssh-keys \ + --output none +fi + +az aks get-credentials \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --overwrite-existing \ + --output none +kubectl config use-context "$AKS_CLUSTER" >/dev/null + +PROVISIONING_STATE="$(az aks show -g "$RESOURCE_GROUP" -n "$AKS_CLUSTER" --query provisioningState -o tsv)" +OIDC_ENABLED="$(az aks show -g "$RESOURCE_GROUP" -n "$AKS_CLUSTER" --query oidcIssuerProfile.enabled -o tsv)" +WORKLOAD_IDENTITY_ENABLED="$(az aks show -g "$RESOURCE_GROUP" -n "$AKS_CLUSTER" --query securityProfile.workloadIdentity.enabled -o tsv)" +SERVICE_MESH_MODE="$(az aks show -g "$RESOURCE_GROUP" -n "$AKS_CLUSTER" --query serviceMeshProfile.mode -o tsv)" + +[[ "$PROVISIONING_STATE" == "Succeeded" ]] || { + echo "AKS provisioning state is $PROVISIONING_STATE." >&2 + exit 1 +} +[[ "${OIDC_ENABLED,,}" == "true" ]] || { + echo "AKS OIDC issuer is not enabled." >&2 + exit 1 +} +[[ "${WORKLOAD_IDENTITY_ENABLED,,}" == "true" ]] || { + echo "AKS workload identity is not enabled." >&2 + exit 1 +} +[[ "$SERVICE_MESH_MODE" == "Istio" ]] || { + echo "AKS managed Istio is not enabled." >&2 + exit 1 +} + +kubectl get --raw="/readyz" | grep -qx "ok" +kubectl wait --for=condition=Ready nodes --all --timeout=10m +kubectl get namespace aks-istio-system >/dev/null +kubectl get nodes -o wide + +echo "AKS environment is healthy. Context: $(kubectl config current-context)" diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh new file mode 100644 index 000000000..4393cdd39 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +for command_name in az curl grep kubectl sed ssh; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "Required command not found: $command_name" >&2 + exit 1 + } +done + +: "${AZURE_SUBSCRIPTION:?Set AZURE_SUBSCRIPTION to the target subscription ID.}" +AZURE_LOCATION="${AZURE_LOCATION:-westeurope}" +RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +K3S_VM_NAME="${K3S_VM_NAME:-vm-adaptive-apps-k3s}" +K3S_VM_SIZE="${K3S_VM_SIZE:-Standard_D4s_v5}" +K3S_ADMIN_USERNAME="${K3S_ADMIN_USERNAME:-azureuser}" +K3S_KUBECONFIG="${K3S_KUBECONFIG:-$HOME/.kube/adaptive-apps-k3s.yaml}" +K3S_CONTEXT="${K3S_CONTEXT:-k3s-azure-vm}" + +if [[ -z "${ADMIN_CIDR:-}" ]]; then + ADMIN_CIDR="$(curl --fail --silent --show-error https://api.ipify.org)/32" + echo "ADMIN_CIDR was not set; using ${ADMIN_CIDR}." +fi + +az account show >/dev/null 2>&1 || az login +az account set --subscription "$AZURE_SUBSCRIPTION" +az group create --name "$RESOURCE_GROUP" --location "$AZURE_LOCATION" --output none + +if ! az vm show -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" >/dev/null 2>&1; then + az vm create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM_NAME" \ + --location "$AZURE_LOCATION" \ + --image Ubuntu2204 \ + --size "$K3S_VM_SIZE" \ + --admin-username "$K3S_ADMIN_USERNAME" \ + --authentication-type ssh \ + --generate-ssh-keys \ + --public-ip-sku Standard \ + --nsg-rule NONE \ + --os-disk-size-gb 64 \ + --storage-sku StandardSSD_LRS \ + --output none +else + az vm start -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" --output none +fi + +NIC_ID="$(az vm show -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" --query "networkProfile.networkInterfaces[0].id" -o tsv)" +NSG_ID="$(az network nic show --ids "$NIC_ID" --query networkSecurityGroup.id -o tsv)" +[[ -n "$NSG_ID" ]] || { + echo "The K3s VM network interface has no network security group." >&2 + exit 1 +} +NSG_NAME="${NSG_ID##*/}" + +create_rule() { + az network nsg rule create \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$NSG_NAME" \ + --name "$1" \ + --priority "$2" \ + --access Allow \ + --protocol Tcp \ + --direction Inbound \ + --source-address-prefixes "$3" \ + --destination-port-ranges "$4" \ + --output none +} + +create_rule AllowSshFromAdmin 900 "$ADMIN_CIDR" 22 +create_rule AllowKubernetesApiFromAdmin 910 "$ADMIN_CIDR" 6443 +create_rule AllowHttp 1000 Internet 80 +create_rule AllowHttps 1010 Internet 443 + +PUBLIC_IP="$(az vm show -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" --show-details --query publicIps -o tsv)" +[[ -n "$PUBLIC_IP" ]] || { + echo "The K3s VM has no public IP address." >&2 + exit 1 +} + +az vm run-command invoke \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM_NAME" \ + --command-id RunShellScript \ + --scripts " +set -eu +mkdir -p /etc/rancher/k3s +cat >/etc/rancher/k3s/config.yaml </dev/null 2>&1; then + systemctl enable --now k3s + systemctl restart k3s +else + curl -sfL https://get.k3s.io | sh - +fi +systemctl is-active --quiet k3s +" \ + --output none + +for attempt in {1..30}; do + if ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \ + "${K3S_ADMIN_USERNAME}@${PUBLIC_IP}" \ + "sudo systemctl is-active --quiet k3s" 2>/dev/null; then + break + fi + [[ "$attempt" -lt 30 ]] || { + echo "Timed out waiting for SSH access to K3s." >&2 + exit 1 + } + sleep 10 +done + +mkdir -p "$(dirname "$K3S_KUBECONFIG")" +ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ + "${K3S_ADMIN_USERNAME}@${PUBLIC_IP}" \ + "sudo cat /etc/rancher/k3s/k3s.yaml" | + sed "s/127.0.0.1/${PUBLIC_IP}/" >"$K3S_KUBECONFIG" +chmod 600 "$K3S_KUBECONFIG" + +CURRENT_CONTEXT="$(KUBECONFIG="$K3S_KUBECONFIG" kubectl config current-context)" +if [[ "$CURRENT_CONTEXT" != "$K3S_CONTEXT" ]]; then + KUBECONFIG="$K3S_KUBECONFIG" kubectl config rename-context \ + "$CURRENT_CONTEXT" "$K3S_CONTEXT" >/dev/null +fi + +VM_POWER_STATE="$(az vm get-instance-view -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" \ + --query "instanceView.statuses[?starts_with(code, 'PowerState/')].code | [0]" -o tsv)" +[[ "$VM_POWER_STATE" == "PowerState/running" ]] || { + echo "K3s VM power state is $VM_POWER_STATE." >&2 + exit 1 +} + +KUBECONFIG="$K3S_KUBECONFIG" kubectl get --raw="/readyz" | grep -qx "ok" +KUBECONFIG="$K3S_KUBECONFIG" kubectl wait --for=condition=Ready nodes --all --timeout=10m +KUBECONFIG="$K3S_KUBECONFIG" kubectl get nodes -o wide + +echo "K3s environment is healthy." +echo "Use it with: export KUBECONFIG=\"${K3S_KUBECONFIG}\"" diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md new file mode 100644 index 000000000..96d281af9 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md @@ -0,0 +1,281 @@ +# Walkthrough Challenge 00 - Prerequisites: ready, set, go + +**[Home](../../Readme.md)** - [Next Solution](../challenge-01/solution-01.md) + +## Coach notes + +Challenge 00 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 01, where they provision AKS and K3s. + +Share these instructions one or two weeks before the MicroHack when possible. + +## Required access and capacity + +Confirm the workshop has: + +- An Azure subscription +- Permission to create a resource group, AKS cluster, Linux VM, networking, and role + assignments +- Permission to create a Microsoft Entra application and service principal +- Sufficient regional quota for: + - A two-node AKS cluster using `Standard_D4s_v5` by default + - One K3s VM using `Standard_D4s_v5` by default + - Standard public IP addresses +- A known public egress CIDR for secure K3s management access + +The default scripts assign the Radius identity `Owner` at the lab resource-group +scope because later exercises can create role assignments. Use a dedicated lab +subscription or resource group. + +## Required tools + +- Azure CLI +- `kubectl` +- Helm +- Radius CLI (`rad`) +- Git +- `curl` +- OpenSSH client +- `tar` +- Visual Studio Code +- Bash for the optional automation scripts + +Windows participants should use WSL 2 for the Bash automation scripts. Manual +instructions can also be followed from PowerShell 7 where equivalent commands are +available. + +## Installation instructions + +### macOS + +Install Azure CLI: + +```bash +brew install azure-cli +``` + +Install `kubectl`: + +```bash +brew install kubectl +``` + +Install Helm: + +```bash +brew install helm +``` + +Install Git and supporting command-line tools: + +```bash +brew install git curl +``` + +Install Radius CLI: + +```bash +curl -fsSL https://raw.githubusercontent.com/radius-project/radius/main/deploy/install.sh | + /bin/bash +``` + +If `rad` is not found, add the installer path and reload the shell: + +```bash +echo 'export PATH="$HOME/.local/bin:$PATH"' >>~/.zshrc +source ~/.zshrc +``` + +Install Visual Studio Code: + +```bash +brew install --cask visual-studio-code +``` + +### Linux: Ubuntu or Debian + +Install Azure CLI: + +```bash +curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash +``` + +Install `kubectl`: + +```bash +curl -LO "https://dl.k8s.io/release/$(curl -L -s \ + https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" +sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl +``` + +Install Helm: + +```bash +curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash +``` + +Install supporting tools: + +```bash +sudo apt-get update +sudo apt-get install --yes git curl openssh-client tar +``` + +Install Radius CLI: + +```bash +wget -q "https://raw.githubusercontent.com/radius-project/radius/main/deploy/install.sh" \ + -O - | /bin/bash +``` + +Add Radius to `PATH` if needed: + +```bash +echo 'export PATH="$HOME/.local/bin:$PATH"' >>~/.bashrc +source ~/.bashrc +``` + +Install Visual Studio Code from +[code.visualstudio.com](https://code.visualstudio.com/) or with Snap: + +```bash +sudo snap install code --classic +``` + +### Windows: WSL 2 and PowerShell 7 + +Install WSL 2 with Ubuntu from an elevated PowerShell prompt: + +```powershell +wsl --install -d Ubuntu +``` + +Restart if requested, open Ubuntu, and follow the Linux installation instructions. +This is the recommended environment for the Bash automation scripts. + +Tools can alternatively be installed on Windows with `winget`: + +```powershell +winget install Microsoft.AzureCLI +winget install Kubernetes.kubectl +winget install Helm.Helm +winget install RadiusProject.rad +winget install Git.Git +winget install Microsoft.VisualStudioCode +winget install Microsoft.PowerShell +``` + +> [!IMPORTANT] +> Keep a toolchain together. If automation runs in WSL, install Azure CLI, `kubectl`, +> Helm, `rad`, Git, SSH, and `tar` inside WSL and keep kubeconfig files there. + +## Verify the workstation + +Run: + +```bash +az --version +kubectl version --client +helm version +rad version +git --version +curl --version +ssh -V +tar --version +code --version +``` + +Sign in and select the intended subscription: + +```bash +az login +az account list --output table +az account set --subscription "" +az account show --output table +``` + +Confirm the required resource providers can be registered: + +```bash +az provider register --namespace Microsoft.ContainerService +az provider register --namespace Microsoft.Compute +az provider register --namespace Microsoft.Network +``` + +Provider registration is asynchronous: + +```bash +az provider show \ + --namespace Microsoft.ContainerService \ + --query registrationState \ + --output tsv +``` + +## Verification checklist + +### Azure access + +- `az account show` returns the intended subscription. +- The participant can create resources in the lab resource group. +- The coach can create Entra applications or has arranged an identity in advance. +- AKS and VM quota are sufficient in the selected region. + +### Kubernetes tooling + +- `kubectl version --client` succeeds. +- No cluster connection is required yet; Challenge 01 creates the clusters. + +### Helm + +- `helm version` succeeds. +- Helm 3 is installed. + +### Radius + +- `rad version` succeeds. +- Use the latest stable CLI so the installed control plane uses the matching version. + +### Shell and supporting tools + +- Bash can run the optional automation scripts. +- Git, `curl`, SSH, and `tar` are available. +- On Windows, the team has agreed whether commands run in WSL or native PowerShell. + +## Troubleshooting + +| Tool or symptom | Guidance | +| --- | --- | +| `az: command not found` | Reinstall Azure CLI, restart the shell, and inspect `PATH`. | +| `kubectl: command not found` | Reinstall `kubectl` and inspect `PATH`. | +| `rad: command not found` | Add `$HOME/.local/bin` to `PATH` or reinstall from the Radius releases page. | +| Helm permission error | Verify the Helm binary and local cache ownership; do not default to running Helm as root. | +| Wrong Azure subscription | Run `az account set --subscription ""`. | +| Azure authorization error | Confirm the participant's role and scope before troubleshooting scripts. | +| AKS or VM quota error | Select another VM size or region, or request quota before the event. | +| WSL cannot see native Windows tools | Install the complete toolchain inside WSL rather than mixing environments. | + +Azure Cloud Shell can temporarily help with Azure resource inspection, but the K3s +kubeconfig and later local files still require a consistent participant workstation. + +## Tips for coaches + +- Send these instructions early and ask participants to return the verification output. +- Validate AKS and VM quota before the event. +- Agree on one Azure region and one lab resource-group naming convention. +- Determine the workshop's public egress CIDR for the K3s network rules. +- Decide who runs shared cluster deployment scripts; only one person should provision + each shared environment. +- Encourage participants to use manual tutorials for learning. Automation is an + optional shortcut, not the primary path. +- Keep Challenge 00 focused on verification. Challenge 01 contains the platform work. + +## Expected outcome + +After Challenge 00, participants can: + +- Use Azure CLI and select the correct subscription. +- Run `kubectl`, Helm, and Radius CLI. +- Use Git, `curl`, SSH, and `tar`. +- Explain where their kubeconfig and Radius workspace configuration will be stored. +- Proceed to [Challenge 01](../challenge-01/solution-01.md) to provision AKS and K3s. 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 new file mode 100644 index 000000000..25f04daa8 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-01/solution-01.md @@ -0,0 +1,159 @@ +# Walkthrough Challenge 01 - Prepare the platforms + +[< Previous Solution](../challenge-00/solution-00.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-02/solution-02.md) + +Duration: 30-60 minutes + +## Prerequisites + +Complete [Challenge 00](../challenge-00/solution-00.md) and the +[general prerequisites](../../Readme.md#general-prerequisites) before starting. + +This challenge provisions Kubernetes only. Do not install Radius, the Adaptive Apps +portfolio, recipes, or application workloads yet. + +## Default workshop topology + +| Logical environment | Platform | Purpose | +| --- | --- | --- | +| Azure | Azure Kubernetes Service (AKS) | Azure-managed Kubernetes with OIDC issuer, workload identity, and managed Istio enabled. | +| Local | Single-node K3s on an Azure Linux VM | Self-managed Kubernetes representative of an on-premises or edge platform. | + +Both clusters run in Azure for workshop convenience, but only one is AKS. K3s does not +inherit AKS cluster identity, networking, ACR integration, or managed add-ons. Those +differences form the portability boundary used in later challenges. + +The scripts are idempotent: rerunning one reuses its resource group and cluster or VM, +refreshes credentials, and repeats the health check. + +Common blockers: + +| Blocker | Guidance | +| --- | --- | +| Azure cannot allocate the requested VM size | Set `AKS_NODE_VM_SIZE` or `K3S_VM_SIZE` to an available size, or choose another `AZURE_LOCATION`. | +| AKS nodes remain `NotReady` | Check `az aks show --query provisioningState` and allow time for the initial node image pull. | +| K3s API is unreachable | Confirm the caller is included in `ADMIN_CIDR` and TCP 6443 is allowed by the VM network security group. | +| A teammate cannot access K3s | Add the teammate's public CIDR deliberately; do not expose management ports to the internet. | +| Commands target the wrong cluster | Check both `KUBECONFIG` and `kubectl config current-context`. | + +## Task 1: Provision AKS + +The script creates or updates an Azure resource group and a two-node AKS cluster, +enables OIDC issuer and workload identity, refreshes kubeconfig, and runs health checks. +It does not create ACR, Key Vault, Storage, Radius, or portfolio resources. + +From the MicroHack directory: + +```bash +cd "03-Azure/01-01-App Innovation/04-adaptive-apps" + +export AZURE_SUBSCRIPTION="" +export AZURE_LOCATION="westeurope" +export RESOURCE_GROUP="rg-adaptive-apps" +export AKS_CLUSTER="aks-adaptive-apps" + +chmod +x resources/prepare-aks.sh +./resources/prepare-aks.sh +``` + +Optional settings: + +```bash +export AKS_NODE_COUNT=2 +export AKS_NODE_VM_SIZE="Standard_D4s_v5" +``` + +The script merges AKS credentials into the default kubeconfig and activates the +context named after `AKS_CLUSTER`. + +## Task 2: Provision K3s on an Azure VM + +The script creates or reuses an Ubuntu VM with a static public IP, restricts SSH and +the Kubernetes API to the coach's CIDR, opens HTTP/HTTPS for later exercises, installs +K3s, writes a dedicated kubeconfig, and runs health checks. + +```bash +export AZURE_SUBSCRIPTION="" +export AZURE_LOCATION="westeurope" +export RESOURCE_GROUP="rg-adaptive-apps" +export K3S_VM_NAME="vm-adaptive-apps-k3s" + +# Recommended: set this explicitly to the coach or workshop egress IP. +export ADMIN_CIDR="/32" + +chmod +x resources/prepare-k3s-azure-vm.sh +./resources/prepare-k3s-azure-vm.sh +``` + +Optional settings: + +```bash +export K3S_VM_SIZE="Standard_D4s_v5" +export K3S_ADMIN_USERNAME="azureuser" +export K3S_KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +``` + +Use K3s: + +```bash +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +kubectl get nodes +``` + +Return to AKS: + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +kubectl get nodes +``` + +> [!IMPORTANT] +> Treat kubeconfig files as credentials. Share the K3s kubeconfig only through an +> approved secure channel and remove access when the workshop ends. + +## Health checks + +Each script exits with a nonzero status when its environment is unhealthy. Checks +cover Azure provisioning, the active context, Kubernetes API readiness, node +readiness, AKS identity features, and the K3s service. + +Repeat the Kubernetes checks manually: + +```bash +# AKS +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +kubectl get --raw="/readyz" +kubectl wait --for=condition=Ready nodes --all --timeout=10m +kubectl get nodes -o wide + +# K3s +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +kubectl get --raw="/readyz" +kubectl wait --for=condition=Ready nodes --all --timeout=10m +kubectl get nodes -o wide +``` + +Do not continue until both APIs return `ok` and every node is `Ready`. + +## Optional platforms + +Use these manual paths only when the workshop already has suitable infrastructure: + +| Platform | Manual preparation | +| --- | --- | +| Azure Local with AKS enabled by Azure Arc | [Prepare Azure Local](../../docs/prepare-azure-local.md) | +| Existing Kubernetes connected with Azure Arc | [Prepare Azure Arc-enabled Kubernetes](../../docs/prepare-arc.md) | + +Arc enablement is optional for the default K3s VM. Arc projects an existing cluster +into Azure management; it does not create the cluster and is not required by Radius. + +## Completion criteria + +- Both scripts finish successfully. +- Coaches can switch explicitly between AKS and K3s. +- Both Kubernetes APIs and all nodes are healthy. +- No Radius control plane, portfolio, recipes, or application workloads are installed. 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 new file mode 100644 index 000000000..f8cbddba4 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md @@ -0,0 +1,338 @@ +# Walkthrough Challenge 02 - Deploy and explore Radius + +[< Previous Solution](../challenge-01/solution-01.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-03/solution-03.md) + +Duration: 30-45 minutes per environment + +## Coach notes + +- 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. +- Only one team member runs each installation. Workspaces are local to each + participant's workstation. +- Radius startup can take 5-10 minutes. Inspect pod state before rerunning commands. +- Always compare `kubectl config current-context` with the active Radius workspace. + +## Control-plane deployment options + +### Federated control planes + +Each site runs an independent Radius control plane: + +```mermaid +flowchart LR + Git[Shared Git source] + AKS[AKS
Radius control plane
env-azure-prod] + K3s[K3s on Azure VM
Radius control plane
env-local-prod] + Git -->|promote configuration| AKS + Git -->|promote configuration| K3s + AKS -. no runtime dependency .- K3s +``` + +Key characteristics: + +- A site or WAN outage does not block the other site. +- Configuration is promoted independently from a shared source. +- Data synchronization is a separate application concern. +- The model fits edge, disconnected, and isolated failure-domain scenarios. + +### Centralized control plane + +```mermaid +flowchart TD + Management[Management cluster
shared Radius control plane] + Azure[Azure execution environment] + Local[Local or edge execution environment] + Management --> Azure + Management --> Local +``` + +Key characteristics: + +- Governance and operations are centralized. +- Every site requires reliable connectivity to the management control plane. +- A control-plane outage affects all attached environments. + +| Scenario | Typical choice | +| --- | --- | +| Single Azure region | Centralized | +| Azure plus always-connected datacenter | Centralized or federated | +| Azure plus intermittently connected edge | Federated | +| Disconnected or air-gapped site | Federated | +| Independent sites across a WAN | Federated unless shared connectivity and failure domains are acceptable | + +The default AKS and K3s topology follows the federated model. K3s runs on an Azure VM +for workshop convenience but remains a self-managed Kubernetes platform. + +## Understand the Radius hierarchy + +### Control plane + +A Radius control plane is installed once per Kubernetes cluster. It includes API +services, controllers, the Bicep deployment engine, UCP, CRDs, and the dashboard. + +### Workspace + +A workspace is local CLI configuration stored in `~/.rad/config.yaml`. + +- One workspace targets one control plane. +- Switching workspace changes the destination of subsequent `rad` commands. +- Workspace configuration is per workstation, not stored in Kubernetes. + +### Environment + +An environment defines how and where applications are deployed: + +- A Kubernetes namespace +- Optional cloud-provider configuration +- Recipe registrations added in later challenges + +`env-azure-prod` can use Azure-backed recipes. `env-local-prod` has no Azure provider +and uses in-cluster implementations. + +### Group + +A Radius group is a logical container for applications and resources. It is not an +Azure resource group and has no Azure billing scope. + +```text +Workstation rad configuration +| +|-- Workspace: ws-azure-prod +| `-- AKS Radius control plane +| `-- Environment: env-azure-prod +| `-- Group: rg-trading +| `-- Application: adaptive-apps +| +`-- Workspace: ws-local-prod + `-- K3s Radius control plane + `-- Environment: env-local-prod + `-- Group: rg-trading + `-- Application: adaptive-apps +``` + +## Manual tutorial: AKS + +### 1. Select and verify AKS + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +kubectl config current-context +kubectl get nodes +``` + +### 2. Prepare Azure workload identity + +Set the Azure values: + +```bash +export AZURE_SUBSCRIPTION="" +export RESOURCE_GROUP="rg-adaptive-apps" +export AKS_CLUSTER="aks-adaptive-apps" +export RADIUS_APP_NAME="${AKS_CLUSTER}-radius-app" + +az account set --subscription "$AZURE_SUBSCRIPTION" +export OIDC_ISSUER="$(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --query oidcIssuerProfile.issuerUrl \ + --output tsv)" +export TENANT_ID="$(az account show --query tenantId --output tsv)" +``` + +Create an Entra application and service principal: + +```bash +export APPLICATION_CLIENT_ID="$(az ad app create \ + --display-name "$RADIUS_APP_NAME" \ + --query appId \ + --output tsv)" +export APPLICATION_OBJECT_ID="$(az ad app show \ + --id "$APPLICATION_CLIENT_ID" \ + --query id \ + --output tsv)" + +az ad sp create --id "$APPLICATION_CLIENT_ID" +``` + +For each Radius service account, create a federated credential. The example below is +for `applications-rp`; repeat it for `bicep-de`, `ucp`, and `dynamic-rp`, changing +both `name` and `subject`: + +```bash +cat >radius-applications-rp.json <. Stop the port-forward, switch both kube context and +Radius workspace, and repeat for the other platform. + +Discuss: + +- Which objects live in the cluster and which live on the workstation? +- What happens to K3s applications if AKS is unavailable? +- Why can both sites use `rg-trading` without sharing a group? +- Which environment can provision Azure-managed resources, and why? + +## Optional platforms + +The same Radius installation principles apply when Azure Local or an existing +Arc-enabled cluster replaces K3s: + +- [Azure Local preparation](../../docs/prepare-azure-local.md) +- [Azure Arc-enabled Kubernetes preparation](../../docs/prepare-arc.md) + +Use the platform's active kube context, create a distinct Radius workspace, and decide +explicitly whether that installation needs an Azure provider. + +## Optional automation + +The automation performs the same steps above and is intended only for participants +who choose to skip the manual tutorial. + +### AKS + +```bash +cd "03-Azure/01-01-App Innovation/04-adaptive-apps" +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps + +export AZURE_SUBSCRIPTION="" +export RESOURCE_GROUP="rg-adaptive-apps" +export AKS_CLUSTER="aks-adaptive-apps" + +chmod +x resources/deploy-radius-aks.sh +./resources/deploy-radius-aks.sh +``` + +### K3s + +```bash +cd "03-Azure/01-01-App Innovation/04-adaptive-apps" +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm + +chmod +x resources/deploy-radius-k3s.sh +./resources/deploy-radius-k3s.sh +``` 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 new file mode 100644 index 000000000..1f2c45da9 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md @@ -0,0 +1,329 @@ +# Walkthrough Challenge 03 - Build the platform abstractions + +[< Previous Solution](../challenge-02/solution-02.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-04/solution-04.md) + +Duration: 45-75 minutes + +## Coach notes + +This challenge makes the platform-engineering story concrete. Teams define the +vocabulary that application developers use in later challenges. + +The sequence is deliberate: + +1. Install a capability portfolio that represents the platform contract. +2. Create one resource type manually to understand the schema. +3. Import the full catalog to establish a realistic platform foundation. + +Do not let teams confuse a resource type with a recipe: + +- A **resource type** is a versioned schema and contract. +- A **recipe** is an environment-specific implementation of that contract. + +The manual steps are the learning path. Optional scripts at the end perform the same +registration for participants who need an automated setup. + +## Capability portfolio + +The portfolio establishes the baseline capabilities offered by the platform before +teams define and implement portable contracts. This is the cleanest point in the +storyline to install it: platform infrastructure and Radius are ready, and recipes are +introduced next in Challenge 04. + +The default profile is `core` on both platforms: + +- Identity through Keycloak and PostgreSQL +- Service mesh with strict mTLS +- Observability through OpenTelemetry, Prometheus, and Zipkin + +AKS uses its managed Istio add-on. K3s installs Istio through the portfolio chart. + +## Manual tutorial, Stage 0: Install the portfolio + +Clone the pinned Adaptive Apps source used by this MicroHack, or point +`ADAPTIVE_APPS_REPO` at an existing checkout of the same commit: + +```bash +export MICROHACK_DIR="$(pwd)" +git clone https://github.com/microsoft/adaptive-apps.git /tmp/adaptive-apps +cd /tmp/adaptive-apps +git checkout 885627980684e5bcc6fe4bbd2848c1ec247b0a0b +export ADAPTIVE_APPS_REPO="$(pwd)" +cd "$MICROHACK_DIR" +``` + +### Install manually on AKS + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps + +export PORTFOLIO=core +helm upgrade --install "$PORTFOLIO" \ + "$ADAPTIVE_APPS_REPO/charts/adaptive-apps" \ + --values "$ADAPTIVE_APPS_REPO/charts/adaptive-apps/profiles/${PORTFOLIO}.yaml" \ + --set features.istio.install=false \ + --set istio.namespace=aks-istio-system \ + --namespace "$PORTFOLIO" \ + --create-namespace +``` + +### Install manually on K3s + +```bash +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm + +export PORTFOLIO=core +helm upgrade --install "$PORTFOLIO" \ + "$ADAPTIVE_APPS_REPO/charts/adaptive-apps" \ + --values "$ADAPTIVE_APPS_REPO/charts/adaptive-apps/profiles/${PORTFOLIO}.yaml" \ + --namespace "$PORTFOLIO" \ + --create-namespace +``` + +K3s was prepared without its bundled Traefik ingress controller so the portfolio can +install and use its own Istio stack without an ingress-controller conflict. + +### Validate the portfolio + +Run against each context: + +```bash +helm status core --namespace core +kubectl rollout status deployment --all --namespace core --timeout=15m +kubectl rollout status statefulset --all --namespace core --timeout=15m +kubectl get pods --namespace core +``` + +Do not continue until the workloads are healthy. + +## Resource-type concepts + +A Radius resource type defines: + +- Input properties supplied by an application developer +- Read-only outputs returned by a recipe +- Secrets that must not appear in plain text + +```text +Application developer writes Platform returns +------------------------------------ -------------------------------- +size: S host: sql.prod.svc +environment: port: 1433 +application: secrets.password: +``` + +Resource types live in a namespace such as `Radius.Resources`. A type has no +implementation by itself. Different environments can register different recipes for +the same type while the application declaration remains unchanged. + +## Manual tutorial, Stage 1: Create a SQL Server type + +Create `iac/sql-databases.yaml`: + +```yaml +namespace: Radius.Resources +types: + sqlDatabases: + description: A portable Microsoft SQL Server database resource. + apiVersions: + '2025-08-01-preview': + schema: + type: object + properties: + environment: + type: string + description: (Required) The Radius Environment ID. + application: + type: string + description: (Required) The Radius Application ID. + size: + type: string + enum: [S, M, L] + description: (Optional) Size tier. + host: + type: string + readOnly: true + description: SQL Server hostname or IP. + port: + type: integer + readOnly: true + description: SQL Server port. + database: + type: string + readOnly: true + description: Database name. + username: + type: string + readOnly: true + description: Login username. + secrets: + type: object + readOnly: true + properties: + password: + type: string + readOnly: true + description: Login password. + required: [password] + required: [environment, application] +``` + +### Register manually on AKS + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod + +rad resource-type create \ + --workspace ws-azure-prod \ + --from-file iac/sql-databases.yaml +``` + +### Register manually on K3s + +```bash +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +rad workspace switch ws-local-prod + +rad resource-type create \ + --workspace ws-local-prod \ + --from-file iac/sql-databases.yaml +``` + +Discuss: + +- Which properties are developer inputs? +- Why are outputs marked `readOnly`? +- Why is the password nested under `secrets`? +- Would an Azure SQL recipe require this contract to change? + +## Manual tutorial, Stage 2: Import the catalog + +The source catalog is pinned to the Adaptive Apps commit used to author this +MicroHack: + +```bash +export TYPES_URL="https://raw.githubusercontent.com/microsoft/adaptive-apps/885627980684e5bcc6fe4bbd2848c1ec247b0a0b/radius/resource-types/types.yaml" +curl --fail --location "$TYPES_URL" --output /tmp/adaptive-apps-types.yaml +``` + +Review the downloaded schema before registering it. + +### Import manually on AKS + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod + +rad resource-type create \ + --workspace ws-azure-prod \ + --from-file /tmp/adaptive-apps-types.yaml +``` + +### Import manually on K3s + +```bash +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +rad workspace switch ws-local-prod + +rad resource-type create \ + --workspace ws-local-prod \ + --from-file /tmp/adaptive-apps-types.yaml +``` + +Generate the Bicep extension once per workstation: + +```bash +mkdir -p artifacts +rad bicep publish-extension \ + --from-file /tmp/adaptive-apps-types.yaml \ + --target artifacts/types.tgz \ + --force +``` + +The catalog includes portable types for PostgreSQL, MQTT, workload identity, identity +providers, AI models, governance, and agent guardrails. + +## Explore and validate + +In each workspace: + +```bash +rad resource-type list +rad resource-type show Radius.Resources/sqlDatabases +rad resource-type show Radius.Resources/postgreSqlDatabases +rad resource-type show Radius.Resources/aiModels +``` + +Open the dashboard for the matching kube context: + +```bash +kubectl port-forward service/dashboard \ + --namespace radius-system 7007:80 +``` + +Inspect: + +- Developer inputs +- Read-only outputs +- Secret properties +- API versions + +At least these types should be present: + +```text +Radius.Resources/agentGuardrails +Radius.Resources/aiModels +Radius.Resources/governance +Radius.Resources/idProviders +Radius.Resources/mqttBrokers +Radius.Resources/postgreSqlDatabases +Radius.Resources/sqlDatabases +Radius.Resources/workloadIdentities +``` + +No recipes should be registered yet. Recipes are introduced in Challenge 04. + +## Optional platforms + +If Azure Local or Arc-enabled Kubernetes replaces K3s, run the same resource-type +commands against that platform's Radius workspace. Resource-type schemas are +platform-independent. + +## Optional automation + +These scripts install the portfolio and perform both resource-type stages against one +environment. Use them only when the participant chooses to skip the step-by-step +tutorial. + +### AKS + +```bash +cd "03-Azure/01-01-App Innovation/04-adaptive-apps" +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps + +chmod +x resources/configure-resource-types-aks.sh +./resources/configure-resource-types-aks.sh +``` + +### K3s + +```bash +cd "03-Azure/01-01-App Innovation/04-adaptive-apps" +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm + +chmod +x resources/configure-resource-types-k3s.sh +./resources/configure-resource-types-k3s.sh +``` + +Both scripts accept `TYPES_URL` to override the pinned catalog and +`BICEP_EXTENSION_TARGET` to choose the generated package location. Set `PORTFOLIO` to +choose another profile. 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 new file mode 100644 index 000000000..2f72dbce5 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-04/solution-04.md @@ -0,0 +1,354 @@ +# Walkthrough Challenge 04 - Implement the platform abstractions with recipes + +[< Previous Solution](../challenge-03/solution-03.md) - **[Home](../../Readme.md)** - [Next Challenge](../../challenges/challenge-05.md) + +Duration: 60-90 minutes + +## Coach notes + +Challenge 03 defined contracts. Challenge 04 implements them. + +The teaching sequence is deliberate: + +1. Manually author one Azure SQL recipe to understand `context`, AVM, and `result`. +2. Register the complete recipe sets as environment-as-code. + +Do not let teams skip directly to automation. The optional script at the end exists +for setup recovery and participants who explicitly choose not to follow the tutorial. + +## Recipe concepts + +A recipe is a Bicep file or Terraform module that Radius invokes when a developer +deploys a matching resource type. + +```text +Resource type contract Recipe implementation +---------------------------------- ----------------------------------- +Radius.Resources/sqlDatabases iac/recipes/sql-server.bicep +input: size, environment reads context.resource.properties +output: host, port, database returns result.values +secret: password returns result.secrets +``` + +The application developer declares only the portable resource: + +```bicep +resource db 'Radius.Resources/sqlDatabases@2025-08-01-preview' = { + properties: { + environment: environment + application: app.id + size: 'S' + } +} +``` + +Radius looks up the recipe registered in the current environment, executes it, and +projects the recipe result back onto the resource. + +### The context object + +Radius injects `context` into every recipe: + +| Field | Purpose | +| --- | --- | +| `context.resource.id` | Full Radius resource ID | +| `context.resource.name` | Developer-assigned resource name | +| `context.resource.properties` | Developer inputs such as `size` | +| `context.environment.id` | Radius environment ID | +| `context.environment.providers.azure.scope` | Azure subscription and resource-group scope | +| `context.runtime.kubernetes.namespace` | Kubernetes namespace | +| `context.application.name` | Application name for tags and labels | + +### Azure Verified Modules + +Azure recipes should use [Azure Verified Modules](https://aka.ms/avm) when a suitable +module exists. The recipe remains thin and maps Radius inputs and outputs; AVM provides +maintained Azure resource patterns and security defaults. + +### The result object + +Every recipe returns: + +- `result.resources`: provisioned resources tracked for lifecycle operations +- `result.values`: read-only properties returned to the application +- `result.secrets`: sensitive values stored through Radius secret handling + +## Manual tutorial, Stage 1: Author the Azure SQL recipe + +Open `iac/recipes/sql-server.bicep` and walk through: + +- Size-to-SKU mapping +- Deterministic naming from `context.resource.id` +- AVM SQL Server usage +- Radius and application tags +- Values and secret output mapping + +The complete recipe is provided so students can compare their work: + +```bicep +@description('Injected by Radius.') +param context object + +@description('Database name.') +param databaseName string = context.resource.name + +var skuMap = { + S: { name: 'GP_Gen5', capacity: 2 } + M: { name: 'GP_Gen5', capacity: 4 } + L: { name: 'GP_Gen5', capacity: 8 } +} +var sizeKey = context.resource.properties.?size ?? 'S' +var sku = skuMap[sizeKey] +var seed = uniqueString(context.resource.id) +var serverName = 'sql-${take(seed, 10)}' +var adminPassword = '${uniqueString(context.resource.id)}Aa1!' + +module sqlServer 'br/public:avm/res/sql/server:0.12.0' = { + name: 'sql-server-${seed}' + params: { + name: serverName + location: resourceGroup().location + administratorLogin: 'sqladmin' + administratorLoginPassword: adminPassword + databases: [ + { + name: databaseName + sku: { + name: '${sku.name}_${sku.capacity}' + } + } + ] + tags: { + 'radapp.io/environment': context.environment.id + 'radapp.io/resource': context.resource.id + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } +} + +output result object = { + resources: [ + sqlServer.outputs.resourceId + ] + values: { + host: sqlServer.outputs.fullyQualifiedDomainName + port: 1433 + database: databaseName + username: 'sqladmin' + } + secrets: { + password: adminPassword + } +} +``` + +Discuss why the password belongs in `secrets`, why the developer never supplies the +Azure resource group, and what changes when a different recipe implements the type. + +## Manual tutorial, Stage 2: Publish and register the SQL recipe on AKS + +Select AKS and its Radius workspace: + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad env switch env-azure-prod +rad group switch rg-trading +``` + +Create an ACR. Its name must be globally unique and lowercase: + +```bash +export AZURE_SUBSCRIPTION="" +export RESOURCE_GROUP="rg-adaptive-apps" +export ACR_NAME="" + +az account set --subscription "$AZURE_SUBSCRIPTION" +az acr show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$ACR_NAME" >/dev/null 2>&1 || + az acr create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$ACR_NAME" \ + --sku Basic +``` + +Authenticate with Docker: + +```bash +az acr login --name "$ACR_NAME" +``` + +When Docker is unavailable, create temporary OCI authentication: + +```bash +export DOCKER_CONFIG="$(mktemp -d)" +TOKEN="$(az acr login --name "$ACR_NAME" --expose-token \ + --query accessToken --output tsv)" +AUTH="$(printf '00000000-0000-0000-0000-000000000000:%s' "$TOKEN" | + base64 | tr -d '\n')" + +cat >"${DOCKER_CONFIG}/config.json" <, navigate to the environment, and inspect **Recipes**. + +## Optional platforms + +If Azure Local or Arc-enabled Kubernetes replaces K3s, use `iac/local-env.bicep` for +in-cluster implementations unless the platform team intentionally provides managed +services. Always use a distinct workspace and translate context and environment names +consistently. + +## Optional automation + +The manual tutorial is the intended learning path. To automate the same configuration: + +```bash +cd "03-Azure/01-01-App Innovation/04-adaptive-apps" +chmod +x resources/configure-recipes.sh + +export AZURE_SUBSCRIPTION="" +export RESOURCE_GROUP="rg-adaptive-apps" +export ACR_NAME="" + +# Configure both defaults: +./resources/configure-recipes.sh all + +# Or configure one: +./resources/configure-recipes.sh aks +./resources/configure-recipes.sh k3s +``` + +The script creates or reuses ACR, publishes the custom SQL recipe for AKS, deploys the +appropriate environment definition, and verifies the registered recipes. From 1d8320cf995db13099dfa0cd1d189a1709801c18 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 19 Aug 2026 13:50:45 +0200 Subject: [PATCH 02/29] Add Adaptive Apps dev container Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../.devcontainer/devcontainer.json | 40 ++++ .../.devcontainer/post-create.sh | 143 +++++++++++++ .../04-adaptive-apps/Readme.md | 7 + .../challenges/challenge-00.md | 103 ++++++++- .../walkthrough/challenge-00/solution-00.md | 202 +++++++++++++++++- 5 files changed, 485 insertions(+), 10 deletions(-) create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/devcontainer.json create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/post-create.sh diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/devcontainer.json b/03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/devcontainer.json new file mode 100644 index 000000000..13eeb546d --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/devcontainer.json @@ -0,0 +1,40 @@ +{ + "name": "Adaptive Apps MicroHack", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/azure-cli:1": { + "version": "2.89.1" + }, + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": false, + "installOhMyZsh": false, + "upgradePackages": false + } + }, + "mounts": [ + "source=adaptive-apps-microhack-azure-${devcontainerId},target=/home/vscode/.azure,type=volume", + "source=adaptive-apps-microhack-kube-${devcontainerId},target=/home/vscode/.kube,type=volume", + "source=adaptive-apps-microhack-radius-${devcontainerId},target=/home/vscode/.rad,type=volume", + "source=adaptive-apps-microhack-ssh-${devcontainerId},target=/home/vscode/.ssh,type=volume" + ], + "postCreateCommand": "bash .devcontainer/post-create.sh", + "forwardPorts": [ + 7007 + ], + "portsAttributes": { + "7007": { + "label": "Radius dashboard", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-azuretools.vscode-bicep", + "ms-kubernetes-tools.vscode-kubernetes-tools", + "timonwong.shellcheck" + ] + } + } +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/post-create.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/post-create.sh new file mode 100644 index 000000000..ebc27ab45 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/post-create.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly KUBECTL_VERSION="v1.36.3" +readonly HELM_VERSION="v3.21.4" +readonly RADIUS_VERSION="v0.60.0" +readonly YQ_VERSION="v4.53.4" +readonly BICEP_VERSION="v0.46.1" + +retry() { + local attempts="$1" + shift + local count=1 + + until "$@"; do + if [[ "$count" -ge "$attempts" ]]; then + return 1 + fi + count=$((count + 1)) + sleep 2 + done +} + +case "$(uname -m)" in + x86_64) + readonly ARCH="amd64" + readonly BICEP_PLATFORM="linux-x64" + ;; + aarch64 | arm64) + readonly ARCH="arm64" + readonly BICEP_PLATFORM="linux-arm64" + ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +for state_directory in "$HOME/.azure" "$HOME/.kube" "$HOME/.rad" "$HOME/.ssh"; do + sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "$state_directory" +done + +sudo apt-get update +sudo apt-get install --yes --no-install-recommends \ + ca-certificates \ + curl \ + git \ + jq \ + openssh-client \ + tar +sudo rm -rf /var/lib/apt/lists/* + +mkdir -p "$HOME/.local/bin" +if ! grep -Fqx 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc"; then + echo 'export PATH="$HOME/.local/bin:$PATH"' >>"$HOME/.bashrc" +fi +export PATH="$HOME/.local/bin:$PATH" + +install_kubectl() { + local checksum + retry 3 curl --fail --location --silent --show-error \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" \ + --output "$HOME/.local/bin/kubectl" + checksum="$(retry 3 curl --fail --location --silent --show-error \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl.sha256")" + printf '%s %s\n' "$checksum" "$HOME/.local/bin/kubectl" | sha256sum --check + chmod 0755 "$HOME/.local/bin/kubectl" +} + +install_helm() { + local checksum + local temporary_directory + temporary_directory="$(mktemp -d)" + + retry 3 curl --fail --location --silent --show-error \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" \ + --output "$temporary_directory/helm.tgz" + checksum="$(retry 3 curl --fail --location --silent --show-error \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-${ARCH}.tar.gz.sha256sum" | + awk '{ print $1 }')" + printf '%s %s\n' "$checksum" "$temporary_directory/helm.tgz" | + sha256sum --check + ( + cd "$temporary_directory" + tar -xzf helm.tgz + ) + install -m 0755 \ + "$temporary_directory/linux-${ARCH}/helm" \ + "$HOME/.local/bin/helm" + rm -rf "$temporary_directory" +} + +install_radius() { + retry 3 curl --fail --location --silent --show-error \ + "https://raw.githubusercontent.com/radius-project/radius/${RADIUS_VERSION}/deploy/install.sh" \ + --output /tmp/install-radius.sh + bash /tmp/install-radius.sh \ + --version "$RADIUS_VERSION" \ + --install-dir "$HOME/.local/bin" + rm -f /tmp/install-radius.sh +} + +install_yq() { + local checksum + local checksums + local readonly yq_asset="yq_linux_${ARCH}" + + retry 3 curl --fail --location --silent --show-error \ + "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${yq_asset}" \ + --output "$HOME/.local/bin/yq" + checksums="$(retry 3 curl --fail --location --silent --show-error \ + "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/checksums")" + checksum="$(awk -v asset="$yq_asset" '$1 == asset { print $19 }' <<<"$checksums")" + [[ -n "$checksum" ]] || { + echo "No SHA-256 checksum found for ${yq_asset}." >&2 + return 1 + } + printf '%s %s\n' "$checksum" "$HOME/.local/bin/yq" | sha256sum --check + chmod 0755 "$HOME/.local/bin/yq" +} + +install_kubectl +install_helm +install_radius +install_yq +retry 3 az bicep install \ + --version "$BICEP_VERSION" \ + --target-platform "$BICEP_PLATFORM" + +echo +echo "Adaptive Apps MicroHack tool versions:" +az version --query '"azure-cli"' --output tsv | sed 's/^/azure-cli: /' +az bicep version +kubectl version --client +helm version --short +rad version +git --version +jq --version +yq --version +ssh -V + +echo +echo "The container is ready. Sign in with 'az login' and select the lab subscription." 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 11607e413..620496623 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 @@ -82,6 +82,13 @@ To use the MicroHack time effectively, have the following available: - [Visual Studio Code](https://code.visualstudio.com/) - Bash or PowerShell 7; Windows users can also use WSL 2 +The recommended option is the scoped +[devcontainer](.devcontainer/devcontainer.json), which installs the Challenges 00-04 +toolchain while retaining the manual host setup path. Clone the contribution, open the +`04-adaptive-apps` folder itself in VS Code, and run **Dev Containers: Reopen in +Container**. See [Challenge 00](challenges/challenge-00.md) for host prerequisites, +credential persistence, verification, and troubleshooting. + ### Challenge 00: universal prerequisite Complete Challenge 00 before starting a challenge in any bucket. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md index aef5be5ce..44b7d31eb 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md @@ -51,41 +51,105 @@ Confirm that your workshop team has: > because recipes can create role assignments. Use an isolated workshop subscription > or resource group rather than a production scope. -### Task 2: Install the workstation tools +### Task 2: Choose a workstation setup + +The scoped devcontainer is the recommended setup because it provides a consistent +Linux toolchain for Challenges 00-04. A manual host installation remains supported. + +#### Option A: Use the recommended devcontainer + +Host prerequisites: + +- Docker Desktop or another runtime compatible with VS Code Dev Containers +- [Visual Studio Code](https://code.visualstudio.com/) +- The [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +- On Windows, Docker Desktop configured with its WSL 2 backend + +Clone the fork and check out this MicroHack branch: + +```bash +git clone https://github.com/djong1/MicroHack.git +cd MicroHack +git switch djong1-adaptive-apps-microhack +code "03-Azure/01-01-App Innovation/04-adaptive-apps" +``` + +In VS Code, run **Dev Containers: Reopen in Container**. Open the +`04-adaptive-apps` folder itself, not the repository root, because the configuration is +intentionally scoped at `.devcontainer/devcontainer.json` inside that folder. + +The container installs Azure CLI, Bicep, `kubectl`, Helm 3, Radius CLI, Git, `jq`, +`yq`, `curl`, SSH, and `tar`. These tools can run every command in Challenges 00-04. +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 commands you run. + +> [!IMPORTANT] +> The configuration does not mount the host Docker socket. Challenge 04 supports +> token-based ACR authentication when Docker is unavailable. + +#### Option B: Install tools on the host Install the following tools in one consistent shell environment: - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) +- [Bicep CLI](https://learn.microsoft.com/azure/azure-resource-manager/bicep/install) - [`kubectl`](https://kubernetes.io/docs/tasks/tools/) - [Helm 3](https://helm.sh/docs/intro/install/) - [Radius CLI (`rad`)](https://docs.radapp.io/getting-started/install/) - [Git](https://git-scm.com/downloads) -- `curl`, OpenSSH client, and `tar` +- `jq`, `yq`, `curl`, OpenSSH client, and `tar` - [Visual Studio Code](https://code.visualstudio.com/) Windows participants should use WSL 2 for the Bash automation supplied with this MicroHack. Install the complete toolchain inside WSL rather than mixing WSL and native Windows executables or configuration files. -### Task 3: Verify the toolchain +### Task 3: Understand devcontainer state and security + +The devcontainer stores these directories in per-container Docker named volumes so +they survive a container rebuild without sharing credentials across separate clones: + +| Path | Purpose | +| --- | --- | +| `~/.azure` | Azure CLI sign-in tokens, settings, and Bicep | +| `~/.kube` | Default AKS kubeconfig and the dedicated K3s kubeconfig | +| `~/.rad` | Radius CLI workspaces and downloaded components | +| `~/.ssh` | SSH keys and known hosts used by the K3s VM | + +No credentials are baked into the image or committed to the repository. Treat the +named volumes as credentials: use only a trusted workstation, do not copy their +contents into the workspace, and remove the volumes when the lab is retired. Reopening +or rebuilding the container preserves them; removing the volumes resets the state. + +The workspace files are mounted separately by VS Code. The host Docker socket and host +credential directories are not mounted. + +### Task 4: Verify the toolchain Run the appropriate version command for every required tool: ```bash az --version +az bicep version kubectl version --client helm version rad version git --version +jq --version +yq --version curl --version ssh -V tar --version -code --version ``` Resolve missing commands and `PATH` problems before continuing. -### Task 4: Select and validate the Azure subscription +If the devcontainer setup did not complete, run **Dev Containers: Rebuild Container** +and inspect the creation log. Reopening a terminal is sufficient after a successful +build. + +### Task 5: Select and validate the Azure subscription Sign in, select the intended subscription, and verify the active account: @@ -107,7 +171,13 @@ az provider register --namespace Microsoft.Network Provider registration is asynchronous. Inspect the registration state instead of repeatedly submitting the registration command. -### Task 5: Agree on local configuration locations +Inside the devcontainer, `az login` performs an interactive browser or device-code +sign-in. The resulting Azure CLI state persists in the `~/.azure` volume. Radius does +not reuse the Azure CLI token as a workspace: Challenge 02 creates separate Radius +workspaces under `~/.rad` and registers the Azure workload-identity credential needed +by the AKS control plane. + +### Task 6: Agree on local configuration locations Before sharing access with teammates, decide: @@ -118,6 +188,20 @@ Before sharing access with teammates, decide: - How the team will transfer kubeconfig files securely No cluster connection is required yet. Challenge 01 creates the default platforms. +When those platforms exist, switch safely by changing both `KUBECONFIG` and context: + +```bash +# AKS uses the default ~/.kube/config. +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps + +# K3s uses a dedicated persisted file. +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +``` + +Always run `kubectl config current-context` before a platform operation. Never copy a +kubeconfig or SSH private key into the Git workspace. ## Success criteria @@ -131,6 +215,8 @@ You are ready to continue when: - The workshop's public egress CIDR is known. - Windows participants have a complete toolchain in one shell environment. - The team knows where its Kubernetes and Radius CLI configuration will be stored. +- Devcontainer users can rebuild and reopen the container without losing Azure, + kubeconfig, Radius workspace, or SSH state. ## Hints @@ -140,6 +226,11 @@ You are ready to continue when: the intended subscription explicitly. - Azure Cloud Shell can help inspect Azure resources, but it does not replace the consistent local environment needed for K3s kubeconfig files and later authoring. +- If the browser cannot complete `az login` from a container, follow the device-code + prompt shown by Azure CLI. +- If a rebuilt container appears signed out or has no Kubernetes contexts, confirm + that the four `adaptive-apps-microhack-*` named volumes for that devcontainer still + exist. - Resolve quota or role-assignment blockers before the event; they are difficult to fix within a timed MicroHack. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md index 96d281af9..0b472a5c5 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md @@ -31,10 +31,13 @@ subscription or resource group. ## Required tools - Azure CLI +- Bicep CLI - `kubectl` - Helm - Radius CLI (`rad`) - Git +- `jq` +- `yq` - `curl` - OpenSSH client - `tar` @@ -45,7 +48,179 @@ Windows participants should use WSL 2 for the Bash automation scripts. Manual instructions can also be followed from PowerShell 7 where equivalent commands are available. -## Installation instructions +## Recommended devcontainer + +The MicroHack includes a scoped devcontainer at +`04-adaptive-apps/.devcontainer/devcontainer.json`. It is recommended for participants +who can run containers locally because it keeps Challenges 00-04 on one tested Linux +toolchain. Manual installation remains available below. + +### Host prerequisites + +Confirm each participant has: + +- Docker Desktop or another runtime compatible with VS Code Dev Containers +- Visual Studio Code +- The Dev Containers extension +- Enough local storage for the Ubuntu base image, features, and four small state + volumes + +On Windows, use Docker Desktop's WSL 2 backend. The repository can be cloned in the +WSL filesystem for better filesystem performance, but VS Code must still open the +scoped MicroHack folder. + +This nested configuration is designed for local VS Code Dev Containers. Do not +advertise Codespaces for this contribution: Codespaces normally discovers configuration +from the repository root, while this file is intentionally scoped to one MicroHack. + +### Start the container + +```bash +git clone https://github.com/djong1/MicroHack.git +cd MicroHack +git switch djong1-adaptive-apps-microhack +code "03-Azure/01-01-App Innovation/04-adaptive-apps" +``` + +In VS Code: + +1. Confirm the Explorer root is `04-adaptive-apps`. +2. Run **Dev Containers: Reopen in Container**. +3. Wait for `.devcontainer/post-create.sh` to finish. +4. Open a new Bash terminal in the container. + +Opening the whole MicroHack repository will not discover this scoped configuration. +Use **File: Open Folder** to select `04-adaptive-apps` first. + +### Installed toolchain + +The Azure CLI devcontainer feature installs Azure CLI 2.89.1. The post-create script +installs Bicep 0.46.1 into the persisted Azure CLI state volume and also installs: + +- `kubectl` 1.36.3 +- Helm 3.21.4 +- Radius CLI 0.60.0 and its Radius Bicep support +- `yq` 4.53.4 +- Git, `jq`, `curl`, OpenSSH client, `tar`, and CA certificates + +Version pins make participant environments repeatable. Updating a pin requires static +validation of this MicroHack and compatibility with the Kubernetes versions offered by +AKS. + +Rust, PowerShell, k3d, and application-development extensions from the source +repository devcontainer are intentionally omitted. Challenges 00-04 use Bash, create +K3s on an Azure VM, and do not build the Adaptive Apps application source. + +### Persistent state and security + +The configuration mounts four Docker named volumes. `${devcontainerId}` gives each +devcontainer its own stable suffix, so separate clones do not share credentials: + +| Volume | Container path | Contents | +| --- | --- | --- | +| `adaptive-apps-microhack-azure-${devcontainerId}` | `/home/vscode/.azure` | Azure tokens, CLI settings, and Bicep | +| `adaptive-apps-microhack-kube-${devcontainerId}` | `/home/vscode/.kube` | AKS and K3s kubeconfig files | +| `adaptive-apps-microhack-radius-${devcontainerId}` | `/home/vscode/.rad` | Radius workspaces and components | +| `adaptive-apps-microhack-ssh-${devcontainerId}` | `/home/vscode/.ssh` | K3s VM SSH key and known hosts | + +This state survives **Rebuild Container**. It does not survive deletion of the named +volumes. The files contain credentials and must be protected like the corresponding +host directories. + +The configuration deliberately does not: + +- Bake tokens, kubeconfig files, SSH keys, or Radius workspace state into the image +- Mount the host's `~/.azure`, `~/.kube`, `~/.rad`, or `~/.ssh` +- Mount the host Docker socket + +Without a Docker socket, use the temporary `DOCKER_CONFIG` and `az acr login +--expose-token` flow documented in Solution 04 when publishing the recipe. The +`configure-recipes.sh` helper already uses this token-based approach. + +### Azure and Radius authentication + +Sign in from the container and select the lab subscription: + +```bash +az login +az account set --subscription "" +az account show --output table +``` + +Azure CLI may open a browser on the host or show a device-code prompt. Tokens are saved +only in the `~/.azure` volume. + +Radius CLI state is separate. Challenge 02 creates `ws-azure-prod` and +`ws-local-prod` under `~/.rad/config.yaml`; it also configures the workload identity +used by the Radius control plane on AKS. Rebuilding the container preserves those +local workspace definitions. + +### Kubeconfig and SSH behavior + +Challenge 01 writes: + +- AKS credentials to the default `~/.kube/config` +- K3s credentials to `~/.kube/adaptive-apps-k3s.yaml` +- The generated VM SSH key and accepted host key under `~/.ssh` + +The named volumes preserve all three. Participants must still switch safely: + +```bash +# AKS +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps + +# K3s +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +``` + +Do not copy kubeconfig files or private keys into the Git workspace. If teammates need +K3s access, distribute credentials through an approved secure channel. + +### What runs where + +The devcontainer runs only participant tools. Commands executed inside it create or +configure remote resources: + +- Challenge 01 creates AKS and an Azure VM, then installs K3s on that VM. +- Challenge 02 installs separate Radius control planes into AKS and K3s. +- Challenge 03 installs the portfolio and registers resource types remotely. +- Challenge 04 publishes a Bicep recipe to ACR and registers recipes with both Radius + environments. + +No Kubernetes cluster or Docker daemon runs inside this devcontainer. + +### Verify and troubleshoot + +Run: + +```bash +az --version +az bicep version +kubectl version --client +helm version --short +rad version +git --version +jq --version +yq --version +curl --version +ssh -V +tar --version +``` + +If creation fails: + +1. Inspect the Dev Containers creation log for the first failed download or command. +2. Run **Dev Containers: Rebuild Container** to retry the pinned tool installation. +3. Confirm Docker Desktop is running and the container can reach GitHub, Microsoft, + Kubernetes, and Helm download endpoints. +4. If tools are installed but not found, open a new terminal so `.bashrc` updates + `PATH`. +5. If state is unexpectedly missing, verify that the four named volumes still exist. +6. Delete a named volume only when intentionally resetting its credential state. + +## Manual installation instructions ### macOS @@ -70,7 +245,8 @@ brew install helm Install Git and supporting command-line tools: ```bash -brew install git curl +brew install git curl jq yq +az bicep install ``` Install Radius CLI: @@ -119,7 +295,16 @@ Install supporting tools: ```bash sudo apt-get update -sudo apt-get install --yes git curl openssh-client tar +sudo apt-get install --yes git curl jq openssh-client tar +az bicep install + +YQ_VERSION="v4.53.4" +YQ_ARCH="amd64" +[[ "$(uname -m)" == "aarch64" ]] && YQ_ARCH="arm64" +sudo curl --fail --location \ + "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_${YQ_ARCH}" \ + --output /usr/local/bin/yq +sudo chmod 0755 /usr/local/bin/yq ``` Install Radius CLI: @@ -162,8 +347,11 @@ winget install Kubernetes.kubectl winget install Helm.Helm winget install RadiusProject.rad winget install Git.Git +winget install jqlang.jq +winget install MikeFarah.yq winget install Microsoft.VisualStudioCode winget install Microsoft.PowerShell +az bicep install ``` > [!IMPORTANT] @@ -176,14 +364,16 @@ Run: ```bash az --version +az bicep version kubectl version --client helm version rad version git --version +jq --version +yq --version curl --version ssh -V tar --version -code --version ``` Sign in and select the intended subscription: @@ -241,6 +431,8 @@ az provider show \ - Bash can run the optional automation scripts. - Git, `curl`, SSH, and `tar` are available. - On Windows, the team has agreed whether commands run in WSL or native PowerShell. +- Devcontainer participants have confirmed that rebuilds preserve the expected named + volumes and understand that those volumes contain credentials. ## Troubleshooting @@ -254,6 +446,8 @@ az provider show \ | Azure authorization error | Confirm the participant's role and scope before troubleshooting scripts. | | AKS or VM quota error | Select another VM size or region, or request quota before the event. | | WSL cannot see native Windows tools | Install the complete toolchain inside WSL rather than mixing environments. | +| Devcontainer creation fails | Inspect the creation log, confirm network access, and run **Dev Containers: Rebuild Container**. | +| Devcontainer lost authentication or contexts | Confirm the named Azure, Kubernetes, Radius, and SSH volumes were not deleted. | Azure Cloud Shell can temporarily help with Azure resource inspection, but the K3s kubeconfig and later local files still require a consistent participant workstation. From a912060d68475f13781dcc68a9a2ce72fe2aa534 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 19 Aug 2026 14:08:54 +0200 Subject: [PATCH 03/29] Move dev container to repository configuration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../devcontainer.json | 12 ++++-- .../post-create.sh | 0 .../04-adaptive-apps/Readme.md | 15 ++++--- .../challenges/challenge-00.md | 14 ++++--- .../walkthrough/challenge-00/solution-00.md | 42 ++++++++++++------- 5 files changed, 52 insertions(+), 31 deletions(-) rename {03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer => .devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps}/devcontainer.json (67%) rename {03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer => .devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps}/post-create.sh (100%) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/devcontainer.json b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json similarity index 67% rename from 03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/devcontainer.json rename to .devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json index 13eeb546d..1dcd260f3 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/devcontainer.json +++ b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json @@ -1,6 +1,8 @@ { - "name": "Adaptive Apps MicroHack", + "name": "Azure / App Innovation / Adaptive Apps", "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/microhack,type=bind,consistency=cached", + "workspaceFolder": "/workspaces/microhack/03-Azure/01-01-App Innovation/04-adaptive-apps", "remoteUser": "vscode", "features": { "ghcr.io/devcontainers/features/azure-cli:1": { @@ -18,7 +20,8 @@ "source=adaptive-apps-microhack-radius-${devcontainerId},target=/home/vscode/.rad,type=volume", "source=adaptive-apps-microhack-ssh-${devcontainerId},target=/home/vscode/.ssh,type=volume" ], - "postCreateCommand": "bash .devcontainer/post-create.sh", + "postCreateCommand": "bash /workspaces/microhack/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh", + "postAttachCommand": "code Readme.md", "forwardPorts": [ 7007 ], @@ -34,7 +37,10 @@ "ms-azuretools.vscode-bicep", "ms-kubernetes-tools.vscode-kubernetes-tools", "timonwong.shellcheck" - ] + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "bash" + } } } } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/post-create.sh b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh similarity index 100% rename from 03-Azure/01-01-App Innovation/04-adaptive-apps/.devcontainer/post-create.sh rename to .devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh 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 620496623..bb6839d72 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 @@ -82,12 +82,15 @@ To use the MicroHack time effectively, have the following available: - [Visual Studio Code](https://code.visualstudio.com/) - Bash or PowerShell 7; Windows users can also use WSL 2 -The recommended option is the scoped -[devcontainer](.devcontainer/devcontainer.json), which installs the Challenges 00-04 -toolchain while retaining the manual host setup path. Clone the contribution, open the -`04-adaptive-apps` folder itself in VS Code, and run **Dev Containers: Reopen in -Container**. See [Challenge 00](challenges/challenge-00.md) for host prerequisites, -credential persistence, verification, and troubleshooting. +The recommended option is the repository +[devcontainer](../../../.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json), +which installs the Challenges 00-04 toolchain while retaining the manual host setup +path. Clone the contribution, open the repository root in VS Code, run **Dev +Containers: Reopen in Container**, and select +`03-azure-01-01-app-innovation-04-adaptive-apps` when prompted. The container then +opens this MicroHack as its workspace. See +[Challenge 00](challenges/challenge-00.md) for host prerequisites, credential +persistence, verification, and troubleshooting. ### Challenge 00: universal prerequisite diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md index 44b7d31eb..36e1a52ba 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md @@ -53,8 +53,9 @@ Confirm that your workshop team has: ### Task 2: Choose a workstation setup -The scoped devcontainer is the recommended setup because it provides a consistent -Linux toolchain for Challenges 00-04. A manual host installation remains supported. +The Adaptive Apps devcontainer is the recommended setup because it provides a +consistent Linux toolchain for Challenges 00-04. A manual host installation remains +supported. #### Option A: Use the recommended devcontainer @@ -71,12 +72,13 @@ Clone the fork and check out this MicroHack branch: git clone https://github.com/djong1/MicroHack.git cd MicroHack git switch djong1-adaptive-apps-microhack -code "03-Azure/01-01-App Innovation/04-adaptive-apps" +code . ``` -In VS Code, run **Dev Containers: Reopen in Container**. Open the -`04-adaptive-apps` folder itself, not the repository root, because the configuration is -intentionally scoped at `.devcontainer/devcontainer.json` inside that folder. +In VS Code, run **Dev Containers: Reopen in Container** and select +`03-azure-01-01-app-innovation-04-adaptive-apps` when prompted. The configuration +lives under the repository's root `.devcontainer` directory and opens +`03-Azure/01-01-App Innovation/04-adaptive-apps` as the container workspace. The container installs Azure CLI, Bicep, `kubectl`, Helm 3, Radius CLI, Git, `jq`, `yq`, `curl`, SSH, and `tar`. These tools can run every command in Challenges 00-04. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md index 0b472a5c5..854ef95ea 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md @@ -50,10 +50,11 @@ available. ## Recommended devcontainer -The MicroHack includes a scoped devcontainer at -`04-adaptive-apps/.devcontainer/devcontainer.json`. It is recommended for participants -who can run containers locally because it keeps Challenges 00-04 on one tested Linux -toolchain. Manual installation remains available below. +The MicroHack includes a repository-level devcontainer at +`.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json`. It is +recommended for participants who can run containers locally because it keeps +Challenges 00-04 on one tested Linux toolchain. Manual installation remains available +below. ### Host prerequisites @@ -66,12 +67,11 @@ Confirm each participant has: volumes On Windows, use Docker Desktop's WSL 2 backend. The repository can be cloned in the -WSL filesystem for better filesystem performance, but VS Code must still open the -scoped MicroHack folder. +WSL filesystem for better filesystem performance. -This nested configuration is designed for local VS Code Dev Containers. Do not -advertise Codespaces for this contribution: Codespaces normally discovers configuration -from the repository root, while this file is intentionally scoped to one MicroHack. +This configuration follows the MicroHack repository's multi-configuration convention. +It is documented for local VS Code Dev Containers; do not advertise Codespaces unless +the repository later adds and validates an explicit Codespaces selection workflow. ### Start the container @@ -79,18 +79,28 @@ from the repository root, while this file is intentionally scoped to one MicroHa git clone https://github.com/djong1/MicroHack.git cd MicroHack git switch djong1-adaptive-apps-microhack -code "03-Azure/01-01-App Innovation/04-adaptive-apps" +code . ``` In VS Code: -1. Confirm the Explorer root is `04-adaptive-apps`. +1. Confirm the Explorer root is the MicroHack repository. 2. Run **Dev Containers: Reopen in Container**. -3. Wait for `.devcontainer/post-create.sh` to finish. -4. Open a new Bash terminal in the container. - -Opening the whole MicroHack repository will not discover this scoped configuration. -Use **File: Open Folder** to select `04-adaptive-apps` first. +3. Select `03-azure-01-01-app-innovation-04-adaptive-apps` from the available + configurations. +4. Wait for + `.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh` to + finish. +5. Confirm VS Code reopens + `03-Azure/01-01-App Innovation/04-adaptive-apps` as the workspace and opens + `Readme.md`. +6. Open a new Bash terminal in the container. + +The configuration binds the repository root to `/workspaces/microhack` and sets the +container workspace to +`/workspaces/microhack/03-Azure/01-01-App Innovation/04-adaptive-apps`. Therefore, +commands in Challenges 00-04 start in the MicroHack directory even though VS Code +discovers the configuration from the repository root. ### Installed toolchain From a0059c11bb8d24fbb350d747228bffe1fa1a9ff0 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 19 Aug 2026 14:37:12 +0200 Subject: [PATCH 04/29] Fix Windows dev container script execution Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../devcontainer.json | 3 + .../post-create.sh | 55 +++++++++++++++++++ .gitattributes | 2 + .../challenges/challenge-00.md | 16 +++++- .../04-adaptive-apps/docs/prepare-aks.md | 4 +- .../04-adaptive-apps/docs/prepare-k3s.md | 4 +- .../resources/configure-recipes.sh | 1 + .../resources/configure-resource-types-aks.sh | 1 + .../resources/configure-resource-types-k3s.sh | 1 + .../resources/deploy-radius-aks.sh | 1 + .../resources/deploy-radius-k3s.sh | 1 + .../04-adaptive-apps/resources/prepare-aks.sh | 1 + .../resources/prepare-k3s-azure-vm.sh | 1 + .../walkthrough/challenge-00/solution-00.md | 42 ++++++++++++-- .../walkthrough/challenge-01/solution-01.md | 13 +++-- .../walkthrough/challenge-02/solution-02.md | 10 ++-- .../walkthrough/challenge-03/solution-03.md | 10 ++-- .../walkthrough/challenge-04/solution-04.md | 9 +-- 18 files changed, 139 insertions(+), 36 deletions(-) create mode 100644 .gitattributes diff --git a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json index 1dcd260f3..303012a73 100644 --- a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json +++ b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json @@ -22,6 +22,9 @@ ], "postCreateCommand": "bash /workspaces/microhack/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh", "postAttachCommand": "code Readme.md", + "remoteEnv": { + "PATH": "${containerEnv:PATH}:/home/vscode/.local/bin" + }, "forwardPorts": [ 7007 ], diff --git a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh index ebc27ab45..2e7dfb176 100644 --- a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh +++ b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +trap 'echo "ERROR: Adaptive Apps devcontainer setup failed at line ${LINENO}." >&2' ERR readonly KUBECTL_VERSION="v1.36.3" readonly HELM_VERSION="v3.21.4" @@ -56,6 +57,29 @@ if ! grep -Fqx 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc"; then fi export PATH="$HOME/.local/bin:$PATH" +configure_windows_worktree_shell() { + local readonly git_pointer="/workspaces/microhack/.git" + local readonly marker="# Adaptive Apps Windows worktree prompt" + + if [[ ! -f "$git_pointer" ]] || + ! grep -Eq '^gitdir: [A-Za-z]:[/\\]' "$git_pointer"; then + return + fi + + if ! grep -Fqx "$marker" "$HOME/.bashrc"; then + cat >>"$HOME/.bashrc" <<'EOF' + +# Adaptive Apps Windows worktree prompt +# The bound .git file points to Windows-only worktree metadata. Avoid probing it. +unset PROMPT_COMMAND +PS1='\u@\h:\w\$ ' +EOF + fi + + echo "Windows Git worktree detected. Repository-aware Git commands are unavailable" + echo "inside this container; the MicroHack commands do not require them." +} + install_kubectl() { local checksum retry 3 curl --fail --location --silent --show-error \ @@ -119,6 +143,36 @@ install_yq() { chmod 0755 "$HOME/.local/bin/yq" } +verify_tooling() { + local command_name + local missing=0 + + echo + echo "Adaptive Apps MicroHack tool verification:" + for command_name in az kubectl helm rad git jq yq curl ssh tar; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "ERROR: required command not found: ${command_name}" >&2 + missing=1 + else + printf '%-8s %s\n' "$command_name" "$(command -v "$command_name")" + fi + done + + if ! az bicep version >/dev/null 2>&1; then + echo "ERROR: Azure CLI Bicep is not available." >&2 + missing=1 + else + printf '%-8s %s\n' "bicep" "az bicep" + fi + + if [[ "$missing" -ne 0 ]]; then + echo "Devcontainer setup is incomplete. Rebuild the container or rerun:" >&2 + echo "bash /workspaces/microhack/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh" >&2 + return 1 + fi +} + +configure_windows_worktree_shell install_kubectl install_helm install_radius @@ -126,6 +180,7 @@ install_yq retry 3 az bicep install \ --version "$BICEP_VERSION" \ --target-platform "$BICEP_PLATFORM" +verify_tooling echo echo "Adaptive Apps MicroHack tool versions:" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..9cf30f53d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +"/03-Azure/01-01-App Innovation/04-adaptive-apps/**/*.sh" text eol=lf +"/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/*.sh" text eol=lf diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md index 36e1a52ba..49b8bf1d6 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md @@ -127,6 +127,13 @@ or rebuilding the container preserves them; removing the volumes resets the stat The workspace files are mounted separately by VS Code. The host Docker socket and host credential directories are not mounted. +When the repository root is a Windows Git worktree, its `.git` file points to metadata +using a Windows path that Linux cannot resolve. The devcontainer uses a simple +non-Git-aware Bash prompt in that case so normal MicroHack commands do not emit +recurring `fatal: not a git repository` messages. Repository-aware Git commands remain +unavailable inside that container session. The challenges do not require them; use a +normal clone instead of a worktree if you need Git operations inside the container. + ### Task 4: Verify the toolchain Run the appropriate version command for every required tool: @@ -147,9 +154,10 @@ tar --version Resolve missing commands and `PATH` problems before continuing. -If the devcontainer setup did not complete, run **Dev Containers: Rebuild Container** -and inspect the creation log. Reopening a terminal is sufficient after a successful -build. +If `kubectl` or another required tool is missing, first pull the current branch on the +host. Then run **Dev Containers: Rebuild Container** and inspect the creation log. A +Windows worktree cannot pull from inside the container; use the host shell. Reopen a +Bash terminal after a successful build. ### Task 5: Select and validate the Azure subscription @@ -233,6 +241,8 @@ You are ready to continue when: - If a rebuilt container appears signed out or has no Kubernetes contexts, confirm that the four `adaptive-apps-microhack-*` named volumes for that devcontainer still exist. +- If a Windows worktree still shows Git errors in an already running terminal, rebuild + the container and open a new Bash terminal so the prompt fallback is loaded. - Resolve quota or role-assignment blockers before the event; they are difficult to fix within a timed MicroHack. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-aks.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-aks.md index bf7d77f74..1b084881f 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-aks.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-aks.md @@ -11,8 +11,8 @@ export AZURE_LOCATION="westeurope" export RESOURCE_GROUP="rg-adaptive-apps" export AKS_CLUSTER="aks-adaptive-apps" -chmod +x resources/prepare-aks.sh -./resources/prepare-aks.sh +# Run from the Adaptive Apps MicroHack root. +bash resources/prepare-aks.sh ``` Optional settings are `AKS_NODE_COUNT` and `AKS_NODE_VM_SIZE`. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md index 1d781aff5..ef82dca0a 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md @@ -13,8 +13,8 @@ export RESOURCE_GROUP="rg-adaptive-apps" export K3S_VM_NAME="vm-adaptive-apps-k3s" export ADMIN_CIDR="/32" -chmod +x resources/prepare-k3s-azure-vm.sh -./resources/prepare-k3s-azure-vm.sh +# Run from the Adaptive Apps MicroHack root. +bash resources/prepare-k3s-azure-vm.sh ``` Optional settings are `K3S_VM_SIZE`, `K3S_ADMIN_USERNAME`, and `K3S_KUBECONFIG`. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh index c5445fdfd..7aae4f6a2 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Invoke with Bash from the Adaptive Apps MicroHack root; executable bits are not required. set -euo pipefail TARGET_PLATFORM="${1:-all}" diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh index a6209fbcd..bf7adb9c3 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Invoke with Bash from the Adaptive Apps MicroHack root; executable bits are not required. set -euo pipefail for command_name in curl helm kubectl mktemp rad tar; do diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh index a62c6cbd8..59cafba0a 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Invoke with Bash from the Adaptive Apps MicroHack root; executable bits are not required. set -euo pipefail for command_name in curl helm kubectl mktemp rad tar; do diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh index e3bbc2960..1ebeebd43 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Invoke with Bash from the Adaptive Apps MicroHack root; executable bits are not required. set -euo pipefail for command_name in az grep kubectl mktemp rad; do diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh index eb6864d85..4a9116333 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Invoke with Bash from the Adaptive Apps MicroHack root; executable bits are not required. set -euo pipefail for command_name in kubectl rad; do diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh index 62baa81b2..68f6bf854 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Invoke with Bash from the Adaptive Apps MicroHack root; executable bits are not required. set -euo pipefail # AKS credentials belong in the default kubeconfig, not a previously selected K3s file. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh index 4393cdd39..8c7a6c540 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Invoke with Bash from the Adaptive Apps MicroHack root; executable bits are not required. set -euo pipefail for command_name in az curl grep kubectl sed ssh; do diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md index 854ef95ea..27a328de0 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md @@ -143,6 +143,23 @@ The configuration deliberately does not: - Mount the host's `~/.azure`, `~/.kube`, `~/.rad`, or `~/.ssh` - Mount the host Docker socket +### Windows Git worktrees + +A Windows Git worktree stores a `.git` text pointer containing a Windows path such as +`C:/.../.git/worktrees/...`. Linux cannot resolve that host path through the repository +bind mount. Git-aware shell prompts can therefore print `fatal: not a git repository` +before otherwise unrelated commands. + +The post-create script detects this pointer without changing it and appends a simple +non-Git-aware Bash prompt inside the container. It does not mount arbitrary host paths, +replace Git metadata, or mutate the host worktree. The MicroHack scripts do not require +repository-aware Git commands. + +If participants need Git operations inside the container, use a normal repository +clone, whose `.git` metadata is contained in the bound directory, rather than a Windows +worktree. Git remains installed for normal clones, but do not claim repository Git +functionality for a Windows worktree bind mount. + Without a Docker socket, use the temporary `DOCKER_CONFIG` and `az acr login --expose-token` flow documented in Solution 04 when publishing the recipe. The `configure-recipes.sh` helper already uses this token-based approach. @@ -222,13 +239,27 @@ tar --version If creation fails: 1. Inspect the Dev Containers creation log for the first failed download or command. -2. Run **Dev Containers: Rebuild Container** to retry the pinned tool installation. -3. Confirm Docker Desktop is running and the container can reach GitHub, Microsoft, +2. If using a Windows worktree, pull or check out the fix on the Windows host because + repository-aware Git commands are unavailable inside the container. +3. Run **Dev Containers: Rebuild Container** to apply the LF-normalized setup script, + prompt fallback, and explicit tool `PATH`. +4. Open a new Bash terminal and rerun the verification commands above. +5. If a rebuild is temporarily unavailable after the host has the corrected files, + rerun setup safely from the existing container: + + ```bash + bash /workspaces/microhack/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh + exec bash + ``` + +6. Confirm `command -v kubectl` returns `/home/vscode/.local/bin/kubectl`. +7. Confirm Docker Desktop is running and the container can reach GitHub, Microsoft, Kubernetes, and Helm download endpoints. -4. If tools are installed but not found, open a new terminal so `.bashrc` updates +8. If tools are installed but not found, open a new terminal so `.bashrc` and the + devcontainer `remoteEnv` update `PATH`. -5. If state is unexpectedly missing, verify that the four named volumes still exist. -6. Delete a named volume only when intentionally resetting its credential state. +9. If state is unexpectedly missing, verify that the four named volumes still exist. +10. Delete a named volume only when intentionally resetting its credential state. ## Manual installation instructions @@ -458,6 +489,7 @@ az provider show \ | WSL cannot see native Windows tools | Install the complete toolchain inside WSL rather than mixing environments. | | Devcontainer creation fails | Inspect the creation log, confirm network access, and run **Dev Containers: Rebuild Container**. | | Devcontainer lost authentication or contexts | Confirm the named Azure, Kubernetes, Radius, and SSH volumes were not deleted. | +| Repeated `fatal: not a git repository` in a Windows worktree | Rebuild the container and open a new Bash terminal. Git operations require a normal clone; MicroHack commands do not require Git. | Azure Cloud Shell can temporarily help with Azure resource inspection, but the K3s kubeconfig and later local files still require a consistent participant workstation. 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 25f04daa8..7e4163166 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 @@ -42,18 +42,20 @@ The script creates or updates an Azure resource group and a two-node AKS cluster enables OIDC issuer and workload identity, refreshes kubeconfig, and runs health checks. It does not create ACR, Key Vault, Storage, Radius, or portfolio resources. -From the MicroHack directory: +The devcontainer opens at the Adaptive Apps MicroHack root. Confirm the working +directory before running the script: ```bash -cd "03-Azure/01-01-App Innovation/04-adaptive-apps" +test -f resources/prepare-aks.sh && + test -f resources/prepare-k3s-azure-vm.sh +pwd export AZURE_SUBSCRIPTION="" export AZURE_LOCATION="westeurope" export RESOURCE_GROUP="rg-adaptive-apps" export AKS_CLUSTER="aks-adaptive-apps" -chmod +x resources/prepare-aks.sh -./resources/prepare-aks.sh +bash resources/prepare-aks.sh ``` Optional settings: @@ -81,8 +83,7 @@ export K3S_VM_NAME="vm-adaptive-apps-k3s" # Recommended: set this explicitly to the coach or workshop egress IP. export ADMIN_CIDR="/32" -chmod +x resources/prepare-k3s-azure-vm.sh -./resources/prepare-k3s-azure-vm.sh +bash resources/prepare-k3s-azure-vm.sh ``` Optional settings: 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 f8cbddba4..4d342723f 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 @@ -314,7 +314,6 @@ who choose to skip the manual tutorial. ### AKS ```bash -cd "03-Azure/01-01-App Innovation/04-adaptive-apps" unset KUBECONFIG kubectl config use-context aks-adaptive-apps @@ -322,17 +321,16 @@ export AZURE_SUBSCRIPTION="" export RESOURCE_GROUP="rg-adaptive-apps" export AKS_CLUSTER="aks-adaptive-apps" -chmod +x resources/deploy-radius-aks.sh -./resources/deploy-radius-aks.sh +# Run from the Adaptive Apps MicroHack root. +bash resources/deploy-radius-aks.sh ``` ### K3s ```bash -cd "03-Azure/01-01-App Innovation/04-adaptive-apps" export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm -chmod +x resources/deploy-radius-k3s.sh -./resources/deploy-radius-k3s.sh +# Run from the Adaptive Apps MicroHack root. +bash resources/deploy-radius-k3s.sh ``` 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 1f2c45da9..9e2ee9bbc 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 @@ -305,23 +305,21 @@ tutorial. ### AKS ```bash -cd "03-Azure/01-01-App Innovation/04-adaptive-apps" unset KUBECONFIG kubectl config use-context aks-adaptive-apps -chmod +x resources/configure-resource-types-aks.sh -./resources/configure-resource-types-aks.sh +# Run from the Adaptive Apps MicroHack root. +bash resources/configure-resource-types-aks.sh ``` ### K3s ```bash -cd "03-Azure/01-01-App Innovation/04-adaptive-apps" export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm -chmod +x resources/configure-resource-types-k3s.sh -./resources/configure-resource-types-k3s.sh +# Run from the Adaptive Apps MicroHack root. +bash resources/configure-resource-types-k3s.sh ``` Both scripts accept `TYPES_URL` to override the pinned catalog and 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 2f72dbce5..a4cb4114d 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 @@ -335,19 +335,16 @@ consistently. The manual tutorial is the intended learning path. To automate the same configuration: ```bash -cd "03-Azure/01-01-App Innovation/04-adaptive-apps" -chmod +x resources/configure-recipes.sh - export AZURE_SUBSCRIPTION="" export RESOURCE_GROUP="rg-adaptive-apps" export ACR_NAME="" # Configure both defaults: -./resources/configure-recipes.sh all +bash resources/configure-recipes.sh all # Or configure one: -./resources/configure-recipes.sh aks -./resources/configure-recipes.sh k3s +bash resources/configure-recipes.sh aks +bash resources/configure-recipes.sh k3s ``` The script creates or reuses ACR, publishes the custom SQL recipe for AKS, deploys the From 5ddf3f7dafdae0570f2d0066ccf0a2ac354785c4 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 19 Aug 2026 16:14:59 +0200 Subject: [PATCH 05/29] Secure K3s access with Azure Bastion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../post-create.sh | 15 + .../04-adaptive-apps/Readme.md | 7 +- .../challenges/challenge-00.md | 19 +- .../challenges/challenge-01.md | 32 +- .../challenges/challenge-02.md | 9 + .../challenges/challenge-03.md | 9 + .../challenges/challenge-04.md | 9 + .../04-adaptive-apps/docs/prepare-arc.md | 12 + .../04-adaptive-apps/docs/prepare-k3s.md | 155 +++- .../resources/prepare-k3s-azure-vm.sh | 749 ++++++++++++++++-- .../walkthrough/challenge-00/solution-00.md | 40 +- .../walkthrough/challenge-01/solution-01.md | 49 +- .../walkthrough/challenge-02/solution-02.md | 15 + .../walkthrough/challenge-03/solution-03.md | 12 + .../walkthrough/challenge-04/solution-04.md | 10 + 15 files changed, 1012 insertions(+), 130 deletions(-) diff --git a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh index 2e7dfb176..cb4ac62f0 100644 --- a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh +++ b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh @@ -7,6 +7,7 @@ readonly HELM_VERSION="v3.21.4" readonly RADIUS_VERSION="v0.60.0" readonly YQ_VERSION="v4.53.4" readonly BICEP_VERSION="v0.46.1" +readonly BASTION_EXTENSION_VERSION="1.4.3" retry() { local attempts="$1" @@ -164,6 +165,12 @@ verify_tooling() { else printf '%-8s %s\n' "bicep" "az bicep" fi + if ! az network bastion tunnel --help >/dev/null 2>&1; then + echo "ERROR: Azure CLI Bastion tunnel support is not available." >&2 + missing=1 + else + printf '%-8s %s\n' "bastion" "$(az extension show --name bastion --query version --output tsv)" + fi if [[ "$missing" -ne 0 ]]; then echo "Devcontainer setup is incomplete. Rebuild the container or rerun:" >&2 @@ -177,6 +184,13 @@ install_kubectl install_helm install_radius install_yq +retry 3 az extension add \ + --name bastion \ + --version "$BASTION_EXTENSION_VERSION" \ + --upgrade \ + --yes \ + --allow-preview false \ + --output none retry 3 az bicep install \ --version "$BICEP_VERSION" \ --target-platform "$BICEP_PLATFORM" @@ -192,6 +206,7 @@ rad version git --version jq --version yq --version +az extension show --name bastion --query version --output tsv | sed 's/^/bastion extension: /' ssh -V echo 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 bb6839d72..0042c0228 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 @@ -72,10 +72,12 @@ To use the MicroHack time effectively, have the following available: - An Azure subscription with **Owner** access to the lab resource group - Capacity to create the default two-environment topology: one Azure Kubernetes - Service cluster and one Linux VM hosting self-managed K3s + Service cluster, one private Linux VM hosting self-managed K3s, and one Standard + Azure Bastion host with its managed public endpoint - Alternatively, an existing Azure Local or Arc-enabled Kubernetes environment to use in place of K3s - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) +- Azure CLI `bastion` extension for native client tunneling - [kubectl](https://kubernetes.io/docs/tasks/tools/) - [Helm](https://helm.sh/docs/intro/install/) - [Radius CLI (`rad`)](https://docs.radapp.io/getting-started/install/) @@ -92,6 +94,9 @@ opens this MicroHack as its workspace. See [Challenge 00](challenges/challenge-00.md) for host prerequisites, credential persistence, verification, and troubleshooting. +The default K3s VM has no public IP. Its Kubernetes API is reached through an Azure +Bastion native-client tunnel bound only to localhost in the participant environment. + ### Challenge 00: universal prerequisite Complete Challenge 00 before starting a challenge in any bucket. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md index 49b8bf1d6..47c89fa24 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md @@ -40,11 +40,11 @@ Confirm that your workshop team has: - An Azure subscription - Permission to create a resource group, AKS cluster, Linux VM, networking resources, - public IP addresses, and role assignments + Azure Bastion Standard, its managed public IP, VM Run Command, and role assignments - Permission to create a Microsoft Entra application and service principal -- Regional quota for the AKS nodes, K3s VM, and standard public IP addresses -- A known public egress CIDR that can be used to restrict SSH and Kubernetes API access - to the K3s VM +- Regional quota for the AKS nodes, K3s VM, Azure Bastion, and Bastion public IP +- Contributor or equivalent resource permissions, Network Contributor where networking + is delegated, and permission to open Bastion native-client tunnels > [!IMPORTANT] > Later challenges grant a Radius identity `Owner` on the dedicated lab resource group @@ -80,8 +80,9 @@ In VS Code, run **Dev Containers: Reopen in Container** and select lives under the repository's root `.devcontainer` directory and opens `03-Azure/01-01-App Innovation/04-adaptive-apps` as the container workspace. -The container installs Azure CLI, Bicep, `kubectl`, Helm 3, Radius CLI, Git, `jq`, -`yq`, `curl`, SSH, and `tar`. These tools can run every command in Challenges 00-04. +The container installs Azure CLI, its `bastion` extension, Bicep, `kubectl`, Helm 3, +Radius CLI, Git, `jq`, `yq`, `curl`, SSH, and `tar`. These tools can run every command +in Challenges 00-04. 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 commands you run. @@ -95,6 +96,7 @@ targets by the commands you run. Install the following tools in one consistent shell environment: - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) +- [Azure CLI `bastion` extension](https://learn.microsoft.com/cli/azure/network/bastion) - [Bicep CLI](https://learn.microsoft.com/azure/azure-resource-manager/bicep/install) - [`kubectl`](https://kubernetes.io/docs/tasks/tools/) - [Helm 3](https://helm.sh/docs/intro/install/) @@ -147,6 +149,7 @@ rad version git --version jq --version yq --version +az extension show --name bastion --query version --output tsv curl --version ssh -V tar --version @@ -222,7 +225,9 @@ You are ready to continue when: - The team has the permissions and quota needed for AKS, the K3s VM, networking, and later identity configuration. - The required Azure resource providers are registered or registration is underway. -- The workshop's public egress CIDR is known. +- Azure Bastion Standard is permitted in the chosen region and its hourly cost is + understood. +- Participants have VM Run Command and Bastion tunnel permissions. - Windows participants have a complete toolchain in one shell environment. - The team knows where its Kubernetes and Radius CLI configuration will be stored. - Devcontainer users can rebuild and reopen the container without losing Azure, 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 27d9173e9..59652658d 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 @@ -38,9 +38,9 @@ Agree on names and values for: - Azure subscription and location - Lab resource group - AKS cluster -- K3s VM +- Private K3s VNet, VM, and Azure Bastion names - AKS node and K3s VM sizes -- Public administrator CIDR for K3s management access +- Nonoverlapping VNet, workload-subnet, and `/26` `AzureBastionSubnet` CIDRs The local helper scripts use these defaults: @@ -50,6 +50,8 @@ AKS context: aks-adaptive-apps K3s VM: vm-adaptive-apps-k3s K3s context: k3s-azure-vm K3s kubeconfig: ~/.kube/adaptive-apps-k3s.yaml +K3s API endpoint: https://127.0.0.1:16443 +Azure Bastion: bas-adaptive-apps ``` ### Task 2: Provision AKS @@ -69,14 +71,18 @@ and cluster values explicitly, then validate the result independently. Create an Ubuntu VM with: -- A static public IP -- SSH and Kubernetes API access restricted to the workshop administrator CIDR -- HTTP and HTTPS open for later application exercises +- A private NIC and no VM public IP +- A workload subnet plus a correctly named `/26` or larger `AzureBastionSubnet` +- Azure Bastion Standard with native client tunneling enabled +- VM NSG access on TCP 22 and 6443 only from `AzureBastionSubnet` - K3s installed without its bundled Traefik ingress controller -- A dedicated local kubeconfig using context `k3s-azure-vm` +- A dedicated local kubeconfig using context `k3s-azure-vm` and a localhost endpoint +- A persistent localhost tunnel from port 16443 to the private K3s API The repository includes `resources/prepare-k3s-azure-vm.sh`. Review its network and kubeconfig behavior before running it. Treat the generated kubeconfig as a credential. +Use its `connect` mode after a devcontainer restart. JIT is not a route to a private VM, +and RDP does not apply to Linux; Bastion supplies the private path. ### Task 4: Validate both platforms @@ -90,8 +96,8 @@ kubectl get nodes -o wide ``` For AKS, also confirm that OIDC issuer, workload identity, and managed Istio are -enabled. For K3s, confirm that the K3s service is active on the VM and that the API is -reachable only from an approved network. +enabled. For K3s, confirm the VM has no public IP, Bastion native tunneling is enabled, +the recorded localhost tunnel is healthy, and the K3s service is active. ### Task 5: Practice explicit context switching @@ -108,10 +114,12 @@ Record the active kubeconfig and context whenever you switch platforms. - AKS provisioning state is `Succeeded`. - AKS has OIDC issuer, workload identity, and managed Istio enabled. - The K3s VM is running and its K3s service is active. +- The K3s VM has no public IP or Internet-sourced inbound rule. +- Azure Bastion is `Succeeded`, Standard or Premium, and native tunneling is enabled. - Both Kubernetes APIs return `ok`. - Every node reports `Ready`. - The team can identify and switch between the AKS and K3s kubeconfig/context pairs. -- K3s management access is restricted to the intended administrator CIDR. +- K3s management access is restricted to `AzureBastionSubnet`. - No Radius control plane, portfolio, recipe, or application workload is installed. ## Hints @@ -119,8 +127,10 @@ Record the active kubeconfig and context whenever you switch platforms. - If Azure cannot allocate a requested VM size, select another size or region rather than repeatedly retrying the same deployment. - Check AKS provisioning state and node readiness before rerunning provisioning. -- A K3s API timeout often means the current public address is not included in the - network security rule. +- A K3s API timeout often means the Bastion tunnel must be reconnected after the + devcontainer restarted. +- The tunnel exposes only the Kubernetes API on localhost. Use `kubectl port-forward` + for application and Radius services without opening workload ports on the VM. - `KUBECONFIG` can override the default configuration even when the context name looks familiar. Check both before every platform operation. - Arc enablement projects an existing cluster into Azure management; it does not create 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 9b173a044..48d05a630 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 @@ -26,6 +26,15 @@ 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: + +```bash +export AZURE_SUBSCRIPTION="" +bash resources/prepare-k3s-azure-vm.sh connect +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +``` + ## Tasks ### Task 1: Select the control-plane model 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 2481089a9..769349cfc 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 @@ -25,6 +25,15 @@ The sequence is deliberate: - Register resource types independently with each Radius control plane. - Generate a Bicep extension from a Radius resource-type catalog. +Before the first K3s command, restore the localhost API tunnel after any container +restart: + +```bash +export AZURE_SUBSCRIPTION="" +bash resources/prepare-k3s-azure-vm.sh connect +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +``` + ## Tasks ### Task 1: Install the `core` capability portfolio 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 d113b525d..444726648 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 @@ -21,6 +21,15 @@ implementations. - Publish a Bicep recipe to an OCI registry and register it with an environment. - Compare recipe mappings for the same types across Azure and Local environments. +Before the first K3s command, restore the localhost API tunnel after any container +restart: + +```bash +export AZURE_SUBSCRIPTION="" +bash resources/prepare-k3s-azure-vm.sh connect +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +``` + ## Tasks ### Task 1: Design the custom Azure SQL recipe diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-arc.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-arc.md index 52d3bd6a0..d99b5309c 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-arc.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-arc.md @@ -3,6 +3,18 @@ Use this optional path when the workshop already has a CNCF-conformant Kubernetes cluster reachable from the coach workstation. Arc does not create the cluster. +When connecting the default private K3s VM from this MicroHack, first establish its +localhost API tunnel: + +```bash +export AZURE_SUBSCRIPTION="" +bash resources/prepare-k3s-azure-vm.sh connect +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +``` + +The Arc agents run on the cluster and make outbound Azure connections; Arc does not +replace the participant's Bastion tunnel for direct `kubectl` access to the private API. + Follow the current Microsoft documentation: 1. Review the [Azure Arc-enabled Kubernetes prerequisites](https://learn.microsoft.com/azure/azure-arc/kubernetes/quickstart-connect-cluster#prerequisites). diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md index ef82dca0a..56357c89e 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md @@ -1,42 +1,161 @@ -# Prepare K3s on an Azure VM +# Prepare private K3s through Azure Bastion -The default local environment is single-node K3s on an Azure Linux VM. It represents +The default Local environment is single-node K3s on a private Ubuntu VM. It represents self-managed on-premises or edge Kubernetes without requiring participant compute. -The VM is not AKS, and Arc enablement is optional. +The VM has no public IP and no inbound Internet rules. -## Automated preparation +## Architecture + +The preparation script creates or reuses: + +- VNet `vnet-adaptive-apps` (`10.42.0.0/16`) +- Workload subnet `snet-k3s` (`10.42.0.0/24`) +- Required `AzureBastionSubnet` (`10.42.1.0/26`) +- NIC NSG `nsg-adaptive-apps-k3s` +- Private Ubuntu VM `vm-adaptive-apps-k3s` +- Standard static public IP `pip-adaptive-apps-bastion` for Azure Bastion only +- Standard host `bas-adaptive-apps` with native client tunneling enabled + +The VM NSG allows TCP 22 and 6443 only from `AzureBastionSubnet`, then explicitly denies +all other inbound traffic. K3s installation and kubeconfig retrieval use Azure VM Run +Command, so initial setup does not depend on SSH. Port 22 remains available only through +Bastion for approved Linux troubleshooting. + +Azure Bastion Standard has a managed public endpoint, but the target VM remains private. +An enterprise with stricter requirements can substitute Azure Bastion Premium with its +private-only deployment capability. That substitution requires platform-owned networking +and is not the workshop default. + +## Provision and connect + +From the Adaptive Apps MicroHack root: ```bash export AZURE_SUBSCRIPTION="" export AZURE_LOCATION="westeurope" export RESOURCE_GROUP="rg-adaptive-apps" -export K3S_VM_NAME="vm-adaptive-apps-k3s" -export ADMIN_CIDR="/32" -# Run from the Adaptive Apps MicroHack root. bash resources/prepare-k3s-azure-vm.sh ``` -Optional settings are `K3S_VM_SIZE`, `K3S_ADMIN_USERNAME`, and `K3S_KUBECONFIG`. -If `ADMIN_CIDR` is omitted, the script detects the caller's public IP through -`https://api.ipify.org` and applies a `/32` rule. +The default `provision` mode: + +1. Reconciles networking, Bastion, the private VM, and least-privilege NSG rules. +2. Installs K3s through Run Command with Traefik disabled. +3. Retrieves kubeconfig in bounded base64 chunks without printing cluster credentials. +4. Writes `~/.kube/adaptive-apps-k3s.yaml` with mode `0600`. +5. Rewrites the API endpoint to `https://127.0.0.1:16443`. +6. Starts a persistent `az network bastion tunnel` process. +7. Validates `/readyz` and node readiness. + +Useful optional variables: + +| Variable | Default | +| --- | --- | +| `VNET_NAME` / `VNET_PREFIX` | `vnet-adaptive-apps` / `10.42.0.0/16` | +| `K3S_SUBNET_NAME` / `K3S_SUBNET_PREFIX` | `snet-k3s` / `10.42.0.0/24` | +| `BASTION_SUBNET_PREFIX` | `10.42.1.0/26` | +| `K3S_VM_NAME` / `K3S_VM_SIZE` | `vm-adaptive-apps-k3s` / `Standard_D4s_v5` | +| `BASTION_NAME` | `bas-adaptive-apps` | +| `BASTION_PUBLIC_IP_NAME` | `pip-adaptive-apps-bastion` | +| `K3S_LOCAL_PORT` | `16443` | +| `K3S_KUBECONFIG` | `~/.kube/adaptive-apps-k3s.yaml` | + +Use nonoverlapping CIDRs that comply with the subscription's network policy. The script +will not resize or move existing subnets. It also refuses to reuse an older K3s VM that +still has a public IP; remove the old VM/NIC/public IP or choose new VM and NIC names. + +## Tunnel lifecycle + +The tunnel runs independently of the provisioning shell but ends when the devcontainer +stops or rebuilds. Reconnect without provisioning: + +```bash +export AZURE_SUBSCRIPTION="" +bash resources/prepare-k3s-azure-vm.sh connect +``` + +Inspect or stop only the recorded tunnel: + +```bash +bash resources/prepare-k3s-azure-vm.sh status +bash resources/prepare-k3s-azure-vm.sh disconnect +``` + +PID and log files are stored with restrictive permissions under +`~/.kube/adaptive-apps-bastion/`. Stale PIDs are removed safely. The script verifies the +recorded command line before sending `SIGTERM` and never kills by process name. -The script creates the VM and network rules, installs K3s, writes a dedicated -kubeconfig, and runs platform health checks. +If localhost port 16443 is already occupied, choose a different unprivileged port before +both provisioning and later reconnects: -## Use and validate the cluster +```bash +export K3S_LOCAL_PORT=26443 +``` + +## Use and validate K3s ```bash export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm kubectl get --raw="/readyz" -kubectl wait --for=condition=Ready nodes --all --timeout=10m +kubectl wait --for=condition=Ready nodes --all --timeout=5m kubectl get nodes -o wide ``` -> [!IMPORTANT] -> The K3s kubeconfig contains cluster credentials. Store and share it securely. +The Bastion tunnel forwards only localhost TCP 16443 to the private VM's Kubernetes API. +It does not expose the VM, workloads, HTTP, or HTTPS to the Internet. Access a Kubernetes +or Radius service with `kubectl port-forward`; that traffic travels through the API +tunnel. + +## Permissions and troubleshooting + +Participants need permission to create and update the resource group, VNet, subnets, +NIC, NSG, VM, Bastion host, and Bastion public IP. They also need VM Run Command and +Bastion tunnel actions. A Contributor role normally covers creation; delegated +environments may additionally require Network Contributor on the VNet and an approved +role containing `Microsoft.Network/bastionHosts/tunnels/action`. + +| Symptom | Guidance | +| --- | --- | +| Azure Policy denies a resource | Use approved names, SKU, region, and CIDRs, or request the FDPO/platform exemption. Do not weaken the VM NSG. | +| Bastion creation is denied | Confirm Standard Bastion and its managed public IP are allowed, and verify Network Contributor/Bastion permissions. | +| Tunnel exits before becoming ready | Read `~/.kube/adaptive-apps-bastion/tunnel.log`; confirm Bastion is `Succeeded`, native tunneling is enabled, and the VM is running. | +| Local port is occupied | Stop the known owner or set a different `K3S_LOCAL_PORT`; the script will not kill an unrecorded process. | +| `kubectl` cannot reach 127.0.0.1 | Run `connect`, confirm `status`, and verify the kubeconfig local port matches `K3S_LOCAL_PORT`. | +| Existing VM has a public IP | Follow the script's migration message. It deliberately refuses to leave the legacy public topology in place. | + +JIT access is not used. JIT only opens an NSG rule for a limited time; it does not create +a network route to a private VM. RDP is irrelevant to this Linux VM. Bastion provides the +private route, while SSH is an optional Linux management protocol. + +## Cost and cleanup + +Azure Bastion billing starts when the host is deployed and continues even when no tunnel +is active. After the workshop: + +```bash +bash resources/prepare-k3s-azure-vm.sh disconnect +az network bastion delete \ + --resource-group "$RESOURCE_GROUP" \ + --name bas-adaptive-apps \ + --yes +az network public-ip delete \ + --resource-group "$RESOURCE_GROUP" \ + --name pip-adaptive-apps-bastion +``` + +If the resource group is dedicated to this MicroHack and its contents have been reviewed, +the coach can remove the entire group separately. The script does not automate deletion. K3s can optionally be connected to Azure Arc by following -[Prepare an Azure Arc-enabled cluster](prepare-arc.md). Radius is installed in -Challenge 02. +[Prepare an Azure Arc-enabled cluster](prepare-arc.md). Establish the Bastion API tunnel +before running Arc commands that access the private Kubernetes API. Radius is installed +in Challenge 02. + +## References + +- [Deploy Azure Bastion with Azure CLI](https://learn.microsoft.com/azure/bastion/create-host-cli) +- [Configure Bastion native-client connections](https://learn.microsoft.com/azure/bastion/native-client) +- [Azure Bastion NSG guidance](https://learn.microsoft.com/azure/bastion/bastion-nsg) +- [Azure CLI Bastion reference](https://learn.microsoft.com/cli/azure/network/bastion) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh index 8c7a6c540..3fc2c27e6 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh @@ -1,33 +1,498 @@ #!/usr/bin/env bash # Invoke with Bash from the Adaptive Apps MicroHack root; executable bits are not required. set -euo pipefail +trap 'echo "ERROR: K3s preparation failed at line ${LINENO}. Review Azure Policy and role-assignment errors above." >&2' ERR -for command_name in az curl grep kubectl sed ssh; do - command -v "$command_name" >/dev/null 2>&1 || { - echo "Required command not found: $command_name" >&2 - exit 1 - } -done +TEMP_FILES=() +cleanup_temp_files() { + local file + for file in "${TEMP_FILES[@]}"; do + [[ -n "$file" ]] && rm -f "$file" + done +} +trap cleanup_temp_files EXIT + +readonly MODE="${1:-provision}" +readonly K3S_API_PORT="6443" +readonly KUBECONFIG_CHUNK_SIZE="1800" -: "${AZURE_SUBSCRIPTION:?Set AZURE_SUBSCRIPTION to the target subscription ID.}" AZURE_LOCATION="${AZURE_LOCATION:-westeurope}" RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +VNET_NAME="${VNET_NAME:-vnet-adaptive-apps}" +VNET_PREFIX="${VNET_PREFIX:-10.42.0.0/16}" +K3S_SUBNET_NAME="${K3S_SUBNET_NAME:-snet-k3s}" +K3S_SUBNET_PREFIX="${K3S_SUBNET_PREFIX:-10.42.0.0/24}" +BASTION_SUBNET_PREFIX="${BASTION_SUBNET_PREFIX:-10.42.1.0/26}" +K3S_NSG_NAME="${K3S_NSG_NAME:-nsg-adaptive-apps-k3s}" +K3S_NIC_NAME="${K3S_NIC_NAME:-nic-adaptive-apps-k3s}" K3S_VM_NAME="${K3S_VM_NAME:-vm-adaptive-apps-k3s}" K3S_VM_SIZE="${K3S_VM_SIZE:-Standard_D4s_v5}" K3S_ADMIN_USERNAME="${K3S_ADMIN_USERNAME:-azureuser}" +BASTION_PUBLIC_IP_NAME="${BASTION_PUBLIC_IP_NAME:-pip-adaptive-apps-bastion}" +BASTION_NAME="${BASTION_NAME:-bas-adaptive-apps}" +K3S_LOCAL_PORT="${K3S_LOCAL_PORT:-16443}" K3S_KUBECONFIG="${K3S_KUBECONFIG:-$HOME/.kube/adaptive-apps-k3s.yaml}" K3S_CONTEXT="${K3S_CONTEXT:-k3s-azure-vm}" +K3S_TUNNEL_STATE_DIR="${K3S_TUNNEL_STATE_DIR:-$HOME/.kube/adaptive-apps-bastion}" +readonly TUNNEL_PID_FILE="${K3S_TUNNEL_STATE_DIR}/tunnel.pid" +readonly TUNNEL_LOG_FILE="${K3S_TUNNEL_STATE_DIR}/tunnel.log" + +if (($# > 1)); then + echo "Only one mode argument is supported." >&2 + exit 2 +fi + +usage() { + cat <<'EOF' +Usage: bash resources/prepare-k3s-azure-vm.sh [provision|connect|status|disconnect] + + provision Create or reuse the private K3s platform, retrieve kubeconfig, and connect. + This is the default mode. + connect Re-establish the localhost Azure Bastion tunnel and validate K3s. + status Report local tunnel state without making Azure calls. + disconnect Stop only the recorded, identity-verified tunnel process. + +Required for provision/connect: + AZURE_SUBSCRIPTION Target Azure subscription ID. + +The kubeconfig endpoint defaults to https://127.0.0.1:16443. Override the local port +with K3S_LOCAL_PORT before provisioning and on every later connect command. +EOF +} + +case "$MODE" in + provision | connect | status | disconnect) ;; + -h | --help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; +esac + +is_integer() { + [[ "$1" =~ ^[0-9]+$ ]] +} + +is_pid_running() { + local process_stat + + is_integer "$1" && kill -0 "$1" 2>/dev/null || return 1 + [[ -r "/proc/${1}/stat" ]] || return 1 + process_stat="$(<"/proc/${1}/stat")" + [[ "$process_stat" != *") Z "* ]] +} + +tunnel_pid_matches() { + local pid="$1" + local command_line + + is_pid_running "$pid" || return 1 + [[ -r "/proc/${pid}/cmdline" ]] || return 1 + command_line="$(tr '\0' ' ' <"/proc/${pid}/cmdline")" + [[ "$command_line" == *"az network bastion tunnel"* ]] && + [[ "$command_line" == *"--name ${BASTION_NAME}"* ]] && + [[ "$command_line" == *"--resource-group ${RESOURCE_GROUP}"* ]] && + [[ "$command_line" == *"--resource-port ${K3S_API_PORT}"* ]] && + [[ "$command_line" == *"--port ${K3S_LOCAL_PORT}"* ]] +} + +local_port_ready() { + timeout 1 bash -c "exec 3<>/dev/tcp/127.0.0.1/${K3S_LOCAL_PORT}" 2>/dev/null +} + +read_recorded_pid() { + [[ -f "$TUNNEL_PID_FILE" ]] || return 1 + tr -d '[:space:]' <"$TUNNEL_PID_FILE" +} + +tunnel_status() { + local pid + + if ! pid="$(read_recorded_pid)" || ! is_integer "$pid"; then + echo "K3s Bastion tunnel is not recorded." + return 1 + fi + + if ! tunnel_pid_matches "$pid"; then + echo "K3s Bastion tunnel is not running; PID file is stale or belongs to another process." + return 1 + fi + + if ! local_port_ready; then + echo "K3s Bastion tunnel process ${pid} is running, but localhost:${K3S_LOCAL_PORT} is not ready." + return 1 + fi + + echo "K3s Bastion tunnel is ready on https://127.0.0.1:${K3S_LOCAL_PORT} (PID ${pid})." +} + +disconnect_tunnel() { + local pid + + if ! pid="$(read_recorded_pid)" || ! is_integer "$pid"; then + rm -f "$TUNNEL_PID_FILE" + echo "No K3s Bastion tunnel is recorded." + return + fi + + if ! is_pid_running "$pid"; then + rm -f "$TUNNEL_PID_FILE" + echo "Removed stale K3s Bastion tunnel PID ${pid}." + return + fi + + if ! tunnel_pid_matches "$pid"; then + echo "Refusing to stop PID ${pid}: it is not the recorded Adaptive Apps Bastion tunnel." >&2 + echo "Remove ${TUNNEL_PID_FILE} manually only after investigating that process." >&2 + return 1 + fi -if [[ -z "${ADMIN_CIDR:-}" ]]; then - ADMIN_CIDR="$(curl --fail --silent --show-error https://api.ipify.org)/32" - echo "ADMIN_CIDR was not set; using ${ADMIN_CIDR}." + kill "$pid" + for _ in {1..20}; do + is_pid_running "$pid" || break + sleep 1 + done + if is_pid_running "$pid"; then + echo "Tunnel PID ${pid} did not stop after SIGTERM; it was not force-killed." >&2 + return 1 + fi + + rm -f "$TUNNEL_PID_FILE" + echo "Stopped K3s Bastion tunnel PID ${pid}." +} + +if [[ "$MODE" == "status" ]]; then + if tunnel_status; then + exit 0 + fi + exit 1 +fi + +if [[ "$MODE" == "disconnect" ]]; then + if disconnect_tunnel; then + exit 0 + fi + exit 1 +fi + +for command_name in az base64 cut grep install jq kubectl nohup sed tail timeout tr wc; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "Required command not found: $command_name" >&2 + exit 1 + } +done + +: "${AZURE_SUBSCRIPTION:?Set AZURE_SUBSCRIPTION to the target subscription ID.}" +if ! is_integer "$K3S_LOCAL_PORT"; then + echo "K3S_LOCAL_PORT must be an unprivileged TCP port from 1024 through 65535." >&2 + exit 1 +fi +LOCAL_PORT_DECIMAL=$((10#$K3S_LOCAL_PORT)) +readonly LOCAL_PORT_DECIMAL +((LOCAL_PORT_DECIMAL >= 1024 && LOCAL_PORT_DECIMAL <= 65535)) || { + echo "K3S_LOCAL_PORT must be an unprivileged TCP port from 1024 through 65535." >&2 + exit 1 +} +BASTION_PREFIX_LENGTH="${BASTION_SUBNET_PREFIX##*/}" +[[ "$BASTION_SUBNET_PREFIX" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$ ]] && + is_integer "$BASTION_PREFIX_LENGTH" && + ((10#$BASTION_PREFIX_LENGTH <= 26)) || { + echo "BASTION_SUBNET_PREFIX must be an IPv4 CIDR with a /26 or larger address range." >&2 + exit 1 +} + +if ! az extension show --name bastion >/dev/null 2>&1; then + echo "Azure CLI extension 'bastion' is required." >&2 + echo "Rebuild the devcontainer or run: az extension add --name bastion --upgrade --yes" >&2 + exit 1 fi az account show >/dev/null 2>&1 || az login az account set --subscription "$AZURE_SUBSCRIPTION" +SUBSCRIPTION_ID="$(az account show --query id --output tsv)" +readonly SUBSCRIPTION_ID +readonly VM_RESOURCE_ID="/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Compute/virtualMachines/${K3S_VM_NAME}" + +ensure_tunnel() { + local existing_pid + local new_pid + + install -d -m 0700 "$K3S_TUNNEL_STATE_DIR" + + if existing_pid="$(read_recorded_pid 2>/dev/null)" && is_integer "$existing_pid"; then + if tunnel_pid_matches "$existing_pid" && local_port_ready; then + echo "Reusing K3s Bastion tunnel PID ${existing_pid}." + return + fi + if is_pid_running "$existing_pid" && tunnel_pid_matches "$existing_pid"; then + echo "Replacing unready K3s Bastion tunnel PID ${existing_pid}." + disconnect_tunnel + elif is_pid_running "$existing_pid"; then + echo "Refusing to replace unrelated PID ${existing_pid} from ${TUNNEL_PID_FILE}." >&2 + return 1 + else + rm -f "$TUNNEL_PID_FILE" + fi + fi + + if local_port_ready; then + echo "Local port ${K3S_LOCAL_PORT} is already in use by an unrecorded process." >&2 + echo "Choose another K3S_LOCAL_PORT or stop that process explicitly." >&2 + return 1 + fi + + : >"$TUNNEL_LOG_FILE" + chmod 0600 "$TUNNEL_LOG_FILE" + nohup az network bastion tunnel \ + --name "$BASTION_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --target-resource-id "$VM_RESOURCE_ID" \ + --resource-port "$K3S_API_PORT" \ + --port "$K3S_LOCAL_PORT" \ + --only-show-errors \ + >"$TUNNEL_LOG_FILE" 2>&1 "$TUNNEL_PID_FILE" + chmod 0600 "$TUNNEL_PID_FILE" + + for _ in {1..60}; do + if local_port_ready; then + tunnel_pid_matches "$new_pid" || { + echo "Local port opened, but the recorded tunnel process identity changed." >&2 + return 1 + } + echo "K3s Bastion tunnel is ready on localhost:${K3S_LOCAL_PORT} (PID ${new_pid})." + return + fi + if ! is_pid_running "$new_pid"; then + echo "Azure Bastion tunnel exited before localhost:${K3S_LOCAL_PORT} became ready." >&2 + tail -n 20 "$TUNNEL_LOG_FILE" >&2 + rm -f "$TUNNEL_PID_FILE" + return 1 + fi + sleep 2 + done + + echo "Timed out waiting for Azure Bastion tunnel on localhost:${K3S_LOCAL_PORT}." >&2 + tail -n 20 "$TUNNEL_LOG_FILE" >&2 + return 1 +} + +validate_k3s() { + local configured_server + local expected_server="https://127.0.0.1:${K3S_LOCAL_PORT}" + + [[ -s "$K3S_KUBECONFIG" ]] || { + echo "Kubeconfig not found at ${K3S_KUBECONFIG}; run provision mode first." >&2 + return 1 + } + export KUBECONFIG="$K3S_KUBECONFIG" + configured_server="$(kubectl config view \ + --kubeconfig "$K3S_KUBECONFIG" \ + --minify \ + --output jsonpath='{.clusters[0].cluster.server}')" + [[ "$configured_server" == "$expected_server" ]] || { + echo "Kubeconfig endpoint ${configured_server} does not match ${expected_server}." >&2 + echo "Use the same K3S_LOCAL_PORT as provisioning, or rerun provision mode." >&2 + return 1 + } + kubectl config use-context "$K3S_CONTEXT" >/dev/null + kubectl --request-timeout=15s get --raw="/readyz" | grep -qx "ok" + kubectl wait --for=condition=Ready nodes --all --timeout=5m + kubectl get nodes -o wide +} + +if [[ "$MODE" == "connect" ]]; then + az vm show --resource-group "$RESOURCE_GROUP" --name "$K3S_VM_NAME" >/dev/null || { + echo "K3s VM ${K3S_VM_NAME} was not found; run provision mode first." >&2 + exit 1 + } + CONNECT_NIC_ID="$(az vm show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM_NAME" \ + --query "networkProfile.networkInterfaces[0].id" \ + --output tsv)" + CONNECT_PUBLIC_IP_ID="$(az network nic show \ + --ids "$CONNECT_NIC_ID" \ + --query "ipConfigurations[?publicIPAddress!=null].publicIPAddress.id | [0]" \ + --output tsv)" + [[ -z "$CONNECT_PUBLIC_IP_ID" ]] || { + echo "Refusing to connect: legacy VM ${K3S_VM_NAME} still has a public IP." >&2 + echo "Run provision mode for explicit migration guidance." >&2 + exit 1 + } + az vm start --resource-group "$RESOURCE_GROUP" --name "$K3S_VM_NAME" --output none + CONNECT_BASTION_STATE="$(az network bastion show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_NAME" \ + --query "{state:provisioningState,sku:sku.name,tunneling:enableTunneling}" \ + --output json)" + jq -e \ + '(.state == "Succeeded") and ((.sku == "Standard") or (.sku == "Premium")) and (.tunneling == true)' \ + <<<"$CONNECT_BASTION_STATE" >/dev/null || { + echo "Bastion must be Succeeded, Standard or Premium, and have native tunneling enabled." >&2 + exit 1 + } + ensure_tunnel + validate_k3s + exit +fi + +create_or_validate_subnet() { + local subnet_name="$1" + local subnet_prefix="$2" + local existing_prefix + + existing_prefix="$(az network vnet subnet show \ + --resource-group "$RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$subnet_name" \ + --query addressPrefix \ + --output tsv 2>/dev/null || true)" + if [[ -z "$existing_prefix" ]]; then + az network vnet subnet create \ + --resource-group "$RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$subnet_name" \ + --address-prefixes "$subnet_prefix" \ + --output none + elif [[ "$existing_prefix" != "$subnet_prefix" ]]; then + echo "Subnet ${subnet_name} uses ${existing_prefix}, expected ${subnet_prefix}." >&2 + echo "Choose matching variables or use a new VNET_NAME; the script will not reshape an existing subnet." >&2 + return 1 + fi +} + +echo "Provisioning private K3s network and Azure Bastion." +echo "FDPO policy denials require a compliant region/SKU or an administrator-approved exemption." +echo "Creating network resources requires Contributor or Network Contributor permissions." + az group create --name "$RESOURCE_GROUP" --location "$AZURE_LOCATION" --output none -if ! az vm show -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" >/dev/null 2>&1; then +if ! az network vnet show --resource-group "$RESOURCE_GROUP" --name "$VNET_NAME" >/dev/null 2>&1; then + az network vnet create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$VNET_NAME" \ + --location "$AZURE_LOCATION" \ + --address-prefixes "$VNET_PREFIX" \ + --output none +elif ! az network vnet show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$VNET_NAME" \ + --query "addressSpace.addressPrefixes" \ + --output json | jq -e --arg prefix "$VNET_PREFIX" 'index($prefix) != null' >/dev/null; then + echo "VNet ${VNET_NAME} does not contain expected address prefix ${VNET_PREFIX}." >&2 + exit 1 +fi + +create_or_validate_subnet "$K3S_SUBNET_NAME" "$K3S_SUBNET_PREFIX" +create_or_validate_subnet "AzureBastionSubnet" "$BASTION_SUBNET_PREFIX" + +az network nsg create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_NSG_NAME" \ + --location "$AZURE_LOCATION" \ + --output none + +for rule in \ + "AllowBastionSsh:100:Allow:22:${BASTION_SUBNET_PREFIX}:Tcp" \ + "AllowBastionK3sApi:110:Allow:${K3S_API_PORT}:${BASTION_SUBNET_PREFIX}:Tcp" \ + "DenyOtherInbound:4096:Deny:*:*:*"; do + IFS=: read -r rule_name priority access destination_port source_prefix protocol <<<"$rule" + az network nsg rule create \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$K3S_NSG_NAME" \ + --name "$rule_name" \ + --priority "$priority" \ + --access "$access" \ + --protocol "$protocol" \ + --direction Inbound \ + --source-address-prefixes "$source_prefix" \ + --source-port-ranges '*' \ + --destination-address-prefixes '*' \ + --destination-port-ranges "$destination_port" \ + --output none +done + +EXTRA_INBOUND_ALLOWS="$(az network nsg rule list \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$K3S_NSG_NAME" \ + --query "[?direction=='Inbound' && access=='Allow' && name!='AllowBastionSsh' && name!='AllowBastionK3sApi'].name" \ + --output tsv)" +readonly EXTRA_INBOUND_ALLOWS +[[ -z "$EXTRA_INBOUND_ALLOWS" ]] || { + echo "NSG ${K3S_NSG_NAME} has unexpected inbound allow rules:" >&2 + echo "$EXTRA_INBOUND_ALLOWS" >&2 + echo "Remove those rules or select a new K3S_NSG_NAME; the script will not weaken isolation." >&2 + exit 1 +} + +if az vm show --resource-group "$RESOURCE_GROUP" --name "$K3S_VM_NAME" >/dev/null 2>&1; then + EXISTING_NIC_ID="$(az vm show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM_NAME" \ + --query "networkProfile.networkInterfaces[0].id" \ + --output tsv)" + readonly EXISTING_NIC_ID + EXISTING_PUBLIC_IP_ID="$(az network nic show \ + --ids "$EXISTING_NIC_ID" \ + --query "ipConfigurations[?publicIPAddress!=null].publicIPAddress.id | [0]" \ + --output tsv)" + readonly EXISTING_PUBLIC_IP_ID + if [[ -n "$EXISTING_PUBLIC_IP_ID" ]]; then + echo "Legacy VM ${K3S_VM_NAME} still has a public IP. It will not be modified in place." >&2 + echo "Delete the old VM/NIC/public IP or use a new K3S_VM_NAME and K3S_NIC_NAME, then rerun." >&2 + exit 1 + fi + EXISTING_SUBNET_ID="$(az network nic show \ + --ids "$EXISTING_NIC_ID" \ + --query "ipConfigurations[0].subnet.id" \ + --output tsv)" + readonly EXISTING_SUBNET_ID + if [[ "${EXISTING_SUBNET_ID##*/}" != "$K3S_SUBNET_NAME" ]]; then + echo "Existing VM ${K3S_VM_NAME} is not attached to ${K3S_SUBNET_NAME}; refusing unsafe migration." >&2 + exit 1 + fi + az network nic update \ + --ids "$EXISTING_NIC_ID" \ + --network-security-group "$K3S_NSG_NAME" \ + --output none + az vm start --resource-group "$RESOURCE_GROUP" --name "$K3S_VM_NAME" --output none +else + if ! az network nic show --resource-group "$RESOURCE_GROUP" --name "$K3S_NIC_NAME" >/dev/null 2>&1; then + az network nic create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_NIC_NAME" \ + --location "$AZURE_LOCATION" \ + --vnet-name "$VNET_NAME" \ + --subnet "$K3S_SUBNET_NAME" \ + --network-security-group "$K3S_NSG_NAME" \ + --output none + else + ORPHAN_PUBLIC_IP_ID="$(az network nic show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_NIC_NAME" \ + --query "ipConfigurations[?publicIPAddress!=null].publicIPAddress.id | [0]" \ + --output tsv)" + ORPHAN_SUBNET_ID="$(az network nic show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_NIC_NAME" \ + --query "ipConfigurations[0].subnet.id" \ + --output tsv)" + [[ -z "$ORPHAN_PUBLIC_IP_ID" && "${ORPHAN_SUBNET_ID##*/}" == "$K3S_SUBNET_NAME" ]] || { + echo "Existing NIC ${K3S_NIC_NAME} is not a private NIC in ${K3S_SUBNET_NAME}." >&2 + echo "Remove it or select a new K3S_NIC_NAME." >&2 + exit 1 + } + az network nic update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_NIC_NAME" \ + --network-security-group "$K3S_NSG_NAME" \ + --output none + fi az vm create \ --resource-group "$RESOURCE_GROUP" \ --name "$K3S_VM_NAME" \ @@ -37,59 +502,134 @@ if ! az vm show -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" >/dev/null 2>&1; then --admin-username "$K3S_ADMIN_USERNAME" \ --authentication-type ssh \ --generate-ssh-keys \ - --public-ip-sku Standard \ - --nsg-rule NONE \ + --nics "$K3S_NIC_NAME" \ --os-disk-size-gb 64 \ --storage-sku StandardSSD_LRS \ --output none -else - az vm start -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" --output none fi -NIC_ID="$(az vm show -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" --query "networkProfile.networkInterfaces[0].id" -o tsv)" -NSG_ID="$(az network nic show --ids "$NIC_ID" --query networkSecurityGroup.id -o tsv)" -[[ -n "$NSG_ID" ]] || { - echo "The K3s VM network interface has no network security group." >&2 +NIC_ID="$(az vm show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM_NAME" \ + --query "networkProfile.networkInterfaces[0].id" \ + --output tsv)" +readonly NIC_ID +VM_PUBLIC_IP_ID="$(az network nic show \ + --ids "$NIC_ID" \ + --query "ipConfigurations[?publicIPAddress!=null].publicIPAddress.id | [0]" \ + --output tsv)" +readonly VM_PUBLIC_IP_ID +[[ -z "$VM_PUBLIC_IP_ID" ]] || { + echo "Security validation failed: K3s VM has a public IP association." >&2 exit 1 } -NSG_NAME="${NSG_ID##*/}" +VM_PRIVATE_IP="$(az network nic show \ + --ids "$NIC_ID" \ + --query "ipConfigurations[0].privateIPAddress" \ + --output tsv)" +readonly VM_PRIVATE_IP -create_rule() { - az network nsg rule create \ +if ! az network public-ip show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_PUBLIC_IP_NAME" >/dev/null 2>&1; then + az network public-ip create \ --resource-group "$RESOURCE_GROUP" \ - --nsg-name "$NSG_NAME" \ - --name "$1" \ - --priority "$2" \ - --access Allow \ - --protocol Tcp \ - --direction Inbound \ - --source-address-prefixes "$3" \ - --destination-port-ranges "$4" \ + --name "$BASTION_PUBLIC_IP_NAME" \ + --location "$AZURE_LOCATION" \ + --sku Standard \ + --allocation-method Static \ + --version IPv4 \ --output none -} +else + BASTION_IP_PROPERTIES="$(az network public-ip show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_PUBLIC_IP_NAME" \ + --query "{sku:sku.name,allocation:publicIPAllocationMethod}" \ + --output json)" + readonly BASTION_IP_PROPERTIES + jq -e '.sku == "Standard" and .allocation == "Static"' \ + <<<"$BASTION_IP_PROPERTIES" >/dev/null || { + echo "Existing Bastion public IP must use Standard SKU with static allocation." >&2 + exit 1 + } +fi -create_rule AllowSshFromAdmin 900 "$ADMIN_CIDR" 22 -create_rule AllowKubernetesApiFromAdmin 910 "$ADMIN_CIDR" 6443 -create_rule AllowHttp 1000 Internet 80 -create_rule AllowHttps 1010 Internet 443 +if az network bastion show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_NAME" >/dev/null 2>&1; then + BASTION_SKU="$(az network bastion show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_NAME" \ + --query "sku.name" \ + --output tsv)" + readonly BASTION_SKU + BASTION_LOCATION="$(az network bastion show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_NAME" \ + --query location \ + --output tsv)" + readonly BASTION_LOCATION + case "$BASTION_SKU" in + Standard | Premium) ;; + *) + az network bastion update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_NAME" \ + --location "$BASTION_LOCATION" \ + --sku name=Standard \ + --enable-tunneling true \ + --output none + ;; + esac + az network bastion update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_NAME" \ + --location "$BASTION_LOCATION" \ + --enable-tunneling true \ + --output none +else + az network bastion create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_NAME" \ + --location "$AZURE_LOCATION" \ + --vnet-name "$VNET_NAME" \ + --public-ip-address "$BASTION_PUBLIC_IP_NAME" \ + --sku Standard \ + --enable-tunneling true \ + --output none +fi +az network bastion wait \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_NAME" \ + --custom "provisioningState=='Succeeded'" \ + --timeout 1800 -PUBLIC_IP="$(az vm show -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" --show-details --query publicIps -o tsv)" -[[ -n "$PUBLIC_IP" ]] || { - echo "The K3s VM has no public IP address." >&2 +BASTION_STATE="$(az network bastion show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$BASTION_NAME" \ + --query "{state:provisioningState,sku:sku.name,tunneling:enableTunneling}" \ + --output json)" +readonly BASTION_STATE +jq -e \ + '(.state == "Succeeded") and ((.sku == "Standard") or (.sku == "Premium")) and (.tunneling == true)' \ + <<<"$BASTION_STATE" >/dev/null || { + echo "Bastion must be Succeeded, Standard or Premium, and have native tunneling enabled." >&2 exit 1 } -az vm run-command invoke \ +echo "Installing or reconciling K3s through Azure VM Run Command." +K3S_INSTALL_RESPONSE="$(az vm run-command invoke \ --resource-group "$RESOURCE_GROUP" \ --name "$K3S_VM_NAME" \ --command-id RunShellScript \ --scripts " set -eu -mkdir -p /etc/rancher/k3s +install -d -m 0755 /etc/rancher/k3s cat >/etc/rancher/k3s/config.yaml </dev/null 2>&1; then systemctl enable --now k3s systemctl restart k3s else - curl -sfL https://get.k3s.io | sh - + curl -sfL https://get.k3s.io --output /tmp/install-k3s.sh + sh /tmp/install-k3s.sh + rm -f /tmp/install-k3s.sh fi systemctl is-active --quiet k3s +printf 'K3S_INSTALL_OK\n' " \ - --output none + --output json)" +readonly K3S_INSTALL_RESPONSE +jq -r '.value[]?.message // empty' <<<"$K3S_INSTALL_RESPONSE" | + grep -qx 'K3S_INSTALL_OK' || { + echo "K3s guest installation did not return its success marker." >&2 + echo "Inspect Azure VM Run Command status and outbound access from the private VM." >&2 + exit 1 +} -for attempt in {1..30}; do - if ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \ - "${K3S_ADMIN_USERNAME}@${PUBLIC_IP}" \ - "sudo systemctl is-active --quiet k3s" 2>/dev/null; then - break - fi - [[ "$attempt" -lt 30 ]] || { - echo "Timed out waiting for SSH access to K3s." >&2 - exit 1 +retrieve_kubeconfig() { + local encoded_length + local length_response + local start + local end + local response + local chunk + local encoded_file + local decoded_file + local current_context + + install -d -m 0700 "$(dirname "$K3S_KUBECONFIG")" + encoded_file="$(mktemp)" + decoded_file="$(mktemp)" + chmod 0600 "$encoded_file" "$decoded_file" + TEMP_FILES+=("$encoded_file" "$decoded_file") + + length_response="$(az vm run-command invoke \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM_NAME" \ + --command-id RunShellScript \ + --scripts 'set -eu; encoded="$(base64 -w0 /etc/rancher/k3s/k3s.yaml)"; printf "K3S_LENGTH:%s\n" "${#encoded}"' \ + --output json)" + encoded_length="$(jq -r '.value[]?.message // empty' <<<"$length_response" | + sed -n 's/^K3S_LENGTH:\([0-9][0-9]*\)$/\1/p' | + tail -n 1)" + is_integer "$encoded_length" && ((encoded_length > 0 && encoded_length <= 65536)) || { + echo "Could not determine a safe K3s kubeconfig length from Run Command." >&2 + return 1 } - sleep 10 -done -mkdir -p "$(dirname "$K3S_KUBECONFIG")" -ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ - "${K3S_ADMIN_USERNAME}@${PUBLIC_IP}" \ - "sudo cat /etc/rancher/k3s/k3s.yaml" | - sed "s/127.0.0.1/${PUBLIC_IP}/" >"$K3S_KUBECONFIG" -chmod 600 "$K3S_KUBECONFIG" - -CURRENT_CONTEXT="$(KUBECONFIG="$K3S_KUBECONFIG" kubectl config current-context)" -if [[ "$CURRENT_CONTEXT" != "$K3S_CONTEXT" ]]; then - KUBECONFIG="$K3S_KUBECONFIG" kubectl config rename-context \ - "$CURRENT_CONTEXT" "$K3S_CONTEXT" >/dev/null -fi + : >"$encoded_file" + for ((start = 1; start <= encoded_length; start += KUBECONFIG_CHUNK_SIZE)); do + end=$((start + KUBECONFIG_CHUNK_SIZE - 1)) + echo "Retrieving protected kubeconfig chunk at offset ${start}." + response="$(az vm run-command invoke \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM_NAME" \ + --command-id RunShellScript \ + --scripts "set -eu; printf 'K3S_CHUNK_BEGIN\n'; base64 -w0 /etc/rancher/k3s/k3s.yaml | cut -c ${start}-${end}; printf '\nK3S_CHUNK_END\n'" \ + --output json)" + chunk="$(jq -r '.value[]?.message // empty' <<<"$response" | + sed -n '/^K3S_CHUNK_BEGIN$/,/^K3S_CHUNK_END$/p' | + sed '1d;$d' | + tr -d '\r\n')" + [[ "$chunk" =~ ^[A-Za-z0-9+/=]+$ ]] || { + echo "Run Command returned an invalid kubeconfig chunk at offset ${start}." >&2 + return 1 + } + printf '%s' "$chunk" >>"$encoded_file" + done -VM_POWER_STATE="$(az vm get-instance-view -g "$RESOURCE_GROUP" -n "$K3S_VM_NAME" \ - --query "instanceView.statuses[?starts_with(code, 'PowerState/')].code | [0]" -o tsv)" + [[ "$(wc -c <"$encoded_file" | tr -d '[:space:]')" == "$encoded_length" ]] || { + echo "Run Command kubeconfig retrieval was incomplete." >&2 + return 1 + } + base64 --decode "$encoded_file" >"$decoded_file" + grep -q '^apiVersion:' "$decoded_file" + sed -E \ + "s#server: https://[^[:space:]]+#server: https://127.0.0.1:${K3S_LOCAL_PORT}#" \ + "$decoded_file" >"$encoded_file" + install -m 0600 "$encoded_file" "$K3S_KUBECONFIG" + current_context="$(KUBECONFIG="$K3S_KUBECONFIG" kubectl config current-context)" + if [[ "$current_context" != "$K3S_CONTEXT" ]]; then + KUBECONFIG="$K3S_KUBECONFIG" kubectl config rename-context \ + "$current_context" "$K3S_CONTEXT" >/dev/null + fi + rm -f "$encoded_file" "$decoded_file" +} + +retrieve_kubeconfig + +VM_POWER_STATE="$(az vm get-instance-view \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM_NAME" \ + --query "instanceView.statuses[?starts_with(code, 'PowerState/')].code | [0]" \ + --output tsv)" +readonly VM_POWER_STATE [[ "$VM_POWER_STATE" == "PowerState/running" ]] || { - echo "K3s VM power state is $VM_POWER_STATE." >&2 + echo "K3s VM power state is ${VM_POWER_STATE}." >&2 exit 1 } -KUBECONFIG="$K3S_KUBECONFIG" kubectl get --raw="/readyz" | grep -qx "ok" -KUBECONFIG="$K3S_KUBECONFIG" kubectl wait --for=condition=Ready nodes --all --timeout=10m -KUBECONFIG="$K3S_KUBECONFIG" kubectl get nodes -o wide +ensure_tunnel +validate_k3s -echo "K3s environment is healthy." +echo "K3s environment is healthy through Azure Bastion." echo "Use it with: export KUBECONFIG=\"${K3S_KUBECONFIG}\"" +echo "Reconnect with: bash resources/prepare-k3s-azure-vm.sh connect" +echo "Stop the tunnel with: bash resources/prepare-k3s-azure-vm.sh disconnect" diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md index 27a328de0..60539f8a9 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md @@ -17,12 +17,13 @@ Confirm the workshop has: - An Azure subscription - Permission to create a resource group, AKS cluster, Linux VM, networking, and role assignments +- Permission to create Azure Bastion Standard and its managed public IP, invoke VM Run + Command, and open Bastion native-client tunnels - Permission to create a Microsoft Entra application and service principal - Sufficient regional quota for: - A two-node AKS cluster using `Standard_D4s_v5` by default - One K3s VM using `Standard_D4s_v5` by default - - Standard public IP addresses -- A known public egress CIDR for secure K3s management access + - One Standard Azure Bastion host and its managed public IP The default scripts assign the Radius identity `Owner` at the lab resource-group scope because later exercises can create role assignments. Use a dedicated lab @@ -31,6 +32,7 @@ subscription or resource group. ## Required tools - Azure CLI +- Azure CLI `bastion` extension - Bicep CLI - `kubectl` - Helm @@ -105,7 +107,8 @@ discovers the configuration from the repository root. ### Installed toolchain The Azure CLI devcontainer feature installs Azure CLI 2.89.1. The post-create script -installs Bicep 0.46.1 into the persisted Azure CLI state volume and also installs: +installs Bicep 0.46.1 and the Azure CLI `bastion` extension 1.4.3 into the persisted +Azure CLI state volume and also installs: - `kubectl` 1.36.3 - Helm 3.21.4 @@ -182,15 +185,17 @@ Radius CLI state is separate. Challenge 02 creates `ws-azure-prod` and used by the Radius control plane on AKS. Rebuilding the container preserves those local workspace definitions. -### Kubeconfig and SSH behavior +### Kubeconfig, tunnel, and SSH-key behavior Challenge 01 writes: - AKS credentials to the default `~/.kube/config` -- K3s credentials to `~/.kube/adaptive-apps-k3s.yaml` -- The generated VM SSH key and accepted host key under `~/.ssh` +- K3s credentials and Bastion tunnel state under `~/.kube` +- The generated VM SSH key under `~/.ssh` for optional Bastion-only troubleshooting -The named volumes preserve all three. Participants must still switch safely: +The named volumes preserve this state. The tunnel process itself does not survive a +container restart; Challenge 01 documents its `connect` mode. Participants must still +switch safely: ```bash # AKS @@ -231,6 +236,7 @@ rad version git --version jq --version yq --version +az extension show --name bastion --query version --output tsv curl --version ssh -V tar --version @@ -401,6 +407,17 @@ az bicep install ## Verify the workstation +Install or update the stable Bastion extension on a manual workstation: + +```bash +az extension add \ + --name bastion \ + --version 1.4.3 \ + --upgrade \ + --yes \ + --allow-preview false +``` + Run: ```bash @@ -412,6 +429,7 @@ rad version git --version jq --version yq --version +az extension show --name bastion --query version --output tsv curl --version ssh -V tar --version @@ -485,6 +503,7 @@ az provider show \ | Helm permission error | Verify the Helm binary and local cache ownership; do not default to running Helm as root. | | Wrong Azure subscription | Run `az account set --subscription ""`. | | Azure authorization error | Confirm the participant's role and scope before troubleshooting scripts. | +| Bastion tunnel authorization error | Confirm the role includes `Microsoft.Network/bastionHosts/tunnels/action` and the participant can read the VM/NIC. | | AKS or VM quota error | Select another VM size or region, or request quota before the event. | | WSL cannot see native Windows tools | Install the complete toolchain inside WSL rather than mixing environments. | | Devcontainer creation fails | Inspect the creation log, confirm network access, and run **Dev Containers: Rebuild Container**. | @@ -499,7 +518,10 @@ kubeconfig and later local files still require a consistent participant workstat - Send these instructions early and ask participants to return the verification output. - Validate AKS and VM quota before the event. - Agree on one Azure region and one lab resource-group naming convention. -- Determine the workshop's public egress CIDR for the K3s network rules. +- Confirm Azure Bastion Standard is allowed in the selected region and budget for its + hourly cost until cleanup. +- Confirm delegated-network participants have Network Contributor plus VM Run Command + and Bastion tunnel permissions. - Decide who runs shared cluster deployment scripts; only one person should provision each shared environment. - Encourage participants to use manual tutorials for learning. Automation is an @@ -512,6 +534,6 @@ After Challenge 00, participants can: - Use Azure CLI and select the correct subscription. - Run `kubectl`, Helm, and Radius CLI. -- Use Git, `curl`, SSH, and `tar`. +- Use Git, `curl`, SSH, `tar`, and the Azure CLI Bastion extension. - Explain where their kubeconfig and Radius workspace configuration will be stored. - Proceed to [Challenge 01](../challenge-01/solution-01.md) to provision AKS and K3s. 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 7e4163166..ce43f5359 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 @@ -32,8 +32,8 @@ Common blockers: | --- | --- | | Azure cannot allocate the requested VM size | Set `AKS_NODE_VM_SIZE` or `K3S_VM_SIZE` to an available size, or choose another `AZURE_LOCATION`. | | AKS nodes remain `NotReady` | Check `az aks show --query provisioningState` and allow time for the initial node image pull. | -| K3s API is unreachable | Confirm the caller is included in `ADMIN_CIDR` and TCP 6443 is allowed by the VM network security group. | -| A teammate cannot access K3s | Add the teammate's public CIDR deliberately; do not expose management ports to the internet. | +| K3s API is unreachable | Run the helper in `connect` mode and inspect the recorded tunnel log. | +| Bastion or networking is denied | Confirm FDPO policy, region/SKU, Network Contributor, VM Run Command, and Bastion tunnel permissions. | | Commands target the wrong cluster | Check both `KUBECONFIG` and `kubectl config current-context`. | ## Task 1: Provision AKS @@ -70,9 +70,16 @@ context named after `AKS_CLUSTER`. ## Task 2: Provision K3s on an Azure VM -The script creates or reuses an Ubuntu VM with a static public IP, restricts SSH and -the Kubernetes API to the coach's CIDR, opens HTTP/HTTPS for later exercises, installs -K3s, writes a dedicated kubeconfig, and runs health checks. +The script creates or reuses a VNet, private Ubuntu VM, least-privilege NIC NSG, and +Azure Bastion Standard host. Only Bastion-subnet traffic can reach VM ports 22 and 6443; +all other inbound traffic is denied. The VM has no public IP. K3s installation and +kubeconfig retrieval use Azure VM Run Command, then a native Bastion tunnel maps the +private API to localhost. + +JIT is not used because it only changes when an NSG rule is open; it does not create a +route to a private VM. RDP is a Windows protocol and does not apply to this Ubuntu host. +Azure Bastion supplies the private network path, with SSH retained only as an optional +Bastion-restricted Linux troubleshooting route. ```bash export AZURE_SUBSCRIPTION="" @@ -80,9 +87,6 @@ export AZURE_LOCATION="westeurope" export RESOURCE_GROUP="rg-adaptive-apps" export K3S_VM_NAME="vm-adaptive-apps-k3s" -# Recommended: set this explicitly to the coach or workshop egress IP. -export ADMIN_CIDR="/32" - bash resources/prepare-k3s-azure-vm.sh ``` @@ -92,16 +96,36 @@ Optional settings: export K3S_VM_SIZE="Standard_D4s_v5" export K3S_ADMIN_USERNAME="azureuser" export K3S_KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +export VNET_PREFIX="10.42.0.0/16" +export K3S_SUBNET_PREFIX="10.42.0.0/24" +export BASTION_SUBNET_PREFIX="10.42.1.0/26" +export K3S_LOCAL_PORT=16443 ``` -Use K3s: +The generated kubeconfig points to `https://127.0.0.1:16443`. Its credentials are +retrieved in bounded chunks and are never printed. After reopening or rebuilding the +devcontainer, re-establish the tunnel without changing Azure resources: ```bash +export 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 get nodes ``` +Inspect or stop the tunnel safely: + +```bash +bash resources/prepare-k3s-azure-vm.sh status +bash resources/prepare-k3s-azure-vm.sh disconnect +``` + +The script records the exact PID and verifies its command line before sending +`SIGTERM`; it never kills by process name. The tunnel exposes only the Kubernetes API. +Use `kubectl port-forward` for later Radius and application access. + Return to AKS: ```bash @@ -112,7 +136,9 @@ kubectl get nodes > [!IMPORTANT] > Treat kubeconfig files as credentials. Share the K3s kubeconfig only through an -> approved secure channel and remove access when the workshop ends. +> approved secure channel and remove access when the workshop ends. Azure Bastion +> accrues hourly cost while deployed; follow the +> [K3s cleanup guidance](../../docs/prepare-k3s.md#cost-and-cleanup). ## Health checks @@ -131,6 +157,8 @@ kubectl wait --for=condition=Ready nodes --all --timeout=10m kubectl get nodes -o wide # K3s +export 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 get --raw="/readyz" @@ -157,4 +185,5 @@ into Azure management; it does not create the cluster and is not required by Rad - Both scripts finish successfully. - 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. 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 4d342723f..398c6a081 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 @@ -13,6 +13,17 @@ Duration: 30-45 minutes per environment - Radius startup can take 5-10 minutes. Inspect pod state before rerunning commands. - Always compare `kubectl config current-context` with the active Radius workspace. +Before any K3s work after a devcontainer restart, restore the private API tunnel: + +```bash +export AZURE_SUBSCRIPTION="" +bash resources/prepare-k3s-azure-vm.sh connect +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +``` + +The tunnel exposes only the Kubernetes API on localhost. Radius dashboard access still +uses `kubectl port-forward` through that API connection. + ## Control-plane deployment options ### Federated control planes @@ -231,6 +242,8 @@ rad credential register azure wi \ ### 1. Select and verify K3s ```bash +export 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 @@ -328,6 +341,8 @@ bash resources/deploy-radius-aks.sh ### K3s ```bash +export 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-03/solution-03.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md index 9e2ee9bbc..dec4e9f73 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 @@ -23,6 +23,14 @@ Do not let teams confuse a resource type with a recipe: The manual steps are the learning path. Optional scripts at the end perform the same 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="" +bash resources/prepare-k3s-azure-vm.sh connect +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +``` + ## Capability portfolio The portfolio establishes the baseline capabilities offered by the platform before @@ -71,6 +79,8 @@ helm upgrade --install "$PORTFOLIO" \ ### Install manually on K3s ```bash +export 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 @@ -315,6 +325,8 @@ bash resources/configure-resource-types-aks.sh ### K3s ```bash +export 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 a4cb4114d..1c31a8e54 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 @@ -16,6 +16,14 @@ The teaching sequence is deliberate: Do not let teams skip directly to automation. The optional script at the end exists for setup recovery and participants who explicitly choose not to follow the tutorial. +Before any K3s work after a devcontainer restart, restore the localhost API tunnel: + +```bash +export AZURE_SUBSCRIPTION="" +bash resources/prepare-k3s-azure-vm.sh connect +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +``` + ## Recipe concepts A recipe is a Bicep file or Terraform module that Radius invokes when a developer @@ -263,6 +271,8 @@ rad deploy iac/aks-env.bicep \ Switch both kube context and Radius workspace: ```bash +export 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 rad workspace switch ws-local-prod From c807b37463053693ce581ee78d54eee3076404f1 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 19 Aug 2026 16:48:53 +0200 Subject: [PATCH 06/29] Add portable application challenge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../devcontainer.json | 9 + .../post-create.sh | 3 +- .gitattributes | 5 + .gitignore | 3 + .../04-adaptive-apps/Readme.md | 4 +- .../challenges/challenge-00.md | 4 +- .../challenges/challenge-05.md | 164 ++++- .../04-adaptive-apps/iac/app.bicep | 220 +++++++ .../04-adaptive-apps/iac/bicepconfig.json | 3 +- .../walkthrough/challenge-00/solution-00.md | 8 +- .../walkthrough/challenge-04/solution-04.md | 2 +- .../walkthrough/challenge-05/solution-05.md | 623 ++++++++++++++++++ 12 files changed, 1033 insertions(+), 15 deletions(-) create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md diff --git a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json index 303012a73..b099a3663 100644 --- a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json +++ b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json @@ -12,6 +12,9 @@ "installZsh": false, "installOhMyZsh": false, "upgradePackages": false + }, + "ghcr.io/devcontainers/features/powershell:2": { + "version": "stable" } }, "mounts": [ @@ -26,9 +29,14 @@ "PATH": "${containerEnv:PATH}:/home/vscode/.local/bin" }, "forwardPorts": [ + 3000, 7007 ], "portsAttributes": { + "3000": { + "label": "Adaptive Apps frontend", + "onAutoForward": "notify" + }, "7007": { "label": "Radius dashboard", "onAutoForward": "notify" @@ -39,6 +47,7 @@ "extensions": [ "ms-azuretools.vscode-bicep", "ms-kubernetes-tools.vscode-kubernetes-tools", + "ms-vscode.powershell", "timonwong.shellcheck" ], "settings": { diff --git a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh index cb4ac62f0..f131f9f25 100644 --- a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh +++ b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh @@ -150,7 +150,7 @@ verify_tooling() { echo echo "Adaptive Apps MicroHack tool verification:" - for command_name in az kubectl helm rad git jq yq curl ssh tar; do + for command_name in az kubectl helm rad git jq yq curl pwsh ssh tar; do if ! command -v "$command_name" >/dev/null 2>&1; then echo "ERROR: required command not found: ${command_name}" >&2 missing=1 @@ -203,6 +203,7 @@ az bicep version kubectl version --client helm version --short rad version +pwsh --version git --version jq --version yq --version diff --git a/.gitattributes b/.gitattributes index 9cf30f53d..2d5502e8c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,7 @@ "/03-Azure/01-01-App Innovation/04-adaptive-apps/**/*.sh" text eol=lf "/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/*.sh" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json" text eol=lf +"/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json" text eol=lf diff --git a/.gitignore b/.gitignore index ded6fc6fb..7af82d58a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Exclude .vscode directory .vscode +# Adaptive Apps Bicep resource-type extension generated during Challenge 03. +/03-Azure/01-01-App Innovation/04-adaptive-apps/artifacts/*.tgz + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] 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 0042c0228..4d49abcd1 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 @@ -86,7 +86,7 @@ To use the MicroHack time effectively, have the following available: The recommended option is the repository [devcontainer](../../../.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json), -which installs the Challenges 00-04 toolchain while retaining the manual host setup +which installs the Challenges 00-05 toolchain while retaining the manual host setup path. Clone the contribution, open the repository root in VS Code, run **Dev Containers: Reopen in Container**, and select `03-azure-01-01-app-innovation-04-adaptive-apps` when prompted. The container then @@ -123,7 +123,7 @@ Define portable platform capabilities and implement them with recipes. Deploy the application across environments and adapt its identity, communication, and AI capabilities. -- [Challenge 05 - Port the app across environments](challenges/challenge-05.md) +- [Challenge 05 - Port the App Across Environments](challenges/challenge-05.md) - [Challenge 06 - Adapt identity services](challenges/challenge-06.md) - [Challenge 07 - Secure service communication](challenges/challenge-07.md) - [Challenge 08 - Adapt AI services](challenges/challenge-08.md) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md index 47c89fa24..792c8b0f7 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md @@ -81,8 +81,8 @@ lives under the repository's root `.devcontainer` directory and opens `03-Azure/01-01-App Innovation/04-adaptive-apps` as the container workspace. The container installs Azure CLI, its `bastion` extension, Bicep, `kubectl`, Helm 3, -Radius CLI, Git, `jq`, `yq`, `curl`, SSH, and `tar`. These tools can run every command -in Challenges 00-04. +Radius CLI, PowerShell 7, Git, `jq`, `yq`, `curl`, SSH, and `tar`. These tools can run +every command in Challenges 00-05. 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 commands you run. 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 139b814e5..83482caac 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 @@ -1,7 +1,161 @@ -# Challenge 05 - Port the app across environments +# Challenge 05 - Port the App Across Environments -> [!NOTE] -> This file is an authoring scaffold. Detailed application portability -> instructions will be added in a follow-up contribution. +[< Previous Challenge](challenge-04.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-06.md) -[Back to the Adaptive Apps MicroHack](../Readme.md) +Estimated time: 45-75 minutes | Difficulty: intermediate to advanced + +## Challenge objective + +Prove that the same application model can be deployed to the private K3s platform and +AKS without embedding either platform's implementation details in the application. +Only the selected target, environment parameters, and platform-managed recipes may +change. + +## Learning goals + +- Define the portability boundary between application and platform concerns. +- Prevent deployment drift by checking Kubernetes context, Radius workspace, group, + and environment together. +- Deploy one application model through two independent Radius control planes. +- Compare application graphs, resource implementations, and runtime outcomes. +- Distinguish deployment portability from complete runtime parity. + +## Fixed target map + +Use the exact target map established in Challenges 01-04: + +| Platform | Kubernetes context | Radius workspace | Environment | Radius group | +| --- | --- | --- | --- | --- | +| Private K3s through Bastion | `k3s-azure-vm` | `ws-local-prod` | `env-local-prod` | `rg-trading` | +| AKS | `aks-adaptive-apps` | `ws-azure-prod` | `env-azure-prod` | `rg-trading` | + +The two workspaces target independent Radius control planes. An application named +`adaptive-apps` in one control plane is not the same Radius object as the application +with that name in the other. + +## Constraints + +- Deploy `iac/app.bicep` unchanged to both platforms. +- Use the published `ghcr.io/microsoft/adaptive-apps` images; do not rebuild the + application. +- Explicitly switch both Kubernetes and Radius targets before every deployment. +- Restore the K3s localhost API tunnel after a devcontainer restart. The dedicated + kubeconfig is `~/.kube/adaptive-apps-k3s.yaml`, and its API endpoint is localhost. +- Use the resource types and recipes produced in Challenges 03 and 04. Repair a missing + platform mapping in the environment layer, not in the application model. +- Prompt for the frontend password. Do not put credentials in source, shell history, + screenshots, or evidence files. +- Keep AI disabled. Challenge 08 owns AI adaptation. +- Do not create application workload identities in this challenge. Challenge 06 owns + identity adaptation. The Azure deployment may therefore prove graph and resource + portability before Azure MQTT publish/subscribe reaches full runtime parity. +- Reach the frontend only through `rad resource expose` or Kubernetes port-forwarding. + Do not expose ports on the private K3s VM. + +## Tasks + +### Task 1: Define the portability boundary + +Review `iac/app.bicep` and identify: + +- Application-team inputs and resources that remain identical. +- Environment-specific choices supplied at deployment time. +- Platform implementations selected through recipes. +- Features intentionally deferred to later challenges. + +Create a parity checklist covering target selection, resource-type availability, +recipes, deployment, graph validation, backing resources, and frontend exposure. + +### Task 2: Prove the K3s deployment + +Restore the Bastion tunnel if needed, then explicitly select: + +- Kubernetes context `k3s-azure-vm` +- Radius workspace `ws-local-prod` +- Radius group `rg-trading` +- Radius environment `env-local-prod` + +Verify that the generated Bicep extension from Challenge 03 is available and that the +required PostgreSQL, MQTT, and workload-identity recipes are registered. + +Deploy `iac/app.bicep`, inspect the graph and resources, check Kubernetes workload +health, and expose the frontend through the active K3s Radius control plane. + +### Task 3: Prove the AKS deployment + +Stop the K3s frontend exposure before switching targets. Explicitly select: + +- Kubernetes context `aks-adaptive-apps` +- Radius workspace `ws-azure-prod` +- Radius group `rg-trading` +- Radius environment `env-azure-prod` + +Verify the Azure credential registration from Challenge 02 and the required recipe +mappings from Challenge 04. Deploy the same `iac/app.bicep` file with the same image +inputs, then inspect the graph, Kubernetes resources, Azure backing resources, and +frontend exposure. + +### Task 4: Compare evidence and ownership + +Capture a side-by-side comparison that shows: + +- The same application model and resource-type contracts on both targets. +- A local PostgreSQL and MQTT implementation on K3s. +- Azure Database for PostgreSQL and Azure Event Grid MQTT implementations on AKS. +- Independent application state in each Radius control plane. +- Parameters that changed and parameters that stayed the same. +- Application-team responsibilities versus platform-team responsibilities. +- Known runtime gaps, especially Azure Event Grid MQTT authorization. + +## Success criteria + +- `iac/app.bicep` is deployed unchanged to both target environments. +- Every deployment is preceded by an explicit four-part target check. +- Both Radius control planes show an `adaptive-apps` graph with frontend, backend, + PostgreSQL, MQTT, and workload-identity resources. +- The backing implementations differ according to each environment's recipes. +- The frontend can be exposed through each active control plane, one at a time. +- No password or cluster credential is committed or captured in evidence. +- The team can explain why deployment portability is proven even though Azure MQTT + publish/subscribe still requires topic-space and permission configuration. +- The ownership split is clear: application teams own the model and safe parameters; + platform teams own control planes, environments, provider scopes, recipes, and + backing-service policy. + +## Progressive hints + +### Targeting + +1. A valid Kubernetes context does not select the Radius control plane. +2. Compare the active context with the workspace target before deploying. +3. On K3s, the Bastion process must be running because the kubeconfig endpoint is + `https://127.0.0.1:16443`. + +### Resource readiness + +1. Radius installation alone does not make custom resource types deployable. +2. The application needs the extension package generated from the Challenge 03 catalog. +3. A `RecipeNotFoundFailure` is an environment-layer problem. Return to Challenge 04. + +### Deployment and validation + +1. Keep the Bicep file path and image values identical. +2. Each Radius control plane has its own application graph and state. +3. Stop a foreground exposure before switching workspaces, then start a new exposure + against the newly active control plane. + +### Runtime parity + +1. Successful provisioning and a reachable frontend do not prove every MQTT flow. +2. Event Grid MQTT requires topic spaces, permission bindings, and federated workload + identities in addition to an endpoint. +3. Record the gap; do not bypass identity with a shared secret. + +## Learning resources + +- [Deploy applications with Radius](https://docs.radapp.io/guides/deploy-apps/) +- [Radius environments](https://docs.radapp.io/guides/deploy-apps/environments/overview/) +- [Radius workspaces](https://docs.radapp.io/guides/operations/workspaces/overview/) +- [Radius recipes](https://docs.radapp.io/guides/recipes/overview/) +- [AKS workload identity](https://learn.microsoft.com/azure/aks/workload-identity-overview) +- [Azure Event Grid MQTT](https://learn.microsoft.com/azure/event-grid/mqtt-overview) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep new file mode 100644 index 000000000..9e938e56a --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep @@ -0,0 +1,220 @@ +// Portable stock-trading application adapted from microsoft/adaptive-apps. +// Challenge 05 intentionally keeps AI, OIDC, and governance out of the core +// deployment. Later challenges add those capabilities without changing the +// platform boundary demonstrated here. + +extension radius +extension radiusResources + +@description('The Radius Environment ID. The rad CLI injects this value.') +param environment string + +@description('Published Adaptive Apps container image registry.') +param imageRegistry string = 'ghcr.io/microsoft/adaptive-apps' + +@description('Container image tag shared by the application services.') +param imageTag string = 'latest' + +@description('Username for the frontend local sign-in.') +param authUsername string = 'admin' + +@description('Password for the frontend local sign-in.') +@secure() +param authPassword string + +@description('Secret used to sign frontend session cookies.') +@secure() +#disable-next-line secure-parameter-default +param sessionSecret string = uniqueString(environment, 'session') + +@description('Pre-provisioned managed identity client ID for the backend. Leave empty for Challenge 05.') +param backendClientId string = '' + +@description('Pre-provisioned managed identity client ID for the frontend. Leave empty for Challenge 05.') +param frontendClientId string = '' + +@description('Kubernetes service account bound by the workload-identity recipe.') +param workloadIdentityServiceAccountName string = 'default' + +@description('Entra tenant ID for workload identity. Leave empty for Challenge 05.') +param workloadIdentityTenantId string = '' + +@description('Enable Istio sidecar injection when the platform portfolio includes Istio.') +param enableIstioInjection bool = true + +var kubernetesMetadataExtension = enableIstioInjection ? [ + { + kind: 'kubernetesMetadata' + annotations: { + 'sidecar.istio.io/inject': 'true' + } + labels: { + 'azure.workload.identity/use': 'true' + } + } +] : [ + { + kind: 'kubernetesMetadata' + labels: { + 'azure.workload.identity/use': 'true' + } + } +] + +resource tradingApp 'Applications.Core/applications@2023-10-01-preview' = { + name: 'adaptive-apps' + properties: { + environment: environment + } +} + +resource tradingDb 'Radius.Resources/postgreSqlDatabases@2025-08-01-preview' = { + name: 'trading-db' + properties: { + environment: environment + application: tradingApp.id + size: 'S' + } +} + +resource tradingMqtt 'Radius.Resources/mqttBrokers@2025-08-01-preview' = { + name: 'trading-mqtt' + properties: { + environment: environment + application: tradingApp.id + } +} + +resource backendIdentity 'Radius.Resources/workloadIdentities@2025-08-01-preview' = { + name: 'backend-identity' + properties: { + environment: environment + application: tradingApp.id + #disable-next-line BCP073 + clientId: backendClientId + serviceAccountName: workloadIdentityServiceAccountName + } +} + +resource frontendIdentity 'Radius.Resources/workloadIdentities@2025-08-01-preview' = { + name: 'frontend-identity' + properties: { + environment: environment + application: tradingApp.id + #disable-next-line BCP073 + clientId: frontendClientId + serviceAccountName: workloadIdentityServiceAccountName + } +} + +resource backend 'Applications.Core/containers@2023-10-01-preview' = { + name: 'backend' + properties: { + application: tradingApp.id + container: { + image: '${imageRegistry}/backend:${imageTag}' + ports: { + http: { + containerPort: 8080 + } + } + env: { + ASPNETCORE_URLS: { + value: 'http://+:8080' + } + CONNECTION_DB_SECRETS_PASSWORD: { + value: tradingDb.properties.secrets.password + } + AZURE_CLIENT_ID: { + value: backendIdentity.properties.clientId + } + AZURE_TENANT_ID: { + value: workloadIdentityTenantId + } + MQTT_AUTH_METHOD: { + value: backendIdentity.properties.authMethod + } + MQTT_TOKEN_AUDIENCE: { + value: backendIdentity.properties.tokenAudience + } + MQTT_TOPIC: { + value: 'orders/new' + } + } + } + extensions: kubernetesMetadataExtension + connections: { + db: { + source: tradingDb.id + } + mqtt: { + source: tradingMqtt.id + } + identity: { + source: backendIdentity.id + } + } + } +} + +resource frontend 'Applications.Core/containers@2023-10-01-preview' = { + name: 'frontend' + properties: { + application: tradingApp.id + container: { + image: '${imageRegistry}/frontend:${imageTag}' + ports: { + http: { + containerPort: 3000 + } + } + env: { + PORT: { + value: '3000' + } + BACKEND_URL: { + value: 'http://backend:8080' + } + AI_AGENT_URL: { + value: 'http://ai-agent:7000' + } + MQTT_WS_URL: { + value: '${tradingMqtt.properties.wsPort == 443 ? 'wss' : 'ws'}://${tradingMqtt.properties.host}:${tradingMqtt.properties.wsPort}' + } + AZURE_CLIENT_ID: { + value: frontendIdentity.properties.clientId + } + AZURE_TENANT_ID: { + value: workloadIdentityTenantId + } + MQTT_AUTH_METHOD: { + value: frontendIdentity.properties.authMethod + } + MQTT_TOKEN_AUDIENCE: { + value: frontendIdentity.properties.tokenAudience + } + AUTH_USERNAME: { + value: authUsername + } + AUTH_PASSWORD: { + value: authPassword + } + SESSION_SECRET: { + value: sessionSecret + } + } + } + extensions: kubernetesMetadataExtension + connections: { + backend: { + source: backend.id + } + mqtt: { + source: tradingMqtt.id + } + identity: { + source: frontendIdentity.id + } + } + } +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json index 3a2b5587b..5ae97cc20 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json @@ -1,6 +1,7 @@ { "extensions": { - "radius": "br:biceptypes.azurecr.io/radius:latest" + "radius": "br:biceptypes.azurecr.io/radius:latest", + "radiusResources": "../artifacts/types.tgz" }, "implicitExtensions": [ "az" diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md index 60539f8a9..4726bf10a 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md @@ -113,6 +113,7 @@ Azure CLI state volume and also installs: - `kubectl` 1.36.3 - Helm 3.21.4 - Radius CLI 0.60.0 and its Radius Bicep support +- PowerShell 7 through the official Dev Containers feature - `yq` 4.53.4 - Git, `jq`, `curl`, OpenSSH client, `tar`, and CA certificates @@ -120,9 +121,9 @@ Version pins make participant environments repeatable. Updating a pin requires s validation of this MicroHack and compatibility with the Kubernetes versions offered by AKS. -Rust, PowerShell, k3d, and application-development extensions from the source -repository devcontainer are intentionally omitted. Challenges 00-04 use Bash, create -K3s on an Azure VM, and do not build the Adaptive Apps application source. +Rust, k3d, and application-development extensions from the source repository +devcontainer are intentionally omitted. Challenges 00-05 use Bash and PowerShell 7, +create K3s on an Azure VM, and do not build the Adaptive Apps application source. ### Persistent state and security @@ -233,6 +234,7 @@ az bicep version kubectl version --client helm version --short rad version +pwsh --version git --version jq --version yq --version 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 1c31a8e54..88f7fffea 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 @@ -1,6 +1,6 @@ # Walkthrough Challenge 04 - Implement the platform abstractions with recipes -[< Previous Solution](../challenge-03/solution-03.md) - **[Home](../../Readme.md)** - [Next Challenge](../../challenges/challenge-05.md) +[< Previous Solution](../challenge-03/solution-03.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-05/solution-05.md) Duration: 60-90 minutes 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 new file mode 100644 index 000000000..00e4b75f6 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md @@ -0,0 +1,623 @@ +# Walkthrough Challenge 05 - Port the App Across Environments + +[< Previous Solution](../challenge-04/solution-04.md) - **[Home](../../Readme.md)** - [Next Challenge](../../challenges/challenge-06.md) + +Duration: 45-75 minutes + +## Coach notes + +Challenge 04 established environment-specific recipes. Challenge 05 uses those recipes +to prove the portability boundary: + +```text +Application team Platform team +------------------------------------ -------------------------------------- +iac/app.bicep Radius control plane and environment +portable resource type contracts recipe registrations +published image and safe parameters Kubernetes and Azure implementations +``` + +The core deployment intentionally uses PostgreSQL, MQTT, and workload-identity +contracts. Local password authentication keeps the frontend usable. OIDC adaptation +belongs to Challenge 06, service communication to Challenge 07, and AI to Challenge 08. +Do not provision per-workload managed identities or AI services here. + +Azure Event Grid MQTT exposes a broker endpoint, but end-to-end MQTT requires topic +spaces, permission bindings, and federated workload identities. A green Radius +deployment proves model portability; it does not claim complete MQTT runtime parity. + +## Target discipline + +Use these names without translation: + +| Platform | Kubernetes context | Radius workspace | Environment | Group | +| --- | --- | --- | --- | --- | +| Private K3s | `k3s-azure-vm` | `ws-local-prod` | `env-local-prod` | `rg-trading` | +| AKS | `aks-adaptive-apps` | `ws-azure-prod` | `env-azure-prod` | `rg-trading` | + +The K3s kubeconfig points to `https://127.0.0.1:16443`. That endpoint works only while +the Azure Bastion native-client tunnel is running. The tunnel exposes the Kubernetes +API on localhost; it does not expose application HTTP, HTTPS, MQTT, or SSH ports. + +Each workspace targets an independent Radius control plane. Switching `kubectl` +context without switching the Radius workspace can deploy to the wrong platform. + +## Stage 1: Inspect the invariant application model + +Open `iac/app.bicep`. The core model declares: + +| Resource | Portable contract or Radius resource | +| --- | --- | +| Application | `Applications.Core/applications` | +| Database | `Radius.Resources/postgreSqlDatabases` | +| Broker | `Radius.Resources/mqttBrokers` | +| Workload identities | `Radius.Resources/workloadIdentities` | +| Workloads | `Applications.Core/containers` for `backend` and `frontend` | + +The model does not name a PostgreSQL server, Event Grid namespace, Kubernetes +Deployment, Azure resource group, or VM address. Recipes own those choices. + +The same published images are used on both targets: + +```text +ghcr.io/microsoft/adaptive-apps/backend:latest +ghcr.io/microsoft/adaptive-apps/frontend:latest +``` + +### Generate the local Bicep extension when needed + +Challenge 03 generated `artifacts/types.tgz`. The archive is intentionally ignored by +Git because it is generated for the installed Radius/Bicep toolchain. + +**Bash:** + +```bash +mkdir -p artifacts +if [[ ! -f artifacts/types.tgz ]]; then + TYPES_FILE="$(mktemp)" + curl --fail --location \ + "https://raw.githubusercontent.com/microsoft/adaptive-apps/885627980684e5bcc6fe4bbd2848c1ec247b0a0b/radius/resource-types/types.yaml" \ + --output "$TYPES_FILE" + rad bicep publish-extension \ + --from-file "$TYPES_FILE" \ + --target artifacts/types.tgz \ + --force + rm -f "$TYPES_FILE" +fi +``` + +**PowerShell 7:** + +```powershell +New-Item -ItemType Directory -Path artifacts -Force | Out-Null +if (-not (Test-Path artifacts/types.tgz)) { + $TypesFile = Join-Path ([System.IO.Path]::GetTempPath()) "adaptive-apps-types.yaml" + Invoke-WebRequest ` + -Uri "https://raw.githubusercontent.com/microsoft/adaptive-apps/885627980684e5bcc6fe4bbd2848c1ec247b0a0b/radius/resource-types/types.yaml" ` + -OutFile $TypesFile + rad bicep publish-extension ` + --from-file $TypesFile ` + --target artifacts/types.tgz ` + --force + Remove-Item $TypesFile -Force +} +``` + +`iac/bicepconfig.json` maps the `radiusResources` extension to this generated archive. +Do not commit `types.tgz`. + +## Stage 2: Deploy to private K3s + +### 1. Restore and select the K3s target + +After every devcontainer restart, re-establish the tunnel before K3s commands. +PowerShell also invokes the provisioning tool through Bash because that tool remains a +Bash script. + +**Bash:** + +```bash +export 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 +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod + +test "$(kubectl config current-context)" = "k3s-azure-vm" +test "$(rad workspace show --output json | jq -r '.connection.context')" = "k3s-azure-vm" +rad env show env-local-prod +``` + +**PowerShell 7:** + +```powershell +$env:AZURE_SUBSCRIPTION = "" +bash resources/prepare-k3s-azure-vm.sh connect +$env:KUBECONFIG = "$HOME/.kube/adaptive-apps-k3s.yaml" + +kubectl config use-context k3s-azure-vm +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod + +if ((kubectl config current-context) -ne "k3s-azure-vm") { + throw "Kubernetes target drift: expected k3s-azure-vm." +} +$Workspace = rad workspace show --output json | ConvertFrom-Json +if ($Workspace.connection.context -ne "k3s-azure-vm") { + throw "Radius target drift: ws-local-prod must target k3s-azure-vm." +} +rad env show env-local-prod +``` + +### 2. Verify the Challenge 04 handoff + +**Bash:** + +```bash +rad resource-type list +rad recipe list \ + --workspace ws-local-prod \ + --group rg-trading \ + --environment env-local-prod +``` + +**PowerShell 7:** + +```powershell +rad resource-type list +rad recipe list ` + --workspace ws-local-prod ` + --group rg-trading ` + --environment env-local-prod +``` + +Confirm default recipes exist for: + +- `Radius.Resources/postgreSqlDatabases` +- `Radius.Resources/mqttBrokers` +- `Radius.Resources/workloadIdentities` + +If one is missing, rerun the K3s portion of Challenge 04. Do not edit `iac/app.bicep`. + +### 3. Deploy the model + +Prompt for the local frontend password. The plaintext exists only long enough to pass +it across the CLI process boundary. + +**Bash:** + +```bash +read -rsp "Frontend password: " AUTH_PASSWORD +echo +rad deploy iac/app.bicep \ + --workspace ws-local-prod \ + --group rg-trading \ + --environment env-local-prod \ + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps \ + --parameters imageTag=latest \ + --parameters authUsername=admin \ + --parameters "authPassword=$AUTH_PASSWORD" +unset AUTH_PASSWORD +``` + +**PowerShell 7:** + +```powershell +$SecurePassword = Read-Host "Frontend password" -AsSecureString +$Credential = [System.Management.Automation.PSCredential]::new("admin", $SecurePassword) +$PlainPassword = $Credential.GetNetworkCredential().Password +try { + rad deploy iac/app.bicep ` + --workspace ws-local-prod ` + --group rg-trading ` + --environment env-local-prod ` + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps ` + --parameters imageTag=latest ` + --parameters authUsername=admin ` + --parameters "authPassword=$PlainPassword" +} +finally { + $PlainPassword = $null + $Credential = $null + $SecurePassword = $null +} +``` + +Do not save the password in a parameters file or command transcript. +The CLI argument is briefly visible to local process inspection, so run the lab only +on a trusted, single-user workstation or devcontainer host. + +### 4. Validate graph, resources, and workloads + +**Bash:** + +```bash +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps + +ENV_NAMESPACE="$(rad env show env-local-prod --output json | + jq -r '.properties.compute.namespace')" +APP_NAMESPACE="${ENV_NAMESPACE}-adaptive-apps" +kubectl get namespace "$APP_NAMESPACE" >/dev/null 2>&1 || + APP_NAMESPACE="$ENV_NAMESPACE" +kubectl get pods --namespace "$APP_NAMESPACE" +``` + +**PowerShell 7:** + +```powershell +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps + +$Environment = rad env show env-local-prod --output json | ConvertFrom-Json +$EnvironmentNamespace = $Environment.properties.compute.namespace +$AppNamespace = "$EnvironmentNamespace-adaptive-apps" +kubectl get namespace $AppNamespace --no-headers *> $null +if ($LASTEXITCODE -ne 0) { + $AppNamespace = $EnvironmentNamespace +} +kubectl get pods --namespace $AppNamespace +``` + +The local recipes should produce in-cluster PostgreSQL and Mosquitto resources. The +local workload-identity recipe returns a compatible no-auth mapping. + +### 5. Expose the K3s frontend + +This command runs in the foreground: + +**Bash:** + +```bash +rad resource expose Applications.Core/containers frontend \ + --application adaptive-apps \ + --port 3000 \ + --remote-port 3000 +``` + +**PowerShell 7:** + +```powershell +rad resource expose Applications.Core/containers frontend ` + --application adaptive-apps ` + --port 3000 ` + --remote-port 3000 +``` + +Open and sign in with the prompted password. Press +Ctrl+C to stop the exposure before switching to AKS. + +The browser path travels through Radius/Kubernetes port-forwarding over the Bastion API +tunnel. No inbound application port is opened on the K3s VM. + +## Stage 3: Deploy the same model to AKS + +### 1. Select AKS and its Radius control plane + +**Bash:** + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +test "$(kubectl config current-context)" = "aks-adaptive-apps" +test "$(rad workspace show --output json | jq -r '.connection.context')" = "aks-adaptive-apps" +rad env show env-azure-prod +``` + +**PowerShell 7:** + +```powershell +Remove-Item Env:KUBECONFIG -ErrorAction SilentlyContinue +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +if ((kubectl config current-context) -ne "aks-adaptive-apps") { + throw "Kubernetes target drift: expected aks-adaptive-apps." +} +$Workspace = rad workspace show --output json | ConvertFrom-Json +if ($Workspace.connection.context -ne "aks-adaptive-apps") { + throw "Radius target drift: ws-azure-prod must target aks-adaptive-apps." +} +rad env show env-azure-prod +``` + +### 2. Reuse Azure credentials and recipes + +Challenge 02 registered federated Azure credentials for the AKS Radius control plane. +Verify them; do not create a client secret. + +**Bash:** + +```bash +rad credential show azure +rad recipe list \ + --workspace ws-azure-prod \ + --group rg-trading \ + --environment env-azure-prod +``` + +**PowerShell 7:** + +```powershell +rad credential show azure +rad recipe list ` + --workspace ws-azure-prod ` + --group rg-trading ` + --environment env-azure-prod +``` + +The required resource types are the same as K3s, but their recipes should map to Azure +Database for PostgreSQL, Azure Event Grid MQTT, and AKS workload identity. + +If the credential is missing, return to Challenge 02 and repeat its workload-identity +registration. Do not fall back to a long-lived service-principal secret. + +### 3. Deploy the unchanged model + +Use the same password for a direct UI comparison, but prompt again rather than retaining +it from the K3s command. + +**Bash:** + +```bash +read -rsp "Frontend password: " AUTH_PASSWORD +echo +rad deploy iac/app.bicep \ + --workspace ws-azure-prod \ + --group rg-trading \ + --environment env-azure-prod \ + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps \ + --parameters imageTag=latest \ + --parameters authUsername=admin \ + --parameters "authPassword=$AUTH_PASSWORD" +unset AUTH_PASSWORD +``` + +**PowerShell 7:** + +```powershell +$SecurePassword = Read-Host "Frontend password" -AsSecureString +$Credential = [System.Management.Automation.PSCredential]::new("admin", $SecurePassword) +$PlainPassword = $Credential.GetNetworkCredential().Password +try { + rad deploy iac/app.bicep ` + --workspace ws-azure-prod ` + --group rg-trading ` + --environment env-azure-prod ` + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps ` + --parameters imageTag=latest ` + --parameters authUsername=admin ` + --parameters "authPassword=$PlainPassword" +} +finally { + $PlainPassword = $null + $Credential = $null + $SecurePassword = $null +} +``` + +Azure Database for PostgreSQL can take several minutes to provision. + +### 4. Validate AKS, Radius, and Azure resources + +**Bash:** + +```bash +export AZURE_SUBSCRIPTION="" +export RESOURCE_GROUP="rg-adaptive-apps" +az account set --subscription "$AZURE_SUBSCRIPTION" + +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps + +ENV_NAMESPACE="$(rad env show env-azure-prod --output json | + jq -r '.properties.compute.namespace')" +APP_NAMESPACE="${ENV_NAMESPACE}-adaptive-apps" +kubectl get namespace "$APP_NAMESPACE" >/dev/null 2>&1 || + APP_NAMESPACE="$ENV_NAMESPACE" +kubectl get pods --namespace "$APP_NAMESPACE" + +az resource list \ + --resource-group "$RESOURCE_GROUP" \ + --output table +``` + +**PowerShell 7:** + +```powershell +$env:AZURE_SUBSCRIPTION = "" +$env:RESOURCE_GROUP = "rg-adaptive-apps" +az account set --subscription $env:AZURE_SUBSCRIPTION + +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps + +$Environment = rad env show env-azure-prod --output json | ConvertFrom-Json +$EnvironmentNamespace = $Environment.properties.compute.namespace +$AppNamespace = "$EnvironmentNamespace-adaptive-apps" +kubectl get namespace $AppNamespace --no-headers *> $null +if ($LASTEXITCODE -ne 0) { + $AppNamespace = $EnvironmentNamespace +} +kubectl get pods --namespace $AppNamespace + +az resource list ` + --resource-group $env:RESOURCE_GROUP ` + --output table +``` + +### 5. Expose the AKS frontend + +The K3s exposure must already be stopped. Start a new foreground exposure against the +active AKS control plane: + +**Bash:** + +```bash +rad resource expose Applications.Core/containers frontend \ + --application adaptive-apps \ + --port 3000 \ + --remote-port 3000 +``` + +**PowerShell 7:** + +```powershell +rad resource expose Applications.Core/containers frontend ` + --application adaptive-apps ` + --port 3000 ` + --remote-port 3000 +``` + +Open . A reachable frontend and a complete Radius graph are the +core proof. MQTT-backed features can remain incomplete until Challenge 06 establishes +federated workload identities and Event Grid permissions. + +## Stage 4: Compare the deployments + +Stop the AKS exposure before running comparison commands. + +### K3s evidence + +**Bash:** + +```bash +export 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 +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod + +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps +rad recipe list --environment env-local-prod +``` + +**PowerShell 7:** + +```powershell +$env:AZURE_SUBSCRIPTION = "" +bash resources/prepare-k3s-azure-vm.sh connect +$env:KUBECONFIG = "$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod + +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps +rad recipe list --environment env-local-prod +``` + +### AKS evidence + +**Bash:** + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps +rad recipe list --environment env-azure-prod +``` + +**PowerShell 7:** + +```powershell +Remove-Item Env:KUBECONFIG -ErrorAction SilentlyContinue +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps +rad recipe list --environment env-azure-prod +``` + +### Expected comparison + +| Area | K3s / `ws-local-prod` | AKS / `ws-azure-prod` | +| --- | --- | --- | +| Application file | `iac/app.bicep` | `iac/app.bicep` | +| Radius application | Independent `adaptive-apps` state | Independent `adaptive-apps` state | +| Database contract | `postgreSqlDatabases` | `postgreSqlDatabases` | +| Database implementation | In-cluster PostgreSQL | Azure Database for PostgreSQL | +| MQTT contract | `mqttBrokers` | `mqttBrokers` | +| MQTT implementation | In-cluster Mosquitto | Azure Event Grid MQTT | +| Workload identity | Local no-op mapping | Azure workload-identity mapping | +| AI | Disabled | Disabled | +| Access path | Port-forward over Bastion API tunnel | Port-forward to AKS API | + +The application team owns `iac/app.bicep`, image versions, and safe deployment +parameters. The platform team owns the clusters, Radius control planes, environments, +Azure provider scope, resource types, recipes, and backing-service authorization. + +## Troubleshooting + +### `RecipeNotFoundFailure` + +Run `rad recipe list --environment ` in the matching workspace. Return to +Challenge 04 and deploy `iac/local-env.bicep` or `iac/aks-env.bicep`. Do not replace a +portable resource with a platform-specific declaration. + +### The deployment reached the wrong platform + +Check all four dimensions: + +```bash +kubectl config current-context +rad workspace list +rad group list +rad env show +``` + +Kubernetes context and Radius workspace are independent selectors. + +### K3s reports connection refused on localhost + +The devcontainer restart stopped the tunnel process. Reconnect, then restore the +dedicated kubeconfig: + +```bash +export AZURE_SUBSCRIPTION="" +bash resources/prepare-k3s-azure-vm.sh connect +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +``` + +In PowerShell, set `$env:AZURE_SUBSCRIPTION`, invoke the same script through `bash`, and +set `$env:KUBECONFIG`. + +### The generated extension is missing + +Repeat Stage 1's `rad bicep publish-extension` command. The archive is generated and is +not committed. + +### The frontend is reachable but MQTT features fail on AKS + +This is the documented runtime gap. The Azure recipe creates the Event Grid namespace +and endpoint, but secure publish/subscribe also needs topic spaces, permission bindings, +federated credentials, and appropriate Event Grid data-plane roles. Preserve the +identity boundary and continue with Challenge 06 rather than adding shared secrets. + +### A second exposure cannot bind port 3000 + +Stop the previous `rad resource expose` process with Ctrl+C. +Switch context and workspace, then start the exposure again. A foreground exposure +belongs to the control plane active when it starts. From 5da0aa53405f6d2ed9b36cb5ed82f769c65ce450 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 19 Aug 2026 17:29:39 +0200 Subject: [PATCH 07/29] Add adaptive identity challenge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../devcontainer.json | 5 + .gitattributes | 4 + .../04-adaptive-apps/Readme.md | 8 +- .../challenges/challenge-05.md | 6 +- .../challenges/challenge-06.md | 248 +++- .../04-adaptive-apps/iac/app.bicep | 55 +- .../walkthrough/challenge-05/solution-05.md | 9 +- .../walkthrough/challenge-06/solution-06.md | 1215 +++++++++++++++++ 8 files changed, 1531 insertions(+), 19 deletions(-) create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md diff --git a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json index b099a3663..32edd720c 100644 --- a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json +++ b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json @@ -30,6 +30,7 @@ }, "forwardPorts": [ 3000, + 8080, 7007 ], "portsAttributes": { @@ -37,6 +38,10 @@ "label": "Adaptive Apps frontend", "onAutoForward": "notify" }, + "8080": { + "label": "Keycloak identity broker", + "onAutoForward": "notify" + }, "7007": { "label": "Radius dashboard", "onAutoForward": "notify" diff --git a/.gitattributes b/.gitattributes index 2d5502e8c..308794919 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,11 @@ +"/.gitattributes" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/**/*.sh" text eol=lf "/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/*.sh" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json" text eol=lf "/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json" text eol=lf 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 4d49abcd1..465fe798b 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 @@ -63,8 +63,8 @@ After completing this MicroHack, you will: ## MicroHack challenges > [!NOTE] -> This initial scaffold establishes the challenge sequence and navigation. -> Detailed challenge and coach walkthrough content will be added incrementally. +> The challenge sequence is completed incrementally. Follow the links below for +> the currently published student and coach material. ### General prerequisites @@ -86,7 +86,7 @@ To use the MicroHack time effectively, have the following available: The recommended option is the repository [devcontainer](../../../.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json), -which installs the Challenges 00-05 toolchain while retaining the manual host setup +which installs the Challenges 00-06 toolchain while retaining the manual host setup path. Clone the contribution, open the repository root in VS Code, run **Dev Containers: Reopen in Container**, and select `03-azure-01-01-app-innovation-04-adaptive-apps` when prompted. The container then @@ -124,7 +124,7 @@ Deploy the application across environments and adapt its identity, communication, and AI capabilities. - [Challenge 05 - Port the App Across Environments](challenges/challenge-05.md) -- [Challenge 06 - Adapt identity services](challenges/challenge-06.md) +- [Challenge 06 - Adapt Identity Services - Configure User Authentication](challenges/challenge-06.md) - [Challenge 07 - Secure service communication](challenges/challenge-07.md) - [Challenge 08 - Adapt AI services](challenges/challenge-08.md) 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 83482caac..f9fda9f37 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 @@ -46,9 +46,9 @@ with that name in the other. - Prompt for the frontend password. Do not put credentials in source, shell history, screenshots, or evidence files. - Keep AI disabled. Challenge 08 owns AI adaptation. -- Do not create application workload identities in this challenge. Challenge 06 owns - identity adaptation. The Azure deployment may therefore prove graph and resource - portability before Azure MQTT publish/subscribe reaches full runtime parity. +- Do not complete Azure Event Grid MQTT authorization in this challenge. The Azure + deployment may prove graph and resource portability before MQTT publish/subscribe + reaches full runtime parity; preserve that gap rather than adding shared secrets. - Reach the frontend only through `rad resource expose` or Kubernetes port-forwarding. Do not expose ports on the private K3s VM. 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 1f3207903..e49335059 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 @@ -1,7 +1,245 @@ -# Challenge 06 - Adapt identity services +# Challenge 06 - Adapt Identity Services - Configure User Authentication -> [!NOTE] -> This file is an authoring scaffold. Detailed identity instructions will be -> added in a follow-up contribution. +[< Previous Challenge](challenge-05.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-07.md) -[Back to the Adaptive Apps MicroHack](../Readme.md) +Estimated time: 60-90 minutes | Difficulty: intermediate to advanced + +## Challenge objective + +Add user authentication to the portable trading application without coupling the +frontend to one enterprise identity system. The frontend must keep one OpenID Connect +(OIDC) contract with Keycloak while each platform supplies a different user source: + +- `ws-local-prod` uses users held locally by Keycloak on private K3s. +- `ws-azure-prod` brokers Microsoft Entra ID through Keycloak on AKS. + +The application model changes once to express the stable OIDC contract. That same model +is then deployed to both independent Radius control planes with target-specific +parameters and broker configuration. + +## Scenario + +The trading firm requires cloud users to sign in with Microsoft Entra ID. A local site +must retain an authentication path even when no cloud directory or route to a domain +controller is available. Application developers do not want provider-specific login +code for every location. + +The platform team already deployed a `core` portfolio containing Keycloak on each +cluster. Use those Keycloak instances as identity brokers. In a production design they +would use stable HTTPS hostnames; this workshop reaches Keycloak and the frontend only +through local port-forwarding. + +## Learning goals + +- Separate end-user authentication from Radius control-plane and application workload + identities. +- Use Keycloak as a stable OIDC boundary in front of different user authorities. +- Keep client ID, redirect URI, and application model consistent while client secrets + and upstream federation remain target-specific. +- Prevent identity changes from reaching the wrong Kubernetes or Radius control plane. +- Validate the complete browser, broker, upstream-provider, and callback path. + +## Fixed target map + +| Platform | Kubernetes context | Radius workspace | Environment | Radius group | +| --- | --- | --- | --- | --- | +| Private K3s through Bastion | `k3s-azure-vm` | `ws-local-prod` | `env-local-prod` | `rg-trading` | +| AKS | `aks-adaptive-apps` | `ws-azure-prod` | `env-azure-prod` | `rg-trading` | + +The Keycloak database and the Radius application state are independent on each target. +An OIDC client secret or user created on one target does not exist on the other. + +## Additional prerequisites + +- Completion of Challenge 05 with healthy `core-keycloak` workloads on both clusters. +- A Microsoft Entra role that can create/configure the workshop enterprise application + and assign its test users, such as **Cloud Application Administrator**, + **Application Administrator**, or ownership delegated for the application. +- A non-privileged Entra test user permitted by your organization's Conditional Access + policy. +- Permission to update Kubernetes resources and Secrets in namespace `core` on both + workshop clusters. +- A browser that can reach forwarded localhost ports 3000 and 8080. + +## Identity boundaries + +Do not conflate these three identity planes: + +| Plane | Purpose | Established by | +| --- | --- | --- | +| Radius control-plane identity | Lets Radius deploy Azure resources | Challenge 02 | +| Application workload identity | Lets pods call platform services | Challenges 04-05 recipes | +| End-user authentication | Signs users into the frontend | This challenge | + +Challenge 06 changes only the third plane. Do not create service-principal secrets, +change the Challenge 02 federated credential, or claim that user login completes Azure +Event Grid MQTT authorization. + +## Constraints + +- Start from the application and environments completed in Challenge 05. +- Use the exact target map above and verify all four selectors before each stage. +- After a devcontainer restart, reconnect K3s with + `bash resources/prepare-k3s-azure-vm.sh connect` and use + `~/.kube/adaptive-apps-k3s.yaml`. +- Keep one app-facing contract: + - Client ID `adaptive-apps` + - Confidential OIDC client with authorization-code flow + - Redirect URI `http://localhost:3000/*` + - Browser Keycloak endpoint on `http://localhost:8080` +- Do not commit, echo, screenshot, or save passwords and client secrets in the + repository. Use secure prompts and clear plaintext variables after the CLI boundary. +- Keep all port-forwards in the foreground. Stop Keycloak and frontend forwarding before + switching targets. +- Do not expose the private K3s VM, Kubernetes API, Keycloak, or frontend to the + Internet. +- Use Keycloak-local users for the default K3s path. LDAP/AD DS is an optional + enterprise extension only when the site already has a reachable directory and trust + chain. +- Use Microsoft Entra SAML federation as the AKS upstream identity path. The frontend + still speaks OIDC only to Keycloak. +- Keep AI disabled and do not expand the MQTT runtime scope. + +## Tasks + +### Task 1: Define the portable authentication contract + +Review `iac/app.bicep` and identify the optional OIDC parameters and frontend +environment variables. Draw both authentication sequences: + +1. Browser -> K3s Keycloak -> local Keycloak user -> frontend callback. +2. Browser -> AKS Keycloak -> Microsoft Entra ID -> Keycloak -> frontend callback. + +Label each value as application-owned, broker-owned, or upstream-provider-owned. +Explain why the app model can remain identical even though each Keycloak instance has a +different client secret. + +### Task 2: Configure and validate the K3s identity path + +Reconnect the Bastion API tunnel and explicitly select `k3s-azure-vm`, +`ws-local-prod`, `rg-trading`, and `env-local-prod`. + +On the K3s `core` Keycloak instance: + +- Create or reconcile the confidential `adaptive-apps` client. +- Create a non-administrator local test user with a securely entered password. +- Rotate the portfolio bootstrap administrator without exposing its password. +- Capture the generated client secret only into a protected shell variable. +- Redeploy `iac/app.bicep` with the OIDC contract enabled. +- Forward Keycloak to local port 8080 and the frontend to local port 3000. +- Sign in through the OIDC path and verify the returned user identity. + +Record why this is a local availability pattern rather than an enterprise directory +replacement. + +### Task 3: Configure Microsoft Entra federation on AKS + +Stop both K3s foreground processes, clear the dedicated K3s kubeconfig, and explicitly +select `aks-adaptive-apps`, `ws-azure-prod`, `rg-trading`, and `env-azure-prod`. + +On the AKS `core` Keycloak instance: + +- Create the same `adaptive-apps` OIDC client contract. +- Rotate this broker's portfolio bootstrap administrator independently from K3s. +- Configure a non-gallery Microsoft Entra enterprise application for SAML SSO. +- Use the Keycloak realm as the SAML service provider and add Entra as an upstream + identity provider with alias `entra`. +- Give the SAML service provider a team-unique entity ID so multiple workshop teams can + share one Entra tenant. +- Assign only the workshop test users or group to the enterprise application. +- Map the user attributes needed by the frontend. +- Redeploy the same `iac/app.bicep` with this target's Keycloak client secret. +- Forward Keycloak and the frontend, then complete an Entra-backed sign-in. + +The localhost SAML and OIDC URLs are acceptable only for this port-forwarded workshop. +Document the stable HTTPS hostnames and certificate ownership a production deployment +would require. + +### Task 4: Compare evidence and troubleshoot by layer + +For both targets, capture non-secret evidence of: + +- The active Kubernetes context, Radius workspace, group, and environment. +- The `adaptive-apps` client settings without its secret. +- Frontend and Keycloak workload health. +- The Radius application graph and frontend OIDC environment-variable names. +- Successful login through the intended user authority. + +Create a layered diagnostic checklist covering: + +1. Local port-forward and browser reachability. +2. OIDC client, redirect URI, and secret alignment. +3. Keycloak realm and upstream provider state. +4. Entra enterprise-app assignment or local-user state. +5. Callback, issuer, token, user-info, and browser endpoint alignment. + +## Success criteria + +- `iac/app.bicep` contains one optional, platform-neutral OIDC contract and still deploys + with OIDC disabled when its parameters are omitted. +- The same app model, client ID, redirect URI, and browser ports are used on both + targets. +- K3s login succeeds with a non-administrator local Keycloak user. +- AKS login succeeds with an assigned Microsoft Entra user brokered through Keycloak. +- Client secrets and user passwords differ per target, remain out of source and + evidence, and are cleared from plaintext variables. +- The team can explain why Keycloak changes upstream providers without changing the + frontend's OIDC protocol. +- The team can distinguish user authentication from Radius control-plane identity and + pod workload identity. +- No public endpoint or inbound VM rule is introduced for the workshop login flow. + +## Progressive hints + +### Architecture and targeting + +1. The app should not know whether its user originated in Keycloak or Entra. +2. Kubernetes context selects the Keycloak instance; Radius workspace selects the app + deployment. Verify both. +3. Each control plane has its own client secret even when the client ID is identical. + +### OIDC client + +1. Validate Keycloak health and client configuration before debugging federation. +2. The browser reaches `localhost:8080`, while the frontend container reaches Keycloak + through its `core` cluster service. +3. A redirect mismatch usually means the client does not include + `http://localhost:3000/*`. + +### K3s local identity + +1. A local Keycloak user is enough to prove the broker and OIDC contract without + introducing AD DS into the workshop. +2. Use a non-administrator account for application sign-in. +3. If the callback fails after successful credentials, inspect frontend logs and the + OIDC endpoints rather than changing the user. + +### Entra federation + +1. Keycloak is the SAML service provider; Entra is the upstream SAML identity provider. +2. The reply URL ends in `/broker/entra/endpoint`. +3. An Entra user may authenticate successfully but still be denied when assignment is + required and the user or group is not assigned. + +### Port-forwarding + +1. Keycloak needs local port 8080 and the frontend needs local port 3000. +2. A foreground forward belongs to the cluster active when it started. +3. Stop both forwards before switching targets; do not reuse a stale browser session as + evidence. + +## Learning resources + +- [Keycloak identity brokering](https://www.keycloak.org/docs/latest/server_admin/#_identity_broker) +- [Keycloak OIDC clients](https://www.keycloak.org/docs/latest/server_admin/#assembly-managing-clients_server_administration_guide) +- [Microsoft Entra SAML single sign-on](https://learn.microsoft.com/entra/identity/enterprise-apps/add-application-portal-setup-sso) +- [Assign users and groups to an enterprise application](https://learn.microsoft.com/entra/identity/enterprise-apps/assign-user-or-group-access-portal) +- [OpenID Connect overview](https://openid.net/developers/how-connect-works/) +- [Kubernetes port forwarding](https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/) + +## Optional stretch + +If an actual local AD DS environment is already available, replace the K3s local user +source with Keycloak LDAP user federation over LDAPS. Validate DNS, certificate trust, +bind-account scope, read-only user search, and group/claim mapping. Do not describe this +optional path as complete unless an AD user signs in end to end. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep index 9e938e56a..8b973cdef 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep @@ -1,7 +1,7 @@ // Portable stock-trading application adapted from microsoft/adaptive-apps. -// Challenge 05 intentionally keeps AI, OIDC, and governance out of the core -// deployment. Later challenges add those capabilities without changing the -// platform boundary demonstrated here. +// Challenge 05 keeps AI, OIDC, and governance out of the core deployment. +// Optional parameters let later challenges add those capabilities without +// changing the platform boundary demonstrated here. extension radius extension radiusResources @@ -27,6 +27,31 @@ param authPassword string #disable-next-line secure-parameter-default param sessionSecret string = uniqueString(environment, 'session') +@description('OIDC client ID presented by the frontend. Leave empty to keep OIDC disabled.') +param oidcClientId string = '' + +@description('OIDC client secret presented by the frontend. Leave empty to keep OIDC disabled.') +@secure() +param oidcClientSecret string = '' + +@description('OIDC issuer reachable from the frontend container.') +param oidcIssuer string = '' + +@description('OIDC authorization endpoint reachable from the frontend container.') +param oidcAuthEndpoint string = '' + +@description('OIDC authorization endpoint reachable from the participant browser.') +param oidcBrowserAuthEndpoint string = '' + +@description('OIDC token endpoint reachable from the frontend container.') +param oidcTokenEndpoint string = '' + +@description('OIDC user-info endpoint reachable from the frontend container.') +param oidcUserInfoEndpoint string = '' + +@description('Public frontend URL used to build the OIDC callback URI.') +param appBaseUrl string = 'http://localhost:3000' + @description('Pre-provisioned managed identity client ID for the backend. Leave empty for Challenge 05.') param backendClientId string = '' @@ -202,6 +227,30 @@ resource frontend 'Applications.Core/containers@2023-10-01-preview' = { SESSION_SECRET: { value: sessionSecret } + OIDC_CLIENT_ID: { + value: oidcClientId + } + OIDC_CLIENT_SECRET: { + value: oidcClientSecret + } + OIDC_ISSUER: { + value: oidcIssuer + } + OIDC_AUTH_ENDPOINT: { + value: oidcAuthEndpoint + } + OIDC_BROWSER_AUTH_ENDPOINT: { + value: oidcBrowserAuthEndpoint + } + OIDC_TOKEN_ENDPOINT: { + value: oidcTokenEndpoint + } + OIDC_USERINFO_ENDPOINT: { + value: oidcUserInfoEndpoint + } + APP_BASE_URL: { + value: appBaseUrl + } } } extensions: kubernetesMetadataExtension 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 00e4b75f6..c33f50261 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 @@ -1,6 +1,6 @@ # Walkthrough Challenge 05 - Port the App Across Environments -[< Previous Solution](../challenge-04/solution-04.md) - **[Home](../../Readme.md)** - [Next Challenge](../../challenges/challenge-06.md) +[< Previous Solution](../challenge-04/solution-04.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-06/solution-06.md) Duration: 45-75 minutes @@ -480,8 +480,8 @@ rad resource expose Applications.Core/containers frontend ` ``` Open . A reachable frontend and a complete Radius graph are the -core proof. MQTT-backed features can remain incomplete until Challenge 06 establishes -federated workload identities and Event Grid permissions. +core proof. MQTT-backed features can remain incomplete because Event Grid data-plane +authorization is outside this challenge's portability proof. ## Stage 4: Compare the deployments @@ -614,7 +614,8 @@ not committed. This is the documented runtime gap. The Azure recipe creates the Event Grid namespace and endpoint, but secure publish/subscribe also needs topic spaces, permission bindings, federated credentials, and appropriate Event Grid data-plane roles. Preserve the -identity boundary and continue with Challenge 06 rather than adding shared secrets. +identity boundary and track this as separate platform work rather than adding shared +secrets. Challenge 06 addresses end-user authentication, not MQTT authorization. ### A second exposure cannot bind port 3000 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 new file mode 100644 index 000000000..bbccd690e --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md @@ -0,0 +1,1215 @@ +# Walkthrough Challenge 06 - Adapt Identity Services - Configure User Authentication + +[< Previous Solution](../challenge-05/solution-05.md) - **[Home](../../Readme.md)** - [Next Challenge >](../../challenges/challenge-07.md) + +Duration: 60-90 minutes + +## Coach notes + +This challenge is about **end-user authentication**. It does not replace the federated +identity Radius uses to deploy Azure resources, and it does not complete the workload +identity and authorization needed by Event Grid MQTT. + +```text +Radius control-plane identity Radius -> Azure Resource Manager +Application workload identity pod -> platform service +End-user authentication browser -> frontend -> Keycloak -> user authority +``` + +The authoritative Challenge 06 pattern is: + +```text +K3s: browser -> frontend OIDC -> Keycloak -> local Keycloak user +AKS: browser -> frontend OIDC -> Keycloak -> Entra SAML -> Keycloak -> frontend +``` + +Keycloak is the protocol and portability boundary. The frontend always uses the same +confidential OIDC client, callback, and endpoint shape. The two Keycloak databases, +client secrets, user sources, and Radius application states remain independent. + +The `core` portfolio from Challenge 03 already deployed `core-keycloak` in namespace +`core`. Do not instantiate the application-scoped `idProviders` recipe here: its +current workshop implementation assumes a separate ingress path, while this challenge +deliberately reuses the shared portfolio broker through foreground port-forwarding. + +The local target uses a Keycloak-local test user because this MicroHack does not deploy +AD DS. If a site already has AD DS, the optional LDAPS extension at the end preserves +the source challenge's local-directory scenario. + +## Fixed target map + +| Platform | Kubernetes context | Radius workspace | Environment | Radius group | +| --- | --- | --- | --- | --- | +| Private K3s through Bastion | `k3s-azure-vm` | `ws-local-prod` | `env-local-prod` | `rg-trading` | +| AKS | `aks-adaptive-apps` | `ws-azure-prod` | `env-azure-prod` | `rg-trading` | + +Before every stage, select and verify Kubernetes context, Radius workspace, group, and +environment. Keycloak follows the Kubernetes context; the application deployment +follows the Radius workspace. + +## Stage 1: Understand the stable application contract + +Challenge 06 extends `iac/app.bicep` once with optional parameters: + +| Parameter | Ownership | Purpose | +| --- | --- | --- | +| `oidcClientId` | Application contract | Stable client ID `adaptive-apps` | +| `oidcClientSecret` | Broker instance | Target-specific confidential-client secret | +| `oidcIssuer` | Environment | Internal Keycloak realm URL | +| `oidcAuthEndpoint` | Environment | Internal authorization endpoint | +| `oidcBrowserAuthEndpoint` | Workshop access | Browser-reachable localhost endpoint | +| `oidcTokenEndpoint` | Environment | Internal token endpoint | +| `oidcUserInfoEndpoint` | Environment | Internal user-info endpoint | +| `appBaseUrl` | Application exposure | Builds the callback URL | + +All OIDC parameters default to empty except `appBaseUrl`. Omitting them preserves +Challenge 05 behavior. Enabling them adds these frontend variables without introducing +Entra or K3s resource declarations into the model: + +```text +OIDC_CLIENT_ID +OIDC_CLIENT_SECRET +OIDC_ISSUER +OIDC_AUTH_ENDPOINT +OIDC_BROWSER_AUTH_ENDPOINT +OIDC_TOKEN_ENDPOINT +OIDC_USERINFO_ENDPOINT +APP_BASE_URL +``` + +For this workshop: + +```text +Internal realm: http://core-keycloak.core.svc.cluster.local:8080/realms/master +Browser auth: http://localhost:8080/realms/master/protocol/openid-connect/auth +Callback: http://localhost:3000/auth/oidc/callback +``` + +The split endpoints matter. The participant browser cannot resolve a cluster-local +service name, and the frontend pod cannot reach the devcontainer's localhost. + +## Stage 2: Configure the private K3s identity path + +### 1. Restore and verify the K3s target + +The kubeconfig endpoint is localhost because `prepare-k3s-azure-vm.sh connect` maintains +an Azure Bastion native-client tunnel to the private VM's Kubernetes API. + +**Bash:** + +```bash +export 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 +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod + +test "$(kubectl config current-context)" = "k3s-azure-vm" +test "$(rad workspace show --output json | jq -r '.connection.context')" = "k3s-azure-vm" +rad env show env-local-prod +kubectl rollout status deployment/core-keycloak --namespace core --timeout=10m +``` + +**PowerShell 7:** + +```powershell +$env:AZURE_SUBSCRIPTION = "" +bash resources/prepare-k3s-azure-vm.sh connect +$env:KUBECONFIG = "$HOME/.kube/adaptive-apps-k3s.yaml" + +kubectl config use-context k3s-azure-vm +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod + +if ((kubectl config current-context) -ne "k3s-azure-vm") { + throw "Kubernetes target drift: expected k3s-azure-vm." +} +$Workspace = rad workspace show --output json | ConvertFrom-Json +if ($Workspace.connection.context -ne "k3s-azure-vm") { + throw "Radius target drift: ws-local-prod must target k3s-azure-vm." +} +rad env show env-local-prod +kubectl rollout status deployment/core-keycloak --namespace core --timeout=10m +``` + +### 2. Reconcile the confidential OIDC client + +Use Keycloak's administration CLI inside the pod. The bootstrap administrator password +is consumed inside the container and never crosses the Kubernetes API or terminal. + +**Bash:** + +```bash +KEYCLOAK_POD="$( + kubectl get pods --namespace core \ + --selector app.kubernetes.io/component=keycloak \ + --field-selector status.phase=Running \ + --output jsonpath='{.items[0].metadata.name}' +)" +test -n "$KEYCLOAK_POD" + +kubectl exec --namespace core "$KEYCLOAK_POD" -- /bin/sh -c \ + '/opt/keycloak/bin/kcadm.sh config credentials \ + --config /tmp/kcadm.config \ + --server http://127.0.0.1:8080 \ + --realm master \ + --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ + --password "$KC_BOOTSTRAP_ADMIN_PASSWORD"' + +CLIENT_SPEC="$( + jq -n '{ + clientId: "adaptive-apps", + name: "Adaptive Apps frontend", + enabled: true, + protocol: "openid-connect", + publicClient: false, + clientAuthenticatorType: "client-secret", + standardFlowEnabled: true, + implicitFlowEnabled: false, + directAccessGrantsEnabled: false, + serviceAccountsEnabled: false, + redirectUris: ["http://localhost:3000/*"], + webOrigins: ["http://localhost:3000"] + }' +)" + +CLIENT_UUID="$( + kubectl exec --namespace core "$KEYCLOAK_POD" -- \ + /opt/keycloak/bin/kcadm.sh get clients \ + --config /tmp/kcadm.config \ + --realm master \ + --query clientId=adaptive-apps | + jq -r '.[] | select(.clientId == "adaptive-apps") | .id' | + head -n 1 +)" + +if [ -z "$CLIENT_UUID" ]; then + printf '%s' "$CLIENT_SPEC" | + kubectl exec --stdin --namespace core "$KEYCLOAK_POD" -- \ + /opt/keycloak/bin/kcadm.sh create clients \ + --config /tmp/kcadm.config \ + --realm master \ + --file - + CLIENT_UUID="$( + kubectl exec --namespace core "$KEYCLOAK_POD" -- \ + /opt/keycloak/bin/kcadm.sh get clients \ + --config /tmp/kcadm.config \ + --realm master \ + --query clientId=adaptive-apps | + jq -r '.[] | select(.clientId == "adaptive-apps") | .id' | + head -n 1 + )" +else + printf '%s' "$CLIENT_SPEC" | + kubectl exec --stdin --namespace core "$KEYCLOAK_POD" -- \ + /opt/keycloak/bin/kcadm.sh update "clients/$CLIENT_UUID" \ + --config /tmp/kcadm.config \ + --realm master \ + --file - +fi + +test -n "$CLIENT_UUID" +OIDC_CLIENT_SECRET="$( + kubectl exec --namespace core "$KEYCLOAK_POD" -- \ + /opt/keycloak/bin/kcadm.sh get "clients/$CLIENT_UUID/client-secret" \ + --config /tmp/kcadm.config \ + --realm master | + jq -er '.value' +)" +test -n "$OIDC_CLIENT_SECRET" +unset CLIENT_SPEC +``` + +**PowerShell 7:** + +```powershell +$KeycloakPod = kubectl get pods --namespace core ` + --selector app.kubernetes.io/component=keycloak ` + --field-selector status.phase=Running ` + --output jsonpath='{.items[0].metadata.name}' +if ([string]::IsNullOrWhiteSpace($KeycloakPod)) { + throw "No running Keycloak pod found in namespace core." +} + +kubectl exec --namespace core $KeycloakPod -- /bin/sh -c ` + '/opt/keycloak/bin/kcadm.sh config credentials --config /tmp/kcadm.config --server http://127.0.0.1:8080 --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD"' +if ($LASTEXITCODE -ne 0) { + throw "Keycloak admin authentication failed." +} + +$ClientSpec = @{ + clientId = "adaptive-apps" + name = "Adaptive Apps frontend" + enabled = $true + protocol = "openid-connect" + publicClient = $false + clientAuthenticatorType = "client-secret" + standardFlowEnabled = $true + implicitFlowEnabled = $false + directAccessGrantsEnabled = $false + serviceAccountsEnabled = $false + redirectUris = @("http://localhost:3000/*") + webOrigins = @("http://localhost:3000") +} | ConvertTo-Json -Compress + +$Clients = kubectl exec --namespace core $KeycloakPod -- ` + /opt/keycloak/bin/kcadm.sh get clients ` + --config /tmp/kcadm.config ` + --realm master ` + --query clientId=adaptive-apps | ConvertFrom-Json +$ClientUuid = @($Clients | Where-Object clientId -eq "adaptive-apps")[0].id + +if ([string]::IsNullOrWhiteSpace($ClientUuid)) { + $ClientSpec | kubectl exec --stdin --namespace core $KeycloakPod -- ` + /opt/keycloak/bin/kcadm.sh create clients ` + --config /tmp/kcadm.config ` + --realm master ` + --file - + if ($LASTEXITCODE -ne 0) { + throw "Could not create the Keycloak client." + } + $Clients = kubectl exec --namespace core $KeycloakPod -- ` + /opt/keycloak/bin/kcadm.sh get clients ` + --config /tmp/kcadm.config ` + --realm master ` + --query clientId=adaptive-apps | ConvertFrom-Json + $ClientUuid = @($Clients | Where-Object clientId -eq "adaptive-apps")[0].id +} +else { + $ClientSpec | kubectl exec --stdin --namespace core $KeycloakPod -- ` + /opt/keycloak/bin/kcadm.sh update "clients/$ClientUuid" ` + --config /tmp/kcadm.config ` + --realm master ` + --file - + if ($LASTEXITCODE -ne 0) { + throw "Could not update the Keycloak client." + } +} + +if ([string]::IsNullOrWhiteSpace($ClientUuid)) { + throw "Keycloak did not return an adaptive-apps client ID." +} +$OidcClientSecret = ( + kubectl exec --namespace core $KeycloakPod -- ` + /opt/keycloak/bin/kcadm.sh get "clients/$ClientUuid/client-secret" ` + --config /tmp/kcadm.config ` + --realm master | + ConvertFrom-Json +).value +if ([string]::IsNullOrWhiteSpace($OidcClientSecret)) { + throw "Keycloak did not return a client secret." +} +$ClientSpec = $null +``` + +The client secret is held only in memory. Do not run a command that writes it to the +terminal, shell profile, transcript, or parameters file. + +### 3. Create a local application user + +The application user must not be the Keycloak administrator. + +**Bash:** + +```bash +LOCAL_USERNAME="local-trader" + +LOCAL_USER_ID="$( + kubectl exec --namespace core "$KEYCLOAK_POD" -- \ + /opt/keycloak/bin/kcadm.sh get users \ + --config /tmp/kcadm.config \ + --realm master \ + --query "username=$LOCAL_USERNAME" | + jq -r --arg username "$LOCAL_USERNAME" \ + '.[] | select(.username == $username) | .id' | + head -n 1 +)" + +if [ -z "$LOCAL_USER_ID" ]; then + kubectl exec --namespace core "$KEYCLOAK_POD" -- \ + /opt/keycloak/bin/kcadm.sh create users \ + --config /tmp/kcadm.config \ + --realm master \ + --set "username=$LOCAL_USERNAME" \ + --set enabled=true +fi + +read -rsp "Password for $LOCAL_USERNAME: " LOCAL_USER_PASSWORD +echo +if ! printf '%s\n%s\n' "$LOCAL_USERNAME" "$LOCAL_USER_PASSWORD" | + kubectl exec --stdin --namespace core "$KEYCLOAK_POD" -- /bin/sh -c \ + 'IFS= read -r USERNAME + IFS= read -r PASSWORD + exec /opt/keycloak/bin/kcadm.sh set-password \ + --config /tmp/kcadm.config \ + --realm master \ + --username "$USERNAME" \ + --new-password "$PASSWORD" \ + --temporary=false'; then + echo "Local user password update failed. Resolve the error before continuing." >&2 +fi +unset LOCAL_USER_PASSWORD LOCAL_USER_ID +``` + +**PowerShell 7:** + +```powershell +$LocalUsername = "local-trader" +$LocalUsers = kubectl exec --namespace core $KeycloakPod -- ` + /opt/keycloak/bin/kcadm.sh get users ` + --config /tmp/kcadm.config ` + --realm master ` + --query "username=$LocalUsername" | ConvertFrom-Json +$LocalUser = @($LocalUsers | Where-Object username -eq $LocalUsername)[0] + +if ($null -eq $LocalUser) { + kubectl exec --namespace core $KeycloakPod -- ` + /opt/keycloak/bin/kcadm.sh create users ` + --config /tmp/kcadm.config ` + --realm master ` + --set "username=$LocalUsername" ` + --set enabled=true + if ($LASTEXITCODE -ne 0) { + throw "Could not create the local Keycloak user." + } +} + +$SecureLocalPassword = Read-Host "Password for $LocalUsername" -AsSecureString +$LocalCredential = [System.Management.Automation.PSCredential]::new( + $LocalUsername, + $SecureLocalPassword +) +$PlainLocalPassword = $LocalCredential.GetNetworkCredential().Password +try { + "$LocalUsername`n$PlainLocalPassword`n" | + kubectl exec --stdin --namespace core $KeycloakPod -- /bin/sh -c ` + 'IFS= read -r USERNAME; IFS= read -r PASSWORD; exec /opt/keycloak/bin/kcadm.sh set-password --config /tmp/kcadm.config --realm master --username "$USERNAME" --new-password "$PASSWORD" --temporary=false' + if ($LASTEXITCODE -ne 0) { + throw "Could not set the local Keycloak user password." + } +} +finally { + $PlainLocalPassword = $null + $LocalCredential = $null + $SecureLocalPassword = $null +} +``` + +The password travels over `kubectl exec` standard input and becomes a `kcadm.sh` +argument only inside the pod. It is not placed in the Kubernetes API request URL or the +local process argument list. Use a trusted workshop workstation, keep terminal +transcription disabled, and clear the variable immediately. + +### 4. Rotate the workshop broker administrator + +The portfolio bootstrap credential initializes Keycloak but is not suitable for ongoing +administration. Rotate it before exposing the broker to the browser. This target-neutral +block changes the database password and updates the Helm-managed Kubernetes Secret +without placing the password in an API URL or local process argument list. + +**Bash:** + +```bash +KEYCLOAK_ADMIN_USERNAME="$( + kubectl get secret core-keycloak-admin --namespace core \ + --output jsonpath='{.data.username}' | + base64 --decode +)" +test -n "$KEYCLOAK_ADMIN_USERNAME" + +read -rsp "New workshop Keycloak admin password: " KEYCLOAK_ADMIN_PASSWORD +echo +if printf '%s\n%s\n' "$KEYCLOAK_ADMIN_USERNAME" "$KEYCLOAK_ADMIN_PASSWORD" | + kubectl exec --stdin --namespace core "$KEYCLOAK_POD" -- /bin/sh -c \ + 'IFS= read -r USERNAME + IFS= read -r PASSWORD + exec /opt/keycloak/bin/kcadm.sh set-password \ + --config /tmp/kcadm.config \ + --realm master \ + --username "$USERNAME" \ + --new-password "$PASSWORD" \ + --temporary=false'; then + export KEYCLOAK_ADMIN_USERNAME KEYCLOAK_ADMIN_PASSWORD + ADMIN_SECRET_MANIFEST="$( + jq -n '{ + apiVersion: "v1", + kind: "Secret", + metadata: { + name: "core-keycloak-admin", + namespace: "core" + }, + type: "Opaque", + stringData: { + username: env.KEYCLOAK_ADMIN_USERNAME, + password: env.KEYCLOAK_ADMIN_PASSWORD + } + }' + )" + export -n KEYCLOAK_ADMIN_USERNAME KEYCLOAK_ADMIN_PASSWORD + if printf '%s' "$ADMIN_SECRET_MANIFEST" | kubectl apply --filename -; then + unset ADMIN_SECRET_MANIFEST KEYCLOAK_ADMIN_PASSWORD + kubectl rollout restart deployment/core-keycloak --namespace core + kubectl rollout status deployment/core-keycloak --namespace core --timeout=10m + unset KEYCLOAK_POD + else + unset ADMIN_SECRET_MANIFEST + echo "Secret update failed. Do not restart Keycloak." >&2 + echo "Retry the Secret update while KEYCLOAK_ADMIN_PASSWORD remains available." >&2 + fi +else + unset KEYCLOAK_ADMIN_PASSWORD + echo "Password change failed. The Secret was not updated; do not restart Keycloak." >&2 +fi +``` + +**PowerShell 7:** + +```powershell +$KeycloakAdminUsername = kubectl get secret core-keycloak-admin ` + --namespace core ` + --output jsonpath='{.data.username}' +$KeycloakAdminUsername = [Text.Encoding]::UTF8.GetString( + [Convert]::FromBase64String($KeycloakAdminUsername) +) +if ([string]::IsNullOrWhiteSpace($KeycloakAdminUsername)) { + throw "The Keycloak administrator username is missing." +} + +$SecureAdminPassword = Read-Host "New workshop Keycloak admin password" -AsSecureString +$AdminCredential = [System.Management.Automation.PSCredential]::new( + $KeycloakAdminUsername, + $SecureAdminPassword +) +$PlainAdminPassword = $AdminCredential.GetNetworkCredential().Password +try { + "$KeycloakAdminUsername`n$PlainAdminPassword`n" | + kubectl exec --stdin --namespace core $KeycloakPod -- /bin/sh -c ` + 'IFS= read -r USERNAME; IFS= read -r PASSWORD; exec /opt/keycloak/bin/kcadm.sh set-password --config /tmp/kcadm.config --realm master --username "$USERNAME" --new-password "$PASSWORD" --temporary=false' + if ($LASTEXITCODE -ne 0) { + throw "Could not rotate the Keycloak administrator password." + } + + $AdminSecretManifest = @{ + apiVersion = "v1" + kind = "Secret" + metadata = @{ + name = "core-keycloak-admin" + namespace = "core" + } + type = "Opaque" + stringData = @{ + username = $KeycloakAdminUsername + password = $PlainAdminPassword + } + } | ConvertTo-Json -Compress + $AdminSecretManifest | kubectl apply --filename - + if ($LASTEXITCODE -ne 0) { + throw "The database password changed but the Secret update failed. Do not restart Keycloak; retry the Secret apply with the chosen password." + } +} +finally { + $AdminSecretManifest = $null + $PlainAdminPassword = $null + $AdminCredential = $null + $SecureAdminPassword = $null +} + +kubectl rollout restart deployment/core-keycloak --namespace core +kubectl rollout status deployment/core-keycloak --namespace core --timeout=10m +$KeycloakPod = $null +``` + +Keep the chosen password in the participant's approved password manager. The rollout +invalidates the previous pod name and `/tmp/kcadm.config`; repeat the pod-discovery and +`kcadm.sh config credentials` block from step 2 before any later admin CLI operation. +Do not use the administrator account for application sign-in. + +### 5. Redeploy the application with OIDC enabled + +The Keycloak client secret and frontend break-glass password are separate. Retain a +strong local frontend password for the workshop, but validate the OIDC path. + +**Bash:** + +```bash +OIDC_ISSUER="http://core-keycloak.core.svc.cluster.local:8080/realms/master" +OIDC_AUTH_ENDPOINT="$OIDC_ISSUER/protocol/openid-connect/auth" +OIDC_TOKEN_ENDPOINT="$OIDC_ISSUER/protocol/openid-connect/token" +OIDC_USERINFO_ENDPOINT="$OIDC_ISSUER/protocol/openid-connect/userinfo" +OIDC_BROWSER_AUTH_ENDPOINT="http://localhost:8080/realms/master/protocol/openid-connect/auth" + +read -rsp "Frontend break-glass password: " AUTH_PASSWORD +echo +rad deploy iac/app.bicep \ + --workspace ws-local-prod \ + --group rg-trading \ + --environment env-local-prod \ + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps \ + --parameters imageTag=latest \ + --parameters authUsername=admin \ + --parameters "authPassword=$AUTH_PASSWORD" \ + --parameters oidcClientId=adaptive-apps \ + --parameters "oidcClientSecret=$OIDC_CLIENT_SECRET" \ + --parameters "oidcIssuer=$OIDC_ISSUER" \ + --parameters "oidcAuthEndpoint=$OIDC_AUTH_ENDPOINT" \ + --parameters "oidcBrowserAuthEndpoint=$OIDC_BROWSER_AUTH_ENDPOINT" \ + --parameters "oidcTokenEndpoint=$OIDC_TOKEN_ENDPOINT" \ + --parameters "oidcUserInfoEndpoint=$OIDC_USERINFO_ENDPOINT" \ + --parameters appBaseUrl=http://localhost:3000 +unset AUTH_PASSWORD OIDC_CLIENT_SECRET +``` + +**PowerShell 7:** + +```powershell +$OidcIssuer = "http://core-keycloak.core.svc.cluster.local:8080/realms/master" +$OidcAuthEndpoint = "$OidcIssuer/protocol/openid-connect/auth" +$OidcTokenEndpoint = "$OidcIssuer/protocol/openid-connect/token" +$OidcUserInfoEndpoint = "$OidcIssuer/protocol/openid-connect/userinfo" +$OidcBrowserAuthEndpoint = "http://localhost:8080/realms/master/protocol/openid-connect/auth" + +$SecurePassword = Read-Host "Frontend break-glass password" -AsSecureString +$Credential = [System.Management.Automation.PSCredential]::new("admin", $SecurePassword) +$PlainPassword = $Credential.GetNetworkCredential().Password +try { + rad deploy iac/app.bicep ` + --workspace ws-local-prod ` + --group rg-trading ` + --environment env-local-prod ` + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps ` + --parameters imageTag=latest ` + --parameters authUsername=admin ` + --parameters "authPassword=$PlainPassword" ` + --parameters oidcClientId=adaptive-apps ` + --parameters "oidcClientSecret=$OidcClientSecret" ` + --parameters "oidcIssuer=$OidcIssuer" ` + --parameters "oidcAuthEndpoint=$OidcAuthEndpoint" ` + --parameters "oidcBrowserAuthEndpoint=$OidcBrowserAuthEndpoint" ` + --parameters "oidcTokenEndpoint=$OidcTokenEndpoint" ` + --parameters "oidcUserInfoEndpoint=$OidcUserInfoEndpoint" ` + --parameters appBaseUrl=http://localhost:3000 +} +finally { + $PlainPassword = $null + $Credential = $null + $SecurePassword = $null + $OidcClientSecret = $null +} +``` + +### 6. Validate K3s without disclosing secret values + +The frontend image logs whether OIDC is enabled without logging its client secret. Use +that startup diagnostic rather than rendering the container resource or Deployment: +the current Radius container contract stores the client secret as an inline environment +value, so `rad resource show` and `kubectl get deployment -o yaml` are not safe evidence. + +**Bash:** + +```bash +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps + +ENV_NAMESPACE="$(rad env show env-local-prod --output json | + jq -r '.properties.compute.namespace')" +APP_NAMESPACE="${ENV_NAMESPACE}-adaptive-apps" +kubectl get namespace "$APP_NAMESPACE" >/dev/null 2>&1 || + APP_NAMESPACE="$ENV_NAMESPACE" +kubectl get pods --namespace "$APP_NAMESPACE" +kubectl rollout status deployment/frontend \ + --namespace "$APP_NAMESPACE" \ + --timeout=5m + +FRONTEND_POD="$( + kubectl get pods --namespace "$APP_NAMESPACE" --output json | + jq -r ' + .items | + map(select( + .status.phase == "Running" and + any(.spec.containers[]; .name == "frontend") + )) | + sort_by(.metadata.creationTimestamp) | + last | + .metadata.name // empty + ' +)" +test -n "$FRONTEND_POD" +kubectl logs "$FRONTEND_POD" \ + --namespace "$APP_NAMESPACE" \ + --container frontend | + grep -E "OIDC auth[[:space:]]*:[[:space:]]*enabled" >/dev/null +``` + +**PowerShell 7:** + +```powershell +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps + +$Environment = rad env show env-local-prod --output json | ConvertFrom-Json +$EnvironmentNamespace = $Environment.properties.compute.namespace +$AppNamespace = "$EnvironmentNamespace-adaptive-apps" +kubectl get namespace $AppNamespace --no-headers *> $null +if ($LASTEXITCODE -ne 0) { + $AppNamespace = $EnvironmentNamespace +} +kubectl get pods --namespace $AppNamespace +kubectl rollout status deployment/frontend ` + --namespace $AppNamespace ` + --timeout=5m + +$Pods = kubectl get pods --namespace $AppNamespace --output json | + ConvertFrom-Json +$FrontendPod = @( + $Pods.items | + Where-Object { + $_.status.phase -eq "Running" -and + "frontend" -in $_.spec.containers.name + } | + Sort-Object { [datetime]$_.metadata.creationTimestamp } -Descending +)[0].metadata.name +if ([string]::IsNullOrWhiteSpace($FrontendPod)) { + throw "No frontend pod found." +} +$FrontendLogs = kubectl logs $FrontendPod ` + --namespace $AppNamespace ` + --container frontend +if (-not ($FrontendLogs -match "OIDC auth\s*:\s*enabled")) { + throw "The frontend did not report OIDC as enabled." +} +``` + +### 7. Validate local-user sign-in + +Use two foreground terminals. Keep the Bastion tunnel process managed by +`prepare-k3s-azure-vm.sh`; these commands add only Kubernetes/Radius port-forwards. + +**Terminal A - Bash:** + +```bash +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +kubectl port-forward --namespace core service/core-keycloak 8080:8080 +``` + +**Terminal A - PowerShell 7:** + +```powershell +$env:KUBECONFIG = "$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +kubectl port-forward --namespace core service/core-keycloak 8080:8080 +``` + +**Terminal B - Bash:** + +```bash +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod +rad resource expose Applications.Core/containers frontend \ + --application adaptive-apps \ + --port 3000 \ + --remote-port 3000 +``` + +**Terminal B - PowerShell 7:** + +```powershell +$env:KUBECONFIG = "$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod +rad resource expose Applications.Core/containers frontend ` + --application adaptive-apps ` + --port 3000 ` + --remote-port 3000 +``` + +Open , choose the OIDC sign-in path, and authenticate as +`local-trader`. The browser visits Keycloak on ; the frontend +exchanges the authorization code through `core-keycloak.core.svc.cluster.local`. + +Successful evidence is the authenticated frontend identity, not a screenshot containing +cookies, tokens, passwords, or client secrets. + +Press Ctrl+C in both terminals before continuing. + +## Stage 3: Configure Entra federation on AKS + +### 1. Stop K3s forwarding and verify the AKS target + +**Bash:** + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +test "$(kubectl config current-context)" = "aks-adaptive-apps" +test "$(rad workspace show --output json | jq -r '.connection.context')" = "aks-adaptive-apps" +rad env show env-azure-prod +kubectl rollout status deployment/core-keycloak --namespace core --timeout=10m +``` + +**PowerShell 7:** + +```powershell +Remove-Item Env:KUBECONFIG -ErrorAction SilentlyContinue +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +if ((kubectl config current-context) -ne "aks-adaptive-apps") { + throw "Kubernetes target drift: expected aks-adaptive-apps." +} +$Workspace = rad workspace show --output json | ConvertFrom-Json +if ($Workspace.connection.context -ne "aks-adaptive-apps") { + throw "Radius target drift: ws-azure-prod must target aks-adaptive-apps." +} +rad env show env-azure-prod +kubectl rollout status deployment/core-keycloak --namespace core --timeout=10m +``` + +### 2. Create the same OIDC client on AKS + +Repeat **Stage 2, step 2** now that the active context is `aks-adaptive-apps`. Do not +reuse the K3s `KEYCLOAK_POD`, `$KeycloakPod`, `CLIENT_UUID`, `$ClientUuid`, or client +secret. The commands reconcile an `adaptive-apps` client with the same settings in the +independent AKS Keycloak database and capture a new secret in: + +- Bash: `OIDC_CLIENT_SECRET` +- PowerShell: `$OidcClientSecret` + +Before proceeding, verify non-secret settings only: + +**Bash:** + +```bash +kubectl exec --namespace core "$KEYCLOAK_POD" -- \ + /opt/keycloak/bin/kcadm.sh get "clients/$CLIENT_UUID" \ + --config /tmp/kcadm.config \ + --realm master | + jq '{ + clientId, + enabled, + publicClient, + standardFlowEnabled, + redirectUris, + webOrigins + }' +``` + +**PowerShell 7:** + +```powershell +$Client = kubectl exec --namespace core $KeycloakPod -- ` + /opt/keycloak/bin/kcadm.sh get "clients/$ClientUuid" ` + --config /tmp/kcadm.config ` + --realm master | ConvertFrom-Json +$Client | Select-Object ` + clientId, enabled, publicClient, standardFlowEnabled, redirectUris, webOrigins +``` + +Expected: `adaptive-apps`, enabled, confidential (`publicClient: false`), standard flow +enabled, redirect URI `http://localhost:3000/*`, and web origin +`http://localhost:3000`. + +### 3. Rotate the AKS broker administrator + +Repeat **Stage 2, step 4** while the AKS context is active. Use a different approved +password from the K3s broker. The block restarts Keycloak and deliberately clears the +now-stale pod variable; no later step depends on the old admin CLI session. + +### 4. Configure Entra as an upstream SAML provider + +Start the AKS Keycloak forward in a foreground terminal: + +**Bash or PowerShell 7:** + +```text +kubectl port-forward --namespace core service/core-keycloak 8080:8080 +``` + +Open the [Keycloak admin console](http://localhost:8080/admin/master/console/) and sign +in with the administrator username and the password chosen in the previous step. + +In the [Microsoft Entra admin center](https://entra.microsoft.com): + +1. Go to **Entra ID** > **Enterprise applications** > **New application** > + **Create your own application**. +2. Name it `Adaptive Apps Keycloak workshop` and choose the non-gallery option that + integrates another application. +3. Under **Single sign-on**, select **SAML**. +4. Choose a short team identifier that is unique within the shared Entra tenant, such + as `team-07`. Configure **Basic SAML Configuration**, replacing ``: + + | Setting | Workshop value | + | --- | --- | + | Identifier (Entity ID) | `urn:adaptive-apps:keycloak:` | + | Reply URL (ACS) | `http://localhost:8080/realms/master/broker/entra/endpoint` | + +5. Keep the minimum default claims required for sign-in. Ensure the Name ID identifies + the assigned user and that email, given name, and surname claims are available when + your tenant policy permits them. +6. Under **Users and groups**, assign only the workshop test user or a narrowly scoped + workshop group. Do not disable assignment merely to make the demo pass. +7. Download **Federation Metadata XML** from **SAML Certificates**. Treat certificates + and metadata as configuration; never download browser session data or tokens. + +In the Keycloak admin console: + +1. Select realm **master**. +2. Open **Identity providers** and choose **SAML v2.0**. +3. Set alias `entra` and display name `Microsoft Entra ID`. +4. Import the downloaded Entra Federation Metadata XML. +5. Confirm the provider is enabled, the single sign-on service URL targets the intended + tenant, signature validation is enabled, and sync mode is `FORCE` for this workshop + so mapping changes are visible at the next login. +6. Set **Service provider entity ID** to the same + `urn:adaptive-apps:keycloak:` value registered in Entra. +7. Save, open **Mappers**, and add these **Attribute Importer** mappings with + **Name Format** `ATTRIBUTE_FORMAT_BASIC` and sync mode `INHERIT`: + + | Name | SAML attribute name | Keycloak user attribute | + | --- | --- | --- | + | `email` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | `email` | + | `firstName` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` | `firstName` | + | `lastName` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` | `lastName` | + +The Keycloak login page should now show **Microsoft Entra ID**. The frontend still uses +OIDC only; Keycloak performs the SAML protocol bridge. + +> [!IMPORTANT] +> The localhost entity ID and reply URL are for this foreground, port-forwarded lab. +> Production requires a stable HTTPS Keycloak hostname, a matching SAML entity ID and +> reply URL, managed certificates, restricted origins/redirects, durable secret +> rotation, and an availability plan. + +### 5. Deploy the same model to the Azure environment + +Use the AKS client secret captured in Stage 3, step 2. The endpoint values match K3s +because both portfolios use the same service and realm names; the secret and upstream +provider differ. + +**Bash:** + +```bash +OIDC_ISSUER="http://core-keycloak.core.svc.cluster.local:8080/realms/master" +OIDC_AUTH_ENDPOINT="$OIDC_ISSUER/protocol/openid-connect/auth" +OIDC_TOKEN_ENDPOINT="$OIDC_ISSUER/protocol/openid-connect/token" +OIDC_USERINFO_ENDPOINT="$OIDC_ISSUER/protocol/openid-connect/userinfo" +OIDC_BROWSER_AUTH_ENDPOINT="http://localhost:8080/realms/master/protocol/openid-connect/auth" + +read -rsp "Frontend break-glass password: " AUTH_PASSWORD +echo +rad deploy iac/app.bicep \ + --workspace ws-azure-prod \ + --group rg-trading \ + --environment env-azure-prod \ + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps \ + --parameters imageTag=latest \ + --parameters authUsername=admin \ + --parameters "authPassword=$AUTH_PASSWORD" \ + --parameters oidcClientId=adaptive-apps \ + --parameters "oidcClientSecret=$OIDC_CLIENT_SECRET" \ + --parameters "oidcIssuer=$OIDC_ISSUER" \ + --parameters "oidcAuthEndpoint=$OIDC_AUTH_ENDPOINT" \ + --parameters "oidcBrowserAuthEndpoint=$OIDC_BROWSER_AUTH_ENDPOINT" \ + --parameters "oidcTokenEndpoint=$OIDC_TOKEN_ENDPOINT" \ + --parameters "oidcUserInfoEndpoint=$OIDC_USERINFO_ENDPOINT" \ + --parameters appBaseUrl=http://localhost:3000 +unset AUTH_PASSWORD OIDC_CLIENT_SECRET +``` + +**PowerShell 7:** + +```powershell +$OidcIssuer = "http://core-keycloak.core.svc.cluster.local:8080/realms/master" +$OidcAuthEndpoint = "$OidcIssuer/protocol/openid-connect/auth" +$OidcTokenEndpoint = "$OidcIssuer/protocol/openid-connect/token" +$OidcUserInfoEndpoint = "$OidcIssuer/protocol/openid-connect/userinfo" +$OidcBrowserAuthEndpoint = "http://localhost:8080/realms/master/protocol/openid-connect/auth" + +$SecurePassword = Read-Host "Frontend break-glass password" -AsSecureString +$Credential = [System.Management.Automation.PSCredential]::new("admin", $SecurePassword) +$PlainPassword = $Credential.GetNetworkCredential().Password +try { + rad deploy iac/app.bicep ` + --workspace ws-azure-prod ` + --group rg-trading ` + --environment env-azure-prod ` + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps ` + --parameters imageTag=latest ` + --parameters authUsername=admin ` + --parameters "authPassword=$PlainPassword" ` + --parameters oidcClientId=adaptive-apps ` + --parameters "oidcClientSecret=$OidcClientSecret" ` + --parameters "oidcIssuer=$OidcIssuer" ` + --parameters "oidcAuthEndpoint=$OidcAuthEndpoint" ` + --parameters "oidcBrowserAuthEndpoint=$OidcBrowserAuthEndpoint" ` + --parameters "oidcTokenEndpoint=$OidcTokenEndpoint" ` + --parameters "oidcUserInfoEndpoint=$OidcUserInfoEndpoint" ` + --parameters appBaseUrl=http://localhost:3000 +} +finally { + $PlainPassword = $null + $Credential = $null + $SecurePassword = $null + $OidcClientSecret = $null +} +``` + +### 6. Validate Entra-backed sign-in + +Keep the Keycloak forward running on port 8080. In a second foreground terminal: + +**Bash:** + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps +rad resource expose Applications.Core/containers frontend \ + --application adaptive-apps \ + --port 3000 \ + --remote-port 3000 +``` + +**PowerShell 7:** + +```powershell +Remove-Item Env:KUBECONFIG -ErrorAction SilentlyContinue +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps +rad resource expose Applications.Core/containers frontend ` + --application adaptive-apps ` + --port 3000 ` + --remote-port 3000 +``` + +Open a private browser window at , choose OIDC sign-in, select +**Microsoft Entra ID** at Keycloak, and authenticate as an assigned Entra user. A private +window prevents the earlier K3s Keycloak cookie from masquerading as AKS evidence. + +The expected sequence is: + +```text +frontend -> localhost Keycloak authorization endpoint + -> Entra SAML sign-in + -> localhost Keycloak broker endpoint + -> frontend callback on localhost:3000 +``` + +Stop both foreground processes when validation completes. + +## Stage 4: Compare and debrief + +| Area | K3s / `ws-local-prod` | AKS / `ws-azure-prod` | +| --- | --- | --- | +| App model | `iac/app.bicep` | `iac/app.bicep` | +| App-facing protocol | OIDC | OIDC | +| Client ID | `adaptive-apps` | `adaptive-apps` | +| Redirect URI | `http://localhost:3000/*` | `http://localhost:3000/*` | +| Broker | `core-keycloak` | `core-keycloak` | +| Client secret | K3s-specific | AKS-specific | +| User authority | Local Keycloak user | Microsoft Entra ID | +| Upstream protocol | Keycloak local authentication | SAML | +| Browser access | Local 8080 and 3000 forwards over Bastion-backed API access | Local 8080 and 3000 forwards to AKS | +| Radius state | Independent | Independent | + +The application team owns the OIDC client contract and callback behavior. The platform +team owns Keycloak availability, upstream federation, certificates, client-secret +rotation, user/group mapping, and production ingress. The identity team owns Entra app +policy, assignments, claims, conditional access, and lifecycle. + +### What did not change + +- Application Bicep resource graph +- Frontend image +- OIDC client ID and authorization-code flow +- Redirect URI and local browser ports +- Radius resource contracts + +### What changed by environment + +- Active Kubernetes context and Radius workspace/environment +- Keycloak database and client secret +- User authority and upstream protocol +- Broker-side provider and user configuration + +## Troubleshooting + +### K3s reports connection refused on `127.0.0.1:16443` + +The devcontainer restarted and the Bastion process is no longer running: + +```bash +export 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 +``` + +In PowerShell, set `$env:AZURE_SUBSCRIPTION`, invoke the same script through `bash`, +and set `$env:KUBECONFIG`. + +### Local port 8080 or 3000 is already in use + +Stop the stale foreground `kubectl port-forward` or `rad resource expose` process with +Ctrl+C. Do not kill processes by name. Confirm the intended +Kubernetes context and Radius workspace before starting a replacement. + +### The frontend shows no OIDC sign-in option + +Check that `rad deploy` received a non-empty client ID and secret. Inspect only +environment-variable names with a JSONPath that cannot render their values. Review +frontend pod logs for the non-secret `OIDC disabled: missing config` diagnostic; never +dump all environment values. + +**Bash:** + +```bash +kubectl get deployment frontend \ + --namespace "$APP_NAMESPACE" \ + --output jsonpath='{.spec.template.spec.containers[?(@.name=="frontend")].env[*].name}' +echo +``` + +**PowerShell 7:** + +```powershell +kubectl get deployment frontend ` + --namespace $AppNamespace ` + --output jsonpath='{.spec.template.spec.containers[?(@.name=="frontend")].env[*].name}' +Write-Output "" +``` + +### Keycloak reports an invalid redirect URI + +The client must include `http://localhost:3000/*`, while `appBaseUrl` must be +`http://localhost:3000`. Keep the callback generated by the frontend at +`/auth/oidc/callback`. + +### The browser cannot reach the authorization endpoint + +The browser endpoint must use `http://localhost:8080`. Cluster-local +`core-keycloak.core.svc.cluster.local` addresses are only for frontend-to-Keycloak +token and user-info calls. + +### Login succeeds at Keycloak but the callback fails + +Check: + +1. Keycloak client secret matches the active control plane. +2. Internal issuer, token, and user-info endpoints use the `core-keycloak` service. +3. Frontend and Keycloak forwards belong to the same cluster stage. +4. The frontend callback is on the current port-3000 exposure. + +Do not copy the other cluster's client secret as a shortcut. + +### Entra authenticates the user but denies application access + +Check the enterprise application's **Users and groups** assignments and +**Assignment required** setting. Assign the intended test identity; do not disable +assignment globally just to pass the workshop. + +### Keycloak does not show the Entra option + +Verify the SAML provider is enabled, has alias `entra`, and imported metadata from the +intended tenant. Check that the Keycloak forward is still attached to +`aks-adaptive-apps`, not K3s. + +### Entra reports a reply URL mismatch + +For this lab the reply URL is exactly: + +```text +http://localhost:8080/realms/master/broker/entra/endpoint +``` + +The alias is part of the URL. A production HTTPS hostname requires corresponding changes +in Entra, Keycloak, the OIDC browser endpoint, redirect origins, and application base +URL. + +### Entra reports that the identifier is already in use + +Another workshop application in the tenant already owns the SAML entity ID. Choose a +different ``, then update both the Entra **Identifier (Entity ID)** and +Keycloak **Service provider entity ID** to the same unique +`urn:adaptive-apps:keycloak:` value. + +### The callback fails intermittently with an invalid authorization code + +The portfolio runs multiple Keycloak replicas. Check both Keycloak pod logs for a +healthy Infinispan/JGroups cluster view. The browser forward can stay pinned to one pod +while the frontend token request reaches another through `core-keycloak`; a broken +Keycloak cache cluster makes authorization codes appear invalid on alternate requests. +Repair Keycloak clustering rather than weakening OIDC validation. + +### A later portfolio upgrade breaks admin CLI authentication + +`core-keycloak-admin` is managed by Helm. Re-running the Challenge 03 +`helm upgrade --install core` command with the original chart values can restore the +old Kubernetes Secret while the Keycloak database retains the rotated password. Repeat +the target-specific administrator rotation with approved values after a portfolio +upgrade; do not print or recover the old password from command output. + +### Do not use token output as evidence + +Tokens, authorization codes, cookies, client secrets, and passwords are credentials. +Validate user identity in the frontend and use non-secret configuration metadata. Do +not paste tokens into JWT inspection sites or workshop notes. + +## Optional enterprise local-directory extension + +If a local AD DS deployment already exists, Keycloak can replace the local workshop +user source with LDAP federation: + +1. Use `ldaps://:636`; do not use cleartext LDAP. +2. Mount the issuing CA into Keycloak's trust store. +3. Use a dedicated read-only bind account stored in an approved secret store. +4. Scope the users DN and group search to the workshop organizational units. +5. Use read-only edit mode and test connection, authentication, and user sync. +6. Validate an AD user through the full frontend OIDC flow. + +Do not claim this extension is complete when only TCP connectivity or user import works. +Certificate validation, credential protection, group mapping, account lifecycle, and an +end-to-end sign-in are part of the identity boundary. + +## Cleanup + +Stop foreground port-forwards with Ctrl+C. Remove only +workshop-scoped Entra assignments or the non-gallery enterprise application when it is +no longer needed, following your organization's change process. Do not delete shared +Keycloak, Radius, AKS, K3s, or Bastion resources as part of this challenge. + +For the K3s Bastion tunnel itself: + +```bash +bash resources/prepare-k3s-azure-vm.sh status +bash resources/prepare-k3s-azure-vm.sh disconnect +``` + +Disconnecting the local tunnel does not delete Azure Bastion, which continues to incur +cost until the workshop resource group or Bastion resource is removed through the +narrow cleanup process in [Prepare K3s on a private Azure VM](../../docs/prepare-k3s.md). From 2cbd7775da1fb1a5991e14ccd02eeaf3e6a85d67 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 19 Aug 2026 17:58:16 +0200 Subject: [PATCH 08/29] Add adaptive AI challenge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 5 + .../04-adaptive-apps/Readme.md | 2 +- .../challenges/challenge-08.md | 255 +++- .../04-adaptive-apps/iac/ai.bicep | 95 ++ .../iac/recipes/ai-model-azure-existing.bicep | 25 + .../iac/recipes/ai-model-local-cpu.bicep | 196 +++ .../resources/local-recipe-registry.yaml | 90 ++ .../walkthrough/challenge-08/solution-08.md | 1217 +++++++++++++++++ 8 files changed, 1879 insertions(+), 6 deletions(-) create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/ai.bicep create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/ai-model-azure-existing.bicep create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/ai-model-local-cpu.bicep create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/resources/local-recipe-registry.yaml create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-08/solution-08.md diff --git a/.gitattributes b/.gitattributes index 308794919..1cbd9602d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,8 +4,13 @@ "/03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-08/solution-08.md" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/ai.bicep" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/ai-model-*.bicep" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/local-recipe-registry.yaml" text eol=lf "/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json" text eol=lf 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 465fe798b..a54c5c938 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 @@ -126,7 +126,7 @@ communication, and AI capabilities. - [Challenge 05 - Port the App Across Environments](challenges/challenge-05.md) - [Challenge 06 - Adapt Identity Services - Configure User Authentication](challenges/challenge-06.md) - [Challenge 07 - Secure service communication](challenges/challenge-07.md) -- [Challenge 08 - Adapt AI services](challenges/challenge-08.md) +- [Challenge 08 - Adapt AI Services](challenges/challenge-08.md) ### Bucket 4: Advanced challenges diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md index 51cce5306..7ac643a13 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md @@ -1,7 +1,252 @@ -# Challenge 08 - Adapt AI services +# Challenge 08 - Adapt AI Services -> [!NOTE] -> This file is an authoring scaffold. Detailed AI service instructions will be -> added in a follow-up contribution. +[< Previous Challenge](challenge-07.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-09.md) -[Back to the Adaptive Apps MicroHack](../Readme.md) +Estimated time: 60-90 minutes | Difficulty: advanced + +## Challenge objective + +Add an AI-advice capability to the trading application without coupling its application +model to one model host. Declare one `Radius.Resources/aiModels` contract and deploy the +same additive AI model to both targets: + +- `ws-local-prod` uses a small CPU-hosted model on the private K3s cluster. +- `ws-azure-prod` references an approved Azure OpenAI deployment from AKS. + +Only the environment recipe and identity parameters may vary. The agent, application +connection, capability contract, and request path remain invariant. + +## Scenario + +Traders want the application to summarize market context, but platform constraints +differ. The private K3s site has four CPU cores and no GPU. The connected AKS platform +can use an existing Azure OpenAI deployment, subject to region, quota, and governance +approval. The application team needs one contract rather than provider-specific code and +API keys. + +The platform team will provide two implementations of the same AI capability. The K3s +recipe uses a compact Ollama model that is practical for the workshop VM. The AKS recipe +returns an Azure OpenAI endpoint and deployment name, while the pod authenticates with +Microsoft Entra workload identity. + +## Learning goals + +- Separate AI application intent from model hosting and provider configuration. +- Publish and register target-specific Radius recipes without changing the application + contract. +- Distinguish an Azure OpenAI deployment name from its underlying model name and version. +- Reuse AKS workload identity for keyless model access. +- Compare performance, cost, availability, and operational ownership across AI + implementations. +- Validate graph, resource, workload, and inference behavior without overstating model + quality or local-disconnected capability. + +## Fixed target map + +| Platform | Kubernetes context | Radius workspace | Environment | Radius group | +| --- | --- | --- | --- | --- | +| Private K3s through Bastion | `k3s-azure-vm` | `ws-local-prod` | `env-local-prod` | `rg-trading` | +| AKS | `aks-adaptive-apps` | `ws-azure-prod` | `env-azure-prod` | `rg-trading` | + +These are independent Radius control planes. Each needs its own recipe registry, recipe +registration, `trading-ai` resource, and `ai-agent` workload. + +## Additional prerequisites + +- Challenges 00-06 completed and the Challenge 05/06 application present on both + targets. +- The `Radius.Resources/aiModels@2025-08-01-preview` contract from Challenge 03. +- Permission to create workloads and service accounts in both application namespaces. +- Outbound HTTPS from K3s to pull the Ollama image and `qwen2.5:0.5b` model. +- For the AKS path, an existing approved Azure OpenAI account and deployment in a + supported region with available quota. This challenge does not create either resource. +- For the AKS path, permission to create a user-assigned managed identity and federated + credential, and to assign **Cognitive Services OpenAI User** at the Azure OpenAI + account scope. + +## Portability boundary + +The application addition is `iac/ai.bicep`. It declares: + +- An existing Radius application named `adaptive-apps`. +- A portable AI resource named `trading-ai`. +- An `ai-agent` container connected to that resource. +- Optional Kubernetes service-account and workload-identity metadata. + +The file does not declare Ollama, Kaito, an Azure OpenAI account, a deployment SKU, an +API key, or a GPU. Those are platform concerns. + +## Constraints + +- Deploy the same `iac/ai.bicep` file to both targets. +- Keep AI disabled in `iac/app.bicep`; Challenge 08 is an additive deployment and must + not erase the Challenge 06 OIDC configuration. +- Explicitly select and verify Kubernetes context, Radius workspace, group, and + environment before every target stage. +- After a restart, reconnect K3s with + `bash resources/prepare-k3s-azure-vm.sh connect` and use + `~/.kube/adaptive-apps-k3s.yaml`. +- Do not expose the K3s VM or its Kubernetes API publicly. The API remains behind the + localhost Azure Bastion tunnel. +- Do not require Kaito or a GPU on the default `Standard_D4s_v5` VM. +- Do not put Azure OpenAI keys, access tokens, credentials, or model responses containing + sensitive data in source or evidence. The AKS core path must be keyless. +- Assign **Cognitive Services OpenAI User** only at the selected Azure OpenAI account + scope. Do not grant `Contributor`, subscription-wide access, or a service-principal + client secret. +- Treat model output as untrusted, non-deterministic workshop output, not financial + advice. +- Keep Challenge 07 service-mesh and authorization-policy work out of this challenge. +- Use the workshop-scoped, ClusterIP-only OCI registry for recipe publication. It is + intentionally ephemeral and unauthenticated inside the cluster; do not expose it + externally or reuse that design for production. + +## Tasks + +### Task 1: Define the stable AI contract + +Review `iac/ai.bicep` and the `Radius.Resources/aiModels` contract. Identify: + +- The application-owned model intent and `ai-agent` connection. +- Values injected by the recipe. +- Identity metadata needed only on AKS. +- Hosting, availability, quota, and cost choices owned by each platform team. + +Draw both request paths and mark what remains invariant: + +1. Frontend -> AI agent -> local OpenAI-compatible endpoint -> CPU model. +2. Frontend -> AI agent -> Azure OpenAI endpoint -> approved deployment. + +### Task 2: Implement and prove the K3s recipe + +Reconnect the Bastion tunnel and select `k3s-azure-vm`, `ws-local-prod`, `rg-trading`, +and `env-local-prod`. + +On K3s: + +- Deploy the local ClusterIP recipe registry. +- Publish `iac/recipes/ai-model-local-cpu.bicep` and register it as the default + `Radius.Resources/aiModels` recipe. +- Deploy `iac/ai.bicep` with `qwen2.5:0.5b`. +- Observe model download and startup without assuming GPU acceleration. +- Inspect the Radius graph and backing Kubernetes resources. +- Expose the AI agent through the active Radius control plane and call `/health` and + `/advice`. + +Record first-start latency, CPU/memory limits, model persistence behavior, and why this +Azure-hosted K3s VM is neither AKS nor a disconnected edge device. + +### Task 3: Implement keyless Azure OpenAI access on AKS + +Stop K3s exposure and registry forwarding before changing targets. Select +`aks-adaptive-apps`, `ws-azure-prod`, `rg-trading`, and `env-azure-prod`. + +Before deployment: + +- Verify the Azure OpenAI account, endpoint, deployment name, underlying model/version, + SKU, and capacity. +- Confirm AKS OIDC issuer and workload identity are enabled. +- Deploy the target's local recipe registry. +- Publish `iac/recipes/ai-model-azure-existing.bicep` and register it with the approved + endpoint and deployment name. +- Create or reuse a user-assigned managed identity. +- Create one federated credential for the exact `ai-agent` service-account subject in + the actual application namespace. +- Assign **Cognitive Services OpenAI User** at the account scope. +- Create and annotate the `ai-agent` Kubernetes service account. + +Deploy the same `iac/ai.bicep` with workload identity enabled. Verify the pod label, +service account, projected token, federated credential, and narrow role assignment. +Then validate `/health` and `/advice` without printing a token or key. + +### Task 4: Compare evidence, failure handling, and ownership + +Capture non-secret evidence from each target: + +- Four-part target selection. +- The same Radius application graph and AI contract. +- Different recipe registrations and backing resources. +- Provider, endpoint host, and model/deployment identifier from `/health`. +- Successful `/advice` response and a deliberate bad-request response. +- K3s pod resources and model-ready probe. +- AKS workload-identity label, service account, federated subject, and role scope. + +Create a comparison covering: + +- Model quality and latency. +- Resource persistence and restart behavior. +- Network dependency and disconnected-operation claims. +- Azure quota, region, throughput, and per-token cost. +- Application-team and platform-team ownership. +- How a recipe rollback differs from an application rollback. + +## Success criteria + +- One `Radius.Resources/aiModels` contract and one `iac/ai.bicep` model are used on both + platforms. +- K3s runs `qwen2.5:0.5b` on CPU within the default workshop VM limits; no Kaito or GPU + success is claimed. +- AKS calls an existing Azure OpenAI deployment through an exact service-account + federation and **Cognitive Services OpenAI User** at account scope. +- No API key, service-principal secret, access token, or credential appears in source, + parameters, screenshots, or evidence. +- `rad app graph`, `rad resource list`, Kubernetes health, `/health`, and `/advice` + demonstrate both implementations. +- The application state, recipe registry, and model implementation are recognized as + independent on the two Radius control planes. +- The team can explain endpoint versus deployment-name versus model-name/version. +- Cost, quota, model quality, model-download persistence, and network limitations are + documented honestly. + +## Progressive hints + +### Contract and recipes + +1. The connection injects provider, endpoint, and model values into `ai-agent`. +2. A `RecipeNotFoundFailure` means the active environment lacks a matching recipe; do + not add Ollama or Azure declarations to `iac/ai.bicep`. +3. Recipe OCI artifacts must be published into the registry on the same cluster as the + selected Radius control plane. + +### K3s + +1. The default VM has no GPU; a small quantized model can still prove the contract on + CPU. +2. The first startup includes an outbound model download and can take several minutes. +3. The workshop recipe uses ephemeral model storage, so pod replacement triggers another + download. + +### AKS identity + +1. The federated subject has the form + `system:serviceaccount::ai-agent`. +2. The service account needs the managed identity client ID; the pod needs + `azure.workload.identity/use=true`. +3. A correct federation can still return `403` until the role assignment propagates. +4. A deployment name is the string sent to the Azure OpenAI API; it need not equal the + underlying model name. + +### Runtime validation + +1. `/health` proves configuration and process health, not successful inference. +2. `/advice` proves the model call; expect different text and latency across providers. +3. A `502` usually points to endpoint, identity, quota, model readiness, or network + failure. Inspect those layers before changing the app contract. + +## Learning resources + +- [Radius recipes](https://docs.radapp.io/guides/recipes/overview/) +- [Radius connections](https://docs.radapp.io/guides/author-apps/containers/overview/#connections) +- [AKS workload identity](https://learn.microsoft.com/azure/aks/workload-identity-overview) +- [Azure OpenAI role-based access control](https://learn.microsoft.com/azure/ai-services/openai/how-to/role-based-access-control) +- [Azure OpenAI deployment types](https://learn.microsoft.com/azure/ai-services/openai/how-to/deployment-types) +- [Azure OpenAI quotas and limits](https://learn.microsoft.com/azure/ai-services/openai/quotas-limits) +- [Ollama model library](https://ollama.com/library/qwen2.5) +- [Kubernetes resource management](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/) + +## Optional GPU extension + +Kaito is an optional extension only when a target has a supported GPU node, drivers, +device plugin, storage, and sufficient capacity. Record those prerequisites and costs +before selecting the source Kaito recipe. Do not resize the default VM or deploy a GPU +merely to complete the core challenge. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/ai.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/ai.bicep new file mode 100644 index 000000000..73854332f --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/ai.bicep @@ -0,0 +1,95 @@ +// Adds the portable AI capability and agent to the existing Adaptive Apps model. + +extension radius +extension radiusResources + +@description('The Radius Environment ID. The rad CLI injects this value.') +param environment string + +@description('Published Adaptive Apps container image registry.') +param imageRegistry string = 'ghcr.io/microsoft/adaptive-apps' + +@description('Container image tag for the AI agent.') +param imageTag string = 'latest' + +@description('Model or Azure deployment name requested through the aiModels contract.') +param aiModel string + +@description('Kubernetes service account used by the AI agent.') +param aiAgentServiceAccountName string = 'default' + +@description('Enable AKS workload-identity metadata for the AI agent.') +param enableAzureWorkloadIdentity bool = false + +@description('User-assigned managed identity client ID for Azure OpenAI access.') +param aiAgentClientId string = '' + +@description('Microsoft Entra tenant ID used by the workload identity.') +param workloadIdentityTenantId string = '' + +var aiAgentExtensions = enableAzureWorkloadIdentity ? [ + { + kind: 'kubernetesMetadata' + labels: { + 'azure.workload.identity/use': 'true' + } + } +] : [] + +resource tradingApp 'Applications.Core/applications@2023-10-01-preview' = { + name: 'adaptive-apps' + properties: { + environment: environment + } +} + +resource tradingAI 'Radius.Resources/aiModels@2025-08-01-preview' = { + name: 'trading-ai' + properties: { + environment: environment + application: tradingApp.id + model: aiModel + } +} + +resource aiAgent 'Applications.Core/containers@2023-10-01-preview' = { + name: 'ai-agent' + properties: { + application: tradingApp.id + container: { + image: '${imageRegistry}/ai-agent:${imageTag}' + ports: { + http: { + containerPort: 7000 + } + } + env: { + ASPNETCORE_URLS: { + value: 'http://+:7000' + } + CONNECTION_AI_SECRETS_APIKEY: { + value: tradingAI.properties.secrets.apiKey + } + AZURE_CLIENT_ID: { + value: aiAgentClientId + } + AZURE_TENANT_ID: { + value: workloadIdentityTenantId + } + } + } + extensions: aiAgentExtensions + connections: { + ai: { + source: tradingAI.id + } + } + runtimes: { + kubernetes: { + pod: { + serviceAccountName: aiAgentServiceAccountName + } + } + } + } +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/ai-model-azure-existing.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/ai-model-azure-existing.bicep new file mode 100644 index 000000000..3ee4ff9f4 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/ai-model-azure-existing.bicep @@ -0,0 +1,25 @@ +// Reference recipe for a pre-provisioned Azure OpenAI deployment. + +@description('Information about the resource consuming this Recipe. Generated by Radius.') +param context object + +@description('Azure OpenAI account endpoint reachable from the target cluster.') +@minLength(1) +param endpoint string + +@description('Existing Azure OpenAI deployment name.') +@minLength(1) +param deploymentName string + +output result object = { + resources: [] + values: { + provider: 'azure' + endpoint: endpoint + model: deploymentName + secrets: { + #disable-next-line outputs-should-not-contain-secrets + apiKey: '' + } + } +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/ai-model-local-cpu.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/ai-model-local-cpu.bicep new file mode 100644 index 000000000..ae35d4545 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/ai-model-local-cpu.bicep @@ -0,0 +1,196 @@ +// CPU-only local AI recipe for the MicroHack's default K3s VM. + +@description('Information about the resource consuming this Recipe. Generated by Radius.') +param context object + +@description('Ollama model tag. Keep the default for the 4-vCPU, 16-GiB workshop VM.') +param model string = context.resource.properties.?model ?? 'qwen2.5:0.5b' + +@description('Pinned multi-architecture Ollama image.') +param ollamaImage string = 'ollama/ollama:0.11.10@sha256:a5409cb903d30f9cd67e9f430dd336ddc9274e16fd78f75b675c42065991b4fd' + +var workloadName = 'ollama-${uniqueString(context.resource.id)}' +var namespace = context.runtime.kubernetes.namespace + +extension kubernetes with { + kubeConfig: '' + namespace: namespace +} as kubernetes + +resource ollama 'apps/Deployment@v1' = { + metadata: { + name: workloadName + namespace: namespace + labels: { + app: workloadName + resource: context.resource.name + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } + spec: { + replicas: 1 + strategy: { + type: 'Recreate' + } + selector: { + matchLabels: { + app: workloadName + } + } + template: { + metadata: { + labels: { + app: workloadName + resource: context.resource.name + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } + spec: { + terminationGracePeriodSeconds: 30 + containers: [ + { + name: 'ollama' + image: ollamaImage + imagePullPolicy: 'IfNotPresent' + command: [ + '/bin/sh' + '-c' + ] + args: [ + '''set -eu +ollama serve & +server_pid=$! +until ollama list >/dev/null 2>&1; do + sleep 2 +done +ollama pull "$OLLAMA_MODEL" +wait "$server_pid" +''' + ] + env: [ + { + name: 'OLLAMA_HOST' + value: '0.0.0.0:11434' + } + { + name: 'OLLAMA_MODELS' + value: '/models' + } + { + name: 'OLLAMA_KEEP_ALIVE' + value: '-1' + } + { + name: 'OLLAMA_MODEL' + value: model + } + ] + ports: [ + { + name: 'http' + containerPort: 11434 + } + ] + resources: { + requests: { + cpu: '500m' + memory: '2Gi' + } + limits: { + cpu: '4' + memory: '8Gi' + } + } + startupProbe: { + exec: { + command: [ + 'ollama' + 'show' + model + ] + } + periodSeconds: 10 + failureThreshold: 60 + } + readinessProbe: { + httpGet: { + path: '/api/tags' + port: 11434 + } + periodSeconds: 10 + failureThreshold: 6 + } + livenessProbe: { + httpGet: { + path: '/api/tags' + port: 11434 + } + initialDelaySeconds: 30 + periodSeconds: 30 + failureThreshold: 3 + } + securityContext: { + allowPrivilegeEscalation: false + capabilities: { + drop: [ + 'ALL' + ] + } + } + volumeMounts: [ + { + name: 'models' + mountPath: '/models' + } + ] + } + ] + volumes: [ + { + name: 'models' + emptyDir: {} + } + ] + } + } + } +} + +resource ollamaService 'core/Service@v1' = { + metadata: { + name: workloadName + namespace: namespace + labels: { + app: workloadName + } + } + spec: { + type: 'ClusterIP' + selector: { + app: workloadName + } + ports: [ + { + name: 'http' + port: 11434 + targetPort: 11434 + } + ] + } +} + +output result object = { + resources: [ + '/planes/kubernetes/local/namespaces/${namespace}/providers/apps/Deployment/${ollama.metadata.name}' + '/planes/kubernetes/local/namespaces/${namespace}/providers/core/Service/${ollamaService.metadata.name}' + ] + values: { + provider: 'local' + endpoint: 'http://${ollamaService.metadata.name}.${namespace}.svc.cluster.local:11434/v1' + model: model + secrets: { + #disable-next-line outputs-should-not-contain-secrets + apiKey: '' + } + } +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/local-recipe-registry.yaml b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/local-recipe-registry.yaml new file mode 100644 index 000000000..56cb054f1 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/local-recipe-registry.yaml @@ -0,0 +1,90 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: adaptive-recipes + labels: + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: recipe-registry + namespace: adaptive-recipes + labels: + app.kubernetes.io/name: recipe-registry +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: recipe-registry + template: + metadata: + labels: + app.kubernetes.io/name: recipe-registry + spec: + securityContext: + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: registry + image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 + imagePullPolicy: IfNotPresent + env: + - name: REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY + value: /var/lib/registry + ports: + - name: registry + containerPort: 5000 + readinessProbe: + httpGet: + path: /v2/ + port: registry + initialDelaySeconds: 3 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /v2/ + port: registry + initialDelaySeconds: 10 + periodSeconds: 15 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + volumeMounts: + - name: registry-data + mountPath: /var/lib/registry + volumes: + - name: registry-data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: recipe-registry + namespace: adaptive-recipes + labels: + app.kubernetes.io/name: recipe-registry +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: recipe-registry + ports: + - name: registry + port: 5000 + targetPort: registry diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-08/solution-08.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-08/solution-08.md new file mode 100644 index 000000000..0d992f1fb --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-08/solution-08.md @@ -0,0 +1,1217 @@ +# Walkthrough Challenge 08 - Adapt AI Services + +[< Previous Challenge](../../challenges/challenge-07.md) - **[Home](../../Readme.md)** - [Next Challenge >](../../challenges/challenge-09.md) + +Duration: 60-90 minutes + +## Coach notes + +The source Student Challenge 08 contains only generic challenge boilerplate, and its +Coach Solution 08 is empty. This walkthrough reconstructs the intended **Adapt AI +Services** outcome from the Adaptive Apps AI resource contract, recipes, application, +and `ai-agent` runtime. + +The default workshop topology makes the source Kaito implementation unsuitable for the +core path: `Standard_D4s_v5` has no GPU, while the Kaito recipe schedules only to a node +with `nvidia.com/gpu=true`. The source Azure OpenAI recipe also returns an account key. +This adaptation therefore uses: + +| Target | Recipe implementation | Authentication | +| --- | --- | --- | +| Private K3s | CPU-only Ollama with `qwen2.5:0.5b` | In-cluster endpoint; no credential | +| AKS | Reference to an existing Azure OpenAI deployment | AKS workload identity | + +The K3s model is deliberately small enough to demonstrate the contract, not suitable for +financial advice or production quality evaluation. The AKS path does not create an Azure +OpenAI account or deployment, avoiding unexpected quota and cost. A participant must +select an existing, approved deployment. + +Challenge 07 owns service-mesh authorization and OPA policy. Do not add its governance +resources here. Challenge 08 focuses on model selection, recipes, workload identity, +provider substitution, and runtime evidence. + +## Fixed target map + +| Platform | Kubernetes context | Radius workspace | Environment | Radius group | +| --- | --- | --- | --- | --- | +| Private K3s through Bastion | `k3s-azure-vm` | `ws-local-prod` | `env-local-prod` | `rg-trading` | +| AKS | `aks-adaptive-apps` | `ws-azure-prod` | `env-azure-prod` | `rg-trading` | + +Each Radius control plane has independent application state and can only reach the +ClusterIP recipe registry in its own cluster. Publish and register the correct recipe +after selecting each target. + +## Stage 1: Understand and validate the stable contract + +`iac/ai.bicep` adds two resources to the existing `adaptive-apps` application: + +| Resource | Stable intent | +| --- | --- | +| `trading-ai` | A `Radius.Resources/aiModels` capability with a requested model | +| `ai-agent` | The same published agent image connected to `trading-ai` | + +The recipe supplies `provider`, `endpoint`, `model`, and `secrets.apiKey`. Radius +connections inject the first three values as: + +```text +CONNECTION_AI_PROVIDER +CONNECTION_AI_ENDPOINT +CONNECTION_AI_MODEL +``` + +`iac/ai.bicep` explicitly maps the optional API-key output to +`CONNECTION_AI_SECRETS_APIKEY`. Both core recipes return an empty value. The K3s provider +needs no credential; the Azure provider uses `DefaultAzureCredential` and the projected +AKS workload-identity token. + +The optional service-account and workload-identity parameters default to disabled, so +omitting those identity parameters remains valid on K3s. The portable `aiModel` +parameter is still required for every target. + +### Generate the custom Bicep extension + +The `Radius.Resources/aiModels` type is part of the Challenge 03 catalog. Recreate its +ignored local archive if it is not already present. + +**Bash:** + +```bash +mkdir -p artifacts +if [[ ! -f artifacts/types.tgz ]]; then + TYPES_FILE="$(mktemp)" + curl --fail --location \ + "https://raw.githubusercontent.com/microsoft/adaptive-apps/885627980684e5bcc6fe4bbd2848c1ec247b0a0b/radius/resource-types/types.yaml" \ + --output "$TYPES_FILE" + rad bicep publish-extension \ + --from-file "$TYPES_FILE" \ + --target artifacts/types.tgz \ + --force + rm -f "$TYPES_FILE" +fi +``` + +**PowerShell 7:** + +```powershell +New-Item -ItemType Directory -Path artifacts -Force | Out-Null +if (-not (Test-Path artifacts/types.tgz)) { + $TypesFile = Join-Path ([System.IO.Path]::GetTempPath()) "adaptive-apps-types.yaml" + Invoke-WebRequest ` + -Uri "https://raw.githubusercontent.com/microsoft/adaptive-apps/885627980684e5bcc6fe4bbd2848c1ec247b0a0b/radius/resource-types/types.yaml" ` + -OutFile $TypesFile + rad bicep publish-extension ` + --from-file $TypesFile ` + --target artifacts/types.tgz ` + --force + Remove-Item $TypesFile -Force +} +``` + +Compile without deploying: + +**Bash:** + +```bash +az bicep build --file iac/ai.bicep --stdout >/dev/null +az bicep build --file iac/recipes/ai-model-local-cpu.bicep --stdout >/dev/null +az bicep build --file iac/recipes/ai-model-azure-existing.bicep --stdout >/dev/null +``` + +**PowerShell 7:** + +```powershell +az bicep build --file iac/ai.bicep --stdout | Out-Null +az bicep build --file iac/recipes/ai-model-local-cpu.bicep --stdout | Out-Null +az bicep build --file iac/recipes/ai-model-azure-existing.bicep --stdout | Out-Null +``` + +`iac/bicepconfig.json` resolves the generated extension. Do not commit `types.tgz`. + +## Stage 2: Run the CPU model on private K3s + +### 1. Restore and verify the K3s target + +The dedicated kubeconfig points to a localhost API endpoint maintained by Azure Bastion. +Restore the tunnel after every devcontainer restart. + +**Bash:** + +```bash +export 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 +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod + +test "$(kubectl config current-context)" = "k3s-azure-vm" +test "$(rad workspace show --output json | jq -r '.connection.context')" = "k3s-azure-vm" +rad env show env-local-prod +``` + +**PowerShell 7:** + +```powershell +$env:AZURE_SUBSCRIPTION = "" +bash resources/prepare-k3s-azure-vm.sh connect +$env:KUBECONFIG = "$HOME/.kube/adaptive-apps-k3s.yaml" + +kubectl config use-context k3s-azure-vm +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod + +if ((kubectl config current-context) -ne "k3s-azure-vm") { + throw "Kubernetes target drift: expected k3s-azure-vm." +} +$Workspace = rad workspace show --output json | ConvertFrom-Json +if ($Workspace.connection.context -ne "k3s-azure-vm") { + throw "Radius target drift: ws-local-prod must target k3s-azure-vm." +} +rad env show env-local-prod +``` + +This tunnel exposes only the Kubernetes API on localhost. It does not make the private +VM, registry, model endpoint, or application publicly reachable. + +### 2. Deploy the workshop recipe registry + +Apply the pinned registry manifest and wait for it to become healthy: + +```bash +kubectl apply --filename resources/local-recipe-registry.yaml +kubectl rollout status \ + deployment/recipe-registry \ + --namespace adaptive-recipes \ + --timeout=5m +kubectl get service recipe-registry --namespace adaptive-recipes +``` + +The registry is unauthenticated but `ClusterIP`-only. Its storage is `emptyDir`, so a +registry-pod replacement requires the recipe to be published again. This is a focused +workshop transport, not a production registry pattern. + +In **Terminal A**, set the dedicated K3s kubeconfig again because environment variables +do not carry into a new terminal. Keep the port-forward in the foreground. + +**Bash:** + +```bash +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +kubectl port-forward \ + service/recipe-registry \ + --namespace adaptive-recipes \ + 5000:5000 +``` + +**PowerShell 7:** + +```powershell +$env:KUBECONFIG = "$HOME/.kube/adaptive-apps-k3s.yaml" +kubectl config use-context k3s-azure-vm +kubectl port-forward ` + service/recipe-registry ` + --namespace adaptive-recipes ` + 5000:5000 +``` + +In **Terminal B**, publish the K3s recipe through localhost and register the cluster-DNS +path that the active Radius control plane can resolve. + +**Bash:** + +```bash +curl --fail --silent http://127.0.0.1:5000/v2/ >/dev/null + +rad bicep publish \ + --file iac/recipes/ai-model-local-cpu.bicep \ + --target br:127.0.0.1:5000/adaptive-apps/ai-model-local-cpu:1.0.0 \ + --plain-http + +rad recipe register default \ + --workspace ws-local-prod \ + --group rg-trading \ + --environment env-local-prod \ + --resource-type Radius.Resources/aiModels \ + --template-kind bicep \ + --template-path recipe-registry.adaptive-recipes.svc.cluster.local:5000/adaptive-apps/ai-model-local-cpu:1.0.0 \ + --plain-http + +rad recipe list \ + --workspace ws-local-prod \ + --group rg-trading \ + --environment env-local-prod +``` + +**PowerShell 7:** + +```powershell +Invoke-WebRequest -Uri http://127.0.0.1:5000/v2/ | Out-Null + +rad bicep publish ` + --file iac/recipes/ai-model-local-cpu.bicep ` + --target br:127.0.0.1:5000/adaptive-apps/ai-model-local-cpu:1.0.0 ` + --plain-http + +rad recipe register default ` + --workspace ws-local-prod ` + --group rg-trading ` + --environment env-local-prod ` + --resource-type Radius.Resources/aiModels ` + --template-kind bicep ` + --template-path recipe-registry.adaptive-recipes.svc.cluster.local:5000/adaptive-apps/ai-model-local-cpu:1.0.0 ` + --plain-http + +rad recipe list ` + --workspace ws-local-prod ` + --group rg-trading ` + --environment env-local-prod +``` + +After registration succeeds, stop Terminal A with Ctrl+C>. Radius uses +the in-cluster service path during deployment; the participant-side forward is no longer +needed. + +### 3. Deploy the additive AI model + +Use the compact default model. `iac/ai.bicep` is the same file later deployed to AKS. + +**Bash:** + +```bash +rad deploy iac/ai.bicep \ + --workspace ws-local-prod \ + --group rg-trading \ + --environment env-local-prod \ + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps \ + --parameters imageTag=latest \ + --parameters aiModel=qwen2.5:0.5b +``` + +**PowerShell 7:** + +```powershell +rad deploy iac/ai.bicep ` + --workspace ws-local-prod ` + --group rg-trading ` + --environment env-local-prod ` + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps ` + --parameters imageTag=latest ` + --parameters aiModel=qwen2.5:0.5b +``` + +The model recipe creates an Ollama Deployment and Service. The first startup downloads +the model from the Ollama model registry and can take several minutes. Outbound network +access is therefore required even though inference runs in the K3s cluster afterward. + +### 4. Validate the graph, model, and agent + +Resolve the application namespace from the active environment, then find the model +Deployment by its recipe label. + +**Bash:** + +```bash +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps + +ENV_NAMESPACE="$(rad env show env-local-prod --output json | + jq -r '.properties.compute.namespace')" +APP_NAMESPACE="${ENV_NAMESPACE}-adaptive-apps" +kubectl get namespace "$APP_NAMESPACE" >/dev/null 2>&1 || + APP_NAMESPACE="$ENV_NAMESPACE" + +MODEL_DEPLOYMENT="$(kubectl get deployments \ + --namespace "$APP_NAMESPACE" \ + --selector resource=trading-ai \ + --output jsonpath='{.items[0].metadata.name}')" +test -n "$MODEL_DEPLOYMENT" + +kubectl rollout status \ + "deployment/$MODEL_DEPLOYMENT" \ + --namespace "$APP_NAMESPACE" \ + --timeout=15m +kubectl rollout status \ + deployment/ai-agent \ + --namespace "$APP_NAMESPACE" \ + --timeout=5m +kubectl get pods,services --namespace "$APP_NAMESPACE" +kubectl get deployment "$MODEL_DEPLOYMENT" \ + --namespace "$APP_NAMESPACE" \ + --output json | + jq '{requests: .spec.template.spec.containers[0].resources.requests, + limits: .spec.template.spec.containers[0].resources.limits, + startupProbe: .spec.template.spec.containers[0].startupProbe}' +``` + +**PowerShell 7:** + +```powershell +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps + +$Environment = rad env show env-local-prod --output json | ConvertFrom-Json +$EnvironmentNamespace = $Environment.properties.compute.namespace +$AppNamespace = "$EnvironmentNamespace-adaptive-apps" +kubectl get namespace $AppNamespace --no-headers *> $null +if ($LASTEXITCODE -ne 0) { + $AppNamespace = $EnvironmentNamespace +} + +$ModelDeployment = kubectl get deployments ` + --namespace $AppNamespace ` + --selector resource=trading-ai ` + --output jsonpath='{.items[0].metadata.name}' +if ([string]::IsNullOrWhiteSpace($ModelDeployment)) { + throw "The trading-ai recipe did not create a model Deployment." +} + +kubectl rollout status ` + "deployment/$ModelDeployment" ` + --namespace $AppNamespace ` + --timeout=15m +kubectl rollout status ` + deployment/ai-agent ` + --namespace $AppNamespace ` + --timeout=5m +kubectl get pods,services --namespace $AppNamespace + +$ModelSpec = kubectl get deployment $ModelDeployment ` + --namespace $AppNamespace ` + --output json | ConvertFrom-Json +$ModelContainer = $ModelSpec.spec.template.spec.containers[0] +$ModelContainer.resources +$ModelContainer.startupProbe +``` + +If startup times out, inspect the model container without printing any application +credentials: + +```bash +kubectl logs \ + "deployment/$MODEL_DEPLOYMENT" \ + --namespace "$APP_NAMESPACE" \ + --tail=100 +``` + +The recipe requests `500m` CPU and `2Gi` memory and limits the model to four CPUs and +`8Gi`. Its `emptyDir` model cache is lost when the pod is replaced. + +### 5. Validate inference + +Expose only the `ai-agent` through the active Radius control plane. Run the command in a +foreground terminal: + +**Bash:** + +```bash +export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +rad workspace switch ws-local-prod +rad resource expose Applications.Core/containers ai-agent \ + --application adaptive-apps \ + --port 7000 \ + --remote-port 7000 +``` + +**PowerShell 7:** + +```powershell +$env:KUBECONFIG = "$HOME/.kube/adaptive-apps-k3s.yaml" +rad workspace switch ws-local-prod +rad resource expose Applications.Core/containers ai-agent ` + --application adaptive-apps ` + --port 7000 ` + --remote-port 7000 +``` + +From another terminal, validate configuration, inference, and input handling. + +**Bash:** + +```bash +curl --fail --silent --show-error http://127.0.0.1:7000/health | jq + +curl --fail --silent --show-error \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"question":"List two questions to ask before evaluating a stock."}' \ + http://127.0.0.1:7000/advice | + jq + +curl --silent --show-error \ + --write-out '\nHTTP %{http_code}\n' \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"question":""}' \ + http://127.0.0.1:7000/advice +``` + +**PowerShell 7:** + +```powershell +Invoke-RestMethod -Uri http://127.0.0.1:7000/health + +$AdviceBody = @{ + question = "List two questions to ask before evaluating a stock." +} | ConvertTo-Json +Invoke-RestMethod ` + -Method Post ` + -Uri http://127.0.0.1:7000/advice ` + -ContentType "application/json" ` + -Body $AdviceBody +$AdviceBody = $null + +$BadRequest = Invoke-WebRequest ` + -Method Post ` + -Uri http://127.0.0.1:7000/advice ` + -ContentType "application/json" ` + -Body '{"question":""}' ` + -SkipHttpErrorCheck +if ($BadRequest.StatusCode -ne 400) { + throw "Expected the empty question to return HTTP 400." +} +$BadRequest.StatusCode +``` + +The exact answer is non-deterministic. Validate the response shape and status, not a +specific sentence. Do not use confidential data in prompts. Stop the foreground +exposure before continuing. + +## Stage 3: Use an existing Azure OpenAI deployment from AKS + +### 1. Select AKS and verify the approved model deployment + +Clear the K3s-only kubeconfig before selecting AKS. + +**Bash:** + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +test "$(kubectl config current-context)" = "aks-adaptive-apps" +test "$(rad workspace show --output json | jq -r '.connection.context')" = "aks-adaptive-apps" +rad env show env-azure-prod +``` + +**PowerShell 7:** + +```powershell +Remove-Item Env:KUBECONFIG -ErrorAction SilentlyContinue +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +if ((kubectl config current-context) -ne "aks-adaptive-apps") { + throw "Kubernetes target drift: expected aks-adaptive-apps." +} +$Workspace = rad workspace show --output json | ConvertFrom-Json +if ($Workspace.connection.context -ne "aks-adaptive-apps") { + throw "Radius target drift: ws-azure-prod must target aks-adaptive-apps." +} +rad env show env-azure-prod +``` + +Set the workshop and pre-existing Azure OpenAI values. The deployment name is a +participant-selected Azure resource child name, not necessarily the underlying model +name. + +**Bash:** + +```bash +export AZURE_SUBSCRIPTION="" +export RESOURCE_GROUP="rg-adaptive-apps" +export AKS_NAME="aks-adaptive-apps" +export OPENAI_RESOURCE_GROUP="" +export OPENAI_ACCOUNT="" +export OPENAI_DEPLOYMENT="" +export AI_IDENTITY_NAME="id-adaptive-apps-ai" + +az account set --subscription "$AZURE_SUBSCRIPTION" +test "$(az provider show \ + --namespace Microsoft.CognitiveServices \ + --query registrationState \ + --output tsv)" = "Registered" + +OPENAI_ACCOUNT_JSON="$(az cognitiveservices account show \ + --resource-group "$OPENAI_RESOURCE_GROUP" \ + --name "$OPENAI_ACCOUNT" \ + --output json)" +OPENAI_ID="$(jq -er '.id' <<<"$OPENAI_ACCOUNT_JSON")" +OPENAI_ENDPOINT="$(jq -er '.properties.endpoint' <<<"$OPENAI_ACCOUNT_JSON")" +OPENAI_LOCATION="$(jq -er '.location' <<<"$OPENAI_ACCOUNT_JSON")" +OPENAI_KIND="$(jq -er '.kind' <<<"$OPENAI_ACCOUNT_JSON")" +case "$OPENAI_KIND" in + OpenAI|AIServices) ;; + *) echo "Unsupported account kind: $OPENAI_KIND" >&2; exit 1 ;; +esac + +DEPLOYMENT_JSON="$(az cognitiveservices account deployment show \ + --resource-group "$OPENAI_RESOURCE_GROUP" \ + --name "$OPENAI_ACCOUNT" \ + --deployment-name "$OPENAI_DEPLOYMENT" \ + --output json)" +jq --arg location "$OPENAI_LOCATION" '{ + accountLocation: $location, + deploymentName: .name, + model: .properties.model.name, + modelVersion: .properties.model.version, + sku: .sku.name, + capacity: .sku.capacity +}' <<<"$DEPLOYMENT_JSON" + +AKS_OIDC_ISSUER="$(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_NAME" \ + --query oidcIssuerProfile.issuerUrl \ + --output tsv)" +WORKLOAD_IDENTITY_ENABLED="$(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_NAME" \ + --query securityProfile.workloadIdentity.enabled \ + --output tsv)" +test -n "$AKS_OIDC_ISSUER" +test "$WORKLOAD_IDENTITY_ENABLED" = "true" +``` + +**PowerShell 7:** + +```powershell +$env:AZURE_SUBSCRIPTION = "" +$env:RESOURCE_GROUP = "rg-adaptive-apps" +$env:AKS_NAME = "aks-adaptive-apps" +$env:OPENAI_RESOURCE_GROUP = "" +$env:OPENAI_ACCOUNT = "" +$env:OPENAI_DEPLOYMENT = "" +$env:AI_IDENTITY_NAME = "id-adaptive-apps-ai" + +az account set --subscription $env:AZURE_SUBSCRIPTION +$ProviderState = az provider show ` + --namespace Microsoft.CognitiveServices ` + --query registrationState ` + --output tsv +if ($ProviderState -ne "Registered") { + throw "Microsoft.CognitiveServices is not registered." +} + +$OpenAiAccount = az cognitiveservices account show ` + --resource-group $env:OPENAI_RESOURCE_GROUP ` + --name $env:OPENAI_ACCOUNT ` + --output json | ConvertFrom-Json +if ($OpenAiAccount.kind -notin @("OpenAI", "AIServices")) { + throw "Unsupported account kind: $($OpenAiAccount.kind)" +} +$OpenAiId = $OpenAiAccount.id +$OpenAiEndpoint = $OpenAiAccount.properties.endpoint +$OpenAiLocation = $OpenAiAccount.location + +$OpenAiDeployment = az cognitiveservices account deployment show ` + --resource-group $env:OPENAI_RESOURCE_GROUP ` + --name $env:OPENAI_ACCOUNT ` + --deployment-name $env:OPENAI_DEPLOYMENT ` + --output json | ConvertFrom-Json +[pscustomobject]@{ + AccountLocation = $OpenAiLocation + DeploymentName = $OpenAiDeployment.name + Model = $OpenAiDeployment.properties.model.name + ModelVersion = $OpenAiDeployment.properties.model.version + Sku = $OpenAiDeployment.sku.name + Capacity = $OpenAiDeployment.sku.capacity +} + +$AksOidcIssuer = az aks show ` + --resource-group $env:RESOURCE_GROUP ` + --name $env:AKS_NAME ` + --query oidcIssuerProfile.issuerUrl ` + --output tsv +$WorkloadIdentityEnabled = az aks show ` + --resource-group $env:RESOURCE_GROUP ` + --name $env:AKS_NAME ` + --query securityProfile.workloadIdentity.enabled ` + --output tsv +if ([string]::IsNullOrWhiteSpace($AksOidcIssuer)) { + throw "AKS OIDC issuer is not enabled." +} +if ($WorkloadIdentityEnabled -ne "true") { + throw "AKS workload identity is not enabled." +} +``` + +Confirm the selected deployment is approved for the workshop, has available quota, and +is reachable from AKS under its network policy. This challenge does not change the +deployment SKU, capacity, filters, or lifecycle. + +### 2. Publish and register the Azure reference recipe + +Deploy the registry to AKS: + +```bash +kubectl apply --filename resources/local-recipe-registry.yaml +kubectl rollout status \ + deployment/recipe-registry \ + --namespace adaptive-recipes \ + --timeout=5m +``` + +In **Terminal A**, run the AKS registry port-forward: + +```bash +kubectl port-forward \ + service/recipe-registry \ + --namespace adaptive-recipes \ + 5000:5000 +``` + +In **Terminal B**, publish and register the Azure recipe. + +**Bash:** + +```bash +curl --fail --silent http://127.0.0.1:5000/v2/ >/dev/null + +rad bicep publish \ + --file iac/recipes/ai-model-azure-existing.bicep \ + --target br:127.0.0.1:5000/adaptive-apps/ai-model-azure-existing:1.0.0 \ + --plain-http + +rad recipe register default \ + --workspace ws-azure-prod \ + --group rg-trading \ + --environment env-azure-prod \ + --resource-type Radius.Resources/aiModels \ + --template-kind bicep \ + --template-path recipe-registry.adaptive-recipes.svc.cluster.local:5000/adaptive-apps/ai-model-azure-existing:1.0.0 \ + --parameters "endpoint=$OPENAI_ENDPOINT" \ + --parameters "deploymentName=$OPENAI_DEPLOYMENT" \ + --plain-http + +rad recipe list \ + --workspace ws-azure-prod \ + --group rg-trading \ + --environment env-azure-prod +``` + +**PowerShell 7:** + +```powershell +Invoke-WebRequest -Uri http://127.0.0.1:5000/v2/ | Out-Null + +rad bicep publish ` + --file iac/recipes/ai-model-azure-existing.bicep ` + --target br:127.0.0.1:5000/adaptive-apps/ai-model-azure-existing:1.0.0 ` + --plain-http + +rad recipe register default ` + --workspace ws-azure-prod ` + --group rg-trading ` + --environment env-azure-prod ` + --resource-type Radius.Resources/aiModels ` + --template-kind bicep ` + --template-path recipe-registry.adaptive-recipes.svc.cluster.local:5000/adaptive-apps/ai-model-azure-existing:1.0.0 ` + --parameters "endpoint=$OpenAiEndpoint" ` + --parameters "deploymentName=$env:OPENAI_DEPLOYMENT" ` + --plain-http + +rad recipe list ` + --workspace ws-azure-prod ` + --group rg-trading ` + --environment env-azure-prod +``` + +The endpoint and deployment name are configuration, not credentials. The recipe returns +no API key. Stop Terminal A after registration. + +### 3. Create the exact application workload identity + +First derive the existing Radius application namespace. It is part of the federated +subject and must match exactly. + +**Bash:** + +```bash +ENV_NAMESPACE="$(rad env show env-azure-prod --output json | + jq -r '.properties.compute.namespace')" +APP_NAMESPACE="${ENV_NAMESPACE}-adaptive-apps" +kubectl get namespace "$APP_NAMESPACE" >/dev/null 2>&1 || + APP_NAMESPACE="$ENV_NAMESPACE" +kubectl get namespace "$APP_NAMESPACE" + +SERVICE_ACCOUNT_NAME="ai-agent" +FEDERATED_CREDENTIAL_NAME="adaptive-apps-ai-agent" +FEDERATED_SUBJECT="system:serviceaccount:${APP_NAMESPACE}:${SERVICE_ACCOUNT_NAME}" + +if ! az identity show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AI_IDENTITY_NAME" >/dev/null 2>&1; then + az identity create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AI_IDENTITY_NAME" \ + --location "$(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_NAME" \ + --query location \ + --output tsv)" >/dev/null +fi + +IDENTITY_JSON="$(az identity show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AI_IDENTITY_NAME" \ + --output json)" +AI_CLIENT_ID="$(jq -er '.clientId' <<<"$IDENTITY_JSON")" +AI_PRINCIPAL_ID="$(jq -er '.principalId' <<<"$IDENTITY_JSON")" +TENANT_ID="$(az account show --query tenantId --output tsv)" + +if az identity federated-credential show \ + --resource-group "$RESOURCE_GROUP" \ + --identity-name "$AI_IDENTITY_NAME" \ + --name "$FEDERATED_CREDENTIAL_NAME" >/dev/null 2>&1; then + az identity federated-credential update \ + --resource-group "$RESOURCE_GROUP" \ + --identity-name "$AI_IDENTITY_NAME" \ + --name "$FEDERATED_CREDENTIAL_NAME" \ + --issuer "$AKS_OIDC_ISSUER" \ + --subject "$FEDERATED_SUBJECT" \ + --audiences api://AzureADTokenExchange >/dev/null +else + az identity federated-credential create \ + --resource-group "$RESOURCE_GROUP" \ + --identity-name "$AI_IDENTITY_NAME" \ + --name "$FEDERATED_CREDENTIAL_NAME" \ + --issuer "$AKS_OIDC_ISSUER" \ + --subject "$FEDERATED_SUBJECT" \ + --audiences api://AzureADTokenExchange >/dev/null +fi + +kubectl create serviceaccount "$SERVICE_ACCOUNT_NAME" \ + --namespace "$APP_NAMESPACE" \ + --dry-run=client \ + --output yaml | + kubectl apply --filename - +kubectl annotate serviceaccount "$SERVICE_ACCOUNT_NAME" \ + --namespace "$APP_NAMESPACE" \ + "azure.workload.identity/client-id=$AI_CLIENT_ID" \ + --overwrite +``` + +**PowerShell 7:** + +```powershell +$Environment = rad env show env-azure-prod --output json | ConvertFrom-Json +$EnvironmentNamespace = $Environment.properties.compute.namespace +$AppNamespace = "$EnvironmentNamespace-adaptive-apps" +kubectl get namespace $AppNamespace --no-headers *> $null +if ($LASTEXITCODE -ne 0) { + $AppNamespace = $EnvironmentNamespace +} +kubectl get namespace $AppNamespace + +$ServiceAccountName = "ai-agent" +$FederatedCredentialName = "adaptive-apps-ai-agent" +$FederatedSubject = "system:serviceaccount:${AppNamespace}:${ServiceAccountName}" + +az identity show ` + --resource-group $env:RESOURCE_GROUP ` + --name $env:AI_IDENTITY_NAME *> $null +if ($LASTEXITCODE -ne 0) { + $AksLocation = az aks show ` + --resource-group $env:RESOURCE_GROUP ` + --name $env:AKS_NAME ` + --query location ` + --output tsv + az identity create ` + --resource-group $env:RESOURCE_GROUP ` + --name $env:AI_IDENTITY_NAME ` + --location $AksLocation | Out-Null +} + +$Identity = az identity show ` + --resource-group $env:RESOURCE_GROUP ` + --name $env:AI_IDENTITY_NAME ` + --output json | ConvertFrom-Json +$AiClientId = $Identity.clientId +$AiPrincipalId = $Identity.principalId +$TenantId = az account show --query tenantId --output tsv + +az identity federated-credential show ` + --resource-group $env:RESOURCE_GROUP ` + --identity-name $env:AI_IDENTITY_NAME ` + --name $FederatedCredentialName *> $null +if ($LASTEXITCODE -eq 0) { + az identity federated-credential update ` + --resource-group $env:RESOURCE_GROUP ` + --identity-name $env:AI_IDENTITY_NAME ` + --name $FederatedCredentialName ` + --issuer $AksOidcIssuer ` + --subject $FederatedSubject ` + --audiences api://AzureADTokenExchange | Out-Null +} +else { + az identity federated-credential create ` + --resource-group $env:RESOURCE_GROUP ` + --identity-name $env:AI_IDENTITY_NAME ` + --name $FederatedCredentialName ` + --issuer $AksOidcIssuer ` + --subject $FederatedSubject ` + --audiences api://AzureADTokenExchange | Out-Null +} + +kubectl create serviceaccount $ServiceAccountName ` + --namespace $AppNamespace ` + --dry-run=client ` + --output yaml | + kubectl apply --filename - +kubectl annotate serviceaccount $ServiceAccountName ` + --namespace $AppNamespace ` + "azure.workload.identity/client-id=$AiClientId" ` + --overwrite +``` + +Assign only the data-plane inference role at the selected account scope. + +**Bash:** + +```bash +ROLE_NAME="Cognitive Services OpenAI User" +ROLE_ASSIGNMENT_ID="$(az role assignment list \ + --scope "$OPENAI_ID" \ + --query "[?principalId=='$AI_PRINCIPAL_ID' && roleDefinitionName=='$ROLE_NAME'].id | [0]" \ + --output tsv)" +if [ -z "$ROLE_ASSIGNMENT_ID" ]; then + az role assignment create \ + --assignee-object-id "$AI_PRINCIPAL_ID" \ + --assignee-principal-type ServicePrincipal \ + --role "$ROLE_NAME" \ + --scope "$OPENAI_ID" >/dev/null +fi +``` + +**PowerShell 7:** + +```powershell +$RoleName = "Cognitive Services OpenAI User" +$RoleQuery = "[?principalId=='$AiPrincipalId' && roleDefinitionName=='$RoleName'].id | [0]" +$RoleAssignmentId = az role assignment list ` + --scope $OpenAiId ` + --query $RoleQuery ` + --output tsv +if ([string]::IsNullOrWhiteSpace($RoleAssignmentId)) { + az role assignment create ` + --assignee-object-id $AiPrincipalId ` + --assignee-principal-type ServicePrincipal ` + --role $RoleName ` + --scope $OpenAiId | Out-Null +} +``` + +This identity is for the application pod. It is separate from the Challenge 02 Radius +control-plane identity that deploys Azure resources. + +### 4. Deploy the same AI application addition + +Enable the AKS-only metadata and pass the exact service account and managed identity. + +**Bash:** + +```bash +rad deploy iac/ai.bicep \ + --workspace ws-azure-prod \ + --group rg-trading \ + --environment env-azure-prod \ + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps \ + --parameters imageTag=latest \ + --parameters "aiModel=$OPENAI_DEPLOYMENT" \ + --parameters "aiAgentServiceAccountName=$SERVICE_ACCOUNT_NAME" \ + --parameters enableAzureWorkloadIdentity=true \ + --parameters "aiAgentClientId=$AI_CLIENT_ID" \ + --parameters "workloadIdentityTenantId=$TENANT_ID" +``` + +**PowerShell 7:** + +```powershell +rad deploy iac/ai.bicep ` + --workspace ws-azure-prod ` + --group rg-trading ` + --environment env-azure-prod ` + --parameters imageRegistry=ghcr.io/microsoft/adaptive-apps ` + --parameters imageTag=latest ` + --parameters "aiModel=$env:OPENAI_DEPLOYMENT" ` + --parameters "aiAgentServiceAccountName=$ServiceAccountName" ` + --parameters enableAzureWorkloadIdentity=true ` + --parameters "aiAgentClientId=$AiClientId" ` + --parameters "workloadIdentityTenantId=$TenantId" +``` + +The Azure recipe references the existing account; it creates no Azure AI resource and +returns no key. Azure role propagation can take several minutes even after deployment +succeeds. + +### 5. Validate federation and inference + +Inspect the Radius graph and pod metadata without printing the projected token. + +**Bash:** + +```bash +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps + +kubectl rollout status \ + deployment/ai-agent \ + --namespace "$APP_NAMESPACE" \ + --timeout=5m +AI_POD="$(kubectl get pods \ + --namespace "$APP_NAMESPACE" \ + --output json | + jq -r '.items[] | + select(.spec.serviceAccountName == "ai-agent") | + .metadata.name' | + head -n 1)" +test -n "$AI_POD" + +kubectl get serviceaccount ai-agent \ + --namespace "$APP_NAMESPACE" \ + --output json | + jq '{name: .metadata.name, + clientId: .metadata.annotations["azure.workload.identity/client-id"]}' +kubectl get pod "$AI_POD" \ + --namespace "$APP_NAMESPACE" \ + --output json | + jq '{serviceAccount: .spec.serviceAccountName, + workloadIdentity: + .metadata.labels["azure.workload.identity/use"], + projectedVolumeNames: + [.spec.volumes[]? | select(.projected != null) | .name]}' + +az identity federated-credential show \ + --resource-group "$RESOURCE_GROUP" \ + --identity-name "$AI_IDENTITY_NAME" \ + --name "$FEDERATED_CREDENTIAL_NAME" \ + --query '{issuer:issuer,subject:subject,audiences:audiences}' \ + --output json +az role assignment list \ + --scope "$OPENAI_ID" \ + --query "[?principalId=='$AI_PRINCIPAL_ID'].{role:roleDefinitionName,scope:scope}" \ + --output table +``` + +**PowerShell 7:** + +```powershell +rad app graph --application adaptive-apps +rad resource list --application adaptive-apps + +kubectl rollout status ` + deployment/ai-agent ` + --namespace $AppNamespace ` + --timeout=5m +$Pods = kubectl get pods ` + --namespace $AppNamespace ` + --output json | ConvertFrom-Json +$AiPod = @( + $Pods.items | + Where-Object { $_.spec.serviceAccountName -eq "ai-agent" } +)[0] +if ($null -eq $AiPod) { + throw "No ai-agent pod using the expected service account was found." +} + +$ServiceAccount = kubectl get serviceaccount ai-agent ` + --namespace $AppNamespace ` + --output json | ConvertFrom-Json +[pscustomobject]@{ + Name = $ServiceAccount.metadata.name + ClientId = $ServiceAccount.metadata.annotations.'azure.workload.identity/client-id' +} +[pscustomobject]@{ + ServiceAccount = $AiPod.spec.serviceAccountName + WorkloadIdentity = $AiPod.metadata.labels.'azure.workload.identity/use' + ProjectedVolumeNames = @( + $AiPod.spec.volumes | + Where-Object { $null -ne $_.projected } | + ForEach-Object name + ) +} + +az identity federated-credential show ` + --resource-group $env:RESOURCE_GROUP ` + --identity-name $env:AI_IDENTITY_NAME ` + --name $FederatedCredentialName ` + --query '{issuer:issuer,subject:subject,audiences:audiences}' ` + --output json +$RoleQuery = "[?principalId=='$AiPrincipalId'].{role:roleDefinitionName,scope:scope}" +az role assignment list ` + --scope $OpenAiId ` + --query $RoleQuery ` + --output table +``` + +Do not `cat` the projected token or print environment variables that may contain +credentials. + +Expose `ai-agent` in a foreground terminal: + +**Bash:** + +```bash +rad resource expose Applications.Core/containers ai-agent \ + --application adaptive-apps \ + --port 7000 \ + --remote-port 7000 +``` + +**PowerShell 7:** + +```powershell +rad resource expose Applications.Core/containers ai-agent ` + --application adaptive-apps ` + --port 7000 ` + --remote-port 7000 +``` + +From another terminal, repeat the `/health`, `/advice`, and empty-question requests from +Stage 2. `/health` should report provider `azure` and the selected deployment name. +`/advice` proves both token exchange and model authorization. Stop the exposure after +validation. + +If `/health` succeeds but `/advice` returns `502`, inspect only the recent agent errors: + +```bash +kubectl logs \ + deployment/ai-agent \ + --namespace "$APP_NAMESPACE" \ + --tail=100 +``` + +Check, in order: + +1. Endpoint reachability and private networking. +2. Deployment name, model availability, quota, and content filters. +3. Service-account annotation and pod label. +4. Exact issuer and federated subject. +5. `Cognitive Services OpenAI User` assignment and propagation. + +Do not solve an identity failure by adding an API key or broad `Contributor` role. + +## Stage 4: Compare the implementations + +Use this parity record: + +| Dimension | Private K3s | AKS | +| --- | --- | --- | +| Application addition | `iac/ai.bicep` | `iac/ai.bicep` | +| Contract | `Radius.Resources/aiModels` | `Radius.Resources/aiModels` | +| Agent | Same published `ai-agent` image | Same published `ai-agent` image | +| Recipe output provider | `local` | `azure` | +| Model identifier | `qwen2.5:0.5b` | Existing deployment name | +| Backing host | Ollama Deployment and Service | Existing Azure OpenAI account | +| Credential | None for in-cluster endpoint | Federated managed identity | +| Compute/cost | Workshop VM CPU and download egress | Azure token usage and quota | +| Persistence | Model cache is ephemeral | Managed service deployment | +| Network dependency | Download required; inference then in-cluster | Endpoint required for every call | + +The stable application contract does not promise equal model quality, latency, +throughput, context size, safety controls, or disconnected operation. Those are +capability-level service objectives owned by the platform and application teams +together. + +The frontend can reach the newly created `ai-agent` service by its stable Radius +container name. Challenge 08 does not redeploy `iac/app.bicep`, so Challenge 06's +target-specific OIDC client secret and broker configuration are not overwritten. + +## Optional cleanup + +Cleanup is not required between challenges. If cost or policy requires it, remove only +the resources created here and select each target explicitly first. + +For either active Radius target, remove the additive resources before unregistering the +recipe: + +**Bash:** + +```bash +rad resource delete Applications.Core/containers ai-agent \ + --application adaptive-apps \ + --workspace "" \ + --group rg-trading \ + --yes +rad resource delete Radius.Resources/aiModels trading-ai \ + --application adaptive-apps \ + --workspace "" \ + --group rg-trading \ + --yes +rad recipe unregister default \ + --workspace "" \ + --group rg-trading \ + --environment "" \ + --resource-type Radius.Resources/aiModels +kubectl delete namespace adaptive-recipes +``` + +**PowerShell 7:** + +```powershell +rad resource delete Applications.Core/containers ai-agent ` + --application adaptive-apps ` + --workspace "" ` + --group rg-trading ` + --yes +rad resource delete Radius.Resources/aiModels trading-ai ` + --application adaptive-apps ` + --workspace "" ` + --group rg-trading ` + --yes +rad recipe unregister default ` + --workspace "" ` + --group rg-trading ` + --environment "" ` + --resource-type Radius.Resources/aiModels +kubectl delete namespace adaptive-recipes +``` + +On AKS, delete the dedicated identity only when no other workload uses it. Remove its +account-scoped role assignment first: + +**Bash:** + +```bash +az role assignment delete \ + --assignee-object-id "$AI_PRINCIPAL_ID" \ + --role "Cognitive Services OpenAI User" \ + --scope "$OPENAI_ID" +kubectl delete serviceaccount ai-agent --namespace "$APP_NAMESPACE" +az identity delete \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AI_IDENTITY_NAME" +``` + +**PowerShell 7:** + +```powershell +az role assignment delete ` + --assignee-object-id $AiPrincipalId ` + --role "Cognitive Services OpenAI User" ` + --scope $OpenAiId +kubectl delete serviceaccount ai-agent --namespace $AppNamespace +az identity delete ` + --resource-group $env:RESOURCE_GROUP ` + --name $env:AI_IDENTITY_NAME +``` + +These commands do not delete the existing Azure OpenAI account or deployment. Their +owner retains responsibility for quota, capacity, model lifecycle, and cost. + +## Optional GPU path + +The source Kaito recipe is useful only on a target that actually has a supported GPU +node and the required drivers, device plugin, storage, and capacity. A production +platform team may publish that recipe under the same AI contract and select it through +an environment. Do not claim Kaito validation on `Standard_D4s_v5`, and document GPU VM +cost and regional capacity before provisioning an optional target. From 3b2118ef60209d782c2ed184cd3a434872284254 Mon Sep 17 00:00:00 2001 From: Benjamin Van Brussel Date: Fri, 21 Aug 2026 09:59:35 +0200 Subject: [PATCH 09/29] Fix Adaptive Apps platform deployment for AKS and K3s (#1, #2) Validated challenges 00-04 end to end from the devcontainer against a live Azure subscription, on both the AKS and K3s VM paths. Five blocking script bugs were found and fixed, plus the prerequisite and capacity gaps that made first-run failures hard to diagnose. Blocking fixes: - prepare-k3s-azure-vm.sh: "az network bastion wait --custom provisioningState=='Succeeded'" never matches in the bastion extension, so every run burned the full 30-minute --timeout before continuing. Use the built-in --created condition, which returns as soon as Bastion is ready. - prepare-k3s-azure-vm.sh: disconnect only stopped the az Bash wrapper. /usr/bin/az runs its Python entry point as a child instead of exec'ing it, so the process that owns localhost:16443 survived. disconnect reported a false success and every later connect aborted with "Local port already in use", breaking the reconnect workflow used by challenges 02-08. Match both processes by full signature, stop all of them, and reclaim orphans in ensure_tunnel. - prepare-k3s-azure-vm.sh: register the k3s-azure-vm context in the default kubeconfig during provision and connect. rad 0.60.0 honours KUBECONFIG for the Helm phase but reads ~/.kube/config when configuring the Contour gateway, so deploy-radius-k3s.sh failed with "context was not found". The active context is left unchanged. - deploy-radius-aks.sh and deploy-radius-k3s.sh: the health check queried the CRD applications.radapp.io, which Radius 0.60.0 does not install. Both scripts exited 1 despite a healthy control plane. Check the CRDs Radius actually creates: recipes, deploymenttemplates, and deploymentresources. - configure-resource-types-aks.sh and configure-resource-types-k3s.sh: "kubectl rollout status deployment --all" is not valid, since kubectl rollout status takes a single object. Both scripts failed with "unknown flag: --all" right after the Helm install. Iterate over the objects. VM capacity handling: - prepare-aks.sh and prepare-k3s-azure-vm.sh resolve the node or VM size before creating anything, using one az vm list-skus call per run. Zone-only restrictions are ignored because the deployment is regional. If allocation still fails on capacity or quota, the failed resource is removed and the next candidate is tried; authorization failures are not retried. Override the ordered list with AKS_NODE_VM_SIZE_CANDIDATES or K3S_VM_SIZE_CANDIDATES. Documentation: - challenge-00.md: complete zero-experience host setup. WSL 2 enablement, three supported container runtimes including a Docker Desktop licence note and a Docker Engine in WSL 2 alternative, the Dev Containers extension ID, host Git, runtime sizing, arm64 support, and device-code sign-in. - challenge-00.md: resource provider registration and a public IP preflight, with the repair for the SubscriptionNotRegisteredForFeature AllowBringYourOwnPublicIpAddress error seen on a fresh subscription. - challenge-00.md: regional VM capacity and quota preflight commands. - solution-00.md, solution-01.md: runtime options, arm64 support, tunnel lifecycle, kubeconfig registration, and size fallback behaviour. - solution-04.md: state that challenge 03 must run first, because the environment templates register recipes for the types it creates. - challenge-02.md, solution-02.md, solution-03.md: correct the CRD and rollout commands shown to participants. - post-create.sh: "local readonly VAR=" does not make a variable readonly (shellcheck SC2316). - .gitignore: ignore the generated devcontainer-lock.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../post-create.sh | 6 +- .gitignore | 3 + .../challenges/challenge-00.md | 155 ++++++++++++- .../challenges/challenge-02.md | 2 +- .../resources/configure-resource-types-aks.sh | 17 +- .../resources/configure-resource-types-k3s.sh | 17 +- .../resources/deploy-radius-aks.sh | 2 +- .../resources/deploy-radius-k3s.sh | 2 +- .../04-adaptive-apps/resources/prepare-aks.sh | 96 +++++++- .../resources/prepare-k3s-azure-vm.sh | 217 +++++++++++++++--- .../walkthrough/challenge-00/solution-00.md | 43 +++- .../walkthrough/challenge-01/solution-01.md | 30 ++- .../walkthrough/challenge-02/solution-02.md | 2 +- .../walkthrough/challenge-03/solution-03.md | 7 +- .../walkthrough/challenge-04/solution-04.md | 14 ++ 15 files changed, 533 insertions(+), 80 deletions(-) diff --git a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh index f131f9f25..3e4015b27 100644 --- a/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh +++ b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh @@ -59,8 +59,8 @@ fi export PATH="$HOME/.local/bin:$PATH" configure_windows_worktree_shell() { - local readonly git_pointer="/workspaces/microhack/.git" - local readonly marker="# Adaptive Apps Windows worktree prompt" + local git_pointer="/workspaces/microhack/.git" + local marker="# Adaptive Apps Windows worktree prompt" if [[ ! -f "$git_pointer" ]] || ! grep -Eq '^gitdir: [A-Za-z]:[/\\]' "$git_pointer"; then @@ -128,7 +128,7 @@ install_radius() { install_yq() { local checksum local checksums - local readonly yq_asset="yq_linux_${ARCH}" + local yq_asset="yq_linux_${ARCH}" retry 3 curl --fail --location --silent --show-error \ "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${yq_asset}" \ diff --git a/.gitignore b/.gitignore index 7af82d58a..ddc055283 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # Adaptive Apps Bicep resource-type extension generated during Challenge 03. /03-Azure/01-01-App Innovation/04-adaptive-apps/artifacts/*.tgz +# Dev Containers feature lock file generated on the first container build. +.devcontainer/**/devcontainer-lock.json + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md index 792c8b0f7..e2ebbf019 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md @@ -51,6 +51,83 @@ Confirm that your workshop team has: > because recipes can create role assignments. Use an isolated workshop subscription > or resource group rather than a production scope. +Register the resource providers the MicroHack uses. A subscription that has never +deployed these services silently fails later with provider or feature errors: + +```bash +az account set --subscription "" + +for provider in Microsoft.Network Microsoft.Compute Microsoft.ContainerService \ + Microsoft.ContainerRegistry Microsoft.KeyVault Microsoft.Storage; do + az provider register --namespace "$provider" --wait +done + +az provider list \ + --query "[?namespace=='Microsoft.Network' || namespace=='Microsoft.Compute' || namespace=='Microsoft.ContainerService' || namespace=='Microsoft.ContainerRegistry'].{provider:namespace,state:registrationState}" \ + --output table +``` + +Every provider must report `Registered`. + +Then confirm the subscription can actually create a public IP address, because +Challenge 01 needs one for Azure Bastion: + +```bash +az group create --name rg-adaptive-apps-preflight --location westeurope --output none +az network public-ip create \ + --resource-group rg-adaptive-apps-preflight \ + --name pip-preflight \ + --sku Standard \ + --allocation-method Static \ + --output none +az group delete --name rg-adaptive-apps-preflight --yes --no-wait +``` + +If that fails with +`SubscriptionNotRegisteredForFeature ... Microsoft.Network/AllowBringYourOwnPublicIpAddress`, +the subscription's networking registration is incomplete. Repair it and retry: + +```bash +az feature register --namespace Microsoft.Network --name AllowBringYourOwnPublicIpAddress +az provider register --namespace Microsoft.Network --wait +``` + +### Task 1b: Check regional VM capacity + +Regions differ in which VM sizes they offer, and quota is granted per size family. List +the four-vCPU sizes your subscription can actually use in the target region: + +```bash +az vm list-skus \ + --location westeurope \ + --resource-type virtualMachines \ + --query "[?length(restrictions[?type=='Location']) == \`0\`].name" \ + --output tsv | grep -E '^Standard_D4(a|as|s|ds)?_v[345]$' | sort +``` + +A `Zone` restriction is not a problem, because the MicroHack deploys regionally rather +than into a specific availability zone. A `Location` restriction means the size is +unavailable to you in that region. + +Then confirm the family quota covers 12 vCPUs (two AKS nodes plus the K3s VM): + +```bash +az vm list-usage --location westeurope --output table | + grep -E 'Total Regional vCPUs|Standard DSv5 Family' +``` + +`resources/prepare-aks.sh` and `resources/prepare-k3s-azure-vm.sh` perform this check +themselves. Each script picks the first size that the region offers and, if allocation +still fails because of capacity or quota, automatically retries the next size in +`AKS_NODE_VM_SIZE_CANDIDATES` or `K3S_VM_SIZE_CANDIDATES`. Override either variable to +supply your own ordered list: + +```bash +export K3S_VM_SIZE_CANDIDATES="Standard_D4s_v5 Standard_D4s_v4 Standard_D4s_v3" +``` + +If no size in the region works, choose a different `AZURE_LOCATION`. + ### Task 2: Choose a workstation setup The Adaptive Apps devcontainer is the recommended setup because it provides a @@ -59,12 +136,69 @@ supported. #### Option A: Use the recommended devcontainer -Host prerequisites: +The devcontainer gives every participant the same tested Linux toolchain, so this is the +recommended path. It needs a container runtime on the host. If you have never used +containers before, work through A1 to A4 once; budget about 20 minutes. + +##### A1. Windows only: enable WSL 2 + +Windows runs Linux containers inside WSL 2. In an elevated PowerShell window: + +```powershell +wsl --install +wsl --set-default-version 2 +``` + +Restart if prompted, then confirm that a distribution is installed and reports `2`: + +```powershell +wsl --list --verbose +``` + +macOS and Linux hosts skip this step. + +##### A2. Install a container runtime + +Any of the following works with the Dev Containers extension. Choose one. + +| Runtime | When to choose it | +| --- | --- | +| [Docker Desktop](https://www.docker.com/products/docker-desktop/) | Simplest option. On Windows keep **Settings > General > Use the WSL 2 based engine** enabled. Docker Desktop requires a paid subscription for larger organizations, so check the [Docker Desktop license terms](https://docs.docker.com/subscription/desktop-license/) before using it for work. | +| [Docker Engine](https://docs.docker.com/engine/install/ubuntu/) inside WSL 2 or Linux | No Docker Desktop subscription. Install Docker Engine in the WSL 2 distribution, add your user to the `docker` group, then open the repository through **WSL: Connect to WSL** in VS Code so the extension uses that engine. | +| [Podman Desktop](https://podman-desktop.io/) or [Rancher Desktop](https://rancherdesktop.io/) | Organizations that standardize on a Docker Desktop alternative. Set the runtime as the active Docker-compatible endpoint. | + +Confirm the runtime responds before continuing. The command must print server details +rather than a connection error: + +```bash +docker info +``` + +##### A3. Install Visual Studio Code, the Dev Containers extension, and Git -- Docker Desktop or another runtime compatible with VS Code Dev Containers - [Visual Studio Code](https://code.visualstudio.com/) -- The [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) -- On Windows, Docker Desktop configured with its WSL 2 backend +- [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) + (`ms-vscode-remote.remote-containers`) +- [Git](https://git-scm.com/downloads) on the host, because the repository is cloned + before the container starts + +##### A4. Size the runtime + +The container image, devcontainer features, and four small state volumes need roughly +10 GB of free disk. Allow the runtime at least 4 GB of memory. On Docker Desktop these +limits are under **Settings > Resources**. + +##### A5. Processor architecture + +Both `x86_64` and `arm64` workstations are supported, including Apple Silicon Macs and +Windows on Arm devices. The devcontainer selects matching Linux builds of `kubectl`, +Helm, the Radius CLI, `yq`, and Bicep automatically, so no extra configuration is +needed. Do not force `--platform linux/amd64`: emulation is slower and is not required. + +The workstation architecture is independent of Azure. The AKS nodes and the K3s VM are +always x64 Azure VM sizes even when the devcontainer runs on arm64. + +##### Start the container Clone the fork and check out this MicroHack branch: @@ -80,6 +214,10 @@ In VS Code, run **Dev Containers: Reopen in Container** and select lives under the repository's root `.devcontainer` directory and opens `03-Azure/01-01-App Innovation/04-adaptive-apps` as the container workspace. +The first build downloads the base image and every pinned tool, so it takes several +minutes. The build is finished when the terminal prints +`The container is ready. Sign in with 'az login' and select the lab subscription.` + The container installs Azure CLI, its `bastion` extension, Bicep, `kubectl`, Helm 3, Radius CLI, PowerShell 7, Git, `jq`, `yq`, `curl`, SSH, and `tar`. These tools can run every command in Challenges 00-05. @@ -87,6 +225,15 @@ The container is only a workstation: AKS, the Linux VM, K3s, Radius control plan portfolio workloads, and recipes are still created on the remote Azure and Kubernetes targets by the commands you run. +Sign in to Azure from inside the container. If the container cannot open a browser, use +the device-code flow: + +```bash +az login --use-device-code +az account set --subscription "" +az account show --output table +``` + > [!IMPORTANT] > The configuration does not mount the host Docker socket. Challenge 04 supports > token-based ACR authentication when Docker is unavailable. 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 48d05a630..83762e3a8 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 @@ -95,7 +95,7 @@ kubectl wait --for=condition=Available deployments --all \ --namespace radius-system \ --timeout=10m kubectl get pods --namespace radius-system -kubectl get crd applications.radapp.io +kubectl get crd recipes.radapp.io deploymenttemplates.radapp.io deploymentresources.radapp.io rad workspace list rad env list diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh index bf7adb9c3..53a47fd80 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh @@ -45,8 +45,21 @@ helm upgrade --install "$PORTFOLIO" \ --set istio.namespace=aks-istio-system \ --namespace "$PORTFOLIO" \ --create-namespace -kubectl rollout status deployment --all --namespace "$PORTFOLIO" --timeout=15m -kubectl rollout status statefulset --all --namespace "$PORTFOLIO" --timeout=15m + +# kubectl rollout status accepts one object at a time; it has no --all flag. +wait_for_portfolio_rollout() { + local kind="$1" + local object + local -a objects=() + + mapfile -t objects < <(kubectl get "$kind" --namespace "$PORTFOLIO" --output name) + for object in "${objects[@]}"; do + kubectl rollout status "$object" --namespace "$PORTFOLIO" --timeout=15m + done +} + +wait_for_portfolio_rollout deployment +wait_for_portfolio_rollout statefulset rad workspace switch "$RADIUS_WORKSPACE" rad resource-type create --workspace "$RADIUS_WORKSPACE" --from-file "$SQL_TYPE_FILE" diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh index 59cafba0a..a4cfadb93 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh @@ -46,8 +46,21 @@ helm upgrade --install "$PORTFOLIO" \ --values "${SOURCE_ROOT}/charts/adaptive-apps/profiles/${PORTFOLIO}.yaml" \ --namespace "$PORTFOLIO" \ --create-namespace -kubectl rollout status deployment --all --namespace "$PORTFOLIO" --timeout=15m -kubectl rollout status statefulset --all --namespace "$PORTFOLIO" --timeout=15m + +# kubectl rollout status accepts one object at a time; it has no --all flag. +wait_for_portfolio_rollout() { + local kind="$1" + local object + local -a objects=() + + mapfile -t objects < <(kubectl get "$kind" --namespace "$PORTFOLIO" --output name) + for object in "${objects[@]}"; do + kubectl rollout status "$object" --namespace "$PORTFOLIO" --timeout=15m + done +} + +wait_for_portfolio_rollout deployment +wait_for_portfolio_rollout statefulset rad workspace switch "$RADIUS_WORKSPACE" rad resource-type create --workspace "$RADIUS_WORKSPACE" --from-file "$SQL_TYPE_FILE" diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh index 1ebeebd43..be1195a3f 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh @@ -182,7 +182,7 @@ rad credential register azure wi \ kubectl wait --for=condition=Available deployments --all \ --namespace radius-system \ --timeout=10m -kubectl get crd applications.radapp.io >/dev/null +kubectl get crd recipes.radapp.io deploymenttemplates.radapp.io deploymentresources.radapp.io >/dev/null rad env show "$RADIUS_ENVIRONMENT" >/dev/null rad group show "$RADIUS_GROUP" >/dev/null rad credential show azure >/dev/null diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh index 4a9116333..7d7e85135 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh @@ -48,7 +48,7 @@ rad env switch "$RADIUS_ENVIRONMENT" kubectl wait --for=condition=Available deployments --all \ --namespace radius-system \ --timeout=10m -kubectl get crd applications.radapp.io >/dev/null +kubectl get crd recipes.radapp.io deploymenttemplates.radapp.io deploymentresources.radapp.io >/dev/null rad env show "$RADIUS_ENVIRONMENT" >/dev/null rad group show "$RADIUS_GROUP" >/dev/null kubectl get pods --namespace radius-system diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh index 68f6bf854..705a852ab 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh @@ -19,6 +19,35 @@ AKS_CLUSTER="${AKS_CLUSTER:-aks-adaptive-apps}" AKS_NODE_COUNT="${AKS_NODE_COUNT:-2}" AKS_NODE_VM_SIZE="${AKS_NODE_VM_SIZE:-Standard_D4s_v5}" +# Four-vCPU, 16 GiB x64 sizes in decreasing order of preference. Regions and +# subscriptions differ in what they offer, so the script falls back automatically. +# These are Azure VM sizes: an arm64 workstation still provisions x64 Azure nodes. +read -r -a AKS_NODE_VM_SIZE_CANDIDATES <<<"${AKS_NODE_VM_SIZE_CANDIDATES:-${AKS_NODE_VM_SIZE} Standard_D4as_v5 Standard_D4s_v4 Standard_D4as_v4 Standard_D4s_v3 Standard_D4a_v4}" + +AVAILABLE_VM_SIZES="" + +# az vm list-skus already omits sizes the subscription cannot use. A Location +# restriction blocks the whole region; a Zone restriction only removes individual +# availability zones and does not block the regional, non-zonal deployment used here. +load_available_vm_sizes() { + [[ -z "$AVAILABLE_VM_SIZES" ]] || return 0 + AVAILABLE_VM_SIZES="$(az vm list-skus \ + --location "$AZURE_LOCATION" \ + --resource-type virtualMachines \ + --query "[?length(restrictions[?type=='Location']) == \`0\`].name" \ + --output tsv)" +} + +vm_size_is_available() { + load_available_vm_sizes + grep -qx -- "$1" <<<"$AVAILABLE_VM_SIZES" +} + +# Capacity and quota problems only surface when the control plane tries to allocate. +is_capacity_error() { + grep -Eqi 'AllocationFailed|SkuNotAvailable|ZonalAllocationFailed|OverconstrainedAllocationRequest|QuotaExceeded|exceeding approved .* quota|InsufficientCapacity' "$1" +} + az account show >/dev/null 2>&1 || az login az account set --subscription "$AZURE_SUBSCRIPTION" az group create --name "$RESOURCE_GROUP" --location "$AZURE_LOCATION" --output none @@ -42,18 +71,61 @@ if az aks show --resource-group "$RESOURCE_GROUP" --name "$AKS_CLUSTER" >/dev/nu --output none fi else - az aks create \ - --resource-group "$RESOURCE_GROUP" \ - --name "$AKS_CLUSTER" \ - --location "$AZURE_LOCATION" \ - --node-count "$AKS_NODE_COUNT" \ - --node-vm-size "$AKS_NODE_VM_SIZE" \ - --node-osdisk-type Managed \ - --enable-oidc-issuer \ - --enable-workload-identity \ - --enable-asm \ - --generate-ssh-keys \ - --output none + create_aks_cluster() { + local size + local create_log + local attempted=0 + + create_log="$(mktemp)" + + for size in "${AKS_NODE_VM_SIZE_CANDIDATES[@]}"; do + if ! vm_size_is_available "$size"; then + echo "Node size ${size} is not offered in ${AZURE_LOCATION} for this subscription; trying the next candidate." >&2 + continue + fi + attempted=1 + echo "Creating AKS cluster ${AKS_CLUSTER} with node size ${size}." + if az aks create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --location "$AZURE_LOCATION" \ + --node-count "$AKS_NODE_COUNT" \ + --node-vm-size "$size" \ + --node-osdisk-type Managed \ + --enable-oidc-issuer \ + --enable-workload-identity \ + --enable-asm \ + --generate-ssh-keys \ + --output none 2>"$create_log"; then + rm -f "$create_log" + return 0 + fi + + cat "$create_log" >&2 + if ! is_capacity_error "$create_log"; then + rm -f "$create_log" + return 1 + fi + + echo "Node size ${size} has no capacity or quota in ${AZURE_LOCATION}; removing the failed cluster and trying the next size." >&2 + az aks delete \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --yes \ + --output none 2>/dev/null || true + done + + rm -f "$create_log" + if ((attempted == 0)); then + echo "None of the candidate node sizes is offered in ${AZURE_LOCATION}: ${AKS_NODE_VM_SIZE_CANDIDATES[*]}" >&2 + else + echo "Every candidate node size failed to allocate in ${AZURE_LOCATION}: ${AKS_NODE_VM_SIZE_CANDIDATES[*]}" >&2 + fi + echo "Set AKS_NODE_VM_SIZE_CANDIDATES to sizes offered in your region, or choose another AZURE_LOCATION." >&2 + return 1 + } + + create_aks_cluster fi az aks get-credentials \ diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh index 3fc2c27e6..c69f4395b 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh @@ -27,6 +27,11 @@ K3S_NSG_NAME="${K3S_NSG_NAME:-nsg-adaptive-apps-k3s}" K3S_NIC_NAME="${K3S_NIC_NAME:-nic-adaptive-apps-k3s}" K3S_VM_NAME="${K3S_VM_NAME:-vm-adaptive-apps-k3s}" K3S_VM_SIZE="${K3S_VM_SIZE:-Standard_D4s_v5}" +# Four-vCPU, 16 GiB x64 sizes in decreasing order of preference. Regions and +# subscriptions differ in what they offer, so the script falls back automatically. +# These are Azure VM sizes: an arm64 workstation still provisions an x64 Azure VM, +# and the Ubuntu2204 image alias used below resolves to an x64 image. +read -r -a K3S_VM_SIZE_CANDIDATES <<<"${K3S_VM_SIZE_CANDIDATES:-${K3S_VM_SIZE} Standard_D4as_v5 Standard_D4s_v4 Standard_D4as_v4 Standard_D4s_v3 Standard_D4a_v4}" K3S_ADMIN_USERNAME="${K3S_ADMIN_USERNAME:-azureuser}" BASTION_PUBLIC_IP_NAME="${BASTION_PUBLIC_IP_NAME:-pip-adaptive-apps-bastion}" BASTION_NAME="${BASTION_NAME:-bas-adaptive-apps}" @@ -92,13 +97,53 @@ tunnel_pid_matches() { is_pid_running "$pid" || return 1 [[ -r "/proc/${pid}/cmdline" ]] || return 1 command_line="$(tr '\0' ' ' <"/proc/${pid}/cmdline")" - [[ "$command_line" == *"az network bastion tunnel"* ]] && + # The Azure CLI launcher is a Bash wrapper that runs its Python entry point as a child + # process rather than replacing itself, so both processes carry the same argument + # signature. Match either one; the Bastion name, resource group, and both ports keep + # the signature specific to this tunnel. + [[ "$command_line" == *"network bastion tunnel"* ]] && [[ "$command_line" == *"--name ${BASTION_NAME}"* ]] && [[ "$command_line" == *"--resource-group ${RESOURCE_GROUP}"* ]] && [[ "$command_line" == *"--resource-port ${K3S_API_PORT}"* ]] && [[ "$command_line" == *"--port ${K3S_LOCAL_PORT}"* ]] } +# Every live process carrying this tunnel's exact signature, including the Azure CLI +# Python child that actually owns the local listening socket. +list_tunnel_pids() { + local pid_directory + local pid + + for pid_directory in /proc/[0-9]*; do + pid="${pid_directory#/proc/}" + if tunnel_pid_matches "$pid"; then + printf '%s\n' "$pid" + fi + done +} + +stop_tunnel_pids() { + local pid + local remaining + + (($# > 0)) || return 0 + for pid in "$@"; do + kill "$pid" 2>/dev/null || true + done + for _ in {1..20}; do + remaining=0 + for pid in "$@"; do + if is_pid_running "$pid"; then + remaining=1 + fi + done + if ((remaining == 0)); then + return 0 + fi + sleep 1 + done + return 1 +} local_port_ready() { timeout 1 bash -c "exec 3<>/dev/tcp/127.0.0.1/${K3S_LOCAL_PORT}" 2>/dev/null } @@ -130,38 +175,32 @@ tunnel_status() { } disconnect_tunnel() { - local pid + local recorded + local -a pids=() - if ! pid="$(read_recorded_pid)" || ! is_integer "$pid"; then - rm -f "$TUNNEL_PID_FILE" - echo "No K3s Bastion tunnel is recorded." - return + if recorded="$(read_recorded_pid 2>/dev/null)" && is_integer "$recorded"; then + if is_pid_running "$recorded" && ! tunnel_pid_matches "$recorded"; then + echo "Refusing to stop PID ${recorded}: it is not the recorded Adaptive Apps Bastion tunnel." >&2 + echo "Remove ${TUNNEL_PID_FILE} manually only after investigating that process." >&2 + return 1 + fi fi - if ! is_pid_running "$pid"; then - rm -f "$TUNNEL_PID_FILE" - echo "Removed stale K3s Bastion tunnel PID ${pid}." - return - fi + mapfile -t pids < <(list_tunnel_pids) - if ! tunnel_pid_matches "$pid"; then - echo "Refusing to stop PID ${pid}: it is not the recorded Adaptive Apps Bastion tunnel." >&2 - echo "Remove ${TUNNEL_PID_FILE} manually only after investigating that process." >&2 - return 1 + if ((${#pids[@]} == 0)); then + rm -f "$TUNNEL_PID_FILE" + echo "No K3s Bastion tunnel is running." + return 0 fi - kill "$pid" - for _ in {1..20}; do - is_pid_running "$pid" || break - sleep 1 - done - if is_pid_running "$pid"; then - echo "Tunnel PID ${pid} did not stop after SIGTERM; it was not force-killed." >&2 + if ! stop_tunnel_pids "${pids[@]}"; then + echo "Tunnel PIDs ${pids[*]} did not stop after SIGTERM; they were not force-killed." >&2 return 1 fi rm -f "$TUNNEL_PID_FILE" - echo "Stopped K3s Bastion tunnel PID ${pid}." + echo "Stopped K3s Bastion tunnel PIDs ${pids[*]}." } if [[ "$MODE" == "status" ]]; then @@ -239,7 +278,20 @@ ensure_tunnel() { fi if local_port_ready; then - echo "Local port ${K3S_LOCAL_PORT} is already in use by an unrecorded process." >&2 + local -a orphan_pids=() + mapfile -t orphan_pids < <(list_tunnel_pids) + if ((${#orphan_pids[@]} > 0)); then + echo "Reclaiming orphaned Adaptive Apps Bastion tunnel PIDs ${orphan_pids[*]}." + if ! stop_tunnel_pids "${orphan_pids[@]}"; then + echo "Orphaned Bastion tunnel PIDs ${orphan_pids[*]} did not stop after SIGTERM." >&2 + return 1 + fi + rm -f "$TUNNEL_PID_FILE" + fi + fi + + if local_port_ready; then + echo "Local port ${K3S_LOCAL_PORT} is in use by a process that is not an Adaptive Apps tunnel." >&2 echo "Choose another K3S_LOCAL_PORT or stop that process explicitly." >&2 return 1 fi @@ -305,6 +357,28 @@ validate_k3s() { kubectl get nodes -o wide } +# The Radius CLI resolves part of its installation through the default kubeconfig rather +# than through KUBECONFIG, so the K3s context must also be registered there. The +# dedicated K3s file remains the documented way to target K3s explicitly. +register_k3s_context_in_default_kubeconfig() { + local default_kubeconfig="$HOME/.kube/config" + local merged_file + + merged_file="$(mktemp)" + chmod 0600 "$merged_file" + TEMP_FILES+=("$merged_file") + + KUBECONFIG="${default_kubeconfig}:${K3S_KUBECONFIG}" \ + kubectl config view --flatten >"$merged_file" + KUBECONFIG="$merged_file" kubectl config get-contexts --output name | + grep -qx "$K3S_CONTEXT" || { + echo "Could not register the ${K3S_CONTEXT} context in ${default_kubeconfig}." >&2 + return 1 + } + install -m 0600 "$merged_file" "$default_kubeconfig" + rm -f "$merged_file" +} + if [[ "$MODE" == "connect" ]]; then az vm show --resource-group "$RESOURCE_GROUP" --name "$K3S_VM_NAME" >/dev/null || { echo "K3s VM ${K3S_VM_NAME} was not found; run provision mode first." >&2 @@ -338,9 +412,87 @@ if [[ "$MODE" == "connect" ]]; then } ensure_tunnel validate_k3s + register_k3s_context_in_default_kubeconfig exit fi +AVAILABLE_VM_SIZES="" + +# az vm list-skus already omits sizes the subscription cannot use. A Location +# restriction blocks the whole region; a Zone restriction only removes individual +# availability zones and does not block the regional, non-zonal deployment used here. +load_available_vm_sizes() { + [[ -z "$AVAILABLE_VM_SIZES" ]] || return 0 + AVAILABLE_VM_SIZES="$(az vm list-skus \ + --location "$AZURE_LOCATION" \ + --resource-type virtualMachines \ + --query "[?length(restrictions[?type=='Location']) == \`0\`].name" \ + --output tsv)" +} + +vm_size_is_available() { + load_available_vm_sizes + grep -qx -- "$1" <<<"$AVAILABLE_VM_SIZES" +} + +# Capacity and quota problems only surface when the control plane tries to allocate. +is_capacity_error() { + grep -Eqi 'AllocationFailed|SkuNotAvailable|ZonalAllocationFailed|OverconstrainedAllocationRequest|QuotaExceeded|exceeding approved .* quota|InsufficientCapacity' "$1" +} + +create_k3s_vm() { + local size + local create_log + local attempted=0 + + create_log="$(mktemp)" + TEMP_FILES+=("$create_log") + + for size in "${K3S_VM_SIZE_CANDIDATES[@]}"; do + if ! vm_size_is_available "$size"; then + echo "VM size ${size} is not offered in ${AZURE_LOCATION} for this subscription; trying the next candidate." >&2 + continue + fi + attempted=1 + echo "Creating K3s VM ${K3S_VM_NAME} with size ${size}." + if az vm create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM_NAME" \ + --location "$AZURE_LOCATION" \ + --image Ubuntu2204 \ + --size "$size" \ + --admin-username "$K3S_ADMIN_USERNAME" \ + --authentication-type ssh \ + --generate-ssh-keys \ + --nics "$K3S_NIC_NAME" \ + --os-disk-size-gb 64 \ + --storage-sku StandardSSD_LRS \ + --output none 2>"$create_log"; then + return 0 + fi + + cat "$create_log" >&2 + if ! is_capacity_error "$create_log"; then + return 1 + fi + + echo "VM size ${size} has no capacity or quota in ${AZURE_LOCATION}; removing the failed VM and trying the next size." >&2 + az vm delete \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM_NAME" \ + --yes \ + --output none 2>/dev/null || true + done + + if ((attempted == 0)); then + echo "None of the candidate VM sizes is offered in ${AZURE_LOCATION}: ${K3S_VM_SIZE_CANDIDATES[*]}" >&2 + else + echo "Every candidate VM size failed to allocate in ${AZURE_LOCATION}: ${K3S_VM_SIZE_CANDIDATES[*]}" >&2 + fi + echo "Set K3S_VM_SIZE_CANDIDATES to sizes offered in your region, or choose another AZURE_LOCATION." >&2 + return 1 +} + create_or_validate_subnet() { local subnet_name="$1" local subnet_prefix="$2" @@ -493,19 +645,7 @@ else --network-security-group "$K3S_NSG_NAME" \ --output none fi - az vm create \ - --resource-group "$RESOURCE_GROUP" \ - --name "$K3S_VM_NAME" \ - --location "$AZURE_LOCATION" \ - --image Ubuntu2204 \ - --size "$K3S_VM_SIZE" \ - --admin-username "$K3S_ADMIN_USERNAME" \ - --authentication-type ssh \ - --generate-ssh-keys \ - --nics "$K3S_NIC_NAME" \ - --os-disk-size-gb 64 \ - --storage-sku StandardSSD_LRS \ - --output none + create_k3s_vm fi NIC_ID="$(az vm show \ @@ -601,7 +741,7 @@ fi az network bastion wait \ --resource-group "$RESOURCE_GROUP" \ --name "$BASTION_NAME" \ - --custom "provisioningState=='Succeeded'" \ + --created \ --timeout 1800 BASTION_STATE="$(az network bastion show \ @@ -721,6 +861,7 @@ retrieve_kubeconfig() { "$current_context" "$K3S_CONTEXT" >/dev/null fi rm -f "$encoded_file" "$decoded_file" + register_k3s_context_in_default_kubeconfig } retrieve_kubeconfig diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md index 4726bf10a..0e06fb663 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md @@ -62,14 +62,29 @@ below. Confirm each participant has: -- Docker Desktop or another runtime compatible with VS Code Dev Containers +- A container runtime (see the options below) - Visual Studio Code -- The Dev Containers extension -- Enough local storage for the Ubuntu base image, features, and four small state - volumes +- The Dev Containers extension (`ms-vscode-remote.remote-containers`) +- Git on the host, because the repository is cloned before the container starts +- Roughly 10 GB of free disk for the Ubuntu base image, features, and four small state + volumes, and at least 4 GB of memory available to the runtime -On Windows, use Docker Desktop's WSL 2 backend. The repository can be cloned in the -WSL filesystem for better filesystem performance. +On Windows, WSL 2 must be enabled first (`wsl --install`, then `wsl --set-default-version 2` +in an elevated PowerShell, and `wsl --list --verbose` to confirm the distribution reports +`2`). The repository can be cloned in the WSL filesystem for better filesystem +performance. + +Three container runtimes are known to work with this configuration: + +| Runtime | Coach notes | +| --- | --- | +| Docker Desktop | Simplest to support. Keep the WSL 2 based engine enabled on Windows. Docker Desktop requires a paid subscription for larger organizations, so confirm participants are licensed before recommending it as the default. | +| Docker Engine inside WSL 2 or Linux | Avoids the Docker Desktop subscription. Participants install Docker Engine in the distribution, add themselves to the `docker` group, and open the repository through **WSL: Connect to WSL** so the extension targets that engine. | +| Podman Desktop or Rancher Desktop | Usable where an organization standardizes on a Docker Desktop alternative. | + +Have participants prove the runtime works before the workshop starts. `docker info` must +print server details rather than a connection error. This single check removes most +day-one delays. This configuration follows the MicroHack repository's multi-configuration convention. It is documented for local VS Code Dev Containers; do not advertise Codespaces unless @@ -121,6 +136,12 @@ Version pins make participant environments repeatable. Updating a pin requires s validation of this MicroHack and compatibility with the Kubernetes versions offered by AKS. +Every pinned tool ships both `amd64` and `arm64` Linux builds, and `post-create.sh` +selects the matching one from `uname -m`. The configuration is validated on `x86_64` and +`arm64` hosts, including Apple Silicon and Windows on Arm. Do not tell participants to +force `--platform linux/amd64`. The workstation architecture does not affect Azure: the +AKS nodes and the K3s VM are x64 Azure VM sizes in every case. + Rust, k3d, and application-development extensions from the source repository devcontainer are intentionally omitted. Challenges 00-05 use Bash and PowerShell 7, create K3s on an Azure VM, and do not build the Adaptive Apps application source. @@ -178,8 +199,14 @@ az account set --subscription "" az account show --output table ``` -Azure CLI may open a browser on the host or show a device-code prompt. Tokens are saved -only in the `~/.azure` volume. +Azure CLI may open a browser on the host or show a device-code prompt. When the +container cannot reach a browser at all, force the device-code flow: + +```bash +az login --use-device-code +``` + +Tokens are saved only in the `~/.azure` volume. Radius CLI state is separate. Challenge 02 creates `ws-azure-prod` and `ws-local-prod` under `~/.rad/config.yaml`; it also configures the workload identity 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 ce43f5359..d632bca3c 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 @@ -30,12 +30,18 @@ Common blockers: | Blocker | Guidance | | --- | --- | -| Azure cannot allocate the requested VM size | Set `AKS_NODE_VM_SIZE` or `K3S_VM_SIZE` to an available size, or choose another `AZURE_LOCATION`. | +| Azure cannot allocate the requested VM size | The scripts detect this and fall back automatically. Override the ordered list with `AKS_NODE_VM_SIZE_CANDIDATES` or `K3S_VM_SIZE_CANDIDATES`, or choose another `AZURE_LOCATION`. | | AKS nodes remain `NotReady` | Check `az aks show --query provisioningState` and allow time for the initial node image pull. | | K3s API is unreachable | Run the helper in `connect` mode and inspect the recorded tunnel log. | | Bastion or networking is denied | Confirm FDPO policy, region/SKU, Network Contributor, VM Run Command, and Bastion tunnel permissions. | | Commands target the wrong cluster | Check both `KUBECONFIG` and `kubectl config current-context`. | +Both scripts resolve the node or VM size before creating anything. They list the sizes +the subscription can use in the target region, ignore `Zone`-only restrictions because +the deployment is regional, and pick the first candidate that is offered. If the +allocation still fails with a capacity or quota error, the failed resource is removed and +the next candidate is tried. Authorization and policy failures are not retried. + ## Task 1: Provision AKS The script creates or updates an Azure resource group and a two-node AKS cluster, @@ -63,8 +69,12 @@ Optional settings: ```bash export AKS_NODE_COUNT=2 export AKS_NODE_VM_SIZE="Standard_D4s_v5" +export AKS_NODE_VM_SIZE_CANDIDATES="Standard_D4s_v5 Standard_D4s_v4 Standard_D4s_v3" ``` +`AKS_NODE_VM_SIZE` is the preferred size. `AKS_NODE_VM_SIZE_CANDIDATES` replaces the +whole ordered fallback list when a region needs different sizes. + The script merges AKS credentials into the default kubeconfig and activates the context named after `AKS_CLUSTER`. @@ -94,6 +104,7 @@ Optional settings: ```bash export K3S_VM_SIZE="Standard_D4s_v5" +export K3S_VM_SIZE_CANDIDATES="Standard_D4s_v5 Standard_D4s_v4 Standard_D4s_v3" export K3S_ADMIN_USERNAME="azureuser" export K3S_KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" export VNET_PREFIX="10.42.0.0/16" @@ -103,8 +114,14 @@ export K3S_LOCAL_PORT=16443 ``` The generated kubeconfig points to `https://127.0.0.1:16443`. Its credentials are -retrieved in bounded chunks and are never printed. After reopening or rebuilding the -devcontainer, re-establish the tunnel without changing Azure resources: +retrieved in bounded chunks and are never printed. The script also registers the +`k3s-azure-vm` context in the default `~/.kube/config` without changing the active +context, because the Radius CLI resolves part of its installation through the default +kubeconfig rather than through `KUBECONFIG`. Explicitly exporting `KUBECONFIG` remains +the documented way to target K3s. + +After reopening or rebuilding the devcontainer, re-establish the tunnel without changing +Azure resources: ```bash export AZURE_SUBSCRIPTION="" @@ -123,8 +140,11 @@ bash resources/prepare-k3s-azure-vm.sh disconnect ``` The script records the exact PID and verifies its command line before sending -`SIGTERM`; it never kills by process name. The tunnel exposes only the Kubernetes API. -Use `kubectl port-forward` for later Radius and application access. +`SIGTERM`; it never kills by process name. The Azure CLI launcher runs its Python entry +point as a child process, so `disconnect` stops every process whose command line matches +this tunnel's Bastion name, resource group, and both ports. A tunnel orphaned by a +crashed shell is reclaimed automatically on the next `connect`. The tunnel exposes only +the Kubernetes API. Use `kubectl port-forward` for later Radius and application access. Return to AKS: 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 398c6a081..4a5556daa 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 @@ -284,7 +284,7 @@ kubectl wait --for=condition=Available deployments --all \ --namespace radius-system \ --timeout=10m kubectl get pods --namespace radius-system -kubectl get crd applications.radapp.io +kubectl get crd recipes.radapp.io deploymenttemplates.radapp.io deploymentresources.radapp.io rad workspace list rad env list 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 dec4e9f73..148a06d11 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 @@ -101,11 +101,14 @@ Run against each context: ```bash helm status core --namespace core -kubectl rollout status deployment --all --namespace core --timeout=15m -kubectl rollout status statefulset --all --namespace core --timeout=15m +kubectl wait --for=condition=Available deployments --all --namespace core --timeout=15m +kubectl rollout status statefulset/core-keycloak-postgresql --namespace core --timeout=15m kubectl get pods --namespace core ``` +`kubectl rollout status` accepts a single object, so use `kubectl wait` for the +deployments and name each statefulset explicitly. + Do not continue until the workloads are healthy. ## Resource-type concepts 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 88f7fffea..c6274e894 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 @@ -359,3 +359,17 @@ bash resources/configure-recipes.sh k3s The script creates or reuses ACR, publishes the custom SQL recipe for AKS, deploys the appropriate environment definition, and verifies the registered recipes. + +> [!IMPORTANT] +> Run Challenge 03 first, on the same platform. `iac/aks-env.bicep` and +> `iac/local-env.bicep` register recipes against the `Radius.Resources` types that +> `resources/configure-resource-types-aks.sh` and +> `resources/configure-resource-types-k3s.sh` create. Skipping Challenge 03 makes this +> script fail on unknown resource types. + +The AKS path needs the K3s tunnel only when it is run as `all`. Reconnect first if the +devcontainer was restarted: + +```bash +bash resources/prepare-k3s-azure-vm.sh connect +``` From f82d59617b3ba2c49448a90ef73c0aa5b9ed186d Mon Sep 17 00:00:00 2001 From: Benjamin Van Brussel Date: Fri, 21 Aug 2026 10:46:19 +0200 Subject: [PATCH 10/29] Handle Azure default outbound access retirement for the K3s VM The private K3s VM has no inbound exposure but still needs outbound access to download the K3s installer and pull images. It relied on implicit default outbound access, which Azure is retiring: for API versions released after 2026-03-31, new virtual networks default to private subnets with no outbound path. The failure therefore depends on the Azure CLI version that creates the subnet rather than on the calendar date, and would have appeared as a silent regression on a future CLI. Measured on 2026-08-21 against a live subscription: - a subnet created by az 2.89.1 has defaultOutboundAccess=null, so the platform default still applies and outbound works - a private subnet blocks the installer: curl https://get.k3s.io fails - setting defaultOutboundAccess=true does not take effect on a running VM; outbound is restored only after a deallocate and start - a NAT gateway restores outbound on a private subnet within seconds and needs no restart prepare-k3s-azure-vm.sh now sets the outbound posture explicitly instead of inheriting the platform default: - by default it creates snet-k3s with defaultOutboundAccess=true, which keeps the current zero-cost topology working under any CLI version - K3S_ENABLE_NAT_GATEWAY=true leaves the subnet private and attaches natgw-adaptive-apps with a Standard public IP, which is required when policy forbids default outbound access and is the durable long-term option - an existing private subnet without a NAT gateway is reopened, and the VM is deallocated and started so the change applies - the guest installer reports a dedicated no-outbound marker, so the script fails with actionable guidance instead of a generic K3s error Documented the postures, their cost, and NAT gateway cleanup in docs/prepare-k3s.md and walkthrough/challenge-01/solution-01.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../04-adaptive-apps/docs/prepare-k3s.md | 47 ++++++++ .../resources/prepare-k3s-azure-vm.sh | 114 +++++++++++++++++- .../walkthrough/challenge-01/solution-01.md | 27 +++++ 3 files changed, 187 insertions(+), 1 deletion(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md index 56357c89e..5856a87ec 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md @@ -26,6 +26,39 @@ An enterprise with stricter requirements can substitute Azure Bastion Premium wi private-only deployment capability. That substitution requires platform-owned networking and is not the workshop default. +## Outbound internet access + +The VM has no public IP and no inbound internet exposure, but it still needs *outbound* +access to install K3s from `get.k3s.io` and to pull container images. + +Azure is retiring implicit default outbound access. For API versions released after +2026-03-31, new virtual networks default to private subnets, where a VM has no outbound +path at all unless an explicit method is configured. The behavior therefore depends on +the Azure CLI version that creates the subnet, not on the calendar date. + +The script does not rely on the platform default. It selects one of two explicit +postures: + +| Posture | How to select | Behavior | Cost | +| --- | --- | --- | --- | +| Default outbound access, set explicitly | default | Creates `snet-k3s` with `defaultOutboundAccess=true`, so the subnet keeps a working outbound path even under a newer CLI. | None | +| NAT gateway | `K3S_ENABLE_NAT_GATEWAY=true` | Leaves the subnet private and attaches `natgw-adaptive-apps` with a Standard public IP. Deterministic egress IP. | Hourly NAT gateway charge plus per-GB data processing | + +```bash +export K3S_ENABLE_NAT_GATEWAY=true +bash resources/prepare-k3s-azure-vm.sh +``` + +Choose the NAT gateway when an Azure Policy forbids default outbound access, when a +firewall must allowlist a stable egress IP, or to align with the long-term Azure +direction. Default outbound access is scheduled for retirement, so the NAT gateway is the +durable option. + +If the subnet already exists and is private with no NAT gateway, the script reopens it +and restarts the VM, because a change to `defaultOutboundAccess` only takes effect after +the VM is deallocated and started again. When the installer still cannot reach the +internet, the script fails with an explicit message instead of a generic K3s error. + ## Provision and connect From the Adaptive Apps MicroHack root: @@ -124,6 +157,7 @@ role containing `Microsoft.Network/bastionHosts/tunnels/action`. | Local port is occupied | Stop the known owner or set a different `K3S_LOCAL_PORT`; the script will not kill an unrecorded process. | | `kubectl` cannot reach 127.0.0.1 | Run `connect`, confirm `status`, and verify the kubeconfig local port matches `K3S_LOCAL_PORT`. | | Existing VM has a public IP | Follow the script's migration message. It deliberately refuses to leave the legacy public topology in place. | +| K3s installer cannot be downloaded | The subnet has no outbound path. Re-run with `K3S_ENABLE_NAT_GATEWAY=true`, which is also required when policy forbids default outbound access. | JIT access is not used. JIT only opens an NSG rule for a limited time; it does not create a network route to a private VM. RDP is irrelevant to this Linux VM. Bastion provides the @@ -145,6 +179,17 @@ az network public-ip delete \ --name pip-adaptive-apps-bastion ``` +A NAT gateway, when enabled, also bills hourly. Remove it and its public IP as well: + +```bash +az network nat gateway delete \ + --resource-group "$RESOURCE_GROUP" \ + --name natgw-adaptive-apps +az network public-ip delete \ + --resource-group "$RESOURCE_GROUP" \ + --name pip-adaptive-apps-nat +``` + If the resource group is dedicated to this MicroHack and its contents have been reviewed, the coach can remove the entire group separately. The script does not automate deletion. @@ -159,3 +204,5 @@ in Challenge 02. - [Configure Bastion native-client connections](https://learn.microsoft.com/azure/bastion/native-client) - [Azure Bastion NSG guidance](https://learn.microsoft.com/azure/bastion/bastion-nsg) - [Azure CLI Bastion reference](https://learn.microsoft.com/cli/azure/network/bastion) +- [Default outbound access in Azure](https://learn.microsoft.com/azure/virtual-network/ip-services/default-outbound-access) +- [Design virtual networks with NAT gateway](https://learn.microsoft.com/azure/nat-gateway/nat-gateway-resource) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh index c69f4395b..38f096a1e 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh @@ -36,6 +36,16 @@ K3S_ADMIN_USERNAME="${K3S_ADMIN_USERNAME:-azureuser}" BASTION_PUBLIC_IP_NAME="${BASTION_PUBLIC_IP_NAME:-pip-adaptive-apps-bastion}" BASTION_NAME="${BASTION_NAME:-bas-adaptive-apps}" K3S_LOCAL_PORT="${K3S_LOCAL_PORT:-16443}" +# Outbound internet is required to install K3s and pull container images. Azure is +# retiring implicit default outbound access: for API versions released after +# 2026-03-31, new virtual networks default to private subnets with no outbound path. +# The script therefore sets the subnet's outbound posture explicitly instead of relying +# on the platform default, which would otherwise change silently under a newer CLI. +# Set K3S_ENABLE_NAT_GATEWAY=true for an explicit, future-proof NAT gateway. It adds +# hourly cost and per-GB data processing charges. +K3S_ENABLE_NAT_GATEWAY="${K3S_ENABLE_NAT_GATEWAY:-false}" +NAT_GATEWAY_NAME="${NAT_GATEWAY_NAME:-natgw-adaptive-apps}" +NAT_PUBLIC_IP_NAME="${NAT_PUBLIC_IP_NAME:-pip-adaptive-apps-nat}" K3S_KUBECONFIG="${K3S_KUBECONFIG:-$HOME/.kube/adaptive-apps-k3s.yaml}" K3S_CONTEXT="${K3S_CONTEXT:-k3s-azure-vm}" K3S_TUNNEL_STATE_DIR="${K3S_TUNNEL_STATE_DIR:-$HOME/.kube/adaptive-apps-bastion}" @@ -496,7 +506,9 @@ create_k3s_vm() { create_or_validate_subnet() { local subnet_name="$1" local subnet_prefix="$2" + local default_outbound="${3:-}" local existing_prefix + local -a create_args=() existing_prefix="$(az network vnet subnet show \ --resource-group "$RESOURCE_GROUP" \ @@ -505,11 +517,15 @@ create_or_validate_subnet() { --query addressPrefix \ --output tsv 2>/dev/null || true)" if [[ -z "$existing_prefix" ]]; then + if [[ -n "$default_outbound" ]]; then + create_args+=(--default-outbound "$default_outbound") + fi az network vnet subnet create \ --resource-group "$RESOURCE_GROUP" \ --vnet-name "$VNET_NAME" \ --name "$subnet_name" \ --address-prefixes "$subnet_prefix" \ + "${create_args[@]}" \ --output none elif [[ "$existing_prefix" != "$subnet_prefix" ]]; then echo "Subnet ${subnet_name} uses ${existing_prefix}, expected ${subnet_prefix}." >&2 @@ -518,6 +534,77 @@ create_or_validate_subnet() { fi } +# Guarantee the K3s subnet has a working outbound path, whichever posture is selected. +ensure_k3s_outbound() { + local current_nat + local current_default_outbound + + current_nat="$(az network vnet subnet show \ + --resource-group "$RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$K3S_SUBNET_NAME" \ + --query "natGateway.id" \ + --output tsv 2>/dev/null || true)" + + if [[ "${K3S_ENABLE_NAT_GATEWAY,,}" == "true" ]]; then + if [[ -z "$current_nat" ]]; then + echo "Configuring an explicit NAT gateway for outbound access." + az network public-ip show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$NAT_PUBLIC_IP_NAME" >/dev/null 2>&1 || + az network public-ip create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$NAT_PUBLIC_IP_NAME" \ + --location "$AZURE_LOCATION" \ + --sku Standard \ + --allocation-method Static \ + --version IPv4 \ + --output none + az network nat gateway show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$NAT_GATEWAY_NAME" >/dev/null 2>&1 || + az network nat gateway create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$NAT_GATEWAY_NAME" \ + --location "$AZURE_LOCATION" \ + --public-ip-addresses "$NAT_PUBLIC_IP_NAME" \ + --idle-timeout 10 \ + --output none + az network vnet subnet update \ + --resource-group "$RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$K3S_SUBNET_NAME" \ + --nat-gateway "$NAT_GATEWAY_NAME" \ + --output none + fi + return 0 + fi + + if [[ -n "$current_nat" ]]; then + return 0 + fi + + current_default_outbound="$(az network vnet subnet show \ + --resource-group "$RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$K3S_SUBNET_NAME" \ + --query "defaultOutboundAccess" \ + --output tsv 2>/dev/null || true)" + + # An explicit false blocks the K3s installer. Reopening the subnet only takes effect + # after the VM is deallocated and started again, which the caller handles. + if [[ "${current_default_outbound,,}" == "false" ]]; then + echo "Subnet ${K3S_SUBNET_NAME} is private and has no NAT gateway; enabling default outbound access." >&2 + az network vnet subnet update \ + --resource-group "$RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$K3S_SUBNET_NAME" \ + --default-outbound true \ + --output none + K3S_OUTBOUND_REOPENED=1 + fi +} + echo "Provisioning private K3s network and Azure Bastion." echo "FDPO policy denials require a compliant region/SKU or an administrator-approved exemption." echo "Creating network resources requires Contributor or Network Contributor permissions." @@ -540,8 +627,15 @@ elif ! az network vnet show \ exit 1 fi -create_or_validate_subnet "$K3S_SUBNET_NAME" "$K3S_SUBNET_PREFIX" +K3S_OUTBOUND_REOPENED=0 + +if [[ "${K3S_ENABLE_NAT_GATEWAY,,}" == "true" ]]; then + create_or_validate_subnet "$K3S_SUBNET_NAME" "$K3S_SUBNET_PREFIX" +else + create_or_validate_subnet "$K3S_SUBNET_NAME" "$K3S_SUBNET_PREFIX" true +fi create_or_validate_subnet "AzureBastionSubnet" "$BASTION_SUBNET_PREFIX" +ensure_k3s_outbound az network nsg create \ --resource-group "$RESOURCE_GROUP" \ @@ -612,6 +706,11 @@ if az vm show --resource-group "$RESOURCE_GROUP" --name "$K3S_VM_NAME" >/dev/nul --ids "$EXISTING_NIC_ID" \ --network-security-group "$K3S_NSG_NAME" \ --output none + # A subnet reopened for outbound only applies to a VM after a deallocate/start cycle. + if ((K3S_OUTBOUND_REOPENED == 1)); then + echo "Restarting ${K3S_VM_NAME} so it picks up the restored outbound path." + az vm deallocate --resource-group "$RESOURCE_GROUP" --name "$K3S_VM_NAME" --output none + fi az vm start --resource-group "$RESOURCE_GROUP" --name "$K3S_VM_NAME" --output none else if ! az network nic show --resource-group "$RESOURCE_GROUP" --name "$K3S_NIC_NAME" >/dev/null 2>&1; then @@ -777,6 +876,10 @@ if command -v k3s >/dev/null 2>&1; then systemctl enable --now k3s systemctl restart k3s else + if ! curl -sfI --max-time 20 https://get.k3s.io >/dev/null 2>&1; then + printf 'K3S_NO_OUTBOUND\n' + exit 1 + fi curl -sfL https://get.k3s.io --output /tmp/install-k3s.sh sh /tmp/install-k3s.sh rm -f /tmp/install-k3s.sh @@ -786,6 +889,15 @@ printf 'K3S_INSTALL_OK\n' " \ --output json)" readonly K3S_INSTALL_RESPONSE +if jq -r '.value[]?.message // empty' <<<"$K3S_INSTALL_RESPONSE" | + grep -qx 'K3S_NO_OUTBOUND'; then + echo "The K3s VM has no outbound internet access, so the installer could not be downloaded." >&2 + echo "Azure is retiring implicit default outbound access; subnets created by newer API" >&2 + echo "versions are private by default and need an explicit outbound method." >&2 + echo "Re-run with a NAT gateway: K3S_ENABLE_NAT_GATEWAY=true bash resources/prepare-k3s-azure-vm.sh" >&2 + echo "If an Azure Policy denies default outbound access, the NAT gateway is required." >&2 + exit 1 +fi jq -r '.value[]?.message // empty' <<<"$K3S_INSTALL_RESPONSE" | grep -qx 'K3S_INSTALL_OK' || { echo "K3s guest installation did not return its success marker." >&2 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 d632bca3c..dc3c391ef 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 @@ -34,6 +34,7 @@ Common blockers: | AKS nodes remain `NotReady` | Check `az aks show --query provisioningState` and allow time for the initial node image pull. | | K3s API is unreachable | Run the helper in `connect` mode and inspect the recorded tunnel log. | | Bastion or networking is denied | Confirm FDPO policy, region/SKU, Network Contributor, VM Run Command, and Bastion tunnel permissions. | +| K3s installer cannot be downloaded | The subnet has no outbound path. Re-run with `K3S_ENABLE_NAT_GATEWAY=true`. | | Commands target the wrong cluster | Check both `KUBECONFIG` and `kubectl config current-context`. | Both scripts resolve the node or VM size before creating anything. They list the sizes @@ -111,8 +112,34 @@ export VNET_PREFIX="10.42.0.0/16" export K3S_SUBNET_PREFIX="10.42.0.0/24" export BASTION_SUBNET_PREFIX="10.42.1.0/26" export K3S_LOCAL_PORT=16443 +export K3S_ENABLE_NAT_GATEWAY=false ``` +### Outbound access for the private VM + +The VM has no inbound internet exposure, but it needs outbound access to download K3s and +pull images. Azure is retiring implicit default outbound access: for API versions released +after 2026-03-31, new virtual networks default to private subnets with no outbound path, +so the behavior depends on the Azure CLI version rather than the date. + +The script sets the posture explicitly instead of inheriting the platform default: + +| Posture | Selection | Cost | +| --- | --- | --- | +| Default outbound access, set explicitly on `snet-k3s` | default | None | +| NAT gateway, subnet stays private | `K3S_ENABLE_NAT_GATEWAY=true` | Hourly charge plus per-GB data processing | + +```bash +export K3S_ENABLE_NAT_GATEWAY=true +bash resources/prepare-k3s-azure-vm.sh +``` + +Use the NAT gateway when an Azure Policy forbids default outbound access, when a firewall +must allowlist a stable egress IP, or to match the long-term Azure direction. If the +subnet is already private without a NAT gateway, the script reopens it and restarts the +VM, because the change only applies after a deallocate and start. Remember to delete the +NAT gateway and its public IP during cleanup. + The generated kubeconfig points to `https://127.0.0.1:16443`. Its credentials are retrieved in bounded chunks and are never printed. The script also registers the `k3s-azure-vm` context in the default `~/.kube/config` without changing the active From 002d38915d1ee8a131e385f78fe4ec56bb3ebf12 Mon Sep 17 00:00:00 2001 From: Benjamin Van Brussel Date: Fri, 21 Aug 2026 11:09:14 +0200 Subject: [PATCH 11/29] Make the NAT gateway the default outbound path for the K3s VM Default outbound access is itself scheduled for retirement, so relying on it only defers the problem. The NAT gateway is now the default: it is explicit, survives the retirement, and gives a deterministic egress IP that a firewall can allowlist. K3S_ENABLE_NAT_GATEWAY=false keeps the previous zero-cost posture where policy still permits default outbound access. The K3s subnet is now kept explicitly private when the NAT gateway is used. Azure can still assign a platform-owned fallback outbound IP to a nonprivate subnet even when a NAT gateway is attached, which would defeat the deterministic egress address. Reconciling an existing nonprivate subnet also flags the VM for a deallocate and start, because removing an already-assigned fallback IP only takes effect after that cycle. Verified against a live subscription: - default path creates the subnet private, attaches the NAT gateway, and needs no restart - re-running is idempotent - a nonprivate subnet is converted to private and flagged for restart - opting out with an existing NAT gateway leaves it in place rather than removing an in-use egress path - egress resolves to the NAT public IP Updated the prerequisites, walkthrough, and K3s reference for the new default, including NAT gateway permissions, the second Standard public IP, and cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../challenges/challenge-00.md | 6 ++- .../04-adaptive-apps/docs/prepare-k3s.md | 37 +++++++++++------- .../resources/prepare-k3s-azure-vm.sh | 38 ++++++++++++------- .../walkthrough/challenge-00/solution-00.md | 5 ++- .../walkthrough/challenge-01/solution-01.md | 25 +++++++----- 5 files changed, 70 insertions(+), 41 deletions(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md index e2ebbf019..4398acb8d 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md @@ -40,9 +40,11 @@ Confirm that your workshop team has: - An Azure subscription - Permission to create a resource group, AKS cluster, Linux VM, networking resources, - Azure Bastion Standard, its managed public IP, VM Run Command, and role assignments + 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 -- Regional quota for the AKS nodes, K3s VM, Azure Bastion, and Bastion public IP +- 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 is delegated, and permission to open Bastion native-client tunnels diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md index 5856a87ec..77a5df82e 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md @@ -15,6 +15,8 @@ The preparation script creates or reuses: - Private Ubuntu VM `vm-adaptive-apps-k3s` - Standard static public IP `pip-adaptive-apps-bastion` for Azure Bastion only - Standard host `bas-adaptive-apps` with native client tunneling enabled +- NAT gateway `natgw-adaptive-apps` and its Standard static public IP + `pip-adaptive-apps-nat` for outbound access only The VM NSG allows TCP 22 and 6443 only from `AzureBastionSubnet`, then explicitly denies all other inbound traffic. K3s installation and kubeconfig retrieval use Azure VM Run @@ -41,23 +43,28 @@ postures: | Posture | How to select | Behavior | Cost | | --- | --- | --- | --- | -| Default outbound access, set explicitly | default | Creates `snet-k3s` with `defaultOutboundAccess=true`, so the subnet keeps a working outbound path even under a newer CLI. | None | -| NAT gateway | `K3S_ENABLE_NAT_GATEWAY=true` | Leaves the subnet private and attaches `natgw-adaptive-apps` with a Standard public IP. Deterministic egress IP. | Hourly NAT gateway charge plus per-GB data processing | +| NAT gateway | default | Keeps `snet-k3s` private and attaches `natgw-adaptive-apps` with a Standard public IP. The NAT public IP is the only egress address. | Hourly NAT gateway charge plus per-GB data processing | +| Default outbound access, set explicitly | `K3S_ENABLE_NAT_GATEWAY=false` | Sets `defaultOutboundAccess=true` on `snet-k3s`, so the subnet keeps a working outbound path even under a newer CLI. | None | ```bash -export K3S_ENABLE_NAT_GATEWAY=true +export K3S_ENABLE_NAT_GATEWAY=false bash resources/prepare-k3s-azure-vm.sh ``` -Choose the NAT gateway when an Azure Policy forbids default outbound access, when a -firewall must allowlist a stable egress IP, or to align with the long-term Azure -direction. Default outbound access is scheduled for retirement, so the NAT gateway is the -durable option. +The NAT gateway is the default because it is explicit, survives the retirement of default +outbound access, and provides a deterministic egress IP that a firewall can allowlist. +The subnet is kept private on purpose: a nonprivate subnet can still receive a +platform-assigned fallback outbound IP alongside the NAT gateway, which would defeat the +deterministic egress address. -If the subnet already exists and is private with no NAT gateway, the script reopens it -and restarts the VM, because a change to `defaultOutboundAccess` only takes effect after -the VM is deallocated and started again. When the installer still cannot reach the -internet, the script fails with an explicit message instead of a generic K3s error. +Choose `K3S_ENABLE_NAT_GATEWAY=false` only to avoid the NAT gateway cost, and only where +Azure Policy still permits default outbound access. + +If the subnet posture changes on an existing deployment, the script reconciles it and +restarts the VM, because both adding and removing a platform-assigned outbound IP only +take effect after the VM is deallocated and started again. When the installer still +cannot reach the internet, the script fails with an explicit message instead of a generic +K3s error. ## Provision and connect @@ -157,7 +164,7 @@ role containing `Microsoft.Network/bastionHosts/tunnels/action`. | Local port is occupied | Stop the known owner or set a different `K3S_LOCAL_PORT`; the script will not kill an unrecorded process. | | `kubectl` cannot reach 127.0.0.1 | Run `connect`, confirm `status`, and verify the kubeconfig local port matches `K3S_LOCAL_PORT`. | | Existing VM has a public IP | Follow the script's migration message. It deliberately refuses to leave the legacy public topology in place. | -| K3s installer cannot be downloaded | The subnet has no outbound path. Re-run with `K3S_ENABLE_NAT_GATEWAY=true`, which is also required when policy forbids default outbound access. | +| K3s installer cannot be downloaded | The subnet has no outbound path. Confirm the NAT gateway exists and is attached to `snet-k3s`, or re-run with `K3S_ENABLE_NAT_GATEWAY=false` where policy still allows default outbound access. | JIT access is not used. JIT only opens an NSG rule for a limited time; it does not create a network route to a private VM. RDP is irrelevant to this Linux VM. Bastion provides the @@ -166,7 +173,8 @@ private route, while SSH is an optional Linux management protocol. ## Cost and cleanup Azure Bastion billing starts when the host is deployed and continues even when no tunnel -is active. After the workshop: +is active. The NAT gateway also bills hourly, plus per-GB data processing. After the +workshop: ```bash bash resources/prepare-k3s-azure-vm.sh disconnect @@ -179,7 +187,8 @@ az network public-ip delete \ --name pip-adaptive-apps-bastion ``` -A NAT gateway, when enabled, also bills hourly. Remove it and its public IP as well: +The NAT gateway is deployed by default and bills hourly. Remove it and its public IP as +well: ```bash az network nat gateway delete \ diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh index 38f096a1e..3f22e2c6e 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh @@ -39,11 +39,11 @@ K3S_LOCAL_PORT="${K3S_LOCAL_PORT:-16443}" # Outbound internet is required to install K3s and pull container images. Azure is # retiring implicit default outbound access: for API versions released after # 2026-03-31, new virtual networks default to private subnets with no outbound path. -# The script therefore sets the subnet's outbound posture explicitly instead of relying -# on the platform default, which would otherwise change silently under a newer CLI. -# Set K3S_ENABLE_NAT_GATEWAY=true for an explicit, future-proof NAT gateway. It adds -# hourly cost and per-GB data processing charges. -K3S_ENABLE_NAT_GATEWAY="${K3S_ENABLE_NAT_GATEWAY:-false}" +# The default here is an explicit NAT gateway, which keeps the subnet private, gives a +# deterministic egress IP, and is the durable option. It adds an hourly charge plus +# per-GB data processing. Set K3S_ENABLE_NAT_GATEWAY=false to fall back to explicitly +# enabled default outbound access, which is free but is itself being retired. +K3S_ENABLE_NAT_GATEWAY="${K3S_ENABLE_NAT_GATEWAY:-true}" NAT_GATEWAY_NAME="${NAT_GATEWAY_NAME:-natgw-adaptive-apps}" NAT_PUBLIC_IP_NAME="${NAT_PUBLIC_IP_NAME:-pip-adaptive-apps-nat}" K3S_KUBECONFIG="${K3S_KUBECONFIG:-$HOME/.kube/adaptive-apps-k3s.yaml}" @@ -545,6 +545,12 @@ ensure_k3s_outbound() { --name "$K3S_SUBNET_NAME" \ --query "natGateway.id" \ --output tsv 2>/dev/null || true)" + current_default_outbound="$(az network vnet subnet show \ + --resource-group "$RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$K3S_SUBNET_NAME" \ + --query "defaultOutboundAccess" \ + --output tsv 2>/dev/null || true)" if [[ "${K3S_ENABLE_NAT_GATEWAY,,}" == "true" ]]; then if [[ -z "$current_nat" ]]; then @@ -577,6 +583,19 @@ ensure_k3s_outbound() { --nat-gateway "$NAT_GATEWAY_NAME" \ --output none fi + + # A nonprivate subnet can still receive a platform-assigned fallback outbound IP + # alongside the NAT gateway. Keeping it private makes the NAT public IP the only + # egress address. Removing an already-assigned fallback IP needs a VM restart. + if [[ "${current_default_outbound,,}" != "false" ]]; then + az network vnet subnet update \ + --resource-group "$RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$K3S_SUBNET_NAME" \ + --default-outbound false \ + --output none + [[ -z "$current_default_outbound" ]] || K3S_OUTBOUND_REOPENED=1 + fi return 0 fi @@ -584,13 +603,6 @@ ensure_k3s_outbound() { return 0 fi - current_default_outbound="$(az network vnet subnet show \ - --resource-group "$RESOURCE_GROUP" \ - --vnet-name "$VNET_NAME" \ - --name "$K3S_SUBNET_NAME" \ - --query "defaultOutboundAccess" \ - --output tsv 2>/dev/null || true)" - # An explicit false blocks the K3s installer. Reopening the subnet only takes effect # after the VM is deallocated and started again, which the caller handles. if [[ "${current_default_outbound,,}" == "false" ]]; then @@ -630,7 +642,7 @@ fi K3S_OUTBOUND_REOPENED=0 if [[ "${K3S_ENABLE_NAT_GATEWAY,,}" == "true" ]]; then - create_or_validate_subnet "$K3S_SUBNET_NAME" "$K3S_SUBNET_PREFIX" + create_or_validate_subnet "$K3S_SUBNET_NAME" "$K3S_SUBNET_PREFIX" false else create_or_validate_subnet "$K3S_SUBNET_NAME" "$K3S_SUBNET_PREFIX" true fi diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md index 0e06fb663..43113a75c 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md @@ -17,13 +17,14 @@ Confirm the workshop has: - An Azure subscription - Permission to create a resource group, AKS cluster, Linux VM, networking, and role assignments -- Permission to create Azure Bastion Standard and its managed public IP, invoke VM Run - Command, and open Bastion native-client tunnels +- Permission to create Azure Bastion Standard and its managed public IP, a NAT gateway + and its public IP, invoke VM Run Command, and open Bastion native-client tunnels - Permission to create a Microsoft Entra application and service principal - Sufficient regional quota for: - A two-node AKS cluster using `Standard_D4s_v5` by default - One K3s VM using `Standard_D4s_v5` by default - One Standard Azure Bastion host and its managed public IP + - One NAT gateway and one Standard public IP for K3s outbound access The default scripts assign the Radius identity `Owner` at the lab resource-group scope because later exercises can create role assignments. Use a dedicated lab 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 dc3c391ef..e558807c4 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 @@ -34,7 +34,7 @@ Common blockers: | AKS nodes remain `NotReady` | Check `az aks show --query provisioningState` and allow time for the initial node image pull. | | K3s API is unreachable | Run the helper in `connect` mode and inspect the recorded tunnel log. | | Bastion or networking is denied | Confirm FDPO policy, region/SKU, Network Contributor, VM Run Command, and Bastion tunnel permissions. | -| K3s installer cannot be downloaded | The subnet has no outbound path. Re-run with `K3S_ENABLE_NAT_GATEWAY=true`. | +| K3s installer cannot be downloaded | The subnet has no outbound path. Confirm the NAT gateway is attached, or re-run with `K3S_ENABLE_NAT_GATEWAY=false` where policy allows it. | | Commands target the wrong cluster | Check both `KUBECONFIG` and `kubectl config current-context`. | Both scripts resolve the node or VM size before creating anything. They list the sizes @@ -112,7 +112,7 @@ export VNET_PREFIX="10.42.0.0/16" export K3S_SUBNET_PREFIX="10.42.0.0/24" export BASTION_SUBNET_PREFIX="10.42.1.0/26" export K3S_LOCAL_PORT=16443 -export K3S_ENABLE_NAT_GATEWAY=false +export K3S_ENABLE_NAT_GATEWAY=true ``` ### Outbound access for the private VM @@ -126,19 +126,24 @@ The script sets the posture explicitly instead of inheriting the platform defaul | Posture | Selection | Cost | | --- | --- | --- | -| Default outbound access, set explicitly on `snet-k3s` | default | None | -| NAT gateway, subnet stays private | `K3S_ENABLE_NAT_GATEWAY=true` | Hourly charge plus per-GB data processing | +| NAT gateway, subnet stays private | default | Hourly charge plus per-GB data processing | +| Default outbound access, set explicitly on `snet-k3s` | `K3S_ENABLE_NAT_GATEWAY=false` | None | ```bash -export K3S_ENABLE_NAT_GATEWAY=true +export K3S_ENABLE_NAT_GATEWAY=false bash resources/prepare-k3s-azure-vm.sh ``` -Use the NAT gateway when an Azure Policy forbids default outbound access, when a firewall -must allowlist a stable egress IP, or to match the long-term Azure direction. If the -subnet is already private without a NAT gateway, the script reopens it and restarts the -VM, because the change only applies after a deallocate and start. Remember to delete the -NAT gateway and its public IP during cleanup. +The NAT gateway is the default because it is explicit, survives the retirement of default +outbound access, and gives a deterministic egress IP that a firewall can allowlist. The +subnet is deliberately kept private, because a nonprivate subnet can still receive a +platform-assigned fallback outbound IP alongside the NAT gateway. + +Use `K3S_ENABLE_NAT_GATEWAY=false` only to avoid the NAT gateway cost, and only where +Azure Policy still permits default outbound access. If the posture changes on an existing +deployment, the script reconciles it and restarts the VM, because the change only applies +after a deallocate and start. Remember to delete the NAT gateway and its public IP during +cleanup. The generated kubeconfig points to `https://127.0.0.1:16443`. Its credentials are retrieved in bounded chunks and are never printed. The script also registers the From 51d02117b63a083e431ad885a3f1980a5c684866 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Fri, 21 Aug 2026 13:48:06 +0200 Subject: [PATCH 12/29] Explain resource type validation output Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../walkthrough/challenge-03/solution-03.md | 184 +++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) 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 148a06d11..8a898e366 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 @@ -265,7 +265,105 @@ providers, AI models, governance, and agent guardrails. ## Explore and validate -In each workspace: +Run this section once for each target pair: + +| Platform | Kubernetes context | Radius workspace | Group | Environment | +| --- | --- | --- | --- | --- | +| AKS | `aks-adaptive-apps` | `ws-azure-prod` | `rg-trading` | `env-azure-prod` | +| Private K3s through Bastion | `k3s-azure-vm` | `ws-local-prod` | `rg-trading` | `env-local-prod` | + +The Kubernetes context selects cluster objects such as the portfolio and Radius +dashboard. The Radius workspace selects the independent Radius control plane queried by +`rad resource-type`. Those selectors do not change each other. + +### 1. Select and confirm one target + +For AKS: + +```bash +unset KUBECONFIG +kubectl config use-context aks-adaptive-apps +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod +``` + +For K3s, restore the localhost Bastion API tunnel first. The dedicated kubeconfig points +to that local endpoint; it does not expose K3s publicly. + +```bash +export 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 +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod +``` + +These commands select existing objects; they do not create another cluster, workspace, +group, or environment. `kubectl config use-context` reports the context it selected, +the three `rad ... switch` commands update local CLI defaults, and +`prepare-k3s-azure-vm.sh connect` starts or reuses the localhost Bastion tunnel needed +to reach the private K3s API. + +Now inspect the four selectors: + +```bash +kubectl config current-context +rad workspace show +rad group show rg-trading +rad env show +``` + +| Command | Stable indicators | Why it exists and when it was created | Scope | +| --- | --- | --- | --- | +| `kubectl config current-context` | Exactly `aks-adaptive-apps` or `k3s-azure-vm` for the row being validated | Challenge 01 created or retrieved the cluster credentials; Challenge 02 selected the context before installing Radius | Local kubeconfig selection; subsequent `kubectl` commands reach that Kubernetes cluster | +| `rad workspace show` | Workspace name `ws-azure-prod` with connection context `aks-adaptive-apps`, or `ws-local-prod` with `k3s-azure-vm` | Solution 02 **AKS Step 4** or **K3s Step 3** ran `rad workspace create kubernetes`; `rad workspace switch` selected it | Local CLI configuration in `~/.rad/config.yaml` that points to one Radius control plane | +| `rad group show rg-trading` | Group name `rg-trading`; it is a Radius group, not an Azure resource group | Solution 02 **AKS Step 4** or **K3s Step 3** ran `rad group create rg-trading` independently on each control plane | Active Radius workspace/control plane | +| `rad env show` | `env-azure-prod` with Kubernetes namespace `env-azure-prod` and Azure provider scope, or `env-local-prod` with namespace `env-local-prod` and no Azure provider | Solution 02 **AKS Step 4** or **K3s Step 3** ran `rad env create`; the AKS step then ran `rad env update` to add its Azure scope | Active Radius workspace, group, and environment | + +Output formatting can vary by Radius CLI version. Validate the names and connection or +compute fields rather than column order. Resource types are control-plane-wide +definitions selected by the workspace; the group and environment do not own them. The +group and environment checks confirm the complete target state that later recipe and +application commands will use. + +Do not continue with a mixed pair. For example, `k3s-azure-vm` plus +`ws-azure-prod` would inspect the K3s portfolio but query the AKS Radius control plane. + +### 2. Recheck the capability portfolio + +```bash +helm status core --namespace core +helm list --namespace core --filter '^core$' +kubectl get deployments,statefulsets,services,pods --namespace core +kubectl get crd peerauthentications.security.istio.io +kubectl get peerauthentication --all-namespaces --output yaml +``` + +| Command or object | Stable indicators | Role | Created by | +| --- | --- | --- | --- | +| `helm status core --namespace core` | Release `core`, namespace `core`, and status `deployed`; revision and timestamps may differ | Reports health and release metadata for the selected `core` capability profile | Stage 0 `helm upgrade --install core ... --namespace core --create-namespace`, or the matching optional `configure-resource-types-*.sh` script | +| `helm list --namespace core --filter '^core$'` | One `core` row with chart `adaptive-apps-0.1.0` and status `deployed` | Confirms that the expected pinned portfolio chart backs the release | The same Stage 0 Helm command | +| Namespace `core` | The namespace exists and contains the release objects below | Isolates the shared identity and observability portfolio from Radius and application namespaces | Stage 0's Helm command through `--create-namespace` | +| Deployment `core-keycloak` and StatefulSet `core-keycloak-postgresql` | Workloads report their desired replicas ready; pod names include generated suffixes and are not stable | Keycloak is the shared identity broker; PostgreSQL persists its configuration and users | The identity feature in the Stage 0 `core` profile | +| Deployments and Services `otel-collector`, `prometheus`, and `zipkin` | Deployments become Available and Services are `ClusterIP`; exact pod counts or suffixes are not part of the contract | Receive OTLP data and provide workshop metrics and trace collection/query paths | The observability feature in the Stage 0 `core` profile | +| CRD `peerauthentications.security.istio.io` | The CRD exists and normally reports condition `Established=True` | Defines Istio's `PeerAuthentication` policy object | On AKS, the managed Istio add-on enabled in Challenge 01; on K3s, the Istio base release launched by the Stage 0 portfolio pre-install hook | +| `PeerAuthentication/default` | Namespace `aks-istio-system` on AKS or `istio-system` on K3s, with `spec.mtls.mode: STRICT` | Establishes mesh-wide strict mutual TLS for workloads enrolled in the mesh | The Stage 0 portfolio post-install hook | + +The `core` profile does not enable OPA, AI, or agent-guardrail workloads. Do not expect +those objects here. On K3s the Stage 0 hook also installs separate `istio-base` and +`istiod` Helm releases in `istio-system`; on AKS the portfolio reuses the managed Istio +add-on in `aks-istio-system`. Stable workload readiness matters more than an exact pod +count because chart and platform versions can change replicas and generated pod names. + +These are Kubernetes objects selected by the current kube context. They are not Radius +resource types and are not stored in `ws-azure-prod` or `ws-local-prod`. + +### 3. Inspect the registered contracts + +In each workspace, preserve the following commands: ```bash rad resource-type list @@ -274,6 +372,74 @@ rad resource-type show Radius.Resources/postgreSqlDatabases rad resource-type show Radius.Resources/aiModels ``` +| Command | Stable indicators | Why the object exists and when it was registered | Scope | +| --- | --- | --- | --- | +| `rad resource-type list` | At least the eight `Radius.Resources/*` types listed below; other configured or built-in types and the total row count can vary with the installed Radius version | Lists contracts known to the selected control plane. `sqlDatabases` came from Stage 1; the other seven came from the Stage 2 catalog import | Radius control plane selected by the active workspace; not Kubernetes, group, or environment scoped | +| `rad resource-type show Radius.Resources/sqlDatabases` | API version `2025-08-01-preview`; required inputs `environment` and `application`; optional `size` values `S`, `M`, or `L`; read-only `host`, `port`, `database`, `username`, and `secrets.password` | This is the manually designed SQL contract from `iac/sql-databases.yaml`, registered separately by the Stage 1 `rad resource-type create` command | One independent definition in each Radius control plane | +| `rad resource-type show Radius.Resources/postgreSqlDatabases` | API version `2025-08-01-preview`; writable `environment`, `application`, and `size`; read-only `host`, `port`, `database`, `username`, and `secrets.password` | Provides the portable PostgreSQL vocabulary used by the application. Stage 2 imported it from `/tmp/adaptive-apps-types.yaml` | One independent definition in each Radius control plane | +| `rad resource-type show Radius.Resources/aiModels` | API version `2025-08-01-preview`; writable `environment`, `application`, and `model`; read-only `endpoint`, `provider`, and `secrets.apiKey` | Provides the portable AI-model vocabulary used in Challenge 08. Stage 2 imported it from the same catalog | One independent definition in each Radius control plane | + +`rad resource-type show` displays a **definition**, not a deployed resource instance. +There is no provisioning status, Kubernetes pod, database, or AI model to find at this +stage. The schema tells Radius and Bicep which values an application may write and which +values a future recipe must return. + +The complete expected custom catalog and its origin are: + +| Resource type | Registered by | +| --- | --- | +| `Radius.Resources/sqlDatabases` | Stage 1 manual `rad resource-type create --from-file iac/sql-databases.yaml` | +| `Radius.Resources/agentGuardrails` | Stage 2 catalog import | +| `Radius.Resources/aiModels` | Stage 2 catalog import | +| `Radius.Resources/governance` | Stage 2 catalog import | +| `Radius.Resources/idProviders` | Stage 2 catalog import | +| `Radius.Resources/mqttBrokers` | Stage 2 catalog import | +| `Radius.Resources/postgreSqlDatabases` | Stage 2 catalog import | +| `Radius.Resources/workloadIdentities` | Stage 2 catalog import | + +Every custom type currently uses API version `2025-08-01-preview`. Each Stage 1 and +Stage 2 `rad resource-type create --workspace ...` command was run twice because the +control planes are federated. Registering a type on AKS does not make it available on +K3s, or vice versa. + +### 4. Confirm the local Bicep extension + +```bash +test -s artifacts/types.tgz +ls -lh artifacts/types.tgz +grep -n '"radiusResources"' iac/bicepconfig.json +``` + +| Object | Expected | Why and when it was created | Scope | +| --- | --- | --- | --- | +| `artifacts/types.tgz` | A non-empty generated archive; size and timestamp depend on the tool version | Stage 2 ran `rad bicep publish-extension` after downloading the catalog. The optional scripts run the same command and overwrite this path with `--force` | Local generated artifact on the participant workstation; intentionally not registered with either control plane | +| `radiusResources` entry in `iac/bicepconfig.json` | Maps the extension name to `../artifacts/types.tgz` | Lets Bicep compile declarations such as `Radius.Resources/postgreSqlDatabases@2025-08-01-preview` with the catalog's types | Repository configuration consumed locally by Bicep | + +The extension contains compile-time type metadata generated from the catalog. It does +not contain a recipe or deploy a backing service. The generated archive is gitignored. +One workstation copy can compile models for both platforms, while each control plane +still needs its own Stage 1 and Stage 2 type registration. + +### 5. Confirm the recipe boundary + +```bash +rad recipe list +``` + +After Challenges 00-03 only, the active environment should have no recipe rows for the +custom `Radius.Resources/*` contracts introduced here. A Radius version may show +built-in environment recipes; custom rows indicate that the environment was +preconfigured or has already advanced into Challenge 04. In every case, the type import +itself did not create a recipe. + +A **resource type** declares the portable contract in the Radius control plane. +A **recipe** associates an implementation template with that contract in a particular +Radius environment. Challenge 04 registers different recipes in `env-azure-prod` and +`env-local-prod`; that is when declarations can provision Azure-managed or in-cluster +implementations. + +### 6. Inspect the matching Radius control plane + Open the dashboard for the matching kube context: ```bash @@ -281,7 +447,17 @@ kubectl port-forward service/dashboard \ --namespace radius-system 7007:80 ``` -Inspect: +Expect a foreground message that local port `7007` is forwarding to the dashboard +Service. Open . The `dashboard` Service and the `radius-system` +namespace were created by `rad install kubernetes` in Solution 02 **AKS Step 3** or +**K3s Step 2**. They belong to the Kubernetes-hosted Radius control plane, not to the +`core` portfolio. + +The dashboard shows the control plane in the current Kubernetes context. Compare it +with `rad workspace show`; a mismatched workspace can make CLI and dashboard results +appear inconsistent even though both are functioning. + +Inspect each contract for: - Developer inputs - Read-only outputs @@ -301,7 +477,9 @@ Radius.Resources/sqlDatabases Radius.Resources/workloadIdentities ``` -No recipes should be registered yet. Recipes are introduced in Challenge 04. +Stop the foreground port-forward before switching clusters. K3s dashboard access +depends on the active Bastion API tunnel, but the dashboard itself is never exposed +through a public K3s or VM endpoint. ## Optional platforms From c80400892b25c2353c7d0b2f5ce8f5cf502844ac Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Fri, 21 Aug 2026 14:10:00 +0200 Subject: [PATCH 13/29] Clarify portable app challenge handoff Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../walkthrough/challenge-05/solution-05.md | 53 +++++++++++++++++-- 1 file changed, 48 insertions(+), 5 deletions(-) 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 c33f50261..fcdb0d473 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,8 +6,48 @@ Duration: 45-75 minutes ## Coach notes -Challenge 04 established environment-specific recipes. Challenge 05 uses those recipes -to prove the portability boundary: +### How we arrived here + +1. **Challenge 01 prepared two Kubernetes targets.** `aks-adaptive-apps` is the + Azure-managed AKS target. `k3s-azure-vm` is the private K3s target used for the Local + platform and reached through the Azure Bastion API tunnel. +2. **Challenge 02 installed two independent Radius control planes.** The local workspace + `ws-azure-prod` targets AKS and contains `env-azure-prod`; `ws-local-prod` targets K3s + and contains `env-local-prod`. Each control plane has its own `rg-trading` group. + Identical names do not imply shared state. +3. **Challenge 03 established contracts and the shared platform baseline.** The `core` + capability portfolio installed identity, observability, and the applicable Istio + strict-mTLS baseline on each cluster. The challenge then registered the portable + `Radius.Resources/*` resource-type contracts separately in both Radius control planes + and generated the local `artifacts/types.tgz` Bicep extension. A contract describes + application-facing inputs and recipe-produced outputs; registering it does not create + PostgreSQL, Event Grid, or application containers. +4. **Challenge 04 registered environment-specific recipes.** For the contracts exercised + here, the selected environment now determines these implementations: + + | Portable contract | `env-azure-prod` on AKS/Azure | `env-local-prod` on K3s | + | --- | --- | --- | + | `Radius.Resources/postgreSqlDatabases` | Azure Database for PostgreSQL Flexible Server | In-cluster PostgreSQL | + | `Radius.Resources/mqttBrokers` | Azure Event Grid MQTT | In-cluster Eclipse Mosquitto | + | `Radius.Resources/workloadIdentities` | Azure/AKS workload identity mapping | Local Kubernetes mapping/no-op implementation | + + Challenge 04 also prepared Keycloak, AI, governance, and agent-guardrail recipe + mappings for later challenges; the Challenge 05 core model does not exercise all of + them. + + > [!IMPORTANT] + > The manually authored Azure SQL recipe in Challenge 04 is a teaching example for + > `Radius.Resources/sqlDatabases`. The Trading application in `iac/app.bicep` requests + > `Radius.Resources/postgreSqlDatabases`, so Radius uses the PostgreSQL recipes shown + > above rather than the custom Azure SQL recipe. + +5. **Challenge 05 deploys the Trading application for the first time.** The same + `iac/app.bicep` declares one `Applications.Core/applications` resource, the `backend` + and `frontend` containers, and PostgreSQL, MQTT, and workload-identity capability + requests in both environments. Radius resolves those requests through the recipes + registered on the selected environment. + +This handoff defines the portability boundary: ```text Application team Platform team @@ -22,9 +62,12 @@ contracts. Local password authentication keeps the frontend usable. OIDC adaptat belongs to Challenge 06, service communication to Challenge 07, and AI to Challenge 08. Do not provision per-workload managed identities or AI services here. -Azure Event Grid MQTT exposes a broker endpoint, but end-to-end MQTT requires topic -spaces, permission bindings, and federated workload identities. A green Radius -deployment proves model portability; it does not claim complete MQTT runtime parity. +Deployment parity is not runtime parity. On AKS, recipe execution can create Azure +Database for PostgreSQL Flexible Server and Event Grid resources; on K3s it creates +in-cluster PostgreSQL and Mosquitto workloads. Event Grid MQTT still needs topic spaces, +permission bindings, and federated workload identities for end-to-end messaging. That +runtime work is deferred or discussed in later identity and communication challenges; a +green Radius deployment proves model portability, not complete messaging parity. ## Target discipline From 24ef176b4ec4f097095db06ef811b2a887347095 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Fri, 21 Aug 2026 14:16:41 +0200 Subject: [PATCH 14/29] Add managed identity fallback for Radius setup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../resources/deploy-radius-aks.sh | 100 ++++++++++++++++-- 1 file changed, 89 insertions(+), 11 deletions(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh index be1195a3f..b035232ef 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh @@ -19,6 +19,7 @@ RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" RADIUS_NAMESPACE="${RADIUS_NAMESPACE:-env-azure-prod}" RADIUS_AZURE_ROLE="${RADIUS_AZURE_ROLE:-Owner}" RADIUS_APP_NAME="${RADIUS_APP_NAME:-${AKS_CLUSTER}-radius-app}" +RADIUS_MANAGED_IDENTITY_NAME="${RADIUS_MANAGED_IDENTITY_NAME:-${AKS_CLUSTER}-radius}" az account show >/dev/null 2>&1 || az login az account set --subscription "$AZURE_SUBSCRIPTION" @@ -73,6 +74,9 @@ SERVICE_PRINCIPAL_OBJECT_ID="$(az ad sp show \ --query id \ --output tsv)" +IDENTITY_CLIENT_ID="$APPLICATION_CLIENT_ID" +IDENTITY_PRINCIPAL_ID="$SERVICE_PRINCIPAL_OBJECT_ID" + TEMP_DIR="$(mktemp -d)" trap 'rm -rf "$TEMP_DIR"' EXIT @@ -82,30 +86,34 @@ ensure_federated_credential() { local credential_id local current_issuer local current_subject + local error_file="${TEMP_DIR}/${name}.error" + + : >"$error_file" credential_id="$(az ad app federated-credential list \ --id "$APPLICATION_OBJECT_ID" \ --query "[?name=='${name}'].id | [0]" \ - --output tsv)" + --output tsv 2>"$error_file")" || return if [[ -n "$credential_id" ]]; then current_issuer="$(az ad app federated-credential show \ --id "$APPLICATION_OBJECT_ID" \ --federated-credential-id "$credential_id" \ --query issuer \ - --output tsv)" + --output tsv 2>>"$error_file")" || return current_subject="$(az ad app federated-credential show \ --id "$APPLICATION_OBJECT_ID" \ --federated-credential-id "$credential_id" \ --query subject \ - --output tsv)" + --output tsv 2>>"$error_file")" || return if [[ "$current_issuer" == "$OIDC_ISSUER" && "$current_subject" == "system:serviceaccount:radius-system:${service_account}" ]]; then return fi az ad app federated-credential delete \ --id "$APPLICATION_OBJECT_ID" \ - --federated-credential-id "$credential_id" + --federated-credential-id "$credential_id" \ + 2>>"$error_file" || return fi cat >"${TEMP_DIR}/${name}.json" <>"$error_file" +} + +ensure_managed_identity_federated_credential() { + local name="$1" + local service_account="$2" + local arguments=( + --resource-group "$RESOURCE_GROUP" + --identity-name "$RADIUS_MANAGED_IDENTITY_NAME" + --name "$name" + --issuer "$OIDC_ISSUER" + --subject "system:serviceaccount:radius-system:${service_account}" + --audiences api://AzureADTokenExchange --output none + ) + + if az identity federated-credential show \ + --resource-group "$RESOURCE_GROUP" \ + --identity-name "$RADIUS_MANAGED_IDENTITY_NAME" \ + --name "$name" >/dev/null 2>&1; then + az identity federated-credential update "${arguments[@]}" + else + az identity federated-credential create "${arguments[@]}" + fi +} + +use_managed_identity() { + local aks_location + + aks_location="$(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --query location \ + --output tsv)" + + if ! az identity show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RADIUS_MANAGED_IDENTITY_NAME" >/dev/null 2>&1; then + az identity create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RADIUS_MANAGED_IDENTITY_NAME" \ + --location "$aks_location" \ + --output none + fi + + IDENTITY_CLIENT_ID="$(az identity show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RADIUS_MANAGED_IDENTITY_NAME" \ + --query clientId \ + --output tsv)" + IDENTITY_PRINCIPAL_ID="$(az identity show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RADIUS_MANAGED_IDENTITY_NAME" \ + --query principalId \ + --output tsv)" + + ensure_managed_identity_federated_credential radius-applications-rp applications-rp + ensure_managed_identity_federated_credential radius-bicep-de bicep-de + ensure_managed_identity_federated_credential radius-ucp ucp + ensure_managed_identity_federated_credential radius-dynamic-rp dynamic-rp } -ensure_federated_credential radius-applications-rp applications-rp -ensure_federated_credential radius-bicep-de bicep-de -ensure_federated_credential radius-ucp ucp -ensure_federated_credential radius-dynamic-rp dynamic-rp +if ensure_federated_credential radius-applications-rp applications-rp && + ensure_federated_credential radius-bicep-de bicep-de && + ensure_federated_credential radius-ucp ucp && + ensure_federated_credential radius-dynamic-rp dynamic-rp; then + : +elif grep -Fq "FederatedIdentityCredential.Issuer" "${TEMP_DIR}"/*.error && + grep -Fq "not allowed as per assigned policy" "${TEMP_DIR}"/*.error; then + cat "${TEMP_DIR}"/*.error >&2 + echo "Entra policy blocks AKS issuers on application registrations; using managed identity ${RADIUS_MANAGED_IDENTITY_NAME}." >&2 + use_managed_identity +else + cat "${TEMP_DIR}"/*.error >&2 + exit 1 +fi SCOPE="/subscriptions/${AZURE_SUBSCRIPTION}/resourceGroups/${RESOURCE_GROUP}" ASSIGNMENT_ID="$(az role assignment list \ - --assignee-object-id "$SERVICE_PRINCIPAL_OBJECT_ID" \ + --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \ --fill-principal-name false \ --scope "$SCOPE" \ --role "$RADIUS_AZURE_ROLE" \ @@ -139,7 +217,7 @@ ASSIGNMENT_ID="$(az role assignment list \ --output tsv)" if [[ -z "$ASSIGNMENT_ID" ]]; then az role assignment create \ - --assignee-object-id "$SERVICE_PRINCIPAL_OBJECT_ID" \ + --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \ --assignee-principal-type ServicePrincipal \ --role "$RADIUS_AZURE_ROLE" \ --scope "$SCOPE" \ @@ -176,7 +254,7 @@ rad env switch "$RADIUS_ENVIRONMENT" TENANT_ID="$(az account show --query tenantId --output tsv)" rad credential register azure wi \ - --client-id "$APPLICATION_CLIENT_ID" \ + --client-id "$IDENTITY_CLIENT_ID" \ --tenant-id "$TENANT_ID" kubectl wait --for=condition=Available deployments --all \ From 91a28f85cb444e5c6edb3a0fac04ae90f12f970b Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Fri, 21 Aug 2026 14:45:36 +0200 Subject: [PATCH 15/29] Initialize trading PostgreSQL recipes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../04-adaptive-apps/Readme.md | 3 + .../challenges/challenge-00.md | 4 +- .../challenges/challenge-04.md | 15 +- .../challenges/challenge-05.md | 11 +- .../04-adaptive-apps/iac/aks-env.bicep | 9 +- .../04-adaptive-apps/iac/app.bicep | 5 + .../04-adaptive-apps/iac/local-env.bicep | 4 +- .../iac/recipes/postgres-azure-flex.bicep | 267 ++++++++++++++++ .../iac/recipes/postgres-kubernetes.bicep | 289 ++++++++++++++++++ .../iac/recipes/trading-schema.sql | 46 +++ .../resources/configure-recipes.sh | 114 ++++++- .../resources/validate-postgres-recipes.sh | 52 ++++ .../walkthrough/challenge-00/solution-00.md | 7 +- .../walkthrough/challenge-04/solution-04.md | 211 ++++++++++++- .../walkthrough/challenge-05/solution-05.md | 193 ++++++++++++ 15 files changed, 1203 insertions(+), 27 deletions(-) create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/trading-schema.sql create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh 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 a54c5c938..641bdee9e 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 @@ -74,6 +74,9 @@ To use the MicroHack time effectively, have the following available: - Capacity to create the default two-environment topology: one Azure Kubernetes Service cluster, one private Linux VM hosting self-managed K3s, and one Standard Azure Bastion host with its managed public endpoint +- Permission and budget for a Standard Azure Container Registry in Challenge 04. Its + non-secret pinned recipe artifacts allow anonymous pull so both Radius control planes + can resolve them; publishing remains authenticated. - Alternatively, an existing Azure Local or Arc-enabled Kubernetes environment to use in place of K3s - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md index 4398acb8d..ca4b6e0ae 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md @@ -238,7 +238,9 @@ az account show --output table > [!IMPORTANT] > The configuration does not mount the host Docker socket. Challenge 04 supports -> token-based ACR authentication when Docker is unavailable. +> token-based ACR authentication when Docker is unavailable. It creates or upgrades a +> Standard ACR and enables anonymous pull only for the non-secret compiled recipe +> artifacts needed by both Radius control planes. #### Option B: Install tools on the host 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 444726648..1b0d27c7b 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 @@ -58,8 +58,8 @@ While targeting AKS and workspace `ws-azure-prod`: 1. Create or select a globally unique, lowercase Azure Container Registry. 2. Authenticate to the registry. -3. Publish `iac/recipes/sql-server.bicep` as version `1.0.0` under - `recipes/sql-server`. +3. Publish `iac/recipes/sql-server.bicep` and the workshop-owned Azure and Kubernetes + PostgreSQL recipes as version `1.0.0`. 4. Register it as the default recipe for `Radius.Resources/sqlDatabases` in `env-azure-prod`. 5. Verify that the registration refers to the expected OCI artifact. @@ -76,8 +76,10 @@ Deploy `iac/aks-env.bicep` as environment-as-code for: - Environment `env-azure-prod` - Kubernetes namespace `env-azure-prod` -Provide the Azure subscription, lab resource group, managed Istio revision, and custom -SQL recipe path as parameters. +Provide the Azure subscription, lab resource group, managed Istio revision, custom SQL +recipe path, pinned Azure PostgreSQL recipe path, and every exact static AKS egress IP as +parameters. Reject unsupported outbound configurations rather than using broad firewall +rules. The resulting mappings should include Azure-backed PostgreSQL, MQTT, workload identity, and AI implementations; in-cluster identity, governance, and guardrail @@ -93,7 +95,7 @@ Switch both the Kubernetes context and Radius workspace to K3s, then deploy - Environment `env-local-prod` - Kubernetes namespace `env-local-prod` -The Local mappings should use in-cluster implementations for PostgreSQL, MQTT, +The Local mappings should use the pinned workshop PostgreSQL recipe and in-cluster implementations for MQTT, identity, workload identity, AI, governance, and agent guardrails. Registering an AI recipe does not deploy a model. GPU capacity is needed only when the @@ -114,6 +116,9 @@ Prepare a comparison that answers: ## Success criteria - The custom SQL recipe is published to ACR at version `1.0.0`. +- Both corrected PostgreSQL recipes are published to ACR at version `1.0.0` and + registered without mutable `latest` references. +- Azure PostgreSQL permits only the exact static AKS egress IPs. - The recipe is registered as the default `Radius.Resources/sqlDatabases` implementation in `env-azure-prod`. - AKS has the expected Azure-backed and in-cluster recipe mappings. 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 f9fda9f37..3f940c7d0 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 @@ -78,8 +78,10 @@ Restore the Bastion tunnel if needed, then explicitly select: Verify that the generated Bicep extension from Challenge 03 is available and that the required PostgreSQL, MQTT, and workload-identity recipes are registered. -Deploy `iac/app.bicep`, inspect the graph and resources, check Kubernetes workload -health, and expose the frontend through the active K3s Radius control plane. +Deploy `iac/app.bicep`, wait for recipe-owned schema initialization, prove the four +tables and single `Demo Account` seed through safe metadata checks, inspect the graph and +resources, check backend database access, and expose the frontend through the active K3s +Radius control plane. ### Task 3: Prove the AKS deployment @@ -93,7 +95,8 @@ Stop the K3s frontend exposure before switching targets. Explicitly select: Verify the Azure credential registration from Challenge 02 and the required recipe mappings from Challenge 04. Deploy the same `iac/app.bicep` file with the same image inputs, then inspect the graph, Kubernetes resources, Azure backing resources, and -frontend exposure. +frontend exposure. Repeat the same schema, seed, and backend database-access checks and +confirm the initializer requires TLS. ### Task 4: Compare evidence and ownership @@ -114,6 +117,8 @@ Capture a side-by-side comparison that shows: - Both Radius control planes show an `adaptive-apps` graph with frontend, backend, PostgreSQL, MQTT, and workload-identity resources. - The backing implementations differ according to each environment's recipes. +- Both implementations contain `accounts`, `orders`, `trades`, and `positions`, exactly + one `Demo Account`, and a backend that is Ready only after `/api/accounts` succeeds. - The frontend can be exposed through each active control plane, one at a time. - No password or cluster credential is committed or captured in evidence. - The team can explain why deployment portability is proven even though Azure MQTT diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep index dca619f8c..c15ebb319 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep @@ -7,6 +7,10 @@ extension kubernetes with { param namespace string = 'env-azure-prod' param environmentName string = namespace param recipeRegistry string = 'ghcr.io/microsoft/adaptive-apps/recipes' +@description('Pinned workshop OCI path for the corrected Azure PostgreSQL recipe.') +param postgresRecipeTemplatePath string +@description('Comma-separated static AKS public egress IP addresses allowed by PostgreSQL.') +param aksEgressIps string param sqlDatabasesRecipeTemplatePath string = '' param istioRevision string param azureSubscriptionId string @@ -25,7 +29,10 @@ var baseRecipes = { 'Radius.Resources/postgreSqlDatabases': { default: { templateKind: 'bicep' - templatePath: '${recipeRegistry}/postgres-azure-flex:latest' + templatePath: postgresRecipeTemplatePath + parameters: { + aksEgressIps: aksEgressIps + } } } 'Radius.Resources/mqttBrokers': { diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep index 8b973cdef..9541ba449 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep @@ -143,6 +143,11 @@ resource backend 'Applications.Core/containers@2023-10-01-preview' = { containerPort: 8080 } } + readinessProbe: { + kind: 'httpGet' + path: '/api/accounts' + containerPort: 8080 + } env: { ASPNETCORE_URLS: { value: 'http://+:8080' diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/local-env.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/local-env.bicep index c2b2b9468..33bb4641c 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/local-env.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/local-env.bicep @@ -7,6 +7,8 @@ extension kubernetes with { param namespace string = 'env-local-prod' param environmentName string = namespace param recipeRegistry string = 'ghcr.io/microsoft/adaptive-apps/recipes' +@description('Pinned workshop OCI path for the corrected Kubernetes PostgreSQL recipe.') +param postgresRecipeTemplatePath string resource appNamespace 'core/Namespace@v1' = { metadata: { @@ -32,7 +34,7 @@ resource localEnvironment 'Applications.Core/environments@2023-10-01-preview' = 'Radius.Resources/postgreSqlDatabases': { default: { templateKind: 'bicep' - templatePath: '${recipeRegistry}/postgres:latest' + templatePath: postgresRecipeTemplatePath } } 'Radius.Resources/mqttBrokers': { diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep new file mode 100644 index 000000000..5974d09a1 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep @@ -0,0 +1,267 @@ +@description('Radius-provided recipe context.') +param context object + +@description('PostgreSQL database name.') +param database string = 'trading' + +@description('PostgreSQL administrator username.') +param user string = 'tradeadmin' + +@secure() +@description('PostgreSQL administrator password.') +#disable-next-line secure-parameter-default +param password string = '${uniqueString(context.resource.id)}Aa1!' + +@description('Comma-separated static public egress IP addresses used by AKS.') +@minLength(7) +param aksEgressIps string + +@description('Azure region for PostgreSQL resources.') +param location string = resourceGroup().location + +@description('PostgreSQL client image tag used only by the schema initializer.') +param clientTag string = '16-alpine' + +var sizeKey = context.resource.properties.?size ?? 'S' +var skuBySize = { + S: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + M: { + name: 'Standard_B2s' + tier: 'Burstable' + } + L: { + name: 'Standard_D2s_v3' + tier: 'GeneralPurpose' + } +} +var serverName = 'pg-${uniqueString(context.resource.id)}' +var egressIps = map(filter(split(aksEgressIps, ','), ip => !empty(trim(ip))), ip => trim(ip)) +var schemaSql = loadTextContent('trading-schema.sql') +var schemaRevision = take(uniqueString(schemaSql), 8) +var initializerName = 'postgres-${uniqueString(context.resource.id)}-schema-${schemaRevision}' +var port = 5432 + +extension kubernetes with { + kubeConfig: '' + namespace: context.runtime.kubernetes.namespace +} as k8s + +resource server 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = { + name: serverName + location: location + sku: { + name: skuBySize[sizeKey].name + tier: skuBySize[sizeKey].tier + } + properties: { + administratorLogin: user + administratorLoginPassword: password + version: '16' + storage: { + storageSizeGB: 32 + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + network: { + publicNetworkAccess: 'Enabled' + } + createMode: 'Default' + } + tags: { + 'radapp.io/environment': context.environment.id + 'radapp.io/resource': context.resource.id + 'radapp.io/application': context.application == null ? '' : context.application.name + } +} + +resource databaseResource 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2024-08-01' = { + parent: server + name: database + properties: { + charset: 'UTF8' + collation: 'en_US.utf8' + } +} + +resource requireSecureTransport 'Microsoft.DBforPostgreSQL/flexibleServers/configurations@2024-08-01' = { + parent: server + name: 'require_secure_transport' + properties: { + value: 'on' + source: 'user-override' + } +} + +resource minimumTlsVersion 'Microsoft.DBforPostgreSQL/flexibleServers/configurations@2024-08-01' = { + parent: server + name: 'ssl_min_protocol_version' + properties: { + value: 'TLSv1.2' + source: 'user-override' + } +} + +resource aksFirewallRules 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2024-08-01' = [ + for (egressIp, index) in egressIps: { + parent: server + name: 'aks-egress-${index + 1}' + properties: { + startIpAddress: egressIp + endIpAddress: egressIp + } + } +] + +resource credentials 'core/Secret@v1' = { + metadata: { + name: '${initializerName}-credentials' + labels: { + resource: context.resource.name + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } + stringData: { + password: password + } + type: 'Opaque' +} + +resource initSql 'core/ConfigMap@v1' = { + metadata: { + name: initializerName + labels: { + resource: context.resource.name + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } + data: { + 'init.sql': schemaSql + } +} + +resource schemaInitializer 'batch/Job@v1' = { + metadata: { + name: initializerName + labels: { + app: 'postgres-schema-initializer' + resource: context.resource.name + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } + spec: { + backoffLimit: 6 + activeDeadlineSeconds: 900 + template: { + metadata: { + labels: { + app: 'postgres-schema-initializer' + resource: context.resource.name + } + } + spec: { + restartPolicy: 'Never' + containers: [ + { + name: 'schema-initializer' + image: 'postgres:${clientTag}' + command: [ + 'sh' + '-ceu' + ''' +for attempt in $(seq 1 60); do + if psql --set=ON_ERROR_STOP=1 --file=/schema/init.sql; then + exit 0 + fi + echo "Azure PostgreSQL is not ready for TLS schema initialization (attempt ${attempt}/60)." >&2 + sleep 5 +done +exit 1 +''' + ] + env: [ + { + name: 'PGHOST' + value: server.properties.fullyQualifiedDomainName + } + { + name: 'PGPORT' + value: string(port) + } + { + name: 'PGDATABASE' + value: databaseResource.name + } + { + name: 'PGUSER' + value: user + } + { + name: 'PGSSLMODE' + value: 'require' + } + { + name: 'PGPASSWORD' + valueFrom: { + secretKeyRef: { + name: credentials.metadata.name + key: 'password' + } + } + } + ] + volumeMounts: [ + { + name: 'schema' + mountPath: '/schema' + readOnly: true + } + ] + } + ] + volumes: [ + { + name: 'schema' + configMap: { + name: initSql.metadata.name + } + } + ] + } + } + } + dependsOn: [ + aksFirewallRules + requireSecureTransport + minimumTlsVersion + ] +} + +output result object = { + resources: concat([ + server.id + databaseResource.id + requireSecureTransport.id + minimumTlsVersion.id + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/Secret/${credentials.metadata.name}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/ConfigMap/${initSql.metadata.name}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/batch/Job/${schemaInitializer.metadata.name}' + ], map(aksFirewallRules, rule => rule.id)) + values: { + host: server.properties.fullyQualifiedDomainName + port: port + database: databaseResource.name + username: user + } + secrets: { + #disable-next-line outputs-should-not-contain-secrets + password: password + } +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep new file mode 100644 index 000000000..c3e08a241 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep @@ -0,0 +1,289 @@ +@description('Radius-provided recipe context.') +param context object + +@description('PostgreSQL database name.') +param database string = 'trading' + +@description('PostgreSQL username.') +param user string = 'trade' + +@secure() +@description('PostgreSQL password.') +#disable-next-line secure-parameter-default +param password string = '${uniqueString(context.resource.id)}Aa1!' + +@description('PostgreSQL container image tag.') +param tag string = '16-alpine' + +var memoryBySize = { + S: '512Mi' + M: '1Gi' + L: '2Gi' +} +var sizeKey = context.resource.properties.?size ?? 'S' +var uniqueName = 'postgres-${uniqueString(context.resource.id)}' +var schemaSql = loadTextContent('trading-schema.sql') +var schemaRevision = take(uniqueString(schemaSql), 8) +var port = 5432 + +extension kubernetes with { + kubeConfig: '' + namespace: context.runtime.kubernetes.namespace +} as k8s + +resource credentials 'core/Secret@v1' = { + metadata: { + name: '${uniqueName}-credentials' + labels: { + resource: context.resource.name + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } + stringData: { + password: password + } + type: 'Opaque' +} + +resource initSql 'core/ConfigMap@v1' = { + metadata: { + name: '${uniqueName}-schema-${schemaRevision}' + labels: { + resource: context.resource.name + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } + data: { + 'init.sql': schemaSql + } +} + +resource postgresql 'apps/Deployment@v1' = { + metadata: { + name: uniqueName + labels: { + app: 'postgresql' + resource: context.resource.name + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } + spec: { + replicas: 1 + selector: { + matchLabels: { + app: 'postgresql' + resource: context.resource.name + } + } + template: { + metadata: { + labels: { + app: 'postgresql' + resource: context.resource.name + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } + spec: { + containers: [ + { + name: 'postgres' + image: 'postgres:${tag}' + ports: [ + { + containerPort: port + name: 'postgres' + } + ] + env: [ + { + name: 'POSTGRES_USER' + value: user + } + { + name: 'POSTGRES_PASSWORD' + valueFrom: { + secretKeyRef: { + name: credentials.metadata.name + key: 'password' + } + } + } + { + name: 'POSTGRES_DB' + value: database + } + ] + readinessProbe: { + exec: { + command: [ + 'pg_isready' + '-U' + user + '-d' + database + ] + } + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 + } + resources: { + requests: { + memory: memoryBySize[sizeKey] + } + } + volumeMounts: [ + { + name: 'init-sql' + mountPath: '/docker-entrypoint-initdb.d' + readOnly: true + } + ] + } + ] + volumes: [ + { + name: 'init-sql' + configMap: { + name: initSql.metadata.name + } + } + ] + } + } + } +} + +resource service 'core/Service@v1' = { + metadata: { + name: uniqueName + labels: { + resource: context.resource.name + } + } + spec: { + type: 'ClusterIP' + selector: { + app: 'postgresql' + resource: context.resource.name + } + ports: [ + { + name: 'postgres' + port: port + targetPort: 'postgres' + } + ] + } +} + +resource schemaInitializer 'batch/Job@v1' = { + metadata: { + name: '${uniqueName}-schema-${schemaRevision}' + labels: { + app: 'postgres-schema-initializer' + resource: context.resource.name + 'radapp.io/application': context.application == null ? '' : context.application.name + } + } + spec: { + backoffLimit: 6 + activeDeadlineSeconds: 900 + template: { + metadata: { + labels: { + app: 'postgres-schema-initializer' + resource: context.resource.name + } + } + spec: { + restartPolicy: 'Never' + containers: [ + { + name: 'schema-initializer' + image: 'postgres:${tag}' + command: [ + 'sh' + '-ceu' + ''' +for attempt in $(seq 1 60); do + if psql --set=ON_ERROR_STOP=1 --file=/schema/init.sql; then + exit 0 + fi + echo "PostgreSQL is not ready for schema initialization (attempt ${attempt}/60)." >&2 + sleep 5 +done +exit 1 +''' + ] + env: [ + { + name: 'PGHOST' + value: service.metadata.name + } + { + name: 'PGPORT' + value: string(port) + } + { + name: 'PGDATABASE' + value: database + } + { + name: 'PGUSER' + value: user + } + { + name: 'PGPASSWORD' + valueFrom: { + secretKeyRef: { + name: credentials.metadata.name + key: 'password' + } + } + } + ] + volumeMounts: [ + { + name: 'schema' + mountPath: '/schema' + readOnly: true + } + ] + } + ] + volumes: [ + { + name: 'schema' + configMap: { + name: initSql.metadata.name + } + } + ] + } + } + } + dependsOn: [ + postgresql + ] +} + +output result object = { + resources: [ + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/Secret/${credentials.metadata.name}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/ConfigMap/${initSql.metadata.name}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/apps/Deployment/${postgresql.metadata.name}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/Service/${service.metadata.name}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/batch/Job/${schemaInitializer.metadata.name}' + ] + values: { + host: '${service.metadata.name}.${context.runtime.kubernetes.namespace}.svc.cluster.local' + port: port + database: database + username: user + } + secrets: { + #disable-next-line outputs-should-not-contain-secrets + password: password + } +} diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/trading-schema.sql b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/trading-schema.sql new file mode 100644 index 000000000..8fb26a569 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/trading-schema.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS accounts ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + cash_balance NUMERIC(18, 2) NOT NULL DEFAULT 100000.00 +); + +CREATE TABLE IF NOT EXISTS orders ( + id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id), + symbol TEXT NOT NULL, + side TEXT NOT NULL, + order_type TEXT NOT NULL DEFAULT 'MARKET', + quantity INTEGER NOT NULL, + price NUMERIC(18, 2) NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS trades ( + id SERIAL PRIMARY KEY, + order_id INTEGER NOT NULL REFERENCES orders(id), + account_id INTEGER NOT NULL REFERENCES accounts(id), + symbol TEXT NOT NULL, + side TEXT NOT NULL, + quantity INTEGER NOT NULL, + price NUMERIC(18, 2) NOT NULL, + executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS positions ( + id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id), + symbol TEXT NOT NULL, + quantity INTEGER NOT NULL, + avg_price NUMERIC(18, 2) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (account_id, symbol) +); + +INSERT INTO accounts (name, cash_balance) +SELECT 'Demo Account', 100000.00 +WHERE NOT EXISTS ( + SELECT 1 + FROM accounts + WHERE name = 'Demo Account' +); diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh index 7aae4f6a2..f3ed768c0 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -18,6 +18,7 @@ for command_name in az base64 kubectl mktemp rad tr; do done AKS_CONTEXT="${AKS_CONTEXT:-aks-adaptive-apps}" +AKS_CLUSTER_NAME="${AKS_CLUSTER_NAME:-aks-adaptive-apps}" K3S_CONTEXT="${K3S_CONTEXT:-k3s-azure-vm}" K3S_KUBECONFIG="${K3S_KUBECONFIG:-$HOME/.kube/adaptive-apps-k3s.yaml}" AKS_WORKSPACE="${AKS_WORKSPACE:-ws-azure-prod}" @@ -26,16 +27,93 @@ AKS_ENVIRONMENT="${AKS_ENVIRONMENT:-env-azure-prod}" K3S_ENVIRONMENT="${K3S_ENVIRONMENT:-env-local-prod}" RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" RECIPE_REGISTRY="${RECIPE_REGISTRY:-ghcr.io/microsoft/adaptive-apps/recipes}" +WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.0}" +[[ "$WORKSHOP_RECIPE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "WORKSHOP_RECIPE_VERSION must be an immutable semantic version such as 1.0.0." >&2 + exit 1 +} TEMP_DIR="$(mktemp -d)" trap 'rm -rf "$TEMP_DIR"' EXIT -configure_aks() { - : "${AZURE_SUBSCRIPTION:?Set AZURE_SUBSCRIPTION for the AKS recipe configuration.}" - : "${RESOURCE_GROUP:?Set RESOURCE_GROUP for the AKS recipe configuration.}" +is_ipv4() { + local ip="$1" + local octet + [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 + IFS='.' read -r -a octets <<<"$ip" + for octet in "${octets[@]}"; do + ((10#$octet <= 255)) || return 1 + done +} + +get_aks_egress_ips() { + local outbound_type + local id_query + local resource_id + local allocation + local sku + local ip + local -a resource_ids=() + local -a ips=() + + outbound_type="$(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER_NAME" \ + --query networkProfile.outboundType \ + --output tsv)" + + case "$outbound_type" in + loadBalancer) + id_query='networkProfile.loadBalancerProfile.effectiveOutboundIPs[].id' + ;; + managedNATGateway|userAssignedNATGateway) + id_query='networkProfile.natGatewayProfile.effectiveOutboundIPs[].id' + ;; + *) + echo "Unsupported AKS outbound type '${outbound_type}'." >&2 + echo "Use loadBalancer, managedNATGateway, or userAssignedNATGateway with static public IP resources." >&2 + return 1 + ;; + esac + + mapfile -t resource_ids < <(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER_NAME" \ + --query "$id_query" \ + --output tsv) + ((${#resource_ids[@]} > 0)) || { + echo "AKS reports no effective outbound public IP resources for '${outbound_type}'." >&2 + return 1 + } + + for resource_id in "${resource_ids[@]}"; do + [[ "${resource_id,,}" == *"/providers/microsoft.network/publicipaddresses/"* ]] || { + echo "Unsupported AKS egress resource '${resource_id}'; public IP prefixes and broad ranges are not allowed." >&2 + return 1 + } + read -r ip allocation sku < <(az network public-ip show \ + --ids "$resource_id" \ + --query "[ipAddress,publicIPAllocationMethod,sku.name]" \ + --output tsv) + [[ "$allocation" == "Static" && "$sku" == "Standard" ]] || { + echo "AKS egress IP '${resource_id}' must be a Standard, statically allocated public IP." >&2 + return 1 + } + is_ipv4 "$ip" || { + echo "AKS egress resource '${resource_id}' does not expose a valid IPv4 address." >&2 + return 1 + } + ips+=("$ip") + done + + local IFS=, + printf '%s\n' "${ips[*]}" +} + +publish_workshop_recipes() { + : "${AZURE_SUBSCRIPTION:?Set AZURE_SUBSCRIPTION for workshop recipe publishing.}" + : "${RESOURCE_GROUP:?Set RESOURCE_GROUP for workshop recipe publishing.}" : "${ACR_NAME:?Set a globally unique lowercase ACR_NAME.}" - unset KUBECONFIG - kubectl config use-context "$AKS_CONTEXT" >/dev/null az account show >/dev/null 2>&1 || az login az account set --subscription "$AZURE_SUBSCRIPTION" @@ -45,9 +123,15 @@ configure_aks() { az acr create \ --resource-group "$RESOURCE_GROUP" \ --name "$ACR_NAME" \ - --sku Basic \ + --sku Standard \ --output none fi + az acr update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$ACR_NAME" \ + --sku Standard \ + --anonymous-pull-enabled true \ + --output none local token local auth @@ -72,7 +156,17 @@ EOF DOCKER_CONFIG="${TEMP_DIR}/docker" rad bicep publish \ --file iac/recipes/sql-server.bicep \ --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.0" + DOCKER_CONFIG="${TEMP_DIR}/docker" rad bicep publish \ + --file iac/recipes/postgres-azure-flex.bicep \ + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:${WORKSHOP_RECIPE_VERSION}" + DOCKER_CONFIG="${TEMP_DIR}/docker" rad bicep publish \ + --file iac/recipes/postgres-kubernetes.bicep \ + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:${WORKSHOP_RECIPE_VERSION}" +} +configure_aks() { + unset KUBECONFIG + kubectl config use-context "$AKS_CONTEXT" >/dev/null rad workspace switch "$AKS_WORKSPACE" local istio_revision istio_revision="$(kubectl get deployment \ @@ -83,6 +177,8 @@ EOF echo "Could not determine the AKS managed Istio revision." >&2 exit 1 } + local aks_egress_ips + aks_egress_ips="$(get_aks_egress_ips)" rad deploy iac/aks-env.bicep \ --workspace "$AKS_WORKSPACE" \ @@ -94,6 +190,8 @@ EOF --parameters "azureResourceGroup=${RESOURCE_GROUP}" \ --parameters "istioRevision=${istio_revision}" \ --parameters "recipeRegistry=${RECIPE_REGISTRY}" \ + --parameters "postgresRecipeTemplatePath=${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:${WORKSHOP_RECIPE_VERSION}" \ + --parameters "aksEgressIps=${aks_egress_ips}" \ --parameters "sqlDatabasesRecipeTemplatePath=${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.0" rad recipe list \ @@ -113,7 +211,8 @@ configure_k3s() { --environment "$K3S_ENVIRONMENT" \ --parameters "environmentName=${K3S_ENVIRONMENT}" \ --parameters "namespace=${K3S_ENVIRONMENT}" \ - --parameters "recipeRegistry=${RECIPE_REGISTRY}" + --parameters "recipeRegistry=${RECIPE_REGISTRY}" \ + --parameters "postgresRecipeTemplatePath=${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:${WORKSHOP_RECIPE_VERSION}" rad recipe list \ --workspace "$K3S_WORKSPACE" \ @@ -121,6 +220,7 @@ configure_k3s() { --environment "$K3S_ENVIRONMENT" } +publish_workshop_recipes if [[ "$TARGET_PLATFORM" == "aks" || "$TARGET_PLATFORM" == "all" ]]; then configure_aks fi diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh new file mode 100644 index 000000000..0e44c6dd1 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RECIPE_DIR="${ROOT_DIR}/iac/recipes" +SCHEMA_FILE="${RECIPE_DIR}/trading-schema.sql" +AZURE_RECIPE="${RECIPE_DIR}/postgres-azure-flex.bicep" +KUBERNETES_RECIPE="${RECIPE_DIR}/postgres-kubernetes.bicep" + +for file in "$SCHEMA_FILE" "$AZURE_RECIPE" "$KUBERNETES_RECIPE"; do + [[ -f "$file" ]] || { + echo "Missing PostgreSQL recipe input: $file" >&2 + exit 1 + } +done + +for recipe in "$AZURE_RECIPE" "$KUBERNETES_RECIPE"; do + grep -Fq "loadTextContent('trading-schema.sql')" "$recipe" || { + echo "$(basename "$recipe") does not consume the shared schema." >&2 + exit 1 + } +done + +for table in accounts orders trades positions; do + grep -Eq "CREATE TABLE IF NOT EXISTS ${table}[[:space:]]*\\(" "$SCHEMA_FILE" || { + echo "Shared schema is missing table: $table" >&2 + exit 1 + } +done + +grep -Fq "WHERE NOT EXISTS (" "$SCHEMA_FILE" +grep -Fq "WHERE name = 'Demo Account'" "$SCHEMA_FILE" +if grep -Fq "ON CONFLICT DO NOTHING" "$SCHEMA_FILE"; then + echo "Demo Account must use an explicit idempotency guard." >&2 + exit 1 +fi + +grep -Fq "PGSSLMODE" "$AZURE_RECIPE" +grep -Fq "value: 'require'" "$AZURE_RECIPE" +if grep -Eq "0\\.0\\.0\\.0|255\\.255\\.255\\.255" "$AZURE_RECIPE"; then + echo "Azure PostgreSQL recipe contains a broad firewall address." >&2 + exit 1 +fi + +if grep -REiq "postgres(-azure-flex|-kubernetes)?:latest" \ + "${ROOT_DIR}/Readme.md" "${ROOT_DIR}/iac" "${ROOT_DIR}/resources" \ + "${ROOT_DIR}/challenges" "${ROOT_DIR}/walkthrough"; then + echo "A corrected PostgreSQL recipe still uses a mutable latest reference." >&2 + exit 1 +fi + +echo "PostgreSQL recipe parity and security assertions passed." diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md index 43113a75c..dfa588c20 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md @@ -187,7 +187,7 @@ worktree. Git remains installed for normal clones, but do not claim repository G functionality for a Windows worktree bind mount. Without a Docker socket, use the temporary `DOCKER_CONFIG` and `az acr login ---expose-token` flow documented in Solution 04 when publishing the recipe. The +--expose-token` flow documented in Solution 04 when publishing the recipes. The `configure-recipes.sh` helper already uses this token-based approach. ### Azure and Radius authentication @@ -247,8 +247,9 @@ configure remote resources: - Challenge 01 creates AKS and an Azure VM, then installs K3s on that VM. - Challenge 02 installs separate Radius control planes into AKS and K3s. - Challenge 03 installs the portfolio and registers resource types remotely. -- Challenge 04 publishes a Bicep recipe to ACR and registers recipes with both Radius - environments. +- Challenge 04 publishes the teaching recipe and two corrected PostgreSQL recipes to a + Standard ACR, then registers pinned recipes with both Radius environments. Anonymous + pull applies only to those non-secret recipe artifacts. No Kubernetes cluster or Docker daemon runs inside this devcontainer. 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 c6274e894..ba949a58f 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 @@ -179,9 +179,20 @@ az acr show \ az acr create \ --resource-group "$RESOURCE_GROUP" \ --name "$ACR_NAME" \ - --sku Basic + --sku Standard + +az acr update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$ACR_NAME" \ + --sku Standard \ + --anonymous-pull-enabled true ``` +Anonymous pull is limited to the workshop's non-secret compiled recipe modules so both +independent Radius control planes can resolve the same pinned artifacts. Publishing still +requires an authenticated short-lived token. Database credentials are never stored in +ACR. + Authenticate with Docker: ```bash @@ -208,15 +219,41 @@ cat >"${DOCKER_CONFIG}/config.json" <&2 + exit 1 + ;; +esac + +mapfile -t OUTBOUND_IDS < <(az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name aks-adaptive-apps \ + --query "$OUTBOUND_QUERY" \ + --output tsv) +((${#OUTBOUND_IDS[@]} > 0)) || { + echo "AKS has no effective outbound public IPs." >&2 + exit 1 +} + +AKS_EGRESS_IPS="" +for OUTBOUND_ID in "${OUTBOUND_IDS[@]}"; do + [[ "${OUTBOUND_ID,,}" == *"/providers/microsoft.network/publicipaddresses/"* ]] || { + echo "Only exact public IP resources are supported: $OUTBOUND_ID" >&2 + exit 1 + } + read -r IP ALLOCATION SKU < <(az network public-ip show \ + --ids "$OUTBOUND_ID" \ + --query "[ipAddress,publicIPAllocationMethod,sku.name]" \ + --output tsv) + [[ "$ALLOCATION" == "Static" && "$SKU" == "Standard" && -n "$IP" ]] || { + echo "AKS egress IP must be Standard and static: $OUTBOUND_ID" >&2 + exit 1 + } + AKS_EGRESS_IPS="${AKS_EGRESS_IPS:+${AKS_EGRESS_IPS},}${IP}" +done + rad deploy iac/aks-env.bicep \ --workspace ws-azure-prod \ --group rg-trading \ @@ -252,9 +336,64 @@ rad deploy iac/aks-env.bicep \ --parameters azureSubscriptionId="$AZURE_SUBSCRIPTION" \ --parameters azureResourceGroup="$RESOURCE_GROUP" \ --parameters istioRevision="$ISTIO_REVISION" \ + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.0" \ + --parameters aksEgressIps="$AKS_EGRESS_IPS" \ --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.0" ``` +**PowerShell 7:** + +```powershell +$IstioRevision = kubectl get deployment ` + --namespace aks-istio-system ` + --selector app=istiod ` + --output jsonpath='{.items[0].metadata.labels.istio\.io/rev}' +$Cluster = az aks show ` + --resource-group $env:RESOURCE_GROUP ` + --name aks-adaptive-apps | ConvertFrom-Json + +switch ($Cluster.networkProfile.outboundType) { + "loadBalancer" { + $OutboundIds = @($Cluster.networkProfile.loadBalancerProfile.effectiveOutboundIPs.id) + } + { $_ -in "managedNATGateway", "userAssignedNATGateway" } { + $OutboundIds = @($Cluster.networkProfile.natGatewayProfile.effectiveOutboundIPs.id) + } + default { + throw "Unsupported AKS outbound type: $($Cluster.networkProfile.outboundType)" + } +} +if ($OutboundIds.Count -eq 0) { + throw "AKS has no effective outbound public IPs." +} + +$EgressIps = foreach ($OutboundId in $OutboundIds) { + if ($OutboundId -notmatch "/providers/Microsoft.Network/publicIPAddresses/") { + throw "Only exact public IP resources are supported: $OutboundId" + } + $PublicIp = az network public-ip show --ids $OutboundId | ConvertFrom-Json + if ($PublicIp.publicIPAllocationMethod -ne "Static" -or + $PublicIp.sku.name -ne "Standard" -or + -not $PublicIp.ipAddress) { + throw "AKS egress IP must be Standard and static: $OutboundId" + } + $PublicIp.ipAddress +} + +rad deploy iac/aks-env.bicep ` + --workspace ws-azure-prod ` + --group rg-trading ` + --environment env-azure-prod ` + --parameters environmentName=env-azure-prod ` + --parameters namespace=env-azure-prod ` + --parameters azureSubscriptionId=$env:AZURE_SUBSCRIPTION ` + --parameters azureResourceGroup=$env:RESOURCE_GROUP ` + --parameters istioRevision=$IstioRevision ` + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.0" ` + --parameters "aksEgressIps=$($EgressIps -join ',')" ` + --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.0" +``` + | Resource type | AKS/Azure backend | | --- | --- | | `postgreSqlDatabases` | Azure Database for PostgreSQL Flexible Server | @@ -288,7 +427,20 @@ rad deploy iac/local-env.bicep \ --group rg-trading \ --environment env-local-prod \ --parameters environmentName=env-local-prod \ - --parameters namespace=env-local-prod + --parameters namespace=env-local-prod \ + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.0" +``` + +**PowerShell 7:** + +```powershell +rad deploy iac/local-env.bicep ` + --workspace ws-local-prod ` + --group rg-trading ` + --environment env-local-prod ` + --parameters environmentName=env-local-prod ` + --parameters namespace=env-local-prod ` + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.0" ``` | Resource type | K3s/local backend | @@ -306,6 +458,23 @@ required only when that recipe is exercised in the AI challenge. ## Validate +Before connecting to either cluster, run the automated semantic parity and security +assertions. They prove both recipes embed the same SQL source, the seed has an explicit +idempotency guard, Azure initialization requires TLS, firewall ranges are not broad, and +the corrected recipe registrations are not mutable `latest` references. + +**Bash:** + +```bash +bash resources/validate-postgres-recipes.sh +``` + +**PowerShell 7:** + +```powershell +bash resources/validate-postgres-recipes.sh +``` + AKS: ```bash @@ -358,7 +527,37 @@ bash resources/configure-recipes.sh k3s ``` The script creates or reuses ACR, publishes the custom SQL recipe for AKS, deploys the -appropriate environment definition, and verifies the registered recipes. +two corrected PostgreSQL recipes plus the custom SQL recipe, deploys the appropriate +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. + +## Upgrade an existing workshop + +Existing participants must republish and re-register before redeploying Challenge 05. +Do not keep the old external PostgreSQL `latest` registration. + +**Bash:** + +```bash +export AZURE_SUBSCRIPTION="" +export RESOURCE_GROUP="rg-adaptive-apps" +export ACR_NAME="" +bash resources/configure-recipes.sh all +``` + +**PowerShell 7:** + +```powershell +$env:AZURE_SUBSCRIPTION = "" +$env:RESOURCE_GROUP = "rg-adaptive-apps" +$env:ACR_NAME = "" +bash resources/configure-recipes.sh all +``` + +Then redeploy the unchanged `iac/app.bicep` to `env-local-prod` and `env-azure-prod` in +Challenge 05. Recipe initialization is idempotent, so existing tables remain and the +`Demo Account` seed is inserted only when no account with that name exists. > [!IMPORTANT] > Run Challenge 03 first, on the same platform. `iac/aks-env.bicep` and 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 fcdb0d473..adde12edb 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 @@ -526,6 +526,197 @@ Open . A reachable frontend and a complete Radius graph a core proof. MQTT-backed features can remain incomplete because Event Grid data-plane authorization is outside this challenge's portability proof. +### 6. Prove database initialization and backend readiness + +Run this acceptance check once on K3s after Stage 2 and once on AKS after Stage 3. It +waits for the recipe-owned Job because Kubernetes-extension deployment records API +acceptance, not Job completion. The backend readiness probe calls `/api/accounts`, so +Kubernetes does not mark the backend Ready until the schema is queryable. + +The verification pod reuses the recipe-created Secret through `secretKeyRef`; no command, +manifest, or output contains the database password. It prints only table names and the +number of `Demo Account` rows. + +**Bash:** + +```bash +SCHEMA_JOB="$(kubectl get jobs \ + --namespace "$APP_NAMESPACE" \ + --selector app=postgres-schema-initializer,resource=trading-db \ + --output jsonpath='{.items[0].metadata.name}')" +test -n "$SCHEMA_JOB" +kubectl wait \ + --namespace "$APP_NAMESPACE" \ + --for=condition=complete \ + "job/$SCHEMA_JOB" \ + --timeout=15m + +DB_HOST="$(kubectl get job "$SCHEMA_JOB" --namespace "$APP_NAMESPACE" \ + --output jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="PGHOST")].value}')" +DB_NAME="$(kubectl get job "$SCHEMA_JOB" --namespace "$APP_NAMESPACE" \ + --output jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="PGDATABASE")].value}')" +DB_USER="$(kubectl get job "$SCHEMA_JOB" --namespace "$APP_NAMESPACE" \ + --output jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="PGUSER")].value}')" +DB_SECRET="$(kubectl get job "$SCHEMA_JOB" --namespace "$APP_NAMESPACE" \ + --output jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="PGPASSWORD")].valueFrom.secretKeyRef.name}')" +DB_SSLMODE="$(kubectl get job "$SCHEMA_JOB" --namespace "$APP_NAMESPACE" \ + --output jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="PGSSLMODE")].value}')" +DB_SSLMODE="${DB_SSLMODE:-prefer}" + +cat < Date: Tue, 1 Sep 2026 14:37:27 +0200 Subject: [PATCH 16/29] Remove upgrade instructions for existing workshop Removed instructions for upgrading an existing workshop and redeploying Challenge 05. --- .../walkthrough/challenge-04/solution-04.md | 39 ------------------- 1 file changed, 39 deletions(-) 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 ba949a58f..fc666c1d5 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 @@ -532,43 +532,4 @@ 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. -## Upgrade an existing workshop -Existing participants must republish and re-register before redeploying Challenge 05. -Do not keep the old external PostgreSQL `latest` registration. - -**Bash:** - -```bash -export AZURE_SUBSCRIPTION="" -export RESOURCE_GROUP="rg-adaptive-apps" -export ACR_NAME="" -bash resources/configure-recipes.sh all -``` - -**PowerShell 7:** - -```powershell -$env:AZURE_SUBSCRIPTION = "" -$env:RESOURCE_GROUP = "rg-adaptive-apps" -$env:ACR_NAME = "" -bash resources/configure-recipes.sh all -``` - -Then redeploy the unchanged `iac/app.bicep` to `env-local-prod` and `env-azure-prod` in -Challenge 05. Recipe initialization is idempotent, so existing tables remain and the -`Demo Account` seed is inserted only when no account with that name exists. - -> [!IMPORTANT] -> Run Challenge 03 first, on the same platform. `iac/aks-env.bicep` and -> `iac/local-env.bicep` register recipes against the `Radius.Resources` types that -> `resources/configure-resource-types-aks.sh` and -> `resources/configure-resource-types-k3s.sh` create. Skipping Challenge 03 makes this -> script fail on unknown resource types. - -The AKS path needs the K3s tunnel only when it is run as `all`. Reconnect first if the -devcontainer was restarted: - -```bash -bash resources/prepare-k3s-azure-vm.sh connect -``` From 866e75dd22080f6747a1e19c4061f63fc9f7acd2 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Tue, 1 Sep 2026 15:28:52 +0200 Subject: [PATCH 17/29] Fix AKS egress IP parsing in Challenge 04 recipe configuration `az network public-ip show --query "[ipAddress,publicIPAllocationMethod,sku.name]" --output tsv` returns a JMESPath multi-select list, which Azure CLI renders as one value per line rather than as tab-separated columns. `read -r ip allocation sku` therefore consumed only the first line, leaving `allocation` and `sku` empty, and a valid Standard/Static AKS outbound IP was rejected with "must be a Standard, statically allocated public IP". The failure aborted configure-recipes.sh after the recipes were published to ACR but before `rad deploy iac/aks-env.bicep`, so env-azure-prod was left with no recipes and Challenge 05 failed with RecipeNotFoundFailure for every portable resource type. - Read the egress IP fields with mapfile (one value per row) and strip CR so CRLF `az` output cannot reintroduce the same class of failure. Apply the same handling to the outboundType and outbound resource ID lookups. - Include the observed sku and allocation in the rejection message so a future failure is self-diagnosing. - Replace the informational `rad recipe list` with an assert_recipes helper that fails the run when an expected type has no default recipe. `rad recipe list` prints an empty table and exits 0, so a broken run previously reported success. - Apply the identical parsing fix to the manual tutorial in solution-04.md, which carried the same defect. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../resources/configure-recipes.sh | 77 +++++++++++++++---- .../walkthrough/challenge-04/solution-04.md | 7 +- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh index f3ed768c0..d6be969f5 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -10,7 +10,7 @@ if [[ "$TARGET_PLATFORM" != "aks" && exit 1 fi -for command_name in az base64 kubectl mktemp rad tr; do +for command_name in az base64 grep kubectl mktemp rad tr; do command -v "$command_name" >/dev/null 2>&1 || { echo "Required command not found: $command_name" >&2 exit 1 @@ -52,6 +52,7 @@ get_aks_egress_ips() { local allocation local sku local ip + local -a ip_fields=() local -a resource_ids=() local -a ips=() @@ -59,7 +60,7 @@ get_aks_egress_ips() { --resource-group "$RESOURCE_GROUP" \ --name "$AKS_CLUSTER_NAME" \ --query networkProfile.outboundType \ - --output tsv)" + --output tsv | tr -d '\r')" case "$outbound_type" in loadBalancer) @@ -79,7 +80,7 @@ get_aks_egress_ips() { --resource-group "$RESOURCE_GROUP" \ --name "$AKS_CLUSTER_NAME" \ --query "$id_query" \ - --output tsv) + --output tsv | tr -d '\r') ((${#resource_ids[@]} > 0)) || { echo "AKS reports no effective outbound public IP resources for '${outbound_type}'." >&2 return 1 @@ -90,12 +91,17 @@ get_aks_egress_ips() { echo "Unsupported AKS egress resource '${resource_id}'; public IP prefixes and broad ranges are not allowed." >&2 return 1 } - read -r ip allocation sku < <(az network public-ip show \ + # Azure CLI renders a JMESPath multi-select list as one value per line rather than as + # tab-separated columns, so collect the fields as rows instead of reading a single row. + mapfile -t ip_fields < <(az network public-ip show \ --ids "$resource_id" \ --query "[ipAddress,publicIPAllocationMethod,sku.name]" \ - --output tsv) + --output tsv | tr -d '\r') + ip="${ip_fields[0]:-}" + allocation="${ip_fields[1]:-}" + sku="${ip_fields[2]:-}" [[ "$allocation" == "Static" && "$sku" == "Standard" ]] || { - echo "AKS egress IP '${resource_id}' must be a Standard, statically allocated public IP." >&2 + echo "AKS egress IP '${resource_id}' must be a Standard, statically allocated public IP (observed sku='${sku}', allocation='${allocation}')." >&2 return 1 } is_ipv4 "$ip" || { @@ -164,6 +170,40 @@ EOF --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:${WORKSHOP_RECIPE_VERSION}" } +# `rad recipe list` prints an empty table and still exits 0 when no recipes are +# registered, so assert the registrations that the later challenges depend on. +assert_recipes() { + local workspace="$1" + local environment="$2" + shift 2 + local recipe_json + local resource_type + local missing=0 + + rad recipe list \ + --workspace "$workspace" \ + --group "$RADIUS_GROUP" \ + --environment "$environment" + + recipe_json="$(rad recipe list \ + --workspace "$workspace" \ + --group "$RADIUS_GROUP" \ + --environment "$environment" \ + --output json)" + + for resource_type in "$@"; do + grep -Fq "$resource_type" <<<"$recipe_json" || { + echo "Missing 'default' recipe for ${resource_type} in ${environment}." >&2 + missing=1 + } + done + + ((missing == 0)) || { + echo "Recipe verification failed for ${environment}; the environment definition did not register the expected recipes." >&2 + return 1 + } +} + configure_aks() { unset KUBECONFIG kubectl config use-context "$AKS_CONTEXT" >/dev/null @@ -194,10 +234,15 @@ configure_aks() { --parameters "aksEgressIps=${aks_egress_ips}" \ --parameters "sqlDatabasesRecipeTemplatePath=${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.0" - rad recipe list \ - --workspace "$AKS_WORKSPACE" \ - --group "$RADIUS_GROUP" \ - --environment "$AKS_ENVIRONMENT" + assert_recipes "$AKS_WORKSPACE" "$AKS_ENVIRONMENT" \ + Radius.Resources/postgreSqlDatabases \ + Radius.Resources/mqttBrokers \ + Radius.Resources/idProviders \ + Radius.Resources/workloadIdentities \ + Radius.Resources/aiModels \ + Radius.Resources/governance \ + Radius.Resources/agentGuardrails \ + Radius.Resources/sqlDatabases } configure_k3s() { @@ -214,10 +259,14 @@ configure_k3s() { --parameters "recipeRegistry=${RECIPE_REGISTRY}" \ --parameters "postgresRecipeTemplatePath=${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:${WORKSHOP_RECIPE_VERSION}" - rad recipe list \ - --workspace "$K3S_WORKSPACE" \ - --group "$RADIUS_GROUP" \ - --environment "$K3S_ENVIRONMENT" + assert_recipes "$K3S_WORKSPACE" "$K3S_ENVIRONMENT" \ + Radius.Resources/postgreSqlDatabases \ + Radius.Resources/mqttBrokers \ + Radius.Resources/idProviders \ + Radius.Resources/workloadIdentities \ + Radius.Resources/aiModels \ + Radius.Resources/governance \ + Radius.Resources/agentGuardrails } publish_workshop_recipes 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 fc666c1d5..907adc712 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 @@ -316,10 +316,13 @@ for OUTBOUND_ID in "${OUTBOUND_IDS[@]}"; do echo "Only exact public IP resources are supported: $OUTBOUND_ID" >&2 exit 1 } - read -r IP ALLOCATION SKU < <(az network public-ip show \ + mapfile -t IP_FIELDS < <(az network public-ip show \ --ids "$OUTBOUND_ID" \ --query "[ipAddress,publicIPAllocationMethod,sku.name]" \ - --output tsv) + --output tsv | tr -d '\r') + IP="${IP_FIELDS[0]:-}" + ALLOCATION="${IP_FIELDS[1]:-}" + SKU="${IP_FIELDS[2]:-}" [[ "$ALLOCATION" == "Static" && "$SKU" == "Standard" && -n "$IP" ]] || { echo "AKS egress IP must be Standard and static: $OUTBOUND_ID" >&2 exit 1 From a92bd069a42b5edaf3f87c09638b07651f6e5ddb Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Tue, 1 Sep 2026 15:36:30 +0200 Subject: [PATCH 18/29] Fix InvalidTagNameCharacters on Azure resources in Radius recipes Azure rejects tag names containing '/', so the 'radapp.io/environment', 'radapp.io/resource' and 'radapp.io/application' tags applied to the PostgreSQL flexible server and the AVM SQL server caused challenge 05 to fail with InvalidTagNameCharacters when the postgreSqlDatabases recipe ran. Switch those Azure tag names to a hyphenated form. Kubernetes label keys legitimately allow '/', so the labels in postgres-kubernetes.bicep and the Kubernetes resources inside postgres-azure-flex.bicep are left unchanged. Because the recipes are published to ACR under immutable version tags, correcting the source alone is not enough. Bump the default WORKSHOP_RECIPE_VERSION to 1.0.1 so the corrected recipes are republished and re-registered, and stop sql-server from hardcoding :1.0.0 in the publish and register paths so it can never drift from the other recipes. Add an assertion to validate-postgres-recipes.sh so this class of defect cannot return. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../iac/recipes/postgres-azure-flex.bicep | 9 +++-- .../iac/recipes/sql-server.bicep | 7 ++-- .../resources/configure-recipes.sh | 6 ++-- .../resources/validate-postgres-recipes.sh | 21 +++++++++++- .../walkthrough/challenge-04/solution-04.md | 33 ++++++++++--------- 5 files changed, 50 insertions(+), 26 deletions(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep index 5974d09a1..6ee44883e 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep @@ -75,10 +75,13 @@ resource server 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = { } createMode: 'Default' } + // Azure rejects tag names containing '/', so Radius metadata uses a hyphen on Azure + // resources. The Kubernetes resources below keep the 'radapp.io/...' label form, + // which is a valid Kubernetes label key. tags: { - 'radapp.io/environment': context.environment.id - 'radapp.io/resource': context.resource.id - 'radapp.io/application': context.application == null ? '' : context.application.name + 'radapp.io-environment': context.environment.id + 'radapp.io-resource': context.resource.id + 'radapp.io-application': context.application == null ? '' : context.application.name } } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep index cea2feb84..9f531cb3b 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep @@ -30,10 +30,11 @@ module sqlServer 'br/public:avm/res/sql/server:0.12.0' = { } } ] + // Azure rejects tag names containing '/', so Radius metadata uses a hyphen. tags: { - 'radapp.io/environment': context.environment.id - 'radapp.io/resource': context.resource.id - 'radapp.io/application': context.application == null ? '' : context.application.name + 'radapp.io-environment': context.environment.id + 'radapp.io-resource': context.resource.id + 'radapp.io-application': context.application == null ? '' : context.application.name } } } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh index d6be969f5..37e955194 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -27,7 +27,7 @@ AKS_ENVIRONMENT="${AKS_ENVIRONMENT:-env-azure-prod}" K3S_ENVIRONMENT="${K3S_ENVIRONMENT:-env-local-prod}" RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" RECIPE_REGISTRY="${RECIPE_REGISTRY:-ghcr.io/microsoft/adaptive-apps/recipes}" -WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.0}" +WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.1}" [[ "$WORKSHOP_RECIPE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "WORKSHOP_RECIPE_VERSION must be an immutable semantic version such as 1.0.0." >&2 exit 1 @@ -161,7 +161,7 @@ EOF DOCKER_CONFIG="${TEMP_DIR}/docker" rad bicep publish \ --file iac/recipes/sql-server.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.0" + --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:${WORKSHOP_RECIPE_VERSION}" DOCKER_CONFIG="${TEMP_DIR}/docker" rad bicep publish \ --file iac/recipes/postgres-azure-flex.bicep \ --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:${WORKSHOP_RECIPE_VERSION}" @@ -232,7 +232,7 @@ configure_aks() { --parameters "recipeRegistry=${RECIPE_REGISTRY}" \ --parameters "postgresRecipeTemplatePath=${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:${WORKSHOP_RECIPE_VERSION}" \ --parameters "aksEgressIps=${aks_egress_ips}" \ - --parameters "sqlDatabasesRecipeTemplatePath=${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.0" + --parameters "sqlDatabasesRecipeTemplatePath=${ACR_NAME}.azurecr.io/recipes/sql-server:${WORKSHOP_RECIPE_VERSION}" assert_recipes "$AKS_WORKSPACE" "$AKS_ENVIRONMENT" \ Radius.Resources/postgreSqlDatabases \ diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh index 0e44c6dd1..724393d3a 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh @@ -6,8 +6,9 @@ RECIPE_DIR="${ROOT_DIR}/iac/recipes" SCHEMA_FILE="${RECIPE_DIR}/trading-schema.sql" AZURE_RECIPE="${RECIPE_DIR}/postgres-azure-flex.bicep" KUBERNETES_RECIPE="${RECIPE_DIR}/postgres-kubernetes.bicep" +SQL_RECIPE="${RECIPE_DIR}/sql-server.bicep" -for file in "$SCHEMA_FILE" "$AZURE_RECIPE" "$KUBERNETES_RECIPE"; do +for file in "$SCHEMA_FILE" "$AZURE_RECIPE" "$KUBERNETES_RECIPE" "$SQL_RECIPE"; do [[ -f "$file" ]] || { echo "Missing PostgreSQL recipe input: $file" >&2 exit 1 @@ -49,4 +50,22 @@ if grep -REiq "postgres(-azure-flex|-kubernetes)?:latest" \ exit 1 fi +# Azure rejects tag names containing '/', so recipes that provision Azure resources must +# use the hyphenated form. Kubernetes labels keep 'radapp.io/...' and are not checked here. +for recipe in "$AZURE_RECIPE" "$SQL_RECIPE"; do + for tag in radapp.io-environment radapp.io-resource radapp.io-application; do + grep -Fq "'${tag}'" "$recipe" || { + echo "$(basename "$recipe") must tag Azure resources with '${tag}'." >&2 + exit 1 + } + done +done + +# sql-server.bicep provisions only Azure resources, so any 'radapp.io/' key in it is an +# invalid Azure tag name rather than a Kubernetes label. +if grep -Fq "radapp.io/" "$SQL_RECIPE"; then + echo "sql-server.bicep uses a 'radapp.io/' Azure tag name, which Azure rejects." >&2 + exit 1 +fi + echo "PostgreSQL recipe parity and security assertions passed." 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 907adc712..260e7eab6 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 @@ -126,10 +126,11 @@ module sqlServer 'br/public:avm/res/sql/server:0.12.0' = { } } ] + // Azure rejects tag names containing '/', so Radius metadata uses a hyphen. tags: { - 'radapp.io/environment': context.environment.id - 'radapp.io/resource': context.resource.id - 'radapp.io/application': context.application == null ? '' : context.application.name + 'radapp.io-environment': context.environment.id + 'radapp.io-resource': context.resource.id + 'radapp.io-application': context.application == null ? '' : context.application.name } } } @@ -226,15 +227,15 @@ use immutable workshop tags rather than the external `latest` recipes. ```bash rad bicep publish \ --file iac/recipes/sql-server.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.0" + --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.1" rad bicep publish \ --file iac/recipes/postgres-azure-flex.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.0" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.1" rad bicep publish \ --file iac/recipes/postgres-kubernetes.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.0" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.1" ``` **PowerShell 7:** @@ -242,15 +243,15 @@ rad bicep publish \ ```powershell rad bicep publish ` --file iac/recipes/sql-server.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.0" + --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.1" rad bicep publish ` --file iac/recipes/postgres-azure-flex.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.0" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.1" rad bicep publish ` --file iac/recipes/postgres-kubernetes.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.0" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.1" ``` Register the Azure SQL teaching recipe: @@ -260,7 +261,7 @@ rad recipe register default \ --environment env-azure-prod \ --resource-type Radius.Resources/sqlDatabases \ --template-kind bicep \ - --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.0" + --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.1" ``` Verify: @@ -339,9 +340,9 @@ rad deploy iac/aks-env.bicep \ --parameters azureSubscriptionId="$AZURE_SUBSCRIPTION" \ --parameters azureResourceGroup="$RESOURCE_GROUP" \ --parameters istioRevision="$ISTIO_REVISION" \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.0" \ + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.1" \ --parameters aksEgressIps="$AKS_EGRESS_IPS" \ - --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.0" + --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.1" ``` **PowerShell 7:** @@ -392,9 +393,9 @@ rad deploy iac/aks-env.bicep ` --parameters azureSubscriptionId=$env:AZURE_SUBSCRIPTION ` --parameters azureResourceGroup=$env:RESOURCE_GROUP ` --parameters istioRevision=$IstioRevision ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.0" ` + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.1" ` --parameters "aksEgressIps=$($EgressIps -join ',')" ` - --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.0" + --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.1" ``` | Resource type | AKS/Azure backend | @@ -431,7 +432,7 @@ rad deploy iac/local-env.bicep \ --environment env-local-prod \ --parameters environmentName=env-local-prod \ --parameters namespace=env-local-prod \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.0" + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.1" ``` **PowerShell 7:** @@ -443,7 +444,7 @@ rad deploy iac/local-env.bicep ` --environment env-local-prod ` --parameters environmentName=env-local-prod ` --parameters namespace=env-local-prod ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.0" + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.1" ``` | Resource type | K3s/local backend | From 1f425b9ef7ff53e1d3246ec37da225f589590fa6 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Tue, 1 Sep 2026 15:58:33 +0200 Subject: [PATCH 19/29] Serialize PostgreSQL flexible server child resources Azure Database for PostgreSQL Flexible Server serializes control-plane operations per server, but Bicep infers dependencies only from `parent:`. The database, both server configurations and the firewall rules therefore depended solely on the server, so ARM fanned all of them out in parallel as soon as it was created and the losers failed with: ServerIsBusy: Cannot complete operation while server 'pg-...' is busy processing another operation. Try again later. Which operation loses the race is non-deterministic, so challenge 05 failed intermittently and a retry could appear to succeed. Chain the children explicitly - database, then require_secure_transport, then ssl_min_protocol_version, then the firewall rules with @batchSize(1) so the loop is applied one rule at a time. This also fixes a latent bug: the schema initializer job never depended on the database resource, and now does so transitively. Bump WORKSHOP_RECIPE_VERSION to 1.0.2 so the corrected recipe is republished and re-registered, and assert the serialization in validate-postgres-recipes.sh. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../iac/recipes/postgres-azure-flex.bicep | 14 ++++++++++ .../resources/configure-recipes.sh | 2 +- .../resources/validate-postgres-recipes.sh | 12 +++++++++ .../walkthrough/challenge-04/solution-04.md | 26 +++++++++---------- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep index 6ee44883e..fb4e91785 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep @@ -94,6 +94,10 @@ resource databaseResource 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2 } } +// PostgreSQL Flexible Server serializes control-plane operations per server. Bicep infers +// dependencies only from `parent:`, so without the explicit chain below ARM fans every child +// out in parallel and the losers fail with ServerIsBusy. Each child therefore waits on the +// previous one. This also makes the schema initializer transitively wait for the database. resource requireSecureTransport 'Microsoft.DBforPostgreSQL/flexibleServers/configurations@2024-08-01' = { parent: server name: 'require_secure_transport' @@ -101,6 +105,9 @@ resource requireSecureTransport 'Microsoft.DBforPostgreSQL/flexibleServers/confi value: 'on' source: 'user-override' } + dependsOn: [ + databaseResource + ] } resource minimumTlsVersion 'Microsoft.DBforPostgreSQL/flexibleServers/configurations@2024-08-01' = { @@ -110,8 +117,12 @@ resource minimumTlsVersion 'Microsoft.DBforPostgreSQL/flexibleServers/configurat value: 'TLSv1.2' source: 'user-override' } + dependsOn: [ + requireSecureTransport + ] } +@batchSize(1) resource aksFirewallRules 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2024-08-01' = [ for (egressIp, index) in egressIps: { parent: server @@ -120,6 +131,9 @@ resource aksFirewallRules 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRul startIpAddress: egressIp endIpAddress: egressIp } + dependsOn: [ + minimumTlsVersion + ] } ] diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh index 37e955194..f2530b036 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -27,7 +27,7 @@ AKS_ENVIRONMENT="${AKS_ENVIRONMENT:-env-azure-prod}" K3S_ENVIRONMENT="${K3S_ENVIRONMENT:-env-local-prod}" RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" RECIPE_REGISTRY="${RECIPE_REGISTRY:-ghcr.io/microsoft/adaptive-apps/recipes}" -WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.1}" +WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.2}" [[ "$WORKSHOP_RECIPE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "WORKSHOP_RECIPE_VERSION must be an immutable semantic version such as 1.0.0." >&2 exit 1 diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh index 724393d3a..dcadd3bf9 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh @@ -68,4 +68,16 @@ if grep -Fq "radapp.io/" "$SQL_RECIPE"; then exit 1 fi +# PostgreSQL Flexible Server serializes control-plane operations, so the server's child +# resources must be chained rather than deployed in parallel or they fail with ServerIsBusy. +grep -Fq "@batchSize(1)" "$AZURE_RECIPE" || { + echo "Azure PostgreSQL recipe must deploy firewall rules one at a time." >&2 + exit 1 +} +dependency_blocks="$(grep -c "dependsOn:" "$AZURE_RECIPE" || true)" +if (( dependency_blocks < 4 )); then + echo "Azure PostgreSQL recipe must chain the flexible server's child resources; found ${dependency_blocks} dependsOn blocks, expected at least 4." >&2 + exit 1 +fi + echo "PostgreSQL recipe parity and security assertions passed." 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 260e7eab6..ab51a9104 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 @@ -227,15 +227,15 @@ use immutable workshop tags rather than the external `latest` recipes. ```bash rad bicep publish \ --file iac/recipes/sql-server.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.1" + --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.2" rad bicep publish \ --file iac/recipes/postgres-azure-flex.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.1" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.2" rad bicep publish \ --file iac/recipes/postgres-kubernetes.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.1" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.2" ``` **PowerShell 7:** @@ -243,15 +243,15 @@ rad bicep publish \ ```powershell rad bicep publish ` --file iac/recipes/sql-server.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.1" + --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.2" rad bicep publish ` --file iac/recipes/postgres-azure-flex.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.1" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.2" rad bicep publish ` --file iac/recipes/postgres-kubernetes.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.1" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.2" ``` Register the Azure SQL teaching recipe: @@ -261,7 +261,7 @@ rad recipe register default \ --environment env-azure-prod \ --resource-type Radius.Resources/sqlDatabases \ --template-kind bicep \ - --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.1" + --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.2" ``` Verify: @@ -340,9 +340,9 @@ rad deploy iac/aks-env.bicep \ --parameters azureSubscriptionId="$AZURE_SUBSCRIPTION" \ --parameters azureResourceGroup="$RESOURCE_GROUP" \ --parameters istioRevision="$ISTIO_REVISION" \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.1" \ + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.2" \ --parameters aksEgressIps="$AKS_EGRESS_IPS" \ - --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.1" + --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.2" ``` **PowerShell 7:** @@ -393,9 +393,9 @@ rad deploy iac/aks-env.bicep ` --parameters azureSubscriptionId=$env:AZURE_SUBSCRIPTION ` --parameters azureResourceGroup=$env:RESOURCE_GROUP ` --parameters istioRevision=$IstioRevision ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.1" ` + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.2" ` --parameters "aksEgressIps=$($EgressIps -join ',')" ` - --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.1" + --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.2" ``` | Resource type | AKS/Azure backend | @@ -432,7 +432,7 @@ rad deploy iac/local-env.bicep \ --environment env-local-prod \ --parameters environmentName=env-local-prod \ --parameters namespace=env-local-prod \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.1" + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.2" ``` **PowerShell 7:** @@ -444,7 +444,7 @@ rad deploy iac/local-env.bicep ` --environment env-local-prod ` --parameters environmentName=env-local-prod ` --parameters namespace=env-local-prod ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.1" + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.2" ``` | Resource type | K3s/local backend | From b85d19cc5fed98b2f6f63fa7b3e90ac01d5c4e1e Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Tue, 1 Sep 2026 16:26:49 +0200 Subject: [PATCH 20/29] Nest recipe secrets inside the values map Radius surfaces a recipe's `values` map as the resource's properties. The three database recipes returned `secrets` as a sibling of `values` instead of nested inside it, which is not part of that contract - so dynamic-rp discarded the entire output payload. Deploying the application then failed with: InvalidTemplate: The language expression property 'secrets' doesn't exist, available properties are 'application, environment, provisioningState, recipe, size, status'. Note that host, port, database and username were missing too, not just the password: the unexpected sibling key cost every output, not only the secret. Confirmed against a working control in the same deployment: backend-identity, provisioned by an upstream recipe, surfaced all seven of its read-only outputs, and the registered postgreSqlDatabases schema already declared host, port, database, username and secrets.password correctly. The two ai-model recipes in this repository already nested secrets inside values, so the three database recipes were the outliers. Move `secrets` inside `values` in postgres-azure-flex, postgres-kubernetes and sql-server, matching both the upstream recipes and the ai-model recipes here. Bump WORKSHOP_RECIPE_VERSION to 1.0.3 so the corrected recipes are republished and re-registered, and assert the output shape in validate-postgres-recipes.sh. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../iac/recipes/postgres-azure-flex.bicep | 8 ++--- .../iac/recipes/postgres-kubernetes.bicep | 8 ++--- .../iac/recipes/sql-server.bicep | 7 ++-- .../resources/configure-recipes.sh | 2 +- .../resources/validate-postgres-recipes.sh | 14 ++++++++ .../walkthrough/challenge-04/solution-04.md | 32 +++++++++---------- 6 files changed, 43 insertions(+), 28 deletions(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep index fb4e91785..cb0f8c21b 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep @@ -276,9 +276,9 @@ output result object = { port: port database: databaseResource.name username: user - } - secrets: { - #disable-next-line outputs-should-not-contain-secrets - password: password + secrets: { + #disable-next-line outputs-should-not-contain-secrets + password: password + } } } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep index c3e08a241..b219a742c 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep @@ -281,9 +281,9 @@ output result object = { port: port database: database username: user - } - secrets: { - #disable-next-line outputs-should-not-contain-secrets - password: password + secrets: { + #disable-next-line outputs-should-not-contain-secrets + password: password + } } } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep index 9f531cb3b..cab6b5e4b 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep @@ -48,8 +48,9 @@ output result object = { port: 1433 database: databaseName username: 'sqladmin' - } - secrets: { - password: adminPassword + secrets: { + #disable-next-line outputs-should-not-contain-secrets + password: adminPassword + } } } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh index f2530b036..825230f0b 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -27,7 +27,7 @@ AKS_ENVIRONMENT="${AKS_ENVIRONMENT:-env-azure-prod}" K3S_ENVIRONMENT="${K3S_ENVIRONMENT:-env-local-prod}" RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" RECIPE_REGISTRY="${RECIPE_REGISTRY:-ghcr.io/microsoft/adaptive-apps/recipes}" -WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.2}" +WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.3}" [[ "$WORKSHOP_RECIPE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "WORKSHOP_RECIPE_VERSION must be an immutable semantic version such as 1.0.0." >&2 exit 1 diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh index dcadd3bf9..d0dc42aeb 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh @@ -80,4 +80,18 @@ if (( dependency_blocks < 4 )); then exit 1 fi +# Radius surfaces a recipe's `values` map as the resource's properties. A top-level `secrets` +# sibling is not part of that contract and causes every output to be discarded, so `secrets` +# must be nested inside `values` alongside host/port/database/username. +for recipe in "$AZURE_RECIPE" "$KUBERNETES_RECIPE" "$SQL_RECIPE"; do + if grep -Eq "^ secrets:" "$recipe"; then + echo "$(basename "$recipe") declares a top-level 'secrets' output; nest it inside 'values'." >&2 + exit 1 + fi + grep -Eq "^ secrets:" "$recipe" || { + echo "$(basename "$recipe") must return 'secrets' nested inside the 'values' map." >&2 + exit 1 + } +done + echo "PostgreSQL recipe parity and security assertions passed." 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 ab51a9104..d3c1d02bc 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 @@ -144,9 +144,9 @@ output result object = { port: 1433 database: databaseName username: 'sqladmin' - } - secrets: { - password: adminPassword + secrets: { + password: adminPassword + } } } ``` @@ -227,15 +227,15 @@ use immutable workshop tags rather than the external `latest` recipes. ```bash rad bicep publish \ --file iac/recipes/sql-server.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.2" + --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.3" rad bicep publish \ --file iac/recipes/postgres-azure-flex.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.2" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.3" rad bicep publish \ --file iac/recipes/postgres-kubernetes.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.2" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.3" ``` **PowerShell 7:** @@ -243,15 +243,15 @@ rad bicep publish \ ```powershell rad bicep publish ` --file iac/recipes/sql-server.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.2" + --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.3" rad bicep publish ` --file iac/recipes/postgres-azure-flex.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.2" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.3" rad bicep publish ` --file iac/recipes/postgres-kubernetes.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.2" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.3" ``` Register the Azure SQL teaching recipe: @@ -261,7 +261,7 @@ rad recipe register default \ --environment env-azure-prod \ --resource-type Radius.Resources/sqlDatabases \ --template-kind bicep \ - --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.2" + --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.3" ``` Verify: @@ -340,9 +340,9 @@ rad deploy iac/aks-env.bicep \ --parameters azureSubscriptionId="$AZURE_SUBSCRIPTION" \ --parameters azureResourceGroup="$RESOURCE_GROUP" \ --parameters istioRevision="$ISTIO_REVISION" \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.2" \ + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.3" \ --parameters aksEgressIps="$AKS_EGRESS_IPS" \ - --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.2" + --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.3" ``` **PowerShell 7:** @@ -393,9 +393,9 @@ rad deploy iac/aks-env.bicep ` --parameters azureSubscriptionId=$env:AZURE_SUBSCRIPTION ` --parameters azureResourceGroup=$env:RESOURCE_GROUP ` --parameters istioRevision=$IstioRevision ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.2" ` + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.3" ` --parameters "aksEgressIps=$($EgressIps -join ',')" ` - --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.2" + --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.3" ``` | Resource type | AKS/Azure backend | @@ -432,7 +432,7 @@ rad deploy iac/local-env.bicep \ --environment env-local-prod \ --parameters environmentName=env-local-prod \ --parameters namespace=env-local-prod \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.2" + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.3" ``` **PowerShell 7:** @@ -444,7 +444,7 @@ rad deploy iac/local-env.bicep ` --environment env-local-prod ` --parameters environmentName=env-local-prod ` --parameters namespace=env-local-prod ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.2" + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.3" ``` | Resource type | K3s/local backend | From c2567285f887650fd8210bccb3f4fd8c5863b9dd Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Tue, 1 Sep 2026 17:58:01 +0200 Subject: [PATCH 21/29] Keep the PostgreSQL recipe result evaluable so Radius records its values The Azure PostgreSQL recipe returned every value the postgreSqlDatabases type declares, yet trading-db came back with only application, environment, provisioningState, recipe, size and status. app.bicep then failed with InvalidTemplate: the language expression property 'secrets' doesn't exist. The result output could not be evaluated. Bicep compiles map(aksFirewallRules, rule => rule.id) to references('aksFirewallRules', 'full'), which the Radius deployment engine resolves to the template's resource metadata. That object exposes resourceId, not id, so evaluation threw: Azure.Deployments.Core.Exceptions.ExpressionException: The language expression property 'id' doesn't exist, available properties are '..., resourceId, symbolicName, ...' bicep-de logs that only at WARN. The deployment still reported Succeeded, but it returned no outputs, and prepareRecipeResponse skips the whole block when the outputs map is empty, so Radius stored none of the recipe's values without surfacing an error. The missing Kubernetes entries in status.outputResources were the same failure: non-ARM resources arrive only via result.resources. Precompute the firewall rule IDs with resourceId() and build the Kubernetes IDs from the compile-time name variables, leaving the server FQDN as the only runtime reference in the result. Verified against the compiled template: references() is gone entirely and the values map still carries host, port, database, username and secrets. Bump the recipe version so the corrected template is republished, since ACR tags are immutable, and guard the result block in validate-postgres-recipes.sh. Also correct the rationale on the nested-secrets guard. Nesting is required because a schema-declared secrets property is populated from the values map, whereas a top-level sibling is materialized into a managed Radius.Security/secrets resource that leaves only properties.secrets.name behind. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../iac/recipes/postgres-azure-flex.bicep | 27 ++++++++++++++---- .../resources/configure-recipes.sh | 2 +- .../resources/validate-postgres-recipes.sh | 28 +++++++++++++++++-- .../walkthrough/challenge-04/solution-04.md | 26 ++++++++--------- 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep index cb0f8c21b..cda80de3f 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep @@ -42,8 +42,25 @@ var egressIps = map(filter(split(aksEgressIps, ','), ip => !empty(trim(ip))), ip var schemaSql = loadTextContent('trading-schema.sql') var schemaRevision = take(uniqueString(schemaSql), 8) var initializerName = 'postgres-${uniqueString(context.resource.id)}-schema-${schemaRevision}' +var credentialsName = '${initializerName}-credentials' var port = 5432 +// The `result` output must be evaluated without any runtime reference() call. +// The Radius deployment engine resolves `references(, 'full')` to the +// template's resource metadata, which exposes `resourceId` rather than `id`, so a +// `map(aksFirewallRules, rule => rule.id)` throws while the outputs are evaluated. +// That failure is logged only as a warning: the deployment still reports success but +// returns no outputs at all, so Radius silently records none of the recipe values. +// Building the IDs and Kubernetes names from compile-time values keeps the output +// evaluable. See resources/validate-postgres-recipes.sh for the regression guard. +var firewallRuleIds = [ + for (egressIp, index) in egressIps: resourceId( + 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules', + serverName, + 'aks-egress-${index + 1}' + ) +] + extension kubernetes with { kubeConfig: '' namespace: context.runtime.kubernetes.namespace @@ -139,7 +156,7 @@ resource aksFirewallRules 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRul resource credentials 'core/Secret@v1' = { metadata: { - name: '${initializerName}-credentials' + name: credentialsName labels: { resource: context.resource.name 'radapp.io/application': context.application == null ? '' : context.application.name @@ -267,10 +284,10 @@ output result object = { databaseResource.id requireSecureTransport.id minimumTlsVersion.id - '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/Secret/${credentials.metadata.name}' - '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/ConfigMap/${initSql.metadata.name}' - '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/batch/Job/${schemaInitializer.metadata.name}' - ], map(aksFirewallRules, rule => rule.id)) + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/Secret/${credentialsName}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/ConfigMap/${initializerName}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/batch/Job/${initializerName}' + ], firewallRuleIds) values: { host: server.properties.fullyQualifiedDomainName port: port diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh index 825230f0b..dfc172dac 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -27,7 +27,7 @@ AKS_ENVIRONMENT="${AKS_ENVIRONMENT:-env-azure-prod}" K3S_ENVIRONMENT="${K3S_ENVIRONMENT:-env-local-prod}" RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" RECIPE_REGISTRY="${RECIPE_REGISTRY:-ghcr.io/microsoft/adaptive-apps/recipes}" -WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.3}" +WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.4}" [[ "$WORKSHOP_RECIPE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "WORKSHOP_RECIPE_VERSION must be an immutable semantic version such as 1.0.0." >&2 exit 1 diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh index d0dc42aeb..acdbeef51 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh @@ -80,9 +80,11 @@ if (( dependency_blocks < 4 )); then exit 1 fi -# Radius surfaces a recipe's `values` map as the resource's properties. A top-level `secrets` -# sibling is not part of that contract and causes every output to be discarded, so `secrets` -# must be nested inside `values` alongside host/port/database/username. +# Radius surfaces a recipe's `values` map as the resource's properties, and a schema-declared +# `secrets` property is populated from that map. Returning `secrets` as a top-level sibling +# instead routes it into a managed Radius.Security/secrets resource, which leaves only +# `properties.secrets.name` on the resource and makes `db.properties.secrets.password` +# unresolvable in app.bicep. Keep `secrets` nested inside `values`. for recipe in "$AZURE_RECIPE" "$KUBERNETES_RECIPE" "$SQL_RECIPE"; do if grep -Eq "^ secrets:" "$recipe"; then echo "$(basename "$recipe") declares a top-level 'secrets' output; nest it inside 'values'." >&2 @@ -94,4 +96,24 @@ for recipe in "$AZURE_RECIPE" "$KUBERNETES_RECIPE" "$SQL_RECIPE"; do } done +# The Radius deployment engine resolves `references(, 'full')` to the template's +# resource metadata, which exposes 'resourceId' rather than 'id'. A `map(, r => r.id)` +# or a `.metadata.name` lookup inside `output result` therefore throws while the +# outputs are evaluated. That failure is only a warning: the deployment still reports success but +# returns no outputs, so Radius records none of the recipe's values and the resource silently +# comes back without host/port/database/username/secrets. Keep the result block reference-free. +output_block="$(sed -n '/^output result object = {/,$p' "$AZURE_RECIPE")" +if grep -Eq "map\([a-zA-Z]" <<<"$output_block"; then + echo "Azure PostgreSQL recipe maps over a resource collection in 'output result'; build the IDs with resourceId() instead." >&2 + exit 1 +fi +if grep -Fq ".metadata.name" <<<"$output_block"; then + echo "Azure PostgreSQL recipe dereferences a Kubernetes resource in 'output result'; use the compile-time name variable instead." >&2 + exit 1 +fi +grep -Fq "var firewallRuleIds = [" "$AZURE_RECIPE" || { + echo "Azure PostgreSQL recipe must precompute firewall rule IDs with resourceId()." >&2 + exit 1 +} + echo "PostgreSQL recipe parity and security assertions passed." 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 d3c1d02bc..a5c40a8e6 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 @@ -227,15 +227,15 @@ use immutable workshop tags rather than the external `latest` recipes. ```bash rad bicep publish \ --file iac/recipes/sql-server.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.3" + --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.4" rad bicep publish \ --file iac/recipes/postgres-azure-flex.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.3" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.4" rad bicep publish \ --file iac/recipes/postgres-kubernetes.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.3" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.4" ``` **PowerShell 7:** @@ -243,15 +243,15 @@ rad bicep publish \ ```powershell rad bicep publish ` --file iac/recipes/sql-server.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.3" + --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.4" rad bicep publish ` --file iac/recipes/postgres-azure-flex.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.3" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.4" rad bicep publish ` --file iac/recipes/postgres-kubernetes.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.3" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.4" ``` Register the Azure SQL teaching recipe: @@ -261,7 +261,7 @@ rad recipe register default \ --environment env-azure-prod \ --resource-type Radius.Resources/sqlDatabases \ --template-kind bicep \ - --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.3" + --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.4" ``` Verify: @@ -340,9 +340,9 @@ rad deploy iac/aks-env.bicep \ --parameters azureSubscriptionId="$AZURE_SUBSCRIPTION" \ --parameters azureResourceGroup="$RESOURCE_GROUP" \ --parameters istioRevision="$ISTIO_REVISION" \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.3" \ + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.4" \ --parameters aksEgressIps="$AKS_EGRESS_IPS" \ - --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.3" + --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.4" ``` **PowerShell 7:** @@ -393,9 +393,9 @@ rad deploy iac/aks-env.bicep ` --parameters azureSubscriptionId=$env:AZURE_SUBSCRIPTION ` --parameters azureResourceGroup=$env:RESOURCE_GROUP ` --parameters istioRevision=$IstioRevision ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.3" ` + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.4" ` --parameters "aksEgressIps=$($EgressIps -join ',')" ` - --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.3" + --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.4" ``` | Resource type | AKS/Azure backend | @@ -432,7 +432,7 @@ rad deploy iac/local-env.bicep \ --environment env-local-prod \ --parameters environmentName=env-local-prod \ --parameters namespace=env-local-prod \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.3" + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.4" ``` **PowerShell 7:** @@ -444,7 +444,7 @@ rad deploy iac/local-env.bicep ` --environment env-local-prod ` --parameters environmentName=env-local-prod ` --parameters namespace=env-local-prod ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.3" + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.4" ``` | Resource type | K3s/local backend | From fb62ee53edc8655de49e0a66bb9f5ef46ec22420 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Tue, 1 Sep 2026 18:31:04 +0200 Subject: [PATCH 22/29] Fix recipe secret consumption so the backend container can deploy `rad deploy iac/app.bicep` failed at the backend container with: InvalidTemplate: Unable to process template language expressions for resource 'Applications.Core/containers/backend' ... 'The language expression property 'secrets' doesn't exist, available properties are 'application, database, environment, host, port, provisioningState, recipe, size, status, username'.' The preceding fix made the recipe's `values` land correctly (host, port, database and username are now present), which isolated the remaining failure to `secrets` alone. Radius treats `secrets` as a framework-owned property. It is listed in `pkg/resourceutil.BasicProperties`, which is documented as excluded from generic recipe-output copying and from connection-based environment variable injection. That means neither shape of recipe output can populate `properties.secrets.password`: * nested inside `values`, the map becomes a computed value and is then dropped by `addComputedValuesToResourceProperties`, silently; * as a top-level sibling of `values`, `materializeRecipeSecrets` creates a managed `Radius.Security/secrets` resource and writes back only `properties.secrets.name`, because secret values are never persisted on the owner resource. The second route is additionally unavailable here: consumers bind managed secrets through the reserved `secrets.name` sub-property, and the pinned upstream `postgreSqlDatabases` type declares only `secrets.password`. Bind the Kubernetes Secret directly instead. The recipes already create a Secret holding the password for their own schema-initializer Job, so give it the stable name `-credentials` and have the backend read it with `valueFrom.secretRef`. A container `secretRef.source` that is not an `Applications.Core/secretStores` resource ID is passed through verbatim as a `secretKeyRef.name`, so a plain Secret name resolves without any additional resource. The Secret name is derived from a new compile-time `tradingDbName` variable. Using `tradingDb.name` would compile to `reference('tradingDb').name`, and the resource's runtime property bag has no `name` key, which would have reproduced the same class of failure this change fixes. The Kubernetes recipe gets the same treatment so that both environments keep sharing one `app.bicep`, and its `result` block is rewritten to be reference-free while it is being touched. Changes: * postgres-azure-flex.bicep: stable `credentialsName`, drop the nested `secrets` output. * postgres-kubernetes.bicep: matching `credentialsName`, add `initializerName`, drop the nested `secrets` output, and build the `result` block from compile-time variables instead of `.metadata.name`. * app.bicep: add `tradingDbName` and bind the password via `valueFrom.secretRef`. * validate-postgres-recipes.sh: replace the previous guard, which wrongly required a nested `secrets` block, with guards covering the new contract, and extend the existing output guards to both PostgreSQL recipes. * configure-recipes.sh, solution-04.md: bump the recipe version to 1.0.5 and document both `result` constraints. Every new guard was verified to fail when its defect is reintroduced. All three templates compile against the pinned extension, and the compiled `app.bicep` resolves the Secret name at build time. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../04-adaptive-apps/iac/app.bicep | 15 +++- .../iac/recipes/postgres-azure-flex.bicep | 16 +++-- .../iac/recipes/postgres-kubernetes.bicep | 34 +++++---- .../resources/configure-recipes.sh | 2 +- .../resources/validate-postgres-recipes.sh | 71 +++++++++++++------ .../walkthrough/challenge-04/solution-04.md | 63 ++++++++++++---- 6 files changed, 145 insertions(+), 56 deletions(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep index 9541ba449..27d0b4de6 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep @@ -93,8 +93,14 @@ resource tradingApp 'Applications.Core/applications@2023-10-01-preview' = { } } +// The PostgreSQL recipe names its credentials Secret '-credentials'. The backend +// binds that Secret by name, so the name is declared once here as a compile-time value. Deriving +// it from `tradingDb.name` instead would compile to a runtime `reference('tradingDb').name` call, +// and the resource's property bag does not expose `name`. +var tradingDbName = 'trading-db' + resource tradingDb 'Radius.Resources/postgreSqlDatabases@2025-08-01-preview' = { - name: 'trading-db' + name: tradingDbName properties: { environment: environment application: tradingApp.id @@ -153,7 +159,12 @@ resource backend 'Applications.Core/containers@2023-10-01-preview' = { value: 'http://+:8080' } CONNECTION_DB_SECRETS_PASSWORD: { - value: tradingDb.properties.secrets.password + valueFrom: { + secretRef: { + source: '${tradingDbName}-credentials' + key: 'password' + } + } } AZURE_CLIENT_ID: { value: backendIdentity.properties.clientId diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep index cda80de3f..e4cc960b4 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep @@ -42,7 +42,10 @@ var egressIps = map(filter(split(aksEgressIps, ','), ip => !empty(trim(ip))), ip var schemaSql = loadTextContent('trading-schema.sql') var schemaRevision = take(uniqueString(schemaSql), 8) var initializerName = 'postgres-${uniqueString(context.resource.id)}-schema-${schemaRevision}' -var credentialsName = '${initializerName}-credentials' +// app.bicep binds this Secret by name, so the name must stay stable and predictable. Deriving +// it from the schema revision or the resource ID hash would change it whenever the schema +// changes and make it impossible to reference from the application template. +var credentialsName = '${context.resource.name}-credentials' var port = 5432 // The `result` output must be evaluated without any runtime reference() call. @@ -293,9 +296,12 @@ output result object = { port: port database: databaseResource.name username: user - secrets: { - #disable-next-line outputs-should-not-contain-secrets - password: password - } + // The password is deliberately not returned as a recipe value. Radius treats `secrets` as a + // framework-owned property (pkg/resourceutil.BasicProperties), so a `secrets` map nested in + // `values` is dropped by the recipe-output copier, and a top-level `secrets` object is instead + // materialized into a managed Radius.Security/secrets resource that is surfaced only through a + // reserved `secrets.name` reference this resource type does not declare. Either way + // `properties.secrets.password` never resolves. Consumers bind the Kubernetes Secret above by + // name. See resources/validate-postgres-recipes.sh for the regression guard. } } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep index b219a742c..505599760 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep @@ -25,6 +25,10 @@ var uniqueName = 'postgres-${uniqueString(context.resource.id)}' var schemaSql = loadTextContent('trading-schema.sql') var schemaRevision = take(uniqueString(schemaSql), 8) var port = 5432 +var initializerName = '${uniqueName}-schema-${schemaRevision}' +// app.bicep binds this Secret by name and is shared by both environments, so the name must +// match the Azure recipe's stable naming rather than the generated deployment name. +var credentialsName = '${context.resource.name}-credentials' extension kubernetes with { kubeConfig: '' @@ -33,7 +37,7 @@ extension kubernetes with { resource credentials 'core/Secret@v1' = { metadata: { - name: '${uniqueName}-credentials' + name: credentialsName labels: { resource: context.resource.name 'radapp.io/application': context.application == null ? '' : context.application.name @@ -47,7 +51,7 @@ resource credentials 'core/Secret@v1' = { resource initSql 'core/ConfigMap@v1' = { metadata: { - name: '${uniqueName}-schema-${schemaRevision}' + name: initializerName labels: { resource: context.resource.name 'radapp.io/application': context.application == null ? '' : context.application.name @@ -179,7 +183,7 @@ resource service 'core/Service@v1' = { resource schemaInitializer 'batch/Job@v1' = { metadata: { - name: '${uniqueName}-schema-${schemaRevision}' + name: initializerName labels: { app: 'postgres-schema-initializer' resource: context.resource.name @@ -268,22 +272,26 @@ exit 1 ] } +// The `result` output must be evaluated without any runtime reference() call. The Radius +// deployment engine resolves those lookups against the template's resource metadata, which does +// not expose the same shape, so a `.metadata.name` dereference inside `output result` +// throws while the outputs are evaluated. That failure is logged only as a warning: the deployment +// still reports success but returns no outputs at all, so Radius silently records none of the +// recipe values. Build the names from compile-time variables instead. output result object = { resources: [ - '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/Secret/${credentials.metadata.name}' - '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/ConfigMap/${initSql.metadata.name}' - '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/apps/Deployment/${postgresql.metadata.name}' - '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/Service/${service.metadata.name}' - '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/batch/Job/${schemaInitializer.metadata.name}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/Secret/${credentialsName}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/ConfigMap/${initializerName}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/apps/Deployment/${uniqueName}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/core/Service/${uniqueName}' + '/planes/kubernetes/local/namespaces/${context.runtime.kubernetes.namespace}/providers/batch/Job/${initializerName}' ] values: { - host: '${service.metadata.name}.${context.runtime.kubernetes.namespace}.svc.cluster.local' + host: '${uniqueName}.${context.runtime.kubernetes.namespace}.svc.cluster.local' port: port database: database username: user - secrets: { - #disable-next-line outputs-should-not-contain-secrets - password: password - } + // The password is deliberately not returned as a recipe value; see the matching comment in + // postgres-azure-flex.bicep. Consumers bind the Kubernetes Secret above by name. } } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh index dfc172dac..a8e963f08 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -27,7 +27,7 @@ AKS_ENVIRONMENT="${AKS_ENVIRONMENT:-env-azure-prod}" K3S_ENVIRONMENT="${K3S_ENVIRONMENT:-env-local-prod}" RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" RECIPE_REGISTRY="${RECIPE_REGISTRY:-ghcr.io/microsoft/adaptive-apps/recipes}" -WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.4}" +WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.5}" [[ "$WORKSHOP_RECIPE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "WORKSHOP_RECIPE_VERSION must be an immutable semantic version such as 1.0.0." >&2 exit 1 diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh index acdbeef51..35b5ce66a 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh @@ -7,8 +7,9 @@ SCHEMA_FILE="${RECIPE_DIR}/trading-schema.sql" AZURE_RECIPE="${RECIPE_DIR}/postgres-azure-flex.bicep" KUBERNETES_RECIPE="${RECIPE_DIR}/postgres-kubernetes.bicep" SQL_RECIPE="${RECIPE_DIR}/sql-server.bicep" +APP_TEMPLATE="${ROOT_DIR}/iac/app.bicep" -for file in "$SCHEMA_FILE" "$AZURE_RECIPE" "$KUBERNETES_RECIPE" "$SQL_RECIPE"; do +for file in "$SCHEMA_FILE" "$AZURE_RECIPE" "$KUBERNETES_RECIPE" "$SQL_RECIPE" "$APP_TEMPLATE"; do [[ -f "$file" ]] || { echo "Missing PostgreSQL recipe input: $file" >&2 exit 1 @@ -80,35 +81,61 @@ if (( dependency_blocks < 4 )); then exit 1 fi -# Radius surfaces a recipe's `values` map as the resource's properties, and a schema-declared -# `secrets` property is populated from that map. Returning `secrets` as a top-level sibling -# instead routes it into a managed Radius.Security/secrets resource, which leaves only -# `properties.secrets.name` on the resource and makes `db.properties.secrets.password` -# unresolvable in app.bicep. Keep `secrets` nested inside `values`. -for recipe in "$AZURE_RECIPE" "$KUBERNETES_RECIPE" "$SQL_RECIPE"; do - if grep -Eq "^ secrets:" "$recipe"; then - echo "$(basename "$recipe") declares a top-level 'secrets' output; nest it inside 'values'." >&2 +# Radius treats `secrets` as a framework-owned property (pkg/resourceutil.BasicProperties). A +# `secrets` map nested inside `values` is discarded by the recipe-output copier, and a top-level +# `secrets` object is instead materialized into a managed Radius.Security/secrets resource that is +# surfaced only through a reserved `secrets.name` sub-property these resource types do not declare. +# Either shape leaves `properties.secrets.password` unresolvable, so the PostgreSQL recipes must not +# return the password as a recipe value at all; consumers bind the credentials Secret by name. +# +# The Radius deployment engine also resolves `references(, 'full')` to the template's +# resource metadata, which exposes 'resourceId' rather than 'id'. A `map(, r => r.id)` +# or a `.metadata.name` lookup inside `output result` therefore throws while the +# outputs are evaluated. That failure is only a warning: the deployment still reports success but +# returns no outputs, so Radius records none of the recipe's values and the resource silently comes +# back without host/port/database/username. Keep the result block reference-free. +for recipe in "$AZURE_RECIPE" "$KUBERNETES_RECIPE"; do + output_block="$(sed -n '/^output result object = {/,$p' "$recipe")" + if grep -Eq "^[[:space:]]*secrets:" <<<"$output_block"; then + echo "$(basename "$recipe") returns 'secrets' from the recipe result, which Radius never surfaces as properties.secrets.password." >&2 exit 1 fi - grep -Eq "^ secrets:" "$recipe" || { - echo "$(basename "$recipe") must return 'secrets' nested inside the 'values' map." >&2 + if grep -Eq "map\([a-zA-Z]" <<<"$output_block"; then + echo "$(basename "$recipe") maps over a resource collection in 'output result'; build the IDs with resourceId() instead." >&2 + exit 1 + fi + if grep -Fq ".metadata.name" <<<"$output_block"; then + echo "$(basename "$recipe") dereferences a Kubernetes resource in 'output result'; use the compile-time name variable instead." >&2 + exit 1 + fi +done + +# Both PostgreSQL recipes must name their credentials Secret deterministically from the Radius +# resource name so that the shared application template can bind it in either environment. +for recipe in "$AZURE_RECIPE" "$KUBERNETES_RECIPE"; do + grep -Fq "context.resource.name}-credentials" "$recipe" || { + echo "$(basename "$recipe") must name its credentials Secret '-credentials'." >&2 exit 1 } done -# The Radius deployment engine resolves `references(, 'full')` to the template's -# resource metadata, which exposes 'resourceId' rather than 'id'. A `map(, r => r.id)` -# or a `.metadata.name` lookup inside `output result` therefore throws while the -# outputs are evaluated. That failure is only a warning: the deployment still reports success but -# returns no outputs, so Radius records none of the recipe's values and the resource silently -# comes back without host/port/database/username/secrets. Keep the result block reference-free. -output_block="$(sed -n '/^output result object = {/,$p' "$AZURE_RECIPE")" -if grep -Eq "map\([a-zA-Z]" <<<"$output_block"; then - echo "Azure PostgreSQL recipe maps over a resource collection in 'output result'; build the IDs with resourceId() instead." >&2 +# The application template must consume that Secret rather than a property Radius never populates. +if grep -v '^[[:space:]]*//' "$APP_TEMPLATE" | grep -Fq "properties.secrets.password"; then + echo "app.bicep reads properties.secrets.password, which Radius never populates." >&2 exit 1 fi -if grep -Fq ".metadata.name" <<<"$output_block"; then - echo "Azure PostgreSQL recipe dereferences a Kubernetes resource in 'output result'; use the compile-time name variable instead." >&2 +grep -Fq "tradingDbName}-credentials" "$APP_TEMPLATE" || { + echo "app.bicep must bind the database password from the recipe's credentials Secret." >&2 + exit 1 +} +grep -Fq "secretRef:" "$APP_TEMPLATE" || { + echo "app.bicep must inject the database password with valueFrom.secretRef." >&2 + exit 1 +} +# Deriving the Secret name from `tradingDb.name` compiles to a runtime `reference('tradingDb').name` +# call, and the resource's property bag does not expose `name`. Keep the name compile-time. +if grep -v '^[[:space:]]*//' "$APP_TEMPLATE" | grep -Fq 'tradingDb.name'; then + echo "app.bicep derives a name from tradingDb.name, which compiles to an unresolvable runtime reference()." >&2 exit 1 fi grep -Fq "var firewallRuleIds = [" "$AZURE_RECIPE" || { 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 a5c40a8e6..283b761cc 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 @@ -81,6 +81,43 @@ Every recipe returns: - `result.values`: read-only properties returned to the application - `result.secrets`: sensitive values stored through Radius secret handling +Two constraints on the `result` object are worth calling out, because breaking either one +fails in a way that is hard to diagnose. + +**The result block must be evaluable without runtime lookups.** Radius resolves a +`references(, 'full')` call against the template's resource metadata, which +exposes `resourceId` rather than `id`. A `map(, r => r.id)` or a +`.metadata.name` dereference inside `output result` therefore throws while the +outputs are evaluated. That failure is logged only as a warning, so the deployment still +reports success while returning no outputs at all, and the resource silently comes back with +none of its declared properties. Build IDs and names from compile-time variables instead. + +**Radius never writes secret values onto the resource.** `secrets` is a framework-owned +property, so a `secrets` map nested inside `values` is discarded, and a top-level +`result.secrets` object is materialized into a managed `Radius.Security/secrets` resource that +is surfaced only through a reserved `secrets.name` reference. Either way +`db.properties.secrets.password` never resolves. The PostgreSQL recipes therefore write the +password into a Kubernetes Secret named `-credentials` and the application binds +it with `valueFrom.secretRef`, which is also how the recipe's own schema-initializer Job reads +it: + +```bicep +env: { + CONNECTION_DB_SECRETS_PASSWORD: { + valueFrom: { + secretRef: { + source: '${tradingDbName}-credentials' + key: 'password' + } + } + } +} +``` + +Derive that name from a compile-time variable rather than from `tradingDb.name`, which would +compile to a runtime `reference('tradingDb').name` call that cannot resolve. +`resources/validate-postgres-recipes.sh` guards all of these. + ## Manual tutorial, Stage 1: Author the Azure SQL recipe Open `iac/recipes/sql-server.bicep` and walk through: @@ -227,15 +264,15 @@ use immutable workshop tags rather than the external `latest` recipes. ```bash rad bicep publish \ --file iac/recipes/sql-server.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.4" + --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.5" rad bicep publish \ --file iac/recipes/postgres-azure-flex.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.4" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.5" rad bicep publish \ --file iac/recipes/postgres-kubernetes.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.4" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.5" ``` **PowerShell 7:** @@ -243,15 +280,15 @@ rad bicep publish \ ```powershell rad bicep publish ` --file iac/recipes/sql-server.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.4" + --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.5" rad bicep publish ` --file iac/recipes/postgres-azure-flex.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.4" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.5" rad bicep publish ` --file iac/recipes/postgres-kubernetes.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.4" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.5" ``` Register the Azure SQL teaching recipe: @@ -261,7 +298,7 @@ rad recipe register default \ --environment env-azure-prod \ --resource-type Radius.Resources/sqlDatabases \ --template-kind bicep \ - --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.4" + --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.5" ``` Verify: @@ -340,9 +377,9 @@ rad deploy iac/aks-env.bicep \ --parameters azureSubscriptionId="$AZURE_SUBSCRIPTION" \ --parameters azureResourceGroup="$RESOURCE_GROUP" \ --parameters istioRevision="$ISTIO_REVISION" \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.4" \ + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.5" \ --parameters aksEgressIps="$AKS_EGRESS_IPS" \ - --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.4" + --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.5" ``` **PowerShell 7:** @@ -393,9 +430,9 @@ rad deploy iac/aks-env.bicep ` --parameters azureSubscriptionId=$env:AZURE_SUBSCRIPTION ` --parameters azureResourceGroup=$env:RESOURCE_GROUP ` --parameters istioRevision=$IstioRevision ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.4" ` + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.5" ` --parameters "aksEgressIps=$($EgressIps -join ',')" ` - --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.4" + --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.5" ``` | Resource type | AKS/Azure backend | @@ -432,7 +469,7 @@ rad deploy iac/local-env.bicep \ --environment env-local-prod \ --parameters environmentName=env-local-prod \ --parameters namespace=env-local-prod \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.4" + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.5" ``` **PowerShell 7:** @@ -444,7 +481,7 @@ rad deploy iac/local-env.bicep ` --environment env-local-prod ` --parameters environmentName=env-local-prod ` --parameters namespace=env-local-prod ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.4" + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.5" ``` | Resource type | K3s/local backend | From 352946640b1455ad8d12c4266e8292ed6165020e Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 2 Sep 2026 10:30:27 +0200 Subject: [PATCH 23/29] Fix the schema initializer Job, which has never run on a Windows checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema initializer Job has been failing since it was introduced, and the failure is invisible: the recipe does not wait for the Job, so `rad deploy` reports success while the trading database is left completely empty. On the cluster the Job sits at `Failed 0/1`, having burned through its backoff limit with seven pods that each died in seconds. The cause is line endings. `.gitattributes` pins `eol=lf` through a per-file allowlist that covers `app.bicep`, `ai.bicep`, `ai-model-*.bicep` and `**/*.sh` but omits `iac/recipes/postgres-*.bicep`. Those files are stored as LF and checked out as CRLF on Windows. Bicep preserves on-disk line endings verbatim through `loadTextContent()` and `'''…'''` multi-line strings, so the carriage returns end up inside the Job's shell script. `sh` does not treat CR as whitespace, so `do` stops being the `do` keyword and the container exits immediately: sh: syntax error: unexpected word (expecting "do") Linux and macOS checkouts are unaffected, which is why this went unnoticed. Fix the root cause by replacing the allowlist with globs covering `*.bicep`, `*.sql`, `*.yaml` and `*.yml`, so files added later are pinned by default rather than silently omitted. Additionally strip carriage returns in the recipes themselves with `replace(..., '\r', '')`, which keeps them correct on a checkout configured any other way. ARM evaluates that at deployment time; a probe deployment confirmed `length('a\r\nb')` is 4 while the replaced value is 3. Fixing the script alone is not deployable, because it exposes a second defect. A Job's `spec.template` is immutable, so a Job must be replaced rather than updated when its pod template changes, and that only happens if its name changes. The name was derived from `uniqueString(schemaSql)`, which tracks the schema and nothing else. Renaming the credentials Secret in the previous change therefore altered the pod template while leaving the name identical, and the deployment failed with: Job.batch "postgres-...-schema-c6pwzgiu" is invalid: spec.template: Invalid value: ...: field is immutable Changing the script would have hit exactly the same wall. Hash every input the pod template embeds — the script, the credentials Secret name, the user, the database, the port and the client image tag — so any pod template change yields a new Job name. Radius creates the new Job and garbage-collects the old one through `result.resources`. Both PostgreSQL recipes get the same treatment, since `app.bicep` is shared by the AKS and K3s environments. Changes: * .gitattributes: replace the per-file bicep allowlist with globs. * postgres-azure-flex.bicep, postgres-kubernetes.bicep: strip CR from the schema SQL and the initializer script, and hash the whole pod template into the Job name. * validate-postgres-recipes.sh: guard CR stripping, the use of the extracted script variable, the Job name hash inputs, and the .gitattributes globs. * configure-recipes.sh, solution-04.md: bump the recipe version to 1.0.6 and document both constraints, including how to verify the Job actually completed rather than trusting the deployment result. Each of the nine new guards was verified to fail when its defect is reintroduced. All three templates still compile, and they were compiled from a CRLF working tree so the compiled output exercises the stripping path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 13 +++-- .../iac/recipes/postgres-azure-flex.bicep | 44 ++++++++++----- .../iac/recipes/postgres-kubernetes.bicep | 44 ++++++++++----- .../resources/configure-recipes.sh | 2 +- .../resources/validate-postgres-recipes.sh | 46 +++++++++++++++ .../walkthrough/challenge-04/solution-04.md | 56 ++++++++++++++----- 6 files changed, 161 insertions(+), 44 deletions(-) diff --git a/.gitattributes b/.gitattributes index 1cbd9602d..44323809c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,9 +8,14 @@ "/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-08/solution-08.md" text eol=lf -"/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep" text eol=lf +# Anything that is executed or interpreted inside a container must keep LF endings. Bicep +# preserves on-disk line endings verbatim through loadTextContent() and multi-line strings, +# so a CRLF checkout embeds carriage returns into recipe shell scripts and breaks them at +# runtime. These are globs rather than a per-file allowlist: listing files individually is +# how iac/recipes/postgres-*.bicep came to be missed. +"/03-Azure/01-01-App Innovation/04-adaptive-apps/**/*.bicep" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/**/*.sql" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/**/*.yaml" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/**/*.yml" text eol=lf "/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json" text eol=lf -"/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/ai.bicep" text eol=lf -"/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/ai-model-*.bicep" text eol=lf -"/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/local-recipe-registry.yaml" text eol=lf "/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json" text eol=lf diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep index e4cc960b4..4a2e4d7e1 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep @@ -39,14 +39,41 @@ var skuBySize = { } var serverName = 'pg-${uniqueString(context.resource.id)}' var egressIps = map(filter(split(aksEgressIps, ','), ip => !empty(trim(ip))), ip => trim(ip)) -var schemaSql = loadTextContent('trading-schema.sql') -var schemaRevision = take(uniqueString(schemaSql), 8) -var initializerName = 'postgres-${uniqueString(context.resource.id)}-schema-${schemaRevision}' +// Strip carriage returns from everything that is executed or interpreted inside the cluster. +// Both files are stored in git with LF, but a Windows checkout rewrites them to CRLF unless +// .gitattributes pins them, and Bicep preserves the on-disk line endings verbatim through +// loadTextContent() and multi-line strings. A CR then reaches the container: `sh` does not +// treat it as whitespace, so `do` stops being the `do` keyword and the script dies with +// "syntax error: unexpected word" before it ever contacts the database. .gitattributes now +// pins these files, and this keeps the recipe correct even when it does not. +var schemaSql = replace(loadTextContent('trading-schema.sql'), '\r', '') +var initializerScript = replace(''' +for attempt in $(seq 1 60); do + if psql --set=ON_ERROR_STOP=1 --file=/schema/init.sql; then + exit 0 + fi + echo "Azure PostgreSQL is not ready for TLS schema initialization (attempt ${attempt}/60)." >&2 + sleep 5 +done +exit 1 +''', '\r', '') // app.bicep binds this Secret by name, so the name must stay stable and predictable. Deriving // it from the schema revision or the resource ID hash would change it whenever the schema // changes and make it impossible to reference from the application template. var credentialsName = '${context.resource.name}-credentials' var port = 5432 +// A Job's `spec.template` is immutable, so the Job has to be replaced rather than updated +// whenever anything inside the pod template changes. Hashing only the schema SQL is not +// enough: a change to the script, the image tag or the name of the Secret holding the +// password leaves the Job name identical and Kubernetes rejects the update with +// "spec.template: field is immutable". Hash every input the pod template embeds so that any +// such change yields a new Job name, which Radius then creates while garbage-collecting the +// old Job through result.resources. +var schemaRevision = take( + uniqueString(schemaSql, initializerScript, credentialsName, user, database, string(port), clientTag), + 8 +) +var initializerName = 'postgres-${uniqueString(context.resource.id)}-schema-${schemaRevision}' // The `result` output must be evaluated without any runtime reference() call. // The Radius deployment engine resolves `references(, 'full')` to the @@ -212,16 +239,7 @@ resource schemaInitializer 'batch/Job@v1' = { command: [ 'sh' '-ceu' - ''' -for attempt in $(seq 1 60); do - if psql --set=ON_ERROR_STOP=1 --file=/schema/init.sql; then - exit 0 - fi - echo "Azure PostgreSQL is not ready for TLS schema initialization (attempt ${attempt}/60)." >&2 - sleep 5 -done -exit 1 -''' + initializerScript ] env: [ { diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep index 505599760..ce5f59255 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep @@ -22,13 +22,40 @@ var memoryBySize = { } var sizeKey = context.resource.properties.?size ?? 'S' var uniqueName = 'postgres-${uniqueString(context.resource.id)}' -var schemaSql = loadTextContent('trading-schema.sql') -var schemaRevision = take(uniqueString(schemaSql), 8) +// Strip carriage returns from everything that is executed or interpreted inside the cluster. +// Both files are stored in git with LF, but a Windows checkout rewrites them to CRLF unless +// .gitattributes pins them, and Bicep preserves the on-disk line endings verbatim through +// loadTextContent() and multi-line strings. A CR then reaches the container: `sh` does not +// treat it as whitespace, so `do` stops being the `do` keyword and the script dies with +// "syntax error: unexpected word" before it ever contacts the database. .gitattributes now +// pins these files, and this keeps the recipe correct even when it does not. +var schemaSql = replace(loadTextContent('trading-schema.sql'), '\r', '') +var initializerScript = replace(''' +for attempt in $(seq 1 60); do + if psql --set=ON_ERROR_STOP=1 --file=/schema/init.sql; then + exit 0 + fi + echo "PostgreSQL is not ready for schema initialization (attempt ${attempt}/60)." >&2 + sleep 5 +done +exit 1 +''', '\r', '') var port = 5432 -var initializerName = '${uniqueName}-schema-${schemaRevision}' // app.bicep binds this Secret by name and is shared by both environments, so the name must // match the Azure recipe's stable naming rather than the generated deployment name. var credentialsName = '${context.resource.name}-credentials' +// A Job's `spec.template` is immutable, so the Job has to be replaced rather than updated +// whenever anything inside the pod template changes. Hashing only the schema SQL is not +// enough: a change to the script, the image tag or the name of the Secret holding the +// password leaves the Job name identical and Kubernetes rejects the update with +// "spec.template: field is immutable". Hash every input the pod template embeds so that any +// such change yields a new Job name, which Radius then creates while garbage-collecting the +// old Job through result.resources. +var schemaRevision = take( + uniqueString(schemaSql, initializerScript, credentialsName, user, database, string(port), tag), + 8 +) +var initializerName = '${uniqueName}-schema-${schemaRevision}' extension kubernetes with { kubeConfig: '' @@ -209,16 +236,7 @@ resource schemaInitializer 'batch/Job@v1' = { command: [ 'sh' '-ceu' - ''' -for attempt in $(seq 1 60); do - if psql --set=ON_ERROR_STOP=1 --file=/schema/init.sql; then - exit 0 - fi - echo "PostgreSQL is not ready for schema initialization (attempt ${attempt}/60)." >&2 - sleep 5 -done -exit 1 -''' + initializerScript ] env: [ { diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh index a8e963f08..68c7386a4 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -27,7 +27,7 @@ AKS_ENVIRONMENT="${AKS_ENVIRONMENT:-env-azure-prod}" K3S_ENVIRONMENT="${K3S_ENVIRONMENT:-env-local-prod}" RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" RECIPE_REGISTRY="${RECIPE_REGISTRY:-ghcr.io/microsoft/adaptive-apps/recipes}" -WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.5}" +WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.6}" [[ "$WORKSHOP_RECIPE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "WORKSHOP_RECIPE_VERSION must be an immutable semantic version such as 1.0.0." >&2 exit 1 diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh index 35b5ce66a..f35f79caf 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh @@ -143,4 +143,50 @@ grep -Fq "var firewallRuleIds = [" "$AZURE_RECIPE" || { exit 1 } +# Bicep preserves on-disk line endings verbatim through loadTextContent() and multi-line +# strings, so a CRLF checkout embeds carriage returns into the schema initializer's shell +# script. `sh` does not treat CR as whitespace, so `do` stops being the `do` keyword and +# the container exits immediately with "syntax error: unexpected word" without ever reaching +# the database. The Job then burns through its backoff limit and the schema is silently never +# applied, because the recipe does not wait for the Job to succeed. +for recipe in "$AZURE_RECIPE" "$KUBERNETES_RECIPE"; do + if (( $(grep -c -F "'\r', ''" "$recipe") < 2 )); then + echo "$(basename "$recipe") must strip carriage returns from both the schema SQL and the initializer script." >&2 + exit 1 + fi + grep -A1 -F "'-ceu'" "$recipe" | grep -Fq "initializerScript" || { + echo "$(basename "$recipe") must run the schema initializer from the CR-stripped initializerScript variable, not an inline multi-line string." >&2 + exit 1 + } +done + +# A Job's `spec.template` is immutable, so the Job must be replaced rather than updated +# whenever anything in the pod template changes. Hashing only the schema SQL leaves the Job +# name unchanged when the script, the image tag or the credentials Secret name changes, and +# Kubernetes then rejects the deployment with "spec.template: field is immutable". +for recipe in "$AZURE_RECIPE" "$KUBERNETES_RECIPE"; do + revision_expr="$(sed -n '/^var schemaRevision = take(/,/^)$/p' "$recipe")" + for input in initializerScript credentialsName; do + grep -Fq "$input" <<<"$revision_expr" || { + echo "$(basename "$recipe") must include ${input} in the schema revision hash so pod template changes produce a new Job name." >&2 + exit 1 + } + done +done + +# The line-ending fix above is a safety net; the root cause is that .gitattributes pinned +# eol=lf with a per-file allowlist that omitted the recipes. Keep the glob so new files are +# covered by default. +GITATTRIBUTES="$(cd "${ROOT_DIR}/../../.." && pwd)/.gitattributes" +[[ -f "$GITATTRIBUTES" ]] || { + echo "Missing .gitattributes at repository root: $GITATTRIBUTES" >&2 + exit 1 +} +for pattern in '*.bicep' '*.sql'; do + grep -Fq "04-adaptive-apps/**/${pattern}\" text eol=lf" "$GITATTRIBUTES" || { + echo ".gitattributes must pin '${pattern}' under 04-adaptive-apps to eol=lf." >&2 + exit 1 + } +done + echo "PostgreSQL recipe parity and security assertions passed." 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 283b761cc..4d558874c 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 @@ -118,6 +118,36 @@ Derive that name from a compile-time variable rather than from `tradingDb.name`, compile to a runtime `reference('tradingDb').name` call that cannot resolve. `resources/validate-postgres-recipes.sh` guards all of these. +### Kubernetes resources created by a recipe + +The PostgreSQL recipes also create a Kubernetes Job that applies the trading schema. Two +properties of that Job are easy to get wrong and fail silently. + +**A Job's `spec.template` is immutable.** Kubernetes rejects an update that changes the pod +template of an existing Job with `spec.template: field is immutable`, so the Job has to be +replaced rather than updated. That only happens if its *name* changes, which means the name +must be derived from everything the pod template embeds — the script, the image tag and the +name of the Secret holding the password, not just the schema SQL. The recipes hash all of +those into `schemaRevision`. Radius then creates the new Job and garbage-collects the old one +through `result.resources`. + +**Line endings reach the container verbatim.** Bicep preserves the on-disk line endings of +`loadTextContent()` and of `'''…'''` multi-line strings, so a CRLF checkout embeds carriage +returns in the Job's shell script. `sh` does not treat CR as whitespace, so `do` stops +being the `do` keyword and the container exits immediately with +`syntax error: unexpected word`. Because the recipe does not wait for the Job to succeed, the +deployment still reports success and the database is simply left empty. The repository pins +these files to LF in `.gitattributes`, and the recipes additionally strip carriage returns +with `replace(..., '\r', '')` so they stay correct on any checkout. + +Verify the Job actually completed rather than trusting the deployment result: + +```bash +kubectl get jobs -n +``` + +`COMPLETIONS` must read `1/1`. + ## Manual tutorial, Stage 1: Author the Azure SQL recipe Open `iac/recipes/sql-server.bicep` and walk through: @@ -264,15 +294,15 @@ use immutable workshop tags rather than the external `latest` recipes. ```bash rad bicep publish \ --file iac/recipes/sql-server.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.5" + --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.6" rad bicep publish \ --file iac/recipes/postgres-azure-flex.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.5" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.6" rad bicep publish \ --file iac/recipes/postgres-kubernetes.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.5" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.6" ``` **PowerShell 7:** @@ -280,15 +310,15 @@ rad bicep publish \ ```powershell rad bicep publish ` --file iac/recipes/sql-server.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.5" + --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.6" rad bicep publish ` --file iac/recipes/postgres-azure-flex.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.5" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.6" rad bicep publish ` --file iac/recipes/postgres-kubernetes.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.5" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.6" ``` Register the Azure SQL teaching recipe: @@ -298,7 +328,7 @@ rad recipe register default \ --environment env-azure-prod \ --resource-type Radius.Resources/sqlDatabases \ --template-kind bicep \ - --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.5" + --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.6" ``` Verify: @@ -377,9 +407,9 @@ rad deploy iac/aks-env.bicep \ --parameters azureSubscriptionId="$AZURE_SUBSCRIPTION" \ --parameters azureResourceGroup="$RESOURCE_GROUP" \ --parameters istioRevision="$ISTIO_REVISION" \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.5" \ + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.6" \ --parameters aksEgressIps="$AKS_EGRESS_IPS" \ - --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.5" + --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.6" ``` **PowerShell 7:** @@ -430,9 +460,9 @@ rad deploy iac/aks-env.bicep ` --parameters azureSubscriptionId=$env:AZURE_SUBSCRIPTION ` --parameters azureResourceGroup=$env:RESOURCE_GROUP ` --parameters istioRevision=$IstioRevision ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.5" ` + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.6" ` --parameters "aksEgressIps=$($EgressIps -join ',')" ` - --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.5" + --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.6" ``` | Resource type | AKS/Azure backend | @@ -469,7 +499,7 @@ rad deploy iac/local-env.bicep \ --environment env-local-prod \ --parameters environmentName=env-local-prod \ --parameters namespace=env-local-prod \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.5" + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.6" ``` **PowerShell 7:** @@ -481,7 +511,7 @@ rad deploy iac/local-env.bicep ` --environment env-local-prod ` --parameters environmentName=env-local-prod ` --parameters namespace=env-local-prod ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.5" + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.6" ``` | Resource type | K3s/local backend | From 0a2d2d9b48cfd24339ce06a6ee86a9f67e1aad82 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 2 Sep 2026 13:45:11 +0200 Subject: [PATCH 24/29] Stop asking the application to do MQTT auth it cannot do (#20) Challenge 05 could not finish on AKS. After the schema initializer was fixed the backend still crash-looped, and `rad deploy` failed before the frontend container was ever created: "code": "Internal", "message": "Container state is 'Waiting' Reason: CrashLoopBackOff, ..." The backend reached PostgreSQL fine; `/api/accounts` returned data. It died on MQTT. `MqttOrderListener` called `SubscribeAsync` on a client that never connected, which threw `MqttClientNotConnectedException`, and .NET's default `BackgroundServiceExceptionBehavior.StopHost` stopped the host. The connection was refused because Azure Event Grid accepts a Microsoft Entra token only through the MQTT v5 enhanced-authentication fields: `Authentication Method` set to `OAUTH2-JWT` and the bearer token in `Authentication Data`. The published Trading backend sends the token as an ordinary CONNECT password, which Event Grid never inspects. This is a defect in the application image, not in the workshop's Azure setup, so provisioning topic spaces, federated credentials and Event Grid data-plane roles would all have been correct work and the broker would still have refused every connection. Register the in-cluster Mosquitto recipe for `Radius.Resources/mqttBrokers` on AKS, behind a `mqttRecipeTemplatePath` override so the Event Grid recipe can be restored in one parameter once the application supports enhanced authentication. This keeps the change where it belongs. `iac/app.bicep` still requests the portable contract and names no broker; only the platform team's recipe registration differs. Swapping the recipe alone was not enough, which exposed a second defect. `app.bicep` read `MQTT_AUTH_METHOD` and `MQTT_TOKEN_AUDIENCE` from the workload identity, and the Azure workload-identity recipe hard-codes `authMethod: 'OAUTH2-JWT'`. The application therefore attempted token authentication regardless of which broker recipe the environment had registered, so the identity silently overrode the platform team's choice. Read both from the broker instead, which is what the `mqttBrokers` resource type documents. The expression is identical in both environments and each recipe supplies its own answer, so K3s is unaffected; its no-op identity recipe already reported `none`. While here, derive `AZURE_TENANT_ID` from the identity resource rather than from the bare override parameter. The parameter defaults to empty, so the containers were given an empty tenant even though the recipe returns the correct one. Guard all of this in `validate-postgres-recipes.sh`: the AKS environment must not default `mqttBrokers` to the Event Grid recipe, and `app.bicep` must read the MQTT auth method and audience from the broker and the tenant from the identity. Correct the documentation that described this as a topic-space and permission-binding gap, since that was never the cause, and that promised a reachable frontend with only MQTT features degraded, since the deployment actually failed outright. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../challenges/challenge-05.md | 28 +++--- .../04-adaptive-apps/iac/aks-env.bicep | 23 ++++- .../04-adaptive-apps/iac/app.bicep | 24 +++-- .../resources/validate-postgres-recipes.sh | 49 +++++++++ .../walkthrough/challenge-04/solution-04.md | 10 +- .../walkthrough/challenge-05/solution-05.md | 99 +++++++++++++++---- 6 files changed, 196 insertions(+), 37 deletions(-) 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 3f940c7d0..bbbe1b4e9 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 @@ -46,9 +46,11 @@ with that name in the other. - Prompt for the frontend password. Do not put credentials in source, shell history, screenshots, or evidence files. - Keep AI disabled. Challenge 08 owns AI adaptation. -- Do not complete Azure Event Grid MQTT authorization in this challenge. The Azure - deployment may prove graph and resource portability before MQTT publish/subscribe - reaches full runtime parity; preserve that gap rather than adding shared secrets. +- Do not add Azure Event Grid MQTT authorization in this challenge. On AKS the + `mqttBrokers` contract intentionally resolves to an in-cluster broker, because the + published application cannot perform the MQTT v5 enhanced authentication that Event + Grid requires. Change the recipe registration if you must, never the application model, + and never add a shared secret. - Reach the frontend only through `rad resource expose` or Kubernetes port-forwarding. Do not expose ports on the private K3s VM. @@ -104,11 +106,11 @@ Capture a side-by-side comparison that shows: - The same application model and resource-type contracts on both targets. - A local PostgreSQL and MQTT implementation on K3s. -- Azure Database for PostgreSQL and Azure Event Grid MQTT implementations on AKS. +- Azure Database for PostgreSQL on AKS, and the same in-cluster MQTT broker on both. - Independent application state in each Radius control plane. - Parameters that changed and parameters that stayed the same. - Application-team responsibilities versus platform-team responsibilities. -- Known runtime gaps, especially Azure Event Grid MQTT authorization. +- Known runtime gaps, and why a recipe can differ from the "obvious" cloud service. ## Success criteria @@ -121,8 +123,8 @@ Capture a side-by-side comparison that shows: one `Demo Account`, and a backend that is Ready only after `/api/accounts` succeeds. - The frontend can be exposed through each active control plane, one at a time. - No password or cluster credential is committed or captured in evidence. -- The team can explain why deployment portability is proven even though Azure MQTT - publish/subscribe still requires topic-space and permission configuration. +- The team can explain why deployment portability is proven even though the AKS + environment resolves `mqttBrokers` to an in-cluster broker rather than Event Grid. - The ownership split is clear: application teams own the model and safe parameters; platform teams own control planes, environments, provider scopes, recipes, and backing-service policy. @@ -151,10 +153,14 @@ Capture a side-by-side comparison that shows: ### Runtime parity -1. Successful provisioning and a reachable frontend do not prove every MQTT flow. -2. Event Grid MQTT requires topic spaces, permission bindings, and federated workload - identities in addition to an endpoint. -3. Record the gap; do not bypass identity with a shared secret. +1. A recipe that provisions successfully does not prove the application can use what it + provisioned. +2. Event Grid MQTT accepts an Entra token only through MQTT v5 enhanced authentication, + not as a CONNECT password. A client that gets this wrong is refused at connect time. +3. A crash-looping background service can fail a whole deployment: .NET stops the host by + default when a `BackgroundService` throws. +4. The fix belongs in the recipe registration, not in `iac/app.bicep`. Never bypass + identity with a shared secret. ## Learning resources diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep index c15ebb319..7293901ae 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep @@ -12,6 +12,11 @@ param postgresRecipeTemplatePath string @description('Comma-separated static AKS public egress IP addresses allowed by PostgreSQL.') param aksEgressIps string param sqlDatabasesRecipeTemplatePath string = '' +@description(''' +Override for the Radius.Resources/mqttBrokers recipe. Empty selects the in-cluster +Mosquitto recipe, which is the AKS default for the reason documented on mqttTemplatePath. +''') +param mqttRecipeTemplatePath string = '' param istioRevision string param azureSubscriptionId string param azureResourceGroup string @@ -25,6 +30,22 @@ resource appNamespace 'core/Namespace@v1' = { } } +// The AKS environment deliberately uses the in-cluster Mosquitto broker rather than +// 'mqtt-azure-event-grid'. Azure Event Grid accepts a Microsoft Entra JWT only through the +// MQTT v5 enhanced-authentication fields (Authentication Method 'OAUTH2-JWT' plus +// Authentication Data). The workshop application sends the token as a CONNECT password +// instead, which Event Grid always rejects, so the backend never connects and crash-loops +// the whole deployment. Provisioning topic spaces, federated credentials and the Event Grid +// data-plane roles does not change that outcome, because the defect is in the MQTT client. +// +// Only the recipe changes: app.bicep, the resource types and the K3s environment are +// untouched, so this stays a platform-implementation choice rather than an application one. +// Set mqttRecipeTemplatePath to '/mqtt-azure-event-grid:latest' once the +// application supports MQTT v5 enhanced authentication. +var mqttTemplatePath = empty(mqttRecipeTemplatePath) + ? '${recipeRegistry}/mqtt:latest' + : mqttRecipeTemplatePath + var baseRecipes = { 'Radius.Resources/postgreSqlDatabases': { default: { @@ -38,7 +59,7 @@ var baseRecipes = { 'Radius.Resources/mqttBrokers': { default: { templateKind: 'bicep' - templatePath: '${recipeRegistry}/mqtt-azure-event-grid:latest' + templatePath: mqttTemplatePath } } 'Radius.Resources/idProviders': { diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep index 27d0b4de6..695bf2572 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep @@ -61,7 +61,10 @@ param frontendClientId string = '' @description('Kubernetes service account bound by the workload-identity recipe.') param workloadIdentityServiceAccountName string = 'default' -@description('Entra tenant ID for workload identity. Leave empty for Challenge 05.') +@description(''' +Entra tenant ID override for workload identity. Leave empty to use the tenant returned by +the workloadIdentities recipe, which is the correct value in every environment. +''') param workloadIdentityTenantId string = '' @description('Enable Istio sidecar injection when the platform portfolio includes Istio.') @@ -170,13 +173,18 @@ resource backend 'Applications.Core/containers@2023-10-01-preview' = { value: backendIdentity.properties.clientId } AZURE_TENANT_ID: { - value: workloadIdentityTenantId + value: empty(workloadIdentityTenantId) + ? backendIdentity.properties.tenantId + : workloadIdentityTenantId } + // How to authenticate to MQTT is a property of the broker, not of the workload + // identity. Reading it from the identity hard-codes 'OAUTH2-JWT' on Azure and + // ignores which mqttBrokers recipe the environment actually registered. MQTT_AUTH_METHOD: { - value: backendIdentity.properties.authMethod + value: tradingMqtt.properties.authMethod } MQTT_TOKEN_AUDIENCE: { - value: backendIdentity.properties.tokenAudience + value: tradingMqtt.properties.tokenAudience } MQTT_TOPIC: { value: 'orders/new' @@ -226,13 +234,15 @@ resource frontend 'Applications.Core/containers@2023-10-01-preview' = { value: frontendIdentity.properties.clientId } AZURE_TENANT_ID: { - value: workloadIdentityTenantId + value: empty(workloadIdentityTenantId) + ? frontendIdentity.properties.tenantId + : workloadIdentityTenantId } MQTT_AUTH_METHOD: { - value: frontendIdentity.properties.authMethod + value: tradingMqtt.properties.authMethod } MQTT_TOKEN_AUDIENCE: { - value: frontendIdentity.properties.tokenAudience + value: tradingMqtt.properties.tokenAudience } AUTH_USERNAME: { value: authUsername diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh index f35f79caf..2eb228e14 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh @@ -189,4 +189,53 @@ for pattern in '*.bicep' '*.sql'; do } done +# Azure Event Grid accepts a Microsoft Entra JWT only through the MQTT v5 enhanced +# authentication fields (Authentication Method 'OAUTH2-JWT' plus Authentication Data). The +# workshop application instead sends the token as a CONNECT password, so Event Grid rejects +# every connection, the MQTT background service throws, and .NET's default +# BackgroundServiceExceptionBehavior.StopHost crash-loops the whole backend. Until the +# application supports enhanced authentication, the AKS environment must not select the +# Event Grid recipe as the default mqttBrokers implementation. +AKS_ENVIRONMENT_TEMPLATE="${ROOT_DIR}/iac/aks-env.bicep" +[[ -f "$AKS_ENVIRONMENT_TEMPLATE" ]] || { + echo "Missing AKS environment template: $AKS_ENVIRONMENT_TEMPLATE" >&2 + exit 1 +} +if grep -v '^[[:space:]]*//' "$AKS_ENVIRONMENT_TEMPLATE" | grep -Fq 'mqtt-azure-event-grid'; then + echo "aks-env.bicep must not default Radius.Resources/mqttBrokers to the Event Grid recipe; the application cannot authenticate to it." >&2 + exit 1 +fi +grep -Fq "'\${recipeRegistry}/mqtt:latest'" "$AKS_ENVIRONMENT_TEMPLATE" || { + echo "aks-env.bicep must fall back to the in-cluster Mosquitto recipe for Radius.Resources/mqttBrokers." >&2 + exit 1 +} + +# The workloadIdentities recipe already returns the correct tenant, so the shared application +# template must consume it rather than the optional override parameter. Wiring AZURE_TENANT_ID +# straight to the parameter injected an empty tenant and broke token acquisition. +for identity in backendIdentity frontendIdentity; do + grep -Fq "${identity}.properties.tenantId" "$APP_TEMPLATE" || { + echo "app.bicep must derive AZURE_TENANT_ID from ${identity}.properties.tenantId, not from the bare override parameter." >&2 + exit 1 + } +done + +# The MQTT authentication method belongs to the broker, and the mqttBrokers resource type +# exposes it. The workloadIdentities recipe hard-codes 'OAUTH2-JWT' on Azure, so reading it +# from the identity makes the application ignore which broker recipe is registered. +for identity in backendIdentity frontendIdentity; do + for property in authMethod tokenAudience; do + if grep -Fq "${identity}.properties.${property}" "$APP_TEMPLATE"; then + echo "app.bicep must read MQTT ${property} from the mqttBrokers resource, not from ${identity}." >&2 + exit 1 + fi + done +done +for property in authMethod tokenAudience; do + if (( $(grep -c -F "tradingMqtt.properties.${property}" "$APP_TEMPLATE") < 2 )); then + echo "app.bicep must wire MQTT ${property} from tradingMqtt for both containers." >&2 + exit 1 + fi +done + echo "PostgreSQL recipe parity and security assertions passed." 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 4d558874c..d58f7d0b3 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 @@ -468,7 +468,7 @@ rad deploy iac/aks-env.bicep ` | Resource type | AKS/Azure backend | | --- | --- | | `postgreSqlDatabases` | Azure Database for PostgreSQL Flexible Server | -| `mqttBrokers` | Azure Event Grid MQTT | +| `mqttBrokers` | In-cluster Eclipse Mosquitto | | `idProviders` | In-cluster Keycloak | | `workloadIdentities` | AKS workload identity | | `aiModels` | Azure OpenAI | @@ -476,6 +476,14 @@ rad deploy iac/aks-env.bicep ` | `agentGuardrails` | Agent Governance Toolkit sidecar support | | `sqlDatabases` | Custom Azure SQL recipe | +> [!NOTE] +> `mqttBrokers` maps to in-cluster Mosquitto on AKS as well as K3s. The Event Grid recipe +> exists and provisions correctly, but the published application sends its Entra token as +> an MQTT CONNECT password, while Event Grid requires MQTT v5 enhanced authentication. +> Selecting it therefore crash-loops the backend. Challenge 05 explains this in +> [Why AKS uses Mosquitto for `mqttBrokers`](../challenge-05/solution-05.md#why-aks-uses-mosquitto-for-mqttbrokers), +> including the `mqttRecipeTemplatePath` override that restores the Event Grid recipe. + ## Manual tutorial, Stage 4: Register the complete K3s recipe set Switch both kube context and Radius workspace: 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 adde12edb..4816a185f 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 @@ -28,7 +28,7 @@ Duration: 45-75 minutes | Portable contract | `env-azure-prod` on AKS/Azure | `env-local-prod` on K3s | | --- | --- | --- | | `Radius.Resources/postgreSqlDatabases` | Azure Database for PostgreSQL Flexible Server | In-cluster PostgreSQL | - | `Radius.Resources/mqttBrokers` | Azure Event Grid MQTT | In-cluster Eclipse Mosquitto | + | `Radius.Resources/mqttBrokers` | In-cluster Eclipse Mosquitto | In-cluster Eclipse Mosquitto | | `Radius.Resources/workloadIdentities` | Azure/AKS workload identity mapping | Local Kubernetes mapping/no-op implementation | Challenge 04 also prepared Keycloak, AI, governance, and agent-guardrail recipe @@ -62,12 +62,16 @@ contracts. Local password authentication keeps the frontend usable. OIDC adaptat belongs to Challenge 06, service communication to Challenge 07, and AI to Challenge 08. Do not provision per-workload managed identities or AI services here. -Deployment parity is not runtime parity. On AKS, recipe execution can create Azure -Database for PostgreSQL Flexible Server and Event Grid resources; on K3s it creates -in-cluster PostgreSQL and Mosquitto workloads. Event Grid MQTT still needs topic spaces, -permission bindings, and federated workload identities for end-to-end messaging. That -runtime work is deferred or discussed in later identity and communication challenges; a -green Radius deployment proves model portability, not complete messaging parity. +Deployment parity is not runtime parity. On AKS, recipe execution creates Azure +Database for PostgreSQL Flexible Server; on K3s it creates an in-cluster PostgreSQL +workload. The `mqttBrokers` contract is the exception: both environments currently +resolve it to in-cluster Mosquitto, because the published application image cannot +authenticate to Azure Event Grid. Event Grid's MQTT broker accepts a Microsoft Entra +token only through MQTT v5 *enhanced authentication*, and the application sends the +token as an ordinary CONNECT password instead, so every connection is refused. The +portable contract is unchanged and the swap is a recipe decision, which is exactly the +seam Radius exists to provide. See +[Why AKS uses Mosquitto for `mqttBrokers`](#why-aks-uses-mosquitto-for-mqttbrokers). ## Target discipline @@ -400,7 +404,9 @@ rad recipe list ` ``` The required resource types are the same as K3s, but their recipes should map to Azure -Database for PostgreSQL, Azure Event Grid MQTT, and AKS workload identity. +Database for PostgreSQL and AKS workload identity. `mqttBrokers` deliberately maps to +the same in-cluster Mosquitto recipe on both platforms; see +[Why AKS uses Mosquitto for `mqttBrokers`](#why-aks-uses-mosquitto-for-mqttbrokers). If the credential is missing, return to Challenge 02 and repeat its workload-identity registration. Do not fall back to a long-lived service-principal secret. @@ -523,8 +529,10 @@ rad resource expose Applications.Core/containers frontend ` ``` Open . A reachable frontend and a complete Radius graph are the -core proof. MQTT-backed features can remain incomplete because Event Grid data-plane -authorization is outside this challenge's portability proof. +core proof. Order and trade streaming works in both environments because the +`mqttBrokers` recipe resolves to an in-cluster Mosquitto broker on AKS as well; see +[Why AKS uses Mosquitto for `mqttBrokers`](#why-aks-uses-mosquitto-for-mqttbrokers) for +the reason the Event Grid implementation is not selected. ### 6. Prove database initialization and backend readiness @@ -794,7 +802,7 @@ rad recipe list --environment env-azure-prod | Database contract | `postgreSqlDatabases` | `postgreSqlDatabases` | | Database implementation | In-cluster PostgreSQL | Azure Database for PostgreSQL | | MQTT contract | `mqttBrokers` | `mqttBrokers` | -| MQTT implementation | In-cluster Mosquitto | Azure Event Grid MQTT | +| MQTT implementation | In-cluster Mosquitto | In-cluster Mosquitto | | Workload identity | Local no-op mapping | Azure workload-identity mapping | | AI | Disabled | Disabled | | Access path | Port-forward over Bastion API tunnel | Port-forward to AKS API | @@ -845,13 +853,70 @@ set `$env:KUBECONFIG`. Repeat Stage 1's `rad bicep publish-extension` command. The archive is generated and is not committed. -### The frontend is reachable but MQTT features fail on AKS +### Why AKS uses Mosquitto for `mqttBrokers` -This is the documented runtime gap. The Azure recipe creates the Event Grid namespace -and endpoint, but secure publish/subscribe also needs topic spaces, permission bindings, -federated credentials, and appropriate Event Grid data-plane roles. Preserve the -identity boundary and track this as separate platform work rather than adding shared -secrets. Challenge 06 addresses end-user authentication, not MQTT authorization. +The AKS environment registers the in-cluster Eclipse Mosquitto recipe for +`Radius.Resources/mqttBrokers`, the same implementation K3s uses. This is deliberate. + +Azure Event Grid's MQTT broker accepts a Microsoft Entra token only through MQTT v5 +*enhanced authentication*: the CONNECT packet must carry `Authentication Method` +`OAUTH2-JWT` and put the bearer token in `Authentication Data`. The published Trading +backend instead passes the token as an ordinary CONNECT *password*, which Event Grid +never inspects. No amount of platform work fixes that from the outside — a user-assigned +managed identity, federated credentials, topic spaces, permission bindings, and Event +Grid data-plane role assignments would all be correct and the broker would still refuse +the connection. + +The failure is not graceful. `MqttOrderListener` calls `SubscribeAsync` on an +unconnected client, which throws `MqttClientNotConnectedException`. .NET's default +`BackgroundServiceExceptionBehavior.StopHost` then stops the host, so the backend +container crash-loops, never becomes Ready, and `rad deploy` fails before the `frontend` +container is created: + +```text +"code": "Internal", +"message": "Container state is 'Waiting' Reason: CrashLoopBackOff, ..." +``` + +The in-cluster recipe returns `authMethod: 'none'`, so the application skips the token +branch entirely and connects over plain MQTT inside the cluster. + +Changing the recipe is not sufficient on its own. `iac/app.bicep` originally read +`MQTT_AUTH_METHOD` and `MQTT_TOKEN_AUDIENCE` from the *workload identity* resource, and +the Azure workload-identity recipe hard-codes `authMethod: 'OAUTH2-JWT'`. The +application therefore attempted token authentication no matter which broker recipe the +environment registered. Both variables now come from the broker, which is what the +`mqttBrokers` resource type documents: + +```bicep +MQTT_AUTH_METHOD: { + value: tradingMqtt.properties.authMethod +} +MQTT_TOKEN_AUDIENCE: { + value: tradingMqtt.properties.tokenAudience +} +``` + +This is a correction, not a platform branch: the expression is identical in both +environments and each recipe supplies its own answer. K3s is unaffected, because its +no-op identity recipe already reported `none`. + +This is the portability boundary working as designed. `iac/app.bicep` still requests the +portable `mqttBrokers` contract and names no broker; only the platform team's recipe +registration in `iac/aks-env.bicep` differs. To restore the Event Grid implementation +once the application supports enhanced authentication, pass the override rather than +editing the environment template: + +```bash +rad deploy iac/aks-env.bicep \ + --workspace ws-azure-prod \ + --group rg-trading \ + --parameters mqttRecipeTemplatePath=ghcr.io/microsoft/adaptive-apps/recipes/mqtt-azure-event-grid:latest +``` + +The browser-side MQTT-over-WebSocket connection from the frontend is a separate, +pre-existing gap: the in-cluster broker is not exposed outside the cluster, so live +streaming in the browser still depends on the backend's REST endpoints. ### A second exposure cannot bind port 3000 From 786464c9711dfa6d85827029276dd23bfe0a73dd Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Wed, 2 Sep 2026 16:00:29 +0200 Subject: [PATCH 25/29] Finish challenge-05: place orders, verify them in Azure, document the failure modes (#21) * docs(challenge-05): place orders in step 5 and verify them in Azure PostgreSQL Step 5 stopped at "open localhost:3000", so step 6 had no application data to check and the managed database was never actually queried. Step 5 now covers sign-in and asks the attendee to place several BUY orders (and optionally an uncoverable SELL) so both `processed` and `rejected` rows exist. Orders reach PostgreSQL over the backend's /api/orders endpoint. Step 6 is split per platform, because app.bicep is identical but the database it resolves to is not: - K3s keeps the in-cluster verification pod. - AKS gets an Azure-native check that reads the orders back out of Azure Database for PostgreSQL Flexible Server, which is the actual proof the data left the cluster. Verified end to end against a live Flexible Server. Fixes found while doing so: - `az postgres flexible-server firewall-rule` takes `--server-name` for the server and `--name` for the rule; `--rule-name` does not exist. - `az postgres flexible-server execute` splits `--querytext` on newlines and runs only the first fragment, failing with a misleading `column "..." does not exist`. All queries are now single-line. - Its table renderer drops a bare `id` column and native timestamp columns, so the orders query aliases `id` and casts `created_at::text`. - The recipe allowlists only AKS egress IPs, so the section opens a temporary client firewall rule and closes it again, with an in-cluster psql fallback that needs no firewall change at all. - The PowerShell K3s block piped `kubectl get job` into ConvertFrom-Json without `--output json`, which always threw. Also records that Flexible Server has no portal query editor, so the portal route is Cloud Shell running the same CLI. * docs(challenge-05): document the two failure modes that cost the most time Both were hit repeatedly while validating this challenge and neither was written down anywhere. A deployment can report a container failure that already succeeded. Radius polls the pods that already exist, so a pod left crash-looping by an earlier attempt is still inside its five-minute back-off window and reports a stale status while the replacement ReplicaSet rolls out. The give-away is that the pod hash in the error belongs to the previous attempt. Records how to confirm it from the live pods and that re-running is the fix. The AKS cluster and the PostgreSQL Flexible Server can both be stopped between sessions, and each fails unrecognisably: the cluster stops resolving its API server name, so every rad and kubectl command dies with NXDOMAIN before reaching Radius, while the database surfaces as a bare InternalServerError nested inside RecipeDeploymentFailed. Neither looks like a stopped resource. Adds the state checks, the start commands, and the kubeconfig refresh for a stale context pointing at a deleted cluster. Also links the Mosquitto explanation to the upstream defect it depends on, microsoft/adaptive-apps#44, so readers can tell when Event Grid becomes usable. --- .../walkthrough/challenge-05/solution-05.md | 308 +++++++++++++++++- 1 file changed, 297 insertions(+), 11 deletions(-) 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 4816a185f..4acbfaae1 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 @@ -528,19 +528,47 @@ rad resource expose Applications.Core/containers frontend ` --remote-port 3000 ``` -Open . A reachable frontend and a complete Radius graph are the -core proof. Order and trade streaming works in both environments because the -`mqttBrokers` recipe resolves to an in-cluster Mosquitto broker on AKS as well; see +Open and sign in with the credentials you passed to `rad deploy`: +the `authUsername` value (`admin`) and the `authPassword` you typed at the prompt. The +frontend checks these itself; Challenge 06 replaces them with an OIDC identity provider. + +#### Place several trade orders + +Do not stop at the dashboard. Place orders now, because they are the evidence that +[step 6](#6-prove-database-initialization-and-backend-readiness) reads back out of Azure: + +1. Choose a symbol the backend offers at `/api/symbols`: `AAPL`, `MSFT`, `GOOGL`, `AMZN`, + `TSLA`, `NVDA`, `META`, `NFLX`, `AMD`, or `INTC`. +2. Submit at least three **BUY** orders with different symbols and quantities. The + backend stores each one with status `processed` and records a matching trade. +3. Optionally submit a **SELL** order for a symbol you do not hold. The backend rejects + it server-side and stores it with status `rejected`, so the `orders` table ends up + showing both outcomes. + +The frontend posts these to the backend's `/api/orders` endpoint, and the backend writes +them to whatever database the `postgreSqlDatabases` recipe resolved to. On AKS that is a +managed Azure server outside the cluster, which is what makes step 6 worth running. + +A reachable frontend and a complete Radius graph are the core proof. Order and trade +streaming works in both environments because the `mqttBrokers` recipe resolves to an +in-cluster Mosquitto broker on AKS as well; see [Why AKS uses Mosquitto for `mqttBrokers`](#why-aks-uses-mosquitto-for-mqttbrokers) for the reason the Event Grid implementation is not selected. ### 6. Prove database initialization and backend readiness -Run this acceptance check once on K3s after Stage 2 and once on AKS after Stage 3. It -waits for the recipe-owned Job because Kubernetes-extension deployment records API -acceptance, not Job completion. The backend readiness probe calls `/api/accounts`, so +`app.bicep` never changes, but the database it resolves to does, so the evidence differs +per platform. On K3s the database is a pod inside the cluster. On AKS it is an Azure +Database for PostgreSQL Flexible Server outside the cluster, so the check runs against +Azure and doubles as proof that the orders you placed in step 5 really left Kubernetes. + +Both checks depend on the recipe-owned schema Job. Wait for it: a Kubernetes-extension +deployment records API acceptance, not Job completion, so the deployment can report +success before the tables exist. The backend readiness probe calls `/api/accounts`, so Kubernetes does not mark the backend Ready until the schema is queryable. +#### K3s: check the in-cluster database + The verification pod reuses the recipe-created Secret through `secretKeyRef`; no command, manifest, or output contains the database password. It prints only table names and the number of `Demo Account` rows. @@ -645,7 +673,7 @@ kubectl wait ` "job/$SchemaJob" ` --timeout=15m -$Job = kubectl get job $SchemaJob --namespace $AppNamespace | +$Job = kubectl get job $SchemaJob --namespace $AppNamespace --output json | ConvertFrom-Json $Environment = $Job.spec.template.spec.containers[0].env $DbHost = ($Environment | Where-Object name -eq "PGHOST").value @@ -720,10 +748,188 @@ if ($Accounts -notmatch '"Demo Account"') { } ``` -Expected metadata output is the four table names and `demo_seed_count=1`. On AKS, -`PGSSLMODE=require` proves the initializer uses TLS. Azure PostgreSQL also keeps -`require_secure_transport` enabled, so the external backend image's Npgsql connection -negotiates TLS; the server rejects plaintext sessions. +Expected metadata output is the four table names and `demo_seed_count=1`. + +#### AKS: check the managed Azure database + +On AKS the same `postgreSqlDatabases` resource resolves to an Azure Database for +PostgreSQL Flexible Server, so verify it the way you would verify any managed Azure +database: connect to it and query the `orders` table you just filled from the frontend. + +> [!NOTE] +> Flexible Server has no query editor blade in the Azure portal. The portal route is +> Cloud Shell, which runs the same Azure CLI commands shown below. + +Ask Radius where the database lives. The recipe returns the connection metadata but +deliberately never returns the password, so that still comes from the recipe-created +Kubernetes Secret. + +**Bash:** + +```bash +az extension add --name rdbms-connect + +DB_JSON="$(rad resource show Radius.Resources/postgreSqlDatabases trading-db \ + --output json)" +PG_HOST="$(jq -r '.properties.host' <<<"$DB_JSON")" +PG_DATABASE="$(jq -r '.properties.database' <<<"$DB_JSON")" +PG_USER="$(jq -r '.properties.username' <<<"$DB_JSON")" +PG_SERVER="${PG_HOST%%.*}" +PG_GROUP="$(az postgres flexible-server list \ + --query "[?name=='$PG_SERVER'].resourceGroup | [0]" \ + --output tsv)" +PG_PASSWORD="$(kubectl get secret trading-db-credentials \ + --namespace "$APP_NAMESPACE" \ + --output jsonpath='{.data.password}' | base64 --decode)" + +echo "server=$PG_SERVER group=$PG_GROUP database=$PG_DATABASE user=$PG_USER" +``` + +The recipe allowlisted only the AKS egress IPs, so your own client — laptop, dev +container, or Cloud Shell — cannot reach the server yet. Open a rule for it, then close +it again once the queries are done: + +```bash +CLIENT_IP="$(curl -sf https://api.ipify.org)" +az postgres flexible-server firewall-rule create \ + --resource-group "$PG_GROUP" \ + --server-name "$PG_SERVER" \ + --name workshop-client \ + --start-ip-address "$CLIENT_IP" \ + --end-ip-address "$CLIENT_IP" \ + --output none +``` + +Confirm the schema Job created the tables, then read back your orders: + +> [!IMPORTANT] +> Keep each `--querytext` value on a single line. `az postgres flexible-server execute` +> splits the query on newlines and runs only the first fragment, which fails with a +> confusing `column "..." does not exist` instead of an obvious syntax error. The `id` +> alias and the `::text` cast are also deliberate: the CLI's table renderer drops a bare +> `id` column and native timestamp columns. Use `--output json` to see raw values. + +```bash +az postgres flexible-server execute \ + --name "$PG_SERVER" \ + --admin-user "$PG_USER" \ + --admin-password "$PG_PASSWORD" \ + --database-name "$PG_DATABASE" \ + --querytext "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name;" \ + --output table + +az postgres flexible-server execute \ + --name "$PG_SERVER" \ + --admin-user "$PG_USER" \ + --admin-password "$PG_PASSWORD" \ + --database-name "$PG_DATABASE" \ + --querytext "SELECT id AS order_id, symbol, side, order_type, quantity, price, status, created_at::text AS placed_at FROM orders ORDER BY id;" \ + --output table + +az postgres flexible-server firewall-rule delete \ + --resource-group "$PG_GROUP" \ + --server-name "$PG_SERVER" \ + --name workshop-client \ + --yes +``` + +**PowerShell 7:** + +```powershell +az extension add --name rdbms-connect + +$Database = rad resource show Radius.Resources/postgreSqlDatabases trading-db ` + --output json | + ConvertFrom-Json +$PgHost = $Database.properties.host +$PgDatabase = $Database.properties.database +$PgUser = $Database.properties.username +$PgServer = $PgHost.Split(".")[0] +$PgGroup = az postgres flexible-server list ` + --query "[?name=='$PgServer'].resourceGroup | [0]" ` + --output tsv +$PgPassword = [Text.Encoding]::UTF8.GetString( + [Convert]::FromBase64String((kubectl get secret trading-db-credentials ` + --namespace $AppNamespace ` + --output jsonpath='{.data.password}'))) + +"server=$PgServer group=$PgGroup database=$PgDatabase user=$PgUser" + +$ClientIp = "$(Invoke-RestMethod -Uri 'https://api.ipify.org')".Trim() +az postgres flexible-server firewall-rule create ` + --resource-group $PgGroup ` + --server-name $PgServer ` + --name workshop-client ` + --start-ip-address $ClientIp ` + --end-ip-address $ClientIp ` + --output none + +az postgres flexible-server execute ` + --name $PgServer ` + --admin-user $PgUser ` + --admin-password $PgPassword ` + --database-name $PgDatabase ` + --querytext "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name;" ` + --output table + +az postgres flexible-server execute ` + --name $PgServer ` + --admin-user $PgUser ` + --admin-password $PgPassword ` + --database-name $PgDatabase ` + --querytext "SELECT id AS order_id, symbol, side, order_type, quantity, price, status, created_at::text AS placed_at FROM orders ORDER BY id;" ` + --output table + +az postgres flexible-server firewall-rule delete ` + --resource-group $PgGroup ` + --server-name $PgServer ` + --name workshop-client ` + --yes +``` + +The first query lists `accounts`, `orders`, `positions`, and `trades`. The second returns +one row per order you placed in step 5: + +```text +Order_id Order_type Placed_at Price Quantity Side Status Symbol +---------- ------------ ----------------------------- ------- ---------- ------ --------- -------- +1 MARKET 2026-09-02 12:34:15.020938+00 100.00 10 BUY processed AAPL +2 MARKET 2026-09-02 12:36:09.462464+00 384.04 10 BUY processed TSLA +``` + +An uncovered SELL shows up here too, with `status` `rejected`. Those rows are in Azure, +not in the cluster: the pods you were talking to contain no database at all. Nothing in +`app.bicep` changed to make that happen; only the recipe registered for +`Radius.Resources/postgreSqlDatabases` differs between the two environments. + +`PGSSLMODE=require` on the schema Job proves the initializer uses TLS. The recipe also +enables `require_secure_transport` and a TLS 1.2 floor on the server, so the backend's +Npgsql connection negotiates TLS and the server rejects plaintext sessions. + +> [!TIP] +> To avoid touching the firewall at all, run the query from inside the cluster, whose +> egress IP the recipe already allowlisted. This also avoids the CLI's table-rendering +> quirks, because you get raw `psql` output, and the pod deletes itself on exit: +> +> ```bash +> kubectl run pg-shell --namespace "$APP_NAMESPACE" --rm -i --restart=Never \ +> --image=postgres:16-alpine --env="PGPASSWORD=$PG_PASSWORD" \ +> -- psql "host=$PG_HOST dbname=$PG_DATABASE user=$PG_USER sslmode=require" \ +> -c "SELECT id, symbol, side, quantity, status FROM orders ORDER BY id;" +> ``` +> +> ```text +> id | symbol | side | quantity | status +> ----+--------+------+----------+----------- +> 1 | AAPL | BUY | 10 | processed +> 2 | TSLA | BUY | 10 | processed +> (2 rows) +> ``` + +> [!WARNING] +> Delete the `workshop-client` firewall rule when you are finished. Leaving a public IP +> allowlisted on a database server outlives the workshop, and home or office IPs are +> reassigned to someone else. ## Stage 4: Compare the deployments @@ -834,6 +1040,83 @@ rad env show Kubernetes context and Radius workspace are independent selectors. +### The deployment reported a failure that already succeeded + +`rad deploy` can fail on a container that is actually healthy: + +```text +"code": "Internal", +"message": "Container state is 'Waiting' Reason: CrashLoopBackOff, Message: back-off +5m0s restarting failed container=backend pod=backend-757d95b986-dbhw7" +``` + +or: + +```text +"message": "deployment timed out, name: backend, namespace env-azure-prod-adaptive-apps, +error occurred while fetching latest status: client rate limiter Wait returned an error: +context deadline exceeded" +``` + +Radius polls the pods that already exist. When an earlier attempt left a pod crash-looping, +Kubernetes has backed that pod off by up to five minutes, so it does not restart promptly +even though the new specification is correct. Radius reads that stale status and gives up +while the replacement ReplicaSet is still rolling out. + +**The tell-tale is the pod name.** If the hash in the error matches a pod from the previous +attempt rather than a newly created one, the status is stale. Check what is actually +running: + +```bash +kubectl get pods --namespace "$APP_NAMESPACE" +kubectl get replicasets --namespace "$APP_NAMESPACE" +``` + +If a new pod is `1/1 Running`, the deployment succeeded and the error is a reporting +artifact; continue with the walkthrough. Otherwise simply run `rad deploy` again — the +back-off window has usually expired by then. Deleting the crash-looping pod first also +clears it. + +### The cluster or the database stopped between sessions + +Both Azure back ends can be stopped to save cost, and each fails in its own way. A stopped +AKS cluster stops resolving its API server name, so every `rad` and `kubectl` command +fails before it reaches Radius: + +```text +Error: Get "https://.hcp..azmk8s.io:443/apis/api.ucp.dev/v1alpha3": +dial tcp: lookup .hcp..azmk8s.io: no such host +``` + +A stopped PostgreSQL Flexible Server instead surfaces as an opaque Azure error nested +inside the recipe deployment: + +```text +"code": "RecipeDeploymentFailed", +"message": "failed to deploy recipe default of type Radius.Resources/postgreSqlDatabases" +... +"code": "InternalServerError", +"message": "An unexpected error occured while processing the request. Tracking ID: ..." +``` + +Neither is a defect in the application model. Start whichever is stopped and retry: + +```bash +az aks show --resource-group "$RESOURCE_GROUP" --name "" --query powerState.code -o tsv +az aks start --resource-group "$RESOURCE_GROUP" --name "" + +az postgres flexible-server show --resource-group "$RESOURCE_GROUP" \ + --name "" --query state -o tsv +az postgres flexible-server start --resource-group "$RESOURCE_GROUP" --name "" +``` + +If DNS still fails after the cluster is running, refresh the kubeconfig — a stale context +can point at a cluster that no longer exists: + +```bash +az aks get-credentials --resource-group "$RESOURCE_GROUP" --name "" --overwrite-existing +``` + ### K3s reports connection refused on localhost The devcontainer restart stopped the tunnel process. Reconnect, then restore the @@ -867,6 +1150,9 @@ managed identity, federated credentials, topic spaces, permission bindings, and Grid data-plane role assignments would all be correct and the broker would still refuse the connection. +This is tracked upstream as +[microsoft/adaptive-apps#44](https://github.com/microsoft/adaptive-apps/issues/44). + The failure is not graceful. `MqttOrderListener` calls `SubscribeAsync` on an unconnected client, which throws `MqttClientNotConnectedException`. .NET's default `BackgroundServiceExceptionBehavior.StopHost` then stops the host, so the backend From 78b71c5bf00ccc244f4a1f2969c0f895d12abe9c Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Fri, 4 Sep 2026 11:40:08 +0200 Subject: [PATCH 26/29] Add Challenge 09: model, review, and deploy with Radius Canvas (#22) * Add Challenge 09: model, review, and deploy with Radius Canvas Replaces the custom-resources scaffold with a full student challenge and coach walkthrough built around the Radius Canvas public preview for the GitHub Copilot app. The challenge contrasts the Canvas developer path (source-derived .radius/app.bicep, recipe packs, ephemeral control plane in GitHub Actions, OIDC trust) with the platform path built in Challenges 03-08, and requires an honest assessment of what the preview does and does not solve for the non-Azure port. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Correct Challenge 09 factual errors found in review - OIDC: the federated credential subject binds repository and environment, not branch. Replace the incorrect branch-isolation claim with a three-way table and point at GitHub Environment deployment-branch rules as the actual control. - Diff analysis: modeled properties such as env vars, image references and probes are reported as modified, not unchanged. Replace the two-way table with the correct three-way split of reported, reported-but-not-judged, and not-compared. - Do not run rad on the host after stating no host rad is required; move the control-plane isolation checks into the devcontainer. - Compare AI against iac/ai.bicep, not iac/app.bicep, which keeps AI disabled. - Expect and teach the unresolved-recipe case: a generated custom type has no entry in the azure-avm pack, so plan and deploy are expected to surface it. - A shared Radius.Resources namespace is not a shared contract; require a schema-compatibility judgement rather than implying interchangeability. - Prove isolation with Azure resource snapshots and a Challenge 05 health check, not namespace separation and resource counts alone. - Scope the secret criterion to newly introduced values and add a scan of the generated model, since upstream docker-compose.yml already carries dev values. - Drop the immutable-tag and functionally-similar-recipe overclaims, add coach pre-staging guidance, and raise the estimate to 90-150 minutes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../04-adaptive-apps/Readme.md | 13 +- .../challenges/challenge-09.md | 452 +++++++++++- .../challenges/challenge-10.md | 2 + .../walkthrough/challenge-09/solution-09.md | 647 ++++++++++++++++++ 4 files changed, 1107 insertions(+), 7 deletions(-) create mode 100644 03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-09/solution-09.md 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 641bdee9e..73af9b620 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 @@ -58,6 +58,8 @@ After completing this MicroHack, you will: environment-only configuration changes. - Adapt identity, secure service communication, and AI services without changing the application model. +- Model, review, and deploy the application from the GitHub Copilot app with + Radius Canvas, and judge what that preview does and does not solve. - Apply the Adaptive Apps approach to an existing containerized application. ## MicroHack challenges @@ -100,6 +102,11 @@ persistence, verification, and troubleshooting. The default K3s VM has no public IP. Its Kubernetes API is reached through an Azure Bastion native-client tunnel bound only to localhost in the participant environment. +> [!NOTE] +> Challenge 09 is the exception to the devcontainer workflow. Radius Canvas runs only in +> the GitHub Copilot app, so that challenge is completed on the host machine and needs the +> Azure CLI, GitHub CLI, and `kubectl` available there. + ### Challenge 00: universal prerequisite Complete Challenge 00 before starting a challenge in any bucket. @@ -133,10 +140,10 @@ communication, and AI capabilities. ### Bucket 4: Advanced challenges -Extend the application model and apply the Adaptive Apps approach to an -existing application. +Bring the application model into the developer inner loop and apply the +Adaptive Apps approach to an existing application. -- [Challenge 09 - Extend the application model with custom Radius resources](challenges/challenge-09.md) +- [Challenge 09 - Model, review, and deploy with Radius Canvas](challenges/challenge-09.md) - [Challenge 10 - Modernize a brownfield application](challenges/challenge-10.md) ## Contributors diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md index 25029ea61..ea5699198 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md @@ -1,7 +1,451 @@ -# Challenge 09 - Extend the application model with custom Radius resources +# Challenge 09 - Model, review, and deploy with Radius Canvas + +[< Previous Challenge](challenge-08.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-10.md) + +Estimated time: 90-150 minutes | Difficulty: intermediate to advanced + +## Challenge objective + +Challenges 03-08 built the Adaptive Apps story from the platform side. You authored +resource-type contracts, implemented them with recipes, and deployed a hand-written +`iac/app.bicep` through two persistent Radius control planes. + +This challenge approaches the same trading application from the developer side. +[Radius Canvas](https://techcommunity.microsoft.com/blog/azuredevcommunityblog/introducing-radius-canvas-visualize-review-and-deploy-applications-in-the-github/4549760), +announced in public preview on 3 September 2026, is a canvas extension for the GitHub +Copilot app. It analyzes a repository, writes an application definition to +`.radius/app.bicep`, renders it as a live graph, and deploys it to a cloud environment +through a generated GitHub Actions workflow. + +You will model the trading application from its source, reconcile the generated model +against the model you already know, review the resolved Azure implementations, deploy it, +review an architectural change in the Diff view, and then state precisely which parts of +the "port it to Azure PaaS and to non-Azure platforms" problem this preview solves and +which parts it does not. + +> [!IMPORTANT] +> The point of this challenge is judgement, not clicking. A generated model that looks +> plausible is not the same as a correct portability boundary. Treat every node in the +> graph as a claim you have to verify against the source and against Challenges 03-08. + +> [!NOTE] +> Radius Canvas is a public preview under active development. View labels, generated file +> names, recipe pack contents, and menu wording will change. Record what you observe, +> together with the plugin version shown in the Copilot app, rather than matching a +> screenshot. Several tasks below expect you to find that something does not resolve; that +> is a finding to document, not a failure to work around. + +## Scenario + +The trading firm's platform team has a working capability catalog, but the application +teams are growing and much of the incoming change is agent-generated. Reviewers open pull +requests that touch dozens of files and cannot answer the only question that matters +architecturally: did this change alter the shape of the application, add a backing +service, or introduce a new connection? + +At the same time, leadership wants a faster on-ramp to Azure managed services for teams +that are not ready to author resource types and recipes themselves, without giving up the +promise that the same application can run at a non-Azure site. + +The firm wants to know whether a shared, visual application definition that lives beside +the code can serve both audiences, and where the approach stops. + +## Learning goals + +- Distinguish a source-derived, generated application model from a hand-authored model + written against a curated platform catalog. +- Explain what each Canvas view proves: Modeled, Planned, Deployed, and Diff. +- Map the trading application's components onto predefined `Radius.*` resource types and + compare them with the `Radius.Resources/*` contracts from Challenge 03. +- Read the Planned view as recipe evidence: which implementation an environment resolves + for each declared requirement. +- Explain how a GitHub Environment plus OIDC trust removes long-lived cloud credentials + from a deployment pipeline. +- Contrast an ephemeral, workflow-hosted Radius control plane with the persistent control + planes from Challenges 02-08. +- Judge a preview capability honestly, including the parts of the portability problem it + does not address. + +## Fixed target map + +Challenge 09 adds a third deployment path. It does not replace the first two. + +| Path | Radius control plane | Cluster | Namespace | Model | +| --- | --- | --- | --- | --- | +| Challenges 05-08, Azure | Persistent `ws-azure-prod` / `env-azure-prod` | `aks-adaptive-apps` | Challenge 05 application namespace | `iac/app.bicep` | +| Challenges 05-08, Local | Persistent `ws-local-prod` / `env-local-prod` | `k3s-azure-vm` | Challenge 05 application namespace | `iac/app.bicep` | +| Challenge 09, Canvas | Ephemeral `radius-cp` k3d cluster created inside GitHub Actions | `aks-adaptive-apps` | `trading-canvas` (new) | `.radius/app.bicep` | + +The Canvas path targets the same AKS cluster but runs its own control plane per workflow +run and keeps its state in a private GitHub Container Registry package. It is not the same +Radius control plane you registered recipes with in Challenge 04, and it does not see your +Challenge 05 application. + +## Additional prerequisites + +- Challenges 00-05 completed. Challenges 06-08 are useful context but not required. +- The **GitHub Copilot app** (desktop), latest version. + +> [!IMPORTANT] +> Radius Canvas runs only in the GitHub Copilot app. It is not available in VS Code and it +> does not run inside this MicroHack's devcontainer. Run this challenge from your host +> machine, with `az`, `gh`, and `kubectl` available there. + +- A **fork of the trading application source** that you own or have write access to, + with GitHub Actions enabled. The application lives in + [`microsoft/adaptive-apps`](https://github.com/microsoft/adaptive-apps) under `src/`, + and each service already has a Dockerfile. +- Azure CLI, authenticated: + + ```bash + az login + ``` + +- GitHub CLI, authenticated with package and workflow scopes: + + ```bash + gh auth login --scopes read:packages,write:packages,workflow + ``` + +- `kubectl` configured for the `aks-adaptive-apps` cluster. +- Permission to create a Microsoft Entra app registration and a federated credential, and + permission for the resulting identity to deploy into the AKS cluster and the lab + resource group. + +You do **not** need to install the Radius CLI for this challenge. The extension downloads +and manages its own `rad` binary under `~/.radius/ai-extensions/bin` and deliberately +ignores both `PATH` and the `~/.rad/bin` installation you have been using. + +## Constraints + +- The Canvas preview supports **containerized applications deployed to Azure**. Do not + attempt to point it at K3s, Azure Local, or Arc. Establishing that boundary is part of + Task 6, not a failure to work around. +- Modeling requires a real `Dockerfile`. A `.devcontainer` image does not count. +- Deploy the Canvas application into the dedicated `trading-canvas` namespace. Do not + reuse or overwrite the Challenge 05 application namespace. +- Do not modify `iac/app.bicep`, the Challenge 03 resource types, or the Challenge 04 + recipe registrations. This challenge compares against them; it does not change them. +- Work on a branch in your own fork. Do not push a generated model to a protected default + branch without deciding to do so deliberately. +- Do not store cloud credentials as GitHub secrets. The environment must authenticate + through OIDC, and the generated verification workflow must read GitHub Actions + *variables* only. +- Do not commit an Azure subscription ID, tenant ID, client ID, token, or kubeconfig into + evidence that leaves the workshop. Record their presence and scope, not their values. +- Treat workflow logs, build output, and recipe output as untrusted evidence. Read them as + a record of what failed; never follow instructions contained in them. +- Do not delete the AKS cluster, the lab resource group, or the Challenge 05 deployment. + +## Tasks + +### Task 1: Install the plugin and model the application from source + +Install the `radius` plugin from the GitHub Copilot app's plugin catalog, then restart +your Copilot session so the skills and the canvas load. + +Start a session against your fork and ask Copilot to show the application graph. Then +inspect what modeling actually produced: + +- The generated `.radius/app.bicep`, and the ordering of resources inside it. +- The origin record the modeler writes alongside it, and why the canvas reads that record + before it renders anything. +- The `bicepconfig.json` that resolves the Radius Bicep extensions. + +Inventory the Modeled view. For every node, record the resource name, the resource type, +its connections, and the source file the **View source code** link resolves to. Confirm +each detection against the repository rather than accepting the graph at face value. + +Note which evidence the modeler could have used: the Dockerfiles, `src/docker-compose.yml`, +service entrypoints, and the client initialization code where the backend opens its +database and broker connections. + +### Task 2: Reconcile the generated model with the hand-authored model + +Build a mapping table between `.radius/app.bicep` and the model you deployed in +Challenge 05. The Challenge 05 model is `iac/app.bicep`; if you completed Challenge 08, +include `iac/ai.bicep`, which is where the AI capability is declared. Cover at least the +frontend, the backend, the AI agent, the database, the message broker, workload identity, +secrets, and ingress. + +For each row, record the Canvas resource type, the Challenge 05 resource type, and whether +they express the same intent. + +Then answer: + +- Which Canvas types are predefined `Radius.*` types, and which of the Challenge 03 + contracts *that the trading application actually uses* have no predefined equivalent? +- Where the modeler had to invent a type because no predefined type matched, compare the + generated type with the Challenge 03 contract for the same capability. Do they have the + same name, the same inputs, and the same outputs? Would a recipe written for one satisfy + the other? +- Which Challenge 05-08 capabilities does the generated model not contain? For each one, + decide whether the modeler had no evidence in the source, or whether it deliberately + declined to infer the resource. +- The modeler adds a source reference to every non-application resource. What does that + buy a reviewer that hand-authored Bicep does not, and what does it not affect? +- Both files describe the same running application. Which one encodes a platform contract, + and which one encodes an observation about source code? Why does that distinction matter + when the application has to move to a platform the modeler has never seen? + +### Task 3: Create the environment and review the plan + +Create a credential profile for your Azure tenant and subscription, verify it, then create +a Radius environment that connects your GitHub repository to that subscription. Select the +lab resource group, the `aks-adaptive-apps` cluster, and the `trading-canvas` namespace. + +Take an inventory of the lab resource group's Azure resources before you start this task. +Task 4 needs it, and so does the last question below. + +Once the environment reports ready, account for everything it created: + +- The GitHub Environment, and the Actions variables it holds. Confirm no long-lived cloud + credential was stored. +- The generated credential-verification workflow committed to your repository. +- The private GitHub Container Registry package that holds the environment's Radius state. +- The Microsoft Entra app registration and its federated credential. Record the exact + subject and audience of that credential and explain why the subject is scoped the way it + is. + +Now open the Planned view and review the deployment without deploying it. For each planned +resource, record the resolved recipe and the infrastructure the plan says will be +provisioned. + +Record explicitly any resource for which the plan resolves **no** recipe. A recipe pack +only implements the types it knows about, so a capability the modeler had to invent a type +for has nothing behind it. That row is the most valuable one in your table, and Task 4 +depends on it. + +Then answer: + +- Which Azure service does the plan resolve for the trading database, and where does that + recipe come from? +- How does that compare with `iac/recipes/postgres-azure-flex.bicep`, which you authored, + published, and registered yourself in Challenge 04? Compare the contract inputs and + outputs, who initializes the schema, how TLS and network access are handled, and who owns + the credential. +- Challenge 04 taught that an environment decides how a requirement is met. Point to the + exact place in this flow where that decision is made. +- Verify the claim that reviewing a plan does not create or change cloud resources. Compare + a snapshot of resource IDs and their provisioning state before and after, not just a + count. + +### Task 4: Deploy through the generated workflow + +Deploy the application from the Planned view. Before the run gets far, read the workflow +files that were committed to your repository, because from this point they are part of +your codebase. > [!NOTE] -> This file is an authoring scaffold. Detailed custom resource instructions -> will be added in a follow-up contribution. +> If Task 3 showed a resource with no resolved recipe, expect the deployment to fail on +> that resource. That is the designed outcome of this task, not an error to route around. +> Capture the failure, identify the resource and the missing implementation, and continue. +> If you want a running application as well, deploy a reduced model with the unresolvable +> capability removed, and record exactly what you removed and what the application loses +> without it. + +Watch the Deployed view, then open the workflow run and reconstruct what it did. Account +for at least: the OIDC login to Azure, how the run obtained credentials for the target +cluster, the ephemeral Radius control plane it created, the credential registration, the +state restore and save around the deployment, the environment and recipe pack it deployed, +and the point at which the application itself was deployed. + +Reach the running application if the deployment succeeded, then produce a comparison with +Challenge 05: + +| Question | Challenge 05 | Challenge 09 | +| --- | --- | --- | +| Where does the Radius control plane run? | | | +| How long does it live? | | | +| Where does Radius state live between deployments? | | | +| Who holds the credential that reaches Azure? | | | +| Where is the recipe set defined and registered? | | | +| What is the unit of promotion? | | | + +Finally, prove isolation. Confirm the Canvas deployment landed in `trading-canvas`, that +the Challenge 05 application is still healthy in its own namespace and still serves +traffic, and that the Challenge 05 Radius control plane does not list the Canvas +application. Because both paths share one Azure resource group, also compare an inventory +of Azure resource IDs taken before Task 3 with one taken now, and account for every +difference. + +Run the Radius control-plane checks from the devcontainer, where your `ws-azure-prod` +workspace is configured. The Canvas extension uses its own private `rad` binary and knows +nothing about that workspace. + +### Task 5: Review an architectural change in the Diff view + +On a branch in your fork, make one deliberate architectural change to the application +source that a modeler can detect from code. Adding a cache client to the backend is a good +choice, because it should appear as a new backing service and a new connection while +leaving the rest of the graph unchanged. + +Re-model the application on that branch, then open the Diff view with your default branch +as the base and your branch as the head. + +- Record which resources are reported as added, removed, modified, and unchanged. +- Generate the Markdown summary of the diff and post it on a pull request. +- Confirm whether reviewing a diff requires the branch to be pushed, and explain the + difference between previewing the branch you have checked out and previewing another + branch. + +Then answer the review question honestly. The diff compares two application *models*, so +sort your test cases into three groups rather than two: + +- Changes that alter the shape of the model: a new component, a new backing service, a new + connection, a changed resource type. +- Changes to a property the model does carry, such as an environment variable value, an + image reference, or a readiness probe. The diff can flag the resource as modified, but + ask yourself what it tells a reviewer about whether the change is safe. +- Changes the model does not carry at all: a changed database query, a changed business + rule, a dependency bumped inside a container, or an environment-level setting such as + route exposure that lives in the deployment environment rather than in either branch. + +Work out which group each of these falls into, and test at least one of them rather than +reasoning about it: a changed environment variable, a changed image tag, a changed database +query, a removed readiness probe, and a widened network exposure. + +Decide whether this belongs in your review process as a required gate or as an aid, and +justify the choice using the third group. + +### Task 6: Judge the portability claim + +Produce a written assessment that a platform lead could act on. It must separate what you +observed from what you assumed. + +Cover: + +- What Canvas measurably made easier for the Azure managed-services path, compared with + the work you did in Challenges 03, 04, and 05. +- What Canvas does not do for the non-Azure path today. Ground each limitation in + something you saw or read, including the preview's platform scope, the Dockerfile + requirement, the behaviour of incremental redeployments, the constraints on the managed + gateway, and the assumption that a repository contains one application. +- Which mechanism actually delivers non-Azure portability in this MicroHack, and which + challenge owns it. State plainly whether Canvas changes that mechanism or changes who + authors and reviews the model that uses it. +- What the platform team would have to provide before a K3s, Azure Local, or Arc-enabled + site could be a Canvas deployment target, and which of those things already exist from + Challenges 03 and 04. +- A recommendation on which surface you would give to which role, and what you would tell + an application team that wants to use Canvas today for an application that must also run + at a disconnected site. + +## Success criteria + +- The trading application is modeled from source into `.radius/app.bicep`, and every node + in the Modeled view is traced back to real evidence in the repository. +- A completed mapping table compares the generated model with the Challenge 05 model, and + the team can explain every difference as either missing evidence, deliberate + conservatism, or a contract that has no predefined equivalent. Where the modeler invented + a type, the team can say whether it is interchangeable with the Challenge 03 contract for + the same capability. +- A Radius environment exists that authenticates to Azure through OIDC, with a federated + credential scoped to the repository and environment, and with no long-lived cloud + credential stored in the repository. +- The Planned view is used to identify the resolved recipe and planned infrastructure for + each resource, the team can name where that recipe pack comes from, and any resource with + no resolved recipe is identified. +- Either the application is deployed to the `trading-canvas` namespace through the generated + workflow and is reachable, or the team documents exactly which resource could not be + resolved by the environment's recipe pack and what a platform team would have to supply. + In both cases the team can narrate what the workflow run actually did. +- The Challenge 05 deployment is still serving traffic, its Radius control plane does not + list the Canvas application, and every new Azure resource in the shared resource group is + accounted for. +- A Diff view review is produced for a real architectural change and posted as a Markdown + summary on a pull request, together with the three-way classification of what the diff + reports, what it reports without judging, and what it does not see at all. +- The portability assessment distinguishes the Azure PaaS on-ramp from the non-Azure port, + and correctly attributes non-Azure portability to resource types, recipes, and + environments rather than to the canvas. +- No new secret value is introduced, and no subscription ID, tenant ID, client ID, token, + or kubeconfig is added to committed source or to evidence. The generated + `.radius/app.bicep` is checked for credential values copied out of development + configuration such as `src/docker-compose.yml`, and any found are replaced before + deployment. +- The environment and deployment are cleaned up, or their remaining cost and scope are + documented deliberately. + +## Progressive hints + +### Installing and opening the canvas + +1. The canvas is part of a plugin, and the session has to restart before its skills and + canvas are available. +2. If the panel does not appear even though the plugin is installed, ask Copilot to fix + the Radius Canvas, or reload extensions and try again. +3. A session must have a GitHub repository selected before there is anything to model. + +### Modeling + +1. Modeling needs a real Dockerfile. If the modeler cannot find one, that is a statement + about the repository, not a bug. +2. The modeler is deliberately conservative about ingress. An exposed port or a health + endpoint is not evidence that a component is reachable from outside the cluster. +3. A repository that builds many images is still modeled as one application. If a + repository genuinely contains several independent applications, the modeler will say so + rather than guess. +4. If a model already exists but the source has moved on, the origin record is what makes + that visible. A model that was edited by hand is not silently overwritten. + +### Environment and credentials + +1. The verification workflow reads Actions variables, never secrets. If you find yourself + creating a secret with a cloud credential in it, stop and re-read the flow. +2. The federated credential's subject ties a specific repository and a specific GitHub + environment to the identity. Changing either breaks the trust on purpose. +3. If credential verification times out and your default branch is protected, look for a + pull request containing the generated workflow files. Verification cannot pass until it + is merged. +4. If package operations fail, check that the GitHub CLI login carries both the read and + write package scopes. + +### Deployment + +1. The workflow checks the branch out from GitHub. A fix that exists only in your local + working tree is not deployed. +2. If the generated model does not resolve, confirm that the whole `.radius` directory, + including its Bicep configuration, was committed and pushed to the branch you are + deploying. +3. A deployment timeout reported in the canvas after a few minutes does not mean the run + stopped. Open the workflow run before concluding anything. +4. Classify a failure before reacting to it. An infrastructure or credential failure is + fixed in the environment. A schema or modeling failure is fixed in the application + definition. Do not fix the second by weakening the first. +5. Redeployments are incremental. A resource you removed from the definition does not + disappear from Azure on its own. + +### Comparison and judgement + +1. Two application definitions that produce a similar-looking graph are not equivalent if + only one of them is written against contracts your platform team controls. +2. The Planned view resolves an implementation for a target you have already chosen. That + is not the same property as the one Challenge 05 proved. +3. Before claiming portability, name the target, the control plane, the recipe set, and + who owns each. + +## Learning resources + +- [Introducing Radius Canvas](https://techcommunity.microsoft.com/blog/azuredevcommunityblog/introducing-radius-canvas-visualize-review-and-deploy-applications-in-the-github/4549760) +- [Radius Canvas guide](https://edge.docs.radapp.io/integrations/github-copilot-app/canvas-extension/) +- [Radius AI extensions repository](https://github.com/radius-project/ai-extensions) +- [Radius resource types contributions](https://github.com/radius-project/resource-types-contrib) +- [Working with canvas extensions in the GitHub Copilot app](https://docs.github.com/en/copilot/how-tos/github-copilot-app/working-with-canvas-extensions) +- [Radius recipes](https://docs.radapp.io/guides/recipes/overview/) +- [Radius environments](https://docs.radapp.io/guides/deploy-apps/environments/overview/) +- [Configure OpenID Connect in Azure for GitHub Actions](https://docs.github.com/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-azure) + +## Clean up + +Delete the Canvas deployment first, then the environment, and watch each deletion workflow +complete. + +> [!WARNING] +> Deleting a deployment can permanently remove infrastructure and data owned by the +> application. Confirm you are deleting the Canvas deployment in `trading-canvas` and not +> the Challenge 05 application. -[Back to the Adaptive Apps MicroHack](../Readme.md) +Deleting the deployment and environment does not delete the AKS cluster or the lab +resource group, and the Entra app registration is intentionally left in place. Record +anything that remains so the next challenge starts from a known state. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md index 6504e19b5..dec1c9087 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md @@ -1,5 +1,7 @@ # Challenge 10 - Modernize a brownfield application +[< Previous Challenge](challenge-09.md) - **[Home](../Readme.md)** + > [!NOTE] > This file is an authoring scaffold. Detailed brownfield modernization > instructions will be added in a follow-up contribution. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-09/solution-09.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-09/solution-09.md new file mode 100644 index 000000000..25b429813 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-09/solution-09.md @@ -0,0 +1,647 @@ +# Walkthrough Challenge 09 - Model, review, and deploy with Radius Canvas + +[< Previous Challenge](../../challenges/challenge-08.md) - **[Home](../../Readme.md)** - [Next Challenge >](../../challenges/challenge-10.md) + +Duration: 90-150 minutes + +## Coach notes + +Radius Canvas entered public preview on 3 September 2026. It is a canvas extension for the +GitHub Copilot app, shipped inside a plugin named `radius`, with source in +[`radius-project/ai-extensions`](https://github.com/radius-project/ai-extensions). + +The teaching value of this challenge is the contrast, not the tool. Challenges 03-08 built +Adaptive Apps from the platform side: the team authored contracts, implemented them with +recipes, and drove `rad deploy` against persistent control planes. Canvas approaches the +same application from the developer side and automates the parts the team just did by +hand. Participants should leave able to say exactly which of those parts it automates and +which it does not. + +Three misconceptions to head off early: + +1. **Canvas is not a second way to reach the Challenge 05 environment.** It creates its own + ephemeral Radius control plane inside a GitHub Actions runner for every run and keeps + state in a private GHCR package. It never talks to `env-azure-prod`. +2. **Canvas does not make the application portable.** Resource types, recipes, and + environments do that, and they are Challenge 03 and Challenge 04 work. Canvas changes + who authors and reviews the model, and it gives Azure a fast on-ramp. +3. **The preview is Azure-only.** The published guidance is explicit: it supports + containerized applications deployed to Azure, with AWS "coming soon". There is no K3s, + Azure Local, or Arc target. Do not let a team burn an hour trying to point it at + `k3s-azure-vm`; establishing that boundary *is* Task 6. + +> [!NOTE] +> This is a preview capability under active development. Menu labels, generated file names, +> recipe pack contents, and view details will move. Coach the participants to record what +> they observe rather than to match a screenshot in this document. The behaviours this +> walkthrough relies on, listed in "Stable claims" below, are the ones the challenge is +> actually graded against. + +### Stable claims this walkthrough depends on + +| Claim | Where it comes from | +| --- | --- | +| Install from the Copilot app's plugin catalog, then restart the session | Plugin and docs guide | +| Modeling requires a Dockerfile and writes `.radius/app.bicep` | `radius-app-bicep` skill | +| The graph is `rad app graph` output rendered in the canvas | `radius-app-graph` skill | +| Four views: Modeled, Planned, Deployed, Diff | Extension README | +| Planned resolves a recipe per resource from a provider recipe pack | Core recipe resolver | +| Deploy commits and dispatches a GitHub Actions workflow | Docs guide and `radius-deploy` skill | +| The control plane is an ephemeral k3d cluster inside the run | `radius-deploy` skill | +| Trust is OIDC; the environment holds Actions variables, not secrets | `radius-environment` skill | +| Preview is Azure-only and applications need a Dockerfile | Docs guide preview banner | + +### Environment hygiene + +This challenge runs on the **host**, not in the devcontainer, because the canvas only runs +in the GitHub Copilot app. Confirm before the session that participants have `az`, `gh`, +and `kubectl` on the host and that `kubectl` reaches `aks-adaptive-apps`. + +Have every team fork the trading application source and work on a branch. Nothing in this +challenge should be pushed to `microsoft/adaptive-apps`. + +Shell note: every command in this walkthrough is written to run unchanged in Bash and in +PowerShell 7, so there are no dual code blocks. The Radius control-plane commands in +Stage 4 are the exception to the host rule and belong in the devcontainer. + +### Pre-staging, and why the timing needs it + +The duration above assumes the coach has pre-staged the slow and permission-dependent +parts. Without that, teams routinely lose an hour before they model anything. Run the whole +path yourself once on the day's plugin version and record your own timings. + +Before the session, confirm for each team: + +| Item | Why it blocks | +| --- | --- | +| The Copilot app is installed on the host and the `radius` plugin installs cleanly | Everything | +| The fork exists, with Actions enabled and workflow permissions allowing commits | Environment creation commits workflow files | +| `gh auth status` shows `read:packages`, `write:packages`, and `workflow` | Package and workflow operations fail late and confusingly | +| Permission to create an Entra app registration and a federated credential | Environment creation | +| Whether the fork's default branch is protected | Decides whether verification opens a pull request that must be merged | +| Azure roles on the lab resource group for the deploy identity | The workflow fails at provisioning, minutes in | +| Whether the modeler is likely to emit an AI resource, and whether the subscription has quota for it | An unexpected AI resource can fail on quota | + +Record the plugin version and the upstream application commit each cohort used. This +walkthrough's expected outputs are observations from a preview build, not a specification. +When something differs, the observation is the correct answer and this document is stale. + +Two natural stopping points if a session runs long: finish after Stage 3 and treat the plan +review as the deliverable, or skip the code change in Stage 5 and diff an existing branch. +Stage 6 is the one stage that should never be cut, because it is the learning. + +## Fixed target map + +| Path | Radius control plane | Cluster | Namespace | Model | +| --- | --- | --- | --- | --- | +| Challenges 05-08, Azure | Persistent `ws-azure-prod` / `env-azure-prod` | `aks-adaptive-apps` | Challenge 05 application namespace | `iac/app.bicep` | +| Challenges 05-08, Local | Persistent `ws-local-prod` / `env-local-prod` | `k3s-azure-vm` | Challenge 05 application namespace | `iac/app.bicep` | +| Challenge 09, Canvas | Ephemeral `radius-cp` k3d cluster in the workflow run | `aks-adaptive-apps` | `trading-canvas` | `.radius/app.bicep` | + +## Stage 1: Install the plugin and model the application + +### Install + +In the GitHub Copilot app, open **Customize** in the side menu, select **Plugins**, search +for `radius`, and install it. Restart the Copilot session so the skills and the canvas load. +The plugin's three-dot menu handles update and uninstall. + +Verify the host prerequisites first: + +```bash +az login +gh auth login --scopes read:packages,write:packages,workflow +kubectl config use-context aks-adaptive-apps +kubectl cluster-info +``` + +The Radius CLI is deliberately absent from this list. The extension manages its own binary +at `~/.radius/ai-extensions/bin/rad` (`%USERPROFILE%\.radius\ai-extensions\bin\rad.exe` on +Windows) and does not resolve `rad` from `PATH` or from the `~/.rad/bin` installation used +in earlier challenges. Point this out: it is why a broken `rad` in the devcontainer is not +the explanation for a Canvas problem. + +### Model + +Start a session with the fork selected as a GitHub repository, then ask: + +```text +Show the application graph +``` + +The modeling skill analyses the repository and publishes three things: + +| Artifact | Purpose | +| --- | --- | +| `.radius/app.bicep` | The generated application definition | +| `.radius/app.origin.json` | Origin record: which source produced the model, and a hash of the compiled bytes | +| `.radius/bicepconfig.json` | Resolves the Radius Bicep extensions the definition uses | + +Two behaviours are worth surfacing: + +- Modeling runs into a staging directory and is promoted atomically. A run that cannot + produce a compiling model refuses to publish rather than leaving a half-written one. +- The canvas reads the origin record before rendering. Without it, the only question the + canvas could answer is whether `app.bicep` exists, so a model whose source has moved on + would render as though it were current. If the model was edited by hand, the skill asks + before overwriting. + +### Expected Modeled view + +For the trading application, expect roughly: + +- One application resource named after the repository. +- A container image resource per Dockerfile under `src/`, each pointing at its Dockerfile. +- A container per service: frontend, backend, and the AI agent. +- A PostgreSQL database resource, detected from the backend's database client + initialization and the compose file. +- Connections from the backend to its backing services. + +Have teams click **View source code** on each node. A container's source reference points +at the process entrypoint, not the Dockerfile; a container image's points at the Dockerfile. + +Two absences are the most useful part of the exercise, and both are correct behaviour: + +- **No route.** The modeler adds ingress only on explicit evidence. A Dockerfile `EXPOSE`, + a health endpoint, a published compose port, or a component named "gateway" are + deliberately not sufficient. Challenge 05 also declared no ingress and used + `rad resource expose`, so the two models agree here for different reasons. +- **No workload identity.** There is no predefined type for it. See Stage 2. + +### Scan the generated model before going further + +The upstream repository's `src/docker-compose.yml` carries development values such as a +database password and a placeholder secret, because it is a local development file. The +modeler reads compose files as evidence, so check what ended up in the generated model: + +```bash +git diff --cached -- .radius/ +``` + +Any credential value copied out of development configuration must be replaced before +deployment, and must not be committed. This is not a hypothetical: it is the most likely +way a team leaks something real in this challenge, because the value looks like it was +already in the repository so nobody looks twice. The success criterion is that no *new* or +deployable secret is introduced, not that the upstream repository is clean. + +## Stage 2: Reconcile the two models + +This is the analytical core of the challenge. The generated model is authored against a +fixed allow-list of predefined `Radius.*` types. The Challenge 03 catalog exists precisely +because that allow-list does not cover everything a real platform needs. + +### Model mapping + +| Component | Canvas generated type | Challenge 05-08 type | Same intent? | +| --- | --- | --- | --- | +| Application | `Radius.Core/applications` | `Applications.Core/applications` | Yes; the newer namespace | +| Frontend, backend, AI agent | `Radius.Compute/containers` | `Applications.Core/containers` | Yes | +| Container builds | `Radius.Compute/containerImages` built from each Dockerfile | None; Challenge 05 consumes published `ghcr.io/microsoft/adaptive-apps` images | **No.** Canvas builds from source at a pinned commit; Challenge 05 deploys a published tag, which defaults to the mutable `latest` | +| Trading database | `Radius.Data/postgreSqlDatabases` | `Radius.Resources/postgreSqlDatabases` | Same intent, different owner of the contract | +| Message broker | No predefined MQTT type exists. The allow-list offers `Radius.Messaging/kafka` and `Radius.Messaging/rabbitMQ`; an unmatched need becomes a generated custom type under `Radius.Resources` | `Radius.Resources/mqttBrokers` | **No predefined equivalent** | +| Workload identity | No predefined type | `Radius.Resources/workloadIdentities` | **No predefined equivalent** | +| AI model | `Radius.AI/models` if the source shows a model endpoint | `Radius.Resources/aiModels`, declared in `iac/ai.bicep` (Challenge 08), not in `iac/app.bicep` | Similar intent, different contract | +| Secrets | `Radius.Security/secrets` | Recipe-created Kubernetes Secret bound by `secretRef` | Different mechanism | +| Ingress | `Radius.Compute/routes`, only with explicit evidence | None; `rad resource expose` | Both decline to declare it | + +> [!NOTE] +> The AI row compares against `iac/ai.bicep`. Challenge 05's `iac/app.bicep` deliberately +> keeps AI disabled. A team that skipped Challenge 08 should record the row as out of scope +> rather than inventing a comparison. + +### Model answers + +**Which of the contracts this application uses have no predefined equivalent?** +`mqttBrokers` and `workloadIdentities`. This is the headline finding. The trading +application's MQTT broker and its Entra workload identity are exactly the capabilities the +firm's platform team had to define itself, and they are exactly the two the generated model +cannot express with a predefined type. Scope the question to the contracts the trading +application actually uses; the wider Challenge 03 catalog has further gaps, and listing +them all is an optional extension rather than the point. + +**Is the generated custom type interchangeable with the Challenge 03 contract?** +Almost certainly not, and this is where teams over-conclude. When the modeler has an +unmatched need it generates a custom type under the `Radius.Resources` namespace, which is +the same namespace the team used in Challenge 03 — arriving there from the other direction. +A shared namespace is not a shared contract. The generated type will have its own name, +inputs, and outputs, derived from what the source appeared to need, and the Challenge 04 +recipes were written against the team's schema. A recipe written for one does not implement +the other, and the ephemeral control plane cannot see the Challenge 04 registrations in any +case. Push teams to state this as a schema-compatibility problem, not a naming coincidence. + +**Which capabilities are missing, and why?** + +| Missing | Reason | +| --- | --- | +| Ingress / route | Deliberate conservatism; no explicit external-client evidence | +| Workload identity | No predefined type. The source does show credential use, since the backend and AI agent use `DefaultAzureCredential`, but that evidence does not determine the hosting identity mechanism, which is a platform decision | +| OIDC sign-in configuration (Challenge 06) | Configuration, not structure; it is parameters on a container, not a modeled resource | +| Service-mesh authorization (Challenge 07) | A platform policy concern that does not appear in application source at all | +| MQTT authentication method | A property of the broker recipe, which is why Challenge 05 read it from the resource rather than hard-coding it | + +**What does the source reference buy?** +Every non-application resource carries a reference to the file and line where the resource +was detected, and the canvas turns it into a clickable link. It makes a reviewer able to +challenge the model. It is metadata only and does not affect deployment. + +**Contract versus observation.** +`iac/app.bicep` is written against contracts the platform team controls, so it is a +statement about what the application is allowed to ask for. `.radius/app.bicep` is a +statement about what the source appears to need. Both files are committed artifacts, and +either can be redeployed as-is wherever the types and recipes it references exist. The +difference is governance, not regeneration: the first names contracts a platform team has +committed to implementing on every target, so making a new platform work means writing a +recipe. The second names types chosen by a modeler from a predefined list, so making a new +platform work means someone must first have predefined those types for it. This distinction +is the answer to Task 6 and is worth landing hard. + +## Stage 3: Environment, OIDC trust, and the plan + +### Create the credential profile and environment + +A credential profile is a reusable set of Azure tenant and subscription details used to +authenticate and to configure the GitHub-to-Azure OIDC trust. Create the profile, select +Azure as the provider, enter the tenant and subscription, verify, and save. + +Then create the environment: name it, pick the GitHub account and the saved profile, +accept or adjust the pre-populated Entra app registration name, and select the lab +resource group, the `aks-adaptive-apps` cluster, and a namespace. **Enter `trading-canvas`.** +Do not select the Challenge 05 namespace. + +### Account for what was created + +```bash +# The GitHub environment and its variables. Expect variables, and no cloud secrets. +gh api /repos///environments --jq '.environments[].name' +gh variable list --env --repo / + +# The generated workflows now in your repository. +gh workflow list --repo / + +# The private state package. +gh api /user/packages?package_type=container --jq '.[].name' +``` + +Expected variables include the state-backend settings (an OCI backend, a per-environment +GHCR registry path, and an archive name) and the Azure targeting values: client ID, tenant +ID, subscription ID, resource group, and AKS cluster name. There is also an optional route +exposure setting that defaults to private. + +Make the point explicitly: these are Actions **variables**, not secrets. The verification +workflow reads only variables. Nothing here is a credential, because the credential is +minted per run by OIDC. Have teams confirm the negative rather than assume it: + +```bash +gh secret list --repo / +gh secret list --env --repo / +``` + +Inspect the federated credential: + +```bash +az ad app list --display-name "" --query "[].{id:id,appId:appId}" -o table +az ad app federated-credential list --id \ + --query "[].{name:name,subject:subject,audience:audiences[0]}" -o table +``` + +The subject is exactly `repo:/:environment:` and the +audience is `api://AzureADTokenExchange`. + +Be precise about what this does and does not protect, because it is easy to overstate: + +| Attempt | Result | +| --- | --- | +| A fork, or any other repository, requests a token | Fails; the subject does not match | +| A workflow that does not name this GitHub environment | Fails; the subject does not match | +| A different environment in the same repository | Fails; the subject does not match | +| **A different branch in the same repository targeting this environment** | **Succeeds.** The subject contains no branch | + +That last row is the important one. The subject binds a repository and an environment, not +a branch. Anyone who can run a workflow against that GitHub environment gets the same +subject. Branch restriction is a separate control: GitHub Environment deployment-branch +rules and required reviewers. Ask the team where they would set that, and note that a +default branch with no protection plus an unrestricted environment is a weaker boundary +than it looks. What OIDC removes is the stored, exfiltratable, long-lived credential — a +real and large improvement — not the need to control who can deploy. + +### Review the plan + +Open the Planned view. Confirm the application, branch, and environment selectors, then +select each resource and record its resolved recipe and planned output resources. + +For Azure, the plan resolves recipes from the `azure-avm` recipe pack published by +[`radius-project/resource-types-contrib`](https://github.com/radius-project/resource-types-contrib). +Kubernetes workload nodes are annotated with the managed service backing them, so a +deployment on this environment reads as backed by AKS. + +**Model answers:** + +- The database resolves to an Azure managed PostgreSQL offering through the `azure-avm` + pack, built on Azure Verified Modules. Teams should recognise the shape from Challenge 04, + where they used an Azure Verified Module themselves. +- Compared with `iac/recipes/postgres-azure-flex.bicep`, do not let teams stop at + "functionally similar". Both provision a managed PostgreSQL, but the workshop recipe also + owns schema initialization and the `Demo Account` seed that Challenge 05 verified, names + its credentials Secret in a way `iac/app.bicep` binds to by name, and sets the TLS and + network behaviour the backend depends on. An upstream pack recipe makes its own choices + about all of those. The real difference is ownership and the contract surface, not the + Azure service. Ask what would break in Challenge 05's validation if you swapped one recipe + for the other. +- The decision point is the environment. In Challenge 04 it was `rad recipe register` + against `env-azure-prod`. Here it is the recipe pack the workflow deploys alongside a + `Radius.Core/environments` resource. Both are "the environment decides how a requirement + is met". +- **Expect at least one resource with no resolved recipe.** A pack implements the types it + knows about. Any type the modeler had to invent, which for this application is most likely + the message broker, has no entry in `azure-avm`. Custom-type recipes are supplied by + recipe packs at deploy time; the resolver does not fabricate them. Teams should find this + in the Planned view before they find it in a failed run. +- Reviewing a plan provisions nothing. Verify by comparing resource identities and + provisioning state, not a count, because equal counts do not detect an update or a + replacement: + +```bash +az resource list --resource-group \ + --query "sort_by([].{name:name,type:type,id:id,state:provisioningState}, &id)" -o json > before.json +``` + +Take this snapshot before opening the Planned view and again afterwards. The two files must +be identical. + +## Stage 4: Deploy and compare with Challenge 05 + +Select **Deploy Application** in the Planned view. The canvas commits the workflow files +and dispatches the run, then opens the Deployments view with live status. + +> [!IMPORTANT] +> If Stage 3 identified a resource with no resolved recipe, the run is expected to fail on +> that resource. Do not treat this as a broken lab. It is the sharpest evidence in the whole +> challenge: the application declares a capability, and the environment has no +> implementation for it, which is precisely the `RecipeNotFoundFailure` lesson from +> Challenges 04 and 05 arriving from a different direction. Coach teams to capture the +> failure, name the resource and the missing implementation, and then decide whether to +> deploy a reduced model to also see a running application. Both outcomes satisfy the +> success criteria; only an unexamined failure does not. + +Have participants read the committed workflows before discussing them: + +| Workflow | Role | +| --- | --- | +| `verify-azure.yml` | "Radius - Verify Credentials"; OIDC login, subscription check, cluster credentials, cluster info | +| `run-rad-commands.yml` | Dispatcher; detects the provider from the environment variables | +| `run-rad-commands-azure.yml` | The Azure deployment implementation | +| `delete-application.yml`, `delete-environment.yml` | Teardown, used in cleanup | + +### What the run actually does + +Reconstruct these steps from the run log: + +1. Commit or update the dispatcher and provider workflows. +2. Authenticate to Azure with OIDC; no stored credential is used. +3. Fetch a kubeconfig for the target AKS cluster. +4. Install k3d and create an ephemeral `radius-cp` cluster; install the `rad` CLI and + Terraform on the runner. +5. Install Radius into that ephemeral cluster, pointed at the target cluster. +6. Project GitHub OIDC tokens into the control-plane pods and register the Azure credential + with workload identity. +7. Log in to GHCR and restore the previous run's control-plane state. +8. Deploy a `Radius.Core/environments` resource and the `azure-avm` recipe pack. +9. Install or validate the Gateway API only if the application declares routes. +10. Run `rad deploy` on `.radius/app.bicep`, then persist state back to GHCR. On failure, + logs are uploaded as an artifact. The k3d cluster is always deleted. + +Step 10 is the punchline: it is the same `rad deploy` from Challenge 05, in a different +place, against a control plane that did not exist a minute earlier and will not exist a +minute later. + +### Reach the application + +```text +Access my deployed application +``` + +Copilot sets up port forwarding and returns a URL. Note that the managed gateway defaults +to a cluster-internal service, so nothing is exposed publicly by default. + +### Comparison table + +| Question | Challenge 05 | Challenge 09 | +| --- | --- | --- | +| Where does the Radius control plane run? | Installed in the AKS and K3s clusters | An ephemeral k3d cluster on a GitHub Actions runner | +| How long does it live? | For the workshop | For one workflow run | +| Where does Radius state live between deployments? | In the control plane's database in the cluster | In a private GHCR package per environment | +| Who holds the credential that reaches Azure? | A registered Azure credential on the control plane | Nobody; minted per run through OIDC federation | +| Where is the recipe set defined and registered? | Authored by the team, published to their registry, registered on `env-azure-prod` | An upstream recipe pack deployed by the workflow | +| What is the unit of promotion? | A `rad deploy` invocation from a workstation | A commit on a branch plus a workflow dispatch | + +### Prove isolation + +On the **host**: + +```bash +kubectl get all -n trading-canvas +kubectl get all -n +``` + +The Radius control-plane check belongs in the **devcontainer**, where `ws-azure-prod` is +configured. The Canvas extension uses its own private `rad` binary and never touches that +workspace, so there is no host `rad` to run this with: + +```bash +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad app list +``` + +The Challenge 05 control plane must still list `adaptive-apps` and must not list the Canvas +application. Also confirm the Challenge 05 application still serves traffic rather than +merely having running pods, using the same exposure and `/api/accounts` check from +Challenge 05. + +Because both paths share one Azure resource group, namespace separation is not sufficient +proof on its own. Compare an inventory taken before Stage 3 with one taken now: + +```bash +az resource list --resource-group \ + --query "sort_by([].{name:name,type:type,id:id}, &id)" -o json > after.json +``` + +Every difference must be attributable to the Canvas environment or deployment. Two Radius +control planes are now driving the same cluster from different namespaces, which is a good +moment to ask what would happen if both owned the same namespace. They would fight; that is +why the challenge fixes a separate namespace. Ask the same question about the shared +resource group, where the answer depends on resource naming rather than on isolation. + +## Stage 5: The Diff view as a review artifact + +Have each team make one deliberate architectural change on a branch. Adding a cache client +to the backend works well: it should appear as an added backing service plus an added +connection, with everything else unchanged. + +Re-model on the branch, then open the diff with the default branch as base and the branch +as head. Resources are tagged `added`, `removed`, `modified`, or `unchanged`, coloured +green, red, yellow, and grey. + +Branch semantics matter and are commonly misunderstood: + +- If the selected branch is the branch checked out in the workspace, writing + `.radius/app.bicep` to the working tree is enough. No commit or push is needed to preview. +- If the selected branch is a different branch, the model must be committed and pushed to + that branch before it can render there. The deploy workflow likewise checks the branch + out from GitHub, so a local-only fix is not deployed. + +Generate the Markdown summary and post it on a pull request. + +### Model answer: what the diff does and does not tell a reviewer + +The diff compares two application *models*. The useful framing is three-way, not two-way, +and teams that produce a two-way answer have usually got it wrong. + +| Change | Diff result | What it tells a reviewer | +| --- | --- | --- | +| Component, backing service, or connection added or removed | Added or removed | The architecture changed, and where | +| Resource type swapped, for example PostgreSQL to MongoDB | Modified or added and removed | Exactly the case the announcement highlights | +| A modeled property changed: an environment variable value, an image reference, a probe | Modified, when the property is part of the model | That the resource changed. **Nothing about whether the change is safe** | +| A changed SQL query or business rule | Unchanged | Nothing. The model does not carry application logic | +| A dependency bumped inside a container | Unchanged | Nothing, unless it introduces a new backing service the modeler detects | +| Route exposure widened at the environment level | Not compared at all | Nothing. It is a deployment environment setting, not part of either branch's model | + +Two distinct blind spots, and teams should name both: + +1. **Reported but not judged.** Challenge 06 turned OIDC sign-in on entirely through + parameters. A diff can show that container properties changed; it cannot tell you the + application's authentication posture changed. A removed readiness probe is the same + shape of problem, and Challenge 05 relied on that probe to prove database access. +2. **Not visible at all.** Application logic, and anything that lives in the environment + rather than the model. This second category is the one that matters for the gate + question, because no amount of care in review can catch what is not being compared. + +Correct conclusion: it is an excellent review aid and a poor gate. It tells a reviewer +where to look, which is exactly the problem the blog post describes for large +agent-generated changes. It does not tell them the change is safe, and a team that treats a +grey graph as approval has replaced review with reassurance. + +## Stage 6: Model assessment of the portability claim + +Push teams to produce something a platform lead could act on. + +### What Canvas made easier + +- No hand-authoring of a contract, a recipe, or an OCI publish step to reach an Azure + managed database. Challenge 03 plus Challenge 04 collapsed into an upstream recipe pack. +- No control-plane installation or lifecycle to own for this path. +- No long-lived cloud credential anywhere in the pipeline. +- A shared visual definition that both a developer and an agent read the same way, and a + branch-level architectural diff that did not previously exist. + +### What it does not do today + +| Limitation | Consequence for this MicroHack | +| --- | --- | +| Preview supports containerized applications deployed to Azure; AWS is stated as coming soon | `env-local-prod` on K3s is not reachable. The whole Local half of Challenges 01-08 is out of scope | +| Requires a Dockerfile | Fine here; a blocker for the brownfield work in Challenge 10 | +| Redeployments are incremental and do not prune removed resources | Removing a resource from the model does not remove it from Azure | +| Managed gateway supports HTTP and TLS routes only | The MQTT broker's non-HTTP traffic would need a user-managed gateway | +| One application per repository | The trading repository is fine; a monorepo of independent apps is not | +| Predefined type allow-list | No MQTT broker type and no workload identity type, the two custom contracts this application actually needs | +| Deployment history is per session, and canvas status polling stops before long runs finish | Read the workflow run, not the panel, for the truth | + +### Where portability actually comes from + +Resource types define the contract, recipes implement it per platform, and environments +choose the implementation. That is Challenge 03 and Challenge 04, and it is unchanged by +Canvas. Canvas changes who authors and reviews the model, and gives Azure an on-ramp. It +does not add a new portability mechanism, and using it does not remove the need for the +catalog — the two contracts it could not express are the proof. + +### What would be needed for a K3s, Azure Local, or Arc target + +- Provider support in the extension for a non-Azure, non-AWS compute platform, including + credential and trust handling for a cluster that may be private or disconnected. +- A recipe pack for that platform. The team already has the substance of one: the + Challenge 04 K3s recipe set. +- A path to a control plane that does not assume a GitHub-hosted runner can reach the + cluster. The Challenge 01 topology puts the K3s API behind a localhost Bastion tunnel on + purpose, which no hosted runner can traverse. + +The middle item already exists. The first and third do not, and the third is an +architectural question rather than a missing feature. + +### Recommendation + +Give Canvas to application teams for Azure-targeted work and for pull-request review on +every branch, including branches that will ship elsewhere, because the diff is valuable +regardless of target. Keep the catalog and the recipe sets with the platform team, and +keep the Challenge 05 path as the mechanism of record for any application that must also +run at a disconnected site. Tell a team asking to use Canvas for such an application that +they can model and review with it today, and must deploy the non-Azure target through the +platform path until provider support exists. + +## Troubleshooting + +| Symptom | Cause and action | +| --- | --- | +| The canvas panel does not open | Ask Copilot to fix the Radius Canvas, or reload extensions and restart the app. Confirm the session was restarted after installing the plugin | +| Modeling reports no Dockerfile | Only real Dockerfiles count; a `.devcontainer` image builds the development environment, not the application | +| `gh` is not recognized | Install the GitHub CLI on the **host**, restart the terminal, and run `gh auth login`. The devcontainer's CLI does not help the Copilot app | +| Missing `read:packages` or `write:packages` | `gh auth refresh -s read:packages -s write:packages`. The extension ignores an ambient `GH_TOKEN` for package creation | +| Credential verification times out | If the default branch is protected, environment setup opened a pull request with the workflow files. Merge it, then retry | +| `Branch not pushed yet`, or the Radius extension is not recognised | Commit and push the entire `.radius` directory, including its `bicepconfig.json`, to the branch being deployed | +| An image build fails with `exec format error` | Review target-platform handling in the Dockerfile and the build platforms | +| Deployment reported as timed out after a few minutes | The canvas stopped polling; the run continues. Open the workflow run | +| A removed resource still exists after redeployment | Expected. Redeployments are incremental. Delete it deliberately | +| The graph looks stale | Refresh to rebuild from the selected branch's current definition. Check the origin record if the source has moved on | + +Classify failures before reacting. Infrastructure, credential, and cluster failures are +fixed in the environment. Schema and modeling failures are fixed in the application +definition. Never fix a modeling failure by removing a required dependency, a secret +binding, or a connection just to get a clean compile. + +## Clean up + +Order matters. The environment deletion flow refuses to start while an application is still +deployed, and the Azure federated credential must still exist when the Radius environment +is deleted. + +1. Delete the deployment from the Deployments view and let the deletion workflow finish. +2. Delete the environment. This deletes the Radius environment, the per-environment Azure + federated credential, the GitHub environment, and the GHCR state package. +3. Confirm the Challenge 05 application and the AKS cluster are intact. + +```bash +kubectl get all -n trading-canvas +kubectl get all -n +az resource list --resource-group -o table +``` + +> [!WARNING] +> Deleting a deployment can permanently remove infrastructure and data owned by the +> application. It does not delete the AKS cluster or the lab resource group, and the Entra +> app registration is intentionally left in place. + +Only Azure-backed environments can be deleted through this flow today. If a team's +environment is left behind, record its remaining cost and scope rather than deleting Azure +resources by hand and losing the audit trail. + +## Evidence checklist + +- `.radius/app.bicep`, the origin record, and the Modeled view inventory with source + references verified, plus the credential scan of the generated model. +- The completed model mapping table, including the contracts with no predefined equivalent + and a judgement on whether the generated custom type is interchangeable with the + Challenge 03 contract for the same capability. +- GitHub environment variables listing plus an empty secrets listing at both repository and + environment scope. +- The federated credential subject and audience, with values redacted as agreed, and the + three-way statement of what that subject does and does not restrict. +- Planned-view recipe resolution per resource, including any resource with no resolved + recipe, and identical Azure resource snapshots before and after planning. +- The workflow run, the ten reconstructed steps, and the Challenge 05 comparison table. + Where the run failed on an unresolvable resource, the captured failure and the statement + of what a platform team would have to supply. +- Namespace isolation output from both namespaces, a Challenge 05 application health check + rather than a pod listing alone, `rad app list` on `ws-azure-prod` from the devcontainer, + and the accounted-for Azure resource-group diff. +- The Diff view Markdown summary on a pull request, plus the three-way classification of + reported, reported-but-not-judged, and not-compared changes. +- The written portability assessment. From 7587f50c4250b8c11429ab6155063a07a5697a08 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Fri, 4 Sep 2026 12:54:43 +0200 Subject: [PATCH 27/29] Clarify the two audiences and Hack Console provisioning in the Adaptive Apps readme (#23) * Target platform engineers and app developers in the Adaptive Apps readme Split the challenge sequence into two audience paths, call out that challenges 01-03 can be pre-provisioned via the Hack Console, and add diagrams for the one-application/many-platforms model. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Anchor the readme to microsoft/adaptive-apps and the Simplified Trading App Add a 'What this MicroHack builds on' table mapping every building block to its upstream source, name the Simplified Trading App as the running example, and add an Additional documentation section including the Azure Architecture Center article. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../04-adaptive-apps/Readme.md | 192 +++++++++++++++--- 1 file changed, 166 insertions(+), 26 deletions(-) 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 73af9b620..c2cc8c8e8 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 @@ -1,9 +1,12 @@ # **Adaptive Apps MicroHack** - [MicroHack introduction](#microhack-introduction) +- [What this MicroHack builds on](#what-this-microhack-builds-on) - [MicroHack context](#microhack-context) +- [Who is this MicroHack for?](#who-is-this-microhack-for) - [Objectives](#objectives) - [MicroHack challenges](#microhack-challenges) +- [Additional documentation](#additional-documentation) - [Contributors](#contributors) ## MicroHack introduction @@ -13,33 +16,63 @@ different places: a public cloud region, an on-premises datacenter, an edge location, or a fully disconnected site. They need to do this without rebuilding the application for every environment. +This MicroHack is built on top of the Microsoft +[**Adaptive Apps**](https://github.com/microsoft/adaptive-apps) project - *build +once, adapt everywhere* - and uses its **Simplified Trading App** as the running +example throughout every challenge. + This MicroHack uses [Radius](https://radapp.io), an open-source, cloud-native application platform, to make a single application adaptive. The application declares the capabilities it needs, such as a database, message broker, workload identity, or AI model, while each environment decides how those capabilities are provided. -Across the challenges, you take the role of a platform engineering team. You -prepare target platforms, install Radius, define reusable platform abstractions -with resource types and recipes, and prove portability by deploying a sample -stock-trading application across multiple environments without changing its -application model. Advanced challenges apply the same approach to AI services, -custom resources, and brownfield modernization. - -This MicroHack is not a complete explanation of Radius. The following articles -provide useful background: - -- [What is Radius?](https://docs.radapp.io/concepts/overview/) -- [Radius application model](https://docs.radapp.io/guides/author-apps/application/overview/) -- [Radius environments](https://docs.radapp.io/guides/deploy-apps/environments/overview/) -- [Radius recipes](https://docs.radapp.io/guides/recipes/overview/) +The result is one application that runs unchanged on Azure, on Azure Local, and +on a disconnected edge cluster, because the platform - not the application - +decides what backs each capability: + +```mermaid +flowchart TB + APP["ONE APPLICATION
Frontend | Agent | Backend | Broker | Database"] + APP --> REC{{"Radius recipes"}} + REC --> AZ["Azure
ACA / AKS
Entra ID
Azure PostgreSQL
Azure OpenAI"] + REC --> AL["Azure Local
Arc-enabled Kubernetes
Keycloak / Entra ID
PostgreSQL
Azure OpenAI"] + REC --> ED["Edge
K3s
Keycloak
PostgreSQL
Local LLM"] +``` + +Across the challenges, you work through the full lifecycle of such an +application. You prepare target platforms, install Radius, define reusable +platform abstractions with resource types and recipes, and prove portability by +deploying the Simplified Trading App across multiple environments without +changing its application model. Advanced challenges apply the same approach to +identity, AI services, the developer inner loop, and brownfield modernization. + +## What this MicroHack builds on + +This MicroHack does not invent a new stack. It is a hands-on path through the +Microsoft Adaptive Apps project, and it consumes that project's assets directly. + +| Building block | Where it comes from | How the MicroHack uses it | +| --- | --- | --- | +| **Adaptive Apps** | [microsoft/adaptive-apps](https://github.com/microsoft/adaptive-apps) | The reference architecture, capability portfolios, resource types, and recipes that the challenges install and extend. | +| **Simplified Trading App** | [`src/` in microsoft/adaptive-apps](https://github.com/microsoft/adaptive-apps/tree/main/src) | The example application used in every challenge: a Node.js frontend, a C# backend with MQTT order processing, a C# AI agent, an MQTT broker, and PostgreSQL. | +| **Resource types and recipes** | [`radius/` in microsoft/adaptive-apps](https://github.com/microsoft/adaptive-apps/tree/main/radius) | The `Radius.*` capability contracts imported in Challenge 03 and implemented in Challenge 04. | +| **Container images and recipe artifacts** | `ghcr.io/microsoft/adaptive-apps` | The published trading app images and recipe packages deployed from Challenge 05 onward. | +| **Radius** | [radapp.io](https://radapp.io) | The application platform behind the whole approach. | + +Because the trading app is the same in every challenge, any difference you see +between environments comes from the platform, never from the application code. + +This MicroHack is not a complete explanation of Adaptive Apps or Radius. See +[Additional documentation](#additional-documentation) for background reading. ## MicroHack context -The scenario follows a trading firm whose stock-trading application must run -across cloud, on-premises, and edge environments. Some sites may need to keep -operating when disconnected from the cloud, while connected sites should use -managed Azure services where appropriate. +The scenario follows a trading firm whose stock-trading application - the +Simplified Trading App from the Adaptive Apps project - must run across cloud, +on-premises, and edge environments. Some sites may need to keep operating when +disconnected from the cloud, while connected sites should use managed Azure +services where appropriate. Instead of maintaining a different deployment definition for every site, the platform team wants one application definition with environment-specific @@ -47,13 +80,74 @@ implementations of the same capability contracts. Radius resource types define those contracts, recipes provide their implementations, and Radius environments determine which implementation is used at each location. +The application declares *what* it needs. The recipe registered in the target +environment decides *how* that need is met: + +```mermaid +flowchart LR + DEF["Application definition
Frontend
Backend
Broker
Database
Identity
AI service"] + DEF --> REC{{"Radius recipe"}} + REC --> AZ["Azure
ACA / AKS
Entra ID
Azure PostgreSQL
Azure OpenAI"] + REC --> AL["Azure Local
Kubernetes
Keycloak
PostgreSQL
Azure OpenAI"] + REC --> ED["Edge K3s
K3s
Keycloak
PostgreSQL
Local LLM"] +``` + +The application model stays identical in all three columns. Only the recipes +registered in the environment change. + +## Who is this MicroHack for? + +This MicroHack targets two audiences, and each one has its own path through the +challenges. Both start at Challenge 00. + +| Audience | Path | What you do | +| --- | --- | --- | +| **Platform engineer** | Challenges **01 - 05** | 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** | Challenges **04 - 10** (Challenge 04 is optional) | Consume the platform contracts, deploy and port the trading application, and adapt identity, service communication, AI services, and an existing brownfield application without changing the application model. | + +Challenges 04 and 05 are the deliberate handover point: the platform engineer +finishes by proving the contracts work, and the application developer starts +from the same place. + +> [!TIP] +> **Challenges 01 - 03 can be provisioned automatically through the Hack +> Console.** If your lab environment is pre-provisioned, the platforms, the +> Radius control planes, and the resource-type catalog already exist. Application +> developers can then skip straight to Challenge 04 (optional) or Challenge 05, +> and platform engineers can still walk 01 - 03 manually to understand what the +> automation did. + +```mermaid +flowchart LR + C0["Challenge 00
Prerequisites"] + C13["Challenges 01 - 03
Platforms, Radius,
resource types"] + C4["Challenge 04
Recipes
(optional for developers)"] + C5["Challenge 05
Port the app"] + C610["Challenges 06 - 10
Identity, communication, AI,
Radius Canvas, brownfield"] + + C0 --> C13 --> C4 --> C5 --> C610 + C0 -. "Hack Console provisions 01 - 03" .-> C4 + + classDef platform fill:#dbeafe,stroke:#1d4ed8,color:#0f172a + classDef developer fill:#dcfce7,stroke:#15803d,color:#0f172a + class C13 platform + class C610 developer +``` + ## Objectives After completing this MicroHack, you will: +**As a platform engineer (Challenges 01 - 05)** + - Know how to prepare Kubernetes target platforms and install Radius. - Understand how Radius resource types and recipes define reusable platform capabilities. +- Offer the same capability contract on Azure, Azure Local, and edge platforms + with environment-specific implementations. + +**As an application developer (Challenges 04 - 10)** + - Deploy one application model across multiple environments with minimal environment-only configuration changes. - Adapt identity, secure service communication, and AI services without @@ -66,7 +160,9 @@ After completing this MicroHack, you will: > [!NOTE] > The challenge sequence is completed incrementally. Follow the links below for -> the currently published student and coach material. +> 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 01 - 05) or the application developer path (Challenges 04 - 10). ### General prerequisites @@ -89,6 +185,11 @@ To use the MicroHack time effectively, have the following available: - [Visual Studio Code](https://code.visualstudio.com/) - Bash or PowerShell 7; Windows users can also use WSL 2 +If the Hack Console has pre-provisioned Challenges 01 - 03 for you, the Azure +subscription, cluster capacity, and container registry requirements are already +satisfied. You still need the local tooling above to talk to the provisioned +environments. + The recommended option is the repository [devcontainer](../../../.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json), which installs the Challenges 00-06 toolchain while retaining the manual host setup @@ -109,43 +210,82 @@ Bastion native-client tunnel bound only to localhost in the participant environm ### Challenge 00: universal prerequisite -Complete Challenge 00 before starting a challenge in any bucket. +Complete Challenge 00 before starting a challenge in any bucket, regardless of +which audience path you follow. - [Challenge 00 - Prerequisites: ready, set, go](challenges/challenge-00.md) **<- Start here** ### Bucket 1: Infrastructure setup -Prepare the target platforms and deploy the Radius control plane. +**Audience: platform engineer.** Prepare the target platforms and deploy the +Radius control plane. + +> [!NOTE] +> Challenges 01 - 03 can be pre-provisioned automatically through the Hack +> Console. Work through them manually to learn how the platform is built, or +> use the provisioned environment and continue at Challenge 04 or 05. - [Challenge 01 - Prepare the platforms](challenges/challenge-01.md) - [Challenge 02 - Deploy and explore Radius](challenges/challenge-02.md) ### Bucket 2: Exploring Radius -Define portable platform capabilities and implement them with recipes. +**Audience: platform engineer** (Challenge 04 is also the optional entry point +for application developers). Define portable platform capabilities and +implement them with recipes. - [Challenge 03 - Build the platform abstractions](challenges/challenge-03.md) - [Challenge 04 - Build the platform abstractions with recipes](challenges/challenge-04.md) + - *Optional for application developers - complete it to understand what backs + each contract, or skip it and consume the registered recipes as-is.* ### Bucket 3: Portable apps across platforms -Deploy the application across environments and adapt its identity, -communication, and AI capabilities. +**Audience: application developer** (Challenge 05 also closes out the platform +engineer path). Deploy the application across environments and adapt its +identity, communication, and AI capabilities. - [Challenge 05 - Port the App Across Environments](challenges/challenge-05.md) + **<- Application developer start here** - [Challenge 06 - Adapt Identity Services - Configure User Authentication](challenges/challenge-06.md) - [Challenge 07 - Secure service communication](challenges/challenge-07.md) - [Challenge 08 - Adapt AI Services](challenges/challenge-08.md) ### Bucket 4: Advanced challenges -Bring the application model into the developer inner loop and apply the -Adaptive Apps approach to an existing application. +**Audience: application developer.** Bring the application model into the +developer inner loop and apply the Adaptive Apps approach to an existing +application. - [Challenge 09 - Model, review, and deploy with Radius Canvas](challenges/challenge-09.md) - [Challenge 10 - Modernize a brownfield application](challenges/challenge-10.md) +## Additional documentation + +**Adaptive Apps** + +- [Build portable applications with adaptive apps](https://learn.microsoft.com/azure/architecture/solution-ideas/articles/adaptive-apps) + - Azure Architecture Center +- [microsoft/adaptive-apps repository](https://github.com/microsoft/adaptive-apps) +- [Adaptive Apps getting started tutorial](https://github.com/microsoft/adaptive-apps/tree/main/tutorials/getting-started) +- [Capability portfolios overview](https://github.com/microsoft/adaptive-apps/blob/main/docs/portfolios/overview.md) +- [Simplified Trading App source](https://github.com/microsoft/adaptive-apps/tree/main/src) + +**Radius** + +- [What is Radius?](https://docs.radapp.io/concepts/overview/) +- [Radius application model](https://docs.radapp.io/guides/author-apps/application/overview/) +- [Radius environments](https://docs.radapp.io/guides/deploy-apps/environments/overview/) +- [Radius recipes](https://docs.radapp.io/guides/recipes/overview/) +- [Introducing Radius Canvas](https://techcommunity.microsoft.com/blog/azuredevcommunityblog/introducing-radius-canvas-visualize-review-and-deploy-applications-in-the-github/4549760) + +**Supporting technologies** + +- [Dapr](https://dapr.io/) - the portable programming model used by Adaptive Apps +- [Azure Arc-enabled Kubernetes](https://learn.microsoft.com/azure/azure-arc/kubernetes/overview) +- [Azure Local](https://learn.microsoft.com/azure/azure-local/) + ## Contributors - The Adaptive Apps team at Microsoft From 5dd35c0f67ce7b406ce4ec1cee912ef8cfac375c Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Fri, 4 Sep 2026 13:00:29 +0200 Subject: [PATCH 28/29] Update Readme.md --- 03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 c2cc8c8e8..a1a07faa1 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 @@ -87,9 +87,9 @@ environment decides *how* that need is met: flowchart LR DEF["Application definition
Frontend
Backend
Broker
Database
Identity
AI service"] DEF --> REC{{"Radius recipe"}} - REC --> AZ["Azure
ACA / AKS
Entra ID
Azure PostgreSQL
Azure OpenAI"] - REC --> AL["Azure Local
Kubernetes
Keycloak
PostgreSQL
Azure OpenAI"] - REC --> ED["Edge K3s
K3s
Keycloak
PostgreSQL
Local LLM"] + REC --> AZ["Azure
ACA / AKS
Entra ID
Eventgrid
Azure PostgreSQL
Azure OpenAI"] + REC --> AL["Azure Local
Kubernetes
Keycloak
Rabit MQ
PostgreSQL container
Azure OpenAI"] + REC --> ED["Edge K3s
K3s
Keycloak
Rabit MQ
PostgreSQL vm
Local LLM"] ``` The application model stays identical in all three columns. Only the recipes From 7f9fa52e4df23017261bdca25fefc62f1a01d095 Mon Sep 17 00:00:00 2001 From: Dylan de Jong Date: Fri, 4 Sep 2026 13:01:18 +0200 Subject: [PATCH 29/29] Update Readme to remove outdated references Removed references to resource types, recipe artifacts, and Radius from the Readme. --- 03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md | 3 --- 1 file changed, 3 deletions(-) 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 a1a07faa1..ba646dca7 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 @@ -56,9 +56,6 @@ Microsoft Adaptive Apps project, and it consumes that project's assets directly. | --- | --- | --- | | **Adaptive Apps** | [microsoft/adaptive-apps](https://github.com/microsoft/adaptive-apps) | The reference architecture, capability portfolios, resource types, and recipes that the challenges install and extend. | | **Simplified Trading App** | [`src/` in microsoft/adaptive-apps](https://github.com/microsoft/adaptive-apps/tree/main/src) | The example application used in every challenge: a Node.js frontend, a C# backend with MQTT order processing, a C# AI agent, an MQTT broker, and PostgreSQL. | -| **Resource types and recipes** | [`radius/` in microsoft/adaptive-apps](https://github.com/microsoft/adaptive-apps/tree/main/radius) | The `Radius.*` capability contracts imported in Challenge 03 and implemented in Challenge 04. | -| **Container images and recipe artifacts** | `ghcr.io/microsoft/adaptive-apps` | The published trading app images and recipe packages deployed from Challenge 05 onward. | -| **Radius** | [radapp.io](https://radapp.io) | The application platform behind the whole approach. | Because the trading app is the same in every challenge, any difference you see between environments comes from the platform, never from the application code.