Files
home-network-k3s-impl/schemas
jakman85andClaude Opus 5 4080065aa7 feat(dns): exposure: internal can no longer publish a public record
docs/09 R-DNS-03 and schemas/renderer-contract.md both said an `internal`
service renders no public DNS record. Nothing enforced either sentence.
ExternalDNS's `gateway-httproute` source admitted EVERY HTTPRoute on the shared
Gateway, so six `exposure: internal` services — jackett, pihole, qbittorrent,
radarr, semaphore, sonarr — published `<name>.home.keithjackson.dev` into the
public authoritative Cloudflare zone answering with the Gateway VIP
192.168.2.20. That published the internal subnet, the Gateway VIP and a full
service inventory to anyone who could guess a label, and made each of those
names a DNS-rebind primitive — which is why the SOHO router strips RFC1918
answers and LAN clients need per-client dnsmasq overrides to reach their own
services.

THE MECHANISM, established against the DEPLOYED version rather than from
memory. The live prod Deployment runs
registry.k8s.io/external-dns/external-dns:v0.21.0. In v0.21.0,
source/annotations/processors.go IsControllerMismatch() skips a source object
whose `external-dns.alpha.kubernetes.io/controller` annotation is present and
!= its own `dns-controller`, and source/gateway.go applies that check inside
Endpoints() BEFORE any hostname is resolved; source/service.go applies the
identical check, covering the second configured source. catalog/render.py now
emits that annotation on every HTTPRoute it renders for an `internal` service,
derived from `exposure` alone — no per-service list — so a service that omits
`exposure` inherits the internal default and is unpublishable too. Verified
LIVE on prod on a throwaway hostname with no existing record at risk: the
annotated route published nothing, and adding the annotation to a route that
already had a record produced action=DELETE for both its A record and its `a-`
TXT registry entry one ~60s reconcile later, moving nothing else in the zone.

WHY PER-RESOURCE AND NOT A CLUSTER-WIDE OPT-IN FILTER. `labelFilter` is the
stronger default posture and v0.21.0 supports it for both configured sources,
but nine publishing objects here are hand-authored platform/apps manifests with
no `exposure` field, seven of which hold live records (auth, crafty, crafty-mc,
grafana, headlamp, the apex, unifi). An opt-in filter deletes all seven at
once, and whether each should be publicly resolvable is an operator decision
per name. Per-resource exclusion has exactly the blast radius of the defect;
the seven are enumerated in a shrink-only register so the unadjudicated set
cannot grow silently, and the filter stays available as hardening afterwards.

`policy: sync`, `txtOwnerId` and `domainFilters` are UNTOUCHED. Per-resource
exclusion only ever removes an object from this instance's view, so nothing
here makes prod capable of deleting a record it does not own.

WHAT THIS DELETES, and it is not reversible by a revert alone: 12 records — the
A record and the `a-`-prefixed TXT registry entry for each of jackett, pihole,
qbittorrent, radarr, semaphore and sonarr. The zone has no
`*.home.keithjackson.dev` wildcard and `*.keithjackson.dev` does not match two
labels, so each becomes a public NXDOMAIN with no fallback. The complete
pre-state is captured at the authority and committed as a recovery baseline
(.standup-dns/dns-20260914-pre-externaldns-scope.txt), with the restore
procedure for both halves of a record.

MERGE ORDER IS NOT OPTIONAL: feat/pihole-lan-dns-authority must land and be
verified first. Removing these records while the LAN still depends on them,
with the router stripping RFC1918 and no internal authority yet, breaks name
resolution outright.

The five `exposure: public` services are LEFT UNCHANGED and still answer with
the Gateway VIP, so they remain publicly unreachable. That is two open operator
decisions, both written up in platform/externaldns/helmrelease.yaml: what
address they should answer with (136.51.115.252 is the legacy non-k3s stack
serving a placeholder cert, not established as correct for this cluster), and
how to separate them from the non-public names that share the Gateway —
because in v0.21.0 the target annotation is read only from the GATEWAY and a
ListenerSet, never from an HTTPRoute, so a per-service override is not
expressible while they all attach to `shared`. No knob is added for it yet: one
that cannot take effect invites someone to set it and expect one.

Guard: conformance/tests/l0-static/internal-services-publish-no-public-dns, a
whole-repo-scoped L0 static check (no ci/l0.sh stage — the
whole-repo-invariants-wired meta-test forbids one). It asserts both directions
(present on internal, absent on public), the `service`-source half, that the
renderer DERIVES the annotation from `exposure` and cannot emit external-dns's
own claiming value, proves the shipped renderer end-to-end on
internal/public/omitted-field fixtures, and ratchets the non-catalog register.
Proven to fail on the pre-fix tree (9 of 53 assertions, naming all six
services) and pass after (56/56).

Schema 1.6.0 -> 1.7.0: no field changes, a mapping change to `exposure`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 00:43:12 -05:00
..

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.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):

# 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.