# 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) ```yaml name: # DNS label → .home.keithjackson.dev kind: container | vm # PaaS (default) or IaaS via KubeVirt (R-PLAT-02/03) source: image: # container: image ref # or vmTemplate: # vm: CDI source image exposure: internal | public # LAN-only vs edge port-forwarded (R-DNS/R-NET) egress: internet | vpn # egress class (R-NET-05); vpn ⇒ kill-switch (R-NET-06) storage: - name: config size: 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: port: protocol: tcp|udp ingressRoutes: # hostnames/paths to expose (rendered to HTTPRoute + TLS + DNS) - host: .home.keithjackson.dev port: 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: [ , ... ] # groups allowed access (R-IDP-06) update: channel: stable # never pre-release unless opted in (R-UPD-01) range: # acceptable versions cadence: # how often to check/apply autoRollbackOnFailedHealth: true # R-UPD-03 pin: # operator freeze escape hatch observability: metrics: true slis: [ up, ... ] # per-service alert intents (R-OBS-01) backup: policy: # 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`](../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](03-networking-isolation-and-egress.md)). - **Auth default `forward-auth`**: any exposed UI is protected by Keycloak SSO unless it declares native OIDC (`oidc`) or a documented exception (`native`) ([15](15-identity-and-access.md), 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](04-storage-and-state.md)). 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](15-identity-and-access.md)); **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](04-storage-and-state.md)); 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](09-dns-and-ingress.md)) | | 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](15-identity-and-access.md)):** - **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: }` 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](04-storage-and-state.md)); bulk media may be `reacquirable` per operator choice (OQ-2). - **Public exposure** (Plex, Seerr, Nextcloud, Minecraft, Gitea) requires edge port-forwarding ([09](09-dns-and-ingress.md)); everything else stays `internal`. - **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](05-gitops-and-delivery.md), [`../CONTRIBUTING.md`](../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.