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:
1 parent
b871877c1b
commit
c8dd4f3f9d
6 files changed
+278
-107
No files matched your search
@@ -11,6 +11,69 @@ name: gitea
|
||||
kind: container
|
||||
source:
|
||||
image: gitea/gitea:1.24.3 # pinned recent stable tag (R-UPD-01); update.range governs advancement
|
||||
# Headless-install bootstrap (formerly apps/gitea-bootstrap-patch.yaml; now first-class via the
|
||||
# ServiceDefinition env/envFrom/lifecycle fields, schema >=1.2.0). Gitea must come up INSTALLED
|
||||
# without the interactive web wizard and with a FIXED, persistent SECRET_KEY, so all of this is
|
||||
# declared here and rendered verbatim onto the app container by catalog/render.py.
|
||||
env:
|
||||
- name: GITEA__security__INSTALL_LOCK # skip the first-run web installer (headless install)
|
||||
value: "true"
|
||||
- name: GITEA__security__SECRET_KEY # FIXED persistent key; MUST NOT regenerate per restart or
|
||||
valueFrom: # previously-encrypted data breaks. Pinned via the SOPS Secret.
|
||||
secretKeyRef:
|
||||
name: gitea-bootstrap
|
||||
key: secret-key
|
||||
- name: GITEA__server__DOMAIN # correct public clone/redirect URLs for the TLS-terminated host
|
||||
value: gitea.home.keithjackson.dev
|
||||
- name: GITEA__server__ROOT_URL
|
||||
value: https://gitea.home.keithjackson.dev/
|
||||
- name: GITEA__actions__ENABLED # Phase 6 CI/CD: declared (not implicit upstream default) so the
|
||||
value: "true" # in-cluster act_runner (ns gitea-actions) registers against it
|
||||
- name: GITEA__actions__DEFAULT_ACTIONS_URL # resolve `uses: <org>/<action>@ref` from github.com (cluster
|
||||
value: github # has internet egress; not air-gapped)
|
||||
- name: GITEA_ADMIN_USERNAME # break-glass local admin creds (consumed by the postStart hook)
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gitea-bootstrap
|
||||
key: admin-username
|
||||
- name: GITEA_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gitea-bootstrap
|
||||
key: admin-password
|
||||
lifecycle:
|
||||
postStart:
|
||||
exec:
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
# Idempotent break-glass admin bootstrap. Runs alongside the gitea entrypoint
|
||||
# (which, with INSTALL_LOCK=true, headless-installs + migrates the DB on first
|
||||
# boot). Waits for the DB, then creates the admin only if it does not exist.
|
||||
set -u
|
||||
export HOME=/data/git
|
||||
i=0
|
||||
until su-exec git gitea admin user list >/dev/null 2>&1; do
|
||||
i=$((i + 1))
|
||||
[ "$i" -ge 150 ] && { echo "[gitea-bootstrap] DB not ready after wait; skipping" >&2; exit 0; }
|
||||
sleep 2
|
||||
done
|
||||
if su-exec git gitea admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx "$GITEA_ADMIN_USERNAME"; then
|
||||
echo "[gitea-bootstrap] admin '$GITEA_ADMIN_USERNAME' already present"
|
||||
exit 0
|
||||
fi
|
||||
n=0
|
||||
until su-exec git gitea admin user create \
|
||||
--username "$GITEA_ADMIN_USERNAME" \
|
||||
--password "$GITEA_ADMIN_PASSWORD" \
|
||||
--email "[email protected]" \
|
||||
--admin --must-change-password=false; do
|
||||
n=$((n + 1))
|
||||
[ "$n" -ge 10 ] && { echo "[gitea-bootstrap] admin create failed after retries" >&2; exit 0; }
|
||||
sleep 3
|
||||
done
|
||||
echo "[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'"
|
||||
exposure: public # edge-forwarded git host at gitea.home.keithjackson.dev (docs/09; R-DNS/R-NET)
|
||||
egress: internet # normal WAN egress (docs/03; R-NET-05)
|
||||
storage:
|
||||
|
||||
@@ -218,6 +218,16 @@ def r_deployment(s: dict) -> dict:
|
||||
if ports:
|
||||
container["ports"] = [{"containerPort": p["port"]} for p in ports]
|
||||
|
||||
# env / envFrom (schema-optional): the k8s EnvVar / EnvFromSource lists are emitted
|
||||
# VERBATIM onto the app container. This is what lets a headless-install/DB-backed app
|
||||
# (gitea, nextcloud, semaphore, unifi, minecraft) configure itself and pull a fixed
|
||||
# secret (e.g. gitea's SECRET_KEY via secretKeyRef) without a supplementary patch.
|
||||
# Absent => the key is never set, so apps that declare no env render byte-identical.
|
||||
if s.get("env"):
|
||||
container["env"] = s["env"]
|
||||
if s.get("envFrom"):
|
||||
container["envFrom"] = s["envFrom"]
|
||||
|
||||
volume_mounts, volumes = [], []
|
||||
for vol in s["storage"]:
|
||||
# Default mount is /data/<name>; an explicit `mountPath` (schema-optional) overrides
|
||||
@@ -234,6 +244,12 @@ def r_deployment(s: dict) -> dict:
|
||||
if volume_mounts:
|
||||
container["volumeMounts"] = volume_mounts
|
||||
|
||||
# lifecycle hooks (schema-optional): the k8s Lifecycle (postStart/preStop exec) is
|
||||
# emitted VERBATIM. Used for idempotent break-glass/bootstrap steps (e.g. gitea's
|
||||
# postStart exec that seeds a local admin). Absent => byte-identical for other apps.
|
||||
if s.get("lifecycle"):
|
||||
container["lifecycle"] = s["lifecycle"]
|
||||
|
||||
pod_spec = {"containers": [container]}
|
||||
if volumes:
|
||||
pod_spec["volumes"] = volumes
|
||||
@@ -1100,6 +1116,55 @@ def _selftest() -> int:
|
||||
check("VirtualMachine" in vm_kinds, "vm: missing VirtualMachine")
|
||||
check("DataVolume" in vm_kinds, "vm: missing CDI DataVolume")
|
||||
|
||||
# 6) env / envFrom / lifecycle -> emitted VERBATIM onto the app container (T-APPS).
|
||||
envd_docs = render(
|
||||
{
|
||||
"name": "envd",
|
||||
"source": {"image": "a:1"},
|
||||
"env": [
|
||||
{"name": "PLAIN", "value": "literal"},
|
||||
{
|
||||
"name": "SECRET",
|
||||
"valueFrom": {"secretKeyRef": {"name": "sec", "key": "k"}},
|
||||
},
|
||||
],
|
||||
"envFrom": [{"secretRef": {"name": "bulk"}}],
|
||||
"lifecycle": {
|
||||
"postStart": {"exec": {"command": ["/bin/sh", "-c", "echo hi"]}}
|
||||
},
|
||||
}
|
||||
)
|
||||
ec = next(
|
||||
d[1] for d in envd_docs if d[1]["kind"] == "Deployment"
|
||||
)["spec"]["template"]["spec"]["containers"][0]
|
||||
check(
|
||||
ec.get("env") == [
|
||||
{"name": "PLAIN", "value": "literal"},
|
||||
{"name": "SECRET", "valueFrom": {"secretKeyRef": {"name": "sec", "key": "k"}}},
|
||||
],
|
||||
"env must render verbatim onto the container (literal + secretKeyRef)",
|
||||
)
|
||||
check(
|
||||
ec.get("envFrom") == [{"secretRef": {"name": "bulk"}}],
|
||||
"envFrom must render verbatim onto the container",
|
||||
)
|
||||
check(
|
||||
ec.get("lifecycle")
|
||||
== {"postStart": {"exec": {"command": ["/bin/sh", "-c", "echo hi"]}}},
|
||||
"lifecycle must render verbatim onto the container",
|
||||
)
|
||||
# Absence => the keys are never set (existing apps stay byte-identical).
|
||||
plain_c = next(
|
||||
d[1] for d in render({"name": "plain", "source": {"image": "a:1"}})
|
||||
if d[1]["kind"] == "Deployment"
|
||||
)["spec"]["template"]["spec"]["containers"][0]
|
||||
check(
|
||||
"env" not in plain_c
|
||||
and "envFrom" not in plain_c
|
||||
and "lifecycle" not in plain_c,
|
||||
"an SD without env/envFrom/lifecycle must not gain those container keys",
|
||||
)
|
||||
|
||||
if failures:
|
||||
for f in failures:
|
||||
sys.stderr.write(f"SELFTEST FAIL: {f}\n")
|
||||
|
||||
Reference in new issue
Block a user