Merge feature/p0-schema into main
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
6 files changed
+585
-4
No files matched your search
+36
-2
@@ -1,3 +1,37 @@
|
||||
# catalog
|
||||
# catalog — ServiceDefinition entries (the onboarding surface)
|
||||
|
||||
_Placeholder — populated in Phase 0. See ../home-network-k3s (read-only spec)._
|
||||
One YAML file per service. Each file is a **ServiceDefinition** conforming to
|
||||
[`../schemas/servicedefinition.schema.json`](../schemas/servicedefinition.schema.json). Adding or
|
||||
editing a service here is the *only* onboarding step — the platform renders compute, storage, network
|
||||
policy, egress, TLS, DNS, monitoring, and update policy from it (see
|
||||
[`../schemas/renderer-contract.md`](../schemas/renderer-contract.md)). Serves **R-GITOPS-02**.
|
||||
|
||||
**Source of truth for the initial catalog:** spec
|
||||
[`docs/13-service-catalog.md`](../../home-network-k3s/docs/13-service-catalog.md) (14-service table +
|
||||
SSO/egress/dependency intent).
|
||||
|
||||
## Current examples (Phase 0)
|
||||
| File | docs/13 row | exposure | egress | auth | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `pihole.yaml` | 6 | internal | internet | forward-auth | admin UI behind oauth2-proxy; config on Longhorn (DR-critical); also platform DNS |
|
||||
| `keycloak.yaml` | 0 | public | internet | native | the IdP itself (own admin login + MFA); PostgreSQL on Longhorn; DR-critical |
|
||||
|
||||
The remaining catalog services (plex, sonarr, radarr, jackett, seerr, nextcloud, minecraft, unifi,
|
||||
semaphore, kasm, gitea, qbittorrent) are onboarded in later phases; they follow the same schema and
|
||||
inherit every guarantee automatically.
|
||||
|
||||
## Authoring rules
|
||||
- Only `name` and `source` are mandatory; every other field defaults to docs/13's default posture
|
||||
(`kind: container`, `exposure: internal`, `egress: internet`, `auth.mode: forward-auth`, stable
|
||||
auto-update, DNS-only default-deny). Set a field only to override the default.
|
||||
- Any exposed UI must have an `auth` block: `forward-auth` (default) or `oidc`, each with a non-empty
|
||||
`auth.groups`; `native`/`none` are documented exceptions only.
|
||||
- Irreplaceable state (`class: longhorn` configs/DBs) must set `backup.policy` to an off-cluster
|
||||
schedule — never `reacquirable` (R-STATE-03). Only bulk media may be `reacquirable`.
|
||||
|
||||
## Validate before commit — single command, no cluster (R-TEST-02)
|
||||
```bash
|
||||
check-jsonschema --schemafile schemas/servicedefinition.schema.json catalog/*.yaml
|
||||
```
|
||||
See [`../schemas/README.md`](../schemas/README.md) for install/pinning and a pure-Python fallback.
|
||||
This is the L0/L1 schema gate (spec `docs/14`); CI (`.gitea/`) runs it on every change.
|
||||
@@ -0,0 +1,35 @@
|
||||
# ServiceDefinition: keycloak
|
||||
# Source of truth: spec docs/13-service-catalog.md row 0; docs/15-identity-and-access.md; ADR-0014.
|
||||
# public IdP | egress internet | PostgreSQL on Longhorn (irreplaceable, DR-critical) | auth native.
|
||||
# Keycloak IS the IdP (root of trust): its own admin login is local + MFA, so it does not federate.
|
||||
# Validates against schemas/servicedefinition.schema.json.
|
||||
name: keycloak
|
||||
kind: container
|
||||
source:
|
||||
image: quay.io/keycloak/keycloak
|
||||
exposure: public # login/redirect flows for internet-exposed services; hardened (docs/15; R-DNS/R-NET)
|
||||
egress: internet # normal WAN egress (docs/03; R-NET-05)
|
||||
storage:
|
||||
- name: db
|
||||
size: 10Gi
|
||||
class: longhorn # PostgreSQL on 3-replica Longhorn; irreplaceable (docs/15, docs/04; R-STATE-02)
|
||||
dependencies:
|
||||
- to: keycloak-db # explicit allow: Keycloak -> its PostgreSQL (docs/13 graph; R-NET-02)
|
||||
port: 5432
|
||||
protocol: tcp
|
||||
ingressRoutes:
|
||||
- host: keycloak.home.keithjackson.dev
|
||||
port: 8080 # HTTP listener; TLS terminates at the Gateway (docs/08, docs/09)
|
||||
auth:
|
||||
mode: native # it IS the IdP; own admin login is local + MFA (docs/15; ADR-0014). Documented exception.
|
||||
update:
|
||||
channel: stable # R-UPD-01
|
||||
range: ">=24.0.0"
|
||||
cadence: "0 4 * * *"
|
||||
autoRollbackOnFailedHealth: true # R-UPD-03
|
||||
observability:
|
||||
metrics: true
|
||||
slis:
|
||||
- up # IdP down/unhealthy is a critical alert (docs/10; R-OBS-01)
|
||||
backup:
|
||||
policy: daily-offcluster # DR-critical: rebuilds from DB + git realm config (docs/15, docs/04; R-STATE-03)
|
||||
@@ -0,0 +1,33 @@
|
||||
# ServiceDefinition: pihole
|
||||
# Source of truth: spec docs/13-service-catalog.md, row 6.
|
||||
# internal | egress internet | config on Longhorn (DR-critical config) | auth forward-auth.
|
||||
# Also a platform DNS dependency (docs/09). Validates against schemas/servicedefinition.schema.json.
|
||||
name: pihole
|
||||
kind: container
|
||||
source:
|
||||
image: pihole/pihole
|
||||
exposure: internal # LAN-only admin UI (docs/13 row 6; R-DNS/R-NET)
|
||||
egress: internet # normal WAN egress (docs/03; R-NET-05)
|
||||
storage:
|
||||
- name: config
|
||||
size: 2Gi
|
||||
class: longhorn # replicated config state; DR-critical (docs/04 line 118; R-STATE-02/03)
|
||||
dependencies: [] # default-deny: reaches only DNS; it PROVIDES DNS (docs/03; R-NET-01/02)
|
||||
ingressRoutes:
|
||||
- host: pihole.home.keithjackson.dev
|
||||
port: 80 # admin web UI, protected at the Gateway by forward-auth (docs/09)
|
||||
auth:
|
||||
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no native OIDC (docs/15; R-IDP-02)
|
||||
groups:
|
||||
- platform-admins # sensitive admin UI: authenticated AND in group (R-IDP-06)
|
||||
update:
|
||||
channel: stable # R-UPD-01
|
||||
range: ">=2024.0.0"
|
||||
cadence: "0 4 * * *"
|
||||
autoRollbackOnFailedHealth: true # R-UPD-03
|
||||
observability:
|
||||
metrics: true
|
||||
slis:
|
||||
- up # R-OBS-01
|
||||
backup:
|
||||
policy: daily-offcluster # PiHole config is DR-critical (docs/04 line 118; R-STATE-03)
|
||||
+77
-2
@@ -1,3 +1,78 @@
|
||||
# schemas
|
||||
# schemas — the ServiceDefinition contract
|
||||
|
||||
_Placeholder — populated in Phase 0. See ../home-network-k3s (read-only spec)._
|
||||
This directory holds the **ServiceDefinition** interface — the highest-traffic contract in the
|
||||
platform (spec `agents/coordination-protocol.md` §2). Onboarding a service is adding/editing one
|
||||
ServiceDefinition under [`../catalog/`](../catalog/); the platform compiles it into concrete
|
||||
resources. Operators declare **what**; the platform provides the **how**.
|
||||
|
||||
**Source of truth:** the read-only spec
|
||||
[`docs/13-service-catalog.md`](../../home-network-k3s/docs/13-service-catalog.md). This schema matches
|
||||
docs/13 exactly. Do not diverge from it here — escalate spec problems, don't fork the contract.
|
||||
|
||||
## Files
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `servicedefinition.schema.json` | Formal JSON Schema (draft 2020-12) for a ServiceDefinition. Enforces enums, required fields, patterns, and cross-field invariants. |
|
||||
| `renderer-contract.md` | Authoritative field → rendered-resource mapping, with requirement IDs. The contract other workstreams consume. |
|
||||
|
||||
**Schema version:** 1.0.0 (in the schema `description`). Treat any change as an interface change:
|
||||
propose on the task board, update dependent renderers/tests in the same change, never silently break a
|
||||
consumer (coordination-protocol §2).
|
||||
|
||||
## Required vs. defaulted fields
|
||||
- **Required:** `name`, `source` (with `source.image` for `kind: container`, `source.vmTemplate` for
|
||||
`kind: vm`).
|
||||
- **Everything else is optional and carries a default** matching docs/13's "default posture"
|
||||
(`kind: container`, `exposure: internal`, `egress: internet`, `auth.mode: forward-auth`,
|
||||
`update.channel: stable`, `update.autoRollbackOnFailedHealth: true`, `observability.metrics: true`,
|
||||
`dependencies: []` ⇒ DNS-only). A renderer MUST materialize defaults before rendering — see
|
||||
`renderer-contract.md` §"Default posture".
|
||||
|
||||
## Cross-field rules the schema enforces
|
||||
- `kind: vm` ⇒ `source.vmTemplate` required; otherwise `source.image` required.
|
||||
- `auth.mode: oidc | forward-auth` ⇒ `auth.groups` present and non-empty (R-IDP-06 — access is "in an
|
||||
authorized group," not merely authenticated).
|
||||
- `ingressRoutes[].host` must match `<label>.home.keithjackson.dev`.
|
||||
- `storage[].class ∈ {longhorn, bulk}`; `exposure ∈ {internal, public}`; `egress ∈ {internet, vpn}`;
|
||||
`auth.mode ∈ {oidc, forward-auth, native, none}`.
|
||||
- `additionalProperties: false` everywhere — unknown keys are rejected (catches typos in review/CI).
|
||||
|
||||
## Validate examples against the schema — L1, no cluster (R-TEST-02)
|
||||
|
||||
Reproducible, single command, no cluster required. Canonical tool: **check-jsonschema** (YAML-native,
|
||||
pins the schema file, pin the tool version for determinism):
|
||||
|
||||
```bash
|
||||
# one-time (pin the version for reproducibility)
|
||||
pip install check-jsonschema==0.38.0
|
||||
|
||||
# validate every catalog entry against the contract
|
||||
check-jsonschema --schemafile schemas/servicedefinition.schema.json catalog/*.yaml
|
||||
# -> "ok -- validation done"
|
||||
```
|
||||
|
||||
Fallback with no extra tool (Python `jsonschema` + `pyyaml` only):
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import json, yaml, glob, sys
|
||||
from jsonschema import Draft202012Validator
|
||||
schema = json.load(open('schemas/servicedefinition.schema.json'))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
v = Draft202012Validator(schema); bad = False
|
||||
for f in sorted(glob.glob('catalog/*.yaml')):
|
||||
errs = sorted(v.iter_errors(yaml.safe_load(open(f))), key=lambda e: list(e.path))
|
||||
print(('PASS ' if not errs else 'FAIL ') + f)
|
||||
for e in errs: print(' ', list(e.path), e.message); bad = True
|
||||
sys.exit(1 if bad else 0)
|
||||
PY
|
||||
```
|
||||
|
||||
Both are pure manifest/schema checks (L0/L1 in spec `docs/14`, table row "L1 Rendering") — they run in
|
||||
CI with no cluster and are wired into the `.gitea/` schema-validation gate by WS-TEST.
|
||||
|
||||
## See also
|
||||
- `renderer-contract.md` — what each field renders into (Deployment/VM, PVC, CiliumNetworkPolicy,
|
||||
egress steering, HTTPRoute/Certificate/DNS, ServiceMonitor/alerts, ImagePolicy, Keycloak/oauth2-proxy).
|
||||
- Example ServiceDefinitions: [`../catalog/pihole.yaml`](../catalog/pihole.yaml),
|
||||
[`../catalog/keycloak.yaml`](../catalog/keycloak.yaml).
|
||||
@@ -0,0 +1,155 @@
|
||||
# Renderer Contract — ServiceDefinition → rendered resources
|
||||
|
||||
**Status:** authoritative Phase-0 interface contract. **Consumed by** the manifest renderer
|
||||
(WS-GITOPS), the network/egress renderer (WS-NET), storage (WS-STATE), TLS/DNS (WS-EDGE), identity
|
||||
(WS-SEC), observability (WS-OBS), updates (WS-UPD), and the conformance/policy/CI agents (WS-TEST).
|
||||
|
||||
**Source of truth:** spec [`docs/13-service-catalog.md`](../../home-network-k3s/docs/13-service-catalog.md)
|
||||
(the ServiceDefinition schema and default posture). This document maps each field of
|
||||
[`servicedefinition.schema.json`](./servicedefinition.schema.json) to the concrete resources the
|
||||
platform must render, and cites the requirement each mapping satisfies. Changing this contract is an
|
||||
**interface change** — coordinate per the spec's `agents/coordination-protocol.md` (§2).
|
||||
|
||||
> The renderer MUST first materialize the schema **defaults** (below), then apply this mapping. A
|
||||
> field absent from a ServiceDefinition renders from its default posture, not "not at all."
|
||||
|
||||
## Default posture (applied before mapping) — docs/13 §"Default posture"
|
||||
| Field | Default | Requirement |
|
||||
|---|---|---|
|
||||
| `kind` | `container` | R-PLAT-02/03 |
|
||||
| `exposure` | `internal` | R-DNS/R-NET |
|
||||
| `egress` | `internet` | R-NET-05 |
|
||||
| `storage` | `[]` (config PVC still expected for stateful svc) | R-STATE-02 |
|
||||
| `dependencies` | `[]` → reaches **only DNS** (default-deny) | R-NET-01 |
|
||||
| `auth.mode` | `forward-auth` for any exposed UI | R-IDP-02 |
|
||||
| `update.channel` | `stable` | R-UPD-01 |
|
||||
| `update.autoRollbackOnFailedHealth` | `true` | R-UPD-03 |
|
||||
| `observability.metrics` | `true` | R-OBS-01 |
|
||||
| backup of irreplaceable state | off-cluster (never `reacquirable`) | R-STATE-03 |
|
||||
|
||||
---
|
||||
|
||||
## Field → resource mapping
|
||||
|
||||
### `name`
|
||||
- **Renders:** the service Namespace (trust zone), Deployment/VM name, Service name, and the DNS label
|
||||
`<name>.home.keithjackson.dev`.
|
||||
- **Requirements:** R-DNS-01 (naming model, docs/09), R-NET (namespace = isolation boundary, docs/03).
|
||||
|
||||
### `kind` + `source`
|
||||
- `container` + `source.image` → **Deployment** (+ Service) using the image ref.
|
||||
- `vm` + `source.vmTemplate` → **KubeVirt VirtualMachine** with a **CDI**-imported root disk.
|
||||
- The schema enforces exactly-one-of (`image` for container, `vmTemplate` for vm).
|
||||
- **Requirements:** R-PLAT-02/03 (PaaS/IaaS, docs/13); VM disks inherit Longhorn durability (docs/04).
|
||||
|
||||
### `exposure` (`internal` | `public`)
|
||||
- Both classes render an **HTTPRoute** on the shared Gateway and are reachable on the LAN via internal
|
||||
split-horizon DNS.
|
||||
- `public` **additionally** signals the documented **manual edge port-forward** of 80/443 (and any
|
||||
service-specific port, e.g. Plex 32400, Minecraft 25565) to the ingress VIP, and a **public**
|
||||
ExternalDNS record for `<name>.home.keithjackson.dev`.
|
||||
- `internal` renders **no public DNS record** and is never port-forwarded (PiHole resolves it to the
|
||||
internal Gateway VIP).
|
||||
- Exposure does **not** relax east-west default-deny (docs/09 §"Interaction with default-deny").
|
||||
- **Requirements:** R-DNS-02/03 (docs/09), R-NET-04 (docs/03).
|
||||
|
||||
### `egress` (`internet` | `vpn`) — egress steering
|
||||
- `internet` → pod egress allow to the default WAN route (node → SOHO edge → internet).
|
||||
- `vpn` → the pod is **steered through the shared in-cluster Gluetun/PIA (WireGuard) egress gateway**
|
||||
(Cilium Egress Gateway / routing). The pod gets **no independent egress path**; the
|
||||
**kill-switch (fail-closed)** and allow rules are enforced at the gateway + namespace/policy level,
|
||||
**outside** the workload.
|
||||
- **Requirements:** **R-NET-05** (egress class), **R-NET-06** (VPN kill-switch, mandatory & central).
|
||||
VPN-class set is normative: sonarr, radarr, jackett, qbittorrent (docs/03/13).
|
||||
|
||||
### `dependencies[]` (`to` / `port` / `protocol`) — east-west policy
|
||||
- Each namespace first gets the **default-deny baseline** (deny all ingress + egress) applied by
|
||||
GitOps at namespace creation, with a standing allow only for **DNS to the in-cluster resolver** and
|
||||
for **observability scraping** from the monitoring namespace.
|
||||
- Each `dependencies[]` entry renders one **CiliumNetworkPolicy** egress allow: this service → `to`
|
||||
service on `port`/`protocol`. Everything undeclared stays denied.
|
||||
- Empty `dependencies` ⇒ the service can reach **only DNS**.
|
||||
- **Requirements:** **R-NET-01** (default-deny baseline), **R-NET-02** (explicit allow / deny
|
||||
undeclared). Auditable via Hubble (R-SEC-03, R-OBS-04).
|
||||
|
||||
### `storage[]` (`name` / `size` / `class`) — PVCs
|
||||
- `class: longhorn` → a **PVC** on the 3-replica Longhorn StorageClass sized by `size` (replicated
|
||||
config/DB/app state). For a `vm`, the disk is a Longhorn volume via CDI.
|
||||
- `class: bulk` → binding to the external **bulk** media tier (NFS/SMB indirection, sized outside the
|
||||
cluster; `size` optional). Not 3× replicated.
|
||||
- State separation rule: anything that must survive a rebuild lives on a named volume here, never on
|
||||
ephemeral fs/hostPath — so the workload can be destroyed and re-applied from git with data intact.
|
||||
- **Requirements:** **R-STATE-01** (rebuild survival), **R-STATE-02** (Longhorn replication),
|
||||
**R-STATE-04** (bulk tier). (docs/04)
|
||||
|
||||
### `ingressRoutes[]` (`host` / `port`) — HTTPRoute + Certificate + DNS
|
||||
- Each entry renders an **HTTPRoute** attaching `host` → the service `port` on the shared Gateway.
|
||||
- TLS: the Gateway terminates with the cert-manager **wildcard `*.home.keithjackson.dev`** certificate
|
||||
(Let's Encrypt via **DNS-01**, scoped Cloudflare token), so no per-service cert step — trusted,
|
||||
non-self-signed, auto-renewed, including for `internal` services. Per-service `Certificate` only
|
||||
where isolation warrants.
|
||||
- DNS: **ExternalDNS** watches the HTTPRoute and manages the `<host>` record (public IP for `public`,
|
||||
internal Gateway VIP via PiHole split-horizon for `internal`).
|
||||
- The `host` pattern is enforced to `<label>.home.keithjackson.dev` by the schema.
|
||||
- **Requirements:** **R-TLS-01/02/03** (docs/08), **R-DNS-01/02/03** (docs/09).
|
||||
|
||||
### `auth` (`mode` / `groups[]`) — identity integration (Keycloak / oauth2-proxy)
|
||||
- `oidc` → register the app as a **Keycloak OIDC client** (declarative realm/client config, GitOps;
|
||||
client secret SOPS-encrypted). App does its own login redirect.
|
||||
- `forward-auth` → protect the service's `ingressRoutes` at the **Gateway** with **oauth2-proxy →
|
||||
Keycloak** so auth happens before traffic reaches the app (bare UIs without usable OIDC).
|
||||
- `native` → no platform SSO wiring; app owns its auth (documented exception, e.g. Plex/Seerr; and
|
||||
Keycloak **itself** as the IdP, admin login local + MFA).
|
||||
- `none` → no auth wiring (internal-only, discouraged).
|
||||
- `groups[]` → the Keycloak groups permitted access, enforced by the OIDC client mappers /
|
||||
oauth2-proxy `allowed_groups`. **Schema requires ≥1 group for `oidc`/`forward-auth`** — access is
|
||||
"in an authorized group," not merely "logged in."
|
||||
- Keycloak, oauth2-proxy, and app→Keycloak / proxy→Keycloak / Keycloak→DB flows are ordinary
|
||||
workloads under default-deny with explicit allows (ties back to `dependencies`).
|
||||
- **Requirements:** **R-IDP-02** (protect OIDC-capable and bare UIs), **R-IDP-06** (group-based
|
||||
authorization). (docs/15, ADR-0014)
|
||||
|
||||
### `update` (`channel` / `range` / `cadence` / `autoRollbackOnFailedHealth` / `pin`)
|
||||
- container `kind` → a Flux **ImagePolicy** (+ ImageRepository) encoding `channel`/`range`; Flux
|
||||
image-automation advances the running version on its scan interval (`cadence`). `pin` freezes the
|
||||
service (stops automation). No human/AI edits a tag.
|
||||
- `vm` `kind` → in-guest unattended package updates on `cadence` (cloud-init), per docs/11 §3.
|
||||
- `autoRollbackOnFailedHealth: true` → health-gated rollout that reverts on failed health and alerts.
|
||||
- **Requirements:** **R-UPD-01** (stable only), **R-UPD-02** (no human/AI authoring), **R-UPD-03**
|
||||
(health-gated auto-rollback). (docs/11, ADR-0009)
|
||||
|
||||
### `observability` (`metrics` / `slis[]`) — ServiceMonitor + alerts
|
||||
- `metrics: true` → a **ServiceMonitor/PodMonitor** so Prometheus scrapes the service; logs are
|
||||
shipped to Loki by the node/pod agent by default.
|
||||
- each entry in `slis[]` → at least one **alert rule** (e.g. `up` → service-down alert) routed by
|
||||
Alertmanager to email; a per-service Grafana dashboard row.
|
||||
- **Requirements:** **R-OBS-01** (per-service SLIs/alerts). Platform-health alerts (R-OBS-04) are
|
||||
rendered platform-wide, not per ServiceDefinition. (docs/10)
|
||||
|
||||
### `backup` (`policy`)
|
||||
- a schedule ref (e.g. `daily-offcluster`) → **Velero + Longhorn** off-cluster backup schedule for
|
||||
this service's volumes/namespace.
|
||||
- literal `reacquirable` → **no** 1:1 backup (bulk media only). **Irreplaceable** state (configs, DBs,
|
||||
Nextcloud, Gitea, PiHole config, Keycloak DB) MUST NOT be `reacquirable`.
|
||||
- **Requirements:** **R-STATE-03** (off-cluster DR backup). (docs/04)
|
||||
|
||||
---
|
||||
|
||||
## Cross-field invariants the renderer / L0-L1 tests rely on
|
||||
1. **kind ↔ source:** container⇒`image`, vm⇒`vmTemplate` (schema-enforced).
|
||||
2. **auth ↔ groups:** `oidc`/`forward-auth` ⇒ non-empty `groups` (schema-enforced; R-IDP-06).
|
||||
3. **default-deny is unconditional:** every namespace gets the deny baseline regardless of
|
||||
`dependencies`; `dependencies` only *adds* allows (R-NET-01/02).
|
||||
4. **egress is enforced outside the workload:** `vpn` renders gateway steering + kill-switch, never a
|
||||
per-service sidecar the app could bypass (R-NET-05/06).
|
||||
5. **TLS is never self-signed and needs no per-service step:** the wildcard Gateway cert covers every
|
||||
`ingressRoutes.host`, `internal` included (R-TLS-01/03).
|
||||
6. **irreplaceable ⇒ off-cluster backup:** DR-critical services must not carry `backup.policy:
|
||||
reacquirable` (R-STATE-03). *(Advisory: not enforced by the schema — an L0 policy check should
|
||||
assert it; see "Ambiguities / escalations" in schemas/README.md.)*
|
||||
|
||||
## Requirement traceability (contract-level)
|
||||
Primary **R-GITOPS-02** (single reviewable object wires every requirement). Field-level: R-NET-01/02
|
||||
(dependencies), R-NET-05/06 (egress), R-STATE-01/02/04 (storage), R-STATE-03 (backup),
|
||||
R-TLS-01/02/03 + R-DNS-01/02/03 (ingressRoutes + exposure), R-OBS-01 (observability),
|
||||
R-UPD-01/02/03 (update), R-IDP-02/06 (auth), R-PLAT-02/03 (kind/source).
|
||||
@@ -0,0 +1,249 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json",
|
||||
"title": "ServiceDefinition",
|
||||
"description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.0.0.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "source"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "DNS label -> <name>.home.keithjackson.dev (docs/13, docs/09). Must be a valid RFC 1123 DNS label.",
|
||||
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||
"minLength": 1,
|
||||
"maxLength": 63
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "PaaS container (default) or IaaS VM via KubeVirt (docs/13; R-PLAT-02/03).",
|
||||
"enum": ["container", "vm"],
|
||||
"default": "container"
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"description": "Workload source. container => image; vm => vmTemplate (CDI source). Exactly one, matching kind.",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"description": "Container image reference (repo/image[:tag]). Required when kind=container.",
|
||||
"minLength": 1
|
||||
},
|
||||
"vmTemplate": {
|
||||
"type": "string",
|
||||
"description": "KubeVirt/CDI source image reference. Required when kind=vm.",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"exposure": {
|
||||
"type": "string",
|
||||
"description": "internal = LAN-only (internal DNS + Gateway on LAN); public = additionally edge port-forwarded 80/443 (docs/09; R-DNS/R-NET).",
|
||||
"enum": ["internal", "public"],
|
||||
"default": "internal"
|
||||
},
|
||||
"egress": {
|
||||
"type": "string",
|
||||
"description": "Egress class (docs/03; R-NET-05). internet = default WAN route; vpn = steered through the shared in-cluster Gluetun/PIA gateway with a mandatory kill-switch (R-NET-06).",
|
||||
"enum": ["internet", "vpn"],
|
||||
"default": "internet"
|
||||
},
|
||||
"storage": {
|
||||
"type": "array",
|
||||
"description": "Persistent volumes. If it must survive a rebuild it lives here, never on ephemeral/hostPath (docs/04; R-STATE-01/02/04).",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "class"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Volume identifier within the service (e.g. config, media, db).",
|
||||
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
|
||||
},
|
||||
"size": {
|
||||
"type": "string",
|
||||
"description": "PVC request in Kubernetes quantity form (e.g. 5Gi). Required for the longhorn tier; may be omitted for the bulk tier (external mount, sized outside the cluster - docs/04).",
|
||||
"pattern": "^[1-9][0-9]*(Mi|Gi|Ti)$"
|
||||
},
|
||||
"class": {
|
||||
"type": "string",
|
||||
"description": "longhorn = 3-replica block storage for app/config/DB state (R-STATE-02); bulk = large external media tier, NOT 3x replicated (R-STATE-04).",
|
||||
"enum": ["longhorn", "bulk"],
|
||||
"default": "longhorn"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "array",
|
||||
"description": "Explicit east-west allow rules; everything else is denied (docs/03; R-NET-01/02). Empty => the service can reach only DNS.",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["to", "port"],
|
||||
"properties": {
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Destination service name (another catalog entry or a co-located component such as this service's DB).",
|
||||
"minLength": 1
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"description": "Destination TCP/UDP port.",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"enum": ["tcp", "udp"],
|
||||
"default": "tcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ingressRoutes": {
|
||||
"type": "array",
|
||||
"description": "Hostnames/ports to expose north-south; rendered to HTTPRoute + Certificate + DNS (docs/08, docs/09; R-DNS/R-TLS).",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["host", "port"],
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "FQDN under the platform zone. Must be a <label>.home.keithjackson.dev name (docs/09).",
|
||||
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.home\\.keithjackson\\.dev$"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"description": "Backing Service port the route targets.",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"description": "SSO integration (docs/15, ADR-0014; R-IDP-02/06). Default posture for any exposed UI is forward-auth unless it declares native OIDC (oidc) or a documented exception (native).",
|
||||
"additionalProperties": false,
|
||||
"required": ["mode"],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"description": "oidc = app is a Keycloak OIDC client (own login); forward-auth = oauth2-proxy -> Keycloak at the Gateway; native = app manages its own auth (documented exception, e.g. Plex/Seerr, or Keycloak itself as the IdP); none = no auth (internal-only, discouraged).",
|
||||
"enum": ["oidc", "forward-auth", "native", "none"],
|
||||
"default": "forward-auth"
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"description": "Keycloak groups permitted access (R-IDP-06). Required and non-empty for oidc/forward-auth (least privilege: authenticated AND in an authorized group). N/A for native/none.",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"type": "object",
|
||||
"description": "Per-service automated-update policy (docs/11; R-UPD-01/02/03). The operator declares the policy once; an automation controller resolves it - no human/AI edits a tag.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Release channel. stable only unless explicitly opted in (R-UPD-01).",
|
||||
"default": "stable"
|
||||
},
|
||||
"range": {
|
||||
"type": "string",
|
||||
"description": "Acceptable versions: semver range or tag regex (feeds the Flux ImagePolicy)."
|
||||
},
|
||||
"cadence": {
|
||||
"type": "string",
|
||||
"description": "How often to check/apply (the schedule; scan interval)."
|
||||
},
|
||||
"autoRollbackOnFailedHealth": {
|
||||
"type": "boolean",
|
||||
"description": "Revert automatically on failed health gates (R-UPD-03).",
|
||||
"default": true
|
||||
},
|
||||
"pin": {
|
||||
"type": "string",
|
||||
"description": "Operator freeze escape hatch: a fixed version that stops automation for this service."
|
||||
}
|
||||
}
|
||||
},
|
||||
"observability": {
|
||||
"type": "object",
|
||||
"description": "Observability intent; rendered to ServiceMonitor/PodMonitor + log scraping + dashboards + alerts (docs/10; R-OBS-01).",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"metrics": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the platform scrapes metrics for this service.",
|
||||
"default": true
|
||||
},
|
||||
"slis": {
|
||||
"type": "array",
|
||||
"description": "Per-service alert intents (e.g. up). Each becomes at least one alert rule (R-OBS-01).",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"default": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"type": "object",
|
||||
"description": "Backup posture (docs/04; R-STATE-03). Irreplaceable state MUST be backed up off-cluster; bulk media may be 'reacquirable'.",
|
||||
"additionalProperties": false,
|
||||
"required": ["policy"],
|
||||
"properties": {
|
||||
"policy": {
|
||||
"type": "string",
|
||||
"description": "Reference to an off-cluster backup schedule, or the literal 'reacquirable' for bulk media that is not backed up 1:1.",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$comment": "kind determines which source field is required (docs/13). Absent kind defaults to container -> image.",
|
||||
"if": {
|
||||
"required": ["kind"],
|
||||
"properties": { "kind": { "const": "vm" } }
|
||||
},
|
||||
"then": {
|
||||
"properties": { "source": { "required": ["vmTemplate"] } }
|
||||
},
|
||||
"else": {
|
||||
"properties": { "source": { "required": ["image"] } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"$comment": "oidc/forward-auth require at least one authorized group (R-IDP-06; docs/15 acceptance gate: restricted to the allowed group).",
|
||||
"if": {
|
||||
"required": ["auth"],
|
||||
"properties": {
|
||||
"auth": {
|
||||
"required": ["mode"],
|
||||
"properties": { "mode": { "enum": ["oidc", "forward-auth"] } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"auth": {
|
||||
"required": ["groups"],
|
||||
"properties": { "groups": { "minItems": 1 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user