From b1757294e1520b54fa72c66b5d0be7456f6501e9 Mon Sep 17 00:00:00 2001 From: Joe Corall <1189940+joecorall@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:37:46 +0000 Subject: [PATCH] [minor] Support caller-owned GCP disk attachments --- README.md | 2 +- docs/runtime-contracts.md | 17 +++ main.tf | 5 +- modules/gcp/main.tf | 10 ++ modules/gcp/outputs.tf | 8 +- modules/gcp/runtime_contracts.tftest.hcl | 131 +++++++++++++++++++++ modules/gcp/upgrade_dependency.tftest.hcl | 17 ++- modules/gcp/variables.tf | 42 +++++++ providers/gcp/main.tf | 5 +- providers/gcp/template_versions.tftest.hcl | 29 +++++ providers/gcp/variables.tf | 37 ++++++ runtime_contracts.tftest.hcl | 30 +++++ variables.tf | 37 ++++++ 13 files changed, 362 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 98513ee..f8447b3 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ No resources. |------|-------------|------|---------|:--------:| | [name](#input\_name) | Deployment name. | `string` | n/a | yes | | [cloud\_provider](#input\_cloud\_provider) | Compatibility selector for the root GCP entrypoint. Use providers/do or providers/linode for other clouds. | `string` | `"gcp"` | no | -| [gcp](#input\_gcp) | Google Cloud infrastructure settings. |
object({
project_id = optional(string, "")
project_number = optional(string, "")
region = optional(string, "us-east5")
zone = optional(string, "us-east5-b")

identity = optional(object({
vm_service_account_email = optional(string, "")
app_service_account_email = optional(string, "")
app_credentials_enabled = optional(bool, false)
}), {})

instance = optional(object({
machine_type = optional(string, "n4-standard-2")
os = optional(string, "cos-125-19216-220-185")
production = optional(bool, false)
}), {})

disks = optional(object({
type = optional(string, "hyperdisk-balanced")
docker_volumes_size_gb = optional(number, 50)
}), {})

network = optional(object({
create = optional(bool, true)
project_id = optional(string, "")
name = optional(string, "")
subnetwork = optional(string, "")
ip_cidr_range = optional(string, "10.42.0.0/24")
mtu = optional(number, 1460)
power_button_allowed_ips = optional(list(string), [])
power_button_ip_depth = optional(number)
ssh_ipv4 = optional(list(string), [])
ssh_ipv6 = optional(list(string), [])
}), {})

snapshots = optional(object({
enabled = optional(bool, false)
}), {})

overlay = optional(object({
source_instance = optional(string, "")
volume_names = optional(list(string), [])
}), {})

cloud_init = optional(object({
initcmd = optional(list(string), [])
runcmd = optional(list(string), [])
}), {})

artifact_registry = optional(object({
repository = optional(string, "")
location = optional(string, "us")
}), {})

power_management = optional(object({
enabled = optional(bool, false)
start_role = optional(string, "")
suspend_role = optional(string, "")
frontend = optional(object({
image = string
port = optional(number, 8080)
cpu = optional(string, "1000m")
memory = optional(string, "1Gi")
}), null)
}), {})

rollout = optional(object({
enabled = optional(bool, false)
release_url = optional(string, "")
release_sha256 = optional(string, "")
port = optional(number, 8081)
jwks_uri = optional(string, "")
jwt_audience = optional(string, "")
custom_claims = optional(string, "")
allowed_ipv4 = optional(list(string), ["10.0.0.0/8"])
}), {})
})
| `{}` | no | +| [gcp](#input\_gcp) | Google Cloud infrastructure settings. |
object({
project_id = optional(string, "")
project_number = optional(string, "")
region = optional(string, "us-east5")
zone = optional(string, "us-east5-b")

identity = optional(object({
vm_service_account_email = optional(string, "")
app_service_account_email = optional(string, "")
app_credentials_enabled = optional(bool, false)
}), {})

instance = optional(object({
machine_type = optional(string, "n4-standard-2")
os = optional(string, "cos-125-19216-220-185")
production = optional(bool, false)
}), {})

disks = optional(object({
type = optional(string, "hyperdisk-balanced")
docker_volumes_size_gb = optional(number, 50)
attachments = optional(map(object({
source = string
mode = optional(string, "READ_WRITE")
})), {})
}), {})

network = optional(object({
create = optional(bool, true)
project_id = optional(string, "")
name = optional(string, "")
subnetwork = optional(string, "")
ip_cidr_range = optional(string, "10.42.0.0/24")
mtu = optional(number, 1460)
power_button_allowed_ips = optional(list(string), [])
power_button_ip_depth = optional(number)
ssh_ipv4 = optional(list(string), [])
ssh_ipv6 = optional(list(string), [])
}), {})

snapshots = optional(object({
enabled = optional(bool, false)
}), {})

overlay = optional(object({
source_instance = optional(string, "")
volume_names = optional(list(string), [])
}), {})

cloud_init = optional(object({
initcmd = optional(list(string), [])
runcmd = optional(list(string), [])
}), {})

artifact_registry = optional(object({
repository = optional(string, "")
location = optional(string, "us")
}), {})

power_management = optional(object({
enabled = optional(bool, false)
start_role = optional(string, "")
suspend_role = optional(string, "")
frontend = optional(object({
image = string
port = optional(number, 8080)
cpu = optional(string, "1000m")
memory = optional(string, "1Gi")
}), null)
}), {})

rollout = optional(object({
enabled = optional(bool, false)
release_url = optional(string, "")
release_sha256 = optional(string, "")
port = optional(number, 8081)
jwks_uri = optional(string, "")
jwt_audience = optional(string, "")
custom_claims = optional(string, "")
allowed_ipv4 = optional(list(string), ["10.0.0.0/8"])
}), {})
})
| `{}` | no | | [runtime](#input\_runtime) | Provider-neutral compose/runtime settings. |
object({
rootfs = optional(string, "")
rootfs_archive_url = optional(string, "")
rootfs_archive_sha256 = optional(string, "")
users = optional(map(list(string)), {})

compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
ingress = optional(object({
letsencrypt = optional(bool, false)
bot_mitigation = optional(bool, false)
mode = optional(string, "")
domain = optional(string, "")
acme_email = optional(string, "")
trusted_ips = optional(list(string), [])
max_upload_size = optional(string, "")
upload_timeout = optional(string, "")
}), {})
repo = optional(string, "")
branch = optional(string, "")
projects = optional(map(object({
docker_compose_repo = string
docker_compose_branch = optional(string)
project_dir = optional(string)
compose_project_name = optional(string)
ingress_port = optional(number)
ingress = optional(object({
letsencrypt = optional(bool)
bot_mitigation = optional(bool)
mode = optional(string)
domain = optional(string)
acme_email = optional(string)
trusted_ips = optional(list(string))
max_upload_size = optional(string)
upload_timeout = optional(string)
}), {})
sitectl_context_name = optional(string)
sitectl_plugin = optional(string)
sitectl_environment = optional(string)
sitectl_packages = optional(list(string))
sitectl_verify_args = optional(list(string))
docker_compose_init = optional(list(string))
docker_compose_up = optional(list(string))
docker_compose_down = optional(list(string))
docker_compose_rollout = optional(list(string))
})), {})
init = optional(list(string))
up = optional(list(string))
down = optional(list(string))
rollout = optional(list(string))
}), {})

sitectl = optional(object({
packages = optional(list(string))
version = optional(string, "latest")
package_versions = optional(map(string), {})
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
verify_args = optional(list(string), [])
}), {})

docker = optional(object({
# renovate: datasource=github-releases depName=docker-compose packageName=docker/compose versioning=semver
compose_version = optional(string, "v5.3.1")
# renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver
buildx_version = optional(string, "v0.35.0")
}), {})

managed_runtime = optional(object({
enabled = optional(bool, true)
internal_services_enabled = optional(bool, false)
internal_services_auto_update = optional(bool, false)
artifacts = optional(list(object({
name = string
url = string
sha256 = string
path = string
mode = optional(string, "0755")
owner = optional(string, "root")
group = optional(string, "root")
restart = optional(string, "")
})), [])
}), {})

vault = optional(object({
addr = optional(string, "")
namespace = optional(string, "")
role = optional(string, "")
agent_enabled = optional(bool, false)
auth_method = optional(string, "auto")
gcp_auth_mount_path = optional(string, "auth/gcp")
agent_token_path = optional(string, "/mnt/disks/data/vault/token")
agent_additional_config = optional(string, "")
agent_templates = optional(list(object({
destination = string
contents = string
perms = optional(string, "0640")
command = optional(string, "")
})), [])
}), {})

extra_env = optional(map(string), {})
})
| `{}` | no | | [template](#input\_template) | Optional compose template preset. Supported values are archivesspace, ojs, isle, drupal, wp, omeka-s, and omeka-classic. Explicit runtime settings override preset defaults. | `string` | `""` | no | diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md index 9ad177c..beac237 100644 --- a/docs/runtime-contracts.md +++ b/docs/runtime-contracts.md @@ -226,6 +226,23 @@ Dependency installation dispatches by OS family: Provider modules should mount persistent data and Docker-volume disks before the runtime starts. Destroying/recreating the VM must not destroy these volumes. +GCP callers can add persistent disks through `gcp.disks.attachments`. The map +key is the GCE device name, `source` is the caller-owned disk self-link, and +`mode` defaults to `READ_WRITE` but can be set to `READ_ONLY`. Cloud-compose +owns the complete inline attachment set on the instance so every configured +disk is present when the VM starts; the caller continues to own each disk's +lifecycle. Device names must be valid GCE names and cannot collide with the +module-owned `boot`, `data`, `docker-volumes`, or `prod-volumes` names. + +Do not manage the same VM/disk pair with a standalone +`google_compute_attached_disk`; that creates two Terraform owners for one +attachment. When migrating an existing standalone attachment, abandon the old +resource from state without destroying it (for example, a `removed` block with +`destroy = false` on supported Terraform versions), add the inline attachment, +and inspect the transition plan to confirm it contains no detach. A normal +destroy of the old standalone resource can detach the disk before the inline +owner takes over. + Every Compose `project_dir` must be a normalized descendant of the fixed `/mnt/disks/data` ownership boundary. Terraform rejects paths such as `/`, `/etc`, repeated separators, and dot-segment traversal during planning. The diff --git a/main.tf b/main.tf index dab7d7c..038452a 100644 --- a/main.tf +++ b/main.tf @@ -114,8 +114,9 @@ module "gcp" { os = local.gcp_instance.os production = local.gcp_instance.production - disk_type = local.gcp_disks.type - disk_size_gb = local.gcp_disks.docker_volumes_size_gb + disk_type = local.gcp_disks.type + disk_size_gb = local.gcp_disks.docker_volumes_size_gb + disk_attachments = local.gcp_disks.attachments ingress_port = local.compose.ingress_port primary_compose_project = local.compose.primary diff --git a/modules/gcp/main.tf b/modules/gcp/main.tf index 09012d9..fd19032 100644 --- a/modules/gcp/main.tf +++ b/modules/gcp/main.tf @@ -799,6 +799,16 @@ resource "google_compute_instance" "cloud-compose" { } } + dynamic "attached_disk" { + for_each = var.disk_attachments + iterator = caller_disk + content { + device_name = caller_disk.key + source = trimspace(caller_disk.value.source) + mode = caller_disk.value.mode + } + } + metadata = { google-logging-enabled = "true" google-logging-use-fluentbit = "true" diff --git a/modules/gcp/outputs.tf b/modules/gcp/outputs.tf index 8d62e29..49d7a6b 100644 --- a/modules/gcp/outputs.tf +++ b/modules/gcp/outputs.tf @@ -29,13 +29,19 @@ output "instance" { name = google_compute_disk.docker-volumes.name self_link = google_compute_disk.docker-volumes.self_link } + attached_disks = { + for disk in google_compute_instance.cloud-compose.attached_disk : disk.device_name => { + source = disk.source + mode = disk.mode + } if contains(keys(var.disk_attachments), disk.device_name) + } gsa : { email : local.vm_service_account_email, id : local.vm_service_account_id, name : local.vm_service_account_name, } } - description = "The GCP instance identity, network placement, replaceable boot disk, persistent disks, and VM service account. The legacy disk field remains the Docker-volume disk name." + description = "The GCP instance identity, network placement, replaceable boot disk, persistent disks, caller-owned attachments, and VM service account. The legacy disk field remains the Docker-volume disk name." } output "network" { diff --git a/modules/gcp/runtime_contracts.tftest.hcl b/modules/gcp/runtime_contracts.tftest.hcl index 5d3e1d2..3d9c901 100644 --- a/modules/gcp/runtime_contracts.tftest.hcl +++ b/modules/gcp/runtime_contracts.tftest.hcl @@ -88,6 +88,137 @@ run "disables_privileged_services_by_default" { } } +run "attaches_caller_owned_disks_inline" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + disk_attachments = { + mariadb-backups = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/mariadb-backups" + } + reference-data = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/reference-data" + mode = "READ_ONLY" + } + } + } + + assert { + condition = ( + length(google_compute_instance.cloud-compose.attached_disk) == 4 && + one([ + for disk in google_compute_instance.cloud-compose.attached_disk : disk + if disk.device_name == "mariadb-backups" + ]).source == "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/mariadb-backups" && + one([ + for disk in google_compute_instance.cloud-compose.attached_disk : disk + if disk.device_name == "mariadb-backups" + ]).mode == "READ_WRITE" && + one([ + for disk in google_compute_instance.cloud-compose.attached_disk : disk + if disk.device_name == "reference-data" + ]).source == "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/reference-data" && + one([ + for disk in google_compute_instance.cloud-compose.attached_disk : disk + if disk.device_name == "reference-data" + ]).mode == "READ_ONLY" + ) + error_message = "Caller-owned disks must be part of the VM's authoritative inline attachment set with explicit modes." + } +} + +run "rejects_reserved_disk_attachment_name" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + disk_attachments = { + data = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/extra-data" + } + } + } + + expect_failures = [var.disk_attachments] +} + +run "rejects_invalid_disk_attachment_name" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + disk_attachments = { + "Invalid_Name" = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/invalid-name" + } + } + } + + expect_failures = [var.disk_attachments] +} + +run "rejects_blank_disk_attachment_source" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + disk_attachments = { + backups = { + source = " " + } + } + } + + expect_failures = [var.disk_attachments] +} + +run "rejects_invalid_disk_attachment_mode" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + disk_attachments = { + backups = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/backups" + mode = "READ_MOSTLY" + } + } + } + + expect_failures = [var.disk_attachments] +} + +run "rejects_duplicate_disk_attachment_source" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + disk_attachments = { + backups-a = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/backups" + } + backups-b = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/backups" + } + } + } + + expect_failures = [var.disk_attachments] +} + run "creates_app_key_management_only_when_explicitly_enabled" { command = plan diff --git a/modules/gcp/upgrade_dependency.tftest.hcl b/modules/gcp/upgrade_dependency.tftest.hcl index a45c1dc..42e568c 100644 --- a/modules/gcp/upgrade_dependency.tftest.hcl +++ b/modules/gcp/upgrade_dependency.tftest.hcl @@ -32,6 +32,11 @@ variables { vault_auth_method = "gcp-iam" vault_addr = "https://vault.example" vault_role = "wordpress" + disk_attachments = { + mariadb-backups = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/mariadb-backups" + } + } } run "apply_power_managed_vault_baseline" { @@ -54,8 +59,16 @@ run "plan_vm_replacement_while_retiring_vault_signer" { length(google_compute_instance_iam_member.gce-start) == 1 && length(google_compute_instance_iam_member.gce-suspend) == 1 && google_compute_instance_iam_member.gce-start[0].instance_name == google_compute_instance.cloud-compose.name && - google_compute_instance_iam_member.gce-suspend[0].instance_name == google_compute_instance.cloud-compose.name + google_compute_instance_iam_member.gce-suspend[0].instance_name == google_compute_instance.cloud-compose.name && + one([ + for disk in google_compute_instance.cloud-compose.attached_disk : disk + if disk.device_name == "mariadb-backups" + ]).source == "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/mariadb-backups" && + one([ + for disk in google_compute_instance.cloud-compose.attached_disk : disk + if disk.device_name == "mariadb-backups" + ]).mode == "READ_WRITE" ) - error_message = "A VM replacement must retain both instance-scoped power bindings without introducing an upgrade dependency cycle." + error_message = "A VM replacement must retain instance-scoped power bindings and caller-owned disks without introducing an upgrade dependency cycle." } } diff --git a/modules/gcp/variables.tf b/modules/gcp/variables.tf index eb1e3f9..b50e437 100644 --- a/modules/gcp/variables.tf +++ b/modules/gcp/variables.tf @@ -179,6 +179,48 @@ variable "disk_size_gb" { description = "Data disk size in GB" } +variable "disk_attachments" { + type = map(object({ + source = string + mode = optional(string, "READ_WRITE") + })) + default = {} + description = "Caller-owned persistent disks to attach inline to the VM, keyed by GCE device name. The caller retains disk lifecycle ownership." + + validation { + condition = alltrue([ + for device_name in keys(var.disk_attachments) : + can(regex("^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$", device_name)) && + !contains(["boot", "data", "docker-volumes", "prod-volumes"], device_name) + ]) + error_message = "disk_attachments keys must be valid GCE device names and must not use boot, data, docker-volumes, or prod-volumes." + } + + validation { + condition = alltrue([ + for attachment in values(var.disk_attachments) : + trimspace(attachment.source) != "" + ]) + error_message = "disk_attachments sources must not be empty." + } + + validation { + condition = alltrue([ + for attachment in values(var.disk_attachments) : + contains(["READ_ONLY", "READ_WRITE"], attachment.mode) + ]) + error_message = "disk_attachments modes must be READ_ONLY or READ_WRITE." + } + + validation { + condition = length(distinct([ + for attachment in values(var.disk_attachments) : + trimspace(attachment.source) + ])) == length(var.disk_attachments) + error_message = "disk_attachments must not attach the same source more than once." + } +} + variable "os" { type = string default = "cos-125-19216-220-185" diff --git a/providers/gcp/main.tf b/providers/gcp/main.tf index 308a9d3..d869006 100644 --- a/providers/gcp/main.tf +++ b/providers/gcp/main.tf @@ -98,8 +98,9 @@ module "gcp" { os = local.gcp_instance.os production = local.gcp_instance.production - disk_type = local.gcp_disks.type - disk_size_gb = local.gcp_disks.docker_volumes_size_gb + disk_type = local.gcp_disks.type + disk_size_gb = local.gcp_disks.docker_volumes_size_gb + disk_attachments = local.gcp_disks.attachments ingress_port = local.compose.ingress_port primary_compose_project = local.compose.primary diff --git a/providers/gcp/template_versions.tftest.hcl b/providers/gcp/template_versions.tftest.hcl index dd10162..1b5c5fb 100644 --- a/providers/gcp/template_versions.tftest.hcl +++ b/providers/gcp/template_versions.tftest.hcl @@ -73,6 +73,35 @@ run "explicit_core_only_package_set_disables_template_plugins" { } } +run "gcp_entrypoint_forwards_caller_owned_disks" { + command = plan + + variables { + name = "template-versions" + template = "wp" + gcp = { + project_id = "test-project" + disks = { + attachments = { + mariadb-backups = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/mariadb-backups" + } + } + } + } + } + + assert { + condition = output.instance.attached_disks == { + mariadb-backups = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/mariadb-backups" + mode = "READ_WRITE" + } + } + error_message = "The GCP entrypoint must forward caller-owned disks into the VM's inline attachment set." + } +} + run "accepts_direct_cloud_run_proxy_depth" { command = plan diff --git a/providers/gcp/variables.tf b/providers/gcp/variables.tf index 0cbf042..4522e61 100644 --- a/providers/gcp/variables.tf +++ b/providers/gcp/variables.tf @@ -42,6 +42,10 @@ variable "gcp" { disks = optional(object({ type = optional(string, "hyperdisk-balanced") docker_volumes_size_gb = optional(number, 50) + attachments = optional(map(object({ + source = string + mode = optional(string, "READ_WRITE") + })), {}) }), {}) network = optional(object({ @@ -110,6 +114,39 @@ variable "gcp" { error_message = "gcp.disks.type must be hyperdisk-balanced, pd-ssd, or pd-standard." } + validation { + condition = alltrue([ + for device_name in keys(var.gcp.disks.attachments) : + can(regex("^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$", device_name)) && + !contains(["boot", "data", "docker-volumes", "prod-volumes"], device_name) + ]) + error_message = "gcp.disks.attachments keys must be valid GCE device names and must not use boot, data, docker-volumes, or prod-volumes." + } + + validation { + condition = alltrue([ + for attachment in values(var.gcp.disks.attachments) : + trimspace(attachment.source) != "" + ]) + error_message = "gcp.disks.attachments sources must not be empty." + } + + validation { + condition = alltrue([ + for attachment in values(var.gcp.disks.attachments) : + contains(["READ_ONLY", "READ_WRITE"], attachment.mode) + ]) + error_message = "gcp.disks.attachments modes must be READ_ONLY or READ_WRITE." + } + + validation { + condition = length(distinct([ + for attachment in values(var.gcp.disks.attachments) : + trimspace(attachment.source) + ])) == length(var.gcp.disks.attachments) + error_message = "gcp.disks.attachments must not attach the same source more than once." + } + validation { condition = contains([ "e2-micro", diff --git a/runtime_contracts.tftest.hcl b/runtime_contracts.tftest.hcl index e149839..cfbafff 100644 --- a/runtime_contracts.tftest.hcl +++ b/runtime_contracts.tftest.hcl @@ -74,6 +74,36 @@ run "public_entrypoint_exposes_sitectl_package_versions" { } } +run "public_entrypoint_forwards_caller_owned_disks" { + command = plan + + variables { + name = "root-contract" + cloud_provider = "gcp" + template = "wp" + gcp = { + project_id = "test-project" + disks = { + attachments = { + mariadb-backups = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/mariadb-backups" + } + } + } + } + } + + assert { + condition = output.instance.attached_disks == { + mariadb-backups = { + source = "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-east5-b/disks/mariadb-backups" + mode = "READ_WRITE" + } + } + error_message = "The root entrypoint must forward caller-owned disks into the VM's inline attachment set." + } +} + run "public_entrypoint_rejects_reserved_extra_environment" { command = plan diff --git a/variables.tf b/variables.tf index 9905ee6..e8fa435 100644 --- a/variables.tf +++ b/variables.tf @@ -48,6 +48,10 @@ variable "gcp" { disks = optional(object({ type = optional(string, "hyperdisk-balanced") docker_volumes_size_gb = optional(number, 50) + attachments = optional(map(object({ + source = string + mode = optional(string, "READ_WRITE") + })), {}) }), {}) network = optional(object({ @@ -116,6 +120,39 @@ variable "gcp" { error_message = "gcp.disks.type must be hyperdisk-balanced, pd-ssd, or pd-standard." } + validation { + condition = alltrue([ + for device_name in keys(var.gcp.disks.attachments) : + can(regex("^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$", device_name)) && + !contains(["boot", "data", "docker-volumes", "prod-volumes"], device_name) + ]) + error_message = "gcp.disks.attachments keys must be valid GCE device names and must not use boot, data, docker-volumes, or prod-volumes." + } + + validation { + condition = alltrue([ + for attachment in values(var.gcp.disks.attachments) : + trimspace(attachment.source) != "" + ]) + error_message = "gcp.disks.attachments sources must not be empty." + } + + validation { + condition = alltrue([ + for attachment in values(var.gcp.disks.attachments) : + contains(["READ_ONLY", "READ_WRITE"], attachment.mode) + ]) + error_message = "gcp.disks.attachments modes must be READ_ONLY or READ_WRITE." + } + + validation { + condition = length(distinct([ + for attachment in values(var.gcp.disks.attachments) : + trimspace(attachment.source) + ])) == length(var.gcp.disks.attachments) + error_message = "gcp.disks.attachments must not attach the same source more than once." + } + validation { condition = contains([ "e2-micro",