feat(trmnl): deploy the headless dashboard renderer #483

Merged
k.renner merged 4 commits from feat-trmnl-renderer into master 2026-08-12 16:43:17 +00:00
Member

Phase 4. Screenshots the HA e-ink dashboard, dithers it to 16 greys, and hands it to Terminus — which the panel then polls.

Pairs with homeassistant#210 (the dashboard itself).

Runs standalone, not as an add-on

Upstream ships this as an HA add-on; ours is a plain HA container with no Supervisor. Standalone works because the app prefers HOME_ASSISTANT_URL/ACCESS_TOKEN env vars over /data/options.json and authenticates with an ordinary long-lived token. SUPERVISOR_TOKEN is deliberately left unset — it only flips an internal isAddOn flag and would switch the app to add-on behaviour.

The direction of travel is backwards from what you'd assume

With the byos-hanami webhook in uri delivery mode the renderer does not push image bytes. It POSTs a URL pointing at its own /output/<file> endpoint, and Terminus fetches the image back from it.

That's why this lives in the trmnl namespace: the fetch is intra-namespace and already permitted by default-deny-ingress. Splitting the two across namespaces would have needed a new policy for a dependency that's easy to miss until screens silently stop updating.

Fully declarative — no clicks

Schedules are a JSON file at /data/schedules.json that the app re-reads every 60 s, so the entire schedule is reviewable in git rather than typed into the app's web UI:

viewport      1872x1404       theme    trmnl-eink
dithering     floyd-steinberg, bitDepth 4, gray-16
dashboard     /trmnl-entrance/home
interval      1 min           model_id 1 (TRMNL X)

An init container splices the BYOS tokens into the ConfigMap template with jq, not sed — the tokens are JWTs going into a JSON string, and sed corrupts on any character it treats as special. Verified with a token containing /, + and &.

Render interval is 1 min, deliberately ahead of the device poll, so the panel never fetches a half-written image.

No HTTP in the init container, on purpose. The app exposes an /api/byos/login proxy that trades a Terminus username+password for these tokens — but that would mean shipping the Terminus password into the cluster and making every pod start depend on a network call. Stock Terminus disables session expiry and issues effectively permanent tokens, so they're minted once out of band and kept in OpenBao. The password never enters the cluster.

Reachability — fixed after review

The first revision pointed HOME_ASSISTANT_URL at http://ha.renner.uno:8123, which is unusable from inside a pod. That name is served by the home-assistant-bgp LoadBalancer and is not backed by an Ingress, so CoreDNS's k8s_gateway delegation doesn't intercept it — a pod gets the real external address (10.2.99.87) and hairpins off the LB. This cluster has hit exactly that before with exactly that Service; openbao/rotation/home-assistant.yaml says so outright.

Now uses home-assistant-bgp.home-assistant.svc.cluster.local:8123 (verified live: ClusterIP 10.96.79.213, 8123/TCP), plus a companion CNP ingress rule — the home-assistant namespace's default-deny had no rule admitting trmnl pods on 8123, so the URL fix alone would still have been dropped. Scoped to the renderer's pod label rather than the whole namespace, since Terminus shares that namespace and has no business reaching HA.

Four things that would fail quietly

  • HA reachability (above) — nothing crashes; the schedule fires every minute, each render errors on connect, and the panel just never updates.
  • The PVC at /data is required. The app picks its data directory by testing whether /data exists — with nothing mounted it silently falls back to /app/data inside the container, so schedules and rendered output vanish on restart.
  • ClusterIP, no Ingress. The app's web UI on this port has no authentication whatsoever, per upstream. It must not be routable from the LAN.
  • 2Gi memory limit, not the 512Mi upstream suggests. The app's own watchdog restarts it past 900 MB but only measures its own RSS — Chromium is a separate process rendering a 1872×1404 page.

Verified against the running systems

  • model_id is the integer 1, not the name "v2". GET /api/screens returns model_id as a number, and GET /api/models gives TRMNL X as id: 1. Upstream's example value og_1bit matches no model on this server (the 2-bit OG model here is id 11, name og_plus). The earlier "unverified" caveat is resolved — a wrong value here would have meant a 422 on every push and a permanently blank panel.
  • /health is part of the image's published contract — its Dockerfile declares HEALTHCHECK ... curl -f http://localhost:10000/health. Hence HTTP probes here, where terminus.yaml uses TCP-only.
  • Dropping ALL capabilities is safe despite headless Chromium: the app launches it with --no-sandbox --disable-setuid-sandbox --no-zygote --disable-dev-shm-usage in its own PUPPETEER_ARGS. That last flag is also why no /dev/shm emptyDir is mounted.
  • Both OpenBao prerequisites are already seeded — homelab/apps/trmnl/renderer holds HA_ACCESS_TOKEN (dedicated token, verified 200 against the HA API), BYOS_ACCESS_TOKEN and BYOS_REFRESH_TOKEN (verified 200 against Terminus /api/models).

The image is arch-suffixed (trmnl-ha-amd64) because upstream publishes no multi-arch manifest. Pinned and not enrolled in image automation — a bad bump surfaces as a corrupted or blank panel rather than a failed rollout. Rationale recorded in the image-automation README alongside Terminus's.

🤖 Generated with Claude Code

