Files
home-network-k3s/docs/13-service-catalog.md
T
jakman85andClaude Opus 5.5 28d0e0a0f2 ADR-0016 (Proposed): public ingress via Cloudflare Tunnel
Record the operator's 2026-11-08 decision to move public HTTP(S) ingress
from edge port-forwarding of 80/443 to an outbound-only Cloudflare Tunnel
(in-cluster cloudflared, proxied CNAMEs via ExternalDNS). Motivation: the
GFiber router cannot hairpin NAT and advertises Google IPv6 DNS via RA, so
LAN clients bypass Pi-hole split horizon and fail.

Docs 00/01/03/07/08/09/13/14, GLOSSARY and definition-of-done brought into
line. R-DNS-01..03 and R-TLS-01..03 wording unchanged. New requirements:
R-DNS-04 (no 80/443 forward, resolver-independent LAN reachability),
R-DNS-05 (only `public` services at the tunnel; 404 catch-all; no public
record for `internal`), R-SEC-04 (Kubernetes API never publicly reachable).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:38:16 -06:00

11 KiB

13 — Service Catalog & the ServiceDefinition

Serves: R-GITOPS-02, ties together R-NET-, R-STATE-, R-TLS-, R-DNS-, R-OBS-, R-UPD-. The catalog is the operator-facing onboarding surface.

The ServiceDefinition abstraction

Onboarding a service is adding/editing one ServiceDefinition under catalog/ in the implementation repo. It captures intent; the platform compiles it into concrete resources (Deployment or KubeVirt VM, PVCs, CiliumNetworkPolicies, egress routing, HTTPRoute, Certificate, DNS record, ServiceMonitor + dashboards + alerts, and update policy). Operators declare what; platform provides the how. This keeps every requirement wired from a single reviewable object.

Fields (canonical schema — implementers render manifests from these)

name: <string>                     # DNS label → <name>.home.keithjackson.dev
kind: container | vm               # PaaS (default) or IaaS via KubeVirt (R-PLAT-02/03)
source:
  image: <repo/image>              # container: image ref
  # or vmTemplate: <image/source>  # vm: CDI source image
exposure: internal | public        # LAN/VPN-only vs Cloudflare Tunnel (R-DNS-04/05, ADR-0016)
egress: internet | vpn             # egress class (R-NET-05); vpn ⇒ kill-switch (R-NET-06)
storage:
  - name: config
    size: <e.g. 5Gi>
    class: longhorn                # replicated app/config state (R-STATE-02)
  - name: media                    # optional bulk tier (R-STATE-04)
    class: bulk
dependencies:                      # explicit allow rules; everything else denied (R-NET-01/02)
  - to: <service>
    port: <n>
    protocol: tcp|udp
ingressRoutes:                     # hostnames/paths to expose (rendered to HTTPRoute + TLS + DNS)
  - host: <name>.home.keithjackson.dev
    port: <servicePort>
auth:                              # SSO integration (R-IDP-02; see 15-identity-and-access.md)
  mode: oidc | forward-auth | native | none
  # oidc         → app configured as a Keycloak OIDC client (does its own login)
  # forward-auth → protected by oauth2-proxy → Keycloak at the Gateway (for UIs w/o OIDC)
  # native       → app manages its own auth (e.g. Plex/Seerr via Plex) — documented exception
  # none         → no auth (internal-only, discouraged)
  groups: [ <keycloak-group>, ... ]  # groups allowed access (R-IDP-06)
update:
  channel: stable                  # never pre-release unless opted in (R-UPD-01)
  range: <semver range | tag regex># acceptable versions
  cadence: <schedule>              # how often to check/apply
  autoRollbackOnFailedHealth: true # R-UPD-03
  pin: <optional version>          # operator freeze escape hatch
observability:
  metrics: true
  slis: [ up, ... ]                # per-service alert intents (R-OBS-01)
backup:
  policy: <ref to backup schedule> # off-cluster (R-STATE-03); or 'reacquirable' for bulk media

The schema is the contract between the operator surface and the implementation. Changing it is an interface change — coordinate per agents/coordination-protocol.md.

Default posture for every service (unless overridden)

  • kind: container, exposure: internal, egress: internet.
  • Default-deny: no dependencies ⇒ can reach only DNS (03).
  • Auth default forward-auth: any exposed UI is protected by Keycloak SSO unless it declares native OIDC (oidc) or a documented exception (native) (15, R-IDP-02).
  • Longhorn-backed config PVC; off-cluster backup; trusted TLS; auto-DNS; monitored; auto-updated on stable.

The initial catalog (14 services)

Egress and dependencies below are normative intent; implementers render the exact policies. "Bulk" = large media tier (04). All get trusted TLS + auto-DNS + monitoring

  • auto-update unless noted.
