Files
home-network-k3s-impl/catalog/seerr.yaml
T
jakman85andClaude Opus 5 c8c1d2935d
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
feat(dns): per-environment BASE_DOMAIN — prod moves to its own subdomain
INCIDENT FIX. Prod's external-dns DELETED EVERY ONE OF DEV'S PUBLIC DNS RECORDS. Both
clusters ran policy: sync against the SAME Cloudflare zone with the SAME implicit
txtOwnerId (`default`), so prod believed it owned dev's records, found no matching sources,
and removed them plus their TXT registry entries. Dev recovered ~30s after prod was scaled
to 0; verified restored against Cloudflare directly (my first check went through a pfSense
override and falsely showed healthy — local resolvers are not evidence about public DNS).

THE MISS WAS MINE, and it was a failure of imagination in how I briefed every agent. I put
"never touch dev" in brief after brief and enforced it — separate kubeconfigs, verified
--limit, sha-checked dev's kubeconfig before and after. Every one of those guards held. NONE
of them covered SHARED EXTERNAL RESOURCES. Prod reconciling its OWN correctly-authored
manifests reached across a third party and destroyed dev's state. Cluster-to-cluster
isolation was verified; the dependency that mattered was cluster-to-Cloudflare.

Operator's architecture: dev -> *.dev.home.keithjackson.dev, prod ->
*.prod.home.keithjackson.dev, apex CNAME <svc>.home -> <svc>.prod.home.

PHASE 1 ONLY (this commit): prod moves to its own subdomain. DEV IS NOT MIGRATED and its
effective hostname stays home.keithjackson.dev, so dev renders byte-identically and nothing
live moves. That alone ends the collision and unblocks prod. Phase 2 (dev -> dev.home. +
apex CNAMEs) is deferred to when the operator is present: it relocates Keycloak's issuer and
12 clients' redirect URIs, forces a rolling k3s restart and kubectl SSO re-issue for every
user, and moves gitea.home. — which is BOTH the GitOps source and the current push target.

BASE_DOMAIN WAS A DEAD KNOB — it existed in cluster-vars but the status report flagged it as
referenced by nothing, because hostnames were hardcoded throughout. Now real: 141 refs.
Searched the AUTHORITATIVE reconciled set by resolving each Flux spec.path transitively via
conformance/lib/status.py — the same resolver the L0 gate uses, so "what Flux reconciles"
has one definition. 185 occurrences across 47 files in 154 reconciled files. Tokenized:
Gateway listeners, the wildcard Certificate, coredns-custom, external-dns, Keycloak
instance/realm (26 redirect URIs), oauth2-proxy, Grafana OIDC, all HTTPRoutes, Homepage's 15
links, 30 runbook_urls, unifi cloud-init, render.py's ZONE constant (which drove
--oidc-issuer-url/--cookie-domain/--whitelist-domain on every forward-auth sidecar) and 11
catalog/ ServiceDefinitions re-rendered canonically. Left alone: 43 comment/prose
occurrences and non-reconciled paths.

TWO THAT CANNOT BE TOKENIZED, flagged not forced:
  - headlamp's OIDC URLs live inside a SOPS-encrypted Secret; re-encrypting rewrites the MAC
    over every value and the OFFLINE harness cannot verify it (kustomize does not decrypt),
    so an error would break live-dev SSO with no pre-flight proof. Prod needs its own copy
    anyway — client secrets are realm-scoped.
  - kube-apiserver --oidc-issuer-url is a k3s SERVER FLAG, not a manifest, so Flux can never
    reach it. Done on the inventory seam instead (k3s_base_domain -> k3s_oidc_issuer_url,
    overridden in group_vars/k3s_prod). Verified per host: dev keeps the default, prod gets
    prod.home.

EXTERNAL-DNS OWNER, the actual incident cause: txtOwnerId is now explicit per environment —
dev renders exactly `default` (the chart default it was already using, so its existing
records stay recognised) and prod gets `prod`. The BASE default is a NON-COLLIDING SENTINEL,
deliberately not `default`, so a future environment that forgets the knob owns nothing and
can delete nothing rather than silently inheriting dev's identity.

I OVERRODE ONE AGENT DECISION against our own prior finding: it narrowed prod's
domainFilters to prod.home.keithjackson.dev as defence in depth. FINDING F-DNS1
(DECISIONS-LOG 2026-08-17) already proved a subdomain filter DROPS THE PARENT ZONE —
ExternalDNS generates the record then skips it with "no hosted zone matching record DNS
Name". Prod would have published NOTHING. Set to the ZONE (keithjackson.dev) like dev.
Isolation comes from the subdomain split plus txtOwnerId, which is what the incident
actually needed and what a domain filter cannot provide.

