A platform controller that dials a service's ClusterIP directly had NO authorable
form in the ServiceDefinition contract:
* ingressRoutes renders the north-south allow only — gateway-ingress admits
`fromEntities: [ingress]`, the Gateway's Envoy identity, and nothing else;
* dependencies compiles edges only BETWEEN CATALOG SERVICES, from the source
entry's own declaration.
Under the default-deny baseline (R-NET-01) such a client is therefore DROPPED, and
the only way to unblock it was a live `kubectl apply` of a hand-written CNP — a
cluster object outside git, one `kubectl delete` or one rebuild from coming back.
`inClusterClients: [{namespace, port, protocol?, name?}]` closes that. Each entry
renders ONE least-privilege ingress CiliumNetworkPolicy: this service's
endpointSelector, `fromEndpoints` matching ONLY `k8s:io.kubernetes.pod.namespace`,
`toPorts` carrying ONLY the declared port/protocol — never `fromEntities: all`.
Additive: the namespace default-deny keeps denying everything undeclared (R-NET-02),
and the entry is NOT a dependency edge, so it creates no wave ordering.
Shape notes:
* selected by NAMESPACE, not pod labels: an upstream controller's labels belong to
its chart, not to this platform's contract. The cost is explicit — every pod in
that namespace is admitted on that port, so the field is for platform-controller
namespaces (flux-system, monitoring), never a shared one. The schema description
says so.
* optional `name` (default: the namespace) only names the rendered policy,
`<service>-allow-<name>-ingress`. That name is operator-facing (kubectl get cnp,
a Hubble drop), and pinning it lets an allowance that already had to be applied
live be authored under its EXISTING name, so Flux adopts the live object in place
instead of creating a near-duplicate and leaving the original unmanaged.
* one field, not a policy DSL: no arbitrary selectors, entities or CIDRs.
Covered by render.py --selftest (4c): name/namespace/selector/port shape, no
fromEntities widening, default-deny still `ingress: []`, the gateway allow not
displaced, `name` defaulting to the namespace, and nothing rendered when the field is
absent. No golden changes here — no catalog entry uses the field yet.
Co-Authored-By: Claude Opus 5 <[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.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(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}.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.