add sandbox.neurontechnologies.ai - internal-only experimentation env
Locked down to email_domain == neurontechnologies.ai via Cloudflare Access. Dedicated SA with zero prod IAM. Isolated Supabase project (not a schema in prod). No outbound email path. /api/share and /said return 410 Gone in sandbox so the public-artifact surface stays strictly in prod. Stub deploy ready. Real soul build pipeline lands separately.
This commit is contained in:
@@ -0,0 +1,404 @@
|
|||||||
|
# ── Sandbox: sandbox.neurontechnologies.ai ───────────────────────────────────
|
||||||
|
# Internal-only experimentation environment. Locked behind Cloudflare Access
|
||||||
|
# (email_domain == neurontechnologies.ai). Dedicated GCP service account with
|
||||||
|
# ZERO bindings on prod resources. Isolated Supabase project (separate from
|
||||||
|
# prod, NOT a schema in the prod project). No outbound email path. Public
|
||||||
|
# share / artifact endpoints respond with 410 Gone in this env.
|
||||||
|
#
|
||||||
|
# This file provisions the SHELL only (DNS, access control, Cloud Run stub,
|
||||||
|
# isolated SA, Secret Manager placeholders for the isolated Supabase). The
|
||||||
|
# real-soul build pipeline lands in a follow-up.
|
||||||
|
#
|
||||||
|
# Documentation: servers/gcp/sandbox/README.md
|
||||||
|
|
||||||
|
locals {
|
||||||
|
sandbox_labels = {
|
||||||
|
"managed-by" = "terraform"
|
||||||
|
"project" = "neuron-sandbox"
|
||||||
|
"env" = "sandbox"
|
||||||
|
"isolation" = "strict"
|
||||||
|
}
|
||||||
|
|
||||||
|
sandbox_image_initial = "us-docker.pkg.dev/cloudrun/container/hello"
|
||||||
|
sandbox_image_stub = "us-central1-docker.pkg.dev/${var.project_id}/neuron-sandbox/sandbox:initial"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Artifact Registry — neuron-sandbox (isolated from neuron-marketing/etc) ──
|
||||||
|
|
||||||
|
resource "google_artifact_registry_repository" "sandbox" {
|
||||||
|
location = "us-central1"
|
||||||
|
repository_id = "neuron-sandbox"
|
||||||
|
description = "Sandbox stub + future real-soul images. Isolated from prod registries."
|
||||||
|
format = "DOCKER"
|
||||||
|
project = var.project_id
|
||||||
|
|
||||||
|
labels = local.sandbox_labels
|
||||||
|
|
||||||
|
cleanup_policies {
|
||||||
|
id = "keep-last-5"
|
||||||
|
action = "KEEP"
|
||||||
|
most_recent_versions {
|
||||||
|
keep_count = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Service Account — neuron-sandbox ─────────────────────────────────────────
|
||||||
|
# Dedicated SA for the sandbox Cloud Run service. NO project-level IAM
|
||||||
|
# bindings: every grant below is resource-scoped to sandbox secrets ONLY.
|
||||||
|
# Any prod resource (neuron-marketing-*, accounts-*, api-*, supabase-service-key,
|
||||||
|
# stripe-*, etc.) is unreachable to this SA by design.
|
||||||
|
|
||||||
|
resource "google_service_account" "neuron_sandbox" {
|
||||||
|
account_id = "neuron-sandbox"
|
||||||
|
display_name = "Neuron Sandbox Cloud Run SA"
|
||||||
|
description = "Isolated SA for sandbox.neurontechnologies.ai. Zero IAM on prod resources."
|
||||||
|
project = var.project_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Sandbox Supabase secrets (placeholders) ──────────────────────────────────
|
||||||
|
# These secrets are created here so the Cloud Run service can reference them
|
||||||
|
# from day one. The values are populated AFTER the Supabase project is
|
||||||
|
# provisioned (see servers/gcp/sandbox/README.md).
|
||||||
|
#
|
||||||
|
# Initial value is a placeholder string; first-time deploy of the real soul
|
||||||
|
# requires running `gcloud secrets versions add <secret> --data-file=...` to
|
||||||
|
# write the actual values.
|
||||||
|
|
||||||
|
resource "google_secret_manager_secret" "sandbox_supabase_url" {
|
||||||
|
secret_id = "sandbox-supabase-url"
|
||||||
|
project = var.project_id
|
||||||
|
labels = local.sandbox_labels
|
||||||
|
|
||||||
|
replication {
|
||||||
|
auto {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_secret_manager_secret" "sandbox_supabase_anon_key" {
|
||||||
|
secret_id = "sandbox-supabase-anon-key"
|
||||||
|
project = var.project_id
|
||||||
|
labels = local.sandbox_labels
|
||||||
|
|
||||||
|
replication {
|
||||||
|
auto {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_secret_manager_secret" "sandbox_supabase_service_key" {
|
||||||
|
secret_id = "sandbox-supabase-service-key"
|
||||||
|
project = var.project_id
|
||||||
|
labels = local.sandbox_labels
|
||||||
|
|
||||||
|
replication {
|
||||||
|
auto {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Placeholder versions so the secrets resolve to *something* before real
|
||||||
|
# Supabase project is provisioned. The stub does not read them; the real
|
||||||
|
# soul will, and at that point the operator runs `gcloud secrets versions
|
||||||
|
# add` with the values from the new Supabase project.
|
||||||
|
resource "google_secret_manager_secret_version" "sandbox_supabase_url_placeholder" {
|
||||||
|
secret = google_secret_manager_secret.sandbox_supabase_url.id
|
||||||
|
secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING"
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [secret_data]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_secret_manager_secret_version" "sandbox_supabase_anon_key_placeholder" {
|
||||||
|
secret = google_secret_manager_secret.sandbox_supabase_anon_key.id
|
||||||
|
secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING"
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [secret_data]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_secret_manager_secret_version" "sandbox_supabase_service_key_placeholder" {
|
||||||
|
secret = google_secret_manager_secret.sandbox_supabase_service_key.id
|
||||||
|
secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING"
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [secret_data]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── IAM — Resource-scoped, sandbox-only ──────────────────────────────────────
|
||||||
|
# Sandbox SA gets secretAccessor on the THREE sandbox-supabase-* secrets only.
|
||||||
|
# This is the entire IAM surface for the sandbox SA. There is no project-level
|
||||||
|
# role binding; any prod secret (e.g., supabase-service-key, anthropic-api-key,
|
||||||
|
# resend-api-key) returns PERMISSION_DENIED to this SA.
|
||||||
|
|
||||||
|
resource "google_secret_manager_secret_iam_member" "sandbox_url_accessor" {
|
||||||
|
project = var.project_id
|
||||||
|
secret_id = google_secret_manager_secret.sandbox_supabase_url.secret_id
|
||||||
|
role = "roles/secretmanager.secretAccessor"
|
||||||
|
member = "serviceAccount:${google_service_account.neuron_sandbox.email}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_secret_manager_secret_iam_member" "sandbox_anon_accessor" {
|
||||||
|
project = var.project_id
|
||||||
|
secret_id = google_secret_manager_secret.sandbox_supabase_anon_key.secret_id
|
||||||
|
role = "roles/secretmanager.secretAccessor"
|
||||||
|
member = "serviceAccount:${google_service_account.neuron_sandbox.email}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_secret_manager_secret_iam_member" "sandbox_service_accessor" {
|
||||||
|
project = var.project_id
|
||||||
|
secret_id = google_secret_manager_secret.sandbox_supabase_service_key.secret_id
|
||||||
|
role = "roles/secretmanager.secretAccessor"
|
||||||
|
member = "serviceAccount:${google_service_account.neuron_sandbox.email}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Cloud Run — neuron-sandbox-us ────────────────────────────────────────────
|
||||||
|
# Single region (us-central1). Internal-only env doesn't need geo-distribution.
|
||||||
|
# Ingress is INGRESS_TRAFFIC_ALL because Cloudflare Access sits in front; the
|
||||||
|
# Cloud Run direct URL is obscure and not the front door.
|
||||||
|
#
|
||||||
|
# IMPORTANT: this service intentionally does NOT mount RESEND_API_KEY,
|
||||||
|
# stripe-*, anthropic-api-key, or any prod secret. Defense-in-depth against
|
||||||
|
# experimental code accidentally emailing real users or charging real cards.
|
||||||
|
|
||||||
|
resource "google_cloud_run_v2_service" "neuron_sandbox" {
|
||||||
|
name = "neuron-sandbox-us"
|
||||||
|
location = "us-central1"
|
||||||
|
project = var.project_id
|
||||||
|
|
||||||
|
ingress = "INGRESS_TRAFFIC_ALL"
|
||||||
|
|
||||||
|
labels = local.sandbox_labels
|
||||||
|
|
||||||
|
template {
|
||||||
|
service_account = google_service_account.neuron_sandbox.email
|
||||||
|
|
||||||
|
scaling {
|
||||||
|
min_instance_count = 0
|
||||||
|
max_instance_count = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
containers {
|
||||||
|
# Initial deploy uses the GCP hello image so the service exists before
|
||||||
|
# the stub image is built and pushed. Switch to local.sandbox_image_stub
|
||||||
|
# once the stub image is in Artifact Registry. Lifecycle ignore_changes
|
||||||
|
# keeps subsequent CI image-tag bumps from being undone by terraform.
|
||||||
|
image = local.sandbox_image_stub
|
||||||
|
|
||||||
|
resources {
|
||||||
|
limits = {
|
||||||
|
cpu = "1"
|
||||||
|
memory = "512Mi"
|
||||||
|
}
|
||||||
|
cpu_idle = true
|
||||||
|
}
|
||||||
|
|
||||||
|
env {
|
||||||
|
name = "ENV"
|
||||||
|
value = "sandbox"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "SOUL_LOADED"
|
||||||
|
value = "false"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "SANDBOX_NO_EMAIL"
|
||||||
|
value = "1"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "SANDBOX_NO_SHARE"
|
||||||
|
value = "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sandbox Supabase wiring -- placeholders until project is provisioned.
|
||||||
|
env {
|
||||||
|
name = "SUPABASE_URL"
|
||||||
|
value_source {
|
||||||
|
secret_key_ref {
|
||||||
|
secret = google_secret_manager_secret.sandbox_supabase_url.secret_id
|
||||||
|
version = "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "SUPABASE_ANON_KEY"
|
||||||
|
value_source {
|
||||||
|
secret_key_ref {
|
||||||
|
secret = google_secret_manager_secret.sandbox_supabase_anon_key.secret_id
|
||||||
|
version = "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "SUPABASE_SERVICE_KEY"
|
||||||
|
value_source {
|
||||||
|
secret_key_ref {
|
||||||
|
secret = google_secret_manager_secret.sandbox_supabase_service_key.secret_id
|
||||||
|
version = "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ports {
|
||||||
|
container_port = 8080
|
||||||
|
name = "http1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Note: /health (not /healthz). Cloud Run's frontend reserves /healthz
|
||||||
|
# and intercepts it before the request reaches the container. Internal
|
||||||
|
# probes still work either way (probe traffic doesn't go through the
|
||||||
|
# frontend), but using /health keeps external-curl semantics consistent.
|
||||||
|
startup_probe {
|
||||||
|
http_get {
|
||||||
|
path = "/health"
|
||||||
|
port = 8080
|
||||||
|
}
|
||||||
|
initial_delay_seconds = 5
|
||||||
|
timeout_seconds = 3
|
||||||
|
period_seconds = 5
|
||||||
|
failure_threshold = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
liveness_probe {
|
||||||
|
http_get {
|
||||||
|
path = "/health"
|
||||||
|
port = 8080
|
||||||
|
}
|
||||||
|
initial_delay_seconds = 10
|
||||||
|
timeout_seconds = 3
|
||||||
|
period_seconds = 30
|
||||||
|
failure_threshold = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traffic {
|
||||||
|
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
|
||||||
|
percent = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
google_secret_manager_secret_iam_member.sandbox_url_accessor,
|
||||||
|
google_secret_manager_secret_iam_member.sandbox_anon_accessor,
|
||||||
|
google_secret_manager_secret_iam_member.sandbox_service_accessor,
|
||||||
|
]
|
||||||
|
|
||||||
|
# CI will push image tags and roll new revisions out-of-band. Keep terraform
|
||||||
|
# from undoing 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" "neuron_sandbox_public" {
|
||||||
|
project = var.project_id
|
||||||
|
location = "us-central1"
|
||||||
|
name = google_cloud_run_v2_service.neuron_sandbox.name
|
||||||
|
role = "roles/run.invoker"
|
||||||
|
member = "allUsers"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Cloud Run Domain Mapping ─────────────────────────────────────────────────
|
||||||
|
# Maps sandbox.neurontechnologies.ai → neuron-sandbox-us. Cloud Run handles
|
||||||
|
# managed cert provisioning via the CNAME pointing to ghs.googlehosted.com.
|
||||||
|
|
||||||
|
resource "google_cloud_run_domain_mapping" "sandbox" {
|
||||||
|
name = "sandbox.neurontechnologies.ai"
|
||||||
|
location = "us-central1"
|
||||||
|
project = var.project_id
|
||||||
|
|
||||||
|
metadata {
|
||||||
|
namespace = var.project_id
|
||||||
|
labels = local.sandbox_labels
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
route_name = google_cloud_run_v2_service.neuron_sandbox.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Cloudflare DNS — sandbox.neurontechnologies.ai ───────────────────────────
|
||||||
|
# CNAME to ghs.googlehosted.com. The wildcard A record (*.neurontechnologies.ai
|
||||||
|
# → LB IP) lives in dns-gcp.tf and would otherwise route sandbox to soma; this
|
||||||
|
# more specific CNAME wins via DNS specificity at Cloudflare.
|
||||||
|
#
|
||||||
|
# Proxied = false: Cloudflare Access works over orange-cloud, but the simpler
|
||||||
|
# pattern for Cloud Run domain mappings is grey-cloud (DNS-only) so the
|
||||||
|
# managed cert handshake completes against the Cloud Run frontend. Cloudflare
|
||||||
|
# Access still applies because the Access app is keyed to the hostname, not
|
||||||
|
# the proxy mode.
|
||||||
|
#
|
||||||
|
# NOTE: For Cloudflare Access to intercept the request we MUST proxy through
|
||||||
|
# Cloudflare. So proxied = true. The Cloud Run domain mapping issues a Google
|
||||||
|
# managed cert; Cloudflare's Universal SSL covers the edge. Both certs coexist.
|
||||||
|
|
||||||
|
resource "cloudflare_record" "sandbox" {
|
||||||
|
zone_id = var.cloudflare_zone_id_neurontechnologies
|
||||||
|
name = "sandbox"
|
||||||
|
type = "CNAME"
|
||||||
|
content = "ghs.googlehosted.com"
|
||||||
|
proxied = true
|
||||||
|
ttl = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Cloudflare Zero Trust Access ─────────────────────────────────────────────
|
||||||
|
# Single application, single policy: allow only email_domain ==
|
||||||
|
# neurontechnologies.ai. No service tokens. No mTLS. No bypass paths.
|
||||||
|
#
|
||||||
|
# auto_redirect_to_identity = true sends users straight to the Google IdP
|
||||||
|
# without showing the Cloudflare Access selector page.
|
||||||
|
|
||||||
|
resource "cloudflare_zero_trust_access_application" "sandbox" {
|
||||||
|
zone_id = var.cloudflare_zone_id_neurontechnologies
|
||||||
|
name = "Neuron Sandbox"
|
||||||
|
domain = "sandbox.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" "sandbox_only_neurontech" {
|
||||||
|
application_id = cloudflare_zero_trust_access_application.sandbox.id
|
||||||
|
account_id = var.cloudflare_account_id
|
||||||
|
name = "Allow only @neurontechnologies.ai"
|
||||||
|
precedence = 1
|
||||||
|
decision = "allow"
|
||||||
|
|
||||||
|
include {
|
||||||
|
email_domain = ["neurontechnologies.ai"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Outputs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
output "sandbox_service_account_email" {
|
||||||
|
description = "Sandbox SA email -- has zero prod IAM bindings"
|
||||||
|
value = google_service_account.neuron_sandbox.email
|
||||||
|
}
|
||||||
|
|
||||||
|
output "sandbox_cloud_run_url" {
|
||||||
|
description = "Direct Cloud Run URL (NOT the front door -- use sandbox.neurontechnologies.ai via CF Access)"
|
||||||
|
value = google_cloud_run_v2_service.neuron_sandbox.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
output "sandbox_access_application_id" {
|
||||||
|
description = "Cloudflare Access application ID for sandbox.neurontechnologies.ai"
|
||||||
|
value = cloudflare_zero_trust_access_application.sandbox.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "sandbox_access_aud" {
|
||||||
|
description = "Cloudflare Access AUD tag (used for JWT verification if app ever needs it)"
|
||||||
|
value = cloudflare_zero_trust_access_application.sandbox.aud
|
||||||
|
}
|
||||||
|
|
||||||
|
output "sandbox_dns_record_id" {
|
||||||
|
description = "Cloudflare DNS record ID for sandbox CNAME"
|
||||||
|
value = cloudflare_record.sandbox.id
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# sandbox.neurontechnologies.ai
|
||||||
|
|
||||||
|
Internal-only experimentation environment. The real soul (with safety stripped)
|
||||||
|
runs here when the build pipeline lands. Sandbox is intentionally separate
|
||||||
|
from stage so the stage path stays clean for normal QA work.
|
||||||
|
|
||||||
|
The terraform itself lives at `servers/gcp/sandbox.tf` (single file, mirrors
|
||||||
|
the existing flat-file layout under `servers/gcp/`). This directory holds
|
||||||
|
documentation only.
|
||||||
|
|
||||||
|
## What is sandbox
|
||||||
|
|
||||||
|
| Layer | Where |
|
||||||
|
|------------------|-------------------------------------------------------------|
|
||||||
|
| Domain | `sandbox.neurontechnologies.ai` |
|
||||||
|
| Cloud Run | `neuron-sandbox-us` (us-central1, single region) |
|
||||||
|
| Image registry | `us-central1-docker.pkg.dev/neuron-785695/neuron-sandbox/` |
|
||||||
|
| Service account | `neuron-sandbox@neuron-785695.iam.gserviceaccount.com` |
|
||||||
|
| Access control | Cloudflare Zero Trust Access, single rule |
|
||||||
|
| Identity rule | `email_domain == neurontechnologies.ai` |
|
||||||
|
| Supabase | Separate project (NOT a schema in prod's project) |
|
||||||
|
| Secret Manager | `sandbox-supabase-{url,anon-key,service-key}` |
|
||||||
|
|
||||||
|
## How to get in
|
||||||
|
|
||||||
|
1. Sign in with your `@neurontechnologies.ai` Google account at
|
||||||
|
`https://sandbox.neurontechnologies.ai/`. Cloudflare Access intercepts
|
||||||
|
the request, redirects you to the Google IdP, and only allows
|
||||||
|
`email_domain == neurontechnologies.ai` through.
|
||||||
|
2. After auth, you land on the running stub. With the stub deployed, the
|
||||||
|
root response is:
|
||||||
|
```json
|
||||||
|
{"env":"sandbox","status":"ready","soul":"not_loaded"}
|
||||||
|
```
|
||||||
|
3. There are no service tokens, no mTLS certs, no bypass paths. The single
|
||||||
|
policy is the entire access surface.
|
||||||
|
|
||||||
|
Verify the lockdown from a shell at any time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI https://sandbox.neurontechnologies.ai/ | head -2
|
||||||
|
# HTTP/2 302
|
||||||
|
# location: https://neuralplatform.cloudflareaccess.com/cdn-cgi/access/login/sandbox.neurontechnologies.ai?...
|
||||||
|
```
|
||||||
|
|
||||||
|
If the first line is anything other than 302 to the cloudflareaccess domain,
|
||||||
|
something has broken. Treat it as a P0.
|
||||||
|
|
||||||
|
## What's NOT in sandbox
|
||||||
|
|
||||||
|
- **No outbound email.** `RESEND_API_KEY` is intentionally not mounted on
|
||||||
|
`neuron-sandbox-us`. Defense-in-depth against an experimental soul
|
||||||
|
accidentally emailing real users.
|
||||||
|
- **No public share endpoints.** `/api/share`, `/said`, and `/share/<id>`
|
||||||
|
return `410 Gone` with `{"error":"not_available_in_sandbox"}`. Public
|
||||||
|
artifact distribution stays in prod.
|
||||||
|
- **No prod data.** Sandbox uses an isolated Supabase project. Nothing in
|
||||||
|
the prod Supabase is reachable from the sandbox SA.
|
||||||
|
- **No prod IAM.** The `neuron-sandbox` SA has zero project-level role
|
||||||
|
bindings. Its only grants are `roles/secretmanager.secretAccessor` on the
|
||||||
|
three sandbox-specific secrets. Verify with:
|
||||||
|
```bash
|
||||||
|
# As neuron-sandbox SA, all of these MUST return PERMISSION_DENIED:
|
||||||
|
gcloud secrets versions access latest --secret=stripe-secret-key
|
||||||
|
gcloud secrets versions access latest --secret=anthropic-api-key
|
||||||
|
gcloud secrets versions access latest --secret=supabase-service-key
|
||||||
|
gcloud run services describe marketing-prod-us --region=us-central1
|
||||||
|
```
|
||||||
|
- **No prod LLM credentials.** Sandbox does NOT mount `anthropic-api-key`.
|
||||||
|
When the real soul lands, it will need its own sandbox-only LLM key
|
||||||
|
(cheaper key, lower rate limits) provisioned and granted to the sandbox SA.
|
||||||
|
|
||||||
|
## Provision the isolated Supabase project (one-time, manual)
|
||||||
|
|
||||||
|
There is no Supabase Management API token in `~/Secrets/api-keys/` or in
|
||||||
|
GCP Secret Manager today, so the project creation is a manual step.
|
||||||
|
|
||||||
|
1. Sign in to https://supabase.com/dashboard with the Neuron Technologies
|
||||||
|
org owner account.
|
||||||
|
2. New project. Name: `neuron-sandbox`. Region: `us-east-1` (closest to
|
||||||
|
us-central1 Cloud Run latency-wise; single-region experimental env, so
|
||||||
|
geo doesn't matter much).
|
||||||
|
3. After provisioning, capture from Project Settings -> API:
|
||||||
|
- Project URL: `https://<ref>.supabase.co`
|
||||||
|
- `anon` public key
|
||||||
|
- `service_role` secret key
|
||||||
|
4. Push them into Secret Manager (this overwrites the placeholder versions
|
||||||
|
that terraform created):
|
||||||
|
```bash
|
||||||
|
echo -n 'https://<ref>.supabase.co' | gcloud secrets versions add \
|
||||||
|
sandbox-supabase-url --data-file=- --project=neuron-785695
|
||||||
|
echo -n '<anon-key>' | gcloud secrets versions add \
|
||||||
|
sandbox-supabase-anon-key --data-file=- --project=neuron-785695
|
||||||
|
echo -n '<service-key>' | gcloud secrets versions add \
|
||||||
|
sandbox-supabase-service-key --data-file=- --project=neuron-785695
|
||||||
|
```
|
||||||
|
5. Force a new sandbox revision so it picks up the secrets:
|
||||||
|
```bash
|
||||||
|
gcloud run services update neuron-sandbox-us --region=us-central1 \
|
||||||
|
--project=neuron-785695 \
|
||||||
|
--image=us-central1-docker.pkg.dev/neuron-785695/neuron-sandbox/sandbox:initial
|
||||||
|
```
|
||||||
|
6. Do NOT seed any tables in this project yet. Real soul might want a
|
||||||
|
different schema than the demo soul. Schema lands when real soul lands.
|
||||||
|
|
||||||
|
When a Supabase Management API token does become available, automate this
|
||||||
|
by writing a small script in `scripts/` that calls
|
||||||
|
`POST https://api.supabase.com/v1/projects` and immediately writes the
|
||||||
|
returned values back to Secret Manager. Track that as a follow-up.
|
||||||
|
|
||||||
|
## Deploy a new build (manual, until CI is wired)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Development/neuron-technologies/products/sandbox-stub # or real soul source
|
||||||
|
bash ./build.sh # cross-compile + push to AR
|
||||||
|
gcloud run services update neuron-sandbox-us \
|
||||||
|
--region=us-central1 \
|
||||||
|
--project=neuron-785695 \
|
||||||
|
--image=us-central1-docker.pkg.dev/neuron-785695/neuron-sandbox/sandbox:<tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
The terraform `google_cloud_run_v2_service.neuron_sandbox` has
|
||||||
|
`lifecycle.ignore_changes = [template[0].containers[0].image]` so CI
|
||||||
|
rollouts won't be undone by the next `terraform apply`.
|
||||||
|
|
||||||
|
CI wiring lands as part of the real-soul-build follow-up backlog item
|
||||||
|
(see `mcp__neuron__planWork` for the tracked item).
|
||||||
|
|
||||||
|
## Disaster recovery
|
||||||
|
|
||||||
|
Sandbox is disposable. If state gets weird, nuke it and rebuild:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Development/infrastructure/servers/gcp
|
||||||
|
terraform destroy \
|
||||||
|
-target=cloudflare_zero_trust_access_policy.sandbox_only_neurontech \
|
||||||
|
-target=cloudflare_zero_trust_access_application.sandbox \
|
||||||
|
-target=cloudflare_record.sandbox \
|
||||||
|
-target=google_cloud_run_domain_mapping.sandbox \
|
||||||
|
-target=google_cloud_run_v2_service_iam_member.neuron_sandbox_public \
|
||||||
|
-target=google_cloud_run_v2_service.neuron_sandbox \
|
||||||
|
-target=google_secret_manager_secret_iam_member.sandbox_url_accessor \
|
||||||
|
-target=google_secret_manager_secret_iam_member.sandbox_anon_accessor \
|
||||||
|
-target=google_secret_manager_secret_iam_member.sandbox_service_accessor \
|
||||||
|
-target=google_secret_manager_secret_version.sandbox_supabase_url_placeholder \
|
||||||
|
-target=google_secret_manager_secret_version.sandbox_supabase_anon_key_placeholder \
|
||||||
|
-target=google_secret_manager_secret_version.sandbox_supabase_service_key_placeholder \
|
||||||
|
-target=google_secret_manager_secret.sandbox_supabase_url \
|
||||||
|
-target=google_secret_manager_secret.sandbox_supabase_anon_key \
|
||||||
|
-target=google_secret_manager_secret.sandbox_supabase_service_key \
|
||||||
|
-target=google_service_account.neuron_sandbox \
|
||||||
|
-target=google_artifact_registry_repository.sandbox
|
||||||
|
|
||||||
|
# Then re-apply:
|
||||||
|
terraform apply
|
||||||
|
```
|
||||||
|
|
||||||
|
If the 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/sandbox.tf` | All sandbox terraform |
|
||||||
|
| `servers/gcp/sandbox/README.md` | This document |
|
||||||
|
| `~/Development/neuron-technologies/products/sandbox-stub/` | Stub source code (Go, 4 routes) |
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
cloudflare_zone_id_neurontechnologies = "e844374f203dca4944d77d40ca0710ae"
|
cloudflare_zone_id_neurontechnologies = "e844374f203dca4944d77d40ca0710ae"
|
||||||
cloudflare_api_key = "007bbefe03eac0e502c339423c50dd911776a"
|
cloudflare_api_key = "007bbefe03eac0e502c339423c50dd911776a"
|
||||||
cloudflare_email = "andersonwilliam85@gmail.com"
|
cloudflare_email = "andersonwilliam85@gmail.com"
|
||||||
|
cloudflare_account_id = "651161e0a3d321561b4c90b5bcd5f15b"
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ variable "cloudflare_email" {
|
|||||||
type = string
|
type = string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "cloudflare_account_id" {
|
||||||
|
description = "Cloudflare account ID (used for Zero Trust Access policies)"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
locals {
|
locals {
|
||||||
project_id = var.project_id
|
project_id = var.project_id
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user