From aca0b184f04e334dda536c9786df2679124cbddb Mon Sep 17 00:00:00 2001 From: Param Raval Date: Tue, 20 Oct 2026 16:16:04 -0400 Subject: [PATCH 1/3] changes to deploy.sh and add terraform-dev to infrastructure to create identical dev env on gcp. --- .dockerignore | 2 + .gitignore | 3 + docs/dev-environment.md | 81 +++ infrastructure/deploy.sh | 32 +- .../terraform-dev/.terraform.lock.hcl | 44 ++ infrastructure/terraform-dev/main.tf | 584 ++++++++++++++++++ infrastructure/terraform-dev/outputs.tf | 13 + infrastructure/terraform-dev/variables.tf | 58 ++ 8 files changed, 816 insertions(+), 1 deletion(-) create mode 100644 docs/dev-environment.md create mode 100644 infrastructure/terraform-dev/.terraform.lock.hcl create mode 100644 infrastructure/terraform-dev/main.tf create mode 100644 infrastructure/terraform-dev/outputs.tf create mode 100644 infrastructure/terraform-dev/variables.tf diff --git a/.dockerignore b/.dockerignore index b41e4b8..19f287e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,7 @@ __pycache__ **/.pytest_cache **/.mypy_cache infrastructure/terraform/.terraform +infrastructure/terraform-dev/.terraform *.tfstate *.tfstate.* # Secrets: provided at runtime (Kubernetes secret mount / docker-compose bind mount), never baked into the image @@ -23,3 +24,4 @@ env .env* service-account.json infrastructure/terraform/tfplan +infrastructure/terraform-dev/tfplan diff --git a/.gitignore b/.gitignore index 99b449a..44de16e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ get-pip.py hf_cache/ infrastructure/deploy.ps1 infrastructure/terraform/tfplan +infrastructure/terraform-dev/.terraform +infrastructure/terraform-dev/errored.tfstate +infrastructure/terraform-dev/tfplan diff --git a/docs/dev-environment.md b/docs/dev-environment.md new file mode 100644 index 0000000..6648415 --- /dev/null +++ b/docs/dev-environment.md @@ -0,0 +1,81 @@ +# Dev environment + +A copy of production for end-to-end testing, served at `https://api-dev.veri-fact.ai`. + +It lives in the same GCP project (`misinformation-mitigation`). Everything else is separate, with `dev` in each GCP resource name: +- VPC +- GKE cluster +- Cloud SQL instance +- static IP +- certificate +- Terraform state (`gs://misinformation-mitigation-terraform-state/terraform/dev`) + +Kubernetes objects inside the dev cluster (namespace, deployment, ingress) keep prod's names, so the same `kubectl` commands work in both. + +Shared with prod: +- the `veri-fact.ai` Cloud DNS zone, where dev adds its own `api-dev` A record +- the Artifact Registry, so a commit tested in dev is the exact image that later ships to prod +- the Vertex service-account key (`service-account.json`) + +| | Prod | Dev | +|---|---|---| +| Terraform | `infrastructure/terraform` | `infrastructure/terraform-dev` | +| Secrets file | `.env` | the same `.env` (optional `DEV_DB_PASSWORD`) | +| Deployed from | `main` | the `dev-env` branch only | +| Cluster | `misinformation-mitigation-gke-cluster` | `misinformation-mitigation-dev-cluster` | +| API | `api.veri-fact.ai` | `api-dev.veri-fact.ai` | +| Database | prod data | starts empty; migrations run on pod start | + +The two Terraform roots are copies of each other. When you change resources in one, make the same change in the other. + +## First-time setup + +1. **Optional: give dev its own database password.** Add `DEV_DB_PASSWORD=...` to the same `.env`. If you leave it out, dev uses the prod value of `TF_VAR_db_password`. Dev has its own database instance either way. +2. **Deploy from the `dev-env` branch:** + ```bash + git switch dev-env + DEPLOY_ENV=dev ./infrastructure/deploy.sh + ``` + - On the first run, Terraform first shows a plan for just the cluster and asks for `yes`. Then the script plans everything else and asks again. Both plans should show only creates. + - As with prod, Terraform asks for `auth0_client_id` and `auth0_client_secret` unless they are set; the app doesn't use them. + - The script then creates the `vertex-ai-credentials` secret in the dev cluster from the repo's local `service-account.json`, the same key file used for local development. It does this only when the secret doesn't exist yet. +3. **Wait for the certificate.** Google needs about 15–60 minutes after DNS resolves to issue it. Until then the health check at the end of the script fails. Check progress with: + ```bash + kubectl -n misinformation-mitigation describe managedcertificate misinformation-mitigation-cert + ``` + Once it says `Active`, run the deploy again to confirm the health check passes. + +## Day-to-day + +- **Deploy dev.** Commit and push to `dev-env`, then run the command below. The script refuses dev deploys from any other branch. + ```bash + DEPLOY_ENV=dev ./infrastructure/deploy.sh + ``` +- **Roll back** to an earlier image: + ```bash + DEPLOY_ENV=dev DEPLOY_TAG= ./infrastructure/deploy.sh + ``` +- **Prod is unchanged.** Running `./infrastructure/deploy.sh` without `DEPLOY_ENV` deploys prod exactly as before. + +## Dev frontend + +The frontend reads `NEXT_PUBLIC_API_URL`, and falls back to `https://api.veri-fact.ai` when it is unset. + +1. **Vercel.** On the `dev-env` branch of `veracity-eval-frontend`, set these Preview environment variables, scoped to that branch: + - `NEXT_PUBLIC_API_URL=https://api-dev.veri-fact.ai` + - `AUTH0_BASE_URL=`, for example `https://veracity-eval-frontend-git-dev-env-.vercel.app` + - All other Auth0 variables stay the same as prod. +2. **Auth0 dashboard.** Add the dev frontend URL to the application's: + - Allowed Callback URLs (`/api/auth/callback`) + - Allowed Logout URLs + - Allowed Web Origins +3. **CORS.** Add the dev frontend's URL to the `allow_origins` list in `app/main.py`, the same way the other Vercel branch URLs are listed, and redeploy dev. + +## Costs and teardown + +Dev runs one e2-medium node (autoscaling to at most 2), a `db-g1-small` Cloud SQL instance, an HTTPS load balancer and Cloud NAT. + +To remove everything, run the command below. Terraform asks for each variable it doesn't find; for a destroy the values aren't used, so any value works. It deletes only dev resources, including the dev database and its data, and the `api-dev` DNS record. +```bash +terraform -chdir=infrastructure/terraform-dev destroy +``` diff --git a/infrastructure/deploy.sh b/infrastructure/deploy.sh index 88608c5..141c670 100755 --- a/infrastructure/deploy.sh +++ b/infrastructure/deploy.sh @@ -24,6 +24,19 @@ REGISTRY="${REGION}-docker.pkg.dev/${PROJECT_ID}/misinformation-mitigation" TERRAFORM_DIR="./infrastructure/terraform" HEALTH_URL="https://api.veri-fact.ai/v1/health" +# Dev environment overrides. Usage: DEPLOY_ENV=dev ./infrastructure/deploy.sh (from the dev-env branch) +DEPLOY_ENV="${DEPLOY_ENV:-prod}" +if [ "$DEPLOY_ENV" = "dev" ]; then + CLUSTER_NAME="misinformation-mitigation-dev-cluster" + TERRAFORM_DIR="./infrastructure/terraform-dev" + HEALTH_URL="https://api-dev.veri-fact.ai/v1/health" + # Dev has its own database; give it its own password when DEV_DB_PASSWORD is set + export TF_VAR_db_password="${DEV_DB_PASSWORD:-${TF_VAR_db_password:-}}" +elif [ "$DEPLOY_ENV" != "prod" ]; then + echo "ERROR: DEPLOY_ENV must be 'prod' or 'dev', not '$DEPLOY_ENV'." + exit 1 +fi + # Fall back to the app's own variable names so the same keys need not be duplicated in .env export TF_VAR_serper_api_key="${TF_VAR_serper_api_key:-${SERPER_API_KEY:-}}" export TF_VAR_together_api_key="${TF_VAR_together_api_key:-${TOGETHER_API_KEY:-}}" @@ -64,6 +77,11 @@ if [ -n "$(git status --porcelain --untracked-files=no)" ]; then echo "ERROR: You have uncommitted changes. Commit or stash them before deploying." exit 1 fi +# Dev is only ever deployed from the dev-env branch +if [ "$DEPLOY_ENV" = "dev" ] && [ "$(git rev-parse --abbrev-ref HEAD)" != "dev-env" ]; then + echo "ERROR: Dev deploys must run from the dev-env branch. Run: git switch dev-env" + exit 1 +fi IMAGE_TAG=$(git rev-parse --short HEAD) git fetch --quiet origin if [ -n "${DEPLOY_TAG:-}" ]; then @@ -73,7 +91,7 @@ if [ -n "${DEPLOY_TAG:-}" ]; then elif [ -z "$(git branch -r --contains HEAD)" ]; then echo "ERROR: Commit $IMAGE_TAG has not been pushed to GitHub. Push it before deploying." exit 1 -elif ! git merge-base --is-ancestor HEAD origin/main; then +elif [ "$DEPLOY_ENV" = "prod" ] && ! git merge-base --is-ancestor HEAD origin/main; then read -r -p "WARNING: $IMAGE_TAG is not on origin/main. Deploy it anyway? Type 'yes' to continue: " answer [ "$answer" = "yes" ] || exit 1 fi @@ -137,6 +155,13 @@ cd $TERRAFORM_DIR # A saved plan contains secrets in plain text; always remove it trap "rm -f '$PWD/tfplan'" EXIT terraform init -reconfigure + +# kubernetes_manifest resources need a reachable cluster at plan time, so a +# brand-new dev environment creates the cluster first in a separate apply. +if [ "$DEPLOY_ENV" = "dev" ] && ! gcloud container clusters describe "$CLUSTER_NAME" --zone "$ZONE" > /dev/null 2>&1; then + terraform apply -target=google_container_node_pool.primary_nodes +fi + terraform plan -out=tfplan # The plan covers the database, networking and ingress as well as the API image, @@ -154,6 +179,11 @@ cd - > /dev/null echo "Connecting to GKE cluster..." gcloud container clusters get-credentials $CLUSTER_NAME --zone "$ZONE" +# A new dev cluster has no Vertex key secret yet; create it from the local key file +if [ "$DEPLOY_ENV" = "dev" ] && ! kubectl get secret vertex-ai-credentials -n misinformation-mitigation > /dev/null 2>&1; then + kubectl -n misinformation-mitigation create secret generic vertex-ai-credentials --from-file=service-account.json +fi + kubectl rollout status deployment/misinformation-mitigation-api -n misinformation-mitigation --timeout=10m echo "Checking API health at ${HEALTH_URL}..." diff --git a/infrastructure/terraform-dev/.terraform.lock.hcl b/infrastructure/terraform-dev/.terraform.lock.hcl new file mode 100644 index 0000000..589b02e --- /dev/null +++ b/infrastructure/terraform-dev/.terraform.lock.hcl @@ -0,0 +1,44 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "4.85.0" + constraints = "~> 4.0" + hashes = [ + "h1:ZVDZuhYSIWhCkSuDkwFeSIJjn0/DcCxak2W/cHW4OQQ=", + "h1:sld/eTvevl/Af3upWX1TesnLLCCUMBQlczxo5lPzA48=", + "zh:17d60a6a6c1741cf1e09ac6731433a30950285eac88236e623ab4cbf23832ca3", + "zh:1c70254c016439dbb75cab646b4beace6ceeff117c75d81f2cc27d41c312f752", + "zh:35e2aa2cc7ac84ce55e05bb4de7b461b169d3582e56d3262e249ff09d64fe008", + "zh:417afb08d7b2744429f6b76806f4134d62b0354acf98e8a6c00de3c24f2bb6ad", + "zh:622165d09d21d9a922c86f1fc7177a400507f2a8c4a4513114407ae04da2dd29", + "zh:7cdb8e39a8ea0939558d87d2cb6caceded9e21f21003d9e9f9ce648d5db0bc3a", + "zh:851e737dc551d6004a860a8907fda65118fc2c7ede9fa828f7be704a2a39e68f", + "zh:a331ad289a02a2c4473572a573dc389be0a604cdd9e03dd8dbc10297fb14f14d", + "zh:b67fd531251380decd8dd1f849460d60f329f89df3d15f5815849a1dd001f430", + "zh:be8785957acca4f97aa3e800b313b57d1fca07788761c8867c9bc701fbe0bdb5", + "zh:cb6579a259fe020e1f88217d8f6937b2d5ace15b6406370977a1966eb31b1ca5", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "2.32.0" + constraints = "~> 2.0" + hashes = [ + "h1:3j4XBR5UWQA7xXaiEnzZp0bHbcwOhWetHYKTWIrUTI0=", + "h1:yfV3jmsFAvnByjJEsL4DjjQmGViS+MMcBeZWnDH3mPo=", + "zh:0e715d7fb13a8ad569a5fdc937b488590633f6942e986196fdb17cd7b8f7720e", + "zh:495fc23acfe508ed981e60af9a3758218b0967993065e10a297fdbc210874974", + "zh:4b930a8619910ef528bc90dae739cb4236b9b76ce41367281e3bc3cf586101c7", + "zh:5344405fde7b1febf0734052052268ee24e7220818155702907d9ece1c0697c7", + "zh:92ee11e8c23bbac3536df7b124456407f35c6c2468bc0dbab15c3fc9f414bd0e", + "zh:a45488fe8d5bb59c49380f398da5d109a4ac02ebc10824567dabb87f6102fda8", + "zh:a4a0b57cf719a4c91f642436882b7bea24d659c08a5b6f4214ce4fe6a0204caa", + "zh:b7a27a6d11ba956a2d7b0f7389a46ec857ebe46ae3aeee537250e66cac15bf03", + "zh:bf94ce389028b686bfa70a90f536e81bb776c5c20ab70138bbe5c3d0a04c4253", + "zh:d965b2608da0212e26a65a0b3f33c5baae46cbe839196be15d93f70061516908", + "zh:f441fc793d03057a17af8bdca8b26d54916645bc5c148f54e22a54ed39089e83", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/infrastructure/terraform-dev/main.tf b/infrastructure/terraform-dev/main.tf new file mode 100644 index 0000000..f4b3ae7 --- /dev/null +++ b/infrastructure/terraform-dev/main.tf @@ -0,0 +1,584 @@ +# Dev environment: a copy of infrastructure/terraform/main.tf with "-dev" names, +# non-overlapping CIDRs and its own state. It never reads or writes prod state. +# Keep resource shapes in sync with the prod root when changing either one. + +# Provider configuration +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "~> 4.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.0" + } + } + backend "gcs" { + bucket = "misinformation-mitigation-terraform-state" + prefix = "terraform/dev" + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +locals { + name = "misinformation-mitigation-dev" + cluster_tag = "gke-${local.name}-cluster" +} + +# Get GKE cluster info +data "google_container_cluster" "my_cluster" { + name = google_container_cluster.primary.name + location = google_container_cluster.primary.location +} + +# Configure kubernetes provider with cluster access token +data "google_client_config" "default" {} + +provider "kubernetes" { + host = "https://${data.google_container_cluster.my_cluster.endpoint}" + token = data.google_client_config.default.access_token + cluster_ca_certificate = base64decode(data.google_container_cluster.my_cluster.master_auth[0].cluster_ca_certificate) +} + +# Network resources +resource "google_compute_network" "vpc_network" { + name = "${local.name}-vpc-network" + auto_create_subnetworks = false +} + +resource "google_compute_subnetwork" "subnet" { + name = "${local.name}-subnet" + ip_cidr_range = "10.12.0.0/16" + region = var.region + network = google_compute_network.vpc_network.id + + secondary_ip_range { + range_name = "pod-ranges" + ip_cidr_range = "10.120.0.0/16" + } + + secondary_ip_range { + range_name = "services-ranges" + ip_cidr_range = "10.130.0.0/16" + } +} + +# GKE Cluster +resource "google_container_cluster" "primary" { + name = "${local.name}-cluster" + location = "${var.region}-a" + + remove_default_node_pool = true + initial_node_count = 1 + + network = google_compute_network.vpc_network.name + subnetwork = google_compute_subnetwork.subnet.name + + ip_allocation_policy { + cluster_secondary_range_name = "pod-ranges" + services_secondary_range_name = "services-ranges" + } + + private_cluster_config { + enable_private_nodes = true + enable_private_endpoint = false + master_ipv4_cidr_block = "172.16.0.16/28" + } + + master_authorized_networks_config { + cidr_blocks { + cidr_block = "0.0.0.0/0" + display_name = "All" + } + } + + workload_identity_config { + workload_pool = "${var.project_id}.svc.id.goog" + } +} + +resource "google_container_node_pool" "primary_nodes" { + name = "${local.name}-node-pool" + location = "${var.region}-a" + cluster = google_container_cluster.primary.name + + initial_node_count = 1 + + autoscaling { + min_node_count = 1 + max_node_count = 2 + } + + upgrade_settings { + max_surge = 1 + max_unavailable = 0 + strategy = "SURGE" + } + + management { + auto_repair = true + auto_upgrade = true + } + + lifecycle { + ignore_changes = [ + node_config[0].resource_labels, + node_config[0].metadata, + node_config[0].kubelet_config, + ] + } + + node_config { + preemptible = false + machine_type = "e2-medium" + + labels = { + environment = "dev", + app = "misinformation-mitigation" + } + + service_account = google_service_account.gke_sa.email + oauth_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + tags = [local.cluster_tag] + + workload_metadata_config { + mode = "GKE_METADATA" + } + } +} + +resource "google_compute_firewall" "allow_http_https" { + name = "${local.name}-allow-http-https" + network = google_compute_network.vpc_network.name + + allow { + protocol = "tcp" + ports = ["80", "443"] + } + + source_ranges = ["0.0.0.0/0"] + target_tags = [local.cluster_tag] +} + + +# Service Account for GKE +resource "google_service_account" "gke_sa" { + account_id = "misinfo-dev-gke-sa" + display_name = "GKE Service Account (dev)" +} + +resource "google_project_iam_member" "gke_sa_roles" { + for_each = toset([ + "roles/container.developer", + "roles/cloudsql.client", + "roles/cloudsql.instanceUser", + "roles/storage.objectViewer" + ]) + + project = var.project_id + role = each.key + member = "serviceAccount:${google_service_account.gke_sa.email}" +} + +# Cloud SQL +resource "google_compute_global_address" "private_ip_address" { + name = "${local.name}-private-ip-address" + purpose = "VPC_PEERING" + address_type = "INTERNAL" + prefix_length = 16 + network = google_compute_network.vpc_network.id +} + +resource "google_service_networking_connection" "private_vpc_connection" { + network = google_compute_network.vpc_network.id + service = "servicenetworking.googleapis.com" + reserved_peering_ranges = [google_compute_global_address.private_ip_address.name] +} + +resource "google_sql_database_instance" "misinformation_mitigation_db" { + name = "${local.name}-db" + database_version = "POSTGRES_13" + region = var.region + + depends_on = [google_service_networking_connection.private_vpc_connection] + + settings { + tier = var.db_tier + edition = "ENTERPRISE" + + ip_configuration { + ipv4_enabled = false + private_network = google_compute_network.vpc_network.id + } + } + + deletion_protection = false +} + +resource "google_sql_database" "misinformation_mitigation_database" { + name = "misinformation_mitigation_db" + instance = google_sql_database_instance.misinformation_mitigation_db.name +} + +resource "google_sql_user" "misinformation_mitigation_user" { + name = "misinformation_mitigation_user" + instance = google_sql_database_instance.misinformation_mitigation_db.name + password = var.db_password +} + +# Kubernetes Resources (separate cluster, so the namespace and object names match prod) +resource "kubernetes_namespace" "misinformation_mitigation" { + metadata { + name = "misinformation-mitigation" + } +} + +resource "kubernetes_service_account" "workload_identity_sa" { + metadata { + name = "workload-identity-sa" + namespace = kubernetes_namespace.misinformation_mitigation.metadata[0].name + annotations = { + "iam.gke.io/gcp-service-account" = google_service_account.gke_sa.email + } + } +} + +resource "google_service_account_iam_binding" "workload_identity_sa_binding" { + service_account_id = google_service_account.gke_sa.name + role = "roles/iam.workloadIdentityUser" + + members = [ + "serviceAccount:${var.project_id}.svc.id.goog[${kubernetes_namespace.misinformation_mitigation.metadata[0].name}/${kubernetes_service_account.workload_identity_sa.metadata[0].name}]", + ] +} + +resource "kubernetes_deployment" "misinformation_mitigation_api" { + metadata { + name = "misinformation-mitigation-api" + namespace = kubernetes_namespace.misinformation_mitigation.metadata[0].name + } + + spec { + replicas = 1 + revision_history_limit = 1 + + strategy { + type = "Recreate" + } + + selector { + match_labels = { + app = "misinformation-mitigation-api" + } + } + + template { + metadata { + labels = { + app = "misinformation-mitigation-api" + } + } + + spec { + service_account_name = kubernetes_service_account.workload_identity_sa.metadata[0].name + + container { + image = var.image_uri + name = "misinformation-mitigation-api" + + env { + name = "DATABASE_URL" + value = "postgresql://misinformation_mitigation_user:${var.db_password}@127.0.0.1:5432/misinformation_mitigation_db" + } + + env { + name = "SERPER_API_KEY" + value = var.serper_api_key + } + + env { + name = "TOGETHER_API_KEY" + value = var.together_api_key + } + + env { + name = "GOOGLE_SEARCH_API_KEY" + value = var.google_search_api_key + } + + env { + name = "GOOGLE_SEARCH_ENGINE_ID" + value = var.google_search_engine_id + } + + env { + name = "AUTH0_CLIENT_SECRET" + value = var.auth0_client_secret + } + + env { + name = "AUTH0_CLIENT_ID" + value = var.auth0_client_id + } + + env { + name = "GOOGLE_APPLICATION_CREDENTIALS" + value = "/app/service-account.json" + } + + volume_mount { + name = "service-account-key" + mount_path = "/app/service-account.json" + sub_path = "service-account.json" + } + + + port { + container_port = 8001 + } + + readiness_probe { + http_get { + path = "/v1/health" + port = 8001 + } + initial_delay_seconds = 120 + period_seconds = 5 + } + } + + container { + name = "cloud-sql-proxy" + image = "gcr.io/cloudsql-docker/gce-proxy:1.33.1" + command = [ + "/cloud_sql_proxy", + "-instances=${var.project_id}:${var.region}:${google_sql_database_instance.misinformation_mitigation_db.name}=tcp:5432", + "-ip_address_types=PRIVATE" + ] + + security_context { + run_as_non_root = true + } + } + + # Created by hand once per cluster (see docs/dev-environment.md), as in prod + volume { + name = "service-account-key" + secret { + secret_name = "vertex-ai-credentials" + items { + key = "service-account.json" + path = "service-account.json" + } + } + } + } + } + } + + # The pod cannot become ready until the vertex-ai-credentials secret exists + wait_for_rollout = false +} + +resource "kubernetes_service" "misinformation_mitigation_api" { + metadata { + name = "misinformation-mitigation-api" + namespace = kubernetes_namespace.misinformation_mitigation.metadata[0].name + + annotations = { + "cloud.google.com/backend-config" = jsonencode({ + "default" = "api-timeout-config" + }) + } + } + + spec { + selector = { + app = kubernetes_deployment.misinformation_mitigation_api.spec[0].template[0].metadata[0].labels.app + } + + port { + port = 80 + target_port = 8001 + } + type = "NodePort" + } +} + + +# Load Balancer and DNS +resource "kubernetes_manifest" "api_backend_config" { + manifest = { + apiVersion = "cloud.google.com/v1" + kind = "BackendConfig" + metadata = { + name = "api-timeout-config" + namespace = kubernetes_namespace.misinformation_mitigation.metadata[0].name + } + spec = { + timeoutSec = 600 # 600 seconds = 10 minutes + } + } +} + +resource "google_compute_global_address" "misinformation_mitigation_api_ip" { + name = "${local.name}-api-ip" +} + +resource "google_compute_managed_ssl_certificate" "misinformation_mitigation_cert" { + name = "${local.name}-cert" + managed { + domains = [var.api_hostname] + } +} + +resource "kubernetes_ingress_v1" "misinformation_mitigation_ingress" { + # GKE appends its auto-provisioned managed certificate to this annotation + lifecycle { + ignore_changes = [metadata[0].annotations["ingress.gcp.kubernetes.io/pre-shared-cert"]] + } + + metadata { + name = "misinformation-mitigation-ingress" + namespace = kubernetes_namespace.misinformation_mitigation.metadata[0].name + annotations = { + "kubernetes.io/ingress.class" = "gce" + "kubernetes.io/ingress.global-static-ip-name" = google_compute_global_address.misinformation_mitigation_api_ip.name + "networking.gke.io/managed-certificates" = "misinformation-mitigation-cert" + "kubernetes.io/ingress.allow-http" = "false" + "ingress.gcp.kubernetes.io/pre-shared-cert" = google_compute_managed_ssl_certificate.misinformation_mitigation_cert.name + "kubernetes.io/ingress.force-ssl-redirect" = "true" + } + } + spec { + default_backend { + service { + name = kubernetes_service.misinformation_mitigation_api.metadata[0].name + port { + number = 80 + } + } + } + rule { + host = var.api_hostname + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service.misinformation_mitigation_api.metadata[0].name + port { + number = 80 + } + } + } + } + } + } + } +} + +resource "kubernetes_manifest" "managed_certificate" { + manifest = { + apiVersion = "networking.gke.io/v1" + kind = "ManagedCertificate" + metadata = { + name = "misinformation-mitigation-cert" + namespace = kubernetes_namespace.misinformation_mitigation.metadata[0].name + } + spec = { + domains = [var.api_hostname] + } + } +} + +# The zone is owned by the prod root; dev only adds its own record to it +data "google_dns_managed_zone" "veri_fact_ai" { + name = "veri-fact-ai-zone" +} + +resource "google_dns_record_set" "api_dev" { + name = "${var.api_hostname}." + managed_zone = data.google_dns_managed_zone.veri_fact_ai.name + type = "A" + ttl = 300 + + rrdatas = [google_compute_global_address.misinformation_mitigation_api_ip.address] +} + +resource "google_compute_router" "router" { + name = "${local.name}-router" + region = var.region + network = google_compute_network.vpc_network.id +} + +resource "google_compute_router_nat" "nat" { + name = "${local.name}-nat" + router = google_compute_router.router.name + region = google_compute_router.router.region + nat_ip_allocate_option = "AUTO_ONLY" + source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES" + + log_config { + enable = true + filter = "ERRORS_ONLY" + } +} + +resource "google_compute_firewall" "allow_egress" { + name = "${local.name}-allow-egress" + network = google_compute_network.vpc_network.name + + allow { + protocol = "tcp" + ports = ["443"] # HTTPS + } + + allow { + protocol = "udp" + ports = ["53"] # DNS + } + + direction = "EGRESS" + destination_ranges = ["0.0.0.0/0"] # All destinations +} + +# Cluster-local RBAC (the project-level owner grant is managed by the prod root) +resource "kubernetes_cluster_role_binding" "user_cluster_admin" { + metadata { + name = "user-cluster-admin" + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = "cluster-admin" + } + subject { + kind = "User" + name = var.user_email + api_group = "rbac.authorization.k8s.io" + } +} + +resource "kubernetes_role_binding" "user_namespace_admin" { + metadata { + name = "user-namespace-admin" + namespace = kubernetes_namespace.misinformation_mitigation.metadata[0].name + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = "admin" + } + subject { + kind = "User" + name = var.user_email + api_group = "rbac.authorization.k8s.io" + } +} diff --git a/infrastructure/terraform-dev/outputs.tf b/infrastructure/terraform-dev/outputs.tf new file mode 100644 index 0000000..08e7e15 --- /dev/null +++ b/infrastructure/terraform-dev/outputs.tf @@ -0,0 +1,13 @@ +output "kubernetes_cluster_name" { + value = google_container_cluster.primary.name + description = "GKE Cluster Name" +} + +output "database_connection_name" { + value = google_sql_database_instance.misinformation_mitigation_db.connection_name +} + +output "api_ip_address" { + value = google_compute_global_address.misinformation_mitigation_api_ip.address + description = "The IP address for the dev API" +} diff --git a/infrastructure/terraform-dev/variables.tf b/infrastructure/terraform-dev/variables.tf new file mode 100644 index 0000000..6994617 --- /dev/null +++ b/infrastructure/terraform-dev/variables.tf @@ -0,0 +1,58 @@ +variable "project_id" { + description = "The project ID to deploy to" + default = "misinformation-mitigation" +} + +variable "image_uri" { + description = "Exact Docker image deployed to Kubernetes" + type = string +} + +variable "region" { + description = "The region to deploy to" + default = "northamerica-northeast1" +} + +variable "api_hostname" { + description = "Public hostname of the dev API" + default = "api-dev.veri-fact.ai" +} + +variable "db_tier" { + description = "Cloud SQL machine tier (prod uses db-custom-1-3840)" + default = "db-g1-small" +} + +variable "db_password" { + description = "Database password" + sensitive = true +} + +variable "user_email" { + description = "Email of the user to grant permissions" + default = "wgarneau@veri-fact.ai" +} + +variable "serper_api_key" { + description = "Serper search API Key" +} + +variable "together_api_key" { + description = "Together AI API Key" +} + +variable "google_search_api_key" { + description = "Google Search API Key" +} + +variable "google_search_engine_id" { + description = "Google Search Engine ID" +} + +variable "auth0_client_id" { + description = "Auth0 Client ID" +} + +variable "auth0_client_secret" { + description = "Auth0 Client Secret" +} From fea0f5791cee8cfdf57b980b0f9d60eca4e61b5a Mon Sep 17 00:00:00 2001 From: Param Raval Date: Wed, 21 Oct 2026 10:10:23 -0400 Subject: [PATCH 2/3] add frontend dev-env to allow_origins. --- app/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/main.py b/app/main.py index 08eee3e..27222e1 100644 --- a/app/main.py +++ b/app/main.py @@ -68,6 +68,7 @@ async def lifespan(app: FastAPI): "https://veracity-eval-frontend-git-feature-open-f37c90-complex-data-lab.vercel.app", "https://veracity-eval-frontend-git-g0frontend-veracity-s-projects.vercel.app", "https://veracity-eval-frontend-git-g0frontendmod-veracity-s-projects.vercel.app", + "https://veracity-eval-frontend-git-dev-env-veracity-s-projects.vercel.app", ], allow_credentials=True, allow_methods=["*"], From c40a5f65c8b07851e65fbc75eb4f568f1f3e6e23 Mon Sep 17 00:00:00 2001 From: NardinNakhla Date: Wed, 21 Oct 2026 14:09:59 -0400 Subject: [PATCH 3/3] Fix dev env image pull, mark secrets sensitive, document Cloudflare DNS - Grant dev node SA roles/artifactregistry.reader so pods can pull images - Mark API key/secret Terraform variables sensitive in dev and prod - Fail dev deploys early when service-account.json is missing - Document the manual Cloudflare api-dev record and service-account.json prerequisite --- docs/dev-environment.md | 17 ++++++++++++----- infrastructure/deploy.sh | 6 ++++++ infrastructure/terraform-dev/main.tf | 4 +++- infrastructure/terraform-dev/variables.tf | 4 ++++ infrastructure/terraform/main.tf | 4 ++++ 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/dev-environment.md b/docs/dev-environment.md index 6648415..ae4ac90 100644 --- a/docs/dev-environment.md +++ b/docs/dev-environment.md @@ -13,7 +13,7 @@ It lives in the same GCP project (`misinformation-mitigation`). Everything else Kubernetes objects inside the dev cluster (namespace, deployment, ingress) keep prod's names, so the same `kubectl` commands work in both. Shared with prod: -- the `veri-fact.ai` Cloud DNS zone, where dev adds its own `api-dev` A record +- the `veri-fact.ai` domain. Its DNS is hosted on Cloudflare, where dev has its own `api-dev` A record, added by hand. Terraform also writes an `api-dev` record to the `veri-fact-ai-zone` Cloud DNS zone, but that zone isn't the one the domain uses, so the record has no effect. - the Artifact Registry, so a commit tested in dev is the exact image that later ships to prod - the Vertex service-account key (`service-account.json`) @@ -30,8 +30,9 @@ The two Terraform roots are copies of each other. When you change resources in o ## First-time setup -1. **Optional: give dev its own database password.** Add `DEV_DB_PASSWORD=...` to the same `.env`. If you leave it out, dev uses the prod value of `TF_VAR_db_password`. Dev has its own database instance either way. -2. **Deploy from the `dev-env` branch:** +1. **Put `service-account.json` in the repository root.** This is the Vertex key file used for local development. The script stops before changing anything if it is missing. +2. **Optional: give dev its own database password.** Add `DEV_DB_PASSWORD=...` to the same `.env`. If you leave it out, dev uses the prod value of `TF_VAR_db_password`. Dev has its own database instance either way. +3. **Deploy from the `dev-env` branch:** ```bash git switch dev-env DEPLOY_ENV=dev ./infrastructure/deploy.sh @@ -39,7 +40,12 @@ The two Terraform roots are copies of each other. When you change resources in o - On the first run, Terraform first shows a plan for just the cluster and asks for `yes`. Then the script plans everything else and asks again. Both plans should show only creates. - As with prod, Terraform asks for `auth0_client_id` and `auth0_client_secret` unless they are set; the app doesn't use them. - The script then creates the `vertex-ai-credentials` secret in the dev cluster from the repo's local `service-account.json`, the same key file used for local development. It does this only when the secret doesn't exist yet. -3. **Wait for the certificate.** Google needs about 15–60 minutes after DNS resolves to issue it. Until then the health check at the end of the script fails. Check progress with: +4. **Add the DNS record in Cloudflare.** Get the dev static IP: + ```bash + terraform -chdir=infrastructure/terraform-dev output api_ip_address + ``` + In the Cloudflare dashboard for `veri-fact.ai`, add an `A` record named `api-dev` pointing to that IP, with proxy status **DNS only** (grey cloud). Google can't issue the certificate while the record is proxied. Check it with `dig +short api-dev.veri-fact.ai`, which should print the static IP and not a Cloudflare `104.x` or `172.x` address. +5. **Wait for the certificate.** Google needs about 15–60 minutes after DNS resolves to issue it. Until then the health check at the end of the script fails. Check progress with: ```bash kubectl -n misinformation-mitigation describe managedcertificate misinformation-mitigation-cert ``` @@ -75,7 +81,8 @@ The frontend reads `NEXT_PUBLIC_API_URL`, and falls back to `https://api.veri-fa Dev runs one e2-medium node (autoscaling to at most 2), a `db-g1-small` Cloud SQL instance, an HTTPS load balancer and Cloud NAT. -To remove everything, run the command below. Terraform asks for each variable it doesn't find; for a destroy the values aren't used, so any value works. It deletes only dev resources, including the dev database and its data, and the `api-dev` DNS record. +To remove everything, run the command below. Terraform asks for each variable it doesn't find; for a destroy the values aren't used, so any value works. It deletes only dev resources, including the dev database and its data. ```bash terraform -chdir=infrastructure/terraform-dev destroy ``` +Then delete the `api-dev` record in Cloudflare by hand; Terraform doesn't manage it. diff --git a/infrastructure/deploy.sh b/infrastructure/deploy.sh index 141c670..74edda4 100755 --- a/infrastructure/deploy.sh +++ b/infrastructure/deploy.sh @@ -56,6 +56,12 @@ for var in TF_VAR_db_password TF_VAR_serper_api_key TF_VAR_together_api_key TF_V done [ "$missing" -eq 0 ] || exit 1 +# Dev creates its vertex-ai-credentials secret from this file after Terraform applies; fail before changing anything +if [ "$DEPLOY_ENV" = "dev" ] && [ ! -f service-account.json ]; then + echo "ERROR: service-account.json not found in the repository root. Dev deploys need it to create the vertex-ai-credentials secret." + exit 1 +fi + # Function to check if a command is available check_command() { if ! command -v "$1" &> /dev/null diff --git a/infrastructure/terraform-dev/main.tf b/infrastructure/terraform-dev/main.tf index f4b3ae7..53dde8d 100644 --- a/infrastructure/terraform-dev/main.tf +++ b/infrastructure/terraform-dev/main.tf @@ -177,7 +177,9 @@ resource "google_project_iam_member" "gke_sa_roles" { "roles/container.developer", "roles/cloudsql.client", "roles/cloudsql.instanceUser", - "roles/storage.objectViewer" + "roles/storage.objectViewer", + # Prod's SA has this granted by hand; nodes need it to pull from Artifact Registry + "roles/artifactregistry.reader" ]) project = var.project_id diff --git a/infrastructure/terraform-dev/variables.tf b/infrastructure/terraform-dev/variables.tf index 6994617..25a6b43 100644 --- a/infrastructure/terraform-dev/variables.tf +++ b/infrastructure/terraform-dev/variables.tf @@ -35,14 +35,17 @@ variable "user_email" { variable "serper_api_key" { description = "Serper search API Key" + sensitive = true } variable "together_api_key" { description = "Together AI API Key" + sensitive = true } variable "google_search_api_key" { description = "Google Search API Key" + sensitive = true } variable "google_search_engine_id" { @@ -55,4 +58,5 @@ variable "auth0_client_id" { variable "auth0_client_secret" { description = "Auth0 Client Secret" + sensitive = true } diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf index b647116..01aa7a8 100644 --- a/infrastructure/terraform/main.tf +++ b/infrastructure/terraform/main.tf @@ -618,14 +618,17 @@ variable "user_email" { variable "serper_api_key" { description = "Serper search API Key" + sensitive = true } variable "together_api_key" { description = "Together AI API Key" + sensitive = true } variable "google_search_api_key" { description = "Google Search API Key" + sensitive = true } variable "google_search_engine_id" { @@ -638,5 +641,6 @@ variable "auth0_client_id" { variable "auth0_client_secret" { description = "Auth0 Client Secret" + sensitive = true }