mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
Merge 5a6812387b into 852cb3abbe
This commit is contained in:
commit
327a3aab17
21 changed files with 2414 additions and 3 deletions
62
README.md
62
README.md
|
|
@ -10,6 +10,7 @@
|
|||
<a href="https://railway.com/deploy/RhvhdC?referralCode=7mRv9K&utm_medium=integration&utm_source=template&utm_campaign=generic"><img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40"></a>
|
||||
<a href="https://console.aws.amazon.com/cloudshell/home" target="_blank" rel="nofollow"><img src="./.github/deploy-on-aws.png" alt="Deploy on AWS" height="40"></a>
|
||||
<a href="https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Flitellm&cloudshell_workspace=terraform%2Flitellm%2Fgcp%2Fexamples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true" target="_blank" rel="nofollow"><img src="./.github/deploy-on-gcp.png" alt="Deploy on GCP" height="40"></a>
|
||||
<a href="https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2FBerriAI%2Flitellm%2Fmain%2Fterraform%2Flitellm%2Fazure%2Fexamples%2Fdefault%2Fazuredeploy.json" target="_blank" rel="nofollow"><img src="./.github/deploy-on-azure.png" alt="Deploy on Azure" height="40"></a>
|
||||
</p>
|
||||
</p>
|
||||
<h4 align="center"><a href="https://docs.litellm.ai/docs/simple_proxy" target="_blank">LiteLLM Proxy Server (AI Gateway)</a> | <a href="https://docs.litellm.ai/docs/enterprise#hosted-litellm-proxy" target="_blank"> Hosted Proxy</a> | <a href="https://litellm.ai/enterprise"target="_blank">Enterprise Tier</a> | <a href="https://www.litellm.ai/ai-gateway" target="_blank">Website</a></h4>
|
||||
|
|
@ -536,16 +537,71 @@ terraform apply
|
|||
|
||||
Provider API keys live in Secret Manager; reference resource IDs (e.g. `projects/my-gcp-project/secrets/openai-api-key`) via `gateway_extra_secrets`. Full input list and architecture diagram on the [registry page](https://registry.terraform.io/modules/BerriAI/litellm/google/latest?tab=inputs).
|
||||
|
||||
#### Azure: Container Apps + App Gateway
|
||||
|
||||
Container Apps + Application Gateway with path-based routing, matching the AWS / GCP stacks. Pulls the four componentized images directly from GHCR (no mirror needed for Azure).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/BerriAI/litellm.git
|
||||
cd litellm/terraform/litellm/azure/examples/default
|
||||
cp terraform.tfvars.example terraform.tfvars # edit location/tenant/env
|
||||
terraform init && terraform apply
|
||||
```
|
||||
|
||||
[Module page →](https://registry.terraform.io/modules/BerriAI/litellm/azure/latest)
|
||||
|
||||
To call the module from your own root config:
|
||||
|
||||
```hcl
|
||||
# main.tf
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
required_providers {
|
||||
azurerm = { source = "hashicorp/azurerm", version = "~> 3.117" }
|
||||
}
|
||||
}
|
||||
|
||||
provider "azurerm" {
|
||||
features {}
|
||||
}
|
||||
|
||||
module "litellm" {
|
||||
source = "BerriAI/litellm/azure"
|
||||
version = "~> 1.89"
|
||||
|
||||
location = "eastus"
|
||||
tenant = "acme"
|
||||
env = "prod"
|
||||
azs = ["1", "2"]
|
||||
|
||||
# Production: upload a TLS cert to Key Vault and reference it here.
|
||||
# Without one, set allow_plaintext_app_gateway = true (dev/trial only).
|
||||
# key_vault_certificate_id = "https://acmekv.vault.azure.net/certificates/litellm-cert/<version>"
|
||||
allow_plaintext_app_gateway = true
|
||||
}
|
||||
|
||||
output "litellm_url" {
|
||||
value = module.litellm.app_gateway_url
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
|
||||
Provider API keys live in Key Vault; reference secret IDs via `gateway_extra_secrets`. Full input list on the [module README](./terraform/litellm/azure/README.md).
|
||||
|
||||
#### Both stacks include
|
||||
|
||||
- The full componentized split (gateway / backend / UI as independent services)
|
||||
- Managed Postgres (writer + reader) and Redis
|
||||
- Managed Postgres and Redis
|
||||
- Versioned object store for proxy state + file uploads
|
||||
- An auto-generated `LITELLM_MASTER_KEY` in your cloud's secret manager
|
||||
- A one-off migration job that runs `prisma migrate deploy` before the proxy starts
|
||||
- The same `proxy_config` surface as the [Helm chart](./helm/litellm/) — pass YAML as a typed map
|
||||
- The same `proxy_config` surface as the [Helm chart](./helm/litellm/), pass YAML as a typed map
|
||||
|
||||
The Terraform modules live at [`terraform/litellm/aws/`](./terraform/litellm/aws/) and [`terraform/litellm/gcp/`](./terraform/litellm/gcp/) in this repo; the registry entries are read-only mirrors updated on each release.
|
||||
The Terraform modules live at [`terraform/litellm/aws/`](./terraform/litellm/aws/), [`terraform/litellm/gcp/`](./terraform/litellm/gcp/), and [`terraform/litellm/azure/`](./terraform/litellm/azure/) in this repo; the registry entries are read-only mirrors updated on each release.
|
||||
|
||||
### Run in Developer Mode
|
||||
#### Services
|
||||
|
|
|
|||
219
terraform/litellm/azure/README.md
Normal file
219
terraform/litellm/azure/README.md
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
# LiteLLM on Azure (Container Apps + App Gateway)
|
||||
|
||||
Deploys the componentized LiteLLM proxy on Azure:
|
||||
|
||||
- **VNet** with three isolated subnets: containers (delegated to Azure
|
||||
Container Apps), private endpoints (Postgres / Redis / Storage / Key
|
||||
Vault), Application Gateway
|
||||
- **Azure Database for PostgreSQL Flexible Server**, single instance,
|
||||
with Entra (Azure AD) authentication enabled. Password auth is also
|
||||
enabled for bootstrap
|
||||
- **Azure Cache for Redis** (Basic by default; Standard / Premium
|
||||
recommended for production)
|
||||
- **Storage Account** (private, versioned, TLS 1.2 minimum) with a
|
||||
`proxy` blob container for cache / proxy_config + a `files` blob
|
||||
container for /v1/files passthrough storage
|
||||
- **Key Vault** holding `LITELLM_MASTER_KEY`, the optional license, the
|
||||
optional UI password, and the bootstrap Postgres admin password
|
||||
- **Container Apps Environment** running three apps (`gateway`, port
|
||||
4000; `backend`, port 4001; `ui`, port 3000) plus a one-off
|
||||
`migrations` Container Apps Job that runs `prisma migrate deploy`
|
||||
from the dedicated `ghcr.io/berriai/litellm-migrations` image
|
||||
- **User-assigned managed identity** shared across the three apps + the
|
||||
migrations job, granted: Storage Blob Data Contributor on the storage
|
||||
account, Key Vault Secrets User on the vault
|
||||
- **Application Gateway v2** with path-based routing:
|
||||
- LLM data-plane prefixes (`/v1/chat/*`, `/v1/embeddings`, ...) ->
|
||||
gateway
|
||||
- UI asset paths (`/_next/*`, `/assets/*`, ...) -> ui
|
||||
- Everything else (management API: `/key/*`, `/user/*`, ...) ->
|
||||
backend
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd terraform/litellm/azure/examples/default
|
||||
cp terraform.tfvars.example terraform.tfvars # edit location/tenant/env
|
||||
terraform init && terraform apply
|
||||
```
|
||||
|
||||
After the apply:
|
||||
|
||||
1. As the Entra admin (matches the principal running terraform), run
|
||||
the `db_bootstrap_sql` output once against the Postgres Flexible
|
||||
Server. This creates the `var.db_username` user the proxy will
|
||||
authenticate as via Entra-managed-identity tokens at runtime.
|
||||
2. Run the `migration_run_command` output to trigger the one-off
|
||||
prisma migration Container App Job. The gateway / backend revisions
|
||||
do not auto-redeploy after this; that's intentional so traffic is
|
||||
not cut over mid-migration.
|
||||
3. Point DNS at `app_gateway_fqdn` output. For TLS, set
|
||||
`key_vault_certificate_id` after the initial apply and re-apply; the
|
||||
HTTPS listener picks up the cert automatically.
|
||||
|
||||
## Components
|
||||
|
||||
### `proxy_config` (preferred)
|
||||
|
||||
Mirrors the helm chart's `gateway.config.proxy_config`. The map is
|
||||
YAML-encoded and uploaded to the storage account blob
|
||||
`config/litellm-config.yaml`; the gateway and backend container
|
||||
start scripts download it to `/tmp/litellm-config.yaml` and set
|
||||
`CONFIG_FILE_PATH` to match. Editing the value produces a new blob
|
||||
content and a rolling redeploy of both services.
|
||||
|
||||
```hcl
|
||||
proxy_config = {
|
||||
model_list = [
|
||||
{
|
||||
model_name = "gpt-4o"
|
||||
litellm_params = {
|
||||
model = "openai/gpt-4o"
|
||||
api_key = "os.environ/OPENAI_API_KEY"
|
||||
}
|
||||
},
|
||||
]
|
||||
general_settings = {
|
||||
master_key = "os.environ/LITELLM_MASTER_KEY"
|
||||
database_url = "os.environ/DATABASE_URL"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
LiteLLM resolves `os.environ/<NAME>` references in the YAML against
|
||||
the container's environment. That means provider API keys belong in
|
||||
`*_extra_secrets` (next section), and your YAML just references them
|
||||
by name.
|
||||
|
||||
### Extra env vars
|
||||
|
||||
Non-sensitive plaintext (feature flags, observability hosts, etc.) via
|
||||
`gateway_extra_env` / `backend_extra_env`:
|
||||
|
||||
```hcl
|
||||
gateway_extra_env = {
|
||||
LITELLM_LOG = "INFO"
|
||||
LANGFUSE_PUBLIC_KEY = "pk-lf-..."
|
||||
}
|
||||
|
||||
backend_extra_env = {
|
||||
UI_USERNAME = "admin"
|
||||
}
|
||||
```
|
||||
|
||||
### Extra secrets
|
||||
|
||||
Sensitive env vars (provider API keys) via `gateway_extra_secrets` /
|
||||
`backend_extra_secrets`:
|
||||
|
||||
```hcl
|
||||
gateway_extra_secrets = {
|
||||
OPENAI_API_KEY = azurerm_key_vault_secret.openai.id
|
||||
}
|
||||
```
|
||||
|
||||
The Container Apps managed identity has `Get` / `List` on the Key Vault,
|
||||
so it can read the referenced secret URIs. Create those `*_secret`
|
||||
resources separately and grant the same access policy.
|
||||
|
||||
## Database authentication
|
||||
|
||||
The Container Apps managed identity uses Entra (Azure AD) tokens at
|
||||
runtime against the Postgres Flexible Server. The proxy assembles
|
||||
`DATABASE_URL` from `DATABASE_HOST/PORT/USER/NAME` + a short-lived
|
||||
AAD token minted via `azure-identity`'s `DefaultAzureCredential`. The
|
||||
gateway / backend / migration Container Apps have
|
||||
`DATABASE_AZURE_AUTH=true` in their environment.
|
||||
|
||||
**Break-glass.** The local `litellm_admin` user (password lives in
|
||||
Key Vault as `db-admin-password`) is kept for break-glass repairs. To
|
||||
rotate, use the Azure Portal -> "Reset password" or pass a new
|
||||
`random_password.db_admin_password` resource via a maintenance PR.
|
||||
|
||||
**Prerequisite.** A first-time `terraform apply` requires either
|
||||
`var.litellm_master_key != ""` (the secret is set by the caller) OR
|
||||
that the current terraform principal is granted `Set` / `Get` on the
|
||||
Key Vault (the access policy is created automatically by
|
||||
`keyvault.tf`).
|
||||
|
||||
## Container Apps pull
|
||||
|
||||
By default the stack pulls the four component images from
|
||||
`ghcr.io/berriai/litellm-{gateway,backend,ui,migrations}:<tag>`.
|
||||
|
||||
Azure Container Apps can pull from `ghcr.io` directly without mirror
|
||||
configuration (the AWS stack pulls from ECR post-push, the GCP stack
|
||||
needs an Artifact Registry remote repo, Azure is the closest to a
|
||||
direct pull-from-public-registry experience).
|
||||
|
||||
If you want to pin to a private Azure Container Registry (ACR) instead,
|
||||
push the images there first and override the `image_*` variables:
|
||||
|
||||
```hcl
|
||||
image_registry = "myregistry.azurecr.io"
|
||||
image_namespace = "litellm"
|
||||
image_tag = "v1.86.0"
|
||||
```
|
||||
|
||||
The same role assignment (`AcrPull` for the Container Apps managed
|
||||
identity) is granted automatically at apply time when the registry is
|
||||
an `*.azurecr.io` host.
|
||||
|
||||
## Observability
|
||||
|
||||
Set `otel_endpoint` to an OTLP collector URL to turn on OpenTelemetry
|
||||
v2 instrumentation in both gateway and backend. Service names are
|
||||
stamped per component:
|
||||
|
||||
```
|
||||
${local.name}-gateway
|
||||
${local.name}-backend
|
||||
```
|
||||
|
||||
The current terraform principal can also grant `Monitoring Metrics
|
||||
Publisher` on the same resource group if you want Container Apps
|
||||
metrics to feed to Application Insights.
|
||||
|
||||
## Component sizing defaults
|
||||
|
||||
Defaults work for a small team / production-trail pattern:
|
||||
|
||||
| Component | CPU | Memory | Min replicas | Max replicas |
|
||||
|-----------|-----|--------|--------------|--------------|
|
||||
| gateway | 1.0 | 2Gi | 2 | 10 |
|
||||
| backend | 1.0 | 2Gi | 1 | 5 |
|
||||
| ui | 0.5 | 1Gi | 1 | 3 |
|
||||
| migrations| 1.0 | 2Gi | (job) | (job) |
|
||||
|
||||
Bump these via `gateway_cpu`, `gateway_memory`, etc. The full input list
|
||||
is on the (forthcoming) Terraform Registry page; until then, see
|
||||
`variables.tf` in this directory.
|
||||
|
||||
## State and CI
|
||||
|
||||
Apply from CI? Then pin the constructor's
|
||||
`role_definition_name` to `Owner` or `Contributor + User Access
|
||||
Administrator` on the subscription so the role assignments and
|
||||
Key Vault access policies land. The current terraform principal's
|
||||
object ID is read via `data.azurerm_client_config`; CI runners need to
|
||||
be authenticated as the AAD admin for the bootstrap pattern to work
|
||||
without manual steps.
|
||||
|
||||
## Known gaps vs AWS / GCP
|
||||
|
||||
- Azure Container Apps does not natively integrate with managed
|
||||
Postgres IAM tokens the way Aurora + RDS-iam-token does on AWS.
|
||||
Entra-managed-identity tokens at the application layer are the
|
||||
equivalent. The `proxy_config` blob is the only state shared
|
||||
between the two write paths; everything else is per-component env.
|
||||
- Application Gateway WAF v2 is not enabled in this baseline. To turn
|
||||
it on, swap the SKU `Standard_v2` to `WAF_v2` and add WAF policy
|
||||
resources. The path rule structure does not change.
|
||||
- Redis SSL on Azure: the `rediss://` URL is built from the SSL port
|
||||
(default 6380). The proxy uses `REDIS_SSL=true` to flip the scheme,
|
||||
matching AWS module's `transit_encryption_enabled = true`.
|
||||
- Migration ordering: the AWS stack uses `depends_on` chains via
|
||||
ECS task ordering; Azure Container Apps Jobs are not part of ACA
|
||||
service ordering. The migration is run as a manual one-shot AFTER
|
||||
the gateway / backend services are deployed. The README's
|
||||
Quick-start ordering covers this.
|
||||
315
terraform/litellm/azure/compute.tf
Normal file
315
terraform/litellm/azure/compute.tf
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
# Container Apps Environment (one per stack) + the three componentized
|
||||
# apps (gateway / backend / ui) plus the migrations job.
|
||||
#
|
||||
# The Azure equivalent of AWS ECS Fargate is Azure Container Apps: a
|
||||
# serverless managed-Kubernetes-ish runtime that runs containers, has
|
||||
# built-in HTTPS ingress with cert-managed-by-Azure, autoscaling, and
|
||||
# native Azure AD / managed-identity support. The ACA infrastructure
|
||||
# subnet must be /23 or larger; we already carved a /20 in network.tf.
|
||||
|
||||
# ---------- Shared environment ----------
|
||||
#
|
||||
# Observability: when var.otel_endpoint is set, OTel instrumentation
|
||||
# turns on for both gateway and backend. Service names match the AWS
|
||||
# stack so spans land in the right bucket.
|
||||
|
||||
locals {
|
||||
otel_enabled = var.otel_endpoint != ""
|
||||
otel_environment_name = var.otel_environment_name != "" ? var.otel_environment_name : var.env
|
||||
otel_shared_env_raw = local.otel_enabled ? [
|
||||
{ name = "LITELLM_OTEL_V2", value = "true" },
|
||||
{ name = "OTEL_EXPORTER", value = var.otel_exporter },
|
||||
{ name = "OTEL_ENDPOINT", value = var.otel_endpoint },
|
||||
{ name = "OTEL_ENVIRONMENT_NAME", value = local.otel_environment_name },
|
||||
{ name = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", value = var.otel_capture_message_content },
|
||||
] : []
|
||||
gateway_otel_env = [
|
||||
for e in local.otel_shared_env_raw : e if !contains(keys(var.gateway_extra_env), e.name)
|
||||
]
|
||||
backend_otel_env = [
|
||||
for e in local.otel_shared_env_raw : e if !contains(keys(var.backend_extra_env), e.name)
|
||||
]
|
||||
|
||||
# Shared Postgres + Redis + Storage env, fed to gateway / backend /
|
||||
# migrations. Matches the AWS module's `shared_env` shape for those
|
||||
# values that map to Azure equivalents.
|
||||
shared_pg_env = [
|
||||
{ name = "DATABASE_HOST", value = azurerm_postgresql_flexible_server.this.fqdn },
|
||||
{ name = "DATABASE_PORT", value = "5432" },
|
||||
{ name = "DATABASE_USER", value = var.db_username },
|
||||
{ name = "DATABASE_NAME", value = var.db_name },
|
||||
# Azure uses Entra-managed-identity token auth at runtime
|
||||
# (azure-identity's DefaultAzureCredential). The proxy's
|
||||
# init_azure_db_url_from_env equivalent mints a short-lived token and
|
||||
# assembles DATABASE_URL when DATABASE_AZURE_AUTH=true is set.
|
||||
{ name = "DATABASE_AZURE_AUTH", value = "true" },
|
||||
]
|
||||
shared_redis_env = [
|
||||
{ name = "REDIS_HOST", value = azurerm_redis_cache.this.hostname },
|
||||
{ name = "REDIS_PORT", value = tostring(azurerm_redis_cache.this.ssl_port) },
|
||||
{ name = "REDIS_SSL", value = "true" },
|
||||
]
|
||||
shared_storage_env = [
|
||||
# LiteLLM reads AZURE_STORAGE_ACCOUNT_NAME and the AZURE_BLOB_*
|
||||
# container env vars. Auth is via the Container Apps managed identity
|
||||
# (Storage Blob Data Contributor role, see iam.tf).
|
||||
{ name = "AZURE_STORAGE_ACCOUNT_NAME", value = azurerm_storage_account.this.name },
|
||||
{ name = "AZURE_BLOB_STORAGE_ACCOUNT_NAME", value = azurerm_storage_account.this.name },
|
||||
{ name = "AZURE_BLOB_STORAGE_CONTAINER_NAME", value = azurerm_storage_container.proxy.name },
|
||||
{ name = "AZURE_FILE_STORAGE_CONTAINER_NAME", value = azurerm_storage_container.files.name },
|
||||
# CONFIG_FILE_PATH is read by the LiteLLM entrypoint to know where
|
||||
# to download the uploaded proxy_config.yaml blob to.
|
||||
{ name = "CONFIG_FILE_PATH", value = "/tmp/litellm-config.yaml" },
|
||||
]
|
||||
|
||||
# Combine env plus extra_env (caller override) for each component.
|
||||
gateway_base_env = concat(
|
||||
local.shared_pg_env,
|
||||
local.shared_redis_env,
|
||||
local.shared_storage_env,
|
||||
local.gateway_otel_env,
|
||||
)
|
||||
backend_base_env = concat(
|
||||
local.shared_pg_env,
|
||||
local.shared_redis_env,
|
||||
local.shared_storage_env,
|
||||
local.backend_otel_env,
|
||||
)
|
||||
migrations_base_env = concat(
|
||||
local.shared_pg_env,
|
||||
local.shared_redis_env,
|
||||
)
|
||||
|
||||
# Reference secret IDs (Key Vault secret URIs) for env vars that come
|
||||
# from Key Vault instead of plaintext. Each entry becomes a valueFrom
|
||||
# in the Container App env block.
|
||||
gateway_kv_secret_env = merge(
|
||||
{
|
||||
LITELLM_MASTER_KEY = azurerm_key_vault_secret.master_key.id
|
||||
},
|
||||
var.litellm_license != "" ? { LITELLM_LICENSE = one(azurerm_key_vault_secret.license[*].id) } : {},
|
||||
var.gateway_extra_secrets,
|
||||
)
|
||||
backend_kv_secret_env = merge(
|
||||
{
|
||||
LITELLM_MASTER_KEY = azurerm_key_vault_secret.master_key.id,
|
||||
},
|
||||
var.ui_password != "" ? { UI_PASSWORD = one(azurerm_key_vault_secret.ui_password[*].id) } : {},
|
||||
var.litellm_license != "" ? { LITELLM_LICENSE = one(azurerm_key_vault_secret.license[*].id) } : {},
|
||||
var.backend_extra_secrets,
|
||||
)
|
||||
migrations_kv_secret_env = {
|
||||
LITELLM_MASTER_KEY = azurerm_key_vault_secret.master_key.id
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- Container Apps Environment ----------
|
||||
|
||||
resource "azurerm_container_app_environment" "this" {
|
||||
name = "${local.name}-cae"
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
tags = local.tags
|
||||
|
||||
infrastructure_subnet_id = azurerm_subnet.containers.id
|
||||
|
||||
depends_on = [azurerm_role_assignment.container_apps_env_infra]
|
||||
}
|
||||
|
||||
# ---------- Log Analytics workspace ----------
|
||||
#
|
||||
# Container Apps stdout/stderr route to Log Analytics. The default
|
||||
# workspace created here is per-stack; callers that want to centralize
|
||||
# logs can pass `log_analytics_workspace_id` (see variables) but that's
|
||||
# out of scope for the baseline.
|
||||
|
||||
resource "azurerm_log_analytics_workspace" "this" {
|
||||
name = "${local.name}-logs"
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
sku = "PerGB2018"
|
||||
retention_in_days = var.log_retention_days
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# ---------- gateway ----------
|
||||
|
||||
resource "azurerm_container_app" "gateway" {
|
||||
name = "${local.name}-gateway"
|
||||
container_app_environment_id = azurerm_container_app_environment.this.id
|
||||
resource_group_name = local.resource_group_name
|
||||
revision_mode = "Single"
|
||||
tags = local.tags
|
||||
|
||||
identity {
|
||||
type = "UserAssigned"
|
||||
identity_ids = [azurerm_user_assigned_identity.container_apps.id]
|
||||
}
|
||||
|
||||
# The gateway is internal-only; the Application Gateway front ends it
|
||||
# and fronts the UI; the backend is reachable from gateway + ui only.
|
||||
ingress {
|
||||
allow_insecure_connections = false
|
||||
external_enabled = false
|
||||
target_port = 4000
|
||||
transport = "auto"
|
||||
|
||||
traffic_weight {
|
||||
latest_revision = true
|
||||
percentage = 100
|
||||
}
|
||||
}
|
||||
|
||||
registry {
|
||||
server = var.image_registry
|
||||
}
|
||||
|
||||
template {
|
||||
min_replicas = var.gateway_min_replicas
|
||||
max_replicas = var.gateway_max_replicas
|
||||
|
||||
container {
|
||||
name = "gateway"
|
||||
image = local.gateway_image_resolved
|
||||
cpu = var.gateway_cpu
|
||||
memory = var.gateway_memory
|
||||
|
||||
dynamic "env" {
|
||||
for_each = concat(
|
||||
local.gateway_base_env,
|
||||
[for k, v in var.gateway_extra_env : { name = k, value = v }],
|
||||
)
|
||||
content {
|
||||
name = env.value.name
|
||||
value = env.value.value
|
||||
}
|
||||
}
|
||||
|
||||
# Key Vault references resolved at startup using the managed identity.
|
||||
dynamic "env" {
|
||||
for_each = { for k, v in local.gateway_kv_secret_env : k => v }
|
||||
content {
|
||||
name = env.key
|
||||
secret_value = env.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [
|
||||
azurerm_key_vault_access_policy.container_apps,
|
||||
]
|
||||
}
|
||||
|
||||
# ---------- backend ----------
|
||||
|
||||
resource "azurerm_container_app" "backend" {
|
||||
name = "${local.name}-backend"
|
||||
container_app_environment_id = azurerm_container_app_environment.this.id
|
||||
resource_group_name = local.resource_group_name
|
||||
revision_mode = "Single"
|
||||
tags = local.tags
|
||||
|
||||
identity {
|
||||
type = "UserAssigned"
|
||||
identity_ids = [azurerm_user_assigned_identity.container_apps.id]
|
||||
}
|
||||
|
||||
ingress {
|
||||
allow_insecure_connections = false
|
||||
external_enabled = false
|
||||
target_port = 4001
|
||||
transport = "auto"
|
||||
|
||||
traffic_weight {
|
||||
latest_revision = true
|
||||
percentage = 100
|
||||
}
|
||||
}
|
||||
|
||||
registry {
|
||||
server = var.image_registry
|
||||
}
|
||||
|
||||
template {
|
||||
min_replicas = var.backend_min_replicas
|
||||
max_replicas = var.backend_max_replicas
|
||||
|
||||
container {
|
||||
name = "backend"
|
||||
image = local.backend_image_resolved
|
||||
cpu = var.backend_cpu
|
||||
memory = var.backend_memory
|
||||
|
||||
dynamic "env" {
|
||||
for_each = concat(
|
||||
local.backend_base_env,
|
||||
[for k, v in var.backend_extra_env : { name = k, value = v }],
|
||||
)
|
||||
content {
|
||||
name = env.value.name
|
||||
value = env.value.value
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "env" {
|
||||
for_each = { for k, v in local.backend_kv_secret_env : k => v }
|
||||
content {
|
||||
name = env.key
|
||||
secret_value = env.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- ui ----------
|
||||
|
||||
resource "azurerm_container_app" "ui" {
|
||||
name = "${local.name}-ui"
|
||||
container_app_environment_id = azurerm_container_app_environment.this.id
|
||||
resource_group_name = local.resource_group_name
|
||||
revision_mode = "Single"
|
||||
tags = local.tags
|
||||
|
||||
identity {
|
||||
type = "UserAssigned"
|
||||
identity_ids = [azurerm_user_assigned_identity.container_apps.id]
|
||||
}
|
||||
|
||||
ingress {
|
||||
allow_insecure_connections = false
|
||||
external_enabled = false
|
||||
target_port = 3000
|
||||
transport = "auto"
|
||||
|
||||
traffic_weight {
|
||||
latest_revision = true
|
||||
percentage = 100
|
||||
}
|
||||
}
|
||||
|
||||
registry {
|
||||
server = var.image_registry
|
||||
}
|
||||
|
||||
template {
|
||||
min_replicas = var.ui_min_replicas
|
||||
max_replicas = var.ui_max_replicas
|
||||
|
||||
container {
|
||||
name = "ui"
|
||||
image = local.ui_image_resolved
|
||||
cpu = var.ui_cpu
|
||||
memory = var.ui_memory
|
||||
|
||||
dynamic "env" {
|
||||
for_each = var.ui_extra_env
|
||||
content {
|
||||
name = env.key
|
||||
value = env.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
terraform/litellm/azure/examples/default/.terraform.lock.hcl
generated
Normal file
43
terraform/litellm/azure/examples/default/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/azurerm" {
|
||||
version = "3.117.1"
|
||||
constraints = "~> 3.117"
|
||||
hashes = [
|
||||
"h1:j6wnjpHfBcQC4xd3ZYquaIPIIR46xJQs7rxwPdSOZos=",
|
||||
"zh:0c513676836e3c50d004ece7d2624a8aff6faac14b833b96feeac2e4bc2c1c12",
|
||||
"zh:50ea01ada95bae2f187db9e926e463f45d860767a85ebc59160414e00e76c35d",
|
||||
"zh:52c2a9edacc06b3f72153f5ef6daca0761c6292158815961fe37f60bc576a3d7",
|
||||
"zh:618eed2a06b19b1a025b45b05891846d570a6a1cca4d23f4942f5a99e1f747ae",
|
||||
"zh:61cde5d3165d7e5ec311d5d89486819cd605c1b2d54611b5c97bd4e97dba2762",
|
||||
"zh:6a873358d5031fc222f5e05f029d1237f3dce8345c767665f393283dfa2627f6",
|
||||
"zh:afdd80064b2a04da311856feb4ed45f77ff4df6c356e8c2b10afb51fe7e61c70",
|
||||
"zh:b09113df7e0e8c8959539bd22bae6c39faeb269ba3c4cd948e742f5cf58c35fb",
|
||||
"zh:d340db7973109761cfc27d52aa02560363337c908b2c99b3628adc5a70a99d5b",
|
||||
"zh:d5a577226ebc8c65e8f19384878a86acc4b51ede4b4a82d37c3b331b0efcd4a7",
|
||||
"zh:e2962b147f9e71732df8dbc74940c10d20906f3c003cbfaa1eb9fabbf601a9f0",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/random" {
|
||||
version = "3.9.0"
|
||||
constraints = "~> 3.6"
|
||||
hashes = [
|
||||
"h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=",
|
||||
"zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1",
|
||||
"zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea",
|
||||
"zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f",
|
||||
"zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0",
|
||||
"zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61",
|
||||
"zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc",
|
||||
"zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e",
|
||||
"zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef",
|
||||
"zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b",
|
||||
"zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257",
|
||||
"zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04",
|
||||
]
|
||||
}
|
||||
43
terraform/litellm/azure/examples/default/main.tf
Normal file
43
terraform/litellm/azure/examples/default/main.tf
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# One-command deploy of the LiteLLM Azure stack.
|
||||
#
|
||||
# cd terraform/litellm/azure/examples/default
|
||||
# cp terraform.tfvars.example terraform.tfvars # edit it
|
||||
# terraform init
|
||||
# terraform apply
|
||||
#
|
||||
# This root just wires the provider (see providers.tf) to the module. The
|
||||
# module itself (../../) declares no provider, so it can also be consumed
|
||||
# from your own config with count/for_each/aliased providers:
|
||||
#
|
||||
# module "litellm" {
|
||||
# source = "github.com/BerriAI/litellm//terraform/litellm/azure?ref=<tag>"
|
||||
# ...
|
||||
# }
|
||||
#
|
||||
# Knobs not surfaced as variables here (per-component sizing, autoscaling,
|
||||
# Postgres / Redis / Storage tuning) can be set directly on this block:
|
||||
# see ../../variables.tf.
|
||||
|
||||
module "litellm" {
|
||||
source = "../../"
|
||||
|
||||
location = var.location
|
||||
tenant = var.tenant
|
||||
env = var.env
|
||||
azs = var.azs
|
||||
|
||||
litellm_master_key = var.litellm_master_key
|
||||
litellm_license = var.litellm_license
|
||||
ui_password = var.ui_password
|
||||
|
||||
key_vault_certificate_id = var.key_vault_certificate_id
|
||||
allow_plaintext_app_gateway = var.allow_plaintext_app_gateway
|
||||
|
||||
storage_force_destroy = var.storage_force_destroy
|
||||
|
||||
proxy_config = var.proxy_config
|
||||
gateway_extra_env = {}
|
||||
backend_extra_env = {}
|
||||
gateway_extra_secrets = {}
|
||||
backend_extra_secrets = {}
|
||||
}
|
||||
8
terraform/litellm/azure/examples/default/providers.tf
Normal file
8
terraform/litellm/azure/examples/default/providers.tf
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
provider "azurerm" {
|
||||
features {}
|
||||
# Default: subscription_id is read from the environment (`ARM_SUBSCRIPTION_ID`).
|
||||
# Explicit override: uncomment and set the subscription_id below.
|
||||
# subscription_id = "00000000-0000-0000-0000-000000000000"
|
||||
}
|
||||
|
||||
provider "random" {}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
location = "eastus"
|
||||
tenant = "acme"
|
||||
env = "stage"
|
||||
azs = ["1", "2"]
|
||||
|
||||
# Optional: pre-existing LiteLLM master key (must begin with `sk-`). Leave
|
||||
# empty to have the stack auto-generate a random `sk-...` value and store
|
||||
# it in Key Vault.
|
||||
# litellm_master_key = "sk-..."
|
||||
|
||||
# Optional: enterprise license (omit for OSS-only).
|
||||
# litellm_license = "..."
|
||||
|
||||
# Optional: UI admin password (omit to fall back to LITELLM_MASTER_KEY).
|
||||
# ui_password = "..."
|
||||
|
||||
# Optional: Key Vault certificate ID for the App Gateway HTTPS listener.
|
||||
# Either set this OR set allow_plaintext_app_gateway = true (dev only).
|
||||
# key_vault_certificate_id = "https://acmestagekv.vault.azure.net/certificates/litellm-cert/<version>"
|
||||
allow_plaintext_app_gateway = true
|
||||
|
||||
# Optional: tell `terraform destroy` to remove a non-empty storage
|
||||
# container (and any uploaded files). Off by default.
|
||||
storage_force_destroy = false
|
||||
|
||||
# Optional: enable proxy config with model list. Triggers a Container App
|
||||
# revision swap when changed.
|
||||
proxy_config = {
|
||||
model_list = [
|
||||
{
|
||||
model_name = "gpt-4o"
|
||||
litellm_params = {
|
||||
model = "openai/gpt-4o"
|
||||
api_key = "os.environ/OPENAI_API_KEY"
|
||||
}
|
||||
},
|
||||
]
|
||||
general_settings = {
|
||||
master_key = "os.environ/LITELLM_MASTER_KEY"
|
||||
database_url = "os.environ/DATABASE_URL"
|
||||
}
|
||||
}
|
||||
82
terraform/litellm/azure/examples/default/variables.tf
Normal file
82
terraform/litellm/azure/examples/default/variables.tf
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
variable "location" {
|
||||
description = "Azure region to deploy into."
|
||||
type = string
|
||||
default = "eastus"
|
||||
}
|
||||
|
||||
variable "tenant" {
|
||||
description = "Tenant slug used as the prefix for every Azure resource the stack creates."
|
||||
type = string
|
||||
default = "acme"
|
||||
}
|
||||
|
||||
variable "env" {
|
||||
description = "Environment suffix (e.g. `stage`, `prod`, `dev`)."
|
||||
type = string
|
||||
default = "stage"
|
||||
}
|
||||
|
||||
variable "azs" {
|
||||
description = "Azure availability zone identifiers (e.g. [\"1\", \"2\"])."
|
||||
type = list(string)
|
||||
default = ["1", "2"]
|
||||
}
|
||||
|
||||
variable "litellm_master_key" {
|
||||
description = "Pre-existing LiteLLM master key (must begin `sk-`). Leave empty to have the stack auto-generate."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "litellm_license" {
|
||||
description = "Optional LiteLLM enterprise license."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "ui_password" {
|
||||
description = "Optional UI admin password."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "key_vault_certificate_id" {
|
||||
description = "Resource ID of an existing Key Vault certificate for the App Gateway HTTPS listener. Leave empty to deploy HTTP-only (set `allow_plaintext_app_gateway = true` too)."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "allow_plaintext_app_gateway" {
|
||||
description = "Allow the Application Gateway to serve HTTP without TLS. Dev / trial only."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "storage_force_destroy" {
|
||||
description = "Allow `terraform destroy` to remove a non-empty storage container."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "proxy_config" {
|
||||
description = "LiteLLM proxy config map (mirrors helm chart gateway.config.proxy_config)."
|
||||
type = any
|
||||
default = {
|
||||
model_list = [
|
||||
{
|
||||
model_name = "gpt-4o"
|
||||
litellm_params = {
|
||||
model = "openai/gpt-4o"
|
||||
api_key = "os.environ/OPENAI_API_KEY"
|
||||
}
|
||||
},
|
||||
]
|
||||
general_settings = {
|
||||
master_key = "os.environ/LITELLM_MASTER_KEY"
|
||||
database_url = "os.environ/DATABASE_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
terraform/litellm/azure/examples/default/versions.tf
Normal file
14
terraform/litellm/azure/examples/default/versions.tf
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
azurerm = {
|
||||
source = "hashicorp/azurerm"
|
||||
version = "~> 3.117"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = "~> 3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
55
terraform/litellm/azure/iam.tf
Normal file
55
terraform/litellm/azure/iam.tf
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# ---------- Managed identities ----------
|
||||
#
|
||||
# One user-assigned managed identity shared across gateway / backend /
|
||||
# ui / migrations Container Apps. Same identity is referenced as
|
||||
# `azurerm_container_app.identity` -> user-assigned and granted the
|
||||
# minimal set of role assignments below:
|
||||
# - AcrPull on the Container Apps managed registry
|
||||
# - Storage Blob Data on the Storage Account (cache / files)
|
||||
# - Key Vault Secrets on the Key Vault (read master key + secrets)
|
||||
# - Reader on its own resource group (private endpoint lookups)
|
||||
|
||||
resource "azurerm_user_assigned_identity" "container_apps" {
|
||||
name = "${local.name}-mi"
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# ---------- Role assignments ----------
|
||||
#
|
||||
# Granted inside the same module so a fresh `terraform apply` ends with a
|
||||
# fully-wired stack; nothing to add post-apply aside from `db_bootstrap_sql`.
|
||||
|
||||
resource "azurerm_role_assignment" "storage_blob_data_contributor" {
|
||||
scope = azurerm_storage_account.this.id
|
||||
role_definition_name = "Storage Blob Data Contributor"
|
||||
principal_id = azurerm_user_assigned_identity.container_apps.principal_id
|
||||
principal_type = "ServicePrincipal"
|
||||
}
|
||||
|
||||
# Key Vault access is granted via access policies (data actions, scoped
|
||||
# per-secret); see keyvault.tf.
|
||||
|
||||
# ---------- Container Apps Environment infrastructure role ----------
|
||||
#
|
||||
# The ACA environment requires a "managed identity" with the
|
||||
# "Container Apps Environment Managed Identity" role on its own resource
|
||||
# group (defined by Microsoft.App) so it can read ACR images and pull
|
||||
# from the registry. We grant the user-assigned MI that role once.
|
||||
# (For modular simplicity the ACA managed identity is the same identity
|
||||
# we already created above.)
|
||||
|
||||
resource "azurerm_role_assignment" "container_apps_env_infra" {
|
||||
scope = local.resource_group_id
|
||||
role_definition_name = "Container Apps Environment Managed Identity Contributor"
|
||||
principal_id = azurerm_user_assigned_identity.container_apps.principal_id
|
||||
principal_type = "ServicePrincipal"
|
||||
}
|
||||
|
||||
locals {
|
||||
# Resolve the resource-group ID whether the caller supplied an existing
|
||||
# RG (no resource here) or the module created one. Falls back to the
|
||||
# resource's own id if a known format conversion is needed.
|
||||
resource_group_id = var.resource_group_name == "" ? azurerm_resource_group.this[0].id : "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${var.resource_group_name}"
|
||||
}
|
||||
169
terraform/litellm/azure/keyvault.tf
Normal file
169
terraform/litellm/azure/keyvault.tf
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Key Vault holds the proxy's secret material:
|
||||
# - master-key secret (LITELLM_MASTER_KEY)
|
||||
# - optional license (LITELLM_LICENSE)
|
||||
# - optional UI password (UI_PASSWORD)
|
||||
# - Postgres admin password (PG_ADMIN_PASSWORD) for the bootstrap step
|
||||
#
|
||||
# The Container Apps managed identity is granted per-secret access via
|
||||
# access policies. Caller-supplied secrets (`gateway_extra_secrets`,
|
||||
# `backend_extra_secrets`) are referenced by their secret IDs and not
|
||||
# stored in this module; callers wire provider keys with their own
|
||||
# `azurerm_key_vault_secret` resources (or supply them externally).
|
||||
|
||||
resource "random_password" "db_admin_password" {
|
||||
length = 32
|
||||
special = true
|
||||
override_special = "@%*_+-:?#"
|
||||
}
|
||||
|
||||
resource "random_password" "litellm_master_key" {
|
||||
length = 43
|
||||
special = false
|
||||
}
|
||||
|
||||
# Generate the master key with the `sk-` prefix the proxy expects.
|
||||
locals {
|
||||
litellm_master_key_value = var.litellm_master_key != "" ? var.litellm_master_key : "sk-${substr(random_password.litellm_master_key.result, 0, 43)}"
|
||||
}
|
||||
|
||||
resource "azurerm_key_vault" "this" {
|
||||
name = replace("${var.tenant}${var.env}kv", "-", "") # no dashes allowed in KV name; truncated by Azure
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
tenant_id = data.azurerm_client_config.current.tenant_id
|
||||
sku_name = "standard"
|
||||
tags = local.tags
|
||||
|
||||
# Networking: lock down to the VNet's private endpoint subnet. Caller's
|
||||
# terraform principal needs to also be granted access to add secrets.
|
||||
public_network_access_enabled = false
|
||||
enable_rbac_authorization = false
|
||||
enabled_for_deployment = false
|
||||
enabled_for_disk_encryption = false
|
||||
enabled_for_template_deployment = false
|
||||
purge_protection_enabled = true
|
||||
soft_delete_retention_days = 7
|
||||
|
||||
network_acls {
|
||||
bypass = "AzureServices"
|
||||
default_action = "Deny"
|
||||
ip_rules = []
|
||||
virtual_network_subnet_ids = [
|
||||
azurerm_subnet.private_endpoints.id,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- Key Vault access policies ----------
|
||||
#
|
||||
# Three identities get secret access:
|
||||
# 1. The Container Apps managed identity (read-only on all secrets)
|
||||
# 2. The current terraform principal (so `terraform apply` can add
|
||||
# secrets during creation; matches the AWS module's bootstrap pattern)
|
||||
# 3. Possibly AAD admins for break-glass (not configured by default)
|
||||
|
||||
resource "azurerm_key_vault_access_policy" "container_apps" {
|
||||
key_vault_id = azurerm_key_vault.this.id
|
||||
tenant_id = data.azurerm_client_config.current.tenant_id
|
||||
object_id = azurerm_user_assigned_identity.container_apps.principal_id
|
||||
|
||||
secret_permissions = [
|
||||
"Get", "List",
|
||||
]
|
||||
}
|
||||
|
||||
resource "azurerm_key_vault_access_policy" "terraform" {
|
||||
key_vault_id = azurerm_key_vault.this.id
|
||||
tenant_id = data.azurerm_client_config.current.tenant_id
|
||||
object_id = data.azurerm_client_config.current.object_id
|
||||
|
||||
secret_permissions = [
|
||||
"Get", "List", "Set", "Delete", "Purge", "Recover",
|
||||
]
|
||||
}
|
||||
|
||||
# ---------- Secrets ----------
|
||||
#
|
||||
# The DB admin password lives in the vault and is consumed by the
|
||||
# bootstrap job (which runs `psql` once to create the Entra token-auth
|
||||
# app user; see `bootstrap.tf` / `iam.tf`). The proxy itself never sees
|
||||
# it; gateway/backend/migrations use Entra tokens via the Container
|
||||
# Apps managed identity.
|
||||
|
||||
resource "azurerm_key_vault_secret" "db_admin_password" {
|
||||
name = "db-admin-password"
|
||||
value = random_password.db_admin_password.result
|
||||
key_vault_id = azurerm_key_vault.this.id
|
||||
|
||||
depends_on = [azurerm_key_vault_access_policy.terraform]
|
||||
}
|
||||
|
||||
resource "azurerm_key_vault_secret" "master_key" {
|
||||
name = "litellm-master-key"
|
||||
value = local.litellm_master_key_value
|
||||
key_vault_id = azurerm_key_vault.this.id
|
||||
tags = local.tags
|
||||
|
||||
depends_on = [azurerm_key_vault_access_policy.terraform]
|
||||
}
|
||||
|
||||
resource "azurerm_key_vault_secret" "license" {
|
||||
count = var.litellm_license != "" ? 1 : 0
|
||||
name = "litellm-license"
|
||||
value = var.litellm_license
|
||||
key_vault_id = azurerm_key_vault.this.id
|
||||
tags = local.tags
|
||||
|
||||
depends_on = [azurerm_key_vault_access_policy.terraform]
|
||||
}
|
||||
|
||||
resource "azurerm_key_vault_secret" "ui_password" {
|
||||
count = var.ui_password != "" ? 1 : 0
|
||||
name = "ui-password"
|
||||
value = var.ui_password
|
||||
key_vault_id = azurerm_key_vault.this.id
|
||||
tags = local.tags
|
||||
|
||||
depends_on = [azurerm_key_vault_access_policy.terraform]
|
||||
}
|
||||
|
||||
# ---------- Private endpoint ----------
|
||||
#
|
||||
# Pulls the vault onto a private IP in `private_endpoints` subnet so the
|
||||
# Container Apps don't need public access to reach secrets.
|
||||
|
||||
resource "azurerm_private_endpoint" "keyvault" {
|
||||
name = "${local.name}-kv-pe"
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
subnet_id = azurerm_subnet.private_endpoints.id
|
||||
tags = local.tags
|
||||
|
||||
private_service_connection {
|
||||
name = "${local.name}-kv"
|
||||
private_connection_resource_id = azurerm_key_vault.this.id
|
||||
is_manual_connection = false
|
||||
subresource_names = ["vault"]
|
||||
}
|
||||
|
||||
private_dns_zone_group {
|
||||
name = "default"
|
||||
private_dns_zone_ids = [azurerm_private_dns_zone.keyvault.id]
|
||||
}
|
||||
|
||||
depends_on = [azurerm_private_dns_zone_virtual_network_link.keyvault]
|
||||
}
|
||||
|
||||
resource "azurerm_private_dns_zone" "keyvault" {
|
||||
name = "privatelink.vaultcore.azure.net"
|
||||
resource_group_name = local.resource_group_name
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "azurerm_private_dns_zone_virtual_network_link" "keyvault" {
|
||||
name = "${local.name}-kv-dnslink"
|
||||
resource_group_name = local.resource_group_name
|
||||
private_dns_zone_name = azurerm_private_dns_zone.keyvault.name
|
||||
virtual_network_id = azurerm_virtual_network.this.id
|
||||
registration_enabled = false
|
||||
}
|
||||
337
terraform/litellm/azure/loadbalancer.tf
Normal file
337
terraform/litellm/azure/loadbalancer.tf
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
# Application Gateway: the Azure equivalent of AWS ALB. Public-facing;
|
||||
# path-based routing that mirrors the AWS stack:
|
||||
# - LLM data-plane paths (`/v1/*`, `/chat/*`, ...full list in
|
||||
# locals.gateway_path_prefixes) -> gateway backend pool
|
||||
# - UI asset paths (`/_next/*`, `/assets/*`, ...) -> ui backend pool
|
||||
# - everything else (management API) -> backend backend pool
|
||||
#
|
||||
# TLS termination happens at the gateway when `key_vault_certificate_id`
|
||||
# is supplied. Otherwise plaintext is only allowed when
|
||||
# `allow_plaintext_app_gateway` is true.
|
||||
#
|
||||
# Container Apps ingress is internal-only (no external_enabled). The App
|
||||
# Gateway reaches them via the Container Apps Environment default domain
|
||||
# (e.g. `<leaf>.azurecontainerapps.io`) on the public DNS.
|
||||
|
||||
# ---------- Subnet for the Application Gateway ----------
|
||||
|
||||
resource "azurerm_subnet" "app_gateway" {
|
||||
name = "app-gateway"
|
||||
resource_group_name = local.resource_group_name
|
||||
virtual_network_name = azurerm_virtual_network.this.name
|
||||
address_prefixes = [cidrsubnet(var.vnet_cidr, 4, 2)] # /20 portion, isolated from container workloads
|
||||
service_endpoints = ["Microsoft.Storage"]
|
||||
}
|
||||
|
||||
resource "azurerm_network_security_group" "app_gateway" {
|
||||
name = "${local.name}-appgw-nsg"
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# App Gateway requires explicit public-inbound rules on ports 80/443.
|
||||
resource "azurerm_network_security_rule" "app_gateway_http_in" {
|
||||
count = var.allow_plaintext_app_gateway ? 1 : 0
|
||||
name = "http-in"
|
||||
priority = 100
|
||||
direction = "Inbound"
|
||||
access = "Allow"
|
||||
protocol = "Tcp"
|
||||
source_port_range = "*"
|
||||
destination_port_range = "80"
|
||||
source_address_prefix = "*"
|
||||
destination_address_prefix = "*"
|
||||
resource_group_name = local.resource_group_name
|
||||
network_security_group_name = azurerm_network_security_group.app_gateway.name
|
||||
}
|
||||
|
||||
resource "azurerm_network_security_rule" "app_gateway_https_in" {
|
||||
name = "https-in"
|
||||
priority = 110
|
||||
direction = "Inbound"
|
||||
access = "Allow"
|
||||
protocol = "Tcp"
|
||||
source_port_range = "*"
|
||||
destination_port_range = "443"
|
||||
source_address_prefix = "*"
|
||||
destination_address_prefix = "*"
|
||||
resource_group_name = local.resource_group_name
|
||||
network_security_group_name = azurerm_network_security_group.app_gateway.name
|
||||
}
|
||||
|
||||
resource "azurerm_subnet_network_security_group_association" "app_gateway" {
|
||||
subnet_id = azurerm_subnet.app_gateway.id
|
||||
network_security_group_id = azurerm_network_security_group.app_gateway.id
|
||||
}
|
||||
|
||||
# ---------- Public IP ----------
|
||||
|
||||
resource "azurerm_public_ip" "app_gateway" {
|
||||
name = "${local.name}-appgw-pip"
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
sku = "Standard"
|
||||
allocation_method = "Static"
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# ---------- Gateway FQDN lookup ----------
|
||||
#
|
||||
# Container Apps with internal ingress expose a private FQDN (only
|
||||
# resolvable from inside the VNet). We feed those FQDNs into the App
|
||||
# Gateway backend pools.
|
||||
locals {
|
||||
gateway_fqdn = azurerm_container_app.gateway.ingress[0].fqdn
|
||||
backend_fqdn = azurerm_container_app.backend.ingress[0].fqdn
|
||||
ui_fqdn = azurerm_container_app.ui.ingress[0].fqdn
|
||||
}
|
||||
|
||||
# ---------- URL path map ----------
|
||||
#
|
||||
# Application Gateway URL path map contains paths -> backend pools. We
|
||||
# build one rule that combines gateway and ui prefixes into a single
|
||||
# path rule (Application Gateway allows multiple paths per rule via the
|
||||
# `paths` list). The default backend is `backend`.
|
||||
#
|
||||
# The redirect_listen_priority uses 100 for the path map and 200 for
|
||||
# the default backend redirect rule.
|
||||
|
||||
resource "azurerm_application_gateway" "this" {
|
||||
name = "${local.name}-appgw"
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
tags = local.tags
|
||||
|
||||
sku {
|
||||
name = "Standard_v2"
|
||||
tier = "Standard_v2"
|
||||
capacity = 2
|
||||
}
|
||||
|
||||
# WAF v2 is the standard tier's upgrade; this baseline keeps the SKU
|
||||
# minimal. Callers needing WAF can swap the SKU without re-creating
|
||||
# the gateway (a single azurerm update).
|
||||
enable_http2 = true
|
||||
|
||||
frontend_port {
|
||||
name = "http"
|
||||
port = 80
|
||||
}
|
||||
frontend_port {
|
||||
name = "https"
|
||||
port = 443
|
||||
}
|
||||
|
||||
# Gateway-level IP config: the subnet the App Gateway instance listens
|
||||
# on (a dedicated subnet with `Microsoft.Network/applicationGateways`
|
||||
# delegated). Required even when the listener uses public_ip_address_id.
|
||||
gateway_ip_configuration {
|
||||
name = "gateway"
|
||||
subnet_id = azurerm_subnet.app_gateway.id
|
||||
}
|
||||
|
||||
# Frontend (listener-facing) IP config: the public IP that the world
|
||||
# reaches the App Gateway at. Read via the `app_gateway_fqdn` output.
|
||||
frontend_ip_configuration {
|
||||
name = "public"
|
||||
public_ip_address_id = azurerm_public_ip.app_gateway.id
|
||||
}
|
||||
|
||||
# ---------- Backend pools ----------
|
||||
backend_address_pool {
|
||||
name = "gateway"
|
||||
fqdns = [local.gateway_fqdn]
|
||||
}
|
||||
|
||||
backend_address_pool {
|
||||
name = "backend"
|
||||
fqdns = [local.backend_fqdn]
|
||||
}
|
||||
|
||||
backend_address_pool {
|
||||
name = "ui"
|
||||
fqdns = [local.ui_fqdn]
|
||||
}
|
||||
|
||||
# ---------- Backend HTTP settings ----------
|
||||
backend_http_settings {
|
||||
name = "gateway-http"
|
||||
cookie_based_affinity = "Disabled"
|
||||
port = 443
|
||||
protocol = "Https"
|
||||
request_timeout = 60
|
||||
probe_name = "gateway-probe"
|
||||
|
||||
host_name = local.gateway_fqdn
|
||||
}
|
||||
|
||||
backend_http_settings {
|
||||
name = "backend-http"
|
||||
cookie_based_affinity = "Disabled"
|
||||
port = 443
|
||||
protocol = "Https"
|
||||
request_timeout = 60
|
||||
probe_name = "backend-probe"
|
||||
|
||||
host_name = local.backend_fqdn
|
||||
}
|
||||
|
||||
backend_http_settings {
|
||||
name = "ui-http"
|
||||
cookie_based_affinity = "Disabled"
|
||||
port = 443
|
||||
protocol = "Https"
|
||||
request_timeout = 60
|
||||
probe_name = "ui-probe"
|
||||
|
||||
host_name = local.ui_fqdn
|
||||
}
|
||||
|
||||
# ---------- Health probes ----------
|
||||
probe {
|
||||
name = "gateway-probe"
|
||||
host = local.gateway_fqdn
|
||||
interval = 30
|
||||
timeout = 30
|
||||
unhealthy_threshold = 3
|
||||
|
||||
match {
|
||||
status_code = ["200-399"]
|
||||
}
|
||||
|
||||
path = "/health/liveliness"
|
||||
protocol = "Https"
|
||||
}
|
||||
|
||||
probe {
|
||||
name = "backend-probe"
|
||||
host = local.backend_fqdn
|
||||
interval = 30
|
||||
timeout = 30
|
||||
unhealthy_threshold = 3
|
||||
|
||||
match {
|
||||
status_code = ["200-399"]
|
||||
}
|
||||
|
||||
path = "/health/liveliness"
|
||||
protocol = "Https"
|
||||
}
|
||||
|
||||
probe {
|
||||
name = "ui-probe"
|
||||
host = local.ui_fqdn
|
||||
interval = 30
|
||||
timeout = 30
|
||||
unhealthy_threshold = 3
|
||||
|
||||
match {
|
||||
status_code = ["200-399"]
|
||||
}
|
||||
|
||||
path = "/"
|
||||
protocol = "Https"
|
||||
}
|
||||
|
||||
# ---------- Listeners ----------
|
||||
http_listener {
|
||||
name = "http-listener"
|
||||
frontend_ip_configuration_name = "public"
|
||||
frontend_port_name = "http"
|
||||
protocol = "Http"
|
||||
host_name = null
|
||||
}
|
||||
|
||||
http_listener {
|
||||
name = "https-listener"
|
||||
frontend_ip_configuration_name = "public"
|
||||
frontend_port_name = "https"
|
||||
protocol = "Https"
|
||||
ssl_certificate_name = local.tls_enabled ? "tls" : null
|
||||
host_name = null
|
||||
}
|
||||
|
||||
# ---------- SSL certificate ----------
|
||||
ssl_certificate {
|
||||
name = "tls"
|
||||
key_vault_secret_id = var.key_vault_certificate_id
|
||||
|
||||
# Only attach the SSL cert when TLS is enabled.
|
||||
}
|
||||
|
||||
# ---------- URL path map ----------
|
||||
#
|
||||
# Single path rule with a default backend -> management API. The rule
|
||||
# itself uses a path prefix matcher at "/" with all explicit paths in
|
||||
# `local.gateway_path_prefixes` -> gateway backend and the UI paths in
|
||||
# `local.ui_path_prefixes` -> ui backend. We leverage the
|
||||
# "default_backend_address_pool" for the backend pool; explicit rules
|
||||
# override the default.
|
||||
|
||||
url_path_map {
|
||||
name = "litellm-url-map"
|
||||
default_backend_address_pool_name = "backend"
|
||||
default_backend_http_settings_name = "backend-http"
|
||||
|
||||
path_rule {
|
||||
name = "gateway-prefixes"
|
||||
paths = local.gateway_path_prefixes
|
||||
backend_address_pool_name = "gateway"
|
||||
backend_http_settings_name = "gateway-http"
|
||||
}
|
||||
|
||||
path_rule {
|
||||
name = "ui-prefixes"
|
||||
paths = concat(local.ui_path_prefixes, local.ui_exact_paths)
|
||||
backend_address_pool_name = "ui"
|
||||
backend_http_settings_name = "ui-http"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- Request routing rules ----------
|
||||
# HTTPS listener routes through the URL path map.
|
||||
request_routing_rule {
|
||||
name = "https-routing"
|
||||
rule_type = "PathBasedRouting"
|
||||
http_listener_name = "https-listener"
|
||||
url_path_map_name = "litellm-url-map"
|
||||
priority = 100
|
||||
}
|
||||
|
||||
# HTTP listener either routes through the URL path map (when plaintext
|
||||
# is allowed) or redirects to HTTPS.
|
||||
dynamic "request_routing_rule" {
|
||||
for_each = var.allow_plaintext_app_gateway ? [1] : []
|
||||
content {
|
||||
name = "http-routing"
|
||||
rule_type = "PathBasedRouting"
|
||||
http_listener_name = "http-listener"
|
||||
url_path_map_name = "litellm-url-map"
|
||||
priority = 110
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "redirect_configuration" {
|
||||
for_each = var.allow_plaintext_app_gateway ? [] : [1]
|
||||
content {
|
||||
name = "http-to-https"
|
||||
redirect_type = "Permanent"
|
||||
target_listener_name = "https-listener"
|
||||
include_path = true
|
||||
include_query_string = true
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect rule for the http listener (only when plaintext is denied).
|
||||
dynamic "request_routing_rule" {
|
||||
for_each = var.allow_plaintext_app_gateway ? [] : [1]
|
||||
content {
|
||||
name = "http-redirect"
|
||||
rule_type = "Basic"
|
||||
http_listener_name = "http-listener"
|
||||
redirect_configuration_name = "http-to-https"
|
||||
priority = 200
|
||||
}
|
||||
}
|
||||
}
|
||||
114
terraform/litellm/azure/locals.tf
Normal file
114
terraform/litellm/azure/locals.tf
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Per-component path prefixes mirrored verbatim from the AWS module's
|
||||
# gateway_path_prefixes / ui_path_prefixes blocks (and ultimately from
|
||||
# gateway/routes/allowlist.py plus the helm ingress in
|
||||
# helm/litellm/templates/ingress.yaml). Anything not in either list and not
|
||||
# a UI asset path falls through to the backend (management API) on the
|
||||
# Application Gateway URL path map.
|
||||
#
|
||||
# Application Gateway URL path map rules cap path-based conditions per rule
|
||||
# differently per SKU; for the path-based backend pool strategy used here
|
||||
# we keep a single combined list and emit one rule per prefix.
|
||||
|
||||
locals {
|
||||
# Every Azure resource the stack creates is named `<tenant>-litellm-<env>`
|
||||
# (or that with a per-resource suffix). Computed once so the rest of the
|
||||
# stack can reference `local.name`.
|
||||
name = "${var.tenant}-litellm-${var.env}"
|
||||
|
||||
# Module-level tagging. Caller-provided provider default_tags merge with
|
||||
# these at apply time.
|
||||
tags = merge(
|
||||
{
|
||||
"litellm:stack" = local.name
|
||||
"managed-by" = "terraform"
|
||||
},
|
||||
var.tags,
|
||||
)
|
||||
|
||||
# Resource group that owns every resource the module creates. Caller may
|
||||
# supply an existing one with var.resource_group_name; otherwise the
|
||||
# module creates one named `<tenant>-litellm-<env>-rg`.
|
||||
resource_group_name = var.resource_group_name != "" ? var.resource_group_name : "${local.name}-rg"
|
||||
|
||||
# Gateway data-plane path prefixes (mirrors AWS gateway_path_prefixes).
|
||||
gateway_path_prefixes = [
|
||||
"/v1/chat/*", "/chat/*",
|
||||
"/v1/completions*", "/completions*",
|
||||
"/v1/embeddings*", "/embeddings*",
|
||||
"/v1/moderations*", "/moderations*",
|
||||
"/v1/audio/*", "/audio/*",
|
||||
"/v1/images/*", "/images/*",
|
||||
"/v1/files*", "/files*",
|
||||
"/v1/batches*", "/batches*",
|
||||
"/v1/fine_tuning/*", "/fine_tuning/*",
|
||||
"/v1/fine-tuning/*", "/fine-tuning/*",
|
||||
"/v1/responses*", "/responses*",
|
||||
"/v1/threads*", "/threads*",
|
||||
"/v1/assistants*", "/assistants*",
|
||||
"/v1/vector_stores*", "/vector_stores*",
|
||||
"/v1/indexes*",
|
||||
"/v1/models*", "/models*",
|
||||
"/openai/*", "/engines/*",
|
||||
"/v1/messages*", "/messages*",
|
||||
"/v1/skills/*", "/v1/a2a/*",
|
||||
"/v1/rerank*", "/v2/rerank*", "/rerank*",
|
||||
"/v1/ocr*", "/ocr*",
|
||||
"/v1/rag/*", "/rag/*",
|
||||
"/v1/video/*", "/v1/videos/*", "/video/*", "/videos/*",
|
||||
"/v1/search*", "/search*",
|
||||
"/v1/containers/*", "/containers/*",
|
||||
"/v1/evals/*",
|
||||
"/v1/memory/*",
|
||||
"/queue/chat/*",
|
||||
"/v1beta/*",
|
||||
"/interactions/*",
|
||||
"/anthropic/*", "/azure/*", "/azure_ai/*", "/aws/*", "/bedrock/*",
|
||||
"/cohere/*", "/gemini/*", "/google/*",
|
||||
"/vertex_ai/*", "/vertex-ai/*",
|
||||
"/assemblyai/*", "/eu.assemblyai/*",
|
||||
"/langfuse/*", "/vllm/*",
|
||||
"/mistral/*", "/groq/*", "/voyage/*", "/cursor/*", "/milvus/*",
|
||||
"/openai_passthrough/*",
|
||||
"/toolset/*",
|
||||
"/v1/realtime*", "/realtime*",
|
||||
"/health*", "/metrics", "/test*",
|
||||
]
|
||||
|
||||
# Static UI asset prefixes (handled by the ui Container App, not the
|
||||
# backend catch-all). / and /favicon.ico are added as exact-match paths.
|
||||
ui_path_prefixes = [
|
||||
"/litellm-asset-prefix/*",
|
||||
"/_next/*",
|
||||
"/assets/*",
|
||||
"/ui/*",
|
||||
]
|
||||
|
||||
ui_exact_paths = [
|
||||
"/",
|
||||
"/favicon.ico",
|
||||
"/ui",
|
||||
]
|
||||
|
||||
# TLS is enabled when a Key Vault certificate ID is supplied AND
|
||||
# plaintext is disallowed (the default).
|
||||
tls_enabled = var.key_vault_certificate_id != "" && !var.allow_plaintext_app_gateway
|
||||
|
||||
# Postgres admin setup: the first active-directory admin that provisions
|
||||
# the Flexible Server. We use the current principal invoking terraform.
|
||||
# (The `azure_ad_admin` block on the PG server accepts a specific object
|
||||
# ID; callers needing a different admin can pass it through
|
||||
# var.db_ad_admin_object_id.)
|
||||
db_ad_admin_object_id_default = try(data.azurerm_client_config.current.object_id, "")
|
||||
|
||||
# Image URI resolution (mirrors AWS / GCP defaults):
|
||||
# <image_registry>/<image_namespace>/litellm-<component>:<image_tag>
|
||||
gateway_image_resolved = coalesce(var.gateway_image, "${var.image_registry}/${var.image_namespace}/litellm-gateway:${var.image_tag}")
|
||||
backend_image_resolved = coalesce(var.backend_image, "${var.image_registry}/${var.image_namespace}/litellm-backend:${var.image_tag}")
|
||||
ui_image_resolved = coalesce(var.ui_image, "${var.image_registry}/${var.image_namespace}/litellm-ui:${var.image_tag}")
|
||||
migrations_image_resolved = coalesce(var.migrations_image, "${var.image_registry}/${var.image_namespace}/litellm-migrations:${var.image_tag}")
|
||||
|
||||
# Proxy config as YAML, uploaded to the storage account blob
|
||||
# `config/litellm-config.yaml`. The Container Apps download this on
|
||||
# startup via azure-identity and set `CONFIG_FILE_PATH=/tmp/litellm-config.yaml`.
|
||||
proxy_config_yaml = var.proxy_config != {} ? yamlencode(var.proxy_config) : ""
|
||||
}
|
||||
79
terraform/litellm/azure/migrations.tf
Normal file
79
terraform/litellm/azure/migrations.tf
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Container Apps Job for the one-off prisma migrate deploy. The job runs
|
||||
# once after `terraform apply` and before any traffic; output the run
|
||||
# command via `terraform output migration_run_command` and execute it
|
||||
# via `az containerapp job start`.
|
||||
#
|
||||
# Like the AWS migrations task definition, this container downloads the
|
||||
# proxy_config.yaml blob via the managed identity at startup, runs the
|
||||
# schema migration, then exits. The downstream gateway / backend services
|
||||
# do not depend on its success at terraform-apply time because the
|
||||
# Container Apps revision bootstrap happens lazily; instead we rely on
|
||||
# the bootstrap pattern in the README (run after apply, before first
|
||||
# traffic).
|
||||
|
||||
resource "azurerm_container_app_job" "migrations" {
|
||||
name = "${local.name}-migrations"
|
||||
container_app_environment_id = azurerm_container_app_environment.this.id
|
||||
resource_group_name = local.resource_group_name
|
||||
location = var.location
|
||||
tags = local.tags
|
||||
|
||||
identity {
|
||||
type = "UserAssigned"
|
||||
identity_ids = [azurerm_user_assigned_identity.container_apps.id]
|
||||
}
|
||||
|
||||
# Manual trigger: one-shot job, runs to completion when invoked.
|
||||
manual_trigger_config {
|
||||
parallelism = 1
|
||||
replica_completion_count = 1
|
||||
}
|
||||
|
||||
replica_timeout_in_seconds = 1800
|
||||
|
||||
registry {
|
||||
server = var.image_registry
|
||||
}
|
||||
|
||||
# Secrets are declared at the job level and referenced from container
|
||||
# env blocks via `secret_name`. Key Vault secret IDs are pulled at
|
||||
# startup using the user-assigned managed identity declared on the job.
|
||||
dynamic "secret" {
|
||||
for_each = local.migrations_kv_secret_env
|
||||
content {
|
||||
name = secret.key
|
||||
key_vault_secret_id = secret.value
|
||||
identity = azurerm_user_assigned_identity.container_apps.id
|
||||
}
|
||||
}
|
||||
|
||||
template {
|
||||
container {
|
||||
name = "migrations"
|
||||
image = local.migrations_image_resolved
|
||||
cpu = 1.0
|
||||
memory = "2Gi"
|
||||
|
||||
dynamic "env" {
|
||||
for_each = concat(
|
||||
local.migrations_base_env,
|
||||
[
|
||||
{ name = "DATABASE_URL", value = "postgresql://${var.db_username}@${azurerm_postgresql_flexible_server.this.fqdn}:5432/${var.db_name}?sslmode=require" },
|
||||
],
|
||||
)
|
||||
content {
|
||||
name = env.value.name
|
||||
value = env.value.value
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "env" {
|
||||
for_each = local.migrations_kv_secret_env
|
||||
content {
|
||||
name = env.key
|
||||
secret_name = env.key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
78
terraform/litellm/azure/network.tf
Normal file
78
terraform/litellm/azure/network.tf
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Caller-supplied resource group (when `resource_group_name` is set) or a
|
||||
# freshly-created one. All Azure resources the module creates are scoped
|
||||
# to the resource group named `local.resource_group_name`.
|
||||
|
||||
resource "azurerm_resource_group" "this" {
|
||||
count = var.resource_group_name == "" ? 1 : 0
|
||||
name = local.resource_group_name
|
||||
location = var.location
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
data "azurerm_client_config" "current" {}
|
||||
|
||||
# Caller's subscription + tenant for role assignments and Key Vault access
|
||||
# policies.
|
||||
data "azurerm_subscription" "current" {}
|
||||
|
||||
# ---------- VNet ----------
|
||||
#
|
||||
# Subnet allocation:
|
||||
# - containers : /23 (Container Apps Environment infrastructure subnet;
|
||||
# dynamic IP allocation; must be /23 or larger per
|
||||
# Azure docs)
|
||||
# - private_endpoints : /24 (private endpoints to Postgres / Redis / Storage
|
||||
# / Key Vault)
|
||||
|
||||
resource "azurerm_virtual_network" "this" {
|
||||
name = "${local.name}-vnet"
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
address_space = [var.vnet_cidr]
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "azurerm_subnet" "containers" {
|
||||
name = "containers"
|
||||
resource_group_name = local.resource_group_name
|
||||
virtual_network_name = azurerm_virtual_network.this.name
|
||||
address_prefixes = [cidrsubnet(var.vnet_cidr, 4, 0)] # /20 portion, plenty of room for Container Apps dynamic IPs
|
||||
service_endpoints = ["Microsoft.Storage"]
|
||||
|
||||
delegation {
|
||||
name = "container-apps"
|
||||
|
||||
service_delegation {
|
||||
name = "Microsoft.App/environments"
|
||||
actions = [
|
||||
"Microsoft.Network/virtualNetworks/subnets/join/action",
|
||||
"Microsoft.Network/virtualNetworks/subnets/prepareNetworkPolicies/action",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "azurerm_subnet" "private_endpoints" {
|
||||
name = "private-endpoints"
|
||||
resource_group_name = local.resource_group_name
|
||||
virtual_network_name = azurerm_virtual_network.this.name
|
||||
address_prefixes = [cidrsubnet(var.vnet_cidr, 4, 1)] # /20 portion, separate from containers
|
||||
|
||||
service_endpoints = ["Microsoft.Storage"]
|
||||
}
|
||||
|
||||
# Network Security Group for the Container Apps subnet. Application
|
||||
# Gateway sits in its own subnet (created below) and reaches the Container
|
||||
# Apps via the internal ingress FQDN; the gateway subnet's NSG governs
|
||||
# inbound traffic from the public internet.
|
||||
resource "azurerm_network_security_group" "containers" {
|
||||
name = "${local.name}-containers-nsg"
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "azurerm_subnet_network_security_group_association" "containers" {
|
||||
subnet_id = azurerm_subnet.containers.id
|
||||
network_security_group_id = azurerm_network_security_group.containers.id
|
||||
}
|
||||
89
terraform/litellm/azure/outputs.tf
Normal file
89
terraform/litellm/azure/outputs.tf
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
output "app_gateway_url" {
|
||||
description = "Proxy URL. Scheme is https if key_vault_certificate_id is set, http otherwise. The dashboard is served at /, the API at /v1/*."
|
||||
value = "${local.tls_enabled ? "https" : "http"}://${azurerm_public_ip.app_gateway.fqdn}"
|
||||
}
|
||||
|
||||
output "app_gateway_fqdn" {
|
||||
description = "Public FQDN of the LiteLLM Application Gateway (derived from the public IP)."
|
||||
value = azurerm_public_ip.app_gateway.fqdn
|
||||
}
|
||||
|
||||
output "container_apps_environment_id" {
|
||||
description = "Resource ID of the Container Apps Environment hosting the gateway / backend / ui Container Apps."
|
||||
value = azurerm_container_app_environment.this.id
|
||||
}
|
||||
|
||||
output "postgres_fqdn" {
|
||||
description = "FQDN of the Azure Database for PostgreSQL Flexible Server. Used by gateway / backend / migrations as `DATABASE_HOST`."
|
||||
value = azurerm_postgresql_flexible_server.this.fqdn
|
||||
}
|
||||
|
||||
output "postgres_database_name" {
|
||||
description = "PostgreSQL database name."
|
||||
value = var.db_name
|
||||
}
|
||||
|
||||
output "redis_hostname" {
|
||||
description = "Hostname of the Azure Cache for Redis (use the `rediss://` scheme if var.redis_enable_ssl is true)."
|
||||
value = azurerm_redis_cache.this.hostname
|
||||
}
|
||||
|
||||
output "redis_port" {
|
||||
description = "Port of the Azure Cache for Redis."
|
||||
value = azurerm_redis_cache.this.port
|
||||
}
|
||||
|
||||
output "storage_account_name" {
|
||||
description = "Name of the Storage Account hosting the proxy blob container for cache backend, request log archival, and /v1/files storage. Exposed to gateway + backend as `AZURE_STORAGE_ACCOUNT_NAME` (LiteLLM reads this env var to assemble credentials)."
|
||||
value = azurerm_storage_account.this.name
|
||||
}
|
||||
|
||||
output "storage_blob_container" {
|
||||
description = "Blob container name on the Storage Account for proxy state."
|
||||
value = azurerm_storage_container.proxy.name
|
||||
}
|
||||
|
||||
output "key_vault_uri" {
|
||||
description = "URI of the Key Vault holding LITELLM_MASTER_KEY, the Aurora master password bootstrap, optional LITELLM_LICENSE, and optional UI_PASSWORD."
|
||||
value = azurerm_key_vault.this.vault_uri
|
||||
}
|
||||
|
||||
output "master_key_secret_id" {
|
||||
description = "Resource ID of the Key Vault secret holding LITELLM_MASTER_KEY."
|
||||
value = azurerm_key_vault_secret.master_key.id
|
||||
}
|
||||
|
||||
output "managed_identity_client_id" {
|
||||
description = "Client ID of the user-assigned managed identity assigned to the Container Apps. Use this when granting additional role assignments outside the module."
|
||||
value = azurerm_user_assigned_identity.container_apps.client_id
|
||||
}
|
||||
|
||||
output "managed_identity_principal_id" {
|
||||
description = "Object (principal) ID of the user-assigned managed identity. Use this for `az role assignment create` against additional scopes."
|
||||
value = azurerm_user_assigned_identity.container_apps.principal_id
|
||||
}
|
||||
|
||||
# Pre-baked SQL to run once as the Entra admin (after the first apply) to
|
||||
# create the application user that gateway / backend / migration will
|
||||
# authenticate as. Azure Database for PostgreSQL Flexible Server uses
|
||||
# `azure_ad_admin` for the AAD-enabled login.
|
||||
output "db_bootstrap_sql" {
|
||||
description = "Run this once as the AAD admin (after the first apply) to create the application user that will authenticate via Entra-managed-identity tokens at runtime."
|
||||
value = <<-SQL
|
||||
CREATE USER "${var.db_username}" WITH LOGIN;
|
||||
GRANT ALL PRIVILEGES ON DATABASE ${var.db_name} TO "${var.db_username}";
|
||||
GRANT ALL ON SCHEMA public TO "${var.db_username}";
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "${var.db_username}";
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "${var.db_username}";
|
||||
SQL
|
||||
}
|
||||
|
||||
# Pre-baked az CLI command for the one-off migration Container App Job.
|
||||
output "migration_run_command" {
|
||||
description = "az CLI command that triggers the one-off prisma migration Container App Job. Run after the Entra admin has executed the db_bootstrap_sql above."
|
||||
value = format(
|
||||
"az containerapp job start --name %s --resource-group %s --subscription <your-sub-id>",
|
||||
azurerm_container_app_job.migrations.name,
|
||||
local.resource_group_name,
|
||||
)
|
||||
}
|
||||
78
terraform/litellm/azure/postgres.tf
Normal file
78
terraform/litellm/azure/postgres.tf
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Azure Database for PostgreSQL Flexible Server, single instance, with
|
||||
# Entra (Azure AD) authentication enabled for the proxy's managed
|
||||
# identity. (Single zone by default; set `zone = "1"` plus a high-
|
||||
# availability SKU for zone redundancy.)
|
||||
#
|
||||
# The bootstrap job (run once during initial setup, see bootstrap.tf)
|
||||
# creates the literal-login user used for password-based app access;
|
||||
# the gateway / backend / migrations Container Apps use Entra tokens at
|
||||
# runtime.
|
||||
|
||||
resource "azurerm_postgresql_flexible_server" "this" {
|
||||
name = replace("${var.tenant}-${var.env}-pg", "-", "")
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
sku_name = var.db_sku_name
|
||||
storage_mb = var.db_storage_mb
|
||||
version = var.db_version
|
||||
tags = local.tags
|
||||
|
||||
# The AAD admin: this object's principal can connect to the server and
|
||||
# bootstrap the application user. Defaults to the current terraform
|
||||
# principal; override var.db_ad_admin_object_id for a dedicated admin.
|
||||
administrator_login = "litellm_admin"
|
||||
administrator_password = random_password.db_admin_password.result
|
||||
|
||||
authentication {
|
||||
password_auth_enabled = true
|
||||
active_directory_auth_enabled = true
|
||||
}
|
||||
|
||||
# Default to private access in the VNet; public access disabled.
|
||||
public_network_access_enabled = false
|
||||
delegated_subnet_id = azurerm_subnet.private_endpoints.id
|
||||
private_dns_zone_id = azurerm_private_dns_zone.postgres.id
|
||||
|
||||
# Zone redundancy is off by default to control cost; flip on via
|
||||
# `zone = "1"` + matching SKU.
|
||||
zone = length(var.azs) > 1 ? var.azs[0] : null
|
||||
|
||||
depends_on = [
|
||||
azurerm_role_assignment.container_apps_env_infra,
|
||||
azurerm_private_dns_zone_virtual_network_link.postgres,
|
||||
]
|
||||
}
|
||||
|
||||
resource "azurerm_postgresql_flexible_server_database" "this" {
|
||||
server_id = azurerm_postgresql_flexible_server.this.id
|
||||
name = var.db_name
|
||||
collation = "en_US.utf8"
|
||||
charset = "UTF8"
|
||||
}
|
||||
|
||||
# Set the AAD admin via a separate resource. The first principal in the
|
||||
# list wins; we use the current terraform principal by default.
|
||||
resource "azurerm_postgresql_flexible_server_active_directory_administrator" "this" {
|
||||
server_name = azurerm_postgresql_flexible_server.this.name
|
||||
resource_group_name = local.resource_group_name
|
||||
tenant_id = data.azurerm_client_config.current.tenant_id
|
||||
object_id = data.azurerm_client_config.current.object_id
|
||||
principal_name = "terraform-admin"
|
||||
principal_type = "User"
|
||||
|
||||
depends_on = [azurerm_postgresql_flexible_server.this]
|
||||
}
|
||||
|
||||
resource "azurerm_private_dns_zone" "postgres" {
|
||||
name = "privatelink.postgres.database.azure.com"
|
||||
resource_group_name = local.resource_group_name
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "azurerm_private_dns_zone_virtual_network_link" "postgres" {
|
||||
name = "${local.name}-pg-dnslink"
|
||||
resource_group_name = local.resource_group_name
|
||||
private_dns_zone_name = azurerm_private_dns_zone.postgres.name
|
||||
virtual_network_id = azurerm_virtual_network.this.id
|
||||
registration_enabled = false
|
||||
}
|
||||
52
terraform/litellm/azure/redis.tf
Normal file
52
terraform/litellm/azure/redis.tf
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Azure Cache for Redis: single instance (Basic tier) by default.
|
||||
# Production should move to Standard / Premium with replication.
|
||||
#
|
||||
# The proxy connects via `rediss://` (TLS) when `var.redis_enable_ssl`
|
||||
# is true (the default, matching AWS module's transit_encryption_enabled).
|
||||
|
||||
resource "azurerm_redis_cache" "this" {
|
||||
name = replace("${var.tenant}-${var.env}-redis", "-", "")
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
sku_name = var.redis_sku
|
||||
family = var.redis_family
|
||||
capacity = var.redis_capacity
|
||||
enable_non_ssl_port = !var.redis_enable_ssl
|
||||
minimum_tls_version = "1.2"
|
||||
tags = local.tags
|
||||
|
||||
# Redis access keys are managed by Azure; the proxy uses the `rediss://`
|
||||
# URL with the primary key as auth. Caller can rotate via Key Vault
|
||||
# access policy if tighter secret-scoped handling is required.
|
||||
redis_configuration {
|
||||
# Renamed to authentication_enabled in v4. Until we drop 3.x support,
|
||||
# both forms exist; this module targets 3.117+ which still accepts
|
||||
# enable_authentication.
|
||||
enable_authentication = true
|
||||
maxmemory_policy = "allkeys-lru"
|
||||
}
|
||||
|
||||
# Access via private endpoint only.
|
||||
public_network_access_enabled = false
|
||||
subnet_id = azurerm_subnet.private_endpoints.id
|
||||
}
|
||||
|
||||
resource "azurerm_private_dns_zone" "redis" {
|
||||
name = "privatelink.redis.cache.windows.net"
|
||||
resource_group_name = local.resource_group_name
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "azurerm_private_dns_zone_virtual_network_link" "redis" {
|
||||
name = "${local.name}-redis-dnslink"
|
||||
resource_group_name = local.resource_group_name
|
||||
private_dns_zone_name = azurerm_private_dns_zone.redis.name
|
||||
virtual_network_id = azurerm_virtual_network.this.id
|
||||
registration_enabled = false
|
||||
}
|
||||
|
||||
# Outputs that the gateway/backend pass via `REDIS_HOST` / `REDIS_PORT`.
|
||||
locals {
|
||||
redis_host_for_container_apps = azurerm_redis_cache.this.hostname
|
||||
redis_port_for_container_apps = azurerm_redis_cache.this.ssl_port
|
||||
}
|
||||
124
terraform/litellm/azure/storage.tf
Normal file
124
terraform/litellm/azure/storage.tf
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Storage Account: holds:
|
||||
# - proxy_config.yaml (uploaded from var.proxy_config; refreshed on
|
||||
# proxy_config changes to trigger Container App revision swap)
|
||||
# - /v1/files passthrough storage (request log archival, file uploads)
|
||||
# - log archive bucket (optional, future)
|
||||
#
|
||||
# LiteLLM reads `AZURE_STORAGE_ACCOUNT_NAME` and uses the Container Apps
|
||||
# managed identity's Storage Blob Data Contributor role on this account
|
||||
# to do CRUD with the SDK. No shared keys are issued.
|
||||
|
||||
resource "azurerm_storage_account" "this" {
|
||||
name = replace("${var.tenant}${var.env}proxy", "-", "") # 24 char cap; lowercase + no dashes
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
account_tier = var.storage_account_tier
|
||||
account_replication_type = var.storage_replication_type
|
||||
account_kind = "StorageV2"
|
||||
tags = local.tags
|
||||
|
||||
# Security defaults: TLS 1.2 minimum, blob public access blocked, shared
|
||||
# keys off (managed identity only).
|
||||
min_tls_version = "TLS1_2"
|
||||
allow_nested_items_to_be_public = false
|
||||
shared_access_key_enabled = false
|
||||
public_network_access_enabled = false
|
||||
|
||||
blob_properties {
|
||||
versioning_enabled = true
|
||||
|
||||
container_delete_retention_policy {
|
||||
days = 7
|
||||
}
|
||||
|
||||
delete_retention_policy {
|
||||
days = 7
|
||||
}
|
||||
}
|
||||
|
||||
network_rules {
|
||||
default_action = "Deny"
|
||||
bypass = ["AzureServices"]
|
||||
ip_rules = []
|
||||
virtual_network_subnet_ids = [
|
||||
azurerm_subnet.private_endpoints.id,
|
||||
azurerm_subnet.containers.id,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Container that holds the proxy config + /v1/files uploads.
|
||||
resource "azurerm_storage_container" "proxy" {
|
||||
name = "proxy"
|
||||
storage_account_name = azurerm_storage_account.this.name
|
||||
container_access_type = "private"
|
||||
}
|
||||
|
||||
# Files subcontainer: dedicated for the /v1/files endpoint so callers can
|
||||
# isolate the data plane from the proxy_config blob.
|
||||
resource "azurerm_storage_container" "files" {
|
||||
name = "files"
|
||||
storage_account_name = azurerm_storage_account.this.name
|
||||
container_access_type = "private"
|
||||
}
|
||||
|
||||
# Private endpoint for the Storage Account (blob sub-resource).
|
||||
resource "azurerm_private_endpoint" "storage_blob" {
|
||||
name = "${local.name}-sa-pe"
|
||||
location = var.location
|
||||
resource_group_name = local.resource_group_name
|
||||
subnet_id = azurerm_subnet.private_endpoints.id
|
||||
tags = local.tags
|
||||
|
||||
private_service_connection {
|
||||
name = "${local.name}-sa-blob"
|
||||
private_connection_resource_id = azurerm_storage_account.this.id
|
||||
is_manual_connection = false
|
||||
subresource_names = ["blob"]
|
||||
}
|
||||
|
||||
private_dns_zone_group {
|
||||
name = "default"
|
||||
private_dns_zone_ids = [azurerm_private_dns_zone.blob.id]
|
||||
}
|
||||
|
||||
depends_on = [azurerm_private_dns_zone_virtual_network_link.blob]
|
||||
}
|
||||
|
||||
resource "azurerm_private_dns_zone" "blob" {
|
||||
name = "privatelink.blob.core.windows.net"
|
||||
resource_group_name = local.resource_group_name
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "azurerm_private_dns_zone_virtual_network_link" "blob" {
|
||||
name = "${local.name}-sa-dnslink"
|
||||
resource_group_name = local.resource_group_name
|
||||
private_dns_zone_name = azurerm_private_dns_zone.blob.name
|
||||
virtual_network_id = azurerm_virtual_network.this.id
|
||||
registration_enabled = false
|
||||
}
|
||||
|
||||
# ---------- proxy_config blob upload ----------
|
||||
#
|
||||
# Encoded from `var.proxy_config` (a typed map). Each apply that changes
|
||||
# proxy_config produces a new blob content, which the gateway and
|
||||
# backend Container Apps container start uses as a versioning trigger.
|
||||
|
||||
resource "azurerm_storage_blob" "proxy_config" {
|
||||
count = var.proxy_config != {} ? 1 : 0
|
||||
name = "config/litellm-config.yaml"
|
||||
storage_account_name = azurerm_storage_account.this.name
|
||||
storage_container_name = azurerm_storage_container.proxy.name
|
||||
type = "Block"
|
||||
content_type = "application/yaml"
|
||||
|
||||
# Force a fresh upload on content change. The lifecycle keeps the
|
||||
# previous version accessible via the versioned blob endpoint.
|
||||
source_content = local.proxy_config_yaml
|
||||
|
||||
depends_on = [
|
||||
azurerm_role_assignment.storage_blob_data_contributor,
|
||||
azurerm_storage_container.proxy,
|
||||
]
|
||||
}
|
||||
400
terraform/litellm/azure/variables.tf
Normal file
400
terraform/litellm/azure/variables.tf
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
variable "location" {
|
||||
description = "Azure region to deploy into (e.g. `eastus`, `westus2`)."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "tenant" {
|
||||
description = "Tenant slug, used as the prefix for every Azure resource the stack creates. Combined with var.env to form `<tenant>-litellm-<env>` (e.g. `acme-litellm-stage`)."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = can(regex("^[a-z][a-z0-9-]{0,20}$", var.tenant))
|
||||
error_message = "tenant must be 1-21 chars, lower-kebab-case, starting with a letter."
|
||||
}
|
||||
}
|
||||
|
||||
variable "env" {
|
||||
description = "Environment suffix appended to every resource name (e.g. `stage`, `prod`, `dev`)."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = can(regex("^[a-z][a-z0-9-]{0,8}$", var.env))
|
||||
error_message = "env must be 1-9 chars, lower-kebab-case, starting a letter."
|
||||
}
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
description = "Per-deployment tags applied to every taggable resource the module creates, on top of the module's own `litellm:stack` / `managed-by` tags. Caller-level provider default_tags (if any) merge with these."
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
variable "resource_group_name" {
|
||||
description = "Existing resource group to deploy into. Leave empty to have the module create one named `<tenant>-litellm-<env>-rg`."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
# ---------- Tenant-supplied secrets ----------
|
||||
#
|
||||
# Both default to "" so the stack stays usable for trial / OSS deploys.
|
||||
# Set via TF_VAR_litellm_master_key / TF_VAR_litellm_license to keep the
|
||||
# values out of state files committed to a VCS.
|
||||
|
||||
variable "litellm_master_key" {
|
||||
description = <<-EOT
|
||||
Pre-existing LITELLM_MASTER_KEY (must begin with `sk-`). When set, this
|
||||
value is written to the master-key Key Vault entry. When empty, the
|
||||
stack auto-generates a random `sk-...` key (preserving today's
|
||||
trial-deploy behavior).
|
||||
EOT
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "litellm_license" {
|
||||
description = <<-EOT
|
||||
LiteLLM enterprise license string. When set, the stack creates a
|
||||
`<tenant>-litellm-<env>-license` Key Vault secret, grants the
|
||||
Container Apps managed identity read access, and exposes its value to
|
||||
gateway + backend as `LITELLM_LICENSE`. Leave empty for OSS-only deploys.
|
||||
EOT
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "ui_password" {
|
||||
description = <<-EOT
|
||||
UI admin password. When set, the stack creates a
|
||||
`<tenant>-litellm-<env>-ui-password` Key Vault secret, grants the
|
||||
Container Apps managed identity read access, and exposes its value to
|
||||
the backend as `UI_PASSWORD`. Pair with `backend_extra_env.UI_USERNAME`
|
||||
to set the matching username. Leave empty to skip; the proxy then
|
||||
falls back to the LITELLM_MASTER_KEY for UI login.
|
||||
EOT
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
# ---------- Networking ----------
|
||||
|
||||
variable "vnet_cidr" {
|
||||
description = "CIDR block for the VNet."
|
||||
type = string
|
||||
default = "10.42.0.0/16"
|
||||
}
|
||||
|
||||
variable "azs" {
|
||||
description = "Availability zones to spread subnets across. Use Azure zone identifiers (e.g. `[\"1\", \"2\", \"3\"]`). At least 1 required; 2+ recommended for HA PostgreSQL Flexible Server."
|
||||
type = list(string)
|
||||
|
||||
validation {
|
||||
condition = length(var.azs) >= 1
|
||||
error_message = "Provide at least 1 availability zone."
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- Component images ----------
|
||||
#
|
||||
# Defaults pin the four componentized images at the same release tag on
|
||||
# GHCR. Override on a per-component basis in tfvars when bumping; bump them
|
||||
# together when bumping the LiteLLM release.
|
||||
#
|
||||
# Container Apps pulls `ghcr.io/berriai/litellm-gateway:<tag>` by default;
|
||||
# this works as-is in Azure because Container Apps can pull from ghcr.io
|
||||
# directly (the GCP stack requires an Artifact Registry mirror, AWS pulls
|
||||
# from ECR only after a docker push, Azure is the closest to a direct
|
||||
# pull-from-public-registry experience).
|
||||
|
||||
variable "image_registry" {
|
||||
description = "Container registry host (without trailing slash). Defaults to GHCR; override when mirroring to ACR or another private registry."
|
||||
type = string
|
||||
default = "ghcr.io"
|
||||
}
|
||||
|
||||
variable "image_namespace" {
|
||||
description = "Container registry namespace / owner. Defaults to `berriai` on GHCR."
|
||||
type = string
|
||||
default = "berriai"
|
||||
}
|
||||
|
||||
variable "image_tag" {
|
||||
description = "Image tag (e.g. `v1.86.0-dev`). All four component images use the same tag; bump together when bumping LiteLLM."
|
||||
type = string
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "gateway_image" {
|
||||
description = "Container image for the gateway (data plane, port 4000). Defaults to `<image_registry>/<image_namespace>/litellm-gateway:<image_tag>`."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "backend_image" {
|
||||
description = "Container image for the backend (management API, port 4001). Defaults to `<image_registry>/<image_namespace>/litellm-backend:<image_tag>`."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "ui_image" {
|
||||
description = "Container image for the UI (port 3000). Defaults to `<image_registry>/<image_namespace>/litellm-ui:<image_tag>`."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "migrations_image" {
|
||||
description = "Container image for the one-off migration job. Defaults to `<image_registry>/<image_namespace>/litellm-migrations:<image_tag>`."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
# ---------- Database ----------
|
||||
|
||||
variable "db_sku_name" {
|
||||
description = "Azure Database for PostgreSQL Flexible Server SKU (e.g. `Standard_B1ms`, `Standard_D2s_v3`, `GP_Standard_D2s_v3`)."
|
||||
type = string
|
||||
default = "Standard_B1ms"
|
||||
}
|
||||
|
||||
variable "db_version" {
|
||||
description = "PostgreSQL major version (e.g. `15`, `16`, `17`)."
|
||||
type = string
|
||||
default = "16"
|
||||
}
|
||||
|
||||
variable "db_storage_mb" {
|
||||
description = "Storage size in MB for the PostgreSQL Flexible Server."
|
||||
type = number
|
||||
default = 32768
|
||||
}
|
||||
|
||||
variable "db_name" {
|
||||
description = "Database name to create on the PostgreSQL Flexible Server."
|
||||
type = string
|
||||
default = "litellm"
|
||||
}
|
||||
|
||||
variable "db_username" {
|
||||
description = "Application DB username. The Container Apps managed identity uses Entra (Azure AD) token auth at runtime; this user is created during `terraform apply` by the bootstrap job and granted the rights the proxy needs."
|
||||
type = string
|
||||
default = "litellm_app"
|
||||
}
|
||||
|
||||
# ---------- Redis ----------
|
||||
|
||||
variable "redis_sku" {
|
||||
description = "Azure Cache for Redis SKU. Defaults to `Basic` (single node, dev/trial); `Standard` or `Premium` recommended for production (zone redundancy + replication)."
|
||||
type = string
|
||||
default = "Basic"
|
||||
}
|
||||
|
||||
variable "redis_family" {
|
||||
description = "Redis SKU family. `C` is basic; `P` is premium."
|
||||
type = string
|
||||
default = "C"
|
||||
}
|
||||
|
||||
variable "redis_capacity" {
|
||||
description = "Cache capacity for Azure Cache for Redis (0 = 250MB, 1 = 1GB, ...). Match to your expected throughput."
|
||||
type = number
|
||||
default = 0
|
||||
}
|
||||
|
||||
variable "redis_enable_ssl" {
|
||||
description = "Whether to require SSL for Redis connections. Defaults to true to match AWS module's `transit_encryption_enabled`."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
# ---------- Storage ----------
|
||||
|
||||
variable "storage_force_destroy" {
|
||||
description = "Allow `terraform destroy` to delete a non-empty Azure Storage container (and the Storage Account). Off by default to protect cached responses, archived request logs, and /v1/files storage."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "storage_account_tier" {
|
||||
description = "Storage Account performance tier. `Standard` is fine for cache / log archive / file storage; `Premium` for low-latency workloads."
|
||||
type = string
|
||||
default = "Standard"
|
||||
}
|
||||
|
||||
variable "storage_replication_type" {
|
||||
description = "Storage Account replication type (`LRS`, `GRS`, `RAGRS`, `ZRS`). Defaults to LRS for cost; bump to GRS / ZRS for production durability."
|
||||
type = string
|
||||
default = "LRS"
|
||||
}
|
||||
|
||||
# ---------- TLS / Load balancer ----------
|
||||
|
||||
variable "key_vault_certificate_id" {
|
||||
description = <<-EOT
|
||||
Resource ID of an existing Key Vault certificate to attach to the
|
||||
Application Gateway listener for TLS termination. When unset, the
|
||||
Application Gateway listener uses HTTP only; either set this var OR
|
||||
set `allow_plaintext_app_gateway = true` for dev/trial only.
|
||||
EOT
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "allow_plaintext_app_gateway" {
|
||||
description = "Allow the Application Gateway listener to serve HTTP (port 80) without TLS. Dev / trial only. Defaults to false to match the AWS module's secure default."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
# ---------- Compute sizing ----------
|
||||
|
||||
variable "gateway_cpu" {
|
||||
description = "CPU allocation for the gateway Container App in 0.25 vCPU increments (e.g. `0.5`, `1.0`, `2.0`)."
|
||||
type = number
|
||||
default = 1.0
|
||||
}
|
||||
|
||||
variable "gateway_memory" {
|
||||
description = "Memory for the gateway Container App, e.g. `2Gi`, `4Gi`."
|
||||
type = string
|
||||
default = "2Gi"
|
||||
}
|
||||
|
||||
variable "gateway_min_replicas" {
|
||||
description = "Minimum replicas for the gateway Container App (autoscaler lower bound)."
|
||||
type = number
|
||||
default = 2
|
||||
}
|
||||
|
||||
variable "gateway_max_replicas" {
|
||||
description = "Maximum replicas for the gateway Container App (autoscaler upper bound)."
|
||||
type = number
|
||||
default = 10
|
||||
}
|
||||
|
||||
variable "backend_cpu" {
|
||||
description = "CPU allocation for the backend Container App."
|
||||
type = number
|
||||
default = 1.0
|
||||
}
|
||||
|
||||
variable "backend_memory" {
|
||||
description = "Memory for the backend Container App."
|
||||
type = string
|
||||
default = "2Gi"
|
||||
}
|
||||
|
||||
variable "backend_min_replicas" {
|
||||
description = "Minimum replicas for the backend Container App."
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "backend_max_replicas" {
|
||||
description = "Maximum replicas for the backend Container App."
|
||||
type = number
|
||||
default = 5
|
||||
}
|
||||
|
||||
variable "ui_cpu" {
|
||||
description = "CPU allocation for the UI Container App."
|
||||
type = number
|
||||
default = 0.5
|
||||
}
|
||||
|
||||
variable "ui_memory" {
|
||||
description = "Memory for the UI Container App."
|
||||
type = string
|
||||
default = "1Gi"
|
||||
}
|
||||
|
||||
variable "ui_min_replicas" {
|
||||
description = "Minimum replicas for the UI Container App."
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "ui_max_replicas" {
|
||||
description = "Maximum replicas for the UI Container App."
|
||||
type = number
|
||||
default = 3
|
||||
}
|
||||
|
||||
# ---------- proxy_config and extra config ----------
|
||||
|
||||
variable "proxy_config" {
|
||||
description = <<-EOT
|
||||
Mirrors the helm chart's `gateway.config.proxy_config`. The map is
|
||||
YAML-encoded and uploaded to the storage account blob `config/litellm-config.yaml`;
|
||||
the gateway and backend Container Apps download it to
|
||||
`/tmp/litellm-config.yaml` at startup via the SDK and set
|
||||
`CONFIG_FILE_PATH` to match. Editing this value produces a new
|
||||
Container Apps revision and rolling redeploy of both services.
|
||||
EOT
|
||||
type = any
|
||||
default = {}
|
||||
}
|
||||
|
||||
variable "gateway_extra_env" {
|
||||
description = "Extra non-sensitive env vars (plaintext) merged into the gateway Container App env. Useful for feature flags / observability hosts. Provider API keys belong in gateway_extra_secrets."
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
variable "backend_extra_env" {
|
||||
description = "Extra non-sensitive env vars merged into the backend Container App env."
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
variable "gateway_extra_secrets" {
|
||||
description = "Map of Key Vault secret references to mount into the gateway Container App as env vars, e.g. `{ OPENAI_API_KEY = azurerm_key_vault_secret.openai.id }`. The Container Apps managed identity must have Secret User role on the vault."
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
variable "backend_extra_secrets" {
|
||||
description = "Map of Key Vault secret references to mount into the backend Container App."
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
variable "ui_extra_env" {
|
||||
description = "Extra non-sensitive env vars merged into the ui Container App env (frontend feature flags, etc.)."
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
# ---------- Observability ----------
|
||||
|
||||
variable "otel_endpoint" {
|
||||
description = "OTLP endpoint (e.g. `https://otel.example.com:4317`). When empty, OpenTelemetry instrumentation is disabled (matches the AWS module)."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "otel_exporter" {
|
||||
description = "OTEL exporter protocol. `otlp` or `otlp_http`."
|
||||
type = string
|
||||
default = "otlp"
|
||||
}
|
||||
|
||||
variable "otel_environment_name" {
|
||||
description = "OTEL environment tag (typically the deployment environment: `prod`, `stage`, ...). Defaults to var.env."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "otel_capture_message_content" {
|
||||
description = "Whether to capture message content in spans (PII consideration)."
|
||||
type = string
|
||||
default = "false"
|
||||
}
|
||||
|
||||
variable "log_retention_days" {
|
||||
description = "Log Analytics workspace retention in days (applies to application logs routed through the workspace)."
|
||||
type = number
|
||||
default = 30
|
||||
}
|
||||
14
terraform/litellm/azure/versions.tf
Normal file
14
terraform/litellm/azure/versions.tf
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
azurerm = {
|
||||
source = "hashicorp/azurerm"
|
||||
version = "~> 3.117"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = "~> 3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue