feat(WHE-28): add terraform baseline for cloud run and scheduler

This commit is contained in:
2026-03-05 03:36:54 +04:00
parent 18168a8dab
commit d393c08263
19 changed files with 914 additions and 0 deletions

4
infra/terraform/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.terraform
*.tfstate
*.tfstate.*
terraform.tfvars

22
infra/terraform/.terraform.lock.hcl generated Normal file
View File

@@ -0,0 +1,22 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/google" {
version = "6.50.0"
constraints = "~> 6.0"
hashes = [
"h1:79CwMTsp3Ud1nOl5hFS5mxQHyT0fGVye7pqpU0PPlHI=",
"zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c",
"zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5",
"zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb",
"zh:7bb9e41c568df15ccf1a8946037355eefb4dfb4e35e3b190808bb7c4abae547d",
"zh:81e5d78bdec7778e6d67b5c3544777505db40a826b6eb5abe9b86d4ba396866b",
"zh:8d309d020fb321525883f5c4ea864df3d5942b6087f6656d6d8b3a1377f340fc",
"zh:93e112559655ab95a523193158f4a4ac0f2bfed7eeaa712010b85ebb551d5071",
"zh:d3efe589ffd625b300cef5917c4629513f77e3a7b111c9df65075f76a46a63c7",
"zh:d4a4d672bbef756a870d8f32b35925f8ce2ef4f6bbd5b71a3cb764f1b6c85421",
"zh:e13a86bca299ba8a118e80d5f84fbdd708fe600ecdceea1a13d4919c068379fe",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
"zh:fec30c095647b583a246c39d557704947195a1b7d41f81e369ba377d997faef6",
]
}

79
infra/terraform/README.md Normal file
View File

@@ -0,0 +1,79 @@
# Terraform Infrastructure (WHE-28)
This directory contains baseline IaC for deploying the household bot platform on GCP.
## Provisioned resources
- Artifact Registry Docker repository
- Cloud Run service: bot API (public webhook endpoint)
- Cloud Run service: mini app (public web UI)
- Cloud Scheduler job for reminder triggers
- Runtime and scheduler service accounts with least-privilege bindings
- Secret Manager secrets (IDs only, secret values are added separately)
- Optional GitHub OIDC Workload Identity setup for deploy automation
## Architecture (v1)
- `bot-api`: Telegram webhook + app API endpoints
- `mini-app`: front-end delivery
- `scheduler`: triggers `bot-api` internal reminder endpoint using OIDC token
## Prerequisites
- Terraform `>= 1.8`
- Authenticated GCP CLI context (`gcloud auth application-default login` for local)
- Enabled billing on the target GCP project
## Usage
1. Initialize:
```bash
terraform -chdir=infra/terraform init
```
2. Prepare variables:
```bash
cp infra/terraform/terraform.tfvars.example infra/terraform/terraform.tfvars
```
3. Plan:
```bash
terraform -chdir=infra/terraform plan
```
4. Apply:
```bash
terraform -chdir=infra/terraform apply
```
5. Add secret values (after apply):
```bash
echo -n "<value>" | gcloud secrets versions add telegram-webhook-secret --data-file=- --project <project_id>
echo -n "<value>" | gcloud secrets versions add scheduler-shared-secret --data-file=- --project <project_id>
```
## Environments
Recommended approach:
- Keep one state per environment (dev/prod) using separate backend configs or workspaces
- Use `terraform.tfvars` per environment (`dev.tfvars`, `prod.tfvars`)
- Keep `project_id` separate for dev/prod when possible
## CI validation
CI runs:
- `terraform -chdir=infra/terraform fmt -check -recursive`
- `terraform -chdir=infra/terraform init -backend=false`
- `terraform -chdir=infra/terraform validate`
## Notes
- Scheduler job defaults to `paused = true` to prevent accidental sends before app logic is ready.
- Bot API is public to accept Telegram webhooks; scheduler endpoint should still verify app-level auth.

37
infra/terraform/locals.tf Normal file
View File

@@ -0,0 +1,37 @@
locals {
name_prefix = "${var.service_prefix}-${var.environment}"
common_labels = merge(
{
environment = var.environment
managed_by = "terraform"
project = "household-bot"
},
var.labels
)
artifact_location = coalesce(var.artifact_repository_location, var.region)
runtime_secret_ids = toset(compact([
var.telegram_webhook_secret_id,
var.scheduler_shared_secret_id,
var.supabase_url_secret_id,
var.supabase_publishable_key_secret_id
]))
api_services = toset([
"artifactregistry.googleapis.com",
"cloudscheduler.googleapis.com",
"iam.googleapis.com",
"iamcredentials.googleapis.com",
"run.googleapis.com",
"secretmanager.googleapis.com",
"sts.googleapis.com"
])
github_deploy_roles = toset([
"roles/artifactregistry.writer",
"roles/iam.serviceAccountUser",
"roles/run.admin"
])
}

