diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml new file mode 100644 index 0000000..99996f0 --- /dev/null +++ b/.github/workflows/samples.yml @@ -0,0 +1,189 @@ +name: Test samples on LocalStack for Azure + +# Creates the AKS cluster on the LocalStack for Azure emulator, deploys a sample's Python or .NET web +# app with its numbered scripts, one job per app, and checks the app end to end with +# tests/test-vacation-planner.sh. As in the Samples CI of localstack-azure-samples, a pull request or a +# push runs the apps it changes (every app when a file all of them use changes), and a manual run can +# choose to run every app. + +on: + push: + branches: + - main + paths-ignore: + - '**.md' + pull_request: + branches: + - main + workflow_dispatch: + inputs: + run_mode: + description: "'all' runs every sample, 'changed' runs only the samples with modified files" + required: false + type: choice + options: + - all + - changed + default: changed + +permissions: {} + +# A new push to the same pull request cancels the run it supersedes: with one job per sample and +# language, a stale run would otherwise hold up to twenty runners for the length of a deployment +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + setup: + name: Select samples + # Pull requests from forks get no secrets, so lstk could not start the emulator there + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix: ${{ steps.select.outputs.matrix }} + has_samples: ${{ steps.select.outputs.has_samples }} + + steps: + - name: Check out repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + # The full history, so that 'changed' mode can compare with the base commit + fetch-depth: 0 + + - name: Select the samples to run + id: select + env: + # Pull requests and pushes have no inputs: they run the samples they change + RUN_MODE: ${{ inputs.run_mode || 'changed' }} + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + # Every app a job can deploy, as samples//, so a new sample needs no change here + apps=$(printf '%s\n' samples/*/*/scripts/05-deploy-app.sh | sed 's|/scripts/05-deploy-app\.sh$||') + selected=$apps + + if [ "$RUN_MODE" = changed ]; then + case $EVENT_NAME in + pull_request) base=$PR_BASE_SHA ;; + push) base=$PUSH_BEFORE_SHA ;; + *) base=origin/main ;; + esac + if git rev-parse --quiet --verify "$base^{commit}" > /dev/null; then + changed=$(git diff --name-only "$base...HEAD") + echo "Files changed since $base:" + echo "$changed" + # Every job creates the cluster with the same script and checks the app with the same test, so a + # change to either, or to this workflow, runs every app; otherwise an app runs when its folder changes + shared='^(scripts/01-system-assigned-managed-identity\.sh|tests/test-vacation-planner\.sh|\.github/workflows/samples\.yml)$' + if ! grep -q -E "$shared" <<< "$changed"; then + selected=$(while read -r app; do + if grep -q "^$app/" <<< "$changed"; then echo "$app"; fi + done <<< "$apps") + fi + else + # A push that creates the branch or rewrites its history leaves no base commit to compare with + echo "Base commit '$base' not found: running every sample" + fi + fi + + matrix=$(jq -n -R -c '[inputs | select(length > 0) | split("/") | {sample: .[1], language: .[2]}] | {include: .}' <<< "$selected") + echo "Samples to run: $(jq -r '[.include[] | "\(.sample) (\(.language))"] | if length > 0 then join(", ") else "none" end' <<< "$matrix")" + { + echo "matrix=$matrix" + echo "has_samples=$(jq '.include | length > 0' <<< "$matrix")" + } >> "$GITHUB_OUTPUT" + + sample: + name: ${{ matrix.sample }} (${{ matrix.language }}) + needs: setup + # Skip the job when the run changes no sample + if: needs.setup.outputs.has_samples == 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.setup.outputs.matrix) }} + env: + SAMPLE: ${{ matrix.sample }} + LANGUAGE: ${{ matrix.language }} + + steps: + - name: Check out repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install lstk + # Pinned like actions/checkout, so an lstk release cannot change every run at once: bump it deliberately + run: npm install -g @localstack/lstk@1.4.0 + + - name: Install sqlcmd + # The Azure SQL Database sample seeds its database with sqlcmd, which the runner image does not ship + if: matrix.sample == 'web-app-sql-database' + run: | + sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc > /dev/null + curl -fsSL "https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list" | sudo tee /etc/apt/sources.list.d/mssql-release.list > /dev/null + sudo apt-get update + sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 + echo "/opt/mssql-tools18/bin" >> "$GITHUB_PATH" + + - name: Create the SSH key the cluster script provisions the node pools with + run: ssh-keygen -t rsa -b 4096 -N '' -f ~/.ssh/id_rsa -q + + - name: Start the LocalStack for Azure emulator + env: + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + # lstk forwards LOCALSTACK_-prefixed variables to the emulator. The Azure SQL Database sample + # needs the SQL Server EULA accepted, as its README and the localstack-azure-samples CI do + LOCALSTACK_MSSQL_ACCEPT_EULA: "Y" + # The emulator looks up the k3d release behind each AKS cluster on api.github.com. Anonymous + # calls share the runner IP's 60 requests an hour, which twenty parallel jobs can exhaust, so pass + # the run's own read-only token, as the localstack-azure-samples CI does + LOCALSTACK_GITHUB_API_TOKEN: ${{ github.token }} + run: | + lstk start --type azure --non-interactive + lstk az start-interception + + # The script prompts for the network configuration and both node pool sizes: + # pick option 1 (Azure network plugin and policy) and keep the default sizes + - name: Create the AKS cluster + working-directory: scripts + run: printf '1\n\n\n' | ./01-system-assigned-managed-identity.sh + + - name: Deploy the sample + working-directory: samples/${{ matrix.sample }}/${{ matrix.language }}/scripts + env: + # Answer the Azure Files sample's two provisioning menus; the other samples do not read these + PROVISIONING_MODE: static + FILE_SHARE_PROTOCOL: smb + run: | + ./01-deploy-resources.sh + ./02-build-docker-image.sh + ./04-push-docker-image.sh + ./05-deploy-app.sh + + - name: Test the web app + run: | + variables="samples/$SAMPLE/$LANGUAGE/scripts/00-variables.sh" + namespace=$(sed -n "s/^NAMESPACE=\"\(.*\)\"/\1/p" "$variables") + service=$(sed -n "s/^SERVICE_NAME=\"\(.*\)\"/\1/p" "$variables") + ./tests/test-vacation-planner.sh "$namespace" "$service" + + - name: Show cluster state and emulator logs + if: failure() + run: | + kubectl get pods --all-namespaces -o wide || true + kubectl get events --all-namespaces --sort-by=.lastTimestamp | tail -50 || true + for ns in $(kubectl get namespaces -o name | sed 's|namespace/||' | grep vacation-planner); do + kubectl describe pods --namespace "$ns" | tail -80 || true + kubectl logs --namespace "$ns" --all-containers --tail=100 -l "app=$ns" || true + done + lstk logs | tail -300 || true diff --git a/README.md b/README.md index 31e1440..21f18db 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,7 @@ Start the emulator and point the Azure CLI at it: export LOCALSTACK_AUTH_TOKEN= # Start the LocalStack Azure emulator -IMAGE_NAME=localstack/localstack-azure localstack start -d -localstack wait -t 60 +lstk start --type azure # Route all Azure CLI calls to the emulator lstk az start-interception @@ -184,6 +183,16 @@ Both versions of the app also expose `GET /health`, the endpoint their Kubernete Alternatively, you can use a terminal UI such as [k9s](https://k9scli.io/) to select the service and start a port-forward interactively (press `` on a selected service or pod). +### Testing a deployed sample + +[tests/test-vacation-planner.sh](tests/test-vacation-planner.sh) checks a deployed web app end to end through its Kubernetes service: it waits for the Deployment to roll out, checks that `GET /health` reports the data service as reachable, then adds an activity through the web form, checks that the page lists it, deletes it, and checks that it is gone while a second activity stays listed. It drives both the Python (Flask) and the .NET (Razor Pages) apps. Pass the sample's namespace and service name, for example: + +```bash +./tests/test-vacation-planner.sh vacation-planner-blob vacation-planner-blob +``` + +The [Test samples on LocalStack for Azure](.github/workflows/samples.yml) workflow runs this against the emulator, one job per sample app, in Python and .NET. Each job creates the cluster with `scripts/01-system-assigned-managed-identity.sh`, deploys the app with its numbered scripts, and runs the test. A pull request or a push to `main` runs the apps whose folders it changes, and all 20 when it changes the cluster script, the test or the workflow. A manual run tests the changed apps by default, or all 20 with `run_mode: all`. + ## Tutorials Beyond the Vacation Planner samples, the repository includes standalone tutorials that exercise specific AKS capabilities. Unlike the samples above, they do not deploy the web app. diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh index 2a7562a..26de57e 100644 --- a/samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh @@ -1,7 +1,7 @@ # Variables # Azure Resources -PREFIX='zeus' +PREFIX='local' SUFFIX='test' LOCATION='italynorth' RESOURCE_GROUP_NAME="${PREFIX}-rg" diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/00-variables.sh b/samples/web-app-in-cluster-postgresql/python/scripts/00-variables.sh index 511c161..77786cd 100644 --- a/samples/web-app-in-cluster-postgresql/python/scripts/00-variables.sh +++ b/samples/web-app-in-cluster-postgresql/python/scripts/00-variables.sh @@ -1,7 +1,7 @@ # Variables # Azure Resources -PREFIX='zeus' +PREFIX='local' SUFFIX='test' LOCATION='italynorth' RESOURCE_GROUP_NAME="${PREFIX}-rg" diff --git a/samples/web-app-sql-database/dotnet/scripts/01-deploy-resources.sh b/samples/web-app-sql-database/dotnet/scripts/01-deploy-resources.sh index a305d58..aa241c2 100755 --- a/samples/web-app-sql-database/dotnet/scripts/01-deploy-resources.sh +++ b/samples/web-app-sql-database/dotnet/scripts/01-deploy-resources.sh @@ -143,6 +143,7 @@ fi echo "Creating SQL login [$DATABASE_USER_NAME] on server [$SQL_SERVER_FQDN]..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -U "$ADMIN_USER" \ -P "$ADMIN_PASSWORD" \ -d master \ @@ -157,6 +158,7 @@ fi echo "Creating database user [$DATABASE_USER_NAME] in database [$SQL_DATABASE_NAME]..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -U "$ADMIN_USER" \ -P "$ADMIN_PASSWORD" \ -d "$SQL_DATABASE_NAME" \ @@ -171,6 +173,7 @@ fi echo "Granting roles db_datareader, db_datawriter, db_ddladmin to [$DATABASE_USER_NAME]..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -U "$ADMIN_USER" \ -P "$ADMIN_PASSWORD" \ -d "$SQL_DATABASE_NAME" \ @@ -185,6 +188,7 @@ fi echo "Creating table dbo.Activities in database [$SQL_DATABASE_NAME]..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -U "$ADMIN_USER" \ -P "$ADMIN_PASSWORD" \ -d "$SQL_DATABASE_NAME" \ @@ -200,6 +204,7 @@ fi # Insert data echo "Inserting test data into [Activities] table..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -d "$SQL_DATABASE_NAME" \ -U "$DATABASE_USER_NAME" \ -P "$DATABASE_USER_PASSWORD" \ @@ -228,6 +233,7 @@ fi # Query data echo "Querying test data from [Activities] table..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -d "$SQL_DATABASE_NAME" \ -U "$DATABASE_USER_NAME" \ -P "$DATABASE_USER_PASSWORD" \ diff --git a/samples/web-app-sql-database/python/scripts/01-deploy-resources.sh b/samples/web-app-sql-database/python/scripts/01-deploy-resources.sh index a305d58..aa241c2 100755 --- a/samples/web-app-sql-database/python/scripts/01-deploy-resources.sh +++ b/samples/web-app-sql-database/python/scripts/01-deploy-resources.sh @@ -143,6 +143,7 @@ fi echo "Creating SQL login [$DATABASE_USER_NAME] on server [$SQL_SERVER_FQDN]..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -U "$ADMIN_USER" \ -P "$ADMIN_PASSWORD" \ -d master \ @@ -157,6 +158,7 @@ fi echo "Creating database user [$DATABASE_USER_NAME] in database [$SQL_DATABASE_NAME]..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -U "$ADMIN_USER" \ -P "$ADMIN_PASSWORD" \ -d "$SQL_DATABASE_NAME" \ @@ -171,6 +173,7 @@ fi echo "Granting roles db_datareader, db_datawriter, db_ddladmin to [$DATABASE_USER_NAME]..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -U "$ADMIN_USER" \ -P "$ADMIN_PASSWORD" \ -d "$SQL_DATABASE_NAME" \ @@ -185,6 +188,7 @@ fi echo "Creating table dbo.Activities in database [$SQL_DATABASE_NAME]..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -U "$ADMIN_USER" \ -P "$ADMIN_PASSWORD" \ -d "$SQL_DATABASE_NAME" \ @@ -200,6 +204,7 @@ fi # Insert data echo "Inserting test data into [Activities] table..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -d "$SQL_DATABASE_NAME" \ -U "$DATABASE_USER_NAME" \ -P "$DATABASE_USER_PASSWORD" \ @@ -228,6 +233,7 @@ fi # Query data echo "Querying test data from [Activities] table..." sqlcmd -S "$SQL_SERVER_FQDN" \ + -N -C \ -d "$SQL_DATABASE_NAME" \ -U "$DATABASE_USER_NAME" \ -P "$DATABASE_USER_PASSWORD" \ diff --git a/scripts/01-user-assigned-managed-identity.sh b/scripts/01-user-assigned-managed-identity.sh index 40240a8..cedcc11 100755 --- a/scripts/01-user-assigned-managed-identity.sh +++ b/scripts/01-user-assigned-managed-identity.sh @@ -98,9 +98,9 @@ registering_extensions=() ok=0 # Print the menu -echo "====================================" -echo "Run Docker Container (1-3): " -echo "====================================" +echo "======================================" +echo "Select Network Plugin and Policy (1-3): " +echo "======================================" options=( "Azure Network Plugin + Azure Network Policy" "Azure Network Plugin + Cilium Network Policy" diff --git a/tests/test-vacation-planner.sh b/tests/test-vacation-planner.sh new file mode 100755 index 0000000..0bd3c78 --- /dev/null +++ b/tests/test-vacation-planner.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# Check a deployed Vacation Planner sample end to end through its Kubernetes service: +# 1. the Deployment rolls out and GET /health reports the data service as reachable +# 2. adding an activity through the web form stores it, and the page lists it +# 3. deleting it through the web form removes it from the page +# It drives both versions of every sample: the Python (Flask) and the .NET (Razor Pages) web app. +# +# Usage: tests/test-vacation-planner.sh [deployment] +set -euo pipefail + +NAMESPACE=$1 +SERVICE=$2 +DEPLOYMENT=${3:-$2} +LOCAL_PORT=${LOCAL_PORT:-8080} +MAX_ATTEMPTS=${MAX_ATTEMPTS:-60} +BASE_URL="http://localhost:$LOCAL_PORT" + +kubectl rollout status "deployment/$DEPLOYMENT" --namespace "$NAMESPACE" --timeout=600s + +PF_LOG=$(mktemp) +COOKIES=$(mktemp) +kubectl port-forward --namespace "$NAMESPACE" "service/$SERVICE" "$LOCAL_PORT:80" > "$PF_LOG" 2>&1 & +PF_PID=$! +trap 'kill "$PF_PID" 2>/dev/null || true; rm -f "$PF_LOG" "$COOKIES"' EXIT + +# Wait until kubectl listens on LOCAL_PORT. If it exits instead (the port is taken, for example by a +# port-forward left running for another sample), stop rather than test whatever answers on that port. +until grep -q '^Forwarding from' "$PF_LOG"; do + if ! kill -0 "$PF_PID" 2>/dev/null; then + echo "Test failed: kubectl port-forward exited: $(cat "$PF_LOG")" + exit 1 + fi + sleep 1 +done + +health="" +for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + health=$(curl -s -m 10 "$BASE_URL/health" || true) + echo "$health" | jq -e '.status == "ok"' > /dev/null 2>&1 && break + echo "Attempt $attempt/$MAX_ATTEMPTS: /health returned '$health'" + sleep 5 +done +if ! echo "$health" | jq -e '.status == "ok"' > /dev/null 2>&1; then + echo "Test failed: /health never reported ok" + exit 1 +fi +echo "PASS: /health reports the data service as reachable" + +# Talk to the app the way a browser does. The .NET apps are Razor Pages, which reject a form post without the +# antiforgery token the page renders and the cookie sent with it; the Python apps ignore both. +get_page() { + curl -sS -m 30 -b "$COOKIES" -c "$COOKIES" "$BASE_URL/" +} + +# Post a form and print the HTTP status: post_form [=...] +post_form() { + local path=$1 field + shift + local args=(-sS -m 30 -o /dev/null -w '%{http_code}' -b "$COOKIES" -c "$COOKIES" -X POST) + for field in "$@"; do + args+=(--data-urlencode "$field") + done + if [ -n "$token" ]; then + args+=(--data-urlencode "__RequestVerificationToken=$token") + fi + curl "${args[@]}" "$BASE_URL$path" +} + +# Print the id of the row that lists an activity on the last page fetched, or nothing. +# Each row carries the activity id and text: +row_id_of() { + grep -o "data-id=\"[^\"]*\" data-activity=\"$1\"" <<< "$page" | sed 's/^data-id="\([^"]*\)".*/\1/' || true +} + +# Most samples delete through POST /delete/; the Azure Files sample posts the id to /delete +delete_activity() { + if grep -q 'name="activity_id"' <<< "$page"; then + post_form /delete "activity_id=$1" + else + post_form "/delete/$1" + fi +} + +page=$(get_page) +token=$(sed -n '/name="__RequestVerificationToken"/{s/.*name="__RequestVerificationToken" type="hidden" value="\([^"]*\)".*/\1/p;q;}' <<< "$page") + +ACTIVITY="CI check $(date +%s)" +status=$(post_form / "activity=$ACTIVITY") +if [ "$status" != "302" ]; then + echo "Test failed: adding an activity returned HTTP $status, expected a 302 redirect" + exit 1 +fi +page=$(get_page) +row_id=$(row_id_of "$ACTIVITY") +if [ -z "$row_id" ]; then + echo "Test failed: the page does not list the activity '$ACTIVITY'" + exit 1 +fi +echo "PASS: added activity '$ACTIVITY' (id $row_id) and the page lists it" + +# Keep a second activity listed while the first is deleted. An app that swallows a failed read renders an +# empty list rather than an error (the in-cluster and blob apps do), so without it the absence check below +# also passes on a page that lists nothing. The blob and Azure Files samples name each activity after the +# current second, so wait one first. +sleep 1 +KEPT_ACTIVITY="CI keep $(date +%s)" +post_form / "activity=$KEPT_ACTIVITY" > /dev/null + +status=$(delete_activity "$row_id") +if [ "$status" != "302" ]; then + echo "Test failed: deleting the activity returned HTTP $status, expected a 302 redirect" + exit 1 +fi +page=$(get_page) +kept_id=$(row_id_of "$KEPT_ACTIVITY") +if [ -z "$kept_id" ]; then + echo "Test failed: the page no longer lists '$KEPT_ACTIVITY', so it cannot show the deleted activity is gone" + exit 1 +fi +if [ -n "$(row_id_of "$ACTIVITY")" ]; then + echo "Test failed: the activity is still listed after deleting it" + exit 1 +fi +echo "PASS: deleted the activity and the page no longer lists it" +delete_activity "$kept_id" > /dev/null || true + +echo "Test passed: the Vacation Planner in [$NAMESPACE] stores, lists and deletes activities"