From 77b5e6858b62c421ab5dc9f54dbd7b407bf96071 Mon Sep 17 00:00:00 2001 From: Sam Margolis Date: Mon, 2 Mar 2026 08:47:33 -0800 Subject: [PATCH] feat: add minimum viable terraform stack for hosted gcp --- .github/workflows/terraform-apply.yml | 30 +++++++++ .github/workflows/terraform-plan.yml | 32 +++++++++ infra/terraform/README.md | 18 +++++ infra/terraform/environments/dev/main.tf | 41 ++++++++++++ .../environments/dev/terraform.tfvars.example | 18 +++++ infra/terraform/environments/dev/variables.tf | 66 +++++++++++++++++++ infra/terraform/environments/dev/versions.tf | 10 +++ infra/terraform/environments/prod/main.tf | 41 ++++++++++++ .../prod/terraform.tfvars.example | 18 +++++ .../terraform/environments/prod/variables.tf | 66 +++++++++++++++++++ infra/terraform/environments/prod/versions.tf | 10 +++ infra/terraform/environments/staging/main.tf | 41 ++++++++++++ .../staging/terraform.tfvars.example | 18 +++++ .../environments/staging/variables.tf | 66 +++++++++++++++++++ .../environments/staging/versions.tf | 10 +++ .../modules/artifact-registry/main.tf | 6 ++ .../modules/artifact-registry/outputs.tf | 3 + .../modules/artifact-registry/variables.tf | 7 ++ infra/terraform/modules/cloud-run/main.tf | 29 ++++++++ infra/terraform/modules/cloud-run/outputs.tf | 3 + .../terraform/modules/cloud-run/variables.tf | 25 +++++++ infra/terraform/modules/firestore/main.tf | 7 ++ .../terraform/modules/firestore/variables.tf | 12 ++++ infra/terraform/modules/redis/main.tf | 8 +++ infra/terraform/modules/redis/outputs.tf | 7 ++ infra/terraform/modules/redis/variables.tf | 22 +++++++ .../terraform/modules/secret-manager/main.tf | 7 ++ .../modules/secret-manager/outputs.tf | 3 + .../modules/secret-manager/variables.tf | 3 + 29 files changed, 627 insertions(+) create mode 100644 .github/workflows/terraform-apply.yml create mode 100644 .github/workflows/terraform-plan.yml create mode 100644 infra/terraform/README.md create mode 100644 infra/terraform/environments/dev/main.tf create mode 100644 infra/terraform/environments/dev/terraform.tfvars.example create mode 100644 infra/terraform/environments/dev/variables.tf create mode 100644 infra/terraform/environments/dev/versions.tf create mode 100644 infra/terraform/environments/prod/main.tf create mode 100644 infra/terraform/environments/prod/terraform.tfvars.example create mode 100644 infra/terraform/environments/prod/variables.tf create mode 100644 infra/terraform/environments/prod/versions.tf create mode 100644 infra/terraform/environments/staging/main.tf create mode 100644 infra/terraform/environments/staging/terraform.tfvars.example create mode 100644 infra/terraform/environments/staging/variables.tf create mode 100644 infra/terraform/environments/staging/versions.tf create mode 100644 infra/terraform/modules/artifact-registry/main.tf create mode 100644 infra/terraform/modules/artifact-registry/outputs.tf create mode 100644 infra/terraform/modules/artifact-registry/variables.tf create mode 100644 infra/terraform/modules/cloud-run/main.tf create mode 100644 infra/terraform/modules/cloud-run/outputs.tf create mode 100644 infra/terraform/modules/cloud-run/variables.tf create mode 100644 infra/terraform/modules/firestore/main.tf create mode 100644 infra/terraform/modules/firestore/variables.tf create mode 100644 infra/terraform/modules/redis/main.tf create mode 100644 infra/terraform/modules/redis/outputs.tf create mode 100644 infra/terraform/modules/redis/variables.tf create mode 100644 infra/terraform/modules/secret-manager/main.tf create mode 100644 infra/terraform/modules/secret-manager/outputs.tf create mode 100644 infra/terraform/modules/secret-manager/variables.tf diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml new file mode 100644 index 0000000..51ce028 --- /dev/null +++ b/.github/workflows/terraform-apply.yml @@ -0,0 +1,30 @@ +name: Terraform Apply + +on: + workflow_dispatch: + inputs: + environment: + type: choice + required: true + options: [dev, staging, prod] + +jobs: + apply: + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.environment }} + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + - name: Terraform Init + run: terraform -chdir=infra/terraform/environments/${{ github.event.inputs.environment }} init -input=false + - name: Terraform Apply + env: + TF_VAR_project_id: ${{ secrets.GCP_PROJECT_ID }} + TF_VAR_region: ${{ secrets.GCP_REGION }} + TF_VAR_artifact_repository_id: ${{ secrets.GCP_ARTIFACT_REGISTRY_REPOSITORY }} + TF_VAR_service_name: ${{ secrets.GCP_CLOUD_RUN_SERVICE }} + TF_VAR_service_account: ${{ secrets.GCP_RUNTIME_SERVICE_ACCOUNT }} + TF_VAR_image: ${{ secrets.GCP_CLOUD_RUN_IMAGE }} + TF_VAR_redis_name: ${{ secrets.GCP_REDIS_INSTANCE_NAME }} + TF_VAR_anthropic_secret_id: ${{ secrets.GCP_ANTHROPIC_SECRET_ID }} + run: terraform -chdir=infra/terraform/environments/${{ github.event.inputs.environment }} apply -auto-approve -input=false diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml new file mode 100644 index 0000000..93b194d --- /dev/null +++ b/.github/workflows/terraform-plan.yml @@ -0,0 +1,32 @@ +name: Terraform Plan + +on: + pull_request: + branches: [main] + paths: + - 'infra/terraform/**' + +jobs: + plan: + runs-on: ubuntu-latest + strategy: + matrix: + env: [dev, staging, prod] + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + - name: Terraform Init + run: terraform -chdir=infra/terraform/environments/${{ matrix.env }} init -backend=false + - name: Terraform Validate + run: terraform -chdir=infra/terraform/environments/${{ matrix.env }} validate + - name: Terraform Plan + run: | + terraform -chdir=infra/terraform/environments/${{ matrix.env }} plan -input=false -refresh=false \ + -var="project_id=dummy-project" \ + -var="region=us-central1" \ + -var="artifact_repository_id=openscribe-images" \ + -var="service_name=openscribe-web-${{ matrix.env }}" \ + -var="service_account=openscribe-runtime@dummy-project.iam.gserviceaccount.com" \ + -var="image=us-central1-docker.pkg.dev/dummy-project/openscribe-images/openscribe-web:latest" \ + -var="redis_name=openscribe-redis-${{ matrix.env }}" \ + -var="anthropic_secret_id=anthropic-api-key" diff --git a/infra/terraform/README.md b/infra/terraform/README.md new file mode 100644 index 0000000..a610a09 --- /dev/null +++ b/infra/terraform/README.md @@ -0,0 +1,18 @@ +# Terraform Foundation + +This folder contains environment-scoped Terraform entrypoints for `dev`, `staging`, and `prod`. + +## Conventions +- Shared modules live in `infra/terraform/modules/` +- Environment roots live in `infra/terraform/environments//` +- Production changes require PR approval and release tag flow + +## Planned resources +- Artifact Registry +- Cloud Run +- Identity Platform +- Firestore +- Memorystore (Redis) +- Secret Manager +- Logging sink + alerts +- Load Balancer + Cloud Armor diff --git a/infra/terraform/environments/dev/main.tf b/infra/terraform/environments/dev/main.tf new file mode 100644 index 0000000..2da4ade --- /dev/null +++ b/infra/terraform/environments/dev/main.tf @@ -0,0 +1,41 @@ +provider "google" { + project = var.project_id + region = var.region +} + +module "artifact_registry" { + source = "../../modules/artifact-registry" + location = var.region + repository_id = var.artifact_repository_id +} + +module "secret_anthropic" { + source = "../../modules/secret-manager" + secret_id = var.anthropic_secret_id +} + +module "firestore" { + source = "../../modules/firestore" + project_id = var.project_id + location_id = var.firestore_location + create_database = var.create_firestore_database +} + +module "redis" { + source = "../../modules/redis" + name = var.redis_name + memory_size_gb = var.redis_memory_size_gb + region = var.region + tier = var.redis_tier + authorized_network = var.redis_authorized_network +} + +module "cloud_run" { + source = "../../modules/cloud-run" + service_name = var.service_name + region = var.region + service_account = var.service_account + image = var.image + allow_unauthenticated = var.allow_unauthenticated + env_vars = var.env_vars +} diff --git a/infra/terraform/environments/dev/terraform.tfvars.example b/infra/terraform/environments/dev/terraform.tfvars.example new file mode 100644 index 0000000..66f78bb --- /dev/null +++ b/infra/terraform/environments/dev/terraform.tfvars.example @@ -0,0 +1,18 @@ +project_id = "your-gcp-project" +region = "us-central1" +artifact_repository_id = "openscribe-images" +service_name = "openscribe-web" +service_account = "openscribe-runtime@your-gcp-project.iam.gserviceaccount.com" +image = "us-central1-docker.pkg.dev/your-gcp-project/openscribe-images/openscribe-web:latest" +allow_unauthenticated = true +firestore_location = "nam5" +create_firestore_database = false +redis_name = "openscribe-redis" +redis_memory_size_gb = 1 +redis_tier = "BASIC" +redis_authorized_network = null +anthropic_secret_id = "anthropic-api-key" +env_vars = { + HOSTED_MODE = "true" + TRANSCRIPTION_PROVIDER = "gcp_stt_v2" +} diff --git a/infra/terraform/environments/dev/variables.tf b/infra/terraform/environments/dev/variables.tf new file mode 100644 index 0000000..55cb9da --- /dev/null +++ b/infra/terraform/environments/dev/variables.tf @@ -0,0 +1,66 @@ +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "artifact_repository_id" { + type = string +} + +variable "service_name" { + type = string +} + +variable "service_account" { + type = string +} + +variable "image" { + type = string +} + +variable "allow_unauthenticated" { + type = bool + default = false +} + +variable "firestore_location" { + type = string + default = "nam5" +} + +variable "create_firestore_database" { + type = bool + default = false +} + +variable "redis_name" { + type = string +} + +variable "redis_memory_size_gb" { + type = number + default = 1 +} + +variable "redis_tier" { + type = string + default = "BASIC" +} + +variable "redis_authorized_network" { + type = string + default = null +} + +variable "anthropic_secret_id" { + type = string +} + +variable "env_vars" { + type = map(string) + default = {} +} diff --git a/infra/terraform/environments/dev/versions.tf b/infra/terraform/environments/dev/versions.tf new file mode 100644 index 0000000..9972247 --- /dev/null +++ b/infra/terraform/environments/dev/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + } +} diff --git a/infra/terraform/environments/prod/main.tf b/infra/terraform/environments/prod/main.tf new file mode 100644 index 0000000..2da4ade --- /dev/null +++ b/infra/terraform/environments/prod/main.tf @@ -0,0 +1,41 @@ +provider "google" { + project = var.project_id + region = var.region +} + +module "artifact_registry" { + source = "../../modules/artifact-registry" + location = var.region + repository_id = var.artifact_repository_id +} + +module "secret_anthropic" { + source = "../../modules/secret-manager" + secret_id = var.anthropic_secret_id +} + +module "firestore" { + source = "../../modules/firestore" + project_id = var.project_id + location_id = var.firestore_location + create_database = var.create_firestore_database +} + +module "redis" { + source = "../../modules/redis" + name = var.redis_name + memory_size_gb = var.redis_memory_size_gb + region = var.region + tier = var.redis_tier + authorized_network = var.redis_authorized_network +} + +module "cloud_run" { + source = "../../modules/cloud-run" + service_name = var.service_name + region = var.region + service_account = var.service_account + image = var.image + allow_unauthenticated = var.allow_unauthenticated + env_vars = var.env_vars +} diff --git a/infra/terraform/environments/prod/terraform.tfvars.example b/infra/terraform/environments/prod/terraform.tfvars.example new file mode 100644 index 0000000..66f78bb --- /dev/null +++ b/infra/terraform/environments/prod/terraform.tfvars.example @@ -0,0 +1,18 @@ +project_id = "your-gcp-project" +region = "us-central1" +artifact_repository_id = "openscribe-images" +service_name = "openscribe-web" +service_account = "openscribe-runtime@your-gcp-project.iam.gserviceaccount.com" +image = "us-central1-docker.pkg.dev/your-gcp-project/openscribe-images/openscribe-web:latest" +allow_unauthenticated = true +firestore_location = "nam5" +create_firestore_database = false +redis_name = "openscribe-redis" +redis_memory_size_gb = 1 +redis_tier = "BASIC" +redis_authorized_network = null +anthropic_secret_id = "anthropic-api-key" +env_vars = { + HOSTED_MODE = "true" + TRANSCRIPTION_PROVIDER = "gcp_stt_v2" +} diff --git a/infra/terraform/environments/prod/variables.tf b/infra/terraform/environments/prod/variables.tf new file mode 100644 index 0000000..55cb9da --- /dev/null +++ b/infra/terraform/environments/prod/variables.tf @@ -0,0 +1,66 @@ +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "artifact_repository_id" { + type = string +} + +variable "service_name" { + type = string +} + +variable "service_account" { + type = string +} + +variable "image" { + type = string +} + +variable "allow_unauthenticated" { + type = bool + default = false +} + +variable "firestore_location" { + type = string + default = "nam5" +} + +variable "create_firestore_database" { + type = bool + default = false +} + +variable "redis_name" { + type = string +} + +variable "redis_memory_size_gb" { + type = number + default = 1 +} + +variable "redis_tier" { + type = string + default = "BASIC" +} + +variable "redis_authorized_network" { + type = string + default = null +} + +variable "anthropic_secret_id" { + type = string +} + +variable "env_vars" { + type = map(string) + default = {} +} diff --git a/infra/terraform/environments/prod/versions.tf b/infra/terraform/environments/prod/versions.tf new file mode 100644 index 0000000..9972247 --- /dev/null +++ b/infra/terraform/environments/prod/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + } +} diff --git a/infra/terraform/environments/staging/main.tf b/infra/terraform/environments/staging/main.tf new file mode 100644 index 0000000..2da4ade --- /dev/null +++ b/infra/terraform/environments/staging/main.tf @@ -0,0 +1,41 @@ +provider "google" { + project = var.project_id + region = var.region +} + +module "artifact_registry" { + source = "../../modules/artifact-registry" + location = var.region + repository_id = var.artifact_repository_id +} + +module "secret_anthropic" { + source = "../../modules/secret-manager" + secret_id = var.anthropic_secret_id +} + +module "firestore" { + source = "../../modules/firestore" + project_id = var.project_id + location_id = var.firestore_location + create_database = var.create_firestore_database +} + +module "redis" { + source = "../../modules/redis" + name = var.redis_name + memory_size_gb = var.redis_memory_size_gb + region = var.region + tier = var.redis_tier + authorized_network = var.redis_authorized_network +} + +module "cloud_run" { + source = "../../modules/cloud-run" + service_name = var.service_name + region = var.region + service_account = var.service_account + image = var.image + allow_unauthenticated = var.allow_unauthenticated + env_vars = var.env_vars +} diff --git a/infra/terraform/environments/staging/terraform.tfvars.example b/infra/terraform/environments/staging/terraform.tfvars.example new file mode 100644 index 0000000..66f78bb --- /dev/null +++ b/infra/terraform/environments/staging/terraform.tfvars.example @@ -0,0 +1,18 @@ +project_id = "your-gcp-project" +region = "us-central1" +artifact_repository_id = "openscribe-images" +service_name = "openscribe-web" +service_account = "openscribe-runtime@your-gcp-project.iam.gserviceaccount.com" +image = "us-central1-docker.pkg.dev/your-gcp-project/openscribe-images/openscribe-web:latest" +allow_unauthenticated = true +firestore_location = "nam5" +create_firestore_database = false +redis_name = "openscribe-redis" +redis_memory_size_gb = 1 +redis_tier = "BASIC" +redis_authorized_network = null +anthropic_secret_id = "anthropic-api-key" +env_vars = { + HOSTED_MODE = "true" + TRANSCRIPTION_PROVIDER = "gcp_stt_v2" +} diff --git a/infra/terraform/environments/staging/variables.tf b/infra/terraform/environments/staging/variables.tf new file mode 100644 index 0000000..55cb9da --- /dev/null +++ b/infra/terraform/environments/staging/variables.tf @@ -0,0 +1,66 @@ +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "artifact_repository_id" { + type = string +} + +variable "service_name" { + type = string +} + +variable "service_account" { + type = string +} + +variable "image" { + type = string +} + +variable "allow_unauthenticated" { + type = bool + default = false +} + +variable "firestore_location" { + type = string + default = "nam5" +} + +variable "create_firestore_database" { + type = bool + default = false +} + +variable "redis_name" { + type = string +} + +variable "redis_memory_size_gb" { + type = number + default = 1 +} + +variable "redis_tier" { + type = string + default = "BASIC" +} + +variable "redis_authorized_network" { + type = string + default = null +} + +variable "anthropic_secret_id" { + type = string +} + +variable "env_vars" { + type = map(string) + default = {} +} diff --git a/infra/terraform/environments/staging/versions.tf b/infra/terraform/environments/staging/versions.tf new file mode 100644 index 0000000..9972247 --- /dev/null +++ b/infra/terraform/environments/staging/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + } +} diff --git a/infra/terraform/modules/artifact-registry/main.tf b/infra/terraform/modules/artifact-registry/main.tf new file mode 100644 index 0000000..efcb715 --- /dev/null +++ b/infra/terraform/modules/artifact-registry/main.tf @@ -0,0 +1,6 @@ +resource "google_artifact_registry_repository" "this" { + location = var.location + repository_id = var.repository_id + format = "DOCKER" + description = "OpenScribe container images" +} diff --git a/infra/terraform/modules/artifact-registry/outputs.tf b/infra/terraform/modules/artifact-registry/outputs.tf new file mode 100644 index 0000000..9d0ec9e --- /dev/null +++ b/infra/terraform/modules/artifact-registry/outputs.tf @@ -0,0 +1,3 @@ +output "repository_id" { + value = google_artifact_registry_repository.this.repository_id +} diff --git a/infra/terraform/modules/artifact-registry/variables.tf b/infra/terraform/modules/artifact-registry/variables.tf new file mode 100644 index 0000000..efbd935 --- /dev/null +++ b/infra/terraform/modules/artifact-registry/variables.tf @@ -0,0 +1,7 @@ +variable "location" { + type = string +} + +variable "repository_id" { + type = string +} diff --git a/infra/terraform/modules/cloud-run/main.tf b/infra/terraform/modules/cloud-run/main.tf new file mode 100644 index 0000000..6858cd7 --- /dev/null +++ b/infra/terraform/modules/cloud-run/main.tf @@ -0,0 +1,29 @@ +resource "google_cloud_run_v2_service" "this" { + name = var.service_name + location = var.region + + template { + service_account = var.service_account + + containers { + image = var.image + + dynamic "env" { + for_each = var.env_vars + content { + name = env.key + value = env.value + } + } + } + } +} + +resource "google_cloud_run_service_iam_member" "invoker" { + count = var.allow_unauthenticated ? 1 : 0 + project = google_cloud_run_v2_service.this.project + location = google_cloud_run_v2_service.this.location + service = google_cloud_run_v2_service.this.name + role = "roles/run.invoker" + member = "allUsers" +} diff --git a/infra/terraform/modules/cloud-run/outputs.tf b/infra/terraform/modules/cloud-run/outputs.tf new file mode 100644 index 0000000..97e83f3 --- /dev/null +++ b/infra/terraform/modules/cloud-run/outputs.tf @@ -0,0 +1,3 @@ +output "service_name" { + value = google_cloud_run_v2_service.this.name +} diff --git a/infra/terraform/modules/cloud-run/variables.tf b/infra/terraform/modules/cloud-run/variables.tf new file mode 100644 index 0000000..48c79ee --- /dev/null +++ b/infra/terraform/modules/cloud-run/variables.tf @@ -0,0 +1,25 @@ +variable "service_name" { + type = string +} + +variable "region" { + type = string +} + +variable "service_account" { + type = string +} + +variable "image" { + type = string +} + +variable "allow_unauthenticated" { + type = bool + default = false +} + +variable "env_vars" { + type = map(string) + default = {} +} diff --git a/infra/terraform/modules/firestore/main.tf b/infra/terraform/modules/firestore/main.tf new file mode 100644 index 0000000..c33bdf8 --- /dev/null +++ b/infra/terraform/modules/firestore/main.tf @@ -0,0 +1,7 @@ +resource "google_firestore_database" "this" { + count = var.create_database ? 1 : 0 + project = var.project_id + name = "(default)" + location_id = var.location_id + type = "FIRESTORE_NATIVE" +} diff --git a/infra/terraform/modules/firestore/variables.tf b/infra/terraform/modules/firestore/variables.tf new file mode 100644 index 0000000..d004060 --- /dev/null +++ b/infra/terraform/modules/firestore/variables.tf @@ -0,0 +1,12 @@ +variable "project_id" { + type = string +} + +variable "location_id" { + type = string +} + +variable "create_database" { + type = bool + default = false +} diff --git a/infra/terraform/modules/redis/main.tf b/infra/terraform/modules/redis/main.tf new file mode 100644 index 0000000..86c37de --- /dev/null +++ b/infra/terraform/modules/redis/main.tf @@ -0,0 +1,8 @@ +resource "google_redis_instance" "this" { + name = var.name + memory_size_gb = var.memory_size_gb + region = var.region + tier = var.tier + authorized_network = var.authorized_network + redis_version = "REDIS_7_0" +} diff --git a/infra/terraform/modules/redis/outputs.tf b/infra/terraform/modules/redis/outputs.tf new file mode 100644 index 0000000..6597f54 --- /dev/null +++ b/infra/terraform/modules/redis/outputs.tf @@ -0,0 +1,7 @@ +output "host" { + value = google_redis_instance.this.host +} + +output "port" { + value = google_redis_instance.this.port +} diff --git a/infra/terraform/modules/redis/variables.tf b/infra/terraform/modules/redis/variables.tf new file mode 100644 index 0000000..f7aca03 --- /dev/null +++ b/infra/terraform/modules/redis/variables.tf @@ -0,0 +1,22 @@ +variable "name" { + type = string +} + +variable "memory_size_gb" { + type = number + default = 1 +} + +variable "region" { + type = string +} + +variable "tier" { + type = string + default = "BASIC" +} + +variable "authorized_network" { + type = string + default = null +} diff --git a/infra/terraform/modules/secret-manager/main.tf b/infra/terraform/modules/secret-manager/main.tf new file mode 100644 index 0000000..c5a5a32 --- /dev/null +++ b/infra/terraform/modules/secret-manager/main.tf @@ -0,0 +1,7 @@ +resource "google_secret_manager_secret" "this" { + secret_id = var.secret_id + + replication { + auto {} + } +} diff --git a/infra/terraform/modules/secret-manager/outputs.tf b/infra/terraform/modules/secret-manager/outputs.tf new file mode 100644 index 0000000..8c3f664 --- /dev/null +++ b/infra/terraform/modules/secret-manager/outputs.tf @@ -0,0 +1,3 @@ +output "secret_name" { + value = google_secret_manager_secret.this.secret_id +} diff --git a/infra/terraform/modules/secret-manager/variables.tf b/infra/terraform/modules/secret-manager/variables.tf new file mode 100644 index 0000000..862ae51 --- /dev/null +++ b/infra/terraform/modules/secret-manager/variables.tf @@ -0,0 +1,3 @@ +variable "secret_id" { + type = string +}