From 195342dd22529aa4384c71241da8e6b7944d3896 Mon Sep 17 00:00:00 2001 From: Metal Date: Tue, 6 Oct 2026 17:40:20 +0500 Subject: [PATCH 1/5] feat(terraform/gcp): make the Cloud SQL read replica optional The Google module always created a replica and always set DATABASE_URL_READ_REPLICA. Helm and the proxy docs leave that env unset by default so reads stay on the writer. Add create_read_replica (default true) so existing stacks stay unchanged and a single-instance deploy can skip the replica. Co-authored-by: Cursor --- terraform/litellm/gcp/README.md | 15 ++++---- terraform/litellm/gcp/cloudrun.tf | 20 ++++++++--- terraform/litellm/gcp/cloudsql.tf | 7 ++-- .../litellm/gcp/examples/default/main.tf | 1 + .../litellm/gcp/examples/default/outputs.tf | 2 +- .../examples/default/terraform.tfvars.example | 1 + .../litellm/gcp/examples/default/variables.tf | 6 ++++ terraform/litellm/gcp/outputs.tf | 4 +-- .../litellm/gcp/tests/deps_only.tftest.hcl | 35 ++++++++++++++++++- terraform/litellm/gcp/variables.tf | 13 +++++++ 10 files changed, 87 insertions(+), 17 deletions(-) diff --git a/terraform/litellm/gcp/README.md b/terraform/litellm/gcp/README.md index 4b2f576adc1..03dda8c6db7 100644 --- a/terraform/litellm/gcp/README.md +++ b/terraform/litellm/gcp/README.md @@ -8,8 +8,10 @@ Deploys the componentized LiteLLM proxy on GCP: - **VPC** + Private Services Access range + a Serverless VPC Access connector so Cloud Run can reach private IPs -- **Cloud SQL for PostgreSQL** — primary instance + cross-zone read replica, - password auth via Secret Manager +- **Cloud SQL for PostgreSQL** — primary instance, plus a cross-zone read + replica when `create_read_replica = true` (the default). Set false for a + writer-only stack; LiteLLM then uses the primary for every query. + Password auth via Secret Manager - **Memorystore (Redis)** for caching + rate limiting, private IP only - **GCS bucket** — private, versioned, uniform IAM; exposed as `GCS_BUCKET_NAME` - **Secret Manager** entries for `LITELLM_MASTER_KEY` and `DATABASE_PASSWORD` @@ -95,10 +97,11 @@ spec. This stack therefore uses **password authentication**: (`-db-password`). - Each Cloud Run service receives the password as `DATABASE_PASSWORD` via `value_source.secret_key_ref`. -- The container's entrypoint shim assembles `DATABASE_URL` (and - `DATABASE_URL_READ_REPLICA`) from `DATABASE_HOST` / `DATABASE_PASSWORD` - before exec'ing uvicorn — so the password never appears in the service - spec or in logs. +- The container's entrypoint shim assembles `DATABASE_URL` from + `DATABASE_HOST` / `DATABASE_PASSWORD` before exec'ing uvicorn — so the + password never appears in the service spec or in logs. When + `create_read_replica = true` (the default) it also exports + `DATABASE_URL_READ_REPLICA` from the replica IP. If you need GCP-native IAM auth later, add `cloud-sql-proxy` as a sidecar container under `template.template.containers` (Cloud Run v2 supports diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index d0b32a367d6..4bcff8779ff 100644 --- a/terraform/litellm/gcp/cloudrun.tf +++ b/terraform/litellm/gcp/cloudrun.tf @@ -14,8 +14,12 @@ locals { { name = "DATABASE_PORT", value = "5432" }, { name = "DATABASE_USER", value = var.db_username }, { name = "DATABASE_NAME", value = var.db_name }, - { name = "DATABASE_HOST_READ_REPLICA", value = google_sql_database_instance.reader.private_ip_address }, + ], + var.create_read_replica ? [ + { name = "DATABASE_HOST_READ_REPLICA", value = google_sql_database_instance.reader[0].private_ip_address }, { name = "DATABASE_PORT_READ_REPLICA", value = "5432" }, + ] : [], + [ { name = "REDIS_HOST", value = google_redis_instance.this.host }, { name = "REDIS_PORT", value = tostring(google_redis_instance.this.port) }, ], @@ -133,10 +137,16 @@ locals { "python -c \"import os, base64, pathlib; pathlib.Path(os.environ['REDIS_SSL_CA_CERTS']).write_bytes(base64.b64decode(os.environ['REDIS_CA_PEM_B64']))\"" ] : [] - database_url_fragment = [ - "export DATABASE_URL=\"postgresql://$${DATABASE_USER}:$${DATABASE_PASSWORD}@$${DATABASE_HOST}:$${DATABASE_PORT}/$${DATABASE_NAME}\"", - "export DATABASE_URL_READ_REPLICA=\"postgresql://$${DATABASE_USER}:$${DATABASE_PASSWORD}@$${DATABASE_HOST_READ_REPLICA}:$${DATABASE_PORT_READ_REPLICA}/$${DATABASE_NAME}\"", - ] + # DATABASE_URL_READ_REPLICA is opt-in. Unset, LiteLLM keeps a single Prisma + # client on the writer (docs: https://docs.litellm.ai/docs/proxy/db_read_replica). + database_url_fragment = concat( + [ + "export DATABASE_URL=\"postgresql://$${DATABASE_USER}:$${DATABASE_PASSWORD}@$${DATABASE_HOST}:$${DATABASE_PORT}/$${DATABASE_NAME}\"", + ], + var.create_read_replica ? [ + "export DATABASE_URL_READ_REPLICA=\"postgresql://$${DATABASE_USER}:$${DATABASE_PASSWORD}@$${DATABASE_HOST_READ_REPLICA}:$${DATABASE_PORT_READ_REPLICA}/$${DATABASE_NAME}\"", + ] : [], + ) gateway_pool_env = var.gateway_connection_pool_enabled ? [ { name = "LITELLM_PGBOUNCER_ENABLED", value = "true" }, diff --git a/terraform/litellm/gcp/cloudsql.tf b/terraform/litellm/gcp/cloudsql.tf index 777434b4727..c490f5e6130 100644 --- a/terraform/litellm/gcp/cloudsql.tf +++ b/terraform/litellm/gcp/cloudsql.tf @@ -1,4 +1,5 @@ -# Cloud SQL for PostgreSQL — one primary + one read replica. +# Cloud SQL for PostgreSQL — one primary, plus an optional read replica +# (var.create_read_replica, default true). # # Note on auth: LiteLLM's IAM-auth helper (rds_iam_token.py) mints AWS RDS # tokens via boto3 and doesn't speak GCP IAM. Cloud SQL IAM auth from Cloud @@ -6,7 +7,8 @@ # Cloud Run service spec. We instead use password auth: a random password # lives in Secret Manager and is injected into the Cloud Run services as # DATABASE_PASSWORD. The writer's DATABASE_URL is assembled inside the -# container at startup; the reader URL is built from the replica's IP. +# container at startup; the reader URL is built from the replica's IP +# when create_read_replica is true. resource "google_sql_database_instance" "writer" { name = local.name @@ -64,6 +66,7 @@ resource "google_sql_database_instance" "writer" { } resource "google_sql_database_instance" "reader" { + count = var.create_read_replica ? 1 : 0 name = "${local.name}-reader" region = var.region database_version = var.db_version diff --git a/terraform/litellm/gcp/examples/default/main.tf b/terraform/litellm/gcp/examples/default/main.tf index 782d4ee4b65..169d306c951 100644 --- a/terraform/litellm/gcp/examples/default/main.tf +++ b/terraform/litellm/gcp/examples/default/main.tf @@ -32,6 +32,7 @@ module "litellm" { env = var.env create_runtime = var.create_runtime + create_read_replica = var.create_read_replica network_id = var.network_id create_psa_connection = var.create_psa_connection redis_transit_encryption = var.redis_transit_encryption diff --git a/terraform/litellm/gcp/examples/default/outputs.tf b/terraform/litellm/gcp/examples/default/outputs.tf index 48cdc1af66e..4ef5760a08c 100644 --- a/terraform/litellm/gcp/examples/default/outputs.tf +++ b/terraform/litellm/gcp/examples/default/outputs.tf @@ -29,7 +29,7 @@ output "cloudsql_writer_ip" { } output "cloudsql_reader_ip" { - description = "Private IP of the Cloud SQL read replica." + description = "Private IP of the Cloud SQL read replica. Null when create_read_replica is false." value = module.litellm.cloudsql_reader_ip } diff --git a/terraform/litellm/gcp/examples/default/terraform.tfvars.example b/terraform/litellm/gcp/examples/default/terraform.tfvars.example index ec6206734e2..d5a93dac3d9 100644 --- a/terraform/litellm/gcp/examples/default/terraform.tfvars.example +++ b/terraform/litellm/gcp/examples/default/terraform.tfvars.example @@ -12,6 +12,7 @@ env = "stage" # create_runtime = false, network_id to the full host-project network ID, and # create_psa_connection = false after configuring PSA on that network. # create_runtime = true +# create_read_replica = true # network_id = "" # create_psa_connection = true # redis_transit_encryption = true diff --git a/terraform/litellm/gcp/examples/default/variables.tf b/terraform/litellm/gcp/examples/default/variables.tf index 08b78346df3..89f1ad4c46e 100644 --- a/terraform/litellm/gcp/examples/default/variables.tf +++ b/terraform/litellm/gcp/examples/default/variables.tf @@ -32,6 +32,12 @@ variable "create_runtime" { default = true } +variable "create_read_replica" { + description = "Create the Cloud SQL read replica and set DATABASE_URL_READ_REPLICA. False → writer-only." + type = bool + default = true +} + variable "network_id" { description = "Existing VPC network resource ID. Empty creates a VPC." type = string diff --git a/terraform/litellm/gcp/outputs.tf b/terraform/litellm/gcp/outputs.tf index 2a4742f42cf..9e820d6e9d5 100644 --- a/terraform/litellm/gcp/outputs.tf +++ b/terraform/litellm/gcp/outputs.tf @@ -29,8 +29,8 @@ output "cloudsql_writer_ip" { } output "cloudsql_reader_ip" { - description = "Private IP of the Cloud SQL read replica." - value = google_sql_database_instance.reader.private_ip_address + description = "Private IP of the Cloud SQL read replica. Null when create_read_replica is false." + value = one(google_sql_database_instance.reader[*].private_ip_address) } output "redis_endpoint" { diff --git a/terraform/litellm/gcp/tests/deps_only.tftest.hcl b/terraform/litellm/gcp/tests/deps_only.tftest.hcl index 610c49d5b52..ab8a111c379 100644 --- a/terraform/litellm/gcp/tests/deps_only.tftest.hcl +++ b/terraform/litellm/gcp/tests/deps_only.tftest.hcl @@ -96,7 +96,7 @@ run "deps_only_creates_no_runtime" { assert { condition = alltrue([ google_sql_database_instance.writer.name == "tenant-litellm-test", - google_sql_database_instance.reader.name == "tenant-litellm-test-reader", + google_sql_database_instance.reader[0].name == "tenant-litellm-test-reader", google_redis_instance.this.name == "tenant-litellm-test", google_storage_bucket.this.force_destroy == false, google_secret_manager_secret.master_key.secret_id == "tenant-litellm-test-master-key", @@ -173,3 +173,36 @@ run "redis_plaintext_drops_tls_env" { error_message = "Plaintext Redis mode must not decode a Redis CA at startup." } } + +run "skip_read_replica" { + command = plan + + variables { + create_read_replica = false + } + + assert { + condition = length(google_sql_database_instance.reader) == 0 + error_message = "create_read_replica = false must omit the Cloud SQL replica." + } + + assert { + condition = output.cloudsql_reader_ip == null + error_message = "cloudsql_reader_ip must be null when the replica is not created." + } + + assert { + condition = length(local.shared_env_kv) == 10 + error_message = "Without a replica the runtime env must drop DATABASE_HOST/PORT_READ_REPLICA (12 → 10)." + } + + assert { + condition = length([for env in local.shared_env_kv : env if env.name == "DATABASE_HOST_READ_REPLICA"]) == 0 + error_message = "DATABASE_HOST_READ_REPLICA must not be set when the replica is omitted." + } + + assert { + condition = length(local.database_url_fragment) == 1 + error_message = "Without a replica the startup script must export only DATABASE_URL." + } +} diff --git a/terraform/litellm/gcp/variables.tf b/terraform/litellm/gcp/variables.tf index 412f919ab89..d1343810af4 100644 --- a/terraform/litellm/gcp/variables.tf +++ b/terraform/litellm/gcp/variables.tf @@ -330,6 +330,19 @@ variable "ui_max_instance_request_concurrency" { # ---------- Cloud SQL ---------- +variable "create_read_replica" { + description = <<-EOT + Create a Cloud SQL read replica and set DATABASE_URL_READ_REPLICA so the + proxy routes reads (find_*, count, group_by, query_raw) to it. Default + true, matching today's always-on replica. Set false for a single-instance + stack: LiteLLM then uses the writer for every query (same as leaving + DATABASE_URL_READ_REPLICA unset on Helm / docker). Dev and cost-sensitive + environments usually want false. + EOT + type = bool + default = true +} + variable "db_tier" { description = "Cloud SQL tier (machine type) for the writer instance." type = string From c59a1fcfd6b07b9cb47265ed75bdc5b4c3681eca Mon Sep 17 00:00:00 2001 From: Metal Date: Tue, 6 Oct 2026 17:44:28 +0500 Subject: [PATCH 2/5] chore(terraform/gcp): shorten create_read_replica comments Co-authored-by: Cursor --- terraform/litellm/gcp/README.md | 13 +++++-------- terraform/litellm/gcp/cloudrun.tf | 2 -- terraform/litellm/gcp/cloudsql.tf | 6 +++--- terraform/litellm/gcp/examples/default/outputs.tf | 2 +- terraform/litellm/gcp/examples/default/variables.tf | 2 +- terraform/litellm/gcp/outputs.tf | 2 +- terraform/litellm/gcp/tests/deps_only.tftest.hcl | 10 +++++----- terraform/litellm/gcp/variables.tf | 9 +-------- 8 files changed, 17 insertions(+), 29 deletions(-) diff --git a/terraform/litellm/gcp/README.md b/terraform/litellm/gcp/README.md index 03dda8c6db7..5b21379e34b 100644 --- a/terraform/litellm/gcp/README.md +++ b/terraform/litellm/gcp/README.md @@ -8,10 +8,8 @@ Deploys the componentized LiteLLM proxy on GCP: - **VPC** + Private Services Access range + a Serverless VPC Access connector so Cloud Run can reach private IPs -- **Cloud SQL for PostgreSQL** — primary instance, plus a cross-zone read - replica when `create_read_replica = true` (the default). Set false for a - writer-only stack; LiteLLM then uses the primary for every query. - Password auth via Secret Manager +- **Cloud SQL for PostgreSQL** — primary instance + optional read replica + (`create_read_replica`, default true). Password auth via Secret Manager - **Memorystore (Redis)** for caching + rate limiting, private IP only - **GCS bucket** — private, versioned, uniform IAM; exposed as `GCS_BUCKET_NAME` - **Secret Manager** entries for `LITELLM_MASTER_KEY` and `DATABASE_PASSWORD` @@ -97,11 +95,10 @@ spec. This stack therefore uses **password authentication**: (`-db-password`). - Each Cloud Run service receives the password as `DATABASE_PASSWORD` via `value_source.secret_key_ref`. -- The container's entrypoint shim assembles `DATABASE_URL` from +- The container's entrypoint shim assembles `DATABASE_URL` (and + `DATABASE_URL_READ_REPLICA` when a replica is created) from `DATABASE_HOST` / `DATABASE_PASSWORD` before exec'ing uvicorn — so the - password never appears in the service spec or in logs. When - `create_read_replica = true` (the default) it also exports - `DATABASE_URL_READ_REPLICA` from the replica IP. + password never appears in the service spec or in logs. If you need GCP-native IAM auth later, add `cloud-sql-proxy` as a sidecar container under `template.template.containers` (Cloud Run v2 supports diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index 4bcff8779ff..34d819e9b8f 100644 --- a/terraform/litellm/gcp/cloudrun.tf +++ b/terraform/litellm/gcp/cloudrun.tf @@ -137,8 +137,6 @@ locals { "python -c \"import os, base64, pathlib; pathlib.Path(os.environ['REDIS_SSL_CA_CERTS']).write_bytes(base64.b64decode(os.environ['REDIS_CA_PEM_B64']))\"" ] : [] - # DATABASE_URL_READ_REPLICA is opt-in. Unset, LiteLLM keeps a single Prisma - # client on the writer (docs: https://docs.litellm.ai/docs/proxy/db_read_replica). database_url_fragment = concat( [ "export DATABASE_URL=\"postgresql://$${DATABASE_USER}:$${DATABASE_PASSWORD}@$${DATABASE_HOST}:$${DATABASE_PORT}/$${DATABASE_NAME}\"", diff --git a/terraform/litellm/gcp/cloudsql.tf b/terraform/litellm/gcp/cloudsql.tf index c490f5e6130..1b7be06af8a 100644 --- a/terraform/litellm/gcp/cloudsql.tf +++ b/terraform/litellm/gcp/cloudsql.tf @@ -1,5 +1,5 @@ -# Cloud SQL for PostgreSQL — one primary, plus an optional read replica -# (var.create_read_replica, default true). +# Cloud SQL for PostgreSQL — one primary + optional read replica +# (create_read_replica, default true). # # Note on auth: LiteLLM's IAM-auth helper (rds_iam_token.py) mints AWS RDS # tokens via boto3 and doesn't speak GCP IAM. Cloud SQL IAM auth from Cloud @@ -8,7 +8,7 @@ # lives in Secret Manager and is injected into the Cloud Run services as # DATABASE_PASSWORD. The writer's DATABASE_URL is assembled inside the # container at startup; the reader URL is built from the replica's IP -# when create_read_replica is true. +# when a replica exists. resource "google_sql_database_instance" "writer" { name = local.name diff --git a/terraform/litellm/gcp/examples/default/outputs.tf b/terraform/litellm/gcp/examples/default/outputs.tf index 4ef5760a08c..347b8c9280c 100644 --- a/terraform/litellm/gcp/examples/default/outputs.tf +++ b/terraform/litellm/gcp/examples/default/outputs.tf @@ -29,7 +29,7 @@ output "cloudsql_writer_ip" { } output "cloudsql_reader_ip" { - description = "Private IP of the Cloud SQL read replica. Null when create_read_replica is false." + description = "Private IP of the Cloud SQL read replica, or null if none." value = module.litellm.cloudsql_reader_ip } diff --git a/terraform/litellm/gcp/examples/default/variables.tf b/terraform/litellm/gcp/examples/default/variables.tf index 89f1ad4c46e..f4b546351e6 100644 --- a/terraform/litellm/gcp/examples/default/variables.tf +++ b/terraform/litellm/gcp/examples/default/variables.tf @@ -33,7 +33,7 @@ variable "create_runtime" { } variable "create_read_replica" { - description = "Create the Cloud SQL read replica and set DATABASE_URL_READ_REPLICA. False → writer-only." + description = "Create the Cloud SQL read replica. False = writer only." type = bool default = true } diff --git a/terraform/litellm/gcp/outputs.tf b/terraform/litellm/gcp/outputs.tf index 9e820d6e9d5..d16d04d7ff8 100644 --- a/terraform/litellm/gcp/outputs.tf +++ b/terraform/litellm/gcp/outputs.tf @@ -29,7 +29,7 @@ output "cloudsql_writer_ip" { } output "cloudsql_reader_ip" { - description = "Private IP of the Cloud SQL read replica. Null when create_read_replica is false." + description = "Private IP of the Cloud SQL read replica, or null if none." value = one(google_sql_database_instance.reader[*].private_ip_address) } diff --git a/terraform/litellm/gcp/tests/deps_only.tftest.hcl b/terraform/litellm/gcp/tests/deps_only.tftest.hcl index ab8a111c379..42538f801f7 100644 --- a/terraform/litellm/gcp/tests/deps_only.tftest.hcl +++ b/terraform/litellm/gcp/tests/deps_only.tftest.hcl @@ -183,26 +183,26 @@ run "skip_read_replica" { assert { condition = length(google_sql_database_instance.reader) == 0 - error_message = "create_read_replica = false must omit the Cloud SQL replica." + error_message = "create_read_replica = false must omit the replica instance." } assert { condition = output.cloudsql_reader_ip == null - error_message = "cloudsql_reader_ip must be null when the replica is not created." + error_message = "cloudsql_reader_ip must be null without a replica." } assert { condition = length(local.shared_env_kv) == 10 - error_message = "Without a replica the runtime env must drop DATABASE_HOST/PORT_READ_REPLICA (12 → 10)." + error_message = "Without a replica, shared env should be 10 (no DATABASE_*_READ_REPLICA)." } assert { condition = length([for env in local.shared_env_kv : env if env.name == "DATABASE_HOST_READ_REPLICA"]) == 0 - error_message = "DATABASE_HOST_READ_REPLICA must not be set when the replica is omitted." + error_message = "DATABASE_HOST_READ_REPLICA must be unset without a replica." } assert { condition = length(local.database_url_fragment) == 1 - error_message = "Without a replica the startup script must export only DATABASE_URL." + error_message = "Without a replica, only DATABASE_URL should be exported." } } diff --git a/terraform/litellm/gcp/variables.tf b/terraform/litellm/gcp/variables.tf index d1343810af4..5aa79dfd58a 100644 --- a/terraform/litellm/gcp/variables.tf +++ b/terraform/litellm/gcp/variables.tf @@ -331,14 +331,7 @@ variable "ui_max_instance_request_concurrency" { # ---------- Cloud SQL ---------- variable "create_read_replica" { - description = <<-EOT - Create a Cloud SQL read replica and set DATABASE_URL_READ_REPLICA so the - proxy routes reads (find_*, count, group_by, query_raw) to it. Default - true, matching today's always-on replica. Set false for a single-instance - stack: LiteLLM then uses the writer for every query (same as leaving - DATABASE_URL_READ_REPLICA unset on Helm / docker). Dev and cost-sensitive - environments usually want false. - EOT + description = "Create a Cloud SQL read replica and set DATABASE_URL_READ_REPLICA. Default true. Set false to run writer-only." type = bool default = true } From 7e742d9e4ad991873e2d3269550f159a07a3e539 Mon Sep 17 00:00:00 2001 From: Metal Date: Tue, 6 Oct 2026 17:57:24 +0500 Subject: [PATCH 3/5] docs(terraform): cover turning the GCP read replica off on an existing stack Co-authored-by: Cursor --- terraform/litellm/gcp/README.md | 13 +++++++++++-- .../litellm/gcp/examples/default/deploystack.json | 2 +- terraform/litellm/gcp/outputs.tf | 2 +- terraform/litellm/gcp/tests/deps_only.tftest.hcl | 3 ++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/terraform/litellm/gcp/README.md b/terraform/litellm/gcp/README.md index 5b21379e34b..f95b886e5ca 100644 --- a/terraform/litellm/gcp/README.md +++ b/terraform/litellm/gcp/README.md @@ -8,8 +8,8 @@ Deploys the componentized LiteLLM proxy on GCP: - **VPC** + Private Services Access range + a Serverless VPC Access connector so Cloud Run can reach private IPs -- **Cloud SQL for PostgreSQL** — primary instance + optional read replica - (`create_read_replica`, default true). Password auth via Secret Manager +- **Cloud SQL for PostgreSQL** — primary instance + optional cross-zone read + replica (`create_read_replica`, default true). Password auth via Secret Manager - **Memorystore (Redis)** for caching + rate limiting, private IP only - **GCS bucket** — private, versioned, uniform IAM; exposed as `GCS_BUCKET_NAME` - **Secret Manager** entries for `LITELLM_MASTER_KEY` and `DATABASE_PASSWORD` @@ -559,6 +559,9 @@ masterKey: secretName: ``` +With `create_read_replica = false`, leave out the `reader` block, since +`cloudsql_reader_ip` is null + Create the database Secret with keys `username` (the `db_username` output) and `password` (read it with `gcloud secrets versions access latest --secret=`), and the master key Secret from @@ -587,6 +590,12 @@ Two opt-in tripwires guard against accidental data loss on Flip `cloudsql_deletion_protection` to `false` or `gcs_force_destroy` to `true` only for ephemeral / CI stacks where you accept losing the data. +Setting `create_read_replica = false` on an existing stack destroys the +replica, so that apply also needs `cloudsql_deletion_protection = false`; +set it back to `true` afterwards. Terraform deletes the replica before it +rolls the Cloud Run revisions that drop `DATABASE_URL_READ_REPLICA`, so +reads routed to the replica can fail until the new revisions are serving + ## Redis encryption By default, Memorystore runs with diff --git a/terraform/litellm/gcp/examples/default/deploystack.json b/terraform/litellm/gcp/examples/default/deploystack.json index 47d1fd914ce..c56b5282214 100644 --- a/terraform/litellm/gcp/examples/default/deploystack.json +++ b/terraform/litellm/gcp/examples/default/deploystack.json @@ -1,7 +1,7 @@ { "title": "LiteLLM on GCP (Cloud Run)", "name": "litellm-gcp", - "description": "Deploys the LiteLLM proxy on GCP: Cloud Run gateway/backend/UI, Cloud SQL with a read replica, Memorystore Redis, a GCS bucket, Secret Manager entries, and an external HTTPS load balancer. Takes ~20-25 minutes on the first apply.", + "description": "Deploys the LiteLLM proxy on GCP: Cloud Run gateway/backend/UI, Cloud SQL with an optional read replica, Memorystore Redis, a GCS bucket, Secret Manager entries, and an external HTTPS load balancer. Takes ~20-25 minutes on the first apply.", "duration": 25, "documentation_link": "https://github.com/BerriAI/litellm/blob/main/terraform/litellm/gcp/README.md", "collect_project": true, diff --git a/terraform/litellm/gcp/outputs.tf b/terraform/litellm/gcp/outputs.tf index d16d04d7ff8..fefa7aefcff 100644 --- a/terraform/litellm/gcp/outputs.tf +++ b/terraform/litellm/gcp/outputs.tf @@ -29,7 +29,7 @@ output "cloudsql_writer_ip" { } output "cloudsql_reader_ip" { - description = "Private IP of the Cloud SQL read replica, or null if none." + description = "Private IP of the Cloud SQL read replica, or null when create_read_replica is false." value = one(google_sql_database_instance.reader[*].private_ip_address) } diff --git a/terraform/litellm/gcp/tests/deps_only.tftest.hcl b/terraform/litellm/gcp/tests/deps_only.tftest.hcl index 42538f801f7..d021eccf3bf 100644 --- a/terraform/litellm/gcp/tests/deps_only.tftest.hcl +++ b/terraform/litellm/gcp/tests/deps_only.tftest.hcl @@ -37,6 +37,7 @@ run "default_creates_everything" { length(google_cloud_run_v2_job.migrations) == 1, length(google_compute_global_address.lb) == 1, length(terraform_data.migration) == 1, + length(google_sql_database_instance.reader) == 1, ]) error_message = "The default mode must create networking, runtime services, the load balancer, and migrations." } @@ -193,7 +194,7 @@ run "skip_read_replica" { assert { condition = length(local.shared_env_kv) == 10 - error_message = "Without a replica, shared env should be 10 (no DATABASE_*_READ_REPLICA)." + error_message = "Writer-only mode must omit both DATABASE_*_READ_REPLICA entries." } assert { From ae4dad48df5e7cca2add1ca9ab7201d9762af9d7 Mon Sep 17 00:00:00 2001 From: Metal Date: Tue, 6 Oct 2026 18:03:31 +0500 Subject: [PATCH 4/5] test(terraform): assert GCP replica env by name and trim replica comments Co-authored-by: Cursor --- terraform/litellm/gcp/README.md | 6 +++--- terraform/litellm/gcp/cloudsql.tf | 6 ++---- .../litellm/gcp/examples/default/deploystack.json | 2 +- terraform/litellm/gcp/tests/deps_only.tftest.hcl | 11 +++-------- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/terraform/litellm/gcp/README.md b/terraform/litellm/gcp/README.md index f95b886e5ca..984f99986f5 100644 --- a/terraform/litellm/gcp/README.md +++ b/terraform/litellm/gcp/README.md @@ -592,9 +592,9 @@ Flip `cloudsql_deletion_protection` to `false` or `gcs_force_destroy` to Setting `create_read_replica = false` on an existing stack destroys the replica, so that apply also needs `cloudsql_deletion_protection = false`; -set it back to `true` afterwards. Terraform deletes the replica before it -rolls the Cloud Run revisions that drop `DATABASE_URL_READ_REPLICA`, so -reads routed to the replica can fail until the new revisions are serving +set it back to `true` afterwards. The replica can be gone before the Cloud +Run revisions that drop `DATABASE_URL_READ_REPLICA` are serving, so reads +routed to the replica can fail during that apply ## Redis encryption diff --git a/terraform/litellm/gcp/cloudsql.tf b/terraform/litellm/gcp/cloudsql.tf index 1b7be06af8a..e535df2b9c4 100644 --- a/terraform/litellm/gcp/cloudsql.tf +++ b/terraform/litellm/gcp/cloudsql.tf @@ -1,5 +1,4 @@ -# Cloud SQL for PostgreSQL — one primary + optional read replica -# (create_read_replica, default true). +# Cloud SQL for PostgreSQL — one primary + an optional read replica. # # Note on auth: LiteLLM's IAM-auth helper (rds_iam_token.py) mints AWS RDS # tokens via boto3 and doesn't speak GCP IAM. Cloud SQL IAM auth from Cloud @@ -7,8 +6,7 @@ # Cloud Run service spec. We instead use password auth: a random password # lives in Secret Manager and is injected into the Cloud Run services as # DATABASE_PASSWORD. The writer's DATABASE_URL is assembled inside the -# container at startup; the reader URL is built from the replica's IP -# when a replica exists. +# container at startup; the reader URL is built from the replica's IP. resource "google_sql_database_instance" "writer" { name = local.name diff --git a/terraform/litellm/gcp/examples/default/deploystack.json b/terraform/litellm/gcp/examples/default/deploystack.json index c56b5282214..47d1fd914ce 100644 --- a/terraform/litellm/gcp/examples/default/deploystack.json +++ b/terraform/litellm/gcp/examples/default/deploystack.json @@ -1,7 +1,7 @@ { "title": "LiteLLM on GCP (Cloud Run)", "name": "litellm-gcp", - "description": "Deploys the LiteLLM proxy on GCP: Cloud Run gateway/backend/UI, Cloud SQL with an optional read replica, Memorystore Redis, a GCS bucket, Secret Manager entries, and an external HTTPS load balancer. Takes ~20-25 minutes on the first apply.", + "description": "Deploys the LiteLLM proxy on GCP: Cloud Run gateway/backend/UI, Cloud SQL with a read replica, Memorystore Redis, a GCS bucket, Secret Manager entries, and an external HTTPS load balancer. Takes ~20-25 minutes on the first apply.", "duration": 25, "documentation_link": "https://github.com/BerriAI/litellm/blob/main/terraform/litellm/gcp/README.md", "collect_project": true, diff --git a/terraform/litellm/gcp/tests/deps_only.tftest.hcl b/terraform/litellm/gcp/tests/deps_only.tftest.hcl index d021eccf3bf..93f62a3296d 100644 --- a/terraform/litellm/gcp/tests/deps_only.tftest.hcl +++ b/terraform/litellm/gcp/tests/deps_only.tftest.hcl @@ -193,17 +193,12 @@ run "skip_read_replica" { } assert { - condition = length(local.shared_env_kv) == 10 + condition = length([for env in local.shared_env_kv : env if endswith(env.name, "_READ_REPLICA")]) == 0 error_message = "Writer-only mode must omit both DATABASE_*_READ_REPLICA entries." } assert { - condition = length([for env in local.shared_env_kv : env if env.name == "DATABASE_HOST_READ_REPLICA"]) == 0 - error_message = "DATABASE_HOST_READ_REPLICA must be unset without a replica." - } - - assert { - condition = length(local.database_url_fragment) == 1 - error_message = "Without a replica, only DATABASE_URL should be exported." + condition = length([for line in local.database_url_fragment : line if strcontains(line, "DATABASE_URL_READ_REPLICA")]) == 0 + error_message = "Writer-only mode must not export DATABASE_URL_READ_REPLICA." } } From b2930f72690406346dd10c300900596e490d9b6e Mon Sep 17 00:00:00 2001 From: Metal Date: Tue, 6 Oct 2026 18:09:10 +0500 Subject: [PATCH 5/5] test(terraform): check DATABASE_URL is still exported without a GCP replica Co-authored-by: Cursor --- terraform/litellm/gcp/tests/deps_only.tftest.hcl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/terraform/litellm/gcp/tests/deps_only.tftest.hcl b/terraform/litellm/gcp/tests/deps_only.tftest.hcl index 93f62a3296d..8fbc6b5220c 100644 --- a/terraform/litellm/gcp/tests/deps_only.tftest.hcl +++ b/terraform/litellm/gcp/tests/deps_only.tftest.hcl @@ -201,4 +201,9 @@ run "skip_read_replica" { condition = length([for line in local.database_url_fragment : line if strcontains(line, "DATABASE_URL_READ_REPLICA")]) == 0 error_message = "Writer-only mode must not export DATABASE_URL_READ_REPLICA." } + + assert { + condition = anytrue([for line in local.database_url_fragment : startswith(line, "export DATABASE_URL=")]) + error_message = "Writer-only mode must still export DATABASE_URL." + } }