fix(ci): pin the image to the commit SHA instead of the mutable :latest tag
The review flagged `:latest` as a critical issue, and it is right: a mutable
tag makes `kubectl rollout undo` meaningless and lets different pods in the
same deployment run different code if they restart at different times.
- The build job now pushes the image with BOTH `:latest` and
`:${{ github.sha }}`.
- The deployment manifest carries a sentinel tag rather than `:latest`, and
the deploy job sets the real SHA with `kubectl set image`. That makes the
deploy a genuine manifest change, so the Deployment rolls because its pod
template changed — not because it was told to. `kubectl rollout undo` now
means something: it goes back to the previous SHA.
- The digest check now compares against the SHA tag rather than `:latest`,
and it checks EVERY running pod instead of `items[0]`. During a rolling
update `items[0]` may still be an old pod that has not been terminated,
which would compare against the wrong digest. The job also fails if the
deployment is not fully rolled out.
- The smoke test now checks the app through the Service as well as directly
in the pod. The pod check alone would pass even if the Service selector
or the port mapping were wrong.
- `readOnlyRootFilesystem: true` — the app writes no files, so the root
filesystem can be read-only. /tmp is still writable via the emptyDir.
- The kubectl install is skipped when the runner already has one.
This commit is contained in:
@@ -93,6 +93,7 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
tags: |
|
tags: |
|
||||||
git.aridgwayweb.com/armistace/beer-data:latest
|
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
|
# The deploy job is separate from the build job on purpose. The
|
||||||
# `KUBEC_CONFIG_BUILDX` secret is cluster-admin-equivalent, and a build
|
# `KUBEC_CONFIG_BUILDX` secret is cluster-admin-equivalent, and a build
|
||||||
@@ -117,6 +118,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Install kubectl
|
- name: Install kubectl
|
||||||
run: |
|
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 update
|
||||||
apt-get install -y apt-transport-https ca-certificates curl gnupg
|
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
|
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.33/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
|
||||||
@@ -131,14 +137,21 @@ jobs:
|
|||||||
# (beer-db, beer-app) are hand-provisioned and are NOT in the repo,
|
# (beer-db, beer-app) are hand-provisioned and are NOT in the repo,
|
||||||
# so they are deliberately not in the apply set.
|
# so they are deliberately not in the apply set.
|
||||||
kubectl apply -f kube/beer_namespace.yaml
|
kubectl apply -f kube/beer_namespace.yaml
|
||||||
kubectl apply -f kube/beer-deployment.yaml
|
|
||||||
kubectl apply -f kube/beer-service.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 tag is `:latest`, which is immutable in the manifest, so
|
# The image is pinned to the commit SHA rather than `:latest`.
|
||||||
# `kubectl apply` alone does NOT roll the pods. Without the restart
|
# `kubectl set image` writes the SHA into the pod template, which
|
||||||
# the deployment reports success while the old pods keep serving
|
# makes the change a real manifest change - the Deployment rolls
|
||||||
# the old image.
|
# because its pod template changed, not because it was told to.
|
||||||
kubectl -n beer rollout restart deployment/beer-deployment
|
# 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
|
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
|
||||||
|
|
||||||
- name: Verify the new image is actually running
|
- name: Verify the new image is actually running
|
||||||
@@ -160,8 +173,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Smoke test the deployed app
|
- name: Smoke test the deployed app
|
||||||
run: |
|
run: |
|
||||||
# The pods are behind a NodePort. Hit the service directly from
|
# Check the app's own port from inside the pod, AND the Service in
|
||||||
# inside the cluster.
|
# 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
|
for i in $(seq 1 12); do
|
||||||
if kubectl -n beer exec deploy/beer-deployment -- \
|
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
|
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
|
||||||
@@ -173,3 +188,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
kubectl -n beer exec deploy/beer-deployment -- \
|
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)"
|
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)"
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: beer-data
|
- name: beer-data
|
||||||
image: git.aridgwayweb.com/armistace/beer-data:latest
|
# 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
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5000
|
- containerPort: 5000
|
||||||
@@ -56,7 +62,10 @@ spec:
|
|||||||
capabilities:
|
capabilities:
|
||||||
drop:
|
drop:
|
||||||
- ALL
|
- ALL
|
||||||
readOnlyRootFilesystem: false
|
# 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:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
|
|||||||
Reference in New Issue
Block a user