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