# Service kind exposure egress Storage Key dependencies (explicit allows) Notes
0 keycloak container public internet db (PostgreSQL): longhorn (irreplaceable → backed up) → its DB; ← all SSO clients + oauth2-proxy IdP/SSO (15); foundational, onboarded early; DR-critical + break-glass path; hardened public exposure
1 plex container public internet config: longhorn (small, replicated); media: bulk — (clients reach it; Seerr → Plex) Largest service; media on bulk tier, metadata DB on Longhorn (04); auth: native (Plex)
2 sonarr container internal vpn config: longhorn; media/downloads: bulk → qbittorrent, → jackett; ← seerr VPN + kill-switch (R-NET-06)
3 radarr container internal vpn config: longhorn; media/downloads: bulk → qbittorrent, → jackett; ← seerr VPN + kill-switch
4 jackett container internal vpn config: longhorn ← sonarr, ← radarr VPN + kill-switch (indexer)
5 seerr container public internet config: longhorn → sonarr:8989, → radarr:7878, → plex:32400 Request UI; talks to arr + Plex
6 pihole container internal internet config: longhorn (provides DNS to cluster/LAN) Also a platform DNS dependency (09)
7 nextcloud container public internet data: longhorn (irreplaceable → backed up) → its DB File sync; DR-critical data
8 minecraft container public internet world: longhorn (backed up) — Exposes game port via edge
9 unifi container internal internet config: longhorn — UniFi network controller
10 semaphore container internal internet config/db: longhorn — Ansible UI
11 kasm container internal internet config: longhorn — Kasm Workspaces (may need more resources; container-first, VM if required)
12 gitea container public internet repos/db: longhorn (irreplaceable → backed up) — Git hosting; bootstrapping-critical — self-hosts the platform's GitOps source + CI/CD; onboarded early (see below); DR-critical (off-cluster backup + mirror)
13 qbittorrent container internal vpn config: longhorn; downloads: bulk ← sonarr, ← radarr VPN + kill-switch; download client

VPN-class set (R-NET-05/06): sonarr, radarr, jackett, qbittorrent — all egress via VPN with an enforced kill-switch. Their inter-dependencies (arr → qbittorrent/jackett) are east-west allows within the deny-by-default fabric; being VPN-class governs egress, not intra-cluster reachability.

Dependency graph (explicit allows only; all else denied):

seerr → sonarr, radarr, plex
sonarr → qbittorrent, jackett
radarr → qbittorrent, jackett
(everything → DNS only, by default)

SSO integration (auth.mode, R-IDP-02; see 15):

  • oidc (native Keycloak client): gitea, nextcloud, semaphore, kasm, grafana*, headlamp* (*platform UIs, not in the numbered app list).
  • forward-auth (oauth2-proxy → Keycloak): pihole, sonarr, radarr, jackett, qbittorrent, unifi, and platform UIs (Homepage, Prometheus, Alertmanager, Hubble).
  • native (own auth, documented exceptions): plex, seerr (Plex accounts); minecraft (Mojang/Microsoft).
  • keycloak itself is the IdP — its own admin login is local + MFA (the root of trust).

"…but not limited to": the catalog is open-ended. New services follow the same ServiceDefinition and inherit all guarantees automatically — that is the point of the abstraction.

Contract clarifications (v1.1 — resolved 2026-08-16)

Points the schema left ambiguous, now pinned so the JSON Schema, Kyverno policies, and renderers agree:

  • source is exactly-one-of — image for kind: container, vmTemplate for kind: vm (schema-enforced conditional).
  • storage[].class: longhorn ⇒ size is required — enforced by an L0 Kyverno policy (a replicated volume with no size is a defect). bulk size may be omitted (external mount).
  • backup is a reference — backup: {policy: <ref>} where policy is either the literal reacquirable (bulk/re-acquirable data) or a named off-cluster backup schedule. Cadence/retention live in the referenced schedule object, not inline in the ServiceDefinition.
  • update.channel is a free string defaulting to stable — not a closed enum, so the sanctioned "opt-in to a non-stable channel" (R-UPD-01) is expressible per service.
  • auth.groups is required for auth.mode ∈ {oidc, forward-auth} — access must be scoped to at least one Keycloak group (R-IDP-06); optional for native/none.
  • exposure: public ⇒ auth.mode ≠ none — a public service may not be unauthenticated (docs/15, R-IDP-02); schema-enforced invariant.
  • Irreplaceable ⇒ off-cluster backup — DR-critical services MUST NOT be reacquirable. This depends on per-service criticality the schema can't see, so it is enforced by an L0 Kyverno policy keyed on the service's criticality signal, not by the JSON Schema.

Cross-cutting notes

  • Plex media is the R-STATE-04 case: keep the metadata DB on replicated Longhorn; keep the large library on the bulk tier; downloads stage on bulk and are handed off by the arr stack.
  • DR-critical data (Nextcloud, Gitea, Minecraft worlds, PiHole config, service DBs) MUST have off-cluster backups (04); bulk media may be reacquirable per operator choice (OQ-2).
  • Public exposure (Seerr, Nextcloud, Gitea, …) is through the Cloudflare Tunnel (09, ADR-0016). Everything else stays internal. Plex remote streaming uses plex.tv + an edge forward of tcp/32400 (not the tunnel), and its web UI is LAN-only. Minecraft (crafty-mc) and other L4 services are LAN/VPN-only.
  • Kasm is resource-heavy and may be a candidate for kind: vm if container mode proves insufficient — the ServiceDefinition makes that a one-field change.
  • Gitea is bootstrapping-critical. The platform self-hosts its git and CI/CD, so Gitea is brought online early (WS-APPS step 2, right after PiHole). Once stable, the GitOps source and CI move from the local bootstrap source to Gitea + Gitea Actions (05, ../CONTRIBUTING.md). Because a cluster loss would take its own git host with it, Gitea's repos/DB are backed up off-cluster and mirrored, so the platform can always be rebuilt from a source that outlives the cluster.

Acceptance gates

  • Each service is fully wired from its ServiceDefinition alone (compute, storage, policy, egress, TLS, DNS, monitoring, updates) — no manual steps beyond merge.
  • Default-deny holds; only listed dependencies work.
  • VPN-class services egress via VPN with a proven kill-switch.
  • Stateful services survive destroy/rebuild; DR-critical data restores from off-cluster backup.