400 lines
18 KiB
YAML
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
|