Files
beer_data/.gitea/workflows/build_push.yml
T
hermes b73df93f20
Build and Push Image / Unit tests (push) Successful in 1m8s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
feat(ci): add the deploy job so the app deploys from the repo
The workflow built and pushed the image but never deployed it - the
cluster objects had been created by hand. This adds a `deploy` job that
applies the manifests in `kube/` and rolls the deployment.

The deploy job is separate from the build job on purpose. The
`KUBEC_CONFIG_BUILDX` secret is cluster-admin-equivalent, and a build step
that never talks to the cluster should not be able to read it.

Three things this job does that a naive `kubectl apply` does not:

1. `kubectl rollout restart`. The image tag is `:latest`, which is immutable
   in the manifest, so `apply` alone does not roll the pods. Without the
   restart the deployment reports success while the old pods keep serving
   the old image - a deploy that silently changes nothing.

2. Verifies the running digest against the registry's `:latest`. CI green and
   pods `Ready` do not prove the new code is live; the digest comparison does.

3. Smoke tests the running app - `/healthz` and `/readyz` - from inside the
   pod, so a deploy that comes up but cannot serve is caught in CI rather
   than by the next person to open the page.

The Secrets (`beer-db`, `beer-app`) are hand-provisioned and are deliberately
not in the apply set, so a deploy cannot clobber them.
2026-11-09 11:46:06 +10:00

176 lines
7.1 KiB
YAML

name: Build and Push Image
on: [push]
jobs:
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# NOTE: no actions/setup-python here. That action downloads a Python
# build from GitHub, which this runner cannot reach reliably - the job
# hung on it. The runner image already carries a usable python3, so
# the tests use it directly.
- name: Show the Python version
run: python3 --version
- name: Install dependencies
run: |
python3 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run tests
run: |
. .venv/bin/activate
python -m pytest tests/ -q
# NOTE: the build job deliberately does NOT have a kubeconfig. The
# `KUBEC_CONFIG_BUILDX` secret is cluster-admin-equivalent, and a build step
# that has no need of the cluster should not be able to read it. The deploy
# step, added in a later PR, gets its own job with the kubeconfig - that way
# a compromised dependency in the build cannot reach the cluster.
build:
name: Build and push image
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
needs: test
if: gitea.ref == 'refs/heads/master'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx (remote -> persistent buildkit daemon)
uses: docker/setup-buildx-action@v3
with:
# The `kubernetes` driver talks to the cluster API, which needs the
# k3s CA certificate. The job container does not have it, so the
# bootstrap failed with "x509: certificate signed by unknown
# authority".
#
# The `remote` driver talks straight to the buildkit daemon that is
# already running in the cluster, so no cluster API access (and no
# kubeconfig) is needed at all. This is the same pattern the
# wedding-photos workflow uses successfully.
#
# NOTE: `endpoint` must be passed as a top-level key. The action
# appends it as the POSITIONAL argument to
# `buildx create --driver remote <endpoint>`; putting it in
# driver-opts instead yields "ERROR: no remote endpoint provided".
driver: remote
endpoint: tcp://buildkit.gitea-runner.svc:1234
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: git.aridgwayweb.com
username: armistace
password: ${{ secrets.REG_PASSWORD }}
- name: Build Base Image
uses: docker/build-push-action@v5
with:
context: .
push: true
file: base.Dockerfile
# amd64 only. A multi-arch build runs qemu emulation, which crashes
# the buildkit pod on this NUC (exit 255, SandboxChanged) and the
# build then hangs for many minutes. The cluster is amd64.
platforms: linux/amd64
tags: |
git.aridgwayweb.com/armistace/beer_base_image:latest
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
push: true
file: flask.Dockerfile
platforms: linux/amd64
tags: |
git.aridgwayweb.com/armistace/beer-data:latest
# The deploy job is separate from the build job on purpose. The
# `KUBEC_CONFIG_BUILDX` secret is cluster-admin-equivalent, and a build
# step that never talks to the cluster should not be able to read it - a
# compromised dependency in the build would otherwise have a direct path to
# the cluster.
deploy:
name: Deploy to the cluster
runs-on: ubuntu-latest
needs: build
if: gitea.ref == 'refs/heads/master'
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create Kubeconfig
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBEC_CONFIG_BUILDX }}" > $HOME/.kube/config
- name: Install kubectl
run: |
apt-get update
apt-get install -y apt-transport-https ca-certificates curl gnupg
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.33/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
chmod 644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.33/deb/ /' | tee /etc/apt/sources.list.d/kubernetes.list
apt-get update
apt-get install -y kubectl
- name: Deploy
run: |
# Apply the namespace and the non-secret manifests. The Secrets
# (beer-db, beer-app) are hand-provisioned and are NOT in the repo,
# so they are deliberately not in the apply set.
kubectl apply -f kube/beer_namespace.yaml
kubectl apply -f kube/beer-deployment.yaml
kubectl apply -f kube/beer-service.yaml
# The image tag is `:latest`, which is immutable in the manifest, so
# `kubectl apply` alone does NOT roll the pods. Without the restart
# the deployment reports success while the old pods keep serving
# the old image.
kubectl -n beer rollout restart deployment/beer-deployment
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
- name: Verify the new image is actually running
run: |
# CI green and pods Ready do NOT prove the new code is live. Compare
# the running digest against the registry's :latest.
RUNNING=$(kubectl -n beer get pod -l app=beer-data \
-o jsonpath='{.items[0].status.containerStatuses[0].imageID}')
REGISTRY=$(curl -s -u "armistace:${{ secrets.REG_PASSWORD }}" -I \
"https://git.aridgwayweb.com/v2/armistace/beer-data/manifests/latest" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
| grep -i docker-content-digest | awk '{print $2}' | tr -d '\r')
echo "running: $RUNNING"
echo "registry: $REGISTRY"
if [ "$RUNNING" != "$REGISTRY" ]; then
echo "ERROR: the running pod is not the image that was just pushed"
exit 1
fi
- name: Smoke test the deployed app
run: |
# The pods are behind a NodePort. Hit the service directly from
# inside the cluster.
for i in $(seq 1 12); do
if kubectl -n beer exec deploy/beer-deployment -- \
python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5000/healthz').status==200 else 1)"; then
echo "healthz OK"
break
fi
echo "waiting for the app to come up ($i/12)"
sleep 5
done
kubectl -n beer exec deploy/beer-deployment -- \
python -c "import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:5000/readyz'); print('readyz:', r.read().decode()); sys.exit(0 if r.status==200 else 1)"