diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md index cbf3fbf4f..07a149c40 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-05.md @@ -60,11 +60,30 @@ administrator password. While targeting AKS and workspace `ws-azure-prod`: +> [!IMPORTANT] +> `ACR_NAME` must contain only the Azure Container Registry resource name, not a URL +> or the login-server hostname. +> +> ```bash +> # Correct +> export ACR_NAME="acad3zqjinwxy6mh2" +> +> # Incorrect +> export ACR_NAME="acad3zqjinwxy6mh2.azurecr.io" +> ``` +> +> The commands below append `.azurecr.io` automatically. Including it in `ACR_NAME` +> produces a duplicated hostname and causes Radius recipe downloads to fail with +> `RecipeDownloadFailed` / `no such host`. +> +> Hosted participants should source `artifacts/console-env.sh` and retain its supplied +> `ACR_NAME`. + 1. Hosted: use the supplied `ACR_NAME` from `artifacts/console-env.sh` without recreating it. BYOS: create or select a globally unique, lowercase registry. 2. Authenticate to the registry. 3. Publish `iac/recipes/sql-server.bicep` and the workshop-owned Azure and Kubernetes - PostgreSQL recipes as version `1.0.0`. + PostgreSQL recipes as version `1.0.8`. Never overwrite an existing version tag. 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. @@ -120,9 +139,11 @@ Prepare a comparison that answers: ## Success criteria -- The custom SQL recipe is published to ACR at version `1.0.0`. -- Both corrected PostgreSQL recipes are published to ACR at version `1.0.0` and +- The custom SQL recipe is published to ACR at version `1.0.8`. +- Both corrected PostgreSQL recipes are published to ACR at version `1.0.8` and registered without mutable `latest` references. +- The Azure PostgreSQL server name is deterministic and scoped to both the workshop's + Azure resource group and the Radius resource ID. - 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`. @@ -140,6 +161,9 @@ Prepare a comparison that answers: - Publishing an artifact and registering a recipe are separate steps. Validate each one before continuing. - ACR names must be globally unique and lowercase. +- Azure PostgreSQL server names are globally unique too. Separate workshops can share + Radius resource IDs, so give each workshop its own Azure resource group and include + `resourceGroup().id` in the deterministic name. Never use a timestamp. - Check Kubernetes context, Radius workspace, group, and environment before each deployment. - The Azure environment needs an Azure provider scope; the K3s environment does not. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md index 39c2e6fb0..945b5fc57 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/challenges/challenge-06.md @@ -151,6 +151,9 @@ Capture a side-by-side comparison that shows: 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. +4. `ServerNameAlreadyExists` for PostgreSQL is also an environment-layer problem. Use a + workshop-specific Azure resource group, register Azure PostgreSQL recipe `1.0.8`, + then retry the unchanged deployment. ### Deployment and validation diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep index bec7f97e6..6ecaecfa5 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/app.bicep @@ -107,7 +107,7 @@ resource tradingDb 'Radius.Resources/postgreSqlDatabases@2025-08-01-preview' = { properties: { environment: environment application: tradingApp.id - size: 'S' + size: 'L' } } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep index 4a2e4d7e1..ad048fb0f 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/iac/recipes/postgres-azure-flex.bicep @@ -33,11 +33,15 @@ var skuBySize = { tier: 'Burstable' } L: { - name: 'Standard_D2s_v3' + name: 'Standard_D4ds_v5' tier: 'GeneralPurpose' } } -var serverName = 'pg-${uniqueString(context.resource.id)}' +// Azure PostgreSQL server names are globally unique, while separate workshops commonly reuse +// the same Radius resource ID. Include the target resource group ID, which also contains the +// subscription ID, so isolated workshops do not collide. Every input is stable, so retries reuse +// the same name. Changing these inputs creates a different server rather than renaming one. +var serverName = 'pg-${uniqueString(resourceGroup().id, 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 diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md index 6dc7f34bb..279bcf742 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md @@ -89,8 +89,9 @@ override the ordered regions before provisioning. in both regional and DSv5-family quota: 12 baseline plus 4 for AKS surge. Five labs require 80 free vCPUs (60 steady-state). The check budgets the entire fan-out as new capacity, including on reruns. -5. Check PostgreSQL 16 / Burstable `Standard_B1ms` availability for the later - participant recipe; this does not provision a database. +5. Check PostgreSQL 16 / General Purpose `Standard_D4ds_v5` availability for the later + participant application (`size: 'L'`, 4 vCores, 16 GiB RAM); this does not provision + a database or guarantee allocation capacity. 6. Apply the hosted tag initiative and verify inheritance with a temporary RG/NSG. 7. Persist eligible regions and lab count as subscription tags. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/console-helpers.ps1 b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/console-helpers.ps1 index 80606ae83..4ce125f75 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/console-helpers.ps1 +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/console-helpers.ps1 @@ -43,19 +43,19 @@ function Test-AdaptiveRegion { if ($capability.status -and $capability.status -ne 'Available') { continue } if ($capability.supportedFeatures | Where-Object { $_.name -eq 'OfferRestricted' -and $_.status -eq 'Enabled' }) { continue } # Current CLI uses server editions; older runners return flexible editions per zone. - $burst = @($capability.supportedServerEditions | Where-Object { $_.name -eq 'Burstable' }) + $generalPurpose = @($capability.supportedServerEditions | Where-Object { $_.name -eq 'GeneralPurpose' }) $versions = @($capability.supportedServerVersions | Where-Object { $_.name -eq '16' }) - if ($versions.Count -gt 0 -and ($burst.supportedServerSkus | Where-Object { $_.name -eq 'Standard_B1ms' })) { + if ($versions.Count -gt 0 -and ($generalPurpose.supportedServerSkus | Where-Object { $_.name -eq 'Standard_D4ds_v5' })) { $postgresReady = $true } - $legacyBurst = @($capability.supportedFlexibleServerEditions | Where-Object { $_.name -eq 'Burstable' }) - $legacyVersion = @($legacyBurst.supportedServerVersions | Where-Object { $_.name -eq '16' }) - if ($legacyVersion.supportedVcores | Where-Object { $_.name -eq 'Standard_B1ms' }) { + $legacyGeneralPurpose = @($capability.supportedFlexibleServerEditions | Where-Object { $_.name -eq 'GeneralPurpose' }) + $legacyVersion = @($legacyGeneralPurpose.supportedServerVersions | Where-Object { $_.name -eq '16' }) + if ($legacyVersion.supportedVcores | Where-Object { $_.name -eq 'Standard_D4ds_v5' }) { $postgresReady = $true } } if (-not $postgresReady) { - throw "$Location does not advertise PostgreSQL 16 Burstable Standard_B1ms required by the default recipe." + throw "$Location does not advertise PostgreSQL 16 GeneralPurpose Standard_D4ds_v5 required by the default application." } return $Location } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 index 9a75fa14a..df38334a2 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 @@ -25,8 +25,8 @@ BeforeAll { function kubectl { } function rad { } $subscription = '11111111-1111-1111-1111-111111111111' - $postgresCurrent = '[{"supportedFeatures":[{"name":"OfferRestricted","status":"Disabled"}],"supportedServerVersions":[{"name":"16"}],"supportedServerEditions":[{"name":"Burstable","supportedServerSkus":[{"name":"Standard_B1ms"}]}]}]' - $postgresLegacy = '[{"status":"Available","supportedFlexibleServerEditions":[{"name":"Burstable","supportedServerVersions":[{"name":"16","supportedVcores":[{"name":"Standard_B1ms"}]}]}]}]' + $postgresCurrent = '[{"supportedFeatures":[{"name":"OfferRestricted","status":"Disabled"}],"supportedServerVersions":[{"name":"16"}],"supportedServerEditions":[{"name":"GeneralPurpose","supportedServerSkus":[{"name":"Standard_D4ds_v5"}]}]}]' + $postgresLegacy = '[{"status":"Available","supportedFlexibleServerEditions":[{"name":"GeneralPurpose","supportedServerVersions":[{"name":"16","supportedVcores":[{"name":"Standard_D4ds_v5"}]}]}]}]' } Describe 'Regional preflight' { @@ -70,8 +70,8 @@ Describe 'Regional preflight' { $script:postgres = $postgresLegacy Test-AdaptiveRegion -Location westeurope -LabCount 2 | Should -Be 'westeurope' } - It 'rejects a region without PostgreSQL 16 B1ms' { - $script:postgres = $postgresCurrent.Replace('Standard_B1ms', 'Standard_D2s_v3') + It 'rejects a region without PostgreSQL 16 D4ds_v5' { + $script:postgres = $postgresCurrent.Replace('Standard_D4ds_v5', 'Standard_B1ms') { Test-AdaptiveRegion -Location westeurope -LabCount 2 } | Should -Throw '*PostgreSQL 16*' } It 'rejects offer-restricted PostgreSQL' { diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh index e93a62eca..fd7d472a6 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/configure-recipes.sh @@ -27,7 +27,7 @@ AKS_ENVIRONMENT="${AKS_ENVIRONMENT:-env-azure-prod}" K3S_ENVIRONMENT="${K3S_ENVIRONMENT:-env-local-prod}" RADIUS_GROUP="${RADIUS_GROUP:-rg-trading}" RECIPE_REGISTRY="${RECIPE_REGISTRY:-ghcr.io/microsoft/adaptive-apps/recipes}" -WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.6}" +WORKSHOP_RECIPE_VERSION="${WORKSHOP_RECIPE_VERSION:-1.0.8}" [[ "$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 @@ -120,6 +120,11 @@ publish_workshop_recipes() { : "${RESOURCE_GROUP:?Set RESOURCE_GROUP for workshop recipe publishing.}" : "${ACR_NAME:?Set a globally unique lowercase ACR_NAME.}" + if [[ ! "$ACR_NAME" =~ ^[a-z0-9]{5,50}$ ]]; then + echo "ACR_NAME must be the 5-50 character lowercase alphanumeric registry name, without .azurecr.io or https://." >&2 + exit 1 + fi + source "$(dirname "${BASH_SOURCE[0]}")/azure-session.sh" if ! az acr show \ @@ -158,15 +163,28 @@ publish_workshop_recipes() { } EOF - DOCKER_CONFIG="${TEMP_DIR}/docker" rad bicep publish \ - --file iac/recipes/sql-server.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:${WORKSHOP_RECIPE_VERSION}" - DOCKER_CONFIG="${TEMP_DIR}/docker" rad bicep publish \ - --file iac/recipes/postgres-azure-flex.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:${WORKSHOP_RECIPE_VERSION}" - DOCKER_CONFIG="${TEMP_DIR}/docker" rad bicep publish \ - --file iac/recipes/postgres-kubernetes.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:${WORKSHOP_RECIPE_VERSION}" + publish_recipe() { + local file="$1" + local repository="$2" + local image="${repository}:${WORKSHOP_RECIPE_VERSION}" + + # Version tags are immutable workshop artifacts. Republishing changed source under an + # existing tag would silently alter every environment already registered to that tag. + if az acr repository show \ + --name "$ACR_NAME" \ + --image "$image" >/dev/null 2>&1; then + echo "Reusing existing ${ACR_NAME}.azurecr.io/${image}; published versions are not overwritten." + return 0 + fi + + DOCKER_CONFIG="${TEMP_DIR}/docker" rad bicep publish \ + --file "$file" \ + --target "br:${ACR_NAME}.azurecr.io/${image}" + } + + publish_recipe iac/recipes/sql-server.bicep recipes/sql-server + publish_recipe iac/recipes/postgres-azure-flex.bicep recipes/postgres-azure-flex + publish_recipe iac/recipes/postgres-kubernetes.bicep recipes/postgres-kubernetes } assert_recipes() { diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh index 705a852ab..95cf4acc2 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-aks.sh @@ -5,6 +5,9 @@ set -euo pipefail # AKS credentials belong in the default kubeconfig, not a previously selected K3s file. unset KUBECONFIG +: "${AZURE_SUBSCRIPTION:?Set AZURE_SUBSCRIPTION to the target subscription ID.}" +: "${RESOURCE_GROUP:?Set RESOURCE_GROUP to the lab Azure resource group.}" + for command_name in az grep kubectl; do command -v "$command_name" >/dev/null 2>&1 || { echo "Required command not found: $command_name" >&2 @@ -12,9 +15,7 @@ for command_name in az grep kubectl; do } 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}" diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh index c036c308a..fbb8e903f 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/prepare-k3s-azure-vm.sh @@ -17,7 +17,7 @@ readonly K3S_API_PORT="6443" readonly KUBECONFIG_CHUNK_SIZE="1800" AZURE_LOCATION="${AZURE_LOCATION:-westeurope}" -RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +RESOURCE_GROUP="${RESOURCE_GROUP:-}" VNET_NAME="${VNET_NAME:-vnet-adaptive-apps}" VNET_PREFIX="${VNET_PREFIX:-10.42.0.0/16}" K3S_SUBNET_NAME="${K3S_SUBNET_NAME:-snet-k3s}" @@ -69,6 +69,10 @@ Usage: bash resources/prepare-k3s-azure-vm.sh [provision|connect|status|disconne Required for provision/connect: AZURE_SUBSCRIPTION Target Azure subscription ID. + RESOURCE_GROUP Lab Azure resource group. + +For status/disconnect of a running tunnel, retain the same RESOURCE_GROUP so its +process identity can be verified. These modes do not require Azure authentication. 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. @@ -106,6 +110,7 @@ tunnel_pid_matches() { local pid="$1" local command_line + [[ -n "$RESOURCE_GROUP" ]] || return 1 is_pid_running "$pid" || return 1 [[ -r "/proc/${pid}/cmdline" ]] || return 1 command_line="$(tr '\0' ' ' <"/proc/${pid}/cmdline")" @@ -216,6 +221,10 @@ disconnect_tunnel() { } if [[ "$MODE" == "status" ]]; then + if [[ -z "$RESOURCE_GROUP" && -f "$TUNNEL_PID_FILE" ]]; then + echo "Set RESOURCE_GROUP to the lab Azure resource group to verify the recorded tunnel." >&2 + exit 1 + fi if tunnel_status; then exit 0 fi @@ -223,12 +232,19 @@ if [[ "$MODE" == "status" ]]; then fi if [[ "$MODE" == "disconnect" ]]; then + if [[ -z "$RESOURCE_GROUP" && -f "$TUNNEL_PID_FILE" ]]; then + echo "Set RESOURCE_GROUP to the lab Azure resource group to verify the recorded tunnel." >&2 + exit 1 + fi if disconnect_tunnel; then exit 0 fi exit 1 fi +: "${AZURE_SUBSCRIPTION:?Set AZURE_SUBSCRIPTION to the target subscription ID.}" +: "${RESOURCE_GROUP:?Set RESOURCE_GROUP to the lab Azure resource group.}" + 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 @@ -236,7 +252,6 @@ for command_name in az base64 cut grep install jq kubectl nohup sed tail timeout } 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 diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh index 2eb228e14..3f5cba32a 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/validate-postgres-recipes.sh @@ -23,6 +23,17 @@ for recipe in "$AZURE_RECIPE" "$KUBERNETES_RECIPE"; do } done +grep -A8 -F "resource tradingDb " "$APP_TEMPLATE" | grep -Fq "size: 'L'" || { + echo "The default application must request PostgreSQL size L." >&2 + exit 1 +} +large_sku="$(sed -n '/^ L: {/,/^ }/p' "$AZURE_RECIPE")" +grep -Fq "name: 'Standard_D4ds_v5'" <<<"$large_sku" && + grep -Fq "tier: 'GeneralPurpose'" <<<"$large_sku" || { + echo "Azure PostgreSQL size L must use GeneralPurpose Standard_D4ds_v5." >&2 + exit 1 +} + 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 @@ -62,6 +73,13 @@ for recipe in "$AZURE_RECIPE" "$SQL_RECIPE"; do done done +# Azure PostgreSQL server names are globally unique. Workshops often reuse Radius resource IDs, +# so the deterministic name must also be scoped to the target Azure subscription/resource group. +grep -Fqx "var serverName = 'pg-\${uniqueString(resourceGroup().id, context.resource.id)}'" "$AZURE_RECIPE" || { + echo "Azure PostgreSQL server name must hash resourceGroup().id and context.resource.id." >&2 + exit 1 +} + # 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 diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/verify-trading-db.sh b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/verify-trading-db.sh new file mode 100644 index 000000000..46ef20629 --- /dev/null +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/resources/verify-trading-db.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Invoke with Bash from the Adaptive Apps MicroHack root. +set -euo pipefail + +: "${APP_NAMESPACE:?Set APP_NAMESPACE to the deployed application namespace.}" +for command_name in kubectl jq; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "Required command not found: $command_name" >&2 + exit 1 + } +done + +pod_name="" +cleanup() { + if [[ -n "$pod_name" ]]; then + kubectl delete pod "$pod_name" --namespace "$APP_NAMESPACE" \ + --ignore-not-found --wait=false || { + echo "Could not delete verification pod ${pod_name}; remove it manually." >&2 + return 1 + } + fi +} +trap cleanup EXIT + +echo "Kubernetes context: $(kubectl config current-context)" +echo "Application namespace: ${APP_NAMESPACE}" + +schema_job="$(kubectl get jobs --namespace "$APP_NAMESPACE" \ + --selector app=postgres-schema-initializer,resource=trading-db --output json | + jq -r '.items | sort_by(.metadata.creationTimestamp) | last | .metadata.name // empty')" +[[ -n "$schema_job" ]] || { + echo "No trading-db schema initializer Job found in ${APP_NAMESPACE}." >&2 + exit 1 +} +echo "Using latest schema initializer: ${schema_job}" +if ! kubectl wait --namespace "$APP_NAMESPACE" --for=condition=complete \ + "job/${schema_job}" --timeout=15m; then + kubectl logs --namespace "$APP_NAMESPACE" "job/${schema_job}" || { + echo "Could not retrieve schema initializer logs." >&2 + } + exit 1 +fi + +db_env="$(kubectl get job "$schema_job" --namespace "$APP_NAMESPACE" --output json | + jq '.spec.template.spec.containers[0].env | + map(select(.name == "PGHOST" or .name == "PGDATABASE" or .name == "PGUSER" + or .name == "PGPASSWORD" or .name == "PGSSLMODE")) | + if any(.[]; .name == "PGSSLMODE") then . + else . + [{name: "PGSSLMODE", value: "prefer"}] end')" +jq -e ' + . as $env | + all(["PGHOST", "PGDATABASE", "PGUSER", "PGSSLMODE"][]; + . as $name | any($env[]; .name == $name and + (.value | type == "string" and length > 0))) and + any(.[]; .name == "PGPASSWORD" and + (.valueFrom.secretKeyRef.name | type == "string" and length > 0) and + (.valueFrom.secretKeyRef.key | type == "string" and length > 0)) +' <<<"$db_env" >/dev/null || { + echo "Schema Job is missing database connection settings or its password Secret reference." >&2 + exit 1 +} +jq -r ' + .[] | select(.name == "PGHOST" or .name == "PGDATABASE" or .name == "PGSSLMODE") | + "\(.name)=\(.value)" +' <<<"$db_env" + +kubectl wait --namespace "$APP_NAMESPACE" --for=condition=Available \ + deployment/backend --timeout=5m + +sql_script="$(cat <<'SQL_SCRIPT' +psql --set=ON_ERROR_STOP=1 <<'SQL' +SELECT table_name +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_name IN ('accounts', 'orders', 'trades', 'positions') +ORDER BY table_name; + +SELECT 'demo_seed_count=' || count(*) +FROM accounts +WHERE name = 'Demo Account'; + +SELECT id, account_id, symbol, side, quantity, price, status, created_at +FROM orders +ORDER BY id DESC +LIMIT 20; +SQL +SQL_SCRIPT +)" +backend_script="$(cat <<'BACKEND_SCRIPT' +accounts="$(curl --silent --show-error --fail --connect-timeout 10 --max-time 60 \ + http://backend:8080/api/accounts)" +printf '%s\n' "$accounts" | grep -F '"Demo Account"' +BACKEND_SCRIPT +)" + +pod_name="$(jq -n --arg namespace "$APP_NAMESPACE" --argjson env "$db_env" \ + --arg sql "$sql_script" --arg backend "$backend_script" '{ + apiVersion: "v1", + kind: "Pod", + metadata: {generateName: "trading-db-verify-", namespace: $namespace}, + spec: { + restartPolicy: "Never", + activeDeadlineSeconds: 300, + containers: [ + {name: "psql", image: "postgres:16-alpine", + command: ["sh", "-ceu", $sql], env: $env}, + {name: "backend-check", image: "curlimages/curl:8.12.1", + command: ["sh", "-ceu", $backend]} + ] + } + }' | kubectl create -f - --output jsonpath='{.metadata.name}')" + +echo "Verification pod: ${pod_name}" +for ((attempt = 0; attempt < 150; attempt++)); do + phase="$(kubectl get pod "$pod_name" --namespace "$APP_NAMESPACE" \ + --output jsonpath='{.status.phase}')" + case "$phase" in + Succeeded | Failed) break ;; + esac + sleep 2 +done + +if [[ "$phase" != "Succeeded" ]]; then + echo "Verification did not succeed (pod phase: ${phase})." >&2 + kubectl describe pod "$pod_name" --namespace "$APP_NAMESPACE" || { + echo "Could not describe verification pod." >&2 + } +fi +for container in psql backend-check; do + echo "=== ${container} ===" + kubectl logs "$pod_name" --namespace "$APP_NAMESPACE" --container "$container" || { + echo "Could not retrieve ${container} logs." >&2 + exit 1 + } +done +[[ "$phase" == "Succeeded" ]] || exit 1 +echo "Database queries and backend Demo Account check passed." diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/tests/test_console_helpers.py b/03-Azure/01-01-App Innovation/04-adaptive-apps/tests/test_console_helpers.py index 6f41422ce..6c9c8c34f 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/tests/test_console_helpers.py +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/tests/test_console_helpers.py @@ -114,7 +114,7 @@ def kubeconfig(name): "aiModels", "governance", "agentGuardrails", "sqlDatabases"] recipes = [{"name": os.environ.get("RECIPE_NAME", "default"), "resourceType": "Radius.Resources/" + kind, "templateKind": "bicep", - "templatePath": "mock.azurecr.io/recipe:1.0.6"} for kind in types] + "templatePath": "mock.azurecr.io/recipe:1.0.7"} for kind in types] print(os.environ.get("RECIPE_JSON", json.dumps(recipes))) elif args[:2] == ["bicep", "publish-extension"]: Path(arg("--target")).write_text("mock-extension") @@ -173,6 +173,38 @@ def run_script(self, name, *args, success=True, **overrides): def commands(self): return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else [] + def test_platform_preparation_requires_explicit_azure_resource_group(self): + for script, mode in (("prepare-aks.sh", ()), + ("prepare-k3s-azure-vm.sh", ("provision",)), + ("prepare-k3s-azure-vm.sh", ("connect",))): + with self.subTest(script=script, mode=mode): + result = self.run_script(script, *mode, success=False, RESOURCE_GROUP="") + self.assertIn("Set RESOURCE_GROUP", result.stderr) + self.assertEqual(self.commands(), []) + + def test_k3s_local_modes_without_recorded_tunnel_need_no_azure_configuration(self): + for mode in ("--help", "status", "disconnect"): + with self.subTest(mode=mode): + result = self.run_script( + "prepare-k3s-azure-vm.sh", mode, success=mode != "status", + AZURE_SUBSCRIPTION="", RESOURCE_GROUP="") + self.assertNotIn("Set RESOURCE_GROUP", result.stderr) + self.assertEqual(self.commands(), []) + + def test_k3s_recorded_tunnel_requires_group_for_identity_verification(self): + state = self.root / "recorded-tunnel" + state.mkdir() + (state / "tunnel.pid").write_text("99999999") + for mode in ("status", "disconnect"): + with self.subTest(mode=mode): + result = self.run_script( + "prepare-k3s-azure-vm.sh", mode, success=False, + AZURE_SUBSCRIPTION="", RESOURCE_GROUP="", + K3S_TUNNEL_STATE_DIR=str(state)) + self.assertIn("Set RESOURCE_GROUP", result.stderr) + self.assertTrue((state / "tunnel.pid").exists()) + self.assertEqual(self.commands(), []) + def assert_no_provisioning(self): for command in self.commands(): if command[0] == "az": diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md index fadbc3100..5daac50c0 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-02/solution-02.md @@ -65,7 +65,7 @@ pwd export AZURE_SUBSCRIPTION="" export AZURE_LOCATION="westeurope" -export RESOURCE_GROUP="rg-adaptive-apps" +export RESOURCE_GROUP="${RESOURCE_GROUP:-}" export AKS_CLUSTER="aks-adaptive-apps" bash resources/prepare-aks.sh @@ -101,7 +101,7 @@ Bastion-restricted Linux troubleshooting route. ```bash export AZURE_SUBSCRIPTION="" export AZURE_LOCATION="westeurope" -export RESOURCE_GROUP="rg-adaptive-apps" +export RESOURCE_GROUP="${RESOURCE_GROUP:-}" export K3S_VM_NAME="vm-adaptive-apps-k3s" bash resources/prepare-k3s-azure-vm.sh diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md index 9ea19ef85..0f997003f 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-03/solution-03.md @@ -17,17 +17,6 @@ for Azure workload identity. - 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="${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 @@ -130,6 +119,20 @@ Workstation rad configuration ### 1. Select and verify AKS +If the `aks-adaptive-apps` context is missing, connect to your lab's AKS cluster: + +```bash +export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-}" +unset KUBECONFIG +az aks get-credentials \ + --resource-group "$RESOURCE_GROUP" \ + --name aks-adaptive-apps \ + --context aks-adaptive-apps +``` + +Then select and verify it: + ```bash unset KUBECONFIG kubectl config use-context aks-adaptive-apps @@ -142,11 +145,12 @@ kubectl get namespace radius-system --ignore-not-found Hosted participants first source `artifacts/console-env.sh`; the defaults below preserve those supplied values. BYOS participants set their own subscription and -resource group. Do not replace a Console resource group with `rg-adaptive-apps`. +resource group. Replace `` with your lab's Azure resource-group +name; keep the value supplied by Console if already set. ```bash export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" -export RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-}" export AKS_CLUSTER="${AKS_CLUSTER:-aks-adaptive-apps}" export RADIUS_APP_NAME="${AKS_CLUSTER}-radius-app" @@ -163,7 +167,7 @@ Choose **one** identity option. Both use AKS OIDC federation with no client secr See Microsoft's [AKS workload identity guide](https://learn.microsoft.com/azure/aks/workload-identity-deploy-cluster) for the managed identity and federated credential commands. -#### Option A: user-assigned managed identity (required for hosted Console) +#### Option A: user-assigned managed identity (required for MicroHack Console) Create the identity inside the supplied lab resource group. These are Azure Resource Manager operations, not tenant app/SP creation. The explicit principal object ID @@ -206,7 +210,7 @@ at **Grant the identity access**. #### Option B: Entra application (BYOS with tenant permissions only) -Create an Entra application and service principal. Do not run this option in a +BYOS: Create an Entra application and service principal. Do not run this option in a hosted RG-only lab: ```bash @@ -226,7 +230,7 @@ export IDENTITY_PRINCIPAL_ID="$(az ad sp show \ --id "$APPLICATION_CLIENT_ID" --query id --output tsv)" ``` -For each Radius service account, create a federated credential. The example below is +BYOS: 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`: @@ -307,6 +311,7 @@ rad credential register azure wi \ ```bash export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-}" bash resources/prepare-k3s-azure-vm.sh connect export KUBECONFIG="$HOME/.kube/adaptive-apps-k3s.yaml" kubectl config use-context k3s-azure-vm @@ -412,7 +417,7 @@ unset KUBECONFIG kubectl config use-context aks-adaptive-apps export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" -export RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-}" export AKS_CLUSTER="aks-adaptive-apps" # Run from the Adaptive Apps MicroHack root. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md index 3591f5687..f456c4a7d 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-05/solution-05.md @@ -122,36 +122,6 @@ Derive that name from a compile-time variable rather than from `tradingDb.name`, compile to a runtime `reference('tradingDb').name` call that cannot resolve. `resources/validate-postgres-recipes.sh` guards all of these. -### Kubernetes resources created by a recipe - -The PostgreSQL recipes also create a Kubernetes Job that applies the trading schema. Two -properties of that Job are easy to get wrong and fail silently. - -**A Job's `spec.template` is immutable.** Kubernetes rejects an update that changes the pod -template of an existing Job with `spec.template: field is immutable`, so the Job has to be -replaced rather than updated. That only happens if its *name* changes, which means the name -must be derived from everything the pod template embeds — the script, the image tag and the -name of the Secret holding the password, not just the schema SQL. The recipes hash all of -those into `schemaRevision`. Radius then creates the new Job and garbage-collects the old one -through `result.resources`. - -**Line endings reach the container verbatim.** Bicep preserves the on-disk line endings of -`loadTextContent()` and of `'''…'''` multi-line strings, so a CRLF checkout embeds carriage -returns in the Job's shell script. `sh` does not treat CR as whitespace, so `do` stops -being the `do` keyword and the container exits immediately with -`syntax error: unexpected word`. Because the recipe does not wait for the Job to succeed, the -deployment still reports success and the database is simply left empty. The repository pins -these files to LF in `.gitattributes`, and the recipes additionally strip carriage returns -with `replace(..., '\r', '')` so they stay correct on any checkout. - -Verify the Job actually completed rather than trusting the deployment result: - -```bash -kubectl get jobs -n -``` - -`COMPLETIONS` must read `1/1`. - ## Manual tutorial, Stage 1: Author the Azure SQL recipe Open `iac/recipes/sql-server.bicep` and walk through: @@ -237,6 +207,25 @@ rad env switch env-azure-prod rad group switch rg-trading ``` +> [!IMPORTANT] +> `ACR_NAME` must contain only the Azure Container Registry resource name, not a URL +> or the login-server hostname. +> +> ```bash +> # Correct +> export ACR_NAME="acad3zqjinwxy6mh2" +> +> # Incorrect +> export ACR_NAME="acad3zqjinwxy6mh2.azurecr.io" +> ``` +> +> The commands below append `.azurecr.io` automatically. Including it in `ACR_NAME` +> produces a duplicated hostname and causes Radius recipe downloads to fail with +> `RecipeDownloadFailed` / `no such host`. +> +> Hosted participants should source `artifacts/console-env.sh` and retain its supplied +> `ACR_NAME`. + **Hosted Console:** reuse the supplied registry; do not run the creation block. Source and verify your supplied values: @@ -250,7 +239,7 @@ az acr show --resource-group "$RESOURCE_GROUP" --name "$ACR_NAME" \ ```bash export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" -export RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-}" export ACR_NAME="${ACR_NAME:-}" az account set --subscription "$AZURE_SUBSCRIPTION" @@ -306,20 +295,25 @@ EOF Publish the teaching recipe and both workshop-owned PostgreSQL recipes. The PostgreSQL recipes compile the same `iac/recipes/trading-schema.sql` into each implementation and -use immutable workshop tags rather than the external `latest` recipes. +use immutable workshop tags rather than the external `latest` recipes. Version `1.0.8` +retains the Azure PostgreSQL naming correction and maps size `L` to General Purpose +`Standard_D4ds_v5` (4 vCores, 16 GiB RAM), as requested by the default application. +PostgreSQL remains version 16 with 32 GiB storage. In K3s, the same portable size `L` +selects a 2 GiB memory limit rather than Azure compute. Do not republish changed source over +`1.0.6` or another existing tag; publish a new version instead. ```bash rad bicep publish \ --file iac/recipes/sql-server.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.6" + --target "br:${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.8" rad bicep publish \ --file iac/recipes/postgres-azure-flex.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.6" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.8" rad bicep publish \ --file iac/recipes/postgres-kubernetes.bicep \ - --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.6" + --target "br:${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.8" ``` **PowerShell 7:** @@ -327,15 +321,15 @@ rad bicep publish \ ```powershell rad bicep publish ` --file iac/recipes/sql-server.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.6" + --target "br:$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.8" rad bicep publish ` --file iac/recipes/postgres-azure-flex.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.6" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.8" rad bicep publish ` --file iac/recipes/postgres-kubernetes.bicep ` - --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.6" + --target "br:$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.8" ``` Register the Azure SQL teaching recipe: @@ -345,7 +339,7 @@ rad recipe register default \ --environment env-azure-prod \ --resource-type Radius.Resources/sqlDatabases \ --template-kind bicep \ - --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.6" + --template-path "${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.8" ``` Verify: @@ -424,9 +418,9 @@ rad deploy iac/aks-env.bicep \ --parameters azureSubscriptionId="$AZURE_SUBSCRIPTION" \ --parameters azureResourceGroup="$RESOURCE_GROUP" \ --parameters istioRevision="$ISTIO_REVISION" \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.6" \ + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-azure-flex:1.0.8" \ --parameters aksEgressIps="$AKS_EGRESS_IPS" \ - --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.6" + --parameters sqlDatabasesRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/sql-server:1.0.8" ``` **PowerShell 7:** @@ -477,9 +471,9 @@ rad deploy iac/aks-env.bicep ` --parameters azureSubscriptionId=$env:AZURE_SUBSCRIPTION ` --parameters azureResourceGroup=$env:RESOURCE_GROUP ` --parameters istioRevision=$IstioRevision ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.6" ` + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-azure-flex:1.0.8" ` --parameters "aksEgressIps=$($EgressIps -join ',')" ` - --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.6" + --parameters "sqlDatabasesRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/sql-server:1.0.8" ``` | Resource type | AKS/Azure backend | @@ -524,7 +518,7 @@ rad deploy iac/local-env.bicep \ --environment env-local-prod \ --parameters environmentName=env-local-prod \ --parameters namespace=env-local-prod \ - --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.6" + --parameters postgresRecipeTemplatePath="${ACR_NAME}.azurecr.io/recipes/postgres-kubernetes:1.0.8" ``` **PowerShell 7:** @@ -536,7 +530,7 @@ rad deploy iac/local-env.bicep ` --environment env-local-prod ` --parameters environmentName=env-local-prod ` --parameters namespace=env-local-prod ` - --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.6" + --parameters "postgresRecipeTemplatePath=$env:ACR_NAME.azurecr.io/recipes/postgres-kubernetes:1.0.8" ``` | Resource type | K3s/local backend | @@ -598,12 +592,6 @@ kubectl port-forward service/dashboard \ 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 @@ -611,7 +599,7 @@ The manual tutorial is the intended learning path. To automate the same configur ```bash export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" -export RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-}" export ACR_NAME="${ACR_NAME:-}" # Configure both defaults: @@ -624,6 +612,37 @@ 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. +environment definition, and verifies the registered recipes. It uses +`WORKSHOP_RECIPE_VERSION=1.0.8` by default and reuses an existing version tag instead of +overwriting it. `k3s` also needs `AZURE_SUBSCRIPTION`, `RESOURCE_GROUP`, and `ACR_NAME` +because it publishes the pinned workshop recipe before registration when needed. + +### Troubleshooting: 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`. diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md index 1cf101f59..5407ef2b6 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/walkthrough/challenge-06/solution-06.md @@ -97,7 +97,7 @@ 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 +## Stage 1: Inspect the application model Open `iac/app.bicep`. The core model declares: @@ -288,6 +288,8 @@ on a trusted, single-user workstation or devcontainer host. ### 4. Validate graph, resources, and workloads +Radius stores the application Graph. resources and workload types. Let's check if we can retrieve these from the Radius control plane. + **Bash:** ```bash @@ -471,7 +473,7 @@ Azure Database for PostgreSQL can take several minutes to provision. ```bash export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" -export RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-}" az account set --subscription "$AZURE_SUBSCRIPTION" rad app graph --application adaptive-apps @@ -493,7 +495,9 @@ az resource list \ ```powershell $env:AZURE_SUBSCRIPTION = "" -$env:RESOURCE_GROUP = "rg-adaptive-apps" +if (-not $env:RESOURCE_GROUP) { + $env:RESOURCE_GROUP = "" +} az account set --subscription $env:AZURE_SUBSCRIPTION rad app graph --application adaptive-apps @@ -523,7 +527,7 @@ active AKS control plane: ```bash rad resource expose Applications.Core/containers frontend \ --application adaptive-apps \ - --port 3000 \ + --port 3002 \ --remote-port 3000 ``` @@ -565,335 +569,12 @@ 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: +For one combined Bash check on either platform, so run for both AKS and K3S, select the intended Kubernetes context (if lost set `APP_NAMESPACE` to the application's namespace), then run in both AKS and K3S context: ```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 +bash resources/verify-trading-db.sh ``` +You will see the entered trading records per environment, they should differe and this step proof that the chain is working. The first query lists `accounts`, `orders`, `positions`, and `trades`. The second returns one row per order you placed in step 5: @@ -905,18 +586,9 @@ Order_id Order_type Placed_at Price Quantity Si 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 +> To avoid touching the firewall at all, add the client ip in the Azure portal OR 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 @@ -1035,6 +707,29 @@ Run `rad recipe list --environment ` in the matching workspace. Ret Challenge 05 and deploy `iac/local-env.bicep` or `iac/aks-env.bicep`. Do not replace a portable resource with a platform-specific declaration. +### `ServerNameAlreadyExists` for `trading-db` + +Azure PostgreSQL server names are globally unique. Recipe `1.0.6` derived the name only +from the Radius resource ID, so another workshop with the same group, application, and +resource names could already own it. Do not delete the reported server; it can belong to +another workshop. + +Confirm that this workshop has its own Azure resource group, then inspect the registration: + +```bash +rad recipe list --environment env-azure-prod +``` + +For this fresh deployment, the registration must reference `postgres-azure-flex:1.0.8`. +If it does not, return to Challenge 05: publish the `1.0.8` recipes in Stage 2 and rerun +the Stage 3 `rad deploy iac/aks-env.bicep` command with the `1.0.8` PostgreSQL recipe +path. Verify the registration before retrying the unchanged AKS +`rad deploy iac/app.bicep` command from Stage 3 here. Retries use the same `1.0.8` +server name. + +If the registration already uses `1.0.8`, check that the environment's Azure provider +scope is not shared with another workshop that uses the same Radius resource names. + ### The deployment reached the wrong platform Check all four dimensions: 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 index 64fde3978..cc119a108 100644 --- 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 @@ -908,6 +908,19 @@ Use the AKS client secret captured in Stage 3, step 2. The endpoint values match because both portfolios use the same service and realm names; the secret and upstream provider differ. +> [!IMPORTANT] +> This redeploys `iac/app.bicep`, so the Azure PostgreSQL recipe runs again. Keep the +> same recipe registration used for the healthy Challenge 06 deployment. Record the +> database host before deploying, then confirm it is unchanged afterward: +> +> ```bash +> rad resource show Radius.Resources/postgreSqlDatabases trading-db \ +> --workspace ws-azure-prod --group rg-trading --output json | jq -r '.properties.host' +> ``` +> +> A different host means a replacement server, not a rename. Stop and follow the +> recipe-version guidance in Challenge 05 before continuing. + **Bash:** ```bash 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 index cb8f5fea6..0987d4fda 100644 --- 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 @@ -533,7 +533,7 @@ name. ```bash export AZURE_SUBSCRIPTION="${AZURE_SUBSCRIPTION:-}" -export RESOURCE_GROUP="${RESOURCE_GROUP:-rg-adaptive-apps}" +export RESOURCE_GROUP="${RESOURCE_GROUP:-}" export AKS_NAME="aks-adaptive-apps" export OPENAI_RESOURCE_GROUP="" export OPENAI_ACCOUNT="" @@ -591,7 +591,9 @@ test "$WORKLOAD_IDENTITY_ENABLED" = "true" ```powershell $env:AZURE_SUBSCRIPTION = "" -$env:RESOURCE_GROUP = "rg-adaptive-apps" +if (-not $env:RESOURCE_GROUP) { + $env:RESOURCE_GROUP = "" +} $env:AKS_NAME = "aks-adaptive-apps" $env:OPENAI_RESOURCE_GROUP = "" $env:OPENAI_ACCOUNT = "" @@ -1130,7 +1132,9 @@ 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. +target-specific OIDC client secret and broker configuration are not overwritten. The +Challenge 06 `trading-db` and its Azure PostgreSQL server also remain in place; no +AI-recipe change is needed for the PostgreSQL naming correction. ## Optional cleanup