From 50e43c8aef8a5b87c04efcee207f7321acf73600 Mon Sep 17 00:00:00 2001 From: Alexander Egorov Date: Tue, 13 Oct 2026 16:27:49 +0200 Subject: [PATCH 1/2] Deploy to Azure Container Apps instead of GKE; smoke-test the image in CI The GKE deployment last succeeded before July 2024 and pushed to gcr.io, which stopped accepting images in March 2025. A Kubernetes cluster with a load balancer also costs money when idle. - docker.yml builds the image, runs it and checks the responses on every push. - azure.yml deploys to Azure Container Apps on demand and monthly, then runs the same checks against the deployed app. The image lives on GitHub Container Registry, and GitHub Actions signs in to Azure with OpenID Connect, so there is no long-lived secret to expire. - smoke_test.sh holds the response checks used by both workflows. - README documents the one-time Azure and GitHub setup. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/azure.yml | 56 +++++++++++++++++++++++++++ .github/workflows/docker.yml | 17 +++++++++ .github/workflows/gke.yml | 74 ------------------------------------ Dockerfile | 2 + README.md | 69 ++++++++++++++++++++++++++++++--- deployment.yaml | 35 ----------------- smoke_test.sh | 36 ++++++++++++++++++ 7 files changed, 174 insertions(+), 115 deletions(-) create mode 100644 .github/workflows/azure.yml create mode 100644 .github/workflows/docker.yml delete mode 100644 .github/workflows/gke.yml delete mode 100644 deployment.yaml create mode 100755 smoke_test.sh diff --git a/.github/workflows/azure.yml b/.github/workflows/azure.yml new file mode 100644 index 0000000..f85e6d8 --- /dev/null +++ b/.github/workflows/azure.yml @@ -0,0 +1,56 @@ +name: Deploy to Azure Container Apps + +# Manual and monthly rather than on every push: the deployment is a demonstration, and a +# monthly run shows within weeks when the cloud side has rotted. +on: + workflow_dispatch: + schedule: + - cron: '0 6 1 * *' + +permissions: + contents: read + packages: write + id-token: write + +env: + IMAGE: ghcr.io/winterframework/winter-getting-started + RESOURCE_GROUP: winter-getting-started + CONTAINER_APP: winter-getting-started + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: azure + url: ${{ steps.deploy.outputs.url }} + steps: + - uses: actions/checkout@v7 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + run: | + docker build --tag "$IMAGE:$GITHUB_SHA" . + docker push "$IMAGE:$GITHUB_SHA" + + - uses: azure/login@v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Deploy + id: deploy + run: | + az containerapp update --name "$CONTAINER_APP" --resource-group "$RESOURCE_GROUP" \ + --image "$IMAGE:$GITHUB_SHA" --output none + fqdn=$(az containerapp show --name "$CONTAINER_APP" --resource-group "$RESOURCE_GROUP" \ + --query properties.configuration.ingress.fqdn --output tsv) + echo "url=https://$fqdn" >> "$GITHUB_OUTPUT" + + - name: Smoke test + run: ./smoke_test.sh "${{ steps.deploy.outputs.url }}" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..732b91f --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,17 @@ +name: docker +on: push + +jobs: + smoke-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Build image + run: docker build --tag winter-getting-started . + - name: Start container + run: docker run --detach --name app --publish 8080:8080 winter-getting-started + - name: Smoke test + run: ./smoke_test.sh http://localhost:8080 + - name: Container logs + if: failure() + run: docker logs app diff --git a/.github/workflows/gke.yml b/.github/workflows/gke.yml deleted file mode 100644 index 39aead1..0000000 --- a/.github/workflows/gke.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Deploy to GKE - -on: - push: - branches: - - master - -env: - PROJECT_ID: winter-framework - IMAGE_NAME: winter-getting-started - CLUSTER_NAME: autopilot-cluster-1 - CLUSTER_ZONE: europe-central2 - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Build and tag Docker image - run: | - docker build -t $IMAGE_NAME . - docker tag $IMAGE_NAME gcr.io/$PROJECT_ID/$IMAGE_NAME:latest - - - id: 'auth' - uses: google-github-actions/auth@v1 - with: - credentials_json: '${{ secrets.GCP_SA_KEY }}' - - - name: Configure Google Cloud SDK - uses: google-github-actions/setup-gcloud@v1 - with: - project_id: '${{ env.PROJECT_ID }}' - - - name: Authenticate Docker with Google Artifact Registry - run: gcloud auth configure-docker - - - name: Push Docker image to Google Artifact Registry - run: docker push gcr.io/$PROJECT_ID/$IMAGE_NAME:latest - - deploy: - runs-on: ubuntu-latest - needs: [build] - environment: - name: GKE - url: ${{ steps.deploy.outputs.service_url }} - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - id: 'auth' - uses: google-github-actions/auth@v1 - with: - credentials_json: '${{ secrets.GCP_SA_KEY }}' - - - name: Configure Google Cloud SDK - uses: google-github-actions/setup-gcloud@v1 - with: - project_id: '${{ env.PROJECT_ID }}' - - - name: Authenticate Docker with Google Artifact Registry - run: gcloud auth configure-docker - - - uses: simenandre/setup-gke-gcloud-auth-plugin@v1 - - - name: Deploy to GKE - id: deploy - run: | - gcloud container clusters get-credentials $CLUSTER_NAME --zone $CLUSTER_ZONE - kubectl apply -f deployment.yaml - echo "service_url=http://$(kubectl get service winter-getting-started-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}')" >> $GITHUB_OUTPUT diff --git a/Dockerfile b/Dockerfile index 4ab518d..f394693 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM python:3.13-slim +LABEL org.opencontainers.image.source=https://github.com/WinterFramework/winter-getting-started + ENV PYTHONUNBUFFERED=1 POETRY_VERSION=2.5.1 RUN pip install --no-cache-dir "poetry==$POETRY_VERSION" diff --git a/README.md b/README.md index 1f1d9d4..47effb1 100644 --- a/README.md +++ b/README.md @@ -176,10 +176,67 @@ $ poetry run pytest See [conftest.py](conftest.py) for an `httpx` client that calls the WSGI application in-process. -How to deploy to GKE --------------------- +How to deploy to Azure Container Apps +------------------------------------- -The repository contains the example deployment to GKE. Files of interest: -- [Dockerfile](Dockerfile) - Dockerfile to build the application image -- [deployment.yaml](deployment.yaml) - k8s deployment configuration -- [.github/workflows/gke.yml](.github/workflows/gke.yml) - GitHub workflow to automatically deploy the application to GKE on push to master +The application runs as a container app that scales to zero when idle, so a demo fits in the monthly free +grant of the Consumption plan. The image is public on GitHub Container Registry, which needs no registry +in Azure. GitHub Actions signs in to Azure with OpenID Connect, so no long-lived secret is stored. + +Files of interest: +- [Dockerfile](Dockerfile) - the application image +- [.github/workflows/docker.yml](.github/workflows/docker.yml) - builds the image, runs it and checks the responses on every push +- [.github/workflows/azure.yml](.github/workflows/azure.yml) - deploys to Azure on demand and once a month, then checks the deployed app +- [smoke_test.sh](smoke_test.sh) - the response checks used by both workflows + +One-time setup with the Azure CLI and the GitHub CLI: + +```shell +RESOURCE_GROUP=winter-getting-started +CONTAINER_APP=winter-getting-started +LOCATION=westeurope +REPOSITORY=WinterFramework/winter-getting-started + +az group create --name $RESOURCE_GROUP --location $LOCATION + +# No Log Analytics workspace: it would be billed separately +az containerapp env create --name $CONTAINER_APP-env --resource-group $RESOURCE_GROUP \ + --location $LOCATION --logs-destination none + +# A placeholder image until the first deployment +az containerapp create --name $CONTAINER_APP --resource-group $RESOURCE_GROUP \ + --environment $CONTAINER_APP-env --image mcr.microsoft.com/k8se/quickstart:latest \ + --ingress external --target-port 8080 --min-replicas 0 --max-replicas 1 --cpu 0.25 --memory 0.5Gi + +# An identity for GitHub Actions, trusted only for the "azure" environment of this repository +APP_ID=$(az ad app create --display-name $CONTAINER_APP-github --query appId --output tsv) +az ad sp create --id $APP_ID +az ad app federated-credential create --id $APP_ID --parameters "{ + \"name\": \"github-azure-environment\", + \"issuer\": \"https://token.actions.githubusercontent.com\", + \"subject\": \"repo:$REPOSITORY:environment:azure\", + \"audiences\": [\"api://AzureADTokenExchange\"] +}" +az role assignment create --assignee $APP_ID --role Contributor \ + --scope "$(az group show --name $RESOURCE_GROUP --query id --output tsv)" + +# Deployments only from master +gh api --method PUT repos/$REPOSITORY/environments/azure \ + --input - <<< '{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}' +gh api --method POST repos/$REPOSITORY/environments/azure/deployment-branch-policies -f name=master + +gh secret set AZURE_CLIENT_ID --repo $REPOSITORY --body $APP_ID +gh secret set AZURE_TENANT_ID --repo $REPOSITORY --body "$(az account show --query tenantId --output tsv)" +gh secret set AZURE_SUBSCRIPTION_ID --repo $REPOSITORY --body "$(az account show --query id --output tsv)" +``` + +Then run the workflow once: + +```shell +gh workflow run azure.yml --repo $REPOSITORY +``` + +After the first run, make the `winter-getting-started` package public in the package settings on GitHub, +so that Azure can pull the image without credentials, and run the workflow again. + +A budget alert on the resource group in the Azure portal guards against surprises. diff --git a/deployment.yaml b/deployment.yaml deleted file mode 100644 index f57de21..0000000 --- a/deployment.yaml +++ /dev/null @@ -1,35 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: winter-getting-started-deployment - labels: - app: winter-getting-started -spec: - replicas: 2 - selector: - matchLabels: - app: winter-getting-started - template: - metadata: - labels: - app: winter-getting-started - spec: - containers: - - name: winter-getting-started-app - image: gcr.io/winter-framework/winter-getting-started:latest - ports: - - containerPort: 8080 - ---- -apiVersion: v1 -kind: Service -metadata: - name: winter-getting-started-service -spec: - type: LoadBalancer - selector: - app: winter-getting-started - ports: - - protocol: TCP - port: 80 - targetPort: 8080 diff --git a/smoke_test.sh b/smoke_test.sh new file mode 100755 index 0000000..84c0263 --- /dev/null +++ b/smoke_test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Checks that a running instance of the application answers as expected. +# Usage: ./smoke_test.sh http://localhost:8080 +set -euo pipefail + +base_url="${1%/}" + +# A fresh container or a scaled-to-zero Container App needs time to start. +for attempt in $(seq 1 30); do + if curl --silent --fail --output /dev/null "$base_url/greeting/"; then + break + fi + if [ "$attempt" = 30 ]; then + echo "No answer from $base_url/greeting/" >&2 + exit 1 + fi + sleep 5 +done + +check() { + local path="$1" expected_status="$2" expected_body="$3" + local body status + body=$(curl --silent --show-error --write-out '\n%{http_code}' "$base_url$path") + status="${body##*$'\n'}" + body="${body%$'\n'*}" + if [ "$status" != "$expected_status" ] || [[ "$body" != *"$expected_body"* ]]; then + echo "GET $path: expected $expected_status containing '$expected_body', got $status: $body" >&2 + exit 1 + fi + echo "GET $path: $status" +} + +check /greeting/ 200 '"Hello from Winter API!"' +check /greeting/a-name-that-is-way-too-long/ 400 '"detail": "Name must be at most 20 characters long"' +check /openapi.json 200 '"/greeting/{name}/"' +check / 200 'swagger-ui' From 069f6c44d52074af6a96d6c20132469b3503aa75 Mon Sep 17 00:00:00 2001 From: Alexander Egorov Date: Tue, 13 Oct 2026 16:41:51 +0200 Subject: [PATCH 2/2] Check the container and the deployment with curl instead of a shell test script The pytest suite already tests the responses. CI only needs to know that the image starts and the deployment answers. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/azure.yml | 5 +++-- .github/workflows/docker.yml | 6 +++--- README.md | 5 ++--- smoke_test.sh | 36 ------------------------------------ 4 files changed, 8 insertions(+), 44 deletions(-) delete mode 100755 smoke_test.sh diff --git a/.github/workflows/azure.yml b/.github/workflows/azure.yml index f85e6d8..d5a25c3 100644 --- a/.github/workflows/azure.yml +++ b/.github/workflows/azure.yml @@ -52,5 +52,6 @@ jobs: --query properties.configuration.ingress.fqdn --output tsv) echo "url=https://$fqdn" >> "$GITHUB_OUTPUT" - - name: Smoke test - run: ./smoke_test.sh "${{ steps.deploy.outputs.url }}" + - name: Check that the app answers + # Scaled to zero, the app needs time to start + run: curl --fail --retry 15 --retry-all-errors --retry-delay 10 "${{ steps.deploy.outputs.url }}/greeting/" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 732b91f..8e404a7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,7 +2,7 @@ name: docker on: push jobs: - smoke-test: + run: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -10,8 +10,8 @@ jobs: run: docker build --tag winter-getting-started . - name: Start container run: docker run --detach --name app --publish 8080:8080 winter-getting-started - - name: Smoke test - run: ./smoke_test.sh http://localhost:8080 + - name: Check that the app answers + run: curl --fail --retry 10 --retry-all-errors --retry-delay 2 http://localhost:8080/greeting/ - name: Container logs if: failure() run: docker logs app diff --git a/README.md b/README.md index 47effb1..b88bace 100644 --- a/README.md +++ b/README.md @@ -185,9 +185,8 @@ in Azure. GitHub Actions signs in to Azure with OpenID Connect, so no long-lived Files of interest: - [Dockerfile](Dockerfile) - the application image -- [.github/workflows/docker.yml](.github/workflows/docker.yml) - builds the image, runs it and checks the responses on every push -- [.github/workflows/azure.yml](.github/workflows/azure.yml) - deploys to Azure on demand and once a month, then checks the deployed app -- [smoke_test.sh](smoke_test.sh) - the response checks used by both workflows +- [.github/workflows/docker.yml](.github/workflows/docker.yml) - builds the image and checks that the container answers, on every push +- [.github/workflows/azure.yml](.github/workflows/azure.yml) - deploys to Azure on demand and once a month, then checks that the deployment answers One-time setup with the Azure CLI and the GitHub CLI: diff --git a/smoke_test.sh b/smoke_test.sh deleted file mode 100755 index 84c0263..0000000 --- a/smoke_test.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Checks that a running instance of the application answers as expected. -# Usage: ./smoke_test.sh http://localhost:8080 -set -euo pipefail - -base_url="${1%/}" - -# A fresh container or a scaled-to-zero Container App needs time to start. -for attempt in $(seq 1 30); do - if curl --silent --fail --output /dev/null "$base_url/greeting/"; then - break - fi - if [ "$attempt" = 30 ]; then - echo "No answer from $base_url/greeting/" >&2 - exit 1 - fi - sleep 5 -done - -check() { - local path="$1" expected_status="$2" expected_body="$3" - local body status - body=$(curl --silent --show-error --write-out '\n%{http_code}' "$base_url$path") - status="${body##*$'\n'}" - body="${body%$'\n'*}" - if [ "$status" != "$expected_status" ] || [[ "$body" != *"$expected_body"* ]]; then - echo "GET $path: expected $expected_status containing '$expected_body', got $status: $body" >&2 - exit 1 - fi - echo "GET $path: $status" -} - -check /greeting/ 200 '"Hello from Winter API!"' -check /greeting/a-name-that-is-way-too-long/ 400 '"detail": "Name must be at most 20 characters long"' -check /openapi.json 200 '"/greeting/{name}/"' -check / 200 'swagger-ui'