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.
This commit is contained in:
@@ -0,0 +1,645 @@
|
|||||||
|
## 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# dev.neurontechnologies.ai
|
||||||
|
|
||||||
|
Team-internal free-fire iteration zone. Looser standards than stage:
|
||||||
|
broken builds OK, no public users, no real PII, no live Stripe, no
|
||||||
|
real outbound email. Auto-deploys on every push to the `dev` branch
|
||||||
|
once the CI/CD runner agent ships (this directory provisions the
|
||||||
|
shell + initial deploy only).
|
||||||
|
|
||||||
|
The terraform itself lives at `servers/gcp/marketing-dev.tf` (single
|
||||||
|
file, mirrors the existing flat-file layout under `servers/gcp/`).
|
||||||
|
This directory holds documentation only.
|
||||||
|
|
||||||
|
## What is dev
|
||||||
|
|
||||||
|
| Layer | Where |
|
||||||
|
|------------------|------------------------------------------------------------------|
|
||||||
|
| Domain | `dev.neurontechnologies.ai` |
|
||||||
|
| Cloud Run | `marketing-dev-us` (us-central1, single region) |
|
||||||
|
| Image registry | `us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/` |
|
||||||
|
| Initial image | `marketing:fix-gallery-render-1236` (matched stage at provision) |
|
||||||
|
| Service account | `neuron-marketing-dev@neuron-785695.iam.gserviceaccount.com` |
|
||||||
|
| Access control | Cloudflare Zero Trust Access, two policies |
|
||||||
|
| Identity rule 1 | `email_domain == neurontechnologies.ai` |
|
||||||
|
| Identity rule 2 | named externals via `dev_external_collaborators` variable |
|
||||||
|
| Supabase | NEW dev project (NOT shared with stage or prod) |
|
||||||
|
| LLM | `claude-haiku-4-5` (cheap) |
|
||||||
|
| Stripe | Test mode (same test keys as stage) |
|
||||||
|
| Email | `RESEND_API_KEY` mounted; `RESEND_DRY_RUN=1` short-circuits send |
|
||||||
|
|
||||||
|
## How dev differs from stage
|
||||||
|
|
||||||
|
| Property | stage | dev |
|
||||||
|
|------------------|--------------------------------|-------------------------------------------|
|
||||||
|
| `NODE_ENV` | `staging` | `development` |
|
||||||
|
| LLM model | `claude-sonnet-4-5` | `claude-haiku-4-5` |
|
||||||
|
| Supabase | shares prod project | dedicated `neuron-dev` project (isolated) |
|
||||||
|
| Email | live (real sends) | `RESEND_DRY_RUN=1` (no real sends) |
|
||||||
|
| Stripe | test mode | test mode (same) |
|
||||||
|
| Auto-deploy | manual | every push to `dev` branch (post CI wire) |
|
||||||
|
| Allowed users | team + Tim's gmail | team + named external collaborators |
|
||||||
|
|
||||||
|
## How to get in
|
||||||
|
|
||||||
|
1. Sign in with your `@neurontechnologies.ai` Google account at
|
||||||
|
`https://dev.neurontechnologies.ai/`. Cloudflare Access intercepts
|
||||||
|
the request, redirects you to the Google IdP, and only allows
|
||||||
|
`email_domain == neurontechnologies.ai` plus any allowlisted
|
||||||
|
externals through.
|
||||||
|
2. After auth, you land on the running marketing site exactly as it
|
||||||
|
would render in stage / prod, but with `NODE_ENV=development`.
|
||||||
|
|
||||||
|
Verify the lockdown from a shell at any time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI https://dev.neurontechnologies.ai/ | head -2
|
||||||
|
# HTTP/2 302
|
||||||
|
# location: https://neuralplatform.cloudflareaccess.com/cdn-cgi/access/login/dev.neurontechnologies.ai?...
|
||||||
|
```
|
||||||
|
|
||||||
|
If the first line is anything other than 302 to the cloudflareaccess
|
||||||
|
domain, something has broken. Treat it as a P0.
|
||||||
|
|
||||||
|
## Provision the dev Supabase project (one-time, automated)
|
||||||
|
|
||||||
|
The Supabase Management API token lives in Vault at
|
||||||
|
`secret/neuron-technologies/supabase` (field `access_token`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull the token
|
||||||
|
export SBP_TOKEN=$(vault kv get -field=access_token secret/neuron-technologies/supabase)
|
||||||
|
|
||||||
|
# Create the project
|
||||||
|
ORG_ID=$(curl -s -H "Authorization: Bearer $SBP_TOKEN" \
|
||||||
|
https://api.supabase.com/v1/organizations | jq -r '.[0].id')
|
||||||
|
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $SBP_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
https://api.supabase.com/v1/projects \
|
||||||
|
-d "{\"name\":\"neuron-dev\",\"region\":\"us-west-2\",\"organization_id\":\"$ORG_ID\",\"db_pass\":\"$(openssl rand -hex 24)\"}" | jq .
|
||||||
|
|
||||||
|
# Capture project ref, URL, anon_key, service_role_key from the response.
|
||||||
|
```
|
||||||
|
|
||||||
|
After provisioning, write the values into Secret Manager (this
|
||||||
|
overwrites the placeholder versions terraform created):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo -n 'https://<dev-ref>.supabase.co' | gcloud secrets versions add \
|
||||||
|
dev-supabase-url --data-file=- --project=neuron-785695
|
||||||
|
echo -n '<dev-anon-key>' | gcloud secrets versions add \
|
||||||
|
dev-supabase-anon-key --data-file=- --project=neuron-785695
|
||||||
|
echo -n '<dev-service-key>' | gcloud secrets versions add \
|
||||||
|
dev-supabase-service-key --data-file=- --project=neuron-785695
|
||||||
|
```
|
||||||
|
|
||||||
|
Then apply the prod schema (5 tables + indexes + 1 backfill) to dev
|
||||||
|
via the Supabase Management API SQL endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEV_REF=<dev-ref>
|
||||||
|
for f in ~/Development/neuron-technologies/products/web/migrations/*.sql; do
|
||||||
|
echo "=== applying $f ==="
|
||||||
|
jq -Rs '{query: .}' < "$f" | curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $SBP_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://api.supabase.com/v1/projects/$DEV_REF/database/query" \
|
||||||
|
-d @-
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Force a new dev revision so it picks up the secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gcloud run services update marketing-dev-us --region=us-central1 \
|
||||||
|
--project=neuron-785695 \
|
||||||
|
--image=us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/marketing:fix-gallery-render-1236
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual deploy (until CI is wired)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gcloud run services update marketing-dev-us \
|
||||||
|
--region=us-central1 \
|
||||||
|
--project=neuron-785695 \
|
||||||
|
--image=us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/marketing:<tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
The terraform `google_cloud_run_v2_service.marketing_dev_us` has
|
||||||
|
`lifecycle.ignore_changes = [template[0].containers[0].image]` so CI
|
||||||
|
rollouts will not be undone by the next `terraform apply`.
|
||||||
|
|
||||||
|
CI wiring lands once the CI/CD runner agent (commit a9bfb7d) ships.
|
||||||
|
The follow-up backlog item is tracked in Neuron.
|
||||||
|
|
||||||
|
## What NOT to do in dev
|
||||||
|
|
||||||
|
- Do NOT use real PII. Anything entered here can be wiped without notice.
|
||||||
|
- Do NOT use live Stripe keys. Test mode only.
|
||||||
|
- Do NOT expect persistent data. Dev Supabase is disposable.
|
||||||
|
- Do NOT ship live transactional email. `RESEND_DRY_RUN=1` is the
|
||||||
|
contract; if `main.el`'s `send_email` helper does not honor it yet,
|
||||||
|
the unverified `dev.neurontechnologies.ai` envelope-from will cause
|
||||||
|
Resend to reject the send anyway. Either way: no real inbox is
|
||||||
|
reached. Track that work via the Neuron backlog.
|
||||||
|
- Do NOT grant the dev SA any prod IAM. Resource isolation is the only
|
||||||
|
thing keeping dev experiments from corrupting prod.
|
||||||
|
|
||||||
|
## Disaster recovery
|
||||||
|
|
||||||
|
Dev is disposable. If state gets weird, nuke it and rebuild:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Development/infrastructure/servers/gcp
|
||||||
|
terraform destroy \
|
||||||
|
-target=cloudflare_zero_trust_access_policy.marketing_dev_external \
|
||||||
|
-target=cloudflare_zero_trust_access_policy.marketing_dev_team \
|
||||||
|
-target=cloudflare_zero_trust_access_application.marketing_dev \
|
||||||
|
-target=cloudflare_record.marketing_dev \
|
||||||
|
-target=google_cloud_run_domain_mapping.marketing_dev \
|
||||||
|
-target=google_cloud_run_v2_service_iam_member.marketing_dev_us_public \
|
||||||
|
-target=google_cloud_run_v2_service.marketing_dev_us \
|
||||||
|
-target=google_secret_manager_secret_version.dev_supabase_url_placeholder \
|
||||||
|
-target=google_secret_manager_secret_version.dev_supabase_anon_key_placeholder \
|
||||||
|
-target=google_secret_manager_secret_version.dev_supabase_service_key_placeholder \
|
||||||
|
-target=google_secret_manager_secret_version.dev_stripe_price_professional_placeholder \
|
||||||
|
-target=google_secret_manager_secret_version.dev_stripe_price_founding_placeholder \
|
||||||
|
-target=google_secret_manager_secret_version.dev_stripe_price_family_child_placeholder \
|
||||||
|
-target=google_secret_manager_secret.dev_supabase_url \
|
||||||
|
-target=google_secret_manager_secret.dev_supabase_anon_key \
|
||||||
|
-target=google_secret_manager_secret.dev_supabase_service_key \
|
||||||
|
-target=google_secret_manager_secret.dev_stripe_price_professional \
|
||||||
|
-target=google_secret_manager_secret.dev_stripe_price_founding \
|
||||||
|
-target=google_secret_manager_secret.dev_stripe_price_family_child \
|
||||||
|
-target=google_project_iam_member.marketing_dev_secret_accessor \
|
||||||
|
-target=google_service_account.marketing_dev
|
||||||
|
|
||||||
|
# Then re-apply:
|
||||||
|
terraform apply
|
||||||
|
```
|
||||||
|
|
||||||
|
If the dev Supabase project was provisioned, also delete it from the
|
||||||
|
Supabase dashboard and re-create with a new name. The Secret Manager
|
||||||
|
secrets carry forward as placeholders; rewrite them with the new
|
||||||
|
project's values.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What |
|
||||||
|
|---------------------------------------------------------|-----------------------------------|
|
||||||
|
| `servers/gcp/marketing-dev.tf` | All marketing-dev terraform |
|
||||||
|
| `servers/gcp/marketing-dev/README.md` | This document |
|
||||||
@@ -43,14 +43,12 @@ resource "cloudflare_record" "nt_tunnel_www" {
|
|||||||
ttl = 1
|
ttl = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "cloudflare_record" "nt_tunnel_dev" {
|
# dev.neurontechnologies.ai DNS moved to servers/gcp/marketing-dev.tf
|
||||||
zone_id = local.zone_neurontechnologies_ai
|
# (Cloud Run-fronted marketing dev environment). The legion-side
|
||||||
name = "dev"
|
# neuron-dev namespace + ingress + tunnel ingress_rule remain in place
|
||||||
type = "CNAME"
|
# but no longer receive traffic - they're inert until a follow-up
|
||||||
content = "${var.cloudflare_tunnel_id}.cfargotunnel.com"
|
# decision retires them or rehosts under mcp-dev.neurontechnologies.ai.
|
||||||
proxied = true
|
# See backlog: "Retire or rehost legion neuron-dev namespace".
|
||||||
ttl = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "cloudflare_record" "nt_tunnel_stage" {
|
resource "cloudflare_record" "nt_tunnel_stage" {
|
||||||
zone_id = local.zone_neurontechnologies_ai
|
zone_id = local.zone_neurontechnologies_ai
|
||||||
|
|||||||
Reference in New Issue
Block a user