diff --git a/README.md b/README.md index 98513ee..52f388c 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")
data_size_gb = optional(number, 20)
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 | | [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/ci/gcp-upgrade-smoke-contract.sh b/ci/gcp-upgrade-smoke-contract.sh index 88a33fd..4294e6d 100755 --- a/ci/gcp-upgrade-smoke-contract.sh +++ b/ci/gcp-upgrade-smoke-contract.sh @@ -76,11 +76,23 @@ grep -Fq '/mnt/disks/volumes/.cloud-compose-upgrade-sentinel' "$script" || fail "upgrade runner must write the data-disk sentinel exactly once" [[ "$(grep -Fc 'sudo tee /mnt/disks/volumes/.cloud-compose-upgrade-sentinel' "$script")" -eq 1 ]] || fail "upgrade runner must write the Docker-volume sentinel exactly once" +grep -Fq 'read_data_filesystem_size_bytes()' "$script" || + fail "upgrade runner does not measure mounted application-data filesystem capacity" +grep -Fq 'filesystem="$(findmnt -n -o FSTYPE --target /mnt/disks/data)"' "$script" || + fail "upgrade runner does not require the application-data mount to remain ext4" +grep -Fq 'df --block-size=1 --output=size -- /mnt/disks/data' "$script" || + fail "upgrade runner does not measure mounted ext4 capacity in bytes" +grep -Fq '((10#$upgraded_data_filesystem_bytes > 10#$baseline_data_filesystem_bytes))' "$script" || + fail "upgrade runner does not prove mounted ext4 grew beyond its baseline capacity" grep -Fq 'backend "local" {}' "$fixture" || fail "upgrade fixture does not declare the shared local backend" grep -Fq 'source = "../../.."' "$fixture" || fail "upgrade fixture does not exercise the GCP-only compatibility root" +grep -Fq 'application_data_size_gb = var.legacy_baseline ? 20 : 30' "$fixture" || + fail "upgrade fixture does not request exact 20-to-30-GB application-data growth" +grep -Fq 'data_size_gb = local.application_data_size_gb' "$fixture" || + fail "upgrade fixture does not pass its phase-specific application-data size" grep -Fq 'create = false' "$fixture" || fail "upgrade fixture still owns an ephemeral network" grep -Fq 'project_id = var.gcp_network_project_id' "$fixture" || @@ -208,7 +220,7 @@ grep -Fq 'docker_compose_branch = var.wordpress_compose_ref' "$fixture" || fail "upgrade fixture does not use its pinned WordPress commit" grep -Fq "git_project checkout --detach \${var.wordpress_compose_ref}" "$fixture" || fail "legacy bootstrap does not pre-check out the exact WordPress commit" -grep -Fq 'wordpress_project_dir = "/mnt/disks/data/libops/wp.git/${var.wordpress_compose_ref}"' "$fixture" || +grep -Eq 'wordpress_project_dir[[:space:]]*=[[:space:]]*"/mnt/disks/data/libops/wp.git/\$\{var\.wordpress_compose_ref\}"' "$fixture" || fail "upgrade fixture does not derive the exact legacy single-project checkout path" grep -Fq 'project=${local.wordpress_project_dir};' "$fixture" || fail "upgrade fixture does not pre-check out the shared baseline/current Compose path" @@ -307,6 +319,10 @@ grep -Fq 'pull-request titles beginning with `[major]`' "$docs" || fail "upgrade documentation does not explain when the expensive gate runs" grep -Fq "$wordpress_ref" "$docs" || fail "upgrade documentation omits the pinned WordPress Compose revision" +grep -Fq 'controlled reboot after the apply' "$docs" || + fail "GCP disk-growth documentation does not explain when ext4 capacity activates" +grep -Fq 'update-only application-data growth from 20 to 30 GB' "$docs" || + fail "upgrade documentation omits the exact in-place data-disk growth contract" cat >"$tmp/good-plan.json" <<'EOF' { @@ -363,7 +379,11 @@ cat >"$tmp/good-plan.json" <<'EOF' }, { "address": "module.app.module.gcp[0].google_compute_disk.data", - "change": {"actions": ["no-op"]} + "change": { + "actions": ["update"], + "before": {"size": 20}, + "after": {"size": 30} + } }, { "address": "module.app.module.gcp[0].google_compute_disk.docker-volumes", @@ -394,10 +414,22 @@ if bash "$script" check-plan "$tmp/bad-move.json" >/dev/null 2>&1; then fail "plan checker accepted a missing moved-resource provenance" fi +jq '(.resource_changes[] | select(.address | endswith("google_compute_disk.data")) | .change.actions) = ["delete", "create"]' \ + "$tmp/good-plan.json" >"$tmp/bad-data-disk.json" +if bash "$script" check-plan "$tmp/bad-data-disk.json" >/dev/null 2>&1; then + fail "plan checker accepted application-data disk replacement" +fi + +jq '(.resource_changes[] | select(.address | endswith("google_compute_disk.data")) | .change.after.size) = 29' \ + "$tmp/good-plan.json" >"$tmp/bad-data-growth.json" +if bash "$script" check-plan "$tmp/bad-data-growth.json" >/dev/null 2>&1; then + fail "plan checker accepted the wrong application-data growth target" +fi + jq '(.resource_changes[] | select(.address | endswith("docker-volumes")) | .change.actions) = ["delete", "create"]' \ - "$tmp/good-plan.json" >"$tmp/bad-disk.json" -if bash "$script" check-plan "$tmp/bad-disk.json" >/dev/null 2>&1; then - fail "plan checker accepted persistent-disk replacement" + "$tmp/good-plan.json" >"$tmp/bad-docker-disk.json" +if bash "$script" check-plan "$tmp/bad-docker-disk.json" >/dev/null 2>&1; then + fail "plan checker accepted Docker-volume disk replacement" fi jq '.resource_changes += [{"address":"module.app.google_compute_disk.unexpected","change":{"actions":["delete"]}}]' \ @@ -490,7 +522,13 @@ bash "$script" check-transition \ jq '.docker_disk = "docker-replaced"' "$tmp/new-ids.json" >"$tmp/bad-new-ids.json" if bash "$script" check-transition \ "$tmp/old-ids.json" "$tmp/bad-new-ids.json" "$tmp/old-state.txt" "$tmp/new-state.txt" >/dev/null 2>&1; then - fail "state checker accepted a changed persistent disk id" + fail "state checker accepted a changed Docker-volume disk id" +fi + +jq '.data_disk = "data-replaced"' "$tmp/new-ids.json" >"$tmp/bad-data-ids.json" +if bash "$script" check-transition \ + "$tmp/old-ids.json" "$tmp/bad-data-ids.json" "$tmp/old-state.txt" "$tmp/new-state.txt" >/dev/null 2>&1; then + fail "state checker accepted a changed application-data disk id" fi cp "$tmp/new-state.txt" "$tmp/bad-new-state.txt" diff --git a/ci/gcp-upgrade-smoke.sh b/ci/gcp-upgrade-smoke.sh index 91caf7b..f05c07e 100755 --- a/ci/gcp-upgrade-smoke.sh +++ b/ci/gcp-upgrade-smoke.sh @@ -445,6 +445,35 @@ verify_disk_sentinels() { [[ "$actual_volumes" == "$nonce" ]] || fail "the Docker-volume disk sentinel did not survive the upgrade" } +read_data_filesystem_size_bytes() { + local home_dir="$1" key_path="$2" output_json="$3" + local host port user remote_script encoded_script quoted_script size_bytes + + host="$(jq -er '.host' "$output_json")" + port="$(jq -er '.ssh_port' "$output_json")" + user="$(jq -er '.ssh_user' "$output_json")" + + read -r -d '' remote_script <<'EOF' || true +set -euo pipefail + +filesystem="$(findmnt -n -o FSTYPE --target /mnt/disks/data)" +if [[ "$filesystem" != "ext4" ]]; then + echo "Application-data mount uses ${filesystem:-an unknown filesystem}, expected ext4" >&2 + exit 1 +fi +df --block-size=1 --output=size -- /mnt/disks/data | awk 'NR == 2 { print $1 }' +EOF + + encoded_script="$(printf '%s' "$remote_script" | base64 | tr -d '\n')" + quoted_script="$(shell_quote "$encoded_script")" + size_bytes="$(ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \ + "printf %s ${quoted_script} | base64 -d | sudo bash")" + size_bytes="${size_bytes//$'\r'/}" + [[ "$size_bytes" =~ ^[1-9][0-9]*$ ]] || + fail "mounted application-data ext4 reported an invalid byte capacity: ${size_bytes:-empty}" + printf '%s\n' "$size_bytes" +} + write_tfvars() { local root="$1" name="$2" project="$3" region="$4" zone="$5" public_key="$6" runner_cidr="$7" local network_project="$8" network_name="$9" subnetwork_name="${10}" @@ -577,6 +606,7 @@ run_upgrade() ( local requested_base current_ref current_sha base_sha run_id run_namespace name local work_root old_source new_source state_path old_data new_data key_path public_key local runner_ipv4 runner_cidr region zone plan_file plan_json nonce + local baseline_data_filesystem_bytes upgraded_data_filesystem_bytes local old_root new_root old_output new_output old_home new_home local old_state_json new_state_json old_ids new_ids old_state_list new_state_list local cleanup_started=false @@ -703,6 +733,8 @@ run_upgrade() ( old_state_list="$work_root/old-state-list.txt" phase_output "$old_root" "$old_data" "$old_output" run_phase_healthcheck "0.10.2 baseline" "$old_home" "$key_path" "$old_output" + baseline_data_filesystem_bytes="$(read_data_filesystem_size_bytes \ + "$old_home" "$key_path" "$old_output")" tf_in "$old_root" "$old_data" show -json >"$old_state_json" tf_in "$old_root" "$old_data" state list >"$old_state_list" chmod 0600 "$old_state_json" "$old_state_list" @@ -735,6 +767,10 @@ run_upgrade() ( run_phase_healthcheck "upgraded current" "$new_home" "$key_path" "$new_output" run_direct_vpc_cold_start "$new_home" "$key_path" "$new_output" "$name" "$zone" + upgraded_data_filesystem_bytes="$(read_data_filesystem_size_bytes \ + "$new_home" "$key_path" "$new_output")" + ((10#$upgraded_data_filesystem_bytes > 10#$baseline_data_filesystem_bytes)) || + fail "mounted application-data ext4 did not grow beyond its baseline capacity (${baseline_data_filesystem_bytes} -> ${upgraded_data_filesystem_bytes} bytes)" verify_metadata_isolation "$new_home" "$key_path" "$new_output" verify_disk_sentinels "$new_home" "$key_path" "$new_output" "$nonce" echo "GCP cloud-compose 0.10.2 -> ${current_sha} upgrade smoke test passed" diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md index 9ad177c..f00d73b 100644 --- a/docs/runtime-contracts.md +++ b/docs/runtime-contracts.md @@ -225,6 +225,15 @@ 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. +On GCP, `gcp.disks.data_size_gb` independently sizes the application-data disk; +`gcp.disks.docker_volumes_size_gb` sizes the Docker-volume disk. Callers that +store logical backups or other bounded data under `/mnt/disks/data` must size +the application-data disk for both runtime overhead and the complete retention +and staging budget. Increasing the Terraform input grows the GCP block device +in place; it does not by itself resize an already mounted filesystem. Schedule +a controlled reboot after the apply so cloud-init reruns filesystem preparation +and `resize2fs`, then verify the mounted ext4 capacity. Never reduce the input +after the disk exists. Every Compose `project_dir` must be a normalized descendant of the fixed `/mnt/disks/data` ownership boundary. Terraform rejects paths such as `/`, @@ -659,8 +668,10 @@ The VM uses an ephemeral public address, so replacement can change it even when both persistent disks survive. Inventory DNS records, TLS issuance/routing, and external allowlists before applying; update and verify them against the new address before ending the maintenance window. The hosted upgrade smoke proves -the module's state moves and disk/sentinel preservation on its fixture, not the -DNS, TLS, backups, or application-specific migrations of a downstream stack. +the module's state moves, update-only application-data growth from 20 to 30 GB, +mounted ext4 growth after the controlled cloud-init boot, and disk/sentinel +preservation on its fixture, not the DNS, TLS, backups, or application-specific +migrations of a downstream stack. The `GCP WordPress` pull-request check runs this upgrade path only for pull-request titles beginning with `[major]`; other pull requests retain the @@ -701,15 +712,18 @@ runner's public IPv4 `/32`. Before applying the current source, the gate requires Terraform plan JSON to report all five `previous_address` transitions, requires the four preserved -resources plus the data and Docker-volume disks to be no-ops, requires removal +resources and Docker-volume disk to be no-ops, requires the application-data +disk to be an update-only 20-to-30-GB growth, requires removal of the two legacy project-level power memberships, the legacy default Compute service-account impersonation member, the unused VM self-TokenCreator member, and the obsolete app self-signing member, and requires creation of the two instance-level power memberships. It permits no other managed-resource deletion beyond the expected VM and boot-disk replacements. It healthchecks WordPress before and after the upgrade, -compares provider resource IDs, rejects legacy addresses in upgraded state, and -checks sentinels on both persistent disks. An exit trap performs Terraform +compares provider resource IDs, rejects disk replacement and legacy addresses +in upgraded state, checks sentinels on both persistent disks, and verifies that +the mounted application-data ext4 capacity exceeds its pre-upgrade capacity +after the controlled reboot reruns cloud-init. An exit trap performs Terraform destroy followed by the name- and run-scoped provider sweep; the same GCP job also has an `always()` cleanup step, and the trusted default-branch cleanup workflow remains the final fallback. In GitHub Actions the cleanup scope must diff --git a/internal/upgradecheck/check.go b/internal/upgradecheck/check.go index 7275d5b..c9afe19 100644 --- a/internal/upgradecheck/check.go +++ b/internal/upgradecheck/check.go @@ -9,10 +9,15 @@ import ( "fmt" "io" "slices" + "strconv" "strings" ) -const resourcePrefix = "module.app.module.gcp[0]" +const ( + resourcePrefix = "module.app.module.gcp[0]" + baselineDataDiskSizeGB = 20 + upgradedDataDiskSizeGB = 30 +) var ( dataDiskAddress = resourcePrefix + ".google_compute_disk.data" @@ -72,7 +77,9 @@ type resourceChange struct { PreviousAddress string `json:"previous_address"` Mode string `json:"mode"` Change struct { - Actions []string `json:"actions"` + Actions []string `json:"actions"` + Before map[string]json.RawMessage `json:"before"` + After map[string]json.RawMessage `json:"after"` } `json:"change"` } @@ -140,12 +147,34 @@ func ValidatePlan(reader io.Reader) error { } } - for _, address := range []string{dataDiskAddress, dockerDiskAddress} { - if !anyChange(changes, func(change resourceChange) bool { - return change.Address == address && slices.Equal(change.Change.Actions, []string{"no-op"}) - }) { - return fmt.Errorf("persistent disk was not a no-op in the upgrade plan: %s", address) - } + dataDiskChange, ok := changeAtAddress(changes, dataDiskAddress) + if !ok { + return fmt.Errorf("upgrade plan did not contain the application-data disk: %s", dataDiskAddress) + } + if !slices.Equal(dataDiskChange.Change.Actions, []string{"update"}) { + return fmt.Errorf("application-data disk growth was not update-only in the upgrade plan: %s", dataDiskAddress) + } + beforeSize, err := wholeNumberAttribute(dataDiskChange.Change.Before, "size") + if err != nil { + return fmt.Errorf("application-data disk baseline size: %w", err) + } + afterSize, err := wholeNumberAttribute(dataDiskChange.Change.After, "size") + if err != nil { + return fmt.Errorf("application-data disk upgraded size: %w", err) + } + if beforeSize != baselineDataDiskSizeGB || afterSize != upgradedDataDiskSizeGB { + return fmt.Errorf( + "application-data disk size transition was %d -> %d GB, want %d -> %d GB", + beforeSize, + afterSize, + baselineDataDiskSizeGB, + upgradedDataDiskSizeGB, + ) + } + + dockerDiskChange, ok := changeAtAddress(changes, dockerDiskAddress) + if !ok || !slices.Equal(dockerDiskChange.Change.Actions, []string{"no-op"}) { + return fmt.Errorf("Docker-volume disk was not a no-op in the upgrade plan: %s", dockerDiskAddress) } for _, removal := range removedLegacyBindings { @@ -220,6 +249,31 @@ func ValidatePlan(reader io.Reader) error { return nil } +func changeAtAddress(changes []resourceChange, address string) (resourceChange, bool) { + for _, change := range changes { + if change.Address == address { + return change, true + } + } + return resourceChange{}, false +} + +func wholeNumberAttribute(values map[string]json.RawMessage, attribute string) (int64, error) { + raw, ok := values[attribute] + if !ok { + return 0, fmt.Errorf("plan did not expose %s", attribute) + } + var value json.Number + if err := json.Unmarshal(raw, &value); err != nil { + return 0, fmt.Errorf("plan exposed invalid %s", attribute) + } + parsed, err := strconv.ParseInt(value.String(), 10, 64) + if err != nil { + return 0, fmt.Errorf("plan exposed non-whole %s", attribute) + } + return parsed, nil +} + // CaptureIDs extracts the immutable resource identities needed to compare the // baseline ("old") and upgraded ("new") Terraform states. func CaptureIDs(reader io.Reader, phase string) (map[string]string, error) { diff --git a/internal/upgradecheck/check_test.go b/internal/upgradecheck/check_test.go index fc7fb8b..3d176da 100644 --- a/internal/upgradecheck/check_test.go +++ b/internal/upgradecheck/check_test.go @@ -29,11 +29,26 @@ func TestValidatePlan(t *testing.T) { wantErr: "did not preserve moved resource", }, { - name: "durable disk is replaced", + name: "application data disk is replaced", + mutate: func(t *testing.T, plan map[string]any) { + setActions(t, planChange(t, plan, dataDiskAddress), "delete", "create") + }, + wantErr: "application-data disk growth was not update-only", + }, + { + name: "application data disk has wrong growth target", + mutate: func(t *testing.T, plan map[string]any) { + change := planChange(t, plan, dataDiskAddress)["change"].(map[string]any) + change["after"].(map[string]any)["size"] = float64(29) + }, + wantErr: "size transition was 20 -> 29 GB", + }, + { + name: "Docker-volume disk is replaced", mutate: func(t *testing.T, plan map[string]any) { setActions(t, planChange(t, plan, dockerDiskAddress), "delete", "create") }, - wantErr: "persistent disk was not a no-op", + wantErr: "Docker-volume disk was not a no-op", }, { name: "unexpected managed resource is deleted", @@ -226,7 +241,14 @@ func TestValidateTransition(t *testing.T) { }{ {name: "expected transition"}, { - name: "durable disk identity changes", + name: "application data disk identity changes", + mutateNewIDs: func(values map[string]any) { + values["data_disk"] = "data-replaced" + }, + wantErr: "resource identity changed", + }, + { + name: "Docker-volume disk identity changes", mutateNewIDs: func(values map[string]any) { values["docker_disk"] = "docker-replaced" }, diff --git a/internal/upgradecheck/testdata/good-plan.json b/internal/upgradecheck/testdata/good-plan.json index 0d7bea2..beec5cf 100644 --- a/internal/upgradecheck/testdata/good-plan.json +++ b/internal/upgradecheck/testdata/good-plan.json @@ -52,7 +52,11 @@ }, { "address": "module.app.module.gcp[0].google_compute_disk.data", - "change": {"actions": ["no-op"]} + "change": { + "actions": ["update"], + "before": {"size": 20}, + "after": {"size": 30} + } }, { "address": "module.app.module.gcp[0].google_compute_disk.docker-volumes", diff --git a/main.tf b/main.tf index dab7d7c..49cfea3 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 + data_disk_size_gb = local.gcp_disks.data_size_gb + disk_size_gb = local.gcp_disks.docker_volumes_size_gb 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..7236021 100644 --- a/modules/gcp/main.tf +++ b/modules/gcp/main.tf @@ -629,7 +629,7 @@ resource "google_compute_disk" "data" { project = var.project_id type = var.disk_type zone = var.zone - size = 20 + size = var.data_disk_size_gb physical_block_size_bytes = 4096 } diff --git a/modules/gcp/outputs.tf b/modules/gcp/outputs.tf index 8d62e29..d2ba63d 100644 --- a/modules/gcp/outputs.tf +++ b/modules/gcp/outputs.tf @@ -23,11 +23,13 @@ output "instance" { id = google_compute_disk.data.id name = google_compute_disk.data.name self_link = google_compute_disk.data.self_link + size_gb = google_compute_disk.data.size } docker_volumes_disk = { id = google_compute_disk.docker-volumes.id name = google_compute_disk.docker-volumes.name self_link = google_compute_disk.docker-volumes.self_link + size_gb = google_compute_disk.docker-volumes.size } gsa : { email : local.vm_service_account_email, @@ -57,11 +59,13 @@ output "volumes" { id = google_compute_disk.data.id name = google_compute_disk.data.name self_link = google_compute_disk.data.self_link + size_gb = google_compute_disk.data.size } docker_volumes = { id = google_compute_disk.docker-volumes.id name = google_compute_disk.docker-volumes.name self_link = google_compute_disk.docker-volumes.self_link + size_gb = google_compute_disk.docker-volumes.size } } description = "Persistent GCP application-data and Docker-volume disks. The replaceable boot disk is exposed under instance.boot_disk." diff --git a/modules/gcp/runtime_contracts.tftest.hcl b/modules/gcp/runtime_contracts.tftest.hcl index 5d3e1d2..307dd79 100644 --- a/modules/gcp/runtime_contracts.tftest.hcl +++ b/modules/gcp/runtime_contracts.tftest.hcl @@ -88,6 +88,41 @@ run "disables_privileged_services_by_default" { } } +run "sizes_application_data_independently" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + data_disk_size_gb = 170 + disk_size_gb = 50 + } + + assert { + condition = ( + google_compute_disk.data.size == 170 && + google_compute_disk.docker-volumes.size == 50 && + output.volumes.data.size_gb == 170 && + output.volumes.docker_volumes.size_gb == 50 + ) + error_message = "Application data and Docker volumes must retain independent configured capacities." + } +} + +run "rejects_fractional_application_data_size" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + data_disk_size_gb = 20.5 + } + + expect_failures = [var.data_disk_size_gb] +} + run "creates_app_key_management_only_when_explicitly_enabled" { command = plan diff --git a/modules/gcp/variables.tf b/modules/gcp/variables.tf index eb1e3f9..237bffe 100644 --- a/modules/gcp/variables.tf +++ b/modules/gcp/variables.tf @@ -176,7 +176,18 @@ variable "sitectl_ingress" { variable "disk_size_gb" { type = number default = 50 - description = "Data disk size in GB" + description = "Docker-volume disk size in GB" +} + +variable "data_disk_size_gb" { + type = number + default = 20 + description = "Application-data disk size in GB. Size this independently for checkouts, configuration, credentials, tools, and caller-managed data such as logical backups. Terraform can grow the block device in place; the mounted ext4 filesystem grows when cloud-init reruns filesystem preparation on the next controlled boot. Existing disks cannot shrink." + + validation { + condition = var.data_disk_size_gb >= 10 && floor(var.data_disk_size_gb) == var.data_disk_size_gb + error_message = "data_disk_size_gb must be a whole number of at least 10 GB." + } } variable "os" { diff --git a/providers/gcp/main.tf b/providers/gcp/main.tf index 308a9d3..80a74c0 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 + data_disk_size_gb = local.gcp_disks.data_size_gb + disk_size_gb = local.gcp_disks.docker_volumes_size_gb 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..87e93be 100644 --- a/providers/gcp/template_versions.tftest.hcl +++ b/providers/gcp/template_versions.tftest.hcl @@ -73,6 +73,30 @@ run "explicit_core_only_package_set_disables_template_plugins" { } } +run "gcp_entrypoint_forwards_application_data_size" { + command = plan + + variables { + name = "template-versions" + template = "wp" + gcp = { + project_id = "test-project" + disks = { + data_size_gb = 170 + docker_volumes_size_gb = 50 + } + } + } + + assert { + condition = ( + output.volumes.data.size_gb == 170 && + output.volumes.docker_volumes.size_gb == 50 + ) + error_message = "The GCP entrypoint must forward independent application-data and Docker-volume sizes." + } +} + run "accepts_direct_cloud_run_proxy_depth" { command = plan diff --git a/providers/gcp/variables.tf b/providers/gcp/variables.tf index 0cbf042..fb9edc5 100644 --- a/providers/gcp/variables.tf +++ b/providers/gcp/variables.tf @@ -41,6 +41,7 @@ variable "gcp" { disks = optional(object({ type = optional(string, "hyperdisk-balanced") + data_size_gb = optional(number, 20) docker_volumes_size_gb = optional(number, 50) }), {}) @@ -110,6 +111,11 @@ variable "gcp" { error_message = "gcp.disks.type must be hyperdisk-balanced, pd-ssd, or pd-standard." } + validation { + condition = var.gcp.disks.data_size_gb >= 10 && floor(var.gcp.disks.data_size_gb) == var.gcp.disks.data_size_gb + error_message = "gcp.disks.data_size_gb must be a whole number of at least 10 GB." + } + validation { condition = contains([ "e2-micro", diff --git a/runtime_contracts.tftest.hcl b/runtime_contracts.tftest.hcl index e149839..1cf30b2 100644 --- a/runtime_contracts.tftest.hcl +++ b/runtime_contracts.tftest.hcl @@ -74,6 +74,31 @@ run "public_entrypoint_exposes_sitectl_package_versions" { } } +run "public_entrypoint_forwards_application_data_size" { + command = plan + + variables { + name = "root-contract" + cloud_provider = "gcp" + template = "wp" + gcp = { + project_id = "test-project" + disks = { + data_size_gb = 170 + docker_volumes_size_gb = 50 + } + } + } + + assert { + condition = ( + output.volumes.data.size_gb == 170 && + output.volumes.docker_volumes.size_gb == 50 + ) + error_message = "The root entrypoint must forward independent application-data and Docker-volume sizes." + } +} + run "public_entrypoint_rejects_reserved_extra_environment" { command = plan diff --git a/tests/smoke/gcp-upgrade/main.tf b/tests/smoke/gcp-upgrade/main.tf index 0748389..980bb8c 100644 --- a/tests/smoke/gcp-upgrade/main.tf +++ b/tests/smoke/gcp-upgrade/main.tf @@ -29,8 +29,9 @@ data "google_project" "current" { } locals { - project_id = trimspace(var.gcp_project_id) - wordpress_project_dir = "/mnt/disks/data/libops/wp.git/${var.wordpress_compose_ref}" + project_id = trimspace(var.gcp_project_id) + wordpress_project_dir = "/mnt/disks/data/libops/wp.git/${var.wordpress_compose_ref}" + application_data_size_gb = var.legacy_baseline ? 20 : 30 } module "app" { @@ -57,6 +58,7 @@ module "app" { } disks = { type = "pd-standard" + data_size_gb = local.application_data_size_gb docker_volumes_size_gb = 30 } snapshots = { diff --git a/variables.tf b/variables.tf index 9905ee6..ffe17ac 100644 --- a/variables.tf +++ b/variables.tf @@ -47,6 +47,7 @@ variable "gcp" { disks = optional(object({ type = optional(string, "hyperdisk-balanced") + data_size_gb = optional(number, 20) docker_volumes_size_gb = optional(number, 50) }), {}) @@ -116,6 +117,11 @@ variable "gcp" { error_message = "gcp.disks.type must be hyperdisk-balanced, pd-ssd, or pd-standard." } + validation { + condition = var.gcp.disks.data_size_gb >= 10 && floor(var.gcp.disks.data_size_gb) == var.gcp.disks.data_size_gb + error_message = "gcp.disks.data_size_gb must be a whole number of at least 10 GB." + } + validation { condition = contains([ "e2-micro",