Phase 4. Screenshots the HA e-ink dashboard, dithers it to 16 greys, and hands it to Terminus — which the panel then polls. Pairs with homeassistant#210 (the dashboard itself). ## Runs standalone, not as an add-on Upstream ships this as an HA add-on; ours is a plain HA container with no Supervisor. Standalone works because the app prefers `HOME_ASSISTANT_URL`/`ACCESS_TOKEN` env vars over `/data/options.json` and authenticates with an ordinary long-lived token. `SUPERVISOR_TOKEN` is deliberately left unset — it only flips an internal `isAddOn` flag and would switch the app to add-on behaviour. ## The direction of travel is backwards from what you'd assume With the `byos-hanami` webhook in **`uri` delivery mode the renderer does not push image bytes.** It POSTs a URL pointing at its own `/output/<file>` endpoint, and **Terminus fetches the image back from it.** That's why this lives in the `trmnl` namespace: the fetch is intra-namespace and already permitted by `default-deny-ingress`. Splitting the two across namespaces would have needed a new policy for a dependency that's easy to miss until screens silently stop updating. ## Fully declarative — no clicks Schedules are a JSON file at `/data/schedules.json` that the app re-reads every 60 s, so the entire schedule is reviewable in git rather than typed into the app's web UI: ``` viewport 1872x1404 theme trmnl-eink dithering floyd-steinberg, bitDepth 4, gray-16 dashboard /trmnl-entrance/home interval 1 min model_id 1 (TRMNL X) ``` An init container splices the BYOS tokens into the ConfigMap template with **`jq`, not `sed`** — the tokens are JWTs going into a JSON string, and `sed` corrupts on any character it treats as special. Verified with a token containing `/`, `+` and `&`. Render interval is 1 min, deliberately ahead of the device poll, so the panel never fetches a half-written image. **No HTTP in the init container, on purpose.** The app exposes an `/api/byos/login` proxy that trades a Terminus username+password for these tokens — but that would mean shipping the Terminus *password* into the cluster and making every pod start depend on a network call. Stock Terminus disables session expiry and issues effectively permanent tokens, so they're minted once out of band and kept in OpenBao. The password never enters the cluster. ## Reachability — fixed after review The first revision pointed `HOME_ASSISTANT_URL` at `http://ha.renner.uno:8123`, which is **unusable from inside a pod**. That name is served by the `home-assistant-bgp` LoadBalancer and is not backed by an Ingress, so CoreDNS's `k8s_gateway` delegation doesn't intercept it — a pod gets the real external address (`10.2.99.87`) and hairpins off the LB. This cluster has hit exactly that before with exactly that Service; `openbao/rotation/home-assistant.yaml` says so outright. Now uses `home-assistant-bgp.home-assistant.svc.cluster.local:8123` (verified live: ClusterIP `10.96.79.213`, 8123/TCP), plus a companion CNP ingress rule — the `home-assistant` namespace's default-deny had no rule admitting `trmnl` pods on 8123, so the URL fix alone would still have been dropped. Scoped to the renderer's pod label rather than the whole namespace, since Terminus shares that namespace and has no business reaching HA. ## Four things that would fail quietly - **HA reachability** (above) — nothing crashes; the schedule fires every minute, each render errors on connect, and the panel just never updates. - **The PVC at `/data` is required.** The app picks its data directory by testing whether `/data` exists — with nothing mounted it silently falls back to `/app/data` inside the container, so schedules and rendered output vanish on restart. - **ClusterIP, no Ingress.** The app's web UI on this port has *no authentication whatsoever*, per upstream. It must not be routable from the LAN. - **2Gi memory limit, not the 512Mi** upstream suggests. The app's own watchdog restarts it past 900 MB but only measures its *own* RSS — Chromium is a separate process rendering a 1872×1404 page. ## Verified against the running systems - `model_id` is the **integer 1**, not the name `"v2"`. `GET /api/screens` returns `model_id` as a number, and `GET /api/models` gives TRMNL X as `id: 1`. Upstream's example value `og_1bit` matches no model on this server (the 2-bit OG model here is `id 11, name og_plus`). The earlier "unverified" caveat is resolved — a wrong value here would have meant a 422 on every push and a permanently blank panel. - `/health` is part of the image's published contract — its Dockerfile declares `HEALTHCHECK ... curl -f http://localhost:10000/health`. Hence HTTP probes here, where `terminus.yaml` uses TCP-only. - Dropping ALL capabilities is safe despite headless Chromium: the app launches it with `--no-sandbox --disable-setuid-sandbox --no-zygote --disable-dev-shm-usage` in its own `PUPPETEER_ARGS`. That last flag is also why no `/dev/shm` emptyDir is mounted. - Both OpenBao prerequisites are **already seeded** — `homelab/apps/trmnl/renderer` holds `HA_ACCESS_TOKEN` (dedicated token, verified `200` against the HA API), `BYOS_ACCESS_TOKEN` and `BYOS_REFRESH_TOKEN` (verified `200` against Terminus `/api/models`). The image is arch-suffixed (`trmnl-ha-amd64`) because upstream publishes no multi-arch manifest. Pinned and not enrolled in image automation — a bad bump surfaces as a corrupted or blank panel rather than a failed rollout. Rationale recorded in the image-automation README alongside Terminus's. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-bot added 1 commit 2026-08-11 03:58:58 +00:00
feat(trmnl): deploy the headless dashboard renderer
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
7e2a439b9e
Phase 4. Screenshots the Home Assistant e-ink dashboard, dithers it to
16 greys and hands it to Terminus, which the panel then polls.

