--- # Namespace (trust zone; default-deny + policies apply) apiVersion: v1 kind: Namespace metadata: name: gitea labels: homelab.keithjackson.dev/app-namespace: 'true' gateway-access: shared annotations: homelab.keithjackson.dev/backup-policy: daily-offcluster --- # Default-deny baseline (R-NET-01): deny all, allow only DNS apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny namespace: gitea labels: homelab.keithjackson.dev/baseline: default-deny spec: podSelector: {} policyTypes: - Ingress - Egress ingress: [] egress: - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system ports: - protocol: UDP port: 53 - protocol: TCP port: 53 --- # PVC gitea-data (R-STATE) apiVersion: v1 kind: PersistentVolumeClaim metadata: name: gitea-data namespace: gitea labels: app.kubernetes.io/name: gitea spec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 10Gi --- # Deployment (R-PLAT-02) apiVersion: apps/v1 kind: Deployment metadata: name: gitea namespace: gitea labels: app.kubernetes.io/name: gitea spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: gitea template: metadata: labels: app.kubernetes.io/name: gitea homelab.keithjackson.dev/egress-class: internet homelab.keithjackson.dev/auth-mode: oidc annotations: homelab.keithjackson.dev/auth-mode: oidc homelab.keithjackson.dev/auth-groups: platform-admins spec: containers: - name: gitea image: gitea/gitea:1.24.3 ports: - containerPort: 3000 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.${BASE_DOMAIN} - name: GITEA__server__ROOT_URL value: https://gitea.${BASE_DOMAIN}/ - name: GITEA__actions__ENABLED value: 'true' - name: GITEA__actions__DEFAULT_ACTIONS_URL value: github - name: GITEA_ADMIN_USERNAME valueFrom: secretKeyRef: name: gitea-bootstrap key: admin-username - name: GITEA_ADMIN_PASSWORD valueFrom: secretKeyRef: name: gitea-bootstrap key: admin-password - name: GITEA_OIDC_CLIENT_SECRET valueFrom: secretKeyRef: name: gitea-bootstrap key: oidc-client-secret - name: GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION value: 'true' - name: GITEA__oauth2_client__ACCOUNT_LINKING value: auto - name: GITEA__oauth2_client__USERNAME value: preferred_username volumeMounts: - name: data mountPath: /data lifecycle: postStart: exec: command: - /bin/sh - -c - "# Idempotent break-glass admin bootstrap. Runs alongside the gitea entrypoint\n# (which, with INSTALL_LOCK=true, headless-installs + migrates the DB on first\n# boot). Waits for the DB, then creates the admin only if it does not exist.\nset -u\nexport HOME=/data/git\ni=0\nuntil su-exec git gitea admin user list >/dev/null 2>&1; do\n i=$((i + 1))\n [ \"$i\" -ge 150 ] && { echo \"[gitea-bootstrap] DB not ready after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\nif su-exec git gitea admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx \"$GITEA_ADMIN_USERNAME\"; then\n echo \"[gitea-bootstrap] admin '$GITEA_ADMIN_USERNAME' already present\"\nelse\n n=0\n until su-exec git gitea admin user create \\\n --username \"$GITEA_ADMIN_USERNAME\" \\\n --password \"$GITEA_ADMIN_PASSWORD\" \\\n --email \"$GITEA_ADMIN_USERNAME@home.local\" \\\n --admin --must-change-password=false; do\n n=$((n + 1))\n [ \"$n\" -ge 10 ] && { echo \"[gitea-bootstrap] admin create failed after retries\" >&2; break; }\n sleep 3\n done\n echo \"[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'\"\nfi\n# --- Keycloak OIDC login source (idempotent add-or-update; R-IDP-02/06) ---\n# Registers the confidential `gitea` client as the OAuth2 login source \"keycloak\" against the\n# homelab realm's discovery doc. The `groups` claim maps platform-admins -> Gitea admin\n# (--admin-group). Missing secret => skip (break-glass admin still works), so an unfilled\n# secret never crashes the pod.\n#\n# HARDENED (was a silent first-boot RACE): this step used to fire before the app/network was\n# ready and swallow its failure (|| echo ... >&2, exit 0), so SSO silently never registered.\n# Now it (1) waits \u2014 bounded, ~90s \u2014 for the Keycloak discovery doc to serve HTTP 200,\n# (2) registers (update-in-place if present, else create), (3) VERIFIES the source actually\n# persisted and retries the whole cycle, and (4) on give-up logs LOUDLY to stderr AND a marker\n# file on the persistent /data volume WITHOUT crashlooping the pod (break-glass admin stays\n# usable). The verify-then-retry loop is the real guarantee against \"exit 0 but nothing stuck\".\nGITEA_DISCOVERY_URL=https://keycloak.${BASE_DOMAIN}/realms/homelab/.well-known/openid-configuration\ngitea_oidc_fail() {\n echo \"[gitea-oidc] FAILED to register 'keycloak' login source: $1\" >&2\n { echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) [gitea-oidc] FAILED: $1\"; } >> /data/gitea-oidc-bootstrap.log 2>/dev/null || true\n}\n# NOTE the `$$` escape below \u2014 it is load-bearing. apps-wave-0 declares postBuild.\n# substituteFrom cluster-vars, so Flux envsubst rewrites this whole manifest at RECONCILE\n# time, before any container runs. Written unescaped, `$${GITEA_OIDC_CLIENT_SECRET:-}` is not\n# a cluster-var, so envsubst blanked it and the guard reconciled as `[ -z \"\" ]` \u2014 ALWAYS TRUE\n# \u2014 making every line below (discovery wait, add/update-oauth, verify-then-retry) DEAD CODE on\n# every boot since 8738871 (2026-08-27). SSO kept working only because the login source was\n# already in the DB; a PVC rebuild or fresh cluster would have come up with NO OIDC at all.\n# Flux STRICT substitution cannot catch this \u2014 it EXEMPTS default-operator forms by design.\n# `$${VAR}` is the escape: envsubst emits it back as a plain brace expansion for the shell.\n# Bare `$VAR` and `$(cmd)` are left alone and need NO escaping \u2014 which is why the\n# `$GITEA_OIDC_CLIENT_SECRET` uses below always worked and only the guards broke.\n# Contract: clusters/base/apps-vms.yaml. Enforced at render time by catalog/render.py.\nif [ -z \"$${GITEA_OIDC_CLIENT_SECRET:-}\" ]; then\n echo \"[gitea-oidc] GITEA_OIDC_CLIENT_SECRET unset; skipping OIDC source (break-glass admin only)\"\nelse\n # (1) Wait up to ~90s for the Keycloak discovery doc to be reachable (HTTP 200).\n d=0; disc_ok=0\n while [ \"$d\" -lt 45 ]; do\n if [ \"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \"$GITEA_DISCOVERY_URL\" 2>/dev/null)\" = \"200\" ]; then\n disc_ok=1;\ \ break\n fi\n d=$((d + 1)); sleep 2\n done\n [ \"$disc_ok\" = 1 ] || gitea_oidc_fail \"Keycloak discovery endpoint not reachable (HTTP 200) within timeout\"\n # (2)+(3) Register (update-in-place or create) then VERIFY it persisted; retry the whole cycle.\n r=0; reg_ok=0\n while [ \"$r\" -lt 10 ]; do\n oidc_id=$(su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{print $1; exit}')\n if [ -n \"$oidc_id\" ]; then\n su-exec git gitea admin auth update-oauth --id \"$oidc_id\" \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url \"$GITEA_DISCOVERY_URL\" \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins >/dev/null 2>&1 || true\n else\n su-exec git gitea admin auth add-oauth \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url \"$GITEA_DISCOVERY_URL\" \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins >/dev/null 2>&1 || true\n fi\n # VERIFY: the \"keycloak\" source must actually be listed now (guards the exit-0-but-nothing-persisted race).\n if su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{f=1} END{exit !f}'; then\n reg_ok=1; break\n fi\n r=$((r + 1)); sleep 3\n done\n if [ \"$reg_ok\" = 1 ]; then\n echo \"[gitea-oidc] login source 'keycloak' registered and verified\"\n else\n gitea_oidc_fail \"add/update-oauth ran but the 'keycloak' source did not persist after retries\"\n fi\nfi\n# A postStart hook MUST exit 0 \u2014 a non-zero exit makes the kubelet KILL the container\n# (FailedPostStartHook -> CrashLoopBackOff). This hook already ends 0 on both paths, but\n# it is made EXPLICIT so the guarantee does not depend on whatever statement happens to\n# be last. nextcloud's equivalent hook did depend on that and crashlooped on 2026-09-01,\n# the moment its OIDC guard was fixed and the block actually ran for the first time.\nexit 0\n" volumes: - name: data persistentVolumeClaim: claimName: gitea-data --- # Service apiVersion: v1 kind: Service metadata: name: gitea namespace: gitea labels: app.kubernetes.io/name: gitea spec: selector: app.kubernetes.io/name: gitea ports: - name: port-3000 port: 3000 targetPort: 3000 --- # CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): gitea-allow-gateway-ingress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: name: gitea-allow-gateway-ingress namespace: gitea labels: app.kubernetes.io/name: gitea spec: endpointSelector: matchLabels: app.kubernetes.io/name: gitea ingress: - fromEntities: - ingress toPorts: - ports: - port: '3000' protocol: TCP --- # CiliumNetworkPolicy in-cluster client ingress allow (R-NET-02): gitea-allow-flux-ingress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: name: gitea-allow-flux-ingress namespace: gitea labels: app.kubernetes.io/name: gitea spec: endpointSelector: matchLabels: app.kubernetes.io/name: gitea ingress: - fromEndpoints: - matchLabels: k8s:io.kubernetes.pod.namespace: flux-system toPorts: - ports: - port: '3000' protocol: TCP --- # CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): gitea-allow-internet-egress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: name: gitea-allow-internet-egress namespace: gitea labels: app.kubernetes.io/name: gitea spec: endpointSelector: matchLabels: app.kubernetes.io/name: gitea egress: - toEntities: - world - toEndpoints: - matchLabels: io.kubernetes.pod.namespace: keycloak app: keycloak --- # HTTPRoute gitea-3000 (R-TLS/R-DNS) apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: gitea-3000 namespace: gitea labels: app.kubernetes.io/name: gitea annotations: homelab.keithjackson.dev/auth-mode: oidc homelab.keithjackson.dev/auth-groups: platform-admins spec: parentRefs: - name: shared namespace: gateway sectionName: https hostnames: - gitea.${BASE_DOMAIN} rules: - matches: - path: type: PathPrefix value: / backendRefs: - name: gitea port: 3000 --- # PrometheusRule SLI alerts (R-OBS-01) apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: name: gitea-slis namespace: gitea labels: release: kube-prometheus-stack app.kubernetes.io/name: gitea spec: groups: - name: gitea.slis rules: - alert: gitea-down expr: kube_deployment_status_replicas_available{namespace="gitea", deployment="gitea"} == 0 or absent(kube_deployment_status_replicas_available{namespace="gitea", deployment="gitea"}) for: 5m labels: severity: critical service: gitea annotations: summary: gitea is down description: 'gitea: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available).'