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.