Upstream ships this as a Home Assistant add-on. Ours is a plain HA
container with no Supervisor, so it runs standalone — viable because
the app prefers HOME_ASSISTANT_URL/ACCESS_TOKEN env vars over
/data/options.json and authenticates with an ordinary long-lived
token. SUPERVISOR_TOKEN is deliberately left unset: it only flips an
internal isAddOn flag and would switch the app to add-on behaviour.

The non-obvious part is the direction of travel. With the byos-hanami
webhook in `uri` delivery mode the renderer does NOT push image bytes
— it POSTs a URL pointing at its own /output/<file> endpoint and
TERMINUS FETCHES THE IMAGE BACK. That is why this lives in the trmnl
namespace: the fetch is intra-namespace and already permitted by
default-deny-ingress. Splitting the two across namespaces would have
needed a new policy for a dependency that is easy to miss.

Fully declarative, no clicks. Schedules are a JSON file at
/data/schedules.json which the app re-reads every 60s, so the whole
schedule — viewport 1872x1404, floyd-steinberg at bitDepth 4, the
trmnl-eink theme, the dashboard path — is reviewable in git rather
than typed into the app's web UI. An init container splices the BYOS
tokens into the ConfigMap template with jq; jq rather than sed because
the tokens are JWTs going into a JSON string and sed would corrupt on
any character it treats as special (verified with a token containing
/ + &).

No HTTP in the init container on purpose. The app exposes a
/api/byos/login proxy that trades a Terminus username+password for
these tokens, but using it would mean shipping the Terminus PASSWORD
into the cluster and making every pod start depend on a network call.
Stock Terminus disables session expiry and issues effectively
permanent tokens, so they are minted once out of band and kept in
OpenBao; the password never enters the cluster.

Three things that are load-bearing and would fail quietly otherwise:

  * The PVC at /data is REQUIRED. The app picks its data directory by
    testing whether /data exists — with nothing mounted it silently
    falls back to /app/data in the container, so schedules and rendered
    output vanish on restart and the panel goes blank.
  * The Service is ClusterIP with NO Ingress. The app's web UI on this
    port has no authentication whatsoever, per upstream, so it must not
    be routable from the LAN.
  * The memory limit is 2Gi, not the 512Mi upstream's troubleshooting
    note suggests. The app's own watchdog restarts it past 900MB but
    only measures its OWN rss — Chromium is a separate process, and it
    is rendering a 1872x1404 page.

The image is arch-suffixed (trmnl-ha-amd64); upstream publishes no
multi-arch manifest. Pinned and not enrolled in image automation,
since a bad bump here surfaces as a corrupted or blank panel rather
than a failed rollout — rationale recorded in the image-automation
README alongside Terminus's.

PREREQUISITE: homelab/apps/trmnl/renderer must be seeded in OpenBao
with HA_ACCESS_TOKEN, BYOS_ACCESS_TOKEN and BYOS_REFRESH_TOKEN before
this merges, or the ExternalSecret never syncs, the init container
blocks, and the trmnl Kustomization fails its wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 7e2a439b9e9a5193b3537ffc72c8bd42251c161e.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds the standalone TRMNL/Home-Assistant screenshot renderer to the trmnl namespace: a Deployment (jq-based init container templating schedules.json, Chromium-based renderer container), its PVC, and an ExternalSecret for the HA/BYOS tokens. The manifests are unusually well commented and internally consistent (Service/webhook URLs, ExternalSecret key names, and container env all line up correctly). However, the renderer is pointed at Home Assistant's public/LAN hostname, which this repo's own prior work already documented as unreachable from inside the cluster — as written, the renderer cannot reach HA at all.

🔴 Blocking

  • kubernetes/apps/trmnl/renderer.yaml:271 — HOME_ASSISTANT_URL is set to http://ha.renner.uno:8123. That hostname is served by the home-assistant-bgp LoadBalancer Service with externalTrafficPolicy: Local (kubernetes/apps/home-assistant/bgp-service.yaml), advertised over Cilium BGP to the LAN — it is not backed by an Ingress, so CoreDNS's k8s_gateway delegation does not intercept it (kubernetes/infrastructure/coredns-config/configmap.yaml:8-14, "Names not backed by an in-cluster Ingress fall through ... to the UDM ... which serves the split-horizon renner.uno zone"). A pod resolving ha.renner.uno therefore gets the real external LB IP (10.2.99.87), not an in-cluster address.

    This repo has already hit exactly this failure mode for this exact Service: kubernetes/infrastructure/openbao/rotation/home-assistant.yaml:17-22 runs a CronJob that also needs to reach HA from a pod, and its comment states outright: "Reachability: HA :8123 ... via the home-assistant-bgp Service ClusterIP ... The public name is unusable from a pod (ingress-LB hairpin)." That job uses ws://home-assistant-bgp.home-assistant.svc.cluster.local:8123/api/websocket instead. The identical language ("hairpins off the ... LB from a pod") recurs in gitea/networkpolicy.yaml, woodpecker/networkpolicy.yaml, and home-assistant-ns.yaml:349 — this is an established, repeatedly-confirmed constraint of this cluster, not a hypothetical.

    As written, the renderer's every screenshot attempt will fail to connect to HA — the schedule will fire every minute and error, and the panel will never get real content. This is the core function of the PR.

    Fixing the hostname isn't sufficient on its own, either: kubernetes/infrastructure/network-policies/home-assistant-ns.yaml's default-deny-ingress (lines 1-197) has no rule permitting trmnl-namespace pods to reach HA on :8123 — the only port-8123 ingress rule is home-assistant-rotator-ingress, scoped to openbao/secret-rotator pods specifically (lines 356-374). Switching to home-assistant-bgp.home-assistant.svc.cluster.local:8123 needs a companion ingress rule in that file (matching the rotator's shape, scoped to the renderer's pod label) before traffic would actually get through.

