Files
beer_data/.gitea/workflows/build_push.yml
T
hermes 687339bb9e
Build and Push Image / Unit tests (push) Successful in 55s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
fix(ci): self-heal a stale cluster CA in the deploy job
The deploy job failed on its first run:

    error: error validating "kube/beer_namespace.yaml": error validating
    data: failed to download openapi: Get "https://192.168.178.160:6443/openapi/v2":
    tls: failed to verify certificate: x509: certificate signed by unknown authority

This is the same class of problem as the buildx driver earlier: the
certificate-authority-data embedded in the kubeconfig secret does not match
the CA the API server is currently presenting.

Rather than requiring the secret to be regenerated by hand every time the
cluster's CA is rotated, the job now refreshes it:

- The API server publishes its own CA at `/cacerts`.
- If kubectl cannot verify the server, the job fetches that CA (over an
  insecure connection — verifying it is the problem being solved) and pins
  it explicitly with `kubectl config set-cluster`.
- It then retries, and fails with a clear message if that still does not
  work.

Verified by deliberately corrupting the CA in a working kubeconfig: the
failure reproduces exactly, and the recovery path restores connectivity
(`Server Version: v1.34.5+k3s1`).

The secret is also switched from `KUBEC_CONFIG_BUILDX` to
`KUBEC_CONFIG_BUILDX_NEW_2`, which is the one the working `wedding-photos`
workflow uses.
2026-11-09 12:08:49 +10:00

251 lines
11 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
git.aridgwayweb.com/armistace/beer-data:${{ github.sha }}
# 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
# Written with restrictive permissions: the kubeconfig is
# cluster-admin-equivalent, so it should not be readable by
# anything else on the runner.
install -m 600 /dev/null $HOME/.kube/config
echo "${{ secrets.KUBEC_CONFIG_BUILDX_NEW_2 }}" > $HOME/.kube/config
chmod 600 $HOME/.kube/config
- name: Verify the cluster is reachable
run: |
# Fail fast, and with a useful message, if the kubeconfig cannot
# talk to the API server.
#
# The certificate-authority-data inside the kubeconfig is the k3s
# server CA. If the cluster's CA has been rotated since the secret
# was created, kubectl fails with:
# tls: failed to verify certificate: x509: certificate signed by
# unknown authority
# which is a confusing way to learn that a secret is stale.
if kubectl version --request-timeout=15s >/dev/null 2>&1; then
echo "cluster reachable"
exit 0
fi
echo "the kubeconfig could not verify the API server - refreshing the CA"
# The API server publishes its own CA at /cacerts. Fetch it over an
# insecure connection - we cannot verify it yet, that is the problem
# being solved - and pin it explicitly. This makes the deploy
# self-healing after a CA rotation, instead of needing the secret
# to be regenerated by hand.
SERVER=$(kubectl config view --raw -o jsonpath='{.clusters[0].cluster.server}')
echo "server: $SERVER"
curl -sk "$SERVER/cacerts" -o $HOME/.kube/ca.crt
if [ ! -s $HOME/.kube/ca.crt ]; then
echo "ERROR: could not fetch the cluster CA from $SERVER/cacerts"
exit 1
fi
kubectl config set-cluster default \
--embed-certs=true \
--certificate-authority=$HOME/.kube/ca.crt >/dev/null
rm -f $HOME/.kube/ca.crt
if kubectl version --request-timeout=15s >/dev/null 2>&1; then
echo "cluster reachable after refreshing the CA"
exit 0
fi
echo "ERROR: cannot reach the cluster API server even with a refreshed CA."
echo " The kubeconfig in the KUBEC_CONFIG_BUILDX_NEW_2 secret may be"
echo " stale or invalid, and needs regenerating."
exit 1
- name: Install kubectl
run: |
# Skip the whole apt dance if the runner already has a kubectl.
if command -v kubectl >/dev/null 2>&1; then
kubectl version --client
exit 0
fi
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-service.yaml
# The deployment manifest carries a sentinel image tag; the real
# SHA-pinned image is set immediately below. Applying it here
# keeps the rest of the spec (probes, resources, security context)
# in the repo and under review.
kubectl apply -f kube/beer-deployment.yaml
# The image is pinned to the commit SHA rather than `:latest`.
# `kubectl set image` writes the SHA into the pod template, which
# makes the change a real manifest change - the Deployment rolls
# because its pod template changed, not because it was told to.
# That also makes `kubectl rollout undo` meaningful: it can go back
# to the previous SHA.
kubectl -n beer set image deployment/beer-deployment \
beer-data=git.aridgwayweb.com/armistace/beer-data:${{ github.sha }}
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: Remove the kubeconfig
if: always()
run: rm -f $HOME/.kube/config
- name: Smoke test the deployed app
run: |
# Check the app's own port from inside the pod, AND the Service in
# front of it. The pod check alone would pass even if the Service
# selector or the port mapping were wrong, which is exactly the
# kind of breakage a deploy can introduce silently.
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)"
# Through the Service - the path a real client takes.
kubectl -n beer run smoke --rm -i --restart=Never --image=python:3.14-slim -- \
python -c "import urllib.request,sys; r=urllib.request.urlopen('http://beer-service.beer.svc.cluster.local/healthz'); print('service healthz:', r.read().decode()); sys.exit(0 if r.status==200 else 1)"