Files
infrastructure/servers/gcp/marketing-dev.tf
Will Anderson cd2c22c295 add marketing-dev environment - team-internal auto-deploy zone
dev.neurontechnologies.ai mirrors stage in shape but looser:
broken builds OK, auto-deploys on every push to dev branch (once
CI lands), dev Supabase project (isolated), test Stripe keys,
RESEND_DRY_RUN=1 so transactional email never actually sends.

Locked behind Cloudflare Access with team policy: anyone with
@neurontechnologies.ai email plus named external collaborators
via the access app's emails list.

Initial image is marketing:fix-gallery-render-1236 (matches stage
at the time of this commit). Future deploys land via CI workflow.
2026-05-02 13:13:58 -05:00

646 lines
21 KiB
Terraform

## Marketing Dev - dev.neurontechnologies.ai
#
# Team-internal free-fire iteration zone. Looser standards than stage:
# broken builds OK, auto-deploys on every push to the `dev` branch (CI
# wiring lands once the CI/CD runner agent ships - this file provisions
# the shell + initial deploy only).
#
# Mirrors stage in shape but with three deltas:
# - NODE_ENV=development (not staging)
# - claude-haiku-4-5 (cheap model, not sonnet)
# - Isolated Supabase project (not shared with stage or prod)
# - RESEND_DRY_RUN=1 so transactional email never actually sends
#
# Locked behind Cloudflare Access with a team policy: anyone with a
# @neurontechnologies.ai email plus named external collaborators via the
# `dev_external_collaborators` variable.
#
# Documentation: servers/gcp/marketing-dev/README.md
locals {
marketing_dev_labels = {
"managed-by" = "terraform"
"project" = "neuron-marketing"
"env" = "dev"
"auto_deploy" = "true"
}
# Initial image: matches stage at the time of this commit so dev starts
# in a known-working state. Will float to `marketing:dev` once CI is
# wired and tagging permissions are sorted. The Cloud Run service has
# lifecycle.ignore_changes on container image so CI rolls forward
# without terraform stomping the rollout.
marketing_dev_image_initial = "us-central1-docker.pkg.dev/${var.project_id}/neuron-marketing/marketing:fix-gallery-render-1236"
}
variable "dev_external_collaborators" {
description = "Additional emails (outside @neurontechnologies.ai) granted access to dev.neurontechnologies.ai"
type = list(string)
default = []
}
## Service account - neuron-marketing-dev
# Dedicated SA mirroring the sandbox isolation pattern. Project-level
# secretAccessor is granted (mirrors prod marketing SA) but the dev
# secrets it actually reads are dev-scoped (dev-supabase-*, etc).
# Critically: NO bindings on prod docuseal-webhook-token, prod
# stripe-secret-key (live), or any other prod-only secret. Verified
# in the README's `gcloud secrets versions access` lockdown checks.
resource "google_service_account" "marketing_dev" {
account_id = "neuron-marketing-dev"
display_name = "Neuron Marketing Dev Cloud Run SA"
description = "Service account for marketing-dev-us. Reads dev-scoped secrets only."
project = var.project_id
}
# IAM is RESOURCE-SCOPED, not project-level. Each grant below is on
# exactly one secret. The dev SA cannot read prod secrets (live
# stripe-secret-key, docuseal-webhook-token, supabase-service-key,
# etc.) - this matches the sandbox isolation pattern.
#
# Verify with the lockdown checks in marketing-dev/README.md:
# gcloud secrets versions access latest --secret=docuseal-webhook-token
# gcloud secrets versions access latest --secret=stripe-secret-key
# Both must return PERMISSION_DENIED to the dev SA.
# Dev-scoped Supabase secrets (created in this file)
resource "google_secret_manager_secret_iam_member" "marketing_dev_supabase_url" {
project = var.project_id
secret_id = google_secret_manager_secret.dev_supabase_url.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marketing_dev.email}"
}
resource "google_secret_manager_secret_iam_member" "marketing_dev_supabase_anon" {
project = var.project_id
secret_id = google_secret_manager_secret.dev_supabase_anon_key.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marketing_dev.email}"
}
resource "google_secret_manager_secret_iam_member" "marketing_dev_supabase_service" {
project = var.project_id
secret_id = google_secret_manager_secret.dev_supabase_service_key.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marketing_dev.email}"
}
# Dev-scoped Stripe price secrets (created in this file)
resource "google_secret_manager_secret_iam_member" "marketing_dev_stripe_professional" {
project = var.project_id
secret_id = google_secret_manager_secret.dev_stripe_price_professional.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marketing_dev.email}"
}
resource "google_secret_manager_secret_iam_member" "marketing_dev_stripe_founding" {
project = var.project_id
secret_id = google_secret_manager_secret.dev_stripe_price_founding.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marketing_dev.email}"
}
resource "google_secret_manager_secret_iam_member" "marketing_dev_stripe_family" {
project = var.project_id
secret_id = google_secret_manager_secret.dev_stripe_price_family_child.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marketing_dev.email}"
}
# Shared cross-env secrets that dev legitimately needs:
# - anthropic-api-key (LLM key, same account as stage/prod)
# - resend-api-key (mounted but RESEND_DRY_RUN=1 short-circuits sends)
# - stripe-secret-key-stage (test mode key, same as stage)
# - stripe-webhook-secret-stage (test mode webhook secret, same as stage)
# These are scoped grants too - dev gets read on these specific
# secrets only, NOT on prod stripe-secret-key (live) or
# docuseal-webhook-token, etc.
resource "google_secret_manager_secret_iam_member" "marketing_dev_anthropic" {
project = var.project_id
secret_id = "anthropic-api-key"
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marketing_dev.email}"
}
resource "google_secret_manager_secret_iam_member" "marketing_dev_resend" {
project = var.project_id
secret_id = "resend-api-key"
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marketing_dev.email}"
}
resource "google_secret_manager_secret_iam_member" "marketing_dev_stripe_stage_secret" {
project = var.project_id
secret_id = "stripe-secret-key-stage"
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marketing_dev.email}"
}
resource "google_secret_manager_secret_iam_member" "marketing_dev_stripe_stage_webhook" {
project = var.project_id
secret_id = "stripe-webhook-secret-stage"
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marketing_dev.email}"
}
## Dev Supabase secrets
# Created here so the Cloud Run service can mount them from day one.
# The actual values are populated AFTER the dev Supabase project is
# provisioned (see servers/gcp/marketing-dev/README.md for the runbook).
resource "google_secret_manager_secret" "dev_supabase_url" {
secret_id = "dev-supabase-url"
project = var.project_id
labels = local.marketing_dev_labels
replication {
auto {}
}
}
resource "google_secret_manager_secret" "dev_supabase_anon_key" {
secret_id = "dev-supabase-anon-key"
project = var.project_id
labels = local.marketing_dev_labels
replication {
auto {}
}
}
resource "google_secret_manager_secret" "dev_supabase_service_key" {
secret_id = "dev-supabase-service-key"
project = var.project_id
labels = local.marketing_dev_labels
replication {
auto {}
}
}
## Stripe price secrets - dev (test mode)
# Same Stripe test account as stage; we keep separate secrets so a
# rotation in stage doesn't silently flip dev (and vice versa). Initial
# values are populated as placeholder versions; the operator pushes the
# real test-mode price IDs via `gcloud secrets versions add` per the
# README runbook.
resource "google_secret_manager_secret" "dev_stripe_price_professional" {
secret_id = "stripe-price-professional-dev"
project = var.project_id
labels = local.marketing_dev_labels
replication {
auto {}
}
}
resource "google_secret_manager_secret" "dev_stripe_price_founding" {
secret_id = "stripe-price-founding-dev"
project = var.project_id
labels = local.marketing_dev_labels
replication {
auto {}
}
}
resource "google_secret_manager_secret" "dev_stripe_price_family_child" {
secret_id = "stripe-price-family-child-dev"
project = var.project_id
labels = local.marketing_dev_labels
replication {
auto {}
}
}
# Placeholder versions so the secrets resolve before real values land.
# Cloud Run will mount these as env vars; main.el guards Stripe calls
# with feature flags, so a placeholder won't crash the boot path.
resource "google_secret_manager_secret_version" "dev_supabase_url_placeholder" {
secret = google_secret_manager_secret.dev_supabase_url.id
secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING"
lifecycle {
ignore_changes = [secret_data]
}
}
resource "google_secret_manager_secret_version" "dev_supabase_anon_key_placeholder" {
secret = google_secret_manager_secret.dev_supabase_anon_key.id
secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING"
lifecycle {
ignore_changes = [secret_data]
}
}
resource "google_secret_manager_secret_version" "dev_supabase_service_key_placeholder" {
secret = google_secret_manager_secret.dev_supabase_service_key.id
secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING"
lifecycle {
ignore_changes = [secret_data]
}
}
resource "google_secret_manager_secret_version" "dev_stripe_price_professional_placeholder" {
secret = google_secret_manager_secret.dev_stripe_price_professional.id
secret_data = "PLACEHOLDER_REPLACE_WITH_TEST_PRICE_ID"
lifecycle {
ignore_changes = [secret_data]
}
}
resource "google_secret_manager_secret_version" "dev_stripe_price_founding_placeholder" {
secret = google_secret_manager_secret.dev_stripe_price_founding.id
secret_data = "PLACEHOLDER_REPLACE_WITH_TEST_PRICE_ID"
lifecycle {
ignore_changes = [secret_data]
}
}
resource "google_secret_manager_secret_version" "dev_stripe_price_family_child_placeholder" {
secret = google_secret_manager_secret.dev_stripe_price_family_child.id
secret_data = "PLACEHOLDER_REPLACE_WITH_TEST_PRICE_ID"
lifecycle {
ignore_changes = [secret_data]
}
}
## Cloud Run - marketing-dev-us
# Single region (us-central1). Dev is internal-only; no need for
# multi-region. INGRESS_TRAFFIC_ALL because Cloudflare Access sits in
# front; the Cloud Run direct URL is obscure and not the front door.
#
# Resend is mounted but RESEND_DRY_RUN=1 short-circuits sends in
# main.el's send_email helper (follow-up backlog item if main.el
# doesn't honor the flag yet). This way dev can exercise the email
# code path safely.
resource "google_cloud_run_v2_service" "marketing_dev_us" {
name = "marketing-dev-us"
location = "us-central1"
project = var.project_id
ingress = "INGRESS_TRAFFIC_ALL"
labels = local.marketing_dev_labels
template {
service_account = google_service_account.marketing_dev.email
scaling {
min_instance_count = 0
max_instance_count = 5
}
containers {
image = local.marketing_dev_image_initial
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
cpu_idle = true
}
env {
name = "NODE_ENV"
value = "development"
}
env {
name = "NEURON_ORIGIN"
value = "https://dev.neurontechnologies.ai"
}
env {
name = "GCS_SHARE_BUCKET"
value = "neuron-shares-stage"
}
env {
name = "STRIPE_PUBLISHABLE_KEY"
value = "pk_test_51TPoHwJak4gXxYZLPBDH5g53qOICo0Bzy4S6YYWw723rnD5ZGOfhoT5bIvB00o4gW47yknEz9SeQimUclrDT2txm00aYsPDech"
}
## LLM - claude-haiku-4-5 (cheap for dev)
env {
name = "NEURON_LLM_0_FORMAT"
value = "anthropic"
}
env {
name = "NEURON_LLM_0_MODEL"
value = "claude-haiku-4-5"
}
env {
name = "NEURON_LLM_0_URL"
value = "https://api.anthropic.com/v1/messages"
}
env {
name = "NEURON_LLM_0_KEY"
value_source {
secret_key_ref {
secret = "anthropic-api-key"
version = "latest"
}
}
}
env {
name = "ANTHROPIC_API_KEY"
value_source {
secret_key_ref {
secret = "anthropic-api-key"
version = "latest"
}
}
}
## Email - mounted but dry-run gated
# RESEND_DRY_RUN=1 instructs main.el's send_email helper to return
# an ok-stub without actually hitting api.resend.com. If the helper
# doesn't honor the flag yet, the unverified `dev.neurontechnologies.ai`
# origin domain still causes Resend to reject the send (4xx), so
# dev fails safely either way - email never reaches a real inbox.
env {
name = "RESEND_DRY_RUN"
value = "1"
}
env {
name = "RESEND_API_KEY"
value_source {
secret_key_ref {
secret = "resend-api-key"
version = "latest"
}
}
}
## Supabase - dev project (isolated)
env {
name = "SUPABASE_URL"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.dev_supabase_url.secret_id
version = "latest"
}
}
}
env {
name = "SUPABASE_ANON_KEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.dev_supabase_anon_key.secret_id
version = "latest"
}
}
}
env {
name = "SUPABASE_SERVICE_KEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.dev_supabase_service_key.secret_id
version = "latest"
}
}
}
## Stripe - test mode (same as stage)
env {
name = "STRIPE_SECRET_KEY"
value_source {
secret_key_ref {
secret = "stripe-secret-key-stage"
version = "latest"
}
}
}
env {
name = "STRIPE_WEBHOOK_SECRET"
value_source {
secret_key_ref {
secret = "stripe-webhook-secret-stage"
version = "latest"
}
}
}
env {
name = "STRIPE_PRICE_PROFESSIONAL"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.dev_stripe_price_professional.secret_id
version = "latest"
}
}
}
env {
name = "STRIPE_PRICE_FOUNDING"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.dev_stripe_price_founding.secret_id
version = "latest"
}
}
}
env {
name = "STRIPE_PRICE_FAMILY_CHILD"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.dev_stripe_price_family_child.secret_id
version = "latest"
}
}
}
ports {
container_port = 8080
name = "http1"
}
startup_probe {
http_get {
path = "/api/health"
port = 8080
}
initial_delay_seconds = 15
timeout_seconds = 5
period_seconds = 10
failure_threshold = 10
}
liveness_probe {
http_get {
path = "/api/health"
port = 8080
}
initial_delay_seconds = 20
timeout_seconds = 5
period_seconds = 30
failure_threshold = 3
}
}
}
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 100
}
depends_on = [
google_secret_manager_secret_iam_member.marketing_dev_supabase_url,
google_secret_manager_secret_iam_member.marketing_dev_supabase_anon,
google_secret_manager_secret_iam_member.marketing_dev_supabase_service,
google_secret_manager_secret_iam_member.marketing_dev_stripe_professional,
google_secret_manager_secret_iam_member.marketing_dev_stripe_founding,
google_secret_manager_secret_iam_member.marketing_dev_stripe_family,
google_secret_manager_secret_iam_member.marketing_dev_anthropic,
google_secret_manager_secret_iam_member.marketing_dev_resend,
google_secret_manager_secret_iam_member.marketing_dev_stripe_stage_secret,
google_secret_manager_secret_iam_member.marketing_dev_stripe_stage_webhook,
google_secret_manager_secret_version.dev_supabase_url_placeholder,
google_secret_manager_secret_version.dev_supabase_anon_key_placeholder,
google_secret_manager_secret_version.dev_supabase_service_key_placeholder,
google_secret_manager_secret_version.dev_stripe_price_professional_placeholder,
google_secret_manager_secret_version.dev_stripe_price_founding_placeholder,
google_secret_manager_secret_version.dev_stripe_price_family_child_placeholder,
]
# CI rolls new image tags out-of-band. Don't undo those rollouts.
lifecycle {
ignore_changes = [
template[0].containers[0].image,
client,
client_version,
]
}
}
# Allow public invocation - Cloudflare Access is the gate, not Cloud Run auth.
resource "google_cloud_run_v2_service_iam_member" "marketing_dev_us_public" {
project = var.project_id
location = "us-central1"
name = google_cloud_run_v2_service.marketing_dev_us.name
role = "roles/run.invoker"
member = "allUsers"
}
## Cloud Run domain mapping
# Maps dev.neurontechnologies.ai -> marketing-dev-us. Cloud Run handles
# managed cert provisioning via the CNAME pointing to ghs.googlehosted.com.
resource "google_cloud_run_domain_mapping" "marketing_dev" {
name = "dev.neurontechnologies.ai"
location = "us-central1"
project = var.project_id
metadata {
namespace = var.project_id
labels = local.marketing_dev_labels
}
spec {
route_name = google_cloud_run_v2_service.marketing_dev_us.name
}
}
## Cloudflare DNS - dev.neurontechnologies.ai
# CNAME to ghs.googlehosted.com. The wildcard *.neurontechnologies.ai
# (in dns-gcp.tf) and the legion-side `nt_tunnel_dev` CNAME (in
# servers/legion/dns-neurontechnologies.tf) would otherwise route dev
# elsewhere; this more specific CNAME wins.
#
# IMPORTANT: there's a hostname collision with servers/legion's
# `cloudflare_record.nt_tunnel_dev`. Both terraform stacks try to
# manage the dev CNAME. The legion-side record must be removed (or
# this apply will fail with "record exists" / drift). Tracked as a
# follow-up backlog item: retire the legion neuron-dev MCP/REST or
# rehost it under mcp-dev.neurontechnologies.ai.
#
# proxied = true so Cloudflare Access can intercept the request before
# it reaches Cloud Run. The Cloud Run domain mapping issues a Google
# managed cert; Cloudflare's Universal SSL covers the edge.
resource "cloudflare_record" "marketing_dev" {
zone_id = var.cloudflare_zone_id_neurontechnologies
name = "dev"
type = "CNAME"
content = "ghs.googlehosted.com"
proxied = true
ttl = 1
}
## Cloudflare Zero Trust Access
# Two policies: allow @neurontechnologies.ai domain (the team), plus
# an allowlist for named external collaborators (default empty;
# populated via the `dev_external_collaborators` terraform variable).
#
# auto_redirect_to_identity = true sends users straight to Google IdP.
resource "cloudflare_zero_trust_access_application" "marketing_dev" {
zone_id = var.cloudflare_zone_id_neurontechnologies
name = "Neuron Dev"
domain = "dev.neurontechnologies.ai"
type = "self_hosted"
session_duration = "8h"
allowed_idps = ["808f1913-5a8e-4a97-9e68-8ec58e362110"] # Google
auto_redirect_to_identity = true
}
resource "cloudflare_zero_trust_access_policy" "marketing_dev_team" {
application_id = cloudflare_zero_trust_access_application.marketing_dev.id
account_id = var.cloudflare_account_id
name = "Allow @neurontechnologies.ai domain"
precedence = 1
decision = "allow"
include {
email_domain = ["neurontechnologies.ai"]
}
}
resource "cloudflare_zero_trust_access_policy" "marketing_dev_external" {
count = length(var.dev_external_collaborators) > 0 ? 1 : 0
application_id = cloudflare_zero_trust_access_application.marketing_dev.id
account_id = var.cloudflare_account_id
name = "Allow named external collaborators"
precedence = 2
decision = "allow"
include {
email = var.dev_external_collaborators
}
}
## Outputs
output "marketing_dev_service_account_email" {
description = "Dev SA email - reads dev-scoped secrets only"
value = google_service_account.marketing_dev.email
}
output "marketing_dev_cloud_run_url" {
description = "Direct Cloud Run URL (NOT the front door - use dev.neurontechnologies.ai via CF Access)"
value = google_cloud_run_v2_service.marketing_dev_us.uri
}
output "marketing_dev_access_application_id" {
description = "Cloudflare Access application ID for dev.neurontechnologies.ai"
value = cloudflare_zero_trust_access_application.marketing_dev.id
}
output "marketing_dev_access_aud" {
description = "Cloudflare Access AUD tag (used for JWT verification if app ever needs it)"
value = cloudflare_zero_trust_access_application.marketing_dev.aud
}
output "marketing_dev_dns_record_id" {
description = "Cloudflare DNS record ID for dev CNAME"
value = cloudflare_record.marketing_dev.id
}