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/.devcontainer/03-azure-01-03-infrastructure-01-sovereign-cloud/devcontainer.json b/.devcontainer/03-azure-01-03-infrastructure-01-sovereign-cloud/devcontainer.json index 0f6d9ab37..4a8793ae1 100644 --- a/.devcontainer/03-azure-01-03-infrastructure-01-sovereign-cloud/devcontainer.json +++ b/.devcontainer/03-azure-01-03-infrastructure-01-sovereign-cloud/devcontainer.json @@ -1,15 +1,55 @@ { "name": "Azure / Infra / Sovereign Cloud", - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "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-03-Infrastructure/01_Sovereign_Cloud", + "remoteUser": "vscode", + "postAttachCommand": "sed 's/\\r$//' /workspaces/microhack/.devcontainer/welcome.sh | bash", + "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/rust:1": { + "version": "stable" + }, + "ghcr.io/devcontainers/features/powershell:2": { + "version": "stable" + } + }, + "overrideFeatureInstallOrder": [ + "ghcr.io/devcontainers/features/common-utils", + "ghcr.io/devcontainers/features/azure-cli", + "ghcr.io/devcontainers/features/rust", + "ghcr.io/devcontainers/features/powershell" + ], + "mounts": [ + "source=sovereign-cloud-microhack-azure-${devcontainerId},target=/home/vscode/.azure,type=volume", + "source=sovereign-cloud-microhack-kube-${devcontainerId},target=/home/vscode/.kube,type=volume", + "source=sovereign-cloud-microhack-radius-${devcontainerId},target=/home/vscode/.rad,type=volume", + "source=sovereign-cloud-microhack-ssh-${devcontainerId},target=/home/vscode/.ssh,type=volume" + ], + "postCreateCommand": "bash /workspaces/microhack/.devcontainer/03-azure-01-03-infrastructure-01-sovereign-cloud/post-create.sh", + "remoteEnv": { + "PATH": "${containerEnv:PATH}:/home/vscode/.local/bin" + }, "customizations": { "vscode": { + "extensions": [ + "ms-azuretools.vscode-bicep", + "ms-kubernetes-tools.vscode-kubernetes-tools", + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml", + "ms-vscode.powershell", + "timonwong.shellcheck" + ], "settings": { "terminal.integrated.defaultProfile.linux": "bash" } } - }, - "remoteUser": "vscode", - "postAttachCommand": "bash /workspaces/microhack/.devcontainer/welcome.sh" + } } diff --git a/.devcontainer/03-azure-01-03-infrastructure-01-sovereign-cloud/post-create.sh b/.devcontainer/03-azure-01-03-infrastructure-01-sovereign-cloud/post-create.sh new file mode 100644 index 000000000..0cb4a7d50 --- /dev/null +++ b/.devcontainer/03-azure-01-03-infrastructure-01-sovereign-cloud/post-create.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +set -euo pipefail +trap 'status=$?; echo "ERROR: Sovereign Cloud devcontainer setup failed at line ${LINENO}: ${BASH_COMMAND} (exit ${status})." >&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 \ + nodejs \ + npm \ + 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="# Sovereign Cloud 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' + +# Sovereign Cloud 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." +} + +configure_git_safe_directories() { + local safe_directory + local -a safe_directories=() + + mapfile -t safe_directories < <(git config --global --get-all safe.directory 2>/dev/null || true) + git config --global --unset-all safe.directory 2>/dev/null || true + + for safe_directory in "${safe_directories[@]}"; do + if [[ "$safe_directory" != "/workspaces/microhack" ]] && + [[ "$safe_directory" == /* || "$safe_directory" == '~/'* || "$safe_directory" == '*' ]]; then + git config --global --add safe.directory "$safe_directory" + fi + done + + git config --global --add safe.directory /workspaces/microhack +} + +configure_windows_worktree_shell +configure_git_safe_directories + +# Azure CLI (fallback when the devcontainer feature is unavailable) +if ! command -v az >/dev/null 2>&1; then + AZ_INSTALL_SCRIPT="$(mktemp)" + retry 3 curl -fsSL https://aka.ms/InstallAzureCLIDeb -o "$AZ_INSTALL_SCRIPT" + sudo bash "$AZ_INSTALL_SCRIPT" + rm -f "$AZ_INSTALL_SCRIPT" +fi + +# kubectl +retry 3 curl --fail --location --silent --show-error \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" \ + --output "$HOME/.local/bin/kubectl" +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' "$KUBECTL_CHECKSUM" "$HOME/.local/bin/kubectl" | sha256sum --check +chmod 0755 "$HOME/.local/bin/kubectl" + +# Helm +HELM_TEMPORARY_DIRECTORY="$(mktemp -d)" +retry 3 curl --fail --location --silent --show-error \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" \ + --output "$HELM_TEMPORARY_DIRECTORY/helm.tgz" +HELM_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' "$HELM_CHECKSUM" "$HELM_TEMPORARY_DIRECTORY/helm.tgz" | + sha256sum --check +tar -xzf "$HELM_TEMPORARY_DIRECTORY/helm.tgz" -C "$HELM_TEMPORARY_DIRECTORY" +install -m 0755 \ + "$HELM_TEMPORARY_DIRECTORY/linux-${ARCH}/helm" \ + "$HOME/.local/bin/helm" +rm -rf "$HELM_TEMPORARY_DIRECTORY" + +# Radius CLI (rad) +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 + +# yq for YAML processing used in scripting/tutorials +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" +YQ_CHECKSUMS="$(retry 3 curl --fail --location --silent --show-error \ + "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/checksums")" +YQ_CHECKSUM="$(awk -v asset="$YQ_ASSET" '$1 == asset { print $19 }' <<<"$YQ_CHECKSUMS")" +[[ -n "$YQ_CHECKSUM" ]] || { + echo "No SHA-256 checksum found for ${YQ_ASSET}." >&2 + exit 1 +} +printf '%s %s\n' "$YQ_CHECKSUM" "$HOME/.local/bin/yq" | sha256sum --check +chmod 0755 "$HOME/.local/bin/yq" + +# Standalone Bicep CLI for authoring and PowerShell workflows +retry 3 curl --fail --location --silent --show-error \ + "https://github.com/Azure/bicep/releases/download/${BICEP_VERSION}/bicep-${BICEP_PLATFORM}" \ + --output "$HOME/.local/bin/bicep" +chmod 0755 "$HOME/.local/bin/bicep" + +# Azure CLI manages its own Bicep instance for az deployment commands. +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" + +echo "Installed tool versions:" +MISSING_TOOLING=0 +for cmd in az kubectl helm rad bicep node npm npx rustc cargo git jq yq pwsh ssh tar; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd: not found" + MISSING_TOOLING=1 + continue + fi + + case "$cmd" in + az) + az version --query '"azure-cli"' -o tsv 2>/dev/null | sed 's/^/azure-cli: /' || true + ;; + kubectl) + kubectl version --client 2>/dev/null | head -n 2 || true + ;; + helm) + helm version --short 2>/dev/null || true + ;; + rad) + rad version 2>/dev/null | head -n 4 || true + ;; + bicep) + bicep --version 2>/dev/null || true + ;; + node|npm|npx|rustc|cargo|jq|yq|pwsh) + "$cmd" --version 2>/dev/null | head -n 1 || true + ;; + esac +done + +if ! az bicep version >/dev/null 2>&1; then + echo "Azure CLI Bicep: not found" >&2 + MISSING_TOOLING=1 +fi + +if ! az network bastion tunnel --help >/dev/null 2>&1; then + echo "Azure CLI Bastion tunnel support: not found" >&2 + MISSING_TOOLING=1 +else + az extension show --name bastion --query version -o tsv | sed 's/^/bastion extension: /' +fi + +if [[ "$MISSING_TOOLING" -ne 0 ]]; then + echo "Devcontainer setup is incomplete. Rebuild the container or rerun post-create.sh." >&2 + exit 1 +fi \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..243d554e0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +"/.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-06.md" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-07.md" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.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-07/solution-07.md" text eol=lf +"/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-09/solution-09.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 ded6fc6fb..ddc055283 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ # 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 + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/Readme.md new file mode 100644 index 000000000..30c911bf3 --- /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 01. + +| Audience | Path | What you do | +| --- | --- | --- | +| **Platform engineer** | Challenges **02 - 06** | Prepare the Kubernetes platforms, install and operate Radius, author the resource-type contracts and the recipes behind them, then verify that an application deploys unchanged to every environment. | +| **Application developer** | Challenges **05 - 11** (Challenge 05 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 05 and 06 are the deliberate handover point: the platform engineer +finishes by proving the contracts work, and the application developer starts +from the same place. + +> [!TIP] +> **Challenges 02 - 04 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 05 (optional) or Challenge 06, +> and platform engineers can still walk 02 - 04 manually to understand what the +> automation did. + +```mermaid +flowchart LR + C1["Challenge 01
Prerequisites"] + C24["Challenges 02 - 04
Platforms, Radius,
resource types"] + C5["Challenge 05
Recipes
(optional for developers)"] + C6["Challenge 06
Port the app"] + C711["Challenges 07 - 11
Identity, communication, AI,
Radius Canvas, brownfield"] + + C1 --> C24 --> C5 --> C6 --> C711 + C1 -. "Hack Console provisions 02 - 04" .-> C5 + + classDef platform fill:#dbeafe,stroke:#1d4ed8,color:#0f172a + classDef developer fill:#dcfce7,stroke:#15803d,color:#0f172a + class C24 platform + class C711 developer +``` + +## Objectives + +After completing this MicroHack, you will: + +**As a platform engineer (Challenges 02 - 06)** + +- 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 05 - 11)** + +- 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 02 - 06) or the application developer path (Challenges 05 - 11). + +### 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 05. 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 02 - 04 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 01-07 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 01](challenges/challenge-01.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 10 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 01: universal prerequisite + +Complete Challenge 01 before starting a challenge in any bucket, regardless of +which audience path you follow. + +- [Challenge 01 - Prerequisites: ready, set, go](challenges/challenge-01.md) + **<- Start here** + +### Bucket 1: Infrastructure setup + +**Audience: platform engineer.** Prepare the target platforms and deploy the +Radius control plane. + +> [!NOTE] +> Challenges 02 - 04 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 05 or 06. + +- [Challenge 02 - Prepare the platforms](challenges/challenge-02.md) +- [Challenge 03 - Deploy and explore Radius](challenges/challenge-03.md) + +### Bucket 2: Exploring Radius + +**Audience: platform engineer** (Challenge 05 is also the optional entry point +for application developers). Define portable platform capabilities and +implement them with recipes. + +- [Challenge 04 - Build the platform abstractions](challenges/challenge-04.md) +- [Challenge 05 - Build the platform abstractions with recipes](challenges/challenge-05.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 06 also closes out the platform +engineer path). Deploy the application across environments and adapt its +identity, communication, and AI capabilities. + +- [Challenge 06 - Port the App Across Environments](challenges/challenge-06.md) + **<- Application developer start here** +- [Challenge 07 - Adapt Identity Services - Configure User Authentication](challenges/challenge-07.md) +- [Challenge 08 - Secure service communication](challenges/challenge-08.md) +- [Challenge 09 - Adapt AI Services](challenges/challenge-09.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 10 - Model, review, and deploy with Radius Canvas](challenges/challenge-10.md) +- [Challenge 11 - Modernize a brownfield application](challenges/challenge-11.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-01.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-01.md new file mode 100644 index 000000000..6d7f71420 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-01.md @@ -0,0 +1,411 @@ +# Challenge 01 - Prerequisites: ready, set, go + +**[Home](../Readme.md)** - [Next Challenge >](challenge-02.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 01 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 02 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 01-05. 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 01-06. +The container is only a workstation: AKS, the Linux VM, K3s, Radius control planes, +portfolio workloads, and recipes are still created on the remote Azure and Kubernetes +targets by the 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 05 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 03 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 02 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-02.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-02.md new file mode 100644 index 000000000..d6ce121bd --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-02.md @@ -0,0 +1,156 @@ +# Challenge 02 - Prepare the platforms + +[< Previous Challenge](challenge-01.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-03.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-03.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-03.md new file mode 100644 index 000000000..ca5475afb --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-03.md @@ -0,0 +1,159 @@ +# Challenge 03 - Deploy and explore Radius + +[< Previous Challenge](challenge-02.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-04.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-04.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-04.md new file mode 100644 index 000000000..89a164456 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-04.md @@ -0,0 +1,159 @@ +# Challenge 04 - Build the platform abstractions + +[< Previous Challenge](challenge-03.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-05.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 05. + +## 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-05.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md new file mode 100644 index 000000000..60d30e33b --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md @@ -0,0 +1,159 @@ +# Challenge 05 - Implement the platform abstractions with recipes + +[< Previous Challenge](challenge-04.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-06.md) + +Estimated time: 60-90 minutes | Difficulty: advanced + +## Challenge objective + +Implement the portable resource-type contracts from Challenge 04. 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 04? +- 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-06.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md new file mode 100644 index 000000000..80bbb31b6 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md @@ -0,0 +1,172 @@ +# Challenge 06 - Port the App Across Environments + +[< Previous Challenge](challenge-05.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-07.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 02-05: + +| 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 04 and 05. 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 09 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 04 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 03 and the required recipe +mappings from Challenge 05. 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 04 catalog. +3. A `RecipeNotFoundFailure` is an environment-layer problem. Return to Challenge 05. + +### 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-07.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-07.md new file mode 100644 index 000000000..955ec8e1d --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-07.md @@ -0,0 +1,245 @@ +# Challenge 07 - Adapt Identity Services - Configure User Authentication + +[< Previous Challenge](challenge-06.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-08.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 06 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 03 | +| Application workload identity | Lets pods call platform services | Challenges 05-06 recipes | +| End-user authentication | Signs users into the frontend | This challenge | + +Challenge 07 changes only the third plane. Do not create service-principal secrets, +change the Challenge 03 federated credential, or claim that user login completes Azure +Event Grid MQTT authorization. + +## Constraints + +- Start from the application and environments completed in Challenge 06. +- 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-08.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md new file mode 100644 index 000000000..41070c472 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-08.md @@ -0,0 +1,9 @@ +# Challenge 08 - Secure service communication + +[< Previous Challenge](challenge-07.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-09.md) + +> [!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-09.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md new file mode 100644 index 000000000..a52d4ff0b --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-09.md @@ -0,0 +1,252 @@ +# Challenge 09 - Adapt AI Services + +[< Previous Challenge](challenge-08.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-10.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 01-07 completed and the Challenge 06/07 application present on both + targets. +- The `Radius.Resources/aiModels@2025-08-01-preview` contract from Challenge 04. +- 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 09 is an additive deployment and must + not erase the Challenge 07 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 08 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-10.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md new file mode 100644 index 000000000..1e7e5dbe5 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-10.md @@ -0,0 +1,451 @@ +# Challenge 10 - Model, review, and deploy with Radius Canvas + +[< Previous Challenge](challenge-09.md) - **[Home](../Readme.md)** - [Next Challenge >](challenge-11.md) + +Estimated time: 90-150 minutes | Difficulty: intermediate to advanced + +## Challenge objective + +Challenges 04-09 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 04-09. + +> [!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 04. +- 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 03-09. +- Judge a preview capability honestly, including the parts of the portability problem it + does not address. + +## Fixed target map + +Challenge 10 adds a third deployment path. It does not replace the first two. + +| Path | Radius control plane | Cluster | Namespace | Model | +| --- | --- | --- | --- | --- | +| Challenges 06-09, Azure | Persistent `ws-azure-prod` / `env-azure-prod` | `aks-adaptive-apps` | Challenge 06 application namespace | `iac/app.bicep` | +| Challenges 06-09, Local | Persistent `ws-local-prod` / `env-local-prod` | `k3s-azure-vm` | Challenge 06 application namespace | `iac/app.bicep` | +| Challenge 10, 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 05, and it does not see your +Challenge 06 application. + +## Additional prerequisites + +- Challenges 01-06 completed. Challenges 07-09 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 06 application namespace. +- Do not modify `iac/app.bicep`, the Challenge 04 resource types, or the Challenge 05 + 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 06 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 06. The Challenge 06 model is `iac/app.bicep`; if you completed Challenge 09, +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 06 resource type, and whether +they express the same intent. + +Then answer: + +- Which Canvas types are predefined `Radius.*` types, and which of the Challenge 04 + 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 04 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 06-09 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 05? Compare the contract inputs and + outputs, who initializes the schema, how TLS and network access are handled, and who owns + the credential. +- Challenge 05 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 06: + +| Question | Challenge 06 | Challenge 10 | +| --- | --- | --- | +| 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 06 application is still healthy in its own namespace and still serves +traffic, and that the Challenge 06 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 04, 05, and 06. +- 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 04 and 05. +- 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 06 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 04 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 06 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 06 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 06 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-11.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-11.md new file mode 100644 index 000000000..d83e28ba9 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-11.md @@ -0,0 +1,9 @@ +# Challenge 11 - Modernize a brownfield application + +[< Previous Challenge](challenge-10.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..4b0e3ac9a --- /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 02 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 03. 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..3a4ca2ed7 --- /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 03. 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..f620e6a08 --- /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 03. 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..585af9fa4 --- /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 03. + +## 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..bec7f97e6 --- /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 06 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 06.') +param backendClientId string = '' + +@description('Pre-provisioned managed identity client ID for the frontend. Leave empty for Challenge 06.') +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/99-MicroHack-Template/labautomation/main.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/images/.gitkeep similarity index 100% rename from 99-MicroHack-Template/labautomation/main.bicep rename to 03-Azure/01-01-App Innovation/04-adaptive-apps/images/.gitkeep 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-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..19c405c10 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-01/solution-01.md @@ -0,0 +1,570 @@ +# Walkthrough Challenge 01 - Prerequisites: ready, set, go + +**[Home](../../Readme.md)** - [Next Solution](../challenge-02/solution-02.md) + +## Coach notes + +Challenge 01 verifies prerequisites. Participants should arrive with most tools +installed or be prepared to install them quickly. Coaches should unblock environment +issues so teams can proceed to Challenge 02, where they provision AKS and K3s. + +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 01-05 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 01-05 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 01-06 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 05 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 03 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 02 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 02 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 02 creates AKS and an Azure VM, then installs K3s on that VM. +- Challenge 03 installs separate Radius control planes into AKS and K3s. +- Challenge 04 installs the portfolio and registers resource types remotely. +- Challenge 05 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 02 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 01 focused on verification. Challenge 02 contains the platform work. + +## Expected outcome + +After Challenge 01, 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 02](../challenge-02/solution-02.md) to provision AKS and K3s. 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..4ddee9caf --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md @@ -0,0 +1,241 @@ +# Walkthrough Challenge 02 - Prepare the platforms + +[< Previous Solution](../challenge-01/solution-01.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-03/solution-03.md) + +Duration: 30-60 minutes + +## Prerequisites + +Complete [Challenge 01](../challenge-01/solution-01.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-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..5b3e99a47 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md @@ -0,0 +1,351 @@ +# Walkthrough Challenge 03 - Deploy and explore Radius + +[< Previous Solution](../challenge-02/solution-02.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-04/solution-04.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-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..9b9ce6517 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-04/solution-04.md @@ -0,0 +1,520 @@ +# Walkthrough Challenge 04 - Build the platform abstractions + +[< Previous Solution](../challenge-03/solution-03.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-05/solution-05.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 05. + +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 02 created or retrieved the cluster credentials; Challenge 03 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 03 **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 03 **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 03 **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 02; 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 09. 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 01-04 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 05. 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 05 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 03 **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-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..6acbbef22 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md @@ -0,0 +1,614 @@ +# Walkthrough Challenge 05 - Implement the platform abstractions with recipes + +[< Previous Solution](../challenge-04/solution-04.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-06/solution-06.md) + +Duration: 60-90 minutes + +## Coach notes + +Challenge 04 defined contracts. Challenge 05 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 06 explains this in +> [Why AKS uses Mosquitto for `mqttBrokers`](../challenge-06/solution-06.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-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..e7c4d7b69 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md @@ -0,0 +1,1211 @@ +# Walkthrough Challenge 06 - Port the App Across Environments + +[< Previous Solution](../challenge-05/solution-05.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-07/solution-07.md) + +Duration: 45-75 minutes + +## Coach notes + +### How we arrived here + +1. **Challenge 02 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 03 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 04 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 05 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 05 also prepared Keycloak, AI, governance, and agent-guardrail recipe + mappings for later challenges; the Challenge 06 core model does not exercise all of + them. + + > [!IMPORTANT] + > The manually authored Azure SQL recipe in Challenge 05 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 06 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 07, service communication to Challenge 08, and AI to Challenge 09. +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 04 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 05 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 05. 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 03 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 03 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 07 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 05 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-07/solution-07.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-07/solution-07.md new file mode 100644 index 000000000..2d3903831 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-07/solution-07.md @@ -0,0 +1,1215 @@ +# Walkthrough Challenge 07 - Adapt Identity Services - Configure User Authentication + +[< Previous Solution](../challenge-06/solution-06.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-08/solution-08.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 07 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 04 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 07 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 06 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 04 +`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..75e6d4a45 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-08/solution-08.md @@ -0,0 +1,18 @@ +# Walkthrough Challenge 08 - Secure service communication + +[< Previous Solution](../challenge-07/solution-07.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-09/solution-09.md) + +> [!NOTE] +> This file is an authoring scaffold that matches the +> [Challenge 08](../../challenges/challenge-08.md) scaffold. Detailed coach notes and the +> worked solution for secure service communication will be added in a follow-up +> contribution. + +## Coach notes + +Challenge 08 is not yet published. Until it is, coaches should route teams from +[Challenge 07 - Adapt Identity Services](../../challenges/challenge-07.md) straight to +[Challenge 09 - Adapt AI Services](../../challenges/challenge-09.md); nothing in +Challenge 09 depends on this challenge. + +[Back to the Adaptive Apps MicroHack](../../Readme.md) 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..9e4d9248c --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-09/solution-09.md @@ -0,0 +1,1217 @@ +# Walkthrough Challenge 09 - Adapt AI Services + +[< Previous Solution](../challenge-08/solution-08.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-10/solution-10.md) + +Duration: 60-90 minutes + +## Coach notes + +The source Student Challenge 09 contains only generic challenge boilerplate, and its +Coach Solution 09 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 08 owns service-mesh authorization and OPA policy. Do not add its governance +resources here. Challenge 09 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 04 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 03 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 09 does not redeploy `iac/app.bicep`, so Challenge 07'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-10/solution-10.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-10/solution-10.md new file mode 100644 index 000000000..3b2f36fee --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-10/solution-10.md @@ -0,0 +1,647 @@ +# Walkthrough Challenge 10 - Model, review, and deploy with Radius Canvas + +[< Previous Solution](../challenge-09/solution-09.md) - **[Home](../../Readme.md)** - [Next Solution](../challenge-11/solution-11.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 04-09 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 06 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 04 and Challenge 05 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 06-09, Azure | Persistent `ws-azure-prod` / `env-azure-prod` | `aks-adaptive-apps` | Challenge 06 application namespace | `iac/app.bicep` | +| Challenges 06-09, Local | Persistent `ws-local-prod` / `env-local-prod` | `k3s-azure-vm` | Challenge 06 application namespace | `iac/app.bicep` | +| Challenge 10, 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 06 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 04 catalog exists precisely +because that allow-list does not cover everything a real platform needs. + +### Model mapping + +| Component | Canvas generated type | Challenge 06-09 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 06 consumes published `ghcr.io/microsoft/adaptive-apps` images | **No.** Canvas builds from source at a pinned commit; Challenge 06 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 09), 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 06's `iac/app.bicep` deliberately +> keeps AI disabled. A team that skipped Challenge 09 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 04 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 04 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 04 — 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 05 +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 05 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 07) | Configuration, not structure; it is parameters on a container, not a modeled resource | +| Service-mesh authorization (Challenge 08) | 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 06 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 06 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 05, + 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 06 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 06's validation if you swapped one recipe + for the other. +- The decision point is the environment. In Challenge 05 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 06 + +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 05 and 06 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 06, 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 06 | Challenge 10 | +| --- | --- | --- | +| 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 06 control plane must still list `adaptive-apps` and must not list the Canvas +application. Also confirm the Challenge 06 application still serves traffic rather than +merely having running pods, using the same exposure and `/api/accounts` check from +Challenge 06. + +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 07 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 06 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 04 plus Challenge 05 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 02-09 is out of scope | +| Requires a Dockerfile | Fine here; a blocker for the brownfield work in Challenge 11 | +| 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 04 and Challenge 05, 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 05 K3s recipe set. +- A path to a control plane that does not assume a GitHub-hosted runner can reach the + cluster. The Challenge 02 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 06 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 06 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 04 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 06 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 06 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. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-11/solution-11.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-11/solution-11.md new file mode 100644 index 000000000..ad6802f8c --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-11/solution-11.md @@ -0,0 +1,16 @@ +# Walkthrough Challenge 11 - Modernize a brownfield application + +[< Previous Solution](../challenge-10/solution-10.md) - **[Home](../../Readme.md)** + +> [!NOTE] +> This file is an authoring scaffold that matches the +> [Challenge 11](../../challenges/challenge-11.md) scaffold. Detailed coach notes and the +> worked solution for brownfield modernization will be added in a follow-up contribution. + +## Coach notes + +Challenge 11 is not yet published. It is the final challenge, so teams that finish +[Challenge 10 - Model, review, and deploy with Radius Canvas](../../challenges/challenge-10.md) +have completed the published MicroHack. + +[Back to the Adaptive Apps MicroHack](../../Readme.md) diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md index 4dcaa812c..3a7407af8 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md @@ -37,6 +37,7 @@ After completing this MicroHack you will: - Enforce sovereign cloud controls in Azure using native platform capabilities (Policy, RBAC, region restrictions). - Protect data through encryption at rest, in transit, and in use (CMK, TLS, ACC). - Operate a sovereign hybrid cloud environment by connecting local infrastructure using Azure Arc and Azure Local. +- Deploy one adaptive application model across Azure-managed and self-managed Kubernetes environments using federated Radius control planes. ## MicroHack challenges @@ -48,6 +49,7 @@ After completing this MicroHack you will: | 4 | Encryption in use with Azure Confidential Compute - VM | [Challenge](./challenges/challenge-04.md) | [Solution](./walkthrough/challenge-04/solution-04.md) | 90-120 min | Murali Rao Yelamanchili | | 5 | Encryption in use with Confidential VMs/Node Pools in Azure Kubernetes Service (AKS) | [Challenge](./challenges/challenge-05.md) | [Solution](./walkthrough/challenge-05/solution-05.md) | 90-120 min | Murali Rao Yelamanchili | | 6 | Operating Sovereign in a hybrid environment with Azure Local and Azure Arc | [Challenge](./challenges/challenge-06.md) | [Solution](./walkthrough/challenge-06/solution-06.md) | 60-90 min | Jan Egil Ring / Thomas Maurer | +| 7 | Adaptive Apps across sovereign Azure and private-cloud environments with Radius | [Challenge](./challenges/challenge-07.md) | [Solution](./walkthrough/challenge-07/solution-07.md) | 60 min | Dylan de Jong / Jan Egil Ring / Wesley Backelant | ### General prerequisites @@ -62,6 +64,29 @@ In order to use the MicroHack time most effectively, the following tasks should 2. Contributor or Owner permissions on your subscription or resource group 3. Optional: Access to Azure Arc Jumpstart ArcBox & LocalBox for hybrid challenges 4. [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli). **Hint:** Make sure to use the latest version available. +5. Challenge 7: `kubectl`, `jq`, OpenSSL, the [Radius CLI](https://docs.radapp.io/getting-started/install/), and the Azure CLI `bastion` extension + +The shared Challenge 4/5/7 platform requires **4 AMD SEV-SNP confidential-family vCPUs per participant**. Automation evaluates `Standard_DC2as_v5` and `Standard_DC2as_v6` across the configured regions, preferring the generally available v5 family. With the default two labs per subscription, prepare a minimum family quota of 8 vCPUs in at least one configured region. For example: + +The default candidate order is Sweden Central/v5, Spain Central/v5, Sweden Central/v6, then Spain Central/v6. Shared preparation persists the first candidate with supported VM sizes and sufficient confidential-family, DSv5-family, and regional quota so every participant deployment in that subscription uses the same selection. + +```powershell +./resources/subscription-preparations/2-vcpu-quotas.ps1 -Region swedencentral -NumberOfLabUsers 2 -ConfidentialVmGeneration v5 -SubmitQuotaRequests +``` + +Run the check for `spaincentral` as well if it should remain available for fallback. If v5 is restricted for the subscription, repeat the checks with `-ConfidentialVmGeneration v6`. + +Organizers can optionally use [Azure Quick Review (AZQR)](https://github.com/Azure/azqr) 4.0 or later to compare the curated candidate regions and export SKU, quota, and capacity-reservation inventory: + +```bash +azqr region-selection \ + --subscription-id \ + --target-regions swedencentral,spaincentral \ + --json \ + --output-name sovereign-region-assessment +``` + +Use the AZQR report for planning and preferred-region ordering, not as the deployment gate. Region Selection derives required SKUs from existing resources, so a greenfield subscription might not include the planned confidential SKU in its score. Capacity Reservation Group inventory also does not guarantee on-demand capacity. Shared preparation therefore checks the v5/v6 candidate matrix, family quota, standard-family quota, and regional quota directly, then persists the selected region and SKU for participant deployments. ### Cost estimates @@ -69,14 +94,15 @@ The main cost driver for this MicroHack is virtual machines: - **ArcBox for ITPro** cost is approximately 7 USD per day. We recommend setting it up the week before the event, so for example 5 days before the event would result in a cost between 30-40 USD. - **LocalBox** cost is approximately 100-110 USD per day. We recommend setting it up the week before the event, so for example 5 days before the event would result in a cost between 5-600 USD. -- Challenge 4 and 5 contains a Confidential Compute VM (Standard_DC2as_v5) which costs approximately 5 USD per day. These 2 VMs will run only for a few hours as they will be created by the students, so using 50 students as an example running the VMs for 8 hours would results in 2 VMs x 8 hours = 230 USD. +- **Challenges 4, 5, and 7** share one pre-provisioned participant platform: a two-node AKS system pool, one Confidential VM AKS node, one standalone Confidential VM, one K3s VM, Azure Bastion Standard, and a NAT Gateway. Budget approximately 45-55 USD per participant per day, depending on region and data transfer. The platform starts during lab deployment so participants can focus on validation rather than waiting for capacity-sensitive resources. +- Plan subscription quotas for at least 12 general-purpose vCPUs and 4 DCasv5- or DCasv6-family confidential vCPUs per participant. Keep the default maximum of two participants per subscription unless larger aggregate increases have been approved in advance. -This would result in a total cost of 789 USD. -In addition, there would be some smaller costs for other services like Key Vault, so a rough estimate is 1000 USD for one Sovereign Cloud MicroHack if following the above example. +For a 50-participant event, the shared participant platforms cost approximately 2,250-2,750 USD per day while deployed, in addition to the optional ArcBox and LocalBox environments. +There will also be smaller costs for services such as Key Vault, storage, and monitoring. An Azure Pricing Calculator estimate is available [here](https://azure.com/e/1a7aec76a3e049cba57cda6742025373). This estimate can be adjusted for fewer/more students, running the VMs shorter/longer and adding additional services if desired. -If you plan to run this MicroHack in your own subscription on a limited budget, you may skip deploying the prerequisites for Challenge 6, this would leave you with a cost of less than 50 USD for one day as long as resources are deleted when finished with the challenges. +If you plan to run this MicroHack in your own subscription on a limited budget, skip the optional Challenge 6 environments and remove the participant resource group immediately after finishing the event. ## Contributors diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-04.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-04.md index 40d9a270a..05fe8dc43 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-04.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-04.md @@ -4,16 +4,14 @@ ## Goal -Deploy and validate guest attestation on Azure Confidential VMs to ensure business logic only executes in trusted, compliant confidential computing environments. You'll build and deploy a sample application that implements secure attestation flows using Azure Confidential VMs. The application leverages Microsoft Azure Attestation (MAA) to validate VM integrity before executing protected business logic, demonstrating "encryption in use" capabilities. +Validate guest attestation on a pre-provisioned Azure Confidential VM to confirm that business logic only executes in a trusted, hardware-backed confidential computing environment. You will inspect the VM's security configuration, connect through Azure Bastion using username/password credentials, build and run a sample attestation application, and verify the cryptographic JWT proof of VM integrity using a dedicated Microsoft Azure Attestation (MAA) provider. ## Actions -* Create Key Vault and SSH Keys -* Create Attestation Provider -* Create Virtual Network and Confidential VM -* Create Azure Bastion -* Configure Confidential VM (Run on the CVM) -* Review the attestation token output +* Inspect the pre-provisioned Confidential VM security settings (security type, secure boot, vTPM, no public IP) +* Connect to the Confidential VM via Azure Bastion using username/password credentials from your lab dashboard +* Install dependencies, build, and run the CVM guest attestation sample application +* Inspect the attestation token JWT claims to verify the confidential computing environment ## Success criteria diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-05.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-05.md index 0348a1dc0..4759263c0 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-05.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-05.md @@ -4,14 +4,13 @@ ## Goal -Deploy and validate guest attestation on Azure Confidential VMs to ensure business logic only executes in trusted, compliant confidential computing environments. You'll build and deploy a sample application that implements secure attestation flows using Confidential VMs in AKS. The application leverages Microsoft Azure Attestation (MAA) to validate VM integrity before executing protected business logic, demonstrating "encryption in use" capabilities. +Validate guest attestation on a pre-provisioned Azure Confidential VM node pool in AKS to ensure business logic only executes in trusted, compliant confidential computing environments. You'll inspect the AKS cluster and Confidential VM node pool that were deployed for you, then deploy a sample application that implements secure attestation flows. The application leverages Microsoft Azure Attestation (MAA) to validate VM integrity before executing protected business logic, demonstrating "encryption in use" capabilities. ## Actions -* Create an AKS Cluster -* Add a Confidential VM Node Pool -* Verify Node Pool Configuration -* Run Attestation Verification Sample +* Inspect the pre-provisioned AKS cluster and Confidential VM node pool +* Validate node pool configuration (VM size, node image, node labels) +* Deploy the attestation verification sample * Review the attestation token output ## Success criteria diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-06.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-06.md index c0c88008f..f2b461819 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-06.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-06.md @@ -1,6 +1,6 @@ # Challenge 6 - Operating a Sovereign Hybrid Cloud with Azure Arc & Azure Local -[Previous Challenge](challenge-05.md) - **[Home](../Readme.md)** - [Finish](finish.md) +[Previous Challenge](challenge-05.md) - **[Home](../Readme.md)** - [Next Challenge](challenge-07.md) ## Goal diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-07.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-07.md new file mode 100644 index 000000000..59708cad4 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-07.md @@ -0,0 +1,67 @@ +# Challenge 7 - Adaptive Apps Across Sovereign Environments + +[Previous Challenge](challenge-06.md) - **[Home](../Readme.md)** - [Finish](finish.md) + +## Goal + +Deploy one unchanged application model to two sovereign execution environments: Azure Kubernetes Service (AKS) and a private, self-managed K3s cluster representing an on-premises or edge location. Use independent [Radius](https://radapp.io/) control planes so either site can continue operating when the other site or its network connection is unavailable. + +## Scenario + +A trading organization operates workloads in Azure and at intermittently connected sovereign sites. Rebuilding deployment definitions for every site creates drift and makes governance difficult. The platform team wants application developers to declare the application once, while each environment retains control of its runtime, configuration, and future capability implementations. + +The lab platform has already provisioned: + +| Environment | Platform | Access model | +| --- | --- | --- | +| Azure | AKS with a two-node system pool, a shared Challenge 5 confidential pool, OIDC issuer, workload identity, and managed Istio | Azure-managed Kubernetes API | +| Local/edge | Single-node K3s on a private Azure VM | Kubernetes API through an Azure Bastion localhost tunnel | + +> [!IMPORTANT] +> K3s runs on Azure for workshop convenience. It remains a self-managed Kubernetes distribution and represents a sovereign private-cloud or edge platform. The VM has no public IP, and no application port is opened to the internet. + +## Actions + +* Validate AKS and K3s, including the private Bastion access path +* Compare centralized and federated Radius control-plane models +* Install one independent Radius control plane on each Kubernetes platform +* Create a separate Radius workspace, group, and environment for each site +* Deploy the same unchanged container application model to both environments +* Inspect each independent application graph and runtime resources +* Demonstrate that K3s remains manageable without a dependency on the AKS Radius control plane + +## Success criteria + +* Both Kubernetes APIs return `ok`, and every node reports `Ready` +* The K3s VM has no public IP and is reached through a localhost Azure Bastion tunnel +* Radius deployments in `radius-system` are healthy on both clusters +* `ws-azure-prod` targets AKS and contains `env-azure-prod` +* `ws-local-prod` targets K3s and contains `env-local-prod` +* The same application Bicep file is deployed without modification to both environments +* Both control planes show an independent `sovereign-web` application graph +* The application is reachable through local port forwarding on each platform, one at a time +* You can explain how federated control planes support disconnected operations and how Radius recipes can later provide different implementations behind one capability contract + +## Learning resources + +* [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/) +* [Azure Kubernetes Service documentation](https://learn.microsoft.com/azure/aks/) +* [Microsoft Entra Workload ID on AKS](https://learn.microsoft.com/azure/aks/workload-identity-overview) +* [Azure Bastion native client connections](https://learn.microsoft.com/azure/bastion/connect-vm-native-client-windows) +* [Azure Arc-enabled Kubernetes overview](https://learn.microsoft.com/azure/azure-arc/kubernetes/overview) +* [Full Adaptive Apps MicroHack source](https://github.com/djong1/MicroHack/tree/djong1-adaptive-apps-microhack/03-Azure/01-01-App%20Innovation/04-adaptive-apps) + +## Solution + +> [!TIP] +> Attempt the challenge before opening the worked solution. Use the success criteria to decide when you are finished. + +
+Click here to view the solution + +[Solution for Challenge 7](../walkthrough/challenge-07/solution-07.md) + +
\ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-lab.ps1 index 9d2825a9c..5094c839e 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-lab.ps1 @@ -29,34 +29,145 @@ param( [string[]]$AllowedEntraUserIds = @() ) +. (Join-Path $PSScriptRoot 'quota-helpers.ps1') + # Validate parameters -if($DeploymentType -eq 'resourcegroup' -and [string]::IsNullOrEmpty($ResourceGroupName)) { - throw "ResourceGroupName must be provided when DeploymentType is 'resourcegroup'." +if($DeploymentType -in @('resourcegroup', 'resourcegroup-with-subscriptionowner') -and [string]::IsNullOrEmpty($ResourceGroupName)) { + throw "ResourceGroupName must be provided for a resource-group deployment." +} + +if($AllowedEntraUserIds.Count -eq 0) { + throw "AllowedEntraUserIds must contain at least one participant object ID." +} + +$PreferredLocation = @($PreferredLocation | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + +$stableHash = Get-MhhStableHash -Value $AllowedEntraUserIds -Length 24 +if($DeploymentType -eq 'subscription') { + $ResourceGroupName = "rg-sovereign-$($stableHash.Substring(0, 8))" } # set the effective location (used as the metadata location for the subscription-scoped deployment) if($PreferredLocation.Count -gt 0) { - $effectiveLocation = $PreferredLocation[0] + $deploymentLocations = $PreferredLocation } else { - $effectiveLocation = "swedencentral" # Default location if no preference is provided + $deploymentLocations = @('swedencentral') +} + +$selection = Get-MhhConfidentialComputeSelection -SubscriptionId $SubscriptionId +$validCandidates = @(Get-MhhConfidentialComputeCandidates -PreferredLocation $deploymentLocations) +$selectedCandidate = $validCandidates | + Where-Object { + $_.Location -ieq $selection.Location -and + $_.VmSize -ieq $selection.VmSize -and + $_.QuotaName -ieq $selection.QuotaName + } | + Select-Object -First 1 +if(-not $selectedCandidate) { + throw 'The shared subscription preparation did not persist a valid confidential compute selection for this deployment.' +} + +$confidentialVmSize = $selectedCandidate.VmSize +$confidentialQuotaName = $selectedCandidate.QuotaName +$deploymentLocations = @($selectedCandidate.Location) +$quotaRequirements = @( + @{ Name = $confidentialQuotaName; Required = 4 } + @{ Name = 'StandardDSv5Family'; Required = 12 } + @{ Name = 'cores'; Required = 16 } +) +$regionReadinessSummary = @() +foreach($candidateLocation in $deploymentLocations) { + $requiredVmSizes = @($confidentialVmSize, 'Standard_D4s_v5') + $availableVmSizes = Get-AzComputeResourceSku -Location $candidateLocation -ErrorAction Stop | + Where-Object { + $_.ResourceType -ieq 'virtualMachines' -and + $_.Name -iin $requiredVmSizes -and + -not ($_.Restrictions | Where-Object { $_.Type -ieq 'Location' }) + } | + Select-Object -ExpandProperty Name -Unique + $unavailableVmSizes = @($requiredVmSizes | Where-Object { $_ -inotin $availableVmSizes }) + + if($unavailableVmSizes.Count -gt 0) { + $regionReadinessSummary += "${candidateLocation}: unavailable VM sizes $($unavailableVmSizes -join ', ')" + continue + } + + $regionalUsage = Get-AzVMUsage -Location $candidateLocation -ErrorAction Stop + foreach($requirement in $quotaRequirements) { + $quota = $regionalUsage | + Where-Object { $_.Name.Value -ieq $requirement.Name } | + Select-Object -First 1 + $current = if($null -eq $quota) { 0 } else { $quota.CurrentValue } + $limit = if($null -eq $quota) { 0 } else { $quota.Limit } + $available = $limit - $current + $regionReadinessSummary += "${candidateLocation}/$($requirement.Name): ${current}/${limit} vCPUs used" + + if($available -lt $requirement.Required) { + throw "The shared compute selection is no longer ready ($($regionReadinessSummary -join '; ')). $($requirement.Required) available $($requirement.Name) vCPUs are required per participant." + } + } } +$effectiveLocation = $deploymentLocations[0] + # With deploymentType = resourcegroup the platform has already created one resource # group per participant in the shared subscription and granted the participant Owner -# on it. Surface that resource group name on the participant's dashboard. -if(-not [string]::IsNullOrEmpty($ResourceGroupName)) { - @{"HackboxCredential" = @{ name = "Resource Group Name"; value = $ResourceGroupName; note = "Your dedicated resource group (you have Owner)" }} +# on it. In subscription mode this script creates a stable resource group instead. +@{"HackboxCredential" = @{ name = "Resource Group Name"; value = $ResourceGroupName; note = "Your dedicated resource group (you have Owner)" }} + +# Provider registration is initiated once per subscription by shared-deploy-lab.ps1. +# Registration is asynchronous, so verify the providers required by the shared lab. +$sovereignLabProviders = @( + 'Microsoft.Attestation', + 'Microsoft.Compute', + 'Microsoft.ContainerService', + 'Microsoft.ManagedIdentity', + 'Microsoft.Network' +) +foreach($providerNamespace in $sovereignLabProviders) { + $registered = $false + for($attempt = 1; $attempt -le 30; $attempt++) { + Update-MhhToken | Out-Null + $provider = Get-AzResourceProvider -ProviderNamespace $providerNamespace -ErrorAction Stop + if($provider.RegistrationState -eq 'Registered') { + $registered = $true + break + } + Write-Host "Waiting for $providerNamespace registration ($attempt/30)..." + Start-Sleep -Seconds 10 + } + if(-not $registered) { + throw "Resource provider $providerNamespace did not reach Registered state within 5 minutes." + } } -# Register the resource providers required by the Sovereign Cloud lab on the shared -# subscription (the platform has already set the Azure context to $SubscriptionId). -Write-Host "Registering required resource providers..." -& (Join-Path $PSScriptRoot 'resource-providers.ps1') +# Deploy the shared Sovereign Cloud topology first. The helper recreates the resource group +# on retries, so subscription and resource-group role assignments are applied after it. +$nameSuffix = $stableHash.Substring(0, 8) +$k3sAdminPassword = New-MhhStablePassword -Purpose 'adaptive-apps-k3s-admin' -Length 24 +$cvmAdminPassword = New-MhhStablePassword -Purpose 'sovereign-cvm-admin' -Length 24 + +Write-Host "Deploying the shared Sovereign Cloud platform for Challenges 4, 5, and 7..." +$sovereignLabResult = Invoke-MhhDeploymentWithRegionFallback ` + -PreferredLocations $deploymentLocations ` + -ResourceGroupName $ResourceGroupName ` + -RgOwnerEntraObjectIds $AllowedEntraUserIds ` + -TemplateFile (Join-Path $PSScriptRoot 'sovereign-lab.bicep') ` + -TemplateParameterObject @{ + nameSuffix = $nameSuffix + adminPassword = $k3sAdminPassword + cvmAdminPassword = $cvmAdminPassword + confidentialVmSize = $confidentialVmSize + } ` + -DeploymentNamePrefix 'sovereign-lab' ` + -Tag @{ + workload = 'sovereign-lab' + challenges = '4,5,7' + } # Assign the lab-specific subscription-scoped RBAC (Security Reader + Resource Policy -# Contributor) to the participant. -# main.bicep targets the subscription scope, so it is deployed with New-AzSubscriptionDeployment. -$deploymentName = "lab-" + (Get-MhhStableHash -Value $AllowedEntraUserIds -Length 24) +# Contributor) and resource-group roles after region fallback has recreated the RG. +$deploymentName = "lab-$stableHash" Write-Host "Assigning subscription-scoped lab RBAC to participant $($AllowedEntraUserIds[0])..." New-AzSubscriptionDeployment ` @@ -66,4 +177,18 @@ New-AzSubscriptionDeployment ` -TemplateParameterObject @{ userObjectId = $AllowedEntraUserIds[0] resourceGroupName = $ResourceGroupName - } | Out-Null \ No newline at end of file + } | Out-Null + +@{"HackboxCredential" = @{ name = "Sovereign Lab Region"; value = $sovereignLabResult.LocationUsed; note = "Region selected after capacity checks" }} +@{"HackboxCredential" = @{ name = "Confidential VM Size"; value = $confidentialVmSize; note = "AMD SEV-SNP size selected during shared preparation" }} +@{"HackboxCredential" = @{ name = "Sovereign Lab AKS Cluster"; value = $sovereignLabResult.Outputs.aksClusterName; note = "Shared by Challenges 5 and 7" }} +@{"HackboxCredential" = @{ name = "AKS Confidential Node Pool"; value = $sovereignLabResult.Outputs.confidentialNodePoolName; note = "Challenge 5" }} +@{"HackboxCredential" = @{ name = "Confidential VM"; value = $sovereignLabResult.Outputs.confidentialVmName; note = "Challenge 4" }} +@{"HackboxCredential" = @{ name = "Confidential VM Admin Username"; value = "azureuser"; note = $sovereignLabResult.Outputs.confidentialVmName }} +@{"HackboxCredential" = @{ name = "Confidential VM Admin Password"; value = $cvmAdminPassword; note = $sovereignLabResult.Outputs.confidentialVmName }} +@{"HackboxCredential" = @{ name = "Attestation Provider"; value = $sovereignLabResult.Outputs.attestationProviderName; note = $sovereignLabResult.Outputs.attestationProviderUri }} +@{"HackboxCredential" = @{ name = "Sovereign Lab K3s VM"; value = $sovereignLabResult.Outputs.k3sVmName; note = "Private local/edge execution environment" }} +@{"HackboxCredential" = @{ name = "Sovereign Lab Bastion"; value = $sovereignLabResult.Outputs.bastionName; note = "Shared private access path" }} +@{"HackboxCredential" = @{ name = "K3s VM Admin Username"; value = "azureuser"; note = $sovereignLabResult.Outputs.k3sVmName }} +@{"HackboxCredential" = @{ name = "K3s VM Admin Password"; value = $k3sAdminPassword; note = $sovereignLabResult.Outputs.k3sVmName }} +@{"HackboxCredential" = @{ name = "K3s NAT Egress IP"; value = $sovereignLabResult.Outputs.natEgressIp; note = "Deterministic outbound address" }} \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 new file mode 100644 index 000000000..8b4b8c8a5 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 @@ -0,0 +1,334 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS +Deploys the Azure Arc Jumpstart LocalBox environment used by the Sovereign Cloud lab. +.DESCRIPTION +Downloads a coherent set of upstream LocalBox Bicep templates, validates them, and +deploys the LocalBox host, networking, storage, and optional Azure Bastion resources. +.PARAMETER SubscriptionId +The Azure subscription that receives the LocalBox resources. +.PARAMETER ResourceGroupName +The resource group to create or update. +.PARAMETER Location +The Azure region for the LocalBox Azure resources. +.PARAMETER WindowsAdminPassword +Optional host administrator password. For unattended deployment, prefer the +LOCALBOX_ADMIN_PASSWORD environment variable so the secret is not in the process list. +A random password is generated when neither value is supplied. +.PARAMETER GithubRef +The azure_arc branch, tag, or commit used for both Bicep and runtime artifacts. +.PARAMETER AzureLocalResourceProviderObjectId +Optional tenant-specific object ID of the Microsoft.AzureStackHCI enterprise +application. The script resolves it through Microsoft Graph when omitted. +.PARAMETER NoWait +Submit the deployment without waiting for ARM completion. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SubscriptionId, + + [string]$ResourceGroupName = 'rg-localbox-shared', + + [string]$Location = 'swedencentral', + + [string]$WindowsAdminUsername = 'arcdemo', + + [string]$WindowsAdminPassword, + + [bool]$DeployBastion = $true, + + [ValidateSet('Standard_E32s_v5', 'Standard_E32s_v6')] + [string]$VmSize = 'Standard_E32s_v6', + + [string]$GithubAccount = 'microsoft', + + [string]$GithubRef = 'main', + + [string]$AzureLocalResourceProviderObjectId, + + [ValidateSet('australiaeast', 'southcentralus', 'eastus', 'westeurope', 'southeastasia', 'canadacentral', 'japaneast', 'centralindia')] + [string]$AzureLocalInstanceLocation = 'australiaeast', + + [switch]$NoWait +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +function Invoke-AzJson { + param([Parameter(Mandatory = $true)][string[]]$Arguments) + + $output = & az @Arguments --only-show-errors --output json + if ($LASTEXITCODE -ne 0) { + throw "Azure CLI command failed: az $($Arguments -join ' ')" + } + if ([string]::IsNullOrWhiteSpace(($output -join "`n"))) { + return $null + } + return ($output -join "`n") | ConvertFrom-Json +} + +function New-LocalBoxPassword { + $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ' + $lower = 'abcdefghijkmnopqrstuvwxyz' + $digits = '23456789' + $special = '!@#%+=' + $all = $upper + $lower + $digits + $special + $characters = @( + $upper[(Get-Random -Maximum $upper.Length)] + $lower[(Get-Random -Maximum $lower.Length)] + $digits[(Get-Random -Maximum $digits.Length)] + $special[(Get-Random -Maximum $special.Length)] + ) + $characters += 1..20 | ForEach-Object { $all[(Get-Random -Maximum $all.Length)] } + return -join ($characters | Sort-Object { Get-Random }) +} + +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw 'Azure CLI is required to deploy LocalBox.' +} + +$account = Invoke-AzJson -Arguments @('account', 'show', '--subscription', $SubscriptionId) +Write-Host "Using subscription: $($account.name) ($SubscriptionId)" -ForegroundColor Green + +$requiredProviders = @( + 'Microsoft.Authorization', + 'Microsoft.AzureStackHCI', + 'Microsoft.Compute', + 'Microsoft.ExtendedLocation', + 'Microsoft.GuestConfiguration', + 'Microsoft.HybridCompute', + 'Microsoft.HybridContainerService', + 'Microsoft.KeyVault', + 'Microsoft.Kubernetes', + 'Microsoft.KubernetesConfiguration', + 'Microsoft.ManagedIdentity', + 'Microsoft.Network', + 'Microsoft.OperationalInsights', + 'Microsoft.OperationsManagement', + 'Microsoft.ResourceConnector', + 'Microsoft.Storage' +) +foreach ($providerNamespace in $requiredProviders) { + for ($attempt = 1; $attempt -le 60; $attempt++) { + $state = & az provider show --subscription $SubscriptionId --namespace $providerNamespace --query registrationState --output tsv --only-show-errors + if ($LASTEXITCODE -ne 0) { + throw "Unable to query resource provider $providerNamespace." + } + if ($state -eq 'Registered') { + break + } + Write-Host "Waiting for resource provider $providerNamespace ($attempt/60): $state" -ForegroundColor Gray + Start-Sleep -Seconds 10 + } + if ($state -ne 'Registered') { + throw "Resource provider $providerNamespace did not reach Registered state within 10 minutes (current state: $state)." + } +} + +$networkFeatureName = 'AllowBringYourOwnPublicIpAddress' +$networkFeatureState = & az feature show ` + --subscription $SubscriptionId ` + --namespace Microsoft.Network ` + --name $networkFeatureName ` + --query properties.state ` + --output tsv ` + --only-show-errors +if ($LASTEXITCODE -ne 0) { + throw "Unable to query provider feature Microsoft.Network/$networkFeatureName." +} + +if ($networkFeatureState -ne 'Registered') { + Write-Host "Registering provider feature Microsoft.Network/$networkFeatureName..." -ForegroundColor Yellow + Invoke-AzJson -Arguments @( + 'feature', 'register', '--subscription', $SubscriptionId, + '--namespace', 'Microsoft.Network', '--name', $networkFeatureName + ) | Out-Null + + for ($attempt = 1; $attempt -le 90; $attempt++) { + Start-Sleep -Seconds 10 + $networkFeatureState = & az feature show ` + --subscription $SubscriptionId ` + --namespace Microsoft.Network ` + --name $networkFeatureName ` + --query properties.state ` + --output tsv ` + --only-show-errors + if ($LASTEXITCODE -ne 0) { + throw "Unable to query provider feature Microsoft.Network/$networkFeatureName." + } + if ($networkFeatureState -eq 'Registered') { + break + } + Write-Host "Waiting for feature registration ($attempt/90): $networkFeatureState" -ForegroundColor Gray + } +} + +if ($networkFeatureState -ne 'Registered') { + throw "Provider feature Microsoft.Network/$networkFeatureName did not reach Registered state within 15 minutes (current state: $networkFeatureState)." +} + +Invoke-AzJson -Arguments @( + 'provider', 'register', '--subscription', $SubscriptionId, + '--namespace', 'Microsoft.Network', '--wait' +) | Out-Null + +$availableVmSize = & az vm list-sizes ` + --subscription $SubscriptionId ` + --location $Location ` + --query "[?name=='$VmSize'].name | [0]" ` + --output tsv ` + --only-show-errors +if ($LASTEXITCODE -ne 0) { + throw "Unable to query VM size availability in $Location." +} +if ($availableVmSize -ne $VmSize) { + throw "VM size $VmSize is unavailable in $Location." +} + +$familyName = if ($VmSize -eq 'Standard_E32s_v6') { 'Standard Esv6 Family vCPUs' } else { 'Standard Esv5 Family vCPUs' } +$usageEntries = Invoke-AzJson -Arguments @( + 'vm', 'list-usage', '--subscription', $SubscriptionId, '--location', $Location +) +$usage = @($usageEntries | Where-Object { $_.name.localizedValue -eq $familyName }) | Select-Object -First 1 +if (-not $usage -or ($usage.limit - $usage.currentValue) -lt 32) { + throw "At least 32 unused $familyName vCPUs are required in $Location." +} + +if ([string]::IsNullOrWhiteSpace($AzureLocalResourceProviderObjectId)) { + $servicePrincipals = Invoke-AzJson -Arguments @( + 'ad', 'sp', 'list', '--filter', "appId eq '1412d89f-b8a8-4111-b4fd-e82905cbd85d'" + ) + if ($servicePrincipals.Count -ne 1) { + throw "Expected one Microsoft.AzureStackHCI resource-provider service principal, found $($servicePrincipals.Count)." + } + $AzureLocalResourceProviderObjectId = $servicePrincipals[0].id +} + +$generatedPassword = $false +if ([string]::IsNullOrEmpty($WindowsAdminPassword) -and -not [string]::IsNullOrEmpty($env:LOCALBOX_ADMIN_PASSWORD)) { + $WindowsAdminPassword = $env:LOCALBOX_ADMIN_PASSWORD +} +elseif ([string]::IsNullOrEmpty($WindowsAdminPassword)) { + $WindowsAdminPassword = New-LocalBoxPassword + $generatedPassword = $true +} + +$resourceGroupExists = & az group exists ` + --subscription $SubscriptionId ` + --name $ResourceGroupName ` + --output tsv ` + --only-show-errors +if ($LASTEXITCODE -ne 0) { + throw "Unable to determine whether resource group $ResourceGroupName exists." +} + +if ($resourceGroupExists -eq 'true') { + $resourceGroup = Invoke-AzJson -Arguments @( + 'group', 'show', '--subscription', $SubscriptionId, '--name', $ResourceGroupName + ) + Invoke-AzJson -Arguments @( + 'group', 'update', '--subscription', $SubscriptionId, '--name', $ResourceGroupName, + '--set', 'tags.workload=sovereign-localbox', 'tags.challenge=6', 'tags.SecurityControl=Ignore' + ) | Out-Null +} +else { + $resourceGroup = Invoke-AzJson -Arguments @( + 'group', 'create', '--subscription', $SubscriptionId, '--name', $ResourceGroupName, + '--location', $Location, '--tags', 'workload=sovereign-localbox', 'challenge=6', 'SecurityControl=Ignore' + ) +} +Write-Host "Resource group ready: $($resourceGroup.name) ($($resourceGroup.location))" -ForegroundColor Green + +$tempDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "localbox-bicep-$([guid]::NewGuid())" +$parametersFile = Join-Path $tempDirectory 'localbox.parameters.json' +$templateFile = Join-Path $tempDirectory 'main.bicep' +$baseUri = "https://raw.githubusercontent.com/$GithubAccount/azure_arc/$GithubRef/azure_jumpstart_localbox/bicep" +$bicepFiles = @( + 'main.bicep', + 'mgmt/mgmtArtifacts.bicep', + 'mgmt/storageAccount.bicep', + 'mgmt/customerUsageAttribution.bicep', + 'network/network.bicep', + 'host/host.bicep' +) +$deploymentName = "localbox-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + +try { + foreach ($directory in @('', 'mgmt', 'network', 'host')) { + New-Item -ItemType Directory -Path (Join-Path $tempDirectory $directory) -Force | Out-Null + } + foreach ($file in $bicepFiles) { + Write-Host "Downloading LocalBox template: $file" + Invoke-WebRequest -Uri "$baseUri/$file" -OutFile (Join-Path $tempDirectory $file) -UseBasicParsing + } + + @{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' + contentVersion = '1.0.0.0' + parameters = @{ + windowsAdminUsername = @{ value = $WindowsAdminUsername } + windowsAdminPassword = @{ value = $WindowsAdminPassword } + spnProviderId = @{ value = $AzureLocalResourceProviderObjectId } + tenantId = @{ value = $account.tenantId } + location = @{ value = $Location } + githubAccount = @{ value = $GithubAccount } + githubBranch = @{ value = $GithubRef } + azureLocalInstanceLocation = @{ value = $AzureLocalInstanceLocation } + deployBastion = @{ value = $DeployBastion } + vmSize = @{ value = $VmSize } + tags = @{ value = @{ + Project = 'jumpstart_LocalBox' + SecurityControl = 'Ignore' + } } + governResourceTags = @{ value = $false } + } + } | ConvertTo-Json -Depth 10 | Set-Content -Path $parametersFile -Encoding utf8NoBOM + if (-not $IsWindows) { + chmod 600 $parametersFile + } + + Write-Host 'Compiling and validating LocalBox deployment...' -ForegroundColor Cyan + & az bicep build --file $templateFile --stdout --only-show-errors | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'LocalBox Bicep compilation failed.' + } + Invoke-AzJson -Arguments @( + 'deployment', 'group', 'validate', '--subscription', $SubscriptionId, + '--resource-group', $ResourceGroupName, '--name', $deploymentName, + '--template-file', $templateFile, '--parameters', "@$parametersFile" + ) | Out-Null + + Write-Host "Starting LocalBox deployment '$deploymentName'..." -ForegroundColor Cyan + $deploymentArguments = @( + 'deployment', 'group', 'create', '--subscription', $SubscriptionId, + '--resource-group', $ResourceGroupName, '--name', $deploymentName, + '--template-file', $templateFile, '--parameters', "@$parametersFile" + ) + if ($NoWait) { + $deploymentArguments += '--no-wait' + } + Invoke-AzJson -Arguments $deploymentArguments | Out-Null + + $state = if ($NoWait) { 'Submitted' } else { + & az deployment group show --subscription $SubscriptionId --resource-group $ResourceGroupName --name $deploymentName --query properties.provisioningState --output tsv --only-show-errors + } + Write-Host "LocalBox deployment state: $state" -ForegroundColor Green + Write-Output ([pscustomobject]@{ + SubscriptionId = $SubscriptionId + ResourceGroupName = $ResourceGroupName + DeploymentName = $deploymentName + Location = $Location + VmName = 'LocalBox-Client' + BastionName = if ($DeployBastion) { 'LocalBox-Bastion' } else { $null } + ProvisioningState = $state + GeneratedPassword = $generatedPassword + WindowsAdminUsername = $WindowsAdminUsername + }) +} +finally { + if (Test-Path $tempDirectory) { + Remove-Item -Path $tempDirectory -Recurse -Force + } +} \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/lab-defaults.json b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/lab-defaults.json index 68e612b42..bfb493dc3 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/lab-defaults.json +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/lab-defaults.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/microsoft/MicroHack/refs/heads/main/lab-defaults-schema.json", "groups": [], "deploymentType": "resourcegroup", - "labsPerSubscription": 60, - "preferredLocation": "swedencentral, italynorth, spaincentral", - "estimatedDailyCostsUsd": 5.0 + "labsPerSubscription": 2, + "preferredLocation": "swedencentral, spaincentral", + "estimatedDailyCostsUsd": 55.0 } diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/quota-helpers.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/quota-helpers.ps1 new file mode 100644 index 000000000..743f4fd52 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/quota-helpers.ps1 @@ -0,0 +1,203 @@ +function Get-MhhResponseHeaderValue { + param( + [System.Net.Http.Headers.HttpResponseHeaders]$Headers, + [string]$Name + ) + + $header = $Headers | Where-Object { $_.Key -ieq $Name } | Select-Object -First 1 + return @($header.Value)[0] +} + +function Get-MhhConfidentialComputeCandidates { + param( + [string[]]$PreferredLocation + ) + + $families = @( + [PSCustomObject]@{ + VmSize = 'Standard_DC2as_v5' + QuotaName = 'standardDCASv5Family' + } + [PSCustomObject]@{ + VmSize = 'Standard_DC2as_v6' + QuotaName = 'standardDCasv6Family' + } + ) + + foreach($family in $families) { + foreach($location in $PreferredLocation) { + [PSCustomObject]@{ + Location = $location + VmSize = $family.VmSize + QuotaName = $family.QuotaName + } + } + } +} + +function Set-MhhConfidentialComputeSelection { + param( + [string]$SubscriptionId, + [PSCustomObject]$Candidate + ) + + $subscriptionResourceId = "/subscriptions/$SubscriptionId" + Update-AzTag -ResourceId $subscriptionResourceId -Operation Merge -Tag @{ + 'microhack-sovereign-location' = $Candidate.Location + 'microhack-sovereign-confidential-vm-size' = $Candidate.VmSize + 'microhack-sovereign-confidential-quota' = $Candidate.QuotaName + } -ErrorAction Stop | Out-Null +} + +function Get-MhhConfidentialComputeSelection { + param( + [string]$SubscriptionId + ) + + $subscriptionResourceId = "/subscriptions/$SubscriptionId" + $tags = (Get-AzTag -ResourceId $subscriptionResourceId -ErrorAction Stop).Properties.TagsProperty + if(-not $tags) { + return $null + } + + return [PSCustomObject]@{ + Location = $tags.'microhack-sovereign-location' + VmSize = $tags.'microhack-sovereign-confidential-vm-size' + QuotaName = $tags.'microhack-sovereign-confidential-quota' + } +} + +function Wait-MhhQuotaRequest { + param( + [string]$OperationStatusUrl, + [int]$RetryAfterSeconds = 5, + [int]$MaxAttempts = 20 + ) + + for($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + Start-Sleep -Seconds $RetryAfterSeconds + try { + $response = Invoke-AzRestMethod -Uri $OperationStatusUrl -Method GET -ErrorAction Stop + } catch { + return [PSCustomObject]@{ + Succeeded = $false + State = 'StatusCheckFailed' + ErrorCode = 'QuotaStatusException' + Message = $_.Exception.Message + } + } + + if($response.StatusCode -eq 202) { + Write-Host "Quota request is still processing ($attempt/$MaxAttempts)..." + continue + } + + if($response.StatusCode -ne 200) { + return [PSCustomObject]@{ + Succeeded = $false + State = 'StatusCheckFailed' + ErrorCode = "Http$($response.StatusCode)" + Message = 'The quota request status endpoint returned an unexpected response.' + } + } + + $content = $response.Content | ConvertFrom-Json + $state = $content.properties.provisioningState + + if($state -in @('InProgress', 'Accepted', 'Running')) { + Write-Host "Quota request is still processing ($attempt/$MaxAttempts)..." + continue + } + + $error = if($content.properties.error) { $content.properties.error } else { $content.error } + $errorCode = if($error.code) { $error.code } else { $content.properties.errorCode } + $message = if($error.message) { + $error.message + } elseif($content.properties.message) { + $content.properties.message + } elseif($content.properties.errorMessage) { + $content.properties.errorMessage + } else { + "Quota request ended in state '$state' without error details." + } + + return [PSCustomObject]@{ + Succeeded = $state -eq 'Succeeded' + State = $state + ErrorCode = $errorCode + Message = $message + } + } + + return [PSCustomObject]@{ + Succeeded = $false + State = 'TimedOut' + ErrorCode = 'QuotaRequestTimeout' + Message = "Quota request did not complete after $MaxAttempts status checks." + } +} + +function Request-MhhComputeQuota { + param( + [string]$SubscriptionId, + [string]$Location, + [string]$QuotaName, + [int]$NewLimit + ) + + $scope = "subscriptions/$SubscriptionId/providers/Microsoft.Compute/locations/$Location" + $path = "/$scope/providers/Microsoft.Quota/quotas/${QuotaName}?api-version=2023-02-01" + $payload = @{ + properties = @{ + name = @{ value = $QuotaName } + limit = @{ + limitObjectType = 'LimitValue' + value = $NewLimit + } + } + } | ConvertTo-Json -Depth 10 + + try { + $response = Invoke-AzRestMethod -Path $path -Method PUT -Payload $payload -ErrorAction Stop + } catch { + return [PSCustomObject]@{ + Succeeded = $false + State = 'RequestFailed' + ErrorCode = 'QuotaRequestException' + Message = $_.Exception.Message + } + } + if($response.StatusCode -eq 200) { + return [PSCustomObject]@{ + Succeeded = $true + State = 'Succeeded' + ErrorCode = $null + Message = 'Quota limit updated.' + } + } + + if($response.StatusCode -ne 202) { + return [PSCustomObject]@{ + Succeeded = $false + State = 'RequestFailed' + ErrorCode = "Http$($response.StatusCode)" + Message = $response.Content + } + } + + $locationHeader = Get-MhhResponseHeaderValue -Headers $response.Headers -Name 'Location' + if(-not $locationHeader) { + return [PSCustomObject]@{ + Succeeded = $false + State = 'MissingStatusUrl' + ErrorCode = 'MissingLocationHeader' + Message = 'Azure accepted the quota request but did not return a status URL.' + } + } + + $retryAfter = Get-MhhResponseHeaderValue -Headers $response.Headers -Name 'Retry-After' + $retryAfterSeconds = if($retryAfter -as [int]) { [int]$retryAfter } else { 5 } + return Wait-MhhQuotaRequest ` + -OperationStatusUrl $locationHeader ` + -RetryAfterSeconds $retryAfterSeconds +} \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/resource-providers.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/resource-providers.ps1 index 4453a8099..90278654d 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/resource-providers.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/resource-providers.ps1 @@ -3,8 +3,8 @@ Register required Azure resource providers for the Sovereign Cloud MicroHack. .DESCRIPTION - This script registers all required Azure resource providers needed for the - Sovereign Cloud MicroHack across all available subscriptions. + This script idempotently registers all Azure resource providers needed for + the Sovereign Cloud MicroHack in the current subscription. Required providers include: - Azure Arc and hybrid connectivity @@ -14,7 +14,7 @@ - Monitoring and policy .EXAMPLE - .\1-resource-providers.ps1 + .\resource-providers.ps1 Registers all required resource providers .NOTES @@ -26,48 +26,18 @@ #> [CmdletBinding()] -param( -) - -# Ensure required modules are available -$requiredModules = @('Az.Accounts', 'Az.Resources') -foreach ($module in $requiredModules) { - if (-not (Get-Module -ListAvailable -Name $module)) { - Write-Error "$module module is not installed. Please run: Install-Module -Name $module" - exit 1 - } -} - -# Import required modules -Import-Module Az.Accounts, Az.Resources -ErrorAction Stop +param() Write-Host "`n=== Resource Provider Registration Utility ===" -ForegroundColor Cyan Write-Host "This script will register all required resource providers for the Sovereign Cloud MicroHack." Write-Host "" -# Check if user is logged in -try { - $context = Get-AzContext - if (-not $context) { - Write-Host "No Azure context found. Please login..." -ForegroundColor Yellow - $context = Get-AzContext - } else { - Write-Host "Using Azure account: $($context.Account.Id)" -ForegroundColor Green - } -} catch { - Write-Error "Failed to get Azure context. Please run Connect-AzAccount first." - exit 1 -} - -# Get all subscriptions -$subscriptions = Get-AzSubscription - -if ($subscriptions.Count -eq 0) { - Write-Error "No subscriptions found. Please ensure you have access to at least one subscription." - exit 1 +$context = Get-AzContext -ErrorAction Stop +if (-not $context.Subscription.Id) { + throw "The current Azure context does not contain a subscription." } - -Write-Host "Found $($subscriptions.Count) subscription(s)" -ForegroundColor Green +$subscriptionId = $context.Subscription.Id +Write-Host "Registering providers in subscription: $subscriptionId" -ForegroundColor Green # List of resource providers to register for Sovereign Cloud MicroHack $providers = @( @@ -85,6 +55,7 @@ $providers = @( # Compute and Confidential Computing "Microsoft.Compute", "Microsoft.ConfidentialLedger", + "Microsoft.Quota", # Security and Compliance "Microsoft.Security", @@ -124,28 +95,115 @@ foreach ($provider in $providers) { Write-Host " - $provider" -ForegroundColor Gray } -# Register resource providers for current subscription - - foreach ($provider in $providers) { - $existingProvider = Get-AzResourceProvider -ProviderNamespace $provider -ErrorAction SilentlyContinue - - if ($existingProvider.RegistrationState -eq 'Registered') { - Write-Host " [REGISTERED] $provider" -ForegroundColor Green - } else { - Write-Host " [REGISTERING] $provider..." -ForegroundColor Yellow - try { - Register-AzResourceProvider -ProviderNamespace $provider | Out-Null - Write-Host " Registration initiated" -ForegroundColor Gray - } catch { - Write-Host " Failed to register: $($_.Exception.Message)" -ForegroundColor Red - } +# Register resource providers for the current subscription +foreach ($provider in $providers) { + $existingProvider = Get-AzResourceProvider -ProviderNamespace $provider -ErrorAction Stop + + if ($existingProvider.RegistrationState -eq 'Registered') { + Write-Host " [REGISTERED] $provider" -ForegroundColor Green + continue + } + + Write-Host " [REGISTERING] $provider..." -ForegroundColor Yellow + Register-AzResourceProvider -ProviderNamespace $provider -ErrorAction Stop | Out-Null + Write-Host " Registration initiated" -ForegroundColor Gray +} + +$networkFeatureName = "AllowBringYourOwnPublicIpAddress" +$networkFeature = Get-AzProviderFeature ` + -ProviderNamespace "Microsoft.Network" ` + -FeatureName $networkFeatureName ` + -ErrorAction Stop + +if ($networkFeature.RegistrationState -ne 'Registered') { + Write-Host " [REGISTERING] Microsoft.Network/$networkFeatureName..." -ForegroundColor Yellow + Register-AzProviderFeature ` + -ProviderNamespace "Microsoft.Network" ` + -FeatureName $networkFeatureName ` + -ErrorAction Stop | Out-Null + + for ($attempt = 1; $attempt -le 90; $attempt++) { + Start-Sleep -Seconds 10 + $networkFeature = Get-AzProviderFeature ` + -ProviderNamespace "Microsoft.Network" ` + -FeatureName $networkFeatureName ` + -ErrorAction Stop + if ($networkFeature.RegistrationState -eq 'Registered') { + break + } + Write-Host " Waiting for feature registration ($attempt/90): $($networkFeature.RegistrationState)" -ForegroundColor Gray + } +} + +if ($networkFeature.RegistrationState -ne 'Registered') { + throw "Provider feature Microsoft.Network/$networkFeatureName did not reach Registered state within 15 minutes (current state: $($networkFeature.RegistrationState))." +} + +Write-Host " [REGISTERED] Microsoft.Network/$networkFeatureName" -ForegroundColor Green +Register-AzResourceProvider -ProviderNamespace "Microsoft.Network" -ErrorAction Stop | Out-Null + +for ($attempt = 1; $attempt -le 30; $attempt++) { + $networkProvider = Get-AzResourceProvider -ProviderNamespace "Microsoft.Network" -ErrorAction Stop + if ($networkProvider.RegistrationState -eq 'Registered') { + break + } + Write-Host " Waiting for Microsoft.Network propagation ($attempt/30)..." -ForegroundColor Gray + Start-Sleep -Seconds 10 +} + +if ($networkProvider.RegistrationState -ne 'Registered') { + throw "Resource provider Microsoft.Network did not return to Registered state within 5 minutes." +} + +$aksFeatureName = "AzureLinuxCVMPreview" +$aksFeature = Get-AzProviderFeature ` + -ProviderNamespace "Microsoft.ContainerService" ` + -FeatureName $aksFeatureName ` + -ErrorAction Stop + +if ($aksFeature.RegistrationState -ne 'Registered') { + Write-Host " [REGISTERING] Microsoft.ContainerService/$aksFeatureName..." -ForegroundColor Yellow + Register-AzProviderFeature ` + -ProviderNamespace "Microsoft.ContainerService" ` + -FeatureName $aksFeatureName ` + -ErrorAction Stop | Out-Null + + for ($attempt = 1; $attempt -le 90; $attempt++) { + Start-Sleep -Seconds 10 + $aksFeature = Get-AzProviderFeature ` + -ProviderNamespace "Microsoft.ContainerService" ` + -FeatureName $aksFeatureName ` + -ErrorAction Stop + if ($aksFeature.RegistrationState -eq 'Registered') { + break } + Write-Host " Waiting for feature registration ($attempt/90): $($aksFeature.RegistrationState)" -ForegroundColor Gray } +} + +if ($aksFeature.RegistrationState -ne 'Registered') { + throw "Provider feature Microsoft.ContainerService/$aksFeatureName did not reach Registered state within 15 minutes (current state: $($aksFeature.RegistrationState))." +} + +Write-Host " [REGISTERED] Microsoft.ContainerService/$aksFeatureName" -ForegroundColor Green +Register-AzResourceProvider -ProviderNamespace "Microsoft.ContainerService" -ErrorAction Stop | Out-Null +for ($attempt = 1; $attempt -le 30; $attempt++) { + $containerServiceProvider = Get-AzResourceProvider -ProviderNamespace "Microsoft.ContainerService" -ErrorAction Stop + if ($containerServiceProvider.RegistrationState -eq 'Registered') { + break + } + Write-Host " Waiting for Microsoft.ContainerService propagation ($attempt/30)..." -ForegroundColor Gray + Start-Sleep -Seconds 10 +} + +if ($containerServiceProvider.RegistrationState -ne 'Registered') { + throw "Resource provider Microsoft.ContainerService did not return to Registered state within 5 minutes." +} Write-Host "`n" + ("=" * 80) -ForegroundColor Cyan Write-Host "SUMMARY" -ForegroundColor Cyan Write-Host ("=" * 80) -ForegroundColor Cyan -Write-Host "Resource provider registration initiated for subscription: $($SubscriptionId)" -ForegroundColor Green +Write-Host "Resource provider registration initiated for subscription: $subscriptionId" -ForegroundColor Green Write-Host "`nNote: Some providers may take a few minutes to fully register." -ForegroundColor Yellow Write-Host "You can check registration status with: Get-AzResourceProvider -ProviderNamespace " -ForegroundColor Gray diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/shared-deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/shared-deploy-lab.ps1 new file mode 100644 index 000000000..b88d17d1c --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/shared-deploy-lab.ps1 @@ -0,0 +1,256 @@ +<# +.SYNOPSIS +Prepares a subscription for Sovereign Cloud MicroHack labs. +.DESCRIPTION +Runs once per subscription before participant lab deployments and registers the +required Azure resource providers. It ensures one preferred region has the VM +SKUs and compute quota required by the subscription's participant count. +It also submits one shared LocalBox environment per subscription for Challenge 6. +#> +param( + [Parameter(Mandatory=$true)] + [string]$SubscriptionId, + + [Parameter(Mandatory=$true)] + [string[]]$PreferredLocation, + + [Parameter(Mandatory=$false)] + [string[]]$AllowedEntraUserIds = @() +) + +. (Join-Path $PSScriptRoot 'quota-helpers.ps1') + +$azureLocalResourceProviderAppId = '1412d89f-b8a8-4111-b4fd-e82905cbd85d' +Update-MhhToken | Out-Null +$azureLocalResourceProviderObjectIds = @( + & az ad sp list ` + --filter "appId eq '$azureLocalResourceProviderAppId'" ` + --query '[].id' ` + --output tsv ` + --only-show-errors +) +if($LASTEXITCODE -ne 0) { + throw 'Unable to query Microsoft Graph for the Microsoft.AzureStackHCI service principal.' +} +$azureLocalResourceProviderObjectIds = @($azureLocalResourceProviderObjectIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +if($azureLocalResourceProviderObjectIds.Count -ne 1) { + throw "Expected exactly one Microsoft.AzureStackHCI service principal, found $($azureLocalResourceProviderObjectIds.Count)." +} +Write-Host "Resolved Microsoft.AzureStackHCI service-principal object ID: $($azureLocalResourceProviderObjectIds[0])" -ForegroundColor Green + +Write-Host "Preparing subscription $SubscriptionId for Sovereign Cloud labs..." +& (Join-Path $PSScriptRoot 'resource-providers.ps1') + +if (-not $?) { + throw "Resource provider registration failed for subscription $SubscriptionId." +} + +$defaults = Get-Content (Join-Path $PSScriptRoot 'lab-defaults.json') -Raw | ConvertFrom-Json +if($PreferredLocation.Count -eq 0) { + $PreferredLocation = @($defaults.preferredLocation) +} +$PreferredLocation = @($PreferredLocation | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + +$participantCount = @($AllowedEntraUserIds | Where-Object { $_ } | Select-Object -Unique).Count +if($participantCount -eq 0) { + $participantCount = $defaults.labsPerSubscription +} +if($participantCount -lt 1) { + throw 'Unable to determine the number of participants for confidential compute quota preparation.' +} + +$confidentialCandidates = @(Get-MhhConfidentialComputeCandidates -PreferredLocation $PreferredLocation) +$selectedCandidate = $null +$quotaReadinessSummary = @() + +for($attempt = 1; $attempt -le 30; $attempt++) { + Update-MhhToken | Out-Null + $quotaProvider = Get-AzResourceProvider -ProviderNamespace Microsoft.Quota -ErrorAction Stop + if($quotaProvider.RegistrationState -eq 'Registered') { + break + } + Write-Host "Waiting for Microsoft.Quota registration ($attempt/30)..." + Start-Sleep -Seconds 10 +} +if($quotaProvider.RegistrationState -ne 'Registered') { + throw 'Microsoft.Quota did not reach Registered state within 5 minutes.' +} + +foreach($candidate in $confidentialCandidates) { + Update-MhhToken | Out-Null + $candidateLocation = $candidate.Location + $requiredVmSizes = @($candidate.VmSize, 'Standard_D4s_v5') + $availableVmSizes = Get-AzComputeResourceSku -Location $candidateLocation -ErrorAction Stop | + Where-Object { + $_.ResourceType -ieq 'virtualMachines' -and + $_.Name -iin $requiredVmSizes -and + -not ($_.Restrictions | Where-Object { $_.Type -ieq 'Location' }) + } | + Select-Object -ExpandProperty Name -Unique + $unavailableVmSizes = @($requiredVmSizes | Where-Object { $_ -inotin $availableVmSizes }) + + if($unavailableVmSizes.Count -gt 0) { + $quotaReadinessSummary += "$candidateLocation/$($candidate.VmSize): unavailable VM sizes $($unavailableVmSizes -join ', ')" + continue + } + + $regionalUsage = Get-AzVMUsage -Location $candidateLocation -ErrorAction Stop + $locationReady = $true + $quotaRequirements = @( + @{ Name = $candidate.QuotaName; PerParticipant = 4 } + @{ Name = 'StandardDSv5Family'; PerParticipant = 12 } + @{ Name = 'cores'; PerParticipant = 16 } + ) + foreach($requirement in $quotaRequirements) { + $quotaName = $requirement.Name + $quotaRequired = $requirement.PerParticipant * $participantCount + $quota = $regionalUsage | + Where-Object { $_.Name.Value -ieq $quotaName } | + Select-Object -First 1 + $current = if($null -eq $quota) { 0 } else { $quota.CurrentValue } + $limit = if($null -eq $quota) { 0 } else { $quota.Limit } + $available = $limit - $current + + if($available -ge $quotaRequired) { + continue + } + + $newLimit = $current + $quotaRequired + Write-Host "Requesting exactly $newLimit $quotaName vCPUs in $candidateLocation for $participantCount participants..." + $quotaResult = Request-MhhComputeQuota ` + -SubscriptionId $SubscriptionId ` + -Location $candidateLocation ` + -QuotaName $quotaName ` + -NewLimit $newLimit + + if($quotaResult.Succeeded) { + continue + } + + $quotaFailureDetails = @( + $quotaResult.State + $quotaResult.ErrorCode + $quotaResult.Message + ) | Where-Object { $_ } + $quotaFailureDetails = $quotaFailureDetails -join ' - ' + $quotaReadinessSummary += "$candidateLocation/$($candidate.VmSize)/${quotaName}: $quotaFailureDetails" + Write-Warning "Quota request failed for $quotaName in ${candidateLocation}: $quotaFailureDetails" + $locationReady = $false + break + } + + if($locationReady) { + $selectedCandidate = $candidate + break + } +} + +if(-not $selectedCandidate) { + throw "No preferred region satisfies the aggregate compute requirements for $participantCount participants ($($quotaReadinessSummary -join '; '))." +} + +Set-MhhConfidentialComputeSelection -SubscriptionId $SubscriptionId -Candidate $selectedCandidate +Write-Host "Selected $($selectedCandidate.VmSize) in $($selectedCandidate.Location); compute SKUs and quotas are ready for $participantCount participants." -ForegroundColor Green + +$localBoxResourceGroupName = 'rg-localbox-shared' +$localBoxLocations = @( + @($selectedCandidate.Location) + $PreferredLocation | + Where-Object { $_ } | + Select-Object -Unique +) +$activeDeploymentStates = @('Accepted', 'Running', 'Ready', 'Creating', 'Created', 'Succeeded') +$resourceGroup = Get-AzResourceGroup -Name $localBoxResourceGroupName -ErrorAction SilentlyContinue +if ($resourceGroup) { + Update-AzTag ` + -ResourceId $resourceGroup.ResourceId ` + -Operation Merge ` + -Tag @{ + workload = 'sovereign-localbox' + challenge = '6' + SecurityControl = 'Ignore' + } ` + -ErrorAction Stop | Out-Null + Write-Host "Applied the temporary MCAPS security-control exemption to $localBoxResourceGroupName." -ForegroundColor Green +} +$localBoxDeployment = if ($resourceGroup) { + Get-AzResourceGroupDeployment ` + -ResourceGroupName $localBoxResourceGroupName ` + -ErrorAction Stop | + Where-Object { + $_.DeploymentName -like 'localbox-*' -and + $_.ProvisioningState -in $activeDeploymentStates + } | + Sort-Object Timestamp -Descending | + Select-Object -First 1 +} + +if ($localBoxDeployment) { + Write-Host "Reusing LocalBox deployment '$($localBoxDeployment.DeploymentName)' ($($localBoxDeployment.ProvisioningState))." -ForegroundColor Green +} +else { + foreach ($localBoxLocation in $localBoxLocations) { + try { + Write-Host "Submitting shared LocalBox deployment in $localBoxLocation..." -ForegroundColor Cyan + $localBoxDeployment = & (Join-Path $PSScriptRoot 'deploy-localbox.ps1') ` + -SubscriptionId $SubscriptionId ` + -ResourceGroupName $localBoxResourceGroupName ` + -Location $localBoxLocation ` + -AzureLocalResourceProviderObjectId $azureLocalResourceProviderObjectIds[0] ` + -AzureLocalInstanceLocation 'australiaeast' ` + -NoWait + + if (-not $localBoxDeployment -or $localBoxDeployment.ProvisioningState -ne 'Submitted') { + throw "LocalBox deployment submission did not return the expected Submitted state." + } + break + } + catch { + $failureDetails = @( + $_.Exception.ToString() + $_.ErrorDetails.Message + ) | Where-Object { $_ } + $failureDetails = $failureDetails -join ' ' + $retryableRegionalFailure = $failureDetails -match 'RequestDisallowedByAzure|locationineligible|SkuNotAvailable|QuotaExceeded|OperationNotAllowed|AllocationFailed|VM size .+ unavailable|At least 32 unused' + + if ($retryableRegionalFailure -and $localBoxLocation -ne $localBoxLocations[-1]) { + Write-Warning "LocalBox is unavailable in $localBoxLocation; trying the next preferred region. $($_.Exception.Message)" + $localBoxDeployment = $null + continue + } + throw + } + } + + if (-not $localBoxDeployment) { + throw "LocalBox deployment submission failed in all candidate regions for subscription $SubscriptionId." + } +} + + +$localBoxScope = "/subscriptions/$SubscriptionId/resourceGroups/$localBoxResourceGroupName" +foreach ($participantObjectId in $AllowedEntraUserIds) { + foreach ($roleName in @('Reader', 'Azure Stack HCI VM Contributor')) { + $roleAssignment = Get-AzRoleAssignment ` + -ObjectId $participantObjectId ` + -RoleDefinitionName $roleName ` + -Scope $localBoxScope ` + -ErrorAction SilentlyContinue + + if (-not $roleAssignment) { + Write-Host "Assigning '$roleName' on shared LocalBox to $participantObjectId..." + New-AzRoleAssignment ` + -ObjectId $participantObjectId ` + -RoleDefinitionName $roleName ` + -Scope $localBoxScope ` + -ErrorAction Stop | Out-Null + } + } +} + +@{ + 'HackboxCredential' = @{ + name = 'Shared LocalBox Resource Group' + value = $localBoxResourceGroupName + note = 'Shared Challenge 6 environment; readiness is verified by the facilitator' + } +} \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/sovereign-lab.bicep b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/sovereign-lab.bicep new file mode 100644 index 000000000..7e44a0ce2 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/sovereign-lab.bicep @@ -0,0 +1,476 @@ +targetScope = 'resourceGroup' + +@description('Azure region for the shared Sovereign Cloud lab resources.') +param location string = resourceGroup().location + +@description('Stable suffix used to keep resource names unique per participant.') +@minLength(6) +@maxLength(12) +param nameSuffix string + +@description('Local administrator name for the private K3s VM.') +param adminUsername string = 'azureuser' + +@description('Local administrator password for the private K3s VM.') +@secure() +param adminPassword string + +@description('Local administrator password for the Confidential VM.') +@secure() +param cvmAdminPassword string + +@description('AKS system-pool VM size deployed in the region selected during shared preparation.') +param aksNodeVmSize string = 'Standard_D4s_v5' + +@description('Private K3s VM size deployed in the region selected during shared preparation.') +param k3sVmSize string = 'Standard_D4s_v5' + +@description('AMD SEV-SNP VM size used by Challenge 4 and the Challenge 5 AKS node pool.') +param confidentialVmSize string = 'Standard_DC2as_v5' + +var aksName = 'aks-sovereign-${nameSuffix}' +var vmName = 'vm-k3s-${nameSuffix}' +var cvmName = 'vm-cvm-${nameSuffix}' +var vnetName = 'vnet-sovereign-${nameSuffix}' +var nsgName = 'nsg-sovereign-${nameSuffix}' +var nicName = 'nic-k3s-${nameSuffix}' +var cvmNicName = 'nic-cvm-${nameSuffix}' +var bastionName = 'bas-sovereign-${nameSuffix}' +var bastionPublicIpName = 'pip-bastion-${nameSuffix}' +var natPublicIpName = 'pip-nat-${nameSuffix}' +var natGatewayName = 'nat-sovereign-${nameSuffix}' +var attestationProviderName = 'attest${nameSuffix}' +var k3sPrivateIp = '10.42.0.4' +var cvmPrivateIp = '10.42.2.4' + +var tags = { + workload: 'sovereign-lab' + challenges: '4,5,7' +} + +var k3sInstallScript = ''' +#!/usr/bin/env bash +set -euo pipefail + +install -d -m 0755 /etc/rancher/k3s +cat >/etc/rancher/k3s/config.yaml <<'EOF' +tls-san: + - 127.0.0.1 + - ${k3sPrivateIp} +write-kubeconfig-mode: "0600" +disable: + - traefik +EOF + +if command -v k3s >/dev/null 2>&1; then + systemctl enable --now k3s + systemctl restart k3s +else + curl --fail --location --silent --show-error 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 +''' + +resource natPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = { + name: natPublicIpName + location: location + tags: tags + sku: { + name: 'Standard' + } + properties: { + publicIPAllocationMethod: 'Static' + publicIPAddressVersion: 'IPv4' + } +} + +resource natGateway 'Microsoft.Network/natGateways@2024-05-01' = { + name: natGatewayName + location: location + tags: tags + sku: { + name: 'Standard' + } + properties: { + idleTimeoutInMinutes: 10 + publicIpAddresses: [ + { + id: natPublicIp.id + } + ] + } +} + +resource nsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { + name: nsgName + location: location + tags: tags + properties: { + securityRules: [ + { + name: 'AllowBastionSsh' + properties: { + priority: 100 + access: 'Allow' + direction: 'Inbound' + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefix: '10.42.1.0/26' + destinationAddressPrefix: '*' + } + } + { + name: 'AllowBastionK3sApi' + properties: { + priority: 110 + access: 'Allow' + direction: 'Inbound' + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '6443' + sourceAddressPrefix: '10.42.1.0/26' + destinationAddressPrefix: '*' + } + } + ] + } +} + +resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' = { + name: vnetName + location: location + tags: tags + properties: { + addressSpace: { + addressPrefixes: [ + '10.42.0.0/16' + ] + } + subnets: [ + { + name: 'snet-k3s' + properties: { + addressPrefix: '10.42.0.0/24' + defaultOutboundAccess: false + natGateway: { + id: natGateway.id + } + networkSecurityGroup: { + id: nsg.id + } + } + } + { + name: 'AzureBastionSubnet' + properties: { + addressPrefix: '10.42.1.0/26' + } + } + { + name: 'snet-cvm' + properties: { + addressPrefix: '10.42.2.0/24' + defaultOutboundAccess: true + networkSecurityGroup: { + id: nsg.id + } + } + } + ] + } +} + +resource k3sSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' existing = { + name: 'snet-k3s' + parent: vnet +} + +resource bastionSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' existing = { + name: 'AzureBastionSubnet' + parent: vnet +} + +resource cvmSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' existing = { + name: 'snet-cvm' + parent: vnet +} + +resource nic 'Microsoft.Network/networkInterfaces@2024-05-01' = { + name: nicName + location: location + tags: tags + properties: { + networkSecurityGroup: { + id: nsg.id + } + ipConfigurations: [ + { + name: 'ipconfig1' + properties: { + privateIPAllocationMethod: 'Static' + privateIPAddress: k3sPrivateIp + subnet: { + id: k3sSubnet.id + } + } + } + ] + } +} + +resource cvmNic 'Microsoft.Network/networkInterfaces@2024-05-01' = { + name: cvmNicName + location: location + tags: tags + properties: { + networkSecurityGroup: { + id: nsg.id + } + ipConfigurations: [ + { + name: 'ipconfig1' + properties: { + privateIPAllocationMethod: 'Static' + privateIPAddress: cvmPrivateIp + subnet: { + id: cvmSubnet.id + } + } + } + ] + } +} + +resource k3sVm 'Microsoft.Compute/virtualMachines@2024-11-01' = { + name: vmName + location: location + tags: tags + properties: { + hardwareProfile: { + vmSize: k3sVmSize + } + osProfile: { + computerName: vmName + adminUsername: adminUsername + adminPassword: adminPassword + linuxConfiguration: { + disablePasswordAuthentication: false + provisionVMAgent: true + } + } + storageProfile: { + imageReference: { + publisher: 'Canonical' + offer: '0001-com-ubuntu-server-jammy' + sku: '22_04-lts-gen2' + version: 'latest' + } + osDisk: { + createOption: 'FromImage' + managedDisk: { + storageAccountType: 'StandardSSD_LRS' + } + diskSizeGB: 64 + } + } + networkProfile: { + networkInterfaces: [ + { + id: nic.id + } + ] + } + } +} + +resource k3sExtension 'Microsoft.Compute/virtualMachines/extensions@2024-11-01' = { + name: 'install-k3s' + parent: k3sVm + location: location + properties: { + publisher: 'Microsoft.Azure.Extensions' + type: 'CustomScript' + typeHandlerVersion: '2.1' + autoUpgradeMinorVersion: true + protectedSettings: { + commandToExecute: 'echo ${base64(k3sInstallScript)} | base64 --decode | bash' + } + } +} + +resource confidentialVm 'Microsoft.Compute/virtualMachines@2024-11-01' = { + name: cvmName + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } + properties: { + hardwareProfile: { + vmSize: confidentialVmSize + } + securityProfile: { + securityType: 'ConfidentialVM' + uefiSettings: { + secureBootEnabled: true + vTpmEnabled: true + } + } + osProfile: { + computerName: cvmName + adminUsername: adminUsername + adminPassword: cvmAdminPassword + linuxConfiguration: { + disablePasswordAuthentication: false + provisionVMAgent: true + } + } + storageProfile: { + imageReference: { + publisher: 'Canonical' + offer: '0001-com-ubuntu-confidential-vm-jammy' + sku: '22_04-lts-cvm' + version: 'latest' + } + osDisk: { + createOption: 'FromImage' + managedDisk: { + storageAccountType: 'Premium_LRS' + securityProfile: { + securityEncryptionType: 'VMGuestStateOnly' + } + } + } + } + networkProfile: { + networkInterfaces: [ + { + id: cvmNic.id + } + ] + } + } +} + +resource attestationProvider 'Microsoft.Attestation/attestationProviders@2021-06-01' = { + name: attestationProviderName + location: location + tags: tags + properties: { + publicNetworkAccess: 'Enabled' + } +} + +resource bastionPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = { + name: bastionPublicIpName + location: location + tags: tags + sku: { + name: 'Standard' + } + properties: { + publicIPAllocationMethod: 'Static' + publicIPAddressVersion: 'IPv4' + } +} + +resource bastion 'Microsoft.Network/bastionHosts@2024-05-01' = { + name: bastionName + location: location + tags: tags + sku: { + name: 'Standard' + } + properties: { + enableTunneling: true + ipConfigurations: [ + { + name: 'bastion-ipconfig' + properties: { + privateIPAllocationMethod: 'Dynamic' + publicIPAddress: { + id: bastionPublicIp.id + } + subnet: { + id: bastionSubnet.id + } + } + } + ] + } +} + +resource aks 'Microsoft.ContainerService/managedClusters@2024-10-01' = { + name: aksName + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } + properties: { + dnsPrefix: aksName + enableRBAC: true + oidcIssuerProfile: { + enabled: true + } + securityProfile: { + workloadIdentity: { + enabled: true + } + } + serviceMeshProfile: { + mode: 'Istio' + istio: {} + } + agentPoolProfiles: [ + { + name: 'system' + count: 2 + vmSize: aksNodeVmSize + osType: 'Linux' + osSKU: 'Ubuntu' + mode: 'System' + type: 'VirtualMachineScaleSets' + osDiskType: 'Managed' + } + ] + networkProfile: { + networkPlugin: 'azure' + networkPluginMode: 'overlay' + networkPolicy: 'azure' + loadBalancerSku: 'standard' + outboundType: 'loadBalancer' + } + } +} + +resource confidentialNodePool 'Microsoft.ContainerService/managedClusters/agentPools@2024-10-01' = { + name: 'cvmnodepool' + parent: aks + properties: { + count: 1 + vmSize: confidentialVmSize + osType: 'Linux' + osSKU: 'AzureLinux' + mode: 'User' + type: 'VirtualMachineScaleSets' + osDiskType: 'Managed' + } +} + +output aksClusterName string = aks.name +output confidentialNodePoolName string = confidentialNodePool.name +output confidentialVmName string = confidentialVm.name +output confidentialVmPrivateIp string = cvmPrivateIp +output attestationProviderName string = attestationProvider.name +output attestationProviderUri string = attestationProvider.properties.attestUri +output bastionName string = bastion.name +output k3sVmName string = k3sVm.name +output k3sVmResourceId string = k3sVm.id +output k3sPrivateIp string = k3sPrivateIp +output natEgressIp string = natPublicIp.properties.ipAddress +output k3sInstallStatusResource string = k3sExtension.id diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/README.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/README.md index c0635fe04..416493d69 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/README.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/README.md @@ -86,6 +86,54 @@ Choose the appropriate deployment script: Deployments can take 2-6 hours depending on the environment. Monitor progress in: - Azure Portal > Resource Groups > Deployments +#### Hackathons console deployment behavior + +The Hackathons console runs `labautomation/shared-deploy-lab.ps1` once per +subscription. The shared hook creates or reuses `rg-localbox-shared`, validates the +LocalBox template, and submits the deployment asynchronously. Step 2 is +considered successful when Azure accepts that deployment; it does not wait for +the full LocalBox environment to finish provisioning. + +The LocalBox host, network, Bastion, and Log Analytics resources use the selected +lab region. Azure Local registration and its staging storage account use Australia East, +which is a supported Azure Local registration region and is not blocked by the hosted +lab subscription policy that denies West Europe. + +The shared hook applies `SecurityControl=Ignore` to `rg-localbox-shared`, and the +outer LocalBox template applies the same tag to its resources. This temporary MCAPS +exemption is required during Azure Local deployment because cluster validation uses +storage account key authentication and requires public access to its validation +storage account and Key Vault. The exemption is scoped to the LocalBox resource +group and expires after 14 days under MCAPS governance automation. Deploy or rerun +the shared hook early enough for the exemption to take effect before validating the +Azure Local cluster. + +If MCAPS policy modified resources before the exemption took effect, applying the +tag does not revert those existing settings. After the exemption is active, enable +storage account key access and public network access on the validation storage +account, enable public network access on the validation Key Vault, and rerun the +Azure Local validate and deploy workflow from `LocalBox-Client`. + +After Step 2 completes, a facilitator must verify the deployment manually: + +1. Open `rg-localbox-shared` in the Azure Portal and select **Deployments**. +2. Confirm that the `localbox-*` deployment is progressing without a failed + operation. +3. Confirm that the `LocalBox-Client` VM appears after approximately 15-20 + minutes. Its presence confirms that the deployment has started, not that the + complete LocalBox environment is ready. +4. Before signing in to the VM, use **Help > Reset password** on + `LocalBox-Client` to set a facilitator-known password for the `arcdemo` + account. The unattended shared deployment uses an automatically generated + password and does not publish it to participants. +5. Continue monitoring the deployment and complete the storage, VM image, + logical network, and role-assignment checks below before participants start + Challenge 6. Full provisioning can take 4-6 hours. + +Re-running Step 2 does not submit another LocalBox deployment while an existing +`localbox-*` deployment is active or has succeeded. If the existing deployment +failed, correct the Azure deployment error before retrying Step 2. + ### Step 4: Expand UserStorage Volumes > [!IMPORTANT] diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/1-resource-providers.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/1-resource-providers.ps1 index 1eb3b7bd9..8ad55552e 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/1-resource-providers.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/1-resource-providers.ps1 @@ -147,6 +147,98 @@ foreach ($subscription in $subscriptions) { } } } + + $networkFeatureName = "AllowBringYourOwnPublicIpAddress" + $networkFeature = Get-AzProviderFeature ` + -ProviderNamespace "Microsoft.Network" ` + -FeatureName $networkFeatureName ` + -ErrorAction Stop + + if ($networkFeature.RegistrationState -ne 'Registered') { + Write-Host " [REGISTERING] Microsoft.Network/$networkFeatureName..." -ForegroundColor Yellow + Register-AzProviderFeature ` + -ProviderNamespace "Microsoft.Network" ` + -FeatureName $networkFeatureName ` + -ErrorAction Stop | Out-Null + + for ($attempt = 1; $attempt -le 90; $attempt++) { + Start-Sleep -Seconds 10 + $networkFeature = Get-AzProviderFeature ` + -ProviderNamespace "Microsoft.Network" ` + -FeatureName $networkFeatureName ` + -ErrorAction Stop + if ($networkFeature.RegistrationState -eq 'Registered') { + break + } + Write-Host " Waiting for feature registration ($attempt/90): $($networkFeature.RegistrationState)" -ForegroundColor Gray + } + } + + if ($networkFeature.RegistrationState -ne 'Registered') { + throw "Provider feature Microsoft.Network/$networkFeatureName did not reach Registered state within 15 minutes (current state: $($networkFeature.RegistrationState))." + } + + Write-Host " [REGISTERED] Microsoft.Network/$networkFeatureName" -ForegroundColor Green + Register-AzResourceProvider -ProviderNamespace "Microsoft.Network" -ErrorAction Stop | Out-Null + + for ($attempt = 1; $attempt -le 30; $attempt++) { + $networkProvider = Get-AzResourceProvider -ProviderNamespace "Microsoft.Network" -ErrorAction Stop + if ($networkProvider.RegistrationState -eq 'Registered') { + break + } + Write-Host " Waiting for Microsoft.Network propagation ($attempt/30)..." -ForegroundColor Gray + Start-Sleep -Seconds 10 + } + + if ($networkProvider.RegistrationState -ne 'Registered') { + throw "Resource provider Microsoft.Network did not return to Registered state within 5 minutes." + } + + $aksFeatureName = "AzureLinuxCVMPreview" + $aksFeature = Get-AzProviderFeature ` + -ProviderNamespace "Microsoft.ContainerService" ` + -FeatureName $aksFeatureName ` + -ErrorAction Stop + + if ($aksFeature.RegistrationState -ne 'Registered') { + Write-Host " [REGISTERING] Microsoft.ContainerService/$aksFeatureName..." -ForegroundColor Yellow + Register-AzProviderFeature ` + -ProviderNamespace "Microsoft.ContainerService" ` + -FeatureName $aksFeatureName ` + -ErrorAction Stop | Out-Null + + for ($attempt = 1; $attempt -le 90; $attempt++) { + Start-Sleep -Seconds 10 + $aksFeature = Get-AzProviderFeature ` + -ProviderNamespace "Microsoft.ContainerService" ` + -FeatureName $aksFeatureName ` + -ErrorAction Stop + if ($aksFeature.RegistrationState -eq 'Registered') { + break + } + Write-Host " Waiting for feature registration ($attempt/90): $($aksFeature.RegistrationState)" -ForegroundColor Gray + } + } + + if ($aksFeature.RegistrationState -ne 'Registered') { + throw "Provider feature Microsoft.ContainerService/$aksFeatureName did not reach Registered state within 15 minutes (current state: $($aksFeature.RegistrationState))." + } + + Write-Host " [REGISTERED] Microsoft.ContainerService/$aksFeatureName" -ForegroundColor Green + Register-AzResourceProvider -ProviderNamespace "Microsoft.ContainerService" -ErrorAction Stop | Out-Null + + for ($attempt = 1; $attempt -le 30; $attempt++) { + $containerServiceProvider = Get-AzResourceProvider -ProviderNamespace "Microsoft.ContainerService" -ErrorAction Stop + if ($containerServiceProvider.RegistrationState -eq 'Registered') { + break + } + Write-Host " Waiting for Microsoft.ContainerService propagation ($attempt/30)..." -ForegroundColor Gray + Start-Sleep -Seconds 10 + } + + if ($containerServiceProvider.RegistrationState -ne 'Registered') { + throw "Resource provider Microsoft.ContainerService did not return to Registered state within 5 minutes." + } } Write-Host "`n" + ("=" * 80) -ForegroundColor Cyan diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/2-vcpu-quotas.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/2-vcpu-quotas.ps1 index bb182f566..c69c51e58 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/2-vcpu-quotas.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/2-vcpu-quotas.ps1 @@ -6,10 +6,10 @@ This script checks current vCPU quota usage in a specified Azure region and calculates the required quotas for running the MicroHack lab based on the number of lab users. - For Azure Arc Jumpstart environments (ArcBox/LocalBox), significant vCPU quotas are required: - - ArcBox: ~16-32 vCPUs per instance - - LocalBox: ~48-64 vCPUs per instance - - Confidential VMs: Additional vCPUs for DCasv5/DCadsv5 series + The shared Challenge 4/5/7 platform requires per participant: + - 12 Standard DSv5 Family vCPUs + - 4 Standard DCasv5 or DCasv6 Family vCPUs + - 16 Total Regional vCPUs The script can optionally submit quota increase requests using the Azure Quota REST API. @@ -17,7 +17,7 @@ Azure region for quota check (e.g., eastus, northeurope). If not provided, the script will prompt for selection. .PARAMETER NumberOfLabUsers - Number of lab users to calculate quota requirements for + Number of lab participants hosted by each selected subscription .PARAMETER ShowCurrentUsageOnly Only display current quota usage without calculating requirements @@ -25,6 +25,9 @@ .PARAMETER SubmitQuotaRequests Automatically submit quota increase requests via REST API if needed +.PARAMETER ConfidentialVmGeneration + AMD SEV-SNP confidential VM generation to prepare. Defaults to the generally available v5 family. + .PARAMETER ExportToJson Export results to a JSON file @@ -33,8 +36,8 @@ Interactive mode - prompts for region and user count .EXAMPLE - .\2-vcpu-quotas.ps1 -Region "northeurope" -NumberOfLabUsers 10 -SubmitQuotaRequests - Check quotas for 10 users in North Europe and submit increase requests if needed + .\2-vcpu-quotas.ps1 -Region "swedencentral" -NumberOfLabUsers 2 -ConfidentialVmGeneration v5 -SubmitQuotaRequests + Check quotas for two participants in Sweden Central and submit increase requests if needed .NOTES Author: MicroHack Team @@ -60,6 +63,10 @@ param( [Parameter(Mandatory = $false)] [switch]$SubmitQuotaRequests, + [Parameter(Mandatory = $false)] + [ValidateSet('v5', 'v6')] + [string]$ConfidentialVmGeneration = 'v5', + [Parameter(Mandatory = $false)] [switch]$ExportToJson ) @@ -81,27 +88,19 @@ function Get-AzureRegion { if ([string]::IsNullOrWhiteSpace($Region)) { Write-Host "`n=== Select Azure Region ===" -ForegroundColor Cyan Write-Host "Recommended regions for Sovereign Cloud MicroHack:" - Write-Host " 1. North Europe (northeurope)" - Write-Host " 2. Norway East (norwayeast)" - Write-Host " 3. West Europe (westeurope)" - Write-Host " 4. Germany West Central (germanywestcentral)" - Write-Host " 5. UK South (uksouth)" - Write-Host " 6. Switzerland North (switzerlandnorth)" - Write-Host " 7. Enter custom region" + Write-Host " 1. Sweden Central (swedencentral)" + Write-Host " 2. Spain Central (spaincentral)" + Write-Host " 3. Enter custom region" - $choice = Read-Host "`nEnter your choice (1-7)" + $choice = Read-Host "`nEnter your choice (1-3)" $script:Region = switch ($choice) { - "1" { "northeurope" } - "2" { "norwayeast" } - "3" { "westeurope" } - "4" { "germanywestcentral" } - "5" { "uksouth" } - "6" { "switzerlandnorth" } - "7" { Read-Host "Enter Azure region (e.g., eastus, westeurope)" } + "1" { "swedencentral" } + "2" { "spaincentral" } + "3" { Read-Host "Enter Azure region (e.g., swedencentral, spaincentral)" } default { - Write-Warning "Invalid choice. Defaulting to North Europe (northeurope)." - "northeurope" + Write-Warning "Invalid choice. Defaulting to Sweden Central (swedencentral)." + "swedencentral" } } } @@ -112,7 +111,7 @@ function Get-AzureRegion { function Get-LabUserCount { if ($NumberOfLabUsers -eq 0 -and -not $ShowCurrentUsageOnly) { do { - $userInput = Read-Host "`nEnter the number of lab users (1-60)" + $userInput = Read-Host "`nEnter the number of participants hosted by each selected subscription (1-60)" $script:NumberOfLabUsers = [int]$userInput } while ($NumberOfLabUsers -lt 1 -or $NumberOfLabUsers -gt 60) } @@ -199,6 +198,78 @@ function Register-QuotaProvider { } } +function Get-ResponseHeaderValue { + param( + [System.Net.Http.Headers.HttpResponseHeaders]$Headers, + [string]$Name + ) + + $header = $Headers | Where-Object { $_.Key -ieq $Name } | Select-Object -First 1 + return @($header.Value)[0] +} + +# Wait for an asynchronous quota request to reach a terminal state +function Wait-QuotaRequestStatus { + param( + [string]$OperationStatusUrl, + [int]$RetryAfterSeconds = 5, + [int]$MaxAttempts = 20 + ) + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + Start-Sleep -Seconds $RetryAfterSeconds + $response = Invoke-AzRestMethod -Uri $OperationStatusUrl -Method GET + + if ($response.StatusCode -eq 202) { + Write-Host " Request is still processing (attempt $attempt of $MaxAttempts)..." -ForegroundColor Gray + continue + } + + if ($response.StatusCode -ne 200) { + Write-Host " Status check failed with HTTP $($response.StatusCode)." -ForegroundColor Red + return $false + } + + $content = $response.Content | ConvertFrom-Json + $state = $content.properties.provisioningState + + if ($state -eq 'Succeeded') { + Write-Host " Quota request completed successfully." -ForegroundColor Green + return $true + } + + if ($state -in @('InProgress', 'Accepted', 'Running')) { + Write-Host " Request is still processing (attempt $attempt of $MaxAttempts)..." -ForegroundColor Gray + continue + } + + $error = if($content.properties.error) { $content.properties.error } else { $content.error } + $errorCode = if($error.code) { $error.code } else { $content.properties.errorCode } + $message = $error.message + if (-not $message) { + $message = $content.properties.message + } + if (-not $message) { + $message = $content.properties.errorMessage + } + if (-not $message) { + $message = "Quota request ended without error details." + } + + Write-Host " Quota request ended in state '$state'." -ForegroundColor Red + if ($errorCode) { + Write-Host " Azure error: $errorCode - $message" -ForegroundColor Red + } elseif ($message) { + Write-Host " Azure message: $message" -ForegroundColor Red + } + return $false + } + + Write-Host " Timed out waiting for the quota request to complete." -ForegroundColor Red + Write-Host " Check status at: $OperationStatusUrl" -ForegroundColor Gray + return $false +} + # Function to submit quota increase request via REST API function Submit-QuotaIncreaseRequest { param( @@ -246,17 +317,20 @@ function Submit-QuotaIncreaseRequest { } if ($response.StatusCode -eq 202) { - # Async operation - get the operation status URL - $locationHeader = $response.Headers['Location'] - if ($locationHeader) { - Write-Host " This is an asynchronous operation. Check status at:" -ForegroundColor Yellow - Write-Host " $locationHeader" -ForegroundColor Gray + $locationHeader = Get-ResponseHeaderValue -Headers $response.Headers -Name 'Location' + if (-not $locationHeader) { + Write-Host " Azure accepted the request but did not return a status URL." -ForegroundColor Red + return $false } - $retryAfter = $response.Headers['Retry-After'] - if ($retryAfter) { - Write-Host " Recommended retry after: $retryAfter seconds" -ForegroundColor Gray - } + $retryAfter = Get-ResponseHeaderValue -Headers $response.Headers -Name 'Retry-After' + $retryAfterSeconds = if ($retryAfter -as [int]) { [int]$retryAfter } else { 5 } + + Write-Host " Waiting for the asynchronous request to complete..." -ForegroundColor Yellow + Write-Host " $locationHeader" -ForegroundColor Gray + return Wait-QuotaRequestStatus ` + -OperationStatusUrl $locationHeader ` + -RetryAfterSeconds $retryAfterSeconds } return $true @@ -271,37 +345,15 @@ function Submit-QuotaIncreaseRequest { } } -# Function to check quota request status -function Get-QuotaRequestStatus { - param( - [string]$OperationStatusUrl - ) - - try { - $response = Invoke-AzRestMethod -Uri $OperationStatusUrl -Method GET - - if ($response.StatusCode -eq 200) { - $content = $response.Content | ConvertFrom-Json - Write-Host "Quota request status: $($content.properties.provisioningState)" -ForegroundColor Cyan - return $content - } else { - Write-Warning "Failed to get quota request status. Status Code: $($response.StatusCode)" - return $null - } - } catch { - Write-Warning "Error checking quota request status: $($_.Exception.Message)" - return $null - } -} - # Map display names to API resource names $quotaNameMapping = @{ "Total Regional vCPUs" = "cores" "Standard DSv5 Family vCPUs" = "StandardDSv5Family" "Standard DSv6 Family vCPUs" = "StandardDSv6Family" "Standard DASv5 Family vCPUs" = "StandardDASv5Family" - "Standard DCasv5 Family vCPUs (Confidential)" = "StandardDCasv5Family" - "Standard DCadsv5 Family vCPUs (Confidential)" = "StandardDCadsv5Family" + "Standard DCasv5 Family vCPUs (Confidential)" = "standardDCASv5Family" + "Standard DCasv6 Family vCPUs (Confidential)" = "standardDCasv6Family" + "Standard DCadsv6 Family vCPUs (Confidential)" = "standardDCadsv6Family" "Standard ESv5 Family vCPUs" = "StandardESv5Family" } @@ -309,8 +361,34 @@ $quotaNameMapping = @{ try { $context = Get-AzContext if (-not $context) { - Write-Host "`nNo Azure context found. Please login..." -ForegroundColor Yellow - Connect-AzAccount + $cliAccountJson = if (Get-Command az -ErrorAction SilentlyContinue) { + az account show --output json 2>$null + } + + if ($LASTEXITCODE -eq 0 -and $cliAccountJson) { + $cliAccount = $cliAccountJson | ConvertFrom-Json + $accessToken = az account get-access-token ` + --resource https://management.azure.com/ ` + --query accessToken ` + --output tsv + + if ($LASTEXITCODE -ne 0 -or -not $accessToken) { + throw "Failed to acquire an Azure Resource Manager token from Azure CLI." + } + + Write-Host "`nUsing current Azure CLI subscription: $($cliAccount.name)" -ForegroundColor Green + Connect-AzAccount ` + -AccessToken $accessToken ` + -AccountId $cliAccount.user.name ` + -Tenant $cliAccount.tenantId ` + -Subscription $cliAccount.id ` + -SkipValidation ` + -Scope Process | Out-Null + } else { + Write-Host "`nNo Azure context found. Please login..." -ForegroundColor Yellow + Connect-AzAccount | Out-Null + } + $context = Get-AzContext } } catch { @@ -349,8 +427,9 @@ foreach ($subscription in $selectedSubscriptions) { "StandardDSv5Family" = "Standard DSv5 Family vCPUs" "StandardDSv6Family" = "Standard DSv6 Family vCPUs" "StandardDASv5Family" = "Standard DASv5 Family vCPUs" - "StandardDCasv5Family" = "Standard DCasv5 Family vCPUs (Confidential)" - "StandardDCadsv5Family" = "Standard DCadsv5 Family vCPUs (Confidential)" + "standardDCASv5Family" = "Standard DCasv5 Family vCPUs (Confidential)" + "standardDCasv6Family" = "Standard DCasv6 Family vCPUs (Confidential)" + "standardDCadsv6Family" = "Standard DCadsv6 Family vCPUs (Confidential)" "StandardESv5Family" = "Standard ESv5 Family vCPUs" } @@ -391,38 +470,31 @@ foreach ($subscription in $selectedSubscriptions) { # Get number of lab users $NumberOfLabUsers = Get-LabUserCount - # Calculate requirements for Sovereign Cloud MicroHack - # ArcBox: ~16 vCPUs (Standard DSv5/v6) - # LocalBox: ~48 vCPUs (Standard DSv5/v6) - # Confidential VMs: ~8 vCPUs (DCasv5) - # Per-user VMs: ~6 vCPUs each + # Calculate requirements for the shared Challenge 4/5/7 participant platform. Write-Host "`n=== Lab Requirements Calculation ===" -ForegroundColor Cyan Write-Host ("=" * 80) Write-Host "`nNumber of lab users: $NumberOfLabUsers" -ForegroundColor Green + $confidentialQuotaName = if($ConfidentialVmGeneration -eq 'v5') { 'standardDCASv5Family' } else { 'standardDCasv6Family' } + $confidentialQuotaDisplayName = "Standard DCas$ConfidentialVmGeneration Family vCPUs (Confidential)" $requirements = @{ "cores" = @{ - PerUser = 4 - Shared = 64 # ArcBox + LocalBox shared environment + PerUser = 16 + Shared = 0 Name = "Total Regional vCPUs" } "StandardDSv5Family" = @{ - PerUser = 0 - Shared = 32 - Name = "Standard DSv5 Family vCPUs" - } - "StandardDSv6Family" = @{ - PerUser = 0 - Shared = 32 - Name = "Standard DSv6 Family vCPUs" - } - "StandardDCasv5Family" = @{ - PerUser = 6 + PerUser = 12 Shared = 0 - Name = "Standard DCasv5 Family vCPUs (Confidential)" + Name = "Standard DSv5 Family vCPUs" } } + $requirements[$confidentialQuotaName] = @{ + PerUser = 4 + Shared = 0 + Name = $confidentialQuotaDisplayName + } Write-Host "`nRequired vCPUs per lab user + shared infrastructure:" foreach ($key in $requirements.Keys) { @@ -453,7 +525,7 @@ foreach ($subscription in $selectedSubscriptions) { if ($available -lt $totalNeeded) { $shortfall = $totalNeeded - $available - $newLimit = $current.Current + $totalNeeded + 10 # Add buffer + $newLimit = $current.Current + $totalNeeded Write-Host (" STATUS : INSUFFICIENT") -ForegroundColor Red Write-Host (" Shortfall : {0,6} vCPUs" -f $shortfall) -ForegroundColor Red Write-Host (" Recommended Limit: {0,6} vCPUs" -f $newLimit) -ForegroundColor Yellow diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/portal-guide.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/portal-guide.md index 2f5ab90e4..a9ba17a58 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/portal-guide.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/portal-guide.md @@ -1,20 +1,18 @@ # Walkthrough Challenge 4 - Encryption in use with Azure Confidential Compute – VM # Azure Portal Guide -**Estimated Duration:** 90-120 minutes +**Estimated Duration:** 45-60 minutes -> 💡 **Objective:** Learn how to implement and validate guest attestation on Azure Confidential VMs using the Azure Portal to ensure business logic only executes in trusted, hardware-backed confidential computing environments. You will deploy a Confidential VM through the portal, configure secure access through Azure Bastion and Key Vault, build and run attestation client applications, and verify cryptographic proof of VM integrity before processing sensitive workloads. +> 💡 **Objective:** Validate guest attestation on a pre-provisioned Azure Confidential VM using the Azure Portal to confirm that workloads execute only in trusted, hardware-backed confidential computing environments. You will inspect the VM's security configuration through the portal, connect through Azure Bastion using username/password credentials, build and run attestation client applications, and verify cryptographic proof of VM integrity using your dedicated Microsoft Azure Attestation (MAA) provider. ## Prerequisites Please ensure that you successfully verified the [General prerequisites](../../Readme.md#general-prerequisites) before continuing with this challenge. - Azure Portal access (https://portal.azure.com) -- Azure subscription with permissions to create VMs, Key Vault, and Attestation Providers -- Basic understanding of Azure Virtual Machines and networking concepts -- Familiarity with SSH key authentication +- Lab dashboard credentials (Resource Group Name, Confidential VM, Confidential VM Admin Username, Confidential VM Admin Password, Attestation Provider, Sovereign Lab Bastion) - Access to Azure Cloud Shell -- Basic understanding of confidential computing concepts +- Basic understanding of Azure Virtual Machines and confidential computing concepts ## Scenario Context @@ -82,211 +80,85 @@ The sample application and deployment patterns have been adapted for this MicroH --- -## Task 1: Understand the Deployment Architecture and Naming Conventions +## Task 1: Locate Pre-Provisioned Resources -💡 **Before deploying resources through the portal, it's important to understand the security architecture, components you'll be creating, and the naming conventions you'll use.** +💡 **The platform has pre-provisioned a private Ubuntu Confidential VM, a shared Standard Azure Bastion, and a custom Attestation Provider in your resource group. No resource creation is needed — your focus is on validating the environment and running guest attestation.** -### Resources Being Deployed +### Pre-Provisioned Resources -The following resources will be created in the `North Europe` Azure region: +Locate the following values on your lab dashboard before proceeding: -- **1 Attestation Provider** - Microsoft Azure Attestation (MAA) service for verifying TEE integrity -- **1 Virtual Network** - Isolated network with VM and Bastion subnets -- **1 Confidential VM** - Ubuntu 22.04 with AMD SEV-SNP hardware encryption -- **1 Azure Bastion** - Secure remote access without public IP exposure -- **1 Azure Key Vault** - Secure storage for SSH keys -- **Associated Resources** - NSGs, NICs, OS Disks (auto-created) +| Dashboard Field | Description | +|---|---| +| **Resource Group Name** | Your dedicated resource group containing all lab resources | +| **Sovereign Lab Region** | Azure region where the CVM and Attestation Provider are deployed | +| **Confidential VM** | Name of the pre-provisioned private Ubuntu 22.04 Confidential VM | +| **Confidential VM Admin Username** | Username for Bastion login | +| **Confidential VM Admin Password** | Password for Bastion login | +| **Attestation Provider** | Name of the custom MAA provider (note field contains the full Attest URI) | +| **Sovereign Lab Bastion** | Name of the shared Standard Azure Bastion for private VNet access | + +> [!IMPORTANT] +> Copy the **Attestation Provider** Attest URI from the dashboard note field — you will need it in Task 3 when running the attestation client inside the VM. ### Security Architecture -This setup implements a zero-trust security model: +This environment uses a zero-trust security model: -- **No Public IPs on VMs** - VMs are not directly accessible from internet -- **Azure Bastion (Basic SKU)** - Provides secure RDP/SSH access through Azure Portal -- **Azure Key Vault** - Stores SSH private keys securely with RBAC controls -- **Fresh SSH Key Pair** - Generated specifically for this deployment, never stored locally +- **No Public IPs on VMs** - Confidential VM is not directly accessible from the internet +- **Azure Bastion (Standard SKU)** - Provides secure RDP/SSH access through the Azure Portal - **Hardware-Based Encryption** - AMD SEV-SNP encrypts VM memory at the hardware level - **Secure Boot** - Protects boot chain integrity from firmware through kernel +- **Regional Fallback** - The Attestation Provider is deployed with regional fallback for sovereign Azure region availability 🔑 **Security Best Practice**: This architecture ensures VM access is authenticated through Azure AD, encrypted with TLS, and never exposes SSH directly to the internet. -### Naming Conventions - -Throughout this guide, you'll need to use consistent naming. Choose your own values: - -- **ATTENDEE_ID**: `labuser-xx` (Change this for each participant) -- **LOCATION**: `North Europe` -- **RESOURCE_GROUP**: `{ATTENDEE_ID}` (replace with your ATTENDEE_ID) - -For globally unique resources, append a random 6-character suffix: -- **KEYVAULT_NAME**: `kvmicrohacka1b2c3` (use your own random suffix) -- **ATTESTATION_NAME**: `attesta1b2c3` (use your own random suffix, no hyphens) - -> **💡 Tip**: Write down your chosen names before starting! - ---- - -## Task 2: Create Resource Group and Key Vault - -💡 **You'll create the foundational Azure resources to organize and secure your deployment.** - -### Step 1: Create Resource Group - -| Step | Action | Screenshot | -|------|--------|------------| -| 1. Navigate to the [Azure Portal](https://portal.azure.com)
2. In the search bar at the top, type **"Resource groups"** and select it
3. Click **+ Create** | | ![Resource Groups](./images/select-resource-groups.png) | -| 4. Fill in the details:
- **Subscription**: Select your subscription
- **Resource group**: `` (use your ATTENDEE_ID)
- **Region**: `North Europe` | | ![Create Resource Group](./images/create-resource-group.png) | -| 5. Click **Review + create**
6. Click **Create** | | | - - ---- - -### Step 2: Create Key Vault - -💡 **Create a Key Vault with RBAC permissions to securely store SSH keys.** - -| Step | Action | Screenshot | -|------|--------|------------| -| 1. In the search bar, type **"Key vaults"** and select it
2. Click **+ Create** | | | -| 3. Fill in the **Basics** tab:
- **Subscription**: Select your subscription
- **Resource group**: `rg-cc-attendee01`
- **Key vault name**: `kv-cc-a1b2c3` (use your random suffix)
- **Region**: `North Europe`
- **Pricing tier**: `Standard` | | ![Create Key vault](./images/kv-create.png) | -| 4. Click on the **Access configuration** tab:
- **Permission model**: Select **Azure role-based access control**
- Check ✅ **Azure Virtual Machines for deployment**
- Check ✅ **Azure Resource Manager for template deployment** | | ![Key Vault Access configuration](./images/kv-access-config.png) | -| 5. Click **Review + create**
6. Click **Create**
7. Wait for deployment to complete, then click **Go to resource** | | | - - -### Assign Key Vault Permissions - -| Step | Action | Screenshot | -|------|--------|------------| -| 1. In your Key Vault, click on **Access control (IAM)** in the left menu
2. Click **+ Add** > **Add role assignment** | | ![Configure Key Vault RBAC](./images/kv-add-role-assignment.png) | -| 3. In the **Role** tab, search for and select **Key Vault Secrets Officer**
4. Click **Next** | | ![Assign role](./images/kv-rbac-choose-secrets-officer.png) | -| 5. In the **Members** tab:
- Select **User, group, or service principal**
- Click **+ Select members**
- Search for and select your user account
- Click **Select** | | ![Select role members](./images/kv-rbac-select-members.png) | -| 6. Click **Review + assign**
7. Click **Review + assign** again | | | - -> **⏱️ Wait 2-3 minutes for permissions to propagate before proceeding** - --- -## Task 3: Generate and Store SSH Keys Securely +## Task 2: Inspect Confidential VM Security Settings in the Portal -💡 **Generate SSH key pairs using Azure Cloud Shell and store them in Key Vault for secure authentication.** +💡 **Before connecting, verify that the pre-provisioned Confidential VM is configured with the required security features.** | Step | Action | Screenshot | |------|--------|------------| -| 1. Click the **Cloud Shell** icon (>_) in the top-right corner of the Azure Portal
2. Select **Bash** when prompted | | ![Activate Cloud shell](./images/kv-cloud-shell-activate.png)
![Active Cloud shell](./images/kv-cloud-shell-activated.png) | -| 3. Run the following commands in Cloud Shell:
# Set your attendee ID and Key Vault name
ATTENDEE_ID="attendee01" # Change this

KEYVAULT_NAME="kv-cc-a1b2c3" # Change this to your Key Vault name

# Generate SSH key pair
SSH_TEMP_FILE="/tmp/cc_ssh_key_\${ATTENDEE_ID}_\${RANDOM}"
ssh-keygen -t rsa -b 4096 -f "\$SSH_TEMP_FILE" -N "" -C "microhack-cc"

# Store private key in Key Vault
az keyvault secret set --vault-name \$KEYVAULT_NAME --name "ssh-private-key" --file "\$SSH_TEMP_FILE"

# Store public key in Key Vault
az keyvault secret set --vault-name \$KEYVAULT_NAME --name "ssh-public-key" --file "\${SSH_TEMP_FILE}.pub"

# Display public key (save this for later)
echo "=== Public Key ==="
cat "\${SSH_TEMP_FILE}.pub"
echo "=================="

# Clean up
rm -f "\${SSH_TEMP_FILE}" "\${SSH_TEMP_FILE}.pub"
| | ![Generate SSH Key pair](./images/kv-gen-ssh-key-pair.png)
![store private key in Key Vault](./images/kv-secret-set-privkey.png)
![store public key in Key Vault](./images/kv-secret-set-pubkey.png) | -| 4. **Important**: Copy and save the public key output - you'll need it when creating the VM. | | ![Copy public key output](./images/kv-secret-copy-public-key.png) | +| 1. In the search bar, type **"Resource groups"** and select it
2. Click on your resource group (**Resource Group Name** from your dashboard) | | | +| 3. In the resource list, click on the Confidential VM (**Confidential VM** from your dashboard) | | | +| 4. On the **Overview** blade, confirm:
- **Security type**: `Confidential virtual machines` | | | +| 5. In the left menu under **Settings**, click **Security**:
- **Secure Boot**: Enabled ✅
- **vTPM**: Enabled ✅ | | | +| 6. In the left menu, click **Networking**:
- Confirm there is **no public IP address** assigned to the VM | | | + +> **Why these settings matter:** +> - `Confidential virtual machines` security type activates AMD SEV-SNP hardware memory encryption +> - Secure Boot prevents unauthorized firmware or bootloaders from running +> - vTPM is required for cryptographic attestation +> - No public IP enforces network isolation; all access routes through Azure Bastion --- -## Task 4: Create Attestation Provider +## Task 3: Connect to VM, Build, and Run Attestation Client -💡 **Deploy Microsoft Azure Attestation service for verifying confidential VM integrity.** +💡 **Connect to the pre-provisioned Confidential VM through Azure Bastion using credentials from your lab dashboard, install dependencies, build the attestation client, and verify cryptographic proof of the VM's trusted state.** | Step | Action | Screenshot | -|------|--------|------------| -| 1. In the search bar, type **"Attestation providers"** and select it
2. Click **+ Create** | | ![Attestation service](./images/create-attest-service.png) | -| 3. Fill in the details:
- **Subscription**: Select your subscription
- **Resource group**: `rg-cc-attendee01`
- **Name**: `attesta1b2c3` (use your random suffix, **only alphanumeric characters, no hyphens**)
- **Region**: `North Europe` | | ![Create Attestation service](./images/review-create-attest-service.png) | -| 4. Click **Review + create**
5. Click **Create** | | | - ---- - -## Task 5: Create Virtual Network with Isolated Subnets - -💡 **Create a virtual network with separate subnets for VMs and Azure Bastion to ensure network segmentation.** - -| Step | Action | Screenshot | -|------|--------|------------| -| 1. In the search bar, type **"Virtual networks"** and select it
2. Click **+ Create** | | ![Virtual networks](./images/vnet-resource.png) | -| 3. Fill in the **Basics** tab:
- **Subscription**: Select your subscription
- **Resource group**: `rg-cc-attendee01`
- **Virtual network name**: `vm-ubuntu-cvm-vnet`
- **Region**: `North Europe` | | ![Virtual network basics](./images/vnet-instance-details.png) | -| 4. Click on the **IP addresses** tab:
- Keep the default address space or set to `10.10.0.0/24`
- Delete the "default" subnet
- Click **+ Add a subnet**
- **Subnet name**: `vm-subnet`
- **Subnet address range**: `10.10.0.0/26`
- Click **Add**
- Click **+ Add a subnet** again
- **Subnet name**: `AzureBastionSubnet` (must be exactly this name)
- **Subnet address range**: `10.10.0.64/26`
- Click **Add** | | ![Add VM subnet](./images/vnet-add-subnet.png)
![Default subnet](./images/vnet-default-subnet.png)
![Azure Bastion subnet](./images/vnet-azurebastion-subnet.png)
![Virtual network summary](./images/vnet-subnet-snapshot.png) | -| 5. Click **Review + create**
6. Click **Create** | | | - ---- - -## Task 6: Deploy Confidential VM with Hardware Encryption - -💡 **Create an Azure Confidential VM with AMD SEV-SNP hardware-based encryption and secure boot.** - -| Step | Action | Screenshot | -|------|--------|------------| -| 1. In the search bar, type **"Virtual machines"** and select it
2. Click **+ Create** > **Azure virtual machine** | | ![Virtual machine resources](./images/vm-resource.png) | - -### Basics Tab - -| Step | Action | Screenshot | -|------|--------|------------| -| 3. Fill in the **Basics** tab:
- **Subscription**: Select your subscription
- **Resource group**: `rg-cc-attendee01`
- **Virtual machine name**: `vm-ubuntu-cvm`
- **Region**: `North Europe`
- **Availability options**: No infrastructure redundancy required
- **Security type**: **Confidential virtual machines**
- **Image**: Click **See all images**
- Search for **Ubuntu 22.04 Confidential VM**
- Select **Ubuntu Server 22.04 LTS - Confidential VM x64 Gen2**
- **VM architecture**: x64
- **Size**: Click **See all sizes**
- Search for **DC2as_v5**
- Select **Standard_DC2as_v5** (4 vCPUs, 32 GB memory)
- Click **Select** | | ![Virtual machine basics](./images/vm-basics-part1.png)
![Find Ubuntu CVM](./images/vm-image-find-ubuntu-cvm.png)
![Select Ubuntu CVM](./images/vm-image-select-ubuntu-cvm-gen2.png) | -| 4. Configure **Administrator account**:
- **Authentication type**: SSH public key
- **Username**: `azureuser`
- **SSH public key source**: **Use existing key**
- Open Cloud Shell
- Run this command to get SSH public key:
`az keyvault secret show --name ssh-public-key --vault-name --query value -o tsv`
- **SSH Public Key**: Copy the output into the `SSH Public Key` field | | ![Use Public key](./images/vm-basics-use-publickey.png) | -| 5. Configure **Inbound port rules**:
- **Public inbound ports**: None | | | - -### Disks Tab - -| Step | Action | Screenshot | -|------|--------|------------| -| 6. Click **Next: Disks >**
- **OS disk type**: Keep default (Premium SSD) | | | - -### Networking Tab - -| Step | Action | Screenshot | -|------|--------|------------| -| 7. Click **Next: Networking >**
- **Virtual network**: `vm-ubuntu-cvm-vnet`
- **Subnet**: `vm-subnet (10.10.0.0/26)`
- **Public IP**: **None**
- **NIC network security group**: Basic
- **Public inbound ports**: None | | ![Virtual machine network interface](./images/vm-network-interface.png) | - -### Management Tab - -| Step | Action | Screenshot | -|------|--------|------------| -| 8. Click **Next: Management >**
- Scroll to **Identity**
- **System assigned managed identity**: ✅ Enable | | ![Assign System MI](./images/vm-system-managed-identity.png) | - -### Advanced Tab - -| Step | Action | Screenshot | -|------|--------|------------| -| 9. Click **Next: Advanced >** | | | - -### Review and Create - -| Step | Action | Screenshot | -|------|--------|------------| -| 10. Click **Review + create**
11. Review all settings
12. Click **Create**
13. Wait for deployment (5-10 minutes) | | | - ---- - -## Task 7: Deploy Azure Bastion for Secure Access - -💡 **Create Azure Bastion to enable secure RDP/SSH connectivity without exposing VMs to the public internet.** - -| Step | Action | Screenshot | -|------|--------|------------| -| 1. In the search bar, type **"Bastions"** and select it
2. Click **+ Create** | | ![Azure Bastion](./images/azbastion-resource.png) | -| 3. Fill in the **Basics** tab:
- **Subscription**: Select your subscription
- **Resource group**: `rg-cc-attendee01`
- **Name**: `bastion-northeurope`
- **Region**: `North Europe`
- **Tier**: Basic
- **Virtual network**: `vm-ubuntu-cvm-vnet`
- **Subnet**: `AzureBastionSubnet` (should be auto-selected)
- **Public IP address**: Create new
- **Public IP address name**: `bastion-northeurope-ip`
- Click **OK** | | ![Create Azure Bastion](./images/azbastion-create.png) | -| 4. Click **Review + create**
5. Click **Create**
6. **Wait 5-10 minutes** for Bastion deployment (this takes time) | | | - ---- - -## Task 8: Connect to VM, Build, and Run Attestation Client - -💡 **Connect to the Confidential VM through Bastion, install dependencies, build the attestation client, and verify cryptographic proof of the VM's trusted state.** - -| Step | Action | Screenshot | -|------|--------|------------| -| 1. Navigate to **Virtual machines**
2. Click on **vm-ubuntu-cvm**
3. Click **Connect** > **Connect via Bastion** | | ![Connect to VM via Bastion](./images/vm-connect-via-bastion.png) | -| 4. Fill in the Bastion connection form:
- **Authentication Type**: **SSH Private Key from Azure Key Vault**
- **Username**: `azureuser`
- **Azure Key Vault**: Select your Key Vault `kv-cc-a1b2c3`
- **Azure Key Vault Secret**: `ssh-private-key` | | ![Fill Bastion settings](./images/vm-bastion-settings.png) | -| 5. Click **Connect**
6. A new browser tab will open with a terminal connection | | | +|------|--------|-----------| +| 1. In your resource group, click on the Confidential VM (**Confidential VM** from your dashboard)
2. Click **Connect** > **Connect via Bastion** | | ![Connect to VM via Bastion](./images/vm-connect-via-bastion.png) | +| 3. Fill in the Bastion connection form:
- **Authentication Type**: `Password`
- **Username**: value of **Confidential VM Admin Username** from your dashboard
- **Password**: value of **Confidential VM Admin Password** from your dashboard | | | +| 4. Click **Connect**
5. A new browser tab will open with a terminal connection | | | ### Install Dependencies and Run Attestation | Step | Action | Screenshot | |------|--------|------------| -| 7. In the terminal, run the following commands:

**NOTE**: if you encounter any prompts to restart services, just hit \ to confirm and continue to the next command.


# Install system dependencies
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
export APT_LISTCHANGES_FRONTEND=none

sudo -E apt-get update -y
sudo -E apt-get upgrade -y

sudo -E apt-get install -y build-essential libcurl4-openssl-dev libjsoncpp-dev libboost-all-dev nlohmann-json3-dev cmake wget git jq

# Clone the attestation repository
git clone https://github.com/Azure/confidential-computing-cvm-guest-attestation.git

# Download the attestation package
wget https://packages.microsoft.com/repos/azurecore/pool/main/a/azguestattestation1/azguestattestation1_1.1.2_amd64.deb

# Install the attestation package
sudo dpkg -i azguestattestation1_1.1.2_amd64.deb

# Build the attestation client
cd confidential-computing-cvm-guest-attestation/cvm-attestation-sample-app/
cmake .
make
| | ![Bastion ssh session](./images/vm-bastion-session.png)
![Build Attestation Client](./images/vm-build-attest-client.png) | +| 6. In the terminal, run the following commands:

**NOTE**: if you encounter any prompts to restart services, just hit \ to confirm and continue to the next command.


# Install system dependencies
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
export APT_LISTCHANGES_FRONTEND=none

sudo -E apt-get update -y
sudo -E apt-get upgrade -y

sudo -E apt-get install -y build-essential libcurl4-openssl-dev libjsoncpp-dev libboost-all-dev nlohmann-json3-dev cmake wget git jq

# Clone the attestation repository
git clone https://github.com/Azure/confidential-computing-cvm-guest-attestation.git

# Download the attestation package
wget https://packages.microsoft.com/repos/azurecore/pool/main/a/azguestattestation1/azguestattestation1_1.1.2_amd64.deb

# Install the attestation package
sudo dpkg -i azguestattestation1_1.1.2_amd64.deb

# Build the attestation client
cd confidential-computing-cvm-guest-attestation/cvm-attestation-sample-app/
cmake .
make
| | ![Bastion ssh session](./images/vm-bastion-session.png)
![Build Attestation Client](./images/vm-build-attest-client.png) | | Step | Action | Screenshot | |------|--------|------------| -| 8. Get your custom attestation provider URI from the Azure Portal:
- Navigate to **All Resources**
- Search for your attestation provider (e.g., `attesta1b2c3`)
- Click on the attestation provider
- Copy the **Attest URI** from the Overview page | | ![Portal - Attestation Service](./images/portal-attestation-service.png)
![Portal - Copy Attestation URI](./images/portal-attestation-provider-copy-uri.png) | -| 9. Back in the Bastion terminal, run the attestation client with your custom provider URI:

# Replace with your actual Attestation URI
sudo ./AttestationClient -a https://attesta1b2c3.neu.attest.azure.net -o token \| jq -R 'split(".") \| .[0],.[1] \| @base64d \| fromjson'


🔑 **Why Use a Custom Attestation Provider?**:
- **Custom Policies**: Define organization-specific attestation policies
- **Audit Control**: Maintain your own attestation logs and policies
- **Compliance**: Meet regulatory requirements for attestation service ownership
- **Isolation**: Separate attestation infrastructure from shared services | | ![Attestation Client - Specified Provider](./images/attestation-specified-provider.png) | +| 7. Use the **Attestation Provider** URI from your lab dashboard note field (e.g., `https://..attest.azure.net`) | | | +| 8. Back in the Bastion terminal, run the attestation client with your custom provider URI:

# Set the Attestation URI from your dashboard note field
ATTESTATION_URI=""
sudo ./AttestationClient -a $ATTESTATION_URI -o token \| jq -R 'split(".") \| .[0],.[1] \| @base64d \| fromjson'


🔑 **Why Use a Custom Attestation Provider?**:
- **Custom Policies**: Define organization-specific attestation policies
- **Audit Control**: Maintain your own attestation logs and policies
- **Compliance**: Meet regulatory requirements for attestation service ownership
- **Isolation**: Separate attestation infrastructure from shared services | | ![Attestation Client - Specified Provider](./images/attestation-specified-provider.png) | --- -## Task 9: Understanding Attestation Results and Production Use +## Task 4: Understanding Attestation Results and Production Use 💡 **Understanding what the attestation token proves and how to use it in production workloads.** @@ -337,57 +209,31 @@ def process_sensitive_data(customer_data): --- -## Task 10: Clean Up Resources - -💡 **Delete all resources to avoid ongoing charges.** - -### Delete Resource Group - -1. Navigate to **Resource groups** -2. Click on `rg-cc-attendee01` -3. Click **Delete resource group** -4. Type the resource group name to confirm: `rg-cc-attendee01` -5. Click **Delete** -6. Wait for deletion to complete (5-10 minutes) - -> **💡 Tip**: Deleting the resource group will remove all resources created in this workshop - -⚠️ **Warning**: This command will permanently delete all resources in the resource group including the VM, Key Vault, Bastion, and all associated resources. - ---- - ## Troubleshooting ### Cannot Connect via Bastion -- **Check**: Bastion deployment is complete (Status: Succeeded) -- **Check**: VM is running (Status: Running) -- **Check**: You have Key Vault Secrets Officer role on the Key Vault -- **Wait**: 2-3 minutes after Bastion deployment before connecting - -### VM Creation Failed - -- **Check**: Quota availability for DC-series VMs in North Europe -- **Try**: Different region (e.g., West Europe, UK South) -- **Try**: Smaller VM size (Standard_DC2es_v5) +- **Check**: VM is running (Status: Running) in the Azure Portal +- **Check**: You are using the correct username and password from your dashboard +- **Wait**: 2-3 minutes after the lab starts before connecting ### Attestation Client Build Fails - **Check**: All dependencies were installed successfully - **Try**: Re-run the apt-get commands -- **Check**: VM has internet connectivity +- **Check**: VM has internet connectivity (`curl -I https://packages.microsoft.com`) -### Key Vault Access Denied +### Attestation Client Fails to Connect to MAA -- **Check**: RBAC role assignment completed successfully -- **Wait**: 2-3 minutes for permissions to propagate -- **Verify**: You're logged in with the correct account +- **Check**: The Attestation URI is correct (copy from dashboard note field, not the provider name) +- **Check**: The URI includes the `https://` scheme (e.g., `https://..attest.azure.net`) +- **Verify**: The VM has outbound internet access --- ## Key Takeaways -In this challenge, you successfully implemented and validated Azure Confidential Computing with guest attestation using the Azure Portal. Here are the key concepts and best practices: +In this challenge, you validated Azure Confidential Computing with guest attestation on a pre-provisioned Confidential VM using the Azure Portal. Here are the key concepts and best practices: ### Confidential Computing Fundamentals @@ -407,11 +253,11 @@ In this challenge, you successfully implemented and validated Azure Confidential ### Security Architecture -✅ **Defense in Depth** - Combining multiple security layers (no public IPs, Azure Bastion, Key Vault, hardware encryption, attestation) +✅ **Defense in Depth** - Combining multiple security layers (no public IPs, Azure Bastion, hardware encryption, attestation) -✅ **Secure Key Management** - SSH keys stored exclusively in Azure Key Vault with RBAC controls, never persisted locally +✅ **Custom Attestation Provider** - A dedicated MAA provider enables organization-specific policies, audit logs, and compliance requirements separate from shared services -✅ **Network Isolation** - VMs without public IPs, accessed only through Azure Bastion with Azure AD authentication +✅ **Network Isolation** - Confidential VM has no public IP; all access routes through Azure Bastion with lab-scoped credentials ### Production Best Practices @@ -469,19 +315,12 @@ In this challenge, you successfully implemented and validated Azure Confidential - [Microsoft Azure Attestation](https://learn.microsoft.com/azure/attestation/overview) - [AMD SEV-SNP Technology](https://www.amd.com/en/developer/sev.html) - [Azure Bastion Documentation](https://learn.microsoft.com/azure/bastion/bastion-overview) -- [Azure Key Vault Best Practices](https://learn.microsoft.com/azure/key-vault/general/best-practices) --- ## Technical Notes -1. **Resource Naming**: Ensure globally unique names for Key Vault and Attestation Provider -2. **SSH Keys**: Generated keys are securely stored in Key Vault and never exposed locally -3. **Security**: No public IPs are used; all access is through Azure Bastion -4. **Pricing**: VMs use standard pricing; remember to delete resources after the workshop -5. **Bastion**: Basic SKU is sufficient for this workshop -6. **Regions**: North Europe has good availability for DC-series VMs -7. **Attestation**: The attestation token provides cryptographic proof of VM integrity -8. **Deployment Time**: Azure Bastion typically takes 5-10 minutes to deploy -9. **Auto-Created Resources**: NSGs, NICs, and OS Disks are created automatically by the portal -10. **RBAC Propagation**: Key Vault RBAC permissions may take 2-3 minutes to propagate +1. **Security**: No public IPs are used; all VM access is through Azure Bastion +2. **Regions**: The platform uses sovereign-eligible regions with Confidential Compute availability and regional fallback for the Attestation Provider +3. **Attestation**: The attestation token provides cryptographic proof of VM integrity +4. **Credentials**: Use username/password from your lab dashboard for Bastion login diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/solution-04.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/solution-04.md index 457a86966..8a7819c51 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/solution-04.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/solution-04.md @@ -2,19 +2,18 @@ [Previous Challenge Solution](../challenge-03/solution-03.md) - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-05/solution-05.md) -**Estimated Duration:** 90-120 minutes +**Estimated Duration:** 45-60 minutes -> 💡 **Objective:** Learn how to implement and validate guest attestation on Azure Confidential VMs to ensure business logic only executes in trusted, hardware-backed confidential computing environments. You will deploy a Confidential VM, configure secure access through Azure Bastion, build and run attestation client applications, and verify cryptographic proof of VM integrity before processing sensitive workloads. +> 💡 **Objective:** Validate guest attestation on a pre-provisioned Azure Confidential VM to confirm that workloads execute only in trusted, hardware-backed confidential computing environments. You will inspect the VM's security configuration, connect through Azure Bastion, build and run attestation client applications, and verify cryptographic proof of VM integrity using your dedicated Microsoft Azure Attestation (MAA) provider. ## Prerequisites Please ensure that you successfully verified the [General prerequisites](../../Readme.md#general-prerequisites) before continuing with this challenge. -- Azure subscription with Contributor permissions on your resource group -- Azure CLI >= 2.54 or access to Azure Portal -- **Linux/Bash environment** — Azure Cloud Shell (Bash), WSL2 on Windows, or a native Linux/macOS terminal -- Basic understanding of Azure Virtual Machines, networking, and SSH key authentication -- Basic understanding of confidential computing concepts +- Access to the Azure Portal or Azure CLI +- Lab dashboard credentials (Resource Group Name, Confidential VM, Confidential VM Admin Username, Confidential VM Admin Password, Attestation Provider, Sovereign Lab Bastion) +- **Linux/Bash environment** — Azure Cloud Shell (Bash), WSL2 on Windows, or a native Linux/macOS terminal (for optional CLI steps) +- Basic understanding of Azure Virtual Machines, networking, and confidential computing concepts ## Scenario Context @@ -80,267 +79,128 @@ The sample application and deployment patterns have been adapted for this MicroH --- -## Task 1: Understand the Deployment Architecture +## Task 1: Locate Pre-Provisioned Resources and Verify CVM Security Settings -💡 **Before deploying resources, it's important to understand the security architecture and components you'll be creating.** +💡 **The platform has pre-provisioned a private Ubuntu Confidential VM, a shared Azure Bastion, and a custom Attestation Provider in your resource group. Inspect the VM's security configuration before connecting.** -### Resources Being Deployed +### Pre-Provisioned Resources -The following resources will be created in the `North Europe` Azure region: +Locate the following values on your lab dashboard before proceeding: -- **1 Attestation Provider** - Microsoft Azure Attestation (MAA) service for verifying TEE integrity -- **1 Virtual Network** - Isolated network with VM and Bastion subnets -- **1 Confidential VM** - Ubuntu 22.04 with AMD SEV-SNP hardware encryption -- **1 Azure Bastion** - Secure remote access without public IP exposure -- **1 Azure Key Vault** - Secure storage for SSH keys -- **Associated Resources** - NSGs, NICs, OS Disks (auto-created) - -### Security Architecture - -This setup implements a zero-trust security model: - -- **No Public IPs on VMs** - VMs are not directly accessible from internet -- **Azure Bastion (Basic SKU)** - Provides secure RDP/SSH access through Azure Portal -- **Azure Key Vault** - Stores SSH private keys securely with RBAC controls -- **Fresh SSH Key Pair** - Generated specifically for this deployment, never stored locally -- **Hardware-Based Encryption** - AMD SEV-SNP encrypts VM memory at the hardware level -- **Secure Boot** - Protects boot chain integrity from firmware through kernel - -🔑 **Security Best Practice**: This architecture ensures VM access is authenticated through Azure AD, encrypted with TLS, and never exposes SSH directly to the internet. - ---- - -## Task 2: Set Up Your Environment and Deploy Infrastructure - -💡 **You'll create the foundational Azure resources including Resource Group, Key Vault, SSH keys, Attestation Provider, Virtual Network, Confidential VM, and Azure Bastion.** - -> [!IMPORTANT] -> **Prerequisite — Challenge 1 policy adjustment:** This challenge deploys a **public IP address** for Azure Bastion and creates resources that require tags. If you completed Challenge 1, make sure you ran the **"Preparing for Next Challenges"** section at the end of that walkthrough to switch the tag-requirement and public-IP-block policies to **DoNotEnforce** mode. Otherwise the deployments below will fail. +| Dashboard Field | Description | +|---|---| +| **Resource Group Name** | Your dedicated resource group containing all lab resources | +| **Sovereign Lab Region** | Azure region where the CVM and Attestation Provider are deployed | +| **Confidential VM** | Name of the pre-provisioned private Ubuntu 22.04 Confidential VM | +| **Confidential VM Admin Username** | Username for Bastion login | +| **Confidential VM Admin Password** | Password for Bastion login | +| **Attestation Provider** | Name of the custom MAA provider (note field contains the full Attest URI) | +| **Sovereign Lab Bastion** | Name of the shared Standard Azure Bastion for private VNet access | > [!IMPORTANT] -> The Azure CLI commands in this walkthrough use **bash** syntax and will not work directly in PowerShell. Use **Azure Cloud Shell (Bash)** for the best experience. If running locally on Windows, use **WSL2** (Windows Subsystem for Linux) to run a bash shell. You can install the Azure CLI inside WSL with: -> -> ```bash -> curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash -> ``` +> Copy the **Attestation Provider** Attest URI from the dashboard note field — you will need it in Task 4 when running the attestation client inside the VM. ---- - -### Step 1: Configure Environment Variables - -Set up the variables that will be used throughout the deployment: +### Step 1: Verify Confidential VM Security Settings via Azure CLI ```bash -# Set common variables -# Customize ATTENDEE_ID for each participant -RESOURCE_GROUP="labuser-xx" # Change this for each participant (e.g., labuser-01, labuser-02, ...) - -ATTENDEE_ID="${RESOURCE_GROUP}" -# Generate a short hash from ATTENDEE_ID with random component for uniqueness -HASH_SUFFIX=$(echo -n "${ATTENDEE_ID}-${RANDOM}-${RANDOM}" | md5sum | cut -c1-8) - -LOCATION="northeurope" -ADMIN_USERNAME="azureuser" -KEYVAULT_NAME="kv-cc-${HASH_SUFFIX}" # Must be globally unique -SSH_KEY_NAME="cc-${ATTENDEE_ID}-key" -ATTESTATION_NAME="attest${HASH_SUFFIX}" -``` +# Set variables from your dashboard +RESOURCE_GROUP="" +VM_NAME="" -🔑 **Best Practice**: Using hash-based suffixes ensures globally unique resource names even if multiple participants use similar attendee IDs. - -> [!WARNING] -> If your Azure Cloud Shell session times out (e.g. during a break), the variables defined above will be lost and must be re-defined before continuing. We recommend saving them in a local text file on your machine so you can quickly copy and paste them back into a new session. - -### Step 2: Create Key Vault - -Create a Key Vault with RBAC-based permissions: - -```bash -# Create Key Vault with Azure RBAC permission model -az keyvault create \ - --name $KEYVAULT_NAME \ +# Verify security type, secure boot, and vTPM +az vm show \ --resource-group $RESOURCE_GROUP \ - --location $LOCATION \ - --sku standard \ - --enable-rbac-authorization true \ - --enabled-for-deployment true \ - --enabled-for-template-deployment true - -# Get current user's object ID -CURRENT_USER_ID=$(az ad signed-in-user show --query id -o tsv) - -# Assign Key Vault Secrets Officer role to current user -az role assignment create \ - --role "Key Vault Secrets Officer" \ - --assignee $CURRENT_USER_ID \ - --scope $(az keyvault show --name $KEYVAULT_NAME --resource-group $RESOURCE_GROUP --query id -o tsv) - -# Wait for RBAC permissions to propagate -echo "Waiting for RBAC permissions to propagate..." -sleep 30 + --name $VM_NAME \ + --query "{securityType: securityProfile.securityType, secureBootEnabled: securityProfile.uefiSettings.secureBootEnabled, vTpmEnabled: securityProfile.uefiSettings.vTpmEnabled}" \ + -o table ``` -🔑 **Best Practice**: Using Azure RBAC for Key Vault instead of access policies provides more granular control and better integration with Azure AD identity governance. - -### Step 3: Generate and Store SSH Keys - -Generate SSH key pair and store securely in Key Vault: +Expected output: -```bash -# Generate SSH key pair using temporary file -SSH_TEMP_FILE="/tmp/cc_ssh_key_${ATTENDEE_ID}_${RANDOM}" -ssh-keygen -t rsa -b 4096 -f "$SSH_TEMP_FILE" -N "" -C "microhack-cc" - -# Store private key in Key Vault -az keyvault secret set \ - --vault-name $KEYVAULT_NAME \ - --name "ssh-private-key" \ - --file "$SSH_TEMP_FILE" - -# Store public key in Key Vault -az keyvault secret set \ - --vault-name $KEYVAULT_NAME \ - --name "ssh-public-key" \ - --file "${SSH_TEMP_FILE}.pub" - -# Read public key for VM creation -SSH_PUBLIC_KEY=$(cat "${SSH_TEMP_FILE}.pub") - -# Clean up temporary files -rm -f "$SSH_TEMP_FILE" "${SSH_TEMP_FILE}.pub" - -echo "SSH key pair generated and stored in Key Vault: $KEYVAULT_NAME" -echo "Public key: $SSH_PUBLIC_KEY" ``` - -🔑 **Security Insight**: SSH keys are never stored persistently on your local machine. They exist only in Azure Key Vault, reducing the risk of key compromise. - -### Step 4: Create Attestation Provider - -Create the Microsoft Azure Attestation provider: - -```bash -# Create Attestation Provider -az attestation create \ - --name $ATTESTATION_NAME \ - --resource-group $RESOURCE_GROUP \ - --location $LOCATION +SecurityType SecureBootEnabled VTpmEnabled +----------------- ------------------- ----------- +ConfidentialVM True True ``` -💡 **Understanding MAA**: Microsoft Azure Attestation is a unified solution for remotely verifying the trustworthiness of platforms and integrity of binaries running inside them. - -### Step 5: Create Virtual Network with Subnets - -Create virtual network with separate subnets for VMs and Azure Bastion: +### Step 2: Confirm No Public IP Address ```bash -# Create Virtual Network with VM and Bastion subnets for CVM -az network vnet create \ +# Verify the VM has no public IP (private access via Bastion only) +az vm list-ip-addresses \ --resource-group $RESOURCE_GROUP \ - --name "vm-ubuntu-cvm-vnet" \ - --location $LOCATION \ - --address-prefix "10.10.0.0/24" \ - --subnet-name "vm-subnet" \ - --subnet-prefix "10.10.0.0/26" - -# Create Bastion subnet (must be named AzureBastionSubnet) -az network vnet subnet create \ - --resource-group $RESOURCE_GROUP \ - --vnet-name "vm-ubuntu-cvm-vnet" \ - --name "AzureBastionSubnet" \ - --address-prefix "10.10.0.64/26" + --name $VM_NAME \ + --query "[].virtualMachine.network.publicIpAddresses" \ + -o table ``` -🔑 **Networking Best Practice**: Separating VM and Bastion subnets provides network segmentation and allows for different security policies. +The output should be empty — no public IP is assigned to the Confidential VM. -### Step 6: Deploy Confidential VM +> **Why these settings matter:** +> - `ConfidentialVM` security type activates AMD SEV-SNP hardware memory encryption +> - Secure Boot prevents unauthorized firmware or bootloaders from running +> - vTPM is required for cryptographic attestation +> - No public IP enforces network isolation; all access routes through Azure Bastion -Create the Confidential VM with AMD SEV-SNP hardware encryption: +--- -```bash -# Create Confidential VM - No public IP -az vm create \ - --resource-group $RESOURCE_GROUP \ - --name "vm-ubuntu-cvm" \ - --location $LOCATION \ - --size "Standard_DC2as_v6" \ - --admin-username $ADMIN_USERNAME \ - --ssh-key-value "$SSH_PUBLIC_KEY" \ - --authentication-type ssh \ - --enable-vtpm true \ - --image "Canonical:0001-com-ubuntu-confidential-vm-jammy:22_04-lts-cvm:latest" \ - --security-type "ConfidentialVM" \ - --os-disk-security-encryption-type "VMGuestStateOnly" \ - --enable-secure-boot true \ - --vnet-name "vm-ubuntu-cvm-vnet" \ - --subnet "vm-subnet" \ - --public-ip-address "" - -# Enable system-assigned managed identity for CVM -az vm identity assign \ - --resource-group $RESOURCE_GROUP \ - --name "vm-ubuntu-cvm" -``` +## Task 2: Connect to the Confidential VM via Azure Bastion -💡 **Key Configuration Details**: +💡 **Connect to the private Confidential VM through your pre-provisioned Azure Bastion using username/password credentials from your dashboard.** -- `--security-type "ConfidentialVM"` - Enables hardware-based confidential computing -- `--os-disk-security-encryption-type "VMGuestStateOnly"` - Encrypts VM guest state with platform-managed keys -- `--enable-secure-boot true` - Protects boot integrity -- `--enable-vtpm true` - Enables virtual Trusted Platform Module for attestation -- `--public-ip-address ""` - No public IP for enhanced security +> [!IMPORTANT] +> The Azure CLI commands in this walkthrough use **bash** syntax and will not work directly in PowerShell. Use **Azure Cloud Shell (Bash)** or a local bash terminal. -### Step 7: Deploy Azure Bastion +### Step 1: Connect via Azure Bastion (Portal) -Create Azure Bastion for secure remote access: +1. Navigate to the [Azure Portal](https://portal.azure.com) +2. Open your resource group (**Resource Group Name** from your dashboard) +3. Click on the Confidential VM (**Confidential VM** from your dashboard) +4. Click **Connect** > **Connect via Bastion** +5. In the connection panel: + - **Authentication Type**: `Password` + - **Username**: value of **Confidential VM Admin Username** from your dashboard + - **Password**: value of **Confidential VM Admin Password** from your dashboard +6. Click **Connect** — a new browser tab opens with the VM terminal -```bash -# Create Public IP for Bastion -az network public-ip create \ - --resource-group $RESOURCE_GROUP \ - --name "bastion-ip" \ - --location $LOCATION \ - --sku "Standard" \ - --allocation-method "Static" +![Connect via Azure Bastion](./images/vm-connect-via-bastion.png) -# Create Azure Bastion (Basic SKU) +> [!NOTE] +> All commands in Tasks 3 and 4 run **inside this VM terminal**, not in Azure Cloud Shell or your local machine. -echo "Bastion deployment initiated (this may take 5-10 minutes)" +### Alternative: Connect via Azure CLI Bastion Tunnel -az network bastion create \ +```bash +# Set variables from your dashboard +RESOURCE_GROUP="" +VM_NAME="" +BASTION_NAME="" +ADMIN_USERNAME="" + +# Get the VM resource ID +VM_RESOURCE_ID=$(az vm show --resource-group $RESOURCE_GROUP --name $VM_NAME --query id -o tsv) + +# Open SSH tunnel via Bastion +az network bastion ssh \ + --name $BASTION_NAME \ --resource-group $RESOURCE_GROUP \ - --name "bastion" \ - --location $LOCATION \ - --vnet-name "vm-ubuntu-cvm-vnet" \ - --public-ip-address "bastion-ip" \ - --sku "Basic" + --target-resource-id $VM_RESOURCE_ID \ + --auth-type "password" \ + --username $ADMIN_USERNAME ``` -⏱️ **Deployment Time**: Azure Bastion typically takes 5-10 minutes to deploy. You can proceed with reviewing the next sections while waiting. - --- -## Task 3: Connect to Confidential VM and Install Attestation Tools +## Task 3: Install System Dependencies and Attestation Package -💡 **Now you'll connect to the Confidential VM through Azure Bastion and install the necessary dependencies to build and run the attestation client.** +💡 **Install the required build tools and attestation library on the Confidential VM.** -### Step 1: Connect via Azure Bastion +> **📝 Note: These commands run ON the Linux VM itself inside the Bastion terminal (not on your local machine or in Azure Cloud Shell)** -> **📝 Note: These commands run ON the Linux VM itself after connecting via Bastion (not on your local machine or in Azure Cloud Shell)** +> [!NOTE] +> If you encounter prompts to restart services during installation, press `` to confirm and continue. -1. Navigate to the Azure Portal -2. Go to **Virtual Machines** > **vm-ubuntu-cvm** (select the one in your resource group) -3. Click **Connect** > **Connect via Bastion** -4. **Authentication Type**: SSH Private Key from Azure Key Vault -5. **Username**: `azureuser` -6. **Azure Key Vault Secret**: Select your Key Vault and choose `ssh-private-key` -7. Click **Connect** - -![Connect via Azure Bastion](./images/vm-connect-via-bastion.png) - -### Step 2: Install System Dependencies and Build Tools - -Once connected to the VM via Bastion, run the following commands: +### Step 1: Install System Dependencies and Build Tools ```bash # Install system dependencies @@ -361,14 +221,14 @@ sudo -E apt-get install -y build-essential libcurl4-openssl-dev libjsoncpp-dev l - CMake for build orchestration - jq for JSON parsing and token inspection -### Step 3: Clone Attestation Repository +### Step 2: Clone Attestation Repository ```bash # Clone the attestation repository git clone https://github.com/Azure/confidential-computing-cvm-guest-attestation.git ``` -### Step 4: Install Azure Guest Attestation Package +### Step 3: Download and Install Azure Guest Attestation Package ```bash # Download the attestation package @@ -418,28 +278,19 @@ make ### Step 2: Run Attestation and Inspect JWT Token -To use the dedicated Attestation Provider you created in Task 2 Step 4, you need to pass its URI using the `-a` argument. - -#### Retrieve Your Attestation URI via Azure CLI from your Azure CLI session in Cloud Shell or your local machine +To use the dedicated Attestation Provider from your lab dashboard, pass its Attest URI using the `-a` argument. -```bash -# Retrieve your attestation provider URI -ATTESTATION_URI=$(az attestation show \ - --name $ATTESTATION_NAME \ - --resource-group $RESOURCE_GROUP \ - --query "attestUri" \ - -o tsv) -echo $ATTESTATION_URI -``` +#### Retrieve Your Attestation Provider URI -Alternatively, you may also retrieve the URL from the Azure portal (navigate to your resource group and click on the Attestation provider resource): +Use the **Attestation Provider** URI from your lab dashboard note field: -![Attestation Client - Specified Provider](./images/attestation-provider.png) +> On your dashboard, the **Attestation Provider** row shows the provider name as the value and the full Attest URI in the note field (e.g., `https://..attest.azure.net`). #### Run attestation with your custom provider (inside the Linux VM) ```bash -ATTESTATION_URI= +# Set the Attestation URI from your dashboard note field +ATTESTATION_URI="" # Run attestation with your custom provider sudo ./AttestationClient -a $ATTESTATION_URI -o token | jq -R 'split(".") | .[0],.[1] | @base64d | fromjson' ``` @@ -534,11 +385,11 @@ In this challenge, you successfully implemented and validated Azure Confidential ### Security Architecture -✅ **Defense in Depth** - Combining multiple security layers (no public IPs, Azure Bastion, Key Vault, hardware encryption, attestation) +✅ **Defense in Depth** - Combining multiple security layers (no public IPs, Azure Bastion, hardware encryption, attestation) -✅ **Secure Key Management** - SSH keys stored exclusively in Azure Key Vault with RBAC controls, never persisted locally +✅ **Custom Attestation Provider** - A dedicated MAA provider enables organization-specific policies, audit logs, and compliance requirements separate from shared services -✅ **Network Isolation** - VMs without public IPs, accessed only through Azure Bastion with Azure AD authentication +✅ **Network Isolation** - Confidential VM has no public IP; all access routes through Azure Bastion with lab-scoped credentials ### Production Best Practices @@ -596,41 +447,12 @@ In this challenge, you successfully implemented and validated Azure Confidential - [Microsoft Azure Attestation](https://learn.microsoft.com/azure/attestation/overview) - [AMD SEV-SNP Technology](https://www.amd.com/en/developer/sev.html) - [Azure Bastion Documentation](https://learn.microsoft.com/azure/bastion/bastion-overview) -- [Azure Key Vault Best Practices](https://learn.microsoft.com/azure/key-vault/general/best-practices) --- ## Technical Notes -1. **Attendee Identification**: - - Set `ATTENDEE_ID` to a unique value for each participant (e.g., attendee01, attendee02, etc.) - - A hash-based suffix is automatically generated from ATTENDEE_ID plus random numbers for globally unique resources - - The hash output is alphanumeric (hexadecimal) and safe for all Azure resource naming requirements - - This prevents resource name collisions even if multiple students use the same ATTENDEE_ID - -2. **SSH Key Generation**: - - Fresh SSH key pair is generated and stored directly in Key Vault - - No local SSH key files are created or retained - - Keys are only accessible through Key Vault - -3. **Security Compliance**: - - VMs have **no public IPs** - not directly accessible from internet - - Access only through Azure Bastion (Basic SKU) - - Private SSH key stored securely in Azure Key Vault - - Bastion uses Key Vault integration for authentication - -4. **Network Range**: - - VNet (North Europe): `10.10.0.0/24` - - VM Subnet: `10.10.0.0/26` - - Bastion Subnet: `10.10.0.64/26` - -5. **VM Pricing**: VMs use standard (pay-as-you-go) pricing for microhack reliability - -6. **Bastion Deployment**: Takes 5-10 minutes to complete. You can proceed with other steps while it deploys - -7. **Auto-Created Resources**: - - NSGs (Network Security Groups) - Created automatically by `az vm create` - - NICs (Network Interfaces) - Created automatically and named `VMNic` - - OS Disks - Created automatically with the VMs - -8. **Key Vault Access**: Ensure your Azure account has appropriate permissions to read secrets from Key Vault when using Bastion +1. **Security**: Confidential VM has no public IP; all access routes through Azure Bastion +2. **Credentials**: Use the username and password from your lab dashboard for Bastion login +3. **Attestation URI**: Copy the Attest URI from the dashboard note field — it follows the pattern `https://..attest.azure.net` +4. **Regional Fallback**: The Attestation Provider is deployed with regional fallback; the URI region code reflects the actual deployment region diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/portal-guide.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/portal-guide.md index 6c67b1e15..262415ebb 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/portal-guide.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/portal-guide.md @@ -1,9 +1,9 @@ # Walkthrough Challenge 5 - Encryption in use with Confidential VMs/Node Pools in Azure Kubernetes Service (AKS) # Azure Portal Guide -**Estimated Duration:** 90-120 minutes +**Estimated Duration:** 45-60 minutes -> 💡 **Objective:** Learn how to deploy and validate guest attestation on Azure Confidential VMs in AKS to ensure business logic only executes in trusted, hardware-backed confidential computing environments. You will create an AKS cluster through the Azure Portal, add a Confidential VM node pool, deploy attestation workloads, and verify cryptographic proof of node integrity before processing sensitive operations. +> 💡 **Objective:** Learn how to validate guest attestation on Azure Confidential VMs in AKS to ensure business logic only executes in trusted, hardware-backed confidential computing environments. You will navigate to the pre-provisioned AKS cluster and Confidential VM node pool through the Azure Portal, deploy attestation workloads via Cloud Shell, and verify cryptographic proof of node integrity before processing sensitive operations. --- @@ -12,21 +12,23 @@ Please ensure that you successfully verified the [General prerequisites](../../Readme.md#general-prerequisites) before continuing with this challenge. - Access to Azure Portal (https://portal.azure.com) -- Azure subscription with permissions to create AKS clusters, node pools, and register preview features +- Azure subscription with Reader/Contributor permissions on your resource group (pre-assigned) - Azure Cloud Shell access (for kubectl commands) - Basic understanding of Kubernetes concepts (pods, deployments, node pools) - Familiarity with Azure Portal navigation - Basic understanding of confidential computing concepts -**Configuration Variables:** Throughout this challenge, replace these placeholders with your values: -- **ATTENDEE_ID**: `labuser-xx` (customize for each participant, e.g., labuser-01, labuser-02) -- **Region**: North Europe -- **Resource Group**: `${ATTENDEE_ID}` -- **AKS Cluster Name**: `aks-cvmcluster-` (Azure will help ensure uniqueness) -- **Confidential Node Pool Name**: `cvmnodepool` -- **VM Size for Confidential Nodes**: `Standard_DC2as_v5` +**Pre-Provisioned Infrastructure:** The shared lab automation has already deployed an AKS cluster with a standard system node pool and an AMD SEV-SNP Confidential VM user node pool named `cvmnodepool`. The exact v5 or v6 size is selected from subscription availability and quota. This cluster is shared with Challenge 7 — do not delete it. Retrieve the following values from your lab dashboard credentials: -💡 **Note**: Whenever you see `${ATTENDEE_ID}` or `` in commands or configuration, replace them with your actual values. +| Dashboard Credential | Description | +|---|---| +| **Resource Group Name** | Your dedicated resource group | +| **Sovereign Lab Region** | Region selected after capacity checks | +| **Confidential VM Size** | AMD SEV-SNP size selected during shared preparation | +| **Sovereign Lab AKS Cluster** | Name of the pre-provisioned AKS cluster | +| **AKS Confidential Node Pool** | Name of the Confidential VM node pool (`cvmnodepool`) | + +💡 **Note**: Whenever you see `${ATTENDEE_ID}` or a cluster/node pool name placeholder in these steps, replace them with the values from your dashboard credentials. ## Scenario Context @@ -40,7 +42,7 @@ Your mandate includes: - **Zero Trust for Kubernetes**: Validate the integrity of worker nodes before deploying sensitive workloads - **Compliance and Auditability**: Provide cryptographic evidence that workloads execute in compliant infrastructure -In this challenge, you'll deploy Azure Confidential Computing node pools in AKS using the Azure Portal. You'll configure a confidential VM node pool with AMD SEV-SNP technology, deploy attestation-aware workloads, and verify that containers run in hardware-protected environments before processing sensitive operations. +In this challenge, you'll inspect a pre-provisioned Azure Confidential Computing node pool in AKS through the Azure Portal. You'll validate a confidential VM node pool configured with AMD SEV-SNP technology, deploy attestation-aware workloads, and verify that containers run in hardware-protected environments before processing sensitive operations. ### Understanding Confidential VMs in AKS @@ -73,146 +75,54 @@ This challenge is based on the **AKS with Confidential Computing Linux Sample** - **Main Repository**: [Azure Confidential Computing CVM Guest Attestation](https://github.com/Azure/confidential-computing-cvm-guest-attestation) - **Source Module**: [AKS Linux Sample](https://github.com/Azure/confidential-computing-cvm-guest-attestation/tree/main/aks-linux-sample) -The AKS deployment patterns and attestation verification workflows have been adapted for this MicroHack challenge to provide a guided learning experience with Confidential VM node pools in Azure Kubernetes Service using the Azure Portal. - ---- - -## Task 1: Enable Preview Features and Prepare Environment - -💡 **Before creating AKS clusters with Confidential VM node pools, you must register the required preview features.** - -### Step 1: Register the AzureLinuxCVMPreview Feature - -1. In the Azure Portal, search for **Subscriptions** in the top search bar -2. Select your subscription -3. In the left menu, under **Settings**, click **Preview features** - - ![Screenshot placeholder: Subscription Preview Features menu] - -4. In the search box, type: `AzureLinuxCVMPreview` -5. Select **AzureLinuxCVMPreview** from the results -6. Click **Register** - - ![Screenshot placeholder: Register AzureLinuxCVMPreview feature] - -7. Wait for the registration status to show **Registered** (this may take several minutes) -8. You can check the status by clicking **Refresh** periodically - - ![Screenshot placeholder: Feature registration status showing Registered] - ---- - ---- - -## Task 2: Create Resource Group - -💡 **Create the foundational resource group to organize all AKS-related resources.** - -1. In the Azure Portal, click **Create a resource** or search for **Resource groups** -2. Click **+ Create** -3. Fill in the following details: - - **Subscription**: Select your subscription - - **Resource group**: `${ATTENDEE_ID}` (e.g., `labuser-01`) - - **Region**: **North Europe** - - ![Screenshot placeholder: Create resource group form] - -4. Click **Review + create** -5. Click **Create** - - ![Screenshot placeholder: Resource group created successfully] +The AKS deployment patterns and attestation verification workflows have been adapted for this MicroHack challenge to provide a guided learning experience with Confidential VM node pools in Azure Kubernetes Service, navigated through the Azure Portal. --- ---- - -## Task 3: Create AKS Cluster with Standard Node Pool - -💡 **Deploy the AKS cluster with a standard system node pool. You'll add the Confidential VM node pool in the next task.** - -1. In the Azure Portal, search for **Kubernetes services** in the top search bar -2. Click **+ Create** and select **Kubernetes cluster** - - ![Screenshot placeholder: Create Kubernetes cluster button] - -### Basics Tab - -3. Fill in the **Basics** tab: - - **Subscription**: Select your subscription - - **Resource group**: `${ATTENDEE_ID}` (select the one you just created) - - **Cluster preset configuration**: **Dev/Test** - - **Kubernetes cluster name**: `aks-cvmcluster-` (e.g., `aks-cvmcluster-abc123`) - - **Region**: **North Europe** - - **Availability zones**: None (or as preferred) - - **AKS pricing tier**: **Free** - - **Kubernetes version**: Default (latest stable version) - - **Automatic upgrade**: **Disabled** - - **Node security channel type**: **None** - - ![Screenshot placeholder: AKS Basics tab configuration] +## Task 1: Navigate to the Pre-Provisioned AKS Cluster -### Node pools Tab +💡 **Locate the AKS cluster and resource group that have already been deployed for you.** -4. Click **Next: Node pools** -5. Keep the default node pool settings: - - **Node pool name**: `agentpool` (default system node pool) - - **Node size**: Default (e.g., `Standard_DS2_v2`) - - **Scale method**: **Manual** - - **Node count**: **1** +1. In the Azure Portal, search for **Resource groups** in the top search bar +2. Select the resource group named in your **Resource Group Name** dashboard credential - ![Screenshot placeholder: AKS Node pools tab] + ![Screenshot placeholder: Resource group overview] -### Networking Tab +3. In the resource list, select the AKS cluster resource named in your **Sovereign Lab AKS Cluster** dashboard credential -6. Click **Next: Networking** -7. Configure networking: - - **Network configuration**: **Azure CNI** - - **Network policy**: **None** (or **Calico** if preferred) - - Leave other settings as default + ![Screenshot placeholder: AKS cluster resource in resource group] - ![Screenshot placeholder: AKS Networking tab] +4. On the cluster **Overview** page, confirm the **Region** matches your **Sovereign Lab Region** dashboard credential -### Integrations, Advanced, and Tags Tabs + ![Screenshot placeholder: AKS cluster overview page] -8. Click **Next** through **Integrations**, **Advanced**, and **Tags** tabs -9. Keep the default settings or configure as needed +5. In the left menu, go to **Settings** > **Node pools** and confirm you see two node pools: the default system pool and the confidential pool named in your **AKS Confidential Node Pool** dashboard credential (`cvmnodepool`) -### Review + Create - -10. Click **Review + create** -11. Wait for validation to complete -12. Click **Create** - - ![Screenshot placeholder: AKS cluster deployment in progress] - -13. Wait for the deployment to complete (this may take 5-10 minutes) - - ![Screenshot placeholder: AKS deployment completed] + ![Screenshot placeholder: Node pools list showing system pool and cvmnodepool] --- --- -## Task 4: Connect to AKS Cluster +## Task 2: Connect to the Cluster via Cloud Shell -💡 **Configure kubectl to connect to your AKS cluster using Azure Cloud Shell.** +💡 **Configure kubectl to connect to the pre-provisioned AKS cluster using Azure Cloud Shell.** -1. Once deployment is complete, click **Go to resource** -2. In the AKS cluster overview page, click **Connect** in the top menu +1. In the AKS cluster overview page, click **Connect** in the top menu ![Screenshot placeholder: AKS Connect button] -3. Select **Azure CLI** tab -4. Click **Open Cloud Shell** +2. Select **Azure CLI** tab +3. Click **Open Cloud Shell** ![Screenshot placeholder: Cloud Shell connection instructions] -5. In Cloud Shell, run the provided `az aks get-credentials` command (replace with your values): +4. In Cloud Shell, run the provided `az aks get-credentials` command (replace with your dashboard credential values): ```bash - az aks get-credentials --resource-group ${ATTENDEE_ID} --name aks-cvmcluster- + az aks get-credentials --resource-group --name --overwrite-existing ``` -6. Verify the connection: +5. Verify the connection: ```bash kubectl get nodes ``` @@ -223,9 +133,9 @@ The AKS deployment patterns and attestation verification workflows have been ada --- -## Task 5: Add Confidential VM Node Pool +## Task 3: Inspect the Confidential VM Node Pool -💡 **Create a dedicated node pool with Confidential VM compute resources for running sensitive workloads.** +💡 **Validate that the pre-provisioned Confidential VM node pool is properly configured, using both the Azure Portal and Cloud Shell.** ### Using Azure Portal @@ -233,68 +143,17 @@ The AKS deployment patterns and attestation verification workflows have been ada ![Screenshot placeholder: Node pools menu] -2. Click **+ Add node pool** - - ![Screenshot placeholder: Add node pool button] - -3. Configure the confidential node pool: - - **Node pool name**: `cvmnodepool` - - **Mode**: **User** - - **OS SKU**: **Azure Linux** (or **Ubuntu**) - - **Node size**: Click **Choose a size** - - In the size selector, search for: `DC2as_v5` - - Select **Standard_DC2as_v5** (Confidential VM size) - - Click **Select** - - ![Screenshot placeholder: Select VM size showing DC2as_v5] - -4. Configure scale settings: - - **Scale method**: **Manual** - - **Node count**: **1** - - ![Screenshot placeholder: Node pool configuration form] - -5. Leave other settings as default -6. Click **Add** +2. Click on the node pool named in your **AKS Confidential Node Pool** dashboard credential (`cvmnodepool`) - ![Screenshot placeholder: Node pool being added] - -7. Wait for the node pool to be created (this may take 5-10 minutes) -8. The status will change from **Creating** to **Succeeded** - - ![Screenshot placeholder: Node pool list showing cvmnodepool] - -### Alternative: Using Cloud Shell - -If you prefer, you can add the node pool using Cloud Shell: - -```bash -az aks nodepool add \ - --resource-group ${ATTENDEE_ID} \ - --cluster-name aks-cvmcluster- \ - --name cvmnodepool \ - --node-count 1 \ - --node-vm-size Standard_DC2as_v5 -``` - ---- - ---- - -## Task 6: Verify Confidential Node Pool Configuration - -💡 **Validate that the Confidential VM node pool is properly configured and running.** - -### Using Azure Portal + ![Screenshot placeholder: Node pool details page] -1. In your AKS cluster, go to **Settings** > **Node pools** -2. Click on **cvmnodepool** 3. Verify the following details: - - **Node size**: `Standard_DC2as_v5` - - **Node count**: 1 - - **Status**: Running + - **Node size**: value shown in the **Confidential VM Size** dashboard credential + - **Mode**: `User` + - **OS SKU**: `Azure Linux` + - **Status**: `Running` - ![Screenshot placeholder: Node pool details page] + ![Screenshot placeholder: Node pool details showing the selected confidential VM size] ### Using Cloud Shell @@ -304,15 +163,15 @@ az aks nodepool add \ ```bash # Verify the VM size az aks nodepool show \ - --resource-group ${ATTENDEE_ID} \ - --cluster-name aks-cvmcluster- \ + --resource-group \ + --cluster-name \ --name cvmnodepool \ --query 'vmSize' # Verify the node image version az aks nodepool list \ - --resource-group ${ATTENDEE_ID} \ - --cluster-name aks-cvmcluster- \ + --resource-group \ + --cluster-name \ --query "[?name=='cvmnodepool'].nodeImageVersion" -o tsv ``` @@ -321,21 +180,33 @@ az aks nodepool list \ kubectl get nodes -o wide ``` -You should see two nodes: one from the default node pool and one confidential VM node. +You should see three nodes: two from the default system node pool and one confidential VM node. ![Screenshot placeholder: kubectl get nodes showing cvmnodepool node] +4. Inspect node labels and the OS SKU that confirms confidential compute hardware: +```bash +# List all nodes with their labels and identify the confidential node pool members +kubectl get nodes --show-labels | grep cvmnodepool + +# Confirm the OS SKU and VM size labels on the confidential node(s) +kubectl get nodes -l kubernetes.azure.com/agentpool=cvmnodepool \ + -o custom-columns=NAME:.metadata.name,OS_SKU:.metadata.labels.kubernetes\\.azure\\.com/os-sku,VM_SIZE:.metadata.labels.node\\.kubernetes\\.io/instance-type +``` + +The `OS_SKU` value of `AzureLinux` and a `VM_SIZE` matching the **Confidential VM Size** dashboard credential confirm that the node uses the selected AMD SEV-SNP confidential compute family. + --- --- -## Task 7: Deploy Attestation Verification Pod +## Task 4: Deploy Attestation Verification Pod 💡 **Deploy a sample pod that retrieves attestation data to prove it's running on a confidential VM node.** ### Step 1: Upload the Attestation Pod YAML File -1. The attestation pod YAML file is located at `walkthrough/challenge-5/resources/cvm-attestation-pod.yaml` in this repository. +1. The attestation pod YAML file is located at `walkthrough/challenge-05/resources/cvm-attestation-pod.yaml` in this repository. 2. In Azure Cloud Shell, click the **Upload/Download files** button (📁) in the toolbar @@ -369,7 +240,7 @@ Wait until the pod status shows **Running**. --- -## Task 8: Retrieve and Analyze Attestation Report +## Task 5: Retrieve and Analyze Attestation Report 💡 **Examine the attestation JWT token to verify the pod is running on genuine confidential hardware.** @@ -478,7 +349,7 @@ The attestation report contains important security validation fields: --- -## Task 9: Explore AKS Resources in Azure Portal +## Task 6: Explore AKS Resources in Azure Portal 💡 **Navigate the Azure Portal to view workloads, logs, and node pool configurations.** @@ -511,40 +382,14 @@ The attestation report contains important security validation fields: --- ---- - -## Task 10: Clean Up Resources - -💡 **Delete all resources to avoid ongoing charges.** - -### Using Azure Portal - -1. Go to **Resource groups** in the Azure Portal -2. Find and select your resource group `${ATTENDEE_ID}` -3. Click **Delete resource group** in the top menu - - ![Screenshot placeholder: Delete resource group button] - -4. Type the resource group name to confirm deletion -5. Click **Delete** - - ![Screenshot placeholder: Confirm delete resource group] - -6. Wait for the deletion to complete (this may take several minutes) - -### Using Cloud Shell - -Alternatively, you can delete the resource group using Cloud Shell: - -```bash -az group delete --name ${ATTENDEE_ID} --yes --no-wait -``` +> [!NOTE] +> This AKS cluster and its node pools are shared lab infrastructure also used in Challenge 7. Do not delete the resource group, the cluster, or the node pools. --- ## Key Takeaways -In this challenge, you successfully deployed and validated Azure Confidential Computing in AKS with guest attestation. Here are the key concepts and best practices: +In this challenge, you successfully validated Azure Confidential Computing in AKS with guest attestation. Here are the key concepts and best practices: ### Confidential Computing in Kubernetes @@ -566,13 +411,13 @@ In this challenge, you successfully deployed and validated Azure Confidential Co ✅ **Mixed Node Pools** - Combine standard and confidential node pools in the same cluster for cost optimization (use confidential nodes only for sensitive workloads) -✅ **Preview Features** - Confidential VM support in AKS requires feature registration and may have regional limitations +✅ **Idempotent Infrastructure** - The AKS cluster and Confidential VM node pool were provisioned ahead of time via idempotent Bicep, letting you focus on validation and attestation ✅ **Portal and CLI Management** - AKS supports both Azure Portal and Azure CLI for managing confidential node pools ### Production Best Practices -✅ **Node Pool Sizing** - Start with DC-series VMs (e.g., Standard_DC2as_v5) and scale based on workload requirements +✅ **Node Pool Sizing** - Start with an AMD SEV-SNP DCasv5/DCasv6 size and scale based on workload requirements ✅ **Workload Isolation** - Use Kubernetes namespaces, network policies, and RBAC in addition to confidential computing @@ -630,11 +475,11 @@ In this challenge, you successfully deployed and validated Azure Confidential Co ## Troubleshooting Tips -### Node Pool Creation Fails +### Node Pool Not Visible or Unexpected VM Size -- Verify that the `AzureLinuxCVMPreview` feature is registered -- Check that the region (North Europe) supports Confidential VM sizes -- Ensure you have sufficient quota for `Standard_DC2as_v5` VMs +- Confirm you're viewing the correct resource group and AKS cluster from your dashboard credentials +- Verify the node pool name matches your **AKS Confidential Node Pool** credential (`cvmnodepool`) +- If the VM size or status looks incorrect, contact your lab administrators — this environment is pre-provisioned via automation ### Pod Stays in Pending State diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/solution-05.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/solution-05.md index 48c2057d5..980bf6f58 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/solution-05.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/solution-05.md @@ -2,9 +2,9 @@ [Previous Challenge Solution](../challenge-04/solution-04.md) - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-06/solution-06.md) -**Estimated Duration:** 90-120 minutes +**Estimated Duration:** 45-60 minutes -> 💡 **Objective:** Learn how to deploy and validate guest attestation on Azure Confidential VMs in AKS to ensure business logic only executes in trusted, hardware-backed confidential computing environments. You will create an AKS cluster using Azure CLI, add a Confidential VM node pool, deploy attestation workloads, and verify cryptographic proof of node integrity before processing sensitive operations. +> 💡 **Objective:** Learn how to validate guest attestation on Azure Confidential VMs in AKS to ensure business logic only executes in trusted, hardware-backed confidential computing environments. Using the AKS cluster and Confidential VM node pool that have already been provisioned for this lab, you will inspect the node pool configuration, deploy attestation workloads, and verify cryptographic proof of node integrity before processing sensitive operations. --- @@ -12,13 +12,25 @@ Please ensure that you successfully verified the [General prerequisites](../../Readme.md#general-prerequisites) before continuing with this challenge. -- Azure subscription with Contributor permissions on your resource group +- Azure subscription with Reader/Contributor permissions on your resource group (pre-assigned) - Azure CLI >= 2.54 or access to Azure Portal - **Linux/Bash environment** — Azure Cloud Shell (Bash), WSL2 on Windows, or a native Linux/macOS terminal - `kubectl` command-line tool (can be installed via `az aks install-cli`) - Basic understanding of Kubernetes concepts (pods, deployments, node pools) - Basic understanding of confidential computing concepts +## Pre-Provisioned Infrastructure + +The shared lab automation has already deployed an AKS cluster with a standard system node pool and an AMD SEV-SNP Confidential VM user node pool named `cvmnodepool`. The exact v5 or v6 size is selected from subscription availability and quota. This cluster is shared with Challenge 7 — do not delete it. Retrieve the following values from your lab dashboard credentials: + +| Dashboard Credential | Description | +|---|---| +| **Resource Group Name** | Your dedicated resource group | +| **Sovereign Lab Region** | Region selected after capacity checks | +| **Confidential VM Size** | AMD SEV-SNP size selected during shared preparation | +| **Sovereign Lab AKS Cluster** | Name of the pre-provisioned AKS cluster | +| **AKS Confidential Node Pool** | Name of the Confidential VM node pool (`cvmnodepool`) | + ## Scenario Context You are a cloud security engineer at a European healthcare organization that processes sensitive patient data in containerized applications. Your organization has adopted Kubernetes (AKS) for container orchestration but must comply with strict data protection regulations including GDPR and healthcare privacy requirements. @@ -31,7 +43,7 @@ Your mandate includes: - **Zero Trust for Kubernetes**: Validate the integrity of worker nodes before deploying sensitive workloads - **Compliance and Auditability**: Provide cryptographic evidence that workloads execute in compliant infrastructure -In this challenge, you'll deploy Azure Confidential Computing node pools in AKS using Azure CLI. You'll configure a confidential VM node pool with AMD SEV-SNP technology, deploy attestation-aware workloads, and verify that containers run in hardware-protected environments before processing sensitive operations. +In this challenge, you'll inspect the pre-provisioned Azure Confidential Computing node pool in AKS using Azure CLI and kubectl. You'll validate the confidential VM node pool configuration (AMD SEV-SNP technology), deploy attestation-aware workloads, and verify that containers run in hardware-protected environments before processing sensitive operations. ### Understanding Confidential VMs in AKS @@ -68,9 +80,9 @@ The AKS deployment patterns and attestation verification workflows have been ada --- -## Task 1: Configure Environment and Register Preview Features +## Task 1: Configure Environment Variables -💡 **Set up your environment variables and register the required Azure preview features for Confidential VM support in AKS.** +💡 **Set up your environment variables using the values from your lab dashboard credentials for the pre-provisioned AKS cluster.** ### Step 1: Configure Environment Variables @@ -84,108 +96,79 @@ The AKS deployment patterns and attestation verification workflows have been ada ### Linux/Bash ```bash -# Set common variables -# Customize ATTENDEE_ID for each participant -RESOURCE_GROUP="labuser-xx" # Change this for each participant (e.g., labuser-01, labuser-02,...) - -ATTENDEE_ID="${RESOURCE_GROUP}" - -# Generate a unique random hash suffix (different on each run) -HASH_SUFFIX=$(echo -n "$ATTENDEE_ID-$(date +%s)-$RANDOM" | md5sum | cut -c1-6) - -LOCATION="northeurope" -AKS_CLUSTER_NAME="aks-cvmcluster$HASH_SUFFIX" -DNS_LABEL="cvmcluster$HASH_SUFFIX" -ADMIN_USERNAME="azureuser" -KEYVAULT_NAME="kv-cc-${HASH_SUFFIX}" # Must be globally unique -SSH_KEY_NAME="cc-${ATTENDEE_ID}-key" -ATTESTATION_NAME="attest${HASH_SUFFIX}" +# Populate these from your lab dashboard credentials +RESOURCE_GROUP="" # From the "Resource Group Name" credential +LOCATION="" # From the "Sovereign Lab Region" credential +CONFIDENTIAL_VM_SIZE="" # From the "Confidential VM Size" credential +AKS_CLUSTER_NAME="" # From the "Sovereign Lab AKS Cluster" credential +NODE_POOL_NAME="cvmnodepool" # From the "AKS Confidential Node Pool" credential ``` > [!WARNING] > If your Azure Cloud Shell session times out (e.g. during a break), the variables defined above will be lost and must be re-defined before continuing. We recommend saving them in a local text file on your machine so you can quickly copy and paste them back into a new session. -### Step 2: Install Required AKS Extensions - -```bash -# Install / Update `aks-preview` extension -az extension add --name aks-preview -az extension update --name aks-preview - -# Register the `AzureLinuxCVMPreview` feature flag -az feature register --namespace "Microsoft.ContainerService" --name "AzureLinuxCVMPreview" - -# Verify the registration status - status should show "Registered" -az feature show --namespace Microsoft.ContainerService --name AzureLinuxCVMPreview - -# Refresh the registration of the `Microsoft.ContainerService` resource provider -az provider register --namespace Microsoft.ContainerService -``` - -> [!WARNING] -> For all Microsoft-hosted events, these features and providers below are already registered. Ignore any error messages due to lack of permissions. - --- -## Task 2: Create Azure Kubernetes Service cluster +## Task 2: Connect to the Pre-Provisioned AKS Cluster -💡 **Deploy the foundational AKS cluster with a standard system node pool. You'll add the Confidential VM node pool in the next task.** +💡 **Retrieve credentials for the AKS cluster that has already been deployed for you and confirm connectivity.** -### Step 1: Create AKS cluster +### Step 1: Connect to the Cluster ```bash -# Create an AKS cluster -az aks create --resource-group $RESOURCE_GROUP --name $AKS_CLUSTER_NAME --node-count 1 --location $LOCATION --generate-ssh-keys --node-vm-size Standard_D2s_v5 +# Connect to the pre-provisioned cluster +az aks get-credentials --resource-group $RESOURCE_GROUP --name $AKS_CLUSTER_NAME --overwrite-existing -# Connect to the cluster -az aks get-credentials --resource-group $RESOURCE_GROUP --name $AKS_CLUSTER_NAME +# Confirm connectivity and list nodes +kubectl get nodes ``` -At this point, you should see an AKS cluster in your resource group: +At this point, you should see the pre-provisioned AKS cluster in your resource group: ![AKS](./images//aks.png) --- -## Task 3: Add Confidential VM Node Pool +## Task 3: Inspect the Confidential VM Node Pool -💡 **Create a dedicated node pool with Confidential VM compute resources for running sensitive workloads.** +💡 **Validate that the pre-provisioned Confidential VM node pool is configured correctly using both the Azure CLI and kubectl.** -### Step 1: Add the Confidential Node Pool +### Step 1: Verify VM Size and Image ```bash -az aks nodepool add --resource-group $RESOURCE_GROUP --cluster-name $AKS_CLUSTER_NAME --name cvmnodepool --node-count 1 --node-vm-size Standard_DC2as_v5 +# Verify that the node pool uses a Confidential VM +az aks nodepool show --resource-group $RESOURCE_GROUP --cluster-name $AKS_CLUSTER_NAME --name $NODE_POOL_NAME --query 'vmSize' + +# Verify that the node pool uses a Confidential VM image +az aks nodepool list --resource-group $RESOURCE_GROUP --cluster-name $AKS_CLUSTER_NAME --query "[?name=='$NODE_POOL_NAME'].nodeImageVersion" -o tsv ``` -At this point, you should see another AKS node pool in your cluster: +At this point, you should see the confidential node pool alongside the system node pool: ![AKS](./images//aks-02.png) ---- - -## Task 4: Verify Confidential Node Pool Configuration - -💡 **Validate that the Confidential VM node pool is properly configured and running with the correct VM size and image.** - -### Step 1: Verify VM Size and Image +### Step 2: Inspect Node Labels and Security Type ```bash -# Verify that the node pool uses a Confidential VM -az aks nodepool show --resource-group $RESOURCE_GROUP --cluster-name $AKS_CLUSTER_NAME --name cvmnodepool --query 'vmSize' +# List all nodes with their labels and identify the confidential node pool members +kubectl get nodes --show-labels | grep "$NODE_POOL_NAME" -# Verify that the node pool uses a Confidential VM image -az aks nodepool list --resource-group $RESOURCE_GROUP --cluster-name $AKS_CLUSTER_NAME --query "[?name=='cvmnodepool'].nodeImageVersion" -o tsv +# Confirm the OS SKU and VM size labels on the confidential node(s) +kubectl get nodes -l kubernetes.azure.com/agentpool=$NODE_POOL_NAME \ + -o custom-columns=NAME:.metadata.name,OS_SKU:.metadata.labels.kubernetes\\.azure\\.com/os-sku,VM_SIZE:.metadata.labels.node\\.kubernetes\\.io/instance-type ``` +The `OS_SKU` value of `AzureLinux` and a `VM_SIZE` matching `$CONFIDENTIAL_VM_SIZE` confirm that the node uses the selected AMD SEV-SNP confidential compute family. + --- -## Task 5: Deploy and Verify Attestation Pod +## Task 4: Deploy and Verify Attestation Pod 💡 **Deploy a sample pod that retrieves attestation data to prove it's running on a confidential VM node.** ### Step 1: Deploy the Attestation Pod -1. The attestation pod YAML file is located at `walkthrough/challenge-5/resources/cvm-attestation-pod.yaml` in this repository (if using Cloud Shell, upload the file via the **Manage files** menu option). +1. The attestation pod YAML file is located at `walkthrough/challenge-05/resources/cvm-attestation-pod.yaml` in this repository (if using Cloud Shell, upload the file via the **Manage files** menu option). 2. Apply the YAML file using the file from the repository: @@ -317,25 +300,14 @@ The attestation report contains important security validation fields: - **x-ms-sevsnpvm-is-debuggable**: `false` - VM is not debuggable (production setting) - **x-ms-sevsnpvm-launchmeasurement**: Launch measurement for integrity verification ---- - -## Task 6: Clean Up Resources - -💡 **Delete all resources to avoid ongoing charges.** - -### Step 1: Delete Resource Group - -```bash -az group delete --name $RESOURCE_GROUP --yes --no-wait -``` - -⚠️ **Warning**: This command will permanently delete all resources in the resource group including the AKS cluster, node pools, and all associated resources. +> [!NOTE] +> This AKS cluster and its node pools are shared lab infrastructure also used in Challenge 7. Do not delete the resource group, the cluster, or the node pools. --- ## Key Takeaways -In this challenge, you successfully deployed and validated Azure Confidential Computing in AKS with guest attestation. Here are the key concepts and best practices: +In this challenge, you successfully validated Azure Confidential Computing in AKS with guest attestation. Here are the key concepts and best practices: ### Confidential Computing in Kubernetes @@ -357,13 +329,13 @@ In this challenge, you successfully deployed and validated Azure Confidential Co ✅ **Mixed Node Pools** - Combine standard and confidential node pools in the same cluster for cost optimization (use confidential nodes only for sensitive workloads) -✅ **Preview Features** - Confidential VM support in AKS requires feature registration via `az feature register` +✅ **Idempotent Infrastructure** - The AKS cluster and Confidential VM node pool were provisioned ahead of time via idempotent Bicep, letting you focus on validation and attestation -✅ **CLI Management** - Azure CLI provides efficient commands for managing AKS clusters and confidential node pools +✅ **CLI Management** - Azure CLI provides efficient commands for inspecting and validating AKS clusters and confidential node pools ### Production Best Practices -✅ **Node Pool Sizing** - Start with DC-series VMs (e.g., Standard_DC2as_v5) and scale based on workload requirements +✅ **Node Pool Sizing** - Start with an AMD SEV-SNP DCasv5/DCasv6 size and scale based on workload requirements ✅ **Workload Isolation** - Use Kubernetes namespaces, network policies, and RBAC in addition to confidential computing diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md index 505572f8d..5acc7aa65 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md @@ -1,6 +1,6 @@ # Walkthrough Challenge 6 - Operating a Sovereign Hybrid Cloud with Azure Arc & Azure Local -[Previous Challenge Solution](../challenge-05/solution-05.md) - **[Home](../../Readme.md)** +[Previous Challenge Solution](../challenge-05/solution-05.md) - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-07/solution-07.md) **Estimated Duration:** 60-90 minutes diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-07/solution-07.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-07/solution-07.md new file mode 100644 index 000000000..f1771610b --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-07/solution-07.md @@ -0,0 +1,332 @@ +# Walkthrough Challenge 7 - Adaptive Apps Across Sovereign Environments + +[Previous Challenge Solution](../challenge-06/solution-06.md) - **[Home](../../Readme.md)** + +Duration: 60 minutes + +## Prerequisites + +Please ensure that you successfully verified the [General prerequisites](../../Readme.md#general-prerequisites) before continuing. + +Install these tools on your workstation or use a development container that includes them: + +* Azure CLI with the `bastion` extension +* `kubectl` +* `jq` and OpenSSL +* [Radius CLI (`rad`)](https://docs.radapp.io/getting-started/install/) + +Set the values shown on your lab dashboard: + +```bash +export AZURE_SUBSCRIPTION="" +export RESOURCE_GROUP="" +export AKS_CLUSTER="" +export K3S_VM="" +export BASTION_NAME="" +export K3S_KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" +export K3S_CONTEXT="k3s-azure-vm" + +az account set --subscription "$AZURE_SUBSCRIPTION" +``` + +> [!NOTE] +> The platform creates stable resource names and credentials. Re-running the lab deployment converges on the same names and replaces the isolated resource group only when region fallback is required. + +## Task 1: Connect to and validate both platforms (10 minutes) + +💡 **Start with explicit target discipline. A Kubernetes context and a Radius workspace are independent selectors.** + +### Validate AKS + +```bash +unset KUBECONFIG +az aks get-credentials \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --overwrite-existing + +kubectl config rename-context "$AKS_CLUSTER" aks-sovereign-lab 2>/dev/null || true +kubectl config use-context aks-sovereign-lab +kubectl get --raw='/readyz' +kubectl wait --for=condition=Ready nodes --all --timeout=10m +``` + +Confirm the sovereign-ready AKS features: + +```bash +az aks show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$AKS_CLUSTER" \ + --query '{oidc:oidcIssuerProfile.enabled,workloadIdentity:securityProfile.workloadIdentity.enabled,serviceMesh:serviceMeshProfile.mode}' \ + --output table +``` + +### Retrieve the K3s kubeconfig securely + +The kubeconfig is retrieved in bounded chunks through Azure VM Run Command. It is written with user-only permissions and changed to use the localhost tunnel endpoint. + +```bash +mkdir -p "$(dirname "$K3S_KUBECONFIG")" +chmod 0700 "$(dirname "$K3S_KUBECONFIG")" +ENCODED_KUBECONFIG="$(mktemp)" +DECODED_KUBECONFIG="$(mktemp)" + +LENGTH_RESPONSE="$(az vm run-command invoke \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM" \ + --command-id RunShellScript \ + --scripts 'encoded="$(base64 -w0 /etc/rancher/k3s/k3s.yaml)"; printf "K3S_LENGTH:%s\n" "${#encoded}"' \ + --output json)" +ENCODED_LENGTH="$(printf '%s' "$LENGTH_RESPONSE" \ + | jq -r '.value[]?.message // empty' \ + | sed -n 's/^K3S_LENGTH:\([0-9][0-9]*\)$/\1/p' \ + | tail -n 1)" + +test -n "$ENCODED_LENGTH" +: >"$ENCODED_KUBECONFIG" +for ((START = 1; START <= ENCODED_LENGTH; START += 1800)); do + END=$((START + 1799)) + RESPONSE="$(az vm run-command invoke \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM" \ + --command-id RunShellScript \ + --scripts "printf 'K3S_CHUNK_BEGIN\\n'; base64 -w0 /etc/rancher/k3s/k3s.yaml | cut -c ${START}-${END}; printf '\\nK3S_CHUNK_END\\n'" \ + --output json)" + CHUNK="$(printf '%s' "$RESPONSE" \ + | jq -r '.value[]?.message // empty' \ + | sed -n '/^K3S_CHUNK_BEGIN$/,/^K3S_CHUNK_END$/p' \ + | sed '1d;$d' \ + | tr -d '\r\n')" + test -n "$CHUNK" + printf '%s' "$CHUNK" >>"$ENCODED_KUBECONFIG" +done + +test "$(wc -c <"$ENCODED_KUBECONFIG" | tr -d '[:space:]')" = "$ENCODED_LENGTH" +openssl base64 -d -A -in "$ENCODED_KUBECONFIG" -out "$DECODED_KUBECONFIG" +sed 's#server: https://[^:]*:6443#server: https://127.0.0.1:16443#' \ + "$DECODED_KUBECONFIG" >"$K3S_KUBECONFIG" +chmod 0600 "$K3S_KUBECONFIG" +KUBECONFIG="$K3S_KUBECONFIG" kubectl config rename-context default "$K3S_CONTEXT" 2>/dev/null || true +rm -f "$ENCODED_KUBECONFIG" "$DECODED_KUBECONFIG" + +# Radius resolves installation contexts from the default kubeconfig. Merge the +# K3s context there while keeping the dedicated file as the explicit selector. +touch "$HOME/.kube/config" +MERGED_KUBECONFIG="$(mktemp)" +KUBECONFIG="$HOME/.kube/config:$K3S_KUBECONFIG" \ + kubectl config view --flatten >"$MERGED_KUBECONFIG" +install -m 0600 "$MERGED_KUBECONFIG" "$HOME/.kube/config" +rm -f "$MERGED_KUBECONFIG" +``` + +Start the Bastion tunnel in a second terminal and leave it running: + +```bash +az network bastion tunnel \ + --name "$BASTION_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --target-resource-id "/subscriptions/${AZURE_SUBSCRIPTION}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Compute/virtualMachines/${K3S_VM}" \ + --resource-port 6443 \ + --port 16443 +``` + +Return to the first terminal and validate K3s: + +```bash +export KUBECONFIG="$K3S_KUBECONFIG" +kubectl config use-context "$K3S_CONTEXT" +kubectl get --raw='/readyz' +kubectl wait --for=condition=Ready nodes --all --timeout=10m + +az vm show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$K3S_VM" \ + --show-details \ + --query publicIps \ + --output tsv +``` + +✅ The readiness endpoint returns `ok`, the node is `Ready`, and the final command returns an empty value because the VM has no public IP. + +## Task 2: Install federated Radius control planes (20 minutes) + +🔑 **A federated control plane keeps each site operationally independent. Shared source control can promote the same application model without introducing a runtime dependency between sites.** + +### Install Radius on K3s + +```bash +export KUBECONFIG="$K3S_KUBECONFIG" +kubectl config use-context "$K3S_CONTEXT" + +rad install kubernetes --kubecontext "$K3S_CONTEXT" +rad workspace create kubernetes ws-local-prod \ + --context "$K3S_CONTEXT" \ + --force +rad workspace switch ws-local-prod +rad group show rg-trading >/dev/null 2>&1 || rad group create rg-trading +rad group switch rg-trading +rad env show env-local-prod >/dev/null 2>&1 || rad env create env-local-prod \ + --group rg-trading \ + --kubernetes-namespace env-local-prod +rad env switch env-local-prod + +kubectl wait --for=condition=Available deployments --all \ + --namespace radius-system \ + --timeout=10m +``` + +### Install Radius on AKS + +```bash +unset KUBECONFIG +kubectl config use-context aks-sovereign-lab + +rad install kubernetes --kubecontext aks-sovereign-lab +rad workspace create kubernetes ws-azure-prod \ + --context aks-sovereign-lab \ + --force +rad workspace switch ws-azure-prod +rad group show rg-trading >/dev/null 2>&1 || rad group create rg-trading +rad group switch rg-trading +rad env show env-azure-prod >/dev/null 2>&1 || rad env create env-azure-prod \ + --group rg-trading \ + --kubernetes-namespace env-azure-prod +rad env switch env-azure-prod + +kubectl wait --for=condition=Available deployments --all \ + --namespace radius-system \ + --timeout=10m +``` + +💡 **This challenge does not register an Azure provider with Radius. The portable application uses Kubernetes compute only. Later Adaptive Apps challenges can add workload identity and recipes for Azure-managed capabilities without changing the application boundary.** + +## Task 3: Create one portable application model (10 minutes) + +💥 **Create a Radius Bicep file that contains no AKS cluster name, VM address, namespace, or cloud-specific resource.** + +```bash +cat >sovereign-web.bicep <<'BICEP' +extension radius + +@description('The Radius environment ID injected by the Radius CLI.') +param environment string + +resource app 'Applications.Core/applications@2023-10-01-preview' = { + name: 'sovereign-web' + properties: { + environment: environment + } +} + +resource web 'Applications.Core/containers@2023-10-01-preview' = { + name: 'web' + properties: { + application: app.id + container: { + image: 'nginx:1.27-alpine' + ports: { + http: { + containerPort: 80 + } + } + } + } +} +BICEP +``` + +Review the file and identify the portability boundary: + +* The application team owns the application, image, and requested port. +* The selected Radius environment owns the Kubernetes namespace and target control plane. +* A platform team can later attach recipes to portable capability resources without adding platform details to this application model. + +## Task 4: Deploy the unchanged model twice (15 minutes) + +### Deploy to K3s + +```bash +export KUBECONFIG="$K3S_KUBECONFIG" +kubectl config use-context "$K3S_CONTEXT" +rad workspace switch ws-local-prod +rad group switch rg-trading +rad env switch env-local-prod + +test "$(kubectl config current-context)" = "$K3S_CONTEXT" +rad deploy sovereign-web.bicep \ + --workspace ws-local-prod \ + --group rg-trading \ + --environment env-local-prod + +rad app graph --application sovereign-web +rad resource list --application sovereign-web +``` + +Expose the application, browse to , and stop the foreground process with Ctrl+C: + +```bash +rad resource expose Applications.Core/containers web \ + --application sovereign-web \ + --port 8080 \ + --remote-port 80 +``` + +### Deploy the same file to AKS + +```bash +unset KUBECONFIG +kubectl config use-context aks-sovereign-lab +rad workspace switch ws-azure-prod +rad group switch rg-trading +rad env switch env-azure-prod + +test "$(kubectl config current-context)" = 'aks-sovereign-lab' +rad deploy sovereign-web.bicep \ + --workspace ws-azure-prod \ + --group rg-trading \ + --environment env-azure-prod + +rad app graph --application sovereign-web +rad resource list --application sovereign-web +``` + +Expose the AKS application and browse to : + +```bash +rad resource expose Applications.Core/containers web \ + --application sovereign-web \ + --port 8080 \ + --remote-port 80 +``` + +> [!IMPORTANT] +> Do not edit `sovereign-web.bicep` between deployments. Changing the target selectors is the point of the exercise. + +## Task 5: Validate site independence (5 minutes) + +Stop the AKS exposure, switch back to K3s, and inspect its application without selecting or contacting the AKS Radius control plane: + +```bash +export KUBECONFIG="$K3S_KUBECONFIG" +kubectl config use-context "$K3S_CONTEXT" +rad workspace switch ws-local-prod +rad app graph --application sovereign-web +kubectl get pods --all-namespaces --selector radapp.io/application=sovereign-web +``` + +🔑 **The two applications have the same name and model but independent state. A loss of AKS or connectivity to Azure does not remove the K3s control plane or its running application. Data synchronization, configuration promotion, and disconnected software distribution remain separate architecture decisions.** + +## Validation + +You have completed the challenge when: + +* Both clusters are healthy and the K3s VM has no public IP. +* Both `radius-system` installations are healthy. +* The workspace target matches the Kubernetes context before every deployment. +* `sovereign-web.bicep` remains unchanged. +* Both Radius control planes show an independent `sovereign-web` graph. +* Each deployment displays the NGINX page through local port forwarding. +* You can explain why federated control planes improve site autonomy and where Radius recipes fit when managed and local capability implementations differ. + +> [!WARNING] +> Delete the lab resource group promptly after the workshop. AKS nodes, the K3s VM, NAT Gateway, and Azure Bastion accrue hourly charges. \ No newline at end of file