Extend the ServiceDefinition contract with optional per-container `env`, `envFrom`, and `lifecycle` fields (k8s EnvVar / EnvFromSource / Lifecycle shapes) so headless-install / DB-backed apps configure themselves through the SD instead of a supplementary kustomize patch. - schema: bump 1.1.0 -> 1.2.0; add optional env/envFrom/lifecycle to the container kind (literal + secretKeyRef/configMapKeyRef; secretRef/ configMapRef; postStart/preStop exec via $defs/execHandler). Existing fields unchanged; all fields optional -> absent renders byte-identical. - render.py: emit env/envFrom/lifecycle verbatim onto the Deployment app container when present; --selftest coverage for the new fields. - catalog/gitea.yaml: fold the headless-install env (INSTALL_LOCK, fixed SECRET_KEY via secretKeyRef, DOMAIN/ROOT_URL, actions, break-glass admin creds) + the idempotent postStart break-glass admin bootstrap inline. - apps/gitea.yaml: re-rendered with env/lifecycle inline (effective pod spec provably equivalent to base+patch). - DELETE apps/gitea-bootstrap-patch.yaml + drop it from apps/kustomization patches. gitea-bootstrap-secret.sops.yaml kept (still sourced by the env). Proves the renderer feature by retiring the known TODO; unblocks env + DB onboarding for nextcloud/semaphore/unifi/minecraft. All other rendered apps byte-identical. Gates: selftest PASS, l0 17/17 + schema, l1 goldens, lint. Co-Authored-By: Claude Opus 4.8 <[email protected]>
379 lines
15 KiB
JSON
379 lines
15 KiB
JSON
{
|
|
"$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.2.0 (adds optional per-container env/envFrom/lifecycle for the container kind — k8s EnvVar/EnvFromSource/Lifecycle shapes emitted verbatim onto the app container; unblocks headless-install/DB-backed apps e.g. gitea/nextcloud/semaphore/unifi/minecraft). 1.1.0 added the shape invariant exposure:public => auth.mode!=none (docs/13 v1.1 / docs/15 / R-IDP-02).",
|
|
"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
|
|
}
|
|
}
|
|
},
|
|
"env": {
|
|
"type": "array",
|
|
"description": "Optional per-container environment variables for the container kind (docs/13). Mirrors the k8s EnvVar shape and is emitted verbatim onto the app container: each entry is either a literal {name, value} or a {name, valueFrom: {secretKeyRef|configMapKeyRef}} sourced from a Secret/ConfigMap key. Absent => no env (existing apps render byte-identical). Ignored for kind=vm (VMs configure via cloud-init).",
|
|
"default": [],
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name"],
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Environment variable name (C_IDENTIFIER).",
|
|
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
|
|
},
|
|
"value": {
|
|
"type": "string",
|
|
"description": "Literal value. Mutually exclusive with valueFrom."
|
|
},
|
|
"valueFrom": {
|
|
"type": "object",
|
|
"description": "Source the value from a Secret or ConfigMap key (exactly one of secretKeyRef/configMapKeyRef).",
|
|
"additionalProperties": false,
|
|
"minProperties": 1,
|
|
"maxProperties": 1,
|
|
"properties": {
|
|
"secretKeyRef": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name", "key"],
|
|
"properties": {
|
|
"name": { "type": "string", "minLength": 1 },
|
|
"key": { "type": "string", "minLength": 1 }
|
|
}
|
|
},
|
|
"configMapKeyRef": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name", "key"],
|
|
"properties": {
|
|
"name": { "type": "string", "minLength": 1 },
|
|
"key": { "type": "string", "minLength": 1 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"oneOf": [
|
|
{ "required": ["value"], "not": { "required": ["valueFrom"] } },
|
|
{ "required": ["valueFrom"], "not": { "required": ["value"] } }
|
|
]
|
|
}
|
|
},
|
|
"envFrom": {
|
|
"type": "array",
|
|
"description": "Optional bulk env sources for the container kind. Mirrors the k8s EnvFromSource shape and is emitted verbatim onto the app container: each entry pulls ALL keys from a Secret or ConfigMap (exactly one of secretRef/configMapRef). Absent => no envFrom (existing apps render byte-identical). Ignored for kind=vm.",
|
|
"default": [],
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"minProperties": 1,
|
|
"maxProperties": 1,
|
|
"properties": {
|
|
"secretRef": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name"],
|
|
"properties": { "name": { "type": "string", "minLength": 1 } }
|
|
},
|
|
"configMapRef": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name"],
|
|
"properties": { "name": { "type": "string", "minLength": 1 } }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"lifecycle": {
|
|
"type": "object",
|
|
"description": "Optional container lifecycle hooks for the container kind (docs/13). Mirrors the k8s Lifecycle shape and is emitted verbatim onto the app container. Used for idempotent break-glass/bootstrap steps (e.g. gitea's postStart exec that seeds a local admin). Absent => no lifecycle (existing apps render byte-identical). Ignored for kind=vm.",
|
|
"additionalProperties": false,
|
|
"minProperties": 1,
|
|
"properties": {
|
|
"postStart": { "$ref": "#/$defs/execHandler" },
|
|
"preStop": { "$ref": "#/$defs/execHandler" }
|
|
}
|
|
},
|
|
"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 by Cilium Egress Gateway out the egress-gateway node's host PIA VPN tunnel, with a mandatory host kill-switch (ADR-0015; 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)$"
|
|
},
|
|
"mountPath": {
|
|
"type": "string",
|
|
"description": "Optional explicit absolute container mount path for this volume. Default is /data/<name>. Set it when the app expects a FIXED on-disk layout the default cannot express (e.g. Gitea keeps all state directly under /data). Renderer honors this verbatim; omitting it preserves the /data/<name> default for back-compat (R-STATE-01).",
|
|
"pattern": "^/.*[^/]$"
|
|
},
|
|
"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 } }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"$comment": "public exposure must not be unauthenticated: an explicit auth.mode 'none' is disallowed on public services (docs/13 v1.1, docs/15, R-IDP-02). Absent auth defaults to forward-auth at render time, so it is permitted here.",
|
|
"if": {
|
|
"required": ["exposure"],
|
|
"properties": { "exposure": { "const": "public" } }
|
|
},
|
|
"then": {
|
|
"properties": {
|
|
"auth": {
|
|
"properties": { "mode": { "not": { "const": "none" } } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
],
|
|
"$defs": {
|
|
"execHandler": {
|
|
"type": "object",
|
|
"description": "A container lifecycle hook. Only the exec form is supported (a command run inside the container); mirrors k8s LifecycleHandler.exec.",
|
|
"additionalProperties": false,
|
|
"required": ["exec"],
|
|
"properties": {
|
|
"exec": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["command"],
|
|
"properties": {
|
|
"command": {
|
|
"type": "array",
|
|
"description": "Command line to execute inside the container. Not run in a shell unless the command itself invokes one.",
|
|
"minItems": 1,
|
|
"items": { "type": "string" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|