diff --git a/.github/workflows/cloud-smoke-cleanup.yml b/.github/workflows/cloud-smoke-cleanup.yml index 62971c4..ca238b5 100644 --- a/.github/workflows/cloud-smoke-cleanup.yml +++ b/.github/workflows/cloud-smoke-cleanup.yml @@ -49,20 +49,33 @@ jobs: cancel-in-progress: false env: CLOUD_COMPOSE_SMOKE_RUN_ID: ${{ github.event.workflow_run.id }} - DIGITALOCEAN_TOKEN: ${{ matrix.provider == 'digitalocean' && secrets.DIGITALOCEAN_TOKEN || '' }} - LINODE_TOKEN: ${{ matrix.provider == 'linode' && secrets.LINODE_TOKEN || '' }} steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.sha }} + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.26.x" + cache: false + + - name: Build trusted cloud CI runner + run: make cloud-compose-ci - name: Sweep provider smoke resources if: matrix.kind == 'app' + env: + DIGITALOCEAN_TOKEN: ${{ matrix.provider == 'digitalocean' && secrets.DIGITALOCEAN_TOKEN || '' }} + LINODE_TOKEN: ${{ matrix.provider == 'linode' && secrets.LINODE_TOKEN || '' }} run: ci/cloud-smoke.sh sweep-${{ matrix.provider }}-${{ matrix.template }} - name: Sweep config-management smoke resources if: matrix.kind == 'config-management' + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} run: ci/config-management-cloud-smoke.sh sweep-${{ matrix.method }}-drupal gcp-cleanup: @@ -87,6 +100,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.sha }} + persist-credentials: false - name: Check GCP cleanup configuration run: | diff --git a/.github/workflows/cloud-smoke.yml b/.github/workflows/cloud-smoke.yml index 4e8a67a..6f62f28 100644 --- a/.github/workflows/cloud-smoke.yml +++ b/.github/workflows/cloud-smoke.yml @@ -52,6 +52,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Run Ansible and Salt smoke checks run: make config-management-smoke @@ -79,10 +81,20 @@ jobs: CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT: "1800" CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS: "true" CLOUD_COMPOSE_SMOKE_RUN_ID: ${{ github.run_id }} - LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.26.x" + cache: false + + - name: Build provider cloud CI runner + run: make cloud-compose-ci - name: Install Terraform uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4 @@ -91,10 +103,14 @@ jobs: terraform_wrapper: false - name: Run Linode config-management smoke test + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} run: make config-management-cloud-smoke METHOD=${{ matrix.method }} - name: Destroy Linode config-management smoke resources if: always() + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} run: ci/config-management-cloud-smoke.sh destroy-${{ matrix.method }}-drupal smoke: @@ -125,12 +141,21 @@ jobs: CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS: "true" CLOUD_COMPOSE_SMOKE_RUN_ID: ${{ github.run_id }} CLOUD_COMPOSE_SOURCE_REF: ${{ github.event.pull_request.head.sha }} - DIGITALOCEAN_TOKEN: ${{ matrix.provider == 'digitalocean' && secrets.DIGITALOCEAN_TOKEN || '' }} - LINODE_TOKEN: ${{ matrix.provider == 'linode' && secrets.LINODE_TOKEN || '' }} steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.26.x" + cache: false + + - name: Build provider cloud CI runner + run: make cloud-compose-ci - name: Install Terraform uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4 @@ -142,10 +167,16 @@ jobs: run: ci/install-sitectl-apt.sh - name: Run smoke test + env: + DIGITALOCEAN_TOKEN: ${{ matrix.provider == 'digitalocean' && secrets.DIGITALOCEAN_TOKEN || '' }} + LINODE_TOKEN: ${{ matrix.provider == 'linode' && secrets.LINODE_TOKEN || '' }} run: make smoke-test PROVIDER=${{ matrix.provider }} TEMPLATE=${{ matrix.template }} - name: Destroy smoke resources if: always() + env: + DIGITALOCEAN_TOKEN: ${{ matrix.provider == 'digitalocean' && secrets.DIGITALOCEAN_TOKEN || '' }} + LINODE_TOKEN: ${{ matrix.provider == 'linode' && secrets.LINODE_TOKEN || '' }} run: ci/cloud-smoke.sh destroy-${{ matrix.provider }}-${{ matrix.template }} gcp-smoke: diff --git a/Makefile b/Makefile index f5a6e20..6fe5a16 100644 --- a/Makefile +++ b/Makefile @@ -117,13 +117,13 @@ artifact-install-contract: bash ci/docker-plugin-install-contract.sh bash ci/cos-bootstrap-contract.sh -hosted-cleanup-retry-contract: +hosted-cleanup-retry-contract: go-contracts bash ci/hosted-cleanup-retry-contract.sh config-management-smoke: runtime-config-contract artifact-install-contract ci/config-management-smoke.sh -config-management-cloud-smoke: +config-management-cloud-smoke: cloud-compose-ci @test -n "$(METHOD)" || { echo "METHOD is required"; exit 2; } ci/config-management-cloud-smoke.sh $(METHOD)-drupal @@ -133,7 +133,7 @@ config-management-cloud-smoke-ansible-drupal: config-management-cloud-smoke-salt-drupal: $(MAKE) config-management-cloud-smoke METHOD=salt -destroy-config-management-cloud-smoke: +destroy-config-management-cloud-smoke: cloud-compose-ci @test -n "$(METHOD)" || { echo "METHOD is required"; exit 2; } ci/config-management-cloud-smoke.sh destroy-$(METHOD)-drupal @@ -152,10 +152,9 @@ terraform-docs-check: smoke-test-clouds: cloud-compose-ci ci/cloud-smoke.sh all -smoke-test: +smoke-test: cloud-compose-ci @test -n "$(PROVIDER)" || { echo "PROVIDER is required"; exit 2; } @test -n "$(TEMPLATE)" || { echo "TEMPLATE is required"; exit 2; } - @if [ "$(PROVIDER)" = "gcp" ]; then $(MAKE) --no-print-directory cloud-compose-ci; fi ci/cloud-smoke.sh $(PROVIDER)-$(TEMPLATE) smoke-test-digitalocean-isle: @@ -167,10 +166,9 @@ smoke-test-linode-wp: smoke-test-gcp-wp: $(MAKE) smoke-test PROVIDER=gcp TEMPLATE=wp -destroy-smoke: +destroy-smoke: cloud-compose-ci @test -n "$(PROVIDER)" || { echo "PROVIDER is required"; exit 2; } @test -n "$(TEMPLATE)" || { echo "TEMPLATE is required"; exit 2; } - @if [ "$(PROVIDER)" = "gcp" ]; then $(MAKE) --no-print-directory cloud-compose-ci; fi ci/cloud-smoke.sh destroy-$(PROVIDER)-$(TEMPLATE) destroy-smoke-digitalocean-isle: diff --git a/README.md b/README.md index cc30b15..ea6faa5 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # cloud-compose -Deploy Docker Compose projects to VMs. Provider-specific Terraform entrypoints -under `providers/` cover GCP, DigitalOcean, and Linode without loading unused -cloud providers. Existing Debian/Ubuntu hosts can consume the same runtime -contract through the Ansible role or Salt formula. +Deploy Docker Compose projects to VMs. The repository root is a compatibility +entrypoint for existing GCP callers. New callers should select exactly one +Terraform entrypoint under `providers/`: `providers/gcp`, `providers/do`, or +`providers/linode`. Each entrypoint loads only its target cloud provider. +Existing Debian/Ubuntu hosts can consume the same runtime contract through the +Ansible role or Salt formula. Template defaults live in `templates/apps.json` and are shared by Terraform, Ansible, and Salt. The default deployment shape is one app per VM or host; pass @@ -31,9 +33,7 @@ requirements. |------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [cloudinit](#requirement\_cloudinit) | ~> 2.3 | -| [digitalocean](#requirement\_digitalocean) | ~> 2.0 | | [google](#requirement\_google) | ~> 7.0 | -| [linode](#requirement\_linode) | ~> 4.0 | | [time](#requirement\_time) | ~> 0.14 | ## Providers @@ -44,9 +44,7 @@ No providers. | Name | Source | Version | |------|--------|---------| -| [digitalocean](#module\_digitalocean) | ./modules/digitalocean | n/a | | [gcp](#module\_gcp) | ./modules/gcp | n/a | -| [linode](#module\_linode) | ./modules/linode | n/a | | [managed\_artifacts](#module\_managed\_artifacts) | ./modules/managed-artifacts | n/a | ## Resources @@ -58,10 +56,8 @@ No resources. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [name](#input\_name) | Deployment name. | `string` | n/a | yes | -| [cloud\_provider](#input\_cloud\_provider) | Cloud provider to deploy to. Supported values are gcp, digitalocean, and linode. | `string` | `"gcp"` | no | -| [digitalocean](#input\_digitalocean) | DigitalOcean infrastructure settings. droplet.backups covers only the Droplet boot disk; attached application and Docker volumes require a separate offsite backup policy. |
object({
region = optional(string, "tor1")
tags = optional(list(string), ["cloud-compose"])

droplet = optional(object({
size = optional(string, "s-2vcpu-4gb")
image = optional(string, "ubuntu-24-04-x64")
ssh_keys = optional(list(string), [])
vpc_uuid = optional(string, null)
monitoring = optional(bool, true)
ipv6 = optional(bool, true)
backups = optional(bool, false)
}), {})

ssh = optional(object({
cloud_compose_keys = optional(list(string), [])
users = optional(map(list(string)), {})
}), {})

volumes = optional(object({
data_size_gb = optional(number, 50)
docker_volumes_size_gb = optional(number, 100)
}), {})

firewall = optional(object({
enabled = optional(bool, true)
ssh_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"])
web_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"])
}), {})
})
| `{}` | no | +| [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 | -| [linode](#input\_linode) | Linode infrastructure settings. instance.backups\_enabled covers only the instance disk; attached application and Docker Block Storage volumes require a separate offsite backup policy. |
object({
region = optional(string, "us-east")
tags = optional(list(string), ["cloud-compose"])

instance = optional(object({
type = optional(string, "g6-standard-2")
image = optional(string, "linode/ubuntu22.04")
authorized_keys = optional(list(string), [])
authorized_users = optional(list(string), [])
root_pass = optional(string, null)
private_ip = optional(bool, true)
backups_enabled = optional(bool, false)
watchdog_enabled = optional(bool, true)
}), {})

ssh = optional(object({
cloud_compose_keys = optional(list(string), [])
users = optional(map(list(string)), {})
}), {})

volumes = optional(object({
data_size_gb = optional(number, 50)
docker_volumes_size_gb = optional(number, 100)
}), {})

firewall = optional(object({
enabled = optional(bool, true)
ssh_source_ipv4 = optional(list(string), ["0.0.0.0/0"])
ssh_source_ipv6 = optional(list(string), ["::/0"])
web_source_ipv4 = optional(list(string), ["0.0.0.0/0"])
web_source_ipv6 = optional(list(string), ["::/0"])
}), {})
})
| `{}` | 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, "main")
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 | @@ -71,18 +67,18 @@ No resources. |------|-------------| | [appGsa](#output\_appGsa) | The Google Service Account the app can use for app-scoped auth. | | [backend](#output\_backend) | Backend service ID for attaching Cloud Run ingress to an external HTTPS load balancer. | -| [cloud\_provider](#output\_cloud\_provider) | Selected cloud provider. | +| [cloud\_provider](#output\_cloud\_provider) | Root entrypoint cloud provider (gcp). | | [compose\_projects](#output\_compose\_projects) | Normalized compose project manifest. | -| [external\_ip](#output\_external\_ip) | Selected provider VM public IPv4 address. | -| [instance](#output\_instance) | Selected provider VM instance details. | -| [instance\_id](#output\_instance\_id) | Selected provider VM instance ID. | -| [internal\_ip](#output\_internal\_ip) | Selected provider VM private IPv4 address. | -| [network](#output\_network) | Resolved GCP network and regional subnetwork, or null for non-GCP providers. | +| [external\_ip](#output\_external\_ip) | GCP VM public IPv4 address. | +| [instance](#output\_instance) | GCP VM instance details. | +| [instance\_id](#output\_instance\_id) | GCP VM instance ID. | +| [internal\_ip](#output\_internal\_ip) | GCP VM private IPv4 address. | +| [network](#output\_network) | Resolved GCP network and regional subnetwork. | | [primary\_compose\_project](#output\_primary\_compose\_project) | Normalized primary compose project. | | [rollout](#output\_rollout) | Optional rollout API endpoint details. | | [serviceGsa](#output\_serviceGsa) | The Google Service Account internal services run as. | | [sitectl\_package\_versions](#output\_sitectl\_package\_versions) | Effective release selector for every installed sitectl package; values may be exact tags or latest. | | [template](#output\_template) | Selected compose template preset. | | [urls](#output\_urls) | Cloud Run ingress URLs by region. | -| [volumes](#output\_volumes) | Selected provider persistent application-data and Docker-volume details. | +| [volumes](#output\_volumes) | GCP persistent application-data and Docker-volume details. | diff --git a/ansible/README.md b/ansible/README.md index 824843d..ab7285f 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -90,7 +90,7 @@ all: sitectl: environment: production package_versions: - sitectl-isle: v0.18.0 + sitectl-isle: v0.19.0 wp-prod.example.edu: ansible_user: debian cloud_compose_name: wp-prod diff --git a/ci/cloud-smoke.sh b/ci/cloud-smoke.sh index 87eee12..c77650c 100755 --- a/ci/cloud-smoke.sh +++ b/ci/cloud-smoke.sh @@ -22,6 +22,8 @@ Required environment: DIGITALOCEAN_TOKEN DigitalOcean API token for digitalocean targets. LINODE_TOKEN Linode API token for linode targets. GCLOUD_PROJECT Google Cloud project for gcp targets. + CLOUD_COMPOSE_SMOKE_RUN_ID + Canonical GitHub Actions run id for every apply and scoped cleanup. Optional environment: CLOUD_COMPOSE_SMOKE_AUTO_APPROVE=true Pass -auto-approve outside GitHub Actions. @@ -34,7 +36,6 @@ Optional environment: Remove prior smoke resources for the same target before apply. CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS=true Permit an explicit sweep command to remove every run for a target. - CLOUD_COMPOSE_SMOKE_RUN_ID Run id used to target provider cleanup; required for scoped GCP cleanup. CLOUD_COMPOSE_SMOKE_TARGETS Space-separated targets used by "all" and "sweep". DIGITALOCEAN_API_TOKEN Backward-compatible alias for DIGITALOCEAN_TOKEN. GCLOUD_REGION=us-east5 Google Cloud region for gcp targets. @@ -150,6 +151,21 @@ smoke_run_id() { printf '%s\n' "${CLOUD_COMPOSE_SMOKE_RUN_ID:-${GITHUB_RUN_ID:-}}" } +validate_smoke_run_id() { + local run_id="$1" runner + + if [[ -z "$run_id" ]]; then + echo "CLOUD_COMPOSE_SMOKE_RUN_ID is required for every provider smoke apply" >&2 + return 1 + fi + runner="${CLOUD_COMPOSE_CI_BIN:-$repo_root/.bin/cloud-compose-ci}" + if [[ ! -x "$runner" ]]; then + echo "Missing compiled CI runner: ${runner}; run 'make cloud-compose-ci' first" >&2 + return 1 + fi + "$runner" run validate --run-id "$run_id" +} + gcp_run_namespace() { local target="$1" run_id="$2" runner @@ -169,116 +185,6 @@ gcp_run_namespace() { "$runner" gcp namespace --run-id "$run_id" } -api_request() { - local provider="$1" method="$2" path="$3" - local base_url body http_code response token token_name - - case "$provider" in - digitalocean) - token="${DIGITALOCEAN_TOKEN:-}" - token_name="DIGITALOCEAN_TOKEN" - base_url="https://api.digitalocean.com/v2" - ;; - linode) - token="${LINODE_TOKEN:-}" - token_name="LINODE_TOKEN" - base_url="https://api.linode.com/v4" - ;; - esac - - if response="$(curl -sS \ - --connect-timeout 10 \ - --max-time 45 \ - -X "$method" \ - -H "Authorization: Bearer ${token}" \ - -w $'\n%{http_code}' \ - "${base_url}${path}")"; then - http_code="${response##*$'\n'}" - body="${response%$'\n'"$http_code"}" - else - echo "${provider} API request failed for ${method} ${path}; check ${token_name} and network access." >&2 - return 75 - fi - - case "$http_code" in - 2??) - printf '%s' "$body" - ;; - 401) - echo "${provider} API rejected ${token_name} with HTTP 401 for ${method} ${path}; verify the GitHub secret is current and valid for this provider." >&2 - return 22 - ;; - 403) - echo "${provider} API rejected ${token_name} with HTTP 403 for ${method} ${path}; verify the token has the permissions required by smoke cleanup and Terraform." >&2 - return 22 - ;; - 404 | 410) - if [[ "$method" == "DELETE" ]]; then - return 0 - fi - echo "${provider} API returned HTTP ${http_code} for ${method} ${path}." >&2 - if [[ -n "$body" ]]; then - printf '%s\n' "$body" >&2 - fi - return 22 - ;; - 408 | 425 | 429 | 5??) - echo "${provider} API returned retryable HTTP ${http_code} for ${method} ${path}." >&2 - if [[ -n "$body" ]]; then - printf '%s\n' "$body" >&2 - fi - return 75 - ;; - 409 | 423) - if [[ "$method" == "DELETE" ]]; then - echo "${provider} API returned retryable HTTP ${http_code} for ${method} ${path}." >&2 - if [[ -n "$body" ]]; then - printf '%s\n' "$body" >&2 - fi - return 75 - fi - echo "${provider} API returned HTTP ${http_code} for ${method} ${path}." >&2 - if [[ -n "$body" ]]; then - printf '%s\n' "$body" >&2 - fi - return 22 - ;; - *) - echo "${provider} API returned HTTP ${http_code} for ${method} ${path}." >&2 - if [[ -n "$body" ]]; then - printf '%s\n' "$body" >&2 - fi - return 22 - ;; - esac -} - -api_delete() { - api_request "$1" DELETE "$2" >/dev/null -} - -api_get() { - local provider="$1" path="$2" attempt=1 delay=2 status - - while true; do - if api_request "$provider" GET "$path"; then - return 0 - else - status=$? - fi - if [[ "$status" -ne 75 || "$attempt" -ge 6 ]]; then - return "$status" - fi - echo "Retrying ${provider} API GET ${path} in ${delay}s (attempt $((attempt + 1)) of 6)" >&2 - sleep "$delay" - attempt=$((attempt + 1)) - delay=$((delay * 2)) - if [[ "$delay" -gt 30 ]]; then - delay=30 - fi - done -} - gcp_region() { printf '%s\n' "${GCLOUD_REGION:-us-east5}" } @@ -287,243 +193,45 @@ gcp_zone() { printf '%s\n' "${GCLOUD_ZONE:-$(gcp_region)-b}" } -delete_ids() { - local provider="$1" path_prefix="$2" id attempt status - local failed=0 deleted - - while IFS= read -r id; do - if [[ -z "$id" ]]; then - continue - fi - deleted=false - for attempt in {1..12}; do - echo "Deleting ${provider} ${path_prefix}/${id} (attempt ${attempt})" - if api_delete "$provider" "${path_prefix}/${id}"; then - deleted=true - break - else - status=$? - fi - if [[ "$status" -ne 75 ]]; then - break - fi - if [[ "$attempt" -lt 12 ]]; then - sleep 10 - fi - done - if [[ "$deleted" != "true" ]]; then - echo "Failed to delete ${provider} ${path_prefix}/${id} after ${attempt} attempt(s)" >&2 - failed=1 - fi - done - - return "$failed" -} - -smoke_run_tag() { - local run_id="$1" - - if [[ -z "$run_id" ]]; then - return 0 - fi - run_id="$(printf '%s' "$run_id" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | cut -c1-16)" - printf 'gha-run-%s\n' "$run_id" -} - -template_slug() { - case "$1" in - archivesspace) printf 'as\n' ;; - ojs) printf 'ojs\n' ;; - isle) printf 'isle\n' ;; - drupal) printf 'dr\n' ;; - wp) printf 'wp\n' ;; - omeka-s) printf 'os\n' ;; - omeka-classic) printf 'oc\n' ;; - *) - echo "Unknown smoke template: $1" >&2 - exit 2 - ;; - esac -} - -provider_slug() { - case "$1" in - digitalocean) printf 'do\n' ;; - gcp) printf 'g\n' ;; - linode) printf 'ln\n' ;; - *) - echo "Unknown smoke provider: $1" >&2 - exit 2 - ;; - esac -} - -target_name_prefix() { - local target="$1" provider template +provider_tag_cleanup() { + local target="$1" run_id="${2:-}" allow_all_runs="${3:-false}" provider cleanup_binary + local -a cleanup_args provider="$(target_provider "$target")" - template="$(target_template "$target")" - printf 'cc-%s-%s\n' "$(provider_slug "$provider")" "$(template_slug "$template")" -} - -provider_resource_ids() { - local target="$1" run_id="${2:-}" kind="$3" - local run_tag run_fragment provider name_prefix - - run_tag="$(smoke_run_tag "$run_id")" - run_fragment="" - if [[ -n "$run_id" ]]; then - run_fragment="-$(printf '%s' "$run_id" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | cut -c1-16)-" + cleanup_binary="${CLOUD_COMPOSE_CI_BIN:-$repo_root/.bin/cloud-compose-ci}" + if [[ ! -x "$cleanup_binary" ]]; then + echo "Missing compiled CI runner: ${cleanup_binary}; run 'make cloud-compose-ci' first" >&2 + return 1 fi - provider="$(target_provider "$target")" - name_prefix="$(target_name_prefix "$target")" - - case "${provider}:${kind}" in - digitalocean:firewalls) - api_get digitalocean "/firewalls?per_page=200" | - jq -r --arg name_prefix "${name_prefix}-" --arg run_fragment "$run_fragment" 'if (.firewalls | type) != "array" then error("DigitalOcean firewalls response is not an array") else .firewalls[] | select(.name | startswith($name_prefix)) | select($run_fragment == "" or (.name | contains($run_fragment))) | .id end' - ;; - digitalocean:droplets) - api_get digitalocean "/droplets?tag_name=cloud-compose-smoke&per_page=200" | - jq -r --arg target "$target" --arg run_tag "$run_tag" 'if (.droplets | type) != "array" then error("DigitalOcean droplets response is not an array") else .droplets[] | select((.tags // []) | index($target)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id end' - ;; - digitalocean:volumes) - api_get digitalocean "/volumes?tag_name=cloud-compose-smoke&per_page=200" | - jq -r --arg target "$target" --arg run_tag "$run_tag" 'if (.volumes | type) != "array" then error("DigitalOcean volumes response is not an array") else .volumes[] | select((.tags // []) | index($target)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id end' - ;; - linode:firewalls) - api_get linode "/networking/firewalls?page_size=500" | - jq -r --arg target "$target" --arg run_tag "$run_tag" 'if (.data | type) != "array" then error("Linode firewalls response data is not an array") else .data[] | select((.tags // []) | index("cloud-compose-smoke") and index($target)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id end' - ;; - linode:instances) - api_get linode "/linode/instances?page_size=500" | - jq -r --arg target "$target" --arg run_tag "$run_tag" 'if (.data | type) != "array" then error("Linode instances response data is not an array") else .data[] | select((.tags // []) | index("cloud-compose-smoke") and index($target)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id end' - ;; - linode:volumes) - api_get linode "/volumes?page_size=500" | - jq -r --arg target "$target" --arg run_tag "$run_tag" 'if (.data | type) != "array" then error("Linode volumes response data is not an array") else .data[] | select((.tags // []) | index("cloud-compose-smoke") and index($target)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id end' - ;; - *) - echo "Unknown provider resource kind: ${provider}:${kind}" >&2 - return 2 - ;; - esac -} - -provider_cleanup_residuals() { - local target="$1" run_id="${2:-}" provider kind ids id index - local -a kinds path_prefixes - - provider="$(target_provider "$target")" - case "$provider" in - digitalocean) - kinds=(firewalls droplets volumes) - path_prefixes=(/firewalls /droplets /volumes) - ;; - linode) - kinds=(firewalls instances volumes) - path_prefixes=(/networking/firewalls /linode/instances /volumes) - ;; - *) - return 0 - ;; - esac - - for index in "${!kinds[@]}"; do - kind="${kinds[$index]}" - if ! ids="$(provider_resource_ids "$target" "$run_id" "$kind")"; then - return 1 - fi - while IFS= read -r id; do - [[ -n "$id" ]] || continue - printf '%s%s\n' "${path_prefixes[$index]}/" "$id" - done <<<"$ids" - done -} - -verify_no_provider_resources() { - local target="$1" run_id="${2:-}" attempt residuals - - for attempt in {1..6}; do - if ! residuals="$(provider_cleanup_residuals "$target" "$run_id")"; then - echo "Could not verify provider cleanup for ${target}" >&2 - return 1 - fi - if [[ -z "$residuals" ]]; then - echo "Verified that no matching ${target} smoke resources remain" - return 0 - fi - echo "Matching ${target} smoke resources remain after cleanup verification attempt ${attempt}:" >&2 - printf '%s\n' "$residuals" >&2 - if [[ "$attempt" -lt 6 ]]; then - sleep 10 - fi - done - echo "Provider cleanup left matching ${target} smoke resources" >&2 - return 1 -} - -provider_tag_cleanup() { - local target="$1" run_id="${2:-}" allow_all_runs="${3:-false}" provider kind index name_prefix - local cleanup_status=0 - local -a kinds path_prefixes - - provider="$(target_provider "$target")" - name_prefix="$(target_name_prefix "$target")" case "$provider" in - digitalocean) - kinds=(firewalls droplets volumes) - path_prefixes=(/firewalls /droplets /volumes) - ;; - linode) - kinds=(firewalls instances volumes) - path_prefixes=(/networking/firewalls /linode/instances /volumes) + digitalocean | linode) + cleanup_args=( + "$provider" sweep + --scope application + --target "$target" + ) ;; gcp) - local cleanup_binary - local -a cleanup_args - - cleanup_binary="${CLOUD_COMPOSE_CI_BIN:-$repo_root/.bin/cloud-compose-ci}" - if [[ ! -x "$cleanup_binary" ]]; then - echo "Missing compiled CI runner: ${cleanup_binary}; run 'make cloud-compose-ci' first" >&2 - return 1 - fi cleanup_args=( gcp sweep --project "$GCLOUD_PROJECT" --region "$(gcp_region)" --target "$target" ) - if [[ -n "$run_id" ]]; then - cleanup_args+=(--run-id "$run_id") - elif [[ "$allow_all_runs" == "true" ]]; then - cleanup_args+=(--all-runs) - else - echo "GCP cleanup requires CLOUD_COMPOSE_SMOKE_RUN_ID; set CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS=true only for an intentional target-wide orphan sweep" >&2 - return 1 - fi - if ! "$cleanup_binary" "${cleanup_args[@]}"; then - cleanup_status=1 - fi ;; esac - if [[ "$provider" == "digitalocean" || "$provider" == "linode" ]]; then - for index in "${!kinds[@]}"; do - kind="${kinds[$index]}" - provider_resource_ids "$target" "$run_id" "$kind" | - delete_ids "$provider" "${path_prefixes[$index]}" || cleanup_status=1 - if [[ "$kind" == "droplets" || "$kind" == "instances" ]]; then - sleep 10 - fi - done - if [[ "$cleanup_status" -eq 0 ]] && ! verify_no_provider_resources "$target" "$run_id"; then - cleanup_status=1 - fi + if [[ -n "$run_id" ]]; then + cleanup_args+=(--run-id "$run_id") + elif [[ "$allow_all_runs" == "true" ]]; then + cleanup_args+=(--all-runs) + else + echo "Provider cleanup requires CLOUD_COMPOSE_SMOKE_RUN_ID; set CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS=true only for an intentional target-wide orphan sweep" >&2 + return 1 fi - return "$cleanup_status" + "$cleanup_binary" "${cleanup_args[@]}" } maybe_sweep_orphans() { @@ -865,6 +573,7 @@ run_target() ( ensure_key "$key_path" run_id="$(smoke_run_id)" + validate_smoke_run_id "$run_id" # Compute outside process substitution so an invalid hosted run ID aborts # before Terraform can create resources under an undiscoverable namespace. run_namespace="$(gcp_run_namespace "$target" "$run_id")" @@ -1020,15 +729,9 @@ main() { sweep) for target in $(default_targets); do provider="$(target_provider "$target")" - case "$provider" in - digitalocean | linode) - require_cmd curl - require_cmd jq - ;; - gcp) - require_cmd gcloud - ;; - esac + if [[ "$provider" == "gcp" ]]; then + require_cmd gcloud + fi target_env "$target" provider_tag_cleanup "$target" "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}" "${CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS:-false}" done @@ -1037,15 +740,9 @@ main() { sweep-*) target="${1#sweep-}" provider="$(target_provider "$target")" - case "$provider" in - digitalocean | linode) - require_cmd curl - require_cmd jq - ;; - gcp) - require_cmd gcloud - ;; - esac + if [[ "$provider" == "gcp" ]]; then + require_cmd gcloud + fi target_env "$target" provider_tag_cleanup "$target" "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}" "${CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS:-false}" exit 0 @@ -1054,15 +751,10 @@ main() { target="${1#destroy-}" provider="$(target_provider "$target")" require_cmd terraform - case "$provider" in - digitalocean | linode) - require_cmd curl - require_cmd jq - ;; - gcp) - require_cmd gcloud - ;; - esac + require_cmd curl + if [[ "$provider" == "gcp" ]]; then + require_cmd gcloud + fi destroy_target "$target" exit 0 ;; diff --git a/ci/config-management-cloud-smoke.sh b/ci/config-management-cloud-smoke.sh index 274a5b5..6d2ae86 100755 --- a/ci/config-management-cloud-smoke.sh +++ b/ci/config-management-cloud-smoke.sh @@ -20,13 +20,15 @@ Usage: Required environment: LINODE_TOKEN + CLOUD_COMPOSE_SMOKE_RUN_ID Canonical GitHub Actions run id for every apply and scoped cleanup. Optional environment: CLOUD_COMPOSE_SMOKE_AUTO_APPROVE=true CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT=1200 CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT=1800 CLOUD_COMPOSE_SMOKE_KEEP=true - CLOUD_COMPOSE_SMOKE_RUN_ID + CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS=true + Permit an explicit sweep to remove every run for a target. CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS=true CLOUD_COMPOSE_SMOKE_WORKDIR=.cloud-compose-smoke CLOUD_COMPOSE_CONFIG_MANAGEMENT_IMAGE=${CONFIG_MANAGEMENT_IMAGE_DEFAULT} @@ -97,19 +99,19 @@ smoke_run_id() { printf '%s\n' "${CLOUD_COMPOSE_SMOKE_RUN_ID:-${GITHUB_RUN_ID:-}}" } -smoke_run_tag() { - local run_id="$1" +validate_smoke_run_id() { + local run_id="$1" runner if [[ -z "$run_id" ]]; then - return 0 + echo "CLOUD_COMPOSE_SMOKE_RUN_ID is required for every config-management smoke apply" >&2 + return 1 fi - run_id="$(printf '%s' "$run_id" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | cut -c1-16)" - printf 'gha-run-%s\n' "$run_id" -} - -target_tag() { - local target="$1" - printf 'config-management-%s-%s\n' "$(target_method "$target")" "$(target_template "$target")" + runner="${CLOUD_COMPOSE_CI_BIN:-$repo_root/.bin/cloud-compose-ci}" + if [[ ! -x "$runner" ]]; then + echo "Missing compiled CI runner: ${runner}; run 'make cloud-compose-ci' first" >&2 + return 1 + fi + "$runner" run validate --run-id "$run_id" } target_workdir() { @@ -137,219 +139,31 @@ ensure_key() { chmod 0600 "$key_path" } -api_request() { - local method="$1" path="$2" - local body http_code response - - if response="$(curl -sS \ - --connect-timeout 10 \ - --max-time 45 \ - -X "$method" \ - -H "Authorization: Bearer ${LINODE_TOKEN}" \ - -w $'\n%{http_code}' \ - "https://api.linode.com/v4${path}")"; then - http_code="${response##*$'\n'}" - body="${response%$'\n'"$http_code"}" - else - echo "linode API request failed for ${method} ${path}; check LINODE_TOKEN and network access." >&2 - return 75 - fi - - case "$http_code" in - 2??) - printf '%s' "$body" - ;; - 401) - echo "linode API rejected LINODE_TOKEN with HTTP 401 for ${method} ${path}; verify the GitHub secret is current and valid for Linode." >&2 - return 22 - ;; - 403) - echo "linode API rejected LINODE_TOKEN with HTTP 403 for ${method} ${path}; verify the token has the permissions required by smoke cleanup and Terraform." >&2 - return 22 - ;; - 404 | 410) - if [[ "$method" == "DELETE" ]]; then - return 0 - fi - echo "linode API returned HTTP ${http_code} for ${method} ${path}." >&2 - if [[ -n "$body" ]]; then - printf '%s\n' "$body" >&2 - fi - return 22 - ;; - 408 | 425 | 429 | 5??) - echo "linode API returned retryable HTTP ${http_code} for ${method} ${path}." >&2 - if [[ -n "$body" ]]; then - printf '%s\n' "$body" >&2 - fi - return 75 - ;; - 409 | 423) - if [[ "$method" == "DELETE" ]]; then - echo "linode API returned retryable HTTP ${http_code} for ${method} ${path}." >&2 - if [[ -n "$body" ]]; then - printf '%s\n' "$body" >&2 - fi - return 75 - fi - echo "linode API returned HTTP ${http_code} for ${method} ${path}." >&2 - if [[ -n "$body" ]]; then - printf '%s\n' "$body" >&2 - fi - return 22 - ;; - *) - echo "linode API returned HTTP ${http_code} for ${method} ${path}." >&2 - if [[ -n "$body" ]]; then - printf '%s\n' "$body" >&2 - fi - return 22 - ;; - esac -} - -api_get() { - local path="$1" attempt=1 delay=2 status - - while true; do - if api_request GET "$path"; then - return 0 - else - status=$? - fi - if [[ "$status" -ne 75 || "$attempt" -ge 6 ]]; then - return "$status" - fi - echo "Retrying linode API GET ${path} in ${delay}s (attempt $((attempt + 1)) of 6)" >&2 - sleep "$delay" - attempt=$((attempt + 1)) - delay=$((delay * 2)) - if [[ "$delay" -gt 30 ]]; then - delay=30 - fi - done -} - -api_delete() { - api_request DELETE "$1" >/dev/null -} - -delete_ids() { - local path_prefix="$1" id attempt status - local failed=0 deleted - - while IFS= read -r id; do - [[ -n "$id" ]] || continue - deleted=false - for attempt in {1..12}; do - echo "Deleting Linode resource ${path_prefix}/${id} (attempt ${attempt})" - if api_delete "${path_prefix}/${id}"; then - deleted=true - break - else - status=$? - fi - if [[ "$status" -ne 75 ]]; then - break - fi - if [[ "$attempt" -lt 12 ]]; then - sleep 10 - fi - done - if [[ "$deleted" != "true" ]]; then - echo "Failed to delete Linode resource ${path_prefix}/${id} after ${attempt} attempt(s)" >&2 - failed=1 - fi - done - - return "$failed" -} - -provider_resource_ids() { - local target="$1" run_id="${2:-}" kind="$3" run_tag tag - - run_tag="$(smoke_run_tag "$run_id")" - tag="$(target_tag "$target")" - - case "$kind" in - firewalls) - api_get "/networking/firewalls?page_size=500" | - jq -r --arg tag "$tag" --arg run_tag "$run_tag" 'if (.data | type) != "array" then error("Linode firewalls response data is not an array") else .data[] | select((.tags // []) | index("cloud-compose-smoke") and index("config-management-smoke") and index($tag)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id end' - ;; - instances) - api_get "/linode/instances?page_size=500" | - jq -r --arg tag "$tag" --arg run_tag "$run_tag" 'if (.data | type) != "array" then error("Linode instances response data is not an array") else .data[] | select((.tags // []) | index("cloud-compose-smoke") and index("config-management-smoke") and index($tag)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id end' - ;; - volumes) - api_get "/volumes?page_size=500" | - jq -r --arg tag "$tag" --arg run_tag "$run_tag" 'if (.data | type) != "array" then error("Linode volumes response data is not an array") else .data[] | select((.tags // []) | index("cloud-compose-smoke") and index("config-management-smoke") and index($tag)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id end' - ;; - *) - echo "Unknown Linode resource kind: ${kind}" >&2 - return 2 - ;; - esac -} - -provider_cleanup_residuals() { - local target="$1" run_id="${2:-}" kind ids id index - local -a kinds=(firewalls instances volumes) - local -a path_prefixes=(/networking/firewalls /linode/instances /volumes) - - for index in "${!kinds[@]}"; do - kind="${kinds[$index]}" - if ! ids="$(provider_resource_ids "$target" "$run_id" "$kind")"; then - return 1 - fi - while IFS= read -r id; do - [[ -n "$id" ]] || continue - printf '%s%s\n' "${path_prefixes[$index]}/" "$id" - done <<<"$ids" - done -} - -verify_no_provider_resources() { - local target="$1" run_id="${2:-}" attempt residuals - - for attempt in {1..6}; do - if ! residuals="$(provider_cleanup_residuals "$target" "$run_id")"; then - echo "Could not verify provider cleanup for config-management ${target}" >&2 - return 1 - fi - if [[ -z "$residuals" ]]; then - echo "Verified that no matching config-management ${target} smoke resources remain" - return 0 - fi - echo "Matching config-management ${target} smoke resources remain after cleanup verification attempt ${attempt}:" >&2 - printf '%s\n' "$residuals" >&2 - if [[ "$attempt" -lt 6 ]]; then - sleep 10 - fi - done +provider_tag_cleanup() { + local target="$1" run_id="${2:-}" allow_all_runs="${3:-false}" cleanup_binary + local -a cleanup_args - echo "Provider cleanup left matching config-management ${target} smoke resources" >&2 - return 1 -} + cleanup_binary="${CLOUD_COMPOSE_CI_BIN:-$repo_root/.bin/cloud-compose-ci}" + if [[ ! -x "$cleanup_binary" ]]; then + echo "Missing compiled CI runner: ${cleanup_binary}; run 'make cloud-compose-ci' first" >&2 + return 1 + fi -provider_tag_cleanup() { - local target="$1" run_id="${2:-}" kind index - local cleanup_status=0 - local -a kinds=(firewalls instances volumes) - local -a path_prefixes=(/networking/firewalls /linode/instances /volumes) - - for index in "${!kinds[@]}"; do - kind="${kinds[$index]}" - provider_resource_ids "$target" "$run_id" "$kind" | - delete_ids "${path_prefixes[$index]}" || cleanup_status=1 - if [[ "$kind" == "instances" ]]; then - sleep 10 - fi - done - if [[ "$cleanup_status" -eq 0 ]] && ! verify_no_provider_resources "$target" "$run_id"; then - cleanup_status=1 + cleanup_args=( + linode sweep + --scope config-management + --target "$target" + ) + if [[ -n "$run_id" ]]; then + cleanup_args+=(--run-id "$run_id") + elif [[ "$allow_all_runs" == "true" ]]; then + cleanup_args+=(--all-runs) + else + echo "Provider cleanup requires CLOUD_COMPOSE_SMOKE_RUN_ID; set CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS=true only for an intentional target-wide orphan sweep" >&2 + return 1 fi - return "$cleanup_status" + "$cleanup_binary" "${cleanup_args[@]}" } scan_host_key() { @@ -498,7 +312,6 @@ deploy_config_management() { } require_run_commands() { - require_cmd curl require_cmd docker require_cmd git require_cmd jq @@ -509,15 +322,18 @@ require_run_commands() { } require_destroy_commands() { - require_cmd curl - require_cmd jq require_cmd ssh-keygen require_cmd terraform } require_sweep_commands() { - require_cmd curl - require_cmd jq + local cleanup_binary + + cleanup_binary="${CLOUD_COMPOSE_CI_BIN:-$repo_root/.bin/cloud-compose-ci}" + if [[ ! -x "$cleanup_binary" ]]; then + echo "Missing compiled CI runner: ${cleanup_binary}; run 'make cloud-compose-ci' first" >&2 + return 1 + fi } run_target() ( @@ -542,6 +358,7 @@ run_target() ( ensure_key "$key_path" run_id="$(smoke_run_id)" + validate_smoke_run_id "$run_id" mapfile -d '' -t var_args < <(target_var_args "$key_path" "$target") auto_args=() @@ -597,7 +414,7 @@ run_target() ( TF_DATA_DIR="$workdir/.terraform" terraform -chdir="$root" validate if [[ "${CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS:-}" == "true" ]]; then - provider_tag_cleanup "$target" + provider_tag_cleanup "$target" "" true fi echo "Applying ${target} raw Linode smoke" @@ -687,13 +504,13 @@ main() { require_env LINODE_TOKEN require_sweep_commands for target in $(default_targets); do - provider_tag_cleanup "$target" "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}" + provider_tag_cleanup "$target" "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}" "${CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS:-false}" done ;; sweep-*) require_env LINODE_TOKEN require_sweep_commands - provider_tag_cleanup "${1#sweep-}" "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}" + provider_tag_cleanup "${1#sweep-}" "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}" "${CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS:-false}" ;; destroy-*) require_destroy_commands diff --git a/ci/config-management-smoke-inner.sh b/ci/config-management-smoke-inner.sh index 3973507..b6515a3 100755 --- a/ci/config-management-smoke-inner.sh +++ b/ci/config-management-smoke-inner.sh @@ -262,7 +262,7 @@ assert env["DOCKER_COMPOSE_REPO"] == "https://github.com/libops/isle" assert env["SITECTL_PLUGIN"] == "isle" assert "sitectl-isle" in env["SITECTL_PACKAGES"].split() assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == { - "sitectl": "v0.39.0", + "sitectl": "v0.40.0", "sitectl-drupal": "v0.11.0", "sitectl-isle": "v0.12.0", } @@ -341,7 +341,7 @@ MINION } run_salt_case() { - local minion_id="$1" expected_name="$2" expected_repo="$3" expected_plugin="$4" expected_package="$5" expected_domain="$6" expected_project_dir="$7" + local minion_id="$1" expected_name="$2" expected_repo="$3" expected_plugin="$4" expected_package="$5" expected_domain="$6" expected_project_dir="$7" expected_compose_project_name="$8" rm -rf /home/cloud-compose /mnt/disks write_salt_config "$minion_id" @@ -393,7 +393,7 @@ assert lock_states[0]["changes"] == {}, lock_states[0] PY python -m json.tool /home/cloud-compose/compose-projects.json >/dev/null - python - "$expected_name" "$expected_repo" "$expected_plugin" "$expected_package" "$expected_domain" "$expected_project_dir" <<'PY' + python - "$expected_name" "$expected_repo" "$expected_plugin" "$expected_package" "$expected_domain" "$expected_project_dir" "$expected_compose_project_name" <<'PY' import json import grp import os @@ -402,7 +402,7 @@ import subprocess import sys from pathlib import Path -expected_name, expected_repo, expected_plugin, expected_package, expected_domain, expected_project_dir = sys.argv[1:] +expected_name, expected_repo, expected_plugin, expected_package, expected_domain, expected_project_dir, expected_compose_project_name = sys.argv[1:] def load_runtime_env(path): result = subprocess.run( @@ -431,17 +431,23 @@ assert env["CLOUD_COMPOSE_APPS"] == expected_name assert env["CLOUD_COMPOSE_PRIMARY_APP"] == expected_name assert env["DOCKER_COMPOSE_DIR"] == expected_project_dir assert env["DOCKER_COMPOSE_REPO"] == expected_repo +assert env["COMPOSE_PROJECT_NAME"] == expected_compose_project_name, ( + env["COMPOSE_PROJECT_NAME"], expected_compose_project_name +) assert env["SITECTL_PLUGIN"] == expected_plugin assert expected_package in env["SITECTL_PACKAGES"].split() assert project["docker_compose_repo"] == expected_repo assert project["project_dir"] == expected_project_dir +assert project["compose_project_name"] == expected_compose_project_name, ( + project["compose_project_name"], expected_compose_project_name +) assert project["sitectl_plugin"] == expected_plugin assert project["ingress"]["domain"] == expected_domain assert Path(expected_project_dir).is_dir() if expected_name == "wp-prod": assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == { - "sitectl": "v0.39.0", + "sitectl": "v0.40.0", "sitectl-wp": "v0.10.0", } assert "BASH_ENV" not in env @@ -504,7 +510,8 @@ run_salt_case \ wp \ sitectl-wp \ wp.example.edu \ - /mnt/disks/data/libops/wp/main + /mnt/disks/data/libops/wp/main \ + libops-wp-v1-0-0 run_salt_case \ drupal-prod \ @@ -513,7 +520,8 @@ run_salt_case \ drupal \ sitectl-drupal \ drupal.example.edu \ - /mnt/disks/data/libops/drupal/main + /mnt/disks/data/libops/drupal/main \ + libops-drupal-v1-0-0 run_invalid_salt_case() { local invalid_case="$1" expected="$2" diff --git a/ci/gcp-upgrade-smoke-contract.sh b/ci/gcp-upgrade-smoke-contract.sh index ccc4832..0713e12 100755 --- a/ci/gcp-upgrade-smoke-contract.sh +++ b/ci/gcp-upgrade-smoke-contract.sh @@ -74,6 +74,10 @@ grep -Fq 'capture_state_resource_attribute "$state_json" "$vm_address" instance_ 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 'readonly resource_prefix="module.app.module.gcp[0]"' "$script" || + fail "upgrade runner does not preserve the root GCP module's indexed state address" grep -Fq 'create = false' "$fixture" || fail "upgrade fixture still owns an ephemeral network" grep -Fq 'project_id = var.gcp_network_project_id' "$fixture" || @@ -306,72 +310,72 @@ cat >"$tmp/good-plan.json" <<'EOF' "format_version": "1.2", "resource_changes": [ { - "address": "module.app.module.gcp.google_service_account.internal-services[0]", - "previous_address": "module.app.module.gcp.google_service_account.internal-services", + "address": "module.app.module.gcp[0].google_service_account.internal-services[0]", + "previous_address": "module.app.module.gcp[0].google_service_account.internal-services", "change": {"actions": ["no-op"]} }, { - "address": "module.app.module.gcp.google_service_account_iam_member.internal-services-keys[0]", - "previous_address": "module.app.module.gcp.google_service_account_iam_member.internal-services-keys", + "address": "module.app.module.gcp[0].google_service_account_iam_member.internal-services-keys[0]", + "previous_address": "module.app.module.gcp[0].google_service_account_iam_member.internal-services-keys", "change": {"actions": ["no-op"]} }, { - "address": "module.app.module.gcp.google_project_iam_member.stackdriver[0]", - "previous_address": "module.app.module.gcp.google_project_iam_member.stackdriver", + "address": "module.app.module.gcp[0].google_project_iam_member.stackdriver[0]", + "previous_address": "module.app.module.gcp[0].google_project_iam_member.stackdriver", "change": {"actions": ["no-op"]} }, { - "address": "module.app.module.gcp.google_project_iam_member.gce-start[0]", + "address": "module.app.module.gcp[0].google_project_iam_member.gce-start[0]", "change": {"actions": ["delete"]} }, { - "address": "module.app.module.gcp.google_project_iam_member.gce-suspend", + "address": "module.app.module.gcp[0].google_project_iam_member.gce-suspend", "change": {"actions": ["delete"]} }, { - "address": "module.app.module.gcp.google_service_account_iam_member.gsa-user", + "address": "module.app.module.gcp[0].google_service_account_iam_member.gsa-user", "change": {"actions": ["delete"]} }, { - "address": "module.app.module.gcp.google_service_account_iam_member.token-creator", + "address": "module.app.module.gcp[0].google_service_account_iam_member.token-creator", "change": {"actions": ["delete"]} }, { - "address": "module.app.module.gcp.google_service_account_iam_member.vault_agent_jwt_signer_policy[0]", - "previous_address": "module.app.module.gcp.google_service_account_iam_member.self_jwt_signer_policy", + "address": "module.app.module.gcp[0].google_service_account_iam_member.vault_agent_jwt_signer_policy[0]", + "previous_address": "module.app.module.gcp[0].google_service_account_iam_member.self_jwt_signer_policy", "change": {"actions": ["delete"]} }, { - "address": "module.app.module.gcp.google_service_account_iam_member.app-keys[0]", - "previous_address": "module.app.module.gcp.google_service_account_iam_member.app-keys", + "address": "module.app.module.gcp[0].google_service_account_iam_member.app-keys[0]", + "previous_address": "module.app.module.gcp[0].google_service_account_iam_member.app-keys", "change": {"actions": ["no-op"]} }, { - "address": "module.app.module.gcp.google_compute_instance_iam_member.gce-start[0]", + "address": "module.app.module.gcp[0].google_compute_instance_iam_member.gce-start[0]", "change": {"actions": ["create"]} }, { - "address": "module.app.module.gcp.google_compute_instance_iam_member.gce-suspend[0]", + "address": "module.app.module.gcp[0].google_compute_instance_iam_member.gce-suspend[0]", "change": {"actions": ["create"]} }, { - "address": "module.app.module.gcp.google_compute_disk.data", + "address": "module.app.module.gcp[0].google_compute_disk.data", "change": {"actions": ["no-op"]} }, { - "address": "module.app.module.gcp.google_compute_disk.docker-volumes", + "address": "module.app.module.gcp[0].google_compute_disk.docker-volumes", "change": {"actions": ["no-op"]} }, { - "address": "module.app.module.gcp.google_compute_disk.boot", + "address": "module.app.module.gcp[0].google_compute_disk.boot", "change": {"actions": ["delete", "create"]} }, { - "address": "module.app.module.gcp.google_compute_instance.cloud-compose", + "address": "module.app.module.gcp[0].google_compute_instance.cloud-compose", "change": {"actions": ["delete", "create"]} }, { - "address": "module.app.module.gcp.data.google_project_iam_custom_role.gce-suspend", + "address": "module.app.module.gcp[0].data.google_project_iam_custom_role.gce-suspend", "mode": "data", "change": {"actions": ["delete"]} } @@ -487,23 +491,23 @@ cat >"$tmp/new-ids.json" <<'EOF' } EOF cat >"$tmp/old-state.txt" <<'EOF' -module.app.module.gcp.google_service_account.internal-services -module.app.module.gcp.google_service_account_iam_member.internal-services-keys -module.app.module.gcp.google_project_iam_member.stackdriver -module.app.module.gcp.google_project_iam_member.gce-start[0] -module.app.module.gcp.google_project_iam_member.gce-suspend -module.app.module.gcp.google_service_account_iam_member.gsa-user -module.app.module.gcp.google_service_account_iam_member.token-creator -module.app.module.gcp.google_service_account_iam_member.self_jwt_signer_policy -module.app.module.gcp.google_service_account_iam_member.app-keys +module.app.module.gcp[0].google_service_account.internal-services +module.app.module.gcp[0].google_service_account_iam_member.internal-services-keys +module.app.module.gcp[0].google_project_iam_member.stackdriver +module.app.module.gcp[0].google_project_iam_member.gce-start[0] +module.app.module.gcp[0].google_project_iam_member.gce-suspend +module.app.module.gcp[0].google_service_account_iam_member.gsa-user +module.app.module.gcp[0].google_service_account_iam_member.token-creator +module.app.module.gcp[0].google_service_account_iam_member.self_jwt_signer_policy +module.app.module.gcp[0].google_service_account_iam_member.app-keys EOF cat >"$tmp/new-state.txt" <<'EOF' -module.app.module.gcp.google_service_account.internal-services[0] -module.app.module.gcp.google_service_account_iam_member.internal-services-keys[0] -module.app.module.gcp.google_project_iam_member.stackdriver[0] -module.app.module.gcp.google_compute_instance_iam_member.gce-start[0] -module.app.module.gcp.google_compute_instance_iam_member.gce-suspend[0] -module.app.module.gcp.google_service_account_iam_member.app-keys[0] +module.app.module.gcp[0].google_service_account.internal-services[0] +module.app.module.gcp[0].google_service_account_iam_member.internal-services-keys[0] +module.app.module.gcp[0].google_project_iam_member.stackdriver[0] +module.app.module.gcp[0].google_compute_instance_iam_member.gce-start[0] +module.app.module.gcp[0].google_compute_instance_iam_member.gce-suspend[0] +module.app.module.gcp[0].google_service_account_iam_member.app-keys[0] EOF bash "$script" check-transition \ @@ -516,34 +520,34 @@ if bash "$script" check-transition \ fi cp "$tmp/new-state.txt" "$tmp/bad-new-state.txt" -printf '%s\n' 'module.app.module.gcp.google_service_account.internal-services' >>"$tmp/bad-new-state.txt" +printf '%s\n' 'module.app.module.gcp[0].google_service_account.internal-services' >>"$tmp/bad-new-state.txt" if bash "$script" check-transition \ "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/bad-new-state.txt" >/dev/null 2>&1; then fail "state checker accepted a retained legacy resource address" fi cp "$tmp/new-state.txt" "$tmp/bad-legacy-power-state.txt" -printf '%s\n' 'module.app.module.gcp.google_project_iam_member.gce-start[0]' >>"$tmp/bad-legacy-power-state.txt" +printf '%s\n' 'module.app.module.gcp[0].google_project_iam_member.gce-start[0]' >>"$tmp/bad-legacy-power-state.txt" if bash "$script" check-transition \ "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/bad-legacy-power-state.txt" >/dev/null 2>&1; then fail "state checker accepted a retained legacy project-wide power binding" fi cp "$tmp/new-state.txt" "$tmp/bad-legacy-gsa-state.txt" -printf '%s\n' 'module.app.module.gcp.google_service_account_iam_member.gsa-user' >>"$tmp/bad-legacy-gsa-state.txt" +printf '%s\n' 'module.app.module.gcp[0].google_service_account_iam_member.gsa-user' >>"$tmp/bad-legacy-gsa-state.txt" if bash "$script" check-transition \ "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/bad-legacy-gsa-state.txt" >/dev/null 2>&1; then fail "state checker accepted the legacy default Compute service-account impersonation grant" fi cp "$tmp/new-state.txt" "$tmp/bad-legacy-token-state.txt" -printf '%s\n' 'module.app.module.gcp.google_service_account_iam_member.token-creator' >>"$tmp/bad-legacy-token-state.txt" +printf '%s\n' 'module.app.module.gcp[0].google_service_account_iam_member.token-creator' >>"$tmp/bad-legacy-token-state.txt" if bash "$script" check-transition \ "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/bad-legacy-token-state.txt" >/dev/null 2>&1; then fail "state checker accepted the unused VM self token-creator grant" fi -grep -Fvx 'module.app.module.gcp.google_compute_instance_iam_member.gce-suspend[0]' \ +grep -Fvx 'module.app.module.gcp[0].google_compute_instance_iam_member.gce-suspend[0]' \ "$tmp/new-state.txt" >"$tmp/bad-missing-scoped-state.txt" if bash "$script" check-transition \ "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/bad-missing-scoped-state.txt" >/dev/null 2>&1; then diff --git a/ci/gcp-upgrade-smoke.sh b/ci/gcp-upgrade-smoke.sh index 72c405a..7f258e1 100755 --- a/ci/gcp-upgrade-smoke.sh +++ b/ci/gcp-upgrade-smoke.sh @@ -11,7 +11,7 @@ repo_root="$(cd -- "$script_dir/.." && pwd)" source "$script_dir/cloud-smoke.sh" readonly upgrade_base_sha="f33117cdbbf4a9c7d59006a4db986baef118e6bb" -readonly resource_prefix="module.app.module.gcp" +readonly resource_prefix="module.app.module.gcp[0]" readonly data_disk_address="${resource_prefix}.google_compute_disk.data" readonly docker_disk_address="${resource_prefix}.google_compute_disk.docker-volumes" readonly boot_disk_address="${resource_prefix}.google_compute_disk.boot" diff --git a/ci/hosted-cleanup-retry-contract.sh b/ci/hosted-cleanup-retry-contract.sh index aaa5da2..c8c3b51 100755 --- a/ci/hosted-cleanup-retry-contract.sh +++ b/ci/hosted-cleanup-retry-contract.sh @@ -7,405 +7,32 @@ tmp="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-hosted-cleanup.XXXXXX")" trap 'rm -rf "$tmp"' EXIT fail() { - echo "hosted cleanup retry contract: $*" >&2 + echo "hosted cleanup lifecycle contract: $*" >&2 exit 1 } -mkdir -p "$tmp/bin" "$tmp/state" +mkdir -p "$tmp/bin" "$tmp/work" -cat >"$tmp/bin/sleep" <<'EOF' +cat >"$tmp/bin/cloud-compose-ci" <<'EOF' #!/usr/bin/env bash set -euo pipefail -printf 'sleep %s\n' "$*" >>"$FAKE_API_LOG" -EOF - -cat >"$tmp/bin/curl" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -printf 'curl' >>"$FAKE_API_LOG" -printf ' %q' "$@" >>"$FAKE_API_LOG" -printf '\n' >>"$FAKE_API_LOG" - -method="" -url="" -connect_timeout="" -max_time="" -while [[ "$#" -gt 0 ]]; do - case "$1" in - -sS) - shift - ;; - --connect-timeout) - connect_timeout="${2:-}" - shift 2 - ;; - --max-time) - max_time="${2:-}" - shift 2 - ;; - -X) - method="${2:-}" - shift 2 - ;; - -H | -w) - shift 2 - ;; - https://*) - url="$1" - shift - ;; - *) - echo "unexpected fake curl argument: $1" >&2 - exit 64 - ;; - esac -done - -[[ "$connect_timeout" == "10" ]] || { - echo "cleanup request omitted the 10-second connect timeout" >&2 - exit 64 -} -[[ "$max_time" == "45" ]] || { - echo "cleanup request omitted the 45-second total timeout" >&2 - exit 64 -} - -state_count() { - local key="$1" - - if [[ -f "$FAKE_API_STATE/$key.count" ]]; then - cat "$FAKE_API_STATE/$key.count" - else - printf '0\n' - fi -} - -increment_count() { - local key="$1" count - - count="$(state_count "$key")" - count=$((count + 1)) - printf '%s\n' "$count" >"$FAKE_API_STATE/$key.count" - printf '%s\n' "$count" -} - -respond() { - local body="$1" code="$2" - - printf '%s\n%s\n' "$body" "$code" -} - -mark_deleted() { - printf '%s\n' "$url" >>"$FAKE_API_STATE/deleted.log" -} - -resource_visible() { - local delete_url="$1" primary_count - - case "$FAKE_API_MODE" in - residual-persistent) - return 0 - ;; - residual-delayed) - primary_count="$(state_count primary)" - if [[ "$primary_count" -le 3 ]]; then - return 0 - fi - return 1 - ;; - esac - - if [[ -f "$FAKE_API_STATE/deleted.log" ]] && grep -Fxq "$delete_url" "$FAKE_API_STATE/deleted.log"; then - return 1 - fi - return 0 -} - -normal_get_body() { - local single=false - - if [[ "$FAKE_API_MODE" == delete-* ]]; then - single=true - fi - - case "$FAKE_API_DRIVER:$url" in - app-linode:*api.linode.com*/networking/firewalls\?*) - if resource_visible 'https://api.linode.com/v4/networking/firewalls/101'; then - printf '%s' '{"data":[{"id":101,"tags":["cloud-compose-smoke","linode-wp","gha-run-123456789"]},{"id":901,"tags":["cloud-compose-smoke","linode-wp","gha-run-foreign"]},{"id":902,"tags":["cloud-compose-smoke","linode-isle","gha-run-123456789"]}]}' - else - printf '%s' '{"data":[{"id":901,"tags":["cloud-compose-smoke","linode-wp","gha-run-foreign"]},{"id":902,"tags":["cloud-compose-smoke","linode-isle","gha-run-123456789"]}]}' - fi - ;; - app-linode:*api.linode.com*/linode/instances\?*) - if [[ "$single" == "true" ]]; then - printf '%s' '{"data":[]}' - elif resource_visible 'https://api.linode.com/v4/linode/instances/102'; then - printf '%s' '{"data":[{"id":102,"tags":["cloud-compose-smoke","linode-wp","gha-run-123456789"]},{"id":903,"tags":["cloud-compose-smoke","linode-wp","gha-run-foreign"]}]}' - else - printf '%s' '{"data":[{"id":903,"tags":["cloud-compose-smoke","linode-wp","gha-run-foreign"]}]}' - fi - ;; - app-linode:*api.linode.com*/volumes\?*) - if [[ "$single" == "true" ]]; then - printf '%s' '{"data":[]}' - elif resource_visible 'https://api.linode.com/v4/volumes/103'; then - printf '%s' '{"data":[{"id":103,"tags":["cloud-compose-smoke","linode-wp","gha-run-123456789"]},{"id":904,"tags":["cloud-compose-smoke","linode-isle","gha-run-123456789"]}]}' - else - printf '%s' '{"data":[{"id":904,"tags":["cloud-compose-smoke","linode-isle","gha-run-123456789"]}]}' - fi - ;; - config-management:*api.linode.com*/networking/firewalls\?*) - if resource_visible 'https://api.linode.com/v4/networking/firewalls/201'; then - printf '%s' '{"data":[{"id":201,"tags":["cloud-compose-smoke","config-management-smoke","config-management-ansible-drupal","gha-run-123456789"]},{"id":911,"tags":["cloud-compose-smoke","config-management-smoke","config-management-ansible-drupal","gha-run-foreign"]},{"id":912,"tags":["cloud-compose-smoke","config-management-smoke","config-management-salt-drupal","gha-run-123456789"]}]}' - else - printf '%s' '{"data":[{"id":911,"tags":["cloud-compose-smoke","config-management-smoke","config-management-ansible-drupal","gha-run-foreign"]},{"id":912,"tags":["cloud-compose-smoke","config-management-smoke","config-management-salt-drupal","gha-run-123456789"]}]}' - fi - ;; - config-management:*api.linode.com*/linode/instances\?*) - if [[ "$single" == "true" ]]; then - printf '%s' '{"data":[]}' - elif resource_visible 'https://api.linode.com/v4/linode/instances/202'; then - printf '%s' '{"data":[{"id":202,"tags":["cloud-compose-smoke","config-management-smoke","config-management-ansible-drupal","gha-run-123456789"]},{"id":913,"tags":["cloud-compose-smoke","config-management-smoke","config-management-ansible-drupal","gha-run-foreign"]}]}' - else - printf '%s' '{"data":[{"id":913,"tags":["cloud-compose-smoke","config-management-smoke","config-management-ansible-drupal","gha-run-foreign"]}]}' - fi - ;; - config-management:*api.linode.com*/volumes\?*) - if [[ "$single" == "true" ]]; then - printf '%s' '{"data":[]}' - elif resource_visible 'https://api.linode.com/v4/volumes/203'; then - printf '%s' '{"data":[{"id":203,"tags":["cloud-compose-smoke","config-management-smoke","config-management-ansible-drupal","gha-run-123456789"]},{"id":914,"tags":["cloud-compose-smoke","config-management-smoke","config-management-salt-drupal","gha-run-123456789"]}]}' - else - printf '%s' '{"data":[{"id":914,"tags":["cloud-compose-smoke","config-management-smoke","config-management-salt-drupal","gha-run-123456789"]}]}' - fi - ;; - app-digitalocean:*api.digitalocean.com*/firewalls\?*) - if resource_visible 'https://api.digitalocean.com/v2/firewalls/do-firewall'; then - printf '%s' '{"firewalls":[{"id":"do-firewall","name":"cc-do-isle-123456789-abcdef-cloud-compose"},{"id":"foreign-firewall-run","name":"cc-do-isle-987654321-abcdef-cloud-compose"},{"id":"foreign-firewall-target","name":"cc-do-wp-123456789-abcdef-cloud-compose"}]}' - else - printf '%s' '{"firewalls":[{"id":"foreign-firewall-run","name":"cc-do-isle-987654321-abcdef-cloud-compose"},{"id":"foreign-firewall-target","name":"cc-do-wp-123456789-abcdef-cloud-compose"}]}' - fi - ;; - app-digitalocean:*api.digitalocean.com*/droplets\?*) - if [[ "$single" == "true" ]]; then - printf '%s' '{"droplets":[]}' - elif resource_visible 'https://api.digitalocean.com/v2/droplets/301'; then - printf '%s' '{"droplets":[{"id":301,"tags":["cloud-compose-smoke","digitalocean-isle","gha-run-123456789"]},{"id":931,"tags":["cloud-compose-smoke","digitalocean-isle","gha-run-foreign"]}]}' - else - printf '%s' '{"droplets":[{"id":931,"tags":["cloud-compose-smoke","digitalocean-isle","gha-run-foreign"]}]}' - fi - ;; - app-digitalocean:*api.digitalocean.com*/volumes\?*) - if [[ "$single" == "true" ]]; then - printf '%s' '{"volumes":[]}' - elif resource_visible 'https://api.digitalocean.com/v2/volumes/do-volume'; then - printf '%s' '{"volumes":[{"id":"do-volume","tags":["cloud-compose-smoke","digitalocean-isle","gha-run-123456789"]},{"id":"foreign-volume","tags":["cloud-compose-smoke","digitalocean-wp","gha-run-123456789"]}]}' - else - printf '%s' '{"volumes":[{"id":"foreign-volume","tags":["cloud-compose-smoke","digitalocean-wp","gha-run-123456789"]}]}' - fi - ;; - *) - echo "unexpected fake API URL for $FAKE_API_DRIVER: $url" >&2 - exit 64 - ;; - esac -} - -if [[ "$method" == "DELETE" ]]; then - printf '%s\n' "$url" >>"$FAKE_API_STATE/deletes.log" - count="$(increment_count delete)" - case "$FAKE_API_MODE:$count" in - delete-transient:1) - exit 7 - ;; - delete-transient:2) - respond '{"error":"request timeout"}' 408 - ;; - delete-transient:3) - respond '{"error":"too early"}' 425 - ;; - delete-transient:4) - respond '{"error":"rate limited"}' 429 - ;; - delete-transient:5) - respond '{"error":"server error"}' 500 - ;; - delete-transient:6) - mark_deleted - respond '' 204 - ;; - delete-persistent:*) - respond '{"error":"unavailable"}' 503 - ;; - delete-auth:*) - respond '{"error":"unauthorized"}' 401 - ;; - delete-forbidden:*) - respond '{"error":"forbidden"}' 403 - ;; - delete-permanent:*) - respond '{"error":"bad request"}' 400 - ;; - delete-conflict:1) - respond '{"error":"conflict"}' 409 - ;; - delete-conflict:2) - respond '{"error":"locked"}' 423 - ;; - delete-conflict:3) - mark_deleted - respond '' 204 - ;; - delete-absent-404:*) - mark_deleted - respond '{"error":"not found"}' 404 - ;; - delete-absent-410:*) - mark_deleted - respond '{"error":"gone"}' 410 - ;; - *) - mark_deleted - respond '' 204 - ;; - esac - exit 0 -fi - -[[ "$method" == "GET" ]] || { - echo "unexpected fake curl method: $method" >&2 - exit 64 -} - -case "$url" in - *api.linode.com*/networking/firewalls\?* | *api.digitalocean.com*/firewalls\?*) primary=true ;; - *) primary=false ;; -esac - -if [[ "$primary" == "true" ]]; then - count="$(increment_count primary)" - case "$FAKE_API_MODE:$count" in - get-transient:1) - exit 7 - ;; - get-transient:2) - respond '{"error":"request timeout"}' 408 - exit 0 - ;; - get-transient:3) - respond '{"error":"too early"}' 425 - exit 0 - ;; - get-transient:4) - respond '{"error":"rate limited"}' 429 - exit 0 - ;; - get-transient:5) - respond '{"error":"server error"}' 500 - exit 0 - ;; - get-persistent:*) - respond '{"error":"unavailable"}' 503 - exit 0 - ;; - get-unauthorized:*) - respond '{"error":"unauthorized"}' 401 - exit 0 - ;; - get-forbidden:*) - respond '{"error":"forbidden"}' 403 - exit 0 - ;; - get-client-error:*) - respond '{"error":"bad request"}' 400 - exit 0 - ;; - get-not-found:*) - respond '{"error":"not found"}' 404 - exit 0 - ;; - get-gone:*) - respond '{"error":"gone"}' 410 - exit 0 - ;; - get-conflict:*) - respond '{"error":"conflict"}' 409 - exit 0 - ;; - get-locked:*) - respond '{"error":"locked"}' 423 - exit 0 - ;; - esac -fi - -if [[ "$FAKE_API_MODE" == "malformed" ]]; then - respond '{}' 200 -else - respond "$(normal_get_body)" 200 -fi +printf '%s' "${1:-}" >>"$FAKE_RUNNER_LOG" +shift || true +printf ' %s' "$@" >>"$FAKE_RUNNER_LOG" +printf '\n' >>"$FAKE_RUNNER_LOG" +exit "${FAKE_RUNNER_STATUS:-0}" EOF cat >"$tmp/bin/terraform" <<'EOF' #!/usr/bin/env bash set -euo pipefail - -printf 'terraform' >>"$FAKE_TERRAFORM_LOG" -printf ' %q' "$@" >>"$FAKE_TERRAFORM_LOG" -printf '\n' >>"$FAKE_TERRAFORM_LOG" - -command_name="" -for argument in "$@"; do - case "$argument" in - init | validate | apply | output | destroy) - command_name="$argument" - break - ;; - esac -done - -case "$command_name" in - init | validate) - exit 0 - ;; - apply) - if [[ -n "${FAKE_BODY_SIGNAL:-}" ]]; then - kill -s "$FAKE_BODY_SIGNAL" "$PPID" - exit 0 - fi - exit "${FAKE_BODY_STATUS:-0}" - ;; - output) - case "$FAKE_API_DRIVER" in - app-linode) - printf '%s\n' '{"host":"127.0.0.1","ssh_port":22,"ssh_user":"tester","project_dir":"/home/cloud-compose/app","context_name":"smoke","plugin":"wordpress","environment":"test","site":"smoke","project_name":"smoke","compose_project_name":"smoke","provider":"linode"}' - ;; - config-management) - printf '%s\n' '{"host":"127.0.0.1","method":"ansible","cloud_compose_name":"smoke","app":"drupal","environment":"test","project_dir":"/home/cloud-compose/app"}' - ;; - *) - echo "unexpected terraform output driver: $FAKE_API_DRIVER" >&2 - exit 64 - ;; - esac - ;; - destroy) - if [[ "${FAKE_DESTROY_MODE:-success}" == "failure" ]]; then - exit 42 - fi - ;; - *) - echo "unexpected fake terraform invocation: $*" >&2 - exit 64 - ;; -esac +printf 'terraform %s\n' "$*" >>"$FAKE_TERRAFORM_LOG" +exit "${FAKE_TERRAFORM_STATUS:-0}" EOF cat >"$tmp/bin/ssh-keygen" <<'EOF' #!/usr/bin/env bash set -euo pipefail - path="" while [[ "$#" -gt 0 ]]; do if [[ "$1" == "-f" ]]; then @@ -415,99 +42,47 @@ while [[ "$#" -gt 0 ]]; do shift done [[ -n "$path" ]] || exit 64 -printf '%s\n' 'fake-private-key' >"$path" -printf '%s\n' 'ssh-ed25519 fake-public-key cloud-compose-smoke' >"${path}.pub" +printf 'fake-private-key\n' >"$path" +printf 'ssh-ed25519 fake-public-key cloud-compose-smoke\n' >"${path}.pub" EOF -cat >"$tmp/bin/ssh-keyscan" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' '127.0.0.1 ssh-ed25519 fake-host-key' -EOF - -cat >"$tmp/bin/ssh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -case "$*" in - *cloud-compose-bootstrap-complete*) printf 'complete\n' ;; - *cloud-init\ status*) printf 'cloud-init not installed\n' ;; -esac -EOF - -cat >"$tmp/bin/sitectl" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -exit 0 -EOF - -cat >"$tmp/bin/docker" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -cat >/dev/null -EOF - -cat >"$tmp/bin/tar" <<'EOF' +# Any curl execution is a contract failure: provider HTTP behavior belongs to +# the Go httptest suite, and these wrappers must invoke only the fake runner. +cat >"$tmp/bin/curl" <<'EOF' #!/usr/bin/env bash -set -euo pipefail -printf 'fake archive' +echo "unexpected network client invocation: curl $*" >&2 +exit 97 EOF chmod +x "$tmp/bin/"* -driver_command() { - local driver="$1" operation="$2" - - case "$operation:$driver" in - sweep:app-linode) printf '%s\0%s\0%s\0' bash "$repo_root/ci/cloud-smoke.sh" sweep-linode-wp ;; - sweep:app-digitalocean) printf '%s\0%s\0%s\0' bash "$repo_root/ci/cloud-smoke.sh" sweep-digitalocean-isle ;; - sweep:config-management) printf '%s\0%s\0%s\0' bash "$repo_root/ci/config-management-cloud-smoke.sh" sweep-ansible-drupal ;; - flow:app-linode) printf '%s\0%s\0%s\0' bash "$repo_root/ci/cloud-smoke.sh" linode-wp ;; - flow:config-management) printf '%s\0%s\0%s\0' bash "$repo_root/ci/config-management-cloud-smoke.sh" ansible-drupal ;; - destroy:app-linode) printf '%s\0%s\0%s\0' bash "$repo_root/ci/cloud-smoke.sh" destroy-linode-wp ;; - destroy:config-management) printf '%s\0%s\0%s\0' bash "$repo_root/ci/config-management-cloud-smoke.sh" destroy-ansible-drupal ;; - *) fail "unknown driver operation ${operation}:${driver}" ;; - esac -} - -run_case() { - local operation="$1" driver="$2" mode="$3" - local destroy_mode="${4:-success}" body_signal="${5:-}" body_status="${6:-0}" - local output state status - local -a command +run_wrapper() { + local name="$1" driver="$2" operation="$3" + local terraform_status="${4:-0}" runner_status="${5:-0}" + local state="$tmp/work/$name" - state="$tmp/state/${operation}-${driver}-${mode}-${destroy_mode}-${body_signal:-none}-${body_status}" mkdir -p "$state" - case "$operation:$driver" in - destroy:app-linode) mkdir -p "$state/work/linode-wp/.terraform" ;; - destroy:config-management) mkdir -p "$state/work/config-management-linode-ansible-drupal/.terraform" ;; - esac - output="$state/output.log" - mapfile -d '' -t command < <(driver_command "$driver" "$operation") - + : >"$state/runner.log" + : >"$state/terraform.log" if PATH="$tmp/bin:$PATH" \ + CLOUD_COMPOSE_CI_BIN="$tmp/bin/cloud-compose-ci" \ CLOUD_COMPOSE_SMOKE_AUTO_APPROVE=true \ - CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT=10 \ CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT=10 \ CLOUD_COMPOSE_SMOKE_RUN_ID=123456789 \ - CLOUD_COMPOSE_SMOKE_WORKDIR="$state/work" \ - DIGITALOCEAN_TOKEN=test-token \ - FAKE_API_DRIVER="$driver" \ - FAKE_API_LOG="$state/api.log" \ - FAKE_API_MODE="$mode" \ - FAKE_API_STATE="$state" \ - FAKE_BODY_SIGNAL="$body_signal" \ - FAKE_BODY_STATUS="$body_status" \ - FAKE_DESTROY_MODE="$destroy_mode" \ + CLOUD_COMPOSE_SMOKE_WORKDIR="$state/smoke" \ + CLOUD_COMPOSE_SOURCE_SHA256=0000000000000000000000000000000000000000000000000000000000000000 \ + DIGITALOCEAN_TOKEN=do-wrapper-secret \ + FAKE_RUNNER_LOG="$state/runner.log" \ + FAKE_RUNNER_STATUS="$runner_status" \ FAKE_TERRAFORM_LOG="$state/terraform.log" \ + FAKE_TERRAFORM_STATUS="$terraform_status" \ GITHUB_ACTIONS=true \ - LINODE_TOKEN=test-token \ - "${command[@]}" >"$output" 2>&1; then - status=0 + LINODE_TOKEN=linode-wrapper-secret \ + bash "$repo_root/$driver" "$operation" >"$state/output.log" 2>&1; then + printf '0\n' >"$state/status" else - status=$? + printf '%s\n' "$?" >"$state/status" fi - printf '%s\n' "$status" >"$state/status" printf '%s\n' "$state" } @@ -515,184 +90,47 @@ assert_status() { local state="$1" want="$2" got got="$(<"$state/status")" - if [[ "$want" == "success" && "$got" -ne 0 ]]; then - cat "$state/output.log" >&2 - fail "$(basename "$state") failed with status $got" - fi - if [[ "$want" == "failure" && "$got" -eq 0 ]]; then - cat "$state/output.log" >&2 - fail "$(basename "$state") unexpectedly succeeded" - fi - if [[ "$want" =~ ^[0-9]+$ && "$got" -ne "$want" ]]; then + if [[ "$got" -ne "$want" ]]; then cat "$state/output.log" >&2 fail "$(basename "$state") returned $got, want $want" fi } -count_file() { - local state="$1" key="$2" - - if [[ -f "$state/$key.count" ]]; then - cat "$state/$key.count" - else - printf '0\n' - fi -} - -owned_delete_urls() { - case "$1" in - app-linode) - printf '%s\n' \ - 'https://api.linode.com/v4/networking/firewalls/101' \ - 'https://api.linode.com/v4/linode/instances/102' \ - 'https://api.linode.com/v4/volumes/103' - ;; - config-management) - printf '%s\n' \ - 'https://api.linode.com/v4/networking/firewalls/201' \ - 'https://api.linode.com/v4/linode/instances/202' \ - 'https://api.linode.com/v4/volumes/203' - ;; - app-digitalocean) - printf '%s\n' \ - 'https://api.digitalocean.com/v2/firewalls/do-firewall' \ - 'https://api.digitalocean.com/v2/droplets/301' \ - 'https://api.digitalocean.com/v2/volumes/do-volume' - ;; - esac -} - -assert_owned_only() { - local state="$1" driver="$2" - local actual expected +assert_runner_call() { + local state="$1" want="$2" got - expected="$(owned_delete_urls "$driver")" - if [[ -f "$state/deletes.log" ]]; then - actual="$(<"$state/deletes.log")" - else - actual="" - fi - [[ "$actual" == "$expected" ]] || { - printf 'expected deletes:\n%s\nactual deletes:\n%s\n' "$expected" "$actual" >&2 - fail "$driver did not delete exactly its run-owned resources" + got="$(<"$state/runner.log")" + [[ "$got" == "$want" ]] || { + printf 'runner call:\n%s\nwant:\n%s\n' "$got" "$want" >&2 + fail "$(basename "$state") did not preserve the compiled-runner boundary" } - if grep -Eq 'foreign|/9[0-9][0-9]$' "$state/deletes.log"; then - fail "$driver selected a foreign resource" + if grep -Fq 'wrapper-secret' "$state/runner.log"; then + fail "$(basename "$state") passed a provider token as a process argument" fi } -assert_all_linode_queries() { - local state="$1" - - for path in networking/firewalls linode/instances volumes; do - grep -Fq "$path" "$state/api.log" || fail "$(basename "$state") skipped the $path cleanup pipeline" - done -} - -sleep_count() { - local state="$1" seconds="$2" count - - count="$(grep -c "^sleep ${seconds}$" "$state/api.log" 2>/dev/null || true)" - printf '%s\n' "$count" -} - -for driver in app-linode config-management; do - state="$(run_case sweep "$driver" get-transient)" - assert_status "$state" success - [[ "$(count_file "$state" primary)" == "7" ]] || fail "$driver did not recover on GET attempt six and verify cleanup" - assert_owned_only "$state" "$driver" - mapfile -t delays < <(awk '$1 == "sleep" {print $2}' "$state/api.log" | sed -n '1,5p') - [[ "${delays[*]}" == "2 4 8 16 30" ]] || fail "$driver used unexpected GET backoff: ${delays[*]}" - - state="$(run_case sweep "$driver" get-persistent)" - assert_status "$state" failure - [[ "$(count_file "$state" primary)" == "6" ]] || fail "$driver did not stop after six GET attempts" - - for mode in get-unauthorized get-forbidden get-client-error get-not-found get-gone get-conflict get-locked; do - state="$(run_case sweep "$driver" "$mode")" - assert_status "$state" failure - [[ "$(count_file "$state" primary)" == "1" ]] || fail "$driver retried fail-fast GET mode $mode" - done - - state="$(run_case sweep "$driver" malformed)" - assert_status "$state" failure - assert_all_linode_queries "$state" - [[ "$(count_file "$state" delete)" == "0" ]] || fail "$driver deleted resources from malformed API data" -done - -state="$(run_case sweep app-digitalocean get-transient)" -assert_status "$state" success -assert_owned_only "$state" app-digitalocean -state="$(run_case sweep app-digitalocean malformed)" -assert_status "$state" failure +state="$(run_wrapper sweep-linode ci/cloud-smoke.sh sweep-linode-wp)" +assert_status "$state" 0 +assert_runner_call "$state" 'linode sweep --scope application --target linode-wp --run-id 123456789' -for driver in app-linode config-management app-digitalocean; do - state="$(run_case sweep "$driver" residual-delayed)" - assert_status "$state" success - assert_owned_only "$state" "$driver" - [[ "$(count_file "$state" primary)" == "4" ]] || fail "$driver did not wait for delayed residual visibility to clear" - [[ "$(sleep_count "$state" 10)" == "3" ]] || fail "$driver used unexpected delayed-residual backoff" - - state="$(run_case sweep "$driver" residual-persistent)" - assert_status "$state" failure - assert_owned_only "$state" "$driver" - [[ "$(count_file "$state" primary)" == "7" ]] || fail "$driver did not bound persistent residual verification at six attempts" - [[ "$(sleep_count "$state" 10)" == "6" ]] || fail "$driver used unexpected persistent-residual backoff" -done - -for driver in app-linode config-management; do - while read -r mode want_status want_attempts retry_sleeps; do - state="$(run_case sweep "$driver" "$mode")" - assert_status "$state" "$want_status" - [[ "$(count_file "$state" delete)" == "$want_attempts" ]] || \ - fail "$driver $mode used $(count_file "$state" delete) DELETE attempts, want $want_attempts" - # Each Linode sweep has one dependency-order pause between instances and volumes. - [[ "$(sleep_count "$state" 10)" == "$((retry_sleeps + 1))" ]] || \ - fail "$driver $mode used $(sleep_count "$state" 10) ten-second sleeps, want $((retry_sleeps + 1))" - assert_all_linode_queries "$state" - done <<'EOF' -delete-transient success 6 5 -delete-persistent failure 12 11 -delete-auth failure 1 0 -delete-forbidden failure 1 0 -delete-permanent failure 1 0 -delete-conflict success 3 2 -delete-absent-404 success 1 0 -delete-absent-410 success 1 0 -EOF -done +state="$(run_wrapper sweep-digitalocean ci/cloud-smoke.sh sweep-digitalocean-isle)" +assert_status "$state" 0 +assert_runner_call "$state" 'digitalocean sweep --scope application --target digitalocean-isle --run-id 123456789' -for driver in app-linode config-management; do - state="$(run_case flow "$driver" owned failure)" - assert_status "$state" success - assert_owned_only "$state" "$driver" - grep -Fq ' destroy ' "$state/terraform.log" || fail "$driver EXIT cleanup did not run Terraform destroy" +state="$(run_wrapper sweep-config-management ci/config-management-cloud-smoke.sh sweep-ansible-drupal)" +assert_status "$state" 0 +assert_runner_call "$state" 'linode sweep --scope config-management --target ansible-drupal --run-id 123456789' - state="$(run_case flow "$driver" delete-persistent failure)" - assert_status "$state" 42 - [[ "$(count_file "$state" delete)" == "12" ]] || fail "$driver EXIT fallback did not exhaust DELETE retries" +mkdir -p "$tmp/work/destroy-linode/smoke/linode-wp/.terraform" +state="$(run_wrapper destroy-linode ci/cloud-smoke.sh destroy-linode-wp 42 0)" +assert_status "$state" 0 +assert_runner_call "$state" 'linode sweep --scope application --target linode-wp --run-id 123456789' +grep -Fq ' destroy ' "$state/terraform.log" || fail "destroy-linode skipped Terraform destroy" - state="$(run_case flow "$driver" delete-persistent failure '' 37)" - assert_status "$state" 37 - - while read -r signal want_status; do - state="$(run_case flow "$driver" owned success "$signal")" - assert_status "$state" "$want_status" - grep -Fq ' destroy ' "$state/terraform.log" || fail "$driver $signal path skipped EXIT destroy" - done <<'EOF' -HUP 129 -INT 130 -TERM 143 -EOF -done - -for driver in app-linode config-management; do - state="$(run_case destroy "$driver" owned failure)" - assert_status "$state" success - assert_owned_only "$state" "$driver" - - state="$(run_case destroy "$driver" residual-persistent failure)" - assert_status "$state" 42 -done +mkdir -p "$tmp/work/destroy-config/smoke/config-management-linode-ansible-drupal/.terraform" +state="$(run_wrapper destroy-config ci/config-management-cloud-smoke.sh destroy-ansible-drupal 0 43)" +assert_status "$state" 43 +assert_runner_call "$state" 'linode sweep --scope config-management --target ansible-drupal --run-id 123456789' +grep -Fq ' destroy ' "$state/terraform.log" || fail "destroy-config skipped Terraform destroy" -echo "Hosted cleanup retry contracts passed" +echo "Hosted cleanup lifecycle contracts passed" diff --git a/ci/source-trust-contract.sh b/ci/source-trust-contract.sh index 8dede1e..a4e426a 100644 --- a/ci/source-trust-contract.sh +++ b/ci/source-trust-contract.sh @@ -48,7 +48,7 @@ printf 'one\n' > "$source_repo/version.txt" git -C "$source_repo" add version.txt git -C "$source_repo" commit -m one >/dev/null commit_one="$(git -C "$source_repo" rev-parse HEAD)" -git -C "$source_repo" tag release-one +git -C "$source_repo" tag -a -m 'release one' release-one printf 'two\n' > "$source_repo/version.txt" git -C "$source_repo" commit -am two >/dev/null git -C "$source_repo" remote add origin "$remote" diff --git a/ci/terraform-validate.sh b/ci/terraform-validate.sh index 96f52bf..977e2d1 100644 --- a/ci/terraform-validate.sh +++ b/ci/terraform-validate.sh @@ -25,9 +25,45 @@ safe_name() { printf '%s\n' "$value" } +validate_public_provider_graph() { + local root="$1" data_dir="$2" rel="$3" + local expected_sources provider_graph actual_sources + + case "$rel" in + . | providers/gcp) + expected_sources=$'hashicorp/cloudinit\nhashicorp/google\nhashicorp/time' + ;; + providers/do) + expected_sources=$'digitalocean/digitalocean\nhashicorp/cloudinit' + ;; + providers/linode) + expected_sources=$'hashicorp/cloudinit\nlinode/linode' + ;; + *) + return 0 + ;; + esac + + if ! provider_graph="$(TF_DATA_DIR="$data_dir" terraform -chdir="$root" providers)"; then + echo "Failed to read Terraform provider graph in ${rel}" >&2 + return 1 + fi + actual_sources="$({ + sed -n 's/.*provider\[registry\.terraform\.io\/\([^]]*\)\].*/\1/p' <<<"$provider_graph" + } | sort -u)" + if [[ "$actual_sources" != "$expected_sources" ]]; then + echo "Unexpected Terraform provider graph in ${rel}" >&2 + echo "Expected:" >&2 + printf '%s\n' "$expected_sources" >&2 + echo "Actual:" >&2 + printf '%s\n' "$actual_sources" >&2 + return 1 + fi +} + validate_root() { local root="$1" data_root="$2" - local rel data_dir lockfile created_lock init_status validate_status test_status + local rel data_dir lockfile created_lock init_status validate_status provider_status test_status local -a init_args rel="${root#"$repo_root"/}" @@ -67,8 +103,13 @@ validate_root() { TF_DATA_DIR="$data_dir" terraform -chdir="$root" validate -no-color || validate_status=$? fi + provider_status=0 + if [[ "$init_status" -eq 0 && "$validate_status" -eq 0 ]]; then + validate_public_provider_graph "$root" "$data_dir" "$rel" || provider_status=$? + fi + test_status=0 - if [[ "$init_status" -eq 0 && "$validate_status" -eq 0 ]] && find "$root" -maxdepth 1 -name '*.tftest.hcl' -print -quit | grep -q .; then + if [[ "$init_status" -eq 0 && "$validate_status" -eq 0 && "$provider_status" -eq 0 ]] && find "$root" -maxdepth 1 -name '*.tftest.hcl' -print -quit | grep -q .; then TF_DATA_DIR="$data_dir" terraform -chdir="$root" test -no-color || test_status=$? fi @@ -82,6 +123,9 @@ validate_root() { if [[ "$validate_status" -ne 0 ]]; then return "$validate_status" fi + if [[ "$provider_status" -ne 0 ]]; then + return "$provider_status" + fi return "$test_status" } diff --git a/cmd/cloud-compose-ci/main.go b/cmd/cloud-compose-ci/main.go index 4cb1dcd..f452877 100644 --- a/cmd/cloud-compose-ci/main.go +++ b/cmd/cloud-compose-ci/main.go @@ -13,16 +13,23 @@ import ( "syscall" "github.com/libops/cloud-compose/internal/gcpcleanup" + "github.com/libops/cloud-compose/internal/providercleanup" "github.com/libops/cloud-compose/internal/runnamespace" ) +type providerSweeper interface { + Sweep(context.Context, providercleanup.Config) error +} + +type providerRunnerFactory func(providercleanup.Provider, string, *slog.Logger) (providerSweeper, error) + func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() redactor := gcpcleanup.NewEnvironmentRedactor(os.Environ()) commander := gcpcleanup.ExecCommander{Stderr: os.Stderr, Redactor: redactor} - os.Exit(run(ctx, os.Args[1:], os.Getenv, os.Stdout, os.Stderr, commander, redactor)) + os.Exit(run(ctx, os.Args[1:], os.Getenv, os.Stdout, os.Stderr, commander, redactor, newProviderRunner)) } func run( @@ -33,23 +40,158 @@ func run( stderr io.Writer, commander gcpcleanup.Commander, redactor *gcpcleanup.Redactor, + providerFactory providerRunnerFactory, ) int { - if len(args) < 2 || args[0] != "gcp" { + if len(args) < 2 { printUsage(stderr) return 2 } - switch args[1] { - case "namespace": - return runGCPNamespace(args[2:], stdout, stderr) - case "sweep": - return runGCPSweep(ctx, args[2:], getenv, stderr, commander, redactor) + switch args[0] { + case "run": + if args[1] != "validate" { + printUsage(stderr) + return 2 + } + return runIDValidate(args[2:], stderr) + case "gcp": + switch args[1] { + case "namespace": + return runGCPNamespace(args[2:], stdout, stderr) + case "sweep": + return runGCPSweep(ctx, args[2:], getenv, stderr, commander, redactor) + default: + printUsage(stderr) + return 2 + } + case string(providercleanup.DigitalOcean), string(providercleanup.Linode): + if args[1] != "sweep" { + printUsage(stderr) + return 2 + } + return runProviderSweep(ctx, args[0], args[2:], getenv, stderr, redactor, providerFactory) default: printUsage(stderr) return 2 } } +func runIDValidate(args []string, stderr io.Writer) int { + flags := flag.NewFlagSet("cloud-compose-ci run validate", flag.ContinueOnError) + flags.SetOutput(stderr) + runID := flags.String("run-id", "", "canonical decimal GitHub Actions run ID") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(stderr, "cloud-compose-ci run validate does not accept positional arguments") + return 2 + } + if _, err := runnamespace.Encode(*runID); err != nil { + fmt.Fprintf(stderr, "invalid --run-id: %s\n", err) + return 2 + } + return 0 +} + +func newProviderRunner(provider providercleanup.Provider, token string, logger *slog.Logger) (providerSweeper, error) { + client, err := providercleanup.NewClient(provider, token) + if err != nil { + return nil, err + } + return providercleanup.Runner{Client: client, Logger: logger}, nil +} + +func runProviderSweep( + ctx context.Context, + providerName string, + args []string, + getenv func(string) string, + stderr io.Writer, + redactor *gcpcleanup.Redactor, + factory providerRunnerFactory, +) int { + provider, err := providercleanup.ParseProvider(providerName) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + flags := flag.NewFlagSet("cloud-compose-ci "+providerName+" sweep", flag.ContinueOnError) + flags.SetOutput(stderr) + target := flags.String("target", "", "cloud-compose smoke target") + scopeName := flags.String("scope", string(providercleanup.ApplicationScope), "resource scope: application or config-management") + runID := flags.String("run-id", getenv("CLOUD_COMPOSE_SMOKE_RUN_ID"), "originating canonical GitHub Actions run ID") + allRuns := flags.Bool("all-runs", false, "sweep every run for the target; intended only for explicit orphan cleanup") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintf(stderr, "cloud-compose-ci %s sweep does not accept positional arguments\n", provider) + return 2 + } + if strings.TrimSpace(*target) == "" { + fmt.Fprintln(stderr, "--target is required") + return 2 + } + scope, err := providercleanup.ParseScope(*scopeName) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + + runIDExplicit := false + flags.Visit(func(parsed *flag.Flag) { + if parsed.Name == "run-id" { + runIDExplicit = true + } + }) + if *allRuns && runIDExplicit { + fmt.Fprintln(stderr, "--run-id and --all-runs are mutually exclusive") + return 2 + } + effectiveRunID := *runID + if *allRuns { + effectiveRunID = "" + } + if effectiveRunID == "" && !*allRuns { + fmt.Fprintln(stderr, "--run-id or CLOUD_COMPOSE_SMOKE_RUN_ID is required unless --all-runs is set") + return 2 + } + + tokenName, _ := providercleanup.TokenEnvironment(provider) + token := getenv(tokenName) + if provider == providercleanup.DigitalOcean && token == "" { + token = getenv("DIGITALOCEAN_API_TOKEN") + } + if token == "" { + fmt.Fprintf(stderr, "%s is required\n", tokenName) + return 2 + } + if factory == nil { + fmt.Fprintln(stderr, "provider cleanup runner is unavailable") + return 1 + } + + logger := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + runner, err := factory(provider, token, logger) + if err != nil { + fmt.Fprintf(stderr, "%s cleanup setup failed: %s\n", provider, redactor.String(err.Error())) + return 1 + } + err = runner.Sweep(ctx, providercleanup.Config{ + Provider: provider, + Scope: scope, + Target: *target, + RunID: effectiveRunID, + AllowAllRuns: *allRuns, + }) + if err != nil { + fmt.Fprintf(stderr, "%s cleanup failed: %s\n", provider, redactor.String(err.Error())) + return 1 + } + return 0 +} + func runGCPNamespace(args []string, stdout, stderr io.Writer) int { flags := flag.NewFlagSet("cloud-compose-ci gcp namespace", flag.ContinueOnError) flags.SetOutput(stderr) @@ -150,6 +292,9 @@ func environmentDefault(getenv func(string) string, name, fallback string) strin func printUsage(writer io.Writer) { fmt.Fprintln(writer, "Usage:") + fmt.Fprintln(writer, " cloud-compose-ci run validate --run-id RUN_ID") + fmt.Fprintln(writer, " cloud-compose-ci digitalocean sweep --target digitalocean-TEMPLATE (--run-id RUN_ID | --all-runs)") + fmt.Fprintln(writer, " cloud-compose-ci linode sweep [--scope application|config-management] --target TARGET (--run-id RUN_ID | --all-runs)") fmt.Fprintln(writer, " cloud-compose-ci gcp namespace --run-id RUN_ID") fmt.Fprintln(writer, " cloud-compose-ci gcp sweep [--project PROJECT] [--region REGION] [--target gcp-wp] (--run-id RUN_ID | --all-runs)") } diff --git a/cmd/cloud-compose-ci/main_test.go b/cmd/cloud-compose-ci/main_test.go index caabb00..b76b2d0 100644 --- a/cmd/cloud-compose-ci/main_test.go +++ b/cmd/cloud-compose-ci/main_test.go @@ -4,11 +4,14 @@ import ( "bytes" "context" "errors" + "fmt" + "log/slog" "slices" "strings" "testing" "github.com/libops/cloud-compose/internal/gcpcleanup" + "github.com/libops/cloud-compose/internal/providercleanup" ) type emptyGCloud struct { @@ -39,7 +42,7 @@ func TestRunGCPSweepUsesEnvironmentOwnership(t *testing.T) { } var stdout bytes.Buffer var stderr bytes.Buffer - status := run(context.Background(), []string{"gcp", "sweep"}, environmentGetter(environment), &stdout, &stderr, command, gcpcleanup.NewRedactor()) + status := run(context.Background(), []string{"gcp", "sweep"}, environmentGetter(environment), &stdout, &stderr, command, gcpcleanup.NewRedactor(), nil) if status != 0 { t.Fatalf("run() status = %d, stderr = %s", status, stderr.String()) } @@ -73,6 +76,7 @@ func TestRunGCPSweepRequiresRunID(t *testing.T) { &stderr, command, gcpcleanup.NewRedactor(), + nil, ) if status != 2 { t.Errorf("run() status = %d; want 2", status) @@ -98,6 +102,7 @@ func TestRunGCPSweepAllowsExplicitOrphanSweep(t *testing.T) { &stderr, command, gcpcleanup.NewRedactor(), + nil, ) if status != 0 { t.Fatalf("run() status = %d, stderr = %s", status, stderr.String()) @@ -120,6 +125,7 @@ func TestRunGCPSweepAllRunsOverridesInheritedRunID(t *testing.T) { &stderr, command, gcpcleanup.NewRedactor(), + nil, ) if status != 0 { t.Fatalf("run() status = %d, stderr = %s", status, stderr.String()) @@ -144,6 +150,7 @@ func TestRunGCPSweepRejectsExplicitRunIDWithAllRuns(t *testing.T) { &stderr, command, gcpcleanup.NewRedactor(), + nil, ) if status != 2 { t.Errorf("run() status = %d; want 2", status) @@ -170,6 +177,7 @@ func TestRunGCPNamespaceWritesOnlyNamespaceToStdout(t *testing.T) { &stderr, command, gcpcleanup.NewRedactor(), + nil, ) if status != 0 { t.Fatalf("run() status = %d, stderr = %s", status, stderr.String()) @@ -201,6 +209,7 @@ func TestRunGCPNamespaceRejectsInvalidRunIDs(t *testing.T) { &stderr, &emptyGCloud{}, gcpcleanup.NewRedactor(), + nil, ) if status != 2 { t.Errorf("run() status = %d; want 2", status) @@ -226,6 +235,7 @@ func TestRunGCPNamespaceReportsOutputFailure(t *testing.T) { &stderr, &emptyGCloud{}, gcpcleanup.NewRedactor(), + nil, ) if status != 1 { t.Errorf("run() status = %d; want 1", status) @@ -235,6 +245,199 @@ func TestRunGCPNamespaceReportsOutputFailure(t *testing.T) { } } +func TestRunIDValidateRequiresCanonicalRunIDWithoutOutput(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + runID string + wantStatus int + }{ + {name: "canonical", runID: "123456789", wantStatus: 0}, + {name: "empty", runID: "", wantStatus: 2}, + {name: "noncanonical", runID: "0123", wantStatus: 2}, + {name: "nonnumeric", runID: "run-123", wantStatus: 2}, + } { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var stdout bytes.Buffer + var stderr bytes.Buffer + status := run( + context.Background(), + []string{"run", "validate", "--run-id", test.runID}, + environmentGetter(nil), + &stdout, + &stderr, + &emptyGCloud{}, + gcpcleanup.NewRedactor(), + nil, + ) + if status != test.wantStatus { + t.Fatalf("run() status = %d; want %d, stderr = %s", status, test.wantStatus, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q; want empty", stdout.String()) + } + }) + } +} + +type recordingProviderSweeper struct { + config providercleanup.Config + err error +} + +func (s *recordingProviderSweeper) Sweep(_ context.Context, config providercleanup.Config) error { + s.config = config + return s.err +} + +func TestRunProviderSweepUsesEnvironmentTokenAndExactOwnership(t *testing.T) { + t.Parallel() + const token = "digitalocean-secret" + environment := map[string]string{ + "CLOUD_COMPOSE_SMOKE_RUN_ID": "123456789", + "DIGITALOCEAN_TOKEN": token, + } + sweeper := &recordingProviderSweeper{} + var factoryProvider providercleanup.Provider + var factoryToken string + factory := func(provider providercleanup.Provider, suppliedToken string, _ *slog.Logger) (providerSweeper, error) { + factoryProvider = provider + factoryToken = suppliedToken + return sweeper, nil + } + var stdout bytes.Buffer + var stderr bytes.Buffer + status := run( + context.Background(), + []string{"digitalocean", "sweep", "--target", "digitalocean-isle"}, + environmentGetter(environment), + &stdout, + &stderr, + &emptyGCloud{}, + gcpcleanup.NewRedactor(token), + factory, + ) + if status != 0 { + t.Fatalf("run() status = %d, stderr = %s", status, stderr.String()) + } + if factoryProvider != providercleanup.DigitalOcean || factoryToken != token { + t.Fatalf("factory input = (%q, %q)", factoryProvider, factoryToken) + } + if sweeper.config.Provider != providercleanup.DigitalOcean || + sweeper.config.Scope != providercleanup.ApplicationScope || + sweeper.config.Target != "digitalocean-isle" || + sweeper.config.RunID != "123456789" || + sweeper.config.AllowAllRuns { + t.Fatalf("sweep config = %+v", sweeper.config) + } + if strings.Contains(stderr.String(), token) { + t.Fatalf("stderr leaked token: %s", stderr.String()) + } +} + +func TestRunProviderSweepSupportsConfigManagementAndExplicitAllRuns(t *testing.T) { + t.Parallel() + environment := map[string]string{ + "CLOUD_COMPOSE_SMOKE_RUN_ID": "123456789", + "LINODE_TOKEN": "linode-secret", + } + sweeper := &recordingProviderSweeper{} + factory := func(_ providercleanup.Provider, _ string, _ *slog.Logger) (providerSweeper, error) { + return sweeper, nil + } + var stdout bytes.Buffer + var stderr bytes.Buffer + status := run( + context.Background(), + []string{"linode", "sweep", "--scope", "config-management", "--target", "ansible-drupal", "--all-runs"}, + environmentGetter(environment), + &stdout, + &stderr, + &emptyGCloud{}, + gcpcleanup.NewRedactor("linode-secret"), + factory, + ) + if status != 0 { + t.Fatalf("run() status = %d, stderr = %s", status, stderr.String()) + } + if sweeper.config.Scope != providercleanup.ConfigManagementScope || sweeper.config.RunID != "" || !sweeper.config.AllowAllRuns { + t.Fatalf("sweep config = %+v", sweeper.config) + } +} + +func TestRunProviderSweepFailsBeforeAPIAccessWithoutOwnershipOrToken(t *testing.T) { + t.Parallel() + tests := []struct { + name string + environment map[string]string + want string + }{ + {name: "missing ownership", environment: map[string]string{"LINODE_TOKEN": "secret"}, want: "run-id"}, + {name: "missing token", environment: map[string]string{"CLOUD_COMPOSE_SMOKE_RUN_ID": "123456789"}, want: "LINODE_TOKEN"}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + called := false + factory := func(_ providercleanup.Provider, _ string, _ *slog.Logger) (providerSweeper, error) { + called = true + return &recordingProviderSweeper{}, nil + } + var stdout bytes.Buffer + var stderr bytes.Buffer + status := run( + context.Background(), + []string{"linode", "sweep", "--target", "linode-wp"}, + environmentGetter(test.environment), + &stdout, + &stderr, + &emptyGCloud{}, + gcpcleanup.NewRedactor("secret"), + factory, + ) + if status != 2 { + t.Fatalf("run() status = %d; want 2", status) + } + if called { + t.Fatal("run() constructed provider client before validating inputs") + } + if !strings.Contains(stderr.String(), test.want) { + t.Fatalf("stderr = %q; want %q", stderr.String(), test.want) + } + }) + } +} + +func TestRunProviderSweepRedactsDefensiveErrorPath(t *testing.T) { + t.Parallel() + const token = "linode-secret" + sweeper := &recordingProviderSweeper{err: fmt.Errorf("provider echoed %s", token)} + factory := func(_ providercleanup.Provider, _ string, _ *slog.Logger) (providerSweeper, error) { + return sweeper, nil + } + var stdout bytes.Buffer + var stderr bytes.Buffer + status := run( + context.Background(), + []string{"linode", "sweep", "--target", "linode-wp", "--run-id", "123456789"}, + environmentGetter(map[string]string{"LINODE_TOKEN": token}), + &stdout, + &stderr, + &emptyGCloud{}, + gcpcleanup.NewRedactor(token), + factory, + ) + if status != 1 { + t.Fatalf("run() status = %d; want 1", status) + } + if strings.Contains(stderr.String(), token) || !strings.Contains(stderr.String(), "[REDACTED]") { + t.Fatalf("stderr was not redacted: %s", stderr.String()) + } +} + func firstCommandCall(t testing.TB, calls [][]string, prefix ...string) []string { t.Helper() for _, call := range calls { diff --git a/docs/examples.md b/docs/examples.md index 3b4d135..fdcafbc 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -129,12 +129,12 @@ healthcheck settings: runtime = { sitectl = { package_versions = { - sitectl = "v0.39.0" - sitectl-wp = "v0.5.0" + sitectl = "v0.40.0" + sitectl-wp = "v0.6.0" } } compose = { - branch = "main" + branch = "v1.0.0" ingress = { letsencrypt = true bot_mitigation = true diff --git a/docs/managed-runtime.md b/docs/managed-runtime.md index 06c42b1..3044422 100644 --- a/docs/managed-runtime.md +++ b/docs/managed-runtime.md @@ -45,9 +45,9 @@ runtime = { "sitectl-isle", ] package_versions = { - sitectl = "v0.39.0" - sitectl-drupal = "v0.11.0" - sitectl-isle = "v0.18.0" + sitectl = "v0.40.0" + sitectl-drupal = "v0.12.0" + sitectl-isle = "v0.19.0" } plugin = "isle" } diff --git a/docs/non-gcp-providers.md b/docs/non-gcp-providers.md index 4dfaefc..ce271d3 100644 --- a/docs/non-gcp-providers.md +++ b/docs/non-gcp-providers.md @@ -1,13 +1,66 @@ # Provider Entrypoints And On-Prem Provider-specific Terraform entrypoint modules keep downstream consumers from -loading unused cloud providers. Use these paths instead of the root module when -the caller already knows the target cloud: +loading unused cloud providers. New callers should always select one of these +paths: - `providers/gcp` - `providers/do` - `providers/linode` +The repository root remains a GCP-only compatibility entrypoint for existing +GCP state. It intentionally has no DigitalOcean or Linode provider dependency. +DigitalOcean and Linode callers that previously selected `cloud_provider` on +the root module must move to their provider-specific source path as a separately +reviewed state migration. Terraform cannot conditionally load a statically +declared child module's provider, even when that module has `count = 0`. + +## Migrating A 1.x Root Deployment + +GCP callers can keep the root source path and module block name unchanged. The +root retains its historical `module.gcp[0]` address and now loads only GCP- +related providers. A GCP caller can move to `providers/gcp` later, but that is a +separate state refactor and is not required to remove DigitalOcean or Linode. + +DigitalOcean and Linode callers must change the module source at 2.0.0 while +keeping the caller's module block name unchanged. For example: + +```hcl +module "site" { + source = "github.com/libops/cloud-compose//providers/do?ref=2.0.0" + + # Keep the existing name, template, digitalocean, and runtime values. +} + +moved { + from = module.site.module.digitalocean[0] + to = module.site.module.digitalocean +} +``` + +The Linode equivalent is: + +```hcl +module "site" { + source = "github.com/libops/cloud-compose//providers/linode?ref=2.0.0" + + # Keep the existing name, template, linode, and runtime values. +} + +moved { + from = module.site.module.linode[0] + to = module.site.module.linode +} +``` + +Place the `moved` block in the caller's root module, not inside a downstream +fork of cloud-compose. Back up remote state, initialize the new exact source, +and review the plan before applying. The plan must show the existing VM, +firewall, and both durable volumes moving addresses without replacement or +deletion. If the caller's block is not named `site`, substitute its actual name +in both addresses. Keep the `moved` block in version control for at least one +complete rollout across every workspace that shares the configuration. + DigitalOcean and Linode share the same Linux runtime contract as the GCP module: - mount a persistent app/data disk at `/mnt/disks/data` @@ -58,11 +111,10 @@ the apt installer path. Both paths install the same minimum runtime surface: - `sitectl` and selected plugins DigitalOcean and Linode do not currently have a cloud-compose workload identity -contract equivalent to GCP IAM for Vault Agent. The provider-neutral root module -resolves `runtime.vault.auth_method = "auto"` to `gcp-iam` on GCP and -`consumer-managed` on DigitalOcean and Linode. Provider-specific entrypoints -default directly to their corresponding method. When the Terraform-managed -agent is enabled with `consumer-managed`, supply the auth stanza through +contract equivalent to GCP IAM for Vault Agent. The GCP entrypoints resolve +`runtime.vault.auth_method = "auto"` to `gcp-iam`; the DigitalOcean and Linode +entrypoints resolve it to `consumer-managed`. When the Terraform-managed agent +is enabled with `consumer-managed`, supply the auth stanza through `runtime.vault.agent_additional_config` or a rootfs overlay. Provider-neutral `runtime.users` applies on every cloud. DigitalOcean and Linode diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md index 801dc89..927e7db 100644 --- a/docs/runtime-contracts.md +++ b/docs/runtime-contracts.md @@ -171,9 +171,10 @@ into an ambiguous shell scalar. `cloud-compose` defines the Vault contract and leaves product-specific Vault roles, policies, mounts, and secret paths to consumers. -The provider-neutral root defaults `runtime.vault.auth_method` to `auto`, which -resolves to GCP IAM on GCP and consumer-managed auth elsewhere. GCP IAM uses the -app GSA identity, not the VM GSA. Vault Agent uses the VM's attached identity +Each provider-specific entrypoint defaults `runtime.vault.auth_method` to +`auto`. The GCP entrypoint and GCP-only compatibility root resolve it to GCP +IAM; DigitalOcean and Linode resolve it to consumer-managed auth. GCP IAM uses +the app GSA identity, not the VM GSA. Vault Agent uses the VM's attached identity through Application Default Credentials to call the IAM Credentials `signJwt` API for that app identity. Terraform grants the VM GSA Token Creator on that one app GSA only while managed GCP IAM auto-auth is enabled; it does not create @@ -637,7 +638,7 @@ the app Key Admin member, and the legacy app self-signing member. The first four preserve identity when their corresponding compatibility features remain enabled. The fifth gives Terraform explicit deletion provenance when the new, more narrowly scoped managed Vault signer grant is disabled. These moves cannot -migrate state between the provider-neutral root and `providers/gcp` +migrate state between the GCP compatibility root and `providers/gcp` entrypoints, or between differently named caller module blocks. Treat either refactor as a separate reviewed state migration using caller-owned `moved` blocks or explicit `terraform state mv` commands. If old state records a @@ -664,7 +665,11 @@ pull-request titles beginning with `[major]`; other pull requests retain the fresh-current GCP smoke. The gate provisions the exact `0.10.2` commit `f33117cdbbf4a9c7d59006a4db986baef118e6bb` in one detached worktree and the tested merge commit in another, with both configurations using one absolute -local-backend state path. Both phases use a pre-provisioned, same-project CI +local-backend state path. The fixture deliberately uses the GCP-only +compatibility root in both phases and asserts the historical +`module.app.module.gcp[0]` address, so removing non-GCP providers from root +cannot silently move an existing GCP deployment. New deployments should use +the explicit `providers/gcp` entrypoint. Both phases use a pre-provisioned, same-project CI network and regional subnet that are outside the disposable application state. The `0.10.2` baseline predates Shared VPC support, and a persistent subnet also prevents Direct VPC's one-to-two-hour address-retention window from turning an @@ -742,33 +747,40 @@ reviewers**, and set its deployment branch policy to the selected branch `main` only. Those environments are consumed solely by the `workflow_run` workflow loaded from the default branch. It checks out `github.sha` (the trusted default-branch revision for that event), rejects fork-originated runs, and uses -the originating workflow run ID to constrain the sweep. Only the GCP app-smoke -cleanup job builds `.bin/cloud-compose-ci`; it builds the binary once from that -trusted checkout and uses it for GCP discovery, retries, ordered deletion, and -residual verification. The GCP pull-request job likewise builds one binary -before apply and reuses that exact workspace binary from its `always()` cleanup -step. DigitalOcean and Linode app cleanup, plus Ansible and Salt -config-management cleanup, continue to use their hardened shell drivers; the Go -runner is not yet a general hosted-provider cleanup implementation. No cleanup -job executes a pull-request binary or downloads one as an artifact. This -fallback runs automatically after a failed, cancelled, or timed-out smoke -workflow, including cases where cancellation prevented the in-job destroy from -finishing. Never allow pull-request branches in a cleanup environment's -deployment policy. +the originating workflow run ID to constrain the sweep. Every cleanup job +builds `.bin/cloud-compose-ci` once from that trusted checkout. The compiled +runner owns GCP, DigitalOcean, and Linode discovery, retries, ordered deletion, +and residual verification for both app and config-management smoke resources. +It calls DigitalOcean and Linode through `net/http`; provider tokens are added +only to the credentialed smoke, `always()` destroy, or trusted sweep step and +sent only as authorization headers, never as process arguments or error text. +Checkout, setup, and runner-build steps do not receive provider tokens, and +trusted and provider-job checkouts set `persist-credentials: false`. +Pull-request smoke jobs also build one binary before apply and reuse that exact +workspace binary from their `always()` cleanup step. Shell remains responsible +for Terraform, SSH, cloud-init, diagnostics, and host-runtime black-box checks. +No privileged fallback executes a pull-request binary or downloads one as an +artifact. The fallback runs +automatically after a failed, cancelled, or timed-out smoke workflow, including +cases where cancellation prevented the in-job destroy from finishing. Never +allow pull-request branches in a cleanup environment's deployment policy. GCP smoke writers encode the complete canonical numeric GitHub Actions run ID as a fixed-width, nine-character lowercase base36 namespace. The fresh and upgrade drivers obtain that value from the compiled `cloud-compose-ci gcp namespace` command before Terraform can create resources. A manual fresh GCP smoke apply must set `CLOUD_COMPOSE_SMOKE_RUN_ID`; an empty, malformed, noncanonical, or -greater-than-44-bit value fails before apply. Non-GCP smoke invocation remains -unchanged. The namespace width preserves both +greater-than-44-bit value fails before apply. DigitalOcean, Linode, and raw-host +config-management smoke writers validate that same canonical run ID through +`cloud-compose-ci run validate` before apply. This ensures every created +resource has an exact cleanup tag; manual smoke applies on every cloud must now +set `CLOUD_COMPOSE_SMOKE_RUN_ID`. The namespace width preserves both separators and at least one random suffix character for every supported GCP template within the 21-character resource-name limit. The original decimal run -ID remains the cleanup input and the source of compatibility tags; DigitalOcean -and Linode naming and invocation are unchanged. Manual GCP smoke applies must -set `CLOUD_COMPOSE_SMOKE_RUN_ID`; the smoke context validates that the supplied -namespace decodes to that same canonical, at-most-44-bit run ID. +ID remains the cleanup input and the source of compatibility tags; existing +DigitalOcean and Linode resource names and tags are unchanged. The GCP smoke +context additionally validates that the supplied namespace decodes to that same +canonical, at-most-44-bit run ID. The privileged fallback always executes the default branch, so its compiled runner remains a dual reader: it queries both the legacy first-eight-character @@ -822,10 +834,30 @@ deletion share a bounded two-hour-ten-minute exponential-backoff window because Direct VPC addresses can remain allocated for one to two hours after Cloud Run disconnects; exhausting that window still fails the job and reports the leak. The workflow fails if a mutation exhausts its retry budget or residual resources -remain, making leaks visible to operators. The compiled runner requires a run ID -by default; an operator can sweep every run for one disposable target only by -supplying the explicit `--all-runs` flag. That explicit flag overrides a run ID -inherited from `CLOUD_COMPOSE_SMOKE_RUN_ID`; supplying both `--run-id` and -`--all-runs` explicitly is an error. The compatibility shell exposes the broad -operation only when `CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS=true`; a missing run ID -otherwise fails closed. +remain, making leaks visible to operators. DigitalOcean and Linode cleanup use +the same fail-closed contract: app resources must contain the provider target +and exact `gha-run-*` tags, config-management resources must contain both of +their scope tags, and tagless DigitalOcean firewalls must match the complete +generated run-specific name. Provider `404` and `410` delete responses are +successful idempotent cleanup. Every list is fully materialized before any +resource from that list can be deleted. DigitalOcean pagination follows only an +absolute, sequential `links.pages.next` URL on the configured API origin and +exact list path with its filters intact. Linode requires present, sequential, +and stable `page`/`pages` metadata. Repeated resources, malformed metadata, +cycles, origin or path changes, and listings beyond the 100-page safety bound +fail both deletion and residual verification closed; partial listings are never +treated as success. + +Provider status, timeout, retry/backoff, ordered deletion, idempotency, +pagination, and residual-consistency behavior is tested against the real Go +`net/http` client with `httptest`. The `hosted-cleanup-retry-contract` target +also retains a small, network-disabled shell lifecycle check whose +`CLOUD_COMPOSE_CI_BIN` is an explicit fake; it does not build or discover +`.bin/cloud-compose-ci` and does not emulate provider HTTP with curl. The +compiled runner requires a run ID by default; +an operator can sweep every run for one disposable target only by supplying the +explicit `--all-runs` flag. That explicit flag overrides a run ID inherited from +`CLOUD_COMPOSE_SMOKE_RUN_ID`; supplying both `--run-id` and `--all-runs` +explicitly is an error. The compatibility shell exposes the broad operation +only when `CLOUD_COMPOSE_SMOKE_ALLOW_ALL_RUNS=true`; a missing run ID otherwise +fails closed. diff --git a/internal/contracttest/cloud_smoke_cleanup_test.go b/internal/contracttest/cloud_smoke_cleanup_test.go index 6d9f04d..2c2ec3b 100644 --- a/internal/contracttest/cloud_smoke_cleanup_test.go +++ b/internal/contracttest/cloud_smoke_cleanup_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "path/filepath" "regexp" + "slices" "strings" "testing" ) @@ -95,6 +96,497 @@ func TestCloudSmokeGCPWrapperUsesCompiledRunner(t *testing.T) { } } +func TestNonGCPSmokeCleanupUsesCompiledRunner(t *testing.T) { + t.Parallel() + root := repositoryRoot(t) + appDriver := readRepositoryFile(t, root, "ci/cloud-smoke.sh") + configDriver := readRepositoryFile(t, root, "ci/config-management-cloud-smoke.sh") + hostedContract := readRepositoryFile(t, root, "ci/hosted-cleanup-retry-contract.sh") + makefile := readRepositoryFile(t, root, "Makefile") + workflow := readRepositoryFile(t, root, ".github/workflows/cloud-smoke.yml") + cleanupWorkflow := readRepositoryFile(t, root, ".github/workflows/cloud-smoke-cleanup.yml") + + for label, marker := range map[string]string{ + "app provider command": `"$provider" sweep`, + "app cleanup scope": `--scope application`, + "config provider command": `linode sweep`, + "config cleanup scope": `--scope config-management`, + "owned-run flag": `cleanup_args+=(--run-id "$run_id")`, + "explicit broad-sweep flag": `cleanup_args+=(--all-runs)`, + } { + requireContains(t, appDriver+configDriver, marker, label) + } + for label, driver := range map[string]string{ + "application": appDriver, + "config-management": configDriver, + } { + requireContains(t, driver, `"$runner" run validate --run-id "$run_id"`, label+" canonical run-ID command") + validationIndex := strings.Index(driver, `validate_smoke_run_id "$run_id"`) + argumentsIndex := strings.Index(driver, `mapfile -d '' -t var_args < <(target_var_args`) + applyIndex := strings.Index(driver, `terraform -chdir="$root" apply`) + if validationIndex < 0 || argumentsIndex < 0 || applyIndex < 0 || validationIndex >= argumentsIndex || validationIndex >= applyIndex { + t.Errorf("%s writer does not validate exact run ownership before Terraform argument construction and apply", label) + } + } + for _, obsolete := range []string{ + "api_request()", + "api_get()", + "api_delete()", + "delete_ids()", + "provider_resource_ids()", + "provider_cleanup_residuals()", + "verify_no_provider_resources()", + "Authorization: Bearer", + } { + if strings.Contains(appDriver, obsolete) || strings.Contains(configDriver, obsolete) { + t.Errorf("shell driver still owns migrated provider API behavior %q", obsolete) + } + } + for _, obsolete := range []string{ + "FAKE_API_MODE", + "normal_get_body", + "api.digitalocean.com", + "api.linode.com", + } { + if strings.Contains(hostedContract, obsolete) { + t.Errorf("hosted lifecycle shell contract still emulates provider HTTP behavior %q", obsolete) + } + } + if strings.Contains(appDriver+configDriver+workflow+cleanupWorkflow, "--token") { + t.Fatal("provider token is passed as a process argument") + } + + for label, marker := range map[string]string{ + "app Make dependency": "smoke-test: cloud-compose-ci", + "app destroy Make dependency": "destroy-smoke: cloud-compose-ci", + "config-management Make dependency": "config-management-cloud-smoke: cloud-compose-ci", + "hosted runner build": "- name: Build provider cloud CI runner", + "trusted cleanup runner build": "- name: Build trusted cloud CI runner", + "hosted Go implementation contract": "hosted-cleanup-retry-contract: go-contracts", + "hosted fake-runner boundary": `CLOUD_COMPOSE_CI_BIN="$tmp/bin/cloud-compose-ci"`, + } { + requireContains(t, makefile+workflow+cleanupWorkflow+hostedContract, marker, label) + } + if !regexp.MustCompile(`(?m)^hosted-cleanup-retry-contract: go-contracts$`).MatchString(makefile) { + t.Fatal("hosted cleanup contract must test Go directly without building a workspace runner") + } + if strings.Contains(hostedContract, ".bin/cloud-compose-ci") || strings.Contains(hostedContract, "go run") { + t.Fatal("hosted lifecycle contract can discover or execute an untrusted workspace runner") + } + + for _, shellContract := range []string{ + "ci/config-management-cloud-smoke-inner.sh", + "ci/host-runtime-security.sh", + "ci/systemd-contract.sh", + } { + if _, err := os.Stat(filepath.Join(root, shellContract)); err != nil { + t.Errorf("host/runtime black-box shell contract %s is missing: %v", shellContract, err) + } + } +} + +func TestHostedProviderTokensAreScopedToLifecycleSteps(t *testing.T) { + t.Parallel() + root := repositoryRoot(t) + workflow := readRepositoryFile(t, root, ".github/workflows/cloud-smoke.yml") + cleanupWorkflow := readRepositoryFile(t, root, ".github/workflows/cloud-smoke-cleanup.yml") + + configJob := workflowJobSection(t, workflow, "config-management-cloud-smoke", "smoke") + providerJob := workflowJobSection(t, workflow, "smoke", "gcp-smoke") + cleanupJob := workflowJobSection(t, cleanupWorkflow, "cleanup", "gcp-cleanup") + + for name, job := range map[string]string{ + "config-management smoke": configJob, + "provider smoke": providerJob, + "trusted cleanup": cleanupJob, + } { + stepsIndex := strings.Index(job, "\n steps:\n") + if stepsIndex < 0 { + t.Fatalf("%s job has no steps boundary", name) + } + header := job[:stepsIndex] + if strings.Contains(header, "DIGITALOCEAN_TOKEN:") || strings.Contains(header, "LINODE_TOKEN:") { + t.Errorf("%s exposes a long-lived provider token at job scope", name) + } + requireContains(t, job, "persist-credentials: false", name+" checkout credential isolation") + } + + configToken := " LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }}" + if got := strings.Count(configJob, configToken); got != 2 { + t.Fatalf("config-management token bindings = %d; want exactly the smoke and always-destroy steps", got) + } + for label, marker := range map[string]string{ + "config-management smoke token": ` - name: Run Linode config-management smoke test + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }}`, + "config-management destroy token": ` - name: Destroy Linode config-management smoke resources + if: always() + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }}`, + } { + requireContains(t, configJob, marker, label) + } + + providerTokenEnvironment := ` env: + DIGITALOCEAN_TOKEN: ${{ matrix.provider == 'digitalocean' && secrets.DIGITALOCEAN_TOKEN || '' }} + LINODE_TOKEN: ${{ matrix.provider == 'linode' && secrets.LINODE_TOKEN || '' }}` + if got := strings.Count(providerJob, providerTokenEnvironment); got != 2 { + t.Fatalf("provider token environments = %d; want exactly the smoke and always-destroy steps", got) + } + for _, token := range []string{"DIGITALOCEAN_TOKEN:", "LINODE_TOKEN:"} { + if got := strings.Count(providerJob, token); got != 2 { + t.Fatalf("provider smoke %s bindings = %d; want 2", token, got) + } + } + for label, marker := range map[string]string{ + "provider smoke tokens": ` - name: Run smoke test +` + providerTokenEnvironment, + "provider destroy tokens": ` - name: Destroy smoke resources + if: always() +` + providerTokenEnvironment, + } { + requireContains(t, providerJob, marker, label) + } + + for label, marker := range map[string]string{ + "application sweep token": ` - name: Sweep provider smoke resources + if: matrix.kind == 'app' + env: + DIGITALOCEAN_TOKEN: ${{ matrix.provider == 'digitalocean' && secrets.DIGITALOCEAN_TOKEN || '' }} + LINODE_TOKEN: ${{ matrix.provider == 'linode' && secrets.LINODE_TOKEN || '' }}`, + "config-management sweep token": ` - name: Sweep config-management smoke resources + if: matrix.kind == 'config-management' + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }}`, + } { + requireContains(t, cleanupJob, marker, label) + } + for token, want := range map[string]int{ + "DIGITALOCEAN_TOKEN:": 1, + "LINODE_TOKEN:": 2, + } { + if got := strings.Count(cleanupJob, token); got != want { + t.Fatalf("trusted cleanup %s bindings = %d; want %d lifecycle-step bindings", token, got, want) + } + } + if got := strings.Count(cleanupWorkflow, "persist-credentials: false"); got != 2 { + t.Fatalf("trusted cleanup checkout credential-isolation settings = %d; want 2", got) + } +} + +func TestNonGCPCompatibilityWrappersPassOnlyOwnershipArguments(t *testing.T) { + t.Parallel() + root := repositoryRoot(t) + temporaryDirectory := t.TempDir() + fakeRunner := filepath.Join(temporaryDirectory, "cloud-compose-ci") + // #nosec G306 -- the test fixture must be executable by the child process. + if err := os.WriteFile(fakeRunner, []byte(`#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" >"$FAKE_CLEANUP_LOG" +`), 0o700); err != nil { + t.Fatalf("write fake cleanup runner: %v", err) + } + + tests := []struct { + name string + driver string + command string + tokenName string + tokenValue string + want []string + }{ + { + name: "DigitalOcean app", + driver: "ci/cloud-smoke.sh", + command: "sweep-digitalocean-isle", + tokenName: "DIGITALOCEAN_TOKEN", + tokenValue: "do-wrapper-secret", + want: []string{"digitalocean", "sweep", "--scope", "application", "--target", "digitalocean-isle", "--run-id", "123456789"}, + }, + { + name: "Linode config management", + driver: "ci/config-management-cloud-smoke.sh", + command: "sweep-ansible-drupal", + tokenName: "LINODE_TOKEN", + tokenValue: "linode-wrapper-secret", + want: []string{"linode", "sweep", "--scope", "config-management", "--target", "ansible-drupal", "--run-id", "123456789"}, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + logPath := filepath.Join(t.TempDir(), "cleanup.log") + command := exec.Command("bash", filepath.Join(root, test.driver), test.command) + command.Env = overriddenEnvironment(map[string]string{ + "CLOUD_COMPOSE_CI_BIN": fakeRunner, + "CLOUD_COMPOSE_SMOKE_RUN_ID": "123456789", + "FAKE_CLEANUP_LOG": logPath, + test.tokenName: test.tokenValue, + }) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("wrapper error = %v, output = %s", err, output) + } + logged, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read cleanup log: %v", err) + } + got := strings.Fields(string(logged)) + if !slices.Equal(got, test.want) { + t.Fatalf("runner arguments = %q; want %q", got, test.want) + } + if strings.Contains(string(logged), test.tokenValue) || strings.Contains(string(output), test.tokenValue) { + t.Fatal("wrapper exposed provider token in arguments or output") + } + }) + } +} + +func TestNonGCPSmokeRunExitCleanupLifecycle(t *testing.T) { + t.Parallel() + root := repositoryRoot(t) + tests := []struct { + name string + driver string + target string + applyStatus string + applySignal string + destroyStatus string + runnerStatus string + wantStatus int + wantSweep string + }{ + { + name: "application body failure runs automatic destroy", + driver: "ci/cloud-smoke.sh", + target: "linode-wp", + applyStatus: "37", + destroyStatus: "0", + runnerStatus: "0", + wantStatus: 37, + }, + { + name: "config body status wins when destroy and fallback fail", + driver: "ci/config-management-cloud-smoke.sh", + target: "ansible-drupal", + applyStatus: "37", + destroyStatus: "42", + runnerStatus: "43", + wantStatus: 37, + wantSweep: "linode sweep --scope config-management --target ansible-drupal --run-id 123456789", + }, + { + name: "cleanup-only failure surfaces after successful body", + driver: "ci/cloud-smoke.sh", + target: "linode-wp", + applyStatus: "0", + destroyStatus: "42", + runnerStatus: "43", + wantStatus: 42, + wantSweep: "linode sweep --scope application --target linode-wp --run-id 123456789", + }, + { + name: "term reaches automatic destroy", + driver: "ci/cloud-smoke.sh", + target: "linode-wp", + applyStatus: "0", + applySignal: "TERM", + destroyStatus: "0", + runnerStatus: "0", + wantStatus: 143, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + binDirectory := t.TempDir() + writeCloudSmokeLifecycleFakes(t, binDirectory) + stateDirectory := t.TempDir() + runnerLog := filepath.Join(stateDirectory, "runner.log") + terraformLog := filepath.Join(stateDirectory, "terraform.log") + + command := exec.Command("bash", filepath.Join(root, test.driver), test.target) + command.Env = overriddenEnvironment(map[string]string{ + "CLOUD_COMPOSE_CI_BIN": filepath.Join(binDirectory, "cloud-compose-ci"), + "CLOUD_COMPOSE_SMOKE_AUTO_APPROVE": "true", + "CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT": "10", + "CLOUD_COMPOSE_SMOKE_KEEP": "false", + "CLOUD_COMPOSE_SMOKE_RUN_ID": "123456789", + "CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS": "false", + "CLOUD_COMPOSE_SMOKE_WORKDIR": filepath.Join(stateDirectory, "smoke"), + "CLOUD_COMPOSE_SOURCE_SHA256": strings.Repeat("0", 64), + "DIGITALOCEAN_TOKEN": "do-lifecycle-secret", + "FAKE_APPLY_SIGNAL": test.applySignal, + "FAKE_APPLY_STATUS": test.applyStatus, + "FAKE_DESTROY_STATUS": test.destroyStatus, + "FAKE_RUNNER_LOG": runnerLog, + "FAKE_RUNNER_STATUS": test.runnerStatus, + "FAKE_TERRAFORM_LOG": terraformLog, + "FAKE_TERRAFORM_OUTPUT": `{"host":"127.0.0.1","ssh_port":22,"ssh_user":"tester","project_dir":"/home/cloud-compose/app","context_name":"smoke","plugin":"wordpress","environment":"test","site":"smoke","project_name":"smoke","compose_project_name":"smoke","provider":"linode"}`, + "GITHUB_ACTIONS": "true", + "LINODE_TOKEN": "linode-lifecycle-secret", + "PATH": binDirectory + string(os.PathListSeparator) + os.Getenv("PATH"), + }) + output, err := command.CombinedOutput() + if got := processExitCode(t, err); got != test.wantStatus { + t.Fatalf("wrapper status = %d; want %d, output = %s", got, test.wantStatus, output) + } + + terraformCalls := readTestLog(t, terraformLog) + applyIndex := strings.Index(terraformCalls, "apply\n") + destroyIndex := strings.LastIndex(terraformCalls, "destroy\n") + if applyIndex < 0 || destroyIndex <= applyIndex { + t.Fatalf("automatic cleanup did not destroy after the body:\n%s", terraformCalls) + } + + runnerCalls := readTestLog(t, runnerLog) + if !strings.Contains(runnerCalls, "run validate --run-id 123456789\n") { + t.Fatalf("wrapper skipped compiled run-ID validation:\n%s", runnerCalls) + } + if test.wantSweep == "" { + if strings.Contains(runnerCalls, " sweep ") { + t.Fatalf("successful Terraform destroy unexpectedly used fallback cleanup:\n%s", runnerCalls) + } + } else if !strings.Contains(runnerCalls, test.wantSweep+"\n") { + t.Fatalf("failed Terraform destroy skipped compiled fallback cleanup:\n%s", runnerCalls) + } + + for _, secret := range []string{"do-lifecycle-secret", "linode-lifecycle-secret"} { + if strings.Contains(string(output), secret) || strings.Contains(runnerCalls, secret) || strings.Contains(terraformCalls, secret) { + t.Fatalf("lifecycle output exposed provider token %q", secret) + } + } + }) + } +} + +func writeCloudSmokeLifecycleFakes(t testing.TB, directory string) { + t.Helper() + executables := map[string]string{ + "cloud-compose-ci": `#!/usr/bin/env bash +set -euo pipefail +operation="${1:-}:${2:-}" +{ + printf '%s' "${1:-}" + shift || true + printf ' %s' "$@" + printf '\n' +} >>"$FAKE_RUNNER_LOG" +if [[ "$operation" == "run:validate" ]]; then + exit 0 +fi +exit "${FAKE_RUNNER_STATUS:-0}" +`, + "terraform": `#!/usr/bin/env bash +set -euo pipefail +command_name="" +for argument in "$@"; do + case "$argument" in + init|validate|apply|output|destroy) + command_name="$argument" + break + ;; + esac +done +printf '%s\n' "$command_name" >>"$FAKE_TERRAFORM_LOG" +case "$command_name" in + init|validate) + exit 0 + ;; + apply) + if [[ -n "${FAKE_APPLY_SIGNAL:-}" ]]; then + kill -s "$FAKE_APPLY_SIGNAL" "$PPID" + exit 0 + fi + exit "${FAKE_APPLY_STATUS:-0}" + ;; + output) + printf '%s\n' "$FAKE_TERRAFORM_OUTPUT" + ;; + destroy) + exit "${FAKE_DESTROY_STATUS:-0}" + ;; + *) + echo "unexpected fake terraform invocation: $*" >&2 + exit 64 + ;; +esac +`, + "ssh-keygen": `#!/usr/bin/env bash +set -euo pipefail +path="" +while [[ "$#" -gt 0 ]]; do + if [[ "$1" == "-f" ]]; then + path="${2:-}" + break + fi + shift +done +[[ -n "$path" ]] || exit 64 +printf 'fake-private-key\n' >"$path" +printf 'ssh-ed25519 fake-public-key cloud-compose-smoke\n' >"${path}.pub" +`, + "ssh-keyscan": `#!/usr/bin/env bash +set -euo pipefail +printf '127.0.0.1 ssh-ed25519 fake-host-key\n' +`, + "ssh": `#!/usr/bin/env bash +set -euo pipefail +case "$*" in + *cloud-compose-bootstrap-complete*) printf 'complete\n' ;; + *cloud-init\ status*) printf 'cloud-init not installed\n' ;; +esac +`, + "sitectl": `#!/usr/bin/env bash +set -euo pipefail +exit 0 +`, + "docker": `#!/usr/bin/env bash +set -euo pipefail +cat >/dev/null +`, + "git": `#!/usr/bin/env bash +set -euo pipefail +printf 'fake archive' +`, + "curl": `#!/usr/bin/env bash +echo "unexpected network client invocation: curl $*" >&2 +exit 97 +`, + } + for name, content := range executables { + path := filepath.Join(directory, name) + if err := os.WriteFile(path, []byte(content), 0o700); err != nil { + t.Fatalf("write fake executable %s: %v", name, err) + } + } +} + +func processExitCode(t testing.TB, err error) int { + t.Helper() + if err == nil { + return 0 + } + exitError, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("run wrapper: %v", err) + } + return exitError.ExitCode() +} + +func readTestLog(t testing.TB, path string) string { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read test log %s: %v", path, err) + } + return string(contents) +} + func TestCloudSmokeGCPApplyRequiresCanonicalRunID(t *testing.T) { t.Parallel() root := repositoryRoot(t) @@ -272,3 +764,19 @@ func overriddenEnvironment(overrides map[string]string) []string { } return environment } + +func workflowJobSection(t testing.TB, workflow, job, nextJob string) string { + t.Helper() + startMarker := "\n " + job + ":\n" + endMarker := "\n " + nextJob + ":\n" + start := strings.Index(workflow, startMarker) + if start < 0 { + t.Fatalf("workflow job %q is missing", job) + } + start++ + endOffset := strings.Index(workflow[start:], endMarker) + if endOffset < 0 { + t.Fatalf("workflow job %q has no following %q boundary", job, nextJob) + } + return workflow[start : start+endOffset] +} diff --git a/internal/contracttest/provider_boundary_test.go b/internal/contracttest/provider_boundary_test.go new file mode 100644 index 0000000..04d82e8 --- /dev/null +++ b/internal/contracttest/provider_boundary_test.go @@ -0,0 +1,94 @@ +package contracttest + +import ( + "strings" + "testing" +) + +func TestRootEntrypointLoadsOnlyGCPProviders(t *testing.T) { + t.Parallel() + root := repositoryRoot(t) + rootConfiguration := strings.Join([]string{ + readRepositoryFile(t, root, "main.tf"), + readRepositoryFile(t, root, "variables.tf"), + readRepositoryFile(t, root, "outputs.tf"), + }, "\n") + + for _, forbidden := range []string{ + "digitalocean/digitalocean", + "linode/linode", + `source = "./modules/digitalocean"`, + `source = "./modules/linode"`, + `variable "digitalocean"`, + `variable "linode"`, + "module.digitalocean", + "module.linode", + } { + if strings.Contains(rootConfiguration, forbidden) { + t.Errorf("root GCP entrypoint contains non-GCP dependency %q", forbidden) + } + } + + for path, providerSource := range map[string]string{ + "providers/gcp/main.tf": "hashicorp/google", + "providers/do/main.tf": "digitalocean/digitalocean", + "providers/linode/main.tf": "linode/linode", + } { + requireContains(t, readRepositoryFile(t, root, path), providerSource, path+" provider ownership") + } + + publicOutputs := []string{ + "cloud_provider", + "template", + "instance", + "instance_id", + "external_ip", + "internal_ip", + "network", + "volumes", + "serviceGsa", + "appGsa", + "urls", + "backend", + "rollout", + "compose_projects", + "primary_compose_project", + "sitectl_package_versions", + } + for _, path := range []string{ + "outputs.tf", + "providers/gcp/outputs.tf", + "providers/do/outputs.tf", + "providers/linode/outputs.tf", + } { + content := readRepositoryFile(t, root, path) + for _, output := range publicOutputs { + requireContains(t, content, `output "`+output+`"`, path+" public output parity") + } + } + + validator := readRepositoryFile(t, root, "ci/terraform-validate.sh") + for _, marker := range []string{ + "terraform -chdir=\"$root\" providers", + "digitalocean/digitalocean", + "hashicorp/cloudinit", + "hashicorp/google", + "hashicorp/time", + "linode/linode", + "Unexpected Terraform provider graph", + } { + requireContains(t, validator, marker, "transitive provider-graph validation") + } + + migrationDocs := readRepositoryFile(t, root, "docs/non-gcp-providers.md") + for _, marker := range []string{ + "module.site.module.digitalocean[0]", + "module.site.module.digitalocean", + "module.site.module.linode[0]", + "module.site.module.linode", + "must show the existing VM,", + "both durable volumes moving addresses without replacement", + } { + requireContains(t, migrationDocs, marker, "root 1.x provider-entrypoint migration") + } +} diff --git a/internal/contracttest/template_version_test.go b/internal/contracttest/template_version_test.go index 44f4df1..444cd56 100644 --- a/internal/contracttest/template_version_test.go +++ b/internal/contracttest/template_version_test.go @@ -13,6 +13,7 @@ type templateRegistry struct { } type templateDefinition struct { + Branch string `json:"branch"` Packages []string `json:"packages"` PackageVersions map[string]string `json:"package_versions"` } @@ -28,36 +29,36 @@ func TestTemplateVersionContract(t *testing.T) { expectedVersions := map[string]map[string]string{ "default": { - "sitectl": "v0.39.0", + "sitectl": "v0.40.0", }, "archivesspace": { - "sitectl": "v0.39.0", - "sitectl-archivesspace": "v0.6.0", + "sitectl": "v0.40.0", + "sitectl-archivesspace": "v0.7.0", }, "drupal": { - "sitectl": "v0.39.0", - "sitectl-drupal": "v0.11.0", + "sitectl": "v0.40.0", + "sitectl-drupal": "v0.12.0", }, "isle": { - "sitectl": "v0.39.0", - "sitectl-drupal": "v0.11.0", - "sitectl-isle": "v0.18.0", + "sitectl": "v0.40.0", + "sitectl-drupal": "v0.12.0", + "sitectl-isle": "v0.19.0", }, "ojs": { - "sitectl": "v0.39.0", - "sitectl-ojs": "v0.6.0", + "sitectl": "v0.40.0", + "sitectl-ojs": "v0.7.0", }, "omeka-classic": { - "sitectl": "v0.39.0", - "sitectl-omeka-classic": "v0.6.0", + "sitectl": "v0.40.0", + "sitectl-omeka-classic": "v0.7.0", }, "omeka-s": { - "sitectl": "v0.39.0", - "sitectl-omeka-s": "v0.6.0", + "sitectl": "v0.40.0", + "sitectl-omeka-s": "v0.7.0", }, "wp": { - "sitectl": "v0.39.0", - "sitectl-wp": "v0.5.0", + "sitectl": "v0.40.0", + "sitectl-wp": "v0.6.0", }, } @@ -78,6 +79,9 @@ func TestTemplateVersionContract(t *testing.T) { if !maps.Equal(definition.PackageVersions, expected) { t.Errorf("template %q package versions diverged:\nexpected %s\nactual %s", name, prettyJSON(t, expected), prettyJSON(t, definition.PackageVersions)) } + if definition.Branch != "v1.0.0" { + t.Errorf("template %q branch = %q, want stable contract v1.0.0", name, definition.Branch) + } } checkPackageVersionKeys := func(name string, definition templateDefinition) { diff --git a/internal/providercleanup/api.go b/internal/providercleanup/api.go new file mode 100644 index 0000000..2910dff --- /dev/null +++ b/internal/providercleanup/api.go @@ -0,0 +1,699 @@ +// Package providercleanup removes DigitalOcean and Linode resources owned by +// a cloud-compose smoke run. +package providercleanup + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + defaultRequestTimeout = 45 * time.Second + maxResponseBytes = 4 << 20 + maximumListPages = 100 +) + +var validResourceID = regexp.MustCompile(`^[A-Za-z0-9-]+$`) + +// Provider identifies a supported hosted infrastructure API. +type Provider string + +const ( + // DigitalOcean identifies the DigitalOcean v2 API. + DigitalOcean Provider = "digitalocean" + // Linode identifies the Linode v4 API. + Linode Provider = "linode" +) + +// ParseProvider validates a provider name. +func ParseProvider(value string) (Provider, error) { + provider := Provider(strings.ToLower(strings.TrimSpace(value))) + switch provider { + case DigitalOcean, Linode: + return provider, nil + default: + return "", fmt.Errorf("unsupported cleanup provider %q", value) + } +} + +// TokenEnvironment returns the environment variable used for a provider API +// token. +func TokenEnvironment(provider Provider) (string, error) { + switch provider { + case DigitalOcean: + return "DIGITALOCEAN_TOKEN", nil + case Linode: + return "LINODE_TOKEN", nil + default: + return "", fmt.Errorf("unsupported cleanup provider %q", provider) + } +} + +type resourceKind string + +const ( + kindFirewalls resourceKind = "firewalls" + kindDroplets resourceKind = "droplets" + kindInstances resourceKind = "instances" + kindVolumes resourceKind = "volumes" +) + +type resource struct { + ID string + Name string + Tags []string +} + +type resourceID string + +func (id *resourceID) UnmarshalJSON(data []byte) error { + var value string + if len(data) > 0 && data[0] == '"' { + if err := json.Unmarshal(data, &value); err != nil { + return fmt.Errorf("decode resource ID: %w", err) + } + } else { + value = string(data) + } + if !validResourceID.MatchString(value) { + return errors.New("provider returned an invalid resource ID") + } + *id = resourceID(value) + return nil +} + +type apiResource struct { + ID resourceID `json:"id"` + Name string `json:"name"` + Label string `json:"label"` + Tags []string `json:"tags"` +} + +type digitalOceanFirewallsResponse struct { + Firewalls *[]apiResource `json:"firewalls"` + Links *digitalOceanLinks `json:"links"` + Meta *digitalOceanMeta `json:"meta"` +} + +type digitalOceanDropletsResponse struct { + Droplets *[]apiResource `json:"droplets"` + Links *digitalOceanLinks `json:"links"` + Meta *digitalOceanMeta `json:"meta"` +} + +type digitalOceanVolumesResponse struct { + Volumes *[]apiResource `json:"volumes"` + Links *digitalOceanLinks `json:"links"` + Meta *digitalOceanMeta `json:"meta"` +} + +type linodeResponse struct { + Data *[]apiResource `json:"data"` + Page *int `json:"page"` + Pages *int `json:"pages"` + Results *int `json:"results"` +} + +type digitalOceanMeta struct { + Total *int `json:"total"` +} + +type digitalOceanLinks struct { + Pages *digitalOceanPageLinks `json:"pages"` +} + +type digitalOceanPageLinks struct { + Next *string `json:"next"` +} + +type resourcePage struct { + resources []resource + digitalOceanNext *string + linodePage int + linodePages int + declaredTotal int +} + +// Client performs authenticated provider API requests. Tokens are held only +// in memory and are sent in request headers, never command arguments. +type Client struct { + provider Provider + token string + baseURL *url.URL + httpClient *http.Client + requestTimeout time.Duration +} + +// NewClient constructs a provider API client with bounded connect and request +// deadlines. +func NewClient(provider Provider, token string) (*Client, error) { + var endpoint string + switch provider { + case DigitalOcean: + endpoint = "https://api.digitalocean.com/v2/" + case Linode: + endpoint = "https://api.linode.com/v4/" + default: + return nil, fmt.Errorf("unsupported cleanup provider %q", provider) + } + if strings.TrimSpace(token) == "" { + name, _ := TokenEnvironment(provider) + return nil, fmt.Errorf("%s is required", name) + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext + transport.TLSHandshakeTimeout = 10 * time.Second + transport.ResponseHeaderTimeout = 30 * time.Second + + return newClient(provider, token, endpoint, &http.Client{ + Transport: transport, + Timeout: defaultRequestTimeout, + }) +} + +func newClient(provider Provider, token, endpoint string, httpClient *http.Client) (*Client, error) { + baseURL, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("parse provider endpoint: %w", err) + } + if baseURL.Scheme != "http" && baseURL.Scheme != "https" { + return nil, errors.New("provider endpoint must use HTTP or HTTPS") + } + if baseURL.Host == "" { + return nil, errors.New("provider endpoint must include a host") + } + if baseURL.Opaque != "" || baseURL.User != nil || baseURL.RawQuery != "" || baseURL.Fragment != "" || baseURL.RawPath != "" { + return nil, errors.New("provider endpoint must be a plain API base URL") + } + if !strings.HasSuffix(baseURL.Path, "/") { + baseURL.Path += "/" + } + if httpClient == nil { + return nil, errors.New("provider HTTP client is required") + } + client := *httpClient + // Provider API calls are expected to be direct. Refusing redirects prevents + // an upstream response from forwarding the authorization header to a target + // that was not validated against the configured API origin. + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + return &Client{ + provider: provider, + token: token, + baseURL: baseURL, + httpClient: &client, + requestTimeout: defaultRequestTimeout, + }, nil +} + +func (c *Client) list(ctx context.Context, kind resourceKind) ([]resource, error) { + initialPath, err := listPath(c.provider, kind) + if err != nil { + return nil, err + } + initialEndpoint, err := c.resolveEndpoint(initialPath) + if err != nil { + return nil, err + } + + nextEndpoint := initialEndpoint + seenPages := make(map[string]struct{}, maximumListPages) + seenResources := make(map[string]struct{}) + resources := make([]resource, 0) + declaredLinodePages := 0 + declaredTotal := -1 + + for pageNumber := 1; pageNumber <= maximumListPages; pageNumber++ { + canonical := canonicalEndpoint(nextEndpoint) + if _, seen := seenPages[canonical]; seen { + return nil, fmt.Errorf("%s %s pagination contained a cycle", c.provider, kind) + } + seenPages[canonical] = struct{}{} + + body, requestErr := c.requestEndpoint(ctx, http.MethodGet, nextEndpoint) + if requestErr != nil { + return nil, requestErr + } + page, decodeErr := decodeResourcePage(c.provider, kind, body) + if decodeErr != nil { + return nil, decodeErr + } + if declaredTotal == -1 { + declaredTotal = page.declaredTotal + } else if page.declaredTotal != declaredTotal { + return nil, fmt.Errorf("%s %s pagination changed its declared result count", c.provider, kind) + } + for _, candidate := range page.resources { + if _, duplicate := seenResources[candidate.ID]; duplicate { + return nil, fmt.Errorf("%s %s pagination repeated a resource ID", c.provider, kind) + } + seenResources[candidate.ID] = struct{}{} + resources = append(resources, candidate) + } + if len(resources) > declaredTotal { + return nil, fmt.Errorf("%s %s pagination returned more resources than its declared result count", c.provider, kind) + } + + switch c.provider { + case DigitalOcean: + if page.digitalOceanNext == nil { + if len(resources) != declaredTotal { + return nil, fmt.Errorf("%s %s pagination ended after %d of %d declared resources", c.provider, kind, len(resources), declaredTotal) + } + return resources, nil + } + if len(resources) == declaredTotal { + return nil, fmt.Errorf("%s %s pagination continued after returning its declared result count", c.provider, kind) + } + if pageNumber == maximumListPages { + return nil, fmt.Errorf("%s %s pagination exceeded the %d-page safety limit", c.provider, kind, maximumListPages) + } + nextEndpoint, err = c.digitalOceanNextEndpoint(*page.digitalOceanNext, initialEndpoint, pageNumber+1) + if err != nil { + return nil, err + } + case Linode: + if page.linodePage != pageNumber { + return nil, fmt.Errorf("%s %s pagination returned page %d while page %d was requested", c.provider, kind, page.linodePage, pageNumber) + } + if declaredLinodePages == 0 { + declaredLinodePages = page.linodePages + if declaredLinodePages > maximumListPages { + return nil, fmt.Errorf("%s %s pagination exceeds the %d-page safety limit", c.provider, kind, maximumListPages) + } + } else if page.linodePages != declaredLinodePages { + return nil, fmt.Errorf("%s %s pagination changed its declared page count", c.provider, kind) + } + if pageNumber == declaredLinodePages { + if len(resources) != declaredTotal { + return nil, fmt.Errorf("%s %s pagination ended after %d of %d declared resources", c.provider, kind, len(resources), declaredTotal) + } + return resources, nil + } + nextEndpoint = linodeNextEndpoint(initialEndpoint, pageNumber+1) + default: + return nil, fmt.Errorf("unsupported cleanup provider %q", c.provider) + } + } + return nil, fmt.Errorf("%s %s pagination exceeded the %d-page safety limit", c.provider, kind, maximumListPages) +} + +func (c *Client) delete(ctx context.Context, kind resourceKind, id string) error { + if !validResourceID.MatchString(id) { + return errors.New("refusing to delete an invalid provider resource ID") + } + prefix, err := deletePrefix(c.provider, kind) + if err != nil { + return err + } + _, err = c.request(ctx, http.MethodDelete, prefix+url.PathEscape(id)) + return err +} + +func (c *Client) resolveEndpoint(reference string) (*url.URL, error) { + parsed, err := url.Parse(reference) + if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Fragment != "" { + return nil, errors.New("construct provider API request") + } + endpoint := c.baseURL.ResolveReference(parsed) + if !strings.EqualFold(endpoint.Scheme, c.baseURL.Scheme) || + !strings.EqualFold(endpoint.Host, c.baseURL.Host) || + endpoint.User != nil || endpoint.Fragment != "" || endpoint.RawPath != "" { + return nil, errors.New("provider API request target escaped the configured origin") + } + if !strings.HasPrefix(endpoint.Path, c.baseURL.Path) || endpoint.Path == c.baseURL.Path { + return nil, errors.New("provider API request target escaped the configured base path") + } + return endpoint, nil +} + +func (c *Client) digitalOceanNextEndpoint(next string, initial *url.URL, expectedPage int) (*url.URL, error) { + if next == "" || strings.TrimSpace(next) != next { + return nil, fmt.Errorf("%s pagination returned an invalid next link", c.provider) + } + reference, err := url.Parse(next) + if err != nil || !reference.IsAbs() { + return nil, fmt.Errorf("%s pagination returned an invalid next link", c.provider) + } + endpoint, err := c.resolveEndpoint(next) + if err != nil { + return nil, fmt.Errorf("%s pagination returned an unsafe next link", c.provider) + } + if endpoint.Path != initial.Path { + return nil, fmt.Errorf("%s pagination changed the list endpoint", c.provider) + } + query, err := url.ParseQuery(endpoint.RawQuery) + if err != nil { + return nil, fmt.Errorf("%s pagination next link contained an invalid query", c.provider) + } + pages, found := query["page"] + if !found || len(pages) != 1 { + return nil, fmt.Errorf("%s pagination next link omitted a single page number", c.provider) + } + page, err := strconv.Atoi(pages[0]) + if err != nil || page != expectedPage || pages[0] != strconv.Itoa(expectedPage) { + return nil, fmt.Errorf("%s pagination next link did not advance to page %d", c.provider, expectedPage) + } + initialQuery, err := url.ParseQuery(initial.RawQuery) + if err != nil { + return nil, errors.New("construct provider API pagination request") + } + expectedQuery := cloneQuery(initialQuery) + expectedQuery.Set("page", strconv.Itoa(expectedPage)) + if !equalQuerySets(query, expectedQuery) { + return nil, fmt.Errorf("%s pagination next link changed the exact list query", c.provider) + } + return endpoint, nil +} + +func cloneQuery(values url.Values) url.Values { + cloned := make(url.Values, len(values)) + for key, entries := range values { + cloned[key] = append([]string(nil), entries...) + } + return cloned +} + +func equalQuerySets(left, right url.Values) bool { + if len(left) != len(right) { + return false + } + for key, leftValues := range left { + rightValues, found := right[key] + if !found || len(leftValues) != len(rightValues) { + return false + } + for index := range leftValues { + if leftValues[index] != rightValues[index] { + return false + } + } + } + return true +} + +func linodeNextEndpoint(initial *url.URL, page int) *url.URL { + next := *initial + query := next.Query() + query.Set("page", strconv.Itoa(page)) + next.RawQuery = query.Encode() + return &next +} + +func canonicalEndpoint(endpoint *url.URL) string { + canonical := *endpoint + canonical.RawQuery = canonical.Query().Encode() + canonical.ForceQuery = false + return canonical.String() +} + +func (c *Client) request(ctx context.Context, method, path string) ([]byte, error) { + endpoint, err := c.resolveEndpoint(path) + if err != nil { + return nil, err + } + return c.requestEndpoint(ctx, method, endpoint) +} + +func (c *Client) requestEndpoint(ctx context.Context, method string, endpoint *url.URL) ([]byte, error) { + requestTimeout := c.requestTimeout + if requestTimeout <= 0 { + requestTimeout = defaultRequestTimeout + } + requestContext, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + request, err := http.NewRequestWithContext(requestContext, method, endpoint.String(), nil) + if err != nil { + return nil, errors.New("construct provider API request") + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+c.token) + request.Header.Set("User-Agent", "libops-cloud-compose-ci") + + response, err := c.httpClient.Do(request) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, &requestError{ + provider: c.provider, + method: method, + path: endpoint.EscapedPath(), + retryable: true, + message: "network request failed or timed out", + } + } + defer response.Body.Close() + + if method == http.MethodDelete && (response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone) { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBytes)) + return nil, nil + } + if method == http.MethodGet && response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBytes)) + return nil, &requestError{ + provider: c.provider, + method: method, + path: endpoint.EscapedPath(), + statusCode: response.StatusCode, + retryable: retryableStatus(method, response.StatusCode), + message: "unexpected response status", + } + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBytes)) + return nil, &requestError{ + provider: c.provider, + method: method, + path: endpoint.EscapedPath(), + statusCode: response.StatusCode, + retryable: retryableStatus(method, response.StatusCode), + message: "request rejected", + } + } + + body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1)) + if err != nil { + return nil, &requestError{ + provider: c.provider, + method: method, + path: endpoint.EscapedPath(), + retryable: true, + message: "read response failed", + } + } + if len(body) > maxResponseBytes { + return nil, &requestError{ + provider: c.provider, + method: method, + path: endpoint.EscapedPath(), + retryable: false, + message: "response exceeded size limit", + } + } + return body, nil +} + +type requestError struct { + provider Provider + method string + path string + statusCode int + retryable bool + message string +} + +func (e *requestError) Error() string { + if e.statusCode != 0 { + return fmt.Sprintf("%s API %s %s returned HTTP %d: %s", e.provider, e.method, e.path, e.statusCode, e.message) + } + return fmt.Sprintf("%s API %s %s: %s", e.provider, e.method, e.path, e.message) +} + +func retryable(err error) bool { + var apiError *requestError + return errors.As(err, &apiError) && apiError.retryable +} + +func retryableStatus(method string, status int) bool { + if status == http.StatusRequestTimeout || status == http.StatusTooEarly || status == http.StatusTooManyRequests || status >= 500 { + return true + } + return method == http.MethodDelete && (status == http.StatusConflict || status == http.StatusLocked) +} + +func listPath(provider Provider, kind resourceKind) (string, error) { + switch provider { + case DigitalOcean: + switch kind { + case kindFirewalls: + return "firewalls?per_page=200", nil + case kindDroplets: + return "droplets?tag_name=cloud-compose-smoke&per_page=200", nil + case kindVolumes: + return "volumes?tag_name=cloud-compose-smoke&per_page=200", nil + } + case Linode: + switch kind { + case kindFirewalls: + return "networking/firewalls?page_size=500", nil + case kindInstances: + return "linode/instances?page_size=500", nil + case kindVolumes: + return "volumes?page_size=500", nil + } + } + return "", fmt.Errorf("unsupported %s resource kind %q", provider, kind) +} + +func deletePrefix(provider Provider, kind resourceKind) (string, error) { + switch provider { + case DigitalOcean: + switch kind { + case kindFirewalls: + return "firewalls/", nil + case kindDroplets: + return "droplets/", nil + case kindVolumes: + return "volumes/", nil + } + case Linode: + switch kind { + case kindFirewalls: + return "networking/firewalls/", nil + case kindInstances: + return "linode/instances/", nil + case kindVolumes: + return "volumes/", nil + } + } + return "", fmt.Errorf("unsupported %s resource kind %q", provider, kind) +} + +func decodeResources(provider Provider, kind resourceKind, body []byte) ([]resource, error) { + page, err := decodeResourcePage(provider, kind, body) + if err != nil { + return nil, err + } + return page.resources, nil +} + +func decodeResourcePage(provider Provider, kind resourceKind, body []byte) (resourcePage, error) { + var values *[]apiResource + page := resourcePage{} + switch provider { + case DigitalOcean: + switch kind { + case kindFirewalls: + response := digitalOceanFirewallsResponse{} + if err := decodeJSON(body, &response); err != nil { + return resourcePage{}, fmt.Errorf("decode DigitalOcean firewalls response: %w", err) + } + values = response.Firewalls + page.digitalOceanNext = digitalOceanNext(response.Links) + page.declaredTotal = digitalOceanTotal(response.Meta) + case kindDroplets: + response := digitalOceanDropletsResponse{} + if err := decodeJSON(body, &response); err != nil { + return resourcePage{}, fmt.Errorf("decode DigitalOcean droplets response: %w", err) + } + values = response.Droplets + page.digitalOceanNext = digitalOceanNext(response.Links) + page.declaredTotal = digitalOceanTotal(response.Meta) + case kindVolumes: + response := digitalOceanVolumesResponse{} + if err := decodeJSON(body, &response); err != nil { + return resourcePage{}, fmt.Errorf("decode DigitalOcean volumes response: %w", err) + } + values = response.Volumes + page.digitalOceanNext = digitalOceanNext(response.Links) + page.declaredTotal = digitalOceanTotal(response.Meta) + default: + return resourcePage{}, fmt.Errorf("unsupported %s resource kind %q", provider, kind) + } + case Linode: + response := linodeResponse{} + if err := decodeJSON(body, &response); err != nil { + return resourcePage{}, fmt.Errorf("decode Linode %s response: %w", kind, err) + } + values = response.Data + if response.Page == nil || response.Pages == nil || response.Results == nil || *response.Page < 1 || *response.Pages < 1 || *response.Page > *response.Pages || *response.Results < 0 { + return resourcePage{}, fmt.Errorf("Linode %s response included invalid pagination metadata", kind) + } + page.linodePage = *response.Page + page.linodePages = *response.Pages + page.declaredTotal = *response.Results + default: + return resourcePage{}, fmt.Errorf("unsupported cleanup provider %q", provider) + } + if values == nil { + return resourcePage{}, fmt.Errorf("%s %s response omitted the resource array", provider, kind) + } + if page.declaredTotal < 0 { + return resourcePage{}, fmt.Errorf("%s %s response omitted a valid collection total", provider, kind) + } + + resources := make([]resource, 0, len(*values)) + for _, value := range *values { + if !validResourceID.MatchString(string(value.ID)) { + return resourcePage{}, fmt.Errorf("%s %s response included a missing or invalid resource ID", provider, kind) + } + name := value.Name + if name == "" { + name = value.Label + } + resources = append(resources, resource{ + ID: string(value.ID), + Name: name, + Tags: append([]string(nil), value.Tags...), + }) + } + page.resources = resources + return page, nil +} + +func digitalOceanTotal(meta *digitalOceanMeta) int { + if meta == nil || meta.Total == nil { + return -1 + } + return *meta.Total +} + +func digitalOceanNext(links *digitalOceanLinks) *string { + if links == nil || links.Pages == nil { + return nil + } + return links.Pages.Next +} + +func decodeJSON(body []byte, destination any) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + if err := decoder.Decode(destination); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("response contained multiple JSON values") + } + return err + } + return nil +} diff --git a/internal/providercleanup/api_test.go b/internal/providercleanup/api_test.go new file mode 100644 index 0000000..9e77879 --- /dev/null +++ b/internal/providercleanup/api_test.go @@ -0,0 +1,460 @@ +package providercleanup + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestClientUsesHeaderAuthenticationAndBoundedRequest(t *testing.T) { + t.Parallel() + const token = "provider-secret-token" + requestCanceled := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if got := request.Header.Get("Authorization"); got != "Bearer "+token { + t.Errorf("Authorization header = %q", got) + } + <-request.Context().Done() + close(requestCanceled) + })) + t.Cleanup(server.Close) + + client := testClient(t, DigitalOcean, token, server.URL+"/v2/") + client.requestTimeout = 25 * time.Millisecond + _, err := client.list(context.Background(), kindFirewalls) + if err == nil { + t.Fatal("list() unexpectedly succeeded") + } + if strings.Contains(err.Error(), token) { + t.Fatalf("list() error leaked token: %v", err) + } + select { + case <-requestCanceled: + case <-time.After(time.Second): + t.Fatal("provider request context did not honor its deadline") + } +} + +func TestClientTreatsMissingDeleteAsSuccess(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + client := testClient(t, Linode, "token", server.URL+"/v4/") + if err := client.delete(context.Background(), kindInstances, "12345"); err != nil { + t.Fatalf("delete() error = %v", err) + } +} + +func TestClientDoesNotExposeProviderResponseOrToken(t *testing.T) { + t.Parallel() + const token = "top-secret-token" + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + _, _ = writer.Write([]byte("upstream diagnostic includes " + token)) + })) + t.Cleanup(server.Close) + + client := testClient(t, Linode, token, server.URL+"/v4/") + _, err := client.list(context.Background(), kindInstances) + if err == nil { + t.Fatal("list() unexpectedly succeeded") + } + for _, forbidden := range []string{token, "upstream diagnostic"} { + if strings.Contains(err.Error(), forbidden) { + t.Fatalf("list() error leaked %q: %v", forbidden, err) + } + } + if !retryable(err) { + t.Fatalf("HTTP 500 error was not retryable: %v", err) + } +} + +func TestClientRefusesCrossOriginRedirects(t *testing.T) { + t.Parallel() + const token = "redirect-secret" + var redirectedRequests atomic.Int32 + redirectTarget := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + redirectedRequests.Add(1) + if request.Header.Get("Authorization") != "" { + t.Error("redirect target received provider authorization") + } + writer.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(redirectTarget.Close) + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Location", redirectTarget.URL+"/v4/linode/instances") + writer.WriteHeader(http.StatusFound) + })) + t.Cleanup(server.Close) + + client := testClient(t, Linode, token, server.URL+"/v4/") + if _, err := client.list(context.Background(), kindInstances); err == nil { + t.Fatal("list() unexpectedly followed a provider redirect") + } else if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), redirectTarget.URL) { + t.Fatalf("redirect error exposed a token or redirect target: %v", err) + } + if got := redirectedRequests.Load(); got != 0 { + t.Fatalf("redirect target received %d requests", got) + } +} + +func TestClientRequiresHTTP200ForLists(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusPartialContent) + _, _ = writer.Write([]byte(`{"droplets":[],"meta":{"total":0}}`)) + })) + t.Cleanup(server.Close) + + client := testClient(t, DigitalOcean, "token", server.URL+"/v2/") + resources, err := client.list(context.Background(), kindDroplets) + if err == nil || resources != nil { + t.Fatalf("list() = (%+v, %v); want HTTP 206 refusal", resources, err) + } +} + +func TestDecodeResourcesRejectsMissingArrayAndUnsafeID(t *testing.T) { + t.Parallel() + for name, body := range map[string]string{ + "missing array": `{}`, + "unsafe id": `{"data":[{"id":"../../instance","tags":[]}],"page":1,"pages":1,"results":1}`, + } { + name, body := name, body + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := decodeResources(Linode, kindInstances, []byte(body)) + if err == nil { + t.Fatal("decodeResources() unexpectedly succeeded") + } + }) + } +} + +func TestDecodeResourcesAcceptsProviderStringAndNumericIDs(t *testing.T) { + t.Parallel() + tests := []struct { + provider Provider + kind resourceKind + body string + wantID string + }{ + {provider: DigitalOcean, kind: kindVolumes, body: `{"volumes":[{"id":"volume-123","tags":[]}],"meta":{"total":1}}`, wantID: "volume-123"}, + {provider: Linode, kind: kindInstances, body: `{"data":[{"id":12345,"tags":[]}],"page":1,"pages":1,"results":1}`, wantID: "12345"}, + } + for _, test := range tests { + resources, err := decodeResources(test.provider, test.kind, []byte(test.body)) + if err != nil { + t.Fatalf("decodeResources() error = %v", err) + } + if len(resources) != 1 || resources[0].ID != test.wantID { + t.Fatalf("resources = %+v; want ID %q", resources, test.wantID) + } + } +} + +func TestNewClientValidation(t *testing.T) { + t.Parallel() + if _, err := NewClient(DigitalOcean, ""); err == nil || !strings.Contains(err.Error(), "DIGITALOCEAN_TOKEN") { + t.Fatalf("NewClient() missing-token error = %v", err) + } + if _, err := NewClient(Provider("unknown"), "token"); err == nil { + t.Fatal("NewClient() accepted unknown provider") + } + if _, err := newClient(Linode, "token", "file:///tmp/provider", http.DefaultClient); err == nil { + t.Fatal("newClient() accepted non-HTTP endpoint") + } +} + +func TestDigitalOceanPaginationRejectsUnsafeNextLinks(t *testing.T) { + t.Parallel() + const token = "pagination-secret" + tests := []struct { + name string + next func(baseURL, attackerURL string) string + }{ + { + name: "malformed URL", + next: func(_, _ string) string { return "https://[::1" }, + }, + { + name: "relative URL", + next: func(_, _ string) string { return "/v2/droplets?tag_name=cloud-compose-smoke&per_page=200&page=2" }, + }, + { + name: "cross origin", + next: func(_, attackerURL string) string { + return attackerURL + "/v2/droplets?tag_name=cloud-compose-smoke&per_page=200&page=2" + }, + }, + { + name: "different API path", + next: func(baseURL, _ string) string { + return baseURL + "/v2/volumes?tag_name=cloud-compose-smoke&per_page=200&page=2" + }, + }, + { + name: "changed filter", + next: func(baseURL, _ string) string { + return baseURL + "/v2/droplets?per_page=200&page=2" + }, + }, + { + name: "added filter value", + next: func(baseURL, _ string) string { + return baseURL + "/v2/droplets?tag_name=cloud-compose-smoke&tag_name=foreign&per_page=200&page=2" + }, + }, + { + name: "unknown query key", + next: func(baseURL, _ string) string { + return baseURL + "/v2/droplets?tag_name=cloud-compose-smoke&per_page=200&page=2&cursor=unexpected" + }, + }, + { + name: "malformed query encoding", + next: func(baseURL, _ string) string { + return baseURL + "/v2/droplets?tag_name=cloud-compose-smoke&per_page=200&page=2;cursor=unexpected" + }, + }, + { + name: "cycle", + next: func(baseURL, _ string) string { + return baseURL + "/v2/droplets?tag_name=cloud-compose-smoke&per_page=200&page=1" + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var attackerRequests atomic.Int32 + attacker := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + attackerRequests.Add(1) + if request.Header.Get("Authorization") != "" { + t.Error("cross-origin pagination request exposed authorization header") + } + writer.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(attacker.Close) + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + next := test.next(server.URL, attacker.URL) + _, _ = fmt.Fprintf(writer, `{"droplets":[{"id":101,"tags":[]}],"links":{"pages":{"next":%q}},"meta":{"total":2}}`, next) + })) + t.Cleanup(server.Close) + + client := testClient(t, DigitalOcean, token, server.URL+"/v2/") + resources, err := client.list(context.Background(), kindDroplets) + if err == nil { + t.Fatal("list() unexpectedly accepted unsafe pagination") + } + if resources != nil { + t.Fatalf("list() returned partial resources: %+v", resources) + } + if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), attacker.URL) { + t.Fatalf("pagination error exposed a token or untrusted URL: %v", err) + } + if got := attackerRequests.Load(); got != 0 { + t.Fatalf("unsafe next-link origin received %d requests", got) + } + }) + } +} + +func TestDigitalOceanPaginationRejectsMalformedMetadata(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte(`{"droplets":[],"links":{"pages":{"next":42}},"meta":{"total":1}}`)) + })) + t.Cleanup(server.Close) + + client := testClient(t, DigitalOcean, "token", server.URL+"/v2/") + if resources, err := client.list(context.Background(), kindDroplets); err == nil || resources != nil { + t.Fatalf("list() = (%+v, %v); want malformed-metadata failure", resources, err) + } +} + +func TestDigitalOceanPaginationHasSafePageBound(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests.Add(1) + page := 1 + if value := request.URL.Query().Get("page"); value != "" { + parsed, err := strconv.Atoi(value) + if err != nil { + t.Errorf("page query = %q", value) + } + page = parsed + } + next := fmt.Sprintf("%s/v2/droplets?tag_name=cloud-compose-smoke&per_page=200&page=%d", server.URL, page+1) + _, _ = fmt.Fprintf(writer, `{"droplets":[],"links":{"pages":{"next":%q}},"meta":{"total":1}}`, next) + })) + t.Cleanup(server.Close) + + client := testClient(t, DigitalOcean, "token", server.URL+"/v2/") + resources, err := client.list(context.Background(), kindDroplets) + if err == nil || !strings.Contains(err.Error(), "safety limit") { + t.Fatalf("list() error = %v; want safety-limit failure", err) + } + if resources != nil { + t.Fatalf("list() returned a partial listing: %+v", resources) + } + if got := requests.Load(); got != maximumListPages { + t.Fatalf("pagination requests = %d; want %d", got, maximumListPages) + } +} + +func TestLinodePaginationRejectsMalformedOrUnboundedMetadata(t *testing.T) { + t.Parallel() + tests := map[string]string{ + "missing page": `{"data":[],"pages":1,"results":0}`, + "missing pages": `{"data":[],"page":1,"results":0}`, + "missing results": `{"data":[],"page":1,"pages":1}`, + "negative results": `{"data":[],"page":1,"pages":1,"results":-1}`, + "zero page": `{"data":[],"page":0,"pages":1,"results":0}`, + "zero pages": `{"data":[],"page":1,"pages":0,"results":0}`, + "page past end": `{"data":[],"page":2,"pages":1,"results":0}`, + "over bound": fmt.Sprintf(`{"data":[],"page":1,"pages":%d,"results":0}`, maximumListPages+1), + } + for name, response := range tests { + name, response := name, response + t.Run(name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte(response)) + })) + t.Cleanup(server.Close) + + client := testClient(t, Linode, "token", server.URL+"/v4/") + resources, err := client.list(context.Background(), kindInstances) + if err == nil || resources != nil { + t.Fatalf("list() = (%+v, %v); want strict metadata failure", resources, err) + } + }) + } +} + +func TestLinodePaginationRejectsChangingPageCount(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Query().Get("page") == "2" { + _, _ = writer.Write([]byte(`{"data":[],"page":2,"pages":3,"results":1}`)) + return + } + _, _ = writer.Write([]byte(`{"data":[{"id":101,"tags":[]}],"page":1,"pages":2,"results":1}`)) + })) + t.Cleanup(server.Close) + + client := testClient(t, Linode, "token", server.URL+"/v4/") + resources, err := client.list(context.Background(), kindInstances) + if err == nil || !strings.Contains(err.Error(), "changed its declared page count") { + t.Fatalf("list() error = %v; want changing-page-count failure", err) + } + if resources != nil { + t.Fatalf("list() returned a partial listing: %+v", resources) + } +} + +func TestPaginationRejectsMissingOrMismatchedResultCount(t *testing.T) { + t.Parallel() + tests := []struct { + name string + provider Provider + kind resourceKind + version string + body string + }{ + {name: "DigitalOcean missing total", provider: DigitalOcean, kind: kindDroplets, version: "/v2/", body: `{"droplets":[]}`}, + {name: "DigitalOcean negative total", provider: DigitalOcean, kind: kindDroplets, version: "/v2/", body: `{"droplets":[],"meta":{"total":-1}}`}, + {name: "DigitalOcean truncated terminal page", provider: DigitalOcean, kind: kindDroplets, version: "/v2/", body: `{"droplets":[{"id":101,"tags":[]}],"meta":{"total":2}}`}, + {name: "Linode missing results", provider: Linode, kind: kindInstances, version: "/v4/", body: `{"data":[],"page":1,"pages":1}`}, + {name: "Linode truncated terminal page", provider: Linode, kind: kindInstances, version: "/v4/", body: `{"data":[{"id":101,"tags":[]}],"page":1,"pages":1,"results":2}`}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte(test.body)) + })) + t.Cleanup(server.Close) + + client := testClient(t, test.provider, "token", server.URL+test.version) + resources, err := client.list(context.Background(), test.kind) + if err == nil || resources != nil { + t.Fatalf("list() = (%+v, %v); want declared-count failure", resources, err) + } + }) + } +} + +func TestPaginationRejectsChangingResultCount(t *testing.T) { + t.Parallel() + for _, provider := range []Provider{DigitalOcean, Linode} { + provider := provider + t.Run(string(provider), func(t *testing.T) { + t.Parallel() + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if provider == Linode { + if request.URL.Query().Get("page") == "2" { + _, _ = writer.Write([]byte(`{"data":[{"id":102,"tags":[]}],"page":2,"pages":2,"results":3}`)) + return + } + _, _ = writer.Write([]byte(`{"data":[{"id":101,"tags":[]}],"page":1,"pages":2,"results":2}`)) + return + } + if request.URL.Query().Get("page") == "2" { + _, _ = writer.Write([]byte(`{"droplets":[{"id":102,"tags":[]}],"meta":{"total":3}}`)) + return + } + next := server.URL + "/v2/droplets?tag_name=cloud-compose-smoke&per_page=200&page=2" + _, _ = fmt.Fprintf(writer, `{"droplets":[{"id":101,"tags":[]}],"links":{"pages":{"next":%q}},"meta":{"total":2}}`, next) + })) + t.Cleanup(server.Close) + + kind := kindDroplets + version := "/v2/" + if provider == Linode { + kind = kindInstances + version = "/v4/" + } + client := testClient(t, provider, "token", server.URL+version) + resources, err := client.list(context.Background(), kind) + if err == nil || !strings.Contains(err.Error(), "changed its declared result count") { + t.Fatalf("list() error = %v; want changing-result-count failure", err) + } + if resources != nil { + t.Fatalf("list() returned a partial listing: %+v", resources) + } + }) + } +} + +func testClient(t testing.TB, provider Provider, token, endpoint string) *Client { + t.Helper() + client, err := newClient(provider, token, endpoint, &http.Client{Timeout: time.Second}) + if err != nil { + t.Fatalf("newClient() error = %v", err) + } + return client +} + +func noSleep(ctx context.Context, _ time.Duration) error { + return context.Cause(ctx) +} diff --git a/internal/providercleanup/cleanup.go b/internal/providercleanup/cleanup.go new file mode 100644 index 0000000..a7e5572 --- /dev/null +++ b/internal/providercleanup/cleanup.go @@ -0,0 +1,424 @@ +package providercleanup + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "regexp" + "slices" + "strings" + "time" + + "github.com/libops/cloud-compose/internal/runnamespace" +) + +const ( + defaultDeleteAttempts = 12 + defaultDeleteRetryDelay = 10 * time.Second + defaultListAttempts = 6 + defaultListRetryDelay = 2 * time.Second + defaultVerifyAttempts = 6 + defaultVerifyRetryDelay = 10 * time.Second + defaultComputeSettleDelay = 10 * time.Second + maximumListRetryDelay = 30 * time.Second +) + +var templateSlugs = map[string]string{ + "archivesspace": "as", + "drupal": "dr", + "isle": "isle", + "ojs": "ojs", + "omeka-classic": "oc", + "omeka-s": "os", + "wp": "wp", +} + +// Scope identifies the smoke-test family whose resources are being removed. +type Scope string + +const ( + // ApplicationScope selects the provider-neutral app smoke fixtures. + ApplicationScope Scope = "application" + // ConfigManagementScope selects the raw-host Ansible or Salt smoke fixture. + ConfigManagementScope Scope = "config-management" +) + +// ParseScope validates a cleanup scope. +func ParseScope(value string) (Scope, error) { + scope := Scope(strings.ToLower(strings.TrimSpace(value))) + switch scope { + case ApplicationScope, ConfigManagementScope: + return scope, nil + default: + return "", fmt.Errorf("unsupported cleanup scope %q", value) + } +} + +// Config identifies one exact smoke-run resource set, or an explicitly +// authorized target-wide orphan sweep. +type Config struct { + Provider Provider + Scope Scope + Target string + RunID string + AllowAllRuns bool + DeleteAttempts int + DeleteRetryDelay time.Duration + ListAttempts int + ListRetryDelay time.Duration + VerifyAttempts int + VerifyRetryDelay time.Duration + ComputeSettleDelay time.Duration +} + +// Runner performs ordered and idempotent provider resource cleanup. +type Runner struct { + Client *Client + Logger *slog.Logger + Sleep func(context.Context, time.Duration) error +} + +// Sweep removes only resources whose provider metadata matches the requested +// target and run ownership. +func (r Runner) Sweep(ctx context.Context, config Config) error { + config, ownership, err := normalizeConfig(config) + if err != nil { + return err + } + if r.Client == nil { + return errors.New("provider cleanup client is required") + } + if r.Client.provider != config.Provider { + return errors.New("provider cleanup client does not match the requested provider") + } + logger := r.Logger + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + sleep := r.Sleep + if sleep == nil { + sleep = sleepContext + } + r.Logger = logger + r.Sleep = sleep + + failures := &failureList{} + for _, kind := range resourceKinds(config.Provider) { + resources, listErr := r.listWithRetry(ctx, config, kind) + if listErr != nil { + failures.add(fmt.Sprintf("list %s %s", config.Provider, kind), listErr) + continue + } + + deleted := 0 + for _, candidate := range resources { + if !ownership.owns(kind, candidate) { + continue + } + if deleteErr := r.deleteWithRetry(ctx, config, kind, candidate.ID); deleteErr != nil { + failures.add(fmt.Sprintf("delete %s %s %s", config.Provider, kind, candidate.ID), deleteErr) + continue + } + deleted++ + } + + if deleted > 0 && isComputeKind(kind) && config.ComputeSettleDelay > 0 { + if sleepErr := r.Sleep(ctx, config.ComputeSettleDelay); sleepErr != nil { + failures.add("wait for compute deletion to settle", sleepErr) + break + } + } + } + + if verifyErr := r.verify(ctx, config, ownership); verifyErr != nil { + failures.add("verify provider cleanup", verifyErr) + } + if failures.empty() { + logger.Info("provider smoke cleanup completed", + "provider", config.Provider, + "scope", config.Scope, + "target", config.Target, + "run_id", config.RunID, + ) + return nil + } + return failures +} + +func (r Runner) listWithRetry(ctx context.Context, config Config, kind resourceKind) ([]resource, error) { + delay := config.ListRetryDelay + var err error + for attempt := 1; attempt <= config.ListAttempts; attempt++ { + var resources []resource + resources, err = r.Client.list(ctx, kind) + if err == nil { + return resources, nil + } + if !retryable(err) || attempt == config.ListAttempts { + break + } + r.Logger.Warn("retrying provider resource list", + "provider", config.Provider, + "kind", kind, + "attempt", attempt+1, + "attempts", config.ListAttempts, + ) + if sleepErr := r.Sleep(ctx, delay); sleepErr != nil { + return nil, sleepErr + } + delay *= 2 + if delay > maximumListRetryDelay { + delay = maximumListRetryDelay + } + } + return nil, err +} + +func (r Runner) deleteWithRetry(ctx context.Context, config Config, kind resourceKind, id string) error { + var err error + for attempt := 1; attempt <= config.DeleteAttempts; attempt++ { + r.Logger.Info("deleting provider smoke resource", + "provider", config.Provider, + "kind", kind, + "id", id, + "attempt", attempt, + ) + err = r.Client.delete(ctx, kind, id) + if err == nil { + return nil + } + if !retryable(err) || attempt == config.DeleteAttempts { + break + } + if sleepErr := r.Sleep(ctx, config.DeleteRetryDelay); sleepErr != nil { + return sleepErr + } + } + return err +} + +func (r Runner) verify(ctx context.Context, config Config, ownership ownership) error { + var residuals []string + var err error + for attempt := 1; attempt <= config.VerifyAttempts; attempt++ { + residuals, err = r.residuals(ctx, config, ownership) + if err == nil && len(residuals) == 0 { + return nil + } + if attempt == config.VerifyAttempts { + break + } + r.Logger.Warn("provider cleanup verification found residual resources", + "provider", config.Provider, + "target", config.Target, + "count", len(residuals), + "attempt", attempt, + ) + if sleepErr := r.Sleep(ctx, config.VerifyRetryDelay); sleepErr != nil { + return sleepErr + } + } + if err != nil { + return err + } + return fmt.Errorf("provider cleanup left matching resources: %s", strings.Join(residuals, ", ")) +} + +func (r Runner) residuals(ctx context.Context, config Config, ownership ownership) ([]string, error) { + var residuals []string + for _, kind := range resourceKinds(config.Provider) { + resources, err := r.listWithRetry(ctx, config, kind) + if err != nil { + return nil, err + } + for _, candidate := range resources { + if ownership.owns(kind, candidate) { + residuals = append(residuals, fmt.Sprintf("%s/%s", kind, candidate.ID)) + } + } + } + slices.Sort(residuals) + return residuals, nil +} + +type ownership struct { + provider Provider + requiredTags []string + firewallPattern *regexp.Regexp +} + +func (o ownership) owns(kind resourceKind, candidate resource) bool { + if o.provider == DigitalOcean && kind == kindFirewalls { + return o.firewallPattern != nil && o.firewallPattern.MatchString(candidate.Name) + } + for _, required := range o.requiredTags { + if !slices.Contains(candidate.Tags, required) { + return false + } + } + return true +} + +func normalizeConfig(config Config) (Config, ownership, error) { + provider, err := ParseProvider(string(config.Provider)) + if err != nil { + return Config{}, ownership{}, err + } + config.Provider = provider + if config.Scope == "" { + config.Scope = ApplicationScope + } + scope, err := ParseScope(string(config.Scope)) + if err != nil { + return Config{}, ownership{}, err + } + config.Scope = scope + config.Target = strings.ToLower(strings.TrimSpace(config.Target)) + config.RunID = strings.TrimSpace(config.RunID) + if config.RunID != "" && config.AllowAllRuns { + return Config{}, ownership{}, errors.New("run ID and all-run cleanup are mutually exclusive") + } + if config.RunID == "" && !config.AllowAllRuns { + return Config{}, ownership{}, errors.New("run ID is required unless all-run cleanup is explicitly enabled") + } + if config.RunID != "" { + if _, err := runnamespace.Encode(config.RunID); err != nil { + return Config{}, ownership{}, fmt.Errorf("invalid run ID: %w", err) + } + } + + resourceOwnership, err := buildOwnership(config) + if err != nil { + return Config{}, ownership{}, err + } + applyDefaults(&config) + if err := validateRetryConfig(config); err != nil { + return Config{}, ownership{}, err + } + return config, resourceOwnership, nil +} + +func buildOwnership(config Config) (ownership, error) { + requiredTags := []string{"cloud-compose-smoke"} + resourceOwnership := ownership{provider: config.Provider} + switch config.Scope { + case ApplicationScope: + providerPrefix := string(config.Provider) + "-" + if !strings.HasPrefix(config.Target, providerPrefix) { + return ownership{}, fmt.Errorf("application target %q does not belong to provider %s", config.Target, config.Provider) + } + template := strings.TrimPrefix(config.Target, providerPrefix) + templateSlug, found := templateSlugs[template] + if !found { + return ownership{}, fmt.Errorf("unsupported application smoke target %q", config.Target) + } + requiredTags = append(requiredTags, config.Target) + if config.Provider == DigitalOcean { + prefix := "cc-do-" + templateSlug + "-" + var namePattern string + if config.RunID != "" { + namePattern = "^" + regexp.QuoteMeta(prefix+config.RunID) + `-[0-9a-f]{6}-cloud-compose$` + } else { + namePattern = "^" + regexp.QuoteMeta(prefix) + `(?:[a-z0-9-]+-)?[0-9a-f]{6}-cloud-compose$` + } + resourceOwnership.firewallPattern = regexp.MustCompile(namePattern) + } + case ConfigManagementScope: + if config.Provider != Linode { + return ownership{}, errors.New("config-management smoke cleanup is supported only on Linode") + } + if config.Target != "ansible-drupal" && config.Target != "salt-drupal" { + return ownership{}, fmt.Errorf("unsupported config-management smoke target %q", config.Target) + } + requiredTags = append(requiredTags, "config-management-smoke", "config-management-"+config.Target) + } + if config.RunID != "" { + requiredTags = append(requiredTags, "gha-run-"+config.RunID) + } + resourceOwnership.requiredTags = requiredTags + return resourceOwnership, nil +} + +func applyDefaults(config *Config) { + if config.DeleteAttempts == 0 { + config.DeleteAttempts = defaultDeleteAttempts + } + if config.DeleteRetryDelay == 0 { + config.DeleteRetryDelay = defaultDeleteRetryDelay + } + if config.ListAttempts == 0 { + config.ListAttempts = defaultListAttempts + } + if config.ListRetryDelay == 0 { + config.ListRetryDelay = defaultListRetryDelay + } + if config.VerifyAttempts == 0 { + config.VerifyAttempts = defaultVerifyAttempts + } + if config.VerifyRetryDelay == 0 { + config.VerifyRetryDelay = defaultVerifyRetryDelay + } + if config.ComputeSettleDelay == 0 { + config.ComputeSettleDelay = defaultComputeSettleDelay + } +} + +func validateRetryConfig(config Config) error { + if config.DeleteAttempts < 1 || config.ListAttempts < 1 || config.VerifyAttempts < 1 { + return errors.New("provider cleanup attempt counts must be positive") + } + if config.DeleteRetryDelay < 0 || config.ListRetryDelay < 0 || config.VerifyRetryDelay < 0 || config.ComputeSettleDelay < 0 { + return errors.New("provider cleanup retry delays cannot be negative") + } + return nil +} + +func resourceKinds(provider Provider) []resourceKind { + if provider == DigitalOcean { + return []resourceKind{kindFirewalls, kindDroplets, kindVolumes} + } + return []resourceKind{kindFirewalls, kindInstances, kindVolumes} +} + +func isComputeKind(kind resourceKind) bool { + return kind == kindDroplets || kind == kindInstances +} + +func sleepContext(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +type failureList struct { + errors []error +} + +func (f *failureList) add(operation string, err error) { + if err != nil { + f.errors = append(f.errors, fmt.Errorf("%s: %w", operation, err)) + } +} + +func (f *failureList) empty() bool { + return len(f.errors) == 0 +} + +func (f *failureList) Error() string { + messages := make([]string, 0, len(f.errors)) + for _, err := range f.errors { + messages = append(messages, err.Error()) + } + return "provider smoke cleanup failed: " + strings.Join(messages, "; ") +} + +func (f *failureList) Unwrap() []error { + return f.errors +} diff --git a/internal/providercleanup/cleanup_test.go b/internal/providercleanup/cleanup_test.go new file mode 100644 index 0000000..97b2798 --- /dev/null +++ b/internal/providercleanup/cleanup_test.go @@ -0,0 +1,998 @@ +package providercleanup + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestDigitalOceanSweepDeletesOnlyExactRunResources(t *testing.T) { + t.Parallel() + api := newFakeProviderAPI(t, DigitalOcean, "do-secret", map[resourceKind][]fakeResource{ + kindFirewalls: { + {ID: "fw-owned", Name: "cc-do-isle-123456789-a1b2c3-cloud-compose"}, + {ID: "fw-other-run", Name: "cc-do-isle-123456780-a1b2c3-cloud-compose"}, + {ID: "fw-wrong-shape", Name: "cc-do-isle-123456789-cloud-compose"}, + }, + kindDroplets: { + {ID: "101", Tags: []string{"cloud-compose-smoke", "digitalocean-isle", "gha-run-123456789"}}, + {ID: "102", Tags: []string{"cloud-compose-smoke", "digitalocean-isle", "gha-run-123456780"}}, + {ID: "103", Tags: []string{"digitalocean-isle", "gha-run-123456789"}}, + }, + kindVolumes: { + {ID: "vol-owned", Tags: []string{"cloud-compose-smoke", "digitalocean-isle", "gha-run-123456789"}}, + {ID: "vol-other-target", Tags: []string{"cloud-compose-smoke", "digitalocean-wp", "gha-run-123456789"}}, + }, + }) + + runner := Runner{Client: api.Client(t), Sleep: noSleep} + err := runner.Sweep(context.Background(), fastConfig(Config{ + Provider: DigitalOcean, + Target: "digitalocean-isle", + RunID: "123456789", + })) + if err != nil { + t.Fatalf("Sweep() error = %v", err) + } + + want := []string{"firewalls/fw-owned", "droplets/101", "volumes/vol-owned"} + if got := api.Deletions(); !slices.Equal(got, want) { + t.Fatalf("deletions = %q; want %q", got, want) + } + api.AssertTokenNeverAppearedInPath(t) +} + +func TestLinodeSweepSharesExactOwnershipAcrossApplicationAndConfigManagement(t *testing.T) { + t.Parallel() + tests := []struct { + name string + config Config + tags []string + wrongTags []string + wantDelete []string + }{ + { + name: "application", + config: Config{ + Provider: Linode, + Target: "linode-wp", + RunID: "123456789", + }, + tags: []string{"cloud-compose-smoke", "linode-wp", "gha-run-123456789"}, + wrongTags: []string{"cloud-compose-smoke", "linode-wp", "gha-run-123456780"}, + wantDelete: []string{"firewalls/201", "instances/301", "volumes/401"}, + }, + { + name: "config management", + config: Config{ + Provider: Linode, + Scope: ConfigManagementScope, + Target: "ansible-drupal", + RunID: "123456789", + }, + tags: []string{"cloud-compose-smoke", "config-management-smoke", "config-management-ansible-drupal", "gha-run-123456789"}, + wrongTags: []string{"cloud-compose-smoke", "config-management-smoke", "config-management-salt-drupal", "gha-run-123456789"}, + wantDelete: []string{"firewalls/201", "instances/301", "volumes/401"}, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + api := newFakeProviderAPI(t, Linode, "linode-secret", map[resourceKind][]fakeResource{ + kindFirewalls: { + {ID: "201", Tags: test.tags}, + {ID: "202", Tags: test.wrongTags}, + }, + kindInstances: { + {ID: "301", Tags: test.tags}, + {ID: "302", Tags: test.wrongTags}, + }, + kindVolumes: { + {ID: "401", Tags: test.tags}, + {ID: "402", Tags: test.wrongTags}, + }, + }) + + runner := Runner{Client: api.Client(t), Sleep: noSleep} + if err := runner.Sweep(context.Background(), fastConfig(test.config)); err != nil { + t.Fatalf("Sweep() error = %v", err) + } + if got := api.Deletions(); !slices.Equal(got, test.wantDelete) { + t.Fatalf("deletions = %q; want %q", got, test.wantDelete) + } + }) + } +} + +func TestSweepDeletesOwnedResourceFoundOnlyOnSecondPage(t *testing.T) { + t.Parallel() + tests := []struct { + name string + provider Provider + kind resourceKind + target string + }{ + {name: "DigitalOcean", provider: DigitalOcean, kind: kindDroplets, target: "digitalocean-isle"}, + {name: "Linode", provider: Linode, kind: kindInstances, target: "linode-wp"}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + const ownedID = "owned-page-2" + var mu sync.Mutex + deleted := false + pageTwoRequests := 0 + deletions := []string{} + var server *httptest.Server + + server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + mu.Lock() + defer mu.Unlock() + if got := request.Header.Get("Authorization"); got != "Bearer token" { + t.Errorf("Authorization header = %q", got) + } + + prefix := "/v2/" + if test.provider == Linode { + prefix = "/v4/" + } + list, _ := listPath(test.provider, test.kind) + list = strings.Split(list, "?")[0] + deletePath, _ := deletePrefix(test.provider, test.kind) + if request.Method == http.MethodDelete && request.URL.Path == prefix+deletePath+ownedID { + deleted = true + deletions = append(deletions, string(test.kind)+"/"+ownedID) + writer.WriteHeader(http.StatusNoContent) + return + } + if request.Method != http.MethodGet { + http.NotFound(writer, request) + return + } + + kind, _, ok := paginatedTestRoute(test.provider, request.URL.Path) + if !ok { + http.NotFound(writer, request) + return + } + if request.URL.Path != prefix+list { + writeEmptyProviderPage(writer, test.provider, kind) + return + } + + page := request.URL.Query().Get("page") + if page == "2" { + pageTwoRequests++ + writeProviderPage(writer, test.provider, kind, 2, 2, 2, []fakeResource{{ + ID: ownedID, + Tags: []string{"cloud-compose-smoke", test.target, "gha-run-123456789"}, + }}, "") + return + } + foreign := []fakeResource{{ID: "foreign-page-1", Tags: []string{"cloud-compose-smoke", test.target, "gha-run-999999999"}}} + if deleted { + writeProviderPage(writer, test.provider, kind, 1, 1, 1, foreign, "") + return + } + next := "" + if test.provider == DigitalOcean { + next = server.URL + request.URL.Path + "?" + request.URL.Query().Encode() + "&page=2" + } + writeProviderPage(writer, test.provider, kind, 1, 2, 2, foreign, next) + })) + t.Cleanup(server.Close) + + version := "/v2/" + if test.provider == Linode { + version = "/v4/" + } + runner := Runner{Client: testClient(t, test.provider, "token", server.URL+version), Sleep: noSleep} + if err := runner.Sweep(context.Background(), fastConfig(Config{ + Provider: test.provider, + Target: test.target, + RunID: "123456789", + })); err != nil { + t.Fatalf("Sweep() error = %v", err) + } + + mu.Lock() + defer mu.Unlock() + if !slices.Equal(deletions, []string{string(test.kind) + "/" + ownedID}) { + t.Fatalf("deletions = %q", deletions) + } + if pageTwoRequests == 0 { + t.Fatal("Sweep() never requested the page containing its owned resource") + } + }) + } +} + +func TestSweepFailsClosedBeforeDeletingFromPartialPageSet(t *testing.T) { + t.Parallel() + var deleteRequests atomic.Int32 + attacker := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "" { + t.Error("unsafe pagination target received provider authorization") + } + writer.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(attacker.Close) + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodDelete { + deleteRequests.Add(1) + writer.WriteHeader(http.StatusNoContent) + return + } + kind, _, ok := paginatedTestRoute(DigitalOcean, request.URL.Path) + if !ok { + http.NotFound(writer, request) + return + } + if kind != kindDroplets { + writeEmptyProviderPage(writer, DigitalOcean, kind) + return + } + writeProviderPage(writer, DigitalOcean, kind, 1, 2, 2, []fakeResource{{ + ID: "owned-page-1", + Tags: []string{"cloud-compose-smoke", "digitalocean-isle", "gha-run-123456789"}, + }}, attacker.URL+"/v2/droplets?tag_name=cloud-compose-smoke&per_page=200&page=2") + })) + t.Cleanup(server.Close) + + runner := Runner{Client: testClient(t, DigitalOcean, "token", server.URL+"/v2/"), Sleep: noSleep} + err := runner.Sweep(context.Background(), fastConfig(Config{ + Provider: DigitalOcean, + Target: "digitalocean-isle", + RunID: "123456789", + })) + if err == nil { + t.Fatal("Sweep() silently accepted a partial provider listing") + } + if got := deleteRequests.Load(); got != 0 { + t.Fatalf("Sweep() issued %d deletes from a partial provider listing", got) + } +} + +func TestSweepFailsClosedBeforeDeletingFromTruncatedTerminalList(t *testing.T) { + t.Parallel() + var deleteRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodDelete { + deleteRequests.Add(1) + writer.WriteHeader(http.StatusNoContent) + return + } + kind, _, ok := paginatedTestRoute(DigitalOcean, request.URL.Path) + if !ok { + http.NotFound(writer, request) + return + } + if kind != kindDroplets { + writeEmptyProviderPage(writer, DigitalOcean, kind) + return + } + writeProviderPage(writer, DigitalOcean, kind, 1, 1, 2, []fakeResource{{ + ID: "owned-visible-resource", + Tags: []string{"cloud-compose-smoke", "digitalocean-isle", "gha-run-123456789"}, + }}, "") + })) + t.Cleanup(server.Close) + + runner := Runner{Client: testClient(t, DigitalOcean, "token", server.URL+"/v2/"), Sleep: noSleep} + err := runner.Sweep(context.Background(), fastConfig(Config{ + Provider: DigitalOcean, + Target: "digitalocean-isle", + RunID: "123456789", + })) + if err == nil { + t.Fatal("Sweep() silently accepted a truncated terminal listing") + } + if got := deleteRequests.Load(); got != 0 { + t.Fatalf("Sweep() issued %d deletes from a truncated terminal listing", got) + } +} + +func TestSweepIsIdempotentWhenDeleteReportsNotFound(t *testing.T) { + t.Parallel() + api := newFakeProviderAPI(t, Linode, "token", map[resourceKind][]fakeResource{ + kindInstances: { + {ID: "301", Tags: []string{"cloud-compose-smoke", "linode-wp", "gha-run-123456789"}, DeleteStatus: http.StatusNotFound}, + }, + }) + + runner := Runner{Client: api.Client(t), Sleep: noSleep} + if err := runner.Sweep(context.Background(), fastConfig(Config{ + Provider: Linode, + Target: "linode-wp", + RunID: "123456789", + })); err != nil { + t.Fatalf("Sweep() error = %v", err) + } + if got := api.Deletions(); !slices.Equal(got, []string{"instances/301"}) { + t.Fatalf("deletions = %q", got) + } +} + +func TestSweepRetriesTransientResponsesWithoutLoggingSecrets(t *testing.T) { + t.Parallel() + const token = "never-log-this-token" + api := newFakeProviderAPI(t, Linode, token, map[resourceKind][]fakeResource{}) + api.FailLists(kindFirewalls, 1) + var logs bytes.Buffer + runner := Runner{ + Client: api.Client(t), + Logger: slog.New(slog.NewTextHandler(&logs, nil)), + Sleep: noSleep, + } + if err := runner.Sweep(context.Background(), fastConfig(Config{ + Provider: Linode, + Target: "linode-wp", + RunID: "123456789", + })); err != nil { + t.Fatalf("Sweep() error = %v", err) + } + if strings.Contains(logs.String(), token) { + t.Fatalf("logs leaked provider token: %s", logs.String()) + } + if api.ListCalls(kindFirewalls) < 2 { + t.Fatal("Sweep() did not retry transient list failure") + } +} + +func TestSweepRetriesTransientDeleteConflict(t *testing.T) { + t.Parallel() + api := newFakeProviderAPI(t, Linode, "token", map[resourceKind][]fakeResource{ + kindInstances: { + {ID: "301", Tags: []string{"cloud-compose-smoke", "linode-wp", "gha-run-123456789"}}, + }, + }) + api.FailDeletes(kindInstances, "301", 1) + runner := Runner{Client: api.Client(t), Sleep: noSleep} + if err := runner.Sweep(context.Background(), fastConfig(Config{ + Provider: Linode, + Target: "linode-wp", + RunID: "123456789", + })); err != nil { + t.Fatalf("Sweep() error = %v", err) + } + if got := api.Deletions(); !slices.Equal(got, []string{"instances/301", "instances/301"}) { + t.Fatalf("delete attempts = %q", got) + } +} + +func TestListRetryPolicyUsesActualHTTPResponses(t *testing.T) { + t.Parallel() + tests := []struct { + name string + statuses []int + attempts int + wantErr bool + wantRequests int + wantDelays []time.Duration + }{ + { + name: "transient statuses recover with capped exponential backoff", + statuses: []int{http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusServiceUnavailable, http.StatusOK}, + attempts: 6, + wantRequests: 6, + wantDelays: []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second, 30 * time.Second}, + }, + { + name: "persistent transient status is bounded", + statuses: []int{http.StatusServiceUnavailable}, + attempts: 3, + wantErr: true, + wantRequests: 3, + wantDelays: []time.Duration{2 * time.Second, 4 * time.Second}, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + server, requests := newStatusSequenceServer(t, test.statuses, `{"data":[],"page":1,"pages":1,"results":0}`) + client := testClient(t, Linode, "token", server.URL+"/v4/") + delays := []time.Duration{} + runner := Runner{ + Client: client, + Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), + Sleep: func(ctx context.Context, delay time.Duration) error { + delays = append(delays, delay) + return context.Cause(ctx) + }, + } + _, err := runner.listWithRetry(context.Background(), Config{ + Provider: Linode, + ListAttempts: test.attempts, + ListRetryDelay: 2 * time.Second, + }, kindInstances) + if (err != nil) != test.wantErr { + t.Fatalf("listWithRetry() error = %v; wantErr %t", err, test.wantErr) + } + if got := int(requests.Load()); got != test.wantRequests { + t.Fatalf("requests = %d; want %d", got, test.wantRequests) + } + if !slices.Equal(delays, test.wantDelays) { + t.Fatalf("backoff delays = %v; want %v", delays, test.wantDelays) + } + }) + } +} + +func TestListRetryPolicyFailsFastOnPermanentHTTPResponses(t *testing.T) { + t.Parallel() + for _, status := range []int{ + http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + http.StatusGone, + http.StatusConflict, + http.StatusLocked, + http.StatusFound, + } { + status := status + t.Run(http.StatusText(status), func(t *testing.T) { + t.Parallel() + server, requests := newStatusSequenceServer(t, []int{status}, `{"data":[],"page":1,"pages":1,"results":0}`) + runner := Runner{ + Client: testClient(t, Linode, "token", server.URL+"/v4/"), + Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), + Sleep: func(_ context.Context, _ time.Duration) error { + t.Fatal("permanent list failure unexpectedly slept for a retry") + return nil + }, + } + if _, err := runner.listWithRetry(context.Background(), Config{ + Provider: Linode, + ListAttempts: 6, + ListRetryDelay: time.Second, + }, kindInstances); err == nil { + t.Fatal("listWithRetry() unexpectedly accepted a permanent HTTP failure") + } + if got := requests.Load(); got != 1 { + t.Fatalf("requests = %d; want 1", got) + } + }) + } +} + +func TestListRetryPolicyRecoversFromNetworkFailure(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + if requests.Add(1) == 1 { + hijacker, ok := writer.(http.Hijacker) + if !ok { + t.Error("httptest response writer does not support connection hijacking") + return + } + connection, _, err := hijacker.Hijack() + if err != nil { + t.Errorf("hijack connection: %v", err) + return + } + _ = connection.Close() + return + } + _, _ = writer.Write([]byte(`{"data":[],"page":1,"pages":1,"results":0}`)) + })) + t.Cleanup(server.Close) + + delays := []time.Duration{} + runner := Runner{ + Client: testClient(t, Linode, "token", server.URL+"/v4/"), + Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), + Sleep: func(ctx context.Context, delay time.Duration) error { + delays = append(delays, delay) + return context.Cause(ctx) + }, + } + if _, err := runner.listWithRetry(context.Background(), Config{ + Provider: Linode, + ListAttempts: 2, + ListRetryDelay: 3 * time.Second, + }, kindInstances); err != nil { + t.Fatalf("listWithRetry() error = %v", err) + } + if requests.Load() != 2 || !slices.Equal(delays, []time.Duration{3 * time.Second}) { + t.Fatalf("network retry requests = %d, delays = %v", requests.Load(), delays) + } +} + +func TestDeleteRetryPolicyUsesActualHTTPResponses(t *testing.T) { + t.Parallel() + tests := []struct { + name string + statuses []int + attempts int + wantErr bool + wantRequests int + wantSleeps int + }{ + {name: "transient statuses", statuses: []int{408, 425, 429, 500, 204}, attempts: 5, wantRequests: 5, wantSleeps: 4}, + {name: "dependency conflicts", statuses: []int{409, 423, 204}, attempts: 3, wantRequests: 3, wantSleeps: 2}, + {name: "not found is idempotent", statuses: []int{404}, attempts: 3, wantRequests: 1}, + {name: "gone is idempotent", statuses: []int{410}, attempts: 3, wantRequests: 1}, + {name: "bad request fails fast", statuses: []int{400}, attempts: 3, wantErr: true, wantRequests: 1}, + {name: "unauthorized fails fast", statuses: []int{401}, attempts: 3, wantErr: true, wantRequests: 1}, + {name: "forbidden fails fast", statuses: []int{403}, attempts: 3, wantErr: true, wantRequests: 1}, + {name: "persistent transient is bounded", statuses: []int{503}, attempts: 3, wantErr: true, wantRequests: 3, wantSleeps: 2}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + server, requests := newStatusSequenceServer(t, test.statuses, "") + sleeps := 0 + runner := Runner{ + Client: testClient(t, Linode, "token", server.URL+"/v4/"), + Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), + Sleep: func(ctx context.Context, delay time.Duration) error { + if delay != 7*time.Second { + t.Errorf("delete retry delay = %s; want 7s", delay) + } + sleeps++ + return context.Cause(ctx) + }, + } + err := runner.deleteWithRetry(context.Background(), Config{ + Provider: Linode, + DeleteAttempts: test.attempts, + DeleteRetryDelay: 7 * time.Second, + }, kindInstances, "301") + if (err != nil) != test.wantErr { + t.Fatalf("deleteWithRetry() error = %v; wantErr %t", err, test.wantErr) + } + if got := int(requests.Load()); got != test.wantRequests { + t.Fatalf("requests = %d; want %d", got, test.wantRequests) + } + if sleeps != test.wantSleeps { + t.Fatalf("retry sleeps = %d; want %d", sleeps, test.wantSleeps) + } + }) + } +} + +func TestDeleteRetryPolicyRecoversFromNetworkFailure(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + if requests.Add(1) == 1 { + hijacker, ok := writer.(http.Hijacker) + if !ok { + t.Error("httptest response writer does not support connection hijacking") + return + } + connection, _, err := hijacker.Hijack() + if err != nil { + t.Errorf("hijack connection: %v", err) + return + } + _ = connection.Close() + return + } + writer.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + + sleeps := 0 + runner := Runner{ + Client: testClient(t, Linode, "token", server.URL+"/v4/"), + Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), + Sleep: func(ctx context.Context, delay time.Duration) error { + if delay != 5*time.Second { + t.Errorf("delete retry delay = %s; want 5s", delay) + } + sleeps++ + return context.Cause(ctx) + }, + } + if err := runner.deleteWithRetry(context.Background(), Config{ + Provider: Linode, + DeleteAttempts: 2, + DeleteRetryDelay: 5 * time.Second, + }, kindInstances, "301"); err != nil { + t.Fatalf("deleteWithRetry() error = %v", err) + } + if requests.Load() != 2 || sleeps != 1 { + t.Fatalf("network retry requests = %d, sleeps = %d", requests.Load(), sleeps) + } +} + +func TestSweepFailsClosedWithoutExactOwnership(t *testing.T) { + t.Parallel() + client := &Client{provider: Linode} + runner := Runner{Client: client, Sleep: noSleep} + tests := []struct { + name string + config Config + }{ + {name: "missing run", config: Config{Provider: Linode, Target: "linode-wp"}}, + {name: "malformed run", config: Config{Provider: Linode, Target: "linode-wp", RunID: "run-123"}}, + {name: "conflicting scope", config: Config{Provider: Linode, Target: "linode-wp", RunID: "123", AllowAllRuns: true}}, + {name: "wrong provider target", config: Config{Provider: Linode, Target: "digitalocean-isle", RunID: "123"}}, + {name: "wrong config provider", config: Config{Provider: DigitalOcean, Scope: ConfigManagementScope, Target: "ansible-drupal", RunID: "123"}}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if err := runner.Sweep(context.Background(), test.config); err == nil { + t.Fatal("Sweep() accepted unsafe ownership") + } + }) + } +} + +func TestExplicitAllRunsSweepRemainsTargetScoped(t *testing.T) { + t.Parallel() + api := newFakeProviderAPI(t, Linode, "token", map[resourceKind][]fakeResource{ + kindInstances: { + {ID: "301", Tags: []string{"cloud-compose-smoke", "linode-wp", "gha-run-111"}}, + {ID: "302", Tags: []string{"cloud-compose-smoke", "linode-wp", "gha-run-222"}}, + {ID: "303", Tags: []string{"cloud-compose-smoke", "linode-drupal", "gha-run-111"}}, + }, + }) + runner := Runner{Client: api.Client(t), Sleep: noSleep} + if err := runner.Sweep(context.Background(), fastConfig(Config{ + Provider: Linode, + Target: "linode-wp", + AllowAllRuns: true, + })); err != nil { + t.Fatalf("Sweep() error = %v", err) + } + if got := api.Deletions(); !slices.Equal(got, []string{"instances/301", "instances/302"}) { + t.Fatalf("deletions = %q", got) + } +} + +func TestSweepResidualVerificationIsRetriedAndBounded(t *testing.T) { + t.Parallel() + owned := fakeResource{ID: "201", Name: "cc-do-isle-123456789-a1b2c3-cloud-compose"} + tests := []struct { + name string + retainedListings int + verifyAttempts int + wantErr bool + wantSleeps int + }{ + {name: "eventual consistency clears", retainedListings: 2, verifyAttempts: 3, wantSleeps: 2}, + {name: "persistent residual fails", retainedListings: 20, verifyAttempts: 3, wantErr: true, wantSleeps: 2}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + api := newFakeProviderAPI(t, DigitalOcean, "token", map[resourceKind][]fakeResource{ + kindFirewalls: {owned}, + }) + api.RetainAfterDelete(kindFirewalls, owned.ID, test.retainedListings) + delays := []time.Duration{} + runner := Runner{ + Client: api.Client(t), + Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), + Sleep: func(ctx context.Context, delay time.Duration) error { + delays = append(delays, delay) + return context.Cause(ctx) + }, + } + config := fastConfig(Config{ + Provider: DigitalOcean, + Target: "digitalocean-isle", + RunID: "123456789", + }) + config.VerifyAttempts = test.verifyAttempts + config.VerifyRetryDelay = 9 * time.Second + err := runner.Sweep(context.Background(), config) + if (err != nil) != test.wantErr { + t.Fatalf("Sweep() error = %v; wantErr %t", err, test.wantErr) + } + if got := api.Deletions(); !slices.Equal(got, []string{"firewalls/201"}) { + t.Fatalf("deletions = %q", got) + } + if len(delays) != test.wantSleeps { + t.Fatalf("verification sleeps = %v; want %d", delays, test.wantSleeps) + } + for _, delay := range delays { + if delay != 9*time.Second { + t.Errorf("verification delay = %s; want 9s", delay) + } + } + }) + } +} + +func fastConfig(config Config) Config { + config.DeleteAttempts = 2 + config.DeleteRetryDelay = time.Nanosecond + config.ListAttempts = 2 + config.ListRetryDelay = time.Nanosecond + config.VerifyAttempts = 2 + config.VerifyRetryDelay = time.Nanosecond + config.ComputeSettleDelay = time.Nanosecond + return config +} + +func paginatedTestRoute(provider Provider, requestPath string) (resourceKind, string, bool) { + prefix := "/v2/" + if provider == Linode { + prefix = "/v4/" + } + path := strings.TrimPrefix(requestPath, prefix) + for _, kind := range resourceKinds(provider) { + list, _ := listPath(provider, kind) + list = strings.Split(list, "?")[0] + if path == list { + return kind, "", true + } + deletePath, _ := deletePrefix(provider, kind) + if strings.HasPrefix(path, deletePath) && !strings.Contains(strings.TrimPrefix(path, deletePath), "/") { + return kind, strings.TrimPrefix(path, deletePath), true + } + } + return "", "", false +} + +func writeEmptyProviderPage(writer http.ResponseWriter, provider Provider, kind resourceKind) { + writeProviderPage(writer, provider, kind, 1, 1, 0, nil, "") +} + +func writeProviderPage(writer http.ResponseWriter, provider Provider, kind resourceKind, page, pages, total int, resources []fakeResource, next string) { + items := make([]string, 0, len(resources)) + for _, candidate := range resources { + tags := make([]string, 0, len(candidate.Tags)) + for _, tag := range candidate.Tags { + tags = append(tags, fmt.Sprintf("%q", tag)) + } + nameField := "name" + if provider == Linode { + nameField = "label" + } + items = append(items, fmt.Sprintf(`{"id":%q,%q:%q,"tags":[%s]}`, candidate.ID, nameField, candidate.Name, strings.Join(tags, ","))) + } + key := string(kind) + if provider == Linode { + key = "data" + _, _ = fmt.Fprintf(writer, `{"%s":[%s],"page":%d,"pages":%d,"results":%d}`, key, strings.Join(items, ","), page, pages, total) + return + } + if next == "" { + _, _ = fmt.Fprintf(writer, `{"%s":[%s],"meta":{"total":%d}}`, key, strings.Join(items, ","), total) + return + } + _, _ = fmt.Fprintf(writer, `{"%s":[%s],"links":{"pages":{"next":%q}},"meta":{"total":%d}}`, key, strings.Join(items, ","), next, total) +} + +func newStatusSequenceServer(t testing.TB, statuses []int, successBody string) (*httptest.Server, *atomic.Int32) { + t.Helper() + if len(statuses) == 0 { + t.Fatal("status sequence must not be empty") + } + requests := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if got := request.Header.Get("Authorization"); got != "Bearer token" { + t.Errorf("Authorization header = %q", got) + } + index := int(requests.Add(1)) - 1 + if index >= len(statuses) { + index = len(statuses) - 1 + } + status := statuses[index] + writer.WriteHeader(status) + if status >= http.StatusOK && status < http.StatusMultipleChoices && successBody != "" { + _, _ = writer.Write([]byte(successBody)) + } + })) + t.Cleanup(server.Close) + return server, requests +} + +type fakeResource struct { + ID string + Name string + Tags []string + DeleteStatus int +} + +type fakeProviderAPI struct { + testing testing.TB + provider Provider + token string + server *httptest.Server + mu sync.Mutex + resources map[resourceKind][]fakeResource + deleted map[string]bool + deletions []string + listCalls map[resourceKind]int + failingLists map[resourceKind]int + failingDeletes map[string]int + retainDeleted map[string]int + paths []string +} + +func newFakeProviderAPI(t testing.TB, provider Provider, token string, resources map[resourceKind][]fakeResource) *fakeProviderAPI { + t.Helper() + api := &fakeProviderAPI{ + testing: t, + provider: provider, + token: token, + resources: resources, + deleted: map[string]bool{}, + listCalls: map[resourceKind]int{}, + failingLists: map[resourceKind]int{}, + failingDeletes: map[string]int{}, + retainDeleted: map[string]int{}, + } + api.server = httptest.NewServer(http.HandlerFunc(api.ServeHTTP)) + t.Cleanup(api.server.Close) + return api +} + +func (a *fakeProviderAPI) Client(t testing.TB) *Client { + t.Helper() + version := "/v2/" + if a.provider == Linode { + version = "/v4/" + } + return testClient(t, a.provider, a.token, a.server.URL+version) +} + +func (a *fakeProviderAPI) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + a.paths = append(a.paths, request.URL.RequestURI()) + if got := request.Header.Get("Authorization"); got != "Bearer "+a.token { + a.testing.Errorf("Authorization header = %q", got) + } + + kind, id, ok := a.route(request.URL.Path) + if !ok { + http.NotFound(writer, request) + return + } + if request.Method == http.MethodDelete { + for _, candidate := range a.resources[kind] { + if candidate.ID != id { + continue + } + key := string(kind) + "/" + id + a.deletions = append(a.deletions, key) + if a.failingDeletes[key] > 0 { + a.failingDeletes[key]-- + writer.WriteHeader(http.StatusConflict) + return + } + a.deleted[key] = true + status := candidate.DeleteStatus + if status == 0 { + status = http.StatusNoContent + } + writer.WriteHeader(status) + return + } + http.NotFound(writer, request) + return + } + if request.Method != http.MethodGet { + writer.WriteHeader(http.StatusMethodNotAllowed) + return + } + a.listCalls[kind]++ + if a.failingLists[kind] > 0 { + a.failingLists[kind]-- + writer.WriteHeader(http.StatusInternalServerError) + _, _ = writer.Write([]byte("provider failure containing " + a.token)) + return + } + + visible := make([]fakeResource, 0, len(a.resources[kind])) + for _, candidate := range a.resources[kind] { + key := string(kind) + "/" + candidate.ID + if a.deleted[key] { + if a.retainDeleted[key] <= 0 { + continue + } + a.retainDeleted[key]-- + } + visible = append(visible, candidate) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(writer, a.response(kind, visible)) +} + +func (a *fakeProviderAPI) route(path string) (resourceKind, string, bool) { + prefix := "/v2/" + if a.provider == Linode { + prefix = "/v4/" + } + path = strings.TrimPrefix(path, prefix) + for _, kind := range resourceKinds(a.provider) { + list, _ := listPath(a.provider, kind) + list = strings.Split(list, "?")[0] + if path == list { + return kind, "", true + } + deletePath, _ := deletePrefix(a.provider, kind) + if strings.HasPrefix(path, deletePath) && !strings.Contains(strings.TrimPrefix(path, deletePath), "/") { + return kind, strings.TrimPrefix(path, deletePath), true + } + } + return "", "", false +} + +func (a *fakeProviderAPI) response(kind resourceKind, resources []fakeResource) string { + items := make([]string, 0, len(resources)) + for _, candidate := range resources { + tags := make([]string, 0, len(candidate.Tags)) + for _, tag := range candidate.Tags { + tags = append(tags, fmt.Sprintf("%q", tag)) + } + nameField := "name" + if a.provider == Linode { + nameField = "label" + } + items = append(items, fmt.Sprintf(`{"id":%q,%q:%q,"tags":[%s]}`, candidate.ID, nameField, candidate.Name, strings.Join(tags, ","))) + } + key := string(kind) + if a.provider == Linode { + key = "data" + return fmt.Sprintf(`{"%s":[%s],"page":1,"pages":1,"results":%d}`, key, strings.Join(items, ","), len(items)) + } + return fmt.Sprintf(`{"%s":[%s],"meta":{"total":%d}}`, key, strings.Join(items, ","), len(items)) +} + +func (a *fakeProviderAPI) Deletions() []string { + a.mu.Lock() + defer a.mu.Unlock() + return slices.Clone(a.deletions) +} + +func (a *fakeProviderAPI) FailLists(kind resourceKind, count int) { + a.mu.Lock() + defer a.mu.Unlock() + a.failingLists[kind] = count +} + +func (a *fakeProviderAPI) FailDeletes(kind resourceKind, id string, count int) { + a.mu.Lock() + defer a.mu.Unlock() + a.failingDeletes[string(kind)+"/"+id] = count +} + +func (a *fakeProviderAPI) RetainAfterDelete(kind resourceKind, id string, listCount int) { + a.mu.Lock() + defer a.mu.Unlock() + a.retainDeleted[string(kind)+"/"+id] = listCount +} + +func (a *fakeProviderAPI) ListCalls(kind resourceKind) int { + a.mu.Lock() + defer a.mu.Unlock() + return a.listCalls[kind] +} + +func (a *fakeProviderAPI) AssertTokenNeverAppearedInPath(t testing.TB) { + t.Helper() + a.mu.Lock() + defer a.mu.Unlock() + for _, path := range a.paths { + if strings.Contains(path, a.token) { + t.Fatalf("provider token appeared in request path %q", path) + } + } +} diff --git a/main.tf b/main.tf index ac4a580..74fa2e2 100644 --- a/main.tf +++ b/main.tf @@ -6,18 +6,10 @@ terraform { source = "hashicorp/cloudinit" version = "~> 2.3" } - digitalocean = { - source = "digitalocean/digitalocean" - version = "~> 2.0" - } google = { source = "hashicorp/google" version = "~> 7.0" } - linode = { - source = "linode/linode" - version = "~> 4.0" - } time = { source = "hashicorp/time" version = "~> 0.14" @@ -78,7 +70,7 @@ locals { vault = merge(local.runtime.vault, { auth_method = ( local.runtime.vault.auth_method == "auto" - ? (local.cloud_provider == "gcp" ? "gcp-iam" : "consumer-managed") + ? "gcp-iam" : local.runtime.vault.auth_method ) }) @@ -94,32 +86,6 @@ locals { gcp_power_management = var.gcp.power_management gcp_rollout = var.gcp.rollout - linux_runtime = { - rootfs = local.runtime.rootfs - rootfs_archive_url = local.runtime.rootfs_archive_url - rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256 - users = local.runtime.users - compose = local.compose - sitectl = local.sitectl - docker = local.docker - managed_runtime = { - enabled = local.managed.enabled - internal_services_enabled = local.managed.internal_services_enabled - internal_services_auto_update = local.managed.internal_services_auto_update - artifacts = module.managed_artifacts.artifacts - } - vault = { - addr = local.vault.addr - namespace = local.vault.namespace - role = local.vault.role - agent_enabled = local.vault.agent_enabled - auth_method = local.vault.auth_method - agent_token_path = local.vault.agent_token_path - agent_additional_config = local.vault.agent_additional_config - agent_templates = local.vault.agent_templates - } - extra_env = local.runtime.extra_env - } } module "managed_artifacts" { @@ -129,7 +95,8 @@ module "managed_artifacts" { } module "gcp" { - count = local.cloud_provider == "gcp" ? 1 : 0 + # Keep the historical indexed address so existing GCP state remains stable. + count = 1 source = "./modules/gcp" name = var.name @@ -227,21 +194,3 @@ module "gcp" { vault_agent_templates = local.vault.agent_templates vault_agent_additional_config = local.vault.agent_additional_config } - -module "digitalocean" { - count = local.cloud_provider == "digitalocean" ? 1 : 0 - source = "./modules/digitalocean" - - name = var.name - digitalocean = var.digitalocean - runtime = local.linux_runtime -} - -module "linode" { - count = local.cloud_provider == "linode" ? 1 : 0 - source = "./modules/linode" - - name = var.name - linode = var.linode - runtime = local.linux_runtime -} diff --git a/modules/gcp/runtime_contracts.tftest.hcl b/modules/gcp/runtime_contracts.tftest.hcl index 111f8be..5d3e1d2 100644 --- a/modules/gcp/runtime_contracts.tftest.hcl +++ b/modules/gcp/runtime_contracts.tftest.hcl @@ -162,7 +162,8 @@ run "normalizes_minimal_compose_project" { project_number = "123456789" compose_projects = { wordpress = { - docker_compose_repo = "https://github.com/libops/wp.git" + docker_compose_repo = "https://github.com/libops/wp.git" + docker_compose_branch = "v1.0.0" } } sitectl_verify_args = ["--route", "/"] @@ -172,10 +173,10 @@ run "normalizes_minimal_compose_project" { condition = ( local.compose_projects.wordpress.name == "wordpress" && local.compose_projects.wordpress.docker_compose_repo == "https://github.com/libops/wp.git" && - local.compose_projects.wordpress.docker_compose_branch == "main" && + local.compose_projects.wordpress.docker_compose_branch == "v1.0.0" && local.compose_projects.wordpress.repo_path == "libops/wp.git" && - local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/main" && - local.compose_projects.wordpress.compose_project_name == "libops-wp-main" && + local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/v1.0.0" && + local.compose_projects.wordpress.compose_project_name == "libops-wp-v1-0-0" && local.compose_projects.wordpress.ingress_port == 80 && local.compose_projects.wordpress.ingress.letsencrypt == var.sitectl_ingress.letsencrypt && local.compose_projects.wordpress.sitectl_context_name == "wordpress" && diff --git a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl index a66ba28..4135776 100644 --- a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl +++ b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl @@ -38,7 +38,8 @@ run "normalizes_minimal_compose_project" { volumes_device = "/dev/test-volumes" compose_projects = { wordpress = { - docker_compose_repo = "https://github.com/libops/wp.git" + docker_compose_repo = "https://github.com/libops/wp.git" + docker_compose_branch = "v1.0.0" } } sitectl_verify_args = ["--route", "/"] @@ -48,10 +49,10 @@ run "normalizes_minimal_compose_project" { condition = ( local.compose_projects.wordpress.name == "wordpress" && local.compose_projects.wordpress.docker_compose_repo == "https://github.com/libops/wp.git" && - local.compose_projects.wordpress.docker_compose_branch == "main" && + local.compose_projects.wordpress.docker_compose_branch == "v1.0.0" && local.compose_projects.wordpress.repo_path == "libops/wp.git" && - local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/main" && - local.compose_projects.wordpress.compose_project_name == "libops-wp-main" && + local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/v1.0.0" && + local.compose_projects.wordpress.compose_project_name == "libops-wp-v1-0-0" && local.compose_projects.wordpress.ingress_port == 80 && local.compose_projects.wordpress.ingress.letsencrypt == var.sitectl_ingress.letsencrypt && local.compose_projects.wordpress.sitectl_context_name == "wordpress" && diff --git a/outputs.tf b/outputs.tf index 75e78d6..2a5668d 100644 --- a/outputs.tf +++ b/outputs.tf @@ -1,6 +1,6 @@ output "cloud_provider" { value = local.cloud_provider - description = "Selected cloud provider." + description = "Root entrypoint cloud provider (gcp)." } output "template" { @@ -9,111 +9,71 @@ output "template" { } output "instance" { - value = try( - module.gcp[0].instance, - module.digitalocean[0].instance, - module.linode[0].instance, - null, - ) - description = "Selected provider VM instance details." + value = module.gcp[0].instance + description = "GCP VM instance details." } output "instance_id" { - value = try( - module.gcp[0].instance_id, - module.digitalocean[0].instance.id, - module.linode[0].instance.id, - null, - ) - description = "Selected provider VM instance ID." + value = module.gcp[0].instance_id + description = "GCP VM instance ID." } output "external_ip" { - value = try( - module.gcp[0].external_ip, - module.digitalocean[0].instance.ipv4, - module.linode[0].instance.public_ipv4, - null, - ) - description = "Selected provider VM public IPv4 address." + value = module.gcp[0].external_ip + description = "GCP VM public IPv4 address." } output "internal_ip" { - value = try( - module.gcp[0].internal_ip, - module.digitalocean[0].instance.private_ip, - module.linode[0].instance.private_ip, - null, - ) - description = "Selected provider VM private IPv4 address." + value = module.gcp[0].internal_ip + description = "GCP VM private IPv4 address." } output "network" { - value = try(module.gcp[0].network, null) - description = "Resolved GCP network and regional subnetwork, or null for non-GCP providers." + value = module.gcp[0].network + description = "Resolved GCP network and regional subnetwork." } output "volumes" { - value = try( - module.gcp[0].volumes, - module.digitalocean[0].volumes, - module.linode[0].volumes, - null, - ) - description = "Selected provider persistent application-data and Docker-volume details." + value = module.gcp[0].volumes + description = "GCP persistent application-data and Docker-volume details." } output "serviceGsa" { - value = try(module.gcp[0].serviceGsa, null) + value = module.gcp[0].serviceGsa description = "The Google Service Account internal services run as." } output "appGsa" { - value = try(module.gcp[0].appGsa, null) + value = module.gcp[0].appGsa description = "The Google Service Account the app can use for app-scoped auth." } output "urls" { - value = try(module.gcp[0].urls, {}) + value = module.gcp[0].urls description = "Cloud Run ingress URLs by region." } output "backend" { - value = try(module.gcp[0].backend, null) + value = module.gcp[0].backend description = "Backend service ID for attaching Cloud Run ingress to an external HTTPS load balancer." } output "rollout" { - value = try(module.gcp[0].rollout, null) + value = module.gcp[0].rollout description = "Optional rollout API endpoint details." } output "compose_projects" { - value = try( - module.gcp[0].compose_projects, - module.digitalocean[0].compose_projects, - module.linode[0].compose_projects, - {}, - ) + value = module.gcp[0].compose_projects description = "Normalized compose project manifest." } output "primary_compose_project" { - value = try( - module.gcp[0].primary_compose_project, - module.digitalocean[0].primary_compose_project, - module.linode[0].primary_compose_project, - null, - ) + value = module.gcp[0].primary_compose_project description = "Normalized primary compose project." } output "sitectl_package_versions" { - value = try( - module.gcp[0].sitectl_package_versions, - module.digitalocean[0].sitectl_package_versions, - module.linode[0].sitectl_package_versions, - {}, - ) + value = module.gcp[0].sitectl_package_versions description = "Effective release selector for every installed sitectl package; values may be exact tags or latest." } diff --git a/providers/do/outputs.tf b/providers/do/outputs.tf index 86ea9c2..acf0588 100644 --- a/providers/do/outputs.tf +++ b/providers/do/outputs.tf @@ -28,6 +28,11 @@ output "internal_ip" { description = "Selected provider VM private IPv4 address." } +output "network" { + value = null + description = "GCP network details; always null for DigitalOcean." +} + output "volumes" { value = module.digitalocean.volumes description = "Selected provider persistent application-data and Docker-volume details." diff --git a/providers/do/template_versions.tftest.hcl b/providers/do/template_versions.tftest.hcl index 45d2cf7..103be07 100644 --- a/providers/do/template_versions.tftest.hcl +++ b/providers/do/template_versions.tftest.hcl @@ -46,7 +46,7 @@ run "explicit_core_only_package_set_disables_template_plugins" { assert { condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == { - sitectl = "v0.39.0" + sitectl = "v0.40.0" } error_message = "The DigitalOcean entrypoint must preserve an explicit core-only package set." } diff --git a/providers/gcp/template_versions.tftest.hcl b/providers/gcp/template_versions.tftest.hcl index a6e4c69..ab65579 100644 --- a/providers/gcp/template_versions.tftest.hcl +++ b/providers/gcp/template_versions.tftest.hcl @@ -57,7 +57,7 @@ run "explicit_core_only_package_set_disables_template_plugins" { assert { condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == { - sitectl = "v0.39.0" + sitectl = "v0.40.0" } error_message = "The GCP entrypoint must preserve an explicit core-only package set." } diff --git a/providers/linode/outputs.tf b/providers/linode/outputs.tf index 2f08930..d018854 100644 --- a/providers/linode/outputs.tf +++ b/providers/linode/outputs.tf @@ -28,6 +28,11 @@ output "internal_ip" { description = "Selected provider VM private IPv4 address." } +output "network" { + value = null + description = "GCP network details; always null for Linode." +} + output "volumes" { value = module.linode.volumes description = "Selected provider persistent application-data and Docker-volume details." diff --git a/providers/linode/template_versions.tftest.hcl b/providers/linode/template_versions.tftest.hcl index a61e428..76f04a4 100644 --- a/providers/linode/template_versions.tftest.hcl +++ b/providers/linode/template_versions.tftest.hcl @@ -56,7 +56,7 @@ run "explicit_core_only_package_set_disables_template_plugins" { assert { condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == { - sitectl = "v0.39.0" + sitectl = "v0.40.0" } error_message = "The Linode entrypoint must preserve an explicit core-only package set." } diff --git a/rootfs/home/cloud-compose/compose-apps.sh b/rootfs/home/cloud-compose/compose-apps.sh index df23bf8..f830a76 100644 --- a/rootfs/home/cloud-compose/compose-apps.sh +++ b/rootfs/home/cloud-compose/compose-apps.sh @@ -831,7 +831,7 @@ clone_or_update_compose_app() { git merge --ff-only FETCH_HEAD || { popd >/dev/null; return 1; } fi local_head="$(git rev-parse --verify HEAD)" || { popd >/dev/null; return 1; } - fetched_head="$(git rev-parse --verify FETCH_HEAD)" || { popd >/dev/null; return 1; } + fetched_head="$(git rev-parse --verify 'FETCH_HEAD^{commit}')" || { popd >/dev/null; return 1; } if [[ "$local_head" != "$fetched_head" ]]; then echo "Compose checkout did not converge exactly to fetched origin/$DOCKER_COMPOSE_BRANCH" >&2 popd >/dev/null diff --git a/runtime_contracts.tftest.hcl b/runtime_contracts.tftest.hcl index 8f10f86..e149839 100644 --- a/runtime_contracts.tftest.hcl +++ b/runtime_contracts.tftest.hcl @@ -1,5 +1,4 @@ mock_provider "cloudinit" {} -mock_provider "digitalocean" {} mock_provider "google" { mock_data "google_project" { defaults = { @@ -7,26 +6,22 @@ mock_provider "google" { } } } -mock_provider "linode" {} mock_provider "time" {} -run "provider_neutral_vault_auth_defaults_follow_provider" { +run "root_rejects_non_gcp_provider_selection" { command = plan variables { name = "root-contract" cloud_provider = "digitalocean" template = "wp" - runtime = { - rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" - rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + gcp = { + project_id = "test-project" + project_number = "123456789" } } - assert { - condition = local.vault.auth_method == "consumer-managed" - error_message = "The provider-neutral Vault default must resolve to consumer-managed outside GCP." - } + expect_failures = [var.cloud_provider] } run "gcp_vault_auth_default_uses_gcp_iam" { @@ -43,7 +38,7 @@ run "gcp_vault_auth_default_uses_gcp_iam" { assert { condition = local.vault.auth_method == "gcp-iam" - error_message = "The provider-neutral Vault default must resolve to gcp-iam on GCP." + error_message = "The root GCP entrypoint must resolve the automatic Vault method to gcp-iam." } } diff --git a/salt/cloud-compose/README.md b/salt/cloud-compose/README.md index 70aa573..a05e5d4 100644 --- a/salt/cloud-compose/README.md +++ b/salt/cloud-compose/README.md @@ -111,7 +111,7 @@ cloud_compose: sitectl: environment: production package_versions: - sitectl-isle: v0.18.0 + sitectl-isle: v0.19.0 ``` Apply: diff --git a/salt/cloud-compose/init.sls b/salt/cloud-compose/init.sls index 435ad33..16a0ff5 100644 --- a/salt/cloud-compose/init.sls +++ b/salt/cloud-compose/init.sls @@ -1,4 +1,20 @@ {% import_json "templates/apps.json" as app_registry %} +{% macro normalize_compose_project_name(repo_path, ref) -%} +{%- set candidate = (repo_path | replace('.git', '')) ~ '-' ~ ref -%} +{%- set normalized = namespace(value='', separator=False) -%} +{%- for character in candidate | lower -%} +{%- if character in 'abcdefghijklmnopqrstuvwxyz0123456789' -%} +{%- if normalized.separator and normalized.value -%} +{%- set normalized.value = normalized.value ~ '-' -%} +{%- endif -%} +{%- set normalized.value = normalized.value ~ character -%} +{%- set normalized.separator = False -%} +{%- elif normalized.value -%} +{%- set normalized.separator = True -%} +{%- endif -%} +{%- endfor -%} +{{- normalized.value -}} +{%- endmacro %} {% set invalid_runtime_inputs = [] %} {% set raw_cc = salt['pillar.get']('cloud_compose', {}) %} {% if raw_cc is mapping %} @@ -246,7 +262,7 @@ {% set repo_path_source = repo | replace('https://github.com/', '') | replace('http://github.com/', '') | replace('git@github.com:', '') %} {% set repo_path = compose.get('repo_path') or (repo_path_source.strip('/') if repo_path_source else name) %} {% set project_dir = compose.get('project_dir') or data_dir ~ '/' ~ repo_path ~ '/' ~ branch %} -{% set compose_project_name = compose.get('compose_project_name') or ((repo_path ~ '-' ~ branch) | lower | replace('.git', '') | replace('/', '-') | replace('_', '-')) %} +{% set compose_project_name = compose.get('compose_project_name') or normalize_compose_project_name(repo_path, branch) %} {% set raw_explicit_projects = compose.get('projects', {}) %} {% if raw_explicit_projects is mapping %} {% set explicit_projects = raw_explicit_projects %} @@ -327,7 +343,7 @@ 'docker_compose_branch': app_branch, 'repo_path': app_repo_path, 'project_dir': app.get('project_dir') or data_dir ~ '/' ~ app_repo_path ~ '/' ~ app_branch, - 'compose_project_name': app.get('compose_project_name') or ((app_repo_path ~ '-' ~ app_branch) | lower | replace('.git', '') | replace('/', '-') | replace('_', '-')), + 'compose_project_name': app.get('compose_project_name') or normalize_compose_project_name(app_repo_path, app_branch), 'ingress_port': app_ingress_port, 'ingress': app_ingress, 'sitectl_context_name': app.get('sitectl_context_name', app_name), diff --git a/template_versions.tftest.hcl b/template_versions.tftest.hcl index d7b3376..8752f40 100644 --- a/template_versions.tftest.hcl +++ b/template_versions.tftest.hcl @@ -1,5 +1,4 @@ mock_provider "cloudinit" {} -mock_provider "digitalocean" {} mock_provider "google" { mock_data "google_project" { defaults = { @@ -7,7 +6,6 @@ mock_provider "google" { } } } -mock_provider "linode" {} mock_provider "time" {} run "template_uses_released_package_set" { @@ -15,8 +13,12 @@ run "template_uses_released_package_set" { variables { name = "template-versions" - cloud_provider = "digitalocean" + cloud_provider = "gcp" template = "isle" + gcp = { + project_id = "test-project" + project_number = "123456789" + } runtime = { rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -25,9 +27,9 @@ run "template_uses_released_package_set" { assert { condition = local.sitectl.package_versions == { - sitectl = "v0.39.0" - sitectl-drupal = "v0.11.0" - sitectl-isle = "v0.18.0" + sitectl = "v0.40.0" + sitectl-drupal = "v0.12.0" + sitectl-isle = "v0.19.0" } error_message = "The Isle template must select its reviewed core and plugin release set by default." } @@ -38,15 +40,19 @@ run "explicit_package_versions_override_template_defaults" { variables { name = "template-versions" - cloud_provider = "digitalocean" + cloud_provider = "gcp" template = "isle" + gcp = { + project_id = "test-project" + project_number = "123456789" + } runtime = { rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" sitectl = { package_versions = { - sitectl = "v0.40.0" - sitectl-isle = "v0.19.0" + sitectl = "v0.40.1" + sitectl-isle = "v0.19.1" } } } @@ -54,9 +60,9 @@ run "explicit_package_versions_override_template_defaults" { assert { condition = local.sitectl.package_versions == { - sitectl = "v0.40.0" - sitectl-drupal = "v0.11.0" - sitectl-isle = "v0.19.0" + sitectl = "v0.40.1" + sitectl-drupal = "v0.12.0" + sitectl-isle = "v0.19.1" } error_message = "Explicit per-package selectors must override only their matching template defaults." } @@ -67,15 +73,19 @@ run "custom_package_set_filters_template_versions" { variables { name = "template-versions" - cloud_provider = "digitalocean" + cloud_provider = "gcp" template = "isle" + gcp = { + project_id = "test-project" + project_number = "123456789" + } runtime = { rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" sitectl = { packages = ["sitectl", "sitectl-wp"] package_versions = { - sitectl-wp = "v0.5.1" + sitectl-wp = "v0.6.1" } } } @@ -83,8 +93,8 @@ run "custom_package_set_filters_template_versions" { assert { condition = local.sitectl.package_versions == { - sitectl = "v0.39.0" - sitectl-wp = "v0.5.1" + sitectl = "v0.40.0" + sitectl-wp = "v0.6.1" } error_message = "Template selectors for packages omitted by a custom package set must not reach the runtime." } @@ -95,8 +105,12 @@ run "explicit_core_only_package_set_disables_template_plugins" { variables { name = "template-versions" - cloud_provider = "digitalocean" + cloud_provider = "gcp" template = "isle" + gcp = { + project_id = "test-project" + project_number = "123456789" + } runtime = { rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -108,7 +122,7 @@ run "explicit_core_only_package_set_disables_template_plugins" { assert { condition = local.sitectl.packages == tolist(["sitectl"]) && local.sitectl.package_versions == { - sitectl = "v0.39.0" + sitectl = "v0.40.0" } error_message = "An explicit core-only package set must not be mistaken for an omitted template package selection." } diff --git a/templates/apps.json b/templates/apps.json index 788d965..3dbaef6 100644 --- a/templates/apps.json +++ b/templates/apps.json @@ -5,79 +5,79 @@ "plugin": "core", "packages": ["sitectl"], "package_versions": { - "sitectl": "v0.39.0" + "sitectl": "v0.40.0" } }, "templates": { "archivesspace": { "repo": "https://github.com/libops/archivesspace.git", - "branch": "main", + "branch": "v1.0.0", "plugin": "archivesspace", "packages": ["sitectl", "sitectl-archivesspace"], "package_versions": { - "sitectl": "v0.39.0", - "sitectl-archivesspace": "v0.6.0" + "sitectl": "v0.40.0", + "sitectl-archivesspace": "v0.7.0" } }, "ojs": { "repo": "https://github.com/libops/ojs.git", - "branch": "main", + "branch": "v1.0.0", "plugin": "ojs", "packages": ["sitectl", "sitectl-ojs"], "package_versions": { - "sitectl": "v0.39.0", - "sitectl-ojs": "v0.6.0" + "sitectl": "v0.40.0", + "sitectl-ojs": "v0.7.0" } }, "isle": { "repo": "https://github.com/libops/isle", - "branch": "main", + "branch": "v1.0.0", "plugin": "isle", "packages": ["sitectl", "sitectl-drupal", "sitectl-isle"], "package_versions": { - "sitectl": "v0.39.0", - "sitectl-drupal": "v0.11.0", - "sitectl-isle": "v0.18.0" + "sitectl": "v0.40.0", + "sitectl-drupal": "v0.12.0", + "sitectl-isle": "v0.19.0" } }, "drupal": { "repo": "https://github.com/libops/drupal.git", - "branch": "main", + "branch": "v1.0.0", "plugin": "drupal", "packages": ["sitectl", "sitectl-drupal"], "package_versions": { - "sitectl": "v0.39.0", - "sitectl-drupal": "v0.11.0" + "sitectl": "v0.40.0", + "sitectl-drupal": "v0.12.0" } }, "wp": { "repo": "https://github.com/libops/wp.git", - "branch": "main", + "branch": "v1.0.0", "plugin": "wp", "packages": ["sitectl", "sitectl-wp"], "package_versions": { - "sitectl": "v0.39.0", - "sitectl-wp": "v0.5.0" + "sitectl": "v0.40.0", + "sitectl-wp": "v0.6.0" } }, "omeka-s": { "repo": "https://github.com/libops/omeka-s.git", - "branch": "main", + "branch": "v1.0.0", "plugin": "omeka-s", "packages": ["sitectl", "sitectl-omeka-s"], "package_versions": { - "sitectl": "v0.39.0", - "sitectl-omeka-s": "v0.6.0" + "sitectl": "v0.40.0", + "sitectl-omeka-s": "v0.7.0" } }, "omeka-classic": { "repo": "https://github.com/libops/omeka-classic.git", - "branch": "main", + "branch": "v1.0.0", "plugin": "omeka-classic", "packages": ["sitectl", "sitectl-omeka-classic"], "package_versions": { - "sitectl": "v0.39.0", - "sitectl-omeka-classic": "v0.6.0" + "sitectl": "v0.40.0", + "sitectl-omeka-classic": "v0.7.0" } } } diff --git a/tests/config-management/ansible/smoke.yml b/tests/config-management/ansible/smoke.yml index 6c42e97..79ef976 100644 --- a/tests/config-management/ansible/smoke.yml +++ b/tests/config-management/ansible/smoke.yml @@ -118,6 +118,7 @@ - '(cloud_compose_application_env_file.content | b64decode | from_json).PORT == "9999"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].docker_compose_repo == "https://github.com/libops/isle"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].project_dir == "/mnt/disks/data/libops/isle/main"' + - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].compose_project_name == "libops-isle-v1-0-0"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].ingress.domain == "isle.example.edu"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].sitectl_plugin == "isle"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].init_commands == []' diff --git a/tests/smoke/gcp-upgrade/main.tf b/tests/smoke/gcp-upgrade/main.tf index 8a0babd..0748389 100644 --- a/tests/smoke/gcp-upgrade/main.tf +++ b/tests/smoke/gcp-upgrade/main.tf @@ -34,7 +34,9 @@ locals { } module "app" { - source = "../../../providers/gcp" + # Exercise the GCP-only compatibility root across the real pre-1.0 state + # boundary. New deployments should use ../../../providers/gcp instead. + source = "../../.." name = var.name template = "wp" diff --git a/variables.tf b/variables.tf index c57ba85..56be3f8 100644 --- a/variables.tf +++ b/variables.tf @@ -6,11 +6,11 @@ variable "name" { variable "cloud_provider" { type = string default = "gcp" - description = "Cloud provider to deploy to. Supported values are gcp, digitalocean, and linode." + description = "Compatibility selector for the root GCP entrypoint. Use providers/do or providers/linode for other clouds." validation { - condition = contains(["gcp", "digitalocean", "linode"], lower(trimspace(var.cloud_provider))) - error_message = "cloud_provider must be gcp, digitalocean, or linode." + condition = lower(trimspace(var.cloud_provider)) == "gcp" + error_message = "The root module supports only gcp; use //providers/do or //providers/linode for another cloud." } } @@ -190,88 +190,6 @@ variable "gcp" { } } -variable "digitalocean" { - description = "DigitalOcean infrastructure settings. droplet.backups covers only the Droplet boot disk; attached application and Docker volumes require a separate offsite backup policy." - type = object({ - region = optional(string, "tor1") - tags = optional(list(string), ["cloud-compose"]) - - droplet = optional(object({ - size = optional(string, "s-2vcpu-4gb") - image = optional(string, "ubuntu-24-04-x64") - ssh_keys = optional(list(string), []) - vpc_uuid = optional(string, null) - monitoring = optional(bool, true) - ipv6 = optional(bool, true) - backups = optional(bool, false) - }), {}) - - ssh = optional(object({ - cloud_compose_keys = optional(list(string), []) - users = optional(map(list(string)), {}) - }), {}) - - volumes = optional(object({ - data_size_gb = optional(number, 50) - docker_volumes_size_gb = optional(number, 100) - }), {}) - - firewall = optional(object({ - enabled = optional(bool, true) - ssh_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"]) - web_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"]) - }), {}) - }) - default = {} -} - -variable "linode" { - description = "Linode infrastructure settings. instance.backups_enabled covers only the instance disk; attached application and Docker Block Storage volumes require a separate offsite backup policy." - type = object({ - region = optional(string, "us-east") - tags = optional(list(string), ["cloud-compose"]) - - instance = optional(object({ - type = optional(string, "g6-standard-2") - image = optional(string, "linode/ubuntu22.04") - authorized_keys = optional(list(string), []) - authorized_users = optional(list(string), []) - root_pass = optional(string, null) - private_ip = optional(bool, true) - backups_enabled = optional(bool, false) - watchdog_enabled = optional(bool, true) - }), {}) - - ssh = optional(object({ - cloud_compose_keys = optional(list(string), []) - users = optional(map(list(string)), {}) - }), {}) - - volumes = optional(object({ - data_size_gb = optional(number, 50) - docker_volumes_size_gb = optional(number, 100) - }), {}) - - firewall = optional(object({ - enabled = optional(bool, true) - ssh_source_ipv4 = optional(list(string), ["0.0.0.0/0"]) - ssh_source_ipv6 = optional(list(string), ["::/0"]) - web_source_ipv4 = optional(list(string), ["0.0.0.0/0"]) - web_source_ipv6 = optional(list(string), ["::/0"]) - }), {}) - }) - default = {} - - validation { - condition = alltrue([ - for key in var.linode.instance.authorized_keys : trimspace(key) != "" && !can(regex("[\\r\\n]", key)) - ]) && alltrue([ - for username in var.linode.instance.authorized_users : can(regex("^[A-Za-z0-9._-]+$", username)) - ]) - error_message = "linode.instance authorized_keys must be non-empty single-line values and authorized_users must contain safe single-line usernames." - } -} - variable "runtime" { description = "Provider-neutral compose/runtime settings." type = object({