Files
homelab/kubernetes/apps/trmnl/renderer.yaml
security-bot 104c5edf67
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
chore(image-automation): bump ghcr.io/usetrmnl/trmnl-ha-amd64:0.11.1
Updates managed by image-automation-controller.
2026-10-15 23:43:55 +00:00

400 lines
18 KiB
YAML

# Headless renderer: screenshots the Home Assistant e-ink dashboard,
# dithers it to 16 greys and hands it to Terminus.
#
# Upstream ships this as a Home Assistant add-on; it runs standalone here
# because the app prefers the env vars over /data/options.json. Do NOT set
# SUPERVISOR_TOKEN — it flips an internal isAddOn flag and switches the app to
# add-on behaviour (fixed port, /data assumptions).
#
# DIRECTION OF TRAVEL, the non-obvious part: in `uri` delivery mode the
# renderer does not push image bytes. It POSTs a URL to its own /output/<file>
# and Terminus fetches the image back. Both therefore live in this namespace,
# where that pull is already permitted.
apiVersion: v1
kind: ConfigMap
metadata:
name: trmnl-renderer-schedule
namespace: trmnl
data:
# Template for /data/schedules.json. The init container splices the
# BYOS tokens in; everything non-secret is declared here so the
# schedule is reviewable in git rather than clicked into the app's
# web UI (which has no authentication and is not exposed).
#
# The app re-reads schedules.json every 60 s, so edits here take
# effect without a restart once the init container has rewritten it.
#
# lang is "de", not null. It calls Home Assistant's own
# _selectLanguage() for the screenshot session, so the rendered page
# is localised by HA rather than by hand. With it left null the
# dashboard grew a German weekday array, a German month array and a
# fifteen-entry weather-condition dict — reimplementing translations
# HA already ships. The instance itself stays language "en"; this
# only affects the render session.
#
# model_id is 1 — TRMNL X (name "v2", 1872x1404, 16 colours) — and it
# is an INTEGER, not a name. Confirmed against the running server:
# GET /api/screens returns model_id as a number, and upstream's
# example value "og_1bit" matches no model on this instance (the
# 2-bit OG model here is id 11, name "og_plus"). Getting this wrong
# shows up as Terminus rejecting the pushed screen with a 422.
#
# All three auth fields below are placeholders the init container
# replaces — including obtained_at, which must end up a real epoch-MS
# stamp. The 0 here is inert only because it is always overwritten;
# left as 0 the scheduler reads the tokens as ancient and refuses to
# push at all ("expired beyond the refresh window"), with no render
# attempt and no error beyond one startup warning.
schedules.template.json: |
[
{
"id": "trmnl-entrance",
"name": "Entrance e-ink panel",
"enabled": true,
"cron": "* * * * *",
"interval_minutes": 1,
"ha_mode": true,
"dashboard_path": "/trmnl-entrance/home",
"target_url": null,
"viewport": { "width": 1872, "height": 1404 },
"device": "schedule_trmnl_x",
"crop": { "enabled": false, "x": 0, "y": 0, "width": 0, "height": 0 },
"format": "png",
"rotate": null,
"zoom": 1,
"wait": null,
"theme": "Graphite E-ink Light",
"lang": "de",
"dark": false,
"invert": false,
"timestamp": false,
"dithering": {
"enabled": true,
"method": "floyd-steinberg",
"palette": "gray-16",
"bitDepth": 4,
"gammaCorrection": true,
"blackLevel": 0,
"whiteLevel": 100,
"normalize": true,
"saturationBoost": false
},
"webhook_url": "http://terminus.trmnl.svc.cluster.local/api/screens",
"webhook_headers": {},
"webhook_format": {
"format": "byos-hanami",
"byosConfig": {
"label": "Eingang",
"name": "trmnl-entrance",
"model_id": 1,
"preprocessed": true,
"addon_base_url": "http://trmnl-renderer.trmnl.svc.cluster.local:10000",
"delivery_mode": "uri",
"auth": {
"enabled": true,
"access_token": "__BYOS_ACCESS_TOKEN__",
"refresh_token": "__BYOS_REFRESH_TOKEN__",
"obtained_at": 0
}
}
},
"createdAt": "2026-08-11T00:00:00.000Z",
"updatedAt": "2026-08-11T00:00:00.000Z"
}
]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: trmnl-renderer
namespace: trmnl
labels:
app.kubernetes.io/name: trmnl-renderer
annotations:
# BOTH sources need this, and for the same reason: the init
# container writes /data/schedules.json ONCE, at pod start. Nothing
# re-reads either source afterwards.
#
# The app re-reads schedules.json every 60s, which makes it look
# self-updating — but only the FILE, and the file is only rewritten
# by the init container. So a ConfigMap or Secret change lands in
# git, updates the object, and the running renderer keeps the old
# values indefinitely.
#
# Both halves were hit repeatedly in practice and each time needed
# `kubectl delete pod` by hand: the theme name, the schedule's lang
# field, and every re-mint of the BYOS tokens. A fix that depends on
# someone remembering to restart a pod is not deployed.
#
# Named per-resource rather than the blanket
# reloader.stakater.com/auto: "true", matching terminus.yaml and
# mosquitto-deployment.yaml.
configmap.reloader.stakater.com/reload: trmnl-renderer-schedule
secret.reloader.stakater.com/reload: trmnl-renderer-secrets
spec:
replicas: 1
# RWO PVC, and two renderers would push conflicting screens for the
# same device.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: trmnl-renderer
template:
metadata:
labels:
app.kubernetes.io/name: trmnl-renderer
spec:
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
initContainers:
# Writes /data/schedules.json from the ConfigMap template with
# the BYOS tokens injected.
#
# jq rather than sed: the tokens are JWTs and go into a JSON
# string, so they need JSON-safe escaping. A sed substitution
# would corrupt on any character sed treats as special.
#
# No HTTP here on purpose. The app does expose a /api/byos/login
# proxy that exchanges a Terminus username+password for these
# tokens, but that would mean shipping the Terminus PASSWORD
# into the cluster and doing a network call at every pod start.
# Tokens are therefore minted once out of band and kept in
# OpenBao — the password never enters the cluster.
#
# That only works because Terminus is configured with
# API_ACCESS_TOKEN_PERIOD = 10 years (see terminus.yaml). It is
# NOT, as an earlier version of this comment claimed, because
# stock Terminus issues permanent tokens — the stock default is
# 1800s, and this deployment demonstrated it by having the
# webhook start failing with "expired JWT access token" about
# thirty minutes after seeding.
- name: render-schedule
# konairius/, not gitea_admin/: this container starts during a cold
# rebuild, when Gitea does not exist because it runs inside the
# cluster being rebuilt — a rehearsal left it in Init:ImagePullBackOff.
# The node falls back to a ghcr.io copy then
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
# keeps the repository path, so it has to exist on both registries.
# Steady state still pulls from Gitea.
image: gitea.renner.uno/konairius/alpine-jq:2026-09-04
command:
- sh
- -c
- |
set -eu
# obtained_at is stamped at write time, in MILLISECONDS.
# It is not cosmetic: the scheduler derives token age from
# it, and a literal 0 made the app treat freshly-minted
# tokens as ancient and skip the webhook entirely —
# "BYOS tokens ... expired beyond the refresh window"
#
# Stamping it "now" is only safe because Terminus is
# configured with API_ACCESS_TOKEN_PERIOD = 10 years (see
# terminus.yaml). Against the 30-minute default it would be
# actively harmful: the app would believe a stale token had
# 30 fresh minutes left, skip its proactive refresh, and
# then be unable to recover, because refreshing requires a
# still-valid token. If that setting is ever reverted, this
# must become a real mint time carried alongside the tokens.
jq --arg at "$BYOS_ACCESS_TOKEN" \
--arg rt "$BYOS_REFRESH_TOKEN" \
'(.[].webhook_format.byosConfig.auth.access_token) = $at
| (.[].webhook_format.byosConfig.auth.refresh_token) = $rt
| (.[].webhook_format.byosConfig.auth.obtained_at) = (now * 1000 | floor)' \
/template/schedules.template.json > /data/schedules.json
echo "wrote /data/schedules.json ($(wc -c < /data/schedules.json) bytes)"
env:
- name: BYOS_ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: trmnl-renderer-secrets
key: BYOS_ACCESS_TOKEN
- name: BYOS_REFRESH_TOKEN
valueFrom:
secretKeyRef:
name: trmnl-renderer-secrets
key: BYOS_REFRESH_TOKEN
volumeMounts:
- name: data
mountPath: /data
- name: schedule-template
mountPath: /template
readOnly: true
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
containers:
# Enrolled in image automation (flux-system:trmnl-renderer). A bad
# bump shows up as a corrupted screen, not a failed rollout — check
# the panel after one lands. ARCH-SUFFIXED image: upstream publishes
# no multi-arch manifest.
- name: renderer
image: ghcr.io/usetrmnl/trmnl-ha-amd64:0.11.1 # {"$imagepolicy": "flux-system:trmnl-renderer"}
ports:
- name: http
containerPort: 10000
env:
# The in-cluster Service name, NOT ha.renner.uno and not
# home.renner.uno. Both public names are unusable from a
# pod: ha.renner.uno resolves to the home-assistant-bgp
# LoadBalancer's external address (10.2.99.87), which is not
# backed by an Ingress, so CoreDNS's k8s_gateway delegation
# does not intercept it and the pod hairpins off the LB.
#
# This cluster has hit that exact failure before — see the
# comment on kubernetes/infrastructure/openbao/rotation/
# home-assistant.yaml, which reaches HA the same way and
# states it outright: "The public name is unusable from a
# pod (ingress-LB hairpin)."
#
# FAILS QUIETLY if wrong: the schedule keeps firing every
# minute, each render errors on connect, and the panel just
# never updates. Nothing goes CrashLoopBackOff.
- name: HOME_ASSISTANT_URL
value: "http://home-assistant-bgp.home-assistant.svc.cluster.local:8123"
- name: TZ
value: "Europe/Berlin"
# Chromium will not start without a writable HOME. Running
# as uid 1000 with no matching passwd entry leaves HOME="/",
# which is root-owned 0755, and the browser dies on launch
# with
# chrome_crashpad_handler: --database is required
# because crashpad cannot create its database directory.
#
# Diagnosed on the running pod, not guessed: HOME=/, uid=1000,
# / not writable, /tmp writable. Crash dumps are throwaway, so
# /tmp is the right home for them rather than the PVC.
#
# This is a consequence of OUR non-root securityContext, not
# of the image — so it must move together with runAsUser.
- name: HOME
value: "/tmp"
# Dedicated long-lived token, NOT the operator's personal
# one, so the renderer's access can be revoked on its own.
# It is injected into the headless browser's localStorage,
# which is how the app authenticates to the HA frontend.
- name: ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: trmnl-renderer-secrets
key: HA_ACCESS_TOKEN
volumeMounts:
- name: data
mountPath: /data
# Bounded scratch backing HOME. Chromium writes crashpad
# state and profile data here on every launch, and it now
# launches once a minute for the life of the pod — on the
# container's writable layer that is unbounded growth
# against node disk, with no ephemeral-storage limit to
# catch it. A sizeLimit converts a slow, cluster-wide
# node-disk-pressure problem into a bounded and clearly
# attributable eviction of this pod.
- name: tmp
mountPath: /tmp
# HTTP probes here, unlike terminus.yaml's TCP-only ones, because
# /health is confirmed rather than assumed: the upstream image's
# own Dockerfile declares
# HEALTHCHECK ... curl -f http://localhost:10000/health
# so the route is part of the image's published contract. Terminus
# got TCP probes precisely because it publishes no such endpoint.
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 250m
memory: 768Mi
# Chromium is a SEPARATE process from the app, and the app's
# own watchdog only measures its own RSS (restarting itself
# past 900 MB). The limit therefore has to cover the app's
# ceiling PLUS a browser rendering a 1872x1404 page, which
# is well above the 512 MB upstream's troubleshooting note
# suggests.
limits:
memory: 2Gi
# Chromium normally needs either SYS_ADMIN or a user namespace to
# start its own sandbox, which would make dropping ALL fatal. It
# is safe here because the app launches the browser with
# --no-sandbox --disable-setuid-sandbox --no-zygote (and
# --disable-dev-shm-usage, which is why no /dev/shm emptyDir is
# mounted either). Those flags are baked into the app's own
# PUPPETEER_ARGS, not something we pass, so this holds as long as
# the pinned tag does — worth re-checking on any bump.
#
# Confirmed on the running pod: the capability drop is genuinely
# not what broke the browser. The first deploy DID crash it, but
# on a writable-HOME problem caused by runAsUser — see the HOME
# env above. Do not "fix" a future Chromium launch failure by
# loosening this block before checking HOME and /tmp.
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumes:
- name: data
persistentVolumeClaim:
claimName: trmnl-renderer-data
- name: schedule-template
configMap:
name: trmnl-renderer-schedule
- name: tmp
emptyDir:
# RAM-backed, NOT disk. Without `medium: Memory` an emptyDir lands
# on the node's filesystem, and this one was the single largest
# writer in the cluster: 249 GB over 7.3 days = ~34 GB/day onto
# /dev/vda5, more than every Ceph OSD and every database combined
# (measured 2026-08-20 from the container's cgroup io.stat).
#
# The cause is cadence, not volume. schedules.template.json renders
# every 60 s, so Chrome is launched 1440 times a day and each launch
# creates, fills and deletes a /tmp/puppeteer_dev_chrome_profile-*
# directory. At rest /tmp holds under 8 MB; it is pure churn for
# data that never outlives the render.
#
# tmpfs pages count against the container's memory limit, which is
# why the cap stays at 512Mi: measured usage is 129Mi against a 2Gi
# limit, so even the full cap leaves ample headroom.
medium: Memory
sizeLimit: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: trmnl-renderer
namespace: trmnl
labels:
app.kubernetes.io/name: trmnl-renderer
spec:
# ClusterIP only, and deliberately no Ingress. The app's web UI on
# this port has NO authentication of any kind — upstream says so
# outright — so it must not be routable from the LAN. Terminus
# reaches it in-namespace to fetch rendered screens; nothing else
# needs to.
selector:
app.kubernetes.io/name: trmnl-renderer
ports:
- name: http
port: 10000
targetPort: http