diff --git a/.gitea/workflows/build_push.yml b/.gitea/workflows/build_push.yml index d7f41b6..e1de732 100644 --- a/.gitea/workflows/build_push.yml +++ b/.gitea/workflows/build_push.yml @@ -93,3 +93,111 @@ jobs: 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 }}" > $HOME/.kube/config + chmod 600 $HOME/.kube/config + + - 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)" diff --git a/kube/beer-deployment.yaml b/kube/beer-deployment.yaml new file mode 100644 index 0000000..3fa07ba --- /dev/null +++ b/kube/beer-deployment.yaml @@ -0,0 +1,100 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: beer-deployment + namespace: beer + labels: + app: beer-data +spec: + replicas: 3 + selector: + matchLabels: + app: beer-data + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + labels: + app: beer-data + spec: + containers: + - name: beer-data + # The image tag is pinned to the commit SHA by CI (`kubectl set + # image`). The value here is a sentinel: applying the manifest + # as-is would leave the deployment on that sentinel, so CI always + # replaces it before rolling. It is deliberately NOT `:latest` — + # a mutable tag makes the rollout a no-op (the pod template does not + # change) and makes `rollout undo` meaningless. + image: git.aridgwayweb.com/armistace/beer-data:SET_BY_CI + imagePullPolicy: Always + ports: + - containerPort: 5000 + protocol: TCP + env: + - name: MONGO_HOST + value: "192.168.178.229" + - name: MONGO_USER + value: "root" + # MONGO_PASS and SECRET_KEY come from the live Secret - they are + # deliberately NOT in this file. See RUNBOOK.md. + - name: MONGO_PASS + valueFrom: + secretKeyRef: + name: beer-db + key: mongo_pass + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: beer-app + key: secret_key + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 50m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + # The app reads its own source and talks to Mongo over the + # network. It writes no files, so the root filesystem can be + # read-only. /tmp is still writable via the emptyDir below. + readOnlyRootFilesystem: true + livenessProbe: + httpGet: + path: /healthz + port: 5000 + initialDelaySeconds: 10 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 10 + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + securityContext: + runAsNonRoot: true + # The image creates a named user "beer". kubelet cannot verify that a + # *named* user is non-root (it can only check a numeric UID), so the + # UID is set explicitly here. It must match the UID in the image. + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + nodeSelector: + kubernetes.io/hostname: archlinux-k3s-3 + imagePullSecrets: + - name: beercred + terminationGracePeriodSeconds: 30 + restartPolicy: Always diff --git a/kube/beer-service.yaml b/kube/beer-service.yaml new file mode 100644 index 0000000..e562b70 --- /dev/null +++ b/kube/beer-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: beer-service + namespace: beer + labels: + app: beer-data +spec: + type: NodePort + selector: + app: beer-data + ports: + - name: http + port: 80 + targetPort: 5000 + nodePort: 30015 + protocol: TCP diff --git a/kube/beer_namespace.yaml b/kube/beer_namespace.yaml new file mode 100644 index 0000000..1f0f5b5 --- /dev/null +++ b/kube/beer_namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: beer + labels: + app: beer-data