diff --git a/.gitea/workflows/finance-api.yml b/.gitea/workflows/finance-api.yml new file mode 100644 index 0000000..6f065c9 --- /dev/null +++ b/.gitea/workflows/finance-api.yml @@ -0,0 +1,95 @@ +name: Finance API + +on: + push: + branches: [main] + paths: + - "apps/finance/**" + - "pkg/**" + - "go.mod" + - "go.sum" + pull_request: + paths: + - "apps/finance/**" + - "pkg/**" + +env: + # Internal Gitea service — reachable from within the cluster (pipeline steps via DinD) + GITEA_INTERNAL: gitea-http.gitea.svc.cluster.local:3000 + # Public registry hostname — used in k8s image references (containerd mirrors to NodePort 30002) + REGISTRY: git.homelab.local + IMAGE: git.homelab.local/admin/finance-api + +jobs: + test: + runs-on: ubuntu-latest + container: + image: golang:1.25 + steps: + - uses: actions/checkout@v4 + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: /go/pkg/mod + key: go-${{ hashFiles('go.sum') }} + - name: Test + run: go test ./apps/finance/... ./pkg/... + + build-push: + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Login to Gitea registry + run: | + echo "${{ secrets.GITEA_ADMIN_PASSWORD }}" | \ + docker login ${{ env.GITEA_INTERNAL }} -u admin --password-stdin + + - name: Build and push + run: | + SHA=${{ github.sha }} + # Build image — tag with both sha and latest + docker build \ + -t ${{ env.GITEA_INTERNAL }}/admin/finance-api:${SHA} \ + -t ${{ env.GITEA_INTERNAL }}/admin/finance-api:latest \ + -f apps/finance/services/api/Dockerfile \ + . + docker push ${{ env.GITEA_INTERNAL }}/admin/finance-api:${SHA} + docker push ${{ env.GITEA_INTERNAL }}/admin/finance-api:latest + + deploy: + needs: build-push + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - name: Install kubectl + run: | + curl -LO "https://dl.k8s.io/release/v1.31.0/bin/linux/amd64/kubectl" + chmod +x kubectl && mv kubectl /usr/local/bin/ + + # The runner pod has a ServiceAccount with deploy permissions. + # Mount its token via the act runner valid_volumes config. + - name: Deploy to cluster + run: | + SHA=${{ github.sha }} + TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) + CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt + K8S=https://kubernetes.default.svc + + kubectl \ + --server=$K8S \ + --token=$TOKEN \ + --certificate-authority=$CA \ + set image deployment/api \ + api=${{ env.IMAGE }}:${SHA} \ + -n finance + + kubectl \ + --server=$K8S \ + --token=$TOKEN \ + --certificate-authority=$CA \ + rollout status deployment/api \ + -n finance \ + --timeout=120s diff --git a/apps/finance/services/api/k8s/deployment.yaml b/apps/finance/services/api/k8s/deployment.yaml index ccf2151..f55d342 100644 --- a/apps/finance/services/api/k8s/deployment.yaml +++ b/apps/finance/services/api/k8s/deployment.yaml @@ -15,10 +15,12 @@ spec: labels: app: api spec: + imagePullSecrets: + - name: gitea-registry containers: - name: api - image: homelab/api:latest - imagePullPolicy: IfNotPresent + image: git.homelab.local/admin/finance-api:latest + imagePullPolicy: Always ports: - name: http containerPort: 8080 @@ -26,12 +28,17 @@ spec: - name: PORT value: "8080" - name: LOG_LEVEL - value: "debug" + value: "info" - name: OTEL_EXPORTER_OTLP_ENDPOINT value: "jaeger.monitoring.svc:4317" + - name: BASE_URL + value: "https://finance.homelab.local" envFrom: - secretRef: name: mongodb-shared-config + - secretRef: + name: finance-api-secrets + optional: true livenessProbe: httpGet: path: /healthz diff --git a/infrastructure/Makefile/service.mk b/infrastructure/Makefile/service.mk index 70722d5..047433a 100644 --- a/infrastructure/Makefile/service.mk +++ b/infrastructure/Makefile/service.mk @@ -15,7 +15,8 @@ _rel_path := $(patsubst $(_abs_root)/%,%,$(CURDIR)) NAMESPACE ?= $(word 2,$(subst /, ,$(_rel_path))) IMAGE_TAG ?= latest -IMAGE ?= homelab/$(SERVICE_NAME):$(IMAGE_TAG) +REGISTRY ?= git.homelab.local/admin +IMAGE ?= $(REGISTRY)/$(SERVICE_NAME):$(IMAGE_TAG) CLUSTER_NAME ?= homelab _is_node := $(shell [ -f $(SERVICE_DIR)/package.json ] && echo yes) diff --git a/infrastructure/k3d/config.yaml b/infrastructure/k3d/config.yaml index 7e7df9a..bddaf57 100644 --- a/infrastructure/k3d/config.yaml +++ b/infrastructure/k3d/config.yaml @@ -18,6 +18,21 @@ ports: nodeFilters: - loadbalancer +# Registry mirror: k3s containerd pulls "git.homelab.local/..." images by redirecting +# to the Gitea NodePort (30002) on localhost inside the k3d node container. +# This is set up once at cluster creation — changing it requires recreating the cluster: +# k3d cluster delete homelab && k3d cluster create --config infrastructure/k3d/config.yaml +registries: + config: | + mirrors: + "git.homelab.local": + endpoint: + - "http://localhost:30002" + configs: + "localhost:30002": + tls: + insecure_skip_verify: true + options: k3s: extraArgs: diff --git a/infrastructure/terraform/act-runner.tf b/infrastructure/terraform/act-runner.tf new file mode 100644 index 0000000..55f91e5 --- /dev/null +++ b/infrastructure/terraform/act-runner.tf @@ -0,0 +1,214 @@ +resource "kubernetes_service_account" "act_runner" { + metadata { + name = "act-runner" + namespace = kubernetes_namespace.domains["gitea"].metadata[0].name + } +} + +resource "kubernetes_cluster_role" "act_runner" { + metadata { + name = "act-runner" + } + # Allow deploying to finance namespace + rule { + api_groups = ["apps"] + resources = ["deployments"] + verbs = ["get", "list", "patch", "update"] + } + rule { + api_groups = [""] + resources = ["pods", "pods/log"] + verbs = ["get", "list"] + } + # Allow creating Kaniko build jobs in gitea namespace + rule { + api_groups = ["batch"] + resources = ["jobs"] + verbs = ["create", "get", "list", "watch", "delete"] + } +} + +resource "kubernetes_cluster_role_binding" "act_runner" { + metadata { + name = "act-runner" + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = kubernetes_cluster_role.act_runner.metadata[0].name + } + subject { + kind = "ServiceAccount" + name = kubernetes_service_account.act_runner.metadata[0].name + namespace = kubernetes_namespace.domains["gitea"].metadata[0].name + } +} + +# Populated after initial Gitea deploy: +# 1. Open http://git.homelab.local → Admin Area → Runners → Create Runner +# 2. Copy the token +# 3. terraform apply -var gitea_runner_token= +resource "kubernetes_secret" "gitea_runner_token" { + metadata { + name = "gitea-runner-token" + namespace = kubernetes_namespace.domains["gitea"].metadata[0].name + } + data = { + token = var.gitea_runner_token + } +} + +# ConfigMap for act runner config (host executor mode — steps run directly in runner container) +resource "kubernetes_config_map" "act_runner" { + metadata { + name = "act-runner-config" + namespace = kubernetes_namespace.domains["gitea"].metadata[0].name + } + data = { + "config.yaml" = yamlencode({ + log = { level = "info" } + runner = { + capacity = 2 + fetch_timeout = "5s" + fetch_interval = "2s" + report_interval = "1s" + envs = {} + } + cache = { enabled = false } + container = { + network = "host" + # Allow pipeline steps to mount the SA token for kubectl + valid_volumes = [ + "/var/run/secrets/kubernetes.io/serviceaccount", + ] + docker_host = "tcp://localhost:2375" + } + }) + } +} + +resource "kubernetes_deployment" "act_runner" { + depends_on = [helm_release.gitea, kubernetes_secret.gitea_runner_token] + + metadata { + name = "act-runner" + namespace = kubernetes_namespace.domains["gitea"].metadata[0].name + labels = { app = "act-runner" } + } + + spec { + replicas = 1 + selector { + match_labels = { app = "act-runner" } + } + template { + metadata { + labels = { app = "act-runner" } + } + spec { + service_account_name = kubernetes_service_account.act_runner.metadata[0].name + + # act runner — runs steps using Docker (provided by dind sidecar) + container { + name = "runner" + image = "gitea/act_runner:latest" + + command = ["/bin/sh", "-c"] + args = [<<-EOT + set -e + # Register if not yet registered + if [ ! -f /data/.runner ]; then + act_runner register \ + --no-interactive \ + --instance http://gitea-http.gitea.svc.cluster.local:3000 \ + --token "$(cat /etc/runner-token/token)" \ + --name "k3d-runner-$(hostname)" \ + --labels ubuntu-latest + fi + exec act_runner daemon --config /etc/act-runner/config.yaml + EOT + ] + + env { + name = "DOCKER_HOST" + value = "tcp://localhost:2375" + } + # Make the runner's KUBERNETES_SERVICE env accessible to pipeline steps + env { + name = "KUBERNETES_SERVICE_HOST" + value_from { + field_ref { field_path = "status.hostIP" } + } + } + + volume_mount { + name = "runner-data" + mount_path = "/data" + } + volume_mount { + name = "runner-config" + mount_path = "/etc/act-runner" + } + volume_mount { + name = "runner-token" + mount_path = "/etc/runner-token" + read_only = true + } + + resources { + requests = { cpu = "100m", memory = "128Mi" } + limits = { cpu = "500m", memory = "512Mi" } + } + } + + # Docker-in-Docker: provides a Docker daemon for the pipeline steps + container { + name = "dind" + image = "docker:27-dind" + + security_context { + privileged = true + } + args = [ + "--insecure-registry=gitea-http.gitea.svc.cluster.local:3000", + ] + env { + name = "DOCKER_TLS_CERTDIR" + value = "" + } + + volume_mount { + name = "docker-storage" + mount_path = "/var/lib/docker" + } + + resources { + requests = { cpu = "200m", memory = "256Mi" } + limits = { cpu = "1", memory = "1Gi" } + } + } + + volume { + name = "runner-data" + empty_dir {} + } + volume { + name = "docker-storage" + empty_dir {} + } + volume { + name = "runner-config" + config_map { + name = kubernetes_config_map.act_runner.metadata[0].name + } + } + volume { + name = "runner-token" + secret { + secret_name = kubernetes_secret.gitea_runner_token.metadata[0].name + } + } + } + } + } +} diff --git a/infrastructure/terraform/finance.tf b/infrastructure/terraform/finance.tf new file mode 100644 index 0000000..6c5ad1b --- /dev/null +++ b/infrastructure/terraform/finance.tf @@ -0,0 +1,34 @@ +# SESSION_SECRET must be a random 32+ byte hex string. +# Set it via: TF_VAR_finance_session_secret= terraform apply +variable "finance_session_secret" { + description = "HMAC secret for finance-api session cookies (32+ random bytes)" + type = string + default = "dev-secret-change-in-production-32x" + sensitive = true +} + +variable "finance_google_client_id" { + description = "Google OAuth client ID for finance-api (optional)" + type = string + default = "" + sensitive = false +} + +variable "finance_google_client_secret" { + description = "Google OAuth client secret for finance-api (optional)" + type = string + default = "" + sensitive = true +} + +resource "kubernetes_secret" "finance_api" { + metadata { + name = "finance-api-secrets" + namespace = kubernetes_namespace.domains["finance"].metadata[0].name + } + data = { + SESSION_SECRET = var.finance_session_secret + GOOGLE_CLIENT_ID = var.finance_google_client_id + GOOGLE_CLIENT_SECRET = var.finance_google_client_secret + } +} diff --git a/infrastructure/terraform/gitea.tf b/infrastructure/terraform/gitea.tf new file mode 100644 index 0000000..c4b3ad6 --- /dev/null +++ b/infrastructure/terraform/gitea.tf @@ -0,0 +1,95 @@ +resource "kubernetes_secret" "gitea_admin" { + metadata { + name = "gitea-admin" + namespace = kubernetes_namespace.domains["gitea"].metadata[0].name + } + data = { + username = "admin" + password = var.gitea_admin_password + email = "admin@homelab.local" + } +} + +resource "helm_release" "gitea" { + name = "gitea" + namespace = kubernetes_namespace.domains["gitea"].metadata[0].name + repository = "https://dl.gitea.com/charts/" + chart = "gitea" + version = "~> 10.0" + atomic = true + timeout = 300 + + values = [yamlencode({ + gitea = { + admin = { + existingSecret = kubernetes_secret.gitea_admin.metadata[0].name + } + config = { + APP_NAME = "Homelab Git" + server = { + DOMAIN = "git.homelab.local" + ROOT_URL = "http://git.homelab.local" + SSH_DOMAIN = "localhost" + SSH_PORT = 30001 + } + packages = { ENABLED = "true" } + service = { DISABLE_REGISTRATION = "true" } + log = { LEVEL = "Warn" } + } + } + + ingress = { + enabled = true + className = "traefik" + hosts = [{ + host = "git.homelab.local" + paths = [{ path = "/", pathType = "Prefix" }] + }] + } + + # NodePort 30002: used by k3d containerd registry mirror (see k3d/config.yaml) + service = { + http = { + type = "NodePort" + port = 3000 + nodePort = 30002 + } + ssh = { + type = "NodePort" + port = 22 + nodePort = 30001 + } + } + + persistence = { + enabled = true + size = "10Gi" + storageClass = "local-path" + } + + resources = { + requests = { cpu = "100m", memory = "256Mi" } + limits = { cpu = "500m", memory = "512Mi" } + } + })] +} + +# imagePullSecret for finance namespace — allows k8s to pull images from Gitea registry. +# Containerd mirrors "git.homelab.local" to localhost:30002 (see k3d/config.yaml) and +# forwards these credentials to authenticate against the Gitea NodePort. +resource "kubernetes_secret" "gitea_registry_finance" { + metadata { + name = "gitea-registry" + namespace = kubernetes_namespace.domains["finance"].metadata[0].name + } + type = "kubernetes.io/dockerconfigjson" + data = { + ".dockerconfigjson" = jsonencode({ + auths = { + "git.homelab.local" = { + auth = base64encode("admin:${var.gitea_admin_password}") + } + } + }) + } +} diff --git a/infrastructure/terraform/mongodb.tf b/infrastructure/terraform/mongodb.tf index 6648460..c961599 100644 --- a/infrastructure/terraform/mongodb.tf +++ b/infrastructure/terraform/mongodb.tf @@ -96,6 +96,11 @@ resource "kubernetes_stateful_set" "mongodb" { value = "homelab" } + volume_mount { + name = "mongodb-data" + mount_path = "/data/db" + } + port { container_port = 27017 } @@ -113,5 +118,20 @@ resource "kubernetes_stateful_set" "mongodb" { } } } + + volume_claim_template { + metadata { + name = "mongodb-data" + } + spec { + access_modes = ["ReadWriteOnce"] + storage_class_name = "local-path" + resources { + requests = { + storage = "5Gi" + } + } + } + } } } diff --git a/infrastructure/terraform/namespaces.tf b/infrastructure/terraform/namespaces.tf index 1b55955..6f80689 100644 --- a/infrastructure/terraform/namespaces.tf +++ b/infrastructure/terraform/namespaces.tf @@ -1,5 +1,5 @@ locals { - namespaces = ["auth", "home", "finance", "test", "monitoring", "infrastructure"] + namespaces = ["auth", "home", "finance", "test", "monitoring", "infrastructure", "gitea"] } resource "kubernetes_namespace" "domains" { diff --git a/infrastructure/terraform/variables.tf b/infrastructure/terraform/variables.tf new file mode 100644 index 0000000..328596c --- /dev/null +++ b/infrastructure/terraform/variables.tf @@ -0,0 +1,13 @@ +variable "gitea_admin_password" { + description = "Gitea admin password — set TF_VAR_gitea_admin_password or override in terraform.tfvars" + type = string + default = "gitea-dev-changeme" + sensitive = true +} + +variable "gitea_runner_token" { + description = "Gitea runner registration token — obtain from Gitea UI: Admin Area → Runners → Create Runner, then set TF_VAR_gitea_runner_token" + type = string + default = "" + sensitive = true +}