🟡 Suggestions

  • The model_id: "v2" field in the BYOS config (renderer.yaml:156) is called out in the PR description as unverified against Terminus's actual API contract (name vs. numeric id). Worth resolving before merge if this is quick to check against the running Terminus instance, since a 422 here would be another quiet failure mode alongside the HA-reachability one above.

🟢 Nits

  • kubernetes/infrastructure/post-crds/velero-schedules.yaml:296-298 — the trmnl-daily Schedule's comment enumerates the PVCs it covers ("terminus-data ... and terminus-valkey-data") but wasn't updated to mention the new trmnl-renderer-data PVC. Functionally it's still backed up (the schedule selects includedNamespaces: [trmnl] + all PVCs minus the CNPG-labelled one, so the new PVC is included automatically), but the comment now undersells what's covered.

Tests

No automated coverage exists for this path — kubernetes/e2e-test.sh doesn't reference trmnl or home-assistant at all, so nothing in CI would have caught the HA-reachability issue above; it would only surface at runtime (render schedule firing and failing every minute, or the panel staying blank). Given the PR body already documents three other "fails quietly" modes it planned for, it'd be worth adding a fourth note (and ideally a gatus check or similar, matching the pattern used elsewhere in this repo for LB-hairpin-prone paths) once the HOME_ASSISTANT_URL fix lands.

Recommendation

Request changes — 1 blocking issue.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `7e2a439b9e9a5193b3537ffc72c8bd42251c161e`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds the standalone TRMNL/Home-Assistant screenshot renderer to the `trmnl` namespace: a Deployment (jq-based init container templating `schedules.json`, Chromium-based renderer container), its PVC, and an ExternalSecret for the HA/BYOS tokens. The manifests are unusually well commented and internally consistent (Service/webhook URLs, ExternalSecret key names, and container env all line up correctly). However, the renderer is pointed at Home Assistant's public/LAN hostname, which this repo's own prior work already documented as unreachable from inside the cluster — as written, the renderer cannot reach HA at all. ## 🔴 Blocking - **`kubernetes/apps/trmnl/renderer.yaml:271`** — `HOME_ASSISTANT_URL` is set to `http://ha.renner.uno:8123`. That hostname is served by the `home-assistant-bgp` **LoadBalancer** Service with `externalTrafficPolicy: Local` (`kubernetes/apps/home-assistant/bgp-service.yaml`), advertised over Cilium BGP to the LAN — it is not backed by an Ingress, so CoreDNS's `k8s_gateway` delegation does not intercept it (`kubernetes/infrastructure/coredns-config/configmap.yaml:8-14`, "Names not backed by an in-cluster Ingress fall through ... to the UDM ... which serves the split-horizon renner.uno zone"). A pod resolving `ha.renner.uno` therefore gets the real external LB IP (`10.2.99.87`), not an in-cluster address. This repo has already hit exactly this failure mode for this exact Service: `kubernetes/infrastructure/openbao/rotation/home-assistant.yaml:17-22` runs a CronJob that also needs to reach HA from a pod, and its comment states outright: *"Reachability: HA :8123 ... via the home-assistant-bgp Service ClusterIP ... The public name is unusable from a pod (ingress-LB hairpin)."* That job uses `ws://home-assistant-bgp.home-assistant.svc.cluster.local:8123/api/websocket` instead. The identical language ("hairpins off the ... LB from a pod") recurs in `gitea/networkpolicy.yaml`, `woodpecker/networkpolicy.yaml`, and `home-assistant-ns.yaml:349` — this is an established, repeatedly-confirmed constraint of this cluster, not a hypothetical. As written, the renderer's every screenshot attempt will fail to connect to HA — the schedule will fire every minute and error, and the panel will never get real content. This is the core function of the PR. Fixing the hostname isn't sufficient on its own, either: `kubernetes/infrastructure/network-policies/home-assistant-ns.yaml`'s `default-deny-ingress` (lines 1-197) has no rule permitting `trmnl`-namespace pods to reach HA on `:8123` — the only port-8123 ingress rule is `home-assistant-rotator-ingress`, scoped to `openbao`/`secret-rotator` pods specifically (lines 356-374). Switching to `home-assistant-bgp.home-assistant.svc.cluster.local:8123` needs a companion ingress rule in that file (matching the rotator's shape, scoped to the renderer's pod label) before traffic would actually get through. ## 🟡 Suggestions - The `model_id: "v2"` field in the BYOS config (`renderer.yaml:156`) is called out in the PR description as unverified against Terminus's actual API contract (name vs. numeric id). Worth resolving before merge if this is quick to check against the running Terminus instance, since a 422 here would be another quiet failure mode alongside the HA-reachability one above. ## 🟢 Nits - `kubernetes/infrastructure/post-crds/velero-schedules.yaml:296-298` — the `trmnl-daily` Schedule's comment enumerates the PVCs it covers ("terminus-data ... and terminus-valkey-data") but wasn't updated to mention the new `trmnl-renderer-data` PVC. Functionally it's still backed up (the schedule selects `includedNamespaces: [trmnl]` + all PVCs minus the CNPG-labelled one, so the new PVC is included automatically), but the comment now undersells what's covered. ## Tests No automated coverage exists for this path — `kubernetes/e2e-test.sh` doesn't reference `trmnl` or `home-assistant` at all, so nothing in CI would have caught the HA-reachability issue above; it would only surface at runtime (render schedule firing and failing every minute, or the panel staying blank). Given the PR body already documents three other "fails quietly" modes it planned for, it'd be worth adding a fourth note (and ideally a gatus check or similar, matching the pattern used elsewhere in this repo for LB-hairpin-prone paths) once the HOME_ASSISTANT_URL fix lands. ## Recommendation Request changes — 1 blocking issue. <!-- pr-review:sha=7e2a439b9e9a5193b3537ffc72c8bd42251c161e -->
claude-bot added 1 commit 2026-08-12 16:28:07 +00:00
fix(trmnl): model_id is the integer 1, not the name "v2"
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
f466f00029
Resolves the one field flagged as unverified on the renderer PR, now
that a Terminus API token exists to check it with.

