From 73c4475d34f44a03681ec33102c77b683272f069 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Tue, 25 Aug 2026 18:07:56 -0500 Subject: [PATCH] fix(gitea): mount PVC at /data + headless-install bootstrap (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects blocked in-cluster Gitea from becoming the Flux source: 1. Storage mountPath: the renderer hard-coded /data/, so Gitea's PVC mounted at /data/data while Gitea writes all state to /data — real repos/DB landed on the ephemeral rootfs. Add an OPTIONAL `mountPath` to the storage schema + render.py (default stays /data/, back-compat). catalog/gitea.yaml sets data.mountPath: /data. Only gitea.yaml's rendered mount changes; every other app renders byte-identical. 2. Headless install: the instance was un-installed (INSTALL_LOCK=false, ephemeral SECRET_KEY, no admin, API 404). A Gitea-only supplementary manifest (apps/gitea-bootstrap-patch.yaml) patches the rendered Deployment with GITEA__security__INSTALL_LOCK=true, a FIXED SECRET_KEY from a persistent SOPS secret (apps/gitea-bootstrap-secret.sops.yaml), ROOT_URL/DOMAIN, and a postStart hook that idempotently creates a break-glass local admin. State persists on the now-correct /data Longhorn PVC. The env/lifecycle wiring is a supplementary kustomize patch because the ServiceDefinition schema/renderer does not yet express env/envFrom/lifecycle — noted as a renderer follow-up (TODO in the patch). Co-Authored-By: Claude Opus 4.8 --- apps/gitea-bootstrap-patch.yaml | 88 +++++++++++++++++++++++++++ apps/gitea-bootstrap-secret.sops.yaml | 36 +++++++++++ apps/gitea.yaml | 4 +- apps/kustomization.yaml | 5 ++ catalog/gitea.yaml | 2 + catalog/render.py | 6 +- schemas/servicedefinition.schema.json | 5 ++ 7 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 apps/gitea-bootstrap-patch.yaml create mode 100644 apps/gitea-bootstrap-secret.sops.yaml diff --git a/apps/gitea-bootstrap-patch.yaml b/apps/gitea-bootstrap-patch.yaml new file mode 100644 index 0000000..ab11d0f --- /dev/null +++ b/apps/gitea-bootstrap-patch.yaml @@ -0,0 +1,88 @@ +--- +# Gitea headless-install bootstrap patch (WS-APPS, Phase 6). +# +# WHY THIS IS A SUPPLEMENTARY MANIFEST (renderer follow-up): +# The ServiceDefinition schema/renderer (catalog/render.py) does not yet express per-container +# `env`, `envFrom`, `lifecycle`, or `initContainers`. Gitea needs all three to come up INSTALLED +# without the interactive web wizard and with a persistent SECRET_KEY. Rather than widen the +# renderer contract for a single app (out of scope for this task), this is a minimal, Gitea-only +# kustomize strategic-merge patch layered onto the rendered apps/gitea.yaml Deployment. It is +# fully GitOps-reconciled (Flux applies it), so nothing here is a manual pod hack Flux would revert. +# TODO(renderer): fold generic `env`/`envFrom`/`lifecycle` support into the ServiceDefinition +# schema + render.py, then delete this patch. +# +# WHAT IT DOES: +# - GITEA__security__INSTALL_LOCK=true -> skip the first-run web installer (headless install). +# - GITEA__security__SECRET_KEY (from the gitea-bootstrap SOPS Secret) -> a FIXED, persistent +# SECRET_KEY. It must NOT regenerate per restart or previously-encrypted data breaks; sourcing +# it from a committed SOPS secret pins it across pod restarts/reschedules. +# - GITEA__server__ROOT_URL / __DOMAIN -> correct public clone/redirect URLs for the git host +# that TLS-terminates at the shared Gateway (gitea.home.keithjackson.dev). +# - A postStart lifecycle hook creates a BREAK-GLASS local admin idempotently (only if absent), +# so an IdP outage never locks the operator out of recovery tools (docs/15 availability caveat). +# All state (repos, SQLite DB, app.ini, INTERNAL_TOKEN) lands on the /data Longhorn PVC. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gitea + namespace: gitea +spec: + template: + spec: + containers: + - name: gitea + env: + - name: GITEA__security__INSTALL_LOCK + value: "true" + - name: GITEA__security__SECRET_KEY + valueFrom: + secretKeyRef: + name: gitea-bootstrap + key: secret-key + - name: GITEA__server__DOMAIN + value: gitea.home.keithjackson.dev + - name: GITEA__server__ROOT_URL + value: https://gitea.home.keithjackson.dev/ + - name: GITEA_ADMIN_USERNAME + 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 "$GITEA_ADMIN_USERNAME@home.local" \ + --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'" diff --git a/apps/gitea-bootstrap-secret.sops.yaml b/apps/gitea-bootstrap-secret.sops.yaml new file mode 100644 index 0000000..60182ce --- /dev/null +++ b/apps/gitea-bootstrap-secret.sops.yaml @@ -0,0 +1,36 @@ +# Gitea headless-install bootstrap secret (WS-APPS, Phase 6). SOPS-encrypted (age). +# secret-key: FIXED, persistent Gitea SECRET_KEY (must not regenerate per restart). +# admin-*: break-glass local admin (docs/15 availability caveat). Recover with: +# infra/devcontainer/dev.sh run sops -d apps/gitea-bootstrap-secret.sops.yaml +apiVersion: v1 +kind: Secret +metadata: + name: gitea-bootstrap + namespace: gitea + labels: + app.kubernetes.io/name: gitea +type: Opaque +stringData: + secret-key: ENC[AES256_GCM,data:N4wgSipwTYNgkwujPzGs8Bo63IzUip1TwfVq9fG+Z2nECrgIV9PYkEyL4ic3Ej9f5udOE4fHwsbgcyuiJw==,iv:p33bef3z2fDbaAi6beGF+CWmeTeS7Vn46ce1/2zQH4g=,tag:CLJ+kZbmz0tiPtOsI3+dIg==,type:str] + admin-username: ENC[AES256_GCM,data:hyWYQ3UC8c9H2Os=,iv:bgn72bsILLxChY9QAsAD3tYWvsWS2CBe0FlneCCSr+E=,tag:7qP/P8TjDmRFsuXjJF8A2g==,type:str] + admin-password: ENC[AES256_GCM,data:GPz1vracB1U6YurrV1AEhX9VEGx3lwCOXqOmKAg6+/6NjGPmTv+r2uosawVYtwie,iv:L/QKFqo7RpSg4eQGie/+R5BDBgTiTzwDdWuofJeebFE=,tag:cD6AU9tahLskTaaU/A7BOA==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1bXVWK2sxUDNyZ0UvcFhW + MDZ2akY3ditycjdQN0FSM2JYcXZod2lqS0F3ClF6T2pOdmxNZ3JwSjdLeDBBYmR4 + QU52bWlDdGVBZkM2ZW1BNkRFSjVpcTQKLS0tIHdYS1VQaE5mSUhXR1U4UktyM1Jm + Nys4V2NBZWF6U2lER05HV3FNZ1pOQ1kKEVHBTPTAJfiQBzkP/QezTzOfS96nYzxg + Ad1m50j67vyFaBCVAb0/0toEUAm+qEV4wzOglaNmZuScavY4ntwCIw== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-08-25T23:06:33Z" + mac: ENC[AES256_GCM,data:ik735BfSMdlB4IUceScEfY4aNNNo61po7W7x/6iod1VvsiMo0+7nUrVqD2uiL8p066k6g/hV+BNg5BwnYRVjvkVm8tU60ctcjEDfinxtgnqRNBVK81RROn5GtsRCzuBxiJ5J8ulgb45+jG9ND6Xuo9XuTZ35PWs/4/Ebdl/jpPM=,iv:Nt3QtDz3S37AnrtG2nd1TIHgFFemgxXrKrHfXTVo6S4=,tag:ScMtwQAF8a4heyhN+iXt/A==,type:str] + pgp: [] + encrypted_regex: ^(data|stringData)$ + version: 3.9.4 diff --git a/apps/gitea.yaml b/apps/gitea.yaml index a4b1c86..a562e4d 100644 --- a/apps/gitea.yaml +++ b/apps/gitea.yaml @@ -81,7 +81,7 @@ spec: - containerPort: 3000 volumeMounts: - name: data - mountPath: /data/data + mountPath: /data volumes: - name: data persistentVolumeClaim: @@ -123,7 +123,7 @@ spec: - port: '3000' protocol: TCP --- -# CiliumNetworkPolicy internet/world egress (internet class, R-NET-05): gitea-allow-internet-egress +# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): gitea-allow-internet-egress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: diff --git a/apps/kustomization.yaml b/apps/kustomization.yaml index 6aa25be..eb3837d 100644 --- a/apps/kustomization.yaml +++ b/apps/kustomization.yaml @@ -8,8 +8,13 @@ kind: Kustomization resources: - pihole.yaml - gitea.yaml + - gitea-bootstrap-secret.sops.yaml # SOPS: fixed SECRET_KEY + break-glass admin creds (decrypted by Flux) - sonarr.yaml - radarr.yaml - plex.yaml - qbittorrent.yaml - jackett.yaml +# Gitea-only supplementary patch: headless-install env + break-glass admin postStart hook. +# The renderer does not yet emit env/lifecycle (renderer follow-up); see gitea-bootstrap-patch.yaml. +patches: + - path: gitea-bootstrap-patch.yaml diff --git a/catalog/gitea.yaml b/catalog/gitea.yaml index ddb9fea..57b040a 100644 --- a/catalog/gitea.yaml +++ b/catalog/gitea.yaml @@ -17,6 +17,8 @@ storage: - name: data size: 10Gi class: longhorn # repos + SQLite DB on 3-replica Longhorn; irreplaceable (docs/04; R-STATE-02) + mountPath: /data # Gitea keeps ALL state (repos, DB, app.ini, INTERNAL_TOKEN) directly under /data; + # the default /data/ would strand real state on the ephemeral rootfs (R-STATE-01) dependencies: [] # default-deny: reaches only DNS; git clients reach it north-south via the Gateway (R-NET-01/02) ingressRoutes: - host: gitea.home.keithjackson.dev diff --git a/catalog/render.py b/catalog/render.py index 203def9..de0753f 100644 --- a/catalog/render.py +++ b/catalog/render.py @@ -220,7 +220,11 @@ def r_deployment(s: dict) -> dict: volume_mounts, volumes = [], [] for vol in s["storage"]: - volume_mounts.append({"name": vol["name"], "mountPath": f"/data/{vol['name']}"}) + # Default mount is /data/; an explicit `mountPath` (schema-optional) overrides + # it verbatim for apps that need a fixed on-disk layout (e.g. Gitea keeps all state + # directly under /data). Omitting it preserves the /data/ default (back-compat). + mount_path = vol.get("mountPath", f"/data/{vol['name']}") + volume_mounts.append({"name": vol["name"], "mountPath": mount_path}) volumes.append( { "name": vol["name"], diff --git a/schemas/servicedefinition.schema.json b/schemas/servicedefinition.schema.json index 122e033..e521cc9 100644 --- a/schemas/servicedefinition.schema.json +++ b/schemas/servicedefinition.schema.json @@ -69,6 +69,11 @@ "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/. 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/ 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).",