diff --git a/.github/workflows/cloud-smoke-cleanup.yml b/.github/workflows/cloud-smoke-cleanup.yml
new file mode 100644
index 0000000..a69a006
--- /dev/null
+++ b/.github/workflows/cloud-smoke-cleanup.yml
@@ -0,0 +1,65 @@
+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
+ provider: digitalocean
+ template: isle
+ - name: Linode WordPress
+ provider: linode
+ template: wp
+ - name: GCP WordPress
+ provider: gcp
+ template: wp
+ concurrency:
+ group: cloud-compose-smoke-${{ matrix.provider }}-${{ matrix.template }}
+ 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 sweep-${{ matrix.provider }}-${{ matrix.template }}
+
+ - 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..da1e5c4
--- /dev/null
+++ b/.github/workflows/cloud-smoke.yml
@@ -0,0 +1,162 @@
+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
+ provider: digitalocean
+ template: isle
+ - name: Linode WordPress
+ provider: linode
+ template: wp
+ - name: GCP WordPress
+ provider: gcp
+ template: wp
+ concurrency:
+ group: cloud-compose-smoke-${{ matrix.provider }}-${{ matrix.template }}
+ 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 smoke-test PROVIDER=${{ matrix.provider }} TEMPLATE=${{ matrix.template }}
+
+ - 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.provider }}-${{ matrix.template }}
+
+ - 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
+ provider: digitalocean
+ template: isle
+ - name: Linode WordPress
+ provider: linode
+ template: wp
+ - name: GCP WordPress
+ provider: gcp
+ template: wp
+ concurrency:
+ group: cloud-compose-smoke-${{ matrix.provider }}-${{ matrix.template }}
+ 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 sweep-${{ matrix.provider }}-${{ matrix.template }}
+
+ - 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/.terraform.lock.hcl b/.terraform.lock.hcl
index d09b678..f56a508 100644
--- a/.terraform.lock.hcl
+++ b/.terraform.lock.hcl
@@ -1,6 +1,30 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
+provider "registry.terraform.io/digitalocean/digitalocean" {
+ version = "2.93.0"
+ constraints = "~> 2.0"
+ hashes = [
+ "h1:furxWCm/4BbtUX/XHa3TsP0eI+YjQekvcUTyx4BLqnM=",
+ "zh:2509716282fb0d04027279b2d6ffb449d5f4b4a5f380a2ad7b7b6221d2f2fdc4",
+ "zh:3a6407123d127b6948040583c29371b6ea92120d2a239d8bcd27673725673013",
+ "zh:71443c90f85572fce0c563e3587d956afb8be9f0a0090f1c40b33b1005718824",
+ "zh:790c8a3694baba6e28b13d45d86523995ffda4693e0c8ad1584e34deee914fe2",
+ "zh:7fe00fd5d4d322923550651b6ebca9dea14b3b7a68c09ba4c20f9b416a34cb40",
+ "zh:8bf4d5ccb634bacba929fc632d554eddb349b890d26e5f4dbcb53481ca34dbff",
+ "zh:8f545ec1622fa4d42a7112de8cbd0c42f8967e8dafd5c514d77b7ec25914951d",
+ "zh:98300c632e9eabdcecb9f77586a4a48e19dab62b7365410f4ab857ab4007e159",
+ "zh:9db648994459fe62a9e91f9d875dd9220db5b063a00f4eec22dbdf2247a67730",
+ "zh:9f21713916718613da2a42c8b39c503e46399b2d06c3b492979bde939d05d2bb",
+ "zh:a4b4afa0ef6f672d50ac072cae1a39ec6d97716e4ed118900b5db1a553a3c1cd",
+ "zh:bebed73ab49be830ef03346cb91e1da83fff06b555695f69fc3de13c51a456ec",
+ "zh:d207d54f3ab86a0f71c194b8b34a49a219f97dd40ec6db0ed13ed70df9f26aab",
+ "zh:ee4caa17a2e62bfa9c2669ddd909026a3ded3294c4f2bb325cf563f5766e1981",
+ "zh:efb59a5436f103a4930a7cab2e77ba75d31fd08f2f024c9a4177c70a89cd3264",
+ "zh:ffaf97ab916b12c2181bdf5f7232aaac88cce59bb8edbad07a527207da232f90",
+ ]
+}
+
provider "registry.terraform.io/hashicorp/cloudinit" {
version = "2.4.0"
constraints = "~> 2.3"
@@ -72,3 +96,24 @@ provider "registry.terraform.io/hashicorp/time" {
"zh:c239c619101a8c95e1f14061eb973c57a8d15fa0e68878ced5bbd76858ee5b79",
]
}
+
+provider "registry.terraform.io/linode/linode" {
+ version = "4.0.0"
+ constraints = "~> 4.0"
+ hashes = [
+ "h1:ZUykBGGXIJsXzafpxxKN+oRJh/n/d+jFiiTZlYVDrvY=",
+ "zh:01e0045344d13ca61c8b065df73b007bf45f665eb419d045a31c8d9ba48ede86",
+ "zh:3e22a4672940ec1a8c59bd6c8d594a6fb4bdfc50efa993ac2b9edd21a9459b03",
+ "zh:467c82a79a04cc2f279b4b8206db691035dbcb3ba88f8a29a63e060046a7747b",
+ "zh:4d7f3a1ec9dfa15ea2bac0f9670885812d09e45737d55c7daea049770143bc1d",
+ "zh:4d9b946d66940231488990943f9564725891310f8500c149af380eba614f5ab4",
+ "zh:75b48ac11262ac401c2fc3eb5fb73b01ad1607a1c2a0ddcb42687d1941158e6d",
+ "zh:864de67a080eeb4b14902056dc96bd29f1dc935442440cdb774ad2d46a872ca9",
+ "zh:a3fd99fb4f9b2e631c63b0ab9ff4d28e32aca4ff0a14b9271fdc634798ecc808",
+ "zh:b0c63ea32e65a76e987a086dc4ee6d8f445876a59d7a5a94aa2f225f5fd8729f",
+ "zh:b3d6f4461724983798af50bc91003b0e7892a5b2ab172d7fd0a8d57578c6a7bf",
+ "zh:b5f2643c36657241b6ef4a987727e99352ed2dcf63d54cba6d5292ee0edd59f4",
+ "zh:bf148ad0a4cdd049de12bdea2dc2bb20f2a0e09574a1917f7e45c1f54aa8dc0b",
+ "zh:d1448b79e1c9c9d14adc03c97730f68fec042011012ec6727ff7b17d7e130df4",
+ ]
+}
diff --git a/Makefile b/Makefile
index a7a927f..8e51f0d 100644
--- a/Makefile
+++ b/Makefile
@@ -1,9 +1,97 @@
-.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 smoke-test-digitalocean-isle smoke-test-linode-wp smoke-test-gcp-wp destroy-smoke 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:
+ @test -n "$(PROVIDER)" || { echo "PROVIDER is required"; exit 2; }
+ @test -n "$(TEMPLATE)" || { echo "TEMPLATE is required"; exit 2; }
+ ci/cloud-smoke.sh $(PROVIDER)-$(TEMPLATE)
+
+smoke-test-digitalocean-isle:
+ $(MAKE) smoke-test PROVIDER=digitalocean TEMPLATE=isle
+
+smoke-test-linode-wp:
+ $(MAKE) smoke-test PROVIDER=linode TEMPLATE=wp
+
+smoke-test-gcp-wp:
+ $(MAKE) smoke-test PROVIDER=gcp TEMPLATE=wp
+
+destroy-smoke:
+ @test -n "$(PROVIDER)" || { echo "PROVIDER is required"; exit 2; }
+ @test -n "$(TEMPLATE)" || { echo "TEMPLATE is required"; exit 2; }
+ ci/cloud-smoke.sh destroy-$(PROVIDER)-$(TEMPLATE)
+
+destroy-smoke-digitalocean-isle:
+ $(MAKE) destroy-smoke PROVIDER=digitalocean TEMPLATE=isle
+
+destroy-smoke-linode-wp:
+ $(MAKE) destroy-smoke PROVIDER=linode TEMPLATE=wp
+
+destroy-smoke-gcp-wp:
+ $(MAKE) destroy-smoke PROVIDER=gcp TEMPLATE=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..124b48b 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,7 @@
# 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
@@ -13,130 +9,51 @@ Optional VM APIs:
| 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 |
+| [digitalocean](#module\_digitalocean) | ./modules/digitalocean | n/a |
+| [gcp](#module\_gcp) | ./modules/gcp | n/a |
+| [linode](#module\_linode) | ./modules/linode | 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)` |
[
"sitectl compose --context \"${SITECTL_CONTEXT_NAME}\" down"
]
| 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 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\_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)` | [
"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 |
-| [docker\_compose\_up](#input\_docker\_compose\_up) | Command to start the docker compose project | `list(string)` | [
"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"
]
| no |
-| [frontend](#input\_frontend) | Optional frontend container to deploy as a sidecar next to ppb. When set,
ppb continues to power on and ping the VM referenced by machineMetadata,
but proxies incoming requests to this container on localhost instead of
to the VM. Use this to serve a frontend from Cloud Run while keeping
backend services on the VM. | 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)` | [
"10.0.0.0/8"
]
| 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)` | [
"sitectl"
]
| 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 |
+| [name](#input\_name) | Deployment name. | `string` | n/a | yes |
+| [cloud\_provider](#input\_cloud\_provider) | Cloud provider to deploy to. Supported values are gcp, digitalocean, and linode. | `string` | `"gcp"` | no |
+| [digitalocean](#input\_digitalocean) | DigitalOcean infrastructure settings. | 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, "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
| 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. |
-| [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\_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 |
+| [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) | 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
new file mode 100755
index 0000000..41952cd
--- /dev/null
+++ b/ci/cloud-smoke.sh
@@ -0,0 +1,885 @@
+#!/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 -
+ ci/cloud-smoke.sh destroy--
+ ci/cloud-smoke.sh sweep
+ ci/cloud-smoke.sh sweep--
+
+Examples:
+ ci/cloud-smoke.sh digitalocean-isle
+ ci/cloud-smoke.sh linode-wp
+ ci/cloud-smoke.sh gcp-wp
+
+Required environment:
+ DIGITALOCEAN_TOKEN DigitalOcean API token for digitalocean targets.
+ LINODE_TOKEN Linode API token for linode targets.
+ GCLOUD_PROJECT Google Cloud project for gcp targets.
+
+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.
+ CLOUD_COMPOSE_SMOKE_TARGETS Space-separated targets used by "all" and "sweep".
+ DIGITALOCEAN_API_TOKEN Backward-compatible alias for DIGITALOCEAN_TOKEN.
+ GCLOUD_REGION=us-east5 Google Cloud region for gcp targets.
+ GCLOUD_ZONE=us-east5-b Google Cloud zone for gcp targets.
+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
+}
+
+default_targets() {
+ printf '%s\n' "${CLOUD_COMPOSE_SMOKE_TARGETS:-digitalocean-isle linode-wp gcp-wp}"
+}
+
+valid_template() {
+ case "$1" in
+ archivesspace | ojs | isle | drupal | wp | omeka-s | omeka-classic) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+target_provider() {
+ case "$1" in
+ digitalocean-*) printf 'digitalocean\n' ;;
+ linode-*) printf 'linode\n' ;;
+ gcp-*) printf 'gcp\n' ;;
+ *)
+ echo "Unknown smoke target: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+}
+
+target_template() {
+ local target="$1" provider template
+
+ provider="$(target_provider "$target")"
+ template="${target#"$provider"-}"
+ if ! valid_template "$template"; then
+ echo "Unknown smoke template in target: $target" >&2
+ usage >&2
+ exit 2
+ fi
+ printf '%s\n' "$template"
+}
+
+target_root() {
+ target_template "$1" >/dev/null
+ printf '%s/tests/smoke/app\n' "$repo_root"
+}
+
+target_env() {
+ case "$(target_provider "$1")" in
+ digitalocean)
+ if [[ -z "${DIGITALOCEAN_TOKEN:-}" && -n "${DIGITALOCEAN_API_TOKEN:-}" ]]; then
+ export DIGITALOCEAN_TOKEN="$DIGITALOCEAN_API_TOKEN"
+ fi
+ require_env DIGITALOCEAN_TOKEN
+ ;;
+ linode)
+ require_env LINODE_TOKEN
+ ;;
+ gcp)
+ 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 1200
+}
+
+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"
+}
+
+template_slug() {
+ case "$1" in
+ archivesspace) printf 'as\n' ;;
+ ojs) printf 'ojs\n' ;;
+ isle) printf 'isle\n' ;;
+ drupal) printf 'dr\n' ;;
+ wp) printf 'wp\n' ;;
+ omeka-s) printf 'os\n' ;;
+ omeka-classic) printf 'oc\n' ;;
+ *)
+ echo "Unknown smoke template: $1" >&2
+ exit 2
+ ;;
+ esac
+}
+
+provider_slug() {
+ case "$1" in
+ digitalocean) printf 'do\n' ;;
+ gcp) printf 'g\n' ;;
+ linode) printf 'ln\n' ;;
+ *)
+ echo "Unknown smoke provider: $1" >&2
+ exit 2
+ ;;
+ esac
+}
+
+target_name_prefix() {
+ local target="$1" provider template
+
+ provider="$(target_provider "$target")"
+ template="$(target_template "$target")"
+ printf 'cc-%s-%s\n' "$(provider_slug "$provider")" "$(template_slug "$template")"
+}
+
+provider_tag_cleanup() {
+ local target="$1" run_id="${2:-}" run_tag run_fragment provider name_prefix
+
+ run_tag="$(smoke_run_tag "$run_id")"
+ run_fragment=""
+ if [[ -n "$run_id" ]]; then
+ run_fragment="-$(printf '%s' "$run_id" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | cut -c1-16)-"
+ fi
+ provider="$(target_provider "$target")"
+ name_prefix="$(target_name_prefix "$target")"
+
+ case "$provider" in
+ digitalocean)
+ api_get digitalocean "/firewalls?per_page=200" |
+ jq -r --arg name_prefix "${name_prefix}-" --arg run_fragment "$run_fragment" '.firewalls[]? | select(.name | startswith($name_prefix)) | 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 target "$target" --arg run_tag "$run_tag" '.droplets[]? | select((.tags // []) | index($target)) | 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 target "$target" --arg run_tag "$run_tag" '.volumes[]? | select((.tags // []) | index($target)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id' |
+ delete_ids digitalocean "/volumes"
+ ;;
+ linode)
+ api_get linode "/networking/firewalls?page_size=500" |
+ jq -r --arg target "$target" --arg run_tag "$run_tag" '.data[]? | select((.tags // []) | index("cloud-compose-smoke") and index($target)) | 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 target "$target" --arg run_tag "$run_tag" '.data[]? | select((.tags // []) | index("cloud-compose-smoke") and index($target)) | 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 target "$target" --arg run_tag "$run_tag" '.data[]? | select((.tags // []) | index("cloud-compose-smoke") and index($target)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id' |
+ delete_ids linode "/volumes"
+ ;;
+ gcp)
+ local project name_filter
+ project="$GCLOUD_PROJECT"
+ name_filter="^${name_prefix}-"
+ if [[ -n "$run_id" ]]; then
+ name_filter="^${name_prefix}-$(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" target="$3" public_key provider template
+
+ provider="$(target_provider "$target")"
+ template="$(target_template "$target")"
+
+ 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_provider"' "$root/variables.tf"; then
+ printf '%s\0%s\0' "-var" "cloud_provider=${provider}"
+ fi
+ if grep -q 'variable "template"' "$root/variables.tf"; then
+ printf '%s\0%s\0' "-var" "template=${template}"
+ fi
+ 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
+ if command -v runuser >/dev/null 2>&1; 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
+ sudo -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\"
+ fi
+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" 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 \
+ --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"
+ 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"
+ 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" "$target")
+
+ auto_args=()
+ if [[ -n "${GITHUB_ACTIONS:-}" || "${CLOUD_COMPOSE_SMOKE_AUTO_APPROVE:-}" == "true" ]]; then
+ auto_args=(-auto-approve)
+ fi
+
+ cleanup_started=false
+ # shellcheck disable=SC2317
+ 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" "$key_path" "$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" "$target")
+
+ 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() {
+ local target provider
+
+ if [[ "$#" -ne 1 ]]; then
+ usage >&2
+ exit 2
+ fi
+
+ case "$1" in
+ sweep)
+ for target in $(default_targets); do
+ provider="$(target_provider "$target")"
+ require_cmd jq
+ case "$provider" in
+ digitalocean | linode)
+ require_cmd curl
+ ;;
+ gcp)
+ require_cmd gcloud
+ ;;
+ esac
+ target_env "$target"
+ provider_tag_cleanup "$target" "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}"
+ done
+ exit 0
+ ;;
+ sweep-*)
+ target="${1#sweep-}"
+ provider="$(target_provider "$target")"
+ require_cmd jq
+ case "$provider" in
+ digitalocean | linode)
+ require_cmd curl
+ ;;
+ gcp)
+ require_cmd gcloud
+ ;;
+ esac
+ target_env "$target"
+ provider_tag_cleanup "$target" "${CLOUD_COMPOSE_SMOKE_RUN_ID:-}"
+ exit 0
+ ;;
+ destroy-*)
+ target="${1#destroy-}"
+ provider="$(target_provider "$target")"
+ require_cmd jq
+ require_cmd terraform
+ case "$provider" in
+ digitalocean | linode)
+ require_cmd curl
+ ;;
+ gcp)
+ require_cmd gcloud
+ ;;
+ esac
+ destroy_target "$target"
+ 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)
+ for target in $(default_targets); do
+ run_target "$target"
+ done
+ ;;
+ digitalocean-* | linode-* | gcp-*)
+ target_template "$1" >/dev/null
+ 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..3167394
--- /dev/null
+++ b/docs/examples.md
@@ -0,0 +1,81 @@
+# Examples
+
+The `examples/` directory contains runnable Terraform entry points for common
+compose app shapes.
+
+## Single app examples
+
+- `examples/app` deploys any supported template by setting `template`.
+- `examples/archivesspace` deploys ArchivesSpace.
+- `examples/ojs` deploys OJS.
+- `examples/isle` deploys ISLE.
+- `examples/drupal` deploys Drupal.
+- `examples/wp` deploys WordPress.
+- `examples/omeka-s` deploys Omeka S.
+- `examples/omeka-classic` deploys Omeka Classic.
+- `examples/digitalocean` deploys WordPress on DigitalOcean.
+- `examples/linode` deploys Drupal on Linode.
+
+The root module knows the default compose repo, `sitectl` plugin, and package
+set for each template. A minimal caller supplies the provider and template:
+
+```hcl
+module "app" {
+ source = "../.."
+
+ name = "cc-wp"
+ cloud_provider = "digitalocean"
+ template = "wp"
+ digitalocean = {
+ ssh = {
+ cloud_compose_keys = var.operator_ssh_keys
+ }
+ }
+}
+```
+
+Use `runtime` only for overrides such as branch, ingress, Vault Agent, or
+healthcheck settings:
+
+```hcl
+runtime = {
+ compose = {
+ branch = "main"
+ ingress = {
+ letsencrypt = true
+ bot_mitigation = true
+ domain = "example.org"
+ acme_email = "ops@example.org"
+ }
+ }
+}
+```
+
+## Bin packing
+
+`examples/binpack` shows how several compose projects can share one VM:
+
+```hcl
+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..00a5b16
--- /dev/null
+++ b/docs/non-gcp-providers.md
@@ -0,0 +1,87 @@
+# 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. Prefer the root module contract so the app template selects
+the compose repo, `sitectl` plugin, and plugin package defaults:
+
+```hcl
+module "wp" {
+ source = "../.."
+
+ name = "cc-wp"
+ cloud_provider = "digitalocean"
+ template = "wp"
+ digitalocean = {
+ region = "tor1"
+ ssh = {
+ cloud_compose_keys = var.operator_ssh_keys
+ }
+ }
+}
+```
+
+## Linode
+
+Use `LINODE_TOKEN` or an explicit Linode provider configuration in the calling
+stack. Linode metadata is tighter than DigitalOcean, so CI and examples can pass
+`rootfs_archive_url` when the embedded cloud-init payload would be too large:
+
+```hcl
+module "drupal" {
+ source = "../.."
+
+ name = "cc-drupal"
+ cloud_provider = "linode"
+ template = "drupal"
+ linode = {
+ region = "us-east"
+ instance = {
+ authorized_keys = var.operator_ssh_keys
+ }
+ ssh = {
+ cloud_compose_keys = var.operator_ssh_keys
+ }
+ }
+ runtime = {
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/main.tar.gz"
+ }
+}
+```
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//` by default.
+
+## Power Management
+
+`power_management_enabled` gates GCP-specific cost-saving behavior:
+
+- Cloud Run proxy-power-button ingress
+- the `lightsout` internal-service profile
+
+DigitalOcean and Linode deployments should leave power management disabled
+because stopped VMs do not provide the same cost profile.
diff --git a/examples/app/main.tf b/examples/app/main.tf
new file mode 100644
index 0000000..f1e3b86
--- /dev/null
+++ b/examples/app/main.tf
@@ -0,0 +1,39 @@
+terraform {
+ required_version = ">= 1.2.4"
+}
+
+module "app" {
+ source = "../.."
+
+ name = var.name
+ cloud_provider = var.cloud_provider
+ template = var.template
+ gcp = var.gcp
+ digitalocean = var.digitalocean
+ linode = var.linode
+ runtime = var.runtime
+}
+
+output "instance" {
+ value = module.app.instance
+}
+
+output "external_ip" {
+ value = module.app.external_ip
+}
+
+output "internal_ip" {
+ value = module.app.internal_ip
+}
+
+output "volumes" {
+ value = module.app.volumes
+}
+
+output "primary_compose_project" {
+ value = module.app.primary_compose_project
+}
+
+output "compose_projects" {
+ value = module.app.compose_projects
+}
diff --git a/examples/app/variables.tf b/examples/app/variables.tf
new file mode 100644
index 0000000..38e54ce
--- /dev/null
+++ b/examples/app/variables.tf
@@ -0,0 +1,41 @@
+variable "name" {
+ type = string
+ default = "cloud-compose-app"
+ description = "Deployment name."
+}
+
+variable "cloud_provider" {
+ type = string
+ default = "digitalocean"
+ description = "Cloud provider to deploy to: gcp, digitalocean, or linode."
+}
+
+variable "template" {
+ type = string
+ default = "wp"
+ description = "Compose template preset: archivesspace, ojs, isle, drupal, wp, omeka-s, or omeka-classic."
+}
+
+variable "gcp" {
+ type = any
+ default = {}
+ description = "GCP provider settings passed to the root module."
+}
+
+variable "digitalocean" {
+ type = any
+ default = {}
+ description = "DigitalOcean provider settings passed to the root module."
+}
+
+variable "linode" {
+ type = any
+ default = {}
+ description = "Linode provider settings passed to the root module."
+}
+
+variable "runtime" {
+ type = any
+ default = {}
+ description = "Provider-neutral runtime settings passed to the root module."
+}
diff --git a/examples/archivesspace/main.tf b/examples/archivesspace/main.tf
new file mode 100644
index 0000000..e4641b9
--- /dev/null
+++ b/examples/archivesspace/main.tf
@@ -0,0 +1,27 @@
+terraform {
+ required_version = ">= 1.2.4"
+}
+
+module "archivesspace" {
+ source = "../app"
+
+ name = var.name
+ cloud_provider = var.cloud_provider
+ template = "archivesspace"
+ gcp = var.gcp
+ digitalocean = var.digitalocean
+ linode = var.linode
+ runtime = var.runtime
+}
+
+output "instance" {
+ value = module.archivesspace.instance
+}
+
+output "external_ip" {
+ value = module.archivesspace.external_ip
+}
+
+output "primary_compose_project" {
+ value = module.archivesspace.primary_compose_project
+}
diff --git a/examples/archivesspace/variables.tf b/examples/archivesspace/variables.tf
new file mode 100644
index 0000000..377694f
--- /dev/null
+++ b/examples/archivesspace/variables.tf
@@ -0,0 +1,35 @@
+variable "name" {
+ type = string
+ default = "archivesspace-example"
+ description = "Deployment name."
+}
+
+variable "cloud_provider" {
+ type = string
+ default = "digitalocean"
+ description = "Cloud provider to deploy to: gcp, digitalocean, or linode."
+}
+
+variable "gcp" {
+ type = any
+ default = {}
+ description = "GCP provider settings passed to the app example."
+}
+
+variable "digitalocean" {
+ type = any
+ default = {}
+ description = "DigitalOcean provider settings passed to the app example."
+}
+
+variable "linode" {
+ type = any
+ default = {}
+ description = "Linode provider settings passed to the app example."
+}
+
+variable "runtime" {
+ type = any
+ default = {}
+ description = "Provider-neutral runtime settings passed to the app example."
+}
diff --git a/examples/binpack/main.tf b/examples/binpack/main.tf
new file mode 100644
index 0000000..3629c97
--- /dev/null
+++ b/examples/binpack/main.tf
@@ -0,0 +1,32 @@
+terraform {
+ required_version = ">= 1.2.4"
+}
+
+module "apps" {
+ source = "../.."
+
+ name = var.name
+ gcp = {
+ project_id = var.project_id
+ project_number = var.project_number
+ }
+ 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"]
+ }
+ }
+ }
+ }
+}
diff --git a/examples/binpack/variables.tf b/examples/binpack/variables.tf
new file mode 100644
index 0000000..4ab76b5
--- /dev/null
+++ b/examples/binpack/variables.tf
@@ -0,0 +1,12 @@
+variable "name" {
+ type = string
+ default = "cloud-compose-binpack"
+}
+
+variable "project_id" {
+ type = string
+}
+
+variable "project_number" {
+ type = string
+}
diff --git a/examples/digitalocean/main.tf b/examples/digitalocean/main.tf
new file mode 100644
index 0000000..5891cc1
--- /dev/null
+++ b/examples/digitalocean/main.tf
@@ -0,0 +1,35 @@
+terraform {
+ required_version = ">= 1.2.4"
+}
+
+module "wp" {
+ source = "../.."
+
+ name = var.name
+ cloud_provider = "digitalocean"
+ template = "wp"
+ digitalocean = {
+ region = var.region
+ droplet = {
+ size = var.size
+ ssh_keys = var.ssh_keys
+ }
+ ssh = {
+ cloud_compose_keys = var.cloud_compose_ssh_keys
+ }
+ }
+ runtime = {
+ compose = {
+ branch = var.docker_compose_branch
+ ingress_port = var.ingress_port
+ }
+ }
+}
+
+output "instance" {
+ value = module.wp.instance
+}
+
+output "volumes" {
+ value = module.wp.volumes
+}
diff --git a/examples/digitalocean/variables.tf b/examples/digitalocean/variables.tf
new file mode 100644
index 0000000..a0a440c
--- /dev/null
+++ b/examples/digitalocean/variables.tf
@@ -0,0 +1,47 @@
+variable "name" {
+ type = string
+ default = "cc-wp"
+ description = "Deployment name."
+}
+
+variable "region" {
+ type = string
+ default = "tor1"
+ description = "DigitalOcean region slug."
+}
+
+variable "size" {
+ type = string
+ default = "s-2vcpu-4gb"
+ description = "DigitalOcean Droplet size slug."
+}
+
+variable "ssh_keys" {
+ type = list(string)
+ default = []
+ description = "DigitalOcean SSH key IDs or fingerprints for the root account."
+}
+
+variable "cloud_compose_ssh_keys" {
+ type = list(string)
+ default = []
+ description = "SSH public keys for the cloud-compose user."
+}
+
+variable "docker_compose_repo" {
+ type = string
+ default = ""
+ description = "Optional compose project repository override. Empty uses the selected template default."
+}
+
+variable "docker_compose_branch" {
+ type = string
+ default = "main"
+ description = "Compose project branch."
+}
+
+variable "ingress_port" {
+ type = number
+ default = 80
+ description = "Host port exposed by the compose project."
+}
diff --git a/examples/drupal/main.tf b/examples/drupal/main.tf
new file mode 100644
index 0000000..7bf79c6
--- /dev/null
+++ b/examples/drupal/main.tf
@@ -0,0 +1,27 @@
+terraform {
+ required_version = ">= 1.2.4"
+}
+
+module "drupal" {
+ source = "../app"
+
+ name = var.name
+ cloud_provider = var.cloud_provider
+ template = "drupal"
+ gcp = var.gcp
+ digitalocean = var.digitalocean
+ linode = var.linode
+ runtime = var.runtime
+}
+
+output "instance" {
+ value = module.drupal.instance
+}
+
+output "external_ip" {
+ value = module.drupal.external_ip
+}
+
+output "primary_compose_project" {
+ value = module.drupal.primary_compose_project
+}
diff --git a/examples/drupal/variables.tf b/examples/drupal/variables.tf
new file mode 100644
index 0000000..934e263
--- /dev/null
+++ b/examples/drupal/variables.tf
@@ -0,0 +1,35 @@
+variable "name" {
+ type = string
+ default = "drupal-example"
+ description = "Deployment name."
+}
+
+variable "cloud_provider" {
+ type = string
+ default = "digitalocean"
+ description = "Cloud provider to deploy to: gcp, digitalocean, or linode."
+}
+
+variable "gcp" {
+ type = any
+ default = {}
+ description = "GCP provider settings passed to the app example."
+}
+
+variable "digitalocean" {
+ type = any
+ default = {}
+ description = "DigitalOcean provider settings passed to the app example."
+}
+
+variable "linode" {
+ type = any
+ default = {}
+ description = "Linode provider settings passed to the app example."
+}
+
+variable "runtime" {
+ type = any
+ default = {}
+ description = "Provider-neutral runtime settings passed to the app example."
+}
diff --git a/examples/isle/main.tf b/examples/isle/main.tf
new file mode 100644
index 0000000..a1caa37
--- /dev/null
+++ b/examples/isle/main.tf
@@ -0,0 +1,27 @@
+terraform {
+ required_version = ">= 1.2.4"
+}
+
+module "isle" {
+ source = "../app"
+
+ name = var.name
+ cloud_provider = var.cloud_provider
+ template = "isle"
+ gcp = var.gcp
+ digitalocean = var.digitalocean
+ linode = var.linode
+ runtime = var.runtime
+}
+
+output "instance" {
+ value = module.isle.instance
+}
+
+output "external_ip" {
+ value = module.isle.external_ip
+}
+
+output "primary_compose_project" {
+ value = module.isle.primary_compose_project
+}
diff --git a/examples/isle/variables.tf b/examples/isle/variables.tf
new file mode 100644
index 0000000..279be6c
--- /dev/null
+++ b/examples/isle/variables.tf
@@ -0,0 +1,35 @@
+variable "name" {
+ type = string
+ default = "isle-example"
+ description = "Deployment name."
+}
+
+variable "cloud_provider" {
+ type = string
+ default = "digitalocean"
+ description = "Cloud provider to deploy to: gcp, digitalocean, or linode."
+}
+
+variable "gcp" {
+ type = any
+ default = {}
+ description = "GCP provider settings passed to the app example."
+}
+
+variable "digitalocean" {
+ type = any
+ default = {}
+ description = "DigitalOcean provider settings passed to the app example."
+}
+
+variable "linode" {
+ type = any
+ default = {}
+ description = "Linode provider settings passed to the app example."
+}
+
+variable "runtime" {
+ type = any
+ default = {}
+ description = "Provider-neutral runtime settings passed to the app example."
+}
diff --git a/examples/linode/main.tf b/examples/linode/main.tf
new file mode 100644
index 0000000..4b458d9
--- /dev/null
+++ b/examples/linode/main.tf
@@ -0,0 +1,38 @@
+terraform {
+ required_version = ">= 1.2.4"
+}
+
+module "drupal" {
+ source = "../.."
+
+ name = var.name
+ cloud_provider = "linode"
+ template = "drupal"
+ linode = {
+ region = var.region
+ instance = {
+ type = var.type
+ authorized_keys = var.authorized_keys
+ authorized_users = var.authorized_users
+ root_pass = var.root_pass
+ }
+ ssh = {
+ cloud_compose_keys = var.cloud_compose_ssh_keys
+ }
+ }
+ runtime = {
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz"
+ compose = {
+ branch = var.docker_compose_branch
+ ingress_port = var.ingress_port
+ }
+ }
+}
+
+output "instance" {
+ value = module.drupal.instance
+}
+
+output "volumes" {
+ value = module.drupal.volumes
+}
diff --git a/examples/linode/variables.tf b/examples/linode/variables.tf
new file mode 100644
index 0000000..fc512c2
--- /dev/null
+++ b/examples/linode/variables.tf
@@ -0,0 +1,66 @@
+variable "name" {
+ type = string
+ default = "cc-drupal"
+ description = "Deployment name."
+}
+
+variable "region" {
+ type = string
+ default = "us-east"
+ description = "Linode region slug."
+}
+
+variable "type" {
+ type = string
+ default = "g6-standard-2"
+ description = "Linode instance type."
+}
+
+variable "authorized_keys" {
+ type = list(string)
+ default = []
+ description = "SSH public keys for the root account."
+}
+
+variable "authorized_users" {
+ type = list(string)
+ default = []
+ description = "Linode users whose SSH keys are added to the root account."
+}
+
+variable "root_pass" {
+ type = string
+ default = null
+ sensitive = true
+ description = "Optional root password. Provide this or authorized_keys/authorized_users."
+}
+
+variable "cloud_compose_ssh_keys" {
+ type = list(string)
+ default = []
+ description = "SSH public keys for the cloud-compose user."
+}
+
+variable "cloud_compose_source_ref" {
+ type = string
+ default = "main"
+ description = "cloud-compose Git ref whose rootfs is fetched by Linode cloud-init."
+}
+
+variable "docker_compose_repo" {
+ type = string
+ default = ""
+ description = "Optional compose project repository override. Empty uses the selected template default."
+}
+
+variable "docker_compose_branch" {
+ type = string
+ default = "main"
+ description = "Compose project branch."
+}
+
+variable "ingress_port" {
+ type = number
+ default = 80
+ description = "Host port exposed by the compose project."
+}
diff --git a/examples/ojs/README.md b/examples/ojs/README.md
deleted file mode 100644
index 6eeb0f8..0000000
--- a/examples/ojs/README.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# OJS
-
-Deploy OJS to Google Cloud
-
-## Usage
-
-Create the production VM
-
-```
-terraform init
-terraform apply -target=module.production
-```
-
-The staging VM then relies on a snapshot of the public files docker volume. This is to allow staging to mirror production.
-
-So need wait until snapshot schedule executes (~1h) OR get a snapshot of the production docker volume disk immediately. then the rest of the terraform runs can just execute as normal
-
-```
-terraform apply
-```
diff --git a/examples/ojs/main.tf b/examples/ojs/main.tf
index 6716999..5d5c18d 100644
--- a/examples/ojs/main.tf
+++ b/examples/ojs/main.tf
@@ -1,41 +1,27 @@
-resource "random_shuffle" "zone" {
- input = var.region == "us-central1" ? ["a", "b", "c", "f"] : ["a", "b", "c"]
- result_count = 1
+terraform {
+ required_version = ">= 1.2.4"
}
-module "production" {
- source = "git::https://github.com/libops/cloud-compose?ref=0.4.0"
+module "ojs" {
+ source = "../app"
- name = "ojs-production"
- project_id = var.project_id
- project_number = var.project_number
- docker_compose_repo = var.docker_compose_repo
- docker_compose_init = var.docker_compose_init
- region = var.region
- zone = format("%s-%s", var.region, random_shuffle.zone.result[0])
- production = true
- run_snapshots = true
- allowed_ips = var.allowed_ips
+ name = var.name
+ cloud_provider = var.cloud_provider
+ template = "ojs"
+ gcp = var.gcp
+ digitalocean = var.digitalocean
+ linode = var.linode
+ runtime = var.runtime
}
-module "staging" {
- source = "git::https://github.com/libops/cloud-compose?ref=0.4.0"
+output "instance" {
+ value = module.ojs.instance
+}
- name = "ojs-staging"
- project_id = var.project_id
- project_number = var.project_number
- docker_compose_repo = var.docker_compose_repo
- docker_compose_init = var.docker_compose_init
- region = var.region
- zone = format("%s-%s", var.region, random_shuffle.zone.result[0])
- sitectl_environment = "staging"
- disk_size_gb = 20
- allowed_ips = var.allowed_ips
+output "external_ip" {
+ value = module.ojs.external_ip
+}
- # make production public files available in staging
- overlay_source_instance = "ojs-production"
- volume_names = [
- "compose_ojs-public",
- "compose_ojs-files"
- ]
+output "primary_compose_project" {
+ value = module.ojs.primary_compose_project
}
diff --git a/examples/ojs/terraform.tfvars b/examples/ojs/terraform.tfvars
deleted file mode 100644
index 43b2002..0000000
--- a/examples/ojs/terraform.tfvars
+++ /dev/null
@@ -1,4 +0,0 @@
-docker_compose_repo = "https://github.com/libops/ojs"
-docker_compose_init = [
- "docker compose run init"
-]
diff --git a/examples/ojs/variables.tf b/examples/ojs/variables.tf
index 8f61463..fb084b2 100644
--- a/examples/ojs/variables.tf
+++ b/examples/ojs/variables.tf
@@ -1,32 +1,35 @@
-variable "project_id" {
- description = "The GCP project ID"
+variable "name" {
type = string
+ default = "ojs-example"
+ description = "Deployment name."
}
-variable "project_number" {
+variable "cloud_provider" {
type = string
- description = "The GCP project number"
+ default = "digitalocean"
+ description = "Cloud provider to deploy to: gcp, digitalocean, or linode."
}
-variable "region" {
- description = "GCP region for resources"
- type = string
- default = "us-central1"
+variable "gcp" {
+ type = any
+ default = {}
+ description = "GCP provider settings passed to the app example."
}
-variable "docker_compose_repo" {
- type = string
- description = "git repo to checkout that contains a docker compose project"
+variable "digitalocean" {
+ type = any
+ default = {}
+ description = "DigitalOcean provider settings passed to the app example."
}
-variable "docker_compose_init" {
- type = list(string)
- default = []
- description = "After cloning the docker compose git repo, any initialization that needs to happen before the docker compose project can start"
+variable "linode" {
+ type = any
+ default = {}
+ description = "Linode provider settings passed to the app example."
}
-variable "allowed_ips" {
- type = list(string)
- default = []
- description = "CIDR IP Addresses allowed to turn on this site's GCP instance"
+variable "runtime" {
+ type = any
+ default = {}
+ description = "Provider-neutral runtime settings passed to the app example."
}
diff --git a/examples/omeka-classic/main.tf b/examples/omeka-classic/main.tf
new file mode 100644
index 0000000..a48af8f
--- /dev/null
+++ b/examples/omeka-classic/main.tf
@@ -0,0 +1,27 @@
+terraform {
+ required_version = ">= 1.2.4"
+}
+
+module "omeka_classic" {
+ source = "../app"
+
+ name = var.name
+ cloud_provider = var.cloud_provider
+ template = "omeka-classic"
+ gcp = var.gcp
+ digitalocean = var.digitalocean
+ linode = var.linode
+ runtime = var.runtime
+}
+
+output "instance" {
+ value = module.omeka_classic.instance
+}
+
+output "external_ip" {
+ value = module.omeka_classic.external_ip
+}
+
+output "primary_compose_project" {
+ value = module.omeka_classic.primary_compose_project
+}
diff --git a/examples/omeka-classic/variables.tf b/examples/omeka-classic/variables.tf
new file mode 100644
index 0000000..aac4363
--- /dev/null
+++ b/examples/omeka-classic/variables.tf
@@ -0,0 +1,35 @@
+variable "name" {
+ type = string
+ default = "omeka-classic-example"
+ description = "Deployment name."
+}
+
+variable "cloud_provider" {
+ type = string
+ default = "digitalocean"
+ description = "Cloud provider to deploy to: gcp, digitalocean, or linode."
+}
+
+variable "gcp" {
+ type = any
+ default = {}
+ description = "GCP provider settings passed to the app example."
+}
+
+variable "digitalocean" {
+ type = any
+ default = {}
+ description = "DigitalOcean provider settings passed to the app example."
+}
+
+variable "linode" {
+ type = any
+ default = {}
+ description = "Linode provider settings passed to the app example."
+}
+
+variable "runtime" {
+ type = any
+ default = {}
+ description = "Provider-neutral runtime settings passed to the app example."
+}
diff --git a/examples/omeka-s/main.tf b/examples/omeka-s/main.tf
new file mode 100644
index 0000000..bc24741
--- /dev/null
+++ b/examples/omeka-s/main.tf
@@ -0,0 +1,27 @@
+terraform {
+ required_version = ">= 1.2.4"
+}
+
+module "omeka_s" {
+ source = "../app"
+
+ name = var.name
+ cloud_provider = var.cloud_provider
+ template = "omeka-s"
+ gcp = var.gcp
+ digitalocean = var.digitalocean
+ linode = var.linode
+ runtime = var.runtime
+}
+
+output "instance" {
+ value = module.omeka_s.instance
+}
+
+output "external_ip" {
+ value = module.omeka_s.external_ip
+}
+
+output "primary_compose_project" {
+ value = module.omeka_s.primary_compose_project
+}
diff --git a/examples/omeka-s/variables.tf b/examples/omeka-s/variables.tf
new file mode 100644
index 0000000..f304c5b
--- /dev/null
+++ b/examples/omeka-s/variables.tf
@@ -0,0 +1,35 @@
+variable "name" {
+ type = string
+ default = "omeka-s-example"
+ description = "Deployment name."
+}
+
+variable "cloud_provider" {
+ type = string
+ default = "digitalocean"
+ description = "Cloud provider to deploy to: gcp, digitalocean, or linode."
+}
+
+variable "gcp" {
+ type = any
+ default = {}
+ description = "GCP provider settings passed to the app example."
+}
+
+variable "digitalocean" {
+ type = any
+ default = {}
+ description = "DigitalOcean provider settings passed to the app example."
+}
+
+variable "linode" {
+ type = any
+ default = {}
+ description = "Linode provider settings passed to the app example."
+}
+
+variable "runtime" {
+ type = any
+ default = {}
+ description = "Provider-neutral runtime settings passed to the app example."
+}
diff --git a/examples/wp/main.tf b/examples/wp/main.tf
new file mode 100644
index 0000000..75260c4
--- /dev/null
+++ b/examples/wp/main.tf
@@ -0,0 +1,27 @@
+terraform {
+ required_version = ">= 1.2.4"
+}
+
+module "wp" {
+ source = "../app"
+
+ name = var.name
+ cloud_provider = var.cloud_provider
+ template = "wp"
+ gcp = var.gcp
+ digitalocean = var.digitalocean
+ linode = var.linode
+ runtime = var.runtime
+}
+
+output "instance" {
+ value = module.wp.instance
+}
+
+output "external_ip" {
+ value = module.wp.external_ip
+}
+
+output "primary_compose_project" {
+ value = module.wp.primary_compose_project
+}
diff --git a/examples/wp/variables.tf b/examples/wp/variables.tf
new file mode 100644
index 0000000..7bbc626
--- /dev/null
+++ b/examples/wp/variables.tf
@@ -0,0 +1,35 @@
+variable "name" {
+ type = string
+ default = "wp-example"
+ description = "Deployment name."
+}
+
+variable "cloud_provider" {
+ type = string
+ default = "digitalocean"
+ description = "Cloud provider to deploy to: gcp, digitalocean, or linode."
+}
+
+variable "gcp" {
+ type = any
+ default = {}
+ description = "GCP provider settings passed to the app example."
+}
+
+variable "digitalocean" {
+ type = any
+ default = {}
+ description = "DigitalOcean provider settings passed to the app example."
+}
+
+variable "linode" {
+ type = any
+ default = {}
+ description = "Linode provider settings passed to the app example."
+}
+
+variable "runtime" {
+ type = any
+ default = {}
+ description = "Provider-neutral runtime settings passed to the app example."
+}
diff --git a/main.tf b/main.tf
index 88f1f85..784d019 100644
--- a/main.tf
+++ b/main.tf
@@ -1,701 +1,246 @@
terraform {
required_version = ">= 1.2.4"
-
- required_providers {
- cloudinit = {
- source = "hashicorp/cloudinit"
- version = "~> 2.3"
- }
- google = {
- source = "hashicorp/google"
- version = "~> 7.0"
- }
- time = {
- source = "hashicorp/time"
- version = "~> 0.14"
- }
- }
}
-resource "time_static" "snapshot_time_static" {}
-
locals {
- rootFs = "${path.module}/rootfs"
- additional_rootfs = var.rootfs != "" ? var.rootfs : ""
- # Input example: "https://github.com/my-org/my-repo.git"
- # This regex removes everything from the start up to the first single slash (after the protocol)
- repo_path = replace(var.docker_compose_repo, "/^[^:]+://[^/]+/", "")
-
- # base the docker compose project name on the repo + branch
- clean_repo_path = replace(format("%s-%s", trim(local.repo_path, "/"), var.docker_compose_branch), "/^[^:]+://[^/]+/", "")
- compose_project_name = lower(
- replace(
- replace(local.clean_repo_path, ".git", ""),
- "/[^a-zA-Z0-9]/",
- "-"
- )
- )
- sitectl_context_name = trimspace(var.sitectl_context_name) != "" ? trimspace(var.sitectl_context_name) : var.name
- sitectl_packages = distinct(concat(["sitectl"], var.sitectl_packages))
-
- # Get files from base rootfs
- base_files = fileset(local.rootFs, "**")
-
- # Get files from additional rootfs if path is provided
- additional_files = local.additional_rootfs != "" ? fileset(local.additional_rootfs, "**") : []
-
- # Combine both file sets (additional files will override base files with same path)
- all_files = merge(
- { for file in local.base_files : file => "${local.rootFs}/${file}" },
- { for file in local.additional_files : file => "${local.additional_rootfs}/${file}" }
- )
-
- write_files_content = join("\n", [
- for file, fullpath in local.all_files : <<-EOT
- - path: "/${file}"
- permissions: "0644"
- content: |
- ${indent(4, file(fullpath))}
-EOT
- ])
- docker_compose_scripts = join("\n", [
- for name, cmds in {
- "init" = var.docker_compose_init
- "up" = var.docker_compose_up
- "down" = var.docker_compose_down
- "rollout" = var.docker_compose_rollout
- } : <<-EOT
- - path: "/mnt/disks/data/${name}"
- permissions: "0755"
- content: |
- #!/usr/bin/env bash
-
- set -eou pipefail
-
- source /home/cloud-compose/profile.sh
- pushd "$${DOCKER_COMPOSE_DIR}"
-
- echo "Running cloud-compose ${name}"
- ${join("\n ", cmds)}
- popd
-EOT
- ])
- managed_runtime_artifact_lines = [
- for artifact in var.libops_managed_artifacts : join("\t", [
- artifact.name,
- artifact.url,
- artifact.sha256,
- artifact.path,
- try(artifact.mode, "0755"),
- try(artifact.owner, "root"),
- try(artifact.group, "root"),
- try(artifact.restart, ""),
- ])
- ]
- managed_runtime_artifacts_file = <<-EOT
- - path: "/home/cloud-compose/managed-runtime-artifacts.tsv"
- permissions: "0640"
- content: |
- ${indent(8, join("\n", local.managed_runtime_artifact_lines))}
-EOT
- rollout_env_lines = var.rollout_enabled ? [
- "ROLLOUT_ENABLED=true",
- "ROLLOUT_DOWNLOAD_URL=\"${trimspace(var.rollout_release_url)}\"",
- "ROLLOUT_DOWNLOAD_SHA256=\"${trimspace(var.rollout_release_sha256)}\"",
- "PORT=\"${var.rollout_port}\"",
- "JWKS_URI=\"${trimspace(var.rollout_jwks_uri)}\"",
- "JWT_AUD=\"${trimspace(var.rollout_jwt_audience)}\"",
- "CUSTOM_CLAIMS='${trimspace(var.rollout_custom_claims)}'",
- "ROLLOUT_CMD=\"/bin/bash\"",
- "ROLLOUT_ARGS=\"/mnt/disks/data/rollout\"",
- "ROLLOUT_LOCK_FILE=\"/mnt/disks/data/rollout.lock\"",
- ] : [
- "ROLLOUT_ENABLED=false",
- ]
- rollout_env = join("\n", [for line in local.rollout_env_lines : " ${line}"])
- env_file_content = <<-EOT
- - path: "/home/cloud-compose/.env"
- permissions: "0640"
- content: |
- HOME=/home/cloud-compose
- GCP_PROJECT="${var.project_id}"
- GCP_PROJECT_NUMBER="${var.project_number}"
- GCP_INSTANCE_NAME="${var.name}"
- GCP_REGION="${var.region}"
- GCP_ZONE="${var.zone}"
- COMPOSE_PROJECT_NAME=${replace(local.compose_project_name, "/-+/", "-")}
- DOCKER_COMPOSE_DIR=/mnt/disks/data${local.repo_path}/${var.docker_compose_branch}
- DOCKER_COMPOSE_REPO="${var.docker_compose_repo}"
- DOCKER_COMPOSE_BRANCH="${var.docker_compose_branch}"
- SITECTL_PACKAGES="${join(" ", local.sitectl_packages)}"
- SITECTL_VERSION="${var.sitectl_version}"
- SITECTL_CONTEXT_NAME="${local.sitectl_context_name}"
- SITECTL_PLUGIN="${var.sitectl_plugin}"
- SITECTL_ENVIRONMENT="${var.sitectl_environment}"
- PRODUCTION="${var.production}"
- SITECTL_HEALTHCHECK_TIMEOUT="${var.sitectl_healthcheck_timeout}"
- SITECTL_HEALTHCHECK_INTERVAL="${var.sitectl_healthcheck_interval}"
- SITECTL_VERIFY_ARGS="${join(" ", var.sitectl_verify_args)}"
- LIBOPS_MANAGED_RUNTIME_ENABLED="${var.libops_managed_runtime_enabled}"
- LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE="${var.libops_internal_services_auto_update}"
- LIBOPS_LIGHTSOUT_IMAGE="${var.libops_lightsout_image}"
- LIBOPS_CAP_IMAGE="${var.libops_cap_image}"
- LIBOPS_CADVISOR_IMAGE="${var.libops_cadvisor_image}"
-${local.rollout_env}
-EOT
- use_overlay = length(var.volume_names) > 0
- prod_disk_name = var.overlay_source_instance != "" ? format("%s-data-disk", var.overlay_source_instance) : ""
- prod_disk_url = var.overlay_source_instance != "" ? format("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/disks/%s-docker-volumes", var.project_id, var.zone, var.overlay_source_instance) : ""
- rollout_runcmd = var.rollout_enabled ? [
- "bash /home/cloud-compose/deploy-rollout.sh >> /home/cloud-compose/run.log 2>&1",
- ] : []
- cloud_init_yaml = templatefile("${path.module}/templates/cloud-init.yml", {
- WRITE_FILES_CONTENT = local.write_files_content,
- DOCKER_COMPOSE_SCRIPTS = local.docker_compose_scripts,
- ENV_FILE_CONTENT = local.env_file_content,
- MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file,
- USE_OVERLAY = local.use_overlay,
- DOCKER_VOLUME_OVERLAYS = var.volume_names,
- SSH_USERS = var.users,
- ADDITIONAL_INITCMD = var.initcmd,
- ADDITIONAL_RUNCMD = concat(local.rollout_runcmd, var.runcmd),
- })
-
- # have prod snapshot begin ten minutes after the initial run
- # so non-prod environments can have a snapshot disk to overlay
- snapshot_start_time = formatdate("h:00", time_static.snapshot_time_static.rfc3339)
-
- vm_service_account_email = var.service_account_email != "" ? var.service_account_email : google_service_account.cloud-compose[0].email
- vm_service_account_id = var.service_account_email != "" ? "projects/${var.project_id}/serviceAccounts/${var.service_account_email}" : google_service_account.cloud-compose[0].id
- vm_service_account_name = var.service_account_email != "" ? local.vm_service_account_id : google_service_account.cloud-compose[0].name
-}
-
-data "cloudinit_config" "ci" {
- part {
- content_type = "text/cloud-config"
- content = local.cloud_init_yaml
- }
-}
-
-resource "google_service_account" "cloud-compose" {
- count = var.service_account_email == "" ? 1 : 0
- account_id = format("vm-%s", var.name)
- project = var.project_id
-}
-
-# docker pull app images
-resource "google_artifact_registry_repository_iam_member" "private-policy-cloud-compose" {
- count = var.artifact_registry_repository != "" ? 1 : 0
- project = var.project_id
- location = var.artifact_registry_location
- repository = var.artifact_registry_repository
- role = "roles/artifactregistry.reader"
- member = "serviceAccount:${local.vm_service_account_email}"
-}
-
-# let VM run as the GSA
-resource "google_service_account_iam_member" "gsa-user" {
- service_account_id = local.vm_service_account_id
- role = "roles/iam.serviceAccountUser"
- member = "serviceAccount:${var.project_number}-compute@developer.gserviceaccount.com"
-}
-
-resource "google_service_account_iam_member" "token-creator" {
- service_account_id = local.vm_service_account_id
- role = "roles/iam.serviceAccountTokenCreator"
- member = "serviceAccount:${local.vm_service_account_email}"
-}
-
-# push logs to GCP
-resource "google_project_iam_member" "log" {
- project = var.project_id
- role = "roles/logging.logWriter"
- member = "serviceAccount:${local.vm_service_account_email}"
-}
-
-resource "google_compute_disk" "boot" {
- # force re-create VM when cloud-init changes
- name = format("%s-boot-%s", var.name, md5(data.cloudinit_config.ci.rendered))
- project = var.project_id
- type = var.disk_type
- zone = var.zone
- size = 15
- image = "projects/cos-cloud/global/images/${var.os}"
- physical_block_size_bytes = 4096
-}
-
-resource "google_compute_disk" "data" {
- name = format("%s-data-disk", var.name)
- project = var.project_id
- type = var.disk_type
- zone = var.zone
- size = 20
- image = "debian-13-trixie-v20251111"
- physical_block_size_bytes = 4096
-}
-
-resource "google_compute_disk" "docker-volumes" {
- name = format("%s-docker-volumes", var.name)
- project = var.project_id
- type = var.disk_type
- zone = var.zone
- size = var.disk_size_gb
- image = "debian-13-trixie-v20251111"
- physical_block_size_bytes = 4096
-}
-
-resource "google_compute_reservation" "production" {
- count = var.production ? 1 : 0
- name = format("%s-production", var.name)
- project = var.project_id
- zone = var.zone
-
- specific_reservation {
- count = 1
- instance_properties {
- machine_type = var.machine_type
- }
- }
- specific_reservation_required = true
-}
-
-# Daily snapshot schedule for production docker volume disk
-resource "google_compute_resource_policy" "daily_snapshot" {
- count = var.run_snapshots ? 1 : 0
- name = format("%s-daily-snapshot", var.name)
- project = var.project_id
- region = var.region
-
- snapshot_schedule_policy {
- schedule {
- daily_schedule {
- days_in_cycle = 1
- start_time = local.snapshot_start_time
- }
- }
-
- retention_policy {
- max_retention_days = 7
- on_source_disk_delete = "KEEP_AUTO_SNAPSHOTS"
- }
-
- snapshot_properties {
- labels = {
- managed_by = "terraform"
- instance = var.name
- }
- storage_locations = [var.region]
- guest_flush = false
- }
- }
-}
-
-resource "google_compute_resource_policy" "weekly_snapshot" {
- count = var.run_snapshots ? 1 : 0
- name = format("%s-weekly-snapshot", var.name)
- project = var.project_id
- region = var.region
-
- snapshot_schedule_policy {
- schedule {
- weekly_schedule {
- day_of_weeks {
- day = "SUNDAY"
- start_time = "01:00"
- }
- }
- }
-
- retention_policy {
- max_retention_days = 365
- on_source_disk_delete = "KEEP_AUTO_SNAPSHOTS"
- }
-
- snapshot_properties {
- storage_locations = [var.region]
- guest_flush = false
- }
- }
-}
-
-resource "google_compute_disk_resource_policy_attachment" "daily_snapshot" {
- for_each = var.run_snapshots ? toset([
- google_compute_disk.docker-volumes.name,
- google_compute_disk.data.name
- ]) : []
-
- name = google_compute_resource_policy.daily_snapshot[0].name
- disk = each.value
- project = var.project_id
- zone = var.zone
-}
-
-resource "google_compute_disk_resource_policy_attachment" "weekly_snapshot" {
- for_each = var.run_snapshots ? toset([
- google_compute_disk.docker-volumes.name,
- google_compute_disk.data.name
- ]) : []
-
- name = google_compute_resource_policy.weekly_snapshot[0].name
- disk = each.value
- project = var.project_id
- zone = var.zone
-}
-
-# Get the latest snapshot from production instance's data disk
-data "google_compute_snapshot" "latest_prod" {
- count = local.use_overlay ? 1 : 0
- project = var.project_id
-
- # Filter to snapshots of the production data disk, get most recent
- most_recent = true
- filter = "sourceDisk eq ${local.prod_disk_url}"
-}
-
-# Restore production snapshot to a staging-specific disk for overlays
-resource "google_compute_disk" "overlay_disk" {
- count = local.use_overlay ? 1 : 0
- name = data.google_compute_snapshot.latest_prod[0].name
- project = var.project_id
- type = var.disk_type
- zone = var.zone
- snapshot = data.google_compute_snapshot.latest_prod[0].self_link
- physical_block_size_bytes = 4096
-
- lifecycle {
- create_before_destroy = true
- }
-}
-
-resource "google_compute_instance" "cloud-compose" {
- name = var.name
- project = var.project_id
- machine_type = var.machine_type
- zone = var.zone
- allow_stopping_for_update = true
- tags = ["cloud-compose", var.name]
- can_ip_forward = "false"
-
- boot_disk {
- auto_delete = "true"
- device_name = "boot"
- source = google_compute_disk.boot.self_link
- }
- attached_disk {
- device_name = "data"
- source = google_compute_disk.data.self_link
- }
- attached_disk {
- device_name = "docker-volumes"
- source = google_compute_disk.docker-volumes.self_link
- }
-
- dynamic "attached_disk" {
- for_each = local.use_overlay ? [1] : []
- content {
- device_name = "prod-volumes"
- source = google_compute_disk.overlay_disk[0].self_link
- # hyperdisk needs to be attached rw
- # even though we're setting this as lowerdir read only
- mode = "READ_WRITE"
- }
- }
-
- metadata = {
- google-logging-enabled = "true"
- google-logging-use-fluentbit = "true"
- google-monitoring-enabled = "true"
- user-data = data.cloudinit_config.ci.part[0].content
- }
-
- network_interface {
- network = "default"
- access_config {}
- }
-
- reservation_affinity {
- type = var.production ? "SPECIFIC_RESERVATION" : "ANY_RESERVATION"
-
- dynamic "specific_reservation" {
- for_each = var.production ? [google_compute_reservation.production[0].name] : []
- content {
- key = "compute.googleapis.com/reservation-name"
- values = [specific_reservation.value]
- }
- }
- }
-
- scheduling {
- automatic_restart = "true"
- min_node_cpus = "0"
- on_host_maintenance = "MIGRATE"
- preemptible = "false"
- provisioning_model = "STANDARD"
- }
-
- service_account {
- email = local.vm_service_account_email
- scopes = [
- "https://www.googleapis.com/auth/cloud-platform"
- ]
- }
-
- shielded_instance_config {
- enable_integrity_monitoring = "true"
- enable_secure_boot = "true"
- enable_vtpm = "true"
- }
-
- lifecycle {
- precondition {
- condition = (
- startswith(var.machine_type, "e2") ?
- contains(["pd-ssd", "pd-standard"], var.disk_type) :
- true
+ cloud_provider = lower(trimspace(var.cloud_provider))
+ template_name = lower(trimspace(var.template))
+
+ app_templates = {
+ archivesspace = {
+ repo = "https://github.com/libops/archivesspace.git"
+ branch = "main"
+ plugin = "archivesspace"
+ packages = ["sitectl", "sitectl-archivesspace"]
+ }
+ ojs = {
+ repo = "https://github.com/libops/ojs.git"
+ branch = "main"
+ plugin = "ojs"
+ packages = ["sitectl", "sitectl-ojs"]
+ }
+ isle = {
+ repo = "https://github.com/libops/isle"
+ branch = "main"
+ plugin = "isle"
+ packages = ["sitectl", "sitectl-drupal", "sitectl-isle"]
+ }
+ drupal = {
+ repo = "https://github.com/libops/drupal.git"
+ branch = "main"
+ plugin = "drupal"
+ packages = ["sitectl", "sitectl-drupal"]
+ }
+ wp = {
+ repo = "https://github.com/libops/wp.git"
+ branch = "main"
+ plugin = "wp"
+ packages = ["sitectl", "sitectl-wp"]
+ }
+ "omeka-s" = {
+ repo = "https://github.com/libops/omeka-s.git"
+ branch = "main"
+ plugin = "omeka-s"
+ packages = ["sitectl", "sitectl-omeka-s"]
+ }
+ "omeka-classic" = {
+ repo = "https://github.com/libops/omeka-classic.git"
+ branch = "main"
+ plugin = "omeka-classic"
+ packages = ["sitectl", "sitectl-omeka-classic"]
+ }
+ }
+ empty_template = {
+ repo = ""
+ branch = "main"
+ plugin = "core"
+ packages = ["sitectl"]
+ }
+ template = local.template_name == "" ? local.empty_template : try(local.app_templates[local.template_name], local.empty_template)
+
+ input_compose = var.runtime.compose
+ input_sitectl = var.runtime.sitectl
+
+ runtime = merge(var.runtime, {
+ compose = merge(local.input_compose, {
+ repo = (
+ trimspace(local.input_compose.repo) != ""
+ ? local.input_compose.repo
+ : local.template.repo
)
- error_message = "When using an 'e2' machine type, 'disk_type' must be 'pd-ssd' or 'pd-standard'."
- }
- precondition {
- condition = !var.rollout_enabled || trimspace(var.rollout_release_url) != ""
- error_message = "rollout_release_url is required when rollout_enabled is true."
- }
- precondition {
- condition = !var.rollout_enabled || can(regex("^[0-9a-f]{64}$", trimspace(var.rollout_release_sha256)))
- error_message = "rollout_release_sha256 must be a lowercase SHA256 hex digest when rollout_enabled is true."
- }
- precondition {
- condition = !var.rollout_enabled || trimspace(var.rollout_jwks_uri) != ""
- error_message = "rollout_jwks_uri is required when rollout_enabled is true."
- }
- precondition {
- condition = !var.rollout_enabled || trimspace(var.rollout_jwt_audience) != ""
- error_message = "rollout_jwt_audience is required when rollout_enabled is true."
- }
- }
-
- depends_on = [google_compute_disk.overlay_disk]
-}
-
-# machine needs to be able to suspend itself
-data "google_project_iam_custom_role" "gce-suspend" {
- project = var.project_id
- role_id = "suspendVM"
-}
-
-
-# =============================================================================
-# LIBOPS ADMIN SERVICES IDENTITY
-# =============================================================================
-
-resource "google_service_account" "internal-services" {
- account_id = format("internal-%s", var.name)
- project = var.project_id
-}
-
-resource "google_service_account_iam_member" "internal-services-keys" {
- service_account_id = google_service_account.internal-services.id
- role = "roles/iam.serviceAccountKeyAdmin"
- member = "serviceAccount:${local.vm_service_account_email}"
-}
-
-# push metrics to GCP
-resource "google_project_iam_member" "stackdriver" {
- project = var.project_id
- role = "roles/monitoring.metricWriter"
- member = "serviceAccount:${google_service_account.internal-services.email}"
-}
-
-# suspend the GCP instance
-resource "google_project_iam_member" "gce-suspend" {
- project = var.project_id
- role = data.google_project_iam_custom_role.gce-suspend.name
- member = "serviceAccount:${google_service_account.internal-services.email}"
-}
-
-# =============================================================================
-# DOCKER COMPOSE APP IDENTITY
-# =============================================================================
-
-resource "google_service_account" "app" {
- account_id = var.name
- project = var.project_id
-}
-
-resource "google_service_account_iam_member" "app-keys" {
- service_account_id = google_service_account.app.id
- role = "roles/iam.serviceAccountKeyAdmin"
- member = "serviceAccount:${local.vm_service_account_email}"
-}
-
-resource "google_service_account_iam_member" "self_jwt_signer_policy" {
- service_account_id = google_service_account.app.id
- role = "roles/iam.serviceAccountTokenCreator"
- member = format("serviceAccount:%s", google_service_account.app.email)
-}
-
-# =============================================================================
-# CLOUD RUN INGRESS
-# =============================================================================
-
-locals {
- base_config = yamldecode(
- < 0 ? var.allowed_ssh_ipv4 : ["127.0.0.1/32"]
-}
-
-resource "google_compute_firewall" "allow_ssh_ipv6" {
- project = var.project_id
- name = format("allow-ssh-ipv6-%s", var.name)
- network = "default"
- priority = 10
- direction = "INGRESS"
-
- allow {
- protocol = "tcp"
- ports = ["22"]
- }
-
- target_tags = [var.name]
-
- source_ranges = length(var.allowed_ssh_ipv6) > 0 ? var.allowed_ssh_ipv6 : ["127.0.0.1/32"]
-}
-
-resource "google_compute_firewall" "allow_rollout_ipv4" {
- count = var.rollout_enabled ? 1 : 0
- project = var.project_id
- name = format("allow-rollout-ipv4-%s", var.name)
- network = "default"
- priority = 20
- direction = "INGRESS"
-
- allow {
- protocol = "tcp"
- ports = [tostring(var.rollout_port)]
- }
+ branch = (
+ trimspace(local.input_compose.branch) != ""
+ ? local.input_compose.branch
+ : local.template.branch
+ )
+ })
+ sitectl = merge(local.input_sitectl, {
+ packages = (
+ local.template_name != "" && length(local.input_sitectl.packages) == 1 && local.input_sitectl.packages[0] == "sitectl"
+ ? local.template.packages
+ : local.input_sitectl.packages
+ )
+ plugin = (
+ local.template_name != "" && local.input_sitectl.plugin == "core"
+ ? local.template.plugin
+ : local.input_sitectl.plugin
+ )
+ })
+ })
- target_tags = [var.name]
- source_ranges = length(var.rollout_allowed_ipv4) > 0 ? var.rollout_allowed_ipv4 : ["127.0.0.1/32"]
+ compose = local.runtime.compose
+ sitectl = local.runtime.sitectl
+ docker = local.runtime.docker
+ managed = local.runtime.managed_runtime
+ vault = local.runtime.vault
+
+ gcp_instance = var.gcp.instance
+ gcp_disks = var.gcp.disks
+ gcp_identity = var.gcp.identity
+ gcp_network = var.gcp.network
+ gcp_snapshots = var.gcp.snapshots
+ gcp_overlay = var.gcp.overlay
+ gcp_artifact_registry = var.gcp.artifact_registry
+ gcp_cloud_init = var.gcp.cloud_init
+ gcp_power_management = var.gcp.power_management
+ gcp_rollout = var.gcp.rollout
+
+ linux_runtime = {
+ rootfs = local.runtime.rootfs
+ rootfs_archive_url = local.runtime.rootfs_archive_url
+ rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256
+ compose = local.compose
+ sitectl = local.sitectl
+ docker = local.docker
+ managed_runtime = {
+ enabled = local.managed.enabled
+ internal_services_enabled = local.managed.internal_services_enabled
+ internal_services_auto_update = local.managed.internal_services_auto_update
+ artifacts = local.managed.artifacts
+ }
+ vault = {
+ addr = local.vault.addr
+ namespace = local.vault.namespace
+ role = local.vault.role
+ agent_enabled = local.vault.agent_enabled
+ auth_method = local.vault.auth_method
+ agent_token_path = local.vault.agent_token_path
+ agent_additional_config = local.vault.agent_additional_config
+ agent_templates = local.vault.agent_templates
+ }
+ extra_env = local.runtime.extra_env
+ }
+}
+
+module "gcp" {
+ count = local.cloud_provider == "gcp" ? 1 : 0
+ source = "./modules/gcp"
+
+ name = var.name
+ project_id = var.gcp.project_id
+ project_number = var.gcp.project_number
+ region = var.gcp.region
+ zone = var.gcp.zone
+
+ service_account_email = local.gcp_identity.vm_service_account_email
+ app_service_account_email = local.gcp_identity.app_service_account_email
+
+ machine_type = local.gcp_instance.machine_type
+ os = local.gcp_instance.os
+ production = local.gcp_instance.production
+
+ disk_type = local.gcp_disks.type
+ disk_size_gb = local.gcp_disks.docker_volumes_size_gb
+
+ ingress_port = local.compose.ingress_port
+ primary_compose_project = local.compose.primary
+ sitectl_ingress = local.compose.ingress
+ docker_compose_repo = local.compose.repo
+ docker_compose_branch = local.compose.branch
+ compose_projects = local.compose.projects
+ docker_compose_init = local.compose.init
+ docker_compose_up = local.compose.up
+ docker_compose_down = local.compose.down
+ docker_compose_rollout = local.compose.rollout
+
+ sitectl_packages = local.sitectl.packages
+ sitectl_version = local.sitectl.version
+ sitectl_context_name = local.sitectl.context_name
+ sitectl_plugin = local.sitectl.plugin
+ sitectl_environment = local.sitectl.environment
+ sitectl_healthcheck_timeout = local.sitectl.healthcheck_timeout
+ sitectl_healthcheck_interval = local.sitectl.healthcheck_interval
+ sitectl_verify_args = local.sitectl.verify_args
+
+ docker_compose_version = local.docker.compose_version
+ docker_buildx_version = local.docker.buildx_version
+
+ libops_managed_runtime_enabled = local.managed.enabled
+ libops_internal_services_enabled = local.managed.internal_services_enabled
+ libops_internal_services_auto_update = local.managed.internal_services_auto_update
+ libops_lightsout_image = local.managed.lightsout_image
+ libops_cap_image = local.managed.cap_image
+ libops_cadvisor_image = local.managed.cadvisor_image
+ libops_managed_artifacts = local.managed.artifacts
+
+ allowed_ips = local.gcp_network.power_button_allowed_ips
+ allowed_ssh_ipv4 = local.gcp_network.ssh_ipv4
+ allowed_ssh_ipv6 = local.gcp_network.ssh_ipv6
+
+ create_network = local.gcp_network.create
+ network_name = local.gcp_network.name
+ subnetwork_name = local.gcp_network.subnetwork
+ network_ip_cidr_range = local.gcp_network.ip_cidr_range
+
+ run_snapshots = local.gcp_snapshots.enabled
+ overlay_source_instance = local.gcp_overlay.source_instance
+ volume_names = local.gcp_overlay.volume_names
+
+ users = local.runtime.users
+ rootfs = local.runtime.rootfs
+ runcmd = local.gcp_cloud_init.runcmd
+ initcmd = local.gcp_cloud_init.initcmd
+
+ artifact_registry_repository = local.gcp_artifact_registry.repository
+ artifact_registry_location = local.gcp_artifact_registry.location
+
+ power_management_enabled = local.gcp_power_management.enabled
+ frontend = local.gcp_power_management.frontend
+
+ rollout_enabled = local.gcp_rollout.enabled
+ rollout_release_url = local.gcp_rollout.release_url
+ rollout_release_sha256 = local.gcp_rollout.release_sha256
+ rollout_port = local.gcp_rollout.port
+ rollout_jwks_uri = local.gcp_rollout.jwks_uri
+ rollout_jwt_audience = local.gcp_rollout.jwt_audience
+ rollout_custom_claims = local.gcp_rollout.custom_claims
+ rollout_allowed_ipv4 = local.gcp_rollout.allowed_ipv4
+
+ vault_addr = local.vault.addr
+ vault_namespace = local.vault.namespace
+ vault_role = local.vault.role
+ vault_agent_enabled = local.vault.agent_enabled
+ vault_auth_method = local.vault.auth_method
+ vault_gcp_auth_mount_path = local.vault.gcp_auth_mount_path
+ vault_agent_token_path = local.vault.agent_token_path
+ vault_agent_templates = local.vault.agent_templates
+ vault_agent_additional_config = local.vault.agent_additional_config
+}
+
+module "digitalocean" {
+ count = local.cloud_provider == "digitalocean" ? 1 : 0
+ source = "./modules/digitalocean"
+
+ name = var.name
+ digitalocean = var.digitalocean
+ runtime = local.linux_runtime
+}
+
+module "linode" {
+ count = local.cloud_provider == "linode" ? 1 : 0
+ source = "./modules/linode"
+
+ name = var.name
+ linode = var.linode
+ runtime = local.linux_runtime
}
diff --git a/modules/digitalocean/main.tf b/modules/digitalocean/main.tf
new file mode 100644
index 0000000..30e923b
--- /dev/null
+++ b/modules/digitalocean/main.tf
@@ -0,0 +1,173 @@
+locals {
+ do = var.digitalocean
+ runtime = var.runtime
+ compose = var.runtime.compose
+ sitectl = var.runtime.sitectl
+ docker = var.runtime.docker
+ managed = var.runtime.managed_runtime
+ vault = var.runtime.vault
+ data_volume_name = "${var.name}-data"
+ docker_volumes_volume_name = "${var.name}-docker-volumes"
+}
+
+module "runtime" {
+ source = "../linux-vm-runtime"
+
+ name = var.name
+ provider_name = "digitalocean"
+ region = local.do.region
+ data_device = "/dev/disk/by-id/scsi-0DO_Volume_${local.data_volume_name}"
+ volumes_device = "/dev/disk/by-id/scsi-0DO_Volume_${local.docker_volumes_volume_name}"
+ ssh_users = local.do.ssh.users
+ cloud_compose_ssh_keys = local.do.ssh.cloud_compose_keys
+ rootfs = local.runtime.rootfs
+ rootfs_archive_url = local.runtime.rootfs_archive_url
+ rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256
+ ingress_port = local.compose.ingress_port
+ primary_compose_project = local.compose.primary
+ sitectl_ingress = local.compose.ingress
+ docker_compose_repo = local.compose.repo
+ docker_compose_branch = local.compose.branch
+ compose_projects = local.compose.projects
+ docker_compose_init = local.compose.init
+ docker_compose_up = local.compose.up
+ docker_compose_down = local.compose.down
+ docker_compose_rollout = local.compose.rollout
+
+ sitectl_packages = local.sitectl.packages
+ sitectl_version = local.sitectl.version
+ sitectl_context_name = local.sitectl.context_name
+ sitectl_plugin = local.sitectl.plugin
+ sitectl_environment = local.sitectl.environment
+ sitectl_healthcheck_timeout = local.sitectl.healthcheck_timeout
+ sitectl_healthcheck_interval = local.sitectl.healthcheck_interval
+ sitectl_verify_args = local.sitectl.verify_args
+ docker_compose_version = local.docker.compose_version
+ docker_buildx_version = local.docker.buildx_version
+
+ libops_managed_runtime_enabled = local.managed.enabled
+ libops_internal_services_enabled = local.managed.internal_services_enabled
+ libops_internal_services_auto_update = local.managed.internal_services_auto_update
+ libops_managed_artifacts = local.managed.artifacts
+
+ vault_addr = local.vault.addr
+ vault_namespace = local.vault.namespace
+ vault_role = local.vault.role
+ vault_agent_enabled = local.vault.agent_enabled
+ vault_auth_method = local.vault.auth_method
+ vault_agent_token_path = local.vault.agent_token_path
+ vault_agent_templates = local.vault.agent_templates
+ vault_agent_additional_config = local.vault.agent_additional_config
+ extra_env = local.runtime.extra_env
+}
+
+locals {
+ ingress_ports = distinct([for _, app in module.runtime.compose_projects : tostring(app.ingress_port)])
+}
+
+resource "digitalocean_volume" "data" {
+ region = local.do.region
+ name = local.data_volume_name
+ size = local.do.volumes.data_size_gb
+ initial_filesystem_type = "ext4"
+ description = "cloud-compose persistent data for ${var.name}"
+ tags = local.do.tags
+}
+
+resource "digitalocean_volume" "docker_volumes" {
+ region = local.do.region
+ name = local.docker_volumes_volume_name
+ size = local.do.volumes.docker_volumes_size_gb
+ initial_filesystem_type = "ext4"
+ description = "cloud-compose Docker volumes for ${var.name}"
+ tags = local.do.tags
+}
+
+resource "digitalocean_droplet" "cloud_compose" {
+ name = var.name
+ region = local.do.region
+ size = local.do.droplet.size
+ image = local.do.droplet.image
+ ssh_keys = local.do.droplet.ssh_keys
+ tags = local.do.tags
+ vpc_uuid = local.do.droplet.vpc_uuid
+ monitoring = local.do.droplet.monitoring
+ ipv6 = local.do.droplet.ipv6
+ backups = local.do.droplet.backups
+ graceful_shutdown = true
+ user_data = module.runtime.cloud_init
+
+ lifecycle {
+ precondition {
+ condition = alltrue([
+ for _, app in module.runtime.compose_projects : trimspace(app.docker_compose_repo) != ""
+ ])
+ error_message = "Each compose project must define docker_compose_repo."
+ }
+ precondition {
+ condition = alltrue([
+ for _, app in module.runtime.compose_projects :
+ !(contains(["https-letsencrypt", "letsencrypt", "le"], app.ingress.mode) || app.ingress.letsencrypt) ||
+ trimspace(app.ingress.domain) != "" && trimspace(app.ingress.acme_email) != ""
+ ])
+ error_message = "Let's Encrypt ingress requires ingress.domain and ingress.acme_email for each enabled compose project."
+ }
+ precondition {
+ condition = !local.vault.agent_enabled || trimspace(local.vault.addr) != ""
+ error_message = "vault_addr is required when vault_agent_enabled is true."
+ }
+ precondition {
+ condition = !local.vault.agent_enabled || local.vault.auth_method != "consumer-managed" || trimspace(local.vault.agent_additional_config) != ""
+ error_message = "vault_agent_additional_config is required when vault_agent_enabled uses consumer-managed auth."
+ }
+ }
+}
+
+resource "digitalocean_volume_attachment" "data" {
+ droplet_id = digitalocean_droplet.cloud_compose.id
+ volume_id = digitalocean_volume.data.id
+}
+
+resource "digitalocean_volume_attachment" "docker_volumes" {
+ droplet_id = digitalocean_droplet.cloud_compose.id
+ volume_id = digitalocean_volume.docker_volumes.id
+}
+
+resource "digitalocean_firewall" "cloud_compose" {
+ count = local.do.firewall.enabled ? 1 : 0
+ name = "${var.name}-cloud-compose"
+
+ droplet_ids = [digitalocean_droplet.cloud_compose.id]
+
+ inbound_rule {
+ protocol = "tcp"
+ port_range = "22"
+ source_addresses = local.do.firewall.ssh_source_addresses
+ }
+
+ dynamic "inbound_rule" {
+ for_each = toset(local.ingress_ports)
+ content {
+ protocol = "tcp"
+ port_range = inbound_rule.value
+ source_addresses = local.do.firewall.web_source_addresses
+ }
+ }
+
+ outbound_rule {
+ protocol = "tcp"
+ port_range = "1-65535"
+ destination_addresses = ["0.0.0.0/0", "::/0"]
+ }
+
+ outbound_rule {
+ protocol = "udp"
+ port_range = "1-65535"
+ destination_addresses = ["0.0.0.0/0", "::/0"]
+ }
+
+ outbound_rule {
+ protocol = "icmp"
+ destination_addresses = ["0.0.0.0/0", "::/0"]
+ }
+}
diff --git a/modules/digitalocean/outputs.tf b/modules/digitalocean/outputs.tf
new file mode 100644
index 0000000..0c88cf8
--- /dev/null
+++ b/modules/digitalocean/outputs.tf
@@ -0,0 +1,36 @@
+output "instance" {
+ value = {
+ id = digitalocean_droplet.cloud_compose.id
+ urn = digitalocean_droplet.cloud_compose.urn
+ name = digitalocean_droplet.cloud_compose.name
+ region = digitalocean_droplet.cloud_compose.region
+ ipv4 = digitalocean_droplet.cloud_compose.ipv4_address
+ private_ip = digitalocean_droplet.cloud_compose.ipv4_address_private
+ ipv6 = digitalocean_droplet.cloud_compose.ipv6_address
+ }
+ description = "DigitalOcean Droplet details."
+}
+
+output "volumes" {
+ value = {
+ data = {
+ id = digitalocean_volume.data.id
+ name = digitalocean_volume.data.name
+ }
+ docker_volumes = {
+ id = digitalocean_volume.docker_volumes.id
+ name = digitalocean_volume.docker_volumes.name
+ }
+ }
+ description = "Persistent DigitalOcean volumes."
+}
+
+output "compose_projects" {
+ value = module.runtime.compose_projects
+ description = "Normalized compose project manifest."
+}
+
+output "primary_compose_project" {
+ value = module.runtime.primary_compose_project
+ description = "Normalized primary compose project."
+}
diff --git a/modules/digitalocean/variables.tf b/modules/digitalocean/variables.tf
new file mode 100644
index 0000000..4b95ae7
--- /dev/null
+++ b/modules/digitalocean/variables.tf
@@ -0,0 +1,180 @@
+variable "name" {
+ type = string
+ description = "Deployment name. Used for the Droplet, volumes, firewall, and default compose project."
+
+ validation {
+ condition = can(regex("^[a-z][a-z0-9-]{0,45}$", var.name))
+ error_message = "name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens."
+ }
+}
+
+variable "digitalocean" {
+ description = "DigitalOcean infrastructure settings."
+ type = object({
+ region = optional(string, "tor1")
+ tags = optional(list(string), ["cloud-compose"])
+
+ droplet = optional(object({
+ size = optional(string, "s-2vcpu-4gb")
+ image = optional(string, "ubuntu-24-04-x64")
+ ssh_keys = optional(list(string), [])
+ vpc_uuid = optional(string, null)
+ monitoring = optional(bool, true)
+ ipv6 = optional(bool, true)
+ backups = optional(bool, false)
+ }), {})
+
+ ssh = optional(object({
+ cloud_compose_keys = optional(list(string), [])
+ users = optional(map(list(string)), {})
+ }), {})
+
+ volumes = optional(object({
+ data_size_gb = optional(number, 50)
+ docker_volumes_size_gb = optional(number, 100)
+ }), {})
+
+ firewall = optional(object({
+ enabled = optional(bool, true)
+ ssh_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"])
+ web_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"])
+ }), {})
+ })
+ default = {}
+}
+
+variable "runtime" {
+ description = "Provider-neutral compose/runtime settings."
+ type = object({
+ rootfs = optional(string, "")
+ rootfs_archive_url = optional(string, "")
+ rootfs_archive_sha256 = optional(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), [
+ "sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --skip-git",
+ "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\""
+ ])
+ }), {})
+
+ 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, false)
+ internal_services_auto_update = optional(bool, false)
+ artifacts = optional(list(object({
+ name = string
+ url = string
+ sha256 = string
+ path = string
+ mode = optional(string, "0755")
+ owner = optional(string, "root")
+ group = optional(string, "root")
+ restart = optional(string, "")
+ })), [])
+ }), {})
+
+ vault = optional(object({
+ addr = optional(string, "")
+ namespace = optional(string, "")
+ role = optional(string, "")
+ agent_enabled = optional(bool, false)
+ auth_method = optional(string, "consumer-managed")
+ 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), {})
+ })
+ default = {}
+
+ validation {
+ condition = alltrue([
+ for name, app in var.runtime.compose.projects :
+ can(regex("^[a-z][a-z0-9-]*$", name)) &&
+ trimspace(app.docker_compose_repo) != "" &&
+ try(app.ingress_port, var.runtime.compose.ingress_port) > 0 &&
+ try(app.ingress_port, var.runtime.compose.ingress_port) <= 65535
+ ])
+ error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535."
+ }
+
+ validation {
+ condition = contains(["gcp-iam", "consumer-managed"], var.runtime.vault.auth_method)
+ error_message = "runtime.vault.auth_method must be gcp-iam or consumer-managed."
+ }
+}
diff --git a/modules/digitalocean/versions.tf b/modules/digitalocean/versions.tf
new file mode 100644
index 0000000..e6e546b
--- /dev/null
+++ b/modules/digitalocean/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.2.4"
+
+ required_providers {
+ digitalocean = {
+ source = "digitalocean/digitalocean"
+ version = "~> 2.0"
+ }
+ }
+}
diff --git a/modules/gcp/main.tf b/modules/gcp/main.tf
new file mode 100644
index 0000000..9bc8ff5
--- /dev/null
+++ b/modules/gcp/main.tf
@@ -0,0 +1,904 @@
+terraform {
+ required_version = ">= 1.2.4"
+
+ required_providers {
+ cloudinit = {
+ source = "hashicorp/cloudinit"
+ version = "~> 2.3"
+ }
+ google = {
+ source = "hashicorp/google"
+ version = "~> 7.0"
+ }
+ time = {
+ source = "hashicorp/time"
+ version = "~> 0.14"
+ }
+ }
+}
+
+locals {
+ rootFs = "${path.module}/../../rootfs"
+ additional_rootfs = var.rootfs != "" ? var.rootfs : ""
+
+ single_compose_project = {
+ (var.name) = {
+ docker_compose_repo = var.docker_compose_repo
+ docker_compose_branch = var.docker_compose_branch
+ ingress_port = var.ingress_port
+ ingress = var.sitectl_ingress
+ sitectl_context_name = trimspace(var.sitectl_context_name) != "" ? trimspace(var.sitectl_context_name) : var.name
+ sitectl_plugin = var.sitectl_plugin
+ sitectl_environment = var.sitectl_environment
+ sitectl_packages = var.sitectl_packages
+ sitectl_verify_args = var.sitectl_verify_args
+ docker_compose_init = var.docker_compose_init
+ docker_compose_up = var.docker_compose_up
+ docker_compose_down = var.docker_compose_down
+ docker_compose_rollout = var.docker_compose_rollout
+ }
+ }
+ raw_compose_projects = length(var.compose_projects) > 0 ? var.compose_projects : local.single_compose_project
+ primary_compose_project_key = trimspace(var.primary_compose_project) != "" ? trimspace(var.primary_compose_project) : keys(local.raw_compose_projects)[0]
+ compose_projects = {
+ for app_name, app in local.raw_compose_projects : app_name => {
+ name = app_name
+ docker_compose_repo = trimspace(app.docker_compose_repo)
+ docker_compose_branch = trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))
+ repo_path = trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/")
+ project_dir = (
+ trimspace(try(app.project_dir, "")) != ""
+ ? trimspace(try(app.project_dir, ""))
+ : "/mnt/disks/data/${trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/")}/${trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))}"
+ )
+ compose_project_name = (
+ trimspace(try(app.compose_project_name, "")) != ""
+ ? trimspace(try(app.compose_project_name, ""))
+ : replace(lower(replace(replace(format("%s-%s", trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/"), trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))), ".git", ""), "/[^a-zA-Z0-9]/", "-")), "/-+/", "-")
+ )
+ ingress_port = coalesce(try(app.ingress_port, null), var.ingress_port)
+ ingress = {
+ letsencrypt = coalesce(try(app.ingress.letsencrypt, null), var.sitectl_ingress.letsencrypt)
+ bot_mitigation = coalesce(try(app.ingress.bot_mitigation, null), var.sitectl_ingress.bot_mitigation)
+ mode = try(app.ingress.mode, null) != null ? trimspace(app.ingress.mode) : trimspace(var.sitectl_ingress.mode)
+ domain = try(app.ingress.domain, null) != null ? trimspace(app.ingress.domain) : trimspace(var.sitectl_ingress.domain)
+ acme_email = try(app.ingress.acme_email, null) != null ? trimspace(app.ingress.acme_email) : trimspace(var.sitectl_ingress.acme_email)
+ trusted_ips = try(app.ingress.trusted_ips, null) != null ? app.ingress.trusted_ips : var.sitectl_ingress.trusted_ips
+ max_upload_size = try(app.ingress.max_upload_size, null) != null ? trimspace(app.ingress.max_upload_size) : trimspace(var.sitectl_ingress.max_upload_size)
+ upload_timeout = try(app.ingress.upload_timeout, null) != null ? trimspace(app.ingress.upload_timeout) : trimspace(var.sitectl_ingress.upload_timeout)
+ }
+ sitectl_context_name = (
+ trimspace(try(app.sitectl_context_name, "")) != ""
+ ? trimspace(try(app.sitectl_context_name, ""))
+ : app_name
+ )
+ sitectl_plugin = trimspace(coalesce(try(app.sitectl_plugin, null), var.sitectl_plugin))
+ sitectl_environment = trimspace(coalesce(try(app.sitectl_environment, null), var.sitectl_environment))
+ sitectl_packages = distinct(concat(["sitectl"], try(app.sitectl_packages, [])))
+ sitectl_verify_args = try(app.sitectl_verify_args, var.sitectl_verify_args)
+ init_commands = try(app.docker_compose_init, null) != null ? app.docker_compose_init : var.docker_compose_init
+ up_commands = try(app.docker_compose_up, null) != null ? app.docker_compose_up : var.docker_compose_up
+ down_commands = try(app.docker_compose_down, null) != null ? app.docker_compose_down : var.docker_compose_down
+ rollout_commands = try(app.docker_compose_rollout, null) != null ? app.docker_compose_rollout : var.docker_compose_rollout
+ }
+ }
+ primary_compose_project = local.compose_projects[local.primary_compose_project_key]
+ sitectl_packages = distinct(concat(
+ ["sitectl"],
+ var.sitectl_packages,
+ flatten([for _, app in local.compose_projects : app.sitectl_packages])
+ ))
+ create_network = var.create_network && trimspace(var.network_name) == "" && trimspace(var.subnetwork_name) == ""
+ network_name = trimspace(var.network_name) != "" ? trimspace(var.network_name) : (local.create_network ? google_compute_network.cloud-compose[0].self_link : "default")
+ subnetwork_name = trimspace(var.subnetwork_name) != "" ? trimspace(var.subnetwork_name) : (local.create_network ? google_compute_subnetwork.cloud-compose[0].self_link : null)
+
+ # Get files from base rootfs
+ base_files = fileset(local.rootFs, "**")
+
+ # Get files from additional rootfs if path is provided
+ additional_files = local.additional_rootfs != "" ? fileset(local.additional_rootfs, "**") : []
+
+ # Combine both file sets (additional files will override base files with same path)
+ all_files = merge(
+ { for file in local.base_files : file => "${local.rootFs}/${file}" },
+ { for file in local.additional_files : file => "${local.additional_rootfs}/${file}" }
+ )
+
+ write_files_content = join("\n", [
+ for file, fullpath in local.all_files : <<-EOT
+ - path: ${jsonencode("/${file}")}
+ permissions: ${jsonencode(endswith(file, ".sh") ? "0755" : "0644")}
+ encoding: gzip+base64
+ content: ${jsonencode(base64gzip(file(fullpath)))}
+EOT
+ ])
+ docker_compose_scripts = join("\n", [
+ for name in ["init", "up", "down", "rollout"] : <<-EOT
+ - path: "/home/cloud-compose/${name}"
+ permissions: "0755"
+ encoding: gzip+base64
+ content: ${jsonencode(base64gzip(<<-EOS
+ #!/usr/bin/env bash
+
+ set -eou pipefail
+
+ source /home/cloud-compose/profile.sh
+ exec bash /home/cloud-compose/compose-dispatch.sh "${name}"
+ EOS
+))}
+EOT
+])
+compose_projects_file = <<-EOT
+ - path: "/home/cloud-compose/compose-projects.json"
+ permissions: "0640"
+ encoding: gzip+base64
+ content: ${jsonencode(base64gzip(jsonencode(local.compose_projects)))}
+EOT
+managed_runtime_artifact_lines = [
+ for artifact in var.libops_managed_artifacts : join("\t", [
+ artifact.name,
+ artifact.url,
+ artifact.sha256,
+ artifact.path,
+ try(artifact.mode, "0755"),
+ try(artifact.owner, "root"),
+ try(artifact.group, "root"),
+ try(artifact.restart, ""),
+ ])
+]
+managed_runtime_artifacts_file = <<-EOT
+ - path: "/home/cloud-compose/managed-runtime-artifacts.tsv"
+ permissions: "0640"
+ encoding: gzip+base64
+ content: ${jsonencode(base64gzip(join("\n", local.managed_runtime_artifact_lines)))}
+EOT
+vault_agent_template_stanzas = join("\n", [
+ for template in var.vault_agent_templates : <<-EOT
+ template {
+ destination = ${jsonencode(template.destination)}
+ contents = < 0
+prod_disk_name = var.overlay_source_instance != "" ? format("%s-data-disk", var.overlay_source_instance) : ""
+prod_disk_url = var.overlay_source_instance != "" ? format("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/disks/%s-docker-volumes", var.project_id, var.zone, var.overlay_source_instance) : ""
+rollout_runcmd = var.rollout_enabled ? [
+ "bash /home/cloud-compose/deploy-rollout.sh >> /home/cloud-compose/run.log 2>&1",
+] : []
+cloud_init_yaml = templatefile("${path.module}/../../templates/cloud-init.yml", {
+ WRITE_FILES_CONTENT = local.write_files_content,
+ DOCKER_COMPOSE_SCRIPTS = local.docker_compose_scripts,
+ COMPOSE_PROJECTS_FILE = local.compose_projects_file,
+ ENV_FILE_CONTENT = local.env_file_content,
+ VAULT_AGENT_FILES = local.vault_agent_files,
+ MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file,
+ USE_OVERLAY = local.use_overlay,
+ DOCKER_VOLUME_OVERLAYS = var.volume_names,
+ CLOUD_COMPOSE_SSH_KEYS = try(var.users["cloud-compose"], []),
+ SSH_USERS = { for username, ssh_keys in var.users : username => ssh_keys if username != "cloud-compose" },
+ ADDITIONAL_INITCMD = var.initcmd,
+ ADDITIONAL_RUNCMD = concat(local.rollout_runcmd, var.runcmd),
+})
+
+vm_service_account_email = var.service_account_email != "" ? var.service_account_email : google_service_account.cloud-compose[0].email
+vm_service_account_id = var.service_account_email != "" ? "projects/${var.project_id}/serviceAccounts/${var.service_account_email}" : google_service_account.cloud-compose[0].id
+vm_service_account_name = var.service_account_email != "" ? local.vm_service_account_id : google_service_account.cloud-compose[0].name
+
+app_service_account_email = var.app_service_account_email != "" ? var.app_service_account_email : google_service_account.app[0].email
+app_service_account_id = var.app_service_account_email != "" ? "projects/${var.project_id}/serviceAccounts/${var.app_service_account_email}" : google_service_account.app[0].id
+app_service_account_name = var.app_service_account_email != "" ? local.app_service_account_id : google_service_account.app[0].name
+
+internal_services_compose_profiles = var.power_management_enabled ? "lightsout" : ""
+scheduled_snapshots_enabled = var.production && var.run_snapshots
+# have prod snapshot begin near the initial run so non-prod overlays can
+# discover a production snapshot; non-production plans avoid snapshot resources.
+snapshot_start_time = local.scheduled_snapshots_enabled ? formatdate("h:00", time_static.snapshot_time_static[0].rfc3339) : "00:00"
+}
+
+resource "time_static" "snapshot_time_static" {
+ count = local.scheduled_snapshots_enabled ? 1 : 0
+}
+
+data "cloudinit_config" "ci" {
+ part {
+ content_type = "text/cloud-config"
+ content = local.cloud_init_yaml
+ }
+}
+
+resource "google_service_account" "cloud-compose" {
+ count = var.service_account_email == "" ? 1 : 0
+ account_id = format("vm-%s", var.name)
+ project = var.project_id
+}
+
+# docker pull app images
+resource "google_artifact_registry_repository_iam_member" "private-policy-cloud-compose" {
+ count = var.artifact_registry_repository != "" ? 1 : 0
+ project = var.project_id
+ location = var.artifact_registry_location
+ repository = var.artifact_registry_repository
+ role = "roles/artifactregistry.reader"
+ member = "serviceAccount:${local.vm_service_account_email}"
+}
+
+# let VM run as the GSA
+resource "google_service_account_iam_member" "gsa-user" {
+ service_account_id = local.vm_service_account_id
+ role = "roles/iam.serviceAccountUser"
+ member = "serviceAccount:${var.project_number}-compute@developer.gserviceaccount.com"
+}
+
+resource "google_service_account_iam_member" "token-creator" {
+ service_account_id = local.vm_service_account_id
+ role = "roles/iam.serviceAccountTokenCreator"
+ member = "serviceAccount:${local.vm_service_account_email}"
+}
+
+# push logs to GCP
+resource "google_project_iam_member" "log" {
+ project = var.project_id
+ role = "roles/logging.logWriter"
+ member = "serviceAccount:${local.vm_service_account_email}"
+}
+
+resource "google_compute_network" "cloud-compose" {
+ count = local.create_network ? 1 : 0
+ name = var.name
+ project = var.project_id
+ auto_create_subnetworks = false
+}
+
+resource "google_compute_subnetwork" "cloud-compose" {
+ count = local.create_network ? 1 : 0
+ name = var.name
+ project = var.project_id
+ region = var.region
+ network = google_compute_network.cloud-compose[0].self_link
+ ip_cidr_range = var.network_ip_cidr_range
+ private_ip_google_access = true
+}
+
+resource "google_compute_disk" "boot" {
+ # force re-create VM when cloud-init changes
+ name = format("%s-boot-%s", var.name, md5(data.cloudinit_config.ci.rendered))
+ project = var.project_id
+ type = var.disk_type
+ zone = var.zone
+ size = 15
+ image = "projects/cos-cloud/global/images/${var.os}"
+ physical_block_size_bytes = 4096
+}
+
+resource "google_compute_disk" "data" {
+ name = format("%s-data-disk", var.name)
+ project = var.project_id
+ type = var.disk_type
+ zone = var.zone
+ size = 20
+ physical_block_size_bytes = 4096
+}
+
+resource "google_compute_disk" "docker-volumes" {
+ name = format("%s-docker-volumes", var.name)
+ project = var.project_id
+ type = var.disk_type
+ zone = var.zone
+ size = var.disk_size_gb
+ physical_block_size_bytes = 4096
+}
+
+resource "google_compute_reservation" "production" {
+ count = var.production ? 1 : 0
+ name = format("%s-production", var.name)
+ project = var.project_id
+ zone = var.zone
+
+ specific_reservation {
+ count = 1
+ instance_properties {
+ machine_type = var.machine_type
+ }
+ }
+ specific_reservation_required = true
+}
+
+# Daily snapshot schedule for production docker volume disk
+resource "google_compute_resource_policy" "daily_snapshot" {
+ count = local.scheduled_snapshots_enabled ? 1 : 0
+ name = format("%s-daily-snapshot", var.name)
+ project = var.project_id
+ region = var.region
+
+ snapshot_schedule_policy {
+ schedule {
+ daily_schedule {
+ days_in_cycle = 1
+ start_time = local.snapshot_start_time
+ }
+ }
+
+ retention_policy {
+ max_retention_days = 7
+ on_source_disk_delete = "KEEP_AUTO_SNAPSHOTS"
+ }
+
+ snapshot_properties {
+ labels = {
+ managed_by = "terraform"
+ instance = var.name
+ }
+ storage_locations = [var.region]
+ guest_flush = false
+ }
+ }
+}
+
+resource "google_compute_resource_policy" "weekly_snapshot" {
+ count = local.scheduled_snapshots_enabled ? 1 : 0
+ name = format("%s-weekly-snapshot", var.name)
+ project = var.project_id
+ region = var.region
+
+ snapshot_schedule_policy {
+ schedule {
+ weekly_schedule {
+ day_of_weeks {
+ day = "SUNDAY"
+ start_time = "01:00"
+ }
+ }
+ }
+
+ retention_policy {
+ max_retention_days = 365
+ on_source_disk_delete = "KEEP_AUTO_SNAPSHOTS"
+ }
+
+ snapshot_properties {
+ storage_locations = [var.region]
+ guest_flush = false
+ }
+ }
+}
+
+resource "google_compute_disk_resource_policy_attachment" "daily_snapshot" {
+ for_each = local.scheduled_snapshots_enabled ? toset([
+ google_compute_disk.docker-volumes.name,
+ google_compute_disk.data.name
+ ]) : []
+
+ name = google_compute_resource_policy.daily_snapshot[0].name
+ disk = each.value
+ project = var.project_id
+ zone = var.zone
+}
+
+resource "google_compute_disk_resource_policy_attachment" "weekly_snapshot" {
+ for_each = local.scheduled_snapshots_enabled ? toset([
+ google_compute_disk.docker-volumes.name,
+ google_compute_disk.data.name
+ ]) : []
+
+ name = google_compute_resource_policy.weekly_snapshot[0].name
+ disk = each.value
+ project = var.project_id
+ zone = var.zone
+}
+
+# Get the latest snapshot from production instance's data disk
+data "google_compute_snapshot" "latest_prod" {
+ count = local.use_overlay ? 1 : 0
+ project = var.project_id
+
+ # Filter to snapshots of the production data disk, get most recent
+ most_recent = true
+ filter = "sourceDisk eq ${local.prod_disk_url}"
+}
+
+# Restore production snapshot to a staging-specific disk for overlays
+resource "google_compute_disk" "overlay_disk" {
+ count = local.use_overlay ? 1 : 0
+ name = data.google_compute_snapshot.latest_prod[0].name
+ project = var.project_id
+ type = var.disk_type
+ zone = var.zone
+ snapshot = data.google_compute_snapshot.latest_prod[0].self_link
+ physical_block_size_bytes = 4096
+
+ lifecycle {
+ create_before_destroy = true
+ }
+}
+
+resource "google_compute_instance" "cloud-compose" {
+ name = var.name
+ project = var.project_id
+ machine_type = var.machine_type
+ zone = var.zone
+ allow_stopping_for_update = true
+ tags = ["cloud-compose", var.name]
+ can_ip_forward = "false"
+
+ boot_disk {
+ auto_delete = "true"
+ device_name = "boot"
+ source = google_compute_disk.boot.self_link
+ }
+ attached_disk {
+ device_name = "data"
+ source = google_compute_disk.data.self_link
+ }
+ attached_disk {
+ device_name = "docker-volumes"
+ source = google_compute_disk.docker-volumes.self_link
+ }
+
+ dynamic "attached_disk" {
+ for_each = local.use_overlay ? [1] : []
+ content {
+ device_name = "prod-volumes"
+ source = google_compute_disk.overlay_disk[0].self_link
+ # hyperdisk needs to be attached rw
+ # even though we're setting this as lowerdir read only
+ mode = "READ_WRITE"
+ }
+ }
+
+ metadata = {
+ google-logging-enabled = "true"
+ google-logging-use-fluentbit = "true"
+ google-monitoring-enabled = "true"
+ user-data = data.cloudinit_config.ci.part[0].content
+ }
+
+ network_interface {
+ network = local.network_name
+ subnetwork = local.subnetwork_name
+ access_config {}
+ }
+
+ reservation_affinity {
+ type = var.production ? "SPECIFIC_RESERVATION" : "ANY_RESERVATION"
+
+ dynamic "specific_reservation" {
+ for_each = var.production ? [google_compute_reservation.production[0].name] : []
+ content {
+ key = "compute.googleapis.com/reservation-name"
+ values = [specific_reservation.value]
+ }
+ }
+ }
+
+ scheduling {
+ automatic_restart = "true"
+ min_node_cpus = "0"
+ on_host_maintenance = "MIGRATE"
+ preemptible = "false"
+ provisioning_model = "STANDARD"
+ }
+
+ service_account {
+ email = local.vm_service_account_email
+ scopes = [
+ "https://www.googleapis.com/auth/cloud-platform"
+ ]
+ }
+
+ shielded_instance_config {
+ enable_integrity_monitoring = "true"
+ enable_secure_boot = "true"
+ enable_vtpm = "true"
+ }
+
+ lifecycle {
+ precondition {
+ condition = contains(keys(local.compose_projects), local.primary_compose_project_key)
+ error_message = "primary_compose_project must be one of the compose_projects keys."
+ }
+ precondition {
+ condition = !var.vault_agent_enabled || trimspace(var.vault_addr) != ""
+ error_message = "vault_addr is required when vault_agent_enabled is true."
+ }
+ precondition {
+ condition = !var.vault_agent_enabled || var.vault_auth_method != "gcp-iam" || trimspace(var.vault_role) != ""
+ error_message = "vault_role is required when vault_agent_enabled uses gcp-iam."
+ }
+ precondition {
+ condition = !var.vault_agent_enabled || var.vault_auth_method != "consumer-managed" || trimspace(var.vault_agent_additional_config) != ""
+ error_message = "vault_agent_additional_config is required when vault_agent_enabled uses consumer-managed auth."
+ }
+ precondition {
+ condition = alltrue([
+ for _, app in local.compose_projects : trimspace(app.docker_compose_repo) != ""
+ ])
+ error_message = "Each compose project must define docker_compose_repo. Set docker_compose_repo for legacy single-app deployments or pass compose_projects."
+ }
+ precondition {
+ condition = alltrue([
+ for _, app in local.compose_projects :
+ !(contains(["https-letsencrypt", "letsencrypt", "le"], app.ingress.mode) || app.ingress.letsencrypt) ||
+ trimspace(app.ingress.domain) != "" && trimspace(app.ingress.acme_email) != ""
+ ])
+ error_message = "Let's Encrypt ingress requires ingress.domain and ingress.acme_email for each enabled compose project."
+ }
+ precondition {
+ condition = (
+ startswith(var.machine_type, "e2") ?
+ contains(["pd-ssd", "pd-standard"], var.disk_type) :
+ true
+ )
+ error_message = "When using an 'e2' machine type, 'disk_type' must be 'pd-ssd' or 'pd-standard'."
+ }
+ precondition {
+ condition = !var.rollout_enabled || trimspace(var.rollout_release_url) != ""
+ error_message = "rollout_release_url is required when rollout_enabled is true."
+ }
+ precondition {
+ condition = !var.rollout_enabled || can(regex("^[0-9a-f]{64}$", trimspace(var.rollout_release_sha256)))
+ error_message = "rollout_release_sha256 must be a lowercase SHA256 hex digest when rollout_enabled is true."
+ }
+ precondition {
+ condition = !var.rollout_enabled || trimspace(var.rollout_jwks_uri) != ""
+ error_message = "rollout_jwks_uri is required when rollout_enabled is true."
+ }
+ precondition {
+ condition = !var.rollout_enabled || trimspace(var.rollout_jwt_audience) != ""
+ error_message = "rollout_jwt_audience is required when rollout_enabled is true."
+ }
+ }
+
+ depends_on = [google_compute_disk.overlay_disk]
+}
+
+# machine needs to be able to suspend itself
+data "google_project_iam_custom_role" "gce-suspend" {
+ project = var.project_id
+ role_id = "suspendVM"
+}
+
+
+# =============================================================================
+# LIBOPS ADMIN SERVICES IDENTITY
+# =============================================================================
+
+resource "google_service_account" "internal-services" {
+ account_id = format("internal-%s", var.name)
+ project = var.project_id
+}
+
+resource "google_service_account_iam_member" "internal-services-keys" {
+ service_account_id = google_service_account.internal-services.id
+ role = "roles/iam.serviceAccountKeyAdmin"
+ member = "serviceAccount:${local.vm_service_account_email}"
+}
+
+# push metrics to GCP
+resource "google_project_iam_member" "stackdriver" {
+ project = var.project_id
+ role = "roles/monitoring.metricWriter"
+ member = "serviceAccount:${google_service_account.internal-services.email}"
+}
+
+# suspend the GCP instance
+resource "google_project_iam_member" "gce-suspend" {
+ project = var.project_id
+ role = data.google_project_iam_custom_role.gce-suspend.name
+ member = "serviceAccount:${google_service_account.internal-services.email}"
+}
+
+# =============================================================================
+# DOCKER COMPOSE APP IDENTITY
+# =============================================================================
+
+resource "google_service_account" "app" {
+ count = var.app_service_account_email == "" ? 1 : 0
+ account_id = var.name
+ project = var.project_id
+}
+
+resource "google_service_account_iam_member" "app-keys" {
+ service_account_id = local.app_service_account_id
+ role = "roles/iam.serviceAccountKeyAdmin"
+ member = "serviceAccount:${local.vm_service_account_email}"
+}
+
+resource "google_service_account_iam_member" "self_jwt_signer_policy" {
+ service_account_id = local.app_service_account_id
+ role = "roles/iam.serviceAccountTokenCreator"
+ member = format("serviceAccount:%s", local.app_service_account_email)
+}
+
+# =============================================================================
+# CLOUD RUN INGRESS
+# =============================================================================
+
+locals {
+ base_config = yamldecode(
+ < 0 ? var.allowed_ssh_ipv4 : ["127.0.0.1/32"]
+}
+
+resource "google_compute_firewall" "allow_ssh_ipv6" {
+ project = var.project_id
+ name = format("allow-ssh-ipv6-%s", var.name)
+ network = local.network_name
+ priority = 10
+ direction = "INGRESS"
+
+ allow {
+ protocol = "tcp"
+ ports = ["22"]
+ }
+
+ target_tags = [var.name]
+
+ source_ranges = length(var.allowed_ssh_ipv6) > 0 ? var.allowed_ssh_ipv6 : ["127.0.0.1/32"]
+}
+
+resource "google_compute_firewall" "allow_rollout_ipv4" {
+ count = var.rollout_enabled ? 1 : 0
+ project = var.project_id
+ name = format("allow-rollout-ipv4-%s", var.name)
+ network = local.network_name
+ priority = 20
+ direction = "INGRESS"
+
+ allow {
+ protocol = "tcp"
+ ports = [tostring(var.rollout_port)]
+ }
+
+ target_tags = [var.name]
+ source_ranges = length(var.rollout_allowed_ipv4) > 0 ? var.rollout_allowed_ipv4 : ["127.0.0.1/32"]
+}
diff --git a/modules/gcp/outputs.tf b/modules/gcp/outputs.tf
new file mode 100644
index 0000000..67cedf0
--- /dev/null
+++ b/modules/gcp/outputs.tf
@@ -0,0 +1,79 @@
+output "instance" {
+ value = {
+ id : google_compute_instance.cloud-compose.instance_id,
+ name : google_compute_instance.cloud-compose.name,
+ disk : google_compute_disk.docker-volumes.name,
+ zone : google_compute_instance.cloud-compose.zone,
+ internal_ip : google_compute_instance.cloud-compose.network_interface[0].network_ip,
+ gsa : {
+ email : local.vm_service_account_email,
+ id : local.vm_service_account_id,
+ name : local.vm_service_account_name,
+ }
+ }
+ description = "The Google Compute instance ID, name, zone, data disk, GSA for the instance."
+}
+
+output "instance_id" {
+ value = google_compute_instance.cloud-compose.instance_id
+ description = "The Google Compute instance ID."
+}
+
+output "external_ip" {
+ value = google_compute_instance.cloud-compose.network_interface[0].access_config[0].nat_ip
+ description = "The Google Compute instance external IPv4 address."
+}
+
+output "internal_ip" {
+ value = google_compute_instance.cloud-compose.network_interface[0].network_ip
+ description = "The Google Compute instance internal IPv4 address."
+}
+
+output "serviceGsa" {
+ value = {
+ email : google_service_account.internal-services.email,
+ id : google_service_account.internal-services.id,
+ name : google_service_account.internal-services.name,
+ }
+ description = "The Google Service Account internal services that manage the VM runs as"
+}
+
+output "appGsa" {
+ value = {
+ email : local.app_service_account_email,
+ id : local.app_service_account_id,
+ name : local.app_service_account_name,
+ }
+ description = "The Google Service Account the app can leverage to auth to other Google services"
+}
+
+output "urls" {
+ value = var.power_management_enabled ? module.ppb[0].urls : {}
+ description = "Cloud Run ingress URLs by region."
+}
+
+output "backend" {
+ value = var.power_management_enabled ? module.ppb[0].backend : null
+ description = "Backend service ID for attaching the Cloud Run ingress to an external HTTPS load balancer."
+}
+
+output "rollout" {
+ value = {
+ enabled : var.rollout_enabled,
+ port : var.rollout_port,
+ url : var.rollout_enabled ? "http://${google_compute_instance.cloud-compose.network_interface[0].network_ip}:${var.rollout_port}" : null,
+ internal_url : var.rollout_enabled ? "http://${google_compute_instance.cloud-compose.network_interface[0].network_ip}:${var.rollout_port}" : null,
+ audience : var.rollout_jwt_audience,
+ }
+ description = "Optional rollout API endpoint details. The URL is the VPC-internal endpoint."
+}
+
+output "compose_projects" {
+ value = local.compose_projects
+ description = "Normalized compose project manifest."
+}
+
+output "primary_compose_project" {
+ value = local.primary_compose_project
+ description = "Normalized primary compose project."
+}
diff --git a/modules/gcp/variables.tf b/modules/gcp/variables.tf
new file mode 100644
index 0000000..0105c17
--- /dev/null
+++ b/modules/gcp/variables.tf
@@ -0,0 +1,605 @@
+variable "project_id" {
+ description = "The GCP project ID"
+ type = string
+}
+
+variable "project_number" {
+ type = string
+ description = "The GCP project number"
+}
+
+variable "region" {
+ description = "GCP region for resources"
+ type = string
+ default = "us-east5"
+}
+
+variable "zone" {
+ description = "GCP zone for resources"
+ type = string
+ default = "us-east5-b"
+}
+
+variable "name" {
+ type = string
+ description = "The site name (will be the name of the GCP instance)"
+}
+
+variable "service_account_email" {
+ description = "Existing service account email for the VM. When empty, this module creates one."
+ type = string
+ default = ""
+}
+
+variable "app_service_account_email" {
+ description = "Existing service account email for the compose app identity. When empty, this module creates one. On GCP this identity is used for app-scoped credentials such as Vault GCP IAM auth."
+ type = string
+ default = ""
+}
+
+variable "disk_type" {
+ type = string
+ description = "The disk type for disks attached to the machine"
+ default = "hyperdisk-balanced"
+ validation {
+ condition = contains([
+ "hyperdisk-balanced",
+ "pd-ssd",
+ "pd-standard",
+ ], var.disk_type)
+ error_message = "Invalid 'disk_type'"
+ }
+}
+
+variable "machine_type" {
+ type = string
+ default = "n4-standard-2"
+ description = "VM machine type (General-purpose series that support Hyperdisk Balanced"
+
+ validation {
+ condition = contains([
+ "e2-micro",
+ "e2-small",
+ "e2-medium",
+ "n4-standard-2",
+ "n4-standard-4",
+ "n4-standard-8",
+ "n4-standard-16",
+ "n4-standard-32",
+ "n4-standard-48",
+ "n4-standard-64",
+ "n4-standard-80",
+ "c4-standard-2",
+ "c4-standard-4",
+ "c4-standard-8",
+ "c4-standard-16",
+ "c4-standard-32",
+ "c4-standard-48",
+ "c4-standard-96",
+ ], var.machine_type)
+ error_message = "The 'machine_type' must be from a General-Purpose family that supports Hyperdisk Balanced (C4, or N4 series)"
+ }
+}
+
+variable "ingress_port" {
+ type = number
+ default = 80
+ description = "TCP port on the VM that the Cloud Run ingress should connect to."
+}
+
+variable "primary_compose_project" {
+ type = string
+ default = ""
+ description = "Key from compose_projects used as the default ingress and rollout target. Defaults to the first compose_projects key, or var.name for single-app deployments."
+}
+
+variable "sitectl_ingress" {
+ type = 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, "")
+ })
+ default = {}
+ description = "Default sitectl ingress component settings applied during init. Per-app compose_projects entries can override these values."
+}
+
+variable "disk_size_gb" {
+ type = number
+ default = 50
+ description = "Data disk size in GB"
+}
+
+variable "os" {
+ type = string
+ default = "cos-125-19216-220-185"
+ description = "The host OS to install on the GCP instance"
+}
+
+variable "docker_compose_repo" {
+ type = string
+ default = ""
+ description = "git repo to checkout that contains a docker compose project"
+}
+
+variable "compose_projects" {
+ description = <<-EOT
+ Compose apps to run on this VM. Leave empty to use the legacy single-app
+ docker_compose_* inputs. Each app gets its own sitectl context, git checkout,
+ compose project name, lifecycle scripts, and optional ingress port so multiple
+ compose projects can share one VM.
+ EOT
+ type = 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))
+ }))
+ default = {}
+
+ validation {
+ condition = alltrue([
+ for name, app in var.compose_projects :
+ can(regex("^[a-z][a-z0-9-]*$", name)) &&
+ trimspace(app.docker_compose_repo) != "" &&
+ try(app.ingress_port, 80) > 0 &&
+ try(app.ingress_port, 80) <= 65535
+ ])
+ error_message = "compose_projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535."
+ }
+}
+
+variable "docker_compose_branch" {
+ type = string
+ default = "main"
+ description = "git branch to checkout for var.docker_compose_repo"
+}
+
+variable "docker_compose_init" {
+ type = list(string)
+ default = [
+ "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"
+ ]
+ description = "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"
+}
+
+variable "docker_compose_up" {
+ type = list(string)
+ default = [
+ "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"
+ ]
+ description = "Command to start the docker compose project"
+}
+
+variable "docker_compose_down" {
+ type = list(string)
+ default = [
+ "sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" down"
+ ]
+ description = "Command to stop the docker compose project"
+}
+
+variable "docker_compose_rollout" {
+ type = list(string)
+ default = [
+ "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"
+ ]
+ description = "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."
+}
+
+variable "sitectl_packages" {
+ type = list(string)
+ default = ["sitectl"]
+ description = "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."
+
+ validation {
+ condition = alltrue([
+ for package in var.sitectl_packages :
+ can(regex("^sitectl(-[a-z0-9]+)*$", package))
+ ])
+ error_message = "sitectl_packages entries must be release package names such as sitectl, sitectl-isle, or sitectl-wp."
+ }
+}
+
+variable "sitectl_version" {
+ type = string
+ default = "latest"
+ description = "Sitectl release tag to install for sitectl packages, or latest to follow https://github.com/libops/sitectl/releases/latest."
+
+ validation {
+ condition = var.sitectl_version == "latest" || can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+", var.sitectl_version))
+ error_message = "sitectl_version must be latest or a release tag such as v0.19.7."
+ }
+}
+
+variable "docker_compose_version" {
+ type = string
+ # renovate: datasource=github-releases depName=docker-compose packageName=docker/compose versioning=semver
+ default = "v5.2.0"
+ description = "Docker Compose release tag installed as the docker compose CLI plugin on hosts that need a manually managed plugin."
+}
+
+variable "docker_buildx_version" {
+ type = string
+ # renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver
+ default = "v0.35.0"
+ description = "Docker Buildx release tag installed as the docker buildx CLI plugin on hosts that need a manually managed plugin."
+}
+
+variable "sitectl_context_name" {
+ type = string
+ default = ""
+ description = "Sitectl context name to create on the VM. Defaults to var.name."
+}
+
+variable "sitectl_plugin" {
+ type = string
+ default = "core"
+ description = "Sitectl plugin id to associate with the VM context."
+}
+
+variable "sitectl_environment" {
+ type = string
+ default = "production"
+ description = "Sitectl environment label. Production runs healthcheck only by default; non-production also runs sitectl verify."
+}
+
+variable "production" {
+ type = bool
+ default = false
+ description = "Whether this VM is the production environment. Production VMs reserve one matching machine so stop/start and recreate operations keep capacity."
+}
+
+variable "sitectl_healthcheck_timeout" {
+ type = string
+ default = "10m"
+ description = "Timeout passed to sitectl healthcheck --timeout in default lifecycle commands."
+}
+
+variable "sitectl_healthcheck_interval" {
+ type = string
+ default = "15s"
+ description = "Interval passed to sitectl healthcheck --interval in default lifecycle commands."
+}
+
+variable "sitectl_verify_args" {
+ type = list(string)
+ default = []
+ description = "Additional arguments appended to sitectl verify by the default non-production lifecycle commands."
+}
+
+variable "libops_managed_runtime_enabled" {
+ type = bool
+ default = true
+ description = "Install and periodically update LibOps-managed host tools and internal VM services."
+}
+
+variable "libops_internal_services_auto_update" {
+ type = bool
+ default = true
+ description = "Whether the managed runtime updater should pull and restart the internal LibOps compose project."
+}
+
+variable "libops_internal_services_enabled" {
+ type = bool
+ default = true
+ description = "Whether to start the internal LibOps services timer. GCP deployments usually keep this enabled; non-GCP modules disable it by default."
+}
+
+variable "power_management_enabled" {
+ type = bool
+ default = true
+ description = "Enable GCP power-management support services such as lightsout and Cloud Run proxy-power-button. Disable for providers that do not save cost when VMs are stopped."
+}
+
+variable "libops_lightsout_image" {
+ type = string
+ default = "ghcr.io/libops/lightsout:main"
+ description = "Container image used for the internal lightsout service."
+}
+
+variable "libops_cap_image" {
+ type = string
+ default = "ghcr.io/libops/cap:main"
+ description = "Container image used for the internal CAP metrics service."
+}
+
+variable "libops_cadvisor_image" {
+ type = string
+ default = "ghcr.io/google/cadvisor:v0.57.0@sha256:e75bdb03b74b0b6995f208f166fead2e6e555dde73e44200113bb26f41b1981d"
+ description = "Container image used for the internal cAdvisor service."
+}
+
+variable "libops_managed_artifacts" {
+ type = 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, "")
+ }))
+ default = []
+ description = "Additional LibOps-managed files or binaries to download, verify, install, and optionally restart with the managed runtime updater."
+
+ validation {
+ condition = alltrue([
+ for artifact in var.libops_managed_artifacts :
+ !can(regex("[\t\r\n]", artifact.name)) &&
+ !can(regex("[\t\r\n]", artifact.url)) &&
+ !can(regex("[\t\r\n]", artifact.sha256)) &&
+ !can(regex("[\t\r\n]", artifact.path)) &&
+ can(regex("^[0-9a-f]{64}$", artifact.sha256))
+ ])
+ error_message = "libops_managed_artifacts values must not contain tabs or newlines, and sha256 must be a lowercase SHA256 hex digest."
+ }
+}
+
+variable "allowed_ips" {
+ type = list(string)
+ default = []
+ description = "CIDR IP Addresses allowed to turn on this site's GCP instance"
+}
+
+variable "allowed_ssh_ipv4" {
+ type = list(string)
+ default = []
+ description = "CIDR IPv4 Addresses allowed to to SSH into this site's GCP instance"
+}
+
+variable "allowed_ssh_ipv6" {
+ type = list(string)
+ default = []
+ description = "CIDR IPv6 Addresses allowed to SSH into this site's GCP instance"
+}
+
+variable "network_name" {
+ type = string
+ default = ""
+ description = "Existing VPC network name or self link for the VM and firewall rules. When empty and create_network is true, this module creates a per-deployment network."
+}
+
+variable "subnetwork_name" {
+ type = string
+ default = ""
+ description = "Existing regional subnetwork name or self link for the VM. When empty and create_network is true, this module creates a per-deployment subnetwork."
+}
+
+variable "create_network" {
+ type = bool
+ default = true
+ description = "Create a per-deployment VPC network and regional subnetwork when network_name and subnetwork_name are empty."
+}
+
+variable "network_ip_cidr_range" {
+ type = string
+ default = "10.42.0.0/24"
+ description = "CIDR range used for the managed GCP subnetwork when create_network is true."
+
+ validation {
+ condition = can(cidrhost(var.network_ip_cidr_range, 1))
+ error_message = "network_ip_cidr_range must be a valid CIDR range."
+ }
+}
+
+variable "run_snapshots" {
+ type = bool
+ default = false
+ description = "Enable daily snapshots of the data disk (recommended for production). Last seven days of snapshots are available. Also weekly snapshots for past year."
+}
+
+variable "overlay_source_instance" {
+ type = string
+ default = ""
+ description = "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."
+}
+
+variable "volume_names" {
+ type = list(string)
+ default = []
+ description = "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."
+}
+
+variable "users" {
+ type = map(list(string))
+ default = {}
+ description = "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...\"] }"
+}
+
+variable "rootfs" {
+ type = string
+ default = ""
+ description = "Path to additional rootfs files to copy into the VM. Files will be merged with the base rootfs. Example: '/path/to/custom/rootfs'"
+}
+
+variable "runcmd" {
+ type = list(string)
+ default = []
+ description = "Additional commands to run during cloud-init. Commands are executed after the main initialization."
+}
+
+variable "initcmd" {
+ type = list(string)
+ default = []
+ description = "Commands to run before /home/cloud-compose/run.sh"
+}
+
+variable "artifact_registry_repository" {
+ type = string
+ default = ""
+ description = "Optional Artifact Registry repository name to grant the VM service account reader access to. Leave empty to skip creating the IAM binding."
+}
+
+variable "artifact_registry_location" {
+ type = string
+ default = "us"
+ description = "Artifact Registry location for var.artifact_registry_repository."
+}
+
+variable "frontend" {
+ description = <<-EOT
+ Optional frontend container to deploy as a sidecar next to ppb. When set,
+ ppb continues to power on and ping the VM referenced by machineMetadata,
+ but proxies incoming requests to this container on localhost instead of
+ to the VM. Use this to serve a frontend from Cloud Run while keeping
+ backend services on the VM.
+ EOT
+ type = object({
+ image = string
+ port = optional(number, 8080)
+ cpu = optional(string, "1000m")
+ memory = optional(string, "1Gi")
+ })
+ default = null
+}
+
+variable "rollout_enabled" {
+ description = "Install and run the optional generic rollout HTTP service on the VM."
+ type = bool
+ default = false
+}
+
+variable "rollout_release_url" {
+ description = "HTTPS URL for the pinned rollout Linux binary."
+ type = string
+ default = ""
+}
+
+variable "rollout_release_sha256" {
+ description = "Lowercase SHA256 checksum for var.rollout_release_url."
+ type = string
+ default = ""
+ validation {
+ condition = var.rollout_release_sha256 == "" || can(regex("^[0-9a-f]{64}$", var.rollout_release_sha256))
+ error_message = "rollout_release_sha256 must be empty or a lowercase SHA256 hex digest."
+ }
+}
+
+variable "rollout_port" {
+ description = "TCP port exposed by the optional rollout service."
+ type = number
+ default = 8081
+ validation {
+ condition = var.rollout_port > 0 && var.rollout_port <= 65535
+ error_message = "rollout_port must be between 1 and 65535."
+ }
+}
+
+variable "rollout_jwks_uri" {
+ description = "JWKS URI used by the rollout service to validate bearer JWTs."
+ type = string
+ default = ""
+}
+
+variable "rollout_jwt_audience" {
+ description = "JWT audience required by the rollout service."
+ type = string
+ default = ""
+}
+
+variable "rollout_custom_claims" {
+ description = "Optional JSON object of additional JWT claims required by the rollout service."
+ type = string
+ default = ""
+}
+
+variable "rollout_allowed_ipv4" {
+ description = "CIDR IPv4 ranges allowed to reach the rollout service port."
+ type = list(string)
+ default = ["10.0.0.0/8"]
+}
+
+variable "vault_addr" {
+ description = "Vault address exposed to the VM and app contexts. Empty disables the default Vault Agent contract."
+ type = string
+ default = ""
+}
+
+variable "vault_namespace" {
+ description = "Optional Vault namespace for Enterprise Vault deployments."
+ type = string
+ default = ""
+}
+
+variable "vault_role" {
+ description = "Vault auth role for the app workload identity."
+ type = string
+ default = ""
+}
+
+variable "vault_agent_enabled" {
+ description = "Write Vault Agent configuration and start vault-agent.service when Vault is configured."
+ type = bool
+ default = false
+}
+
+variable "vault_auth_method" {
+ description = "Vault Agent auth method contract. gcp-iam uses the app GSA identity. consumer-managed writes env/config scaffolding only."
+ type = string
+ default = "gcp-iam"
+
+ validation {
+ condition = contains(["gcp-iam", "consumer-managed"], var.vault_auth_method)
+ error_message = "vault_auth_method must be gcp-iam or consumer-managed."
+ }
+}
+
+variable "vault_gcp_auth_mount_path" {
+ description = "Vault GCP auth mount path used by Vault Agent for gcp-iam auth."
+ type = string
+ default = "auth/gcp"
+}
+
+variable "vault_agent_token_path" {
+ description = "Path where Vault Agent writes its auto-auth token sink."
+ type = string
+ default = "/mnt/disks/data/vault/token"
+}
+
+variable "vault_agent_templates" {
+ description = "Vault Agent template stanzas. Contents should use Vault template syntax and should not contain secret values in Terraform."
+ type = list(object({
+ destination = string
+ contents = string
+ perms = optional(string, "0640")
+ command = optional(string, "")
+ }))
+ default = []
+}
+
+variable "vault_agent_additional_config" {
+ description = "Additional Vault Agent HCL appended to the generated config. Use for consumer-managed auth on non-GCP providers."
+ type = string
+ default = ""
+}
diff --git a/modules/linode/main.tf b/modules/linode/main.tf
new file mode 100644
index 0000000..ef9592d
--- /dev/null
+++ b/modules/linode/main.tf
@@ -0,0 +1,167 @@
+locals {
+ linode = var.linode
+ runtime = var.runtime
+ compose = var.runtime.compose
+ sitectl = var.runtime.sitectl
+ docker = var.runtime.docker
+ managed = var.runtime.managed_runtime
+ vault = var.runtime.vault
+ label_prefix = trimsuffix(substr(var.name, 0, 20), "-")
+ label_hash = substr(sha1(var.name), 0, 6)
+ data_volume_label = "${local.label_prefix}-${local.label_hash}-data"
+ docker_volumes_volume_label = "${local.label_prefix}-${local.label_hash}-dock"
+ firewall_label = "${local.label_prefix}-${local.label_hash}-fw"
+}
+
+module "runtime" {
+ source = "../linux-vm-runtime"
+
+ name = var.name
+ provider_name = "linode"
+ region = local.linode.region
+ data_device = "/dev/disk/by-id/scsi-0Linode_Volume_${local.data_volume_label}"
+ volumes_device = "/dev/disk/by-id/scsi-0Linode_Volume_${local.docker_volumes_volume_label}"
+ ssh_users = local.linode.ssh.users
+ cloud_compose_ssh_keys = local.linode.ssh.cloud_compose_keys
+ rootfs = local.runtime.rootfs
+ rootfs_archive_url = local.runtime.rootfs_archive_url
+ rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256
+ ingress_port = local.compose.ingress_port
+ primary_compose_project = local.compose.primary
+ sitectl_ingress = local.compose.ingress
+ docker_compose_repo = local.compose.repo
+ docker_compose_branch = local.compose.branch
+ compose_projects = local.compose.projects
+ docker_compose_init = local.compose.init
+ docker_compose_up = local.compose.up
+ docker_compose_down = local.compose.down
+ docker_compose_rollout = local.compose.rollout
+
+ sitectl_packages = local.sitectl.packages
+ sitectl_version = local.sitectl.version
+ sitectl_context_name = local.sitectl.context_name
+ sitectl_plugin = local.sitectl.plugin
+ sitectl_environment = local.sitectl.environment
+ sitectl_healthcheck_timeout = local.sitectl.healthcheck_timeout
+ sitectl_healthcheck_interval = local.sitectl.healthcheck_interval
+ sitectl_verify_args = local.sitectl.verify_args
+ docker_compose_version = local.docker.compose_version
+ docker_buildx_version = local.docker.buildx_version
+
+ libops_managed_runtime_enabled = local.managed.enabled
+ libops_internal_services_enabled = local.managed.internal_services_enabled
+ libops_internal_services_auto_update = local.managed.internal_services_auto_update
+ libops_managed_artifacts = local.managed.artifacts
+
+ vault_addr = local.vault.addr
+ vault_namespace = local.vault.namespace
+ vault_role = local.vault.role
+ vault_agent_enabled = local.vault.agent_enabled
+ vault_auth_method = local.vault.auth_method
+ vault_agent_token_path = local.vault.agent_token_path
+ vault_agent_templates = local.vault.agent_templates
+ vault_agent_additional_config = local.vault.agent_additional_config
+ extra_env = local.runtime.extra_env
+}
+
+locals {
+ ingress_ports = distinct([for _, app in module.runtime.compose_projects : tostring(app.ingress_port)])
+}
+
+resource "linode_instance" "cloud_compose" {
+ label = var.name
+ region = local.linode.region
+ type = local.linode.instance.type
+ image = local.linode.instance.image
+ authorized_keys = local.linode.instance.authorized_keys
+ authorized_users = local.linode.instance.authorized_users
+ root_pass = local.linode.instance.root_pass
+ private_ip = local.linode.instance.private_ip
+ backups_enabled = local.linode.instance.backups_enabled
+ watchdog_enabled = local.linode.instance.watchdog_enabled
+ tags = local.linode.tags
+
+ metadata {
+ user_data = base64gzip(module.runtime.cloud_init)
+ }
+
+ lifecycle {
+ precondition {
+ condition = length(local.linode.instance.authorized_keys) > 0 || length(local.linode.instance.authorized_users) > 0 || local.linode.instance.root_pass != null
+ error_message = "Linode image deployments require authorized_keys, authorized_users, or root_pass."
+ }
+ precondition {
+ condition = alltrue([
+ for _, app in module.runtime.compose_projects : trimspace(app.docker_compose_repo) != ""
+ ])
+ error_message = "Each compose project must define docker_compose_repo."
+ }
+ precondition {
+ condition = alltrue([
+ for _, app in module.runtime.compose_projects :
+ !(contains(["https-letsencrypt", "letsencrypt", "le"], app.ingress.mode) || app.ingress.letsencrypt) ||
+ trimspace(app.ingress.domain) != "" && trimspace(app.ingress.acme_email) != ""
+ ])
+ error_message = "Let's Encrypt ingress requires ingress.domain and ingress.acme_email for each enabled compose project."
+ }
+ precondition {
+ condition = length(base64gzip(module.runtime.cloud_init)) <= 16384
+ error_message = "Linode metadata.user_data is limited to 16 KiB. Set runtime.rootfs_archive_url so cloud-compose rootfs is fetched during boot instead of embedded in cloud-init."
+ }
+ precondition {
+ condition = !local.vault.agent_enabled || trimspace(local.vault.addr) != ""
+ error_message = "vault_addr is required when vault_agent_enabled is true."
+ }
+ precondition {
+ condition = !local.vault.agent_enabled || local.vault.auth_method != "consumer-managed" || trimspace(local.vault.agent_additional_config) != ""
+ error_message = "vault_agent_additional_config is required when vault_agent_enabled uses consumer-managed auth."
+ }
+ }
+}
+
+resource "linode_volume" "data" {
+ label = local.data_volume_label
+ region = local.linode.region
+ size = local.linode.volumes.data_size_gb
+ linode_id = linode_instance.cloud_compose.id
+ tags = local.linode.tags
+}
+
+resource "linode_volume" "docker_volumes" {
+ label = local.docker_volumes_volume_label
+ region = local.linode.region
+ size = local.linode.volumes.docker_volumes_size_gb
+ linode_id = linode_instance.cloud_compose.id
+ tags = local.linode.tags
+}
+
+resource "linode_firewall" "cloud_compose" {
+ count = local.linode.firewall.enabled ? 1 : 0
+ label = local.firewall_label
+
+ inbound {
+ label = "ssh"
+ action = "ACCEPT"
+ protocol = "TCP"
+ ports = "22"
+ ipv4 = local.linode.firewall.ssh_source_ipv4
+ ipv6 = local.linode.firewall.ssh_source_ipv6
+ }
+
+ dynamic "inbound" {
+ for_each = toset(local.ingress_ports)
+ content {
+ label = "app-${inbound.value}"
+ action = "ACCEPT"
+ protocol = "TCP"
+ ports = inbound.value
+ ipv4 = local.linode.firewall.web_source_ipv4
+ ipv6 = local.linode.firewall.web_source_ipv6
+ }
+ }
+
+ inbound_policy = "DROP"
+ outbound_policy = "ACCEPT"
+ linodes = [linode_instance.cloud_compose.id]
+ tags = local.linode.tags
+}
diff --git a/modules/linode/outputs.tf b/modules/linode/outputs.tf
new file mode 100644
index 0000000..05f097b
--- /dev/null
+++ b/modules/linode/outputs.tf
@@ -0,0 +1,38 @@
+output "instance" {
+ value = {
+ id = linode_instance.cloud_compose.id
+ label = linode_instance.cloud_compose.label
+ region = linode_instance.cloud_compose.region
+ public_ipv4 = one(setsubtract(linode_instance.cloud_compose.ipv4, [linode_instance.cloud_compose.private_ip_address]))
+ ipv4 = linode_instance.cloud_compose.ipv4
+ private_ip = linode_instance.cloud_compose.private_ip_address
+ ipv6 = linode_instance.cloud_compose.ipv6
+ }
+ description = "Linode instance details."
+}
+
+output "volumes" {
+ value = {
+ data = {
+ id = linode_volume.data.id
+ label = linode_volume.data.label
+ filesystem_path = linode_volume.data.filesystem_path
+ }
+ docker_volumes = {
+ id = linode_volume.docker_volumes.id
+ label = linode_volume.docker_volumes.label
+ filesystem_path = linode_volume.docker_volumes.filesystem_path
+ }
+ }
+ description = "Persistent Linode volumes."
+}
+
+output "compose_projects" {
+ value = module.runtime.compose_projects
+ description = "Normalized compose project manifest."
+}
+
+output "primary_compose_project" {
+ value = module.runtime.primary_compose_project
+ description = "Normalized primary compose project."
+}
diff --git a/modules/linode/variables.tf b/modules/linode/variables.tf
new file mode 100644
index 0000000..efdd15d
--- /dev/null
+++ b/modules/linode/variables.tf
@@ -0,0 +1,183 @@
+variable "name" {
+ type = string
+ description = "Deployment name. Used for the Linode, volumes, firewall, and default compose project."
+
+ validation {
+ condition = can(regex("^[a-z][a-z0-9-]{0,45}$", var.name))
+ error_message = "name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens."
+ }
+}
+
+variable "linode" {
+ description = "Linode infrastructure settings."
+ type = object({
+ region = optional(string, "us-east")
+ tags = optional(list(string), ["cloud-compose"])
+
+ instance = optional(object({
+ type = optional(string, "g6-standard-2")
+ image = optional(string, "linode/ubuntu22.04")
+ authorized_keys = optional(list(string), [])
+ authorized_users = optional(list(string), [])
+ root_pass = optional(string, null)
+ private_ip = optional(bool, true)
+ backups_enabled = optional(bool, false)
+ watchdog_enabled = optional(bool, true)
+ }), {})
+
+ ssh = optional(object({
+ cloud_compose_keys = optional(list(string), [])
+ users = optional(map(list(string)), {})
+ }), {})
+
+ volumes = optional(object({
+ data_size_gb = optional(number, 50)
+ docker_volumes_size_gb = optional(number, 100)
+ }), {})
+
+ firewall = optional(object({
+ enabled = optional(bool, true)
+ ssh_source_ipv4 = optional(list(string), ["0.0.0.0/0"])
+ ssh_source_ipv6 = optional(list(string), ["::/0"])
+ web_source_ipv4 = optional(list(string), ["0.0.0.0/0"])
+ web_source_ipv6 = optional(list(string), ["::/0"])
+ }), {})
+ })
+ default = {}
+}
+
+variable "runtime" {
+ description = "Provider-neutral compose/runtime settings."
+ type = object({
+ rootfs = optional(string, "")
+ rootfs_archive_url = optional(string, "")
+ rootfs_archive_sha256 = optional(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), [
+ "sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --skip-git",
+ "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\""
+ ])
+ }), {})
+
+ 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, false)
+ internal_services_auto_update = optional(bool, false)
+ artifacts = optional(list(object({
+ name = string
+ url = string
+ sha256 = string
+ path = string
+ mode = optional(string, "0755")
+ owner = optional(string, "root")
+ group = optional(string, "root")
+ restart = optional(string, "")
+ })), [])
+ }), {})
+
+ vault = optional(object({
+ addr = optional(string, "")
+ namespace = optional(string, "")
+ role = optional(string, "")
+ agent_enabled = optional(bool, false)
+ auth_method = optional(string, "consumer-managed")
+ 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), {})
+ })
+ default = {}
+
+ validation {
+ condition = alltrue([
+ for name, app in var.runtime.compose.projects :
+ can(regex("^[a-z][a-z0-9-]*$", name)) &&
+ trimspace(app.docker_compose_repo) != "" &&
+ try(app.ingress_port, var.runtime.compose.ingress_port) > 0 &&
+ try(app.ingress_port, var.runtime.compose.ingress_port) <= 65535
+ ])
+ error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535."
+ }
+
+ validation {
+ condition = contains(["gcp-iam", "consumer-managed"], var.runtime.vault.auth_method)
+ error_message = "runtime.vault.auth_method must be gcp-iam or consumer-managed."
+ }
+}
diff --git a/modules/linode/versions.tf b/modules/linode/versions.tf
new file mode 100644
index 0000000..77ed60a
--- /dev/null
+++ b/modules/linode/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.2.4"
+
+ required_providers {
+ linode = {
+ source = "linode/linode"
+ version = "~> 4.0"
+ }
+ }
+}
diff --git a/modules/linux-vm-runtime/main.tf b/modules/linux-vm-runtime/main.tf
new file mode 100644
index 0000000..eb59f43
--- /dev/null
+++ b/modules/linux-vm-runtime/main.tf
@@ -0,0 +1,297 @@
+locals {
+ rootfs = "${path.module}/../../rootfs"
+ additional_rootfs = var.rootfs != "" ? var.rootfs : ""
+ rootfs_archive_url = trimspace(var.rootfs_archive_url)
+ rootfs_archive_sha256 = trimspace(var.rootfs_archive_sha256)
+
+ single_compose_project = {
+ (var.name) = {
+ docker_compose_repo = var.docker_compose_repo
+ docker_compose_branch = var.docker_compose_branch
+ ingress_port = var.ingress_port
+ ingress = var.sitectl_ingress
+ sitectl_context_name = trimspace(var.sitectl_context_name) != "" ? trimspace(var.sitectl_context_name) : var.name
+ sitectl_plugin = var.sitectl_plugin
+ sitectl_environment = var.sitectl_environment
+ sitectl_packages = var.sitectl_packages
+ sitectl_verify_args = var.sitectl_verify_args
+ docker_compose_init = var.docker_compose_init
+ docker_compose_up = var.docker_compose_up
+ docker_compose_down = var.docker_compose_down
+ docker_compose_rollout = var.docker_compose_rollout
+ }
+ }
+ raw_compose_projects = length(var.compose_projects) > 0 ? var.compose_projects : local.single_compose_project
+ primary_compose_project_key = trimspace(var.primary_compose_project) != "" ? trimspace(var.primary_compose_project) : keys(local.raw_compose_projects)[0]
+ compose_projects = {
+ for app_name, app in local.raw_compose_projects : app_name => {
+ name = app_name
+ docker_compose_repo = trimspace(app.docker_compose_repo)
+ docker_compose_branch = trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))
+ repo_path = trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/")
+ project_dir = (
+ trimspace(try(app.project_dir, "")) != ""
+ ? trimspace(try(app.project_dir, ""))
+ : "/mnt/disks/data/${trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/")}/${trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))}"
+ )
+ compose_project_name = (
+ trimspace(try(app.compose_project_name, "")) != ""
+ ? trimspace(try(app.compose_project_name, ""))
+ : replace(lower(replace(replace(format("%s-%s", trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/"), trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))), ".git", ""), "/[^a-zA-Z0-9]/", "-")), "/-+/", "-")
+ )
+ ingress_port = coalesce(try(app.ingress_port, null), var.ingress_port)
+ ingress = {
+ letsencrypt = coalesce(try(app.ingress.letsencrypt, null), var.sitectl_ingress.letsencrypt)
+ bot_mitigation = coalesce(try(app.ingress.bot_mitigation, null), var.sitectl_ingress.bot_mitigation)
+ mode = try(app.ingress.mode, null) != null ? trimspace(app.ingress.mode) : trimspace(var.sitectl_ingress.mode)
+ domain = try(app.ingress.domain, null) != null ? trimspace(app.ingress.domain) : trimspace(var.sitectl_ingress.domain)
+ acme_email = try(app.ingress.acme_email, null) != null ? trimspace(app.ingress.acme_email) : trimspace(var.sitectl_ingress.acme_email)
+ trusted_ips = try(app.ingress.trusted_ips, null) != null ? app.ingress.trusted_ips : var.sitectl_ingress.trusted_ips
+ max_upload_size = try(app.ingress.max_upload_size, null) != null ? trimspace(app.ingress.max_upload_size) : trimspace(var.sitectl_ingress.max_upload_size)
+ upload_timeout = try(app.ingress.upload_timeout, null) != null ? trimspace(app.ingress.upload_timeout) : trimspace(var.sitectl_ingress.upload_timeout)
+ }
+ sitectl_context_name = (
+ trimspace(try(app.sitectl_context_name, "")) != ""
+ ? trimspace(try(app.sitectl_context_name, ""))
+ : app_name
+ )
+ sitectl_plugin = trimspace(coalesce(try(app.sitectl_plugin, null), var.sitectl_plugin))
+ sitectl_environment = trimspace(coalesce(try(app.sitectl_environment, null), var.sitectl_environment))
+ sitectl_packages = distinct(concat(["sitectl"], try(app.sitectl_packages, [])))
+ sitectl_verify_args = try(app.sitectl_verify_args, var.sitectl_verify_args)
+ init_commands = try(app.docker_compose_init, null) != null ? app.docker_compose_init : var.docker_compose_init
+ up_commands = try(app.docker_compose_up, null) != null ? app.docker_compose_up : var.docker_compose_up
+ down_commands = try(app.docker_compose_down, null) != null ? app.docker_compose_down : var.docker_compose_down
+ rollout_commands = try(app.docker_compose_rollout, null) != null ? app.docker_compose_rollout : var.docker_compose_rollout
+ }
+ }
+ primary_compose_project = local.compose_projects[local.primary_compose_project_key]
+ sitectl_packages = distinct(concat(
+ ["sitectl"],
+ var.sitectl_packages,
+ flatten([for _, app in local.compose_projects : app.sitectl_packages])
+ ))
+
+ base_files = local.rootfs_archive_url == "" ? fileset(local.rootfs, "**") : []
+ additional_files = local.additional_rootfs != "" ? fileset(local.additional_rootfs, "**") : []
+ all_files = merge(
+ { for file in local.base_files : file => "${local.rootfs}/${file}" },
+ { for file in local.additional_files : file => "${local.additional_rootfs}/${file}" }
+ )
+
+ write_files_content = join("\n", [
+ for file, fullpath in local.all_files : <<-EOT
+ - path: ${jsonencode("/${file}")}
+ permissions: ${jsonencode(endswith(file, ".sh") ? "0755" : "0644")}
+ encoding: gzip+base64
+ content: ${jsonencode(base64gzip(file(fullpath)))}
+EOT
+ ])
+
+ docker_compose_scripts = join("\n", [
+ for name in ["init", "up", "down", "rollout"] : <<-EOT
+ - path: "/home/cloud-compose/${name}"
+ permissions: "0755"
+ encoding: gzip+base64
+ content: ${jsonencode(base64gzip(<<-EOS
+ #!/usr/bin/env bash
+
+ set -eou pipefail
+
+ source /home/cloud-compose/profile.sh
+ exec bash /home/cloud-compose/compose-dispatch.sh "${name}"
+ EOS
+))}
+EOT
+])
+
+compose_projects_content = jsonencode(local.compose_projects)
+compose_projects_file = <<-EOT
+ - path: "/home/cloud-compose/compose-projects.json"
+ permissions: "0640"
+ encoding: gzip+base64
+ content: ${jsonencode(base64gzip(local.compose_projects_content))}
+EOT
+
+managed_runtime_artifact_lines = [
+ for artifact in var.libops_managed_artifacts : join("\t", [
+ artifact.name,
+ artifact.url,
+ artifact.sha256,
+ artifact.path,
+ try(artifact.mode, "0755"),
+ try(artifact.owner, "root"),
+ try(artifact.group, "root"),
+ try(artifact.restart, ""),
+ ])
+]
+managed_runtime_artifacts_content = join("\n", local.managed_runtime_artifact_lines)
+managed_runtime_artifacts_file = <<-EOT
+ - path: "/home/cloud-compose/managed-runtime-artifacts.tsv"
+ permissions: "0640"
+ encoding: gzip+base64
+ content: ${jsonencode(base64gzip(local.managed_runtime_artifacts_content))}
+EOT
+
+vault_agent_template_stanzas = join("\n", [
+ for template in var.vault_agent_templates : <<-EOT
+ template {
+ destination = ${jsonencode(template.destination)}
+ contents = </dev/null 2>&1; then
+ if command -v apt-get >/dev/null 2>&1; then
+ export DEBIAN_FRONTEND=noninteractive
+ apt-get update
+ apt-get install -y ca-certificates curl tar
+ elif command -v dnf >/dev/null 2>&1; then
+ dnf install -y ca-certificates curl tar
+ elif command -v rpm-ostree >/dev/null 2>&1; then
+ rpm-ostree install --apply-live ca-certificates curl tar
+ else
+ echo "No supported package manager found to install curl" >&2
+ exit 1
+ fi
+ fi
+ tmp="$(mktemp -d)"
+ trap 'rm -rf "$tmp"' EXIT
+ curl -fsSL "$archive_url" -o "$tmp/rootfs.tar.gz"
+ if [ -n "$archive_sha256" ]; then
+ printf '%s %s\n' "$archive_sha256" "$tmp/rootfs.tar.gz" | sha256sum -c -
+ fi
+ tar -xzf "$tmp/rootfs.tar.gz" -C "$tmp"
+ rootfs_dir="$(find "$tmp" -mindepth 2 -maxdepth 2 -type d -name rootfs | head -n 1)"
+ if [ -z "$rootfs_dir" ]; then
+ echo "rootfs directory not found in $archive_url" >&2
+ exit 1
+ fi
+ cp -a "$rootfs_dir"/. /
+ EOT
+rootfs_archive_command = local.rootfs_archive_url != "" ? local.rootfs_archive_command_raw : ""
+
+cloud_init = templatefile("${path.module}/templates/cloud-init.yml", {
+ CLOUD_COMPOSE_SSH_KEYS = var.cloud_compose_ssh_keys
+ SSH_USERS = var.ssh_users
+ DATA_DEVICE = var.data_device
+ VOLUMES_DEVICE = var.volumes_device
+ WRITE_FILES_CONTENT = local.write_files_content
+ DOCKER_COMPOSE_SCRIPTS = local.docker_compose_scripts
+ COMPOSE_PROJECTS_FILE = local.compose_projects_file
+ ENV_FILE_CONTENT = local.env_file_content
+ VAULT_AGENT_FILES = local.vault_agent_files
+ MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file
+ ROOTFS_ARCHIVE_COMMAND = local.rootfs_archive_command
+})
+}
diff --git a/modules/linux-vm-runtime/outputs.tf b/modules/linux-vm-runtime/outputs.tf
new file mode 100644
index 0000000..c1358b8
--- /dev/null
+++ b/modules/linux-vm-runtime/outputs.tf
@@ -0,0 +1,24 @@
+output "cloud_init" {
+ value = local.cloud_init
+ description = "Rendered cloud-init user data."
+}
+
+output "compose_projects" {
+ value = local.compose_projects
+ description = "Normalized compose project manifest."
+}
+
+output "primary_compose_project" {
+ value = local.primary_compose_project
+ description = "Normalized primary compose project."
+}
+
+output "primary_compose_project_key" {
+ value = local.primary_compose_project_key
+ description = "Primary compose project key."
+}
+
+output "sitectl_packages" {
+ value = local.sitectl_packages
+ description = "Normalized sitectl package list."
+}
diff --git a/modules/linux-vm-runtime/templates/cloud-init.yml b/modules/linux-vm-runtime/templates/cloud-init.yml
new file mode 100644
index 0000000..80daaa0
--- /dev/null
+++ b/modules/linux-vm-runtime/templates/cloud-init.yml
@@ -0,0 +1,91 @@
+#cloud-config
+
+groups:
+- docker
+
+users:
+- name: cloud-compose
+ shell: /bin/bash
+ groups:
+ - docker
+ sudo:
+ - ALL=(ALL) NOPASSWD:ALL
+%{ if length(CLOUD_COMPOSE_SSH_KEYS) > 0 ~}
+ ssh_authorized_keys:
+%{ for key in CLOUD_COMPOSE_SSH_KEYS ~}
+ - ${key}
+%{ endfor ~}
+%{ endif ~}
+%{ for username, ssh_keys in SSH_USERS ~}
+- name: ${username}
+ shell: /bin/bash
+ ssh_authorized_keys:
+%{ for key in ssh_keys ~}
+ - ${key}
+%{ endfor ~}
+%{ endfor ~}
+
+write_files:
+${WRITE_FILES_CONTENT}
+${DOCKER_COMPOSE_SCRIPTS}
+${COMPOSE_PROJECTS_FILE}
+${ENV_FILE_CONTENT}
+${VAULT_AGENT_FILES}
+${MANAGED_RUNTIME_ARTIFACTS_FILE}
+
+runcmd:
+- |
+ set -eu
+ ensure_cloud_compose_user() {
+ if ! getent group docker >/dev/null 2>&1; then
+ groupadd docker
+ fi
+ if ! id -u cloud-compose >/dev/null 2>&1; then
+ useradd --create-home --shell /bin/bash --groups docker cloud-compose
+ else
+ usermod --append --groups docker cloud-compose
+ fi
+ install -d -m 0700 -o cloud-compose -g cloud-compose /home/cloud-compose/.ssh
+ cat >/home/cloud-compose/.ssh/authorized_keys <<'EOF'
+%{ for key in CLOUD_COMPOSE_SSH_KEYS ~}
+ ${key}
+%{ endfor ~}
+ EOF
+ chown cloud-compose:cloud-compose /home/cloud-compose/.ssh/authorized_keys
+ chmod 0600 /home/cloud-compose/.ssh/authorized_keys
+ install -d -m 0755 /etc/sudoers.d
+ printf 'cloud-compose ALL=(ALL) NOPASSWD:ALL\n' >/etc/sudoers.d/90-cloud-compose
+ chmod 0440 /etc/sudoers.d/90-cloud-compose
+ }
+
+ mount_cloud_compose_disk() {
+ device_path="$1"
+ mount_path="$2"
+ device="$(readlink -f "$device_path" 2>/dev/null || true)"
+ until [ -n "$device" ] && [ -b "$device" ]; do
+ echo "Waiting for $device_path"
+ sleep 2
+ device="$(readlink -f "$device_path" 2>/dev/null || true)"
+ done
+ mkdir -p "$mount_path"
+ if ! blkid "$device" >/dev/null 2>&1; then
+ mkfs.ext4 -m 0 -E lazy_itable_init=1,lazy_journal_init=1,nodiscard "$device"
+ fi
+ if ! mountpoint -q "$mount_path"; then
+ mount -o defaults,nofail "$device" "$mount_path"
+ fi
+ chmod a+w "$mount_path"
+ }
+
+ ensure_cloud_compose_user
+ mount_cloud_compose_disk '${DATA_DEVICE}' /mnt/disks/data
+ mount_cloud_compose_disk '${VOLUMES_DEVICE}' /mnt/disks/volumes
+ mkdir -p /mnt/disks/data/docker/volumes
+ if ! mountpoint -q /mnt/disks/data/docker/volumes; then
+ mount --bind /mnt/disks/volumes /mnt/disks/data/docker/volumes
+ fi
+${ROOTFS_ARCHIVE_COMMAND}
+ chown cloud-compose:cloud-compose /mnt/disks/data /mnt/disks/volumes
+ chmod 0775 /mnt/disks/data /mnt/disks/volumes
+ install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops
+- bash /home/cloud-compose/run.sh > /home/cloud-compose/run.log 2>&1
diff --git a/modules/linux-vm-runtime/variables.tf b/modules/linux-vm-runtime/variables.tf
new file mode 100644
index 0000000..16092b2
--- /dev/null
+++ b/modules/linux-vm-runtime/variables.tf
@@ -0,0 +1,341 @@
+variable "name" {
+ type = string
+ description = "Deployment name used for the VM and default compose project."
+}
+
+variable "provider_name" {
+ type = string
+ description = "Cloud provider identifier written to CLOUD_COMPOSE_PROVIDER."
+}
+
+variable "region" {
+ type = string
+ description = "Provider region."
+}
+
+variable "zone" {
+ type = string
+ default = ""
+ description = "Provider zone or region-local placement label."
+}
+
+variable "data_device" {
+ type = string
+ description = "Stable device path for the persistent data disk."
+}
+
+variable "volumes_device" {
+ type = string
+ description = "Stable device path for the persistent Docker volumes disk."
+}
+
+variable "ssh_users" {
+ type = map(list(string))
+ default = {}
+ description = "Additional Linux users and authorized SSH keys to create through cloud-init."
+}
+
+variable "cloud_compose_ssh_keys" {
+ type = list(string)
+ default = []
+ description = "Authorized SSH keys for the cloud-compose Linux user."
+}
+
+variable "rootfs" {
+ type = string
+ default = ""
+ description = "Optional rootfs overlay. Files here override the packaged cloud-compose rootfs by relative path."
+}
+
+variable "rootfs_archive_url" {
+ type = string
+ default = ""
+ description = "Optional tar.gz URL containing a rootfs directory to fetch during boot instead of embedding the packaged rootfs in cloud-init."
+}
+
+variable "rootfs_archive_sha256" {
+ type = string
+ default = ""
+ description = "Optional SHA-256 checksum for rootfs_archive_url."
+}
+
+variable "ingress_port" {
+ type = number
+ default = 80
+ description = "Default TCP port exposed by a compose project on the VM."
+}
+
+variable "primary_compose_project" {
+ type = string
+ default = ""
+ description = "Key from compose_projects used as the default target. Defaults to the first compose_projects key, or var.name for single-app deployments."
+}
+
+variable "sitectl_ingress" {
+ type = 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, "")
+ })
+ default = {}
+ description = "Default sitectl ingress component settings applied during init. Per-app compose_projects entries can override these values."
+}
+
+variable "docker_compose_repo" {
+ type = string
+ default = ""
+ description = "Legacy single-app git repo containing a Docker Compose project."
+}
+
+variable "docker_compose_branch" {
+ type = string
+ default = "main"
+ description = "Default git branch for compose repositories."
+}
+
+variable "compose_projects" {
+ description = "Compose apps to run on this VM."
+ type = 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))
+ }))
+ default = {}
+
+ validation {
+ condition = alltrue([
+ for name, app in var.compose_projects :
+ can(regex("^[a-z][a-z0-9-]*$", name)) &&
+ trimspace(app.docker_compose_repo) != "" &&
+ try(app.ingress_port, 80) > 0 &&
+ try(app.ingress_port, 80) <= 65535
+ ])
+ error_message = "compose_projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535."
+ }
+}
+
+variable "docker_compose_init" {
+ type = list(string)
+ default = [
+ "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"
+ ]
+ description = "Commands run after a compose repository is cloned."
+}
+
+variable "docker_compose_up" {
+ type = list(string)
+ default = [
+ "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"
+ ]
+ description = "Commands used to bring a compose project up."
+}
+
+variable "docker_compose_down" {
+ type = list(string)
+ default = [
+ "sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" down"
+ ]
+ description = "Commands used to stop a compose project."
+}
+
+variable "docker_compose_rollout" {
+ type = list(string)
+ default = [
+ "sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --skip-git",
+ "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist --timeout \"$${SITECTL_HEALTHCHECK_TIMEOUT}\" --interval \"$${SITECTL_HEALTHCHECK_INTERVAL}\""
+ ]
+ description = "Commands used by rollout triggers."
+}
+
+variable "sitectl_packages" {
+ type = list(string)
+ default = ["sitectl"]
+ description = "sitectl release packages to install."
+}
+
+variable "sitectl_version" {
+ type = string
+ default = "latest"
+ description = "sitectl release tag to install, or latest."
+}
+
+variable "sitectl_context_name" {
+ type = string
+ default = ""
+ description = "Legacy single-app sitectl context name. Defaults to var.name."
+}
+
+variable "sitectl_plugin" {
+ type = string
+ default = "core"
+ description = "Default sitectl plugin id."
+}
+
+variable "sitectl_environment" {
+ type = string
+ default = "production"
+ description = "Default sitectl environment label."
+}
+
+variable "sitectl_healthcheck_timeout" {
+ type = string
+ default = "10m"
+ description = "Timeout passed to sitectl healthcheck."
+}
+
+variable "sitectl_healthcheck_interval" {
+ type = string
+ default = "15s"
+ description = "Interval passed to sitectl healthcheck."
+}
+
+variable "sitectl_verify_args" {
+ type = list(string)
+ default = []
+ description = "Additional arguments appended to sitectl verify."
+}
+
+variable "docker_compose_version" {
+ type = string
+ # renovate: datasource=github-releases depName=docker-compose packageName=docker/compose versioning=semver
+ default = "v5.2.0"
+ description = "Docker Compose release tag installed as the Docker CLI plugin."
+}
+
+variable "docker_buildx_version" {
+ type = string
+ # renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver
+ default = "v0.35.0"
+ description = "Docker Buildx release tag installed as the Docker CLI plugin."
+}
+
+variable "libops_managed_runtime_enabled" {
+ type = bool
+ default = true
+ description = "Install and periodically update sitectl packages and managed artifacts."
+}
+
+variable "libops_internal_services_enabled" {
+ type = bool
+ default = false
+ description = "Start internal LibOps metrics and power services."
+}
+
+variable "libops_internal_services_auto_update" {
+ type = bool
+ default = false
+ description = "Allow the managed runtime to update internal LibOps services."
+}
+
+variable "libops_managed_artifacts" {
+ type = 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, "")
+ }))
+ default = []
+ description = "Extra host artifacts installed by the managed runtime."
+}
+
+variable "vault_addr" {
+ type = string
+ default = ""
+ description = "Vault server address."
+}
+
+variable "vault_namespace" {
+ type = string
+ default = ""
+ description = "Vault namespace."
+}
+
+variable "vault_role" {
+ type = string
+ default = ""
+ description = "Vault auth role."
+}
+
+variable "vault_agent_enabled" {
+ type = bool
+ default = false
+ description = "Write Vault Agent configuration and start vault-agent.service when Vault is configured."
+}
+
+variable "vault_auth_method" {
+ type = string
+ default = "consumer-managed"
+ description = "Vault Agent auth method contract. Non-GCP providers default to consumer-managed."
+
+ validation {
+ condition = contains(["gcp-iam", "consumer-managed"], var.vault_auth_method)
+ error_message = "vault_auth_method must be gcp-iam or consumer-managed."
+ }
+}
+
+variable "vault_gcp_auth_mount_path" {
+ type = string
+ default = "auth/gcp"
+ description = "Vault GCP auth mount path used by Vault Agent for gcp-iam auth."
+}
+
+variable "vault_agent_token_path" {
+ type = string
+ default = "/mnt/disks/data/vault/token"
+ description = "Path where Vault Agent writes its token sink."
+}
+
+variable "vault_agent_templates" {
+ type = list(object({
+ destination = string
+ contents = string
+ perms = optional(string, "0640")
+ command = optional(string, "")
+ }))
+ default = []
+ description = "Vault Agent template stanzas."
+}
+
+variable "vault_agent_additional_config" {
+ type = string
+ default = ""
+ description = "Additional Vault Agent HCL appended to the generated config."
+}
+
+variable "extra_env" {
+ type = map(string)
+ default = {}
+ description = "Additional shell environment variables written to /home/cloud-compose/.env."
+}
diff --git a/moved.tf b/moved.tf
new file mode 100644
index 0000000..3b118b5
--- /dev/null
+++ b/moved.tf
@@ -0,0 +1,144 @@
+moved {
+ from = time_static.snapshot_time_static
+ to = module.gcp.time_static.snapshot_time_static
+}
+
+moved {
+ from = google_service_account.cloud-compose
+ to = module.gcp.google_service_account.cloud-compose
+}
+
+moved {
+ from = google_artifact_registry_repository_iam_member.private-policy-cloud-compose
+ to = module.gcp.google_artifact_registry_repository_iam_member.private-policy-cloud-compose
+}
+
+moved {
+ from = google_service_account_iam_member.gsa-user
+ to = module.gcp.google_service_account_iam_member.gsa-user
+}
+
+moved {
+ from = google_service_account_iam_member.token-creator
+ to = module.gcp.google_service_account_iam_member.token-creator
+}
+
+moved {
+ from = google_project_iam_member.log
+ to = module.gcp.google_project_iam_member.log
+}
+
+moved {
+ from = google_compute_disk.boot
+ to = module.gcp.google_compute_disk.boot
+}
+
+moved {
+ from = google_compute_disk.data
+ to = module.gcp.google_compute_disk.data
+}
+
+moved {
+ from = google_compute_disk.docker-volumes
+ to = module.gcp.google_compute_disk.docker-volumes
+}
+
+moved {
+ from = google_compute_reservation.production
+ to = module.gcp.google_compute_reservation.production
+}
+
+moved {
+ from = google_compute_resource_policy.daily_snapshot
+ to = module.gcp.google_compute_resource_policy.daily_snapshot
+}
+
+moved {
+ from = google_compute_resource_policy.weekly_snapshot
+ to = module.gcp.google_compute_resource_policy.weekly_snapshot
+}
+
+moved {
+ from = google_compute_disk_resource_policy_attachment.daily_snapshot
+ to = module.gcp.google_compute_disk_resource_policy_attachment.daily_snapshot
+}
+
+moved {
+ from = google_compute_disk_resource_policy_attachment.weekly_snapshot
+ to = module.gcp.google_compute_disk_resource_policy_attachment.weekly_snapshot
+}
+
+moved {
+ from = google_compute_disk.overlay_disk
+ to = module.gcp.google_compute_disk.overlay_disk
+}
+
+moved {
+ from = google_compute_instance.cloud-compose
+ to = module.gcp.google_compute_instance.cloud-compose
+}
+
+moved {
+ from = google_service_account.internal-services
+ to = module.gcp.google_service_account.internal-services
+}
+
+moved {
+ from = google_service_account_iam_member.internal-services-keys
+ to = module.gcp.google_service_account_iam_member.internal-services-keys
+}
+
+moved {
+ from = google_project_iam_member.stackdriver
+ to = module.gcp.google_project_iam_member.stackdriver
+}
+
+moved {
+ from = google_project_iam_member.gce-suspend
+ to = module.gcp.google_project_iam_member.gce-suspend
+}
+
+moved {
+ from = google_service_account.app
+ to = module.gcp.google_service_account.app
+}
+
+moved {
+ from = google_service_account_iam_member.app-keys
+ to = module.gcp.google_service_account_iam_member.app-keys
+}
+
+moved {
+ from = google_service_account_iam_member.self_jwt_signer_policy
+ to = module.gcp.google_service_account_iam_member.self_jwt_signer_policy
+}
+
+moved {
+ from = google_service_account.ppb
+ to = module.gcp.google_service_account.ppb
+}
+
+moved {
+ from = module.ppb
+ to = module.gcp.module.ppb
+}
+
+moved {
+ from = google_project_iam_member.gce-start
+ to = module.gcp.google_project_iam_member.gce-start
+}
+
+moved {
+ from = google_compute_firewall.allow_ssh_ipv4
+ to = module.gcp.google_compute_firewall.allow_ssh_ipv4
+}
+
+moved {
+ from = google_compute_firewall.allow_ssh_ipv6
+ to = module.gcp.google_compute_firewall.allow_ssh_ipv6
+}
+
+moved {
+ from = google_compute_firewall.allow_rollout_ipv4
+ to = module.gcp.google_compute_firewall.allow_rollout_ipv4
+}
diff --git a/outputs.tf b/outputs.tf
index 97a6e36..5ea4e55 100644
--- a/outputs.tf
+++ b/outputs.tf
@@ -1,69 +1,103 @@
+output "cloud_provider" {
+ value = local.cloud_provider
+ description = "Selected cloud provider."
+}
+
+output "template" {
+ value = local.template_name
+ description = "Selected compose template preset."
+}
+
output "instance" {
- value = {
- id : google_compute_instance.cloud-compose.instance_id,
- name : google_compute_instance.cloud-compose.name,
- disk : google_compute_disk.docker-volumes.name,
- zone : google_compute_instance.cloud-compose.zone,
- internal_ip : google_compute_instance.cloud-compose.network_interface[0].network_ip,
- gsa : {
- email : local.vm_service_account_email,
- id : local.vm_service_account_id,
- name : local.vm_service_account_name,
- }
- }
- description = "The Google Compute instance ID, name, zone, data disk, GSA for the instance."
+ value = try(
+ module.gcp[0].instance,
+ module.digitalocean[0].instance,
+ module.linode[0].instance,
+ null,
+ )
+ description = "Selected provider VM instance details."
}
output "instance_id" {
- value = google_compute_instance.cloud-compose.instance_id
- description = "The Google Compute instance ID."
+ value = try(
+ module.gcp[0].instance_id,
+ module.digitalocean[0].instance.id,
+ module.linode[0].instance.id,
+ null,
+ )
+ description = "Selected provider VM instance ID."
}
output "external_ip" {
- value = google_compute_instance.cloud-compose.network_interface[0].access_config[0].nat_ip
- description = "The Google Compute instance external IPv4 address."
+ value = try(
+ module.gcp[0].external_ip,
+ module.digitalocean[0].instance.ipv4,
+ module.linode[0].instance.public_ipv4,
+ null,
+ )
+ description = "Selected provider VM public IPv4 address."
}
output "internal_ip" {
- value = google_compute_instance.cloud-compose.network_interface[0].network_ip
- description = "The Google Compute instance internal IPv4 address."
+ value = try(
+ module.gcp[0].internal_ip,
+ module.digitalocean[0].instance.private_ip,
+ module.linode[0].instance.private_ip,
+ null,
+ )
+ description = "Selected provider VM private IPv4 address."
+}
+
+output "volumes" {
+ value = try(
+ module.digitalocean[0].volumes,
+ module.linode[0].volumes,
+ null,
+ )
+ description = "Selected provider persistent volume details where available."
}
output "serviceGsa" {
- value = {
- email : google_service_account.internal-services.email,
- id : google_service_account.internal-services.id,
- name : google_service_account.internal-services.name,
- }
- description = "The Google Service Account internal services that manage the VM runs as"
+ value = try(module.gcp[0].serviceGsa, null)
+ description = "The Google Service Account internal services run as."
}
output "appGsa" {
- value = {
- email : google_service_account.app.email,
- id : google_service_account.app.id,
- name : google_service_account.app.name,
- }
- description = "The Google Service Account the app can leverage to auth to other Google services"
+ value = try(module.gcp[0].appGsa, null)
+ description = "The Google Service Account the app can use for app-scoped auth."
}
output "urls" {
- value = module.ppb.urls
+ value = try(module.gcp[0].urls, {})
description = "Cloud Run ingress URLs by region."
}
output "backend" {
- value = module.ppb.backend
- description = "Backend service ID for attaching the Cloud Run ingress to an external HTTPS load balancer."
+ value = try(module.gcp[0].backend, null)
+ description = "Backend service ID for attaching Cloud Run ingress to an external HTTPS load balancer."
}
output "rollout" {
- value = {
- enabled : var.rollout_enabled,
- port : var.rollout_port,
- url : var.rollout_enabled ? "http://${google_compute_instance.cloud-compose.network_interface[0].network_ip}:${var.rollout_port}" : null,
- internal_url : var.rollout_enabled ? "http://${google_compute_instance.cloud-compose.network_interface[0].network_ip}:${var.rollout_port}" : null,
- audience : var.rollout_jwt_audience,
- }
- description = "Optional rollout API endpoint details. The URL is the VPC-internal endpoint."
+ value = try(module.gcp[0].rollout, null)
+ description = "Optional rollout API endpoint details."
+}
+
+output "compose_projects" {
+ value = try(
+ module.gcp[0].compose_projects,
+ module.digitalocean[0].compose_projects,
+ module.linode[0].compose_projects,
+ {},
+ )
+ description = "Normalized compose project manifest."
+}
+
+output "primary_compose_project" {
+ value = try(
+ module.gcp[0].primary_compose_project,
+ module.digitalocean[0].primary_compose_project,
+ module.linode[0].primary_compose_project,
+ null,
+ )
+ description = "Normalized primary compose project."
}
diff --git a/renovate.json5 b/renovate.json5
index c73c70e..f1ca847 100644
--- a/renovate.json5
+++ b/renovate.json5
@@ -3,4 +3,29 @@
extends: [
'github>libops/renovate-config:default.json5',
],
+ customManagers: [
+ {
+ customType: 'regex',
+ description: 'Update Docker Compose and Buildx Terraform defaults',
+ managerFilePatterns: [
+ '/(^|/)variables\\.tf$/',
+ ],
+ matchStrings: [
+ '# renovate: datasource=(?[^\\s]+) depName=(?[^\\s]+)(?: packageName=(?[^\\s]+))?(?: versioning=(?[^\\s]+))?\\s+\\w+_version\\s*=\\s*optional\\(string,\\s*"(?[^"]+)"\\)',
+ '# renovate: datasource=(?[^\\s]+) depName=(?[^\\s]+)(?: packageName=(?[^\\s]+))?(?: versioning=(?[^\\s]+))?\\s+default\\s*=\\s*"(?[^"]+)"',
+ ],
+ versioningTemplate: '{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}',
+ },
+ {
+ customType: 'regex',
+ description: 'Update Docker Compose and Buildx shell fallback defaults',
+ managerFilePatterns: [
+ '/(^|/)install-docker-plugins\\.sh$/',
+ ],
+ matchStrings: [
+ '# renovate: datasource=(?[^\\s]+) depName=(?[^\\s]+)(?: packageName=(?[^\\s]+))?(?: versioning=(?[^\\s]+))?\\s+[A-Z_]+="\\$\\{[A-Z_]+:-(?[^}]+)\\}"',
+ ],
+ versioningTemplate: '{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}',
+ },
+ ],
}
diff --git a/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service b/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service
new file mode 100644
index 0000000..1337d52
--- /dev/null
+++ b/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=Cloud Compose MariaDB Backup
+After=cloud-compose.service
+Wants=cloud-compose.service
+
+[Service]
+Type=oneshot
+User=cloud-compose
+Group=cloud-compose
+EnvironmentFile=/home/cloud-compose/.env
+ExecStart=/bin/bash /home/cloud-compose/mariadb-backup.sh
diff --git a/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.timer b/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.timer
new file mode 100644
index 0000000..ef56532
--- /dev/null
+++ b/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.timer
@@ -0,0 +1,14 @@
+[Unit]
+Description=Run Cloud Compose MariaDB backups nightly
+
+[Timer]
+# 9pm-7am EST, splayed per deployment by systemd's stable random delay.
+OnCalendar=*-*-* 02:00:00
+Persistent=true
+RandomizedDelaySec=10h
+FixedRandomDelay=true
+AccuracySec=1min
+Unit=cloud-compose-mariadb-backup.service
+
+[Install]
+WantedBy=timers.target
diff --git a/rootfs/etc/systemd/system/cloud-compose.service b/rootfs/etc/systemd/system/cloud-compose.service
index d461ffd..ddeecd7 100644
--- a/rootfs/etc/systemd/system/cloud-compose.service
+++ b/rootfs/etc/systemd/system/cloud-compose.service
@@ -4,13 +4,13 @@ PartOf=docker.service
After=docker.service
[Service]
+Type=oneshot
+RemainAfterExit=yes
User=cloud-compose
Group=cloud-compose
EnvironmentFile=/home/cloud-compose/.env
-ExecStart=/mnt/disks/data/up
-ExecStop=/mnt/disks/data/down
-Restart=on-failure
-RestartSec=30s
+ExecStart=/bin/bash /home/cloud-compose/up
+ExecStop=/bin/bash /home/cloud-compose/down
[Install]
WantedBy=multi-user.target
diff --git a/rootfs/etc/systemd/system/vault-agent.service b/rootfs/etc/systemd/system/vault-agent.service
new file mode 100644
index 0000000..e61268d
--- /dev/null
+++ b/rootfs/etc/systemd/system/vault-agent.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Cloud Compose Vault Agent
+After=network-online.target
+Wants=network-online.target
+ConditionPathExists=/etc/vault-agent.d/cloud-compose.hcl
+
+[Service]
+User=root
+Group=root
+EnvironmentFile=-/etc/default/vault-agent
+ExecStart=/usr/local/bin/vault agent -config=/etc/vault-agent.d/cloud-compose.hcl
+Restart=on-failure
+RestartSec=10s
+
+[Install]
+WantedBy=multi-user.target
diff --git a/rootfs/home/cloud-compose/app-init.sh b/rootfs/home/cloud-compose/app-init.sh
index 6b07650..8e1d9e2 100644
--- a/rootfs/home/cloud-compose/app-init.sh
+++ b/rootfs/home/cloud-compose/app-init.sh
@@ -6,22 +6,23 @@ set -eou pipefail
source /home/cloud-compose/profile.sh
export HOME
-git config --global --add safe.directory "$DOCKER_COMPOSE_DIR"
+# shellcheck disable=SC1091
+source /home/cloud-compose/compose-apps.sh
+
+while read -r app; do
+ if [ -z "$app" ]; then
+ continue
+ fi
-if [ ! -d "$DOCKER_COMPOSE_DIR" ]; then
- echo "Directory '$DOCKER_COMPOSE_DIR' not found. Cloning repository."
- mkdir -p "$DOCKER_COMPOSE_DIR"
- pushd "$DOCKER_COMPOSE_DIR"
- retry_until_success git clone -b "$DOCKER_COMPOSE_BRANCH" "$DOCKER_COMPOSE_REPO" .
- chown -R cloud-compose:cloud-compose .
-else
- pushd "$DOCKER_COMPOSE_DIR"
- retry_until_success git pull origin "$DOCKER_COMPOSE_BRANCH"
-fi
+ clone_or_update_compose_app "$app"
+ source_compose_app_env "$app"
-# set COMPOSE_PROJECT_NAME from value set in cloud-compose
-# sourced from /home/cloud-compose/profile.sh which loads /home/cloud-compose/.env
-update_env COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME"
-update_env SITE_NAME "$GCP_INSTANCE_NAME"
-retry_until_success /mnt/disks/data/init
-popd
+ pushd "$DOCKER_COMPOSE_DIR" >/dev/null
+ scaffold_compose_app_defaults "$app"
+ update_env COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME"
+ update_env SITE_NAME "${CLOUD_COMPOSE_INSTANCE_NAME:-${GCP_INSTANCE_NAME:-$app}}"
+ update_env COMPOSE_BIND_PORT "$COMPOSE_BIND_PORT"
+ run_compose_app_lifecycle "$app" init
+ configure_sitectl_app_features "$app"
+ popd >/dev/null
+done < <(compose_app_names)
diff --git a/rootfs/home/cloud-compose/app-rollout.sh b/rootfs/home/cloud-compose/app-rollout.sh
index 64437bd..4025243 100644
--- a/rootfs/home/cloud-compose/app-rollout.sh
+++ b/rootfs/home/cloud-compose/app-rollout.sh
@@ -5,36 +5,4 @@ set -eou pipefail
# shellcheck disable=SC1091
source /home/cloud-compose/profile.sh
-pushd "$DOCKER_COMPOSE_DIR"
-
-retry_until_success git pull origin "$DOCKER_COMPOSE_BRANCH"
-retry_until_success docker info
-
-# Pull all images and check if any were updated
-shopt -s nullglob
-RESTART=0
-while read -r IMAGE; do
- # Skip empty lines and comments
- if [ -z "$IMAGE" ] || [[ "$IMAGE" =~ ^# ]]; then
- continue
- fi
-
- current_image_id=$(docker images --format "{{.ID}}" "$IMAGE" || echo "")
- retry_until_success docker pull "$IMAGE"
- new_image_id=$(docker images --format "{{.ID}}" "$IMAGE")
-
- if [ "$current_image_id" != "$new_image_id" ]; then
- RESTART=1
- fi
-done < <(grep "image:" ./*.{yml,yaml} 2>/dev/null| awk -F': ' '{print $2}')
-shopt -u nullglob
-
-if [ "$RESTART" -eq 1 ]; then
- SERVICE=$(grep -sl "WorkingDirectory=$DIR" /etc/systemd/system/*.service | xargs basename)
- if [ -n "$SERVICE" ]; then
- echo "Restarting $SERVICE"
- systemctl restart "$SERVICE"
- fi
-fi
-
-popd
+exec bash /home/cloud-compose/compose-dispatch.sh rollout
diff --git a/rootfs/home/cloud-compose/compose-apps.sh b/rootfs/home/cloud-compose/compose-apps.sh
new file mode 100644
index 0000000..f3d9b4f
--- /dev/null
+++ b/rootfs/home/cloud-compose/compose-apps.sh
@@ -0,0 +1,420 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+COMPOSE_PROJECTS_FILE="${COMPOSE_PROJECTS_FILE:-/home/cloud-compose/compose-projects.json}"
+COMPOSE_APPS_ENV_DIR="${COMPOSE_APPS_ENV_DIR:-/home/cloud-compose/apps}"
+
+shell_env_line() {
+ local name="$1"
+ local value="$2"
+
+ printf '%s=%q\n' "$name" "$value"
+}
+
+compose_app_names() {
+ jq -r 'keys[]' "$COMPOSE_PROJECTS_FILE"
+}
+
+compose_app_field() {
+ local app="$1"
+ local field="$2"
+
+ jq -r --arg app "$app" --arg field "$field" '.[$app][$field] // ""' "$COMPOSE_PROJECTS_FILE"
+}
+
+compose_app_array() {
+ local app="$1"
+ local field="$2"
+
+ jq -r --arg app "$app" --arg field "$field" '.[$app][$field][]?' "$COMPOSE_PROJECTS_FILE"
+}
+
+compose_app_verify_args() {
+ local app="$1"
+
+ jq -r --arg app "$app" '.[$app].sitectl_verify_args // [] | join(" ")' "$COMPOSE_PROJECTS_FILE"
+}
+
+compose_app_ingress_field() {
+ local app="$1"
+ local field="$2"
+
+ jq -r --arg app "$app" --arg field "$field" '.[$app].ingress[$field] // ""' "$COMPOSE_PROJECTS_FILE"
+}
+
+compose_app_ingress_array() {
+ local app="$1"
+ local field="$2"
+
+ jq -r --arg app "$app" --arg field "$field" '.[$app].ingress[$field] // [] | .[]?' "$COMPOSE_PROJECTS_FILE"
+}
+
+sitectl_truthy() {
+ local value
+ value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
+
+ case "$value" in
+ 1 | true | yes | y | on | enabled)
+ return 0
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+random_chars() {
+ local chars="$1"
+ local length="$2"
+ local value=""
+
+ while [ "${#value}" -lt "$length" ]; do
+ value="${value}$(openssl rand -base64 "$((length * 2))" | tr -dc "$chars")"
+ done
+
+ printf '%s' "${value:0:length}"
+}
+
+random_secret() {
+ local length="$1"
+
+ random_chars 'A-Za-z0-9' "$length"
+}
+
+scaffold_secret_file() {
+ local file="$1"
+ local mode="${2:-0640}"
+
+ if [ -s "$file" ]; then
+ return 0
+ fi
+
+ install -d -m 0700 "$(dirname "$file")"
+ echo "Creating scaffold secret: ${file}" >&2
+ random_secret 32 > "$file"
+ chmod "$mode" "$file"
+}
+
+scaffold_drupal_salt() {
+ local file="$1"
+
+ if [ -s "$file" ]; then
+ return 0
+ fi
+
+ install -d -m 0700 "$(dirname "$file")"
+ echo "Creating scaffold secret: ${file}" >&2
+ random_chars 'A-Za-z0-9_-' 74 > "$file"
+ chmod 0640 "$file"
+}
+
+scaffold_jwt_keys() {
+ local private_key="${1:-./secrets/JWT_PRIVATE_KEY}"
+ local public_key="${2:-./secrets/JWT_PUBLIC_KEY}"
+
+ install -d -m 0700 "$(dirname "$private_key")"
+ if [ ! -s "$private_key" ]; then
+ echo "Creating scaffold secret: ${private_key}" >&2
+ openssl genrsa 2048 > "$private_key" 2>/dev/null
+ chmod 0640 "$private_key"
+ fi
+ if [ ! -s "$public_key" ]; then
+ echo "Creating scaffold secret: ${public_key}" >&2
+ openssl rsa -pubout < "$private_key" > "$public_key" 2>/dev/null
+ chmod 0644 "$public_key"
+ fi
+}
+
+scaffold_local_certs() {
+ local cert_dir="${1:-./certs}"
+ local openssl_config
+
+ if [ -s "${cert_dir}/cert.pem" ] && [ -s "${cert_dir}/rootCA.pem" ]; then
+ return 0
+ fi
+
+ install -d -m 0755 "$cert_dir"
+ openssl_config="$(mktemp)"
+ cat > "$openssl_config" <<'EOF'
+[req]
+distinguished_name = req_distinguished_name
+prompt = no
+
+[req_distinguished_name]
+CN = localhost
+
+[v3_req]
+subjectAltName = @alt_names
+
+[alt_names]
+DNS.1 = localhost
+DNS.2 = *.localhost
+DNS.3 = islandora.io
+DNS.4 = *.islandora.io
+DNS.5 = islandora.info
+DNS.6 = *.islandora.info
+IP.1 = 127.0.0.1
+IP.2 = ::1
+EOF
+
+ if [ ! -s "${cert_dir}/rootCA-key.pem" ] || [ ! -s "${cert_dir}/rootCA.pem" ]; then
+ echo "Creating scaffold certificate authority in ${cert_dir}" >&2
+ openssl req -x509 -newkey rsa:2048 -nodes \
+ -keyout "${cert_dir}/rootCA-key.pem" \
+ -out "${cert_dir}/rootCA.pem" \
+ -subj "/CN=cloud-compose local root" \
+ -days 3650 >/dev/null 2>&1
+ fi
+
+ if [ ! -s "${cert_dir}/privkey.pem" ] || [ ! -s "${cert_dir}/cert.pem" ]; then
+ echo "Creating scaffold certificate in ${cert_dir}" >&2
+ openssl req -newkey rsa:2048 -nodes \
+ -keyout "${cert_dir}/privkey.pem" \
+ -out "${cert_dir}/cert.csr" \
+ -config "$openssl_config" >/dev/null 2>&1
+ openssl x509 -req \
+ -in "${cert_dir}/cert.csr" \
+ -CA "${cert_dir}/rootCA.pem" \
+ -CAkey "${cert_dir}/rootCA-key.pem" \
+ -CAcreateserial \
+ -out "${cert_dir}/cert.pem" \
+ -days 825 \
+ -sha256 \
+ -extensions v3_req \
+ -extfile "$openssl_config" >/dev/null 2>&1
+ rm -f "${cert_dir}/cert.csr"
+ fi
+
+ rm -f "$openssl_config"
+ chmod 0644 "${cert_dir}/cert.pem" "${cert_dir}/rootCA.pem"
+ chmod 0640 "${cert_dir}/privkey.pem" "${cert_dir}/rootCA-key.pem" 2>/dev/null || true
+}
+
+compose_secret_files() {
+ local compose_file
+
+ for compose_file in docker-compose.yaml docker-compose.yml; do
+ if [ ! -f "$compose_file" ]; then
+ continue
+ fi
+
+ awk '
+ /^[[:space:]]*services:/ { in_secrets = 0 }
+ /^[^[:space:]][^:]*:/ {
+ if ($0 ~ /^secrets:/) {
+ in_secrets = 1
+ } else if (in_secrets) {
+ in_secrets = 0
+ }
+ }
+ in_secrets && /^[[:space:]]*file:[[:space:]]*/ {
+ value = $0
+ sub(/^[[:space:]]*file:[[:space:]]*/, "", value)
+ gsub(/^["'\'']|["'\'']$/, "", value)
+ print value
+ }
+ ' "$compose_file"
+ done | sort -u
+}
+
+scaffold_compose_app_defaults() {
+ local app="$1"
+ local file base generated=false
+
+ echo "Scaffolding default compose files for ${app}"
+ while IFS= read -r file; do
+ if [ -z "$file" ]; then
+ continue
+ fi
+
+ generated=true
+ base="$(basename "$file")"
+ case "$file" in
+ ./*) ;;
+ *) file="./${file}" ;;
+ esac
+
+ case "$base" in
+ cert.pem | rootCA.pem | privkey.pem | rootCA-key.pem)
+ scaffold_local_certs "$(dirname "$file")"
+ ;;
+ UID)
+ install -d -m 0755 "$(dirname "$file")"
+ id -u > "$file"
+ chmod 0644 "$file"
+ ;;
+ DRUPAL_DEFAULT_SALT)
+ scaffold_drupal_salt "$file"
+ ;;
+ JWT_PRIVATE_KEY)
+ scaffold_jwt_keys "$file" "./secrets/JWT_PUBLIC_KEY"
+ ;;
+ JWT_PUBLIC_KEY)
+ scaffold_jwt_keys "./secrets/JWT_PRIVATE_KEY" "$file"
+ ;;
+ *)
+ scaffold_secret_file "$file"
+ ;;
+ esac
+ done < <(compose_secret_files)
+
+ if [ "$generated" = false ]; then
+ echo "No compose secret files declared for ${app}"
+ fi
+}
+
+write_compose_app_env() {
+ local app="$1"
+ local env_file="${COMPOSE_APPS_ENV_DIR}/${app}.env"
+
+ mkdir -p "$COMPOSE_APPS_ENV_DIR"
+ {
+ shell_env_line APP_NAME "$app"
+ shell_env_line DOCKER_COMPOSE_REPO "$(compose_app_field "$app" docker_compose_repo)"
+ shell_env_line DOCKER_COMPOSE_BRANCH "$(compose_app_field "$app" docker_compose_branch)"
+ shell_env_line DOCKER_COMPOSE_DIR "$(compose_app_field "$app" project_dir)"
+ shell_env_line COMPOSE_PROJECT_NAME "$(compose_app_field "$app" compose_project_name)"
+ shell_env_line COMPOSE_BIND_PORT "$(compose_app_field "$app" ingress_port)"
+ shell_env_line SITECTL_CONTEXT_NAME "$(compose_app_field "$app" sitectl_context_name)"
+ shell_env_line SITECTL_PLUGIN "$(compose_app_field "$app" sitectl_plugin)"
+ shell_env_line SITECTL_ENVIRONMENT "$(compose_app_field "$app" sitectl_environment)"
+ shell_env_line SITECTL_VERIFY_ARGS "$(compose_app_verify_args "$app")"
+ } > "$env_file"
+ chown cloud-compose:cloud-compose "$env_file" 2>/dev/null || true
+ chmod 0640 "$env_file"
+}
+
+source_compose_app_env() {
+ local app="$1"
+
+ write_compose_app_env "$app"
+ set -a
+ # shellcheck disable=SC1090
+ source "${COMPOSE_APPS_ENV_DIR}/${app}.env"
+ set +a
+}
+
+target_compose_apps() {
+ local lifecycle="$1"
+ local target="${CLOUD_COMPOSE_APP:-${COMPOSE_APP:-${APP_NAME:-}}}"
+
+ if [ -z "$target" ] && [ "$lifecycle" = "rollout" ]; then
+ target="${CLOUD_COMPOSE_PRIMARY_APP:-}"
+ fi
+
+ if [ -n "$target" ]; then
+ printf '%s\n' "$target"
+ return 0
+ fi
+
+ compose_app_names
+}
+
+clone_or_update_compose_app() {
+ local app="$1"
+
+ source_compose_app_env "$app"
+
+ git config --global --add safe.directory "$DOCKER_COMPOSE_DIR"
+
+ if [ ! -d "$DOCKER_COMPOSE_DIR/.git" ]; then
+ echo "Directory '$DOCKER_COMPOSE_DIR' not found. Cloning repository for ${app}."
+ mkdir -p "$DOCKER_COMPOSE_DIR"
+ pushd "$DOCKER_COMPOSE_DIR" >/dev/null
+ retry_until_success git clone -b "$DOCKER_COMPOSE_BRANCH" "$DOCKER_COMPOSE_REPO" .
+ if [ "$(id -u)" -eq 0 ]; then
+ chown -R cloud-compose:cloud-compose .
+ fi
+ popd >/dev/null
+ else
+ pushd "$DOCKER_COMPOSE_DIR" >/dev/null
+ retry_until_success git pull origin "$DOCKER_COMPOSE_BRANCH"
+ popd >/dev/null
+ fi
+}
+
+configure_sitectl_app_features() {
+ local app="$1"
+ local letsencrypt bot_mitigation mode domain acme_email max_upload_size upload_timeout
+ local configure_ingress=false
+ local changed=false
+ local trusted_ip
+
+ source_compose_app_env "$app"
+
+ letsencrypt="$(compose_app_ingress_field "$app" letsencrypt)"
+ bot_mitigation="$(compose_app_ingress_field "$app" bot_mitigation)"
+ mode="$(compose_app_ingress_field "$app" mode)"
+ domain="$(compose_app_ingress_field "$app" domain)"
+ acme_email="$(compose_app_ingress_field "$app" acme_email)"
+ max_upload_size="$(compose_app_ingress_field "$app" max_upload_size)"
+ upload_timeout="$(compose_app_ingress_field "$app" upload_timeout)"
+
+ if sitectl_truthy "$letsencrypt" && [ -z "$mode" ]; then
+ mode="https-letsencrypt"
+ fi
+
+ local ingress_args=(set ingress enabled --context "$SITECTL_CONTEXT_NAME" --yolo)
+ if [ -n "$mode" ]; then
+ ingress_args+=(--mode "$mode")
+ configure_ingress=true
+ fi
+ if [ -n "$domain" ]; then
+ ingress_args+=(--domain "$domain")
+ configure_ingress=true
+ fi
+ if [ -n "$acme_email" ]; then
+ ingress_args+=(--acme-email "$acme_email")
+ configure_ingress=true
+ fi
+ while IFS= read -r trusted_ip; do
+ if [ -n "$trusted_ip" ]; then
+ ingress_args+=(--trusted-ip "$trusted_ip")
+ configure_ingress=true
+ fi
+ done < <(compose_app_ingress_array "$app" trusted_ips)
+ if [ -n "$max_upload_size" ]; then
+ ingress_args+=(--max-upload-size "$max_upload_size")
+ configure_ingress=true
+ fi
+ if [ -n "$upload_timeout" ]; then
+ ingress_args+=(--upload-timeout "$upload_timeout")
+ configure_ingress=true
+ fi
+
+ if [ "$configure_ingress" = true ]; then
+ sitectl "${ingress_args[@]}"
+ changed=true
+ fi
+ if sitectl_truthy "$bot_mitigation"; then
+ sitectl set bot-mitigation on --context "$SITECTL_CONTEXT_NAME" --yolo
+ changed=true
+ fi
+ if [ "$changed" = true ]; then
+ sitectl converge --context "$SITECTL_CONTEXT_NAME" --yolo
+ fi
+}
+
+run_compose_app_lifecycle() {
+ local app="$1"
+ local lifecycle="$2"
+ local field="${lifecycle}_commands"
+ local command
+
+ if [ "$lifecycle" != "down" ]; then
+ clone_or_update_compose_app "$app"
+ else
+ source_compose_app_env "$app"
+ fi
+
+ echo "Running cloud-compose ${lifecycle} for ${app}"
+ pushd "$DOCKER_COMPOSE_DIR" >/dev/null
+ while IFS= read -r command; do
+ if [ -z "$command" ]; then
+ continue
+ fi
+ bash -c "$command"
+ done < <(compose_app_array "$app" "$field")
+ popd >/dev/null
+}
diff --git a/rootfs/home/cloud-compose/compose-dispatch.sh b/rootfs/home/cloud-compose/compose-dispatch.sh
new file mode 100644
index 0000000..4d3a7a1
--- /dev/null
+++ b/rootfs/home/cloud-compose/compose-dispatch.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+lifecycle="${1:-}"
+if [ -z "$lifecycle" ]; then
+ echo "usage: compose-dispatch.sh init|up|down|rollout" >&2
+ exit 2
+fi
+
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+# shellcheck disable=SC1091
+source /home/cloud-compose/compose-apps.sh
+
+case "$lifecycle" in
+ init | up | down | rollout) ;;
+ *)
+ echo "unknown lifecycle: ${lifecycle}" >&2
+ exit 2
+ ;;
+esac
+
+while read -r app; do
+ if [ -z "$app" ]; then
+ continue
+ fi
+ run_compose_app_lifecycle "$app" "$lifecycle"
+done < <(target_compose_apps "$lifecycle")
diff --git a/rootfs/home/cloud-compose/host-conf.sh b/rootfs/home/cloud-compose/host-conf.sh
index 7741ffe..cbc1a0c 100644
--- a/rootfs/home/cloud-compose/host-conf.sh
+++ b/rootfs/home/cloud-compose/host-conf.sh
@@ -5,63 +5,47 @@ set -eou pipefail
# shellcheck disable=SC1091
source /home/cloud-compose/profile.sh
-# block metadata server from docker and non-root
-/sbin/iptables -I FORWARD -d 169.254.169.254/32 -i docker0 -j DROP
-/sbin/iptables -A OUTPUT -m owner ! --uid-owner 0 -d 169.254.169.254/32 -p tcp --dport 80 -j DROP
-
-# restart services we've overwritten files for
-systemctl restart fluent-bit
-systemctl restart docker
-
-# wait until our data-root /etc/docker/daemon.json setting are applied
-until test -d /mnt/disks/data/docker/overlay2; do
- echo "Waiting for docker overlay2 dir"
- sleep 1
-done
-
-mkdir -p /home/cloud-compose/.docker/cli-plugins /home/cloud-compose/bin
+if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then
+ # block metadata server from docker and non-root
+ /sbin/iptables -I FORWARD -d 169.254.169.254/32 -i docker0 -j DROP || true
+ /sbin/iptables -A OUTPUT -m owner ! --uid-owner 0 -d 169.254.169.254/32 -p tcp --dport 80 -j DROP || true
+fi
-# since COS is read only FS, install host tools in the home directory
-# and symlink to our data disk which can have executables.
-# A custom COS image could eventually bake these in, but it is unnecessary
-# for this handful of simple creation-time installs.
-if [ ! -f "/home/cloud-compose/.docker/cli-plugins/docker-compose" ]; then
- retry_until_success curl -sSL \
- https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 \
- -o /mnt/disks/data/docker-compose
- chmod o+x /mnt/disks/data/docker-compose
- ln -sf /mnt/disks/data/docker-compose /home/cloud-compose/.docker/cli-plugins/docker-compose
+if systemctl list-unit-files fluent-bit.service >/dev/null 2>&1; then
+ systemctl restart fluent-bit || true
fi
-if [ ! -f "/home/cloud-compose/.docker/cli-plugins/docker-buildx" ]; then
- retry_until_success curl -sSL \
- https://github.com/docker/buildx/releases/download/v0.30.1/buildx-v0.30.1.linux-amd64 \
- -o /mnt/disks/data/docker-buildx
- chmod o+x /mnt/disks/data/docker-buildx
- ln -sf /mnt/disks/data/docker-buildx /home/cloud-compose/.docker/cli-plugins/docker-buildx
+bash /home/cloud-compose/install-dependencies.sh
+
+if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then
+ # Docker build containers cannot use GCE metadata DNS after we block metadata
+ # access from docker0, so give Docker explicit external resolvers.
+ install -d /etc/docker
+ cat >/etc/docker/daemon.json <<'EOF'
+{
+ "data-root": "/mnt/disks/data/docker",
+ "dns": ["8.8.8.8", "8.8.4.4", "1.1.1.1"]
+}
+EOF
fi
-if [ ! -f "/mnt/disks/data/make" ]; then
- # shellcheck disable=SC2016
- retry_until_success /usr/bin/docker run --rm \
- -v /mnt/disks/data:/out \
- alpine:3.22 \
- /bin/sh -euxc '
- MAKE_VERSION="4.4.1"
- MAKE_SHA256="dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3"
+# restart services we've overwritten files for
+systemctl restart docker
- apk add --no-cache build-base curl make tar
- curl -fsSL "https://ftp.gnu.org/gnu/make/make-${MAKE_VERSION}.tar.gz" -o /tmp/make.tar.gz
- echo "${MAKE_SHA256} /tmp/make.tar.gz" | sha256sum -c -
- tar -xzf /tmp/make.tar.gz -C /tmp
- cd "/tmp/make-${MAKE_VERSION}"
- LDFLAGS="-static" ./configure --disable-nls
- make -j2
- cp make /out/make
- '
-fi
-chmod o+x /mnt/disks/data/make
-ln -sf /mnt/disks/data/make /home/cloud-compose/bin/make
+# wait until our data-root /etc/docker/daemon.json setting is applied
+deadline=$((SECONDS + 120))
+while [ "$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)" != "/mnt/disks/data/docker" ]; do
+ if (( SECONDS >= deadline )); then
+ echo "Docker did not switch to /mnt/disks/data/docker" >&2
+ docker info || true
+ systemctl status docker --no-pager || true
+ cat /etc/docker/daemon.json || true
+ findmnt /mnt/disks/data || true
+ exit 1
+ fi
+ echo "Waiting for docker data-root"
+ sleep 2
+done
bash /home/cloud-compose/libops-managed-runtime.sh install-tools
systemctl daemon-reload
diff --git a/rootfs/home/cloud-compose/host-init.sh b/rootfs/home/cloud-compose/host-init.sh
index 01ade3d..50b50bd 100644
--- a/rootfs/home/cloud-compose/host-init.sh
+++ b/rootfs/home/cloud-compose/host-init.sh
@@ -1,34 +1,53 @@
#!/usr/bin/env bash
-set -euo pipefail
+set -euxo pipefail
cleanup() {
- rm tmp.attr .env.tmp || echo ""
- popd
+ rm -f tmp.attr .env.tmp
+ popd >/dev/null
}
-pushd /home/cloud-compose
+pushd /home/cloud-compose >/dev/null
trap cleanup EXIT
if [ -f .env ]; then
cp .env .env.tmp
+else
+ touch .env .env.tmp
fi
-curl -sf \
- -H "Metadata-Flavor: Google" \
- "http://metadata.google.internal/computeMetadata/v1/?recursive=true" > tmp.attr
+if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then
+ curl -sf \
+ -H "Metadata-Flavor: Google" \
+ "http://metadata.google.internal/computeMetadata/v1/?recursive=true" > tmp.attr
-{
- echo "GCP_PUBLIC_IP=$(jq -r '.instance.networkInterfaces[0].accessConfigs[0].externalIp' tmp.attr)"
- echo "GCP_PRIVATE_IP=$(jq -r '.instance.networkInterfaces[0].ip' tmp.attr)"
-} >> .env.tmp
+ {
+ echo "GCP_PUBLIC_IP=$(jq -r '.instance.networkInterfaces[0].accessConfigs[0].externalIp' tmp.attr)"
+ echo "GCP_PRIVATE_IP=$(jq -r '.instance.networkInterfaces[0].ip' tmp.attr)"
+ } >> .env.tmp
+fi
-if ! diff <(md5sum .env.tmp) <(md5sum .env); then
+if ! diff <(md5sum .env.tmp) <(md5sum .env) >/dev/null 2>&1; then
mv .env.tmp .env
+else
+ rm -f .env.tmp
+fi
+
+if [ "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" = "true" ]; then
+ install -d -m 0755 /mnt/disks/data/libops-internal
cp .env /mnt/disks/data/libops-internal/
- chown cloud-compose /mnt/disks/data/libops-internal/.env
+ chown cloud-compose:cloud-compose /mnt/disks/data/libops-internal/.env
fi
chown -R cloud-compose:cloud-compose /home/cloud-compose
-usermod -aG docker cloud-compose
+groupadd --force docker
+if ! id -u cloud-compose >/dev/null 2>&1; then
+ useradd --create-home --shell /bin/bash --groups docker cloud-compose
+elif ! id -nG cloud-compose | tr ' ' '\n' | grep -qx docker; then
+ usermod --append --groups docker cloud-compose || {
+ echo "Warning: failed to add cloud-compose to docker group" >&2
+ id cloud-compose >&2 || true
+ getent group docker >&2 || true
+ }
+fi
diff --git a/rootfs/home/cloud-compose/install-dependencies-coreos.sh b/rootfs/home/cloud-compose/install-dependencies-coreos.sh
new file mode 100644
index 0000000..1338cb2
--- /dev/null
+++ b/rootfs/home/cloud-compose/install-dependencies-coreos.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+
+install -d /etc/yum.repos.d /usr/local/lib/docker/cli-plugins
+cat >/etc/yum.repos.d/sitectl.repo <<'EOF'
+[sitectl]
+name=sitectl
+baseurl=https://packages.libops.io/sitectl/rpm
+enabled=1
+gpgcheck=0
+repo_gpgcheck=1
+gpgkey=https://packages.libops.io/sitectl/sitectl-archive-keyring.asc
+EOF
+
+bash /home/cloud-compose/install-docker-plugins.sh
+
+packages=()
+for package in git jq make; do
+ if ! rpm -q "$package" >/dev/null 2>&1; then
+ packages+=("$package")
+ fi
+done
+
+for package in ${SITECTL_PACKAGES:-sitectl}; do
+ if [ -n "$package" ] && ! rpm -q "$package" >/dev/null 2>&1; then
+ packages+=("$package")
+ fi
+done
+
+if [ "${#packages[@]}" -gt 0 ]; then
+ rpm-ostree install --apply-live "${packages[@]}"
+fi
diff --git a/rootfs/home/cloud-compose/install-dependencies-cos.sh b/rootfs/home/cloud-compose/install-dependencies-cos.sh
new file mode 100644
index 0000000..ea0c5b1
--- /dev/null
+++ b/rootfs/home/cloud-compose/install-dependencies-cos.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+
+mkdir -p "${DOCKER_CONFIG}/cli-plugins" /home/cloud-compose/.docker/cli-plugins /home/cloud-compose/bin /mnt/disks/data
+DOCKER_CLI_PLUGIN_DIR="${DOCKER_CONFIG}/cli-plugins" \
+ bash /home/cloud-compose/install-docker-plugins.sh
+DOCKER_CLI_PLUGIN_DIR=/home/cloud-compose/.docker/cli-plugins \
+ bash /home/cloud-compose/install-docker-plugins.sh
+chown -R cloud-compose:cloud-compose "$DOCKER_CONFIG" /home/cloud-compose/.docker /home/cloud-compose/bin
+
+if [ ! -f "/mnt/disks/data/make" ]; then
+ # COS can start Docker before bridge egress is reliable; this bootstrap container does not run app code.
+ # shellcheck disable=SC2016
+ retry_until_success /usr/bin/docker run --rm \
+ --network host \
+ -v /mnt/disks/data:/out \
+ alpine:3.22 \
+ /bin/sh -euxc '
+ MAKE_VERSION="4.4.1"
+ MAKE_SHA256="dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3"
+
+ apk add --no-cache build-base curl make tar
+ curl -fsSL "https://ftp.gnu.org/gnu/make/make-${MAKE_VERSION}.tar.gz" -o /tmp/make.tar.gz
+ echo "${MAKE_SHA256} /tmp/make.tar.gz" | sha256sum -c -
+ tar -xzf /tmp/make.tar.gz -C /tmp
+ cd "/tmp/make-${MAKE_VERSION}"
+ LDFLAGS="-static" ./configure --disable-nls
+ make -j2
+ cp make /out/make
+ '
+fi
+chmod a+x /mnt/disks/data/make
+ln -sf /mnt/disks/data/make /home/cloud-compose/bin/make
diff --git a/rootfs/home/cloud-compose/install-dependencies-debian.sh b/rootfs/home/cloud-compose/install-dependencies-debian.sh
new file mode 100644
index 0000000..d8099cb
--- /dev/null
+++ b/rootfs/home/cloud-compose/install-dependencies-debian.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+
+export DEBIAN_FRONTEND=noninteractive
+
+retry_until_success apt-get update -qq
+retry_until_success apt-get install -y \
+ ca-certificates \
+ curl \
+ docker.io \
+ git \
+ jq \
+ make
+
+install -d /usr/local/lib/docker/cli-plugins
+bash /home/cloud-compose/install-docker-plugins.sh
+
+systemctl enable docker
diff --git a/rootfs/home/cloud-compose/install-dependencies.sh b/rootfs/home/cloud-compose/install-dependencies.sh
new file mode 100644
index 0000000..c6d837e
--- /dev/null
+++ b/rootfs/home/cloud-compose/install-dependencies.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+if [ -f /etc/os-release ]; then
+ # shellcheck disable=SC1091
+ source /etc/os-release
+fi
+
+os_id="${ID:-unknown}"
+os_id_like="${ID_LIKE:-}"
+variant_id="${VARIANT_ID:-}"
+
+case "$os_id:$variant_id:$os_id_like" in
+ cos:*:*)
+ exec bash /home/cloud-compose/install-dependencies-cos.sh
+ ;;
+ *:coreos:* | fedora:*:*)
+ if command -v rpm-ostree >/dev/null 2>&1; then
+ exec bash /home/cloud-compose/install-dependencies-coreos.sh
+ fi
+ ;;
+ debian:*:* | ubuntu:*:* | *:*:*debian*)
+ exec bash /home/cloud-compose/install-dependencies-debian.sh
+ ;;
+esac
+
+echo "Unsupported OS for cloud-compose dependency installation: ID=${os_id} VARIANT_ID=${variant_id} ID_LIKE=${os_id_like}" >&2
+exit 1
diff --git a/rootfs/home/cloud-compose/install-docker-plugins.sh b/rootfs/home/cloud-compose/install-docker-plugins.sh
new file mode 100644
index 0000000..99f8540
--- /dev/null
+++ b/rootfs/home/cloud-compose/install-docker-plugins.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+
+docker_arch() {
+ case "$(uname -m)" in
+ x86_64 | amd64) echo "x86_64" ;;
+ aarch64 | arm64) echo "aarch64" ;;
+ *)
+ echo "unsupported architecture: $(uname -m)" >&2
+ return 1
+ ;;
+ esac
+}
+
+buildx_arch() {
+ case "$(uname -m)" in
+ x86_64 | amd64) echo "amd64" ;;
+ aarch64 | arm64) echo "arm64" ;;
+ *)
+ echo "unsupported architecture: $(uname -m)" >&2
+ return 1
+ ;;
+ esac
+}
+
+install_docker_cli_plugin() {
+ local name="$1"
+ local url="$2"
+ local path="$3"
+
+ if [ -x "$path" ]; then
+ return 0
+ fi
+
+ echo "Installing Docker CLI plugin ${name}"
+ mkdir -p "$(dirname "$path")"
+ retry_until_success curl -fsSL "$url" -o "$path"
+ chmod a+x "$path"
+}
+
+install_docker_plugins() {
+ local plugin_dir="${DOCKER_CLI_PLUGIN_DIR:-/usr/local/lib/docker/cli-plugins}"
+ local compose_arch buildx_asset_arch
+
+ # renovate: datasource=github-releases depName=docker-compose packageName=docker/compose versioning=semver
+ DOCKER_COMPOSE_VERSION="${DOCKER_COMPOSE_VERSION:-v5.2.0}"
+ # renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver
+ DOCKER_BUILDX_VERSION="${DOCKER_BUILDX_VERSION:-v0.35.0}"
+
+ compose_arch="$(docker_arch)"
+ buildx_asset_arch="$(buildx_arch)"
+ install_docker_cli_plugin \
+ docker-compose \
+ "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-linux-${compose_arch}" \
+ "${plugin_dir}/docker-compose"
+ install_docker_cli_plugin \
+ docker-buildx \
+ "https://github.com/docker/buildx/releases/download/${DOCKER_BUILDX_VERSION}/buildx-${DOCKER_BUILDX_VERSION}.linux-${buildx_asset_arch}" \
+ "${plugin_dir}/docker-buildx"
+}
+
+install_docker_plugins "$@"
diff --git a/rootfs/home/cloud-compose/mariadb-backup.sh b/rootfs/home/cloud-compose/mariadb-backup.sh
new file mode 100644
index 0000000..7779f9d
--- /dev/null
+++ b/rootfs/home/cloud-compose/mariadb-backup.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+# shellcheck disable=SC1091
+source /home/cloud-compose/compose-apps.sh
+
+BACKUP_ROOT="${MARIADB_BACKUP_ROOT:-/mnt/disks/data/backups/mariadb}"
+today="$(date -u +%Y%m%d)"
+
+while read -r app; do
+ if [ -z "$app" ]; then
+ continue
+ fi
+
+ source_compose_app_env "$app"
+ backup_dir="${BACKUP_ROOT}/${app}"
+ mkdir -p "$backup_dir"
+
+ output="${backup_dir}/${today}-${SITECTL_CONTEXT_NAME}.sql.gz"
+ if [ -f "$output" ]; then
+ echo "MariaDB backup already exists for ${app}: ${output}"
+ continue
+ fi
+
+ echo "Running MariaDB backup for ${app}"
+ sitectl mariadb backup \
+ --context "$SITECTL_CONTEXT_NAME" \
+ --gzip \
+ --output "$output"
+done < <(compose_app_names)
diff --git a/rootfs/home/cloud-compose/profile.sh b/rootfs/home/cloud-compose/profile.sh
index 84039ef..ba8cd25 100644
--- a/rootfs/home/cloud-compose/profile.sh
+++ b/rootfs/home/cloud-compose/profile.sh
@@ -1,9 +1,12 @@
#!/usr/bin/env bash
# shellcheck disable=SC1091
+set -a
. /home/cloud-compose/.env
+set +a
export PATH="/home/cloud-compose/bin:$PATH"
+export DOCKER_CONFIG="${DOCKER_CONFIG:-/mnt/disks/data/docker-config}"
DEFAULT_MAX_RETRIES=10
DEFAULT_SLEEP_INCREMENT=5
diff --git a/rootfs/home/cloud-compose/rotate-keys-app.sh b/rootfs/home/cloud-compose/rotate-keys-app.sh
index a791f34..c19840b 100644
--- a/rootfs/home/cloud-compose/rotate-keys-app.sh
+++ b/rootfs/home/cloud-compose/rotate-keys-app.sh
@@ -7,15 +7,28 @@ pushd /home/cloud-compose
# shellcheck disable=SC1091
source /home/cloud-compose/profile.sh
-if [ ! -d "$DOCKER_COMPOSE_DIR/secrets" ]; then
- mkdir -p "$DOCKER_COMPOSE_DIR/secrets"
-fi
+# shellcheck disable=SC1091
+source /home/cloud-compose/compose-apps.sh
+APP_CREDENTIALS_FILE="/mnt/disks/data/cloud-compose/app/GOOGLE_APPLICATION_CREDENTIALS"
+mkdir -p "$(dirname "$APP_CREDENTIALS_FILE")"
bash rotate-keys.sh \
- "$GCP_INSTANCE_NAME@$GCP_PROJECT.iam.gserviceaccount.com" \
+ "$GCP_APP_SERVICE_ACCOUNT_EMAIL" \
"$GCP_PROJECT" \
- "$DOCKER_COMPOSE_DIR/secrets/GOOGLE_APPLICATION_CREDENTIALS"
+ "$APP_CREDENTIALS_FILE"
+
+chgrp cloud-compose "$APP_CREDENTIALS_FILE"
-chgrp cloud-compose "$DOCKER_COMPOSE_DIR/secrets/GOOGLE_APPLICATION_CREDENTIALS"
+while read -r app; do
+ if [ -z "$app" ]; then
+ continue
+ fi
+
+ source_compose_app_env "$app"
+ mkdir -p "$DOCKER_COMPOSE_DIR/secrets"
+ install -o 100 -g cloud-compose -m 0440 \
+ "$APP_CREDENTIALS_FILE" \
+ "$DOCKER_COMPOSE_DIR/secrets/GOOGLE_APPLICATION_CREDENTIALS"
+done < <(compose_app_names)
popd
diff --git a/rootfs/home/cloud-compose/run.sh b/rootfs/home/cloud-compose/run.sh
index dbb9c99..af1450e 100644
--- a/rootfs/home/cloud-compose/run.sh
+++ b/rootfs/home/cloud-compose/run.sh
@@ -6,13 +6,34 @@ set -x
# shellcheck disable=SC1091
source /home/cloud-compose/profile.sh
+run_as_cloud_compose() {
+ if command -v runuser >/dev/null 2>&1; then
+ runuser -u cloud-compose -- env HOME=/home/cloud-compose PATH="$PATH" "$@"
+ elif command -v sudo >/dev/null 2>&1; then
+ sudo -u cloud-compose env HOME=/home/cloud-compose PATH="$PATH" "$@"
+ elif command -v su >/dev/null 2>&1; then
+ su -s /bin/bash -c "HOME=/home/cloud-compose PATH=$(printf '%q' "$PATH") $(printf '%q ' "$@")" cloud-compose
+ else
+ echo "No supported user-switching command found for cloud-compose app init" >&2
+ return 1
+ fi
+}
+
bash /home/cloud-compose/host-conf.sh
bash /home/cloud-compose/host-init.sh
-bash /home/cloud-compose/app-init.sh
-bash /home/cloud-compose/rotate-keys-app.sh || true
-bash /home/cloud-compose/rotate-keys-internal.sh || true
+run_as_cloud_compose bash /home/cloud-compose/app-init.sh
+if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then
+ bash /home/cloud-compose/rotate-keys-app.sh || true
+ bash /home/cloud-compose/rotate-keys-internal.sh || true
+fi
+bash /home/cloud-compose/vault-agent-init.sh || true
systemctl start cloud-compose
-systemctl start internal-services.timer
+if [ "${LIBOPS_INTERNAL_SERVICES_ENABLED:-true}" = "true" ]; then
+ systemctl start internal-services.timer
+fi
systemctl start libops-managed-runtime.timer
systemctl start cron.timer
+systemctl start cloud-compose-mariadb-backup.timer
+touch /home/cloud-compose/.cloud-compose-bootstrap-complete
+chown cloud-compose:cloud-compose /home/cloud-compose/.cloud-compose-bootstrap-complete
diff --git a/rootfs/home/cloud-compose/vault-agent-init.sh b/rootfs/home/cloud-compose/vault-agent-init.sh
new file mode 100644
index 0000000..1377566
--- /dev/null
+++ b/rootfs/home/cloud-compose/vault-agent-init.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+
+if [ "${VAULT_AGENT_ENABLED:-false}" != "true" ]; then
+ echo "Vault Agent is disabled"
+ exit 0
+fi
+
+if [ ! -f /etc/vault-agent.d/cloud-compose.hcl ]; then
+ echo "Vault Agent config is missing"
+ exit 0
+fi
+
+if ! command -v vault >/dev/null 2>&1; then
+ echo "Vault binary is not installed; skipping vault-agent.service"
+ exit 0
+fi
+
+mkdir -p "$(dirname "${VAULT_AGENT_TOKEN_PATH:-/mnt/disks/data/vault/token}")"
+chmod 0700 "$(dirname "${VAULT_AGENT_TOKEN_PATH:-/mnt/disks/data/vault/token}")"
+
+systemctl daemon-reload
+systemctl enable vault-agent.service
+systemctl restart vault-agent.service
diff --git a/rootfs/mnt/disks/data/libops-internal/docker-compose.yaml b/rootfs/mnt/disks/data/libops-internal/docker-compose.yaml
index 2c190ad..fb2f802 100644
--- a/rootfs/mnt/disks/data/libops-internal/docker-compose.yaml
+++ b/rootfs/mnt/disks/data/libops-internal/docker-compose.yaml
@@ -2,6 +2,8 @@ networks:
internal:
services:
lightsout:
+ profiles:
+ - lightsout
image: ${LIBOPS_LIGHTSOUT_IMAGE:-ghcr.io/libops/lightsout:main}
ports:
- "8808:8808"
diff --git a/templates/cloud-init.yml b/templates/cloud-init.yml
index 226cc07..ea8d173 100644
--- a/templates/cloud-init.yml
+++ b/templates/cloud-init.yml
@@ -1,8 +1,21 @@
#cloud-config
+groups:
+- docker
+
users:
- name: cloud-compose
shell: /bin/bash
+ groups:
+ - docker
+ sudo:
+ - ALL=(ALL) NOPASSWD:ALL
+%{ if length(CLOUD_COMPOSE_SSH_KEYS) > 0 ~}
+ ssh_authorized_keys:
+%{ for key in CLOUD_COMPOSE_SSH_KEYS ~}
+ - ${key}
+%{ endfor ~}
+%{ endif ~}
%{ for username, ssh_keys in SSH_USERS ~}
- name: ${username}
shell: /bin/bash
@@ -14,15 +27,15 @@ users:
bootcmd:
# mount the main Data disk
-- fsck.ext4 -tvy $(readlink -f /dev/disk/by-id/google-data) || mkfs.ext4 -m 0 -E lazy_itable_init=0,lazy_journal_init=0,discard $(readlink -f /dev/disk/by-id/google-data)
+- fsck.ext4 -tvy $(readlink -f /dev/disk/by-id/google-data) || mkfs.ext4 -m 0 -E lazy_itable_init=1,lazy_journal_init=1,nodiscard $(readlink -f /dev/disk/by-id/google-data)
- mkdir -p /mnt/disks/data
-- mount -o discard,defaults $(readlink -f /dev/disk/by-id/google-data) /mnt/disks/data
+- mount -o defaults $(readlink -f /dev/disk/by-id/google-data) /mnt/disks/data
- chmod a+w /mnt/disks/data
# mount the specific Volumes disk
-- fsck.ext4 -tvy $(readlink -f /dev/disk/by-id/google-docker-volumes) || mkfs.ext4 -m 0 -E lazy_itable_init=0,lazy_journal_init=0,discard $(readlink -f /dev/disk/by-id/google-docker-volumes)
+- fsck.ext4 -tvy $(readlink -f /dev/disk/by-id/google-docker-volumes) || mkfs.ext4 -m 0 -E lazy_itable_init=1,lazy_journal_init=1,nodiscard $(readlink -f /dev/disk/by-id/google-docker-volumes)
- mkdir -p /mnt/disks/volumes
-- mount -o discard,defaults $(readlink -f /dev/disk/by-id/google-docker-volumes) /mnt/disks/volumes
+- mount -o defaults $(readlink -f /dev/disk/by-id/google-docker-volumes) /mnt/disks/volumes
- chmod a+w /mnt/disks/volumes
- mkdir -p /mnt/disks/data/docker/volumes
@@ -36,13 +49,18 @@ bootcmd:
write_files:
${WRITE_FILES_CONTENT}
${DOCKER_COMPOSE_SCRIPTS}
+${COMPOSE_PROJECTS_FILE}
${ENV_FILE_CONTENT}
+${VAULT_AGENT_FILES}
${MANAGED_RUNTIME_ARTIFACTS_FILE}
runcmd:
%{ for CMD in ADDITIONAL_INITCMD ~}
- ${CMD}
%{ endfor ~}
+- chown cloud-compose:cloud-compose /mnt/disks/data /mnt/disks/volumes
+- chmod 0775 /mnt/disks/data /mnt/disks/volumes
+- install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops
- bash /home/cloud-compose/run.sh > /home/cloud-compose/run.log 2>&1
%{ for VOLUME in DOCKER_VOLUME_OVERLAYS ~}
- bash /home/cloud-compose/overlay-init.sh "${VOLUME}" >> /home/cloud-compose/run.log 2>&1
diff --git a/tests/smoke/app/main.tf b/tests/smoke/app/main.tf
new file mode 100644
index 0000000..db061bf
--- /dev/null
+++ b/tests/smoke/app/main.tf
@@ -0,0 +1,188 @@
+terraform {
+ required_version = ">= 1.2.4"
+
+ required_providers {
+ digitalocean = {
+ source = "digitalocean/digitalocean"
+ version = "~> 2.0"
+ }
+ google = {
+ source = "hashicorp/google"
+ version = "~> 7.0"
+ }
+ linode = {
+ source = "linode/linode"
+ version = "~> 4.0"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.7"
+ }
+ }
+}
+
+provider "digitalocean" {}
+
+provider "google" {
+ access_token = local.cloud_provider == "gcp" ? null : "unused"
+ project = local.cloud_provider == "gcp" ? local.project_id : "unused"
+ region = var.gcp_region
+ zone = var.gcp_zone
+}
+
+provider "linode" {}
+
+data "google_project" "current" {
+ count = local.cloud_provider == "gcp" ? 1 : 0
+ project_id = local.project_id
+}
+
+resource "random_id" "suffix" {
+ byte_length = local.cloud_provider == "gcp" ? 2 : 3
+}
+
+locals {
+ cloud_provider = lower(trimspace(var.cloud_provider))
+ template = lower(trimspace(var.template))
+ target = "${local.cloud_provider}-${local.template}"
+ project_id = trimspace(var.gcp_project_id)
+
+ provider_prefixes = {
+ digitalocean = "do"
+ gcp = "g"
+ linode = "ln"
+ }
+ template_slugs = {
+ archivesspace = "as"
+ ojs = "ojs"
+ isle = "isle"
+ drupal = "dr"
+ wp = "wp"
+ "omeka-s" = "os"
+ "omeka-classic" = "oc"
+ }
+
+ smoke_run_id = substr(replace(lower(var.smoke_run_id), "/[^a-z0-9-]/", "-"), 0, local.cloud_provider == "gcp" ? 8 : 16)
+ name_prefix = "cc-${local.provider_prefixes[local.cloud_provider]}-${local.template_slugs[local.template]}"
+ name_limit = local.cloud_provider == "gcp" ? 21 : 46
+ name = substr(join("-", compact([local.name_prefix, local.smoke_run_id, random_id.suffix.hex])), 0, local.name_limit)
+ run_tag = local.smoke_run_id != "" ? "gha-run-${local.smoke_run_id}" : ""
+ tags = distinct(concat(var.tags, ["cloud-compose-smoke", local.target], local.run_tag != "" ? [local.run_tag] : []))
+
+ ssh_keys = distinct(concat([var.ssh_public_key], var.operator_ssh_public_keys))
+ healthcheck_timeout = trimspace(var.healthcheck_timeout) != "" ? var.healthcheck_timeout : contains(["isle"], local.template) ? "30m" : "20m"
+
+ runtime = {
+ rootfs_archive_url = local.cloud_provider == "linode" ? "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz" : ""
+ users = local.cloud_provider == "gcp" ? { cloud-compose = local.ssh_keys } : {}
+ compose = {
+ branch = var.docker_compose_branch
+ ingress_port = var.ingress_port
+ up = [
+ "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}\""
+ ]
+ }
+ sitectl = {
+ environment = "smoke"
+ healthcheck_timeout = local.healthcheck_timeout
+ healthcheck_interval = var.healthcheck_interval
+ }
+ managed_runtime = {
+ enabled = true
+ internal_services_enabled = false
+ internal_services_auto_update = false
+ }
+ vault = {
+ auth_method = "consumer-managed"
+ }
+ }
+}
+
+module "app" {
+ source = "../../../examples/app"
+
+ name = local.name
+ cloud_provider = local.cloud_provider
+ template = local.template
+ gcp = {
+ project_id = local.project_id
+ project_number = local.cloud_provider == "gcp" ? data.google_project.current[0].number : ""
+ region = var.gcp_region
+ zone = var.gcp_zone
+ instance = {
+ machine_type = var.gcp_machine_type
+ os = var.gcp_os
+ production = false
+ }
+ disks = {
+ type = var.gcp_disk_type
+ docker_volumes_size_gb = var.docker_volumes_volume_size_gb
+ }
+ snapshots = {
+ enabled = false
+ }
+ network = {
+ ssh_ipv4 = var.ssh_source_ranges
+ ssh_ipv6 = []
+ }
+ power_management = {
+ enabled = false
+ }
+ }
+ digitalocean = {
+ region = var.digitalocean_region
+ tags = local.tags
+ droplet = {
+ size = var.digitalocean_size
+ image = var.digitalocean_image
+ monitoring = true
+ ipv6 = true
+ backups = false
+ }
+ ssh = {
+ cloud_compose_keys = local.ssh_keys
+ }
+ volumes = {
+ data_size_gb = var.data_volume_size_gb
+ docker_volumes_size_gb = var.docker_volumes_volume_size_gb
+ }
+ }
+ linode = {
+ region = var.linode_region
+ tags = local.tags
+ instance = {
+ type = var.linode_type
+ image = var.linode_image
+ authorized_keys = local.ssh_keys
+ }
+ ssh = {
+ cloud_compose_keys = local.ssh_keys
+ }
+ volumes = {
+ data_size_gb = var.data_volume_size_gb
+ docker_volumes_size_gb = var.docker_volumes_volume_size_gb
+ }
+ }
+ runtime = local.runtime
+}
+
+output "smoke" {
+ description = "Remote sitectl context details for the smoke test runner."
+ value = {
+ provider = local.cloud_provider
+ app = local.template
+ host = module.app.external_ip
+ ssh_user = "cloud-compose"
+ ssh_port = 22
+ context_name = module.app.primary_compose_project.sitectl_context_name
+ plugin = module.app.primary_compose_project.sitectl_plugin
+ environment = module.app.primary_compose_project.sitectl_environment
+ site = local.name
+ project_name = local.name
+ project_dir = module.app.primary_compose_project.project_dir
+ compose_project_name = module.app.primary_compose_project.compose_project_name
+ healthcheck_timeout = local.healthcheck_timeout
+ healthcheck_interval = var.healthcheck_interval
+ }
+}
diff --git a/tests/smoke/app/variables.tf b/tests/smoke/app/variables.tf
new file mode 100644
index 0000000..882cfd1
--- /dev/null
+++ b/tests/smoke/app/variables.tf
@@ -0,0 +1,171 @@
+variable "cloud_provider" {
+ type = string
+ default = "digitalocean"
+ description = "Cloud provider smoke target."
+
+ validation {
+ condition = contains(["digitalocean", "gcp", "linode"], lower(trimspace(var.cloud_provider)))
+ error_message = "cloud_provider must be digitalocean, gcp, or linode."
+ }
+}
+
+variable "template" {
+ type = string
+ default = "wp"
+ description = "Compose template smoke target."
+
+ validation {
+ condition = contains(["archivesspace", "ojs", "isle", "drupal", "wp", "omeka-s", "omeka-classic"], lower(trimspace(var.template)))
+ error_message = "template must be archivesspace, ojs, isle, drupal, wp, omeka-s, or omeka-classic."
+ }
+}
+
+variable "gcp_project_id" {
+ type = string
+ default = ""
+ description = "Google Cloud project used for disposable smoke-test resources."
+}
+
+variable "gcp_region" {
+ type = string
+ default = "us-east5"
+ description = "Google Cloud region for smoke-test resources."
+}
+
+variable "gcp_zone" {
+ type = string
+ default = "us-east5-b"
+ description = "Google Cloud zone for smoke-test resources."
+}
+
+variable "gcp_machine_type" {
+ type = string
+ default = "e2-medium"
+ description = "Google Compute Engine machine type."
+}
+
+variable "gcp_disk_type" {
+ type = string
+ default = "pd-standard"
+ description = "Google Compute Engine disk type."
+}
+
+variable "gcp_os" {
+ type = string
+ default = "cos-125-19216-220-185"
+ description = "Compute-optimized OS image name."
+}
+
+variable "digitalocean_region" {
+ type = string
+ default = "tor1"
+ description = "DigitalOcean region slug."
+}
+
+variable "digitalocean_size" {
+ type = string
+ default = "s-4vcpu-8gb"
+ description = "DigitalOcean Droplet size slug."
+}
+
+variable "digitalocean_image" {
+ type = string
+ default = "ubuntu-24-04-x64"
+ description = "DigitalOcean Droplet image slug."
+}
+
+variable "linode_region" {
+ type = string
+ default = "us-east"
+ description = "Linode region slug."
+}
+
+variable "linode_type" {
+ type = string
+ default = "g6-standard-2"
+ description = "Linode instance type."
+}
+
+variable "linode_image" {
+ type = string
+ default = "linode/ubuntu22.04"
+ description = "Linode image slug."
+}
+
+variable "ssh_public_key" {
+ type = string
+ description = "Public SSH key authorized for smoke-test access."
+
+ validation {
+ condition = trimspace(var.ssh_public_key) != ""
+ error_message = "ssh_public_key is required."
+ }
+}
+
+variable "operator_ssh_public_keys" {
+ type = list(string)
+ default = [
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOuUgUvvcJyWVZkgLrBGGI9RfcNmQsw32QNftNS5/Iiv jcorall@MacBookPro"
+ ]
+ description = "Additional SSH public keys authorized for operator access during smoke tests."
+}
+
+variable "ssh_source_ranges" {
+ type = list(string)
+ default = ["0.0.0.0/0"]
+ description = "CIDR IPv4 ranges allowed to SSH into the disposable smoke-test VM."
+}
+
+variable "smoke_run_id" {
+ type = string
+ default = ""
+ description = "Optional GitHub Actions run id used to tag and name disposable smoke-test resources."
+}
+
+variable "docker_compose_branch" {
+ type = string
+ default = "main"
+ description = "Compose template branch."
+}
+
+variable "ingress_port" {
+ type = number
+ default = 80
+ description = "Host port exposed by Traefik."
+}
+
+variable "data_volume_size_gb" {
+ type = number
+ default = 10
+ description = "Disposable smoke-test data volume size."
+}
+
+variable "docker_volumes_volume_size_gb" {
+ type = number
+ default = 30
+ description = "Disposable smoke-test Docker volumes volume size."
+}
+
+variable "healthcheck_timeout" {
+ type = string
+ default = ""
+ description = "Timeout passed to sitectl healthcheck. Empty chooses a template-specific default."
+}
+
+variable "healthcheck_interval" {
+ type = string
+ default = "20s"
+ description = "Interval passed to sitectl healthcheck on the VM and from the runner."
+}
+
+variable "tags" {
+ type = list(string)
+ default = ["cloud-compose"]
+ description = "Extra provider tags applied to smoke-test resources."
+}
+
+variable "cloud_compose_source_ref" {
+ type = string
+ default = "main"
+ description = "cloud-compose Git ref whose rootfs is fetched by providers with metadata size limits."
+}
diff --git a/variables.tf b/variables.tf
index b1557ed..0d20e19 100644
--- a/variables.tf
+++ b/variables.tf
@@ -1,54 +1,114 @@
-variable "project_id" {
- description = "The GCP project ID"
+variable "name" {
type = string
+ description = "Deployment name."
}
-variable "project_number" {
+variable "cloud_provider" {
type = string
- description = "The GCP project number"
-}
+ default = "gcp"
+ description = "Cloud provider to deploy to. Supported values are gcp, digitalocean, and linode."
-variable "region" {
- description = "GCP region for resources"
- type = string
- default = "us-east5"
+ validation {
+ condition = contains(["gcp", "digitalocean", "linode"], lower(trimspace(var.cloud_provider)))
+ error_message = "cloud_provider must be gcp, digitalocean, or linode."
+ }
}
-variable "zone" {
- description = "GCP zone for resources"
+variable "template" {
type = string
- default = "us-east5-b"
-}
+ default = ""
+ description = "Optional compose template preset. Supported values are archivesspace, ojs, isle, drupal, wp, omeka-s, and omeka-classic. Explicit runtime settings override preset defaults."
-variable "name" {
- type = string
- description = "The site name (will be the name of the GCP instance)"
+ validation {
+ condition = contains(["", "archivesspace", "ojs", "isle", "drupal", "wp", "omeka-s", "omeka-classic"], lower(trimspace(var.template)))
+ error_message = "template must be empty, archivesspace, ojs, isle, drupal, wp, omeka-s, or omeka-classic."
+ }
}
-variable "service_account_email" {
- description = "Existing service account email for the VM. When empty, this module creates one."
- type = string
- default = ""
-}
+variable "gcp" {
+ description = "Google Cloud infrastructure settings."
+ type = 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"])
+ }), {})
+ })
+ default = {}
-variable "disk_type" {
- type = string
- description = "The disk type for disks attached to the machine"
- default = "hyperdisk-balanced"
validation {
condition = contains([
"hyperdisk-balanced",
"pd-ssd",
"pd-standard",
- ], var.disk_type)
- error_message = "Invalid 'disk_type'"
+ ], var.gcp.disks.type)
+ error_message = "gcp.disks.type must be hyperdisk-balanced, pd-ssd, or pd-standard."
}
-}
-
-variable "machine_type" {
- type = string
- default = "n4-standard-2"
- description = "VM machine type (General-purpose series that support Hyperdisk Balanced"
validation {
condition = contains([
@@ -70,343 +130,253 @@ variable "machine_type" {
"c4-standard-32",
"c4-standard-48",
"c4-standard-96",
- ], var.machine_type)
- error_message = "The 'machine_type' must be from a General-Purpose family that supports Hyperdisk Balanced (C4, or N4 series)"
+ ], var.gcp.instance.machine_type)
+ error_message = "gcp.instance.machine_type must be an allowed general-purpose machine type."
}
-}
-
-variable "ingress_port" {
- type = number
- default = 80
- description = "TCP port on the VM that the Cloud Run ingress should connect to."
-}
-
-variable "disk_size_gb" {
- type = number
- default = 50
- description = "Data disk size in GB"
-}
-variable "os" {
- type = string
- default = "cos-125-19216-220-185"
- description = "The host OS to install on the GCP instance"
-}
-
-variable "docker_compose_repo" {
- type = string
- description = "git repo to checkout that contains a docker compose project"
-}
-
-variable "docker_compose_branch" {
- type = string
- default = "main"
- description = "git branch to checkout for var.docker_compose_repo"
-}
-
-variable "docker_compose_init" {
- type = list(string)
- default = [
- "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"
- ]
- description = "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"
+ validation {
+ condition = var.gcp.rollout.release_sha256 == "" || can(regex("^[0-9a-f]{64}$", var.gcp.rollout.release_sha256))
+ error_message = "gcp.rollout.release_sha256 must be empty or a lowercase SHA256 hex digest."
+ }
}
-variable "docker_compose_up" {
- type = list(string)
- default = [
- "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"
- ]
- description = "Command to start the docker compose project"
+variable "digitalocean" {
+ description = "DigitalOcean infrastructure settings."
+ type = object({
+ region = optional(string, "tor1")
+ tags = optional(list(string), ["cloud-compose"])
+
+ droplet = optional(object({
+ size = optional(string, "s-2vcpu-4gb")
+ image = optional(string, "ubuntu-24-04-x64")
+ ssh_keys = optional(list(string), [])
+ vpc_uuid = optional(string, null)
+ monitoring = optional(bool, true)
+ ipv6 = optional(bool, true)
+ backups = optional(bool, false)
+ }), {})
+
+ ssh = optional(object({
+ cloud_compose_keys = optional(list(string), [])
+ users = optional(map(list(string)), {})
+ }), {})
+
+ volumes = optional(object({
+ data_size_gb = optional(number, 50)
+ docker_volumes_size_gb = optional(number, 100)
+ }), {})
+
+ firewall = optional(object({
+ enabled = optional(bool, true)
+ ssh_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"])
+ web_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"])
+ }), {})
+ })
+ default = {}
}
-variable "docker_compose_down" {
- type = list(string)
- default = [
- "sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" down"
- ]
- description = "Command to stop the docker compose project"
+variable "linode" {
+ description = "Linode infrastructure settings."
+ type = object({
+ region = optional(string, "us-east")
+ tags = optional(list(string), ["cloud-compose"])
+
+ instance = optional(object({
+ type = optional(string, "g6-standard-2")
+ image = optional(string, "linode/ubuntu22.04")
+ authorized_keys = optional(list(string), [])
+ authorized_users = optional(list(string), [])
+ root_pass = optional(string, null)
+ private_ip = optional(bool, true)
+ backups_enabled = optional(bool, false)
+ watchdog_enabled = optional(bool, true)
+ }), {})
+
+ ssh = optional(object({
+ cloud_compose_keys = optional(list(string), [])
+ users = optional(map(list(string)), {})
+ }), {})
+
+ volumes = optional(object({
+ data_size_gb = optional(number, 50)
+ docker_volumes_size_gb = optional(number, 100)
+ }), {})
+
+ firewall = optional(object({
+ enabled = optional(bool, true)
+ ssh_source_ipv4 = optional(list(string), ["0.0.0.0/0"])
+ ssh_source_ipv6 = optional(list(string), ["::/0"])
+ web_source_ipv4 = optional(list(string), ["0.0.0.0/0"])
+ web_source_ipv6 = optional(list(string), ["::/0"])
+ }), {})
+ })
+ default = {}
}
-variable "docker_compose_rollout" {
- type = list(string)
- default = [
- "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"
- ]
- description = "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."
-}
+variable "runtime" {
+ description = "Provider-neutral compose/runtime settings."
+ type = 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), {})
+ })
+ default = {}
-variable "sitectl_packages" {
- type = list(string)
- default = ["sitectl"]
- description = "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."
+ validation {
+ condition = alltrue([
+ for name, app in var.runtime.compose.projects :
+ can(regex("^[a-z][a-z0-9-]*$", name)) &&
+ trimspace(app.docker_compose_repo) != "" &&
+ try(app.ingress_port, var.runtime.compose.ingress_port) > 0 &&
+ try(app.ingress_port, var.runtime.compose.ingress_port) <= 65535
+ ])
+ error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535."
+ }
validation {
condition = alltrue([
- for package in var.sitectl_packages :
+ for package in var.runtime.sitectl.packages :
can(regex("^sitectl(-[a-z0-9]+)*$", package))
])
- error_message = "sitectl_packages entries must be release package names such as sitectl, sitectl-isle, or sitectl-wp."
+ error_message = "runtime.sitectl.packages entries must be release package names such as sitectl, sitectl-isle, or sitectl-wp."
}
-}
-
-variable "sitectl_version" {
- type = string
- default = "latest"
- description = "Sitectl release tag to install for sitectl packages, or latest to follow https://github.com/libops/sitectl/releases/latest."
validation {
- condition = var.sitectl_version == "latest" || can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+", var.sitectl_version))
- error_message = "sitectl_version must be latest or a release tag such as v0.19.7."
+ condition = var.runtime.sitectl.version == "latest" || can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+", var.runtime.sitectl.version))
+ error_message = "runtime.sitectl.version must be latest or a release tag such as v0.19.7."
}
-}
-
-variable "sitectl_context_name" {
- type = string
- default = ""
- description = "Sitectl context name to create on the VM. Defaults to var.name."
-}
-
-variable "sitectl_plugin" {
- type = string
- default = "core"
- description = "Sitectl plugin id to associate with the VM context."
-}
-
-variable "sitectl_environment" {
- type = string
- default = "production"
- description = "Sitectl environment label. Production runs healthcheck only by default; non-production also runs sitectl verify."
-}
-
-variable "production" {
- type = bool
- default = false
- description = "Whether this VM is the production environment. Production VMs reserve one matching machine so stop/start and recreate operations keep capacity."
-}
-variable "sitectl_healthcheck_timeout" {
- type = string
- default = "10m"
- description = "Timeout passed to sitectl healthcheck --timeout in default lifecycle commands."
-}
-
-variable "sitectl_healthcheck_interval" {
- type = string
- default = "15s"
- description = "Interval passed to sitectl healthcheck --interval in default lifecycle commands."
-}
-
-variable "sitectl_verify_args" {
- type = list(string)
- default = []
- description = "Additional arguments appended to sitectl verify by the default non-production lifecycle commands."
-}
-
-variable "libops_managed_runtime_enabled" {
- type = bool
- default = true
- description = "Install and periodically update LibOps-managed host tools and internal VM services."
-}
-
-variable "libops_internal_services_auto_update" {
- type = bool
- default = true
- description = "Whether the managed runtime updater should pull and restart the internal LibOps compose project."
-}
-
-variable "libops_lightsout_image" {
- type = string
- default = "ghcr.io/libops/lightsout:main"
- description = "Container image used for the internal lightsout service."
-}
-
-variable "libops_cap_image" {
- type = string
- default = "ghcr.io/libops/cap:main"
- description = "Container image used for the internal CAP metrics service."
-}
-
-variable "libops_cadvisor_image" {
- type = string
- default = "ghcr.io/google/cadvisor:v0.57.0@sha256:e75bdb03b74b0b6995f208f166fead2e6e555dde73e44200113bb26f41b1981d"
- description = "Container image used for the internal cAdvisor service."
-}
-
-variable "libops_managed_artifacts" {
- type = 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, "")
- }))
- default = []
- description = "Additional LibOps-managed files or binaries to download, verify, install, and optionally restart with the managed runtime updater."
+ validation {
+ condition = contains(["gcp-iam", "consumer-managed"], var.runtime.vault.auth_method)
+ error_message = "runtime.vault.auth_method must be gcp-iam or consumer-managed."
+ }
validation {
condition = alltrue([
- for artifact in var.libops_managed_artifacts :
+ for artifact in var.runtime.managed_runtime.artifacts :
!can(regex("[\t\r\n]", artifact.name)) &&
!can(regex("[\t\r\n]", artifact.url)) &&
!can(regex("[\t\r\n]", artifact.sha256)) &&
!can(regex("[\t\r\n]", artifact.path)) &&
can(regex("^[0-9a-f]{64}$", artifact.sha256))
])
- error_message = "libops_managed_artifacts values must not contain tabs or newlines, and sha256 must be a lowercase SHA256 hex digest."
+ error_message = "runtime.managed_runtime.artifacts values must not contain tabs or newlines, and sha256 must be a lowercase SHA256 hex digest."
}
}
-
-variable "allowed_ips" {
- type = list(string)
- default = []
- description = "CIDR IP Addresses allowed to turn on this site's GCP instance"
-}
-
-variable "allowed_ssh_ipv4" {
- type = list(string)
- default = []
- description = "CIDR IPv4 Addresses allowed to to SSH into this site's GCP instance"
-}
-
-variable "allowed_ssh_ipv6" {
- type = list(string)
- default = []
- description = "CIDR IPv6 Addresses allowed to SSH into this site's GCP instance"
-}
-
-variable "run_snapshots" {
- type = bool
- default = false
- description = "Enable daily snapshots of the data disk (recommended for production). Last seven days of snapshots are available. Also weekly snapshots for past year."
-}
-
-variable "overlay_source_instance" {
- type = string
- default = ""
- description = "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."
-}
-
-variable "volume_names" {
- type = list(string)
- default = []
- description = "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."
-}
-
-variable "users" {
- type = map(list(string))
- default = {}
- description = "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...\"] }"
-}
-
-variable "rootfs" {
- type = string
- default = ""
- description = "Path to additional rootfs files to copy into the VM. Files will be merged with the base rootfs. Example: '/path/to/custom/rootfs'"
-}
-
-variable "runcmd" {
- type = list(string)
- default = []
- description = "Additional commands to run during cloud-init. Commands are executed after the main initialization."
-}
-
-variable "initcmd" {
- type = list(string)
- default = []
- description = "Commands to run before /home/cloud-compose/run.sh"
-}
-
-variable "artifact_registry_repository" {
- type = string
- default = ""
- description = "Optional Artifact Registry repository name to grant the VM service account reader access to. Leave empty to skip creating the IAM binding."
-}
-
-variable "artifact_registry_location" {
- type = string
- default = "us"
- description = "Artifact Registry location for var.artifact_registry_repository."
-}
-
-variable "frontend" {
- description = <<-EOT
- Optional frontend container to deploy as a sidecar next to ppb. When set,
- ppb continues to power on and ping the VM referenced by machineMetadata,
- but proxies incoming requests to this container on localhost instead of
- to the VM. Use this to serve a frontend from Cloud Run while keeping
- backend services on the VM.
- EOT
- type = object({
- image = string
- port = optional(number, 8080)
- cpu = optional(string, "1000m")
- memory = optional(string, "1Gi")
- })
- default = null
-}
-
-variable "rollout_enabled" {
- description = "Install and run the optional generic rollout HTTP service on the VM."
- type = bool
- default = false
-}
-
-variable "rollout_release_url" {
- description = "HTTPS URL for the pinned rollout Linux binary."
- type = string
- default = ""
-}
-
-variable "rollout_release_sha256" {
- description = "Lowercase SHA256 checksum for var.rollout_release_url."
- type = string
- default = ""
- validation {
- condition = var.rollout_release_sha256 == "" || can(regex("^[0-9a-f]{64}$", var.rollout_release_sha256))
- error_message = "rollout_release_sha256 must be empty or a lowercase SHA256 hex digest."
- }
-}
-
-variable "rollout_port" {
- description = "TCP port exposed by the optional rollout service."
- type = number
- default = 8081
- validation {
- condition = var.rollout_port > 0 && var.rollout_port <= 65535
- error_message = "rollout_port must be between 1 and 65535."
- }
-}
-
-variable "rollout_jwks_uri" {
- description = "JWKS URI used by the rollout service to validate bearer JWTs."
- type = string
- default = ""
-}
-
-variable "rollout_jwt_audience" {
- description = "JWT audience required by the rollout service."
- type = string
- default = ""
-}
-
-variable "rollout_custom_claims" {
- description = "Optional JSON object of additional JWT claims required by the rollout service."
- type = string
- default = ""
-}
-
-variable "rollout_allowed_ipv4" {
- description = "CIDR IPv4 ranges allowed to reach the rollout service port."
- type = list(string)
- default = ["10.0.0.0/8"]
-}