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
new file mode 100644
index 000000000..32edd720c
--- /dev/null
+++ b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json
@@ -0,0 +1,63 @@
+{
+ "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": {
+ "version": "2.89.1"
+ },
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": false,
+ "installOhMyZsh": false,
+ "upgradePackages": false
+ },
+ "ghcr.io/devcontainers/features/powershell:2": {
+ "version": "stable"
+ }
+ },
+ "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 /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": [
+ 3000,
+ 8080,
+ 7007
+ ],
+ "portsAttributes": {
+ "3000": {
+ "label": "Adaptive Apps frontend",
+ "onAutoForward": "notify"
+ },
+ "8080": {
+ "label": "Keycloak identity broker",
+ "onAutoForward": "notify"
+ },
+ "7007": {
+ "label": "Radius dashboard",
+ "onAutoForward": "notify"
+ }
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "ms-azuretools.vscode-bicep",
+ "ms-kubernetes-tools.vscode-kubernetes-tools",
+ "ms-vscode.powershell",
+ "timonwong.shellcheck"
+ ],
+ "settings": {
+ "terminal.integrated.defaultProfile.linux": "bash"
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..3e4015b27
--- /dev/null
+++ b/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/post-create.sh
@@ -0,0 +1,214 @@
+#!/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"
+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"
+ 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"
+
+configure_windows_worktree_shell() {
+ 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
+ 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 \
+ "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 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"
+}
+
+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 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
+ 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 ! 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
+ 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
+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"
+verify_tooling
+
+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
+pwsh --version
+git --version
+jq --version
+yq --version
+az extension show --name bastion --query version --output tsv | sed 's/^/bastion extension: /'
+ssh -V
+
+echo
+echo "The container is ready. Sign in with 'az login' and select the lab subscription."
diff --git a/.gitattributes b/.gitattributes
index 92dba4f24..44323809c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,26 +1,21 @@
-# Shell scripts execute inside Linux containers and must retain LF line endings.
-*.sh text eol=lf
-
-# Enforce consistent line endings across Windows + Linux environments.
-* text=auto eol=lf
-
-# Keep Windows command scripts with CRLF for compatibility.
-*.bat text eol=crlf
-*.cmd text eol=crlf
-
-# Treat common binary formats as binary.
-*.png binary
-*.jpg binary
-*.jpeg binary
-*.gif binary
-*.webp binary
-*.ico binary
-*.pdf binary
-*.zip binary
-*.tgz binary
-*.gz binary
-*.jar binary
-*.woff binary
-*.woff2 binary
-*.ttf binary
-*.eot binary
\ No newline at end of file
+"/.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/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
+# 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
+"/.devcontainer/03-azure-01-01-app-innovation-04-adaptive-apps/devcontainer.json" text eol=lf
diff --git a/.gitignore b/.gitignore
index 907a5c1b1..ddc055283 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
+
# Dev Containers feature lock file generated on the first container build.
.devcontainer/**/devcontainer-lock.json
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..ba646dca7
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md
@@ -0,0 +1,294 @@
+# **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
+
+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 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.
+
+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. |
+
+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 - 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
+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
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
+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
+ 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
+
+> [!NOTE]
+> The challenge sequence is completed incrementally. Follow the links below for
+> the currently published student and coach material. Each bucket is labelled
+> with its primary audience, so you can follow either the platform engineer path
+> (Challenges 01 - 05) or the application developer path (Challenges 04 - 10).
+
+### 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, 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)
+- 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/)
+- [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
+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.
+
+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, regardless of
+which audience path you follow.
+
+- [Challenge 00 - Prerequisites: ready, set, go](challenges/challenge-00.md)
+ **<- Start here**
+
+### Bucket 1: Infrastructure setup
+
+**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
+
+**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
+
+**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
+
+**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
+- 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..ca4b6e0ae
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-00.md
@@ -0,0 +1,411 @@
+# 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,
+ 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 two Standard public IP
+ addresses
+- 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
+> 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
+consistent Linux toolchain for Challenges 00-04. A manual host installation remains
+supported.
+
+#### Option A: Use the recommended devcontainer
+
+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
+
+- [Visual Studio Code](https://code.visualstudio.com/)
+- [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:
+
+```bash
+git clone https://github.com/djong1/MicroHack.git
+cd MicroHack
+git switch djong1-adaptive-apps-microhack
+code .
+```
+
+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 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.
+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.
+
+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. 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
+
+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/)
+- [Radius CLI (`rad`)](https://docs.radapp.io/getting-started/install/)
+- [Git](https://git-scm.com/downloads)
+- `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: 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.
+
+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:
+
+```bash
+az --version
+az bicep version
+kubectl version --client
+helm version
+rad version
+git --version
+jq --version
+yq --version
+az extension show --name bastion --query version --output tsv
+curl --version
+ssh -V
+tar --version
+```
+
+Resolve missing commands and `PATH` problems before continuing.
+
+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
+
+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.
+
+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:
+
+- 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.
+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
+
+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.
+- 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,
+ kubeconfig, Radius workspace, or SSH state.
+
+## 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.
+- 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.
+- 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.
+
+## 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..59652658d
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-01.md
@@ -0,0 +1,156 @@
+# 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
+- Private K3s VNet, VM, and Azure Bastion names
+- AKS node and K3s VM sizes
+- Nonoverlapping VNet, workload-subnet, and `/26` `AzureBastionSubnet` CIDRs
+
+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
+K3s API endpoint: https://127.0.0.1:16443
+Azure Bastion: bas-adaptive-apps
+```
+
+### 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 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` 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
+
+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 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
+
+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.
+- 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 `AzureBastionSubnet`.
+- 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 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
+ 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..83762e3a8
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-02.md
@@ -0,0 +1,159 @@
+# 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.
+
+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
+
+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 recipes.radapp.io deploymenttemplates.radapp.io deploymentresources.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..769349cfc
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-03.md
@@ -0,0 +1,159 @@
+# 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.
+
+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
+
+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..1b0d27c7b
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-04.md
@@ -0,0 +1,159 @@
+# 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.
+
+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
+
+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` 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.
+
+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, 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
+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 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
+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`.
+- 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.
+- 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..bbbe1b4e9
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md
@@ -0,0 +1,172 @@
+# Challenge 05 - Port the App Across Environments
+
+[< Previous Challenge](challenge-04.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-06.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 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.
+
+## 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`, 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
+
+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. Repeat the same schema, seed, and backend database-access checks and
+confirm the initializer requires TLS.
+
+### 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 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, and why a recipe can differ from the "obvious" cloud service.
+
+## 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.
+- 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 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.
+
+## 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. 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
+
+- [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/challenges/challenge-06.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md
new file mode 100644
index 000000000..e49335059
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md
@@ -0,0 +1,245 @@
+# Challenge 06 - Adapt Identity Services - Configure User Authentication
+
+[< Previous Challenge](challenge-05.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-07.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/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..7ac643a13
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md
@@ -0,0 +1,252 @@
+# Challenge 08 - Adapt AI Services
+
+[< Previous Challenge](challenge-07.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-09.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/challenges/challenge-09.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md
new file mode 100644
index 000000000..ea5699198
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md
@@ -0,0 +1,451 @@
+# 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]
+> 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.
+
+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
new file mode 100644
index 000000000..dec1c9087
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md
@@ -0,0 +1,9 @@
+# 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.
+
+[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..1b084881f
--- /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"
+
+# Run from the Adaptive Apps MicroHack root.
+bash 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..d99b5309c
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-arc.md
@@ -0,0 +1,36 @@
+# 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.
+
+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).
+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..77a5df82e
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/docs/prepare-k3s.md
@@ -0,0 +1,217 @@
+# Prepare private K3s through Azure Bastion
+
+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 has no public IP and no inbound Internet rules.
+
+## 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
+- 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
+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.
+
+## 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 |
+| --- | --- | --- | --- |
+| 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=false
+bash resources/prepare-k3s-azure-vm.sh
+```
+
+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.
+
+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
+
+From the Adaptive Apps MicroHack root:
+
+```bash
+export AZURE_SUBSCRIPTION=""
+export AZURE_LOCATION="westeurope"
+export RESOURCE_GROUP="rg-adaptive-apps"
+
+bash resources/prepare-k3s-azure-vm.sh
+```
+
+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.
+
+If localhost port 16443 is already occupied, choose a different unprivileged port before
+both provisioning and later reconnects:
+
+```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=5m
+kubectl get nodes -o wide
+```
+
+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. |
+| 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
+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. The NAT gateway also bills hourly, plus per-GB data processing. 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
+```
+
+The NAT gateway is deployed by default and 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.
+
+K3s can optionally be connected to Azure Arc by following
+[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)
+- [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/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/aks-env.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep
new file mode 100644
index 000000000..7293901ae
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/aks-env.bicep
@@ -0,0 +1,123 @@
+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'
+@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 = ''
+@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
+
+resource appNamespace 'core/Namespace@v1' = {
+ metadata: {
+ name: namespace
+ labels: {
+ 'istio.io/rev': istioRevision
+ }
+ }
+}
+
+// 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: {
+ templateKind: 'bicep'
+ templatePath: postgresRecipeTemplatePath
+ parameters: {
+ aksEgressIps: aksEgressIps
+ }
+ }
+ }
+ 'Radius.Resources/mqttBrokers': {
+ default: {
+ templateKind: 'bicep'
+ templatePath: mqttTemplatePath
+ }
+ }
+ '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/app.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep
new file mode 100644
index 000000000..695bf2572
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep
@@ -0,0 +1,295 @@
+// Portable stock-trading application adapted from microsoft/adaptive-apps.
+// 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
+
+@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('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 = ''
+
+@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 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.')
+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
+ }
+}
+
+// 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: tradingDbName
+ 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
+ }
+ }
+ readinessProbe: {
+ kind: 'httpGet'
+ path: '/api/accounts'
+ containerPort: 8080
+ }
+ env: {
+ ASPNETCORE_URLS: {
+ value: 'http://+:8080'
+ }
+ CONNECTION_DB_SECRETS_PASSWORD: {
+ valueFrom: {
+ secretRef: {
+ source: '${tradingDbName}-credentials'
+ key: 'password'
+ }
+ }
+ }
+ AZURE_CLIENT_ID: {
+ value: backendIdentity.properties.clientId
+ }
+ AZURE_TENANT_ID: {
+ 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: tradingMqtt.properties.authMethod
+ }
+ MQTT_TOKEN_AUDIENCE: {
+ value: tradingMqtt.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: empty(workloadIdentityTenantId)
+ ? frontendIdentity.properties.tenantId
+ : workloadIdentityTenantId
+ }
+ MQTT_AUTH_METHOD: {
+ value: tradingMqtt.properties.authMethod
+ }
+ MQTT_TOKEN_AUDIENCE: {
+ value: tradingMqtt.properties.tokenAudience
+ }
+ AUTH_USERNAME: {
+ value: authUsername
+ }
+ AUTH_PASSWORD: {
+ value: authPassword
+ }
+ 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
+ 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
new file mode 100644
index 000000000..5ae97cc20
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/bicepconfig.json
@@ -0,0 +1,9 @@
+{
+ "extensions": {
+ "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/iac/local-env.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/local-env.bicep
new file mode 100644
index 000000000..33bb4641c
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/local-env.bicep
@@ -0,0 +1,78 @@
+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'
+@description('Pinned workshop OCI path for the corrected Kubernetes PostgreSQL recipe.')
+param postgresRecipeTemplatePath string
+
+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: postgresRecipeTemplatePath
+ }
+ }
+ '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/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/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..4a2e4d7e1
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep
@@ -0,0 +1,325 @@
+@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))
+// 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
+// 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
+} 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'
+ }
+ // 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
+ }
+}
+
+resource databaseResource 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2024-08-01' = {
+ parent: server
+ name: database
+ properties: {
+ charset: 'UTF8'
+ collation: 'en_US.utf8'
+ }
+}
+
+// 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'
+ properties: {
+ value: 'on'
+ source: 'user-override'
+ }
+ dependsOn: [
+ databaseResource
+ ]
+}
+
+resource minimumTlsVersion 'Microsoft.DBforPostgreSQL/flexibleServers/configurations@2024-08-01' = {
+ parent: server
+ name: 'ssl_min_protocol_version'
+ properties: {
+ 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
+ name: 'aks-egress-${index + 1}'
+ properties: {
+ startIpAddress: egressIp
+ endIpAddress: egressIp
+ }
+ dependsOn: [
+ minimumTlsVersion
+ ]
+ }
+]
+
+resource credentials 'core/Secret@v1' = {
+ metadata: {
+ name: credentialsName
+ 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'
+ initializerScript
+ ]
+ 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/${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
+ database: databaseResource.name
+ username: user
+ // 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
new file mode 100644
index 000000000..ce5f59255
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-kubernetes.bicep
@@ -0,0 +1,315 @@
+@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)}'
+// 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
+// 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: ''
+ namespace: context.runtime.kubernetes.namespace
+} as k8s
+
+resource credentials 'core/Secret@v1' = {
+ metadata: {
+ name: credentialsName
+ 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 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: 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:${tag}'
+ command: [
+ 'sh'
+ '-ceu'
+ initializerScript
+ ]
+ 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
+ ]
+}
+
+// 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/${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: '${uniqueName}.${context.runtime.kubernetes.namespace}.svc.cluster.local'
+ port: port
+ database: database
+ username: user
+ // 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/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..cab6b5e4b
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/sql-server.bicep
@@ -0,0 +1,56 @@
+@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}'
+ }
+ }
+ ]
+ // 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
+ }
+ }
+}
+
+output result object = {
+ resources: [
+ sqlServer.outputs.resourceId
+ ]
+ values: {
+ host: sqlServer.outputs.fullyQualifiedDomainName
+ port: 1433
+ database: databaseName
+ username: 'sqladmin'
+ secrets: {
+ #disable-next-line outputs-should-not-contain-secrets
+ password: adminPassword
+ }
+ }
+}
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/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..68c7386a4
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh
@@ -0,0 +1,280 @@
+#!/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}"
+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 grep 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}"
+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}"
+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}"
+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
+}
+TEMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TEMP_DIR"' EXIT
+
+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 ip_fields=()
+ 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 | tr -d '\r')"
+
+ 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 | tr -d '\r')
+ ((${#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
+ }
+ # 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 | 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 (observed sku='${sku}', allocation='${allocation}')." >&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.}"
+
+ 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 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
+ 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
+ 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
+ rad workspace switch "$AKS_WORKSPACE"
+ local istio_revision
+ istio_revision="$(kubectl get deployment \
+ --namespace aks-istio-system \
+ --selector app=istiod \
+ --output jsonpath='{.items[0].metadata.labels.istio\.io/rev}')"
+ [[ -n "$istio_revision" ]] || {
+ 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" \
+ --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 "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:${WORKSHOP_RECIPE_VERSION}"
+
+ 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() {
+ 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}" \
+ --parameters "postgresRecipeTemplatePath=${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:${WORKSHOP_RECIPE_VERSION}"
+
+ 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
+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..53a47fd80
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-aks.sh
@@ -0,0 +1,79 @@
+#!/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
+ 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 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"
+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..a4cfadb93
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-resource-types-k3s.sh
@@ -0,0 +1,80 @@
+#!/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
+ 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 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"
+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..b035232ef
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-aks.sh
@@ -0,0 +1,269 @@
+#!/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
+ 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}"
+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"
+
+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)"
+
+IDENTITY_CLIENT_ID="$APPLICATION_CLIENT_ID"
+IDENTITY_PRINCIPAL_ID="$SERVICE_PRINCIPAL_OBJECT_ID"
+
+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
+ 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 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 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 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" \
+ 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
+}
+
+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 "$IDENTITY_PRINCIPAL_ID" \
+ --fill-principal-name false \
+ --scope "$SCOPE" \
+ --role "$RADIUS_AZURE_ROLE" \
+ --query "[0].id" \
+ --output tsv)"
+if [[ -z "$ASSIGNMENT_ID" ]]; then
+ az role assignment create \
+ --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \
+ --assignee-principal-type ServicePrincipal \
+ --role "$RADIUS_AZURE_ROLE" \
+ --scope "$SCOPE" \
+ --output none
+fi
+
+INSTALL_ARGS=(
+ kubernetes
+ --kubecontext "$CURRENT_CONTEXT"
+ --set global.azureWorkloadIdentity.enabled=true
+)
+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 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 "$IDENTITY_CLIENT_ID" \
+ --tenant-id "$TENANT_ID"
+
+kubectl wait --for=condition=Available deployments --all \
+ --namespace radius-system \
+ --timeout=10m
+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
+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..7d7e85135
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/deploy-radius-k3s.sh
@@ -0,0 +1,56 @@
+#!/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
+ 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 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
+
+echo "Radius on K3s is healthy in workspace ${RADIUS_WORKSPACE}."
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/resources/prepare-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh
new file mode 100644
index 000000000..705a852ab
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh
@@ -0,0 +1,165 @@
+#!/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.
+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}"
+
+# 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
+
+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
+ 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 \
+ --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..3f22e2c6e
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh
@@ -0,0 +1,1010 @@
+#!/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
+
+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_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}"
+# 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}"
+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 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}"
+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")"
+ # 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
+}
+
+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 recorded
+ local -a pids=()
+
+ 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
+
+ mapfile -t pids < <(list_tunnel_pids)
+
+ if ((${#pids[@]} == 0)); then
+ rm -f "$TUNNEL_PID_FILE"
+ echo "No K3s Bastion tunnel is running."
+ return 0
+ fi
+
+ 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 PIDs ${pids[*]}."
+}
+
+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
+ 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
+
+ : >"$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
+}
+
+# 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
+ 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
+ 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"
+ local default_outbound="${3:-}"
+ local existing_prefix
+ local -a create_args=()
+
+ 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
+ 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
+ echo "Choose matching variables or use a new VNET_NAME; the script will not reshape an existing subnet." >&2
+ return 1
+ 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)"
+ 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
+ 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
+
+ # 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
+
+ if [[ -n "$current_nat" ]]; then
+ return 0
+ fi
+
+ # 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."
+
+az group create --name "$RESOURCE_GROUP" --location "$AZURE_LOCATION" --output none
+
+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
+
+K3S_OUTBOUND_REOPENED=0
+
+if [[ "${K3S_ENABLE_NAT_GATEWAY,,}" == "true" ]]; then
+ create_or_validate_subnet "$K3S_SUBNET_NAME" "$K3S_SUBNET_PREFIX" false
+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" \
+ --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
+ # 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
+ 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
+ create_k3s_vm
+fi
+
+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
+}
+VM_PRIVATE_IP="$(az network nic show \
+ --ids "$NIC_ID" \
+ --query "ipConfigurations[0].privateIPAddress" \
+ --output tsv)"
+readonly VM_PRIVATE_IP
+
+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" \
+ --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
+
+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" \
+ --created \
+ --timeout 1800
+
+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
+}
+
+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
+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
+ 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
+fi
+systemctl is-active --quiet k3s
+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
+ echo "Inspect Azure VM Run Command status and outbound access from the private VM." >&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
+ }
+
+ : >"$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
+
+ [[ "$(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"
+ register_k3s_context_in_default_kubeconfig
+}
+
+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
+ exit 1
+}
+
+ensure_tunnel
+validate_k3s
+
+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/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..2eb228e14
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh
@@ -0,0 +1,241 @@
+#!/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"
+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" "$APP_TEMPLATE"; 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
+
+# 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
+
+# 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
+
+# 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
+ 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 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
+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" || {
+ echo "Azure PostgreSQL recipe must precompute firewall rule IDs with resourceId()." >&2
+ 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
+
+# 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-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..dfa588c20
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-00/solution-00.md
@@ -0,0 +1,570 @@
+# 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 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
+subscription or resource group.
+
+## Required tools
+
+- Azure CLI
+- Azure CLI `bastion` extension
+- Bicep CLI
+- `kubectl`
+- Helm
+- Radius CLI (`rad`)
+- Git
+- `jq`
+- `yq`
+- `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.
+
+## Recommended devcontainer
+
+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
+
+Confirm each participant has:
+
+- A container runtime (see the options below)
+- Visual Studio Code
+- 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, 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
+the repository later adds and validates an explicit Codespaces selection workflow.
+
+### Start the container
+
+```bash
+git clone https://github.com/djong1/MicroHack.git
+cd MicroHack
+git switch djong1-adaptive-apps-microhack
+code .
+```
+
+In VS Code:
+
+1. Confirm the Explorer root is the MicroHack repository.
+2. Run **Dev Containers: Reopen in Container**.
+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
+
+The Azure CLI devcontainer feature installs Azure CLI 2.89.1. The post-create script
+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
+- 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
+
+Version pins make participant environments repeatable. Updating a pin requires static
+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.
+
+### 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
+
+### 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 recipes. 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. 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
+used by the Radius control plane on AKS. Rebuilding the container preserves those
+local workspace definitions.
+
+### Kubeconfig, tunnel, and SSH-key behavior
+
+Challenge 01 writes:
+
+- AKS credentials to the default `~/.kube/config`
+- K3s credentials and Bastion tunnel state under `~/.kube`
+- The generated VM SSH key under `~/.ssh` for optional Bastion-only troubleshooting
+
+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
+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 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.
+
+### Verify and troubleshoot
+
+Run:
+
+```bash
+az --version
+az bicep version
+kubectl version --client
+helm version --short
+rad version
+pwsh --version
+git --version
+jq --version
+yq --version
+az extension show --name bastion --query version --output tsv
+curl --version
+ssh -V
+tar --version
+```
+
+If creation fails:
+
+1. Inspect the Dev Containers creation log for the first failed download or command.
+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.
+8. If tools are installed but not found, open a new terminal so `.bashrc` and the
+ devcontainer `remoteEnv` update
+ `PATH`.
+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
+
+### 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 jq yq
+az bicep install
+```
+
+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 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:
+
+```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 jqlang.jq
+winget install MikeFarah.yq
+winget install Microsoft.VisualStudioCode
+winget install Microsoft.PowerShell
+az bicep install
+```
+
+> [!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
+
+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
+az --version
+az bicep version
+kubectl version --client
+helm version
+rad version
+git --version
+jq --version
+yq --version
+az extension show --name bastion --query version --output tsv
+curl --version
+ssh -V
+tar --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.
+- Devcontainer participants have confirmed that rebuilds preserve the expected named
+ volumes and understand that those volumes contain credentials.
+
+## 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. |
+| 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**. |
+| 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.
+
+## 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.
+- 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
+ 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, `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
new file mode 100644
index 000000000..e558807c4
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-01/solution-01.md
@@ -0,0 +1,241 @@
+# 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 | 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. |
+| 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
+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,
+enables OIDC issuer and workload identity, refreshes kubeconfig, and runs health checks.
+It does not create ACR, Key Vault, Storage, Radius, or portfolio resources.
+
+The devcontainer opens at the Adaptive Apps MicroHack root. Confirm the working
+directory before running the script:
+
+```bash
+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"
+
+bash resources/prepare-aks.sh
+```
+
+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`.
+
+## Task 2: Provision K3s on an Azure VM
+
+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=""
+export AZURE_LOCATION="westeurope"
+export RESOURCE_GROUP="rg-adaptive-apps"
+export K3S_VM_NAME="vm-adaptive-apps-k3s"
+
+bash resources/prepare-k3s-azure-vm.sh
+```
+
+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"
+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=true
+```
+
+### 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 |
+| --- | --- | --- |
+| 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=false
+bash resources/prepare-k3s-azure-vm.sh
+```
+
+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
+`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=""
+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 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:
+
+```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. Azure Bastion
+> accrues hourly cost while deployed; follow the
+> [K3s cleanup guidance](../../docs/prepare-k3s.md#cost-and-cleanup).
+
+## 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 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"
+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.
+- 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
new file mode 100644
index 000000000..4a5556daa
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md
@@ -0,0 +1,351 @@
+# 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.
+
+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
+
+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
+unset KUBECONFIG
+kubectl config use-context aks-adaptive-apps
+
+export AZURE_SUBSCRIPTION=""
+export RESOURCE_GROUP="rg-adaptive-apps"
+export AKS_CLUSTER="aks-adaptive-apps"
+
+# Run from the Adaptive Apps MicroHack root.
+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
+
+# 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
new file mode 100644
index 000000000..8a898e366
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md
@@ -0,0 +1,520 @@
+# 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.
+
+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
+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 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
+
+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 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
+
+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
+
+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
+rad resource-type show Radius.Resources/sqlDatabases
+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
+kubectl port-forward service/dashboard \
+ --namespace radius-system 7007:80
+```
+
+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
+- 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
+```
+
+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
+
+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
+unset KUBECONFIG
+kubectl config use-context aks-adaptive-apps
+
+# Run from the Adaptive Apps MicroHack root.
+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
+
+# 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
+`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..d58f7d0b3
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-04/solution-04.md
@@ -0,0 +1,614 @@
+# Walkthrough Challenge 04 - Implement the platform abstractions with recipes
+
+[< Previous Solution](../challenge-03/solution-03.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-05/solution-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.
+
+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
+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
+
+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.
+
+### 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:
+
+- 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}'
+ }
+ }
+ ]
+ // 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
+ }
+ }
+}
+
+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 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
+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" <&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
+ }
+ mapfile -t IP_FIELDS < <(az network public-ip show \
+ --ids "$OUTBOUND_ID" \
+ --query "[ipAddress,publicIPAllocationMethod,sku.name]" \
+ --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
+ }
+ AKS_EGRESS_IPS="${AKS_EGRESS_IPS:+${AKS_EGRESS_IPS},}${IP}"
+done
+
+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="$AZURE_SUBSCRIPTION" \
+ --parameters azureResourceGroup="$RESOURCE_GROUP" \
+ --parameters istioRevision="$ISTIO_REVISION" \
+ --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.6"
+```
+
+**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.6" `
+ --parameters "aksEgressIps=$($EgressIps -join ',')" `
+ --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.6"
+```
+
+| Resource type | AKS/Azure backend |
+| --- | --- |
+| `postgreSqlDatabases` | Azure Database for PostgreSQL Flexible Server |
+| `mqttBrokers` | In-cluster Eclipse Mosquitto |
+| `idProviders` | In-cluster Keycloak |
+| `workloadIdentities` | AKS workload identity |
+| `aiModels` | Azure OpenAI |
+| `governance` | In-cluster OPA |
+| `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:
+
+```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 env switch env-local-prod
+rad group switch rg-trading
+```
+
+Deploy the local environment definition:
+
+```bash
+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="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.6"
+```
+
+**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.6"
+```
+
+| Resource type | K3s/local backend |
+| --- | --- |
+| `postgreSqlDatabases` | PostgreSQL container |
+| `mqttBrokers` | Eclipse Mosquitto container |
+| `idProviders` | Keycloak container |
+| `workloadIdentities` | Local Kubernetes no-op mapping |
+| `aiModels` | Kaito recipe, requiring a GPU when deployed |
+| `governance` | In-cluster OPA |
+| `agentGuardrails` | Agent Governance Toolkit sidecar support |
+
+Registering the Kaito recipe does not deploy a model. A GPU-enabled platform is
+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
+unset KUBECONFIG
+kubectl config use-context aks-adaptive-apps
+rad workspace switch ws-azure-prod
+rad recipe list --environment env-azure-prod
+```
+
+K3s:
+
+```bash
+export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml"
+kubectl config use-context k3s-azure-vm
+rad workspace switch ws-local-prod
+rad recipe list --environment env-local-prod
+```
+
+For either active platform:
+
+```bash
+kubectl port-forward service/dashboard \
+ --namespace radius-system 7007:80
+```
+
+Open , 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
+export AZURE_SUBSCRIPTION=""
+export RESOURCE_GROUP="rg-adaptive-apps"
+export ACR_NAME=""
+
+# Configure both defaults:
+bash resources/configure-recipes.sh all
+
+# Or configure one:
+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
+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.
+
+
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..4acbfaae1
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md
@@ -0,0 +1,1211 @@
+# Walkthrough Challenge 05 - Port the App Across Environments
+
+[< Previous Solution](../challenge-04/solution-04.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-06/solution-06.md)
+
+Duration: 45-75 minutes
+
+## Coach notes
+
+### 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` | 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
+ 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
+------------------------------------ --------------------------------------
+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.
+
+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
+
+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 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.
+
+### 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 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
+
+`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.
+
+**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 < [!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
+
+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 | 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 |
+| Schema source | `iac/recipes/trading-schema.sql` | `iac/recipes/trading-schema.sql` |
+| Seed result | One `Demo Account` | One `Demo Account` |
+
+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.
+
+### 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
+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.
+
+### Why AKS uses Mosquitto for `mqttBrokers`
+
+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.
+
+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
+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
+
+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.
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).
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.
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.