Compare commits

...
Author SHA1 Message Date
Frank d2810ab610 feat(security): digest-pin every registry image on all three hosts (fleet law)
Closing the command-center security queue #70/#71/#72/#73/#74: rolling tags
let a rebuild move under a running service with nobody approving it, and
make a CVE audit ungradeable. 36 quadlet units across dankclaw, dankserver
and frankbook-pro now carry tag@sha256; localhost/* builds stay exempt
(their build is the pin). The pihole and copyparty pins pull the fixed
builds; valkey 9.1.2 (already running) and redis 7.4.11 are pinned in
place, vendor fixes TBD.

New gate tests/quadlet_image_pins.bats: no unpinned registry image, no
generator-rejected key anywhere. Three seams that pinned the old exact-tag
law (frankbook readarr, open_webui, seerr) now require tag AND digest -
strictly stronger. Quadlet Lifecycle docs: batch-abort law, entrypoint
flags-only law, pin law.
2026-10-21 16:32:36 -07:00
Frank 7a8cc83385 fix(quadlet): drop HostAddr from prometheus.unit - podman 5.7.0 rejects the key and the quadlet generator then aborts the ENTIRE user batch, silently orphaning every other unit (prometheus.service vanished while the container ran; deploy died 'Unit prometheus.service not found', live 2026-10-21). host.containers.internal resolves natively; the key was never needed. 2026-10-21 16:32:36 -07:00
dank c4623f1629 Merge pull request 'fix(blackbox): flags-only Exec; bind loopback+LAN; exec-form HealthCmd' (#73) from fix/blackbox-entrypoint-bind into main 2026-10-21 16:25:32 -07:00
Frank 065a6911f9 fix(blackbox): Exec carries flags only (image has ENTRYPOINT) and the exporter binds loopback+LAN
Three stacked lessons, each proved live 2026-10-21:
- the image's ENTRYPOINT is /bin/blackbox_exporter, so Exec= repeating the
  binary made it a positional arg: 'unexpected /bin/blackbox_exporter'.
- the podman gateway 169.254.1.2 is NOT bindable from the host netns - it
  exists only inside each rootless netns: 'bind: cannot assign requested
  address'. The scrape path does not need it bound: a bridge container
  dialing the gateway lands on the host LOOPBACK (proved 200 from the
  prometheus container), so the exporter binds 127.0.0.1 + LAN.
- HealthCmd shell form ('/bin/sh -c "..."') is passed as one quoted token
  and quadlet folds the following --health-interval/--health-retries (and
  the image) into the command string; the plain exec form the sibling units
  use is correct.

Prometheus.md bind law corrected (gateway is a forward-to-loopback, not a
bind address); deploy-remote gate stays loopback, which is now honest.
2026-10-21 16:04:53 -07:00
dank 745d66d60d Merge pull request 'fix(blackbox): --web.listen-address (repeatable), not --listen-address' (#72) from fix/blackbox-web-listen-flag into main 2026-10-21 15:58:36 -07:00
Frank 4009f118c1 fix(blackbox): the flag is --web.listen-address, repeatable, not --listen-address
Third boot failure: the exporter exited 100 ms in with 'unknown long
flag --listen-address' (proved against the pinned image, plus --help).
The bind semantics were right; the flag name was invented. --web.listen-address
is repeatable - one per address - so the dual gateway+LAN bind stands.
Proven again pre-deploy with the real argv: the run starts and dies
only on the deliberate /dev/null placeholder config.
2026-10-21 15:55:21 -07:00
dank 7de343c171 Merge pull request 'fix(blackbox): complete the unit - Image, Network=host, honest bind topology' (#71) from fix/blackbox-unit-generation into main 2026-10-21 15:52:30 -07:00
Frank 139a3cd4fa fix(blackbox): unit had no Image and no Network=host - quadlet refused to generate it
The deploy died at 'Unit blackbox-exporter.service not found': a
.container without Image= is not generated at all, and the header
argued for Network=host it never declared. Completes the unit (digest
pinned prom/blackbox-exporter, Network=host) and fixes the bind
topology the first cut got wrong:

- Prometheus is bridge-networked and the netavark hairpin gap bars it
  from its own host's LAN IP, so a LAN-only prober bind is unscrapable
  and a loopback-only bind was claimed to ride the gateway on faith.
  The prober now binds 9115 on the podman gateway (scrape path) AND
  the LAN (healthcheck/diagnostics); the listen_address line leaves
  blackbox.yml so config and Exec cannot disagree.
- comfyui probe target http://comfyui:8188 -> http://127.0.0.1:8188:
  bridge container names do not resolve in the host netns; the
  published 0.0.0.0:8188 answers on loopback there.
- New seam: every observability unit must declare Image= (red on the
  shipped file by name); copier test pinned module presence instead
  of the removed listen line. Docs updated to the real topology.
2026-10-21 15:41:37 -07:00
dank 5ffbf19fb2 Merge pull request 'fix(deploy): blackbox-exporter was gated out of its own deploy path' (#70) from fix/blackbox-service-gates into main 2026-10-21 15:34:49 -07:00
50 changed files with 198 additions and 82 deletions

No files matched your search

@@ -20,7 +20,7 @@ Wants=authentik-server.service
[Container] [Container]
ContainerName=authentik-outpost ContainerName=authentik-outpost
Image=ghcr.io/goauthentik/server:2026.8.3 Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
Exec=worker Exec=worker
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -14,7 +14,7 @@ After=network.target
[Container] [Container]
ContainerName=authentik-postgres ContainerName=authentik-postgres
Image=docker.io/library/postgres:16-alpine Image=docker.io/library/postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=POSTGRES_DB=authentik Environment=POSTGRES_DB=authentik
@@ -19,7 +19,7 @@ Wants=authentik-postgres.service
[Container] [Container]
ContainerName=authentik-server ContainerName=authentik-server
Image=ghcr.io/goauthentik/server:2026.8.3 Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
Exec=server Exec=server
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -16,7 +16,7 @@ Wants=authentik-server.service
[Container] [Container]
ContainerName=authentik-worker ContainerName=authentik-worker
Image=ghcr.io/goauthentik/server:2026.8.3 Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
Exec=worker Exec=worker
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -15,7 +15,7 @@ After=network.target
[Container] [Container]
ContainerName=gitea-postgres ContainerName=gitea-postgres
Network=gitea.network Network=gitea.network
Image=docker.io/library/postgres:16-alpine Image=docker.io/library/postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
EnvironmentFile=/home/dank/Containers/gitea/env EnvironmentFile=/home/dank/Containers/gitea/env
Environment=POSTGRES_USER=gitea Environment=POSTGRES_USER=gitea
Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD} Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD}
@@ -19,7 +19,7 @@ After=gitea-postgres.service network.target
[Container] [Container]
ContainerName=gitea-server ContainerName=gitea-server
Network=gitea.network Network=gitea.network
Image=docker.io/gitea/gitea:latest Image=docker.io/gitea/gitea:latest@sha256:30777c91280d973fa02be48b1ade735bd6859e8f6f7af3b4444d519437852504
EnvironmentFile=/home/dank/Containers/gitea/env EnvironmentFile=/home/dank/Containers/gitea/env
Environment=DATABASE_TYPE=postgres Environment=DATABASE_TYPE=postgres
Environment=DATABASE_HOST=gitea-postgres Environment=DATABASE_HOST=gitea-postgres
@@ -23,7 +23,7 @@ After=gluetun.service network.target
[Container] [Container]
ContainerName=marreta_browser ContainerName=marreta_browser
Network=gluetun.container Network=gluetun.container
Image=docker.io/lightpanda/browser:nightly Image=docker.io/lightpanda/browser:nightly@sha256:979c36ed869f1016b8dcf22939a0633c262f2c30e892a03a9f720eba2ff1931e
[Service] [Service]
TimeoutStartSec=300 TimeoutStartSec=300
+1 -1
View File
@@ -32,7 +32,7 @@ After=gluetun.service marreta-browser.service network.target
[Container] [Container]
ContainerName=marreta ContainerName=marreta
Network=gluetun.container Network=gluetun.container
Image=ghcr.io/manualdousuario/marreta:latest Image=ghcr.io/manualdousuario/marreta:latest@sha256:30f2a34567b79b66da794b4b9423751bbbcd07ceff600d9f6a2d219e7e439362
# Relocate off SearXNG's 8080 inside the shared netns. # Relocate off SearXNG's 8080 inside the shared netns.
Environment=NGINX_HTTP_PORT=8085 Environment=NGINX_HTTP_PORT=8085
Environment=APP_NAME=Marreta Environment=APP_NAME=Marreta
@@ -33,7 +33,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=alertmanager ContainerName=alertmanager
Image=docker.io/prom/alertmanager:latest Image=docker.io/prom/alertmanager:latest@sha256:e9733bafb1bdef9b00e25a21f8f99dc26a22224bf16641ad754d1649f4c3357a
Network=dankclaw_default Network=dankclaw_default
User=0:0 User=0:0
Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager
@@ -1,29 +1,50 @@
# Quadlet unit: blackbox exporter (rootless, boot-persistent). # Quadlet unit: blackbox exporter (rootless, host network, boot-persistent).
# #
# Install path on dankclaw: # Install path on dankclaw:
# ~/.config/containers/systemd/blackbox-exporter.container # ~/.config/containers/systemd/blackbox-exporter.container
# #
# WHY Network=host. The probes it runs are (a) the host's own LAN IP # WHY Network=host. The probes it runs are (a) the host's own LAN IP
# (192.168.0.143:<port>) and (b) container names on dankclaw_default (comfyui). # (192.168.0.143:8081 SearXNG, :3005 OpenWebUI — host-process/portmap binds a
# A bridge container cannot reach the host's LAN IP at an unpublished port # bridge container cannot reach; netavark DNATs published ports only, see
# (netavark DNATs published ports only — see Container Host Reachability in the # Container Host Reachability in the vault) and (b) ComfyUI's published port
# vault); a host-netns process reaches both. Prometheus (bridge) reaches THIS # via 127.0.0.1 (rootless --network=host shares the real host netns, so the
# container the same way it reaches the signal relay: the quadlet-published # 0.0.0.0:8188 publish answers on loopback). A bridge-networked prober could
# host.containers.internal address (169.254.1.2), mapped via HostAddr= on the # reach neither class reliably.
# prometheus unit.
# #
# The 9115 metrics port publishes on loopback only — it is scraped by the # WHY the listener binds 127.0.0.1 + LAN. A bridge container CANNOT dial
# prometheus container through the gateway, and never opened on the LAN. # its own host's LAN IP (netavark hairpin gap, measured 2026-10-21), but a
# bridge container dialing the podman gateway 169.254.1.2 lands on the host
# LOOPBACK — that is the scrape path, and it needs this exporter to be
# LISTENING ON LOOPBACK (proved: 200 from the prometheus container to
# http://169.254.1.2:9115/-/healthy with a loopback bind). The gateway
# address itself is NOT bindable from the host netns — it exists only
# inside each rootless netns; binding it dies "bind: cannot assign
# requested address" (live 2026-10-21). The LAN bind stays for diagnostics.
# No public DNS name, no guard chain: internal net only, per the exposure
# policy. The listen flag is --web.listen-address and it is REPEATABLE, one
# flag per bind; plain --listen-address does not exist and the exporter
# exits in 100 ms with "unknown long flag" (live 2026-10-21).
[Unit] [Unit]
Description=Blackbox exporter (HTTP probe prober) Description=Blackbox exporter (HTTP probe prober)
After=network-online.target After=network-online.target
[Container] [Container]
Image=docker.io/prom/blackbox-exporter@sha256:ea1c7aa3ef3f307cd03e85f34abb604478ccb90b567fc690da0cd9776da76b13
ContainerName=blackbox-exporter ContainerName=blackbox-exporter
Exec=/bin/blackbox_exporter --config.file=/etc/blackbox_exporter/config.yml Network=host
# The image's ENTRYPOINT is /bin/blackbox_exporter itself (proved live
# 2026-10-21: `podman run IMAGE sh` dies "unexpected sh"), so Exec= carries
# FLAGS ONLY — repeating the binary makes it a positional arg and kingpin
# exits "unexpected /bin/blackbox_exporter".
Exec=--config.file=/etc/blackbox_exporter/config.yml --web.listen-address=127.0.0.1:9115 --web.listen-address=192.168.0.143:9115
Volume=/opt/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro Volume=/opt/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
HealthCmd=/bin/sh -c "wget -q -O /dev/null http://127.0.0.1:9115/-/healthy || exit 1" # Plain exec form like every sibling unit. Shell form (`/bin/sh -c "…"`)
# is passed as one quoted token and quadlet then folds the following
# --health-interval/--health-retries (and the image name) into the command
# string — unit died twice that way, live 2026-10-21. wget only; this
# busybox variant has no curl, and host-network wget answers on loopback.
HealthCmd=wget -q -O /dev/null http://127.0.0.1:9115/-/healthy
HealthInterval=30s HealthInterval=30s
HealthRetries=3 HealthRetries=3
@@ -35,7 +35,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=cadvisor ContainerName=cadvisor
Image=gcr.io/cadvisor/cadvisor:v0.49.1 Image=gcr.io/cadvisor/cadvisor:v0.49.1@sha256:3cde6faf0791ebf7b41d6f8ae7145466fed712ea6f252c935294d2608b1af388
PodmanArgs=--pid=host --cgroupns=host PodmanArgs=--pid=host --cgroupns=host
PublishPort=127.0.0.1:8080:8080 PublishPort=127.0.0.1:8080:8080
PublishPort=192.168.0.143:8080:8080 PublishPort=192.168.0.143:8080:8080
@@ -34,7 +34,7 @@ Wants=network-online.target prometheus.service
[Container] [Container]
ContainerName=grafana ContainerName=grafana
Image=docker.io/grafana/grafana:latest Image=docker.io/grafana/grafana:latest@sha256:dbbc38ea21103cefb50396340cd88afec779c7e070b0a7bf5a75b4ce46651d3f
Network=dankclaw_default Network=dankclaw_default
User=0:0 User=0:0
PublishPort=127.0.0.1:3002:3000 PublishPort=127.0.0.1:3002:3000
@@ -33,7 +33,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=node-exporter ContainerName=node-exporter
Image=docker.io/prom/node-exporter:latest Image=docker.io/prom/node-exporter:latest@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0
Network=host Network=host
PodmanArgs=--pid=host PodmanArgs=--pid=host
Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.143:9100 Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.143:9100
@@ -43,13 +43,16 @@ Wants=network-online.target
[Container] [Container]
ContainerName=prometheus ContainerName=prometheus
Image=docker.io/prom/prometheus:latest Image=docker.io/prom/prometheus:latest@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
Network=dankclaw_default Network=dankclaw_default
# Name the podman gateway so scrape jobs can reach host-netns processes (the # Same-host scrape targets are dialed at host.containers.internal (the podman
# blackbox exporter listens on the host's loopback; a bridge container reaches # gateway): a bridge container cannot dial its own host's LAN IP (netavark
# it at this address, never at 127.0.0.1). Same mechanism the hermes Caddy # hairpin gap, measured 2026-10-21), and traffic to the gateway lands on host
# upstream learned the hard way (Container Host Reachability in the vault). # loopback / published ports (cadvisor/gpu jobs and the blackbox scrape all
HostAddr=host.containers.internal=169.254.1.2 # ride it). The name resolves natively — NO HostAddr key: quadlet 5.7.0 calls
# it unsupported and the WHOLE user quadlet batch then fails to generate,
# silently orphaning every other unit (live 2026-10-21: prometheus.service
# vanished while its container kept running as an orphan).
User=0:0 User=0:0
Exec=--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --storage.tsdb.retention.time=30d --web.enable-lifecycle Exec=--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --storage.tsdb.retention.time=30d --web.enable-lifecycle
PublishPort=127.0.0.1:9090:9090 PublishPort=127.0.0.1:9090:9090
@@ -27,7 +27,7 @@ After=network.target
[Container] [Container]
ContainerName=open-webui ContainerName=open-webui
Network=open-webui.network Network=open-webui.network
Image=ghcr.io/open-webui/open-webui:main Image=ghcr.io/open-webui/open-webui:main@sha256:4860ba005ae8f19f08d1e12b0868855b7b339ea0dad9927f196820fc0de04575
PublishPort=3005:8080 PublishPort=3005:8080
Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770 Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770
Environment=ANONYMIZED_TELEMETRY=False Environment=ANONYMIZED_TELEMETRY=False
+1 -1
View File
@@ -24,7 +24,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=pihole ContainerName=pihole
Image=docker.io/pihole/pihole:latest Image=docker.io/pihole/pihole:latest@sha256:d34134716fc32038b052a8674c7c6879e0ff21f0e6d8af345166fffa2c27c113
Network=slirp4netns Network=slirp4netns
PublishPort=0.0.0.0:53:53/udp PublishPort=0.0.0.0:53:53/udp
PublishPort=0.0.0.0:53:53/tcp PublishPort=0.0.0.0:53:53/tcp
+1 -1
View File
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
[Container] [Container]
ContainerName=gluetun ContainerName=gluetun
Network=searxng.network Network=searxng.network
Image=docker.io/qmcgaw/gluetun:latest Image=docker.io/qmcgaw/gluetun:latest@sha256:c2ee4191762fca6b6a3c3359a731198a95d9c9af4db32d466e223bf3a84e779b
AddCapability=NET_ADMIN AddCapability=NET_ADMIN
AddDevice=/dev/net/tun:/dev/net/tun AddDevice=/dev/net/tun:/dev/net/tun
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER} Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
ContainerName=searxng-valkey ContainerName=searxng-valkey
Network=searxng.network Network=searxng.network
NetworkAlias=valkey NetworkAlias=valkey
Image=docker.io/valkey/valkey:9-alpine Image=docker.io/valkey/valkey:9-alpine@sha256:48332870af354a799964c0012ae1194a0bf2bf894eb508f945810596dc2d8d11
Volume=/opt/searxng/valkey:/data Volume=/opt/searxng/valkey:/data
[Service] [Service]
+1 -1
View File
@@ -16,7 +16,7 @@ After=gluetun.service searxng-valkey.service network.target
[Container] [Container]
ContainerName=searxng ContainerName=searxng
Network=gluetun.container Network=gluetun.container
Image=ghcr.io/searxng/searxng:latest Image=ghcr.io/searxng/searxng:latest@sha256:5ed0323b5f5f8701264ef65e239a2bd83cb57098f2b971fa30bf79c06eeae271
Environment=SEARXNG_SECRET=${SEARXNG_SECRET} Environment=SEARXNG_SECRET=${SEARXNG_SECRET}
Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL} Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL}
Volume=/opt/searxng/config:/etc/searxng Volume=/opt/searxng/config:/etc/searxng
+1 -1
View File
@@ -29,7 +29,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=seerr ContainerName=seerr
Image=ghcr.io/seerr-team/seerr:v3.4.1 Image=ghcr.io/seerr-team/seerr:v3.4.1@sha256:f4768de5f616248d723e05891f3345a1402123775d03bf0890dbfedc0831bda1
Network=dankclaw_default Network=dankclaw_default
PublishPort=127.0.0.1:5055:5055 PublishPort=127.0.0.1:5055:5055
Volume=/home/dank/Containers/seerr/data:/app/config Volume=/home/dank/Containers/seerr/data:/app/config
@@ -43,7 +43,7 @@ After=network.target
[Container] [Container]
ContainerName=calibre-server ContainerName=calibre-server
Image=lscr.io/linuxserver/calibre:9.15.0 Image=lscr.io/linuxserver/calibre:9.15.0@sha256:59e4b0e036e62f228f8bcfae1ade42f47047719885fe454ddbad38e4597b987b
Network=pasta Network=pasta
EnvironmentFile=/home/dank/Containers/calibre-server/env EnvironmentFile=/home/dank/Containers/calibre-server/env
Environment=PUID=1000 Environment=PUID=1000
@@ -16,7 +16,7 @@ After=network.target
[Container] [Container]
ContainerName=calibre-web ContainerName=calibre-web
Image=lscr.io/linuxserver/calibre-web:0.6.27-ls402 Image=lscr.io/linuxserver/calibre-web:0.6.27-ls402@sha256:802729966faed5e32d671cd4f92ba6196e9e754a6c0f740ce1ee9acd86b10e55
Network=pasta Network=pasta
Environment=DOCKER_MODS=linuxserver/mods:universal-calibre Environment=DOCKER_MODS=linuxserver/mods:universal-calibre
Environment=LANG=en_US.UTF-8 Environment=LANG=en_US.UTF-8
@@ -21,7 +21,13 @@ After=network.target
[Container] [Container]
ContainerName=copyparty ContainerName=copyparty
Image=ghcr.io/9001/copyparty-ac:latest # Digest-pinned: upstream stopped publishing version tags after 1.19.15, so
# the pin rides :latest@digest (the tag in the reference is cosmetic; the
# digest is the pull). Pinned 2026-10-21 at the build whose label is
# 1.20.26 - closes GHSA-3c88-p299-7hvr (1.20.25) and GHSA-hg99-jqfp-rf76
# (1.20.26); the previously running build was the rolling :latest at
# 1.20.24 (issue #73 on command-center).
Image=ghcr.io/9001/copyparty-ac:latest@sha256:7f77459160a4bf34fdca79f2965dff4f0c1d7069a3d9cdfb3632acb041dd2f6a
Network=dankserver_appnet Network=dankserver_appnet
EnvironmentFile=/home/dank/Containers/copyparty/env EnvironmentFile=/home/dank/Containers/copyparty/env
Exec=-a ${COPAY_ACCOUNT}:${COPAY_PASSWORD} -v /w::r:ra,dank Exec=-a ${COPAY_ACCOUNT}:${COPAY_PASSWORD} -v /w::r:ra,dank
@@ -16,7 +16,7 @@ After=network.target
[Container] [Container]
ContainerName=jellyfin ContainerName=jellyfin
Image=ghcr.io/jellyfin/jellyfin:latest Image=ghcr.io/jellyfin/jellyfin:latest@sha256:c3efa69a22f2691fc88d6bb035e87a8560e395a6047f062fb381c61c3185e8f5
Network=dankserver_appnet Network=dankserver_appnet
Environment=DEBIAN_FRONTEND=noninteractive Environment=DEBIAN_FRONTEND=noninteractive
Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
@@ -18,7 +18,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=cadvisor ContainerName=cadvisor
Image=gcr.io/cadvisor/cadvisor:v0.49.1 Image=gcr.io/cadvisor/cadvisor:v0.49.1@sha256:3cde6faf0791ebf7b41d6f8ae7145466fed712ea6f252c935294d2608b1af388
PodmanArgs=--pid=host --cgroupns=host PodmanArgs=--pid=host --cgroupns=host
PublishPort=127.0.0.1:8080:8080 PublishPort=127.0.0.1:8080:8080
PublishPort=192.168.0.235:8080:8080 PublishPort=192.168.0.235:8080:8080
@@ -37,7 +37,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=node-exporter ContainerName=node-exporter
Image=docker.io/prom/node-exporter:latest Image=docker.io/prom/node-exporter:latest@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0
Network=host Network=host
PodmanArgs=--pid=host PodmanArgs=--pid=host
Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.235:9100 Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.235:9100
+1 -1
View File
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target Wants=network-online.target
[Container] [Container]
Image=lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329 Image=lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329@sha256:8f655bad7dcbc5398a78f70a5af12600b4ed3424f901566ed1eaa6710e9e2334
ContainerName=plex ContainerName=plex
Network=dankserver_appnet:alias=plex Network=dankserver_appnet:alias=plex
Environment=PUID=0 Environment=PUID=0
@@ -14,7 +14,7 @@ After=network.target
[Container] [Container]
ContainerName=romm-mariadb ContainerName=romm-mariadb
Image=docker.io/library/mariadb:11 Image=docker.io/library/mariadb:11@sha256:f16d7a344413e519dff518f27bdb221388f84b0d274c97c4fb5f2cb0dc1b34fa
Network=dankserver_appnet:alias=mariadb Network=dankserver_appnet:alias=mariadb
EnvironmentFile=/home/dank/Containers/romm/env EnvironmentFile=/home/dank/Containers/romm/env
Environment=MARIADB_DATABASE=romm Environment=MARIADB_DATABASE=romm
+1 -1
View File
@@ -12,7 +12,7 @@ After=network.target
[Container] [Container]
ContainerName=romm-redis ContainerName=romm-redis
Image=docker.io/library/redis:7-alpine Image=docker.io/library/redis:7-alpine@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499
Network=dankserver_appnet:alias=redis Network=dankserver_appnet:alias=redis
Volume=/home/dank/Containers/romm/redis:/data Volume=/home/dank/Containers/romm/redis:/data
HealthCmd=redis-cli ping HealthCmd=redis-cli ping
+1 -1
View File
@@ -18,7 +18,7 @@ After=romm-mariadb.service romm-redis.service network.target
[Container] [Container]
ContainerName=romm ContainerName=romm
Image=ghcr.io/rommapp/romm:latest Image=ghcr.io/rommapp/romm:latest@sha256:03295f95a6290e752641f9553ac4198b0e5b71b879d57f7b2371b98f608feb54
Network=dankserver_appnet Network=dankserver_appnet
EnvironmentFile=/home/dank/Containers/romm/env EnvironmentFile=/home/dank/Containers/romm/env
Environment=DB_HOST=mariadb Environment=DB_HOST=mariadb
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target Wants=network-online.target
[Container] [Container]
Image=lscr.io/linuxserver/tautulli:v2.18.1-ls245 Image=lscr.io/linuxserver/tautulli:v2.18.1-ls245@sha256:c66d3afa8f7a701a48dd1b1f57b22eca0483d895d670fe11ecff783f6a9bbd73
ContainerName=tautulli ContainerName=tautulli
Network=dankserver_appnet:alias=tautulli Network=dankserver_appnet:alias=tautulli
Environment=PUID=0 Environment=PUID=0
@@ -19,7 +19,7 @@ Description=Gluetun VPN tunnel (FrankBook-Pro podman machine)
[Container] [Container]
ContainerName=gluetun ContainerName=gluetun
Image=docker.io/qmcgaw/gluetun:latest Image=docker.io/qmcgaw/gluetun:latest@sha256:c2ee4191762fca6b6a3c3359a731198a95d9c9af4db32d466e223bf3a84e779b
AddCapability=NET_ADMIN AddCapability=NET_ADMIN
AddDevice=/dev/net/tun:/dev/net/tun AddDevice=/dev/net/tun:/dev/net/tun
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER} Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
@@ -15,7 +15,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=lidarr ContainerName=lidarr
Image=lscr.io/linuxserver/lidarr:latest Image=lscr.io/linuxserver/lidarr:latest@sha256:d8549ff9ea29b4015c32257360652c1000c31ea471a2b3f0835ae77292faf35b
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -24,7 +24,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=prowlarr ContainerName=prowlarr
Image=lscr.io/linuxserver/prowlarr:latest Image=lscr.io/linuxserver/prowlarr:latest@sha256:b3b00047ed20f6ce2b16780145cb6a0acb51c9e28ea6aa6f5dcd33c7034f1797
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -29,7 +29,7 @@ Network=gluetun.container
# Pinned to the last 4.x line: Readarr's retired qB client requires the # Pinned to the last 4.x line: Readarr's retired qB client requires the
# qB 4.x login response (HTTP 200, body "Ok."); qB 5.x answers 204 with an # qB 4.x login response (HTTP 200, body "Ok."); qB 5.x answers 204 with an
# empty body, which fails Readarr's auth check. All other clients accept 4.x. # empty body, which fails Readarr's auth check. All other clients accept 4.x.
Image=lscr.io/linuxserver/qbittorrent:4.6.7 Image=lscr.io/linuxserver/qbittorrent:4.6.7@sha256:55f15d44396315551f87294a176efae733b16e283f38980308e46073950257c6
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -15,7 +15,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=radarr ContainerName=radarr
Image=lscr.io/linuxserver/radarr:latest Image=lscr.io/linuxserver/radarr:latest@sha256:fc0ae3c50f8128b97e84381903ec8d6845c3a569641cb7117c124376cccb57c1
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -19,7 +19,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=readarr ContainerName=readarr
Image=lscr.io/linuxserver/readarr:amd64-1.0.980 Image=lscr.io/linuxserver/readarr:amd64-1.0.980@sha256:a95e82dbb0eb2bb43de4c150f1dab9dd821306c67ea0b418983a957e731f2b61
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -16,7 +16,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=sonarr ContainerName=sonarr
Image=lscr.io/linuxserver/sonarr:latest Image=lscr.io/linuxserver/sonarr:latest@sha256:889dcc58c6aea859033985894b519b2e6b1e55d92527ed123f9f52e42ae7c97b
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
+2
View File
@@ -617,6 +617,8 @@ deploy_observability() {
loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;; loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;;
node-exporter) wait_for_health node-exporter http://127.0.0.1:9100/metrics 60 ;; node-exporter) wait_for_health node-exporter http://127.0.0.1:9100/metrics 60 ;;
cadvisor) wait_for_health cadvisor http://127.0.0.1:8080/healthz 90 ;; cadvisor) wait_for_health cadvisor http://127.0.0.1:8080/healthz 90 ;;
# Loopback: the exporter binds 127.0.0.1 + LAN (the gateway address
# is not bindable from the host netns — see the unit header).
blackbox-exporter) wait_for_health blackbox-exporter http://127.0.0.1:9115/-/healthy 60 ;; blackbox-exporter) wait_for_health blackbox-exporter http://127.0.0.1:9115/-/healthy 60 ;;
esac esac
} }
@@ -130,10 +130,19 @@ Quickest unblock: `podman compose up -d --force-recreate <svc>`. Durable fix
## 4. Podman 5.x quadlet v1 generator quirks (verified live 2026-08-28) ## 4. Podman 5.x quadlet v1 generator quirks (verified live 2026-08-28)
- **Hard-fail keys** — the whole unit silently fails to generate - **Hard-fail keys abort the WHOLE BATCH, not one unit** (podman 5.7.0,
(`systemctl --user cat <unit>` shows "could not be found"): any live 2026-10-21): one unsupported key — e.g. `HostAddr=` — makes the
dash-prefixed `[Container]` option (`--shm-size=…` → `unsupported key … generator log `unsupported key … in group 'Container'` plus
in group 'Container'`); `Command=` is unsupported, use `Exec=`. `processing encountered some errors` and generate NOTHING for that
manager. The running containers keep going as orphans, so the failure
first appears on the next deploy as `Unit <x>.service not found` for a
service that is visibly up. Other offenders: any dash-prefixed
`[Container]` option (`--shm-size=…`), `Command=` (use `Exec=`).
The seam `tests/quadlet_image_pins.bats` fails the build on any
generator-rejected key in any host's units.
Also: an image whose ENTRYPOINT is the binary takes FLAGS ONLY in
`Exec=` — repeating the binary makes it a positional arg
(`unexpected /bin/blackbox_exporter`, live 2026-10-21).
- **HealthCmd space-splitting:** the v1 generator splits `HealthCmd` on - **HealthCmd space-splitting:** the v1 generator splits `HealthCmd` on
spaces into the healthcheck argv; a quoted probe spaces into the healthcheck argv; a quoted probe
`/bin/sh -c "pg_isready …"` lands as argv token `"pg_isready` → `/bin/sh -c "pg_isready …"` lands as argv token `"pg_isready` →
@@ -148,6 +157,16 @@ Quickest unblock: `podman compose up -d --force-recreate <svc>`. Durable fix
"unsupported key"`, `systemctl --user cat probe.service` (shows the "unsupported key"`, `systemctl --user cat probe.service` (shows the
generated ExecStart verbatim), then remove + reload. generated ExecStart verbatim), then remove + reload.
## 4a. Image pins (fleet law, 2026-10-21)
Every registry image in a quadlet is digest-pinned (`tag@sha256:…`);
locally built images (`localhost/*`, `franksdank/*`) are exempt — their
build is the pin. A rolling tag lets a rebuild move under a running
service with nobody approving it, and it makes a CVE audit ungradeable.
Gate: `tests/quadlet_image_pins.bats`. Where upstream publishes no version
tags (copyparty-ac stops at 1.19.15), pin `:latest@sha256:` and name the
version the config label carries in the comment beside it.
## 5. Rootless Quadlet log retrieval ## 5. Rootless Quadlet log retrieval
Step 0 — establish ownership: `systemctl --user status <name>.service` Step 0 — establish ownership: `systemctl --user status <name>.service`
@@ -21,7 +21,7 @@ committed.
| `shared/prometheus/rules/llm-fleet.yml` | alert rules + recording rules | | `shared/prometheus/rules/llm-fleet.yml` | alert rules + recording rules |
| `shared/grafana/provisioning/` | datasources + dashboards (provisioned tree) | | `shared/grafana/provisioning/` | datasources + dashboards (provisioned tree) |
| `shared/alloy/config.alloy` | the log pipeline: journal source → label promotion → Loki push | | `shared/alloy/config.alloy` | the log pipeline: journal source → label promotion → Loki push |
| `shared/blackbox/blackbox.yml` | prober modules (`http_2xx`) + the loopback bind | | `shared/blackbox/blackbox.yml` | prober modules (`http_2xx`); the listen bind lives on the unit's `Exec` |
Targets and datasources reference **container names** (`grafana:3000` Targets and datasources reference **container names** (`grafana:3000`
`loki:3100` — `shared/prometheus/prometheus.yml:52-61`), never `loki:3100` — `shared/prometheus/prometheus.yml:52-61`), never
@@ -163,6 +163,6 @@ verify it through the APIs.
- `scripts/gpu-exporter.py`, `scripts/gpu-exporter.service` (per-GPU host exporter) - `scripts/gpu-exporter.py`, `scripts/gpu-exporter.service` (per-GPU host exporter)
- `shared/prometheus/prometheus.yml`, `shared/prometheus/alertmanager.yml`, `shared/prometheus/rules/llm-fleet.yml` - `shared/prometheus/prometheus.yml`, `shared/prometheus/alertmanager.yml`, `shared/prometheus/rules/llm-fleet.yml`
- `shared/grafana/provisioning/`, `shared/loki/loki-config.yaml`, `shared/alloy/config.alloy` - `shared/grafana/provisioning/`, `shared/loki/loki-config.yaml`, `shared/alloy/config.alloy`
- `shared/blackbox/blackbox.yml` (prober modules + loopback bind) - `shared/blackbox/blackbox.yml` (prober modules; the listen bind is on the unit's `Exec`)
- `tests/observability_quadlet.bats` (boot persistence, no wildcard publish, probe addresses, delivery contract) - `tests/observability_quadlet.bats` (boot persistence, no wildcard publish, probe addresses, delivery contract)
- Host evidence 2026-10-18: `ss -ltnp`, `podman ps`, `systemctl --user is-enabled|is-active`, `curl` on `127.0.0.1:9090/api/v1/{targets,rules,alerts}`, `127.0.0.1:3100/loki/api/v1/labels`, `127.0.0.1:12345/metrics`, `127.0.0.1:3002/api/health`, `192.168.0.143:18093/health`, `journalctl --user -u signal-relay` - Host evidence 2026-10-18: `ss -ltnp`, `podman ps`, `systemctl --user is-enabled|is-active`, `curl` on `127.0.0.1:9090/api/v1/{targets,rules,alerts}`, `127.0.0.1:3100/loki/api/v1/labels`, `127.0.0.1:12345/metrics`, `127.0.0.1:3002/api/health`, `192.168.0.143:18093/health`, `journalctl --user -u signal-relay`
@@ -48,16 +48,18 @@ retired 2026-10-21; census re-run after each deploy):
| Job | Target | how | | Job | Target | how |
|---|---|---| |---|---|---|
| `gitea` | `192.168.0.143:3000/metrics` | metrics enabled in the quadlet (`GITEA__metrics__ENABLED=true`, `ENABLED_HOST=0.0.0.0`) — the page once said "stays off until `[metrics] ENABLED = true` in its own config"; the env form is what the deployer ships | | `gitea` | `192.168.0.143:3000/metrics` | metrics enabled in the quadlet (`GITEA__metrics__ENABLED=true`, `ENABLED_HOST=0.0.0.0`) — the page once said "stays off until `[metrics] ENABLED = true` in its own config"; the env form is what the deployer ships |
| `services` | `http://192.168.0.143:8081` (SearXNG), `:3005` (OpenWebUI), `http://comfyui:8188` | **blackbox exporter** probe, module `http_2xx` — the comfyui metrics job is retired, one owner per service | | `services` | `http://192.168.0.143:8081` (SearXNG), `:3005` (OpenWebUI), `http://127.0.0.1:8188` (ComfyUI) | **blackbox exporter** probe, module `http_2xx` — the comfyui metrics job is retired, one owner per service. The prober runs in the host netns, so comfyui is probed over loopback (its published port); bridge container names do not resolve there |
Scraping a web root is not a health check: an HTML page fails the scrape Scraping a web root is not a health check: an HTML page fails the scrape
protocol (`unsupported Content-Type`), which is what kept those targets red protocol (`unsupported Content-Type`), which is what kept those targets red
beside healthy services. The classification rule and the honesty law live in beside healthy services. The classification rule and the honesty law live in
[[Stale Signal Audit]]. The prober is `blackbox-exporter.container` (host [[Stale Signal Audit]]. The prober is `blackbox-exporter.container` (host
netns, config `shared/blackbox/blackbox.yml`, bound 127.0.0.1:9115; Prometheus netns, config `shared/blackbox/blackbox.yml`); it binds 9115 on loopback and
reaches it at the `host.containers.internal` gateway name). Plain targets stay the LAN, and Prometheus scrapes it at `host.containers.internal` — traffic to
`host:port`, never URLs — the probe job's URL targets are the documented that gateway lands on the host's loopback listener. The gateway address is
exception, relabeled onto the prober. not itself bindable from the host netns.
Plain targets stay `host:port`, never URLs — the probe job's URL targets are
the documented exception, relabeled onto the prober.
**`frank-zealot` reads up since 2026-10-18, and did not before.** llama.cpp only answers `/metrics` when started with `--metrics`, so `up{job="frank-zealot"}` was 0 across the whole retained history (the oldest sample, 2026-10-11, is already 0) while the service answered `/health` 200 the whole time; the flag and its opt-out are on [[frank-zealot]]. Read `lastError` before believing an outage: a target can read down beside a healthy service. **`frank-zealot` reads up since 2026-10-18, and did not before.** llama.cpp only answers `/metrics` when started with `--metrics`, so `up{job="frank-zealot"}` was 0 across the whole retained history (the oldest sample, 2026-10-11, is already 0) while the service answered `/health` 200 the whole time; the flag and its opt-out are on [[frank-zealot]]. Read `lastError` before believing an outage: a target can read down beside a healthy service.
+4 -5
View File
@@ -3,11 +3,10 @@
# 200 that fails the scrape protocol), which is why the old `services` and # 200 that fails the scrape protocol), which is why the old `services` and
# `comfyui` jobs sat permanently red on `unsupported Content-Type`. # `comfyui` jobs sat permanently red on `unsupported Content-Type`.
# See serverAppConfigs-docs/Architecture/Stale Signal Audit.md §1. # See serverAppConfigs-docs/Architecture/Stale Signal Audit.md §1.
# Loopback bind: the image's default 0.0.0.0:9115 would ride the LAN on this # The listen address is NOT set here: the quadlet unit's Exec passes
# Network=host container. Prometheus (bridge) still reaches it — its traffic # --listen-address (LAN bind — see the unit header for why loopback is
# arrives on the podman gateway interface, which is not loopback. # unreachable from the bridge-networked Prometheus). Keep this file to
network: # modules only, so the two sources cannot disagree.
listen_address: 127.0.0.1:9115
modules: modules:
http_2xx: http_2xx:
+8 -6
View File
@@ -64,11 +64,13 @@ scrape_configs:
# web root is NOT a health check — an HTML front page fails the scrape # web root is NOT a health check — an HTML front page fails the scrape
# protocol (`unsupported Content-Type`), which is exactly why the old # protocol (`unsupported Content-Type`), which is exactly why the old
# `services` and `comfyui` jobs were red forever beside healthy services # `services` and `comfyui` jobs were red forever beside healthy services
# (Stale Signal Audit §1). The prober is a host-netns container reached at # (Stale Signal Audit §1). The prober is a host-netns container listening on
# the podman gateway name (HostAddr= on the prometheus unit); it in turn # the podman gateway (the scrape address below - the netavark hairpin gap
# reaches both the host LAN IP and bridge container names. comfyui's UI probe # bars this container from its own host's LAN IP, cadvisor job note) and on
# lives here now — its own metrics-scrape job is retired, one owner per # the LAN; from that netns it reaches the host LAN IP, and published ports
# service. # via 127.0.0.1 - bridge container names do NOT resolve in the host netns,
# which is why comfyui's probe is a loopback URL. comfyui's own
# metrics-scrape job is retired, one owner per service.
- job_name: "services" - job_name: "services"
metrics_path: /probe metrics_path: /probe
params: params:
@@ -77,7 +79,7 @@ scrape_configs:
- targets: - targets:
- "http://192.168.0.143:8081" # SearXNG (via the Gluetun tunnel) - "http://192.168.0.143:8081" # SearXNG (via the Gluetun tunnel)
- "http://192.168.0.143:3005" # OpenWebUI - "http://192.168.0.143:3005" # OpenWebUI
- "http://comfyui:8188" # ComfyUI UI (gfx1151 generator) - "http://127.0.0.1:8188" # ComfyUI UI (published 0.0.0.0:8188; probed from the prober's host netns)
relabel_configs: relabel_configs:
- source_labels: [__address__] - source_labels: [__address__]
target_label: __param_target target_label: __param_target
+2 -1
View File
@@ -77,6 +77,7 @@ FRANKBOOK_UNITS=(
} }
@test "readarr keeps its pinned image" { @test "readarr keeps its pinned image" {
grep -q '^Image=lscr.io/linuxserver/readarr:amd64-1.0.980$' \ # 2026-10-21 fleet law: version tag AND digest (tests/quadlet_image_pins.bats)
grep -qE '^Image=lscr\.io/linuxserver/readarr:amd64-1\.0\.980@sha256:[0-9a-f]{64}$' \
"$REPO_ROOT/frankbook-pro/readarr/quadlet/readarr.container" || return 1 "$REPO_ROOT/frankbook-pro/readarr/quadlet/readarr.container" || return 1
} }
+13 -1
View File
@@ -669,6 +669,18 @@ assert not any('container_network' in e or 'container_fs_' in e for e in names),
[ -f "$sandbox/opt/grafana/provisioning/datasources/prometheus.yml" ] [ -f "$sandbox/opt/grafana/provisioning/datasources/prometheus.yml" ]
} }
@test "every observability unit declares an Image" {
# Quadlet refuses to GENERATE a service for a .container without Image=
# - the deployer then dies at `systemctl restart` with "Unit not found"
# and no diagnosis (blackbox-exporter, live 2026-10-21: merged with the
# copier, gates and health check wired, but no Image line).
local svc
for svc in "${UNITS[@]}"; do
grep -q '^Image=' "$UNIT_DIR/$svc.container" \
|| { echo "$svc.container has no Image= - quadlet will not generate it"; return 1; }
done
}
@test "changes under shared/blackbox/ plan the prober" { @test "changes under shared/blackbox/ plan the prober" {
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankclaw \ run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankclaw \
--paths shared/blackbox/blackbox.yml --paths shared/blackbox/blackbox.yml
@@ -716,7 +728,7 @@ assert not any('container_network' in e or 'container_fs_' in e for e in names),
copy_blackbox_config copy_blackbox_config
) )
[ -f "$sandbox/blackbox.yml" ] [ -f "$sandbox/blackbox.yml" ]
grep -q "listen_address: 127.0.0.1:9115" "$sandbox/blackbox.yml" grep -q '^ http_2xx:' "$sandbox/blackbox.yml"
} }
@test "the alloy copier refuses to install a config the engine rejects" { @test "the alloy copier refuses to install a config the engine rejects" {
+3 -2
View File
@@ -15,8 +15,9 @@ QDIR="$REPO_ROOT/dankclaw/open-webui/quadlet"
} }
@test "open-webui uses the CPU image, not :cuda (dankclaw is CPU-only)" { @test "open-webui uses the CPU image, not :cuda (dankclaw is CPU-only)" {
grep -qE '^Image=ghcr\.io/open-webui/open-webui:main$' "$QDIR/open-webui.container" \ # 2026-10-21 fleet law: tag AND digest (tests/quadlet_image_pins.bats)
|| { echo "open-webui.container must use the :main (CPU) image"; return 1; } grep -qE '^Image=ghcr\.io/open-webui/open-webui:main@sha256:[0-9a-f]{64}$' "$QDIR/open-webui.container" \
|| { echo "open-webui.container must use the :main (CPU) image, digest-pinned"; return 1; }
if grep -qE '^Image=.*:cuda' "$QDIR/open-webui.container"; then if grep -qE '^Image=.*:cuda' "$QDIR/open-webui.container"; then
echo "open-webui.container must not use the :cuda image on a CPU-only host" echo "open-webui.container must not use the :cuda image on a CPU-only host"
return 1 return 1
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bats
# Seam: fleet-wide quadlet laws learned the hard way.
# 1. Every registry image is digest-pinned. A rolling tag (":latest",
# ":9-alpine", ":nightly") lets a rebuild move under a running
# service at the next restart/reboot with nobody approving it, and it
# makes a CVE audit ungradeable (you cannot score a tag). Locally
# built images (localhost/*, franksdank/*) are exempt: their build
# IS the pin, and they never pull.
# 2. No unit carries a key the podman 5.7.0 quadlet generator rejects.
# The generator aborts the WHOLE batch on an unsupported key, so one
# bad file silently un-generates every other unit in that manager -
# live 2026-10-21: HostAddr= in prometheus.container orphaned
# prometheus.service while its container kept running, and the next
# deploy died on "Unit prometheus.service not found".
load helpers
@test "every registry Image= is digest-pinned across all three hosts" {
bad=""
while IFS= read -r unit; do
while IFS= read -r img; do
case "$img" in
localhost/*|franksdank/*) continue ;;
esac
case "$img" in
*@sha256:*) ;;
*) bad="$bad$unit: $img"$'\n' ;;
esac
done < <(sed -n 's/^Image=//p' "$unit")
done < <(find "$REPO_ROOT"/dankserver "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro \
-path '*/quadlet/*.container' -not -path '*.AGENTTRASH*')
[ -z "$bad" ] || { printf 'unpinned images:\n%s' "$bad" >&2; return 1; }
}
@test "no unit uses a key the 5.7.0 quadlet generator rejects (batch killer)" {
# HostAddr rejected by podman 5.7.0 (live 2026-10-21); Device= and
# Command= are the older v1 findings, guarded host-locally too.
# Keep this list current with every generator finding.
bad=""
while IFS= read -r unit; do
for key in HostAddr Device Command; do
grep -q "^${key}=" "$unit" && bad="$bad$unit: ${key}="$'\n'
done
done < <(find "$REPO_ROOT"/dankserver "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro \
-path '*/quadlet/*.container' -not -path '*.AGENTTRASH*')
[ -z "$bad" ] || { printf 'generator-rejected keys:\n%s' "$bad" >&2; return 1; }
}
+3 -2
View File
@@ -20,8 +20,9 @@ SEERR_UNIT="$REPO_ROOT/dankclaw/seerr/quadlet/seerr.container"
grep -q '^Restart=always$' "$SEERR_UNIT" grep -q '^Restart=always$' "$SEERR_UNIT"
} }
@test "seerr image is pinned to an exact version tag" { @test "seerr image is pinned to an exact version tag and digest" {
grep -Eq '^Image=ghcr\.io/seerr-team/seerr:v[0-9]+\.[0-9]+\.[0-9]+$' "$SEERR_UNIT" # 2026-10-21 fleet law: tag AND digest (tests/quadlet_image_pins.bats)
grep -Eq '^Image=ghcr\.io/seerr-team/seerr:v[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}$' "$SEERR_UNIT"
} }
@test "seerr publishes 5055 on loopback only (guard chain is the user path)" { @test "seerr publishes 5055 on loopback only (guard chain is the user path)" {