218
infra/terraform/main.tf Normal file
View File

@@ -0,0 +1,218 @@
data "google_project" "current" {
project_id = var.project_id
}
resource "google_project_service" "enabled" {
for_each = local.api_services
project = var.project_id
service = each.value
disable_on_destroy = false
disable_dependent_services = false
}
resource "google_artifact_registry_repository" "containers" {
location = local.artifact_location
project = var.project_id
repository_id = var.artifact_repository_id
description = "Container images for household bot"
format = "DOCKER"
labels = local.common_labels
depends_on = [google_project_service.enabled]
}
resource "google_service_account" "bot_runtime" {
project = var.project_id
account_id = "${var.environment}-bot-runtime"
display_name = "${local.name_prefix} bot runtime"
}
resource "google_service_account" "mini_runtime" {
project = var.project_id
account_id = "${var.environment}-mini-runtime"
display_name = "${local.name_prefix} mini runtime"
}
resource "google_service_account" "scheduler_invoker" {
project = var.project_id
account_id = "${var.environment}-scheduler"
display_name = "${local.name_prefix} scheduler invoker"
}
resource "google_secret_manager_secret" "runtime" {
for_each = local.runtime_secret_ids
project = var.project_id
secret_id = each.value
replication {
auto {}
}
labels = local.common_labels
depends_on = [google_project_service.enabled]
}
resource "google_secret_manager_secret_iam_member" "bot_runtime_access" {
for_each = google_secret_manager_secret.runtime
project = var.project_id
secret_id = each.value.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.bot_runtime.email}"
}
module "bot_api_service" {
source = "./modules/cloud_run_service"
project_id = var.project_id
region = var.region
name = "${local.name_prefix}-bot-api"
service_account_email = google_service_account.bot_runtime.email
image = var.bot_api_image
allow_unauthenticated = true
min_instance_count = var.bot_min_instances
max_instance_count = var.bot_max_instances
labels = local.common_labels
env = {
NODE_ENV = var.environment
}
secret_env = merge(
{
TELEGRAM_WEBHOOK_SECRET = var.telegram_webhook_secret_id
SCHEDULER_SHARED_SECRET = var.scheduler_shared_secret_id
},
var.supabase_url_secret_id == null ? {} : {
SUPABASE_URL = var.supabase_url_secret_id
},
var.supabase_publishable_key_secret_id == null ? {} : {
SUPABASE_PUBLISHABLE_KEY = var.supabase_publishable_key_secret_id
}
)
depends_on = [
google_project_service.enabled,
google_secret_manager_secret.runtime
]
}
module "mini_app_service" {
source = "./modules/cloud_run_service"
project_id = var.project_id
region = var.region
name = "${local.name_prefix}-mini-app"
service_account_email = google_service_account.mini_runtime.email
image = var.mini_app_image
allow_unauthenticated = true
min_instance_count = var.mini_min_instances
max_instance_count = var.mini_max_instances
labels = local.common_labels
env = {
NODE_ENV = var.environment
}
depends_on = [google_project_service.enabled]
}
resource "google_cloud_run_v2_service_iam_member" "scheduler_invoker" {
project = var.project_id
location = var.region
name = module.bot_api_service.name
role = "roles/run.invoker"
member = "serviceAccount:${google_service_account.scheduler_invoker.email}"
}
resource "google_service_account_iam_member" "scheduler_token_creator" {
service_account_id = google_service_account.scheduler_invoker.name
role = "roles/iam.serviceAccountTokenCreator"
member = "serviceAccount:service-${data.google_project.current.number}@gcp-sa-cloudscheduler.iam.gserviceaccount.com"
}
resource "google_cloud_scheduler_job" "reminders" {
project = var.project_id
region = var.region
name = "${local.name_prefix}-reminders"
schedule = var.scheduler_cron
time_zone = var.scheduler_timezone
paused = var.scheduler_paused
http_target {
uri = "${module.bot_api_service.uri}${var.scheduler_path}"
http_method = var.scheduler_http_method
headers = {
"Content-Type" = "application/json"
}
body = base64encode(var.scheduler_body_json)
oidc_token {
service_account_email = google_service_account.scheduler_invoker.email
audience = module.bot_api_service.uri
}
}
depends_on = [
module.bot_api_service,
google_service_account_iam_member.scheduler_token_creator
]
}
resource "google_service_account" "github_deployer" {
count = var.create_workload_identity ? 1 : 0
project = var.project_id
account_id = var.github_deploy_service_account_id
display_name = "${local.name_prefix} GitHub deployer"
}
resource "google_iam_workload_identity_pool" "github" {
count = var.create_workload_identity ? 1 : 0
project = var.project_id
workload_identity_pool_id = var.workload_identity_pool_id
display_name = "GitHub Actions Pool"
}
resource "google_iam_workload_identity_pool_provider" "github" {
count = var.create_workload_identity ? 1 : 0
project = var.project_id
workload_identity_pool_id = google_iam_workload_identity_pool.github[0].workload_identity_pool_id
workload_identity_pool_provider_id = var.workload_identity_provider_id
display_name = "GitHub Actions Provider"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.actor" = "assertion.actor"
"attribute.repository" = "assertion.repository"
}
attribute_condition = "assertion.repository == \"${var.github_repository}\""
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
resource "google_service_account_iam_member" "github_oidc" {
count = var.create_workload_identity ? 1 : 0
service_account_id = google_service_account.github_deployer[0].name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github[0].name}/attribute.repository/${var.github_repository}"
}
resource "google_project_iam_member" "github_deployer_roles" {
for_each = var.create_workload_identity ? local.github_deploy_roles : toset([])
project = var.project_id
role = each.value
member = "serviceAccount:${google_service_account.github_deployer[0].email}"
}

