Files
Will Anderson c31edc8b83 deploy gitea CI runner to GKE as k8s pod
Replaces the GCE VM runner with a k8s Deployment in the ci namespace on
neuron-platform GKE. Uses Docker-in-Docker for build isolation since
Autopilot doesn't expose the node socket. Runner token pulled from Secret
Manager via ESO + Workload Identity.

- servers/gcp/k8s/gitea-runner/: namespace, serviceaccount, external-secrets,
  deployment manifests (ci namespace, dind sidecar, idempotent registration)
- servers/gcp/k8s/argocd-apps/gitea-runner-gke.yaml: Argo CD Application
- servers/gcp/gitea-runner.tf: gitea-runner-gke GCP SA with secretAccessor
  on gitea-runner-token, Workload Identity binding for ci/gitea-runner,
  artifactregistry.reader for pulling ci-base image
2026-05-05 10:16:49 -05:00

328 lines
14 KiB
Terraform

# ── Gitea Actions runner on GCE ───────────────────────────────────────────────
# A single e2-standard-4 VM in us-central1-a runs `act_runner`, registered
# against https://git.neuralplatform.ai. CI workflows that say
# `runs-on: ubuntu-latest` get scheduled here.
#
# Identity model
# • The VM mounts the `gitea-runner-vm` SA so it can pull the runner
# registration token from Secret Manager at first boot.
# • Workflows authenticate to GCP via Workload Identity Federation —
# they exchange a Gitea OIDC token for a short-lived access token
# impersonating the existing `neuron-ci-pusher` SA. No long-lived
# JSON keys live on the runner.
# • If Gitea OIDC is unavailable (older Gitea), the workflow can fall
# back to `credentials_json: ${{ secrets.GCP_SA_KEY }}` and the
# existing JSON key flow keeps working — see the README for details.
#
# IAM scope
# The deploy SA already holds artifactregistry.writer + run.developer
# + serviceAccountUser on the runtime SAs (see artifact-registry.tf).
# We add only what's missing here: Cloud Run service-level developer
# bindings and the WIF binding itself.
# ── Runner VM service account ─────────────────────────────────────────────────
# Boot-time identity. Only role: read the registration token. We deliberately
# DO NOT grant deploy permissions to this SA — workflow steps assume the
# deploy SA via WIF instead, so a runner compromise doesn't grant push.
resource "google_service_account" "gitea_runner_vm" {
account_id = "gitea-runner-vm"
display_name = "Gitea Actions runner (VM identity)"
description = "Boot-time identity for the GCE-hosted Gitea Actions runner. Read-only access to the registration token."
project = var.project_id
}
resource "google_secret_manager_secret_iam_member" "runner_token_access" {
project = var.project_id
secret_id = "gitea-runner-token"
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.gitea_runner_vm.email}"
}
# Logs/metrics from the VM's ops agent (if installed later)
resource "google_project_iam_member" "runner_log_writer" {
project = var.project_id
role = "roles/logging.logWriter"
member = "serviceAccount:${google_service_account.gitea_runner_vm.email}"
}
resource "google_project_iam_member" "runner_metric_writer" {
project = var.project_id
role = "roles/monitoring.metricWriter"
member = "serviceAccount:${google_service_account.gitea_runner_vm.email}"
}
# ── Cloud Run service-level deploy bindings ───────────────────────────────────
# `roles/run.developer` is granted project-wide on the ci_pusher SA in
# artifact-registry.tf — that's the simple path. We layer service-level
# bindings on top so the principle of least privilege is documented per
# service (and so future scoping reductions are mechanical).
locals {
ci_marketing_services = {
prod_us = { region = "us-central1", name = google_cloud_run_v2_service.prod_us.name }
prod_eu = { region = "europe-west1", name = google_cloud_run_v2_service.prod_eu.name }
prod_apac = { region = "asia-northeast1", name = google_cloud_run_v2_service.prod_apac.name }
}
}
resource "google_cloud_run_v2_service_iam_member" "ci_pusher_marketing_developer" {
for_each = local.ci_marketing_services
project = var.project_id
location = each.value.region
name = each.value.name
role = "roles/run.developer"
member = "serviceAccount:${google_service_account.ci_pusher.email}"
}
# ── Startup script staged in GCS ──────────────────────────────────────────────
# We stage the bootstrap script in a private GCS bucket and pull it via
# `gsutil cat` from a tiny bootstrap one-liner in metadata. This keeps the
# Terraform diff readable when the script changes.
resource "google_storage_bucket" "runner_assets" {
name = "${var.project_id}-runner-assets"
location = "US-CENTRAL1"
project = var.project_id
uniform_bucket_level_access = true
force_destroy = false
versioning {
enabled = true
}
lifecycle_rule {
condition {
num_newer_versions = 5
}
action {
type = "Delete"
}
}
}
resource "google_storage_bucket_iam_member" "runner_bucket_read" {
bucket = google_storage_bucket.runner_assets.name
role = "roles/storage.objectViewer"
member = "serviceAccount:${google_service_account.gitea_runner_vm.email}"
}
resource "google_storage_bucket_object" "runner_startup" {
name = "gitea-runner/startup.sh"
bucket = google_storage_bucket.runner_assets.name
source = "${path.module}/runners/startup.sh"
# Force a re-deploy of the VM when the script changes
metadata = {
sha256 = filesha256("${path.module}/runners/startup.sh")
}
}
# ── The runner VM ─────────────────────────────────────────────────────────────
resource "google_compute_instance" "gitea_runner" {
name = "gitea-runner-1"
machine_type = "e2-standard-4" # 4 vCPU / 16 GiB — comfortable for docker builds
zone = "us-central1-a"
project = var.project_id
tags = ["gitea-runner"]
boot_disk {
initialize_params {
image = "projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64"
size = 50
type = "pd-balanced"
}
}
network_interface {
network = "default"
# Ephemeral external IP — the runner needs outbound to git.neuralplatform.ai
# via Cloudflare and to *.googleapis.com. No inbound is permitted (no
# public SSH firewall rule below); ops is via IAP.
access_config {}
}
service_account {
email = google_service_account.gitea_runner_vm.email
scopes = ["cloud-platform"]
}
metadata = {
# Bootstrap shim — fetch and execute the real startup script from GCS.
# gsutil ships with the Google SDK preinstalled on Ubuntu base images.
startup-script = <<-EOT
#!/usr/bin/env bash
set -euxo pipefail
apt-get update
apt-get install -y curl ca-certificates apt-transport-https gnupg
curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
echo "deb https://packages.cloud.google.com/apt cloud-sdk main" > /etc/apt/sources.list.d/google-cloud-sdk.list
apt-get update
apt-get install -y google-cloud-cli
gsutil cat gs://${google_storage_bucket.runner_assets.name}/${google_storage_bucket_object.runner_startup.name} > /tmp/startup.sh
chmod +x /tmp/startup.sh
/tmp/startup.sh
EOT
enable-oslogin = "TRUE"
}
# Stop -> change machine type / metadata -> start cleanly
allow_stopping_for_update = true
depends_on = [
google_secret_manager_secret_iam_member.runner_token_access,
google_storage_bucket_object.runner_startup,
]
}
# ── Firewall: IAP-only SSH ────────────────────────────────────────────────────
# 35.235.240.0/20 is GCP's IAP CIDR. SSH from anywhere else is dropped.
# Ops shell:
# gcloud compute ssh gitea-runner-1 --zone=us-central1-a --tunnel-through-iap
resource "google_compute_firewall" "gitea_runner_iap_ssh" {
name = "gitea-runner-iap-ssh"
network = "default"
project = var.project_id
direction = "INGRESS"
priority = 1000
allow {
protocol = "tcp"
ports = ["22"]
}
source_ranges = ["35.235.240.0/20"]
target_tags = ["gitea-runner"]
}
# ── Workload Identity Federation: Gitea → GCP ─────────────────────────────────
# Gitea Actions issues OIDC tokens with `iss=https://git.neuralplatform.ai`
# (verified at runtime via the .well-known endpoint). The pool below trusts
# that issuer; the provider maps token claims into Google principal subjects.
#
# A workflow then runs:
# uses: google-github-actions/auth@v2
# with:
# workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}
# service_account: ${{ secrets.GCP_DEPLOY_SA }}
# and the auth action exchanges the OIDC token for a short-lived access
# token that impersonates google_service_account.ci_pusher.
#
# If Gitea's OIDC isn't reachable from `oauth2.googleapis.com` (CF Access in
# front of the .well-known endpoint, etc.), the JSON-key fallback documented
# in artifact-registry.tf still works.
resource "google_iam_workload_identity_pool" "gitea" {
workload_identity_pool_id = "gitea-pool"
project = var.project_id
display_name = "Gitea Actions"
description = "Trust pool for OIDC tokens from git.neuralplatform.ai"
}
resource "google_iam_workload_identity_pool_provider" "gitea" {
workload_identity_pool_id = google_iam_workload_identity_pool.gitea.workload_identity_pool_id
workload_identity_pool_provider_id = "gitea-oidc"
project = var.project_id
display_name = "Gitea OIDC"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
"attribute.actor" = "assertion.actor"
"attribute.ref" = "assertion.ref"
}
# Restrict which Gitea repos can mint tokens for this pool.
attribute_condition = "assertion.repository.startsWith('neuron-technologies/')"
oidc {
issuer_uri = "https://git.neuralplatform.ai"
# No `allowed_audiences` set — we accept the default audience the
# google-github-actions/auth action requests.
}
}
# Allow workflows from the neuron-technologies/neuron-web repo to impersonate
# the deploy SA. To extend to additional repos, add a second member.
resource "google_service_account_iam_member" "ci_pusher_wif_neuron_web" {
service_account_id = google_service_account.ci_pusher.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.gitea.name}/attribute.repository/neuron-technologies/neuron-web"
}
# ── Gitea Actions runner on GKE ───────────────────────────────────────────────
# A k8s Deployment in the `ci` namespace on GKE runs act_runner with a DinD
# sidecar. The pod uses this GCP SA via Workload Identity to pull the runner
# registration token from Secret Manager at startup.
#
# See servers/gcp/k8s/gitea-runner/ for the k8s manifests.
resource "google_service_account" "gitea_runner_gke" {
account_id = "gitea-runner-gke"
display_name = "Gitea Actions runner (GKE identity)"
description = "Workload Identity SA for the GKE-hosted Gitea Actions runner. Read-only access to the registration token."
project = var.project_id
}
# Allow the GKE SA to read the runner registration token from Secret Manager.
resource "google_secret_manager_secret_iam_member" "runner_gke_token_access" {
project = var.project_id
secret_id = "gitea-runner-token"
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.gitea_runner_gke.email}"
}
# Workload Identity binding — the k8s SA `gitea-runner` in the `ci` namespace
# can impersonate this GCP SA without a JSON key file.
resource "google_service_account_iam_member" "gitea_runner_gke_workload_identity" {
service_account_id = google_service_account.gitea_runner_gke.name
role = "roles/iam.workloadIdentityUser"
member = "serviceAccount:${var.project_id}.svc.id.goog[ci/gitea-runner]"
depends_on = [google_container_cluster.neuron_platform]
}
# Allow the runner to pull images from Artifact Registry (needed for build jobs
# that pull from neuron-* repos, and for the runner's own ci-base image).
resource "google_project_iam_member" "gitea_runner_gke_ar_reader" {
project = var.project_id
role = "roles/artifactregistry.reader"
member = "serviceAccount:${google_service_account.gitea_runner_gke.email}"
}
# ── Outputs ───────────────────────────────────────────────────────────────────
output "gitea_runner_vm_name" {
description = "GCE instance name for the Gitea Actions runner"
value = google_compute_instance.gitea_runner.name
}
output "gitea_runner_vm_zone" {
description = "GCE zone for the runner"
value = google_compute_instance.gitea_runner.zone
}
output "gitea_runner_vm_external_ip" {
description = "Ephemeral external IP for the runner (for outbound only; no inbound public SSH)"
value = google_compute_instance.gitea_runner.network_interface[0].access_config[0].nat_ip
}
output "gitea_runner_sa_email" {
description = "Boot-time identity for the runner VM"
value = google_service_account.gitea_runner_vm.email
}
output "gitea_wif_provider" {
description = "Full resource name for the Gitea OIDC WIF provider — set as Gitea repo secret GCP_WIF_PROVIDER"
value = google_iam_workload_identity_pool_provider.gitea.name
}
output "gitea_wif_deploy_sa" {
description = "Service account workflows impersonate via WIF — set as Gitea repo secret GCP_DEPLOY_SA"
value = google_service_account.ci_pusher.email
}