Compare commits

..
Author SHA1 Message Date
claude-bot 96f11cb0bf fix(ci): dispatch security-scan by cron name and deploy target
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/deployment/security-scan Pipeline was successful
Two separate bugs in one `when` block, only one of which is #582.

The cron gate has never matched. It asked for `PIPELINE == "security-scan"` on
`event: cron`, and crons cannot carry variables — `repo cron add` takes name,
branch, schedule and enabled, and nothing else. So the variable is undefined on
every cron run and the workflow is filtered out. The `daily-security-scan` cron
does fire, and pipeline 6877 shows what actually ran under it: the `e2e`
workflow alone, which opts into that cron by name. The Trivy scan and the
security-fix agent have not been running on schedule. This predates 3.18.0 and
would have survived any fix to it.

The manual gate is the #582 case. Woodpecker 3.18 stopped merging manual-run
variables into the environment the workflow-root filter evaluates against
(builder.go:102-111 builds it from metadata plus global envs; AdditionalEnvs is
passed to the compiler at line 223 but never into this map), so
`PIPELINE == "security-scan"` cannot be true there either.

Both now use selectors the root filter can actually see:

  cron       -> matched by the cron's NAME, the way alert-monitor and e2e
                already do it. First-class filter, no variable involved.
  deployment -> CI_PIPELINE_DEPLOY_TARGET, which comes from pipeline metadata
                (metadata/environment.go:74) and therefore reaches the root
                filter on 3.18.

Dispatch becomes `woodpecker-cli pipeline deploy k.renner/homelab <n>
security-scan` — a named deployment target rather than a magic variable that
exists only to pick which of fourteen files is allowed to run.

Pilot for the remaining thirteen. Nothing else is converted until this one is
confirmed working end to end.
2026-08-28 09:39:00 +02:00
claude-bot 7f71e6655f docs(monitoring): correct why the CNPG backup-age alert is absent
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The comment blamed #522 — Backup CRs deleted within a minute of completing, so
CNPG has no record to derive the metric from. That is not what is happening.
The CRs are retained: 31 completed backups for ak-pg, im-pg and pprl-pg, 19 for
tr-pg, 11 for wp-pg, 10 for ha-pg, the newest of each from this morning.

The metric still reads 0 everywhere. The actual cause is one level up: the
collector derives it from Cluster.status.lastSuccessfulBackup, and that field
is absent on every Cluster because they all back up with method: plugin and the
barman-cloud plugin path never writes it back. lastFailedBackup is populated on
three of them, which is what makes the absence look like "no backups" rather
than "no reporting".

The conclusion does not change — the alert still cannot be built on that metric
— but the stated blocker does, and #522 is not it. Whoever picks this up next
would have gone looking at CR retention and found nothing wrong.

Also drops the incident retellings and the dated "verified on" stamps from this
file's comments, which is the cleanup already applied elsewhere in the repo.
2026-08-28 09:01:03 +02:00
claude-bot 15ec3ce2f0 fix(monitoring): stop double-scraping two authentik outposts
Chasing a stale comment turned up a live duplicate. Since serviceMonitorSelector
was loosened to {}, Prometheus discovers the goauthentik.io operator's own
ServiceMonitors — which is why it was loosened. The hand-written copies for ldap
and paperless-gate, added back when the strict selector was dropping the
operator's, now scrape the same endpoints a second time.

Measured, not assumed: 10.244.4.113:9300 and 10.244.4.76:9300 were each
producing 40 samples per 10 minutes against 20 for every other outpost, and
ldap picked up a third target because the hand-written selector matched the
plain Service as well as the -metrics one. No duplicate-timestamp errors,
because the two pools scrape at different offsets — the cost is doubled
ingestion and a target list that lies about how many outposts there are.

Radius stays, and the comment now says why it is not the same case: the
operator's ServiceMonitor for it selects on managed-by=goauthentik.io together
with service-type=metrics, and no Service carries both — the operator's has the
first, the one defined here has the second. It is the only thing scraping
radius, so deleting it would have been a silent loss.

Also corrects the comments that claimed the release label is required for
discovery. It is not, for ServiceMonitors and PodMonitors: those two selectors
are empty. It very much is for Probes, PrometheusRules and ScrapeConfigs, whose
selectors still match on it, so probes-blackbox.yaml now says so explicitly —
otherwise the corrected comments elsewhere read as license to drop the label
there too.

Live selector state, for the record:
  serviceMonitorSelector {}   podMonitorSelector {}
  probeSelector / ruleSelector / scrapeConfigSelector  release=kube-prometheus-stack
