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:
@@ -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
|
||||
Executable
+97
@@ -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}"
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -10,6 +10,10 @@ terraform {
|
||||
source = "hashicorp/google-beta"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = "~> 3.6"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
|
||||
+68
-16
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user