Phase 2 of making LAN DNS survive a node failure. Pihole was one pod on one node; measured 2026-10-04, losing that node meant roughly 6-10 minutes with no LAN resolution (tolerationSeconds 300 + NotReady detection + a Longhorn RWO force-detach), while losing the node holding the pihole-dns L2 lease -- a DIFFERENT node -- meant tens of seconds. Two of three nodes were single points of failure with wildly different blast radii. Adds `availability.replicas` to the ServiceDefinition schema (1.10.0). Intent only; the mechanism is render.py's, documented in the renderer contract. 1 or absent renders a Deployment exactly as before -- verified byte-identical across the whole apps/ tree. >1 renders a StatefulSet (a longhorn PVC is RWO, so a multi-replica Deployment never schedules the second pod), per-replica volumeClaimTemplates, REQUIRED hostname anti-affinity, podManagementPolicy Parallel, a minAvailable:1 PodDisruptionBudget, and an SLI that follows the rendered kind (a Deployment-shaped rule against a StatefulSet absent()s forever). Pinned by ~20 new --selftest assertions rather than a conformance change. ALSO FIXES A LIVE BUG. Pihole's config PVC was mounted at the renderer default /data/config, but pihole writes to /etc/pihole. Measured: /etc/pihole (7.1M -- gravity.db, pihole.toml, query DB) was on the OVERLAY, while the 2Gi Longhorn volume held lost+found and 24K of nothing. So every restart discarded all state and re-downloaded 72k gravity domains, and backup.policy has been backing up an empty volume while reporting success. Fixed here because otherwise the new per-replica volumes would hold nothing while state stayed ephemeral -- and because persisting gravity.db is what makes a restart cheap. The /etc/pihole mount is safe on the image's own evidence: start.sh is PID 1 as uid 0 and chowns the directory recursively (no fsGroup needed), and it calls migrate_gravity which creates the database when absent. The gate caught a real regression on the first run: the wave-0 alert unit test fed pihole a Deployment-shaped series. Updated; promtool SUCCESS. Touches no sensitive paths, so this one needs no override. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
schemas — the ServiceDefinition contract
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/; 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. 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.8.0 (in the schema description, which carries the full per-version changelog — this line had said 1.0.0 since 1.1.0 and is now synced). 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(withsource.imageforkind: container,source.vmTemplateforkind: 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 — seerenderer-contract.md§"Default posture".
Cross-field rules the schema enforces
kind: vm⇒source.vmTemplaterequired; otherwisesource.imagerequired.auth.mode: oidc | forward-auth⇒auth.groupspresent and non-empty (R-IDP-06 — access is "in an authorized group," not merely authenticated).ingressRoutes[].hostmust match<label>.home.keithjackson.dev.storage[].class ∈ {longhorn, bulk};exposure ∈ {internal, public};egress ∈ {internet, vpn};auth.mode ∈ {oidc, forward-auth, native, none}.hardware.gpu ∈ {intel-quicksync}— a host capability class, never a device path. The enum is the contract: the mechanism that satisfies it lives only incatalog/render.py(seerenderer-contract.md§hardware).additionalProperties: falseeverywhere — 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):
# 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):
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/keycloak.yaml.