2026-08-28 09:00:58 +02:00
k.renner 617e5c5650 Merge pull request 'fix(monitoring): alert on wear trajectory, not the wear level' (#689) from fix-wear-alert-design into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#689
2026-08-28 06:32:18 +00:00
claude-bot 2a7c6de1b2 fix(monitoring): alert on wear trajectory, not the wear level
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Deleting the wear warning outright went too far. Wear that is high AND still
climbing is exactly the thing worth knowing about; only the level on its own
is noise. This replaces the flat 80% threshold with the combination.

The rule now projects: at the write rate of the last seven days, when does the
drive reach 95%? Under six months, and above 80% already, it alerts. The
projection clears when writes slow, which the level never could.

The rate is self-calibrating. Each drive reports both lifetime bytes written
and its own wear percentage, so bytes-per-percent is derived from the drive
rather than from a rated-endurance table that would have to be kept in sync
with the hardware. That matters here because the fleet is three different
models with three different endurance ratings sharing one rule.

It also has to work that way. percentage_used is a whole-number percent and
advances roughly once every six weeks on these drives, so any rate taken from
it directly is either zero or a single step — far too coarse to project from,
and the reason a delta- or predict_linear-based rule would have been useless.
bytes_written moves on every scrape.

The >= 80 gate is numerical rather than editorial: bytes-per-percent divides
by an integer percentage, so on a lightly worn drive half a percent of
rounding swings the projection by tens of percent. Idle drives divide by a
zero rate, yield +Inf, and stay silent without needing a special case.

Measured against live Prometheus before committing: the expression returns
nothing today, and with the horizon widened it selects exactly the three
Crucial drives at 81/83/87%, projecting 7822, 6513 and 3544 days to 95%. High,
flat, and roughly a decade of headroom — which is why the old rule was firing
every fifteen minutes about nothing.

The 95% critical alert stays a flat threshold. Past rated endurance the drive
is outside what the manufacturer stands behind however fast it got there, and
that alert clears by replacing the drive.

Closes #686
2026-08-28 07:31:18 +02:00
claude-bot 45b0249d45 fix(monitoring): drop the wear warning that can never clear
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
NvmeDriveWearHigh has produced four identical issues since yesterday — #673,
#675, #682, #686 — and would have kept producing them every fifteen minutes.
The alert-monitor deduplicates against OPEN issues, so closing one just makes
room for the next.

That is not a bug in the pipeline. It is a bug in the rule I wrote yesterday:
wear only ever increases, so a `>= 80` threshold starts firing the day a drive
crosses it and never stops. It is a status dressed as an alert, and no
suppression mechanism fixes a rule that is permanently true by construction.

The second half is worse. The drives it fired on are the EPHEMERAL volumes,
worn deliberately — they hold /var precisely because their remaining endurance
does not matter there. The alert was reporting the design as a fault, three
times over, for drives at 81/83/87% that are doing exactly what they were put
there to do.

So the warning goes and the critical threshold stays. 95% is a decision — the
drive is outside what the manufacturer stands behind — and a decision is what
an alert is for. The wear figure itself lives on the Disk Health dashboard,
per drive and with the role attached, which is where a slowly-moving budget
belongs.

The comment left behind says all of this, because the obvious reaction to
"there is no wear warning" is to add one back.

Not done here, deliberately: the noise log (#326) would also have silenced
this, and it is the designed path for an alert that fires continuously without
being repo-fixable. It is the wrong tool here — the alert was fixable, and
suppressing a bad rule leaves the bad rule.

Closes #686
2026-08-28 07:24:48 +02:00
k.renner 2080260212 Merge pull request 'docs(authentik): trim the blueprint comments, keep every ordering rule' (#688) from trim-comments-authentik into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#688
2026-08-28 05:21:47 +00:00
claude-bot aac2599e5f docs(authentik): trim the blueprint comments, keep every ordering rule
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
559 -> 509 comment lines. A far smaller cut than the other files, and that is
the honest result: most of this file is per-entry rationale for a declarative
blueprint, where the comment is the only place the constraint is written down.

Everything load-bearing stays, and stays emphasised. Every ORDER MATTERS note
survives — Application before Provider, Application before Provider before
Outpost — because Application.provider is on_delete=PROTECT and getting the
order wrong leaves the blueprint failing to apply with no obvious cause. So do
both "git owns membership, add and remove people HERE not in the UI" notes,
since blueprint apply silently reverts UI edits to those groups. So does the
reason each gate exists at all: code-server runs --auth=none as root over
plaintext HA secrets, and shelly-manager has no authentication whatsoever.

Cut: the derivations. Why the dual-OAuth2 setup was collapsed into one
provider, the full W3C-spec account of credentials:'omit', a paragraph
reconstructing how the music-assistant gate failed. The instruction that came
out of that last one is kept and sharpened into "do not reintroduce a gate in
front of a PWA".

Two stale things went with it. The paperless proxy comment said the ingress
controller "only routes paperless.renner.uno to the outpost service" and named
ingress-nginx as that controller — there is no ingress-nginx left, Traefik is
the only IngressClass. And a leftover instruction to "delete via the Authentik
UI once everything's confirmed working" from a session long finished.

Verified beyond the usual: these comments live INSIDE the embedded blueprint
string, so a broken indent would render fine and only fail when Authentik
parses it. Extracted the rendered ConfigMap's k8s-outposts.yaml and parsed it
separately — valid, and still 74 entries.
2026-08-28 07:17:25 +02:00
k.renner 45ca838f22 Merge pull request 'docs(apps): trim the home-assistant manifest comments' (#687) from trim-comments-apps into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#687
2026-08-28 05:13:57 +00:00
claude-bot c90406cca7 docs(apps): trim otbr, trmnl, zigbee2mqtt — and correct a copy-pasted falsehood
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Comments only across otbr, trmnl/renderer, zigbee2mqtt-deployment and the
smartctl ServiceMonitor. All four still parse; monitoring-rules still renders.

Each keeps the instruction and loses the investigation. otbr stays disabled with
the condition for re-enabling it — verified partition merge, one OMR prefix, the
Nest routers visible — instead of the paragraph reconstructing how the split
partition was diagnosed. Its socat bridge keeps both dead ends, because they are
what stops someone simplifying it: forkpty needs a socat the image does not ship,
and a sidecar cannot supply the PTY because devpts is per-container. zigbee2mqtt
keeps "never run alongside ZHA, and never start without the restored coordinator
backup or you form a new network and drop every device", and loses the cutover
diary around it.

The ServiceMonitor correction is the useful part. Four monitors carry the claim
that kube-prometheus-stack's Prometheus "selects ServiceMonitors by this label;
without it the resource exists but is never discovered". The live resource has
serviceMonitorSelector {} and podMonitorSelector {} — empty, so every monitor is
picked up regardless of labels. Only ruleSelector matches on
release=kube-prometheus-stack, which is why the same sentence on the disk-wear
PrometheusRule is correct and this one is not.

Corrected here for the monitor I added yesterday, which is where I introduced it
by copying a neighbour. The other three carry the same wrong sentence and are
left for a pass that can check each rather than sed across them.
2026-08-28 06:59:04 +02:00
claude-bot 14d62f3aa1 docs(home-assistant): trim the mdns-relay and mosquitto comments
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
mdns-relay 122 -> 64 comment lines, mosquitto 131 -> 118. Comments only; the
diff filtered for non-comment lines is empty and apps/home-assistant still
renders 83 objects.

The mdns-relay header was mine, written yesterday, and was the clearest case
of the problem: a seventy-line essay with sections. What survives is what stops
someone undoing it — that the relay must stay unicast because Cilium runs in
tunnel mode without enable-multicast and drops re-emitted multicast, so an
avahi-style reflector cannot work; that python-zeroconf accepts unicast at all,
which is the non-obvious fact the design rests on; that it restores
announcements but not HA-initiated browsing; and that so-bindtodevice=net1 is
what keeps any sibling pod from injecting records into HA's zeroconf cache.

Cut: the problem statement, the Errno 113 symptom, the exposure analysis
weighing what the IoT VLAN could already forge, and a paragraph explaining that
the relay is not what makes HA's listener reachable in-cluster.

mosquitto keeps every warning and loses the reasoning around them. The one
worth naming is the retained-state note: a roll preserves connectivity but not
the retained store, and Zigbee2MQTT does not recover on its own. That is an
operational consequence someone needs before touching the rollout strategy, so
it stays — the paragraph explaining why maxSurge:1 is safe does not.
2026-08-28 06:55:34 +02:00
k.renner 85b8f0eba9 Merge pull request 'docs(infra): trim the cilium and traefik helm release comments' (#685) from trim-comments-helm into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#685
2026-08-28 04:53:37 +00:00
18 changed files with 248 additions and 391 deletions
+15 -3
View File
@@ -1,8 +1,20 @@
# Trivy scan of the IaC tree and every image referenced from ansible/, with an
# agent pass that opens a PR for CRITICAL/HIGH findings.
#
# Triggers:
# - cron : the named `daily-security-scan` cron, matched by NAME.
# Crons cannot carry variables — `woodpecker-cli repo cron add`
# has no --var — so a variable gate can never match a cron.
# - deployment : woodpecker-cli pipeline deploy k.renner/homelab <n> security-scan
# The target rides in pipeline metadata as
# CI_PIPELINE_DEPLOY_TARGET, which the workflow-root filter
# does see, unlike manual-run variables on Woodpecker 3.18
# (#582).
when:
- event: cron
evaluate: 'PIPELINE == "security-scan"'
- event: manual
evaluate: 'PIPELINE == "security-scan"'
cron: daily-security-scan
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "security-scan"'
steps:
- name: iac-scan
@@ -25,20 +25,13 @@ data:
attrs:
local: true
# Retired OIDC layer. Paperless's URL-level access is gated by the
# paperless-gate ProxyProvider (below); user identity is forwarded
# from the gate to paperless via the X-authentik-* request headers
# and consumed by paperless via PAPERLESS_ENABLE_HTTP_REMOTE_USER
# (see kubernetes/apps/paperless/paperless.yaml). One Authentik
# Application, one Provider — the dual-OAuth2-flow setup with a
# separate paperless OIDC client + django-allauth callback was
# structurally fine but added a second consent screen on first
# login for no real defense-in-depth gain.
# Retired OIDC layer. Access is gated by the paperless-gate
# ProxyProvider below, which forwards identity as X-authentik-* headers
# consumed via PAPERLESS_ENABLE_HTTP_REMOTE_USER.
#
# Order matters: delete the Application BEFORE the OAuth2Provider
# because Application.provider is on_delete=PROTECT, so the
# OAuth2Provider can't be removed while the Application still
# references it.
# ORDER MATTERS: delete the Application BEFORE the OAuth2Provider.
# Application.provider is on_delete=PROTECT, so the provider cannot be
# removed while an Application still references it.
- model: authentik_core.application
identifiers:
slug: paperless
@@ -49,22 +42,15 @@ data:
name: Paperless
state: absent
# Also clean up the very-early orphan `paperless` ProxyProvider
# left behind from this session's first attempts. It's not bound
# to anything any more, just leftover state in the DB.
# Orphan ProxyProvider, unbound — leftover DB state.
- model: authentik_providers_proxy.proxyprovider
identifiers:
name: paperless
state: absent
# Dedicated forward-auth gate for paperless. This is a SEPARATE
# outpost (its own Deployment + Service `ak-outpost-paperless-
# gate`) running in front of paperless so every request is
# authenticated and authorized by Authentik before it reaches
# paperless's own attack surface. The gate sits on top of, and
# separate from, the OIDC OAuth2Provider above (which is what
# paperless's django-allauth uses for SSO once a request makes
# it through the gate).
# Dedicated forward-auth gate for paperless: its own outpost
# Deployment + Service, so every request is authenticated before it
# reaches paperless.
- id: paperless-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
@@ -73,16 +59,13 @@ data:
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
# Proxy (not forward_domain) so the outpost itself terminates
# the request and forwards to paperless — moving the trust
# boundary off ingress-nginx and onto paperless-gate. ingress-
# nginx now only routes paperless.renner.uno to the outpost
# service; it never talks to paperless directly. The outpost
# is what populates X-Authentik-Username on the upstream
# request, and the NetworkPolicy in the paperless namespace
# only allows traffic to paperless from the gate outpost pod
# itself, so ingress-nginx (or any other in-cluster workload)
# cannot bypass the gate to inject headers.
# Proxy, NOT forward_domain: the outpost terminates the request
# itself, so the trust boundary sits here rather than at the ingress
# controller, which only routes to the outpost Service and never
# reaches paperless directly. The outpost populates
# X-Authentik-Username, and the paperless NetworkPolicy admits
# traffic only from this outpost — so nothing else in the cluster
# can bypass the gate and inject those headers.
mode: proxy
internal_host: http://paperless.paperless.svc.cluster.local
internal_host_ssl_validation: false
@@ -91,19 +74,12 @@ data:
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
# Pass paperless's entire static-asset tree through anonymously.
# Two reasons:
# 1. <link rel="manifest"> is fetched with `credentials: 'omit'`
# (W3C spec, no crossorigin attr) so the cookie is never sent
# — every page load and every SPA navigation turned into a
# 302 → auth.renner.uno round-trip with an orphan session JWT.
# Same root cause as the retired music-assistant-gate (see
# comment further below); paperless can't retire the gate so
# it carves out the offending paths instead.
# 2. Every CSS/JS/icon fetch otherwise validates the cookie at
# the outpost — pure overhead on assets that carry no
# authentication value. Django's STATIC tree is public by
# design; MEDIA (under /api/documents/*) stays gated.
# The static-asset tree passes anonymously, and must. A
# <link rel="manifest"> is fetched with `credentials: 'omit'` per
# the W3C spec, so the session cookie is never sent and every page
# load turns into a redirect loop through the login origin. Django's
# STATIC tree is public by design; MEDIA under /api/documents/*
# stays gated.
skip_path_regex: ^/static/.*
- model: authentik_core.application
@@ -130,18 +106,14 @@ data:
negate: false
failure_result: false
# Retired: the music-assistant-gate ProxyProvider, Application,
# and dedicated Outpost. The gate fundamentally conflicted with
# MA's PWA model — Chrome fetches manifest.webmanifest / sw.js /
# icons with `credentials: 'omit'` (per W3C spec, no crossorigin
# attribute on MA's <link rel="manifest">), and the cached
# service worker bypasses fresh navigations on PWA cold-start so
# the Authentik cookie never lands in the WebAPK's cookie jar.
# MA has its own auth (delegated to Home Assistant); the gate's
# marginal value didn't justify the recurring breakage.
# Order matters: Application before Provider before Outpost.
# Application.provider is on_delete=PROTECT, so the Provider
# can't be removed while the Application still references it.
# Retired: the music-assistant gate. A forward-auth gate cannot work in
# front of a PWA — the manifest and service worker are fetched with
# `credentials: 'omit'`, and a cached service worker bypasses fresh
# navigations, so the session cookie never reaches the installed app.
# Do not reintroduce one there; MA delegates auth to Home Assistant.
#
# ORDER MATTERS: Application before Provider before Outpost, because
# Application.provider is on_delete=PROTECT.
- model: authentik_core.application
identifiers:
slug: music-assistant-gate
@@ -157,18 +129,13 @@ data:
name: music-assistant-gate
state: absent
# Dedicated outpost: its ONLY job is to serve the in-cluster
# /outpost.goauthentik.io/auth/nginx endpoint that nginx
# auth_request hits for every request to paperless. The
# browser-facing legs of the OAuth flow (/start, /callback, the
# `/if/...` login UI, /application/o/authorize, /api, etc.) all
# need to happen on auth.renner.uno because that's the origin
# WebAuthn/FIDO/TOTP credentials are bound to. So we attach the
# ProxyProvider to BOTH outposts (see embedded outpost below) and
# set authentik_host_browser to auth.renner.uno here too — the
# nginx auth_request happens server-to-server, but if the
# dedicated outpost ever does emit a redirect URL, it points at
# the right host.
# Dedicated outpost, serving the in-cluster auth endpoint only.
#
# The browser-facing legs of the OAuth flow must happen on
# auth.renner.uno, because that is the origin WebAuthn credentials are
# bound to. Hence the ProxyProvider is attached to BOTH this outpost and
# the embedded one, and authentik_host_browser points at auth.renner.uno
# here too — so any redirect this outpost emits names the right host.
- model: authentik_outposts.outpost
identifiers:
name: paperless-gate
@@ -196,12 +163,7 @@ data:
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# Tear down the leftover paperless-proxy Application from an
# earlier experiment. The orphan `paperless` ProxyProvider that
# used to back paperless.app is left in place — it's PROTECTed
# by Application.provider as long as anything references it, and
# leftover orphans don't break anything. Delete via the Authentik
# UI once everything's confirmed working.
# Leftover Application from an earlier experiment.
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, paperless-proxy]]
@@ -217,15 +179,11 @@ data:
# ------------------------------------------------------------------
# vscode config-editor gate
#
# code-server (the HA /config editor sidecar) runs with --auth=none,
# as root, mounting the whole HA /config incl. plaintext secrets.yaml
# and .storage. This gate moves it behind Authentik with a passkey
# step-up, mirroring the paperless-gate proxy-outpost pattern above:
# the dedicated ak-outpost-vscode-gate terminates the request,
# enforces the session, and forwards to the code-server Service.
# Traefik only ever routes vscode.home.renner.uno to the outpost; the
# home-assistant CNP is tightened so ONLY the gate outpost may reach
# :8080 (see home-assistant-ns.yaml + vscode-gate-egress.yaml).
# code-server runs with --auth=none, as root, mounting the whole HA
# /config including plaintext secrets.yaml and .storage. This gate is
# the only thing in front of it: the outpost terminates the request and
# enforces a passkey step-up, Traefik routes only to the outpost, and
# the home-assistant CNP admits :8080 from the gate alone.
# ------------------------------------------------------------------
- id: vscode-config-editors-group
model: authentik_core.group
@@ -265,17 +223,14 @@ data:
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
# code-server is a service-worker app: <link rel="manifest"> and
# the service worker are fetched with credentials:'omit', which
# turned into a /authorize redirect loop and forced the RETIREMENT
# of the music-assistant gate (see comment above). Carve its
# static/manifest/SW tree out so those cookieless fetches pass
# through instead of 302-looping; the editor's actual content
# (the /config tree) is served over authenticated websocket/API
# paths, which stay gated.
# NOTE: the exact skip set is code-server-version-specific and
# MUST be confirmed post-deploy against the real redirect-loop
# behaviour (see the PR validation checklist).
# code-server is a service-worker app, so its manifest and SW are
# fetched with credentials:'omit' and 302-loop unless carved out.
# The editor's actual content rides authenticated websocket/API
# paths and stays gated.
#
# This skip set is version-specific: re-check it after a
# code-server bump, because a renamed asset path reintroduces the
# loop silently.
skip_path_regex: ^/(_static/|manifest\.json|.*\.webmanifest|.*serviceWorker\.js).*
- model: authentik_core.application
@@ -331,16 +286,11 @@ data:
# ------------------------------------------------------------------
# shelly-manager gate
#
# shelly-manager has no authentication of its own: anything that can
# reach :8080 can rewrite Shelly device config, push scripts, and read
# the stored per-device AdminPassword values. Until now the only
# control was reachability of shelly-manager.home.renner.uno.
#
# Same shape as the vscode-gate above: a dedicated outpost terminates
# the request, enforces a passkey step-up, and forwards to the
# shelly-manager Service. Traefik only ever routes the hostname to the
# outpost; the home-assistant CNP allows ONLY this outpost to reach
# :8080 (see home-assistant-ns.yaml + shelly-manager-gate-egress.yaml).
# shelly-manager has no authentication of its own: anything that reaches
# :8080 can rewrite device config, push scripts and read the stored
# per-device AdminPassword values. Same shape as the vscode gate — the
# outpost is the only control, Traefik routes only to it, and the CNP
# admits :8080 from this outpost alone.
# ------------------------------------------------------------------
- id: shelly-managers-group
model: authentik_core.group
@@ -1,50 +1,10 @@
---
# The goauthentik.io operator (inside authentik-worker) auto-creates a
# ServiceMonitor per outpost, but it does not stamp the
# `release: kube-prometheus-stack` label that this cluster's Prometheus
# requires on serviceMonitorSelector. Those operator-owned SMs are
# silently ignored. Declare standalone SMs here with the correct label
# so the outposts actually get scraped.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: authentik-outpost-ldap
namespace: authentik
labels:
release: kube-prometheus-stack
spec:
namespaceSelector:
matchNames:
- authentik
selector:
matchLabels:
goauthentik.io/outpost-name: ldap-outpost
goauthentik.io/outpost-type: ldap
endpoints:
- port: http-metrics
path: /metrics
interval: 30s
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: authentik-outpost-paperless-gate
namespace: authentik
labels:
release: kube-prometheus-stack
spec:
namespaceSelector:
matchNames:
- authentik
selector:
matchLabels:
goauthentik.io/outpost-name: paperless-gate
goauthentik.io/outpost-type: proxy
endpoints:
- port: http-metrics
path: /metrics
interval: 30s
---
# Only radius needs a hand-written ServiceMonitor. The goauthentik.io operator
# creates one per outpost and Prometheus discovers those directly, because
# serviceMonitorSelector here is empty (see monitoring/helmrelease-prometheus.yaml).
# Standalone copies for ldap and paperless-gate used to live here as well; they
# duplicated the operator's and were removed.
#
# Radius requires extra plumbing. The radius binary listens on :9300 just
# like ldap/proxy (hardcoded in authentik/lib/default.yml), but authentik's
# Python operator (`providers/radius/controllers/kubernetes.py`) omits the
@@ -53,6 +13,11 @@ spec:
# it, radius does not. Result: the operator-managed `ak-outpost-local-radius`
# Service exposes only UDP 1812. Add a sibling Service here that selects the
# same pod labels and exposes 9300/TCP, then a ServiceMonitor for it.
#
# The operator's own `ak-outpost-local-radius` ServiceMonitor cannot cover
# this: it selects on `app.kubernetes.io/managed-by: goauthentik.io` together
# with `goauthentik.io/service-type: metrics`, and no Service carries both —
# the operator's has the first, the one below has the second.
apiVersion: v1
kind: Service
metadata:
@@ -86,6 +51,8 @@ spec:
matchNames:
- authentik
selector:
# Pinned to the Service above. Without managed-by this would also match the
# operator's Service and scrape the same endpoint twice.
matchLabels:
app.kubernetes.io/managed-by: homelab
goauthentik.io/outpost-name: local-radius
@@ -1,80 +1,22 @@
---
# mDNS relay: gives Home Assistant sight of the IoT VLAN's mDNS announcements
# without putting a macvlan on the HA pod itself.
# mDNS relay: holds the multicast membership on the IoT VLAN through its own
# macvlan and forwards each datagram to Home Assistant as ordinary UNICAST UDP.
#
# THE PROBLEM
# It must stay unicast. An avahi-style reflector cannot work here — Cilium runs
# in tunnel mode without enable-multicast, so re-emitted multicast is dropped by
# the CNI. Unicast is carried, and python-zeroconf accepts it: its datagram
# handler checks size and duplicates but never the destination or source
# address. RFC 6762's QU bit makes this protocol-legal rather than a trick.
#
# HA's zeroconf listener joins both mDNS groups on eth0 — 224.0.0.251 and
# ff02::fb — and never hears anything on either.
# Multicast is link-local, so IoT-VLAN announcements do not reach the pod
# CIDR; and Cilium runs in tunnel mode (VXLAN) without `enable-multicast`, so
# it does not replicate multicast between pods either. Both halves of the path
# are dead, which is why an avahi-style reflector — receive on one interface,
# re-emit as multicast on the other — cannot work here: the re-emitted packets
# would be dropped by the CNI.
# This restores passive reception only — announcements, including the
# re-announce a device sends when its address changes. HA-initiated browsing
# still dies on eth0; that would need a querier on the VLAN side, which this
# deliberately is not.
#
# Consequence for HA: the ESPHome integration dials devices at the address
# cached in its config entry. When a lease rotates, every attempt fails with
# `[Errno 113] Connect call failed` and the device stays unreachable, because
# the mDNS announcement that would carry the new address never arrives. New
# devices are never discovered at all.
#
# THE MECHANISM
#
# This relay holds the multicast membership on the IoT VLAN through its own
# macvlan (mdns-relay-network-attachment.yaml) and forwards each datagram it
# receives to HA as ordinary UNICAST UDP. That is the one direction of travel
# the CNI will carry.
#
# It works because python-zeroconf does not filter on how a packet arrived:
# its datagram handler validates size, deduplicates, and parses, but performs
# no check on destination address, source address, or source port. A unicast
# datagram delivered to :5353 is processed exactly like a multicast one. (mDNS
# unicast responses are part of RFC 6762 — the QU bit — so this is within the
# protocol, not a trick.)
#
# WHAT THIS DOES AND DOES NOT RESTORE
#
# Restored: passive reception. RFC 6762 requires a responder to re-announce
# when its address changes, so a device that moves to a new lease announces
# the move, HA sees it, and the ESPHome integration updates the config entry.
# That is the failure this exists to fix. New devices announcing themselves
# are likewise seen.
#
# NOT restored: HA-initiated browsing. Queries HA sends leave on eth0 as
# multicast and die there, so HA cannot actively solicit a response. Anything
# depending on a query rather than an announcement stays broken. Making that
# work needs a querier on the VLAN side, which this relay deliberately is not.
#
# EXPOSURE
#
# The receive sockets carry so-bindtodevice=net1, so only datagrams that
# actually arrived on the IoT VLAN interface are delivered. Binding to net1's
# address instead is not possible — it is DHCP-leased and unknown at exec time
# — but binding to the device achieves the same confinement without needing to
# know the address, and needs no capability: SO_BINDTODEVICE succeeds as an
# unprivileged uid here.
#
# Without it socat would bind the wildcard, and since the namespace CNP grants
# every pod in home-assistant unrestricted access to every other pod on every
# port, any sibling pod could reach :5353 over the cluster network and have
# records forwarded into HA's zeroconf cache.
#
# What remains, and cannot be closed here: any host on the IoT VLAN can send
# this port a datagram. That is the same trust already extended to genuine
# mDNS from that segment, which any device there can equally forge — the relay
# widens who can reach HA's listener, not what can be claimed. macvlan traffic
# bypasses Cilium policy entirely, so the namespace CNP does not constrain
# this interface either way.
#
# Note that the relay is not what makes HA's zeroconf reachable from inside
# the cluster: HA binds :5353 on the pod address (v4) and the wildcard (v6),
# and the namespace CNP already permits any sibling pod to reach it directly.
# Confining the relay removes a second route to that listener; it does not
# remove the first.
#
# Nothing else is exposed: the containers run socat and only socat, with no
# second listener to reach.
# so-bindtodevice=net1 on both receive sockets is load-bearing: without it
# socat binds the wildcard, and the namespace CNP lets any sibling pod reach
# :5353 and inject records into HA's zeroconf cache. Binding net1's address
# instead is impossible — it is DHCP-leased and unknown at exec time.
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -6,32 +6,23 @@ metadata:
labels:
app.kubernetes.io/name: mosquitto
annotations:
# REQUIRED for credential rotation to take effect at all. The startup shim
# rebuilds /mosquitto/auth/passwd from these Secrets on every pod start, so
# a rotated Secret is inert until the pod restarts — without this the new
# password would sit unused until some unrelated restart, while the old one
# stayed valid indefinitely.
# REQUIRED for credential rotation to take effect. The startup shim rebuilds
# the password file only on pod start, so without this a rotated Secret sits
# inert while the old password stays valid indefinitely.
#
# Listed per-Secret rather than using the blanket `reloader.stakater.com/
# auto: "true"`, so that enrolling each remaining broker user in rotation is
# a visible, reviewable edit to this line rather than something that starts
# happening implicitly.
# Listed per-Secret rather than with the blanket auto annotation, so that
# enrolling another broker user is a visible edit to this line.
secret.reloader.stakater.com/reload: mosquitto-health-credentials
spec:
replicas: 1
strategy:
# No PVC anymore, so nothing to fence: the new broker can come up and pass
# its readiness probe before the old one is torn down. maxUnavailable:0 keeps
# a broker serving throughout an image bump / reschedule (clients on the old
# pod just reconnect to the new one); maxSurge:1 permits the brief two-pod
# overlap — two independent stateless brokers behind the LB, fine since there
# is no shared state.
# No PVC, so nothing to fence — the new broker can serve before the old one
# is torn down. Two stateless brokers overlapping behind the LB is fine.
#
# NOTE: connectivity survives the roll, but RETAINED STATE DOES NOT — the
# new pod starts with an empty retained store, so a roll is equivalent to a
# broker restart for every client that depends on retention. Zigbee2MQTT
# does not recover from that on its own; see mosquitto-config.yaml and
# gitea issue #381.
# Connectivity survives a roll but RETAINED STATE DOES NOT: the new pod
# starts with an empty retained store, so every roll is a broker restart
# for clients that depend on retention. Zigbee2MQTT does not recover from
# that on its own (#381).
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
@@ -44,14 +35,10 @@ spec:
labels:
app.kubernetes.io/name: mosquitto
spec:
# Runs on any node (no nodeSelector); the mosquitto-iot LB forwards
# IoT-VLAN traffic to the broker wherever it lands.
# Availability: home-critical priority (never preempted, reschedules
# first) + short fault tolerations so a node failure reschedules the
# broker in ~30s instead of the default ~5min. With persistence off there
# is no PVC to fence/detach/reattach, so the broker can (re)start anywhere
# instantly, no longer depends on Ceph being healthy, and rolls without a
# tear-down-first gap (see the RollingUpdate strategy above).
# Runs anywhere; the mosquitto-iot LB finds it. Short fault tolerations
# reschedule the broker in ~30s rather than the default ~5min, which is
# only possible because there is no PVC to fence — so it also does not
# depend on Ceph being healthy.
priorityClassName: home-critical
terminationGracePeriodSeconds: 10
tolerations:
@@ -6,12 +6,9 @@ metadata:
labels:
app.kubernetes.io/name: zigbee2mqtt
spec:
# CUTOVER DONE (2026-07-23): ZHA disabled, the coordinator-backup Secret is
# populated in OpenBao, and Z2M is now the live Zigbee stack — so replicas is
# 1. It shipped at 0 as a safety interlock (Z2M contends with ZHA for the
# coordinator's single TCP session, and starting without the restored backup
# would form a NEW network and drop every device); that interlock is spent
# once the migration is committed. To roll back, set 0 and re-enable ZHA.
# Z2M is the live Zigbee stack. Never run it alongside ZHA: they contend for
# the coordinator's single TCP session, and a Z2M start without the restored
# coordinator backup forms a NEW network and drops every paired device.
replicas: 1
# RWO PVC → single writer; never run two pods against the same /app/data.
strategy:
@@ -39,14 +36,10 @@ spec:
# network_key, the latter extracted from the OpenBao coordinator backup
# as a natural-byte-order int array.
- name: seed-data
# alpine + jq, built by .woodpecker/build-ci-images.yml from
# ci/images/alpine-jq/. Replaces `apk add --no-cache jq` on every pod
# start: tools belong in the image, not installed at runtime.
# Referenced by tag rather than digest — these are our own images.
# DATED tag, not :latest, matching the ci-ansible:2026-04-29 precedent
# in kubernetes/infrastructure/netbird-backup/cronjob.yaml. With
# :latest, rebuilding this base for some unrelated consumer would
# silently change what this init container runs on its next pod
# Our own alpine + jq image, built by build-ci-images.yml. DATED tag,
# never :latest — rebuilding this base for an unrelated consumer
# would otherwise silently change what this init container runs on
# its next pod
# restart, with no diff here to review — and this is the container that
# renders the Zigbee network key. Bumping the tag is a deliberate,
# reviewable act; the cost is remembering to bump it.
@@ -5,9 +5,8 @@ metadata:
namespace: monitoring
labels:
app.kubernetes.io/name: alloy
# kube-prometheus-stack's Prometheus resource selects ServiceMonitors
# by this label; without it the resource exists but is never
# discovered.
# Not required for discovery — serviceMonitorSelector is empty — but kept
# for consistency with the other monitors.
release: kube-prometheus-stack
spec:
selector:
@@ -1,7 +1,8 @@
# Scrape the CNPG Postgres instances (port 9187, built-in exporter).
# CNPG's own enablePodMonitor would generate PodMonitors without the
# `release: kube-prometheus-stack` label, which this Prometheus requires
# (podMonitorSelector), so we define them here instead.
# Defined here rather than via CNPG's own enablePodMonitor so the scrape
# config lives with the other monitors. The `release` label below is not
# required for discovery — podMonitorSelector is empty — but kept for
# consistency.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
@@ -5,9 +5,8 @@ metadata:
namespace: home-assistant
labels:
app.kubernetes.io/name: home-assistant
# kube-prometheus-stack's Prometheus resource selects ServiceMonitors
# by this label; without it the resource exists but is never
# discovered. Same trap the garage PrometheusRule documents.
# Not required for discovery — serviceMonitorSelector is empty — but kept
# for consistency with the other monitors.
release: kube-prometheus-stack
spec:
selector:
@@ -5,9 +5,8 @@ metadata:
namespace: monitoring
labels:
app.kubernetes.io/name: loki
# kube-prometheus-stack's Prometheus resource selects ServiceMonitors
# by this label; without it the resource exists but is never
# discovered.
# Not required for discovery — serviceMonitorSelector is empty — but kept
# for consistency with the other monitors.
release: kube-prometheus-stack
spec:
# Matches the `loki` (single-binary) and `loki-canary` services, but
@@ -1,5 +1,9 @@
# HTTP probes — fan out to blackbox-exporter. Probe targets mirror the
# legacy Ansible role's blackbox_http_targets list.
#
# The `release` label on every Probe here IS load-bearing: probeSelector still
# matches on it, unlike serviceMonitorSelector and podMonitorSelector, which
# are empty. Removing it silently stops the probe from being discovered.
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
@@ -11,25 +11,22 @@ spec:
groups:
- name: cnpg-backup.rules
rules:
# WHY THIS FILE EXISTS
# Seven Postgres clusters back up to Hetzner and NOTHING alerted if that
# stopped. The only related rule was HetznerBackupEndpointDown, which
# probes the S3 endpoint — it proves the bucket is reachable, not that
# anything was written to it. Same failure shape as the WordPress backup
# that failed nightly for three days against a bucket that was declared
# in tofu but never applied: the status surface looked fine because
# nothing checked the thing that mattered.
# The Postgres clusters back up to Hetzner. HetznerBackupEndpointDown
# probes that S3 endpoint, which proves the bucket is reachable and
# nothing more — these two rules check that something is actually being
# written to it.
- alert: CNPGWalArchiveBacklog
# `ready` counts WAL segments sitting in pg_wal/archive_status waiting
# to be archived. Steady state is 0 across every cluster (verified
# 2026-08-19). A sustained backlog means the archiver is failing:
# point-in-time recovery is silently broken from that moment, and
# pg_wal keeps growing until it fills the PVC and takes Postgres down.
# to be archived. Steady state is 0. A sustained backlog means the
# archiver is failing: point-in-time recovery is silently broken from
# that moment, and pg_wal keeps growing until it fills the PVC and
# takes Postgres down.
#
# This is the load-bearing alert of the two. It depends only on the
# instance-level collector, so unlike the backup-age metrics below it
# is unaffected by the missing Backup CRs (#522).
# This is the load-bearing alert of the two. It reads the
# instance-level collector directly, so unlike the backup-age metric
# discussed below it does not depend on Cluster status being
# populated.
#
# Threshold: >5 segments sustained for 30m. Brief non-zero values are
# normal under write bursts — a segment is 16 MiB, so 5 is roughly
@@ -63,16 +60,17 @@ spec:
#
# The natural expression is
# (time() - cnpg_collector_last_available_backup_timestamp) > 48*3600
# and it CANNOT be used today: that metric reads exactly 0 on every
# instance in the cluster (verified 2026-08-19 across gf-pg, ak-pg-1/2,
# pprl-pg-2/3, im-pg, wp-pg, ha-pg). CNPG has no record of any
# successful backup anywhere, because the Backup CRs it derives that
# from are being deleted within a minute of completing — see #522.
# and it cannot be used, because that metric reads exactly 0 on every
# instance. The collector derives it from `Cluster.status
# .lastSuccessfulBackup`, and that field is absent on every Cluster
# here: all of them back up with `method: plugin` (barman-cloud), and
# the plugin path does not write it back.
#
# Backups themselves DO work; that was verified independently by
# listing the bucket with barman-cloud-backup-list and restoring a WAL
# segment back out of S3. The gap is in the record, not the data.
# The backups themselves run and are retained — completed Backup CRs
# going back weeks exist for every scheduled cluster. The gap is in the
# status field the metric reads, not in the data.
#
# Adding the rule now would fire permanently for all seven clusters and
# be silenced within a day, which is worse than not having it. Add it as
# part of resolving #522, once the metric reflects reality.
# Adding the rule against that metric would fire permanently for every
# cluster, which is worse than not having it. Either the plugin starts
# populating the field, or the alert has to be built on the Backup CRs
# directly, which Prometheus does not see today.
@@ -12,21 +12,54 @@ spec:
- name: disk-wear.rules
rules:
# Wear percentages are self-reported: 100 means the rated endurance is
# spent, not that the drive stops. Planning thresholds, not predictions.
- alert: NvmeDriveWearHigh
# Long `for` because wear is monotonic and cannot flap.
expr: smartctl_device_percentage_used >= 80
for: 1h
# spent, not that the drive stops.
#
# Wear alone is not a signal. It only ever rises, so a plain threshold
# starts firing the day a drive crosses it and never stops — a status
# dressed as an alert (#673, #675, #682, #686 were all one drive set
# crossing 80%). What matters is wear that is high AND still climbing,
# so the alert below is a projection: at the write rate of the last
# week, when does this drive reach 95%?
#
# The rate is self-calibrating. Each drive reports both its lifetime
# bytes written and its own wear percentage, so bytes-per-percent comes
# from the drive itself and no rated-endurance table has to be kept in
# sync with the hardware. percentage_used is a whole-number percent and
# advances only every few weeks here, which is far too coarse to
# differentiate; bytes_written moves on every scrape.
- alert: NvmeDriveWearProjectedExhaustion
# The >= 80 gate is numerical, not editorial: bytes-per-percent is
# derived by dividing by an integer percentage, so on a lightly worn
# drive a half-percent rounding error swings the projection by tens
# of percent. Above 80 the same rounding is under one percent.
# Idle drives divide by a zero rate and yield +Inf, which is not < the
# threshold, so they stay silent on their own.
expr: >-
(
(95 - smartctl_device_percentage_used)
/
(
rate(smartctl_device_bytes_written[7d]) * 86400
* smartctl_device_percentage_used
/ smartctl_device_bytes_written
)
) < 180
and smartctl_device_percentage_used >= 80
# Long, because a projection off a 7d rate should not react to a day.
for: 6h
labels:
severity: warning
annotations:
summary: "NVMe {{ $labels.device }} on {{ $labels.instance }} is at {{ $value }}% rated wear"
summary: "NVMe {{ $labels.device }} on {{ $labels.instance }} projects to 95% wear in {{ $value | printf \"%.0f\" }} days"
description: >-
{{ $value }}% of rated write endurance used. Nothing is failing
yet. Urgency depends on what the drive carries — the Disk Health
dashboard shows the role. Losing an OSD costs a rebalance;
EPHEMERAL is /var and rebuilds, apart from the node's etcd member
data, which has to be removed and re-added.
The drive is past 80% of its rated endurance and the write rate
of the last week would spend the rest within six months. This is
a lead-time warning, not a fault: decide whether to replace the
drive or move what is writing to it. The Disk Health dashboard
shows which drives carry which role.
# Kept as a flat threshold on purpose: past rated endurance the drive
# runs outside what the manufacturer stands behind regardless of how
# fast it got there, and the alert clears by replacing it.
- alert: NvmeDriveWearCritical
expr: smartctl_device_percentage_used >= 95
for: 1h
@@ -5,9 +5,8 @@ metadata:
namespace: monitoring
labels:
app.kubernetes.io/name: smartctl-exporter
# kube-prometheus-stack's Prometheus resource selects ServiceMonitors
# by this label; without it the resource exists but is never
# discovered.
# Not required for discovery — serviceMonitorSelector is empty — but kept
# for consistency with the other monitors.
release: kube-prometheus-stack
spec:
selector:
@@ -24,8 +24,10 @@ spec:
enabled: true
defaults:
labels:
# Required for kube-prometheus-stack's Prometheus to pick up
# this ServiceMonitor (its selector matches release).
# Not required for discovery — serviceMonitorSelector is empty — but
# kept for consistency with the other monitors. The Probes in
# monitoring-rules/probes-blackbox.yaml do need it; probeSelector
# still matches on release.
release: kube-prometheus-stack
interval: 30s
# Helm DOES deep-merge this map with the chart's default (that is why
@@ -56,26 +56,21 @@ spec:
#
# By default kube-prometheus-stack sets these selectors to
# `matchLabels: {release: kube-prometheus-stack}`. ServiceMonitors
# created at RUNTIME by third-party operators carry that operator's own
# labels instead and can never be made to match — no static manifest or
# Kustomize patch can relabel an object the operator creates itself.
# created at RUNTIME by third-party operators — Rook, goauthentik.io —
# carry that operator's own labels instead and can never be made to
# match: no static manifest or Kustomize patch can relabel an object
# the operator creates itself. Left strict, those targets are dropped
# silently, with no failed resource and no gap visible anywhere except
# in the missing series.
#
# This was silently dropping 12 of 30 ServiceMonitors (2026-07-27):
# rook-ceph/rook-ceph-mgr, rook-ceph/rook-ceph-exporter (team: rook)
# authentik/ak-outpost-* x10 (goauthentik.io/...)
# Consequence: ZERO `ceph_*` series existed in Prometheus for all 79
# days of the cluster's life — the entire storage layer had no
# telemetry, no history, and any Ceph alerting rule would have had
# nothing to fire on. The Rook side is the reason this was found; the
# authentik outpost targets come along with it.
# Because these two are empty, the `release` label on this repo's own
# ServiceMonitors and PodMonitors is decorative. It is still set for
# consistency, and the comments there say so.
#
# This is the ServiceMonitor twin of the trap already documented for
# rules in monitoring-rules/prometheusrule-velero.yaml ("without this
# label the rule is created but never loaded").
#
# ruleSelector is deliberately NOT loosened: this repo's PrometheusRules
# all carry `release: kube-prometheus-stack` on purpose, and keeping
# that selector strict stops a stray third-party rule from firing here.
# ruleSelector, probeSelector and scrapeConfigSelector are deliberately
# NOT loosened: nothing outside this repo creates those, so keeping the
# selectors strict stops a stray third-party rule from firing here. For
# PrometheusRules and Probes the label IS required.
serviceMonitorSelectorNilUsesHelmValues: false
podMonitorSelectorNilUsesHelmValues: false
# Rewrites the in-cluster URL embedded in alert generatorURLs and
+17 -35
View File
@@ -5,25 +5,15 @@ metadata:
labels:
app.kubernetes.io/name: otbr
spec:
# Disabled 2026-07-11 (replicas: 0) after otbr broke Matter.
# DISABLED (replicas: 0). Running otbr broke Matter: its radio is RF-isolated
# enough from the Nest mesh that it forms a rival partition of the same
# network, and the two bridge only over Ethernet — fast enough for a ping,
# too slow for the fragmented CASE handshake, so devices stay discoverable
# while every commissioning attempt times out.
#
# otbr is seeded with the Nest network's dataset so it should join the
# existing partition as an additive co-BR. In practice it does not merge
# cleanly: its SLZB-06 radio is RF-isolated enough from the Nest mesh that
# otbr elects itself leader of a rival partition (or the network re-addresses
# under a new OMR prefix, observed fd5f -> fdd5). The two same-network
# partitions then bridge only over the Ethernet backbone, which carries a
# ping but is too slow for the large fragmented CASE handshake -- every
# Matter device stays mDNS-discoverable while CASE times out. Removing otbr
# re-merges the mesh onto the Nest hubs and Matter recovers.
#
# This is NOT a diagnosis of a specific config bug: the DHCP/sbr routing
# changes (19a4482, df61407) were investigated and matter-server's IPv6 path
# to the mesh works (reaches devices, ping ok); sbr's v6 table 100 is an
# inert orphan and otbr's eth0 default is outranked by net1's pref-high RA
# default. The instability is at the Thread partition layer. Re-enable only
# after the co-BR integration is verified to MERGE (one partition, one OMR,
# otbr's router table shows the Nest routers) instead of split.
# The instability is at the Thread partition layer, not in the config below.
# Re-enable only once co-BR integration is verified to MERGE: one partition,
# one OMR prefix, and the Nest routers visible in otbr's router table.
replicas: 0
strategy:
type: Recreate
@@ -46,25 +36,17 @@ spec:
# (slzb-thread.renner.uno). Dropping the workload-class=pinned
# toleration lets the edge taint keep it off the tiny edge box.
containers:
# The Thread RCP lives on a SLZB-06 over Ethernet at
# slzb-thread.renner.uno:6638 (Serial-over-IP). otbr-agent's
# spinel transport supports `spinel+hdlc+forkpty://<exec>?<args>`
# which forks an exec whose stdio becomes the spinel-HDLC
# transport, but the upstream openthread/otbr image does NOT
# ship socat (or any TCP-stream tool), so that URL form ends in
# silent ENOENT and otbr-agent dies right after logging the
# radio URL.
# The Thread RCP is a network-attached radio (Serial-over-IP), so the
# spinel transport needs a TCP-to-PTY bridge. Two obvious approaches
# do not work and should not be retried:
#
# A sidecar socat won't work either: each container has its own
# devpts namespace, so a PTY (e.g. /dev/pts/0) created in a
# sibling container is invisible from this container — a shared
# emptyDir symlink to /dev/pts/0 is dangling.
# - `spinel+hdlc+forkpty://` needs socat, which the upstream image
# does not ship; the URL fails with a silent ENOENT and otbr-agent
# dies right after logging it.
# - A sidecar cannot provide it either: devpts is per-container, so
# a PTY created next door is invisible here and the symlink dangles.
#
# The bridge has to live in the otbr container's own namespace.
# Install socat at startup via apt (Ubuntu 18.04 base, no egress
# restrictions on this namespace), run it in the background as
# the spinel-HDLC TCP↔PTY bridge, then exec the upstream
# entrypoint with RADIO_URL pointing at the PTY symlink.
# Hence socat is installed at startup and run inside this container.
- name: otbr
image: openthread/otbr:latest
command:
+8 -13
View File
@@ -1,20 +1,15 @@
# 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. Our HA is a plain
# container with no Supervisor, so it runs standalone here instead —
# viable because the app prefers HOME_ASSISTANT_URL/ACCESS_TOKEN env
# vars over /data/options.json and authenticates with an ordinary
# long-lived token. SUPERVISOR_TOKEN is deliberately NOT set: it only
# flips an internal isAddOn flag, and setting it would switch the app
# to add-on behaviour (fixed port, /data assumptions).
# 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, which is the non-obvious part: with the
# byos-hanami webhook in `uri` delivery mode the renderer does NOT
# push image bytes. It POSTs a URL pointing at its own /output/<file>
# endpoint, and TERMINUS FETCHES THE IMAGE BACK from it. That is why
# both live in this namespace — the pull is intra-namespace and is
# already permitted by the default-deny-ingress rule.
# 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: