Files
home-network-k3s-impl/catalog/gitea.yaml
T
jakman85andClaude Opus 4.8 87388712f0 feat(gitea): wire native Keycloak OIDC SSO (break-glass admin retained)
Wires the bootstrapping-critical Gitea git host to the Keycloak `homelab` realm
as a native confidential OIDC client, moving it off the local-admin-only login
while keeping the local admin as the break-glass path (docs/15 availability
caveat; R-IDP-02/06).

Realm (source of truth): add the `gitea` confidential client to
platform/keycloak/keycloak-realm-import.yaml (modeled on grafana) + its
GITEA_CLIENT_SECRET placeholder sourced from the keycloak-realm-clients Secret.
NOTE: a KeycloakRealmImport no-ops on an already-existing realm, so the live
client was ALSO created via kcadm (id 70a6ca60-317a-42ce-8331-a60e380838ee);
the YAML keeps git authoritative for a rebuild.

Secrets (SOPS/age, never plaintext): add gitea-client-secret to
platform/keycloak/keycloak-realm-clients.sops.yaml (keycloak ns) and mirror the
same value as oidc-client-secret into apps/wave-0/gitea-bootstrap-secret.sops.yaml
(gitea ns) so the app can read it in-namespace. Both == the live client secret.

App config (catalog source + re-render): catalog/gitea.yaml gains the
GITEA_OIDC_CLIENT_SECRET env (from the mirrored Secret), [oauth2_client]
auto-registration/account-linking app.ini env, and an idempotent postStart step
that registers the "keycloak" OAuth2 login source (auto-discovery; groups claim;
platform-admins -> Gitea admin). apps/wave-0/gitea.yaml re-rendered via
`render.py --no-image-automation`.

Verified: realm import Done=True/HasErrors=False and platform-config
Ready=True/Healthy=True (unchanged); edited realm YAML passes server dry-run;
static gate (lint/l0/l1) green. Browser SSO login still needs a human check.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 17:01:46 -05:00

155 lines
9.1 KiB
YAML

# ServiceDefinition: gitea
# Source of truth: spec docs/13-service-catalog.md row 12; docs/15-identity-and-access.md; docs/05.
# public git hosting | egress internet | repos+DB on Longhorn (irreplaceable, DR-critical) | auth oidc.
# Bootstrapping-critical: self-hosts the platform's GitOps source + CI/CD, onboarded EARLY (docs/13
# "Gitea is bootstrapping-critical", WS-APPS step 2, right after PiHole). A cluster loss would take its
# own git host with it, so repos/DB are backed up off-cluster + mirrored (R-STATE-03).
# Single-container Gitea with the built-in SQLite datastore is sufficient at home scale; the repos and
# DB live on one Longhorn PVC (/data), so no external PostgreSQL is required for this onboarding.
# Validates against schemas/servicedefinition.schema.json.
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
# --- Keycloak native OIDC SSO (auth.mode oidc; docs/15 "Native OIDC"; R-IDP-02/06). The confidential
# `gitea` realm client's secret is mirrored into this namespace's gitea-bootstrap Secret (the same
# value SOPS-held in keycloak-realm-clients); the postStart hook registers it as the "keycloak"
# OAuth2 login source. The LOCAL admin above stays as the break-glass path (docs/15). ---
- name: GITEA_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: gitea-bootstrap
key: oidc-client-secret
# app.ini [oauth2_client]: auto-provision a Gitea account on first Keycloak login and link it to an
# existing local account by verified email, so SSO users don't need a pre-created local user.
- 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
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"
else
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; break; }
sleep 3
done
echo "[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'"
fi
# --- Keycloak OIDC login source (idempotent add-or-update; R-IDP-02/06) ---
# Registers the confidential `gitea` client as the OAuth2 login source "keycloak" against the
# homelab realm's discovery doc. The `groups` claim maps the platform-admins group to Gitea
# admin (--admin-group). Auto-discovery pulls auth/token/userinfo/jwks endpoints. Runs on every
# start: updates the existing source if present, else creates it. Missing secret => skip (the
# local break-glass admin above still works), so an unfilled secret never crashes the pod.
if [ -n "${GITEA_OIDC_CLIENT_SECRET:-}" ]; then
oidc_id=$(su-exec git gitea admin auth list 2>/dev/null | awk '$2=="keycloak"{print $1; exit}')
if [ -n "$oidc_id" ]; then
su-exec git gitea admin auth update-oauth --id "$oidc_id" \
--name keycloak --provider openidConnect --key gitea \
--secret "$GITEA_OIDC_CLIENT_SECRET" \
--auto-discover-url https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration \
--scopes "openid email profile" --group-claim-name groups --admin-group platform-admins \
&& echo "[gitea-oidc] updated login source 'keycloak' (id $oidc_id)" \
|| echo "[gitea-oidc] update-oauth failed" >&2
else
su-exec git gitea admin auth add-oauth \
--name keycloak --provider openidConnect --key gitea \
--secret "$GITEA_OIDC_CLIENT_SECRET" \
--auto-discover-url https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration \
--scopes "openid email profile" --group-claim-name groups --admin-group platform-admins \
&& echo "[gitea-oidc] added login source 'keycloak'" \
|| echo "[gitea-oidc] add-oauth failed" >&2
fi
else
echo "[gitea-oidc] GITEA_OIDC_CLIENT_SECRET unset; skipping OIDC source (break-glass admin only)"
fi
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:
- 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
port: 3000 # Gitea HTTP web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
auth:
mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC" set; R-IDP-02)
groups:
- platform-admins # access scoped to an authorized group (R-IDP-06)
# WIRED (native OIDC): the Keycloak `gitea` confidential client (platform/keycloak/keycloak-realm-
# import.yaml + live kcadm) is registered as Gitea's "keycloak" OAuth2 login source by the postStart
# hook, using the client secret mirrored into the gitea-bootstrap Secret (oidc-client-secret; same
# value SOPS-held in keycloak-realm-clients). platform-admins (groups claim) -> Gitea admin. Gitea
# KEEPS a LOCAL admin as the break-glass path so an IdP outage never locks the operator out of
# recovery tools (docs/15 "Availability caveat"). No secret is embedded here.
update:
channel: stable # R-UPD-01
range: ">=1.22.0 <2.0.0" # stay on the 1.x stable line
cadence: "0 4 * * *"
autoRollbackOnFailedHealth: true # R-UPD-03
observability:
metrics: true
slis:
- up # git host down is a critical alert (docs/10; R-OBS-01)
backup:
policy: daily-offcluster # DR-critical: repos/DB backed up off-cluster + mirrored (docs/13, docs/04; R-STATE-03)