diff --git a/.github/workflows/cloud-smoke-cleanup.yml b/.github/workflows/cloud-smoke-cleanup.yml
index 4d62288..3e2f515 100644
--- a/.github/workflows/cloud-smoke-cleanup.yml
+++ b/.github/workflows/cloud-smoke-cleanup.yml
@@ -19,6 +19,12 @@ jobs:
fail-fast: false
matrix:
include:
+ - name: DigitalOcean ISLE
+ kind: app
+ provider: digitalocean
+ template: isle
+ environment: cloud-smoke-cleanup-digitalocean
+ concurrency_group: cloud-compose-smoke-digitalocean-isle
- name: DigitalOcean WordPress
kind: app
provider: digitalocean
@@ -93,7 +99,10 @@ jobs:
CLOUD_COMPOSE_SMOKE_RUN_ID: ${{ github.event.workflow_run.id }}
GCLOUD_OIDC_POOL: ${{ vars.GCLOUD_OIDC_POOL || secrets.GCLOUD_OIDC_POOL }}
GCLOUD_PROJECT: ${{ vars.GCLOUD_PROJECT || secrets.GCLOUD_PROJECT }}
+ GCLOUD_FRESH_REGION: ${{ vars.GCLOUD_FRESH_REGION || secrets.GCLOUD_FRESH_REGION }}
+ GCLOUD_FRESH_ZONE: ${{ vars.GCLOUD_FRESH_ZONE || secrets.GCLOUD_FRESH_ZONE }}
GCLOUD_REGION: ${{ vars.GCLOUD_REGION || secrets.GCLOUD_REGION }}
+ GCLOUD_ZONE: ${{ vars.GCLOUD_ZONE || secrets.GCLOUD_ZONE }}
GSA: ${{ vars.GSA || secrets.GSA }}
steps:
- name: Checkout repository
@@ -127,5 +136,12 @@ jobs:
- name: Install gcloud
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3
- - name: Sweep GCP smoke resources
+ - name: Sweep GCP upgrade-region resources
+ run: ci/cloud-smoke.sh sweep-gcp-wp
+
+ - name: Sweep GCP fresh-smoke region
+ if: ${{ env.GCLOUD_FRESH_REGION != '' && env.GCLOUD_FRESH_REGION != env.GCLOUD_REGION }}
+ env:
+ GCLOUD_REGION: ${{ env.GCLOUD_FRESH_REGION }}
+ GCLOUD_ZONE: ${{ env.GCLOUD_FRESH_ZONE }}
run: ci/cloud-smoke.sh sweep-gcp-wp
diff --git a/.github/workflows/cloud-smoke.yml b/.github/workflows/cloud-smoke.yml
index 26a5cd6..f8e1b8f 100644
--- a/.github/workflows/cloud-smoke.yml
+++ b/.github/workflows/cloud-smoke.yml
@@ -78,7 +78,9 @@ jobs:
cancel-in-progress: false
env:
CLOUD_COMPOSE_SMOKE_AUTO_APPROVE: "true"
- CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT: "1800"
+ CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT: "900"
+ CLOUD_COMPOSE_SMOKE_CONFIG_MANAGEMENT_TIMEOUT: "2400"
+ CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT: "900"
CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS: "true"
CLOUD_COMPOSE_SMOKE_RUN_ID: ${{ github.run_id }}
steps:
@@ -103,12 +105,14 @@ jobs:
terraform_wrapper: false
- name: Run Linode config-management smoke test
+ timeout-minutes: 100
env:
LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }}
run: make config-management-cloud-smoke METHOD=${{ matrix.method }}
- name: Destroy Linode config-management smoke resources
if: always()
+ timeout-minutes: 20
env:
LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }}
run: ci/config-management-cloud-smoke.sh destroy-${{ matrix.method }}-drupal
@@ -126,21 +130,30 @@ jobs:
fail-fast: false
matrix:
include:
+ - name: DigitalOcean ISLE
+ provider: digitalocean
+ template: isle
+ # Exceed the 90-minute application service ceiling with enough
+ # room to observe the terminal unit state before cleanup.
+ boot_timeout: "6000"
- name: DigitalOcean WordPress
provider: digitalocean
template: wp
+ boot_timeout: "1200"
- name: Linode WordPress
provider: linode
template: wp
+ boot_timeout: "1200"
concurrency:
group: cloud-compose-smoke-${{ matrix.provider }}-${{ matrix.template }}
cancel-in-progress: false
env:
CLOUD_COMPOSE_SMOKE_AUTO_APPROVE: "true"
+ CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT: ${{ matrix.boot_timeout }}
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 }}
+ CLOUD_COMPOSE_SOURCE_REF: ${{ github.sha }}
steps:
- name: Checkout repository
@@ -199,12 +212,15 @@ jobs:
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 }}
+ CLOUD_COMPOSE_SOURCE_REF: ${{ github.sha }}
CLOUD_COMPOSE_UPGRADE_BASE_SHA: f33117cdbbf4a9c7d59006a4db986baef118e6bb
CLOUD_COMPOSE_UPGRADE_CURRENT_REF: ${{ github.sha }}
GCLOUD_OIDC_POOL: ${{ vars.GCLOUD_OIDC_POOL || secrets.GCLOUD_OIDC_POOL }}
GCLOUD_PROJECT: ${{ vars.GCLOUD_PROJECT || secrets.GCLOUD_PROJECT }}
+ GCLOUD_FRESH_REGION: ${{ vars.GCLOUD_FRESH_REGION || secrets.GCLOUD_FRESH_REGION }}
+ GCLOUD_FRESH_ZONE: ${{ vars.GCLOUD_FRESH_ZONE || secrets.GCLOUD_FRESH_ZONE }}
GCLOUD_REGION: ${{ vars.GCLOUD_REGION || secrets.GCLOUD_REGION }}
+ GCLOUD_ZONE: ${{ vars.GCLOUD_ZONE || secrets.GCLOUD_ZONE }}
GCLOUD_NETWORK_PROJECT_ID: ${{ vars.GCLOUD_NETWORK_PROJECT_ID || secrets.GCLOUD_NETWORK_PROJECT_ID }}
GCLOUD_NETWORK_NAME: ${{ vars.GCLOUD_NETWORK_NAME || secrets.GCLOUD_NETWORK_NAME }}
GCLOUD_SUBNETWORK_NAME: ${{ vars.GCLOUD_SUBNETWORK_NAME || secrets.GCLOUD_SUBNETWORK_NAME }}
@@ -264,6 +280,9 @@ jobs:
- name: Run fresh smoke test
if: ${{ !startsWith(github.event.pull_request.title, '[major]') }}
+ env:
+ GCLOUD_REGION: ${{ vars.GCLOUD_FRESH_REGION || secrets.GCLOUD_FRESH_REGION || vars.GCLOUD_REGION || secrets.GCLOUD_REGION }}
+ GCLOUD_ZONE: ${{ vars.GCLOUD_FRESH_ZONE || secrets.GCLOUD_FRESH_ZONE || vars.GCLOUD_ZONE || secrets.GCLOUD_ZONE }}
run: make smoke-test PROVIDER=gcp TEMPLATE=wp
- name: Run 0.10.2 upgrade smoke test
@@ -272,6 +291,9 @@ jobs:
- name: Destroy fresh smoke resources
if: ${{ always() && !startsWith(github.event.pull_request.title, '[major]') && env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '' }}
+ env:
+ GCLOUD_REGION: ${{ vars.GCLOUD_FRESH_REGION || secrets.GCLOUD_FRESH_REGION || vars.GCLOUD_REGION || secrets.GCLOUD_REGION }}
+ GCLOUD_ZONE: ${{ vars.GCLOUD_FRESH_ZONE || secrets.GCLOUD_FRESH_ZONE || vars.GCLOUD_ZONE || secrets.GCLOUD_ZONE }}
run: ci/cloud-smoke.sh destroy-gcp-wp
- name: Destroy upgrade smoke resources
diff --git a/.github/workflows/github-release.yaml b/.github/workflows/github-release.yaml
index 0956dd4..dc04bf3 100644
--- a/.github/workflows/github-release.yaml
+++ b/.github/workflows/github-release.yaml
@@ -15,3 +15,24 @@ jobs:
contents: write
actions: write
secrets: inherit
+
+ verify-rootfs-assets:
+ name: Verify rootfs release assets
+ needs: release
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout merged source
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.merge_commit_sha }}
+
+ - name: Wait for and verify immutable rootfs assets
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: >-
+ ci/verify-rootfs-release.sh commit
+ "${{ github.event.pull_request.merge_commit_sha }}"
diff --git a/.github/workflows/publish-rootfs.yaml b/.github/workflows/publish-rootfs.yaml
index 2592899..533bb12 100644
--- a/.github/workflows/publish-rootfs.yaml
+++ b/.github/workflows/publish-rootfs.yaml
@@ -20,11 +20,17 @@ jobs:
ci/rootfs-package-contract.sh
ci/package-rootfs.sh dist
- - name: Upload rootfs asset and checksum
+ - name: Upload rootfs asset, archive checksum, and source contract
env:
GH_TOKEN: ${{ github.token }}
run: >-
gh release upload "$GITHUB_REF_NAME"
dist/cloud-compose-rootfs.tar.gz
dist/cloud-compose-rootfs.tar.gz.sha256
+ dist/cloud-compose-rootfs.contract.sha256
--clobber
+
+ - name: Verify published rootfs release assets
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: ci/verify-rootfs-release.sh tag "$GITHUB_REF_NAME"
diff --git a/Makefile b/Makefile
index cb7e0e5..0443768 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: lint lint-check actionlint shell-lint runtime-config-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract bootstrap-recovery-contract sitectl-version-contract go-fmt-check go-vet go-contracts template-version-contract rollout-parity-contract rootfs-package-contract host-runtime-security cos-jq-portability-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract artifact-install-contract config-management-smoke cloud-compose-ci
+.PHONY: lint lint-check actionlint shell-lint runtime-config-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract disaster-recovery-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract bootstrap-recovery-contract sitectl-version-contract go-fmt-check go-vet go-contracts template-version-contract rollout-parity-contract rootfs-package-contract host-runtime-security inline-data-program-contract cos-jq-portability-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract artifact-install-contract config-management-smoke cloud-compose-ci
.PHONY: terraform-fmt terraform-fmt-check terraform-validate terraform-validate-contract terraform-lint-check terraform-docs terraform-docs-check
.PHONY: config-management-cloud-smoke config-management-cloud-smoke-ansible-drupal config-management-cloud-smoke-salt-drupal
.PHONY: destroy-config-management-cloud-smoke destroy-config-management-cloud-smoke-ansible-drupal destroy-config-management-cloud-smoke-salt-drupal
@@ -16,9 +16,9 @@ export CLOUD_COMPOSE_CI_BIN
GO_MODULE_FILES := $(wildcard go.mod go.sum)
GO_SOURCES := $(shell find cmd internal -type f -name '*.go')
-lint: terraform-fmt actionlint shell-lint host-runtime-security cos-jq-portability-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract bootstrap-recovery-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract sitectl-version-contract template-version-contract rollout-parity-contract rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check
+lint: terraform-fmt actionlint shell-lint host-runtime-security inline-data-program-contract cos-jq-portability-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract disaster-recovery-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract bootstrap-recovery-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract sitectl-version-contract template-version-contract rollout-parity-contract rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check
-lint-check: terraform-fmt-check actionlint shell-lint host-runtime-security cos-jq-portability-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract bootstrap-recovery-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract sitectl-version-contract template-version-contract rollout-parity-contract rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check
+lint-check: terraform-fmt-check actionlint shell-lint host-runtime-security inline-data-program-contract cos-jq-portability-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract disaster-recovery-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract bootstrap-recovery-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract sitectl-version-contract template-version-contract rollout-parity-contract rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check
actionlint:
go run github.com/rhysd/actionlint/cmd/actionlint@$(ACTIONLINT_VERSION)
@@ -58,6 +58,9 @@ app-filesystem-convergence-contract:
backup-contract:
bash ci/backup-contract.sh
+disaster-recovery-contract:
+ bash ci/disaster-recovery-contract.sh
+
overlay-contract:
bash ci/overlay-contract.sh
@@ -102,6 +105,9 @@ rootfs-package-contract:
host-runtime-security:
bash ci/host-runtime-security.sh
+inline-data-program-contract:
+ bash ci/inline-data-program-contract.sh
+
cos-jq-portability-contract:
bash ci/cos-jq-portability-contract.sh
diff --git a/README.md b/README.md
index 6e8962a..4fc7397 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,15 @@ release set. Override individual selectors with
`runtime.sitectl.package_versions` when intentionally testing or promoting a
different compatible release set.
-All presets use their coordinated sitectl v1.0.0 core/plugin release set. The
-ISLE preset selects the `libops/isle` v1.1.0 template; every other application
-preset remains on its v1.0.0 template contract. Keep each preset's complete
-template and package set together when promoting an override.
+Renovate tracks both the Compose tags and sitectl release tags in the registry.
+Its pull requests are promotion candidates: keep the complete compatible set
+together and require the hosted application smoke before merging them.
+
+All presets pin sitectl v1.9.1 plus the latest reviewed application plugin.
+ISLE selects the `libops/isle` v1.3.1 template, ArchivesSpace selects v1.0.1,
+WordPress selects v1.1.1, and Drupal, OJS, Omeka Classic, and Omeka S select
+v1.2.1. Keep each preset's complete template and package set together when
+promoting an override.
GCP deployments have two Terraform ownership layers. Apply
[`modules/gcp-foundation`](modules/gcp-foundation/README.md) once per service
@@ -36,6 +41,14 @@ They must not recreate or destroy the singleton foundation. The
cover the state boundary, Shared VPC setup, and Cloud Run Direct VPC egress
requirements.
+Production operators can require the provider-neutral
+[`runtime.disaster_recovery`](docs/disaster-recovery.md) contract. Cloud Compose
+then hands complete database, application-file, and volume-topology coverage to
+a root-owned operator driver and accepts only an encrypted off-host receipt plus
+scheduled disposable restore proof. Storage credentials remain outside
+Terraform and the runtime environment. Same-disk logical dumps and provider
+snapshots are never presented as disaster recovery.
+
## Requirements
@@ -68,7 +81,7 @@ No resources.
| [name](#input\_name) | Deployment name. | `string` | n/a | yes |
| [cloud\_provider](#input\_cloud\_provider) | Compatibility selector for the root GCP entrypoint. Use providers/do or providers/linode for other clouds. | `string` | `"gcp"` | no |
| [gcp](#input\_gcp) | Google Cloud infrastructure settings. |
object({
project_id = optional(string, "")
project_number = optional(string, "")
region = optional(string, "us-east5")
zone = optional(string, "us-east5-b")
identity = optional(object({
vm_service_account_email = optional(string, "")
app_service_account_email = optional(string, "")
app_credentials_enabled = optional(bool, false)
}), {})
instance = optional(object({
machine_type = optional(string, "n4-standard-2")
os = optional(string, "cos-125-19216-220-185")
production = optional(bool, false)
}), {})
disks = optional(object({
type = optional(string, "hyperdisk-balanced")
data_size_gb = optional(number, 20)
docker_volumes_size_gb = optional(number, 50)
}), {})
network = optional(object({
create = optional(bool, true)
project_id = optional(string, "")
name = optional(string, "")
subnetwork = optional(string, "")
ip_cidr_range = optional(string, "10.42.0.0/24")
mtu = optional(number, 1460)
power_button_allowed_ips = optional(list(string), [])
power_button_ip_depth = optional(number)
ssh_ipv4 = optional(list(string), [])
ssh_ipv6 = optional(list(string), [])
}), {})
snapshots = optional(object({
enabled = optional(bool, true)
}), {})
overlay = optional(object({
source_instance = optional(string, "")
volume_names = optional(list(string), [])
}), {})
cloud_init = optional(object({
initcmd = optional(list(string), [])
runcmd = optional(list(string), [])
}), {})
artifact_registry = optional(object({
repository = optional(string, "")
location = optional(string, "us")
}), {})
power_management = optional(object({
enabled = optional(bool, false)
start_role = optional(string, "")
suspend_role = optional(string, "")
frontend = optional(object({
image = string
port = optional(number, 8080)
cpu = optional(string, "1000m")
memory = optional(string, "1Gi")
}), null)
}), {})
rollout = optional(object({
enabled = optional(bool, false)
release_url = optional(string, "")
release_sha256 = optional(string, "")
port = optional(number, 8081)
jwks_uri = optional(string, "")
jwt_audience = optional(string, "")
custom_claims = optional(string, "")
allowed_ipv4 = optional(list(string), ["10.0.0.0/8"])
}), {})
}) | `{}` | no |
-| [runtime](#input\_runtime) | Provider-neutral compose/runtime settings. | object({
rootfs = optional(string, "")
rootfs_archive_url = optional(string, "")
rootfs_archive_sha256 = optional(string, "")
users = optional(map(list(string)), {})
compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
ingress = optional(object({
letsencrypt = optional(bool, false)
bot_mitigation = optional(bool, false)
mode = optional(string, "")
domain = optional(string, "")
acme_email = optional(string, "")
trusted_ips = optional(list(string), [])
max_upload_size = optional(string, "")
upload_timeout = optional(string, "")
}), {})
repo = optional(string, "")
branch = optional(string, "")
projects = optional(map(object({
docker_compose_repo = string
docker_compose_branch = optional(string)
project_dir = optional(string)
compose_project_name = optional(string)
ingress_port = optional(number)
ingress = optional(object({
letsencrypt = optional(bool)
bot_mitigation = optional(bool)
mode = optional(string)
domain = optional(string)
acme_email = optional(string)
trusted_ips = optional(list(string))
max_upload_size = optional(string)
upload_timeout = optional(string)
}), {})
sitectl_context_name = optional(string)
sitectl_plugin = optional(string)
sitectl_environment = optional(string)
sitectl_packages = optional(list(string))
sitectl_verify_args = optional(list(string))
docker_compose_init = optional(list(string))
docker_compose_up = optional(list(string))
docker_compose_down = optional(list(string))
docker_compose_rollout = optional(list(string))
})), {})
init = optional(list(string))
up = optional(list(string))
down = optional(list(string))
rollout = optional(list(string))
}), {})
sitectl = optional(object({
packages = optional(list(string))
version = optional(string, "latest")
package_versions = optional(map(string), {})
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
verify_args = optional(list(string), [])
}), {})
docker = optional(object({
# renovate: datasource=github-releases depName=docker-compose packageName=docker/compose versioning=semver
compose_version = optional(string, "v5.3.1")
# renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver
buildx_version = optional(string, "v0.35.0")
}), {})
managed_runtime = optional(object({
enabled = optional(bool, true)
internal_services_enabled = optional(bool, false)
internal_services_auto_update = optional(bool, false)
artifacts = optional(list(object({
name = string
url = string
sha256 = string
path = string
mode = optional(string, "0755")
owner = optional(string, "root")
group = optional(string, "root")
restart = optional(string, "")
})), [])
}), {})
vault = optional(object({
addr = optional(string, "")
namespace = optional(string, "")
role = optional(string, "")
agent_enabled = optional(bool, false)
auth_method = optional(string, "auto")
gcp_auth_mount_path = optional(string, "auth/gcp")
agent_token_path = optional(string, "/mnt/disks/data/vault/token")
agent_additional_config = optional(string, "")
agent_templates = optional(list(object({
destination = string
contents = string
perms = optional(string, "0640")
command = optional(string, "")
})), [])
}), {})
extra_env = optional(map(string), {})
}) | `{}` | no |
+| [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)), {})
disaster_recovery = optional(object({
required = optional(bool, false)
driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
}), {})
compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
ingress = optional(object({
letsencrypt = optional(bool, false)
bot_mitigation = optional(bool, false)
mode = optional(string, "")
domain = optional(string, "")
acme_email = optional(string, "")
trusted_ips = optional(list(string), [])
max_upload_size = optional(string, "")
upload_timeout = optional(string, "")
}), {})
repo = optional(string, "")
branch = optional(string, "")
projects = optional(map(object({
docker_compose_repo = string
docker_compose_branch = optional(string)
project_dir = optional(string)
compose_project_name = optional(string)
ingress_port = optional(number)
ingress = optional(object({
letsencrypt = optional(bool)
bot_mitigation = optional(bool)
mode = optional(string)
domain = optional(string)
acme_email = optional(string)
trusted_ips = optional(list(string))
max_upload_size = optional(string)
upload_timeout = optional(string)
}), {})
sitectl_context_name = optional(string)
sitectl_plugin = optional(string)
sitectl_environment = optional(string)
sitectl_packages = optional(list(string))
sitectl_verify_args = optional(list(string))
docker_compose_init = optional(list(string))
docker_compose_up = optional(list(string))
docker_compose_down = optional(list(string))
docker_compose_rollout = optional(list(string))
})), {})
init = optional(list(string))
up = optional(list(string))
down = optional(list(string))
rollout = optional(list(string))
}), {})
sitectl = optional(object({
packages = optional(list(string))
version = optional(string, "latest")
package_versions = optional(map(string), {})
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
verify_args = optional(list(string), [])
}), {})
docker = optional(object({
# renovate: datasource=github-releases depName=docker-compose packageName=docker/compose versioning=semver
compose_version = optional(string, "v5.3.1")
# renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver
buildx_version = optional(string, "v0.35.0")
}), {})
managed_runtime = optional(object({
enabled = optional(bool, true)
internal_services_enabled = optional(bool, false)
internal_services_auto_update = optional(bool, false)
artifacts = optional(list(object({
name = string
url = string
sha256 = string
path = string
mode = optional(string, "0755")
owner = optional(string, "root")
group = optional(string, "root")
restart = optional(string, "")
})), [])
}), {})
vault = optional(object({
addr = optional(string, "")
namespace = optional(string, "")
role = optional(string, "")
agent_enabled = optional(bool, false)
auth_method = optional(string, "auto")
gcp_auth_mount_path = optional(string, "auth/gcp")
agent_token_path = optional(string, "/mnt/disks/data/vault/token")
agent_additional_config = optional(string, "")
agent_templates = optional(list(object({
destination = string
contents = string
perms = optional(string, "0640")
command = optional(string, "")
})), [])
}), {})
extra_env = optional(map(string), {})
}) | `{}` | no |
| [template](#input\_template) | Optional compose template preset. Supported values are archivesspace, ojs, isle, drupal, wp, omeka-s, and omeka-classic. Explicit runtime settings override preset defaults. | `string` | `""` | no |
## Outputs
diff --git a/ansible/README.md b/ansible/README.md
index b849020..4051c02 100644
--- a/ansible/README.md
+++ b/ansible/README.md
@@ -62,6 +62,27 @@ is meaningful: it disables that phase and is not replaced by the default.
Runtime feature switches must be YAML booleans, not quoted strings; ambiguous
values are rejected before host mutation.
+Lifecycle list entries are program selectors, not shell source. Use the
+built-in `/home/cloud-compose/default-lifecycle.sh ACTION`, `true`, `false`, or
+one argument-free root-owned executable immediately below
+`/etc/cloud-compose/lifecycle.d`. Put multi-step logic and quoting inside that
+checked program file.
+
+The provider-neutral disaster-recovery interface is shared with Terraform:
+
+```yaml
+cloud_compose_runtime:
+ disaster_recovery:
+ required: true
+ driver_path: /etc/cloud-compose/libexec/offhost-backup-driver
+```
+
+Install that executable and its credentials separately as root. The role
+renders only the boolean requirement and executable path; storage endpoints,
+credentials, encryption keys, and retention policy must not be placed in
+inventory. See [the driver contract](../docs/disaster-recovery.md) for receipt
+and scheduled restore-proof requirements.
+
Set `cloud_compose_runtime.rollout` to enable the same authenticated rollout
listener used by Terraform. Supply a pinned HTTPS `release_url`, its lowercase
`release_sha256`, an HTTPS `jwks_uri`, `jwt_audience`, and optional JSON-object
@@ -73,7 +94,8 @@ The role installs lifecycle dispatchers as `root:cloud-compose` mode `0750` and
the root-consumed `.env`, project/application JSON, and managed-artifact
manifest as `root:cloud-compose` mode `0640`. Reapplying the role restores that
ownership boundary while leaving app checkout directories writable by the
-`cloud-compose` account.
+`cloud-compose` account. Before a requested runtime bootstrap, the role invokes
+the same checked-in bootstrap path hardener used by the Terraform modules.
The normal on-prem shape is one app per machine. Put each machine in the
`cloud_compose` inventory group and set that host's template/runtime variables.
@@ -100,7 +122,7 @@ all:
sitectl:
environment: production
package_versions:
- sitectl-isle: v1.0.0
+ sitectl-isle: v1.5.0
wp-prod.example.edu:
ansible_user: debian
cloud_compose_name: wp-prod
diff --git a/ansible/roles/cloud_compose/defaults/main.yml b/ansible/roles/cloud_compose/defaults/main.yml
index 4dff754..716153d 100644
--- a/ansible/roles/cloud_compose/defaults/main.yml
+++ b/ansible/roles/cloud_compose/defaults/main.yml
@@ -29,6 +29,7 @@ cloud_compose_force_bootstrap: false
cloud_compose_dedicated_host_acknowledged: false
cloud_compose_bootstrap_timeout: 14400
cloud_compose_bootstrap_poll_interval: 15
+cloud_compose_bootstrap_wait_seconds: 10800
cloud_compose_extra_env: {}
cloud_compose_runtime: {}
@@ -53,15 +54,10 @@ cloud_compose_default_ingress:
upload_timeout: ""
cloud_compose_default_init:
- - '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'
+ - '/home/cloud-compose/default-lifecycle.sh init'
cloud_compose_default_up:
- - 'sitectl compose --context "${SITECTL_CONTEXT_NAME}" up -d --remove-orphans'
- - 'sitectl healthcheck --context "${SITECTL_CONTEXT_NAME}" --persist'
- - 'if [ "${SITECTL_ENVIRONMENT}" != "production" ]; then sitectl verify --context "${SITECTL_CONTEXT_NAME}" ${SITECTL_VERIFY_ARGS:-}; fi'
+ - '/home/cloud-compose/default-lifecycle.sh up'
cloud_compose_default_down:
- - 'sitectl compose --context "${SITECTL_CONTEXT_NAME}" down'
+ - '/home/cloud-compose/default-lifecycle.sh down'
cloud_compose_default_rollout:
- - 'TARGET_REF="${GIT_REF:-${GIT_BRANCH:-}}"'
- - 'if [ -n "$TARGET_REF" ]; then sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --ref "$TARGET_REF"; else sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --skip-git; fi'
- - 'sitectl healthcheck --context "${SITECTL_CONTEXT_NAME}" --persist'
- - 'if [ "${SITECTL_ENVIRONMENT}" != "production" ]; then sitectl verify --context "${SITECTL_CONTEXT_NAME}" ${SITECTL_VERIFY_ARGS:-}; fi'
+ - '/home/cloud-compose/default-lifecycle.sh rollout'
diff --git a/ansible/roles/cloud_compose/tasks/main.yml b/ansible/roles/cloud_compose/tasks/main.yml
index 7057042..d4ce17e 100644
--- a/ansible/roles/cloud_compose/tasks/main.yml
+++ b/ansible/roles/cloud_compose/tasks/main.yml
@@ -33,7 +33,13 @@
- cloud_compose_managed_runtime_enabled is boolean
- cloud_compose_internal_services_enabled is boolean
- cloud_compose_internal_services_auto_update is boolean
- fail_msg: "The adapter requires an onprem provider, a runtime/extra_env map, a name matching ^[a-z][a-z0-9-]*$, and cloud_compose_dedicated_host_acknowledged=true before it can own Docker and host runtime configuration."
+ - cloud_compose_bootstrap_wait_seconds is number
+ - cloud_compose_bootstrap_wait_seconds is not boolean
+ - (cloud_compose_bootstrap_wait_seconds | int) == cloud_compose_bootstrap_wait_seconds
+ - (cloud_compose_bootstrap_wait_seconds | int) >= 1
+ - (cloud_compose_bootstrap_wait_seconds | int) <= 43200
+ - (cloud_compose_bootstrap_timeout | int) > (cloud_compose_bootstrap_wait_seconds | int)
+ fail_msg: "The adapter requires an onprem provider, a runtime/extra_env map, a name matching ^[a-z][a-z0-9-]*$, cloud_compose_dedicated_host_acknowledged=true, and a bootstrap async timeout longer than the 1-43200 second service wait."
- name: Require safe cloud-compose environment entries
ansible.builtin.assert:
@@ -55,6 +61,7 @@
_cc_sitectl: "{{ cloud_compose_runtime.sitectl | default({}) }}"
_cc_managed: "{{ cloud_compose_runtime.managed_runtime | default({}) }}"
_cc_vault: "{{ cloud_compose_runtime.vault | default({}) }}"
+ _cc_disaster_recovery: "{{ cloud_compose_runtime.disaster_recovery | default({}) }}"
_cc_rollout_service: "{{ cloud_compose_default_rollout_service | combine(cloud_compose_runtime.rollout | default({}), recursive=True) }}"
_cc_extra_env: "{{ cloud_compose_runtime.extra_env | default(cloud_compose_extra_env) }}"
@@ -65,6 +72,7 @@
- _cc_sitectl is mapping
- _cc_managed is mapping
- _cc_vault is mapping
+ - _cc_disaster_recovery is mapping
- (cloud_compose_runtime.rollout | default({})) is mapping
- (_cc_compose.ingress | default({})) is mapping
- (_cc_compose.projects | default({})) is mapping
@@ -79,7 +87,12 @@
- (_cc_managed.internal_services_auto_update | default(cloud_compose_internal_services_auto_update)) is boolean
- (_cc_vault.agent_enabled | default(false)) is boolean
- not (_cc_vault.agent_enabled | default(false))
- fail_msg: "runtime sections must be maps, runtime feature switches must be booleans, template must name a supported app, and Vault Agent is currently supported only by Terraform providers (set vault.agent_enabled=false for Ansible)."
+ - (_cc_disaster_recovery.required | default(false)) is boolean
+ - (_cc_disaster_recovery.driver_path | default('/etc/cloud-compose/libexec/offhost-backup-driver')) is string
+ - (_cc_disaster_recovery.driver_path | default('/etc/cloud-compose/libexec/offhost-backup-driver')) is match('^/[A-Za-z0-9._/+:-]+$')
+ - "'//' not in (_cc_disaster_recovery.driver_path | default('/etc/cloud-compose/libexec/offhost-backup-driver'))"
+ - (_cc_disaster_recovery.driver_path | default('/etc/cloud-compose/libexec/offhost-backup-driver') | regex_search('(^|/)\\.\\.?(/|$)')) is none
+ fail_msg: "runtime sections must be maps, runtime feature switches must be booleans, template must name a supported app, Vault Agent is currently supported only by Terraform providers (set vault.agent_enabled=false for Ansible), and disaster_recovery.driver_path must be a safe absolute path without whitespace or dot segments."
- name: Require safe rollout service settings
ansible.builtin.assert:
@@ -136,6 +149,8 @@
_cc_down_commands: "{{ _cc_compose.down | default(cloud_compose_default_down) }}"
_cc_rollout_commands: "{{ _cc_compose.rollout | default(cloud_compose_default_rollout) }}"
_cc_internal_services_enabled: "{{ _cc_managed.internal_services_enabled | default(cloud_compose_internal_services_enabled) }}"
+ _cc_offhost_backup_required: "{{ _cc_disaster_recovery.required | default(false) }}"
+ _cc_offhost_backup_driver: "{{ _cc_disaster_recovery.driver_path | default('/etc/cloud-compose/libexec/offhost-backup-driver') }}"
- name: Reject GCP-only internal services on an on-prem host
ansible.builtin.assert:
@@ -398,6 +413,8 @@
CLOUD_COMPOSE_INSTANCE_NAME: "{{ cloud_compose_name }}"
CLOUD_COMPOSE_APPS: "{{ _cc_compose_projects.keys() | list | join(' ') }}"
CLOUD_COMPOSE_PRIMARY_APP: "{{ _cc_primary_key }}"
+ CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED: "{{ _cc_offhost_backup_required | ternary('true', 'false') }}"
+ CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER: "{{ _cc_offhost_backup_driver }}"
COMPOSE_PROJECTS_FILE: "{{ cloud_compose_home }}/compose-projects.json"
COMPOSE_PROJECT_NAME: "{{ _cc_primary_project.compose_project_name }}"
COMPOSE_BIND_PORT: "{{ _cc_primary_project.ingress_port }}"
@@ -503,6 +520,21 @@
dest: /
mode: preserve
+- name: Secure Cloud Compose privileged program directories
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: directory
+ owner: root
+ group: root
+ mode: "0755"
+ loop:
+ - "{{ cloud_compose_home }}"
+ - /etc/cloud-compose
+ - /etc/cloud-compose/awk
+ - /etc/cloud-compose/bin
+ - /etc/cloud-compose/jq
+ - /etc/cloud-compose/libexec
+
- name: Inspect cloud-compose lifecycle lock directory
ansible.builtin.stat:
path: /run/lock/cloud-compose
@@ -532,33 +564,73 @@
(cloud_compose_lifecycle_lock_file.stat.mode | default('')) == '0660'
)
-- name: Find cloud-compose shell scripts
+- name: Find top-level cloud-compose control scripts
ansible.builtin.find:
- paths: "{{ cloud_compose_home }}"
+ paths:
+ - "{{ cloud_compose_home }}"
+ - /etc/cloud-compose/bin
+ - /etc/cloud-compose/libexec
patterns: "*.sh"
- recurse: true
+ recurse: false
file_type: file
register: cloud_compose_shell_scripts
-- name: Make cloud-compose shell scripts executable
+- name: Make cloud-compose control scripts root-owned and executable
ansible.builtin.file:
path: "{{ item.path }}"
+ owner: root
+ group: root
mode: "0755"
loop: "{{ cloud_compose_shell_scripts.files }}"
+- name: Make checked Cloud Compose program resolver root-controlled
+ ansible.builtin.file:
+ path: /etc/cloud-compose/libexec/checked-programs.bash
+ state: file
+ owner: root
+ group: root
+ mode: "0644"
+
+- name: Find checked-in Cloud Compose jq programs
+ ansible.builtin.find:
+ paths: /etc/cloud-compose/jq
+ patterns: "*.jq"
+ recurse: false
+ file_type: file
+ register: cloud_compose_jq_programs
+
+- name: Make checked-in Cloud Compose jq programs root-controlled
+ ansible.builtin.file:
+ path: "{{ item.path }}"
+ owner: root
+ group: root
+ mode: "0644"
+ loop: "{{ cloud_compose_jq_programs.files }}"
+
+- name: Find checked-in Cloud Compose awk programs
+ ansible.builtin.find:
+ paths: /etc/cloud-compose/awk
+ patterns: "*.awk"
+ recurse: false
+ file_type: file
+ register: cloud_compose_awk_programs
+
+- name: Make checked-in Cloud Compose awk programs root-controlled
+ ansible.builtin.file:
+ path: "{{ item.path }}"
+ owner: root
+ group: root
+ mode: "0644"
+ loop: "{{ cloud_compose_awk_programs.files }}"
+
- name: Write cloud-compose lifecycle dispatchers
ansible.builtin.copy:
+ src: /home/cloud-compose/lifecycle-entrypoint.sh
dest: "{{ cloud_compose_home }}/{{ item }}"
+ remote_src: true
owner: root
group: "{{ cloud_compose_group }}"
mode: "0750"
- content: |
- #!/usr/bin/env bash
-
- set -eou pipefail
-
- source /home/cloud-compose/profile.sh
- exec bash /home/cloud-compose/compose-dispatch.sh "{{ item }}"
loop:
- init
- up
@@ -599,6 +671,11 @@
mode: "0640"
no_log: true
+- name: Harden trusted cloud-compose bootstrap paths
+ ansible.builtin.command:
+ cmd: /etc/cloud-compose/libexec/harden-bootstrap-paths.sh
+ when: cloud_compose_run_bootstrap | bool
+
- name: Reload systemd units
ansible.builtin.systemd:
daemon_reload: true
@@ -606,19 +683,30 @@
- name: Install and start authenticated rollout service
ansible.builtin.command:
- cmd: bash "{{ cloud_compose_home }}/deploy-rollout.sh"
+ cmd: bash /etc/cloud-compose/libexec/run-root-program.sh deploy-rollout.sh
when: _cc_rollout_service.enabled | bool
- name: Clear cloud-compose bootstrap marker
ansible.builtin.file:
- path: "{{ cloud_compose_home }}/.cloud-compose-bootstrap-complete"
+ path: /var/lib/cloud-compose/bootstrap-complete
state: absent
when: cloud_compose_force_bootstrap | bool
+- name: Inspect validated cloud-compose bootstrap readiness
+ ansible.builtin.command:
+ cmd: bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh
+ register: cloud_compose_bootstrap_readiness
+ changed_when: false
+ failed_when: false
+ when: cloud_compose_run_bootstrap | bool
+
- name: Bootstrap cloud-compose runtime
ansible.builtin.command:
- cmd: bash "{{ cloud_compose_home }}/start-cloud-compose-bootstrap.sh"
- creates: "{{ cloud_compose_home }}/.cloud-compose-bootstrap-complete"
+ cmd: bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
async: "{{ cloud_compose_bootstrap_timeout }}"
poll: "{{ cloud_compose_bootstrap_poll_interval }}"
- when: cloud_compose_run_bootstrap | bool
+ environment:
+ CLOUD_COMPOSE_BOOTSTRAP_WAIT_SECONDS: "{{ cloud_compose_bootstrap_wait_seconds | int }}"
+ when:
+ - cloud_compose_run_bootstrap | bool
+ - (cloud_compose_bootstrap_readiness.rc | default(1)) != 0
diff --git a/ci/app-filesystem-convergence-contract.sh b/ci/app-filesystem-convergence-contract.sh
index cc6a5f1..494e7de 100755
--- a/ci/app-filesystem-convergence-contract.sh
+++ b/ci/app-filesystem-convergence-contract.sh
@@ -3,6 +3,7 @@
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
diff --git a/ci/application-env-contract.sh b/ci/application-env-contract.sh
index 303575f..118d629 100755
--- a/ci/application-env-contract.sh
+++ b/ci/application-env-contract.sh
@@ -18,6 +18,7 @@ require_cmd base64
require_cmd jq
profile="$repo_root/rootfs/home/cloud-compose/profile.sh"
+jq_program_dir="$repo_root/rootfs/etc/cloud-compose/jq"
rollout_service="$repo_root/rootfs/home/cloud-compose/run-rollout-service.sh"
host_env="$tmp/host.env"
application_env="$tmp/application-env.json"
@@ -69,6 +70,7 @@ done
# shellcheck disable=SC2016
env -i \
PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$jq_program_dir" \
CLOUD_COMPOSE_ENV_FILE="$host_env" \
CLOUD_COMPOSE_APPLICATION_ENV_FILE="$application_env" \
bash --noprofile --norc -c '
@@ -138,7 +140,8 @@ for invalid_application_data in \
'{"SAFE\n":"value"}' \
'{"SAFE\u0000":"value"}'; do
printf '%s' "$invalid_application_data" >"$tmp/application-env.invalid.json"
- if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$host_env" \
+ if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_JQ_PROGRAM_DIR="$jq_program_dir" \
+ CLOUD_COMPOSE_ENV_FILE="$host_env" \
CLOUD_COMPOSE_APPLICATION_ENV_FILE="$tmp/application-env.invalid.json" \
bash --noprofile --norc -c '
source "$1"
@@ -155,7 +158,8 @@ done
jq 'del(.APPLICATION_LITERAL)' "$application_env" >"$tmp/application-env.next.json"
# The child shell receives file paths as positional parameters.
# shellcheck disable=SC2016
-env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$host_env" \
+env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_JQ_PROGRAM_DIR="$jq_program_dir" \
+ CLOUD_COMPOSE_ENV_FILE="$host_env" \
CLOUD_COMPOSE_APPLICATION_ENV_FILE="$tmp/application-env.next.json" \
bash --noprofile --norc -c '
source "$1"
diff --git a/ci/bootstrap-recovery-contract.sh b/ci/bootstrap-recovery-contract.sh
index e37e1ba..54712bb 100644
--- a/ci/bootstrap-recovery-contract.sh
+++ b/ci/bootstrap-recovery-contract.sh
@@ -9,6 +9,11 @@ start_bootstrap="$repo_root/rootfs/home/cloud-compose/start-cloud-compose-bootst
run_script="$repo_root/rootfs/home/cloud-compose/run.sh"
app_init="$repo_root/rootfs/home/cloud-compose/app-init.sh"
run_bootstrap="$repo_root/rootfs/home/cloud-compose/run-bootstrap.sh"
+bootstrap_security="$repo_root/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh"
+bootstrap_required="$repo_root/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh"
+bootstrap_entrypoint="$repo_root/rootfs/etc/cloud-compose/libexec/run-bootstrap.sh"
+bootstrap_start_entrypoint="$repo_root/rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh"
+bootstrap_readiness_gate="$repo_root/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh"
bootstrap_unit="$repo_root/rootfs/etc/systemd/system/cloud-compose-bootstrap.service"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
@@ -182,10 +187,14 @@ fi
assert_contains "$app_init" 'acquire_cloud_compose_lifecycle_lock "init"'
assert_contains "$app_init" 'trap release_cloud_compose_lifecycle_lock EXIT'
assert_contains "$run_script" 'if cloud_compose_should_run_app_init'
+assert_contains "$run_script" 'durable_bootstrap_marker="/var/lib/cloud-compose/bootstrap-complete"'
assert_contains "$run_script" 'cloud_compose_publish_marker "$current_boot_app_init_marker"'
assert_contains "$run_script" 'cloud_compose_start_and_wait_for_oneshot cloud-compose.service "$app_wait_seconds"'
assert_contains "$run_script" '"$fresh_filesystem_marker" "$fresh_filesystem_identity"'
assert_contains "$run_script" 'cloud_compose_publish_marker "$durable_bootstrap_marker"'
+assert_contains "$helpers" 'if ((EUID == 0)) && ! chown 0:0 "$tmp_marker"; then'
+assert_contains "$helpers" '0:0:644:1:regular file'
+assert_contains "$helpers" '0:0:755:directory'
[[ "$(grep -Fc 'cloud_compose_consume_fresh_filesystem_marker \' "$run_script")" == "1" ]] ||
fail "bootstrap must have one fresh-filesystem authority consumption boundary"
rotation_line="$(grep -nF 'bash /home/cloud-compose/rotate-keys-daily.sh' "$run_script" | cut -d: -f1)"
@@ -209,6 +218,17 @@ durable_marker_line="$(grep -nF 'cloud_compose_publish_marker "$durable_bootstra
assert_contains "$run_bootstrap" 'if ((EUID != 0)); then'
assert_contains "$run_bootstrap" 'exec bash /home/cloud-compose/run.sh'
+assert_contains "$bootstrap_entrypoint" 'cloud_compose_secure_runtime_home'
+assert_contains "$bootstrap_entrypoint" 'exec /bin/bash /home/cloud-compose/run.sh'
+assert_contains "$bootstrap_start_entrypoint" 'cloud_compose_secure_runtime_home'
+assert_contains "$bootstrap_required" 'cloud_compose_bootstrap_marker_ready'
+assert_contains "$bootstrap_readiness_gate" 'cloud_compose_bootstrap_marker_ready'
+assert_contains "$bootstrap_security" '0:0:644:1:regular file'
+assert_contains "$bootstrap_security" '"$marker_size" == "6"'
+assert_contains "$bootstrap_security" '"$payload" == "ready"'
+assert_contains "$bootstrap_security" 'Cloud Compose control input is not root-controlled'
+assert_contains "$bootstrap_security" 'Cloud Compose lifecycle dispatcher is not root-controlled'
+assert_contains "$bootstrap_security" '"$CLOUD_COMPOSE_RUNTIME_HOME/default-lifecycle.sh"'
if rg -n 'bootstrap\\.log|exec (>>|>)[^[:space:]]' "$run_bootstrap" >/dev/null; then
fail "bootstrap wrapper writes an independently unbounded log file"
fi
@@ -245,6 +265,7 @@ done
fail "published sitectl is not executable after first-boot installation"
assert_contains "$start_bootstrap" 'CLOUD_COMPOSE_BOOTSTRAP_WAIT_SECONDS:-10800'
+assert_contains "$start_bootstrap" 'CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER:-/var/lib/cloud-compose/bootstrap-complete'
assert_contains "$start_bootstrap" 'if cloud_compose_marker_exists "$durable_marker"; then'
assert_contains "$start_bootstrap" 'systemctl daemon-reload'
assert_contains "$start_bootstrap" 'systemctl stop -- "$bootstrap_unit"'
@@ -254,15 +275,19 @@ if rg -n '_SYSTEMD_UNIT=cloud-compose-bootstrap\\.service' \
fail "raw bootstrap output was added to Fluent Bit"
fi
-for cloud_init_template in \
- "$repo_root/templates/cloud-init.yml" \
- "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml"; do
- assert_contains "$cloud_init_template" 'bash /home/cloud-compose/start-cloud-compose-bootstrap.sh'
+for cloud_init_program in \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh" \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh"; do
+ assert_contains "$cloud_init_program" 'bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh'
if grep -Fq 'bash /home/cloud-compose/run.sh > /home/cloud-compose/run.log 2>&1' \
- "$cloud_init_template"; then
- fail "cloud-init bypasses the retryable bootstrap unit"
+ "$cloud_init_program"; then
+ fail "$cloud_init_program bypasses the retryable bootstrap unit"
fi
done
+assert_contains "$repo_root/templates/cloud-init.yml" \
+ '/var/lib/cloud-compose/bootstrap/gcp-cloud-init-finalize.sh'
+assert_contains "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml" \
+ '/var/lib/cloud-compose/bootstrap/linux-vm-cloud-init.sh'
: >"$systemctl_log"
printf '0\n' >"$active_calls"
@@ -304,6 +329,8 @@ rm -f -- "$durable_marker"
integration_root="$tmp/integration"
integration_home="$integration_root/home/cloud-compose"
integration_run="$integration_root/run"
+integration_state="$integration_root/var/lib/cloud-compose"
+integration_durable_marker="$integration_state/bootstrap-complete"
integration_bin="$integration_root/bin"
integration_log="$integration_root/systemctl.log"
app_init_count="$integration_root/app-init-count"
@@ -311,7 +338,7 @@ fresh_data_root="$integration_root/data"
fresh_marker="$fresh_data_root/.cloud-compose/fresh-filesystem"
first_output="$integration_root/first-attempt.log"
retry_output="$integration_root/retry-attempt.log"
-mkdir -p "$integration_home" "$integration_run" "$integration_bin" "$(dirname -- "$fresh_marker")"
+mkdir -p "$integration_home" "$integration_run" "$integration_state" "$integration_bin" "$(dirname -- "$fresh_marker")"
: >"$integration_log"
printf '0\n' >"$app_init_count"
printf 'fresh\n' >"$fresh_marker"
@@ -319,6 +346,7 @@ printf 'fresh\n' >"$fresh_marker"
sed \
-e "s#/home/cloud-compose#$integration_home#g" \
-e "s#/run/cloud-compose-app-init-complete#$integration_run/app-init-complete#g" \
+ -e "s#/var/lib/cloud-compose#$integration_state#g" \
"$run_script" >"$integration_home/run.sh"
cp "$helpers" "$integration_home/bootstrap-helpers.sh"
cat >"$integration_home/profile.sh" <<'EOF'
@@ -453,7 +481,7 @@ fi
fail "first full bootstrap attempt did not complete app-init exactly once"
[[ -f "$integration_run/app-init-complete" ]] ||
fail "failed first attempt did not retain current-boot app-init readiness"
-[[ ! -e "$integration_home/.cloud-compose-bootstrap-complete" ]] ||
+[[ ! -e "$integration_durable_marker" ]] ||
fail "failed first attempt published durable bootstrap readiness"
[[ ! -e "$fresh_marker" ]] ||
fail "failed first attempt retained fresh-filesystem authority past key convergence"
@@ -462,7 +490,7 @@ env "${integration_env[@]}" RUN_ATTEMPT=recover \
bash "$integration_home/run.sh" >"$retry_output" 2>&1
[[ "$(<"$app_init_count")" == "1" ]] ||
fail "bootstrap retry repeated successful app-init"
-[[ -f "$integration_home/.cloud-compose-bootstrap-complete" ]] ||
+[[ -f "$integration_durable_marker" ]] ||
fail "bootstrap retry did not publish durable readiness"
[[ ! -e "$fresh_marker" ]] ||
fail "successful bootstrap retry retained fresh-filesystem reconciliation authority"
@@ -474,7 +502,7 @@ grep -Fq 'recover enable -- cloud-compose.service' "$integration_log" ||
fail "bootstrap retry did not converge the application service"
# Marker removal must be flushed before durable readiness can be republished.
-rm -f -- "$integration_home/.cloud-compose-bootstrap-complete"
+rm -f -- "$integration_durable_marker"
printf 'fresh\n' >"$fresh_marker"
if env "${integration_env[@]}" RUN_ATTEMPT=sync-fail \
bash "$integration_home/run.sh" >/dev/null 2>&1; then
@@ -482,16 +510,16 @@ if env "${integration_env[@]}" RUN_ATTEMPT=sync-fail \
fi
[[ ! -e "$fresh_marker" ]] ||
fail "post-consume durability coverage did not remove the fresh marker"
-[[ ! -e "$integration_home/.cloud-compose-bootstrap-complete" ]] ||
+[[ ! -e "$integration_durable_marker" ]] ||
fail "failed post-consume durability barrier published readiness"
env "${integration_env[@]}" RUN_ATTEMPT=recover \
bash "$integration_home/run.sh" >/dev/null 2>&1
-[[ -f "$integration_home/.cloud-compose-bootstrap-complete" ]] ||
+[[ -f "$integration_durable_marker" ]] ||
fail "bootstrap retry did not flush an already-absent marker before readiness"
# GCP never falls back to the generic non-GCP marker identity. This check runs
# before key rotation, so missing disk identity cannot reach IAM.
-rm -f -- "$integration_home/.cloud-compose-bootstrap-complete"
+rm -f -- "$integration_durable_marker"
printf 'fresh\n' >"$fresh_marker"
if env "${integration_env[@]}" CLOUD_COMPOSE_PROVIDER=gcp RUN_ATTEMPT=recover \
bash "$integration_home/run.sh" >/dev/null 2>&1; then
@@ -499,12 +527,12 @@ if env "${integration_env[@]}" CLOUD_COMPOSE_PROVIDER=gcp RUN_ATTEMPT=recover \
fi
[[ -f "$fresh_marker" ]] ||
fail "GCP bootstrap consumed generic fresh-filesystem authority"
-[[ ! -e "$integration_home/.cloud-compose-bootstrap-complete" ]] ||
+[[ ! -e "$integration_durable_marker" ]] ||
fail "GCP bootstrap with generic authority published durable readiness"
# A marker payload for another incarnation must fail before application
# initialization or durable readiness.
-rm -f -- "$integration_home/.cloud-compose-bootstrap-complete"
+rm -f -- "$integration_durable_marker"
printf 'v1:gcp-disk-id:111111111111111111\n' >"$fresh_marker"
if env "${integration_env[@]}" RUN_ATTEMPT=recover \
bash "$integration_home/run.sh" >/dev/null 2>&1; then
@@ -512,13 +540,13 @@ if env "${integration_env[@]}" RUN_ATTEMPT=recover \
fi
[[ -f "$fresh_marker" ]] ||
fail "mismatched fresh-filesystem marker payload was consumed"
-[[ ! -e "$integration_home/.cloud-compose-bootstrap-complete" ]] ||
+[[ ! -e "$integration_durable_marker" ]] ||
fail "mismatched fresh-filesystem marker payload published durable readiness"
# Unsafe authority must fail closed before durable readiness. The current-boot
# app-init marker remains valid, so these attempts exercise only the early
# marker boundary rather than repeating application initialization.
-rm -f -- "$integration_home/.cloud-compose-bootstrap-complete"
+rm -f -- "$integration_durable_marker"
printf 'fresh\n' >"$fresh_marker"
if env "${integration_env[@]}" RUN_ATTEMPT=recover \
FRESH_MARKER_IDENTITY=1000:1000:600:1 \
@@ -527,7 +555,7 @@ if env "${integration_env[@]}" RUN_ATTEMPT=recover \
fi
[[ -f "$fresh_marker" ]] ||
fail "unsafe fresh-filesystem marker was consumed"
-[[ ! -e "$integration_home/.cloud-compose-bootstrap-complete" ]] ||
+[[ ! -e "$integration_durable_marker" ]] ||
fail "unsafe fresh-filesystem marker published durable readiness"
rm -f -- "$fresh_marker"
@@ -538,7 +566,7 @@ if env "${integration_env[@]}" RUN_ATTEMPT=recover \
fi
[[ -L "$fresh_marker" ]] ||
fail "symlink fresh-filesystem marker was consumed"
-[[ ! -e "$integration_home/.cloud-compose-bootstrap-complete" ]] ||
+[[ ! -e "$integration_durable_marker" ]] ||
fail "symlink fresh-filesystem marker published durable readiness"
echo "Bootstrap recovery contract passed"
diff --git a/ci/cloud-smoke.sh b/ci/cloud-smoke.sh
index 97a18fb..ef5c654 100755
--- a/ci/cloud-smoke.sh
+++ b/ci/cloud-smoke.sh
@@ -3,6 +3,8 @@
set -euo pipefail
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
+readonly diagnostics_program="/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"
+readonly smoke_healthcheck_program="/home/cloud-compose/smoke-healthcheck.sh"
usage() {
cat <<'EOF'
@@ -264,7 +266,7 @@ target_workdir() {
target_var_args() {
local root="$1" key_path="$2" target="$3" run_id="$4" run_namespace="$5"
local public_key provider template
- local source_ref source_sha256 source_cache_key checksum_dir checksum_file archive_tmp
+ local source_ref source_sha256 source_cache_key checksum_dir checksum_file archive_tmp checkout_sha
provider="$(target_provider "$target")"
template="$(target_template "$target")"
@@ -283,14 +285,19 @@ target_var_args() {
printf '%s\0%s\0' "-var" "template=${template}"
fi
if grep -q 'variable "cloud_compose_source_ref"' "$root/variables.tf"; then
- source_ref="${CLOUD_COMPOSE_SOURCE_REF:-${GITHUB_SHA:-main}}"
+ checkout_sha="$(git -C "$repo_root" rev-parse HEAD)"
+ source_ref="${CLOUD_COMPOSE_SOURCE_REF:-${GITHUB_SHA:-$checkout_sha}}"
+ if [[ ! "$source_ref" =~ ^[0-9a-f]{40}$ || "$source_ref" != "$checkout_sha" ]]; then
+ echo "CLOUD_COMPOSE_SOURCE_REF must equal the exact lowercase checked-out commit ${checkout_sha}" >&2
+ return 1
+ fi
printf '%s\0%s\0' "-var" "cloud_compose_source_ref=${source_ref}"
fi
if grep -q 'variable "cloud_compose_source_sha256"' "$root/variables.tf"; then
source_sha256="${CLOUD_COMPOSE_SOURCE_SHA256:-}"
if [[ -z "$source_sha256" ]]; then
checksum_dir="$(target_workdir "$target")"
- source_cache_key="$(printf '%s' "$source_ref" | sha256sum | awk '{print $1}')"
+ source_cache_key="$(printf '%s' "$source_ref" | sha256sum | cut -d' ' -f1)"
checksum_file="${checksum_dir}/cloud-compose-source-${source_cache_key}.sha256"
mkdir -p "$checksum_dir"
if [[ -s "$checksum_file" ]]; then
@@ -301,7 +308,7 @@ target_var_args() {
curl -fsSL --retry 3 \
"https://github.com/libops/cloud-compose/archive/${source_ref}.tar.gz" \
-o "$archive_tmp"
- source_sha256="$(sha256sum "$archive_tmp" | awk '{print $1}')"
+ source_sha256="$(sha256sum "$archive_tmp" | cut -d' ' -f1)"
rm -f "$archive_tmp"
printf '%s\n' "$source_sha256" > "$checksum_file"
fi
@@ -377,42 +384,36 @@ wait_for_ssh() {
return 1
}
+remote_diagnostics_available() {
+ local home_dir="$1" key_path="$2" host="$3" port="$4" user="$5"
+
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "test -x ${diagnostics_program}" >/dev/null 2>&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
-bootstrap_load_state=\"\$(systemctl show --property=LoadState --value -- cloud-compose-bootstrap.service 2>/dev/null)\"
-if [ \"\$bootstrap_load_state\" = loaded ]; then
- bootstrap_active_state=\"\$(systemctl show --property=ActiveState --value -- cloud-compose-bootstrap.service 2>/dev/null)\"
- bootstrap_sub_state=\"\$(systemctl show --property=SubState --value -- cloud-compose-bootstrap.service 2>/dev/null)\"
- case \"\$bootstrap_active_state:\$bootstrap_sub_state\" in
- active:* | activating:* | *:auto-restart)
- echo active
- exit 0
- ;;
- esac
-elif [ \"\$bootstrap_load_state\" = not-found ] &&
- systemctl is-active --quiet cloud-compose; then
- # Releases before retryable bootstrap have no durable unit or marker; their
- # active application service remains the compatibility completion signal.
- 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
+ if remote_diagnostics_available "$home_dir" "$key_path" "$host" "$port" "$user"; then
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "sudo -n ${diagnostics_program} state" 2>/dev/null || true
+ return
+ fi
+
+ # The pinned upgrade fixture predates the checked-in diagnostics program.
+ # Keep its compatibility probes simple and non-interactive.
+ if ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "test -f /home/cloud-compose/.cloud-compose-bootstrap-complete" >/dev/null 2>&1; then
+ echo complete
+ elif ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "systemctl is-active --quiet cloud-compose.service" >/dev/null 2>&1; then
+ echo complete
+ elif ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "systemctl is-active --quiet cloud-final.service" >/dev/null 2>&1; then
+ echo active
+ else
+ echo idle
+ fi
}
wait_for_cloud_init() {
@@ -421,25 +422,47 @@ wait_for_cloud_init() {
local deadline
local status_output
local bootstrap_state
- local last_dump=0
+ local diagnostics_available
timeout_seconds="$(boot_timeout_seconds)"
deadline=$((SECONDS + timeout_seconds))
echo "Waiting for cloud-init on ${host}"
while (( SECONDS < deadline )); do
+ diagnostics_available=false
+ if remote_diagnostics_available "$home_dir" "$key_path" "$host" "$port" "$user"; then
+ diagnostics_available=true
+ fi
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
- )"
+ if [[ "$diagnostics_available" == "true" ]]; then
+ status_output="$(
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "sudo -n ${diagnostics_program} status" 2>&1 || true
+ )"
+ else
+ status_output="$(
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "cloud-init status --long" 2>&1 || true
+ )"
+ fi
printf '%s\n' "$status_output"
if grep -q '^status: done' <<<"$status_output"; then
+ if [[ "$diagnostics_available" == "true" ]]; then
+ if [[ "$bootstrap_state" == "active" ]]; then
+ echo "cloud-init is done while cloud-compose bootstrap is still active; continuing"
+ sleep 30
+ continue
+ fi
+ echo "cloud-init completed without the Cloud Compose readiness marker" >&2
+ return 1
+ fi
+ # The pinned upgrade fixture predates the durable readiness marker.
+ # Retain cloud-init completion as its final compatibility signal.
return 0
fi
if grep -q '^status: error' <<<"$status_output"; then
@@ -451,24 +474,6 @@ wait_for_cloud_init() {
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 \"--- cloud-compose bootstrap unit ---\"
-sudo journalctl -u cloud-compose-bootstrap --no-pager -n 160
-echo \"--- legacy cloud-compose bootstrap log (when present) ---\"
-if sudo test -f /home/cloud-compose/run.log && sudo test ! -L /home/cloud-compose/run.log; then
- sudo tail -n 160 /home/cloud-compose/run.log
-else
- echo \"Legacy bootstrap log is not present\"
-fi
-echo \"--- /var/log/cloud-init-output.log ---\"
-sudo tail -n 120 /var/log/cloud-init-output.log
-'" || true
- fi
-
sleep 30
done
@@ -482,40 +487,50 @@ dump_remote_logs() {
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 \"--- cloud-compose bootstrap unit ---\"
-sudo journalctl -u cloud-compose-bootstrap --no-pager -n 400
-echo \"--- legacy cloud-compose bootstrap log (when present) ---\"
-if sudo test -f /home/cloud-compose/run.log && sudo test ! -L /home/cloud-compose/run.log; then
- sudo tail -n 400 /home/cloud-compose/run.log
-else
- echo \"Legacy bootstrap log is not present\"
-fi
-echo \"--- cloud-compose unit ---\"
-sudo journalctl -u cloud-compose --no-pager -n 300
-echo \"--- lifecycle lock permissions ---\"
-sudo stat -Lc '%A %a %U:%G %u:%g %n' /run/lock/cloud-compose /run/lock/cloud-compose/lifecycle.lock
-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\"
+ if remote_diagnostics_available "$home_dir" "$key_path" "$host" "$port" "$user"; then
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "sudo -n ${diagnostics_program} dump" || true
+ return
fi
-else
- echo \"Project directory ${project_dir} is not present yet\"
-fi
-'" || true
+
+ # Compatibility diagnostics for the pinned pre-program upgrade fixture.
+ echo "--- legacy cloud-init status ---"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "cloud-init status --long" || true
+ echo "--- legacy /var/log/cloud-init-output.log ---"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "tail -n 400 /var/log/cloud-init-output.log" || true
+ echo "--- legacy /var/log/cloud-init.log ---"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "tail -n 400 /var/log/cloud-init.log" || true
+ echo "--- legacy cloud-init runcmd ---"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "sed -n 1,240p /var/lib/cloud/instance/scripts/runcmd" || true
+ echo "--- legacy cloud-compose bootstrap unit ---"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "systemctl status cloud-compose-bootstrap.service --no-pager" || true
+ echo "--- legacy cloud-compose bootstrap log ---"
+ if ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "test -f /home/cloud-compose/run.log" >/dev/null 2>&1 &&
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "test ! -L /home/cloud-compose/run.log" >/dev/null 2>&1; then
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "tail -n 400 /home/cloud-compose/run.log" || true
+ else
+ echo "Legacy bootstrap log is not present"
+ fi
+ echo "--- legacy cloud-compose unit ---"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "sudo -n /usr/bin/systemctl status cloud-compose.service" || true
+ echo "--- legacy lifecycle lock permissions ---"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "stat -Lc '%A %a %U:%G %u:%g %n' /run/lock/cloud-compose /run/lock/cloud-compose/lifecycle.lock" || true
+ echo "--- legacy docker ps ---"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "docker ps -a" || true
+ echo "--- legacy docker compose ps ---"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "docker compose --project-directory ${quoted_project_dir} ps" || true
}
configure_sitectl_context() {
@@ -564,11 +579,17 @@ run_healthcheck() {
user="$(jq -r '.ssh_user' "$output_json")"
quoted_context="$(shell_quote "$context")"
- 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 --format table
-'"
+ if ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "test -x ${smoke_healthcheck_program}" >/dev/null 2>&1; then
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "${smoke_healthcheck_program} ${quoted_context}"
+ else
+ # The pinned 0.10.2 upgrade fixture predates the checked-in wrapper.
+ # Invoke its sitectl binary directly with the environment profile's
+ # stable path settings, without sending an embedded shell program.
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "env HOME=/home/cloud-compose DOCKER_CONFIG=/mnt/disks/data/docker-config PATH=/home/cloud-compose/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin sitectl healthcheck --context ${quoted_context} --persist --format table"
+ fi
return
fi
@@ -578,6 +599,47 @@ exec sitectl healthcheck --context ${quoted_context} --persist --format table
--format table
}
+run_lifecycle_program_contract() {
+ local home_dir="$1" key_path="$2" output_json="$3"
+ local host port user remote_contract_dir remote_contract status
+
+ host="$(jq -r '.host' "$output_json")"
+ port="$(jq -r '.ssh_port' "$output_json")"
+ user="$(jq -r '.ssh_user' "$output_json")"
+ if ! remote_contract_dir="$(ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ 'mktemp -d /mnt/disks/data/cloud-compose-hosted-contract.XXXXXX')"; then
+ echo "Could not create the remote lifecycle contract directory on the executable data disk" >&2
+ return 1
+ fi
+ if [[ ! "$remote_contract_dir" =~ ^/mnt/disks/data/cloud-compose-hosted-contract\.[A-Za-z0-9]+$ ]]; then
+ echo "Remote lifecycle contract directory is unsafe: $remote_contract_dir" >&2
+ return 1
+ fi
+
+ remote_contract="$remote_contract_dir/lifecycle-program-contract.sh"
+ if ! ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "install -m 0700 /dev/stdin $remote_contract" \
+ <"$repo_root/ci/lifecycle-program-contract.sh"; then
+ echo "Could not install the remote lifecycle program contract" >&2
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "rm -f -- $remote_contract && rmdir -- $remote_contract_dir" || true
+ return 1
+ fi
+
+ if ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "/bin/bash -- $remote_contract /home/cloud-compose/default-lifecycle.sh"; then
+ status=0
+ else
+ status=$?
+ echo "Remote lifecycle program contract failed with status $status" >&2
+ fi
+ if ! ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "rm -f -- $remote_contract && rmdir -- $remote_contract_dir"; then
+ echo "Could not remove the remote lifecycle program contract" >&2
+ fi
+ return "$status"
+}
+
run_target() (
set -euo pipefail
@@ -675,6 +737,11 @@ run_target() (
return 1
fi
+ if ! run_lifecycle_program_contract "$home_dir" "$key_path" "$output_json"; 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"
diff --git a/ci/compose-runtime-contract.sh b/ci/compose-runtime-contract.sh
index 31537ad..08f85a5 100755
--- a/ci/compose-runtime-contract.sh
+++ b/ci/compose-runtime-contract.sh
@@ -11,22 +11,121 @@ fail() {
exit 1
}
+lifecycle_program="$repo_root/rootfs/home/cloud-compose/default-lifecycle.sh"
+for defaults_file in \
+ modules/gcp/variables.tf \
+ modules/linux-vm-runtime/variables.tf \
+ ansible/roles/cloud_compose/defaults/main.yml \
+ salt/cloud-compose/init.sls; do
+ for action in init up down rollout; do
+ if [[ "$(grep -Fc -- "/home/cloud-compose/default-lifecycle.sh $action" "$repo_root/$defaults_file")" -ne 1 ]]; then
+ fail "$defaults_file does not invoke the $action lifecycle program exactly once"
+ fi
+ done
+ if grep -Fq -- 'TARGET_REF=' "$repo_root/$defaults_file"; then
+ fail "$defaults_file still splits rollout state across lifecycle command entries"
+ fi
+done
+bash "$repo_root/ci/lifecycle-program-contract.sh" \
+ "$lifecycle_program" \
+ "$repo_root/rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq"
+grep -Fq -- 'run_lifecycle_program_contract "$home_dir" "$key_path" "$output_json"' \
+ "$repo_root/ci/cloud-smoke.sh" || fail "provider smoke does not execute the lifecycle program contract"
+smoke_lifecycle_contract="$(sed -n '/^run_lifecycle_program_contract()/,/^}/p' "$repo_root/ci/cloud-smoke.sh")"
+grep -Fq -- 'mktemp -d /mnt/disks/data/cloud-compose-hosted-contract.XXXXXX' \
+ <<<"$smoke_lifecycle_contract" || \
+ fail "provider smoke does not stage its executable lifecycle contract on the data disk"
+grep -Fq -- '^/mnt/disks/data/cloud-compose-hosted-contract\.[A-Za-z0-9]+$' \
+ <<<"$smoke_lifecycle_contract" || \
+ fail "provider smoke does not validate the exact remote lifecycle contract directory"
+if grep -Fq -- '/tmp/cloud-compose-hosted-contract' <<<"$smoke_lifecycle_contract"; then
+ fail "provider smoke still stages an executable lifecycle contract below /tmp"
+fi
+grep -Fq -- '/bin/bash -- $remote_contract /home/cloud-compose/default-lifecycle.sh' \
+ <<<"$smoke_lifecycle_contract" || \
+ fail "provider smoke does not open the checked-in contract through the fixed interpreter"
+if grep -Fq -- 'test -x /home/cloud-compose/default-lifecycle.sh' <<<"$smoke_lifecycle_contract"; then
+ fail "provider smoke still rejects a lifecycle program solely because /home is noexec"
+fi
+lifecycle_contract="$repo_root/ci/lifecycle-program-contract.sh"
+grep -Fq -- 'trap report_unexpected_failure ERR' "$lifecycle_contract" || \
+ fail "lifecycle program contract does not report unexpected command failures"
+grep -Fq -- '[[ -L "$lifecycle_program" ]]' "$lifecycle_contract" || \
+ fail "lifecycle program contract accepts a redirected target"
+grep -Fq -- '[[ ! -r "$lifecycle_program" ]]' "$lifecycle_contract" || \
+ fail "lifecycle program contract accepts an unreadable target"
+grep -Fq -- 'bash "$lifecycle_program_contract" /home/cloud-compose/default-lifecycle.sh' \
+ "$repo_root/ci/remote/config-management-verify.sh" || \
+ fail "config-management smoke does not execute the lifecycle program contract"
+
grep -Fq 'cd "$script_dir"' "$repo_root/rootfs/home/cloud-compose/prepare-app-sources.sh" || \
fail "source preparation does not enter an accessible working directory before dropping privileges"
grep -Fq 'run_as_cloud_compose() (' "$repo_root/rootfs/home/cloud-compose/run.sh" || \
fail "privilege-drop helper does not isolate its working-directory change"
grep -Fq 'cd /home/cloud-compose' "$repo_root/rootfs/home/cloud-compose/run.sh" || \
fail "privilege-drop helper can inherit an inaccessible caller working directory"
+if grep -Fq 'su -s /bin/bash -c' "$repo_root/rootfs/home/cloud-compose/run.sh"; then
+ fail "privilege-drop helper still synthesizes a shell program through su"
+fi
+grep -Fq 'run_compose_lifecycle_executor "$lifecycle" "$command"' \
+ "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" || \
+ fail "Compose lifecycle entries do not pass through the checked executor"
+grep -Fq 'readonly COMPOSE_LIFECYCLE_EXECUTOR="/etc/cloud-compose/libexec/run-lifecycle-program.sh"' \
+ "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" || \
+ fail "Compose lifecycle entries do not use the canonical checked executor path"
+grep -Fq '"$COMPOSE_LIFECYCLE_EXECUTOR" "$@"' \
+ "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" || \
+ fail "Compose lifecycle executor wrapper does not invoke the canonical checked program"
+grep -Fq 'run_compose_lifecycle_executor --validate "$lifecycle" "$command" || return 1' \
+ "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" || \
+ fail "Compose lifecycle program sets are not validated before execution"
+if grep -Fq 'bash -c "$command"' "$repo_root/rootfs/home/cloud-compose/compose-apps.sh"; then
+ fail "Compose lifecycle entries still execute as shell strings"
+fi
+lifecycle_executor="$repo_root/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh"
+grep -Fq 'if [[ "$program" == "/home/cloud-compose/default-lifecycle.sh" ]]; then' \
+ "$lifecycle_executor" || \
+ fail "checked executor does not isolate the built-in lifecycle program"
+grep -Fq 'exec /bin/bash -- "$program" "${program_args[@]}"' \
+ "$lifecycle_executor" || \
+ fail "checked executor cannot open the built-in lifecycle program from a noexec home mount"
+grep -Fq 'exec "$program" "${program_args[@]}"' "$lifecycle_executor" || \
+ fail "checked executor does not preserve direct execution for custom lifecycle programs"
+if grep -Eq '/bin/bash[[:space:]]+-[^[:space:]]*c([[:space:]]|$)' "$lifecycle_executor"; then
+ fail "checked executor evaluates a lifecycle entry as shell source"
+fi
export COMPOSE_PROJECTS_FILE="$tmp/compose-projects.json"
export COMPOSE_APPS_ENV_DIR="$tmp/apps"
export COMPOSE_APPS_STATE_DIR="$tmp/state"
export CLOUD_COMPOSE_DATA_ROOT="$tmp/data"
+export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
mkdir -p "$CLOUD_COMPOSE_DATA_ROOT/project"
# shellcheck disable=SC1091
source "$repo_root/rootfs/home/cloud-compose/compose-apps.sh"
+# Exercise the checked-in executor while retaining the immutable production
+# path contract above. The production image installs the same file at the
+# canonical /etc location.
+export CLOUD_COMPOSE_TEST_LIFECYCLE_EXECUTOR="$repo_root/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh"
+# shellcheck disable=SC1091
+source "$repo_root/ci/fixtures/checked-lifecycle-executor.sh"
+
+cat >"$CLOUD_COMPOSE_DATA_ROOT/project/compose.yaml" <<'EOF'
+secrets:
+ DB_ROOT_PASSWORD:
+ file: ./secrets/DB_ROOT_PASSWORD
+ WORDPRESS_DB_PASSWORD:
+ file: ./secrets/WORDPRESS_DB_PASSWORD
+EOF
+mapfile -t compose_secrets < <(
+ cd "$CLOUD_COMPOSE_DATA_ROOT/project"
+ compose_secret_files
+)
+[[ "${compose_secrets[*]}" == "./secrets/DB_ROOT_PASSWORD ./secrets/WORDPRESS_DB_PASSWORD" ]] || \
+ fail "Compose Specification compose.yaml secret files were not discovered"
+
jq -n \
--arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" \
--arg trailing_command $'printf "trailing newline preserved"\n' '{
@@ -132,6 +231,21 @@ FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{network_mode:"default"}}}')"
export FAKE_COMPOSE_CONFIG
reject_host_network_compose_services
+checked_jq_program_dir="$CLOUD_COMPOSE_JQ_PROGRAM_DIR"
+CLOUD_COMPOSE_JQ_PROGRAM_DIR="$tmp/missing-jq-programs"
+if reject_host_network_compose_services >/dev/null 2>&1; then
+ fail "GCP metadata isolation accepted a missing checked jq program"
+fi
+mkdir -p "$tmp/invalid-jq-programs"
+cp "$repo_root/ci/fixtures/invalid-checked-program.jq" \
+ "$tmp/invalid-jq-programs/compose-reject-host-network.jq"
+CLOUD_COMPOSE_JQ_PROGRAM_DIR="$tmp/invalid-jq-programs"
+if reject_host_network_compose_services >/dev/null 2>&1; then
+ fail "GCP metadata isolation accepted an invalid checked jq program"
+fi
+CLOUD_COMPOSE_JQ_PROGRAM_DIR="$checked_jq_program_dir"
+export CLOUD_COMPOSE_JQ_PROGRAM_DIR
+
FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{build:{context:".",network:"host"}}}}')"
export FAKE_COMPOSE_CONFIG
if reject_host_network_compose_services >/dev/null 2>&1; then
@@ -150,27 +264,47 @@ reject_host_network_compose_services
# Preserve list(string) verify arguments as argv. A value containing spaces is
# one argument, not an unquoted scalar split by the lifecycle shell.
-jq -n --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" '{app: {
- docker_compose_repo: "https://github.com/libops/wp.git",
- docker_compose_branch: "main",
- project_dir: $project_dir,
- compose_project_name: "app",
- sitectl_context_name: "app",
- sitectl_verify_args: ["--label", "value with spaces"],
- up_commands: ["sitectl verify --context \"$SITECTL_CONTEXT_NAME\" ${SITECTL_VERIFY_ARGS:-}"],
- init_commands: [], down_commands: [], rollout_commands: []
-}}' >"$COMPOSE_PROJECTS_FILE"
-cat >"$tmp/bin/sitectl" <<'EOF'
-#!/usr/bin/env bash
-printf '<%s>\n' "$@" >"${SITECTL_ARGV_LOG:?}"
-EOF
-chmod +x "$tmp/bin/sitectl"
+jq -n \
+ --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" \
+ --arg up_program "$repo_root/ci/fixtures/lifecycle.d/default-up" \
+ -f "$repo_root/ci/fixtures/verify-argv-compose-project.jq" >"$COMPOSE_PROJECTS_FILE"
+ln -s "$repo_root/ci/fixtures/sitectl-argv-log.sh" "$tmp/bin/sitectl"
export SITECTL_ARGV_LOG="$tmp/sitectl.argv"
+export CLOUD_COMPOSE_LIFECYCLE_PROGRAM_DIR="$repo_root/ci/fixtures/lifecycle.d"
+export CLOUD_COMPOSE_SITECTL_VERIFY_ARGS_PROGRAM="$repo_root/rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq"
clone_or_update_compose_app() { source_compose_app_env "$1"; }
record_compose_app_head() { return 0; }
+
+# Even a default ingress configuration must initialize durable desired state;
+# otherwise sitectl verify correctly rejects the missing .libops/site.yaml.
+jq -n \
+ --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" \
+ -f "$repo_root/ci/fixtures/default-ingress-compose-project.jq" >"$COMPOSE_PROJECTS_FILE"
+write_compose_app_env app
+: >"$SITECTL_ARGV_LOG"
+configure_sitectl_app_features app
+cmp -s "$repo_root/ci/fixtures/default-ingress-sitectl.argv" "$SITECTL_ARGV_LOG" || \
+ fail "default ingress did not initialize and converge sitectl component state"
+
+jq -n \
+ --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" \
+ --arg up_program "$repo_root/ci/fixtures/lifecycle.d/default-up" \
+ -f "$repo_root/ci/fixtures/verify-argv-compose-project.jq" >"$COMPOSE_PROJECTS_FILE"
+write_compose_app_env app
+: >"$SITECTL_ARGV_LOG"
CLOUD_COMPOSE_PROVIDER=linode
run_compose_app_lifecycle app up
cat >"$tmp/expected.argv" <<'EOF'
+
+<--context>
+
+
+<-d>
+<--remove-orphans>
+
+<--context>
+
+<--persist>
<--context>
diff --git a/ci/config-management-cloud-smoke-container.sh b/ci/config-management-cloud-smoke-container.sh
new file mode 100755
index 0000000..a83bb9d
--- /dev/null
+++ b/ci/config-management-cloud-smoke-container.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+install -d -m 0755 /work
+tar -C /work -xf -
+cd /work
+exec bash ci/config-management-cloud-smoke-inner.sh
diff --git a/ci/config-management-cloud-smoke-inner.sh b/ci/config-management-cloud-smoke-inner.sh
index f53111d..0652f3a 100755
--- a/ci/config-management-cloud-smoke-inner.sh
+++ b/ci/config-management-cloud-smoke-inner.sh
@@ -86,6 +86,8 @@ all:
cloud_compose_name: ${SMOKE_NAME}
cloud_compose_template: ${SMOKE_TEMPLATE}
cloud_compose_dedicated_host_acknowledged: true
+ cloud_compose_bootstrap_timeout: 1500
+ cloud_compose_bootstrap_wait_seconds: 1200
cloud_compose_runtime:
compose:
ingress_port: 80
@@ -100,6 +102,8 @@ EOF
}
deploy_salt() {
+ local remote_command
+
if ! tar -C /work \
--exclude="./.git" \
--exclude="./.terraform" \
@@ -110,141 +114,69 @@ deploy_salt() {
return 1
fi
- # Smoke settings must be expanded locally for the remote shell.
- # shellcheck disable=SC2029
- if ! ssh "${ssh_opts[@]}" "$ssh_target" \
- "SMOKE_NAME=${SMOKE_NAME} SMOKE_TEMPLATE=${SMOKE_TEMPLATE} SMOKE_ENVIRONMENT=${SMOKE_ENVIRONMENT} SMOKE_PROJECT_DIR=${SMOKE_PROJECT_DIR} bash -s" <<'REMOTE'
-set -euo pipefail
-
-export DEBIAN_FRONTEND=noninteractive
-apt-get update
-apt-get install -y --no-install-recommends python3-venv ca-certificates
-
-python3 -m venv /opt/cloud-compose-salt-smoke
-/opt/cloud-compose-salt-smoke/bin/python -m pip install --no-cache-dir \
- salt==3007.1 \
- tornado==6.4.2 \
- looseversion==1.3.0 \
- PyYAML==6.0.2 \
- packaging==24.2 \
- msgpack==1.1.0 \
- distro==1.9.0 \
- Jinja2==3.1.4
-
-mkdir -p /tmp/cloud-compose-salt/etc /tmp/cloud-compose-salt/cache /tmp/cloud-compose-salt/pki /srv/cloud-compose/.smoke-pillar
-cat >/tmp/cloud-compose-salt/etc/minion </srv/cloud-compose/.smoke-pillar/top.sls </srv/cloud-compose/.smoke-pillar/cloud-compose.sls </tmp/cloud-compose-salt-show-sls.txt
-
-/opt/cloud-compose-salt-smoke/bin/salt-call \
- --local \
- --retcode-passthrough \
- --config-dir=/tmp/cloud-compose-salt/etc \
- state.apply cloud-compose
-REMOTE
- then
+ printf -v remote_command '%q ' \
+ /srv/cloud-compose/ci/remote/config-management-deploy-salt.sh \
+ "$SMOKE_NAME" \
+ "$SMOKE_TEMPLATE" \
+ "$SMOKE_ENVIRONMENT" \
+ "$SMOKE_PROJECT_DIR"
+ if ! ssh "${ssh_opts[@]}" "$ssh_target" "$remote_command"; then
return 1
fi
}
verify_remote() {
- # Smoke settings must be expanded locally for the remote shell.
- # shellcheck disable=SC2029
- ssh "${ssh_opts[@]}" "$ssh_target" \
- "SMOKE_NAME=${SMOKE_NAME} SMOKE_TEMPLATE=${SMOKE_TEMPLATE} SMOKE_ENVIRONMENT=${SMOKE_ENVIRONMENT} SMOKE_PROJECT_DIR=${SMOKE_PROJECT_DIR} bash -s" <<'REMOTE'
-set -euo pipefail
-
-test -x /home/cloud-compose/init
-test -x /home/cloud-compose/up
-test -x /home/cloud-compose/down
-test -x /home/cloud-compose/rollout
-test -x /home/cloud-compose/run.sh
-test -x /home/cloud-compose/start-cloud-compose-bootstrap.sh
-python3 -m json.tool /home/cloud-compose/compose-projects.json >/dev/null
-python3 -m json.tool /home/cloud-compose/application-env.json >/dev/null
-
-python3 - <<'PY'
-import json
-import os
-import subprocess
-from pathlib import Path
-
-name = os.environ["SMOKE_NAME"]
-template = os.environ["SMOKE_TEMPLATE"]
-project_dir = os.environ["SMOKE_PROJECT_DIR"]
-def load_runtime_env(path):
- result = subprocess.run(
- [
- "env", "-i", "PATH=/usr/bin:/bin", f"CLOUD_COMPOSE_ENV_FILE={path}",
- "bash", "--noprofile", "--norc", "-c",
- "source /home/cloud-compose/profile.sh; env -0",
- ],
- check=True,
- stdout=subprocess.PIPE,
- )
- return {
- entry.split(b"=", 1)[0].decode(): entry.split(b"=", 1)[1].decode()
- for entry in result.stdout.split(b"\0") if b"=" in entry
- }
+ local remote_contract_dir lifecycle_contract runtime_state_contract verification_program
+ local remote_command status
-env = load_runtime_env(Path("/home/cloud-compose/.env"))
+ remote_contract_dir="$(ssh "${ssh_opts[@]}" "$ssh_target" \
+ 'mktemp -d /tmp/cloud-compose-hosted-contract.XXXXXX')" || return 1
+ if [[ ! "$remote_contract_dir" =~ ^/tmp/cloud-compose-hosted-contract\.[A-Za-z0-9]+$ ]]; then
+ echo "Remote contract directory is unsafe: $remote_contract_dir" >&2
+ return 1
+ fi
-projects = json.loads(Path("/home/cloud-compose/compose-projects.json").read_text())
-project = projects[name]
+ lifecycle_contract="$remote_contract_dir/lifecycle-program-contract.sh"
+ runtime_state_contract="$remote_contract_dir/config-management-runtime-state-contract.py"
+ verification_program="$remote_contract_dir/config-management-verify.sh"
+ if ! ssh "${ssh_opts[@]}" "$ssh_target" \
+ "install -m 0700 /dev/stdin $lifecycle_contract" \
+ &2
- ssh_cmd "$home_dir" "$key_path" "$host" "bash -lc 'set +e
-echo \"--- cloud-init status ---\"
-cloud-init status --long
-echo \"--- /var/log/cloud-init-output.log ---\"
-tail -n 300 /var/log/cloud-init-output.log
-echo \"--- cloud-compose bootstrap unit ---\"
-sudo journalctl -u cloud-compose-bootstrap --no-pager -n 300
-echo \"--- cloud-compose unit ---\"
-journalctl -u cloud-compose --no-pager -n 300
-echo \"--- docker ps ---\"
-docker ps -a
-echo \"--- compose manifest ---\"
-cat /home/cloud-compose/compose-projects.json
-'" || true
+ ssh_cmd "$home_dir" "$key_path" "$host" \
+ "install -d -m 0700 $diagnostics_dir && install -m 0700 /dev/stdin $diagnostics_path" \
+ <"$diagnostics_source" || return 0
+ ssh_cmd "$home_dir" "$key_path" "$host" "$diagnostics_path" || true
}
target_var_args() {
@@ -272,7 +270,7 @@ target_var_args() {
deploy_config_management() {
local target="$1" key_path="$2" output_json="$3"
- local method host name template environment project_dir image
+ local method host name template environment project_dir image deploy_timeout container_entrypoint
method="$(jq -r '.method' "$output_json")"
host="$(jq -r '.host' "$output_json")"
@@ -281,6 +279,12 @@ deploy_config_management() {
environment="$(jq -r '.environment' "$output_json")"
project_dir="$(jq -r '.project_dir' "$output_json")"
image="${CLOUD_COMPOSE_CONFIG_MANAGEMENT_IMAGE:-$CONFIG_MANAGEMENT_IMAGE_DEFAULT}"
+ deploy_timeout="$(config_management_timeout_seconds)"
+ container_entrypoint="$repo_root/ci/config-management-cloud-smoke-container.sh"
+ [[ -f "$container_entrypoint" && ! -L "$container_entrypoint" ]] || {
+ echo "Config-management smoke container entrypoint is missing or unsafe" >&2
+ return 1
+ }
if [[ -L "$key_path" || ! -f "$key_path" ]]; then
echo "Config-management smoke SSH private-key path is missing or unsafe: $key_path" >&2
@@ -297,7 +301,7 @@ deploy_config_management() {
# would also copy ignored Terraform state and the generated private key into
# the helper container and, for Salt, onward to the provisioned VM.
git -C "$repo_root" archive --format=tar HEAD |
- docker run --rm -i \
+ timeout --signal=TERM --kill-after=30s "${deploy_timeout}s" docker run --rm -i \
--env "SMOKE_METHOD=${method}" \
--env "SMOKE_HOST=${host}" \
--env "SMOKE_NAME=${name}" \
@@ -305,10 +309,11 @@ deploy_config_management() {
--env "SMOKE_ENVIRONMENT=${environment}" \
--env "SMOKE_PROJECT_DIR=${project_dir}" \
--mount "type=bind,src=${key_path},dst=/run/secrets/cloud-compose-ssh-key,readonly" \
+ --mount "type=bind,src=${container_entrypoint},dst=/usr/local/libexec/cloud-compose-config-management-smoke,readonly" \
--tmpfs /run \
--tmpfs /tmp \
"$image" \
- bash -lc 'mkdir -p /work && tar -C /work -xf - && cd /work && bash ci/config-management-cloud-smoke-inner.sh'
+ /usr/local/libexec/cloud-compose-config-management-smoke
}
require_run_commands() {
@@ -319,6 +324,7 @@ require_run_commands() {
require_cmd ssh-keygen
require_cmd ssh-keyscan
require_cmd terraform
+ require_cmd timeout
}
require_destroy_commands() {
diff --git a/ci/config-management-input-contract.py b/ci/config-management-input-contract.py
new file mode 100755
index 0000000..f824584
--- /dev/null
+++ b/ci/config-management-input-contract.py
@@ -0,0 +1,483 @@
+#!/usr/bin/env python3
+
+import base64
+import copy
+import json
+import os
+import stat
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+repo_root = Path(sys.argv[1])
+validator = Path(sys.argv[2])
+
+
+def fail(message):
+ raise SystemExit(f"config-management input contract: {message}")
+
+
+def snapshot(root):
+ entries = []
+ for path in sorted(root.rglob("*")):
+ metadata = path.lstat()
+ relative = str(path.relative_to(root))
+ if path.is_symlink():
+ payload = ("symlink", os.readlink(path))
+ elif path.is_file():
+ payload = ("file", path.read_bytes())
+ else:
+ payload = ("other", b"")
+ entries.append(
+ (
+ relative,
+ stat.S_IMODE(metadata.st_mode),
+ metadata.st_uid,
+ metadata.st_gid,
+ payload,
+ )
+ )
+ return entries
+
+
+with tempfile.TemporaryDirectory(prefix="cloud-compose-input-contract.") as temp_dir:
+ test_root = Path(temp_dir)
+ data_root = test_root / "mnt" / "disks" / "data"
+ outside = test_root / "outside"
+ data_root.mkdir(parents=True)
+ outside.mkdir()
+ (outside / "sentinel").write_text("must-not-change\n")
+ (data_root / "escape").symlink_to(outside, target_is_directory=True)
+
+ safe_artifact = {
+ "name": "rollout-agent",
+ "url": "https://example.invalid/rollout-agent",
+ "sha256": "a" * 64,
+ "path": "/usr/local/bin/rollout-agent",
+ "mode": "0750",
+ "owner": "root",
+ "group": "root",
+ "restart": "cloud-compose-rollout.service",
+ }
+ safe_payload = {
+ "projects": [{"name": "app", "project_dir": str(data_root / "app")}],
+ "artifacts": [safe_artifact],
+ }
+
+ def run(payload):
+ before = snapshot(test_root)
+ environment = os.environ.copy()
+ environment["CLOUD_COMPOSE_VALIDATION_PAYLOAD_B64"] = base64.b64encode(
+ json.dumps(payload).encode("utf-8")
+ ).decode("ascii")
+ result = subprocess.run(
+ [sys.executable, str(validator), "--data-root", str(data_root)],
+ env=environment,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=False,
+ )
+ after = snapshot(test_root)
+ if before != after:
+ fail("validator mutated the host fixture")
+ return result
+
+ accepted = run(safe_payload)
+ if accepted.returncode != 0:
+ fail(f"safe payload was rejected: {accepted.stderr}")
+
+ def reject(label, payload, expected):
+ result = run(payload)
+ if result.returncode == 0:
+ fail(f"{label} was accepted")
+ if expected not in result.stderr:
+ fail(f"{label} did not report {expected!r}: {result.stderr}")
+
+ invalid_project_paths = {
+ "root project path": "/",
+ "system project path": "/etc",
+ "data root itself": str(data_root),
+ "traversal project path": str(data_root / ".." / "outside"),
+ "dot project segment": f"{data_root}/./app",
+ "empty project segment": f"{data_root}//app",
+ "control-character project segment": f"{data_root}/bad\x7fname",
+ "symlink escape": str(data_root / "escape" / "app"),
+ }
+ for label, project_dir in invalid_project_paths.items():
+ payload = copy.deepcopy(safe_payload)
+ payload["projects"][0]["project_dir"] = project_dir
+ reject(label, payload, "project_dir")
+
+ duplicate_ports = copy.deepcopy(safe_payload)
+ duplicate_ports["projects"] = [
+ {"name": "alpha", "project_dir": str(data_root / "alpha"), "ingress_port": 8080},
+ {"name": "beta", "project_dir": str(data_root / "beta"), "ingress_port": 8080},
+ ]
+ reject("duplicate project ports", duplicate_ports, "ingress ports must be unique")
+
+ artifact_cases = []
+
+ def artifact_case(label, field, value, expected):
+ payload = copy.deepcopy(safe_payload)
+ payload["artifacts"][0][field] = value
+ artifact_cases.append((label, payload, expected))
+
+ artifact_case("unsafe artifact name", "name", "../agent", "safe basename")
+ artifact_case("overlong artifact name", "name", "a" * 129, "safe basename")
+ artifact_case("non-HTTPS artifact URL", "url", "http://example.invalid/agent", "HTTPS URL")
+ artifact_case("uppercase artifact SHA", "sha256", "A" * 64, "64 lowercase hex")
+ artifact_case("root artifact path", "path", "/", "non-root absolute path")
+ artifact_case("relative artifact path", "path", "usr/local/bin/agent", "non-root absolute path")
+ artifact_case("dot artifact path", "path", "/usr/local/../bin/agent", "non-root absolute path")
+ artifact_case("empty artifact segment", "path", "/usr//local/bin/agent", "non-root absolute path")
+ artifact_case("control artifact segment", "path", "/usr/local/bad\x7fname", "non-root absolute path")
+ artifact_case("unsafe artifact mode", "mode", "4755", "mode must match")
+ artifact_case("unsafe artifact owner", "owner", "root:root", "owner must be")
+ artifact_case("unsafe artifact group", "group", "root:root", "group must be")
+ artifact_case("unsafe restart unit", "restart", "../docker.service", "safe .service")
+
+ duplicate_name = copy.deepcopy(safe_payload)
+ second = copy.deepcopy(safe_artifact)
+ second["path"] = "/usr/local/bin/rollout-agent-two"
+ duplicate_name["artifacts"].append(second)
+ artifact_cases.append(("duplicate artifact name", duplicate_name, "names must be unique"))
+
+ duplicate_path = copy.deepcopy(safe_payload)
+ second = copy.deepcopy(safe_artifact)
+ second["name"] = "rollout-agent-two"
+ duplicate_path["artifacts"].append(second)
+ artifact_cases.append(("duplicate artifact path", duplicate_path, "target paths must be unique"))
+
+ for label, payload, expected in artifact_cases:
+ reject(label, payload, expected)
+
+ansible_tasks = (repo_root / "ansible/roles/cloud_compose/tasks/main.yml").read_text()
+ansible_defaults = (repo_root / "ansible/roles/cloud_compose/defaults/main.yml").read_text()
+ansible_gate = ansible_tasks.find("Validate project directory and managed artifact host boundaries")
+ansible_first_mutation = ansible_tasks.find("Install Debian runtime dependencies")
+if ansible_gate < 0 or ansible_first_mutation < 0 or ansible_gate > ansible_first_mutation:
+ fail("Ansible host-input validation does not precede its first host mutation")
+if "files/validate-runtime-inputs.py" not in ansible_tasks:
+ fail("Ansible does not execute the shared host-input validator")
+if "--data-root" in ansible_tasks:
+ fail("Ansible makes the production project ownership boundary configurable")
+if "cmd: bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh" not in ansible_tasks:
+ fail("Ansible bypasses the retryable bootstrap service")
+if "cmd: bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh" not in ansible_tasks:
+ fail("Ansible does not validate bootstrap readiness evidence")
+if 'cmd: bash "{{ cloud_compose_home }}/run.sh"' in ansible_tasks:
+ fail("Ansible still invokes the one-shot bootstrap script directly")
+if "cloud_compose_bootstrap_timeout: 14400" not in ansible_defaults:
+ fail("Ansible bootstrap timeout does not cover the bounded retryable bootstrap wait")
+if "cloud_compose_bootstrap_wait_seconds: 10800" not in ansible_defaults:
+ fail("Ansible bootstrap service wait does not have a bounded default")
+for marker in (
+ "cloud_compose_bootstrap_wait_seconds is number",
+ "(cloud_compose_bootstrap_wait_seconds | int) >= 1",
+ "(cloud_compose_bootstrap_wait_seconds | int) <= 43200",
+ "(cloud_compose_bootstrap_timeout | int) > (cloud_compose_bootstrap_wait_seconds | int)",
+ 'CLOUD_COMPOSE_BOOTSTRAP_WAIT_SECONDS: "{{ cloud_compose_bootstrap_wait_seconds | int }}"',
+):
+ if marker not in ansible_tasks:
+ fail(f"Ansible bounded bootstrap wait marker is missing: {marker!r}")
+
+salt_state = (repo_root / "salt/cloud-compose/init.sls").read_text()
+cloud_smoke_driver = (repo_root / "ci/config-management-cloud-smoke.sh").read_text()
+cloud_smoke_inner = (repo_root / "ci/config-management-cloud-smoke-inner.sh").read_text()
+cloud_smoke_container = (repo_root / "ci/config-management-cloud-smoke-container.sh").read_text()
+remote_diagnostics = (repo_root / "ci/remote/config-management-diagnostics.sh").read_text()
+remote_salt_deploy = (repo_root / "ci/remote/config-management-deploy-salt.sh").read_text()
+remote_verification = (repo_root / "ci/remote/config-management-verify.sh").read_text()
+cloud_smoke_workflow = (repo_root / ".github/workflows/cloud-smoke.yml").read_text()
+local_smoke_driver = (repo_root / "ci/config-management-smoke.sh").read_text()
+local_smoke_container = (repo_root / "ci/config-management-smoke-container.sh").read_text()
+local_smoke_inner = (repo_root / "ci/config-management-smoke-inner.sh").read_text()
+local_lifecycle_fixture = (
+ repo_root / "ci/fixtures/config-management-lifecycle-lock.sh"
+).read_text()
+salt_gate = salt_state.find("cloud-compose-host-inputs-valid:")
+salt_first_mutation = salt_state.find("cloud-compose-packages:")
+if salt_gate < 0 or salt_first_mutation < 0 or salt_gate > salt_first_mutation:
+ fail("Salt host-input validation does not precede its first host mutation")
+if "bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh" not in salt_state:
+ fail("Salt bypasses the retryable bootstrap service")
+if "bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh" not in salt_state:
+ fail("Salt does not validate bootstrap readiness evidence")
+if "home ~ '/run.sh'" in salt_state:
+ fail("Salt still invokes the one-shot bootstrap script directly")
+for marker in (
+ "bootstrap_wait_seconds = cc.get('bootstrap_wait_seconds', 10800)",
+ "bootstrap_wait_seconds < 1 or bootstrap_wait_seconds > 43200",
+ "CLOUD_COMPOSE_BOOTSTRAP_WAIT_SECONDS: {{ (bootstrap_wait_seconds | string) | json }}",
+):
+ if marker not in salt_state:
+ fail(f"Salt bounded bootstrap wait marker is missing: {marker!r}")
+gate_block = salt_state[salt_gate:salt_first_mutation]
+for marker in (
+ "cmd.run:",
+ '/usr/bin/env python3 "$CLOUD_COMPOSE_RUNTIME_VALIDATOR"',
+ "failhard: True",
+ "order: 2",
+):
+ if marker not in gate_block:
+ fail(f"Salt host-input validation gate is missing {marker!r}")
+for state_name in ("cloud-compose-packages:", "cloud-compose-docker-group:", "cloud-compose-group:"):
+ state_start = salt_state.find(state_name)
+ state_end = salt_state.find("\n\n", state_start)
+ if "cmd: cloud-compose-host-inputs-valid" not in salt_state[state_start:state_end]:
+ fail(f"Salt mutating state {state_name} does not require host-input validation")
+
+for marker in (
+ "_cc_compose.init | default(cloud_compose_default_init)",
+ "_cc_compose.up | default(cloud_compose_default_up)",
+ "_cc_compose.down | default(cloud_compose_default_down)",
+ "_cc_compose.rollout | default(cloud_compose_default_rollout)",
+ "item.value.docker_compose_up | default(_cc_up_commands)",
+):
+ if marker not in ansible_tasks:
+ fail(f"Ansible explicit-empty lifecycle parity marker is missing: {marker!r}")
+
+for marker in (
+ "_cc_template.package_versions | default({})",
+ "_cc_template_sitectl_package_versions[item] | default(_cc_sitectl_version)",
+ "_cc_sitectl_package_version_overrides[item] | default(",
+ "_cc_sitectl.packages | default(_cc_template.packages)",
+ "item.value.sitectl_packages | default(_cc_sitectl_packages)",
+ "(_cc_managed.enabled | default(cloud_compose_managed_runtime_enabled)) is boolean",
+ "(_cc_vault.agent_enabled | default(false)) is boolean",
+ "(_cc_disaster_recovery.required | default(false)) is boolean",
+ "CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED",
+ "CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER",
+):
+ if marker not in ansible_tasks:
+ fail(f"Ansible template package-version parity marker is missing: {marker!r}")
+
+for marker in (
+ "configured_commands = compose.get(lifecycle)",
+ "configured_commands if lifecycle in compose else default_commands",
+ "if legacy_key in app",
+ "elif docker_key in app",
+):
+ if marker not in salt_state:
+ fail(f"Salt explicit-empty lifecycle parity marker is missing: {marker!r}")
+
+for marker in (
+ "template_sitectl_package_versions = template.get('package_versions', {})",
+ "sitectl_package_version_overrides.get(package, template_sitectl_package_versions.get(package, sitectl_version))",
+ "sitectl.get('packages') if 'packages' in sitectl else template.packages",
+ "app.get('sitectl_packages', sitectl_packages)",
+ "all_packages = sitectl_packages | list",
+ "'managed_runtime.enabled': managed_runtime_enabled",
+ "'vault.agent_enabled': vault.get('agent_enabled', False)",
+ "runtime_sections.disaster_recovery",
+ "CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED",
+ "CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER",
+ "run_bootstrap is sameas true",
+):
+ if marker not in salt_state:
+ fail(f"Salt template package-version parity marker is missing: {marker!r}")
+
+for marker in (
+ 'retry_remote_operation "Ansible deployment" deploy_ansible',
+ 'retry_remote_operation "Salt deployment" deploy_salt',
+ 'retry_remote_operation "Remote verification" verify_remote',
+ '-o ServerAliveInterval=30',
+ 'for attempt in 1 2 3; do',
+ 'cloud_compose_bootstrap_timeout: 1500',
+ 'cloud_compose_bootstrap_wait_seconds: 1200',
+):
+ if marker not in cloud_smoke_inner:
+ fail(f"hosted adapter smoke retry contract is missing: {marker!r}")
+for marker in (
+ '/srv/cloud-compose/ci/remote/config-management-deploy-salt.sh',
+ ' ansible_first_mutation:
- fail("Ansible host-input validation does not precede its first host mutation")
-if "files/validate-runtime-inputs.py" not in ansible_tasks:
- fail("Ansible does not execute the shared host-input validator")
-if "--data-root" in ansible_tasks:
- fail("Ansible makes the production project ownership boundary configurable")
-if 'cmd: bash "{{ cloud_compose_home }}/start-cloud-compose-bootstrap.sh"' not in ansible_tasks:
- fail("Ansible bypasses the retryable bootstrap service")
-if 'cmd: bash "{{ cloud_compose_home }}/run.sh"' in ansible_tasks:
- fail("Ansible still invokes the one-shot bootstrap script directly")
-if "cloud_compose_bootstrap_timeout: 14400" not in ansible_defaults:
- fail("Ansible bootstrap timeout does not cover the bounded retryable bootstrap wait")
-
-salt_state = (repo_root / "salt/cloud-compose/init.sls").read_text()
-cloud_smoke_driver = (repo_root / "ci/config-management-cloud-smoke.sh").read_text()
-cloud_smoke_inner = (repo_root / "ci/config-management-cloud-smoke-inner.sh").read_text()
-salt_gate = salt_state.find("cloud-compose-host-inputs-valid:")
-salt_first_mutation = salt_state.find("cloud-compose-packages:")
-if salt_gate < 0 or salt_first_mutation < 0 or salt_gate > salt_first_mutation:
- fail("Salt host-input validation does not precede its first host mutation")
-if "home ~ '/start-cloud-compose-bootstrap.sh'" not in salt_state:
- fail("Salt bypasses the retryable bootstrap service")
-if "home ~ '/run.sh'" in salt_state:
- fail("Salt still invokes the one-shot bootstrap script directly")
-gate_block = salt_state[salt_gate:salt_first_mutation]
-for marker in (
- "cmd.run:",
- '/usr/bin/env python3 "$CLOUD_COMPOSE_RUNTIME_VALIDATOR"',
- "failhard: True",
- "order: 2",
-):
- if marker not in gate_block:
- fail(f"Salt host-input validation gate is missing {marker!r}")
-for state_name in ("cloud-compose-packages:", "cloud-compose-docker-group:", "cloud-compose-group:"):
- state_start = salt_state.find(state_name)
- state_end = salt_state.find("\n\n", state_start)
- if "cmd: cloud-compose-host-inputs-valid" not in salt_state[state_start:state_end]:
- fail(f"Salt mutating state {state_name} does not require host-input validation")
-
-for marker in (
- "_cc_compose.init | default(cloud_compose_default_init)",
- "_cc_compose.up | default(cloud_compose_default_up)",
- "_cc_compose.down | default(cloud_compose_default_down)",
- "_cc_compose.rollout | default(cloud_compose_default_rollout)",
- "item.value.docker_compose_up | default(_cc_up_commands)",
-):
- if marker not in ansible_tasks:
- fail(f"Ansible explicit-empty lifecycle parity marker is missing: {marker!r}")
-
-for marker in (
- "_cc_template.package_versions | default({})",
- "_cc_template_sitectl_package_versions[item] | default(_cc_sitectl_version)",
- "_cc_sitectl_package_version_overrides[item] | default(",
- "_cc_sitectl.packages | default(_cc_template.packages)",
- "item.value.sitectl_packages | default(_cc_sitectl_packages)",
- "(_cc_managed.enabled | default(cloud_compose_managed_runtime_enabled)) is boolean",
- "(_cc_vault.agent_enabled | default(false)) is boolean",
-):
- if marker not in ansible_tasks:
- fail(f"Ansible template package-version parity marker is missing: {marker!r}")
-
-for marker in (
- "configured_commands = compose.get(lifecycle)",
- "configured_commands if lifecycle in compose else default_commands",
- "if legacy_key in app",
- "elif docker_key in app",
-):
- if marker not in salt_state:
- fail(f"Salt explicit-empty lifecycle parity marker is missing: {marker!r}")
-
-for marker in (
- "template_sitectl_package_versions = template.get('package_versions', {})",
- "sitectl_package_version_overrides.get(package, template_sitectl_package_versions.get(package, sitectl_version))",
- "sitectl.get('packages') if 'packages' in sitectl else template.packages",
- "app.get('sitectl_packages', sitectl_packages)",
- "all_packages = sitectl_packages | list",
- "'managed_runtime.enabled': managed_runtime_enabled",
- "'vault.agent_enabled': vault.get('agent_enabled', False)",
- "run_bootstrap is sameas true",
-):
- if marker not in salt_state:
- fail(f"Salt template package-version parity marker is missing: {marker!r}")
-
-for marker in (
- 'retry_remote_operation "Ansible deployment" deploy_ansible',
- 'retry_remote_operation "Salt deployment" deploy_salt',
- 'retry_remote_operation "Remote verification" verify_remote',
- '-o ServerAliveInterval=30',
- 'for attempt in 1 2 3; do',
-):
- if marker not in cloud_smoke_inner:
- fail(f"hosted adapter smoke retry contract is missing: {marker!r}")
-
-for text in (cloud_smoke_driver, cloud_smoke_inner):
- if "SMOKE_SSH_KEY_B64" in text:
- fail("hosted adapter smoke exposes its SSH private key through the environment")
-if 'type=bind,src=${key_path},dst=/run/secrets/cloud-compose-ssh-key,readonly' not in cloud_smoke_driver:
- fail("hosted adapter smoke does not mount its SSH private key read-only")
-if 'git -C "$repo_root" archive --format=tar HEAD' not in cloud_smoke_driver:
- fail("hosted adapter smoke does not limit its source stream to committed files")
-if 'tar \\\n --exclude="./.git"' in cloud_smoke_driver:
- fail("hosted adapter smoke still archives the secret-bearing working tree")
-for marker in (
- '[[ -L "$key_path" || ! -f "$key_path" ]]',
- 'key_path="$(cd -P -- "$(dirname -- "$key_path")" && pwd)/$(basename -- "$key_path")"',
-):
- if marker not in cloud_smoke_driver:
- fail(f"hosted adapter smoke key-source validation is missing: {marker!r}")
-for marker in (
- 'readonly smoke_ssh_key_mount="/run/secrets/cloud-compose-ssh-key"',
- 'install -m 0600 "$smoke_ssh_key_mount" /tmp/cloud-compose-ssh/id_ed25519',
- '-i /tmp/cloud-compose-ssh/id_ed25519',
- 'ansible_ssh_private_key_file: /tmp/cloud-compose-ssh/id_ed25519',
-):
- if marker not in cloud_smoke_inner:
- fail(f"hosted adapter smoke key-file contract is missing: {marker!r}")
-
-for label, text, owner_marker, mode_marker in (
- ("Ansible", ansible_tasks, "owner: root", 'mode: "0640"'),
- ("Salt", salt_state, "- user: root", "- mode: '0640'"),
-):
- if text.count(owner_marker) < 4 or text.count(mode_marker) < 4:
- fail(f"{label} does not keep every root-consumed runtime input root-owned and mode 0640")
-
-if "no_log: true" not in ansible_tasks[ansible_tasks.find("Write Compose application environment data"):]:
- fail("Ansible may expose application environment data in task output")
-for state_name in ("cloud-compose-env:", "cloud-compose-application-env:", "cloud-compose-managed-runtime-artifacts:"):
- state_start = salt_state.find(state_name)
- state_end = salt_state.find("\n\n", state_start)
- if "- show_changes: False" not in salt_state[state_start:state_end]:
- fail(f"Salt sensitive state {state_name} may expose rendered data in state output")
-
-terraform_artifacts = (repo_root / "modules/managed-artifacts/main.tf").read_text()
-validator_source = validator.read_text()
-if 'PRODUCTION_DATA_ROOT = "/mnt/disks/data"' not in validator_source:
- fail("configuration-management validator changed the fixed production data boundary")
-if "--data-root" in salt_state:
- fail("Salt makes the production project ownership boundary configurable")
-if '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' not in terraform_artifacts:
- fail("Terraform managed-artifact name bound changed without adapter parity")
-if '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' not in validator_source:
- fail("configuration-management validator is missing the 128-character artifact name bound")
-
-project_fixture_values = {
- "invalid-project-dir-root": "project_dir: /",
- "invalid-project-dir-etc": "project_dir: /etc",
- "invalid-project-dir-traversal": "project_dir: /mnt/disks/data/../escape",
- "invalid-project-dir-symlink": "project_dir: /mnt/disks/data/escape/site",
-}
-for fixture_name, marker in project_fixture_values.items():
- for relative_path in (
- f"tests/config-management/ansible/{fixture_name}.yml",
- f"tests/config-management/salt-pillar/{fixture_name}.sls",
- ):
- fixture = repo_root / relative_path
- if not fixture.is_file() or marker not in fixture.read_text():
- fail(f"project boundary fixture {relative_path} is missing {marker!r}")
-
-artifact_fixture_markers = (
- "../unsafe-name",
- "a" * 129,
- "http://example.invalid/unsafe-url",
- "A" * 64,
- "path: /",
- "path: usr/local/bin/relative-path",
- "path: /usr/local/../bin/dot-path",
- "path: /usr//local/bin/empty-path-segment",
- "bad\\u007fname",
- 'mode: "4755"',
- "owner: root:root",
- "group: root:root",
- "restart: ../docker.service",
- "name: duplicate-name",
- "path: /usr/local/bin/duplicate-path",
-)
-for relative_path in (
- "tests/config-management/ansible/invalid-artifacts.yml",
- "tests/config-management/salt-pillar/invalid-artifacts.sls",
-):
- fixture_text = (repo_root / relative_path).read_text()
- for marker in artifact_fixture_markers:
- if marker not in fixture_text:
- fail(f"managed artifact fixture {relative_path} is missing {marker!r}")
-
-adapter_fixture_markers = {
- "invalid-ingress-port": "ingress_port: 80.5",
- "invalid-project-port": "ingress_port: 443.5",
- "invalid-lifecycle": "- 42",
- "invalid-project-lifecycle": "docker_compose_up: not-a-list",
- "invalid-internal-services": "internal_services_enabled: true",
+contract_fail() {
+ echo "config-management input contract: $1" >&2
+ exit 1
}
-for fixture_name, marker in adapter_fixture_markers.items():
- for relative_path in (
- f"tests/config-management/ansible/{fixture_name}.yml",
- f"tests/config-management/salt-pillar/{fixture_name}.sls",
- ):
- fixture = repo_root / relative_path
- if not fixture.is_file() or marker not in fixture.read_text():
- fail(f"adapter parity fixture {relative_path} is missing {marker!r}")
-
-for relative_path in (
- "tests/config-management/ansible/smoke.yml",
- "tests/config-management/salt-pillar/wp-prod.sls",
-):
- fixture_text = (repo_root / relative_path).read_text()
- for marker in ("init: []", "docker_compose_up: []", "app-down-command"):
- if marker not in fixture_text:
- fail(f"explicit-empty lifecycle fixture {relative_path} is missing {marker!r}")
-print("Configuration-management input contracts passed")
-PY
+grep -Fq "cmd: $rollout_runner" "$ansible_tasks" || \
+ contract_fail "Ansible rollout does not use the trusted root-program runner"
+if grep -Fq 'cmd: bash "{{ cloud_compose_home }}/' "$ansible_tasks" || \
+ grep -Fq 'cmd: bash /home/cloud-compose/' "$ansible_tasks"; then
+ contract_fail "Ansible rollout executes a root program directly from the runtime home"
+fi
+grep -Fq 'cmd: /etc/cloud-compose/libexec/harden-bootstrap-paths.sh' "$ansible_tasks" || \
+ contract_fail "Ansible does not apply the shared bootstrap path hardener"
+grep -Fq -- '- name: /etc/cloud-compose/libexec/harden-bootstrap-paths.sh' "$salt_state" || \
+ contract_fail "Salt does not apply the shared bootstrap path hardener"
+
+ansible_privileged_block="$(sed -n '/^- name: Secure Cloud Compose privileged program directories/,/^- name:/p' "$ansible_tasks")"
+grep -Fq -- '- "{{ cloud_compose_home }}"' <<<"$ansible_privileged_block" || \
+ contract_fail "Ansible does not close the account-owned runtime-home window after installing rootfs"
+salt_privileged_block="$(sed -n '/^cloud-compose-privileged-program-directories:/,/^[^[:space:]]/p' "$salt_state")"
+grep -Fq -- '- {{ home | json }}' <<<"$salt_privileged_block" || \
+ contract_fail "Salt does not close the account-owned runtime-home window after installing rootfs"
+grep -Fq 'path: /etc/cloud-compose/libexec/checked-programs.bash' "$ansible_tasks" || \
+ contract_fail "Ansible does not explicitly secure the checked program resolver"
+grep -Fq 'source: salt://rootfs/etc/cloud-compose/libexec/checked-programs.bash' "$salt_state" || \
+ contract_fail "Salt does not explicitly secure the checked program resolver"
+
+grep -Fq 'configure-metadata-firewall.sh | deploy-rollout.sh | docker-prune.sh' "$root_program_runner" || \
+ contract_fail "the trusted root-program runner does not allow deploy-rollout.sh"
+
+salt_rollout_block="$(sed -n '/^cloud-compose-rollout-service:/,/^{% endif %}$/p' "$salt_state")"
+[[ -n "$salt_rollout_block" ]] || contract_fail "Salt rollout state is missing"
+if grep -Fq -- '- name: bash /home/cloud-compose/' "$salt_state"; then
+ contract_fail "Salt rollout executes a root program directly from the runtime home"
+fi
+for marker in \
+ "- name: $rollout_runner" \
+ '- file: cloud-compose-env' \
+ '- file: cloud-compose-application-env' \
+ '- file: cloud-compose-project-manifest' \
+ '- file: cloud-compose-managed-runtime-artifacts' \
+ '- file: cloud-compose-rootfs' \
+ '- cmd: cloud-compose-lifecycle-lock' \
+ '- cmd: cloud-compose-rootfs-script-modes' \
+ '- file: cloud-compose-checked-program-resolver' \
+ '- cmd: cloud-compose-rootfs-jq-modes' \
+ '- cmd: cloud-compose-rootfs-awk-modes' \
+ '- file: cloud-compose-lifecycle-init' \
+ '- file: cloud-compose-lifecycle-up' \
+ '- file: cloud-compose-lifecycle-down' \
+ '- file: cloud-compose-lifecycle-rollout'; do
+ grep -Fq -- "$marker" <<<"$salt_rollout_block" || \
+ contract_fail "Salt rollout state is missing $marker"
+done
+
+
+exec python3 "$repo_root/ci/config-management-input-contract.py" "$repo_root" "$validator"
diff --git a/ci/config-management-runtime-state-contract.py b/ci/config-management-runtime-state-contract.py
new file mode 100644
index 0000000..6ab03ad
--- /dev/null
+++ b/ci/config-management-runtime-state-contract.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+
+import json
+import os
+import subprocess
+from pathlib import Path
+
+
+def load_runtime_env(path: Path) -> dict[str, str]:
+ result = subprocess.run(
+ [
+ "env",
+ "-i",
+ "PATH=/usr/bin:/bin",
+ f"CLOUD_COMPOSE_ENV_FILE={path}",
+ "bash",
+ "--noprofile",
+ "--norc",
+ "-c",
+ "source /home/cloud-compose/profile.sh; env -0",
+ ],
+ check=True,
+ stdout=subprocess.PIPE,
+ )
+ return {
+ entry.split(b"=", 1)[0].decode(): entry.split(b"=", 1)[1].decode()
+ for entry in result.stdout.split(b"\0")
+ if b"=" in entry
+ }
+
+
+name = os.environ["SMOKE_NAME"]
+template = os.environ["SMOKE_TEMPLATE"]
+project_dir = os.environ["SMOKE_PROJECT_DIR"]
+runtime_env = load_runtime_env(Path("/home/cloud-compose/.env"))
+projects = json.loads(Path("/home/cloud-compose/compose-projects.json").read_text())
+project = projects[name]
+
+assert runtime_env["CLOUD_COMPOSE_PROVIDER"] == "onprem"
+assert runtime_env["CLOUD_COMPOSE_APPS"] == name
+assert runtime_env["CLOUD_COMPOSE_PRIMARY_APP"] == name
+assert runtime_env["SITECTL_PLUGIN"] == template
+assert runtime_env["DOCKER_COMPOSE_DIR"] == project_dir
+assert f"sitectl-{template}" in runtime_env["SITECTL_PACKAGES"].split()
+assert project["docker_compose_repo"] == f"https://github.com/libops/{template}.git"
+assert project["project_dir"] == project_dir
+assert project["sitectl_plugin"] == template
diff --git a/ci/config-management-smoke-assert.py b/ci/config-management-smoke-assert.py
new file mode 100644
index 0000000..8a98b19
--- /dev/null
+++ b/ci/config-management-smoke-assert.py
@@ -0,0 +1,382 @@
+#!/usr/bin/env python3
+
+"""Assertions for the containerized Ansible and Salt smoke tests."""
+
+import argparse
+import grp
+import json
+import os
+import stat
+import subprocess
+from pathlib import Path
+
+
+RUNTIME_HOME = Path("/home/cloud-compose")
+PRIVILEGED_PROGRAM_ROOT = Path("/etc/cloud-compose")
+DIAGNOSTICS_PROGRAM = PRIVILEGED_PROGRAM_ROOT / "bin/cloud-compose-diagnostics.sh"
+BOOTSTRAP_LIBEXEC = Path("/etc/cloud-compose/libexec")
+AWK_PROGRAM_DIR = PRIVILEGED_PROGRAM_ROOT / "awk"
+JQ_PROGRAM_DIR = PRIVILEGED_PROGRAM_ROOT / "jq"
+
+
+def load_runtime_env(path: Path) -> dict[str, str]:
+ result = subprocess.run(
+ [
+ "env",
+ "-i",
+ "PATH=/usr/bin:/bin",
+ f"CLOUD_COMPOSE_ENV_FILE={path}",
+ "bash",
+ "--noprofile",
+ "--norc",
+ "-c",
+ "source /home/cloud-compose/profile.sh; env -0",
+ ],
+ check=True,
+ stdout=subprocess.PIPE,
+ )
+ return {
+ entry.split(b"=", 1)[0].decode(): entry.split(b"=", 1)[1].decode()
+ for entry in result.stdout.split(b"\0")
+ if b"=" in entry
+ }
+
+
+def assert_runtime_files() -> None:
+ for path in [
+ RUNTIME_HOME / "init",
+ RUNTIME_HOME / "up",
+ RUNTIME_HOME / "down",
+ RUNTIME_HOME / "rollout",
+ RUNTIME_HOME / "default-lifecycle.sh",
+ RUNTIME_HOME / "lifecycle-entrypoint.sh",
+ RUNTIME_HOME / "run.sh",
+ RUNTIME_HOME / "start-cloud-compose-bootstrap.sh",
+ BOOTSTRAP_LIBEXEC / "bootstrap-required.sh",
+ BOOTSTRAP_LIBEXEC / "bootstrap-security.sh",
+ BOOTSTRAP_LIBEXEC / "run-bootstrap.sh",
+ BOOTSTRAP_LIBEXEC / "require-bootstrap-ready.sh",
+ BOOTSTRAP_LIBEXEC / "run-root-program.sh",
+ BOOTSTRAP_LIBEXEC / "start-cloud-compose-bootstrap.sh",
+ DIAGNOSTICS_PROGRAM,
+ ]:
+ assert path.exists(), path
+ assert os.access(path, os.X_OK), path
+
+ for path in [
+ BOOTSTRAP_LIBEXEC / "checked-programs.bash",
+ AWK_PROGRAM_DIR / "compose-secret-files.awk",
+ AWK_PROGRAM_DIR / "reconcile-fstab.awk",
+ AWK_PROGRAM_DIR / "release-checksum.awk",
+ JQ_PROGRAM_DIR / "diagnostics-validate-compose-projects.jq",
+ JQ_PROGRAM_DIR / "compose-validate-projects.jq",
+ JQ_PROGRAM_DIR / "offhost-validate-manifest.jq",
+ JQ_PROGRAM_DIR / "rotation-validate-state.jq",
+ JQ_PROGRAM_DIR / "sitectl-verify-args.jq",
+ ]:
+ assert path.exists(), path
+
+ cloud_compose_gid = grp.getgrnam("cloud-compose").gr_gid
+ for path, expected_mode in {
+ RUNTIME_HOME / "init": 0o750,
+ RUNTIME_HOME / "up": 0o750,
+ RUNTIME_HOME / "down": 0o750,
+ RUNTIME_HOME / "rollout": 0o750,
+ RUNTIME_HOME / ".env": 0o640,
+ RUNTIME_HOME / "application-env.json": 0o640,
+ RUNTIME_HOME / "compose-projects.json": 0o640,
+ RUNTIME_HOME / "managed-runtime-artifacts.tsv": 0o640,
+ }.items():
+ metadata = path.stat()
+ assert metadata.st_uid == 0, (path, metadata.st_uid)
+ assert metadata.st_gid == cloud_compose_gid, (path, metadata.st_gid)
+ assert stat.S_IMODE(metadata.st_mode) == expected_mode, (
+ path,
+ oct(stat.S_IMODE(metadata.st_mode)),
+ )
+
+ for path in BOOTSTRAP_LIBEXEC.glob("*.sh"):
+ metadata = path.stat()
+ assert metadata.st_uid == 0, (path, metadata.st_uid)
+ assert metadata.st_gid == 0, (path, metadata.st_gid)
+ assert stat.S_IMODE(metadata.st_mode) == 0o755, (
+ path,
+ oct(stat.S_IMODE(metadata.st_mode)),
+ )
+
+ checked_programs = BOOTSTRAP_LIBEXEC / "checked-programs.bash"
+ checked_programs_metadata = checked_programs.lstat()
+ assert not checked_programs.is_symlink()
+ assert checked_programs_metadata.st_uid == 0
+ assert checked_programs_metadata.st_gid == 0
+ assert checked_programs_metadata.st_nlink == 1
+ assert stat.S_IMODE(checked_programs_metadata.st_mode) == 0o644
+
+ for path in [
+ RUNTIME_HOME,
+ PRIVILEGED_PROGRAM_ROOT,
+ AWK_PROGRAM_DIR,
+ DIAGNOSTICS_PROGRAM.parent,
+ BOOTSTRAP_LIBEXEC,
+ JQ_PROGRAM_DIR,
+ ]:
+ assert not path.is_symlink(), path
+ metadata = path.lstat()
+ assert stat.S_ISDIR(metadata.st_mode), path
+ assert metadata.st_uid == 0, (path, metadata.st_uid)
+ assert metadata.st_gid == 0, (path, metadata.st_gid)
+ assert stat.S_IMODE(metadata.st_mode) == 0o755, (
+ path,
+ oct(stat.S_IMODE(metadata.st_mode)),
+ )
+
+ diagnostics_metadata = DIAGNOSTICS_PROGRAM.stat()
+ assert diagnostics_metadata.st_uid == 0
+ assert diagnostics_metadata.st_gid == 0
+ assert stat.S_IMODE(diagnostics_metadata.st_mode) == 0o755
+
+ lifecycle_metadata = (RUNTIME_HOME / "default-lifecycle.sh").stat()
+ assert lifecycle_metadata.st_uid == 0
+ assert lifecycle_metadata.st_gid == 0
+ assert stat.S_IMODE(lifecycle_metadata.st_mode) == 0o755
+
+ jq_programs = list(JQ_PROGRAM_DIR.glob("*.jq"))
+ assert jq_programs
+ for path in jq_programs:
+ assert not path.is_symlink(), path
+ metadata = path.lstat()
+ assert stat.S_ISREG(metadata.st_mode), path
+ assert metadata.st_nlink == 1, (path, metadata.st_nlink)
+ assert metadata.st_uid == 0, (path, metadata.st_uid)
+ assert metadata.st_gid == 0, (path, metadata.st_gid)
+ assert stat.S_IMODE(metadata.st_mode) == 0o644, (
+ path,
+ oct(stat.S_IMODE(metadata.st_mode)),
+ )
+
+ awk_programs = list(AWK_PROGRAM_DIR.glob("*.awk"))
+ assert awk_programs
+ for path in awk_programs:
+ assert not path.is_symlink(), path
+ metadata = path.lstat()
+ assert stat.S_ISREG(metadata.st_mode), path
+ assert metadata.st_nlink == 1, (path, metadata.st_nlink)
+ assert metadata.st_uid == 0, (path, metadata.st_uid)
+ assert metadata.st_gid == 0, (path, metadata.st_gid)
+ assert stat.S_IMODE(metadata.st_mode) == 0o644, (
+ path,
+ oct(stat.S_IMODE(metadata.st_mode)),
+ )
+
+
+def assert_ansible_runtime() -> None:
+ env = load_runtime_env(RUNTIME_HOME / ".env")
+ application_env = json.loads(
+ (RUNTIME_HOME / "application-env.json").read_text()
+ )
+ projects = json.loads((RUNTIME_HOME / "compose-projects.json").read_text())
+ project = projects["isle-prod"]
+
+ assert env["CLOUD_COMPOSE_PROVIDER"] == "onprem"
+ assert env["CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED"] == "false"
+ assert (
+ env["CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER"]
+ == "/etc/cloud-compose/libexec/ansible-offhost"
+ )
+ assert env["DOCKER_COMPOSE_DIR"] == "/mnt/disks/data/libops/isle/isle-prod"
+ assert env["DOCKER_COMPOSE_REPO"] == "https://github.com/libops/isle"
+ assert env["SITECTL_PLUGIN"] == "isle"
+ assert "sitectl-isle" in env["SITECTL_PACKAGES"].split()
+ assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == {
+ "sitectl": "v1.8.2",
+ "sitectl-drupal": "v1.3.0",
+ "sitectl-isle": "v1.5.0",
+ }
+ assert project["docker_compose_repo"] == "https://github.com/libops/isle"
+ assert project["project_dir"] == "/mnt/disks/data/libops/isle/isle-prod"
+ assert project["compose_project_name"] == "libops-isle-v1-3-1"
+ assert project["ingress"]["domain"] == "isle.example.edu"
+ assert project["sitectl_plugin"] == "isle"
+ assert project["init_commands"] == []
+ assert project["up_commands"] == []
+ assert project["down_commands"] == ["app-down-command"]
+ assert project["rollout_commands"] == ["global-rollout-command"]
+ assert Path("/mnt/disks/data/libops/isle/isle-prod").is_dir()
+ assert "BASH_ENV" not in env
+ assert "LD_PRELOAD" not in env
+ assert "PORT" not in env
+ assert application_env["BASH_ENV"] == (
+ "/tmp/cloud-compose-ansible-untrusted-bash-env"
+ )
+ assert application_env["LD_PRELOAD"] == (
+ "/tmp/cloud-compose-ansible-untrusted-preload.so"
+ )
+ assert application_env["PORT"] == "9999"
+ assert application_env["CONTRACT_BACKTICKS"] == (
+ "`touch /tmp/cloud-compose-ansible-backtick-injection`"
+ )
+ assert application_env["CONTRACT_BACKSLASH"] == "a\\path\\ends\\"
+ assert application_env["CONTRACT_COMMAND_SUB"] == (
+ "$(touch /tmp/cloud-compose-ansible-command-injection)"
+ )
+ assert application_env["CONTRACT_DOLLARS"] == "$HOME ${HOME}"
+ assert application_env["CONTRACT_QUOTES"] == (
+ 'a "double" and a single quote: O\'Reilly'
+ )
+ assert application_env["CONTRACT_WHITESPACE"] == " leading and trailing "
+ assert application_env["CONTRACT_MULTILINE"] == "line one\nline two"
+ assert not Path("/tmp/cloud-compose-ansible-backtick-injection").exists()
+ assert not Path("/tmp/cloud-compose-ansible-command-injection").exists()
+
+ assert_runtime_files()
+
+
+def assert_salt_noop() -> None:
+ states = json.loads(Path("/tmp/cloud-compose-salt-noop.json").read_text())[
+ "local"
+ ]
+ lock_states = [
+ state
+ for state_id, state in states.items()
+ if "|-cloud-compose-lifecycle-lock_|-" in state_id
+ ]
+ assert len(lock_states) == 1, lock_states
+ assert lock_states[0]["result"] is True, lock_states[0]
+ assert lock_states[0]["changes"] == {}, lock_states[0]
+
+
+def assert_salt_runtime(
+ expected_name: str,
+ expected_repo: str,
+ expected_plugin: str,
+ expected_package: str,
+ expected_domain: str,
+ expected_project_dir: str,
+ expected_compose_project_name: str,
+) -> None:
+ env = load_runtime_env(RUNTIME_HOME / ".env")
+ application_env = json.loads(
+ (RUNTIME_HOME / "application-env.json").read_text()
+ )
+ projects = json.loads((RUNTIME_HOME / "compose-projects.json").read_text())
+ project = projects[expected_name]
+ artifact_manifest = (RUNTIME_HOME / "managed-runtime-artifacts.tsv").read_text()
+
+ assert env["CLOUD_COMPOSE_PROVIDER"] == "onprem"
+ assert env["CLOUD_COMPOSE_APPS"] == expected_name
+ assert env["CLOUD_COMPOSE_PRIMARY_APP"] == expected_name
+ assert env["DOCKER_COMPOSE_DIR"] == expected_project_dir
+ assert env["DOCKER_COMPOSE_REPO"] == expected_repo
+ assert env["COMPOSE_PROJECT_NAME"] == expected_compose_project_name, (
+ env["COMPOSE_PROJECT_NAME"],
+ expected_compose_project_name,
+ )
+ assert env["SITECTL_PLUGIN"] == expected_plugin
+ assert expected_package in env["SITECTL_PACKAGES"].split()
+ assert project["docker_compose_repo"] == expected_repo
+ assert project["project_dir"] == expected_project_dir
+ assert project["compose_project_name"] == expected_compose_project_name, (
+ project["compose_project_name"],
+ expected_compose_project_name,
+ )
+ assert project["sitectl_plugin"] == expected_plugin
+ assert project["ingress"]["domain"] == expected_domain
+ assert Path(expected_project_dir).is_dir()
+
+ if expected_name == "wp-prod":
+ assert env["CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED"] == "false"
+ assert (
+ env["CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER"]
+ == "/etc/cloud-compose/libexec/salt-offhost"
+ )
+ assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == {
+ "sitectl": "v1.8.2",
+ "sitectl-wp": "v2.0.0",
+ }
+ assert "BASH_ENV" not in env
+ assert "LD_PRELOAD" not in env
+ assert "PORT" not in env
+ assert application_env["BASH_ENV"] == (
+ "/tmp/cloud-compose-salt-untrusted-bash-env"
+ )
+ assert application_env["LD_PRELOAD"] == (
+ "/tmp/cloud-compose-salt-untrusted-preload.so"
+ )
+ assert application_env["PORT"] == "9999"
+ assert application_env["CONTRACT_BACKTICKS"] == (
+ "`touch /tmp/cloud-compose-salt-backtick-injection`"
+ )
+ assert application_env["CONTRACT_BACKSLASH"] == "a\\path\\ends\\"
+ assert application_env["CONTRACT_COMMAND_SUB"] == (
+ "$(touch /tmp/cloud-compose-salt-command-injection)"
+ )
+ assert application_env["CONTRACT_DOLLARS"] == "$HOME ${HOME}"
+ assert application_env["CONTRACT_QUOTES"] == (
+ 'a "double" and a single quote: O\'Reilly'
+ )
+ assert application_env["CONTRACT_WHITESPACE"] == " leading and trailing "
+ assert application_env["CONTRACT_MULTILINE"] == "line one\nline two"
+ assert env["LIBOPS_MANAGED_RUNTIME_ENABLED"] == "false"
+ assert env["LIBOPS_INTERNAL_SERVICES_ENABLED"] == "false"
+ assert env["LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE"] == "false"
+ assert project["init_commands"] == []
+ assert project["up_commands"] == []
+ assert project["down_commands"] == ["app-down-command"]
+ assert project["rollout_commands"] == ["global-rollout-command"]
+ assert (
+ "contract-agent\thttps://example.invalid/contract-agent\t"
+ in artifact_manifest
+ )
+ assert (
+ "\t/usr/local/bin/contract-agent\t0750\troot\troot\t"
+ "cloud-compose.service"
+ in artifact_manifest
+ )
+ assert not Path("/tmp/cloud-compose-salt-backtick-injection").exists()
+ assert not Path("/tmp/cloud-compose-salt-command-injection").exists()
+ elif expected_name == "drupal-prod":
+ assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == {
+ "sitectl": "v1.9.1",
+ "sitectl-drupal": "v1.5.0",
+ }
+
+ assert_runtime_files()
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ subparsers.add_parser("ansible-runtime")
+ subparsers.add_parser("salt-noop")
+
+ salt_runtime = subparsers.add_parser("salt-runtime")
+ salt_runtime.add_argument("expected_name")
+ salt_runtime.add_argument("expected_repo")
+ salt_runtime.add_argument("expected_plugin")
+ salt_runtime.add_argument("expected_package")
+ salt_runtime.add_argument("expected_domain")
+ salt_runtime.add_argument("expected_project_dir")
+ salt_runtime.add_argument("expected_compose_project_name")
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ if args.command == "ansible-runtime":
+ assert_ansible_runtime()
+ elif args.command == "salt-noop":
+ assert_salt_noop()
+ else:
+ assert_salt_runtime(
+ args.expected_name,
+ args.expected_repo,
+ args.expected_plugin,
+ args.expected_package,
+ args.expected_domain,
+ args.expected_project_dir,
+ args.expected_compose_project_name,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ci/config-management-smoke-container.sh b/ci/config-management-smoke-container.sh
new file mode 100755
index 0000000..bb5d1cf
--- /dev/null
+++ b/ci/config-management-smoke-container.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+install -d -m 0755 /work
+tar -C /work -xf -
+cd /work
+exec bash ci/config-management-smoke-inner.sh
diff --git a/ci/config-management-smoke-inner.sh b/ci/config-management-smoke-inner.sh
index 611e5db..6b6cde6 100755
--- a/ci/config-management-smoke-inner.sh
+++ b/ci/config-management-smoke-inner.sh
@@ -51,6 +51,7 @@ verify_lifecycle_lock_contract() {
local lock_dir=/run/lock/cloud-compose
local lock_file="$lock_dir/lifecycle.lock"
local profile=/home/cloud-compose/profile.sh
+ local fixture=/work/ci/fixtures/config-management-lifecycle-lock.sh
local ready=/tmp/cloud-compose-lifecycle-lock-ready
local holder_pid contention_status passwd_sha
@@ -62,14 +63,7 @@ verify_lifecycle_lock_contract() {
rm -f -- "$ready"
CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \
- bash -c '
- set -euo pipefail
- source "$1"
- acquire_cloud_compose_lifecycle_lock root-first-contract
- touch "$2"
- sleep 3
- release_cloud_compose_lifecycle_lock
- ' _ "$profile" "$ready" &
+ "$fixture" hold "$profile" "$ready" &
holder_pid=$!
for _ in {1..50}; do
[[ -e "$ready" ]] && break
@@ -82,10 +76,7 @@ verify_lifecycle_lock_contract() {
HOME=/home/cloud-compose \
CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \
CLOUD_COMPOSE_LIFECYCLE_LOCK_TIMEOUT_SECONDS=1 \
- bash -c '
- source "$1"
- acquire_cloud_compose_lifecycle_lock contention-contract
- ' _ "$profile" >/dev/null 2>&1
+ "$fixture" contend "$profile" >/dev/null 2>&1
contention_status=$?
set -e
[[ "$contention_status" -ne 0 ]]
@@ -94,20 +85,13 @@ verify_lifecycle_lock_contract() {
runuser -u cloud-compose -- env \
HOME=/home/cloud-compose \
CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \
- bash -c '
- set -euo pipefail
- source "$1"
- (
- acquire_cloud_compose_lifecycle_lock subshell-contract
- release_cloud_compose_lifecycle_lock
- )
- ' _ "$profile"
+ "$fixture" subshell "$profile"
passwd_sha="$(sha256sum /etc/passwd)"
mv -- "$lock_file" "${lock_file}.real"
ln -s /etc/passwd "$lock_file"
if CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \
- bash -c 'source "$1"; acquire_cloud_compose_lifecycle_lock symlink-contract' _ "$profile" \
+ "$fixture" reject-symlink "$profile" \
>/dev/null 2>&1; then
echo "Lifecycle lock accepted a symbolic-link target" >&2
exit 1
@@ -141,6 +125,7 @@ ansible_invalid_cases=(
invalid-primary
invalid-template
invalid-vault
+ invalid-disaster-recovery
invalid-package
invalid-host-ack
invalid-project-dir-root
@@ -180,13 +165,20 @@ run_invalid_ansible_case() {
echo "Ansible accepted invalid settings from ${invalid_case}.yml" >&2
exit 1
fi
- grep -Fq -- "$expected" "$log"
- [[ "$(stat -c '%u:%g:%a' /)" == "$root_before" ]]
- [[ "$(stat -c '%u:%g:%a' /etc)" == "$etc_before" ]]
- [[ "$(getent passwd cloud-compose || true)" == "$passwd_before" ]]
- [[ "$(getent group cloud-compose || true)" == "$group_before" ]]
- [[ ! -e /home/cloud-compose ]]
- [[ ! -e /mnt/disks/volumes ]]
+ if ! grep -Fq -- "$expected" "$log"; then
+ echo "Ansible invalid case ${invalid_case} did not report: ${expected}" >&2
+ cat "$log" >&2
+ exit 1
+ fi
+ if [[ "$(stat -c '%u:%g:%a' /)" != "$root_before" ||
+ "$(stat -c '%u:%g:%a' /etc)" != "$etc_before" ||
+ "$(getent passwd cloud-compose || true)" != "$passwd_before" ||
+ "$(getent group cloud-compose || true)" != "$group_before" ||
+ -e /home/cloud-compose || -e /mnt/disks/volumes ]]; then
+ echo "Ansible invalid case ${invalid_case} mutated host state before rejection" >&2
+ cat "$log" >&2
+ exit 1
+ fi
if [[ "$invalid_case" == "invalid-project-dir-symlink" ]]; then
grep -Fxq 'must-not-change' /tmp/cloud-compose-symlink-target/sentinel
[[ -L /mnt/disks/data/escape ]]
@@ -206,6 +198,7 @@ run_invalid_ansible_case invalid-internal-services 'internal-services stack is G
run_invalid_ansible_case invalid-primary 'compose.primary must match'
run_invalid_ansible_case invalid-template 'template must name a supported app'
run_invalid_ansible_case invalid-vault 'Vault Agent is currently supported only by Terraform'
+run_invalid_ansible_case invalid-disaster-recovery 'driver_path must be a safe absolute path'
run_invalid_ansible_case invalid-package 'sitectl packages must use valid release package names'
run_invalid_ansible_case invalid-host-ack 'dedicated_host_acknowledged=true'
run_invalid_ansible_case invalid-project-dir-root 'project_dir must be a normalized non-root absolute path'
@@ -227,97 +220,7 @@ ansible-playbook \
verify_lifecycle_lock_contract
-python - <<'PY'
-import json
-import grp
-import os
-import stat
-import subprocess
-from pathlib import Path
-
-def load_runtime_env(path):
- result = subprocess.run(
- [
- "env", "-i", "PATH=/usr/bin:/bin", f"CLOUD_COMPOSE_ENV_FILE={path}",
- "bash", "--noprofile", "--norc", "-c",
- "source /home/cloud-compose/profile.sh; env -0",
- ],
- check=True,
- stdout=subprocess.PIPE,
- )
- return {
- entry.split(b"=", 1)[0].decode(): entry.split(b"=", 1)[1].decode()
- for entry in result.stdout.split(b"\0") if b"=" in entry
- }
-
-env = load_runtime_env(Path("/home/cloud-compose/.env"))
-application_env = json.loads(Path("/home/cloud-compose/application-env.json").read_text())
-
-projects = json.loads(Path("/home/cloud-compose/compose-projects.json").read_text())
-project = projects["isle-prod"]
-
-assert env["CLOUD_COMPOSE_PROVIDER"] == "onprem"
-assert env["DOCKER_COMPOSE_DIR"] == "/mnt/disks/data/libops/isle/isle-prod"
-assert env["DOCKER_COMPOSE_REPO"] == "https://github.com/libops/isle"
-assert env["SITECTL_PLUGIN"] == "isle"
-assert "sitectl-isle" in env["SITECTL_PACKAGES"].split()
-assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == {
- "sitectl": "v1.0.0",
- "sitectl-drupal": "v1.0.0",
- "sitectl-isle": "v1.0.0",
-}
-assert project["docker_compose_repo"] == "https://github.com/libops/isle"
-assert project["project_dir"] == "/mnt/disks/data/libops/isle/isle-prod"
-assert project["ingress"]["domain"] == "isle.example.edu"
-assert project["sitectl_plugin"] == "isle"
-assert project["init_commands"] == []
-assert project["up_commands"] == []
-assert project["down_commands"] == ["app-down-command"]
-assert project["rollout_commands"] == ["global-rollout-command"]
-assert Path("/mnt/disks/data/libops/isle/isle-prod").is_dir()
-assert "BASH_ENV" not in env
-assert "LD_PRELOAD" not in env
-assert "PORT" not in env
-assert application_env["BASH_ENV"] == "/tmp/cloud-compose-ansible-untrusted-bash-env"
-assert application_env["LD_PRELOAD"] == "/tmp/cloud-compose-ansible-untrusted-preload.so"
-assert application_env["PORT"] == "9999"
-assert application_env["CONTRACT_BACKTICKS"] == "`touch /tmp/cloud-compose-ansible-backtick-injection`"
-assert application_env["CONTRACT_BACKSLASH"] == "a\\path\\ends\\"
-assert application_env["CONTRACT_COMMAND_SUB"] == "$(touch /tmp/cloud-compose-ansible-command-injection)"
-assert application_env["CONTRACT_DOLLARS"] == "$HOME ${HOME}"
-assert application_env["CONTRACT_QUOTES"] == 'a "double" and a single quote: O\'Reilly'
-assert application_env["CONTRACT_WHITESPACE"] == " leading and trailing "
-assert application_env["CONTRACT_MULTILINE"] == "line one\nline two"
-assert not Path("/tmp/cloud-compose-ansible-backtick-injection").exists()
-assert not Path("/tmp/cloud-compose-ansible-command-injection").exists()
-
-for path in [
- "/home/cloud-compose/init",
- "/home/cloud-compose/up",
- "/home/cloud-compose/down",
- "/home/cloud-compose/rollout",
- "/home/cloud-compose/run.sh",
- "/home/cloud-compose/start-cloud-compose-bootstrap.sh",
-]:
- assert Path(path).exists(), path
- assert os.access(path, os.X_OK), path
-
-cloud_compose_gid = grp.getgrnam("cloud-compose").gr_gid
-for path, expected_mode in {
- "/home/cloud-compose/init": 0o750,
- "/home/cloud-compose/up": 0o750,
- "/home/cloud-compose/down": 0o750,
- "/home/cloud-compose/rollout": 0o750,
- "/home/cloud-compose/.env": 0o640,
- "/home/cloud-compose/application-env.json": 0o640,
- "/home/cloud-compose/compose-projects.json": 0o640,
- "/home/cloud-compose/managed-runtime-artifacts.tsv": 0o640,
-}.items():
- metadata = Path(path).stat()
- assert metadata.st_uid == 0, (path, metadata.st_uid)
- assert metadata.st_gid == cloud_compose_gid, (path, metadata.st_gid)
- assert stat.S_IMODE(metadata.st_mode) == expected_mode, (path, oct(stat.S_IMODE(metadata.st_mode)))
-PY
+python /work/ci/config-management-smoke-assert.py ansible-runtime
rm -rf /home/cloud-compose /mnt/disks
@@ -379,135 +282,17 @@ run_salt_case() {
--config-dir=/tmp/salt/etc \
--out=json \
state.apply cloud-compose >/tmp/cloud-compose-salt-noop.json
- python - <<'PY'
-import json
-from pathlib import Path
-
-states = json.loads(Path("/tmp/cloud-compose-salt-noop.json").read_text())["local"]
-lock_states = [
- state for state_id, state in states.items()
- if "|-cloud-compose-lifecycle-lock_|-" in state_id
-]
-assert len(lock_states) == 1, lock_states
-assert lock_states[0]["result"] is True, lock_states[0]
-assert lock_states[0]["changes"] == {}, lock_states[0]
-PY
+ python /work/ci/config-management-smoke-assert.py salt-noop
python -m json.tool /home/cloud-compose/compose-projects.json >/dev/null
- python - "$expected_name" "$expected_repo" "$expected_plugin" "$expected_package" "$expected_domain" "$expected_project_dir" "$expected_compose_project_name" <<'PY'
-import json
-import grp
-import os
-import stat
-import subprocess
-import sys
-from pathlib import Path
-
-expected_name, expected_repo, expected_plugin, expected_package, expected_domain, expected_project_dir, expected_compose_project_name = sys.argv[1:]
-
-def load_runtime_env(path):
- result = subprocess.run(
- [
- "env", "-i", "PATH=/usr/bin:/bin", f"CLOUD_COMPOSE_ENV_FILE={path}",
- "bash", "--noprofile", "--norc", "-c",
- "source /home/cloud-compose/profile.sh; env -0",
- ],
- check=True,
- stdout=subprocess.PIPE,
- )
- return {
- entry.split(b"=", 1)[0].decode(): entry.split(b"=", 1)[1].decode()
- for entry in result.stdout.split(b"\0") if b"=" in entry
- }
-
-env = load_runtime_env(Path("/home/cloud-compose/.env"))
-application_env = json.loads(Path("/home/cloud-compose/application-env.json").read_text())
-
-projects = json.loads(Path("/home/cloud-compose/compose-projects.json").read_text())
-project = projects[expected_name]
-artifact_manifest = Path("/home/cloud-compose/managed-runtime-artifacts.tsv").read_text()
-
-assert env["CLOUD_COMPOSE_PROVIDER"] == "onprem"
-assert env["CLOUD_COMPOSE_APPS"] == expected_name
-assert env["CLOUD_COMPOSE_PRIMARY_APP"] == expected_name
-assert env["DOCKER_COMPOSE_DIR"] == expected_project_dir
-assert env["DOCKER_COMPOSE_REPO"] == expected_repo
-assert env["COMPOSE_PROJECT_NAME"] == expected_compose_project_name, (
- env["COMPOSE_PROJECT_NAME"], expected_compose_project_name
-)
-assert env["SITECTL_PLUGIN"] == expected_plugin
-assert expected_package in env["SITECTL_PACKAGES"].split()
-assert project["docker_compose_repo"] == expected_repo
-assert project["project_dir"] == expected_project_dir
-assert project["compose_project_name"] == expected_compose_project_name, (
- project["compose_project_name"], expected_compose_project_name
-)
-assert project["sitectl_plugin"] == expected_plugin
-assert project["ingress"]["domain"] == expected_domain
-assert Path(expected_project_dir).is_dir()
-
-if expected_name == "wp-prod":
- assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == {
- "sitectl": "v1.0.0",
- "sitectl-wp": "v1.0.0",
- }
- assert "BASH_ENV" not in env
- assert "LD_PRELOAD" not in env
- assert "PORT" not in env
- assert application_env["BASH_ENV"] == "/tmp/cloud-compose-salt-untrusted-bash-env"
- assert application_env["LD_PRELOAD"] == "/tmp/cloud-compose-salt-untrusted-preload.so"
- assert application_env["PORT"] == "9999"
- assert application_env["CONTRACT_BACKTICKS"] == "`touch /tmp/cloud-compose-salt-backtick-injection`"
- assert application_env["CONTRACT_BACKSLASH"] == "a\\path\\ends\\"
- assert application_env["CONTRACT_COMMAND_SUB"] == "$(touch /tmp/cloud-compose-salt-command-injection)"
- assert application_env["CONTRACT_DOLLARS"] == "$HOME ${HOME}"
- assert application_env["CONTRACT_QUOTES"] == 'a "double" and a single quote: O\'Reilly'
- assert application_env["CONTRACT_WHITESPACE"] == " leading and trailing "
- assert application_env["CONTRACT_MULTILINE"] == "line one\nline two"
- assert env["LIBOPS_MANAGED_RUNTIME_ENABLED"] == "false"
- assert env["LIBOPS_INTERNAL_SERVICES_ENABLED"] == "false"
- assert env["LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE"] == "false"
- assert project["init_commands"] == []
- assert project["up_commands"] == []
- assert project["down_commands"] == ["app-down-command"]
- assert project["rollout_commands"] == ["global-rollout-command"]
- assert "contract-agent\thttps://example.invalid/contract-agent\t" in artifact_manifest
- assert "\t/usr/local/bin/contract-agent\t0750\troot\troot\tcloud-compose.service" in artifact_manifest
- assert not Path("/tmp/cloud-compose-salt-backtick-injection").exists()
- assert not Path("/tmp/cloud-compose-salt-command-injection").exists()
-elif expected_name == "drupal-prod":
- assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == {
- "sitectl": "v1.0.0",
- "sitectl-drupal": "v1.0.0",
- }
-
-for path in [
- "/home/cloud-compose/init",
- "/home/cloud-compose/up",
- "/home/cloud-compose/down",
- "/home/cloud-compose/rollout",
- "/home/cloud-compose/run.sh",
- "/home/cloud-compose/start-cloud-compose-bootstrap.sh",
-]:
- assert Path(path).exists(), path
- assert os.access(path, os.X_OK), path
-
-cloud_compose_gid = grp.getgrnam("cloud-compose").gr_gid
-for path, expected_mode in {
- "/home/cloud-compose/init": 0o750,
- "/home/cloud-compose/up": 0o750,
- "/home/cloud-compose/down": 0o750,
- "/home/cloud-compose/rollout": 0o750,
- "/home/cloud-compose/.env": 0o640,
- "/home/cloud-compose/application-env.json": 0o640,
- "/home/cloud-compose/compose-projects.json": 0o640,
- "/home/cloud-compose/managed-runtime-artifacts.tsv": 0o640,
-}.items():
- metadata = Path(path).stat()
- assert metadata.st_uid == 0, (path, metadata.st_uid)
- assert metadata.st_gid == cloud_compose_gid, (path, metadata.st_gid)
- assert stat.S_IMODE(metadata.st_mode) == expected_mode, (path, oct(stat.S_IMODE(metadata.st_mode)))
-PY
+ python /work/ci/config-management-smoke-assert.py salt-runtime \
+ "$expected_name" \
+ "$expected_repo" \
+ "$expected_plugin" \
+ "$expected_package" \
+ "$expected_domain" \
+ "$expected_project_dir" \
+ "$expected_compose_project_name"
}
run_salt_case \
@@ -518,7 +303,7 @@ run_salt_case \
sitectl-wp \
wp.example.edu \
/mnt/disks/data/libops/wp.git/wp-prod \
- libops-wp-v1-0-0
+ libops-wp-v1-1-1
run_salt_case \
drupal-prod \
@@ -528,7 +313,7 @@ run_salt_case \
sitectl-drupal \
drupal.example.edu \
/mnt/disks/data/libops/drupal.git/drupal-prod \
- libops-drupal-v1-0-0
+ libops-drupal-v1-2-1
run_invalid_salt_case() {
local invalid_case="$1" expected="$2"
@@ -581,6 +366,7 @@ run_invalid_salt_case invalid-internal-services 'internal-services stack is GCP-
run_invalid_salt_case invalid-primary 'compose.primary must match'
run_invalid_salt_case invalid-template 'template must name a supported cloud-compose app'
run_invalid_salt_case invalid-vault 'Vault Agent is currently supported only by Terraform'
+run_invalid_salt_case invalid-disaster-recovery 'driver_path must be a safe absolute path'
run_invalid_salt_case invalid-package 'invalid installed package'
run_invalid_salt_case invalid-host-ack 'dedicated_host_acknowledged=true'
run_invalid_salt_case invalid-project-dir-root 'project_dir must be a normalized non-root absolute path'
diff --git a/ci/config-management-smoke.sh b/ci/config-management-smoke.sh
index 42e356d..5794d72 100755
--- a/ci/config-management-smoke.sh
+++ b/ci/config-management-smoke.sh
@@ -6,6 +6,7 @@ repo_root="$(cd "$(dirname "$0")/.." && pwd)"
# renovate: datasource=docker depName=python packageName=python versioning=docker
CONFIG_MANAGEMENT_IMAGE_DEFAULT="python:3.11-slim@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3"
image="${CLOUD_COMPOSE_CONFIG_MANAGEMENT_IMAGE:-$CONFIG_MANAGEMENT_IMAGE_DEFAULT}"
+container_entrypoint="$repo_root/ci/config-management-smoke-container.sh"
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
@@ -16,6 +17,10 @@ require_cmd() {
require_cmd docker
require_cmd tar
+[[ -f "$container_entrypoint" && ! -L "$container_entrypoint" ]] || {
+ echo "Config-management smoke container entrypoint is missing or unsafe" >&2
+ exit 1
+}
tar \
--exclude="./.git" \
@@ -24,7 +29,8 @@ tar \
-C "$repo_root" \
-cf - . |
docker run --rm -i \
+ --mount "type=bind,src=${container_entrypoint},dst=/usr/local/libexec/cloud-compose-config-management-smoke,readonly" \
--tmpfs /run \
--tmpfs /tmp \
"$image" \
- bash -lc 'mkdir -p /work && tar -C /work -xf - && cd /work && bash ci/config-management-smoke-inner.sh'
+ /usr/local/libexec/cloud-compose-config-management-smoke
diff --git a/ci/cos-bootstrap-contract.sh b/ci/cos-bootstrap-contract.sh
index adf8219..43125b6 100755
--- a/ci/cos-bootstrap-contract.sh
+++ b/ci/cos-bootstrap-contract.sh
@@ -4,6 +4,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALLER="${ROOT_DIR}/rootfs/home/cloud-compose/install-dependencies-cos.sh"
+BUILD_PROGRAM="${ROOT_DIR}/rootfs/etc/cloud-compose/libexec/build-cos-make.sh"
TEST_ROOT="$(mktemp -d)"
trap 'rm -rf -- "$TEST_ROOT"' EXIT
@@ -21,22 +22,22 @@ if ! grep -Fqx 'ALPINE_BUILD_IMAGE="alpine:3.22@sha256:14358309a308569c32bdc37e2
echo "COS bootstrap Alpine image is not digest pinned" >&2
exit 1
fi
-grep -Fq '"${alpine_mirror}/v3.22/main"' "$INSTALLER" || {
+grep -Fq '"${alpine_mirror}/v3.22/main"' "$BUILD_PROGRAM" || {
echo "COS bootstrap Alpine main repository does not match the pinned image release" >&2
exit 1
}
-grep -Fq '"${alpine_mirror}/v3.22/community"' "$INSTALLER" || {
+grep -Fq '"${alpine_mirror}/v3.22/community"' "$BUILD_PROGRAM" || {
echo "COS bootstrap Alpine community repository does not match the pinned image release" >&2
exit 1
}
-# Assert the literal command in the bootstrap script.
+# Assert the literal command in the checked-in build program.
# shellcheck disable=SC2016
-if ! grep -Fq 'echo "${MAKE_SHA256} /tmp/make.tar.gz" | sha256sum -c -' "$INSTALLER"; then
+if ! grep -Fq 'echo "${MAKE_SHA256} /tmp/make.tar.gz" | sha256sum -c -' "$BUILD_PROGRAM"; then
echo "COS bootstrap does not verify the GNU Make source archive" >&2
exit 1
fi
-checksum_line="$(grep -nF 'sha256sum -c -' "$INSTALLER" | head -n1)"
-archive_line="$(grep -nF 'tar -xzf /tmp/make.tar.gz' "$INSTALLER" | head -n1)"
+checksum_line="$(grep -nF 'sha256sum -c -' "$BUILD_PROGRAM" | head -n1)"
+archive_line="$(grep -nF 'tar -xzf /tmp/make.tar.gz' "$BUILD_PROGRAM" | head -n1)"
if (( ${checksum_line%%:*} >= ${archive_line%%:*} )); then
echo "COS bootstrap extracts GNU Make before checksum verification" >&2
exit 1
@@ -45,11 +46,27 @@ if grep -Eq '^[[:space:]]*--network[[:space:]]+host([[:space:]\\]|$)' "$INSTALLE
echo "COS bootstrap exposes third-party build code to the host network" >&2
exit 1
fi
+[[ -x "$BUILD_PROGRAM" ]] || {
+ echo "COS checked-in Make build program is not executable" >&2
+ exit 1
+}
+grep -Fq -- '-v "${make_build_program}:/tmp/cloud-compose-build-cos-make.sh:ro"' "$INSTALLER" || {
+ echo "COS bootstrap does not mount its checked-in Make build program read-only" >&2
+ exit 1
+}
+grep -Fq '/bin/sh /tmp/cloud-compose-build-cos-make.sh; then' "$INSTALLER" || {
+ echo "COS bootstrap does not invoke its checked-in Make build program by path" >&2
+ exit 1
+}
+if grep -Eq '/bin/sh[[:space:]]+-[^[:space:]]*c[[:space:]]' "$INSTALLER"; then
+ echo "COS bootstrap still embeds a shell program in a command argument" >&2
+ exit 1
+fi
grep -Fq 'iptables -C DOCKER-USER -d 169.254.169.254/32 -j DROP' "$INSTALLER" || {
echo "COS bootstrap does not require the GCP metadata deny policy" >&2
exit 1
}
-grep -Fq 'COS_TOOL_STATE_DIR:-/mnt/disks/data/cloud-compose-tools' "$INSTALLER" || {
+grep -Fq 'COS_TOOL_STATE_DIR:-/mnt/disks/data/libops-managed/bin' "$INSTALLER" || {
echo "COS bootstrap stores an executable tool on a potentially noexec filesystem" >&2
exit 1
}
@@ -68,24 +85,24 @@ for mirror in \
https://dl-cdn.alpinelinux.org/alpine \
https://mirror.math.princeton.edu/pub/alpinelinux \
https://mirror.fel.cvut.cz/alpine; do
- grep -Fq "$mirror" "$INSTALLER" || {
+ grep -Fq "$mirror" "$BUILD_PROGRAM" || {
echo "COS bootstrap is missing Alpine package mirror: $mirror" >&2
exit 1
}
done
-grep -Fq 'for alpine_mirror in ${alpine_mirrors}; do' "$INSTALLER" || {
+grep -Fq 'for alpine_mirror in ${alpine_mirrors}; do' "$BUILD_PROGRAM" || {
echo "COS bootstrap does not fail over between Alpine package mirrors" >&2
exit 1
}
-grep -Fq 'if apk update && apk add build-base curl make tar; then' "$INSTALLER" || {
+grep -Fq 'if apk update && apk add build-base curl make tar; then' "$BUILD_PROGRAM" || {
echo "COS bootstrap does not validate an index before installing packages" >&2
exit 1
}
-grep -Fq 'All configured Alpine package mirrors failed' "$INSTALLER" || {
+grep -Fq 'All configured Alpine package mirrors failed' "$BUILD_PROGRAM" || {
echo "COS bootstrap accepts exhaustion of all Alpine package mirrors" >&2
exit 1
}
-if grep -Fq -- '--allow-untrusted' "$INSTALLER"; then
+if grep -Fq -- '--allow-untrusted' "$BUILD_PROGRAM"; then
echo "COS bootstrap disables Alpine package signature verification" >&2
exit 1
fi
@@ -97,7 +114,7 @@ retry_until_success() {
"$@"
}
export DOCKER_CONFIG="$MOCK_DOCKER_CONFIG"
-install_cos_dependencies "$TEST_CLOUD_HOME" "" "$TEST_DOCKER_BIN"
+install_cos_dependencies "$TEST_CLOUD_HOME" "" "$TEST_DOCKER_BIN" "$TEST_BUILD_PROGRAM"
EOF
cat >"${BIN_DIR}/bash" <<'EOF'
@@ -156,6 +173,7 @@ PATH="${BIN_DIR}:$PATH" \
TEST_CLOUD_HOME="$CLOUD_HOME" \
TEST_DATA_DIR="$DATA_DIR" \
TEST_DOCKER_BIN="${BIN_DIR}/docker" \
+ TEST_BUILD_PROGRAM="$BUILD_PROGRAM" \
MOCK_DOCKER_CONFIG="$DOCKER_CONFIG_DIR" \
MOCK_CALL_LOG="$CALL_LOG" \
/bin/bash "$HARNESS"
@@ -163,11 +181,16 @@ PATH="${BIN_DIR}:$PATH" \
grep -Fq "plugin:${DOCKER_CONFIG_DIR}/cli-plugins:${CLOUD_HOME}/install-docker-plugins.sh" "$CALL_LOG"
grep -Fq "plugin:${CLOUD_HOME}/.docker/cli-plugins:${CLOUD_HOME}/install-docker-plugins.sh" "$CALL_LOG"
grep -Fq 'alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce' "$CALL_LOG"
+grep -Fq "${BUILD_PROGRAM}:/tmp/cloud-compose-build-cos-make.sh:ro" "$CALL_LOG"
[[ -x "${DATA_DIR}/make" ]]
"${DATA_DIR}/make" --version | grep -Fq 'GNU Make 4.4.1'
[[ -f "${DATA_DIR}/make.state" ]]
[[ -L "${CLOUD_HOME}/bin/make" ]]
[[ "$(readlink "${CLOUD_HOME}/bin/make")" == "${DATA_DIR}/make" ]]
+if grep -A3 -F 'chown -R cloud-compose:cloud-compose' "$INSTALLER" | grep -Fq '"${cloud_compose_home}/bin"'; then
+ echo "COS bootstrap gives the application account ownership of the privileged command directory" >&2
+ exit 1
+fi
# A failed replacement must not promote a partial binary or leave a pending
# artifact that a later boot could mistake for a completed build.
@@ -181,6 +204,7 @@ if PATH="${BIN_DIR}:$PATH" \
TEST_CLOUD_HOME="$CLOUD_HOME" \
TEST_DATA_DIR="$DATA_DIR" \
TEST_DOCKER_BIN="${BIN_DIR}/docker" \
+ TEST_BUILD_PROGRAM="$BUILD_PROGRAM" \
MOCK_DOCKER_CONFIG="$DOCKER_CONFIG_DIR" \
MOCK_CALL_LOG="$CALL_LOG" \
/bin/bash "$HARNESS" >/dev/null 2>&1; then
@@ -201,6 +225,7 @@ if PATH="${BIN_DIR}:$PATH" \
TEST_CLOUD_HOME="$CLOUD_HOME" \
TEST_DATA_DIR="$DATA_DIR" \
TEST_DOCKER_BIN="${BIN_DIR}/docker" \
+ TEST_BUILD_PROGRAM="$BUILD_PROGRAM" \
MOCK_DOCKER_CONFIG="$DOCKER_CONFIG_DIR" \
MOCK_CALL_LOG="$CALL_LOG" \
/bin/bash "$HARNESS" >/dev/null 2>&1; then
diff --git a/ci/cos-jq-portability-contract.sh b/ci/cos-jq-portability-contract.sh
index 9780f3c..48b07b7 100644
--- a/ci/cos-jq-portability-contract.sh
+++ b/ci/cos-jq-portability-contract.sh
@@ -3,13 +3,17 @@
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-runtime_root="$repo_root/rootfs/home/cloud-compose"
+runtime_program_roots=(
+ "$repo_root/rootfs/home/cloud-compose"
+ "$repo_root/rootfs/etc/cloud-compose/jq"
+)
regex_call_pattern='(^|[^[:alnum:]_])(test|match|capture|scan|splits|sub|gsub)[[:space:]]*\('
nul_contains_pattern='contains[[:space:]]*\([[:space:]]*"\\u0000"[[:space:]]*\)'
failed=false
# Container-Optimized OS ships jq without Oniguruma. Its runtime scripts may
# use jq for JSON structure and types, but text validation belongs in Bash.
+# Check both shell call sites and the deployed jq program library.
# The remaining sub(/.../) and gsub(/.../) calls are awk regex literals, not
# jq filters; jq has no slash-delimited regex syntax.
while IFS= read -r call; do
@@ -18,7 +22,7 @@ while IFS= read -r call; do
fi
printf 'COS runtime contains a regex-dependent jq-style call: %s\n' "$call" >&2
failed=true
-done < <(grep -ERn --include='*.sh' "$regex_call_pattern" "$runtime_root" || true)
+done < <(grep -ERn --include='*.sh' --include='*.jq' "$regex_call_pattern" "${runtime_program_roots[@]}" || true)
# jq 1.6 treats every string as containing a NUL when contains("\u0000") is
# used. COS and supported configuration-management hosts can still run jq 1.6,
@@ -26,7 +30,7 @@ done < <(grep -ERn --include='*.sh' "$regex_call_pattern" "$runtime_root" || tru
while IFS= read -r call; do
printf 'Runtime contains jq 1.6-incompatible NUL validation: %s\n' "$call" >&2
failed=true
-done < <(grep -ERn --include='*.sh' "$nul_contains_pattern" "$runtime_root" || true)
+done < <(grep -ERn --include='*.sh' --include='*.jq' "$nul_contains_pattern" "${runtime_program_roots[@]}" || true)
if [[ "$failed" == "true" ]]; then
exit 1
diff --git a/ci/disaster-recovery-contract.sh b/ci/disaster-recovery-contract.sh
new file mode 100644
index 0000000..4a7c565
--- /dev/null
+++ b/ci/disaster-recovery-contract.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+backup_script="$repo_root/rootfs/home/cloud-compose/offhost-backup.sh"
+restore_script="$repo_root/rootfs/home/cloud-compose/restore-test.sh"
+fixture_root="$repo_root/ci/testdata/disaster-recovery"
+tmp="$(mktemp -d "$repo_root/.disaster-recovery-contract.XXXXXX")"
+trap 'rm -rf -- "$tmp"' EXIT
+
+fail() {
+ echo "disaster recovery contract: $*" >&2
+ exit 1
+}
+
+mkdir -p "$tmp/bin" "$tmp/data/projects/alpha/files" "$tmp/data/backups/mariadb/alpha" "$tmp/drivers"
+chmod 0755 "$tmp" "$tmp/bin" "$tmp/data" "$tmp/data/projects" "$tmp/data/projects/alpha" "$tmp/data/projects/alpha/files" "$tmp/drivers"
+
+/usr/bin/install -m 0644 "$fixture_root/profile.sh" "$tmp/profile.sh"
+/usr/bin/install -m 0644 "$fixture_root/compose-apps.sh" "$tmp/compose-apps.sh"
+/usr/bin/install -m 0755 "$fixture_root/fake-stat.sh" "$tmp/bin/stat"
+/usr/bin/install -m 0755 "$fixture_root/fake-install.sh" "$tmp/bin/install"
+/usr/bin/install -m 0755 "$fixture_root/fake-docker.sh" "$tmp/bin/docker"
+/usr/bin/install -m 0644 "$fixture_root/fake-compose-config.jq" "$tmp/bin/fake-compose-config.jq"
+/usr/bin/install -m 0755 "$fixture_root/good-driver.sh" "$tmp/drivers/good-driver"
+/usr/bin/install -m 0755 "$fixture_root/incomplete-driver.sh" "$tmp/drivers/incomplete-driver"
+/usr/bin/install -m 0644 \
+ "$fixture_root/good-backup-receipt.jq" \
+ "$fixture_root/good-restore-proof.jq" \
+ "$fixture_root/incomplete-backup-receipt.jq" \
+ "$tmp/drivers/"
+printf 'logical database\n' | gzip -c >"$tmp/data/backups/mariadb/alpha/$(date -u +%Y%m%d)-alpha.sql.gz"
+
+export TEST_BIN="$tmp/bin"
+export TEST_DATA_ROOT="$tmp/data"
+export LOCK_LOG="$tmp/lock.log"
+export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh"
+export CLOUD_COMPOSE_COMPOSE_APPS_PATH="$tmp/compose-apps.sh"
+export CLOUD_COMPOSE_DR_LIBRARY_PATH="$repo_root/rootfs/home/cloud-compose/disaster-recovery-lib.sh"
+export CLOUD_COMPOSE_DR_STATE_ROOT="$tmp/data/dr"
+export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
+export CLOUD_COMPOSE_DATA_ROOT="$tmp/data"
+export CLOUD_COMPOSE_VOLUMES_ROOT="$tmp/data/volumes"
+export MARIADB_BACKUP_ROOT="$tmp/data/backups/mariadb"
+export CLOUD_COMPOSE_INSTANCE_NAME="contract-site"
+export CLOUD_COMPOSE_PROVIDER="contract"
+export CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED="true"
+export CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="$tmp/drivers/good-driver"
+export SHOULD_NOT_REACH_DRIVER="terraform-secret"
+: >"$LOCK_LOG"
+
+bash "$backup_script"
+receipt="$(find "$CLOUD_COMPOSE_DR_STATE_ROOT/backup-receipts" -maxdepth 1 -type f -name '*.json' -print -quit)"
+manifest="$(find "$CLOUD_COMPOSE_DR_STATE_ROOT/manifests" -maxdepth 1 -type f -name '*.json' -print -quit)"
+[[ -n "$receipt" && -n "$manifest" ]] || fail "successful driver did not publish its atomic manifest and receipt"
+jq -e -f "$fixture_root/assert-coverage-manifest.jq" "$manifest" >/dev/null || \
+ fail "coverage manifest omitted database, application files, or volume topology"
+
+# A second attempt sees the same already-existing daily dump but must still
+# invoke off-host transfer, allowing a failed first handoff to be retried.
+bash "$backup_script"
+[[ "$(grep -c '^backup$' "$tmp/drivers/good-driver.calls")" == "2" ]] || \
+ fail "nightly flow skipped off-host transfer when the daily dump already existed"
+
+published_receipt_sha="$(sha256sum -- "$receipt")"
+published_receipt_sha="${published_receipt_sha%% *}"
+export CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="$tmp/drivers/incomplete-driver"
+if bash "$backup_script" >/dev/null 2>&1; then
+ fail "driver receipt without complete volume coverage was accepted"
+fi
+current_receipt_sha="$(sha256sum -- "$receipt")"
+current_receipt_sha="${current_receipt_sha%% *}"
+[[ "$current_receipt_sha" == "$published_receipt_sha" ]] || \
+ fail "invalid driver output replaced the last validated receipt"
+
+export CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="$tmp/drivers/good-driver"
+bash "$restore_script"
+proof="$(find "$CLOUD_COMPOSE_DR_STATE_ROOT/restore-proofs" -maxdepth 1 -type f -name '*.json' -print -quit)"
+[[ -n "$proof" ]] || fail "scheduled disposable restore test did not publish proof"
+jq -e -f "$fixture_root/assert-restore-proof.jq" "$proof" >/dev/null || \
+ fail "restore proof omitted required recovery evidence"
+
+rm -f -- "$tmp/data/backups/mariadb/alpha/$(date -u +%Y%m%d)-alpha.sql.gz"
+calls_before="$(wc -l <"$tmp/drivers/good-driver.calls")"
+if bash "$backup_script" >/dev/null 2>&1; then
+ fail "required DR coverage succeeded without a database artifact"
+fi
+[[ "$(wc -l <"$tmp/drivers/good-driver.calls")" == "$calls_before" ]] || \
+ fail "driver ran before required local coverage was validated"
+
+CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED=false bash "$backup_script" >/dev/null
+echo "Disaster recovery contract passed"
diff --git a/ci/docker-plugin-install-contract.sh b/ci/docker-plugin-install-contract.sh
index 1efdf94..8bf765c 100755
--- a/ci/docker-plugin-install-contract.sh
+++ b/ci/docker-plugin-install-contract.sh
@@ -103,6 +103,7 @@ run_installer() {
PATH="${BIN_DIR}:$PATH" \
DOCKER_PLUGIN_INSTALLER="$INSTALLER" \
+ CLOUD_COMPOSE_RELEASE_CHECKSUM_PROGRAM="$ROOT_DIR/rootfs/etc/cloud-compose/awk/release-checksum.awk" \
DOCKER_CLI_PLUGIN_DIR="$PLUGIN_DIR" \
DOCKER_COMPOSE_VERSION="$compose_version" \
DOCKER_BUILDX_VERSION="$buildx_version" \
diff --git a/ci/filesystem-prep-contract.sh b/ci/filesystem-prep-contract.sh
index 270e730..d9006fe 100644
--- a/ci/filesystem-prep-contract.sh
+++ b/ci/filesystem-prep-contract.sh
@@ -5,6 +5,12 @@ set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
prep_script="$repo_root/rootfs/home/cloud-compose/prepare-filesystem.sh"
persist_script="$repo_root/rootfs/home/cloud-compose/persist-filesystems.sh"
+export CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM="$repo_root/rootfs/etc/cloud-compose/awk/reconcile-fstab.awk"
+gcp_filesystem_boot="$repo_root/rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh"
+gcp_filesystem_boothook="$repo_root/templates/gcp-filesystem-boothook.sh.tftpl"
+gcp_cloud_init_mime="$repo_root/templates/gcp-cloud-init.mime.tftpl"
+gcp_cloud_init_finalize="$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh"
+linux_cloud_init="$repo_root/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh"
tmp="$(mktemp -d)"
trap 'rm -rf -- "$tmp"' EXIT
@@ -593,22 +599,38 @@ fi
if grep -Eq 'fsck[^\n]*\|\|[^\n]*mkfs|fsck[^\n]*mkfs' "$repo_root/templates/cloud-init.yml"; then
fail "GCP cloud-init still formats after an fsck failure"
fi
-grep -Fq 'FILESYSTEM_PREP_SCRIPT_B64' "$repo_root/templates/cloud-init.yml" || \
- fail "GCP cloud-init does not bootstrap the tested filesystem helper"
+grep -Fq 'FILESYSTEM_PREP_SCRIPT_B64' "$gcp_filesystem_boothook" || \
+ fail "GCP boothook does not bootstrap the tested filesystem helper"
+for compressed_boot_program in \
+ 'base64 -d | gzip -d >"$filesystem_boot"' \
+ 'base64 -d | gzip -d >"$filesystem_prep"' \
+ 'base64 -d | gzip -d >"$filesystem_persist"' \
+ 'base64 -d | gzip -d >"$filesystem_reconcile"'; do
+ grep -Fq "$compressed_boot_program" "$gcp_filesystem_boothook" || \
+ fail "GCP boothook does not unpack a compressed checked program: $compressed_boot_program"
+done
+grep -Fq 'bash "$filesystem_boot"' "$gcp_filesystem_boothook" || \
+ fail "GCP boothook does not invoke the checked filesystem program"
+grep -Fq 'Content-Type: text/cloud-boothook' "$gcp_cloud_init_mime" || \
+ fail "GCP user data does not preserve an early every-boot filesystem phase"
+if grep -Fq '/var/lib/cloud-compose/bootstrap/gcp-filesystem-boot.sh' \
+ "$repo_root/templates/cloud-init.yml"; then
+ fail "GCP cloud-config invokes filesystem preparation late from runcmd"
+fi
grep -Fq 'bash "$filesystem_prep" /dev/disk/by-id/google-data /mnt/disks/data \' \
- "$repo_root/templates/cloud-init.yml" ||
+ "$gcp_filesystem_boot" ||
fail "GCP cloud-init does not request a fresh marker for the data filesystem"
-grep -Fq -- '--publish-fresh-marker ${jsonencode(FRESH_FILESYSTEM_IDENTITY)}' \
- "$repo_root/templates/cloud-init.yml" ||
+grep -Fq -- '--publish-fresh-marker "$fresh_filesystem_identity"' \
+ "$gcp_filesystem_boot" ||
fail "GCP cloud-init does not bind the marker to the rendered data-disk identity"
-grep -Fq 'bash "$filesystem_prep" '\''${DATA_DEVICE}'\'' /mnt/disks/data --publish-fresh-marker' \
- "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml" ||
+grep -Fq 'bash "$filesystem_prep" "$data_device" /mnt/disks/data --publish-fresh-marker' \
+ "$linux_cloud_init" ||
fail "Linux VM cloud-init does not request a fresh marker for the data filesystem"
-grep -Fq 'bash /run/cloud-compose-prepare-filesystem /dev/disk/by-id/google-docker-volumes /mnt/disks/volumes' \
- "$repo_root/templates/cloud-init.yml" ||
+grep -Fq 'bash "$filesystem_prep" /dev/disk/by-id/google-docker-volumes /mnt/disks/volumes' \
+ "$gcp_filesystem_boot" ||
fail "GCP cloud-init does not prepare the volumes filesystem without a fresh marker"
-grep -Fq 'bash "$filesystem_prep" '\''${VOLUMES_DEVICE}'\'' /mnt/disks/volumes' \
- "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml" ||
+grep -Fq 'bash "$filesystem_prep" "$volumes_device" /mnt/disks/volumes' \
+ "$linux_cloud_init" ||
fail "Linux VM cloud-init does not prepare the volumes filesystem without a fresh marker"
grep -Fq '(umask 077 && mkdir -- "$marker_dir")' "$prep_script" ||
fail "the fresh-filesystem marker directory is not created privately"
@@ -623,41 +645,42 @@ grep -Fq -- '-L "$fresh_filesystem_pending_label" -- "$device"' "$prep_script" |
grep -Fq 'e2label "$device" ""' "$prep_script" ||
fail "fresh-filesystem pending intent is not cleared after marker publication"
if grep -Eq '^[[:space:]]*"?\$filesystem_(prep|persist)"?[[:space:]]' \
- "$repo_root/templates/cloud-init.yml" "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml"; then
+ "$gcp_filesystem_boot" "$linux_cloud_init"; then
fail "cloud-init directly executes a temporary helper from potentially noexec /run"
fi
-for cloud_init_template in \
- "$repo_root/templates/cloud-init.yml" \
- "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml"; do
- [[ "$(grep -Fc -- '--publish-fresh-marker' "$cloud_init_template")" == "1" ]] ||
- fail "cloud-init must publish a fresh marker for the data filesystem only"
- grep -Fq 'chown root:cloud-compose /mnt/disks/data' "$cloud_init_template" ||
- fail "cloud-init does not preserve root ownership of the data mount root"
- grep -Fq 'chmod 1775 /mnt/disks/data' "$cloud_init_template" ||
- fail "cloud-init does not make the data mount root sticky and group-writable"
- grep -Fq 'chown cloud-compose:cloud-compose /mnt/disks/volumes' "$cloud_init_template" ||
- fail "cloud-init does not assign the volumes mount root to cloud-compose"
- grep -Fq 'chmod 0775 /mnt/disks/volumes' "$cloud_init_template" ||
- fail "cloud-init does not make the volumes mount root group-writable"
+for filesystem_bootstrap in "$gcp_filesystem_boot" "$linux_cloud_init"; do
+ [[ "$(grep -Fc -- '--publish-fresh-marker' "$filesystem_bootstrap")" == "1" ]] ||
+ fail "$filesystem_bootstrap must publish a fresh marker for the data filesystem only"
grep -Fq 'for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do' \
- "$cloud_init_template" || fail "cloud-init does not verify every required mount before initialization"
- grep -Fq 'Required cloud-compose mount is unavailable:' "$cloud_init_template" ||
- fail "cloud-init mount gate does not report the unavailable path"
- grep -Fq ' bash /home/cloud-compose/start-cloud-compose-bootstrap.sh' \
- "$cloud_init_template" || fail "cloud-init retryable bootstrap startup is outside the fail-closed mount block"
- marker_reset_line="$(grep -nF ' rm -f /home/cloud-compose/.cloud-compose-bootstrap-complete' \
- "$cloud_init_template" | cut -d: -f1)"
- run_line="$(grep -nF ' bash /home/cloud-compose/start-cloud-compose-bootstrap.sh' \
- "$cloud_init_template" | cut -d: -f1)"
+ "$filesystem_bootstrap" || fail "$filesystem_bootstrap does not verify every required mount before initialization"
+ grep -Fq 'Required cloud-compose mount is unavailable:' "$filesystem_bootstrap" ||
+ fail "$filesystem_bootstrap mount gate does not report the unavailable path"
+done
+for bootstrap_finalize in "$gcp_cloud_init_finalize" "$linux_cloud_init"; do
+ grep -Fq 'chown root:cloud-compose /mnt/disks/data' "$bootstrap_finalize" ||
+ fail "$bootstrap_finalize does not preserve root ownership of the data mount root"
+ grep -Fq 'chmod 1775 /mnt/disks/data' "$bootstrap_finalize" ||
+ fail "$bootstrap_finalize does not make the data mount root sticky and group-writable"
+ grep -Fq 'chown cloud-compose:cloud-compose /mnt/disks/volumes' "$bootstrap_finalize" ||
+ fail "$bootstrap_finalize does not assign the volumes mount root to cloud-compose"
+ grep -Fq 'chmod 0775 /mnt/disks/volumes' "$bootstrap_finalize" ||
+ fail "$bootstrap_finalize does not make the volumes mount root group-writable"
+ grep -Fq 'bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh' \
+ "$bootstrap_finalize" || fail "$bootstrap_finalize does not start the retryable bootstrap service"
+ marker_reset_line="$(grep -nF 'rm -f /var/lib/cloud-compose/bootstrap-complete' \
+ "$bootstrap_finalize" | cut -d: -f1)"
+ run_line="$(grep -nF 'bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh' \
+ "$bootstrap_finalize" | cut -d: -f1)"
[[ -n "$marker_reset_line" && -n "$run_line" && "$marker_reset_line" -lt "$run_line" ]] ||
- fail "cloud-init does not clear stale bootstrap readiness before retryable startup"
+ fail "$bootstrap_finalize does not clear stale bootstrap readiness before retryable startup"
done
grep -Fq 'install -m 0600 /dev/null /run/cloud-compose-filesystems-ready' \
- "$repo_root/templates/cloud-init.yml" || fail "GCP bootcmd does not publish filesystem readiness"
-grep -Fq 'test -f /run/cloud-compose-filesystems-ready || {' \
- "$repo_root/templates/cloud-init.yml" || fail "GCP runcmd does not require filesystem readiness"
-grep -Fq 'test -f /run/cloud-compose-filesystems-ready || {' \
- "$repo_root/modules/gcp/main.tf" || fail "GCP archive installation is not gated by filesystem readiness"
+ "$gcp_filesystem_boot" || fail "GCP boot program does not publish filesystem readiness"
+grep -Fq '[[ ! -f /run/cloud-compose-filesystems-ready ]]' \
+ "$gcp_cloud_init_finalize" || fail "GCP finalization does not require filesystem readiness"
+grep -Fq 'fail "Cloud Compose filesystems were not prepared; refusing rootfs installation"' \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" || \
+ fail "GCP archive installation is not gated by filesystem readiness"
persist_tmp="$tmp/persist"
mkdir -p "$persist_tmp/bin"
@@ -779,11 +802,26 @@ if CLOUD_COMPOSE_FSTAB_PATH="$fstab" \
fail "filesystem persistence accepted an unsafe device path"
fi
-grep -Fq 'FILESYSTEM_PERSIST_SCRIPT_B64' "$repo_root/templates/cloud-init.yml" || \
- fail "GCP cloud-init does not bootstrap persistent mount configuration"
-grep -Fq 'FILESYSTEM_PERSIST_SCRIPT_B64' "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml" || \
- fail "Linux VM cloud-init does not bootstrap persistent mount configuration"
-grep -Fq '/var/lib/cloud-compose/mounted-rootfs/mnt/disks' "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml" || \
+grep -Fq 'FILESYSTEM_PERSIST_SCRIPT_B64' "$gcp_filesystem_boothook" || \
+ fail "GCP boothook does not bootstrap persistent mount configuration"
+grep -Fq 'FSTAB_RECONCILE_AWK_B64' "$gcp_filesystem_boothook" || \
+ fail "GCP boothook does not bootstrap the checked fstab reconciliation program"
+grep -Fq 'require_root_owned_data_program "$filesystem_reconcile"' "$gcp_filesystem_boot" || \
+ fail "GCP filesystem bootstrap does not verify its checked fstab reconciliation program"
+grep -Fq 'CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM="$filesystem_reconcile" bash "$filesystem_persist"' \
+ "$gcp_filesystem_boot" || \
+ fail "GCP filesystem bootstrap does not pass the checked fstab reconciliation program as data"
+grep -Fq 'bash "$filesystem_persist" "$data_device" "$volumes_device"' "$linux_cloud_init" || \
+ fail "Linux VM cloud-init does not invoke persistent mount configuration"
+grep -Fq 'install -m 0600 -- /etc/cloud-compose/awk/reconcile-fstab.awk "$filesystem_reconcile"' \
+ "$linux_cloud_init" || \
+ fail "embedded Linux bootstrap does not stage the checked fstab reconciliation program"
+grep -Fq 'require_root_owned_data_program "$filesystem_reconcile"' "$linux_cloud_init" || \
+ fail "Linux bootstrap does not verify its checked fstab reconciliation program"
+grep -Fq '"$staged_rootfs/etc/cloud-compose/awk/reconcile-fstab.awk"' \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" || \
+ fail "archive-mode Linux bootstrap does not stage the checked fstab reconciliation program from the verified rootfs"
+grep -Fq '/var/lib/cloud-compose/mounted-rootfs/mnt/disks' "$linux_cloud_init" || \
fail "Linux VM cloud-init does not copy mounted-root files after mounting durable storage"
do_module="$repo_root/modules/digitalocean/main.tf"
diff --git a/ci/fixtures/checked-lifecycle-executor.sh b/ci/fixtures/checked-lifecycle-executor.sh
new file mode 100644
index 0000000..5823272
--- /dev/null
+++ b/ci/fixtures/checked-lifecycle-executor.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+: "${CLOUD_COMPOSE_TEST_LIFECYCLE_EXECUTOR:?CLOUD_COMPOSE_TEST_LIFECYCLE_EXECUTOR is required}"
+
+run_compose_lifecycle_executor() {
+ "$CLOUD_COMPOSE_TEST_LIFECYCLE_EXECUTOR" "$@"
+}
diff --git a/ci/fixtures/config-management-lifecycle-lock.sh b/ci/fixtures/config-management-lifecycle-lock.sh
new file mode 100755
index 0000000..3323baf
--- /dev/null
+++ b/ci/fixtures/config-management-lifecycle-lock.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+action="${1:-}"
+profile="${2:-}"
+
+if [[ "$profile" != "/home/cloud-compose/profile.sh" || ! -f "$profile" || -L "$profile" ]]; then
+ echo "config-management lifecycle-lock fixture requires the checked runtime profile" >&2
+ exit 2
+fi
+
+# shellcheck disable=SC1090
+source "$profile"
+
+case "$action" in
+ hold)
+ ready="${3:-}"
+ [[ "$#" -eq 3 && "$ready" == /tmp/cloud-compose-lifecycle-lock-ready ]] || {
+ echo "usage: config-management-lifecycle-lock.sh hold PROFILE READY" >&2
+ exit 2
+ }
+ acquire_cloud_compose_lifecycle_lock root-first-contract
+ touch -- "$ready"
+ sleep 3
+ release_cloud_compose_lifecycle_lock
+ ;;
+ contend)
+ [[ "$#" -eq 2 ]] || {
+ echo "usage: config-management-lifecycle-lock.sh contend PROFILE" >&2
+ exit 2
+ }
+ acquire_cloud_compose_lifecycle_lock contention-contract
+ ;;
+ subshell)
+ [[ "$#" -eq 2 ]] || {
+ echo "usage: config-management-lifecycle-lock.sh subshell PROFILE" >&2
+ exit 2
+ }
+ (
+ acquire_cloud_compose_lifecycle_lock subshell-contract
+ release_cloud_compose_lifecycle_lock
+ )
+ ;;
+ reject-symlink)
+ [[ "$#" -eq 2 ]] || {
+ echo "usage: config-management-lifecycle-lock.sh reject-symlink PROFILE" >&2
+ exit 2
+ }
+ acquire_cloud_compose_lifecycle_lock symlink-contract
+ ;;
+ *)
+ echo "usage: config-management-lifecycle-lock.sh hold|contend|subshell|reject-symlink ..." >&2
+ exit 2
+ ;;
+esac
diff --git a/ci/fixtures/default-ingress-compose-project.jq b/ci/fixtures/default-ingress-compose-project.jq
new file mode 100644
index 0000000..154789f
--- /dev/null
+++ b/ci/fixtures/default-ingress-compose-project.jq
@@ -0,0 +1,16 @@
+{
+ app: {
+ docker_compose_repo: "https://github.com/libops/wp.git",
+ docker_compose_branch: "v1.1.1",
+ project_dir: $project_dir,
+ compose_project_name: "app",
+ sitectl_context_name: "app",
+ sitectl_plugin: "wp",
+ sitectl_environment: "preview",
+ ingress: {},
+ init_commands: [],
+ up_commands: [],
+ down_commands: [],
+ rollout_commands: []
+ }
+}
diff --git a/ci/fixtures/default-ingress-sitectl.argv b/ci/fixtures/default-ingress-sitectl.argv
new file mode 100644
index 0000000..fb5dc4f
--- /dev/null
+++ b/ci/fixtures/default-ingress-sitectl.argv
@@ -0,0 +1,10 @@
+
+
+
+<--context>
+
+<--yolo>
+
+<--context>
+
+<--yolo>
diff --git a/ci/fixtures/gcp-upgrade-smoke-contract-harness.sh b/ci/fixtures/gcp-upgrade-smoke-contract-harness.sh
new file mode 100755
index 0000000..07212ac
--- /dev/null
+++ b/ci/fixtures/gcp-upgrade-smoke-contract-harness.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+action="${1:-}"
+runner="${2:-}"
+[[ -f "$runner" && ! -L "$runner" ]] || {
+ echo "GCP upgrade contract harness requires the checked smoke runner" >&2
+ exit 2
+}
+
+# shellcheck disable=SC1090
+source "$runner"
+
+case "$action" in
+ supported-cidrs)
+ [[ "$#" -eq 2 ]]
+ valid_direct_vpc_cidr "10.60.0.0/26"
+ valid_direct_vpc_cidr "172.20.0.0/24"
+ valid_direct_vpc_cidr "100.64.0.0/26"
+ valid_direct_vpc_cidr "240.0.0.0/26"
+ ;;
+ cidr)
+ [[ "$#" -eq 3 ]]
+ valid_direct_vpc_cidr "$3"
+ ;;
+ network-ownership)
+ [[ "$#" -eq 6 ]]
+ validate_upgrade_network_ownership "$3" "$4" "$5" "$6"
+ ;;
+ write-tfvars)
+ [[ "$#" -eq 3 ]]
+ write_tfvars "$3" name project us-east5 us-east5-b key 192.0.2.1/32 \
+ project network subnet projects/project/roles/startVM \
+ projects/project/roles/suspendVM true
+ ;;
+ cleanup-failure)
+ [[ "$#" -eq 3 ]]
+ [[ -d "$3/.git" ]]
+ : "${CLEANUP_LOG:?CLEANUP_LOG is required}"
+ require_cmd() { :; }
+ require_env() { :; }
+ validate_upgrade_network() { :; }
+ cleanup_calls=0
+ cleanup_resources() {
+ cleanup_calls=$((cleanup_calls + 1))
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$@" >>"$CLEANUP_LOG"
+ [[ "$cleanup_calls" -gt 1 ]]
+ }
+ run_upgrade
+ ;;
+ *)
+ echo "usage: gcp-upgrade-smoke-contract-harness.sh supported-cidrs|cidr|network-ownership|write-tfvars|cleanup-failure RUNNER ..." >&2
+ exit 2
+ ;;
+esac
diff --git a/ci/fixtures/invalid-checked-program.jq b/ci/fixtures/invalid-checked-program.jq
new file mode 100644
index 0000000..6e5fbf9
--- /dev/null
+++ b/ci/fixtures/invalid-checked-program.jq
@@ -0,0 +1 @@
+this is not a valid jq program
diff --git a/ci/fixtures/lifecycle.d/default-up b/ci/fixtures/lifecycle.d/default-up
new file mode 100755
index 0000000..db50257
--- /dev/null
+++ b/ci/fixtures/lifecycle.d/default-up
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)"
+exec bash "$repo_root/rootfs/home/cloud-compose/default-lifecycle.sh" up
diff --git a/ci/fixtures/sitectl-argv-log.sh b/ci/fixtures/sitectl-argv-log.sh
new file mode 100755
index 0000000..0d284d2
--- /dev/null
+++ b/ci/fixtures/sitectl-argv-log.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+printf '<%s>\n' "$@" >>"${SITECTL_ARGV_LOG:?SITECTL_ARGV_LOG is required}"
diff --git a/ci/fixtures/source-trust-rollout.sh b/ci/fixtures/source-trust-rollout.sh
new file mode 100755
index 0000000..996408c
--- /dev/null
+++ b/ci/fixtures/source-trust-rollout.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+target_ref="${SOURCE_TRUST_ROLLOUT_REF:?SOURCE_TRUST_ROLLOUT_REF is required}"
+if [[ ! "$target_ref" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ||
+ "$target_ref" == -* || "$target_ref" =~ (^|/)\.\.?(/|$) ]]; then
+ echo "Unsafe source-trust rollout ref: $target_ref" >&2
+ exit 2
+fi
+
+git fetch -- origin "$target_ref"
+git checkout --detach FETCH_HEAD
diff --git a/ci/fixtures/verify-argv-compose-project.jq b/ci/fixtures/verify-argv-compose-project.jq
new file mode 100644
index 0000000..3f22402
--- /dev/null
+++ b/ci/fixtures/verify-argv-compose-project.jq
@@ -0,0 +1,15 @@
+{
+ app: {
+ docker_compose_repo: "https://github.com/libops/wp.git",
+ docker_compose_branch: "main",
+ project_dir: $project_dir,
+ compose_project_name: "app",
+ sitectl_context_name: "app",
+ sitectl_environment: "preview",
+ sitectl_verify_args: ["--label", "value with spaces"],
+ up_commands: [$up_program],
+ init_commands: [],
+ down_commands: [],
+ rollout_commands: []
+ }
+}
diff --git a/ci/gcp-upgrade-smoke-contract.sh b/ci/gcp-upgrade-smoke-contract.sh
index 6d02628..d2a668b 100755
--- a/ci/gcp-upgrade-smoke-contract.sh
+++ b/ci/gcp-upgrade-smoke-contract.sh
@@ -24,10 +24,34 @@ script="$repo_root/ci/gcp-upgrade-smoke.sh"
shared_smoke="$repo_root/ci/cloud-smoke.sh"
fixture="$repo_root/tests/smoke/gcp-upgrade/main.tf"
variables="$repo_root/tests/smoke/gcp-upgrade/variables.tf"
+fixture_prepare="$repo_root/tests/smoke/gcp-upgrade/rootfs/home/cloud-compose/gcp-upgrade-prepare-repository.sh"
+fixture_up="$repo_root/tests/smoke/gcp-upgrade/rootfs/etc/cloud-compose/lifecycle.d/gcp-upgrade-up.sh"
+context_fixture="$repo_root/tests/smoke/modules/context/main.tf"
+remote_services="$repo_root/ci/remote/gcp-upgrade-assert-services-disabled.sh"
+remote_metadata="$repo_root/ci/remote/gcp-upgrade-verify-metadata-isolation.sh"
+remote_container_metadata="$repo_root/ci/remote/gcp-upgrade-container-metadata-isolation.sh"
+remote_sentinels="$repo_root/ci/remote/gcp-upgrade-write-disk-sentinels.sh"
+remote_filesystem_size="$repo_root/ci/remote/gcp-upgrade-read-filesystem-size.sh"
workflow="$repo_root/.github/workflows/cloud-smoke.yml"
docs="$repo_root/docs/runtime-contracts.md"
-
-for required in "$script" "$shared_smoke" "$fixture" "$variables" "$workflow" "$docs"; do
+contract_harness="$repo_root/ci/fixtures/gcp-upgrade-smoke-contract-harness.sh"
+
+for required in \
+ "$script" \
+ "$shared_smoke" \
+ "$fixture" \
+ "$variables" \
+ "$fixture_prepare" \
+ "$fixture_up" \
+ "$context_fixture" \
+ "$remote_services" \
+ "$remote_metadata" \
+ "$remote_container_metadata" \
+ "$remote_sentinels" \
+ "$remote_filesystem_size" \
+ "$contract_harness" \
+ "$workflow" \
+ "$docs"; do
[[ -f "$required" ]] || fail "required file is missing: $required"
done
@@ -47,16 +71,33 @@ grep -Fq "provider_tag_cleanup gcp-wp \"\$run_id\"" "$script" ||
fail "upgrade runner does not finish cleanup with the verified provider sweep"
grep -Fq 'target_env gcp-wp' "$script" ||
fail "upgrade cleanup does not load the concrete GCP WordPress target environment"
-[[ "$(grep -Fc 'sudo test -f /home/cloud-compose/run.log && sudo test ! -L /home/cloud-compose/run.log' "$shared_smoke")" -eq 2 ]] ||
- fail "shared smoke diagnostics do not retain guarded legacy bootstrap logs for the pinned baseline"
-grep -Fq 'bootstrap_load_state=\"\$(systemctl show --property=LoadState --value -- cloud-compose-bootstrap.service' "$shared_smoke" ||
- fail "shared smoke readiness does not distinguish retryable bootstrap from the legacy baseline"
-grep -Fq 'active:* | activating:* | *:auto-restart)' "$shared_smoke" ||
- fail "shared smoke readiness can abandon the bootstrap unit during its restart delay"
-grep -Fq 'elif [ \"\$bootstrap_load_state\" = not-found ] &&' "$shared_smoke" ||
- fail "shared smoke readiness does not restrict legacy fallback to an absent bootstrap unit"
-grep -Fq 'systemctl is-active --quiet cloud-compose; then' "$shared_smoke" ||
+grep -Fq 'readonly diagnostics_program="/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"' "$shared_smoke" ||
+ fail "shared smoke diagnostics do not use the checked-in privileged program"
+for diagnostics_command in state status dump; do
+ grep -Fq "sudo -n \${diagnostics_program} ${diagnostics_command}" "$shared_smoke" ||
+ fail "shared smoke diagnostics do not invoke the ${diagnostics_command} command non-interactively"
+done
+grep -Fq 'test -f /home/cloud-compose/.cloud-compose-bootstrap-complete' "$shared_smoke" ||
+ fail "shared smoke readiness lost its pinned-baseline marker compatibility probe"
+grep -Fq 'systemctl is-active --quiet cloud-compose.service' "$shared_smoke" ||
fail "shared smoke readiness lost its pre-bootstrap-unit compatibility signal"
+grep -Fq 'cloud-init completed without the Cloud Compose readiness marker' "$shared_smoke" ||
+ fail "current smoke readiness can still accept cloud-init completion without durable application readiness"
+grep -Fq 'The pinned upgrade fixture predates the durable readiness marker.' "$shared_smoke" ||
+ fail "legacy cloud-init completion is not explicitly confined to the pinned upgrade fixture"
+grep -Fq 'test ! -L /home/cloud-compose/run.log' "$shared_smoke" ||
+ fail "shared smoke diagnostics do not retain a guarded legacy bootstrap log fallback"
+grep -Fq 'sudo -n /usr/bin/systemctl status cloud-compose.service' "$shared_smoke" ||
+ fail "shared smoke diagnostics do not limit pinned-baseline sudo to its existing exact command"
+grep -Fq 'readonly smoke_healthcheck_program="/home/cloud-compose/smoke-healthcheck.sh"' "$shared_smoke" ||
+ fail "shared smoke healthcheck does not use the checked-in host wrapper"
+grep -Fq '"${smoke_healthcheck_program} ${quoted_context}"' "$shared_smoke" ||
+ fail "shared smoke healthcheck does not invoke the checked-in host wrapper directly"
+grep -Fq 'env HOME=/home/cloud-compose DOCKER_CONFIG=/mnt/disks/data/docker-config PATH=/home/cloud-compose/bin:' "$shared_smoke" ||
+ fail "shared smoke healthcheck lost its direct pinned-baseline fallback"
+if sed -n '/^run_healthcheck()/,/^}/p' "$shared_smoke" | grep -Fq 'bash -lc'; then
+ fail "shared smoke healthcheck still sends an embedded Bash program over SSH"
+fi
grep -Fq 'CLOUD_COMPOSE_SMOKE_RUN_ID must match GITHUB_RUN_ID in GitHub Actions' "$script" ||
fail "hosted cleanup ownership is not bound to the actual GitHub run id"
grep -Fq 'CLOUD_COMPOSE_SMOKE_RUN_ID must be set explicitly outside GitHub Actions' "$script" ||
@@ -83,16 +124,20 @@ grep -Fq '/mnt/disks/data/.cloud-compose-upgrade-sentinel' "$script" ||
fail "upgrade runner omits the persistent data-disk sentinel"
grep -Fq '/mnt/disks/volumes/.cloud-compose-upgrade-sentinel' "$script" ||
fail "upgrade runner omits the Docker-volume disk sentinel"
-[[ "$(grep -Fc 'sudo tee /mnt/disks/data/.cloud-compose-upgrade-sentinel' "$script")" -eq 1 ]] ||
- fail "upgrade runner must write the data-disk sentinel exactly once"
-[[ "$(grep -Fc 'sudo tee /mnt/disks/volumes/.cloud-compose-upgrade-sentinel' "$script")" -eq 1 ]] ||
- fail "upgrade runner must write the Docker-volume sentinel exactly once"
+grep -Fq 'gcp-upgrade-write-disk-sentinels.sh' "$script" ||
+ fail "upgrade runner does not stage the checked disk-sentinel program"
+[[ "$(grep -Fc '>/mnt/disks/data/.cloud-compose-upgrade-sentinel' "$remote_sentinels")" -eq 1 ]] ||
+ fail "checked remote program must write the data-disk sentinel exactly once"
+[[ "$(grep -Fc '>/mnt/disks/volumes/.cloud-compose-upgrade-sentinel' "$remote_sentinels")" -eq 1 ]] ||
+ fail "checked remote program must write the Docker-volume sentinel exactly once"
+grep -Fq '[[ "$#" -ne 1 || ! "$1" =~ ^[A-Za-z0-9._:-]{1,128}$ ]]' "$remote_sentinels" ||
+ fail "checked disk-sentinel program does not validate its nonce argument"
grep -Fq 'read_data_filesystem_size_bytes()' "$script" ||
fail "upgrade runner does not measure mounted application-data filesystem capacity"
-grep -Fq 'filesystem="$(findmnt -n -o FSTYPE --target /mnt/disks/data)"' "$script" ||
- fail "upgrade runner does not require the application-data mount to remain ext4"
-grep -Fq 'df --block-size=1 --output=size -- /mnt/disks/data' "$script" ||
- fail "upgrade runner does not measure mounted ext4 capacity in bytes"
+grep -Fq 'filesystem="$(findmnt -n -o FSTYPE --target /mnt/disks/data)"' "$remote_filesystem_size" ||
+ fail "checked filesystem-size program does not require the application-data mount to remain ext4"
+grep -Fq 'df --block-size=1 --output=size -- /mnt/disks/data' "$remote_filesystem_size" ||
+ fail "checked filesystem-size program does not measure mounted ext4 capacity in bytes"
grep -Fq '((10#$upgraded_data_filesystem_bytes > 10#$baseline_data_filesystem_bytes))' "$script" ||
fail "upgrade runner does not prove mounted ext4 grew beyond its baseline capacity"
@@ -141,11 +186,28 @@ grep -Fq 'branch = var.wordpress_compose_ref' "$fixture" ||
grep -Fq 'projects = var.legacy_baseline ? {} : {' "$fixture" ||
fail "upgrade fixture does not transition from the legacy inputs to the current project map"
for unit in internal-services.timer cloud-compose-internal-services.timer; do
- grep -Fq "$unit" "$fixture" || fail "upgrade fixture does not disable ${unit}"
+ grep -Fq "$unit" "$fixture_prepare" || fail "upgrade fixture program does not disable ${unit}"
done
grep -Fq 'initcmd = [' "$fixture" ||
fail "upgrade fixture does not disable internal-service timers before bootstrap"
-grep -Fq 'git -c safe.directory=\"$project\" -C \"$project\"' "$fixture" ||
+grep -Fq 'rootfs = "${path.module}/rootfs"' "$fixture" ||
+ fail "upgrade fixture does not package its checked initialization program"
+grep -Fq 'bash /home/cloud-compose/gcp-upgrade-prepare-repository.sh' "$fixture" ||
+ fail "upgrade fixture does not invoke its checked initialization program"
+grep -Fq '"/etc/cloud-compose/lifecycle.d/gcp-upgrade-up.sh"' "$fixture" ||
+ fail "upgrade fixture does not invoke its checked lifecycle program"
+grep -Fq '"/home/cloud-compose/default-lifecycle.sh up"' "$context_fixture" ||
+ fail "hosted smoke context does not invoke the checked default lifecycle program"
+grep -Fq 'sitectl compose --context "$context" up -d --remove-orphans' "$fixture_up" ||
+ fail "checked upgrade lifecycle program does not bring up the exact sitectl context"
+grep -Fq 'sitectl healthcheck --context "$context" --persist' "$fixture_up" ||
+ fail "checked upgrade lifecycle program does not persist the post-start healthcheck"
+[[ -x "$fixture_up" ]] ||
+ fail "checked upgrade lifecycle program is not executable"
+if grep -Eq '"sitectl (compose|healthcheck)' "$fixture" "$context_fixture"; then
+ fail "smoke fixtures still embed raw sitectl lifecycle commands in Terraform"
+fi
+grep -Fq 'git -c safe.directory="$project" -C "$project"' "$fixture_prepare" ||
fail "upgrade fixture does not explicitly trust its preserved pinned repository"
if grep -Fq 'runcmd = [' "$fixture"; then
fail "upgrade fixture defers its timer shutdown until after bootstrap"
@@ -158,26 +220,42 @@ baseline_initcmd_line="$(grep -nF 'for CMD in ADDITIONAL_INITCMD' "$tmp/baseline
fail "baseline cloud-init does not execute fixture initcmd before run.sh"
current_bootstrap_line="$(
- grep -nF 'bash /home/cloud-compose/start-cloud-compose-bootstrap.sh' \
- "$repo_root/templates/cloud-init.yml" |
+ grep -nF 'bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh' \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh" |
cut -d: -f1 || true
)"
current_initcmd_line="$(
- grep -nF 'for CMD in ADDITIONAL_INITCMD' "$repo_root/templates/cloud-init.yml" |
+ grep -nF 'source "$init_commands_file"' \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh" |
cut -d: -f1 || true
)"
[[ -n "$current_initcmd_line" && -n "$current_bootstrap_line" && "$current_initcmd_line" -lt "$current_bootstrap_line" ]] ||
- fail "current cloud-init does not execute fixture initcmd before retryable bootstrap"
-grep -Fq 'for unit in internal-services.timer internal-services.service cloud-compose-internal-services.timer cloud-compose-internal-services.service' "$script" ||
- fail "upgrade runner does not assert that both timer generations remain inactive after boot"
+ fail "current checked-in cloud-init program does not execute fixture initcmd before retryable bootstrap"
+for unit in internal-services.timer internal-services.service cloud-compose-internal-services.timer cloud-compose-internal-services.service; do
+ grep -Fq "$unit" "$remote_services" ||
+ fail "checked service assertion does not cover ${unit}"
+done
+grep -Fq 'gcp-upgrade-assert-services-disabled.sh' "$script" ||
+ fail "upgrade runner does not invoke the checked service assertion after boot"
grep -Fq 'run_direct_vpc_cold_start "$new_home" "$key_path" "$new_output" "$name" "$zone"' "$script" ||
fail "upgrade runner does not exercise the upgraded Direct VPC cold-start path"
grep -Fq 'verify_metadata_isolation "$new_home" "$key_path" "$new_output"' "$script" ||
fail "upgrade runner does not verify metadata isolation after the replacement boot"
-grep -Fq 'nslookup metadata.google.internal' "$script" ||
+grep -Fq 'nslookup metadata.google.internal' "$remote_container_metadata" ||
fail "upgrade runner does not prove that Compute internal DNS survives metadata isolation"
-grep -Fq 'sudo -u cloud-compose curl' "$script" ||
+grep -Fq 'for metadata_scheme in http https; do' "$remote_metadata" ||
fail "upgrade runner does not prove that unprivileged host metadata access is denied"
+grep -Fq 'type=bind,src=${container_program},dst=/usr/local/bin/cloud-compose-metadata-isolation,readonly' "$remote_metadata" ||
+ fail "metadata-isolation smoke does not mount its checked container program read-only"
+grep -Fq '/bin/sh /usr/local/bin/cloud-compose-metadata-isolation' "$remote_metadata" ||
+ fail "metadata-isolation smoke does not invoke its checked container program directly"
+if grep -Eq 'base64[^[:space:]]*[[:space:]]*\|[[:space:]]*(sudo[[:space:]]+)?bash|ssh_cmd.*bash[[:space:]]+-(c|lc)' "$script"; then
+ fail "upgrade runner still sends an embedded Bash program to the remote host"
+fi
+grep -Fq 'remote_dir=/home/cloud-compose/.cache/libops-ci' "$script" ||
+ fail "upgrade runner does not stage checked remote programs at a stable path"
+grep -Fq 'install -m 0700 /dev/stdin $remote_path' "$script" ||
+ fail "upgrade runner does not install checked remote programs with a private executable mode"
grep -Fq -- '--header '\''X-Forwarded-For: 10.0.0.8'\''' "$script" ||
fail "Direct VPC smoke does not test an attacker-controlled forwarded prefix"
grep -Fq -- '--max-time 600' "$script" ||
@@ -208,24 +286,20 @@ grep -Fq 'valid_direct_vpc_cidr "$subnet_cidr"' "$script" ||
fail "upgrade runner does not enforce Cloud Run Direct VPC supported IPv4 ranges"
grep -Fq '((10#$subnet_prefix <= 26))' "$script" ||
fail "upgrade runner does not require a /26-or-larger persistent Direct VPC subnet"
-bash -c '
- source "$1"
- valid_direct_vpc_cidr "10.60.0.0/26"
- valid_direct_vpc_cidr "172.20.0.0/24"
- valid_direct_vpc_cidr "100.64.0.0/26"
- valid_direct_vpc_cidr "240.0.0.0/26"
-' _ "$script" || fail "upgrade runner rejected a supported Direct VPC IPv4 range"
-if bash -c 'source "$1"; valid_direct_vpc_cidr "203.0.113.0/24"' _ "$script"; then
+"$contract_harness" supported-cidrs "$script" ||
+ fail "upgrade runner rejected a supported Direct VPC IPv4 range"
+if "$contract_harness" cidr "$script" "203.0.113.0/24"; then
fail "upgrade runner accepted an unsupported Direct VPC IPv4 range"
fi
-bash -c 'source "$1"; validate_upgrade_network_ownership "service-project" "service-project" "ci-network" "ci-subnet"' \
- _ "$script" || fail "upgrade runner rejected safe persistent-network ownership"
-if bash -c 'source "$1"; validate_upgrade_network_ownership "service-project" "host-project" "ci-network" "ci-subnet"' \
- _ "$script" >/dev/null 2>&1; then
+"$contract_harness" network-ownership "$script" \
+ service-project service-project ci-network ci-subnet ||
+ fail "upgrade runner rejected safe persistent-network ownership"
+if "$contract_harness" network-ownership "$script" \
+ service-project host-project ci-network ci-subnet >/dev/null 2>&1; then
fail "upgrade runner accepted Shared VPC input unsupported by the baseline"
fi
-if bash -c 'source "$1"; validate_upgrade_network_ownership "service-project" "service-project" "cc-g-wp-owned" "ci-subnet"' \
- _ "$script" >/dev/null 2>&1; then
+if "$contract_harness" network-ownership "$script" \
+ service-project service-project cc-g-wp-owned ci-subnet >/dev/null 2>&1; then
fail "upgrade runner placed its persistent network inside the disposable sweep boundary"
fi
network_validation_line="$(grep -nF ' validate_upgrade_network \' "$script" | cut -d: -f1)"
@@ -239,23 +313,18 @@ wordpress_ref='5058610fddc7267ace92d65a5c49713dce570ac3'
fail "upgrade fixture variable does not default to and enforce the pinned WordPress commit"
grep -Fq 'docker_compose_branch = var.wordpress_compose_ref' "$fixture" ||
fail "upgrade fixture does not use its pinned WordPress commit"
-grep -Fq "git_project checkout --detach \${var.wordpress_compose_ref}" "$fixture" ||
+grep -Fq 'git_project checkout --detach "$revision"' "$fixture_prepare" ||
fail "legacy bootstrap does not pre-check out the exact WordPress commit"
grep -Eq 'wordpress_project_dir[[:space:]]*=[[:space:]]*"/mnt/disks/data/libops/wp.git/\$\{var\.wordpress_compose_ref\}"' "$fixture" ||
fail "upgrade fixture does not derive the exact legacy single-project checkout path"
-grep -Fq 'project=${local.wordpress_project_dir};' "$fixture" ||
- fail "upgrade fixture does not pre-check out the shared baseline/current Compose path"
+grep -Fq '[[ "$project" == "/mnt/disks/data/libops/wp.git/${revision}" ]]' "$fixture_prepare" ||
+ fail "upgrade fixture program does not constrain the shared baseline/current Compose path"
grep -Fq 'project_dir = local.wordpress_project_dir' "$fixture" ||
fail "baseline and current phases do not share the pinned Compose checkout path"
tfvars_dir="$tmp/tfvars"
mkdir -p "$tfvars_dir"
-bash -c '
- set -euo pipefail
- source "$1"
- write_tfvars "$2" name project us-east5 us-east5-b key 192.0.2.1/32 \
- project network subnet projects/project/roles/startVM projects/project/roles/suspendVM true
-' _ "$script" "$tfvars_dir"
+"$contract_harness" write-tfvars "$script" "$tfvars_dir"
jq -e '.legacy_baseline == true' "$tfvars_dir/upgrade.auto.tfvars.json" >/dev/null ||
fail "upgrade runner does not mark the old phase as a legacy baseline"
@@ -273,21 +342,7 @@ GCLOUD_POWER_START_ROLE=projects/contract-project/roles/startVM \
GCLOUD_POWER_SUSPEND_ROLE=projects/contract-project/roles/suspendVM \
CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR="$tmp/cleanup-work" \
CLEANUP_LOG="$cleanup_log" \
- bash -c '
- set -euo pipefail
- source "$1"
- repo_root="$2"
- require_cmd() { :; }
- require_env() { :; }
- validate_upgrade_network() { :; }
- cleanup_calls=0
- cleanup_resources() {
- cleanup_calls=$((cleanup_calls + 1))
- printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" "$@" >>"$CLEANUP_LOG"
- [[ "$cleanup_calls" -gt 1 ]]
- }
- run_upgrade
- ' _ "$script" "$repo_root"
+ "$contract_harness" cleanup-failure "$script" "$repo_root"
cleanup_status=$?
set -e
[[ "$cleanup_status" -eq 1 ]] ||
diff --git a/ci/gcp-upgrade-smoke.sh b/ci/gcp-upgrade-smoke.sh
index f05c07e..23a0630 100755
--- a/ci/gcp-upgrade-smoke.sh
+++ b/ci/gcp-upgrade-smoke.sh
@@ -234,6 +234,22 @@ phase_output() {
chmod 0600 "$output_json"
}
+stage_upgrade_remote_program() {
+ local home_dir="$1" key_path="$2" host="$3" port="$4" user="$5"
+ local source_path="$6" program_name="$7"
+ local remote_dir=/home/cloud-compose/.cache/libops-ci remote_path
+
+ [[ "$program_name" =~ ^[a-z0-9][a-z0-9.-]*\.sh$ ]] ||
+ fail "unsafe remote upgrade program name: $program_name"
+ [[ -f "$source_path" && ! -L "$source_path" ]] ||
+ fail "remote upgrade program is missing or unsafe: $source_path"
+ remote_path="$remote_dir/$program_name"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "install -d -m 0700 $remote_dir && install -m 0700 /dev/stdin $remote_path" \
+ <"$source_path" || return 1
+ printf '%s\n' "$remote_path"
+}
+
dump_remote_logs_from_output() {
local home_dir="$1" key_path="$2" output_json="$3"
local host port user project_dir
@@ -247,7 +263,7 @@ dump_remote_logs_from_output() {
run_phase_healthcheck() {
local phase="$1" home_dir="$2" key_path="$3" output_json="$4"
- local host port user project_dir
+ local host port user project_dir remote_program
host="$(jq -er '.host' "$output_json")"
port="$(jq -er '.ssh_port' "$output_json")"
@@ -262,16 +278,11 @@ run_phase_healthcheck() {
dump_remote_logs "$home_dir" "$key_path" "$host" "$port" "$user" "$project_dir"
return 1
fi
- ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" "bash -lc 'set -euo pipefail
-sudo systemctl disable --now internal-services.timer internal-services.service 2>/dev/null || true
-sudo systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service 2>/dev/null || true
-for unit in internal-services.timer internal-services.service cloud-compose-internal-services.timer cloud-compose-internal-services.service; do
- if sudo systemctl is-active --quiet \"\$unit\" 2>/dev/null; then
- echo \"Fixture-only internal service remained active: \$unit\" >&2
- exit 1
- fi
-done
-'"
+ remote_program="$(stage_upgrade_remote_program \
+ "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "$repo_root/ci/remote/gcp-upgrade-assert-services-disabled.sh" \
+ gcp-upgrade-assert-services-disabled.sh)"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" "$remote_program"
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"
@@ -357,76 +368,40 @@ run_direct_vpc_cold_start() {
verify_metadata_isolation() {
local home_dir="$1" key_path="$2" output_json="$3"
- local host port user remote_script encoded_script quoted_script
+ local host port user remote_program container_program quoted_container_program
host="$(jq -er '.host' "$output_json")"
port="$(jq -er '.ssh_port' "$output_json")"
user="$(jq -er '.ssh_user' "$output_json")"
- read -r -d '' remote_script <<'EOF' || true
-set -euo pipefail
-
-metadata_address="169.254.169.254"
-metadata_header="Metadata-Flavor: Google"
-alpine_image="alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"
-
-# The default bridge must retain normal and Compute internal DNS through the
-# metadata resolver, while metadata HTTP and HTTPS remain unreachable.
-docker run --rm --network bridge "$alpine_image" /bin/sh -ec '
- nslookup dl-cdn.alpinelinux.org >/dev/null
- nslookup metadata.google.internal | grep -Fq "169.254.169.254"
- for metadata_port in 80 443; do
- if nc -z -w 3 169.254.169.254 "$metadata_port"; then
- echo "Container reached GCP metadata TCP port ${metadata_port}" >&2
- exit 1
- fi
- done
-'
-
-# Root retains the narrow access required by key rotation.
-curl -fsS --connect-timeout 3 --max-time 10 \
- --header "$metadata_header" \
- "http://${metadata_address}/computeMetadata/v1/instance/id" >/dev/null
-
-# No unprivileged host process may reach either metadata transport.
-if sudo -u cloud-compose curl -kfsS --connect-timeout 3 --max-time 5 \
- --header "$metadata_header" \
- "http://${metadata_address}/computeMetadata/v1/instance/id" >/dev/null 2>&1; then
- echo "Unprivileged host process reached GCP metadata HTTP" >&2
- exit 1
-fi
-if sudo -u cloud-compose curl -kfsS --connect-timeout 3 --max-time 5 \
- --header "$metadata_header" \
- "https://${metadata_address}/computeMetadata/v1/instance/id" >/dev/null 2>&1; then
- echo "Unprivileged host process reached GCP metadata HTTPS" >&2
- exit 1
-fi
-EOF
-
- encoded_script="$(printf '%s' "$remote_script" | base64 | tr -d '\n')"
- quoted_script="$(shell_quote "$encoded_script")"
+ remote_program="$(stage_upgrade_remote_program \
+ "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "$repo_root/ci/remote/gcp-upgrade-verify-metadata-isolation.sh" \
+ gcp-upgrade-verify-metadata-isolation.sh)"
+ container_program="$(stage_upgrade_remote_program \
+ "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "$repo_root/ci/remote/gcp-upgrade-container-metadata-isolation.sh" \
+ gcp-upgrade-container-metadata-isolation.sh)"
+ quoted_container_program="$(shell_quote "$container_program")"
echo "Verifying GCP DNS continuity and metadata isolation after the replacement boot"
ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
- "printf %s ${quoted_script} | base64 -d | sudo bash"
+ "$remote_program ${quoted_container_program}"
}
write_disk_sentinels() {
local home_dir="$1" key_path="$2" output_json="$3" nonce="$4"
- local host port user encoded_nonce quoted_nonce
+ local host port user remote_program quoted_nonce
host="$(jq -er '.host' "$output_json")"
port="$(jq -er '.ssh_port' "$output_json")"
user="$(jq -er '.ssh_user' "$output_json")"
- encoded_nonce="$(printf '%s' "$nonce" | base64 | tr -d '\n')"
- quoted_nonce="$(shell_quote "$encoded_nonce")"
-
- ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" "bash -lc 'set -euo pipefail
-sudo findmnt -n /mnt/disks/data >/dev/null
-sudo findmnt -n /mnt/disks/volumes >/dev/null
-printf %s ${quoted_nonce} | base64 -d | sudo tee /mnt/disks/data/.cloud-compose-upgrade-sentinel >/dev/null
-printf %s ${quoted_nonce} | base64 -d | sudo tee /mnt/disks/volumes/.cloud-compose-upgrade-sentinel >/dev/null
-sudo sync
-'"
+ remote_program="$(stage_upgrade_remote_program \
+ "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "$repo_root/ci/remote/gcp-upgrade-write-disk-sentinels.sh" \
+ gcp-upgrade-write-disk-sentinels.sh)"
+ quoted_nonce="$(shell_quote "$nonce")"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "$remote_program ${quoted_nonce}"
}
verify_disk_sentinels() {
@@ -437,9 +412,9 @@ verify_disk_sentinels() {
port="$(jq -er '.ssh_port' "$output_json")"
user="$(jq -er '.ssh_user' "$output_json")"
actual_data="$(ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
- "sudo cat /mnt/disks/data/.cloud-compose-upgrade-sentinel")"
+ "cat /mnt/disks/data/.cloud-compose-upgrade-sentinel")"
actual_volumes="$(ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
- "sudo cat /mnt/disks/volumes/.cloud-compose-upgrade-sentinel")"
+ "cat /mnt/disks/volumes/.cloud-compose-upgrade-sentinel")"
[[ "$actual_data" == "$nonce" ]] || fail "the persistent data-disk sentinel did not survive the upgrade"
[[ "$actual_volumes" == "$nonce" ]] || fail "the Docker-volume disk sentinel did not survive the upgrade"
@@ -447,27 +422,18 @@ verify_disk_sentinels() {
read_data_filesystem_size_bytes() {
local home_dir="$1" key_path="$2" output_json="$3"
- local host port user remote_script encoded_script quoted_script size_bytes
+ local host port user remote_program size_bytes
host="$(jq -er '.host' "$output_json")"
port="$(jq -er '.ssh_port' "$output_json")"
user="$(jq -er '.ssh_user' "$output_json")"
- read -r -d '' remote_script <<'EOF' || true
-set -euo pipefail
-
-filesystem="$(findmnt -n -o FSTYPE --target /mnt/disks/data)"
-if [[ "$filesystem" != "ext4" ]]; then
- echo "Application-data mount uses ${filesystem:-an unknown filesystem}, expected ext4" >&2
- exit 1
-fi
-df --block-size=1 --output=size -- /mnt/disks/data | awk 'NR == 2 { print $1 }'
-EOF
-
- encoded_script="$(printf '%s' "$remote_script" | base64 | tr -d '\n')"
- quoted_script="$(shell_quote "$encoded_script")"
+ remote_program="$(stage_upgrade_remote_program \
+ "$home_dir" "$key_path" "$host" "$port" "$user" \
+ "$repo_root/ci/remote/gcp-upgrade-read-filesystem-size.sh" \
+ gcp-upgrade-read-filesystem-size.sh)"
size_bytes="$(ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
- "printf %s ${quoted_script} | base64 -d | sudo bash")"
+ "$remote_program")"
size_bytes="${size_bytes//$'\r'/}"
[[ "$size_bytes" =~ ^[1-9][0-9]*$ ]] ||
fail "mounted application-data ext4 reported an invalid byte capacity: ${size_bytes:-empty}"
@@ -740,7 +706,7 @@ run_upgrade() (
chmod 0600 "$old_state_json" "$old_state_list"
write_phase_ids old "$old_state_json" "$old_ids"
- nonce="$(printf '%s' "${run_id}:${base_sha}:${current_sha}" | sha256sum | awk '{print $1}')"
+ read -r nonce _ < <(printf '%s' "${run_id}:${base_sha}:${current_sha}" | sha256sum)
write_disk_sentinels "$old_home" "$key_path" "$old_output" "$nonce"
initialize_phase "$new_root" "$new_data" "$state_path"
diff --git a/ci/host-runtime-security.sh b/ci/host-runtime-security.sh
index 6a76810..f133601 100644
--- a/ci/host-runtime-security.sh
+++ b/ci/host-runtime-security.sh
@@ -3,6 +3,7 @@
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
@@ -16,6 +17,23 @@ assert_contains() {
grep -Fq -- "$pattern" "$file" || fail "$file does not contain: $pattern"
}
+binding_inventory() {
+ local file="$1" marker="$2" line
+ local capture=false
+
+ while IFS= read -r line; do
+ if [[ "$line" == *"$marker"* ]]; then
+ capture=true
+ fi
+ if [[ "$capture" == "true" ]]; then
+ printf '%s\n' "$line"
+ if [[ "$line" != *\\ ]]; then
+ capture=false
+ fi
+ fi
+ done <"$file"
+}
+
mkdir -p "$tmp/bin"
cat >"$tmp/profile.sh" <<'EOF'
@@ -165,6 +183,8 @@ assert_contains "$profile_script" 'export PATH="/usr/local/sbin:/usr/local/bin:/
tmpfiles_conf="$repo_root/rootfs/etc/tmpfiles.d/cloud-compose.conf"
assert_contains "$tmpfiles_conf" 'd /run/lock/cloud-compose 0750 root cloud-compose -'
assert_contains "$tmpfiles_conf" 'f /run/lock/cloud-compose/lifecycle.lock 0660 root cloud-compose -'
+assert_contains "$tmpfiles_conf" 'd /var/lib/cloud-compose 0755 root root -'
+assert_contains "$tmpfiles_conf" 'd /home/cloud-compose 0755 root root -'
host_init="$repo_root/rootfs/home/cloud-compose/host-init.sh"
if grep -Eq 'chown[[:space:]]+-R[[:space:]]+cloud-compose[^[:space:]]*[[:space:]]+/home/cloud-compose' "$host_init"; then
fail "host initialization gives the app account recursive ownership of root-executed code"
@@ -174,11 +194,25 @@ if grep -Eq 'chown[[:space:]]+-R[[:space:]]+cloud-compose[^[:space:]]*[[:space:]
fi
assert_contains "$host_init" 'chown root:root /home/cloud-compose'
assert_contains "$host_init" "-exec chown root:root {} +"
+assert_contains "$host_init" 'Unsafe Cloud Compose lifecycle dispatcher:'
+assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh" \
+ 'Cloud Compose control input is not root-controlled'
assert_contains "$host_init" '/home/cloud-compose/.sitectl \'
assert_contains "$host_init" 'install -d -m 0750 -o cloud-compose -g cloud-compose "$mutable_dir"'
+assert_contains "$host_init" 'Managed command directory was not secured by the runtime installer'
+if sed -n '/for mutable_dir in/,/done/p' "$host_init" | grep -Fq '/home/cloud-compose/bin'; then
+ fail "host initialization leaves the privileged published command directory app-writable"
+fi
assert_contains "$host_init" 'install -d -m 1775 -o root -g cloud-compose /mnt/disks/data'
assert_contains "$host_init" 'install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/volumes'
assert_contains "$host_init" 'install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops'
+managed_runtime="$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh"
+assert_contains "$managed_runtime" 'prepare_managed_runtime_directory'
+assert_contains "$managed_runtime" 'validate_published_bin_directory'
+assert_contains "$managed_runtime" '$STATE_DIR:0755:false'
+assert_contains "$managed_runtime" '$PUBLISHED_BIN_DIR:0755:true'
+assert_contains "$managed_runtime" 'managed runtime updates must run as root'
+assert_contains "$managed_runtime" 'production managed runtime directories require a root updater'
if grep -A12 -F 'source_compose_app_env()' "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" | \
grep -Eq 'source[[:space:]]+.*COMPOSE_APPS_ENV_DIR'; then
fail "privileged Compose manifest loading still sources an app-writable shell file"
@@ -215,7 +249,8 @@ fi
assert_contains "$rotate_script" 'prepare|status|audit|recover|authenticate|ready|commit|rollback|rollback-ready'
assert_contains "$rotate_script" 'ROTATION_DISABLE_GRACE_SECONDS'
-assert_contains "$rotate_script" 'https://oauth2.googleapis.com/token'
+assert_contains "$repo_root/rootfs/etc/cloud-compose/jq/service-account-credentials-valid.jq" \
+ 'https://oauth2.googleapis.com/token'
assert_contains "$rotate_script" 'KEY_OPERATION_RESULT=absent'
run_script="$repo_root/rootfs/home/cloud-compose/run.sh"
@@ -227,11 +262,193 @@ if grep -Fq 'rotate-keys' "$repo_root/rootfs/home/cloud-compose/docker-prune.sh"
fail "provider-neutral Docker prune still invokes GCP key rotation"
fi
-archive_source="$repo_root/modules/linux-vm-runtime/main.tf"
+archive_source="$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh"
verify_line="$(grep -n 'sha256sum -c -' "$archive_source" | head -n 1 | cut -d: -f1)"
-extract_line="$(grep -n 'tar -xzf "\$tmp/rootfs.tar.gz"' "$archive_source" | head -n 1 | cut -d: -f1)"
-[[ -n "$verify_line" && -n "$extract_line" && "$verify_line" -lt "$extract_line" ]] || \
- fail "rootfs archive is not verified before extraction"
+source_members_line="$(grep -n 'validate_rootfs_test_source_archive "\$stage_root/rootfs.tar.gz"' "$archive_source" | head -n 1 | cut -d: -f1)"
+source_extract_line="$(grep -n 'tar --no-same-owner --same-permissions -xzf "\$stage_root/rootfs.tar.gz"' "$archive_source" | head -n 1 | cut -d: -f1)"
+members_line="$(grep -n 'validate_rootfs_archive "\$stage_root/rootfs.tar.gz"' "$archive_source" | head -n 1 | cut -d: -f1)"
+extract_line="$(grep -n 'tar --no-same-owner --same-permissions -xzf "\$stage_root/rootfs.tar.gz"' "$archive_source" | tail -n 1 | cut -d: -f1)"
+contract_line="$(grep -n 'rootfs archive paths, bytes, or canonical metadata do not match this cloud-compose module source' "$archive_source" | head -n 1 | cut -d: -f1)"
+copy_line="$(grep -n 'cp -a "\$staged_rootfs"/. /' "$archive_source" | head -n 1 | cut -d: -f1)"
+[[ -n "$verify_line" && -n "$source_members_line" && -n "$source_extract_line" &&
+ -n "$members_line" && -n "$extract_line" && -n "$contract_line" && -n "$copy_line" &&
+ "$verify_line" -lt "$source_members_line" && "$source_members_line" -lt "$source_extract_line" &&
+ "$source_extract_line" -lt "$contract_line" && "$verify_line" -lt "$members_line" &&
+ "$members_line" -lt "$extract_line" && "$extract_line" -lt "$contract_line" &&
+ "$contract_line" -lt "$copy_line" ]] || \
+ fail "$archive_source does not verify archive bytes and canonical rootfs metadata before installation"
+assert_contains "$archive_source" "stat -c '%a:%h:%F'"
+assert_contains "$archive_source" '[[ "$metadata" == "${expected_mode}:1:regular file" ]]'
+assert_contains "$archive_source" '[[ "$require_root_owner" != "true" || "$owner" == "0:0" ]]'
+
+if [[ -n "$(git -C "$repo_root" ls-files 'rootfs/usr/**')" ]]; then
+ fail "Cloud Compose-owned rootfs programs still target immutable /usr"
+fi
+for trusted_program in \
+ rootfs/etc/cloud-compose/awk/compose-secret-files.awk \
+ rootfs/etc/cloud-compose/awk/reconcile-fstab.awk \
+ rootfs/etc/cloud-compose/awk/release-checksum.awk \
+ rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh \
+ rootfs/etc/cloud-compose/libexec/checked-programs.bash \
+ rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh \
+ rootfs/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh \
+ rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh \
+ rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh \
+ rootfs/etc/cloud-compose/libexec/build-cos-make.sh \
+ rootfs/etc/cloud-compose/libexec/bootstrap-security.sh \
+ rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh \
+ rootfs/etc/cloud-compose/libexec/rootfs-archive.sh \
+ rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh \
+ rootfs/etc/cloud-compose/libexec/run-bootstrap.sh \
+ rootfs/etc/cloud-compose/libexec/run-root-program.sh \
+ rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq \
+ rootfs/etc/cloud-compose/jq/compose-validate-projects.jq \
+ rootfs/etc/cloud-compose/jq/rotation-validate-state.jq \
+ rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq; do
+ [[ -f "$repo_root/$trusted_program" ]] || fail "COS-safe trusted program is missing: $trusted_program"
+done
+
+checked_programs="$repo_root/rootfs/etc/cloud-compose/libexec/checked-programs.bash"
+assert_contains "$checked_programs" 'candidate_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"'
+assert_contains "$checked_programs" 'installed_home="$(cloud_compose_installed_home)" || return 2'
+assert_contains "$checked_programs" 'Installed Cloud Compose home resolves to the filesystem root'
+assert_contains "$checked_programs" '"$alias_target" != "var/home"'
+assert_contains "$checked_programs" "stat -c '%u:%g:%a'"
+assert_contains "$checked_programs" "stat -c '%u:%g:%a:%h'"
+assert_contains "$checked_programs" '"$program" == "$program_dir/$program_name"'
+assert_contains "$checked_programs" 'cloud_compose_validate_installed_program_dir "$selected"'
+assert_contains "$checked_programs" '$((8#$mode & 0022)) -ne 0'
+assert_contains "$checked_programs" '"$links" != 1'
+assert_contains "$checked_programs" 'cannot override the installed Cloud Compose program'
+assert_contains "$checked_programs" 'cannot override the installed Cloud Compose source program'
+assert_contains "$checked_programs" 'readonly "$variable_name"'
+if grep -Fq '%F' "$checked_programs"; then
+ fail "checked program validation relies on localized file-type labels"
+fi
+
+while IFS= read -r checked_consumer; do
+ assert_contains "$checked_consumer" '/etc/cloud-compose/libexec/checked-programs.bash'
+ assert_contains "$checked_consumer" 'readlink -f -- /home/cloud-compose'
+done < <(grep -RlE --include='*.sh' \
+ 'cloud_compose_bind_(program|program_dir|source_program)' \
+ "$repo_root/rootfs/home/cloud-compose")
+
+if grep -REq --include='*.sh' '\$\{![A-Za-z_]' \
+ "$repo_root/rootfs/home/cloud-compose"; then
+ fail "indirect parameter expansion bypasses the installed source/program inventory"
+fi
+
+source_reference_pattern='\$CLOUD_COMPOSE_[A-Z0-9_]*_PATH([^A-Z0-9_]|$)|\$\{CLOUD_COMPOSE_[A-Z0-9_]*_PATH([^A-Z0-9_]|$)'
+while IFS= read -r source_consumer; do
+ source_binding_inventory="$(binding_inventory \
+ "$source_consumer" cloud_compose_bind_source_program)"
+ mapfile -t referenced_source_variables < <(
+ grep -oE \
+ '\$CLOUD_COMPOSE_[A-Z0-9_]*_PATH|\$\{CLOUD_COMPOSE_[A-Z0-9_]*_PATH([^A-Z0-9_]|$)' \
+ "$source_consumer" |
+ sed -E 's/^\$\{//; s/^\$//; s/([^A-Z0-9_]).*$//' |
+ sort -u
+ )
+ requires_source_binder=false
+ for source_variable in "${referenced_source_variables[@]}"; do
+ case "$source_variable" in
+ CLOUD_COMPOSE_DOCKER_PRUNE_LOCK_PATH)
+ [[ "$source_consumer" == "$repo_root/rootfs/home/cloud-compose/docker-prune.sh" ]] || \
+ fail "$source_variable is registered only as docker-prune lock data"
+ continue
+ ;;
+ CLOUD_COMPOSE_FSTAB_PATH | CLOUD_COMPOSE_FSTAB_LOCK_PATH)
+ [[ "$source_consumer" == "$repo_root/rootfs/home/cloud-compose/persist-filesystems.sh" ]] || \
+ fail "$source_variable is registered only as fstab data"
+ continue
+ ;;
+ esac
+ requires_source_binder=true
+ grep -Eq "(^|[^A-Z0-9_])${source_variable}([^A-Z0-9_]|$)" \
+ <<<"$source_binding_inventory" || \
+ fail "$source_consumer references $source_variable without binding that exact source variable"
+ done
+ if [[ "$requires_source_binder" == "true" ]]; then
+ assert_contains "$source_consumer" 'cloud_compose_bind_source_program'
+ assert_contains "$source_consumer" '/etc/cloud-compose/libexec/checked-programs.bash'
+ assert_contains "$source_consumer" 'readlink -f -- /home/cloud-compose'
+ fi
+done < <(grep -RlE --include='*.sh' \
+ "$source_reference_pattern" \
+ "$repo_root/rootfs/home/cloud-compose")
+
+program_reference_pattern='\$CLOUD_COMPOSE_[A-Z0-9_]*(PROGRAM|PROGRAM_DIR)([^A-Z0-9_]|$)|\$\{CLOUD_COMPOSE_[A-Z0-9_]*(PROGRAM|PROGRAM_DIR)([^A-Z0-9_]|$)'
+while IFS= read -r program_consumer; do
+ program_dir_binding_inventory="$(binding_inventory \
+ "$program_consumer" cloud_compose_bind_program_dir)"
+ program_binding_inventory="$(binding_inventory \
+ "$program_consumer" 'cloud_compose_bind_program ')"
+ mapfile -t referenced_program_variables < <(
+ grep -oE \
+ '\$CLOUD_COMPOSE_[A-Z0-9_]*(PROGRAM|PROGRAM_DIR)|\$\{CLOUD_COMPOSE_[A-Z0-9_]*(PROGRAM|PROGRAM_DIR)([^A-Z0-9_]|$)' \
+ "$program_consumer" |
+ sed -E 's/^\$\{//; s/^\$//; s/([^A-Z0-9_]).*$//' |
+ sort -u
+ )
+ for program_variable in "${referenced_program_variables[@]}"; do
+ case "$program_variable" in
+ *_PROGRAM_DIR) binding_inventory="$program_dir_binding_inventory" ;;
+ *) binding_inventory="$program_binding_inventory" ;;
+ esac
+ grep -Eq "(^|[^A-Z0-9_])${program_variable}([^A-Z0-9_]|$)" \
+ <<<"$binding_inventory" || \
+ fail "$program_consumer references $program_variable without binding that exact program variable"
+ done
+ assert_contains "$program_consumer" '/etc/cloud-compose/libexec/checked-programs.bash'
+ assert_contains "$program_consumer" 'readlink -f -- /home/cloud-compose'
+done < <(grep -RlE --include='*.sh' \
+ "$program_reference_pattern" \
+ "$repo_root/rootfs/home/cloud-compose")
+
+assert_contains "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" \
+ '/etc/cloud-compose/awk/compose-secret-files.awk'
+[[ ! -e "$repo_root/rootfs/home/cloud-compose/compose-secret-files.awk" ]] || \
+ fail "Compose secret extraction program remains under the runtime home"
+assert_contains "$rotate_script" 'cloud_compose_bind_source_program'
+assert_contains "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" \
+ 'CLOUD_COMPOSE_ROTATE_KEYS_PATH'
+assert_contains "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" \
+ 'CLOUD_COMPOSE_COMPOSE_APPS_PATH'
+assert_contains "$managed_runtime" 'cloud_compose_bind_source_program'
+
+assert_contains "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" \
+ '-f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-validate-projects.jq"'
+assert_contains "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" \
+ 'if [[ "$filter_status" -ne 1 ]]; then'
+assert_contains "$repo_root/rootfs/home/cloud-compose/profile.sh" \
+ '-f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/application-env-validate.jq"'
+assert_contains "$rotate_script" \
+ '-f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-validate-state.jq"'
+assert_contains "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" \
+ '-f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/service-account-credentials-valid.jq"'
+assert_contains "$managed_runtime" \
+ '-f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/sitectl-package-versions-validate.jq"'
+assert_contains "$repo_root/rootfs/home/cloud-compose/persist-filesystems.sh" \
+ '-f "$fstab_reconcile_program"'
+assert_contains "$repo_root/rootfs/home/cloud-compose/install-docker-plugins.sh" \
+ '-f "$release_checksum_program"'
+
+assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh" \
+ '/etc/cloud-compose/libexec/harden-bootstrap-paths.sh'
+assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh" \
+ '/etc/cloud-compose/libexec/harden-bootstrap-paths.sh'
+assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh" \
+ 'chown root:root "$cloud_compose_home"'
+assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh" \
+ "0:1:regular file"
+for cloud_init_program in \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh" \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh"; do
+ assert_contains "$cloud_init_program" 'install-diagnostics "$diagnostics_sha256"'
+ if grep -Eq 'install -d[^#]* /usr/local' "$cloud_init_program"; then
+ fail "$cloud_init_program writes Cloud Compose-owned programs beneath immutable /usr"
+ fi
+done
for variables_file in \
"$repo_root/variables.tf" \
diff --git a/ci/inline-data-program-contract.sh b/ci/inline-data-program-contract.sh
new file mode 100644
index 0000000..6c15dd7
--- /dev/null
+++ b/ci/inline-data-program-contract.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly repo_root
+
+fail() {
+ echo "inline data program contract: $*" >&2
+ exit 1
+}
+
+unchecked_data_program_invocation() {
+ local logical_command="$1"
+ local invocation_pattern
+
+ # Match commands at a statement boundary, in a command/process
+ # substitution, or after a pipeline/control operator. This intentionally
+ # ignores package names, filenames, and prose that merely mention jq/awk.
+ invocation_pattern='(^|[|;&]|[$][(]|<[(])[[:space:]]*(if|elif|while|until|then)?[[:space:]]*!?[[:space:]]*(command[[:space:]]+)?(jq|awk)([[:space:]]|$)'
+ [[ "$logical_command" =~ $invocation_pattern ]] || return 1
+ [[ ! "$logical_command" =~ (^|[[:space:]])-f([[:space:]]|$) ]]
+}
+
+# Keep the scanner itself fail-closed as its shell matching evolves.
+unchecked_data_program_invocation 'value="$(jq -r '\''keys[]'\'' input.json)"' || \
+ fail "scanner did not recognize inline jq"
+unchecked_data_program_invocation 'awk -v key=value '\''$1 == key { print }'\'' input' || \
+ fail "scanner did not recognize inline awk"
+if unchecked_data_program_invocation 'jq -r -f "$program" input.json'; then
+ fail "scanner rejected checked jq"
+fi
+if unchecked_data_program_invocation 'awk -v key=value -f "$program" input'; then
+ fail "scanner rejected checked awk"
+fi
+
+while IFS= read -r -d '' script; do
+ IFS= read -r shebang <"$script" || continue
+ case "$shebang" in
+ '#!/usr/bin/env bash' | '#!/bin/bash' | '#!/bin/sh') ;;
+ *) continue ;;
+ esac
+ logical_command=""
+ logical_start=0
+ line_number=0
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ ((line_number += 1))
+ if [[ -z "$logical_command" ]]; then
+ logical_start="$line_number"
+ fi
+ if [[ "${line: -1}" == "\\" ]]; then
+ logical_command+="${line::-1} "
+ continue
+ fi
+ logical_command+="$line"
+ if unchecked_data_program_invocation "$logical_command"; then
+ fail "${script#$repo_root/}:$logical_start invokes jq/awk without a checked -f program"
+ fi
+ logical_command=""
+ done <"$script"
+ if [[ -n "$logical_command" ]]; then
+ fail "${script#$repo_root/}:$logical_start ends with an incomplete continued command"
+ fi
+done < <(find "$repo_root/rootfs" -type f -print0)
+
+echo "Inline data program contract passed"
diff --git a/ci/key-rotation-contract.sh b/ci/key-rotation-contract.sh
index 353f7d8..d529f80 100644
--- a/ci/key-rotation-contract.sh
+++ b/ci/key-rotation-contract.sh
@@ -3,6 +3,7 @@
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
tmp="$(mktemp -d)"
trap 'rm -rf -- "$tmp"' EXIT
diff --git a/ci/lifecycle-program-contract.sh b/ci/lifecycle-program-contract.sh
new file mode 100755
index 0000000..5ddbebe
--- /dev/null
+++ b/ci/lifecycle-program-contract.sh
@@ -0,0 +1,137 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+report_unexpected_failure() {
+ local status=$?
+ local line="${BASH_LINENO[0]:-unknown}"
+
+ trap - ERR
+ echo "lifecycle program contract: unexpected failure at line ${line} with status ${status}" >&2
+ exit "$status"
+}
+
+trap report_unexpected_failure ERR
+
+if [[ "${0##*/}" == "sitectl" ]]; then
+ : "${SITECTL_ARGV_LOG:?SITECTL_ARGV_LOG is required}"
+ {
+ printf '%s' "${1:-}"
+ shift || true
+ for argument in "$@"; do
+ printf '\t%s' "$argument"
+ done
+ printf '\n'
+ } >>"$SITECTL_ARGV_LOG"
+ exit 0
+fi
+
+lifecycle_program="${1:-/home/cloud-compose/default-lifecycle.sh}"
+verify_args_program="${2:-/etc/cloud-compose/jq/sitectl-verify-args.jq}"
+if [[ -L "$lifecycle_program" ]]; then
+ echo "lifecycle program contract: target is redirected: $lifecycle_program" >&2
+ exit 1
+fi
+if [[ ! -f "$lifecycle_program" ]]; then
+ echo "lifecycle program contract: missing $lifecycle_program" >&2
+ exit 1
+fi
+if [[ ! -r "$lifecycle_program" ]]; then
+ echo "lifecycle program contract: target is unreadable: $lifecycle_program" >&2
+ exit 1
+fi
+
+contract_dir="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-lifecycle-program.XXXXXX")"
+trap 'rm -rf -- "$contract_dir"' EXIT
+
+contract_program="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/$(basename -- "${BASH_SOURCE[0]}")"
+ln -s -- "$contract_program" "$contract_dir/sitectl"
+argv_log="$contract_dir/sitectl.argv"
+target_ref="refs/pull/123/head"
+commit_sha="0123456789abcdef0123456789abcdef01234567"
+context="lifecycle-contract"
+
+env -u SITECTL_EXECUTABLE \
+ PATH="$contract_dir:/usr/bin:/bin" \
+ SITECTL_ARGV_LOG="$argv_log" \
+ SITECTL_CONTEXT_NAME="$context" \
+ SITECTL_ENVIRONMENT=production \
+ GIT_COMMIT_SHA="$commit_sha" \
+ GIT_REF="$target_ref" \
+ GIT_BRANCH=ignored-branch \
+ bash "$lifecycle_program" rollout
+
+expected_ref_call="deploy"$'\t'"--context"$'\t'"$context"$'\t'"--ref"$'\t'"$commit_sha"
+grep -Fxq -- "$expected_ref_call" "$argv_log" || {
+ echo "lifecycle program contract: GIT_COMMIT_SHA did not take precedence for sitectl deploy --ref" >&2
+ exit 1
+}
+if grep -Fq -- $'deploy\t--context\tlifecycle-contract\t--skip-git' "$argv_log"; then
+ echo "lifecycle program contract: supplied GIT_REF incorrectly selected --skip-git" >&2
+ exit 1
+fi
+
+: >"$argv_log"
+env -u GIT_COMMIT_SHA -u SITECTL_EXECUTABLE \
+ PATH="$contract_dir:/usr/bin:/bin" \
+ SITECTL_ARGV_LOG="$argv_log" \
+ SITECTL_CONTEXT_NAME="$context" \
+ SITECTL_ENVIRONMENT=production \
+ GIT_REF="$target_ref" \
+ GIT_BRANCH=ignored-branch \
+ bash "$lifecycle_program" rollout
+
+expected_ref_call="deploy"$'\t'"--context"$'\t'"$context"$'\t'"--ref"$'\t'"$target_ref"
+grep -Fxq -- "$expected_ref_call" "$argv_log" || {
+ echo "lifecycle program contract: GIT_REF did not reach sitectl deploy --ref" >&2
+ exit 1
+}
+
+: >"$argv_log"
+if env -u SITECTL_EXECUTABLE \
+ PATH="$contract_dir:/usr/bin:/bin" \
+ SITECTL_ARGV_LOG="$argv_log" \
+ SITECTL_CONTEXT_NAME="$context" \
+ SITECTL_ENVIRONMENT=production \
+ GIT_COMMIT_SHA=not-a-commit \
+ GIT_REF="$target_ref" \
+ bash "$lifecycle_program" rollout; then
+ echo "lifecycle program contract: malformed GIT_COMMIT_SHA was accepted" >&2
+ exit 1
+fi
+if [[ -s "$argv_log" ]]; then
+ echo "lifecycle program contract: malformed GIT_COMMIT_SHA reached sitectl" >&2
+ exit 1
+fi
+
+: >"$argv_log"
+env -u GIT_COMMIT_SHA -u GIT_REF -u GIT_BRANCH -u SITECTL_EXECUTABLE \
+ PATH="$contract_dir:/usr/bin:/bin" \
+ SITECTL_ARGV_LOG="$argv_log" \
+ SITECTL_CONTEXT_NAME="$context" \
+ SITECTL_ENVIRONMENT=production \
+ bash "$lifecycle_program" rollout
+
+expected_skip_call="deploy"$'\t'"--context"$'\t'"$context"$'\t'"--skip-git"
+grep -Fxq -- "$expected_skip_call" "$argv_log" || {
+ echo "lifecycle program contract: rollout without a ref did not select --skip-git" >&2
+ exit 1
+}
+
+: >"$argv_log"
+env -u SITECTL_EXECUTABLE \
+ PATH="$contract_dir:/usr/bin:/bin" \
+ SITECTL_ARGV_LOG="$argv_log" \
+ SITECTL_CONTEXT_NAME="$context" \
+ SITECTL_ENVIRONMENT=preview \
+ SITECTL_VERIFY_ARGS_JSON='["--label","value with spaces",""]' \
+ CLOUD_COMPOSE_SITECTL_VERIFY_ARGS_PROGRAM="$verify_args_program" \
+ bash "$lifecycle_program" up
+
+expected_verify_call="verify"$'\t'"--context"$'\t'"$context"$'\t'"--label"$'\t'"value with spaces"$'\t'
+grep -Fxq -- "$expected_verify_call" "$argv_log" || {
+ echo "lifecycle program contract: verify argument boundaries were not preserved" >&2
+ exit 1
+}
+
+echo "Lifecycle program contract passed"
diff --git a/ci/managed-artifact-contract.sh b/ci/managed-artifact-contract.sh
index 608b56f..cb0f4a7 100644
--- a/ci/managed-artifact-contract.sh
+++ b/ci/managed-artifact-contract.sh
@@ -84,8 +84,12 @@ CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" bash --noprofile --norc -c '
managed_artifact_metadata_matches "$2" 0755 "$3" "$4"
! managed_artifact_metadata_matches "$2" 0755 wrong-owner "$4"
! managed_artifact_metadata_matches "$2" 0755 "$3" wrong-group
-' managed-artifact-metadata "$runtime_script" "$target" "$artifact_owner" "$artifact_group" || \
- fail "owner/group metadata does not participate in the installed-spec gate"
+ test "$(tab_separated_field_count "$5")" = 8
+ test "$(tab_separated_field_count "$6")" = 9
+' managed-artifact-metadata "$runtime_script" "$target" "$artifact_owner" "$artifact_group" \
+ $'one\ttwo\tthree\tfour\tfive\tsix\tseven\t' \
+ $'one\ttwo\tthree\tfour\tfive\tsix\tseven\teight\t' || \
+ fail "managed-artifact metadata or tab-field parsing contract failed"
chmod 0644 "$target"
: >"$curl_log"
run_install
@@ -141,6 +145,7 @@ assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0
assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\t-unit.service'
assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\tunit;id.service'
assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot'
+assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\tunit.service\textra'
long_name="$(printf 'a%.0s' {1..129})"
assert_rejected "$long_name"$'\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\t'
diff --git a/ci/package-rootfs.sh b/ci/package-rootfs.sh
index 6e3af96..07ba931 100755
--- a/ci/package-rootfs.sh
+++ b/ci/package-rootfs.sh
@@ -6,6 +6,7 @@ script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "$script_dir/.." && pwd)"
output_dir="${1:-$repo_root/dist}"
asset_name="cloud-compose-rootfs.tar.gz"
+contract_asset_name="cloud-compose-rootfs.contract.sha256"
if [[ "$output_dir" != /* ]]; then
output_dir="$PWD/$output_dir"
@@ -14,13 +15,21 @@ install -d -m 0755 -- "$output_dir"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-rootfs.XXXXXX")"
trap 'rm -rf -- "$tmp"' EXIT
+cp -a -- "$repo_root/rootfs" "$tmp/rootfs"
+find "$tmp/rootfs" -depth -type d -empty -delete
+find "$tmp/rootfs" -type d -exec chmod 0755 -- {} +
+find "$tmp/rootfs" -type f -exec chmod 0644 -- {} +
+find "$tmp/rootfs" -type f -name '*.sh' -exec chmod 0755 -- {} +
+bash "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" \
+ contract "$tmp/rootfs" >"$tmp/$contract_asset_name"
+
LC_ALL=C tar \
--sort=name \
--mtime='UTC 1970-01-01' \
--owner=0 \
--group=0 \
--numeric-owner \
- -C "$repo_root" \
+ -C "$tmp" \
-cf - rootfs | gzip -n -9 >"$tmp/$asset_name"
(
@@ -29,3 +38,4 @@ LC_ALL=C tar \
)
install -m 0644 "$tmp/$asset_name" "$output_dir/$asset_name"
install -m 0644 "$tmp/${asset_name}.sha256" "$output_dir/${asset_name}.sha256"
+install -m 0644 "$tmp/$contract_asset_name" "$output_dir/$contract_asset_name"
diff --git a/ci/remote/config-management-deploy-salt.sh b/ci/remote/config-management-deploy-salt.sh
new file mode 100755
index 0000000..d8ce9a5
--- /dev/null
+++ b/ci/remote/config-management-deploy-salt.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+if [[ "$#" -ne 4 ]]; then
+ echo "usage: config-management-deploy-salt.sh NAME TEMPLATE ENVIRONMENT PROJECT_DIR" >&2
+ exit 2
+fi
+
+smoke_name="$1"
+smoke_template="$2"
+smoke_environment="$3"
+smoke_project_dir="$4"
+
+for identifier in "$smoke_name" "$smoke_template" "$smoke_environment"; do
+ [[ "$identifier" =~ ^[a-z][a-z0-9-]*$ ]] || {
+ echo "Unsafe config-management smoke identifier: $identifier" >&2
+ exit 2
+ }
+done
+[[ "$smoke_project_dir" == /mnt/disks/data/libops/* &&
+ "$smoke_project_dir" != *'//'* &&
+ ! "$smoke_project_dir" =~ (^|/)\.\.?(/|$) ]] || {
+ echo "Unsafe config-management smoke project directory: $smoke_project_dir" >&2
+ exit 2
+}
+
+export DEBIAN_FRONTEND=noninteractive
+apt-get update
+apt-get install -y --no-install-recommends python3-venv ca-certificates
+
+python3 -m venv /opt/cloud-compose-salt-smoke
+/opt/cloud-compose-salt-smoke/bin/python -m pip install --no-cache-dir \
+ salt==3007.1 \
+ tornado==6.4.2 \
+ looseversion==1.3.0 \
+ PyYAML==6.0.2 \
+ packaging==24.2 \
+ msgpack==1.1.0 \
+ distro==1.9.0 \
+ Jinja2==3.1.4
+
+mkdir -p \
+ /tmp/cloud-compose-salt/etc \
+ /tmp/cloud-compose-salt/cache \
+ /tmp/cloud-compose-salt/pki \
+ /srv/cloud-compose/.smoke-pillar
+cat >/tmp/cloud-compose-salt/etc/minion </srv/cloud-compose/.smoke-pillar/top.sls </srv/cloud-compose/.smoke-pillar/cloud-compose.sls </tmp/cloud-compose-salt-show-sls.txt
+
+/opt/cloud-compose-salt-smoke/bin/salt-call \
+ --local \
+ --retcode-passthrough \
+ --config-dir=/tmp/cloud-compose-salt/etc \
+ state.apply cloud-compose
diff --git a/ci/remote/config-management-diagnostics.sh b/ci/remote/config-management-diagnostics.sh
new file mode 100755
index 0000000..6f020b0
--- /dev/null
+++ b/ci/remote/config-management-diagnostics.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+
+set +e
+
+echo "--- cloud-init status ---"
+cloud-init status --long
+echo "--- /var/log/cloud-init-output.log ---"
+tail -n 300 /var/log/cloud-init-output.log
+echo "--- bootstrap unit state ---"
+systemctl show --no-pager \
+ --property=ActiveState,SubState,Result,NRestarts,ExecMainCode,ExecMainStatus \
+ cloud-compose-bootstrap.service
+echo "--- cloud-compose unit state ---"
+systemctl show --no-pager \
+ --property=ActiveState,SubState,Result,NRestarts,ExecMainCode,ExecMainStatus \
+ cloud-compose.service
+echo "--- cloud-compose bootstrap journal ---"
+journalctl -u cloud-compose-bootstrap.service --no-pager -n 300
+echo "--- cloud-compose application journal ---"
+journalctl -u cloud-compose.service --no-pager -n 300
+echo "--- active bootstrap processes ---"
+ps -eo pid,ppid,stat,etime,args --forest
+echo "--- docker ps ---"
+docker ps -a
+echo "--- compose manifest ---"
+cat /home/cloud-compose/compose-projects.json
diff --git a/ci/remote/config-management-verify.sh b/ci/remote/config-management-verify.sh
new file mode 100755
index 0000000..ed355eb
--- /dev/null
+++ b/ci/remote/config-management-verify.sh
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+if [[ "$#" -ne 6 ]]; then
+ echo "usage: config-management-verify.sh NAME TEMPLATE ENVIRONMENT PROJECT_DIR LIFECYCLE_CONTRACT RUNTIME_STATE_CONTRACT" >&2
+ exit 2
+fi
+
+export SMOKE_NAME="$1"
+export SMOKE_TEMPLATE="$2"
+export SMOKE_ENVIRONMENT="$3"
+export SMOKE_PROJECT_DIR="$4"
+readonly lifecycle_program_contract="$5"
+readonly runtime_state_contract="$6"
+
+for contract_program in "$lifecycle_program_contract" "$runtime_state_contract"; do
+ [[ "$contract_program" == /tmp/cloud-compose-hosted-contract.*/* &&
+ -f "$contract_program" && ! -L "$contract_program" ]] || {
+ echo "Remote contract program is missing or unsafe: $contract_program" >&2
+ exit 1
+ }
+done
+
+for program in \
+ /home/cloud-compose/init \
+ /home/cloud-compose/up \
+ /home/cloud-compose/down \
+ /home/cloud-compose/rollout \
+ /home/cloud-compose/default-lifecycle.sh \
+ /home/cloud-compose/run.sh \
+ /home/cloud-compose/start-cloud-compose-bootstrap.sh \
+ /etc/cloud-compose/libexec/bootstrap-required.sh \
+ /etc/cloud-compose/libexec/bootstrap-security.sh \
+ /etc/cloud-compose/libexec/run-bootstrap.sh \
+ /etc/cloud-compose/libexec/require-bootstrap-ready.sh \
+ /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh \
+ /etc/cloud-compose/libexec/run-root-program.sh; do
+ test -x "$program"
+done
+
+shopt -s nullglob dotglob
+awk_programs=(/etc/cloud-compose/awk/*.awk)
+jq_programs=(/etc/cloud-compose/jq/*.jq)
+shopt -u nullglob dotglob
+(( ${#awk_programs[@]} > 0 ))
+(( ${#jq_programs[@]} > 0 ))
+data_programs=(
+ /etc/cloud-compose/libexec/checked-programs.bash
+ "${awk_programs[@]}"
+ "${jq_programs[@]}"
+)
+for data_program in "${data_programs[@]}"; do
+ test ! -L "$data_program"
+ test -f "$data_program"
+ test "$(stat -c '%u:%g:%a:%h' -- "$data_program")" = "0:0:644:1"
+done
+
+for program_parent in \
+ /home/cloud-compose \
+ /etc/cloud-compose \
+ /etc/cloud-compose/awk \
+ /etc/cloud-compose/jq \
+ /etc/cloud-compose/libexec; do
+ test ! -L "$program_parent"
+ test -d "$program_parent"
+ test "$(stat -c '%u:%g:%a' -- "$program_parent")" = "0:0:755"
+done
+
+python3 -m json.tool /home/cloud-compose/compose-projects.json >/dev/null
+python3 -m json.tool /home/cloud-compose/application-env.json >/dev/null
+python3 "$runtime_state_contract"
+bash "$lifecycle_program_contract" /home/cloud-compose/default-lifecycle.sh
+
+test -d "$SMOKE_PROJECT_DIR/.git"
+systemctl is-active --quiet cloud-compose
+runuser -u cloud-compose -- env HOME=/home/cloud-compose \
+ /home/cloud-compose/smoke-healthcheck.sh "$SMOKE_NAME"
diff --git a/ci/remote/gcp-upgrade-assert-services-disabled.sh b/ci/remote/gcp-upgrade-assert-services-disabled.sh
new file mode 100755
index 0000000..6a48104
--- /dev/null
+++ b/ci/remote/gcp-upgrade-assert-services-disabled.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+for unit in \
+ internal-services.timer \
+ internal-services.service \
+ cloud-compose-internal-services.timer \
+ cloud-compose-internal-services.service; do
+ if systemctl is-active --quiet "$unit" 2>/dev/null; then
+ echo "Fixture-only internal service remained active: $unit" >&2
+ exit 1
+ fi
+done
diff --git a/ci/remote/gcp-upgrade-container-metadata-isolation.sh b/ci/remote/gcp-upgrade-container-metadata-isolation.sh
new file mode 100755
index 0000000..3fa1b83
--- /dev/null
+++ b/ci/remote/gcp-upgrade-container-metadata-isolation.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+set -eu
+
+nslookup dl-cdn.alpinelinux.org >/dev/null
+nslookup metadata.google.internal | grep -Fq "169.254.169.254"
+for metadata_port in 80 443; do
+ if nc -z -w 3 169.254.169.254 "$metadata_port"; then
+ echo "Container reached GCP metadata TCP port ${metadata_port}" >&2
+ exit 1
+ fi
+done
diff --git a/ci/remote/gcp-upgrade-read-filesystem-size.sh b/ci/remote/gcp-upgrade-read-filesystem-size.sh
new file mode 100755
index 0000000..c2bbbd6
--- /dev/null
+++ b/ci/remote/gcp-upgrade-read-filesystem-size.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+filesystem="$(findmnt -n -o FSTYPE --target /mnt/disks/data)"
+if [[ "$filesystem" != "ext4" ]]; then
+ echo "Application-data mount uses ${filesystem:-an unknown filesystem}, expected ext4" >&2
+ exit 1
+fi
+{
+ read -r _header
+ read -r size_bytes
+ [[ "$size_bytes" =~ ^[1-9][0-9]*$ ]]
+ printf '%s\n' "$size_bytes"
+} < <(df --block-size=1 --output=size -- /mnt/disks/data)
diff --git a/ci/remote/gcp-upgrade-verify-metadata-isolation.sh b/ci/remote/gcp-upgrade-verify-metadata-isolation.sh
new file mode 100755
index 0000000..903be2e
--- /dev/null
+++ b/ci/remote/gcp-upgrade-verify-metadata-isolation.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+if [[ "$#" -ne 1 ]]; then
+ echo "usage: gcp-upgrade-verify-metadata-isolation.sh CONTAINER_PROGRAM" >&2
+ exit 2
+fi
+
+container_program="$1"
+metadata_address="169.254.169.254"
+metadata_header="Metadata-Flavor: Google"
+alpine_image="alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"
+
+[[ "$container_program" == /home/cloud-compose/.cache/libops-ci/gcp-upgrade-container-metadata-isolation.sh &&
+ -f "$container_program" && ! -L "$container_program" ]] || {
+ echo "Container metadata-isolation program is missing or unsafe" >&2
+ exit 1
+}
+
+docker run --rm --network bridge \
+ --mount "type=bind,src=${container_program},dst=/usr/local/bin/cloud-compose-metadata-isolation,readonly" \
+ "$alpine_image" \
+ /bin/sh /usr/local/bin/cloud-compose-metadata-isolation
+
+for metadata_scheme in http https; do
+ if curl -kfsS --connect-timeout 3 --max-time 5 \
+ --header "$metadata_header" \
+ "${metadata_scheme}://${metadata_address}/computeMetadata/v1/instance/id" \
+ >/dev/null 2>&1; then
+ echo "Unprivileged host process reached GCP metadata ${metadata_scheme}" >&2
+ exit 1
+ fi
+done
diff --git a/ci/remote/gcp-upgrade-write-disk-sentinels.sh b/ci/remote/gcp-upgrade-write-disk-sentinels.sh
new file mode 100755
index 0000000..c5b35d2
--- /dev/null
+++ b/ci/remote/gcp-upgrade-write-disk-sentinels.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+if [[ "$#" -ne 1 || ! "$1" =~ ^[A-Za-z0-9._:-]{1,128}$ ]]; then
+ echo "usage: gcp-upgrade-write-disk-sentinels.sh SAFE_NONCE" >&2
+ exit 2
+fi
+
+findmnt -n /mnt/disks/data >/dev/null
+findmnt -n /mnt/disks/volumes >/dev/null
+printf '%s' "$1" >/mnt/disks/data/.cloud-compose-upgrade-sentinel
+printf '%s' "$1" >/mnt/disks/volumes/.cloud-compose-upgrade-sentinel
+sync
diff --git a/ci/rootfs-archive-path-contract.awk b/ci/rootfs-archive-path-contract.awk
new file mode 100644
index 0000000..9a2783f
--- /dev/null
+++ b/ci/rootfs-archive-path-contract.awk
@@ -0,0 +1,3 @@
+$0 !~ /^rootfs\// { bad = 1 }
+/(^|\/)\.\.($|\/)/ { bad = 1 }
+END { exit bad }
diff --git a/ci/rootfs-package-contract.sh b/ci/rootfs-package-contract.sh
index 0c00046..f7ec1dc 100755
--- a/ci/rootfs-package-contract.sh
+++ b/ci/rootfs-package-contract.sh
@@ -10,15 +10,63 @@ bash "$script_dir/package-rootfs.sh" "$tmp/one"
bash "$script_dir/package-rootfs.sh" "$tmp/two"
cmp "$tmp/one/cloud-compose-rootfs.tar.gz" "$tmp/two/cloud-compose-rootfs.tar.gz"
cmp "$tmp/one/cloud-compose-rootfs.tar.gz.sha256" "$tmp/two/cloud-compose-rootfs.tar.gz.sha256"
+cmp "$tmp/one/cloud-compose-rootfs.contract.sha256" "$tmp/two/cloud-compose-rootfs.contract.sha256"
(
cd "$tmp/one"
sha256sum -c cloud-compose-rootfs.tar.gz.sha256
)
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | awk '
- $0 !~ /^rootfs\// { bad = 1 }
- /(^|\/)\.\.($|\/)/ { bad = 1 }
- END { exit bad }
-'
+contract_sha256="$(<"$tmp/one/cloud-compose-rootfs.contract.sha256")"
+[[ "$contract_sha256" =~ ^[0-9a-f]{64}$ ]] || {
+ echo "Rootfs contract sidecar is not one lowercase SHA-256 digest" >&2
+ exit 1
+}
+mkdir "$tmp/extracted"
+tar -xzf "$tmp/one/cloud-compose-rootfs.tar.gz" -C "$tmp/extracted"
+actual_contract_sha256="$(
+ bash "$script_dir/../rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" \
+ contract "$tmp/extracted/rootfs"
+)"
+[[ "$actual_contract_sha256" == "$contract_sha256" ]] || {
+ echo "Rootfs contract sidecar does not match the packaged rootfs" >&2
+ exit 1
+}
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
+ awk -f "$script_dir/rootfs-archive-path-contract.awk"
tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/home/cloud-compose/run.sh' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/home/cloud-compose/default-lifecycle.sh' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/home/cloud-compose/lifecycle-entrypoint.sh' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/jq/compose-validate-projects.jq' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/jq/rotation-validate-state.jq' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/awk/reconcile-fstab.awk' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/awk/release-checksum.awk' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/awk/compose-secret-files.awk' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/libexec/checked-programs.bash' >/dev/null
+for bootstrap_program in \
+ gcp-cloud-init-finalize.sh \
+ gcp-cloud-init-post-bootstrap.sh \
+ gcp-filesystem-boot.sh \
+ linux-vm-cloud-init.sh \
+ rootfs-archive.sh \
+ run-lifecycle-program.sh; do
+ tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
+ grep -Fx "rootfs/etc/cloud-compose/libexec/$bootstrap_program" >/dev/null
+done
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
+ grep -Fx 'rootfs/etc/cloud-compose/libexec/run-bootstrap.sh' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
+ grep -Fx 'rootfs/etc/cloud-compose/libexec/run-root-program.sh' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
+ grep -Fx 'rootfs/etc/cloud-compose/libexec/build-cos-make.sh' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
+ grep -Fx 'rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
+ grep -Fx 'rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq' >/dev/null
+tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
+ grep -Fx 'rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh' >/dev/null
+if tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -E '^rootfs/usr/' >/dev/null; then
+ echo "Rootfs package contains a Cloud Compose-owned immutable /usr path" >&2
+ exit 1
+fi
echo "Rootfs package contract passed"
diff --git a/ci/runtime-config-contract.sh b/ci/runtime-config-contract.sh
index 0fade8e..f6dc862 100644
--- a/ci/runtime-config-contract.sh
+++ b/ci/runtime-config-contract.sh
@@ -74,7 +74,9 @@ assert_runtime_values() {
# The child shell receives values as positional parameters.
# shellcheck disable=SC2016
- env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" bash --noprofile --norc -c '
+ env -i PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
+ CLOUD_COMPOSE_ENV_FILE="$env_file" bash --noprofile --norc -c '
source "$1"
shift
test "$BACKTICKS" = "$1"
@@ -249,7 +251,9 @@ update_dir="$tmp/update"
mkdir -p "$update_dir"
cp "$env_file" "$update_dir/.env"
UPDATE_VALUE=$'updated $(touch update-injection) "quote" \\ path\nnext line'
-env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$update_dir/.env" \
+env -i PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
+ CLOUD_COMPOSE_ENV_FILE="$update_dir/.env" \
bash --noprofile --norc -c '
source "$1"
cd "$2"
@@ -272,7 +276,9 @@ DOMAIN=example.org
EXPANDED=${DOMAIN}/path
SINGLE_QUOTED='literal $DOMAIN'
EOF
-env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" \
+env -i PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
+ CLOUD_COMPOSE_ENV_FILE="$env_file" \
bash --noprofile --norc -c '
source "$1"
cd "$2"
@@ -306,7 +312,9 @@ jq -n \
'$ARGS.named' >"$tmp/application-env.json"
# The child shell receives file paths as positional parameters.
# shellcheck disable=SC2016
-env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" \
+env -i PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
+ CLOUD_COMPOSE_ENV_FILE="$env_file" \
CLOUD_COMPOSE_APPLICATION_ENV_FILE="$tmp/application-env.json" \
bash --noprofile --norc -c '
source "$1"
@@ -323,7 +331,9 @@ test ! -e "$tmp/command-injection"
unsafe_update_dir="$tmp/unsafe-update"
mkdir -p "$unsafe_update_dir"
ln -s /etc/passwd "$unsafe_update_dir/.env"
-if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" \
+if env -i PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
+ CLOUD_COMPOSE_ENV_FILE="$env_file" \
bash --noprofile --norc -c '
source "$1"
cd "$2"
@@ -335,7 +345,9 @@ if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" \
exit 1
fi
-if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" \
+if env -i PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
+ CLOUD_COMPOSE_ENV_FILE="$env_file" \
bash --noprofile --norc -c '
source "$1"
cd "$2"
@@ -350,7 +362,9 @@ fi
printf '%s\n' 'BAD-NAME="value"' >"$tmp/invalid.env"
# Source runs in the intentionally isolated child shell.
# shellcheck disable=SC2016
-if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$tmp/invalid.env" \
+if env -i PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
+ CLOUD_COMPOSE_ENV_FILE="$tmp/invalid.env" \
bash --noprofile --norc -c 'source "$1"' cloud-compose-env \
"$repo_root/rootfs/home/cloud-compose/profile.sh" >/dev/null 2>&1; then
echo "Runtime environment loader accepted an unsafe variable name" >&2
@@ -365,7 +379,9 @@ for invalid_value in \
printf '%s\n' "$invalid_value" >"$tmp/invalid.env"
# Source runs in the intentionally isolated child shell.
# shellcheck disable=SC2016
- if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$tmp/invalid.env" \
+ if env -i PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
+ CLOUD_COMPOSE_ENV_FILE="$tmp/invalid.env" \
bash --noprofile --norc -c 'source "$1"' cloud-compose-env \
"$repo_root/rootfs/home/cloud-compose/profile.sh" >/dev/null 2>&1; then
echo "Runtime environment loader accepted data outside the encoding contract: $invalid_value" >&2
@@ -374,7 +390,9 @@ for invalid_value in \
done
retry_log="$tmp/retry.log"
-if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" MAX_RETRIES=1 \
+if env -i PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
+ CLOUD_COMPOSE_ENV_FILE="$env_file" MAX_RETRIES=1 \
bash --noprofile --norc -c '
source "$1"
retry_until_success /bin/false "https://example.invalid/?token=must-not-appear"
@@ -398,7 +416,9 @@ for assignment in \
'SLEEP_INCREMENT=1+1' \
'SLEEP_INCREMENT=a[$(touch /tmp/cloud-compose-retry-injection)]'; do
rm -f /tmp/cloud-compose-retry-injection
- if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" "$assignment" \
+ if env -i PATH=/usr/bin:/bin \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
+ CLOUD_COMPOSE_ENV_FILE="$env_file" "$assignment" \
bash --noprofile --norc -c '
source "$1"
retry_until_success /bin/true
diff --git a/ci/sitectl-version-contract.sh b/ci/sitectl-version-contract.sh
index 0ec2f60..a2ba393 100644
--- a/ci/sitectl-version-contract.sh
+++ b/ci/sitectl-version-contract.sh
@@ -4,6 +4,7 @@ set -euo pipefail
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
runtime_script="$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh"
+export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-sitectl-versions.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT
@@ -42,6 +43,53 @@ CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
test "$(stat -c "%a" "$TMP_DIR")" = 700
test "$(stat -c "%a" "$PACKAGE_STATE_DIR")" = 700
test "$(stat -c "%a" "$ARTIFACT_STATE_DIR")" = 700
+ test "$(stat -c "%a" "$PUBLISHED_BIN_DIR")" = 755
+ test "$(stat -c "%u:%g" "$STATE_DIR")" = "$(id -u):$(id -g)"
+ test "$(stat -c "%u:%g" "$PUBLISHED_BIN_DIR")" = "$(id -u):$(id -g)"
+
+ touch "$PUBLISHED_BIN_DIR/docker"
+ if mkdirs; then
+ echo "managed runtime accepted an unmanaged published command" >&2
+ exit 1
+ fi
+ rm -f "$PUBLISHED_BIN_DIR/docker"
+
+ touch "$BIN_DIR/make"
+ ln -s "$BIN_DIR/make" "$PUBLISHED_BIN_DIR/make"
+ mkdirs
+ rm -f "$PUBLISHED_BIN_DIR/make"
+ ln -s "$2/unsafe-make" "$PUBLISHED_BIN_DIR/make"
+ if mkdirs; then
+ echo "managed runtime accepted an unsafe published Make target" >&2
+ exit 1
+ fi
+ rm -f "$PUBLISHED_BIN_DIR/make"
+
+ unsafe_target="$2/unsafe-target"
+ unsafe_state="$2/unsafe-state"
+ mkdir -p "$unsafe_target"
+ ln -s "$unsafe_target" "$unsafe_state"
+ STATE_DIR="$unsafe_state"
+ BIN_DIR="$STATE_DIR/bin"
+ TMP_DIR="$STATE_DIR/tmp"
+ PACKAGE_STATE_DIR="$STATE_DIR/packages"
+ ARTIFACT_STATE_DIR="$STATE_DIR/artifacts"
+ if mkdirs; then
+ echo "managed runtime accepted a redirected state directory" >&2
+ exit 1
+ fi
+
+ unsafe_state="$2/unsafe-writable-state"
+ mkdir -m 0775 "$unsafe_state"
+ STATE_DIR="$unsafe_state"
+ BIN_DIR="$STATE_DIR/bin"
+ TMP_DIR="$STATE_DIR/tmp"
+ PACKAGE_STATE_DIR="$STATE_DIR/packages"
+ ARTIFACT_STATE_DIR="$STATE_DIR/artifacts"
+ if mkdirs; then
+ echo "managed runtime accepted group-writable state" >&2
+ exit 1
+ fi
' cloud-compose-sitectl-modes "$runtime_script" "$tmp"
run_contract() {
diff --git a/ci/source-trust-contract.sh b/ci/source-trust-contract.sh
index 5c3c5cb..5a941b7 100644
--- a/ci/source-trust-contract.sh
+++ b/ci/source-trust-contract.sh
@@ -3,6 +3,7 @@
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
@@ -59,6 +60,8 @@ export COMPOSE_PROJECTS_FILE="$projects_file"
export COMPOSE_APPS_ENV_DIR="$tmp/apps"
export COMPOSE_APPS_STATE_DIR="$tmp/state"
export CLOUD_COMPOSE_DATA_ROOT="$tmp"
+export CLOUD_COMPOSE_LIFECYCLE_PROGRAM_DIR="$repo_root/ci/fixtures"
+readonly source_trust_rollout_program="$repo_root/ci/fixtures/source-trust-rollout.sh"
retry_until_success() {
"$@"
@@ -66,6 +69,9 @@ retry_until_success() {
# shellcheck disable=SC1091
source "$repo_root/rootfs/home/cloud-compose/compose-apps.sh"
+export CLOUD_COMPOSE_TEST_LIFECYCLE_EXECUTOR="$repo_root/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh"
+# shellcheck disable=SC1091
+source "$repo_root/ci/fixtures/checked-lifecycle-executor.sh"
pinned_checkout="$tmp/pinned"
write_project pinned "$commit_one" "$pinned_checkout"
@@ -135,34 +141,33 @@ git -C "$source_repo" commit -m feature >/dev/null
feature_commit="$(git -C "$source_repo" rev-parse HEAD)"
git -C "$source_repo" push origin feature >/dev/null
git -C "$source_repo" checkout main >/dev/null
-jq '.branch.rollout_commands = [
- "git fetch -- origin feature",
- "git checkout --detach FETCH_HEAD"
- ] | .branch.up_commands = ["true"]' \
+jq --arg rollout_program "$source_trust_rollout_program" \
+ '.branch.rollout_commands = [$rollout_program] | .branch.up_commands = ["true"]' \
"$projects_file" >"$projects_file.tmp"
mv "$projects_file.tmp" "$projects_file"
+export SOURCE_TRUST_ROLLOUT_REF=feature
run_compose_app_lifecycle branch rollout
assert_head "$branch_checkout" "$feature_commit"
[[ "$(<"$COMPOSE_APPS_STATE_DIR/branch.deployed-head")" == "$feature_commit" ]] || \
fail "feature rollout HEAD was not recorded"
run_compose_app_lifecycle branch up
assert_head "$branch_checkout" "$feature_commit"
-jq '.branch.rollout_commands = [
- "git fetch -- origin main",
- "git checkout --detach FETCH_HEAD"
- ]' "$projects_file" >"$projects_file.tmp"
+jq --arg rollout_program "$source_trust_rollout_program" \
+ '.branch.rollout_commands = [$rollout_program]' \
+ "$projects_file" >"$projects_file.tmp"
mv "$projects_file.tmp" "$projects_file"
+export SOURCE_TRUST_ROLLOUT_REF=main
run_compose_app_lifecycle branch rollout
assert_head "$branch_checkout" "$commit_four"
# Full bootstrap/source preparation may restore the configured baseline after
# a recorded rollout. It must not grant the same reset authority to an
# unrecorded local-ahead commit.
-jq '.branch.rollout_commands = [
- "git fetch -- origin feature",
- "git checkout --detach FETCH_HEAD"
- ]' "$projects_file" >"$projects_file.tmp"
+jq --arg rollout_program "$source_trust_rollout_program" \
+ '.branch.rollout_commands = [$rollout_program]' \
+ "$projects_file" >"$projects_file.tmp"
mv "$projects_file.tmp" "$projects_file"
+export SOURCE_TRUST_ROLLOUT_REF=feature
run_compose_app_lifecycle branch rollout
assert_head "$branch_checkout" "$feature_commit"
clone_or_update_compose_app branch
@@ -205,6 +210,29 @@ fi
rm -f -- "$tag_checkout/compose.override.yaml"
clone_or_update_compose_app tag
assert_head "$tag_checkout" "$commit_one"
+
+# A successful sitectl reconciliation may intentionally derive tracked runtime
+# configuration from the untracked desired-state document. Accept only the
+# exact recorded diff, reject any later mutation, and restore the committed
+# source before a source move.
+printf 'managed\n' >"$tag_checkout/version.txt"
+pushd "$tag_checkout" >/dev/null
+record_compose_managed_diff tag
+verify_clean_compose_checkout tag
+printf 'unexpected\n' >>version.txt
+if verify_clean_compose_checkout tag >/dev/null 2>&1; then
+ fail "recorded managed Compose state accepted a different tracked change"
+fi
+printf 'managed\n' >version.txt
+restore_recorded_compose_managed_diff tag
+popd >/dev/null
+[[ "$(<"$tag_checkout/version.txt")" == "one" ]] || \
+ fail "recorded managed Compose state was not restored to committed source"
+if ! git -C "$tag_checkout" diff --quiet --ignore-submodules -- ||
+ ! git -C "$tag_checkout" diff --cached --quiet --ignore-submodules --; then
+ fail "recorded managed Compose restore left tracked changes"
+fi
+
run_compose_app_lifecycle tag init
[[ "$(<"$COMPOSE_APPS_STATE_DIR/tag.deployed-head")" == "$commit_one" ]] || \
fail "tag deployed HEAD was not recorded"
@@ -281,20 +309,125 @@ if grep -Eq 'source = "https://github\.com/.*/archive/(refs/)?(heads|tags)/' "$g
fi
linux_runtime="$repo_root/modules/linux-vm-runtime/main.tf"
-grep -Fq "archive_url_b64='\${base64encode(local.rootfs_archive_url)}'" "$linux_runtime" || \
- fail "Linux rootfs archive URL is not rendered as base64 shell data"
-if grep -Fq 'archive_url=${jsonencode(local.rootfs_archive_url)}' "$linux_runtime"; then
- fail "Linux rootfs archive URL is still rendered as executable shell syntax"
-fi
-grep -Fq 'archive_additional_rootfs_commands' "$linux_runtime" || \
- fail "Additional rootfs content is not reapplied after archive extraction"
+linux_runtime_outputs="$repo_root/modules/linux-vm-runtime/outputs.tf"
+linux_runtime_variables="$repo_root/modules/linux-vm-runtime/variables.tf"
+linux_runtime_tests="$repo_root/modules/linux-vm-runtime/runtime_inputs.tftest.hcl"
+gcp_cloud_init="$repo_root/templates/cloud-init.yml"
+linux_cloud_init="$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml"
+archive_program="$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh"
+
+grep -Eq 'ROOTFS_ARCHIVE_URL_B64[[:space:]]*=[[:space:]]*base64encode\(local\.rootfs_archive_url\)' "$linux_runtime" || \
+ fail "Linux rootfs archive URL is not transported as base64 data"
+grep -Fq 'count = local.rootfs_archive_url != "" && local.rootfs_test_source_archive_prefix == "" ? 1 : 0' "$linux_runtime" || \
+ fail "Linux production archive mode does not keep the release sidecar mandatory"
+grep -Fq 'variable "rootfs_test_source_archive_prefix"' "$linux_runtime_variables" || \
+ fail "Linux hosted smoke source mode is not unmistakably test-only"
+grep -Fq 'regex("^cloud-compose-[0-9a-f]{40}$"' "$linux_runtime_variables" || \
+ fail "Linux hosted smoke source mode does not require one exact lowercase commit SHA"
+grep -Fq 'https://github.com/libops/cloud-compose/archive/${trimprefix(local.rootfs_test_source_archive_prefix, "cloud-compose-")}.tar.gz' "$linux_runtime_outputs" || \
+ fail "Linux hosted smoke source mode does not bind its URL to the exact prefix commit"
+for negative_contract in \
+ rejects_source_archive_from_another_commit \
+ rejects_tag_named_source_archive_prefix \
+ rejects_arbitrary_test_source_archive_url; do
+ grep -Fq "run \"${negative_contract}\"" "$linux_runtime_tests" || \
+ fail "Linux hosted smoke source mode lacks ${negative_contract} coverage"
+done
+for production_surface in \
+ "$repo_root/variables.tf" \
+ "$repo_root/modules/gcp/variables.tf" \
+ "$repo_root/providers/gcp/variables.tf" \
+ "$repo_root/providers/do/variables.tf" \
+ "$repo_root/providers/linode/variables.tf"; do
+ if grep -Fq 'rootfs_test_source_archive_prefix' "$production_surface"; then
+ fail "$production_surface exposes the test-only hosted source mode"
+ fi
+done
+grep -Fq 'ROOTFS_ARCHIVE_URL_B64' "$linux_cloud_init" || \
+ fail "Linux cloud-init does not pass rootfs archive URL data to its checked-in entrypoint"
+grep -Fq 'prepare-linux-test-source' "$linux_cloud_init" || \
+ fail "Linux hosted smoke cannot invoke the checked source-archive fixture path"
+grep -Fq 'rootfs_overlay_staging_path' "$linux_runtime" || \
+ fail "Additional rootfs content is not staged for reapplication after archive extraction"
for runtime_module in "$linux_runtime" "$gcp_module"; do
- grep -Fq -- "curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2" "$runtime_module" || \
- fail "rootfs archive download is not restricted to HTTPS with TLS 1.2 or newer in $runtime_module"
- grep -Fq -- '--connect-timeout 10 --max-time 300 -o "$tmp/rootfs.tar.gz" -- "$archive_url"' "$runtime_module" || \
- fail "rootfs archive download is not bounded or separated from curl options in $runtime_module"
- grep -Fq 'rootfs_dir="$(find "$tmp" -mindepth 1 -maxdepth 3 -type d -name rootfs -print -quit)"' "$runtime_module" || \
- fail "rootfs archive discovery does not accept the documented depth range in $runtime_module"
+ grep -Fq 'ROOTFS_ARCHIVE_SCRIPT_B64' "$runtime_module" || \
+ fail "$runtime_module does not transfer the checked-in rootfs archive program"
+ grep -Fq 'rootfs_contract_sha256 = sha256(join("", [' "$runtime_module" || \
+ fail "$runtime_module does not bind archive contents to its exact bundled rootfs"
+ grep -Fq 'cloud-compose-rootfs.contract.sha256' "$runtime_module" || \
+ fail "$runtime_module does not derive the immutable rootfs contract sidecar"
+ grep -Fq 'data "http" "rootfs_contract"' "$runtime_module" || \
+ fail "$runtime_module does not verify the release contract during planning"
+ if grep -Fq -- "curl -fsSL --proto '=https'" "$runtime_module"; then
+ fail "$runtime_module still embeds the rootfs archive shell implementation"
+ fi
+done
+for cloud_init_template in "$linux_cloud_init" "$gcp_cloud_init"; do
+ grep -Fq '/var/lib/cloud-compose/bootstrap/rootfs-archive.sh' "$cloud_init_template" || \
+ fail "$cloud_init_template does not install or invoke the checked-in rootfs archive program"
+done
+grep -Fq -- "curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2" "$archive_program" || \
+ fail "rootfs archive download is not restricted to HTTPS with TLS 1.2 or newer"
+grep -Fq -- '--connect-timeout 10 --max-time 300' "$archive_program" || \
+ fail "rootfs archive download is not bounded"
+grep -Fq -- '-o "$stage_root/rootfs.tar.gz" -- "$archive_url"' "$archive_program" || \
+ fail "rootfs archive URL is not separated from curl options"
+grep -Fq 'validate_rootfs_archive "$stage_root/rootfs.tar.gz"' "$archive_program" || \
+ fail "rootfs archive members are not validated before extraction"
+grep -Fq 'validate_rootfs_test_source_archive "$stage_root/rootfs.tar.gz" "$test_source_prefix"' "$archive_program" || \
+ fail "hosted smoke source-archive members are not validated before extraction"
+grep -Fq 'https://github.com/libops/cloud-compose/archive/${source_commit}.tar.gz' "$archive_program" || \
+ fail "hosted smoke source archives are not tied to one exact libops/cloud-compose commit"
+grep -Fq '[[ "$member_type" == "-" || "$member_type" == "d" ]]' "$archive_program" || \
+ fail "rootfs archive validation does not reject links before extraction"
+grep -Fq 'rootfs archive paths, bytes, or canonical metadata do not match this cloud-compose module source' "$archive_program" || \
+ fail "rootfs archive extraction does not reject module/archive content mismatches"
+grep -Fq "stat -c '%a:%h:%F'" "$archive_program" || \
+ fail "rootfs archive contract does not reject noncanonical modes or hard links"
+for embedded_source in "$linux_runtime" "$gcp_module" "$linux_cloud_init" "$gcp_cloud_init"; do
+ if grep -Eq 'sha256sum -c -|tar --no-same-owner|rootfs_dir=\"\$\(find' "$embedded_source"; then
+ fail "$embedded_source still embeds the substantive rootfs archive program"
+ fi
+done
+
+cloud_smoke="$repo_root/ci/cloud-smoke.sh"
+cloud_smoke_workflow="$repo_root/.github/workflows/cloud-smoke.yml"
+for fixture in "$repo_root/tests/smoke/do/main.tf" "$repo_root/tests/smoke/linode/main.tf"; do
+ grep -Fq 'rootfs_test_source_archive_prefix = "cloud-compose-${var.cloud_compose_source_ref}"' "$fixture" || \
+ fail "$fixture does not select the explicit exact-commit source-archive fixture mode"
+done
+grep -Fq 'source = "../../../modules/digitalocean"' "$repo_root/tests/smoke/do/main.tf" || \
+ fail "DigitalOcean hosted smoke does not keep source-archive mode below the public provider entrypoint"
+grep -Fq 'source = "../../../modules/linode"' "$repo_root/tests/smoke/linode/main.tf" || \
+ fail "Linode hosted smoke does not keep source-archive mode below the public provider entrypoint"
+for example_name in digitalocean linode; do
+ example_main="$repo_root/examples/$example_name/main.tf"
+ example_variables="$repo_root/examples/$example_name/variables.tf"
+ source_ref_block="$(sed -n '/^variable "cloud_compose_source_ref" {/,/^}/p' "$example_variables")"
+ source_sha_block="$(sed -n '/^variable "cloud_compose_source_sha256" {/,/^}/p' "$example_variables")"
+ if grep -Fq 'default' <<<"$source_ref_block" || grep -Fq 'default' <<<"$source_sha_block"; then
+ fail "$example_name runnable example still defaults to an obsolete cloud-compose release"
+ fi
+ grep -Fq 'releases/download/${var.cloud_compose_source_ref}/cloud-compose-rootfs.tar.gz' "$example_main" || \
+ fail "$example_name runnable example does not derive its canonical archive from the required exact release"
+ grep -Fq 'rootfs_archive_sha256 = var.cloud_compose_source_sha256' "$example_main" || \
+ fail "$example_name runnable example does not require the matching archive checksum"
+done
+grep -Fq 'run "rejects_rootfs_release_from_another_module_version"' "$linux_runtime_tests" || \
+ fail "runnable provider examples lack plan-time module/archive mismatch coverage"
+grep -Fq '"$source_ref" != "$checkout_sha"' "$cloud_smoke" || \
+ fail "hosted smoke does not bind its downloadable source archive to the checked-out commit"
+grep -Fq 'CLOUD_COMPOSE_SOURCE_REF: ${{ github.sha }}' "$cloud_smoke_workflow" || \
+ fail "hosted smoke does not select the exact tested merge commit"
+grep -Fq 'contents: read' "$cloud_smoke_workflow" || \
+ fail "hosted smoke lacks read-only repository permission"
+if grep -Fq 'contents: write' "$cloud_smoke_workflow"; then
+ fail "untrusted pull-request smoke code has repository write permission"
+fi
+for cloud_init_template in "$linux_cloud_init" "$gcp_cloud_init"; do
+ if grep -Eq '^[[:space:]]*-[[:space:]]*[|>][+-]?[[:space:]]*$' "$cloud_init_template"; then
+ fail "$cloud_init_template still embeds a shell program instead of invoking a checked-in file"
+ fi
done
rollout_installer="$repo_root/rootfs/home/cloud-compose/deploy-rollout.sh"
diff --git a/ci/systemd-contract.sh b/ci/systemd-contract.sh
index 07e38a4..0236e66 100755
--- a/ci/systemd-contract.sh
+++ b/ci/systemd-contract.sh
@@ -4,6 +4,10 @@ set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
unit_dir="$repo_root/rootfs/etc/systemd/system"
+diagnostics_program="$repo_root/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"
+smoke_healthcheck_program="$repo_root/rootfs/home/cloud-compose/smoke-healthcheck.sh"
+bootstrap_security="$repo_root/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh"
+root_program_runner="$repo_root/rootfs/etc/cloud-compose/libexec/run-root-program.sh"
fail() {
echo "systemd contract: $*" >&2
@@ -25,8 +29,15 @@ assert_contains() {
[[ -f "$unit_dir/cloud-compose-vault-agent.service" ]] || fail "namespaced Vault Agent unit is missing"
[[ -f "$unit_dir/cloud-compose-overlay.service" ]] || fail "Docker overlay mount unit is missing"
[[ -f "$unit_dir/cloud-compose-bootstrap.service" ]] || fail "retryable bootstrap unit is missing"
+[[ -x "$diagnostics_program" ]] || fail "checked-in Cloud Compose diagnostics program is missing or not executable"
+[[ -f "$bootstrap_security" ]] || fail "root-owned bootstrap security helper is missing"
+[[ -f "$root_program_runner" ]] || fail "root-owned service launcher is missing"
+[[ -x "$smoke_healthcheck_program" ]] || fail "checked-in smoke healthcheck wrapper is missing or not executable"
[[ -f "$unit_dir/cloud-compose-internal-services.service" && -f "$unit_dir/cloud-compose-internal-services.timer" ]] || \
fail "namespaced internal-services units are missing"
+[[ -f "$unit_dir/cloud-compose-offhost-backup.service" ]] || fail "off-host backup service is missing"
+[[ -f "$unit_dir/cloud-compose-restore-test.service" && -f "$unit_dir/cloud-compose-restore-test.timer" ]] || \
+ fail "scheduled restore-test units are missing"
assert_contains "$unit_dir/cloud-compose.service" 'Requires=docker.service cloud-compose-metadata-firewall.service'
assert_contains "$unit_dir/cloud-compose.service" 'RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes'
@@ -34,10 +45,17 @@ assert_contains "$unit_dir/cloud-compose.service" 'After=network-online.target d
assert_contains "$unit_dir/cloud-compose.service" 'ExecStartPre=/bin/bash /home/cloud-compose/assert-app-initialized.sh'
assert_contains "$unit_dir/cloud-compose.service" 'Restart=on-failure'
assert_contains "$unit_dir/cloud-compose.service" 'RestartSec=30s'
-assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'ConditionPathExists=!/home/cloud-compose/.cloud-compose-bootstrap-complete'
-assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'ExecStart=/bin/bash /home/cloud-compose/run-bootstrap.sh'
+assert_contains "$unit_dir/cloud-compose.service" 'StartLimitIntervalSec=6h'
+assert_contains "$unit_dir/cloud-compose.service" 'StartLimitBurst=3'
+if grep -Fq 'ConditionPathExists=' "$unit_dir/cloud-compose-bootstrap.service"; then
+ fail "bootstrap still trusts an unvalidated marker path condition"
+fi
+assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'ExecCondition=/bin/bash /etc/cloud-compose/libexec/bootstrap-required.sh'
+assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'ExecStart=/bin/bash /etc/cloud-compose/libexec/run-bootstrap.sh'
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'Restart=on-failure'
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'RestartSec=30s'
+assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'StartLimitIntervalSec=8h'
+assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'StartLimitBurst=3'
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'TimeoutStartSec=2h'
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'UMask=0022'
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'StandardOutput=journal'
@@ -48,8 +66,37 @@ assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'SyslogLevelPrefix=n
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'LogRateLimitIntervalSec=30s'
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'LogRateLimitBurst=1000'
assert_contains "$unit_dir/cloud-compose-internal-services.service" 'Requires=cloud-compose.service cloud-compose-metadata-firewall.service'
-assert_contains "$unit_dir/cloud-compose.service" 'TimeoutStartSec=1h'
+assert_contains "$unit_dir/cloud-compose.service" 'TimeoutStartSec=90min'
+assert_contains "$diagnostics_program" 'usage: ${diagnostics_program} state|status|dump'
+assert_contains "$diagnostics_program" 'readonly PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"'
+assert_contains "$diagnostics_program" 'readonly bootstrap_marker="/var/lib/cloud-compose/bootstrap-complete"'
+assert_contains "$bootstrap_security" 'cloud_compose_bootstrap_marker_ready()'
+assert_contains "$bootstrap_security" '0:0:644:1:regular file'
+assert_contains "$bootstrap_security" '"$marker_size" == "6"'
+assert_contains "$bootstrap_security" '"$payload" == "ready"'
+assert_contains "$bootstrap_security" 'cloud_compose_secure_runtime_home()'
+assert_contains "$root_program_runner" 'cloud_compose_secure_runtime_home'
+assert_contains "$root_program_runner" 'Unsupported Cloud Compose root program:'
+[[ -x "$repo_root/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh" ]] || \
+ fail "validated bootstrap readiness gate is missing or not executable"
+assert_contains "$diagnostics_program" '--- Cloud Compose provisioning heartbeat ---'
+assert_contains "$diagnostics_program" 'ps -p "$main_pid" -o pid=,ppid=,stat=,etime=,comm='
+assert_contains "$diagnostics_program" 'DOCKER_CONFIG=/mnt/disks/data/docker-config'
+assert_contains "$smoke_healthcheck_program" 'source /home/cloud-compose/profile.sh'
+assert_contains "$smoke_healthcheck_program" 'exec sitectl healthcheck --context "$context" --persist --format table'
assert_contains "$unit_dir/cloud-compose-mariadb-backup.service" 'TimeoutStartSec=12h'
+assert_contains "$unit_dir/cloud-compose-mariadb-backup.service" 'User=cloud-compose'
+assert_contains "$unit_dir/cloud-compose-mariadb-backup.timer" 'Unit=cloud-compose-offhost-backup.service'
+assert_contains "$unit_dir/cloud-compose-offhost-backup.service" 'Requires=cloud-compose-mariadb-backup.service'
+assert_contains "$unit_dir/cloud-compose-offhost-backup.service" 'After=cloud-compose-mariadb-backup.service network-online.target'
+assert_contains "$unit_dir/cloud-compose-offhost-backup.service" 'User=root'
+assert_contains "$unit_dir/cloud-compose-offhost-backup.service" 'UMask=0077'
+assert_contains "$unit_dir/cloud-compose-offhost-backup.service" 'TimeoutStartSec=24h'
+assert_contains "$unit_dir/cloud-compose-restore-test.service" 'User=root'
+assert_contains "$unit_dir/cloud-compose-restore-test.service" 'UMask=0077'
+assert_contains "$unit_dir/cloud-compose-restore-test.service" 'TimeoutStartSec=24h'
+assert_contains "$unit_dir/cloud-compose-restore-test.timer" 'OnCalendar=Sun *-*-* 03:00:00'
+assert_contains "$unit_dir/cloud-compose-restore-test.timer" 'Persistent=true'
if grep -Fq 'Wants=cloud-compose.service' "$unit_dir/cloud-compose-mariadb-backup.service"; then
fail "backup service starts an intentionally inactive application"
fi
@@ -74,9 +121,24 @@ assert_contains "$docker_metadata_dropin" 'Requires=cloud-compose-metadata-firew
assert_contains "$docker_metadata_dropin" 'After=cloud-compose-metadata-firewall-pre.service'
assert_contains "$metadata_pre_unit" 'Before=docker.service'
assert_contains "$metadata_pre_unit" 'WantedBy=multi-user.target'
-assert_contains "$metadata_pre_unit" 'ExecStart=/bin/bash /home/cloud-compose/configure-metadata-firewall.sh pre-docker'
+assert_contains "$metadata_pre_unit" 'ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh configure-metadata-firewall.sh pre-docker'
assert_contains "$unit_dir/cloud-compose-overlay.service" 'Before=docker.service cloud-compose.service'
-assert_contains "$unit_dir/cloud-compose-overlay.service" 'ExecStart=/bin/bash /home/cloud-compose/mount-overlays.sh'
+assert_contains "$unit_dir/cloud-compose-overlay.service" 'ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh mount-overlays.sh'
+for root_home_unit in \
+ cloud-compose-docker-prune.service \
+ cloud-compose-key-rotation.service \
+ cloud-compose-metadata-firewall-pre.service \
+ cloud-compose-metadata-firewall.service \
+ cloud-compose-offhost-backup.service \
+ cloud-compose-overlay.service \
+ cloud-compose-restore-test.service \
+ cloud-compose-vault-agent.service \
+ libops-managed-runtime.service; do
+ if grep -Eq '^Exec(Start|StartPre|StartPost|Stop|StopPost)=/bin/bash /home/cloud-compose/' "$unit_dir/$root_home_unit"; then
+ fail "$root_home_unit executes historically writable home code without the root-owned launcher"
+ fi
+ assert_contains "$unit_dir/$root_home_unit" '/etc/cloud-compose/libexec/run-root-program.sh'
+done
if grep -Eq '^(After|Before|BindsTo|PartOf|Requires|Requisite|Wants)=.*cloud-compose-bootstrap\\.service' \
"$unit_dir/cloud-compose.service"; then
fail "application service has an ordering dependency on the bootstrap service"
@@ -98,6 +160,9 @@ assert_contains "$run_script" 'systemctl disable --now libops-managed-runtime.ti
assert_contains "$run_script" 'systemctl enable --now cloud-compose-docker-prune.timer'
assert_contains "$run_script" 'systemctl disable --now cloud-compose-docker-prune.timer cloud-compose-docker-prune.service'
assert_contains "$run_script" 'systemctl enable --now cloud-compose-mariadb-backup.timer'
+assert_contains "$run_script" 'runtime_enabled "${CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED:-false}"'
+assert_contains "$run_script" 'systemctl enable --now cloud-compose-restore-test.timer'
+assert_contains "$run_script" 'systemctl disable --now cloud-compose-restore-test.timer cloud-compose-restore-test.service'
migration_script="$repo_root/rootfs/home/cloud-compose/migrate-legacy-systemd-units.sh"
migration_tmp="$(mktemp -d)"
diff --git a/ci/terraform-validate.sh b/ci/terraform-validate.sh
index 17e0156..66a38eb 100644
--- a/ci/terraform-validate.sh
+++ b/ci/terraform-validate.sh
@@ -31,13 +31,13 @@ validate_public_provider_graph() {
case "$rel" in
. | providers/gcp)
- expected_sources=$'hashicorp/cloudinit\nhashicorp/google\nhashicorp/time'
+ expected_sources=$'hashicorp/cloudinit\nhashicorp/google\nhashicorp/http\nhashicorp/time'
;;
providers/do)
- expected_sources='digitalocean/digitalocean'
+ expected_sources=$'digitalocean/digitalocean\nhashicorp/http'
;;
providers/linode)
- expected_sources='linode/linode'
+ expected_sources=$'hashicorp/http\nlinode/linode'
;;
*)
return 0
diff --git a/ci/testdata/disaster-recovery/assert-coverage-manifest.jq b/ci/testdata/disaster-recovery/assert-coverage-manifest.jq
new file mode 100644
index 0000000..2aedb1c
--- /dev/null
+++ b/ci/testdata/disaster-recovery/assert-coverage-manifest.jq
@@ -0,0 +1,6 @@
+.required_coverage == ["database", "application_files", "volume_topology"] and
+(.applications | length == 1) and
+(.applications[0].databases | length == 1) and
+.applications[0].application_files.roots == [env.TEST_DATA_ROOT + "/projects/alpha"] and
+.applications[0].volume_topology.declared_named_volumes == ["alpha_data"] and
+(.applications[0].volume_topology.service_mounts | length == 3)
diff --git a/ci/testdata/disaster-recovery/assert-restore-proof.jq b/ci/testdata/disaster-recovery/assert-restore-proof.jq
new file mode 100644
index 0000000..c7752de
--- /dev/null
+++ b/ci/testdata/disaster-recovery/assert-restore-proof.jq
@@ -0,0 +1,4 @@
+.disposable_recovery == true and
+.recovery_destroyed == true and
+.integrity_verified == true and
+.coverage == {database: true, application_files: true, volume_topology: true}
diff --git a/ci/testdata/disaster-recovery/compose-apps.sh b/ci/testdata/disaster-recovery/compose-apps.sh
new file mode 100644
index 0000000..e220836
--- /dev/null
+++ b/ci/testdata/disaster-recovery/compose-apps.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+compose_app_names_array() {
+ local -n result="$1"
+ # shellcheck disable=SC2034 # The caller reads the array through this nameref.
+ result=(alpha)
+}
+
+source_compose_app_env() {
+ DOCKER_COMPOSE_DIR="${TEST_DATA_ROOT:?}/projects/$1"
+ export DOCKER_COMPOSE_DIR
+}
+
+validate_compose_project_dir() {
+ [[ "$1" == "${TEST_DATA_ROOT:?}/projects/"* ]]
+}
diff --git a/ci/testdata/disaster-recovery/fake-compose-config.jq b/ci/testdata/disaster-recovery/fake-compose-config.jq
new file mode 100644
index 0000000..919adbb
--- /dev/null
+++ b/ci/testdata/disaster-recovery/fake-compose-config.jq
@@ -0,0 +1,12 @@
+{
+ services: {
+ web: {
+ volumes: [
+ {type: "bind", source: ($root + "/projects/alpha/files"), target: "/srv/files", read_only: false},
+ {type: "volume", source: "alpha_data", target: "/var/lib/app", read_only: false},
+ {type: "tmpfs", source: "", target: "/run/app", read_only: false}
+ ]
+ }
+ },
+ volumes: {alpha_data: {name: "alpha_data"}}
+}
diff --git a/ci/testdata/disaster-recovery/fake-docker.sh b/ci/testdata/disaster-recovery/fake-docker.sh
new file mode 100644
index 0000000..9e04044
--- /dev/null
+++ b/ci/testdata/disaster-recovery/fake-docker.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+fixture_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+[[ "$*" == "compose config --format json" ]]
+jq -cn --arg root "${TEST_DATA_ROOT:?}" \
+ -f "$fixture_dir/fake-compose-config.jq"
diff --git a/ci/testdata/disaster-recovery/fake-install.sh b/ci/testdata/disaster-recovery/fake-install.sh
new file mode 100644
index 0000000..46c7700
--- /dev/null
+++ b/ci/testdata/disaster-recovery/fake-install.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+args=()
+while (($# > 0)); do
+ case "$1" in
+ -o | -g)
+ shift 2
+ ;;
+ *)
+ args+=("$1")
+ shift
+ ;;
+ esac
+done
+exec /usr/bin/install "${args[@]}"
diff --git a/ci/testdata/disaster-recovery/fake-stat.sh b/ci/testdata/disaster-recovery/fake-stat.sh
new file mode 100644
index 0000000..17c8e33
--- /dev/null
+++ b/ci/testdata/disaster-recovery/fake-stat.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+output="$(/usr/bin/stat "$@")"
+if [[ "$*" == *"%u:"* ]]; then
+ printf '0:%s\n' "${output#*:}"
+else
+ printf '%s\n' "$output"
+fi
diff --git a/ci/testdata/disaster-recovery/good-backup-receipt.jq b/ci/testdata/disaster-recovery/good-backup-receipt.jq
new file mode 100644
index 0000000..20e0d65
--- /dev/null
+++ b/ci/testdata/disaster-recovery/good-backup-receipt.jq
@@ -0,0 +1,12 @@
+{
+ schema_version: 1,
+ kind: "cloud-compose.offhost-backup-receipt",
+ operation_id: $operation_id,
+ completed_at: "2026-08-07T12:00:00Z",
+ manifest_sha256: $manifest_sha256,
+ encrypted: true,
+ off_host: true,
+ status: "succeeded",
+ remote_id: "contract/backup-1",
+ coverage: {database: true, application_files: true, volume_topology: true}
+}
diff --git a/ci/testdata/disaster-recovery/good-driver.sh b/ci/testdata/disaster-recovery/good-driver.sh
new file mode 100644
index 0000000..16a684d
--- /dev/null
+++ b/ci/testdata/disaster-recovery/good-driver.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+fixture_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+[[ -z "${SHOULD_NOT_REACH_DRIVER+x}" ]] || exit 90
+printf '%s\n' "$1" >>"${0}.calls"
+operation="$1"
+shift
+declare -A args=()
+while (($# > 0)); do
+ args["$1"]="$2"
+ shift 2
+done
+case "$operation" in
+ backup)
+ jq -cn \
+ --arg operation_id "${args[--operation-id]}" \
+ --arg manifest_sha256 "${args[--manifest-sha256]}" \
+ -f "$fixture_dir/good-backup-receipt.jq" >"${args[--receipt]}"
+ ;;
+ restore-test)
+ jq -cn \
+ --arg test_id "${args[--test-id]}" \
+ --arg manifest_sha256 "${args[--source-manifest-sha256]}" \
+ --arg receipt_sha256 "${args[--source-receipt-sha256]}" \
+ -f "$fixture_dir/good-restore-proof.jq" >"${args[--proof]}"
+ ;;
+ *) exit 64 ;;
+esac
diff --git a/ci/testdata/disaster-recovery/good-restore-proof.jq b/ci/testdata/disaster-recovery/good-restore-proof.jq
new file mode 100644
index 0000000..512892a
--- /dev/null
+++ b/ci/testdata/disaster-recovery/good-restore-proof.jq
@@ -0,0 +1,15 @@
+{
+ schema_version: 1,
+ kind: "cloud-compose.restore-test-proof",
+ test_id: $test_id,
+ completed_at: "2026-08-07T13:00:00Z",
+ source_manifest_sha256: $manifest_sha256,
+ source_receipt_sha256: $receipt_sha256,
+ source_encrypted: true,
+ status: "succeeded",
+ recovery_id: "contract/recovery-1",
+ disposable_recovery: true,
+ recovery_destroyed: true,
+ integrity_verified: true,
+ coverage: {database: true, application_files: true, volume_topology: true}
+}
diff --git a/ci/testdata/disaster-recovery/incomplete-backup-receipt.jq b/ci/testdata/disaster-recovery/incomplete-backup-receipt.jq
new file mode 100644
index 0000000..114cf7e
--- /dev/null
+++ b/ci/testdata/disaster-recovery/incomplete-backup-receipt.jq
@@ -0,0 +1,12 @@
+{
+ schema_version: 1,
+ kind: "cloud-compose.offhost-backup-receipt",
+ operation_id: $operation_id,
+ completed_at: "2026-08-07T12:00:00Z",
+ manifest_sha256: $manifest_sha256,
+ encrypted: true,
+ off_host: true,
+ status: "succeeded",
+ remote_id: "contract/incomplete",
+ coverage: {database: true, application_files: true, volume_topology: false}
+}
diff --git a/ci/testdata/disaster-recovery/incomplete-driver.sh b/ci/testdata/disaster-recovery/incomplete-driver.sh
new file mode 100644
index 0000000..4b5ed07
--- /dev/null
+++ b/ci/testdata/disaster-recovery/incomplete-driver.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+fixture_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+operation="$1"
+shift
+declare -A args=()
+while (($# > 0)); do
+ args["$1"]="$2"
+ shift 2
+done
+[[ "$operation" == "backup" ]]
+jq -cn \
+ --arg operation_id "${args[--operation-id]}" \
+ --arg manifest_sha256 "${args[--manifest-sha256]}" \
+ -f "$fixture_dir/incomplete-backup-receipt.jq" >"${args[--receipt]}"
diff --git a/ci/testdata/disaster-recovery/profile.sh b/ci/testdata/disaster-recovery/profile.sh
new file mode 100644
index 0000000..2c901e6
--- /dev/null
+++ b/ci/testdata/disaster-recovery/profile.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+export PATH="${TEST_BIN:?}:/usr/bin:/bin"
+
+acquire_cloud_compose_lifecycle_lock() {
+ printf '%s\n' "$1" >>"${LOCK_LOG:?}"
+}
diff --git a/ci/vault-runtime-contract.sh b/ci/vault-runtime-contract.sh
index 521413c..901d776 100644
--- a/ci/vault-runtime-contract.sh
+++ b/ci/vault-runtime-contract.sh
@@ -179,7 +179,13 @@ cmp -s "$tmp/expected-source-preparation.log" "$source_log" || \
fail "source preparation did not clone every app without executing lifecycle work"
grep -Fq 'ExecStartPre=/bin/bash /home/cloud-compose/assert-vault-ready.sh' \
"$repo_root/rootfs/etc/systemd/system/cloud-compose.service" || fail "app service lacks a Vault readiness gate"
-grep -Fq 'ExecStartPost=/bin/bash /home/cloud-compose/vault-agent-readiness.sh wait' \
- "$repo_root/rootfs/etc/systemd/system/cloud-compose-vault-agent.service" || fail "Vault unit does not publish token readiness"
+vault_unit="$repo_root/rootfs/etc/systemd/system/cloud-compose-vault-agent.service"
+for readiness_command in \
+ 'ExecStartPre=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh vault-agent-readiness.sh prepare' \
+ 'ExecStartPost=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh vault-agent-readiness.sh wait' \
+ 'ExecStopPost=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh vault-agent-readiness.sh clear'; do
+ grep -Fq "$readiness_command" "$vault_unit" || \
+ fail "Vault unit does not route readiness through the trusted root launcher: $readiness_command"
+done
echo "Vault runtime contract passed"
diff --git a/ci/verify-rootfs-release.sh b/ci/verify-rootfs-release.sh
new file mode 100755
index 0000000..5497890
--- /dev/null
+++ b/ci/verify-rootfs-release.sh
@@ -0,0 +1,125 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd -- "$script_dir/.." && pwd)"
+readonly archive_name=cloud-compose-rootfs.tar.gz
+readonly archive_checksum_name=cloud-compose-rootfs.tar.gz.sha256
+readonly contract_name=cloud-compose-rootfs.contract.sha256
+
+fail() {
+ echo "rootfs release verification: $*" >&2
+ exit 1
+}
+
+resolve_tag_for_commit() {
+ local commit="$1" attempt tag
+
+ [[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "commit must be a full lowercase Git SHA"
+ for ((attempt = 1; attempt <= 60; attempt++)); do
+ git fetch --force --tags origin >/dev/null 2>&1 || true
+ tag="$(git tag --points-at "$commit" --sort=-version:refname | head -n 1)"
+ if [[ -n "$tag" ]]; then
+ printf '%s\n' "$tag"
+ return 0
+ fi
+ sleep 10
+ done
+ fail "no release tag appeared for commit $commit"
+}
+
+download_assets() {
+ local tag="$1" destination="$2" attempt
+
+ for ((attempt = 1; attempt <= 60; attempt++)); do
+ if gh release download "$tag" \
+ --repo "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" \
+ --dir "$destination" \
+ --clobber \
+ --pattern "$archive_name" \
+ --pattern "$archive_checksum_name" \
+ --pattern "$contract_name" >/dev/null 2>&1 &&
+ [[ -f "$destination/$archive_name" &&
+ -f "$destination/$archive_checksum_name" &&
+ -f "$destination/$contract_name" ]]; then
+ return 0
+ fi
+ sleep 10
+ done
+ fail "release $tag did not publish all three immutable rootfs assets"
+}
+
+verify_assets() {
+ local tag="$1" asset_dir="$2" extract_dir="$3"
+ local checksum_line expected_archive_sha256 checksum_filename actual_archive_sha256
+ local contract_sha256 actual_contract_sha256
+
+ checksum_line="$(<"$asset_dir/$archive_checksum_name")"
+ [[ "$checksum_line" =~ ^([0-9a-f]{64})\ \ cloud-compose-rootfs\.tar\.gz$ ]] ||
+ fail "release $tag has a malformed archive checksum asset"
+ expected_archive_sha256="${BASH_REMATCH[1]}"
+ checksum_filename="${checksum_line#* }"
+ [[ "$checksum_filename" == "$archive_name" ]] ||
+ fail "release $tag checksum names an unexpected archive"
+ actual_archive_sha256="$(sha256sum -- "$asset_dir/$archive_name" | cut -d' ' -f1)"
+ [[ "$actual_archive_sha256" == "$expected_archive_sha256" ]] ||
+ fail "release $tag archive bytes do not match its checksum"
+
+ [[ "$(wc -c <"$asset_dir/$contract_name")" -eq 65 ]] ||
+ fail "release $tag rootfs contract must be one digest plus a newline"
+ contract_sha256="$(<"$asset_dir/$contract_name")"
+ [[ "$contract_sha256" =~ ^[0-9a-f]{64}$ ]] ||
+ fail "release $tag rootfs contract is not a lowercase SHA-256 digest"
+
+ bash "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" \
+ validate-archive "$asset_dir/$archive_name" ||
+ fail "release $tag archive member topology is unsafe"
+
+ install -d -m 0755 "$extract_dir"
+ tar --no-same-owner --same-permissions \
+ -xzf "$asset_dir/$archive_name" -C "$extract_dir"
+ actual_contract_sha256="$(
+ bash "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" \
+ contract "$extract_dir/rootfs"
+ )"
+ [[ "$actual_contract_sha256" == "$contract_sha256" ]] ||
+ fail "release $tag archive does not match its canonical rootfs contract"
+}
+
+verify_source_assets() {
+ local tag="$1" asset_dir="$2" expected_dir="$3"
+
+ bash "$repo_root/ci/package-rootfs.sh" "$expected_dir"
+ for asset in "$archive_name" "$archive_checksum_name" "$contract_name"; do
+ cmp -- "$expected_dir/$asset" "$asset_dir/$asset" ||
+ fail "release $tag asset $asset does not match the tagged source"
+ done
+}
+
+main() {
+ local selector="${1:-}" value="${2:-}" tag tmp
+
+ [[ "$#" -eq 2 ]] || fail "usage: verify-rootfs-release.sh tag TAG|commit SHA"
+ case "$selector" in
+ tag)
+ [[ "$value" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] ||
+ fail "tag is not a semantic-version release tag"
+ tag="$value"
+ ;;
+ commit)
+ tag="$(resolve_tag_for_commit "$value")"
+ ;;
+ *) fail "usage: verify-rootfs-release.sh tag TAG|commit SHA" ;;
+ esac
+
+ tmp="$(mktemp -d "${RUNNER_TEMP:-/tmp}/cloud-compose-rootfs-release.XXXXXX")"
+ trap 'rm -rf -- "$tmp"' EXIT
+ install -d -m 0755 "$tmp/assets"
+ download_assets "$tag" "$tmp/assets"
+ verify_source_assets "$tag" "$tmp/assets" "$tmp/expected"
+ verify_assets "$tag" "$tmp/assets" "$tmp/extracted"
+ echo "Verified rootfs release assets for $tag"
+}
+
+main "$@"
diff --git a/docs/disaster-recovery.md b/docs/disaster-recovery.md
new file mode 100644
index 0000000..618201e
--- /dev/null
+++ b/docs/disaster-recovery.md
@@ -0,0 +1,183 @@
+# Disaster recovery driver
+
+Cloud Compose keeps a same-disk logical MariaDB dump for each application and
+can hand a complete recovery manifest to an operator-owned off-host backup
+driver. The interface is provider-neutral: cloud-compose does not choose an
+object store, account, encryption service, retention policy, or credential
+mechanism.
+
+Local dumps and provider boot-disk snapshots are recovery aids, not disaster
+recovery. A site has DR coverage only after the nightly unit publishes a valid
+receipt proving encrypted off-host coverage of every database, application-file
+root, bind mount, named volume, and service-mount topology in the manifest.
+
+## Enable the contract
+
+Install the reviewed driver and all of its configuration out of band. The file
+and every directory in its path must be root-owned, must not be a symlink or be
+group/world writable, and the executable must have exactly one hard link.
+`/etc/cloud-compose/libexec/offhost-backup-driver` is the portable default on
+COS and conventional Linux hosts. An explicit safe absolute path remains
+supported when an operator manages the driver elsewhere. Because COS rebuilds
+`/etc` at boot, provide the driver through the configured rootfs overlay or an
+equivalent startup provisioner there; a one-time manual copy will not survive a
+reboot. Keep credentials outside that overlay.
+Then enable the provider-neutral runtime input:
+
+```hcl
+runtime = {
+ disaster_recovery = {
+ required = true
+ driver_path = "/etc/cloud-compose/libexec/offhost-backup-driver"
+ }
+}
+```
+
+Terraform, cloud-init, `.env`, plans, state, and application lifecycle commands
+must not contain a storage endpoint, bucket credential, encryption key, or
+access token. The driver owns those details. A root-only configuration file,
+host workload identity, or an operator-managed Vault integration are suitable
+implementation choices. Do not use `runtime.extra_env` for driver credentials.
+
+Ansible and Salt accept the same nested `runtime.disaster_recovery` object.
+Changing `required` to `true` before the driver is installed intentionally
+makes the next nightly handoff and weekly restore test fail.
+
+## Backup invocation
+
+The nightly `cloud-compose-mariadb-backup.timer` starts
+`cloud-compose-offhost-backup.service`. Systemd first runs the existing
+unprivileged MariaDB dump service. The root service then creates a deterministic
+manifest and invokes:
+
+```text
+DRIVER backup \
+ --manifest PATH \
+ --manifest-sha256 SHA256 \
+ --operation-id YYYYMMDD-SITE \
+ --receipt PATH
+```
+
+The driver receives a clean environment containing only `HOME=/root` and a
+fixed system `PATH`. Its stdout and stderr are suppressed so an accidental SDK
+or credential diagnostic cannot enter the system journal. The driver must load
+its own operator-managed configuration and write its receipt to the requested
+path. It must not modify the manifest.
+
+For every app, the manifest includes:
+
+- a root-only staged copy of the validated `sql.gz` logical dump, including its
+ SHA-256 and byte count;
+- the application checkout root and persistent bind-mount sources;
+- every declared named volume; and
+- every resolved Compose service mount, including target, type, source, and
+ read-only state.
+
+Cloud Compose extracts only this topology from `docker compose config`; the
+rendered Compose model, which can contain application environment values, stays
+in a mode-0600 staging directory and is deleted. Persistent bind mounts outside
+`/mnt/disks/data` and `/mnt/disks/volumes` fail closed instead of extending the
+privileged backup boundary.
+
+The driver must finish encrypting and durably transferring every referenced
+component before it writes this exact receipt shape:
+
+```json
+{
+ "schema_version": 1,
+ "kind": "cloud-compose.offhost-backup-receipt",
+ "operation_id": "20260807-example-site",
+ "completed_at": "2026-08-07T12:00:00Z",
+ "manifest_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ "encrypted": true,
+ "off_host": true,
+ "status": "succeeded",
+ "remote_id": "operator-safe-opaque-reference",
+ "coverage": {
+ "database": true,
+ "application_files": true,
+ "volume_topology": true
+ }
+}
+```
+
+`remote_id` is deliberately constrained to a short opaque identifier; it must
+not contain a signed URL, token, query string, or credential. Unknown fields,
+missing coverage, false encryption/off-host claims, a mismatched operation or
+manifest digest, unsafe ownership, links, oversized JSON, and malformed values
+are rejected. The validated manifest and receipt are atomically published under
+`/mnt/disks/data/.cloud-compose-disaster-recovery/`. Driver failure leaves the
+last valid receipt untouched.
+
+The off-host handoff runs even when the day's valid local dump already exists.
+That is what lets the timer retry an earlier transfer failure without rewriting
+the database artifact. The local dump remains subject to its independent
+14-day retention policy and must never be reported as DR coverage.
+
+## Scheduled restore proof
+
+When DR is required, bootstrap enables `cloud-compose-restore-test.timer`. It
+runs weekly on Sunday with a stable randomized delay of up to six hours. The
+service selects the newest validated backup receipt, creates a cryptographically
+random one-time test ID, and invokes:
+
+```text
+DRIVER restore-test \
+ --manifest PATH \
+ --backup-receipt PATH \
+ --source-manifest-sha256 SHA256 \
+ --source-receipt-sha256 SHA256 \
+ --test-id ONE_TIME_ID \
+ --proof PATH
+```
+
+The driver must restore from off-host encrypted storage into a disposable
+recovery environment, verify the database plus representative application-file
+and volume data, and destroy that environment. Only then may it emit:
+
+```json
+{
+ "schema_version": 1,
+ "kind": "cloud-compose.restore-test-proof",
+ "test_id": "20260807T130000Z-random-challenge",
+ "completed_at": "2026-08-07T13:00:00Z",
+ "source_manifest_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ "source_receipt_sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
+ "source_encrypted": true,
+ "status": "succeeded",
+ "recovery_id": "operator-safe-opaque-reference",
+ "disposable_recovery": true,
+ "recovery_destroyed": true,
+ "integrity_verified": true,
+ "coverage": {
+ "database": true,
+ "application_files": true,
+ "volume_topology": true
+ }
+}
+```
+
+The one-time ID and both source digests prevent a stale proof from satisfying a
+new run. Proofs use the same root-owned, bounded, exact-schema, atomic
+publication rules as backup receipts.
+
+## Operator checks
+
+Treat either unit failure as loss of the managed recovery claim and alert on it:
+
+```bash
+systemctl status cloud-compose-mariadb-backup.service \
+ cloud-compose-offhost-backup.service \
+ cloud-compose-restore-test.service
+journalctl -u cloud-compose-offhost-backup.service \
+ -u cloud-compose-restore-test.service
+```
+
+The journal intentionally contains only cloud-compose's generic status and
+validation errors. Driver-specific diagnostics must go to an operator-owned
+root-only sink that applies its own secret redaction.
+
+Before approving a destructive Terraform plan, verify that the newest backup
+receipt covers the current topology and that a recent restore proof exists.
+Provider snapshots can shorten recovery time, but they do not replace this
+independent encrypted copy and disposable restore evidence.
diff --git a/docs/examples.md b/docs/examples.md
index e128c5c..df15d80 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -22,7 +22,7 @@ module path and supplies the template:
```hcl
module "app" {
- source = "github.com/libops/cloud-compose//providers/do?ref=1.0.0"
+ source = "github.com/libops/cloud-compose//providers/do?ref=REPLACE_WITH_EXACT_RELEASE"
name = "cc-wp"
template = "wp"
@@ -31,12 +31,19 @@ module "app" {
cloud_compose_keys = var.operator_ssh_keys
}
}
+ runtime = {
+ rootfs_archive_url = var.cloud_compose_rootfs_archive_url
+ rootfs_archive_sha256 = var.cloud_compose_rootfs_archive_sha256
+ }
}
```
-The examples pin the reviewed `1.0.0` release. Replace that ref only with the
-exact release or full commit your organization has reviewed; omitting `?ref=`
-makes future plans consume a moving module source.
+Replace the source placeholder with an exact release that publishes all three
+canonical rootfs assets, and set the URL and SHA-256 from that same release.
+The runnable DigitalOcean and Linode examples intentionally provide no release
+default. Terraform fetches the adjacent rootfs contract during planning and
+rejects a module/archive mismatch; omitting `?ref=` would make future plans
+consume a moving module source.
## GCP foundation and application states
@@ -129,12 +136,12 @@ healthcheck settings:
runtime = {
sitectl = {
package_versions = {
- sitectl = "v1.0.0"
- sitectl-wp = "v1.0.0"
+ sitectl = "v1.9.1"
+ sitectl-wp = "v2.1.0"
}
}
compose = {
- branch = "v1.0.0"
+ branch = "v1.1.1"
ingress = {
letsencrypt = true
bot_mitigation = true
@@ -175,9 +182,9 @@ runtime = {
}
sitectl = {
package_versions = {
- sitectl = "v1.0.0"
- sitectl-wp = "v1.0.0"
- sitectl-drupal = "v1.0.0"
+ sitectl = "v1.9.1"
+ sitectl-wp = "v2.1.0"
+ sitectl-drupal = "v1.5.0"
}
}
}
diff --git a/docs/index.md b/docs/index.md
index 58bafa5..75f5439 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -21,7 +21,8 @@ the same lifecycle path used by later rollouts.
- Optional GCP power management through Cloud Run and lightsout
- Provider-neutral runtime contracts for DigitalOcean and Linode
- Existing-host deployment through Ansible or Salt
-- Nightly MariaDB backups through systemd timers
+- Nightly local MariaDB recovery dumps plus an optional provider-neutral,
+ encrypted off-host DR driver and scheduled restore proofs
## Who owns what
@@ -32,7 +33,7 @@ the same lifecycle path used by later rollouts.
| App source revision on an existing VM | Authenticated rollout endpoint or operator-run `/home/cloud-compose/rollout` | `sitectl deploy` against an explicit ref and manifest app key |
| App Compose behavior and health verification | sitectl plugin/component definitions | Versioned plugin release and normal lifecycle commands |
| Secrets and private forge credentials | Vault/operator secret delivery | Short-lived files rendered outside Terraform state |
-| Logical backup retention and off-host disaster recovery | cloud-compose timer plus operator-owned storage policy | Local dumps are pruned after 14 days; independent copies and restore tests remain an operator responsibility |
+| Local logical backup and off-host disaster recovery | cloud-compose timers plus operator-owned DR driver | Local dumps are pruned after 14 days; a strict receipt proves encrypted independent coverage and a scheduled disposable restore |
Any cloud-init byte can change the GCP boot-disk identity and replace the VM;
cloud-init is bootstrap configuration, not the day-2 app update channel. Keep
@@ -41,6 +42,8 @@ routine source deployments in rollout and application behavior in sitectl.
## Start here
- [Runtime contracts](runtime-contracts.md) explains the VM/app contract.
+- [Disaster recovery](disaster-recovery.md) defines the off-host driver,
+ receipts, restore proofs, and operator checks.
- [GCP foundation and application states](runtime-contracts.md#gcp-foundation-and-application-states) explains singleton API/IAM ownership, Shared VPC, and Direct VPC egress.
- [Managed runtime](managed-runtime.md) covers host tools and internal services.
- [Rollout API](rollout.md) covers authenticated deploy triggers.
diff --git a/docs/managed-runtime.md b/docs/managed-runtime.md
index 630426f..44f4e67 100644
--- a/docs/managed-runtime.md
+++ b/docs/managed-runtime.md
@@ -26,11 +26,14 @@ core and plugins independently. The provider-neutral
backward-compatible fallback only for an installed package with neither a
template selector nor an explicit per-package selector.
-All presets select coordinated sitectl v1 release packages. The ISLE preset
-uses the `libops/isle` v1.1.0 template with sitectl, sitectl-drupal, and
-sitectl-isle v1.0.0. Every other application preset remains on its v1.0.0
-template contract. Override the template or package selectors only as one
-reviewed, compatible release set.
+All presets pin sitectl v1.9.1 with reviewed application plugins:
+sitectl-archivesspace v2.1.1, sitectl-drupal v1.5.0, sitectl-isle v1.6.0,
+sitectl-ojs v1.4.0, sitectl-omeka-classic v1.4.0, sitectl-omeka-s v1.4.0,
+and sitectl-wp v2.1.0. ISLE includes both the Drupal and ISLE plugins. The
+Compose template branches remain independently pinned: ISLE uses v1.3.1,
+ArchivesSpace uses v1.0.1, WordPress uses v1.1.1, and Drupal, OJS, Omeka
+Classic, and Omeka S use v1.2.1. Override template or package selectors only
+as one reviewed, compatible release set.
Omitting `runtime.sitectl.packages` selects the template's package set. An
explicit list replaces that set; `packages = []` or `packages = ["sitectl"]`
@@ -51,9 +54,9 @@ runtime = {
"sitectl-isle",
]
package_versions = {
- sitectl = "v1.0.0"
- sitectl-drupal = "v1.0.0"
- sitectl-isle = "v1.0.0"
+ sitectl = "v1.9.1"
+ sitectl-drupal = "v1.5.0"
+ sitectl-isle = "v1.6.0"
}
plugin = "isle"
}
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index f879f50..f59c73b 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -63,6 +63,7 @@ extra_javascript:
nav:
- Home: index.md
- Runtime contracts: runtime-contracts.md
+ - Disaster recovery: disaster-recovery.md
- Managed runtime: managed-runtime.md
- Rollout API: rollout.md
- DigitalOcean and Linode: non-gcp-providers.md
diff --git a/docs/non-gcp-providers.md b/docs/non-gcp-providers.md
index 24321af..3c781b4 100644
--- a/docs/non-gcp-providers.md
+++ b/docs/non-gcp-providers.md
@@ -102,13 +102,14 @@ nightly MariaDB dumps under `/mnt/disks/data/backups` are useful for logical
recovery, but they live on the same data volume and are not disaster recovery.
Also, `terraform destroy` intentionally deletes both managed volumes.
-Before production, establish an independently owned offsite policy for both
-volumes (provider volume snapshots where available, or encrypted export to
-separate object storage/account), define retention separately from this
-application state, and test a restore into disposable new volumes. A restore is
-complete only after the Compose projects start, `sitectl healthcheck` passes,
-and representative files plus database records are verified. Do not enable a
-boot-disk backup toggle and record the application as protected.
+Before production, install an operator-owned driver and set
+`runtime.disaster_recovery.required = true`. The provider-neutral handoff
+requires encrypted off-host coverage for the logical database, application
+files, and complete Compose volume topology, then schedules restore tests into
+a disposable recovery environment. The exact driver and proof contract is in
+[Disaster recovery](disaster-recovery.md). Retention and credentials remain
+operator-owned. Do not enable a boot-disk backup toggle and record the
+application as protected.
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:
@@ -261,7 +262,7 @@ repo, `sitectl` plugin, and plugin package defaults:
```hcl
module "wp" {
- source = "github.com/libops/cloud-compose//providers/do?ref=1.0.0"
+ source = "github.com/libops/cloud-compose//providers/do?ref=REPLACE_WITH_EXACT_RELEASE"
name = "cc-wp"
template = "wp"
@@ -272,18 +273,20 @@ module "wp" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/releases/download/1.0.0/cloud-compose-rootfs.tar.gz"
+ rootfs_archive_url = var.cloud_compose_rootfs_archive_url
rootfs_archive_sha256 = var.cloud_compose_rootfs_sha256
}
}
```
-The `1.0.0` ref is intentional. Replace it only with an exact reviewed release
-or full commit. DigitalOcean limits Droplet `user_data` to 64 KiB, and the full
-managed runtime no longer fits safely inline. Use the integrity-pinned archive
-pair shown above; Terraform rejects an oversized inline payload before calling
-the Droplet API. The default DigitalOcean SSH firewall sources include the
-public internet; production callers should set
+Replace the source placeholder with the exact reviewed release that supplied
+`cloud_compose_rootfs_archive_url` and its SHA-256. The runnable example leaves
+both release inputs required because older releases do not publish the adjacent
+contract now required at plan time. DigitalOcean limits Droplet `user_data` to
+64 KiB, and the full managed runtime no longer fits safely inline. Terraform
+rejects both an oversized inline payload and a mismatched module/archive before
+calling the Droplet API. The default DigitalOcean SSH firewall sources include
+the public internet; production callers should set
`digitalocean.firewall.ssh_source_addresses` to institutional or operator
CIDRs instead of inheriting that default.
@@ -312,7 +315,7 @@ before extracting any archive content.
```hcl
module "drupal" {
- source = "github.com/libops/cloud-compose//providers/linode?ref=1.0.0"
+ source = "github.com/libops/cloud-compose//providers/linode?ref=REPLACE_WITH_EXACT_RELEASE"
name = "cc-drupal"
template = "drupal"
@@ -326,7 +329,7 @@ module "drupal" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/releases/download/1.0.0/cloud-compose-rootfs.tar.gz"
+ rootfs_archive_url = var.cloud_compose_rootfs_archive_url
rootfs_archive_sha256 = var.cloud_compose_rootfs_sha256
}
}
@@ -334,13 +337,18 @@ module "drupal" {
Linode also defaults SSH ingress to the public internet. Set
`linode.firewall.ssh_source_ipv4` and `ssh_source_ipv6` to reviewed operator
-CIDRs. Replace the `1.0.0` module ref only with an exact release or full commit
-your organization has reviewed.
-
-Use the canonical `cloud-compose-rootfs.tar.gz` and adjacent `.sha256` release
-assets. They are built reproducibly from the tagged `rootfs/` tree; GitHub's
-generated repository source archives are not a stable long-term checksum
-boundary because GitHub may regenerate their outer compression. CI may use a
-commit archive only within the same run while testing an unreleased commit.
-Download the release asset through the same URL consumers will use and verify
-its published checksum before planning the deployment.
+CIDRs. Replace the source placeholder only with the exact reviewed release that
+supplied the rootfs archive URL and checksum. The runnable example intentionally
+has no release default.
+
+Use the canonical `cloud-compose-rootfs.tar.gz`, adjacent archive `.sha256`,
+and `cloud-compose-rootfs.contract.sha256` release assets. They are built
+reproducibly from the tagged `rootfs/` tree. Terraform fetches the contract
+sidecar while planning and rejects an archive from a different module release
+before changing a VM; boot independently verifies both the pinned archive
+bytes and the canonical rootfs contract. GitHub's generated repository source
+archives are not a stable long-term checksum boundary because GitHub may
+regenerate their outer compression. CI may use a commit archive only within the
+same run while testing an unreleased commit. Promote a release only after its
+`Verify rootfs release assets` job is green, then use the exact release asset
+URL and published archive checksum in the deployment.
diff --git a/docs/rollout.md b/docs/rollout.md
index c4b35e2..a023561 100644
--- a/docs/rollout.md
+++ b/docs/rollout.md
@@ -28,16 +28,16 @@ module "site" {
runtime = {
compose = {
rollout = [
- "TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-}}\"",
- "if [ -n \"$TARGET_REF\" ]; then sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --ref \"$TARGET_REF\"; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --skip-git; fi",
- "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist",
- "if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi",
+ "/home/cloud-compose/default-lifecycle.sh rollout",
]
}
}
}
```
+The `runtime.compose.rollout` entry above is the built-in default and may be
+omitted. It is shown to make the single-program execution boundary explicit.
+
The example pins the reviewed cloud-compose `1.0.0` release. Replace that ref
only with an exact reviewed release or full commit. `gcp.rollout.release_url`
should point to a pinned Linux binary built from the generic rollout service;
@@ -124,9 +124,13 @@ For bin-packed hosts, set `rollout_arg1` to the exact app key from
`CLOUD_COMPOSE_APP` only for the rollout lifecycle and validates it against the
manifest before running any command. Omit it to retain the primary-app default.
-The generated rollout script runs from the checked-out compose repository after
-sourcing `/home/cloud-compose/profile.sh`. The default contract prefers
-`GIT_REF`, then `GIT_BRANCH`. When either is present, `sitectl deploy --ref`
+The root-owned default lifecycle program runs from the checked-out compose
+repository after the host dispatcher loads the validated app environment. For
+controller-compatible callers that additionally provide `GIT_COMMIT_SHA`, the
+default contract requires an exact lowercase 40-character commit SHA and
+prefers it over `GIT_REF`, then `GIT_BRANCH`. The authenticated rollout service
+currently supplies the latter two fields. When a source selector is present,
+`sitectl deploy --ref`
fetches that exact remote ref into a dedicated local ref, verifies it resolves
to a commit, and checks it out detached before invoking the active plugin's
component lifecycle. This supports branch names, advertised commit IDs, and
diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md
index a94d779..0464d5b 100644
--- a/docs/runtime-contracts.md
+++ b/docs/runtime-contracts.md
@@ -138,16 +138,29 @@ remote branch moves. The deployed commit for every app is recorded at
`/home/cloud-compose/state/.deployed-head`, including branch/tag
deployments, so operators can compare desired and observed source state.
+`sitectl` component reconciliation can derive tracked runtime configuration
+from `.libops/site.yaml`. After a successful initialization or rollout,
+cloud-compose records the exact binary Git-diff fingerprint at
+`/home/cloud-compose/state/.managed-diff`. Service starts accept that
+derived state only while its fingerprint is unchanged. A source update or
+rollout restores the committed tree only after the current diff matches the
+recorded value, then derives a new fingerprint after success. Partial
+reconciliation and operator edits still fail closed; institution-specific
+source changes must be committed in the downstream repository.
+
Use a full commit for reproducible production rollouts. A commit pin fixes the
repository contents but does not prove who authored them; protect the selected
repository and review/sign commits according to your downstream governance.
First boot runs through `cloud-compose-bootstrap.service`. Both that bootstrap
-and `cloud-compose.service` retry failures after 30 seconds, so a transient
-registry, Vault, or Compose failure converges without an operator restarting
-cloud-init. Application initialization is serialized by the normal lifecycle
-lock and recorded for the current boot before the app starts; a bootstrap retry
-reuses that successful initialization instead of repeating it. Durable
+and `cloud-compose.service` retry transient failures after 30 seconds, with a
+three-attempt systemd start limit. A terminal unit failure stops the caller's
+wait immediately; a five-minute heartbeat records the unit state during a long
+but healthy convergence. An operator can inspect the failure and start the
+unit again to open a new bounded retry window. Application initialization is
+serialized by the normal lifecycle lock and recorded for the current boot
+before the app starts; a bootstrap retry reuses that successful initialization
+instead of repeating it. Durable
readiness is published only after the Compose `up` unit reaches its successful
oneshot state. The cloud-init caller waits for that result, so configured
post-initialization commands retain their ordering. Inspect the bounded system
@@ -156,6 +169,62 @@ with `systemctl status cloud-compose-bootstrap cloud-compose`. Bootstrap output
uses a fixed `info` priority and has no unit-specific Fluent Bit input, so raw
bootstrap output is not forwarded to Cloud Logging; systemd's own service
failures remain available to the existing warning-level collector.
+Root systemd jobs enter through `/etc/cloud-compose/libexec`, validate the
+root-owned home scripts and control inputs, and only then execute their
+allowlisted `/home/cloud-compose` program. The privileged entrypoints,
+diagnostics command, and checked-in `jq` and AWK programs live below
+`/etc/cloud-compose/{libexec,bin,jq,awk}` because COS permits cloud-init to rebuild
+that stateless tree while its `/usr` filesystem is immutable. Application
+services retain their unprivileged execution model. Compose diagnostics run as
+the application account and select the verified plugin copy below the
+executable data disk explicitly; they do not fall back to the compatibility
+copy below COS's `noexec` `/home` mount.
+
+An installed script resolved below `/home/cloud-compose` always uses the fixed
+`/etc/cloud-compose/jq` and `/etc/cloud-compose/awk` programs. Environment
+overrides for those programs, the shared profile, key-rotation entrypoint, or
+any other sourced runtime helper are rejected in installed mode. Repository
+and CI fixtures may select checked programs only while the owning script resolves
+outside the installed home. Before an installed program is consumed, its
+canonical root-owned parent chain must contain no symlink or group/world-writable
+directory. The only accepted logical alias is the operating-system-owned
+`/home -> /var/home` link used by Fedora CoreOS. The program itself must be a
+root-owned, single-link, non-writable regular file. Configuration-management
+adapters make `/home/cloud-compose` root-owned immediately after copying rootfs,
+before any root execution; mutable application state remains in its explicitly
+account-owned child/data paths. A checked directory binding validates every
+direct `jq` or AWK program in that installed directory, so adding a new filter
+cannot bypass per-file ownership, link, and mode checks. Production rootfs shell
+scripts invoke `jq` and AWK only with checked `-f` program files; the static CI
+contract rejects embedded filters so review and integrity checks cannot be
+bypassed by a later one-line program.
+
+Terraform and cloud-init are transport and orchestration boundaries, not the
+home of bootstrap implementations. The GCP and provider-neutral templates
+transfer checked-in programs from `rootfs/etc/cloud-compose/libexec` and invoke
+them by path with data-only arguments. GCP packages its checked filesystem
+program in a `text/cloud-boothook` MIME part so durable mounts are validated at
+the original early, every-boot phase before cloud-config writes files or starts
+services. Archive-mode Linux user data separately transports the small current
+bootstrap driver and verifies its Terraform-rendered SHA-256 before execution;
+filesystem helpers and the rest of the runtime still come from the verified
+rootfs archive. This preserves metadata headroom without requiring an older
+rootfs archive to contain the new driver. Files destined for `/mnt/disks` are
+staged until the checked-in filesystem program has mounted and validated the
+durable disks. GCP `initcmd` and `runcmd` values are likewise written as
+root-controlled program files before they are sourced at their documented
+points in the bootstrap sequence. Keep substantive shell, Python, `jq`, and
+similar programs in reviewed files; do not interpolate them into Terraform
+heredocs, cloud-init command strings, Compose commands, or configuration-
+management task bodies. The early filesystem path stages its checked-in fstab
+AWK program from the same Terraform source or already verified rootfs archive,
+requires an unlinked root-owned mode-0600 file, and invokes it as data with
+`awk -f` before the full stateless rootfs is available.
+
+GCP compresses checked bootstrap programs before carrying them in user-data and
+enforces a 240 KiB plan-time budget. This leaves explicit headroom below the
+provider's 256 KiB metadata-item limit; larger custom inputs must use the
+verified rootfs archive path or be reduced before a VM can be replaced.
## Sitectl
@@ -176,6 +245,12 @@ package list before validation. Terraform, Ansible, and Salt serialize their
resolved map as `SITECTL_PACKAGE_VERSIONS` JSON and the privileged installer
validates it again before downloading anything. Per-project package versions
are not supported because projects on one host share the same binaries.
+`/home/cloud-compose/bin` is reserved for generated managed-tool symlinks. They
+target the root-owned managed binary directory and normally comprise `sitectl`,
+its plugins, and the verified static GNU Make build required by Container-
+Optimized OS. During an upgrade from the former application-owned directory,
+the installer closes the directory to root and rejects any other inherited
+command or target rather than carrying an untrusted PATH entry forward.
`sitectl_verify_args` remains a real argument list. The host stores it as JSON
and appends each value through an argv-aware wrapper when a lifecycle command
@@ -183,6 +258,23 @@ invokes `sitectl verify`; spaces in one value never become additional arguments.
Newlines, carriage returns, and NUL bytes are rejected instead of being flattened
into an ambiguous shell scalar.
+Each configured lifecycle list value names an independent checked program; it
+is not parsed as shell source. The built-in `init`, `up`, `down`, and `rollout`
+defaults each contain exactly `/home/cloud-compose/default-lifecycle.sh ACTION`,
+where `ACTION` matches the lifecycle field. A custom entry must be one
+argument-free, root-controlled program immediately below
+`/etc/cloud-compose/lifecycle.d`; pass data through the documented lifecycle
+environment. `true` and `false` remain explicit no-op and failure sentinels.
+Multi-step work, state such as local variables or traps, and any quoting belong
+inside that reviewed program file. The constrained executor invokes its argv
+without evaluating a manifest value as shell source. Custom programs retain
+direct execution. After the same root-owner, link-count, parent-directory, and
+mode checks, the built-in script is opened as
+`/bin/bash -- /home/cloud-compose/default-lifecycle.sh ACTION`; this fixed
+interpreter path keeps the built-in lifecycle usable on Container-Optimized OS,
+where `/home` is deliberately mounted `noexec`, without turning the manifest
+entry into a shell command string.
+
## Vault
`cloud-compose` defines the Vault contract and leaves product-specific Vault
@@ -328,7 +420,9 @@ release versions and the Alpine tag/digest pair; review those changes as
supply-chain updates rather than accepting an unpinned replacement. The image
and its network-fetched package build scripts execute only after the metadata
firewall is installed and use the bridge network, so they cannot inherit the
-host network's root exemption.
+host network's root exemption. The build itself is a checked-in shell program
+mounted read-only into the container and invoked by path, keeping the audited
+program out of cloud-init and Docker command arguments.
The GCP COS VM image name is a reviewed manual pin. Renovate has no built-in
GCP Compute image-family datasource, and the shared LibOps preset does not add
@@ -652,18 +746,41 @@ All Terraform entrypoints, including GCP, DigitalOcean, and Linode, support the
same verified rootfs archive contract. When `runtime.rootfs_archive_url` is
used, it must be an HTTPS URL without whitespace and
`runtime.rootfs_archive_sha256` is mandatory with a 64-character SHA-256
-digest. Boot restricts curl and redirects to HTTPS with TLS 1.2 or newer,
-downloads to a temporary path, and verifies the complete file before extracting
-its `rootfs` directory. The GCP path stages
-the caller's packaged rootfs overlay and reapplies it after the archive, so
-consumer overrides still win. Use an immutable archive URL; a moving branch and
-a pinned checksum intentionally fail as soon as the branch content changes.
+digest. Terraform derives the adjacent
+`cloud-compose-rootfs.contract.sha256` URL and reads it during planning. That
+sidecar must contain exactly the canonical contract digest for the module's
+checked-in `rootfs`: every directory, file path, file byte, root ownership,
+mode, and single-link file topology. A missing, malformed, older, or otherwise
+mismatched sidecar rejects the plan before Terraform can replace a VM.
+
+Boot restricts curl and redirects to HTTPS with TLS 1.2 or newer, downloads to
+a root-only temporary path, verifies the complete archive checksum, rejects
+links and unsupported filesystem objects, and re-verifies the same canonical
+rootfs contract before copying anything onto the host. Packaged directories are
+root-owned mode `0755`; checked `*.sh` programs are mode `0755`; other files are
+mode `0644`; regular files must have one link. The GCP path stages the caller's
+packaged rootfs overlay and reapplies it after the archive, so consumer
+overrides still win. A single checked-in archive program implements download,
+verification, and installation for every Terraform provider rather than
+duplicating provider-specific shell bodies. Use the immutable assets from the
+same release as the Terraform module. Keeping an older archive while advancing
+the module fails safely during planning and leaves the existing workload
+untouched.
+
+Each release publishes three assets:
+`cloud-compose-rootfs.tar.gz`, its archive-byte `.sha256`, and
+`cloud-compose-rootfs.contract.sha256`. The release workflow downloads the
+published assets again, validates the archive bytes and canonical tree, and
+does not complete its release gate until all three agree. Downstream catalogs
+or automation must promote a cloud-compose release only after the
+`Verify rootfs release assets` job is green.
## 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:
+`cloud-compose-mariadb-backup.timer` runs the local-backup and off-host handoff
+flow 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 unprivileged local phase executes:
```bash
sitectl mariadb backup --context "$SITECTL_CONTEXT_NAME" --gzip --output "$path"
@@ -684,13 +801,24 @@ service exits non-zero after attempting all of them. Dumps older than
`MARIADB_BACKUP_RETENTION_DAYS` (14 by default) are pruned from each validated
app directory so they cannot fill the shared data disk indefinitely.
-Local dumps remain on the same failure-domain disk as application data.
-Downstream operators must still define reviewed encrypted off-host retention
-and restore tests. GCP production enables crash-consistent scheduled disk
-snapshots by default; `guest_flush = false` is deliberate because the logical
-dump supplies the application-consistent recovery artifact. DigitalOcean and
-Linode boot-disk backup toggles do not include attached volumes; see the
-provider guide before claiming disaster-recovery coverage.
+Local dumps remain on the same failure-domain disk as application data and are
+never disaster recovery. Set `runtime.disaster_recovery.required = true` only
+after installing the operator-owned root driver. The root handoff runs after
+the local service, including when the daily dump already exists, and requires a
+strict atomic receipt proving encrypted off-host coverage of each app's logical
+database, checkout/bind files, named volumes, and resolved service-mount
+topology. A weekly timer requires a challenge-bound proof that the driver
+restored all three coverage classes into a disposable recovery environment,
+verified integrity, and destroyed that environment. Storage-vendor settings
+and credentials stay behind the driver and never enter Terraform, cloud-init,
+the host environment, or logs. The complete interface and receipt schemas are
+in [Disaster recovery](disaster-recovery.md).
+
+GCP production enables crash-consistent scheduled disk snapshots by default;
+`guest_flush = false` is deliberate because the logical dump supplies the
+application-consistent database artifact. DigitalOcean and Linode boot-disk
+backup toggles do not include attached volumes. None of those provider-local
+copies replace the independent driver receipt and restore proof.
Terraform owns the attached data and Docker-volume disks. A normal
`terraform destroy` deletes them; GCP production snapshots are retained, but
@@ -839,10 +967,10 @@ deploy the same
`5058610fddc7267ace92d65a5c49713dce570ac3`; an early exact checkout bridges
the legacy runtime's branch-only clone behavior without following a moving
branch. Its `gcp.cloud_init.initcmd` disables both generations of the
-internal-service timer after cloud-init writes the units but before
-`/home/cloud-compose/run.sh` starts the potentially long bootstrap, so the
-disposable VM cannot suspend itself. The runner checks the units again after
-each boot.
+internal-service timer after cloud-init writes the units but before the
+root-owned `/etc/cloud-compose/libexec/run-bootstrap.sh` entrypoint
+starts the potentially long bootstrap, so the disposable VM cannot suspend
+itself. The runner checks the units again after each boot.
Only the ephemeral runner key is authorized, and SSH is limited to that
runner's public IPv4 `/32`.
@@ -902,11 +1030,19 @@ convention:
| --- | --- | --- |
| DigitalOcean create/test/destroy | `cloud-smoke-digitalocean` | `DIGITALOCEAN_TOKEN` |
| Linode create/test/destroy | `cloud-smoke-linode` | `LINODE_TOKEN` |
-| GCP create/test/destroy | `cloud-smoke-gcp` | `GCLOUD_OIDC_POOL`, `GSA`, `GCLOUD_PROJECT`, optional `GCLOUD_REGION` |
+| GCP create/test/destroy | `cloud-smoke-gcp` | `GCLOUD_OIDC_POOL`, `GSA`, `GCLOUD_PROJECT`, optional `GCLOUD_REGION` and `GCLOUD_ZONE`, and optional fresh-smoke overrides `GCLOUD_FRESH_REGION` and `GCLOUD_FRESH_ZONE` |
| GCP major-version upgrade | `cloud-smoke-gcp` | the GCP values above plus `GCLOUD_NETWORK_PROJECT_ID`, `GCLOUD_NETWORK_NAME`, `GCLOUD_SUBNETWORK_NAME`, `GCLOUD_POWER_START_ROLE`, and `GCLOUD_POWER_SUSPEND_ROLE` |
| DigitalOcean fallback deletion | `cloud-smoke-cleanup-digitalocean` | a distinct cleanup-only `DIGITALOCEAN_TOKEN` |
| Linode fallback deletion | `cloud-smoke-cleanup-linode` | a distinct cleanup-only `LINODE_TOKEN` |
-| GCP fallback deletion | `cloud-smoke-cleanup-gcp` | cleanup-specific `GCLOUD_OIDC_POOL`, `GSA`, `GCLOUD_PROJECT`, optional `GCLOUD_REGION` |
+| GCP fallback deletion | `cloud-smoke-cleanup-gcp` | cleanup-specific `GCLOUD_OIDC_POOL`, `GSA`, `GCLOUD_PROJECT`, optional `GCLOUD_REGION` and `GCLOUD_ZONE`, plus any `GCLOUD_FRESH_REGION` and `GCLOUD_FRESH_ZONE` overrides used by the smoke environment |
+
+When `GCLOUD_ZONE` is set, it must belong to `GCLOUD_REGION`. Those values keep
+the historical major-upgrade smoke aligned with its persistent subnet. A fresh
+smoke can use a different location by setting `GCLOUD_FRESH_REGION` and
+`GCLOUD_FRESH_ZONE`; the latter must belong to the former. Mirror both location
+pairs in `cloud-smoke-gcp` and `cloud-smoke-cleanup-gcp`. The fallback first
+sweeps the upgrade region, then also sweeps the fresh-smoke region when it is
+different, so region-scoped Cloud Run resources cannot be stranded.
Configure required reviewers and prevent self-review on the three
`cloud-smoke-*` environments. Permit only the same-repository feature branches
@@ -933,6 +1069,13 @@ trusted and provider-job checkouts set `persist-credentials: false`.
Pull-request smoke jobs also build one binary before apply and reuse that exact
workspace binary from their `always()` cleanup step. Shell remains responsible
for Terraform, SSH, cloud-init, diagnostics, and host-runtime black-box checks.
+The application smoke stages its checked-in lifecycle contract in a private,
+strictly validated directory on the executable data disk, so its fake `sitectl`
+program remains executable on COS hosts whose temporary filesystems are
+`noexec`. The smoke driver opens that checked-in contract through fixed
+`/bin/bash`; the contract rejects a missing, redirected, or unreadable lifecycle
+target without using an execute-access probe that fails on COS's `noexec`
+`/home`. The contract is removed immediately after the check.
No privileged fallback executes a pull-request binary or downloads one as an
artifact. The fallback runs
automatically after a failed, cancelled, or timed-out smoke workflow, including
diff --git a/examples/binpack/main.tf b/examples/binpack/main.tf
index 98f06b4..c709108 100644
--- a/examples/binpack/main.tf
+++ b/examples/binpack/main.tf
@@ -29,9 +29,9 @@ module "apps" {
}
sitectl = {
package_versions = {
- sitectl = "v1.0.0"
- sitectl-wp = "v1.0.0"
- sitectl-drupal = "v1.0.0"
+ sitectl = "v1.9.1"
+ sitectl-wp = "v2.1.0"
+ sitectl-drupal = "v1.5.0"
}
}
}
diff --git a/examples/digitalocean/variables.tf b/examples/digitalocean/variables.tf
index d9b71af..fc89289 100644
--- a/examples/digitalocean/variables.tf
+++ b/examples/digitalocean/variables.tf
@@ -30,8 +30,8 @@ variable "cloud_compose_ssh_keys" {
variable "cloud_compose_source_ref" {
type = string
- default = "1.0.0"
- description = "Exact cloud-compose release tag whose canonical rootfs asset is fetched by DigitalOcean cloud-init."
+ nullable = false
+ description = "Required exact cloud-compose release tag whose canonical rootfs asset is fetched by DigitalOcean cloud-init. It must match the release containing this example/module source."
validation {
condition = can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+$", trimspace(var.cloud_compose_source_ref)))
@@ -41,11 +41,12 @@ variable "cloud_compose_source_ref" {
variable "cloud_compose_source_sha256" {
type = string
+ nullable = false
description = "SHA-256 of the canonical rootfs asset published for cloud_compose_source_ref."
validation {
- condition = can(regex("^[0-9a-fA-F]{64}$", trimspace(var.cloud_compose_source_sha256)))
- error_message = "cloud_compose_source_sha256 must be a 64-character SHA-256 digest."
+ condition = can(regex("^[0-9a-f]{64}$", trimspace(var.cloud_compose_source_sha256)))
+ error_message = "cloud_compose_source_sha256 must be a lowercase 64-character SHA-256 digest."
}
}
diff --git a/examples/linode/variables.tf b/examples/linode/variables.tf
index d65d708..cb1c596 100644
--- a/examples/linode/variables.tf
+++ b/examples/linode/variables.tf
@@ -43,8 +43,8 @@ variable "cloud_compose_ssh_keys" {
variable "cloud_compose_source_ref" {
type = string
- default = "1.0.0"
- description = "Exact cloud-compose release tag whose canonical rootfs asset is fetched by Linode cloud-init."
+ nullable = false
+ description = "Required exact cloud-compose release tag whose canonical rootfs asset is fetched by Linode cloud-init. It must match the release containing this example/module source."
validation {
condition = can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+$", trimspace(var.cloud_compose_source_ref)))
@@ -54,11 +54,12 @@ variable "cloud_compose_source_ref" {
variable "cloud_compose_source_sha256" {
type = string
+ nullable = false
description = "SHA-256 of the canonical rootfs asset published for cloud_compose_source_ref."
validation {
- condition = can(regex("^[0-9a-fA-F]{64}$", trimspace(var.cloud_compose_source_sha256)))
- error_message = "cloud_compose_source_sha256 must be a 64-character SHA-256 digest."
+ condition = can(regex("^[0-9a-f]{64}$", trimspace(var.cloud_compose_source_sha256)))
+ error_message = "cloud_compose_source_sha256 must be a lowercase 64-character SHA-256 digest."
}
}
diff --git a/internal/contracttest/cloud_smoke_cleanup_test.go b/internal/contracttest/cloud_smoke_cleanup_test.go
index 2c2ec3b..6577b74 100644
--- a/internal/contracttest/cloud_smoke_cleanup_test.go
+++ b/internal/contracttest/cloud_smoke_cleanup_test.go
@@ -217,10 +217,12 @@ func TestHostedProviderTokensAreScopedToLifecycleSteps(t *testing.T) {
}
for label, marker := range map[string]string{
"config-management smoke token": ` - name: Run Linode config-management smoke test
+ timeout-minutes: 100
env:
LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }}`,
"config-management destroy token": ` - name: Destroy Linode config-management smoke resources
if: always()
+ timeout-minutes: 20
env:
LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }}`,
} {
@@ -347,6 +349,7 @@ printf '%s\n' "$@" >"$FAKE_CLEANUP_LOG"
func TestNonGCPSmokeRunExitCleanupLifecycle(t *testing.T) {
t.Parallel()
root := repositoryRoot(t)
+ checkoutSHA := repositoryCommitSHA(t, root)
tests := []struct {
name string
driver string
@@ -413,11 +416,13 @@ func TestNonGCPSmokeRunExitCleanupLifecycle(t *testing.T) {
command.Env = overriddenEnvironment(map[string]string{
"CLOUD_COMPOSE_CI_BIN": filepath.Join(binDirectory, "cloud-compose-ci"),
"CLOUD_COMPOSE_SMOKE_AUTO_APPROVE": "true",
+ "CLOUD_COMPOSE_SMOKE_BOOT_TIMEOUT": "5",
"CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT": "10",
"CLOUD_COMPOSE_SMOKE_KEEP": "false",
"CLOUD_COMPOSE_SMOKE_RUN_ID": "123456789",
"CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS": "false",
"CLOUD_COMPOSE_SMOKE_WORKDIR": filepath.Join(stateDirectory, "smoke"),
+ "CLOUD_COMPOSE_SOURCE_REF": checkoutSHA,
"CLOUD_COMPOSE_SOURCE_SHA256": strings.Repeat("0", 64),
"DIGITALOCEAN_TOKEN": "do-lifecycle-secret",
"FAKE_APPLY_SIGNAL": test.applySignal,
@@ -466,6 +471,13 @@ func TestNonGCPSmokeRunExitCleanupLifecycle(t *testing.T) {
func writeCloudSmokeLifecycleFakes(t testing.TB, directory string) {
t.Helper()
+ sshFixture, err := os.ReadFile(filepath.Join(
+ repositoryRoot(t),
+ "internal/contracttest/testdata/cloud-smoke-lifecycle/ssh.sh",
+ ))
+ if err != nil {
+ t.Fatalf("read lifecycle SSH fixture: %v", err)
+ }
executables := map[string]string{
"cloud-compose-ci": `#!/usr/bin/env bash
set -euo pipefail
@@ -534,13 +546,7 @@ printf 'ssh-ed25519 fake-public-key cloud-compose-smoke\n' >"${path}.pub"
set -euo pipefail
printf '127.0.0.1 ssh-ed25519 fake-host-key\n'
`,
- "ssh": `#!/usr/bin/env bash
-set -euo pipefail
-case "$*" in
- *cloud-compose-bootstrap-complete*) printf 'complete\n' ;;
- *cloud-init\ status*) printf 'cloud-init not installed\n' ;;
-esac
-`,
+ "ssh": string(sshFixture),
"sitectl": `#!/usr/bin/env bash
set -euo pipefail
exit 0
@@ -578,6 +584,19 @@ func processExitCode(t testing.TB, err error) int {
return exitError.ExitCode()
}
+func repositoryCommitSHA(t testing.TB, root string) string {
+ t.Helper()
+ output, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
+ if err != nil {
+ t.Fatalf("resolve repository commit: %v", err)
+ }
+ commit := strings.TrimSpace(string(output))
+ if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(commit) {
+ t.Fatalf("repository commit is not an exact lowercase SHA: %q", commit)
+ }
+ return commit
+}
+
func readTestLog(t testing.TB, path string) string {
t.Helper()
contents, err := os.ReadFile(path)
diff --git a/internal/contracttest/disaster_recovery_test.go b/internal/contracttest/disaster_recovery_test.go
new file mode 100644
index 0000000..61d6931
--- /dev/null
+++ b/internal/contracttest/disaster_recovery_test.go
@@ -0,0 +1,105 @@
+package contracttest
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDisasterRecoveryInputParity(t *testing.T) {
+ root := repositoryRoot(t)
+
+ for _, relativePath := range []string{
+ "variables.tf",
+ "providers/gcp/variables.tf",
+ "providers/do/variables.tf",
+ "providers/linode/variables.tf",
+ "modules/digitalocean/variables.tf",
+ "modules/linode/variables.tf",
+ } {
+ content := readRepositoryFile(t, root, relativePath)
+ requireContains(t, content, "disaster_recovery = optional(object({", relativePath+" disaster-recovery object")
+ requireContains(t, content, "required = optional(bool, false)", relativePath+" required switch")
+ requireContains(t, content, `driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")`, relativePath+" driver default")
+ requireContains(t, content, "runtime.disaster_recovery.driver_path must be a safe absolute path", relativePath+" path validation")
+ }
+
+ for _, relativePath := range []string{
+ "main.tf",
+ "providers/gcp/main.tf",
+ "modules/digitalocean/main.tf",
+ "modules/linode/main.tf",
+ } {
+ content := readRepositoryFile(t, root, relativePath)
+ requireContains(t, content, "offhost_backup_required", relativePath+" required forwarding")
+ requireContains(t, content, "offhost_backup_driver_path", relativePath+" driver forwarding")
+ }
+
+ for _, relativePath := range []string{
+ "modules/gcp/main.tf",
+ "modules/linux-vm-runtime/main.tf",
+ "ansible/roles/cloud_compose/tasks/main.yml",
+ "salt/cloud-compose/init.sls",
+ } {
+ content := readRepositoryFile(t, root, relativePath)
+ requireContains(t, content, "CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED", relativePath+" required host control")
+ requireContains(t, content, "CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER", relativePath+" driver host control")
+ }
+}
+
+func TestDisasterRecoveryReceiptAndRestoreContracts(t *testing.T) {
+ root := repositoryRoot(t)
+ library := readRepositoryFile(t, root, "rootfs/home/cloud-compose/disaster-recovery-lib.sh")
+ backup := readRepositoryFile(t, root, "rootfs/home/cloud-compose/offhost-backup.sh")
+ restore := readRepositoryFile(t, root, "rootfs/home/cloud-compose/restore-test.sh")
+ validationContract := strings.Join([]string{
+ library,
+ readRepositoryFile(t, root, "rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq"),
+ readRepositoryFile(t, root, "rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq"),
+ }, "\n")
+ backupContract := strings.Join([]string{
+ backup,
+ readRepositoryFile(t, root, "rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq"),
+ }, "\n")
+
+ for marker, label := range map[string]string{
+ `env -i HOME=/root`: "clean driver environment",
+ `>/dev/null 2>&1`: "suppressed driver output",
+ `cloud-compose.offhost-backup-receipt`: "backup receipt kind",
+ `.encrypted == true`: "encrypted coverage",
+ `.off_host == true`: "off-host coverage",
+ `.database == true`: "database coverage",
+ `.application_files == true`: "application-file coverage",
+ `.volume_topology == true`: "volume-topology coverage",
+ `cloud-compose.restore-test-proof`: "restore proof kind",
+ `.disposable_recovery == true`: "disposable recovery proof",
+ `.recovery_destroyed == true`: "recovery cleanup proof",
+ `.source_receipt_sha256 == $receipt_sha256`: "source receipt binding",
+ } {
+ requireContains(t, validationContract, marker, label)
+ }
+
+ for marker, label := range map[string]string{
+ `docker compose config --format json`: "resolved Compose topology",
+ `local_recovery_artifact`: "logical database artifact",
+ `application_files`: "application files",
+ `volume_topology`: "volume topology",
+ `cloud_compose_dr_run_driver`: "provider-neutral backup handoff",
+ } {
+ requireContains(t, backupContract, marker, label)
+ }
+
+ for marker, label := range map[string]string{
+ `/dev/urandom`: "one-time restore challenge",
+ `--backup-receipt`: "remote receipt input",
+ `cloud_compose_dr_validate_restore_proof`: "strict restore proof validation",
+ `mv -- "$staged_proof" "$proof_path"`: "atomic restore proof publication",
+ } {
+ requireContains(t, restore, marker, label)
+ }
+
+ for _, forbidden := range []string{"AWS_ACCESS_KEY", "GOOGLE_APPLICATION_CREDENTIALS", "AZURE_STORAGE_KEY"} {
+ if strings.Contains(library+backup+restore, forbidden) {
+ t.Errorf("disaster-recovery runtime hard-codes credential channel %q", forbidden)
+ }
+ }
+}
diff --git a/internal/contracttest/provider_boundary_test.go b/internal/contracttest/provider_boundary_test.go
index 04d82e8..3a83457 100644
--- a/internal/contracttest/provider_boundary_test.go
+++ b/internal/contracttest/provider_boundary_test.go
@@ -73,6 +73,7 @@ func TestRootEntrypointLoadsOnlyGCPProviders(t *testing.T) {
"digitalocean/digitalocean",
"hashicorp/cloudinit",
"hashicorp/google",
+ "hashicorp/http",
"hashicorp/time",
"linode/linode",
"Unexpected Terraform provider graph",
diff --git a/internal/contracttest/rollout_parity_test.go b/internal/contracttest/rollout_parity_test.go
index a774967..c521e7b 100644
--- a/internal/contracttest/rollout_parity_test.go
+++ b/internal/contracttest/rollout_parity_test.go
@@ -11,10 +11,7 @@ import (
)
var expectedRollout = []string{
- `TARGET_REF="${GIT_REF:-${GIT_BRANCH:-}}"`,
- `if [ -n "$TARGET_REF" ]; then sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --ref "$TARGET_REF"; else sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --skip-git; fi`,
- `sitectl healthcheck --context "${SITECTL_CONTEXT_NAME}" --persist`,
- `if [ "${SITECTL_ENVIRONMENT}" != "production" ]; then sitectl verify --context "${SITECTL_CONTEXT_NAME}" ${SITECTL_VERIFY_ARGS:-}; fi`,
+ `/home/cloud-compose/default-lifecycle.sh rollout`,
}
type rolloutSource struct {
diff --git a/internal/contracttest/template_version_test.go b/internal/contracttest/template_version_test.go
index ba1b401..4b7245c 100644
--- a/internal/contracttest/template_version_test.go
+++ b/internal/contracttest/template_version_test.go
@@ -30,38 +30,47 @@ func TestTemplateVersionContract(t *testing.T) {
expectedVersions := map[string]map[string]string{
"default": {
- "sitectl": "v1.0.0",
+ "sitectl": "v1.9.1",
},
"archivesspace": {
- "sitectl": "v1.0.0",
- "sitectl-archivesspace": "v1.0.0",
+ "sitectl": "v1.9.1",
+ "sitectl-archivesspace": "v2.1.1",
},
"drupal": {
- "sitectl": "v1.0.0",
- "sitectl-drupal": "v1.0.0",
+ "sitectl": "v1.9.1",
+ "sitectl-drupal": "v1.5.0",
},
"isle": {
- "sitectl": "v1.0.0",
- "sitectl-drupal": "v1.0.0",
- "sitectl-isle": "v1.0.0",
+ "sitectl": "v1.9.1",
+ "sitectl-drupal": "v1.5.0",
+ "sitectl-isle": "v1.6.0",
},
"ojs": {
- "sitectl": "v1.0.0",
- "sitectl-ojs": "v1.0.0",
+ "sitectl": "v1.9.1",
+ "sitectl-ojs": "v1.4.0",
},
"omeka-classic": {
- "sitectl": "v1.0.0",
- "sitectl-omeka-classic": "v1.0.0",
+ "sitectl": "v1.9.1",
+ "sitectl-omeka-classic": "v1.4.0",
},
"omeka-s": {
- "sitectl": "v1.0.0",
- "sitectl-omeka-s": "v1.0.0",
+ "sitectl": "v1.9.1",
+ "sitectl-omeka-s": "v1.4.0",
},
"wp": {
- "sitectl": "v1.0.0",
- "sitectl-wp": "v1.0.0",
+ "sitectl": "v1.9.1",
+ "sitectl-wp": "v2.1.0",
},
}
+ expectedBranches := map[string]string{
+ "archivesspace": "v1.0.1",
+ "drupal": "v1.2.1",
+ "isle": "v1.3.1",
+ "ojs": "v1.2.1",
+ "omeka-classic": "v1.2.1",
+ "omeka-s": "v1.2.1",
+ "wp": "v1.1.1",
+ }
if !maps.Equal(registry.Default.PackageVersions, expectedVersions["default"]) {
t.Errorf("template %q package versions diverged:\nexpected %s\nactual %s", "default", prettyJSON(t, expectedVersions["default"]), prettyJSON(t, registry.Default.PackageVersions))
@@ -80,10 +89,7 @@ func TestTemplateVersionContract(t *testing.T) {
if !maps.Equal(definition.PackageVersions, expected) {
t.Errorf("template %q package versions diverged:\nexpected %s\nactual %s", name, prettyJSON(t, expected), prettyJSON(t, definition.PackageVersions))
}
- expectedBranch := "v1.0.0"
- if name == "isle" {
- expectedBranch = "v1.1.0"
- }
+ expectedBranch := expectedBranches[name]
if definition.Branch != expectedBranch {
t.Errorf("template %q branch = %q, want stable contract %s", name, definition.Branch, expectedBranch)
}
@@ -140,8 +146,8 @@ func TestTemplateVersionContract(t *testing.T) {
for _, packageName := range []string{"sitectl", "sitectl-wp", "sitectl-drupal"} {
requireContains(t, content, packageName, relativePath+" bin-pack package")
}
- requireContains(t, content, `sitectl = "v1.0.0"`, relativePath+" bin-pack core selector")
- requireContains(t, content, `sitectl-wp = "v1.0.0"`, relativePath+" bin-pack WordPress selector")
- requireContains(t, content, `sitectl-drupal = "v1.0.0"`, relativePath+" bin-pack Drupal selector")
+ requireContains(t, content, `sitectl = "v1.9.1"`, relativePath+" bin-pack core selector")
+ requireContains(t, content, `sitectl-wp = "v2.1.0"`, relativePath+" bin-pack WordPress selector")
+ requireContains(t, content, `sitectl-drupal = "v1.5.0"`, relativePath+" bin-pack Drupal selector")
}
}
diff --git a/internal/contracttest/testdata/cloud-smoke-lifecycle/ssh.sh b/internal/contracttest/testdata/cloud-smoke-lifecycle/ssh.sh
new file mode 100644
index 0000000..4462855
--- /dev/null
+++ b/internal/contracttest/testdata/cloud-smoke-lifecycle/ssh.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+case "$*" in
+ *mktemp\ -d\ /mnt/disks/data/cloud-compose-hosted-contract.XXXXXX*)
+ printf '/mnt/disks/data/cloud-compose-hosted-contract.fixture123\n'
+ ;;
+ *install\ -m\ 0700\ /dev/stdin*)
+ cat >/dev/null
+ ;;
+ *cloud-compose-diagnostics.sh\ state*) printf 'complete\n' ;;
+ *cloud-compose-bootstrap-complete*) printf 'complete\n' ;;
+ *cloud-init\ status*) printf 'cloud-init not installed\n' ;;
+esac
diff --git a/main.tf b/main.tf
index 49cfea3..d11f765 100644
--- a/main.tf
+++ b/main.tf
@@ -161,13 +161,15 @@ module "gcp" {
overlay_source_instance = local.gcp_overlay.source_instance
volume_names = local.gcp_overlay.volume_names
- users = local.runtime.users
- rootfs = local.runtime.rootfs
- rootfs_archive_url = local.runtime.rootfs_archive_url
- rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256
- extra_env = local.runtime.extra_env
- runcmd = local.gcp_cloud_init.runcmd
- initcmd = local.gcp_cloud_init.initcmd
+ users = local.runtime.users
+ rootfs = local.runtime.rootfs
+ rootfs_archive_url = local.runtime.rootfs_archive_url
+ rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256
+ offhost_backup_required = local.runtime.disaster_recovery.required
+ offhost_backup_driver_path = local.runtime.disaster_recovery.driver_path
+ extra_env = local.runtime.extra_env
+ 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
diff --git a/modules/digitalocean/main.tf b/modules/digitalocean/main.tf
index 8cd72b9..663bee2 100644
--- a/modules/digitalocean/main.tf
+++ b/modules/digitalocean/main.tf
@@ -13,33 +13,36 @@ locals {
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 = merge(local.runtime.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
- rollout_enabled = local.do.rollout.enabled
- rollout_release_url = local.do.rollout.release_url
- rollout_release_sha256 = local.do.rollout.release_sha256
- rollout_port = local.do.rollout.port
- rollout_jwks_uri = local.do.rollout.jwks_uri
- rollout_jwt_audience = local.do.rollout.jwt_audience
- rollout_custom_claims = local.do.rollout.custom_claims
+ 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 = merge(local.runtime.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
+ rootfs_test_source_archive_prefix = local.runtime.rootfs_test_source_archive_prefix
+ offhost_backup_required = local.runtime.disaster_recovery.required
+ offhost_backup_driver_path = local.runtime.disaster_recovery.driver_path
+ 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
+ rollout_enabled = local.do.rollout.enabled
+ rollout_release_url = local.do.rollout.release_url
+ rollout_release_sha256 = local.do.rollout.release_sha256
+ rollout_port = local.do.rollout.port
+ rollout_jwks_uri = local.do.rollout.jwks_uri
+ rollout_jwt_audience = local.do.rollout.jwt_audience
+ rollout_custom_claims = local.do.rollout.custom_claims
sitectl_packages = local.sitectl.packages
sitectl_version = local.sitectl.version
diff --git a/modules/digitalocean/runtime_inputs.tftest.hcl b/modules/digitalocean/runtime_inputs.tftest.hcl
index 19c9150..61ebac1 100644
--- a/modules/digitalocean/runtime_inputs.tftest.hcl
+++ b/modules/digitalocean/runtime_inputs.tftest.hcl
@@ -1,4 +1,12 @@
mock_provider "digitalocean" {}
+mock_provider "http" {
+ mock_data "http" {
+ defaults = {
+ response_body = "c33470299657aca69837d7ce2cee73659aa5fd9a3297dcaad4444b50b54cdde2\n"
+ status_code = 200
+ }
+ }
+}
run "merges_provider_neutral_and_provider_specific_ssh_users" {
command = plan
@@ -14,8 +22,9 @@ run "merges_provider_neutral_and_provider_specific_ssh_users" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
users = {
shared = ["ssh-ed25519 AAAARUNTIME"]
runtime-only = ["ssh-ed25519 AAAANEUTRAL"]
@@ -43,8 +52,9 @@ run "exposes_independent_sitectl_package_versions" {
variables {
name = "do-contract"
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/isle.git"
}
@@ -75,8 +85,9 @@ run "rejects_reserved_extra_environment" {
variables {
name = "do-contract"
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
diff --git a/modules/digitalocean/variables.tf b/modules/digitalocean/variables.tf
index 9f65b80..2def7c6 100644
--- a/modules/digitalocean/variables.tf
+++ b/modules/digitalocean/variables.tf
@@ -74,7 +74,14 @@ variable "runtime" {
rootfs = optional(string, "")
rootfs_archive_url = optional(string, "")
rootfs_archive_sha256 = optional(string, "")
- users = optional(map(list(string)), {})
+ # Internal hosted-smoke escape hatch. Public provider entrypoints must not expose it.
+ rootfs_test_source_archive_prefix = optional(string, "")
+ users = optional(map(list(string)), {})
+
+ disaster_recovery = optional(object({
+ required = optional(bool, false)
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
+ }), {})
compose = optional(object({
primary = optional(string, "")
@@ -184,6 +191,15 @@ variable "runtime" {
error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together."
}
+ validation {
+ condition = (
+ can(regex("^/[A-Za-z0-9._/+:-]+$", var.runtime.disaster_recovery.driver_path)) &&
+ !strcontains(var.runtime.disaster_recovery.driver_path, "//") &&
+ length(regexall("(^|/)\\.\\.?(/|$)", var.runtime.disaster_recovery.driver_path)) == 0
+ )
+ error_message = "runtime.disaster_recovery.driver_path must be a safe absolute path without whitespace or dot segments."
+ }
+
validation {
condition = alltrue([
for name in keys(var.runtime.extra_env) :
diff --git a/modules/gcp/main.tf b/modules/gcp/main.tf
index db483be..64efa0e 100644
--- a/modules/gcp/main.tf
+++ b/modules/gcp/main.tf
@@ -10,6 +10,10 @@ terraform {
source = "hashicorp/google"
version = "~> 7.0"
}
+ http = {
+ source = "hashicorp/http"
+ version = "~> 3.6"
+ }
time = {
source = "hashicorp/time"
version = "~> 0.14"
@@ -22,6 +26,8 @@ locals {
additional_rootfs = var.rootfs != "" ? var.rootfs : ""
rootfs_archive_url = trimspace(var.rootfs_archive_url)
rootfs_archive_sha256 = lower(trimspace(var.rootfs_archive_sha256))
+ rootfs_archive_asset_url = split("#", split("?", local.rootfs_archive_url)[0])[0]
+ rootfs_contract_sidecar_url = local.rootfs_archive_url == "" ? "" : replace(local.rootfs_archive_asset_url, "/[^/]+$/", "cloud-compose-rootfs.contract.sha256")
rootfs_overlay_staging_path = "/var/lib/cloud-compose/rootfs-overlay"
project_number = tostring(data.google_project.service.number)
@@ -210,8 +216,21 @@ locals {
selected_subnetwork_project_matches = local.network_project_id == local.subnetwork_project_id
selected_subnetwork_region_matches = local.subnetwork_region == var.region
- # Archive mode fetches the packaged base rootfs at boot. A consumer-provided
- # rootfs is staged separately and applied after extraction so it still wins.
+ # Archive mode fetches the packaged base rootfs at boot. A content contract
+ # binds that archive to this exact module source before anything is copied.
+ rootfs_contract_files = sort(tolist(fileset(local.rootFs, "**")))
+ rootfs_contract_directories = sort(distinct(flatten([
+ for file in local.rootfs_contract_files : dirname(file) == "." ? [] : [
+ for index in range(length(split("/", dirname(file)))) :
+ join("/", slice(split("/", dirname(file)), 0, index + 1))
+ ]
+ ])))
+ rootfs_contract_sha256 = sha256(join("", [
+ for entry in concat(
+ [for directory in local.rootfs_contract_directories : "d\t0:0:755\t${directory}\n"],
+ [for file in local.rootfs_contract_files : "f\t${filesha256("${local.rootFs}/${file}")}\t0:0:${endswith(file, ".sh") ? "755" : "644"}\t${file}\n"],
+ ) : entry
+ ]))
base_files = local.rootfs_archive_url == "" ? fileset(local.rootFs, "**") : []
# Get files from additional rootfs if path is provided
@@ -227,16 +246,21 @@ locals {
},
{
for file in local.additional_files : file => {
- destination = local.rootfs_archive_url == "" ? "/${file}" : "${local.rootfs_overlay_staging_path}/${file}"
+ destination = local.rootfs_archive_url != "" ? "${local.rootfs_overlay_staging_path}/${file}" : "/${file}"
source = "${local.additional_rootfs}/${file}"
}
}
)
+ rootfs_file_permissions = {
+ for file in setunion(local.base_files, local.additional_files) :
+ file => endswith(file, ".sh") || "/${file}" == var.offhost_backup_driver_path ? "0755" : "0644"
+ }
write_files_content = join("\n", [
for file, config in local.all_files : <<-EOT
- path: ${jsonencode(config.destination)}
- permissions: ${jsonencode(endswith(file, ".sh") ? "0755" : "0644")}
+ owner: "root:root"
+ permissions: ${jsonencode(local.rootfs_file_permissions[file])}
encoding: gzip+base64
content: ${jsonencode(base64gzip(file(config.source)))}
EOT
@@ -244,45 +268,41 @@ EOT
docker_compose_scripts = join("\n", [
for name in ["init", "up", "down", "rollout"] : <<-EOT
- path: "/home/cloud-compose/${name}"
+ owner: "root:root"
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
-))}
+ content: ${base64gzip(file("${local.rootFs}/home/cloud-compose/lifecycle-entrypoint.sh"))}
EOT
-])
-compose_projects_file = <<-EOT
+ ])
+ compose_projects_file = <<-EOT
- path: "/home/cloud-compose/compose-projects.json"
+ owner: "root:root"
permissions: "0640"
encoding: gzip+base64
content: ${jsonencode(base64gzip(jsonencode(local.validated_compose_projects)))}
EOT
-managed_runtime_artifact_lines = [
- for artifact in module.managed_artifacts.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
+ managed_runtime_artifact_lines = [
+ for artifact in module.managed_artifacts.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 = length(local.managed_runtime_artifact_lines) == 0 ? "\n" : "${join("\n", local.managed_runtime_artifact_lines)}\n"
+ managed_runtime_artifacts_file = <<-EOT
- path: "/home/cloud-compose/managed-runtime-artifacts.tsv"
+ owner: "root:root"
permissions: "0640"
encoding: gzip+base64
- content: ${jsonencode(base64gzip(join("\n", local.managed_runtime_artifact_lines)))}
+ content: ${jsonencode(base64gzip(local.managed_runtime_artifacts_content))}
EOT
-vault_agent_template_stanzas = join("\n", [
- for template in var.vault_agent_templates : <<-EOT
+ vault_agent_template_stanzas = join("\n", [
+ for template in var.vault_agent_templates : <<-EOT
template {
destination = ${jsonencode(template.destination)}
contents = <&2
- exit 1
- }
- archive_url="$(printf '%s' '${base64encode(local.rootfs_archive_url)}' | base64 -d)"
- archive_sha256=${jsonencode(local.rootfs_archive_sha256)}
- overlay_dir=${jsonencode(local.rootfs_overlay_staging_path)}
- case "$archive_url" in
- https://*) ;;
- *) echo "rootfs archive URL must use HTTPS" >&2; exit 1 ;;
- esac
- case "$archive_url" in
- *[[:space:]]*) echo "rootfs archive URL must not contain whitespace" >&2; exit 1 ;;
- esac
- tmp="$(mktemp -d)"
- trap 'rm -rf "$tmp"' EXIT
- curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
- --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 900 \
- --connect-timeout 10 --max-time 300 -o "$tmp/rootfs.tar.gz" -- "$archive_url"
- if ! command -v sha256sum >/dev/null 2>&1; then
- echo "sha256sum is required to verify $archive_url" >&2
- exit 1
- fi
- printf '%s %s\n' "$archive_sha256" "$tmp/rootfs.tar.gz" | sha256sum -c -
- tar -xzf "$tmp/rootfs.tar.gz" -C "$tmp"
- rootfs_dir="$(find "$tmp" -mindepth 1 -maxdepth 3 -type d -name rootfs -print -quit)"
- if [ -z "$rootfs_dir" ]; then
- echo "rootfs directory not found in $archive_url" >&2
- exit 1
- fi
- cp -a "$rootfs_dir"/. /
- if [ -d "$overlay_dir" ]; then
- cp -a "$overlay_dir"/. /
- rm -rf "$overlay_dir"
- fi
- EOT
-rootfs_archive_command = local.rootfs_archive_url != "" ? local.rootfs_archive_command_raw : ""
-use_overlay = length(var.volume_names) > 0
-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", {
- FILESYSTEM_PREP_SCRIPT_B64 = filebase64("${local.rootFs}/home/cloud-compose/prepare-filesystem.sh"),
- FILESYSTEM_PERSIST_SCRIPT_B64 = filebase64("${local.rootFs}/home/cloud-compose/persist-filesystems.sh"),
- FRESH_FILESYSTEM_IDENTITY = "v1:gcp-disk-id:${google_compute_disk.data.disk_id}",
- 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,
- APPLICATION_ENV_FILE_CONTENT = local.application_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,
- 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 != "" ? data.google_service_account.vm[0].email : google_service_account.cloud-compose[0].email
-vm_service_account_id = var.service_account_email != "" ? data.google_service_account.vm[0].name : google_service_account.cloud-compose[0].id
-vm_service_account_name = var.service_account_email != "" ? data.google_service_account.vm[0].name : google_service_account.cloud-compose[0].name
-
-app_service_account_email = var.app_service_account_email != "" ? data.google_service_account.app[0].email : google_service_account.app[0].email
-app_service_account_id = var.app_service_account_email != "" ? data.google_service_account.app[0].name : google_service_account.app[0].id
-app_service_account_name = var.app_service_account_email != "" ? data.google_service_account.app[0].name : google_service_account.app[0].name
-app_service_account_managed = var.app_service_account_email == ""
-
-app_credentials_enabled = var.app_credentials_enabled
-internal_services_enabled = var.libops_internal_services_enabled || var.power_management_enabled
-internal_services_compose_profiles = var.power_management_enabled ? "lightsout" : ""
-# Production snapshots are crash-consistent (`guest_flush = false`). MariaDB
-# logical dumps run before the snapshot window and provide application-level
-# consistency without coupling disk snapshots to a guest-agent implementation.
-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"
+ initcmd_content = length(var.initcmd) > 0 ? "${join("\n", var.initcmd)}\n" : ""
+ runcmd_content = length(var.runcmd) > 0 ? "${join("\n", var.runcmd)}\n" : ""
+ post_bootstrap_required = var.rollout_enabled || length(var.runcmd) > 0
+ use_overlay = length(var.volume_names) > 0
+ 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) : ""
+ gcp_filesystem_boothook = templatefile("${path.module}/../../templates/gcp-filesystem-boothook.sh.tftpl", {
+ FILESYSTEM_PREP_SCRIPT_B64 = base64gzip(file("${local.rootFs}/home/cloud-compose/prepare-filesystem.sh")),
+ FILESYSTEM_PERSIST_SCRIPT_B64 = base64gzip(file("${local.rootFs}/home/cloud-compose/persist-filesystems.sh")),
+ FSTAB_RECONCILE_AWK_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/awk/reconcile-fstab.awk")),
+ GCP_FILESYSTEM_BOOT_SCRIPT_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/libexec/gcp-filesystem-boot.sh")),
+ FRESH_FILESYSTEM_IDENTITY = "v1:gcp-disk-id:${google_compute_disk.data.disk_id}",
+ USE_OVERLAY = local.use_overlay,
+ })
+ cloud_config_yaml = templatefile("${path.module}/../../templates/cloud-init.yml", {
+ ROOTFS_ARCHIVE_SCRIPT_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/libexec/rootfs-archive.sh")),
+ GCP_CLOUD_INIT_FINALIZE_SCRIPT_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh")),
+ GCP_CLOUD_INIT_POST_SCRIPT_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh")),
+ DIAGNOSTICS_SCRIPT_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/bin/cloud-compose-diagnostics.sh")),
+ DIAGNOSTICS_SCRIPT_SHA256 = filesha256("${local.rootFs}/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"),
+ INIT_COMMANDS_B64 = base64encode(local.initcmd_content),
+ RUNCMD_B64 = base64encode(local.runcmd_content),
+ 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,
+ APPLICATION_ENV_FILE_CONTENT = local.application_env_file_content,
+ VAULT_AGENT_FILES = local.vault_agent_files,
+ MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file,
+ ROOTFS_ARCHIVE_ENABLED = local.rootfs_archive_url != "",
+ ROOTFS_ARCHIVE_URL_B64 = base64encode(local.rootfs_archive_url),
+ ROOTFS_ARCHIVE_SHA256 = local.rootfs_archive_sha256,
+ ROOTFS_CONTRACT_SHA256 = local.rootfs_contract_sha256,
+ ROOTFS_OVERLAY_DIR = local.rootfs_overlay_staging_path,
+ ROLLOUT_ENABLED = tostring(var.rollout_enabled),
+ POST_BOOTSTRAP_REQUIRED = local.post_bootstrap_required,
+ 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" },
+ })
+ cloud_init_yaml = templatefile("${path.module}/../../templates/gcp-cloud-init.mime.tftpl", {
+ FILESYSTEM_BOOTHOOK = local.gcp_filesystem_boothook,
+ CLOUD_CONFIG = local.cloud_config_yaml,
+ })
+
+ vm_service_account_email = var.service_account_email != "" ? data.google_service_account.vm[0].email : google_service_account.cloud-compose[0].email
+ vm_service_account_id = var.service_account_email != "" ? data.google_service_account.vm[0].name : google_service_account.cloud-compose[0].id
+ vm_service_account_name = var.service_account_email != "" ? data.google_service_account.vm[0].name : google_service_account.cloud-compose[0].name
+
+ app_service_account_email = var.app_service_account_email != "" ? data.google_service_account.app[0].email : google_service_account.app[0].email
+ app_service_account_id = var.app_service_account_email != "" ? data.google_service_account.app[0].name : google_service_account.app[0].id
+ app_service_account_name = var.app_service_account_email != "" ? data.google_service_account.app[0].name : google_service_account.app[0].name
+ app_service_account_managed = var.app_service_account_email == ""
+
+ app_credentials_enabled = var.app_credentials_enabled
+ internal_services_enabled = var.libops_internal_services_enabled || var.power_management_enabled
+ internal_services_compose_profiles = var.power_management_enabled ? "lightsout" : ""
+ # Production snapshots are crash-consistent (`guest_flush = false`). MariaDB
+ # logical dumps run before the snapshot window and provide application-level
+ # consistency without coupling disk snapshots to a guest-agent implementation.
+ 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"
}
data "google_project" "service" {
project_id = var.project_id
}
+data "http" "rootfs_contract" {
+ count = local.rootfs_archive_url != "" ? 1 : 0
+
+ url = local.rootfs_contract_sidecar_url
+ request_timeout_ms = 30000
+
+ lifecycle {
+ postcondition {
+ condition = (
+ self.status_code == 200 &&
+ can(regex("^[0-9a-f]{64}\\n?$", self.response_body)) &&
+ trimspace(self.response_body) == local.rootfs_contract_sha256
+ )
+ error_message = "The immutable rootfs release sidecar must contain exactly this module source's canonical rootfs contract digest. Publish or select a matching archive before replacing a VM."
+ }
+ }
+}
+
data "google_service_account" "vm" {
count = var.service_account_email != "" ? 1 : 0
@@ -855,6 +875,10 @@ resource "google_compute_instance" "cloud-compose" {
}
lifecycle {
+ precondition {
+ condition = length(data.cloudinit_config.ci.part[0].content) <= 245760
+ error_message = "GCP user-data must stay within a 240 KiB review budget so the 256 KiB metadata-item limit retains bootstrap headroom. Select a verified rootfs archive or reduce unusually large runtime inputs."
+ }
precondition {
condition = var.project_number == "" || var.project_number == local.project_number
error_message = "project_number does not match the number derived from project_id; omit the deprecated assertion or correct it."
@@ -914,6 +938,13 @@ resource "google_compute_instance" "cloud-compose" {
)
error_message = "rootfs_archive_url and a 64-character rootfs_archive_sha256 must be supplied together."
}
+ precondition {
+ condition = (
+ local.rootfs_archive_url == "" ||
+ trimspace(try(data.http.rootfs_contract[0].response_body, "")) == local.rootfs_contract_sha256
+ )
+ error_message = "The rootfs archive release contract must match this module before a VM is replaced."
+ }
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."
diff --git a/modules/gcp/runtime_contracts.tftest.hcl b/modules/gcp/runtime_contracts.tftest.hcl
index 38166be..e1a70a5 100644
--- a/modules/gcp/runtime_contracts.tftest.hcl
+++ b/modules/gcp/runtime_contracts.tftest.hcl
@@ -1,4 +1,12 @@
mock_provider "cloudinit" {}
+mock_provider "http" {
+ mock_data "http" {
+ defaults = {
+ response_body = "c33470299657aca69837d7ce2cee73659aa5fd9a3297dcaad4444b50b54cdde2\n"
+ status_code = 200
+ }
+ }
+}
mock_provider "google" {
mock_resource "google_compute_disk" {
override_during = plan
@@ -90,10 +98,28 @@ run "disables_privileged_services_by_default" {
local.host_env.CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY == "v1:gcp-disk-id:987654321012345678" &&
strcontains(
local.cloud_init_yaml,
- "--publish-fresh-marker \"v1:gcp-disk-id:987654321012345678\"",
- )
+ jsonencode("v1:gcp-disk-id:987654321012345678"),
+ ) &&
+ strcontains(
+ local.cloud_init_yaml,
+ base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh")),
+ ) &&
+ strcontains(
+ local.cloud_init_yaml,
+ base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/awk/reconcile-fstab.awk")),
+ ) &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/etc/cloud-compose/jq/compose-validate-projects.jq\"",
+ ) &&
+ strcontains(local.cloud_init_yaml, "Content-Type: text/cloud-boothook")
)
- error_message = "GCP cloud-init and the root runtime environment must carry the same immutable data-disk identity."
+ error_message = "GCP's early every-boot filesystem programs and root runtime environment must carry the same immutable data-disk identity and checked fstab reconciler."
+ }
+
+ assert {
+ condition = length(data.cloudinit_config.ci.part[0].content) <= 245760
+ error_message = "The default GCP user-data must preserve headroom below the provider's metadata-item limit."
}
assert {
@@ -535,13 +561,14 @@ run "renders_verified_archive_before_downstream_overlay" {
command = plan
variables {
- name = "gcp-contract"
- project_id = "test-project"
- project_number = "123456789"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs = "testdata/rootfs"
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz?literal=$(id)"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ name = "gcp-contract"
+ project_id = "test-project"
+ project_number = "123456789"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs = "testdata/rootfs"
+ rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz?literal=$(id)"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ offhost_backup_driver_path = "/etc/cloud-compose/libexec/custom-offhost-driver"
extra_env = {
NGINX_CLIENT_MAX_BODY_SIZE = "512m"
PHP_UPLOAD_MAX_FILESIZE = "512M"
@@ -550,21 +577,34 @@ run "renders_verified_archive_before_downstream_overlay" {
}
}
+ override_data {
+ target = data.http.rootfs_contract[0]
+ values = {
+ response_body = "c33470299657aca69837d7ce2cee73659aa5fd9a3297dcaad4444b50b54cdde2\n"
+ status_code = 200
+ }
+ }
+
assert {
condition = (
- strcontains(local.cloud_init_yaml, "sha256sum -c -") &&
+ strcontains(
+ local.cloud_init_yaml,
+ base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/libexec/rootfs-archive.sh")),
+ ) &&
+ strcontains(local.cloud_init_yaml, local.rootfs_contract_sha256) &&
+ data.http.rootfs_contract[0].url == "https://example.invalid/cloud-compose-rootfs.contract.sha256" &&
strcontains(local.cloud_init_yaml, base64encode(var.rootfs_archive_url)) &&
!strcontains(local.cloud_init_yaml, var.rootfs_archive_url)
)
- error_message = "The GCP archive URL must be transported as literal base64 data and verified before extraction."
+ error_message = "The GCP archive program must be transferred as a checked file and receive literal URL data plus the exact module rootfs contract."
}
assert {
- condition = can(regex(
- "(?s)cp -a \\\"\\$rootfs_dir\\\"/\\. /.*cp -a \\\"\\$overlay_dir\\\"/\\. /",
- local.cloud_init_yaml,
- ))
- error_message = "The consumer rootfs overlay must be applied after the verified base archive."
+ condition = (
+ strcontains(local.cloud_config_yaml, "runcmd:\n- [bash, /var/lib/cloud-compose/bootstrap/rootfs-archive.sh, install") &&
+ strcontains(local.cloud_config_yaml, "\n- [bash, /var/lib/cloud-compose/bootstrap/gcp-cloud-init-finalize.sh")
+ )
+ error_message = "The verified base archive and downstream overlay must be installed before GCP application initialization."
}
assert {
@@ -581,6 +621,73 @@ run "renders_verified_archive_before_downstream_overlay" {
)
error_message = "The downstream overlay and isolated application environment data must be present in cloud-init."
}
+
+ assert {
+ condition = (
+ local.rootfs_file_permissions["etc/cloud-compose/libexec/custom-offhost-driver"] == "0755" &&
+ local.rootfs_file_permissions["etc/cloud-compose-overlay-marker"] == "0644" &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose/libexec/custom-offhost-driver\"\n owner: \"root:root\"\n permissions: \"0755\"",
+ ) &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose-overlay-marker\"\n owner: \"root:root\"\n permissions: \"0644\"",
+ )
+ )
+ error_message = "Archive-backed GCP overlays must make only the configured off-host backup driver executable."
+ }
+}
+
+run "rejects_rootfs_release_from_another_module_version" {
+ command = plan
+
+ variables {
+ name = "gcp-contract"
+ project_id = "test-project"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ }
+
+ override_data {
+ target = data.http.rootfs_contract[0]
+ values = {
+ response_body = "0000000000000000000000000000000000000000000000000000000000000000\n"
+ status_code = 200
+ }
+ }
+
+ expect_failures = [data.http.rootfs_contract[0]]
+}
+
+run "renders_embedded_offhost_backup_driver_executable" {
+ command = plan
+
+ variables {
+ name = "gcp-embedded-driver"
+ project_id = "test-project"
+ project_number = "123456789"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs = "testdata/rootfs"
+ offhost_backup_driver_path = "/etc/cloud-compose/libexec/custom-offhost-driver"
+ }
+
+ assert {
+ condition = (
+ local.rootfs_file_permissions["etc/cloud-compose/libexec/custom-offhost-driver"] == "0755" &&
+ local.rootfs_file_permissions["etc/cloud-compose-overlay-marker"] == "0644" &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/etc/cloud-compose/libexec/custom-offhost-driver\"\n owner: \"root:root\"\n permissions: \"0755\"",
+ ) &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/etc/cloud-compose-overlay-marker\"\n owner: \"root:root\"\n permissions: \"0644\"",
+ )
+ )
+ error_message = "Embedded GCP overlays must make only the configured off-host backup driver executable."
+ }
}
run "namespaces_rollout_auth_away_from_application_environment" {
@@ -651,9 +758,18 @@ run "rejects_archive_without_checksum" {
project_id = "test-project"
project_number = "123456789"
docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs = "testdata/rootfs"
rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
}
+ override_data {
+ target = data.http.rootfs_contract[0]
+ values = {
+ response_body = "c33470299657aca69837d7ce2cee73659aa5fd9a3297dcaad4444b50b54cdde2\n"
+ status_code = 200
+ }
+ }
+
expect_failures = [google_compute_instance.cloud-compose]
}
diff --git a/modules/gcp/testdata/rootfs/etc/cloud-compose/libexec/custom-offhost-driver b/modules/gcp/testdata/rootfs/etc/cloud-compose/libexec/custom-offhost-driver
new file mode 100644
index 0000000..df09e99
--- /dev/null
+++ b/modules/gcp/testdata/rootfs/etc/cloud-compose/libexec/custom-offhost-driver
@@ -0,0 +1 @@
+fixture driver payload
diff --git a/modules/gcp/variables.tf b/modules/gcp/variables.tf
index 9e1b969..f9f33f6 100644
--- a/modules/gcp/variables.tf
+++ b/modules/gcp/variables.tf
@@ -262,7 +262,7 @@ variable "docker_compose_branch" {
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"
+ "/home/cloud-compose/default-lifecycle.sh init"
]
nullable = false
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"
@@ -271,9 +271,7 @@ variable "docker_compose_init" {
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",
- "if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
+ "/home/cloud-compose/default-lifecycle.sh up"
]
nullable = false
description = "Command to start the docker compose project"
@@ -282,7 +280,7 @@ variable "docker_compose_up" {
variable "docker_compose_down" {
type = list(string)
default = [
- "sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" down"
+ "/home/cloud-compose/default-lifecycle.sh down"
]
nullable = false
description = "Command to stop the docker compose project"
@@ -291,13 +289,10 @@ variable "docker_compose_down" {
variable "docker_compose_rollout" {
type = list(string)
default = [
- "TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-}}\"",
- "if [ -n \"$TARGET_REF\" ]; then sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --ref \"$TARGET_REF\"; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --skip-git; fi",
- "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist",
- "if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
+ "/home/cloud-compose/default-lifecycle.sh rollout"
]
nullable = false
- description = "Commands used by rollout triggers. GIT_REF/GIT_BRANCH selects a source ref; without one, sitectl reconciles the current checkout."
+ description = "Commands used by rollout triggers. A validated GIT_COMMIT_SHA takes precedence over GIT_REF/GIT_BRANCH; without one, sitectl reconciles the current checkout."
}
variable "sitectl_packages" {
@@ -591,7 +586,7 @@ variable "rootfs" {
variable "rootfs_archive_url" {
type = string
default = ""
- description = "Optional HTTPS tar.gz URL containing a rootfs directory to fetch during boot instead of embedding the packaged rootfs. Must be set with rootfs_archive_sha256."
+ description = "Optional immutable HTTPS cloud-compose rootfs release archive to fetch during boot instead of embedding the packaged rootfs. Must be set with rootfs_archive_sha256; planning also requires the adjacent cloud-compose-rootfs.contract.sha256 asset to match this module source."
validation {
condition = (
@@ -608,6 +603,27 @@ variable "rootfs_archive_sha256" {
description = "Required 64-character SHA-256 checksum when rootfs_archive_url is set."
}
+variable "offhost_backup_required" {
+ type = bool
+ default = false
+ description = "Require nightly encrypted off-host coverage and scheduled disposable restore proofs from an operator-owned driver. Same-disk MariaDB dumps are retained but are not disaster recovery."
+}
+
+variable "offhost_backup_driver_path" {
+ type = string
+ default = "/etc/cloud-compose/libexec/offhost-backup-driver"
+ description = "Absolute path to the operator-supplied, root-owned provider-neutral DR driver. The driver owns its credentials; do not pass them through Terraform."
+
+ validation {
+ condition = (
+ can(regex("^/[A-Za-z0-9._/+:-]+$", var.offhost_backup_driver_path)) &&
+ !strcontains(var.offhost_backup_driver_path, "//") &&
+ length(regexall("(^|/)\\.\\.?(/|$)", var.offhost_backup_driver_path)) == 0
+ )
+ error_message = "offhost_backup_driver_path must be a safe absolute path without whitespace or dot segments."
+ }
+}
+
variable "extra_env" {
type = map(string)
default = {}
@@ -645,7 +661,7 @@ variable "runcmd" {
variable "initcmd" {
type = list(string)
default = []
- description = "Commands to run before /home/cloud-compose/run.sh"
+ description = "Commands to run before the root-owned Cloud Compose bootstrap entrypoint"
}
variable "artifact_registry_repository" {
diff --git a/modules/linode/main.tf b/modules/linode/main.tf
index 979dd4e..6c219bd 100644
--- a/modules/linode/main.tf
+++ b/modules/linode/main.tf
@@ -16,33 +16,36 @@ locals {
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 = merge(local.runtime.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
- rollout_enabled = local.linode.rollout.enabled
- rollout_release_url = local.linode.rollout.release_url
- rollout_release_sha256 = local.linode.rollout.release_sha256
- rollout_port = local.linode.rollout.port
- rollout_jwks_uri = local.linode.rollout.jwks_uri
- rollout_jwt_audience = local.linode.rollout.jwt_audience
- rollout_custom_claims = local.linode.rollout.custom_claims
+ 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 = merge(local.runtime.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
+ rootfs_test_source_archive_prefix = local.runtime.rootfs_test_source_archive_prefix
+ offhost_backup_required = local.runtime.disaster_recovery.required
+ offhost_backup_driver_path = local.runtime.disaster_recovery.driver_path
+ 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
+ rollout_enabled = local.linode.rollout.enabled
+ rollout_release_url = local.linode.rollout.release_url
+ rollout_release_sha256 = local.linode.rollout.release_sha256
+ rollout_port = local.linode.rollout.port
+ rollout_jwks_uri = local.linode.rollout.jwks_uri
+ rollout_jwt_audience = local.linode.rollout.jwt_audience
+ rollout_custom_claims = local.linode.rollout.custom_claims
sitectl_packages = local.sitectl.packages
sitectl_version = local.sitectl.version
diff --git a/modules/linode/runtime_inputs.tftest.hcl b/modules/linode/runtime_inputs.tftest.hcl
index 1962318..6f0b6ef 100644
--- a/modules/linode/runtime_inputs.tftest.hcl
+++ b/modules/linode/runtime_inputs.tftest.hcl
@@ -1,4 +1,12 @@
mock_provider "linode" {}
+mock_provider "http" {
+ mock_data "http" {
+ defaults = {
+ response_body = "c33470299657aca69837d7ce2cee73659aa5fd9a3297dcaad4444b50b54cdde2\n"
+ status_code = 200
+ }
+ }
+}
run "merges_provider_neutral_and_provider_specific_ssh_users" {
command = plan
@@ -17,8 +25,9 @@ run "merges_provider_neutral_and_provider_specific_ssh_users" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
users = {
shared = ["ssh-ed25519 AAAARUNTIME"]
runtime-only = ["ssh-ed25519 AAAANEUTRAL"]
@@ -53,8 +62,9 @@ run "rejects_multiline_authorized_key" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
@@ -75,8 +85,9 @@ run "rejects_unsafe_authorized_username" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
@@ -106,8 +117,9 @@ run "rejects_public_rollout_listener" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
@@ -123,7 +135,8 @@ run "rejects_archive_without_checksum" {
variables {
name = "contract-test"
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
@@ -144,8 +157,9 @@ run "exposes_independent_sitectl_package_versions" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/isle.git"
}
@@ -181,8 +195,9 @@ run "rejects_reserved_extra_environment" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
@@ -214,8 +229,9 @@ run "archive_bootstrap_fits_linode_metadata_limit" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/releases/download/1.0.0/cloud-compose-rootfs.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
branch = "main"
diff --git a/modules/linode/variables.tf b/modules/linode/variables.tf
index 6f3dae8..71fed27 100644
--- a/modules/linode/variables.tf
+++ b/modules/linode/variables.tf
@@ -88,7 +88,14 @@ variable "runtime" {
rootfs = optional(string, "")
rootfs_archive_url = optional(string, "")
rootfs_archive_sha256 = optional(string, "")
- users = optional(map(list(string)), {})
+ # Internal hosted-smoke escape hatch. Public provider entrypoints must not expose it.
+ rootfs_test_source_archive_prefix = optional(string, "")
+ users = optional(map(list(string)), {})
+
+ disaster_recovery = optional(object({
+ required = optional(bool, false)
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
+ }), {})
compose = optional(object({
primary = optional(string, "")
@@ -198,6 +205,15 @@ variable "runtime" {
error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together."
}
+ validation {
+ condition = (
+ can(regex("^/[A-Za-z0-9._/+:-]+$", var.runtime.disaster_recovery.driver_path)) &&
+ !strcontains(var.runtime.disaster_recovery.driver_path, "//") &&
+ length(regexall("(^|/)\\.\\.?(/|$)", var.runtime.disaster_recovery.driver_path)) == 0
+ )
+ error_message = "runtime.disaster_recovery.driver_path must be a safe absolute path without whitespace or dot segments."
+ }
+
validation {
condition = alltrue([
for name in keys(var.runtime.extra_env) :
diff --git a/modules/linux-vm-runtime/disaster_recovery.tftest.hcl b/modules/linux-vm-runtime/disaster_recovery.tftest.hcl
new file mode 100644
index 0000000..495c3fc
--- /dev/null
+++ b/modules/linux-vm-runtime/disaster_recovery.tftest.hcl
@@ -0,0 +1,48 @@
+run "renders_provider_neutral_disaster_recovery_controls" {
+ command = plan
+
+ variables {
+ name = "contract-test"
+ provider_name = "linode"
+ region = "us-east"
+ data_device = "/dev/test-data"
+ volumes_device = "/dev/test-volumes"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ offhost_backup_required = true
+ offhost_backup_driver_path = "/etc/cloud-compose/libexec/acme-offhost"
+ }
+
+ assert {
+ condition = (
+ local.host_env.CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED == "true" &&
+ local.host_env.CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER == "/etc/cloud-compose/libexec/acme-offhost"
+ )
+ error_message = "The Linux VM runtime must render only the required switch and operator-owned driver path."
+ }
+
+ assert {
+ condition = (
+ !strcontains(module.runtime_env.content, "ACCESS_KEY") &&
+ !strcontains(module.runtime_env.content, "SECRET_KEY") &&
+ !strcontains(module.runtime_env.content, "BUCKET")
+ )
+ error_message = "The provider-neutral DR contract must not render storage credentials or a storage-vendor destination."
+ }
+}
+
+run "rejects_unsafe_disaster_recovery_driver_path" {
+ command = plan
+
+ variables {
+ name = "contract-test"
+ provider_name = "linode"
+ region = "us-east"
+ data_device = "/dev/test-data"
+ volumes_device = "/dev/test-volumes"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ offhost_backup_required = true
+ offhost_backup_driver_path = "/etc/cloud-compose/libexec/../untrusted"
+ }
+
+ expect_failures = [var.offhost_backup_driver_path]
+}
diff --git a/modules/linux-vm-runtime/main.tf b/modules/linux-vm-runtime/main.tf
index c2bb357..375ddd7 100644
--- a/modules/linux-vm-runtime/main.tf
+++ b/modules/linux-vm-runtime/main.tf
@@ -1,8 +1,12 @@
locals {
- rootfs = "${path.module}/../../rootfs"
- additional_rootfs = var.rootfs != "" ? var.rootfs : ""
- rootfs_archive_url = trimspace(var.rootfs_archive_url)
- rootfs_archive_sha256 = lower(trimspace(var.rootfs_archive_sha256))
+ rootfs = "${path.module}/../../rootfs"
+ additional_rootfs = var.rootfs != "" ? var.rootfs : ""
+ rootfs_archive_url = trimspace(var.rootfs_archive_url)
+ rootfs_archive_sha256 = lower(trimspace(var.rootfs_archive_sha256))
+ rootfs_test_source_archive_prefix = trimspace(var.rootfs_test_source_archive_prefix)
+ rootfs_archive_asset_url = split("#", split("?", local.rootfs_archive_url)[0])[0]
+ rootfs_contract_sidecar_url = local.rootfs_archive_url == "" || local.rootfs_test_source_archive_prefix != "" ? "" : replace(local.rootfs_archive_asset_url, "/[^/]+$/", "cloud-compose-rootfs.contract.sha256")
+ rootfs_overlay_staging_path = "/var/lib/cloud-compose/rootfs-overlay"
single_compose_project = {
(var.name) = {
@@ -82,27 +86,35 @@ locals {
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, "**") : []
- embedded_additional_files = local.rootfs_archive_url == "" ? local.additional_files : []
+ rootfs_contract_files = sort(tolist(fileset(local.rootfs, "**")))
+ rootfs_contract_directories = sort(distinct(flatten([
+ for file in local.rootfs_contract_files : dirname(file) == "." ? [] : [
+ for index in range(length(split("/", dirname(file)))) :
+ join("/", slice(split("/", dirname(file)), 0, index + 1))
+ ]
+ ])))
+ rootfs_contract_sha256 = sha256(join("", [
+ for entry in concat(
+ [for directory in local.rootfs_contract_directories : "d\t0:0:755\t${directory}\n"],
+ [for file in local.rootfs_contract_files : "f\t${filesha256("${local.rootfs}/${file}")}\t0:0:${endswith(file, ".sh") ? "755" : "644"}\t${file}\n"],
+ ) : entry
+ ]))
+ 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.embedded_additional_files : file => "${local.additional_rootfs}/${file}" }
+ { for file in local.additional_files : file => "${local.additional_rootfs}/${file}" }
)
-
- archive_additional_rootfs_commands = join("\n", [
- for file in local.additional_files : <<-EOT
- destination="$(printf '%s' '${base64encode("/${file}")}' | base64 -d)"
- install -d "$(dirname "$destination")"
- printf '%s' '${filebase64("${local.additional_rootfs}/${file}")}' | base64 -d >"$destination"
- chmod ${endswith(file, ".sh") ? "0755" : "0644"} "$destination"
- EOT
- ])
+ rootfs_file_permissions = {
+ for file in setunion(local.base_files, local.additional_files) :
+ file => endswith(file, ".sh") || "/${file}" == var.offhost_backup_driver_path ? "0755" : "0644"
+ }
write_files_content = join("\n", [
for file, fullpath in local.all_files : <<-EOT
- - path: ${jsonencode(startswith(file, "mnt/disks/") ? "/var/lib/cloud-compose/mounted-rootfs/${file}" : "/${file}")}
- permissions: ${jsonencode(endswith(file, ".sh") ? "0755" : "0644")}
+ - path: ${jsonencode(local.rootfs_archive_url != "" ? "${local.rootfs_overlay_staging_path}/${file}" : startswith(file, "mnt/disks/") ? "/var/lib/cloud-compose/mounted-rootfs/${file}" : "/${file}")}
+ owner: "root:root"
+ permissions: ${jsonencode(local.rootfs_file_permissions[file])}
encoding: gzip+base64
content: ${jsonencode(base64gzip(file(fullpath)))}
EOT
@@ -111,50 +123,45 @@ EOT
docker_compose_scripts = join("\n", [
for name in ["init", "up", "down", "rollout"] : <<-EOT
- path: "/home/cloud-compose/${name}"
+ owner: "root:root"
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
-))}
+ encoding: b64
+ content: ${filebase64("${local.rootfs}/home/cloud-compose/lifecycle-entrypoint.sh")}
EOT
-])
+ ])
-compose_projects_content = jsonencode(local.validated_compose_projects)
-compose_projects_file = <<-EOT
+ compose_projects_content = jsonencode(local.validated_compose_projects)
+ compose_projects_file = <<-EOT
- path: "/home/cloud-compose/compose-projects.json"
+ owner: "root:root"
permissions: "0640"
encoding: gzip+base64
content: ${jsonencode(base64gzip(local.compose_projects_content))}
EOT
-managed_runtime_artifact_lines = [
- for artifact in module.managed_artifacts.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
+ managed_runtime_artifact_lines = [
+ for artifact in module.managed_artifacts.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 = length(local.managed_runtime_artifact_lines) == 0 ? "\n" : "${join("\n", local.managed_runtime_artifact_lines)}\n"
+ managed_runtime_artifacts_file = <<-EOT
- path: "/home/cloud-compose/managed-runtime-artifacts.tsv"
+ owner: "root:root"
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
+ vault_agent_template_stanzas = join("\n", [
+ for template in var.vault_agent_templates : <<-EOT
template {
destination = ${jsonencode(template.destination)}
contents = <&2; exit 1 ;;
- esac
- case "$archive_url" in
- *[[:space:]]*) echo "rootfs archive URL must not contain whitespace" >&2; exit 1 ;;
- esac
- if ! command -v curl >/dev/null 2>&1 || ! command -v tar >/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 and tar" >&2
- exit 1
- fi
- fi
- for required_command in curl tar sha256sum; do
- if ! command -v "$required_command" >/dev/null 2>&1; then
- echo "$required_command is required to install the verified rootfs archive" >&2
- exit 1
- fi
- done
- tmp="$(mktemp -d)"
- trap 'rm -rf "$tmp"' EXIT
- curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
- --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 900 \
- --connect-timeout 10 --max-time 300 -o "$tmp/rootfs.tar.gz" -- "$archive_url"
- printf '%s %s\n' "$archive_sha256" "$tmp/rootfs.tar.gz" | sha256sum -c -
- tar -xzf "$tmp/rootfs.tar.gz" -C "$tmp"
- rootfs_dir="$(find "$tmp" -mindepth 1 -maxdepth 3 -type d -name rootfs -print -quit)"
- if [ -z "$rootfs_dir" ] || [ ! -d "$rootfs_dir" ]; then
- echo "rootfs directory not found in verified archive $archive_url" >&2
- exit 1
- fi
- filesystem_prep_source="$rootfs_dir/home/cloud-compose/prepare-filesystem.sh"
- filesystem_persist_source="$rootfs_dir/home/cloud-compose/persist-filesystems.sh"
- if [ ! -f "$filesystem_prep_source" ] || [ ! -f "$filesystem_persist_source" ]; then
- echo "verified rootfs archive is missing filesystem preparation scripts" >&2
- exit 1
- fi
- install -m 0600 -- "$filesystem_prep_source" "$filesystem_prep"
- install -m 0600 -- "$filesystem_persist_source" "$filesystem_persist"
- EOT
+data "http" "rootfs_contract" {
+ count = local.rootfs_archive_url != "" && local.rootfs_test_source_archive_prefix == "" ? 1 : 0
-rootfs_archive_install_command_raw = <<-EOT
- if [ -z "$${rootfs_dir:-}" ] || [ ! -d "$rootfs_dir" ]; then
- echo "verified rootfs directory is unavailable during installation" >&2
- exit 1
- fi
- cp -a "$rootfs_dir"/. /
- EOT
+ url = local.rootfs_contract_sidecar_url
+ request_timeout_ms = 30000
-rootfs_archive_prepare_command = local.rootfs_archive_url != "" ? local.rootfs_archive_prepare_command_raw : ""
-rootfs_archive_install_command = local.rootfs_archive_url != "" ? local.rootfs_archive_install_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
- APPLICATION_ENV_FILE_CONTENT = local.application_env_file_content
- VAULT_AGENT_FILES = local.vault_agent_files
- MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file
- ROLLOUT_RUNCMD = var.rollout_enabled ? "bash /home/cloud-compose/deploy-rollout.sh >> /home/cloud-compose/run.log 2>&1" : ""
- ROOTFS_ARCHIVE_ENABLED = local.rootfs_archive_url != ""
- ROOTFS_ARCHIVE_PREPARE_COMMAND = local.rootfs_archive_prepare_command
- ROOTFS_ARCHIVE_INSTALL_COMMAND = local.rootfs_archive_install_command
- ARCHIVE_ADDITIONAL_ROOTFS = local.archive_additional_rootfs_commands
- FILESYSTEM_PREP_SCRIPT_B64 = local.rootfs_archive_url == "" ? filebase64("${local.rootfs}/home/cloud-compose/prepare-filesystem.sh") : ""
- FILESYSTEM_PERSIST_SCRIPT_B64 = local.rootfs_archive_url == "" ? filebase64("${local.rootfs}/home/cloud-compose/persist-filesystems.sh") : ""
-})
+ lifecycle {
+ postcondition {
+ condition = (
+ self.status_code == 200 &&
+ can(regex("^[0-9a-f]{64}\\n?$", self.response_body)) &&
+ trimspace(self.response_body) == local.rootfs_contract_sha256
+ )
+ error_message = "The immutable rootfs release sidecar must contain exactly this module source's canonical rootfs contract digest. Publish or select a matching archive before replacing a VM."
+ }
+ }
}
module "sitectl_runtime" {
diff --git a/modules/linux-vm-runtime/outputs.tf b/modules/linux-vm-runtime/outputs.tf
index d021d89..4b2f973 100644
--- a/modules/linux-vm-runtime/outputs.tf
+++ b/modules/linux-vm-runtime/outputs.tf
@@ -9,6 +9,21 @@ output "cloud_init" {
)
error_message = "rootfs_archive_url and a 64-character rootfs_archive_sha256 must be supplied together."
}
+ precondition {
+ condition = (
+ local.rootfs_archive_url == "" ||
+ local.rootfs_test_source_archive_prefix != "" ||
+ trimspace(try(data.http.rootfs_contract[0].response_body, "")) == local.rootfs_contract_sha256
+ )
+ error_message = "The rootfs archive release contract must match this module before cloud-init is rendered."
+ }
+ precondition {
+ condition = (
+ local.rootfs_test_source_archive_prefix == "" ||
+ local.rootfs_archive_url == "https://github.com/libops/cloud-compose/archive/${trimprefix(local.rootfs_test_source_archive_prefix, "cloud-compose-")}.tar.gz"
+ )
+ error_message = "The test-only rootfs source archive must use the exact libops/cloud-compose commit named by rootfs_test_source_archive_prefix."
+ }
}
output "compose_projects" {
diff --git a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl
index 26288d4..50c478f 100644
--- a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl
+++ b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl
@@ -1,3 +1,12 @@
+mock_provider "http" {
+ mock_data "http" {
+ defaults = {
+ response_body = "c33470299657aca69837d7ce2cee73659aa5fd9a3297dcaad4444b50b54cdde2\n"
+ status_code = 200
+ }
+ }
+}
+
run "renders_safe_ssh_values" {
command = plan
@@ -25,6 +34,28 @@ run "renders_safe_ssh_values" {
condition = strcontains(output.cloud_init, "name: ${jsonencode("app_operator")}")
error_message = "SSH usernames must be YAML encoded as literal values."
}
+
+ assert {
+ condition = (
+ strcontains(output.cloud_init, "path: \"/etc/cloud-compose/bin/cloud-compose-diagnostics.sh\"") &&
+ strcontains(output.cloud_init, "NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh state") &&
+ strcontains(output.cloud_init, "NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh status") &&
+ strcontains(output.cloud_init, "NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh dump") &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/etc/cloud-compose/libexec/linux-vm-cloud-init.sh\"",
+ ) &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/etc/cloud-compose/jq/compose-validate-projects.jq\"",
+ ) &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/etc/cloud-compose/awk/reconcile-fstab.awk\"",
+ )
+ )
+ error_message = "Cloud-init must install root-owned diagnostics and checked jq/awk programs with exact passwordless sudo commands."
+ }
}
run "normalizes_minimal_compose_project" {
@@ -196,80 +227,220 @@ run "renders_verified_rootfs_archive" {
command = plan
variables {
- name = "contract-test"
- provider_name = "linode"
- region = "us-east"
- data_device = "/dev/test-data"
- volumes_device = "/dev/test-volumes"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs_archive_url = "https://example.invalid/cloud-compose-$(id).tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ name = "contract-test"
+ provider_name = "linode"
+ region = "us-east"
+ data_device = "/dev/test-data"
+ volumes_device = "/dev/test-volumes"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs = "testdata/rootfs"
+ rootfs_archive_url = "https://example.invalid/cloud-compose-$(id).tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ offhost_backup_driver_path = "/etc/cloud-compose/libexec/custom-offhost-driver"
}
assert {
- condition = strcontains(output.cloud_init, "sha256sum -c -")
- error_message = "Archive cloud-init must verify SHA-256 before extraction."
+ condition = (
+ strcontains(
+ output.cloud_init,
+ base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/libexec/rootfs-archive.sh")),
+ ) &&
+ length(data.http.rootfs_contract) == 1 &&
+ strcontains(output.cloud_init, local.rootfs_contract_sha256) &&
+ data.http.rootfs_contract[0].url == "https://example.invalid/cloud-compose-rootfs.contract.sha256" &&
+ strcontains(output.cloud_init, "[bash, /var/lib/cloud-compose/bootstrap/rootfs-archive.sh, prepare-linux") &&
+ strcontains(output.cloud_init, "[bash, /var/lib/cloud-compose/bootstrap/linux-vm-cloud-init.sh")
+ )
+ error_message = "Archive-mode Linux cloud-init must transfer compressed checked bootstrap programs, bind the archive to the exact module rootfs, and invoke stable paths."
}
assert {
condition = (
- strcontains(output.cloud_init, "tar -xzf \"$tmp/rootfs.tar.gz\"") &&
- strcontains(output.cloud_init, "install -m 0600 -- \"$filesystem_prep_source\" \"$filesystem_prep\"") &&
- strcontains(output.cloud_init, "bash \"$filesystem_prep\"") &&
- strcontains(output.cloud_init, "bash \"$filesystem_persist\"") &&
- strcontains(output.cloud_init, "cp -a \"$rootfs_dir\"/. /") &&
- length(split("sha256sum -c -", output.cloud_init)[0]) < length(split("tar -xzf \"$tmp/rootfs.tar.gz\"", output.cloud_init)[0]) &&
- length(split("tar -xzf \"$tmp/rootfs.tar.gz\"", output.cloud_init)[0]) < length(split("install -m 0600 -- \"$filesystem_prep_source\" \"$filesystem_prep\"", output.cloud_init)[0]) &&
- length(split("install -m 0600 -- \"$filesystem_prep_source\" \"$filesystem_prep\"", output.cloud_init)[0]) < length(split("bash \"$filesystem_prep\"", output.cloud_init)[0]) &&
- length(split("bash \"$filesystem_persist\"", output.cloud_init)[0]) < length(split("cp -a \"$rootfs_dir\"/. /", output.cloud_init)[0])
+ !strcontains(
+ local.write_files_content,
+ base64gzip(file("${path.module}/../../rootfs/home/cloud-compose/prepare-filesystem.sh")),
+ ) &&
+ !strcontains(
+ local.write_files_content,
+ base64gzip(file("${path.module}/../../rootfs/home/cloud-compose/persist-filesystems.sh")),
+ )
)
- error_message = "Archive cloud-init must verify and extract before loading its helpers, then install the rootfs only after filesystem preparation and persistence."
+ error_message = "Archive-backed cloud-init must not embed the filesystem helper payloads."
}
assert {
condition = (
- !strcontains(output.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/prepare-filesystem.sh")) &&
- !strcontains(output.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/persist-filesystems.sh"))
+ strcontains(output.cloud_init, base64encode(var.rootfs_archive_url)) &&
+ !strcontains(output.cloud_init, var.rootfs_archive_url)
)
- error_message = "Archive-backed cloud-init must not embed the filesystem helper payloads."
+ error_message = "Archive URLs must be rendered as base64 data rather than executable shell syntax."
}
assert {
condition = (
- strcontains(output.cloud_init, "verified rootfs archive is missing filesystem preparation scripts") &&
- strcontains(output.cloud_init, "verified rootfs directory is unavailable during installation")
+ local.rootfs_file_permissions["etc/cloud-compose/libexec/custom-offhost-driver"] == "0755" &&
+ local.rootfs_file_permissions["etc/cloud-compose/unrelated-config"] == "0644" &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose/libexec/custom-offhost-driver\"\n owner: \"root:root\"\n permissions: \"0755\"",
+ ) &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose/unrelated-config\"\n owner: \"root:root\"\n permissions: \"0644\"",
+ )
)
- error_message = "Archive-backed cloud-init must fail closed when the verified rootfs or its filesystem helpers are missing."
+ error_message = "Archive-backed Linux VM overlays must make only the configured off-host backup driver executable."
+ }
+}
+
+run "renders_exact_current_source_archive_for_hosted_smoke" {
+ command = plan
+
+ variables {
+ name = "contract-test"
+ provider_name = "linode"
+ region = "us-east"
+ data_device = "/dev/test-data"
+ volumes_device = "/dev/test-volumes"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
}
assert {
condition = (
- strcontains(output.cloud_init, "archive_url_b64=") &&
- !strcontains(output.cloud_init, "$(id)")
+ length(data.http.rootfs_contract) == 0 &&
+ strcontains(output.cloud_init, "prepare-linux-test-source") &&
+ strcontains(output.cloud_init, jsonencode(var.rootfs_test_source_archive_prefix)) &&
+ strcontains(output.cloud_init, local.rootfs_contract_sha256)
)
- error_message = "Archive URLs must be rendered as base64 data rather than executable shell syntax."
+ error_message = "Hosted smoke source-archive mode must skip the unavailable release sidecar while binding the exact source rootfs to this module contract."
}
}
+run "rejects_source_archive_from_another_commit" {
+ command = plan
+
+ variables {
+ name = "contract-test"
+ provider_name = "linode"
+ region = "us-east"
+ data_device = "/dev/test-data"
+ volumes_device = "/dev/test-volumes"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/2222222222222222222222222222222222222222.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
+ }
+
+ expect_failures = [output.cloud_init]
+}
+
+run "rejects_tag_named_source_archive_prefix" {
+ command = plan
+
+ variables {
+ name = "contract-test"
+ provider_name = "linode"
+ region = "us-east"
+ data_device = "/dev/test-data"
+ volumes_device = "/dev/test-volumes"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/refs/tags/v1.2.3.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-v1.2.3"
+ }
+
+ expect_failures = [var.rootfs_test_source_archive_prefix]
+}
+
+run "rejects_arbitrary_test_source_archive_url" {
+ command = plan
+
+ variables {
+ name = "contract-test"
+ provider_name = "linode"
+ region = "us-east"
+ data_device = "/dev/test-data"
+ volumes_device = "/dev/test-volumes"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs_archive_url = "https://example.invalid/1111111111111111111111111111111111111111.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
+ }
+
+ expect_failures = [output.cloud_init]
+}
+
+run "rejects_rootfs_release_from_another_module_version" {
+ command = plan
+
+ variables {
+ name = "contract-test"
+ provider_name = "linode"
+ region = "us-east"
+ data_device = "/dev/test-data"
+ volumes_device = "/dev/test-volumes"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
+ rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ }
+
+ override_data {
+ target = data.http.rootfs_contract[0]
+ values = {
+ response_body = "0000000000000000000000000000000000000000000000000000000000000000\n"
+ status_code = 200
+ }
+ }
+
+ expect_failures = [data.http.rootfs_contract[0]]
+}
+
run "embeds_filesystem_helpers_without_archive" {
command = plan
variables {
- name = "contract-test"
- provider_name = "digitalocean"
- region = "nyc3"
- data_device = "/dev/test-data"
- volumes_device = "/dev/test-volumes"
- docker_compose_repo = "https://github.com/libops/wp.git"
+ name = "contract-test"
+ provider_name = "digitalocean"
+ region = "nyc3"
+ data_device = "/dev/test-data"
+ volumes_device = "/dev/test-volumes"
+ docker_compose_repo = "https://github.com/libops/wp.git"
+ rootfs = "testdata/rootfs"
+ offhost_backup_driver_path = "/etc/cloud-compose/libexec/custom-offhost-driver"
+ }
+
+ assert {
+ condition = (
+ strcontains(
+ local.write_files_content,
+ base64gzip(file("${path.module}/../../rootfs/home/cloud-compose/prepare-filesystem.sh")),
+ ) &&
+ strcontains(
+ local.write_files_content,
+ base64gzip(file("${path.module}/../../rootfs/home/cloud-compose/persist-filesystems.sh")),
+ ) &&
+ !strcontains(local.write_files_content, "/var/lib/cloud-compose/rootfs-overlay/")
+ )
+ error_message = "Non-archive cloud-init must transfer the checked-in filesystem helpers without using the archive overlay."
}
assert {
condition = (
- strcontains(output.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/prepare-filesystem.sh")) &&
- strcontains(output.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/persist-filesystems.sh")) &&
- !strcontains(output.cloud_init, "archive_url_b64=")
+ local.rootfs_file_permissions["etc/cloud-compose/libexec/custom-offhost-driver"] == "0755" &&
+ local.rootfs_file_permissions["etc/cloud-compose/unrelated-config"] == "0644" &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/etc/cloud-compose/libexec/custom-offhost-driver\"\n owner: \"root:root\"\n permissions: \"0755\"",
+ ) &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/etc/cloud-compose/unrelated-config\"\n owner: \"root:root\"\n permissions: \"0644\"",
+ )
)
- error_message = "Inline cloud-init must retain the embedded filesystem-helper bootstrap when no archive is configured."
+ error_message = "Embedded Linux VM overlays must make only the configured off-host backup driver executable."
}
}
diff --git a/modules/linux-vm-runtime/templates/cloud-init.yml b/modules/linux-vm-runtime/templates/cloud-init.yml
index 1cea770..3d812f5 100644
--- a/modules/linux-vm-runtime/templates/cloud-init.yml
+++ b/modules/linux-vm-runtime/templates/cloud-init.yml
@@ -10,6 +10,9 @@ users:
- docker
sudo:
- ALL=(root) NOPASSWD:/usr/bin/systemctl start cloud-compose.service,/usr/bin/systemctl stop cloud-compose.service,/usr/bin/systemctl restart cloud-compose.service,/usr/bin/systemctl status cloud-compose.service
+ - ALL=(root) NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh state
+ - ALL=(root) NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh status
+ - ALL=(root) NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh dump
%{ if length(CLOUD_COMPOSE_SSH_KEYS) > 0 ~}
ssh_authorized_keys:
%{ for key in CLOUD_COMPOSE_SSH_KEYS ~}
@@ -26,6 +29,23 @@ users:
%{ endfor ~}
write_files:
+- path: /var/lib/cloud-compose/bootstrap/rootfs-archive.sh
+ owner: root:root
+ permissions: "0700"
+ encoding: gzip+base64
+ content: ${ROOTFS_ARCHIVE_SCRIPT_B64}
+%{ if ROOTFS_ARCHIVE_ENABLED ~}
+- path: /var/lib/cloud-compose/bootstrap/linux-vm-cloud-init.sh
+ owner: root:root
+ permissions: "0700"
+ encoding: gzip+base64
+ content: ${LINUX_CLOUD_INIT_SCRIPT_B64}
+%{ endif ~}
+- path: /var/lib/cloud-compose/bootstrap/cloud-compose-diagnostics.sh
+ owner: root:root
+ permissions: "0600"
+ encoding: gzip+base64
+ content: ${DIAGNOSTICS_SCRIPT_B64}
${WRITE_FILES_CONTENT}
${DOCKER_COMPOSE_SCRIPTS}
${COMPOSE_PROJECTS_FILE}
@@ -35,68 +55,13 @@ ${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=(root) NOPASSWD:/usr/bin/systemctl start cloud-compose.service,/usr/bin/systemctl stop cloud-compose.service,/usr/bin/systemctl restart cloud-compose.service,/usr/bin/systemctl status cloud-compose.service\n' >/etc/sudoers.d/90-cloud-compose
- chmod 0440 /etc/sudoers.d/90-cloud-compose
- }
-
- ensure_cloud_compose_user
- filesystem_prep=/run/cloud-compose-prepare-filesystem
- filesystem_persist=/run/cloud-compose-persist-filesystems
-%{ if ROOTFS_ARCHIVE_ENABLED }
- ${indent(2, ROOTFS_ARCHIVE_PREPARE_COMMAND)}
-%{ else }
- printf '%s' '${FILESYSTEM_PREP_SCRIPT_B64}' | base64 -d >"$filesystem_prep"
- printf '%s' '${FILESYSTEM_PERSIST_SCRIPT_B64}' | base64 -d >"$filesystem_persist"
- chmod 0600 "$filesystem_prep" "$filesystem_persist"
-%{ endif }
- bash "$filesystem_prep" '${DATA_DEVICE}' /mnt/disks/data --publish-fresh-marker
- bash "$filesystem_prep" '${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
- for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do
- if ! mountpoint -q -- "$required_mount"; then
- echo "Required cloud-compose mount is unavailable: $required_mount" >&2
- exit 1
- fi
- done
- bash "$filesystem_persist" '${DATA_DEVICE}' '${VOLUMES_DEVICE}'
-%{ if ROOTFS_ARCHIVE_ENABLED }
- ${indent(2, ROOTFS_ARCHIVE_INSTALL_COMMAND)}
- ${indent(2, ARCHIVE_ADDITIONAL_ROOTFS)}
-%{ else }
- if [ -d /var/lib/cloud-compose/mounted-rootfs/mnt/disks ]; then
- cp -a /var/lib/cloud-compose/mounted-rootfs/mnt/disks/. /mnt/disks/
- fi
-%{ endif }
- chown root:cloud-compose /mnt/disks/data
- chmod 1775 /mnt/disks/data
- chown cloud-compose:cloud-compose /mnt/disks/volumes
- chmod 0775 /mnt/disks/volumes
- install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops
-%{ if ROLLOUT_RUNCMD != "" ~}
- ${ROLLOUT_RUNCMD}
+%{ if ROOTFS_ARCHIVE_ENABLED ~}
+%{ if ROOTFS_TEST_SOURCE_ARCHIVE_PREFIX != "" ~}
+- [bash, /var/lib/cloud-compose/bootstrap/rootfs-archive.sh, prepare-linux-test-source, ${jsonencode(ROOTFS_ARCHIVE_URL_B64)}, ${jsonencode(ROOTFS_ARCHIVE_SHA256)}, ${jsonencode(ROOTFS_CONTRACT_SHA256)}, ${jsonencode(LINUX_CLOUD_INIT_SCRIPT_SHA256)}, ${jsonencode(ROOTFS_TEST_SOURCE_ARCHIVE_PREFIX)}]
+%{ else ~}
+- [bash, /var/lib/cloud-compose/bootstrap/rootfs-archive.sh, prepare-linux, ${jsonencode(ROOTFS_ARCHIVE_URL_B64)}, ${jsonencode(ROOTFS_ARCHIVE_SHA256)}, ${jsonencode(ROOTFS_CONTRACT_SHA256)}, ${jsonencode(LINUX_CLOUD_INIT_SCRIPT_SHA256)}]
+%{ endif ~}
+- [bash, /var/lib/cloud-compose/bootstrap/linux-vm-cloud-init.sh, ${jsonencode(DATA_DEVICE)}, ${jsonencode(VOLUMES_DEVICE)}, "true", ${jsonencode(ROLLOUT_ENABLED)}, ${jsonencode(DIAGNOSTICS_SCRIPT_SHA256)}]
+%{ else ~}
+- [bash, /etc/cloud-compose/libexec/linux-vm-cloud-init.sh, ${jsonencode(DATA_DEVICE)}, ${jsonencode(VOLUMES_DEVICE)}, "false", ${jsonencode(ROLLOUT_ENABLED)}, ${jsonencode(DIAGNOSTICS_SCRIPT_SHA256)}]
%{ endif ~}
- rm -f /home/cloud-compose/.cloud-compose-bootstrap-complete
- bash /home/cloud-compose/start-cloud-compose-bootstrap.sh
diff --git a/modules/linux-vm-runtime/testdata/rootfs/etc/cloud-compose/libexec/custom-offhost-driver b/modules/linux-vm-runtime/testdata/rootfs/etc/cloud-compose/libexec/custom-offhost-driver
new file mode 100644
index 0000000..df09e99
--- /dev/null
+++ b/modules/linux-vm-runtime/testdata/rootfs/etc/cloud-compose/libexec/custom-offhost-driver
@@ -0,0 +1 @@
+fixture driver payload
diff --git a/modules/linux-vm-runtime/testdata/rootfs/etc/cloud-compose/unrelated-config b/modules/linux-vm-runtime/testdata/rootfs/etc/cloud-compose/unrelated-config
new file mode 100644
index 0000000..578c4da
--- /dev/null
+++ b/modules/linux-vm-runtime/testdata/rootfs/etc/cloud-compose/unrelated-config
@@ -0,0 +1 @@
+fixture config payload
diff --git a/modules/linux-vm-runtime/variables.tf b/modules/linux-vm-runtime/variables.tf
index 968cf42..c01d590 100644
--- a/modules/linux-vm-runtime/variables.tf
+++ b/modules/linux-vm-runtime/variables.tf
@@ -79,7 +79,7 @@ variable "rootfs" {
variable "rootfs_archive_url" {
type = string
default = ""
- description = "Optional HTTPS tar.gz URL containing a rootfs directory to fetch during boot instead of embedding the packaged rootfs in cloud-init. Must be set with rootfs_archive_sha256."
+ description = "Optional immutable HTTPS cloud-compose rootfs release archive to fetch during boot instead of embedding the packaged rootfs in cloud-init. Must be set with rootfs_archive_sha256; planning also requires the adjacent cloud-compose-rootfs.contract.sha256 asset to match this module source."
validation {
condition = (
@@ -96,6 +96,41 @@ variable "rootfs_archive_sha256" {
description = "Required 64-character SHA-256 checksum when rootfs_archive_url is set."
}
+variable "rootfs_test_source_archive_prefix" {
+ type = string
+ default = ""
+ description = "Test-only GitHub source-archive prefix for hosted smoke tests of an unreleased exact commit. Empty keeps the production canonical release archive and adjacent sidecar contract mandatory."
+
+ validation {
+ condition = (
+ trimspace(var.rootfs_test_source_archive_prefix) == "" ||
+ can(regex("^cloud-compose-[0-9a-f]{40}$", trimspace(var.rootfs_test_source_archive_prefix)))
+ )
+ error_message = "rootfs_test_source_archive_prefix must be empty or cloud-compose- followed by one exact lowercase 40-character commit SHA."
+ }
+}
+
+variable "offhost_backup_required" {
+ type = bool
+ default = false
+ description = "Require nightly encrypted off-host coverage and scheduled disposable restore proofs from an operator-owned driver. Same-disk MariaDB dumps are retained but are not disaster recovery."
+}
+
+variable "offhost_backup_driver_path" {
+ type = string
+ default = "/etc/cloud-compose/libexec/offhost-backup-driver"
+ description = "Absolute path to the operator-supplied, root-owned provider-neutral DR driver. The driver owns its credentials; do not pass them through Terraform."
+
+ validation {
+ condition = (
+ can(regex("^/[A-Za-z0-9._/+:-]+$", var.offhost_backup_driver_path)) &&
+ !strcontains(var.offhost_backup_driver_path, "//") &&
+ length(regexall("(^|/)\\.\\.?(/|$)", var.offhost_backup_driver_path)) == 0
+ )
+ error_message = "offhost_backup_driver_path must be a safe absolute path without whitespace or dot segments."
+ }
+}
+
variable "ingress_port" {
type = number
default = 80
@@ -188,7 +223,7 @@ variable "compose_projects" {
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"
+ "/home/cloud-compose/default-lifecycle.sh init"
]
nullable = false
description = "Commands run after a compose repository is cloned."
@@ -197,9 +232,7 @@ variable "docker_compose_init" {
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",
- "if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
+ "/home/cloud-compose/default-lifecycle.sh up"
]
nullable = false
description = "Commands used to bring a compose project up."
@@ -208,7 +241,7 @@ variable "docker_compose_up" {
variable "docker_compose_down" {
type = list(string)
default = [
- "sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" down"
+ "/home/cloud-compose/default-lifecycle.sh down"
]
nullable = false
description = "Commands used to stop a compose project."
@@ -217,13 +250,10 @@ variable "docker_compose_down" {
variable "docker_compose_rollout" {
type = list(string)
default = [
- "TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-}}\"",
- "if [ -n \"$TARGET_REF\" ]; then sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --ref \"$TARGET_REF\"; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --skip-git; fi",
- "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist",
- "if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi"
+ "/home/cloud-compose/default-lifecycle.sh rollout"
]
nullable = false
- description = "Commands used by rollout triggers. GIT_REF/GIT_BRANCH selects a source ref; without one, sitectl reconciles the current checkout."
+ description = "Commands used by rollout triggers. A validated GIT_COMMIT_SHA takes precedence over GIT_REF/GIT_BRANCH; without one, sitectl reconciles the current checkout."
}
variable "rollout_enabled" {
diff --git a/modules/linux-vm-runtime/versions.tf b/modules/linux-vm-runtime/versions.tf
index 12ad22a..1135eed 100644
--- a/modules/linux-vm-runtime/versions.tf
+++ b/modules/linux-vm-runtime/versions.tf
@@ -1,3 +1,10 @@
terraform {
required_version = ">= 1.3.0"
+
+ required_providers {
+ http = {
+ source = "hashicorp/http"
+ version = "~> 3.6"
+ }
+ }
}
diff --git a/providers/do/template_versions.tftest.hcl b/providers/do/template_versions.tftest.hcl
index 781c11c..a577a17 100644
--- a/providers/do/template_versions.tftest.hcl
+++ b/providers/do/template_versions.tftest.hcl
@@ -3,11 +3,19 @@ mock_provider "digitalocean" {}
run "custom_package_set_merges_only_applicable_template_versions" {
command = plan
+ override_data {
+ target = module.digitalocean.module.runtime.data.http.rootfs_contract[0]
+ values = {
+ response_body = "c33470299657aca69837d7ce2cee73659aa5fd9a3297dcaad4444b50b54cdde2\n"
+ status_code = 200
+ }
+ }
+
variables {
name = "template-versions"
template = "isle"
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
+ rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
packages = ["sitectl", "sitectl-wp"]
@@ -28,8 +36,8 @@ run "custom_package_set_merges_only_applicable_template_versions" {
}
assert {
- condition = local.runtime.compose.branch == "v1.1.0"
- error_message = "The DigitalOcean entrypoint must inherit the ISLE v1.1.0 template branch when no override is supplied."
+ condition = local.runtime.compose.branch == "v1.3.1"
+ error_message = "The DigitalOcean entrypoint must inherit the ISLE v1.3.1 template branch when no override is supplied."
}
assert {
@@ -41,11 +49,19 @@ run "custom_package_set_merges_only_applicable_template_versions" {
run "explicit_core_only_package_set_disables_template_plugins" {
command = plan
+ override_data {
+ target = module.digitalocean.module.runtime.data.http.rootfs_contract[0]
+ values = {
+ response_body = "c33470299657aca69837d7ce2cee73659aa5fd9a3297dcaad4444b50b54cdde2\n"
+ status_code = 200
+ }
+ }
+
variables {
name = "template-versions"
template = "isle"
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
+ rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
packages = []
@@ -55,7 +71,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == {
- sitectl = "v1.0.0"
+ sitectl = "v1.9.1"
}
error_message = "The DigitalOcean entrypoint must preserve an explicit core-only package set."
}
diff --git a/providers/do/variables.tf b/providers/do/variables.tf
index 571ca4c..eab146b 100644
--- a/providers/do/variables.tf
+++ b/providers/do/variables.tf
@@ -87,6 +87,11 @@ variable "runtime" {
rootfs_archive_sha256 = optional(string, "")
users = optional(map(list(string)), {})
+ disaster_recovery = optional(object({
+ required = optional(bool, false)
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
+ }), {})
+
compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
@@ -195,6 +200,15 @@ variable "runtime" {
error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together."
}
+ validation {
+ condition = (
+ can(regex("^/[A-Za-z0-9._/+:-]+$", var.runtime.disaster_recovery.driver_path)) &&
+ !strcontains(var.runtime.disaster_recovery.driver_path, "//") &&
+ length(regexall("(^|/)\\.\\.?(/|$)", var.runtime.disaster_recovery.driver_path)) == 0
+ )
+ error_message = "runtime.disaster_recovery.driver_path must be a safe absolute path without whitespace or dot segments."
+ }
+
validation {
condition = alltrue([
for name in keys(var.runtime.extra_env) :
diff --git a/providers/gcp/main.tf b/providers/gcp/main.tf
index 80a74c0..0ff9757 100644
--- a/providers/gcp/main.tf
+++ b/providers/gcp/main.tf
@@ -145,13 +145,15 @@ module "gcp" {
overlay_source_instance = local.gcp_overlay.source_instance
volume_names = local.gcp_overlay.volume_names
- users = local.runtime.users
- rootfs = local.runtime.rootfs
- rootfs_archive_url = local.runtime.rootfs_archive_url
- rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256
- extra_env = local.runtime.extra_env
- runcmd = local.gcp_cloud_init.runcmd
- initcmd = local.gcp_cloud_init.initcmd
+ users = local.runtime.users
+ rootfs = local.runtime.rootfs
+ rootfs_archive_url = local.runtime.rootfs_archive_url
+ rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256
+ offhost_backup_required = local.runtime.disaster_recovery.required
+ offhost_backup_driver_path = local.runtime.disaster_recovery.driver_path
+ extra_env = local.runtime.extra_env
+ 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
diff --git a/providers/gcp/template_versions.tftest.hcl b/providers/gcp/template_versions.tftest.hcl
index 958be67..ee04d8a 100644
--- a/providers/gcp/template_versions.tftest.hcl
+++ b/providers/gcp/template_versions.tftest.hcl
@@ -38,8 +38,8 @@ run "custom_package_set_merges_only_applicable_template_versions" {
}
assert {
- condition = local.runtime.compose.branch == "v1.1.0"
- error_message = "The GCP entrypoint must inherit the ISLE v1.1.0 template branch when no override is supplied."
+ condition = local.runtime.compose.branch == "v1.3.1"
+ error_message = "The GCP entrypoint must inherit the ISLE v1.3.1 template branch when no override is supplied."
}
assert {
@@ -67,7 +67,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == {
- sitectl = "v1.0.0"
+ sitectl = "v1.9.1"
}
error_message = "The GCP entrypoint must preserve an explicit core-only package set."
}
diff --git a/providers/gcp/variables.tf b/providers/gcp/variables.tf
index 1bfadee..df1ef48 100644
--- a/providers/gcp/variables.tf
+++ b/providers/gcp/variables.tf
@@ -199,6 +199,11 @@ variable "runtime" {
rootfs_archive_sha256 = optional(string, "")
users = optional(map(list(string)), {})
+ disaster_recovery = optional(object({
+ required = optional(bool, false)
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
+ }), {})
+
compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
@@ -308,6 +313,15 @@ variable "runtime" {
error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together."
}
+ validation {
+ condition = (
+ can(regex("^/[A-Za-z0-9._/+:-]+$", var.runtime.disaster_recovery.driver_path)) &&
+ !strcontains(var.runtime.disaster_recovery.driver_path, "//") &&
+ length(regexall("(^|/)\\.\\.?(/|$)", var.runtime.disaster_recovery.driver_path)) == 0
+ )
+ error_message = "runtime.disaster_recovery.driver_path must be a safe absolute path without whitespace or dot segments."
+ }
+
validation {
condition = alltrue([
for name in keys(var.runtime.extra_env) :
diff --git a/providers/linode/template_versions.tftest.hcl b/providers/linode/template_versions.tftest.hcl
index 460b3a2..d32bfc8 100644
--- a/providers/linode/template_versions.tftest.hcl
+++ b/providers/linode/template_versions.tftest.hcl
@@ -3,6 +3,14 @@ mock_provider "linode" {}
run "custom_package_set_merges_only_applicable_template_versions" {
command = plan
+ override_data {
+ target = module.linode.module.runtime.data.http.rootfs_contract[0]
+ values = {
+ response_body = "c33470299657aca69837d7ce2cee73659aa5fd9a3297dcaad4444b50b54cdde2\n"
+ status_code = 200
+ }
+ }
+
variables {
name = "template-versions"
template = "isle"
@@ -12,7 +20,7 @@ run "custom_package_set_merges_only_applicable_template_versions" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
+ rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
packages = ["sitectl", "sitectl-wp"]
@@ -33,8 +41,8 @@ run "custom_package_set_merges_only_applicable_template_versions" {
}
assert {
- condition = local.runtime.compose.branch == "v1.1.0"
- error_message = "The Linode entrypoint must inherit the ISLE v1.1.0 template branch when no override is supplied."
+ condition = local.runtime.compose.branch == "v1.3.1"
+ error_message = "The Linode entrypoint must inherit the ISLE v1.3.1 template branch when no override is supplied."
}
assert {
@@ -46,6 +54,14 @@ run "custom_package_set_merges_only_applicable_template_versions" {
run "explicit_core_only_package_set_disables_template_plugins" {
command = plan
+ override_data {
+ target = module.linode.module.runtime.data.http.rootfs_contract[0]
+ values = {
+ response_body = "c33470299657aca69837d7ce2cee73659aa5fd9a3297dcaad4444b50b54cdde2\n"
+ status_code = 200
+ }
+ }
+
variables {
name = "template-versions"
template = "isle"
@@ -55,7 +71,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
+ rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
packages = []
@@ -65,7 +81,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == {
- sitectl = "v1.0.0"
+ sitectl = "v1.9.1"
}
error_message = "The Linode entrypoint must preserve an explicit core-only package set."
}
diff --git a/providers/linode/variables.tf b/providers/linode/variables.tf
index 725ce0b..8ab7190 100644
--- a/providers/linode/variables.tf
+++ b/providers/linode/variables.tf
@@ -102,6 +102,11 @@ variable "runtime" {
rootfs_archive_sha256 = optional(string, "")
users = optional(map(list(string)), {})
+ disaster_recovery = optional(object({
+ required = optional(bool, false)
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
+ }), {})
+
compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
@@ -210,6 +215,15 @@ variable "runtime" {
error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together."
}
+ validation {
+ condition = (
+ can(regex("^/[A-Za-z0-9._/+:-]+$", var.runtime.disaster_recovery.driver_path)) &&
+ !strcontains(var.runtime.disaster_recovery.driver_path, "//") &&
+ length(regexall("(^|/)\\.\\.?(/|$)", var.runtime.disaster_recovery.driver_path)) == 0
+ )
+ error_message = "runtime.disaster_recovery.driver_path must be a safe absolute path without whitespace or dot segments."
+ }
+
validation {
condition = alltrue([
for name in keys(var.runtime.extra_env) :
diff --git a/renovate.json5 b/renovate.json5
index 169e5fc..eabf862 100644
--- a/renovate.json5
+++ b/renovate.json5
@@ -4,6 +4,32 @@
'github>libops/renovate-config:default.json5',
],
customManagers: [
+ {
+ customType: 'regex',
+ description: 'Propose reviewed sitectl package promotions for application presets',
+ managerFilePatterns: [
+ '/^templates/apps\.json$/',
+ ],
+ matchStrings: [
+ '"(?sitectl(?:-[a-z0-9-]+)?)"\\s*:\\s*"(?v[0-9]+\\.[0-9]+\\.[0-9]+)"',
+ ],
+ datasourceTemplate: 'github-releases',
+ packageNameTemplate: 'libops/{{{depName}}}',
+ versioningTemplate: 'semver',
+ },
+ {
+ customType: 'regex',
+ description: 'Propose reviewed Compose-template promotions for application presets',
+ managerFilePatterns: [
+ '/^templates/apps\.json$/',
+ ],
+ matchStrings: [
+ '"repo"\\s*:\\s*"https://github\\.com/libops/(?[a-z0-9-]+)(?:\\.git)?"\\s*,\\s*"branch"\\s*:\\s*"(?v[0-9]+\\.[0-9]+\\.[0-9]+)"',
+ ],
+ datasourceTemplate: 'github-tags',
+ packageNameTemplate: 'libops/{{{depName}}}',
+ versioningTemplate: 'semver',
+ },
{
customType: 'regex',
description: 'Update Docker Compose and Buildx Terraform defaults',
diff --git a/rootfs/etc/cloud-compose/awk/compose-secret-files.awk b/rootfs/etc/cloud-compose/awk/compose-secret-files.awk
new file mode 100644
index 0000000..1fc8827
--- /dev/null
+++ b/rootfs/etc/cloud-compose/awk/compose-secret-files.awk
@@ -0,0 +1,18 @@
+/^[[: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
+}
diff --git a/rootfs/etc/cloud-compose/awk/reconcile-fstab.awk b/rootfs/etc/cloud-compose/awk/reconcile-fstab.awk
new file mode 100644
index 0000000..36b907c
--- /dev/null
+++ b/rootfs/etc/cloud-compose/awk/reconcile-fstab.awk
@@ -0,0 +1,20 @@
+$0 == begin { managed = 1; next }
+$0 == end { managed = 0; next }
+!managed {
+ if (data_provider_mount != "" && $2 == data_provider_mount) {
+ if ($1 == data_device) next
+ conflict = 1
+ }
+ if (volumes_provider_mount != "" && $2 == volumes_provider_mount) {
+ if ($1 == volumes_device) next
+ conflict = 1
+ }
+ if ($2 == "/mnt/disks/data" ||
+ $2 == "/mnt/disks/volumes" ||
+ $2 == "/mnt/disks/data/docker/volumes" ||
+ $2 == "/mnt/disks/prod-readonly") {
+ conflict = 1
+ }
+ print
+}
+END { if (managed || conflict) exit 42 }
diff --git a/rootfs/etc/cloud-compose/awk/release-checksum-entry.awk b/rootfs/etc/cloud-compose/awk/release-checksum-entry.awk
new file mode 100644
index 0000000..9b68308
--- /dev/null
+++ b/rootfs/etc/cloud-compose/awk/release-checksum-entry.awk
@@ -0,0 +1 @@
+$2 == archive { print }
diff --git a/rootfs/etc/cloud-compose/awk/release-checksum.awk b/rootfs/etc/cloud-compose/awk/release-checksum.awk
new file mode 100644
index 0000000..a00540a
--- /dev/null
+++ b/rootfs/etc/cloud-compose/awk/release-checksum.awk
@@ -0,0 +1,14 @@
+{
+ filename = $2
+ sub(/^\*/, "", filename)
+ if (filename == asset) {
+ checksum = $1
+ matches++
+ }
+}
+END {
+ if (matches != 1) {
+ exit 1
+ }
+ print checksum
+}
diff --git a/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh b/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh
new file mode 100755
index 0000000..ddbb352
--- /dev/null
+++ b/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh
@@ -0,0 +1,217 @@
+#!/bin/bash
+
+set -euo pipefail
+
+# This program is reached through an exact sudoers command. Never resolve its
+# child commands from a caller-controlled tool directory.
+readonly PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+export PATH
+
+readonly bootstrap_marker="/var/lib/cloud-compose/bootstrap-complete"
+readonly diagnostics_program="/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"
+readonly jq_program_dir="/etc/cloud-compose/jq"
+readonly process_pattern='[/]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'
+
+usage() {
+ echo "usage: ${diagnostics_program} state|status|dump" >&2
+}
+
+require_root() {
+ if ((EUID != 0)); then
+ echo "Cloud Compose diagnostics must run as root" >&2
+ exit 1
+ fi
+}
+
+unit_value() {
+ local unit="$1" property="$2" value
+
+ value="$(systemctl show --property="$property" --value -- "$unit" 2>/dev/null || true)"
+ printf '%s\n' "${value:-unknown}"
+}
+
+bootstrap_state() {
+ local bootstrap_load_state bootstrap_active_state bootstrap_sub_state
+ local marker_dir_metadata marker_metadata marker_size marker_payload
+
+ if [[ -f "$bootstrap_marker" && ! -L "$bootstrap_marker" ]]; then
+ marker_dir_metadata="$(stat -c '%u:%g:%a:%F' -- "$(dirname -- "$bootstrap_marker")" 2>/dev/null || true)"
+ marker_metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$bootstrap_marker" 2>/dev/null || true)"
+ marker_size="$(stat -c '%s' -- "$bootstrap_marker" 2>/dev/null || true)"
+ marker_payload=""
+ IFS= read -r marker_payload <"$bootstrap_marker" || true
+ if [[ "$marker_dir_metadata" == "0:0:755:directory" &&
+ "$marker_metadata" == "0:0:644:1:regular file" &&
+ "$marker_size" == "6" && "$marker_payload" == "ready" ]]; then
+ echo complete
+ return 0
+ fi
+ fi
+
+ bootstrap_load_state="$(unit_value cloud-compose-bootstrap.service LoadState)"
+ if [[ "$bootstrap_load_state" == "loaded" ]]; then
+ bootstrap_active_state="$(unit_value cloud-compose-bootstrap.service ActiveState)"
+ bootstrap_sub_state="$(unit_value cloud-compose-bootstrap.service SubState)"
+ case "${bootstrap_active_state}:${bootstrap_sub_state}" in
+ active:* | activating:* | *:auto-restart)
+ echo active
+ return 0
+ ;;
+ esac
+ elif [[ "$bootstrap_load_state" == "not-found" ]] &&
+ systemctl is-active --quiet cloud-compose.service; then
+ # Compatibility with releases that predate the retryable bootstrap
+ # unit and durable readiness marker.
+ echo complete
+ return 0
+ fi
+
+ if systemctl is-active --quiet cloud-final.service; then
+ echo active
+ return 0
+ fi
+ if pgrep -f -- "$process_pattern" >/dev/null; then
+ echo active
+ return 0
+ fi
+
+ echo idle
+ return 1
+}
+
+unit_heartbeat() {
+ local unit main_pid
+
+ for unit in cloud-final.service cloud-compose-bootstrap.service cloud-compose.service; do
+ echo "--- ${unit} state ---"
+ systemctl show --no-pager \
+ --property=LoadState \
+ --property=ActiveState \
+ --property=SubState \
+ --property=Result \
+ --property=MainPID \
+ --property=ExecMainStatus \
+ -- "$unit" 2>&1 || true
+ main_pid="$(unit_value "$unit" MainPID)"
+ if [[ "$main_pid" =~ ^[1-9][0-9]*$ ]]; then
+ ps -p "$main_pid" -o pid=,ppid=,stat=,etime=,comm= 2>/dev/null || true
+ fi
+ done
+
+ echo "--- active bootstrap process state ---"
+ while IFS= read -r process_id; do
+ [[ "$process_id" =~ ^[1-9][0-9]*$ ]] || continue
+ ps -p "$process_id" -o pid=,ppid=,stat=,etime=,comm= 2>/dev/null || true
+ done < <(pgrep -f -- "$process_pattern" 2>/dev/null || true)
+}
+
+diagnostic_status() {
+ local cloud_init_status=0 state
+
+ echo "--- Cloud Compose provisioning heartbeat ---"
+ date -u '+%Y-%m-%dT%H:%M:%SZ'
+ echo "--- cloud-init status ---"
+ if command -v cloud-init >/dev/null 2>&1; then
+ cloud-init status --long || cloud_init_status=$?
+ else
+ echo "cloud-init not installed"
+ fi
+ unit_heartbeat
+ state="$(bootstrap_state 2>/dev/null || true)"
+ echo "bootstrap-state: ${state:-unknown}"
+ return "$cloud_init_status"
+}
+
+tail_regular_file() {
+ local label="$1" path="$2" lines="$3"
+
+ echo "--- ${label} ---"
+ if [[ -f "$path" && ! -L "$path" ]]; then
+ tail -n "$lines" -- "$path" 2>&1 || true
+ else
+ echo "${label} is not present as a regular file"
+ fi
+}
+
+dump_compose_state() {
+ local manifest="/home/cloud-compose/compose-projects.json"
+ local docker_path encoded row app project_dir
+
+ echo "--- docker ps ---"
+ docker_path="$(command -v docker || true)"
+ if [[ -z "$docker_path" ]]; then
+ echo "docker is not installed"
+ return 0
+ fi
+ "$docker_path" ps -a 2>&1 || true
+
+ echo "--- docker compose project state ---"
+ if [[ ! -f "$manifest" || -L "$manifest" ]] ||
+ ! jq -e -f "$jq_program_dir/diagnostics-validate-compose-projects.jq" \
+ "$manifest" >/dev/null 2>&1; then
+ echo "Compose project manifest is unavailable or invalid"
+ return 0
+ fi
+
+ while IFS= read -r encoded; do
+ [[ -n "$encoded" ]] || continue
+ row="$(printf '%s' "$encoded" | base64 -d)" || continue
+ app="$(jq -er -f "$jq_program_dir/diagnostics-entry-app.jq" <<<"$row" 2>/dev/null || true)"
+ project_dir="$(jq -er -f "$jq_program_dir/diagnostics-entry-project-dir.jq" <<<"$row" 2>/dev/null || true)"
+ if [[ -z "$app" || -z "$project_dir" || ! -d "$project_dir" || -L "$project_dir" ]]; then
+ echo "Skipping unavailable or unsafe Compose project: ${app:-unknown}"
+ continue
+ fi
+ echo "--- docker compose ps: ${app} ---"
+ # COS mounts /home with noexec. Select the verified plugin copy on the
+ # executable data disk instead of allowing Docker to discover the
+ # compatibility copy below /home/cloud-compose/.docker.
+ runuser -u cloud-compose -- env \
+ HOME=/home/cloud-compose \
+ DOCKER_CONFIG=/mnt/disks/data/docker-config \
+ "$docker_path" compose --project-directory "$project_dir" ps 2>&1 || true
+ done < <(jq -r -f "$jq_program_dir/diagnostics-project-entries.jq" "$manifest")
+}
+
+diagnostic_dump() {
+ diagnostic_status || true
+ tail_regular_file "/var/log/cloud-init-output.log" "/var/log/cloud-init-output.log" 400
+ tail_regular_file "/var/log/cloud-init.log" "/var/log/cloud-init.log" 400
+ echo "--- cloud-init runcmd ---"
+ if [[ -f /var/lib/cloud/instance/scripts/runcmd &&
+ ! -L /var/lib/cloud/instance/scripts/runcmd ]]; then
+ sed -n '1,240p' /var/lib/cloud/instance/scripts/runcmd 2>&1 || true
+ else
+ echo "cloud-init runcmd is not present as a regular file"
+ fi
+ echo "--- cloud-compose bootstrap unit ---"
+ journalctl -u cloud-compose-bootstrap --no-pager -n 400 2>&1 || true
+ tail_regular_file "legacy cloud-compose bootstrap log" "/home/cloud-compose/run.log" 400
+ echo "--- cloud-compose unit ---"
+ journalctl -u cloud-compose --no-pager -n 300 2>&1 || true
+ echo "--- lifecycle lock permissions ---"
+ stat -Lc '%A %a %U:%G %u:%g %n' \
+ /run/lock/cloud-compose \
+ /run/lock/cloud-compose/lifecycle.lock 2>&1 || true
+ dump_compose_state
+}
+
+main() {
+ require_root
+ if [[ "$#" -ne 1 ]]; then
+ usage
+ return 2
+ fi
+
+ case "$1" in
+ state) bootstrap_state ;;
+ status) diagnostic_status ;;
+ dump) diagnostic_dump ;;
+ *)
+ usage
+ return 2
+ ;;
+ esac
+}
+
+main "$@"
diff --git a/rootfs/etc/cloud-compose/jq/application-env-validate.jq b/rootfs/etc/cloud-compose/jq/application-env-validate.jq
new file mode 100644
index 0000000..6c8b533
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/application-env-validate.jq
@@ -0,0 +1,6 @@
+type == "object" and
+all(to_entries[];
+ (.key | explode | index(0) == null) and
+ (.value | type == "string") and
+ (.value | explode | index(0) == null)
+)
diff --git a/rootfs/etc/cloud-compose/jq/array-values-base64.jq b/rootfs/etc/cloud-compose/jq/array-values-base64.jq
new file mode 100644
index 0000000..fec3d58
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/array-values-base64.jq
@@ -0,0 +1 @@
+.[] | @base64
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-array.jq b/rootfs/etc/cloud-compose/jq/compose-app-array.jq
new file mode 100644
index 0000000..bf25de2
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/compose-app-array.jq
@@ -0,0 +1,6 @@
+(.[$app][$field] // []) as $values |
+if ($values | type) != "array" or any($values[]; type != "string") then
+ error("invalid string array")
+else
+ $values
+end
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-field.jq b/rootfs/etc/cloud-compose/jq/compose-app-field.jq
new file mode 100644
index 0000000..e5aa9b8
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/compose-app-field.jq
@@ -0,0 +1,6 @@
+(.[$app][$field] // "" | tostring) as $value |
+if $value | (explode | index(0) != null) or contains("\n") or contains("\r") then
+ error("invalid scalar field")
+else
+ $value
+end
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-ingress-array.jq b/rootfs/etc/cloud-compose/jq/compose-app-ingress-array.jq
new file mode 100644
index 0000000..d7dfc3f
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/compose-app-ingress-array.jq
@@ -0,0 +1,6 @@
+(.[$app].ingress[$field] // []) as $values |
+if ($values | type) != "array" or any($values[]; type != "string") then
+ error("invalid ingress string array")
+else
+ $values
+end
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-ingress-field.jq b/rootfs/etc/cloud-compose/jq/compose-app-ingress-field.jq
new file mode 100644
index 0000000..8f9424c
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/compose-app-ingress-field.jq
@@ -0,0 +1,6 @@
+(.[$app].ingress[$field] // "" | tostring) as $value |
+if $value | (explode | index(0) != null) or contains("\n") or contains("\r") then
+ error("invalid ingress scalar field")
+else
+ $value
+end
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-verify-args-json.jq b/rootfs/etc/cloud-compose/jq/compose-app-verify-args-json.jq
new file mode 100644
index 0000000..272f433
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/compose-app-verify-args-json.jq
@@ -0,0 +1,8 @@
+(.[$app].sitectl_verify_args // []) as $values |
+if ($values | type) != "array" or any($values[];
+ type != "string" or (explode | index(0) != null) or contains("\n") or contains("\r")
+) then
+ error("invalid verify args")
+else
+ $values
+end
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-verify-args.jq b/rootfs/etc/cloud-compose/jq/compose-app-verify-args.jq
new file mode 100644
index 0000000..bb4a258
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/compose-app-verify-args.jq
@@ -0,0 +1,6 @@
+(.[$app].sitectl_verify_args // []) as $values |
+if ($values | type) != "array" or any($values[]; type != "string") then
+ error("invalid verify args")
+else
+ $values | join(" ")
+end
diff --git a/rootfs/etc/cloud-compose/jq/compose-project-dirs-base64.jq b/rootfs/etc/cloud-compose/jq/compose-project-dirs-base64.jq
new file mode 100644
index 0000000..f9f67fe
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/compose-project-dirs-base64.jq
@@ -0,0 +1 @@
+.[] | .project_dir | @base64
diff --git a/rootfs/etc/cloud-compose/jq/compose-reject-host-network.jq b/rootfs/etc/cloud-compose/jq/compose-reject-host-network.jq
new file mode 100644
index 0000000..c8d9c8d
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/compose-reject-host-network.jq
@@ -0,0 +1,12 @@
+any(.services[];
+ (.network_mode // "") == "host" or
+ (
+ (.build | type) == "object" and
+ (
+ (.build.network // "") == "host" or
+ any((.build.entitlements // [])[];
+ . == "network.host" or . == "security.insecure"
+ )
+ )
+ )
+)
diff --git a/rootfs/etc/cloud-compose/jq/compose-services-object-validate.jq b/rootfs/etc/cloud-compose/jq/compose-services-object-validate.jq
new file mode 100644
index 0000000..696aa4b
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/compose-services-object-validate.jq
@@ -0,0 +1 @@
+.services | type == "object"
diff --git a/rootfs/etc/cloud-compose/jq/compose-validate-projects.jq b/rootfs/etc/cloud-compose/jq/compose-validate-projects.jq
new file mode 100644
index 0000000..99e1efd
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/compose-validate-projects.jq
@@ -0,0 +1,26 @@
+type == "object" and length > 0 and
+all(to_entries[]; . as $entry |
+ ($entry.key | explode | index(0) == null) and
+ ($entry.value | type == "object") and
+ (all($entry.value | .. | select(type == "string");
+ explode | index(0) == null)) and
+ ($entry.value.docker_compose_repo | type == "string" and length > 0) and
+ ($entry.value.docker_compose_branch | type == "string" and length > 0) and
+ ($entry.value.project_dir | type == "string" and length > 0) and
+ ($entry.value.compose_project_name | type == "string" and length > 0) and
+ (all(["init_commands", "up_commands", "down_commands", "rollout_commands"][];
+ . as $field |
+ ($entry.value[$field] == null) or
+ (($entry.value[$field] | type) == "array" and
+ all($entry.value[$field][]; type == "string"))
+ )) and
+ (($entry.value.sitectl_verify_args == null) or
+ (($entry.value.sitectl_verify_args | type) == "array" and
+ all($entry.value.sitectl_verify_args[];
+ type == "string" and
+ (explode | index(0) == null) and
+ (contains("\n") | not) and
+ (contains("\r") | not)
+ ))) and
+ (($entry.value.ingress == null) or ($entry.value.ingress | type == "object"))
+)
diff --git a/rootfs/etc/cloud-compose/jq/diagnostics-entry-app.jq b/rootfs/etc/cloud-compose/jq/diagnostics-entry-app.jq
new file mode 100644
index 0000000..edb6cdb
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/diagnostics-entry-app.jq
@@ -0,0 +1 @@
+.key | select(type == "string" and length > 0)
diff --git a/rootfs/etc/cloud-compose/jq/diagnostics-entry-project-dir.jq b/rootfs/etc/cloud-compose/jq/diagnostics-entry-project-dir.jq
new file mode 100644
index 0000000..f0b9664
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/diagnostics-entry-project-dir.jq
@@ -0,0 +1 @@
+.value.project_dir | select(type == "string" and startswith("/mnt/disks/data/"))
diff --git a/rootfs/etc/cloud-compose/jq/diagnostics-project-entries.jq b/rootfs/etc/cloud-compose/jq/diagnostics-project-entries.jq
new file mode 100644
index 0000000..4785439
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/diagnostics-project-entries.jq
@@ -0,0 +1 @@
+to_entries[] | @base64
diff --git a/rootfs/etc/cloud-compose/jq/diagnostics-validate-compose-projects.jq b/rootfs/etc/cloud-compose/jq/diagnostics-validate-compose-projects.jq
new file mode 100644
index 0000000..a25876a
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/diagnostics-validate-compose-projects.jq
@@ -0,0 +1 @@
+type == "object"
diff --git a/rootfs/etc/cloud-compose/jq/dr-backup-completed-at.jq b/rootfs/etc/cloud-compose/jq/dr-backup-completed-at.jq
new file mode 100644
index 0000000..9822734
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/dr-backup-completed-at.jq
@@ -0,0 +1 @@
+.completed_at
diff --git a/rootfs/etc/cloud-compose/jq/dr-backup-remote-id.jq b/rootfs/etc/cloud-compose/jq/dr-backup-remote-id.jq
new file mode 100644
index 0000000..f142fb1
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/dr-backup-remote-id.jq
@@ -0,0 +1 @@
+.remote_id
diff --git a/rootfs/etc/cloud-compose/jq/dr-restore-completed-at.jq b/rootfs/etc/cloud-compose/jq/dr-restore-completed-at.jq
new file mode 100644
index 0000000..9822734
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/dr-restore-completed-at.jq
@@ -0,0 +1 @@
+.completed_at
diff --git a/rootfs/etc/cloud-compose/jq/dr-restore-recovery-id.jq b/rootfs/etc/cloud-compose/jq/dr-restore-recovery-id.jq
new file mode 100644
index 0000000..fcaabb5
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/dr-restore-recovery-id.jq
@@ -0,0 +1 @@
+.recovery_id
diff --git a/rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq b/rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq
new file mode 100644
index 0000000..628e5a3
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq
@@ -0,0 +1,16 @@
+type == "object" and length == 10 and
+.schema_version == 1 and
+.kind == "cloud-compose.offhost-backup-receipt" and
+.operation_id == $operation_id and
+.manifest_sha256 == $manifest_sha256 and
+.status == "succeeded" and
+.encrypted == true and
+.off_host == true and
+(.completed_at | type == "string" and length == 20 and
+ (explode | all(.[]; . >= 32 and . != 127))) and
+(.remote_id | type == "string" and length >= 1 and length <= 512 and
+ (explode | all(.[]; . >= 32 and . != 127))) and
+(.coverage | type == "object" and length == 3 and
+ .database == true and
+ .application_files == true and
+ .volume_topology == true)
diff --git a/rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq b/rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq
new file mode 100644
index 0000000..461bd52
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq
@@ -0,0 +1,19 @@
+type == "object" and length == 13 and
+.schema_version == 1 and
+.kind == "cloud-compose.restore-test-proof" and
+.test_id == $test_id and
+.source_manifest_sha256 == $manifest_sha256 and
+.source_receipt_sha256 == $receipt_sha256 and
+.status == "succeeded" and
+.disposable_recovery == true and
+.recovery_destroyed == true and
+.integrity_verified == true and
+(.completed_at | type == "string" and length == 20 and
+ (explode | all(.[]; . >= 32 and . != 127))) and
+(.recovery_id | type == "string" and length >= 1 and length <= 512 and
+ (explode | all(.[]; . >= 32 and . != 127))) and
+(.coverage | type == "object" and length == 3 and
+ .database == true and
+ .application_files == true and
+ .volume_topology == true) and
+(.source_encrypted == true)
diff --git a/rootfs/etc/cloud-compose/jq/gcp-metadata-private-ip.jq b/rootfs/etc/cloud-compose/jq/gcp-metadata-private-ip.jq
new file mode 100644
index 0000000..4693313
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/gcp-metadata-private-ip.jq
@@ -0,0 +1 @@
+.instance.networkInterfaces[0].ip
diff --git a/rootfs/etc/cloud-compose/jq/gcp-metadata-public-ip.jq b/rootfs/etc/cloud-compose/jq/gcp-metadata-public-ip.jq
new file mode 100644
index 0000000..2a84839
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/gcp-metadata-public-ip.jq
@@ -0,0 +1 @@
+.instance.networkInterfaces[0].accessConfigs[0].externalIp
diff --git a/rootfs/etc/cloud-compose/jq/github-latest-release-tag.jq b/rootfs/etc/cloud-compose/jq/github-latest-release-tag.jq
new file mode 100644
index 0000000..42f0fd4
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/github-latest-release-tag.jq
@@ -0,0 +1,2 @@
+(.tag_name | select(type == "string" and length > 0 and (explode | index(0) == null))),
+"\u001f"
diff --git a/rootfs/etc/cloud-compose/jq/json-length.jq b/rootfs/etc/cloud-compose/jq/json-length.jq
new file mode 100644
index 0000000..7f5e3b6
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/json-length.jq
@@ -0,0 +1 @@
+length
diff --git a/rootfs/etc/cloud-compose/jq/json-object-validate.jq b/rootfs/etc/cloud-compose/jq/json-object-validate.jq
new file mode 100644
index 0000000..a25876a
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/json-object-validate.jq
@@ -0,0 +1 @@
+type == "object"
diff --git a/rootfs/etc/cloud-compose/jq/nonempty-string-field.jq b/rootfs/etc/cloud-compose/jq/nonempty-string-field.jq
new file mode 100644
index 0000000..58b565d
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/nonempty-string-field.jq
@@ -0,0 +1 @@
+.[$field] | select(type == "string" and length > 0)
diff --git a/rootfs/etc/cloud-compose/jq/object-entries-sorted-base64.jq b/rootfs/etc/cloud-compose/jq/object-entries-sorted-base64.jq
new file mode 100644
index 0000000..617be57
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/object-entries-sorted-base64.jq
@@ -0,0 +1 @@
+to_entries | sort_by(.key)[] | @base64
diff --git a/rootfs/etc/cloud-compose/jq/object-field-delimited.jq b/rootfs/etc/cloud-compose/jq/object-field-delimited.jq
new file mode 100644
index 0000000..d1b8407
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/object-field-delimited.jq
@@ -0,0 +1 @@
+(.[$field]), "\u001f"
diff --git a/rootfs/etc/cloud-compose/jq/object-field.jq b/rootfs/etc/cloud-compose/jq/object-field.jq
new file mode 100644
index 0000000..fff061d
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/object-field.jq
@@ -0,0 +1 @@
+.[$field]
diff --git a/rootfs/etc/cloud-compose/jq/object-has-key.jq b/rootfs/etc/cloud-compose/jq/object-has-key.jq
new file mode 100644
index 0000000..f9943a1
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/object-has-key.jq
@@ -0,0 +1 @@
+has($key)
diff --git a/rootfs/etc/cloud-compose/jq/object-keys-base64.jq b/rootfs/etc/cloud-compose/jq/object-keys-base64.jq
new file mode 100644
index 0000000..2975208
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/object-keys-base64.jq
@@ -0,0 +1 @@
+keys[] | @base64
diff --git a/rootfs/etc/cloud-compose/jq/object-keys.jq b/rootfs/etc/cloud-compose/jq/object-keys.jq
new file mode 100644
index 0000000..d7d2124
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/object-keys.jq
@@ -0,0 +1 @@
+keys[]
diff --git a/rootfs/etc/cloud-compose/jq/offhost-bind-sources.jq b/rootfs/etc/cloud-compose/jq/offhost-bind-sources.jq
new file mode 100644
index 0000000..880c92b
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/offhost-bind-sources.jq
@@ -0,0 +1 @@
+.services[].volumes[]? | select(.type == "bind") | .source
diff --git a/rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq b/rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq
new file mode 100644
index 0000000..77633bd
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq
@@ -0,0 +1,27 @@
+{
+ name: $app,
+ databases: [{
+ engine: "mariadb",
+ format: "sql.gz",
+ local_recovery_artifact: $dump_path,
+ sha256: $dump_sha256,
+ bytes: $dump_bytes
+ }],
+ application_files: {
+ roots: [$project_dir],
+ bind_mounts: [
+ .services | to_entries[] as $service |
+ ($service.value.volumes // [])[] |
+ select(.type == "bind") |
+ {service: $service.key, source: .source, target: .target, read_only: (.read_only // false)}
+ ] | sort_by(.service, .source, .target)
+ },
+ volume_topology: {
+ declared_named_volumes: ((.volumes // {}) | keys | sort),
+ service_mounts: [
+ .services | to_entries[] as $service |
+ ($service.value.volumes // [])[] |
+ {service: $service.key, type: .type, source: (.source // ""), target: .target, read_only: (.read_only // false)}
+ ] | sort_by(.service, .type, .source, .target)
+ }
+}
diff --git a/rootfs/etc/cloud-compose/jq/offhost-build-manifest.jq b/rootfs/etc/cloud-compose/jq/offhost-build-manifest.jq
new file mode 100644
index 0000000..a1d4451
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/offhost-build-manifest.jq
@@ -0,0 +1,10 @@
+{
+ schema_version: 1,
+ kind: "cloud-compose.offhost-backup-manifest",
+ operation_id: $operation_id,
+ backup_date: $backup_date,
+ provider: $provider,
+ instance: $instance,
+ required_coverage: ["database", "application_files", "volume_topology"],
+ applications: (sort_by(.name))
+}
diff --git a/rootfs/etc/cloud-compose/jq/offhost-manifest-app-digests.jq b/rootfs/etc/cloud-compose/jq/offhost-manifest-app-digests.jq
new file mode 100644
index 0000000..c3cef5b
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/offhost-manifest-app-digests.jq
@@ -0,0 +1 @@
+.applications[] | [.name, .databases[0].sha256] | @tsv
diff --git a/rootfs/etc/cloud-compose/jq/offhost-validate-bind-roots.jq b/rootfs/etc/cloud-compose/jq/offhost-validate-bind-roots.jq
new file mode 100644
index 0000000..3d8ac7b
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/offhost-validate-bind-roots.jq
@@ -0,0 +1,8 @@
+all(.services[].volumes[]?;
+ .type != "bind" or
+ (.source | type == "string" and
+ (. == $data_root or startswith($data_root + "/") or
+ . == $volumes_root or startswith($volumes_root + "/")) and
+ (explode | all(.[]; . >= 32 and . != 127)) and
+ (contains("//") | not) and
+ length > 0))
diff --git a/rootfs/etc/cloud-compose/jq/offhost-validate-compose-config.jq b/rootfs/etc/cloud-compose/jq/offhost-validate-compose-config.jq
new file mode 100644
index 0000000..12b0534
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/offhost-validate-compose-config.jq
@@ -0,0 +1,11 @@
+type == "object" and
+(.services | type == "object" and length > 0) and
+all(.services | to_entries[];
+ ((.value.volumes // []) | type == "array") and
+ all((.value.volumes // [])[];
+ type == "object" and
+ (.type | type == "string") and
+ (.type == "volume" or .type == "bind" or .type == "tmpfs") and
+ ((.source // "") | type == "string") and
+ ((.target // "") | type == "string" and length > 0))) and
+((.volumes // {}) | type == "object")
diff --git a/rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq b/rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq
new file mode 100644
index 0000000..f196ef2
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq
@@ -0,0 +1,14 @@
+.schema_version == 1 and
+.kind == "cloud-compose.offhost-backup-manifest" and
+(.applications | type == "array" and length == $app_count) and
+all(.applications[];
+ (.name | type == "string" and length >= 1 and length <= 63 and
+ (explode | all(.[]; . >= 32 and . != 127))) and
+ (.databases | length == 1) and
+ (.databases[0].sha256 | type == "string" and length == 64 and
+ (explode | all(.[]; . >= 32 and . != 127))) and
+ (.databases[0].bytes | type == "number" and . > 0) and
+ (.application_files.roots | type == "array" and length > 0) and
+ (.application_files.bind_mounts | type == "array") and
+ (.volume_topology.declared_named_volumes | type == "array") and
+ (.volume_topology.service_mounts | type == "array"))
diff --git a/rootfs/etc/cloud-compose/jq/rotation-audit.jq b/rootfs/etc/cloud-compose/jq/rotation-audit.jq
new file mode 100644
index 0000000..6eeb3a4
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-audit.jq
@@ -0,0 +1,12 @@
+{
+ version: 1,
+ phase: $phase,
+ current_key_id: $current_key_id,
+ new_key_id: $new_key_id,
+ recovery_required: ($phase == "creating" or $phase == "creating-fresh"),
+ candidate_key_ids: [$candidate_names[] | split("/")[-1]],
+ created_at: $created_at,
+ ready_at: $ready_at,
+ disabled_at: $disabled_at,
+ grace_remaining_seconds: $grace_remaining
+}
diff --git a/rootfs/etc/cloud-compose/jq/rotation-build-state.jq b/rootfs/etc/cloud-compose/jq/rotation-build-state.jq
new file mode 100644
index 0000000..fd619a2
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-build-state.jq
@@ -0,0 +1,14 @@
+{
+ version: 2,
+ phase: $phase,
+ service_account: $service_account,
+ project_id: $project_id,
+ credentials_file: $credentials_file,
+ current_key_id: $current_key_id,
+ new_key_id: $new_key_id,
+ new_key_name: $new_key_name,
+ baseline_key_names: $baseline_key_names,
+ created_at: $created_at,
+ ready_at: $ready_at,
+ disabled_at: $disabled_at
+}
diff --git a/rootfs/etc/cloud-compose/jq/rotation-first-key-id.jq b/rootfs/etc/cloud-compose/jq/rotation-first-key-id.jq
new file mode 100644
index 0000000..34529ca
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-first-key-id.jq
@@ -0,0 +1 @@
+.[0] | split("/")[-1]
diff --git a/rootfs/etc/cloud-compose/jq/rotation-idle-audit.jq b/rootfs/etc/cloud-compose/jq/rotation-idle-audit.jq
new file mode 100644
index 0000000..9c9b20e
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-idle-audit.jq
@@ -0,0 +1,7 @@
+{
+ version: 1,
+ phase: "idle",
+ recovery_required: false,
+ candidate_key_ids: [],
+ grace_remaining_seconds: 0
+}
diff --git a/rootfs/etc/cloud-compose/jq/rotation-jwt-claims.jq b/rootfs/etc/cloud-compose/jq/rotation-jwt-claims.jq
new file mode 100644
index 0000000..e431aa8
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-jwt-claims.jq
@@ -0,0 +1,7 @@
+{
+ iss: $iss,
+ scope: "https://www.googleapis.com/auth/cloud-platform",
+ aud: $aud,
+ iat: $iat,
+ exp: ($iat + 3600)
+}
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-disabled.jq b/rootfs/etc/cloud-compose/jq/rotation-key-disabled.jq
new file mode 100644
index 0000000..9ce9b69
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-key-disabled.jq
@@ -0,0 +1 @@
+any(.[]; .name == $name and .disabled == true)
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-ids-join.jq b/rootfs/etc/cloud-compose/jq/rotation-key-ids-join.jq
new file mode 100644
index 0000000..1ede644
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-key-ids-join.jq
@@ -0,0 +1 @@
+[.[] | split("/")[-1]] | join(", ")
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-names-base64.jq b/rootfs/etc/cloud-compose/jq/rotation-key-names-base64.jq
new file mode 100644
index 0000000..9ee5619
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-key-names-base64.jq
@@ -0,0 +1 @@
+.[].name | @base64
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-names-sorted.jq b/rootfs/etc/cloud-compose/jq/rotation-key-names-sorted.jq
new file mode 100644
index 0000000..7704945
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-key-names-sorted.jq
@@ -0,0 +1 @@
+[.[].name] | sort
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-object-ids-join.jq b/rootfs/etc/cloud-compose/jq/rotation-key-object-ids-join.jq
new file mode 100644
index 0000000..d9ce6ea
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-key-object-ids-join.jq
@@ -0,0 +1 @@
+[.[].name | split("/")[-1]] | sort | join(", ")
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-present.jq b/rootfs/etc/cloud-compose/jq/rotation-key-present.jq
new file mode 100644
index 0000000..cd6febc
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-key-present.jq
@@ -0,0 +1 @@
+any(.[]; .name == $name)
diff --git a/rootfs/etc/cloud-compose/jq/rotation-new-key-names.jq b/rootfs/etc/cloud-compose/jq/rotation-new-key-names.jq
new file mode 100644
index 0000000..60a7881
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-new-key-names.jq
@@ -0,0 +1 @@
+$after - $before | sort
diff --git a/rootfs/etc/cloud-compose/jq/rotation-normalize-user-keys.jq b/rootfs/etc/cloud-compose/jq/rotation-normalize-user-keys.jq
new file mode 100644
index 0000000..685da02
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-normalize-user-keys.jq
@@ -0,0 +1,15 @@
+(.keys // []) as $keys |
+if ($keys | type) != "array" then error("invalid key list") else
+ [$keys[] | select(.keyType == "USER_MANAGED")] as $user_keys |
+ if ($user_keys | length) > 10 then error("too many user-managed keys")
+ elif any($user_keys[];
+ (.name | type) != "string" or
+ (.name | startswith($prefix) | not) or
+ ((.disabled // false) | type) != "boolean")
+ then error("invalid user-managed key")
+ elif ([$user_keys[].name] | unique | length) != ($user_keys | length)
+ then error("duplicate user-managed key")
+ else
+ [$user_keys[] | {name: .name, disabled: (.disabled // false)}] | sort_by(.name)
+ end
+end
diff --git a/rootfs/etc/cloud-compose/jq/rotation-remaining-baseline.jq b/rootfs/etc/cloud-compose/jq/rotation-remaining-baseline.jq
new file mode 100644
index 0000000..cd22ed3
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-remaining-baseline.jq
@@ -0,0 +1 @@
+$after | map(select(. as $name | $before | index($name))) | sort
diff --git a/rootfs/etc/cloud-compose/jq/rotation-validate-state.jq b/rootfs/etc/cloud-compose/jq/rotation-validate-state.jq
new file mode 100644
index 0000000..cf4b1fa
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/rotation-validate-state.jq
@@ -0,0 +1,20 @@
+select(
+ .version == 2 and
+ (.phase == "reconciling" or .phase == "creating-fresh" or
+ .phase == "creating" or .phase == "staged" or .phase == "authenticated" or
+ .phase == "ready" or .phase == "grace" or .phase == "rolling-back" or
+ .phase == "rollback" or .phase == "revoke-new") and
+ .service_account == $service_account and
+ .project_id == $project_id and
+ .credentials_file == $credentials_file and
+ (.current_key_id | type == "string" and (explode | index(0) == null)) and
+ (.new_key_id | type == "string" and (explode | index(0) == null)) and
+ (.new_key_name | type == "string" and (explode | index(0) == null)) and
+ (.baseline_key_names | type == "array") and
+ (.baseline_key_names | length <= 10 and . == (sort | unique)) and
+ all(.baseline_key_names[];
+ type == "string" and (explode | index(0) == null)) and
+ (.created_at | type == "number" and . >= 0 and floor == .) and
+ (.ready_at | type == "number" and . >= 0 and floor == .) and
+ (.disabled_at | type == "number" and . >= 0 and floor == .)
+)
diff --git a/rootfs/etc/cloud-compose/jq/service-account-credentials-valid.jq b/rootfs/etc/cloud-compose/jq/service-account-credentials-valid.jq
new file mode 100644
index 0000000..cccc453
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/service-account-credentials-valid.jq
@@ -0,0 +1,8 @@
+.type == "service_account" and
+.private_key_id == $key_id and
+.client_email == $service_account and
+.project_id == $project_id and
+.token_uri == "https://oauth2.googleapis.com/token" and
+(.private_key | type == "string" and
+ startswith("-----BEGIN PRIVATE KEY-----") and
+ contains("-----END PRIVATE KEY-----"))
diff --git a/rootfs/etc/cloud-compose/jq/service-account-key-id.jq b/rootfs/etc/cloud-compose/jq/service-account-key-id.jq
new file mode 100644
index 0000000..5539109
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/service-account-key-id.jq
@@ -0,0 +1,3 @@
+(.private_key_id |
+ select(type == "string" and length > 0 and (explode | index(0) == null))),
+"\u001f"
diff --git a/rootfs/etc/cloud-compose/jq/sitectl-package-version.jq b/rootfs/etc/cloud-compose/jq/sitectl-package-version.jq
new file mode 100644
index 0000000..42eaeb7
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/sitectl-package-version.jq
@@ -0,0 +1 @@
+.[$package] // $fallback
diff --git a/rootfs/etc/cloud-compose/jq/sitectl-package-versions-validate.jq b/rootfs/etc/cloud-compose/jq/sitectl-package-versions-validate.jq
new file mode 100644
index 0000000..6c8b533
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/sitectl-package-versions-validate.jq
@@ -0,0 +1,6 @@
+type == "object" and
+all(to_entries[];
+ (.key | explode | index(0) == null) and
+ (.value | type == "string") and
+ (.value | explode | index(0) == null)
+)
diff --git a/rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq b/rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq
new file mode 100644
index 0000000..fc24963
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq
@@ -0,0 +1,5 @@
+if type == "array" and all(.[]; type == "string") then
+ .[] | ("x" + @base64)
+else
+ error("sitectl verify arguments must be an array of strings")
+end
diff --git a/rootfs/etc/cloud-compose/jq/string-array-validate.jq b/rootfs/etc/cloud-compose/jq/string-array-validate.jq
new file mode 100644
index 0000000..5083d4c
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/string-array-validate.jq
@@ -0,0 +1 @@
+type == "array" and all(.[]; type == "string")
diff --git a/rootfs/etc/cloud-compose/jq/string-field-valid.jq b/rootfs/etc/cloud-compose/jq/string-field-valid.jq
new file mode 100644
index 0000000..60c5b0d
--- /dev/null
+++ b/rootfs/etc/cloud-compose/jq/string-field-valid.jq
@@ -0,0 +1 @@
+.[$field] | type == "string" and length > 0
diff --git a/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh b/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh
new file mode 100755
index 0000000..c0ba4fb
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+
+set -uo pipefail
+
+# shellcheck disable=SC1091
+if ! source /etc/cloud-compose/libexec/bootstrap-security.sh; then
+ echo "Cloud Compose bootstrap security helper could not be loaded" >&2
+ exit 255
+fi
+
+if ! cloud_compose_bootstrap_require_root; then
+ exit 255
+fi
+if cloud_compose_bootstrap_marker_ready; then
+ # ExecCondition exit 1 skips an already-complete oneshot without marking it
+ # failed. Missing or invalid evidence returns zero below and must converge.
+ exit 1
+fi
+exit 0
diff --git a/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh b/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh
new file mode 100755
index 0000000..192f8aa
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh
@@ -0,0 +1,124 @@
+#!/usr/bin/env bash
+
+readonly CLOUD_COMPOSE_BOOTSTRAP_MARKER="/var/lib/cloud-compose/bootstrap-complete"
+readonly CLOUD_COMPOSE_RUNTIME_HOME="/home/cloud-compose"
+
+cloud_compose_bootstrap_require_root() {
+ if ((EUID != 0)); then
+ echo "Cloud Compose bootstrap control must run as root" >&2
+ return 1
+ fi
+}
+
+cloud_compose_bootstrap_marker_ready() {
+ local marker="${1:-$CLOUD_COMPOSE_BOOTSTRAP_MARKER}"
+ local marker_dir marker_dir_metadata marker_metadata marker_size payload
+
+ marker_dir="$(dirname -- "$marker")"
+ [[ -d "$marker_dir" && ! -L "$marker_dir" && -f "$marker" && ! -L "$marker" ]] || return 1
+ marker_dir_metadata="$(stat -c '%u:%g:%a:%F' -- "$marker_dir")" || return 1
+ marker_metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$marker")" || return 1
+ marker_size="$(stat -c '%s' -- "$marker")" || return 1
+ [[ "$marker_dir_metadata" == "0:0:755:directory" &&
+ "$marker_metadata" == "0:0:644:1:regular file" &&
+ "$marker_size" == "6" ]] || return 1
+ IFS= read -r payload <"$marker" || return 1
+ [[ "$payload" == "ready" ]]
+}
+
+cloud_compose_secure_runtime_home() {
+ local home_metadata program dispatcher metadata owner_uid group_gid mode links kind
+ local -a required_programs programs dispatchers control_inputs
+
+ cloud_compose_bootstrap_require_root || return 1
+ if [[ -L "$CLOUD_COMPOSE_RUNTIME_HOME" || ! -d "$CLOUD_COMPOSE_RUNTIME_HOME" ]]; then
+ echo "Cloud Compose runtime home is missing or redirected" >&2
+ return 1
+ fi
+ home_metadata="$(stat -c '%a:%F' -- "$CLOUD_COMPOSE_RUNTIME_HOME")" || return 1
+ if [[ ! "$home_metadata" =~ ^[0-7]{3,4}:directory$ ]]; then
+ echo "Cloud Compose runtime home is not a real directory" >&2
+ return 1
+ fi
+
+ # Close the historical user-owned parent boundary before inspecting any
+ # program beneath it. A replacement file retains its non-root ownership and
+ # is rejected below; a now-unwritable parent prevents another replacement.
+ chown 0:0 "$CLOUD_COMPOSE_RUNTIME_HOME" || return 1
+ chmod 0755 "$CLOUD_COMPOSE_RUNTIME_HOME" || return 1
+
+ required_programs=(
+ "$CLOUD_COMPOSE_RUNTIME_HOME/run.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/profile.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/bootstrap-helpers.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/host-conf.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/host-init.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/converge-app-filesystems.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/default-lifecycle.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/prepare-app-sources.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/rotate-keys-daily.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/vault-agent-init.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/app-init.sh"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/init"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/up"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/down"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/rollout"
+ )
+ for program in "${required_programs[@]}"; do
+ if [[ -L "$program" || ! -f "$program" ]]; then
+ echo "Required Cloud Compose bootstrap program is missing or redirected: $program" >&2
+ return 1
+ fi
+ done
+
+ shopt -s nullglob
+ programs=("$CLOUD_COMPOSE_RUNTIME_HOME"/*.sh)
+ shopt -u nullglob
+ for program in "${programs[@]}"; do
+ if [[ -L "$program" || ! -f "$program" ]]; then
+ echo "Cloud Compose bootstrap program is not a regular file: $program" >&2
+ return 1
+ fi
+ metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$program")" || return 1
+ IFS=: read -r owner_uid group_gid mode links kind <<<"$metadata"
+ if [[ "$owner_uid" != "0" || "$group_gid" != "0" || "$links" != "1" || "$kind" != "regular file" ||
+ ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
+ echo "Cloud Compose bootstrap program is not root-controlled: $program" >&2
+ return 1
+ fi
+ chown 0:0 "$program" || return 1
+ chmod 0755 "$program" || return 1
+ done
+
+ control_inputs=(
+ "$CLOUD_COMPOSE_RUNTIME_HOME/.env"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/compose-projects.json"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/application-env.json"
+ "$CLOUD_COMPOSE_RUNTIME_HOME/managed-runtime-artifacts.tsv"
+ )
+ for program in "${control_inputs[@]}"; do
+ if [[ -L "$program" || ! -f "$program" ]]; then
+ echo "Required Cloud Compose control input is missing or redirected: $program" >&2
+ return 1
+ fi
+ metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$program")" || return 1
+ IFS=: read -r owner_uid group_gid mode links kind <<<"$metadata"
+ if [[ "$owner_uid" != "0" || "$links" != "1" || "$kind" != "regular file" ||
+ ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
+ echo "Cloud Compose control input is not root-controlled: $program" >&2
+ return 1
+ fi
+ done
+
+ dispatchers=(init up down rollout)
+ for dispatcher in "${dispatchers[@]}"; do
+ program="$CLOUD_COMPOSE_RUNTIME_HOME/$dispatcher"
+ metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$program")" || return 1
+ IFS=: read -r owner_uid group_gid mode links kind <<<"$metadata"
+ if [[ "$owner_uid" != "0" || "$links" != "1" || "$kind" != "regular file" ||
+ ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
+ echo "Cloud Compose lifecycle dispatcher is not root-controlled: $program" >&2
+ return 1
+ fi
+ done
+}
diff --git a/rootfs/etc/cloud-compose/libexec/build-cos-make.sh b/rootfs/etc/cloud-compose/libexec/build-cos-make.sh
new file mode 100755
index 0000000..f4f091a
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/build-cos-make.sh
@@ -0,0 +1,46 @@
+#!/bin/sh
+
+set -eux
+
+MAKE_VERSION="4.4.1"
+MAKE_SHA256="dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3"
+
+# A single Alpine CDN outage must not make a healthy VM replacement fail.
+# These are HTTPS endpoints from the Alpine official mirror list; apk still
+# verifies the signed indexes and packages with the keys baked into the pinned
+# image.
+alpine_mirrors="
+ https://dl-cdn.alpinelinux.org/alpine
+ https://mirror.math.princeton.edu/pub/alpinelinux
+ https://mirror.fel.cvut.cz/alpine
+"
+packages_installed=false
+for alpine_mirror in ${alpine_mirrors}; do
+ printf "%s\n%s\n" \
+ "${alpine_mirror}/v3.22/main" \
+ "${alpine_mirror}/v3.22/community" \
+ >/etc/apk/repositories
+ rm -f /var/cache/apk/*
+ if apk update && apk add build-base curl make tar; then
+ packages_installed=true
+ break
+ fi
+ echo "Alpine package mirror failed: ${alpine_mirror}" >&2
+done
+if [ "${packages_installed}" != true ]; then
+ echo "All configured Alpine package mirrors failed" >&2
+ exit 1
+fi
+
+curl -fsSL --proto "=https" --proto-redir "=https" --tlsv1.2 \
+ --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 900 \
+ --connect-timeout 10 --max-time 300 \
+ "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
+install -m 0755 make /out/.cloud-compose-make.pending
+/out/.cloud-compose-make.pending --version | grep -Fqm 1 "GNU Make ${MAKE_VERSION}"
+mv -f /out/.cloud-compose-make.pending /out/make
diff --git a/rootfs/etc/cloud-compose/libexec/checked-programs.bash b/rootfs/etc/cloud-compose/libexec/checked-programs.bash
new file mode 100644
index 0000000..c338767
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/checked-programs.bash
@@ -0,0 +1,271 @@
+# Shared trust boundary for jq, AWK, and sourced shell programs consumed by Cloud Compose.
+# This file is sourced from checked runtime scripts; it is not an entrypoint.
+
+cloud_compose_program_owner_path() {
+ local owner_source="$1"
+
+ readlink -f -- "$owner_source"
+}
+
+cloud_compose_validate_root_parent() {
+ local current="$1" metadata owner group mode
+
+ if [[ -L "$current" || ! -d "$current" ]]; then
+ echo "Installed Cloud Compose program parent is missing or redirected: $current" >&2
+ return 1
+ fi
+ metadata="$(stat -c '%u:%g:%a' -- "$current")" || return 1
+ IFS=: read -r owner group mode <<<"$metadata"
+ if [[ "$owner" != 0 || "$group" != 0 || ! "$mode" =~ ^[0-7]{3,4}$ ||
+ $((8#$mode & 0022)) -ne 0 ]]; then
+ echo "Installed Cloud Compose program parent is not root-controlled: $current" >&2
+ return 1
+ fi
+}
+
+cloud_compose_installed_home() {
+ local resolved_home alias_metadata alias_target
+ local -a parents
+
+ if [[ -L /home ]]; then
+ alias_metadata="$(stat -c '%u:%g:%h' -- /home)" || return 1
+ alias_target="$(readlink -- /home)" || return 1
+ if [[ "$alias_metadata" != "0:0:1" ||
+ ( "$alias_target" != "var/home" && "$alias_target" != "/var/home" ) ]]; then
+ echo "Installed Cloud Compose home uses an unsafe operating-system alias" >&2
+ return 1
+ fi
+ elif [[ ! -d /home ]]; then
+ echo "Installed Cloud Compose home parent is missing" >&2
+ return 1
+ fi
+
+ resolved_home="$(readlink -f -- /home)" || return 1
+ case "$resolved_home" in
+ /home) parents=(/ /home /home/cloud-compose) ;;
+ /var/home) parents=(/ /var /var/home /var/home/cloud-compose) ;;
+ *)
+ echo "Installed Cloud Compose home resolves outside a supported root-controlled path" >&2
+ return 1
+ ;;
+ esac
+ for parent in "${parents[@]}"; do
+ cloud_compose_validate_root_parent "$parent" || return 1
+ done
+ printf '%s/cloud-compose\n' "$resolved_home"
+}
+
+cloud_compose_owner_is_installed() {
+ local owner_path="$1" candidate_home installed_home
+
+ candidate_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+ if [[ "$candidate_home" == "/" ]]; then
+ echo "Installed Cloud Compose home resolves to the filesystem root" >&2
+ return 2
+ fi
+ if [[ -z "$candidate_home" ||
+ "$owner_path" != "${candidate_home%/}/"* ]]; then
+ return 1
+ fi
+ installed_home="$(cloud_compose_installed_home)" || return 2
+ [[ "$candidate_home" == "$installed_home" ]] || return 2
+}
+
+cloud_compose_validate_installed_program() {
+ local program="$1" program_dir="$2" program_name current metadata owner group mode links
+ local -a parents
+
+ case "$program_dir" in
+ /etc/cloud-compose/jq | /etc/cloud-compose/awk) ;;
+ *)
+ echo "Unsupported installed Cloud Compose program directory: $program_dir" >&2
+ return 1
+ ;;
+ esac
+ program_name="${program##*/}"
+ [[ "$program" == "$program_dir/$program_name" &&
+ "$program_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.(jq|awk)$ &&
+ "$program" != *$'\n'* &&
+ "$program" != *$'\r'* && ! "$program" =~ (^|/)\.\.?(/|$) ]] || {
+ echo "Unsafe installed Cloud Compose program path: $program" >&2
+ return 1
+ }
+
+ parents=(/ /etc /etc/cloud-compose "$program_dir")
+ for current in "${parents[@]}"; do
+ cloud_compose_validate_root_parent "$current" || return 1
+ done
+
+ if [[ -L "$program" || ! -f "$program" ]]; then
+ echo "Installed Cloud Compose program is missing or redirected: $program" >&2
+ return 1
+ fi
+ metadata="$(stat -c '%u:%g:%a:%h' -- "$program")" || return 1
+ IFS=: read -r owner group mode links <<<"$metadata"
+ if [[ "$owner" != 0 || "$group" != 0 || "$links" != 1 ||
+ ! "$mode" =~ ^[0-7]{3,4}$ ||
+ $((8#$mode & 0022)) -ne 0 ]]; then
+ echo "Installed Cloud Compose program is not a single-link, root-controlled regular file: $program" >&2
+ return 1
+ fi
+}
+
+cloud_compose_validate_installed_program_dir() (
+ local program_dir="$1" program
+ local -a programs
+
+ shopt -s nullglob dotglob
+ case "$program_dir" in
+ /etc/cloud-compose/jq) programs=("$program_dir"/*.jq) ;;
+ /etc/cloud-compose/awk) programs=("$program_dir"/*.awk) ;;
+ *)
+ echo "Unsupported installed Cloud Compose program directory: $program_dir" >&2
+ return 1
+ ;;
+ esac
+ ((${#programs[@]} > 0)) || {
+ echo "Installed Cloud Compose program directory is empty: $program_dir" >&2
+ return 1
+ }
+ for program in "${programs[@]}"; do
+ cloud_compose_validate_installed_program "$program" "$program_dir" || return 1
+ done
+)
+
+cloud_compose_validate_installed_source() {
+ local program="$1" resolved_program installed_home metadata owner group mode links
+
+ [[ "$program" =~ ^/home/cloud-compose/[A-Za-z0-9][A-Za-z0-9._-]*\.sh$ ]] || {
+ echo "Unsafe installed Cloud Compose source program path: $program" >&2
+ return 1
+ }
+ installed_home="$(cloud_compose_installed_home)" || return 1
+ resolved_program="$(readlink -f -- "$program")" || return 1
+ [[ "$resolved_program" == "$installed_home/${program##*/}" ]] || {
+ echo "Installed Cloud Compose source program resolves outside its fixed home" >&2
+ return 1
+ }
+ if [[ -L "$program" || ! -f "$program" ]]; then
+ echo "Installed Cloud Compose source program is missing or redirected: $program" >&2
+ return 1
+ fi
+ metadata="$(stat -c '%u:%g:%a:%h' -- "$program")" || return 1
+ IFS=: read -r owner group mode links <<<"$metadata"
+ if [[ "$owner" != 0 || "$group" != 0 || "$links" != 1 ||
+ ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
+ echo "Installed Cloud Compose source program is not a single-link, root-controlled regular file: $program" >&2
+ return 1
+ fi
+}
+
+cloud_compose_bind_program_dir() {
+ local owner_source="$1" variable_name="$2" installed_dir="$3" repository_dir="$4"
+ shift 4
+ local owner_path selected name program installed_status=0
+
+ owner_path="$(cloud_compose_program_owner_path "$owner_source")" || return 1
+ if cloud_compose_owner_is_installed "$owner_path"; then
+ installed_status=0
+ else
+ installed_status=$?
+ fi
+ if ((installed_status > 1)); then
+ return 1
+ fi
+ if ((installed_status == 0)); then
+ if [[ -v $variable_name && "${!variable_name}" != "$installed_dir" ]]; then
+ echo "$variable_name cannot override the installed Cloud Compose program directory" >&2
+ return 1
+ fi
+ selected="$installed_dir"
+ cloud_compose_validate_installed_program_dir "$selected" || return 1
+ for name in "$@"; do
+ [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.(jq|awk)$ ]] || {
+ echo "Unsafe Cloud Compose program name: $name" >&2
+ return 1
+ }
+ program="$selected/$name"
+ cloud_compose_validate_installed_program "$program" "$selected" || return 1
+ done
+ if [[ ! -v $variable_name ]]; then
+ printf -v "$variable_name" '%s' "$selected"
+ fi
+ readonly "$variable_name"
+ else
+ if [[ -v $variable_name && -n "${!variable_name}" ]]; then
+ selected="${!variable_name}"
+ else
+ selected="$repository_dir"
+ fi
+ printf -v "$variable_name" '%s' "$selected"
+ fi
+}
+
+cloud_compose_bind_program() {
+ local owner_source="$1" variable_name="$2" installed_program="$3" repository_program="$4"
+ local owner_path selected program_dir installed_status=0
+
+ owner_path="$(cloud_compose_program_owner_path "$owner_source")" || return 1
+ if cloud_compose_owner_is_installed "$owner_path"; then
+ installed_status=0
+ else
+ installed_status=$?
+ fi
+ if ((installed_status > 1)); then
+ return 1
+ fi
+ if ((installed_status == 0)); then
+ if [[ -v $variable_name && "${!variable_name}" != "$installed_program" ]]; then
+ echo "$variable_name cannot override the installed Cloud Compose program" >&2
+ return 1
+ fi
+ selected="$installed_program"
+ program_dir="${installed_program%/*}"
+ cloud_compose_validate_installed_program "$selected" "$program_dir" || return 1
+ if [[ ! -v $variable_name ]]; then
+ printf -v "$variable_name" '%s' "$selected"
+ fi
+ readonly "$variable_name"
+ else
+ if [[ -v $variable_name && -n "${!variable_name}" ]]; then
+ selected="${!variable_name}"
+ else
+ selected="$repository_program"
+ fi
+ printf -v "$variable_name" '%s' "$selected"
+ fi
+}
+
+cloud_compose_bind_source_program() {
+ local owner_source="$1" variable_name="$2" installed_program="$3" repository_program="$4"
+ local owner_path selected installed_status=0
+
+ owner_path="$(cloud_compose_program_owner_path "$owner_source")" || return 1
+ if cloud_compose_owner_is_installed "$owner_path"; then
+ installed_status=0
+ else
+ installed_status=$?
+ fi
+ if ((installed_status > 1)); then
+ return 1
+ fi
+ if ((installed_status == 0)); then
+ if [[ -v $variable_name && "${!variable_name}" != "$installed_program" ]]; then
+ echo "$variable_name cannot override the installed Cloud Compose source program" >&2
+ return 1
+ fi
+ selected="$installed_program"
+ cloud_compose_validate_installed_source "$selected" || return 1
+ if [[ ! -v $variable_name ]]; then
+ printf -v "$variable_name" '%s' "$selected"
+ fi
+ readonly "$variable_name"
+ else
+ if [[ -v $variable_name && -n "${!variable_name}" ]]; then
+ selected="${!variable_name}"
+ else
+ selected="$repository_program"
+ fi
+ printf -v "$variable_name" '%s' "$selected"
+ fi
+}
diff --git a/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh b/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh
new file mode 100755
index 0000000..63eed17
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+
+set -eu
+
+if [[ "$#" -ne 2 ]]; then
+ echo "usage: gcp-cloud-init-finalize.sh INIT_COMMANDS_FILE DIAGNOSTICS_SHA256" >&2
+ exit 2
+fi
+
+init_commands_file="$1"
+diagnostics_sha256="$2"
+
+bash /var/lib/cloud-compose/bootstrap/rootfs-archive.sh \
+ install-diagnostics "$diagnostics_sha256"
+if [[ ! -f /run/cloud-compose-filesystems-ready ]]; then
+ echo "Cloud Compose filesystems were not prepared; refusing application initialization" >&2
+ exit 1
+fi
+if [[ -s "$init_commands_file" ]]; then
+ # Operator-provided initialization commands are stored as a root-controlled
+ # program instead of being interpolated into the cloud-init shell body.
+ # shellcheck disable=SC1090
+ source "$init_commands_file"
+fi
+chown root:cloud-compose /mnt/disks/data
+chmod 1775 /mnt/disks/data
+chown cloud-compose:cloud-compose /mnt/disks/volumes
+chmod 0775 /mnt/disks/volumes
+install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops
+rm -f /var/lib/cloud-compose/bootstrap-complete
+/etc/cloud-compose/libexec/harden-bootstrap-paths.sh
+bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
diff --git a/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh b/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh
new file mode 100755
index 0000000..16449d4
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+
+set -eu
+
+if [[ "$#" -ne 2 ]]; then
+ echo "usage: gcp-cloud-init-post-bootstrap.sh ROLLOUT_ENABLED RUNCMD_FILE" >&2
+ exit 2
+fi
+
+rollout_enabled="$1"
+runcmd_file="$2"
+case "$rollout_enabled" in
+ true | false) ;;
+ *)
+ echo "ROLLOUT_ENABLED must be true or false" >&2
+ exit 2
+ ;;
+esac
+
+bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh
+if [[ "$rollout_enabled" == "true" ]]; then
+ bash /home/cloud-compose/deploy-rollout.sh >>/home/cloud-compose/run.log 2>&1
+fi
+if [[ -s "$runcmd_file" ]]; then
+ # shellcheck disable=SC1090
+ source "$runcmd_file"
+fi
diff --git a/rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh b/rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh
new file mode 100755
index 0000000..55a7904
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+if [[ "$#" -ne 5 ]]; then
+ echo "usage: gcp-filesystem-boot.sh FRESH_FILESYSTEM_IDENTITY USE_OVERLAY PREP_PROGRAM PERSIST_PROGRAM FSTAB_RECONCILE_PROGRAM" >&2
+ exit 2
+fi
+
+fresh_filesystem_identity="$1"
+use_overlay="$2"
+filesystem_prep="$3"
+filesystem_persist="$4"
+filesystem_reconcile="$5"
+
+require_root_owned_data_program() {
+ local path="$1" metadata
+
+ if [[ -L "$path" || ! -f "$path" ]]; then
+ echo "Checked filesystem data program is missing or unsafe: $path" >&2
+ return 1
+ fi
+ metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$path")" || return 1
+ if [[ "$metadata" != "0:0:600:1:regular file" ]]; then
+ echo "Checked filesystem data program is not an unlinked root-owned mode-0600 file: $path" >&2
+ return 1
+ fi
+}
+
+case "$use_overlay" in
+ true | false) ;;
+ *)
+ echo "USE_OVERLAY must be true or false" >&2
+ exit 2
+ ;;
+esac
+
+require_root_owned_data_program "$filesystem_reconcile"
+
+rm -f /run/cloud-compose-filesystems-ready
+bash "$filesystem_prep" /dev/disk/by-id/google-data /mnt/disks/data \
+ --publish-fresh-marker "$fresh_filesystem_identity"
+bash "$filesystem_prep" /dev/disk/by-id/google-docker-volumes /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
+for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do
+ if ! mountpoint -q -- "$required_mount"; then
+ echo "Required cloud-compose mount is unavailable: $required_mount" >&2
+ exit 1
+ fi
+done
+
+if [[ "$use_overlay" == "true" ]]; then
+ mkdir -p /mnt/disks/prod-readonly
+ if ! mountpoint -q /mnt/disks/prod-readonly; then
+ mount -o ro "$(readlink -f /dev/disk/by-id/google-prod-volumes)" \
+ /mnt/disks/prod-readonly
+ fi
+ CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM="$filesystem_reconcile" bash "$filesystem_persist" \
+ /dev/disk/by-id/google-data \
+ /dev/disk/by-id/google-docker-volumes \
+ /dev/disk/by-id/google-prod-volumes
+else
+ CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM="$filesystem_reconcile" bash "$filesystem_persist" \
+ /dev/disk/by-id/google-data \
+ /dev/disk/by-id/google-docker-volumes
+fi
+install -m 0600 /dev/null /run/cloud-compose-filesystems-ready
diff --git a/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh b/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh
new file mode 100755
index 0000000..eccc403
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+cloud_compose_home="/home/cloud-compose"
+
+if [[ -L "$cloud_compose_home" || ! -d "$cloud_compose_home" ]]; then
+ echo "Cloud Compose home is unavailable or unsafe" >&2
+ exit 1
+fi
+
+# cloud-init creates the operator account before all providers finish placing
+# the checked-in runtime files. Close that initial writable-home window before
+# any root process sources or executes those files. Files must already be
+# root-owned, regular, and unlinked before this script normalizes archive modes;
+# an operator replacement therefore fails closed instead of being blessed.
+chown root:root "$cloud_compose_home"
+chmod 0755 "$cloud_compose_home"
+
+require_root_owned_regular_file() {
+ local path="$1"
+ local metadata
+
+ if [[ -L "$path" || ! -f "$path" ]]; then
+ echo "Unsafe Cloud Compose bootstrap file: $path" >&2
+ exit 1
+ fi
+ metadata="$(stat -c '%u:%h:%F' -- "$path")"
+ if [[ "$metadata" != "0:1:regular file" ]]; then
+ echo "Cloud Compose bootstrap file is not an unlinked root-owned regular file: $path" >&2
+ exit 1
+ fi
+}
+
+shopt -s nullglob
+programs=(
+ "$cloud_compose_home"/*.sh
+ "$cloud_compose_home"/*.jq
+ "$cloud_compose_home"/*.awk
+)
+shopt -u nullglob
+for path in "${programs[@]}"; do
+ require_root_owned_regular_file "$path"
+done
+
+for dispatcher in init up down rollout; do
+ path="${cloud_compose_home}/${dispatcher}"
+ if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then
+ echo "Unsafe Cloud Compose dispatcher: $path" >&2
+ exit 1
+ fi
+ if [[ -f "$path" ]]; then
+ require_root_owned_regular_file "$path"
+ fi
+done
+
+for input in .env compose-projects.json application-env.json managed-runtime-artifacts.tsv; do
+ path="${cloud_compose_home}/${input}"
+ if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then
+ echo "Unsafe Cloud Compose input: $path" >&2
+ exit 1
+ fi
+ if [[ -f "$path" ]]; then
+ require_root_owned_regular_file "$path"
+ fi
+done
+
+find "$cloud_compose_home" -mindepth 1 -maxdepth 1 -type f -name '*.sh' \
+ -exec chown root:root {} + \
+ -exec chmod 0755 {} +
+find "$cloud_compose_home" -mindepth 1 -maxdepth 1 -type f -name '*.jq' \
+ -exec chown root:root {} + \
+ -exec chmod 0644 {} +
+find "$cloud_compose_home" -mindepth 1 -maxdepth 1 -type f -name '*.awk' \
+ -exec chown root:root {} + \
+ -exec chmod 0644 {} +
+
+for dispatcher in init up down rollout; do
+ path="${cloud_compose_home}/${dispatcher}"
+ if [[ -f "$path" ]]; then
+ chown root:root "$path"
+ chmod 0755 "$path"
+ fi
+done
+
+for input in .env compose-projects.json application-env.json managed-runtime-artifacts.tsv; do
+ path="${cloud_compose_home}/${input}"
+ if [[ -f "$path" ]]; then
+ chown root:cloud-compose "$path"
+ chmod 0640 "$path"
+ fi
+done
+
+if [[ -L "${cloud_compose_home}/bin" ||
+ ( -e "${cloud_compose_home}/bin" && ! -d "${cloud_compose_home}/bin" ) ]]; then
+ echo "Unsafe Cloud Compose command directory" >&2
+ exit 1
+fi
+install -d -m 0755 -o root -g root "${cloud_compose_home}/bin"
+
+home_identity="$(stat -Lc '%U:%G:%a' -- "$cloud_compose_home")"
+bin_identity="$(stat -Lc '%U:%G:%a' -- "${cloud_compose_home}/bin")"
+if [[ "$home_identity" != "root:root:755" || "$bin_identity" != "root:root:755" ]]; then
+ echo "Cloud Compose privileged paths have unsafe ownership or modes" >&2
+ exit 1
+fi
diff --git a/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh b/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh
new file mode 100755
index 0000000..68375ec
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+if [[ "$#" -ne 5 ]]; then
+ echo "usage: linux-vm-cloud-init.sh DATA_DEVICE VOLUMES_DEVICE ROOTFS_ARCHIVE_ENABLED ROLLOUT_ENABLED DIAGNOSTICS_SHA256" >&2
+ exit 2
+fi
+
+data_device="$1"
+volumes_device="$2"
+rootfs_archive_enabled="$3"
+rollout_enabled="$4"
+diagnostics_sha256="$5"
+readonly bootstrap_dir=/var/lib/cloud-compose/bootstrap
+readonly archive_program="$bootstrap_dir/rootfs-archive.sh"
+readonly overlay_dir=/var/lib/cloud-compose/rootfs-overlay
+readonly filesystem_prep=/run/cloud-compose-prepare-filesystem
+readonly filesystem_persist=/run/cloud-compose-persist-filesystems
+readonly filesystem_reconcile=/run/cloud-compose-reconcile-fstab.awk
+
+require_root_owned_data_program() {
+ local path="$1" metadata
+
+ if [[ -L "$path" || ! -f "$path" ]]; then
+ echo "Checked filesystem data program is missing or unsafe: $path" >&2
+ return 1
+ fi
+ metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$path")" || return 1
+ if [[ "$metadata" != "0:0:600:1:regular file" ]]; then
+ echo "Checked filesystem data program is not an unlinked root-owned mode-0600 file: $path" >&2
+ return 1
+ fi
+}
+
+for boolean_name in rootfs_archive_enabled rollout_enabled; do
+ case "${!boolean_name}" in
+ true | false) ;;
+ *)
+ echo "${boolean_name^^} must be true or false" >&2
+ exit 2
+ ;;
+ esac
+done
+if ! id -u cloud-compose >/dev/null 2>&1 || ! getent group docker >/dev/null 2>&1; then
+ echo "cloud-init did not create the cloud-compose user and docker group" >&2
+ exit 1
+fi
+cloud_compose_in_docker_group=false
+for account_group in $(id -nG cloud-compose); do
+ if [[ "$account_group" == "docker" ]]; then
+ cloud_compose_in_docker_group=true
+ break
+ fi
+done
+if [[ "$cloud_compose_in_docker_group" != "true" ]]; then
+ echo "cloud-init did not add cloud-compose to the docker group" >&2
+ exit 1
+fi
+
+if [[ "$rootfs_archive_enabled" == "false" ]]; then
+ install -m 0600 -- /home/cloud-compose/prepare-filesystem.sh "$filesystem_prep"
+ install -m 0600 -- /home/cloud-compose/persist-filesystems.sh "$filesystem_persist"
+ install -m 0600 -- /etc/cloud-compose/awk/reconcile-fstab.awk "$filesystem_reconcile"
+fi
+
+require_root_owned_data_program "$filesystem_reconcile"
+
+bash "$filesystem_prep" "$data_device" /mnt/disks/data --publish-fresh-marker
+bash "$filesystem_prep" "$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
+for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do
+ if ! mountpoint -q -- "$required_mount"; then
+ echo "Required cloud-compose mount is unavailable: $required_mount" >&2
+ exit 1
+ fi
+done
+CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM="$filesystem_reconcile" \
+ bash "$filesystem_persist" "$data_device" "$volumes_device"
+
+if [[ "$rootfs_archive_enabled" == "true" ]]; then
+ bash "$archive_program" install-staged "$overlay_dir"
+elif [[ -d /var/lib/cloud-compose/mounted-rootfs/mnt/disks ]]; then
+ cp -a /var/lib/cloud-compose/mounted-rootfs/mnt/disks/. /mnt/disks/
+fi
+
+bash "$archive_program" install-diagnostics "$diagnostics_sha256"
+chown root:cloud-compose /mnt/disks/data
+chmod 1775 /mnt/disks/data
+chown cloud-compose:cloud-compose /mnt/disks/volumes
+chmod 0775 /mnt/disks/volumes
+install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops
+
+if [[ "$rollout_enabled" == "true" ]]; then
+ bash /home/cloud-compose/deploy-rollout.sh >>/home/cloud-compose/run.log 2>&1
+fi
+rm -f /var/lib/cloud-compose/bootstrap-complete
+/etc/cloud-compose/libexec/harden-bootstrap-paths.sh
+bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
diff --git a/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh b/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh
new file mode 100755
index 0000000..1526af1
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /etc/cloud-compose/libexec/bootstrap-security.sh
+
+cloud_compose_bootstrap_require_root
+if ! cloud_compose_bootstrap_marker_ready; then
+ echo "Cloud Compose bootstrap readiness evidence is missing or invalid" >&2
+ exit 1
+fi
diff --git a/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh b/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh
new file mode 100755
index 0000000..ff361db
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh
@@ -0,0 +1,354 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+readonly stage_root=/run/cloud-compose-rootfs-stage
+readonly staged_rootfs="$stage_root/rootfs"
+readonly bootstrap_dir=/var/lib/cloud-compose/bootstrap
+readonly filesystem_prep=/run/cloud-compose-prepare-filesystem
+readonly filesystem_persist=/run/cloud-compose-persist-filesystems
+readonly filesystem_reconcile=/run/cloud-compose-reconcile-fstab.awk
+
+fail() {
+ echo "rootfs archive: $*" >&2
+ exit 1
+}
+
+canonical_file_mode() {
+ case "$1" in
+ *.sh) printf '755\n' ;;
+ *) printf '644\n' ;;
+ esac
+}
+
+require_safe_contract_path() {
+ local relative_path="$1"
+
+ [[ -n "$relative_path" && "$relative_path" != /* &&
+ "$relative_path" != *$'\t'* && "$relative_path" != *$'\r'* &&
+ "$relative_path" != *$'\n'* && "$relative_path" != *'//'*
+ && ! "$relative_path" =~ (^|/)\.\.?(/|$) ]] ||
+ fail "rootfs contract contains an unsafe path"
+}
+
+validate_rootfs_archive() {
+ local archive_path="$1"
+ local member_path normalized_path member_listing member_type
+ local rootfs_present=false
+
+ [[ -f "$archive_path" && ! -L "$archive_path" ]] ||
+ fail "rootfs archive must be a regular file"
+ while IFS= read -r member_path; do
+ normalized_path="${member_path%/}"
+ require_safe_contract_path "$normalized_path"
+ [[ "$normalized_path" == rootfs || "$normalized_path" == rootfs/* ]] ||
+ fail "rootfs archive contains a path outside rootfs"
+ if [[ "$normalized_path" == rootfs ]]; then
+ rootfs_present=true
+ fi
+ done < <(LC_ALL=C tar --quoting-style=literal -tzf "$archive_path")
+ [[ "$rootfs_present" == "true" ]] ||
+ fail "rootfs archive does not contain its rootfs directory"
+
+ while IFS= read -r member_listing; do
+ member_type="${member_listing:0:1}"
+ [[ "$member_type" == "-" || "$member_type" == "d" ]] ||
+ fail "rootfs archive contains a link or unsupported filesystem object"
+ done < <(LC_ALL=C tar --numeric-owner --quoting-style=escape -tvzf "$archive_path")
+}
+
+validate_rootfs_test_source_archive() {
+ local archive_path="$1" test_source_prefix="$2"
+ local member_path normalized_path member_listing member_type
+ local rootfs_present=false
+
+ [[ -f "$archive_path" && ! -L "$archive_path" ]] ||
+ fail "test-only rootfs source archive must be a regular file"
+ [[ "$test_source_prefix" =~ ^cloud-compose-([0-9a-f]{40})$ ]] ||
+ fail "test-only rootfs source archive prefix must identify one exact cloud-compose commit"
+ while IFS= read -r member_path; do
+ normalized_path="${member_path%/}"
+ require_safe_contract_path "$normalized_path"
+ [[ "$normalized_path" == "$test_source_prefix" ||
+ "$normalized_path" == "$test_source_prefix/"* ]] ||
+ fail "test-only rootfs source archive contains a path outside its exact commit prefix"
+ if [[ "$normalized_path" == "$test_source_prefix/rootfs" ]]; then
+ rootfs_present=true
+ fi
+ done < <(LC_ALL=C tar --quoting-style=literal -tzf "$archive_path")
+ [[ "$rootfs_present" == "true" ]] ||
+ fail "test-only rootfs source archive does not contain ${test_source_prefix}/rootfs"
+
+ while IFS= read -r member_listing; do
+ member_type="${member_listing:0:1}"
+ [[ "$member_type" == "-" || "$member_type" == "d" ]] ||
+ fail "test-only rootfs source archive contains a link or unsupported filesystem object"
+ done < <(LC_ALL=C tar --numeric-owner --quoting-style=escape -tvzf "$archive_path")
+}
+
+rootfs_contract_sha256() {
+ local rootfs_dir="$1"
+ local contract_manifest="$2"
+ local require_root_owner="${3:-true}"
+ local absolute_path relative_path expected_mode metadata owner file_sha256 unsupported_path
+
+ [[ -d "$rootfs_dir" && ! -L "$rootfs_dir" ]] ||
+ fail "rootfs contract source must be a real directory"
+ unsupported_path="$(find "$rootfs_dir" -mindepth 1 ! -type d ! -type f -print -quit)"
+ if [[ -n "$unsupported_path" ]]; then
+ fail "rootfs contract contains a symlink or unsupported filesystem object"
+ fi
+
+ : >"$contract_manifest"
+ while IFS= read -r -d '' absolute_path; do
+ relative_path="${absolute_path#"$rootfs_dir"/}"
+ require_safe_contract_path "$relative_path"
+ metadata="$(stat -c '%a:%F' -- "$absolute_path")"
+ [[ "$metadata" == "755:directory" ]] ||
+ fail "rootfs contract directory is not mode 0755: $relative_path"
+ owner="$(stat -c '%u:%g' -- "$absolute_path")"
+ [[ "$require_root_owner" != "true" || "$owner" == "0:0" ]] ||
+ fail "rootfs contract directory is not root-owned: $relative_path"
+ printf 'd\t0:0:755\t%s\n' "$relative_path" >>"$contract_manifest"
+ done < <(find "$rootfs_dir" -mindepth 1 -type d -print0 | LC_ALL=C sort -z)
+
+ while IFS= read -r -d '' absolute_path; do
+ relative_path="${absolute_path#"$rootfs_dir"/}"
+ require_safe_contract_path "$relative_path"
+ expected_mode="$(canonical_file_mode "$relative_path")"
+ metadata="$(stat -c '%a:%h:%F' -- "$absolute_path")"
+ [[ "$metadata" == "${expected_mode}:1:regular file" ]] ||
+ fail "rootfs contract file metadata is not canonical: $relative_path"
+ owner="$(stat -c '%u:%g' -- "$absolute_path")"
+ [[ "$require_root_owner" != "true" || "$owner" == "0:0" ]] ||
+ fail "rootfs contract file is not root-owned: $relative_path"
+ read -r file_sha256 _ < <(sha256sum -- "$absolute_path")
+ printf 'f\t%s\t0:0:%s\t%s\n' \
+ "$file_sha256" "$expected_mode" "$relative_path" >>"$contract_manifest"
+ done < <(find "$rootfs_dir" -type f -print0 | LC_ALL=C sort -z)
+
+ read -r contract_sha256 _ < <(sha256sum -- "$contract_manifest")
+ printf '%s\n' "$contract_sha256"
+}
+
+require_safe_overlay_dir() {
+ local overlay_dir="$1"
+
+ if [[ -n "$overlay_dir" && "$overlay_dir" != "/var/lib/cloud-compose/rootfs-overlay" ]]; then
+ fail "overlay directory must be /var/lib/cloud-compose/rootfs-overlay"
+ fi
+ if [[ -L "$overlay_dir" ]]; then
+ fail "overlay directory must not be a symlink"
+ fi
+}
+
+install_archive_tools() {
+ if command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1; then
+ return
+ fi
+
+ 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
+ fail "no supported package manager found to install curl and tar"
+ fi
+}
+
+prepare_archive() {
+ local archive_url_b64="$1" archive_sha256="$2" expected_contract_sha256="$3"
+ local test_source_prefix="${4:-}"
+ local archive_url extract_dir rootfs_dir required_command
+ local contract_manifest contract_sha256 source_commit unsupported_path
+
+ archive_url="$(printf '%s' "$archive_url_b64" | base64 -d)" || \
+ fail "rootfs archive URL is not valid base64 data"
+ [[ "$archive_url" == https://* ]] || fail "rootfs archive URL must use HTTPS"
+ [[ "$archive_url" != *[[:space:]]* ]] || fail "rootfs archive URL must not contain whitespace"
+ [[ "$archive_sha256" =~ ^[0-9a-f]{64}$ ]] || \
+ fail "rootfs archive checksum must be a lowercase SHA-256 digest"
+ [[ "$expected_contract_sha256" =~ ^[0-9a-f]{64}$ ]] || \
+ fail "rootfs content contract must be a lowercase SHA-256 digest"
+ if [[ -n "$test_source_prefix" ]]; then
+ [[ "$test_source_prefix" =~ ^cloud-compose-([0-9a-f]{40})$ ]] ||
+ fail "test-only rootfs source archive prefix must identify one exact cloud-compose commit"
+ source_commit="${BASH_REMATCH[1]}"
+ [[ "$archive_url" == "https://github.com/libops/cloud-compose/archive/${source_commit}.tar.gz" ]] ||
+ fail "test-only rootfs source archive URL must select the exact commit named by its prefix"
+ fi
+
+ install_archive_tools
+ for required_command in awk chmod curl find sha256sum sort stat tar; do
+ command -v "$required_command" >/dev/null 2>&1 || \
+ fail "$required_command is required to install the verified rootfs archive"
+ done
+
+ if [[ -L "$stage_root" ]]; then
+ fail "rootfs archive stage must not be a symlink"
+ fi
+ rm -rf -- "$stage_root"
+ install -d -m 0700 -o root -g root "$stage_root"
+ extract_dir="$stage_root/extract"
+ install -d -m 0700 -o root -g root "$extract_dir"
+
+ curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
+ --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 900 \
+ --connect-timeout 10 --max-time 300 \
+ -o "$stage_root/rootfs.tar.gz" -- "$archive_url"
+ printf '%s %s\n' "$archive_sha256" "$stage_root/rootfs.tar.gz" | sha256sum -c -
+ if [[ -n "$test_source_prefix" ]]; then
+ validate_rootfs_test_source_archive "$stage_root/rootfs.tar.gz" "$test_source_prefix"
+ tar --no-same-owner --same-permissions -xzf "$stage_root/rootfs.tar.gz" \
+ -C "$extract_dir" "$test_source_prefix/rootfs"
+ rootfs_dir="$extract_dir/$test_source_prefix/rootfs"
+ # This test-only GitHub source path preserves Git modes rather than
+ # canonical rootfs package modes. Normalize only the isolated rootfs
+ # subtree; the exact byte/content contract remains authoritative.
+ find "$rootfs_dir" -depth -type d -empty -delete
+ find "$rootfs_dir" -type d -exec chmod 0755 -- {} +
+ find "$rootfs_dir" -type f -exec chmod 0644 -- {} +
+ find "$rootfs_dir" -type f -name '*.sh' -exec chmod 0755 -- {} +
+ else
+ validate_rootfs_archive "$stage_root/rootfs.tar.gz"
+ tar --no-same-owner --same-permissions -xzf "$stage_root/rootfs.tar.gz" -C "$extract_dir"
+ rootfs_dir="$extract_dir/rootfs"
+ fi
+ [[ -n "$rootfs_dir" && -d "$rootfs_dir" ]] || \
+ fail "rootfs directory not found in $archive_url"
+ unsupported_path="$(find "$rootfs_dir" -mindepth 1 ! -type d ! -type f -print -quit)"
+ if [[ -n "$unsupported_path" ]]; then
+ fail "verified rootfs archive contains a symlink or unsupported filesystem object"
+ fi
+ contract_manifest="$stage_root/rootfs-contract.tsv"
+ contract_sha256="$(rootfs_contract_sha256 "$rootfs_dir" "$contract_manifest" true)"
+ [[ "$contract_sha256" == "$expected_contract_sha256" ]] || \
+ fail "rootfs archive paths, bytes, or canonical metadata do not match this cloud-compose module source"
+ mv -- "$rootfs_dir" "$staged_rootfs"
+ rm -f -- "$stage_root/rootfs.tar.gz"
+ rm -rf -- "$extract_dir"
+
+ [[ -f "$staged_rootfs/home/cloud-compose/prepare-filesystem.sh" &&
+ -f "$staged_rootfs/home/cloud-compose/persist-filesystems.sh" &&
+ -f "$staged_rootfs/etc/cloud-compose/awk/reconcile-fstab.awk" ]] || \
+ fail "verified rootfs archive is missing filesystem preparation programs"
+ install -m 0600 -- \
+ "$staged_rootfs/home/cloud-compose/prepare-filesystem.sh" \
+ "$filesystem_prep"
+ install -m 0600 -- \
+ "$staged_rootfs/home/cloud-compose/persist-filesystems.sh" \
+ "$filesystem_persist"
+ install -m 0600 -- \
+ "$staged_rootfs/etc/cloud-compose/awk/reconcile-fstab.awk" \
+ "$filesystem_reconcile"
+}
+
+install_staged_archive() {
+ local overlay_dir="${1:-}"
+
+ [[ -d "$staged_rootfs" && ! -L "$staged_rootfs" ]] || \
+ fail "verified rootfs directory is unavailable during installation"
+ require_safe_overlay_dir "$overlay_dir"
+ cp -a "$staged_rootfs"/. /
+ if [[ -n "$overlay_dir" && -d "$overlay_dir" ]]; then
+ cp -a "$overlay_dir"/. /
+ rm -rf -- "$overlay_dir"
+ fi
+ rm -rf -- "$stage_root"
+}
+
+verify_linux_bootstrap() {
+ local expected_sha256="$1"
+ local source="$bootstrap_dir/linux-vm-cloud-init.sh"
+ local metadata
+
+ [[ "$expected_sha256" =~ ^[0-9a-f]{64}$ ]] || \
+ fail "Linux VM cloud-init checksum must be a lowercase SHA-256 digest"
+ [[ -f "$source" && ! -L "$source" ]] || \
+ fail "Linux VM cloud-init program is missing or redirected"
+ metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$source")"
+ [[ "$metadata" == "0:0:700:1:regular file" ]] || \
+ fail "Linux VM cloud-init program is not an unlinked root-owned mode-0700 file"
+ printf '%s %s\n' "$expected_sha256" "$source" | sha256sum -c -
+}
+
+install_bootstrap_diagnostics() {
+ local expected_sha256="$1"
+ local source="$bootstrap_dir/cloud-compose-diagnostics.sh"
+ local destination=/etc/cloud-compose/bin/cloud-compose-diagnostics.sh
+ local source_metadata destination_metadata
+
+ [[ "$expected_sha256" =~ ^[0-9a-f]{64}$ ]] || \
+ fail "diagnostics checksum must be a lowercase SHA-256 digest"
+ [[ -f "$source" && ! -L "$source" ]] || \
+ fail "staged diagnostics program is missing or redirected"
+ source_metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$source")"
+ [[ "$source_metadata" == "0:0:600:1:regular file" ]] || \
+ fail "staged diagnostics program is not an unlinked root-owned mode-0600 file"
+ printf '%s %s\n' "$expected_sha256" "$source" | sha256sum -c -
+
+ for directory in /etc/cloud-compose /etc/cloud-compose/bin; do
+ [[ ! -L "$directory" ]] || fail "diagnostics destination is redirected: $directory"
+ install -d -m 0755 -o root -g root -- "$directory"
+ done
+ install -m 0755 -o root -g root -- "$source" "$destination"
+ destination_metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$destination")"
+ [[ "$destination_metadata" == "0:0:755:1:regular file" ]] || \
+ fail "installed diagnostics program is not an unlinked root-owned mode-0755 file"
+ printf '%s %s\n' "$expected_sha256" "$destination" | sha256sum -c -
+}
+
+action="${1:-}"
+case "$action" in
+ contract)
+ [[ "$#" -eq 2 ]] || fail "usage: rootfs-archive.sh contract ROOTFS_DIR"
+ contract_tmp="$(mktemp)"
+ trap 'rm -f -- "$contract_tmp"' EXIT
+ rootfs_contract_sha256 "$2" "$contract_tmp" false
+ ;;
+ validate-archive)
+ [[ "$#" -eq 2 ]] || fail "usage: rootfs-archive.sh validate-archive ARCHIVE"
+ validate_rootfs_archive "$2"
+ ;;
+ prepare)
+ [[ "$#" -eq 4 ]] || \
+ fail "usage: rootfs-archive.sh prepare URL_B64 SHA256 ROOTFS_CONTRACT_SHA256"
+ prepare_archive "$2" "$3" "$4"
+ ;;
+ prepare-linux)
+ [[ "$#" -eq 5 ]] || \
+ fail "usage: rootfs-archive.sh prepare-linux URL_B64 SHA256 ROOTFS_CONTRACT_SHA256 LINUX_BOOTSTRAP_SHA256"
+ prepare_archive "$2" "$3" "$4"
+ verify_linux_bootstrap "$5"
+ ;;
+ prepare-linux-test-source)
+ [[ "$#" -eq 6 ]] || \
+ fail "usage: rootfs-archive.sh prepare-linux-test-source URL_B64 SHA256 ROOTFS_CONTRACT_SHA256 LINUX_BOOTSTRAP_SHA256 TEST_SOURCE_PREFIX"
+ prepare_archive "$2" "$3" "$4" "$6"
+ verify_linux_bootstrap "$5"
+ ;;
+ install-staged)
+ [[ "$#" -le 2 ]] || fail "usage: rootfs-archive.sh install-staged [OVERLAY_DIR]"
+ install_staged_archive "${2:-}"
+ ;;
+ install)
+ [[ "$#" -le 5 && "$#" -ge 4 ]] || \
+ fail "usage: rootfs-archive.sh install URL_B64 SHA256 ROOTFS_CONTRACT_SHA256 [OVERLAY_DIR]"
+ [[ -f /run/cloud-compose-filesystems-ready ]] || \
+ fail "Cloud Compose filesystems were not prepared; refusing rootfs installation"
+ prepare_archive "$2" "$3" "$4"
+ install_staged_archive "${5:-}"
+ ;;
+ install-diagnostics)
+ [[ "$#" -eq 2 ]] || \
+ fail "usage: rootfs-archive.sh install-diagnostics DIAGNOSTICS_SHA256"
+ install_bootstrap_diagnostics "$2"
+ ;;
+ *)
+ fail "usage: rootfs-archive.sh contract|validate-archive|prepare|prepare-linux|prepare-linux-test-source|install-staged|install|install-diagnostics ..."
+ ;;
+esac
diff --git a/rootfs/etc/cloud-compose/libexec/run-bootstrap.sh b/rootfs/etc/cloud-compose/libexec/run-bootstrap.sh
new file mode 100755
index 0000000..eda9d3b
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/run-bootstrap.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /etc/cloud-compose/libexec/bootstrap-security.sh
+
+cloud_compose_secure_runtime_home
+exec /bin/bash /home/cloud-compose/run.sh
diff --git a/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh b/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh
new file mode 100755
index 0000000..bc7b557
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+validate_only=false
+if [[ "${1:-}" == "--validate" ]]; then
+ validate_only=true
+ shift
+fi
+
+if [[ "$#" -ne 2 ]]; then
+ echo "usage: run-lifecycle-program.sh [--validate] LIFECYCLE PROGRAM" >&2
+ exit 2
+fi
+
+lifecycle="$1"
+entry="$2"
+case "$lifecycle" in
+ init | up | down | rollout) ;;
+ *)
+ echo "Unsupported Cloud Compose lifecycle: $lifecycle" >&2
+ exit 2
+ ;;
+esac
+
+case "$entry" in
+ true)
+ exit 0
+ ;;
+ false)
+ [[ "$validate_only" == "true" ]] && exit 0
+ exit 1
+ ;;
+ "/home/cloud-compose/default-lifecycle.sh $lifecycle")
+ program=/home/cloud-compose/default-lifecycle.sh
+ program_args=("$lifecycle")
+ ;;
+ *)
+ program_dir="${CLOUD_COMPOSE_LIFECYCLE_PROGRAM_DIR:-/etc/cloud-compose/lifecycle.d}"
+ if [[ "$program_dir" != /* || "$program_dir" == "/" ||
+ "$program_dir" == *$'\n'* || "$program_dir" == *$'\r'* ||
+ "$program_dir" =~ (^|/)\.\.?(/|$) ]]; then
+ echo "Unsafe Cloud Compose lifecycle program directory: $program_dir" >&2
+ exit 2
+ fi
+ program_name="${entry#"$program_dir"/}"
+ if [[ "$entry" != "$program_dir/"* || "$program_name" == */* ||
+ ! "$program_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
+ echo "Lifecycle entries must name one checked program in ${program_dir}: $entry" >&2
+ exit 2
+ fi
+ program="$entry"
+ program_args=()
+ ;;
+esac
+
+executor_uid="$(stat -c '%u' -- "${BASH_SOURCE[0]}")"
+program_parent="$(dirname -- "$program")"
+if [[ -L "$program_parent" || ! -d "$program_parent" || -L "$program" || ! -f "$program" ]]; then
+ echo "Lifecycle program is missing or redirected: $program" >&2
+ exit 1
+fi
+parent_metadata="$(stat -c '%u:%a:%F' -- "$program_parent")"
+program_metadata="$(stat -c '%u:%a:%h:%F' -- "$program")"
+IFS=: read -r parent_uid parent_mode parent_kind <<<"$parent_metadata"
+IFS=: read -r program_uid program_mode program_links program_kind <<<"$program_metadata"
+if [[ "$parent_uid" != "$executor_uid" || "$parent_kind" != "directory" ||
+ ! "$parent_mode" =~ ^[0-7]{3,4}$ || $((8#$parent_mode & 0022)) -ne 0 ]]; then
+ echo "Lifecycle program directory is not controlled by the executor owner: $program_parent" >&2
+ exit 1
+fi
+if [[ "$program_uid" != "$executor_uid" || "$program_links" != "1" ||
+ "$program_kind" != "regular file" || ! "$program_mode" =~ ^[0-7]{3,4}$ ||
+ $((8#$program_mode & 0122)) -ne 0100 ]]; then
+ echo "Lifecycle program is not an unlinked owner-executable file controlled by the executor owner: $program" >&2
+ exit 1
+fi
+
+if [[ "$validate_only" == "true" ]]; then
+ exit 0
+fi
+
+# Container-Optimized OS mounts /home with noexec. The built-in lifecycle
+# program is still an unlinked, root-owned regular file below the checked
+# root-owned home boundary, so open it through the fixed system interpreter
+# after validation instead of asking the kernel to execute it from that mount.
+# Keep custom programs on the direct-exec path so their reviewed interpreter or
+# binary contract is preserved. No manifest value is evaluated as shell source.
+if [[ "$program" == "/home/cloud-compose/default-lifecycle.sh" ]]; then
+ exec /bin/bash -- "$program" "${program_args[@]}"
+fi
+
+exec "$program" "${program_args[@]}"
diff --git a/rootfs/etc/cloud-compose/libexec/run-root-program.sh b/rootfs/etc/cloud-compose/libexec/run-root-program.sh
new file mode 100755
index 0000000..b5e37e3
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/run-root-program.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /etc/cloud-compose/libexec/bootstrap-security.sh
+
+program="${1:-}"
+if [[ -z "$program" ]]; then
+ echo "A Cloud Compose root program is required" >&2
+ exit 2
+fi
+shift
+
+case "$program" in
+ configure-metadata-firewall.sh | deploy-rollout.sh | docker-prune.sh | \
+ libops-managed-runtime.sh | mount-overlays.sh | offhost-backup.sh | \
+ restore-test.sh | rotate-keys-daily.sh | \
+ vault-agent-readiness.sh) ;;
+ *)
+ echo "Unsupported Cloud Compose root program: $program" >&2
+ exit 2
+ ;;
+esac
+
+cloud_compose_secure_runtime_home
+exec /bin/bash "/home/cloud-compose/$program" "$@"
diff --git a/rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh b/rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
new file mode 100755
index 0000000..b38af25
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /etc/cloud-compose/libexec/bootstrap-security.sh
+
+cloud_compose_secure_runtime_home
+exec /bin/bash /home/cloud-compose/start-cloud-compose-bootstrap.sh "$@"
diff --git a/rootfs/etc/systemd/system/cloud-compose-bootstrap.service b/rootfs/etc/systemd/system/cloud-compose-bootstrap.service
index 48db515..6277dda 100644
--- a/rootfs/etc/systemd/system/cloud-compose-bootstrap.service
+++ b/rootfs/etc/systemd/system/cloud-compose-bootstrap.service
@@ -3,13 +3,17 @@ Description=Converge Cloud Compose application bootstrap
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes
-ConditionPathExists=!/home/cloud-compose/.cloud-compose-bootstrap-complete
-StartLimitIntervalSec=0
+# Three full two-hour activation attempts must all remain inside the rate-limit
+# window; otherwise a repeatedly hung bootstrap could retry forever as the
+# oldest start aged out before the fourth attempt.
+StartLimitIntervalSec=8h
+StartLimitBurst=3
[Service]
Type=oneshot
RemainAfterExit=yes
-ExecStart=/bin/bash /home/cloud-compose/run-bootstrap.sh
+ExecCondition=/bin/bash /etc/cloud-compose/libexec/bootstrap-required.sh
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-bootstrap.sh
Restart=on-failure
RestartSec=30s
TimeoutStartSec=2h
diff --git a/rootfs/etc/systemd/system/cloud-compose-docker-prune.service b/rootfs/etc/systemd/system/cloud-compose-docker-prune.service
index 03f8d29..701132d 100644
--- a/rootfs/etc/systemd/system/cloud-compose-docker-prune.service
+++ b/rootfs/etc/systemd/system/cloud-compose-docker-prune.service
@@ -8,5 +8,5 @@ After=docker.service
Type=oneshot
User=root
Group=root
-ExecStart=/bin/bash /home/cloud-compose/docker-prune.sh
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh docker-prune.sh
TimeoutStartSec=30min
diff --git a/rootfs/etc/systemd/system/cloud-compose-key-rotation.service b/rootfs/etc/systemd/system/cloud-compose-key-rotation.service
index b9b917d..4a39855 100644
--- a/rootfs/etc/systemd/system/cloud-compose-key-rotation.service
+++ b/rootfs/etc/systemd/system/cloud-compose-key-rotation.service
@@ -6,7 +6,7 @@ RequiresMountsFor=/mnt/disks/data
[Service]
Type=oneshot
-ExecStart=/bin/bash /home/cloud-compose/rotate-keys-daily.sh
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh rotate-keys-daily.sh
User=root
Group=root
TimeoutStartSec=1h
diff --git a/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.timer b/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.timer
index ef56532..09362b8 100644
--- a/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.timer
+++ b/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.timer
@@ -1,5 +1,5 @@
[Unit]
-Description=Run Cloud Compose MariaDB backups nightly
+Description=Run Cloud Compose local backups and off-host DR handoff nightly
[Timer]
# 9pm-7am EST, splayed per deployment by systemd's stable random delay.
@@ -8,7 +8,7 @@ Persistent=true
RandomizedDelaySec=10h
FixedRandomDelay=true
AccuracySec=1min
-Unit=cloud-compose-mariadb-backup.service
+Unit=cloud-compose-offhost-backup.service
[Install]
WantedBy=timers.target
diff --git a/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service b/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service
index fb04f77..e8742ab 100644
--- a/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service
+++ b/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service
@@ -6,7 +6,7 @@ Before=docker.service
[Service]
Type=oneshot
-ExecStart=/bin/bash /home/cloud-compose/configure-metadata-firewall.sh pre-docker
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh configure-metadata-firewall.sh pre-docker
RemainAfterExit=yes
TimeoutStartSec=2min
diff --git a/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service b/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service
index 40dec57..0333f5a 100644
--- a/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service
+++ b/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service
@@ -6,7 +6,7 @@ PartOf=docker.service
[Service]
Type=oneshot
-ExecStart=/bin/bash /home/cloud-compose/configure-metadata-firewall.sh
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh configure-metadata-firewall.sh
RemainAfterExit=yes
TimeoutStartSec=2min
diff --git a/rootfs/etc/systemd/system/cloud-compose-offhost-backup.service b/rootfs/etc/systemd/system/cloud-compose-offhost-backup.service
new file mode 100644
index 0000000..c1d4069
--- /dev/null
+++ b/rootfs/etc/systemd/system/cloud-compose-offhost-backup.service
@@ -0,0 +1,17 @@
+[Unit]
+Description=Cloud Compose encrypted off-host disaster-recovery handoff
+Requires=cloud-compose-mariadb-backup.service
+After=cloud-compose-mariadb-backup.service network-online.target
+Wants=network-online.target
+RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes
+
+[Service]
+Type=oneshot
+User=root
+Group=root
+UMask=0077
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh offhost-backup.sh
+TimeoutStartSec=24h
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=cloud-compose-offhost-backup
diff --git a/rootfs/etc/systemd/system/cloud-compose-overlay.service b/rootfs/etc/systemd/system/cloud-compose-overlay.service
index c280d3f..69f8544 100644
--- a/rootfs/etc/systemd/system/cloud-compose-overlay.service
+++ b/rootfs/etc/systemd/system/cloud-compose-overlay.service
@@ -8,7 +8,7 @@ Type=oneshot
User=root
Group=root
EnvironmentFile=-/home/cloud-compose/.env
-ExecStart=/bin/bash /home/cloud-compose/mount-overlays.sh
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh mount-overlays.sh
RemainAfterExit=yes
[Install]
diff --git a/rootfs/etc/systemd/system/cloud-compose-restore-test.service b/rootfs/etc/systemd/system/cloud-compose-restore-test.service
new file mode 100644
index 0000000..baa3b0a
--- /dev/null
+++ b/rootfs/etc/systemd/system/cloud-compose-restore-test.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Cloud Compose disposable disaster-recovery restore test
+After=network-online.target docker.service
+Wants=network-online.target
+RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes
+
+[Service]
+Type=oneshot
+User=root
+Group=root
+UMask=0077
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh restore-test.sh
+TimeoutStartSec=24h
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=cloud-compose-restore-test
diff --git a/rootfs/etc/systemd/system/cloud-compose-restore-test.timer b/rootfs/etc/systemd/system/cloud-compose-restore-test.timer
new file mode 100644
index 0000000..831f449
--- /dev/null
+++ b/rootfs/etc/systemd/system/cloud-compose-restore-test.timer
@@ -0,0 +1,13 @@
+[Unit]
+Description=Test Cloud Compose off-host recovery weekly
+
+[Timer]
+OnCalendar=Sun *-*-* 03:00:00
+Persistent=true
+RandomizedDelaySec=6h
+FixedRandomDelay=true
+AccuracySec=1min
+Unit=cloud-compose-restore-test.service
+
+[Install]
+WantedBy=timers.target
diff --git a/rootfs/etc/systemd/system/cloud-compose-vault-agent.service b/rootfs/etc/systemd/system/cloud-compose-vault-agent.service
index d3d1f63..c2e74f9 100644
--- a/rootfs/etc/systemd/system/cloud-compose-vault-agent.service
+++ b/rootfs/etc/systemd/system/cloud-compose-vault-agent.service
@@ -9,10 +9,10 @@ RequiresMountsFor=/mnt/disks/data
User=root
Group=root
EnvironmentFile=-/etc/default/vault-agent
-ExecStartPre=/bin/bash /home/cloud-compose/vault-agent-readiness.sh prepare
+ExecStartPre=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh vault-agent-readiness.sh prepare
ExecStart=/usr/local/bin/vault agent -config=/etc/vault-agent.d/cloud-compose.hcl
-ExecStartPost=/bin/bash /home/cloud-compose/vault-agent-readiness.sh wait
-ExecStopPost=/bin/bash /home/cloud-compose/vault-agent-readiness.sh clear
+ExecStartPost=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh vault-agent-readiness.sh wait
+ExecStopPost=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh vault-agent-readiness.sh clear
Restart=on-failure
RestartSec=10s
TimeoutStartSec=2min
diff --git a/rootfs/etc/systemd/system/cloud-compose.service b/rootfs/etc/systemd/system/cloud-compose.service
index 2cd1381..25e7e8f 100644
--- a/rootfs/etc/systemd/system/cloud-compose.service
+++ b/rootfs/etc/systemd/system/cloud-compose.service
@@ -5,7 +5,10 @@ Requires=docker.service cloud-compose-metadata-firewall.service
RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes
Wants=network-online.target
After=network-online.target docker.service cloud-compose-metadata-firewall.service cloud-compose-vault-agent.service
-StartLimitIntervalSec=0
+# Keep all three possible 90-minute activation attempts inside one rate-limit
+# window so repeated hangs cannot age out and restart forever.
+StartLimitIntervalSec=6h
+StartLimitBurst=3
[Service]
Type=oneshot
@@ -18,7 +21,10 @@ ExecStart=/bin/bash /home/cloud-compose/up
ExecStop=/bin/bash /home/cloud-compose/down
Restart=on-failure
RestartSec=30s
-TimeoutStartSec=1h
+# run.sh allows application convergence to take 75 minutes. Keep systemd's
+# ceiling above that application budget so the service manager cannot kill a
+# healthy first boot before the caller's bounded wait reports its own result.
+TimeoutStartSec=90min
TimeoutStopSec=15min
[Install]
diff --git a/rootfs/etc/systemd/system/libops-managed-runtime.service b/rootfs/etc/systemd/system/libops-managed-runtime.service
index 5ddf8ec..94d6781 100644
--- a/rootfs/etc/systemd/system/libops-managed-runtime.service
+++ b/rootfs/etc/systemd/system/libops-managed-runtime.service
@@ -8,7 +8,7 @@ RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volu
Type=oneshot
User=root
Group=root
-ExecStart=/bin/bash /home/cloud-compose/libops-managed-runtime.sh update
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh libops-managed-runtime.sh update
TimeoutStartSec=1h
StandardOutput=journal
StandardError=journal
diff --git a/rootfs/etc/tmpfiles.d/cloud-compose.conf b/rootfs/etc/tmpfiles.d/cloud-compose.conf
index 9c5eb15..78617d7 100644
--- a/rootfs/etc/tmpfiles.d/cloud-compose.conf
+++ b/rootfs/etc/tmpfiles.d/cloud-compose.conf
@@ -1,2 +1,5 @@
+d /var/lib/cloud-compose 0755 root root -
+d /home/cloud-compose 0755 root root -
d /run/lock/cloud-compose 0750 root cloud-compose -
f /run/lock/cloud-compose/lifecycle.lock 0660 root cloud-compose -
+d /mnt/disks/data/.cloud-compose-disaster-recovery 0700 root root -
diff --git a/rootfs/home/cloud-compose/app-init.sh b/rootfs/home/cloud-compose/app-init.sh
index 36bf261..a23aa3a 100644
--- a/rootfs/home/cloud-compose/app-init.sh
+++ b/rootfs/home/cloud-compose/app-init.sh
@@ -31,5 +31,6 @@ for app in "${apps[@]}"; do
update_compose_env COMPOSE_BIND_PORT "$COMPOSE_BIND_PORT"
run_compose_app_lifecycle "$app" init
configure_sitectl_app_features "$app"
+ record_compose_managed_diff "$app"
popd >/dev/null
done
diff --git a/rootfs/home/cloud-compose/assert-app-initialized.sh b/rootfs/home/cloud-compose/assert-app-initialized.sh
index 6434942..bf08842 100644
--- a/rootfs/home/cloud-compose/assert-app-initialized.sh
+++ b/rootfs/home/cloud-compose/assert-app-initialized.sh
@@ -2,10 +2,30 @@
set -euo pipefail
+_cc_assert_initialized_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_assert_initialized_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_assert_initialized_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_assert_initialized_source _cc_assert_initialized_dir _cc_assert_initialized_installed_home
+if [[ -n "$_cc_assert_initialized_installed_home" &&
+ ( "$_cc_assert_initialized_installed_home" == "/" ||
+ "$_cc_assert_initialized_source" == "${_cc_assert_initialized_installed_home%/}/"* ) ]]; then
+ _cc_assert_initialized_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_assert_initialized_checked_programs="$_cc_assert_initialized_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_assert_initialized_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_assert_initialized_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_assert_initialized_source" CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH \
+ /home/cloud-compose/bootstrap-helpers.sh "$_cc_assert_initialized_dir/bootstrap-helpers.sh"
+bootstrap_helpers_path="$CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH"
+readonly bootstrap_helpers_path
+
# shellcheck disable=SC1090
-source "${CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH:-/home/cloud-compose/bootstrap-helpers.sh}"
+source "$bootstrap_helpers_path"
-durable_marker="${CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER:-/home/cloud-compose/.cloud-compose-bootstrap-complete}"
+durable_marker="${CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER:-/var/lib/cloud-compose/bootstrap-complete}"
boot_marker="${CLOUD_COMPOSE_APP_INIT_MARKER:-/run/cloud-compose-app-init-complete}"
if cloud_compose_marker_exists "$durable_marker" ||
diff --git a/rootfs/home/cloud-compose/assert-vault-ready.sh b/rootfs/home/cloud-compose/assert-vault-ready.sh
index 4662a49..33f3f48 100644
--- a/rootfs/home/cloud-compose/assert-vault-ready.sh
+++ b/rootfs/home/cloud-compose/assert-vault-ready.sh
@@ -2,7 +2,28 @@
set -euo pipefail
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-/home/cloud-compose/profile.sh}"
+_cc_assert_vault_ready_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_assert_vault_ready_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_assert_vault_ready_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_assert_vault_ready_source _cc_assert_vault_ready_dir _cc_assert_vault_ready_installed_home
+if [[ -n "$_cc_assert_vault_ready_installed_home" &&
+ ( "$_cc_assert_vault_ready_installed_home" == "/" ||
+ "$_cc_assert_vault_ready_source" == "${_cc_assert_vault_ready_installed_home%/}/"* ) ]]; then
+ _cc_assert_vault_ready_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_assert_vault_ready_checked_programs="$_cc_assert_vault_ready_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_assert_vault_ready_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_assert_vault_ready_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_assert_vault_ready_source" \
+ CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh \
+ "$_cc_assert_vault_ready_dir/profile.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+readonly profile_path
+
# shellcheck disable=SC1090
source "$profile_path"
diff --git a/rootfs/home/cloud-compose/bootstrap-helpers.sh b/rootfs/home/cloud-compose/bootstrap-helpers.sh
index 4a4c774..a83769a 100644
--- a/rootfs/home/cloud-compose/bootstrap-helpers.sh
+++ b/rootfs/home/cloud-compose/bootstrap-helpers.sh
@@ -1,9 +1,17 @@
#!/usr/bin/env bash
cloud_compose_marker_exists() {
- local marker="$1"
-
- [[ -f "$marker" && ! -L "$marker" ]]
+ local marker="$1" marker_size payload
+
+ [[ -f "$marker" && ! -L "$marker" ]] || return 1
+ if [[ "$marker" == "/var/lib/cloud-compose/bootstrap-complete" ]]; then
+ [[ "$(stat -c '%u:%g:%a:%F' -- "$(dirname -- "$marker")")" == "0:0:755:directory" &&
+ "$(stat -c '%u:%g:%a:%h:%F' -- "$marker")" == "0:0:644:1:regular file" ]] || return 1
+ marker_size="$(stat -c '%s' -- "$marker")" || return 1
+ [[ "$marker_size" == "6" ]] || return 1
+ IFS= read -r payload <"$marker" || return 1
+ [[ "$payload" == "ready" ]]
+ fi
}
cloud_compose_should_run_app_init() {
@@ -23,12 +31,25 @@ cloud_compose_publish_marker() (
echo "Unsafe Cloud Compose marker directory: $marker_dir" >&2
return 1
fi
+ if [[ "$marker" == "/var/lib/cloud-compose/bootstrap-complete" &&
+ ( "$EUID" != "0" ||
+ "$(stat -c '%u:%g:%a:%F' -- "$marker_dir")" != "0:0:755:directory" ) ]]; then
+ echo "Durable Cloud Compose readiness requires a root-owned state directory" >&2
+ return 1
+ fi
umask 022
tmp_marker="$(mktemp "${marker}.tmp.XXXXXXXXXX")" || return 1
if ! printf 'ready\n' >"$tmp_marker" ||
- ! chmod 0644 "$tmp_marker" ||
- ! mv -fT -- "$tmp_marker" "$marker"; then
+ ! chmod 0644 "$tmp_marker"; then
+ rm -f -- "$tmp_marker"
+ return 1
+ fi
+ if ((EUID == 0)) && ! chown 0:0 "$tmp_marker"; then
+ rm -f -- "$tmp_marker"
+ return 1
+ fi
+ if ! mv -fT -- "$tmp_marker" "$marker"; then
rm -f -- "$tmp_marker"
return 1
fi
@@ -121,6 +142,7 @@ cloud_compose_wait_for_oneshot() {
local unit="$1"
local timeout_seconds="$2"
local poll_seconds="${CLOUD_COMPOSE_SYSTEMD_POLL_SECONDS:-2}"
+ local heartbeat_seconds="${CLOUD_COMPOSE_SYSTEMD_HEARTBEAT_SECONDS:-300}"
local elapsed=0 active_state load_state
cloud_compose_validate_systemd_unit "$unit" || return
@@ -134,6 +156,11 @@ cloud_compose_wait_for_oneshot() {
echo "CLOUD_COMPOSE_SYSTEMD_POLL_SECONDS must be from 1 through 300 seconds" >&2
return 2
fi
+ if [[ ! "$heartbeat_seconds" =~ ^[1-9][0-9]{0,3}$ ]] ||
+ ((10#$heartbeat_seconds > 3600)); then
+ echo "CLOUD_COMPOSE_SYSTEMD_HEARTBEAT_SECONDS must be from 1 through 3600 seconds" >&2
+ return 2
+ fi
while ((elapsed < 10#$timeout_seconds)); do
load_state="$(systemctl show --property=LoadState --value -- "$unit")" || return 1
@@ -146,8 +173,19 @@ cloud_compose_wait_for_oneshot() {
if [[ "$active_state" == "active" ]]; then
return 0
fi
+ if [[ "$active_state" == "failed" ]]; then
+ echo "Cloud Compose systemd unit reached a terminal failed state: $unit" >&2
+ systemctl status --no-pager --full -- "$unit" >&2 || true
+ return 1
+ fi
sleep "$poll_seconds"
elapsed=$((elapsed + 10#$poll_seconds))
+ if ((elapsed % 10#$heartbeat_seconds < 10#$poll_seconds)); then
+ echo "Still waiting for $unit after ${elapsed}s (active state: $active_state)" >&2
+ systemctl show --no-pager \
+ --property=ActiveState,SubState,Result,NRestarts,ExecMainCode,ExecMainStatus \
+ -- "$unit" >&2 || true
+ fi
done
echo "Timed out waiting ${timeout_seconds}s for $unit to become active" >&2
diff --git a/rootfs/home/cloud-compose/compose-apps.sh b/rootfs/home/cloud-compose/compose-apps.sh
index d1834b9..9b67610 100644
--- a/rootfs/home/cloud-compose/compose-apps.sh
+++ b/rootfs/home/cloud-compose/compose-apps.sh
@@ -6,6 +6,52 @@ COMPOSE_PROJECTS_FILE="${COMPOSE_PROJECTS_FILE:-/home/cloud-compose/compose-proj
COMPOSE_APPS_ENV_DIR="${COMPOSE_APPS_ENV_DIR:-/home/cloud-compose/apps}"
COMPOSE_APPS_STATE_DIR="${COMPOSE_APPS_STATE_DIR:-/home/cloud-compose/state}"
CLOUD_COMPOSE_DATA_ROOT="${CLOUD_COMPOSE_DATA_ROOT:-/mnt/disks/data}"
+_cc_compose_apps_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_compose_apps_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_compose_apps_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_compose_apps_source _cc_compose_apps_dir _cc_compose_apps_installed_home
+if [[ -n "$_cc_compose_apps_installed_home" &&
+ ( "$_cc_compose_apps_installed_home" == "/" ||
+ "$_cc_compose_apps_source" == "${_cc_compose_apps_installed_home%/}/"* ) ]]; then
+ _cc_compose_apps_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_compose_apps_checked_programs="$_cc_compose_apps_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_compose_apps_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_compose_apps_checked_programs"
+cloud_compose_bind_program_dir \
+ "$_cc_compose_apps_source" \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR \
+ /etc/cloud-compose/jq \
+ "$_cc_compose_apps_dir/../../etc/cloud-compose/jq" \
+ compose-validate-projects.jq \
+ compose-app-field.jq \
+ compose-app-array.jq \
+ compose-app-verify-args.jq \
+ compose-app-verify-args-json.jq \
+ compose-app-ingress-field.jq \
+ compose-app-ingress-array.jq \
+ compose-reject-host-network.jq \
+ compose-project-dirs-base64.jq \
+ compose-services-object-validate.jq \
+ array-values-base64.jq \
+ object-has-key.jq \
+ object-keys-base64.jq \
+ object-keys.jq \
+ string-array-validate.jq
+cloud_compose_bind_program \
+ "$_cc_compose_apps_source" \
+ CLOUD_COMPOSE_COMPOSE_SECRET_FILES_PROGRAM \
+ /etc/cloud-compose/awk/compose-secret-files.awk \
+ "$_cc_compose_apps_dir/../../etc/cloud-compose/awk/compose-secret-files.awk"
+COMPOSE_SECRET_FILES_PROGRAM="$CLOUD_COMPOSE_COMPOSE_SECRET_FILES_PROGRAM"
+readonly COMPOSE_SECRET_FILES_PROGRAM
+readonly COMPOSE_LIFECYCLE_EXECUTOR="/etc/cloud-compose/libexec/run-lifecycle-program.sh"
+
+run_compose_lifecycle_executor() {
+ "$COMPOSE_LIFECYCLE_EXECUTOR" "$@"
+}
shell_env_line() {
local name="$1"
@@ -59,32 +105,8 @@ validate_compose_projects_manifest() {
return 1
fi
- if ! jq -e '
- type == "object" and length > 0 and
- all(to_entries[]; . as $entry |
- ($entry.key | explode | index(0) == null) and
- ($entry.value | type == "object") and
- (all($entry.value | .. | select(type == "string");
- explode | index(0) == null)) and
- ($entry.value.docker_compose_repo | type == "string" and length > 0) and
- ($entry.value.docker_compose_branch | type == "string" and length > 0) and
- ($entry.value.project_dir | type == "string" and length > 0) and
- ($entry.value.compose_project_name | type == "string" and length > 0) and
- (all(["init_commands", "up_commands", "down_commands", "rollout_commands"][];
- . as $field |
- ($entry.value[$field] == null) or
- (($entry.value[$field] | type) == "array" and all($entry.value[$field][]; type == "string"))
- )) and
- (($entry.value.sitectl_verify_args == null) or
- (($entry.value.sitectl_verify_args | type) == "array" and all($entry.value.sitectl_verify_args[];
- type == "string" and
- (explode | index(0) == null) and
- (contains("\n") | not) and
- (contains("\r") | not)
- ))) and
- (($entry.value.ingress == null) or ($entry.value.ingress | type == "object"))
- )
- ' "$COMPOSE_PROJECTS_FILE" >/dev/null; then
+ if ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-validate-projects.jq" \
+ "$COMPOSE_PROJECTS_FILE" >/dev/null; then
echo "Invalid cloud-compose project manifest: $COMPOSE_PROJECTS_FILE" >&2
return 1
fi
@@ -99,7 +121,8 @@ validate_compose_projects_manifest() {
)" || return 1
app="${app%$'\x1f'}"
validate_compose_app_name "$app" || return 1
- done < <(jq -r 'keys[] | @base64' "$COMPOSE_PROJECTS_FILE")
+ done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-keys-base64.jq" \
+ "$COMPOSE_PROJECTS_FILE")
while IFS= read -r encoded_project_dir; do
project_dir="$(
@@ -110,7 +133,8 @@ validate_compose_projects_manifest() {
)" || return 1
project_dir="${project_dir%$'\x1f'}"
validate_compose_project_dir "$project_dir" || return 1
- done < <(jq -r '.[] | .project_dir | @base64' "$COMPOSE_PROJECTS_FILE")
+ done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-project-dirs-base64.jq" \
+ "$COMPOSE_PROJECTS_FILE")
}
# Converge one already-existing manifest project without traversing or changing
@@ -241,7 +265,8 @@ compose_app_exists() {
validate_compose_app_name "$app" || return 1
validate_compose_projects_manifest || return 1
- jq -e --arg app "$app" 'has($app)' "$COMPOSE_PROJECTS_FILE" >/dev/null || {
+ jq -e --arg key "$app" -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-has-key.jq" \
+ "$COMPOSE_PROJECTS_FILE" >/dev/null || {
echo "Cloud-compose app is not present in the manifest: $app" >&2
return 1
}
@@ -253,7 +278,8 @@ compose_app_names_array() {
local -n "result=$result_name"
validate_compose_projects_manifest || return 1
- names="$(jq -er 'keys[]' "$COMPOSE_PROJECTS_FILE")" || return 1
+ names="$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-keys.jq" \
+ "$COMPOSE_PROJECTS_FILE")" || return 1
result=()
while IFS= read -r app; do
validate_compose_app_name "$app" || return 1
@@ -273,14 +299,9 @@ compose_app_field() {
local field="$2"
compose_app_exists "$app" || return 1
- jq -er --arg app "$app" --arg field "$field" '
- (.[$app][$field] // "" | tostring) as $value |
- if $value | (explode | index(0) != null) or contains("\n") or contains("\r") then
- error("invalid scalar field")
- else
- $value
- end
- ' "$COMPOSE_PROJECTS_FILE"
+ jq -er --arg app "$app" --arg field "$field" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-field.jq" \
+ "$COMPOSE_PROJECTS_FILE"
}
compose_app_array_values() {
@@ -291,20 +312,16 @@ compose_app_array_values() {
local -n "result=$result_name"
compose_app_exists "$app" || return 1
- payload="$(jq -ce --arg app "$app" --arg field "$field" '
- (.[$app][$field] // []) as $values |
- if ($values | type) != "array" or any($values[]; type != "string") then
- error("invalid string array")
- else
- $values
- end
- ' "$COMPOSE_PROJECTS_FILE")" || {
+ payload="$(jq -ce --arg app "$app" --arg field "$field" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-array.jq" \
+ "$COMPOSE_PROJECTS_FILE")" || {
echo "Invalid $field array for cloud-compose app $app" >&2
return 1
}
result=()
- encoded_lines="$(jq -r '.[] | @base64' <<<"$payload")" || return 1
+ encoded_lines="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/array-values-base64.jq" \
+ <<<"$payload")" || return 1
if [[ -z "$encoded_lines" ]]; then
return 0
fi
@@ -336,30 +353,18 @@ compose_app_verify_args() {
local app="$1"
compose_app_exists "$app" || return 1
- jq -er --arg app "$app" '
- (.[$app].sitectl_verify_args // []) as $values |
- if ($values | type) != "array" or any($values[]; type != "string") then
- error("invalid verify args")
- else
- $values | join(" ")
- end
- ' "$COMPOSE_PROJECTS_FILE"
+ jq -er --arg app "$app" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-verify-args.jq" \
+ "$COMPOSE_PROJECTS_FILE"
}
compose_app_verify_args_json() {
local app="$1"
compose_app_exists "$app" || return 1
- jq -cer --arg app "$app" '
- (.[$app].sitectl_verify_args // []) as $values |
- if ($values | type) != "array" or any($values[];
- type != "string" or (explode | index(0) != null) or contains("\n") or contains("\r")
- ) then
- error("invalid verify args")
- else
- $values
- end
- ' "$COMPOSE_PROJECTS_FILE"
+ jq -cer --arg app "$app" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-verify-args-json.jq" \
+ "$COMPOSE_PROJECTS_FILE"
}
compose_app_ingress_field() {
@@ -367,14 +372,9 @@ compose_app_ingress_field() {
local field="$2"
compose_app_exists "$app" || return 1
- jq -er --arg app "$app" --arg field "$field" '
- (.[$app].ingress[$field] // "" | tostring) as $value |
- if $value | (explode | index(0) != null) or contains("\n") or contains("\r") then
- error("invalid ingress scalar field")
- else
- $value
- end
- ' "$COMPOSE_PROJECTS_FILE"
+ jq -er --arg app "$app" --arg field "$field" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-ingress-field.jq" \
+ "$COMPOSE_PROJECTS_FILE"
}
compose_app_ingress_array_values() {
@@ -385,20 +385,16 @@ compose_app_ingress_array_values() {
local -n "result=$result_name"
compose_app_exists "$app" || return 1
- payload="$(jq -ce --arg app "$app" --arg field "$field" '
- (.[$app].ingress[$field] // []) as $values |
- if ($values | type) != "array" or any($values[]; type != "string") then
- error("invalid ingress string array")
- else
- $values
- end
- ' "$COMPOSE_PROJECTS_FILE")" || {
+ payload="$(jq -ce --arg app "$app" --arg field "$field" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-ingress-array.jq" \
+ "$COMPOSE_PROJECTS_FILE")" || {
echo "Invalid ingress $field array for cloud-compose app $app" >&2
return 1
}
result=()
- encoded_lines="$(jq -r '.[] | @base64' <<<"$payload")" || return 1
+ encoded_lines="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/array-values-base64.jq" \
+ <<<"$payload")" || return 1
if [[ -z "$encoded_lines" ]]; then
return 0
fi
@@ -570,27 +566,12 @@ EOF
compose_secret_files() {
local compose_file
- for compose_file in docker-compose.yaml docker-compose.yml; do
+ for compose_file in compose.yaml compose.yml 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"
+ awk -f "$COMPOSE_SECRET_FILES_PROGRAM" "$compose_file"
done | sort -u
}
@@ -700,7 +681,7 @@ configure_sitectl_verify_argv() {
# not invoke sitectl (for example a source-policy contract). The exported
# wrapper fails at the actual call site if the executable is missing.
SITECTL_EXECUTABLE="$(type -P sitectl || true)"
- if ! jq -e 'type == "array" and all(.[]; type == "string")' \
+ if ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/string-array-validate.jq" \
<<<"${SITECTL_VERIFY_ARGS_JSON:-[]}" >/dev/null; then
echo "Invalid sitectl verify argument JSON for ${APP_NAME:-unknown app}" >&2
return 1
@@ -723,7 +704,8 @@ configure_sitectl_verify_argv() {
[[ -n "$encoded" ]] || continue
decoded="$(printf '%s' "$encoded" | base64 -d)" || return 1
configured_verify_args+=("$decoded")
- done < <(jq -r '.[] | @base64' <<<"${SITECTL_VERIFY_ARGS_JSON:-[]}")
+ done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/array-values-base64.jq" \
+ <<<"${SITECTL_VERIFY_ARGS_JSON:-[]}")
fi
"$executable" "$@" "${configured_verify_args[@]}"
}
@@ -797,6 +779,82 @@ record_compose_app_head() {
mv -f "$state_tmp" "$state_file"
}
+compose_checkout_diff_digest() {
+ local digest digest_output diff_file
+
+ diff_file="$(mktemp)" || return 1
+ if ! git diff --binary --full-index --no-color --no-ext-diff --no-textconv HEAD -- >"$diff_file"; then
+ rm -f -- "$diff_file"
+ echo "Could not fingerprint managed Compose changes" >&2
+ return 1
+ fi
+ if ! digest_output="$(sha256sum "$diff_file")"; then
+ rm -f -- "$diff_file"
+ echo "Could not hash managed Compose changes" >&2
+ return 1
+ fi
+ rm -f -- "$diff_file"
+ digest="${digest_output%% *}"
+ if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then
+ echo "Managed Compose change fingerprint is invalid" >&2
+ return 1
+ fi
+ printf '%s\n' "$digest"
+}
+
+record_compose_managed_diff() {
+ local app="$1"
+ local digest state_file state_tmp
+
+ validate_compose_app_name "$app" || return 1
+ digest="$(compose_checkout_diff_digest)" || return 1
+ install -d -m 0750 "$COMPOSE_APPS_STATE_DIR"
+ state_file="${COMPOSE_APPS_STATE_DIR}/${app}.managed-diff"
+ state_tmp="$(mktemp "${state_file}.tmp.XXXXXX")"
+ printf '%s\n' "$digest" >"$state_tmp"
+ chmod 0640 "$state_tmp"
+ chown cloud-compose:cloud-compose "$state_tmp" 2>/dev/null || true
+ mv -f "$state_tmp" "$state_file"
+}
+
+verify_recorded_compose_managed_diff() {
+ local app="$1"
+ local current_digest recorded_digest state_file
+
+ validate_compose_app_name "$app" || return 1
+ state_file="${COMPOSE_APPS_STATE_DIR}/${app}.managed-diff"
+ if [[ -L "$state_file" || ! -f "$state_file" ]]; then
+ echo "Compose checkout contains tracked changes without recorded managed state: $state_file" >&2
+ return 1
+ fi
+ recorded_digest="$(<"$state_file")"
+ if [[ ! "$recorded_digest" =~ ^[0-9a-f]{64}$ ]]; then
+ echo "Recorded managed Compose state is invalid for ${app}: $state_file" >&2
+ return 1
+ fi
+ current_digest="$(compose_checkout_diff_digest)" || return 1
+ if [[ "$current_digest" != "$recorded_digest" ]]; then
+ echo "Compose checkout differs from its recorded sitectl-managed state; commit operator changes in the downstream fork before deployment" >&2
+ return 1
+ fi
+}
+
+restore_recorded_compose_managed_diff() {
+ local app="$1"
+
+ if git diff --quiet --ignore-submodules -- &&
+ git diff --cached --quiet --ignore-submodules --; then
+ return 0
+ fi
+ verify_recorded_compose_managed_diff "$app" || return 1
+ git restore --source=HEAD --staged --worktree -- . || return 1
+ if ! git diff --quiet --ignore-submodules -- ||
+ ! git diff --cached --quiet --ignore-submodules --; then
+ echo "Could not restore the committed Compose source before deployment" >&2
+ return 1
+ fi
+}
+
checkout_exact_compose_commit() {
local app="$1"
local requested_commit="$2"
@@ -832,12 +890,16 @@ verify_compose_origin() {
}
verify_clean_compose_checkout() {
+ local app="${1:-}"
local untracked_compose_control
if ! git diff --quiet --ignore-submodules -- ||
! git diff --cached --quiet --ignore-submodules --; then
- echo "Compose checkout contains tracked or staged changes; commit them in the downstream fork before deployment" >&2
- return 1
+ if [[ -z "$app" ]]; then
+ echo "Compose checkout contains tracked or staged changes; commit them in the downstream fork before deployment" >&2
+ return 1
+ fi
+ verify_recorded_compose_managed_diff "$app" || return 1
fi
untracked_compose_control="$(git ls-files --others --exclude-standard -- \
':(glob)**/compose*.yml' ':(glob)**/compose*.yaml' \
@@ -849,7 +911,7 @@ verify_clean_compose_checkout() {
}
reject_host_network_compose_services() {
- local config_json
+ local config_json filter_status
if [[ "${CLOUD_COMPOSE_PROVIDER:-}" != "gcp" ]]; then
return 0
@@ -862,26 +924,21 @@ reject_host_network_compose_services() {
echo "Could not render Compose configuration for metadata-isolation validation" >&2
return 1
}
- if ! jq -e '.services | type == "object"' <<<"$config_json" >/dev/null; then
+ if ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-services-object-validate.jq" \
+ <<<"$config_json" >/dev/null; then
echo "Docker Compose returned an invalid service configuration" >&2
return 1
fi
- if jq -e '
- any(.services[];
- (.network_mode // "") == "host" or
- (
- (.build | type) == "object" and
- (
- (.build.network // "") == "host" or
- any((.build.entitlements // [])[];
- . == "network.host" or . == "security.insecure"
- )
- )
- )
- )
- ' <<<"$config_json" >/dev/null; then
+ if jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-reject-host-network.jq" \
+ <<<"$config_json" >/dev/null; then
echo "Host runtime/build networking and insecure BuildKit entitlements are not allowed on GCP because they bypass container metadata isolation" >&2
return 1
+ else
+ filter_status=$?
+ fi
+ if [[ "$filter_status" -ne 1 ]]; then
+ echo "Could not evaluate Compose network isolation with the checked jq program" >&2
+ return 1
fi
}
@@ -907,7 +964,7 @@ clone_or_update_compose_app() {
return 1
}
fi
- verify_clean_compose_checkout || { popd >/dev/null; return 1; }
+ verify_clean_compose_checkout "$app" || { popd >/dev/null; return 1; }
if [ "$(id -u)" -eq 0 ]; then
chown -R cloud-compose:cloud-compose . || { popd >/dev/null; return 1; }
fi
@@ -915,7 +972,8 @@ clone_or_update_compose_app() {
else
pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1
verify_compose_origin || { popd >/dev/null; return 1; }
- verify_clean_compose_checkout || { popd >/dev/null; return 1; }
+ verify_clean_compose_checkout "$app" || { popd >/dev/null; return 1; }
+ restore_recorded_compose_managed_diff "$app" || { popd >/dev/null; return 1; }
if compose_ref_is_full_commit "$DOCKER_COMPOSE_BRANCH"; then
checkout_exact_compose_commit "$app" "$DOCKER_COMPOSE_BRANCH" || { popd >/dev/null; return 1; }
else
@@ -961,7 +1019,7 @@ clone_or_update_compose_app() {
return 1
fi
fi
- verify_clean_compose_checkout || { popd >/dev/null; return 1; }
+ verify_clean_compose_checkout "$app" || { popd >/dev/null; return 1; }
popd >/dev/null
fi
}
@@ -979,7 +1037,7 @@ verify_existing_compose_app_checkout() {
pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1
verify_compose_origin || { popd >/dev/null; return 1; }
- verify_clean_compose_checkout || { popd >/dev/null; return 1; }
+ verify_clean_compose_checkout "$app" || { popd >/dev/null; return 1; }
current_head="$(git rev-parse --verify 'HEAD^{commit}')" || {
echo "Compose checkout has no deployed commit for ${app}: $DOCKER_COMPOSE_DIR" >&2
popd >/dev/null
@@ -996,8 +1054,6 @@ verify_existing_compose_app_checkout() {
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
local -a trusted_ips=()
@@ -1018,43 +1074,35 @@ configure_sitectl_app_features() {
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
compose_app_ingress_array_values "$app" trusted_ips trusted_ips || return 1
for trusted_ip in "${trusted_ips[@]}"; do
if [ -n "$trusted_ip" ]; then
ingress_args+=(--trusted-ip "$trusted_ip")
- configure_ingress=true
fi
done
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
+ # Always initialize component desired state, including when every ingress
+ # option uses its default. Verification deliberately fails when
+ # .libops/site.yaml is absent, and component set initializes every
+ # registered component from its declared default before applying ingress.
+ sitectl "${ingress_args[@]}"
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
+ sitectl converge --context "$SITECTL_CONTEXT_NAME" --yolo
}
run_compose_app_lifecycle() {
@@ -1064,6 +1112,22 @@ run_compose_app_lifecycle() {
local command command_status
local -a commands=()
+ case "$lifecycle" in
+ init | up | down | rollout) ;;
+ *)
+ echo "Unsupported cloud-compose lifecycle: $lifecycle" >&2
+ return 2
+ ;;
+ esac
+
+ # Reject the entire program set before cloning, updating, or running
+ # anything so a bad later selector cannot leave partial lifecycle state.
+ compose_app_array_values "$app" "$field" commands || return 1
+ for command in "${commands[@]}"; do
+ [[ -n "$command" ]] || continue
+ run_compose_lifecycle_executor --validate "$lifecycle" "$command" || return 1
+ done
+
case "$lifecycle" in
init)
# Initialization is the explicit baseline-source convergence phase.
@@ -1082,19 +1146,19 @@ run_compose_app_lifecycle() {
else
clone_or_update_compose_app "$app" || return 1
fi
+ if [[ "$lifecycle" == "rollout" ]]; then
+ pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1
+ restore_recorded_compose_managed_diff "$app" || { popd >/dev/null; return 1; }
+ popd >/dev/null
+ fi
;;
down)
source_compose_app_env "$app" || return 1
validate_compose_git_source || return 1
;;
- *)
- echo "Unsupported cloud-compose lifecycle: $lifecycle" >&2
- return 2
- ;;
esac
echo "Running cloud-compose ${lifecycle} for ${app}"
- compose_app_array_values "$app" "$field" commands || return 1
configure_sitectl_verify_argv || return 1
pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1
if [[ "$lifecycle" != "down" ]]; then
@@ -1104,7 +1168,7 @@ run_compose_app_lifecycle() {
if [ -z "$command" ]; then
continue
fi
- bash -c "$command" || {
+ run_compose_lifecycle_executor "$lifecycle" "$command" || {
command_status=$?
popd >/dev/null
return "$command_status"
@@ -1113,5 +1177,8 @@ run_compose_app_lifecycle() {
if [[ "$lifecycle" != "down" ]]; then
record_compose_app_head "$app" || { popd >/dev/null; return 1; }
fi
+ if [[ "$lifecycle" == "rollout" ]]; then
+ record_compose_managed_diff "$app" || { popd >/dev/null; return 1; }
+ fi
popd >/dev/null
}
diff --git a/rootfs/home/cloud-compose/configure-metadata-firewall.sh b/rootfs/home/cloud-compose/configure-metadata-firewall.sh
index 257734e..70ee5c2 100644
--- a/rootfs/home/cloud-compose/configure-metadata-firewall.sh
+++ b/rootfs/home/cloud-compose/configure-metadata-firewall.sh
@@ -2,6 +2,28 @@
set -euo pipefail
+_cc_configure_metadata_firewall_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_configure_metadata_firewall_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_configure_metadata_firewall_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_configure_metadata_firewall_source _cc_configure_metadata_firewall_dir _cc_configure_metadata_firewall_installed_home
+if [[ -n "$_cc_configure_metadata_firewall_installed_home" &&
+ ( "$_cc_configure_metadata_firewall_installed_home" == "/" ||
+ "$_cc_configure_metadata_firewall_source" == "${_cc_configure_metadata_firewall_installed_home%/}/"* ) ]]; then
+ _cc_configure_metadata_firewall_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_configure_metadata_firewall_checked_programs="$_cc_configure_metadata_firewall_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_configure_metadata_firewall_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_configure_metadata_firewall_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_configure_metadata_firewall_source" \
+ CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh \
+ "$_cc_configure_metadata_firewall_dir/profile.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+readonly profile_path
+
mode="${1:-full}"
case "$mode" in
full | pre-docker)
@@ -12,9 +34,6 @@ case "$mode" in
;;
esac
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
-
# shellcheck disable=SC1090
source "$profile_path"
diff --git a/rootfs/home/cloud-compose/converge-app-filesystems.sh b/rootfs/home/cloud-compose/converge-app-filesystems.sh
index 02ac070..11f861b 100644
--- a/rootfs/home/cloud-compose/converge-app-filesystems.sh
+++ b/rootfs/home/cloud-compose/converge-app-filesystems.sh
@@ -2,15 +2,40 @@
set -euo pipefail
+_cc_converge_app_filesystems_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_converge_app_filesystems_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_converge_app_filesystems_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_converge_app_filesystems_source _cc_converge_app_filesystems_dir _cc_converge_app_filesystems_installed_home
+if [[ -n "$_cc_converge_app_filesystems_installed_home" &&
+ ( "$_cc_converge_app_filesystems_installed_home" == "/" ||
+ "$_cc_converge_app_filesystems_source" == "${_cc_converge_app_filesystems_installed_home%/}/"* ) ]]; then
+ _cc_converge_app_filesystems_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_converge_app_filesystems_checked_programs="$_cc_converge_app_filesystems_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_converge_app_filesystems_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_converge_app_filesystems_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_converge_app_filesystems_source" \
+ CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh \
+ "$_cc_converge_app_filesystems_dir/profile.sh"
+cloud_compose_bind_source_program \
+ "$_cc_converge_app_filesystems_source" \
+ CLOUD_COMPOSE_COMPOSE_APPS_PATH \
+ /home/cloud-compose/compose-apps.sh \
+ "$_cc_converge_app_filesystems_dir/compose-apps.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+compose_apps_path="$CLOUD_COMPOSE_COMPOSE_APPS_PATH"
+readonly profile_path compose_apps_path
+
if ((EUID != 0)); then
echo "Compose application filesystem convergence must run as root" >&2
exit 1
fi
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
-compose_apps_path="${CLOUD_COMPOSE_COMPOSE_APPS_PATH:-$script_dir/compose-apps.sh}"
-cd "$script_dir"
+cd "$_cc_converge_app_filesystems_dir"
# shellcheck disable=SC1090
source "$profile_path"
# shellcheck disable=SC1090
diff --git a/rootfs/home/cloud-compose/default-lifecycle.sh b/rootfs/home/cloud-compose/default-lifecycle.sh
new file mode 100644
index 0000000..424a793
--- /dev/null
+++ b/rootfs/home/cloud-compose/default-lifecycle.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+_cc_default_lifecycle_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_default_lifecycle_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_default_lifecycle_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_default_lifecycle_source _cc_default_lifecycle_dir _cc_default_lifecycle_installed_home
+if [[ -n "$_cc_default_lifecycle_installed_home" &&
+ ( "$_cc_default_lifecycle_installed_home" == "/" ||
+ "$_cc_default_lifecycle_source" == "${_cc_default_lifecycle_installed_home%/}/"* ) ]]; then
+ _cc_default_lifecycle_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_default_lifecycle_checked_programs="$_cc_default_lifecycle_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_default_lifecycle_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_default_lifecycle_checked_programs"
+cloud_compose_bind_program \
+ "$_cc_default_lifecycle_source" \
+ CLOUD_COMPOSE_SITECTL_VERIFY_ARGS_PROGRAM \
+ /etc/cloud-compose/jq/sitectl-verify-args.jq \
+ "$_cc_default_lifecycle_dir/../../etc/cloud-compose/jq/sitectl-verify-args.jq"
+sitectl_verify_args_program="$CLOUD_COMPOSE_SITECTL_VERIFY_ARGS_PROGRAM"
+readonly sitectl_verify_args_program
+
+action="${1:-}"
+if [[ "$#" -ne 1 ]]; then
+ echo "usage: default-lifecycle.sh init|up|down|rollout" >&2
+ exit 2
+fi
+
+case "$action" in
+ init | up | down | rollout) ;;
+ *)
+ echo "unknown default lifecycle action: $action" >&2
+ exit 2
+ ;;
+esac
+
+context="${SITECTL_CONTEXT_NAME:?SITECTL_CONTEXT_NAME is required}"
+
+run_sitectl() {
+ if [[ -n "${SITECTL_EXECUTABLE:-}" ]]; then
+ "${SITECTL_EXECUTABLE}" "$@"
+ return
+ fi
+ command sitectl "$@"
+}
+
+verify_nonproduction() {
+ local encoded decoded encoded_args
+ local -a verify_args=()
+
+ if [[ "${SITECTL_ENVIRONMENT:?SITECTL_ENVIRONMENT is required}" != "production" ]]; then
+ if ! encoded_args="$(jq -r -f "$sitectl_verify_args_program" \
+ <<<"${SITECTL_VERIFY_ARGS_JSON:-[]}")"; then
+ echo "SITECTL_VERIFY_ARGS_JSON must be an array of strings" >&2
+ return 1
+ fi
+ while IFS= read -r encoded; do
+ [[ -n "$encoded" ]] || continue
+ decoded="$(printf '%s' "${encoded#x}" | base64 -d)"
+ verify_args+=("$decoded")
+ done <<<"$encoded_args"
+ run_sitectl verify --context "$context" "${verify_args[@]}"
+ fi
+}
+
+case "$action" in
+ init)
+ run_sitectl config set-context "$context" \
+ --type local \
+ --project-dir "${DOCKER_COMPOSE_DIR:?DOCKER_COMPOSE_DIR is required}" \
+ --site "${CLOUD_COMPOSE_INSTANCE_NAME:?CLOUD_COMPOSE_INSTANCE_NAME is required}" \
+ --plugin "${SITECTL_PLUGIN:?SITECTL_PLUGIN is required}" \
+ --environment "${SITECTL_ENVIRONMENT:?SITECTL_ENVIRONMENT is required}" \
+ --compose-project-name "${COMPOSE_PROJECT_NAME:?COMPOSE_PROJECT_NAME is required}" \
+ --docker-socket /var/run/docker.sock \
+ --env-file .env \
+ --yolo \
+ --default
+ ;;
+ up)
+ run_sitectl compose --context "$context" up -d --remove-orphans
+ run_sitectl healthcheck --context "$context" --persist
+ verify_nonproduction
+ ;;
+ down)
+ run_sitectl compose --context "$context" down
+ ;;
+ rollout)
+ commit_sha="${GIT_COMMIT_SHA:-}"
+ if [[ -n "$commit_sha" && ! "$commit_sha" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "GIT_COMMIT_SHA must be an exact lowercase 40-character commit SHA" >&2
+ exit 2
+ fi
+ target_ref="${commit_sha:-${GIT_REF:-${GIT_BRANCH:-}}}"
+ if [[ -n "$target_ref" ]]; then
+ run_sitectl deploy --context "$context" --ref "$target_ref"
+ else
+ run_sitectl deploy --context "$context" --skip-git
+ fi
+ run_sitectl healthcheck --context "$context" --persist
+ verify_nonproduction
+ ;;
+esac
diff --git a/rootfs/home/cloud-compose/disaster-recovery-lib.sh b/rootfs/home/cloud-compose/disaster-recovery-lib.sh
new file mode 100644
index 0000000..3175b5f
--- /dev/null
+++ b/rootfs/home/cloud-compose/disaster-recovery-lib.sh
@@ -0,0 +1,227 @@
+#!/usr/bin/env bash
+
+# Shared validation for the provider-neutral disaster-recovery driver contract.
+# The caller must enable `set -euo pipefail` before sourcing this file.
+
+_cc_dr_library_source="$(readlink -f -- "${BASH_SOURCE[0]}")" || {
+ echo "Could not resolve the Cloud Compose disaster-recovery library path" >&2
+ return 1 2>/dev/null || exit 1
+}
+_cc_dr_library_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || {
+ echo "Could not resolve the Cloud Compose disaster-recovery library directory" >&2
+ return 1 2>/dev/null || exit 1
+}
+_cc_dr_library_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_dr_library_source _cc_dr_library_dir _cc_dr_library_installed_home
+if [[ -n "$_cc_dr_library_installed_home" &&
+ ( "$_cc_dr_library_installed_home" == "/" ||
+ "$_cc_dr_library_source" == "${_cc_dr_library_installed_home%/}/"* ) ]]; then
+ _cc_dr_library_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_dr_library_checked_programs="$_cc_dr_library_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_dr_library_checked_programs
+# shellcheck disable=SC1090
+if ! source "$_cc_dr_library_checked_programs"; then
+ echo "Could not load the checked Cloud Compose program resolver" >&2
+ return 1 2>/dev/null || exit 1
+fi
+if ! cloud_compose_bind_program_dir \
+ "$_cc_dr_library_source" \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR \
+ /etc/cloud-compose/jq \
+ "$_cc_dr_library_dir/../../etc/cloud-compose/jq" \
+ dr-validate-backup-receipt.jq \
+ dr-backup-completed-at.jq \
+ dr-backup-remote-id.jq \
+ dr-validate-restore-proof.jq \
+ dr-restore-completed-at.jq \
+ dr-restore-recovery-id.jq; then
+ return 1 2>/dev/null || exit 1
+fi
+
+CLOUD_COMPOSE_DR_STATE_ROOT="${CLOUD_COMPOSE_DR_STATE_ROOT:-/mnt/disks/data/.cloud-compose-disaster-recovery}"
+CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="${CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER:-/etc/cloud-compose/libexec/offhost-backup-driver}"
+
+cloud_compose_dr_is_required() {
+ case "${CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED:-false}" in
+ true) return 0 ;;
+ false) return 1 ;;
+ *)
+ echo "CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED must be true or false" >&2
+ return 2
+ ;;
+ esac
+}
+
+cloud_compose_dr_validate_safe_absolute_path() {
+ local path="$1" label="$2"
+
+ if [[ ! "$path" =~ ^/[A-Za-z0-9._/+:-]+$ || "$path" == *"//"* ||
+ "$path" =~ (^|/)\.\.?(/|$) ]]; then
+ echo "$label must be a safe absolute path without whitespace or dot segments" >&2
+ return 1
+ fi
+}
+
+cloud_compose_dr_validate_utc_timestamp() {
+ local value="$1" label="$2"
+
+ if [[ ! "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then
+ echo "$label must be an RFC 3339 UTC timestamp with whole-second precision" >&2
+ return 1
+ fi
+}
+
+cloud_compose_dr_validate_remote_id() {
+ local value="$1" label="$2"
+
+ if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511}$ ]]; then
+ echo "$label contains unsupported characters or exceeds 512 bytes" >&2
+ return 1
+ fi
+}
+
+cloud_compose_dr_sha256_file() {
+ local path="$1" output digest
+
+ output="$(sha256sum -- "$path")" || return 1
+ digest="${output%% *}"
+ if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then
+ echo "sha256sum returned an invalid digest for: $path" >&2
+ return 1
+ fi
+ printf '%s\n' "$digest"
+}
+
+cloud_compose_dr_validate_driver() {
+ local driver="${1:-$CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER}"
+ local current component metadata owner mode kind links resolved
+ local -a components
+
+ cloud_compose_dr_validate_safe_absolute_path "$driver" "Off-host backup driver path" || return 1
+ if [[ -L "$driver" || ! -f "$driver" || ! -x "$driver" ]]; then
+ echo "Off-host backup driver must be a non-symlink executable file: $driver" >&2
+ return 1
+ fi
+
+ resolved="$(realpath -e -- "$driver")" || return 1
+ if [[ "$resolved" != "$driver" ]]; then
+ echo "Off-host backup driver path must not traverse symbolic links: $driver" >&2
+ return 1
+ fi
+
+ IFS='/' read -r -a components <<<"${driver#/}"
+ current="/"
+ for component in "${components[@]:0:${#components[@]}-1}"; do
+ current="${current%/}/${component}"
+ if [[ -L "$current" || ! -d "$current" ]]; then
+ echo "Off-host backup driver parent must be a real directory: $current" >&2
+ return 1
+ fi
+ metadata="$(stat -c '%u:%a:%F' -- "$current")" || return 1
+ IFS=: read -r owner mode kind <<<"$metadata"
+ if [[ "$owner" != "0" || "$kind" != "directory" || ! "$mode" =~ ^[0-7]{3,4}$ ||
+ $((8#$mode & 0022)) -ne 0 ]]; then
+ echo "Off-host backup driver parents must be root-owned and not group/world writable: $current" >&2
+ return 1
+ fi
+ done
+
+ metadata="$(stat -c '%u:%a:%h:%F' -- "$driver")" || return 1
+ IFS=: read -r owner mode links kind <<<"$metadata"
+ if [[ "$owner" != "0" || "$links" != "1" || "$kind" != "regular file" ||
+ ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
+ echo "Off-host backup driver must be a single-link, root-owned executable that is not group/world writable: $driver" >&2
+ return 1
+ fi
+}
+
+cloud_compose_dr_validate_json_file() {
+ local path="$1" label="$2" metadata owner mode links kind size
+
+ if [[ -L "$path" || ! -f "$path" ]]; then
+ echo "$label is missing or unsafe" >&2
+ return 1
+ fi
+ metadata="$(stat -c '%u:%a:%h:%F' -- "$path")" || return 1
+ IFS=: read -r owner mode links kind <<<"$metadata"
+ if [[ "$owner" != "0" || "$links" != "1" || "$kind" != "regular file" ||
+ ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
+ echo "$label must be a single-link, root-owned regular file that is not group/world writable" >&2
+ return 1
+ fi
+ size="$(wc -c <"$path")" || return 1
+ if ((size < 2 || size > 65536)); then
+ echo "$label must contain between 2 and 65536 bytes" >&2
+ return 1
+ fi
+}
+
+cloud_compose_dr_prepare_state_directory() {
+ local path="$1" metadata owner mode kind
+
+ cloud_compose_dr_validate_safe_absolute_path "$path" "Disaster-recovery state path" || return 1
+ if [[ -L "$path" || ( -e "$path" && ! -d "$path" ) ]]; then
+ echo "Disaster-recovery state path is unsafe: $path" >&2
+ return 1
+ fi
+ install -d -m 0700 -o root -g root -- "$path" || return 1
+ metadata="$(stat -c '%u:%a:%F' -- "$path")" || return 1
+ IFS=: read -r owner mode kind <<<"$metadata"
+ if [[ "$owner" != "0" || "$mode" != "700" || "$kind" != "directory" || -L "$path" ]]; then
+ echo "Disaster-recovery state directories must be real root-owned mode-0700 directories: $path" >&2
+ return 1
+ fi
+}
+
+cloud_compose_dr_validate_backup_receipt() {
+ local path="$1" operation_id="$2" manifest_sha256="$3" completed_at remote_id
+
+ cloud_compose_dr_validate_json_file "$path" "Off-host backup receipt" || return 1
+ jq -e \
+ --arg operation_id "$operation_id" \
+ --arg manifest_sha256 "$manifest_sha256" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-validate-backup-receipt.jq" \
+ "$path" >/dev/null || {
+ echo "Off-host backup driver returned an invalid or incomplete coverage receipt" >&2
+ return 1
+ }
+ completed_at="$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-backup-completed-at.jq" "$path")" || return 1
+ remote_id="$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-backup-remote-id.jq" "$path")" || return 1
+ cloud_compose_dr_validate_utc_timestamp "$completed_at" "Off-host backup completion time" || return 1
+ cloud_compose_dr_validate_remote_id "$remote_id" "Off-host backup remote id" || return 1
+}
+
+cloud_compose_dr_validate_restore_proof() {
+ local path="$1" test_id="$2" manifest_sha256="$3" receipt_sha256="$4" completed_at recovery_id
+
+ cloud_compose_dr_validate_json_file "$path" "Restore-test proof" || return 1
+ jq -e \
+ --arg test_id "$test_id" \
+ --arg manifest_sha256 "$manifest_sha256" \
+ --arg receipt_sha256 "$receipt_sha256" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-validate-restore-proof.jq" \
+ "$path" >/dev/null || {
+ echo "Off-host backup driver returned an invalid restore-test proof" >&2
+ return 1
+ }
+ completed_at="$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-restore-completed-at.jq" "$path")" || return 1
+ recovery_id="$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-restore-recovery-id.jq" "$path")" || return 1
+ cloud_compose_dr_validate_utc_timestamp "$completed_at" "Restore-test completion time" || return 1
+ cloud_compose_dr_validate_remote_id "$recovery_id" "Restore-test recovery id" || return 1
+}
+
+cloud_compose_dr_run_driver() {
+ local driver="$1"
+ shift
+
+ # Driver credentials and configuration are installed and resolved by the
+ # operator-owned executable. Terraform-rendered host/application variables
+ # are deliberately absent, and driver output is never copied to the journal.
+ if ! env -i HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
+ "$driver" "$@" /dev/null 2>&1; then
+ echo "Off-host disaster-recovery driver failed; inspect its operator-owned diagnostics" >&2
+ return 1
+ fi
+}
diff --git a/rootfs/home/cloud-compose/docker-prune.sh b/rootfs/home/cloud-compose/docker-prune.sh
index 22e21e4..4ef168e 100755
--- a/rootfs/home/cloud-compose/docker-prune.sh
+++ b/rootfs/home/cloud-compose/docker-prune.sh
@@ -2,8 +2,27 @@
set -euo pipefail
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
+_cc_docker_prune_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_docker_prune_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_docker_prune_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_docker_prune_source _cc_docker_prune_dir _cc_docker_prune_installed_home
+if [[ -n "$_cc_docker_prune_installed_home" &&
+ ( "$_cc_docker_prune_installed_home" == "/" ||
+ "$_cc_docker_prune_source" == "${_cc_docker_prune_installed_home%/}/"* ) ]]; then
+ _cc_docker_prune_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_docker_prune_checked_programs="$_cc_docker_prune_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_docker_prune_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_docker_prune_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_docker_prune_source" \
+ CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh \
+ "$_cc_docker_prune_dir/profile.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+readonly profile_path
# shellcheck disable=SC1090
source "$profile_path"
diff --git a/rootfs/home/cloud-compose/host-init.sh b/rootfs/home/cloud-compose/host-init.sh
index 0c00eb4..1799a99 100644
--- a/rootfs/home/cloud-compose/host-init.sh
+++ b/rootfs/home/cloud-compose/host-init.sh
@@ -5,6 +5,28 @@ set -euo pipefail
# shellcheck disable=SC1091
source /home/cloud-compose/profile.sh
+_cc_host_init_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_host_init_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_host_init_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_host_init_source _cc_host_init_dir _cc_host_init_installed_home
+if [[ -n "$_cc_host_init_installed_home" &&
+ ( "$_cc_host_init_installed_home" == "/" ||
+ "$_cc_host_init_source" == "${_cc_host_init_installed_home%/}/"* ) ]]; then
+ _cc_host_init_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_host_init_checked_programs="$_cc_host_init_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_host_init_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_host_init_checked_programs"
+cloud_compose_bind_program_dir \
+ "$_cc_host_init_source" \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR \
+ /etc/cloud-compose/jq \
+ "$_cc_host_init_dir/../../etc/cloud-compose/jq" \
+ gcp-metadata-public-ip.jq \
+ gcp-metadata-private-ip.jq
+
cleanup() {
if [ -n "${metadata_file:-}" ]; then
rm -f "$metadata_file"
@@ -23,9 +45,11 @@ if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then
"http://metadata.google.internal/computeMetadata/v1/?recursive=true" >"$metadata_file"
update_runtime_env_file .env GCP_PUBLIC_IP \
- "$(jq -er '.instance.networkInterfaces[0].accessConfigs[0].externalIp' "$metadata_file")"
+ "$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/gcp-metadata-public-ip.jq" \
+ "$metadata_file")"
update_runtime_env_file .env GCP_PRIVATE_IP \
- "$(jq -er '.instance.networkInterfaces[0].ip' "$metadata_file")"
+ "$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/gcp-metadata-private-ip.jq" \
+ "$metadata_file")"
fi
if [ "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" = "true" ]; then
@@ -42,7 +66,6 @@ chown root:root /home/cloud-compose
chmod 0755 /home/cloud-compose
for mutable_dir in \
/home/cloud-compose/apps \
- /home/cloud-compose/bin \
/home/cloud-compose/state \
/home/cloud-compose/.sitectl \
/home/cloud-compose/.cache \
@@ -50,15 +73,32 @@ for mutable_dir in \
/home/cloud-compose/.local; do
install -d -m 0750 -o cloud-compose -g cloud-compose "$mutable_dir"
done
+if [[ -L /home/cloud-compose/bin || ! -d /home/cloud-compose/bin ||
+ "$(stat -c '%u:%g:%a:%F' -- /home/cloud-compose/bin)" != "0:0:755:directory" ]]; then
+ echo "Managed command directory was not secured by the runtime installer" >&2
+ exit 1
+fi
find /home/cloud-compose -maxdepth 1 -type f -name '*.sh' \
-exec chown root:root {} + \
-exec chmod 0755 {} +
for dispatcher in init up down rollout; do
- if [ -f "/home/cloud-compose/$dispatcher" ]; then
- chown root:root "/home/cloud-compose/$dispatcher"
- chmod 0755 "/home/cloud-compose/$dispatcher"
+ dispatcher_path="/home/cloud-compose/$dispatcher"
+ dispatcher_metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$dispatcher_path")" || {
+ echo "Unable to inspect Cloud Compose lifecycle dispatcher: $dispatcher_path" >&2
+ exit 1
+ }
+ IFS=: read -r dispatcher_uid _ dispatcher_mode dispatcher_links dispatcher_kind \
+ <<<"$dispatcher_metadata"
+ if [[ -L "$dispatcher_path" || ! -f "$dispatcher_path" ||
+ "$dispatcher_uid" != "0" || "$dispatcher_links" != "1" ||
+ "$dispatcher_kind" != "regular file" || ! "$dispatcher_mode" =~ ^[0-7]{3,4}$ ||
+ $((8#$dispatcher_mode & 0022)) -ne 0 ]]; then
+ echo "Unsafe Cloud Compose lifecycle dispatcher: $dispatcher_path" >&2
+ exit 1
fi
+ chown root:root "$dispatcher_path"
+ chmod 0755 "$dispatcher_path"
done
for runtime_input in .env compose-projects.json application-env.json; do
if [ -f "/home/cloud-compose/$runtime_input" ]; then
diff --git a/rootfs/home/cloud-compose/install-dependencies-cos.sh b/rootfs/home/cloud-compose/install-dependencies-cos.sh
index 0f6320e..ba1da78 100644
--- a/rootfs/home/cloud-compose/install-dependencies-cos.sh
+++ b/rootfs/home/cloud-compose/install-dependencies-cos.sh
@@ -18,15 +18,16 @@ valid_make_binary() {
}
# This function is sourced by the COS bootstrap contract, which supplies all
-# three arguments even though the executable entrypoint below uses defaults.
+# four arguments even though the executable entrypoint below uses defaults.
# shellcheck disable=SC2120
install_cos_dependencies() {
local cloud_compose_home="${1:-/home/cloud-compose}"
# COS mounts both /home and /var with noexec. Keep the verified Make binary
# on the executable persistent data disk; only the unprivileged application
# PATH consumes the published symlink.
- local tool_state_dir="${2:-${COS_TOOL_STATE_DIR:-/mnt/disks/data/cloud-compose-tools}}"
+ local tool_state_dir="${2:-${COS_TOOL_STATE_DIR:-/mnt/disks/data/libops-managed/bin}}"
local docker_bin="${3:-/usr/bin/docker}"
+ local make_build_program="${4:-/etc/cloud-compose/libexec/build-cos-make.sh}"
local make_path pending_make_path make_state_path make_state_tmp make_sha
local tool_mount_options installer_uid installer_gid
@@ -61,14 +62,19 @@ install_cos_dependencies() {
installer_gid="$(id -g)"
chown "${installer_uid}:${installer_gid}" "$tool_state_dir"
chmod 0755 "$tool_state_dir"
+ # This directory is on the privileged host PATH. Close legacy
+ # application ownership before publishing any verified tool link. The
+ # installer is root in production; the numeric identity keeps the
+ # standalone contract harness unprivileged.
+ chown "${installer_uid}:${installer_gid}" "${cloud_compose_home}/bin"
+ chmod 0755 "${cloud_compose_home}/bin"
DOCKER_CLI_PLUGIN_DIR="${DOCKER_CONFIG}/cli-plugins" \
bash "${cloud_compose_home}/install-docker-plugins.sh"
DOCKER_CLI_PLUGIN_DIR="${cloud_compose_home}/.docker/cli-plugins" \
bash "${cloud_compose_home}/install-docker-plugins.sh"
chown -R cloud-compose:cloud-compose \
"$DOCKER_CONFIG" \
- "${cloud_compose_home}/.docker" \
- "${cloud_compose_home}/bin"
+ "${cloud_compose_home}/.docker"
make_path="${tool_state_dir}/make"
make_state_path="${tool_state_dir}/make.state"
@@ -81,6 +87,10 @@ install_cos_dependencies() {
return 1
fi
fi
+ if [[ -L "$make_build_program" || ! -f "$make_build_program" || ! -x "$make_build_program" ]]; then
+ echo "COS Make build program is missing or unsafe: $make_build_program" >&2
+ return 1
+ fi
rm -f -- "$pending_make_path"
# The GCP metadata deny policy is installed before this container runs.
# Keep the build on Docker's isolated bridge: `--network host` would
@@ -89,50 +99,9 @@ install_cos_dependencies() {
# shellcheck disable=SC2016
if ! retry_until_success "$docker_bin" run --rm \
-v "${tool_state_dir}:/out" \
+ -v "${make_build_program}:/tmp/cloud-compose-build-cos-make.sh:ro" \
"$ALPINE_BUILD_IMAGE" \
- /bin/sh -euxc '
- MAKE_VERSION="4.4.1"
- MAKE_SHA256="dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3"
-
- # A single Alpine CDN outage must not make a healthy VM
- # replacement fail. These are HTTPS endpoints from the Alpine
- # official mirror list; apk still verifies the signed indexes
- # and packages with the keys baked into the pinned image.
- alpine_mirrors="
- https://dl-cdn.alpinelinux.org/alpine
- https://mirror.math.princeton.edu/pub/alpinelinux
- https://mirror.fel.cvut.cz/alpine
- "
- packages_installed=false
- for alpine_mirror in ${alpine_mirrors}; do
- printf "%s\n%s\n" \
- "${alpine_mirror}/v3.22/main" \
- "${alpine_mirror}/v3.22/community" \
- >/etc/apk/repositories
- rm -f /var/cache/apk/*
- if apk update && apk add build-base curl make tar; then
- packages_installed=true
- break
- fi
- echo "Alpine package mirror failed: ${alpine_mirror}" >&2
- done
- if [ "${packages_installed}" != true ]; then
- echo "All configured Alpine package mirrors failed" >&2
- exit 1
- fi
- curl -fsSL --proto "=https" --proto-redir "=https" --tlsv1.2 \
- --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 900 \
- --connect-timeout 10 --max-time 300 \
- "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
- install -m 0755 make /out/.cloud-compose-make.pending
- /out/.cloud-compose-make.pending --version | grep -Fqm 1 "GNU Make ${MAKE_VERSION}"
- mv -f /out/.cloud-compose-make.pending /out/make
- '; then
+ /bin/sh /tmp/cloud-compose-build-cos-make.sh; then
rm -f -- "$pending_make_path"
return 1
fi
diff --git a/rootfs/home/cloud-compose/install-docker-plugins.sh b/rootfs/home/cloud-compose/install-docker-plugins.sh
index 50b7671..23855d9 100644
--- a/rootfs/home/cloud-compose/install-docker-plugins.sh
+++ b/rootfs/home/cloud-compose/install-docker-plugins.sh
@@ -2,6 +2,28 @@
set -euo pipefail
+_cc_docker_plugins_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_docker_plugins_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_docker_plugins_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_docker_plugins_source _cc_docker_plugins_dir _cc_docker_plugins_installed_home
+if [[ -n "$_cc_docker_plugins_installed_home" &&
+ ( "$_cc_docker_plugins_installed_home" == "/" ||
+ "$_cc_docker_plugins_source" == "${_cc_docker_plugins_installed_home%/}/"* ) ]]; then
+ _cc_docker_plugins_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_docker_plugins_checked_programs="$_cc_docker_plugins_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_docker_plugins_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_docker_plugins_checked_programs"
+cloud_compose_bind_program \
+ "$_cc_docker_plugins_source" \
+ CLOUD_COMPOSE_RELEASE_CHECKSUM_PROGRAM \
+ /etc/cloud-compose/awk/release-checksum.awk \
+ "$_cc_docker_plugins_dir/../../etc/cloud-compose/awk/release-checksum.awk"
+release_checksum_program="$CLOUD_COMPOSE_RELEASE_CHECKSUM_PROGRAM"
+readonly release_checksum_program
+
docker_arch() {
case "$(uname -m)" in
x86_64 | amd64) echo "x86_64" ;;
@@ -28,22 +50,7 @@ release_checksum() {
local manifest="$1"
local asset="$2"
- awk -v asset="$asset" '
- {
- filename = $2
- sub(/^\*/, "", filename)
- if (filename == asset) {
- checksum = $1
- matches++
- }
- }
- END {
- if (matches != 1) {
- exit 1
- }
- print checksum
- }
- ' "$manifest"
+ awk -v asset="$asset" -f "$release_checksum_program" "$manifest"
}
validate_release_version() {
diff --git a/rootfs/home/cloud-compose/libops-managed-runtime.sh b/rootfs/home/cloud-compose/libops-managed-runtime.sh
index 94bc722..71cfb54 100644
--- a/rootfs/home/cloud-compose/libops-managed-runtime.sh
+++ b/rootfs/home/cloud-compose/libops-managed-runtime.sh
@@ -2,8 +2,47 @@
set -euo pipefail
+_cc_managed_runtime_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_managed_runtime_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_managed_runtime_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_managed_runtime_source _cc_managed_runtime_dir _cc_managed_runtime_installed_home
+if [[ -n "$_cc_managed_runtime_installed_home" &&
+ ( "$_cc_managed_runtime_installed_home" == "/" ||
+ "$_cc_managed_runtime_source" == "${_cc_managed_runtime_installed_home%/}/"* ) ]]; then
+ _cc_managed_runtime_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_managed_runtime_checked_programs="$_cc_managed_runtime_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_managed_runtime_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_managed_runtime_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_managed_runtime_source" \
+ CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh \
+ "$_cc_managed_runtime_dir/profile.sh"
+
# shellcheck disable=SC1090
-source "${CLOUD_COMPOSE_PROFILE_PATH:-/home/cloud-compose/profile.sh}"
+source "$CLOUD_COMPOSE_PROFILE_PATH"
+
+# Reload the fixed resolver after the profile before binding data programs.
+# shellcheck disable=SC1090
+source "$_cc_managed_runtime_checked_programs"
+cloud_compose_bind_program_dir \
+ "$_cc_managed_runtime_source" \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR \
+ /etc/cloud-compose/jq \
+ "$_cc_managed_runtime_dir/../../etc/cloud-compose/jq" \
+ github-latest-release-tag.jq \
+ sitectl-package-version.jq \
+ sitectl-package-versions-validate.jq \
+ object-field-delimited.jq \
+ object-keys-base64.jq
+cloud_compose_bind_program \
+ "$_cc_managed_runtime_source" \
+ CLOUD_COMPOSE_RELEASE_CHECKSUM_ENTRY_PROGRAM \
+ /etc/cloud-compose/awk/release-checksum-entry.awk \
+ "$_cc_managed_runtime_dir/../../etc/cloud-compose/awk/release-checksum-entry.awk"
LOG_PREFIX="[libops-managed-runtime]"
STATE_DIR="/mnt/disks/data/libops-managed"
@@ -27,20 +66,135 @@ enabled() {
}
mkdirs() {
+ local expected_uid expected_gid path mode allow_owner_migration spec
+ local -a directory_specs
+
+ expected_uid="$EUID"
+ expected_gid="$(id -g)"
+ if [[ "$STATE_DIR" == "/mnt/disks/data/libops-managed" ||
+ "$PUBLISHED_BIN_DIR" == "/home/cloud-compose/bin" ]]; then
+ if ((EUID != 0)); then
+ log "production managed runtime directories require a root updater"
+ return 1
+ fi
+ expected_uid=0
+ expected_gid=0
+ fi
+
# The managed binary is published through /home/cloud-compose/bin and must
# remain traversable after a root-owned bootstrap drops to cloud-compose.
- # Converge existing directories as well as new ones so a previously
- # restrictive service umask cannot leave the published symlink unusable.
- install -d -m 0755 "$STATE_DIR" "$BIN_DIR"
- install -d -m 0700 "$TMP_DIR" "$PACKAGE_STATE_DIR" "$ARTIFACT_STATE_DIR"
- mkdir -p "$PUBLISHED_BIN_DIR"
+ # Refuse redirected, non-directory, non-owner-controlled, or writable
+ # state before creating package staging files beneath the shared data mount.
+ directory_specs=(
+ "$STATE_DIR:0755:false"
+ "$BIN_DIR:0755:false"
+ "$TMP_DIR:0700:false"
+ "$PACKAGE_STATE_DIR:0700:false"
+ "$ARTIFACT_STATE_DIR:0700:false"
+ "$PUBLISHED_BIN_DIR:0755:true"
+ )
+ for spec in "${directory_specs[@]}"; do
+ IFS=: read -r path mode allow_owner_migration <<<"$spec"
+ prepare_managed_runtime_directory \
+ "$path" "$mode" "$expected_uid" "$expected_gid" "$allow_owner_migration" || return 1
+ done
+}
+
+prepare_managed_runtime_directory() {
+ local path="$1" mode="$2" expected_uid="$3" expected_gid="$4"
+ local allow_owner_migration="$5" metadata owner_uid group_gid actual_mode kind resolved desired_mode
+ local created=false
+
+ if [[ -L "$path" || ( -e "$path" && ! -d "$path" ) ]]; then
+ log "managed runtime path is not a real directory: ${path}"
+ return 1
+ fi
+ if [[ ! -e "$path" && ! -L "$path" ]]; then
+ # mkdir is the creation boundary: if an unprivileged process wins the
+ # name between inspection and creation, fail rather than adopting its
+ # pre-populated directory with install -d.
+ if ! mkdir -m "$mode" -- "$path"; then
+ log "managed runtime directory appeared during creation: ${path}"
+ return 1
+ fi
+ created=true
+ fi
+ if [[ "$created" != "true" ]]; then
+ if [[ -L "$path" || ! -d "$path" ]]; then
+ log "managed runtime path changed during validation: ${path}"
+ return 1
+ fi
+ metadata="$(stat -c '%u:%g:%a:%F' -- "$path")" || return 1
+ IFS=: read -r owner_uid group_gid actual_mode kind <<<"$metadata"
+ if [[ "$kind" != "directory" || ! "$actual_mode" =~ ^[0-7]{3,4}$ ||
+ $((8#$actual_mode & 0022)) -ne 0 ]]; then
+ log "managed runtime directory is writable by another account: ${path}"
+ return 1
+ fi
+ if [[ ( "$allow_owner_migration" != "true" || "$EUID" != "0" ) &&
+ ( "$owner_uid" != "$expected_uid" || "$group_gid" != "$expected_gid" ) ]]; then
+ log "managed runtime directory is not owned by the updater: ${path}"
+ return 1
+ fi
+ if [[ "$allow_owner_migration" == "true" ]]; then
+ # Close the legacy application-owned PATH directory before walking
+ # its entries. The bootstrap libexec boundary has already made its
+ # parent root-owned, so an old owner cannot race validation.
+ if ((EUID == 0)); then
+ install -d -m "$mode" -o "$expected_uid" -g "$expected_gid" -- "$path" || return 1
+ else
+ chmod "$mode" -- "$path" || return 1
+ fi
+ validate_published_bin_directory "$path" || return 1
+ fi
+ fi
+
+ if ((EUID == 0)); then
+ install -d -m "$mode" -o "$expected_uid" -g "$expected_gid" -- "$path" || return 1
+ else
+ chmod "$mode" -- "$path" || return 1
+ fi
+ resolved="$(readlink -f -- "$path")" || return 1
+ desired_mode="$(printf '%o' "$((8#$mode))")"
+ metadata="$(stat -c '%u:%g:%a:%F' -- "$path")" || return 1
+ if [[ "$resolved" != "$path" || "$metadata" != "${expected_uid}:${expected_gid}:${desired_mode}:directory" ||
+ -L "$path" ]]; then
+ log "managed runtime directory did not converge safely: ${path}"
+ return 1
+ fi
+}
+
+validate_published_bin_directory() {
+ local path="$1" entry name target
+ local -a entries
+
+ # /home/cloud-compose/bin was application-owned on older hosts. Preserve
+ # only generated managed-tool links whose targets remain under the
+ # validated root-owned package directory; reject every other inherited PATH
+ # entry. COS publishes its verified static Make build here because /home
+ # and /var are mounted noexec.
+ shopt -s nullglob dotglob
+ entries=("$path"/*)
+ shopt -u nullglob dotglob
+ for entry in "${entries[@]}"; do
+ name="${entry##*/}"
+ if [[ ! "$name" =~ ^(make|sitectl(-[a-z0-9]+)*)$ || ! -L "$entry" ]]; then
+ log "published command directory contains an unmanaged entry: ${entry}"
+ return 1
+ fi
+ target="$(readlink -- "$entry")" || return 1
+ if [[ "$target" != "${BIN_DIR}/${name}" ]]; then
+ log "published command has an unsafe target: ${entry}"
+ return 1
+ fi
+ done
}
with_lock() {
local action="$1"
shift
- mkdirs
+ mkdirs || return 1
if command -v flock >/dev/null 2>&1; then
exec 9>"${STATE_DIR}/runtime.lock"
if ! flock -n 9; then
@@ -86,10 +240,8 @@ latest_release_tag() {
return 1
fi
- if ! tag="$(jq -jr '
- (.tag_name | select(type == "string" and length > 0 and (explode | index(0) == null))),
- "\u001f"
- ' "$metadata")"; then
+ if ! tag="$(jq -jr -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/github-latest-release-tag.jq" \
+ "$metadata")"; then
log "latest release metadata for ${package} did not contain a tag"
rm -f "$metadata"
return 1
@@ -210,7 +362,8 @@ install_release_package() {
--connect-timeout 10 --max-time 300 -o "${tmp}/checksums.txt" -- "${base_url}/checksums.txt"
checksum_file="${tmp}/checksums.selected.txt"
- awk -v archive="$archive" '$2 == archive { print }' "${tmp}/checksums.txt" >"$checksum_file"
+ awk -v archive="$archive" -f "$CLOUD_COMPOSE_RELEASE_CHECKSUM_ENTRY_PROGRAM" \
+ "${tmp}/checksums.txt" >"$checksum_file"
if [[ "$(wc -l <"$checksum_file")" -ne 1 ]]; then
log "release checksums must contain exactly one entry for ${archive}"
rm -rf -- "$tmp"
@@ -293,7 +446,7 @@ sitectl_package_version() {
local fallback="${SITECTL_VERSION:-latest}"
jq -er --arg package "$package" --arg fallback "$fallback" \
- '.[$package] // $fallback' \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/sitectl-package-version.jq" \
<<<"$(sitectl_package_versions_json)"
}
@@ -308,14 +461,8 @@ validate_sitectl_configuration() {
fi
versions_json="$(sitectl_package_versions_json)"
- if ! jq -e '
- type == "object" and
- all(to_entries[];
- (.key | explode | index(0) == null) and
- (.value | type == "string") and
- (.value | explode | index(0) == null)
- )
- ' <<<"$versions_json" >/dev/null; then
+ if ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/sitectl-package-versions-validate.jq" \
+ <<<"$versions_json" >/dev/null; then
log "SITECTL_PACKAGE_VERSIONS must be a JSON object of sitectl package names to latest or exact semantic-version release tags"
return 1
fi
@@ -340,7 +487,9 @@ validate_sitectl_configuration() {
log "SITECTL_PACKAGE_VERSIONS contains an invalid package name: ${override}"
return 1
fi
- override_version="$(jq -jr --arg package "$override" '(.[$package]), "\u001f"' <<<"$versions_json")" || return 1
+ override_version="$(jq -jr --arg field "$override" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" \
+ <<<"$versions_json")" || return 1
override_version="${override_version%$'\x1f'}"
if ! valid_sitectl_version "$override_version"; then
log "SITECTL_PACKAGE_VERSIONS contains an invalid release tag for ${override}: ${override_version}"
@@ -350,7 +499,8 @@ validate_sitectl_configuration() {
log "SITECTL_PACKAGE_VERSIONS contains an uninstalled package: ${override}"
return 1
fi
- done < <(jq -r 'keys[] | @base64' <<<"$versions_json")
+ done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-keys-base64.jq" \
+ <<<"$versions_json")
}
validate_stale_managed_sitectl_package() {
@@ -406,7 +556,7 @@ install_sitectl_packages() {
local -A desired_packages=() stale_seen=()
validate_sitectl_configuration
- mkdirs
+ mkdirs || return 1
mapfile -t packages < <(sitectl_package_list)
for package in "${packages[@]}"; do
@@ -687,6 +837,17 @@ write_artifact_state() {
mv -f -- "$state_tmp" "$state_file"
}
+tab_separated_field_count() {
+ local value="$1"
+ local count=1
+
+ while [[ "$value" == *$'\t'* ]]; do
+ value="${value#*$'\t'}"
+ ((count += 1))
+ done
+ printf '%s\n' "$count"
+}
+
install_managed_artifacts() {
local line name url sha path mode owner group restart index
local state_file failed_state download_tmp install_tmp target_dir target_name backup
@@ -705,7 +866,7 @@ install_managed_artifacts() {
if [ -z "$line" ] || [[ "$line" == \#* ]]; then
continue
fi
- field_count="$(awk -F '\t' '{ print NF }' <<<"$line")"
+ field_count="$(tab_separated_field_count "$line")"
if [[ "$field_count" != "8" ]]; then
log "managed artifact manifest row must contain exactly eight tab-separated fields"
return 1
@@ -871,6 +1032,11 @@ run_update() {
main() {
local command="${1:-update}"
+ if ((EUID != 0)); then
+ log "managed runtime updates must run as root"
+ return 1
+ fi
+
case "$command" in
install-tools)
with_lock run_install_tools
diff --git a/rootfs/home/cloud-compose/lifecycle-entrypoint.sh b/rootfs/home/cloud-compose/lifecycle-entrypoint.sh
new file mode 100644
index 0000000..7516a14
--- /dev/null
+++ b/rootfs/home/cloud-compose/lifecycle-entrypoint.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+lifecycle="${0##*/}"
+case "$lifecycle" in
+ init | up | down | rollout) ;;
+ *)
+ echo "lifecycle entrypoint must be installed as init, up, down, or rollout" >&2
+ exit 2
+ ;;
+esac
+
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+exec bash /home/cloud-compose/compose-dispatch.sh "$lifecycle"
diff --git a/rootfs/home/cloud-compose/mariadb-backup.sh b/rootfs/home/cloud-compose/mariadb-backup.sh
index ebdcd3c..c7513a7 100644
--- a/rootfs/home/cloud-compose/mariadb-backup.sh
+++ b/rootfs/home/cloud-compose/mariadb-backup.sh
@@ -2,11 +2,34 @@
set -euo pipefail
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
-compose_apps_path="${CLOUD_COMPOSE_COMPOSE_APPS_PATH:-$script_dir/compose-apps.sh}"
+_cc_mariadb_backup_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_mariadb_backup_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_mariadb_backup_source script_dir _cc_mariadb_backup_installed_home
+if [[ -n "$_cc_mariadb_backup_installed_home" &&
+ ( "$_cc_mariadb_backup_installed_home" == "/" ||
+ "$_cc_mariadb_backup_source" == "${_cc_mariadb_backup_installed_home%/}/"* ) ]]; then
+ _cc_mariadb_backup_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_mariadb_backup_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_mariadb_backup_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_mariadb_backup_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_mariadb_backup_source" CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh "$script_dir/profile.sh"
+cloud_compose_bind_source_program \
+ "$_cc_mariadb_backup_source" CLOUD_COMPOSE_COMPOSE_APPS_PATH \
+ /home/cloud-compose/compose-apps.sh "$script_dir/compose-apps.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+compose_apps_path="$CLOUD_COMPOSE_COMPOSE_APPS_PATH"
+readonly profile_path compose_apps_path
# shellcheck disable=SC1090
source "$profile_path"
+# Reload the fixed resolver before sourcing the Compose library.
+# shellcheck disable=SC1090
+source "$_cc_mariadb_backup_checked_programs"
# shellcheck disable=SC1090
source "$compose_apps_path"
diff --git a/rootfs/home/cloud-compose/mount-overlays.sh b/rootfs/home/cloud-compose/mount-overlays.sh
index 81ba681..8d96c14 100755
--- a/rootfs/home/cloud-compose/mount-overlays.sh
+++ b/rootfs/home/cloud-compose/mount-overlays.sh
@@ -2,8 +2,33 @@
set -euo pipefail
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-/home/cloud-compose/profile.sh}"
-overlay_script="${CLOUD_COMPOSE_OVERLAY_INIT_PATH:-/home/cloud-compose/overlay-init.sh}"
+_cc_mount_overlays_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_mount_overlays_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_mount_overlays_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_mount_overlays_source _cc_mount_overlays_dir _cc_mount_overlays_installed_home
+if [[ -n "$_cc_mount_overlays_installed_home" &&
+ ( "$_cc_mount_overlays_installed_home" == "/" ||
+ "$_cc_mount_overlays_source" == "${_cc_mount_overlays_installed_home%/}/"* ) ]]; then
+ _cc_mount_overlays_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_mount_overlays_checked_programs="$_cc_mount_overlays_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_mount_overlays_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_mount_overlays_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_mount_overlays_source" \
+ CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh \
+ "$_cc_mount_overlays_dir/profile.sh"
+cloud_compose_bind_source_program \
+ "$_cc_mount_overlays_source" \
+ CLOUD_COMPOSE_OVERLAY_INIT_PATH \
+ /home/cloud-compose/overlay-init.sh \
+ "$_cc_mount_overlays_dir/overlay-init.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+overlay_script="$CLOUD_COMPOSE_OVERLAY_INIT_PATH"
+readonly profile_path overlay_script
# shellcheck disable=SC1090
source "$profile_path"
diff --git a/rootfs/home/cloud-compose/offhost-backup.sh b/rootfs/home/cloud-compose/offhost-backup.sh
new file mode 100644
index 0000000..35a5989
--- /dev/null
+++ b/rootfs/home/cloud-compose/offhost-backup.sh
@@ -0,0 +1,226 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+_cc_offhost_backup_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_offhost_backup_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_offhost_backup_source script_dir _cc_offhost_backup_installed_home
+if [[ -n "$_cc_offhost_backup_installed_home" &&
+ ( "$_cc_offhost_backup_installed_home" == "/" ||
+ "$_cc_offhost_backup_source" == "${_cc_offhost_backup_installed_home%/}/"* ) ]]; then
+ _cc_offhost_backup_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_offhost_backup_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_offhost_backup_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_offhost_backup_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_offhost_backup_source" CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh "$script_dir/profile.sh"
+cloud_compose_bind_source_program \
+ "$_cc_offhost_backup_source" CLOUD_COMPOSE_COMPOSE_APPS_PATH \
+ /home/cloud-compose/compose-apps.sh "$script_dir/compose-apps.sh"
+cloud_compose_bind_source_program \
+ "$_cc_offhost_backup_source" CLOUD_COMPOSE_DR_LIBRARY_PATH \
+ /home/cloud-compose/disaster-recovery-lib.sh "$script_dir/disaster-recovery-lib.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+compose_apps_path="$CLOUD_COMPOSE_COMPOSE_APPS_PATH"
+dr_library_path="$CLOUD_COMPOSE_DR_LIBRARY_PATH"
+readonly profile_path compose_apps_path dr_library_path
+# shellcheck disable=SC1090
+source "$profile_path"
+# shellcheck disable=SC1090
+source "$compose_apps_path"
+# Restore the fixed binding functions before the final sourced dependency.
+# shellcheck disable=SC1090
+source "$_cc_offhost_backup_checked_programs"
+cloud_compose_bind_program_dir \
+ "$_cc_offhost_backup_source" \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR \
+ /etc/cloud-compose/jq \
+ "$script_dir/../../etc/cloud-compose/jq" \
+ offhost-validate-compose-config.jq \
+ offhost-validate-bind-roots.jq \
+ offhost-bind-sources.jq \
+ offhost-build-application-coverage.jq \
+ offhost-build-manifest.jq \
+ offhost-validate-manifest.jq \
+ offhost-manifest-app-digests.jq
+# shellcheck disable=SC1090
+source "$dr_library_path"
+
+if cloud_compose_dr_is_required; then
+ :
+else
+ status=$?
+ if ((status == 1)); then
+ echo "Off-host disaster recovery is not required; local MariaDB dumps remain same-disk recovery artifacts only"
+ exit 0
+ fi
+ exit "$status"
+fi
+
+driver="$CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER"
+backup_root="${MARIADB_BACKUP_ROOT:-/mnt/disks/data/backups/mariadb}"
+data_root="${CLOUD_COMPOSE_DATA_ROOT:-/mnt/disks/data}"
+volumes_root="${CLOUD_COMPOSE_VOLUMES_ROOT:-/mnt/disks/volumes}"
+backup_date="$(date -u +%Y%m%d)"
+operation_id="${backup_date}-${CLOUD_COMPOSE_INSTANCE_NAME:-cloud-compose}"
+state_root="$CLOUD_COMPOSE_DR_STATE_ROOT"
+manifest_dir="$state_root/manifests"
+receipt_dir="$state_root/backup-receipts"
+staging_root="$state_root/staging"
+manifest_path="$manifest_dir/${operation_id}.json"
+receipt_path="$receipt_dir/${operation_id}.json"
+staging_dir=""
+
+cleanup() {
+ if [[ -n "$staging_dir" ]]; then
+ rm -rf -- "$staging_dir"
+ fi
+}
+trap cleanup EXIT
+
+cloud_compose_dr_validate_driver "$driver"
+acquire_cloud_compose_lifecycle_lock offhost-backup
+
+for path in "$state_root" "$manifest_dir" "$receipt_dir" "$staging_root"; do
+ cloud_compose_dr_prepare_state_directory "$path"
+done
+
+staging_dir="$(mktemp -d "$staging_root/.${operation_id}.XXXXXX")"
+chmod 0700 "$staging_dir"
+application_rows="$staging_dir/applications.jsonl"
+: >"$application_rows"
+chmod 0600 "$application_rows"
+
+apps=()
+compose_app_names_array apps
+if ((${#apps[@]} == 0)); then
+ echo "Off-host backup requires at least one compose application" >&2
+ exit 1
+fi
+
+for app in "${apps[@]}"; do
+ source_compose_app_env "$app"
+ project_dir="$DOCKER_COMPOSE_DIR"
+ dump_path="${backup_root}/${app}/${backup_date}-${app}.sql.gz"
+ staged_dump="$staging_dir/${app}.sql.gz"
+ compose_config="$staging_dir/${app}.compose-config.json"
+ application_row="$staging_dir/${app}.coverage.json"
+
+ validate_compose_project_dir "$project_dir"
+ if [[ -L "$project_dir" || ! -d "$project_dir" ]]; then
+ echo "Application checkout is missing or unsafe for ${app}: $project_dir" >&2
+ exit 1
+ fi
+ if [[ -L "$dump_path" || ! -f "$dump_path" || ! -s "$dump_path" ]]; then
+ echo "Required local MariaDB recovery artifact is missing or unsafe for ${app}" >&2
+ exit 1
+ fi
+ if [[ "$(stat -c '%h:%F' -- "$dump_path")" != "1:regular file" ]]; then
+ echo "Required local MariaDB recovery artifact must have one link for ${app}" >&2
+ exit 1
+ fi
+
+ # Copy through an already-open descriptor into the root-only handoff. This
+ # prevents the privileged driver from following a later path replacement in
+ # the application-owned local-backup directory.
+ exec {dump_fd}<"$dump_path"
+ dump_identity="$(stat -Lc '%d:%i' -- "/proc/${BASHPID}/fd/${dump_fd}")"
+ if [[ "$(stat -c '%d:%i' -- "$dump_path")" != "$dump_identity" ]]; then
+ echo "Local MariaDB recovery artifact changed while opening for ${app}" >&2
+ exit 1
+ fi
+ cat <&"$dump_fd" >"$staged_dump"
+ exec {dump_fd}<&-
+ chmod 0400 "$staged_dump"
+ if [[ ! -s "$staged_dump" ]] || ! gzip -t -- "$staged_dump"; then
+ echo "Required local MariaDB recovery artifact is invalid for ${app}" >&2
+ exit 1
+ fi
+ dump_sha256="$(cloud_compose_dr_sha256_file "$staged_dump")"
+ dump_bytes="$(wc -c <"$staged_dump")"
+
+ (
+ cd -- "$project_dir"
+ umask 077
+ docker compose config --format json >"$compose_config"
+ )
+ if ! jq -e \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-validate-compose-config.jq" \
+ "$compose_config" >/dev/null; then
+ echo "Docker Compose returned unsafe or unsupported volume topology for ${app}" >&2
+ exit 1
+ fi
+ if ! jq -e \
+ --arg data_root "$data_root" \
+ --arg volumes_root "$volumes_root" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-validate-bind-roots.jq" \
+ "$compose_config" >/dev/null; then
+ echo "Persistent bind topology escapes managed data roots for ${app}" >&2
+ exit 1
+ fi
+ while IFS= read -r bind_source; do
+ if [[ "$bind_source" =~ (^|/)\.\.?(/|$) ]]; then
+ echo "Persistent bind topology contains a dot segment for ${app}" >&2
+ exit 1
+ fi
+ done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-bind-sources.jq" "$compose_config")
+
+ jq -cS \
+ --arg app "$app" \
+ --arg project_dir "$project_dir" \
+ --arg dump_path "$staged_dump" \
+ --arg dump_sha256 "$dump_sha256" \
+ --argjson dump_bytes "$dump_bytes" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-build-application-coverage.jq" \
+ "$compose_config" >"$application_row"
+ cat "$application_row" >>"$application_rows"
+ rm -f -- "$compose_config"
+done
+
+staged_manifest="$staging_dir/manifest.json"
+jq -cS -s \
+ --arg operation_id "$operation_id" \
+ --arg backup_date "$backup_date" \
+ --arg provider "${CLOUD_COMPOSE_PROVIDER:-unknown}" \
+ --arg instance "${CLOUD_COMPOSE_INSTANCE_NAME:-cloud-compose}" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-build-manifest.jq" \
+ "$application_rows" >"$staged_manifest"
+chmod 0400 "$staged_manifest"
+
+if ! jq -e \
+ --argjson app_count "${#apps[@]}" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-validate-manifest.jq" \
+ "$staged_manifest" >/dev/null; then
+ echo "Generated off-host coverage manifest is incomplete" >&2
+ exit 1
+fi
+while IFS=$'\t' read -r manifest_app manifest_sha; do
+ if [[ ! "$manifest_app" =~ ^[a-z][a-z0-9-]*$ || ! "$manifest_sha" =~ ^[0-9a-f]{64}$ ]]; then
+ echo "Generated off-host coverage manifest contains an unsafe application name or digest" >&2
+ exit 1
+ fi
+done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-manifest-app-digests.jq" "$staged_manifest")
+
+manifest_sha256="$(cloud_compose_dr_sha256_file "$staged_manifest")"
+staged_receipt="$staging_dir/receipt.json"
+cloud_compose_dr_run_driver "$driver" backup \
+ --manifest "$staged_manifest" \
+ --manifest-sha256 "$manifest_sha256" \
+ --operation-id "$operation_id" \
+ --receipt "$staged_receipt"
+post_driver_manifest_sha256="$(cloud_compose_dr_sha256_file "$staged_manifest")"
+if [[ "$post_driver_manifest_sha256" != "$manifest_sha256" ]]; then
+ echo "Off-host backup driver modified the immutable coverage manifest" >&2
+ exit 1
+fi
+cloud_compose_dr_validate_backup_receipt "$staged_receipt" "$operation_id" "$manifest_sha256"
+
+chmod 0640 "$staged_manifest" "$staged_receipt"
+mv -f -- "$staged_manifest" "$manifest_path"
+mv -f -- "$staged_receipt" "$receipt_path"
+echo "Encrypted off-host disaster-recovery coverage proven for ${#apps[@]} application(s): $receipt_path"
diff --git a/rootfs/home/cloud-compose/persist-filesystems.sh b/rootfs/home/cloud-compose/persist-filesystems.sh
index 03a761a..fab4bf7 100755
--- a/rootfs/home/cloud-compose/persist-filesystems.sh
+++ b/rootfs/home/cloud-compose/persist-filesystems.sh
@@ -2,6 +2,29 @@
set -euo pipefail
+_cc_persist_filesystems_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_persist_filesystems_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_persist_filesystems_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_persist_filesystems_source _cc_persist_filesystems_dir _cc_persist_filesystems_installed_home
+if [[ -n "$_cc_persist_filesystems_installed_home" &&
+ ( "$_cc_persist_filesystems_installed_home" == "/" ||
+ "$_cc_persist_filesystems_source" == "${_cc_persist_filesystems_installed_home%/}/"* ) ]]; then
+ # shellcheck disable=SC1091
+ source /etc/cloud-compose/libexec/checked-programs.bash
+ cloud_compose_bind_program \
+ "$_cc_persist_filesystems_source" \
+ CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM \
+ /etc/cloud-compose/awk/reconcile-fstab.awk \
+ /etc/cloud-compose/awk/reconcile-fstab.awk
+ fstab_reconcile_program="$CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM"
+else
+ # Early boot executes a verified root-owned copy from /run and passes its
+ # separately verified awk program explicitly. Repository contracts use the
+ # same override without weakening the installed /home path.
+ fstab_reconcile_program="${CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM:-$_cc_persist_filesystems_dir/../../etc/cloud-compose/awk/reconcile-fstab.awk}"
+fi
+readonly fstab_reconcile_program
+
log() {
printf '[filesystem-persist] %s\n' "$*" >&2
}
@@ -185,28 +208,8 @@ main() {
trap 'rm -f -- "$tmp"' EXIT
awk -v begin="$begin_marker" -v end="$end_marker" \
-v data_device="$data_device" -v data_provider_mount="$data_provider_mount" \
- -v volumes_device="$volumes_device" -v volumes_provider_mount="$volumes_provider_mount" '
- $0 == begin { managed = 1; next }
- $0 == end { managed = 0; next }
- !managed {
- if (data_provider_mount != "" && $2 == data_provider_mount) {
- if ($1 == data_device) next
- conflict = 1
- }
- if (volumes_provider_mount != "" && $2 == volumes_provider_mount) {
- if ($1 == volumes_device) next
- conflict = 1
- }
- if ($2 == "/mnt/disks/data" ||
- $2 == "/mnt/disks/volumes" ||
- $2 == "/mnt/disks/data/docker/volumes" ||
- $2 == "/mnt/disks/prod-readonly") {
- conflict = 1
- }
- print
- }
- END { if (managed || conflict) exit 42 }
- ' "$fstab_path" >"$tmp" || {
+ -v volumes_device="$volumes_device" -v volumes_provider_mount="$volumes_provider_mount" \
+ -f "$fstab_reconcile_program" "$fstab_path" >"$tmp" || {
status=$?
if [[ "$status" -eq 42 ]]; then
log "fstab contains an unterminated managed block or an unmanaged cloud-compose mount target"
diff --git a/rootfs/home/cloud-compose/prepare-app-sources.sh b/rootfs/home/cloud-compose/prepare-app-sources.sh
index 6b98879..3d6b15d 100644
--- a/rootfs/home/cloud-compose/prepare-app-sources.sh
+++ b/rootfs/home/cloud-compose/prepare-app-sources.sh
@@ -2,12 +2,35 @@
set -euo pipefail
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
-compose_apps_path="${CLOUD_COMPOSE_COMPOSE_APPS_PATH:-$script_dir/compose-apps.sh}"
+_cc_prepare_sources_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_prepare_sources_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_prepare_sources_source script_dir _cc_prepare_sources_installed_home
+if [[ -n "$_cc_prepare_sources_installed_home" &&
+ ( "$_cc_prepare_sources_installed_home" == "/" ||
+ "$_cc_prepare_sources_source" == "${_cc_prepare_sources_installed_home%/}/"* ) ]]; then
+ _cc_prepare_sources_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_prepare_sources_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_prepare_sources_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_prepare_sources_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_prepare_sources_source" CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh "$script_dir/profile.sh"
+cloud_compose_bind_source_program \
+ "$_cc_prepare_sources_source" CLOUD_COMPOSE_COMPOSE_APPS_PATH \
+ /home/cloud-compose/compose-apps.sh "$script_dir/compose-apps.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+compose_apps_path="$CLOUD_COMPOSE_COMPOSE_APPS_PATH"
+readonly profile_path compose_apps_path
cd "$script_dir"
# shellcheck disable=SC1090
source "$profile_path"
+# Reload the fixed resolver before sourcing the Compose library.
+# shellcheck disable=SC1090
+source "$_cc_prepare_sources_checked_programs"
# shellcheck disable=SC1090
source "$compose_apps_path"
diff --git a/rootfs/home/cloud-compose/profile.sh b/rootfs/home/cloud-compose/profile.sh
index 7602d40..7695c52 100644
--- a/rootfs/home/cloud-compose/profile.sh
+++ b/rootfs/home/cloud-compose/profile.sh
@@ -1,5 +1,29 @@
#!/usr/bin/env bash
+_cc_profile_source="$(readlink -f -- "${BASH_SOURCE[0]}")" || {
+ echo "Could not resolve the Cloud Compose profile path" >&2
+ return 1 2>/dev/null || exit 1
+}
+_cc_profile_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || {
+ echo "Could not resolve the Cloud Compose profile directory" >&2
+ return 1 2>/dev/null || exit 1
+}
+_cc_profile_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_profile_source _cc_profile_dir _cc_profile_installed_home
+if [[ -n "$_cc_profile_installed_home" &&
+ ( "$_cc_profile_installed_home" == "/" ||
+ "$_cc_profile_source" == "${_cc_profile_installed_home%/}/"* ) ]]; then
+ _cc_profile_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_profile_checked_programs="$_cc_profile_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_profile_checked_programs
+# shellcheck disable=SC1090
+if ! source "$_cc_profile_checked_programs"; then
+ echo "Could not load the checked Cloud Compose program resolver" >&2
+ return 1 2>/dev/null || exit 1
+fi
+
decode_runtime_env_value() {
local encoded="$1"
local output="" character next index
@@ -106,6 +130,17 @@ if ! load_runtime_env "${CLOUD_COMPOSE_ENV_FILE:-/home/cloud-compose/.env}"; the
exit 1
fi
+if ! cloud_compose_bind_program_dir \
+ "$_cc_profile_source" \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR \
+ /etc/cloud-compose/jq \
+ "$_cc_profile_dir/../../etc/cloud-compose/jq" \
+ application-env-validate.jq \
+ object-entries-sorted-base64.jq \
+ object-field-delimited.jq; then
+ return 1 2>/dev/null || exit 1
+fi
+
if ((EUID == 0)); then
# Root-owned systemd/bootstrap paths must never resolve commands from the
# cloud-compose-writable tool directory.
@@ -329,14 +364,8 @@ sync_compose_application_env() (
echo "Refusing unsafe Compose environment path: $env_file" >&2
return 1
fi
- if ! jq -e '
- type == "object" and
- all(to_entries[];
- (.key | explode | index(0) == null) and
- (.value | type == "string") and
- (.value | explode | index(0) == null)
- )
- ' "$application_env_file" >/dev/null; then
+ if ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/application-env-validate.jq" \
+ "$application_env_file" >/dev/null; then
echo "Invalid Compose application environment data: $application_env_file" >&2
return 1
fi
@@ -349,7 +378,8 @@ sync_compose_application_env() (
}
trap 'rm -f -- "$entries_file" "$tmp_file"' EXIT
- jq -r 'to_entries | sort_by(.key)[] | @base64' "$application_env_file" >"$entries_file" || return 1
+ jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-entries-sorted-base64.jq" \
+ "$application_env_file" >"$entries_file" || return 1
if [[ -f "$env_file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
@@ -372,13 +402,17 @@ sync_compose_application_env() (
while IFS= read -r encoded || [[ -n "$encoded" ]]; do
[[ -n "$encoded" ]] || continue
entry_json="$(printf '%s' "$encoded" | base64 -d)" || return 1
- name="$(jq -jr '.key, "\u001f"' <<<"$entry_json")" || return 1
+ name="$(jq -jr --arg field key \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" \
+ <<<"$entry_json")" || return 1
name="${name%$'\x1f'}"
if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
echo "Invalid Compose application environment data: $application_env_file" >&2
return 1
fi
- value="$(jq -jr '.value, "\u001f"' <<<"$entry_json")" || return 1
+ value="$(jq -jr --arg field value \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" \
+ <<<"$entry_json")" || return 1
value="${value%$'\x1f'}"
printf '# cloud-compose application: %s\n' "$name" >>"$tmp_file"
write_runtime_env_assignment "$name" "$value" >>"$tmp_file" || return 1
diff --git a/rootfs/home/cloud-compose/restore-test.sh b/rootfs/home/cloud-compose/restore-test.sh
new file mode 100644
index 0000000..260114d
--- /dev/null
+++ b/rootfs/home/cloud-compose/restore-test.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+_cc_restore_test_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_restore_test_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_restore_test_source script_dir _cc_restore_test_installed_home
+if [[ -n "$_cc_restore_test_installed_home" &&
+ ( "$_cc_restore_test_installed_home" == "/" ||
+ "$_cc_restore_test_source" == "${_cc_restore_test_installed_home%/}/"* ) ]]; then
+ _cc_restore_test_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_restore_test_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_restore_test_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_restore_test_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_restore_test_source" CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh "$script_dir/profile.sh"
+cloud_compose_bind_source_program \
+ "$_cc_restore_test_source" CLOUD_COMPOSE_DR_LIBRARY_PATH \
+ /home/cloud-compose/disaster-recovery-lib.sh "$script_dir/disaster-recovery-lib.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+dr_library_path="$CLOUD_COMPOSE_DR_LIBRARY_PATH"
+readonly profile_path dr_library_path
+# shellcheck disable=SC1090
+source "$profile_path"
+# Restore the fixed binding functions before the final sourced dependency.
+# shellcheck disable=SC1090
+source "$_cc_restore_test_checked_programs"
+# shellcheck disable=SC1090
+source "$dr_library_path"
+
+if cloud_compose_dr_is_required; then
+ :
+else
+ status=$?
+ if ((status == 1)); then
+ echo "Off-host disaster recovery is not required; skipping scheduled restore test"
+ exit 0
+ fi
+ exit "$status"
+fi
+
+driver="$CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER"
+state_root="$CLOUD_COMPOSE_DR_STATE_ROOT"
+manifest_dir="$state_root/manifests"
+receipt_dir="$state_root/backup-receipts"
+proof_dir="$state_root/restore-proofs"
+staging_root="$state_root/staging"
+staging_dir=""
+
+cleanup() {
+ if [[ -n "$staging_dir" ]]; then
+ rm -rf -- "$staging_dir"
+ fi
+}
+trap cleanup EXIT
+
+cloud_compose_dr_validate_driver "$driver"
+for path in "$state_root" "$manifest_dir" "$receipt_dir" "$proof_dir" "$staging_root"; do
+ cloud_compose_dr_prepare_state_directory "$path"
+done
+
+latest_receipt="$(find "$receipt_dir" -xdev -maxdepth 1 -type f -name '*.json' -printf '%f\n' | sort | tail -n 1)"
+if [[ -z "$latest_receipt" ]]; then
+ echo "Scheduled restore test requires a validated off-host backup receipt" >&2
+ exit 1
+fi
+operation_id="${latest_receipt%.json}"
+if [[ ! "$operation_id" =~ ^[0-9]{8}-[a-z][a-z0-9-]*$ ]]; then
+ echo "Latest off-host backup receipt has an unsafe operation id" >&2
+ exit 1
+fi
+receipt_path="$receipt_dir/$latest_receipt"
+manifest_path="$manifest_dir/$latest_receipt"
+if [[ -L "$manifest_path" || ! -f "$manifest_path" ]]; then
+ echo "Scheduled restore test is missing the source coverage manifest" >&2
+ exit 1
+fi
+cloud_compose_dr_validate_json_file "$manifest_path" "Off-host backup manifest"
+manifest_sha256="$(cloud_compose_dr_sha256_file "$manifest_path")"
+cloud_compose_dr_validate_backup_receipt "$receipt_path" "$operation_id" "$manifest_sha256"
+receipt_sha256="$(cloud_compose_dr_sha256_file "$receipt_path")"
+
+test_id="$(date -u +%Y%m%dT%H%M%SZ)-$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
+staging_dir="$(mktemp -d "$staging_root/.restore-${test_id}.XXXXXX")"
+chmod 0700 "$staging_dir"
+staged_proof="$staging_dir/proof.json"
+
+cloud_compose_dr_run_driver "$driver" restore-test \
+ --manifest "$manifest_path" \
+ --backup-receipt "$receipt_path" \
+ --source-manifest-sha256 "$manifest_sha256" \
+ --source-receipt-sha256 "$receipt_sha256" \
+ --test-id "$test_id" \
+ --proof "$staged_proof"
+cloud_compose_dr_validate_restore_proof \
+ "$staged_proof" "$test_id" "$manifest_sha256" "$receipt_sha256"
+
+proof_path="$proof_dir/${test_id}.json"
+chmod 0640 "$staged_proof"
+mv -- "$staged_proof" "$proof_path"
+echo "Disposable restore test proved database, application-file, and volume-topology recovery: $proof_path"
diff --git a/rootfs/home/cloud-compose/rotate-keys-app.sh b/rootfs/home/cloud-compose/rotate-keys-app.sh
index 1b15f9d..6977760 100644
--- a/rootfs/home/cloud-compose/rotate-keys-app.sh
+++ b/rootfs/home/cloud-compose/rotate-keys-app.sh
@@ -2,10 +2,33 @@
set -euo pipefail
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
-rotate_keys_script="${CLOUD_COMPOSE_ROTATE_KEYS_PATH:-$script_dir/rotate-keys.sh}"
-compose_apps_path="${CLOUD_COMPOSE_COMPOSE_APPS_PATH:-/home/cloud-compose/compose-apps.sh}"
+_cc_rotate_keys_app_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_rotate_keys_app_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_rotate_keys_app_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_rotate_keys_app_source _cc_rotate_keys_app_dir _cc_rotate_keys_app_installed_home
+if [[ -n "$_cc_rotate_keys_app_installed_home" &&
+ ( "$_cc_rotate_keys_app_installed_home" == "/" ||
+ "$_cc_rotate_keys_app_source" == "${_cc_rotate_keys_app_installed_home%/}/"* ) ]]; then
+ _cc_rotate_keys_app_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_rotate_keys_app_checked_programs="$_cc_rotate_keys_app_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_rotate_keys_app_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_rotate_keys_app_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_rotate_keys_app_source" CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh "$_cc_rotate_keys_app_dir/profile.sh"
+cloud_compose_bind_source_program \
+ "$_cc_rotate_keys_app_source" CLOUD_COMPOSE_ROTATE_KEYS_PATH \
+ /home/cloud-compose/rotate-keys.sh "$_cc_rotate_keys_app_dir/rotate-keys.sh"
+cloud_compose_bind_source_program \
+ "$_cc_rotate_keys_app_source" CLOUD_COMPOSE_COMPOSE_APPS_PATH \
+ /home/cloud-compose/compose-apps.sh "$_cc_rotate_keys_app_dir/compose-apps.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+rotate_keys_script="$CLOUD_COMPOSE_ROTATE_KEYS_PATH"
+compose_apps_path="$CLOUD_COMPOSE_COMPOSE_APPS_PATH"
+readonly profile_path rotate_keys_script compose_apps_path
# shellcheck disable=SC1090
source "$profile_path"
@@ -22,6 +45,17 @@ esac
# shellcheck disable=SC1090
source "$compose_apps_path"
+# Reload the owner-relative resolver after both sourced dependencies.
+# shellcheck disable=SC1090
+source "$_cc_rotate_keys_app_checked_programs"
+cloud_compose_bind_program_dir \
+ "$_cc_rotate_keys_app_source" \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR \
+ /etc/cloud-compose/jq \
+ "$_cc_rotate_keys_app_dir/../../etc/cloud-compose/jq" \
+ service-account-key-id.jq \
+ service-account-credentials-valid.jq
+
APP_CREDENTIALS_FILE="${APP_CREDENTIALS_FILE:-/mnt/disks/data/cloud-compose/app/GOOGLE_APPLICATION_CREDENTIALS}"
ROTATION_APP_CREDENTIAL_OWNER="${ROTATION_CREDENTIAL_OWNER-100}"
ROTATION_CENTRAL_CREDENTIAL_OWNER="${ROTATION_CENTRAL_CREDENTIAL_OWNER-root}"
@@ -98,11 +132,8 @@ require_inactive_app_service() {
app_credential_key_id() {
local file="$1" key_id
- key_id="$(jq -jr '
- (.private_key_id |
- select(type == "string" and length > 0 and (explode | index(0) == null))),
- "\u001f"
- ' "$file")" || return 1
+ key_id="$(jq -jr -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/service-account-key-id.jq" \
+ "$file")" || return 1
key_id="${key_id%$'\x1f'}"
[[ "$key_id" =~ ^[A-Za-z0-9_-]+$ ]] || return 1
printf '%s\n' "$key_id"
@@ -116,16 +147,9 @@ validate_app_credentials() {
jq -e \
--arg key_id "$key_id" \
--arg service_account "$GCP_APP_SERVICE_ACCOUNT_EMAIL" \
- --arg project_id "$GCP_PROJECT" '
- .type == "service_account" and
- .private_key_id == $key_id and
- .client_email == $service_account and
- .project_id == $project_id and
- .token_uri == "https://oauth2.googleapis.com/token" and
- (.private_key | type == "string" and
- startswith("-----BEGIN PRIVATE KEY-----") and
- contains("-----END PRIVATE KEY-----"))
- ' "$file" >/dev/null
+ --arg project_id "$GCP_PROJECT" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/service-account-credentials-valid.jq" \
+ "$file" >/dev/null
}
restore_central_app_credentials() {
diff --git a/rootfs/home/cloud-compose/rotate-keys-daily.sh b/rootfs/home/cloud-compose/rotate-keys-daily.sh
index fb30a69..8d8e60e 100644
--- a/rootfs/home/cloud-compose/rotate-keys-daily.sh
+++ b/rootfs/home/cloud-compose/rotate-keys-daily.sh
@@ -2,8 +2,25 @@
set -euo pipefail
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
+_cc_rotate_keys_daily_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_rotate_keys_daily_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_rotate_keys_daily_source script_dir _cc_rotate_keys_daily_installed_home
+if [[ -n "$_cc_rotate_keys_daily_installed_home" &&
+ ( "$_cc_rotate_keys_daily_installed_home" == "/" ||
+ "$_cc_rotate_keys_daily_source" == "${_cc_rotate_keys_daily_installed_home%/}/"* ) ]]; then
+ _cc_rotate_keys_daily_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_rotate_keys_daily_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_rotate_keys_daily_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_rotate_keys_daily_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_rotate_keys_daily_source" CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh "$script_dir/profile.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+readonly profile_path
# shellcheck disable=SC1090
source "$profile_path"
diff --git a/rootfs/home/cloud-compose/rotate-keys-internal.sh b/rootfs/home/cloud-compose/rotate-keys-internal.sh
index f8aca1e..6641109 100644
--- a/rootfs/home/cloud-compose/rotate-keys-internal.sh
+++ b/rootfs/home/cloud-compose/rotate-keys-internal.sh
@@ -2,9 +2,29 @@
set -euo pipefail
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
-rotate_keys_script="${CLOUD_COMPOSE_ROTATE_KEYS_PATH:-$script_dir/rotate-keys.sh}"
+_cc_rotate_keys_internal_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_rotate_keys_internal_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_rotate_keys_internal_source script_dir _cc_rotate_keys_internal_installed_home
+if [[ -n "$_cc_rotate_keys_internal_installed_home" &&
+ ( "$_cc_rotate_keys_internal_installed_home" == "/" ||
+ "$_cc_rotate_keys_internal_source" == "${_cc_rotate_keys_internal_installed_home%/}/"* ) ]]; then
+ _cc_rotate_keys_internal_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_rotate_keys_internal_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_rotate_keys_internal_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_rotate_keys_internal_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_rotate_keys_internal_source" CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh "$script_dir/profile.sh"
+cloud_compose_bind_source_program \
+ "$_cc_rotate_keys_internal_source" CLOUD_COMPOSE_ROTATE_KEYS_PATH \
+ /home/cloud-compose/rotate-keys.sh "$script_dir/rotate-keys.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+rotate_keys_script="$CLOUD_COMPOSE_ROTATE_KEYS_PATH"
+readonly profile_path rotate_keys_script
# shellcheck disable=SC1090
source "$profile_path"
diff --git a/rootfs/home/cloud-compose/rotate-keys.sh b/rootfs/home/cloud-compose/rotate-keys.sh
index 9735301..7161577 100644
--- a/rootfs/home/cloud-compose/rotate-keys.sh
+++ b/rootfs/home/cloud-compose/rotate-keys.sh
@@ -2,12 +2,64 @@
set -euo pipefail
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
+_cc_rotate_keys_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_rotate_keys_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_rotate_keys_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_rotate_keys_source _cc_rotate_keys_dir _cc_rotate_keys_installed_home
+if [[ -n "$_cc_rotate_keys_installed_home" &&
+ ( "$_cc_rotate_keys_installed_home" == "/" ||
+ "$_cc_rotate_keys_source" == "${_cc_rotate_keys_installed_home%/}/"* ) ]]; then
+ _cc_rotate_keys_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_rotate_keys_checked_programs="$_cc_rotate_keys_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_rotate_keys_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_rotate_keys_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_rotate_keys_source" \
+ CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh \
+ "$_cc_rotate_keys_dir/profile.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+readonly profile_path
# shellcheck disable=SC1090
source "$profile_path"
+# Reload the fixed resolver after the profile so an installed environment
+# cannot replace the binding functions used for checked data programs.
+# shellcheck disable=SC1090
+source "$_cc_rotate_keys_checked_programs"
+cloud_compose_bind_program_dir \
+ "$_cc_rotate_keys_source" \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR \
+ /etc/cloud-compose/jq \
+ "$_cc_rotate_keys_dir/../../etc/cloud-compose/jq" \
+ service-account-key-id.jq \
+ rotation-build-state.jq \
+ rotation-validate-state.jq \
+ rotation-normalize-user-keys.jq \
+ service-account-credentials-valid.jq \
+ rotation-jwt-claims.jq \
+ rotation-remaining-baseline.jq \
+ rotation-new-key-names.jq \
+ rotation-audit.jq \
+ rotation-first-key-id.jq \
+ rotation-idle-audit.jq \
+ rotation-key-disabled.jq \
+ rotation-key-ids-join.jq \
+ rotation-key-names-base64.jq \
+ rotation-key-names-sorted.jq \
+ rotation-key-object-ids-join.jq \
+ rotation-key-present.jq \
+ array-values-base64.jq \
+ json-length.jq \
+ nonempty-string-field.jq \
+ object-field-delimited.jq \
+ object-field.jq \
+ string-field-valid.jq
+
log_info() {
printf '[key-rotation] %s\n' "$1" >&2
}
@@ -179,11 +231,8 @@ valid_iam_key_id() {
credential_key_id() {
local file="$1" key_id
- key_id="$(jq -jr '
- (.private_key_id |
- select(type == "string" and length > 0 and (explode | index(0) == null))),
- "\u001f"
- ' "$file")" || return 1
+ key_id="$(jq -jr -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/service-account-key-id.jq" \
+ "$file")" || return 1
key_id="${key_id%$'\x1f'}"
valid_iam_key_id "$key_id" || return 1
printf '%s\n' "$key_id"
@@ -263,20 +312,8 @@ write_state() {
--argjson created_at "$STATE_CREATED_AT" \
--argjson ready_at "$STATE_READY_AT" \
--argjson disabled_at "$STATE_DISABLED_AT" \
- '{
- version: 2,
- phase: $phase,
- service_account: $service_account,
- project_id: $project_id,
- credentials_file: $credentials_file,
- current_key_id: $current_key_id,
- new_key_id: $new_key_id,
- new_key_name: $new_key_name,
- baseline_key_names: $baseline_key_names,
- created_at: $created_at,
- ready_at: $ready_at,
- disabled_at: $disabled_at
- }' >"$state_tmp"; then
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-build-state.jq" \
+ >"$state_tmp"; then
rm -f -- "$state_tmp"
return 1
fi
@@ -298,43 +335,32 @@ load_state() {
payload="$(jq -ce \
--arg service_account "$SERVICE_ACCOUNT" \
--arg project_id "$PROJECT_ID" \
- --arg credentials_file "$CREDENTIALS_FILE" '
- select(
- .version == 2 and
- (.phase == "reconciling" or .phase == "creating-fresh" or
- .phase == "creating" or .phase == "staged" or .phase == "authenticated" or
- .phase == "ready" or .phase == "grace" or .phase == "rolling-back" or
- .phase == "rollback" or .phase == "revoke-new") and
- .service_account == $service_account and
- .project_id == $project_id and
- .credentials_file == $credentials_file and
- (.current_key_id | type == "string" and (explode | index(0) == null)) and
- (.new_key_id | type == "string" and (explode | index(0) == null)) and
- (.new_key_name | type == "string" and (explode | index(0) == null)) and
- (.baseline_key_names | type == "array") and
- (.baseline_key_names | length <= 10 and . == (sort | unique)) and
- all(.baseline_key_names[];
- type == "string" and (explode | index(0) == null)) and
- (.created_at | type == "number" and . >= 0 and floor == .) and
- (.ready_at | type == "number" and . >= 0 and floor == .) and
- (.disabled_at | type == "number" and . >= 0 and floor == .)
- )
- ' "$PENDING_STATE")" || {
+ --arg credentials_file "$CREDENTIALS_FILE" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-validate-state.jq" \
+ "$PENDING_STATE")" || {
log_error "Pending rotation state is invalid or belongs to another target: $PENDING_STATE"
return 1
}
- STATE_PHASE="$(jq -r '.phase' <<<"$payload")"
- STATE_CURRENT_KEY_ID="$(jq -jr '(.current_key_id), "\u001f"' <<<"$payload")"
+ STATE_PHASE="$(jq -r --arg field phase \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" <<<"$payload")"
+ STATE_CURRENT_KEY_ID="$(jq -jr --arg field current_key_id \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" <<<"$payload")"
STATE_CURRENT_KEY_ID="${STATE_CURRENT_KEY_ID%$'\x1f'}"
- STATE_NEW_KEY_ID="$(jq -jr '(.new_key_id), "\u001f"' <<<"$payload")"
+ STATE_NEW_KEY_ID="$(jq -jr --arg field new_key_id \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" <<<"$payload")"
STATE_NEW_KEY_ID="${STATE_NEW_KEY_ID%$'\x1f'}"
- STATE_NEW_KEY_NAME="$(jq -jr '(.new_key_name), "\u001f"' <<<"$payload")"
+ STATE_NEW_KEY_NAME="$(jq -jr --arg field new_key_name \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" <<<"$payload")"
STATE_NEW_KEY_NAME="${STATE_NEW_KEY_NAME%$'\x1f'}"
- STATE_BASELINE_KEY_NAMES="$(jq -c '.baseline_key_names' <<<"$payload")"
- STATE_CREATED_AT="$(jq -r '.created_at' <<<"$payload")"
- STATE_READY_AT="$(jq -r '.ready_at' <<<"$payload")"
- STATE_DISABLED_AT="$(jq -r '.disabled_at' <<<"$payload")"
+ STATE_BASELINE_KEY_NAMES="$(jq -c --arg field baseline_key_names \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" <<<"$payload")"
+ STATE_CREATED_AT="$(jq -r --arg field created_at \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" <<<"$payload")"
+ STATE_READY_AT="$(jq -r --arg field ready_at \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" <<<"$payload")"
+ STATE_DISABLED_AT="$(jq -r --arg field disabled_at \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" <<<"$payload")"
if [[ -n "$STATE_CURRENT_KEY_ID" && ! "$STATE_CURRENT_KEY_ID" =~ ^[A-Za-z0-9_-]+$ ]]; then
log_error "Pending rotation state contains an invalid previous key ID"
@@ -352,7 +378,8 @@ load_state() {
log_error "Pending rotation state contains an invalid baseline key name"
return 1
fi
- done < <(jq -r '.[] | @base64' <<<"$STATE_BASELINE_KEY_NAMES")
+ done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/array-values-base64.jq" \
+ <<<"$STATE_BASELINE_KEY_NAMES")
case "$STATE_PHASE" in
staged | authenticated | ready | grace | rolling-back | rollback | revoke-new)
@@ -383,7 +410,9 @@ fetch_access_token() {
log_error "Failed to get access token from metadata server"
return 1
}
- ACCESS_TOKEN="$(jq -er '.access_token | select(type == "string" and length > 0)' <<<"$token_response")" || {
+ ACCESS_TOKEN="$(jq -er --arg field access_token \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/nonempty-string-field.jq" \
+ <<<"$token_response")" || {
log_error "Metadata server returned an invalid access-token response"
return 1
}
@@ -412,23 +441,9 @@ list_user_keys() {
log_error "Failed to list service-account keys"
return 1
fi
- normalized_keys="$(jq -cer --arg prefix "$SA_RESOURCE/keys/" '
- (.keys // []) as $keys |
- if ($keys | type) != "array" then error("invalid key list") else
- [$keys[] | select(.keyType == "USER_MANAGED")] as $user_keys |
- if ($user_keys | length) > 10 then error("too many user-managed keys")
- elif any($user_keys[];
- (.name | type) != "string" or
- (.name | startswith($prefix) | not) or
- ((.disabled // false) | type) != "boolean")
- then error("invalid user-managed key")
- elif ([$user_keys[].name] | unique | length) != ($user_keys | length)
- then error("duplicate user-managed key")
- else
- [$user_keys[] | {name: .name, disabled: (.disabled // false)}] | sort_by(.name)
- end
- end
- ' <<<"$keys_response")" || {
+ normalized_keys="$(jq -cer --arg prefix "$SA_RESOURCE/keys/" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-normalize-user-keys.jq" \
+ <<<"$keys_response")" || {
log_error "IAM returned an invalid user-managed key inventory"
return 1
}
@@ -444,21 +459,26 @@ list_user_keys() {
log_error "IAM returned an invalid user-managed key name"
return 1
fi
- done < <(jq -r '.[].name | @base64' <<<"$normalized_keys")
+ done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-names-base64.jq" \
+ <<<"$normalized_keys")
printf '%s\n' "$normalized_keys"
}
list_user_key_names() {
- list_user_keys | jq -c '[.[].name] | sort'
+ list_user_keys | jq -c -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-names-sorted.jq"
}
key_remote_status() {
local key_name="$1" keys
keys="$(list_user_keys)" || return 1
- if ! jq -e --arg name "$key_name" 'any(.[]; .name == $name)' <<<"$keys" >/dev/null; then
+ if ! jq -e --arg name "$key_name" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-present.jq" \
+ <<<"$keys" >/dev/null; then
printf 'absent\n'
- elif jq -e --arg name "$key_name" 'any(.[]; .name == $name and .disabled == true)' <<<"$keys" >/dev/null; then
+ elif jq -e --arg name "$key_name" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-disabled.jq" \
+ <<<"$keys" >/dev/null; then
printf 'disabled\n'
else
printf 'enabled\n'
@@ -550,14 +570,9 @@ validate_credentials_key() {
jq -e \
--arg key_id "$expected_key_id" \
--arg service_account "$SERVICE_ACCOUNT" \
- --arg project_id "$PROJECT_ID" '
- .type == "service_account" and
- .private_key_id == $key_id and
- .client_email == $service_account and
- .project_id == $project_id and
- .token_uri == "https://oauth2.googleapis.com/token" and
- (.private_key | type == "string" and startswith("-----BEGIN PRIVATE KEY-----") and contains("-----END PRIVATE KEY-----"))
- ' "$file" >/dev/null
+ --arg project_id "$PROJECT_ID" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/service-account-credentials-valid.jq" \
+ "$file" >/dev/null
}
install_credentials_file() {
@@ -618,8 +633,10 @@ authenticate_credentials_once() {
log_error "openssl is required to authenticate replacement credentials"
return 1
}
- email="$(jq -er '.client_email' "$file")" || return 1
- token_uri="$(jq -er '.token_uri' "$file")" || return 1
+ email="$(jq -er --arg field client_email \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" "$file")" || return 1
+ token_uri="$(jq -er --arg field token_uri \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" "$file")" || return 1
private_key_tmp="$(mktemp "${credentials_dir}/.auth-key.XXXXXX")" || return 1
request_tmp="$(mktemp "${credentials_dir}/.auth-request.XXXXXX")" || {
rm -f -- "$private_key_tmp"
@@ -630,7 +647,9 @@ authenticate_credentials_once() {
return 1
}
chmod 0600 "$private_key_tmp" "$request_tmp" "$response_tmp"
- jq -er '.private_key' "$file" >"$private_key_tmp" || {
+ jq -er --arg field private_key \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" \
+ "$file" >"$private_key_tmp" || {
rm -f -- "$private_key_tmp" "$request_tmp" "$response_tmp"
return 1
}
@@ -641,7 +660,7 @@ authenticate_credentials_once() {
--arg iss "$email" \
--arg aud "$token_uri" \
--argjson iat "$now" \
- '{iss: $iss, scope: "https://www.googleapis.com/auth/cloud-platform", aud: $aud, iat: $iat, exp: ($iat + 3600)}' | base64url)"
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-jwt-claims.jq" | base64url)"
signing_input="${header}.${payload}"
signature="$(printf '%s' "$signing_input" | openssl dgst -sha256 -sign "$private_key_tmp" | base64url)" || {
rm -f -- "$private_key_tmp" "$request_tmp" "$response_tmp"
@@ -657,7 +676,9 @@ authenticate_credentials_once() {
"$token_uri" || curl_status=$?
rm -f -- "$private_key_tmp" "$request_tmp"
if ((curl_status != 0)) ||
- ! jq -e '.access_token | type == "string" and length > 0' "$response_tmp" >/dev/null; then
+ ! jq -e --arg field access_token \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/string-field-valid.jq" \
+ "$response_tmp" >/dev/null; then
rm -f -- "$response_tmp"
return 1
fi
@@ -752,7 +773,8 @@ create_replacement_key() {
"https://iam.googleapis.com/v1/$SA_RESOURCE/keys")" || curl_status=$?
rm -f -- "$ACCESS_HEADER_FILE"
- new_key_name="$(jq -er '.name | select(type == "string" and length > 0)' \
+ new_key_name="$(jq -er --arg field name \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/nonempty-string-field.jq" \
"$response_tmp" 2>/dev/null)" || new_key_name=""
if [[ -z "$new_key_name" ]]; then
cleanup_ephemeral_create_response
@@ -774,7 +796,8 @@ create_replacement_key() {
STATE_NEW_KEY_ID="$new_key_id"
STATE_NEW_KEY_NAME="$new_key_name"
- private_key_data="$(jq -er '.privateKeyData | select(type == "string" and length > 0)' \
+ private_key_data="$(jq -er --arg field privateKeyData \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/nonempty-string-field.jq" \
"$response_tmp" 2>/dev/null)" || private_key_data=""
cleanup_ephemeral_create_response
if [[ -z "$private_key_data" ]]; then
@@ -837,25 +860,30 @@ finish_orphan_reconciliation() {
log_error "Failed to delete fresh-filesystem orphan key ${key_name##*/}; reconciliation will retry"
return 1
fi
- done < <(jq -r '.[] | @base64' <<<"$STATE_BASELINE_KEY_NAMES")
+ done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/array-values-base64.jq" \
+ <<<"$STATE_BASELINE_KEY_NAMES")
current_names="$(list_user_key_names)" || return 1
remaining_baseline="$(jq -cn \
--argjson before "$STATE_BASELINE_KEY_NAMES" \
--argjson after "$current_names" \
- '$after | map(select(. as $name | $before | index($name))) | sort')" || return 1
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-remaining-baseline.jq")" || return 1
unexpected="$(jq -cn \
--argjson before "$STATE_BASELINE_KEY_NAMES" \
--argjson after "$current_names" \
- '$after - $before | sort')" || return 1
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-new-key-names.jq")" || return 1
- if [[ "$(jq -r 'length' <<<"$unexpected")" != "0" ]]; then
- unexpected_ids="$(jq -r '[.[] | split("/")[-1]] | join(", ")' <<<"$unexpected")" || return 1
+ if [[ "$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
+ <<<"$unexpected")" != "0" ]]; then
+ unexpected_ids="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-ids-join.jq" \
+ <<<"$unexpected")" || return 1
log_error "Unexpected concurrent user-managed keys appeared during fresh-filesystem reconciliation: $unexpected_ids"
return 1
fi
- if [[ "$(jq -r 'length' <<<"$remaining_baseline")" != "0" ]]; then
- remaining_ids="$(jq -r '[.[] | split("/")[-1]] | join(", ")' <<<"$remaining_baseline")" || return 1
+ if [[ "$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
+ <<<"$remaining_baseline")" != "0" ]]; then
+ remaining_ids="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-ids-join.jq" \
+ <<<"$remaining_baseline")" || return 1
log_error "Deleted orphan keys are still visible and will be retried: $remaining_ids"
return 1
fi
@@ -993,9 +1021,11 @@ prepare_rotation() {
fetch_access_token || return 1
baseline_key_names="$(list_user_key_names)" || return 1
- baseline_count="$(jq -r 'length' <<<"$baseline_key_names")" || return 1
+ baseline_count="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
+ <<<"$baseline_key_names")" || return 1
if ((10#$baseline_count >= 10)); then
- baseline_ids="$(jq -r '[.[] | split("/")[-1]] | join(", ")' <<<"$baseline_key_names")" || return 1
+ baseline_ids="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-ids-join.jq" \
+ <<<"$baseline_key_names")" || return 1
log_error "Service account already has the 10 user-managed keys allowed by IAM: $baseline_ids"
return 1
fi
@@ -1202,11 +1232,14 @@ retire_credentials() {
# remaining IDs; never guess which externally managed key to delete.
fetch_access_token || return 1
remaining_keys="$(list_user_keys)" || return 1
- if [[ "$(jq -r 'length' <<<"$remaining_keys")" == "0" ]]; then
+ if [[ "$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
+ <<<"$remaining_keys")" == "0" ]]; then
log_info "No local credential or remote user-managed key remains; retirement is complete"
return 0
fi
- remaining_key_ids="$(jq -r '[.[].name | split("/")[-1]] | sort | join(", ")' <<<"$remaining_keys")" || return 1
+ remaining_key_ids="$(jq -r \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-object-ids-join.jq" \
+ <<<"$remaining_keys")" || return 1
log_error "No local credential is available to identify the managed key, but remote user-managed keys remain: $remaining_key_ids"
log_error "Audit and explicitly revoke the remaining key IDs before disabling managed credentials"
return 1
@@ -1241,14 +1274,14 @@ creation_candidates() {
fetch_access_token || return 1
current_names="$(list_user_key_names)" || return 1
jq -cn --argjson before "$STATE_BASELINE_KEY_NAMES" --argjson after "$current_names" \
- '$after - $before | sort'
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-new-key-names.jq"
}
rotation_audit() {
local candidates='[]' grace_remaining=0 elapsed
if [[ ! -e "$PENDING_STATE" && ! -L "$PENDING_STATE" ]]; then
- jq -n '{version: 1, phase: "idle", recovery_required: false, candidate_key_ids: [], grace_remaining_seconds: 0}'
+ jq -n -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-idle-audit.jq"
return 0
fi
load_state || return 1
@@ -1269,19 +1302,8 @@ rotation_audit() {
--argjson created_at "$STATE_CREATED_AT" \
--argjson ready_at "$STATE_READY_AT" \
--argjson disabled_at "$STATE_DISABLED_AT" \
- --argjson grace_remaining "$grace_remaining" '
- {
- version: 1,
- phase: $phase,
- current_key_id: $current_key_id,
- new_key_id: $new_key_id,
- recovery_required: ($phase == "creating" or $phase == "creating-fresh"),
- candidate_key_ids: [$candidate_names[] | split("/")[-1]],
- created_at: $created_at,
- ready_at: $ready_at,
- disabled_at: $disabled_at,
- grace_remaining_seconds: $grace_remaining
- }'
+ --argjson grace_remaining "$grace_remaining" \
+ -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-audit.jq"
}
recover_ambiguous_creation() {
@@ -1299,7 +1321,8 @@ recover_ambiguous_creation() {
return 1
fi
candidates="$(creation_candidates)" || return 1
- if [[ "$(jq -r 'length' <<<"$candidates")" == "0" ]]; then
+ if [[ "$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
+ <<<"$candidates")" == "0" ]]; then
if [[ "$STATE_PHASE" == "creating-fresh" ]]; then
STATE_PHASE=reconciling
STATE_BASELINE_KEY_NAMES='[]'
@@ -1311,12 +1334,14 @@ recover_ambiguous_creation() {
fi
return 0
fi
- if [[ "$(jq -r 'length' <<<"$candidates")" != "1" ]]; then
+ if [[ "$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
+ <<<"$candidates")" != "1" ]]; then
rotation_audit
log_error "Recovery is ambiguous because more than one post-baseline key exists; no key was changed"
return 1
fi
- candidate_id="$(jq -r '.[0] | split("/")[-1]' <<<"$candidates")"
+ candidate_id="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-first-key-id.jq" \
+ <<<"$candidates")"
if [[ -z "$RECOVERY_KEY_ID" || "$RECOVERY_KEY_ID" != "$candidate_id" ]]; then
rotation_audit
log_error "Recovery requires the single audited candidate key ID as the final argument; no key was changed"
diff --git a/rootfs/home/cloud-compose/run-rollout-service.sh b/rootfs/home/cloud-compose/run-rollout-service.sh
index 413973f..7857425 100644
--- a/rootfs/home/cloud-compose/run-rollout-service.sh
+++ b/rootfs/home/cloud-compose/run-rollout-service.sh
@@ -2,8 +2,40 @@
set -euo pipefail
+_cc_run_rollout_service_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_run_rollout_service_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_run_rollout_service_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_run_rollout_service_source _cc_run_rollout_service_dir _cc_run_rollout_service_installed_home
+if [[ -n "$_cc_run_rollout_service_installed_home" &&
+ ( "$_cc_run_rollout_service_installed_home" == "/" ||
+ "$_cc_run_rollout_service_source" == "${_cc_run_rollout_service_installed_home%/}/"* ) ]]; then
+ _cc_run_rollout_service_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_run_rollout_service_checked_programs="$_cc_run_rollout_service_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_run_rollout_service_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_run_rollout_service_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_run_rollout_service_source" \
+ CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh \
+ "$_cc_run_rollout_service_dir/profile.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+readonly profile_path
+
# shellcheck disable=SC1090,SC1091
-source "${CLOUD_COMPOSE_PROFILE_PATH:-/home/cloud-compose/profile.sh}"
+source "$profile_path"
+
+# Reload the fixed resolver after the profile before binding data programs.
+# shellcheck disable=SC1090
+source "$_cc_run_rollout_service_checked_programs"
+cloud_compose_bind_program_dir \
+ "$_cc_run_rollout_service_source" \
+ CLOUD_COMPOSE_JQ_PROGRAM_DIR \
+ /etc/cloud-compose/jq \
+ "$_cc_run_rollout_service_dir/../../etc/cloud-compose/jq" \
+ json-object-validate.jq
unset BASH_ENV ENV LD_PRELOAD LD_LIBRARY_PATH
PORT="${ROLLOUT_PORT:?ROLLOUT_PORT is required}"
@@ -24,7 +56,8 @@ if [[ "$JWT_AUD" == *$'\n'* || "$JWT_AUD" == *$'\r'* || -z "$JWT_AUD" ]]; then
exit 2
fi
if [[ -n "$CUSTOM_CLAIMS" ]] &&
- ! jq -e 'type == "object"' <<<"$CUSTOM_CLAIMS" >/dev/null; then
+ ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-object-validate.jq" \
+ <<<"$CUSTOM_CLAIMS" >/dev/null; then
echo "ROLLOUT_CUSTOM_CLAIMS must be empty or a JSON object" >&2
exit 2
fi
diff --git a/rootfs/home/cloud-compose/run.sh b/rootfs/home/cloud-compose/run.sh
index 470a72f..dcd884a 100644
--- a/rootfs/home/cloud-compose/run.sh
+++ b/rootfs/home/cloud-compose/run.sh
@@ -17,10 +17,8 @@ run_as_cloud_compose() (
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
+ echo "Neither runuser nor sudo is available for cloud-compose app init" >&2
return 1
fi
)
@@ -32,7 +30,7 @@ runtime_enabled() {
esac
}
-durable_bootstrap_marker="/home/cloud-compose/.cloud-compose-bootstrap-complete"
+durable_bootstrap_marker="/var/lib/cloud-compose/bootstrap-complete"
current_boot_app_init_marker="/run/cloud-compose-app-init-complete"
fresh_filesystem_marker="${CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER:-/mnt/disks/data/.cloud-compose/fresh-filesystem}"
fresh_filesystem_identity="${CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY:-fresh}"
@@ -113,4 +111,9 @@ else
systemctl disable --now cloud-compose-docker-prune.timer cloud-compose-docker-prune.service >/dev/null 2>&1 || true
fi
systemctl enable --now cloud-compose-mariadb-backup.timer
+if runtime_enabled "${CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED:-false}"; then
+ systemctl enable --now cloud-compose-restore-test.timer
+else
+ systemctl disable --now cloud-compose-restore-test.timer cloud-compose-restore-test.service >/dev/null 2>&1 || true
+fi
cloud_compose_publish_marker "$durable_bootstrap_marker"
diff --git a/rootfs/home/cloud-compose/smoke-healthcheck.sh b/rootfs/home/cloud-compose/smoke-healthcheck.sh
new file mode 100755
index 0000000..2318fe4
--- /dev/null
+++ b/rootfs/home/cloud-compose/smoke-healthcheck.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+if (($# != 1)) || [[ -z "$1" ]]; then
+ echo "usage: smoke-healthcheck.sh CONTEXT" >&2
+ exit 2
+fi
+
+readonly context="$1"
+
+# Load the same validated runtime environment and tool path used by the
+# host-owned lifecycle scripts before handing control to sitectl.
+export HOME=/home/cloud-compose
+source /home/cloud-compose/profile.sh
+
+exec sitectl healthcheck --context "$context" --persist --format table
diff --git a/rootfs/home/cloud-compose/start-cloud-compose-bootstrap.sh b/rootfs/home/cloud-compose/start-cloud-compose-bootstrap.sh
index 0e55092..1016806 100644
--- a/rootfs/home/cloud-compose/start-cloud-compose-bootstrap.sh
+++ b/rootfs/home/cloud-compose/start-cloud-compose-bootstrap.sh
@@ -2,10 +2,30 @@
set -euo pipefail
+_cc_start_bootstrap_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_start_bootstrap_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_start_bootstrap_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_start_bootstrap_source _cc_start_bootstrap_dir _cc_start_bootstrap_installed_home
+if [[ -n "$_cc_start_bootstrap_installed_home" &&
+ ( "$_cc_start_bootstrap_installed_home" == "/" ||
+ "$_cc_start_bootstrap_source" == "${_cc_start_bootstrap_installed_home%/}/"* ) ]]; then
+ _cc_start_bootstrap_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_start_bootstrap_checked_programs="$_cc_start_bootstrap_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_start_bootstrap_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_start_bootstrap_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_start_bootstrap_source" CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH \
+ /home/cloud-compose/bootstrap-helpers.sh "$_cc_start_bootstrap_dir/bootstrap-helpers.sh"
+bootstrap_helpers_path="$CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH"
+readonly bootstrap_helpers_path
+
# shellcheck disable=SC1090
-source "${CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH:-/home/cloud-compose/bootstrap-helpers.sh}"
+source "$bootstrap_helpers_path"
-durable_marker="${CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER:-/home/cloud-compose/.cloud-compose-bootstrap-complete}"
+durable_marker="${CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER:-/var/lib/cloud-compose/bootstrap-complete}"
wait_seconds="${CLOUD_COMPOSE_BOOTSTRAP_WAIT_SECONDS:-10800}"
bootstrap_unit="cloud-compose-bootstrap.service"
diff --git a/rootfs/home/cloud-compose/vault-agent-readiness.sh b/rootfs/home/cloud-compose/vault-agent-readiness.sh
index af9f7df..8549eb7 100644
--- a/rootfs/home/cloud-compose/vault-agent-readiness.sh
+++ b/rootfs/home/cloud-compose/vault-agent-readiness.sh
@@ -2,7 +2,28 @@
set -euo pipefail
-profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-/home/cloud-compose/profile.sh}"
+_cc_vault_agent_readiness_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
+_cc_vault_agent_readiness_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+_cc_vault_agent_readiness_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
+readonly _cc_vault_agent_readiness_source _cc_vault_agent_readiness_dir _cc_vault_agent_readiness_installed_home
+if [[ -n "$_cc_vault_agent_readiness_installed_home" &&
+ ( "$_cc_vault_agent_readiness_installed_home" == "/" ||
+ "$_cc_vault_agent_readiness_source" == "${_cc_vault_agent_readiness_installed_home%/}/"* ) ]]; then
+ _cc_vault_agent_readiness_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
+else
+ _cc_vault_agent_readiness_checked_programs="$_cc_vault_agent_readiness_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
+fi
+readonly _cc_vault_agent_readiness_checked_programs
+# shellcheck disable=SC1090
+source "$_cc_vault_agent_readiness_checked_programs"
+cloud_compose_bind_source_program \
+ "$_cc_vault_agent_readiness_source" \
+ CLOUD_COMPOSE_PROFILE_PATH \
+ /home/cloud-compose/profile.sh \
+ "$_cc_vault_agent_readiness_dir/profile.sh"
+profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
+readonly profile_path
+
# shellcheck disable=SC1090
source "$profile_path"
diff --git a/runtime_contracts.tftest.hcl b/runtime_contracts.tftest.hcl
index 1dfbf9d..d818bdd 100644
--- a/runtime_contracts.tftest.hcl
+++ b/runtime_contracts.tftest.hcl
@@ -147,6 +147,56 @@ run "public_entrypoint_rejects_reserved_extra_environment" {
expect_failures = [var.runtime]
}
+run "public_entrypoint_accepts_provider_neutral_disaster_recovery_driver" {
+ command = plan
+
+ variables {
+ name = "root-contract"
+ cloud_provider = "gcp"
+ template = "wp"
+ gcp = {
+ project_id = "test-project"
+ project_number = "123456789"
+ }
+ runtime = {
+ disaster_recovery = {
+ required = true
+ driver_path = "/etc/cloud-compose/libexec/acme-offhost"
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ local.runtime.disaster_recovery.required &&
+ local.runtime.disaster_recovery.driver_path == "/etc/cloud-compose/libexec/acme-offhost"
+ )
+ error_message = "The public entrypoint must preserve provider-neutral DR controls."
+ }
+}
+
+run "public_entrypoint_rejects_unsafe_disaster_recovery_driver_path" {
+ command = plan
+
+ variables {
+ name = "root-contract"
+ cloud_provider = "gcp"
+ template = "wp"
+ gcp = {
+ project_id = "test-project"
+ project_number = "123456789"
+ }
+ runtime = {
+ disaster_recovery = {
+ required = true
+ driver_path = "/tmp/driver with spaces"
+ }
+ }
+ }
+
+ expect_failures = [var.runtime]
+}
+
run "public_entrypoint_accepts_direct_cloud_run_proxy_depth" {
command = plan
diff --git a/salt/cloud-compose/README.md b/salt/cloud-compose/README.md
index a9ebcbf..2008296 100644
--- a/salt/cloud-compose/README.md
+++ b/salt/cloud-compose/README.md
@@ -65,6 +65,28 @@ empty list disables that phase and is preserved instead of restoring a default.
Runtime feature switches must be YAML booleans, not quoted strings; ambiguous
values are rejected before host mutation.
+Lifecycle list entries are program selectors, not shell source. Use the
+built-in `/home/cloud-compose/default-lifecycle.sh ACTION`, `true`, `false`, or
+one argument-free root-owned executable immediately below
+`/etc/cloud-compose/lifecycle.d`. Put multi-step logic and quoting inside that
+checked program file.
+
+The provider-neutral disaster-recovery interface is shared with Terraform:
+
+```yaml
+cloud_compose:
+ runtime:
+ disaster_recovery:
+ required: true
+ driver_path: /etc/cloud-compose/libexec/offhost-backup-driver
+```
+
+Install that executable and its credentials separately as root. The formula
+renders only the boolean requirement and executable path; storage endpoints,
+credentials, encryption keys, and retention policy must not be placed in
+pillar. See [the driver contract](../../docs/disaster-recovery.md) for receipt
+and scheduled restore-proof requirements.
+
Set `cloud_compose.runtime.rollout` to enable the same authenticated rollout
listener used by Terraform. Supply a pinned HTTPS `release_url`, its lowercase
`release_sha256`, an HTTPS `jwks_uri`, `jwt_audience`, and optional JSON-object
@@ -76,7 +98,9 @@ The formula installs lifecycle dispatchers as `root:cloud-compose` mode `0750`
and the root-consumed `.env`, project/application JSON, and managed-artifact
manifest as `root:cloud-compose` mode `0640`. Reapplying the state restores that
ownership boundary while leaving application checkout directories writable by
-the `cloud-compose` account.
+the `cloud-compose` account. Before a requested runtime bootstrap, the formula
+invokes the same checked-in bootstrap path hardener used by the Terraform
+modules.
The normal on-prem shape is one app per machine. Use pillar targeting to give
each minion its own `cloud_compose` values, then apply the same
@@ -121,7 +145,7 @@ cloud_compose:
sitectl:
environment: production
package_versions:
- sitectl-isle: v1.0.0
+ sitectl-isle: v1.5.0
```
Apply:
diff --git a/salt/cloud-compose/init.sls b/salt/cloud-compose/init.sls
index a6ac016..9cd2151 100644
--- a/salt/cloud-compose/init.sls
+++ b/salt/cloud-compose/init.sls
@@ -42,7 +42,8 @@
'sitectl': runtime.get('sitectl', {}),
'docker': runtime.get('docker', {}),
'managed_runtime': runtime.get('managed_runtime', {}),
- 'vault': runtime.get('vault', {})
+ 'vault': runtime.get('vault', {}),
+ 'disaster_recovery': runtime.get('disaster_recovery', {})
} %}
{% for section_name, section_value in runtime_sections.items() %}
{% if section_value is not mapping %}
@@ -54,6 +55,7 @@
{% set docker = runtime_sections.docker if runtime_sections.docker is mapping else {} %}
{% set managed = runtime_sections.managed_runtime if runtime_sections.managed_runtime is mapping else {} %}
{% set vault = runtime_sections.vault if runtime_sections.vault is mapping else {} %}
+{% set disaster_recovery = runtime_sections.disaster_recovery if runtime_sections.disaster_recovery is mapping else {} %}
{% set raw_rollout_service = runtime.get('rollout', {}) %}
{% if raw_rollout_service is mapping %}
{% set rollout_service = raw_rollout_service %}
@@ -115,6 +117,11 @@
{% set reload_systemd = cc.get('reload_systemd', True) %}
{% set run_bootstrap = cc.get('run_bootstrap', True) %}
{% set force_bootstrap = cc.get('force_bootstrap', False) %}
+{% set bootstrap_wait_seconds = cc.get('bootstrap_wait_seconds', 10800) %}
+{% if bootstrap_wait_seconds is boolean or bootstrap_wait_seconds is not number or bootstrap_wait_seconds != bootstrap_wait_seconds | int or bootstrap_wait_seconds < 1 or bootstrap_wait_seconds > 43200 %}
+{% set ignored = invalid_runtime_inputs.append('bootstrap_wait_seconds must be a whole number from 1 through 43200') %}
+{% set bootstrap_wait_seconds = 10800 %}
+{% endif %}
{% set raw_template_name = cc.get('template', '') %}
{% if raw_template_name is string %}
{% set template_name = raw_template_name | lower | trim %}
@@ -141,6 +148,14 @@
{% if vault.get('agent_enabled', False) %}
{% set ignored = invalid_runtime_inputs.append('Vault Agent is currently supported only by Terraform providers; set vault.agent_enabled=false for Salt') %}
{% endif %}
+{% set offhost_backup_required = disaster_recovery.get('required', False) %}
+{% set offhost_backup_driver = disaster_recovery.get('driver_path', '/etc/cloud-compose/libexec/offhost-backup-driver') %}
+{% if offhost_backup_required is not boolean %}
+{% set ignored = invalid_runtime_inputs.append('runtime.disaster_recovery.required must be a boolean') %}
+{% endif %}
+{% if offhost_backup_driver is not string or not (offhost_backup_driver is match('^/[A-Za-z0-9._/+:-]+$')) or '//' in offhost_backup_driver or '/./' in offhost_backup_driver or '/../' in offhost_backup_driver or offhost_backup_driver.endswith('/.') or offhost_backup_driver.endswith('/..') %}
+{% set ignored = invalid_runtime_inputs.append('runtime.disaster_recovery.driver_path must be a safe absolute path without whitespace or dot segments') %}
+{% endif %}
{% set rollout_enabled = rollout_service.get('enabled', False) %}
{% set rollout_port = rollout_service.get('port', 8081) %}
{% if rollout_enabled is not boolean %}
@@ -199,21 +214,16 @@
'upload_timeout': ''
} %}
{% set default_init = [
- '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'
+ '/home/cloud-compose/default-lifecycle.sh init'
] %}
{% set default_up = [
- 'sitectl compose --context "${SITECTL_CONTEXT_NAME}" up -d --remove-orphans',
- 'sitectl healthcheck --context "${SITECTL_CONTEXT_NAME}" --persist',
- 'if [ "${SITECTL_ENVIRONMENT}" != "production" ]; then sitectl verify --context "${SITECTL_CONTEXT_NAME}" ${SITECTL_VERIFY_ARGS:-}; fi'
+ '/home/cloud-compose/default-lifecycle.sh up'
] %}
{% set default_down = [
- 'sitectl compose --context "${SITECTL_CONTEXT_NAME}" down'
+ '/home/cloud-compose/default-lifecycle.sh down'
] %}
{% set default_rollout = [
- 'TARGET_REF="${GIT_REF:-${GIT_BRANCH:-}}"',
- 'if [ -n "$TARGET_REF" ]; then sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --ref "$TARGET_REF"; else sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --skip-git; fi',
- 'sitectl healthcheck --context "${SITECTL_CONTEXT_NAME}" --persist',
- 'if [ "${SITECTL_ENVIRONMENT}" != "production" ]; then sitectl verify --context "${SITECTL_CONTEXT_NAME}" ${SITECTL_VERIFY_ARGS:-}; fi'
+ '/home/cloud-compose/default-lifecycle.sh rollout'
] %}
{% set lifecycle_defaults = {
'init': default_init,
@@ -464,6 +474,8 @@
'CLOUD_COMPOSE_INSTANCE_NAME': name,
'CLOUD_COMPOSE_APPS': compose_projects.keys() | list | join(' '),
'CLOUD_COMPOSE_PRIMARY_APP': primary_key,
+ 'CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED': 'true' if offhost_backup_required is sameas true else 'false',
+ 'CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER': offhost_backup_driver,
'COMPOSE_PROJECTS_FILE': home ~ '/compose-projects.json',
'COMPOSE_PROJECT_NAME': primary_project.get('compose_project_name', compose_project_name),
'COMPOSE_BIND_PORT': primary_project.get('ingress_port', ingress_port),
@@ -646,6 +658,21 @@ cloud-compose-rootfs:
- require:
- user: cloud-compose-user
+cloud-compose-privileged-program-directories:
+ file.directory:
+ - names:
+ - {{ home | json }}
+ - /etc/cloud-compose
+ - /etc/cloud-compose/awk
+ - /etc/cloud-compose/bin
+ - /etc/cloud-compose/jq
+ - /etc/cloud-compose/libexec
+ - user: root
+ - group: root
+ - mode: '0755'
+ - require:
+ - file: cloud-compose-rootfs
+
cloud-compose-lifecycle-lock:
cmd.run:
- name: systemd-tmpfiles --create /etc/tmpfiles.d/cloud-compose.conf
@@ -661,25 +688,47 @@ cloud-compose-lifecycle-lock:
cloud-compose-rootfs-script-modes:
cmd.run:
- - name: find /home/cloud-compose -type f -name '*.sh' -exec chmod 0755 {} +
- - unless: test -z "$(find /home/cloud-compose -type f -name '*.sh' ! -perm -u=x -print -quit)"
+ - name: find /home/cloud-compose /etc/cloud-compose/bin /etc/cloud-compose/libexec -maxdepth 1 -type f -name '*.sh' -exec chown root:root {} + -exec chmod 0755 {} +
+ - unless: test -z "$(find /home/cloud-compose /etc/cloud-compose/bin /etc/cloud-compose/libexec -maxdepth 1 -type f -name '*.sh' \( ! -user root -o ! -group root -o ! -perm 0755 \) -print -quit)"
+ - require:
+ - file: cloud-compose-rootfs
+ - file: cloud-compose-privileged-program-directories
+
+cloud-compose-checked-program-resolver:
+ file.managed:
+ - name: /etc/cloud-compose/libexec/checked-programs.bash
+ - source: salt://rootfs/etc/cloud-compose/libexec/checked-programs.bash
+ - user: root
+ - group: root
+ - mode: '0644'
+ - require:
+ - file: cloud-compose-rootfs
+ - file: cloud-compose-privileged-program-directories
+
+cloud-compose-rootfs-jq-modes:
+ cmd.run:
+ - name: find /etc/cloud-compose/jq -maxdepth 1 -type f -name '*.jq' -exec chown root:root {} + -exec chmod 0644 {} +
+ - unless: test -z "$(find /etc/cloud-compose/jq -maxdepth 1 -type f -name '*.jq' \( ! -user root -o ! -group root -o ! -perm 0644 \) -print -quit)"
+ - require:
+ - file: cloud-compose-rootfs
+ - file: cloud-compose-privileged-program-directories
+
+cloud-compose-rootfs-awk-modes:
+ cmd.run:
+ - name: find /etc/cloud-compose/awk -maxdepth 1 -type f -name '*.awk' -exec chown root:root {} + -exec chmod 0644 {} +
+ - unless: test -z "$(find /etc/cloud-compose/awk -maxdepth 1 -type f -name '*.awk' \( ! -user root -o ! -group root -o ! -perm 0644 \) -print -quit)"
- require:
- file: cloud-compose-rootfs
+ - file: cloud-compose-privileged-program-directories
{% for lifecycle in ['init', 'up', 'down', 'rollout'] %}
cloud-compose-lifecycle-{{ lifecycle }}:
file.managed:
- name: {{ (home ~ '/' ~ lifecycle) | json }}
+ - source: salt://rootfs/home/cloud-compose/lifecycle-entrypoint.sh
- user: root
- group: {{ group | json }}
- mode: '0750'
- - contents: |
- #!/usr/bin/env bash
-
- set -eou pipefail
-
- source /home/cloud-compose/profile.sh
- exec bash /home/cloud-compose/compose-dispatch.sh "{{ lifecycle }}"
- require:
- file: cloud-compose-rootfs
{% endfor %}
@@ -741,6 +790,24 @@ cloud-compose-managed-runtime-artifacts:
- require:
- file: cloud-compose-rootfs
+{% if run_bootstrap is sameas true %}
+cloud-compose-bootstrap-paths-hardened:
+ cmd.run:
+ - name: /etc/cloud-compose/libexec/harden-bootstrap-paths.sh
+ - require:
+ - file: cloud-compose-env
+ - file: cloud-compose-application-env
+ - file: cloud-compose-project-manifest
+ - file: cloud-compose-managed-runtime-artifacts
+ - cmd: cloud-compose-rootfs-script-modes
+ - file: cloud-compose-checked-program-resolver
+ - cmd: cloud-compose-rootfs-jq-modes
+ - cmd: cloud-compose-rootfs-awk-modes
+{% for lifecycle in ['init', 'up', 'down', 'rollout'] %}
+ - file: cloud-compose-lifecycle-{{ lifecycle }}
+{% endfor %}
+{% endif %}
+
{% if reload_systemd %}
cloud-compose-systemd-reload:
module.run:
@@ -752,10 +819,22 @@ cloud-compose-systemd-reload:
{% if rollout_enabled is sameas true %}
cloud-compose-rollout-service:
cmd.run:
- - name: bash /home/cloud-compose/deploy-rollout.sh
+ - name: bash /etc/cloud-compose/libexec/run-root-program.sh deploy-rollout.sh
- require:
- file: cloud-compose-env
+ - file: cloud-compose-application-env
+ - file: cloud-compose-project-manifest
+ - file: cloud-compose-managed-runtime-artifacts
- file: cloud-compose-rootfs
+ - cmd: cloud-compose-lifecycle-lock
+ - cmd: cloud-compose-rootfs-script-modes
+ - file: cloud-compose-checked-program-resolver
+ - cmd: cloud-compose-rootfs-jq-modes
+ - cmd: cloud-compose-rootfs-awk-modes
+ - file: cloud-compose-lifecycle-init
+ - file: cloud-compose-lifecycle-up
+ - file: cloud-compose-lifecycle-down
+ - file: cloud-compose-lifecycle-rollout
{% if reload_systemd %}
- module: cloud-compose-systemd-reload
{% endif %}
@@ -764,7 +843,7 @@ cloud-compose-rollout-service:
{% if force_bootstrap is sameas true %}
cloud-compose-clear-bootstrap-marker:
file.absent:
- - name: {{ (home ~ '/.cloud-compose-bootstrap-complete') | json }}
+ - name: /var/lib/cloud-compose/bootstrap-complete
- require:
- cmd: cloud-compose-host-inputs-valid
{% endif %}
@@ -772,8 +851,10 @@ cloud-compose-clear-bootstrap-marker:
{% if run_bootstrap is sameas true %}
cloud-compose-bootstrap:
cmd.run:
- - name: bash {{ (home ~ '/start-cloud-compose-bootstrap.sh') | json }}
- - creates: {{ (home ~ '/.cloud-compose-bootstrap-complete') | json }}
+ - name: bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
+ - env:
+ CLOUD_COMPOSE_BOOTSTRAP_WAIT_SECONDS: {{ (bootstrap_wait_seconds | string) | json }}
+ - unless: bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh
- require:
{% if install_packages %}
- service: cloud-compose-docker
@@ -782,6 +863,17 @@ cloud-compose-bootstrap:
- file: cloud-compose-application-env
- file: cloud-compose-project-manifest
- file: cloud-compose-managed-runtime-artifacts
+ - cmd: cloud-compose-rootfs-script-modes
+ - file: cloud-compose-checked-program-resolver
+ - cmd: cloud-compose-rootfs-jq-modes
+ - cmd: cloud-compose-rootfs-awk-modes
+ - cmd: cloud-compose-bootstrap-paths-hardened
+{% for lifecycle in ['init', 'up', 'down', 'rollout'] %}
+ - file: cloud-compose-lifecycle-{{ lifecycle }}
+{% endfor %}
+{% if force_bootstrap is sameas true %}
+ - file: cloud-compose-clear-bootstrap-marker
+{% endif %}
{% if compose_projects %}
- file: cloud-compose-project-dirs
{% endif %}
diff --git a/template_versions.tftest.hcl b/template_versions.tftest.hcl
index b33564c..34a310e 100644
--- a/template_versions.tftest.hcl
+++ b/template_versions.tftest.hcl
@@ -1,4 +1,12 @@
mock_provider "cloudinit" {}
+mock_provider "http" {
+ mock_data "http" {
+ defaults = {
+ response_body = "20732b7e37bab43eb1b7478305e242f6b3ccced09669d646b05ef656284cfab1\n"
+ status_code = 200
+ }
+ }
+}
mock_provider "google" {
mock_data "google_project" {
defaults = {
@@ -19,8 +27,6 @@ run "default_template_uses_v1_core" {
project_number = "123456789"
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
compose = {
repo = "https://github.com/libops/wp.git"
}
@@ -29,7 +35,7 @@ run "default_template_uses_v1_core" {
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.0.0"
+ sitectl = "v1.9.1"
}
error_message = "The default template must select the released sitectl v1 core."
}
@@ -52,22 +58,20 @@ run "non_isle_template_uses_v1_release_set" {
project_number = "123456789"
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.0.0"
- sitectl-wp = "v1.0.0"
+ sitectl = "v1.9.1"
+ sitectl-wp = "v2.1.0"
}
error_message = "Non-ISLE templates must select their coordinated sitectl v1 release set."
}
assert {
- condition = local.compose.branch == "v1.0.0"
- error_message = "Non-ISLE templates must retain their stable v1.0.0 template contract."
+ condition = local.compose.branch == "v1.1.1"
+ error_message = "The WordPress preset must select its stable v1.1.1 template contract."
}
}
@@ -83,23 +87,21 @@ run "isle_template_uses_v1_release_set" {
project_number = "123456789"
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.0.0"
- sitectl-drupal = "v1.0.0"
- sitectl-isle = "v1.0.0"
+ sitectl = "v1.9.1"
+ sitectl-drupal = "v1.5.0"
+ sitectl-isle = "v1.6.0"
}
error_message = "The ISLE template must select its coordinated sitectl v1 release set by default."
}
assert {
- condition = local.compose.branch == "v1.1.0"
- error_message = "The ISLE preset must select the stable v1.1.0 template contract."
+ condition = local.compose.branch == "v1.3.1"
+ error_message = "The ISLE preset must select the stable v1.3.1 template contract."
}
assert {
@@ -123,8 +125,6 @@ run "explicit_application_environment_overrides_template_defaults" {
project_number = "123456789"
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
extra_env = {
ISLANDORA_TAG = "6.3.20"
SITE_LABEL = "repository"
@@ -154,8 +154,6 @@ run "explicit_package_versions_override_template_defaults" {
project_number = "123456789"
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
package_versions = {
sitectl = "v0.40.1"
@@ -168,7 +166,7 @@ run "explicit_package_versions_override_template_defaults" {
assert {
condition = local.sitectl.package_versions == {
sitectl = "v0.40.1"
- sitectl-drupal = "v1.0.0"
+ sitectl-drupal = "v1.5.0"
sitectl-isle = "v0.19.1"
}
error_message = "Explicit per-package selectors must override only their matching template defaults."
@@ -187,8 +185,6 @@ run "custom_package_set_filters_template_versions" {
project_number = "123456789"
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
packages = ["sitectl", "sitectl-wp"]
package_versions = {
@@ -200,7 +196,7 @@ run "custom_package_set_filters_template_versions" {
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.0.0"
+ sitectl = "v1.9.1"
sitectl-wp = "v0.6.1"
}
error_message = "Template selectors for packages omitted by a custom package set must not reach the runtime."
@@ -219,8 +215,6 @@ run "explicit_core_only_package_set_disables_template_plugins" {
project_number = "123456789"
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
packages = ["sitectl"]
}
@@ -229,7 +223,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.sitectl.packages == tolist(["sitectl"]) && local.sitectl.package_versions == {
- sitectl = "v1.0.0"
+ sitectl = "v1.9.1"
}
error_message = "An explicit core-only package set must not be mistaken for an omitted template package selection."
}
diff --git a/templates/apps.json b/templates/apps.json
index 5a8600d..f14a602 100644
--- a/templates/apps.json
+++ b/templates/apps.json
@@ -6,88 +6,88 @@
"packages": ["sitectl"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.0.0"
+ "sitectl": "v1.9.1"
}
},
"templates": {
"archivesspace": {
"repo": "https://github.com/libops/archivesspace.git",
- "branch": "v1.0.0",
+ "branch": "v1.0.1",
"plugin": "archivesspace",
"packages": ["sitectl", "sitectl-archivesspace"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.0.0",
- "sitectl-archivesspace": "v1.0.0"
+ "sitectl": "v1.9.1",
+ "sitectl-archivesspace": "v2.1.1"
}
},
"ojs": {
"repo": "https://github.com/libops/ojs.git",
- "branch": "v1.0.0",
+ "branch": "v1.2.1",
"plugin": "ojs",
"packages": ["sitectl", "sitectl-ojs"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.0.0",
- "sitectl-ojs": "v1.0.0"
+ "sitectl": "v1.9.1",
+ "sitectl-ojs": "v1.4.0"
}
},
"isle": {
"repo": "https://github.com/libops/isle",
- "branch": "v1.1.0",
+ "branch": "v1.3.1",
"plugin": "isle",
"packages": ["sitectl", "sitectl-drupal", "sitectl-isle"],
"extra_env": {
"ISLANDORA_TAG": "6.3.19"
},
"package_versions": {
- "sitectl": "v1.0.0",
- "sitectl-drupal": "v1.0.0",
- "sitectl-isle": "v1.0.0"
+ "sitectl": "v1.9.1",
+ "sitectl-drupal": "v1.5.0",
+ "sitectl-isle": "v1.6.0"
}
},
"drupal": {
"repo": "https://github.com/libops/drupal.git",
- "branch": "v1.0.0",
+ "branch": "v1.2.1",
"plugin": "drupal",
"packages": ["sitectl", "sitectl-drupal"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.0.0",
- "sitectl-drupal": "v1.0.0"
+ "sitectl": "v1.9.1",
+ "sitectl-drupal": "v1.5.0"
}
},
"wp": {
"repo": "https://github.com/libops/wp.git",
- "branch": "v1.0.0",
+ "branch": "v1.1.1",
"plugin": "wp",
"packages": ["sitectl", "sitectl-wp"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.0.0",
- "sitectl-wp": "v1.0.0"
+ "sitectl": "v1.9.1",
+ "sitectl-wp": "v2.1.0"
}
},
"omeka-s": {
"repo": "https://github.com/libops/omeka-s.git",
- "branch": "v1.0.0",
+ "branch": "v1.2.1",
"plugin": "omeka-s",
"packages": ["sitectl", "sitectl-omeka-s"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.0.0",
- "sitectl-omeka-s": "v1.0.0"
+ "sitectl": "v1.9.1",
+ "sitectl-omeka-s": "v1.4.0"
}
},
"omeka-classic": {
"repo": "https://github.com/libops/omeka-classic.git",
- "branch": "v1.0.0",
+ "branch": "v1.2.1",
"plugin": "omeka-classic",
"packages": ["sitectl", "sitectl-omeka-classic"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.0.0",
- "sitectl-omeka-classic": "v1.0.0"
+ "sitectl": "v1.9.1",
+ "sitectl-omeka-classic": "v1.4.0"
}
}
}
diff --git a/templates/cloud-init.yml b/templates/cloud-init.yml
index 4559bee..f51bb08 100644
--- a/templates/cloud-init.yml
+++ b/templates/cloud-init.yml
@@ -10,6 +10,9 @@ users:
- docker
sudo:
- ALL=(root) NOPASSWD:/usr/bin/systemctl start cloud-compose.service,/usr/bin/systemctl stop cloud-compose.service,/usr/bin/systemctl restart cloud-compose.service,/usr/bin/systemctl status cloud-compose.service
+ - ALL=(root) NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh state
+ - ALL=(root) NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh status
+ - ALL=(root) NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh dump
%{ if length(CLOUD_COMPOSE_SSH_KEYS) > 0 ~}
ssh_authorized_keys:
%{ for key in CLOUD_COMPOSE_SSH_KEYS ~}
@@ -25,51 +28,37 @@ users:
%{ endfor ~}
%{ endfor ~}
-bootcmd:
-# mount the main Data disk
-- |
- set -eu
- rm -f /run/cloud-compose-filesystems-ready
- filesystem_prep=/run/cloud-compose-prepare-filesystem
- filesystem_persist=/run/cloud-compose-persist-filesystems
- printf '%s' '${FILESYSTEM_PREP_SCRIPT_B64}' | base64 -d >"$filesystem_prep"
- printf '%s' '${FILESYSTEM_PERSIST_SCRIPT_B64}' | base64 -d >"$filesystem_persist"
- chmod 0600 "$filesystem_prep" "$filesystem_persist"
- bash "$filesystem_prep" /dev/disk/by-id/google-data /mnt/disks/data \
- --publish-fresh-marker ${jsonencode(FRESH_FILESYSTEM_IDENTITY)}
-
-# mount the specific Volumes disk
-- bash /run/cloud-compose-prepare-filesystem /dev/disk/by-id/google-docker-volumes /mnt/disks/volumes
-
-- mkdir -p /mnt/disks/data/docker/volumes
-- |
- set -eu
- if ! mountpoint -q /mnt/disks/data/docker/volumes; then
- mount --bind /mnt/disks/volumes /mnt/disks/data/docker/volumes
- fi
- for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do
- if ! mountpoint -q -- "$required_mount"; then
- echo "Required cloud-compose mount is unavailable: $required_mount" >&2
- exit 1
- fi
- done
-%{ if USE_OVERLAY ~}
- mkdir -p /mnt/disks/prod-readonly
- if ! mountpoint -q /mnt/disks/prod-readonly; then
- mount -o ro "$(readlink -f /dev/disk/by-id/google-prod-volumes)" /mnt/disks/prod-readonly
- fi
- bash /run/cloud-compose-persist-filesystems \
- /dev/disk/by-id/google-data \
- /dev/disk/by-id/google-docker-volumes \
- /dev/disk/by-id/google-prod-volumes
-%{ else ~}
- bash /run/cloud-compose-persist-filesystems \
- /dev/disk/by-id/google-data \
- /dev/disk/by-id/google-docker-volumes
-%{ endif ~}
- install -m 0600 /dev/null /run/cloud-compose-filesystems-ready
-
write_files:
+- path: /var/lib/cloud-compose/bootstrap/rootfs-archive.sh
+ owner: root:root
+ permissions: "0700"
+ encoding: gzip+base64
+ content: ${ROOTFS_ARCHIVE_SCRIPT_B64}
+- path: /var/lib/cloud-compose/bootstrap/gcp-cloud-init-finalize.sh
+ owner: root:root
+ permissions: "0700"
+ encoding: gzip+base64
+ content: ${GCP_CLOUD_INIT_FINALIZE_SCRIPT_B64}
+- path: /var/lib/cloud-compose/bootstrap/gcp-cloud-init-post-bootstrap.sh
+ owner: root:root
+ permissions: "0700"
+ encoding: gzip+base64
+ content: ${GCP_CLOUD_INIT_POST_SCRIPT_B64}
+- path: /var/lib/cloud-compose/bootstrap/cloud-compose-diagnostics.sh
+ owner: root:root
+ permissions: "0600"
+ encoding: gzip+base64
+ content: ${DIAGNOSTICS_SCRIPT_B64}
+- path: /var/lib/cloud-compose/bootstrap/init-commands.sh
+ owner: root:root
+ permissions: "0600"
+ encoding: b64
+ content: ${jsonencode(INIT_COMMANDS_B64)}
+- path: /var/lib/cloud-compose/bootstrap/runcmd.sh
+ owner: root:root
+ permissions: "0600"
+ encoding: b64
+ content: ${jsonencode(RUNCMD_B64)}
${WRITE_FILES_CONTENT}
${DOCKER_COMPOSE_SCRIPTS}
${COMPOSE_PROJECTS_FILE}
@@ -79,31 +68,10 @@ ${VAULT_AGENT_FILES}
${MANAGED_RUNTIME_ARTIFACTS_FILE}
runcmd:
-${ROOTFS_ARCHIVE_COMMAND}
-- |
- set -eu
- test -f /run/cloud-compose-filesystems-ready || {
- echo "Cloud Compose filesystems were not prepared; refusing application initialization" >&2
- exit 1
- }
-%{ for CMD in ADDITIONAL_INITCMD ~}
- ${indent(2, CMD)}
-%{ endfor ~}
- chown root:cloud-compose /mnt/disks/data
- chmod 1775 /mnt/disks/data
- chown cloud-compose:cloud-compose /mnt/disks/volumes
- chmod 0775 /mnt/disks/volumes
- install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops
- rm -f /home/cloud-compose/.cloud-compose-bootstrap-complete
- bash /home/cloud-compose/start-cloud-compose-bootstrap.sh
-%{ if length(ADDITIONAL_RUNCMD) > 0 ~}
-- |
- set -eu
- test -f /home/cloud-compose/.cloud-compose-bootstrap-complete || {
- echo "Cloud Compose application initialization did not complete; refusing post-initialization commands" >&2
- exit 1
- }
-%{ for CMD in ADDITIONAL_RUNCMD ~}
- ${indent(2, CMD)}
-%{ endfor ~}
+%{ if ROOTFS_ARCHIVE_ENABLED ~}
+- [bash, /var/lib/cloud-compose/bootstrap/rootfs-archive.sh, install, ${jsonencode(ROOTFS_ARCHIVE_URL_B64)}, ${jsonencode(ROOTFS_ARCHIVE_SHA256)}, ${jsonencode(ROOTFS_CONTRACT_SHA256)}, ${jsonencode(ROOTFS_OVERLAY_DIR)}]
+%{ endif ~}
+- [bash, /var/lib/cloud-compose/bootstrap/gcp-cloud-init-finalize.sh, /var/lib/cloud-compose/bootstrap/init-commands.sh, ${jsonencode(DIAGNOSTICS_SCRIPT_SHA256)}]
+%{ if POST_BOOTSTRAP_REQUIRED ~}
+- [bash, /var/lib/cloud-compose/bootstrap/gcp-cloud-init-post-bootstrap.sh, ${jsonencode(ROLLOUT_ENABLED)}, /var/lib/cloud-compose/bootstrap/runcmd.sh]
%{ endif ~}
diff --git a/templates/gcp-cloud-init.mime.tftpl b/templates/gcp-cloud-init.mime.tftpl
new file mode 100644
index 0000000..8d3afb5
--- /dev/null
+++ b/templates/gcp-cloud-init.mime.tftpl
@@ -0,0 +1,18 @@
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="===============cloud-compose=="
+
+--===============cloud-compose==
+Content-Type: text/cloud-boothook; charset="us-ascii"
+MIME-Version: 1.0
+Content-Transfer-Encoding: 7bit
+Content-Disposition: attachment; filename="gcp-filesystem-boothook.sh"
+
+${FILESYSTEM_BOOTHOOK}
+--===============cloud-compose==
+Content-Type: text/cloud-config; charset="us-ascii"
+MIME-Version: 1.0
+Content-Transfer-Encoding: 7bit
+Content-Disposition: attachment; filename="cloud-config.yml"
+
+${CLOUD_CONFIG}
+--===============cloud-compose==--
diff --git a/templates/gcp-filesystem-boothook.sh.tftpl b/templates/gcp-filesystem-boothook.sh.tftpl
new file mode 100644
index 0000000..3641a7b
--- /dev/null
+++ b/templates/gcp-filesystem-boothook.sh.tftpl
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+umask 077
+readonly filesystem_boot=/run/cloud-compose-gcp-filesystem-boot
+readonly filesystem_prep=/run/cloud-compose-prepare-filesystem
+readonly filesystem_persist=/run/cloud-compose-persist-filesystems
+readonly filesystem_reconcile=/run/cloud-compose-reconcile-fstab.awk
+
+printf '%s' '${GCP_FILESYSTEM_BOOT_SCRIPT_B64}' | base64 -d | gzip -d >"$filesystem_boot"
+printf '%s' '${FILESYSTEM_PREP_SCRIPT_B64}' | base64 -d | gzip -d >"$filesystem_prep"
+printf '%s' '${FILESYSTEM_PERSIST_SCRIPT_B64}' | base64 -d | gzip -d >"$filesystem_persist"
+printf '%s' '${FSTAB_RECONCILE_AWK_B64}' | base64 -d | gzip -d >"$filesystem_reconcile"
+chmod 0600 "$filesystem_boot" "$filesystem_prep" "$filesystem_persist" "$filesystem_reconcile"
+
+bash "$filesystem_boot" \
+ ${jsonencode(FRESH_FILESYSTEM_IDENTITY)} \
+ ${jsonencode(USE_OVERLAY ? "true" : "false")} \
+ "$filesystem_prep" \
+ "$filesystem_persist" \
+ "$filesystem_reconcile"
diff --git a/tests/config-management/ansible/invalid-disaster-recovery.yml b/tests/config-management/ansible/invalid-disaster-recovery.yml
new file mode 100644
index 0000000..19aac33
--- /dev/null
+++ b/tests/config-management/ansible/invalid-disaster-recovery.yml
@@ -0,0 +1,16 @@
+- name: Reject unsafe disaster-recovery driver settings
+ hosts: localhost
+ gather_facts: true
+ vars:
+ cloud_compose_name: invalid-disaster-recovery
+ cloud_compose_template: wp
+ cloud_compose_dedicated_host_acknowledged: true
+ cloud_compose_install_packages: false
+ cloud_compose_reload_systemd: false
+ cloud_compose_run_bootstrap: false
+ cloud_compose_runtime:
+ disaster_recovery:
+ required: true
+ driver_path: /etc/cloud-compose/libexec/../untrusted
+ roles:
+ - role: cloud_compose
diff --git a/tests/config-management/ansible/smoke.yml b/tests/config-management/ansible/smoke.yml
index 57813e0..c2f33ca 100644
--- a/tests/config-management/ansible/smoke.yml
+++ b/tests/config-management/ansible/smoke.yml
@@ -12,6 +12,9 @@
cloud_compose_internal_services_enabled: true
cloud_compose_internal_services_auto_update: true
cloud_compose_runtime:
+ disaster_recovery:
+ required: false
+ driver_path: /etc/cloud-compose/libexec/ansible-offhost
extra_env:
BASH_ENV: /tmp/cloud-compose-ansible-untrusted-bash-env
LD_PRELOAD: /tmp/cloud-compose-ansible-untrusted-preload.so
@@ -45,9 +48,9 @@
sitectl:
environment: production
package_versions:
- sitectl: v1.0.0
- sitectl-drupal: v1.0.0
- sitectl-isle: v1.0.0
+ sitectl: v1.8.2
+ sitectl-drupal: v1.3.0
+ sitectl-isle: v1.5.0
managed_runtime:
enabled: false
internal_services_enabled: false
@@ -94,6 +97,10 @@
that:
- >-
'CLOUD_COMPOSE_PROVIDER="onprem"' in (cloud_compose_env_file.content | b64decode)
+ - >-
+ 'CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED="false"' in (cloud_compose_env_file.content | b64decode)
+ - >-
+ 'CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="/etc/cloud-compose/libexec/ansible-offhost"' in (cloud_compose_env_file.content | b64decode)
- >-
'DOCKER_COMPOSE_DIR="/mnt/disks/data/libops/isle/isle-prod"' in (cloud_compose_env_file.content | b64decode)
- >-
@@ -117,7 +124,7 @@
- '(cloud_compose_application_env_file.content | b64decode | from_json).PORT == "9999"'
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].docker_compose_repo == "https://github.com/libops/isle"'
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].project_dir == "/mnt/disks/data/libops/isle/isle-prod"'
- - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].compose_project_name == "libops-isle-v1-1-0"'
+ - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].compose_project_name == "libops-isle-v1-3-1"'
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].ingress.domain == "isle.example.edu"'
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].sitectl_plugin == "isle"'
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].init_commands == []'
diff --git a/tests/config-management/salt-pillar/invalid-disaster-recovery.sls b/tests/config-management/salt-pillar/invalid-disaster-recovery.sls
new file mode 100644
index 0000000..9454410
--- /dev/null
+++ b/tests/config-management/salt-pillar/invalid-disaster-recovery.sls
@@ -0,0 +1,12 @@
+cloud_compose:
+ name: invalid-disaster-recovery
+ provider: onprem
+ template: wp
+ dedicated_host_acknowledged: true
+ install_packages: false
+ reload_systemd: false
+ run_bootstrap: false
+ runtime:
+ disaster_recovery:
+ required: true
+ driver_path: /etc/cloud-compose/libexec/../untrusted
diff --git a/tests/config-management/salt-pillar/top.sls b/tests/config-management/salt-pillar/top.sls
index e652068..72b983b 100644
--- a/tests/config-management/salt-pillar/top.sls
+++ b/tests/config-management/salt-pillar/top.sls
@@ -29,6 +29,8 @@ base:
- invalid-template
'invalid-vault':
- invalid-vault
+ 'invalid-disaster-recovery':
+ - invalid-disaster-recovery
'invalid-package':
- invalid-package
'invalid-host-ack':
diff --git a/tests/config-management/salt-pillar/wp-prod.sls b/tests/config-management/salt-pillar/wp-prod.sls
index cc2526e..e02485e 100644
--- a/tests/config-management/salt-pillar/wp-prod.sls
+++ b/tests/config-management/salt-pillar/wp-prod.sls
@@ -10,6 +10,9 @@ cloud_compose:
internal_services_auto_update: true
template: wp
runtime:
+ disaster_recovery:
+ required: false
+ driver_path: /etc/cloud-compose/libexec/salt-offhost
extra_env:
BASH_ENV: /tmp/cloud-compose-salt-untrusted-bash-env
LD_PRELOAD: /tmp/cloud-compose-salt-untrusted-preload.so
@@ -43,8 +46,8 @@ cloud_compose:
sitectl:
environment: production
package_versions:
- sitectl: v1.0.0
- sitectl-wp: v1.0.0
+ sitectl: v1.8.2
+ sitectl-wp: v2.0.0
managed_runtime:
enabled: false
internal_services_enabled: false
diff --git a/tests/smoke/do/main.tf b/tests/smoke/do/main.tf
index 852e030..00a659f 100644
--- a/tests/smoke/do/main.tf
+++ b/tests/smoke/do/main.tf
@@ -14,23 +14,25 @@ provider "digitalocean" {}
module "context" {
source = "../modules/context"
- cloud_provider = "digitalocean"
- template = var.template
- ssh_public_key = var.ssh_public_key
- operator_ssh_public_keys = var.operator_ssh_public_keys
- smoke_run_id = var.smoke_run_id
- docker_compose_branch = var.docker_compose_branch
- ingress_port = var.ingress_port
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz"
- rootfs_archive_sha256 = var.cloud_compose_source_sha256
- tags = var.tags
+ cloud_provider = "digitalocean"
+ template = var.template
+ ssh_public_key = var.ssh_public_key
+ operator_ssh_public_keys = var.operator_ssh_public_keys
+ smoke_run_id = var.smoke_run_id
+ docker_compose_branch = var.docker_compose_branch
+ ingress_port = var.ingress_port
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz"
+ rootfs_archive_sha256 = var.cloud_compose_source_sha256
+ rootfs_test_source_archive_prefix = "cloud-compose-${var.cloud_compose_source_ref}"
+ tags = var.tags
}
module "app" {
- source = "../../../providers/do"
+ # Hosted smoke alone needs the exact-commit source-archive fixture. Keep that
+ # test-only escape hatch below the public provider entrypoint.
+ source = "../../../modules/digitalocean"
- name = module.context.name
- template = module.context.template
+ name = module.context.name
digitalocean = {
region = var.digitalocean_region
tags = module.context.tags
@@ -58,7 +60,7 @@ module "smoke" {
cloud_provider = "digitalocean"
template = module.context.template
name = module.context.name
- host = module.app.external_ip
+ host = module.app.instance.ipv4
primary_compose_project = module.app.primary_compose_project
}
diff --git a/tests/smoke/do/variables.tf b/tests/smoke/do/variables.tf
index 80a89d4..600811a 100644
--- a/tests/smoke/do/variables.tf
+++ b/tests/smoke/do/variables.tf
@@ -83,8 +83,12 @@ variable "tags" {
variable "cloud_compose_source_ref" {
type = string
- default = "main"
- description = "cloud-compose Git ref whose rootfs is fetched to keep DigitalOcean user_data below 64 KiB."
+ description = "Exact lowercase cloud-compose commit whose source archive is fetched to keep DigitalOcean user_data below 64 KiB."
+
+ validation {
+ condition = can(regex("^[0-9a-f]{40}$", var.cloud_compose_source_ref))
+ error_message = "cloud_compose_source_ref must be an exact lowercase 40-character commit SHA."
+ }
}
variable "cloud_compose_source_sha256" {
diff --git a/tests/smoke/gcp-upgrade/main.tf b/tests/smoke/gcp-upgrade/main.tf
index 980bb8c..6ab7c42 100644
--- a/tests/smoke/gcp-upgrade/main.tf
+++ b/tests/smoke/gcp-upgrade/main.tf
@@ -79,9 +79,7 @@ module "app" {
# timer can suspend the disposable host during a long bootstrap, so
# disabling it in the later runcmd phase is too late.
initcmd = [
- "systemctl disable --now internal-services.timer internal-services.service 2>/dev/null || true",
- "systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service 2>/dev/null || true",
- "bash -ceu 'project=${local.wordpress_project_dir}; git_project() { git -c safe.directory=\"$project\" -C \"$project\" \"$@\"; }; install -d -m 0755 \"$project\"; if [[ ! -d \"$project/.git\" ]]; then git_project init; git_project remote add origin https://github.com/libops/wp.git; fi; git_project remote set-url origin https://github.com/libops/wp.git; git_project fetch --force --no-tags --depth=1 origin ${var.wordpress_compose_ref}; git_project checkout --detach ${var.wordpress_compose_ref}; test \"$(git_project rev-parse HEAD)\" = ${var.wordpress_compose_ref}; chown -R cloud-compose:cloud-compose \"$project\"'",
+ "bash /home/cloud-compose/gcp-upgrade-prepare-repository.sh",
]
}
power_management = {
@@ -91,6 +89,7 @@ module "app" {
}
}
runtime = {
+ rootfs = "${path.module}/rootfs"
users = {
cloud-compose = [var.ssh_public_key]
}
@@ -112,8 +111,7 @@ module "app" {
}
}
up = [
- "sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" up -d --remove-orphans",
- "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist",
+ "/etc/cloud-compose/lifecycle.d/gcp-upgrade-up.sh",
]
}
sitectl = {
diff --git a/tests/smoke/gcp-upgrade/rootfs/etc/cloud-compose/lifecycle.d/gcp-upgrade-up.sh b/tests/smoke/gcp-upgrade/rootfs/etc/cloud-compose/lifecycle.d/gcp-upgrade-up.sh
new file mode 100755
index 0000000..8cc1988
--- /dev/null
+++ b/tests/smoke/gcp-upgrade/rootfs/etc/cloud-compose/lifecycle.d/gcp-upgrade-up.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+context="${SITECTL_CONTEXT_NAME:?SITECTL_CONTEXT_NAME is required}"
+sitectl compose --context "$context" up -d --remove-orphans
+sitectl healthcheck --context "$context" --persist
diff --git a/tests/smoke/gcp-upgrade/rootfs/home/cloud-compose/gcp-upgrade-prepare-repository.sh b/tests/smoke/gcp-upgrade/rootfs/home/cloud-compose/gcp-upgrade-prepare-repository.sh
new file mode 100755
index 0000000..11cc2d3
--- /dev/null
+++ b/tests/smoke/gcp-upgrade/rootfs/home/cloud-compose/gcp-upgrade-prepare-repository.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+
+project="${DOCKER_COMPOSE_DIR:?DOCKER_COMPOSE_DIR is required}"
+repository="${DOCKER_COMPOSE_REPO:?DOCKER_COMPOSE_REPO is required}"
+revision="${DOCKER_COMPOSE_BRANCH:?DOCKER_COMPOSE_BRANCH is required}"
+
+[[ "$repository" == "https://github.com/libops/wp.git" ]] || {
+ echo "Unexpected GCP upgrade fixture repository: $repository" >&2
+ exit 1
+}
+[[ "$revision" =~ ^[0-9a-f]{40}$ ]] || {
+ echo "GCP upgrade fixture revision must be an exact commit" >&2
+ exit 1
+}
+[[ "$project" == "/mnt/disks/data/libops/wp.git/${revision}" ]] || {
+ echo "Unexpected GCP upgrade fixture project path: $project" >&2
+ exit 1
+}
+
+systemctl disable --now internal-services.timer internal-services.service 2>/dev/null || true
+systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service 2>/dev/null || true
+
+git_project() {
+ git -c safe.directory="$project" -C "$project" "$@"
+}
+
+install -d -m 0755 "$project"
+if [[ ! -d "$project/.git" ]]; then
+ git_project init
+ git_project remote add origin "$repository"
+fi
+git_project remote set-url origin "$repository"
+git_project fetch --force --no-tags --depth=1 origin "$revision"
+git_project checkout --detach "$revision"
+[[ "$(git_project rev-parse HEAD)" == "$revision" ]] || {
+ echo "GCP upgrade fixture checkout did not reach $revision" >&2
+ exit 1
+}
+chown -R cloud-compose:cloud-compose "$project"
diff --git a/tests/smoke/linode/main.tf b/tests/smoke/linode/main.tf
index 7fa37ca..d985c33 100644
--- a/tests/smoke/linode/main.tf
+++ b/tests/smoke/linode/main.tf
@@ -14,23 +14,25 @@ provider "linode" {}
module "context" {
source = "../modules/context"
- cloud_provider = "linode"
- template = var.template
- ssh_public_key = var.ssh_public_key
- operator_ssh_public_keys = var.operator_ssh_public_keys
- smoke_run_id = var.smoke_run_id
- docker_compose_branch = var.docker_compose_branch
- ingress_port = var.ingress_port
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz"
- rootfs_archive_sha256 = var.cloud_compose_source_sha256
- tags = var.tags
+ cloud_provider = "linode"
+ template = var.template
+ ssh_public_key = var.ssh_public_key
+ operator_ssh_public_keys = var.operator_ssh_public_keys
+ smoke_run_id = var.smoke_run_id
+ docker_compose_branch = var.docker_compose_branch
+ ingress_port = var.ingress_port
+ rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz"
+ rootfs_archive_sha256 = var.cloud_compose_source_sha256
+ rootfs_test_source_archive_prefix = "cloud-compose-${var.cloud_compose_source_ref}"
+ tags = var.tags
}
module "app" {
- source = "../../../providers/linode"
+ # Hosted smoke alone needs the exact-commit source-archive fixture. Keep that
+ # test-only escape hatch below the public provider entrypoint.
+ source = "../../../modules/linode"
- name = module.context.name
- template = module.context.template
+ name = module.context.name
linode = {
region = var.linode_region
tags = module.context.tags
@@ -56,7 +58,7 @@ module "smoke" {
cloud_provider = "linode"
template = module.context.template
name = module.context.name
- host = module.app.external_ip
+ host = module.app.instance.public_ipv4
primary_compose_project = module.app.primary_compose_project
}
diff --git a/tests/smoke/linode/variables.tf b/tests/smoke/linode/variables.tf
index aac5b0b..17bb339 100644
--- a/tests/smoke/linode/variables.tf
+++ b/tests/smoke/linode/variables.tf
@@ -83,8 +83,12 @@ variable "tags" {
variable "cloud_compose_source_ref" {
type = string
- default = "main"
- description = "cloud-compose Git ref whose rootfs is fetched by providers with metadata size limits."
+ description = "Exact lowercase cloud-compose commit whose source archive is fetched by providers with metadata size limits."
+
+ validation {
+ condition = can(regex("^[0-9a-f]{40}$", var.cloud_compose_source_ref))
+ error_message = "cloud_compose_source_ref must be an exact lowercase 40-character commit SHA."
+ }
}
variable "cloud_compose_source_sha256" {
diff --git a/tests/smoke/modules/context/main.tf b/tests/smoke/modules/context/main.tf
index b0646ee..359ba3e 100644
--- a/tests/smoke/modules/context/main.tf
+++ b/tests/smoke/modules/context/main.tf
@@ -29,6 +29,11 @@ locals {
cloud_provider = lower(trimspace(var.cloud_provider))
template = lower(trimspace(var.template))
+ app_registry = jsondecode(file("${path.module}/../../../../templates/apps.json"))
+ app_templates = local.app_registry.templates
+ empty_template = local.app_registry.default
+ app_template = try(local.app_templates[local.template], local.empty_template)
+
provider_prefixes = {
digitalocean = "do"
gcp = "g"
@@ -55,18 +60,22 @@ locals {
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))
runtime_base = {
- rootfs_archive_url = var.rootfs_archive_url
- rootfs_archive_sha256 = var.rootfs_archive_sha256
+ rootfs_archive_url = var.rootfs_archive_url
+ rootfs_archive_sha256 = var.rootfs_archive_sha256
+ rootfs_test_source_archive_prefix = var.rootfs_test_source_archive_prefix
compose = {
+ repo = local.app_template.repo
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"
+ "/home/cloud-compose/default-lifecycle.sh up"
]
}
sitectl = {
- environment = "smoke"
+ packages = local.app_template.packages
+ package_versions = local.app_template.package_versions
+ plugin = local.app_template.plugin
+ environment = "smoke"
}
managed_runtime = {
enabled = true
@@ -76,6 +85,7 @@ locals {
vault = {
auth_method = "consumer-managed"
}
+ extra_env = local.app_template.extra_env
}
gcp_runtime = merge(local.runtime_base, {
diff --git a/tests/smoke/modules/context/outputs.tf b/tests/smoke/modules/context/outputs.tf
index 6539a63..dbec719 100644
--- a/tests/smoke/modules/context/outputs.tf
+++ b/tests/smoke/modules/context/outputs.tf
@@ -5,7 +5,7 @@ output "name" {
output "runtime" {
value = local.runtime_base
- description = "Provider-neutral runtime overrides for smoke tests."
+ description = "Provider-neutral runtime with template defaults and private fixture inputs resolved for hosted smoke tests."
}
output "gcp_runtime" {
diff --git a/tests/smoke/modules/context/variables.tf b/tests/smoke/modules/context/variables.tf
index 09d1937..bd01af5 100644
--- a/tests/smoke/modules/context/variables.tf
+++ b/tests/smoke/modules/context/variables.tf
@@ -75,6 +75,20 @@ variable "rootfs_archive_sha256" {
description = "SHA-256 for rootfs_archive_url."
}
+variable "rootfs_test_source_archive_prefix" {
+ type = string
+ default = ""
+ description = "Explicit source-archive prefix used only by hosted smoke tests for an unreleased exact commit."
+
+ validation {
+ condition = (
+ trimspace(var.rootfs_test_source_archive_prefix) == "" ||
+ can(regex("^cloud-compose-[0-9a-f]{40}$", trimspace(var.rootfs_test_source_archive_prefix)))
+ )
+ error_message = "rootfs_test_source_archive_prefix must be empty or identify one exact lowercase 40-character commit SHA."
+ }
+}
+
variable "tags" {
type = list(string)
default = ["cloud-compose"]
diff --git a/variables.tf b/variables.tf
index 9e1c074..5809f27 100644
--- a/variables.tf
+++ b/variables.tf
@@ -210,6 +210,11 @@ variable "runtime" {
rootfs_archive_sha256 = optional(string, "")
users = optional(map(list(string)), {})
+ disaster_recovery = optional(object({
+ required = optional(bool, false)
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
+ }), {})
+
compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
@@ -319,6 +324,15 @@ variable "runtime" {
error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together."
}
+ validation {
+ condition = (
+ can(regex("^/[A-Za-z0-9._/+:-]+$", var.runtime.disaster_recovery.driver_path)) &&
+ !strcontains(var.runtime.disaster_recovery.driver_path, "//") &&
+ length(regexall("(^|/)\\.\\.?(/|$)", var.runtime.disaster_recovery.driver_path)) == 0
+ )
+ error_message = "runtime.disaster_recovery.driver_path must be a safe absolute path without whitespace or dot segments."
+ }
+
validation {
condition = alltrue([
for name in keys(var.runtime.extra_env) :