DEV PROVEN UNCHANGED: 11 of 12 substituting paths byte-identical; the 12th differs by
exactly one line, `+ txtOwnerId: default` — the value dev was already using implicitly.
POSITIVE CONTROLS confirm the harness is not blind: perturbing BASE_DOMAIN moves 10 of 12
hashes (the 2 that don't provably contain no hostname), perturbing the owner id moves
exactly 1. Prod's rendered output has ZERO home.keithjackson.dev references outside prod.,
and its cert renders *.prod.home.keithjackson.dev — no overlap with dev's.

L0 substitution assertions 106 -> 254; BASE_DOMAIN is no longer flagged dead. Gate PASS.

PROD REMAINS SUSPENDED (platform + 10 downstream tiers) pending review; nothing applied.

Serves: R-ENV-02, R-DNS-01, R-DNS-02, R-DNS-03, R-TLS-01.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-04 22:43:54 -05:00

79 lines
5.0 KiB
YAML

# ServiceDefinition: seerr
# Source of truth: spec docs/13-service-catalog.md row 5; docs/15-identity-and-access.md;
# docs/03-networking-isolation-and-egress.md (explicit allow rules).
# public | egress internet | config on Longhorn | auth: native (Plex accounts) |
# seerr -> sonarr:8989, seerr -> radarr:7878, seerr -> plex:32400.
#
# Seerr is the media request UI (Overseerr's maintained successor): users request titles, it
# hands them to the arr stack and reflects library state from Plex. It is publicly exposed (edge port-forward + public
# DNS, docs/09) and authenticates with Plex's own account system — a DOCUMENTED SSO exception
# (docs/13/15; R-IDP-02): NOT Keycloak, so no oidc/forward-auth and no auth.groups. The three
# east-west hops below are EXPLICIT allows on top of default-deny (docs/03; R-NET-01/02);
# everything else (except DNS) is denied. Validates against schemas/servicedefinition.schema.json.
#
# Image: Seerr (ghcr.io/seerr-team/seerr) — the maintained successor into which the original
# Overseerr (sctx/overseerr, now unmaintained) and Jellyseerr (fallenbagel/jellyseerr) merged;
# the Plex-native request UI on port 5055 (docs/13 row 5 names "seerr" generically). Pinned to a
# specific stable tag (v3.4.1) per the arr-stack convention (sonarr/radarr/qbittorrent/gitea all
# pin explicit tags; R-UPD-01); update.range is the advisory bound the ImagePolicy will enforce
# once the Flux image controllers are installed. Config lives under /app/config (upstream default).
#
# No `bulk` volume: seerr keeps only its own config PVC (settings + request/cache DB) on the
# longhorn tier. It stores no media and needs no bulk mount, so OQ-2 (unresolved bulk backing)
# does not apply here.
name: seerr
kind: container
source:
image: ghcr.io/seerr-team/seerr:v3.4.1 # Seerr: maintained Overseerr/Jellyseerr successor, Plex-native request UI (docs/13 row 5); served on 5055
# The ghcr.io/seerr-team/seerr image runs as the non-root user `node:node` (UID 1000 / GID 1000 —
# the standard node base-image user; confirmed from the image config's User field). On boot the app
# creates /app/config/logs and writes its settings/DB under the mounted /app/config PVC. A fresh
# Longhorn PVC is root:root, so UID 1000 cannot mkdir there -> the pod CrashLoopBackOffs with
# "EACCES: permission denied, mkdir '/app/config/logs/'". (The old sctx/overseerr image ran as root
# and never hit this.) fsGroup=1000 makes the kubelet group-own the mounted PVC to GID 1000 and add
# it as a supplemental group so the process can write — Longhorn's CSIDriver
# fsGroupPolicy: ReadWriteOnceWithFSType applies fsGroup to this RWO filesystem volume. runAsUser/
# runAsGroup pin the image's own identity, runAsNonRoot asserts it (same pattern as semaphore; R-STATE-01/02).
podSecurityContext:
runAsUser: 1000
runAsGroup: 1000
runAsNonRoot: true
fsGroup: 1000
exposure: public # edge port-forward + public DNS (docs/09; R-DNS/R-NET)
egress: internet # normal WAN egress (docs/03; R-NET-05) — TMDB/Plex.tv metadata over the internet
storage:
- name: config
size: 5Gi
class: longhorn # settings + request/cache DB: replicated app state on the 3-replica tier (docs/04; R-STATE-01/02)
mountPath: /app/config # Seerr/Overseerr/Jellyseerr keep config + DB under /app/config (docs.seerr.dev); /data/<name> default would not persist it
dependencies:
- to: sonarr # seerr -> sonarr: submit TV requests (docs/03 allow rules; docs/13 graph; R-NET-02)
port: 8989
protocol: tcp
- to: radarr # seerr -> radarr: submit movie requests (docs/03 allow rules; docs/13 graph; R-NET-02)
port: 7878
protocol: tcp
- to: plex # seerr -> plex: read library / watch state (docs/03 allow rules; docs/13 graph; R-NET-02)
port: 32400
protocol: tcp
ingressRoutes:
- host: seerr.${BASE_DOMAIN}
port: 5055 # Overseerr web UI + API; wildcard TLS terminates at the shared Gateway (docs/08/09)
auth:
mode: native # Plex account auth — documented exception, NOT Keycloak (docs/13/15; R-IDP-02)
update:
channel: stable # R-UPD-01 (never pre-release)
range: ">=3.4.0" # acceptable versions (advisory; image-automation off during onboarding) — Seerr 3.x line
cadence: "0 4 * * 0" # weekly stable check/apply
autoRollbackOnFailedHealth: true # R-UPD-03
observability:
metrics: true
slis:
- up # R-OBS-01: service-down alert
backup:
# seerr's config is on the longhorn (replicated) tier, so the L0 Kyverno backup policy
# (servicedefinition-backup; R-STATE-03) forbids `reacquirable` here — it must reference an
# off-cluster schedule. The request/settings DB is cheap to rebuild but still lives on the
# irreplaceable tier, so it is backed up off-cluster like the other arr-stack config volumes.
policy: daily-offcluster # off-cluster backup of the longhorn config volume (docs/04; R-STATE-03)