GET /api/screens on the running instance returns model_id as a NUMBER
(an existing welcome screen carries model_id 11), and GET /api/models
gives TRMNL X as id 1, name "v2", 1872x1404, 16 colours. Upstream's
example value "og_1bit" matches no model on this server at all — the
2-bit OG model here is id 11, name "og_plus" — so that example was
illustrative rather than a real identifier.

Would have surfaced as Terminus rejecting every pushed screen with a
422, i.e. a permanently blank panel with the renderer looking healthy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot added 1 commit 2026-08-12 16:35:05 +00:00
fix(trmnl): reach HA over the in-cluster Service, not the public name
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
b6ada58b7a
Review caught a blocking bug, correctly. HOME_ASSISTANT_URL pointed at
http://ha.renner.uno:8123, which is unusable from inside a pod.

That hostname is served by the home-assistant-bgp LoadBalancer with
externalTrafficPolicy: Local, advertised to the LAN over BGP. It is
not backed by an Ingress, so CoreDNS's k8s_gateway delegation does not
intercept it — a pod resolving it gets the real external address
(10.2.99.87) and hairpins off the LB.

This cluster has hit precisely this before with precisely this
Service. kubernetes/infrastructure/openbao/rotation/home-assistant.yaml
reaches HA from a pod for token rotation and says so outright: "The
public name is unusable from a pod (ingress-LB hairpin)." The same
language recurs in the gitea and woodpecker network policies. I took
the hostname from the repo's own "internal http://ha.renner.uno:8123"
note, which is true for a workstation and false from a pod.

Now uses home-assistant-bgp.home-assistant.svc.cluster.local:8123,
verified to exist with ClusterIP 10.96.79.213 on 8123/TCP.

Fixing the URL alone was not enough. The home-assistant namespace's
default-deny-ingress had no rule admitting trmnl pods on 8123 — the
only 8123 rule is scoped to the openbao secret-rotator — so traffic
would still have been dropped. Adds a companion ingress rule modelled
on the rotator's, scoped to the renderer's pod label rather than the
whole trmnl namespace, since Terminus shares that namespace and has no
business reaching Home Assistant. Only the ingress half is needed:
trmnl has no default-deny egress selecting the renderer, unlike
openbao.

Worth naming the failure mode, because it is the fourth "fails
quietly" case in this stack: nothing crashes. The schedule fires every
minute, each render errors on connect, and the panel simply never
updates.

Also updates the trmnl-daily Velero comment, which enumerated PVCs and
so undersold itself once trmnl-renderer-data existed — the schedule
selects the namespace minus CNPG-labelled PVCs, so new PVCs are
already covered without editing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit b6ada58b7ade5cb40a7e47515864fa425bd0eca6.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds the standalone TRMNL/HA headless renderer (Deployment + ConfigMap + PVC + ExternalSecret + Service) to the trmnl namespace, plus the supporting CiliumNetworkPolicy (renderer → HA :8123), an image-automation exclusion, and a comment update noting the new PVC rides the existing namespace-wide Velero schedule. I traced the intra-namespace (Terminus ↔ renderer) and cross-namespace (renderer → Home Assistant) network paths against the actual CiliumNetworkPolicy objects already in the repo, cross-checked Service/ConfigMap/Secret names end-to-end, and diffed the securityContext/probe/resource choices against the sibling terminus.yaml Deployment for consistency. No blocking issues found — the design holds together and matches what the PR body claims. A few points are worth a second look before or shortly after merge, mostly around assumptions about the third-party image's runtime behaviour that this repo can't verify statically.

🟡 Suggestions

  • kubernetes/apps/trmnl/renderer.yaml:307-320 — livenessProbe/readinessProbe hit GET /health on the renderer. That's an assumption about the upstream trmnl-ha-amd64 image's route set that isn't verified anywhere in the diff or repo. Contrast with kubernetes/apps/trmnl/terminus.yaml:91-94, where the same author deliberately used TCP-only probes with the comment "Terminus exposes no documented health endpoint... Tighten to an HTTP GET once the route set is confirmed against the running app." If /health doesn't exist (or 404s while the app is otherwise up), the liveness probe will CrashLoopBackOff the renderer repeatedly — a much noisier failure than the "fails quietly" risks the PR body calls out for the other three gotchas. Worth confirming against the running container's logs/routes on first deploy, or falling back to a TCP probe like terminus.yaml does until confirmed.
  • kubernetes/apps/trmnl/renderer.yaml:333-336 — the renderer container runs with allowPrivilegeEscalation: false and capabilities: drop: ["ALL"]. Headless Chromium (which this image wraps, per the PR description) commonly needs either --no-sandbox baked into its launch flags or extra capabilities to start its own sandbox under a restricted securityContext; a hard failure here would show up as the app process staying up while every render fails, or the process crashing outright. Not something I can confirm from the checkout — worth watching pod logs after first render cycle.
  • PR body vs. shipped manifest are out of sync on model_id: the body's "Unverified" section says model_id: "v2" and flags uncertainty about whether Terminus wants a name or a numeric id there. The actual ConfigMap (kubernetes/apps/trmnl/renderer.yaml:116-121) ships "model_id": 1 (integer) with a comment claiming this was already confirmed against the running server (GET /api/screens). If that confirmation is accurate the "Unverified" caveat in the PR description is stale and should be dropped so a future reader doesn't re-litigate a question the code already answered; if it's not accurate, the code comment is overclaiming. Worth a quick pass to reconcile the two before merge.

🟢 Nits

None beyond the above — the manifest is unusually well-commented and the two cross-file claims I could check (network policy reachability, PVC inclusion in the Velero namespace-wide schedule) both held up against the actual policy/schedule objects.

Tests

No test suite applies to Kubernetes manifests in this repo beyond .woodpecker/ci-manifests-test.yml's kustomize-build step, which validates that every kustomization tree still builds statically (resource references resolve, YAML is well-formed) — it doesn't touch a cluster or exercise runtime behavior. I wasn't able to run kubectl kustomize locally in this sandbox to double-check the build (command was blocked by the environment's permission gate), so I verified the wiring by hand instead: kustomization.yaml lists both new files, the Service/ConfigMap/Secret names referenced by the Deployment all exist with matching names, and the new PVC (trmnl-renderer-data) has no cnpg.io/cluster label so it's correctly swept into the existing namespace-wide trmnl-daily Velero schedule without needing an edit there. The runtime-only risks (health endpoint path, Chromium sandboxing under the restricted securityContext, whether Terminus's BYOS endpoint actually accepts this payload shape) are inherently untestable from a static checkout and are the PR's own explicitly-flagged unknowns; that's a reasonable scope boundary given the "prerequisite: seed OpenBao first" gate already blocks accidental rollout.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `b6ada58b7ade5cb40a7e47515864fa425bd0eca6`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds the standalone TRMNL/HA headless renderer (Deployment + ConfigMap + PVC + ExternalSecret + Service) to the `trmnl` namespace, plus the supporting CiliumNetworkPolicy (renderer → HA `:8123`), an image-automation exclusion, and a comment update noting the new PVC rides the existing namespace-wide Velero schedule. I traced the intra-namespace (Terminus ↔ renderer) and cross-namespace (renderer → Home Assistant) network paths against the actual `CiliumNetworkPolicy` objects already in the repo, cross-checked Service/ConfigMap/Secret names end-to-end, and diffed the securityContext/probe/resource choices against the sibling `terminus.yaml` Deployment for consistency. No blocking issues found — the design holds together and matches what the PR body claims. A few points are worth a second look before or shortly after merge, mostly around assumptions about the third-party image's runtime behaviour that this repo can't verify statically. ## 🟡 Suggestions - `kubernetes/apps/trmnl/renderer.yaml:307-320` — `livenessProbe`/`readinessProbe` hit `GET /health` on the renderer. That's an assumption about the upstream `trmnl-ha-amd64` image's route set that isn't verified anywhere in the diff or repo. Contrast with `kubernetes/apps/trmnl/terminus.yaml:91-94`, where the same author deliberately used TCP-only probes with the comment "Terminus exposes no documented health endpoint... Tighten to an HTTP GET once the route set is confirmed against the running app." If `/health` doesn't exist (or 404s while the app is otherwise up), the liveness probe will `CrashLoopBackOff` the renderer repeatedly — a much noisier failure than the "fails quietly" risks the PR body calls out for the other three gotchas. Worth confirming against the running container's logs/routes on first deploy, or falling back to a TCP probe like `terminus.yaml` does until confirmed. - `kubernetes/apps/trmnl/renderer.yaml:333-336` — the renderer container runs with `allowPrivilegeEscalation: false` and `capabilities: drop: ["ALL"]`. Headless Chromium (which this image wraps, per the PR description) commonly needs either `--no-sandbox` baked into its launch flags or extra capabilities to start its own sandbox under a restricted `securityContext`; a hard failure here would show up as the app process staying up while every render fails, or the process crashing outright. Not something I can confirm from the checkout — worth watching pod logs after first render cycle. - PR body vs. shipped manifest are out of sync on `model_id`: the body's "Unverified" section says `model_id: "v2"` and flags uncertainty about whether Terminus wants a name or a numeric id there. The actual ConfigMap (`kubernetes/apps/trmnl/renderer.yaml:116-121`) ships `"model_id": 1` (integer) with a comment claiming this was already confirmed against the running server (`GET /api/screens`). If that confirmation is accurate the "Unverified" caveat in the PR description is stale and should be dropped so a future reader doesn't re-litigate a question the code already answered; if it's not accurate, the code comment is overclaiming. Worth a quick pass to reconcile the two before merge. ## 🟢 Nits None beyond the above — the manifest is unusually well-commented and the two cross-file claims I could check (network policy reachability, PVC inclusion in the Velero namespace-wide schedule) both held up against the actual policy/schedule objects. ## Tests No test suite applies to Kubernetes manifests in this repo beyond `.woodpecker/ci-manifests-test.yml`'s `kustomize-build` step, which validates that every kustomization tree still builds statically (resource references resolve, YAML is well-formed) — it doesn't touch a cluster or exercise runtime behavior. I wasn't able to run `kubectl kustomize` locally in this sandbox to double-check the build (command was blocked by the environment's permission gate), so I verified the wiring by hand instead: `kustomization.yaml` lists both new files, the `Service`/`ConfigMap`/`Secret` names referenced by the `Deployment` all exist with matching names, and the new PVC (`trmnl-renderer-data`) has no `cnpg.io/cluster` label so it's correctly swept into the existing namespace-wide `trmnl-daily` Velero schedule without needing an edit there. The runtime-only risks (health endpoint path, Chromium sandboxing under the restricted securityContext, whether Terminus's BYOS endpoint actually accepts this payload shape) are inherently untestable from a static checkout and are the PR's own explicitly-flagged unknowns; that's a reasonable scope boundary given the "prerequisite: seed OpenBao first" gate already blocks accidental rollout. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=b6ada58b7ade5cb40a7e47515864fa425bd0eca6 -->
claude-bot added 1 commit 2026-08-12 16:41:01 +00:00
docs(trmnl): record why the probe path and dropped caps are safe
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
a79a5417e3
Both were verified against the upstream image while writing this, but
the evidence never made it into the manifest, so review flagged them as
unverified assumptions. Fair — the reasoning was invisible.

/health is not a guess: the image's own Dockerfile declares
`HEALTHCHECK ... curl -f http://localhost:10000/health`, so the route is
part of its published contract. That is the difference from
terminus.yaml, which got TCP-only probes precisely because Terminus
publishes no health endpoint.

Dropping ALL capabilities is safe despite the container running headless
Chromium because the app launches it with --no-sandbox
--disable-setuid-sandbox --no-zygote --disable-dev-shm-usage, baked into
its own PUPPETEER_ARGS. That last flag is also why no /dev/shm emptyDir
is mounted. Both hold only for the pinned tag, so the comment says to
re-check on a bump.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
k.renner merged commit b0f0f9242b into master 2026-08-12 16:43:16 +00:00
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit a79a5417e3c5b7df7f10d6ad60d2bd0fb19072e0.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds the TRMNL headless renderer (Deployment, ConfigMap-templated schedule, PVC, ExternalSecret) plus the supporting network policy and backup-schedule wiring. The manifests are careful and internally consistent — I cross-checked every cross-file claim (Terminus's Service port, the HA pod's real container port, the trmnl namespace's default-deny-ingress, the absence of any default-deny-egress in trmnl, the velero PVC selector, the alpine-jq image precedent, and the CiliumNetworkPolicy scoping pattern used elsewhere in home-assistant-ns.yaml) and all of them check out against the actual checked-out state. One real gap: the "fully declarative, no clicks" schedule-editing story doesn't hold together as described.

🔴 Blocking

  • kubernetes/apps/trmnl/renderer.yaml:112-114 (ConfigMap comment) claims: "The app re-reads schedules.json every 60 s, so edits here take effect without a restart once the init container has rewritten it." This is not how it will behave. The render-schedule init container (lines 225-259) only runs when the pod is created or restarted — it does not re-run when the trmnl-renderer-schedule ConfigMap changes. This repo's own Reloader deployment (kubernetes/infrastructure/reloader/helmrelease.yaml:5-7) states explicitly that it "Acts ONLY on workloads carrying its annotations… it never blanket-reloads un-annotated resources," and the trmnl-renderer Deployment carries no configmap.reloader.stakater.com/reload (or equivalent) annotation. There's also no configMapGenerator in kubernetes/apps/trmnl/kustomization.yaml:1-21 to hash-suffix the ConfigMap and force a rollout on change — it's a plain resource, so Flux will update the ConfigMap object in place without touching the Deployment.
    Net effect: editing schedules.template.json in git (e.g. changing dashboard_path, cron, or interval_minutes) will silently not take effect until someone manually restarts the trmnl-renderer pod. That directly undercuts the PR's stated design goal ("fully declarative — no clicks… entire schedule is reviewable in git") and is exactly the class of quiet failure the rest of this PR is unusually careful to call out (compare the "Four things that would fail quietly" section in the PR body, which doesn't mention this one).
    Fix: add configmap.reloader.stakater.com/reload: trmnl-renderer-schedule to the Deployment's metadata.annotations (mirroring the existing secret.reloader.stakater.com/reload pattern on home-assistant's Deployment), or correct the comment to say a manual/triggered pod restart is required after a schedule edit.