View File

@@ -0,0 +1,67 @@
resource "google_cloud_run_v2_service" "this" {
project = var.project_id
location = var.region
name = var.name
ingress = "INGRESS_TRAFFIC_ALL"
deletion_protection = false
labels = var.labels
template {
service_account = var.service_account_email
scaling {
min_instance_count = var.min_instance_count
max_instance_count = var.max_instance_count
}
containers {
image = var.image
ports {
container_port = var.container_port
}
resources {
limits = var.limits
}
dynamic "env" {
for_each = var.env
content {
name = env.key
value = env.value
}
}
dynamic "env" {
for_each = var.secret_env
content {
name = env.key
value_source {
secret_key_ref {
secret = env.value
version = "latest"
}
}
}
}
}
}
traffic {
percent = 100
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
}
}
resource "google_cloud_run_v2_service_iam_member" "public_invoker" {
count = var.allow_unauthenticated ? 1 : 0
project = var.project_id
location = var.region
name = google_cloud_run_v2_service.this.name
role = "roles/run.invoker"
member = "allUsers"
}

View File

@@ -0,0 +1,7 @@
output "name" {
value = google_cloud_run_v2_service.this.name
}
output "uri" {
value = google_cloud_run_v2_service.this.uri
}

View File

@@ -0,0 +1,63 @@
variable "project_id" {
type = string
}
variable "region" {
type = string
}
variable "name" {
type = string
}
variable "image" {
type = string
}
variable "service_account_email" {
type = string
default = null
}
variable "allow_unauthenticated" {
type = bool
default = false
}
variable "env" {
type = map(string)
default = {}
}
variable "secret_env" {
type = map(string)
default = {}
}
variable "labels" {
type = map(string)
default = {}
}
variable "container_port" {
type = number
default = 8080
}
variable "min_instance_count" {
type = number
default = 0
}
variable "max_instance_count" {
type = number
default = 3
}
variable "limits" {
type = map(string)
default = {
cpu = "1"
memory = "512Mi"
}
}

View File

@@ -0,0 +1,44 @@
output "artifact_repository" {
description = "Artifact Registry repository"
value = google_artifact_registry_repository.containers.id
}
output "bot_api_service_name" {
description = "Cloud Run bot API service name"
value = module.bot_api_service.name
}
output "bot_api_service_url" {
description = "Cloud Run bot API URL"
value = module.bot_api_service.uri
}
output "mini_app_service_name" {
description = "Cloud Run mini app service name"
value = module.mini_app_service.name
}
output "mini_app_service_url" {
description = "Cloud Run mini app URL"
value = module.mini_app_service.uri
}
output "scheduler_job_name" {
description = "Cloud Scheduler job for reminders"
value = google_cloud_scheduler_job.reminders.name
}
output "runtime_secret_ids" {
description = "Secret Manager IDs expected by runtime"
value = sort([for secret in google_secret_manager_secret.runtime : secret.secret_id])
}
output "github_deployer_service_account" {
description = "GitHub OIDC deployer service account email"
value = var.create_workload_identity ? google_service_account.github_deployer[0].email : null
}
output "github_workload_identity_provider" {
description = "Full Workload Identity Provider resource name"
value = var.create_workload_identity ? google_iam_workload_identity_pool_provider.github[0].name : null
}

