feat(terraform/gcp): dependencies-only mode and bring-your-own-network for GKE (#39695)

* feat(terraform/gcp): add dependencies-only mode

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(terraform/gcp): review fixes for dependencies-only mode

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-05 12:22:52 -07:00 committed by GitHub
parent 730d789418
commit b290dd410e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 540 additions and 120 deletions

View file

@ -4,6 +4,7 @@ on:
push:
paths:
- "terraform/litellm/aws/**"
- "terraform/litellm/gcp/**"
- ".github/workflows/test-terraform-modules.yml"
pull_request:
branches:
@ -13,6 +14,7 @@ on:
- "litellm_**"
paths:
- "terraform/litellm/aws/**"
- "terraform/litellm/gcp/**"
- ".github/workflows/test-terraform-modules.yml"
permissions:
@ -52,3 +54,32 @@ jobs:
# Plan-only, mock_provider-backed: no AWS credentials, no API calls.
- name: test
run: terraform test
gcp-module:
name: fmt, validate, test (gcp)
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: terraform/litellm/gcp
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
with:
terraform_version: 1.13.3
terraform_wrapper: false
- name: fmt
run: terraform fmt -recursive -check -diff
- name: init
run: terraform init -backend=false -input=false
- name: validate
run: terraform validate
- name: test
run: terraform test

View file

@ -392,6 +392,63 @@ with its own provider config (one `examples/default`-style root per project),
or fork the module to add `configuration_aliases` and pass per-instance
`providers = { ... }`.
## Dependencies only (run LiteLLM on GKE)
Set `create_runtime = false` to provision Cloud SQL, Memorystore, GCS,
Secret Manager, and the runtime service account without Cloud Run or the
load balancer. For a Shared VPC, set the full host-project network ID and
skip PSA creation after the host project has configured it:
```hcl
create_runtime = false
network_id = "projects/<host>/global/networks/<vpc>"
create_psa_connection = false
```
The host project must already have Private Services Access configured on
that network and the Service Networking API enabled; the module cannot set
PSA up from a service project. GKE nodes must sit on the same Shared VPC so
the Cloud SQL and Memorystore private IPs are routable from the pods. Run
the root with its provider pointed at the project that should own the
dependencies. `create_runtime = true` with `network_id` set is also allowed,
but the Serverless VPC Access connector has to live in the same project as
the network, so that combination only works when the VPC is in the
deployment project
Map the outputs into the Helm values as follows:
```yaml
database:
writer:
host: <cloudsql_writer_ip>
dbname: <db_name>
passwordSecret:
name: <kubernetes-secret-with-db-credentials>
reader:
host: <cloudsql_reader_ip>
dbname: <db_name>
passwordSecret:
name: <kubernetes-secret-with-db-credentials>
redis:
host: <redis_host>
port: <redis_port>
masterKey:
secretName: <kubernetes-secret-with-master-key>
```
Create the database Secret with keys `username` (the `db_username` output)
and `password` (read it with `gcloud secrets versions access latest
--secret=<db_password_secret_id>`), and the master key Secret from
`master_key_secret_id` the same way. Memorystore only accepts TLS by
default, so store the `redis_server_ca_pem` output in a third Secret,
mount it into the gateway and backend pods via `volumes` / `volumeMounts`,
and add `REDIS_SSL=true` and `REDIS_SSL_CA_CERTS=<mount path>` to each
component's `extraEnv`. Setting `redis_transit_encryption = false` removes
the CA plumbing at the cost of plaintext Redis traffic inside the VPC
The chart's pre-install/pre-upgrade migration hook runs the Prisma
migration, so nothing replaces the Cloud Run migrations Job in this mode
## Storage and database retention
Two opt-in tripwires guard against accidental data loss on
@ -409,14 +466,15 @@ Flip `cloudsql_deletion_protection` to `false` or `gcs_force_destroy` to
## Redis encryption
Memorystore runs with `transit_encryption_mode = "SERVER_AUTHENTICATION"`,
so the proxy connects via `rediss://`. The instance's self-signed CA cert
(`server_ca_certs[0].cert`) is shipped to gateway + backend as
`REDIS_CA_PEM_B64`; their entrypoint shell decodes it to `/tmp/redis-ca.pem`
before uvicorn starts and points `REDIS_SSL_CA_CERTS` at that path. No
extra config needed — but if you ever swap Memorystore for an external
Redis, override `REDIS_HOST`/`REDIS_PORT` and either drop these env vars
or point them at your own CA.
By default, Memorystore runs with
`transit_encryption_mode = "SERVER_AUTHENTICATION"`, so Cloud Run connects
via `rediss://`. The instance's self-signed CA cert
(`server_ca_certs[0].cert`) is shipped to gateway and backend as
`REDIS_CA_PEM_B64`; their entrypoint shell decodes it to
`/tmp/redis-ca.pem` before uvicorn starts and points `REDIS_SSL_CA_CERTS` at
that path. Set `redis_transit_encryption = false` to use plaintext Redis.
For GKE, use `redis_server_ca_pem` as described in the dependencies-only
section, or accept the security tradeoff of disabling transit encryption
## Files
@ -434,4 +492,5 @@ or point them at your own CA.
| `iam.tf` | Runtime SA + Cloud SQL client + Secret Manager accessor |
| `cloudrun.tf` | 3 Cloud Run services + Cloud Run Job for migrations |
| `load_balancer.tf`| External HTTPS LB, serverless NEGs, URL map for path routing |
| `outputs.tf` | LB IP, service URLs, secret IDs, migration `execute` command |
| `outputs.tf` | LB IP, service URLs, dependency endpoints, secret IDs, migration command |
| `tests/` | Plan-only mock-provider coverage for deployment modes and Redis encryption |

View file

@ -15,15 +15,17 @@
# enough to invoke Cloud Run admin APIs (`gcloud auth login`).
resource "terraform_data" "migration" {
count = var.create_runtime ? 1 : 0
triggers_replace = {
job_id = google_cloud_run_v2_job.migrations.id
job_id = google_cloud_run_v2_job.migrations[0].id
job_image = local.migrations_image
}
provisioner "local-exec" {
interpreter = ["bash", "-c"]
environment = {
JOB = google_cloud_run_v2_job.migrations.name
JOB = google_cloud_run_v2_job.migrations[0].name
REGION = var.region
PROJECT = var.project_id
}

View file

@ -6,25 +6,28 @@ locals {
# Memorystore exposes a self-signed CA cert per instance; we ship it as
# a base64 env var and decode it to a file at container startup so the
# rediss:// connection can validate. Public cert, not sensitive.
redis_ca_pem_b64 = base64encode(google_redis_instance.this.server_ca_certs[0].cert)
redis_ca_pem_b64 = var.redis_transit_encryption ? base64encode(google_redis_instance.this.server_ca_certs[0].cert) : ""
shared_env_kv = [
{ name = "DATABASE_HOST", value = google_sql_database_instance.writer.private_ip_address },
{ 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 },
{ 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) },
# _redis.get_redis_url_from_environment honors REDIS_SSL to flip the
# scheme to rediss://; REDIS_SSL_CA_CERTS is mapped via
# _get_redis_env_kwarg_mapping ssl_ca_certs on the redis-py client.
{ name = "REDIS_SSL", value = "true" },
{ name = "REDIS_SSL_CA_CERTS", value = "/tmp/redis-ca.pem" },
{ name = "REDIS_CA_PEM_B64", value = local.redis_ca_pem_b64 },
{ name = "GCS_BUCKET_NAME", value = google_storage_bucket.this.name },
]
shared_env_kv = concat(
[
{ name = "DATABASE_HOST", value = google_sql_database_instance.writer.private_ip_address },
{ 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 },
{ 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) },
],
var.redis_transit_encryption ? [
{ name = "REDIS_SSL", value = "true" },
{ name = "REDIS_SSL_CA_CERTS", value = "/tmp/redis-ca.pem" },
{ name = "REDIS_CA_PEM_B64", value = local.redis_ca_pem_b64 },
] : [],
[
{ name = "GCS_BUCKET_NAME", value = google_storage_bucket.this.name },
],
)
# OTel v2 is opt-in and gated on otel_endpoint, matching the AWS stack
# nothing OTel-related is added to the container env until an endpoint is
@ -126,9 +129,9 @@ locals {
# Decode the Memorystore CA cert (passed as REDIS_CA_PEM_B64) to the
# path REDIS_SSL_CA_CERTS points at, so the redis-py client can validate
# the rediss:// handshake.
redis_ca_fragment = [
redis_ca_fragment = var.redis_transit_encryption ? [
"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}\"",
@ -171,29 +174,7 @@ locals {
# ---------- Gateway ----------
resource "google_cloud_run_v2_service" "gateway" {
# Metering needs a client certificate AND its key. Each secret is created only
# when its own PEM is supplied, so an endpoint set with a missing key would
# otherwise apply cleanly and leave the proxy logging "missing config" and
# never exporting. ca_cert_pem stays optional: empty means fall back to the
# system trust store.
#
# The guard lives here, on an unconditional resource, rather than on the cert
# secret: that secret is count-gated on the cert itself, so it has zero
# instances in exactly the case this must catch. Adding count or for_each to
# this resource would silently stop the guard from evaluating.
#
# endpoint cert key -> result
# "" any any -> metering off, no secrets created
# set set set -> metering on
# set any-missing -> plan fails here
lifecycle {
precondition {
condition = var.billing_metrics_endpoint == "" || (
var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != ""
)
error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set."
}
}
count = var.create_runtime ? 1 : 0
name = "${local.name}-gateway"
location = var.region
@ -206,7 +187,7 @@ resource "google_cloud_run_v2_service" "gateway" {
max_instance_request_concurrency = var.gateway_max_instance_request_concurrency
vpc_access {
connector = google_vpc_access_connector.this.id
connector = google_vpc_access_connector.this[0].id
egress = "PRIVATE_RANGES_ONLY"
}
@ -312,17 +293,7 @@ resource "google_cloud_run_v2_service" "gateway" {
# ---------- Backend ----------
resource "google_cloud_run_v2_service" "backend" {
# Same guard as the gateway: the backend meters too (it serves the named-server
# MCP transport), and a targeted apply of just this resource must not slip a
# billing endpoint through without the credentials to use it.
lifecycle {
precondition {
condition = var.billing_metrics_endpoint == "" || (
var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != ""
)
error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set."
}
}
count = var.create_runtime ? 1 : 0
name = "${local.name}-backend"
location = var.region
@ -335,7 +306,7 @@ resource "google_cloud_run_v2_service" "backend" {
max_instance_request_concurrency = var.backend_max_instance_request_concurrency
vpc_access {
connector = google_vpc_access_connector.this.id
connector = google_vpc_access_connector.this[0].id
egress = "PRIVATE_RANGES_ONLY"
}
@ -443,6 +414,8 @@ resource "google_cloud_run_v2_service" "backend" {
# with zero IAM bindings, so a compromised UI container can't pivot to
# Secret Manager / Cloud SQL via the metadata service.
resource "google_cloud_run_v2_service" "ui" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-ui"
location = var.region
ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
@ -450,7 +423,7 @@ resource "google_cloud_run_v2_service" "ui" {
deletion_protection = false
template {
service_account = google_service_account.ui_runtime.email
service_account = google_service_account.ui_runtime[0].email
max_instance_request_concurrency = var.ui_max_instance_request_concurrency
scaling {
@ -491,25 +464,31 @@ resource "google_cloud_run_v2_service" "ui" {
# (LITELLM_MASTER_KEY); these IAM bindings just open up Cloud Run's invoker
# gate so the LB request makes it to the container.
resource "google_cloud_run_v2_service_iam_member" "gateway_allusers" {
count = var.create_runtime ? 1 : 0
project = var.project_id
location = google_cloud_run_v2_service.gateway.location
name = google_cloud_run_v2_service.gateway.name
location = google_cloud_run_v2_service.gateway[0].location
name = google_cloud_run_v2_service.gateway[0].name
role = "roles/run.invoker"
member = "allUsers"
}
resource "google_cloud_run_v2_service_iam_member" "backend_allusers" {
count = var.create_runtime ? 1 : 0
project = var.project_id
location = google_cloud_run_v2_service.backend.location
name = google_cloud_run_v2_service.backend.name
location = google_cloud_run_v2_service.backend[0].location
name = google_cloud_run_v2_service.backend[0].name
role = "roles/run.invoker"
member = "allUsers"
}
resource "google_cloud_run_v2_service_iam_member" "ui_allusers" {
count = var.create_runtime ? 1 : 0
project = var.project_id
location = google_cloud_run_v2_service.ui.location
name = google_cloud_run_v2_service.ui.name
location = google_cloud_run_v2_service.ui[0].location
name = google_cloud_run_v2_service.ui[0].name
role = "roles/run.invoker"
member = "allUsers"
}
@ -519,6 +498,8 @@ resource "google_cloud_run_v2_service_iam_member" "ui_allusers" {
# assembles DATABASE_URL from the DATABASE_* env vars and runs `prisma
# migrate deploy`. No proxy_config, no master key, no shell wrapper.
resource "google_cloud_run_v2_job" "migrations" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-migrations"
location = var.region
labels = local.labels
@ -529,7 +510,7 @@ resource "google_cloud_run_v2_job" "migrations" {
service_account = google_service_account.runtime.email
vpc_access {
connector = google_vpc_access_connector.this.id
connector = google_vpc_access_connector.this[0].id
egress = "PRIVATE_RANGES_ONLY"
}

View file

@ -36,7 +36,7 @@ resource "google_sql_database_instance" "writer" {
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.this.id
private_network = local.network_id
}
insights_config {
@ -55,6 +55,11 @@ resource "google_sql_database_instance" "writer" {
# (full data loss). Set the initial size only; let Cloud SQL own it
# thereafter.
ignore_changes = [settings[0].disk_size]
precondition {
condition = var.create_psa_connection || var.network_id != ""
error_message = "create_psa_connection must be true unless network_id references an existing VPC with Private Services Access configured."
}
}
}
@ -76,7 +81,7 @@ resource "google_sql_database_instance" "reader" {
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.this.id
private_network = local.network_id
}
}

View file

@ -31,6 +31,11 @@ module "litellm" {
tenant = var.tenant
env = var.env
create_runtime = var.create_runtime
network_id = var.network_id
create_psa_connection = var.create_psa_connection
redis_transit_encryption = var.redis_transit_encryption
litellm_master_key = var.litellm_master_key
litellm_license = var.litellm_license
ui_password = var.ui_password

View file

@ -38,6 +38,31 @@ output "redis_endpoint" {
value = module.litellm.redis_endpoint
}
output "redis_host" {
description = "Memorystore Redis host."
value = module.litellm.redis_host
}
output "redis_port" {
description = "Memorystore Redis port."
value = module.litellm.redis_port
}
output "redis_server_ca_pem" {
description = "Memorystore server CA PEM."
value = module.litellm.redis_server_ca_pem
}
output "db_username" {
description = "Cloud SQL application username."
value = module.litellm.db_username
}
output "db_name" {
description = "Cloud SQL database name."
value = module.litellm.db_name
}
output "gcs_bucket" {
description = "GCS bucket name."
value = module.litellm.gcs_bucket
@ -53,6 +78,11 @@ output "db_password_secret_id" {
value = module.litellm.db_password_secret_id
}
output "runtime_service_account_email" {
description = "Runtime service account email."
value = module.litellm.runtime_service_account_email
}
output "migration_run_command" {
description = "Break-glass command to re-run the one-off migration job."
value = module.litellm.migration_run_command

View file

@ -8,6 +8,14 @@ region = "us-central1"
tenant = "acme"
env = "stage"
# Deployment mode. For dependencies only on a Shared VPC, set
# 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
# network_id = ""
# create_psa_connection = true
# redis_transit_encryption = true
# Tenant-supplied secrets. Prefer TF_VAR_litellm_master_key /
# TF_VAR_litellm_license / TF_VAR_ui_password env vars so the values don't
# end up in a committed tfvars file. All three are optional — when

View file

@ -26,6 +26,30 @@ variable "env" {
type = string
}
variable "create_runtime" {
description = "Create Cloud Run and load balancer resources."
type = bool
default = true
}
variable "network_id" {
description = "Existing VPC network resource ID. Empty creates a VPC."
type = string
default = ""
}
variable "create_psa_connection" {
description = "Create Private Services Access resources."
type = bool
default = true
}
variable "redis_transit_encryption" {
description = "Enable Memorystore transit encryption."
type = bool
default = true
}
# Sensitive prefer TF_VAR_litellm_master_key / TF_VAR_litellm_license /
# TF_VAR_ui_password so values stay out of any committed tfvars file.
variable "litellm_master_key" {

View file

@ -6,6 +6,15 @@
resource "google_service_account" "runtime" {
account_id = "${local.name}-runtime"
display_name = "LiteLLM Cloud Run runtime"
lifecycle {
precondition {
condition = !var.create_runtime || var.billing_metrics_endpoint == "" || (
var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != ""
)
error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set and create_runtime is true."
}
}
}
# UI runtime SA no role bindings. The UI is static nginx with no DB,
@ -14,6 +23,8 @@ resource "google_service_account" "runtime" {
# project's serverless service agent (not this SA), so it doesn't need
# artifactregistry.reader either.
resource "google_service_account" "ui_runtime" {
count = var.create_runtime ? 1 : 0
account_id = "${local.name}-ui-runtime"
display_name = "LiteLLM Cloud Run UI runtime (no data-plane access)"
}

View file

@ -14,77 +14,93 @@ locals {
}
resource "google_compute_global_address" "lb" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-lb-ip"
labels = local.labels
}
# Serverless NEGs one per Cloud Run service.
resource "google_compute_region_network_endpoint_group" "gateway" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-gateway-neg"
region = var.region
network_endpoint_type = "SERVERLESS"
cloud_run {
service = google_cloud_run_v2_service.gateway.name
service = google_cloud_run_v2_service.gateway[0].name
}
}
resource "google_compute_region_network_endpoint_group" "backend" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-backend-neg"
region = var.region
network_endpoint_type = "SERVERLESS"
cloud_run {
service = google_cloud_run_v2_service.backend.name
service = google_cloud_run_v2_service.backend[0].name
}
}
resource "google_compute_region_network_endpoint_group" "ui" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-ui-neg"
region = var.region
network_endpoint_type = "SERVERLESS"
cloud_run {
service = google_cloud_run_v2_service.ui.name
service = google_cloud_run_v2_service.ui[0].name
}
}
# Backend services wrap each NEG.
resource "google_compute_backend_service" "gateway" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-gateway-bs"
protocol = "HTTP"
load_balancing_scheme = "EXTERNAL_MANAGED"
backend {
group = google_compute_region_network_endpoint_group.gateway.id
group = google_compute_region_network_endpoint_group.gateway[0].id
}
}
resource "google_compute_backend_service" "backend" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-backend-bs"
protocol = "HTTP"
load_balancing_scheme = "EXTERNAL_MANAGED"
backend {
group = google_compute_region_network_endpoint_group.backend.id
group = google_compute_region_network_endpoint_group.backend[0].id
}
}
resource "google_compute_backend_service" "ui" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-ui-bs"
protocol = "HTTP"
load_balancing_scheme = "EXTERNAL_MANAGED"
backend {
group = google_compute_region_network_endpoint_group.ui.id
group = google_compute_region_network_endpoint_group.ui[0].id
}
}
# URL map. Default backend (management API). Path matchers route the
# gateway and UI prefixes elsewhere.
resource "google_compute_url_map" "this" {
count = var.create_runtime ? 1 : 0
name = local.name
default_service = google_compute_backend_service.backend.id
default_service = google_compute_backend_service.backend[0].id
host_rule {
hosts = ["*"]
@ -93,13 +109,13 @@ resource "google_compute_url_map" "this" {
path_matcher {
name = "main"
default_service = google_compute_backend_service.backend.id
default_service = google_compute_backend_service.backend[0].id
# UI paths (catch them before any /v1/* gateway rules so /favicon.ico
# and / take precedence).
path_rule {
paths = local.ui_path_prefixes
service = google_compute_backend_service.ui.id
service = google_compute_backend_service.ui[0].id
}
# Gateway path prefixes. GCP URL maps cap a path_rule at 10 path globs,
@ -108,7 +124,7 @@ resource "google_compute_url_map" "this" {
for_each = { for idx, chunk in chunklist(local.gateway_path_prefixes, 10) : idx => chunk }
content {
paths = path_rule.value
service = google_compute_backend_service.gateway.id
service = google_compute_backend_service.gateway[0].id
}
}
}
@ -118,7 +134,7 @@ resource "google_compute_url_map" "this" {
# target proxy when TLS is enabled; otherwise the regular path-routing
# URL map is attached to the HTTP proxy and everything stays plaintext.
resource "google_compute_url_map" "https_redirect" {
count = local.tls_enabled ? 1 : 0
count = var.create_runtime && local.tls_enabled ? 1 : 0
name = "${local.name}-redirect"
default_url_redirect {
@ -129,8 +145,10 @@ resource "google_compute_url_map" "https_redirect" {
}
resource "google_compute_target_http_proxy" "this" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-http"
url_map = local.tls_enabled ? google_compute_url_map.https_redirect[0].id : google_compute_url_map.this.id
url_map = local.tls_enabled ? google_compute_url_map.https_redirect[0].id : google_compute_url_map.this[0].id
# Default-deny on the HTTP-only path: TLS is the supported posture.
# Operators must either supply DNS names or explicitly opt in.
@ -143,12 +161,14 @@ resource "google_compute_target_http_proxy" "this" {
}
resource "google_compute_global_forwarding_rule" "http" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-http"
ip_protocol = "TCP"
port_range = "80"
load_balancing_scheme = "EXTERNAL_MANAGED"
ip_address = google_compute_global_address.lb.address
target = google_compute_target_http_proxy.this.id
ip_address = google_compute_global_address.lb[0].address
target = google_compute_target_http_proxy.this[0].id
labels = local.labels
}
@ -161,7 +181,7 @@ resource "google_compute_global_forwarding_rule" "http" {
# transitions to ACTIVE.
resource "google_compute_managed_ssl_certificate" "this" {
count = local.tls_enabled ? 1 : 0
count = var.create_runtime && local.tls_enabled ? 1 : 0
# A managed cert's `domains` is immutable, so changing var.lb_domains
# forces replacement, and the cert is referenced by the HTTPS target
@ -181,19 +201,19 @@ resource "google_compute_managed_ssl_certificate" "this" {
}
resource "google_compute_target_https_proxy" "this" {
count = local.tls_enabled ? 1 : 0
count = var.create_runtime && local.tls_enabled ? 1 : 0
name = "${local.name}-https"
url_map = google_compute_url_map.this.id
url_map = google_compute_url_map.this[0].id
ssl_certificates = [google_compute_managed_ssl_certificate.this[0].id]
}
resource "google_compute_global_forwarding_rule" "https" {
count = local.tls_enabled ? 1 : 0
count = var.create_runtime && local.tls_enabled ? 1 : 0
name = "${local.name}-https"
ip_protocol = "TCP"
port_range = "443"
load_balancing_scheme = "EXTERNAL_MANAGED"
ip_address = google_compute_global_address.lb.address
ip_address = google_compute_global_address.lb[0].address
target = google_compute_target_https_proxy.this[0].id
labels = local.labels
}

View file

@ -21,6 +21,9 @@ locals {
var.labels,
)
create_network = var.network_id == ""
network_id = local.create_network ? google_compute_network.this[0].id : var.network_id
gateway_path_prefixes = [
"/v1/chat/*", "/chat/*",
"/v1/completions*", "/completions*",
@ -74,7 +77,7 @@ locals {
"/ui/*",
]
proxy_config_enabled = length(keys(var.proxy_config)) > 0
proxy_config_enabled = var.create_runtime && length(keys(var.proxy_config)) > 0
proxy_config_yaml = local.proxy_config_enabled ? yamlencode(var.proxy_config) : ""
proxy_config_mount_path = "/etc/litellm"

View file

@ -1,13 +1,17 @@
resource "google_compute_network" "this" {
count = local.create_network ? 1 : 0
name = local.name
auto_create_subnetworks = false
routing_mode = "REGIONAL"
}
resource "google_compute_subnetwork" "this" {
count = local.create_network ? 1 : 0
name = "${local.name}-${var.region}"
region = var.region
network = google_compute_network.this.id
network = google_compute_network.this[0].id
ip_cidr_range = var.subnet_cidr
private_ip_google_access = true
}
@ -16,17 +20,21 @@ resource "google_compute_subnetwork" "this" {
# managed services peer with the VPC over the connection below using
# addresses from this range.
resource "google_compute_global_address" "psa" {
count = var.create_psa_connection ? 1 : 0
name = "${local.name}-psa"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 16
network = google_compute_network.this.id
network = local.network_id
}
resource "google_service_networking_connection" "psa" {
network = google_compute_network.this.id
count = var.create_psa_connection ? 1 : 0
network = local.network_id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.psa.name]
reserved_peering_ranges = [google_compute_global_address.psa[0].name]
}
# Serverless VPC Access connector required so Cloud Run can reach
@ -37,9 +45,11 @@ resource "google_service_networking_connection" "psa" {
# for low-to-moderate Cloud Run egress; bump max if your services push
# heavy private-network traffic.
resource "google_vpc_access_connector" "this" {
count = var.create_runtime ? 1 : 0
name = "${local.name}-conn"
region = var.region
network = google_compute_network.this.name
network = local.network_id
ip_cidr_range = var.vpc_connector_cidr
min_instances = 2
max_instances = 3

View file

@ -1,26 +1,26 @@
output "lb_ip" {
description = "Global anycast IP of the external HTTPS load balancer."
value = google_compute_global_address.lb.address
description = "Global anycast IP of the external HTTPS load balancer. Null when create_runtime is false."
value = var.create_runtime ? one(google_compute_global_address.lb[*].address) : null
}
output "lb_url" {
description = "Proxy URL. Switches scheme based on whether lb_domains is set; when TLS is enabled the URL points at the first listed domain (since managed certs are tied to the hostname, not the anycast IP). The dashboard is served at /, the API at /v1/*."
value = local.tls_enabled ? "https://${var.lb_domains[0]}" : "http://${google_compute_global_address.lb.address}"
description = "Proxy URL, or null when create_runtime is false. Switches scheme based on whether lb_domains is set."
value = var.create_runtime ? (local.tls_enabled ? "https://${var.lb_domains[0]}" : "http://${one(google_compute_global_address.lb[*].address)}") : null
}
output "gateway_service_url" {
description = "Default Cloud Run URL for the gateway (bypasses the LB)."
value = google_cloud_run_v2_service.gateway.uri
description = "Default Cloud Run URL for the gateway, or null when create_runtime is false."
value = var.create_runtime ? one(google_cloud_run_v2_service.gateway[*].uri) : null
}
output "backend_service_url" {
description = "Default Cloud Run URL for the backend (bypasses the LB)."
value = google_cloud_run_v2_service.backend.uri
description = "Default Cloud Run URL for the backend, or null when create_runtime is false."
value = var.create_runtime ? one(google_cloud_run_v2_service.backend[*].uri) : null
}
output "ui_service_url" {
description = "Default Cloud Run URL for the UI (bypasses the LB)."
value = google_cloud_run_v2_service.ui.uri
description = "Default Cloud Run URL for the UI, or null when create_runtime is false."
value = var.create_runtime ? one(google_cloud_run_v2_service.ui[*].uri) : null
}
output "cloudsql_writer_ip" {
@ -38,6 +38,36 @@ output "redis_endpoint" {
value = "${google_redis_instance.this.host}:${google_redis_instance.this.port}"
}
output "runtime_service_account_email" {
description = "Runtime service account email for Cloud Run or GKE Workload Identity."
value = google_service_account.runtime.email
}
output "redis_host" {
description = "Memorystore Redis host."
value = google_redis_instance.this.host
}
output "redis_port" {
description = "Memorystore Redis port."
value = google_redis_instance.this.port
}
output "redis_server_ca_pem" {
description = "Memorystore server CA PEM. Mount it in the pod and set REDIS_SSL=true and REDIS_SSL_CA_CERTS=<path> via extraEnv when transit encryption is enabled."
value = var.redis_transit_encryption ? google_redis_instance.this.server_ca_certs[0].cert : null
}
output "db_username" {
description = "Cloud SQL application username."
value = var.db_username
}
output "db_name" {
description = "Cloud SQL database name."
value = var.db_name
}
output "gcs_bucket" {
description = "GCS bucket name. Exposed to gateway + backend as GCS_BUCKET_NAME. Reference from proxy_config via `os.environ/GCS_BUCKET_NAME`."
value = google_storage_bucket.this.name
@ -54,11 +84,11 @@ output "db_password_secret_id" {
}
output "migration_run_command" {
description = "Shell command that executes the one-off migration job against Cloud SQL. Run this once after the first apply."
value = format(
description = "Shell command that executes the one-off migration job against Cloud SQL, or null when create_runtime is false."
value = var.create_runtime ? format(
"gcloud run jobs execute %s --region %s --project %s --wait",
google_cloud_run_v2_job.migrations.name,
one(google_cloud_run_v2_job.migrations[*].name),
var.region,
var.project_id,
)
) : null
}

View file

@ -4,7 +4,7 @@ resource "google_redis_instance" "this" {
memory_size_gb = var.redis_memory_size_gb
region = var.region
authorized_network = google_compute_network.this.id
authorized_network = local.network_id
connect_mode = "PRIVATE_SERVICE_ACCESS"
redis_version = "REDIS_7_0"
@ -16,7 +16,7 @@ resource "google_redis_instance" "this" {
# and passed to the proxy as REDIS_CA_PEM_B64); the proxy decodes it to
# /tmp/redis-ca.pem at startup and uses it to validate the rediss://
# handshake. Mirrors `transit_encryption_enabled = true` on AWS.
transit_encryption_mode = "SERVER_AUTHENTICATION"
transit_encryption_mode = var.redis_transit_encryption ? "SERVER_AUTHENTICATION" : "DISABLED"
depends_on = [google_service_networking_connection.psa]
}

View file

@ -0,0 +1,175 @@
mock_provider "google" {
mock_resource "google_redis_instance" {
defaults = {
host = "10.0.0.4"
port = 6379
server_ca_certs = [{
cert = "-----BEGIN CERTIFICATE-----\nmock\n-----END CERTIFICATE-----"
}]
}
}
}
mock_provider "google-beta" {}
mock_provider "random" {}
variables {
project_id = "test-project"
tenant = "tenant"
env = "test"
allow_plaintext_lb = true
image_registry = "us-central1-docker.pkg.dev/test-project/litellm"
}
run "default_creates_everything" {
command = plan
assert {
condition = alltrue([
length(google_compute_network.this) == 1,
length(google_compute_subnetwork.this) == 1,
length(google_compute_global_address.psa) == 1,
length(google_service_networking_connection.psa) == 1,
length(google_vpc_access_connector.this) == 1,
length(google_cloud_run_v2_service.gateway) == 1,
length(google_cloud_run_v2_service.backend) == 1,
length(google_cloud_run_v2_service.ui) == 1,
length(google_cloud_run_v2_job.migrations) == 1,
length(google_compute_global_address.lb) == 1,
length(terraform_data.migration) == 1,
])
error_message = "The default mode must create networking, runtime services, the load balancer, and migrations."
}
assert {
condition = google_redis_instance.this.transit_encryption_mode == "SERVER_AUTHENTICATION"
error_message = "Redis transit encryption must remain enabled by default."
}
assert {
condition = length(local.shared_env_kv) == 12
error_message = "The default runtime environment must include GCS and the three Redis TLS entries."
}
}
run "deps_only_creates_no_runtime" {
command = plan
variables {
create_runtime = false
proxy_config = {
model_list = []
}
}
assert {
condition = alltrue([
length(google_cloud_run_v2_service.gateway) == 0,
length(google_cloud_run_v2_service.backend) == 0,
length(google_cloud_run_v2_service.ui) == 0,
length(google_cloud_run_v2_job.migrations) == 0,
length(google_cloud_run_v2_service_iam_member.gateway_allusers) == 0,
length(google_cloud_run_v2_service_iam_member.backend_allusers) == 0,
length(google_cloud_run_v2_service_iam_member.ui_allusers) == 0,
length(google_compute_global_address.lb) == 0,
length(google_compute_region_network_endpoint_group.gateway) == 0,
length(google_compute_region_network_endpoint_group.backend) == 0,
length(google_compute_region_network_endpoint_group.ui) == 0,
length(google_compute_backend_service.gateway) == 0,
length(google_compute_backend_service.backend) == 0,
length(google_compute_backend_service.ui) == 0,
length(google_compute_url_map.this) == 0,
length(google_compute_url_map.https_redirect) == 0,
length(google_compute_target_http_proxy.this) == 0,
length(google_compute_global_forwarding_rule.http) == 0,
length(google_compute_managed_ssl_certificate.this) == 0,
length(google_compute_target_https_proxy.this) == 0,
length(google_compute_global_forwarding_rule.https) == 0,
length(terraform_data.migration) == 0,
length(google_vpc_access_connector.this) == 0,
length(google_service_account.ui_runtime) == 0,
length(google_storage_bucket.proxy_config) == 0,
])
error_message = "Dependencies-only mode must omit all runtime, load balancer, connector, UI identity, and proxy config resources."
}
assert {
condition = alltrue([
google_sql_database_instance.writer.name == "tenant-litellm-test",
google_sql_database_instance.reader.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",
google_secret_manager_secret.db_password.secret_id == "tenant-litellm-test-db-password",
google_service_account.runtime.account_id == "tenant-litellm-test-runtime",
])
error_message = "Dependencies-only mode must retain data stores, secrets, and the runtime service account."
}
assert {
condition = output.lb_url == null && output.migration_run_command == null
error_message = "Runtime outputs must be null while dependency outputs remain available."
}
}
run "existing_network_attaches_data_stores" {
command = plan
variables {
network_id = "projects/host-proj/global/networks/shared"
create_psa_connection = false
create_runtime = false
}
assert {
condition = alltrue([
length(google_compute_network.this) == 0,
length(google_compute_subnetwork.this) == 0,
length(google_compute_global_address.psa) == 0,
length(google_service_networking_connection.psa) == 0,
google_sql_database_instance.writer.settings[0].ip_configuration[0].private_network == var.network_id,
google_redis_instance.this.authorized_network == var.network_id,
])
error_message = "An existing VPC must receive the Cloud SQL and Memorystore private-network attachments."
}
}
run "psa_required_without_existing_network" {
command = plan
variables {
create_psa_connection = false
}
expect_failures = [
google_sql_database_instance.writer,
]
}
run "redis_plaintext_drops_tls_env" {
command = plan
variables {
redis_transit_encryption = false
}
assert {
condition = google_redis_instance.this.transit_encryption_mode == "DISABLED"
error_message = "Redis transit encryption must be disabled when requested."
}
assert {
condition = length(local.shared_env_kv) == 9
error_message = "Plaintext Redis mode must include GCS and omit the three Redis TLS entries."
}
assert {
condition = length([for env in local.shared_env_kv : env if env.name == "REDIS_SSL"]) == 0
error_message = "Plaintext Redis mode must not set REDIS_SSL."
}
assert {
condition = length(local.redis_ca_fragment) == 0
error_message = "Plaintext Redis mode must not decode a Redis CA at startup."
}
}

View file

@ -79,16 +79,42 @@ variable "ui_password" {
sensitive = true
}
# ---------- Deployment mode ----------
variable "create_runtime" {
description = "Create Cloud Run, load balancer, VPC connector, runtime support resources, and the migration job. Set false for GKE or another external runtime."
type = bool
default = true
}
variable "network_id" {
description = "Existing VPC network resource ID (`projects/<host-project>/global/networks/<name>`). When set, no VPC or subnet is created. A VPC connector requires this network to be in the deployment project when create_runtime is true."
type = string
default = ""
}
variable "create_psa_connection" {
description = "Create the Private Services Access range and connection for Cloud SQL and Memorystore. Set false when the existing network already has PSA configured."
type = bool
default = true
}
variable "redis_transit_encryption" {
description = "Enable Memorystore transit encryption and inject Redis TLS settings into Cloud Run. Set false to use plaintext Redis."
type = bool
default = true
}
# ---------- Networking ----------
variable "subnet_cidr" {
description = "Primary CIDR block for the LiteLLM subnet."
description = "Primary CIDR block for the LiteLLM subnet. Unused when network_id is set."
type = string
default = "10.40.0.0/16"
}
variable "vpc_connector_cidr" {
description = "CIDR for the Serverless VPC Access connector. /28 required."
description = "CIDR for the Serverless VPC Access connector. /28 required. Unused when create_runtime is false."
type = string
default = "10.41.0.0/28"
}