Add k8s Gitea deployment (HA-shaped, migration target for git.unkin.net)

Stand up the forge on k8s to replace the Puppet VM. Deployed HA-shaped to match
what the VM already runs (multi-replica on shared storage + external DB/cache):
official Gitea chart 12.6.0 (app 1.26.2) at 2 replicas on RWX CephFS, CNPG
Postgres with S3 backup, standalone Valkey for cache/session/queue, Authentik
OIDC, Actions disabled and the container registry moved to artifactapi. Serves a
temporary git2.k8s.syd1.au.unkin.net host; cutover is staged (see the doc).

- add apps/base/gitea (namespace, CNPG cluster+backup+pooler, Valkey, VaultAuth,
  VaultStaticSecrets, Gateway, HTTPRoute)
- add apps/overlays/au-syd1/gitea (chart 12.6.0 via helm-through-kustomize + values,
  drop the chart test Pod)
- register gitea in the platform ApplicationSet and AppProject
- add docs/gitea-migration.md staged cutover plan

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
2026-07-29 22:01:05 +10:00
parent 6a13ca758a
commit e8d46d08f2
18 changed files with 847 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
---
# Ceph RGW (S3) backup target for the gitea CNPG cluster, provisioned by the
# in-estate cephrgw-operator. One dedicated bucket + owner user per cluster.
apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser
metadata:
name: cnpg-gitea-backup
namespace: gitea
spec:
displayName: "CNPG backup owner (gitea)"
# RGW users are global; keep the uid namespace-qualified so it never collides.
uid: cnpg-gitea-backup
maxBuckets: 5
# Operator writes AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ RGW_UID,
# S3_ENDPOINT) into this Secret; the Cluster's barmanObjectStore consumes it.
secretName: cnpg-gitea-backup-s3
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: cnpg-gitea
namespace: gitea
spec:
bucketName: cnpg-gitea
ownerRef: cnpg-gitea-backup
versioning: false
tags:
app: gitea
purpose: cnpg-backup
retainOnDelete: true
---
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: cnpg-gitea-nightly
namespace: gitea
spec:
# 6-field CNPG cron (seconds first). 04:00 — next free slot after netbox
# (03:40), keeping the estate's 20-minute stagger.
schedule: "0 0 4 * * *"
immediate: false
backupOwnerReference: self
method: barmanObjectStore
cluster:
name: gitea-postgres
+90
View File
@@ -0,0 +1,90 @@
---
# Postgres for the k8s Gitea (replaces the Patroni-shared DB the VM uses). Gitea
# already runs on Postgres, so cutover is a plain pg dump/restore (no engine
# conversion). App-user creds come from the postgres-credentials Vault secret.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: gitea-postgres
namespace: gitea
spec:
affinity:
podAntiAffinityType: preferred
backup:
# 30-day retention. Enforced by CNPG against the object store on each
# successful base backup.
retentionPolicy: 30d
barmanObjectStore:
# Dedicated per-cluster Ceph RGW bucket (cephrgw-operator provisions it).
destinationPath: s3://cnpg-gitea
endpointURL: https://s3.ceph.unkin.net
# radosgw serves a Vault-PKI cert; trust the internal CA (reflected into
# every namespace as the vault-ca-cert Secret).
endpointCA:
name: vault-ca-cert
key: ca.crt
# Keys minted by the ObjectStoreUser in cnpg_backup.yaml; never hardcoded.
s3Credentials:
accessKeyId:
name: cnpg-gitea-backup-s3
key: AWS_ACCESS_KEY_ID
secretAccessKey:
name: cnpg-gitea-backup-s3
key: AWS_SECRET_ACCESS_KEY
# Path prefix within the bucket; keep stable across restores (see docs).
serverName: gitea
data:
compression: bzip2
jobs: 2
wal:
compression: zstd
maxParallel: 2
bootstrap:
initdb:
database: gitea
encoding: UTF8
localeCType: C
localeCollate: C
owner: gitea
secret:
name: postgres-credentials
enablePDB: true
enableSuperuserAccess: false
failoverDelay: 0
imageName: ghcr.io/cloudnative-pg/postgresql:18.1-system-trixie
instances: 2
logLevel: info
monitoring:
customQueriesConfigMap:
- key: queries
name: cnpg-default-monitoring
disableDefaultQueries: false
enablePodMonitor: false
postgresql:
parameters:
max_connections: "200"
shared_buffers: 256MB
primaryUpdateMethod: restart
primaryUpdateStrategy: unsupervised
replicationSlots:
highAvailability:
enabled: true
slotPrefix: _cnpg_
synchronizeReplicas:
enabled: true
updateInterval: 30
resources:
limits:
cpu: "2"
memory: 2Gi
requests:
cpu: 250m
memory: 512Mi
smartShutdownTimeout: 180
startDelay: 3600
stopDelay: 1800
storage:
resizeInUseVolumes: true
size: 20Gi
storageClass: cephrbd-fast-delete
switchoverDelay: 3600
+36
View File
@@ -0,0 +1,36 @@
---
# pgbouncer in front of the primary. Gitea opens a connection per request and
# benefits from pooling under multiple app replicas. Session mode keeps Gitea's
# occasional session-scoped state (advisory locks, LISTEN/NOTIFY) working.
apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
name: gitea-postgres-pooler-rw
namespace: gitea
spec:
cluster:
name: gitea-postgres
instances: 2
pgbouncer:
parameters:
default_pool_size: "50"
max_client_conn: "200"
paused: false
poolMode: session
template:
metadata:
labels:
app: pooler-rw
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- pooler-rw
topologyKey: kubernetes.io/hostname
containers: []
type: rw
+43
View File
@@ -0,0 +1,43 @@
---
# HTTPS front for the k8s Gitea. Temporary validation hostname
# git2.k8s.syd1.au.unkin.net; the git.unkin.net SAN is added at cutover (see
# docs/gitea-migration.md). SSH is exposed separately as an L4 LoadBalancer
# (ssh-service.yaml) since Gateway API here terminates HTTP(S) only.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: gitea
namespace: gitea
labels:
app.kubernetes.io/name: gitea
app.kubernetes.io/instance: gitea
traefik.io/instance: internal
annotations:
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: git2.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: git2.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
spec:
gatewayClassName: traefik-internal
listeners:
- name: http
port: 80
protocol: HTTP
hostname: git2.k8s.syd1.au.unkin.net
allowedRoutes:
namespaces:
from: Same
- name: https
port: 443
protocol: HTTPS
hostname: git2.k8s.syd1.au.unkin.net
allowedRoutes:
namespaces:
from: Same
tls:
mode: Terminate
certificateRefs:
- group: ""
kind: Secret
name: gitea-tls
+55
View File
@@ -0,0 +1,55 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: gitea-http-redirect
namespace: gitea
labels:
app.kubernetes.io/name: gitea
app.kubernetes.io/instance: gitea
spec:
hostnames:
- git2.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: gitea
sectionName: http
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
matches:
- path:
type: PathPrefix
value: /
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: gitea
namespace: gitea
labels:
app.kubernetes.io/name: gitea
app.kubernetes.io/instance: gitea
spec:
hostnames:
- git2.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: gitea
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: gitea-http
port: 3000
weight: 1
matches:
- path:
type: PathPrefix
value: /
+16
View File
@@ -0,0 +1,16 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- cnpg_cluster.yaml
- cnpg_backup.yaml
- cnpg_pooler.yaml
- valkey-deployment.yaml
- valkey-pvc.yaml
- valkey-service.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- gateway.yaml
- httproute.yaml
+7
View File
@@ -0,0 +1,7 @@
---
apiVersion: v1
kind: Namespace
metadata:
labels:
app.kubernetes.io/name: gitea
name: gitea
+89
View File
@@ -0,0 +1,89 @@
---
# Standalone Valkey (Redis-compatible) for Gitea's session store, cache and
# queue. The Gitea chart bundles a redis-cluster subchart, but we run our own
# standalone Valkey here: it keeps image control in-estate (valkey/valkey,
# already allowlisted through the artifactapi dockerhub mirror) and matches the
# standalone-cache pattern used by litellm/netbox. One instance serves three
# logical DBs: DB 0 = session, DB 1 = cache, DB 2 = queue. AOF persistence is
# enabled so queued actions/webhook deliveries survive a restart.
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea-valkey
namespace: gitea
labels:
app.kubernetes.io/name: gitea
app.kubernetes.io/component: valkey
spec:
replicas: 1
selector:
matchLabels:
app: gitea-valkey
strategy:
type: Recreate
template:
metadata:
labels:
app: gitea-valkey
app.kubernetes.io/name: gitea
app.kubernetes.io/component: valkey
spec:
securityContext:
fsGroup: 999
containers:
- name: valkey
image: valkey/valkey:8-alpine
imagePullPolicy: IfNotPresent
command:
- valkey-server
- --appendonly
- "yes"
- --save
- "60"
- "1"
ports:
- containerPort: 6379
name: valkey
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 999
capabilities:
drop:
- ALL
livenessProbe:
exec:
command:
- valkey-cli
- ping
failureThreshold: 3
initialDelaySeconds: 30
periodSeconds: 30
successThreshold: 1
timeoutSeconds: 5
readinessProbe:
exec:
command:
- valkey-cli
- ping
failureThreshold: 3
initialDelaySeconds: 5
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 50m
memory: 128Mi
volumeMounts:
- mountPath: /data
name: data
restartPolicy: Always
volumes:
- name: data
persistentVolumeClaim:
claimName: gitea-valkey-data
+14
View File
@@ -0,0 +1,14 @@
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gitea-valkey-data
namespace: gitea
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-delete
volumeMode: Filesystem
+20
View File
@@ -0,0 +1,20 @@
---
apiVersion: v1
kind: Service
metadata:
name: gitea-valkey
namespace: gitea
labels:
app.kubernetes.io/name: gitea
app.kubernetes.io/component: valkey
spec:
internalTrafficPolicy: Cluster
ports:
- name: valkey
port: 6379
protocol: TCP
targetPort: valkey
selector:
app: gitea-valkey
sessionAffinity: None
type: ClusterIP
+18
View File
@@ -0,0 +1,18 @@
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: default
namespace: gitea
spec:
allowedNamespaces:
- gitea
kubernetes:
audiences:
- vault
role: default
serviceAccount: default
tokenExpirationSeconds: 600
method: kubernetes
mount: k8s/au/syd1
vaultConnectionRef: vso-system/default
+83
View File
@@ -0,0 +1,83 @@
---
# CNPG app-user credentials (keys: username, password). Consumed by the Cluster
# bootstrap (initdb.secret) AND by Gitea (gitea.config.database.PASSWD via the
# chart's existingSecret wiring). One-time Vault seed — see the PR description.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: postgres-credentials
namespace: gitea
spec:
destination:
create: true
name: postgres-credentials
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/gitea/default/postgres-credentials
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
# Initial Gitea admin (keys: username, password, email). Applied by the chart's
# init job on first boot (gitea.admin.existingSecret). Local fallback account
# that survives the Authentik OIDC cutover. One-time Vault seed.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: gitea-admin
namespace: gitea
spec:
destination:
create: true
name: gitea-admin
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/gitea/default/gitea-admin
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
# Gitea internal secrets (keys: SECRET_KEY, INTERNAL_TOKEN). Pinned here rather
# than chart-generated so all replicas share identical values AND so the data
# cutover can replace them with the VM's app.ini values (SECRET_KEY encrypts
# 2FA/mirror/oauth secrets in the DB — it MUST match the restored database).
# One-time Vault seed.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: gitea-inner
namespace: gitea
spec:
destination:
create: true
name: gitea-inner
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/gitea/default/gitea-inner
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
# Authentik OIDC client secret (key: client_secret). Read by the
# terraform-authentik provider runner (policy already grants
# kv/.../namespace/+/default/oauth-credentials) AND mounted into Gitea to
# register the OIDC login source. One-time Vault seed.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: oauth-credentials
namespace: gitea
spec:
destination:
create: true
name: oauth-credentials
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/gitea/default/oauth-credentials
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -0,0 +1,29 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../../base/gitea
helmCharts:
- name: gitea
repo: oci://docker.gitea.com/charts
version: "12.6.0"
releaseName: gitea
namespace: gitea
valuesFile: values.yaml
# The chart renders a `helm.sh/hook: test` connection Pod (busybox). We deploy
# via kustomize+ArgoCD (not `helm test`), so drop it rather than leave an orphan
# Pod pulling an unallowlisted image.
patches:
- target:
version: v1
kind: Pod
name: gitea-test-connection
patch: |-
$patch: delete
apiVersion: v1
kind: Pod
metadata:
name: gitea-test-connection
+169
View File
@@ -0,0 +1,169 @@
# Gitea helm-gitea chart 12.6.0 (appVersion 1.26.1), app pinned to 1.26.2 to
# match the VM being replaced. HA-shaped: 2 replicas on shared RWX CephFS, with
# Postgres (CNPG), cache/session/queue (Valkey), and OIDC all externalised —
# exactly the shape the chart's docs/ha-setup.md requires for replicaCount > 1.
replicaCount: 2
image:
registry: docker.io
repository: gitea/gitea
tag: "1.26.2" # chart appends "-rootless" because rootless: true below
rootless: true
pullPolicy: IfNotPresent
# All bundled stateful subcharts OFF — we run CNPG Postgres + standalone Valkey.
postgresql-ha:
enabled: false
postgresql:
enabled: false
valkey-cluster:
enabled: false
valkey:
enabled: false
# Shared repo/LFS/attachment storage. RWX is mandatory for >1 replica so every
# pod sees the same /data (repos, lfs, avatars, attachments).
persistence:
enabled: true
create: true
mount: true
accessModes:
- ReadWriteMany
storageClass: cephfs-raid6-delete
size: 20Gi
service:
http:
type: ClusterIP
port: 3000
# git-over-ssh. Each replica runs Gitea's built-in Go SSH server (rootless,
# listens 2222); the LoadBalancer fans connections across pods. Dedicated
# PureLB VIP + its own DNS name for the validation phase (see the migration
# doc for the git.unkin.net:2222 unification decision at cutover).
ssh:
type: LoadBalancer
port: 2222
loadBalancerIP: 198.18.200.10
externalTrafficPolicy: Local
annotations:
purelb.io/addresses: 198.18.200.10
purelb.io/service-group: common
external-dns.alpha.kubernetes.io/hostname: git2-ssh.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.10
# Gateway API (HTTPRoute in the base) fronts HTTP; the chart Ingress is unused.
ingress:
enabled: false
# Rolling replicas on slow shared storage: keep one old pod up and give new pods
# generous startup headroom so migrations on boot don't trip the probes.
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: "100%"
maxUnavailable: 0
resources:
requests:
cpu: "500m"
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
gitea:
# Local admin fallback (survives the OIDC cutover). Secret keys: username,
# password. Seeded in Vault -> synced to the gitea-admin Secret by VSO.
admin:
existingSecret: gitea-admin
email: "benvin@unkin.net"
passwordMode: keepUpdated
# DB password (and Gitea internal secrets) injected as env from VSO Secrets,
# never rendered into app.ini in git. GITEA__<section>__<KEY> maps to app.ini.
additionalConfigFromEnvs:
- name: GITEA__database__PASSWD
valueFrom:
secretKeyRef:
name: postgres-credentials
key: password
- name: GITEA__security__SECRET_KEY
valueFrom:
secretKeyRef:
name: gitea-inner
key: SECRET_KEY
- name: GITEA__security__INTERNAL_TOKEN
valueFrom:
secretKeyRef:
name: gitea-inner
key: INTERNAL_TOKEN
# Authentik OIDC login source, registered declaratively on boot. existingSecret
# must carry keys `key` (client id) and `secret` (client secret) — seeded at the
# same Vault path terraform-authentik reads client_secret from.
oauth:
- name: "authentik"
provider: "openidConnect"
existingSecret: oauth-credentials
autoDiscoverUrl: "https://identity.k8s.syd1.au.unkin.net/application/o/gitea/.well-known/openid-configuration"
config:
server:
DOMAIN: git2.k8s.syd1.au.unkin.net
ROOT_URL: https://git2.k8s.syd1.au.unkin.net/
SSH_DOMAIN: git2-ssh.k8s.syd1.au.unkin.net
SSH_PORT: 2222 # advertised in clone URLs
SSH_LISTEN_PORT: 2222 # rootless in-container listener
START_SSH_SERVER: true
LFS_START_SERVER: true
database:
DB_TYPE: postgres
HOST: gitea-postgres-pooler-rw:5432
NAME: gitea
USER: gitea
SSL_MODE: disable
# Standalone Valkey: session on db 0, cache on db 1, queue on db 2.
session:
PROVIDER: redis
PROVIDER_CONFIG: "redis://gitea-valkey:6379/0"
cache:
ENABLED: true
ADAPTER: redis
HOST: "redis://gitea-valkey:6379/1"
queue:
TYPE: redis
CONN_STR: "redis://gitea-valkey:6379/2"
# DB-backed issue indexer is replica-safe on shared storage (matches the VM);
# avoids the on-disk bleve indexer that HA can't share.
indexer:
ISSUE_INDEXER_TYPE: db
REPO_INDEXER_ENABLED: false
# CI stays on Woodpecker — Gitea Actions disabled.
actions:
ENABLED: false
repository:
DEFAULT_BRANCH: main
DEFAULT_PRIVATE: last
# OIDC auto-registration + account linking so existing local users (benvin,
# bots) link to their Authentik identity by matching username on first login.
service:
ENABLE_AUTO_REGISTRATION: true
oauth2_client:
ENABLE_AUTO_REGISTRATION: true
ACCOUNT_LINKING: auto
USERNAME: preferred_username
UPDATE_AVATAR: true
# Startup headroom for migrations on RWX storage across a rolling update.
startupProbe:
enabled: true
livenessProbe:
enabled: true
readinessProbe:
enabled: true