Expand GCP infra: accounts + API services, Cloud SQL, Artifact Registry

Architecture: intelligence stays on Legion; only compiled artifacts cross
to GCP. Source code and Neuron's knowledge base never leave the system.

Artifact Registry:
- neuron-marketing, neuron-accounts, neuron-api repos in us-central1
- Keep-last-10 cleanup policy; ci-pusher SA with writer access
- Legion CI runners authenticate via GCP_SA_KEY Gitea secret

Cloud SQL (cloud-sql.tf):
- postgres-15 on db-g1-small, us-central1 (scale up to REGIONAL HA at 1k users)
- Point-in-time recovery, 14-day backup retention
- Accounts DB + user; password generated and stored in Secret Manager
- JWT signing key in Secret Manager (shared by accounts + api)
- Cloud Run connects via built-in Auth Proxy (Unix socket volume mount)

Accounts Cloud Run (cloud-run-accounts.tf):
- 3 regions (us-central1, europe-west1, asia-northeast1), min:1 max:50
- Cloud SQL proxy volume mount; secrets via Secret Manager
- Stripe + JWT env vars; health probe on /health

API Cloud Run (cloud-run-api.tf):
- 3 regions, min:1 max:100, cpu_idle=false (always-hot)
- Validates JWTs from accounts service; no direct DB connection
- License admin token from Secret Manager

Load balancer (host-based routing):
- Same global anycast IP for all three services
- URL map routes by Host: neurontechnologies.ai→marketing,
  api.neurontechnologies.ai→api, accounts.neurontechnologies.ai→accounts
- New managed SSL certs for api.* and accounts.* added to HTTPS proxy
- Cloud Armor (WAF + rate limit) applied to all backends

Service accounts + IAM:
- neuron-accounts-sa: secretmanager.secretAccessor + cloudsql.client
- neuron-api-sa: secretmanager.secretAccessor
- allUsers invoker on all prod Cloud Run services (LB health checks)

bootstrap.sh:
- One-shot setup: pulls Stripe secrets from Vault → Secret Manager,
  creates CI SA JSON key, prints DNS + next-step instructions
This commit is contained in:
Will Anderson
2026-04-25 22:43:19 -05:00
parent 93358505fc
commit d4c65d5857
11 changed files with 1245 additions and 45 deletions
+96
View File
@@ -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
+97
View File
@@ -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}"
+330
View File
@@ -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,
]
}
+226
View File
@@ -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]
}
+150
View File
@@ -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]
}
}
+119
View File
@@ -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"]
}
}
+43 -5
View File
@@ -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 ────────────────────────────────────────────────
+4
View File
@@ -10,6 +10,10 @@ terraform {
source = "hashicorp/google-beta"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
backend "s3" {
+68 -16
View File
@@ -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"
}
}
+93 -8
View File
@@ -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"
}
+19 -16
View File
@@ -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"