diff --git a/servers/gcp/artifact-registry.tf b/servers/gcp/artifact-registry.tf new file mode 100644 index 0000000..2791ccd --- /dev/null +++ b/servers/gcp/artifact-registry.tf @@ -0,0 +1,96 @@ +# ── Artifact Registry ───────────────────────────────────────────────────────── +# One repository per service. Images are pushed here from Legion CI runners +# (the only compiled artifacts that cross the boundary to GCP). +# Source code and intelligence stay on Legion — only the built container +# crosses out. + +resource "google_artifact_registry_repository" "marketing" { + location = "us-central1" + repository_id = "neuron-marketing" + description = "Marketing site (Next.js) Docker images" + format = "DOCKER" + project = var.project_id + + cleanup_policies { + id = "keep-last-10" + action = "KEEP" + most_recent_versions { + keep_count = 10 + } + } +} + +resource "google_artifact_registry_repository" "accounts" { + location = "us-central1" + repository_id = "neuron-accounts" + description = "Accounts service (Go) Docker images" + format = "DOCKER" + project = var.project_id + + cleanup_policies { + id = "keep-last-10" + action = "KEEP" + most_recent_versions { + keep_count = 10 + } + } +} + +resource "google_artifact_registry_repository" "api" { + location = "us-central1" + repository_id = "neuron-api" + description = "REST API (neuron-rest, Go) Docker images" + format = "DOCKER" + project = var.project_id + + cleanup_policies { + id = "keep-last-10" + action = "KEEP" + most_recent_versions { + keep_count = 10 + } + } +} + +# ── IAM: CI runner pushes images ────────────────────────────────────────────── +# The CI service account (from Legion's Gitea runner) needs Artifact Registry +# writer access so it can push compiled images after each build. + +resource "google_service_account" "ci_pusher" { + account_id = "neuron-ci-pusher" + display_name = "Neuron CI Image Pusher" + description = "Legion CI runner uses this SA to push compiled images to Artifact Registry" + project = var.project_id +} + +resource "google_artifact_registry_repository_iam_member" "ci_marketing" { + project = var.project_id + location = "us-central1" + repository = google_artifact_registry_repository.marketing.name + role = "roles/artifactregistry.writer" + member = "serviceAccount:${google_service_account.ci_pusher.email}" +} + +resource "google_artifact_registry_repository_iam_member" "ci_accounts" { + project = var.project_id + location = "us-central1" + repository = google_artifact_registry_repository.accounts.name + role = "roles/artifactregistry.writer" + member = "serviceAccount:${google_service_account.ci_pusher.email}" +} + +resource "google_artifact_registry_repository_iam_member" "ci_api" { + project = var.project_id + location = "us-central1" + repository = google_artifact_registry_repository.api.name + role = "roles/artifactregistry.writer" + member = "serviceAccount:${google_service_account.ci_pusher.email}" +} + +# ── CI pusher JSON key (download once, store in Vault/Secret Manager) ───────── +# After `terraform apply`, create a key and save it to GCP Secret Manager: +# gcloud iam service-accounts keys create /tmp/ci-pusher.json \ +# --iam-account=$(terraform output -raw ci_pusher_email) +# gcloud secrets create ci-gcp-sa-key --data-file=/tmp/ci-pusher.json +# rm /tmp/ci-pusher.json +# Then set it as a repo secret in Gitea: GCP_SA_KEY diff --git a/servers/gcp/bootstrap.sh b/servers/gcp/bootstrap.sh new file mode 100755 index 0000000..297a7ae --- /dev/null +++ b/servers/gcp/bootstrap.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# ── GCP Bootstrap — run once after terraform apply ──────────────────────────── +# Sets up secrets and credentials that Terraform can't create automatically. +# +# Prerequisites: +# - GOOGLE_APPLICATION_CREDENTIALS or `gcloud auth application-default login` +# - GCP project access +# - Vault running (for reading existing secrets) +# - terraform apply has run successfully (creates SA, secrets) +# +# Usage: +# cd servers/gcp +# terraform apply +# bash bootstrap.sh + +set -euo pipefail + +PROJECT="neuron-785695" +VAULT_ADDR="${VAULT_ADDR:-https://vault.neuralplatform.ai}" + +echo "==> Bootstrapping GCP secrets and CI credentials" + +# ── 1. Populate Stripe secrets in GCP Secret Manager ───────────────────────── +echo "--> Pulling Stripe secrets from Vault..." +MARKETING_SECRETS=$(curl -sf \ + -H "X-Vault-Token: ${VAULT_TOKEN}" \ + "${VAULT_ADDR}/v1/secret/data/neuron-technologies/marketing") + +populate_secret() { + local secret_id="$1" + local vault_key="$2" + local value + value=$(echo "${MARKETING_SECRETS}" | python3 -c " +import json,sys; d=json.load(sys.stdin)['data']['data']; print(d.get('${vault_key}','')) +") + if [ -z "${value}" ]; then + echo " WARN: vault key '${vault_key}' not found — skipping ${secret_id}" + return + fi + # Check if secret already has a version + if gcloud secrets versions list "${secret_id}" --project="${PROJECT}" \ + --format="value(name)" 2>/dev/null | grep -q .; then + echo " SKIP: ${secret_id} already has a version" + else + echo "${value}" | gcloud secrets versions add "${secret_id}" \ + --project="${PROJECT}" \ + --data-file=- + echo " OK: ${secret_id}" + fi +} + +populate_secret "stripe-secret-key" "stripe_secret_key" +populate_secret "stripe-webhook-secret" "stripe_webhook_secret" +populate_secret "stripe-price-professional" "stripe_price_professional" +populate_secret "stripe-price-founding" "stripe_price_founding" + +# ── 2. License admin token ──────────────────────────────────────────────────── +echo "--> Populating license-admin-token..." +LICENSE_TOKEN=$(curl -sf \ + -H "X-Vault-Token: ${VAULT_TOKEN}" \ + "${VAULT_ADDR}/v1/secret/data/neuron-technologies/marketing" \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['data'].get('license_admin_token',''))") +if [ -n "${LICENSE_TOKEN}" ]; then + echo "${LICENSE_TOKEN}" | gcloud secrets versions add "license-admin-token" \ + --project="${PROJECT}" --data-file=- 2>/dev/null || echo " SKIP: already exists" + echo " OK: license-admin-token" +fi + +# ── 3. Create CI service account JSON key ──────────────────────────────────── +CI_SA_EMAIL=$(terraform output -raw ci_pusher_email) +echo "--> Creating CI SA key for: ${CI_SA_EMAIL}" +KEY_FILE="/tmp/neuron-ci-pusher.json" +gcloud iam service-accounts keys create "${KEY_FILE}" \ + --iam-account="${CI_SA_EMAIL}" \ + --project="${PROJECT}" +echo " SA key written to: ${KEY_FILE}" +echo "" +echo " !! Add this as a Gitea secret GCP_SA_KEY in the neuron-technologies/neuron repo:" +echo " tea secret set --repo neuron-technologies/neuron GCP_SA_KEY < ${KEY_FILE}" +echo " Then delete the key file:" +echo " rm ${KEY_FILE}" +echo "" + +# ── 4. Summary ──────────────────────────────────────────────────────────────── +echo "==> Bootstrap complete" +echo "" +echo "Next steps:" +echo " 1. Set Gitea secret: tea secret set --repo neuron-technologies/neuron GCP_SA_KEY < /tmp/neuron-ci-pusher.json" +echo " 2. Delete key file: rm /tmp/neuron-ci-pusher.json" +echo " 3. Point DNS at: $(terraform output -raw prod_lb_ip)" +echo " A records (proxied=true in Cloudflare):" +echo " neurontechnologies.ai → prod_lb_ip" +echo " www.neurontechnologies.ai → prod_lb_ip" +echo " api.neurontechnologies.ai → prod_lb_ip" +echo " accounts.neurontechnologies.ai → prod_lb_ip" +echo " 4. Run a marketing CI build on main to push first image to GCP AR" +echo " 5. Check SSL cert provisioning: gcloud compute ssl-certificates list --project=${PROJECT}" diff --git a/servers/gcp/cloud-run-accounts.tf b/servers/gcp/cloud-run-accounts.tf new file mode 100644 index 0000000..b15dc45 --- /dev/null +++ b/servers/gcp/cloud-run-accounts.tf @@ -0,0 +1,330 @@ +# ── Accounts Service — Cloud Run ────────────────────────────────────────────── +# Handles auth, billing, subscriptions, marketplace. +# Connects to Cloud SQL via built-in Auth Proxy (Unix socket volume mount). +# Deployed in 3 regions for global latency; all regions share the same +# Cloud SQL instance (us-central1). Auth lookups are fast — cross-region +# latency acceptable until read replicas are warranted. + +locals { + accounts_labels = { + "managed-by" = "terraform" + "service" = "neuron-accounts" + } + sql_instance = google_sql_database_instance.main.connection_name +} + +# ── Prod — us-central1 ──────────────────────────────────────────────────────── + +resource "google_cloud_run_v2_service" "accounts_us" { + name = "accounts-prod-us" + location = "us-central1" + project = var.project_id + ingress = "INGRESS_TRAFFIC_ALL" + labels = local.accounts_labels + + template { + service_account = google_service_account.accounts.email + + scaling { + min_instance_count = 1 + max_instance_count = 50 + } + + containers { + image = local.accounts_image + + resources { + limits = { + cpu = "1" + memory = "512Mi" + } + cpu_idle = true # accounts is bursty; don't pay for idle CPU + } + + env { + name = "ENV" + value = "production" + } + env { + name = "PORT" + value = "8080" + } + + # Database URL via Secret Manager (uses /cloudsql/ socket path) + env { + name = "ACCOUNTS_DATABASE_URL" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.accounts_database_url.secret_id + version = "latest" + } + } + } + + # JWT signing key (shared with API service) + env { + name = "JWT_SECRET" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.jwt_secret.secret_id + version = "latest" + } + } + } + + # Stripe secrets (accounts handles purchase webhooks + subscription management) + env { + name = "STRIPE_SECRET_KEY" + value_source { + secret_key_ref { + secret = "stripe-secret-key" + version = "latest" + } + } + } + env { + name = "STRIPE_WEBHOOK_SECRET" + value_source { + secret_key_ref { + secret = "stripe-webhook-secret" + version = "latest" + } + } + } + env { + name = "STRIPE_PRICE_PROFESSIONAL" + value_source { + secret_key_ref { + secret = "stripe-price-professional" + version = "latest" + } + } + } + env { + name = "STRIPE_PRICE_FOUNDING" + value_source { + secret_key_ref { + secret = "stripe-price-founding" + version = "latest" + } + } + } + + ports { + container_port = 8080 + name = "http1" + } + + startup_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 2 + timeout_seconds = 5 + period_seconds = 5 + failure_threshold = 10 + } + + liveness_probe { + http_get { + path = "/health" + port = 8080 + } + timeout_seconds = 5 + period_seconds = 30 + failure_threshold = 3 + } + + # Cloud SQL Auth Proxy socket mount + volume_mounts { + name = "cloudsql" + mount_path = "/cloudsql" + } + } + + volumes { + name = "cloudsql" + cloud_sql_instance { + instances = [local.sql_instance] + } + } + } + + traffic { + type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" + percent = 100 + } + + depends_on = [ + google_project_iam_member.accounts_secret_accessor, + google_project_iam_member.accounts_sql_client, + google_secret_manager_secret_version.accounts_database_url, + google_secret_manager_secret_version.jwt_secret, + ] +} + +# ── Prod — europe-west1 ─────────────────────────────────────────────────────── + +resource "google_cloud_run_v2_service" "accounts_eu" { + name = "accounts-prod-eu" + location = "europe-west1" + project = var.project_id + ingress = "INGRESS_TRAFFIC_ALL" + labels = local.accounts_labels + + template { + service_account = google_service_account.accounts.email + + scaling { + min_instance_count = 1 + max_instance_count = 50 + } + + containers { + image = local.accounts_image + + resources { + limits = { + cpu = "1" + memory = "512Mi" + } + cpu_idle = true + } + + env { name = "ENV"; value = "production" } + env { name = "PORT"; value = "8080" } + + env { + name = "ACCOUNTS_DATABASE_URL" + value_source { secret_key_ref { secret = google_secret_manager_secret.accounts_database_url.secret_id; version = "latest" } } + } + env { + name = "JWT_SECRET" + value_source { secret_key_ref { secret = google_secret_manager_secret.jwt_secret.secret_id; version = "latest" } } + } + env { + name = "STRIPE_SECRET_KEY" + value_source { secret_key_ref { secret = "stripe-secret-key"; version = "latest" } } + } + env { + name = "STRIPE_WEBHOOK_SECRET" + value_source { secret_key_ref { secret = "stripe-webhook-secret"; version = "latest" } } + } + env { + name = "STRIPE_PRICE_PROFESSIONAL" + value_source { secret_key_ref { secret = "stripe-price-professional"; version = "latest" } } + } + env { + name = "STRIPE_PRICE_FOUNDING" + value_source { secret_key_ref { secret = "stripe-price-founding"; version = "latest" } } + } + + ports { container_port = 8080; name = "http1" } + + startup_probe { + http_get { path = "/health"; port = 8080 } + initial_delay_seconds = 2; timeout_seconds = 5; period_seconds = 5; failure_threshold = 10 + } + liveness_probe { + http_get { path = "/health"; port = 8080 } + timeout_seconds = 5; period_seconds = 30; failure_threshold = 3 + } + + volume_mounts { name = "cloudsql"; mount_path = "/cloudsql" } + } + + volumes { + name = "cloudsql" + cloud_sql_instance { instances = [local.sql_instance] } + } + } + + traffic { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"; percent = 100 } + + depends_on = [ + google_project_iam_member.accounts_secret_accessor, + google_project_iam_member.accounts_sql_client, + ] +} + +# ── Prod — asia-northeast1 ──────────────────────────────────────────────────── + +resource "google_cloud_run_v2_service" "accounts_apac" { + name = "accounts-prod-apac" + location = "asia-northeast1" + project = var.project_id + ingress = "INGRESS_TRAFFIC_ALL" + labels = local.accounts_labels + + template { + service_account = google_service_account.accounts.email + + scaling { + min_instance_count = 1 + max_instance_count = 50 + } + + containers { + image = local.accounts_image + + resources { + limits = { cpu = "1"; memory = "512Mi" } + cpu_idle = true + } + + env { name = "ENV"; value = "production" } + env { name = "PORT"; value = "8080" } + + env { + name = "ACCOUNTS_DATABASE_URL" + value_source { secret_key_ref { secret = google_secret_manager_secret.accounts_database_url.secret_id; version = "latest" } } + } + env { + name = "JWT_SECRET" + value_source { secret_key_ref { secret = google_secret_manager_secret.jwt_secret.secret_id; version = "latest" } } + } + env { + name = "STRIPE_SECRET_KEY" + value_source { secret_key_ref { secret = "stripe-secret-key"; version = "latest" } } + } + env { + name = "STRIPE_WEBHOOK_SECRET" + value_source { secret_key_ref { secret = "stripe-webhook-secret"; version = "latest" } } + } + env { + name = "STRIPE_PRICE_PROFESSIONAL" + value_source { secret_key_ref { secret = "stripe-price-professional"; version = "latest" } } + } + env { + name = "STRIPE_PRICE_FOUNDING" + value_source { secret_key_ref { secret = "stripe-price-founding"; version = "latest" } } + } + + ports { container_port = 8080; name = "http1" } + + startup_probe { + http_get { path = "/health"; port = 8080 } + initial_delay_seconds = 2; timeout_seconds = 5; period_seconds = 5; failure_threshold = 10 + } + liveness_probe { + http_get { path = "/health"; port = 8080 } + timeout_seconds = 5; period_seconds = 30; failure_threshold = 3 + } + + volume_mounts { name = "cloudsql"; mount_path = "/cloudsql" } + } + + volumes { + name = "cloudsql" + cloud_sql_instance { instances = [local.sql_instance] } + } + } + + traffic { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"; percent = 100 } + + depends_on = [ + google_project_iam_member.accounts_secret_accessor, + google_project_iam_member.accounts_sql_client, + ] +} diff --git a/servers/gcp/cloud-run-api.tf b/servers/gcp/cloud-run-api.tf new file mode 100644 index 0000000..f6e79ad --- /dev/null +++ b/servers/gcp/cloud-run-api.tf @@ -0,0 +1,226 @@ +# ── REST API (neuron-rest) — Cloud Run ──────────────────────────────────────── +# Stateless API layer. Validates JWTs issued by the accounts service. +# Does NOT connect to Cloud SQL directly — reads from accounts service for +# user context; maintains its own in-process state only. +# No Cloud SQL volume needed here. + +locals { + api_labels = { + "managed-by" = "terraform" + "service" = "neuron-api" + } +} + +# ── Prod — us-central1 ──────────────────────────────────────────────────────── + +resource "google_cloud_run_v2_service" "api_us" { + name = "api-prod-us" + location = "us-central1" + project = var.project_id + ingress = "INGRESS_TRAFFIC_ALL" + labels = local.api_labels + + template { + service_account = google_service_account.api.email + + scaling { + min_instance_count = 1 + max_instance_count = 100 + } + + containers { + image = local.api_image + + resources { + limits = { + cpu = "2" + memory = "1Gi" + } + cpu_idle = false # API needs to be always-hot; no cold starts + } + + env { name = "ENV"; value = "production" } + env { name = "PORT"; value = "8080" } + + # JWT key — shared with accounts service for token validation + env { + name = "JWT_SECRET" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.jwt_secret.secret_id + version = "latest" + } + } + } + + # License admin token — for neuron-mcp license checks + env { + name = "LICENSE_ADMIN_TOKEN" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.license_admin_token.secret_id + version = "latest" + } + } + } + + # Accounts service URL — for JWT validation and user lookups + env { + name = "ACCOUNTS_SERVICE_URL" + value = "https://accounts.neurontechnologies.ai" + } + + ports { + container_port = 8080 + name = "http1" + } + + startup_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 2 + timeout_seconds = 5 + period_seconds = 5 + failure_threshold = 6 + } + + liveness_probe { + http_get { + path = "/health" + port = 8080 + } + timeout_seconds = 5 + period_seconds = 30 + failure_threshold = 3 + } + } + + max_instance_request_concurrency = 1000 + } + + traffic { + type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" + percent = 100 + } + + depends_on = [ + google_project_iam_member.api_secret_accessor, + google_secret_manager_secret_version.jwt_secret, + ] +} + +# ── Prod — europe-west1 ─────────────────────────────────────────────────────── + +resource "google_cloud_run_v2_service" "api_eu" { + name = "api-prod-eu" + location = "europe-west1" + project = var.project_id + ingress = "INGRESS_TRAFFIC_ALL" + labels = local.api_labels + + template { + service_account = google_service_account.api.email + + scaling { + min_instance_count = 1 + max_instance_count = 100 + } + + containers { + image = local.api_image + + resources { + limits = { cpu = "2"; memory = "1Gi" } + cpu_idle = false + } + + env { name = "ENV"; value = "production" } + env { name = "PORT"; value = "8080" } + + env { + name = "JWT_SECRET" + value_source { secret_key_ref { secret = google_secret_manager_secret.jwt_secret.secret_id; version = "latest" } } + } + env { + name = "LICENSE_ADMIN_TOKEN" + value_source { secret_key_ref { secret = google_secret_manager_secret.license_admin_token.secret_id; version = "latest" } } + } + env { name = "ACCOUNTS_SERVICE_URL"; value = "https://accounts.neurontechnologies.ai" } + + ports { container_port = 8080; name = "http1" } + + startup_probe { + http_get { path = "/health"; port = 8080 } + initial_delay_seconds = 2; timeout_seconds = 5; period_seconds = 5; failure_threshold = 6 + } + liveness_probe { + http_get { path = "/health"; port = 8080 } + timeout_seconds = 5; period_seconds = 30; failure_threshold = 3 + } + } + + max_instance_request_concurrency = 1000 + } + + traffic { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"; percent = 100 } + depends_on = [google_project_iam_member.api_secret_accessor] +} + +# ── Prod — asia-northeast1 ──────────────────────────────────────────────────── + +resource "google_cloud_run_v2_service" "api_apac" { + name = "api-prod-apac" + location = "asia-northeast1" + project = var.project_id + ingress = "INGRESS_TRAFFIC_ALL" + labels = local.api_labels + + template { + service_account = google_service_account.api.email + + scaling { + min_instance_count = 1 + max_instance_count = 100 + } + + containers { + image = local.api_image + + resources { + limits = { cpu = "2"; memory = "1Gi" } + cpu_idle = false + } + + env { name = "ENV"; value = "production" } + env { name = "PORT"; value = "8080" } + + env { + name = "JWT_SECRET" + value_source { secret_key_ref { secret = google_secret_manager_secret.jwt_secret.secret_id; version = "latest" } } + } + env { + name = "LICENSE_ADMIN_TOKEN" + value_source { secret_key_ref { secret = google_secret_manager_secret.license_admin_token.secret_id; version = "latest" } } + } + env { name = "ACCOUNTS_SERVICE_URL"; value = "https://accounts.neurontechnologies.ai" } + + ports { container_port = 8080; name = "http1" } + + startup_probe { + http_get { path = "/health"; port = 8080 } + initial_delay_seconds = 2; timeout_seconds = 5; period_seconds = 5; failure_threshold = 6 + } + liveness_probe { + http_get { path = "/health"; port = 8080 } + timeout_seconds = 5; period_seconds = 30; failure_threshold = 3 + } + } + + max_instance_request_concurrency = 1000 + } + + traffic { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"; percent = 100 } + depends_on = [google_project_iam_member.api_secret_accessor] +} diff --git a/servers/gcp/cloud-sql.tf b/servers/gcp/cloud-sql.tf new file mode 100644 index 0000000..f4c42eb --- /dev/null +++ b/servers/gcp/cloud-sql.tf @@ -0,0 +1,150 @@ +# ── Cloud SQL — PostgreSQL 15 ───────────────────────────────────────────────── +# Single instance in us-central1 for the accounts service. +# Cloud Run services connect via the built-in Cloud SQL Auth Proxy +# (no direct IP exposure, encrypted, IAM-authenticated). +# +# Sizing: db-g1-small (1 shared vCPU, 1.7 GB) for launch. Scale up to +# db-n1-standard-2 once traffic warrants it — zero-downtime restart. + +resource "google_sql_database_instance" "main" { + name = "neuron-prod-pg15" + database_version = "POSTGRES_15" + region = "us-central1" + project = var.project_id + + # Prevent accidental deletion via terraform destroy + deletion_protection = true + + settings { + tier = "db-g1-small" + availability_type = "ZONAL" # Upgrade to REGIONAL (HA) once > 1k users + + backup_configuration { + enabled = true + point_in_time_recovery_enabled = true + start_time = "03:00" # 3am UTC — before backup window on Legion + transaction_log_retention_days = 7 + backup_retention_settings { + retained_backups = 14 + } + } + + maintenance_window { + day = 7 # Sunday + hour = 4 # 4am UTC + update_track = "stable" + } + + ip_configuration { + # No public IP — Cloud Run uses the Auth Proxy via private service connect + # Flip ipv4_enabled=true + authorized_networks if you ever need direct access + # for migrations/seeding from a bastion. + ipv4_enabled = true # Required for Cloud Run Auth Proxy (until VPC SC configured) + ssl_mode = "ENCRYPTED_ONLY" + } + + database_flags { + name = "max_connections" + value = "100" # Cloud Run can burst many instances; cap connections early + } + + database_flags { + name = "log_min_duration_statement" + value = "500" # Log queries taking > 500ms + } + + insights_config { + query_insights_enabled = true + query_string_length = 1024 + record_application_tags = true + record_client_address = false + } + } +} + +# ── Databases ───────────────────────────────────────────────────────────────── + +resource "google_sql_database" "accounts" { + name = "accounts" + instance = google_sql_database_instance.main.name + project = var.project_id +} + +# ── Users ───────────────────────────────────────────────────────────────────── +# Passwords are stored in Secret Manager. The accounts service reads them +# at runtime via the ACCOUNTS_DATABASE_URL secret. +# Cloud IAM DB users are the preferred path long-term (no password rotation); +# using password auth here for compatibility with pgx/stdlib. + +resource "google_sql_user" "accounts" { + name = "accounts" + instance = google_sql_database_instance.main.name + project = var.project_id + password = random_password.accounts_db.result +} + +resource "random_password" "accounts_db" { + length = 32 + special = true +} + +# ── Secret Manager — Database URL ───────────────────────────────────────────── +# Stored as a full DSN so the app just reads one env var. +# Uses /cloudsql/ Unix socket path — Cloud Run Auth Proxy mounts it there. + +resource "google_secret_manager_secret" "accounts_database_url" { + secret_id = "accounts-database-url" + project = var.project_id + + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "accounts_database_url" { + secret = google_secret_manager_secret.accounts_database_url.id + secret_data = "host=/cloudsql/${google_sql_database_instance.main.connection_name} user=accounts password=${random_password.accounts_db.result} dbname=accounts sslmode=disable" +} + +# ── Secret Manager — JWT signing key ───────────────────────────────────────── +# Shared between accounts (issues tokens) and neuron-rest (validates tokens). + +resource "random_password" "jwt_secret" { + length = 64 + special = false # URL-safe; used as HMAC key +} + +resource "google_secret_manager_secret" "jwt_secret" { + secret_id = "jwt-signing-key" + project = var.project_id + + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "jwt_secret" { + secret = google_secret_manager_secret.jwt_secret.id + secret_data = random_password.jwt_secret.result +} + +# ── Secret Manager — License admin token ───────────────────────────────────── +# Used by neuron-rest to authorize license validation calls. +# Currently held in Vault under neuron-technologies/marketing.license_admin_token. +# Create this secret manually from Vault: +# gcloud secrets create license-admin-token --data-file=<(vault kv get \ +# -field=license_admin_token secret/neuron-technologies/marketing) + +resource "google_secret_manager_secret" "license_admin_token" { + secret_id = "license-admin-token" + project = var.project_id + + replication { + auto {} + } + # Version populated manually or via bootstrap script — not managed by Terraform + # to avoid exposing the value in state. + lifecycle { + ignore_changes = [id] + } +} diff --git a/servers/gcp/load-balancer-backends.tf b/servers/gcp/load-balancer-backends.tf new file mode 100644 index 0000000..a7ab25e --- /dev/null +++ b/servers/gcp/load-balancer-backends.tf @@ -0,0 +1,119 @@ +# ── Serverless NEGs — Accounts ──────────────────────────────────────────────── + +resource "google_compute_region_network_endpoint_group" "accounts_us" { + name = "accounts-neg-us" + network_endpoint_type = "SERVERLESS" + region = "us-central1" + project = var.project_id + cloud_run { service = google_cloud_run_v2_service.accounts_us.name } +} + +resource "google_compute_region_network_endpoint_group" "accounts_eu" { + name = "accounts-neg-eu" + network_endpoint_type = "SERVERLESS" + region = "europe-west1" + project = var.project_id + cloud_run { service = google_cloud_run_v2_service.accounts_eu.name } +} + +resource "google_compute_region_network_endpoint_group" "accounts_apac" { + name = "accounts-neg-apac" + network_endpoint_type = "SERVERLESS" + region = "asia-northeast1" + project = var.project_id + cloud_run { service = google_cloud_run_v2_service.accounts_apac.name } +} + +# ── Serverless NEGs — API ───────────────────────────────────────────────────── + +resource "google_compute_region_network_endpoint_group" "api_us" { + name = "api-neg-us" + network_endpoint_type = "SERVERLESS" + region = "us-central1" + project = var.project_id + cloud_run { service = google_cloud_run_v2_service.api_us.name } +} + +resource "google_compute_region_network_endpoint_group" "api_eu" { + name = "api-neg-eu" + network_endpoint_type = "SERVERLESS" + region = "europe-west1" + project = var.project_id + cloud_run { service = google_cloud_run_v2_service.api_eu.name } +} + +resource "google_compute_region_network_endpoint_group" "api_apac" { + name = "api-neg-apac" + network_endpoint_type = "SERVERLESS" + region = "asia-northeast1" + project = var.project_id + cloud_run { service = google_cloud_run_v2_service.api_apac.name } +} + +# ── Backend Service — Accounts ──────────────────────────────────────────────── + +resource "google_compute_backend_service" "accounts" { + name = "accounts-backend-prod" + project = var.project_id + load_balancing_scheme = "EXTERNAL_MANAGED" + protocol = "HTTPS" + timeout_sec = 30 + + security_policy = google_compute_security_policy.marketing.self_link + + # No CDN for accounts — responses are user-specific and must not be cached + enable_cdn = false + + backend { group = google_compute_region_network_endpoint_group.accounts_us.self_link } + backend { group = google_compute_region_network_endpoint_group.accounts_eu.self_link } + backend { group = google_compute_region_network_endpoint_group.accounts_apac.self_link } + + log_config { + enable = true + sample_rate = 1.0 + } +} + +# ── Backend Service — API ───────────────────────────────────────────────────── + +resource "google_compute_backend_service" "api" { + name = "api-backend-prod" + project = var.project_id + load_balancing_scheme = "EXTERNAL_MANAGED" + protocol = "HTTPS" + timeout_sec = 60 # API calls may be longer (AI inference routing) + + security_policy = google_compute_security_policy.marketing.self_link + + enable_cdn = false # API responses are per-request; no CDN benefit + + backend { group = google_compute_region_network_endpoint_group.api_us.self_link } + backend { group = google_compute_region_network_endpoint_group.api_eu.self_link } + backend { group = google_compute_region_network_endpoint_group.api_apac.self_link } + + log_config { + enable = true + sample_rate = 1.0 + } +} + +# ── SSL Certs — accounts and api subdomains ─────────────────────────────────── +# Added to the existing prod HTTPS proxy alongside the marketing cert. +# DNS must point to the same prod global IP (marketing-ip-prod) for +# provisioning to succeed. + +resource "google_compute_managed_ssl_certificate" "accounts" { + name = "accounts-cert-prod" + project = var.project_id + managed { + domains = ["accounts.neurontechnologies.ai"] + } +} + +resource "google_compute_managed_ssl_certificate" "api" { + name = "api-cert-prod" + project = var.project_id + managed { + domains = ["api.neurontechnologies.ai"] + } +} diff --git a/servers/gcp/load-balancer.tf b/servers/gcp/load-balancer.tf index 9a6c345..0aa21aa 100644 --- a/servers/gcp/load-balancer.tf +++ b/servers/gcp/load-balancer.tf @@ -223,21 +223,59 @@ resource "google_compute_managed_ssl_certificate" "stage" { } } -# ── Prod URL Map ────────────────────────────────────────────────────────────── +# ── Prod URL Map — host-based routing ──────────────────────────────────────── +# One global IP handles all three services via Host header routing. +# - neurontechnologies.ai / www. → marketing (default) +# - api.neurontechnologies.ai → API backend +# - accounts.neurontechnologies.ai → accounts backend resource "google_compute_url_map" "prod" { name = "marketing-urlmap-prod" project = var.project_id default_service = google_compute_backend_service.prod.self_link + + host_rule { + hosts = ["neurontechnologies.ai", "www.neurontechnologies.ai"] + path_matcher = "marketing" + } + + host_rule { + hosts = ["api.neurontechnologies.ai"] + path_matcher = "api" + } + + host_rule { + hosts = ["accounts.neurontechnologies.ai"] + path_matcher = "accounts" + } + + path_matcher { + name = "marketing" + default_service = google_compute_backend_service.prod.self_link + } + + path_matcher { + name = "api" + default_service = google_compute_backend_service.api.self_link + } + + path_matcher { + name = "accounts" + default_service = google_compute_backend_service.accounts.self_link + } } # ── Prod HTTPS Target Proxy ─────────────────────────────────────────────────── resource "google_compute_target_https_proxy" "prod" { - name = "marketing-https-proxy-prod" - project = var.project_id - url_map = google_compute_url_map.prod.self_link - ssl_certificates = [google_compute_managed_ssl_certificate.prod.self_link] + name = "marketing-https-proxy-prod" + project = var.project_id + url_map = google_compute_url_map.prod.self_link + ssl_certificates = [ + google_compute_managed_ssl_certificate.prod.self_link, + google_compute_managed_ssl_certificate.accounts.self_link, + google_compute_managed_ssl_certificate.api.self_link, + ] } # ── Prod HTTP → HTTPS redirect ──────────────────────────────────────────────── diff --git a/servers/gcp/main.tf b/servers/gcp/main.tf index 702fab2..47a109c 100644 --- a/servers/gcp/main.tf +++ b/servers/gcp/main.tf @@ -10,6 +10,10 @@ terraform { source = "hashicorp/google-beta" version = "~> 5.0" } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } } backend "s3" { diff --git a/servers/gcp/outputs.tf b/servers/gcp/outputs.tf index 5613db7..1ab4933 100644 --- a/servers/gcp/outputs.tf +++ b/servers/gcp/outputs.tf @@ -1,44 +1,96 @@ # ── Outputs ─────────────────────────────────────────────────────────────────── -# After `terraform apply`, use these IPs to update Cloudflare DNS A records. +# After `terraform apply`, use prod_lb_ip for Cloudflare DNS A records. # -# Prod: neurontechnologies.ai + www.neurontechnologies.ai → prod_lb_ip -# Stage: stage.neurontechnologies.ai → stage_lb_ip +# All three services share the same global anycast IP (prod_lb_ip). +# Set these A records in Cloudflare (proxied=true for CDN + DDoS): +# neurontechnologies.ai → prod_lb_ip +# www.neurontechnologies.ai → prod_lb_ip +# api.neurontechnologies.ai → prod_lb_ip +# accounts.neurontechnologies.ai → prod_lb_ip # -# In the Legion Terraform (dns-neurontechnologies.tf), replace the tunnel CNAMEs -# for neurontechnologies.ai, www, and stage with A records pointing to these IPs. -# Set proxied=true in Cloudflare to keep CDN/DDoS protection layered in front. +# Stage: stage.neurontechnologies.ai → stage_lb_ip (separate IP) output "prod_lb_ip" { - description = "Global anycast IP for the production load balancer (neurontechnologies.ai + www)" + description = "Global anycast IP for all prod services (marketing, accounts, api)" value = google_compute_global_address.prod.address } output "stage_lb_ip" { - description = "Global anycast IP for the staging load balancer (stage.neurontechnologies.ai)" + description = "Global anycast IP for the staging load balancer" value = google_compute_global_address.stage.address } output "prod_ssl_cert_name" { - description = "Name of the Google-managed SSL cert for prod (check provisioning status in GCP console)" + description = "Marketing SSL cert (check provisioning status in GCP console)" value = google_compute_managed_ssl_certificate.prod.name } +output "accounts_ssl_cert_name" { + description = "Accounts SSL cert (check provisioning status in GCP console)" + value = google_compute_managed_ssl_certificate.accounts.name +} + +output "api_ssl_cert_name" { + description = "API SSL cert (check provisioning status in GCP console)" + value = google_compute_managed_ssl_certificate.api.name +} + output "stage_ssl_cert_name" { - description = "Name of the Google-managed SSL cert for stage" + description = "Stage SSL cert" value = google_compute_managed_ssl_certificate.stage.name } output "marketing_service_account_email" { - description = "Service account email for Cloud Run services" + description = "Marketing Cloud Run SA" value = google_service_account.marketing.email } +output "accounts_service_account_email" { + description = "Accounts Cloud Run SA" + value = google_service_account.accounts.email +} + +output "api_service_account_email" { + description = "API Cloud Run SA" + value = google_service_account.api.email +} + +output "ci_pusher_email" { + description = "CI SA email — use to create the JSON key for GCP_SA_KEY Gitea secret" + value = google_service_account.ci_pusher.email +} + +output "cloud_sql_connection_name" { + description = "Cloud SQL instance connection name — use in Cloud Run volume mounts" + value = google_sql_database_instance.main.connection_name +} + +output "cloud_sql_instance_ip" { + description = "Cloud SQL public IP (for bastion/migration access — disable when not needed)" + value = google_sql_database_instance.main.public_ip_address +} + output "cloud_run_services" { - description = "Cloud Run service URLs per region" + description = "Cloud Run service URLs per service and region" value = { - prod_us = google_cloud_run_v2_service.prod_us.uri - prod_eu = google_cloud_run_v2_service.prod_eu.uri - prod_apac = google_cloud_run_v2_service.prod_apac.uri - stage = google_cloud_run_v2_service.stage.uri + marketing_us = google_cloud_run_v2_service.prod_us.uri + marketing_eu = google_cloud_run_v2_service.prod_eu.uri + marketing_apac = google_cloud_run_v2_service.prod_apac.uri + marketing_stage = google_cloud_run_v2_service.stage.uri + accounts_us = google_cloud_run_v2_service.accounts_us.uri + accounts_eu = google_cloud_run_v2_service.accounts_eu.uri + accounts_apac = google_cloud_run_v2_service.accounts_apac.uri + api_us = google_cloud_run_v2_service.api_us.uri + api_eu = google_cloud_run_v2_service.api_eu.uri + api_apac = google_cloud_run_v2_service.api_apac.uri + } +} + +output "artifact_registry_urls" { + description = "Docker image base URLs for each service" + value = { + marketing = "us-central1-docker.pkg.dev/${var.project_id}/neuron-marketing/marketing" + accounts = "us-central1-docker.pkg.dev/${var.project_id}/neuron-accounts/accounts" + api = "us-central1-docker.pkg.dev/${var.project_id}/neuron-api/api" } } diff --git a/servers/gcp/service-account.tf b/servers/gcp/service-account.tf index 70fcc90..1c0692e 100644 --- a/servers/gcp/service-account.tf +++ b/servers/gcp/service-account.tf @@ -1,13 +1,27 @@ -# ── Service Account ─────────────────────────────────────────────────────────── +# ── Service Accounts ────────────────────────────────────────────────────────── resource "google_service_account" "marketing" { account_id = "neuron-marketing-sa" display_name = "Neuron Marketing Cloud Run SA" description = "Service account for the neurontechnologies.ai marketing site on Cloud Run" + project = var.project_id } -# ── IAM — Secret Manager accessor ───────────────────────────────────────────── -# Grant the SA access to read all marketing secrets from Secret Manager. +resource "google_service_account" "accounts" { + account_id = "neuron-accounts-sa" + display_name = "Neuron Accounts Cloud Run SA" + description = "Service account for the accounts service (auth, billing, marketplace)" + project = var.project_id +} + +resource "google_service_account" "api" { + account_id = "neuron-api-sa" + display_name = "Neuron REST API Cloud Run SA" + description = "Service account for the neuron-rest API service" + project = var.project_id +} + +# ── IAM — Secret Manager ────────────────────────────────────────────────────── resource "google_project_iam_member" "marketing_secret_accessor" { project = var.project_id @@ -15,9 +29,32 @@ resource "google_project_iam_member" "marketing_secret_accessor" { member = "serviceAccount:${google_service_account.marketing.email}" } -# ── IAM — Public (unauthenticated) invocation for prod services ─────────────── -# Cloud Run v2: allow-unauthenticated is set per-service via google_cloud_run_v2_service_iam_member. +resource "google_project_iam_member" "accounts_secret_accessor" { + project = var.project_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.accounts.email}" +} +resource "google_project_iam_member" "api_secret_accessor" { + project = var.project_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.api.email}" +} + +# ── IAM — Cloud SQL ─────────────────────────────────────────────────────────── +# Only accounts connects to Cloud SQL; API and marketing don't need DB access. + +resource "google_project_iam_member" "accounts_sql_client" { + project = var.project_id + role = "roles/cloudsql.client" + member = "serviceAccount:${google_service_account.accounts.email}" +} + +# ── IAM — Public invocation (Cloud Run → LB) ────────────────────────────────── +# allUsers invoker allows the global HTTP(S) load balancer's health checks +# and traffic to reach the services without additional auth headers. + +# Marketing resource "google_cloud_run_v2_service_iam_member" "prod_us_public" { project = var.project_id location = "us-central1" @@ -48,7 +85,55 @@ resource "google_cloud_run_v2_service_iam_member" "stage_public" { name = google_cloud_run_v2_service.stage.name role = "roles/run.invoker" member = "allUsers" - - # Note: Cloudflare Access enforces authentication in front of stage. - # Cloud Run itself allows allUsers so the LB health checks pass. + # Note: Cloudflare Access enforces auth in front of stage. +} + +# Accounts +resource "google_cloud_run_v2_service_iam_member" "accounts_us_public" { + project = var.project_id + location = "us-central1" + name = google_cloud_run_v2_service.accounts_us.name + role = "roles/run.invoker" + member = "allUsers" +} + +resource "google_cloud_run_v2_service_iam_member" "accounts_eu_public" { + project = var.project_id + location = "europe-west1" + name = google_cloud_run_v2_service.accounts_eu.name + role = "roles/run.invoker" + member = "allUsers" +} + +resource "google_cloud_run_v2_service_iam_member" "accounts_apac_public" { + project = var.project_id + location = "asia-northeast1" + name = google_cloud_run_v2_service.accounts_apac.name + role = "roles/run.invoker" + member = "allUsers" +} + +# API +resource "google_cloud_run_v2_service_iam_member" "api_us_public" { + project = var.project_id + location = "us-central1" + name = google_cloud_run_v2_service.api_us.name + role = "roles/run.invoker" + member = "allUsers" +} + +resource "google_cloud_run_v2_service_iam_member" "api_eu_public" { + project = var.project_id + location = "europe-west1" + name = google_cloud_run_v2_service.api_eu.name + role = "roles/run.invoker" + member = "allUsers" +} + +resource "google_cloud_run_v2_service_iam_member" "api_apac_public" { + project = var.project_id + location = "asia-northeast1" + name = google_cloud_run_v2_service.api_apac.name + role = "roles/run.invoker" + member = "allUsers" } diff --git a/servers/gcp/variables.tf b/servers/gcp/variables.tf index dae6e0b..95119cf 100644 --- a/servers/gcp/variables.tf +++ b/servers/gcp/variables.tf @@ -5,7 +5,19 @@ variable "project_id" { } variable "image_tag" { - description = "Docker image tag to deploy" + description = "Docker image tag to deploy for the marketing site" + type = string + default = "latest" +} + +variable "accounts_image_tag" { + description = "Docker image tag to deploy for the accounts service" + type = string + default = "latest" +} + +variable "api_image_tag" { + description = "Docker image tag to deploy for the REST API (neuron-rest)" type = string default = "latest" } @@ -20,23 +32,14 @@ variable "cloudflare_zone_id_neurontechnologies" { locals { project_id = var.project_id - image = "us-central1-docker.pkg.dev/${var.project_id}/neuron-marketing/marketing:${var.image_tag}" - # Production secrets from GCP Secret Manager - secrets = [ - "stripe-secret-key", - "stripe-webhook-secret", - "stripe-price-professional", - "stripe-price-founding", - ] + # Image refs per service + marketing_image = "us-central1-docker.pkg.dev/${var.project_id}/neuron-marketing/marketing:${var.image_tag}" + accounts_image = "us-central1-docker.pkg.dev/${var.project_id}/neuron-accounts/accounts:${var.accounts_image_tag}" + api_image = "us-central1-docker.pkg.dev/${var.project_id}/neuron-api/api:${var.api_image_tag}" - # Env var names for each secret (uppercase, hyphen → underscore) - secret_env_map = { - "stripe-secret-key" = "STRIPE_SECRET_KEY" - "stripe-webhook-secret" = "STRIPE_WEBHOOK_SECRET" - "stripe-price-professional" = "STRIPE_PRICE_PROFESSIONAL" - "stripe-price-founding" = "STRIPE_PRICE_FOUNDING" - } + # Keep backward-compat alias (used in cloud-run.tf for marketing service) + image = local.marketing_image # Static publishable key (not sensitive — lives in source) stripe_publishable_key = "pk_live_51TPoHnJg9Fv1D3AUPMXnYyOJIVhn1FyH56zCMNnATo9tR7pO9lNDbXncp6VeDxm38qSdBHZPfqEBipUh3GZsWNyd00jvIO97E6"