Round-2 review findings.
- `_ensure_indexes` swallowed a `PyMongoError` with a bare `pass`. An index
failure does not stop the app serving, but it means the lookup is a
collection scan, which gets slow as the data grows — so it is now logged
as a warning with the traceback.
- The Content-Security-Policy string is now a module-level `CSP` constant
rather than inline in the response handler, and it gained
`object-src 'none'` (the app uses no plugins). The test now also asserts
that neither `unsafe-inline` nor `unsafe-eval` appears — either would
defeat the point of the policy.
- `api_run` returned a bare dict for its 404 while the success path used
`jsonify`. It is now `jsonify({"error": "not found"})` for consistency.
The image build failed on master:
ERROR: Ignored the following versions that require a different python
version: 4.19.0 Requires-Python >=3.11
ERROR: Could not find a version that satisfies the requirement
pymongo==4.19.0
The base image was `python:3.10` but the pinned pymongo 4.19 needs Python
>= 3.11. The image is now `python:3.14-slim` — the current stable release,
so it is not carrying the CVEs fixed in 3.11, 3.12 and 3.13.
All the runtime dependencies ship a cp314 wheel (pymongo included), so no
compiler toolchain is needed. The old apt-get line installed
libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev for
packages that are no longer in the dependency list, so it is gone and the
image is smaller.
The build now asserts the interpreter version, so a base image change that
breaks the pinned requirements fails the build with a clear message rather
than a pip resolution error.
Review findings, all from the independent review of this PR
------------------------------------------------------------
- Mongo indexes: the run metadata is always looked up by `beer_run_id` and
the readings by `(beer_run_id, date)`. Both are now indexed.
- Content-Security-Policy: the app serves only its own vendored assets, so
a strict `default-src 'self'` policy is enforceable. Adding it required
removing the inline `style=` attributes from the templates (they became
`.mb-lg` / `.mt-lg` utility classes) and the one inline `<script> (the run
id now travels as a `data-run-id` attribute). A test asserts no template
has either.
- Job timeouts: the `test` and `build` jobs now have `timeout-minutes`, so
a wedged build stops occupying a runner.
- OG range: raised from 1300 to 1500 points, so a strong specialty brew
(a 1.150 wort is real) can be recorded.
Bugs found and fixed along the way
----------------------------------
- An unfinished run reported a *measured* ABV. `abv_percent()` now returns
None for a run that is still in the fermenter — its figure is a projection,
not a measurement. This was showing the two active runs in the "alcohol by
brew" chart as if they were finished.
- The `/api/runs` endpoints the charts fetch from did not exist, so every
chart was silently empty. They are back, with tests.
- The "brews over time" chart left out the months with no brews, which
spaced the busy months evenly and made the chart read as continuous
activity. The quiet months are now included with a zero.
- The run-detail chart was dropped when PR 1 was scoped down, leaving dead
code behind. It is restored.
Tests: 105 (up from 91).
Adds the pages that were missing: an "Active Beers" view of what is in the
fermenter right now, and the per-run metadata (canonical name, tap number,
original gravity, brew notes) that could not be derived from the readings.
Why the metadata is needed
--------------------------
The `beer_data` collection stores a beer name on *each reading*, and that
name drifts within a run - run 4 was entered as "Necatron-Christmas Beer",
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". There was
nowhere to record a tap number, a confirmed original gravity, or a note about
the brew.
The new `beer_runs` collection holds one document per run with exactly those
details. It starts empty; `scripts/backfill_run_metadata.py` populates it
from the existing readings. The script is idempotent, only ever inserts into
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
already set by hand.
The active beers page
---------------------
A card per brew that is still in the fermenter, showing the tap number, the
current gravity, the projected alcohol, how many days it has been brewing, a
sparkline of the gravity so far, and the latest comment on a reading.
A run with only one reading and no recorded original gravity has no reliable
estimate, so the alcohol figure shows a dash rather than a misleading number
- record the OG or take a second reading and it appears. The same applies to
the existing reads, where the legacy app's assumed OG of 1012 produced
*negative* alcohol for a perfectly normal OG.
Also adds the history page: alcohol by brew, brews per month, every run in a
table, a comparison of the standard ABV formula against the legacy one, and
a list of readings that fall outside the plausible hydrometer range.
Changes to the existing pages
-----------------------------
- The brew-process page and the run detail page now show the canonical run
name and the latest comment, and the run detail page lets you edit the
metadata inline.
- Gravity is displayed as a decimal reading (1.039) with the point scale
beneath it (39 points), which is the way a brewer reads it. Labelling the
stored `1039` as "1039 points" would have been wrong.
- A run's display name now prefers the name on its *final* reading - the one
written when the brew was finished, so the name the operator settled on -
falling back to the most-used name.
- `upsert_run_metadata` now clears a field when it is passed as `None`, so a
name or tap number can be deleted. `upsert_run_metadata_partial` is the
non-clearing variant, used by the backfill.
Bugs found and fixed along the way
----------------------------------
- The chart script was included both in the base template and again by each
page, so every chart was drawn twice. A test now asserts it is loaded
exactly once per page.
- The chart library was never referenced by the base template, so all charts
were silently dead. That was fixed in the previous PR.
Tests
-----
91 (up from 74). New coverage: the active page contents, the tap number, the
projected alcohol, the "needs 2nd reading" case, metadata saving and
clearing, the stored name overriding the drifting reading name, the next free
tap number, the partial-update semantics, the history page, the outlier
list, and the point-scale formatting.
Data safety
-----------
`beer_data` is untouched by this change. The backfill script only inserts into
`beer_runs` and skips any run whose name has already been set by hand.
Round-2 review findings, all low severity.
- `curl` now has `--connect-timeout` and `--max-time`, so an unreachable API
server fails the job instead of hanging the runner indefinitely.
- A guard for a kubeconfig with no cluster entries, or an empty one, with a
clear message rather than an opaque kubectl error.
- The fingerprint mismatch message now prints both the expected and the
actual value, which cuts the time to diagnose the next CA rotation.
- `set -euo pipefail` was already there; the script now passes shellcheck
clean, and a shellcheck step was added to the CI test job so the
operational scripts get the same treatment as the Python code.
The CA fingerprint is not a secret. It is a public value that anyone can
read straight off the certificate, so pinning it in a Gitea secret was the
wrong shape:
- A change to it would be invisible - no commit, no review.
- Nobody with secret-write access could silently repoint the CI runner at a
different CA.
It now lives in `scripts/cluster-ca.sha256`, so changing it is a reviewable
commit. The script reads it from there, and still accepts an override as
its first argument.
This also removes the need for a `KUBE_SERVER_CA_FINGERPRINT` secret, which
the bot token cannot create anyway - the Actions secrets API is owner-only.
The behaviour is unchanged: the CA is fetched over an unverified connection
but only USED if it matches the pinned fingerprint, so an intercepted CA is
refused. Re-tested against the live API server - the matching fingerprint
refreshes the CA and kubectl then works; a wrong one is refused and the
stale CA is left in place.
The review was right on both counts, and both are fixed.
1. MITM. Fetching the CA over an unverified connection (`curl -sk`) and then
trusting it is a privilege-escalation hole: the runner holds cluster-admin
credentials, so an attacker able to intercept the fetch could hand us a CA
of their choosing and then read everything sent over the connection.
The CA is now PINNED. It is still fetched over an insecure connection, but
it is only USED if its SHA-256 fingerprint matches
`KUBE_SERVER_CA_FINGERPRINT`. An intercepted CA will not hash to the
pinned value, so it is refused. If the secret is not set, the job fails
rather than trusting an unverified CA.
2. The hardcoded cluster name. `kubectl config set-cluster default` creates a
NEW cluster entry when the kubeconfig's entry is called something else,
leaving the original (and its stale CA) in use. The name is now read from
the kubeconfig: `{.clusters[0].name}`.
The bash is also moved out of the YAML into `scripts/kube-ca-refresh.sh`, so
it can be read, reviewed and tested on its own rather than as a wall of
shell inside a workflow file.
All three paths are tested against the live API server:
- no fingerprint supplied -> refuses, exit 1
- wrong fingerprint -> refuses, exit 1
- correct fingerprint -> refreshes the CA, exit 0, kubectl then works
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.
Two small changes to the deploy job:
- The registry password went through `-u armistace:$PASSWORD` on the curl
command line, where it is visible in the process list for the duration of
the request. It now goes through a curl config file on stdin instead.
- The kubeconfig is written with mode 600 rather than the default, and is
removed at the end of the job (with `if: always()`, so it is removed even
if a later step fails). It is cluster-admin-equivalent, so it should not
be readable by anything else on the runner.
Verified: `curl --config -` against the registry returns the same digest as
the `-u` form.
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.
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.
The cluster objects for the beer namespace were created by hand. This puts
them in the repo so they can be applied and reviewed like everything else.
- `beer-deployment.yaml` — the Deployment, with the security context, the
resource limits, and both probes wired up (`/healthz` liveness,
`/readyz` readiness).
- `beer-service.yaml` — the NodePort service.
- `beer_namespace.yaml` — the namespace.
The credentials are NOT in these files. `MONGO_PASS` and `SECRET_KEY` come
from the live `beer-db` and `beer-app` Secrets, so applying the manifests
cannot clobber a hand-provisioned credential.
Notes on the pod security context
---------------------------------
`runAsNonRoot: true` cannot be set on the container when the image's user is
a *named* user - kubelet can only verify a numeric UID. The pod-level
security context therefore sets `runAsUser: 999`, which matches the `beer`
user the image creates (verified with `id` in the built image).
The container also drops all capabilities and refuses privilege escalation.
Two follow-ups to the review of the base image change.
1. Pin the patch version: `python:3.14-slim` is a floating tag, so a rebuild
could silently pick up a different base image. It is now
`python:3.14.10-slim`, so a rebuild is reproducible.
2. Run the app as an unprivileged user. The app only reads its own source
and talks to Mongo over the network — it writes no files — so it does not
need to be root. A `beer` system user is created and the image ends with
`USER beer`.
The image build failed on master:
ERROR: Ignored the following versions that require a different python
version: 4.19.0 Requires-Python >=3.11
ERROR: Could not find a version that satisfies the requirement
pymongo==4.19.0
The base image was `python:3.10` but the pinned pymongo 4.19 needs Python
>= 3.11. The image is now `python:3.14-slim` — the current stable release,
so it is not carrying the CVEs fixed in 3.11, 3.12 and 3.13.
All the runtime dependencies ship a cp314 wheel (pymongo included), so no
compiler toolchain is needed. The old apt-get line installed
libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev for
packages that are no longer in the dependency list, so it is gone and the
image is smaller.
The build now asserts the interpreter version, so a base image change that
breaks the pinned requirements fails the build with a clear message rather
than a pip resolution error.
The independent review flagged that the build job wrote the
`KUBEC_CONFIG_BUILDX` secret to disk even though the build does not need
cluster access at all. That secret is cluster-admin-equivalent: a build step
that never talks to the cluster should not be able to read it, and a
compromised dependency in the build would otherwise have a direct path to the
cluster.
The "Create Kubeconfig" step is removed from the build job. The deploy step,
added in a later PR, gets its own job with the kubeconfig - so the privilege
is only present where it is actually used.
Also checked the buildkit daemon's resources in response to the review: it
already has limits (2 CPU, 4Gi memory, 2Gi ephemeral storage) and requests
(500m, 1Gi). The crash that motivated the amd64-only change was qemu
exhausting memory during multi-arch emulation, not an unconstrained daemon, so
no change is needed there.
The build job failed at "Set up Docker Buildx":
Error: ERROR: error for bootstrap "builder-...":
Get "https://192.168.178.160:6443/apis/apps/v1/namespaces/gitea-runner/deployments/builder-...":
tls: failed to verify certificate: x509: certificate signed by unknown authority
Cause
------
The `kubernetes` buildx driver talks to the cluster API to create its builder
pods. That needs the k3s CA certificate, which the job container does not
have - the `catthehacker/ubuntu:act-latest` image ships the public CA set
only. So the bootstrap could not verify the API server.
Fix
---
Switch to the `remote` driver, which talks straight to the buildkit daemon
already running in the cluster:
driver: remote
endpoint: tcp://buildkit.gitea-runner.svc:1234
No cluster API access is needed at all, so no kubeconfig is needed for the
build. This is the same pattern the wedding-photos workflow uses.
`endpoint` must be a top-level key, not a driver-opt: the action appends it
as the POSITIONAL argument to `buildx create --driver remote <endpoint>`, and
passing it in driver-opts yields "ERROR: no remote endpoint provided".
Also
----
The build was multi-arch (linux/amd64,linux/arm64), which runs qemu
emulation and crashes the buildkit pod on this NUC (exit 255,
SandboxChanged), after which the build hangs for many minutes. The cluster
is amd64, so the image is built for linux/amd64 only.
Verified: `docker buildx create --driver remote tcp://buildkit.gitea-runner.svc:1234`
succeeds from the runner's dind container and reports buildkit v0.29.0 with
linux/amd64 among its supported platforms.
2026-11-09 10:58:28 +10:00
8 changed files with 406 additions and 3 deletions
echo"ERROR: the cluster CA does not match the pinned fingerprint." >&2
echo" expected: $WANTED" >&2
echo" actual: $ACTUAL" >&2
echo" Refusing to use it. Either the CA has been rotated and the" >&2
echo" pinned fingerprint needs updating, or the connection was" >&2
echo" intercepted." >&2
exit1
fi
# The fingerprint matches the pinned value, so this is the CA we expect.
kubectl config set-cluster "$CLUSTER_NAME"\
--embed-certs=true\
--certificate-authority="$CA_FILE" >/dev/null
if kubectl version --request-timeout=15s >/dev/null 2>&1;then
echo"cluster reachable after refreshing the CA"
exit0
fi
echo"ERROR: cannot reach the cluster API server even with a refreshed CA." >&2
echo" The kubeconfig secret may be stale or invalid." >&2
exit1
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.