mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(terraform): add GCP onyx composition and README (#15139)
This commit is contained in:
1 parent
8d8036d97e
commit
5f15e2aed2
6 files changed
+1652
No files matched your search
@@ -0,0 +1,422 @@
|
||||
# Onyx GCP modules
|
||||
|
||||
## Status
|
||||
|
||||
These modules pass validation against the `google` provider schema. Each one
|
||||
has a `terraform test` suite that plans it against a mocked provider. No one
|
||||
has applied them to a live project yet. Treat the first deployment as a first
|
||||
deployment.
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains Terraform modules to provision the core GCP
|
||||
infrastructure for Onyx:
|
||||
|
||||
- `vpc`: a custom-mode VPC with one subnet, secondary ranges for pods and
|
||||
services, Cloud NAT for egress, flow logs, and Private Service Access for
|
||||
Cloud SQL and Memorystore
|
||||
- `gke`: a regional GKE Standard cluster with Workload Identity, private
|
||||
nodes, pools for the application and the document index, optional GPU and
|
||||
sandbox pools, and the Onyx namespace and service account
|
||||
- `postgres`: a Cloud SQL for PostgreSQL instance with a private IP only, and
|
||||
five alert policies
|
||||
- `redis`: a Memorystore for Redis instance with AUTH on, and five alert
|
||||
policies
|
||||
- `gcs`: a bucket for the Onyx file store, with versioning, lifecycle rules
|
||||
and public access prevention
|
||||
- `cloud-armor`: a Cloud Armor policy with the OWASP Core Rule Set, two rate
|
||||
limits, and optional IP and country rules
|
||||
- `onyx`: a higher-level composition that wires the above together
|
||||
|
||||
Use the `onyx` module for a working cluster with sane defaults. Use the
|
||||
individual modules when you need more control.
|
||||
|
||||
These mirror the AWS modules in `../aws` and the Azure modules in `../azure`.
|
||||
Read [Differences from the AWS and Azure
|
||||
modules](#differences-from-the-aws-and-azure-modules) before you port a
|
||||
configuration across.
|
||||
|
||||
## Consuming these modules from another repository
|
||||
|
||||
The quickstart below uses local paths. To consume the modules from a different
|
||||
repository, point `source` at this repository and pin a ref:
|
||||
|
||||
```hcl
|
||||
module "vpc" {
|
||||
source = "git::https://github.com/onyx-dot-app/onyx.git//deployment/terraform/modules/gcp/vpc?ref=tf-gcp/v1.0.0"
|
||||
project_id = "my-project"
|
||||
region = "us-east1"
|
||||
}
|
||||
```
|
||||
|
||||
GCP releases have tags of the form `tf-gcp/vX.Y.Z`. Their versions are
|
||||
independent of the AWS modules (`tf/`), the Azure modules (`tf-azure/`) and
|
||||
Onyx product releases. A commit sha also works as a `ref`. Automated consumers
|
||||
should use a sha: no one can move a sha, but someone can move a tag.
|
||||
|
||||
Pin something. Without a `ref`, Terraform tracks the default branch, and an
|
||||
unrelated merge can change your infrastructure.
|
||||
|
||||
## Quickstart (copy/paste)
|
||||
|
||||
This module declares no `provider` block. Your root module configures the
|
||||
`google` provider and the `kubernetes` provider.
|
||||
|
||||
```hcl
|
||||
locals {
|
||||
project_id = "my-project"
|
||||
region = "us-east1"
|
||||
# Supply this from a secret store or TF_VAR_ in anything but a scratch stack.
|
||||
postgres_password = "your-postgres-password"
|
||||
}
|
||||
|
||||
provider "google" {
|
||||
project = local.project_id
|
||||
region = local.region
|
||||
}
|
||||
|
||||
module "onyx" {
|
||||
source = "./modules/gcp/onyx"
|
||||
|
||||
name = "onyx"
|
||||
project_id = local.project_id
|
||||
region = local.region
|
||||
size = "medium"
|
||||
|
||||
postgres_password = local.postgres_password
|
||||
|
||||
# Required. A public API server with no authorized networks is open to every
|
||||
# address on the internet. The module refuses to build one unless you
|
||||
# restrict it, make it private, or say the exposure is intended.
|
||||
master_authorized_networks = [
|
||||
{ cidr_block = "203.0.113.0/24", display_name = "office" },
|
||||
]
|
||||
}
|
||||
|
||||
# An access token for whoever runs Terraform. It is not keyed on the cluster,
|
||||
# so Terraform can read it at plan time, before the cluster exists.
|
||||
data "google_client_config" "default" {}
|
||||
|
||||
# The composition creates a namespace and a service account, so the kubernetes
|
||||
# provider needs the cluster. It reads the address and CA from the module.
|
||||
provider "kubernetes" {
|
||||
host = module.onyx.cluster_endpoint
|
||||
cluster_ca_certificate = base64decode(module.onyx.cluster_ca_certificate)
|
||||
token = data.google_client_config.default.access_token
|
||||
}
|
||||
|
||||
output "gcs_bucket_name" {
|
||||
value = module.onyx.gcs_bucket_name
|
||||
}
|
||||
|
||||
output "postgres_host" {
|
||||
value = module.onyx.postgres_host
|
||||
}
|
||||
|
||||
output "redis_host" {
|
||||
value = module.onyx.redis_host
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
|
||||
Configure the `kubernetes` provider from the module outputs, as above. Do not
|
||||
use a `data "google_container_cluster"` keyed on the cluster name: on the first
|
||||
apply the cluster does not exist, and the read fails at plan. Do not add
|
||||
`depends_on` to that data source or to the module either. It defers reads to
|
||||
apply time, and plan values that must be known become unknown. Module outputs
|
||||
carry the dependency without either problem. The Azure modules use the same
|
||||
pattern for the same reason.
|
||||
|
||||
## T-shirt sizing
|
||||
|
||||
`size` sets every compute and data-plane knob together. Any individual sizing
|
||||
variable set to a non-null value overrides its tier default.
|
||||
|
||||
| | small | medium | large |
|
||||
|---|---|---|---|
|
||||
| main pool machine | `n2-standard-8` | `n2-standard-16` | `n2-standard-16` |
|
||||
| main pool nodes | 1-3 | 1-5 | 2-8 |
|
||||
| index pool machine | `n2-highmem-4` | `n2-highmem-8` | `n2-highmem-16` |
|
||||
| index pool disk | 256 GB | 512 GB | 1024 GB |
|
||||
| database tier | `db-custom-2-8192` | `db-custom-2-8192` | `db-custom-4-16384` |
|
||||
| database disk | 64 GB | 128 GB | 256 GB |
|
||||
| cache | 5 GB | 10 GB | 20 GB |
|
||||
|
||||
Roughly: small suits pilots and small teams, medium a department or company,
|
||||
large an org-wide deployment. Node counts are for the whole pool, not per zone.
|
||||
The index pool is memory-optimised at every tier because on GCP it carries the
|
||||
document index itself.
|
||||
|
||||
### Using an existing network
|
||||
|
||||
```hcl
|
||||
module "onyx" {
|
||||
source = "./modules/gcp/onyx"
|
||||
|
||||
project_id = "my-project"
|
||||
region = "us-east1"
|
||||
create_network = false
|
||||
|
||||
network_id = "projects/my-project/global/networks/shared"
|
||||
subnet_id = "projects/my-project/regions/us-east1/subnetworks/gke"
|
||||
pods_range_name = "gke-pods"
|
||||
services_range_name = "gke-services"
|
||||
|
||||
postgres_password = local.postgres_password
|
||||
master_authorized_networks = [{ cidr_block = "203.0.113.0/24" }]
|
||||
}
|
||||
```
|
||||
|
||||
The network must already have a Private Service Access connection, or Cloud
|
||||
SQL and Memorystore fail to create. The nodes have no public IPs, so the
|
||||
subnet also needs a Cloud NAT for egress. `enable_flow_logs` does nothing here;
|
||||
configure flow logs on your own subnet.
|
||||
|
||||
## What each module does
|
||||
|
||||
### `onyx`
|
||||
|
||||
Enables the project APIs, then wires the modules below together with t-shirt
|
||||
sizing. Its outputs carry everything the Helm chart needs. One
|
||||
`deletion_protection` switch guards the cluster, database, cache, bucket and
|
||||
Cloud Armor policy.
|
||||
|
||||
### `vpc`
|
||||
|
||||
A custom-mode network and one subnet. The subnet has secondary ranges for pods
|
||||
and services, and Private Google Access, so nodes reach Google APIs without the
|
||||
NAT. Cloud NAT gives private nodes egress. The module reserves a Private
|
||||
Service Access range and peers it. Its `private_service_access_network_id`
|
||||
output arrives only once the peering exists, so Cloud SQL and Memorystore wait
|
||||
for it.
|
||||
|
||||
### `gke`
|
||||
|
||||
A regional GKE Standard cluster with Dataplane V2, private nodes, Workload
|
||||
Identity and the Gateway API. The nodes run as a dedicated service account with
|
||||
the minimum roles. It creates a main pool, a tainted document-index pool, and
|
||||
optional GPU and Craft sandbox pools. It also creates the `onyx` namespace and
|
||||
the `onyx-workload-access` service account.
|
||||
|
||||
### `postgres`
|
||||
|
||||
A Cloud SQL for PostgreSQL instance with a private IP only, TLS enforced,
|
||||
backups and point-in-time recovery. It creates the `onyx` database. It sets the
|
||||
password of the `postgres` user that Cloud SQL ships.
|
||||
|
||||
### `redis`
|
||||
|
||||
A Memorystore for Redis instance on the Private Service Access range. AUTH is
|
||||
always on. The eviction policy is `volatile-lru`, because Celery broker keys
|
||||
carry no TTL and an `allkeys-*` policy would drop queued tasks. RDB snapshots
|
||||
are on.
|
||||
|
||||
### `gcs`
|
||||
|
||||
A bucket with uniform access, public access prevention, versioning, and a
|
||||
lifecycle rule for old versions. It grants the workload identity principal
|
||||
object access and the narrow read that Onyx needs at startup.
|
||||
|
||||
### `cloud-armor`
|
||||
|
||||
A backend security policy with the OWASP Core Rule Set at sensitivity 1, an API
|
||||
rate limit, a global rate limit and Adaptive Protection. It takes effect only on
|
||||
an L7 load balancer; see below.
|
||||
|
||||
## Differences from the AWS and Azure modules
|
||||
|
||||
Most of these follow from the platform rather than from taste.
|
||||
|
||||
| | AWS | Azure | GCP |
|
||||
|---|---|---|---|
|
||||
| document index | managed OpenSearch, or in-cluster | in-cluster only | in-cluster only; GCP has no managed OpenSearch |
|
||||
| cache | ElastiCache | Azure Managed Redis, off by default | Memorystore for Redis, on by default |
|
||||
| pod identity | IRSA: assume a role | federate a managed identity to a service account | grant IAM roles straight to the Kubernetes service account |
|
||||
| WAF attachment | ALB | Application Gateway | L7 load balancer through a BackendConfig or GCPBackendPolicy |
|
||||
| flow logs | on, to CloudWatch | opt-in, needs a storage account | on, to Cloud Logging |
|
||||
| project APIs | none | none | enabled by the composition |
|
||||
| provider config | declared inside the composition | declared by your root module | declared by your root module |
|
||||
|
||||
**Managed Redis works here with no extra settings.** Onyx uses database 0 for
|
||||
the app, 14 for Celery results and 15 for the Celery broker. Memorystore for
|
||||
Redis is one primary endpoint with the default 16 databases. The Onyx defaults
|
||||
work unchanged. Azure Managed Redis has only database 0, and the Redis Cluster
|
||||
products shard keys, which breaks Celery with `CROSSSLOT`. So the composition
|
||||
turns Memorystore on by default. Set `enable_redis = false` to use the
|
||||
in-cluster Redis.
|
||||
|
||||
**The document index runs in the cluster.** GCP has no managed OpenSearch. The
|
||||
chart's OpenSearch subchart runs on the tainted index pool.
|
||||
|
||||
**Workload Identity needs no Google service account.** GKE Workload Identity
|
||||
Federation lets IAM name a Kubernetes service account directly. The bucket
|
||||
grant goes to the `workload_identity_principal` output. There is no Google
|
||||
service account to impersonate and no annotation on the Kubernetes service
|
||||
account. Use the same output to grant access to other resources.
|
||||
|
||||
**Cloud Armor attaches only to an L7 load balancer.** Put the
|
||||
`cloud_armor_policy_name` output in a GKE `BackendConfig`
|
||||
(`spec.securityPolicy.name`) for Ingress, or in a `GCPBackendPolicy`
|
||||
(`spec.default.securityPolicy`) for Gateway. The chart's ingress-nginx
|
||||
controller sits behind a Service of type `LoadBalancer`, which is an L4
|
||||
passthrough load balancer. That load balancer cannot carry the policy. The
|
||||
policy then protects nothing, and nothing reports it. This is the same trap as
|
||||
the Azure WAF policy without an Application Gateway.
|
||||
|
||||
**Flow logs are on by default.** GCP writes them to Cloud Logging and needs no
|
||||
extra infrastructure. Azure needs a Network Watcher and a storage account, so
|
||||
it keeps them off.
|
||||
|
||||
**The composition enables the project APIs.** A new project has most of them
|
||||
off. `enable_project_apis` turns on Compute, GKE, Cloud SQL Admin, Memorystore,
|
||||
Service Networking, Storage, Monitoring, Logging, IAM and Resource Manager. It
|
||||
never turns them off on destroy, because other workloads may use them.
|
||||
|
||||
## Installing the Onyx Helm chart (after Terraform)
|
||||
|
||||
```bash
|
||||
gcloud container clusters get-credentials "$(terraform output -raw cluster_name)" \
|
||||
--location "$(terraform output -raw location)" --project my-project
|
||||
```
|
||||
|
||||
**Install into the `onyx` namespace.** A Kubernetes service account belongs to
|
||||
one namespace, and the `gke` module creates the one with the bucket grant in
|
||||
`onyx`. A release in a different namespace references an account that does not
|
||||
exist there, and the API and Celery pods never start.
|
||||
|
||||
Terraform creates the namespace itself, because the service account needs it
|
||||
and the Helm install happens afterwards. So the release joins that namespace
|
||||
and does not create one:
|
||||
|
||||
```bash
|
||||
helm install onyx onyx/onyx --namespace onyx -f values.yaml
|
||||
```
|
||||
|
||||
Set `create_workload_namespace = false` if something else already creates it.
|
||||
|
||||
Four pieces of chart configuration are necessary. Their defaults do not work
|
||||
here, so a plain `helm install` does not use the infrastructure that Terraform
|
||||
built.
|
||||
|
||||
### 1. Point the workloads at the workload service account
|
||||
|
||||
The chart runs every Onyx pod, including the API server and the Celery
|
||||
workers, as `serviceAccount.name`. The bucket grant names that account, so no
|
||||
pod label and no annotation are necessary. Without it, the pods run as
|
||||
`default` and every file store call gets `403`.
|
||||
|
||||
```yaml
|
||||
serviceAccount:
|
||||
create: false
|
||||
name: onyx-workload-access # the gke module's default, in namespace onyx
|
||||
```
|
||||
|
||||
### 2. Point the file store at the bucket
|
||||
|
||||
Turn off the bundled MinIO and set the GCS keys. The chart copies every
|
||||
non-empty `configMap` key into the pod environment, so no chart change is
|
||||
necessary.
|
||||
|
||||
```yaml
|
||||
minio:
|
||||
enabled: false
|
||||
|
||||
configMap:
|
||||
FILE_STORE_BACKEND: "gcs"
|
||||
GCS_FILE_STORE_BUCKET_NAME: "<gcs_bucket_name output>"
|
||||
GCS_PROJECT_ID: "<gcs_project_id output>"
|
||||
```
|
||||
|
||||
Leave `GCS_SERVICE_ACCOUNT_KEY_PATH` and `GCS_SERVICE_ACCOUNT_KEY_JSON` unset.
|
||||
Onyx then uses Application Default Credentials, which pick up the workload
|
||||
identity from step 1.
|
||||
|
||||
### 3. Point Onyx at Cloud SQL and Memorystore
|
||||
|
||||
```yaml
|
||||
postgresql:
|
||||
enabled: false
|
||||
|
||||
redis:
|
||||
enabled: false
|
||||
|
||||
configMap:
|
||||
POSTGRES_HOST: "<postgres_host output>"
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DB: "onyx" # postgres_db_name output
|
||||
REDIS_HOST: "<redis_host output>"
|
||||
REDIS_PORT: "6379" # redis_port output
|
||||
|
||||
auth:
|
||||
postgresql:
|
||||
existingSecret: "onyx-postgresql" # keys: username, password
|
||||
redis:
|
||||
existingSecret: "onyx-redis" # key: redis_password = redis_auth_string output
|
||||
```
|
||||
|
||||
Set `POSTGRES_DB`. Without it, Onyx uses the `postgres` database that Cloud SQL
|
||||
ships, and the `onyx` database stays empty.
|
||||
|
||||
Create the two secrets in the `onyx` namespace from the Terraform outputs.
|
||||
Do not put the passwords in `values.yaml`. Cloud SQL accepts only encrypted
|
||||
connections, and Onyx encrypts by default, so no TLS setting is necessary. With
|
||||
`redis_transit_encryption_enabled = true`, also set `REDIS_PORT: "6378"` and
|
||||
the chart's `redisTls` values, with the CA from the redis module's
|
||||
`server_ca_certs` output.
|
||||
|
||||
### 4. Send the document index to its own node pool
|
||||
|
||||
The `gke` module taints the index pool `document-index=true:NoSchedule`, so
|
||||
nothing else lands on it. The OpenSearch subchart sets no tolerations of its
|
||||
own. Without this, it goes onto the main pool and the index pool stays empty:
|
||||
|
||||
```yaml
|
||||
opensearch:
|
||||
nodeSelector:
|
||||
onyx.app/workload: document-index
|
||||
tolerations:
|
||||
- key: document-index
|
||||
operator: Equal
|
||||
value: "true"
|
||||
effect: NoSchedule
|
||||
```
|
||||
|
||||
Set `index_node_pool_enabled = false` to run the index on the main pool and
|
||||
skip this.
|
||||
|
||||
## Testing
|
||||
|
||||
Every module has a `terraform test` suite that plans it against a mocked
|
||||
provider. The suites need no GCP project and no credentials:
|
||||
|
||||
```bash
|
||||
cd deployment/terraform/modules/gcp/vpc
|
||||
terraform init -backend=false
|
||||
terraform test
|
||||
```
|
||||
|
||||
They cover the wiring and the input validation. Without them, a wrong argument
|
||||
name or an out-of-range value shows only at apply. They do not prove that the
|
||||
modules work against GCP. Only an apply does that.
|
||||
|
||||
## Security
|
||||
|
||||
- The bucket has uniform access and enforced public access prevention, so no
|
||||
object can become public.
|
||||
- The database and cache have private IPs only. The database refuses
|
||||
unencrypted connections. The cache always requires AUTH.
|
||||
- Nodes have no public IPs, run as a dedicated service account with the
|
||||
minimum roles, and use shielded VMs. Pods see only their own identity through
|
||||
the GKE metadata server.
|
||||
- The API server will not be built open. Set `master_authorized_networks`, or
|
||||
`private_endpoint_enabled`, or `allow_unrestricted_api_server_access` to
|
||||
record that an open control plane is what you meant.
|
||||
- The database will not be built without a password of at least 8 characters.
|
||||
- `deletion_protection` is on by default for the cluster, database, cache,
|
||||
bucket and Cloud Armor policy. Set it to `false` and apply before a destroy.
|
||||
@@ -0,0 +1,266 @@
|
||||
locals {
|
||||
workspace = terraform.workspace
|
||||
merged_labels = merge(var.labels, { tenant = var.name, environment = local.workspace })
|
||||
|
||||
resource_prefix = "${var.name}-${local.workspace}"
|
||||
cluster_name = local.resource_prefix
|
||||
postgres_name = "${var.name}-postgres-${local.workspace}"
|
||||
redis_name = "${var.name}-redis-${local.workspace}"
|
||||
|
||||
# Bucket names are globally unique across every GCP customer, so the
|
||||
# readable prefix is truncated and a short digest of the full identity is
|
||||
# appended to keep collisions unlikely.
|
||||
bucket_name_digest = substr(sha1("${var.project_id}-${var.name}-${local.workspace}-${var.region}"), 0, 8)
|
||||
bucket_name_sanitized = replace(lower("${local.resource_prefix}-files"), "/[^a-z0-9-]/", "-")
|
||||
# 54 + "-" + 8 = 63, the most a bucket name without dots may have.
|
||||
bucket_name = coalesce(
|
||||
var.bucket_name,
|
||||
"${substr(local.bucket_name_sanitized, 0, 54)}-${local.bucket_name_digest}",
|
||||
)
|
||||
|
||||
project_apis = var.enable_project_apis ? toset([
|
||||
"compute.googleapis.com",
|
||||
"container.googleapis.com",
|
||||
"sqladmin.googleapis.com",
|
||||
"redis.googleapis.com",
|
||||
"servicenetworking.googleapis.com",
|
||||
"storage.googleapis.com",
|
||||
"monitoring.googleapis.com",
|
||||
"logging.googleapis.com",
|
||||
"iam.googleapis.com",
|
||||
# Project IAM grants for the node service account go through this API.
|
||||
"cloudresourcemanager.googleapis.com",
|
||||
]) : toset([])
|
||||
|
||||
# T-shirt size defaults, chosen so each tier lands on the GCP machine closest
|
||||
# to what the AWS and Azure compositions pick. The index pool is
|
||||
# memory-optimised because on GCP it carries the document index itself:
|
||||
# there is no managed OpenSearch to move that load off the cluster.
|
||||
size_defaults = {
|
||||
small = {
|
||||
main_node_machine_type = "n2-standard-8"
|
||||
main_node_min_count = 1
|
||||
main_node_max_count = 3
|
||||
index_node_machine_type = "n2-highmem-4"
|
||||
index_node_disk_size_gb = 256
|
||||
postgres_tier = "db-custom-2-8192"
|
||||
postgres_disk_size_gb = 64
|
||||
redis_memory_size_gb = 5
|
||||
}
|
||||
medium = {
|
||||
main_node_machine_type = "n2-standard-16"
|
||||
main_node_min_count = 1
|
||||
main_node_max_count = 5
|
||||
index_node_machine_type = "n2-highmem-8"
|
||||
index_node_disk_size_gb = 512
|
||||
postgres_tier = "db-custom-2-8192"
|
||||
postgres_disk_size_gb = 128
|
||||
redis_memory_size_gb = 10
|
||||
}
|
||||
large = {
|
||||
main_node_machine_type = "n2-standard-16"
|
||||
main_node_min_count = 2
|
||||
main_node_max_count = 8
|
||||
index_node_machine_type = "n2-highmem-16"
|
||||
index_node_disk_size_gb = 1024
|
||||
postgres_tier = "db-custom-4-16384"
|
||||
postgres_disk_size_gb = 256
|
||||
redis_memory_size_gb = 20
|
||||
}
|
||||
}
|
||||
sizing = local.size_defaults[var.size]
|
||||
|
||||
# An explicitly set variable always wins over its tier default.
|
||||
main_node_machine_type = coalesce(var.main_node_machine_type, local.sizing.main_node_machine_type)
|
||||
main_node_min_count = coalesce(var.main_node_min_count, local.sizing.main_node_min_count)
|
||||
main_node_max_count = coalesce(var.main_node_max_count, local.sizing.main_node_max_count)
|
||||
index_node_machine_type = coalesce(var.index_node_machine_type, local.sizing.index_node_machine_type)
|
||||
index_node_disk_size_gb = coalesce(var.index_node_disk_size_gb, local.sizing.index_node_disk_size_gb)
|
||||
postgres_tier = coalesce(var.postgres_tier, local.sizing.postgres_tier)
|
||||
postgres_disk_size_gb = coalesce(var.postgres_disk_size_gb, local.sizing.postgres_disk_size_gb)
|
||||
redis_memory_size_gb = coalesce(var.redis_memory_size_gb, local.sizing.redis_memory_size_gb)
|
||||
|
||||
# The index label and taint are the gke module's defaults, which the chart
|
||||
# values in the README select and tolerate. The gke module drops this pool
|
||||
# when index_node_pool_enabled is false.
|
||||
node_pools = {
|
||||
main = {
|
||||
machine_type = local.main_node_machine_type
|
||||
min_count = local.main_node_min_count
|
||||
max_count = local.main_node_max_count
|
||||
disk_size_gb = var.main_node_disk_size_gb
|
||||
}
|
||||
index = {
|
||||
machine_type = local.index_node_machine_type
|
||||
min_count = var.index_node_min_count
|
||||
max_count = var.index_node_max_count
|
||||
disk_size_gb = local.index_node_disk_size_gb
|
||||
labels = { "onyx.app/workload" = "document-index" }
|
||||
taints = [{ key = "document-index", value = "true", effect = "NO_SCHEDULE" }]
|
||||
}
|
||||
}
|
||||
|
||||
# Read per module rather than passed as the bare variable, so the tests can
|
||||
# see that every guarded resource follows the one switch.
|
||||
deletion_protection = {
|
||||
gke = var.deletion_protection
|
||||
postgres = var.deletion_protection
|
||||
redis = var.deletion_protection
|
||||
gcs = var.deletion_protection
|
||||
cloud_armor = var.deletion_protection
|
||||
}
|
||||
}
|
||||
|
||||
resource "google_project_service" "this" {
|
||||
for_each = local.project_apis
|
||||
|
||||
project = var.project_id
|
||||
service = each.value
|
||||
|
||||
# Other workloads in the project may use the same APIs, and turning one off
|
||||
# on destroy breaks them.
|
||||
disable_on_destroy = false
|
||||
}
|
||||
|
||||
locals {
|
||||
# Reading the project back off the API resources is what orders every
|
||||
# module after them. A depends_on on the modules would do it too, but it
|
||||
# defers the gke module's project lookup to apply, which makes the bucket
|
||||
# grant's principal unknown at plan and fails its for_each.
|
||||
project_id = coalesce(one(distinct([for s in google_project_service.this : s.project])), var.project_id)
|
||||
|
||||
network_id = var.create_network ? module.vpc[0].network_id : var.network_id
|
||||
subnet_id = var.create_network ? module.vpc[0].subnet_id : var.subnet_id
|
||||
pods_range_name = var.create_network ? module.vpc[0].pods_range_name : var.pods_range_name
|
||||
services_range_name = var.create_network ? module.vpc[0].services_range_name : var.services_range_name
|
||||
|
||||
# Cloud SQL and Memorystore take the id that arrives only once the Private
|
||||
# Service Access peering exists; network_id would let them race it.
|
||||
private_service_network_id = var.create_network ? module.vpc[0].private_service_access_network_id : var.network_id
|
||||
|
||||
bucket_object_admin_members = [module.gke.workload_identity_principal]
|
||||
}
|
||||
|
||||
module "vpc" {
|
||||
source = "../vpc"
|
||||
count = var.create_network ? 1 : 0
|
||||
|
||||
name = local.resource_prefix
|
||||
project_id = local.project_id
|
||||
region = var.region
|
||||
labels = local.merged_labels
|
||||
|
||||
subnet_cidr = var.subnet_cidr
|
||||
pods_cidr = var.pods_cidr
|
||||
services_cidr = var.services_cidr
|
||||
psa_prefix_length = var.psa_prefix_length
|
||||
enable_flow_logs = var.enable_flow_logs
|
||||
}
|
||||
|
||||
module "gke" {
|
||||
source = "../gke"
|
||||
|
||||
name = local.cluster_name
|
||||
project_id = local.project_id
|
||||
region = var.region
|
||||
kubernetes_version = var.kubernetes_version
|
||||
release_channel = var.release_channel
|
||||
deletion_protection = local.deletion_protection.gke
|
||||
labels = local.merged_labels
|
||||
|
||||
network_id = local.network_id
|
||||
subnet_id = local.subnet_id
|
||||
pods_range_name = local.pods_range_name
|
||||
services_range_name = local.services_range_name
|
||||
|
||||
private_endpoint_enabled = var.private_endpoint_enabled
|
||||
master_authorized_networks = var.master_authorized_networks
|
||||
allow_unrestricted_api_server_access = var.allow_unrestricted_api_server_access
|
||||
|
||||
node_pools = local.node_pools
|
||||
index_node_pool_enabled = var.index_node_pool_enabled
|
||||
enable_gpu_node_pool = var.enable_gpu_node_pool
|
||||
enable_sandbox_node_pool = var.enable_sandbox_node_pool
|
||||
|
||||
create_workload_namespace = var.create_workload_namespace
|
||||
workload_namespace = var.workload_namespace
|
||||
workload_service_account_name = var.workload_service_account_name
|
||||
}
|
||||
|
||||
module "gcs" {
|
||||
source = "../gcs"
|
||||
|
||||
name = local.bucket_name
|
||||
project_id = local.project_id
|
||||
location = upper(var.region)
|
||||
force_destroy = var.bucket_force_destroy
|
||||
deletion_protection = local.deletion_protection.gcs
|
||||
labels = local.merged_labels
|
||||
|
||||
# Built from inputs inside the gke module, so it is known at plan and safe
|
||||
# as a for_each key there.
|
||||
object_admin_members = local.bucket_object_admin_members
|
||||
}
|
||||
|
||||
module "postgres" {
|
||||
source = "../postgres"
|
||||
|
||||
name = local.postgres_name
|
||||
project_id = local.project_id
|
||||
region = var.region
|
||||
network_id = local.private_service_network_id
|
||||
deletion_protection = local.deletion_protection.postgres
|
||||
labels = local.merged_labels
|
||||
|
||||
database_version = var.postgres_database_version
|
||||
tier = local.postgres_tier
|
||||
disk_size_gb = local.postgres_disk_size_gb
|
||||
availability_type = var.postgres_availability_type
|
||||
max_connections = var.postgres_max_connections
|
||||
|
||||
db_name = var.postgres_db_name
|
||||
username = var.postgres_username
|
||||
password = var.postgres_password
|
||||
|
||||
enable_alerts = var.enable_alerts
|
||||
notification_channels = var.notification_channels
|
||||
}
|
||||
|
||||
module "redis" {
|
||||
source = "../redis"
|
||||
count = var.enable_redis ? 1 : 0
|
||||
|
||||
name = local.redis_name
|
||||
project_id = local.project_id
|
||||
region = var.region
|
||||
network_id = local.private_service_network_id
|
||||
deletion_protection = local.deletion_protection.redis
|
||||
labels = local.merged_labels
|
||||
|
||||
tier = var.redis_tier
|
||||
memory_size_gb = local.redis_memory_size_gb
|
||||
transit_encryption_enabled = var.redis_transit_encryption_enabled
|
||||
|
||||
enable_alerts = var.enable_alerts
|
||||
notification_channels = var.notification_channels
|
||||
}
|
||||
|
||||
module "cloud_armor" {
|
||||
source = "../cloud-armor"
|
||||
count = var.enable_cloud_armor ? 1 : 0
|
||||
|
||||
name = local.resource_prefix
|
||||
project_id = local.project_id
|
||||
deletion_protection = local.deletion_protection.cloud_armor
|
||||
labels = local.merged_labels
|
||||
|
||||
preview = var.cloud_armor_preview
|
||||
sensitivity = var.cloud_armor_sensitivity
|
||||
allowed_ip_cidrs = var.cloud_armor_allowed_ip_cidrs
|
||||
blocked_ip_cidrs = var.cloud_armor_blocked_ip_cidrs
|
||||
rate_limit_exempt_ip_cidrs = var.cloud_armor_rate_limit_exempt_ip_cidrs
|
||||
geo_restriction_countries = var.cloud_armor_geo_restriction_countries
|
||||
rate_limit_threshold = var.cloud_armor_rate_limit_threshold
|
||||
api_rate_limit_threshold = var.cloud_armor_api_rate_limit_threshold
|
||||
adaptive_protection_enabled = var.cloud_armor_adaptive_protection_enabled
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
output "cluster_name" {
|
||||
description = "Name of the GKE cluster"
|
||||
value = module.gke.cluster_name
|
||||
}
|
||||
|
||||
output "location" {
|
||||
description = "Region of the cluster. gcloud container clusters get-credentials takes this as --location."
|
||||
value = module.gke.location
|
||||
}
|
||||
|
||||
output "cluster_endpoint" {
|
||||
description = "API server URL for the kubernetes and helm providers"
|
||||
value = module.gke.endpoint
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
# A CA certificate is public by design, so this is not marked sensitive.
|
||||
output "cluster_ca_certificate" {
|
||||
description = "Cluster CA certificate, base64 encoded"
|
||||
value = module.gke.cluster_ca_certificate
|
||||
}
|
||||
|
||||
output "workload_identity_principal" {
|
||||
description = "IAM member for the workload service account, for granting it roles on resources outside this module"
|
||||
value = module.gke.workload_identity_principal
|
||||
}
|
||||
|
||||
output "workload_namespace" {
|
||||
description = "Namespace of the workload service account. Install the Helm release into it."
|
||||
value = module.gke.workload_namespace
|
||||
}
|
||||
|
||||
output "workload_service_account_name" {
|
||||
description = "Kubernetes service account that holds the bucket grant. Set it as the chart's serviceAccount.name."
|
||||
value = module.gke.workload_service_account_name
|
||||
}
|
||||
|
||||
output "node_service_account_email" {
|
||||
description = "Service account the nodes run as. Grant it roles/artifactregistry.reader on a registry in another project to pull from it."
|
||||
value = module.gke.node_service_account_email
|
||||
}
|
||||
|
||||
# With a supplied network these are read off the ids given, so they name what
|
||||
# the cluster joined either way.
|
||||
output "network_name" {
|
||||
description = "Name of the VPC network the cluster joins"
|
||||
value = var.create_network ? module.vpc[0].network_name : element(reverse(split("/", var.network_id)), 0)
|
||||
}
|
||||
|
||||
output "subnet_name" {
|
||||
description = "Name of the subnet the nodes join"
|
||||
value = var.create_network ? module.vpc[0].subnet_name : element(reverse(split("/", var.subnet_id)), 0)
|
||||
}
|
||||
|
||||
# --- Values the Helm chart needs ---------------------------------------------
|
||||
|
||||
output "gcs_bucket_name" {
|
||||
description = "Set as GCS_FILE_STORE_BUCKET_NAME"
|
||||
value = module.gcs.bucket_name
|
||||
}
|
||||
|
||||
output "gcs_project_id" {
|
||||
description = "Set as GCS_PROJECT_ID"
|
||||
value = var.project_id
|
||||
}
|
||||
|
||||
output "postgres_host" {
|
||||
description = "Private IP of the database. Set as POSTGRES_HOST."
|
||||
value = module.postgres.host
|
||||
}
|
||||
|
||||
output "postgres_port" {
|
||||
description = "Database port. Set as POSTGRES_PORT."
|
||||
value = module.postgres.port
|
||||
}
|
||||
|
||||
output "postgres_db_name" {
|
||||
description = "Database name. Set as POSTGRES_DB; Onyx falls back to \"postgres\" without it."
|
||||
value = module.postgres.db_name
|
||||
}
|
||||
|
||||
output "postgres_username" {
|
||||
description = "Login Onyx connects as. Set as POSTGRES_USER."
|
||||
value = module.postgres.username
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "redis_host" {
|
||||
description = "Private IP of the cache, null when enable_redis is false. Set as REDIS_HOST."
|
||||
value = one(module.redis[*].host)
|
||||
}
|
||||
|
||||
output "redis_port" {
|
||||
description = "Cache port: 6379, or 6378 with transit encryption. Set as REDIS_PORT."
|
||||
value = one(module.redis[*].port)
|
||||
}
|
||||
|
||||
# Memorystore generates this rather than accepting one, so it comes out of the
|
||||
# module rather than going in.
|
||||
output "redis_auth_string" {
|
||||
description = "Generated AUTH string. Set as REDIS_PASSWORD through a secret."
|
||||
value = one(module.redis[*].auth_string)
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "redis_url_scheme" {
|
||||
description = "\"rediss\" with transit encryption, \"redis\" without. With rediss, set REDIS_SSL=true."
|
||||
value = one(module.redis[*].redis_url_scheme)
|
||||
}
|
||||
|
||||
output "cloud_armor_policy_name" {
|
||||
description = "Cloud Armor policy to name in a BackendConfig or GCPBackendPolicy, null when disabled"
|
||||
value = try(module.cloud_armor[0].policy_name, null)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
# Plans the whole composition against mocked providers, so these run without a
|
||||
# GCP project or a cluster. Run with `terraform test` from the module directory.
|
||||
|
||||
mock_provider "google" {
|
||||
# The workload identity principal is written with the project number, and
|
||||
# a mocked random string would make it impossible to check.
|
||||
override_data {
|
||||
target = module.gke.data.google_project.this
|
||||
values = {
|
||||
number = "123456789012"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mock_provider "kubernetes" {}
|
||||
|
||||
variables {
|
||||
name = "onyx"
|
||||
project_id = "example-project"
|
||||
region = "us-east1"
|
||||
postgres_password = "not-a-real-password"
|
||||
|
||||
# The composition refuses a public API server that no range restricts, so
|
||||
# every case below has to say what it wants.
|
||||
master_authorized_networks = [{ cidr_block = "203.0.113.0/24" }]
|
||||
}
|
||||
|
||||
run "medium_is_the_default_tier" {
|
||||
command = plan
|
||||
|
||||
assert {
|
||||
condition = local.main_node_machine_type == "n2-standard-16"
|
||||
error_message = "Medium should land on the GCP machine closest to the AWS composition's m7i.4xlarge."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.main_node_min_count == 1 && local.main_node_max_count == 5
|
||||
error_message = "Medium runs 1-5 main nodes, like the Azure composition."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.index_node_machine_type == "n2-highmem-8" && local.index_node_disk_size_gb == 512
|
||||
error_message = "The index pool is memory-optimised because on GCP it carries the document index itself."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.postgres_tier == "db-custom-2-8192" && local.postgres_disk_size_gb == 128
|
||||
error_message = "Medium keeps the small database tier, matching the AWS composition."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.redis_memory_size_gb == 10
|
||||
error_message = "Medium gets a 10 GB cache, like the Azure composition's Balanced_B10."
|
||||
}
|
||||
}
|
||||
|
||||
run "small_moves_every_knob_together" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
size = "small"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = alltrue([
|
||||
local.main_node_machine_type == "n2-standard-8",
|
||||
local.main_node_min_count == 1,
|
||||
local.main_node_max_count == 3,
|
||||
local.index_node_machine_type == "n2-highmem-4",
|
||||
local.index_node_disk_size_gb == 256,
|
||||
local.postgres_tier == "db-custom-2-8192",
|
||||
local.postgres_disk_size_gb == 64,
|
||||
local.redis_memory_size_gb == 5,
|
||||
])
|
||||
error_message = "The small tier should move compute, database, cache and index sizing together."
|
||||
}
|
||||
}
|
||||
|
||||
run "large_moves_every_knob_together" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
size = "large"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = alltrue([
|
||||
local.main_node_machine_type == "n2-standard-16",
|
||||
local.main_node_min_count == 2,
|
||||
local.main_node_max_count == 8,
|
||||
local.index_node_machine_type == "n2-highmem-16",
|
||||
local.index_node_disk_size_gb == 1024,
|
||||
local.postgres_tier == "db-custom-4-16384",
|
||||
local.postgres_disk_size_gb == 256,
|
||||
local.redis_memory_size_gb == 20,
|
||||
])
|
||||
error_message = "The large tier should move compute, database, cache and index sizing together."
|
||||
}
|
||||
}
|
||||
|
||||
run "an_explicit_value_beats_its_tier_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
size = "small"
|
||||
postgres_disk_size_gb = 512
|
||||
main_node_max_count = 10
|
||||
redis_memory_size_gb = 8
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.postgres_disk_size_gb == 512 && local.main_node_max_count == 10 && local.redis_memory_size_gb == 8
|
||||
error_message = "An explicitly set variable must win over the tier default."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.main_node_machine_type == "n2-standard-8"
|
||||
error_message = "Overriding one knob must not disturb the others in the tier."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.node_pools.main.max_count == 10
|
||||
error_message = "The override has to reach the node pool the gke module builds."
|
||||
}
|
||||
}
|
||||
|
||||
run "the_derived_bucket_name_is_one_gcs_accepts" {
|
||||
command = plan
|
||||
|
||||
assert {
|
||||
condition = can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", local.bucket_name))
|
||||
error_message = "Bucket names allow 3-63 lowercase letters, digits and hyphens."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = startswith(local.bucket_name, "onyx-default-files-") && endswith(local.bucket_name, local.bucket_name_digest)
|
||||
error_message = "The bucket name should read as its deployment and end in the digest."
|
||||
}
|
||||
}
|
||||
|
||||
run "a_long_name_keeps_the_whole_digest" {
|
||||
command = plan
|
||||
|
||||
# The longest name the composition accepts. Only a long workspace name
|
||||
# pushes the prefix past 54 characters, and tests cannot set one.
|
||||
variables {
|
||||
name = "onyx-enterprise-producti"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(local.bucket_name) <= 63 && endswith(local.bucket_name, local.bucket_name_digest)
|
||||
error_message = "Truncation must eat the readable prefix, never the digest."
|
||||
}
|
||||
}
|
||||
|
||||
run "an_explicit_bucket_name_is_used_as_given" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
bucket_name = "onyx-files-prod"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.gcs_bucket_name == "onyx-files-prod"
|
||||
error_message = "An explicit bucket name must reach the bucket unchanged."
|
||||
}
|
||||
}
|
||||
|
||||
run "deletion_protection_is_on_everywhere_by_default" {
|
||||
command = plan
|
||||
|
||||
assert {
|
||||
condition = alltrue(values(local.deletion_protection)) && length(local.deletion_protection) == 5
|
||||
error_message = "The cluster, database, cache, bucket and policy must all be protected by default."
|
||||
}
|
||||
}
|
||||
|
||||
run "one_switch_turns_deletion_protection_off_everywhere" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
deletion_protection = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = !anytrue(values(local.deletion_protection))
|
||||
error_message = "deletion_protection = false must reach every module, or a teardown stops halfway."
|
||||
}
|
||||
}
|
||||
|
||||
run "data_services_wait_for_the_peering" {
|
||||
command = plan
|
||||
|
||||
# Distinct ids for the two outputs make the choice visible. In the real vpc
|
||||
# module they hold the same string and differ only in when they arrive.
|
||||
override_module {
|
||||
target = module.vpc
|
||||
outputs = {
|
||||
network_id = "projects/example-project/global/networks/plain"
|
||||
private_service_access_network_id = "projects/example-project/global/networks/after-peering"
|
||||
subnet_id = "projects/example-project/regions/us-east1/subnetworks/onyx-default-subnet"
|
||||
pods_range_name = "onyx-default-pods"
|
||||
services_range_name = "onyx-default-services"
|
||||
network_name = "onyx-default-vpc"
|
||||
subnet_name = "onyx-default-subnet"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.private_service_network_id == "projects/example-project/global/networks/after-peering"
|
||||
error_message = "Cloud SQL and Memorystore must take the id that waits for Private Service Access, or they race the peering."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.network_id == "projects/example-project/global/networks/plain"
|
||||
error_message = "The cluster has no reason to wait for the peering."
|
||||
}
|
||||
}
|
||||
|
||||
run "the_bucket_grant_goes_to_the_workload_service_account" {
|
||||
command = plan
|
||||
|
||||
assert {
|
||||
condition = local.bucket_object_admin_members == ["principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/example-project.svc.id.goog/subject/ns/onyx/sa/onyx-workload-access"]
|
||||
error_message = "The bucket should grant the Kubernetes service account the chart runs as, and nothing else."
|
||||
}
|
||||
|
||||
# Known at plan is the point: the gcs module keys a for_each on it.
|
||||
assert {
|
||||
condition = output.workload_identity_principal == local.bucket_object_admin_members[0]
|
||||
error_message = "The published principal must be the one the bucket grants."
|
||||
}
|
||||
}
|
||||
|
||||
run "redis_and_cloud_armor_are_on_by_default" {
|
||||
command = plan
|
||||
|
||||
assert {
|
||||
condition = length(module.redis) == 1 && length(module.cloud_armor) == 1
|
||||
error_message = "Memorystore and Cloud Armor should both be created by default."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.redis_url_scheme == "redis"
|
||||
error_message = "The cache is plaintext by default, so the scheme is redis."
|
||||
}
|
||||
}
|
||||
|
||||
run "turning_off_redis_and_cloud_armor_drops_them" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
enable_redis = false
|
||||
enable_cloud_armor = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(module.redis) == 0 && length(module.cloud_armor) == 0
|
||||
error_message = "Disabled modules must create nothing."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = alltrue([
|
||||
output.redis_host == null,
|
||||
output.redis_port == null,
|
||||
output.redis_url_scheme == null,
|
||||
output.cloud_armor_policy_name == null,
|
||||
])
|
||||
error_message = "With nothing created there is nothing to publish."
|
||||
}
|
||||
}
|
||||
|
||||
run "the_project_apis_are_enabled_by_default" {
|
||||
command = plan
|
||||
|
||||
assert {
|
||||
condition = length(google_project_service.this) == 10
|
||||
error_message = "Every API a module calls should be enabled."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = alltrue([for s in google_project_service.this : !s.disable_on_destroy])
|
||||
error_message = "Destroying Onyx must not turn off APIs other workloads in the project may use."
|
||||
}
|
||||
}
|
||||
|
||||
run "apis_managed_elsewhere_are_left_alone" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
enable_project_apis = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(google_project_service.this) == 0
|
||||
error_message = "enable_project_apis = false must enable nothing."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.project_id == "example-project"
|
||||
error_message = "With no API resources the modules still need the project."
|
||||
}
|
||||
}
|
||||
|
||||
run "an_existing_network_is_used_as_given" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
create_network = false
|
||||
network_id = "projects/example-project/global/networks/shared"
|
||||
subnet_id = "projects/example-project/regions/us-east1/subnetworks/gke"
|
||||
pods_range_name = "gke-pods"
|
||||
services_range_name = "gke-services"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(module.vpc) == 0
|
||||
error_message = "A supplied network means no network is created."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.private_service_network_id == "projects/example-project/global/networks/shared"
|
||||
error_message = "The database and cache should join the supplied network."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.network_name == "shared" && output.subnet_name == "gke"
|
||||
error_message = "The names should be read off the supplied ids."
|
||||
}
|
||||
}
|
||||
|
||||
run "bringing_your_own_network_needs_every_id" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
create_network = false
|
||||
network_id = "projects/example-project/global/networks/shared"
|
||||
}
|
||||
|
||||
expect_failures = [var.create_network]
|
||||
}
|
||||
|
||||
run "rejects_a_deployment_with_no_database_password" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
postgres_password = null
|
||||
}
|
||||
|
||||
expect_failures = [var.postgres_password]
|
||||
}
|
||||
|
||||
run "rejects_an_open_control_plane" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
master_authorized_networks = []
|
||||
}
|
||||
|
||||
expect_failures = [var.allow_unrestricted_api_server_access]
|
||||
}
|
||||
|
||||
run "an_open_control_plane_can_be_asked_for" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
master_authorized_networks = []
|
||||
allow_unrestricted_api_server_access = true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.cluster_name == "onyx-default"
|
||||
error_message = "Recording that the exposure is intended should let the plan through."
|
||||
}
|
||||
}
|
||||
|
||||
run "rejects_a_size_that_is_not_a_tier" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
size = "extra-large"
|
||||
}
|
||||
|
||||
expect_failures = [var.size]
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "Name prefix for every resource. Example: \"onyx\"."
|
||||
default = "onyx"
|
||||
|
||||
# The Memorystore instance ID is the tightest limit: "<name>-redis-<workspace>"
|
||||
# must fit in 40 characters, which leaves room for a workspace name of about 8.
|
||||
validation {
|
||||
condition = can(regex("^[a-z]([-a-z0-9]{0,22}[a-z0-9])?$", var.name))
|
||||
error_message = "name must be 1-24 characters of lowercase letters, digits and hyphens, start with a letter and not end with a hyphen."
|
||||
}
|
||||
}
|
||||
|
||||
variable "project_id" {
|
||||
type = string
|
||||
description = "GCP project that holds every resource"
|
||||
|
||||
validation {
|
||||
condition = can(regex("^[a-z][-a-z0-9]{4,28}[a-z0-9]$", var.project_id))
|
||||
error_message = "project_id must be a GCP project ID: 6-30 lowercase letters, digits and hyphens, starting with a letter."
|
||||
}
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
description = "GCP region for all resources, for example \"us-east1\""
|
||||
|
||||
validation {
|
||||
condition = can(regex("^[a-z]+-[a-z]+[0-9]+$", var.region))
|
||||
error_message = "region must be a GCP region such as \"us-east1\", not a zone."
|
||||
}
|
||||
}
|
||||
|
||||
variable "labels" {
|
||||
type = map(string)
|
||||
description = "Base labels applied to every resource that supports them"
|
||||
# Add an owner label here if your asset inventory expects one. GCP labels
|
||||
# take only lowercase letters, digits, underscores and hyphens.
|
||||
default = {
|
||||
"project" = "onyx"
|
||||
}
|
||||
}
|
||||
|
||||
variable "size" {
|
||||
type = string
|
||||
description = <<-EOT
|
||||
T-shirt size that sets coherent defaults for every compute and data-plane knob:
|
||||
small - pilots and small teams: up to ~200 users, < ~500k documents
|
||||
medium - typical department or company: ~200-1,000 users, ~0.5-2M documents
|
||||
large - org-wide deployments: 1,000+ users, multi-million documents
|
||||
Any individual sizing variable set to a non-null value overrides its tier
|
||||
default. See the README for the full per-tier table.
|
||||
EOT
|
||||
default = "medium"
|
||||
|
||||
validation {
|
||||
condition = contains(["small", "medium", "large"], var.size)
|
||||
error_message = "size must be one of: small, medium, large."
|
||||
}
|
||||
}
|
||||
|
||||
# One switch rather than one per module, so a teardown needs one apply with
|
||||
# this false and nothing is left guarded by a flag someone forgot.
|
||||
variable "deletion_protection" {
|
||||
type = bool
|
||||
description = "Refuse to destroy the cluster, database, cache, bucket and Cloud Armor policy. Set false and apply before a destroy."
|
||||
default = true
|
||||
}
|
||||
|
||||
# A fresh project has most of these off, and every module fails on its first
|
||||
# API call without them.
|
||||
variable "enable_project_apis" {
|
||||
type = bool
|
||||
description = "Enable the Google APIs the deployment uses. Turn this off when the project is managed elsewhere and the APIs are already on."
|
||||
default = true
|
||||
}
|
||||
|
||||
# --- Network -----------------------------------------------------------------
|
||||
|
||||
variable "create_network" {
|
||||
type = bool
|
||||
description = "Create the VPC network. False uses the network, subnet and secondary ranges supplied below."
|
||||
default = true
|
||||
|
||||
validation {
|
||||
condition = var.create_network || (var.network_id != null && var.subnet_id != null && var.pods_range_name != null && var.services_range_name != null)
|
||||
error_message = "Bringing your own network needs network_id, subnet_id, pods_range_name and services_range_name."
|
||||
}
|
||||
}
|
||||
|
||||
variable "subnet_cidr" {
|
||||
type = string
|
||||
description = "Primary range of the created subnet, used for node IPs"
|
||||
default = "10.0.0.0/20"
|
||||
}
|
||||
|
||||
variable "pods_cidr" {
|
||||
type = string
|
||||
description = "Secondary range of the created subnet for pod IPs"
|
||||
default = "10.4.0.0/14"
|
||||
}
|
||||
|
||||
variable "services_cidr" {
|
||||
type = string
|
||||
description = "Secondary range of the created subnet for service IPs"
|
||||
default = "10.8.0.0/20"
|
||||
}
|
||||
|
||||
variable "psa_prefix_length" {
|
||||
type = number
|
||||
description = "Prefix length of the range reserved for Private Service Access (Cloud SQL, Memorystore)"
|
||||
default = 16
|
||||
}
|
||||
|
||||
# On by default, like the AWS modules and unlike Azure: GCP writes flow logs
|
||||
# to Cloud Logging with no extra infrastructure.
|
||||
variable "enable_flow_logs" {
|
||||
type = bool
|
||||
description = "Write VPC flow logs for the subnet to Cloud Logging. Only applies to a network this module creates."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "network_id" {
|
||||
type = string
|
||||
description = "Existing network, as projects/<project>/global/networks/<name>. It must already have Private Service Access and a Cloud NAT. Required when create_network is false."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "subnet_id" {
|
||||
type = string
|
||||
description = "Existing subnet for the nodes. Required when create_network is false."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "pods_range_name" {
|
||||
type = string
|
||||
description = "Secondary range on the existing subnet for pods. Required when create_network is false."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "services_range_name" {
|
||||
type = string
|
||||
description = "Secondary range on the existing subnet for services. Required when create_network is false."
|
||||
default = null
|
||||
}
|
||||
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
|
||||
variable "bucket_name" {
|
||||
type = string
|
||||
description = "Name of the file store bucket. Null derives one from the name, workspace and a short digest, because bucket names are globally unique."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "bucket_force_destroy" {
|
||||
type = bool
|
||||
description = "Delete every object when the bucket is destroyed. Off, so a destroy fails on a bucket that still holds files."
|
||||
default = false
|
||||
}
|
||||
|
||||
# --- Postgres ----------------------------------------------------------------
|
||||
|
||||
variable "postgres_tier" {
|
||||
type = string
|
||||
description = "Cloud SQL machine tier, for example \"db-custom-2-8192\". Null uses the t-shirt size default."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "postgres_disk_size_gb" {
|
||||
type = number
|
||||
description = "Initial data disk in GB. It grows on its own and never shrinks. Null uses the t-shirt size default."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "postgres_username" {
|
||||
type = string
|
||||
description = "Login Onyx connects as. \"postgres\" is the user Cloud SQL ships and the chart's default."
|
||||
default = "postgres"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "postgres_password" {
|
||||
type = string
|
||||
description = "Password for postgres_username. Supply it from a secret store."
|
||||
default = null
|
||||
sensitive = true
|
||||
|
||||
validation {
|
||||
condition = try(length(var.postgres_password) >= 8, false)
|
||||
error_message = "postgres_password must be set and at least 8 characters."
|
||||
}
|
||||
}
|
||||
|
||||
# Not "postgres", the database Cloud SQL ships: Onyx gets a database of its
|
||||
# own. Onyx reads POSTGRES_DB, which defaults to "postgres", so the chart
|
||||
# values have to name this one.
|
||||
variable "postgres_db_name" {
|
||||
type = string
|
||||
description = "Database Onyx connects to. Set POSTGRES_DB to this value in the chart."
|
||||
default = "onyx"
|
||||
}
|
||||
|
||||
variable "postgres_database_version" {
|
||||
type = string
|
||||
description = "Cloud SQL PostgreSQL version"
|
||||
default = "POSTGRES_16"
|
||||
}
|
||||
|
||||
variable "postgres_availability_type" {
|
||||
type = string
|
||||
description = "ZONAL runs one instance. REGIONAL adds a standby in a second zone and roughly doubles database cost."
|
||||
default = "ZONAL"
|
||||
}
|
||||
|
||||
variable "postgres_max_connections" {
|
||||
type = number
|
||||
description = "max_connections flag. Size it to pods x workers x (pool size + overflow). Changing it restarts the instance."
|
||||
default = 500
|
||||
}
|
||||
|
||||
# --- Redis -------------------------------------------------------------------
|
||||
|
||||
# On by default, unlike Azure. Memorystore for Redis is a single endpoint with
|
||||
# 16 databases, so Onyx runs on it unchanged.
|
||||
variable "enable_redis" {
|
||||
type = bool
|
||||
description = "Create a Memorystore for Redis instance. False leaves Redis to the chart's in-cluster instance."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "redis_tier" {
|
||||
type = string
|
||||
description = "STANDARD_HA keeps a replica in a second zone. BASIC is one node, and a restart loses every queued Celery task."
|
||||
default = "STANDARD_HA"
|
||||
}
|
||||
|
||||
variable "redis_memory_size_gb" {
|
||||
type = number
|
||||
description = "Instance memory in GiB. Null uses the t-shirt size default."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "redis_transit_encryption_enabled" {
|
||||
type = bool
|
||||
description = "Serve TLS on 6378. Needs the server CA mounted into the pods (the chart's redisTls). Changing this replaces the instance."
|
||||
default = false
|
||||
}
|
||||
|
||||
# --- Cluster -----------------------------------------------------------------
|
||||
|
||||
variable "kubernetes_version" {
|
||||
type = string
|
||||
description = "Minimum control plane version, for example \"1.33\". Null takes the release channel default."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "release_channel" {
|
||||
type = string
|
||||
description = "GKE release channel"
|
||||
default = "REGULAR"
|
||||
}
|
||||
|
||||
variable "main_node_machine_type" {
|
||||
type = string
|
||||
description = "Machine type for the main pool. Null uses the t-shirt size default."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "main_node_min_count" {
|
||||
type = number
|
||||
description = "Minimum nodes in the main pool, across all zones. Null uses the t-shirt size default."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "main_node_max_count" {
|
||||
type = number
|
||||
description = "Maximum nodes in the main pool, across all zones. Null uses the t-shirt size default."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "main_node_disk_size_gb" {
|
||||
type = number
|
||||
description = "Boot disk for main pool nodes"
|
||||
default = 100
|
||||
}
|
||||
|
||||
# GCP has no managed OpenSearch, so this pool is where the document index
|
||||
# runs. It is on by default, unlike the AWS module's equivalent.
|
||||
variable "index_node_pool_enabled" {
|
||||
type = bool
|
||||
description = "Create the document-index node pool"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "index_node_machine_type" {
|
||||
type = string
|
||||
description = "Machine type for the document-index pool. Memory-optimised, because the index is what needs it. Null uses the t-shirt size default."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "index_node_min_count" {
|
||||
type = number
|
||||
description = "Minimum nodes in the document-index pool, across all zones"
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "index_node_max_count" {
|
||||
type = number
|
||||
description = "Maximum nodes in the document-index pool, across all zones"
|
||||
default = 3
|
||||
}
|
||||
|
||||
variable "index_node_disk_size_gb" {
|
||||
type = number
|
||||
description = "Boot disk for document-index nodes. Null uses the t-shirt size default."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "private_endpoint_enabled" {
|
||||
type = bool
|
||||
description = "Serve the API server on its private address only"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "master_authorized_networks" {
|
||||
type = list(object({
|
||||
cidr_block = string
|
||||
display_name = optional(string)
|
||||
}))
|
||||
description = "CIDR ranges allowed to reach the API server"
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "allow_unrestricted_api_server_access" {
|
||||
type = bool
|
||||
description = "Accept a public API server reachable from any address. Only for throwaway deployments; set master_authorized_networks or private_endpoint_enabled instead."
|
||||
default = false
|
||||
|
||||
validation {
|
||||
condition = var.allow_unrestricted_api_server_access || var.private_endpoint_enabled || length(var.master_authorized_networks) > 0
|
||||
error_message = "A public API server with no authorized networks is reachable from every address on the internet. Set master_authorized_networks, or private_endpoint_enabled, or allow_unrestricted_api_server_access to record that the exposure is intended."
|
||||
}
|
||||
}
|
||||
|
||||
variable "enable_gpu_node_pool" {
|
||||
type = bool
|
||||
description = "Create a tainted GPU pool for the embedding model server"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "enable_sandbox_node_pool" {
|
||||
type = bool
|
||||
description = "Create a tainted pool for Craft sandbox workloads"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "create_workload_namespace" {
|
||||
type = bool
|
||||
description = "Create the namespace the workload service account lives in. Terraform has to make it: the Helm release that would otherwise is installed afterwards."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "workload_namespace" {
|
||||
type = string
|
||||
description = "Namespace of the workload service account. Install the Helm release into it."
|
||||
default = "onyx"
|
||||
}
|
||||
|
||||
variable "workload_service_account_name" {
|
||||
type = string
|
||||
description = "Kubernetes service account that gets the bucket grant. Point the chart's serviceAccount.name at it."
|
||||
default = "onyx-workload-access"
|
||||
}
|
||||
|
||||
# --- Cloud Armor -------------------------------------------------------------
|
||||
|
||||
variable "enable_cloud_armor" {
|
||||
type = bool
|
||||
description = "Create a Cloud Armor policy to attach to an L7 load balancer through a BackendConfig or GCPBackendPolicy"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "cloud_armor_preview" {
|
||||
type = bool
|
||||
description = "Log what the WAF and rate limit rules match instead of acting on it"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "cloud_armor_sensitivity" {
|
||||
type = number
|
||||
description = "Sensitivity of the preconfigured WAF rules, 1 (fewest false positives) to 4"
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "cloud_armor_allowed_ip_cidrs" {
|
||||
type = list(string)
|
||||
description = "Ranges allowed to reach the application. Empty disables the allowlist."
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "cloud_armor_blocked_ip_cidrs" {
|
||||
type = list(string)
|
||||
description = "Ranges refused with 403 before any other rule"
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "cloud_armor_rate_limit_exempt_ip_cidrs" {
|
||||
type = list(string)
|
||||
description = "Ranges exempt from the rate limits"
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "cloud_armor_geo_restriction_countries" {
|
||||
type = list(string)
|
||||
description = "Two-letter country codes to block"
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "cloud_armor_rate_limit_threshold" {
|
||||
type = number
|
||||
description = "Requests per 5 minutes from one address before it gets 429s"
|
||||
default = 2000
|
||||
}
|
||||
|
||||
variable "cloud_armor_api_rate_limit_threshold" {
|
||||
type = number
|
||||
description = "Requests per 5 minutes from one address to the API path before it gets 429s"
|
||||
default = 1000
|
||||
}
|
||||
|
||||
variable "cloud_armor_adaptive_protection_enabled" {
|
||||
type = bool
|
||||
description = "Enable Adaptive Protection layer 7 DDoS detection"
|
||||
default = true
|
||||
}
|
||||
|
||||
# --- Alerts ------------------------------------------------------------------
|
||||
|
||||
variable "enable_alerts" {
|
||||
type = bool
|
||||
description = "Create the Cloud Monitoring alert policies for the database and cache"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "notification_channels" {
|
||||
type = list(string)
|
||||
description = "Cloud Monitoring notification channel IDs for the database and cache alerts. Empty = alerts exist but notify nothing."
|
||||
default = []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
terraform {
|
||||
required_version = ">= 1.12.0"
|
||||
|
||||
required_providers {
|
||||
# The highest floor among the child modules: gcs and cloud-armor need 7.33
|
||||
# for deletion_policy.
|
||||
google = {
|
||||
source = "hashicorp/google"
|
||||
version = "~> 7.33"
|
||||
}
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
version = "~> 2.37"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user