cage+Chromium kiosk, host ustreamer sense-daemon, SSE→MQTT push, headless
Home Assistant) re-drawn so the fleet’s hard invariant holds: every box = Debian + k3s
only; k3s is the sole workload runtime. Design only — nothing here is built. The
prerequisites in the phasing section gate any build, and one of them (verifying i5’s DRM
and video devices) could not be checked read-only from where this was written; that gap is
called out honestly rather than papered over.
Why this document exists
Turn 79 sketched the peripherals subsystem as host-native processes on i5 — acage
(Wayland kiosk compositor) running Chromium against the physical display, and a ustreamer
daemon owning the camera/mic — explicitly “NOT a pod.” That was a deliberate escape hatch:
owning a physical display and a /dev/dri seat from inside a container is genuinely harder
than running the same binary on the host. But it directly violates the rule the whole
rewrite converged on — OS + k3s, nothing else — and a single host daemon on i5 is exactly
the “homeless layer” the k3s migration existed to kill. Augustin chose to redesign it
k8s-native. This is that design, plus an honest account of where the host-native path was
actually easier, so the exception decision can be reopened with eyes open rather than by
drift.
The precedents already in the repo say this is plausible: jellyfin
passes /dev/dri into a pod for QuickSync; the downloads stack runs a NET_ADMIN +
/dev/net/tun pod for VPN; beszel’s DaemonSet takes SYS_ADMIN/SYS_RAWIO and hostPath
/proc+/sys. Device-passthrough pods are a solved, blessed pattern here. What’s new is
DRM master (owning a display output, not just decoding on the render node) and a
Wayland seat inside a container. That’s the whole ballgame, and section Risks
does not pretend otherwise.
The GPU reality (read this before anything else)
The two GPUs are not interchangeable, and the display is on the wrong one for the existing precedent:- i3 (
kind=storage, 192.168.1.100) has the working/dev/dri— Intel HD 4400,card0+renderD128, used by jellyfin for QuickSync. It has no attached screen that matters. - i5 (
kind=compute, 192.168.1.25) has the physical screen — the Dell Inspiron 5490 All-in-One (model W24C). It has its own iGPU (10th-gen-class, QuickSync-capable per the turn-13 spec recon), but no one has verified its/dev/dri//dev/video*//dev/inputdevice layout in the k3s era. The jellyfin passthrough is on i3 and tells us nothing about i5’s device nodes.
nodeSelector: { kind: compute } (i5) and mounts i5’s device nodes, whose exact paths
are a hard prerequisite to verify (see Phasing).
One piece of good news for free: the fleet’s OS is headless (Debian + k3s, no desktop, no
display manager). That is exactly the condition a DRM-master container needs — nothing else on
i5 is holding the display. The “OS + k3s only” rule and “a pod owns the screen” are, on this
specific point, aligned: because there’s no host gdm/Xorg/cage competing for
/dev/dri/card*, a pod can become DRM master cleanly. The invariant that makes this hard
elsewhere makes it possible here.
Architecture at a glance
Five components, one new namespaceperipherals, all on the GitOps tree:
| Component | Kind | Node | The hard part |
|---|---|---|---|
| kiosk | Deployment, privileged | i5 (kind=compute) | owns the physical display (DRM master + Wayland seat in-pod) |
| senses | Deployment, device-mounted | i5 (kind=compute) | /dev/video* + /dev/snd passthrough; MJPEG/audio out |
| mosquitto | Deployment + PVC | i3 (kind=storage) | in-cluster MQTT broker; the push bus |
| home-assistant | Deployment + PVC | i3 (kind=storage) | headless / API-only state hub |
| (no new agent tooling) | — | — | the agent drives the kiosk via existing deploy-web-app + call-service |
deploy-web-app → apps.augustin.ai/<x>) and already speaks HTTP to any in-cluster service
(call-service). The display subsystem is therefore almost entirely a consumer of
capabilities that already exist. The only genuinely new infrastructure is the push bus
(so the screen learns what to show without polling) and the two device-owning pods.
1. Display / kiosk as a pod
Goal: a k3s pod renders Chromium full-screen to i5’s physical panel, and switches what it shows on command. Shape: a single-container (or compositor+browser two-container) Deployment, pinned to i5, running a Wayland kiosk compositor inside the pod (cage, or weston --shell=kiosk) that
takes DRM master on i5’s card and launches Chromium as its only client in kiosk mode. This is
the host-native turn-79 design (cage + Chromium) moved inside a container — same binaries,
same idea, different runtime boundary.
Why the compositor lives in the pod and not on the host: putting cage on the host is
precisely the host-process exception we’re removing. The compositor is the workload; the
workload runs in k3s. The container image is cage + chromium + mesa/libva +
seatd/libseat on a minimal Debian base (built under images/kiosk/, pushed to
ghcr.io/amarcin/kiosk, same CI matrix as the other custom images).
The three hard problems and the proposed solutions
(a) DRM master — only one process can own the display. KMS allows exactly one DRM master per card. If anything else holds it, the compositor’sdrmSetMaster fails and you get a black screen. Solution, in order of preference:
- Rely on the headless OS. i5 ships with no display manager (OS + k3s only), so on boot
nothing owns
/dev/dri/card*. The kiosk pod’scagebecomes master unopposed. This is the clean path and it’s a consequence of the k3s-only rule — verify at bring-up that i5 has nogettyon the console VT grabbing DRM and noplymouth/fbconsplash holding it. - VT/seat handoff. Run the compositor on a dedicated VT and mount
/dev/tty0+/dev/tty2socagecanVT_SETMODE/KDSETMODEto take the console.seatd(a tiny seat manager) in the pod brokers device access without a fullsystemd-logind; mount/run/seatd.sockfrom aseatdsidecar or runseatdas the container’s init.cagesupportslibseatwith theseatdbackend, which is the lightest option that still does proper master arbitration.
/dev/input/event*. cage/libinput needs these mounted and needs
seatd to grant them. Mount /dev/input (hostPath) and let seatd own the seat. If touch
calibration is off, that’s an evdev/libinput config problem inside the pod, not a k8s problem.
(c) The GPU render + KMS device. Mount i5’s /dev/dri (both card* for KMS/scanout
and renderD* for GL/VA) exactly like jellyfin mounts i3’s — but the device paths and the
LIBVA_DRIVER_NAME (i5 is 10th-gen → iHD, not jellyfin’s i965 for the 4400) are
i5-specific and must be verified, not copied from jellyfin.
Pod spec (described)
nodeSelector: { kind: compute }— pin to i5 (the panel is there).strategy: { type: Recreate }— a rolling second pod would fight for DRM master; only one kiosk may exist. Same reasoning as thehostPort/Recreate rule for ingress.- securityContext — the honest part. This pod is privileged or runs with a specific
cap set (
SYS_ADMINfor DRM ioctls/mode-setting, plusSYS_TTY_CONFIGfor VT switching) andseLinuxOptions/seccompProfile: Unconfinedif the compositor trips the default seccomp filter (mode-setting ioctls sometimes do — the same class of exception firefox neededseccomp:unconfinedfor). Start from the minimal set —SYS_ADMIN+SYS_TTY_CONFIG+ the device mounts +seccomp: Unconfined— and only fall back toprivileged: trueifseatd-brokered access can’t get DRM master. Document whichever lands; do not leave it at blanketprivilegedif a cap set works. - Device mounts (hostPath, all on i5, all VERIFY-FIRST):
/dev/dri→ the GPU (KMScard*+ renderrenderD*)./dev/input→ keyboard/mouse/touch (event*)./dev/tty0(+ a dedicated VT like/dev/tty2) → console/VT for master handoff./run/udev(readOnly) →libinputdevice discovery, ifseatdalone isn’t enough.
/dev/shmsizing — Chromium crashes on the default 64 MB shm (the exact trap firefox documents). Mount anemptyDir{ medium: Memory, sizeLimit: 1Gi }at/dev/shm.- No ingress, no Service. The kiosk is an output device, not a network service — nothing connects to it. It only makes outbound connections (subscribe to MQTT, fetch the app URL).
- Resources: request ~512Mi/250m, limit ~2Gi (a full Chromium + compositor; comparable to the browserless limit).
- Config: subscribe target (MQTT broker Service DNS), a default/idle URL to show when no
event is pending (e.g. a clock/dashboard app on
apps.augustin.ai), and screen resolution/rotation for the W24C panel.
The tradeoff vs. the shell-free / minimal-privilege ethos
The agent stack fought hard to be shell-free and least-privilege (turn 73; the mastra pod runsrunAsUser: 1000 with a narrow RBAC role and no node shell). This kiosk pod is the
opposite corner of that ethos: privileged-ish, hostPath device mounts, likely
seccomp: Unconfined. That is not a contradiction to resolve away — it’s a real cost. The
mitigations: it runs in its own namespace with no serviceAccount privileges (it never
touches the k8s API — it only owns hardware), it exposes no listening port (no inbound
attack surface), and its blast radius is “the screen and the local input devices on one AIO,”
not the cluster. It is a device driver wearing a pod, and we accept that a device driver
needs device access. The line we hold: privileged for hardware, never privileged for
convenience.
2. Camera / mic sense layer as a pod
Goal: replace the hostustreamer daemon with a pod that owns i5’s camera and mic and
publishes them as a passive feed plus a callable input.
Shape: a Deployment pinned to i5, mounting /dev/video* and /dev/snd, running
ustreamer (MJPEG over HTTP) for the camera and, if audio is wanted, a small ALSA/PulseAudio
capture into the same pod or a PCM/RTP stream. Direct analog of jellyfin’s /dev/dri mount —
this is the device-mount precedent applied to V4L2 + ALSA instead of DRM.
Pod spec (described)
nodeSelector: { kind: compute }— the camera/mic are physically on the i5 AIO.- securityContext: far lighter than the kiosk. V4L2 + ALSA generally need only the device
mounts and a matching group (
video,audio), notprivilegedand notSYS_ADMIN. UsesupplementalGroupsfor the hostvideo/audioGIDs (the pattern jellyfin uses withsupplementalGroups: [992]forrender). No net caps. - Device mounts (hostPath, on i5, VERIFY-FIRST):
/dev/video0(and any/dev/video1..N— UVC cameras expose two nodes, capture + metadata; mount the directory or enumerate) viatype: CharDevice./dev/snd→ the sound devices (directory).
- Service + no public ingress: expose the MJPEG stream on a ClusterIP Service
(
senses.peripherals.svc) so HA and the agent can read it in-cluster only. If a camera view ever needs to be shown, it’s shown through the kiosk (which fetches anapps.augustin.aipage embedding the stream) — the camera feed does not get its own public hostname. - Device-plugin note: the “proper” k8s way to hand out
/dev/video*//dev/driis a device plugin (e.g.intel-gpu-plugin, or a generick8s-device-pluginfor hostPath devices) advertisingintel.com/gpu/ avideoresource so pods request the device instead of hostPath-mounting it. That’s cleaner and schedules honestly, but it’s more moving parts for a single-node, single-device situation. Decision: start with hostPath (matches jellyfin, zero new infra) and only adopt a device plugin if a second consumer or a second GPU ever makes contention real. Don’t gold-plate a one-camera problem.
3. The push channel
Goal: the agent (and any service) can push a “show X” event to the screen without the kiosk polling. Decision — reconsider SSE→MQTT, land on MQTT. Turn 79 said “SSE→MQTT push channel,” which is really two things: MQTT as the bus, SSE as one possible edge for a browser client. For a kiosk that is a native Chromium+compositor (not a thin browser tab hitting an SSE endpoint), a plain MQTT subscription is simpler and more robust than SSE — the kiosk holds one long MQTT connection and reacts tokiosk/show messages. Keep it fully in-cluster and
k8s-native: an in-cluster Mosquitto broker as a pod.
mosquitto (broker)
- Deployment + PVC,
nodeSelector: { kind: storage }(i3 — it’s stateful-ish: retained messages + a persistence file; pin it with the rest of the stateful data),Recreate. - ClusterIP Service on 1883, in-cluster only (no ingress, no LoadBalancer — unlike the stalwart mail-port exception, nothing external needs MQTT).
- Auth: a username/password from a
kubectl-created secret (secretKeyRef), never in git. runAsUser: 1000, minimal config ConfigMap (listener 1883, persistence on, one ACL: publishers can writekiosk/#, the kiosk subscribeskiosk/#).
The bridge — reuse call-service, don’t build a tool
The agent already has call-service (turn 93): a shell-free authenticated HTTP primitive
that reaches any in-cluster service via a registry. MQTT is a TCP protocol, not HTTP, so the
agent can’t publish to Mosquitto directly with call-service. Two ways to keep it
k8s-native without new agent code:
- HA as the bridge (preferred). Home Assistant (§4) speaks both MQTT and a REST API.
The agent calls HA’s REST/webhook API via
call-service(register HA in the service registry), and an HA automation republishes to thekiosk/showMQTT topic. One config entry, zero new agent tooling, and it makes “show X” a first-class HA action alongside every other device action. - A tiny MQTT-HTTP bridge pod (
mqtt/httpshim, e.g. a 20-line service exposingPOST /publish {topic,payload}→ MQTT) registered incall-service. Only build this if HA turns out to be the wrong hub. It’s the fallback, not the default.
4. Home Assistant headless / API-only
Goal: HA as the device/state hub (the thing that knows about the camera, the screen, and any future smart-home devices) — its API and MQTT, not its frontend. Build our own UI (a web app onapps.augustin.ai); don’t adopt HA’s Lovelace as the surface. This matches the
turn-36/38 senses framing: the agent is the UI; the services are senses.
Pod spec (described)
- Deployment + PVC (
/config— HA’s SQLite/state store),nodeSelector: { kind: storage }(i3, with the stateful data),Recreate,replicas: 1. - Image:
ghcr.io/home-assistant/home-assistant:stable. - Network — the one wrinkle. HA’s device auto-discovery (mDNS/SSDP for smart-home gear)
wants host networking or at least broadcast reach on the LAN. For an API/MQTT-only
hub driving the screen, that discovery is not needed — so run it as a normal ClusterIP
Deployment (no
hostNetwork), talking to Mosquitto over the cluster network and to the agent over REST. If and when a real broadcast-discovered device is added, revisithostNetwork: true(pinned to i3) as a scoped exception — but don’t take that networking hit speculatively. Document it as a known future edge. - Config: the MQTT integration pointed at
mosquitto.peripherals.svc, a long-lived access token (secret) for the agent’scall-servicecalls, and thekiosk/showautomation from §3. - No public ingress by default (agent reaches it in-cluster). If a debugging view of HA’s
own UI is ever wanted, add an ingress behind the existing CF Access policy — but the
product UI is the custom
apps.augustin.aiapp, per the framing.
5. How the agent drives it (no new tooling)
The whole point of tying into existing capabilities: the display subsystem is a client of what the agent can already do.- Content: the agent publishes a page with
deploy-web-app→ served by Caddy atapps.augustin.ai/<x>(turn 72, LOCKED). “Show me images of X” = generate a tiny gallery app,deploy-web-appit, done. No new content pipeline. - Command: the agent tells the screen what to show by
call-serviceto HA (or the bridge), which publisheskiosk/show {url: "https://apps.augustin.ai/<x>"}. The kiosk pod, subscribed, navigates Chromium there. - Proactive: the agent can already reach out via ntfy (the mastra pod has
NTFY_URLwired). “I put it on the screen” can also ping the phone with the same link — the surface is the screen and the notification, reusing the existing escalation path. - Camera as a tool: the senses pod’s in-cluster MJPEG/PCM endpoints are just more
in-cluster services — reachable by
call-service(HTTP) for a snapshot, or surfaced through HA as a camera entity. No bespoke “camera tool” needed; it collapses into the existing broad primitive, which is exactly the turn-91→95 direction (few broad primitives, not a per-device tool pile).
Phasing
Hard prerequisites first. Nothing below is buildable until these are answered; several require physical/console access to i5 that a read-only k8s session does not have. Phase 0 — verification & access (BLOCKS everything).- P0.1 — Re-establish i5 bring-up access.
ssh i5is unwired (verified: from pentium,ssh i5→ “Could not resolve hostname i5”). i5 is a live k3s node at 192.168.1.25 (verified:kubectl get nodesshows i5 Ready,kind=compute, Debian 13, kernel 6.12.94), but there is no working shell path to it from the bastion for device inspection or console work. Restore a reachable, authenticated path to i5 (fix the~/.ssh/confighost entry / mesh name resolution) before touching hardware. This document could not enumerate i5’s devices for exactly this reason — treat every device path below as unconfirmed. - P0.2 — Verify i5’s
/dev/dri. Confirmcard*(KMS/scanout) andrenderD*exist and which VA driver the 10th-gen iGPU uses (iHDvsi965). The jellyfin manifest is on i3 and tells us nothing about i5. Without this, the kiosk manifest is a guess. - P0.3 — Verify i5’s
/dev/video*and/dev/snd. Confirm the AIO webcam enumerates (UVC →/dev/video0+) and the mic/speakers appear under/dev/snd`. - P0.4 — Verify i5 holds no DRM master at boot. Confirm the headless OS truly leaves
/dev/dri/card*free — nogdm/Xorg/getty-on-console/plymouthgrabbing it. This is the assumption the whole kiosk rests on; the “OS + k3s only” rule should guarantee it, but verify, don’t assume. - P0.5 — W24C panel/touch/backlight verify (the standing turn-79 TODO). Confirm the Dell
W24C’s touch SKU, panel native resolution/rotation, and that backlight control works
headless (some AIO panels need a kernel
video=/i915quirk). Touch drives whether we mount/dev/inputfor a touchscreen and whether calibration is needed.
call-service registry, author the kiosk/show automation. Now “publish a show-event” works
end-to-end before any screen exists — testable by watching the MQTT topic.
Phase 3 — senses pod (needs P0.1/P0.3). Build/deploy the senses Deployment on i5 with
/dev/video* + /dev/snd mounts and supplementalGroups. Lower-risk than the kiosk (no DRM
master). Verify the MJPEG stream on the ClusterIP Service and, optionally, as an HA camera
entity. This also proves i5 device passthrough works at all before betting the display on
it.
Phase 4 — the kiosk (needs ALL of P0). Build images/kiosk/ (cage + Chromium + mesa +
seatd), deploy the privileged/carefully-capped Deployment on i5 with the DRM/input/tty mounts
and the /dev/shm sizing. Iterate the securityContext down from a known-working state
toward the minimal cap set. Wire it to subscribe kiosk/show. First success = an
apps.augustin.ai page appears on the physical panel on command. This is last because it is
the hardest and every earlier phase de-risks a piece of it.
Phase 5 — glue & polish. Default/idle screen app (clock/dashboard), the agent’s
“show me X” flow end-to-end (generate → deploy-web-app → call-service HA → screen),
ntfy dual-surface, touch interactions if the W24C is a touch panel.
Risks — where k8s-native is genuinely harder
This is the section that doesn’t rubber-stamp the mandate.- DRM master in a container is the real risk. On a headless host it should work (nothing
competes), but the failure modes are nasty and remote-debuggable only up to a point: if
cagecan’t take master you get a black screen with no network symptom, and diagnosing it means console access to i5 (which, per P0.1, isn’t even wired yet). A host process hitting the same problem is debuggable over plain SSH withjournalctl; a pod adds a container/seat layer between you and the DRM error. This is strictly harder than host-native, and it’s the single most likely place the build stalls. - Seat management without
systemd-logind. Containers don’t have the host’slogindseat.seatdis the answer and it works, but it’s one more in-pod daemon to get right, andlibseatbackend selection (seatdvslogindvsbuiltin) is fiddly. Host-nativecageunder the host’sseatd/logindis a documented, common setup; in-pod is comparatively uncharted. - Single-display arbitration. Only one DRM master, ever. The
Recreatestrategy enforces one pod, but a crash-looping kiosk that grabs-and-releases master can wedge the console; and if P0.4 is wrong (something does hold master at boot), the pod never starts cleanly. A host process has the same constraint but a simpler recovery (restart the unit). - securityContext cost. This pod is the least-privilege ethos’s worst citizen (privileged
or
SYS_ADMIN+SYS_TTY_CONFIG+seccomp: Unconfined+broad device hostPaths). We contain it (own namespace, no API access, no inbound port), but it’s a real expansion of the fleet’s privileged surface — from “beszel reads/sys” to “a pod owns the console.”
deploy-web-app/call-service is a better fit than the host design. No exception wanted
there.
The kiosk compositor is the honest maybe. DRM-master-in-a-container is a known-hard thing,
and the turn-79 host cage existed precisely to sidestep it. My recommendation: build it
k8s-native as designed — the headless OS removes the usual blocker, the precedents exist, and
a host cage daemon is exactly the homeless layer the k3s migration killed. But set a
concrete tripwire: if, after Phase 0 verification, the in-pod cage cannot reliably take DRM
master (crash-loops, black screen, or needs escalating hacks beyond a documented cap set),
that is the signal to reopen the host-exception decision for the compositor only — as a
narrow, documented carve-out (a single host cage unit that the k8s kiosk pod’s Chromium
connects to as a Wayland client), not a reversal of the k3s-only rule. Everything else stays in
k3s regardless. The k8s-native path is the primary; the tripwire keeps us honest instead of
sinking unbounded effort into the one genuinely hard component. Augustin decides at the
tripwire, with real evidence instead of a guess.