diff --git a/terraform/litellm/gcp/README.md b/terraform/litellm/gcp/README.md index c90af26ebf2..74d19656831 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 + cross-zone read replica, - 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` @@ -96,9 +96,9 @@ spec. This stack therefore uses **password authentication**: - 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. + `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. If you need GCP-native IAM auth later, add `cloud-sql-proxy` as a sidecar container under `template.template.containers` (Cloud Run v2 supports @@ -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. 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 By default, Memorystore runs with diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index d0b32a367d6..34d819e9b8f 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,14 @@ 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_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..e535df2b9c4 100644 --- a/terraform/litellm/gcp/cloudsql.tf +++ b/terraform/litellm/gcp/cloudsql.tf @@ -1,4 +1,4 @@ -# Cloud SQL for PostgreSQL — one primary + one read replica. +# 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 @@ -64,6 +64,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..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." + 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/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..f4b546351e6 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. 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..fefa7aefcff 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, or 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..8fbc6b5220c 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." } @@ -96,7 +97,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 +174,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 replica instance." + } + + assert { + condition = output.cloudsql_reader_ip == null + error_message = "cloudsql_reader_ip must be null without a replica." + } + + assert { + 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 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." + } +} diff --git a/terraform/litellm/gcp/variables.tf b/terraform/litellm/gcp/variables.tf index 412f919ab89..5aa79dfd58a 100644 --- a/terraform/litellm/gcp/variables.tf +++ b/terraform/litellm/gcp/variables.tf @@ -330,6 +330,12 @@ variable "ui_max_instance_request_concurrency" { # ---------- Cloud SQL ---------- +variable "create_read_replica" { + 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 +} + variable "db_tier" { description = "Cloud SQL tier (machine type) for the writer instance." type = string