View File

@@ -0,0 +1,4 @@
provider "google" {
project = var.project_id
region = var.region
}

View File

@@ -0,0 +1,16 @@
project_id = "my-gcp-project"
region = "europe-west1"
environment = "dev"
service_prefix = "household"
artifact_repository_id = "household-bot"
bot_api_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/bot-api:latest"
mini_app_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/mini-app:latest"
scheduler_cron = "0 9 * * *"
scheduler_timezone = "Asia/Tbilisi"
scheduler_paused = true
create_workload_identity = true
github_repository = "whekin/household-bot"

View File

@@ -0,0 +1,167 @@
variable "project_id" {
description = "GCP project ID"
type = string
}
variable "region" {
description = "Primary GCP region for Cloud Run services"
type = string
default = "europe-west1"
}
variable "environment" {
description = "Environment name (e.g. dev, prod)"
type = string
default = "dev"
}
variable "service_prefix" {
description = "Prefix for service names"
type = string
default = "household"
}
variable "artifact_repository_id" {
description = "Artifact Registry repository ID"
type = string
default = "household-bot"
}
variable "artifact_repository_location" {
description = "Artifact Registry location (defaults to region)"
type = string
default = null
nullable = true
}
variable "bot_api_image" {
description = "Container image for bot API service"
type = string
}
variable "mini_app_image" {
description = "Container image for mini app service"
type = string
}
variable "telegram_webhook_secret_id" {
description = "Secret Manager ID for Telegram webhook secret token"
type = string
default = "telegram-webhook-secret"
}
variable "scheduler_shared_secret_id" {
description = "Secret Manager ID for app-level scheduler secret"
type = string
default = "scheduler-shared-secret"
}
variable "supabase_url_secret_id" {
description = "Optional Secret Manager ID for SUPABASE_URL"
type = string
default = null
nullable = true
}
variable "supabase_publishable_key_secret_id" {
description = "Optional Secret Manager ID for SUPABASE_PUBLISHABLE_KEY"
type = string
default = null
nullable = true
}
variable "scheduler_path" {
description = "Reminder endpoint path on bot API"
type = string
default = "/internal/scheduler/reminders"
}
variable "scheduler_http_method" {
description = "Scheduler HTTP method"
type = string
default = "POST"
}
variable "scheduler_cron" {
description = "Cron expression for reminder scheduler"
type = string
default = "0 9 * * *"
}
variable "scheduler_timezone" {
description = "Scheduler timezone"
type = string
default = "Asia/Tbilisi"
}
variable "scheduler_body_json" {
description = "JSON payload for scheduler requests"
type = string
default = "{\"kind\":\"monthly-reminder\"}"
}
variable "scheduler_paused" {
description = "Whether scheduler should be paused initially"
type = bool
default = true
}
variable "bot_min_instances" {
description = "Minimum bot API instances"
type = number
default = 0
}
variable "bot_max_instances" {
description = "Maximum bot API instances"
type = number
default = 3
}
variable "mini_min_instances" {
description = "Minimum mini app instances"
type = number
default = 0
}
variable "mini_max_instances" {
description = "Maximum mini app instances"
type = number
default = 2
}
variable "labels" {
description = "Additional labels"
type = map(string)
default = {}
}
variable "create_workload_identity" {
description = "Create GitHub OIDC Workload Identity resources"
type = bool
default = false
}
variable "github_repository" {
description = "GitHub repository in owner/repo format"
type = string
default = "whekin/household-bot"
}
variable "workload_identity_pool_id" {
description = "Workload Identity Pool ID"
type = string
default = "github-pool"
}
variable "workload_identity_provider_id" {
description = "Workload Identity Provider ID"
type = string
default = "github-provider"
}
variable "github_deploy_service_account_id" {
description = "Service account ID used by GitHub Actions via OIDC"
type = string
default = "github-deployer"
}

View File

@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.8.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}