diff --git a/iac/cloudrun-redirect/.gitignore b/iac/cloudrun-redirect/.gitignore new file mode 100644 index 00000000..1d0d4a4d --- /dev/null +++ b/iac/cloudrun-redirect/.gitignore @@ -0,0 +1,9 @@ +# Local terraform working dir + provider binaries +.terraform/ +# State (may contain secrets) — use remote backend instead +*.tfstate +*.tfstate.* +# Local var overrides +terraform.tfvars +*.auto.tfvars +crash.log diff --git a/iac/cloudrun-redirect/.terraform.lock.hcl b/iac/cloudrun-redirect/.terraform.lock.hcl new file mode 100644 index 00000000..bdd97f13 --- /dev/null +++ b/iac/cloudrun-redirect/.terraform.lock.hcl @@ -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 = ">= 5.0.0, < 7.0.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", + ] +} diff --git a/iac/cloudrun-redirect/README.md b/iac/cloudrun-redirect/README.md new file mode 100644 index 00000000..3c4a7c5e --- /dev/null +++ b/iac/cloudrun-redirect/README.md @@ -0,0 +1,58 @@ +# Cloud Run redirect service + +Terraform for a Cloud Run service that 301-redirects **every** request to a +canonical host (default `https://metermanager.pvacd.com`), preserving path and +query string. Uses the prebuilt public image `schmunk42/nginx-redirect` — no +image to build or maintain. + +This module creates the **service only**. It does not map a custom domain; point +DNS or a Cloud Run domain mapping at the emitted `service_url` yourself. + +## Files + +| File | Purpose | +|------|---------| +| `versions.tf` | Terraform + google provider constraints | +| `variables.tf` | Inputs (project, region, redirect target, scaling) | +| `main.tf` | `google_cloud_run_v2_service` + public invoker IAM | +| `outputs.tf` | Service name + `run.app` URL | +| `terraform.tfvars.example` | Copy to `terraform.tfvars` | + +## Deploy + +```sh +gcloud auth application-default login # or set GOOGLE_APPLICATION_CREDENTIALS +gcloud services enable run.googleapis.com --project YOUR_PROJECT + +cp terraform.tfvars.example terraform.tfvars # edit project_id +terraform init +terraform apply +``` + +## Verify + +```sh +URL=$(terraform output -raw service_url) +curl -sI "$URL/some/path?q=1" +# expect: HTTP/2 301 +# location: https://metermanager.pvacd.com/some/path?q=1 +``` + +## Pointing the old domain at it + +`terraform apply` gives a `*.run.app` URL. To send +`pvacd.newmexicowaterdata.org` through it, add a +[Cloud Run domain mapping](https://cloud.google.com/run/docs/mapping-custom-domains) +(needs the domain verified in the project) or front it with an external HTTPS +load balancer. Domain mapping was intentionally left out of this module — add it +here later if you want it managed in Terraform. + +## Notes + +- Public access: `allow_unauthenticated = true` grants `allUsers` the + `roles/run.invoker` role so browsers can reach the redirect. An org policy + (`iam.allowedPolicyMemberDomains`) may block `allUsers`; if apply fails on the + IAM member, that policy is why. +- Swapping the image: set `image` and pass matching env var names via `env`. +- Pin the image: replace `:latest` in `variables.tf` with a specific tag or + `@sha256:...` digest for reproducible deploys. diff --git a/iac/cloudrun-redirect/main.tf b/iac/cloudrun-redirect/main.tf new file mode 100644 index 00000000..73397282 --- /dev/null +++ b/iac/cloudrun-redirect/main.tf @@ -0,0 +1,52 @@ +locals { + # Env vars for schmunk42/nginx-redirect. Override with var.env for a different image. + default_env = { + SERVER_REDIRECT = var.redirect_host + SERVER_REDIRECT_SCHEME = var.redirect_scheme + SERVER_REDIRECT_CODE = tostring(var.redirect_code) + } + + container_env = length(var.env) > 0 ? var.env : local.default_env +} + +resource "google_cloud_run_v2_service" "redirect" { + name = var.service_name + location = var.region + + # Public HTTP endpoint; browsers hit it directly. + ingress = "INGRESS_TRAFFIC_ALL" + + template { + scaling { + min_instance_count = var.min_instances + max_instance_count = var.max_instances + } + + containers { + image = var.image + + ports { + container_port = var.container_port + } + + dynamic "env" { + for_each = local.container_env + content { + name = env.key + value = env.value + } + } + } + } +} + +# Allow public (unauthenticated) access so the redirect is reachable. +resource "google_cloud_run_v2_service_iam_member" "public" { + count = var.allow_unauthenticated ? 1 : 0 + + project = google_cloud_run_v2_service.redirect.project + location = google_cloud_run_v2_service.redirect.location + name = google_cloud_run_v2_service.redirect.name + role = "roles/run.invoker" + member = "allUsers" +} diff --git a/iac/cloudrun-redirect/outputs.tf b/iac/cloudrun-redirect/outputs.tf new file mode 100644 index 00000000..2ea11e17 --- /dev/null +++ b/iac/cloudrun-redirect/outputs.tf @@ -0,0 +1,9 @@ +output "service_name" { + description = "Cloud Run service name." + value = google_cloud_run_v2_service.redirect.name +} + +output "service_url" { + description = "Auto-assigned run.app URL of the redirect service. Point DNS (CNAME / domain mapping) at this, or verify redirect here directly." + value = google_cloud_run_v2_service.redirect.uri +} diff --git a/iac/cloudrun-redirect/terraform.tfvars.example b/iac/cloudrun-redirect/terraform.tfvars.example new file mode 100644 index 00000000..3579591f --- /dev/null +++ b/iac/cloudrun-redirect/terraform.tfvars.example @@ -0,0 +1,8 @@ +# Copy to terraform.tfvars and fill in. +project_id = "your-gcp-project-id" +region = "us-central1" + +# Everything hitting this service 301s here, path + query preserved. +redirect_host = "metermanager.pvacd.com" +redirect_scheme = "https" +redirect_code = 301 diff --git a/iac/cloudrun-redirect/variables.tf b/iac/cloudrun-redirect/variables.tf new file mode 100644 index 00000000..25dab4e7 --- /dev/null +++ b/iac/cloudrun-redirect/variables.tf @@ -0,0 +1,79 @@ +variable "project_id" { + type = string + description = "GCP project ID to deploy the redirect service into." +} + +variable "region" { + type = string + description = "Cloud Run region." + default = "us-central1" +} + +variable "service_name" { + type = string + description = "Name of the Cloud Run service." + default = "metermanager-redirect" +} + +variable "image" { + type = string + description = <<-EOT + Prebuilt redirect container image. Cloud Run supports images from + Artifact Registry, Container Registry, and public Docker Hub. + Default is schmunk42/nginx-redirect, which 301s $scheme://$host$request_uri + (path + query preserved) and listens on port 80. + EOT + default = "docker.io/schmunk42/nginx-redirect:latest" +} + +variable "container_port" { + type = number + description = "Port the redirect image listens on (schmunk42/nginx-redirect uses 80)." + default = 80 +} + +variable "redirect_host" { + type = string + description = "Canonical host to redirect every request to." + default = "metermanager.pvacd.com" +} + +variable "redirect_scheme" { + type = string + description = "Scheme of the redirect target." + default = "https" +} + +variable "redirect_code" { + type = number + description = "HTTP redirect status code (301 permanent / 302 temporary)." + default = 301 +} + +variable "env" { + type = map(string) + description = <<-EOT + Optional override of container env vars. When empty, env vars are derived + from redirect_host/redirect_scheme/redirect_code for schmunk42/nginx-redirect. + Set this if you swap `image` for one expecting different env var names. + EOT + default = {} +} + +variable "allow_unauthenticated" { + type = bool + description = "Grant allUsers roles/run.invoker so browsers can reach the redirect. Required for a public redirect." + default = true +} + +variable "min_instances" { + type = number + description = "Minimum Cloud Run instances (0 = scale to zero)." + default = 0 +} + +variable "max_instances" { + type = number + description = "Maximum Cloud Run instances." + default = 2 +} diff --git a/iac/cloudrun-redirect/versions.tf b/iac/cloudrun-redirect/versions.tf new file mode 100644 index 00000000..ea0cb2aa --- /dev/null +++ b/iac/cloudrun-redirect/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + google = { + source = "hashicorp/google" + version = ">= 5.0, < 7.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +}