fix(gitea): mount PVC at /data + headless-install bootstrap (Phase 6)

Two defects blocked in-cluster Gitea from becoming the Flux source:

1. Storage mountPath: the renderer hard-coded /data/<name>, 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/<name>, 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 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-25 18:07:56 -05:00
1 parent 9f52f195a7
commit 73c4475d34
7 files changed
+143 -3

No files matched your search

+88
View File
@@ -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 "[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'"
+36
View File
@@ -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
+2 -2
View File
@@ -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:
+5
View File
@@ -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
+2
View File
@@ -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/<name> 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
+5 -1
View File
@@ -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/<name>; 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/<name> 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"],
+5
View File
@@ -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/<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).",