diff --git a/.github/workflows/azure.yml b/.github/workflows/azure.yml new file mode 100644 index 0000000..d5a25c3 --- /dev/null +++ b/.github/workflows/azure.yml @@ -0,0 +1,57 @@ +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: 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 new file mode 100644 index 0000000..8e404a7 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,17 @@ +name: docker +on: push + +jobs: + run: + 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: 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/.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 be2e336..f17f279 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,66 @@ $ 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 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: + +```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