From 32fd79e9cc3e8e78c8b56e371012b14f6f83aa03 Mon Sep 17 00:00:00 2001 From: "H.E. Pennypacker" <115990865+pennypacker-he@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:25:44 +0000 Subject: [PATCH 1/8] [minor] Add multi-provider compose runtime --- .github/workflows/cloud-smoke-cleanup.yml | 68 ++ .github/workflows/cloud-smoke.yml | 168 ++++ .github/workflows/docs.yml | 40 + .gitignore | 4 + Makefile | 88 +- README.md | 130 +-- ci/cloud-smoke.sh | 784 +++++++++++++++ ci/install-sitectl-apt.sh | 20 + ci/terraform-validate.sh | 120 +++ docs/Dockerfile | 12 + docs/css/custom.css | 53 + docs/examples.md | 58 ++ docs/index.md | 28 + docs/js/mermaid-init.mjs | 136 +++ docs/mkdocs.yml | 69 ++ docs/non-gcp-providers.md | 75 ++ docs/runtime-contracts.md | 85 ++ examples/binpack/main.tf | 32 + examples/binpack/variables.tf | 12 + examples/digitalocean/main.tf | 38 + examples/digitalocean/variables.tf | 47 + examples/drupal/main.tf | 29 + examples/drupal/variables.tf | 42 + examples/isle/main.tf | 35 + examples/isle/variables.tf | 62 ++ examples/linode/main.tf | 41 + examples/linode/variables.tf | 66 ++ examples/ojs/main.tf | 90 +- examples/ojs/variables.tf | 6 + examples/wp/main.tf | 29 + examples/wp/variables.tf | 42 + main.tf | 805 +++------------- modules/digitalocean/main.tf | 173 ++++ modules/digitalocean/outputs.tf | 36 + modules/digitalocean/variables.tf | 180 ++++ modules/digitalocean/versions.tf | 10 + modules/gcp/main.tf | 904 ++++++++++++++++++ modules/gcp/outputs.tf | 79 ++ modules/gcp/variables.tf | 605 ++++++++++++ modules/linode/main.tf | 167 ++++ modules/linode/outputs.tf | 38 + modules/linode/variables.tf | 183 ++++ modules/linode/versions.tf | 10 + modules/linux-vm-runtime/main.tf | 297 ++++++ modules/linux-vm-runtime/outputs.tf | 24 + .../linux-vm-runtime/templates/cloud-init.yml | 91 ++ modules/linux-vm-runtime/variables.tf | 341 +++++++ moved.tf | 144 +++ outputs.tf | 63 +- renovate.json5 | 25 + .../cloud-compose-mariadb-backup.service | 11 + .../system/cloud-compose-mariadb-backup.timer | 14 + .../etc/systemd/system/cloud-compose.service | 8 +- rootfs/etc/systemd/system/vault-agent.service | 16 + rootfs/home/cloud-compose/app-init.sh | 34 +- rootfs/home/cloud-compose/app-rollout.sh | 34 +- rootfs/home/cloud-compose/compose-apps.sh | 220 +++++ rootfs/home/cloud-compose/compose-dispatch.sh | 29 + rootfs/home/cloud-compose/host-conf.sh | 86 +- rootfs/home/cloud-compose/host-init.sh | 47 +- .../install-dependencies-coreos.sh | 36 + .../cloud-compose/install-dependencies-cos.sh | 37 + .../install-dependencies-debian.sh | 22 + .../cloud-compose/install-dependencies.sh | 29 + .../cloud-compose/install-docker-plugins.sh | 65 ++ rootfs/home/cloud-compose/mariadb-backup.sh | 33 + rootfs/home/cloud-compose/profile.sh | 3 + rootfs/home/cloud-compose/rotate-keys-app.sh | 25 +- rootfs/home/cloud-compose/run.sh | 29 +- rootfs/home/cloud-compose/vault-agent-init.sh | 28 + .../data/libops-internal/docker-compose.yaml | 2 + templates/cloud-init.yml | 26 +- .../digitalocean-isle/.terraform.lock.hcl | 47 + tests/smoke/digitalocean-isle/main.tf | 88 ++ tests/smoke/digitalocean-isle/variables.tf | 100 ++ tests/smoke/gcp-wp/main.tf | 114 +++ tests/smoke/gcp-wp/variables.tf | 116 +++ tests/smoke/linode-wp/.terraform.lock.hcl | 44 + tests/smoke/linode-wp/main.tf | 87 ++ tests/smoke/linode-wp/variables.tf | 106 ++ variables.tf | 578 +++++------ 81 files changed, 7432 insertions(+), 1366 deletions(-) create mode 100644 .github/workflows/cloud-smoke-cleanup.yml create mode 100644 .github/workflows/cloud-smoke.yml create mode 100644 .github/workflows/docs.yml create mode 100755 ci/cloud-smoke.sh create mode 100755 ci/install-sitectl-apt.sh create mode 100644 ci/terraform-validate.sh create mode 100644 docs/Dockerfile create mode 100644 docs/css/custom.css create mode 100644 docs/examples.md create mode 100644 docs/index.md create mode 100644 docs/js/mermaid-init.mjs create mode 100644 docs/mkdocs.yml create mode 100644 docs/non-gcp-providers.md create mode 100644 docs/runtime-contracts.md create mode 100644 examples/binpack/main.tf create mode 100644 examples/binpack/variables.tf create mode 100644 examples/digitalocean/main.tf create mode 100644 examples/digitalocean/variables.tf create mode 100644 examples/drupal/main.tf create mode 100644 examples/drupal/variables.tf create mode 100644 examples/isle/main.tf create mode 100644 examples/isle/variables.tf create mode 100644 examples/linode/main.tf create mode 100644 examples/linode/variables.tf create mode 100644 examples/wp/main.tf create mode 100644 examples/wp/variables.tf create mode 100644 modules/digitalocean/main.tf create mode 100644 modules/digitalocean/outputs.tf create mode 100644 modules/digitalocean/variables.tf create mode 100644 modules/digitalocean/versions.tf create mode 100644 modules/gcp/main.tf create mode 100644 modules/gcp/outputs.tf create mode 100644 modules/gcp/variables.tf create mode 100644 modules/linode/main.tf create mode 100644 modules/linode/outputs.tf create mode 100644 modules/linode/variables.tf create mode 100644 modules/linode/versions.tf create mode 100644 modules/linux-vm-runtime/main.tf create mode 100644 modules/linux-vm-runtime/outputs.tf create mode 100644 modules/linux-vm-runtime/templates/cloud-init.yml create mode 100644 modules/linux-vm-runtime/variables.tf create mode 100644 moved.tf create mode 100644 rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service create mode 100644 rootfs/etc/systemd/system/cloud-compose-mariadb-backup.timer create mode 100644 rootfs/etc/systemd/system/vault-agent.service create mode 100644 rootfs/home/cloud-compose/compose-apps.sh create mode 100644 rootfs/home/cloud-compose/compose-dispatch.sh create mode 100644 rootfs/home/cloud-compose/install-dependencies-coreos.sh create mode 100644 rootfs/home/cloud-compose/install-dependencies-cos.sh create mode 100644 rootfs/home/cloud-compose/install-dependencies-debian.sh create mode 100644 rootfs/home/cloud-compose/install-dependencies.sh create mode 100644 rootfs/home/cloud-compose/install-docker-plugins.sh create mode 100644 rootfs/home/cloud-compose/mariadb-backup.sh create mode 100644 rootfs/home/cloud-compose/vault-agent-init.sh create mode 100644 tests/smoke/digitalocean-isle/.terraform.lock.hcl create mode 100644 tests/smoke/digitalocean-isle/main.tf create mode 100644 tests/smoke/digitalocean-isle/variables.tf create mode 100644 tests/smoke/gcp-wp/main.tf create mode 100644 tests/smoke/gcp-wp/variables.tf create mode 100644 tests/smoke/linode-wp/.terraform.lock.hcl create mode 100644 tests/smoke/linode-wp/main.tf create mode 100644 tests/smoke/linode-wp/variables.tf diff --git a/.github/workflows/cloud-smoke-cleanup.yml b/.github/workflows/cloud-smoke-cleanup.yml new file mode 100644 index 0000000..1ad2ec9 --- /dev/null +++ b/.github/workflows/cloud-smoke-cleanup.yml @@ -0,0 +1,68 @@ +name: Cloud smoke cleanup + +on: + workflow_run: + workflows: + - Cloud smoke tests + types: + - completed + +permissions: + contents: read + id-token: write + +jobs: + cleanup: + name: Destroy ${{ matrix.name }} + if: github.event.workflow_run.head_repository.full_name == github.repository && contains(fromJSON('["failure","cancelled","timed_out"]'), github.event.workflow_run.conclusion) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - name: DigitalOcean ISLE + sweep_target: sweep-digitalocean-isle + concurrency_target: smoke-test-digitalocean-isle + provider: digitalocean + - name: Linode WordPress + sweep_target: sweep-linode-wp + concurrency_target: smoke-test-linode-wp + provider: linode + - name: GCP WordPress + sweep_target: sweep-gcp-wp + concurrency_target: smoke-test-gcp-wp + provider: gcp + concurrency: + group: cloud-compose-smoke-${{ matrix.concurrency_target }} + cancel-in-progress: false + env: + CLOUD_COMPOSE_SMOKE_RUN_ID: ${{ github.event.workflow_run.id }} + DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} + GCLOUD_OIDC_POOL: ${{ vars.GCLOUD_OIDC_POOL || secrets.GCLOUD_OIDC_POOL }} + GCLOUD_PROJECT: ${{ vars.GCLOUD_PROJECT || secrets.GCLOUD_PROJECT }} + GCLOUD_REGION: ${{ vars.GCLOUD_REGION || secrets.GCLOUD_REGION }} + GSA: ${{ vars.GSA || secrets.GSA }} + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Authenticate to Google Cloud + if: matrix.provider == 'gcp' && env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '' + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + workload_identity_provider: ${{ env.GCLOUD_OIDC_POOL }} + service_account: ${{ env.GSA }} + project_id: ${{ env.GCLOUD_PROJECT }} + + - name: Install gcloud + if: matrix.provider == 'gcp' && env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '' + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3 + + - name: Sweep smoke resources + if: matrix.provider != 'gcp' || (env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '') + run: ci/cloud-smoke.sh ${{ matrix.sweep_target }} + + - name: Skip GCP cleanup + if: matrix.provider == 'gcp' && (env.GCLOUD_OIDC_POOL == '' || env.GSA == '' || env.GCLOUD_PROJECT == '') + run: echo "Skipping GCP cleanup because GCP smoke secrets are not configured." diff --git a/.github/workflows/cloud-smoke.yml b/.github/workflows/cloud-smoke.yml new file mode 100644 index 0000000..3965a6e --- /dev/null +++ b/.github/workflows/cloud-smoke.yml @@ -0,0 +1,168 @@ +name: Cloud smoke tests + +on: + pull_request: + branches: + - main + +permissions: + contents: read + id-token: write + +concurrency: + group: cloud-compose-smoke + cancel-in-progress: true + +jobs: + terraform-lint: + name: Terraform lint + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4 + with: + terraform_version: 1.14.6 + terraform_wrapper: false + + - name: Run Terraform lint + run: make terraform-lint-check + + smoke: + name: ${{ matrix.name }} + needs: terraform-lint + runs-on: ubuntu-24.04 + timeout-minutes: 150 + if: github.event.pull_request.head.repo.full_name == github.repository + strategy: + fail-fast: false + matrix: + include: + - name: DigitalOcean ISLE + make_target: smoke-test-digitalocean-isle + smoke_target: digitalocean-isle + provider: digitalocean + - name: Linode WordPress + make_target: smoke-test-linode-wp + smoke_target: linode-wp + provider: linode + - name: GCP WordPress + make_target: smoke-test-gcp-wp + smoke_target: gcp-wp + provider: gcp + concurrency: + group: cloud-compose-smoke-${{ matrix.make_target }} + cancel-in-progress: false + env: + CLOUD_COMPOSE_SMOKE_AUTO_APPROVE: "true" + CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT: "1800" + 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: ${{ secrets.DIGITALOCEAN_TOKEN }} + GCLOUD_OIDC_POOL: ${{ vars.GCLOUD_OIDC_POOL || secrets.GCLOUD_OIDC_POOL }} + GCLOUD_PROJECT: ${{ vars.GCLOUD_PROJECT || secrets.GCLOUD_PROJECT }} + GCLOUD_REGION: ${{ vars.GCLOUD_REGION || secrets.GCLOUD_REGION }} + GSA: ${{ vars.GSA || secrets.GSA }} + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4 + with: + terraform_version: 1.14.6 + terraform_wrapper: false + + - name: Check GCP smoke secrets + if: matrix.provider == 'gcp' + run: | + test -n "$GCLOUD_OIDC_POOL" || { echo "GCLOUD_OIDC_POOL Actions variable or secret is required for GCP smoke tests"; exit 1; } + test -n "$GSA" || { echo "GSA Actions variable or secret is required for GCP smoke tests"; exit 1; } + test -n "$GCLOUD_PROJECT" || { echo "GCLOUD_PROJECT Actions variable or secret is required for GCP smoke tests"; exit 1; } + + - name: Authenticate to Google Cloud + if: matrix.provider == 'gcp' + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + workload_identity_provider: ${{ env.GCLOUD_OIDC_POOL }} + service_account: ${{ env.GSA }} + project_id: ${{ env.GCLOUD_PROJECT }} + + - name: Install gcloud + if: matrix.provider == 'gcp' + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3 + + - name: Install sitectl + run: ci/install-sitectl-apt.sh + + - name: Run smoke test + run: make ${{ matrix.make_target }} + + - name: Destroy smoke resources + if: always() && (matrix.provider != 'gcp' || (env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '')) + run: ci/cloud-smoke.sh destroy-${{ matrix.smoke_target }} + + - name: Skip GCP destroy + if: always() && matrix.provider == 'gcp' && (env.GCLOUD_OIDC_POOL == '' || env.GSA == '' || env.GCLOUD_PROJECT == '') + run: echo "Skipping GCP destroy because GCP smoke secrets are not configured and no GCP resources were applied." + + cleanup: + name: Destroy ${{ matrix.name }} + needs: smoke + if: always() && (failure() || cancelled()) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - name: DigitalOcean ISLE + sweep_target: sweep-digitalocean-isle + concurrency_target: smoke-test-digitalocean-isle + provider: digitalocean + - name: Linode WordPress + sweep_target: sweep-linode-wp + concurrency_target: smoke-test-linode-wp + provider: linode + - name: GCP WordPress + sweep_target: sweep-gcp-wp + concurrency_target: smoke-test-gcp-wp + provider: gcp + concurrency: + group: cloud-compose-smoke-${{ matrix.concurrency_target }} + cancel-in-progress: false + env: + CLOUD_COMPOSE_SMOKE_RUN_ID: ${{ github.run_id }} + DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} + GCLOUD_OIDC_POOL: ${{ vars.GCLOUD_OIDC_POOL || secrets.GCLOUD_OIDC_POOL }} + GCLOUD_PROJECT: ${{ vars.GCLOUD_PROJECT || secrets.GCLOUD_PROJECT }} + GCLOUD_REGION: ${{ vars.GCLOUD_REGION || secrets.GCLOUD_REGION }} + GSA: ${{ vars.GSA || secrets.GSA }} + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Authenticate to Google Cloud + if: matrix.provider == 'gcp' && env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '' + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + workload_identity_provider: ${{ env.GCLOUD_OIDC_POOL }} + service_account: ${{ env.GSA }} + project_id: ${{ env.GCLOUD_PROJECT }} + + - name: Install gcloud + if: matrix.provider == 'gcp' && env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '' + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3 + + - name: Sweep smoke resources + if: matrix.provider != 'gcp' || (env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '') + run: ci/cloud-smoke.sh ${{ matrix.sweep_target }} + + - name: Skip GCP cleanup + if: matrix.provider == 'gcp' && (env.GCLOUD_OIDC_POOL == '' || env.GSA == '' || env.GCLOUD_PROJECT == '') + run: echo "Skipping GCP cleanup because GCP smoke secrets are not configured." diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..208e5cb --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,40 @@ +name: "Build docs" + +on: + push: + branches: + - main + paths: + - "docs/**" + - ".github/workflows/docs.yml" + - "Makefile" + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-24.04 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Build docs + run: make docs-build + + - name: Setup Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 + + - name: Upload artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 + with: + path: ./docs/site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.gitignore b/.gitignore index 23031b9..6ac0c2b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ override.tf override.tf.json *_override.tf *_override.tf.json +site/ +docs/site/ +docs/.cache/ +.cloud-compose-smoke/ diff --git a/Makefile b/Makefile index a7a927f..e043b7d 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,87 @@ -.PHONY: docs lint +.PHONY: lint lint-check terraform-fmt terraform-fmt-check terraform-validate terraform-lint-check shell-lint terraform-docs smoke-test-clouds smoke-test-digitalocean-isle smoke-test-linode-wp smoke-test-gcp-wp destroy-smoke-digitalocean-isle destroy-smoke-linode-wp destroy-smoke-gcp-wp docs docs-docker-build docs-build docs-serve docs-preview docs-clean -lint: - @find . -type f -name "*.tf" -exec terraform fmt {} + - @find . -type f -name "*.sh" -exec shellcheck {} + +DOCS_IMAGE ?= cloud-compose-docs +DOCS_PORT ?= 8888 +DOCS_DOCKER_USER ?= $(shell id -u):$(shell id -g) -docs: +lint: terraform-fmt shell-lint terraform-validate + +lint-check: terraform-fmt-check shell-lint terraform-validate + +terraform-fmt: + terraform fmt -recursive + +terraform-fmt-check: + terraform fmt -check -recursive + +terraform-validate: + bash ci/terraform-validate.sh + +terraform-lint-check: terraform-fmt-check terraform-validate + +shell-lint: + @find . \ + -path "./.terraform" -prune -o \ + -path "./docs/site" -prune -o \ + -type f -name "*.sh" -print0 | xargs -0 shellcheck + +terraform-docs: terraform-docs markdown table --sort-by required --output-file README.md . +smoke-test-clouds: + ci/cloud-smoke.sh all + +smoke-test-digitalocean-isle: + ci/cloud-smoke.sh digitalocean-isle + +smoke-test-linode-wp: + ci/cloud-smoke.sh linode-wp + +smoke-test-gcp-wp: + ci/cloud-smoke.sh gcp-wp + +destroy-smoke-digitalocean-isle: + ci/cloud-smoke.sh destroy-digitalocean-isle + +destroy-smoke-linode-wp: + ci/cloud-smoke.sh destroy-linode-wp + +destroy-smoke-gcp-wp: + ci/cloud-smoke.sh destroy-gcp-wp + +docs: docs-build + +docs-docker-build: + docker build -f docs/Dockerfile -t $(DOCS_IMAGE) . + +docs-build: docs-docker-build + rm -rf site docs/site + docker run --rm \ + -u "$(DOCS_DOCKER_USER)" \ + $(if $(SITE_URL),-e SITE_URL=$(SITE_URL)) \ + -v "$(CURDIR):/work" \ + -w /work \ + $(DOCS_IMAGE) \ + build --clean --config-file docs/mkdocs.yml + +docs-serve: docs-docker-build + docker run --rm -it \ + -u "$(DOCS_DOCKER_USER)" \ + -p $(DOCS_PORT):8080 \ + -v "$(CURDIR):/work" \ + -w /work \ + $(DOCS_IMAGE) \ + serve --config-file docs/mkdocs.yml --dev-addr 0.0.0.0:8080 + +docs-preview: + $(MAKE) docs-build SITE_URL=http://localhost:$(DOCS_PORT) + docker run --rm -it \ + -p $(DOCS_PORT):8080 \ + -v "$(CURDIR)/docs/site:/site" \ + -w /site \ + --entrypoint python3 \ + $(DOCS_IMAGE) \ + -m http.server 8080 + +docs-clean: + rm -rf site docs/site diff --git a/README.md b/README.md index e79a468..2b13806 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,50 @@ # cloud-compose -Deploy a docker compose project to a Google Cloud Compute Instance. - -Optional VM APIs: - -- [Rollout API](docs/rollout.md) exposes authenticated deployment rollout triggers for the compose project. -- [Managed Runtime](docs/managed-runtime.md) keeps `sitectl`, sitectl plugins, and LibOps-side support services up to date on the VM. +Deploy Docker Compose projects to cloud VMs. The root module targets Google +Cloud; provider modules under `modules/` cover DigitalOcean and Linode. ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.2.4 | -| [cloudinit](#requirement\_cloudinit) | ~> 2.3 | -| [google](#requirement\_google) | ~> 7.0 | -| [time](#requirement\_time) | ~> 0.14 | ## Providers -| Name | Version | -|------|---------| -| [cloudinit](#provider\_cloudinit) | 2.4.0 | -| [google](#provider\_google) | 7.34.0 | -| [time](#provider\_time) | 0.14.0 | +No providers. ## Modules | Name | Source | Version | -|------|--------|---------| -| [ppb](#module\_ppb) | git::https://github.com/libops/terraform-cloudrun-v2 | 0.5.3 | +| ---- | ------ | ------- | +| [gcp](#module\_gcp) | ./modules/gcp | n/a | ## Resources -| Name | Type | -|------|------| -| [google_artifact_registry_repository_iam_member.private-policy-cloud-compose](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/artifact_registry_repository_iam_member) | resource | -| [google_compute_disk.boot](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_disk) | resource | -| [google_compute_disk.data](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_disk) | resource | -| [google_compute_disk.docker-volumes](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_disk) | resource | -| [google_compute_disk.overlay_disk](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_disk) | resource | -| [google_compute_disk_resource_policy_attachment.daily_snapshot](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_disk_resource_policy_attachment) | resource | -| [google_compute_disk_resource_policy_attachment.weekly_snapshot](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_disk_resource_policy_attachment) | resource | -| [google_compute_firewall.allow_rollout_ipv4](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_firewall) | resource | -| [google_compute_firewall.allow_ssh_ipv4](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_firewall) | resource | -| [google_compute_firewall.allow_ssh_ipv6](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_firewall) | resource | -| [google_compute_instance.cloud-compose](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_instance) | resource | -| [google_compute_reservation.production](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_reservation) | resource | -| [google_compute_resource_policy.daily_snapshot](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_resource_policy) | resource | -| [google_compute_resource_policy.weekly_snapshot](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_resource_policy) | resource | -| [google_project_iam_member.gce-start](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/project_iam_member) | resource | -| [google_project_iam_member.gce-suspend](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/project_iam_member) | resource | -| [google_project_iam_member.log](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/project_iam_member) | resource | -| [google_project_iam_member.stackdriver](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/project_iam_member) | resource | -| [google_service_account.app](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account) | resource | -| [google_service_account.cloud-compose](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account) | resource | -| [google_service_account.internal-services](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account) | resource | -| [google_service_account.ppb](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account) | resource | -| [google_service_account_iam_member.app-keys](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account_iam_member) | resource | -| [google_service_account_iam_member.gsa-user](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account_iam_member) | resource | -| [google_service_account_iam_member.internal-services-keys](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account_iam_member) | resource | -| [google_service_account_iam_member.self_jwt_signer_policy](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account_iam_member) | resource | -| [google_service_account_iam_member.token-creator](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account_iam_member) | resource | -| [time_static.snapshot_time_static](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/static) | resource | -| [cloudinit_config.ci](https://registry.terraform.io/providers/hashicorp/cloudinit/latest/docs/data-sources/config) | data source | -| [google_compute_snapshot.latest_prod](https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/compute_snapshot) | data source | -| [google_project_iam_custom_role.gce-start](https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/project_iam_custom_role) | data source | -| [google_project_iam_custom_role.gce-suspend](https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/project_iam_custom_role) | data source | +No resources. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [docker\_compose\_repo](#input\_docker\_compose\_repo) | git repo to checkout that contains a docker compose project | `string` | n/a | yes | -| [name](#input\_name) | The site name (will be the name of the GCP instance) | `string` | n/a | yes | -| [project\_id](#input\_project\_id) | The GCP project ID | `string` | n/a | yes | -| [project\_number](#input\_project\_number) | The GCP project number | `string` | n/a | yes | -| [allowed\_ips](#input\_allowed\_ips) | CIDR IP Addresses allowed to turn on this site's GCP instance | `list(string)` | `[]` | no | -| [allowed\_ssh\_ipv4](#input\_allowed\_ssh\_ipv4) | CIDR IPv4 Addresses allowed to to SSH into this site's GCP instance | `list(string)` | `[]` | no | -| [allowed\_ssh\_ipv6](#input\_allowed\_ssh\_ipv6) | CIDR IPv6 Addresses allowed to SSH into this site's GCP instance | `list(string)` | `[]` | no | -| [artifact\_registry\_location](#input\_artifact\_registry\_location) | Artifact Registry location for var.artifact\_registry\_repository. | `string` | `"us"` | no | -| [artifact\_registry\_repository](#input\_artifact\_registry\_repository) | Optional Artifact Registry repository name to grant the VM service account reader access to. Leave empty to skip creating the IAM binding. | `string` | `""` | no | -| [disk\_size\_gb](#input\_disk\_size\_gb) | Data disk size in GB | `number` | `50` | no | -| [disk\_type](#input\_disk\_type) | The disk type for disks attached to the machine | `string` | `"hyperdisk-balanced"` | no | -| [docker\_compose\_branch](#input\_docker\_compose\_branch) | git branch to checkout for var.docker\_compose\_repo | `string` | `"main"` | no | -| [docker\_compose\_down](#input\_docker\_compose\_down) | Command to stop the docker compose project | `list(string)` |
[| no | -| [docker\_compose\_init](#input\_docker\_compose\_init) | After cloning the docker compose git repo, any initialization that needs to happen before the docker compose project can start. One command per list value | `list(string)` |
"sitectl compose --context \"${SITECTL_CONTEXT_NAME}\" down"
]
[| no | -| [docker\_compose\_rollout](#input\_docker\_compose\_rollout) | Command to roll out a new git ref for the docker compose project. The optional rollout service sets GIT\_REF/GIT\_BRANCH from the trigger request. | `list(string)` |
"sitectl config set-context \"${SITECTL_CONTEXT_NAME}\" --type local --project-dir \"${DOCKER_COMPOSE_DIR}\" --site \"${GCP_INSTANCE_NAME}\" --plugin \"${SITECTL_PLUGIN}\" --environment \"${SITECTL_ENVIRONMENT}\" --project-name \"${GCP_INSTANCE_NAME}\" --compose-project-name \"${COMPOSE_PROJECT_NAME}\" --docker-socket /var/run/docker.sock --env-file .env --default"
]
[| no | -| [docker\_compose\_up](#input\_docker\_compose\_up) | Command to start the docker compose project | `list(string)` |
"TARGET_REF=\"${GIT_REF:-${GIT_BRANCH:-${DOCKER_COMPOSE_BRANCH:-main}}}\"",
"if [ -x ./scripts/rollout.sh ]; then ./scripts/rollout.sh; else sitectl deploy --context \"${SITECTL_CONTEXT_NAME}\" --branch \"$TARGET_REF\"; fi",
"sitectl healthcheck --context \"${SITECTL_CONTEXT_NAME}\" --persist --timeout \"${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"${SITECTL_CONTEXT_NAME}\" ${SITECTL_VERIFY_ARGS:-}; fi"
]
[| no | -| [frontend](#input\_frontend) | Optional frontend container to deploy as a sidecar next to ppb. When set,
"sitectl deploy --context \"${SITECTL_CONTEXT_NAME}\" --skip-git",
"sitectl healthcheck --context \"${SITECTL_CONTEXT_NAME}\" --persist --timeout \"${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"${SITECTL_CONTEXT_NAME}\" ${SITECTL_VERIFY_ARGS:-}; fi",
"sitectl compose --context \"${SITECTL_CONTEXT_NAME}\" logs -f"
]
object({
image = string
port = optional(number, 8080)
cpu = optional(string, "1000m")
memory = optional(string, "1Gi")
}) | `null` | no |
-| [ingress\_port](#input\_ingress\_port) | TCP port on the VM that the Cloud Run ingress should connect to. | `number` | `80` | no |
-| [initcmd](#input\_initcmd) | Commands to run before /home/cloud-compose/run.sh | `list(string)` | `[]` | no |
-| [libops\_cadvisor\_image](#input\_libops\_cadvisor\_image) | Container image used for the internal cAdvisor service. | `string` | `"ghcr.io/google/cadvisor:v0.57.0@sha256:e75bdb03b74b0b6995f208f166fead2e6e555dde73e44200113bb26f41b1981d"` | no |
-| [libops\_cap\_image](#input\_libops\_cap\_image) | Container image used for the internal CAP metrics service. | `string` | `"ghcr.io/libops/cap:main"` | no |
-| [libops\_internal\_services\_auto\_update](#input\_libops\_internal\_services\_auto\_update) | Whether the managed runtime updater should pull and restart the internal LibOps compose project. | `bool` | `true` | no |
-| [libops\_lightsout\_image](#input\_libops\_lightsout\_image) | Container image used for the internal lightsout service. | `string` | `"ghcr.io/libops/lightsout:main"` | no |
-| [libops\_managed\_artifacts](#input\_libops\_managed\_artifacts) | Additional LibOps-managed files or binaries to download, verify, install, and optionally restart with the managed runtime updater. | 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, "")
})) | `[]` | no |
-| [libops\_managed\_runtime\_enabled](#input\_libops\_managed\_runtime\_enabled) | Install and periodically update LibOps-managed host tools and internal VM services. | `bool` | `true` | no |
-| [machine\_type](#input\_machine\_type) | VM machine type (General-purpose series that support Hyperdisk Balanced | `string` | `"n4-standard-2"` | no |
-| [os](#input\_os) | The host OS to install on the GCP instance | `string` | `"cos-125-19216-220-185"` | no |
-| [overlay\_source\_instance](#input\_overlay\_source\_instance) | Name of production instance to get latest snapshot from (e.g., 'ojs-production'). Terraform will automatically use the most recent snapshot from this instance's data disk. Leave empty for production environments. | `string` | `""` | no |
-| [production](#input\_production) | Whether this VM is the production environment. Production VMs reserve one matching machine so stop/start and recreate operations keep capacity. | `bool` | `false` | no |
-| [region](#input\_region) | GCP region for resources | `string` | `"us-east5"` | no |
-| [rollout\_allowed\_ipv4](#input\_rollout\_allowed\_ipv4) | CIDR IPv4 ranges allowed to reach the rollout service port. | `list(string)` | [| no | -| [rollout\_custom\_claims](#input\_rollout\_custom\_claims) | Optional JSON object of additional JWT claims required by the rollout service. | `string` | `""` | no | -| [rollout\_enabled](#input\_rollout\_enabled) | Install and run the optional generic rollout HTTP service on the VM. | `bool` | `false` | no | -| [rollout\_jwks\_uri](#input\_rollout\_jwks\_uri) | JWKS URI used by the rollout service to validate bearer JWTs. | `string` | `""` | no | -| [rollout\_jwt\_audience](#input\_rollout\_jwt\_audience) | JWT audience required by the rollout service. | `string` | `""` | no | -| [rollout\_port](#input\_rollout\_port) | TCP port exposed by the optional rollout service. | `number` | `8081` | no | -| [rollout\_release\_sha256](#input\_rollout\_release\_sha256) | Lowercase SHA256 checksum for var.rollout\_release\_url. | `string` | `""` | no | -| [rollout\_release\_url](#input\_rollout\_release\_url) | HTTPS URL for the pinned rollout Linux binary. | `string` | `""` | no | -| [rootfs](#input\_rootfs) | Path to additional rootfs files to copy into the VM. Files will be merged with the base rootfs. Example: '/path/to/custom/rootfs' | `string` | `""` | no | -| [run\_snapshots](#input\_run\_snapshots) | Enable daily snapshots of the data disk (recommended for production). Last seven days of snapshots are available. Also weekly snapshots for past year. | `bool` | `false` | no | -| [runcmd](#input\_runcmd) | Additional commands to run during cloud-init. Commands are executed after the main initialization. | `list(string)` | `[]` | no | -| [service\_account\_email](#input\_service\_account\_email) | Existing service account email for the VM. When empty, this module creates one. | `string` | `""` | no | -| [sitectl\_context\_name](#input\_sitectl\_context\_name) | Sitectl context name to create on the VM. Defaults to var.name. | `string` | `""` | no | -| [sitectl\_environment](#input\_sitectl\_environment) | Sitectl environment label. Production runs healthcheck only by default; non-production also runs sitectl verify. | `string` | `"production"` | no | -| [sitectl\_healthcheck\_interval](#input\_sitectl\_healthcheck\_interval) | Interval passed to sitectl healthcheck --interval in default lifecycle commands. | `string` | `"15s"` | no | -| [sitectl\_healthcheck\_timeout](#input\_sitectl\_healthcheck\_timeout) | Timeout passed to sitectl healthcheck --timeout in default lifecycle commands. | `string` | `"10m"` | no | -| [sitectl\_packages](#input\_sitectl\_packages) | LibOps GitHub release package names to install and keep updated on the VM. Include plugin packages such as sitectl-isle or sitectl-wp as needed. | `list(string)` |
"10.0.0.0/8"
]
[| no | -| [sitectl\_plugin](#input\_sitectl\_plugin) | Sitectl plugin id to associate with the VM context. | `string` | `"core"` | no | -| [sitectl\_verify\_args](#input\_sitectl\_verify\_args) | Additional arguments appended to sitectl verify by the default non-production lifecycle commands. | `list(string)` | `[]` | no | -| [sitectl\_version](#input\_sitectl\_version) | Sitectl release tag to install for sitectl packages, or latest to follow https://github.com/libops/sitectl/releases/latest. | `string` | `"latest"` | no | -| [users](#input\_users) | Map of usernames to lists of SSH public keys. Users will be created with docker group membership. Example: { "alice" = ["ssh-rsa AAAA..."], "bob" = ["ssh-ed25519 AAAA...", "ssh-rsa BBBB..."] } | `map(list(string))` | `{}` | no | -| [volume\_names](#input\_volume\_names) | List of docker volumes to overlay from production snapshot (e.g., ['compose\_ojs-public']). Production data is mounted read-only as lower layer, staging writes go to upper layer. | `list(string)` | `[]` | no | -| [zone](#input\_zone) | GCP zone for resources | `string` | `"us-east5-b"` | no | +| ---- | ----------- | ---- | ------- | :------: | +| [gcp](#input\_gcp) | Google Cloud infrastructure settings. |
"sitectl"
]
object({
project_id = string
project_number = 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, "")
}), {})
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)
name = optional(string, "")
subnetwork = optional(string, "")
ip_cidr_range = optional(string, "10.42.0.0/24")
power_button_allowed_ips = optional(list(string), [])
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, true)
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"])
}), {})
}) | n/a | yes |
+| [name](#input\_name) | Deployment name. | `string` | n/a | yes |
+| [runtime](#input\_runtime) | Provider-neutral compose/runtime settings. | object({
rootfs = 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), [
"sitectl config set-context \"$${SITECTL_CONTEXT_NAME}\" --type local --project-dir \"$${DOCKER_COMPOSE_DIR}\" --site \"$${CLOUD_COMPOSE_INSTANCE_NAME}\" --plugin \"$${SITECTL_PLUGIN}\" --environment \"$${SITECTL_ENVIRONMENT}\" --project-name \"$${CLOUD_COMPOSE_INSTANCE_NAME}\" --compose-project-name \"$${COMPOSE_PROJECT_NAME}\" --docker-socket /var/run/docker.sock --env-file .env --default"
])
up = optional(list(string), [
"sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" up -d --remove-orphans",
"sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
])
down = optional(list(string), [
"sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" down"
])
rollout = optional(list(string), [
"TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-$${DOCKER_COMPOSE_BRANCH:-main}}}\"",
"if [ -x ./scripts/rollout.sh ]; then ./scripts/rollout.sh; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --branch \"$TARGET_REF\"; fi",
"sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
])
}), {})
sitectl = optional(object({
packages = optional(list(string), ["sitectl"])
version = optional(string, "latest")
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
healthcheck_timeout = optional(string, "10m")
healthcheck_interval = optional(string, "15s")
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.2.0")
# 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, true)
internal_services_auto_update = optional(bool, true)
lightsout_image = optional(string, "ghcr.io/libops/lightsout:main")
cap_image = optional(string, "ghcr.io/libops/cap:main")
cadvisor_image = optional(string, "ghcr.io/google/cadvisor:v0.57.0@sha256:e75bdb03b74b0b6995f208f166fead2e6e555dde73e44200113bb26f41b1981d")
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, "gcp-iam")
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, "")
})), [])
}), {})
}) | `{}` | no |
## Outputs
| Name | Description |
-|------|-------------|
-| [appGsa](#output\_appGsa) | The Google Service Account the app can leverage to auth to other Google services |
-| [backend](#output\_backend) | Backend service ID for attaching the Cloud Run ingress to an external HTTPS load balancer. |
+| ---- | ----------- |
+| [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. |
+| [compose\_projects](#output\_compose\_projects) | Normalized compose project manifest. |
| [external\_ip](#output\_external\_ip) | The Google Compute instance external IPv4 address. |
-| [instance](#output\_instance) | The Google Compute instance ID, name, zone, data disk, GSA for the instance. |
+| [instance](#output\_instance) | The Google Compute instance ID, name, zone, data disk, and GSA. |
| [instance\_id](#output\_instance\_id) | The Google Compute instance ID. |
| [internal\_ip](#output\_internal\_ip) | The Google Compute instance internal IPv4 address. |
-| [rollout](#output\_rollout) | Optional rollout API endpoint details. The URL is the VPC-internal endpoint. |
-| [serviceGsa](#output\_serviceGsa) | The Google Service Account internal services that manage the VM runs as |
+| [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. |
| [urls](#output\_urls) | Cloud Run ingress URLs by region. |
diff --git a/ci/cloud-smoke.sh b/ci/cloud-smoke.sh
new file mode 100755
index 0000000..25c63a2
--- /dev/null
+++ b/ci/cloud-smoke.sh
@@ -0,0 +1,784 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "$0")/.." && pwd)"
+
+usage() {
+ cat <<'EOF'
+Usage:
+ ci/cloud-smoke.sh all
+ ci/cloud-smoke.sh digitalocean-isle
+ ci/cloud-smoke.sh linode-wp
+ ci/cloud-smoke.sh gcp-wp
+ ci/cloud-smoke.sh destroy-digitalocean-isle
+ ci/cloud-smoke.sh destroy-linode-wp
+ ci/cloud-smoke.sh destroy-gcp-wp
+ ci/cloud-smoke.sh sweep
+ ci/cloud-smoke.sh sweep-digitalocean-isle
+ ci/cloud-smoke.sh sweep-linode-wp
+ ci/cloud-smoke.sh sweep-gcp-wp
+
+Required environment:
+ DIGITALOCEAN_TOKEN DigitalOcean API token for digitalocean-isle.
+ LINODE_TOKEN Linode API token for linode-wp.
+ GCLOUD_PROJECT Google Cloud project for gcp-wp.
+
+Optional environment:
+ CLOUD_COMPOSE_SMOKE_AUTO_APPROVE=true Pass -auto-approve outside GitHub Actions.
+ CLOUD_COMPOSE_SMOKE_KEEP=true Keep resources for debugging instead of destroying them.
+ CLOUD_COMPOSE_SMOKE_WORKDIR=.smoke Directory for generated keys, Terraform data, and sitectl config.
+ CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT=3600 Seconds to wait for SSH and cloud-init.
+ CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT=1800
+ Seconds allowed for Terraform destroy during cleanup.
+ CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS=true
+ Remove prior smoke resources for the same target before apply.
+ CLOUD_COMPOSE_SMOKE_RUN_ID Optional run id used to target provider cleanup.
+ DIGITALOCEAN_API_TOKEN Backward-compatible alias for DIGITALOCEAN_TOKEN.
+ GCLOUD_REGION=us-east5 Google Cloud region for gcp-wp.
+ GCLOUD_ZONE=us-east5-b Google Cloud zone for gcp-wp.
+EOF
+}
+
+require_cmd() {
+ command -v "$1" >/dev/null 2>&1 || {
+ echo "Missing required command: $1" >&2
+ exit 1
+ }
+}
+
+require_env() {
+ if [[ -z "${!1:-}" ]]; then
+ echo "$1 is required" >&2
+ exit 1
+ fi
+}
+
+target_root() {
+ case "$1" in
+ digitalocean-isle) printf '%s/tests/smoke/digitalocean-isle\n' "$repo_root" ;;
+ linode-wp) printf '%s/tests/smoke/linode-wp\n' "$repo_root" ;;
+ gcp-wp) printf '%s/tests/smoke/gcp-wp\n' "$repo_root" ;;
+ *)
+ echo "Unknown smoke target: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+}
+
+target_env() {
+ case "$1" in
+ digitalocean-isle)
+ if [[ -z "${DIGITALOCEAN_TOKEN:-}" && -n "${DIGITALOCEAN_API_TOKEN:-}" ]]; then
+ export DIGITALOCEAN_TOKEN="$DIGITALOCEAN_API_TOKEN"
+ fi
+ require_env DIGITALOCEAN_TOKEN
+ ;;
+ linode-wp)
+ require_env LINODE_TOKEN
+ ;;
+ gcp-wp)
+ require_env GCLOUD_PROJECT
+ export GOOGLE_PROJECT="${GOOGLE_PROJECT:-$GCLOUD_PROJECT}"
+ export GOOGLE_CLOUD_PROJECT="${GOOGLE_CLOUD_PROJECT:-$GCLOUD_PROJECT}"
+ export CLOUDSDK_CORE_PROJECT="${CLOUDSDK_CORE_PROJECT:-$GCLOUD_PROJECT}"
+ ;;
+ esac
+}
+
+shell_quote() {
+ printf '%q' "$1"
+}
+
+positive_integer_env() {
+ local name="$1" default="$2" value
+
+ value="${!name:-$default}"
+
+ if [[ ! "$value" =~ ^[0-9]+$ ]] || [[ "$value" -eq 0 ]]; then
+ echo "${name} must be a positive integer number of seconds" >&2
+ exit 2
+ fi
+ printf '%s\n' "$value"
+}
+
+boot_timeout_seconds() {
+ positive_integer_env CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT 3600
+}
+
+destroy_timeout_seconds() {
+ positive_integer_env CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT 1800
+}
+
+smoke_run_id() {
+ printf '%s\n' "${CLOUD_COMPOSE_SMOKE_RUN_ID:-${GITHUB_RUN_ID:-}}"
+}
+
+api_delete() {
+ local provider="$1" path="$2" token
+
+ case "$provider" in
+ digitalocean)
+ token="${DIGITALOCEAN_TOKEN:-}"
+ curl -fsS -X DELETE -H "Authorization: Bearer ${token}" "https://api.digitalocean.com/v2${path}" >/dev/null
+ ;;
+ linode)
+ token="${LINODE_TOKEN:-}"
+ curl -fsS -X DELETE -H "Authorization: Bearer ${token}" "https://api.linode.com/v4${path}" >/dev/null
+ ;;
+ esac
+}
+
+api_get() {
+ local provider="$1" path="$2" token
+
+ case "$provider" in
+ digitalocean)
+ token="${DIGITALOCEAN_TOKEN:-}"
+ curl -fsS -H "Authorization: Bearer ${token}" "https://api.digitalocean.com/v2${path}"
+ ;;
+ linode)
+ token="${LINODE_TOKEN:-}"
+ curl -fsS -H "Authorization: Bearer ${token}" "https://api.linode.com/v4${path}"
+ ;;
+ esac
+}
+
+gcp_region() {
+ printf '%s\n' "${GCLOUD_REGION:-us-east5}"
+}
+
+gcp_zone() {
+ printf '%s\n' "${GCLOUD_ZONE:-$(gcp_region)-b}"
+}
+
+delete_ids() {
+ local provider="$1" path_prefix="$2" id attempt
+
+ while IFS= read -r id; do
+ if [[ -z "$id" ]]; then
+ continue
+ fi
+ for attempt in {1..12}; do
+ echo "Deleting ${provider} ${path_prefix}/${id} (attempt ${attempt})"
+ if api_delete "$provider" "${path_prefix}/${id}"; then
+ break
+ fi
+ sleep 10
+ done
+ done
+}
+
+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"
+}
+
+provider_tag_cleanup() {
+ local target="$1" run_id="${2:-}" run_tag run_fragment
+
+ 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)-"
+ fi
+
+ case "$target" in
+ digitalocean-isle)
+ api_get digitalocean "/firewalls?per_page=200" |
+ jq -r --arg run_fragment "$run_fragment" '.firewalls[]? | select(.name | startswith("cc-do-isle-")) | select($run_fragment == "" or (.name | contains($run_fragment))) | .id' |
+ delete_ids digitalocean "/firewalls"
+ api_get digitalocean "/droplets?tag_name=cloud-compose-smoke&per_page=200" |
+ jq -r --arg run_tag "$run_tag" '.droplets[]? | select((.tags // []) | index("digitalocean-isle")) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id' |
+ delete_ids digitalocean "/droplets"
+ sleep 10
+ api_get digitalocean "/volumes?tag_name=cloud-compose-smoke&per_page=200" |
+ jq -r --arg run_tag "$run_tag" '.volumes[]? | select((.tags // []) | index("digitalocean-isle")) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id' |
+ delete_ids digitalocean "/volumes"
+ ;;
+ linode-wp)
+ api_get linode "/networking/firewalls?page_size=500" |
+ jq -r --arg run_tag "$run_tag" '.data[]? | select((.tags // []) | index("cloud-compose-smoke") and index("linode-wp")) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id' |
+ delete_ids linode "/networking/firewalls"
+ api_get linode "/linode/instances?page_size=500" |
+ jq -r --arg run_tag "$run_tag" '.data[]? | select((.tags // []) | index("cloud-compose-smoke") and index("linode-wp")) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id' |
+ delete_ids linode "/linode/instances"
+ sleep 10
+ api_get linode "/volumes?page_size=500" |
+ jq -r --arg run_tag "$run_tag" '.data[]? | select((.tags // []) | index("cloud-compose-smoke") and index("linode-wp")) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id' |
+ delete_ids linode "/volumes"
+ ;;
+ gcp-wp)
+ local project name_filter
+ project="$GCLOUD_PROJECT"
+ name_filter='^cc-gwp-'
+ if [[ -n "$run_id" ]]; then
+ name_filter="^cc-gwp-$(printf '%s' "$run_id" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | cut -c1-8)-"
+ fi
+
+ gcloud compute instances list \
+ --project "$project" \
+ --filter="name~'${name_filter}'" \
+ --format=json |
+ jq -r '.[]? | [.zone, .name] | @tsv' |
+ while IFS=$'\t' read -r zone_url name; do
+ [[ -n "$name" ]] || continue
+ echo "Deleting gcp instance ${name}"
+ gcloud compute instances delete "$name" \
+ --project "$project" \
+ --zone "${zone_url##*/}" \
+ --quiet || true
+ done
+
+ gcloud compute firewall-rules list \
+ --project "$project" \
+ --filter="name~'^(allow-ssh-ipv4-|allow-ssh-ipv6-)${name_filter#^}'" \
+ --format='value(name)' |
+ while IFS= read -r name; do
+ [[ -n "$name" ]] || continue
+ echo "Deleting gcp firewall ${name}"
+ gcloud compute firewall-rules delete "$name" --project "$project" --quiet || true
+ done
+
+ gcloud compute disks list \
+ --project "$project" \
+ --filter="name~'${name_filter}'" \
+ --format=json |
+ jq -r '.[]? | [.zone, .name] | @tsv' |
+ while IFS=$'\t' read -r zone_url name; do
+ [[ -n "$name" ]] || continue
+ echo "Deleting gcp disk ${name}"
+ gcloud compute disks delete "$name" \
+ --project "$project" \
+ --zone "${zone_url##*/}" \
+ --quiet || true
+ done
+
+ gcloud iam service-accounts list \
+ --project "$project" \
+ --filter="email~'^(vm-|internal-)?${name_filter#^}.*@${project}\\.iam\\.gserviceaccount\\.com$'" \
+ --format='value(email)' |
+ while IFS= read -r email; do
+ [[ -n "$email" ]] || continue
+ echo "Deleting gcp service account ${email}"
+ gcloud projects remove-iam-policy-binding "$project" \
+ --member "serviceAccount:${email}" \
+ --role roles/logging.logWriter \
+ --quiet >/dev/null 2>&1 || true
+ gcloud projects remove-iam-policy-binding "$project" \
+ --member "serviceAccount:${email}" \
+ --role roles/monitoring.metricWriter \
+ --quiet >/dev/null 2>&1 || true
+ gcloud projects remove-iam-policy-binding "$project" \
+ --member "serviceAccount:${email}" \
+ --role "projects/${project}/roles/suspendVM" \
+ --quiet >/dev/null 2>&1 || true
+ gcloud iam service-accounts delete "$email" --project "$project" --quiet || true
+ done
+ ;;
+ esac
+}
+
+maybe_sweep_orphans() {
+ local target="$1"
+
+ if [[ "${CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS:-}" != "true" ]]; then
+ return 0
+ fi
+ echo "Sweeping prior ${target} smoke-test resources"
+ provider_tag_cleanup "$target"
+}
+
+ensure_key() {
+ local key_path="$1"
+
+ if [[ -f "$key_path" ]]; then
+ return 0
+ fi
+ mkdir -p "$(dirname "$key_path")"
+ ssh-keygen -t ed25519 -N "" -C "cloud-compose-smoke" -f "$key_path" >/dev/null
+ chmod 0600 "$key_path"
+}
+
+target_workdir() {
+ local target="$1"
+
+ printf '%s/%s\n' "${CLOUD_COMPOSE_SMOKE_WORKDIR:-$repo_root/.cloud-compose-smoke}" "$target"
+}
+
+target_var_args() {
+ local root="$1" key_path="$2" public_key
+
+ if [[ -f "${key_path}.pub" ]]; then
+ public_key="$(cat "${key_path}.pub")"
+ else
+ public_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeFakeFakeFakeFakeFakeFakeFakeFakeFakeFakeFake cloud-compose-smoke-placeholder"
+ fi
+
+ printf '%s\0%s\0' "-var" "ssh_public_key=${public_key}"
+ if grep -q 'variable "cloud_compose_source_ref"' "$root/variables.tf"; then
+ printf '%s\0%s\0' "-var" "cloud_compose_source_ref=${CLOUD_COMPOSE_SOURCE_REF:-${GITHUB_SHA:-main}}"
+ fi
+ if grep -q 'variable "smoke_run_id"' "$root/variables.tf" && [[ -n "$(smoke_run_id)" ]]; then
+ printf '%s\0%s\0' "-var" "smoke_run_id=$(smoke_run_id)"
+ fi
+ if grep -q 'variable "gcp_project_id"' "$root/variables.tf"; then
+ printf '%s\0%s\0' "-var" "gcp_project_id=${GCLOUD_PROJECT:-}"
+ fi
+ if grep -q 'variable "gcp_region"' "$root/variables.tf"; then
+ printf '%s\0%s\0' "-var" "gcp_region=$(gcp_region)"
+ fi
+ if grep -q 'variable "gcp_zone"' "$root/variables.tf"; then
+ printf '%s\0%s\0' "-var" "gcp_zone=$(gcp_zone)"
+ fi
+}
+
+scan_host_key() {
+ local host="$1" port="$2" known_hosts="$3"
+
+ mkdir -p "$(dirname "$known_hosts")"
+ touch "$known_hosts"
+ chmod 0600 "$known_hosts"
+ ssh-keyscan -p "$port" -H "$host" >> "$known_hosts" 2>/dev/null
+}
+
+ssh_cmd() {
+ local home_dir="$1" key_path="$2" host="$3" port="$4" user="$5"
+ shift 5
+
+ ssh \
+ -i "$key_path" \
+ -p "$port" \
+ -o BatchMode=yes \
+ -o ConnectTimeout=10 \
+ -o StrictHostKeyChecking=yes \
+ -o UserKnownHostsFile="$home_dir/.ssh/known_hosts" \
+ "${user}@${host}" \
+ "$@"
+}
+
+wait_for_ssh() {
+ local home_dir="$1" key_path="$2" host="$3" port="$4" user="$5"
+ local timeout_seconds
+ local deadline
+ local attempt=1
+
+ timeout_seconds="$(boot_timeout_seconds)"
+ deadline=$((SECONDS + timeout_seconds))
+
+ echo "Waiting for SSH on ${user}@${host}:${port}"
+ while (( SECONDS < deadline )); do
+ scan_host_key "$host" "$port" "$home_dir/.ssh/known_hosts" || true
+ if ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" "true" >/dev/null 2>&1; then
+ return 0
+ fi
+ echo "SSH not ready for ${host}; retrying in 15s (attempt ${attempt})"
+ attempt=$((attempt + 1))
+ sleep 15
+ done
+
+ echo "Timed out waiting for SSH on ${host}" >&2
+ return 1
+}
+
+remote_bootstrap_state() {
+ local home_dir="$1" key_path="$2" host="$3" port="$4" user="$5"
+
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" "bash -lc 'set +e
+if [ -f /home/cloud-compose/.cloud-compose-bootstrap-complete ]; then
+ echo complete
+ exit 0
+fi
+if systemctl is-active --quiet cloud-compose; then
+ echo complete
+ exit 0
+fi
+if systemctl is-active --quiet cloud-final.service; then
+ echo active
+ exit 0
+fi
+if pgrep -f \"[/]home/cloud-compose/run[.]sh|[/]home/cloud-compose/[h]ost-conf[.]sh|[/]home/cloud-compose/[h]ost-init[.]sh|[/]home/cloud-compose/[a]pp-init[.]sh|[/]home/cloud-compose/[i]nstall-dependencies|[a]pt-get|[r]pm-ostree|[d]ocker run|[s]itectl|[g]it clone\" >/dev/null; then
+ echo active
+ exit 0
+fi
+echo idle
+exit 1
+'" 2>/dev/null || true
+}
+
+wait_for_cloud_init() {
+ local home_dir="$1" key_path="$2" host="$3" port="$4" user="$5"
+ local timeout_seconds
+ local deadline
+ local status_output
+ local bootstrap_state
+ local last_dump=0
+
+ timeout_seconds="$(boot_timeout_seconds)"
+ deadline=$((SECONDS + timeout_seconds))
+
+ echo "Waiting for cloud-init on ${host}"
+ while (( SECONDS < deadline )); do
+ bootstrap_state="$(remote_bootstrap_state "$home_dir" "$key_path" "$host" "$port" "$user")"
+ if [[ "$bootstrap_state" == "complete" ]]; then
+ return 0
+ fi
+
+ status_output="$(
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "if command -v cloud-init >/dev/null 2>&1; then sudo cloud-init status --long 2>&1; else echo 'cloud-init not installed'; fi" 2>&1 || true
+ )"
+ printf '%s\n' "$status_output"
+
+ if grep -q '^status: done' <<<"$status_output"; then
+ return 0
+ fi
+ if grep -q '^status: error' <<<"$status_output"; then
+ if [[ "$bootstrap_state" == "active" ]]; then
+ echo "cloud-init reports an error while cloud-compose bootstrap is still active; continuing"
+ sleep 30
+ continue
+ fi
+ return 1
+ fi
+
+ if (( SECONDS - last_dump >= 120 )); then
+ last_dump=$SECONDS
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" "bash -lc 'set +e
+echo \"--- active bootstrap processes ---\"
+ps -eo pid,ppid,stat,etime,args | grep -E \"cloud-init|runcmd|run.sh|host-conf|host-init|app-init|install-dependencies|apt-get|docker|sitectl|git clone\" | grep -v grep
+echo \"--- /home/cloud-compose/run.log ---\"
+sudo tail -n 160 /home/cloud-compose/run.log
+echo \"--- /var/log/cloud-init-output.log ---\"
+sudo tail -n 120 /var/log/cloud-init-output.log
+'" || true
+ fi
+
+ sleep 30
+ done
+
+ echo "Timed out waiting for cloud-init on ${host}" >&2
+ return 1
+}
+
+dump_remote_logs() {
+ local home_dir="$1" key_path="$2" host="$3" port="$4" user="$5" project_dir="$6"
+ local quoted_project_dir
+ quoted_project_dir="$(shell_quote "$project_dir")"
+
+ echo "Dumping smoke-test diagnostics from ${host}" >&2
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" "bash -lc 'set +e
+echo \"--- cloud-init status ---\"
+sudo cloud-init status --long
+echo \"--- /var/log/cloud-init-output.log ---\"
+sudo tail -n 400 /var/log/cloud-init-output.log
+echo \"--- /var/log/cloud-init.log ---\"
+sudo tail -n 400 /var/log/cloud-init.log
+echo \"--- cloud-init runcmd ---\"
+sudo sed -n '1,240p' /var/lib/cloud/instance/scripts/runcmd
+echo \"--- /home/cloud-compose/run.log ---\"
+sudo tail -n 400 /home/cloud-compose/run.log
+echo \"--- cloud-compose unit ---\"
+sudo journalctl -u cloud-compose --no-pager -n 300
+echo \"--- docker ps ---\"
+sudo docker ps -a
+echo \"--- docker compose ps ---\"
+if [ -d ${quoted_project_dir} ]; then
+ runuser -u cloud-compose -- env HOME=/home/cloud-compose PROJECT_DIR=${quoted_project_dir} bash -lc \"source /home/cloud-compose/profile.sh && cd \\\"\$PROJECT_DIR\\\" && docker compose ps\"
+else
+ echo \"Project directory ${project_dir} is not present yet\"
+fi
+'" || true
+}
+
+configure_sitectl_context() {
+ local home_dir="$1" key_path="$2" output_json="$3"
+ local host port user context plugin environment site project_name project_dir compose_project_name
+
+ host="$(jq -r '.host' "$output_json")"
+ port="$(jq -r '.ssh_port' "$output_json")"
+ user="$(jq -r '.ssh_user' "$output_json")"
+ context="$(jq -r '.context_name' "$output_json")"
+ plugin="$(jq -r '.plugin' "$output_json")"
+ environment="$(jq -r '.environment' "$output_json")"
+ site="$(jq -r '.site' "$output_json")"
+ project_name="$(jq -r '.project_name' "$output_json")"
+ project_dir="$(jq -r '.project_dir' "$output_json")"
+ compose_project_name="$(jq -r '.compose_project_name' "$output_json")"
+
+ HOME="$home_dir" sitectl config set-context "$context" \
+ --type remote \
+ --ssh-hostname "$host" \
+ --ssh-port "$port" \
+ --ssh-user "$user" \
+ --ssh-key "$key_path" \
+ --project-dir "$project_dir" \
+ --site "$site" \
+ --plugin "$plugin" \
+ --environment "$environment" \
+ --project-name "$project_name" \
+ --compose-project-name "$compose_project_name" \
+ --docker-socket /var/run/docker.sock \
+ --env-file .env \
+ --default
+}
+
+run_healthcheck() {
+ local home_dir="$1" output_json="$2"
+ local context timeout interval
+
+ context="$(jq -r '.context_name' "$output_json")"
+ timeout="$(jq -r '.healthcheck_timeout' "$output_json")"
+ interval="$(jq -r '.healthcheck_interval' "$output_json")"
+
+ HOME="$home_dir" sitectl healthcheck \
+ --context "$context" \
+ --persist \
+ --timeout "$timeout" \
+ --interval "$interval" \
+ --format table
+}
+
+run_target() (
+ set -euo pipefail
+
+ local target="$1"
+ local root workdir key_path home_dir output_json public_key
+ local -a auto_args var_args
+
+ root="$(target_root "$target")"
+ target_env "$target"
+
+ workdir="$(target_workdir "$target")"
+ key_path="$workdir/id_ed25519"
+ home_dir="$workdir/home"
+ output_json="$workdir/smoke.json"
+ mkdir -p "$workdir" "$home_dir/.ssh"
+ chmod 0700 "$home_dir/.ssh"
+
+ ensure_key "$key_path"
+ mapfile -d '' -t var_args < <(target_var_args "$root" "$key_path")
+
+ auto_args=()
+ if [[ -n "${GITHUB_ACTIONS:-}" || "${CLOUD_COMPOSE_SMOKE_AUTO_APPROVE:-}" == "true" ]]; then
+ auto_args=(-auto-approve)
+ fi
+
+ cleanup_started=false
+ cleanup() {
+ local status=$?
+ local destroy_status destroy_timeout cleanup_status
+
+ trap - EXIT INT TERM HUP
+ if [[ "$cleanup_started" == "true" ]]; then
+ exit "$status"
+ fi
+ cleanup_started=true
+
+ if [[ "${CLOUD_COMPOSE_SMOKE_KEEP:-}" == "true" ]]; then
+ echo "Keeping ${target} smoke-test resources because CLOUD_COMPOSE_SMOKE_KEEP=true"
+ exit "$status"
+ fi
+
+ echo "Destroying ${target} smoke-test resources"
+ set +e
+ cleanup_status=0
+ destroy_timeout="$(destroy_timeout_seconds)"
+ if command -v timeout >/dev/null 2>&1; then
+ timeout "${destroy_timeout}s" \
+ env TF_DATA_DIR="$workdir/.terraform" \
+ terraform -chdir="$root" destroy -lock-timeout=10m -input=false "${auto_args[@]}" "${var_args[@]}"
+ destroy_status=$?
+ else
+ TF_DATA_DIR="$workdir/.terraform" terraform -chdir="$root" destroy -lock-timeout=10m -input=false "${auto_args[@]}" "${var_args[@]}"
+ destroy_status=$?
+ fi
+ if [[ "$destroy_status" -ne 0 ]]; then
+ echo "Terraform destroy failed for ${target}; attempting provider tag cleanup"
+ provider_tag_cleanup "$target" || cleanup_status=$?
+ fi
+ if [[ "$status" -eq 0 && "$destroy_status" -ne 0 && "$cleanup_status" -ne 0 ]]; then
+ exit "$destroy_status"
+ fi
+ exit "$status"
+ }
+ trap cleanup EXIT INT TERM HUP
+
+ echo "Initializing ${target}"
+ TF_DATA_DIR="$workdir/.terraform" terraform -chdir="$root" init -input=false
+ TF_DATA_DIR="$workdir/.terraform" terraform -chdir="$root" validate
+
+ maybe_sweep_orphans "$target"
+
+ echo "Applying ${target}"
+ TF_DATA_DIR="$workdir/.terraform" terraform -chdir="$root" apply -input=false "${auto_args[@]}" "${var_args[@]}"
+ TF_DATA_DIR="$workdir/.terraform" terraform -chdir="$root" output -json smoke > "$output_json"
+
+ local host port user project_dir
+ host="$(jq -r '.host' "$output_json")"
+ port="$(jq -r '.ssh_port' "$output_json")"
+ user="$(jq -r '.ssh_user' "$output_json")"
+ project_dir="$(jq -r '.project_dir' "$output_json")"
+
+ wait_for_ssh "$home_dir" "$key_path" "$host" "$port" "$user"
+ if ! wait_for_cloud_init "$home_dir" "$key_path" "$host" "$port" "$user"; then
+ dump_remote_logs "$home_dir" "$key_path" "$host" "$port" "$user" "$project_dir"
+ return 1
+ fi
+
+ configure_sitectl_context "$home_dir" "$key_path" "$output_json"
+ if ! run_healthcheck "$home_dir" "$output_json"; then
+ dump_remote_logs "$home_dir" "$key_path" "$host" "$port" "$user" "$project_dir"
+ return 1
+ fi
+
+ echo "${target} smoke test passed"
+)
+
+destroy_target() (
+ set -euo pipefail
+
+ local target="$1"
+ local root workdir key_path destroy_status destroy_timeout cleanup_status
+ local -a auto_args var_args
+
+ root="$(target_root "$target")"
+ target_env "$target"
+
+ workdir="$(target_workdir "$target")"
+ key_path="$workdir/id_ed25519"
+ mapfile -d '' -t var_args < <(target_var_args "$root" "$key_path")
+
+ auto_args=()
+ if [[ -n "${GITHUB_ACTIONS:-}" || "${CLOUD_COMPOSE_SMOKE_AUTO_APPROVE:-}" == "true" ]]; then
+ auto_args=(-auto-approve)
+ fi
+
+ destroy_status=0
+ if [[ -d "$workdir/.terraform" ]]; then
+ echo "Destroying ${target} smoke-test resources from Terraform state"
+ set +e
+ destroy_timeout="$(destroy_timeout_seconds)"
+ if command -v timeout >/dev/null 2>&1; then
+ timeout "${destroy_timeout}s" \
+ env TF_DATA_DIR="$workdir/.terraform" \
+ terraform -chdir="$root" destroy -lock-timeout=10m -input=false "${auto_args[@]}" "${var_args[@]}"
+ destroy_status=$?
+ else
+ TF_DATA_DIR="$workdir/.terraform" terraform -chdir="$root" destroy -lock-timeout=10m -input=false "${auto_args[@]}" "${var_args[@]}"
+ destroy_status=$?
+ fi
+ set -e
+ else
+ echo "No Terraform data directory found for ${target}; using provider cleanup"
+ fi
+
+ cleanup_status=0
+ provider_tag_cleanup "$target" "$(smoke_run_id)" || cleanup_status=$?
+
+ if [[ "$destroy_status" -ne 0 && "$cleanup_status" -eq 0 ]]; then
+ echo "Provider tag cleanup completed for ${target} after Terraform destroy failed"
+ return 0
+ fi
+
+ if [[ "$destroy_status" -ne 0 ]]; then
+ return "$destroy_status"
+ fi
+ return "$cleanup_status"
+)
+
+main() {
+ if [[ "$#" -ne 1 ]]; then
+ usage >&2
+ exit 2
+ fi
+
+ case "$1" in
+ sweep)
+ require_cmd jq
+ require_cmd curl
+ target_env digitalocean-isle
+ provider_tag_cleanup digitalocean-isle "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}"
+ target_env linode-wp
+ provider_tag_cleanup linode-wp "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}"
+ require_cmd gcloud
+ target_env gcp-wp
+ provider_tag_cleanup gcp-wp "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}"
+ exit 0
+ ;;
+ sweep-digitalocean-isle)
+ require_cmd jq
+ require_cmd curl
+ target_env digitalocean-isle
+ provider_tag_cleanup digitalocean-isle "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}"
+ exit 0
+ ;;
+ sweep-linode-wp)
+ require_cmd jq
+ require_cmd curl
+ target_env linode-wp
+ provider_tag_cleanup linode-wp "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}"
+ exit 0
+ ;;
+ sweep-gcp-wp)
+ require_cmd jq
+ require_cmd gcloud
+ target_env gcp-wp
+ provider_tag_cleanup gcp-wp "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}"
+ exit 0
+ ;;
+ destroy-digitalocean-isle)
+ require_cmd jq
+ require_cmd curl
+ require_cmd terraform
+ destroy_target digitalocean-isle
+ exit 0
+ ;;
+ destroy-linode-wp)
+ require_cmd jq
+ require_cmd curl
+ require_cmd terraform
+ destroy_target linode-wp
+ exit 0
+ ;;
+ destroy-gcp-wp)
+ require_cmd jq
+ require_cmd gcloud
+ require_cmd terraform
+ destroy_target gcp-wp
+ exit 0
+ ;;
+ esac
+
+ require_cmd jq
+ require_cmd curl
+ require_cmd ssh
+ require_cmd ssh-keygen
+ require_cmd ssh-keyscan
+ require_cmd sitectl
+ require_cmd terraform
+
+ case "$1" in
+ all)
+ run_target digitalocean-isle
+ run_target linode-wp
+ run_target gcp-wp
+ ;;
+ digitalocean-isle|linode-wp|gcp-wp)
+ run_target "$1"
+ ;;
+ *)
+ echo "Unknown smoke target: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+}
+
+main "$@"
diff --git a/ci/install-sitectl-apt.sh b/ci/install-sitectl-apt.sh
new file mode 100755
index 0000000..1f992b0
--- /dev/null
+++ b/ci/install-sitectl-apt.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+packages=("$@")
+if [ "${#packages[@]}" -eq 0 ]; then
+ packages=(sitectl-isle sitectl-wp)
+fi
+
+sudo apt-get update -qq
+sudo apt-get install -y ca-certificates curl gnupg openssh-client jq
+
+curl -fsSL https://packages.libops.io/sitectl/sitectl-archive-keyring.asc |
+ sudo gpg --dearmor -o /usr/share/keyrings/sitectl-archive-keyring.gpg
+
+echo "deb [signed-by=/usr/share/keyrings/sitectl-archive-keyring.gpg] https://packages.libops.io/sitectl ./" |
+ sudo tee /etc/apt/sources.list.d/sitectl.list >/dev/null
+
+sudo apt-get update -qq
+sudo apt-get install -y "${packages[@]}"
diff --git a/ci/terraform-validate.sh b/ci/terraform-validate.sh
new file mode 100644
index 0000000..7a53b88
--- /dev/null
+++ b/ci/terraform-validate.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "$0")/.." && pwd)"
+cleanup_data_root=""
+
+cleanup() {
+ if [[ -n "${cleanup_data_root:-}" ]]; then
+ rm -rf "$cleanup_data_root"
+ fi
+}
+
+require_cmd() {
+ command -v "$1" >/dev/null 2>&1 || {
+ echo "Missing required command: $1" >&2
+ exit 1
+ }
+}
+
+safe_name() {
+ local value="$1"
+ value="${value//\//_}"
+ value="${value// /_}"
+ printf '%s\n' "$value"
+}
+
+validate_root() {
+ local root="$1" data_root="$2"
+ local rel data_dir lockfile created_lock init_status validate_status
+ local -a init_args
+
+ rel="${root#"$repo_root"/}"
+ if [[ "$root" == "$repo_root" ]]; then
+ rel="."
+ fi
+
+ data_dir="${data_root}/$(safe_name "$rel")"
+ lockfile="${root}/.terraform.lock.hcl"
+ created_lock=false
+ init_args=(-backend=false -input=false)
+
+ if [[ -f "$lockfile" ]]; then
+ init_args+=(-lockfile=readonly)
+ else
+ created_lock=true
+ fi
+
+ echo "Validating Terraform in ${rel}"
+
+ init_status=0
+ for attempt in 1 2 3; do
+ if TF_DATA_DIR="$data_dir" terraform -chdir="$root" init "${init_args[@]}" >/dev/null; then
+ init_status=0
+ break
+ fi
+ init_status=$?
+ if [[ "$attempt" -lt 3 ]]; then
+ echo "terraform init failed in ${rel}; retrying in $((attempt * 10))s (attempt ${attempt}/3)" >&2
+ sleep $((attempt * 10))
+ fi
+ done
+
+ validate_status=0
+ if [[ "$init_status" -eq 0 ]]; then
+ TF_DATA_DIR="$data_dir" terraform -chdir="$root" validate -no-color || validate_status=$?
+ fi
+
+ if [[ "$created_lock" == "true" ]]; then
+ rm -f "$lockfile"
+ fi
+
+ if [[ "$init_status" -ne 0 ]]; then
+ return "$init_status"
+ fi
+ return "$validate_status"
+}
+
+main() {
+ local data_root cleanup
+ local -a roots
+
+ require_cmd terraform
+
+ if [[ -n "${TF_VALIDATE_DATA_ROOT:-}" ]]; then
+ data_root="$TF_VALIDATE_DATA_ROOT"
+ cleanup=false
+ mkdir -p "$data_root"
+ else
+ data_root="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-terraform-validate.XXXXXX")"
+ cleanup=true
+ fi
+
+ if [[ "$cleanup" == "true" ]]; then
+ cleanup_data_root="$data_root"
+ trap cleanup EXIT
+ fi
+
+ export TF_PLUGIN_CACHE_DIR="${TF_PLUGIN_CACHE_DIR:-$data_root/plugin-cache}"
+ mkdir -p "$TF_PLUGIN_CACHE_DIR"
+
+ mapfile -t roots < <(
+ find "$repo_root" \
+ -path "*/.terraform" -prune -o \
+ -path "$repo_root/docs/site" -prune -o \
+ -name "*.tf" -printf '%h\n' |
+ sort -u
+ )
+
+ if [[ "${#roots[@]}" -eq 0 ]]; then
+ echo "No Terraform files found"
+ return 0
+ fi
+
+ for root in "${roots[@]}"; do
+ validate_root "$root" "$data_root"
+ done
+}
+
+main "$@"
diff --git a/docs/Dockerfile b/docs/Dockerfile
new file mode 100644
index 0000000..866a07e
--- /dev/null
+++ b/docs/Dockerfile
@@ -0,0 +1,12 @@
+FROM python:3.14-alpine3.22@sha256:6b91e66ab2a880ce9ca5a1b91c70f45963ff71ff68268df056336e1a657d5efd
+
+WORKDIR /work
+
+RUN apk add --no-cache git && \
+ pip install uv && \
+ uv pip install --break-system-packages --system zensical==0.0.46
+
+EXPOSE 8080
+
+ENTRYPOINT ["zensical"]
+CMD ["serve", "--dev-addr", "0.0.0.0:8080"]
diff --git a/docs/css/custom.css b/docs/css/custom.css
new file mode 100644
index 0000000..584663c
--- /dev/null
+++ b/docs/css/custom.css
@@ -0,0 +1,53 @@
+.mermaid-expandable {
+ cursor: zoom-in;
+}
+
+.mermaid-expandable:focus-visible {
+ outline: 2px solid var(--md-accent-fg-color);
+ outline-offset: 0.3rem;
+}
+
+.mermaid-lightbox {
+ background: transparent;
+ border: 0;
+ max-width: min(96vw, 1400px);
+ padding: 0;
+ width: 96vw;
+}
+
+.mermaid-lightbox::backdrop {
+ background: rgba(0, 0, 0, 0.72);
+}
+
+.mermaid-lightbox__frame {
+ background: var(--md-default-bg-color);
+ border-radius: 0.6rem;
+ box-shadow: var(--md-shadow-z3);
+ display: flex;
+ flex-direction: column;
+ max-height: 90vh;
+ overflow: hidden;
+}
+
+.mermaid-lightbox__close {
+ appearance: none;
+ background: transparent;
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ cursor: pointer;
+ display: block;
+ margin: 1rem 1rem 0 auto;
+ padding: 0.35rem 0.75rem;
+}
+
+.mermaid-lightbox__body {
+ flex: 1 1 auto;
+ max-height: calc(90vh - 4rem);
+ overflow: auto;
+ padding: 1rem;
+}
+
+.mermaid-lightbox__body svg {
+ display: block;
+ margin: 0 auto;
+}
diff --git a/docs/examples.md b/docs/examples.md
new file mode 100644
index 0000000..4849678
--- /dev/null
+++ b/docs/examples.md
@@ -0,0 +1,58 @@
+# Examples
+
+The `examples/` directory contains runnable Terraform entry points for common
+compose app shapes.
+
+## Single app examples
+
+- `examples/ojs` deploys OJS production and staging environments.
+- `examples/wp` deploys a WordPress compose project with `sitectl-wp`.
+- `examples/drupal` deploys a Drupal compose project with `sitectl-drupal`.
+- `examples/isle` deploys an ISLE compose project with `sitectl-isle`.
+- `examples/digitalocean` deploys WordPress on DigitalOcean.
+- `examples/linode` deploys Drupal on Linode.
+
+Each single-app example passes a provider object and a runtime object:
+
+```hcl
+runtime = {
+ compose = {
+ repo = "https://github.com/libops/wp.git"
+ branch = "main"
+ }
+ sitectl = {
+ packages = ["sitectl", "sitectl-wp"]
+ plugin = "wp"
+ }
+}
+```
+
+## Bin packing
+
+`examples/binpack` shows how several compose projects can share one VM:
+
+```hcl
+compose_projects = {
+runtime = {
+ compose = {
+ primary = "wp"
+ projects = {
+ wp = {
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ ingress_port = 8080
+ sitectl_plugin = "wp"
+ sitectl_packages = ["sitectl-wp"]
+ }
+ drupal = {
+ docker_compose_repo = "https://github.com/libops/drupal.git"
+ ingress_port = 8081
+ sitectl_plugin = "drupal"
+ sitectl_packages = ["sitectl-drupal"]
+ }
+ }
+ }
+}
+```
+
+`cloud-compose` provides the VM/runtime primitives for bin packing. Placement
+policy is intentionally left to consumers for now.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..def9adc
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,28 @@
+# cloud-compose
+
+`cloud-compose` runs Docker Compose projects on cloud VMs while keeping Git as
+the desired application state and `sitectl` as the application reconciler.
+
+Terraform owns durable infrastructure. The VM runtime installs Docker, Docker
+Compose, Docker Buildx, `sitectl`, selected sitectl plugins, and host support
+services. During init it checks out each compose repository, creates a sitectl
+context, and starts the app through the same lifecycle path used by later
+rollouts.
+
+## What it provides
+
+- Compose app lifecycle management through `sitectl`
+- Multi-app VM support through `compose_projects`
+- GCP app and VM identity separation
+- Optional Vault Agent contract
+- Optional GCP power management through Cloud Run and lightsout
+- Provider-neutral runtime contracts for DigitalOcean and Linode
+- Nightly MariaDB backups through systemd timers
+
+## Start here
+
+- [Runtime contracts](runtime-contracts.md) explains the VM/app contract.
+- [Managed runtime](managed-runtime.md) covers host tools and internal services.
+- [Rollout API](rollout.md) covers authenticated deploy triggers.
+- [DigitalOcean and Linode](non-gcp-providers.md) covers non-GCP provider boundaries.
+- [Examples](examples.md) covers the example modules.
diff --git a/docs/js/mermaid-init.mjs b/docs/js/mermaid-init.mjs
new file mode 100644
index 0000000..ad08c6e
--- /dev/null
+++ b/docs/js/mermaid-init.mjs
@@ -0,0 +1,136 @@
+import mermaid from "https://unpkg.com/mermaid@11.13.0/dist/mermaid.esm.min.mjs";
+
+mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: "loose",
+});
+
+const LIGHTBOX_ID = "mermaid-lightbox";
+
+const ensureLightbox = () => {
+ let lightbox = document.getElementById(LIGHTBOX_ID);
+ if (lightbox) {
+ return lightbox;
+ }
+
+ lightbox = document.createElement("dialog");
+ lightbox.id = LIGHTBOX_ID;
+ lightbox.className = "mermaid-lightbox";
+ lightbox.setAttribute("aria-label", "Expanded Mermaid diagram");
+
+ const frame = document.createElement("div");
+ frame.className = "mermaid-lightbox__frame";
+
+ const closeButton = document.createElement("button");
+ closeButton.type = "button";
+ closeButton.className = "mermaid-lightbox__close";
+ closeButton.setAttribute("aria-label", "Close expanded diagram");
+ closeButton.textContent = "Close";
+ closeButton.addEventListener("click", () => {
+ lightbox.close();
+ });
+
+ const body = document.createElement("div");
+ body.className = "mermaid-lightbox__body";
+
+ frame.append(closeButton, body);
+ lightbox.append(frame);
+
+ lightbox.addEventListener("click", (event) => {
+ if (event.target === lightbox) {
+ lightbox.close();
+ }
+ });
+
+ document.body.append(lightbox);
+ return lightbox;
+};
+
+const openLightbox = (container) => {
+ const svg = container.querySelector("svg");
+ if (!svg) {
+ return;
+ }
+
+ const lightbox = ensureLightbox();
+ const body = lightbox.querySelector(".mermaid-lightbox__body");
+ const clone = svg.cloneNode(true);
+
+ clone.removeAttribute("width");
+ clone.removeAttribute("height");
+ clone.style.width = "100%";
+ clone.style.height = "auto";
+
+ body.replaceChildren(clone);
+
+ if (!lightbox.open) {
+ lightbox.showModal();
+ }
+};
+
+const attachExpandBehavior = (container) => {
+ if (container.dataset.mermaidExpandable === "true") {
+ return;
+ }
+ container.dataset.mermaidExpandable = "true";
+ container.classList.add("mermaid-expandable");
+ container.tabIndex = 0;
+ container.setAttribute("role", "button");
+ container.setAttribute("aria-label", "Open Mermaid diagram in a larger view");
+ container.title = "Click to enlarge diagram";
+
+ container.addEventListener("click", (event) => {
+ if (event.target.closest("button, a")) {
+ return;
+ }
+ openLightbox(container);
+ });
+
+ container.addEventListener("keydown", (event) => {
+ if (event.key !== "Enter" && event.key !== " ") {
+ return;
+ }
+ event.preventDefault();
+ openLightbox(container);
+ });
+};
+
+const renderDiagram = async (container, index) => {
+ if (container.dataset.mermaidRendered === "true") {
+ return;
+ }
+ container.dataset.mermaidRendered = "true";
+
+ const code = container.querySelector("code");
+ const source = code ? code.textContent : container.textContent;
+ if (!source || !source.trim()) {
+ return;
+ }
+
+ const renderTarget = document.createElement("div");
+ renderTarget.className = "mermaid";
+
+ try {
+ const result = await mermaid.render(`cloud-compose-mermaid-${index}`, source);
+ renderTarget.innerHTML = result.svg;
+ attachExpandBehavior(renderTarget);
+ container.replaceWith(renderTarget);
+ document.dispatchEvent(new CustomEvent("mermaid:rendered", { detail: renderTarget }));
+ } catch (error) {
+ console.error("Mermaid render failed", error);
+ }
+};
+
+const renderAll = () => {
+ document.querySelectorAll("pre.mermaid").forEach((container, index) => {
+ void renderDiagram(container, index);
+ });
+};
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", renderAll, { once: true });
+} else {
+ renderAll();
+}
+
+window.mermaid = mermaid;
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
new file mode 100644
index 0000000..f879f50
--- /dev/null
+++ b/docs/mkdocs.yml
@@ -0,0 +1,69 @@
+site_name: cloud-compose
+site_description: Run Docker Compose projects on cloud VMs with sitectl
+repo_url: https://github.com/libops/cloud-compose
+site_url: !ENV [ SITE_URL, "https://libops.github.io/cloud-compose/" ]
+docs_dir: .
+edit_uri: edit/main/docs/
+
+theme:
+ name: material
+ features:
+ - navigation.footer
+ - navigation.indexes
+ - navigation.sections
+ - navigation.top
+ - content.action.edit
+ - content.action.view
+ - content.code.copy
+ - content.code.select
+ - search.highlight
+ palette:
+ primary: indigo
+ accent: blue
+
+plugins:
+ - search
+ - tags
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - def_list
+ - footnotes
+ - md_in_html
+ - toc:
+ permalink: true
+ - pymdownx.details
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.inlinehilite
+ - pymdownx.magiclink:
+ normalize_issue_symbols: true
+ repo_url_shorthand: true
+ user: libops
+ repo: cloud-compose
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:pymdownx.superfences.fence_code_format ""
+ - pymdownx.tabbed:
+ alternate_style: true
+ - pymdownx.tasklist:
+ custom_checkbox: true
+
+extra_css:
+ - css/custom.css
+
+extra_javascript:
+ - js/mermaid-init.mjs
+
+nav:
+ - Home: index.md
+ - Runtime contracts: runtime-contracts.md
+ - Managed runtime: managed-runtime.md
+ - Rollout API: rollout.md
+ - DigitalOcean and Linode: non-gcp-providers.md
+ - Examples: examples.md
diff --git a/docs/non-gcp-providers.md b/docs/non-gcp-providers.md
new file mode 100644
index 0000000..decf9a9
--- /dev/null
+++ b/docs/non-gcp-providers.md
@@ -0,0 +1,75 @@
+# DigitalOcean And Linode
+
+DigitalOcean and Linode use provider-specific Terraform modules that share the
+same Linux runtime contract as the GCP module:
+
+- `modules/digitalocean`
+- `modules/linode`
+
+Both modules:
+
+- mount a persistent app/data disk at `/mnt/disks/data`
+- mount a persistent Docker-volume disk at `/mnt/disks/volumes`
+- bind-mount `/mnt/disks/volumes` to `/mnt/disks/data/docker/volumes`
+- run the cloud-compose rootfs bootstrap after mounts are ready
+- set `POWER_MANAGEMENT_ENABLED=false`
+- set `CLOUD_COMPOSE_PROVIDER` to `digitalocean` or `linode`
+- create one firewall rule per compose app ingress port
+
+Destroying or replacing the VM should not destroy either persistent volume
+unless the whole Terraform stack is destroyed. This matches the GCP module's
+data disk and Docker-volume disk behavior.
+
+Fedora CoreOS should use the CoreOS installer path. Debian and Ubuntu should use
+the apt installer path. Both paths install the same minimum runtime surface:
+
+- Docker
+- Docker Compose
+- Docker Buildx
+- Git
+- jq
+- make
+- `sitectl` and selected plugins
+
+DigitalOcean and Linode do not currently have a cloud-compose workload identity
+contract equivalent to GCP IAM for Vault Agent. Their modules default
+`vault_auth_method` to `consumer-managed`. When `vault_agent_enabled=true`, pass
+the auth method through `vault_agent_additional_config` or a rootfs overlay.
+
+## DigitalOcean
+
+Use `DIGITALOCEAN_TOKEN` or an explicit DigitalOcean provider configuration in
+the calling stack.
+
+```hcl
+module "wp" {
+ source = "./modules/digitalocean"
+
+ name = "cc-wp"
+ region = "tor1"
+ ssh_keys = var.digitalocean_ssh_keys
+ cloud_compose_ssh_keys = var.operator_ssh_keys
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ sitectl_packages = ["sitectl", "sitectl-wp"]
+ sitectl_plugin = "wp"
+}
+```
+
+## Linode
+
+Use `LINODE_TOKEN` or an explicit Linode provider configuration in the calling
+stack.
+
+```hcl
+module "drupal" {
+ source = "./modules/linode"
+
+ name = "cc-drupal"
+ region = "us-east"
+ authorized_keys = var.operator_ssh_keys
+ cloud_compose_ssh_keys = var.operator_ssh_keys
+ docker_compose_repo = "https://github.com/libops/drupal.git"
+ sitectl_packages = ["sitectl", "sitectl-drupal"]
+ sitectl_plugin = "drupal"
+}
+```
diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md
new file mode 100644
index 0000000..aebbe26
--- /dev/null
+++ b/docs/runtime-contracts.md
@@ -0,0 +1,85 @@
+# Runtime Contracts
+
+`cloud-compose` treats Git as the desired application state and `sitectl` as the
+runtime reconciler. Terraform provisions durable infrastructure and writes the
+VM contract; boot and rollout then use `sitectl` to move each compose project to
+the desired state.
+
+## Compose Apps
+
+Use `compose_projects` to run more than one compose app on the same VM. Each map
+entry gets:
+
+- a git checkout
+- a `sitectl` local context
+- a compose project name
+- an ingress port
+- init/up/down/rollout commands
+
+`primary_compose_project` selects the app used by Cloud Run power-management
+ingress and the default rollout endpoint. Bin-packing policy is intentionally
+not implemented yet; callers decide which apps share a VM.
+
+Every app should expose a distinct host port through its compose/Traefik config.
+The generated app env file exports `COMPOSE_BIND_PORT` for that purpose.
+
+## Sitectl
+
+During init, the VM creates a `sitectl` context for every app. The default up and
+rollout commands use `sitectl deploy`, which now reuses the compose reconcile
+path before `docker compose up`. This means plugin-declared init artifacts,
+volumes, and buildable images are repaired during normal deployment.
+
+## Vault
+
+`cloud-compose` defines the Vault contract and leaves product-specific Vault
+roles, policies, mounts, and secret paths to consumers.
+
+GCP defaults to Vault GCP IAM auth with the app GSA identity, not the VM GSA. The
+VM still uses its broader GSA for host work such as logs and key rotation. The
+app GSA key is rotated into:
+
+- `/mnt/disks/data/cloud-compose/app/GOOGLE_APPLICATION_CREDENTIALS`
+- each app's `secrets/GOOGLE_APPLICATION_CREDENTIALS`
+
+Vault Agent can use that credential to authenticate as the app identity and
+render files for compose projects. DigitalOcean and Linode do not currently
+provide a matching GCP-style workload identity contract here; use
+`vault_auth_method = "consumer-managed"` and provide auth config through
+`vault_agent_additional_config` or consumer rootfs overlays.
+
+## OS Families
+
+Dependency installation dispatches by OS family:
+
+- COS: installs Docker Compose, Buildx, and `make` into writable paths.
+- Fedora CoreOS: configures the LibOps RPM repo, installs host packages with
+ `rpm-ostree install --apply-live`, and installs Docker CLI plugins.
+- Debian/Ubuntu: installs Docker, Git, jq, make, and CA/curl packages with apt,
+ then installs Docker CLI plugins.
+
+Provider modules should mount persistent data and Docker-volume disks before the
+runtime starts. Destroying/recreating the VM must not destroy these volumes.
+
+## Backups
+
+`cloud-compose-mariadb-backup.timer` runs nightly between 9pm and 7am EST. It
+uses a fixed randomized delay so deployments spread out across that window while
+keeping a stable schedule on each VM. The timer executes:
+
+```bash
+sitectl mariadb backup --context "$SITECTL_CONTEXT_NAME" --gzip --output "$path"
+```
+
+for every app context. Backups are written under
+`/mnt/disks/data/backups/mariadb/object({
project_id = string
project_number = 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, "")
}), {})
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)
name = optional(string, "")
subnetwork = optional(string, "")
ip_cidr_range = optional(string, "10.42.0.0/24")
power_button_allowed_ips = optional(list(string), [])
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, true)
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"])
}), {})
}) | n/a | yes |
+|------|-------------|------|---------|:--------:|
| [name](#input\_name) | Deployment name. | `string` | n/a | yes |
-| [runtime](#input\_runtime) | Provider-neutral compose/runtime settings. | object({
rootfs = 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), [
"sitectl config set-context \"$${SITECTL_CONTEXT_NAME}\" --type local --project-dir \"$${DOCKER_COMPOSE_DIR}\" --site \"$${CLOUD_COMPOSE_INSTANCE_NAME}\" --plugin \"$${SITECTL_PLUGIN}\" --environment \"$${SITECTL_ENVIRONMENT}\" --project-name \"$${CLOUD_COMPOSE_INSTANCE_NAME}\" --compose-project-name \"$${COMPOSE_PROJECT_NAME}\" --docker-socket /var/run/docker.sock --env-file .env --default"
])
up = optional(list(string), [
"sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" up -d --remove-orphans",
"sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
])
down = optional(list(string), [
"sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" down"
])
rollout = optional(list(string), [
"TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-$${DOCKER_COMPOSE_BRANCH:-main}}}\"",
"if [ -x ./scripts/rollout.sh ]; then ./scripts/rollout.sh; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --branch \"$TARGET_REF\"; fi",
"sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
])
}), {})
sitectl = optional(object({
packages = optional(list(string), ["sitectl"])
version = optional(string, "latest")
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
healthcheck_timeout = optional(string, "10m")
healthcheck_interval = optional(string, "15s")
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.2.0")
# 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, true)
internal_services_auto_update = optional(bool, true)
lightsout_image = optional(string, "ghcr.io/libops/lightsout:main")
cap_image = optional(string, "ghcr.io/libops/cap:main")
cadvisor_image = optional(string, "ghcr.io/google/cadvisor:v0.57.0@sha256:e75bdb03b74b0b6995f208f166fead2e6e555dde73e44200113bb26f41b1981d")
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, "gcp-iam")
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, "")
})), [])
}), {})
}) | `{}` | no |
+| [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. | 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 |
+| [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, "")
}), {})
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)
name = optional(string, "")
subnetwork = optional(string, "")
ip_cidr_range = optional(string, "10.42.0.0/24")
power_button_allowed_ips = optional(list(string), [])
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, true)
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. | 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), [
"sitectl config set-context \"$${SITECTL_CONTEXT_NAME}\" --type local --project-dir \"$${DOCKER_COMPOSE_DIR}\" --site \"$${CLOUD_COMPOSE_INSTANCE_NAME}\" --plugin \"$${SITECTL_PLUGIN}\" --environment \"$${SITECTL_ENVIRONMENT}\" --project-name \"$${CLOUD_COMPOSE_INSTANCE_NAME}\" --compose-project-name \"$${COMPOSE_PROJECT_NAME}\" --docker-socket /var/run/docker.sock --env-file .env --default"
])
up = optional(list(string), [
"sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" up -d --remove-orphans",
"sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
])
down = optional(list(string), [
"sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" down"
])
rollout = optional(list(string), [
"TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-$${DOCKER_COMPOSE_BRANCH:-main}}}\"",
"if [ -x ./scripts/rollout.sh ]; then ./scripts/rollout.sh; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --branch \"$TARGET_REF\"; fi",
"sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
])
}), {})
sitectl = optional(object({
packages = optional(list(string), ["sitectl"])
version = optional(string, "latest")
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
healthcheck_timeout = optional(string, "10m")
healthcheck_interval = optional(string, "15s")
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.2.0")
# 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, true)
internal_services_auto_update = optional(bool, true)
lightsout_image = optional(string, "ghcr.io/libops/lightsout:main")
cap_image = optional(string, "ghcr.io/libops/cap:main")
cadvisor_image = optional(string, "ghcr.io/google/cadvisor:v0.57.0@sha256:e75bdb03b74b0b6995f208f166fead2e6e555dde73e44200113bb26f41b1981d")
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, "gcp-iam")
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 |
## Outputs
| Name | Description |
-| ---- | ----------- |
+|------|-------------|
| [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. |
| [compose\_projects](#output\_compose\_projects) | Normalized compose project manifest. |
-| [external\_ip](#output\_external\_ip) | The Google Compute instance external IPv4 address. |
-| [instance](#output\_instance) | The Google Compute instance ID, name, zone, data disk, and GSA. |
-| [instance\_id](#output\_instance\_id) | The Google Compute instance ID. |
-| [internal\_ip](#output\_internal\_ip) | The Google Compute instance internal IPv4 address. |
+| [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. |
| [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. |
+| [template](#output\_template) | Selected compose template preset. |
| [urls](#output\_urls) | Cloud Run ingress URLs by region. |
+| [volumes](#output\_volumes) | Selected provider persistent volume details where available. |
diff --git a/ci/cloud-smoke.sh b/ci/cloud-smoke.sh
index 25c63a2..1b80bb5 100755
--- a/ci/cloud-smoke.sh
+++ b/ci/cloud-smoke.sh
@@ -8,21 +8,20 @@ usage() {
cat <<'EOF'
Usage:
ci/cloud-smoke.sh all
+ ci/cloud-smoke.sh 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 |
| [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, "")
}), {})
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)
name = optional(string, "")
subnetwork = optional(string, "")
ip_cidr_range = optional(string, "10.42.0.0/24")
power_button_allowed_ips = optional(list(string), [])
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, true)
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. | 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), [
"sitectl config set-context \"$${SITECTL_CONTEXT_NAME}\" --type local --project-dir \"$${DOCKER_COMPOSE_DIR}\" --site \"$${CLOUD_COMPOSE_INSTANCE_NAME}\" --plugin \"$${SITECTL_PLUGIN}\" --environment \"$${SITECTL_ENVIRONMENT}\" --project-name \"$${CLOUD_COMPOSE_INSTANCE_NAME}\" --compose-project-name \"$${COMPOSE_PROJECT_NAME}\" --docker-socket /var/run/docker.sock --env-file .env --default"
])
up = optional(list(string), [
"sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" up -d --remove-orphans",
"sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
])
down = optional(list(string), [
"sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" down"
])
rollout = optional(list(string), [
"TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-$${DOCKER_COMPOSE_BRANCH:-main}}}\"",
"if [ -x ./scripts/rollout.sh ]; then ./scripts/rollout.sh; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --branch \"$TARGET_REF\"; fi",
"sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
])
}), {})
sitectl = optional(object({
packages = optional(list(string), ["sitectl"])
version = optional(string, "latest")
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
healthcheck_timeout = optional(string, "10m")
healthcheck_interval = optional(string, "15s")
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.2.0")
# 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, true)
internal_services_auto_update = optional(bool, true)
lightsout_image = optional(string, "ghcr.io/libops/lightsout:main")
cap_image = optional(string, "ghcr.io/libops/cap:main")
cadvisor_image = optional(string, "ghcr.io/google/cadvisor:v0.57.0@sha256:e75bdb03b74b0b6995f208f166fead2e6e555dde73e44200113bb26f41b1981d")
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, "gcp-iam")
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 |
+| [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), [
"sitectl config set-context \"$${SITECTL_CONTEXT_NAME}\" --type local --project-dir \"$${DOCKER_COMPOSE_DIR}\" --site \"$${CLOUD_COMPOSE_INSTANCE_NAME}\" --plugin \"$${SITECTL_PLUGIN}\" --environment \"$${SITECTL_ENVIRONMENT}\" --project-name \"$${CLOUD_COMPOSE_INSTANCE_NAME}\" --compose-project-name \"$${COMPOSE_PROJECT_NAME}\" --docker-socket /var/run/docker.sock --env-file .env --default"
])
up = optional(list(string), [
"sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" up -d --remove-orphans",
"sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
])
down = optional(list(string), [
"sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" down"
])
rollout = optional(list(string), [
"TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-$${DOCKER_COMPOSE_BRANCH:-main}}}\"",
"if [ -x ./scripts/rollout.sh ]; then ./scripts/rollout.sh; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --branch \"$TARGET_REF\"; fi",
"sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\"",
"if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
])
}), {})
sitectl = optional(object({
packages = optional(list(string), ["sitectl"])
version = optional(string, "latest")
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
healthcheck_timeout = optional(string, "20m")
healthcheck_interval = optional(string, "15s")
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.2.0")
# 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, true)
internal_services_auto_update = optional(bool, true)
lightsout_image = optional(string, "ghcr.io/libops/lightsout:main")
cap_image = optional(string, "ghcr.io/libops/cap:main")
cadvisor_image = optional(string, "ghcr.io/google/cadvisor:v0.57.0@sha256:e75bdb03b74b0b6995f208f166fead2e6e555dde73e44200113bb26f41b1981d")
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, "gcp-iam")
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 |
## Outputs
diff --git a/modules/digitalocean/variables.tf b/modules/digitalocean/variables.tf
index a55efe5..4b95ae7 100644
--- a/modules/digitalocean/variables.tf
+++ b/modules/digitalocean/variables.tf
@@ -114,7 +114,7 @@ variable "runtime" {
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
- healthcheck_timeout = optional(string, "10m")
+ healthcheck_timeout = optional(string, "20m")
healthcheck_interval = optional(string, "15s")
verify_args = optional(list(string), [])
}), {})
diff --git a/modules/linode/variables.tf b/modules/linode/variables.tf
index c6179fb..efdd15d 100644
--- a/modules/linode/variables.tf
+++ b/modules/linode/variables.tf
@@ -117,7 +117,7 @@ variable "runtime" {
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
- healthcheck_timeout = optional(string, "10m")
+ healthcheck_timeout = optional(string, "20m")
healthcheck_interval = optional(string, "15s")
verify_args = optional(list(string), [])
}), {})
diff --git a/variables.tf b/variables.tf
index d2387b0..0d20e19 100644
--- a/variables.tf
+++ b/variables.tf
@@ -287,7 +287,7 @@ variable "runtime" {
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
- healthcheck_timeout = optional(string, "10m")
+ healthcheck_timeout = optional(string, "20m")
healthcheck_interval = optional(string, "15s")
verify_args = optional(list(string), [])
}), {})
From 74b894e9365d6d6c7dea7c614723cd197d22b2df Mon Sep 17 00:00:00 2001
From: "H.E. Pennypacker" <115990865+pennypacker-he@users.noreply.github.com>
Date: Sun, 5 Jul 2026 20:43:35 +0000
Subject: [PATCH 7/8] [minor] Tighten smoke bootstrap timeouts
---
ci/cloud-smoke.sh | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/ci/cloud-smoke.sh b/ci/cloud-smoke.sh
index f93bcdf..9ed3a24 100755
--- a/ci/cloud-smoke.sh
+++ b/ci/cloud-smoke.sh
@@ -133,7 +133,7 @@ positive_integer_env() {
}
boot_timeout_seconds() {
- positive_integer_env CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT 3600
+ positive_integer_env CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT 1200
}
destroy_timeout_seconds() {
@@ -634,6 +634,9 @@ run_target() (
root="$(target_root "$target")"
target_env "$target"
+ if [[ -z "${CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT:-}" ]] && [[ "$(target_template "$target")" == "isle" ]]; then
+ export CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT=1800
+ fi
workdir="$(target_workdir "$target")"
key_path="$workdir/id_ed25519"
From 370d267c86d7ac3a7c583ed8466c3479e356c8d8 Mon Sep 17 00:00:00 2001
From: "H.E. Pennypacker" <115990865+pennypacker-he@users.noreply.github.com>
Date: Sun, 5 Jul 2026 20:47:51 +0000
Subject: [PATCH 8/8] [minor] Run GCP smoke healthcheck on VM
---
ci/cloud-smoke.sh | 25 ++++++++++++++++++++++---
1 file changed, 22 insertions(+), 3 deletions(-)
diff --git a/ci/cloud-smoke.sh b/ci/cloud-smoke.sh
index 9ed3a24..41952cd 100755
--- a/ci/cloud-smoke.sh
+++ b/ci/cloud-smoke.sh
@@ -610,13 +610,32 @@ configure_sitectl_context() {
}
run_healthcheck() {
- local home_dir="$1" output_json="$2"
- local context timeout interval
+ local home_dir="$1" key_path="$2" output_json="$3"
+ local provider context timeout interval
+ provider="$(jq -r '.provider' "$output_json")"
context="$(jq -r '.context_name' "$output_json")"
timeout="$(jq -r '.healthcheck_timeout' "$output_json")"
interval="$(jq -r '.healthcheck_interval' "$output_json")"
+ if [[ "$provider" == "gcp" ]]; then
+ local host port user quoted_context quoted_timeout quoted_interval
+
+ host="$(jq -r '.host' "$output_json")"
+ port="$(jq -r '.ssh_port' "$output_json")"
+ user="$(jq -r '.ssh_user' "$output_json")"
+ quoted_context="$(shell_quote "$context")"
+ quoted_timeout="$(shell_quote "$timeout")"
+ quoted_interval="$(shell_quote "$interval")"
+
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" "bash -lc 'set -euo pipefail
+export HOME=/home/cloud-compose
+source /home/cloud-compose/profile.sh
+exec sitectl healthcheck --context ${quoted_context} --persist --timeout ${quoted_timeout} --interval ${quoted_interval} --format table
+'"
+ return
+ fi
+
HOME="$home_dir" sitectl healthcheck \
--context "$context" \
--persist \
@@ -717,7 +736,7 @@ run_target() (
fi
configure_sitectl_context "$home_dir" "$key_path" "$output_json"
- if ! run_healthcheck "$home_dir" "$output_json"; then
+ if ! run_healthcheck "$home_dir" "$key_path" "$output_json"; then
dump_remote_logs "$home_dir" "$key_path" "$host" "$port" "$user" "$project_dir"
return 1
fi