feat(catalog): add SD env/envFrom/lifecycle; retire gitea bootstrap patch

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]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-26 01:13:52 -05:00
1 parent b871877c1b
commit c8dd4f3f9d
6 files changed
+278 -107

No files matched your search

+112 -2
View File
@@ -2,7 +2,7 @@
"$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.1.0 (adds shape invariant exposure:public => auth.mode!=none, docs/13 v1.1 / docs/15 / R-IDP-02).",
"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"],
@@ -38,6 +38,93 @@
}
}
},
"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).",
@@ -264,5 +351,28 @@
}
}
}
]
],
"$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" }
}
}
}
}
}
}
}