Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ No resources.
|------|-------------|------|---------|:--------:|
| <a name="input_name"></a> [name](#input\_name) | Deployment name. | `string` | n/a | yes |
| <a name="input_cloud_provider"></a> [cloud\_provider](#input\_cloud\_provider) | Compatibility selector for the root GCP entrypoint. Use providers/do or providers/linode for other clouds. | `string` | `"gcp"` | no |
| <a name="input_gcp"></a> [gcp](#input\_gcp) | Google Cloud infrastructure settings. | <pre>object({<br/> project_id = optional(string, "")<br/> project_number = optional(string, "")<br/> region = optional(string, "us-east5")<br/> zone = optional(string, "us-east5-b")<br/><br/> identity = optional(object({<br/> vm_service_account_email = optional(string, "")<br/> app_service_account_email = optional(string, "")<br/> app_credentials_enabled = optional(bool, false)<br/> }), {})<br/><br/> instance = optional(object({<br/> machine_type = optional(string, "n4-standard-2")<br/> os = optional(string, "cos-125-19216-220-185")<br/> production = optional(bool, false)<br/> }), {})<br/><br/> disks = optional(object({<br/> type = optional(string, "hyperdisk-balanced")<br/> docker_volumes_size_gb = optional(number, 50)<br/> }), {})<br/><br/> network = optional(object({<br/> create = optional(bool, true)<br/> project_id = optional(string, "")<br/> name = optional(string, "")<br/> subnetwork = optional(string, "")<br/> ip_cidr_range = optional(string, "10.42.0.0/24")<br/> mtu = optional(number, 1460)<br/> power_button_allowed_ips = optional(list(string), [])<br/> power_button_ip_depth = optional(number)<br/> ssh_ipv4 = optional(list(string), [])<br/> ssh_ipv6 = optional(list(string), [])<br/> }), {})<br/><br/> snapshots = optional(object({<br/> enabled = optional(bool, false)<br/> }), {})<br/><br/> overlay = optional(object({<br/> source_instance = optional(string, "")<br/> volume_names = optional(list(string), [])<br/> }), {})<br/><br/> cloud_init = optional(object({<br/> initcmd = optional(list(string), [])<br/> runcmd = optional(list(string), [])<br/> }), {})<br/><br/> artifact_registry = optional(object({<br/> repository = optional(string, "")<br/> location = optional(string, "us")<br/> }), {})<br/><br/> power_management = optional(object({<br/> enabled = optional(bool, false)<br/> start_role = optional(string, "")<br/> suspend_role = optional(string, "")<br/> frontend = optional(object({<br/> image = string<br/> port = optional(number, 8080)<br/> cpu = optional(string, "1000m")<br/> memory = optional(string, "1Gi")<br/> }), null)<br/> }), {})<br/><br/> rollout = optional(object({<br/> enabled = optional(bool, false)<br/> release_url = optional(string, "")<br/> release_sha256 = optional(string, "")<br/> port = optional(number, 8081)<br/> jwks_uri = optional(string, "")<br/> jwt_audience = optional(string, "")<br/> custom_claims = optional(string, "")<br/> allowed_ipv4 = optional(list(string), ["10.0.0.0/8"])<br/> }), {})<br/> })</pre> | `{}` | no |
| <a name="input_gcp"></a> [gcp](#input\_gcp) | Google Cloud infrastructure settings. | <pre>object({<br/> project_id = optional(string, "")<br/> project_number = optional(string, "")<br/> region = optional(string, "us-east5")<br/> zone = optional(string, "us-east5-b")<br/><br/> identity = optional(object({<br/> vm_service_account_email = optional(string, "")<br/> app_service_account_email = optional(string, "")<br/> app_credentials_enabled = optional(bool, false)<br/> }), {})<br/><br/> instance = optional(object({<br/> machine_type = optional(string, "n4-standard-2")<br/> os = optional(string, "cos-125-19216-220-185")<br/> production = optional(bool, false)<br/> }), {})<br/><br/> disks = optional(object({<br/> type = optional(string, "hyperdisk-balanced")<br/> docker_volumes_size_gb = optional(number, 50)<br/> attachments = optional(map(object({<br/> source = string<br/> mode = optional(string, "READ_WRITE")<br/> })), {})<br/> }), {})<br/><br/> network = optional(object({<br/> create = optional(bool, true)<br/> project_id = optional(string, "")<br/> name = optional(string, "")<br/> subnetwork = optional(string, "")<br/> ip_cidr_range = optional(string, "10.42.0.0/24")<br/> mtu = optional(number, 1460)<br/> power_button_allowed_ips = optional(list(string), [])<br/> power_button_ip_depth = optional(number)<br/> ssh_ipv4 = optional(list(string), [])<br/> ssh_ipv6 = optional(list(string), [])<br/> }), {})<br/><br/> snapshots = optional(object({<br/> enabled = optional(bool, false)<br/> }), {})<br/><br/> overlay = optional(object({<br/> source_instance = optional(string, "")<br/> volume_names = optional(list(string), [])<br/> }), {})<br/><br/> cloud_init = optional(object({<br/> initcmd = optional(list(string), [])<br/> runcmd = optional(list(string), [])<br/> }), {})<br/><br/> artifact_registry = optional(object({<br/> repository = optional(string, "")<br/> location = optional(string, "us")<br/> }), {})<br/><br/> power_management = optional(object({<br/> enabled = optional(bool, false)<br/> start_role = optional(string, "")<br/> suspend_role = optional(string, "")<br/> frontend = optional(object({<br/> image = string<br/> port = optional(number, 8080)<br/> cpu = optional(string, "1000m")<br/> memory = optional(string, "1Gi")<br/> }), null)<br/> }), {})<br/><br/> rollout = optional(object({<br/> enabled = optional(bool, false)<br/> release_url = optional(string, "")<br/> release_sha256 = optional(string, "")<br/> port = optional(number, 8081)<br/> jwks_uri = optional(string, "")<br/> jwt_audience = optional(string, "")<br/> custom_claims = optional(string, "")<br/> allowed_ipv4 = optional(list(string), ["10.0.0.0/8"])<br/> }), {})<br/> })</pre> | `{}` | no |
| <a name="input_runtime"></a> [runtime](#input\_runtime) | Provider-neutral compose/runtime settings. | <pre>object({<br/> rootfs = optional(string, "")<br/> rootfs_archive_url = optional(string, "")<br/> rootfs_archive_sha256 = optional(string, "")<br/> users = optional(map(list(string)), {})<br/><br/> compose = optional(object({<br/> primary = optional(string, "")<br/> ingress_port = optional(number, 80)<br/> ingress = optional(object({<br/> letsencrypt = optional(bool, false)<br/> bot_mitigation = optional(bool, false)<br/> mode = optional(string, "")<br/> domain = optional(string, "")<br/> acme_email = optional(string, "")<br/> trusted_ips = optional(list(string), [])<br/> max_upload_size = optional(string, "")<br/> upload_timeout = optional(string, "")<br/> }), {})<br/> repo = optional(string, "")<br/> branch = optional(string, "")<br/> projects = optional(map(object({<br/> docker_compose_repo = string<br/> docker_compose_branch = optional(string)<br/> project_dir = optional(string)<br/> compose_project_name = optional(string)<br/> ingress_port = optional(number)<br/> ingress = optional(object({<br/> letsencrypt = optional(bool)<br/> bot_mitigation = optional(bool)<br/> mode = optional(string)<br/> domain = optional(string)<br/> acme_email = optional(string)<br/> trusted_ips = optional(list(string))<br/> max_upload_size = optional(string)<br/> upload_timeout = optional(string)<br/> }), {})<br/> sitectl_context_name = optional(string)<br/> sitectl_plugin = optional(string)<br/> sitectl_environment = optional(string)<br/> sitectl_packages = optional(list(string))<br/> sitectl_verify_args = optional(list(string))<br/> docker_compose_init = optional(list(string))<br/> docker_compose_up = optional(list(string))<br/> docker_compose_down = optional(list(string))<br/> docker_compose_rollout = optional(list(string))<br/> })), {})<br/> init = optional(list(string))<br/> up = optional(list(string))<br/> down = optional(list(string))<br/> rollout = optional(list(string))<br/> }), {})<br/><br/> sitectl = optional(object({<br/> packages = optional(list(string))<br/> version = optional(string, "latest")<br/> package_versions = optional(map(string), {})<br/> context_name = optional(string, "")<br/> plugin = optional(string, "core")<br/> environment = optional(string, "production")<br/> verify_args = optional(list(string), [])<br/> }), {})<br/><br/> docker = optional(object({<br/> # renovate: datasource=github-releases depName=docker-compose packageName=docker/compose versioning=semver<br/> compose_version = optional(string, "v5.3.1")<br/> # renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver<br/> buildx_version = optional(string, "v0.35.0")<br/> }), {})<br/><br/> managed_runtime = optional(object({<br/> enabled = optional(bool, true)<br/> internal_services_enabled = optional(bool, false)<br/> internal_services_auto_update = optional(bool, false)<br/> artifacts = optional(list(object({<br/> name = string<br/> url = string<br/> sha256 = string<br/> path = string<br/> mode = optional(string, "0755")<br/> owner = optional(string, "root")<br/> group = optional(string, "root")<br/> restart = optional(string, "")<br/> })), [])<br/> }), {})<br/><br/> vault = optional(object({<br/> addr = optional(string, "")<br/> namespace = optional(string, "")<br/> role = optional(string, "")<br/> agent_enabled = optional(bool, false)<br/> auth_method = optional(string, "auto")<br/> gcp_auth_mount_path = optional(string, "auth/gcp")<br/> agent_token_path = optional(string, "/mnt/disks/data/vault/token")<br/> agent_additional_config = optional(string, "")<br/> agent_templates = optional(list(object({<br/> destination = string<br/> contents = string<br/> perms = optional(string, "0640")<br/> command = optional(string, "")<br/> })), [])<br/> }), {})<br/><br/> extra_env = optional(map(string), {})<br/> })</pre> | `{}` | no |
| <a name="input_template"></a> [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 |

Expand Down
17 changes: 17 additions & 0 deletions docs/runtime-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions modules/gcp/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 7 additions & 1 deletion modules/gcp/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
131 changes: 131 additions & 0 deletions modules/gcp/runtime_contracts.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading