From e3b313a47da4899a54115a010c8b853a06546819 Mon Sep 17 00:00:00 2001 From: libops-agent <115990865+libops-agent@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:56:00 +0000 Subject: [PATCH 01/24] [minor] Add off-host backup and restore proof --- Makefile | 9 +- README.md | 10 +- ansible/README.md | 15 ++ ansible/roles/cloud_compose/tasks/main.yml | 13 +- ci/config-management-input-contract.sh | 7 + ci/config-management-smoke-inner.sh | 7 + ci/disaster-recovery-contract.sh | 237 +++++++++++++++++ ci/systemd-contract.sh | 18 ++ docs/disaster-recovery.md | 177 +++++++++++++ docs/index.md | 7 +- docs/mkdocs.yml | 1 + docs/non-gcp-providers.md | 15 +- docs/runtime-contracts.md | 32 ++- .../contracttest/disaster_recovery_test.go | 96 +++++++ main.tf | 16 +- modules/digitalocean/main.tf | 56 ++-- modules/digitalocean/variables.tf | 14 + modules/gcp/main.tf | 84 +++--- modules/gcp/variables.tf | 21 ++ modules/linode/main.tf | 56 ++-- modules/linode/variables.tf | 14 + .../disaster_recovery.tftest.hcl | 48 ++++ modules/linux-vm-runtime/main.tf | 100 +++---- modules/linux-vm-runtime/variables.tf | 21 ++ providers/do/variables.tf | 14 + providers/gcp/main.tf | 16 +- providers/gcp/variables.tf | 14 + providers/linode/variables.tf | 14 + .../system/cloud-compose-mariadb-backup.timer | 4 +- .../cloud-compose-offhost-backup.service | 17 ++ .../system/cloud-compose-restore-test.service | 16 ++ .../system/cloud-compose-restore-test.timer | 13 + rootfs/etc/tmpfiles.d/cloud-compose.conf | 1 + .../cloud-compose/disaster-recovery-lib.sh | 211 +++++++++++++++ rootfs/home/cloud-compose/offhost-backup.sh | 249 ++++++++++++++++++ rootfs/home/cloud-compose/restore-test.sh | 83 ++++++ rootfs/home/cloud-compose/run.sh | 5 + runtime_contracts.tftest.hcl | 50 ++++ salt/cloud-compose/README.md | 16 ++ salt/cloud-compose/init.sls | 14 +- .../ansible/invalid-disaster-recovery.yml | 16 ++ tests/config-management/ansible/smoke.yml | 7 + .../salt-pillar/invalid-disaster-recovery.sls | 12 + tests/config-management/salt-pillar/top.sls | 2 + .../config-management/salt-pillar/wp-prod.sls | 3 + variables.tf | 14 + 46 files changed, 1680 insertions(+), 185 deletions(-) create mode 100644 ci/disaster-recovery-contract.sh create mode 100644 docs/disaster-recovery.md create mode 100644 internal/contracttest/disaster_recovery_test.go create mode 100644 modules/linux-vm-runtime/disaster_recovery.tftest.hcl create mode 100644 rootfs/etc/systemd/system/cloud-compose-offhost-backup.service create mode 100644 rootfs/etc/systemd/system/cloud-compose-restore-test.service create mode 100644 rootfs/etc/systemd/system/cloud-compose-restore-test.timer create mode 100644 rootfs/home/cloud-compose/disaster-recovery-lib.sh create mode 100644 rootfs/home/cloud-compose/offhost-backup.sh create mode 100644 rootfs/home/cloud-compose/restore-test.sh create mode 100644 tests/config-management/ansible/invalid-disaster-recovery.yml create mode 100644 tests/config-management/salt-pillar/invalid-disaster-recovery.sls diff --git a/Makefile b/Makefile index cb7e0e5..ed0b7fb 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 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 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 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 diff --git a/README.md b/README.md index 6e8962a..9fd235c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,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 +76,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, "/usr/local/libexec/cloud-compose/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..665539c 100644
--- a/ansible/README.md
+++ b/ansible/README.md
@@ -62,6 +62,21 @@ 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.
+The provider-neutral disaster-recovery interface is shared with Terraform:
+
+```yaml
+cloud_compose_runtime:
+ disaster_recovery:
+ required: true
+ driver_path: /usr/local/libexec/cloud-compose/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
diff --git a/ansible/roles/cloud_compose/tasks/main.yml b/ansible/roles/cloud_compose/tasks/main.yml
index 7057042..1dd1912 100644
--- a/ansible/roles/cloud_compose/tasks/main.yml
+++ b/ansible/roles/cloud_compose/tasks/main.yml
@@ -55,6 +55,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 +66,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 +81,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('/usr/local/libexec/cloud-compose/offhost-backup-driver')) is string
+ - (_cc_disaster_recovery.driver_path | default('/usr/local/libexec/cloud-compose/offhost-backup-driver')) is match('^/[A-Za-z0-9._/+:-]+$')
+ - "'//' not in (_cc_disaster_recovery.driver_path | default('/usr/local/libexec/cloud-compose/offhost-backup-driver'))"
+ - (_cc_disaster_recovery.driver_path | default('/usr/local/libexec/cloud-compose/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 +143,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('/usr/local/libexec/cloud-compose/offhost-backup-driver') }}"
- name: Reject GCP-only internal services on an on-prem host
ansible.builtin.assert:
@@ -398,6 +407,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 }}"
diff --git a/ci/config-management-input-contract.sh b/ci/config-management-input-contract.sh
index 8b3283b..7781958 100755
--- a/ci/config-management-input-contract.sh
+++ b/ci/config-management-input-contract.sh
@@ -226,6 +226,9 @@ for marker in (
"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}")
@@ -247,6 +250,9 @@ for marker in (
"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:
@@ -359,6 +365,7 @@ adapter_fixture_markers = {
"invalid-lifecycle": "- 42",
"invalid-project-lifecycle": "docker_compose_up: not-a-list",
"invalid-internal-services": "internal_services_enabled: true",
+ "invalid-disaster-recovery": "driver_path: /usr/local/libexec/cloud-compose/../untrusted",
}
for fixture_name, marker in adapter_fixture_markers.items():
for relative_path in (
diff --git a/ci/config-management-smoke-inner.sh b/ci/config-management-smoke-inner.sh
index 611e5db..00ce871 100755
--- a/ci/config-management-smoke-inner.sh
+++ b/ci/config-management-smoke-inner.sh
@@ -141,6 +141,7 @@ ansible_invalid_cases=(
invalid-primary
invalid-template
invalid-vault
+ invalid-disaster-recovery
invalid-package
invalid-host-ack
invalid-project-dir-root
@@ -206,6 +207,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'
@@ -257,6 +259,8 @@ projects = json.loads(Path("/home/cloud-compose/compose-projects.json").read_tex
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"] == "/usr/local/libexec/cloud-compose/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"
@@ -447,6 +451,8 @@ 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"] == "/usr/local/libexec/cloud-compose/salt-offhost"
assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == {
"sitectl": "v1.0.0",
"sitectl-wp": "v1.0.0",
@@ -581,6 +587,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/disaster-recovery-contract.sh b/ci/disaster-recovery-contract.sh
new file mode 100644
index 0000000..bfdfa23
--- /dev/null
+++ b/ci/disaster-recovery-contract.sh
@@ -0,0 +1,237 @@
+#!/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"
+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"
+
+cat >"$tmp/profile.sh" <<'EOF'
+#!/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:?}"
+}
+EOF
+
+cat >"$tmp/compose-apps.sh" <<'EOF'
+#!/usr/bin/env bash
+compose_app_names_array() {
+ local -n result="$1"
+ 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/"* ]]
+}
+EOF
+
+cat >"$tmp/bin/stat" <<'EOF'
+#!/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
+EOF
+
+cat >"$tmp/bin/install" <<'EOF'
+#!/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[@]}"
+EOF
+
+cat >"$tmp/bin/docker" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+[[ "$*" == "compose config --format json" ]]
+jq -cn --arg root "${TEST_DATA_ROOT:?}" '{
+ 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"}}
+}'
+EOF
+
+cat >"$tmp/drivers/good-driver" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+[[ -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]}" '{
+ 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}
+ }' >"${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]}" '{
+ 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}
+ }' >"${args[--proof]}"
+ ;;
+ *) exit 64 ;;
+esac
+EOF
+
+cat >"$tmp/drivers/incomplete-driver" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+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]}" '{
+ 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}
+ }' >"${args[--receipt]}"
+EOF
+
+chmod 0755 "$tmp/bin/stat" "$tmp/bin/install" "$tmp/bin/docker" "$tmp/drivers/good-driver" "$tmp/drivers/incomplete-driver"
+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_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 '
+ .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)
+' "$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" | awk '{print $1}')"
+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
+[[ "$(sha256sum "$receipt" | awk '{print $1}')" == "$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 '
+ .disposable_recovery == true and
+ .recovery_destroyed == true and
+ .integrity_verified == true and
+ .coverage == {database: true, application_files: true, volume_topology: true}
+' "$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/systemd-contract.sh b/ci/systemd-contract.sh
index 07e38a4..dc00466 100755
--- a/ci/systemd-contract.sh
+++ b/ci/systemd-contract.sh
@@ -27,6 +27,9 @@ assert_contains() {
[[ -f "$unit_dir/cloud-compose-bootstrap.service" ]] || fail "retryable bootstrap unit is missing"
[[ -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'
@@ -50,6 +53,18 @@ assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'LogRateLimitBurst=1
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-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
@@ -98,6 +113,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/docs/disaster-recovery.md b/docs/disaster-recovery.md
new file mode 100644
index 0000000..5ddcf3f
--- /dev/null
+++ b/docs/disaster-recovery.md
@@ -0,0 +1,177 @@
+# 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.
+Then enable the provider-neutral runtime input:
+
+```hcl
+runtime = {
+ disaster_recovery = {
+ required = true
+ driver_path = "/usr/local/libexec/cloud-compose/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/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/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..f22bce6 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:
diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md
index a94d779..52c1791 100644
--- a/docs/runtime-contracts.md
+++ b/docs/runtime-contracts.md
@@ -661,9 +661,10 @@ a pinned checksum intentionally fail as soon as the branch content changes.
## 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 +685,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
diff --git a/internal/contracttest/disaster_recovery_test.go b/internal/contracttest/disaster_recovery_test.go
new file mode 100644
index 0000000..81d65de
--- /dev/null
+++ b/internal/contracttest/disaster_recovery_test.go
@@ -0,0 +1,96 @@
+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, "/usr/local/libexec/cloud-compose/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")
+
+ 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, library, 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, backup, 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/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..bf78b55 100644
--- a/modules/digitalocean/main.tf
+++ b/modules/digitalocean/main.tf
@@ -13,33 +13,35 @@ 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
+ 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/variables.tf b/modules/digitalocean/variables.tf
index 9f65b80..eef7bb9 100644
--- a/modules/digitalocean/variables.tf
+++ b/modules/digitalocean/variables.tf
@@ -76,6 +76,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, "/usr/local/libexec/cloud-compose/offhost-backup-driver")
+ }), {})
+
compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
@@ -184,6 +189,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..e7b03ba 100644
--- a/modules/gcp/main.tf
+++ b/modules/gcp/main.tf
@@ -362,47 +362,49 @@ fresh_filesystem_env = {
CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY = "v1:gcp-disk-id:${google_compute_disk.data.disk_id}"
}
host_env = merge({
- HOME = "/home/cloud-compose"
- GCP_PROJECT = var.project_id
- GCP_PROJECT_NUMBER = local.project_number
- GCP_INSTANCE_NAME = var.name
- CLOUD_COMPOSE_INSTANCE_NAME = var.name
- GCP_REGION = var.region
- GCP_ZONE = var.zone
- CLOUD_COMPOSE_PROVIDER = "gcp"
- CLOUD_COMPOSE_APPS = join(" ", keys(local.compose_projects))
- CLOUD_COMPOSE_PRIMARY_APP = local.primary_compose_project_key
- COMPOSE_PROJECTS_FILE = "/home/cloud-compose/compose-projects.json"
- COMPOSE_PROJECT_NAME = local.primary_compose_project.compose_project_name
- COMPOSE_BIND_PORT = tostring(local.primary_compose_project.ingress_port)
- DOCKER_COMPOSE_DIR = local.primary_compose_project.project_dir
- DOCKER_COMPOSE_REPO = local.primary_compose_project.docker_compose_repo
- DOCKER_COMPOSE_BRANCH = local.primary_compose_project.docker_compose_branch
- DOCKER_COMPOSE_VERSION = var.docker_compose_version
- DOCKER_BUILDX_VERSION = var.docker_buildx_version
- DOCKER_VOLUME_OVERLAYS = join(" ", var.volume_names)
- SITECTL_PACKAGES = join(" ", module.sitectl_runtime.packages)
- SITECTL_VERSION = var.sitectl_version
- SITECTL_PACKAGE_VERSIONS = jsonencode(module.sitectl_runtime.package_versions)
- SITECTL_CONTEXT_NAME = local.primary_compose_project.sitectl_context_name
- SITECTL_PLUGIN = local.primary_compose_project.sitectl_plugin
- SITECTL_ENVIRONMENT = local.primary_compose_project.sitectl_environment
- PRODUCTION = tostring(var.production)
- SITECTL_VERIFY_ARGS = join(" ", local.primary_compose_project.sitectl_verify_args)
- GCP_APP_SERVICE_ACCOUNT_EMAIL = local.app_service_account_email
- GCP_APP_SERVICE_ACCOUNT_MANAGED = tostring(local.app_service_account_managed)
- GCP_APP_CREDENTIALS_ENABLED = tostring(local.app_credentials_enabled)
- POWER_MANAGEMENT_ENABLED = tostring(var.power_management_enabled)
- COMPOSE_PROFILES = local.internal_services_compose_profiles
- VAULT_ADDR = trimspace(var.vault_addr)
- VAULT_NAMESPACE = trimspace(var.vault_namespace)
- VAULT_ROLE = trimspace(var.vault_role)
- VAULT_AGENT_ENABLED = var.vault_agent_enabled && trimspace(var.vault_addr) != "" ? "true" : "false"
- VAULT_AUTH_METHOD = var.vault_auth_method
- VAULT_AGENT_TOKEN_PATH = var.vault_agent_token_path
- LIBOPS_MANAGED_RUNTIME_ENABLED = tostring(var.libops_managed_runtime_enabled)
- LIBOPS_INTERNAL_SERVICES_ENABLED = tostring(local.internal_services_enabled)
- LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE = tostring(local.internal_services_enabled && var.libops_internal_services_auto_update)
+ HOME = "/home/cloud-compose"
+ GCP_PROJECT = var.project_id
+ GCP_PROJECT_NUMBER = local.project_number
+ GCP_INSTANCE_NAME = var.name
+ CLOUD_COMPOSE_INSTANCE_NAME = var.name
+ GCP_REGION = var.region
+ GCP_ZONE = var.zone
+ CLOUD_COMPOSE_PROVIDER = "gcp"
+ CLOUD_COMPOSE_APPS = join(" ", keys(local.compose_projects))
+ CLOUD_COMPOSE_PRIMARY_APP = local.primary_compose_project_key
+ CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED = tostring(var.offhost_backup_required)
+ CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER = var.offhost_backup_driver_path
+ COMPOSE_PROJECTS_FILE = "/home/cloud-compose/compose-projects.json"
+ COMPOSE_PROJECT_NAME = local.primary_compose_project.compose_project_name
+ COMPOSE_BIND_PORT = tostring(local.primary_compose_project.ingress_port)
+ DOCKER_COMPOSE_DIR = local.primary_compose_project.project_dir
+ DOCKER_COMPOSE_REPO = local.primary_compose_project.docker_compose_repo
+ DOCKER_COMPOSE_BRANCH = local.primary_compose_project.docker_compose_branch
+ DOCKER_COMPOSE_VERSION = var.docker_compose_version
+ DOCKER_BUILDX_VERSION = var.docker_buildx_version
+ DOCKER_VOLUME_OVERLAYS = join(" ", var.volume_names)
+ SITECTL_PACKAGES = join(" ", module.sitectl_runtime.packages)
+ SITECTL_VERSION = var.sitectl_version
+ SITECTL_PACKAGE_VERSIONS = jsonencode(module.sitectl_runtime.package_versions)
+ SITECTL_CONTEXT_NAME = local.primary_compose_project.sitectl_context_name
+ SITECTL_PLUGIN = local.primary_compose_project.sitectl_plugin
+ SITECTL_ENVIRONMENT = local.primary_compose_project.sitectl_environment
+ PRODUCTION = tostring(var.production)
+ SITECTL_VERIFY_ARGS = join(" ", local.primary_compose_project.sitectl_verify_args)
+ GCP_APP_SERVICE_ACCOUNT_EMAIL = local.app_service_account_email
+ GCP_APP_SERVICE_ACCOUNT_MANAGED = tostring(local.app_service_account_managed)
+ GCP_APP_CREDENTIALS_ENABLED = tostring(local.app_credentials_enabled)
+ POWER_MANAGEMENT_ENABLED = tostring(var.power_management_enabled)
+ COMPOSE_PROFILES = local.internal_services_compose_profiles
+ VAULT_ADDR = trimspace(var.vault_addr)
+ VAULT_NAMESPACE = trimspace(var.vault_namespace)
+ VAULT_ROLE = trimspace(var.vault_role)
+ VAULT_AGENT_ENABLED = var.vault_agent_enabled && trimspace(var.vault_addr) != "" ? "true" : "false"
+ VAULT_AUTH_METHOD = var.vault_auth_method
+ VAULT_AGENT_TOKEN_PATH = var.vault_agent_token_path
+ LIBOPS_MANAGED_RUNTIME_ENABLED = tostring(var.libops_managed_runtime_enabled)
+ LIBOPS_INTERNAL_SERVICES_ENABLED = tostring(local.internal_services_enabled)
+ LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE = tostring(local.internal_services_enabled && var.libops_internal_services_auto_update)
}, local.fresh_filesystem_env, local.rollout_env)
env_file_content = <<-EOT
- path: "/home/cloud-compose/.env"
diff --git a/modules/gcp/variables.tf b/modules/gcp/variables.tf
index 9e1b969..e5b9643 100644
--- a/modules/gcp/variables.tf
+++ b/modules/gcp/variables.tf
@@ -608,6 +608,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 = "/usr/local/libexec/cloud-compose/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 = {}
diff --git a/modules/linode/main.tf b/modules/linode/main.tf
index 979dd4e..eed23d8 100644
--- a/modules/linode/main.tf
+++ b/modules/linode/main.tf
@@ -16,33 +16,35 @@ 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
+ 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/variables.tf b/modules/linode/variables.tf
index 6f3dae8..45cec74 100644
--- a/modules/linode/variables.tf
+++ b/modules/linode/variables.tf
@@ -90,6 +90,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, "/usr/local/libexec/cloud-compose/offhost-backup-driver")
+ }), {})
+
compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
@@ -198,6 +203,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..d4a3414
--- /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 = "/usr/local/libexec/cloud-compose/acme-offhost"
+ }
+
+ assert {
+ condition = (
+ local.host_env.CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED == "true" &&
+ local.host_env.CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER == "/usr/local/libexec/cloud-compose/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 = "/usr/local/libexec/cloud-compose/../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..8632ee9 100644
--- a/modules/linux-vm-runtime/main.tf
+++ b/modules/linux-vm-runtime/main.tf
@@ -199,55 +199,57 @@ vault_agent_files_raw = <<-EOT
vault_agent_files = var.vault_agent_enabled && trimspace(var.vault_addr) != "" ? local.vault_agent_files_raw : ""
host_env = {
- HOME = "/home/cloud-compose"
- CLOUD_COMPOSE_PROVIDER = var.provider_name
- CLOUD_COMPOSE_INSTANCE_NAME = var.name
- CLOUD_COMPOSE_APPS = join(" ", keys(local.compose_projects))
- CLOUD_COMPOSE_PRIMARY_APP = local.primary_compose_project_key
- COMPOSE_PROJECTS_FILE = "/home/cloud-compose/compose-projects.json"
- COMPOSE_PROJECT_NAME = local.primary_compose_project.compose_project_name
- COMPOSE_BIND_PORT = tostring(local.primary_compose_project.ingress_port)
- DOCKER_COMPOSE_DIR = local.primary_compose_project.project_dir
- DOCKER_COMPOSE_REPO = local.primary_compose_project.docker_compose_repo
- DOCKER_COMPOSE_BRANCH = local.primary_compose_project.docker_compose_branch
- DOCKER_COMPOSE_VERSION = var.docker_compose_version
- DOCKER_BUILDX_VERSION = var.docker_buildx_version
- GCP_PROJECT = ""
- GCP_PROJECT_NUMBER = ""
- GCP_INSTANCE_NAME = var.name
- GCP_REGION = var.region
- GCP_ZONE = var.zone != "" ? var.zone : var.region
- GCP_APP_SERVICE_ACCOUNT_EMAIL = ""
- GCP_APP_CREDENTIALS_ENABLED = "false"
- SITECTL_PACKAGES = join(" ", module.sitectl_runtime.packages)
- SITECTL_VERSION = var.sitectl_version
- SITECTL_PACKAGE_VERSIONS = jsonencode(module.sitectl_runtime.package_versions)
- SITECTL_CONTEXT_NAME = local.primary_compose_project.sitectl_context_name
- SITECTL_PLUGIN = local.primary_compose_project.sitectl_plugin
- SITECTL_ENVIRONMENT = local.primary_compose_project.sitectl_environment
- SITECTL_VERIFY_ARGS = join(" ", local.primary_compose_project.sitectl_verify_args)
- POWER_MANAGEMENT_ENABLED = "false"
- COMPOSE_PROFILES = ""
- VAULT_ADDR = trimspace(var.vault_addr)
- VAULT_NAMESPACE = trimspace(var.vault_namespace)
- VAULT_ROLE = trimspace(var.vault_role)
- VAULT_AGENT_ENABLED = var.vault_agent_enabled && trimspace(var.vault_addr) != "" ? "true" : "false"
- VAULT_AUTH_METHOD = var.vault_auth_method
- ROLLOUT_ENABLED = tostring(var.rollout_enabled)
- ROLLOUT_DOWNLOAD_URL = trimspace(var.rollout_release_url)
- ROLLOUT_DOWNLOAD_SHA256 = trimspace(var.rollout_release_sha256)
- ROLLOUT_PORT = tostring(var.rollout_port)
- ROLLOUT_JWKS_URI = trimspace(var.rollout_jwks_uri)
- ROLLOUT_JWT_AUD = trimspace(var.rollout_jwt_audience)
- ROLLOUT_CUSTOM_CLAIMS = trimspace(var.rollout_custom_claims)
- ROLLOUT_CMD = "/bin/bash"
- ROLLOUT_ARGS = "/home/cloud-compose/rollout"
- ROLLOUT_LOCK_FILE = "/mnt/disks/data/rollout.lock"
- VAULT_AGENT_TOKEN_PATH = var.vault_agent_token_path
- LIBOPS_MANAGED_RUNTIME_ENABLED = tostring(var.libops_managed_runtime_enabled)
- LIBOPS_INTERNAL_SERVICES_ENABLED = tostring(var.libops_internal_services_enabled)
- LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE = tostring(var.libops_internal_services_auto_update)
- INTERNAL_SERVICES_COMPOSE_PROFILES = ""
+ HOME = "/home/cloud-compose"
+ CLOUD_COMPOSE_PROVIDER = var.provider_name
+ CLOUD_COMPOSE_INSTANCE_NAME = var.name
+ CLOUD_COMPOSE_APPS = join(" ", keys(local.compose_projects))
+ CLOUD_COMPOSE_PRIMARY_APP = local.primary_compose_project_key
+ CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED = tostring(var.offhost_backup_required)
+ CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER = var.offhost_backup_driver_path
+ COMPOSE_PROJECTS_FILE = "/home/cloud-compose/compose-projects.json"
+ COMPOSE_PROJECT_NAME = local.primary_compose_project.compose_project_name
+ COMPOSE_BIND_PORT = tostring(local.primary_compose_project.ingress_port)
+ DOCKER_COMPOSE_DIR = local.primary_compose_project.project_dir
+ DOCKER_COMPOSE_REPO = local.primary_compose_project.docker_compose_repo
+ DOCKER_COMPOSE_BRANCH = local.primary_compose_project.docker_compose_branch
+ DOCKER_COMPOSE_VERSION = var.docker_compose_version
+ DOCKER_BUILDX_VERSION = var.docker_buildx_version
+ GCP_PROJECT = ""
+ GCP_PROJECT_NUMBER = ""
+ GCP_INSTANCE_NAME = var.name
+ GCP_REGION = var.region
+ GCP_ZONE = var.zone != "" ? var.zone : var.region
+ GCP_APP_SERVICE_ACCOUNT_EMAIL = ""
+ GCP_APP_CREDENTIALS_ENABLED = "false"
+ SITECTL_PACKAGES = join(" ", module.sitectl_runtime.packages)
+ SITECTL_VERSION = var.sitectl_version
+ SITECTL_PACKAGE_VERSIONS = jsonencode(module.sitectl_runtime.package_versions)
+ SITECTL_CONTEXT_NAME = local.primary_compose_project.sitectl_context_name
+ SITECTL_PLUGIN = local.primary_compose_project.sitectl_plugin
+ SITECTL_ENVIRONMENT = local.primary_compose_project.sitectl_environment
+ SITECTL_VERIFY_ARGS = join(" ", local.primary_compose_project.sitectl_verify_args)
+ POWER_MANAGEMENT_ENABLED = "false"
+ COMPOSE_PROFILES = ""
+ VAULT_ADDR = trimspace(var.vault_addr)
+ VAULT_NAMESPACE = trimspace(var.vault_namespace)
+ VAULT_ROLE = trimspace(var.vault_role)
+ VAULT_AGENT_ENABLED = var.vault_agent_enabled && trimspace(var.vault_addr) != "" ? "true" : "false"
+ VAULT_AUTH_METHOD = var.vault_auth_method
+ ROLLOUT_ENABLED = tostring(var.rollout_enabled)
+ ROLLOUT_DOWNLOAD_URL = trimspace(var.rollout_release_url)
+ ROLLOUT_DOWNLOAD_SHA256 = trimspace(var.rollout_release_sha256)
+ ROLLOUT_PORT = tostring(var.rollout_port)
+ ROLLOUT_JWKS_URI = trimspace(var.rollout_jwks_uri)
+ ROLLOUT_JWT_AUD = trimspace(var.rollout_jwt_audience)
+ ROLLOUT_CUSTOM_CLAIMS = trimspace(var.rollout_custom_claims)
+ ROLLOUT_CMD = "/bin/bash"
+ ROLLOUT_ARGS = "/home/cloud-compose/rollout"
+ ROLLOUT_LOCK_FILE = "/mnt/disks/data/rollout.lock"
+ VAULT_AGENT_TOKEN_PATH = var.vault_agent_token_path
+ LIBOPS_MANAGED_RUNTIME_ENABLED = tostring(var.libops_managed_runtime_enabled)
+ LIBOPS_INTERNAL_SERVICES_ENABLED = tostring(var.libops_internal_services_enabled)
+ LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE = tostring(var.libops_internal_services_auto_update)
+ INTERNAL_SERVICES_COMPOSE_PROFILES = ""
}
env_file_content = <<-EOT
diff --git a/modules/linux-vm-runtime/variables.tf b/modules/linux-vm-runtime/variables.tf
index 968cf42..2d8d0f9 100644
--- a/modules/linux-vm-runtime/variables.tf
+++ b/modules/linux-vm-runtime/variables.tf
@@ -96,6 +96,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 = "/usr/local/libexec/cloud-compose/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
diff --git a/providers/do/variables.tf b/providers/do/variables.tf
index 571ca4c..aad28f9 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, "/usr/local/libexec/cloud-compose/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/variables.tf b/providers/gcp/variables.tf
index 1bfadee..80a8a4a 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, "/usr/local/libexec/cloud-compose/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/variables.tf b/providers/linode/variables.tf
index 725ce0b..4b29dc0 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, "/usr/local/libexec/cloud-compose/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/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-offhost-backup.service b/rootfs/etc/systemd/system/cloud-compose-offhost-backup.service
new file mode 100644
index 0000000..9ae3aa2
--- /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 /home/cloud-compose/offhost-backup.sh
+TimeoutStartSec=24h
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=cloud-compose-offhost-backup
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..98dd9b1
--- /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 /home/cloud-compose/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/tmpfiles.d/cloud-compose.conf b/rootfs/etc/tmpfiles.d/cloud-compose.conf
index 9c5eb15..596c446 100644
--- a/rootfs/etc/tmpfiles.d/cloud-compose.conf
+++ b/rootfs/etc/tmpfiles.d/cloud-compose.conf
@@ -1,2 +1,3 @@
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/disaster-recovery-lib.sh b/rootfs/home/cloud-compose/disaster-recovery-lib.sh
new file mode 100644
index 0000000..f976e08
--- /dev/null
+++ b/rootfs/home/cloud-compose/disaster-recovery-lib.sh
@@ -0,0 +1,211 @@
+#!/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.
+
+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:-/usr/local/libexec/cloud-compose/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_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" '
+ 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)
+ ' "$path" >/dev/null || {
+ echo "Off-host backup driver returned an invalid or incomplete coverage receipt" >&2
+ return 1
+ }
+ completed_at="$(jq -er '.completed_at' "$path")" || return 1
+ remote_id="$(jq -er '.remote_id' "$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" '
+ 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)
+ ' "$path" >/dev/null || {
+ echo "Off-host backup driver returned an invalid restore-test proof" >&2
+ return 1
+ }
+ completed_at="$(jq -er '.completed_at' "$path")" || return 1
+ recovery_id="$(jq -er '.recovery_id' "$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/offhost-backup.sh b/rootfs/home/cloud-compose/offhost-backup.sh
new file mode 100644
index 0000000..704e622
--- /dev/null
+++ b/rootfs/home/cloud-compose/offhost-backup.sh
@@ -0,0 +1,249 @@
+#!/usr/bin/env bash
+
+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}"
+dr_library_path="${CLOUD_COMPOSE_DR_LIBRARY_PATH:-$script_dir/disaster-recovery-lib.sh}"
+# shellcheck disable=SC1090
+source "$profile_path"
+# shellcheck disable=SC1090
+source "$compose_apps_path"
+# 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="$(sha256sum "$staged_dump" | awk '{print $1}')"
+ dump_bytes="$(wc -c <"$staged_dump")"
+
+ (
+ cd -- "$project_dir"
+ umask 077
+ docker compose config --format json >"$compose_config"
+ )
+ if ! jq -e '
+ 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")
+ ' "$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" '
+ 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))
+ ' "$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 '.services[].volumes[]? | select(.type == "bind") | .source' "$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" '
+ {
+ 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)
+ }
+ }
+ ' "$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}" '
+ {
+ 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))
+ }
+' "$application_rows" >"$staged_manifest"
+chmod 0400 "$staged_manifest"
+
+if ! jq -e --argjson app_count "${#apps[@]}" '
+ .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"))
+' "$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 '.applications[] | [.name, .databases[0].sha256] | @tsv' "$staged_manifest")
+
+manifest_sha256="$(sha256sum "$staged_manifest" | awk '{print $1}')"
+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"
+if [[ "$(sha256sum "$staged_manifest" | awk '{print $1}')" != "$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/restore-test.sh b/rootfs/home/cloud-compose/restore-test.sh
new file mode 100644
index 0000000..e3b5ac1
--- /dev/null
+++ b/rootfs/home/cloud-compose/restore-test.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
+dr_library_path="${CLOUD_COMPOSE_DR_LIBRARY_PATH:-$script_dir/disaster-recovery-lib.sh}"
+# shellcheck disable=SC1090
+source "$profile_path"
+# 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="$(sha256sum "$manifest_path" | awk '{print $1}')"
+cloud_compose_dr_validate_backup_receipt "$receipt_path" "$operation_id" "$manifest_sha256"
+receipt_sha256="$(sha256sum "$receipt_path" | awk '{print $1}')"
+
+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/run.sh b/rootfs/home/cloud-compose/run.sh
index 470a72f..c4d5c11 100644
--- a/rootfs/home/cloud-compose/run.sh
+++ b/rootfs/home/cloud-compose/run.sh
@@ -113,4 +113,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/runtime_contracts.tftest.hcl b/runtime_contracts.tftest.hcl
index 1dfbf9d..7cdcf89 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 = "/usr/local/libexec/cloud-compose/acme-offhost"
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ local.runtime.disaster_recovery.required &&
+ local.runtime.disaster_recovery.driver_path == "/usr/local/libexec/cloud-compose/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..01f4614 100644
--- a/salt/cloud-compose/README.md
+++ b/salt/cloud-compose/README.md
@@ -65,6 +65,22 @@ 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.
+The provider-neutral disaster-recovery interface is shared with Terraform:
+
+```yaml
+cloud_compose:
+ runtime:
+ disaster_recovery:
+ required: true
+ driver_path: /usr/local/libexec/cloud-compose/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
diff --git a/salt/cloud-compose/init.sls b/salt/cloud-compose/init.sls
index a6ac016..6ff0a03 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 %}
@@ -141,6 +143,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', '/usr/local/libexec/cloud-compose/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 %}
@@ -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),
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..896513c
--- /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: /usr/local/libexec/cloud-compose/../untrusted
+ roles:
+ - role: cloud_compose
diff --git a/tests/config-management/ansible/smoke.yml b/tests/config-management/ansible/smoke.yml
index 57813e0..6614299 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: /usr/local/libexec/cloud-compose/ansible-offhost
extra_env:
BASH_ENV: /tmp/cloud-compose-ansible-untrusted-bash-env
LD_PRELOAD: /tmp/cloud-compose-ansible-untrusted-preload.so
@@ -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="/usr/local/libexec/cloud-compose/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)
- >-
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..a9ca01c
--- /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: /usr/local/libexec/cloud-compose/../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..54d794f 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: /usr/local/libexec/cloud-compose/salt-offhost
extra_env:
BASH_ENV: /tmp/cloud-compose-salt-untrusted-bash-env
LD_PRELOAD: /tmp/cloud-compose-salt-untrusted-preload.so
diff --git a/variables.tf b/variables.tf
index 9e1c074..23f2c11 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, "/usr/local/libexec/cloud-compose/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) :
From 2361abf792bbac97dbcc8d7daf9ce78dfd8d6cd4 Mon Sep 17 00:00:00 2001
From: libops-agent <115990865+libops-agent@users.noreply.github.com>
Date: Sat, 8 Aug 2026 12:17:05 +0000
Subject: [PATCH 02/24] [patch] Restore required DigitalOcean ISLE smoke
---
.github/workflows/cloud-smoke-cleanup.yml | 6 ++++++
.github/workflows/cloud-smoke.yml | 3 +++
2 files changed, 9 insertions(+)
diff --git a/.github/workflows/cloud-smoke-cleanup.yml b/.github/workflows/cloud-smoke-cleanup.yml
index 4d62288..6cdb48c 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
diff --git a/.github/workflows/cloud-smoke.yml b/.github/workflows/cloud-smoke.yml
index 26a5cd6..5b38dd8 100644
--- a/.github/workflows/cloud-smoke.yml
+++ b/.github/workflows/cloud-smoke.yml
@@ -126,6 +126,9 @@ jobs:
fail-fast: false
matrix:
include:
+ - name: DigitalOcean ISLE
+ provider: digitalocean
+ template: isle
- name: DigitalOcean WordPress
provider: digitalocean
template: wp
From 67bc19ae5171083ad1db490af46c823a17935916 Mon Sep 17 00:00:00 2001
From: Joe Corall 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)), {})
disaster_recovery = optional(object({
required = optional(bool, false)
driver_path = optional(string, "/usr/local/libexec/cloud-compose/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 |
+| [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 8dc3181..96f9875 100644
--- a/ansible/README.md
+++ b/ansible/README.md
@@ -68,7 +68,7 @@ The provider-neutral disaster-recovery interface is shared with Terraform:
cloud_compose_runtime:
disaster_recovery:
required: true
- driver_path: /usr/local/libexec/cloud-compose/offhost-backup-driver
+ driver_path: /etc/cloud-compose/libexec/offhost-backup-driver
```
Install that executable and its credentials separately as root. The role
diff --git a/ansible/roles/cloud_compose/tasks/main.yml b/ansible/roles/cloud_compose/tasks/main.yml
index 8dcb9e2..0567e84 100644
--- a/ansible/roles/cloud_compose/tasks/main.yml
+++ b/ansible/roles/cloud_compose/tasks/main.yml
@@ -82,10 +82,10 @@
- (_cc_vault.agent_enabled | default(false)) is boolean
- not (_cc_vault.agent_enabled | default(false))
- (_cc_disaster_recovery.required | default(false)) is boolean
- - (_cc_disaster_recovery.driver_path | default('/usr/local/libexec/cloud-compose/offhost-backup-driver')) is string
- - (_cc_disaster_recovery.driver_path | default('/usr/local/libexec/cloud-compose/offhost-backup-driver')) is match('^/[A-Za-z0-9._/+:-]+$')
- - "'//' not in (_cc_disaster_recovery.driver_path | default('/usr/local/libexec/cloud-compose/offhost-backup-driver'))"
- - (_cc_disaster_recovery.driver_path | default('/usr/local/libexec/cloud-compose/offhost-backup-driver') | regex_search('(^|/)\\.\\.?(/|$)')) is none
+ - (_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
@@ -144,7 +144,7 @@
_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('/usr/local/libexec/cloud-compose/offhost-backup-driver') }}"
+ _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:
@@ -514,6 +514,19 @@
dest: /
mode: preserve
+- name: Secure Cloud Compose privileged program directories
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: directory
+ owner: root
+ group: root
+ mode: "0755"
+ loop:
+ - /etc/cloud-compose
+ - /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
@@ -547,7 +560,8 @@
ansible.builtin.find:
paths:
- "{{ cloud_compose_home }}"
- - /usr/local/libexec/cloud-compose
+ - /etc/cloud-compose/bin
+ - /etc/cloud-compose/libexec
patterns: "*.sh"
recurse: false
file_type: file
@@ -561,6 +575,22 @@
mode: "0755"
loop: "{{ cloud_compose_shell_scripts.files }}"
+- 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: Write cloud-compose lifecycle dispatchers
ansible.builtin.copy:
dest: "{{ cloud_compose_home }}/{{ item }}"
@@ -621,7 +651,7 @@
- name: Install and start authenticated rollout service
ansible.builtin.command:
- cmd: bash /usr/local/libexec/cloud-compose/run-root-program.sh 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
@@ -632,7 +662,7 @@
- name: Inspect validated cloud-compose bootstrap readiness
ansible.builtin.command:
- cmd: bash /usr/local/libexec/cloud-compose/require-bootstrap-ready.sh
+ cmd: bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh
register: cloud_compose_bootstrap_readiness
changed_when: false
failed_when: false
@@ -640,7 +670,7 @@
- name: Bootstrap cloud-compose runtime
ansible.builtin.command:
- cmd: bash /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh
+ cmd: bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
async: "{{ cloud_compose_bootstrap_timeout }}"
poll: "{{ cloud_compose_bootstrap_poll_interval }}"
when:
diff --git a/ci/bootstrap-recovery-contract.sh b/ci/bootstrap-recovery-contract.sh
index bc20151..5486cf9 100644
--- a/ci/bootstrap-recovery-contract.sh
+++ b/ci/bootstrap-recovery-contract.sh
@@ -9,11 +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/usr/local/libexec/cloud-compose/bootstrap-security.sh"
-bootstrap_required="$repo_root/rootfs/usr/local/libexec/cloud-compose/bootstrap-required.sh"
-bootstrap_entrypoint="$repo_root/rootfs/usr/local/libexec/cloud-compose/run-bootstrap.sh"
-bootstrap_start_entrypoint="$repo_root/rootfs/usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh"
-bootstrap_readiness_gate="$repo_root/rootfs/usr/local/libexec/cloud-compose/require-bootstrap-ready.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
@@ -277,7 +277,7 @@ 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 /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh'
+ assert_contains "$cloud_init_template" '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"
diff --git a/ci/cloud-smoke.sh b/ci/cloud-smoke.sh
index 77164cc..eb6ac11 100755
--- a/ci/cloud-smoke.sh
+++ b/ci/cloud-smoke.sh
@@ -3,7 +3,7 @@
set -euo pipefail
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
-readonly diagnostics_program="/usr/local/sbin/cloud-compose-diagnostics.sh"
+readonly diagnostics_program="/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"
readonly smoke_healthcheck_program="/home/cloud-compose/smoke-healthcheck.sh"
usage() {
diff --git a/ci/config-management-cloud-smoke-inner.sh b/ci/config-management-cloud-smoke-inner.sh
index d872ff5..4a70bf9 100755
--- a/ci/config-management-cloud-smoke-inner.sh
+++ b/ci/config-management-cloud-smoke-inner.sh
@@ -197,12 +197,12 @@ 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
-test -x /usr/local/libexec/cloud-compose/bootstrap-required.sh
-test -x /usr/local/libexec/cloud-compose/bootstrap-security.sh
-test -x /usr/local/libexec/cloud-compose/run-bootstrap.sh
-test -x /usr/local/libexec/cloud-compose/require-bootstrap-ready.sh
-test -x /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh
-test -x /usr/local/libexec/cloud-compose/run-root-program.sh
+test -x /etc/cloud-compose/libexec/bootstrap-required.sh
+test -x /etc/cloud-compose/libexec/bootstrap-security.sh
+test -x /etc/cloud-compose/libexec/run-bootstrap.sh
+test -x /etc/cloud-compose/libexec/require-bootstrap-ready.sh
+test -x /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
+test -x /etc/cloud-compose/libexec/run-root-program.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
diff --git a/ci/config-management-input-contract.sh b/ci/config-management-input-contract.sh
index a1e1d0a..bdc060b 100755
--- a/ci/config-management-input-contract.sh
+++ b/ci/config-management-input-contract.sh
@@ -13,8 +13,8 @@ cmp -s "$validator" "$salt_validator" || {
ansible_tasks="$repo_root/ansible/roles/cloud_compose/tasks/main.yml"
salt_state="$repo_root/salt/cloud-compose/init.sls"
-root_program_runner="$repo_root/rootfs/usr/local/libexec/cloud-compose/run-root-program.sh"
-rollout_runner="bash /usr/local/libexec/cloud-compose/run-root-program.sh deploy-rollout.sh"
+root_program_runner="$repo_root/rootfs/etc/cloud-compose/libexec/run-root-program.sh"
+rollout_runner="bash /etc/cloud-compose/libexec/run-root-program.sh deploy-rollout.sh"
contract_fail() {
echo "config-management input contract: $1" >&2
@@ -217,9 +217,9 @@ 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 /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh" not in ansible_tasks:
+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 /usr/local/libexec/cloud-compose/require-bootstrap-ready.sh" not in ansible_tasks:
+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")
@@ -233,9 +233,9 @@ 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 /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh" not in salt_state:
+if "bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh" not in salt_state:
fail("Salt bypasses the retryable bootstrap service")
-if "bash /usr/local/libexec/cloud-compose/require-bootstrap-ready.sh" not in salt_state:
+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")
@@ -411,7 +411,7 @@ adapter_fixture_markers = {
"invalid-lifecycle": "- 42",
"invalid-project-lifecycle": "docker_compose_up: not-a-list",
"invalid-internal-services": "internal_services_enabled: true",
- "invalid-disaster-recovery": "driver_path: /usr/local/libexec/cloud-compose/../untrusted",
+ "invalid-disaster-recovery": "driver_path: /etc/cloud-compose/libexec/../untrusted",
}
for fixture_name, marker in adapter_fixture_markers.items():
for relative_path in (
diff --git a/ci/config-management-smoke-assert.py b/ci/config-management-smoke-assert.py
index 247163b..fc777ab 100644
--- a/ci/config-management-smoke-assert.py
+++ b/ci/config-management-smoke-assert.py
@@ -12,7 +12,10 @@
RUNTIME_HOME = Path("/home/cloud-compose")
-BOOTSTRAP_LIBEXEC = Path("/usr/local/libexec/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")
+JQ_PROGRAM_DIR = PRIVILEGED_PROGRAM_ROOT / "jq"
def load_runtime_env(path: Path) -> dict[str, str]:
@@ -52,10 +55,17 @@ def assert_runtime_files() -> None:
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 [
+ JQ_PROGRAM_DIR / "diagnostics-validate-compose-projects.jq",
+ JQ_PROGRAM_DIR / "offhost-validate-manifest.jq",
+ ]:
+ assert path.exists(), path
+
cloud_compose_gid = grp.getgrnam("cloud-compose").gr_gid
for path, expected_mode in {
RUNTIME_HOME / "init": 0o750,
@@ -84,6 +94,36 @@ def assert_runtime_files() -> None:
oct(stat.S_IMODE(metadata.st_mode)),
)
+ for path in [
+ PRIVILEGED_PROGRAM_ROOT,
+ DIAGNOSTICS_PROGRAM.parent,
+ BOOTSTRAP_LIBEXEC,
+ JQ_PROGRAM_DIR,
+ ]:
+ 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)),
+ )
+
+ 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
+
+ jq_programs = list(JQ_PROGRAM_DIR.glob("*.jq"))
+ assert jq_programs
+ for path in jq_programs:
+ 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) == 0o644, (
+ path,
+ oct(stat.S_IMODE(metadata.st_mode)),
+ )
+
def assert_ansible_runtime() -> None:
env = load_runtime_env(RUNTIME_HOME / ".env")
@@ -97,7 +137,7 @@ def assert_ansible_runtime() -> None:
assert env["CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED"] == "false"
assert (
env["CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER"]
- == "/usr/local/libexec/cloud-compose/ansible-offhost"
+ == "/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"
@@ -203,7 +243,7 @@ def assert_salt_runtime(
assert env["CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED"] == "false"
assert (
env["CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER"]
- == "/usr/local/libexec/cloud-compose/salt-offhost"
+ == "/etc/cloud-compose/libexec/salt-offhost"
)
assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == {
"sitectl": "v1.8.2",
diff --git a/ci/cos-bootstrap-contract.sh b/ci/cos-bootstrap-contract.sh
index adf8219..8b0998d 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,6 +46,22 @@ 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
@@ -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,6 +181,7 @@ 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" ]]
@@ -181,6 +200,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 +221,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 f349150..48b07b7 100644
--- a/ci/cos-jq-portability-contract.sh
+++ b/ci/cos-jq-portability-contract.sh
@@ -5,7 +5,7 @@ set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
runtime_program_roots=(
"$repo_root/rootfs/home/cloud-compose"
- "$repo_root/rootfs/usr/local/share/cloud-compose/jq"
+ "$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:]]*\)'
diff --git a/ci/disaster-recovery-contract.sh b/ci/disaster-recovery-contract.sh
index bb66236..a6c7d5b 100644
--- a/ci/disaster-recovery-contract.sh
+++ b/ci/disaster-recovery-contract.sh
@@ -39,7 +39,7 @@ 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/usr/local/share/cloud-compose/jq"
+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"
diff --git a/ci/filesystem-prep-contract.sh b/ci/filesystem-prep-contract.sh
index 722747d..a6f49bd 100644
--- a/ci/filesystem-prep-contract.sh
+++ b/ci/filesystem-prep-contract.sh
@@ -643,11 +643,11 @@ for cloud_init_template in \
"$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 /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh' \
+ grep -Fq ' bash /etc/cloud-compose/libexec/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 /var/lib/cloud-compose/bootstrap-complete' \
"$cloud_init_template" | cut -d: -f1)"
- run_line="$(grep -nF ' bash /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh' \
+ run_line="$(grep -nF ' bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh' \
"$cloud_init_template" | 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"
diff --git a/ci/gcp-upgrade-smoke-contract.sh b/ci/gcp-upgrade-smoke-contract.sh
index 3c887d7..1fcc795 100755
--- a/ci/gcp-upgrade-smoke-contract.sh
+++ b/ci/gcp-upgrade-smoke-contract.sh
@@ -47,7 +47,7 @@ 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 -Fq 'readonly diagnostics_program="/usr/local/sbin/cloud-compose-diagnostics.sh"' "$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" ||
@@ -175,7 +175,7 @@ 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 /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh' \
+ grep -nF 'bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh' \
"$repo_root/templates/cloud-init.yml" |
cut -d: -f1 || true
)"
diff --git a/ci/host-runtime-security.sh b/ci/host-runtime-security.sh
index 8079f5e..859b16f 100644
--- a/ci/host-runtime-security.sh
+++ b/ci/host-runtime-security.sh
@@ -177,7 +177,7 @@ 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/usr/local/libexec/cloud-compose/bootstrap-security.sh" \
+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"'
@@ -243,12 +243,39 @@ 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"
-verify_line="$(grep -n 'sha256sum -c -' "$archive_source" | head -n 1 | cut -d: -f1)"
-extract_line="$(grep -n 'tar --no-same-owner -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 ownership-safe extraction"
-assert_contains "$repo_root/modules/gcp/main.tf" 'tar --no-same-owner -xzf "$tmp/rootfs.tar.gz"'
+for archive_source in \
+ "$repo_root/modules/linux-vm-runtime/main.tf" \
+ "$repo_root/modules/gcp/main.tf"; do
+ verify_line="$(grep -n 'sha256sum -c -' "$archive_source" | head -n 1 | cut -d: -f1)"
+ extract_line="$(grep -n 'tar --no-same-owner -xzf "\$tmp/rootfs.tar.gz"' "$archive_source" | head -n 1 | cut -d: -f1)"
+ normalize_line="$(grep -n 'chown -hR 0:0 -- "\$rootfs_dir"' "$archive_source" | head -n 1 | cut -d: -f1)"
+ copy_line="$(grep -n 'cp -a "\$rootfs_dir"/. /' "$archive_source" | head -n 1 | cut -d: -f1)"
+ [[ -n "$verify_line" && -n "$extract_line" && -n "$normalize_line" && -n "$copy_line" &&
+ "$verify_line" -lt "$extract_line" && "$extract_line" -lt "$normalize_line" &&
+ "$normalize_line" -lt "$copy_line" ]] || \
+ fail "$archive_source does not verify, ownership-safe extract, then normalize the rootfs to root"
+done
+
+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/bin/cloud-compose-diagnostics.sh \
+ rootfs/etc/cloud-compose/libexec/build-cos-make.sh \
+ rootfs/etc/cloud-compose/libexec/bootstrap-security.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; do
+ [[ -f "$repo_root/$trusted_program" ]] || fail "COS-safe trusted program is missing: $trusted_program"
+done
+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" 'install -d -m 0755 -o root -g root /etc/cloud-compose/bin'
+ if grep -Eq '^[[:space:]]*- path: /usr/|install -d[^#]* /usr/local' "$cloud_init_template"; then
+ fail "$cloud_init_template writes Cloud Compose-owned programs beneath immutable /usr"
+ fi
+done
for variables_file in \
"$repo_root/variables.tf" \
diff --git a/ci/rootfs-package-contract.sh b/ci/rootfs-package-contract.sh
index 47f1737..d4612c6 100755
--- a/ci/rootfs-package-contract.sh
+++ b/ci/rootfs-package-contract.sh
@@ -21,10 +21,18 @@ tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | 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/usr/local/libexec/cloud-compose/run-bootstrap.sh' >/dev/null
+ grep -Fx 'rootfs/etc/cloud-compose/libexec/run-bootstrap.sh' >/dev/null
tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
- grep -Fx 'rootfs/usr/local/libexec/cloud-compose/run-root-program.sh' >/dev/null
+ 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/usr/local/share/cloud-compose/jq/offhost-validate-manifest.jq' >/dev/null
+ 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/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/systemd-contract.sh b/ci/systemd-contract.sh
index 9ce70de..aa6f000 100755
--- a/ci/systemd-contract.sh
+++ b/ci/systemd-contract.sh
@@ -4,10 +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/usr/local/sbin/cloud-compose-diagnostics.sh"
+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/usr/local/libexec/cloud-compose/bootstrap-security.sh"
-root_program_runner="$repo_root/rootfs/usr/local/libexec/cloud-compose/run-root-program.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
@@ -48,8 +48,8 @@ assert_contains "$unit_dir/cloud-compose.service" 'RestartSec=30s'
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 /usr/local/libexec/cloud-compose/bootstrap-required.sh'
-assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'ExecStart=/bin/bash /usr/local/libexec/cloud-compose/run-bootstrap.sh'
+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" 'TimeoutStartSec=2h'
@@ -73,8 +73,8 @@ 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:'
-[[ -f "$repo_root/rootfs/usr/local/libexec/cloud-compose/require-bootstrap-ready.sh" ]] || \
- fail "validated bootstrap readiness gate is missing"
+[[ -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 "$smoke_healthcheck_program" 'source /home/cloud-compose/profile.sh'
@@ -116,9 +116,9 @@ 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 /usr/local/libexec/cloud-compose/run-root-program.sh 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 /usr/local/libexec/cloud-compose/run-root-program.sh 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 \
@@ -132,7 +132,7 @@ for root_home_unit in \
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" '/usr/local/libexec/cloud-compose/run-root-program.sh'
+ 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
diff --git a/ci/vault-runtime-contract.sh b/ci/vault-runtime-contract.sh
index 1d96543..901d776 100644
--- a/ci/vault-runtime-contract.sh
+++ b/ci/vault-runtime-contract.sh
@@ -181,9 +181,9 @@ 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"
vault_unit="$repo_root/rootfs/etc/systemd/system/cloud-compose-vault-agent.service"
for readiness_command in \
- 'ExecStartPre=/bin/bash /usr/local/libexec/cloud-compose/run-root-program.sh vault-agent-readiness.sh prepare' \
- 'ExecStartPost=/bin/bash /usr/local/libexec/cloud-compose/run-root-program.sh vault-agent-readiness.sh wait' \
- 'ExecStopPost=/bin/bash /usr/local/libexec/cloud-compose/run-root-program.sh vault-agent-readiness.sh clear'; do
+ '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
diff --git a/docs/disaster-recovery.md b/docs/disaster-recovery.md
index 5ddcf3f..618201e 100644
--- a/docs/disaster-recovery.md
+++ b/docs/disaster-recovery.md
@@ -16,13 +16,19 @@ root, bind mount, named volume, and service-mount topology in the manifest.
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 = "/usr/local/libexec/cloud-compose/offhost-backup-driver"
+ driver_path = "/etc/cloud-compose/libexec/offhost-backup-driver"
}
}
```
diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md
index 42eef98..066f9c2 100644
--- a/docs/runtime-contracts.md
+++ b/docs/runtime-contracts.md
@@ -156,10 +156,13 @@ 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 `/usr/local/libexec/cloud-compose`, validate
-the root-owned home scripts and control inputs, and only then execute their
-allowlisted `/home/cloud-compose` program. Application services retain their
-unprivileged execution model.
+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` programs live below
+`/etc/cloud-compose/{libexec,bin,jq}` because COS permits cloud-init to rebuild
+that stateless tree while its `/usr` filesystem is immutable. Application
+services retain their unprivileged execution model.
## Sitectl
@@ -336,7 +339,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
@@ -662,10 +667,12 @@ 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.
+its `rootfs` directory. Both archive installers normalize the verified tree to
+UID/GID `0:0` before loading a helper or copying it onto the host, so Git forge
+source-archive ownership cannot leak into the privileged runtime. 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.
## Backups
@@ -860,7 +867,7 @@ deploy the same
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 the
-root-owned `/usr/local/libexec/cloud-compose/run-bootstrap.sh` entrypoint
+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
diff --git a/internal/contracttest/disaster_recovery_test.go b/internal/contracttest/disaster_recovery_test.go
index 083d853..61d6931 100644
--- a/internal/contracttest/disaster_recovery_test.go
+++ b/internal/contracttest/disaster_recovery_test.go
@@ -19,7 +19,7 @@ func TestDisasterRecoveryInputParity(t *testing.T) {
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, "/usr/local/libexec/cloud-compose/offhost-backup-driver")`, relativePath+" driver default")
+ 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")
}
@@ -53,12 +53,12 @@ func TestDisasterRecoveryReceiptAndRestoreContracts(t *testing.T) {
restore := readRepositoryFile(t, root, "rootfs/home/cloud-compose/restore-test.sh")
validationContract := strings.Join([]string{
library,
- readRepositoryFile(t, root, "rootfs/usr/local/share/cloud-compose/jq/dr-validate-backup-receipt.jq"),
- readRepositoryFile(t, root, "rootfs/usr/local/share/cloud-compose/jq/dr-validate-restore-proof.jq"),
+ 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/usr/local/share/cloud-compose/jq/offhost-build-application-coverage.jq"),
+ readRepositoryFile(t, root, "rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq"),
}, "\n")
for marker, label := range map[string]string{
diff --git a/modules/digitalocean/variables.tf b/modules/digitalocean/variables.tf
index eef7bb9..26a6104 100644
--- a/modules/digitalocean/variables.tf
+++ b/modules/digitalocean/variables.tf
@@ -78,7 +78,7 @@ variable "runtime" {
disaster_recovery = optional(object({
required = optional(bool, false)
- driver_path = optional(string, "/usr/local/libexec/cloud-compose/offhost-backup-driver")
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
}), {})
compose = optional(object({
diff --git a/modules/gcp/main.tf b/modules/gcp/main.tf
index b5b2035..8aa3c14 100644
--- a/modules/gcp/main.tf
+++ b/modules/gcp/main.tf
@@ -232,11 +232,15 @@ locals {
}
}
)
+ 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")}
+ permissions: ${jsonencode(local.rootfs_file_permissions[file])}
encoding: gzip+base64
content: ${jsonencode(base64gzip(file(config.source)))}
EOT
@@ -447,10 +451,11 @@ rootfs_archive_command_raw = <<-EOT
printf '%s %s\n' "$archive_sha256" "$tmp/rootfs.tar.gz" | sha256sum -c -
tar --no-same-owner -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
+ if [ -z "$rootfs_dir" ] || [ ! -d "$rootfs_dir" ]; then
echo "rootfs directory not found in $archive_url" >&2
exit 1
fi
+ chown -hR 0:0 -- "$rootfs_dir"
cp -a "$rootfs_dir"/. /
if [ -d "$overlay_dir" ]; then
cp -a "$overlay_dir"/. /
@@ -464,7 +469,7 @@ 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", {
- DIAGNOSTICS_SCRIPT_B64 = filebase64("${local.rootFs}/usr/local/sbin/cloud-compose-diagnostics.sh"),
+ DIAGNOSTICS_SCRIPT_B64 = filebase64("${local.rootFs}/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"),
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}",
diff --git a/modules/gcp/runtime_contracts.tftest.hcl b/modules/gcp/runtime_contracts.tftest.hcl
index 38166be..0c5fd62 100644
--- a/modules/gcp/runtime_contracts.tftest.hcl
+++ b/modules/gcp/runtime_contracts.tftest.hcl
@@ -535,13 +535,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"
@@ -561,10 +562,10 @@ run "renders_verified_archive_before_downstream_overlay" {
assert {
condition = can(regex(
- "(?s)cp -a \\\"\\$rootfs_dir\\\"/\\. /.*cp -a \\\"\\$overlay_dir\\\"/\\. /",
+ "(?s)sha256sum -c -.*tar --no-same-owner -xzf \\\"\\$tmp/rootfs\\.tar\\.gz\\\".*chown -hR 0:0 -- \\\"\\$rootfs_dir\\\".*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."
+ error_message = "The verified base archive must be extracted ownership-safely, normalized to root, and installed before the consumer rootfs overlay."
}
assert {
@@ -581,6 +582,51 @@ 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 permissions: \"0755\"",
+ ) &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose-overlay-marker\"\n permissions: \"0644\"",
+ )
+ )
+ error_message = "Archive-backed GCP overlays must make only the configured off-host backup driver executable."
+ }
+}
+
+run "renders_embedded_offhost_backup_driver_executable" {
+ command = plan
+
+ variables {
+ name = "gcp-embedded-driver-contract"
+ 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 permissions: \"0755\"",
+ ) &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/etc/cloud-compose-overlay-marker\"\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" {
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 f843c46..35aca7f 100644
--- a/modules/gcp/variables.tf
+++ b/modules/gcp/variables.tf
@@ -616,7 +616,7 @@ variable "offhost_backup_required" {
variable "offhost_backup_driver_path" {
type = string
- default = "/usr/local/libexec/cloud-compose/offhost-backup-driver"
+ 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 {
diff --git a/modules/linode/variables.tf b/modules/linode/variables.tf
index 45cec74..2ce975e 100644
--- a/modules/linode/variables.tf
+++ b/modules/linode/variables.tf
@@ -92,7 +92,7 @@ variable "runtime" {
disaster_recovery = optional(object({
required = optional(bool, false)
- driver_path = optional(string, "/usr/local/libexec/cloud-compose/offhost-backup-driver")
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
}), {})
compose = optional(object({
diff --git a/modules/linux-vm-runtime/disaster_recovery.tftest.hcl b/modules/linux-vm-runtime/disaster_recovery.tftest.hcl
index d4a3414..495c3fc 100644
--- a/modules/linux-vm-runtime/disaster_recovery.tftest.hcl
+++ b/modules/linux-vm-runtime/disaster_recovery.tftest.hcl
@@ -9,13 +9,13 @@ run "renders_provider_neutral_disaster_recovery_controls" {
volumes_device = "/dev/test-volumes"
docker_compose_repo = "https://github.com/libops/wp.git"
offhost_backup_required = true
- offhost_backup_driver_path = "/usr/local/libexec/cloud-compose/acme-offhost"
+ 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 == "/usr/local/libexec/cloud-compose/acme-offhost"
+ 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."
}
@@ -41,7 +41,7 @@ run "rejects_unsafe_disaster_recovery_driver_path" {
volumes_device = "/dev/test-volumes"
docker_compose_repo = "https://github.com/libops/wp.git"
offhost_backup_required = true
- offhost_backup_driver_path = "/usr/local/libexec/cloud-compose/../untrusted"
+ 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 8e89792..047a98b 100644
--- a/modules/linux-vm-runtime/main.tf
+++ b/modules/linux-vm-runtime/main.tf
@@ -89,20 +89,24 @@ locals {
{ for file in local.base_files : file => "${local.rootfs}/${file}" },
{ for file in local.embedded_additional_files : file => "${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"
+ }
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"
+ chmod ${local.rootfs_file_permissions[file]} "$destination"
EOT
])
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")}
+ permissions: ${jsonencode(local.rootfs_file_permissions[file])}
encoding: gzip+base64
content: ${jsonencode(base64gzip(file(fullpath)))}
EOT
@@ -309,6 +313,7 @@ rootfs_archive_prepare_command_raw = <<-EOT
echo "rootfs directory not found in verified archive $archive_url" >&2
exit 1
fi
+ chown -hR 0:0 -- "$rootfs_dir"
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
@@ -333,7 +338,7 @@ rootfs_archive_install_command = local.rootfs_archive_url != "" ? local.rootfs_a
cloud_init = templatefile("${path.module}/templates/cloud-init.yml", {
CLOUD_COMPOSE_SSH_KEYS = var.cloud_compose_ssh_keys
SSH_USERS = var.ssh_users
- DIAGNOSTICS_SCRIPT_B64 = filebase64("${local.rootfs}/usr/local/sbin/cloud-compose-diagnostics.sh")
+ DIAGNOSTICS_SCRIPT_B64 = filebase64("${local.rootfs}/etc/cloud-compose/bin/cloud-compose-diagnostics.sh")
DATA_DEVICE = var.data_device
VOLUMES_DEVICE = var.volumes_device
WRITE_FILES_CONTENT = local.write_files_content
diff --git a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl
index 78bc2a1..3a5f841 100644
--- a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl
+++ b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl
@@ -29,9 +29,9 @@ run "renders_safe_ssh_values" {
assert {
condition = (
strcontains(output.cloud_init, "path: /var/lib/cloud-compose/bootstrap/cloud-compose-diagnostics.sh") &&
- strcontains(output.cloud_init, "NOPASSWD:/usr/local/sbin/cloud-compose-diagnostics.sh state") &&
- strcontains(output.cloud_init, "NOPASSWD:/usr/local/sbin/cloud-compose-diagnostics.sh status") &&
- strcontains(output.cloud_init, "NOPASSWD:/usr/local/sbin/cloud-compose-diagnostics.sh dump") &&
+ 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(output.cloud_init, "install -m 0755 -o root -g root")
)
error_message = "Cloud-init must install one root-owned diagnostics program with exact passwordless sudo commands."
@@ -207,14 +207,16 @@ 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 {
@@ -225,16 +227,18 @@ run "renders_verified_rootfs_archive" {
assert {
condition = (
strcontains(output.cloud_init, "tar --no-same-owner -xzf \"$tmp/rootfs.tar.gz\"") &&
+ strcontains(output.cloud_init, "chown -hR 0:0 -- \"$rootfs_dir\"") &&
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 --no-same-owner -xzf \"$tmp/rootfs.tar.gz\"", output.cloud_init)[0]) &&
- length(split("tar --no-same-owner -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("tar --no-same-owner -xzf \"$tmp/rootfs.tar.gz\"", output.cloud_init)[0]) < length(split("chown -hR 0:0 -- \"$rootfs_dir\"", output.cloud_init)[0]) &&
+ length(split("chown -hR 0:0 -- \"$rootfs_dir\"", 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])
)
- 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 cloud-init must verify, extract, and normalize root ownership before loading its helpers, then install the rootfs only after filesystem preparation and persistence."
}
assert {
@@ -260,18 +264,36 @@ run "renders_verified_rootfs_archive" {
)
error_message = "Archive URLs must be rendered as base64 data rather than executable shell syntax."
}
+
+ assert {
+ condition = (
+ local.rootfs_file_permissions["etc/cloud-compose/libexec/custom-offhost-driver"] == "0755" &&
+ local.rootfs_file_permissions["etc/cloud-compose/unrelated-config"] == "0644" &&
+ strcontains(
+ split(base64encode("/etc/cloud-compose/libexec/custom-offhost-driver"), local.archive_additional_rootfs_commands)[1],
+ "chmod 0755 \"$destination\"",
+ ) &&
+ strcontains(
+ split(base64encode("/etc/cloud-compose/unrelated-config"), local.archive_additional_rootfs_commands)[1],
+ "chmod 0644 \"$destination\"",
+ )
+ )
+ error_message = "Archive-backed Linux VM overlays must make only the configured off-host backup driver executable."
+ }
}
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 {
@@ -282,6 +304,22 @@ run "embeds_filesystem_helpers_without_archive" {
)
error_message = "Inline cloud-init must retain the embedded filesystem-helper bootstrap when no archive is configured."
}
+
+ assert {
+ condition = (
+ 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 permissions: \"0755\"",
+ ) &&
+ strcontains(
+ local.write_files_content,
+ "- path: \"/etc/cloud-compose/unrelated-config\"\n permissions: \"0644\"",
+ )
+ )
+ error_message = "Embedded Linux VM overlays must make only the configured off-host backup driver executable."
+ }
}
run "rejects_rootfs_archive_without_checksum" {
diff --git a/modules/linux-vm-runtime/templates/cloud-init.yml b/modules/linux-vm-runtime/templates/cloud-init.yml
index 7a46576..d35c7e8 100644
--- a/modules/linux-vm-runtime/templates/cloud-init.yml
+++ b/modules/linux-vm-runtime/templates/cloud-init.yml
@@ -10,9 +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:/usr/local/sbin/cloud-compose-diagnostics.sh state
- - ALL=(root) NOPASSWD:/usr/local/sbin/cloud-compose-diagnostics.sh status
- - ALL=(root) NOPASSWD:/usr/local/sbin/cloud-compose-diagnostics.sh dump
+ - 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 ~}
@@ -65,9 +65,9 @@ runcmd:
install -d -m 0755 /etc/sudoers.d
printf '%s\n' \
'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' \
- 'cloud-compose ALL=(root) NOPASSWD:/usr/local/sbin/cloud-compose-diagnostics.sh state' \
- 'cloud-compose ALL=(root) NOPASSWD:/usr/local/sbin/cloud-compose-diagnostics.sh status' \
- 'cloud-compose ALL=(root) NOPASSWD:/usr/local/sbin/cloud-compose-diagnostics.sh dump' \
+ 'cloud-compose ALL=(root) NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh state' \
+ 'cloud-compose ALL=(root) NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh status' \
+ 'cloud-compose ALL=(root) NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh dump' \
>/etc/sudoers.d/90-cloud-compose
chmod 0440 /etc/sudoers.d/90-cloud-compose
}
@@ -103,10 +103,10 @@ runcmd:
cp -a /var/lib/cloud-compose/mounted-rootfs/mnt/disks/. /mnt/disks/
fi
%{ endif }
- install -d -m 0755 -o root -g root /usr/local/sbin
+ install -d -m 0755 -o root -g root /etc/cloud-compose/bin
install -m 0755 -o root -g root \
/var/lib/cloud-compose/bootstrap/cloud-compose-diagnostics.sh \
- /usr/local/sbin/cloud-compose-diagnostics.sh
+ /etc/cloud-compose/bin/cloud-compose-diagnostics.sh
chown root:cloud-compose /mnt/disks/data
chmod 1775 /mnt/disks/data
chown cloud-compose:cloud-compose /mnt/disks/volumes
@@ -116,4 +116,4 @@ runcmd:
${ROLLOUT_RUNCMD}
%{ endif ~}
rm -f /var/lib/cloud-compose/bootstrap-complete
- bash /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh
+ bash /etc/cloud-compose/libexec/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 2d8d0f9..5bf5f85 100644
--- a/modules/linux-vm-runtime/variables.tf
+++ b/modules/linux-vm-runtime/variables.tf
@@ -104,7 +104,7 @@ variable "offhost_backup_required" {
variable "offhost_backup_driver_path" {
type = string
- default = "/usr/local/libexec/cloud-compose/offhost-backup-driver"
+ 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 {
diff --git a/providers/do/variables.tf b/providers/do/variables.tf
index aad28f9..eab146b 100644
--- a/providers/do/variables.tf
+++ b/providers/do/variables.tf
@@ -89,7 +89,7 @@ variable "runtime" {
disaster_recovery = optional(object({
required = optional(bool, false)
- driver_path = optional(string, "/usr/local/libexec/cloud-compose/offhost-backup-driver")
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
}), {})
compose = optional(object({
diff --git a/providers/gcp/variables.tf b/providers/gcp/variables.tf
index 80a8a4a..df1ef48 100644
--- a/providers/gcp/variables.tf
+++ b/providers/gcp/variables.tf
@@ -201,7 +201,7 @@ variable "runtime" {
disaster_recovery = optional(object({
required = optional(bool, false)
- driver_path = optional(string, "/usr/local/libexec/cloud-compose/offhost-backup-driver")
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
}), {})
compose = optional(object({
diff --git a/providers/linode/variables.tf b/providers/linode/variables.tf
index 4b29dc0..8ab7190 100644
--- a/providers/linode/variables.tf
+++ b/providers/linode/variables.tf
@@ -104,7 +104,7 @@ variable "runtime" {
disaster_recovery = optional(object({
required = optional(bool, false)
- driver_path = optional(string, "/usr/local/libexec/cloud-compose/offhost-backup-driver")
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
}), {})
compose = optional(object({
diff --git a/rootfs/usr/local/sbin/cloud-compose-diagnostics.sh b/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh
similarity index 98%
rename from rootfs/usr/local/sbin/cloud-compose-diagnostics.sh
rename to rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh
index 7662827..185b24b 100755
--- a/rootfs/usr/local/sbin/cloud-compose-diagnostics.sh
+++ b/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh
@@ -8,8 +8,8 @@ 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="/usr/local/sbin/cloud-compose-diagnostics.sh"
-readonly jq_program_dir="/usr/local/share/cloud-compose/jq"
+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() {
diff --git a/rootfs/usr/local/share/cloud-compose/jq/diagnostics-entry-app.jq b/rootfs/etc/cloud-compose/jq/diagnostics-entry-app.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/diagnostics-entry-app.jq
rename to rootfs/etc/cloud-compose/jq/diagnostics-entry-app.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/diagnostics-entry-project-dir.jq b/rootfs/etc/cloud-compose/jq/diagnostics-entry-project-dir.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/diagnostics-entry-project-dir.jq
rename to rootfs/etc/cloud-compose/jq/diagnostics-entry-project-dir.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/diagnostics-project-entries.jq b/rootfs/etc/cloud-compose/jq/diagnostics-project-entries.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/diagnostics-project-entries.jq
rename to rootfs/etc/cloud-compose/jq/diagnostics-project-entries.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/diagnostics-validate-compose-projects.jq b/rootfs/etc/cloud-compose/jq/diagnostics-validate-compose-projects.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/diagnostics-validate-compose-projects.jq
rename to rootfs/etc/cloud-compose/jq/diagnostics-validate-compose-projects.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/dr-backup-completed-at.jq b/rootfs/etc/cloud-compose/jq/dr-backup-completed-at.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/dr-backup-completed-at.jq
rename to rootfs/etc/cloud-compose/jq/dr-backup-completed-at.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/dr-backup-remote-id.jq b/rootfs/etc/cloud-compose/jq/dr-backup-remote-id.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/dr-backup-remote-id.jq
rename to rootfs/etc/cloud-compose/jq/dr-backup-remote-id.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/dr-restore-completed-at.jq b/rootfs/etc/cloud-compose/jq/dr-restore-completed-at.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/dr-restore-completed-at.jq
rename to rootfs/etc/cloud-compose/jq/dr-restore-completed-at.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/dr-restore-recovery-id.jq b/rootfs/etc/cloud-compose/jq/dr-restore-recovery-id.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/dr-restore-recovery-id.jq
rename to rootfs/etc/cloud-compose/jq/dr-restore-recovery-id.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/dr-validate-backup-receipt.jq b/rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/dr-validate-backup-receipt.jq
rename to rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/dr-validate-restore-proof.jq b/rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/dr-validate-restore-proof.jq
rename to rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/offhost-bind-sources.jq b/rootfs/etc/cloud-compose/jq/offhost-bind-sources.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/offhost-bind-sources.jq
rename to rootfs/etc/cloud-compose/jq/offhost-bind-sources.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/offhost-build-application-coverage.jq b/rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/offhost-build-application-coverage.jq
rename to rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/offhost-build-manifest.jq b/rootfs/etc/cloud-compose/jq/offhost-build-manifest.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/offhost-build-manifest.jq
rename to rootfs/etc/cloud-compose/jq/offhost-build-manifest.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/offhost-manifest-app-digests.jq b/rootfs/etc/cloud-compose/jq/offhost-manifest-app-digests.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/offhost-manifest-app-digests.jq
rename to rootfs/etc/cloud-compose/jq/offhost-manifest-app-digests.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/offhost-validate-bind-roots.jq b/rootfs/etc/cloud-compose/jq/offhost-validate-bind-roots.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/offhost-validate-bind-roots.jq
rename to rootfs/etc/cloud-compose/jq/offhost-validate-bind-roots.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/offhost-validate-compose-config.jq b/rootfs/etc/cloud-compose/jq/offhost-validate-compose-config.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/offhost-validate-compose-config.jq
rename to rootfs/etc/cloud-compose/jq/offhost-validate-compose-config.jq
diff --git a/rootfs/usr/local/share/cloud-compose/jq/offhost-validate-manifest.jq b/rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq
similarity index 100%
rename from rootfs/usr/local/share/cloud-compose/jq/offhost-validate-manifest.jq
rename to rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq
diff --git a/rootfs/usr/local/libexec/cloud-compose/bootstrap-required.sh b/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh
old mode 100644
new mode 100755
similarity index 86%
rename from rootfs/usr/local/libexec/cloud-compose/bootstrap-required.sh
rename to rootfs/etc/cloud-compose/libexec/bootstrap-required.sh
index 2d56f2a..c0ba4fb
--- a/rootfs/usr/local/libexec/cloud-compose/bootstrap-required.sh
+++ b/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh
@@ -3,7 +3,7 @@
set -uo pipefail
# shellcheck disable=SC1091
-if ! source /usr/local/libexec/cloud-compose/bootstrap-security.sh; then
+if ! source /etc/cloud-compose/libexec/bootstrap-security.sh; then
echo "Cloud Compose bootstrap security helper could not be loaded" >&2
exit 255
fi
diff --git a/rootfs/usr/local/libexec/cloud-compose/bootstrap-security.sh b/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh
old mode 100644
new mode 100755
similarity index 100%
rename from rootfs/usr/local/libexec/cloud-compose/bootstrap-security.sh
rename to rootfs/etc/cloud-compose/libexec/bootstrap-security.sh
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/usr/local/libexec/cloud-compose/require-bootstrap-ready.sh b/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh
old mode 100644
new mode 100755
similarity index 80%
rename from rootfs/usr/local/libexec/cloud-compose/require-bootstrap-ready.sh
rename to rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh
index a9c1ac5..1526af1
--- a/rootfs/usr/local/libexec/cloud-compose/require-bootstrap-ready.sh
+++ b/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh
@@ -3,7 +3,7 @@
set -euo pipefail
# shellcheck disable=SC1091
-source /usr/local/libexec/cloud-compose/bootstrap-security.sh
+source /etc/cloud-compose/libexec/bootstrap-security.sh
cloud_compose_bootstrap_require_root
if ! cloud_compose_bootstrap_marker_ready; then
diff --git a/rootfs/usr/local/libexec/cloud-compose/run-bootstrap.sh b/rootfs/etc/cloud-compose/libexec/run-bootstrap.sh
old mode 100644
new mode 100755
similarity index 70%
rename from rootfs/usr/local/libexec/cloud-compose/run-bootstrap.sh
rename to rootfs/etc/cloud-compose/libexec/run-bootstrap.sh
index 259f1c1..eda9d3b
--- a/rootfs/usr/local/libexec/cloud-compose/run-bootstrap.sh
+++ b/rootfs/etc/cloud-compose/libexec/run-bootstrap.sh
@@ -3,7 +3,7 @@
set -euo pipefail
# shellcheck disable=SC1091
-source /usr/local/libexec/cloud-compose/bootstrap-security.sh
+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/usr/local/libexec/cloud-compose/run-root-program.sh b/rootfs/etc/cloud-compose/libexec/run-root-program.sh
old mode 100644
new mode 100755
similarity index 91%
rename from rootfs/usr/local/libexec/cloud-compose/run-root-program.sh
rename to rootfs/etc/cloud-compose/libexec/run-root-program.sh
index 326455e..b5e37e3
--- a/rootfs/usr/local/libexec/cloud-compose/run-root-program.sh
+++ b/rootfs/etc/cloud-compose/libexec/run-root-program.sh
@@ -3,7 +3,7 @@
set -euo pipefail
# shellcheck disable=SC1091
-source /usr/local/libexec/cloud-compose/bootstrap-security.sh
+source /etc/cloud-compose/libexec/bootstrap-security.sh
program="${1:-}"
if [[ -z "$program" ]]; then
diff --git a/rootfs/usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh b/rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
old mode 100644
new mode 100755
similarity index 73%
rename from rootfs/usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh
rename to rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
index 02ce01b..b38af25
--- a/rootfs/usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh
+++ b/rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
@@ -3,7 +3,7 @@
set -euo pipefail
# shellcheck disable=SC1091
-source /usr/local/libexec/cloud-compose/bootstrap-security.sh
+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 32c72ef..1149e95 100644
--- a/rootfs/etc/systemd/system/cloud-compose-bootstrap.service
+++ b/rootfs/etc/systemd/system/cloud-compose-bootstrap.service
@@ -8,8 +8,8 @@ StartLimitIntervalSec=0
[Service]
Type=oneshot
RemainAfterExit=yes
-ExecCondition=/bin/bash /usr/local/libexec/cloud-compose/bootstrap-required.sh
-ExecStart=/bin/bash /usr/local/libexec/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 2b81c9c..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 /usr/local/libexec/cloud-compose/run-root-program.sh 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 cac15e9..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 /usr/local/libexec/cloud-compose/run-root-program.sh 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-metadata-firewall-pre.service b/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service
index ec04bd7..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 /usr/local/libexec/cloud-compose/run-root-program.sh 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 bb810f5..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 /usr/local/libexec/cloud-compose/run-root-program.sh 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
index 4a1f07f..c1d4069 100644
--- a/rootfs/etc/systemd/system/cloud-compose-offhost-backup.service
+++ b/rootfs/etc/systemd/system/cloud-compose-offhost-backup.service
@@ -10,7 +10,7 @@ Type=oneshot
User=root
Group=root
UMask=0077
-ExecStart=/bin/bash /usr/local/libexec/cloud-compose/run-root-program.sh offhost-backup.sh
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh offhost-backup.sh
TimeoutStartSec=24h
StandardOutput=journal
StandardError=journal
diff --git a/rootfs/etc/systemd/system/cloud-compose-overlay.service b/rootfs/etc/systemd/system/cloud-compose-overlay.service
index 7c5b261..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 /usr/local/libexec/cloud-compose/run-root-program.sh 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
index 64f9bd6..baa3b0a 100644
--- a/rootfs/etc/systemd/system/cloud-compose-restore-test.service
+++ b/rootfs/etc/systemd/system/cloud-compose-restore-test.service
@@ -9,7 +9,7 @@ Type=oneshot
User=root
Group=root
UMask=0077
-ExecStart=/bin/bash /usr/local/libexec/cloud-compose/run-root-program.sh restore-test.sh
+ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh restore-test.sh
TimeoutStartSec=24h
StandardOutput=journal
StandardError=journal
diff --git a/rootfs/etc/systemd/system/cloud-compose-vault-agent.service b/rootfs/etc/systemd/system/cloud-compose-vault-agent.service
index 51d2cf0..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 /usr/local/libexec/cloud-compose/run-root-program.sh 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 /usr/local/libexec/cloud-compose/run-root-program.sh vault-agent-readiness.sh wait
-ExecStopPost=/bin/bash /usr/local/libexec/cloud-compose/run-root-program.sh 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/libops-managed-runtime.service b/rootfs/etc/systemd/system/libops-managed-runtime.service
index 2644d67..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 /usr/local/libexec/cloud-compose/run-root-program.sh 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/home/cloud-compose/disaster-recovery-lib.sh b/rootfs/home/cloud-compose/disaster-recovery-lib.sh
index f797fde..8cba3df 100644
--- a/rootfs/home/cloud-compose/disaster-recovery-lib.sh
+++ b/rootfs/home/cloud-compose/disaster-recovery-lib.sh
@@ -4,8 +4,8 @@
# The caller must enable `set -euo pipefail` before sourcing this file.
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:-/usr/local/libexec/cloud-compose/offhost-backup-driver}"
-CLOUD_COMPOSE_JQ_PROGRAM_DIR="${CLOUD_COMPOSE_JQ_PROGRAM_DIR:-/usr/local/share/cloud-compose/jq}"
+CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="${CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER:-/etc/cloud-compose/libexec/offhost-backup-driver}"
+CLOUD_COMPOSE_JQ_PROGRAM_DIR="${CLOUD_COMPOSE_JQ_PROGRAM_DIR:-/etc/cloud-compose/jq}"
cloud_compose_dr_is_required() {
case "${CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED:-false}" in
diff --git a/rootfs/home/cloud-compose/install-dependencies-cos.sh b/rootfs/home/cloud-compose/install-dependencies-cos.sh
index 0f6320e..8273c56 100644
--- a/rootfs/home/cloud-compose/install-dependencies-cos.sh
+++ b/rootfs/home/cloud-compose/install-dependencies-cos.sh
@@ -18,7 +18,7 @@ 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}"
@@ -27,6 +27,7 @@ install_cos_dependencies() {
# PATH consumes the published symlink.
local tool_state_dir="${2:-${COS_TOOL_STATE_DIR:-/mnt/disks/data/cloud-compose-tools}}"
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
@@ -81,6 +82,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 +94,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/offhost-backup.sh b/rootfs/home/cloud-compose/offhost-backup.sh
index f24f1ea..e84ea8b 100644
--- a/rootfs/home/cloud-compose/offhost-backup.sh
+++ b/rootfs/home/cloud-compose/offhost-backup.sh
@@ -6,7 +6,7 @@ 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}"
dr_library_path="${CLOUD_COMPOSE_DR_LIBRARY_PATH:-$script_dir/disaster-recovery-lib.sh}"
-jq_program_dir="${CLOUD_COMPOSE_JQ_PROGRAM_DIR:-/usr/local/share/cloud-compose/jq}"
+jq_program_dir="${CLOUD_COMPOSE_JQ_PROGRAM_DIR:-/etc/cloud-compose/jq}"
# shellcheck disable=SC1090
source "$profile_path"
# shellcheck disable=SC1090
diff --git a/rootfs/home/cloud-compose/restore-test.sh b/rootfs/home/cloud-compose/restore-test.sh
index 0ca6a0b..92c766f 100644
--- a/rootfs/home/cloud-compose/restore-test.sh
+++ b/rootfs/home/cloud-compose/restore-test.sh
@@ -5,7 +5,7 @@ set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}"
dr_library_path="${CLOUD_COMPOSE_DR_LIBRARY_PATH:-$script_dir/disaster-recovery-lib.sh}"
-jq_program_dir="${CLOUD_COMPOSE_JQ_PROGRAM_DIR:-/usr/local/share/cloud-compose/jq}"
+jq_program_dir="${CLOUD_COMPOSE_JQ_PROGRAM_DIR:-/etc/cloud-compose/jq}"
# shellcheck disable=SC1090
source "$profile_path"
CLOUD_COMPOSE_JQ_PROGRAM_DIR="$jq_program_dir"
diff --git a/runtime_contracts.tftest.hcl b/runtime_contracts.tftest.hcl
index 7cdcf89..d818bdd 100644
--- a/runtime_contracts.tftest.hcl
+++ b/runtime_contracts.tftest.hcl
@@ -161,7 +161,7 @@ run "public_entrypoint_accepts_provider_neutral_disaster_recovery_driver" {
runtime = {
disaster_recovery = {
required = true
- driver_path = "/usr/local/libexec/cloud-compose/acme-offhost"
+ driver_path = "/etc/cloud-compose/libexec/acme-offhost"
}
}
}
@@ -169,7 +169,7 @@ run "public_entrypoint_accepts_provider_neutral_disaster_recovery_driver" {
assert {
condition = (
local.runtime.disaster_recovery.required &&
- local.runtime.disaster_recovery.driver_path == "/usr/local/libexec/cloud-compose/acme-offhost"
+ local.runtime.disaster_recovery.driver_path == "/etc/cloud-compose/libexec/acme-offhost"
)
error_message = "The public entrypoint must preserve provider-neutral DR controls."
}
diff --git a/salt/cloud-compose/README.md b/salt/cloud-compose/README.md
index b554c61..5d08c82 100644
--- a/salt/cloud-compose/README.md
+++ b/salt/cloud-compose/README.md
@@ -72,7 +72,7 @@ cloud_compose:
runtime:
disaster_recovery:
required: true
- driver_path: /usr/local/libexec/cloud-compose/offhost-backup-driver
+ driver_path: /etc/cloud-compose/libexec/offhost-backup-driver
```
Install that executable and its credentials separately as root. The formula
diff --git a/salt/cloud-compose/init.sls b/salt/cloud-compose/init.sls
index e62ffd5..bcbeb54 100644
--- a/salt/cloud-compose/init.sls
+++ b/salt/cloud-compose/init.sls
@@ -144,7 +144,7 @@
{% 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', '/usr/local/libexec/cloud-compose/offhost-backup-driver') %}
+{% 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 %}
@@ -658,6 +658,19 @@ cloud-compose-rootfs:
- require:
- user: cloud-compose-user
+cloud-compose-privileged-program-directories:
+ file.directory:
+ - names:
+ - /etc/cloud-compose
+ - /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
@@ -673,10 +686,19 @@ cloud-compose-lifecycle-lock:
cloud-compose-rootfs-script-modes:
cmd.run:
- - name: find /home/cloud-compose /usr/local/libexec/cloud-compose -maxdepth 1 -type f -name '*.sh' -exec chown root:root {} + -exec chmod 0755 {} +
- - unless: test -z "$(find /home/cloud-compose /usr/local/libexec/cloud-compose -maxdepth 1 -type f -name '*.sh' \( ! -user root -o ! -group root -o ! -perm 0755 \) -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-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
{% for lifecycle in ['init', 'up', 'down', 'rollout'] %}
cloud-compose-lifecycle-{{ lifecycle }}:
@@ -764,7 +786,7 @@ cloud-compose-systemd-reload:
{% if rollout_enabled is sameas true %}
cloud-compose-rollout-service:
cmd.run:
- - name: bash /usr/local/libexec/cloud-compose/run-root-program.sh 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
@@ -793,8 +815,8 @@ cloud-compose-clear-bootstrap-marker:
{% if run_bootstrap is sameas true %}
cloud-compose-bootstrap:
cmd.run:
- - name: bash /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh
- - unless: bash /usr/local/libexec/cloud-compose/require-bootstrap-ready.sh
+ - name: bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
+ - unless: bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh
- require:
{% if install_packages %}
- service: cloud-compose-docker
@@ -804,6 +826,7 @@ cloud-compose-bootstrap:
- file: cloud-compose-project-manifest
- file: cloud-compose-managed-runtime-artifacts
- cmd: cloud-compose-rootfs-script-modes
+ - cmd: cloud-compose-rootfs-jq-modes
{% for lifecycle in ['init', 'up', 'down', 'rollout'] %}
- file: cloud-compose-lifecycle-{{ lifecycle }}
{% endfor %}
diff --git a/templates/cloud-init.yml b/templates/cloud-init.yml
index 9694f3b..38b136b 100644
--- a/templates/cloud-init.yml
+++ b/templates/cloud-init.yml
@@ -10,9 +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:/usr/local/sbin/cloud-compose-diagnostics.sh state
- - ALL=(root) NOPASSWD:/usr/local/sbin/cloud-compose-diagnostics.sh status
- - ALL=(root) NOPASSWD:/usr/local/sbin/cloud-compose-diagnostics.sh dump
+ - 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 ~}
@@ -90,10 +90,10 @@ runcmd:
${ROOTFS_ARCHIVE_COMMAND}
- |
set -eu
- install -d -m 0755 -o root -g root /usr/local/sbin
+ install -d -m 0755 -o root -g root /etc/cloud-compose/bin
install -m 0755 -o root -g root \
/var/lib/cloud-compose/bootstrap/cloud-compose-diagnostics.sh \
- /usr/local/sbin/cloud-compose-diagnostics.sh
+ /etc/cloud-compose/bin/cloud-compose-diagnostics.sh
test -f /run/cloud-compose-filesystems-ready || {
echo "Cloud Compose filesystems were not prepared; refusing application initialization" >&2
exit 1
@@ -107,11 +107,11 @@ ${ROOTFS_ARCHIVE_COMMAND}
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
- bash /usr/local/libexec/cloud-compose/start-cloud-compose-bootstrap.sh
+ bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
%{ if length(ADDITIONAL_RUNCMD) > 0 ~}
- |
set -eu
- bash /usr/local/libexec/cloud-compose/require-bootstrap-ready.sh
+ bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh
%{ for CMD in ADDITIONAL_RUNCMD ~}
${indent(2, CMD)}
%{ endfor ~}
diff --git a/tests/config-management/ansible/invalid-disaster-recovery.yml b/tests/config-management/ansible/invalid-disaster-recovery.yml
index 896513c..19aac33 100644
--- a/tests/config-management/ansible/invalid-disaster-recovery.yml
+++ b/tests/config-management/ansible/invalid-disaster-recovery.yml
@@ -11,6 +11,6 @@
cloud_compose_runtime:
disaster_recovery:
required: true
- driver_path: /usr/local/libexec/cloud-compose/../untrusted
+ 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 96ed382..f03fb35 100644
--- a/tests/config-management/ansible/smoke.yml
+++ b/tests/config-management/ansible/smoke.yml
@@ -14,7 +14,7 @@
cloud_compose_runtime:
disaster_recovery:
required: false
- driver_path: /usr/local/libexec/cloud-compose/ansible-offhost
+ 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
@@ -100,7 +100,7 @@
- >-
'CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED="false"' in (cloud_compose_env_file.content | b64decode)
- >-
- 'CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="/usr/local/libexec/cloud-compose/ansible-offhost"' 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)
- >-
diff --git a/tests/config-management/salt-pillar/invalid-disaster-recovery.sls b/tests/config-management/salt-pillar/invalid-disaster-recovery.sls
index a9ca01c..9454410 100644
--- a/tests/config-management/salt-pillar/invalid-disaster-recovery.sls
+++ b/tests/config-management/salt-pillar/invalid-disaster-recovery.sls
@@ -9,4 +9,4 @@ cloud_compose:
runtime:
disaster_recovery:
required: true
- driver_path: /usr/local/libexec/cloud-compose/../untrusted
+ driver_path: /etc/cloud-compose/libexec/../untrusted
diff --git a/tests/config-management/salt-pillar/wp-prod.sls b/tests/config-management/salt-pillar/wp-prod.sls
index 1c910c4..e02485e 100644
--- a/tests/config-management/salt-pillar/wp-prod.sls
+++ b/tests/config-management/salt-pillar/wp-prod.sls
@@ -12,7 +12,7 @@ cloud_compose:
runtime:
disaster_recovery:
required: false
- driver_path: /usr/local/libexec/cloud-compose/salt-offhost
+ 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
diff --git a/variables.tf b/variables.tf
index 23f2c11..5809f27 100644
--- a/variables.tf
+++ b/variables.tf
@@ -212,7 +212,7 @@ variable "runtime" {
disaster_recovery = optional(object({
required = optional(bool, false)
- driver_path = optional(string, "/usr/local/libexec/cloud-compose/offhost-backup-driver")
+ driver_path = optional(string, "/etc/cloud-compose/libexec/offhost-backup-driver")
}), {})
compose = optional(object({
From c6984fda93e833248dd6df3871702f8feb1d5581 Mon Sep 17 00:00:00 2001
From: Joe Corall