🟡 Suggestions

  • kubernetes/apps/trmnl/renderer.yaml:129-130: the schedule template sets both "cron": "* * * * *" and "interval_minutes": 1. If the renderer treats these as two independent triggers rather than cron being authoritative when set, this could double-fire renders every minute. Not verifiable from this repo (app internals aren't vendored here) — worth a quick check against the running instance's logs after this deploys, same diligence already applied elsewhere in this PR to model_id and /health.

🟢 Nits

None beyond the above.

Tests

No automated tests apply here (this is a pure Kubernetes-manifest change in a GitOps repo; correctness is verified by reading manifests against the live cluster state, which is what this review did). The PR body documents that the author verified several externally-dependent facts against running systems (OpenBao secrets present, HA token returns 200, BYOS tokens valid against Terminus, model_id semantics, /health route) — these aren't independently checkable from the checkout and are taken on trust given the specificity of the verification described. Everything that is checkable from the repo (service names/ports, network policy scoping, PVC backup coverage, image-automation opt-out rationale, ConfigMap/volume mount wiring) was verified directly and is correct.

Recommendation

Request changes — 1 blocking issue.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `a79a5417e3c5b7df7f10d6ad60d2bd0fb19072e0`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds the TRMNL headless renderer (Deployment, ConfigMap-templated schedule, PVC, ExternalSecret) plus the supporting network policy and backup-schedule wiring. The manifests are careful and internally consistent — I cross-checked every cross-file claim (Terminus's Service port, the HA pod's real container port, the `trmnl` namespace's default-deny-ingress, the absence of any default-deny-egress in `trmnl`, the velero PVC selector, the `alpine-jq` image precedent, and the `CiliumNetworkPolicy` scoping pattern used elsewhere in `home-assistant-ns.yaml`) and all of them check out against the actual checked-out state. One real gap: the "fully declarative, no clicks" schedule-editing story doesn't hold together as described. ## 🔴 Blocking - `kubernetes/apps/trmnl/renderer.yaml:112-114` (ConfigMap comment) claims: *"The app re-reads schedules.json every 60 s, so edits here take effect without a restart once the init container has rewritten it."* This is not how it will behave. The `render-schedule` init container (lines 225-259) only runs when the pod is created or restarted — it does not re-run when the `trmnl-renderer-schedule` ConfigMap changes. This repo's own Reloader deployment (`kubernetes/infrastructure/reloader/helmrelease.yaml:5-7`) states explicitly that it "Acts ONLY on workloads carrying its annotations… it never blanket-reloads un-annotated resources," and the `trmnl-renderer` Deployment carries no `configmap.reloader.stakater.com/reload` (or equivalent) annotation. There's also no `configMapGenerator` in `kubernetes/apps/trmnl/kustomization.yaml:1-21` to hash-suffix the ConfigMap and force a rollout on change — it's a plain resource, so Flux will update the ConfigMap object in place without touching the Deployment. Net effect: editing `schedules.template.json` in git (e.g. changing `dashboard_path`, `cron`, or `interval_minutes`) will silently **not** take effect until someone manually restarts the `trmnl-renderer` pod. That directly undercuts the PR's stated design goal ("fully declarative — no clicks… entire schedule is reviewable in git") and is exactly the class of quiet failure the rest of this PR is unusually careful to call out (compare the "Four things that would fail quietly" section in the PR body, which doesn't mention this one). Fix: add `configmap.reloader.stakater.com/reload: trmnl-renderer-schedule` to the Deployment's `metadata.annotations` (mirroring the existing `secret.reloader.stakater.com/reload` pattern on `home-assistant`'s Deployment), or correct the comment to say a manual/triggered pod restart is required after a schedule edit. ## 🟡 Suggestions - `kubernetes/apps/trmnl/renderer.yaml:129-130`: the schedule template sets both `"cron": "* * * * *"` and `"interval_minutes": 1`. If the renderer treats these as two independent triggers rather than cron being authoritative when set, this could double-fire renders every minute. Not verifiable from this repo (app internals aren't vendored here) — worth a quick check against the running instance's logs after this deploys, same diligence already applied elsewhere in this PR to `model_id` and `/health`. ## 🟢 Nits None beyond the above. ## Tests No automated tests apply here (this is a pure Kubernetes-manifest change in a GitOps repo; correctness is verified by reading manifests against the live cluster state, which is what this review did). The PR body documents that the author verified several externally-dependent facts against running systems (OpenBao secrets present, HA token returns 200, BYOS tokens valid against Terminus, `model_id` semantics, `/health` route) — these aren't independently checkable from the checkout and are taken on trust given the specificity of the verification described. Everything that *is* checkable from the repo (service names/ports, network policy scoping, PVC backup coverage, image-automation opt-out rationale, ConfigMap/volume mount wiring) was verified directly and is correct. ## Recommendation Request changes — 1 blocking issue. <!-- pr-review:sha=a79a5417e3c5b7df7f10d6ad60d2bd0fb19072e0 -->
Sign in to join this conversation.