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",