# ── 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 }