soroban-abacus-flashcards/infra/terraform/gitea.tf

1016 lines
24 KiB
HCL

# Gitea - Self-hosted Git Server with CI/CD
#
# Replaces GitHub as primary repo + GitHub Actions for CI/CD.
# GitHub becomes a mirror/backup only.
#
# Architecture:
# - Gitea: Git hosting + web UI + Gitea Actions (GitHub Actions compatible)
# - Act Runner: Executes CI/CD pipelines using existing .github/workflows
# - Local Registry: Stores built Docker images
#
# Workflow:
# 1. git push → Gitea (primary)
# 2. Gitea Actions runs .github/workflows/*.yml
# 3. Built images pushed to local registry
# 4. Keel detects new images and updates app pods
# 5. Gitea mirrors to GitHub (backup)
# ===========================================================================
# Gitea Namespace
# ===========================================================================
resource "kubernetes_namespace" "gitea" {
metadata {
name = "gitea"
labels = {
"app.kubernetes.io/managed-by" = "terraform"
}
}
}
# ===========================================================================
# Local Docker Registry (shared with CI builds)
# ===========================================================================
resource "kubernetes_persistent_volume" "registry" {
metadata {
name = "registry-pv"
labels = {
type = "nfs"
app = "registry"
}
}
spec {
capacity = {
storage = "50Gi"
}
access_modes = ["ReadWriteMany"]
persistent_volume_reclaim_policy = "Retain"
storage_class_name = "nfs"
persistent_volume_source {
nfs {
server = var.nfs_server
path = "/volume1/homes/antialias/projects/abaci.one/data/registry"
}
}
}
}
resource "kubernetes_persistent_volume_claim" "registry" {
metadata {
name = "registry"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
spec {
access_modes = ["ReadWriteMany"]
storage_class_name = "nfs"
resources {
requests = {
storage = "50Gi"
}
}
selector {
match_labels = {
type = "nfs"
app = "registry"
}
}
}
}
resource "kubernetes_deployment" "registry" {
metadata {
name = "registry"
namespace = kubernetes_namespace.gitea.metadata[0].name
labels = {
app = "registry"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "registry"
}
}
template {
metadata {
labels = {
app = "registry"
}
}
spec {
container {
name = "registry"
image = "registry:2"
port {
container_port = 5000
}
env {
name = "REGISTRY_STORAGE_DELETE_ENABLED"
value = "true"
}
volume_mount {
name = "data"
mount_path = "/var/lib/registry"
}
resources {
requests = {
memory = "64Mi"
cpu = "50m"
}
limits = {
memory = "256Mi"
cpu = "500m"
}
}
liveness_probe {
http_get {
path = "/v2/"
port = 5000
}
initial_delay_seconds = 10
period_seconds = 30
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.registry.metadata[0].name
}
}
}
}
}
}
resource "kubernetes_service" "registry" {
metadata {
name = "registry"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
spec {
selector = {
app = "registry"
}
port {
port = 5000
target_port = 5000
}
type = "ClusterIP"
}
}
# ===========================================================================
# Gitea Server
# ===========================================================================
resource "kubernetes_persistent_volume" "gitea" {
metadata {
name = "gitea-pv"
labels = {
type = "nfs"
app = "gitea"
}
}
spec {
capacity = {
storage = "20Gi"
}
access_modes = ["ReadWriteMany"]
persistent_volume_reclaim_policy = "Retain"
storage_class_name = "nfs"
persistent_volume_source {
nfs {
server = var.nfs_server
path = "/volume1/homes/antialias/projects/abaci.one/data/gitea"
}
}
}
}
resource "kubernetes_persistent_volume_claim" "gitea" {
metadata {
name = "gitea"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
spec {
access_modes = ["ReadWriteMany"]
storage_class_name = "nfs"
resources {
requests = {
storage = "20Gi"
}
}
selector {
match_labels = {
type = "nfs"
app = "gitea"
}
}
}
}
resource "kubernetes_config_map" "gitea" {
metadata {
name = "gitea-config"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
data = {
# Gitea app.ini configuration
"app.ini" = <<-EOT
APP_NAME = Abaci Git
RUN_MODE = prod
RUN_USER = git
[database]
DB_TYPE = sqlite3
PATH = /data/gitea/gitea.db
[repository]
ROOT = /data/git/repositories
[server]
DOMAIN = git.dev.${var.app_domain}
SSH_DOMAIN = git.dev.${var.app_domain}
HTTP_PORT = 3000
ROOT_URL = https://git.dev.${var.app_domain}/
DISABLE_SSH = true
LFS_START_SERVER = true
LFS_JWT_SECRET = ${random_password.gitea_lfs_jwt[0].result}
[lfs]
PATH = /data/git/lfs
[mailer]
ENABLED = false
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
NO_REPLY_ADDRESS = noreply.localhost
[picture]
DISABLE_GRAVATAR = false
ENABLE_FEDERATED_AVATAR = true
[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
[session]
PROVIDER = file
[log]
MODE = console
LEVEL = info
ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = true
SECRET_KEY = ${random_password.gitea_secret_key[0].result}
INTERNAL_TOKEN = ${random_password.gitea_internal_token[0].result}
PASSWORD_HASH_ALGO = pbkdf2
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = github
[mirror]
ENABLED = true
DISABLE_NEW_PULL = false
DISABLE_NEW_PUSH = false
DEFAULT_INTERVAL = 8h
MIN_INTERVAL = 10m
EOT
}
}
# Generate secrets for Gitea
resource "random_password" "gitea_secret_key" {
count = 1
length = 64
special = false
}
resource "random_password" "gitea_internal_token" {
count = 1
length = 64
special = false
}
resource "random_password" "gitea_lfs_jwt" {
count = 1
length = 43
special = false
}
resource "kubernetes_deployment" "gitea" {
metadata {
name = "gitea"
namespace = kubernetes_namespace.gitea.metadata[0].name
labels = {
app = "gitea"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "gitea"
}
}
template {
metadata {
labels = {
app = "gitea"
}
}
spec {
# Run as git user (UID 1000 in gitea image)
security_context {
fs_group = 1000
}
init_container {
name = "init-config"
image = "busybox:1.36"
command = ["/bin/sh", "-c"]
args = [
# NFS root squashing prevents chown, so we just create dirs
# and rely on 777 permissions set on the NFS share
<<-EOT
mkdir -p /data/gitea/conf /data/git/repositories /data/git/lfs
cp /config/app.ini /data/gitea/conf/app.ini
chmod -R 777 /data || true
EOT
]
volume_mount {
name = "data"
mount_path = "/data"
}
volume_mount {
name = "config"
mount_path = "/config"
}
}
container {
name = "gitea"
image = "gitea/gitea:1.21-rootless"
port {
container_port = 3000
name = "http"
}
# Tell Gitea where to find custom config
env {
name = "GITEA_WORK_DIR"
value = "/data"
}
env {
name = "GITEA_CUSTOM"
value = "/data/gitea"
}
env {
name = "GITEA_APP_INI"
value = "/data/gitea/conf/app.ini"
}
env {
name = "GITEA__database__DB_TYPE"
value = "sqlite3"
}
env {
name = "GITEA__database__PATH"
value = "/data/gitea/gitea.db"
}
volume_mount {
name = "data"
mount_path = "/data"
}
resources {
requests = {
memory = "256Mi"
cpu = "100m"
}
limits = {
memory = "512Mi"
cpu = "1000m"
}
}
liveness_probe {
http_get {
path = "/api/healthz"
port = 3000
}
initial_delay_seconds = 30
period_seconds = 30
}
readiness_probe {
http_get {
path = "/api/healthz"
port = 3000
}
initial_delay_seconds = 10
period_seconds = 10
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.gitea.metadata[0].name
}
}
volume {
name = "config"
config_map {
name = kubernetes_config_map.gitea.metadata[0].name
}
}
}
}
}
}
resource "kubernetes_service" "gitea" {
metadata {
name = "gitea"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
spec {
selector = {
app = "gitea"
}
port {
port = 3000
target_port = 3000
name = "http"
}
type = "ClusterIP"
}
}
# Ingress for git.dev.abaci.one
resource "kubernetes_ingress_v1" "gitea" {
metadata {
name = "gitea"
namespace = kubernetes_namespace.gitea.metadata[0].name
annotations = {
"cert-manager.io/cluster-issuer" = var.use_staging_certs ? "letsencrypt-staging" : "letsencrypt-prod"
"traefik.ingress.kubernetes.io/router.entrypoints" = "websecure"
}
}
spec {
ingress_class_name = "traefik"
tls {
hosts = ["git.dev.${var.app_domain}"]
secret_name = "gitea-tls"
}
rule {
host = "git.dev.${var.app_domain}"
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = kubernetes_service.gitea.metadata[0].name
port {
number = 3000
}
}
}
}
}
}
}
depends_on = [null_resource.cert_manager_issuers]
}
# HTTP to HTTPS redirect
resource "kubernetes_ingress_v1" "gitea_http_redirect" {
metadata {
name = "gitea-http-redirect"
namespace = kubernetes_namespace.gitea.metadata[0].name
annotations = {
"traefik.ingress.kubernetes.io/router.entrypoints" = "web"
"traefik.ingress.kubernetes.io/router.middlewares" = "${kubernetes_namespace.abaci.metadata[0].name}-redirect-https@kubernetescrd"
}
}
spec {
ingress_class_name = "traefik"
rule {
host = "git.dev.${var.app_domain}"
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = kubernetes_service.gitea.metadata[0].name
port {
number = 3000
}
}
}
}
}
}
}
}
# ===========================================================================
# Gitea Actions Runner (act_runner)
# ===========================================================================
# The runner needs to be registered with Gitea after initial setup.
# This deployment will be created but the runner won't work until:
# 1. Gitea is running and you've created an admin account
# 2. Generate a runner token: Gitea UI → Site Admin → Actions → Runners → Create
# 3. Update the GITEA_RUNNER_REGISTRATION_TOKEN secret
resource "kubernetes_secret" "gitea_runner" {
metadata {
name = "gitea-runner"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
data = {
# Placeholder - update after Gitea setup
GITEA_RUNNER_REGISTRATION_TOKEN = var.gitea_runner_token
}
}
resource "kubernetes_deployment" "gitea_runner" {
metadata {
name = "gitea-runner"
namespace = kubernetes_namespace.gitea.metadata[0].name
labels = {
app = "gitea-runner"
}
}
spec {
replicas = var.gitea_runner_token != "" ? 1 : 0
selector {
match_labels = {
app = "gitea-runner"
}
}
template {
metadata {
labels = {
app = "gitea-runner"
}
}
spec {
# Docker-in-Docker sidecar for running container-based actions
container {
name = "dind"
image = "docker:24-dind"
security_context {
privileged = true
}
env {
name = "DOCKER_TLS_CERTDIR"
value = ""
}
volume_mount {
name = "docker-data"
mount_path = "/var/lib/docker"
}
resources {
requests = {
memory = "256Mi"
cpu = "100m"
}
limits = {
memory = "2Gi"
cpu = "2000m"
}
}
}
container {
name = "runner"
image = "gitea/act_runner:latest"
env {
name = "GITEA_INSTANCE_URL"
value = "http://gitea.${kubernetes_namespace.gitea.metadata[0].name}.svc.cluster.local:3000"
}
env {
name = "GITEA_RUNNER_REGISTRATION_TOKEN"
value_from {
secret_key_ref {
name = kubernetes_secret.gitea_runner.metadata[0].name
key = "GITEA_RUNNER_REGISTRATION_TOKEN"
}
}
}
env {
name = "GITEA_RUNNER_NAME"
value = "k3s-runner"
}
env {
name = "GITEA_RUNNER_LABELS"
value = "ubuntu-latest:docker://node:20,ubuntu-22.04:docker://node:20"
}
env {
name = "DOCKER_HOST"
value = "tcp://localhost:2375"
}
resources {
requests = {
memory = "128Mi"
cpu = "100m"
}
limits = {
memory = "512Mi"
cpu = "1000m"
}
}
}
volume {
name = "docker-data"
empty_dir {}
}
}
}
}
}
# ===========================================================================
# Admin User Setup Job
# ===========================================================================
resource "kubernetes_secret" "gitea_admin" {
metadata {
name = "gitea-admin"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
data = {
username = var.gitea_admin_user
email = var.gitea_admin_email
password = var.gitea_admin_password
}
}
resource "kubernetes_job" "gitea_admin_setup" {
metadata {
name = "gitea-admin-setup"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
spec {
ttl_seconds_after_finished = 300
template {
metadata {
labels = {
app = "gitea-admin-setup"
}
}
spec {
restart_policy = "OnFailure"
init_container {
name = "wait-for-gitea"
image = "busybox:1.36"
command = ["/bin/sh", "-c"]
args = [
<<-EOT
echo "Waiting for Gitea to be ready..."
until wget -q --spider http://gitea.${kubernetes_namespace.gitea.metadata[0].name}.svc.cluster.local:3000/api/healthz; do
echo "Gitea not ready, waiting..."
sleep 5
done
echo "Gitea is ready!"
EOT
]
}
container {
name = "create-admin"
image = "gitea/gitea:1.21-rootless"
command = ["/bin/sh", "-c"]
args = [
<<-EOT
export GITEA_WORK_DIR=/data
export GITEA_CUSTOM=/data/gitea
# Initialize database if needed (runs migrations)
echo "Running database migrations..."
gitea migrate --config /data/gitea/conf/app.ini || true
# Check if admin user already exists
if gitea admin user list --config /data/gitea/conf/app.ini 2>/dev/null | grep -q "$GITEA_ADMIN_USER"; then
echo "Admin user already exists, skipping creation"
exit 0
fi
# Create admin user
echo "Creating admin user..."
gitea admin user create \
--config /data/gitea/conf/app.ini \
--username "$GITEA_ADMIN_USER" \
--password "$GITEA_ADMIN_PASSWORD" \
--email "$GITEA_ADMIN_EMAIL" \
--admin \
--must-change-password=false
if [ $? -eq 0 ]; then
echo "Admin user created successfully!"
else
echo "Failed to create admin user"
exit 1
fi
EOT
]
env {
name = "GITEA_ADMIN_USER"
value_from {
secret_key_ref {
name = kubernetes_secret.gitea_admin.metadata[0].name
key = "username"
}
}
}
env {
name = "GITEA_ADMIN_PASSWORD"
value_from {
secret_key_ref {
name = kubernetes_secret.gitea_admin.metadata[0].name
key = "password"
}
}
}
env {
name = "GITEA_ADMIN_EMAIL"
value_from {
secret_key_ref {
name = kubernetes_secret.gitea_admin.metadata[0].name
key = "email"
}
}
}
volume_mount {
name = "data"
mount_path = "/data"
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.gitea.metadata[0].name
}
}
}
}
}
depends_on = [kubernetes_deployment.gitea]
}
# ===========================================================================
# Repository Setup Job
# ===========================================================================
resource "kubernetes_job" "gitea_repo_setup" {
metadata {
name = "gitea-repo-setup"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
spec {
ttl_seconds_after_finished = 600
template {
metadata {
labels = {
app = "gitea-repo-setup"
}
}
spec {
restart_policy = "OnFailure"
init_container {
name = "wait-for-gitea"
image = "busybox:1.36"
command = ["/bin/sh", "-c"]
args = [
<<-EOT
echo "Waiting for Gitea to be ready..."
until wget -q --spider http://gitea.${kubernetes_namespace.gitea.metadata[0].name}.svc.cluster.local:3000/api/healthz; do
echo "Gitea not ready, waiting..."
sleep 5
done
# Extra wait for admin user setup to complete
sleep 10
echo "Gitea is ready!"
EOT
]
}
container {
name = "setup-repo"
image = "curlimages/curl:8.5.0"
env {
name = "GITEA_ADMIN_USER"
value_from {
secret_key_ref {
name = kubernetes_secret.gitea_admin.metadata[0].name
key = "username"
}
}
}
env {
name = "GITEA_ADMIN_PASSWORD"
value_from {
secret_key_ref {
name = kubernetes_secret.gitea_admin.metadata[0].name
key = "password"
}
}
}
env {
name = "GITEA_URL"
value = "http://gitea.${kubernetes_namespace.gitea.metadata[0].name}.svc.cluster.local:3000"
}
env {
name = "REPO_NAME"
value = var.gitea_repo_name
}
env {
name = "GITHUB_REPO_URL"
value = var.github_repo_url
}
env {
name = "GITHUB_MIRROR_TOKEN"
value = var.github_mirror_token
}
command = ["/bin/sh", "-c"]
args = [
<<-EOT
set -e
GITEA_API="$GITEA_URL/api/v1"
AUTH="$GITEA_ADMIN_USER:$GITEA_ADMIN_PASSWORD"
echo "Checking if repo already exists..."
REPO_EXISTS=$(curl -s -o /dev/null -w "%%{http_code}" -u "$AUTH" "$GITEA_API/repos/$GITEA_ADMIN_USER/$REPO_NAME")
if [ "$REPO_EXISTS" = "200" ]; then
echo "Repository already exists, skipping migration"
else
echo "Migrating repository from GitHub..."
curl -s -X POST "$GITEA_API/repos/migrate" \
-u "$AUTH" \
-H "Content-Type: application/json" \
-d "{
\"clone_addr\": \"$GITHUB_REPO_URL\",
\"repo_name\": \"$REPO_NAME\",
\"mirror\": false,
\"private\": false,
\"description\": \"Migrated from GitHub\"
}"
echo "Waiting for migration to complete..."
sleep 30
fi
# Set up push mirror to GitHub if token provided
if [ -n "$GITHUB_MIRROR_TOKEN" ]; then
echo "Setting up push mirror to GitHub..."
# Extract GitHub owner/repo from URL
GITHUB_PUSH_URL=$(echo "$GITHUB_REPO_URL" | sed "s|https://github.com/|https://$GITHUB_MIRROR_TOKEN@github.com/|")
curl -s -X POST "$GITEA_API/repos/$GITEA_ADMIN_USER/$REPO_NAME/push_mirrors" \
-u "$AUTH" \
-H "Content-Type: application/json" \
-d "{
\"remote_address\": \"$GITHUB_PUSH_URL\",
\"interval\": \"8h0m0s\",
\"sync_on_commit\": true
}" || echo "Push mirror may already exist"
fi
echo "Repository setup complete!"
EOT
]
}
}
}
}
depends_on = [kubernetes_job.gitea_admin_setup]
}
# ===========================================================================
# Outputs
# ===========================================================================
output "gitea_url" {
description = "URL to access Gitea"
value = "https://git.dev.${var.app_domain}"
}
output "registry_url" {
description = "Local Docker registry URL (cluster-internal)"
value = "registry.${kubernetes_namespace.gitea.metadata[0].name}.svc.cluster.local:5000"
}