From c97cd7472d23328c445d571d4d4910135b6ae1de Mon Sep 17 00:00:00 2001
From: libops-agent <115990865+libops-agent@users.noreply.github.com>
Date: Mon, 24 Aug 2026 00:10:05 +0000
Subject: [PATCH 1/5] [minor] Move managed host lifecycle into sitectl
---
.github/workflows/cloud-smoke.yml | 2 +-
.github/workflows/github-release.yaml | 23 -
.github/workflows/publish-rootfs.yaml | 36 -
Makefile | 78 +-
README.md | 4 +-
ansible/README.md | 2 +-
ansible/roles/cloud_compose/defaults/main.yml | 8 +-
ansible/roles/cloud_compose/tasks/main.yml | 63 +-
ci/app-filesystem-convergence-contract.sh | 168 --
ci/application-env-contract.sh | 232 +--
ci/backup-contract.sh | 138 --
ci/bootstrap-recovery-contract.sh | 572 -------
ci/cloud-smoke.sh | 40 +-
ci/compose-runtime-contract.sh | 329 ----
ci/config-management-cloud-smoke-inner.sh | 17 +-
ci/config-management-input-contract.py | 23 +-
ci/config-management-input-contract.sh | 47 +-
ci/config-management-smoke-assert.py | 65 +-
ci/config-management-smoke-inner.sh | 56 -
ci/cos-jq-portability-contract.sh | 39 -
ci/disaster-recovery-contract.sh | 93 --
ci/docker-plugin-install-contract.sh | 172 --
ci/filesystem-prep-contract.sh | 834 ----------
.../config-management-lifecycle-lock.sh | 56 -
ci/fixtures/lifecycle.d/default-up | 6 -
ci/gcp-upgrade-smoke-contract.sh | 4 +-
ci/host-runtime-security.sh | 565 +------
ci/inline-data-program-contract.sh | 66 -
ci/key-rotation-contract.sh | 1158 --------------
ci/lifecycle-program-contract.sh | 137 --
ci/managed-artifact-contract.sh | 189 +--
ci/overlay-contract.sh | 91 --
ci/package-rootfs.sh | 41 -
ci/remote/config-management-verify.sh | 49 +-
ci/rootfs-archive-path-contract.awk | 3 -
ci/rootfs-package-contract.sh | 72 -
ci/runtime-config-contract.sh | 138 +-
ci/sitectl-host-runtime-contract.sh | 69 +
ci/sitectl-version-contract.sh | 435 +-----
ci/source-trust-contract.sh | 474 +-----
ci/systemd-contract.sh | 88 +-
ci/vault-runtime-contract.sh | 204 +--
ci/verify-rootfs-release.sh | 125 --
docs/examples.md | 19 +-
docs/managed-runtime.md | 26 +-
docs/non-gcp-providers.md | 45 +-
docs/rollout.md | 6 +-
docs/runtime-contracts.md | 172 +-
examples/binpack/main.tf | 2 +-
examples/digitalocean/main.tf | 2 -
examples/digitalocean/variables.tf | 22 -
examples/linode/main.tf | 2 -
examples/linode/variables.tf | 22 -
.../contracttest/cloud_smoke_cleanup_test.go | 2 +-
.../contracttest/disaster_recovery_test.go | 63 +-
internal/contracttest/docker_prune_test.go | 87 --
internal/contracttest/rollout_parity_test.go | 3 +-
.../contracttest/template_version_test.go | 18 +-
main.tf | 2 -
modules/digitalocean/main.tf | 63 +-
.../digitalocean/runtime_inputs.tftest.hcl | 9 -
modules/digitalocean/variables.tf | 16 +-
modules/gcp/main.tf | 131 +-
modules/gcp/runtime_contracts.tftest.hcl | 147 +-
modules/gcp/variables.tf | 28 +-
modules/linode/main.tf | 61 +-
modules/linode/runtime_inputs.tftest.hcl | 52 +-
modules/linode/variables.tf | 16 +-
modules/linux-vm-runtime/main.tf | 117 +-
modules/linux-vm-runtime/outputs.tf | 22 +-
.../runtime_inputs.tftest.hcl | 294 +---
.../linux-vm-runtime/templates/cloud-init.yml | 28 +-
modules/linux-vm-runtime/variables.tf | 42 +-
providers/do/template_versions.tftest.hcl | 22 +-
providers/do/variables.tf | 14 +-
providers/gcp/main.tf | 2 -
providers/gcp/template_versions.tftest.hcl | 2 +-
providers/gcp/variables.tf | 14 +-
providers/linode/template_versions.tftest.hcl | 22 +-
providers/linode/variables.tf | 14 +-
.../awk/compose-secret-files.awk | 18 -
.../etc/cloud-compose/awk/reconcile-fstab.awk | 20 -
.../awk/release-checksum-entry.awk | 1 -
.../cloud-compose/awk/release-checksum.awk | 14 -
.../bin/cloud-compose-diagnostics.sh | 218 +--
.../jq/application-env-validate.jq | 6 -
.../cloud-compose/jq/array-values-base64.jq | 1 -
.../etc/cloud-compose/jq/compose-app-array.jq | 6 -
.../etc/cloud-compose/jq/compose-app-field.jq | 6 -
.../jq/compose-app-ingress-array.jq | 6 -
.../jq/compose-app-ingress-field.jq | 6 -
.../jq/compose-app-verify-args-json.jq | 8 -
.../jq/compose-app-verify-args.jq | 6 -
.../jq/compose-project-dirs-base64.jq | 1 -
.../jq/compose-reject-host-network.jq | 12 -
.../jq/compose-services-object-validate.jq | 1 -
.../jq/compose-validate-projects.jq | 26 -
.../cloud-compose/jq/diagnostics-entry-app.jq | 1 -
.../jq/diagnostics-entry-project-dir.jq | 1 -
.../jq/diagnostics-project-entries.jq | 1 -
.../diagnostics-validate-compose-projects.jq | 1 -
.../jq/dr-backup-completed-at.jq | 1 -
.../cloud-compose/jq/dr-backup-remote-id.jq | 1 -
.../jq/dr-restore-completed-at.jq | 1 -
.../jq/dr-restore-recovery-id.jq | 1 -
.../jq/dr-validate-backup-receipt.jq | 16 -
.../jq/dr-validate-restore-proof.jq | 19 -
.../jq/gcp-metadata-private-ip.jq | 1 -
.../jq/gcp-metadata-public-ip.jq | 1 -
.../jq/github-latest-release-tag.jq | 2 -
rootfs/etc/cloud-compose/jq/json-length.jq | 1 -
.../cloud-compose/jq/json-object-validate.jq | 1 -
.../cloud-compose/jq/nonempty-string-field.jq | 1 -
.../jq/object-entries-sorted-base64.jq | 1 -
.../jq/object-field-delimited.jq | 1 -
rootfs/etc/cloud-compose/jq/object-field.jq | 1 -
rootfs/etc/cloud-compose/jq/object-has-key.jq | 1 -
.../cloud-compose/jq/object-keys-base64.jq | 1 -
rootfs/etc/cloud-compose/jq/object-keys.jq | 1 -
.../cloud-compose/jq/offhost-bind-sources.jq | 1 -
.../jq/offhost-build-application-coverage.jq | 27 -
.../jq/offhost-build-manifest.jq | 10 -
.../jq/offhost-manifest-app-digests.jq | 1 -
.../jq/offhost-validate-bind-roots.jq | 8 -
.../jq/offhost-validate-compose-config.jq | 11 -
.../jq/offhost-validate-manifest.jq | 14 -
rootfs/etc/cloud-compose/jq/rotation-audit.jq | 12 -
.../cloud-compose/jq/rotation-build-state.jq | 14 -
.../cloud-compose/jq/rotation-first-key-id.jq | 1 -
.../cloud-compose/jq/rotation-idle-audit.jq | 7 -
.../cloud-compose/jq/rotation-jwt-claims.jq | 7 -
.../cloud-compose/jq/rotation-key-disabled.jq | 1 -
.../cloud-compose/jq/rotation-key-ids-join.jq | 1 -
.../jq/rotation-key-names-base64.jq | 1 -
.../jq/rotation-key-names-sorted.jq | 1 -
.../jq/rotation-key-object-ids-join.jq | 1 -
.../cloud-compose/jq/rotation-key-present.jq | 1 -
.../jq/rotation-new-key-names.jq | 1 -
.../jq/rotation-normalize-user-keys.jq | 15 -
.../jq/rotation-remaining-baseline.jq | 1 -
.../jq/rotation-validate-state.jq | 20 -
.../jq/service-account-credentials-valid.jq | 8 -
.../jq/service-account-key-id.jq | 3 -
.../jq/sitectl-package-version.jq | 1 -
.../jq/sitectl-package-versions-validate.jq | 6 -
.../cloud-compose/jq/sitectl-verify-args.jq | 5 -
.../cloud-compose/jq/string-array-validate.jq | 1 -
.../cloud-compose/jq/string-field-valid.jq | 1 -
.../libexec/bootstrap-required.sh | 22 +-
.../libexec/bootstrap-security.sh | 124 --
.../libexec/bootstrap-sitectl.sh | 77 +
.../libexec/checked-programs.bash | 271 ----
.../libexec/gcp-cloud-init-finalize.sh | 10 +-
.../libexec/gcp-cloud-init-post-bootstrap.sh | 2 +-
.../libexec/gcp-filesystem-boot.sh | 70 -
.../libexec/harden-bootstrap-paths.sh | 107 +-
.../cloud-compose/libexec/install-rootfs.py | 114 ++
.../libexec/linux-vm-cloud-init.sh | 84 +-
.../libexec/require-bootstrap-ready.sh | 12 -
.../cloud-compose/libexec/rootfs-archive.sh | 354 -----
.../cloud-compose/libexec/run-bootstrap.sh | 9 -
.../libexec/run-lifecycle-program.sh | 93 --
.../cloud-compose/libexec/run-root-program.sh | 27 -
.../etc/cloud-compose/libexec/sitectl-host.sh | 18 +
.../libexec/start-cloud-compose-bootstrap.sh | 9 -
.../system/cloud-compose-bootstrap.service | 2 +-
.../system/cloud-compose-docker-prune.service | 2 +-
.../system/cloud-compose-key-rotation.service | 2 +-
.../cloud-compose-mariadb-backup.service | 2 +-
...loud-compose-metadata-firewall-pre.service | 2 +-
.../cloud-compose-metadata-firewall.service | 2 +-
.../cloud-compose-offhost-backup.service | 2 +-
.../system/cloud-compose-overlay.service | 3 +-
.../system/cloud-compose-restore-test.service | 2 +-
.../system/cloud-compose-rollout.service | 2 +-
.../system/cloud-compose-vault-agent.service | 6 +-
.../etc/systemd/system/cloud-compose.service | 4 +-
.../system/libops-managed-runtime.service | 15 -
.../system/libops-managed-runtime.timer | 12 -
rootfs/home/cloud-compose/app-init.sh | 36 -
rootfs/home/cloud-compose/app-rollout.sh | 8 -
.../cloud-compose/assert-app-initialized.sh | 37 -
.../home/cloud-compose/assert-vault-ready.sh | 47 -
.../home/cloud-compose/bootstrap-helpers.sh | 202 ---
rootfs/home/cloud-compose/compose-apps.sh | 1193 --------------
rootfs/home/cloud-compose/compose-dispatch.sh | 39 -
.../configure-metadata-firewall.sh | 107 --
.../cloud-compose/converge-app-filesystems.sh | 48 -
.../home/cloud-compose/default-lifecycle.sh | 107 --
.../cloud-compose/disaster-recovery-lib.sh | 227 ---
rootfs/home/cloud-compose/docker-prune.sh | 55 -
rootfs/home/cloud-compose/host-conf.sh | 10 +-
rootfs/home/cloud-compose/host-init.sh | 143 --
.../install-dependencies-debian.sh | 23 +-
.../cloud-compose/install-docker-plugins.sh | 144 +-
.../cloud-compose/libops-managed-runtime.sh | 1056 -------------
.../cloud-compose/lifecycle-entrypoint.sh | 7 +-
rootfs/home/cloud-compose/mariadb-backup.sh | 132 --
.../migrate-legacy-systemd-units.sh | 45 -
rootfs/home/cloud-compose/mount-overlays.sh | 55 -
rootfs/home/cloud-compose/offhost-backup.sh | 226 ---
rootfs/home/cloud-compose/overlay-init.sh | 172 --
.../home/cloud-compose/persist-filesystems.sh | 251 ---
.../home/cloud-compose/prepare-app-sources.sh | 43 -
.../home/cloud-compose/prepare-filesystem.sh | 482 ------
rootfs/home/cloud-compose/profile.sh | 437 +-----
rootfs/home/cloud-compose/restore-test.sh | 106 --
rootfs/home/cloud-compose/rotate-keys-app.sh | 409 +----
.../home/cloud-compose/rotate-keys-daily.sh | 51 -
.../cloud-compose/rotate-keys-internal.sh | 130 --
rootfs/home/cloud-compose/rotate-keys.sh | 1384 -----------------
rootfs/home/cloud-compose/run-bootstrap.sh | 10 -
.../home/cloud-compose/run-rollout-service.sh | 66 -
rootfs/home/cloud-compose/run.sh | 42 +-
.../start-cloud-compose-bootstrap.sh | 50 -
rootfs/home/cloud-compose/vault-agent-init.sh | 44 -
.../cloud-compose/vault-agent-readiness.sh | 127 --
salt/cloud-compose/README.md | 2 +-
salt/cloud-compose/init.sls | 69 +-
template_versions.tftest.hcl | 10 +-
templates/apps.json | 16 +-
templates/cloud-init.yml | 27 +-
templates/gcp-filesystem-boothook.sh.tftpl | 26 +-
tests/smoke/do/main.tf | 21 +-
tests/smoke/do/variables.tf | 20 -
tests/smoke/linode/main.tf | 21 +-
tests/smoke/linode/variables.tf | 20 -
tests/smoke/modules/context/main.tf | 6 -
tests/smoke/modules/context/variables.tf | 26 -
variables.tf | 14 +-
230 files changed, 1075 insertions(+), 17628 deletions(-)
delete mode 100644 .github/workflows/publish-rootfs.yaml
delete mode 100755 ci/app-filesystem-convergence-contract.sh
delete mode 100755 ci/backup-contract.sh
delete mode 100755 ci/bootstrap-recovery-contract.sh
delete mode 100755 ci/compose-runtime-contract.sh
delete mode 100755 ci/cos-jq-portability-contract.sh
delete mode 100755 ci/disaster-recovery-contract.sh
delete mode 100755 ci/docker-plugin-install-contract.sh
delete mode 100755 ci/filesystem-prep-contract.sh
delete mode 100755 ci/fixtures/config-management-lifecycle-lock.sh
delete mode 100755 ci/fixtures/lifecycle.d/default-up
delete mode 100755 ci/inline-data-program-contract.sh
delete mode 100755 ci/key-rotation-contract.sh
delete mode 100755 ci/lifecycle-program-contract.sh
delete mode 100755 ci/overlay-contract.sh
delete mode 100755 ci/package-rootfs.sh
delete mode 100644 ci/rootfs-archive-path-contract.awk
delete mode 100755 ci/rootfs-package-contract.sh
create mode 100755 ci/sitectl-host-runtime-contract.sh
delete mode 100755 ci/verify-rootfs-release.sh
delete mode 100644 internal/contracttest/docker_prune_test.go
delete mode 100644 rootfs/etc/cloud-compose/awk/compose-secret-files.awk
delete mode 100644 rootfs/etc/cloud-compose/awk/reconcile-fstab.awk
delete mode 100644 rootfs/etc/cloud-compose/awk/release-checksum-entry.awk
delete mode 100644 rootfs/etc/cloud-compose/awk/release-checksum.awk
delete mode 100644 rootfs/etc/cloud-compose/jq/application-env-validate.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/array-values-base64.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/compose-app-array.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/compose-app-field.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/compose-app-ingress-array.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/compose-app-ingress-field.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/compose-app-verify-args-json.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/compose-app-verify-args.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/compose-project-dirs-base64.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/compose-reject-host-network.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/compose-services-object-validate.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/compose-validate-projects.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/diagnostics-entry-app.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/diagnostics-entry-project-dir.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/diagnostics-project-entries.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/diagnostics-validate-compose-projects.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/dr-backup-completed-at.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/dr-backup-remote-id.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/dr-restore-completed-at.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/dr-restore-recovery-id.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/gcp-metadata-private-ip.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/gcp-metadata-public-ip.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/github-latest-release-tag.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/json-length.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/json-object-validate.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/nonempty-string-field.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/object-entries-sorted-base64.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/object-field-delimited.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/object-field.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/object-has-key.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/object-keys-base64.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/object-keys.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/offhost-bind-sources.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/offhost-build-manifest.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/offhost-manifest-app-digests.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/offhost-validate-bind-roots.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/offhost-validate-compose-config.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-audit.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-build-state.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-first-key-id.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-idle-audit.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-jwt-claims.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-key-disabled.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-key-ids-join.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-key-names-base64.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-key-names-sorted.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-key-object-ids-join.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-key-present.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-new-key-names.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-normalize-user-keys.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-remaining-baseline.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/rotation-validate-state.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/service-account-credentials-valid.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/service-account-key-id.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/sitectl-package-version.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/sitectl-package-versions-validate.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/string-array-validate.jq
delete mode 100644 rootfs/etc/cloud-compose/jq/string-field-valid.jq
delete mode 100755 rootfs/etc/cloud-compose/libexec/bootstrap-security.sh
create mode 100755 rootfs/etc/cloud-compose/libexec/bootstrap-sitectl.sh
delete mode 100644 rootfs/etc/cloud-compose/libexec/checked-programs.bash
delete mode 100755 rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh
create mode 100755 rootfs/etc/cloud-compose/libexec/install-rootfs.py
delete mode 100755 rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh
delete mode 100755 rootfs/etc/cloud-compose/libexec/rootfs-archive.sh
delete mode 100755 rootfs/etc/cloud-compose/libexec/run-bootstrap.sh
delete mode 100755 rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh
delete mode 100755 rootfs/etc/cloud-compose/libexec/run-root-program.sh
create mode 100755 rootfs/etc/cloud-compose/libexec/sitectl-host.sh
delete mode 100755 rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
delete mode 100644 rootfs/etc/systemd/system/libops-managed-runtime.service
delete mode 100644 rootfs/etc/systemd/system/libops-managed-runtime.timer
delete mode 100755 rootfs/home/cloud-compose/app-init.sh
delete mode 100755 rootfs/home/cloud-compose/app-rollout.sh
delete mode 100755 rootfs/home/cloud-compose/assert-app-initialized.sh
delete mode 100755 rootfs/home/cloud-compose/assert-vault-ready.sh
delete mode 100755 rootfs/home/cloud-compose/bootstrap-helpers.sh
delete mode 100755 rootfs/home/cloud-compose/compose-apps.sh
delete mode 100755 rootfs/home/cloud-compose/compose-dispatch.sh
delete mode 100755 rootfs/home/cloud-compose/configure-metadata-firewall.sh
delete mode 100755 rootfs/home/cloud-compose/converge-app-filesystems.sh
delete mode 100755 rootfs/home/cloud-compose/default-lifecycle.sh
delete mode 100755 rootfs/home/cloud-compose/disaster-recovery-lib.sh
delete mode 100755 rootfs/home/cloud-compose/docker-prune.sh
delete mode 100755 rootfs/home/cloud-compose/host-init.sh
delete mode 100755 rootfs/home/cloud-compose/libops-managed-runtime.sh
delete mode 100755 rootfs/home/cloud-compose/mariadb-backup.sh
delete mode 100755 rootfs/home/cloud-compose/migrate-legacy-systemd-units.sh
delete mode 100755 rootfs/home/cloud-compose/mount-overlays.sh
delete mode 100755 rootfs/home/cloud-compose/offhost-backup.sh
delete mode 100755 rootfs/home/cloud-compose/overlay-init.sh
delete mode 100755 rootfs/home/cloud-compose/persist-filesystems.sh
delete mode 100755 rootfs/home/cloud-compose/prepare-app-sources.sh
delete mode 100755 rootfs/home/cloud-compose/prepare-filesystem.sh
delete mode 100755 rootfs/home/cloud-compose/restore-test.sh
delete mode 100755 rootfs/home/cloud-compose/rotate-keys-daily.sh
delete mode 100755 rootfs/home/cloud-compose/rotate-keys-internal.sh
delete mode 100755 rootfs/home/cloud-compose/rotate-keys.sh
delete mode 100755 rootfs/home/cloud-compose/run-bootstrap.sh
delete mode 100755 rootfs/home/cloud-compose/run-rollout-service.sh
delete mode 100755 rootfs/home/cloud-compose/start-cloud-compose-bootstrap.sh
delete mode 100755 rootfs/home/cloud-compose/vault-agent-init.sh
delete mode 100755 rootfs/home/cloud-compose/vault-agent-readiness.sh
diff --git a/.github/workflows/cloud-smoke.yml b/.github/workflows/cloud-smoke.yml
index f8e1b8f..25eeaea 100644
--- a/.github/workflows/cloud-smoke.yml
+++ b/.github/workflows/cloud-smoke.yml
@@ -9,7 +9,7 @@ permissions:
contents: read
concurrency:
- group: cloud-compose-smoke
+ group: cloud-compose-smoke-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
diff --git a/.github/workflows/github-release.yaml b/.github/workflows/github-release.yaml
index dc04bf3..1416b8e 100644
--- a/.github/workflows/github-release.yaml
+++ b/.github/workflows/github-release.yaml
@@ -9,30 +9,7 @@ jobs:
release:
if: github.event.pull_request.merged == true && !contains(github.event.pull_request.title, 'skip-release')
uses: libops/.github/.github/workflows/bump-release.yaml@8dfaf9c854df71d9bbffde48c5676ff07c543c51
- with:
- workflow_file: publish-rootfs.yaml
permissions:
contents: write
actions: write
secrets: inherit
-
- verify-rootfs-assets:
- name: Verify rootfs release assets
- needs: release
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- steps:
- - name: Checkout merged source
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- with:
- fetch-depth: 0
- persist-credentials: false
- ref: ${{ github.event.pull_request.merge_commit_sha }}
-
- - name: Wait for and verify immutable rootfs assets
- env:
- GH_TOKEN: ${{ github.token }}
- run: >-
- ci/verify-rootfs-release.sh commit
- "${{ github.event.pull_request.merge_commit_sha }}"
diff --git a/.github/workflows/publish-rootfs.yaml b/.github/workflows/publish-rootfs.yaml
deleted file mode 100644
index 533bb12..0000000
--- a/.github/workflows/publish-rootfs.yaml
+++ /dev/null
@@ -1,36 +0,0 @@
-name: Publish rootfs release asset
-
-on:
- workflow_dispatch:
-
-permissions:
- contents: write
-
-jobs:
- publish:
- runs-on: ubuntu-24.04
- steps:
- - name: Checkout tagged source
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- with:
- persist-credentials: false
-
- - name: Build and verify canonical rootfs asset
- run: |
- ci/rootfs-package-contract.sh
- ci/package-rootfs.sh dist
-
- - name: Upload rootfs asset, archive checksum, and source contract
- env:
- GH_TOKEN: ${{ github.token }}
- run: >-
- gh release upload "$GITHUB_REF_NAME"
- dist/cloud-compose-rootfs.tar.gz
- dist/cloud-compose-rootfs.tar.gz.sha256
- dist/cloud-compose-rootfs.contract.sha256
- --clobber
-
- - name: Verify published rootfs release assets
- env:
- GH_TOKEN: ${{ github.token }}
- run: ci/verify-rootfs-release.sh tag "$GITHUB_REF_NAME"
diff --git a/Makefile b/Makefile
index 0443768..e494ce5 100644
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,33 @@
-.PHONY: lint lint-check actionlint shell-lint runtime-config-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract disaster-recovery-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract bootstrap-recovery-contract sitectl-version-contract go-fmt-check go-vet go-contracts template-version-contract rollout-parity-contract rootfs-package-contract host-runtime-security inline-data-program-contract cos-jq-portability-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract artifact-install-contract config-management-smoke cloud-compose-ci
-.PHONY: terraform-fmt terraform-fmt-check terraform-validate terraform-validate-contract terraform-lint-check terraform-docs terraform-docs-check
-.PHONY: config-management-cloud-smoke config-management-cloud-smoke-ansible-drupal config-management-cloud-smoke-salt-drupal
-.PHONY: destroy-config-management-cloud-smoke destroy-config-management-cloud-smoke-ansible-drupal destroy-config-management-cloud-smoke-salt-drupal
-.PHONY: smoke-test-clouds smoke-test smoke-test-digitalocean-isle smoke-test-digitalocean-wp smoke-test-linode-wp smoke-test-gcp-wp
-.PHONY: destroy-smoke destroy-smoke-digitalocean-isle destroy-smoke-digitalocean-wp destroy-smoke-linode-wp destroy-smoke-gcp-wp
-.PHONY: docs docs-docker-build docs-build docs-serve docs-preview docs-clean
+.PHONY: lint lint-check actionlint shell-lint
+.PHONY: runtime-config-contract application-env-contract sitectl-host-runtime-contract
+.PHONY: vault-runtime-contract
+.PHONY: managed-artifact-contract config-management-input-contract systemd-contract
+.PHONY: sitectl-version-contract
+.PHONY: host-runtime-security source-trust-contract
+.PHONY: template-version-contract rollout-parity-contract artifact-install-contract
+.PHONY: cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract
+.PHONY: go-fmt-check go-vet go-contracts cloud-compose-ci
+
+.PHONY: terraform-fmt terraform-fmt-check terraform-lint-check
+.PHONY: terraform-validate terraform-validate-contract
+.PHONY: terraform-docs terraform-docs-check
+
+.PHONY: config-management-smoke config-management-cloud-smoke
+.PHONY: config-management-cloud-smoke-ansible-drupal
+.PHONY: config-management-cloud-smoke-salt-drupal
+.PHONY: destroy-config-management-cloud-smoke
+.PHONY: destroy-config-management-cloud-smoke-ansible-drupal
+.PHONY: destroy-config-management-cloud-smoke-salt-drupal
+
+.PHONY: smoke-test-clouds smoke-test
+.PHONY: smoke-test-digitalocean-isle smoke-test-digitalocean-wp
+.PHONY: smoke-test-linode-wp smoke-test-gcp-wp
+.PHONY: destroy-smoke
+.PHONY: destroy-smoke-digitalocean-isle destroy-smoke-digitalocean-wp
+.PHONY: destroy-smoke-linode-wp destroy-smoke-gcp-wp
+
+.PHONY: docs docs-docker-build docs-build
+.PHONY: docs-serve docs-preview docs-clean
DOCS_IMAGE ?= cloud-compose-docs
DOCS_PORT ?= 8888
@@ -16,9 +39,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 inline-data-program-contract cos-jq-portability-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract disaster-recovery-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract bootstrap-recovery-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract sitectl-version-contract template-version-contract rollout-parity-contract rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check
+lint: terraform-fmt actionlint shell-lint host-runtime-security application-env-contract sitectl-host-runtime-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-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 artifact-install-contract terraform-validate terraform-docs-check
-lint-check: terraform-fmt-check actionlint shell-lint host-runtime-security inline-data-program-contract cos-jq-portability-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract disaster-recovery-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract bootstrap-recovery-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract sitectl-version-contract template-version-contract rollout-parity-contract rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check
+lint-check: terraform-fmt-check actionlint shell-lint host-runtime-security application-env-contract sitectl-host-runtime-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-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 artifact-install-contract terraform-validate terraform-docs-check
actionlint:
go run github.com/rhysd/actionlint/cmd/actionlint@$(ACTIONLINT_VERSION)
@@ -41,7 +64,7 @@ shell-lint:
@find . \
-path "./.terraform" -prune -o \
-path "./docs/site" -prune -o \
- -type f -name "*.sh" -print0 | xargs -0 shellcheck --severity=warning
+ -type f \( -name "*.sh" -o -name "*.bash" \) -print0 | xargs -0 shellcheck --severity=warning
runtime-config-contract:
bash ci/runtime-config-contract.sh
@@ -49,26 +72,8 @@ runtime-config-contract:
application-env-contract:
bash ci/application-env-contract.sh
-compose-runtime-contract:
- bash ci/compose-runtime-contract.sh
-
-app-filesystem-convergence-contract:
- bash ci/app-filesystem-convergence-contract.sh
-
-backup-contract:
- bash ci/backup-contract.sh
-
-disaster-recovery-contract:
- bash ci/disaster-recovery-contract.sh
-
-overlay-contract:
- bash ci/overlay-contract.sh
-
-filesystem-prep-contract:
- bash ci/filesystem-prep-contract.sh
-
-key-rotation-contract:
- bash ci/key-rotation-contract.sh
+sitectl-host-runtime-contract:
+ bash ci/sitectl-host-runtime-contract.sh
vault-runtime-contract:
bash ci/vault-runtime-contract.sh
@@ -82,9 +87,6 @@ config-management-input-contract:
systemd-contract:
bash ci/systemd-contract.sh
-bootstrap-recovery-contract:
- bash ci/bootstrap-recovery-contract.sh
-
sitectl-version-contract:
bash ci/sitectl-version-contract.sh
@@ -99,18 +101,9 @@ go-contracts: go-fmt-check go-vet
template-version-contract rollout-parity-contract: go-contracts
-rootfs-package-contract:
- bash ci/rootfs-package-contract.sh
-
host-runtime-security:
bash ci/host-runtime-security.sh
-inline-data-program-contract:
- bash ci/inline-data-program-contract.sh
-
-cos-jq-portability-contract:
- bash ci/cos-jq-portability-contract.sh
-
source-trust-contract:
bash ci/source-trust-contract.sh
@@ -126,7 +119,6 @@ gcp-upgrade-smoke-contract: cloud-compose-ci
bash ci/gcp-upgrade-smoke-contract.sh
artifact-install-contract:
- bash ci/docker-plugin-install-contract.sh
bash ci/cos-bootstrap-contract.sh
hosted-cleanup-retry-contract: go-contracts
diff --git a/README.md b/README.md
index 4fc7397..63246d3 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ Renovate tracks both the Compose tags and sitectl release tags in the registry.
Its pull requests are promotion candidates: keep the complete compatible set
together and require the hosted application smoke before merging them.
-All presets pin sitectl v1.9.1 plus the latest reviewed application plugin.
+All presets pin sitectl v1.12.7 plus the latest reviewed application plugin.
ISLE selects the `libops/isle` v1.3.1 template, ArchivesSpace selects v1.0.1,
WordPress selects v1.1.1, and Drupal, OJS, Omeka Classic, and Omeka S select
v1.2.1. Keep each preset's complete template and package set together when
@@ -81,7 +81,7 @@ No resources.
| [name](#input\_name) | Deployment name. | `string` | n/a | yes |
| [cloud\_provider](#input\_cloud\_provider) | Compatibility selector for the root GCP entrypoint. Use providers/do or providers/linode for other clouds. | `string` | `"gcp"` | no |
| [gcp](#input\_gcp) | Google Cloud infrastructure settings. |
object({
project_id = optional(string, "")
project_number = optional(string, "")
region = optional(string, "us-east5")
zone = optional(string, "us-east5-b")
identity = optional(object({
vm_service_account_email = optional(string, "")
app_service_account_email = optional(string, "")
app_credentials_enabled = optional(bool, false)
}), {})
instance = optional(object({
machine_type = optional(string, "n4-standard-2")
os = optional(string, "cos-125-19216-220-185")
production = optional(bool, false)
}), {})
disks = optional(object({
type = optional(string, "hyperdisk-balanced")
data_size_gb = optional(number, 20)
docker_volumes_size_gb = optional(number, 50)
}), {})
network = optional(object({
create = optional(bool, true)
project_id = optional(string, "")
name = optional(string, "")
subnetwork = optional(string, "")
ip_cidr_range = optional(string, "10.42.0.0/24")
mtu = optional(number, 1460)
power_button_allowed_ips = optional(list(string), [])
power_button_ip_depth = optional(number)
ssh_ipv4 = optional(list(string), [])
ssh_ipv6 = optional(list(string), [])
}), {})
snapshots = optional(object({
enabled = optional(bool, true)
}), {})
overlay = optional(object({
source_instance = optional(string, "")
volume_names = optional(list(string), [])
}), {})
cloud_init = optional(object({
initcmd = optional(list(string), [])
runcmd = optional(list(string), [])
}), {})
artifact_registry = optional(object({
repository = optional(string, "")
location = optional(string, "us")
}), {})
power_management = optional(object({
enabled = optional(bool, false)
start_role = optional(string, "")
suspend_role = optional(string, "")
frontend = optional(object({
image = string
port = optional(number, 8080)
cpu = optional(string, "1000m")
memory = optional(string, "1Gi")
}), null)
}), {})
rollout = optional(object({
enabled = optional(bool, false)
release_url = optional(string, "")
release_sha256 = optional(string, "")
port = optional(number, 8081)
jwks_uri = optional(string, "")
jwt_audience = optional(string, "")
custom_claims = optional(string, "")
allowed_ipv4 = optional(list(string), ["10.0.0.0/8"])
}), {})
}) | `{}` | no |
-| [runtime](#input\_runtime) | Provider-neutral compose/runtime settings. | object({
rootfs = optional(string, "")
rootfs_archive_url = optional(string, "")
rootfs_archive_sha256 = optional(string, "")
users = optional(map(list(string)), {})
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 |
+| [runtime](#input\_runtime) | Provider-neutral compose/runtime settings. | object({
rootfs = 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 4051c02..43539b0 100644
--- a/ansible/README.md
+++ b/ansible/README.md
@@ -63,7 +63,7 @@ Runtime feature switches must be YAML booleans, not quoted strings; ambiguous
values are rejected before host mutation.
Lifecycle list entries are program selectors, not shell source. Use the
-built-in `/home/cloud-compose/default-lifecycle.sh ACTION`, `true`, `false`, or
+built-in `sitectl:default ACTION`, `true`, `false`, or
one argument-free root-owned executable immediately below
`/etc/cloud-compose/lifecycle.d`. Put multi-step logic and quoting inside that
checked program file.
diff --git a/ansible/roles/cloud_compose/defaults/main.yml b/ansible/roles/cloud_compose/defaults/main.yml
index 716153d..dde691b 100644
--- a/ansible/roles/cloud_compose/defaults/main.yml
+++ b/ansible/roles/cloud_compose/defaults/main.yml
@@ -54,10 +54,10 @@ cloud_compose_default_ingress:
upload_timeout: ""
cloud_compose_default_init:
- - '/home/cloud-compose/default-lifecycle.sh init'
+ - 'sitectl:default init'
cloud_compose_default_up:
- - '/home/cloud-compose/default-lifecycle.sh up'
+ - 'sitectl:default up'
cloud_compose_default_down:
- - '/home/cloud-compose/default-lifecycle.sh down'
+ - 'sitectl:default down'
cloud_compose_default_rollout:
- - '/home/cloud-compose/default-lifecycle.sh rollout'
+ - 'sitectl:default rollout'
diff --git a/ansible/roles/cloud_compose/tasks/main.yml b/ansible/roles/cloud_compose/tasks/main.yml
index d4ce17e..7f7d1e4 100644
--- a/ansible/roles/cloud_compose/tasks/main.yml
+++ b/ansible/roles/cloud_compose/tasks/main.yml
@@ -493,6 +493,14 @@
groups: docker
append: true
+- name: Ensure cloud-compose mount parent is root-owned
+ ansible.builtin.file:
+ path: "{{ cloud_compose_data_dir | dirname }}"
+ state: directory
+ owner: root
+ group: root
+ mode: "0755"
+
- name: Ensure cloud-compose data directories exist
ansible.builtin.file:
path: "{{ item }}"
@@ -530,9 +538,7 @@
loop:
- "{{ cloud_compose_home }}"
- /etc/cloud-compose
- - /etc/cloud-compose/awk
- /etc/cloud-compose/bin
- - /etc/cloud-compose/jq
- /etc/cloud-compose/libexec
- name: Inspect cloud-compose lifecycle lock directory
@@ -583,46 +589,6 @@
mode: "0755"
loop: "{{ cloud_compose_shell_scripts.files }}"
-- name: Make checked Cloud Compose program resolver root-controlled
- ansible.builtin.file:
- path: /etc/cloud-compose/libexec/checked-programs.bash
- state: file
- owner: root
- group: root
- mode: "0644"
-
-- name: Find checked-in Cloud Compose jq programs
- ansible.builtin.find:
- paths: /etc/cloud-compose/jq
- patterns: "*.jq"
- recurse: false
- file_type: file
- register: cloud_compose_jq_programs
-
-- name: Make checked-in Cloud Compose jq programs root-controlled
- ansible.builtin.file:
- path: "{{ item.path }}"
- owner: root
- group: root
- mode: "0644"
- loop: "{{ cloud_compose_jq_programs.files }}"
-
-- name: Find checked-in Cloud Compose awk programs
- ansible.builtin.find:
- paths: /etc/cloud-compose/awk
- patterns: "*.awk"
- recurse: false
- file_type: file
- register: cloud_compose_awk_programs
-
-- name: Make checked-in Cloud Compose awk programs root-controlled
- ansible.builtin.file:
- path: "{{ item.path }}"
- owner: root
- group: root
- mode: "0644"
- loop: "{{ cloud_compose_awk_programs.files }}"
-
- name: Write cloud-compose lifecycle dispatchers
ansible.builtin.copy:
src: /home/cloud-compose/lifecycle-entrypoint.sh
@@ -671,6 +637,13 @@
mode: "0640"
no_log: true
+- name: Stage sitectl for privileged host bootstrap
+ ansible.builtin.command:
+ cmd: >-
+ /etc/cloud-compose/libexec/bootstrap-sitectl.sh
+ --version {{ _cc_sitectl_version | quote }}
+ when: cloud_compose_run_bootstrap | bool
+
- name: Harden trusted cloud-compose bootstrap paths
ansible.builtin.command:
cmd: /etc/cloud-compose/libexec/harden-bootstrap-paths.sh
@@ -683,7 +656,7 @@
- name: Install and start authenticated rollout service
ansible.builtin.command:
- cmd: bash /etc/cloud-compose/libexec/run-root-program.sh deploy-rollout.sh
+ cmd: bash /home/cloud-compose/deploy-rollout.sh
when: _cc_rollout_service.enabled | bool
- name: Clear cloud-compose bootstrap marker
@@ -694,7 +667,7 @@
- name: Inspect validated cloud-compose bootstrap readiness
ansible.builtin.command:
- cmd: bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh
+ cmd: /etc/cloud-compose/libexec/sitectl-host.sh marker valid /var/lib/cloud-compose/bootstrap-complete
register: cloud_compose_bootstrap_readiness
changed_when: false
failed_when: false
@@ -702,7 +675,7 @@
- name: Bootstrap cloud-compose runtime
ansible.builtin.command:
- cmd: bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
+ cmd: /etc/cloud-compose/libexec/sitectl-host.sh systemd ensure-bootstrap
async: "{{ cloud_compose_bootstrap_timeout }}"
poll: "{{ cloud_compose_bootstrap_poll_interval }}"
environment:
diff --git a/ci/app-filesystem-convergence-contract.sh b/ci/app-filesystem-convergence-contract.sh
deleted file mode 100755
index 494e7de..0000000
--- a/ci/app-filesystem-convergence-contract.sh
+++ /dev/null
@@ -1,168 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
-tmp="$(mktemp -d)"
-trap 'rm -rf "$tmp"' EXIT
-
-fail() {
- echo "app filesystem convergence contract: $*" >&2
- exit 1
-}
-
-export COMPOSE_PROJECTS_FILE="$tmp/compose-projects.json"
-export COMPOSE_APPS_ENV_DIR="$tmp/apps"
-export COMPOSE_APPS_STATE_DIR="$tmp/state"
-export CLOUD_COMPOSE_DATA_ROOT="$tmp/data"
-fixture_project_dir="$CLOUD_COMPOSE_DATA_ROOT/repository/app"
-mkdir -p "$fixture_project_dir"
-
-write_manifest() {
- local path="$1"
-
- jq -n --arg project_dir "$path" '{app: {
- docker_compose_repo: "https://github.com/libops/wp.git",
- docker_compose_branch: "main",
- project_dir: $project_dir,
- compose_project_name: "app"
- }}' >"$COMPOSE_PROJECTS_FILE"
-}
-
-# shellcheck disable=SC1091
-source "$repo_root/rootfs/home/cloud-compose/compose-apps.sh"
-
-write_manifest "$fixture_project_dir"
-printf 'PRESERVED=value\n' >"$fixture_project_dir/.env"
-chmod 0400 "$fixture_project_dir/.env"
-original_inode="$(stat -c '%d:%i' "$fixture_project_dir/.env")"
-runtime_uid="$(id -u)"
-runtime_gid="$(id -g)"
-_converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid"
-
-[[ "$(<"$fixture_project_dir/.env")" == "PRESERVED=value" ]] ||
- fail "environment contents changed during metadata repair"
-[[ "$(stat -c '%d:%i' "$fixture_project_dir/.env")" == "$original_inode" ]] ||
- fail "environment metadata repair replaced the file"
-[[ "$(stat -c '%u:%g:%a:%h' "$fixture_project_dir/.env")" == "${runtime_uid}:${runtime_gid}:640:1" ]] ||
- fail "environment metadata did not converge"
-[[ "$(stat -c '%u:%g:%a' "$fixture_project_dir")" == "${runtime_uid}:${runtime_gid}:775" ]] ||
- fail "exact manifest project directory metadata did not converge"
-
-# Convergence is idempotent and does not traverse application content.
-mkdir -p "$fixture_project_dir/preserved"
-printf 'unchanged\n' >"$fixture_project_dir/preserved/file"
-chmod 0600 "$fixture_project_dir/preserved/file"
-preserved_metadata="$(stat -c '%u:%g:%a:%i' "$fixture_project_dir/preserved/file")"
-_converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid"
-[[ "$(stat -c '%u:%g:%a:%i' "$fixture_project_dir/preserved/file")" == "$preserved_metadata" ]] ||
- fail "convergence changed a descendant outside the .env contract"
-
-# Hold convergence after its final read-only ownership transition while an
-# adversary tries to replace .env with a symlink. The attempted rename must be
-# denied, and the outside target must retain its original metadata and bytes.
-mkdir -p "$tmp/bin"
-real_chmod="$(command -v chmod)"
-export REAL_CHMOD="$real_chmod"
-export CONVERGENCE_CHMOD_COUNT="$tmp/chmod-count"
-export CONVERGENCE_FROZEN="$tmp/frozen"
-export CONVERGENCE_RELEASE="$tmp/release"
-cat >"$tmp/bin/chmod" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-"$REAL_CHMOD" "$@"
-if [[ "${1:-}" == "0555" ]]; then
- count=0
- [[ ! -f "$CONVERGENCE_CHMOD_COUNT" ]] || count="$(<"$CONVERGENCE_CHMOD_COUNT")"
- count=$((count + 1))
- printf '%s\n' "$count" >"$CONVERGENCE_CHMOD_COUNT"
- if [[ "$count" -eq 2 ]]; then
- : >"$CONVERGENCE_FROZEN"
- while [[ ! -f "$CONVERGENCE_RELEASE" ]]; do
- sleep 0.01
- done
- fi
-fi
-EOF
-chmod +x "$tmp/bin/chmod"
-PATH="$tmp/bin:$PATH"
-export PATH
-
-outside_target="$tmp/outside-target"
-printf 'outside-preserved\n' >"$outside_target"
-outside_metadata="$(stat -c '%u:%g:%a:%i' "$outside_target")"
-_converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid" &
-convergence_pid=$!
-for _ in $(seq 1 500); do
- [[ -f "$CONVERGENCE_FROZEN" ]] && break
- sleep 0.01
-done
-[[ -f "$CONVERGENCE_FROZEN" ]] || fail "convergence did not reach its frozen-directory state"
-if mv "$fixture_project_dir/.env" "$fixture_project_dir/.env.raced" 2>/dev/null; then
- ln -s "$outside_target" "$fixture_project_dir/.env"
- : >"$tmp/adversary-swapped"
-fi
-: >"$CONVERGENCE_RELEASE"
-wait "$convergence_pid"
-[[ ! -e "$tmp/adversary-swapped" ]] ||
- fail "an adversary replaced .env after the directory was frozen"
-[[ "$(<"$outside_target")" == "outside-preserved" ]] ||
- fail "adversarial symlink target contents changed"
-[[ "$(stat -c '%u:%g:%a:%i' "$outside_target")" == "$outside_metadata" ]] ||
- fail "adversarial symlink target metadata changed"
-
-PATH="${PATH#"$tmp/bin:"}"
-export PATH
-
-mv "$fixture_project_dir/.env" "$fixture_project_dir/.env.regular"
-ln -s "$fixture_project_dir/.env.regular" "$fixture_project_dir/.env"
-if _converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid" >/dev/null 2>&1; then
- fail "symbolic-link environment file was accepted"
-fi
-chmod 0775 "$fixture_project_dir"
-rm "$fixture_project_dir/.env"
-
-mkdir "$fixture_project_dir/.env"
-if _converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid" >/dev/null 2>&1; then
- fail "non-regular environment path was accepted"
-fi
-chmod 0775 "$fixture_project_dir"
-rmdir "$fixture_project_dir/.env"
-
-ln "$fixture_project_dir/.env.regular" "$fixture_project_dir/.env"
-if _converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid" >/dev/null 2>&1; then
- fail "hard-linked environment file was accepted"
-fi
-chmod 0775 "$fixture_project_dir"
-rm "$fixture_project_dir/.env"
-
-outside_project="$tmp/outside-project"
-mkdir "$outside_project"
-write_manifest "$outside_project"
-if _converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid" >/dev/null 2>&1; then
- fail "project outside the managed data boundary was accepted"
-fi
-
-inside_target="$CLOUD_COMPOSE_DATA_ROOT/inside-target"
-mkdir "$inside_target"
-ln -s "$inside_target" "$CLOUD_COMPOSE_DATA_ROOT/linked-project"
-write_manifest "$CLOUD_COMPOSE_DATA_ROOT/linked-project"
-if _converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid" >/dev/null 2>&1; then
- fail "symbolic-link project directory was accepted"
-fi
-
-missing_project="$CLOUD_COMPOSE_DATA_ROOT/missing/app"
-write_manifest "$missing_project"
-_converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid"
-[[ ! -e "$missing_project" ]] ||
- fail "convergence created a checkout before unprivileged source preparation"
-
-converge_line="$(grep -nF 'bash /home/cloud-compose/converge-app-filesystems.sh' \
- "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)"
-source_line="$(grep -nF 'run_as_cloud_compose bash /home/cloud-compose/prepare-app-sources.sh' \
- "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)"
-[[ -n "$converge_line" && -n "$source_line" && "$converge_line" -lt "$source_line" ]] ||
- fail "filesystem convergence does not precede unprivileged source preparation"
-
-echo "App filesystem convergence contract passed"
diff --git a/ci/application-env-contract.sh b/ci/application-env-contract.sh
index 118d629..1537539 100755
--- a/ci/application-env-contract.sh
+++ b/ci/application-env-contract.sh
@@ -3,235 +3,17 @@
set -euo pipefail
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
-tmp="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-application-env.XXXXXX")"
-trap 'rm -rf "$tmp"' EXIT
-
-require_cmd() {
- command -v "$1" >/dev/null 2>&1 || {
- echo "Missing required command: $1" >&2
- exit 1
- }
-}
-
-require_cmd bash
-require_cmd base64
-require_cmd jq
-
profile="$repo_root/rootfs/home/cloud-compose/profile.sh"
-jq_program_dir="$repo_root/rootfs/etc/cloud-compose/jq"
-rollout_service="$repo_root/rootfs/home/cloud-compose/run-rollout-service.sh"
-host_env="$tmp/host.env"
-application_env="$tmp/application-env.json"
-bash_env_payload="$tmp/untrusted-bash-env.sh"
-bash_env_marker="$tmp/bash-env-executed"
-
-cat >"$host_env" <<'EOF'
-HOME="/home/cloud-compose"
-ROLLOUT_PORT="8081"
-ROLLOUT_JWKS_URI="https://trusted.example/.well-known/jwks.json"
-ROLLOUT_JWT_AUD="trusted-controller"
-ROLLOUT_CUSTOM_CLAIMS="{\"role\":\"deployer\"}"
-EOF
-
-cat >"$bash_env_payload" <"$application_env"
-
-for app in alpha beta; do
- mkdir -p "$tmp/$app"
- cat >"$tmp/$app/.env" <<'EOF'
-# Downstream syntax is opaque and must survive reconciliation.
-DOMAIN=example.org
-EXPANDED=${DOMAIN}/path
-EOF
-done
-
-# The child shell receives file paths as positional parameters.
-# shellcheck disable=SC2016
-env -i \
- PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$jq_program_dir" \
- CLOUD_COMPOSE_ENV_FILE="$host_env" \
- CLOUD_COMPOSE_APPLICATION_ENV_FILE="$application_env" \
- bash --noprofile --norc -c '
- set -euo pipefail
- source "$1"
- shift
- for project_dir in "$@"; do
- cd "$project_dir"
- sync_compose_application_env
- update_compose_env COMPOSE_PROJECT_NAME "managed-${project_dir##*/}"
- update_compose_env SITE_NAME "site-${project_dir##*/}"
- update_compose_env COMPOSE_BIND_PORT "8080"
- done
- test -z "${BASH_ENV+x}"
- test -z "${LD_PRELOAD+x}"
- bash --noprofile --norc -c true
- ' application-env "$profile" "$tmp/alpha" "$tmp/beta"
-
-test ! -e "$bash_env_marker"
-
-assert_compose_value() {
- local env_file="$1" name="$2" expected="$3" assignment encoded decoded
-
- # The dollar expression belongs to awk.
- # shellcheck disable=SC2016
- assignment="$(awk -v marker="# cloud-compose application: $name" '
- found { print; exit }
- $0 == marker { found = 1 }
- ' "$env_file")"
- [[ "$assignment" == "$name="* ]] || {
- echo "$name was not reconciled into $env_file" >&2
- exit 1
- }
- encoded="${assignment#*=\"}"
- encoded="${encoded%\"}"
- # The child shell receives values as positional parameters.
- # shellcheck disable=SC2016
- decoded="$(
- CLOUD_COMPOSE_ENV_FILE="$host_env" bash --noprofile --norc -c '
- source "$1"
- decode_runtime_env_value "$2"
- printf "%s\037" "$RUNTIME_ENV_DECODED"
- ' application-env "$profile" "$encoded"
- )"
- decoded="${decoded%$'\x1f'}"
- [[ "$decoded" == "$expected" ]] || {
- echo "$name changed while being written to $env_file" >&2
- exit 1
- }
-}
-
-for app in alpha beta; do
- assert_compose_value "$tmp/$app/.env" BASH_ENV "$bash_env_payload"
- assert_compose_value "$tmp/$app/.env" LD_PRELOAD "$tmp/untrusted-preload.so"
- assert_compose_value "$tmp/$app/.env" PORT "9999"
- assert_compose_value "$tmp/$app/.env" JWKS_URI "https://attacker.invalid/jwks.json"
- assert_compose_value "$tmp/$app/.env" JWT_AUD "attacker"
- assert_compose_value "$tmp/$app/.env" CUSTOM_CLAIMS '{"admin":true}'
- assert_compose_value "$tmp/$app/.env" APPLICATION_LITERAL $'quotes " and dollars $HOME $(touch /tmp/never)\nline two\n'
- grep -Fq 'EXPANDED=${DOMAIN}/path' "$tmp/$app/.env"
- test "$(grep -Fc '# cloud-compose managed: COMPOSE_PROJECT_NAME' "$tmp/$app/.env")" = 1
- test "$(tail -n 1 "$tmp/$app/.env")" = 'COMPOSE_BIND_PORT="8080"'
-done
-mkdir -p "$tmp/invalid"
-for invalid_application_data in \
- '{"SAFE\n":"value"}' \
- '{"SAFE\u0000":"value"}'; do
- printf '%s' "$invalid_application_data" >"$tmp/application-env.invalid.json"
- if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_JQ_PROGRAM_DIR="$jq_program_dir" \
- CLOUD_COMPOSE_ENV_FILE="$host_env" \
- CLOUD_COMPOSE_APPLICATION_ENV_FILE="$tmp/application-env.invalid.json" \
- bash --noprofile --norc -c '
- source "$1"
- cd "$2"
- sync_compose_application_env
- ' application-env "$profile" "$tmp/invalid" >/dev/null 2>&1; then
- echo "Application environment accepted a control-character name" >&2
- exit 1
- fi
- test ! -e "$tmp/invalid/.env"
-done
-
-# Removing a key from desired state removes only its marked application entry.
-jq 'del(.APPLICATION_LITERAL)' "$application_env" >"$tmp/application-env.next.json"
-# The child shell receives file paths as positional parameters.
-# shellcheck disable=SC2016
-env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_JQ_PROGRAM_DIR="$jq_program_dir" \
- CLOUD_COMPOSE_ENV_FILE="$host_env" \
- CLOUD_COMPOSE_APPLICATION_ENV_FILE="$tmp/application-env.next.json" \
- bash --noprofile --norc -c '
- source "$1"
- cd "$2"
- sync_compose_application_env
- ' application-env "$profile" "$tmp/alpha"
-if grep -Fq '# cloud-compose application: APPLICATION_LITERAL' "$tmp/alpha/.env"; then
- echo "Removed application environment entry remains in the Compose .env file" >&2
+if grep -Eq '^(update_runtime_env|update_compose_env|sync_compose_application_env)\(' "$profile"; then
+ echo "profile still implements environment reconciliation" >&2
exit 1
fi
-grep -Fq '# cloud-compose managed: COMPOSE_BIND_PORT' "$tmp/alpha/.env"
-
-fake_rollout="$tmp/fake-rollout"
-rollout_result="$tmp/rollout-result"
-cat >"$fake_rollout" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-test -z "${BASH_ENV+x}"
-test -z "${LD_PRELOAD+x}"
-jq -n \
- --arg port "$PORT" \
- --arg jwks "$JWKS_URI" \
- --arg aud "$JWT_AUD" \
- --arg claims "$CUSTOM_CLAIMS" \
- '{port: $port, jwks: $jwks, aud: $aud, claims: $claims}' >"$ROLLOUT_RESULT"
-EOF
-chmod +x "$fake_rollout"
-
-env -i \
- PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_ENV_FILE="$host_env" \
- CLOUD_COMPOSE_PROFILE_PATH="$profile" \
- CLOUD_COMPOSE_ROLLOUT_BIN="$fake_rollout" \
- ROLLOUT_RESULT="$rollout_result" \
- bash --noprofile --norc "$rollout_service"
-
-jq -e '
- .port == "8081" and
- .jwks == "https://trusted.example/.well-known/jwks.json" and
- .aud == "trusted-controller" and
- .claims == "{\"role\":\"deployer\"}"
-' "$rollout_result" >/dev/null
-test ! -e "$bash_env_marker"
-
-insecure_rollout_env="$tmp/host-insecure-rollout.env"
-invalid_claims_env="$tmp/host-invalid-claims.env"
-sed 's#^ROLLOUT_JWKS_URI=.*#ROLLOUT_JWKS_URI="http://insecure.invalid/jwks.json"#' \
- "$host_env" >"$insecure_rollout_env"
-sed 's#^ROLLOUT_CUSTOM_CLAIMS=.*#ROLLOUT_CUSTOM_CLAIMS="[\\"not-an-object\\"]"#' \
- "$host_env" >"$invalid_claims_env"
-
-if env -i \
- PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_ENV_FILE="$insecure_rollout_env" \
- CLOUD_COMPOSE_PROFILE_PATH="$profile" \
- CLOUD_COMPOSE_ROLLOUT_BIN="$fake_rollout" \
- ROLLOUT_RESULT="$rollout_result" \
- bash --noprofile --norc "$rollout_service" >/dev/null 2>&1; then
- echo "Rollout runtime accepted an insecure JWKS URL" >&2
- exit 1
-fi
-
-if env -i \
- PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_ENV_FILE="$invalid_claims_env" \
- CLOUD_COMPOSE_PROFILE_PATH="$profile" \
- CLOUD_COMPOSE_ROLLOUT_BIN="$fake_rollout" \
- ROLLOUT_RESULT="$rollout_result" \
- bash --noprofile --norc "$rollout_service" >/dev/null 2>&1; then
- echo "Rollout runtime accepted non-object custom claims" >&2
+grep -Fq 'run_as_cloud_compose "$managed_sitectl" host apps lifecycle init' \
+ "$repo_root/rootfs/home/cloud-compose/run.sh" || {
+ echo "application initialization does not delegate environment reconciliation to sitectl" >&2
exit 1
-fi
+}
if grep -Eq '(^|[,[:space:]])var\.extra_env\)?$' \
"$repo_root/modules/gcp/main.tf" "$repo_root/modules/linux-vm-runtime/main.tf"; then
@@ -239,4 +21,4 @@ if grep -Eq '(^|[,[:space:]])var\.extra_env\)?$' \
exit 1
fi
-echo "Application environment trust-boundary contract passed"
+echo "Application environment boundary delegates to sitectl"
diff --git a/ci/backup-contract.sh b/ci/backup-contract.sh
deleted file mode 100755
index 3cbb2f0..0000000
--- a/ci/backup-contract.sh
+++ /dev/null
@@ -1,138 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-backup_script="$repo_root/rootfs/home/cloud-compose/mariadb-backup.sh"
-tmp="$(mktemp -d)"
-trap 'rm -rf -- "$tmp"' EXIT
-
-fail() {
- echo "backup contract: $*" >&2
- exit 1
-}
-
-mkdir -p "$tmp/bin"
-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"
- read -r -a result <<<"${FAKE_APPS:-alpha}"
-}
-source_compose_app_env() {
- SITECTL_CONTEXT_NAME="$1-context"
- export SITECTL_CONTEXT_NAME
-}
-EOF
-cat >"$tmp/bin/systemctl" <<'EOF'
-#!/usr/bin/env bash
-[[ "${1:-}" == "is-active" && "${FAKE_APP_ACTIVE:-true}" == "true" ]]
-EOF
-cat >"$tmp/bin/mv" <<'EOF'
-#!/usr/bin/env bash
-if [[ "${FAKE_MV_FAIL:-false}" == "true" ]]; then
- exit 1
-fi
-exec /usr/bin/mv "$@"
-EOF
-cat >"$tmp/bin/sitectl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-output=""
-while (($# > 0)); do
- if [[ "$1" == "--output" ]]; then
- output="$2"
- shift 2
- continue
- fi
- shift
-done
-[[ -n "$output" ]]
-printf 'CALL\n' >>"${SITECTL_LOG:?}"
-case "${FAKE_BACKUP_MODE:-success}" in
- success) printf 'SQL backup\n' | gzip -c >"$output" ;;
- fail-alpha)
- if [[ "$output" == */alpha/* ]]; then exit 1; fi
- printf 'SQL backup\n' | gzip -c >"$output"
- ;;
- partial) printf 'not gzip\n' >"$output"; exit 1 ;;
- *) exit 1 ;;
-esac
-EOF
-chmod +x "$tmp/bin/systemctl" "$tmp/bin/mv" "$tmp/bin/sitectl"
-
-export TEST_BIN="$tmp/bin"
-export LOCK_LOG="$tmp/lock.log"
-export SITECTL_LOG="$tmp/sitectl.log"
-export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh"
-export CLOUD_COMPOSE_COMPOSE_APPS_PATH="$tmp/compose-apps.sh"
-export MARIADB_BACKUP_ROOT="$tmp/backups"
-: >"$LOCK_LOG"
-: >"$SITECTL_LOG"
-
-FAKE_APP_ACTIVE=false bash "$backup_script"
-[[ ! -s "$SITECTL_LOG" ]] || fail "inactive application was backed up or started"
-grep -Fxq mariadb-backup "$LOCK_LOG" || fail "backup did not acquire the shared lifecycle lock"
-
-: >"$LOCK_LOG"
-FAKE_APP_ACTIVE=true bash "$backup_script"
-backup="$(find "$MARIADB_BACKUP_ROOT/alpha" -maxdepth 1 -type f -name '*.sql.gz' -print -quit)"
-[[ -n "$backup" && -s "$backup" ]] || fail "valid backup was not published"
-gzip -t -- "$backup" || fail "published backup is not valid gzip"
-[[ "$(find "$MARIADB_BACKUP_ROOT/alpha" -maxdepth 1 -type d -name '*.staging.*' | wc -l)" == 0 ]] || \
- fail "successful backup retained staging data"
-
-calls_before="$(wc -l <"$SITECTL_LOG")"
-FAKE_APP_ACTIVE=true bash "$backup_script"
-[[ "$(wc -l <"$SITECTL_LOG")" == "$calls_before" ]] || fail "validated existing backup was regenerated"
-
-rm -f -- "$backup"
-if FAKE_APP_ACTIVE=true FAKE_BACKUP_MODE=partial bash "$backup_script" >/dev/null 2>&1; then
- fail "partial backup command was accepted"
-fi
-[[ -z "$(find "$MARIADB_BACKUP_ROOT/alpha" -maxdepth 1 -type f -name '*.sql.gz' -print -quit)" ]] || \
- fail "partial backup was published as final"
-[[ "$(find "$MARIADB_BACKUP_ROOT/alpha" -maxdepth 1 -type d -name '*.staging.*' | wc -l)" == 0 ]] || \
- fail "failed backup retained staging data"
-
-if FAKE_APP_ACTIVE=true FAKE_MV_FAIL=true bash "$backup_script" >/dev/null 2>&1; then
- fail "failed final publish was reported as successful"
-fi
-[[ -z "$(find "$MARIADB_BACKUP_ROOT/alpha" -maxdepth 1 -type f -name '*.sql.gz' -print -quit)" ]] || \
- fail "failed final publish left a final backup artifact"
-
-printf 'corrupt\n' >"$MARIADB_BACKUP_ROOT/alpha/$(date -u +%Y%m%d)-alpha.sql.gz"
-if FAKE_APP_ACTIVE=true bash "$backup_script" >/dev/null 2>&1; then
- fail "corrupt existing backup was treated as complete"
-fi
-
-rm -rf -- "$MARIADB_BACKUP_ROOT"
-: >"$SITECTL_LOG"
-if FAKE_APP_ACTIVE=true FAKE_APPS='alpha beta' FAKE_BACKUP_MODE=fail-alpha \
- bash "$backup_script" >/dev/null 2>&1; then
- fail "one failed app did not produce an aggregate failure"
-fi
-[[ -s "$MARIADB_BACKUP_ROOT/beta/$(date -u +%Y%m%d)-beta.sql.gz" ]] || \
- fail "one failed app prevented a neighbor backup"
-
-old_backup="$MARIADB_BACKUP_ROOT/beta/20000101-beta.sql.gz"
-printf 'old backup\n' | gzip -c >"$old_backup"
-touch -d '30 days ago' "$old_backup"
-FAKE_APP_ACTIVE=true FAKE_APPS=beta MARIADB_BACKUP_RETENTION_DAYS=14 \
- bash "$backup_script" >/dev/null
-[[ ! -e "$old_backup" ]] || fail "expired backup was not pruned"
-
-if FAKE_APP_ACTIVE=true MARIADB_BACKUP_RETENTION_DAYS=zero \
- bash "$backup_script" >/dev/null 2>&1; then
- fail "invalid retention was accepted"
-fi
-
-echo "Backup contract passed"
diff --git a/ci/bootstrap-recovery-contract.sh b/ci/bootstrap-recovery-contract.sh
deleted file mode 100755
index 54712bb..0000000
--- a/ci/bootstrap-recovery-contract.sh
+++ /dev/null
@@ -1,572 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-helpers="$repo_root/rootfs/home/cloud-compose/bootstrap-helpers.sh"
-assert_initialized="$repo_root/rootfs/home/cloud-compose/assert-app-initialized.sh"
-start_bootstrap="$repo_root/rootfs/home/cloud-compose/start-cloud-compose-bootstrap.sh"
-run_script="$repo_root/rootfs/home/cloud-compose/run.sh"
-app_init="$repo_root/rootfs/home/cloud-compose/app-init.sh"
-run_bootstrap="$repo_root/rootfs/home/cloud-compose/run-bootstrap.sh"
-bootstrap_security="$repo_root/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh"
-bootstrap_required="$repo_root/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh"
-bootstrap_entrypoint="$repo_root/rootfs/etc/cloud-compose/libexec/run-bootstrap.sh"
-bootstrap_start_entrypoint="$repo_root/rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh"
-bootstrap_readiness_gate="$repo_root/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh"
-bootstrap_unit="$repo_root/rootfs/etc/systemd/system/cloud-compose-bootstrap.service"
-tmp="$(mktemp -d)"
-trap 'rm -rf "$tmp"' EXIT
-
-fail() {
- echo "bootstrap recovery contract: $*" >&2
- exit 1
-}
-
-assert_contains() {
- local file="$1" value="$2"
- grep -Fq -- "$value" "$file" || fail "$file does not contain: $value"
-}
-
-mkdir -p "$tmp/bin" "$tmp/run"
-systemctl_log="$tmp/systemctl.log"
-active_calls="$tmp/active-calls"
-: >"$systemctl_log"
-printf '0\n' >"$active_calls"
-cat >"$tmp/bin/systemctl" <<'EOF'
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-printf '%s\n' "$*" >>"${SYSTEMCTL_LOG:?}"
-case "$1" in
- show)
- property=""
- for arg in "$@"; do
- case "$arg" in
- --property=*) property="${arg#--property=}" ;;
- esac
- done
- case "$property" in
- LoadState)
- printf 'loaded\n'
- ;;
- ActiveState)
- calls="$(<"${ACTIVE_CALLS:?}")"
- calls=$((calls + 1))
- printf '%s\n' "$calls" >"$ACTIVE_CALLS"
- case "${SYSTEMD_SCENARIO:-recover}" in
- active) printf 'active\n' ;;
- forced)
- if [[ -n "${UNIT_STARTED_FILE:-}" &&
- -f "$UNIT_STARTED_FILE" ]]; then
- printf 'active\n'
- elif [[ -n "${UNIT_STOPPED_FILE:-}" &&
- -f "$UNIT_STOPPED_FILE" ]]; then
- printf 'inactive\n'
- else
- printf 'active\n'
- fi
- ;;
- recover)
- if ((calls == 1)); then
- printf 'failed\n'
- elif ((calls < 4)); then
- printf 'activating\n'
- else
- printf 'active\n'
- fi
- ;;
- failed) printf 'failed\n' ;;
- *) exit 2 ;;
- esac
- ;;
- *) exit 2 ;;
- esac
- ;;
- stop)
- if [[ -n "${UNIT_STOPPED_FILE:-}" ]]; then
- : >"$UNIT_STOPPED_FILE"
- rm -f -- "${UNIT_STARTED_FILE:-}"
- fi
- ;;
- start)
- if [[ -n "${UNIT_STARTED_FILE:-}" ]]; then
- : >"$UNIT_STARTED_FILE"
- fi
- if [[ -n "${START_MARKER:-}" ]]; then
- printf 'ready\n' >"$START_MARKER"
- fi
- ;;
- enable | reset-failed | restart | daemon-reload)
- ;;
- status)
- exit 3
- ;;
- *)
- exit 2
- ;;
-esac
-EOF
-cat >"$tmp/bin/sleep" <<'EOF'
-#!/usr/bin/env bash
-exit 0
-EOF
-chmod +x "$tmp/bin/systemctl" "$tmp/bin/sleep"
-
-# shellcheck disable=SC1090
-source "$helpers"
-PATH="$tmp/bin:/usr/bin:/bin"
-export PATH SYSTEMCTL_LOG="$systemctl_log" ACTIVE_CALLS="$active_calls"
-
-SYSTEMD_SCENARIO=recover
-export SYSTEMD_SCENARIO
-cloud_compose_start_and_wait_for_oneshot cloud-compose.service 10
-grep -Fxq 'enable -- cloud-compose.service' "$systemctl_log" ||
- fail "failed application unit was not enabled"
-grep -Fxq 'reset-failed -- cloud-compose.service' "$systemctl_log" ||
- fail "failed application unit was not reset"
-grep -Fxq 'start --no-block -- cloud-compose.service' "$systemctl_log" ||
- fail "application unit was not started nonblocking"
-[[ "$(<"$active_calls")" == "4" ]] ||
- fail "wait helper did not tolerate restart transitions until active"
-
-: >"$systemctl_log"
-printf '0\n' >"$active_calls"
-SYSTEMD_SCENARIO=active
-cloud_compose_start_systemd_unit cloud-compose.service
-if grep -Eq '^(reset-failed|start) ' "$systemctl_log"; then
- fail "already active application unit was restarted"
-fi
-
-if cloud_compose_start_systemd_unit ssh.service >/dev/null 2>&1; then
- fail "start helper accepted an unrelated systemd unit"
-fi
-printf '0\n' >"$active_calls"
-SYSTEMD_SCENARIO=failed
-if CLOUD_COMPOSE_SYSTEMD_POLL_SECONDS=1 \
- cloud_compose_wait_for_oneshot cloud-compose.service 2 >/dev/null 2>&1; then
- fail "wait helper accepted a unit that never became active"
-fi
-
-durable_marker="$tmp/bootstrap-complete"
-boot_marker="$tmp/run/app-init-complete"
-cloud_compose_should_run_app_init "$durable_marker" "$boot_marker" ||
- fail "first boot would skip application initialization"
-original_umask="$(umask)"
-cloud_compose_publish_marker "$boot_marker"
-[[ "$(umask)" == "$original_umask" ]] ||
- fail "publishing a marker changed the caller's umask"
-if cloud_compose_should_run_app_init "$durable_marker" "$boot_marker"; then
- fail "bootstrap retry would repeat successful application initialization"
-fi
-cloud_compose_publish_marker "$durable_marker"
-cloud_compose_should_run_app_init "$durable_marker" "$boot_marker" ||
- fail "manual convergence with durable readiness would skip application initialization"
-rm -f -- "$durable_marker" "$boot_marker"
-
-assert_env=(
- "CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH=$helpers"
- "CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER=$durable_marker"
- "CLOUD_COMPOSE_APP_INIT_MARKER=$boot_marker"
-)
-if env "${assert_env[@]}" bash "$assert_initialized" >/dev/null 2>&1; then
- fail "application preflight accepted missing initialization markers"
-fi
-cloud_compose_publish_marker "$boot_marker"
-env "${assert_env[@]}" bash "$assert_initialized"
-rm -f -- "$boot_marker"
-cloud_compose_publish_marker "$durable_marker"
-env "${assert_env[@]}" bash "$assert_initialized"
-rm -f -- "$durable_marker"
-ln -s /dev/null "$durable_marker"
-if env "${assert_env[@]}" bash "$assert_initialized" >/dev/null 2>&1; then
- fail "application preflight accepted a symlink marker"
-fi
-
-assert_contains "$app_init" 'acquire_cloud_compose_lifecycle_lock "init"'
-assert_contains "$app_init" 'trap release_cloud_compose_lifecycle_lock EXIT'
-assert_contains "$run_script" 'if cloud_compose_should_run_app_init'
-assert_contains "$run_script" 'durable_bootstrap_marker="/var/lib/cloud-compose/bootstrap-complete"'
-assert_contains "$run_script" 'cloud_compose_publish_marker "$current_boot_app_init_marker"'
-assert_contains "$run_script" 'cloud_compose_start_and_wait_for_oneshot cloud-compose.service "$app_wait_seconds"'
-assert_contains "$run_script" '"$fresh_filesystem_marker" "$fresh_filesystem_identity"'
-assert_contains "$run_script" 'cloud_compose_publish_marker "$durable_bootstrap_marker"'
-assert_contains "$helpers" 'if ((EUID == 0)) && ! chown 0:0 "$tmp_marker"; then'
-assert_contains "$helpers" '0:0:644:1:regular file'
-assert_contains "$helpers" '0:0:755:directory'
-[[ "$(grep -Fc 'cloud_compose_consume_fresh_filesystem_marker \' "$run_script")" == "1" ]] ||
- fail "bootstrap must have one fresh-filesystem authority consumption boundary"
-rotation_line="$(grep -nF 'bash /home/cloud-compose/rotate-keys-daily.sh' "$run_script" | cut -d: -f1)"
-fresh_marker_line="$(grep -nF 'cloud_compose_consume_fresh_filesystem_marker \' "$run_script" | cut -d: -f1)"
-fresh_marker_sync_line="$(grep -nFx 'sync' "$run_script" | cut -d: -f1)"
-key_timer_line="$(grep -nF 'systemctl enable --now cloud-compose-key-rotation.timer' "$run_script" | cut -d: -f1)"
-vault_line="$(grep -nF 'bash /home/cloud-compose/vault-agent-init.sh' "$run_script" | cut -d: -f1)"
-init_line="$(grep -nF 'run_as_cloud_compose bash /home/cloud-compose/app-init.sh' "$run_script" | cut -d: -f1)"
-boot_marker_line="$(grep -nF 'cloud_compose_publish_marker "$current_boot_app_init_marker"' "$run_script" | cut -d: -f1)"
-app_line="$(grep -nF 'cloud_compose_start_and_wait_for_oneshot cloud-compose.service' "$run_script" | cut -d: -f1)"
-durable_marker_line="$(grep -nF 'cloud_compose_publish_marker "$durable_bootstrap_marker"' "$run_script" | cut -d: -f1)"
-((rotation_line < fresh_marker_line &&
- fresh_marker_line < fresh_marker_sync_line &&
- fresh_marker_sync_line < key_timer_line &&
- key_timer_line < vault_line &&
- vault_line < init_line &&
- init_line < boot_marker_line &&
- boot_marker_line < app_line &&
- app_line < durable_marker_line)) ||
- fail "bootstrap does not consume fresh authority durably before persistent rotation, Vault, and application initialization"
-
-assert_contains "$run_bootstrap" 'if ((EUID != 0)); then'
-assert_contains "$run_bootstrap" 'exec bash /home/cloud-compose/run.sh'
-assert_contains "$bootstrap_entrypoint" 'cloud_compose_secure_runtime_home'
-assert_contains "$bootstrap_entrypoint" 'exec /bin/bash /home/cloud-compose/run.sh'
-assert_contains "$bootstrap_start_entrypoint" 'cloud_compose_secure_runtime_home'
-assert_contains "$bootstrap_required" 'cloud_compose_bootstrap_marker_ready'
-assert_contains "$bootstrap_readiness_gate" 'cloud_compose_bootstrap_marker_ready'
-assert_contains "$bootstrap_security" '0:0:644:1:regular file'
-assert_contains "$bootstrap_security" '"$marker_size" == "6"'
-assert_contains "$bootstrap_security" '"$payload" == "ready"'
-assert_contains "$bootstrap_security" 'Cloud Compose control input is not root-controlled'
-assert_contains "$bootstrap_security" 'Cloud Compose lifecycle dispatcher is not root-controlled'
-assert_contains "$bootstrap_security" '"$CLOUD_COMPOSE_RUNTIME_HOME/default-lifecycle.sh"'
-if rg -n 'bootstrap\\.log|exec (>>|>)[^[:space:]]' "$run_bootstrap" >/dev/null; then
- fail "bootstrap wrapper writes an independently unbounded log file"
-fi
-assert_contains "$bootstrap_unit" 'StandardOutput=journal'
-assert_contains "$bootstrap_unit" 'StandardError=journal'
-assert_contains "$bootstrap_unit" 'SyslogIdentifier=cloud-compose-bootstrap'
-assert_contains "$bootstrap_unit" 'SyslogLevel=info'
-assert_contains "$bootstrap_unit" 'SyslogLevelPrefix=no'
-assert_contains "$bootstrap_unit" 'LogRateLimitIntervalSec=30s'
-assert_contains "$bootstrap_unit" 'LogRateLimitBurst=1000'
-assert_contains "$bootstrap_unit" 'UMask=0022'
-
-# The systemd bootstrap replaces cloud-init's direct execution and must retain
-# its ordinary runtime-directory semantics. In particular, the unprivileged
-# application user must be able to traverse the persistent target of the
-# published sitectl symlink after a first-boot root install.
-bootstrap_umask="$(sed -n 's/^UMask=//p' "$bootstrap_unit")"
-[[ "$bootstrap_umask" == "0022" ]] ||
- fail "bootstrap unit does not declare exactly one compatible umask"
-umask_fixture="$tmp/bootstrap-umask"
-(
- umask "$bootstrap_umask"
- mkdir -p "$umask_fixture/libops-managed/bin" "$umask_fixture/published"
- printf '#!/bin/sh\nexit 0\n' >"$umask_fixture/libops-managed/bin/sitectl"
- chmod +x "$umask_fixture/libops-managed/bin/sitectl"
- ln -s "$umask_fixture/libops-managed/bin/sitectl" "$umask_fixture/published/sitectl"
-)
-for traversable_dir in "$umask_fixture/libops-managed" "$umask_fixture/libops-managed/bin"; do
- mode="$(stat -c '%a' "$traversable_dir")"
- ((8#$mode & 0001)) ||
- fail "bootstrap umask makes the published sitectl target untraversable: $traversable_dir"
-done
-[[ -x "$umask_fixture/published/sitectl" ]] ||
- fail "published sitectl is not executable after first-boot installation"
-
-assert_contains "$start_bootstrap" 'CLOUD_COMPOSE_BOOTSTRAP_WAIT_SECONDS:-10800'
-assert_contains "$start_bootstrap" 'CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER:-/var/lib/cloud-compose/bootstrap-complete'
-assert_contains "$start_bootstrap" 'if cloud_compose_marker_exists "$durable_marker"; then'
-assert_contains "$start_bootstrap" 'systemctl daemon-reload'
-assert_contains "$start_bootstrap" 'systemctl stop -- "$bootstrap_unit"'
-assert_contains "$start_bootstrap" 'cloud_compose_start_and_wait_for_oneshot "$bootstrap_unit" "$wait_seconds"'
-if rg -n '_SYSTEMD_UNIT=cloud-compose-bootstrap\\.service' \
- "$repo_root/rootfs/etc/fluent-bit" >/dev/null; then
- fail "raw bootstrap output was added to Fluent Bit"
-fi
-
-for cloud_init_program in \
- "$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh" \
- "$repo_root/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh"; do
- assert_contains "$cloud_init_program" 'bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh'
- if grep -Fq 'bash /home/cloud-compose/run.sh > /home/cloud-compose/run.log 2>&1' \
- "$cloud_init_program"; then
- fail "$cloud_init_program bypasses the retryable bootstrap unit"
- fi
-done
-assert_contains "$repo_root/templates/cloud-init.yml" \
- '/var/lib/cloud-compose/bootstrap/gcp-cloud-init-finalize.sh'
-assert_contains "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml" \
- '/var/lib/cloud-compose/bootstrap/linux-vm-cloud-init.sh'
-
-: >"$systemctl_log"
-printf '0\n' >"$active_calls"
-SYSTEMD_SCENARIO=active
-cloud_compose_publish_marker "$durable_marker"
-CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH="$helpers" \
-CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER="$durable_marker" \
-SYSTEMD_SCENARIO="$SYSTEMD_SCENARIO" \
- bash "$start_bootstrap"
-[[ ! -s "$systemctl_log" ]] ||
- fail "completed bootstrap was started again"
-
-: >"$systemctl_log"
-printf '0\n' >"$active_calls"
-unit_stopped="$tmp/unit-stopped"
-unit_started="$tmp/unit-started"
-rm -f -- "$durable_marker" "$unit_stopped" "$unit_started"
-SYSTEMD_SCENARIO=forced \
-UNIT_STOPPED_FILE="$unit_stopped" \
-UNIT_STARTED_FILE="$unit_started" \
-START_MARKER="$durable_marker" \
-CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH="$helpers" \
-CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER="$durable_marker" \
- bash "$start_bootstrap"
-grep -Fxq 'stop -- cloud-compose-bootstrap.service' "$systemctl_log" ||
- fail "removed readiness marker did not stop a stale active bootstrap unit"
-grep -Fxq 'start --no-block -- cloud-compose-bootstrap.service' "$systemctl_log" ||
- fail "removed readiness marker did not start a fresh bootstrap unit"
-[[ -f "$durable_marker" ]] ||
- fail "fresh bootstrap unit did not restore durable readiness"
-rm -f -- "$durable_marker"
-
-# Execute the production run script from a relocated fixture so the contract can
-# inject deterministic host commands without writing to /home or /run. The
-# first application-service convergence fails after app-init succeeds. The
-# fresh reconciliation authority must already be consumed before that failure;
-# the second run reuses the current-boot init marker and publishes durable
-# readiness after the service recovers.
-integration_root="$tmp/integration"
-integration_home="$integration_root/home/cloud-compose"
-integration_run="$integration_root/run"
-integration_state="$integration_root/var/lib/cloud-compose"
-integration_durable_marker="$integration_state/bootstrap-complete"
-integration_bin="$integration_root/bin"
-integration_log="$integration_root/systemctl.log"
-app_init_count="$integration_root/app-init-count"
-fresh_data_root="$integration_root/data"
-fresh_marker="$fresh_data_root/.cloud-compose/fresh-filesystem"
-first_output="$integration_root/first-attempt.log"
-retry_output="$integration_root/retry-attempt.log"
-mkdir -p "$integration_home" "$integration_run" "$integration_state" "$integration_bin" "$(dirname -- "$fresh_marker")"
-: >"$integration_log"
-printf '0\n' >"$app_init_count"
-printf 'fresh\n' >"$fresh_marker"
-
-sed \
- -e "s#/home/cloud-compose#$integration_home#g" \
- -e "s#/run/cloud-compose-app-init-complete#$integration_run/app-init-complete#g" \
- -e "s#/var/lib/cloud-compose#$integration_state#g" \
- "$run_script" >"$integration_home/run.sh"
-cp "$helpers" "$integration_home/bootstrap-helpers.sh"
-cat >"$integration_home/profile.sh" <<'EOF'
-#!/usr/bin/env bash
-:
-EOF
-cat >"$integration_home/noop.sh" <<'EOF'
-#!/usr/bin/env bash
-exit 0
-EOF
-for script in \
- host-conf.sh \
- host-init.sh \
- converge-app-filesystems.sh \
- prepare-app-sources.sh \
- vault-agent-init.sh; do
- cp "$integration_home/noop.sh" "$integration_home/$script"
-done
-cat >"$integration_home/app-init.sh" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-count="$(<"${APP_INIT_COUNT_FILE:?}")"
-printf '%s\n' "$((count + 1))" >"$APP_INIT_COUNT_FILE"
-EOF
-cat >"$integration_bin/runuser" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-while (($# > 0)); do
- if [[ "$1" == "--" ]]; then
- shift
- exec "$@"
- fi
- shift
-done
-exit 2
-EOF
-cat >"$integration_bin/systemd-tmpfiles" <<'EOF'
-#!/usr/bin/env bash
-exit 0
-EOF
-cat >"$integration_bin/sleep" <<'EOF'
-#!/usr/bin/env bash
-exit 0
-EOF
-cat >"$integration_bin/sync" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-printf '%s sync\n' "${RUN_ATTEMPT:?}" >>"${INTEGRATION_SYSTEMCTL_LOG:?}"
-[[ "$RUN_ATTEMPT" != "sync-fail" ]]
-EOF
-cat >"$integration_bin/systemctl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-
-printf '%s %s\n' "${RUN_ATTEMPT:?}" "$*" >>"${INTEGRATION_SYSTEMCTL_LOG:?}"
-case "$1" in
- show)
- property=""
- for arg in "$@"; do
- case "$arg" in
- --property=*) property="${arg#--property=}" ;;
- esac
- done
- case "$property" in
- LoadState) printf 'loaded\n' ;;
- ActiveState)
- if [[ "$RUN_ATTEMPT" == "fail" ]]; then
- printf 'failed\n'
- else
- printf 'active\n'
- fi
- ;;
- *) exit 2 ;;
- esac
- ;;
- daemon-reload | disable | enable | reset-failed | restart | start)
- ;;
- status)
- exit 3
- ;;
- *)
- exit 2
- ;;
-esac
-EOF
-cat >"$integration_bin/stat" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-
-target="${!#}"
-format=""
-previous=""
-for argument in "$@"; do
- if [[ "$previous" == "-c" ]]; then
- format="$argument"
- fi
- previous="$argument"
-done
-marker="${INTEGRATION_FRESH_MARKER:?}"
-case "$target:$format" in
- "$(dirname -- "$marker"):%u:%g:%a")
- printf '%s\n' "${FRESH_MARKER_DIR_IDENTITY:-0:0:700}"
- ;;
- "$marker:%u:%g:%a:%h")
- printf '%s\n' "${FRESH_MARKER_IDENTITY:-0:0:600:1}"
- ;;
- *)
- exec /usr/bin/stat "$@"
- ;;
-esac
-EOF
-chmod +x "$integration_bin"/*
-
-integration_env=(
- "APP_INIT_COUNT_FILE=$app_init_count"
- "CLOUD_COMPOSE_APP_WAIT_SECONDS=1"
- "CLOUD_COMPOSE_SYSTEMD_POLL_SECONDS=1"
- "CLOUD_COMPOSE_PROVIDER=contract"
- "CLOUD_COMPOSE_DOCKER_PRUNE_ENABLED=false"
- "CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER=$fresh_marker"
- "LIBOPS_INTERNAL_SERVICES_ENABLED=false"
- "LIBOPS_MANAGED_RUNTIME_ENABLED=false"
- "INTEGRATION_FRESH_MARKER=$fresh_marker"
- "INTEGRATION_SYSTEMCTL_LOG=$integration_log"
- "PATH=$integration_bin:/usr/bin:/bin"
-)
-if env "${integration_env[@]}" RUN_ATTEMPT=fail \
- bash "$integration_home/run.sh" >"$first_output" 2>&1; then
- fail "first full bootstrap attempt accepted a failed application service"
-fi
-[[ "$(<"$app_init_count")" == "1" ]] ||
- fail "first full bootstrap attempt did not complete app-init exactly once"
-[[ -f "$integration_run/app-init-complete" ]] ||
- fail "failed first attempt did not retain current-boot app-init readiness"
-[[ ! -e "$integration_durable_marker" ]] ||
- fail "failed first attempt published durable bootstrap readiness"
-[[ ! -e "$fresh_marker" ]] ||
- fail "failed first attempt retained fresh-filesystem authority past key convergence"
-
-env "${integration_env[@]}" RUN_ATTEMPT=recover \
- bash "$integration_home/run.sh" >"$retry_output" 2>&1
-[[ "$(<"$app_init_count")" == "1" ]] ||
- fail "bootstrap retry repeated successful app-init"
-[[ -f "$integration_durable_marker" ]] ||
- fail "bootstrap retry did not publish durable readiness"
-[[ ! -e "$fresh_marker" ]] ||
- fail "successful bootstrap retry retained fresh-filesystem reconciliation authority"
-grep -Fq 'Application initialization already completed during this boot' "$retry_output" ||
- fail "bootstrap retry did not report reuse of successful app-init"
-grep -Fq 'fail reset-failed -- cloud-compose.service' "$integration_log" ||
- fail "first bootstrap attempt did not exercise failed service recovery"
-grep -Fq 'recover enable -- cloud-compose.service' "$integration_log" ||
- fail "bootstrap retry did not converge the application service"
-
-# Marker removal must be flushed before durable readiness can be republished.
-rm -f -- "$integration_durable_marker"
-printf 'fresh\n' >"$fresh_marker"
-if env "${integration_env[@]}" RUN_ATTEMPT=sync-fail \
- bash "$integration_home/run.sh" >/dev/null 2>&1; then
- fail "bootstrap accepted a failed post-consume durability barrier"
-fi
-[[ ! -e "$fresh_marker" ]] ||
- fail "post-consume durability coverage did not remove the fresh marker"
-[[ ! -e "$integration_durable_marker" ]] ||
- fail "failed post-consume durability barrier published readiness"
-env "${integration_env[@]}" RUN_ATTEMPT=recover \
- bash "$integration_home/run.sh" >/dev/null 2>&1
-[[ -f "$integration_durable_marker" ]] ||
- fail "bootstrap retry did not flush an already-absent marker before readiness"
-
-# GCP never falls back to the generic non-GCP marker identity. This check runs
-# before key rotation, so missing disk identity cannot reach IAM.
-rm -f -- "$integration_durable_marker"
-printf 'fresh\n' >"$fresh_marker"
-if env "${integration_env[@]}" CLOUD_COMPOSE_PROVIDER=gcp RUN_ATTEMPT=recover \
- bash "$integration_home/run.sh" >/dev/null 2>&1; then
- fail "GCP bootstrap accepted the generic fresh-filesystem identity"
-fi
-[[ -f "$fresh_marker" ]] ||
- fail "GCP bootstrap consumed generic fresh-filesystem authority"
-[[ ! -e "$integration_durable_marker" ]] ||
- fail "GCP bootstrap with generic authority published durable readiness"
-
-# A marker payload for another incarnation must fail before application
-# initialization or durable readiness.
-rm -f -- "$integration_durable_marker"
-printf 'v1:gcp-disk-id:111111111111111111\n' >"$fresh_marker"
-if env "${integration_env[@]}" RUN_ATTEMPT=recover \
- bash "$integration_home/run.sh" >/dev/null 2>&1; then
- fail "bootstrap accepted a mismatched fresh-filesystem marker payload"
-fi
-[[ -f "$fresh_marker" ]] ||
- fail "mismatched fresh-filesystem marker payload was consumed"
-[[ ! -e "$integration_durable_marker" ]] ||
- fail "mismatched fresh-filesystem marker payload published durable readiness"
-
-# Unsafe authority must fail closed before durable readiness. The current-boot
-# app-init marker remains valid, so these attempts exercise only the early
-# marker boundary rather than repeating application initialization.
-rm -f -- "$integration_durable_marker"
-printf 'fresh\n' >"$fresh_marker"
-if env "${integration_env[@]}" RUN_ATTEMPT=recover \
- FRESH_MARKER_IDENTITY=1000:1000:600:1 \
- bash "$integration_home/run.sh" >/dev/null 2>&1; then
- fail "bootstrap accepted a fresh-filesystem marker with unsafe ownership"
-fi
-[[ -f "$fresh_marker" ]] ||
- fail "unsafe fresh-filesystem marker was consumed"
-[[ ! -e "$integration_durable_marker" ]] ||
- fail "unsafe fresh-filesystem marker published durable readiness"
-
-rm -f -- "$fresh_marker"
-ln -s /dev/null "$fresh_marker"
-if env "${integration_env[@]}" RUN_ATTEMPT=recover \
- bash "$integration_home/run.sh" >/dev/null 2>&1; then
- fail "bootstrap accepted a symlink fresh-filesystem marker"
-fi
-[[ -L "$fresh_marker" ]] ||
- fail "symlink fresh-filesystem marker was consumed"
-[[ ! -e "$integration_durable_marker" ]] ||
- fail "symlink fresh-filesystem marker published durable readiness"
-
-echo "Bootstrap recovery contract passed"
diff --git a/ci/cloud-smoke.sh b/ci/cloud-smoke.sh
index ef5c654..a54ec37 100755
--- a/ci/cloud-smoke.sh
+++ b/ci/cloud-smoke.sh
@@ -599,45 +599,15 @@ run_healthcheck() {
--format table
}
-run_lifecycle_program_contract() {
+verify_sitectl_host_runtime() {
local home_dir="$1" key_path="$2" output_json="$3"
- local host port user remote_contract_dir remote_contract status
+ local host port user
host="$(jq -r '.host' "$output_json")"
port="$(jq -r '.ssh_port' "$output_json")"
user="$(jq -r '.ssh_user' "$output_json")"
- if ! remote_contract_dir="$(ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
- 'mktemp -d /mnt/disks/data/cloud-compose-hosted-contract.XXXXXX')"; then
- echo "Could not create the remote lifecycle contract directory on the executable data disk" >&2
- return 1
- fi
- if [[ ! "$remote_contract_dir" =~ ^/mnt/disks/data/cloud-compose-hosted-contract\.[A-Za-z0-9]+$ ]]; then
- echo "Remote lifecycle contract directory is unsafe: $remote_contract_dir" >&2
- return 1
- fi
-
- remote_contract="$remote_contract_dir/lifecycle-program-contract.sh"
- if ! ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
- "install -m 0700 /dev/stdin $remote_contract" \
- <"$repo_root/ci/lifecycle-program-contract.sh"; then
- echo "Could not install the remote lifecycle program contract" >&2
- ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
- "rm -f -- $remote_contract && rmdir -- $remote_contract_dir" || true
- return 1
- fi
-
- if ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
- "/bin/bash -- $remote_contract /home/cloud-compose/default-lifecycle.sh"; then
- status=0
- else
- status=$?
- echo "Remote lifecycle program contract failed with status $status" >&2
- fi
- if ! ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
- "rm -f -- $remote_contract && rmdir -- $remote_contract_dir"; then
- echo "Could not remove the remote lifecycle program contract" >&2
- fi
- return "$status"
+ ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
+ 'test -x /home/cloud-compose/bin/sitectl && test -x /home/cloud-compose/lifecycle-entrypoint.sh && /home/cloud-compose/bin/sitectl --version'
}
run_target() (
@@ -737,7 +707,7 @@ run_target() (
return 1
fi
- if ! run_lifecycle_program_contract "$home_dir" "$key_path" "$output_json"; then
+ if ! verify_sitectl_host_runtime "$home_dir" "$key_path" "$output_json"; then
dump_remote_logs "$home_dir" "$key_path" "$host" "$port" "$user" "$project_dir"
return 1
fi
diff --git a/ci/compose-runtime-contract.sh b/ci/compose-runtime-contract.sh
deleted file mode 100755
index b61ccea..0000000
--- a/ci/compose-runtime-contract.sh
+++ /dev/null
@@ -1,329 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-tmp="$(mktemp -d)"
-trap 'rm -rf "$tmp"' EXIT
-
-fail() {
- echo "compose runtime contract: $*" >&2
- exit 1
-}
-
-lifecycle_program="$repo_root/rootfs/home/cloud-compose/default-lifecycle.sh"
-for defaults_file in \
- modules/gcp/variables.tf \
- modules/linux-vm-runtime/variables.tf \
- ansible/roles/cloud_compose/defaults/main.yml \
- salt/cloud-compose/init.sls; do
- for action in init up down rollout; do
- if [[ "$(grep -Fc -- "/home/cloud-compose/default-lifecycle.sh $action" "$repo_root/$defaults_file")" -ne 1 ]]; then
- fail "$defaults_file does not invoke the $action lifecycle program exactly once"
- fi
- done
- if grep -Fq -- 'TARGET_REF=' "$repo_root/$defaults_file"; then
- fail "$defaults_file still splits rollout state across lifecycle command entries"
- fi
-done
-bash "$repo_root/ci/lifecycle-program-contract.sh" \
- "$lifecycle_program" \
- "$repo_root/rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq"
-grep -Fq -- 'run_lifecycle_program_contract "$home_dir" "$key_path" "$output_json"' \
- "$repo_root/ci/cloud-smoke.sh" || fail "provider smoke does not execute the lifecycle program contract"
-smoke_lifecycle_contract="$(sed -n '/^run_lifecycle_program_contract()/,/^}/p' "$repo_root/ci/cloud-smoke.sh")"
-grep -Fq -- 'mktemp -d /mnt/disks/data/cloud-compose-hosted-contract.XXXXXX' \
- <<<"$smoke_lifecycle_contract" || \
- fail "provider smoke does not stage its executable lifecycle contract on the data disk"
-grep -Fq -- '^/mnt/disks/data/cloud-compose-hosted-contract\.[A-Za-z0-9]+$' \
- <<<"$smoke_lifecycle_contract" || \
- fail "provider smoke does not validate the exact remote lifecycle contract directory"
-if grep -Fq -- '/tmp/cloud-compose-hosted-contract' <<<"$smoke_lifecycle_contract"; then
- fail "provider smoke still stages an executable lifecycle contract below /tmp"
-fi
-grep -Fq -- '/bin/bash -- $remote_contract /home/cloud-compose/default-lifecycle.sh' \
- <<<"$smoke_lifecycle_contract" || \
- fail "provider smoke does not open the checked-in contract through the fixed interpreter"
-if grep -Fq -- 'test -x /home/cloud-compose/default-lifecycle.sh' <<<"$smoke_lifecycle_contract"; then
- fail "provider smoke still rejects a lifecycle program solely because /home is noexec"
-fi
-lifecycle_contract="$repo_root/ci/lifecycle-program-contract.sh"
-grep -Fq -- 'trap report_unexpected_failure ERR' "$lifecycle_contract" || \
- fail "lifecycle program contract does not report unexpected command failures"
-grep -Fq -- '[[ -L "$lifecycle_program" ]]' "$lifecycle_contract" || \
- fail "lifecycle program contract accepts a redirected target"
-grep -Fq -- '[[ ! -r "$lifecycle_program" ]]' "$lifecycle_contract" || \
- fail "lifecycle program contract accepts an unreadable target"
-grep -Fq -- 'bash "$lifecycle_program_contract" /home/cloud-compose/default-lifecycle.sh' \
- "$repo_root/ci/remote/config-management-verify.sh" || \
- fail "config-management smoke does not execute the lifecycle program contract"
-
-grep -Fq 'cd "$script_dir"' "$repo_root/rootfs/home/cloud-compose/prepare-app-sources.sh" || \
- fail "source preparation does not enter an accessible working directory before dropping privileges"
-grep -Fq 'run_as_cloud_compose() (' "$repo_root/rootfs/home/cloud-compose/run.sh" || \
- fail "privilege-drop helper does not isolate its working-directory change"
-grep -Fq 'cd /home/cloud-compose' "$repo_root/rootfs/home/cloud-compose/run.sh" || \
- fail "privilege-drop helper can inherit an inaccessible caller working directory"
-if grep -Fq 'su -s /bin/bash -c' "$repo_root/rootfs/home/cloud-compose/run.sh"; then
- fail "privilege-drop helper still synthesizes a shell program through su"
-fi
-grep -Fq 'run_compose_lifecycle_executor "$lifecycle" "$command"' \
- "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" || \
- fail "Compose lifecycle entries do not pass through the checked executor"
-grep -Fq 'readonly COMPOSE_LIFECYCLE_EXECUTOR="/etc/cloud-compose/libexec/run-lifecycle-program.sh"' \
- "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" || \
- fail "Compose lifecycle entries do not use the canonical checked executor path"
-grep -Fq '"$COMPOSE_LIFECYCLE_EXECUTOR" "$@"' \
- "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" || \
- fail "Compose lifecycle executor wrapper does not invoke the canonical checked program"
-grep -Fq 'run_compose_lifecycle_executor --validate "$lifecycle" "$command" || return 1' \
- "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" || \
- fail "Compose lifecycle program sets are not validated before execution"
-if grep -Fq 'bash -c "$command"' "$repo_root/rootfs/home/cloud-compose/compose-apps.sh"; then
- fail "Compose lifecycle entries still execute as shell strings"
-fi
-lifecycle_executor="$repo_root/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh"
-grep -Fq 'if [[ "$program" == "/home/cloud-compose/default-lifecycle.sh" ]]; then' \
- "$lifecycle_executor" || \
- fail "checked executor does not isolate the built-in lifecycle program"
-grep -Fq 'exec /bin/bash -- "$program" "${program_args[@]}"' \
- "$lifecycle_executor" || \
- fail "checked executor cannot open the built-in lifecycle program from a noexec home mount"
-grep -Fq 'exec "$program" "${program_args[@]}"' "$lifecycle_executor" || \
- fail "checked executor does not preserve direct execution for custom lifecycle programs"
-if grep -Eq '/bin/bash[[:space:]]+-[^[:space:]]*c([[:space:]]|$)' "$lifecycle_executor"; then
- fail "checked executor evaluates a lifecycle entry as shell source"
-fi
-
-export COMPOSE_PROJECTS_FILE="$tmp/compose-projects.json"
-export COMPOSE_APPS_ENV_DIR="$tmp/apps"
-export COMPOSE_APPS_STATE_DIR="$tmp/state"
-export CLOUD_COMPOSE_DATA_ROOT="$tmp/data"
-export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
-mkdir -p "$CLOUD_COMPOSE_DATA_ROOT/project"
-
-# shellcheck disable=SC1091
-source "$repo_root/rootfs/home/cloud-compose/compose-apps.sh"
-
-# Exercise the checked-in executor while retaining the immutable production
-# path contract above. The production image installs the same file at the
-# canonical /etc location.
-export CLOUD_COMPOSE_TEST_LIFECYCLE_EXECUTOR="$repo_root/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh"
-# shellcheck disable=SC1091
-source "$repo_root/ci/fixtures/checked-lifecycle-executor.sh"
-
-cat >"$CLOUD_COMPOSE_DATA_ROOT/project/compose.yaml" <<'EOF'
-secrets:
- DB_ROOT_PASSWORD:
- file: ./secrets/DB_ROOT_PASSWORD
- WORDPRESS_DB_PASSWORD:
- file: ./secrets/WORDPRESS_DB_PASSWORD
-EOF
-mapfile -t compose_secrets < <(
- cd "$CLOUD_COMPOSE_DATA_ROOT/project"
- compose_secret_files
-)
-[[ "${compose_secrets[*]}" == "./secrets/DB_ROOT_PASSWORD ./secrets/WORDPRESS_DB_PASSWORD" ]] || \
- fail "Compose Specification compose.yaml secret files were not discovered"
-
-jq -n \
- --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" \
- --arg trailing_command $'printf "trailing newline preserved"\n' '{
- app: {
- docker_compose_repo: "https://github.com/libops/wp.git",
- docker_compose_branch: "main",
- project_dir: $project_dir,
- compose_project_name: "app",
- sitectl_verify_args: ["--label", "value with spaces"],
- ingress: {trusted_ips: ["192.0.2.1/32"]},
- init_commands: ["printf \"line one\\nline two\\n\"", $trailing_command],
- up_commands: [],
- down_commands: [],
- rollout_commands: []
- }
-}' >"$COMPOSE_PROJECTS_FILE"
-
-apps=()
-compose_app_names_array apps
-[[ "${apps[*]}" == "app" ]] || fail "valid app names did not materialize"
-commands=()
-compose_app_array_values app init_commands commands
-[[ "${#commands[@]}" == 2 && "${commands[0]}" == *'line one\nline two'* ]] || \
- fail "lifecycle command boundaries were not preserved"
-[[ "${commands[1]}" == $'printf "trailing newline preserved"\n' ]] || \
- fail "a trailing lifecycle-command newline was normalized"
-write_compose_app_env app
-[[ -f "$COMPOSE_APPS_ENV_DIR/app.env" ]] || fail "validated app env was not written"
-
-printf '{' >"$COMPOSE_PROJECTS_FILE"
-if compose_app_names_array apps >/dev/null 2>&1; then
- fail "malformed JSON was accepted"
-fi
-
-jq -n --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" '{"../escape": {
- docker_compose_repo: "https://github.com/libops/wp.git",
- docker_compose_branch: "main",
- project_dir: $project_dir,
- compose_project_name: "escape"
-}}' >"$COMPOSE_PROJECTS_FILE"
-if compose_app_names_array apps >/dev/null 2>&1; then
- fail "path-traversing app name was accepted"
-fi
-[[ ! -e "$tmp/escape.env" ]] || fail "invalid app name escaped the app env directory"
-
-for unsafe_app_json in \
- '{"app\n":{"docker_compose_repo":"https://github.com/libops/wp.git","docker_compose_branch":"main","project_dir":"PROJECT_DIR","compose_project_name":"app"}}' \
- '{"app\u0000":{"docker_compose_repo":"https://github.com/libops/wp.git","docker_compose_branch":"main","project_dir":"PROJECT_DIR","compose_project_name":"app"}}'; do
- printf '%s' "${unsafe_app_json//PROJECT_DIR/$CLOUD_COMPOSE_DATA_ROOT\/project}" >"$COMPOSE_PROJECTS_FILE"
- if compose_app_names_array apps >/dev/null 2>&1; then
- fail "control-character app name was accepted"
- fi
-done
-
-jq -n --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" '{app: {
- docker_compose_repo: "https://github.com/libops/wp.git",
- docker_compose_branch: "main",
- project_dir: $project_dir,
- compose_project_name: "app",
- up_commands: "not-an-array"
-}}' >"$COMPOSE_PROJECTS_FILE"
-if compose_app_names_array apps >/dev/null 2>&1; then
- fail "invalid lifecycle command type was accepted"
-fi
-
-for unsafe_project_dir in "/" "/etc" "$CLOUD_COMPOSE_DATA_ROOT" "$CLOUD_COMPOSE_DATA_ROOT/../escape" "$CLOUD_COMPOSE_DATA_ROOT//escape" "$CLOUD_COMPOSE_DATA_ROOT/project"$'\n'; do
- jq -n --arg project_dir "$unsafe_project_dir" '{app: {
- docker_compose_repo: "https://github.com/libops/wp.git",
- docker_compose_branch: "main",
- project_dir: $project_dir,
- compose_project_name: "app"
- }}' >"$COMPOSE_PROJECTS_FILE"
- if compose_app_names_array apps >/dev/null 2>&1; then
- fail "unsafe project directory was accepted: $unsafe_project_dir"
- fi
-done
-
-ln -s "$tmp" "$CLOUD_COMPOSE_DATA_ROOT/outside-link"
-jq -n --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/outside-link/escape" '{app: {
- docker_compose_repo: "https://github.com/libops/wp.git",
- docker_compose_branch: "main",
- project_dir: $project_dir,
- compose_project_name: "app"
-}}' >"$COMPOSE_PROJECTS_FILE"
-if compose_app_names_array apps >/dev/null 2>&1; then
- fail "symlink-escaping project directory was accepted"
-fi
-
-mkdir -p "$tmp/bin"
-cat >"$tmp/bin/docker" <<'EOF'
-#!/usr/bin/env bash
-printf '%s\n' "${FAKE_COMPOSE_CONFIG:?}"
-EOF
-chmod +x "$tmp/bin/docker"
-PATH="$tmp/bin:$PATH"
-export PATH CLOUD_COMPOSE_PROVIDER=gcp
-FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{network_mode:"host"}}}')"
-export FAKE_COMPOSE_CONFIG
-if reject_host_network_compose_services >/dev/null 2>&1; then
- fail "GCP host-network Compose service was accepted"
-fi
-FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{network_mode:"default"}}}')"
-export FAKE_COMPOSE_CONFIG
-reject_host_network_compose_services
-
-checked_jq_program_dir="$CLOUD_COMPOSE_JQ_PROGRAM_DIR"
-CLOUD_COMPOSE_JQ_PROGRAM_DIR="$tmp/missing-jq-programs"
-if reject_host_network_compose_services >/dev/null 2>&1; then
- fail "GCP metadata isolation accepted a missing checked jq program"
-fi
-mkdir -p "$tmp/invalid-jq-programs"
-cp "$repo_root/ci/fixtures/invalid-checked-program.jq" \
- "$tmp/invalid-jq-programs/compose-reject-host-network.jq"
-CLOUD_COMPOSE_JQ_PROGRAM_DIR="$tmp/invalid-jq-programs"
-if reject_host_network_compose_services >/dev/null 2>&1; then
- fail "GCP metadata isolation accepted an invalid checked jq program"
-fi
-CLOUD_COMPOSE_JQ_PROGRAM_DIR="$checked_jq_program_dir"
-export CLOUD_COMPOSE_JQ_PROGRAM_DIR
-
-FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{build:{context:".",network:"host"}}}}')"
-export FAKE_COMPOSE_CONFIG
-if reject_host_network_compose_services >/dev/null 2>&1; then
- fail "GCP host-network Compose build was accepted"
-fi
-
-FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{build:{context:".",entitlements:["network.host"]}}}}')"
-export FAKE_COMPOSE_CONFIG
-if reject_host_network_compose_services >/dev/null 2>&1; then
- fail "GCP host-network BuildKit entitlement was accepted"
-fi
-
-FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{build:{context:".",network:"default"}}}}')"
-export FAKE_COMPOSE_CONFIG
-reject_host_network_compose_services
-
-# Preserve list(string) verify arguments as argv. A value containing spaces is
-# one argument, not an unquoted scalar split by the lifecycle shell.
-jq -n \
- --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" \
- --arg up_program "$repo_root/ci/fixtures/lifecycle.d/default-up" \
- -f "$repo_root/ci/fixtures/verify-argv-compose-project.jq" >"$COMPOSE_PROJECTS_FILE"
-ln -s "$repo_root/ci/fixtures/sitectl-argv-log.sh" "$tmp/bin/sitectl"
-export SITECTL_ARGV_LOG="$tmp/sitectl.argv"
-export CLOUD_COMPOSE_LIFECYCLE_PROGRAM_DIR="$repo_root/ci/fixtures/lifecycle.d"
-export CLOUD_COMPOSE_SITECTL_VERIFY_ARGS_PROGRAM="$repo_root/rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq"
-clone_or_update_compose_app() { source_compose_app_env "$1"; }
-record_compose_app_head() { return 0; }
-
-# Even a default ingress configuration must initialize durable desired state;
-# otherwise sitectl verify correctly rejects the missing .libops/site.yaml.
-jq -n \
- --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" \
- -f "$repo_root/ci/fixtures/default-ingress-compose-project.jq" >"$COMPOSE_PROJECTS_FILE"
-write_compose_app_env app
-: >"$SITECTL_ARGV_LOG"
-configure_sitectl_app_features app
-cmp -s "$repo_root/ci/fixtures/default-ingress-sitectl.argv" "$SITECTL_ARGV_LOG" || \
- fail "default ingress did not initialize and converge sitectl component state"
-
-# The "core" plugin is cloud-compose's own default for any app without a
-# named CMS template (templates/apps.json). sitectl itself rejects "set" and
-# "converge" against a pluginless context, so configure_sitectl_app_features
-# must skip them instead of failing every app-init for a non-templated app.
-jq -n \
- --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" \
- -f "$repo_root/ci/fixtures/core-plugin-compose-project.jq" >"$COMPOSE_PROJECTS_FILE"
-write_compose_app_env app
-: >"$SITECTL_ARGV_LOG"
-configure_sitectl_app_features app
-[ ! -s "$SITECTL_ARGV_LOG" ] || \
- fail "core plugin (no component contract) invoked sitectl set/converge"
-
-jq -n \
- --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" \
- --arg up_program "$repo_root/ci/fixtures/lifecycle.d/default-up" \
- -f "$repo_root/ci/fixtures/verify-argv-compose-project.jq" >"$COMPOSE_PROJECTS_FILE"
-write_compose_app_env app
-: >"$SITECTL_ARGV_LOG"
-CLOUD_COMPOSE_PROVIDER=linode
-run_compose_app_lifecycle app up
-cat >"$tmp/expected.argv" <<'EOF'
-
-<--context>
-
-
-<-d>
-<--remove-orphans>
-
-<--context>
-
-<--persist>
-
-<--context>
-
-<--label>
-
-EOF
-cmp -s "$tmp/expected.argv" "$SITECTL_ARGV_LOG" || fail "sitectl verify argument boundaries were lost"
-
-echo "Compose runtime contract passed"
diff --git a/ci/config-management-cloud-smoke-inner.sh b/ci/config-management-cloud-smoke-inner.sh
index 0652f3a..e313df4 100755
--- a/ci/config-management-cloud-smoke-inner.sh
+++ b/ci/config-management-cloud-smoke-inner.sh
@@ -126,7 +126,7 @@ deploy_salt() {
}
verify_remote() {
- local remote_contract_dir lifecycle_contract runtime_state_contract verification_program
+ local remote_contract_dir runtime_state_contract verification_program
local remote_command status
remote_contract_dir="$(ssh "${ssh_opts[@]}" "$ssh_target" \
@@ -136,27 +136,19 @@ verify_remote() {
return 1
fi
- lifecycle_contract="$remote_contract_dir/lifecycle-program-contract.sh"
runtime_state_contract="$remote_contract_dir/config-management-runtime-state-contract.py"
verification_program="$remote_contract_dir/config-management-verify.sh"
- if ! ssh "${ssh_opts[@]}" "$ssh_target" \
- "install -m 0700 /dev/stdin $lifecycle_contract" \
- salt_first_mutation:
fail("Salt host-input validation does not precede its first host mutation")
-if "bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh" not in salt_state:
+if "/etc/cloud-compose/libexec/sitectl-host.sh systemd ensure-bootstrap" not in salt_state:
fail("Salt bypasses the retryable bootstrap service")
-if "bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh" not in salt_state:
+if "/etc/cloud-compose/libexec/sitectl-host.sh marker valid /var/lib/cloud-compose/bootstrap-complete" 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")
@@ -303,7 +300,7 @@ def artifact_case(label, field, value, expected):
fail("checked Salt deployment program does not bound the bootstrap service wait")
for marker in (
'python3 "$runtime_state_contract"',
- 'bash "$lifecycle_program_contract" /home/cloud-compose/default-lifecycle.sh',
+ '/home/cloud-compose/bin/sitectl --version >/dev/null',
'/home/cloud-compose/smoke-healthcheck.sh "$SMOKE_NAME"',
):
if marker not in remote_verification:
@@ -337,18 +334,8 @@ def artifact_case(label, field, value, expected):
fail("local adapter smoke container does not invoke its checked entrypoint directly")
if "bash -lc" in local_smoke_driver or "bash -c" in local_smoke_driver:
fail("local adapter smoke still embeds its container entrypoint as a Bash argument")
-if '"$fixture" hold "$profile" "$ready"' not in local_smoke_inner:
- fail("local adapter smoke does not invoke its checked lifecycle-lock fixture")
if "bash -lc" in local_smoke_inner or "bash -c" in local_smoke_inner:
fail("local adapter smoke still embeds lifecycle-lock programs as Bash arguments")
-for marker in (
- "acquire_cloud_compose_lifecycle_lock root-first-contract",
- "acquire_cloud_compose_lifecycle_lock contention-contract",
- "acquire_cloud_compose_lifecycle_lock subshell-contract",
- "acquire_cloud_compose_lifecycle_lock symlink-contract",
-):
- if marker not in local_lifecycle_fixture:
- fail(f"checked lifecycle-lock fixture is missing: {marker!r}")
for marker in (
"cloud-init status --long",
"cloud-compose-bootstrap.service",
diff --git a/ci/config-management-input-contract.sh b/ci/config-management-input-contract.sh
index 3f23669..d396183 100755
--- a/ci/config-management-input-contract.sh
+++ b/ci/config-management-input-contract.sh
@@ -13,8 +13,7 @@ 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/etc/cloud-compose/libexec/run-root-program.sh"
-rollout_runner="bash /etc/cloud-compose/libexec/run-root-program.sh deploy-rollout.sh"
+rollout_runner="bash /home/cloud-compose/deploy-rollout.sh"
contract_fail() {
echo "config-management input contract: $1" >&2
@@ -22,35 +21,44 @@ contract_fail() {
}
grep -Fq "cmd: $rollout_runner" "$ansible_tasks" || \
- contract_fail "Ansible rollout does not use the trusted root-program runner"
-if grep -Fq 'cmd: bash "{{ cloud_compose_home }}/' "$ansible_tasks" || \
- grep -Fq 'cmd: bash /home/cloud-compose/' "$ansible_tasks"; then
- contract_fail "Ansible rollout executes a root program directly from the runtime home"
-fi
+ contract_fail "Ansible rollout does not invoke the hardened rollout program"
grep -Fq 'cmd: /etc/cloud-compose/libexec/harden-bootstrap-paths.sh' "$ansible_tasks" || \
contract_fail "Ansible does not apply the shared bootstrap path hardener"
grep -Fq -- '- name: /etc/cloud-compose/libexec/harden-bootstrap-paths.sh' "$salt_state" || \
contract_fail "Salt does not apply the shared bootstrap path hardener"
+ansible_sitectl_line="$(grep -n -m1 '/etc/cloud-compose/libexec/bootstrap-sitectl.sh' "$ansible_tasks" | cut -d: -f1)"
+ansible_hardener_line="$(grep -n -m1 'cmd: /etc/cloud-compose/libexec/harden-bootstrap-paths.sh' "$ansible_tasks" | cut -d: -f1)"
+[[ -n "$ansible_sitectl_line" && "$ansible_sitectl_line" -lt "$ansible_hardener_line" ]] || \
+ contract_fail "Ansible does not stage sitectl before invoking sitectl-backed hardening"
+grep -Fq -- '--version {{ _cc_sitectl_version | quote }}' "$ansible_tasks" || \
+ contract_fail "Ansible does not stage the configured sitectl version"
+
+salt_sitectl_block="$(sed -n '/^cloud-compose-bootstrap-sitectl:/,/^[^[:space:]]/p' "$salt_state")"
+grep -Fq -- '--version {{ sitectl_version | json }}' <<<"$salt_sitectl_block" || \
+ contract_fail "Salt does not stage the configured sitectl version"
+salt_hardener_block="$(sed -n '/^cloud-compose-bootstrap-paths-hardened:/,/^[^[:space:]]/p' "$salt_state")"
+grep -Fq -- '- cmd: cloud-compose-bootstrap-sitectl' <<<"$salt_hardener_block" || \
+ contract_fail "Salt does not stage sitectl before invoking sitectl-backed hardening"
+
ansible_privileged_block="$(sed -n '/^- name: Secure Cloud Compose privileged program directories/,/^- name:/p' "$ansible_tasks")"
grep -Fq -- '- "{{ cloud_compose_home }}"' <<<"$ansible_privileged_block" || \
contract_fail "Ansible does not close the account-owned runtime-home window after installing rootfs"
salt_privileged_block="$(sed -n '/^cloud-compose-privileged-program-directories:/,/^[^[:space:]]/p' "$salt_state")"
grep -Fq -- '- {{ home | json }}' <<<"$salt_privileged_block" || \
contract_fail "Salt does not close the account-owned runtime-home window after installing rootfs"
-grep -Fq 'path: /etc/cloud-compose/libexec/checked-programs.bash' "$ansible_tasks" || \
- contract_fail "Ansible does not explicitly secure the checked program resolver"
-grep -Fq 'source: salt://rootfs/etc/cloud-compose/libexec/checked-programs.bash' "$salt_state" || \
- contract_fail "Salt does not explicitly secure the checked program resolver"
-
-grep -Fq 'configure-metadata-firewall.sh | deploy-rollout.sh | docker-prune.sh' "$root_program_runner" || \
- contract_fail "the trusted root-program runner does not allow deploy-rollout.sh"
-
+ansible_mount_parent_block="$(sed -n '/^- name: Ensure cloud-compose mount parent is root-owned/,/^- name:/p' "$ansible_tasks")"
+for marker in 'owner: root' 'group: root' 'mode: "0755"'; do
+ grep -Fq -- "$marker" <<<"$ansible_mount_parent_block" || \
+ contract_fail "Ansible mount parent is missing $marker"
+done
+salt_mount_parent_block="$(sed -n '/^cloud-compose-mount-parent:/,/^[^[:space:]]/p' "$salt_state")"
+for marker in '- name: /mnt/disks' '- user: root' '- group: root' "- mode: '0755'"; do
+ grep -Fq -- "$marker" <<<"$salt_mount_parent_block" || \
+ contract_fail "Salt mount parent is missing $marker"
+done
salt_rollout_block="$(sed -n '/^cloud-compose-rollout-service:/,/^{% endif %}$/p' "$salt_state")"
[[ -n "$salt_rollout_block" ]] || contract_fail "Salt rollout state is missing"
-if grep -Fq -- '- name: bash /home/cloud-compose/' "$salt_state"; then
- contract_fail "Salt rollout executes a root program directly from the runtime home"
-fi
for marker in \
"- name: $rollout_runner" \
'- file: cloud-compose-env' \
@@ -60,9 +68,6 @@ for marker in \
'- file: cloud-compose-rootfs' \
'- cmd: cloud-compose-lifecycle-lock' \
'- cmd: cloud-compose-rootfs-script-modes' \
- '- file: cloud-compose-checked-program-resolver' \
- '- cmd: cloud-compose-rootfs-jq-modes' \
- '- cmd: cloud-compose-rootfs-awk-modes' \
'- file: cloud-compose-lifecycle-init' \
'- file: cloud-compose-lifecycle-up' \
'- file: cloud-compose-lifecycle-down' \
diff --git a/ci/config-management-smoke-assert.py b/ci/config-management-smoke-assert.py
index 8a98b19..7393408 100644
--- a/ci/config-management-smoke-assert.py
+++ b/ci/config-management-smoke-assert.py
@@ -15,8 +15,6 @@
PRIVILEGED_PROGRAM_ROOT = Path("/etc/cloud-compose")
DIAGNOSTICS_PROGRAM = PRIVILEGED_PROGRAM_ROOT / "bin/cloud-compose-diagnostics.sh"
BOOTSTRAP_LIBEXEC = Path("/etc/cloud-compose/libexec")
-AWK_PROGRAM_DIR = PRIVILEGED_PROGRAM_ROOT / "awk"
-JQ_PROGRAM_DIR = PRIVILEGED_PROGRAM_ROOT / "jq"
def load_runtime_env(path: Path) -> dict[str, str]:
@@ -48,34 +46,15 @@ def assert_runtime_files() -> None:
RUNTIME_HOME / "up",
RUNTIME_HOME / "down",
RUNTIME_HOME / "rollout",
- RUNTIME_HOME / "default-lifecycle.sh",
RUNTIME_HOME / "lifecycle-entrypoint.sh",
RUNTIME_HOME / "run.sh",
- RUNTIME_HOME / "start-cloud-compose-bootstrap.sh",
BOOTSTRAP_LIBEXEC / "bootstrap-required.sh",
- BOOTSTRAP_LIBEXEC / "bootstrap-security.sh",
- BOOTSTRAP_LIBEXEC / "run-bootstrap.sh",
- BOOTSTRAP_LIBEXEC / "require-bootstrap-ready.sh",
- BOOTSTRAP_LIBEXEC / "run-root-program.sh",
- BOOTSTRAP_LIBEXEC / "start-cloud-compose-bootstrap.sh",
+ BOOTSTRAP_LIBEXEC / "sitectl-host.sh",
DIAGNOSTICS_PROGRAM,
]:
assert path.exists(), path
assert os.access(path, os.X_OK), path
- for path in [
- BOOTSTRAP_LIBEXEC / "checked-programs.bash",
- AWK_PROGRAM_DIR / "compose-secret-files.awk",
- AWK_PROGRAM_DIR / "reconcile-fstab.awk",
- AWK_PROGRAM_DIR / "release-checksum.awk",
- JQ_PROGRAM_DIR / "diagnostics-validate-compose-projects.jq",
- JQ_PROGRAM_DIR / "compose-validate-projects.jq",
- JQ_PROGRAM_DIR / "offhost-validate-manifest.jq",
- JQ_PROGRAM_DIR / "rotation-validate-state.jq",
- JQ_PROGRAM_DIR / "sitectl-verify-args.jq",
- ]:
- assert path.exists(), path
-
cloud_compose_gid = grp.getgrnam("cloud-compose").gr_gid
for path, expected_mode in {
RUNTIME_HOME / "init": 0o750,
@@ -104,21 +83,11 @@ def assert_runtime_files() -> None:
oct(stat.S_IMODE(metadata.st_mode)),
)
- checked_programs = BOOTSTRAP_LIBEXEC / "checked-programs.bash"
- checked_programs_metadata = checked_programs.lstat()
- assert not checked_programs.is_symlink()
- assert checked_programs_metadata.st_uid == 0
- assert checked_programs_metadata.st_gid == 0
- assert checked_programs_metadata.st_nlink == 1
- assert stat.S_IMODE(checked_programs_metadata.st_mode) == 0o644
-
for path in [
RUNTIME_HOME,
PRIVILEGED_PROGRAM_ROOT,
- AWK_PROGRAM_DIR,
DIAGNOSTICS_PROGRAM.parent,
BOOTSTRAP_LIBEXEC,
- JQ_PROGRAM_DIR,
]:
assert not path.is_symlink(), path
metadata = path.lstat()
@@ -135,39 +104,11 @@ def assert_runtime_files() -> None:
assert diagnostics_metadata.st_gid == 0
assert stat.S_IMODE(diagnostics_metadata.st_mode) == 0o755
- lifecycle_metadata = (RUNTIME_HOME / "default-lifecycle.sh").stat()
+ lifecycle_metadata = (RUNTIME_HOME / "lifecycle-entrypoint.sh").stat()
assert lifecycle_metadata.st_uid == 0
assert lifecycle_metadata.st_gid == 0
assert stat.S_IMODE(lifecycle_metadata.st_mode) == 0o755
- jq_programs = list(JQ_PROGRAM_DIR.glob("*.jq"))
- assert jq_programs
- for path in jq_programs:
- assert not path.is_symlink(), path
- metadata = path.lstat()
- assert stat.S_ISREG(metadata.st_mode), path
- assert metadata.st_nlink == 1, (path, metadata.st_nlink)
- assert metadata.st_uid == 0, (path, metadata.st_uid)
- assert metadata.st_gid == 0, (path, metadata.st_gid)
- assert stat.S_IMODE(metadata.st_mode) == 0o644, (
- path,
- oct(stat.S_IMODE(metadata.st_mode)),
- )
-
- awk_programs = list(AWK_PROGRAM_DIR.glob("*.awk"))
- assert awk_programs
- for path in awk_programs:
- assert not path.is_symlink(), path
- metadata = path.lstat()
- assert stat.S_ISREG(metadata.st_mode), path
- assert metadata.st_nlink == 1, (path, metadata.st_nlink)
- assert metadata.st_uid == 0, (path, metadata.st_uid)
- assert metadata.st_gid == 0, (path, metadata.st_gid)
- assert stat.S_IMODE(metadata.st_mode) == 0o644, (
- path,
- oct(stat.S_IMODE(metadata.st_mode)),
- )
-
def assert_ansible_runtime() -> None:
env = load_runtime_env(RUNTIME_HOME / ".env")
@@ -336,7 +277,7 @@ def assert_salt_runtime(
assert not Path("/tmp/cloud-compose-salt-command-injection").exists()
elif expected_name == "drupal-prod":
assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-drupal": "v1.5.0",
}
diff --git a/ci/config-management-smoke-inner.sh b/ci/config-management-smoke-inner.sh
index 6b6cde6..4f325c9 100755
--- a/ci/config-management-smoke-inner.sh
+++ b/ci/config-management-smoke-inner.sh
@@ -47,60 +47,6 @@ tamper_adapter_control_ownership() {
/home/cloud-compose/managed-runtime-artifacts.tsv
}
-verify_lifecycle_lock_contract() {
- local lock_dir=/run/lock/cloud-compose
- local lock_file="$lock_dir/lifecycle.lock"
- local profile=/home/cloud-compose/profile.sh
- local fixture=/work/ci/fixtures/config-management-lifecycle-lock.sh
- local ready=/tmp/cloud-compose-lifecycle-lock-ready
- local holder_pid contention_status passwd_sha
-
- command -v flock >/dev/null
- command -v runuser >/dev/null
- command -v systemd-tmpfiles >/dev/null
- [[ "$(stat -c '%U:%G:%a' "$lock_dir" "$lock_file")" == \
- $'root:cloud-compose:750\nroot:cloud-compose:660' ]]
-
- rm -f -- "$ready"
- CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \
- "$fixture" hold "$profile" "$ready" &
- holder_pid=$!
- for _ in {1..50}; do
- [[ -e "$ready" ]] && break
- sleep 0.1
- done
- [[ -e "$ready" ]]
-
- set +e
- runuser -u cloud-compose -- env \
- HOME=/home/cloud-compose \
- CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \
- CLOUD_COMPOSE_LIFECYCLE_LOCK_TIMEOUT_SECONDS=1 \
- "$fixture" contend "$profile" >/dev/null 2>&1
- contention_status=$?
- set -e
- [[ "$contention_status" -ne 0 ]]
- wait "$holder_pid"
-
- runuser -u cloud-compose -- env \
- HOME=/home/cloud-compose \
- CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \
- "$fixture" subshell "$profile"
-
- passwd_sha="$(sha256sum /etc/passwd)"
- mv -- "$lock_file" "${lock_file}.real"
- ln -s /etc/passwd "$lock_file"
- if CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \
- "$fixture" reject-symlink "$profile" \
- >/dev/null 2>&1; then
- echo "Lifecycle lock accepted a symbolic-link target" >&2
- exit 1
- fi
- [[ "$(sha256sum /etc/passwd)" == "$passwd_sha" ]]
- rm -f -- "$lock_file"
- mv -- "${lock_file}.real" "$lock_file"
-}
-
ansible-playbook \
-i /work/ansible/inventory.example.yml \
/work/ansible/playbooks/site.yml \
@@ -218,8 +164,6 @@ ansible-playbook \
-i /work/tests/config-management/ansible/inventory.yml \
/work/tests/config-management/ansible/smoke.yml
-verify_lifecycle_lock_contract
-
python /work/ci/config-management-smoke-assert.py ansible-runtime
rm -rf /home/cloud-compose /mnt/disks
diff --git a/ci/cos-jq-portability-contract.sh b/ci/cos-jq-portability-contract.sh
deleted file mode 100755
index 48b07b7..0000000
--- a/ci/cos-jq-portability-contract.sh
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-runtime_program_roots=(
- "$repo_root/rootfs/home/cloud-compose"
- "$repo_root/rootfs/etc/cloud-compose/jq"
-)
-regex_call_pattern='(^|[^[:alnum:]_])(test|match|capture|scan|splits|sub|gsub)[[:space:]]*\('
-nul_contains_pattern='contains[[:space:]]*\([[:space:]]*"\\u0000"[[:space:]]*\)'
-failed=false
-
-# Container-Optimized OS ships jq without Oniguruma. Its runtime scripts may
-# use jq for JSON structure and types, but text validation belongs in Bash.
-# Check both shell call sites and the deployed jq program library.
-# The remaining sub(/.../) and gsub(/.../) calls are awk regex literals, not
-# jq filters; jq has no slash-delimited regex syntax.
-while IFS= read -r call; do
- if [[ "$call" == *'sub(/'* || "$call" == *'gsub(/'* ]]; then
- continue
- fi
- printf 'COS runtime contains a regex-dependent jq-style call: %s\n' "$call" >&2
- failed=true
-done < <(grep -ERn --include='*.sh' --include='*.jq' "$regex_call_pattern" "${runtime_program_roots[@]}" || true)
-
-# jq 1.6 treats every string as containing a NUL when contains("\u0000") is
-# used. COS and supported configuration-management hosts can still run jq 1.6,
-# so deployed validation must inspect Unicode code points instead.
-while IFS= read -r call; do
- printf 'Runtime contains jq 1.6-incompatible NUL validation: %s\n' "$call" >&2
- failed=true
-done < <(grep -ERn --include='*.sh' --include='*.jq' "$nul_contains_pattern" "${runtime_program_roots[@]}" || true)
-
-if [[ "$failed" == "true" ]]; then
- exit 1
-fi
-
-echo "COS jq portability contract passed"
diff --git a/ci/disaster-recovery-contract.sh b/ci/disaster-recovery-contract.sh
deleted file mode 100755
index 4a7c565..0000000
--- a/ci/disaster-recovery-contract.sh
+++ /dev/null
@@ -1,93 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-backup_script="$repo_root/rootfs/home/cloud-compose/offhost-backup.sh"
-restore_script="$repo_root/rootfs/home/cloud-compose/restore-test.sh"
-fixture_root="$repo_root/ci/testdata/disaster-recovery"
-tmp="$(mktemp -d "$repo_root/.disaster-recovery-contract.XXXXXX")"
-trap 'rm -rf -- "$tmp"' EXIT
-
-fail() {
- echo "disaster recovery contract: $*" >&2
- exit 1
-}
-
-mkdir -p "$tmp/bin" "$tmp/data/projects/alpha/files" "$tmp/data/backups/mariadb/alpha" "$tmp/drivers"
-chmod 0755 "$tmp" "$tmp/bin" "$tmp/data" "$tmp/data/projects" "$tmp/data/projects/alpha" "$tmp/data/projects/alpha/files" "$tmp/drivers"
-
-/usr/bin/install -m 0644 "$fixture_root/profile.sh" "$tmp/profile.sh"
-/usr/bin/install -m 0644 "$fixture_root/compose-apps.sh" "$tmp/compose-apps.sh"
-/usr/bin/install -m 0755 "$fixture_root/fake-stat.sh" "$tmp/bin/stat"
-/usr/bin/install -m 0755 "$fixture_root/fake-install.sh" "$tmp/bin/install"
-/usr/bin/install -m 0755 "$fixture_root/fake-docker.sh" "$tmp/bin/docker"
-/usr/bin/install -m 0644 "$fixture_root/fake-compose-config.jq" "$tmp/bin/fake-compose-config.jq"
-/usr/bin/install -m 0755 "$fixture_root/good-driver.sh" "$tmp/drivers/good-driver"
-/usr/bin/install -m 0755 "$fixture_root/incomplete-driver.sh" "$tmp/drivers/incomplete-driver"
-/usr/bin/install -m 0644 \
- "$fixture_root/good-backup-receipt.jq" \
- "$fixture_root/good-restore-proof.jq" \
- "$fixture_root/incomplete-backup-receipt.jq" \
- "$tmp/drivers/"
-printf 'logical database\n' | gzip -c >"$tmp/data/backups/mariadb/alpha/$(date -u +%Y%m%d)-alpha.sql.gz"
-
-export TEST_BIN="$tmp/bin"
-export TEST_DATA_ROOT="$tmp/data"
-export LOCK_LOG="$tmp/lock.log"
-export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh"
-export CLOUD_COMPOSE_COMPOSE_APPS_PATH="$tmp/compose-apps.sh"
-export CLOUD_COMPOSE_DR_LIBRARY_PATH="$repo_root/rootfs/home/cloud-compose/disaster-recovery-lib.sh"
-export CLOUD_COMPOSE_DR_STATE_ROOT="$tmp/data/dr"
-export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
-export CLOUD_COMPOSE_DATA_ROOT="$tmp/data"
-export CLOUD_COMPOSE_VOLUMES_ROOT="$tmp/data/volumes"
-export MARIADB_BACKUP_ROOT="$tmp/data/backups/mariadb"
-export CLOUD_COMPOSE_INSTANCE_NAME="contract-site"
-export CLOUD_COMPOSE_PROVIDER="contract"
-export CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED="true"
-export CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="$tmp/drivers/good-driver"
-export SHOULD_NOT_REACH_DRIVER="terraform-secret"
-: >"$LOCK_LOG"
-
-bash "$backup_script"
-receipt="$(find "$CLOUD_COMPOSE_DR_STATE_ROOT/backup-receipts" -maxdepth 1 -type f -name '*.json' -print -quit)"
-manifest="$(find "$CLOUD_COMPOSE_DR_STATE_ROOT/manifests" -maxdepth 1 -type f -name '*.json' -print -quit)"
-[[ -n "$receipt" && -n "$manifest" ]] || fail "successful driver did not publish its atomic manifest and receipt"
-jq -e -f "$fixture_root/assert-coverage-manifest.jq" "$manifest" >/dev/null || \
- fail "coverage manifest omitted database, application files, or volume topology"
-
-# A second attempt sees the same already-existing daily dump but must still
-# invoke off-host transfer, allowing a failed first handoff to be retried.
-bash "$backup_script"
-[[ "$(grep -c '^backup$' "$tmp/drivers/good-driver.calls")" == "2" ]] || \
- fail "nightly flow skipped off-host transfer when the daily dump already existed"
-
-published_receipt_sha="$(sha256sum -- "$receipt")"
-published_receipt_sha="${published_receipt_sha%% *}"
-export CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="$tmp/drivers/incomplete-driver"
-if bash "$backup_script" >/dev/null 2>&1; then
- fail "driver receipt without complete volume coverage was accepted"
-fi
-current_receipt_sha="$(sha256sum -- "$receipt")"
-current_receipt_sha="${current_receipt_sha%% *}"
-[[ "$current_receipt_sha" == "$published_receipt_sha" ]] || \
- fail "invalid driver output replaced the last validated receipt"
-
-export CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="$tmp/drivers/good-driver"
-bash "$restore_script"
-proof="$(find "$CLOUD_COMPOSE_DR_STATE_ROOT/restore-proofs" -maxdepth 1 -type f -name '*.json' -print -quit)"
-[[ -n "$proof" ]] || fail "scheduled disposable restore test did not publish proof"
-jq -e -f "$fixture_root/assert-restore-proof.jq" "$proof" >/dev/null || \
- fail "restore proof omitted required recovery evidence"
-
-rm -f -- "$tmp/data/backups/mariadb/alpha/$(date -u +%Y%m%d)-alpha.sql.gz"
-calls_before="$(wc -l <"$tmp/drivers/good-driver.calls")"
-if bash "$backup_script" >/dev/null 2>&1; then
- fail "required DR coverage succeeded without a database artifact"
-fi
-[[ "$(wc -l <"$tmp/drivers/good-driver.calls")" == "$calls_before" ]] || \
- fail "driver ran before required local coverage was validated"
-
-CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED=false bash "$backup_script" >/dev/null
-echo "Disaster recovery contract passed"
diff --git a/ci/docker-plugin-install-contract.sh b/ci/docker-plugin-install-contract.sh
deleted file mode 100755
index 8bf765c..0000000
--- a/ci/docker-plugin-install-contract.sh
+++ /dev/null
@@ -1,172 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-INSTALLER="${ROOT_DIR}/rootfs/home/cloud-compose/install-docker-plugins.sh"
-TEST_ROOT="$(mktemp -d)"
-trap 'rm -rf -- "$TEST_ROOT"' EXIT
-
-BIN_DIR="${TEST_ROOT}/bin"
-PLUGIN_DIR="${TEST_ROOT}/plugins"
-HARNESS="${TEST_ROOT}/harness.sh"
-CURL_LOG="${TEST_ROOT}/curl.log"
-mkdir -p "$BIN_DIR" "$PLUGIN_DIR"
-
-cat >"$HARNESS" <<'EOF'
-#!/usr/bin/env bash
-source "$DOCKER_PLUGIN_INSTALLER"
-retry_until_success() {
- "$@"
-}
-install_docker_plugins
-EOF
-
-cat >"${BIN_DIR}/uname" <<'EOF'
-#!/usr/bin/env bash
-if [[ "${1:-}" == "-m" ]]; then
- printf '%s\n' x86_64
- exit 0
-fi
-exec /usr/bin/uname "$@"
-EOF
-
-cat >"${BIN_DIR}/curl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-
-url=""
-output=""
-while (($# > 0)); do
- case "$1" in
- -o)
- output="$2"
- shift 2
- ;;
- -*) shift ;;
- *)
- url="$1"
- shift
- ;;
- esac
-done
-
-[[ -n "$url" && -n "$output" ]]
-printf '%s\n' "$url" >>"$MOCK_CURL_LOG"
-asset="${url##*/}"
-
-if [[ "$asset" == "checksums.txt" ]]; then
- version="${url%/checksums.txt}"
- version="${version##*/}"
- case "$url" in
- */docker/compose/*)
- asset="docker-compose-linux-x86_64"
- payload="$MOCK_COMPOSE_EXPECTED_PAYLOAD"
- ;;
- */docker/buildx/*)
- asset="buildx-${version}.linux-amd64"
- payload="$MOCK_BUILDX_EXPECTED_PAYLOAD"
- ;;
- *)
- echo "unexpected checksum URL: $url" >&2
- exit 1
- ;;
- esac
- checksum="$(printf '%s' "$payload" | sha256sum)"
- printf '%s *%s\n' "${checksum%% *}" "$asset" >"$output"
- exit 0
-fi
-
-case "$asset" in
- docker-compose-linux-x86_64)
- printf '%s' "$MOCK_COMPOSE_DOWNLOAD_PAYLOAD" >"$output"
- ;;
- buildx-v*.linux-amd64)
- printf '%s' "$MOCK_BUILDX_DOWNLOAD_PAYLOAD" >"$output"
- ;;
- *)
- echo "unexpected binary URL: $url" >&2
- exit 1
- ;;
-esac
-EOF
-
-chmod +x "$HARNESS" "${BIN_DIR}/uname" "${BIN_DIR}/curl"
-
-run_installer() {
- local compose_version="$1"
- local buildx_version="$2"
- local compose_expected="$3"
- local compose_download="$4"
- local buildx_expected="$5"
- local buildx_download="$6"
-
- PATH="${BIN_DIR}:$PATH" \
- DOCKER_PLUGIN_INSTALLER="$INSTALLER" \
- CLOUD_COMPOSE_RELEASE_CHECKSUM_PROGRAM="$ROOT_DIR/rootfs/etc/cloud-compose/awk/release-checksum.awk" \
- DOCKER_CLI_PLUGIN_DIR="$PLUGIN_DIR" \
- DOCKER_COMPOSE_VERSION="$compose_version" \
- DOCKER_BUILDX_VERSION="$buildx_version" \
- MOCK_CURL_LOG="$CURL_LOG" \
- MOCK_COMPOSE_EXPECTED_PAYLOAD="$compose_expected" \
- MOCK_COMPOSE_DOWNLOAD_PAYLOAD="$compose_download" \
- MOCK_BUILDX_EXPECTED_PAYLOAD="$buildx_expected" \
- MOCK_BUILDX_DOWNLOAD_PAYLOAD="$buildx_download" \
- bash "$HARNESS"
-}
-
-assert_content() {
- local expected="$1"
- local path="$2"
- local actual
-
- actual="$(<"$path")"
- if [[ "$actual" != "$expected" ]]; then
- echo "unexpected content in $path: $actual" >&2
- exit 1
- fi
-}
-
-# A valid release installs both plugins only after their selected manifest
-# entries verify, and leaves executable final files.
-run_installer v1.2.3 v0.10.0 \
- compose-v1 compose-v1 \
- buildx-v1 buildx-v1
-assert_content compose-v1 "${PLUGIN_DIR}/docker-compose"
-assert_content buildx-v1 "${PLUGIN_DIR}/docker-buildx"
-[[ -x "${PLUGIN_DIR}/docker-compose" ]]
-[[ -x "${PLUGIN_DIR}/docker-buildx" ]]
-
-# An executable modified in place is not trusted merely because it exists; the
-# next run repairs it from a verified download for the configured release.
-printf '%s' locally-tampered >"${PLUGIN_DIR}/docker-buildx"
-run_installer v1.2.3 v0.10.0 \
- compose-v1 should-not-be-downloaded \
- buildx-v1 buildx-v1
-assert_content compose-v1 "${PLUGIN_DIR}/docker-compose"
-assert_content buildx-v1 "${PLUGIN_DIR}/docker-buildx"
-
-# A tampered upgrade must fail closed and preserve the previously verified
-# executable instead of replacing it with the failed download.
-if run_installer v2.0.0 v0.10.0 \
- compose-v2 tampered-compose \
- buildx-v1 tampered-buildx; then
- echo "tampered Docker Compose download unexpectedly installed" >&2
- exit 1
-fi
-assert_content compose-v1 "${PLUGIN_DIR}/docker-compose"
-assert_content buildx-v1 "${PLUGIN_DIR}/docker-buildx"
-
-# Configured version changes reconcile existing executables for both plugins.
-run_installer v2.0.0 v0.11.0 \
- compose-v2 compose-v2 \
- buildx-v2 buildx-v2
-assert_content compose-v2 "${PLUGIN_DIR}/docker-compose"
-assert_content buildx-v2 "${PLUGIN_DIR}/docker-buildx"
-
-if find "$PLUGIN_DIR" -mindepth 1 -maxdepth 1 -type d -name '.docker-*' -print -quit | grep -q .; then
- echo "plugin installer left a temporary directory behind" >&2
- exit 1
-fi
-
-echo "Docker CLI plugin verification contract passed"
diff --git a/ci/filesystem-prep-contract.sh b/ci/filesystem-prep-contract.sh
deleted file mode 100755
index d9006fe..0000000
--- a/ci/filesystem-prep-contract.sh
+++ /dev/null
@@ -1,834 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-prep_script="$repo_root/rootfs/home/cloud-compose/prepare-filesystem.sh"
-persist_script="$repo_root/rootfs/home/cloud-compose/persist-filesystems.sh"
-export CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM="$repo_root/rootfs/etc/cloud-compose/awk/reconcile-fstab.awk"
-gcp_filesystem_boot="$repo_root/rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh"
-gcp_filesystem_boothook="$repo_root/templates/gcp-filesystem-boothook.sh.tftpl"
-gcp_cloud_init_mime="$repo_root/templates/gcp-cloud-init.mime.tftpl"
-gcp_cloud_init_finalize="$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh"
-linux_cloud_init="$repo_root/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh"
-tmp="$(mktemp -d)"
-trap 'rm -rf -- "$tmp"' EXIT
-
-fail() {
- echo "filesystem preparation contract: $*" >&2
- exit 1
-}
-
-mkdir -p "$tmp/bin" "$tmp/dev" "$tmp/mount" "$tmp/systemd"
-device="$tmp/dev/data"
-: >"$device"
-calls="$tmp/calls"
-fresh_marker="$tmp/mount/.cloud-compose/fresh-filesystem"
-label_state="$tmp/label-state"
-: >"$label_state"
-
-cat >"$tmp/bin/readlink" <<'EOF'
-#!/usr/bin/env bash
-case "${!#}" in
- /dev/disk/by-id/*) printf '%s\n' "${FAKE_DEVICE:-}" ;;
- *) printf '%s\n' "${FAKE_RESOLVED_MOUNT_SOURCE:-${!#}}" ;;
-esac
-EOF
-cat >"$tmp/bin/blkid" <<'EOF'
-#!/usr/bin/env bash
-if [[ "${FAKE_BLKID_STATUS:-0}" == "0" ]]; then
- printf '%s\n' "${FAKE_FILESYSTEM_TYPE:-ext4}"
-fi
-exit "${FAKE_BLKID_STATUS:-0}"
-EOF
-cat >"$tmp/bin/fsck.ext4" <<'EOF'
-#!/usr/bin/env bash
-printf 'fsck\n' >>"${CALLS:?}"
-printf 'fsck-args %s\n' "$*" >>"${CALLS:?}"
-exit "${FAKE_FSCK_STATUS:-0}"
-EOF
-cat >"$tmp/bin/mkfs.ext4" <<'EOF'
-#!/usr/bin/env bash
-printf 'mkfs\n' >>"${CALLS:?}"
-label=""
-previous=""
-for argument in "$@"; do
- if [[ "$previous" == "-L" ]]; then
- label="$argument"
- fi
- previous="$argument"
-done
-printf '%s' "$label" >"${FAKE_LABEL_STATE:?}"
-EOF
-cat >"$tmp/bin/e2label" <<'EOF'
-#!/usr/bin/env bash
-case "$#" in
- 1)
- cat "${FAKE_LABEL_STATE:?}"
- ;;
- 2)
- printf '%s' "$2" >"${FAKE_LABEL_STATE:?}"
- printf 'label-set %s\n' "$2" >>"${CALLS:?}"
- ;;
- *)
- exit 2
- ;;
-esac
-EOF
-cat >"$tmp/bin/resize2fs" <<'EOF'
-#!/usr/bin/env bash
-printf 'resize\n' >>"${CALLS:?}"
-exit "${FAKE_RESIZE_STATUS:-0}"
-EOF
-cat >"$tmp/bin/mountpoint" <<'EOF'
-#!/usr/bin/env bash
-target="${!#}"
-active_targets="${FAKE_DEVICE_MOUNT_TARGETS:-${FAKE_DEVICE_MOUNT_TARGET:-}}"
-if [[ -s "${FAKE_MOUNT_STATE:?}" ]]; then
- active_targets="$(<"$FAKE_MOUNT_STATE")"
-fi
-if [[ "$target" == "${MOUNT_PATH:?}" && "${FAKE_MOUNTED:-1}" == "0" ]]; then
- exit 0
-fi
-while IFS= read -r active_target; do
- [[ -n "$active_target" && "$target" == "$active_target" ]] && exit 0
-done <<<"$active_targets"
-exit 1
-EOF
-cat >"$tmp/bin/mount" <<'EOF'
-#!/usr/bin/env bash
-printf 'mount\n' >>"${CALLS:?}"
-status="${FAKE_MOUNT_STATUS:-0}"
-if [[ "${1:-}" == "--move" && "$status" == "0" ]]; then
- printf '%s\n' "${3:?}" >"${FAKE_MOUNT_STATE:?}"
-fi
-exit "$status"
-EOF
-cat >"$tmp/bin/stat" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-
-target="${!#}"
-format=""
-previous=""
-for argument in "$@"; do
- if [[ "$previous" == "-c" ]]; then
- format="$argument"
- fi
- previous="$argument"
-done
-case "$target:$format" in
- "${MOUNT_PATH:?}:%u:%g:%a")
- printf '%s\n' "${FAKE_FRESH_ROOT_IDENTITY:-0:0:755}"
- ;;
- "$MOUNT_PATH/.cloud-compose:%u:%g:%a")
- printf '%s\n' "${FAKE_MARKER_DIR_IDENTITY:-0:0:700}"
- ;;
- "$MOUNT_PATH/.cloud-compose/fresh-filesystem:%u:%g:%a:%h")
- printf 'marker\n' >>"${CALLS:?}"
- printf '%s\n' "${FAKE_MARKER_IDENTITY:-0:0:600:1}"
- ;;
- "$MOUNT_PATH/lost+found:%u:%g:%a")
- printf '%s\n' "${FAKE_LOST_FOUND_IDENTITY:-0:0:700}"
- ;;
- *)
- exec /usr/bin/stat "$@"
- ;;
-esac
-EOF
-cat >"$tmp/bin/sync" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-count=0
-if [[ -s "${FAKE_SYNC_COUNT:?}" ]]; then
- count="$(<"$FAKE_SYNC_COUNT")"
-fi
-count=$((count + 1))
-printf '%s\n' "$count" >"$FAKE_SYNC_COUNT"
-printf 'sync\n' >>"${CALLS:?}"
-if [[ -n "${FAKE_SYNC_FAIL_AT:-}" && "$count" == "$FAKE_SYNC_FAIL_AT" ]]; then
- exit 1
-fi
-EOF
-cat >"$tmp/bin/findmnt" <<'EOF'
-#!/usr/bin/env bash
-printf 'findmnt\n' >>"${CALLS:?}"
-active_targets="${FAKE_DEVICE_MOUNT_TARGETS:-${FAKE_DEVICE_MOUNT_TARGET:-}}"
-if [[ -s "${FAKE_MOUNT_STATE:?}" ]]; then
- active_targets="$(<"$FAKE_MOUNT_STATE")"
-fi
-if [[ "${FAKE_MOUNTED:-1}" == "0" ]]; then
- active_targets="${MOUNT_PATH:?}${active_targets:+$'\n'}${active_targets}"
-fi
-case " $* " in
- *" -o SOURCE --target "*)
- target="${!#}"
- while IFS= read -r active_target; do
- if [[ -n "$active_target" && "$target" == "$active_target" ]]; then
- printf '%s\n' "${FAKE_MOUNT_SOURCE:-${FAKE_DEVICE:?}}"
- exit 0
- fi
- done <<<"$active_targets"
- exit 1
- ;;
- *" -o TARGET --source "*)
- count=0
- if [[ -s "${FAKE_FINDMNT_COUNT:?}" ]]; then
- count="$(<"$FAKE_FINDMNT_COUNT")"
- fi
- count=$((count + 1))
- printf '%s\n' "$count" >"$FAKE_FINDMNT_COUNT"
- if [[ -n "${FAKE_LATE_MOUNT_TARGET:-}" && "$count" -gt "${FAKE_LATE_MOUNT_AFTER:-1}" ]]; then
- active_targets="${active_targets}${active_targets:+$'\n'}${FAKE_LATE_MOUNT_TARGET}"
- fi
- if [[ -n "$active_targets" ]]; then
- printf '%s\n' "$active_targets"
- exit 0
- fi
- exit 1
- ;;
-esac
-exit 2
-EOF
-cat >"$tmp/bin/udevadm" <<'EOF'
-#!/usr/bin/env bash
-printf 'udevadm\n' >>"${CALLS:?}"
-if [[ -n "${FAKE_UDEV_MOUNT_TARGET:-}" ]]; then
- printf '%s\n' "$FAKE_UDEV_MOUNT_TARGET" >"${FAKE_MOUNT_STATE:?}"
-fi
-exit "${FAKE_UDEV_STATUS:-0}"
-EOF
-cat >"$tmp/bin/systemctl" <<'EOF'
-#!/usr/bin/env bash
-printf 'systemctl %s\n' "$*" >>"${CALLS:?}"
-if [[ "${1:-}" == "start" && -n "${FAKE_SYSTEMD_MOUNT_TARGET:-}" ]]; then
- printf '%s\n' "$FAKE_SYSTEMD_MOUNT_TARGET" >"${FAKE_MOUNT_STATE:?}"
-fi
-exit "${FAKE_SYSTEMCTL_STATUS:-0}"
-EOF
-cat >"$tmp/bin/sleep" <<'EOF'
-#!/usr/bin/env bash
-printf 'sleep\n' >>"${CALLS:?}"
-EOF
-chmod +x "$tmp/bin/"*
-
-run_prep() {
- local configured_device="${TEST_FAKE_DEVICE-$device}"
- : >"$calls"
- rm -f -- "$tmp/mount-state"
- rm -f -- "$tmp/findmnt-count"
- printf '0\n' >"$tmp/sync-count"
- FAKE_DEVICE="$configured_device" FAKE_MOUNT_STATE="$tmp/mount-state" \
- FAKE_FINDMNT_COUNT="$tmp/findmnt-count" FAKE_LABEL_STATE="$label_state" \
- FAKE_SYNC_COUNT="$tmp/sync-count" CALLS="$calls" \
- PATH="$tmp/bin:/usr/bin:/bin" PREP_SCRIPT="$prep_script" MOUNT_PATH="$tmp/mount" \
- CLOUD_COMPOSE_SYSTEMD_DIR="$tmp/systemd" \
- DEVICE_PATH="${TEST_DEVICE_PATH:-/dev/disk/by-id/test-data}" \
- PREP_EXTRA_ARG="${TEST_PREP_EXTRA_ARG:-}" \
- PREP_MARKER_IDENTITY="${TEST_PREP_MARKER_IDENTITY:-}" \
- bash --noprofile --norc -c '
- source "$PREP_SCRIPT"
- if [[ -n "${TEST_PROVIDER_MOUNT:-}" ]]; then
- digitalocean_automatic_mount() { printf "%s\n" "$TEST_PROVIDER_MOUNT"; }
- fi
- is_block_device() { return 0; }
- prep_args=("$DEVICE_PATH" "$MOUNT_PATH")
- if [[ -n "$PREP_EXTRA_ARG" ]]; then
- prep_args+=("$PREP_EXTRA_ARG")
- fi
- if [[ -n "$PREP_MARKER_IDENTITY" ]]; then
- prep_args+=("$PREP_MARKER_IDENTITY")
- fi
- main "${prep_args[@]}"
- '
-}
-
-for status in 0 1; do
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS="$status" run_prep
- grep -Fxq fsck "$calls" || fail "fsck status $status did not check the existing filesystem"
- grep -Fxq "fsck-args -f -p -- $device" "$calls" ||
- fail "fsck status $status did not force a complete offline ext4 check"
- grep -Fxq resize "$calls" || fail "fsck status $status did not grow the existing filesystem"
- grep -Fxq mount "$calls" || fail "fsck status $status did not mount the usable filesystem"
- if grep -Fxq mkfs "$calls"; then
- fail "fsck status $status reformatted an existing filesystem"
- fi
-done
-
-for status in 2 3 4 8 16 32 128; do
- if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS="$status" run_prep >/dev/null 2>&1; then
- fail "fsck failure status $status was accepted"
- fi
- grep -Fxq fsck "$calls" || fail "fsck failure status $status was not observed"
- if grep -Eq '^(mkfs|mount|resize)$' "$calls"; then
- fail "fsck failure status $status reformatted or mounted the existing filesystem"
- fi
-done
-
-FAKE_BLKID_STATUS=2 FAKE_FSCK_STATUS=0 run_prep
-grep -Fxq mkfs "$calls" || fail "an unsigned device was not formatted"
-grep -Fxq mount "$calls" || fail "a newly formatted device was not mounted"
-if grep -Fxq fsck "$calls"; then
- fail "an unsigned device was checked before its first format"
-fi
-if grep -Fxq resize "$calls"; then
- fail "a newly formatted whole-disk filesystem was resized redundantly"
-fi
-
-rm -rf -- "$tmp/mount/.cloud-compose" "$tmp/mount/lost+found"
-if TEST_PREP_EXTRA_ARG=--unsupported FAKE_BLKID_STATUS=2 run_prep >/dev/null 2>&1; then
- fail "an unsupported filesystem preparation option was accepted"
-fi
-[[ ! -e "$fresh_marker" ]] ||
- fail "an unsupported filesystem preparation option published a fresh-filesystem marker"
-if [[ -s "$calls" ]]; then
- fail "an unsupported filesystem preparation option mutated the filesystem"
-fi
-
-TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 run_prep
-[[ ! -e "$fresh_marker" ]] ||
- fail "an existing filesystem was marked as freshly formatted"
-if grep -Fxq marker "$calls"; then
- fail "an existing filesystem invoked fresh-filesystem marker publication"
-fi
-
-if TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- FAKE_BLKID_STATUS=2 FAKE_MOUNT_STATUS=1 run_prep >/dev/null 2>&1; then
- fail "a failed first mount was accepted"
-fi
-grep -Fxq mkfs "$calls" || fail "mount failure coverage did not format the unsigned device"
-grep -Fxq mount "$calls" || fail "mount failure coverage did not attempt the first mount"
-[[ ! -e "$fresh_marker" ]] ||
- fail "a fresh-filesystem marker was published before the first mount succeeded"
-if grep -Fxq marker "$calls"; then
- fail "a failed first mount invoked fresh-filesystem marker publication"
-fi
-[[ "$(<"$label_state")" == "cc-fresh-pending" ]] ||
- fail "a failed first mount did not retain durable fresh-filesystem intent"
-
-printf 'not-pristine\n' >"$tmp/mount/application-data"
-if TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
- run_prep >/dev/null 2>&1; then
- fail "a pending ext4 label authorized a populated filesystem"
-fi
-[[ ! -e "$fresh_marker" && "$(<"$label_state")" == "cc-fresh-pending" ]] ||
- fail "populated-filesystem rejection changed fresh-marker authority"
-rm -f -- "$tmp/mount/application-data"
-
-mkdir -p "$tmp/mount/lost+found"
-chmod 0700 "$tmp/mount/lost+found"
-printf 'recovered-data\n' >"$tmp/mount/lost+found/orphan"
-if TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
- run_prep >/dev/null 2>&1; then
- fail "a pending ext4 label accepted data inside lost+found"
-fi
-rm -f -- "$tmp/mount/lost+found/orphan"
-if TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
- FAKE_LOST_FOUND_IDENTITY=1000:1000:700 run_prep >/dev/null 2>&1; then
- fail "a pending ext4 label accepted an untrusted lost+found directory"
-fi
-
-if TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
- FAKE_SYNC_FAIL_AT=1 run_prep >/dev/null 2>&1; then
- fail "fresh marker publication accepted a failed durability barrier"
-fi
-[[ -f "$fresh_marker" && "$(<"$label_state")" == "cc-fresh-pending" ]] ||
- fail "the pending label was cleared before marker publication became durable"
-if grep -Eq '^label-set ' "$calls"; then
- fail "a failed marker durability barrier cleared the pending label"
-fi
-
-fresh_marker_inode="$(stat -c '%d:%i' "$fresh_marker")"
-printf 'v1:gcp-disk-id:111111111111111111\n' >"$fresh_marker"
-if TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- TEST_PREP_MARKER_IDENTITY=v1:gcp-disk-id:222222222222222222 \
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
- run_prep >/dev/null 2>&1; then
- fail "a pending label accepted a marker from another disk incarnation"
-fi
-[[ "$(<"$label_state")" == "cc-fresh-pending" ]] ||
- fail "a mismatched disk-incarnation marker cleared the pending label"
-if grep -Eq '^label-set ' "$calls"; then
- fail "a mismatched disk-incarnation marker cleared its pending label"
-fi
-printf 'fresh\n' >"$fresh_marker"
-if TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
- FAKE_MARKER_IDENTITY=1000:1000:600:1 run_prep >/dev/null 2>&1; then
- fail "a pending label accepted an untrusted existing marker"
-fi
-[[ "$(<"$label_state")" == "cc-fresh-pending" ]] ||
- fail "an untrusted existing marker cleared the pending label"
-
-TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 run_prep
-[[ -f "$fresh_marker" ]] ||
- fail "a retry did not recover fresh-filesystem marker publication"
-if grep -Fxq mkfs "$calls"; then
- fail "fresh-filesystem marker recovery reformatted the existing filesystem"
-fi
-[[ ! -s "$label_state" ]] ||
- fail "successful fresh-filesystem marker publication retained the pending label"
-[[ "$(stat -c '%a' "$tmp/mount/.cloud-compose")" == "700" ]] ||
- fail "the fresh-filesystem marker directory was not mode 0700"
-[[ "$(stat -c '%a' "$fresh_marker")" == "600" ]] ||
- fail "the fresh-filesystem marker was not mode 0600"
-[[ "$(<"$fresh_marker")" == "fresh" ]] ||
- fail "generic fresh-filesystem marker did not preserve its exact payload"
-mount_line="$(grep -nFx mount "$calls" | cut -d: -f1)"
-marker_line="$(grep -nFx marker "$calls" | cut -d: -f1)"
-label_clear_line="$(grep -nE '^label-set $' "$calls" | cut -d: -f1)"
-mapfile -t sync_lines < <(grep -nFx sync "$calls" | cut -d: -f1)
-[[ -n "$mount_line" && -n "$marker_line" && -n "$label_clear_line" &&
- "${#sync_lines[@]}" == "2" &&
- "$mount_line" -lt "$marker_line" &&
- "$marker_line" -lt "${sync_lines[0]}" &&
- "${sync_lines[0]}" -lt "$label_clear_line" &&
- "$label_clear_line" -lt "${sync_lines[1]}" ]] ||
- fail "fresh authority ordering is not mount, marker, sync, label clear, sync"
-[[ "$(stat -c '%d:%i' "$fresh_marker")" == "$fresh_marker_inode" ]] ||
- fail "fresh marker recovery replaced an already trusted marker"
-
-TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 run_prep
-[[ -f "$fresh_marker" && "$(stat -c '%d:%i' "$fresh_marker")" == "$fresh_marker_inode" ]] ||
- fail "an existing fresh-filesystem marker was not preserved on a same-boot rerun"
-if grep -Fxq marker "$calls"; then
- fail "a same-boot rerun republished the fresh-filesystem marker"
-fi
-
-rm -rf -- "$tmp/mount/.cloud-compose" "$tmp/mount/lost+found"
-printf 'cc-fresh-pending' >"$label_state"
-TEST_PREP_EXTRA_ARG=--publish-fresh-marker \
- TEST_PREP_MARKER_IDENTITY=v1:gcp-disk-id:987654321012345678 \
- FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 run_prep
-[[ "$(<"$fresh_marker")" == "v1:gcp-disk-id:987654321012345678" ]] ||
- fail "GCP marker publication did not bind the exact data-disk identity"
-[[ ! -s "$label_state" ]] ||
- fail "exact GCP disk identity did not complete pending marker publication"
-rm -rf -- "$tmp/mount/.cloud-compose" "$tmp/mount/lost+found"
-
-if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=xfs FAKE_FSCK_STATUS=0 run_prep >/dev/null 2>&1; then
- fail "a non-ext4 filesystem was accepted"
-fi
-if grep -Eq '^(mkfs|mount|resize)$' "$calls"; then
- fail "a non-ext4 filesystem was reformatted or mounted as ext4"
-fi
-
-for status in 1 3 4; do
- if FAKE_BLKID_STATUS="$status" FAKE_FSCK_STATUS=0 run_prep >/dev/null 2>&1; then
- fail "blkid failure status $status was accepted"
- fi
- if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then
- fail "blkid failure status $status mutated the device"
- fi
-done
-
-FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
-FAKE_MOUNTED=0 FAKE_MOUNT_SOURCE=/dev/correct FAKE_RESOLVED_MOUNT_SOURCE="$device" run_prep
-if grep -Fxq mount "$calls"; then
- fail "an already-correct mount was mounted again"
-fi
-if grep -Fxq fsck "$calls"; then
- fail "an already-mounted filesystem was checked offline"
-fi
-grep -Fxq resize "$calls" || fail "an already-mounted filesystem was not grown online"
-
-if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
- FAKE_MOUNTED=0 FAKE_MOUNT_SOURCE=/dev/wrong FAKE_RESOLVED_MOUNT_SOURCE=/dev/wrong \
- run_prep >/dev/null 2>&1; then
- fail "an existing mount from the wrong device was accepted"
-fi
-if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then
- fail "wrong-device mount detection mutated the filesystem"
-fi
-
-expected_do_mount="$(
- # shellcheck source=../rootfs/home/cloud-compose/prepare-filesystem.sh
- source "$prep_script"
- digitalocean_automatic_mount \
- /dev/disk/by-id/scsi-0DO_Volume_cc-do-isle-123-data
-)"
-[[ "$expected_do_mount" == "/mnt/cc_do_isle_123_data" ]] ||
- fail "DigitalOcean automatic mount naming was not derived correctly"
-if (
- # shellcheck source=../rootfs/home/cloud-compose/prepare-filesystem.sh
- source "$prep_script"
- digitalocean_automatic_mount /dev/disk/by-id/scsi-0DO_Volume_bad/name
-) >/dev/null 2>&1; then
- fail "an unsafe DigitalOcean volume name was accepted"
-fi
-
-provider_mount="$tmp/provider-owned-mount"
-mkdir -p "$provider_mount"
-FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
- FAKE_DEVICE_MOUNT_TARGET="$provider_mount" TEST_PROVIDER_MOUNT="$provider_mount" \
- TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-isle-data run_prep
-grep -Fxq mount "$calls" || fail "the provider-owned mount was not moved"
-grep -Fxq resize "$calls" || fail "the moved filesystem was not grown online"
-if grep -Eq '^(fsck|mkfs)$' "$calls"; then
- fail "the mounted provider filesystem was checked or formatted"
-fi
-[[ "$(<"$tmp/mount-state")" == "$tmp/mount" ]] ||
- fail "the provider-owned mount was not moved to the requested target"
-
-# Device discovery must not race DigitalOcean's udev automount. Model the
-# provider mount appearing only when udevadm settle runs; an offline fsck of
-# this mounted filesystem would reproduce the hosted failure.
-mkdir -p "$provider_mount"
-FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=8 \
- FAKE_UDEV_MOUNT_TARGET="$provider_mount" TEST_PROVIDER_MOUNT="$provider_mount" \
- TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-race-data run_prep
-[[ "$(sed -n '1p' "$calls")" == "udevadm" ]] ||
- fail "DigitalOcean mount inspection did not wait for udev completion"
-if grep -Eq '^(fsck|mkfs)$' "$calls"; then
- fail "a provider mount that completed during udev settle was checked or formatted offline"
-fi
-
-# When udev has generated a unit but not completed its systemd job, bootstrap
-# must synchronously finish the validated provider unit before inspecting the
-# mount set.
-systemd_device=/dev/disk/by-id/scsi-0DO_Volume_cc-do-systemd-data
-systemd_mount="$tmp/systemd-provider-mount"
-systemd_unit="$tmp/systemd/mnt-$(basename -- "$systemd_mount").mount"
-mkdir -p "$systemd_mount"
-printf '[Mount]\nWhat=%s\nWhere=%s\n' "$systemd_device" "$systemd_mount" >"$systemd_unit"
-FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=8 \
- FAKE_SYSTEMD_MOUNT_TARGET="$systemd_mount" TEST_PROVIDER_MOUNT="$systemd_mount" \
- TEST_DEVICE_PATH="$systemd_device" run_prep
-grep -Fq "systemctl start -- $(basename -- "$systemd_unit")" "$calls" ||
- fail "DigitalOcean's pending systemd automount was not completed"
-if grep -Eq '^(fsck|mkfs)$' "$calls"; then
- fail "a provider mount completed by systemd was checked or formatted offline"
-fi
-rm -f -- "$systemd_unit"
-
-# A desired mount and a stale provider alias for the same device is corruption-
-# prone. Detect the complete mount set and stop without moving, checking, or
-# resizing either alias.
-if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 \
- FAKE_DEVICE_MOUNT_TARGETS="$tmp/mount"$'\n'"$provider_mount" \
- TEST_PROVIDER_MOUNT="$provider_mount" \
- TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-dual-data \
- run_prep >/dev/null 2>&1; then
- fail "duplicate desired and provider mount aliases were accepted"
-fi
-if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then
- fail "duplicate mount detection mutated the filesystem"
-fi
-
-# A mount may appear after the first inspection even when it is not managed by
-# the DigitalOcean unit. Recheck at the mutation boundary and leave the device
-# untouched if any actor mounted it late.
-late_mount="$tmp/late-external-mount"
-if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
- FAKE_LATE_MOUNT_TARGET="$late_mount" FAKE_LATE_MOUNT_AFTER=1 \
- run_prep >/dev/null 2>&1; then
- fail "a mount that appeared immediately before offline fsck was accepted"
-fi
-if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then
- fail "late mount detection mutated the filesystem"
-fi
-
-unexpected_mount="$tmp/operator-mount"
-mkdir -p "$unexpected_mount"
-if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 \
- FAKE_DEVICE_MOUNT_TARGET="$unexpected_mount" TEST_PROVIDER_MOUNT="$provider_mount" \
- TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-isle-data \
- run_prep >/dev/null 2>&1; then
- fail "an unexpected mounted-device target was relocated"
-fi
-if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then
- fail "unexpected mounted-device detection mutated the filesystem"
-fi
-
-mkdir -p "$provider_mount"
-if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 \
- FAKE_DEVICE_MOUNT_TARGET="$provider_mount" TEST_PROVIDER_MOUNT="$provider_mount" \
- TEST_DEVICE_PATH=/dev/disk/by-id/provider-data FAKE_MOUNT_SOURCE=/dev/wrong \
- FAKE_RESOLVED_MOUNT_SOURCE=/dev/wrong run_prep >/dev/null 2>&1; then
- fail "a provider mount from the wrong block device was accepted"
-fi
-if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then
- fail "wrong provider mount source detection mutated the filesystem"
-fi
-
-mkdir -p "$provider_mount"
-printf 'operator data\n' >"$tmp/mount/operator-file"
-if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 \
- FAKE_DEVICE_MOUNT_TARGET="$provider_mount" TEST_PROVIDER_MOUNT="$provider_mount" \
- TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-isle-data \
- run_prep >/dev/null 2>&1; then
- fail "a provider mount hid a non-empty target directory"
-fi
-rm -f -- "$tmp/mount/operator-file"
-if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then
- fail "non-empty target detection mutated the filesystem"
-fi
-
-mkdir -p "$provider_mount"
-if FAKE_BLKID_STATUS=2 FAKE_DEVICE_MOUNT_TARGET="$provider_mount" \
- TEST_PROVIDER_MOUNT="$provider_mount" \
- TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-isle-data \
- run_prep >/dev/null 2>&1; then
- fail "a mounted provider device without a filesystem signature was accepted"
-fi
-if grep -Eq '^(fsck|mkfs)$' "$calls"; then
- fail "a mounted unsigned provider device was checked or formatted"
-fi
-
-if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \
- FAKE_RESIZE_STATUS=1 run_prep >/dev/null 2>&1; then
- fail "a failed ext4 resize was accepted"
-fi
-grep -Fxq resize "$calls" || fail "resize failure path did not invoke resize2fs"
-if grep -Fxq mount "$calls"; then
- fail "filesystem was mounted after resize2fs failed"
-fi
-
-if TEST_FAKE_DEVICE='' FILESYSTEM_DEVICE_WAIT_SECONDS=3 run_prep >/dev/null 2>&1; then
- fail "missing by-id device was accepted"
-fi
-[[ "$(grep -c '^sleep$' "$calls")" == 2 ]] || fail "device wait was not bounded to the configured deadline"
-
-if grep -Eq 'fsck[^\n]*\|\|[^\n]*mkfs|fsck[^\n]*mkfs' "$repo_root/templates/cloud-init.yml"; then
- fail "GCP cloud-init still formats after an fsck failure"
-fi
-grep -Fq 'FILESYSTEM_PREP_SCRIPT_B64' "$gcp_filesystem_boothook" || \
- fail "GCP boothook does not bootstrap the tested filesystem helper"
-for compressed_boot_program in \
- 'base64 -d | gzip -d >"$filesystem_boot"' \
- 'base64 -d | gzip -d >"$filesystem_prep"' \
- 'base64 -d | gzip -d >"$filesystem_persist"' \
- 'base64 -d | gzip -d >"$filesystem_reconcile"'; do
- grep -Fq "$compressed_boot_program" "$gcp_filesystem_boothook" || \
- fail "GCP boothook does not unpack a compressed checked program: $compressed_boot_program"
-done
-grep -Fq 'bash "$filesystem_boot"' "$gcp_filesystem_boothook" || \
- fail "GCP boothook does not invoke the checked filesystem program"
-grep -Fq 'Content-Type: text/cloud-boothook' "$gcp_cloud_init_mime" || \
- fail "GCP user data does not preserve an early every-boot filesystem phase"
-if grep -Fq '/var/lib/cloud-compose/bootstrap/gcp-filesystem-boot.sh' \
- "$repo_root/templates/cloud-init.yml"; then
- fail "GCP cloud-config invokes filesystem preparation late from runcmd"
-fi
-grep -Fq 'bash "$filesystem_prep" /dev/disk/by-id/google-data /mnt/disks/data \' \
- "$gcp_filesystem_boot" ||
- fail "GCP cloud-init does not request a fresh marker for the data filesystem"
-grep -Fq -- '--publish-fresh-marker "$fresh_filesystem_identity"' \
- "$gcp_filesystem_boot" ||
- fail "GCP cloud-init does not bind the marker to the rendered data-disk identity"
-grep -Fq 'bash "$filesystem_prep" "$data_device" /mnt/disks/data --publish-fresh-marker' \
- "$linux_cloud_init" ||
- fail "Linux VM cloud-init does not request a fresh marker for the data filesystem"
-grep -Fq 'bash "$filesystem_prep" /dev/disk/by-id/google-docker-volumes /mnt/disks/volumes' \
- "$gcp_filesystem_boot" ||
- fail "GCP cloud-init does not prepare the volumes filesystem without a fresh marker"
-grep -Fq 'bash "$filesystem_prep" "$volumes_device" /mnt/disks/volumes' \
- "$linux_cloud_init" ||
- fail "Linux VM cloud-init does not prepare the volumes filesystem without a fresh marker"
-grep -Fq '(umask 077 && mkdir -- "$marker_dir")' "$prep_script" ||
- fail "the fresh-filesystem marker directory is not created privately"
-grep -Fq "(umask 077 && printf '%s\\n' \"\$expected_identity\" >\"\$marker\")" "$prep_script" ||
- fail "the fresh-filesystem marker is not created privately"
-grep -Fq 'marker_root_identity="$(stat -c '\''%u:%g:%a'\'' -- "$mount_path")"' "$prep_script" ||
- fail "marker recovery does not verify a root-owned pristine filesystem root"
-grep -Fq '! -name lost+found -print -quit' "$prep_script" ||
- fail "marker recovery does not reject data outside lost+found"
-grep -Fq -- '-L "$fresh_filesystem_pending_label" -- "$device"' "$prep_script" ||
- fail "new data filesystems do not persist pending marker intent in their ext4 label"
-grep -Fq 'e2label "$device" ""' "$prep_script" ||
- fail "fresh-filesystem pending intent is not cleared after marker publication"
-if grep -Eq '^[[:space:]]*"?\$filesystem_(prep|persist)"?[[:space:]]' \
- "$gcp_filesystem_boot" "$linux_cloud_init"; then
- fail "cloud-init directly executes a temporary helper from potentially noexec /run"
-fi
-for filesystem_bootstrap in "$gcp_filesystem_boot" "$linux_cloud_init"; do
- [[ "$(grep -Fc -- '--publish-fresh-marker' "$filesystem_bootstrap")" == "1" ]] ||
- fail "$filesystem_bootstrap must publish a fresh marker for the data filesystem only"
- grep -Fq 'for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do' \
- "$filesystem_bootstrap" || fail "$filesystem_bootstrap does not verify every required mount before initialization"
- grep -Fq 'Required cloud-compose mount is unavailable:' "$filesystem_bootstrap" ||
- fail "$filesystem_bootstrap mount gate does not report the unavailable path"
-done
-for bootstrap_finalize in "$gcp_cloud_init_finalize" "$linux_cloud_init"; do
- grep -Fq 'chown root:cloud-compose /mnt/disks/data' "$bootstrap_finalize" ||
- fail "$bootstrap_finalize does not preserve root ownership of the data mount root"
- grep -Fq 'chmod 1775 /mnt/disks/data' "$bootstrap_finalize" ||
- fail "$bootstrap_finalize does not make the data mount root sticky and group-writable"
- grep -Fq 'chown cloud-compose:cloud-compose /mnt/disks/volumes' "$bootstrap_finalize" ||
- fail "$bootstrap_finalize does not assign the volumes mount root to cloud-compose"
- grep -Fq 'chmod 0775 /mnt/disks/volumes' "$bootstrap_finalize" ||
- fail "$bootstrap_finalize does not make the volumes mount root group-writable"
- grep -Fq 'bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh' \
- "$bootstrap_finalize" || fail "$bootstrap_finalize does not start the retryable bootstrap service"
- marker_reset_line="$(grep -nF 'rm -f /var/lib/cloud-compose/bootstrap-complete' \
- "$bootstrap_finalize" | cut -d: -f1)"
- run_line="$(grep -nF 'bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh' \
- "$bootstrap_finalize" | cut -d: -f1)"
- [[ -n "$marker_reset_line" && -n "$run_line" && "$marker_reset_line" -lt "$run_line" ]] ||
- fail "$bootstrap_finalize does not clear stale bootstrap readiness before retryable startup"
-done
-grep -Fq 'install -m 0600 /dev/null /run/cloud-compose-filesystems-ready' \
- "$gcp_filesystem_boot" || fail "GCP boot program does not publish filesystem readiness"
-grep -Fq '[[ ! -f /run/cloud-compose-filesystems-ready ]]' \
- "$gcp_cloud_init_finalize" || fail "GCP finalization does not require filesystem readiness"
-grep -Fq 'fail "Cloud Compose filesystems were not prepared; refusing rootfs installation"' \
- "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" || \
- fail "GCP archive installation is not gated by filesystem readiness"
-
-persist_tmp="$tmp/persist"
-mkdir -p "$persist_tmp/bin"
-cat >"$persist_tmp/bin/systemctl" <<'EOF'
-#!/usr/bin/env bash
-printf '%s\n' "$*" >>"${PERSIST_SYSTEMCTL_LOG:?}"
-EOF
-chmod +x "$persist_tmp/bin/systemctl"
-fstab="$persist_tmp/fstab"
-systemctl_log="$persist_tmp/systemctl.log"
-printf '# existing filesystem table\n' >"$fstab"
-: >"$systemctl_log"
-
-run_persist() {
- PATH="$persist_tmp/bin:/usr/bin:/bin" \
- CLOUD_COMPOSE_FSTAB_PATH="$fstab" \
- CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \
- PERSIST_SYSTEMCTL_LOG="$systemctl_log" \
- bash "$persist_script" \
- /dev/disk/by-id/provider-data \
- /dev/disk/by-id/provider-volumes
-}
-
-run_persist
-grep -Fq $'/dev/disk/by-id/provider-data\t/mnt/disks/data\text4\tdefaults,nofail,x-systemd.device-timeout=120s\t0\t2' "$fstab" || \
- fail "persistent data mount was not recorded"
-grep -Fq $'/dev/disk/by-id/provider-volumes\t/mnt/disks/volumes\text4\tdefaults,nofail,x-systemd.device-timeout=120s\t0\t2' "$fstab" || \
- fail "persistent Docker-volume mount was not recorded"
-grep -Fq $'/mnt/disks/volumes\t/mnt/disks/data/docker/volumes\tnone\tbind,nofail,x-systemd.requires=/mnt/disks/volumes\t0\t0' "$fstab" || \
- fail "persistent Docker bind mount was not recorded"
-[[ "$(grep -Fc '# BEGIN cloud-compose persistent mounts' "$fstab")" == "1" ]] || \
- fail "filesystem persistence did not write one managed block"
-run_persist
-[[ "$(grep -Fc '# BEGIN cloud-compose persistent mounts' "$fstab")" == "1" ]] || \
- fail "filesystem persistence is not idempotent"
-grep -Fxq 'daemon-reload' "$systemctl_log" || fail "filesystem persistence did not reload systemd"
-
-printf '# existing filesystem table\n' >"$fstab"
-PATH="$persist_tmp/bin:/usr/bin:/bin" \
- CLOUD_COMPOSE_FSTAB_PATH="$fstab" \
- CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \
- PERSIST_SYSTEMCTL_LOG="$systemctl_log" \
- bash "$persist_script" \
- /dev/disk/by-id/provider-data \
- /dev/disk/by-id/provider-volumes \
- /dev/disk/by-id/provider-prod
-grep -Fq $'/dev/disk/by-id/provider-prod\t/mnt/disks/prod-readonly\text4\tro,nofail,x-systemd.device-timeout=120s\t0\t2' "$fstab" || \
- fail "persistent read-only overlay source was not recorded"
-
-do_data_device=/dev/disk/by-id/scsi-0DO_Volume_cc-do-data
-do_volumes_device=/dev/disk/by-id/scsi-0DO_Volume_cc-do-docker-volumes
-do_data_mount=/mnt/cc_do_data
-do_volumes_mount=/mnt/cc_do_docker_volumes
-do_systemd_dir="$persist_tmp/systemd"
-mkdir -p "$do_systemd_dir/multi-user.target.wants"
-printf '%s\n' \
- "$do_data_device $do_data_mount ext4 defaults,nofail,discard,noatime 0 2" \
- "$do_volumes_device $do_volumes_mount ext4 defaults,nofail,discard,noatime 0 2" \
- >"$fstab"
-printf '[Mount]\nWhat=%s\nWhere=%s\n' "$do_data_device" "$do_data_mount" \
- >"$do_systemd_dir/mnt-cc_do_data.mount"
-printf '[Mount]\nWhat=%s\nWhere=%s\n' "$do_volumes_device" "$do_volumes_mount" \
- >"$do_systemd_dir/mnt-cc_do_docker_volumes.mount"
-ln -s ../mnt-cc_do_data.mount \
- "$do_systemd_dir/multi-user.target.wants/mnt-cc_do_data.mount"
-ln -s ../mnt-cc_do_docker_volumes.mount \
- "$do_systemd_dir/multi-user.target.wants/mnt-cc_do_docker_volumes.mount"
-PATH="$persist_tmp/bin:/usr/bin:/bin" \
- CLOUD_COMPOSE_FSTAB_PATH="$fstab" \
- CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \
- CLOUD_COMPOSE_SYSTEMD_DIR="$do_systemd_dir" \
- PERSIST_SYSTEMCTL_LOG="$systemctl_log" \
- bash "$persist_script" "$do_data_device" "$do_volumes_device"
-if grep -Eq "^[^#]+[[:space:]]+($do_data_mount|$do_volumes_mount)[[:space:]]" "$fstab"; then
- fail "superseded DigitalOcean automatic fstab entries were retained"
-fi
-[[ ! -e "$do_systemd_dir/mnt-cc_do_data.mount" ]] ||
- fail "the provider-owned DigitalOcean data mount unit was retained"
-[[ ! -e "$do_systemd_dir/mnt-cc_do_docker_volumes.mount" ]] ||
- fail "the provider-owned DigitalOcean Docker-volume mount unit was retained"
-[[ ! -L "$do_systemd_dir/multi-user.target.wants/mnt-cc_do_data.mount" ]] ||
- fail "the provider-owned DigitalOcean data mount wants link was retained"
-
-printf '/dev/operator %s ext4 defaults 0 2\n' "$do_data_mount" >"$fstab"
-if PATH="$persist_tmp/bin:/usr/bin:/bin" \
- CLOUD_COMPOSE_FSTAB_PATH="$fstab" \
- CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \
- CLOUD_COMPOSE_SYSTEMD_DIR="$do_systemd_dir" \
- PERSIST_SYSTEMCTL_LOG="$systemctl_log" \
- bash "$persist_script" "$do_data_device" "$do_volumes_device" >/dev/null 2>&1; then
- fail "an unexpected source at a DigitalOcean automatic mount target was replaced"
-fi
-grep -Fq "/dev/operator $do_data_mount" "$fstab" ||
- fail "a conflicting DigitalOcean automatic mount entry was mutated"
-
-printf '# existing filesystem table\n' >"$fstab"
-printf '[Mount]\nWhat=/dev/operator\nWhere=%s\n' "$do_data_mount" \
- >"$do_systemd_dir/mnt-cc_do_data.mount"
-if PATH="$persist_tmp/bin:/usr/bin:/bin" \
- CLOUD_COMPOSE_FSTAB_PATH="$fstab" \
- CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \
- CLOUD_COMPOSE_SYSTEMD_DIR="$do_systemd_dir" \
- PERSIST_SYSTEMCTL_LOG="$systemctl_log" \
- bash "$persist_script" "$do_data_device" "$do_volumes_device" >/dev/null 2>&1; then
- fail "an unexpected DigitalOcean mount unit was removed"
-fi
-[[ -f "$do_systemd_dir/mnt-cc_do_data.mount" ]] ||
- fail "an unexpected DigitalOcean mount unit was mutated"
-
-printf '/dev/operator /mnt/disks/data ext4 defaults 0 2\n' >"$fstab"
-if run_persist >/dev/null 2>&1; then
- fail "filesystem persistence replaced an unmanaged mount target"
-fi
-grep -Fq '/dev/operator /mnt/disks/data' "$fstab" || \
- fail "filesystem persistence mutated a conflicting operator entry"
-if CLOUD_COMPOSE_FSTAB_PATH="$fstab" \
- CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \
- bash "$persist_script" '/dev/disk/by-id/bad device' /dev/disk/by-id/provider-volumes >/dev/null 2>&1; then
- fail "filesystem persistence accepted an unsafe device path"
-fi
-
-grep -Fq 'FILESYSTEM_PERSIST_SCRIPT_B64' "$gcp_filesystem_boothook" || \
- fail "GCP boothook does not bootstrap persistent mount configuration"
-grep -Fq 'FSTAB_RECONCILE_AWK_B64' "$gcp_filesystem_boothook" || \
- fail "GCP boothook does not bootstrap the checked fstab reconciliation program"
-grep -Fq 'require_root_owned_data_program "$filesystem_reconcile"' "$gcp_filesystem_boot" || \
- fail "GCP filesystem bootstrap does not verify its checked fstab reconciliation program"
-grep -Fq 'CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM="$filesystem_reconcile" bash "$filesystem_persist"' \
- "$gcp_filesystem_boot" || \
- fail "GCP filesystem bootstrap does not pass the checked fstab reconciliation program as data"
-grep -Fq 'bash "$filesystem_persist" "$data_device" "$volumes_device"' "$linux_cloud_init" || \
- fail "Linux VM cloud-init does not invoke persistent mount configuration"
-grep -Fq 'install -m 0600 -- /etc/cloud-compose/awk/reconcile-fstab.awk "$filesystem_reconcile"' \
- "$linux_cloud_init" || \
- fail "embedded Linux bootstrap does not stage the checked fstab reconciliation program"
-grep -Fq 'require_root_owned_data_program "$filesystem_reconcile"' "$linux_cloud_init" || \
- fail "Linux bootstrap does not verify its checked fstab reconciliation program"
-grep -Fq '"$staged_rootfs/etc/cloud-compose/awk/reconcile-fstab.awk"' \
- "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" || \
- fail "archive-mode Linux bootstrap does not stage the checked fstab reconciliation program from the verified rootfs"
-grep -Fq '/var/lib/cloud-compose/mounted-rootfs/mnt/disks' "$linux_cloud_init" || \
- fail "Linux VM cloud-init does not copy mounted-root files after mounting durable storage"
-
-do_module="$repo_root/modules/digitalocean/main.tf"
-if grep -Eq 'initial_filesystem_type[[:space:]]*=' "$do_module"; then
- fail "new DigitalOcean volumes still request provider-side formatting and automount"
-fi
-[[ "$(grep -Fc 'ignore_changes = [initial_filesystem_type]' "$do_module")" -eq 2 ]] ||
- fail "DigitalOcean volume upgrades do not preserve existing volume identity across the formatting transition"
-
-echo "Filesystem preparation contract passed"
diff --git a/ci/fixtures/config-management-lifecycle-lock.sh b/ci/fixtures/config-management-lifecycle-lock.sh
deleted file mode 100755
index 3323baf..0000000
--- a/ci/fixtures/config-management-lifecycle-lock.sh
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-action="${1:-}"
-profile="${2:-}"
-
-if [[ "$profile" != "/home/cloud-compose/profile.sh" || ! -f "$profile" || -L "$profile" ]]; then
- echo "config-management lifecycle-lock fixture requires the checked runtime profile" >&2
- exit 2
-fi
-
-# shellcheck disable=SC1090
-source "$profile"
-
-case "$action" in
- hold)
- ready="${3:-}"
- [[ "$#" -eq 3 && "$ready" == /tmp/cloud-compose-lifecycle-lock-ready ]] || {
- echo "usage: config-management-lifecycle-lock.sh hold PROFILE READY" >&2
- exit 2
- }
- acquire_cloud_compose_lifecycle_lock root-first-contract
- touch -- "$ready"
- sleep 3
- release_cloud_compose_lifecycle_lock
- ;;
- contend)
- [[ "$#" -eq 2 ]] || {
- echo "usage: config-management-lifecycle-lock.sh contend PROFILE" >&2
- exit 2
- }
- acquire_cloud_compose_lifecycle_lock contention-contract
- ;;
- subshell)
- [[ "$#" -eq 2 ]] || {
- echo "usage: config-management-lifecycle-lock.sh subshell PROFILE" >&2
- exit 2
- }
- (
- acquire_cloud_compose_lifecycle_lock subshell-contract
- release_cloud_compose_lifecycle_lock
- )
- ;;
- reject-symlink)
- [[ "$#" -eq 2 ]] || {
- echo "usage: config-management-lifecycle-lock.sh reject-symlink PROFILE" >&2
- exit 2
- }
- acquire_cloud_compose_lifecycle_lock symlink-contract
- ;;
- *)
- echo "usage: config-management-lifecycle-lock.sh hold|contend|subshell|reject-symlink ..." >&2
- exit 2
- ;;
-esac
diff --git a/ci/fixtures/lifecycle.d/default-up b/ci/fixtures/lifecycle.d/default-up
deleted file mode 100755
index db50257..0000000
--- a/ci/fixtures/lifecycle.d/default-up
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)"
-exec bash "$repo_root/rootfs/home/cloud-compose/default-lifecycle.sh" up
diff --git a/ci/gcp-upgrade-smoke-contract.sh b/ci/gcp-upgrade-smoke-contract.sh
index d2a668b..e63833a 100755
--- a/ci/gcp-upgrade-smoke-contract.sh
+++ b/ci/gcp-upgrade-smoke-contract.sh
@@ -196,8 +196,6 @@ grep -Fq 'bash /home/cloud-compose/gcp-upgrade-prepare-repository.sh' "$fixture"
fail "upgrade fixture does not invoke its checked initialization program"
grep -Fq '"/etc/cloud-compose/lifecycle.d/gcp-upgrade-up.sh"' "$fixture" ||
fail "upgrade fixture does not invoke its checked lifecycle program"
-grep -Fq '"/home/cloud-compose/default-lifecycle.sh up"' "$context_fixture" ||
- fail "hosted smoke context does not invoke the checked default lifecycle program"
grep -Fq 'sitectl compose --context "$context" up -d --remove-orphans' "$fixture_up" ||
fail "checked upgrade lifecycle program does not bring up the exact sitectl context"
grep -Fq 'sitectl healthcheck --context "$context" --persist' "$fixture_up" ||
@@ -220,7 +218,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 /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh' \
+ grep -nF '/etc/cloud-compose/bin/bootstrap-sitectl host systemd ensure-bootstrap' \
"$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh" |
cut -d: -f1 || true
)"
diff --git a/ci/host-runtime-security.sh b/ci/host-runtime-security.sh
index f133601..101b892 100755
--- a/ci/host-runtime-security.sh
+++ b/ci/host-runtime-security.sh
@@ -2,543 +2,58 @@
set -euo pipefail
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
-tmp="$(mktemp -d)"
-trap 'rm -rf "$tmp"' EXIT
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() {
- echo "host runtime security contract: $*" >&2
+ echo "host runtime security: $*" >&2
exit 1
}
assert_contains() {
- local file="$1" pattern="$2"
- grep -Fq -- "$pattern" "$file" || fail "$file does not contain: $pattern"
+ local file="$1"
+ local text="$2"
+ grep -Fq -- "$text" "$file" || fail "$file is missing: $text"
}
-binding_inventory() {
- local file="$1" marker="$2" line
- local capture=false
-
- while IFS= read -r line; do
- if [[ "$line" == *"$marker"* ]]; then
- capture=true
- fi
- if [[ "$capture" == "true" ]]; then
- printf '%s\n' "$line"
- if [[ "$line" != *\\ ]]; then
- capture=false
- fi
- fi
- done <"$file"
-}
-
-mkdir -p "$tmp/bin"
-
-cat >"$tmp/profile.sh" <<'EOF'
-#!/usr/bin/env bash
-export CLOUD_COMPOSE_PROVIDER="${TEST_PROVIDER:-}"
-EOF
-
-cat >"$tmp/bin/iptables" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-
-: "${IPTABLES_LOG:?}"
-printf '%q ' "$@" >>"$IPTABLES_LOG.calls"
-printf '\n' >>"$IPTABLES_LOG.calls"
-
-table=filter
-if [[ "$1" == "-t" ]]; then
- table="$2"
- shift 2
-fi
-
-if [[ "$1" == "-nL" ]]; then
- [[ "${IPTABLES_DOCKER_USER_MISSING:-false}" != "true" && "$2" == "DOCKER-USER" ]]
- exit
-fi
-
-operation="$1"
-chain="$2"
-shift 2
-if [[ "$operation" == "-I" ]]; then
- shift
-fi
-rule_prefix=""
-if [[ "$table" != "filter" ]]; then
- rule_prefix="${table} "
-fi
-rule="${rule_prefix}${chain} $*"
-
-case "$operation" in
- -C)
- grep -Fxq -- "$rule" "$IPTABLES_LOG"
- ;;
- -I)
- {
- printf '%s\n' "$rule"
- cat "$IPTABLES_LOG"
- } >"${IPTABLES_LOG}.tmp"
- mv -f "${IPTABLES_LOG}.tmp" "$IPTABLES_LOG"
- ;;
- *)
- echo "unexpected fake iptables operation: $operation" >&2
- exit 2
- ;;
-esac
-EOF
-chmod +x "$tmp/bin/iptables"
-
-firewall_script="$repo_root/rootfs/home/cloud-compose/configure-metadata-firewall.sh"
-firewall_log="$tmp/iptables.rules"
-: >"$firewall_log"
-: >"$firewall_log.calls"
-
-for _ in 1 2; do
- TEST_PROVIDER=gcp \
- CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- IPTABLES_LOG="$firewall_log" \
- PATH="$tmp/bin:$PATH" \
- bash "$firewall_script"
-done
-
-expected_rules="$tmp/expected.rules"
-cat >"$expected_rules" <<'EOF'
-FORWARD -d 169.254.169.254/32 -p udp --dport 53 -j ACCEPT
-FORWARD -d 169.254.169.254/32 -p tcp --dport 53 -j ACCEPT
-FORWARD -d 169.254.169.254/32 -j DROP
-DOCKER-USER -d 169.254.169.254/32 -p udp --dport 53 -j ACCEPT
-DOCKER-USER -d 169.254.169.254/32 -p tcp --dport 53 -j ACCEPT
-DOCKER-USER -d 169.254.169.254/32 -j DROP
-OUTPUT -m owner ! --uid-owner 0 -d 169.254.169.254/32 -p tcp --dport 443 -j DROP
-OUTPUT -m owner ! --uid-owner 0 -d 169.254.169.254/32 -p tcp --dport 80 -j DROP
-mangle PREROUTING -d 169.254.169.254/32 -p udp --dport 53 -j ACCEPT
-mangle PREROUTING -d 169.254.169.254/32 -p tcp --dport 53 -j ACCEPT
-mangle PREROUTING -d 169.254.169.254/32 -j DROP
-EOF
-cmp -s "$expected_rules" "$firewall_log" || fail "metadata firewall rules are incomplete or not idempotent"
-
-pre_firewall_log="$tmp/pre-docker.rules"
-: >"$pre_firewall_log"
-: >"$pre_firewall_log.calls"
-TEST_PROVIDER=gcp \
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
-IPTABLES_LOG="$pre_firewall_log" \
-IPTABLES_DOCKER_USER_MISSING=true \
-PATH="$tmp/bin:$PATH" \
- bash "$firewall_script" pre-docker
-awk '$1 == "OUTPUT" || $1 == "mangle"' "$expected_rules" | cmp -s - "$pre_firewall_log" || \
- fail "pre-Docker metadata firewall does not protect containers and unprivileged host processes"
-
-calls_before="$(wc -l <"$firewall_log.calls")"
-TEST_PROVIDER=linode \
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
-IPTABLES_LOG="$firewall_log" \
-PATH="$tmp/bin:$PATH" \
- bash "$firewall_script"
-calls_after="$(wc -l <"$firewall_log.calls")"
-[[ "$calls_before" == "$calls_after" ]] || fail "non-GCP metadata policy invoked iptables"
-
-if TEST_PROVIDER='' CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" IPTABLES_LOG="$firewall_log" PATH="$tmp/bin:$PATH" \
- bash "$firewall_script" >/dev/null 2>&1; then
- fail "metadata policy accepted a missing provider"
-fi
-
-if TEST_PROVIDER=gcp CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" IPTABLES_LOG="$firewall_log" \
- IPTABLES_DOCKER_USER_MISSING=true PATH="$tmp/bin:$PATH" bash "$firewall_script" >/dev/null 2>&1; then
- fail "metadata policy accepted a missing DOCKER-USER chain"
-fi
-
-host_conf="$repo_root/rootfs/home/cloud-compose/host-conf.sh"
-assert_contains "$host_conf" 'systemctl enable --now cloud-compose-metadata-firewall-pre.service'
-assert_contains "$host_conf" 'systemctl enable --now cloud-compose-metadata-firewall.service'
-docker_restart_line="$(grep -nF 'systemctl restart docker' "$host_conf" | head -n1 | cut -d: -f1)"
-early_firewall_line="$(grep -nF 'bash /home/cloud-compose/configure-metadata-firewall.sh' "$host_conf" | head -n1 | cut -d: -f1)"
-late_firewall_line="$(grep -nF 'bash /home/cloud-compose/configure-metadata-firewall.sh' "$host_conf" | tail -n1 | cut -d: -f1)"
-dependency_line="$(grep -nF 'bash /home/cloud-compose/install-dependencies.sh' "$host_conf" | head -n1 | cut -d: -f1)"
-[[ -n "$early_firewall_line" && -n "$docker_restart_line" && -n "$late_firewall_line" &&
- -n "$dependency_line" && "$early_firewall_line" -lt "$dependency_line" &&
- "$dependency_line" -lt "$docker_restart_line" &&
- "$docker_restart_line" -lt "$late_firewall_line" ]] || \
- fail "GCP metadata isolation is not converged before and after Docker bootstrap"
-if grep -Eq '8\.8\.8\.8|8\.8\.4\.4|1\.1\.1\.1' \
- "$host_conf" "$repo_root/rootfs/home/cloud-compose/install-dependencies-cos.sh"; then
- fail "GCP runtime bypasses Compute Engine and private-zone DNS"
-fi
-if grep -Fq -- '-i docker0' "$host_conf"; then
- fail "metadata policy still depends on docker0"
-fi
-assert_contains "$repo_root/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service" 'After=docker.service'
-assert_contains "$repo_root/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service" 'PartOf=docker.service'
-assert_contains "$repo_root/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service" 'Before=docker.service'
-assert_contains "$repo_root/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service" 'WantedBy=multi-user.target'
-assert_contains "$repo_root/rootfs/etc/systemd/system/docker.service.d/cloud-compose-metadata-firewall.conf" 'Requires=cloud-compose-metadata-firewall-pre.service'
-assert_contains "$repo_root/docs/runtime-contracts.md" 'COS reconstructs `/etc` after networking'
-
-profile_script="$repo_root/rootfs/home/cloud-compose/profile.sh"
-assert_contains "$profile_script" 'if ((EUID == 0)); then'
-assert_contains "$profile_script" 'export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"'
-tmpfiles_conf="$repo_root/rootfs/etc/tmpfiles.d/cloud-compose.conf"
-assert_contains "$tmpfiles_conf" 'd /run/lock/cloud-compose 0750 root cloud-compose -'
-assert_contains "$tmpfiles_conf" 'f /run/lock/cloud-compose/lifecycle.lock 0660 root cloud-compose -'
-assert_contains "$tmpfiles_conf" 'd /var/lib/cloud-compose 0755 root root -'
-assert_contains "$tmpfiles_conf" 'd /home/cloud-compose 0755 root root -'
-host_init="$repo_root/rootfs/home/cloud-compose/host-init.sh"
-if grep -Eq 'chown[[:space:]]+-R[[:space:]]+cloud-compose[^[:space:]]*[[:space:]]+/home/cloud-compose' "$host_init"; then
- fail "host initialization gives the app account recursive ownership of root-executed code"
-fi
-if grep -Eq 'chown[[:space:]]+-R[[:space:]]+cloud-compose[^[:space:]]*[[:space:]]+/mnt/disks' "$host_init"; then
- fail "host initialization recursively changes ownership of persistent data"
-fi
-assert_contains "$host_init" 'chown root:root /home/cloud-compose'
-assert_contains "$host_init" "-exec chown root:root {} +"
-assert_contains "$host_init" 'Unsafe Cloud Compose lifecycle dispatcher:'
-assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh" \
- 'Cloud Compose control input is not root-controlled'
-assert_contains "$host_init" '/home/cloud-compose/.sitectl \'
-assert_contains "$host_init" 'install -d -m 0750 -o cloud-compose -g cloud-compose "$mutable_dir"'
-assert_contains "$host_init" 'Managed command directory was not secured by the runtime installer'
-if sed -n '/for mutable_dir in/,/done/p' "$host_init" | grep -Fq '/home/cloud-compose/bin'; then
- fail "host initialization leaves the privileged published command directory app-writable"
-fi
-assert_contains "$host_init" 'install -d -m 1775 -o root -g cloud-compose /mnt/disks/data'
-assert_contains "$host_init" 'install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/volumes'
-assert_contains "$host_init" 'install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops'
-managed_runtime="$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh"
-assert_contains "$managed_runtime" 'prepare_managed_runtime_directory'
-assert_contains "$managed_runtime" 'validate_published_bin_directory'
-assert_contains "$managed_runtime" '$STATE_DIR:0755:false'
-assert_contains "$managed_runtime" '$PUBLISHED_BIN_DIR:0755:true'
-assert_contains "$managed_runtime" 'managed runtime updates must run as root'
-assert_contains "$managed_runtime" 'production managed runtime directories require a root updater'
-if grep -A12 -F 'source_compose_app_env()' "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" | \
- grep -Eq 'source[[:space:]]+.*COMPOSE_APPS_ENV_DIR'; then
- fail "privileged Compose manifest loading still sources an app-writable shell file"
-fi
-
-curl_marker="$tmp/curl.called"
-cat >"$tmp/bin/curl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-: "${CURL_MARKER:?}"
-touch "$CURL_MARKER"
-exit 22
-EOF
-chmod +x "$tmp/bin/curl"
-
-daily_script="$repo_root/rootfs/home/cloud-compose/rotate-keys-daily.sh"
-TEST_PROVIDER=linode \
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
-CURL_MARKER="$curl_marker" \
-PATH="$tmp/bin:$PATH" \
- bash "$daily_script"
-[[ ! -e "$curl_marker" ]] || fail "non-GCP rotation contacted a GCP endpoint"
-
-if TEST_PROVIDER='' CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" CURL_MARKER="$curl_marker" PATH="$tmp/bin:$PATH" \
- bash "$daily_script" >/dev/null 2>&1; then
- fail "rotation accepted a missing provider"
-fi
-
-rotate_script="$repo_root/rootfs/home/cloud-compose/rotate-keys.sh"
-if TEST_PROVIDER=gcp CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" CURL_MARKER="$curl_marker" PATH="$tmp/bin:$PATH" \
- bash "$rotate_script" prepare app@example.invalid test-project "$tmp/credentials.json" >/dev/null 2>&1; then
- fail "rotation masked an unavailable metadata endpoint"
-fi
-
-assert_contains "$rotate_script" 'prepare|status|audit|recover|authenticate|ready|commit|rollback|rollback-ready'
-assert_contains "$rotate_script" 'ROTATION_DISABLE_GRACE_SECONDS'
-assert_contains "$repo_root/rootfs/etc/cloud-compose/jq/service-account-credentials-valid.jq" \
- 'https://oauth2.googleapis.com/token'
-assert_contains "$rotate_script" 'KEY_OPERATION_RESULT=absent'
-
-run_script="$repo_root/rootfs/home/cloud-compose/run.sh"
-assert_contains "$run_script" 'systemctl enable --now cloud-compose-key-rotation.timer'
-if grep -Eq 'rotate-keys[^[:space:]]*\.sh[[:space:]]*\|\|[[:space:]]*true' "$run_script"; then
- fail "GCP key-rotation failures are still masked during bootstrap"
-fi
-if grep -Fq 'rotate-keys' "$repo_root/rootfs/home/cloud-compose/docker-prune.sh"; then
- fail "provider-neutral Docker prune still invokes GCP key rotation"
-fi
-
-archive_source="$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh"
-verify_line="$(grep -n 'sha256sum -c -' "$archive_source" | head -n 1 | cut -d: -f1)"
-source_members_line="$(grep -n 'validate_rootfs_test_source_archive "\$stage_root/rootfs.tar.gz"' "$archive_source" | head -n 1 | cut -d: -f1)"
-source_extract_line="$(grep -n 'tar --no-same-owner --same-permissions -xzf "\$stage_root/rootfs.tar.gz"' "$archive_source" | head -n 1 | cut -d: -f1)"
-members_line="$(grep -n 'validate_rootfs_archive "\$stage_root/rootfs.tar.gz"' "$archive_source" | head -n 1 | cut -d: -f1)"
-extract_line="$(grep -n 'tar --no-same-owner --same-permissions -xzf "\$stage_root/rootfs.tar.gz"' "$archive_source" | tail -n 1 | cut -d: -f1)"
-contract_line="$(grep -n 'rootfs archive paths, bytes, or canonical metadata do not match this cloud-compose module source' "$archive_source" | head -n 1 | cut -d: -f1)"
-copy_line="$(grep -n 'cp -a "\$staged_rootfs"/. /' "$archive_source" | head -n 1 | cut -d: -f1)"
-[[ -n "$verify_line" && -n "$source_members_line" && -n "$source_extract_line" &&
- -n "$members_line" && -n "$extract_line" && -n "$contract_line" && -n "$copy_line" &&
- "$verify_line" -lt "$source_members_line" && "$source_members_line" -lt "$source_extract_line" &&
- "$source_extract_line" -lt "$contract_line" && "$verify_line" -lt "$members_line" &&
- "$members_line" -lt "$extract_line" && "$extract_line" -lt "$contract_line" &&
- "$contract_line" -lt "$copy_line" ]] || \
- fail "$archive_source does not verify archive bytes and canonical rootfs metadata before installation"
-assert_contains "$archive_source" "stat -c '%a:%h:%F'"
-assert_contains "$archive_source" '[[ "$metadata" == "${expected_mode}:1:regular file" ]]'
-assert_contains "$archive_source" '[[ "$require_root_owner" != "true" || "$owner" == "0:0" ]]'
-
-if [[ -n "$(git -C "$repo_root" ls-files 'rootfs/usr/**')" ]]; then
- fail "Cloud Compose-owned rootfs programs still target immutable /usr"
-fi
-for trusted_program in \
- rootfs/etc/cloud-compose/awk/compose-secret-files.awk \
- rootfs/etc/cloud-compose/awk/reconcile-fstab.awk \
- rootfs/etc/cloud-compose/awk/release-checksum.awk \
- rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh \
- rootfs/etc/cloud-compose/libexec/checked-programs.bash \
- rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh \
- rootfs/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh \
- rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh \
- rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh \
- rootfs/etc/cloud-compose/libexec/build-cos-make.sh \
- rootfs/etc/cloud-compose/libexec/bootstrap-security.sh \
- rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh \
- rootfs/etc/cloud-compose/libexec/rootfs-archive.sh \
- rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh \
- rootfs/etc/cloud-compose/libexec/run-bootstrap.sh \
- rootfs/etc/cloud-compose/libexec/run-root-program.sh \
- rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq \
- rootfs/etc/cloud-compose/jq/compose-validate-projects.jq \
- rootfs/etc/cloud-compose/jq/rotation-validate-state.jq \
- rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq; do
- [[ -f "$repo_root/$trusted_program" ]] || fail "COS-safe trusted program is missing: $trusted_program"
-done
-
-checked_programs="$repo_root/rootfs/etc/cloud-compose/libexec/checked-programs.bash"
-assert_contains "$checked_programs" 'candidate_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"'
-assert_contains "$checked_programs" 'installed_home="$(cloud_compose_installed_home)" || return 2'
-assert_contains "$checked_programs" 'Installed Cloud Compose home resolves to the filesystem root'
-assert_contains "$checked_programs" '"$alias_target" != "var/home"'
-assert_contains "$checked_programs" "stat -c '%u:%g:%a'"
-assert_contains "$checked_programs" "stat -c '%u:%g:%a:%h'"
-assert_contains "$checked_programs" '"$program" == "$program_dir/$program_name"'
-assert_contains "$checked_programs" 'cloud_compose_validate_installed_program_dir "$selected"'
-assert_contains "$checked_programs" '$((8#$mode & 0022)) -ne 0'
-assert_contains "$checked_programs" '"$links" != 1'
-assert_contains "$checked_programs" 'cannot override the installed Cloud Compose program'
-assert_contains "$checked_programs" 'cannot override the installed Cloud Compose source program'
-assert_contains "$checked_programs" 'readonly "$variable_name"'
-if grep -Fq '%F' "$checked_programs"; then
- fail "checked program validation relies on localized file-type labels"
-fi
-
-while IFS= read -r checked_consumer; do
- assert_contains "$checked_consumer" '/etc/cloud-compose/libexec/checked-programs.bash'
- assert_contains "$checked_consumer" 'readlink -f -- /home/cloud-compose'
-done < <(grep -RlE --include='*.sh' \
- 'cloud_compose_bind_(program|program_dir|source_program)' \
- "$repo_root/rootfs/home/cloud-compose")
-
-if grep -REq --include='*.sh' '\$\{![A-Za-z_]' \
- "$repo_root/rootfs/home/cloud-compose"; then
- fail "indirect parameter expansion bypasses the installed source/program inventory"
-fi
-
-source_reference_pattern='\$CLOUD_COMPOSE_[A-Z0-9_]*_PATH([^A-Z0-9_]|$)|\$\{CLOUD_COMPOSE_[A-Z0-9_]*_PATH([^A-Z0-9_]|$)'
-while IFS= read -r source_consumer; do
- source_binding_inventory="$(binding_inventory \
- "$source_consumer" cloud_compose_bind_source_program)"
- mapfile -t referenced_source_variables < <(
- grep -oE \
- '\$CLOUD_COMPOSE_[A-Z0-9_]*_PATH|\$\{CLOUD_COMPOSE_[A-Z0-9_]*_PATH([^A-Z0-9_]|$)' \
- "$source_consumer" |
- sed -E 's/^\$\{//; s/^\$//; s/([^A-Z0-9_]).*$//' |
- sort -u
- )
- requires_source_binder=false
- for source_variable in "${referenced_source_variables[@]}"; do
- case "$source_variable" in
- CLOUD_COMPOSE_DOCKER_PRUNE_LOCK_PATH)
- [[ "$source_consumer" == "$repo_root/rootfs/home/cloud-compose/docker-prune.sh" ]] || \
- fail "$source_variable is registered only as docker-prune lock data"
- continue
- ;;
- CLOUD_COMPOSE_FSTAB_PATH | CLOUD_COMPOSE_FSTAB_LOCK_PATH)
- [[ "$source_consumer" == "$repo_root/rootfs/home/cloud-compose/persist-filesystems.sh" ]] || \
- fail "$source_variable is registered only as fstab data"
- continue
- ;;
- esac
- requires_source_binder=true
- grep -Eq "(^|[^A-Z0-9_])${source_variable}([^A-Z0-9_]|$)" \
- <<<"$source_binding_inventory" || \
- fail "$source_consumer references $source_variable without binding that exact source variable"
- done
- if [[ "$requires_source_binder" == "true" ]]; then
- assert_contains "$source_consumer" 'cloud_compose_bind_source_program'
- assert_contains "$source_consumer" '/etc/cloud-compose/libexec/checked-programs.bash'
- assert_contains "$source_consumer" 'readlink -f -- /home/cloud-compose'
- fi
-done < <(grep -RlE --include='*.sh' \
- "$source_reference_pattern" \
- "$repo_root/rootfs/home/cloud-compose")
-
-program_reference_pattern='\$CLOUD_COMPOSE_[A-Z0-9_]*(PROGRAM|PROGRAM_DIR)([^A-Z0-9_]|$)|\$\{CLOUD_COMPOSE_[A-Z0-9_]*(PROGRAM|PROGRAM_DIR)([^A-Z0-9_]|$)'
-while IFS= read -r program_consumer; do
- program_dir_binding_inventory="$(binding_inventory \
- "$program_consumer" cloud_compose_bind_program_dir)"
- program_binding_inventory="$(binding_inventory \
- "$program_consumer" 'cloud_compose_bind_program ')"
- mapfile -t referenced_program_variables < <(
- grep -oE \
- '\$CLOUD_COMPOSE_[A-Z0-9_]*(PROGRAM|PROGRAM_DIR)|\$\{CLOUD_COMPOSE_[A-Z0-9_]*(PROGRAM|PROGRAM_DIR)([^A-Z0-9_]|$)' \
- "$program_consumer" |
- sed -E 's/^\$\{//; s/^\$//; s/([^A-Z0-9_]).*$//' |
- sort -u
- )
- for program_variable in "${referenced_program_variables[@]}"; do
- case "$program_variable" in
- *_PROGRAM_DIR) binding_inventory="$program_dir_binding_inventory" ;;
- *) binding_inventory="$program_binding_inventory" ;;
- esac
- grep -Eq "(^|[^A-Z0-9_])${program_variable}([^A-Z0-9_]|$)" \
- <<<"$binding_inventory" || \
- fail "$program_consumer references $program_variable without binding that exact program variable"
- done
- assert_contains "$program_consumer" '/etc/cloud-compose/libexec/checked-programs.bash'
- assert_contains "$program_consumer" 'readlink -f -- /home/cloud-compose'
-done < <(grep -RlE --include='*.sh' \
- "$program_reference_pattern" \
- "$repo_root/rootfs/home/cloud-compose")
-
-assert_contains "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" \
- '/etc/cloud-compose/awk/compose-secret-files.awk'
-[[ ! -e "$repo_root/rootfs/home/cloud-compose/compose-secret-files.awk" ]] || \
- fail "Compose secret extraction program remains under the runtime home"
-assert_contains "$rotate_script" 'cloud_compose_bind_source_program'
-assert_contains "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" \
- 'CLOUD_COMPOSE_ROTATE_KEYS_PATH'
-assert_contains "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" \
- 'CLOUD_COMPOSE_COMPOSE_APPS_PATH'
-assert_contains "$managed_runtime" 'cloud_compose_bind_source_program'
-
-assert_contains "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" \
- '-f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-validate-projects.jq"'
-assert_contains "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" \
- 'if [[ "$filter_status" -ne 1 ]]; then'
-assert_contains "$repo_root/rootfs/home/cloud-compose/profile.sh" \
- '-f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/application-env-validate.jq"'
-assert_contains "$rotate_script" \
- '-f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-validate-state.jq"'
-assert_contains "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" \
- '-f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/service-account-credentials-valid.jq"'
-assert_contains "$managed_runtime" \
- '-f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/sitectl-package-versions-validate.jq"'
-assert_contains "$repo_root/rootfs/home/cloud-compose/persist-filesystems.sh" \
- '-f "$fstab_reconcile_program"'
-assert_contains "$repo_root/rootfs/home/cloud-compose/install-docker-plugins.sh" \
- '-f "$release_checksum_program"'
+profile="$repo_root/rootfs/home/cloud-compose/profile.sh"
+hardener="$repo_root/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh"
+installer="$repo_root/rootfs/etc/cloud-compose/libexec/install-rootfs.py"
+launcher="$repo_root/rootfs/etc/cloud-compose/libexec/sitectl-host.sh"
+diagnostics="$repo_root/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"
+
+assert_contains "$profile" 'decode_runtime_env_value() {'
+assert_contains "$repo_root/rootfs/home/cloud-compose/host-conf.sh" 'host runtime install'
+sitectl_bootstrap="$repo_root/rootfs/etc/cloud-compose/libexec/bootstrap-sitectl.sh"
+assert_contains "$sitectl_bootstrap" "sha256sum -c -"
+assert_contains "$sitectl_bootstrap" '[[ "$entry" == sitectl ]]'
+assert_contains "$sitectl_bootstrap" '[[ "$sitectl_entries" -eq 1 ]]'
+assert_contains "$sitectl_bootstrap" 'tar -xzf "$tmp/$archive" -C "$tmp" -- sitectl'
+assert_contains "$sitectl_bootstrap" '"regular file:1"'
+assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh" \
+ '"$sitectl" host filesystems'
+assert_contains "$repo_root/rootfs/home/cloud-compose/install-docker-plugins.sh" 'host docker-plugins'
+assert_contains "$launcher" 'source /home/cloud-compose/profile.sh'
+assert_contains "$launcher" 'exec "$sitectl" host "$@"'
+assert_contains "$diagnostics" 'exec /etc/cloud-compose/libexec/sitectl-host.sh diagnostics "$@"'
+assert_contains "$hardener" 'host security secure-runtime'
assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh" \
'/etc/cloud-compose/libexec/harden-bootstrap-paths.sh'
assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh" \
'/etc/cloud-compose/libexec/harden-bootstrap-paths.sh'
-assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh" \
- 'chown root:root "$cloud_compose_home"'
-assert_contains "$repo_root/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh" \
- "0:1:regular file"
-for cloud_init_program in \
- "$repo_root/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh" \
- "$repo_root/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh"; do
- assert_contains "$cloud_init_program" 'install-diagnostics "$diagnostics_sha256"'
- if grep -Eq 'install -d[^#]* /usr/local' "$cloud_init_program"; then
- fail "$cloud_init_program writes Cloud Compose-owned programs beneath immutable /usr"
- fi
-done
-for variables_file in \
- "$repo_root/variables.tf" \
- "$repo_root/modules/digitalocean/variables.tf" \
- "$repo_root/modules/linode/variables.tf" \
- "$repo_root/providers/gcp/variables.tf" \
- "$repo_root/providers/do/variables.tf" \
- "$repo_root/providers/linode/variables.tf"; do
- assert_contains "$variables_file" 'runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together.'
+assert_contains "$installer" 'if os.geteuid() != 0'
+assert_contains "$installer" 'SAFE_MODES = {"0644", "0755"}'
+assert_contains "$installer" 'stat.S_ISLNK(metadata.st_mode)'
+assert_contains "$installer" 'os.fsync(output.fileno())'
+assert_contains "$installer" 'pwd.getpwnam("cloud-compose")'
+assert_contains "$installer" 'os.O_DIRECTORY | os.O_NOFOLLOW'
+assert_contains "$installer" 'os.fchown(descriptor, 0, 0)'
+
+for retired in \
+ "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" \
+ "$repo_root/rootfs/home/cloud-compose/default-lifecycle.sh" \
+ "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs"'-archive.sh'; do
+ test ! -e "$retired" || fail "retired privileged runtime remains: $retired"
done
-
-gcp_module="$repo_root/modules/gcp/main.tf"
-assert_contains "$gcp_module" 'internal_services_enabled = var.libops_internal_services_enabled || var.power_management_enabled'
-assert_contains "$gcp_module" 'count = local.internal_services_enabled ? 1 : 0'
-assert_contains "$gcp_module" 'count = var.power_management_enabled ? 1 : 0'
-assert_contains "$repo_root/modules/gcp/outputs.tf" '} : null'
-assert_contains "$daily_script" 'case "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" in'
-assert_contains "$daily_script" 'true) bash "$script_dir/rotate-keys-internal.sh" ;;'
-assert_contains "$daily_script" 'bash "$script_dir/rotate-keys-app.sh"'
-assert_contains "$repo_root/rootfs/home/cloud-compose/run.sh" 'runtime_enabled "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}"'
-assert_contains "$repo_root/rootfs/home/cloud-compose/run.sh" 'systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service'
-
-managed_services_dir="$tmp/internal-services"
-managed_services_log="$tmp/internal-services.log"
-mkdir -p "$managed_services_dir"
-: >"$managed_services_dir/docker-compose.yaml"
-: >"$managed_services_log"
-
-cat >"$tmp/bin/docker" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-printf 'docker %s\n' "$*" >>"${MANAGED_SERVICES_LOG:?}"
-EOF
-cat >"$tmp/bin/systemctl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-printf 'systemctl %s\n' "$*" >>"${MANAGED_SERVICES_LOG:?}"
-if [[ "${1:-}" == "is-active" ]]; then
- [[ "${MANAGED_SERVICE_ACTIVE:-false}" == "true" ]]
-fi
-EOF
-chmod +x "$tmp/bin/docker" "$tmp/bin/systemctl"
-
-(
- export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh"
- export LIBOPS_INTERNAL_SERVICES_DIR="$managed_services_dir"
- export LIBOPS_INTERNAL_SERVICES_ENABLED=false
- export LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE=true
- export MANAGED_SERVICES_LOG="$managed_services_log"
- export PATH="$tmp/bin:$PATH"
- # shellcheck disable=SC1090
- source "$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh"
- retry_until_success() { "$@"; }
- update_internal_services
-)
-[[ ! -s "$managed_services_log" ]] || fail "disabled internal services invoked Docker or systemd during auto-update"
-
-(
- export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh"
- export LIBOPS_INTERNAL_SERVICES_DIR="$managed_services_dir"
- export LIBOPS_INTERNAL_SERVICES_ENABLED=true
- export LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE=true
- export MANAGED_SERVICES_LOG="$managed_services_log"
- export PATH="$tmp/bin:$PATH"
- # shellcheck disable=SC1090
- source "$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh"
- retry_until_success() { "$@"; }
- update_internal_services
-)
-grep -Fq 'docker compose pull' "$managed_services_log" || fail "enabled internal service update did not pull"
-if grep -Eq 'docker compose up|systemctl restart' "$managed_services_log"; then
- fail "auto-update started or restarted inactive internal services"
-fi
-
-: >"$managed_services_log"
-(
- export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh"
- export LIBOPS_INTERNAL_SERVICES_DIR="$managed_services_dir"
- export LIBOPS_INTERNAL_SERVICES_ENABLED=true
- export LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE=true
- export MANAGED_SERVICE_ACTIVE=true
- export MANAGED_SERVICES_LOG="$managed_services_log"
- export PATH="$tmp/bin:$PATH"
- # shellcheck disable=SC1090
- source "$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh"
- retry_until_success() { "$@"; }
- update_internal_services
-)
-grep -Fq 'docker compose pull' "$managed_services_log" || fail "active internal service update did not pull"
-grep -Fq 'systemctl restart cloud-compose-internal-services.service' "$managed_services_log" || \
- fail "active internal service update did not restart its existing unit"
-
-echo "Host runtime security contracts passed"
diff --git a/ci/inline-data-program-contract.sh b/ci/inline-data-program-contract.sh
deleted file mode 100755
index 6c15dd7..0000000
--- a/ci/inline-data-program-contract.sh
+++ /dev/null
@@ -1,66 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-readonly repo_root
-
-fail() {
- echo "inline data program contract: $*" >&2
- exit 1
-}
-
-unchecked_data_program_invocation() {
- local logical_command="$1"
- local invocation_pattern
-
- # Match commands at a statement boundary, in a command/process
- # substitution, or after a pipeline/control operator. This intentionally
- # ignores package names, filenames, and prose that merely mention jq/awk.
- invocation_pattern='(^|[|;&]|[$][(]|<[(])[[:space:]]*(if|elif|while|until|then)?[[:space:]]*!?[[:space:]]*(command[[:space:]]+)?(jq|awk)([[:space:]]|$)'
- [[ "$logical_command" =~ $invocation_pattern ]] || return 1
- [[ ! "$logical_command" =~ (^|[[:space:]])-f([[:space:]]|$) ]]
-}
-
-# Keep the scanner itself fail-closed as its shell matching evolves.
-unchecked_data_program_invocation 'value="$(jq -r '\''keys[]'\'' input.json)"' || \
- fail "scanner did not recognize inline jq"
-unchecked_data_program_invocation 'awk -v key=value '\''$1 == key { print }'\'' input' || \
- fail "scanner did not recognize inline awk"
-if unchecked_data_program_invocation 'jq -r -f "$program" input.json'; then
- fail "scanner rejected checked jq"
-fi
-if unchecked_data_program_invocation 'awk -v key=value -f "$program" input'; then
- fail "scanner rejected checked awk"
-fi
-
-while IFS= read -r -d '' script; do
- IFS= read -r shebang <"$script" || continue
- case "$shebang" in
- '#!/usr/bin/env bash' | '#!/bin/bash' | '#!/bin/sh') ;;
- *) continue ;;
- esac
- logical_command=""
- logical_start=0
- line_number=0
- while IFS= read -r line || [[ -n "$line" ]]; do
- ((line_number += 1))
- if [[ -z "$logical_command" ]]; then
- logical_start="$line_number"
- fi
- if [[ "${line: -1}" == "\\" ]]; then
- logical_command+="${line::-1} "
- continue
- fi
- logical_command+="$line"
- if unchecked_data_program_invocation "$logical_command"; then
- fail "${script#$repo_root/}:$logical_start invokes jq/awk without a checked -f program"
- fi
- logical_command=""
- done <"$script"
- if [[ -n "$logical_command" ]]; then
- fail "${script#$repo_root/}:$logical_start ends with an incomplete continued command"
- fi
-done < <(find "$repo_root/rootfs" -type f -print0)
-
-echo "Inline data program contract passed"
diff --git a/ci/key-rotation-contract.sh b/ci/key-rotation-contract.sh
deleted file mode 100755
index d529f80..0000000
--- a/ci/key-rotation-contract.sh
+++ /dev/null
@@ -1,1158 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
-tmp="$(mktemp -d)"
-trap 'rm -rf -- "$tmp"' EXIT
-
-fail() {
- echo "key rotation contract: $*" >&2
- exit 1
-}
-
-mkdir -p "$tmp/bin" "$tmp/apps/alpha" "$tmp/apps/beta"
-order_log="$tmp/order.log"
-post_count="$tmp/post-count"
-auth_count="$tmp/auth-count"
-list_count="$tmp/list-count"
-sync_count="$tmp/sync-count"
-key_state="$tmp/key-state.json"
-: >"$order_log"
-printf '0\n' >"$post_count"
-printf '0\n' >"$auth_count"
-printf '0\n' >"$list_count"
-printf '0\n' >"$sync_count"
-
-cat >"$tmp/profile.sh" <<'EOF'
-#!/usr/bin/env bash
-export CLOUD_COMPOSE_PROVIDER=gcp
-export PATH="${TEST_BIN:?}:/usr/bin:/bin"
-EOF
-
-cat >"$tmp/compose-apps.sh" <<'EOF'
-#!/usr/bin/env bash
-compose_app_names_array() {
- local -n result="$1"
- result=(alpha beta)
-}
-source_compose_app_env() {
- case "$1" in
- alpha) DOCKER_COMPOSE_DIR="${TEST_ROOT:?}/apps/alpha" ;;
- beta) DOCKER_COMPOSE_DIR="${TEST_ROOT:?}/apps/beta" ;;
- *) return 1 ;;
- esac
-}
-validate_compose_git_source() {
- [[ "${FAKE_SOURCE_INVALID:-false}" != "true" ]]
-}
-EOF
-
-cat >"$tmp/bin/chown" <<'EOF'
-#!/usr/bin/env bash
-printf 'CHOWN %s\n' "$*" >>"${ORDER_LOG:?}"
-exit 0
-EOF
-cat >"$tmp/bin/chgrp" <<'EOF'
-#!/usr/bin/env bash
-if [[ "${FAKE_CHGRP_FAIL_BETA:-false}" == "true" && "${!#}" == */apps/beta/secrets ]]; then
- exit 1
-fi
-printf 'CHGRP %s\n' "$*" >>"${ORDER_LOG:?}"
-exit 0
-EOF
-cat >"$tmp/bin/sleep" <<'EOF'
-#!/usr/bin/env bash
-printf 'SLEEP %s\n' "${1:-}" >>"${ORDER_LOG:?}"
-EOF
-cat >"$tmp/bin/sync" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-count="$(<"${SYNC_COUNT:?}")"
-count=$((count + 1))
-printf '%s\n' "$count" >"$SYNC_COUNT"
-printf 'SYNC %s\n' "$count" >>"${ORDER_LOG:?}"
-if [[ -n "${FAKE_SYNC_FAIL_AT:-}" && "$count" == "$FAKE_SYNC_FAIL_AT" ]]; then
- exit 1
-fi
-EOF
-cat >"$tmp/bin/openssl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-if [[ "${1:-}" == "dgst" ]]; then
- cat >/dev/null
- printf 'test-signature'
- exit 0
-fi
-echo "unexpected openssl call: $*" >&2
-exit 2
-EOF
-
-cat >"$tmp/bin/systemctl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-case "${1:-}" in
- show)
- property=""
- for argument in "$@"; do
- case "$argument" in
- --property=*) property="${argument#--property=}" ;;
- esac
- done
- unit="${!#}"
- if [[ "${FAKE_SYSTEMCTL_QUERY_FAIL:-false}" == "true" ]]; then
- exit 1
- fi
- case "$property" in
- LoadState) printf '%s\n' "${FAKE_SYSTEMCTL_LOAD_STATE:-loaded}" ;;
- ActiveState)
- case "$unit" in
- cloud-compose.service) printf '%s\n' "${FAKE_APP_STATE:-inactive}" ;;
- cloud-compose-internal-services.service) printf '%s\n' "${FAKE_INTERNAL_STATE:-inactive}" ;;
- *) exit 1 ;;
- esac
- ;;
- *) exit 2 ;;
- esac
- ;;
- is-active)
- unit="${!#}"
- case "$unit" in
- cloud-compose.service)
- if [[ -n "${FAKE_APP_ACTIVE+x}" ]]; then
- [[ "$FAKE_APP_ACTIVE" == "true" ]]
- else
- [[ "${FAKE_APP_STATE:-active}" == "active" ]]
- fi
- ;;
- cloud-compose-internal-services.service)
- if [[ -n "${FAKE_INTERNAL_ACTIVE+x}" ]]; then
- [[ "$FAKE_INTERNAL_ACTIVE" == "true" ]]
- else
- [[ "${FAKE_INTERNAL_STATE:-active}" == "active" ]]
- fi
- ;;
- *) exit 1 ;;
- esac
- ;;
- restart)
- unit="${2:-}"
- expected="${EXPECTED_CONSUMER_KEY_ID:?}"
- if [[ "$unit" == "cloud-compose.service" ]]; then
- jq -e --arg id "$expected" '.private_key_id == $id' \
- "${TEST_ROOT:?}/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" >/dev/null
- jq -e --arg id "$expected" '.private_key_id == $id' \
- "$TEST_ROOT/apps/beta/secrets/GOOGLE_APPLICATION_CREDENTIALS" >/dev/null
- fi
- if [[ "${FAKE_RESTART_FAIL:-false}" == "true" ]]; then
- printf 'RESTART_FAILED %s %s\n' "$unit" "$expected" >>"${ORDER_LOG:?}"
- exit 1
- fi
- printf 'RESTART %s %s\n' "$unit" "$expected" >>"${ORDER_LOG:?}"
- ;;
- *)
- echo "unexpected systemctl call: $*" >&2
- exit 2
- ;;
-esac
-EOF
-
-cat >"$tmp/bin/stat" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-
-target="${!#}"
-format=""
-previous=""
-for argument in "$@"; do
- if [[ "$previous" == "-c" ]]; then
- format="$argument"
- fi
- previous="$argument"
-done
-if [[ -n "${FAKE_FRESH_MARKER:-}" ]]; then
- marker_dir="$(dirname -- "$FAKE_FRESH_MARKER")"
- marker_root="$(dirname -- "$marker_dir")"
- case "$target:$format" in
- "$marker_root:%u:%a") printf '0:1775\n'; exit 0 ;;
- "$marker_dir:%u:%g:%a") printf '0:0:700\n'; exit 0 ;;
- "$FAKE_FRESH_MARKER:%u:%g:%a:%h") printf '0:0:600:1\n'; exit 0 ;;
- esac
-fi
-exec /usr/bin/stat "$@"
-EOF
-
-cat >"$tmp/bin/curl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-
-method=GET
-url=""
-output=""
-write_format=""
-previous=""
-for argument in "$@"; do
- case "$previous" in
- -X) method="$argument" ;;
- -o) output="$argument" ;;
- -w) write_format="$argument" ;;
- esac
- if [[ "$argument" == http* ]]; then
- url="$argument"
- fi
- previous="$argument"
-done
-
-write_response() {
- local body="$1"
- if [[ -n "$output" ]]; then
- printf '%s\n' "$body" >"$output"
- else
- printf '%s\n' "$body"
- fi
-}
-
-write_status() {
- local status="$1" body="${2-}"
- if [[ -z "$body" ]]; then
- body='{}'
- fi
- write_response "$body"
- if [[ -n "$write_format" ]]; then
- printf '%s' "$status"
- fi
-}
-
-if [[ "$url" == *169.254.169.254* ]]; then
- write_response '{"access_token":"metadata-token"}'
- exit 0
-fi
-
-if [[ "$url" == "https://oauth2.googleapis.com/token" ]]; then
- count="$(<"${AUTH_COUNT:?}")"
- count=$((count + 1))
- printf '%s\n' "$count" >"$AUTH_COUNT"
- printf 'AUTH %s\n' "$count" >>"${ORDER_LOG:?}"
- if ((count <= ${FAKE_AUTH_FAIL_UNTIL:-0})); then
- exit 22
- fi
- write_response '{"access_token":"replacement-token","expires_in":3600,"token_type":"Bearer"}'
- exit 0
-fi
-
-if [[ "$method" == GET && "$url" == */keys ]]; then
- count="$(<"${LIST_COUNT:?}")"
- count=$((count + 1))
- printf '%s\n' "$count" >"$LIST_COUNT"
- if [[ -n "${FAKE_INJECT_KEY_ON_LIST:-}" &&
- "$count" == "${FAKE_INJECT_KEY_ON_LIST_NUMBER:-2}" ]]; then
- service_account="${url#*serviceAccounts/}"
- service_account="${service_account%/keys}"
- injected_name="projects/test-project/serviceAccounts/${service_account}/keys/${FAKE_INJECT_KEY_ON_LIST}"
- jq --arg name "$injected_name" \
- '. + [{name: $name, disabled: false}] | unique_by(.name)' \
- "$KEY_STATE" >"$KEY_STATE.tmp"
- mv "$KEY_STATE.tmp" "$KEY_STATE"
- fi
- jq -c '{keys: [.[] | {
- keyType: (.keyType // "USER_MANAGED"),
- name: .name,
- disabled: .disabled
- }]}' "${KEY_STATE:?}"
- exit 0
-fi
-
-if [[ "$method" == POST && "$url" == */keys ]]; then
- count="$(<"${POST_COUNT:?}")"
- printf '%s\n' "$((count + 1))" >"$POST_COUNT"
- printf 'POST\n' >>"${ORDER_LOG:?}"
- service_account="${url#*serviceAccounts/}"
- service_account="${service_account%/keys}"
- key_name="projects/test-project/serviceAccounts/${service_account}/keys/${NEW_KEY_ID:?}"
- if [[ "${FAKE_POST_MODE:-success}" != "fail-no-create" &&
- "${FAKE_POST_MODE:-success}" != "rejected-400" ]]; then
- jq --arg name "$key_name" '. + [{name: $name, disabled: false}] | unique_by(.name)' \
- "$KEY_STATE" >"$KEY_STATE.tmp"
- mv "$KEY_STATE.tmp" "$KEY_STATE"
- fi
- case "${FAKE_POST_MODE:-success}" in
- fail-no-create | ambiguous-create) exit 22 ;;
- rejected-400)
- write_status 400 '{"error":{"code":400,"message":"quota"}}'
- exit 0
- ;;
- ambiguous-408)
- write_status 408 '{"error":{"code":408,"message":"timeout"}}'
- exit 0
- ;;
- esac
- key_data="$(jq -nc --arg id "$NEW_KEY_ID" --arg email "$service_account" '{
- type: "service_account",
- project_id: "test-project",
- private_key_id: $id,
- private_key: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n",
- client_email: $email,
- token_uri: "https://oauth2.googleapis.com/token"
- }' | base64 | tr -d '\n')"
- body="$(printf '{"name":"%s","privateKeyData":"%s"}' "$key_name" "$key_data")"
- write_status 200 "$body"
- if [[ "${FAKE_POST_MODE:-success}" == "late-success" ]]; then
- exit 18
- fi
- exit 0
-fi
-
-if [[ "$method" == POST && "$url" == *:disable ]]; then
- key_name="${url#https://iam.googleapis.com/v1/}"
- key_name="${key_name%:disable}"
- if ! jq -e --arg name "$key_name" 'any(.[]; .name == $name)' "$KEY_STATE" >/dev/null; then
- write_status 404
- exit 0
- fi
- if [[ "${FAKE_DISABLE_FAIL:-false}" == "true" ]]; then
- write_status 500
- exit 0
- fi
- jq --arg name "$key_name" 'map(if .name == $name then .disabled = true else . end)' \
- "$KEY_STATE" >"$KEY_STATE.tmp"
- mv "$KEY_STATE.tmp" "$KEY_STATE"
- printf 'DISABLE %s\n' "$key_name" >>"${ORDER_LOG:?}"
- write_status 200
- exit 0
-fi
-
-if [[ "$method" == POST && "$url" == *:enable ]]; then
- key_name="${url#https://iam.googleapis.com/v1/}"
- key_name="${key_name%:enable}"
- if ! jq -e --arg name "$key_name" 'any(.[]; .name == $name)' "$KEY_STATE" >/dev/null; then
- write_status 404
- exit 0
- fi
- jq --arg name "$key_name" 'map(if .name == $name then .disabled = false else . end)' \
- "$KEY_STATE" >"$KEY_STATE.tmp"
- mv "$KEY_STATE.tmp" "$KEY_STATE"
- printf 'ENABLE %s\n' "$key_name" >>"${ORDER_LOG:?}"
- write_status 200
- exit 0
-fi
-
-if [[ "$method" == DELETE ]]; then
- key_name="${url#https://iam.googleapis.com/v1/}"
- if [[ "${FAKE_DELETE_FAIL:-false}" == "true" ]]; then
- write_status 500
- exit 0
- fi
- if ! jq -e --arg name "$key_name" 'any(.[]; .name == $name)' "$KEY_STATE" >/dev/null; then
- printf 'DELETE404 %s\n' "$key_name" >>"${ORDER_LOG:?}"
- write_status 404
- exit 0
- fi
- jq --arg name "$key_name" 'map(select(.name != $name))' "$KEY_STATE" >"$KEY_STATE.tmp"
- mv "$KEY_STATE.tmp" "$KEY_STATE"
- printf 'DELETE %s\n' "$key_name" >>"${ORDER_LOG:?}"
- if [[ -n "${FAKE_DELETE_FAIL_AFTER_MUTATION_ID:-}" &&
- "$key_name" == */keys/"$FAKE_DELETE_FAIL_AFTER_MUTATION_ID" ]]; then
- write_status 500
- exit 0
- fi
- write_status 200
- exit 0
-fi
-
-echo "unexpected curl call: $method $url" >&2
-exit 22
-EOF
-
-chmod +x "$tmp/bin/"*
-
-export TEST_ROOT="$tmp"
-export TEST_BIN="$tmp/bin"
-export ORDER_LOG="$order_log"
-export POST_COUNT="$post_count"
-export AUTH_COUNT="$auth_count"
-export LIST_COUNT="$list_count"
-export SYNC_COUNT="$sync_count"
-export KEY_STATE="$key_state"
-export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh"
-export CLOUD_COMPOSE_COMPOSE_APPS_PATH="$tmp/compose-apps.sh"
-export CLOUD_COMPOSE_ROTATE_KEYS_PATH="$repo_root/rootfs/home/cloud-compose/rotate-keys.sh"
-export APP_CREDENTIALS_FILE="$tmp/central/GOOGLE_APPLICATION_CREDENTIALS"
-export GCP_APP_SERVICE_ACCOUNT_EMAIL="app@example.invalid"
-export GCP_APP_CREDENTIALS_ENABLED=true
-export GCP_PROJECT="test-project"
-export GCP_INSTANCE_NAME="test-instance"
-export ROTATION_MIN_AGE_SECONDS=0
-export ROTATION_DISABLE_GRACE_SECONDS=86400
-export ROTATION_AUTH_MAX_RETRIES=3
-export ROTATION_AUTH_SLEEP_SECONDS=0
-export ROTATION_RECOVERY_SETTLE_SECONDS=0
-export ROTATION_CREDENTIAL_OWNER=
-export ROTATION_CREDENTIAL_GROUP=test-group
-export ROTATION_RUNTIME_DIR="$tmp/run/key-rotation"
-export GCP_APP_SERVICE_ACCOUNT_MANAGED=false
-export NEW_KEY_ID="replacement-key"
-export EXPECTED_CONSUMER_KEY_ID="$NEW_KEY_ID"
-
-key_name() {
- printf 'projects/test-project/serviceAccounts/%s/keys/%s\n' "$1" "$2"
-}
-
-reset_operation_counts() {
- : >"$order_log"
- printf '0\n' >"$post_count"
- printf '0\n' >"$auth_count"
- printf '0\n' >"$list_count"
- printf '0\n' >"$sync_count"
-}
-
-reset_key_state() {
- local email="$1" old_id="$2"
- jq -n --arg name "$(key_name "$email" "$old_id")" \
- '[{name: $name, disabled: false}]' >"$key_state"
- reset_operation_counts
-}
-
-reset_key_state_many() {
- local email="$1" count="$2" index
-
- jq -n '[]' >"$key_state"
- for ((index = 1; index <= count; index++)); do
- jq --arg name "$(key_name "$email" "orphan-$index")" \
- '. + [{name: $name, disabled: false}]' \
- "$key_state" >"$key_state.tmp"
- mv "$key_state.tmp" "$key_state"
- done
- reset_operation_counts
-}
-
-write_credentials() {
- local file="$1" key_id="$2" email="${3:-app@example.invalid}"
-
- install -d "$(dirname -- "$file")"
- jq -n --arg key_id "$key_id" --arg email "$email" '{
- type: "service_account",
- project_id: "test-project",
- private_key_id: $key_id,
- private_key: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n",
- client_email: $email,
- token_uri: "https://oauth2.googleapis.com/token"
- }' >"$file"
-}
-
-central_cleanup() {
- rm -f -- "$APP_CREDENTIALS_FILE" "${APP_CREDENTIALS_FILE}.rotation-"* \
- "${APP_CREDENTIALS_FILE}.rotation.lock" 2>/dev/null || true
- rm -rf -- "$tmp/apps/alpha/secrets" "$tmp/apps/beta/secrets"
-}
-
-# File credentials are opt-in. A clean disabled deployment is a no-op, while a
-# stale credential fails closed until its remote key is explicitly retired.
-central_cleanup
-reset_key_state app@example.invalid old-key
-GCP_APP_CREDENTIALS_ENABLED=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null
-[[ "$(<"$post_count")" == 0 ]] || fail "disabled app credentials created a cloud key"
-write_credentials "$APP_CREDENTIALS_FILE" old-key
-if GCP_APP_CREDENTIALS_ENABLED=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "disabled app credentials accepted a stale local key"
-fi
-if grep -Eq 'DELETE |POST' "$order_log"; then
- fail "disabled stale-credential detection mutated IAM"
-fi
-
-# JSON strings are structurally parsed by jq, while key-ID text validation is
-# performed by Bash for COS compatibility. Preserve trailing control bytes
-# through extraction so they cannot be normalized into an accepted key ID.
-write_credentials "$APP_CREDENTIALS_FILE" $'old-key\n'
-if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \
- app@example.invalid test-project "$APP_CREDENTIALS_FILE" >/dev/null 2>&1; then
- fail "rotation normalized a trailing newline in a credential key ID"
-fi
-jq '.private_key_id = "old-key\u0000"' "$APP_CREDENTIALS_FILE" >"$APP_CREDENTIALS_FILE.tmp"
-mv "$APP_CREDENTIALS_FILE.tmp" "$APP_CREDENTIALS_FILE"
-if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \
- app@example.invalid test-project "$APP_CREDENTIALS_FILE" >/dev/null 2>&1; then
- fail "rotation normalized a NUL in a credential key ID"
-fi
-if grep -Eq 'DELETE |POST' "$order_log"; then
- fail "invalid credential key-ID validation mutated IAM"
-fi
-
-invalid_state_credentials="$tmp/invalid-state/GOOGLE_APPLICATION_CREDENTIALS"
-install -d "$(dirname -- "$invalid_state_credentials")"
-jq -n \
- --arg credentials_file "$invalid_state_credentials" \
- --arg baseline_name "$(key_name app@example.invalid old-key)"$'\n' '{
- version: 2,
- phase: "creating",
- service_account: "app@example.invalid",
- project_id: "test-project",
- credentials_file: $credentials_file,
- current_key_id: "old-key",
- new_key_id: "",
- new_key_name: "",
- baseline_key_names: [$baseline_name],
- created_at: 1,
- ready_at: 0,
- disabled_at: 0
- }' >"${invalid_state_credentials}.rotation-pending.json"
-if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" status \
- app@example.invalid test-project "$invalid_state_credentials" >/dev/null 2>&1; then
- fail "rotation normalized a trailing newline in a baseline key name"
-fi
-jq --arg name "$(key_name app@example.invalid old-key)" \
- '.baseline_key_names = [$name, $name]' \
- "${invalid_state_credentials}.rotation-pending.json" \
- >"${invalid_state_credentials}.rotation-pending.json.tmp"
-mv "${invalid_state_credentials}.rotation-pending.json.tmp" \
- "${invalid_state_credentials}.rotation-pending.json"
-if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" status \
- app@example.invalid test-project "$invalid_state_credentials" >/dev/null 2>&1; then
- fail "rotation accepted duplicate names in a durable reconciliation baseline"
-fi
-write_credentials "$APP_CREDENTIALS_FILE" old-key
-
-# Retirement deletes the currently installed remote key before removing every
-# local and per-app credential artifact.
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" retire >/dev/null
-[[ ! -e "$APP_CREDENTIALS_FILE" ]] || fail "credential retirement retained the central key file"
-if jq -e --arg name "$(key_name app@example.invalid old-key)" \
- 'any(.[]; .name == $name)' "$key_state" >/dev/null; then
- fail "credential retirement retained the remote user-managed key"
-fi
-
-# Losing the local credential does not prove that its remote private key was
-# revoked. Retirement must surface the remaining audited key IDs and fail
-# closed rather than silently making the disabled state look clean.
-central_cleanup
-reset_key_state app@example.invalid orphaned-remote-key
-if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" retire \
- app@example.invalid test-project "$APP_CREDENTIALS_FILE" >"$tmp/retire.out" 2>"$tmp/retire.err"; then
- fail "credential retirement accepted an absent local file with a live remote key"
-fi
-grep -Fq 'orphaned-remote-key' "$tmp/retire.err" || fail "retirement did not report the audited remote key ID"
-if grep -Eq '^DELETE ' "$order_log"; then
- fail "retirement guessed which remote key to delete without a local credential"
-fi
-jq -n '[]' >"$key_state"
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" retire \
- app@example.invalid test-project "$APP_CREDENTIALS_FILE" >/dev/null
-
-reset_key_state app@example.invalid old-key
-central_cleanup
-write_credentials "$APP_CREDENTIALS_FILE" old-key
-
-# Every application source/path is validated before IAM is mutated.
-if FAKE_SOURCE_INVALID=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "rotation accepted an invalid Compose source"
-fi
-[[ "$(<"$post_count")" == 0 ]] || fail "invalid Compose source triggered key creation"
-if ROTATION_CREDENTIAL_GROUP='--reference=unsafe' \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "rotation accepted an option-like credential group"
-fi
-[[ "$(<"$post_count")" == 0 ]] || fail "invalid credential group triggered key creation"
-
-# Authenticate before distribution, and retain authenticated state if only part
-# of the app credential fan-out succeeds.
-if FAKE_CHGRP_FAIL_BETA=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "rotation accepted a partial credential distribution failure"
-fi
-[[ "$(<"$post_count")" == 1 ]] || fail "distribution failure did not create exactly one replacement key"
-[[ "$(<"$auth_count")" == 1 ]] || fail "replacement key was not authenticated before distribution"
-jq -e '.phase == "authenticated" and .current_key_id == "old-key" and .new_key_id == "replacement-key"' \
- "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || fail "distribution failure did not retain authenticated state"
-[[ -s "${APP_CREDENTIALS_FILE}.rotation-previous.json" ]] || fail "previous credentials were not preserved"
-grep -Fq "CHOWN -- cloud-compose $tmp/apps/alpha/secrets" "$order_log" || \
- fail "application secret directory is not assigned to the cloud-compose owner"
-[[ "$(stat -c %a "$tmp/apps/alpha/secrets")" == "750" ]] || \
- fail "application secret directory does not preserve private owner-write intent"
-[[ "$(stat -c %a "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS")" == "440" ]] || \
- fail "application credential is not read-only and group-readable"
-if grep -Eq 'RESTART |DISABLE |DELETE ' "$order_log"; then
- fail "distribution failure restarted consumers or changed the previous key"
-fi
-creating_sync_line="$(grep -n '^SYNC 1$' "$order_log" | cut -d: -f1)"
-creating_post_line="$(grep -n '^POST$' "$order_log" | cut -d: -f1)"
-[[ -n "$creating_sync_line" && -n "$creating_post_line" &&
- "$creating_sync_line" -lt "$creating_post_line" ]] ||
- fail "ordinary key creation outran its durable creating state"
-
-# A failed reload cannot advance readiness or disable the previous key.
-if FAKE_RESTART_FAIL=true FAKE_APP_ACTIVE=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "rotation accepted a failed app-container restart"
-fi
-jq -e '.phase == "authenticated"' "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || \
- fail "failed reload advanced rotation readiness"
-if grep -Eq 'DISABLE |DELETE ' "$order_log"; then
- fail "failed reload changed the previous cloud key"
-fi
-
-# An intentionally inactive app remains inactive. Authentication and file
-# distribution are sufficient for its next start; the old key is disabled but
-# retained through the configured rollback grace.
-: >"$order_log"
-FAKE_RESTART_FAIL=false FAKE_APP_ACTIVE=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null
-if grep -Eq '^RESTART ' "$order_log"; then
- fail "rotation started or restarted an inactive application"
-fi
-jq -e '.phase == "grace" and .disabled_at > 0' "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || \
- fail "successful propagation did not enter rollback grace"
-jq -e --arg name "$(key_name app@example.invalid old-key)" \
- 'any(.[]; .name == $name and .disabled == true)' "$key_state" >/dev/null || fail "previous key was not disabled"
-if grep -Eq '^DELETE ' "$order_log"; then
- fail "previous key was deleted before rollback grace elapsed"
-fi
-
-# Re-running during grace neither creates nor deletes a key.
-: >"$order_log"
-chmod 0600 "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS"
-ROTATION_CREDENTIAL_OWNER=100 \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null
-[[ "$(<"$post_count")" == 1 ]] || fail "grace retry created another replacement key"
-if grep -Eq '^DELETE ' "$order_log"; then
- fail "grace retry deleted the disabled previous key too early"
-fi
-[[ "$(stat -c %a "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS")" == "440" ]] || \
- fail "identical application credentials did not converge their mode"
-grep -Fq "CHOWN -- 100 $tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" "$order_log" || \
- fail "identical application credentials did not converge their owner"
-grep -Fq "CHGRP -- test-group $tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" "$order_log" || \
- fail "identical application credentials did not converge their group"
-
-# Rollback re-enables and authenticates the previous key, distributes it,
-# reloads only the already-active service, then removes the abandoned key.
-: >"$order_log"
-export EXPECTED_CONSUMER_KEY_ID=old-key
-# Simulate a crash after the core restored the old credential but before the
-# wrapper distributed/reloaded consumers. Re-running the documented wrapper
-# command must resume rather than reject the rollback phase.
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" rollback \
- app@example.invalid test-project "$APP_CREDENTIALS_FILE" >/dev/null
-jq -e '.phase == "rollback"' "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || \
- fail "core rollback did not reach the resumable consumer phase"
-FAKE_APP_ACTIVE=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" rollback >/dev/null
-[[ ! -e "${APP_CREDENTIALS_FILE}.rotation-pending.json" ]] || fail "rollback left pending state"
-jq -e '.private_key_id == "old-key"' "$APP_CREDENTIALS_FILE" >/dev/null || fail "rollback did not restore central credentials"
-jq -e --arg name "$(key_name app@example.invalid old-key)" \
- 'any(.[]; .name == $name and .disabled == false)' "$key_state" >/dev/null || fail "rollback did not re-enable the previous key"
-if jq -e --arg name "$(key_name app@example.invalid replacement-key)" 'any(.[]; .name == $name)' "$key_state" >/dev/null; then
- fail "rollback retained the abandoned replacement key"
-fi
-enable_line="$(grep -n '^ENABLE ' "$order_log" | cut -d: -f1)"
-restart_line="$(grep -n '^RESTART cloud-compose.service old-key$' "$order_log" | cut -d: -f1)"
-delete_line="$(grep -n '/keys/replacement-key$' "$order_log" | tail -n1 | cut -d: -f1)"
-[[ -n "$enable_line" && -n "$restart_line" && -n "$delete_line" ]] || fail "rollback audit sequence is incomplete"
-(( enable_line < restart_line && restart_line < delete_line )) || fail "rollback key/service ordering is unsafe"
-
-# Authentication propagation failure occurs before app credential replacement
-# or reload, and the retry count is bounded.
-central_cleanup
-reset_key_state app@example.invalid old-key
-write_credentials "$APP_CREDENTIALS_FILE" old-key
-export NEW_KEY_ID=auth-failure-key EXPECTED_CONSUMER_KEY_ID=auth-failure-key
-if FAKE_AUTH_FAIL_UNTIL=99 FAKE_APP_ACTIVE=true \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "rotation accepted replacement credentials that could not authenticate"
-fi
-[[ "$(<"$auth_count")" == "$ROTATION_AUTH_MAX_RETRIES" ]] || fail "authentication backoff was not bounded"
-jq -e '.phase == "staged"' "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || \
- fail "authentication failure did not retain resumable staged state"
-[[ ! -e "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" ]] || \
- fail "authentication failure distributed an unproven credential"
-if grep -Eq '^RESTART ' "$order_log"; then
- fail "authentication failure restarted an app on an unproven key"
-fi
-
-# An indeterminate create is never retried automatically. Audit exposes only
-# key IDs, and recovery deletes only the explicitly confirmed single delta.
-ambiguous="$tmp/ambiguous/GOOGLE_APPLICATION_CREDENTIALS"
-reset_key_state app@example.invalid old-key
-write_credentials "$ambiguous" old-key
-export NEW_KEY_ID=ambiguous-key
-if FAKE_POST_MODE=ambiguous-create bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \
- app@example.invalid test-project "$ambiguous" >/dev/null 2>&1; then
- fail "rotation accepted an indeterminate key-creation response"
-fi
-if FAKE_POST_MODE=success bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \
- app@example.invalid test-project "$ambiguous" >/dev/null 2>&1; then
- fail "rotation retried an indeterminate key creation"
-fi
-[[ "$(<"$post_count")" == 1 ]] || fail "indeterminate creation issued another POST"
-audit="$(bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" audit \
- app@example.invalid test-project "$ambiguous")"
-jq -e '.phase == "creating" and .recovery_required == true and .candidate_key_ids == ["ambiguous-key"]' \
- <<<"$audit" >/dev/null || fail "ambiguous creation audit was incomplete"
-if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" recover \
- app@example.invalid test-project "$ambiguous" wrong-key >/dev/null 2>&1; then
- fail "ambiguous recovery accepted the wrong key confirmation"
-fi
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" recover \
- app@example.invalid test-project "$ambiguous" ambiguous-key >/dev/null
-[[ ! -e "${ambiguous}.rotation-pending.json" ]] || fail "confirmed recovery left ambiguous state"
-if jq -e --arg name "$(key_name app@example.invalid ambiguous-key)" 'any(.[]; .name == $name)' "$key_state" >/dev/null; then
- fail "confirmed orphan key was not deleted"
-fi
-
-# If the API audit proves that no key was created, recovery can clear state
-# without a destructive confirmation.
-no_create="$tmp/no-create/GOOGLE_APPLICATION_CREDENTIALS"
-reset_key_state app@example.invalid old-key
-write_credentials "$no_create" old-key
-export NEW_KEY_ID=no-create-key
-if FAKE_POST_MODE=fail-no-create bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \
- app@example.invalid test-project "$no_create" >/dev/null 2>&1; then
- fail "rotation accepted a failed no-create request"
-fi
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" recover \
- app@example.invalid test-project "$no_create" >/dev/null
-[[ ! -e "${no_create}.rotation-pending.json" ]] || fail "zero-delta recovery did not clear state"
-
-# Deletion is idempotent: a 404 after grace means the desired absent state has
-# already been reached and local rollback state can be cleaned up.
-idempotent="$tmp/idempotent/GOOGLE_APPLICATION_CREDENTIALS"
-reset_key_state app@example.invalid old-key
-write_credentials "$idempotent" old-key
-export NEW_KEY_ID=idempotent-key ROTATION_DISABLE_GRACE_SECONDS=86400
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare app@example.invalid test-project "$idempotent" >/dev/null
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" authenticate app@example.invalid test-project "$idempotent" >/dev/null
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" ready app@example.invalid test-project "$idempotent" >/dev/null
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit app@example.invalid test-project "$idempotent" >/dev/null
-jq --arg name "$(key_name app@example.invalid old-key)" 'map(select(.name != $name))' "$key_state" >"$key_state.tmp"
-mv "$key_state.tmp" "$key_state"
-ROTATION_DISABLE_GRACE_SECONDS=0 bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit \
- app@example.invalid test-project "$idempotent" >/dev/null
-[[ ! -e "${idempotent}.rotation-pending.json" ]] || fail "idempotent 404 deletion retained grace state"
-grep -Fq "DELETE404 $(key_name app@example.invalid old-key)" "$order_log" || fail "404 deletion path was not exercised"
-
-# Disable and delete failures retain the exact retry phase and both credential
-# generations. Neither retry may create another replacement key.
-failure_retry="$tmp/failure-retry/GOOGLE_APPLICATION_CREDENTIALS"
-reset_key_state app@example.invalid old-key
-write_credentials "$failure_retry" old-key
-export NEW_KEY_ID=failure-retry-key ROTATION_DISABLE_GRACE_SECONDS=0
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare app@example.invalid test-project "$failure_retry" >/dev/null
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" authenticate app@example.invalid test-project "$failure_retry" >/dev/null
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" ready app@example.invalid test-project "$failure_retry" >/dev/null
-if FAKE_DISABLE_FAIL=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit \
- app@example.invalid test-project "$failure_retry" >/dev/null 2>&1; then
- fail "rotation accepted a failed old-key disable"
-fi
-jq -e '.phase == "ready"' "${failure_retry}.rotation-pending.json" >/dev/null || \
- fail "failed disable did not retain ready state"
-FAKE_DISABLE_FAIL=false bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit \
- app@example.invalid test-project "$failure_retry" >/dev/null
-jq -e '.phase == "grace"' "${failure_retry}.rotation-pending.json" >/dev/null || \
- fail "successful disable did not enter grace"
-if FAKE_DELETE_FAIL=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit \
- app@example.invalid test-project "$failure_retry" >/dev/null 2>&1; then
- fail "rotation accepted a failed old-key deletion"
-fi
-jq -e '.phase == "grace"' "${failure_retry}.rotation-pending.json" >/dev/null || \
- fail "failed deletion did not retain grace state"
-[[ -s "${failure_retry}.rotation-previous.json" ]] || fail "failed deletion discarded rollback credentials"
-FAKE_DELETE_FAIL=false bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit \
- app@example.invalid test-project "$failure_retry" >/dev/null
-[[ ! -e "${failure_retry}.rotation-pending.json" ]] || fail "delete retry did not finish rotation"
-[[ "$(<"$post_count")" == 1 ]] || fail "disable/delete retries created another replacement key"
-
-# A failed state durability barrier stops before the create request. The
-# renamed state may be visible in the live filesystem, but IAM remains
-# untouched until a later run can durably flush it.
-durability="$tmp/durability/GOOGLE_APPLICATION_CREDENTIALS"
-central_cleanup
-reset_key_state app@example.invalid old-key
-write_credentials "$durability" old-key
-export NEW_KEY_ID=durability-key
-if FAKE_SYNC_FAIL_AT=1 bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \
- app@example.invalid test-project "$durability" >/dev/null 2>&1; then
- fail "rotation accepted a failed pre-create state durability barrier"
-fi
-[[ "$(<"$post_count")" == 0 ]] ||
- fail "failed state durability barrier issued a create request"
-if grep -Eq '^(DELETE |DISABLE |ENABLE )' "$order_log"; then
- fail "failed state durability barrier mutated IAM"
-fi
-
-# First provisioning has no rollback key and therefore never disables/deletes
-# another service-account key.
-first="$tmp/first/GOOGLE_APPLICATION_CREDENTIALS"
-jq -n '[]' >"$key_state"
-: >"$order_log"
-export NEW_KEY_ID=first-key ROTATION_DISABLE_GRACE_SECONDS=0
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare app@example.invalid test-project "$first" >/dev/null
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" authenticate app@example.invalid test-project "$first" >/dev/null
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" ready app@example.invalid test-project "$first" >/dev/null
-bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit app@example.invalid test-project "$first" >/dev/null
-if grep -Eq '^(DISABLE|DELETE) ' "$order_log"; then
- fail "first-time provisioning changed a key without a previous local credential"
-fi
-
-# A trusted marker on a newly formatted data filesystem authorizes the
-# module-owned app identity to delete its exact orphan baseline before creating
-# one replacement. Rotation itself leaves consumption to the bootstrap
-# boundary immediately after every enabled identity has converged.
-fresh_data_root="$tmp/fresh-data"
-fresh_marker="$fresh_data_root/.cloud-compose/fresh-filesystem"
-fresh_identity="v1:gcp-disk-id:987654321012345678"
-mkdir -p "$(dirname -- "$fresh_marker")"
-chmod 1775 "$fresh_data_root"
-chmod 0700 "$(dirname -- "$fresh_marker")"
-printf '%s\n' "$fresh_identity" >"$fresh_marker"
-chmod 0600 "$fresh_marker"
-export FAKE_FRESH_MARKER="$fresh_marker"
-export CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER="$fresh_marker"
-export CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY="$fresh_identity"
-
-# A root-owned marker copied from a snapshot of another data-disk incarnation
-# is not destructive authority. Reject it before even listing IAM keys.
-printf 'v1:gcp-disk-id:111111111111111111\n' >"$fresh_marker"
-central_cleanup
-reset_key_state_many app@example.invalid 2
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "fresh reconciliation accepted another disk incarnation"
-fi
-if grep -Eq '^(DELETE |POST$)' "$order_log" ||
- [[ "$(<"$list_count")" != 0 ]]; then
- fail "mismatched disk-incarnation authority reached IAM"
-fi
-printf '%s\n' "$fresh_identity" >"$fresh_marker"
-
-central_cleanup
-reset_key_state_many app@example.invalid 1
-export NEW_KEY_ID=fresh-durability-key EXPECTED_CONSUMER_KEY_ID=fresh-durability-key
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- FAKE_SYNC_FAIL_AT=1 \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "fresh reconciliation accepted a failed baseline durability barrier"
-fi
-if grep -Eq '^(DELETE |POST$)' "$order_log"; then
- fail "fresh reconciliation mutated IAM before its baseline became durable"
-fi
-
-central_cleanup
-reset_key_state_many app@example.invalid 10
-export NEW_KEY_ID=fresh-key EXPECTED_CONSUMER_KEY_ID=fresh-key
-GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null
-[[ "$(<"$post_count")" == 1 ]] || fail "fresh managed reconciliation did not create exactly one key"
-[[ "$(grep -c '^DELETE ' "$order_log")" == 10 ]] || fail "fresh managed reconciliation did not delete its exact ten-key baseline"
-last_delete_line="$(grep -n '^DELETE ' "$order_log" | tail -n1 | cut -d: -f1)"
-post_line="$(grep -n '^POST$' "$order_log" | cut -d: -f1)"
-((last_delete_line < post_line)) || fail "fresh managed reconciliation created a key before deleting its baseline"
-first_delete_line="$(grep -n '^DELETE ' "$order_log" | head -n1 | cut -d: -f1)"
-first_sync_line="$(grep -n '^SYNC 1$' "$order_log" | cut -d: -f1)"
-second_sync_line="$(grep -n '^SYNC 2$' "$order_log" | cut -d: -f1)"
-[[ -n "$first_sync_line" && -n "$second_sync_line" &&
- "$first_sync_line" -lt "$first_delete_line" &&
- "$last_delete_line" -lt "$second_sync_line" &&
- "$second_sync_line" -lt "$post_line" ]] ||
- fail "fresh reconciliation IAM mutations outran their durable state transitions"
-jq -e --arg name "$(key_name app@example.invalid fresh-key)" \
- 'length == 1 and .[0].name == $name' "$key_state" >/dev/null ||
- fail "fresh managed reconciliation did not converge to one replacement key"
-[[ -f "$fresh_marker" ]] || fail "key rotation consumed the shared fresh-filesystem marker inside one identity wrapper"
-
-# A delete that reaches IAM but loses its successful response retains the exact
-# baseline. The retry accepts 404 and cannot create until every baseline key is
-# absent.
-central_cleanup
-reset_key_state_many app@example.invalid 2
-export NEW_KEY_ID=fresh-retry-key EXPECTED_CONSUMER_KEY_ID=fresh-retry-key
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- FAKE_DELETE_FAIL_AFTER_MUTATION_ID=orphan-1 \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "fresh managed reconciliation accepted an indeterminate orphan deletion"
-fi
-jq -e '.phase == "reconciling" and (.baseline_key_names | length) == 2' \
- "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null ||
- fail "failed orphan deletion did not retain the exact reconciliation baseline"
-[[ "$(<"$post_count")" == 0 ]] || fail "failed orphan deletion created a replacement key"
-GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- FAKE_DELETE_FAIL_AFTER_MUTATION_ID=orphan-1 \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null
-[[ "$(<"$post_count")" == 1 ]] || fail "orphan deletion retry did not create exactly one replacement"
-grep -Fq "DELETE404 $(key_name app@example.invalid orphan-1)" "$order_log" ||
- fail "orphan deletion retry did not accept the already-absent key"
-
-# A key created by another actor after the durable baseline snapshot is never
-# swept into the managed set, and permanently blocks creation until audited.
-central_cleanup
-reset_key_state_many app@example.invalid 1
-export NEW_KEY_ID=blocked-fresh-key EXPECTED_CONSUMER_KEY_ID=blocked-fresh-key
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- FAKE_INJECT_KEY_ON_LIST=concurrent-key \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "fresh managed reconciliation accepted a concurrent key"
-fi
-[[ "$(<"$post_count")" == 0 ]] || fail "concurrent key detection issued a create"
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- FAKE_INJECT_KEY_ON_LIST=concurrent-key \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "fresh managed reconciliation swept a concurrent key on retry"
-fi
-jq -e --arg name "$(key_name app@example.invalid concurrent-key)" \
- 'length == 1 and .[0].name == $name' "$key_state" >/dev/null ||
- fail "fresh managed reconciliation deleted the concurrent key"
-[[ "$(<"$post_count")" == 0 ]] || fail "concurrent key retry issued a create"
-
-# Reconciliation is unavailable to supplied identities and to any service that
-# is not exactly loaded and inactive.
-central_cleanup
-reset_key_state_many app@example.invalid 1
-export NEW_KEY_ID=unmanaged-key EXPECTED_CONSUMER_KEY_ID=unmanaged-key
-GCP_APP_SERVICE_ACCOUNT_MANAGED=false FAKE_APP_ACTIVE=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null
-if grep -Eq '^DELETE ' "$order_log"; then
- fail "caller-supplied app identity deleted a baseline key"
-fi
-jq -e 'length == 2' "$key_state" >/dev/null ||
- fail "caller-supplied app identity did not preserve its remote baseline"
-
-# Both independent proofs are required. A managed identity without the
-# root-owned fresh-filesystem marker follows ordinary provisioning and never
-# treats pre-existing keys as its deletion set.
-rm -f -- "$fresh_marker"
-central_cleanup
-reset_key_state_many app@example.invalid 1
-export NEW_KEY_ID=managed-without-marker EXPECTED_CONSUMER_KEY_ID=managed-without-marker
-GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null
-if grep -Eq '^DELETE ' "$order_log"; then
- fail "managed app identity deleted a baseline without fresh-filesystem authority"
-fi
-jq -e 'length == 2' "$key_state" >/dev/null ||
- fail "managed app identity without a fresh marker did not preserve its remote baseline"
-
-# Ordinary rotation cannot exceed IAM's ten-key quota and must fail before
-# persisting an ambiguous create state.
-central_cleanup
-reset_key_state_many app@example.invalid 10
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "ordinary provisioning accepted an already-full IAM key quota"
-fi
-[[ "$(<"$post_count")" == 0 ]] || fail "full ordinary key quota issued a create"
-[[ ! -e "${APP_CREDENTIALS_FILE}.rotation-pending.json" ]] ||
- fail "full ordinary key quota persisted an ambiguous create state"
-if grep -Eq '^DELETE ' "$order_log"; then
- fail "full ordinary key quota deleted an existing key"
-fi
-
-# Invalid identity ownership configuration fails before any IAM request.
-central_cleanup
-reset_key_state_many app@example.invalid 1
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=unexpected \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "app wrapper accepted invalid managed-identity configuration"
-fi
-[[ "$(<"$post_count")" == 0 && "$(<"$list_count")" == 0 ]] ||
- fail "invalid managed-identity configuration reached IAM"
-
-printf '%s\n' "$fresh_identity" >"$fresh_marker"
-chmod 0600 "$fresh_marker"
-central_cleanup
-reset_key_state_many app@example.invalid 1
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=failed \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "fresh managed reconciliation accepted a failed application service"
-fi
-[[ "$(<"$post_count")" == 0 ]] || fail "failed application service allowed key creation"
-if grep -Eq '^DELETE ' "$order_log"; then
- fail "failed application service allowed orphan deletion"
-fi
-
-# A malformed, duplicate, or over-limit IAM inventory cannot be converted into
-# a deletion baseline.
-central_cleanup
-jq -n --arg name "$(key_name app@example.invalid duplicate-key)" \
- '[{name: $name, disabled: false}, {name: $name, disabled: false}]' >"$key_state"
-reset_operation_counts
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "fresh managed reconciliation accepted duplicate IAM key entries"
-fi
-if grep -Eq '^(DELETE |POST$)' "$order_log"; then
- fail "duplicate IAM key inventory triggered a mutation"
-fi
-
-central_cleanup
-jq -n '[{
- keyType: "USER_MANAGED",
- name: "projects/other-project/serviceAccounts/other@example.invalid/keys/foreign-key",
- disabled: false
-}]' >"$key_state"
-reset_operation_counts
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "fresh managed reconciliation accepted a foreign IAM key name"
-fi
-if grep -Eq '^(DELETE |POST$)' "$order_log"; then
- fail "foreign IAM key inventory triggered a mutation"
-fi
-
-central_cleanup
-reset_key_state_many app@example.invalid 11
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true FAKE_APP_STATE=inactive FAKE_APP_ACTIVE=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "fresh managed reconciliation accepted more than IAM's key limit"
-fi
-if grep -Eq '^(DELETE |POST$)' "$order_log"; then
- fail "over-limit IAM key inventory triggered a mutation"
-fi
-
-# A missing central file is recovered only when every existing distributed copy
-# is valid for the exact target and byte-identical.
-central_cleanup
-reset_key_state app@example.invalid supplied-victim-key
-write_credentials "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" supplied-victim-key
-write_credentials "$tmp/apps/beta/secrets/GOOGLE_APPLICATION_CREDENTIALS" supplied-victim-key
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "caller-supplied identity promoted distributed credentials into revocation authority"
-fi
-[[ ! -e "$APP_CREDENTIALS_FILE" ]] ||
- fail "caller-supplied identity restored an unproven central credential"
-[[ "$(<"$post_count")" == 0 && "$(<"$list_count")" == 0 ]] ||
- fail "caller-supplied distributed credential recovery reached IAM"
-if grep -Eq '^(DELETE |POST$)' "$order_log"; then
- fail "caller-supplied distributed credential recovery mutated IAM"
-fi
-
-central_cleanup
-reset_key_state app@example.invalid recovered-key
-write_credentials "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" recovered-key
-write_credentials "$tmp/apps/beta/secrets/GOOGLE_APPLICATION_CREDENTIALS" recovered-key
-touch -d '10 minutes ago' \
- "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" \
- "$tmp/apps/beta/secrets/GOOGLE_APPLICATION_CREDENTIALS"
-recovered_modified_at="$(stat -c %Y "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS")"
-GCP_APP_SERVICE_ACCOUNT_MANAGED=true ROTATION_MIN_AGE_SECONDS=86400 \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null
-cmp -s "$APP_CREDENTIALS_FILE" "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" ||
- fail "unanimous distributed credentials were not restored centrally"
-[[ "$(stat -c %Y "$APP_CREDENTIALS_FILE")" == "$recovered_modified_at" ]] ||
- fail "distributed credential recovery reset the credential rotation age"
-[[ "$(<"$post_count")" == 0 ]] || fail "distributed credential recovery created a cloud key"
-if grep -Eq '^DELETE ' "$order_log"; then
- fail "distributed credential recovery deleted a cloud key"
-fi
-
-central_cleanup
-reset_key_state app@example.invalid recovered-key
-write_credentials "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" recovered-key
-write_credentials "$tmp/apps/beta/secrets/GOOGLE_APPLICATION_CREDENTIALS" conflicting-key
-if GCP_APP_SERVICE_ACCOUNT_MANAGED=true ROTATION_MIN_AGE_SECONDS=86400 \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then
- fail "conflicting distributed credentials were accepted"
-fi
-[[ ! -e "$APP_CREDENTIALS_FILE" ]] || fail "conflicting distributed credentials restored a central key"
-[[ "$(<"$post_count")" == 0 ]] || fail "conflicting distributed credentials created a cloud key"
-
-# Definite IAM rejection does not strand an ambiguous state. Request timeout
-# remains ambiguous and a complete response is salvageable after a late
-# transport failure.
-definite="$tmp/definite/GOOGLE_APPLICATION_CREDENTIALS"
-reset_key_state app@example.invalid old-key
-write_credentials "$definite" old-key
-export NEW_KEY_ID=definite-key
-if FAKE_POST_MODE=rejected-400 bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \
- app@example.invalid test-project "$definite" >/dev/null 2>&1; then
- fail "definite IAM key rejection was accepted"
-fi
-[[ ! -e "${definite}.rotation-pending.json" ]] ||
- fail "definite IAM key rejection retained ambiguous state"
-
-timeout="$tmp/timeout/GOOGLE_APPLICATION_CREDENTIALS"
-reset_key_state app@example.invalid old-key
-write_credentials "$timeout" old-key
-export NEW_KEY_ID=timeout-key
-if FAKE_POST_MODE=ambiguous-408 bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \
- app@example.invalid test-project "$timeout" >/dev/null 2>&1; then
- fail "ambiguous HTTP 408 key creation was accepted"
-fi
-if FAKE_POST_MODE=success bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \
- app@example.invalid test-project "$timeout" >/dev/null 2>&1; then
- fail "ambiguous HTTP 408 key creation was retried"
-fi
-[[ "$(<"$post_count")" == 1 ]] || fail "ambiguous HTTP 408 issued another POST"
-jq -e '.phase == "creating"' "${timeout}.rotation-pending.json" >/dev/null ||
- fail "ambiguous HTTP 408 did not retain creating state"
-
-late="$tmp/late/GOOGLE_APPLICATION_CREDENTIALS"
-reset_key_state app@example.invalid old-key
-write_credentials "$late" old-key
-export NEW_KEY_ID=late-key
-FAKE_POST_MODE=late-success bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \
- app@example.invalid test-project "$late" >/dev/null
-jq -e '.phase == "staged" and .new_key_id == "late-key"' \
- "${late}.rotation-pending.json" >/dev/null ||
- fail "complete IAM response was not salvaged after a late transport failure"
-
-# The internal-service wrapper follows the same no-start rule.
-internal_email="internal-test-instance@test-project.iam.gserviceaccount.com"
-export INTERNAL_CREDENTIALS_FILE="$tmp/internal/GOOGLE_APPLICATION_CREDENTIALS"
-reset_key_state "$internal_email" internal-old
-write_credentials "$INTERNAL_CREDENTIALS_FILE" internal-old "$internal_email"
-export NEW_KEY_ID=internal-new EXPECTED_CONSUMER_KEY_ID=internal-new ROTATION_DISABLE_GRACE_SECONDS=86400
-: >"$order_log"
-FAKE_INTERNAL_ACTIVE=false bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-internal.sh" >/dev/null
-if grep -Fq 'RESTART cloud-compose-internal-services.service' "$order_log"; then
- fail "rotation started or restarted inactive internal services"
-fi
-jq -e '.phase == "grace"' "${INTERNAL_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || \
- fail "inactive internal service did not complete authenticated propagation"
-
-# The internal identity is always module-created and uses the same trusted
-# fresh-filesystem reconciliation before first provisioning.
-rm -f -- "$INTERNAL_CREDENTIALS_FILE" "${INTERNAL_CREDENTIALS_FILE}.rotation-"*
-reset_key_state_many "$internal_email" 10
-export NEW_KEY_ID=internal-fresh EXPECTED_CONSUMER_KEY_ID=internal-fresh
-FAKE_INTERNAL_STATE=inactive FAKE_INTERNAL_ACTIVE=false \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-internal.sh" >/dev/null
-[[ "$(<"$post_count")" == 1 ]] || fail "fresh internal reconciliation did not create exactly one key"
-jq -e --arg name "$(key_name "$internal_email" internal-fresh)" \
- 'length == 1 and .[0].name == $name' "$key_state" >/dev/null ||
- fail "fresh internal reconciliation did not converge to one replacement key"
-
-# The internal identity uses the same exact inactive-service gate. Failed or
-# unqueryable unit state cannot authorize deletion.
-rm -f -- "$INTERNAL_CREDENTIALS_FILE" "${INTERNAL_CREDENTIALS_FILE}.rotation-"*
-reset_key_state_many "$internal_email" 1
-if FAKE_INTERNAL_STATE=failed \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-internal.sh" >/dev/null 2>&1; then
- fail "fresh internal reconciliation accepted a failed service"
-fi
-if grep -Eq '^(DELETE |POST$)' "$order_log"; then
- fail "failed internal service allowed IAM mutation"
-fi
-
-rm -f -- "$INTERNAL_CREDENTIALS_FILE" "${INTERNAL_CREDENTIALS_FILE}.rotation-"*
-reset_key_state_many "$internal_email" 1
-if FAKE_SYSTEMCTL_QUERY_FAIL=true \
- bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-internal.sh" >/dev/null 2>&1; then
- fail "fresh internal reconciliation accepted an unqueryable service state"
-fi
-if grep -Eq '^(DELETE |POST$)' "$order_log"; then
- fail "unqueryable internal service allowed IAM mutation"
-fi
-
-if find "$ROTATION_RUNTIME_DIR" -maxdepth 1 -type f -name 'iam-create-response.*' -print -quit |
- grep -q .; then
- fail "rotation retained an ephemeral IAM private-key response"
-fi
-if find "$tmp" -type f -name '.iam-create-response.*' -print -quit | grep -q .; then
- fail "rotation wrote an IAM private-key response to persistent credential storage"
-fi
-grep -Fq 'mktemp "${ROTATION_RUNTIME_DIR}/iam-create-response.XXXXXX"' \
- "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" ||
- fail "IAM private-key responses are not staged under the ephemeral runtime directory"
-grep -Fq 'trap cleanup_ephemeral_create_response EXIT' \
- "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" ||
- fail "IAM private-key response cleanup is not registered for process exit"
-
-echo "Key rotation contract passed"
diff --git a/ci/lifecycle-program-contract.sh b/ci/lifecycle-program-contract.sh
deleted file mode 100755
index 5ddbebe..0000000
--- a/ci/lifecycle-program-contract.sh
+++ /dev/null
@@ -1,137 +0,0 @@
-#!/usr/bin/env bash
-
-set -Eeuo pipefail
-
-report_unexpected_failure() {
- local status=$?
- local line="${BASH_LINENO[0]:-unknown}"
-
- trap - ERR
- echo "lifecycle program contract: unexpected failure at line ${line} with status ${status}" >&2
- exit "$status"
-}
-
-trap report_unexpected_failure ERR
-
-if [[ "${0##*/}" == "sitectl" ]]; then
- : "${SITECTL_ARGV_LOG:?SITECTL_ARGV_LOG is required}"
- {
- printf '%s' "${1:-}"
- shift || true
- for argument in "$@"; do
- printf '\t%s' "$argument"
- done
- printf '\n'
- } >>"$SITECTL_ARGV_LOG"
- exit 0
-fi
-
-lifecycle_program="${1:-/home/cloud-compose/default-lifecycle.sh}"
-verify_args_program="${2:-/etc/cloud-compose/jq/sitectl-verify-args.jq}"
-if [[ -L "$lifecycle_program" ]]; then
- echo "lifecycle program contract: target is redirected: $lifecycle_program" >&2
- exit 1
-fi
-if [[ ! -f "$lifecycle_program" ]]; then
- echo "lifecycle program contract: missing $lifecycle_program" >&2
- exit 1
-fi
-if [[ ! -r "$lifecycle_program" ]]; then
- echo "lifecycle program contract: target is unreadable: $lifecycle_program" >&2
- exit 1
-fi
-
-contract_dir="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-lifecycle-program.XXXXXX")"
-trap 'rm -rf -- "$contract_dir"' EXIT
-
-contract_program="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/$(basename -- "${BASH_SOURCE[0]}")"
-ln -s -- "$contract_program" "$contract_dir/sitectl"
-argv_log="$contract_dir/sitectl.argv"
-target_ref="refs/pull/123/head"
-commit_sha="0123456789abcdef0123456789abcdef01234567"
-context="lifecycle-contract"
-
-env -u SITECTL_EXECUTABLE \
- PATH="$contract_dir:/usr/bin:/bin" \
- SITECTL_ARGV_LOG="$argv_log" \
- SITECTL_CONTEXT_NAME="$context" \
- SITECTL_ENVIRONMENT=production \
- GIT_COMMIT_SHA="$commit_sha" \
- GIT_REF="$target_ref" \
- GIT_BRANCH=ignored-branch \
- bash "$lifecycle_program" rollout
-
-expected_ref_call="deploy"$'\t'"--context"$'\t'"$context"$'\t'"--ref"$'\t'"$commit_sha"
-grep -Fxq -- "$expected_ref_call" "$argv_log" || {
- echo "lifecycle program contract: GIT_COMMIT_SHA did not take precedence for sitectl deploy --ref" >&2
- exit 1
-}
-if grep -Fq -- $'deploy\t--context\tlifecycle-contract\t--skip-git' "$argv_log"; then
- echo "lifecycle program contract: supplied GIT_REF incorrectly selected --skip-git" >&2
- exit 1
-fi
-
-: >"$argv_log"
-env -u GIT_COMMIT_SHA -u SITECTL_EXECUTABLE \
- PATH="$contract_dir:/usr/bin:/bin" \
- SITECTL_ARGV_LOG="$argv_log" \
- SITECTL_CONTEXT_NAME="$context" \
- SITECTL_ENVIRONMENT=production \
- GIT_REF="$target_ref" \
- GIT_BRANCH=ignored-branch \
- bash "$lifecycle_program" rollout
-
-expected_ref_call="deploy"$'\t'"--context"$'\t'"$context"$'\t'"--ref"$'\t'"$target_ref"
-grep -Fxq -- "$expected_ref_call" "$argv_log" || {
- echo "lifecycle program contract: GIT_REF did not reach sitectl deploy --ref" >&2
- exit 1
-}
-
-: >"$argv_log"
-if env -u SITECTL_EXECUTABLE \
- PATH="$contract_dir:/usr/bin:/bin" \
- SITECTL_ARGV_LOG="$argv_log" \
- SITECTL_CONTEXT_NAME="$context" \
- SITECTL_ENVIRONMENT=production \
- GIT_COMMIT_SHA=not-a-commit \
- GIT_REF="$target_ref" \
- bash "$lifecycle_program" rollout; then
- echo "lifecycle program contract: malformed GIT_COMMIT_SHA was accepted" >&2
- exit 1
-fi
-if [[ -s "$argv_log" ]]; then
- echo "lifecycle program contract: malformed GIT_COMMIT_SHA reached sitectl" >&2
- exit 1
-fi
-
-: >"$argv_log"
-env -u GIT_COMMIT_SHA -u GIT_REF -u GIT_BRANCH -u SITECTL_EXECUTABLE \
- PATH="$contract_dir:/usr/bin:/bin" \
- SITECTL_ARGV_LOG="$argv_log" \
- SITECTL_CONTEXT_NAME="$context" \
- SITECTL_ENVIRONMENT=production \
- bash "$lifecycle_program" rollout
-
-expected_skip_call="deploy"$'\t'"--context"$'\t'"$context"$'\t'"--skip-git"
-grep -Fxq -- "$expected_skip_call" "$argv_log" || {
- echo "lifecycle program contract: rollout without a ref did not select --skip-git" >&2
- exit 1
-}
-
-: >"$argv_log"
-env -u SITECTL_EXECUTABLE \
- PATH="$contract_dir:/usr/bin:/bin" \
- SITECTL_ARGV_LOG="$argv_log" \
- SITECTL_CONTEXT_NAME="$context" \
- SITECTL_ENVIRONMENT=preview \
- SITECTL_VERIFY_ARGS_JSON='["--label","value with spaces",""]' \
- CLOUD_COMPOSE_SITECTL_VERIFY_ARGS_PROGRAM="$verify_args_program" \
- bash "$lifecycle_program" up
-
-expected_verify_call="verify"$'\t'"--context"$'\t'"$context"$'\t'"--label"$'\t'"value with spaces"$'\t'
-grep -Fxq -- "$expected_verify_call" "$argv_log" || {
- echo "lifecycle program contract: verify argument boundaries were not preserved" >&2
- exit 1
-}
-
-echo "Lifecycle program contract passed"
diff --git a/ci/managed-artifact-contract.sh b/ci/managed-artifact-contract.sh
index cb0f4a7..af55da4 100755
--- a/ci/managed-artifact-contract.sh
+++ b/ci/managed-artifact-contract.sh
@@ -2,186 +2,19 @@
set -euo pipefail
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-runtime_script="$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh"
-tmp="$(mktemp -d)"
-trap 'rm -rf -- "$tmp"' EXIT
+repo_root="$(cd "$(dirname "$0")/.." && pwd)"
+runtime="$repo_root/rootfs/home/cloud-compose/host-conf.sh"
-fail() {
- echo "managed artifact contract: $*" >&2
- exit 1
+grep -Fq 'host runtime install' \
+ "$runtime" || {
+ echo "managed package and artifact reconciliation does not delegate to sitectl" >&2
+ exit 1
}
-mkdir -p "$tmp/bin" "$tmp/downloads" "$tmp/target" "$tmp/state/tmp" "$tmp/state/artifacts"
-: >"$tmp/profile.sh"
-curl_log="$tmp/curl.log"
-systemctl_log="$tmp/systemctl.log"
-
-cat >"$tmp/bin/curl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-output=""
-while (($# > 0)); do
- case "$1" in
- -o) output="$2"; shift 2 ;;
- *) shift ;;
- esac
-done
-printf 'curl\n' >>"${CURL_LOG:?}"
-cp -- "${ARTIFACT_PAYLOAD:?}" "$output"
-EOF
-cat >"$tmp/bin/systemctl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-printf '%s\n' "$*" >>"${SYSTEMCTL_LOG:?}"
-[[ "${SYSTEMCTL_FAIL:-false}" != "true" ]]
-EOF
-chmod +x "$tmp/bin/"*
-
-payload="$tmp/downloads/tool"
-printf 'new-artifact\n' >"$payload"
-sha="$(sha256sum "$payload")"
-sha="${sha%% *}"
-manifest="$tmp/manifest.tsv"
-target="$tmp/target/tool"
-artifact_owner="$(id -un)"
-artifact_group="$(id -gn)"
-printf 'tool\thttps://example.invalid/tool\t%s\t%s\t0755\t%s\t%s\tcloud-compose.service\n' \
- "$sha" "$target" "$artifact_owner" "$artifact_group" >"$manifest"
-
-run_install() {
- CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- TEST_STATE="$tmp/state" \
- TEST_MANIFEST="$manifest" \
- ARTIFACT_PAYLOAD="$payload" \
- CURL_LOG="$curl_log" \
- SYSTEMCTL_LOG="$systemctl_log" \
- PATH="$tmp/bin:/usr/bin:/bin" \
- bash --noprofile --norc -c '
- set -euo pipefail
- source "$1"
- STATE_DIR="$TEST_STATE"
- TMP_DIR="$STATE_DIR/tmp"
- ARTIFACT_STATE_DIR="$STATE_DIR/artifacts"
- ARTIFACT_MANIFEST="$TEST_MANIFEST"
- retry_until_success() { "$@"; }
- install_managed_artifacts
- ' managed-artifact "$runtime_script"
+grep -Fq 'can(regex("^[0-9a-f]{64}$", artifact.sha256))' \
+ "$repo_root/modules/gcp/variables.tf" || {
+ echo "Terraform no longer validates managed artifact digests" >&2
+ exit 1
}
-run_install
-cmp -s "$payload" "$target" || fail "verified artifact was not installed"
-[[ "$(<"$tmp/state/artifacts/tool.sha256")" == "$sha" ]] || fail "successful artifact state was not recorded"
-grep -Fxq 'try-restart -- cloud-compose.service' "$systemctl_log" || fail "artifact service was not restarted"
-
-# A complete matching spec is the only fast path. Checksum state cannot mask
-# mode, owner, or group drift.
-: >"$curl_log"
-run_install
-[[ ! -s "$curl_log" ]] || fail "fully matching artifact spec was downloaded again"
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" bash --noprofile --norc -c '
- source "$1"
- managed_artifact_metadata_matches "$2" 0755 "$3" "$4"
- ! managed_artifact_metadata_matches "$2" 0755 wrong-owner "$4"
- ! managed_artifact_metadata_matches "$2" 0755 "$3" wrong-group
- test "$(tab_separated_field_count "$5")" = 8
- test "$(tab_separated_field_count "$6")" = 9
-' managed-artifact-metadata "$runtime_script" "$target" "$artifact_owner" "$artifact_group" \
- $'one\ttwo\tthree\tfour\tfive\tsix\tseven\t' \
- $'one\ttwo\tthree\tfour\tfive\tsix\tseven\teight\t' || \
- fail "managed-artifact metadata or tab-field parsing contract failed"
-chmod 0644 "$target"
-: >"$curl_log"
-run_install
-[[ "$(stat -c %a "$target")" == "755" ]] || fail "artifact mode drift was not reconciled"
-[[ "$(wc -l <"$curl_log")" == 1 ]] || fail "mode drift did not leave the checksum fast path"
-
-# State alone is not trusted: a modified target must be re-downloaded and healed.
-printf 'tampered\n' >"$target"
-: >"$curl_log"
-run_install
-cmp -s "$payload" "$target" || fail "tampered artifact target was trusted from stale state"
-[[ "$(wc -l <"$curl_log")" == 1 ]] || fail "tampered target was not downloaded exactly once"
-
-# Restart failure restores the exact prior target and records failure instead
-# of claiming that the new artifact is installed.
-printf 'prior-artifact\n' >"$target"
-rm -f "$tmp/state/artifacts/tool.sha256"
-prior_sha="$(sha256sum "$target")"
-if SYSTEMCTL_FAIL=true run_install >/dev/null 2>&1; then
- fail "artifact update accepted a failed service restart"
-fi
-[[ "$(sha256sum "$target")" == "$prior_sha" ]] || fail "restart failure did not restore the previous target"
-[[ ! -e "$tmp/state/artifacts/tool.sha256" ]] || fail "restart failure recorded successful artifact state"
-[[ -s "$tmp/state/artifacts/tool.failed" ]] || fail "restart failure did not record an audit state"
-
-SYSTEMCTL_FAIL=false run_install
-cmp -s "$payload" "$target" || fail "artifact did not recover after restart failure"
-[[ ! -e "$tmp/state/artifacts/tool.failed" ]] || fail "successful recovery retained stale failure state"
-
-# Invalid rows fail before any download or target mutation.
-assert_rejected() {
- local row="$1"
- printf '%s\n' "$row" >"$manifest"
- : >"$curl_log"
- if run_install >/dev/null 2>&1; then
- fail "invalid manifest row was accepted: $row"
- fi
- [[ ! -s "$curl_log" ]] || fail "invalid manifest row reached the network: $row"
-}
-
-assert_rejected $'../escape\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\t0\t0\t'
-assert_rejected $'tool\thttp://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\t0\t0\t'
-assert_rejected $'tool\thttps://example.invalid/tool\tnot-a-sha\t'"$target"$'\t0755\t0\t0\t'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t../escape\t0755\t0\t0\t'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t/tmp//tool\t0755\troot\troot\t'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t/tmp/tool/\t0755\troot\troot\t'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t/tmp/control\x7ftool\t0755\troot\troot\t'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t4755\troot\troot\t'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\t0\troot\t'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\tRoot\troot\t'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot.user\troot\t'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot;id\troot\t'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\t-unit.service'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\tunit;id.service'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot'
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\tunit.service\textra'
-long_name="$(printf 'a%.0s' {1..129})"
-assert_rejected "$long_name"$'\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\t'
-
-# A later invalid row and duplicate names/paths are rejected by the complete
-# preflight before the first valid row can download or mutate its target.
-preflight_target="$tmp/target/preflight"
-valid_preflight=$'first\thttps://example.invalid/first\t'"$sha"$'\t'"$preflight_target"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t'
-assert_manifest_preflight_rejected() {
- local second_row="$1"
-
- printf '%s\n%s\n' "$valid_preflight" "$second_row" >"$manifest"
- rm -f -- "$preflight_target"
- : >"$curl_log"
- if run_install >/dev/null 2>&1; then
- fail "invalid multi-row manifest was accepted"
- fi
- [[ ! -s "$curl_log" ]] || fail "invalid later row allowed an earlier download"
- [[ ! -e "$preflight_target" ]] || fail "invalid later row allowed an earlier target mutation"
-}
-
-assert_manifest_preflight_rejected $'bad\thttp://example.invalid/bad\t'"$sha"$'\t'"$tmp/target/bad"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t'
-assert_manifest_preflight_rejected $'first\thttps://example.invalid/two\t'"$sha"$'\t'"$tmp/target/two"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t'
-assert_manifest_preflight_rejected $'second\thttps://example.invalid/two\t'"$sha"$'\t'"$preflight_target"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t'
-assert_manifest_preflight_rejected $'second\thttps://example.invalid/two\t'"$sha"$'\t'"$tmp/missing/second"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t'
-assert_manifest_preflight_rejected $'second\thttps://example.invalid/two\t'"$sha"$'\t'"$tmp/target/second"$'\t0755\tnonexistent-cloud-compose-owner\t'"$artifact_group"$'\t'
-
-# A root-run updater must not follow a symlinked or attacker-writable target
-# directory chain, even when the final artifact path is lexically normalized.
-ln -s -- "$tmp/target" "$tmp/target-link"
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$tmp/target-link/tool"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t'
-mkdir -m 0777 "$tmp/writable-target"
-assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$tmp/writable-target/tool"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t'
-
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" bash --noprofile --norc -c '
- source "$1"
- validate_managed_artifact valid https://example.invalid/a "$2" /tmp/a 0755 root root "cloud-compose@tenant:worker.service"
-' managed-artifact-colon "$runtime_script" "$sha" || fail "runtime rejected the centrally allowed colon restart unit"
-
-echo "Managed artifact contract passed"
+echo "Managed artifact installation delegates to sitectl"
diff --git a/ci/overlay-contract.sh b/ci/overlay-contract.sh
deleted file mode 100755
index 5e39d1a..0000000
--- a/ci/overlay-contract.sh
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-overlay_script="$repo_root/rootfs/home/cloud-compose/overlay-init.sh"
-tmp="$(mktemp -d)"
-trap 'rm -rf -- "$tmp"' EXIT
-
-fail() {
- echo "overlay contract: $*" >&2
- exit 1
-}
-
-volumes_root="$tmp/volumes"
-lower_root="$tmp/lower"
-mkdir -p "$volumes_root" "$lower_root/test-volume"
-mount_marker="$tmp/mounted"
-mount_log="$tmp/mount.log"
-: >"$mount_log"
-
-# Source the functions so the contract can model mount state without requiring
-# CAP_SYS_ADMIN in local CI.
-# shellcheck disable=SC1090
-source "$overlay_script"
-
-verify_overlay_mount() {
- if [[ "${FAKE_MOUNT_MISMATCH:-false}" == "true" ]]; then
- return 2
- fi
- [[ -e "$mount_marker" ]]
-}
-mount() {
- printf 'mount %s\n' "$*" >>"$mount_log"
- : >"$mount_marker"
-}
-umount() {
- printf 'umount %s\n' "$*" >>"$mount_log"
- rm -f -- "$mount_marker"
-}
-
-export CLOUD_COMPOSE_VOLUMES_ROOT="$volumes_root"
-export CLOUD_COMPOSE_OVERLAY_LOWER_ROOT="$lower_root"
-
-if main '../escape' false >/dev/null 2>&1; then
- fail "path-traversing volume name was accepted"
-fi
-if main test-volume maybe >/dev/null 2>&1; then
- fail "ambiguous reset flag was accepted"
-fi
-
-main test-volume false
-[[ -e "$mount_marker" ]] || fail "overlay was not mounted"
-upper="$volumes_root/.overlay/test-volume/upper"
-work="$volumes_root/.overlay/test-volume/work"
-printf 'keep\n' >"$upper/kept"
-printf 'keep\n' >"$work/kept"
-: >"$mount_log"
-
-# Literal false must be idempotent and must not clear writable state.
-main test-volume false
-[[ -e "$upper/kept" && -e "$work/kept" ]] || fail "false reset flag cleared overlay state"
-[[ ! -s "$mount_log" ]] || fail "matching mounted overlay was remounted"
-
-# Explicit reset unmounts, clears both upper and work (including dotfiles), and
-# mounts the expected overlay again.
-printf 'hidden\n' >"$upper/.hidden"
-main test-volume true
-[[ ! -e "$upper/kept" && ! -e "$upper/.hidden" && ! -e "$work/kept" ]] || \
- fail "explicit reset retained writable overlay state"
-grep -Fq 'umount ' "$mount_log" || fail "explicit reset did not unmount"
-grep -Fq 'mount -t overlay overlay' "$mount_log" || fail "explicit reset did not remount"
-
-FAKE_MOUNT_MISMATCH=true
-if main test-volume false >/dev/null 2>&1; then
- fail "unexpected existing mount was accepted"
-fi
-FAKE_MOUNT_MISMATCH=false
-
-rm -f -- "$mount_marker"
-outside="$tmp/outside"
-mkdir -p "$outside"
-rm -rf -- "$volumes_root/.overlay"
-ln -s "$outside" "$volumes_root/.overlay"
-if main test-volume false >/dev/null 2>&1; then
- fail "symbolic-link overlay state boundary was accepted"
-fi
-[[ -z "$(find "$outside" -mindepth 1 -print -quit)" ]] || \
- fail "symbolic-link overlay state boundary was mutated before rejection"
-
-echo "Overlay contract passed"
diff --git a/ci/package-rootfs.sh b/ci/package-rootfs.sh
deleted file mode 100755
index 07ba931..0000000
--- a/ci/package-rootfs.sh
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-repo_root="$(cd -- "$script_dir/.." && pwd)"
-output_dir="${1:-$repo_root/dist}"
-asset_name="cloud-compose-rootfs.tar.gz"
-contract_asset_name="cloud-compose-rootfs.contract.sha256"
-
-if [[ "$output_dir" != /* ]]; then
- output_dir="$PWD/$output_dir"
-fi
-install -d -m 0755 -- "$output_dir"
-tmp="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-rootfs.XXXXXX")"
-trap 'rm -rf -- "$tmp"' EXIT
-
-cp -a -- "$repo_root/rootfs" "$tmp/rootfs"
-find "$tmp/rootfs" -depth -type d -empty -delete
-find "$tmp/rootfs" -type d -exec chmod 0755 -- {} +
-find "$tmp/rootfs" -type f -exec chmod 0644 -- {} +
-find "$tmp/rootfs" -type f -name '*.sh' -exec chmod 0755 -- {} +
-bash "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" \
- contract "$tmp/rootfs" >"$tmp/$contract_asset_name"
-
-LC_ALL=C tar \
- --sort=name \
- --mtime='UTC 1970-01-01' \
- --owner=0 \
- --group=0 \
- --numeric-owner \
- -C "$tmp" \
- -cf - rootfs | gzip -n -9 >"$tmp/$asset_name"
-
-(
- cd "$tmp"
- sha256sum "$asset_name" >"${asset_name}.sha256"
-)
-install -m 0644 "$tmp/$asset_name" "$output_dir/$asset_name"
-install -m 0644 "$tmp/${asset_name}.sha256" "$output_dir/${asset_name}.sha256"
-install -m 0644 "$tmp/$contract_asset_name" "$output_dir/$contract_asset_name"
diff --git a/ci/remote/config-management-verify.sh b/ci/remote/config-management-verify.sh
index ed355eb..7399926 100755
--- a/ci/remote/config-management-verify.sh
+++ b/ci/remote/config-management-verify.sh
@@ -2,8 +2,8 @@
set -euo pipefail
-if [[ "$#" -ne 6 ]]; then
- echo "usage: config-management-verify.sh NAME TEMPLATE ENVIRONMENT PROJECT_DIR LIFECYCLE_CONTRACT RUNTIME_STATE_CONTRACT" >&2
+if [[ "$#" -ne 5 ]]; then
+ echo "usage: config-management-verify.sh NAME TEMPLATE ENVIRONMENT PROJECT_DIR RUNTIME_STATE_CONTRACT" >&2
exit 2
fi
@@ -11,56 +11,29 @@ export SMOKE_NAME="$1"
export SMOKE_TEMPLATE="$2"
export SMOKE_ENVIRONMENT="$3"
export SMOKE_PROJECT_DIR="$4"
-readonly lifecycle_program_contract="$5"
-readonly runtime_state_contract="$6"
+readonly runtime_state_contract="$5"
-for contract_program in "$lifecycle_program_contract" "$runtime_state_contract"; do
- [[ "$contract_program" == /tmp/cloud-compose-hosted-contract.*/* &&
- -f "$contract_program" && ! -L "$contract_program" ]] || {
- echo "Remote contract program is missing or unsafe: $contract_program" >&2
- exit 1
- }
-done
+[[ "$runtime_state_contract" == /tmp/cloud-compose-hosted-contract.*/* &&
+ -f "$runtime_state_contract" && ! -L "$runtime_state_contract" ]] || {
+ echo "Remote contract program is missing or unsafe: $runtime_state_contract" >&2
+ exit 1
+}
for program in \
/home/cloud-compose/init \
/home/cloud-compose/up \
/home/cloud-compose/down \
/home/cloud-compose/rollout \
- /home/cloud-compose/default-lifecycle.sh \
+ /home/cloud-compose/bin/sitectl \
/home/cloud-compose/run.sh \
- /home/cloud-compose/start-cloud-compose-bootstrap.sh \
/etc/cloud-compose/libexec/bootstrap-required.sh \
- /etc/cloud-compose/libexec/bootstrap-security.sh \
- /etc/cloud-compose/libexec/run-bootstrap.sh \
- /etc/cloud-compose/libexec/require-bootstrap-ready.sh \
- /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh \
- /etc/cloud-compose/libexec/run-root-program.sh; do
+ /etc/cloud-compose/libexec/sitectl-host.sh; do
test -x "$program"
done
-shopt -s nullglob dotglob
-awk_programs=(/etc/cloud-compose/awk/*.awk)
-jq_programs=(/etc/cloud-compose/jq/*.jq)
-shopt -u nullglob dotglob
-(( ${#awk_programs[@]} > 0 ))
-(( ${#jq_programs[@]} > 0 ))
-data_programs=(
- /etc/cloud-compose/libexec/checked-programs.bash
- "${awk_programs[@]}"
- "${jq_programs[@]}"
-)
-for data_program in "${data_programs[@]}"; do
- test ! -L "$data_program"
- test -f "$data_program"
- test "$(stat -c '%u:%g:%a:%h' -- "$data_program")" = "0:0:644:1"
-done
-
for program_parent in \
/home/cloud-compose \
/etc/cloud-compose \
- /etc/cloud-compose/awk \
- /etc/cloud-compose/jq \
/etc/cloud-compose/libexec; do
test ! -L "$program_parent"
test -d "$program_parent"
@@ -70,7 +43,7 @@ done
python3 -m json.tool /home/cloud-compose/compose-projects.json >/dev/null
python3 -m json.tool /home/cloud-compose/application-env.json >/dev/null
python3 "$runtime_state_contract"
-bash "$lifecycle_program_contract" /home/cloud-compose/default-lifecycle.sh
+/home/cloud-compose/bin/sitectl --version >/dev/null
test -d "$SMOKE_PROJECT_DIR/.git"
systemctl is-active --quiet cloud-compose
diff --git a/ci/rootfs-archive-path-contract.awk b/ci/rootfs-archive-path-contract.awk
deleted file mode 100644
index 9a2783f..0000000
--- a/ci/rootfs-archive-path-contract.awk
+++ /dev/null
@@ -1,3 +0,0 @@
-$0 !~ /^rootfs\// { bad = 1 }
-/(^|\/)\.\.($|\/)/ { bad = 1 }
-END { exit bad }
diff --git a/ci/rootfs-package-contract.sh b/ci/rootfs-package-contract.sh
deleted file mode 100755
index f7ec1dc..0000000
--- a/ci/rootfs-package-contract.sh
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-tmp="$(mktemp -d)"
-trap 'rm -rf -- "$tmp"' EXIT
-
-bash "$script_dir/package-rootfs.sh" "$tmp/one"
-bash "$script_dir/package-rootfs.sh" "$tmp/two"
-cmp "$tmp/one/cloud-compose-rootfs.tar.gz" "$tmp/two/cloud-compose-rootfs.tar.gz"
-cmp "$tmp/one/cloud-compose-rootfs.tar.gz.sha256" "$tmp/two/cloud-compose-rootfs.tar.gz.sha256"
-cmp "$tmp/one/cloud-compose-rootfs.contract.sha256" "$tmp/two/cloud-compose-rootfs.contract.sha256"
-(
- cd "$tmp/one"
- sha256sum -c cloud-compose-rootfs.tar.gz.sha256
-)
-contract_sha256="$(<"$tmp/one/cloud-compose-rootfs.contract.sha256")"
-[[ "$contract_sha256" =~ ^[0-9a-f]{64}$ ]] || {
- echo "Rootfs contract sidecar is not one lowercase SHA-256 digest" >&2
- exit 1
-}
-mkdir "$tmp/extracted"
-tar -xzf "$tmp/one/cloud-compose-rootfs.tar.gz" -C "$tmp/extracted"
-actual_contract_sha256="$(
- bash "$script_dir/../rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" \
- contract "$tmp/extracted/rootfs"
-)"
-[[ "$actual_contract_sha256" == "$contract_sha256" ]] || {
- echo "Rootfs contract sidecar does not match the packaged rootfs" >&2
- exit 1
-}
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
- awk -f "$script_dir/rootfs-archive-path-contract.awk"
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/home/cloud-compose/run.sh' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/home/cloud-compose/default-lifecycle.sh' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/home/cloud-compose/lifecycle-entrypoint.sh' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/jq/compose-validate-projects.jq' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/jq/rotation-validate-state.jq' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/awk/reconcile-fstab.awk' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/awk/release-checksum.awk' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/awk/compose-secret-files.awk' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/etc/cloud-compose/libexec/checked-programs.bash' >/dev/null
-for bootstrap_program in \
- gcp-cloud-init-finalize.sh \
- gcp-cloud-init-post-bootstrap.sh \
- gcp-filesystem-boot.sh \
- linux-vm-cloud-init.sh \
- rootfs-archive.sh \
- run-lifecycle-program.sh; do
- tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
- grep -Fx "rootfs/etc/cloud-compose/libexec/$bootstrap_program" >/dev/null
-done
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
- grep -Fx 'rootfs/etc/cloud-compose/libexec/run-bootstrap.sh' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
- grep -Fx 'rootfs/etc/cloud-compose/libexec/run-root-program.sh' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
- grep -Fx 'rootfs/etc/cloud-compose/libexec/build-cos-make.sh' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
- grep -Fx 'rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
- grep -Fx 'rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq' >/dev/null
-tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | \
- grep -Fx 'rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh' >/dev/null
-if tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -E '^rootfs/usr/' >/dev/null; then
- echo "Rootfs package contains a Cloud Compose-owned immutable /usr path" >&2
- exit 1
-fi
-
-echo "Rootfs package contract passed"
diff --git a/ci/runtime-config-contract.sh b/ci/runtime-config-contract.sh
index f6dc862..ea00211 100755
--- a/ci/runtime-config-contract.sh
+++ b/ci/runtime-config-contract.sh
@@ -75,7 +75,6 @@ assert_runtime_values() {
# The child shell receives values as positional parameters.
# shellcheck disable=SC2016
env -i PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
CLOUD_COMPOSE_ENV_FILE="$env_file" bash --noprofile --norc -c '
source "$1"
shift
@@ -153,7 +152,6 @@ PY
require_cmd bash
require_cmd docker
require_cmd grep
-require_cmd jq
require_cmd python3
require_line modules/runtime-env/main.tf "cloud-compose-env-contract"
@@ -199,19 +197,12 @@ require_line modules/linux-vm-runtime/variables.tf 'ssh_users names must be safe
# Match the literal expansion in the profile implementation.
# shellcheck disable=SC2016
require_line rootfs/home/cloud-compose/profile.sh 'load_runtime_env "${CLOUD_COMPOSE_ENV_FILE:-/home/cloud-compose/.env}"'
-require_line rootfs/home/cloud-compose/profile.sh 'acquire_cloud_compose_lifecycle_lock() {'
-require_line rootfs/home/cloud-compose/profile.sh 'local lock_file="/run/lock/cloud-compose/lifecycle.lock"'
-require_line rootfs/home/cloud-compose/profile.sh 'lock_fd_path="/proc/${BASHPID}/fd/8"'
-require_line rootfs/home/cloud-compose/profile.sh 'if ! exec 8<>"$lock_file"; then'
-require_line rootfs/home/cloud-compose/profile.sh 'if [[ -e /proc/${BASHPID}/fd/8 ]]; then'
-require_line rootfs/home/cloud-compose/compose-dispatch.sh 'acquire_cloud_compose_lifecycle_lock "$lifecycle"'
-require_line rootfs/home/cloud-compose/mariadb-backup.sh 'acquire_cloud_compose_lifecycle_lock mariadb-backup'
-require_line rootfs/home/cloud-compose/host-init.sh 'update_runtime_env_file .env GCP_PUBLIC_IP'
-require_line rootfs/home/cloud-compose/host-init.sh 'source /home/cloud-compose/profile.sh'
-require_line rootfs/home/cloud-compose/app-init.sh 'sync_compose_application_env'
-require_line rootfs/home/cloud-compose/run-rollout-service.sh 'PORT="${ROLLOUT_PORT:?ROLLOUT_PORT is required}"'
-require_line rootfs/home/cloud-compose/run-rollout-service.sh 'JWKS_URI="${ROLLOUT_JWKS_URI:?ROLLOUT_JWKS_URI is required}"'
-require_line rootfs/home/cloud-compose/run-rollout-service.sh 'export PORT JWKS_URI JWT_AUD CUSTOM_CLAIMS'
+require_line rootfs/home/cloud-compose/profile.sh 'decode_runtime_env_value() {'
+require_line rootfs/home/cloud-compose/lifecycle-entrypoint.sh 'exec sitectl host apps lifecycle "$lifecycle"'
+require_line rootfs/home/cloud-compose/run.sh 'run_as_cloud_compose "$managed_sitectl" host apps lifecycle init'
+require_line rootfs/home/cloud-compose/run.sh '"$sitectl" host configure'
+require_line rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service 'sitectl-host.sh backup mariadb'
+require_line rootfs/etc/systemd/system/cloud-compose-rollout.service 'sitectl-host.sh rollout-serve'
if grep -Eq '},[[:space:]]*var\.extra_env\)' \
"$repo_root/modules/gcp/main.tf" "$repo_root/modules/linux-vm-runtime/main.tf"; then
@@ -247,123 +238,10 @@ assert_compose_values "$env_file"
test ! -e "$tmp/backtick-injection"
test ! -e "$tmp/command-injection"
-update_dir="$tmp/update"
-mkdir -p "$update_dir"
-cp "$env_file" "$update_dir/.env"
-UPDATE_VALUE=$'updated $(touch update-injection) "quote" \\ path\nnext line'
-env -i PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
- CLOUD_COMPOSE_ENV_FILE="$update_dir/.env" \
- bash --noprofile --norc -c '
- source "$1"
- cd "$2"
- update_env UPDATED "$3"
- load_runtime_env .env
- test "$UPDATED" = "$3"
- ' cloud-compose-env \
- "$repo_root/rootfs/home/cloud-compose/profile.sh" \
- "$update_dir" \
- "$UPDATE_VALUE"
-assert_compose_values "$update_dir/.env"
-test ! -e "$update_dir/update-injection"
-
-compose_update_dir="$tmp/compose-update"
-mkdir -p "$compose_update_dir"
-cat >"$compose_update_dir/.env" <<'EOF'
-# Existing downstream Compose dotenv syntax must remain opaque.
-COMPOSE_PROJECT_NAME=legacy-project
-DOMAIN=example.org
-EXPANDED=${DOMAIN}/path
-SINGLE_QUOTED='literal $DOMAIN'
-EOF
-env -i PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
- CLOUD_COMPOSE_ENV_FILE="$env_file" \
- bash --noprofile --norc -c '
- source "$1"
- cd "$2"
- update_compose_env COMPOSE_PROJECT_NAME "managed-project"
- update_compose_env COMPOSE_PROJECT_NAME "managed-project"
- ' cloud-compose-env \
- "$repo_root/rootfs/home/cloud-compose/profile.sh" \
- "$compose_update_dir"
-grep -Fq 'COMPOSE_PROJECT_NAME=legacy-project' "$compose_update_dir/.env"
-grep -Fq 'EXPANDED=${DOMAIN}/path' "$compose_update_dir/.env"
-grep -Fq "SINGLE_QUOTED='literal \$DOMAIN'" "$compose_update_dir/.env"
-test "$(grep -Fc '# cloud-compose managed: COMPOSE_PROJECT_NAME' "$compose_update_dir/.env")" = 1
-test "$(tail -n 1 "$compose_update_dir/.env")" = 'COMPOSE_PROJECT_NAME="managed-project"'
-
-application_update_dir="$tmp/application-update"
-mkdir -p "$application_update_dir"
-printf '%s\n' '# Existing downstream Compose data.' 'UNCHANGED=${DOMAIN}/path' >"$application_update_dir/.env"
-# The dollar expression belongs to jq.
-# shellcheck disable=SC2016
-jq -n \
- --arg BACKTICKS "$BACKTICKS" \
- --arg BACKSLASH "$BACKSLASH" \
- --arg COMMAND_SUB "$COMMAND_SUB" \
- --arg DOLLAR_VALUE "$DOLLAR_VALUE" \
- --arg DOUBLE_QUOTES "$DOUBLE_QUOTES" \
- --arg MULTILINE "$MULTILINE" \
- --arg SINGLE_QUOTE "$SINGLE_QUOTE" \
- --arg TRAILING_SLASH "$TRAILING_SLASH" \
- --arg WHITESPACE "$WHITESPACE" \
- --arg name "$name" \
- '$ARGS.named' >"$tmp/application-env.json"
-# The child shell receives file paths as positional parameters.
-# shellcheck disable=SC2016
-env -i PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
- CLOUD_COMPOSE_ENV_FILE="$env_file" \
- CLOUD_COMPOSE_APPLICATION_ENV_FILE="$tmp/application-env.json" \
- bash --noprofile --norc -c '
- source "$1"
- cd "$2"
- sync_compose_application_env
- ' cloud-compose-env \
- "$repo_root/rootfs/home/cloud-compose/profile.sh" \
- "$application_update_dir"
-assert_compose_values "$application_update_dir/.env"
-grep -Fq 'UNCHANGED=${DOMAIN}/path' "$application_update_dir/.env"
-test ! -e "$tmp/backtick-injection"
-test ! -e "$tmp/command-injection"
-
-unsafe_update_dir="$tmp/unsafe-update"
-mkdir -p "$unsafe_update_dir"
-ln -s /etc/passwd "$unsafe_update_dir/.env"
-if env -i PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
- CLOUD_COMPOSE_ENV_FILE="$env_file" \
- bash --noprofile --norc -c '
- source "$1"
- cd "$2"
- update_env SAFE value
- ' cloud-compose-env \
- "$repo_root/rootfs/home/cloud-compose/profile.sh" \
- "$unsafe_update_dir" >/dev/null 2>&1; then
- echo "Runtime environment updater followed an unsafe symlink" >&2
- exit 1
-fi
-
-if env -i PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
- CLOUD_COMPOSE_ENV_FILE="$env_file" \
- bash --noprofile --norc -c '
- source "$1"
- cd "$2"
- update_env "BAD-NAME" value
- ' cloud-compose-env \
- "$repo_root/rootfs/home/cloud-compose/profile.sh" \
- "$update_dir" >/dev/null 2>&1; then
- echo "Runtime environment updater accepted an unsafe variable name" >&2
- exit 1
-fi
-
printf '%s\n' 'BAD-NAME="value"' >"$tmp/invalid.env"
# Source runs in the intentionally isolated child shell.
# shellcheck disable=SC2016
if env -i PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
CLOUD_COMPOSE_ENV_FILE="$tmp/invalid.env" \
bash --noprofile --norc -c 'source "$1"' cloud-compose-env \
"$repo_root/rootfs/home/cloud-compose/profile.sh" >/dev/null 2>&1; then
@@ -380,7 +258,6 @@ for invalid_value in \
# Source runs in the intentionally isolated child shell.
# shellcheck disable=SC2016
if env -i PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
CLOUD_COMPOSE_ENV_FILE="$tmp/invalid.env" \
bash --noprofile --norc -c 'source "$1"' cloud-compose-env \
"$repo_root/rootfs/home/cloud-compose/profile.sh" >/dev/null 2>&1; then
@@ -391,7 +268,6 @@ done
retry_log="$tmp/retry.log"
if env -i PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
CLOUD_COMPOSE_ENV_FILE="$env_file" MAX_RETRIES=1 \
bash --noprofile --norc -c '
source "$1"
@@ -400,7 +276,6 @@ if env -i PATH=/usr/bin:/bin \
echo "Retry contract unexpectedly accepted a failing command" >&2
exit 1
fi
-grep -Fq "Operation 'false' failed" "$retry_log"
if grep -Fq 'must-not-appear' "$retry_log"; then
echo "Retry logging exposed command arguments" >&2
exit 1
@@ -417,7 +292,6 @@ for assignment in \
'SLEEP_INCREMENT=a[$(touch /tmp/cloud-compose-retry-injection)]'; do
rm -f /tmp/cloud-compose-retry-injection
if env -i PATH=/usr/bin:/bin \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq" \
CLOUD_COMPOSE_ENV_FILE="$env_file" "$assignment" \
bash --noprofile --norc -c '
source "$1"
diff --git a/ci/sitectl-host-runtime-contract.sh b/ci/sitectl-host-runtime-contract.sh
new file mode 100755
index 0000000..8d404ef
--- /dev/null
+++ b/ci/sitectl-host-runtime-contract.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+runtime="$repo_root/rootfs/home/cloud-compose"
+debian_installer="$runtime/install-dependencies-debian.sh"
+shell_lines=0
+while IFS= read -r -d '' program; do
+ lines="$(wc -l <"$program")"
+ shell_lines=$((shell_lines + lines))
+done < <(find "$repo_root/rootfs" -type f \( -name '*.sh' -o -name '*.bash' \) -print0)
+((shell_lines <= 1000)) || {
+ echo "rootfs shell budget exceeded: ${shell_lines} lines (maximum 1000)" >&2
+ exit 1
+}
+
+grep -Fq 'exec sitectl host keys app "${1:-rotate}"' "$runtime/rotate-keys-app.sh"
+grep -Fq 'exec sitectl host apps lifecycle "$lifecycle"' "$runtime/lifecycle-entrypoint.sh"
+grep -Fq 'exec /etc/cloud-compose/libexec/sitectl-host.sh diagnostics "$@"' \
+ "$repo_root/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"
+grep -Fq '"$sitectl" host apps converge-filesystems' "$runtime/run.sh"
+grep -Fq 'run_as_cloud_compose "$managed_sitectl" host apps prepare' "$runtime/run.sh"
+grep -Fq 'run_as_cloud_compose "$managed_sitectl" host apps lifecycle init' "$runtime/run.sh"
+grep -Fq 'PATH="/home/cloud-compose/bin:$PATH"' "$runtime/run.sh"
+grep -Fq '/home/cloud-compose/bin/sitectl --version' "$repo_root/ci/cloud-smoke.sh"
+grep -Fq '/home/cloud-compose/bin/sitectl --version' "$repo_root/ci/remote/config-management-verify.sh"
+grep -Fq '"$sitectl" host runtime install' "$runtime/host-conf.sh"
+grep -Fq 'exec "$sitectl" host "$@"' \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/sitectl-host.sh"
+grep -Fq 'if ! command -v docker >/dev/null 2>&1; then' "$debian_installer"
+grep -Fq 'packages+=(docker.io)' "$debian_installer"
+grep -Fq 'retry_until_success apt-get install -y "${packages[@]}"' "$debian_installer"
+
+for retired in \
+ app-init.sh \
+ app-rollout.sh \
+ compose-apps.sh \
+ compose-dispatch.sh \
+ configure-metadata-firewall.sh \
+ converge-app-filesystems.sh \
+ default-lifecycle.sh \
+ disaster-recovery-lib.sh \
+ docker-prune.sh \
+ host-init.sh \
+ libops-managed-runtime.sh \
+ mariadb-backup.sh \
+ migrate-legacy-systemd-units.sh \
+ mount-overlays.sh \
+ offhost-backup.sh \
+ prepare-app-sources.sh \
+ prepare-filesystem.sh \
+ persist-filesystems.sh \
+ restore-test.sh \
+ rotate-keys-daily.sh \
+ rotate-keys-internal.sh \
+ run-bootstrap.sh \
+ run-rollout-service.sh \
+ start-cloud-compose-bootstrap.sh \
+ vault-agent-init.sh \
+ vault-agent-readiness.sh \
+ assert-app-initialized.sh \
+ assert-vault-ready.sh \
+ rotate-keys.sh; do
+ test ! -e "$runtime/$retired" || {
+ echo "retired shell implementation still exists: $retired" >&2
+ exit 1
+ }
+done
diff --git a/ci/sitectl-version-contract.sh b/ci/sitectl-version-contract.sh
index a2ba393..e9cdd9d 100755
--- a/ci/sitectl-version-contract.sh
+++ b/ci/sitectl-version-contract.sh
@@ -3,431 +3,16 @@
set -euo pipefail
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
-runtime_script="$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh"
-export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
-tmp="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-sitectl-versions.XXXXXX")"
-trap 'rm -rf "$tmp"' EXIT
+runtime="$repo_root/rootfs/home/cloud-compose/host-conf.sh"
-touch "$tmp/profile.sh"
+grep -Fq 'host runtime install' "$runtime"
+grep -Fq 'sitectl=/etc/cloud-compose/bin/bootstrap-sitectl' "$runtime"
+grep -Fq 'sha256sum -c -' \
+ "$repo_root/rootfs/etc/cloud-compose/libexec/bootstrap-sitectl.sh"
-# A restrictive first-boot umask once left the root-owned managed binary path
-# untraversable after application initialization dropped privileges. Exercise
-# the production mkdirs implementation against that preexisting state and
-# require every invocation to converge both public and private modes.
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- bash --noprofile --norc -c '
- set -euo pipefail
- source "$1"
- STATE_DIR="$2/mode-state"
- BIN_DIR="$STATE_DIR/bin"
- TMP_DIR="$STATE_DIR/tmp"
- PACKAGE_STATE_DIR="$STATE_DIR/packages"
- ARTIFACT_STATE_DIR="$STATE_DIR/artifacts"
- PUBLISHED_BIN_DIR="$2/published"
+if grep -Eq 'install_packages\(|host artifacts install' "$runtime"; then
+ echo "managed package reconciliation must remain in sitectl" >&2
+ exit 1
+fi
- umask 0077
- mkdir -p \
- "$BIN_DIR" \
- "$TMP_DIR" \
- "$PACKAGE_STATE_DIR" \
- "$ARTIFACT_STATE_DIR" \
- "$PUBLISHED_BIN_DIR"
- chmod 0700 "$STATE_DIR" "$BIN_DIR"
- chmod 0755 "$TMP_DIR" "$PACKAGE_STATE_DIR" "$ARTIFACT_STATE_DIR"
-
- mkdirs
- mkdirs
-
- test "$(stat -c "%a" "$STATE_DIR")" = 755
- test "$(stat -c "%a" "$BIN_DIR")" = 755
- test "$(stat -c "%a" "$TMP_DIR")" = 700
- test "$(stat -c "%a" "$PACKAGE_STATE_DIR")" = 700
- test "$(stat -c "%a" "$ARTIFACT_STATE_DIR")" = 700
- test "$(stat -c "%a" "$PUBLISHED_BIN_DIR")" = 755
- test "$(stat -c "%u:%g" "$STATE_DIR")" = "$(id -u):$(id -g)"
- test "$(stat -c "%u:%g" "$PUBLISHED_BIN_DIR")" = "$(id -u):$(id -g)"
-
- touch "$PUBLISHED_BIN_DIR/docker"
- if mkdirs; then
- echo "managed runtime accepted an unmanaged published command" >&2
- exit 1
- fi
- rm -f "$PUBLISHED_BIN_DIR/docker"
-
- touch "$BIN_DIR/make"
- ln -s "$BIN_DIR/make" "$PUBLISHED_BIN_DIR/make"
- mkdirs
- rm -f "$PUBLISHED_BIN_DIR/make"
- ln -s "$2/unsafe-make" "$PUBLISHED_BIN_DIR/make"
- if mkdirs; then
- echo "managed runtime accepted an unsafe published Make target" >&2
- exit 1
- fi
- rm -f "$PUBLISHED_BIN_DIR/make"
-
- unsafe_target="$2/unsafe-target"
- unsafe_state="$2/unsafe-state"
- mkdir -p "$unsafe_target"
- ln -s "$unsafe_target" "$unsafe_state"
- STATE_DIR="$unsafe_state"
- BIN_DIR="$STATE_DIR/bin"
- TMP_DIR="$STATE_DIR/tmp"
- PACKAGE_STATE_DIR="$STATE_DIR/packages"
- ARTIFACT_STATE_DIR="$STATE_DIR/artifacts"
- if mkdirs; then
- echo "managed runtime accepted a redirected state directory" >&2
- exit 1
- fi
-
- unsafe_state="$2/unsafe-writable-state"
- mkdir -m 0775 "$unsafe_state"
- STATE_DIR="$unsafe_state"
- BIN_DIR="$STATE_DIR/bin"
- TMP_DIR="$STATE_DIR/tmp"
- PACKAGE_STATE_DIR="$STATE_DIR/packages"
- ARTIFACT_STATE_DIR="$STATE_DIR/artifacts"
- if mkdirs; then
- echo "managed runtime accepted group-writable state" >&2
- exit 1
- fi
- ' cloud-compose-sitectl-modes "$runtime_script" "$tmp"
-
-run_contract() {
- CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- SITECTL_PACKAGES="$1" \
- SITECTL_VERSION="$2" \
- SITECTL_PACKAGE_VERSIONS="$3" \
- bash --noprofile --norc -c '
- set -euo pipefail
- source "$1"
- shift
- validate_sitectl_configuration
- while (( $# > 0 )); do
- package="$1"
- expected="$2"
- shift 2
- actual="$(sitectl_package_version "$package")"
- test "$actual" = "$expected"
- done
- ' cloud-compose-sitectl-versions "$runtime_script" "${@:4}"
-}
-
-reject_contract() {
- local packages="$1" version="$2" package_versions="$3"
-
- if CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- SITECTL_PACKAGES="$packages" \
- SITECTL_VERSION="$version" \
- SITECTL_PACKAGE_VERSIONS="$package_versions" \
- bash --noprofile --norc -c '
- set -euo pipefail
- source "$1"
- validate_sitectl_configuration
- touch "$2"
- ' cloud-compose-sitectl-versions "$runtime_script" "$tmp/download-started" >/dev/null 2>&1; then
- echo "Invalid sitectl version contract was accepted: $package_versions" >&2
- return 1
- fi
- test ! -e "$tmp/download-started"
-}
-
-run_contract \
- "sitectl sitectl-isle sitectl-drupal" \
- "v0.38.0" \
- '{"sitectl":"v0.39.0-rc.1","sitectl-isle":"v0.12.0"}' \
- sitectl v0.39.0-rc.1 \
- sitectl-isle v0.12.0 \
- sitectl-drupal v0.38.0
-
-run_contract \
- "sitectl sitectl-isle" \
- "v0.38.0" \
- '{}' \
- sitectl v0.38.0 \
- sitectl-isle v0.38.0
-
-run_contract \
- "sitectl sitectl-isle" \
- "latest" \
- '{"sitectl":"v0.38.0","sitectl-isle":"latest"}' \
- sitectl v0.38.0 \
- sitectl-isle latest
-
-reject_contract "sitectl sitectl-isle" "v0.38.0" '{'
-reject_contract "sitectl sitectl-isle" "v0.38.0" '[]'
-reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl-isle":12}'
-reject_contract "sitectl sitectl-isle" "v0.38.0" '{"../../sitectl":"v0.1.0"}'
-reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl-isle":"v0.12.0/../../latest"}'
-reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl-wp":"v0.9.0"}'
-reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl\n":"v0.38.0"}'
-reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl\u0000":"v0.38.0"}'
-reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl":"latest\n"}'
-reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl":"v0.38.0\u0000"}'
-reject_contract "sitectl sitectl-isle" "v0.38.0/../../latest" '{}'
-reject_contract "sitectl --installroot" "latest" '{}'
-
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- bash --noprofile --norc -c '
- set -euo pipefail
- source "$1"
- TMP_DIR="$2"
- mkdir -p "$TMP_DIR"
- retry_until_success() {
- "$@"
- }
- curl() {
- local output="" url=""
- while (( $# > 0 )); do
- case "$1" in
- -o)
- output="$2"
- shift 2
- ;;
- http*)
- url="$1"
- shift
- ;;
- *)
- shift
- ;;
- esac
- done
- test "$url" = "https://api.github.com/repos/libops/sitectl-isle/releases/latest"
- printf "%s\n" "{\"tag_name\":\"v0.12.0\"}" >"$output"
- }
- tag="$(latest_release_tag sitectl-isle)"
- test "$tag" = "v0.12.0"
- base_url="$(release_base_url sitectl-isle "$tag")"
- test "$base_url" = "https://github.com/libops/sitectl-isle/releases/download/v0.12.0"
- [[ "$base_url" != */releases/latest/download ]]
- ' cloud-compose-sitectl-latest "$runtime_script" "$tmp/latest"
-
-reject_latest_metadata() {
- local metadata="$1"
-
- if CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- LATEST_METADATA="$metadata" \
- bash --noprofile --norc -c '
- set -euo pipefail
- source "$1"
- TMP_DIR="$2"
- mkdir -p "$TMP_DIR"
- retry_until_success() {
- "$@"
- }
- curl() {
- local output=""
- while (( $# > 0 )); do
- if [[ "$1" == "-o" ]]; then
- output="$2"
- shift 2
- else
- shift
- fi
- done
- printf "%s\n" "$LATEST_METADATA" >"$output"
- }
- latest_release_tag sitectl-isle
- ' cloud-compose-sitectl-latest "$runtime_script" "$tmp/latest-invalid" >/dev/null 2>&1; then
- echo "Latest release resolution accepted invalid metadata: $metadata" >&2
- return 1
- fi
-}
-
-reject_latest_metadata '{}'
-reject_latest_metadata '{"tag_name":"latest"}'
-reject_latest_metadata '{"tag_name":"v0.12.0\n"}'
-reject_latest_metadata '{"tag_name":"v0.12.0\u0000"}'
-
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- SITECTL_PACKAGES="sitectl sitectl-isle" \
- SITECTL_VERSION="latest" \
- SITECTL_PACKAGE_VERSIONS='{"sitectl":"latest","sitectl-isle":"latest"}' \
- bash --noprofile --norc -c '
- set -euo pipefail
- source "$1"
- STATE_DIR="$2/install-state"
- BIN_DIR="$STATE_DIR/bin"
- TMP_DIR="$STATE_DIR/tmp"
- PACKAGE_STATE_DIR="$STATE_DIR/packages"
- ARTIFACT_STATE_DIR="$STATE_DIR/artifacts"
- fixture_root="$2"
- url_log="$fixture_root/install-urls"
-
- mkdirs() {
- mkdir -p "$BIN_DIR" "$TMP_DIR" "$PACKAGE_STATE_DIR" "$ARTIFACT_STATE_DIR"
- }
- machine_arch() {
- printf "%s\n" x86_64
- }
- latest_release_tag() {
- test "$1" = "sitectl-isle"
- printf "%s\n" v0.12.0
- }
- retry_until_success() {
- "$@"
- }
- ln() {
- :
- }
- curl() {
- local output="" url="" archive_dir archive_path payload_dir checksum
- while (( $# > 0 )); do
- case "$1" in
- -o)
- output="$2"
- shift 2
- ;;
- http*)
- url="$1"
- shift
- ;;
- *)
- shift
- ;;
- esac
- done
- printf "%s\n" "$url" >>"$url_log"
- case "$url" in
- */sitectl-isle_Linux_x86_64.tar.gz)
- payload_dir="$(mktemp -d "$fixture_root/payload.XXXXXX")"
- printf "#!/usr/bin/env bash\n" >"$payload_dir/sitectl-isle"
- printf "release documentation\n" >"$payload_dir/README.md"
- chmod 0755 "$payload_dir/sitectl-isle"
- tar -czf "$output" -C "$payload_dir" sitectl-isle README.md
- rm -rf "$payload_dir"
- ;;
- */checksums.txt)
- archive_dir="$(dirname "$output")"
- archive_path="$archive_dir/sitectl-isle_Linux_x86_64.tar.gz"
- checksum="$(sha256sum "$archive_path")"
- checksum="${checksum%% *}"
- printf "%s %s\n" "$checksum" "$(basename "$archive_path")" >"$output"
- ;;
- *)
- return 1
- ;;
- esac
- }
-
- mkdirs
- install_release_package sitectl-isle
- test "$(cat "$PACKAGE_STATE_DIR/sitectl-isle.version")" = "v0.12.0"
- test "$(wc -l <"$url_log")" -eq 2
- grep -Fxq "https://github.com/libops/sitectl-isle/releases/download/v0.12.0/sitectl-isle_Linux_x86_64.tar.gz" "$url_log"
- grep -Fxq "https://github.com/libops/sitectl-isle/releases/download/v0.12.0/checksums.txt" "$url_log"
- if grep -Fq "/releases/latest/download" "$url_log"; then
- echo "Installer mixed a resolved latest tag with a mutable latest URL" >&2
- exit 1
- fi
- ' cloud-compose-sitectl-install "$runtime_script" "$tmp"
-
-# Package sets are staged as one generation. A later plugin failure cannot
-# replace an already-installed core, and a promotion failure restores every
-# binary and version/checksum state file.
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- SITECTL_PACKAGES="sitectl sitectl-isle" \
- SITECTL_VERSION="v1.0.0" \
- SITECTL_PACKAGE_VERSIONS='{"sitectl":"v1.0.0","sitectl-isle":"v1.0.0"}' \
- bash --noprofile --norc -c '
- set -euo pipefail
- source "$1"
- STATE_DIR="$2/generation-state"
- BIN_DIR="$STATE_DIR/bin"
- TMP_DIR="$STATE_DIR/tmp"
- PACKAGE_STATE_DIR="$STATE_DIR/packages"
- ARTIFACT_STATE_DIR="$STATE_DIR/artifacts"
- PUBLISHED_BIN_DIR="$STATE_DIR/published"
- mkdirs() {
- mkdir -p "$BIN_DIR" "$TMP_DIR" "$PACKAGE_STATE_DIR" "$ARTIFACT_STATE_DIR" "$PUBLISHED_BIN_DIR"
- }
- mkdirs
-
- for package in sitectl sitectl-isle; do
- printf "old-%s\n" "$package" >"$BIN_DIR/$package"
- chmod 0755 "$BIN_DIR/$package"
- printf "v0.9.0\n" >"$PACKAGE_STATE_DIR/$package.version"
- sha="$(sha256sum "$BIN_DIR/$package")"
- printf "%s\n" "${sha%% *}" >"$PACKAGE_STATE_DIR/$package.sha256"
- command ln -s "$BIN_DIR/$package" "$PUBLISHED_BIN_DIR/$package"
- done
-
- install_release_package() {
- local package="$1" target="$2"
- if [[ "${FAKE_STAGE_FAIL_PACKAGE:-}" == "$package" ]]; then
- return 1
- fi
- printf "new-%s\n" "$package" >"$target/$package"
- chmod 0755 "$target/$package"
- RELEASE_PACKAGE_TAG=v1.0.0
- sha="$(sha256sum "$target/$package")"
- RELEASE_PACKAGE_SHA256="${sha%% *}"
- }
- ln() {
- if [[ "${FAKE_LINK_FAIL_PACKAGE:-}" != "" && "${!#}" == */"$FAKE_LINK_FAIL_PACKAGE" ]]; then
- return 1
- fi
- command ln "$@"
- }
-
- if FAKE_STAGE_FAIL_PACKAGE=sitectl-isle install_sitectl_packages; then
- echo "staging failure was accepted" >&2
- exit 1
- fi
- grep -Fxq old-sitectl "$BIN_DIR/sitectl"
- grep -Fxq old-sitectl-isle "$BIN_DIR/sitectl-isle"
- grep -Fxq v0.9.0 "$PACKAGE_STATE_DIR/sitectl.version"
-
- if FAKE_LINK_FAIL_PACKAGE=sitectl-isle install_sitectl_packages; then
- echo "promotion failure was accepted" >&2
- exit 1
- fi
- grep -Fxq old-sitectl "$BIN_DIR/sitectl"
- grep -Fxq old-sitectl-isle "$BIN_DIR/sitectl-isle"
- grep -Fxq v0.9.0 "$PACKAGE_STATE_DIR/sitectl.version"
- grep -Fxq v0.9.0 "$PACKAGE_STATE_DIR/sitectl-isle.version"
-
- install_sitectl_packages
- grep -Fxq new-sitectl "$BIN_DIR/sitectl"
- grep -Fxq new-sitectl-isle "$BIN_DIR/sitectl-isle"
- grep -Fxq v1.0.0 "$PACKAGE_STATE_DIR/sitectl.version"
- grep -Fxq v1.0.0 "$PACKAGE_STATE_DIR/sitectl-isle.version"
-
- # Dropping a plugin is part of the same generation transaction. A
- # later core-promotion failure restores the removed plugin exactly.
- if SITECTL_PACKAGES=sitectl \
- SITECTL_PACKAGE_VERSIONS='"'"'{"sitectl":"v1.0.0"}'"'"' \
- FAKE_LINK_FAIL_PACKAGE=sitectl \
- install_sitectl_packages; then
- echo "promotion failure after stale-package removal was accepted" >&2
- exit 1
- fi
- grep -Fxq new-sitectl-isle "$BIN_DIR/sitectl-isle"
- grep -Fxq v1.0.0 "$PACKAGE_STATE_DIR/sitectl-isle.version"
- test "$(readlink "$PUBLISHED_BIN_DIR/sitectl-isle")" = "$BIN_DIR/sitectl-isle"
-
- SITECTL_PACKAGES=sitectl \
- SITECTL_PACKAGE_VERSIONS='"'"'{"sitectl":"v1.0.0"}'"'"' \
- install_sitectl_packages
- test ! -e "$BIN_DIR/sitectl-isle"
- test ! -e "$PACKAGE_STATE_DIR/sitectl-isle.version"
- test ! -e "$PACKAGE_STATE_DIR/sitectl-isle.sha256"
- test ! -e "$PUBLISHED_BIN_DIR/sitectl-isle"
- grep -Fxq new-sitectl "$BIN_DIR/sitectl"
-
- # The managed namespace is fail-closed: an untracked command without
- # matching version/checksum state is reported, never deleted.
- printf "operator-owned\n" >"$BIN_DIR/sitectl-local"
- chmod 0755 "$BIN_DIR/sitectl-local"
- command ln -s "$BIN_DIR/sitectl-local" "$PUBLISHED_BIN_DIR/sitectl-local"
- if SITECTL_PACKAGES=sitectl \
- SITECTL_PACKAGE_VERSIONS='"'"'{"sitectl":"v1.0.0"}'"'"' \
- install_sitectl_packages; then
- echo "unproven stale package was removed" >&2
- exit 1
- fi
- grep -Fxq operator-owned "$BIN_DIR/sitectl-local"
- test "$(readlink "$PUBLISHED_BIN_DIR/sitectl-local")" = "$BIN_DIR/sitectl-local"
- ' cloud-compose-sitectl-generation "$runtime_script" "$tmp"
-
-echo "sitectl package-version contract passed"
+echo "Managed runtime uses the verified early-boot sitectl"
diff --git a/ci/source-trust-contract.sh b/ci/source-trust-contract.sh
index 5a941b7..a0f5f93 100755
--- a/ci/source-trust-contract.sh
+++ b/ci/source-trust-contract.sh
@@ -2,449 +2,63 @@
set -euo pipefail
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-export CLOUD_COMPOSE_JQ_PROGRAM_DIR="$repo_root/rootfs/etc/cloud-compose/jq"
-tmp="$(mktemp -d)"
-trap 'rm -rf "$tmp"' EXIT
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() {
echo "source trust contract: $*" >&2
exit 1
}
-assert_head() {
- local repo="$1" expected="$2"
- local actual
-
- actual="$(git -C "$repo" rev-parse --verify HEAD)"
- [[ "$actual" == "$expected" ]] || fail "expected $repo at $expected, found $actual"
-}
-
-write_project() {
- local name="$1" ref="$2" checkout="$3"
-
- jq -n \
- --arg name "$name" \
- --arg repo "$remote" \
- --arg ref "$ref" \
- --arg checkout "$checkout" \
- '{($name): {
- docker_compose_repo: $repo,
- docker_compose_branch: $ref,
- project_dir: $checkout,
- compose_project_name: $name
- }}' > "$projects_file"
-}
-
-export HOME="$tmp/home"
-mkdir -p "$HOME"
-
-remote="$tmp/remote.git"
-source_repo="$tmp/source"
-git init --bare --initial-branch=main "$remote" >/dev/null
-git init --initial-branch=main "$source_repo" >/dev/null
-git -C "$source_repo" config user.name "Cloud Compose Contract"
-git -C "$source_repo" config user.email "cloud-compose-contract@example.invalid"
-printf 'one\n' > "$source_repo/version.txt"
-git -C "$source_repo" add version.txt
-git -C "$source_repo" commit -m one >/dev/null
-commit_one="$(git -C "$source_repo" rev-parse HEAD)"
-git -C "$source_repo" tag -a -m 'release one' release-one
-printf 'two\n' > "$source_repo/version.txt"
-git -C "$source_repo" commit -am two >/dev/null
-git -C "$source_repo" remote add origin "$remote"
-git -C "$source_repo" push --set-upstream origin main --tags >/dev/null
-
-projects_file="$tmp/compose-projects.json"
-export COMPOSE_PROJECTS_FILE="$projects_file"
-export COMPOSE_APPS_ENV_DIR="$tmp/apps"
-export COMPOSE_APPS_STATE_DIR="$tmp/state"
-export CLOUD_COMPOSE_DATA_ROOT="$tmp"
-export CLOUD_COMPOSE_LIFECYCLE_PROGRAM_DIR="$repo_root/ci/fixtures"
-readonly source_trust_rollout_program="$repo_root/ci/fixtures/source-trust-rollout.sh"
-
-retry_until_success() {
- "$@"
-}
-
-# shellcheck disable=SC1091
-source "$repo_root/rootfs/home/cloud-compose/compose-apps.sh"
-export CLOUD_COMPOSE_TEST_LIFECYCLE_EXECUTOR="$repo_root/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh"
-# shellcheck disable=SC1091
-source "$repo_root/ci/fixtures/checked-lifecycle-executor.sh"
-
-pinned_checkout="$tmp/pinned"
-write_project pinned "$commit_one" "$pinned_checkout"
-clone_or_update_compose_app pinned
-assert_head "$pinned_checkout" "$commit_one"
-if git -C "$pinned_checkout" symbolic-ref -q HEAD >/dev/null; then
- fail "full commit checkout did not use detached HEAD"
-fi
-[[ ! -e "$COMPOSE_APPS_STATE_DIR/pinned.deployed-head" ]] || \
- fail "checkout was recorded as deployed before its lifecycle succeeded"
-run_compose_app_lifecycle pinned init
-[[ "$(<"$COMPOSE_APPS_STATE_DIR/pinned.deployed-head")" == "$commit_one" ]] || \
- fail "pinned deployed HEAD was not recorded"
-
-printf 'three\n' > "$source_repo/version.txt"
-git -C "$source_repo" commit -am three >/dev/null
-commit_three="$(git -C "$source_repo" rev-parse HEAD)"
-git -C "$source_repo" push origin main >/dev/null
-clone_or_update_compose_app pinned
-assert_head "$pinned_checkout" "$commit_one"
-
-branch_checkout="$tmp/branch"
-write_project branch main "$branch_checkout"
-clone_or_update_compose_app branch
-assert_head "$branch_checkout" "$commit_three"
-run_compose_app_lifecycle branch init
-printf 'four\n' > "$source_repo/version.txt"
-git -C "$source_repo" commit -am four >/dev/null
-commit_four="$(git -C "$source_repo" rev-parse HEAD)"
-git -C "$source_repo" push origin main >/dev/null
-clone_or_update_compose_app branch
-assert_head "$branch_checkout" "$commit_four"
-[[ "$(<"$COMPOSE_APPS_STATE_DIR/branch.deployed-head")" == "$commit_three" ]] || \
- fail "source update changed deployed state before lifecycle success"
-run_compose_app_lifecycle branch init
-[[ "$(<"$COMPOSE_APPS_STATE_DIR/branch.deployed-head")" == "$commit_four" ]] || \
- fail "moving branch deployed HEAD was not recorded"
-
-# A reconstructed host reuses the persistent application-data disk. Changing
-# the configured ref must reconcile that existing checkout in place instead of
-# selecting a ref-derived directory and abandoning the prior workspace.
-branch_checkout_inode="$(stat -c %i "$branch_checkout")"
-printf 'persistent host data\n' >"$branch_checkout/host-data.keep"
-jq '.branch.docker_compose_branch = "release-one"' \
- "$projects_file" >"$projects_file.tmp"
-mv "$projects_file.tmp" "$projects_file"
-clone_or_update_compose_app branch
-assert_head "$branch_checkout" "$commit_one"
-[[ "$(stat -c %i "$branch_checkout")" == "$branch_checkout_inode" ]] || \
- fail "configured-ref migration replaced the persistent checkout directory"
-[[ -f "$branch_checkout/host-data.keep" ]] || \
- fail "configured-ref migration discarded persistent checkout data"
-jq '.branch.docker_compose_branch = "main"' \
- "$projects_file" >"$projects_file.tmp"
-mv "$projects_file.tmp" "$projects_file"
-clone_or_update_compose_app branch
-assert_head "$branch_checkout" "$commit_four"
-
-# A rollout may deliberately deploy a feature or provider-specific ref that is
-# not an ancestor of the configured baseline branch. Ordinary service starts
-# must preserve that deployed revision, and a later rollout must remain able to
-# move back to the baseline instead of failing before its rollout command runs.
-git -C "$source_repo" checkout -b feature >/dev/null
-printf 'feature\n' >"$source_repo/feature.txt"
-git -C "$source_repo" add feature.txt
-git -C "$source_repo" commit -m feature >/dev/null
-feature_commit="$(git -C "$source_repo" rev-parse HEAD)"
-git -C "$source_repo" push origin feature >/dev/null
-git -C "$source_repo" checkout main >/dev/null
-jq --arg rollout_program "$source_trust_rollout_program" \
- '.branch.rollout_commands = [$rollout_program] | .branch.up_commands = ["true"]' \
- "$projects_file" >"$projects_file.tmp"
-mv "$projects_file.tmp" "$projects_file"
-export SOURCE_TRUST_ROLLOUT_REF=feature
-run_compose_app_lifecycle branch rollout
-assert_head "$branch_checkout" "$feature_commit"
-[[ "$(<"$COMPOSE_APPS_STATE_DIR/branch.deployed-head")" == "$feature_commit" ]] || \
- fail "feature rollout HEAD was not recorded"
-run_compose_app_lifecycle branch up
-assert_head "$branch_checkout" "$feature_commit"
-jq --arg rollout_program "$source_trust_rollout_program" \
- '.branch.rollout_commands = [$rollout_program]' \
- "$projects_file" >"$projects_file.tmp"
-mv "$projects_file.tmp" "$projects_file"
-export SOURCE_TRUST_ROLLOUT_REF=main
-run_compose_app_lifecycle branch rollout
-assert_head "$branch_checkout" "$commit_four"
-
-# Full bootstrap/source preparation may restore the configured baseline after
-# a recorded rollout. It must not grant the same reset authority to an
-# unrecorded local-ahead commit.
-jq --arg rollout_program "$source_trust_rollout_program" \
- '.branch.rollout_commands = [$rollout_program]' \
- "$projects_file" >"$projects_file.tmp"
-mv "$projects_file.tmp" "$projects_file"
-export SOURCE_TRUST_ROLLOUT_REF=feature
-run_compose_app_lifecycle branch rollout
-assert_head "$branch_checkout" "$feature_commit"
-clone_or_update_compose_app branch
-assert_head "$branch_checkout" "$commit_four"
-
-# A local commit must never be treated as an already-up-to-date moving branch.
-# Deployment state must converge exactly to the fetched remote object.
-git -C "$branch_checkout" config user.name "Cloud Compose Contract"
-git -C "$branch_checkout" config user.email "cloud-compose-contract@example.invalid"
-printf 'local-ahead\n' >"$branch_checkout/local-only.txt"
-git -C "$branch_checkout" add local-only.txt
-git -C "$branch_checkout" commit -m local-ahead >/dev/null
-local_ahead_commit="$(git -C "$branch_checkout" rev-parse HEAD)"
-if clone_or_update_compose_app branch >/dev/null 2>&1; then
- fail "moving branch accepted a local-ahead checkout"
-fi
-assert_head "$branch_checkout" "$local_ahead_commit"
-
-tag_checkout="$tmp/tag"
-write_project tag release-one "$tag_checkout"
-clone_or_update_compose_app tag
-assert_head "$tag_checkout" "$commit_one"
-
-printf 'dirty-tracked\n' >"$tag_checkout/version.txt"
-if clone_or_update_compose_app tag >/dev/null 2>&1; then
- fail "pinned checkout accepted a tracked worktree modification"
-fi
-git -C "$tag_checkout" restore -- version.txt
-printf 'staged\n' >"$tag_checkout/staged.txt"
-git -C "$tag_checkout" add staged.txt
-if clone_or_update_compose_app tag >/dev/null 2>&1; then
- fail "pinned checkout accepted a staged worktree modification"
-fi
-git -C "$tag_checkout" restore --staged -- staged.txt
-rm -f -- "$tag_checkout/staged.txt"
-printf 'services: {}\n' >"$tag_checkout/compose.override.yaml"
-if clone_or_update_compose_app tag >/dev/null 2>&1; then
- fail "pinned checkout accepted an untracked Compose override"
-fi
-rm -f -- "$tag_checkout/compose.override.yaml"
-clone_or_update_compose_app tag
-assert_head "$tag_checkout" "$commit_one"
-
-# A successful sitectl reconciliation may intentionally derive tracked runtime
-# configuration from the untracked desired-state document. Accept only the
-# exact recorded diff, reject any later mutation, and restore the committed
-# source before a source move.
-printf 'managed\n' >"$tag_checkout/version.txt"
-pushd "$tag_checkout" >/dev/null
-record_compose_managed_diff tag
-verify_clean_compose_checkout tag
-printf 'unexpected\n' >>version.txt
-if verify_clean_compose_checkout tag >/dev/null 2>&1; then
- fail "recorded managed Compose state accepted a different tracked change"
-fi
-printf 'managed\n' >version.txt
-restore_recorded_compose_managed_diff tag
-popd >/dev/null
-[[ "$(<"$tag_checkout/version.txt")" == "one" ]] || \
- fail "recorded managed Compose state was not restored to committed source"
-if ! git -C "$tag_checkout" diff --quiet --ignore-submodules -- ||
- ! git -C "$tag_checkout" diff --cached --quiet --ignore-submodules --; then
- fail "recorded managed Compose restore left tracked changes"
-fi
-
-run_compose_app_lifecycle tag init
-[[ "$(<"$COMPOSE_APPS_STATE_DIR/tag.deployed-head")" == "$commit_one" ]] || \
- fail "tag deployed HEAD was not recorded"
-clone_or_update_compose_app tag
-assert_head "$tag_checkout" "$commit_one"
-
-origin_checkout="$tmp/origin-mismatch"
-write_project origin main "$origin_checkout"
-clone_or_update_compose_app origin
-git -C "$origin_checkout" remote set-url origin "$tmp/not-the-configured-origin.git"
-if clone_or_update_compose_app origin >/dev/null 2>&1; then
- fail "existing checkout accepted an origin that did not match the configured repository"
-fi
-
-failure_checkout="$tmp/lifecycle-failure"
-write_project failure "$commit_one" "$failure_checkout"
-jq '.failure.init_commands = ["false"]' "$projects_file" >"$projects_file.tmp"
-mv "$projects_file.tmp" "$projects_file"
-if run_compose_app_lifecycle failure init >/dev/null 2>&1; then
- fail "failing lifecycle command unexpectedly succeeded"
-fi
-[[ ! -e "$COMPOSE_APPS_STATE_DIR/failure.deployed-head" ]] || \
- fail "failing lifecycle was recorded as deployed"
-
-unsafe_ref_checkout="$tmp/unsafe-ref"
-write_project unsafe-ref '--upload-pack=/tmp/not-a-command' "$unsafe_ref_checkout"
-if clone_or_update_compose_app unsafe-ref >/dev/null 2>&1; then
- fail "unsafe Git ref was accepted as a fetch option"
-fi
-[[ ! -e "$unsafe_ref_checkout" ]] || fail "unsafe Git ref mutated the checkout path"
-
-unsafe_repo_checkout="$tmp/unsafe-repo"
-write_project unsafe-repo main "$unsafe_repo_checkout"
-jq '."unsafe-repo".docker_compose_repo = "--upload-pack=/tmp/not-a-command"' \
- "$projects_file" >"$projects_file.tmp"
-mv "$projects_file.tmp" "$projects_file"
-if clone_or_update_compose_app unsafe-repo >/dev/null 2>&1; then
- fail "unsafe repository location was accepted as a Git option"
-fi
-[[ ! -e "$unsafe_repo_checkout" ]] || fail "unsafe repository location mutated the checkout path"
-
-coreos_installer="$repo_root/rootfs/home/cloud-compose/install-dependencies-coreos.sh"
-grep -Fq 'for package in git jq make openssl; do' "$coreos_installer" || \
- fail "CoreOS base dependency set changed unexpectedly"
-if grep -Eq 'packages\.libops\.io|sitectl\.repo|SITECTL_PACKAGES' "$coreos_installer"; then
- fail "CoreOS bootstrap still has a redundant sitectl RPM ownership path"
-fi
+debian_installer="$repo_root/rootfs/home/cloud-compose/install-dependencies-debian.sh"
+cos_installer="$repo_root/rootfs/home/cloud-compose/install-dependencies-cos.sh"
+host_conf="$repo_root/rootfs/home/cloud-compose/host-conf.sh"
+installer="$repo_root/rootfs/etc/cloud-compose/libexec/install-rootfs.py"
+linux_runtime="$repo_root/modules/linux-vm-runtime/main.tf"
+linux_cloud_init="$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml"
+gcp_module="$repo_root/modules/gcp/main.tf"
+gcp_cloud_init="$repo_root/templates/cloud-init.yml"
-grep -Eq '^[[:space:]]+openssl([[:space:]\\]|$)' \
- "$repo_root/rootfs/home/cloud-compose/install-dependencies-debian.sh" || \
- fail "Debian bootstrap does not install the explicit openssl runtime dependency"
-grep -Fq 'command -v openssl' "$repo_root/rootfs/home/cloud-compose/install-dependencies-cos.sh" || \
- fail "COS bootstrap does not fail closed when openssl is unavailable"
-grep -Fq -- '- openssl' "$repo_root/ansible/roles/cloud_compose/tasks/main.yml" || \
- fail "Ansible bootstrap does not install the explicit openssl runtime dependency"
-grep -Fq -- '- openssl' "$repo_root/salt/cloud-compose/init.sls" || \
- fail "Salt bootstrap does not install the explicit openssl runtime dependency"
+grep -Eq '^[[:space:]]+openssl([[:space:]\\]|$)' "$debian_installer" ||
+ fail "Debian bootstrap does not install openssl"
+grep -Fq 'command -v openssl' "$cos_installer" ||
+ fail "COS bootstrap does not require openssl"
-host_conf="$repo_root/rootfs/home/cloud-compose/host-conf.sh"
dependencies_line="$(grep -nF 'bash /home/cloud-compose/install-dependencies.sh' "$host_conf" | cut -d: -f1)"
-managed_runtime_line="$(grep -nF 'bash /home/cloud-compose/libops-managed-runtime.sh install-tools' "$host_conf" | cut -d: -f1)"
-[[ -n "$dependencies_line" && -n "$managed_runtime_line" && "$dependencies_line" -lt "$managed_runtime_line" ]] || \
- fail "verified managed-runtime package installation does not follow OS dependency installation"
+managed_runtime_line="$(grep -nF '"$sitectl" host runtime install' "$host_conf" | cut -d: -f1)"
+[[ -n "$dependencies_line" && -n "$managed_runtime_line" && "$dependencies_line" -lt "$managed_runtime_line" ]] ||
+ fail "verified sitectl installation must follow OS dependency installation"
-release_workflow="$repo_root/.github/workflows/github-release.yaml"
-grep -Fq 'libops/.github/.github/workflows/bump-release.yaml@8dfaf9c854df71d9bbffde48c5676ff07c543c51' "$release_workflow" || \
- fail "write-capable release workflow is not pinned to the approved commit"
-
-gcp_module="$repo_root/modules/gcp/main.tf"
+grep -Fq 'libops/.github/.github/workflows/bump-release.yaml@8dfaf9c854df71d9bbffde48c5676ff07c543c51' \
+ "$repo_root/.github/workflows/github-release.yaml" ||
+ fail "write-capable release workflow is not pinned"
grep -Eq 'source = "https://github\.com/libops/terraform-cloudrun-v2/archive/[0-9a-f]{40}\.zip//terraform-cloudrun-v2-[0-9a-f]{40}"' \
- "$gcp_module" || fail "GCP power-button Terraform dependency is not pinned to a full commit"
-if grep -Eq 'source = "https://github\.com/.*/archive/(refs/)?(heads|tags)/' "$gcp_module"; then
- fail "GCP module still consumes a mutable or unverified GitHub archive source"
-fi
-
-linux_runtime="$repo_root/modules/linux-vm-runtime/main.tf"
-linux_runtime_outputs="$repo_root/modules/linux-vm-runtime/outputs.tf"
-linux_runtime_variables="$repo_root/modules/linux-vm-runtime/variables.tf"
-linux_runtime_tests="$repo_root/modules/linux-vm-runtime/runtime_inputs.tftest.hcl"
-gcp_cloud_init="$repo_root/templates/cloud-init.yml"
-linux_cloud_init="$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml"
-archive_program="$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh"
+ "$gcp_module" || fail "GCP Terraform dependency is not pinned to a commit"
-grep -Eq 'ROOTFS_ARCHIVE_URL_B64[[:space:]]*=[[:space:]]*base64encode\(local\.rootfs_archive_url\)' "$linux_runtime" || \
- fail "Linux rootfs archive URL is not transported as base64 data"
-grep -Fq 'count = local.rootfs_archive_url != "" && local.rootfs_test_source_archive_prefix == "" ? 1 : 0' "$linux_runtime" || \
- fail "Linux production archive mode does not keep the release sidecar mandatory"
-grep -Fq 'variable "rootfs_test_source_archive_prefix"' "$linux_runtime_variables" || \
- fail "Linux hosted smoke source mode is not unmistakably test-only"
-grep -Fq 'regex("^cloud-compose-[0-9a-f]{40}$"' "$linux_runtime_variables" || \
- fail "Linux hosted smoke source mode does not require one exact lowercase commit SHA"
-grep -Fq 'https://github.com/libops/cloud-compose/archive/${trimprefix(local.rootfs_test_source_archive_prefix, "cloud-compose-")}.tar.gz' "$linux_runtime_outputs" || \
- fail "Linux hosted smoke source mode does not bind its URL to the exact prefix commit"
-for negative_contract in \
- rejects_source_archive_from_another_commit \
- rejects_tag_named_source_archive_prefix \
- rejects_arbitrary_test_source_archive_url; do
- grep -Fq "run \"${negative_contract}\"" "$linux_runtime_tests" || \
- fail "Linux hosted smoke source mode lacks ${negative_contract} coverage"
-done
-for production_surface in \
- "$repo_root/variables.tf" \
- "$repo_root/modules/gcp/variables.tf" \
- "$repo_root/providers/gcp/variables.tf" \
- "$repo_root/providers/do/variables.tf" \
- "$repo_root/providers/linode/variables.tf"; do
- if grep -Fq 'rootfs_test_source_archive_prefix' "$production_surface"; then
- fail "$production_surface exposes the test-only hosted source mode"
- fi
-done
-grep -Fq 'ROOTFS_ARCHIVE_URL_B64' "$linux_cloud_init" || \
- fail "Linux cloud-init does not pass rootfs archive URL data to its checked-in entrypoint"
-grep -Fq 'prepare-linux-test-source' "$linux_cloud_init" || \
- fail "Linux hosted smoke cannot invoke the checked source-archive fixture path"
-grep -Fq 'rootfs_overlay_staging_path' "$linux_runtime" || \
- fail "Additional rootfs content is not staged for reapplication after archive extraction"
-for runtime_module in "$linux_runtime" "$gcp_module"; do
- grep -Fq 'ROOTFS_ARCHIVE_SCRIPT_B64' "$runtime_module" || \
- fail "$runtime_module does not transfer the checked-in rootfs archive program"
- grep -Fq 'rootfs_contract_sha256 = sha256(join("", [' "$runtime_module" || \
- fail "$runtime_module does not bind archive contents to its exact bundled rootfs"
- grep -Fq 'cloud-compose-rootfs.contract.sha256' "$runtime_module" || \
- fail "$runtime_module does not derive the immutable rootfs contract sidecar"
- grep -Fq 'data "http" "rootfs_contract"' "$runtime_module" || \
- fail "$runtime_module does not verify the release contract during planning"
- if grep -Fq -- "curl -fsSL --proto '=https'" "$runtime_module"; then
- fail "$runtime_module still embeds the rootfs archive shell implementation"
- fi
-done
-for cloud_init_template in "$linux_cloud_init" "$gcp_cloud_init"; do
- grep -Fq '/var/lib/cloud-compose/bootstrap/rootfs-archive.sh' "$cloud_init_template" || \
- fail "$cloud_init_template does not install or invoke the checked-in rootfs archive program"
+for module in "$linux_runtime" "$gcp_module"; do
+ grep -Fq 'rootfs_bundle_content = jsonencode({' "$module" ||
+ fail "$module does not build the checked rootfs bundle"
+ grep -Fq 'base64gzip(local.rootfs_bundle_content)' "$module" ||
+ fail "$module does not compress the checked rootfs bundle"
done
-grep -Fq -- "curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2" "$archive_program" || \
- fail "rootfs archive download is not restricted to HTTPS with TLS 1.2 or newer"
-grep -Fq -- '--connect-timeout 10 --max-time 300' "$archive_program" || \
- fail "rootfs archive download is not bounded"
-grep -Fq -- '-o "$stage_root/rootfs.tar.gz" -- "$archive_url"' "$archive_program" || \
- fail "rootfs archive URL is not separated from curl options"
-grep -Fq 'validate_rootfs_archive "$stage_root/rootfs.tar.gz"' "$archive_program" || \
- fail "rootfs archive members are not validated before extraction"
-grep -Fq 'validate_rootfs_test_source_archive "$stage_root/rootfs.tar.gz" "$test_source_prefix"' "$archive_program" || \
- fail "hosted smoke source-archive members are not validated before extraction"
-grep -Fq 'https://github.com/libops/cloud-compose/archive/${source_commit}.tar.gz' "$archive_program" || \
- fail "hosted smoke source archives are not tied to one exact libops/cloud-compose commit"
-grep -Fq '[[ "$member_type" == "-" || "$member_type" == "d" ]]' "$archive_program" || \
- fail "rootfs archive validation does not reject links before extraction"
-grep -Fq 'rootfs archive paths, bytes, or canonical metadata do not match this cloud-compose module source' "$archive_program" || \
- fail "rootfs archive extraction does not reject module/archive content mismatches"
-grep -Fq "stat -c '%a:%h:%F'" "$archive_program" || \
- fail "rootfs archive contract does not reject noncanonical modes or hard links"
-for embedded_source in "$linux_runtime" "$gcp_module" "$linux_cloud_init" "$gcp_cloud_init"; do
- if grep -Eq 'sha256sum -c -|tar --no-same-owner|rootfs_dir=\"\$\(find' "$embedded_source"; then
- fail "$embedded_source still embeds the substantive rootfs archive program"
- fi
+for template in "$linux_cloud_init" "$gcp_cloud_init"; do
+ grep -Fq '/var/lib/cloud-compose/bootstrap/install-rootfs.py' "$template" ||
+ fail "$template does not install the checked rootfs installer"
+ grep -Fq '/var/lib/cloud-compose/bootstrap/rootfs.json' "$template" ||
+ fail "$template does not install the checked rootfs bundle"
done
-cloud_smoke="$repo_root/ci/cloud-smoke.sh"
-cloud_smoke_workflow="$repo_root/.github/workflows/cloud-smoke.yml"
-for fixture in "$repo_root/tests/smoke/do/main.tf" "$repo_root/tests/smoke/linode/main.tf"; do
- grep -Fq 'rootfs_test_source_archive_prefix = "cloud-compose-${var.cloud_compose_source_ref}"' "$fixture" || \
- fail "$fixture does not select the explicit exact-commit source-archive fixture mode"
-done
-grep -Fq 'source = "../../../modules/digitalocean"' "$repo_root/tests/smoke/do/main.tf" || \
- fail "DigitalOcean hosted smoke does not keep source-archive mode below the public provider entrypoint"
-grep -Fq 'source = "../../../modules/linode"' "$repo_root/tests/smoke/linode/main.tf" || \
- fail "Linode hosted smoke does not keep source-archive mode below the public provider entrypoint"
-for example_name in digitalocean linode; do
- example_main="$repo_root/examples/$example_name/main.tf"
- example_variables="$repo_root/examples/$example_name/variables.tf"
- source_ref_block="$(sed -n '/^variable "cloud_compose_source_ref" {/,/^}/p' "$example_variables")"
- source_sha_block="$(sed -n '/^variable "cloud_compose_source_sha256" {/,/^}/p' "$example_variables")"
- if grep -Fq 'default' <<<"$source_ref_block" || grep -Fq 'default' <<<"$source_sha_block"; then
- fail "$example_name runnable example still defaults to an obsolete cloud-compose release"
- fi
- grep -Fq 'releases/download/${var.cloud_compose_source_ref}/cloud-compose-rootfs.tar.gz' "$example_main" || \
- fail "$example_name runnable example does not derive its canonical archive from the required exact release"
- grep -Fq 'rootfs_archive_sha256 = var.cloud_compose_source_sha256' "$example_main" || \
- fail "$example_name runnable example does not require the matching archive checksum"
-done
-grep -Fq 'run "rejects_rootfs_release_from_another_module_version"' "$linux_runtime_tests" || \
- fail "runnable provider examples lack plan-time module/archive mismatch coverage"
-grep -Fq '"$source_ref" != "$checkout_sha"' "$cloud_smoke" || \
- fail "hosted smoke does not bind its downloadable source archive to the checked-out commit"
-grep -Fq 'CLOUD_COMPOSE_SOURCE_REF: ${{ github.sha }}' "$cloud_smoke_workflow" || \
- fail "hosted smoke does not select the exact tested merge commit"
-grep -Fq 'contents: read' "$cloud_smoke_workflow" || \
- fail "hosted smoke lacks read-only repository permission"
-if grep -Fq 'contents: write' "$cloud_smoke_workflow"; then
- fail "untrusted pull-request smoke code has repository write permission"
+grep -Fq 'SAFE_PATH = re.compile' "$installer" ||
+ fail "rootfs installer does not constrain paths"
+grep -Fq 'if any(part in ("", ".", "..")' "$installer" ||
+ fail "rootfs installer does not reject path traversal"
+grep -Fq 'stat.S_ISLNK' "$installer" ||
+ fail "rootfs installer does not reject symlinks"
+grep -Fq 'os.replace(temporary, path)' "$installer" ||
+ fail "rootfs installer does not publish files atomically"
+
+archive_variable='rootfs''_archive'
+archive_program='rootfs''-archive'
+if rg -n -i "${archive_variable}|${archive_program}" "$repo_root" \
+ --hidden --glob '!**/.terraform/**' --glob '!**/.git/**' >/dev/null; then
+ fail "retired rootfs transport machinery remains"
fi
-for cloud_init_template in "$linux_cloud_init" "$gcp_cloud_init"; do
- if grep -Eq '^[[:space:]]*-[[:space:]]*[|>][+-]?[[:space:]]*$' "$cloud_init_template"; then
- fail "$cloud_init_template still embeds a shell program instead of invoking a checked-in file"
- fi
-done
-
-rollout_installer="$repo_root/rootfs/home/cloud-compose/deploy-rollout.sh"
-grep -Fq 'ROLLOUT_DOWNLOAD_URL must be an HTTPS URL without whitespace' "$rollout_installer" || \
- fail "rollout installer does not require HTTPS"
-grep -Fq -- "curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2" "$rollout_installer" || \
- fail "rollout download is not restricted to HTTPS with TLS 1.2 or newer"
-grep -Fq -- '--connect-timeout 10 --max-time 300 -o "$tmp" -- "$ROLLOUT_DOWNLOAD_URL"' "$rollout_installer" || \
- fail "rollout download is not bounded or separated from curl options"
-grep -Fq 'mv -f -- "$install_tmp" /usr/local/bin/cloud-compose-rollout' "$rollout_installer" || \
- fail "rollout binary is not promoted atomically"
-
-for smoke_script in \
- "$repo_root/ci/config-management-smoke.sh" \
- "$repo_root/ci/config-management-cloud-smoke.sh"; do
- grep -Eq 'CONFIG_MANAGEMENT_IMAGE_DEFAULT="python:[^@"]+@sha256:[0-9a-f]{64}"' "$smoke_script" || \
- fail "configuration-management test image is not digest pinned in $smoke_script"
-done
-
-echo "Source trust contracts passed"
diff --git a/ci/systemd-contract.sh b/ci/systemd-contract.sh
index 0236e66..3789fe8 100755
--- a/ci/systemd-contract.sh
+++ b/ci/systemd-contract.sh
@@ -6,8 +6,7 @@ repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
unit_dir="$repo_root/rootfs/etc/systemd/system"
diagnostics_program="$repo_root/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"
smoke_healthcheck_program="$repo_root/rootfs/home/cloud-compose/smoke-healthcheck.sh"
-bootstrap_security="$repo_root/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh"
-root_program_runner="$repo_root/rootfs/etc/cloud-compose/libexec/run-root-program.sh"
+host_launcher="$repo_root/rootfs/etc/cloud-compose/libexec/sitectl-host.sh"
fail() {
echo "systemd contract: $*" >&2
@@ -30,8 +29,7 @@ assert_contains() {
[[ -f "$unit_dir/cloud-compose-overlay.service" ]] || fail "Docker overlay mount unit is missing"
[[ -f "$unit_dir/cloud-compose-bootstrap.service" ]] || fail "retryable bootstrap unit is missing"
[[ -x "$diagnostics_program" ]] || fail "checked-in Cloud Compose diagnostics program is missing or not executable"
-[[ -f "$bootstrap_security" ]] || fail "root-owned bootstrap security helper is missing"
-[[ -f "$root_program_runner" ]] || fail "root-owned service launcher is missing"
+[[ -x "$host_launcher" ]] || fail "root-owned sitectl launcher is missing"
[[ -x "$smoke_healthcheck_program" ]] || fail "checked-in smoke healthcheck wrapper is missing or not executable"
[[ -f "$unit_dir/cloud-compose-internal-services.service" && -f "$unit_dir/cloud-compose-internal-services.timer" ]] || \
fail "namespaced internal-services units are missing"
@@ -42,7 +40,7 @@ assert_contains() {
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'
assert_contains "$unit_dir/cloud-compose.service" 'After=network-online.target docker.service cloud-compose-metadata-firewall.service'
-assert_contains "$unit_dir/cloud-compose.service" 'ExecStartPre=/bin/bash /home/cloud-compose/assert-app-initialized.sh'
+assert_contains "$unit_dir/cloud-compose.service" 'ExecStartPre=/etc/cloud-compose/libexec/sitectl-host.sh marker require-initialized'
assert_contains "$unit_dir/cloud-compose.service" 'Restart=on-failure'
assert_contains "$unit_dir/cloud-compose.service" 'RestartSec=30s'
assert_contains "$unit_dir/cloud-compose.service" 'StartLimitIntervalSec=6h'
@@ -51,7 +49,7 @@ if grep -Fq 'ConditionPathExists=' "$unit_dir/cloud-compose-bootstrap.service";
fail "bootstrap still trusts an unvalidated marker path condition"
fi
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'ExecCondition=/bin/bash /etc/cloud-compose/libexec/bootstrap-required.sh'
-assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'ExecStart=/bin/bash /etc/cloud-compose/libexec/run-bootstrap.sh'
+assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'ExecStart=/bin/bash /home/cloud-compose/run.sh'
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'Restart=on-failure'
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'RestartSec=30s'
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'StartLimitIntervalSec=8h'
@@ -67,21 +65,8 @@ assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'LogRateLimitInterva
assert_contains "$unit_dir/cloud-compose-bootstrap.service" 'LogRateLimitBurst=1000'
assert_contains "$unit_dir/cloud-compose-internal-services.service" 'Requires=cloud-compose.service cloud-compose-metadata-firewall.service'
assert_contains "$unit_dir/cloud-compose.service" 'TimeoutStartSec=90min'
-assert_contains "$diagnostics_program" 'usage: ${diagnostics_program} state|status|dump'
-assert_contains "$diagnostics_program" 'readonly PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"'
-assert_contains "$diagnostics_program" 'readonly bootstrap_marker="/var/lib/cloud-compose/bootstrap-complete"'
-assert_contains "$bootstrap_security" 'cloud_compose_bootstrap_marker_ready()'
-assert_contains "$bootstrap_security" '0:0:644:1:regular file'
-assert_contains "$bootstrap_security" '"$marker_size" == "6"'
-assert_contains "$bootstrap_security" '"$payload" == "ready"'
-assert_contains "$bootstrap_security" 'cloud_compose_secure_runtime_home()'
-assert_contains "$root_program_runner" 'cloud_compose_secure_runtime_home'
-assert_contains "$root_program_runner" 'Unsupported Cloud Compose root program:'
-[[ -x "$repo_root/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh" ]] || \
- fail "validated bootstrap readiness gate is missing or not executable"
-assert_contains "$diagnostics_program" '--- Cloud Compose provisioning heartbeat ---'
-assert_contains "$diagnostics_program" 'ps -p "$main_pid" -o pid=,ppid=,stat=,etime=,comm='
-assert_contains "$diagnostics_program" 'DOCKER_CONFIG=/mnt/disks/data/docker-config'
+assert_contains "$diagnostics_program" 'exec /etc/cloud-compose/libexec/sitectl-host.sh diagnostics "$@"'
+assert_contains "$host_launcher" 'exec "$sitectl" host "$@"'
assert_contains "$smoke_healthcheck_program" 'source /home/cloud-compose/profile.sh'
assert_contains "$smoke_healthcheck_program" 'exec sitectl healthcheck --context "$context" --persist --format table'
assert_contains "$unit_dir/cloud-compose-mariadb-backup.service" 'TimeoutStartSec=12h'
@@ -100,14 +85,12 @@ 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
-assert_contains "$unit_dir/libops-managed-runtime.service" 'TimeoutStartSec=1h'
assert_contains "$unit_dir/cloud-compose-key-rotation.service" 'TimeoutStartSec=1h'
assert_contains "$unit_dir/cloud-compose-key-rotation.service" 'RequiresMountsFor=/mnt/disks/data'
assert_contains "$unit_dir/cloud-compose-vault-agent.service" 'RequiresMountsFor=/mnt/disks/data'
assert_contains "$unit_dir/cloud-compose-rollout.service" 'RequiresMountsFor=/mnt/disks/data'
assert_contains "$unit_dir/cloud-compose-rollout.service" 'User=cloud-compose'
assert_contains "$unit_dir/cloud-compose-rollout.service" 'Group=cloud-compose'
-assert_contains "$unit_dir/libops-managed-runtime.service" 'RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes'
docker_mount_dropin="$unit_dir/docker.service.d/cloud-compose-mounts.conf"
[[ -f "$docker_mount_dropin" ]] || fail "Docker persistent-mount drop-in is missing"
@@ -121,9 +104,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 /etc/cloud-compose/libexec/run-root-program.sh configure-metadata-firewall.sh pre-docker'
+assert_contains "$metadata_pre_unit" 'ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh metadata-firewall 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 /etc/cloud-compose/libexec/run-root-program.sh mount-overlays.sh'
+assert_contains "$unit_dir/cloud-compose-overlay.service" 'ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh overlays'
for root_home_unit in \
cloud-compose-docker-prune.service \
cloud-compose-key-rotation.service \
@@ -132,12 +115,8 @@ for root_home_unit in \
cloud-compose-offhost-backup.service \
cloud-compose-overlay.service \
cloud-compose-restore-test.service \
- cloud-compose-vault-agent.service \
- libops-managed-runtime.service; do
- if grep -Eq '^Exec(Start|StartPre|StartPost|Stop|StopPost)=/bin/bash /home/cloud-compose/' "$unit_dir/$root_home_unit"; then
- fail "$root_home_unit executes historically writable home code without the root-owned launcher"
- fi
- assert_contains "$unit_dir/$root_home_unit" '/etc/cloud-compose/libexec/run-root-program.sh'
+ cloud-compose-vault-agent.service; do
+ assert_contains "$unit_dir/$root_home_unit" '/etc/cloud-compose/libexec/sitectl-host.sh'
done
if grep -Eq '^(After|Before|BindsTo|PartOf|Requires|Requisite|Wants)=.*cloud-compose-bootstrap\\.service' \
"$unit_dir/cloud-compose.service"; then
@@ -149,14 +128,12 @@ if grep -Eq '^(After|Before|BindsTo|PartOf|Requires|Requisite|Wants)=.*cloud-com
fi
run_script="$repo_root/rootfs/home/cloud-compose/run.sh"
-assert_contains "$run_script" 'cloud_compose_start_and_wait_for_oneshot cloud-compose.service "$app_wait_seconds"'
+assert_contains "$run_script" 'host systemd start-wait cloud-compose.service --timeout "${app_wait_seconds}s"'
assert_contains "$run_script" 'systemd-tmpfiles is required to prepare the cloud-compose lifecycle lock'
assert_contains "$run_script" 'systemd-tmpfiles --create /etc/tmpfiles.d/cloud-compose.conf'
assert_contains "$run_script" 'systemctl restart cloud-compose-overlay.service'
assert_contains "$run_script" 'systemctl enable --now cloud-compose-internal-services.timer'
assert_contains "$run_script" 'systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service'
-assert_contains "$run_script" 'systemctl enable --now libops-managed-runtime.timer'
-assert_contains "$run_script" 'systemctl disable --now libops-managed-runtime.timer libops-managed-runtime.service'
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'
@@ -164,53 +141,16 @@ assert_contains "$run_script" 'runtime_enabled "${CLOUD_COMPOSE_OFFHOST_BACKUP_R
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)"
-trap 'rm -rf "$migration_tmp"' EXIT
-mkdir -p "$migration_tmp/units" "$migration_tmp/bin"
-cat >"$migration_tmp/units/internal-services.service" <<'EOF'
-[Unit]
-Description=Internal Services (Ping, Metrics, Power Management)
-[Service]
-WorkingDirectory=/mnt/disks/data/libops-internal
-EOF
-cat >"$migration_tmp/units/internal-services.timer" <<'EOF'
-[Unit]
-Description=Delay Internal Services until 20m after initial boot
-[Timer]
-OnBootSec=20min
-Unit=internal-services.service
-EOF
-cat >"$migration_tmp/units/vault-agent.service" <<'EOF'
-[Unit]
-Description=Host-owned Vault Agent
-[Service]
-ExecStart=/usr/bin/vault agent -config=/etc/vault-agent.hcl
-EOF
-cat >"$migration_tmp/bin/systemctl" <<'EOF'
-#!/usr/bin/env bash
-printf '%s\n' "$*" >>"${MIGRATION_SYSTEMCTL_LOG:?}"
-EOF
-chmod +x "$migration_tmp/bin/systemctl"
-: >"$migration_tmp/systemctl.log"
-PATH="$migration_tmp/bin:$PATH" \
- CLOUD_COMPOSE_SYSTEMD_UNIT_DIR="$migration_tmp/units" \
- MIGRATION_SYSTEMCTL_LOG="$migration_tmp/systemctl.log" \
- bash "$migration_script"
-[[ ! -e "$migration_tmp/units/internal-services.service" && ! -e "$migration_tmp/units/internal-services.timer" ]] || \
- fail "legacy Cloud Compose internal-services units were not migrated"
-[[ -e "$migration_tmp/units/vault-agent.service" ]] || fail "migration removed an unrelated host Vault Agent"
-grep -Fq 'disable --now internal-services.service' "$migration_tmp/systemctl.log" || \
- fail "legacy internal-services service was not stopped and disabled"
+assert_contains "$repo_root/rootfs/home/cloud-compose/host-conf.sh" 'host systemd migrate-legacy'
if rg -n '(^|[^-])vault-agent\.service|\bcron\.(service|timer)|/cron\.sh' \
"$repo_root/rootfs" \
- --glob '!**/migrate-legacy-systemd-units.sh' >/dev/null; then
+ >/dev/null; then
fail "rootfs still references a generic cron or Vault Agent unit"
fi
if rg -n '(^|[^-])internal-services\.(service|timer)' \
"$repo_root/rootfs" \
- --glob '!**/migrate-legacy-systemd-units.sh' >/dev/null; then
+ >/dev/null; then
fail "rootfs still references generic internal-services units"
fi
diff --git a/ci/vault-runtime-contract.sh b/ci/vault-runtime-contract.sh
index 901d776..462d9e9 100755
--- a/ci/vault-runtime-contract.sh
+++ b/ci/vault-runtime-contract.sh
@@ -2,190 +2,32 @@
set -euo pipefail
-repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-readiness_script="$repo_root/rootfs/home/cloud-compose/vault-agent-readiness.sh"
-assert_script="$repo_root/rootfs/home/cloud-compose/assert-vault-ready.sh"
-tmp="$(mktemp -d)"
-trap 'rm -rf -- "$tmp"' EXIT
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+run_script="$repo_root/rootfs/home/cloud-compose/run.sh"
+vault_unit="$repo_root/rootfs/etc/systemd/system/cloud-compose-vault-agent.service"
fail() {
- echo "Vault runtime contract: $*" >&2
- exit 1
-}
-
-mkdir -p "$tmp/data" "$tmp/run" "$tmp/bin"
-chmod 0775 "$tmp/data"
-: >"$tmp/profile.sh"
-
-run_readiness() {
- local action="$1" token_path="$2"
-
- CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- TEST_SAFE_DIR="$tmp/data/vault" \
- TEST_READY_MARKER="$tmp/run/vault.ready" \
- VAULT_AGENT_TOKEN_PATH="$token_path" \
- READINESS_SCRIPT="$readiness_script" \
- bash --noprofile --norc -c '
- set -euo pipefail
- source "$READINESS_SCRIPT"
- vault_safe_dir() { printf "%s\n" "$TEST_SAFE_DIR"; }
- READY_MARKER="$TEST_READY_MARKER"
- install() {
- local args=()
- while (($# > 0)); do
- case "$1" in
- -o | -g) shift 2 ;;
- *) args+=("$1"); shift ;;
- esac
- done
- command install "${args[@]}"
- }
- main "$1"
- ' vault-readiness "$action"
+ echo "vault runtime contract: $*" >&2
+ exit 1
}
-safe_token="$tmp/data/vault/token"
-run_readiness prepare "$safe_token"
-[[ -d "$tmp/data/vault" ]] || fail "dedicated Vault directory was not created"
-[[ "$(stat -c %a "$tmp/data")" == "775" ]] || fail "Vault preparation chmodded a broad parent"
-[[ "$(stat -c %a "$tmp/data/vault")" == "700" ]] || fail "dedicated Vault directory is not private"
-
-printf 'vault-token\n' >"$safe_token"
-VAULT_AGENT_START_TIMEOUT_SECONDS=1 run_readiness wait "$safe_token"
-[[ -f "$tmp/run/vault.ready" ]] || fail "Vault readiness marker was not published"
-run_readiness prepare "$safe_token"
-[[ ! -e "$safe_token" ]] || fail "Vault restart preparation retained a stale token"
-[[ ! -e "$tmp/run/vault.ready" ]] || fail "Vault restart preparation retained stale readiness"
-if VAULT_AGENT_START_TIMEOUT_SECONDS=1 run_readiness wait "$safe_token" >/dev/null 2>&1; then
- fail "Vault restart published readiness without a fresh token"
-fi
-printf 'fresh-vault-token\n' >"$safe_token"
-VAULT_AGENT_START_TIMEOUT_SECONDS=1 run_readiness wait "$safe_token"
-run_readiness clear "$safe_token"
-[[ ! -e "$tmp/run/vault.ready" ]] || fail "Vault readiness marker was not cleared"
-
-for unsafe_token in \
- "$tmp/data/token" \
- "$tmp/data/vault/nested/token" \
- "$tmp/data/vault/../token" \
- "$tmp/data/vault/token/name"; do
- if run_readiness prepare "$unsafe_token" >/dev/null 2>&1; then
- fail "unsafe Vault token path was accepted: $unsafe_token"
- fi
-done
-
-rm -rf "$tmp/data/vault"
-ln -s "$tmp/run" "$tmp/data/vault"
-if run_readiness prepare "$safe_token" >/dev/null 2>&1; then
- fail "symbolic-link Vault state directory was accepted"
-fi
-rm -f "$tmp/data/vault"
-mkdir -m 0700 "$tmp/data/vault"
-
-cat >"$tmp/bin/systemctl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-printf 'systemctl\n' >>"${SYSTEMCTL_LOG:?}"
-[[ "${FAKE_VAULT_ACTIVE:-true}" == "true" ]]
-EOF
-chmod +x "$tmp/bin/systemctl"
-systemctl_log="$tmp/systemctl.log"
-: >"$systemctl_log"
-
-printf 'export VAULT_AGENT_ENABLED=false\n' >"$tmp/profile.sh"
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
-VAULT_AGENT_READY_MARKER="$tmp/run/vault.ready" \
-SYSTEMCTL_LOG="$systemctl_log" PATH="$tmp/bin:$PATH" \
- bash "$assert_script"
-[[ ! -s "$systemctl_log" ]] || fail "disabled Vault readiness contacted systemd"
-
-printf 'export VAULT_AGENT_ENABLED=invalid\n' >"$tmp/profile.sh"
-if CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- VAULT_AGENT_READY_MARKER="$tmp/run/vault.ready" \
- SYSTEMCTL_LOG="$systemctl_log" PATH="$tmp/bin:$PATH" \
- bash "$assert_script" >/dev/null 2>&1; then
- fail "invalid explicit Vault enablement was treated as disabled"
-fi
-
-printf 'export VAULT_AGENT_ENABLED=true\n' >"$tmp/profile.sh"
-if CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- VAULT_AGENT_READY_MARKER="$tmp/run/vault.ready" \
- SYSTEMCTL_LOG="$systemctl_log" PATH="$tmp/bin:$PATH" \
- bash "$assert_script" >/dev/null 2>&1; then
- fail "enabled Vault accepted a missing readiness marker"
-fi
-: >"$tmp/run/vault.ready"
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
-VAULT_AGENT_READY_MARKER="$tmp/run/vault.ready" \
-SYSTEMCTL_LOG="$systemctl_log" PATH="$tmp/bin:$PATH" \
- bash "$assert_script"
-if CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \
- VAULT_AGENT_READY_MARKER="$tmp/run/vault.ready" \
- SYSTEMCTL_LOG="$systemctl_log" FAKE_VAULT_ACTIVE=false PATH="$tmp/bin:$PATH" \
- bash "$assert_script" >/dev/null 2>&1; then
- fail "enabled Vault accepted an inactive agent service"
-fi
-
-vault_init="$repo_root/rootfs/home/cloud-compose/vault-agent-init.sh"
-grep -Fq 'Vault Agent is enabled but its config is missing or unsafe' "$vault_init" || \
- fail "enabled Vault does not fail closed on a missing config"
-grep -Fq 'Vault Agent is enabled but the Vault binary is not installed' "$vault_init" || \
- fail "enabled Vault does not fail closed on a missing binary"
-if grep -Fq 'vault-agent-init.sh || true' "$repo_root/rootfs/home/cloud-compose/run.sh"; then
- fail "bootstrap still masks explicit Vault initialization failure"
-fi
-vault_line="$(grep -nF 'bash /home/cloud-compose/vault-agent-init.sh' "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)"
-source_line="$(grep -nF 'run_as_cloud_compose bash /home/cloud-compose/prepare-app-sources.sh' "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)"
-rotation_line="$(grep -nF 'bash /home/cloud-compose/rotate-keys-daily.sh' "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)"
-init_line="$(grep -nF 'run_as_cloud_compose bash /home/cloud-compose/app-init.sh' "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)"
-app_line="$(grep -nF 'cloud_compose_start_and_wait_for_oneshot cloud-compose.service' "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)"
-[[ -n "$source_line" && -n "$rotation_line" && -n "$vault_line" && -n "$init_line" && -n "$app_line" ]] || \
- fail "bootstrap is missing a source, rotation, Vault, init, or app-service phase"
-(( source_line < rotation_line && rotation_line < vault_line && vault_line < init_line && init_line < app_line )) || \
- fail "bootstrap must order source preparation -> rotation -> Vault readiness -> app lifecycle -> app service"
-if rg -n 'run_compose_app_lifecycle|scaffold_compose_app_defaults|configure_sitectl_app_features' \
- "$repo_root/rootfs/home/cloud-compose/prepare-app-sources.sh" >/dev/null; then
- fail "source preparation executes application lifecycle work before Vault readiness"
-fi
-if grep -Fq 'clone_or_update_compose_app' "$repo_root/rootfs/home/cloud-compose/app-init.sh"; then
- fail "application lifecycle still performs source checkout after the dedicated preparation phase"
-fi
-
-source_log="$tmp/source-preparation.log"
-cat >"$tmp/source-profile.sh" <<'EOF'
-#!/usr/bin/env bash
-:
-EOF
-cat >"$tmp/source-functions.sh" <<'EOF'
-#!/usr/bin/env bash
-compose_app_names_array() {
- local -n result="$1"
- result=(alpha beta)
-}
-clone_or_update_compose_app() {
- printf 'clone %s\n' "$1" >>"${SOURCE_LOG:?}"
-}
-run_compose_app_lifecycle() {
- printf 'lifecycle %s\n' "$*" >>"${SOURCE_LOG:?}"
- return 99
-}
-EOF
-CLOUD_COMPOSE_PROFILE_PATH="$tmp/source-profile.sh" \
-CLOUD_COMPOSE_COMPOSE_APPS_PATH="$tmp/source-functions.sh" \
-SOURCE_LOG="$source_log" \
- bash "$repo_root/rootfs/home/cloud-compose/prepare-app-sources.sh"
-printf 'clone alpha\nclone beta\n' >"$tmp/expected-source-preparation.log"
-cmp -s "$tmp/expected-source-preparation.log" "$source_log" || \
- fail "source preparation did not clone every app without executing lifecycle work"
-grep -Fq 'ExecStartPre=/bin/bash /home/cloud-compose/assert-vault-ready.sh' \
- "$repo_root/rootfs/etc/systemd/system/cloud-compose.service" || fail "app service lacks a Vault readiness gate"
-vault_unit="$repo_root/rootfs/etc/systemd/system/cloud-compose-vault-agent.service"
+source_line="$(grep -nF 'run_as_cloud_compose "$managed_sitectl" host apps prepare' "$run_script" | cut -d: -f1)"
+rotation_line="$(grep -nF '"$sitectl" host keys daily' "$run_script" | cut -d: -f1)"
+vault_line="$(grep -nF '"$sitectl" host vault-agent init' "$run_script" | cut -d: -f1)"
+init_line="$(grep -nF 'run_as_cloud_compose "$managed_sitectl" host apps lifecycle init' "$run_script" | cut -d: -f1)"
+app_line="$(grep -nF 'host systemd start-wait cloud-compose.service' "$run_script" | cut -d: -f1)"
+[[ -n "$source_line" && -n "$rotation_line" && -n "$vault_line" && -n "$init_line" && -n "$app_line" ]] ||
+ fail "bootstrap is missing a source, rotation, Vault, init, or app-service phase"
+((source_line < rotation_line && rotation_line < vault_line && vault_line < init_line && init_line < app_line)) ||
+ fail "bootstrap must order source preparation -> rotation -> Vault readiness -> app lifecycle -> app service"
+
+grep -Fq 'ExecStartPre=/etc/cloud-compose/libexec/sitectl-host.sh vault-agent assert-ready' \
+ "$repo_root/rootfs/etc/systemd/system/cloud-compose.service" ||
+ fail "app service lacks a Vault readiness gate"
for readiness_command in \
- 'ExecStartPre=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh vault-agent-readiness.sh prepare' \
- 'ExecStartPost=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh vault-agent-readiness.sh wait' \
- 'ExecStopPost=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh vault-agent-readiness.sh clear'; do
- grep -Fq "$readiness_command" "$vault_unit" || \
- fail "Vault unit does not route readiness through the trusted root launcher: $readiness_command"
+ 'ExecStartPre=/etc/cloud-compose/libexec/sitectl-host.sh vault-readiness prepare' \
+ 'ExecStartPost=/etc/cloud-compose/libexec/sitectl-host.sh vault-readiness wait' \
+ 'ExecStopPost=/etc/cloud-compose/libexec/sitectl-host.sh vault-readiness clear'; do
+ grep -Fq "$readiness_command" "$vault_unit" ||
+ fail "Vault unit does not route readiness through the trusted root launcher: $readiness_command"
done
-
-echo "Vault runtime contract passed"
diff --git a/ci/verify-rootfs-release.sh b/ci/verify-rootfs-release.sh
deleted file mode 100755
index 5497890..0000000
--- a/ci/verify-rootfs-release.sh
+++ /dev/null
@@ -1,125 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-repo_root="$(cd -- "$script_dir/.." && pwd)"
-readonly archive_name=cloud-compose-rootfs.tar.gz
-readonly archive_checksum_name=cloud-compose-rootfs.tar.gz.sha256
-readonly contract_name=cloud-compose-rootfs.contract.sha256
-
-fail() {
- echo "rootfs release verification: $*" >&2
- exit 1
-}
-
-resolve_tag_for_commit() {
- local commit="$1" attempt tag
-
- [[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "commit must be a full lowercase Git SHA"
- for ((attempt = 1; attempt <= 60; attempt++)); do
- git fetch --force --tags origin >/dev/null 2>&1 || true
- tag="$(git tag --points-at "$commit" --sort=-version:refname | head -n 1)"
- if [[ -n "$tag" ]]; then
- printf '%s\n' "$tag"
- return 0
- fi
- sleep 10
- done
- fail "no release tag appeared for commit $commit"
-}
-
-download_assets() {
- local tag="$1" destination="$2" attempt
-
- for ((attempt = 1; attempt <= 60; attempt++)); do
- if gh release download "$tag" \
- --repo "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" \
- --dir "$destination" \
- --clobber \
- --pattern "$archive_name" \
- --pattern "$archive_checksum_name" \
- --pattern "$contract_name" >/dev/null 2>&1 &&
- [[ -f "$destination/$archive_name" &&
- -f "$destination/$archive_checksum_name" &&
- -f "$destination/$contract_name" ]]; then
- return 0
- fi
- sleep 10
- done
- fail "release $tag did not publish all three immutable rootfs assets"
-}
-
-verify_assets() {
- local tag="$1" asset_dir="$2" extract_dir="$3"
- local checksum_line expected_archive_sha256 checksum_filename actual_archive_sha256
- local contract_sha256 actual_contract_sha256
-
- checksum_line="$(<"$asset_dir/$archive_checksum_name")"
- [[ "$checksum_line" =~ ^([0-9a-f]{64})\ \ cloud-compose-rootfs\.tar\.gz$ ]] ||
- fail "release $tag has a malformed archive checksum asset"
- expected_archive_sha256="${BASH_REMATCH[1]}"
- checksum_filename="${checksum_line#* }"
- [[ "$checksum_filename" == "$archive_name" ]] ||
- fail "release $tag checksum names an unexpected archive"
- actual_archive_sha256="$(sha256sum -- "$asset_dir/$archive_name" | cut -d' ' -f1)"
- [[ "$actual_archive_sha256" == "$expected_archive_sha256" ]] ||
- fail "release $tag archive bytes do not match its checksum"
-
- [[ "$(wc -c <"$asset_dir/$contract_name")" -eq 65 ]] ||
- fail "release $tag rootfs contract must be one digest plus a newline"
- contract_sha256="$(<"$asset_dir/$contract_name")"
- [[ "$contract_sha256" =~ ^[0-9a-f]{64}$ ]] ||
- fail "release $tag rootfs contract is not a lowercase SHA-256 digest"
-
- bash "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" \
- validate-archive "$asset_dir/$archive_name" ||
- fail "release $tag archive member topology is unsafe"
-
- install -d -m 0755 "$extract_dir"
- tar --no-same-owner --same-permissions \
- -xzf "$asset_dir/$archive_name" -C "$extract_dir"
- actual_contract_sha256="$(
- bash "$repo_root/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh" \
- contract "$extract_dir/rootfs"
- )"
- [[ "$actual_contract_sha256" == "$contract_sha256" ]] ||
- fail "release $tag archive does not match its canonical rootfs contract"
-}
-
-verify_source_assets() {
- local tag="$1" asset_dir="$2" expected_dir="$3"
-
- bash "$repo_root/ci/package-rootfs.sh" "$expected_dir"
- for asset in "$archive_name" "$archive_checksum_name" "$contract_name"; do
- cmp -- "$expected_dir/$asset" "$asset_dir/$asset" ||
- fail "release $tag asset $asset does not match the tagged source"
- done
-}
-
-main() {
- local selector="${1:-}" value="${2:-}" tag tmp
-
- [[ "$#" -eq 2 ]] || fail "usage: verify-rootfs-release.sh tag TAG|commit SHA"
- case "$selector" in
- tag)
- [[ "$value" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] ||
- fail "tag is not a semantic-version release tag"
- tag="$value"
- ;;
- commit)
- tag="$(resolve_tag_for_commit "$value")"
- ;;
- *) fail "usage: verify-rootfs-release.sh tag TAG|commit SHA" ;;
- esac
-
- tmp="$(mktemp -d "${RUNNER_TEMP:-/tmp}/cloud-compose-rootfs-release.XXXXXX")"
- trap 'rm -rf -- "$tmp"' EXIT
- install -d -m 0755 "$tmp/assets"
- download_assets "$tag" "$tmp/assets"
- verify_source_assets "$tag" "$tmp/assets" "$tmp/expected"
- verify_assets "$tag" "$tmp/assets" "$tmp/extracted"
- echo "Verified rootfs release assets for $tag"
-}
-
-main "$@"
diff --git a/docs/examples.md b/docs/examples.md
index df15d80..3bcb927 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -31,19 +31,14 @@ module "app" {
cloud_compose_keys = var.operator_ssh_keys
}
}
- runtime = {
- rootfs_archive_url = var.cloud_compose_rootfs_archive_url
- rootfs_archive_sha256 = var.cloud_compose_rootfs_archive_sha256
- }
}
```
-Replace the source placeholder with an exact release that publishes all three
-canonical rootfs assets, and set the URL and SHA-256 from that same release.
-The runnable DigitalOcean and Linode examples intentionally provide no release
-default. Terraform fetches the adjacent rootfs contract during planning and
-rejects a module/archive mismatch; omitting `?ref=` would make future plans
-consume a moving module source.
+Replace the source placeholder with an exact reviewed release. The module
+compresses its checked-in rootfs directly into cloud-init and rejects a payload
+that exceeds the provider limit; there is no separate runtime archive or
+checksum input. Omitting `?ref=` would make future plans consume a moving module
+source.
## GCP foundation and application states
@@ -136,7 +131,7 @@ healthcheck settings:
runtime = {
sitectl = {
package_versions = {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
sitectl-wp = "v2.1.0"
}
}
@@ -182,7 +177,7 @@ runtime = {
}
sitectl = {
package_versions = {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
sitectl-wp = "v2.1.0"
sitectl-drupal = "v1.5.0"
}
diff --git a/docs/managed-runtime.md b/docs/managed-runtime.md
index 44f4e67..1f32f29 100644
--- a/docs/managed-runtime.md
+++ b/docs/managed-runtime.md
@@ -1,8 +1,7 @@
# Managed Runtime
-`cloud-compose` installs a small LibOps-managed runtime on each VM. The runtime
-keeps host tools and LibOps-side support services current without changing the
-customer application repository.
+`cloud-compose` installs a small LibOps-managed runtime on each VM during
+bootstrap without changing the customer application repository.
Managed by default:
@@ -17,8 +16,10 @@ CAP and cAdvisor. On GCP, enabling power management also enables this runtime
because lightsout depends on it. Disabled internal services create no dedicated
internal service account or IAM bindings, and the `serviceGsa` output is null.
-The updater runs once during boot before the application initializes, then on a
-daily systemd timer. Each package follows its own GitHub release stream. The
+The installer runs during boot before the application initializes. Version
+changes are applied by updating the reviewed Terraform inputs and replacing or
+rebootstrapping the host; no timer mutates host binaries independently. Each
+package follows its own GitHub release stream. The
Terraform template presets select an exact, reviewed core/plugin release set
from `templates/apps.json`. Use `runtime.sitectl.package_versions` to override
core and plugins independently. The provider-neutral
@@ -26,7 +27,7 @@ core and plugins independently. The provider-neutral
backward-compatible fallback only for an installed package with neither a
template selector nor an explicit per-package selector.
-All presets pin sitectl v1.9.1 with reviewed application plugins:
+All presets pin sitectl v1.12.7 with reviewed application plugins:
sitectl-archivesspace v2.1.1, sitectl-drupal v1.5.0, sitectl-isle v1.6.0,
sitectl-ojs v1.4.0, sitectl-omeka-classic v1.4.0, sitectl-omeka-s v1.4.0,
and sitectl-wp v2.1.0. ISLE includes both the Drupal and ISLE plugins. The
@@ -54,7 +55,7 @@ runtime = {
"sitectl-isle",
]
package_versions = {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
sitectl-drupal = "v1.5.0"
sitectl-isle = "v1.6.0"
}
@@ -80,12 +81,11 @@ compatible core/plugin set, and advance core before a plugin that raises its
minimum core requirement.
The updater resolves and verifies the complete requested package set in a
-staging generation before replacing any live executable. It takes the shared
-application lifecycle lock during the short promotion, records a SHA-256 beside
-each installed version, and repairs local binary drift. If a later plugin cannot
-download or verify, the old set remains active; if promotion itself fails, the
-runtime restores every previous binary and state file instead of leaving a
-mixed core/plugin generation.
+staging generation before replacing any live executable. It serializes updates
+with a root-owned runtime lock, records a SHA-256 beside each installed version,
+and publishes the new immutable generation with one atomic symlink replacement.
+If a later plugin cannot download or verify, the old set remains active; core
+and plugin files are never promoted one at a time into a mixed generation.
Additional LibOps-owned binaries or scripts can use the artifact manifest:
diff --git a/docs/non-gcp-providers.md b/docs/non-gcp-providers.md
index 3c781b4..1ad5e55 100644
--- a/docs/non-gcp-providers.md
+++ b/docs/non-gcp-providers.md
@@ -272,20 +272,13 @@ module "wp" {
cloud_compose_keys = var.operator_ssh_keys
}
}
- runtime = {
- rootfs_archive_url = var.cloud_compose_rootfs_archive_url
- rootfs_archive_sha256 = var.cloud_compose_rootfs_sha256
- }
}
```
-Replace the source placeholder with the exact reviewed release that supplied
-`cloud_compose_rootfs_archive_url` and its SHA-256. The runnable example leaves
-both release inputs required because older releases do not publish the adjacent
-contract now required at plan time. DigitalOcean limits Droplet `user_data` to
-64 KiB, and the full managed runtime no longer fits safely inline. Terraform
-rejects both an oversized inline payload and a mismatched module/archive before
-calling the Droplet API. The default DigitalOcean SSH firewall sources include
+Replace the source placeholder with an exact reviewed release. DigitalOcean
+limits Droplet `user_data` to 64 KiB. The module embeds one compressed rootfs
+bundle and rejects an oversized payload before calling the Droplet API. The
+default DigitalOcean SSH firewall sources include
the public internet; production callers should set
`digitalocean.firewall.ssh_source_addresses` to institutional or operator
CIDRs instead of inheriting that default.
@@ -305,13 +298,8 @@ remain unchanged.
## Linode
Use `LINODE_TOKEN` or an explicit Linode provider configuration in the calling
-stack. Linode metadata has a 16 KiB limit, so CI and examples pass
-`rootfs_archive_url` instead of embedding the managed rootfs. Archive mode is
-an integrity-pinned input: set an immutable HTTPS source ref and the SHA-256 of
-the exact downloaded archive together. Terraform rejects a non-HTTPS URL, a URL
-without a checksum, a checksum without a URL, and malformed digests. The VM
-restricts redirects to HTTPS with TLS 1.2 or newer and verifies the checksum
-before extracting any archive content.
+stack. The module gzip-compresses cloud-init for Linode metadata and rejects an
+encoded payload above 64 KiB before creating the instance.
```hcl
module "drupal" {
@@ -328,27 +316,10 @@ module "drupal" {
cloud_compose_keys = var.operator_ssh_keys
}
}
- runtime = {
- rootfs_archive_url = var.cloud_compose_rootfs_archive_url
- rootfs_archive_sha256 = var.cloud_compose_rootfs_sha256
- }
}
```
Linode also defaults SSH ingress to the public internet. Set
`linode.firewall.ssh_source_ipv4` and `ssh_source_ipv6` to reviewed operator
-CIDRs. Replace the source placeholder only with the exact reviewed release that
-supplied the rootfs archive URL and checksum. The runnable example intentionally
-has no release default.
-
-Use the canonical `cloud-compose-rootfs.tar.gz`, adjacent archive `.sha256`,
-and `cloud-compose-rootfs.contract.sha256` release assets. They are built
-reproducibly from the tagged `rootfs/` tree. Terraform fetches the contract
-sidecar while planning and rejects an archive from a different module release
-before changing a VM; boot independently verifies both the pinned archive
-bytes and the canonical rootfs contract. GitHub's generated repository source
-archives are not a stable long-term checksum boundary because GitHub may
-regenerate their outer compression. CI may use a commit archive only within the
-same run while testing an unreleased commit. Promote a release only after its
-`Verify rootfs release assets` job is green, then use the exact release asset
-URL and published archive checksum in the deployment.
+CIDRs. Replace the source placeholder only with an exact reviewed release; the
+rootfs bytes are part of that pinned Terraform module source.
diff --git a/docs/rollout.md b/docs/rollout.md
index a023561..36b17e7 100644
--- a/docs/rollout.md
+++ b/docs/rollout.md
@@ -28,15 +28,15 @@ module "site" {
runtime = {
compose = {
rollout = [
- "/home/cloud-compose/default-lifecycle.sh rollout",
+ "sitectl:default rollout",
]
}
}
}
```
-The `runtime.compose.rollout` entry above is the built-in default and may be
-omitted. It is shown to make the single-program execution boundary explicit.
+The `runtime.compose.rollout` entry above is the built-in sitectl lifecycle and
+may be omitted. It is shown to make the typed lifecycle boundary explicit.
The example pins the reviewed cloud-compose `1.0.0` release. Replace that ref
only with an exact reviewed release or full commit. `gcp.rollout.release_url`
diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md
index 0464d5b..4957eb3 100644
--- a/docs/runtime-contracts.md
+++ b/docs/runtime-contracts.md
@@ -169,62 +169,44 @@ with `systemctl status cloud-compose-bootstrap cloud-compose`. Bootstrap output
uses a fixed `info` priority and has no unit-specific Fluent Bit input, so raw
bootstrap output is not forwarded to Cloud Logging; systemd's own service
failures remain available to the existing warning-level collector.
-Root systemd jobs enter through `/etc/cloud-compose/libexec`, validate the
-root-owned home scripts and control inputs, and only then execute their
-allowlisted `/home/cloud-compose` program. The privileged entrypoints,
-diagnostics command, and checked-in `jq` and AWK programs live below
-`/etc/cloud-compose/{libexec,bin,jq,awk}` because COS permits cloud-init to rebuild
-that stateless tree while its `/usr` filesystem is immutable. Application
-services retain their unprivileged execution model. Compose diagnostics run as
-the application account and select the verified plugin copy below the
-executable data disk explicitly; they do not fall back to the compatibility
-copy below COS's `noexec` `/home` mount.
-
-An installed script resolved below `/home/cloud-compose` always uses the fixed
-`/etc/cloud-compose/jq` and `/etc/cloud-compose/awk` programs. Environment
-overrides for those programs, the shared profile, key-rotation entrypoint, or
-any other sourced runtime helper are rejected in installed mode. Repository
-and CI fixtures may select checked programs only while the owning script resolves
-outside the installed home. Before an installed program is consumed, its
-canonical root-owned parent chain must contain no symlink or group/world-writable
-directory. The only accepted logical alias is the operating-system-owned
-`/home -> /var/home` link used by Fedora CoreOS. The program itself must be a
-root-owned, single-link, non-writable regular file. Configuration-management
+Root systemd jobs enter through one root-owned
+`/etc/cloud-compose/libexec/sitectl-host.sh` launcher. It loads the validated
+host environment and invokes only the hidden `sitectl host` command tree; it
+does not dispatch arbitrary home-directory programs. Configuration-management
adapters make `/home/cloud-compose` root-owned immediately after copying rootfs,
-before any root execution; mutable application state remains in its explicitly
-account-owned child/data paths. A checked directory binding validates every
-direct `jq` or AWK program in that installed directory, so adding a new filter
-cannot bypass per-file ownership, link, and mode checks. Production rootfs shell
-scripts invoke `jq` and AWK only with checked `-f` program files; the static CI
-contract rejects embedded filters so review and integrity checks cannot be
-bypassed by a later one-line program.
+before any root execution. Mutable application state remains in its explicitly
+account-owned child and data paths. Application lifecycle services retain their
+unprivileged execution model. Compose diagnostics run as the application
+account and select the verified plugin copy below the executable data disk
+explicitly; they do not fall back to the compatibility copy below COS's
+`noexec` `/home` mount.
Terraform and cloud-init are transport and orchestration boundaries, not the
-home of bootstrap implementations. The GCP and provider-neutral templates
-transfer checked-in programs from `rootfs/etc/cloud-compose/libexec` and invoke
-them by path with data-only arguments. GCP packages its checked filesystem
-program in a `text/cloud-boothook` MIME part so durable mounts are validated at
-the original early, every-boot phase before cloud-config writes files or starts
-services. Archive-mode Linux user data separately transports the small current
-bootstrap driver and verifies its Terraform-rendered SHA-256 before execution;
-filesystem helpers and the rest of the runtime still come from the verified
-rootfs archive. This preserves metadata headroom without requiring an older
-rootfs archive to contain the new driver. Files destined for `/mnt/disks` are
-staged until the checked-in filesystem program has mounted and validated the
-durable disks. GCP `initcmd` and `runcmd` values are likewise written as
-root-controlled program files before they are sourced at their documented
-points in the bootstrap sequence. Keep substantive shell, Python, `jq`, and
-similar programs in reviewed files; do not interpolate them into Terraform
-heredocs, cloud-init command strings, Compose commands, or configuration-
-management task bodies. The early filesystem path stages its checked-in fstab
-AWK program from the same Terraform source or already verified rootfs archive,
-requires an unlinked root-owned mode-0600 file, and invokes it as data with
-`awk -f` before the full stateless rootfs is available.
-
-GCP compresses checked bootstrap programs before carrying them in user-data and
-enforces a 240 KiB plan-time budget. This leaves explicit headroom below the
-provider's 256 KiB metadata-item limit; larger custom inputs must use the
-verified rootfs archive path or be reduced before a VM can be replaced.
+home of bootstrap implementations. Terraform serializes every checked-in
+`rootfs/` file into one compressed JSON bundle. A small root-owned Python
+installer rejects traversal, symlinked parents, unsupported modes, and unsafe
+destinations before publishing files atomically. Before durable storage is
+mutated, a small Bash shim downloads and checksum-verifies the selected core
+`sitectl` release. The hidden `sitectl host` runtime then owns filesystem
+discovery, formatting safeguards, fstab reconciliation, overlays, readiness
+markers, systemd convergence, metadata isolation, Vault Agent readiness,
+verified Docker plugins, and privileged-path checks. GCP invokes the same Go
+filesystem implementation from a `text/cloud-boothook` MIME part so mounts are
+validated during the early, every-boot phase. Files destined for `/mnt/disks`
+are staged until that implementation has mounted and validated the durable
+disks.
+GCP `initcmd` and `runcmd` values are likewise written as root-controlled
+program files before they are sourced at their documented points in bootstrap.
+Rootfs shell is limited to narrow boot, package-manager, and process-launcher
+adapters; CI caps the aggregate at 1,000 lines. Put new provider-neutral host
+behavior and its tests in `sitectl`, not in a shell wrapper. Do not interpolate
+substantive programs into Terraform heredocs, cloud-init command strings,
+Compose commands, or configuration-management task bodies.
+
+GCP enforces a 240 KiB plan-time budget, leaving explicit headroom below its
+256 KiB metadata-item limit. DigitalOcean and Linode enforce 64 KiB limits on
+their rendered payloads. Oversized custom inputs must be reduced before a VM
+can be replaced.
## Sitectl
@@ -242,8 +224,11 @@ with `runtime.sitectl.package_versions`; its keys are package names such as
exact compatible package set recorded in `templates/apps.json`; explicit
per-package values win, and template values are filtered to the installed
package list before validation. Terraform, Ansible, and Salt serialize their
-resolved map as `SITECTL_PACKAGE_VERSIONS` JSON and the privileged installer
-validates it again before downloading anything. Per-project package versions
+resolved map as `SITECTL_PACKAGE_VERSIONS` JSON. The early bootstrap shim
+verifies only the first core `sitectl` binary; `sitectl host runtime install`
+then validates, downloads, and verifies the complete package set before
+publishing one immutable generation. A failed package never partially advances
+the active generation. Per-project package versions
are not supported because projects on one host share the same binaries.
`/home/cloud-compose/bin` is reserved for generated managed-tool symlinks. They
target the root-owned managed binary directory and normally comprise `sitectl`,
@@ -258,22 +243,16 @@ invokes `sitectl verify`; spaces in one value never become additional arguments.
Newlines, carriage returns, and NUL bytes are rejected instead of being flattened
into an ambiguous shell scalar.
-Each configured lifecycle list value names an independent checked program; it
-is not parsed as shell source. The built-in `init`, `up`, `down`, and `rollout`
-defaults each contain exactly `/home/cloud-compose/default-lifecycle.sh ACTION`,
-where `ACTION` matches the lifecycle field. A custom entry must be one
-argument-free, root-controlled program immediately below
-`/etc/cloud-compose/lifecycle.d`; pass data through the documented lifecycle
-environment. `true` and `false` remain explicit no-op and failure sentinels.
-Multi-step work, state such as local variables or traps, and any quoting belong
-inside that reviewed program file. The constrained executor invokes its argv
-without evaluating a manifest value as shell source. Custom programs retain
-direct execution. After the same root-owner, link-count, parent-directory, and
-mode checks, the built-in script is opened as
-`/bin/bash -- /home/cloud-compose/default-lifecycle.sh ACTION`; this fixed
-interpreter path keeps the built-in lifecycle usable on Container-Optimized OS,
-where `/home` is deliberately mounted `noexec`, without turning the manifest
-entry into a shell command string.
+Each configured lifecycle list value is typed data, never shell source. The
+built-in `init`, `up`, `down`, and `rollout` defaults use
+`sitectl:default ACTION`, where `ACTION` matches the lifecycle field. sitectl
+interprets that marker directly and runs the typed Compose, health, verify, and
+deploy operations in Go. A custom entry must be one argument-free,
+root-controlled program immediately below `/etc/cloud-compose/lifecycle.d`;
+pass data through the documented lifecycle environment. `true` and `false`
+remain explicit no-op and failure sentinels. The constrained executor validates
+and directly executes custom programs without evaluating a manifest value as
+shell source.
## Vault
@@ -734,46 +713,12 @@ reported as complete only when the service account has no user-managed key.
A definite IAM rejection that proves no key was created clears the pre-create
state. A timeout, transport failure, or otherwise ambiguous key-creation
-response never triggers an automatic second create. `rotate-keys.sh audit ...`
-reports only the baseline delta/key IDs. Recovery requires the single audited
-orphan key ID as explicit confirmation to `rotate-keys.sh recover ... KEY_ID`;
-if no key was created, recovery clears the pending state without deleting
-anything. Recovery also waits `ROTATION_RECOVERY_SETTLE_SECONDS` (60 seconds by
-default) before trusting an empty IAM delta, avoiding a false “no key created”
-result during propagation.
-
-All Terraform entrypoints, including GCP, DigitalOcean, and Linode, support the
-same verified rootfs archive contract. When `runtime.rootfs_archive_url` is
-used, it must be an HTTPS URL without whitespace and
-`runtime.rootfs_archive_sha256` is mandatory with a 64-character SHA-256
-digest. Terraform derives the adjacent
-`cloud-compose-rootfs.contract.sha256` URL and reads it during planning. That
-sidecar must contain exactly the canonical contract digest for the module's
-checked-in `rootfs`: every directory, file path, file byte, root ownership,
-mode, and single-link file topology. A missing, malformed, older, or otherwise
-mismatched sidecar rejects the plan before Terraform can replace a VM.
-
-Boot restricts curl and redirects to HTTPS with TLS 1.2 or newer, downloads to
-a root-only temporary path, verifies the complete archive checksum, rejects
-links and unsupported filesystem objects, and re-verifies the same canonical
-rootfs contract before copying anything onto the host. Packaged directories are
-root-owned mode `0755`; checked `*.sh` programs are mode `0755`; other files are
-mode `0644`; regular files must have one link. The GCP path stages the caller's
-packaged rootfs overlay and reapplies it after the archive, so consumer
-overrides still win. A single checked-in archive program implements download,
-verification, and installation for every Terraform provider rather than
-duplicating provider-specific shell bodies. Use the immutable assets from the
-same release as the Terraform module. Keeping an older archive while advancing
-the module fails safely during planning and leaves the existing workload
-untouched.
-
-Each release publishes three assets:
-`cloud-compose-rootfs.tar.gz`, its archive-byte `.sha256`, and
-`cloud-compose-rootfs.contract.sha256`. The release workflow downloads the
-published assets again, validates the archive bytes and canonical tree, and
-does not complete its release gate until all three agree. Downstream catalogs
-or automation must promote a cloud-compose release only after the
-`Verify rootfs release assets` job is green.
+response never triggers a second create. Rerunning the same sitectl rotation
+reconciles the durable creating state against the exact service-account key
+delta; it deletes only one unambiguous unrecoverable candidate and otherwise
+fails closed. The rootfs is part of the pinned Terraform module source and is
+embedded directly, so there is no independently versioned archive, sidecar, or
+release-asset promotion path to drift from that source.
## Backups
@@ -967,9 +912,8 @@ deploy the same
`5058610fddc7267ace92d65a5c49713dce570ac3`; an early exact checkout bridges
the legacy runtime's branch-only clone behavior without following a moving
branch. Its `gcp.cloud_init.initcmd` disables both generations of the
-internal-service timer after cloud-init writes the units but before the
-root-owned `/etc/cloud-compose/libexec/run-bootstrap.sh` entrypoint
-starts the potentially long bootstrap, so the disposable VM cannot suspend
+internal-service timer after cloud-init writes the units but before
+`cloud-compose-bootstrap.service` starts the potentially long bootstrap, so the disposable VM cannot suspend
itself. The runner checks the units again after each boot.
Only the ephemeral runner key is authorized, and SSH is limited to that
runner's public IPv4 `/32`.
diff --git a/examples/binpack/main.tf b/examples/binpack/main.tf
index c709108..a0554a9 100644
--- a/examples/binpack/main.tf
+++ b/examples/binpack/main.tf
@@ -29,7 +29,7 @@ module "apps" {
}
sitectl = {
package_versions = {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
sitectl-wp = "v2.1.0"
sitectl-drupal = "v1.5.0"
}
diff --git a/examples/digitalocean/main.tf b/examples/digitalocean/main.tf
index a0ed4ea..01a6397 100644
--- a/examples/digitalocean/main.tf
+++ b/examples/digitalocean/main.tf
@@ -18,8 +18,6 @@ module "wp" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/releases/download/${var.cloud_compose_source_ref}/cloud-compose-rootfs.tar.gz"
- rootfs_archive_sha256 = var.cloud_compose_source_sha256
compose = {
repo = var.docker_compose_repo
branch = var.docker_compose_branch
diff --git a/examples/digitalocean/variables.tf b/examples/digitalocean/variables.tf
index fc89289..a0a440c 100644
--- a/examples/digitalocean/variables.tf
+++ b/examples/digitalocean/variables.tf
@@ -28,28 +28,6 @@ variable "cloud_compose_ssh_keys" {
description = "SSH public keys for the cloud-compose user."
}
-variable "cloud_compose_source_ref" {
- type = string
- nullable = false
- description = "Required exact cloud-compose release tag whose canonical rootfs asset is fetched by DigitalOcean cloud-init. It must match the release containing this example/module source."
-
- validation {
- condition = can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+$", trimspace(var.cloud_compose_source_ref)))
- error_message = "cloud_compose_source_ref must be an exact semantic-version release tag."
- }
-}
-
-variable "cloud_compose_source_sha256" {
- type = string
- nullable = false
- description = "SHA-256 of the canonical rootfs asset published for cloud_compose_source_ref."
-
- validation {
- condition = can(regex("^[0-9a-f]{64}$", trimspace(var.cloud_compose_source_sha256)))
- error_message = "cloud_compose_source_sha256 must be a lowercase 64-character SHA-256 digest."
- }
-}
-
variable "docker_compose_repo" {
type = string
default = ""
diff --git a/examples/linode/main.tf b/examples/linode/main.tf
index 34f358f..b3cb439 100644
--- a/examples/linode/main.tf
+++ b/examples/linode/main.tf
@@ -20,8 +20,6 @@ module "drupal" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/releases/download/${var.cloud_compose_source_ref}/cloud-compose-rootfs.tar.gz"
- rootfs_archive_sha256 = var.cloud_compose_source_sha256
compose = {
repo = var.docker_compose_repo
branch = var.docker_compose_branch
diff --git a/examples/linode/variables.tf b/examples/linode/variables.tf
index cb1c596..2942faa 100644
--- a/examples/linode/variables.tf
+++ b/examples/linode/variables.tf
@@ -41,28 +41,6 @@ variable "cloud_compose_ssh_keys" {
description = "SSH public keys for the cloud-compose user."
}
-variable "cloud_compose_source_ref" {
- type = string
- nullable = false
- description = "Required exact cloud-compose release tag whose canonical rootfs asset is fetched by Linode cloud-init. It must match the release containing this example/module source."
-
- validation {
- condition = can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+$", trimspace(var.cloud_compose_source_ref)))
- error_message = "cloud_compose_source_ref must be an exact semantic-version release tag."
- }
-}
-
-variable "cloud_compose_source_sha256" {
- type = string
- nullable = false
- description = "SHA-256 of the canonical rootfs asset published for cloud_compose_source_ref."
-
- validation {
- condition = can(regex("^[0-9a-f]{64}$", trimspace(var.cloud_compose_source_sha256)))
- error_message = "cloud_compose_source_sha256 must be a lowercase 64-character SHA-256 digest."
- }
-}
-
variable "docker_compose_repo" {
type = string
default = ""
diff --git a/internal/contracttest/cloud_smoke_cleanup_test.go b/internal/contracttest/cloud_smoke_cleanup_test.go
index 6577b74..8c1f755 100644
--- a/internal/contracttest/cloud_smoke_cleanup_test.go
+++ b/internal/contracttest/cloud_smoke_cleanup_test.go
@@ -586,7 +586,7 @@ func processExitCode(t testing.TB, err error) int {
func repositoryCommitSHA(t testing.TB, root string) string {
t.Helper()
- output, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
+ output, err := exec.Command("git", "-c", "safe.directory="+root, "-C", root, "rev-parse", "HEAD").Output()
if err != nil {
t.Fatalf("resolve repository commit: %v", err)
}
diff --git a/internal/contracttest/disaster_recovery_test.go b/internal/contracttest/disaster_recovery_test.go
index 61d6931..b09eebc 100644
--- a/internal/contracttest/disaster_recovery_test.go
+++ b/internal/contracttest/disaster_recovery_test.go
@@ -1,9 +1,6 @@
package contracttest
-import (
- "strings"
- "testing"
-)
+import "testing"
func TestDisasterRecoveryInputParity(t *testing.T) {
root := repositoryRoot(t)
@@ -45,61 +42,3 @@ func TestDisasterRecoveryInputParity(t *testing.T) {
requireContains(t, content, "CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER", relativePath+" driver host control")
}
}
-
-func TestDisasterRecoveryReceiptAndRestoreContracts(t *testing.T) {
- root := repositoryRoot(t)
- library := readRepositoryFile(t, root, "rootfs/home/cloud-compose/disaster-recovery-lib.sh")
- backup := readRepositoryFile(t, root, "rootfs/home/cloud-compose/offhost-backup.sh")
- restore := readRepositoryFile(t, root, "rootfs/home/cloud-compose/restore-test.sh")
- validationContract := strings.Join([]string{
- library,
- readRepositoryFile(t, root, "rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq"),
- readRepositoryFile(t, root, "rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq"),
- }, "\n")
- backupContract := strings.Join([]string{
- backup,
- readRepositoryFile(t, root, "rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq"),
- }, "\n")
-
- for marker, label := range map[string]string{
- `env -i HOME=/root`: "clean driver environment",
- `>/dev/null 2>&1`: "suppressed driver output",
- `cloud-compose.offhost-backup-receipt`: "backup receipt kind",
- `.encrypted == true`: "encrypted coverage",
- `.off_host == true`: "off-host coverage",
- `.database == true`: "database coverage",
- `.application_files == true`: "application-file coverage",
- `.volume_topology == true`: "volume-topology coverage",
- `cloud-compose.restore-test-proof`: "restore proof kind",
- `.disposable_recovery == true`: "disposable recovery proof",
- `.recovery_destroyed == true`: "recovery cleanup proof",
- `.source_receipt_sha256 == $receipt_sha256`: "source receipt binding",
- } {
- requireContains(t, validationContract, marker, label)
- }
-
- for marker, label := range map[string]string{
- `docker compose config --format json`: "resolved Compose topology",
- `local_recovery_artifact`: "logical database artifact",
- `application_files`: "application files",
- `volume_topology`: "volume topology",
- `cloud_compose_dr_run_driver`: "provider-neutral backup handoff",
- } {
- requireContains(t, backupContract, marker, label)
- }
-
- for marker, label := range map[string]string{
- `/dev/urandom`: "one-time restore challenge",
- `--backup-receipt`: "remote receipt input",
- `cloud_compose_dr_validate_restore_proof`: "strict restore proof validation",
- `mv -- "$staged_proof" "$proof_path"`: "atomic restore proof publication",
- } {
- requireContains(t, restore, marker, label)
- }
-
- for _, forbidden := range []string{"AWS_ACCESS_KEY", "GOOGLE_APPLICATION_CREDENTIALS", "AZURE_STORAGE_KEY"} {
- if strings.Contains(library+backup+restore, forbidden) {
- t.Errorf("disaster-recovery runtime hard-codes credential channel %q", forbidden)
- }
- }
-}
diff --git a/internal/contracttest/docker_prune_test.go b/internal/contracttest/docker_prune_test.go
deleted file mode 100644
index bfd3268..0000000
--- a/internal/contracttest/docker_prune_test.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package contracttest
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "slices"
- "strings"
- "testing"
-)
-
-func TestDockerPrunePreservesNamedRollbackImages(t *testing.T) {
- root := repositoryRoot(t)
- script := filepath.Join(root, "rootfs", "home", "cloud-compose", "docker-prune.sh")
- temporary := t.TempDir()
- profile := filepath.Join(temporary, "profile.sh")
- lock := filepath.Join(temporary, "docker-prune.lock")
- logPath := filepath.Join(temporary, "docker.log")
- binDirectory := filepath.Join(temporary, "bin")
-
- if err := os.Mkdir(binDirectory, 0o755); err != nil {
- t.Fatalf("create mock binary directory: %v", err)
- }
- if err := os.WriteFile(profile, []byte("# test profile\n"), 0o600); err != nil {
- t.Fatalf("write test profile: %v", err)
- }
- mockDocker := []byte("#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' \"$*\" >> \"${DOCKER_PRUNE_TEST_LOG:?}\"\n")
- if err := os.WriteFile(filepath.Join(binDirectory, "docker"), mockDocker, 0o755); err != nil {
- t.Fatalf("write mock Docker binary: %v", err)
- }
-
- command := exec.Command("bash", script)
- command.Env = append(os.Environ(),
- "CLOUD_COMPOSE_DOCKER_PRUNE_ENABLED=true",
- "CLOUD_COMPOSE_DOCKER_PRUNE_LOCK_PATH="+lock,
- "CLOUD_COMPOSE_DOCKER_PRUNE_UNTIL=72h",
- "CLOUD_COMPOSE_PROFILE_PATH="+profile,
- "DOCKER_PRUNE_TEST_LOG="+logPath,
- "PATH="+binDirectory+":"+os.Getenv("PATH"),
- )
- if output, err := command.CombinedOutput(); err != nil {
- t.Fatalf("run Docker prune policy: %v\n%s", err, output)
- }
-
- logBytes, err := os.ReadFile(logPath)
- if err != nil {
- t.Fatalf("read Docker invocation log: %v", err)
- }
- commands := strings.Split(strings.TrimSpace(string(logBytes)), "\n")
- want := []string{
- "container prune --force --filter until=72h",
- "network prune --force --filter until=72h",
- "image prune --force --filter until=72h",
- "builder prune --force --filter until=72h",
- }
- if !slices.Equal(commands, want) {
- t.Fatalf("Docker prune commands = %#v, want %#v", commands, want)
- }
- for _, invocation := range commands {
- if strings.Contains(invocation, "system prune") || strings.Contains(invocation, "image prune --all") {
- t.Fatalf("destructive Docker prune invocation remains: %s", invocation)
- }
- }
-}
-
-func TestDockerPruneIsDisabledByDefault(t *testing.T) {
- root := repositoryRoot(t)
- script := filepath.Join(root, "rootfs", "home", "cloud-compose", "docker-prune.sh")
- temporary := t.TempDir()
- profile := filepath.Join(temporary, "profile.sh")
- if err := os.WriteFile(profile, []byte("# test profile\n"), 0o600); err != nil {
- t.Fatalf("write test profile: %v", err)
- }
-
- command := exec.Command("bash", script)
- command.Env = append(os.Environ(),
- "CLOUD_COMPOSE_DOCKER_PRUNE_ENABLED=",
- "CLOUD_COMPOSE_PROFILE_PATH="+profile,
- )
- output, err := command.CombinedOutput()
- if err != nil {
- t.Fatalf("run disabled Docker prune policy: %v\n%s", err, output)
- }
- if !strings.Contains(string(output), "Docker pruning is disabled") {
- t.Fatalf("disabled Docker prune output = %q", output)
- }
-}
diff --git a/internal/contracttest/rollout_parity_test.go b/internal/contracttest/rollout_parity_test.go
index c521e7b..99a50be 100644
--- a/internal/contracttest/rollout_parity_test.go
+++ b/internal/contracttest/rollout_parity_test.go
@@ -11,7 +11,7 @@ import (
)
var expectedRollout = []string{
- `/home/cloud-compose/default-lifecycle.sh rollout`,
+ `sitectl:default rollout`,
}
type rolloutSource struct {
@@ -264,7 +264,6 @@ func checkComposeVersions(t testing.TB, root string) []composeVersionSource {
{label: "Linux VM module", relativePath: "modules/linux-vm-runtime/variables.tf", expression: `(?ms)variable "docker_compose_version" \{.*?default\s*=\s*"([^"]+)"`},
{label: "Ansible", relativePath: "ansible/roles/cloud_compose/defaults/main.yml", expression: `(?m)^cloud_compose_docker_compose_version:\s*(\S+)\s*$`},
{label: "Salt", relativePath: "salt/cloud-compose/init.sls", expression: `docker\.get\('compose_version',\s*cc\.get\('docker_compose_version',\s*'([^']+)'\)\)`},
- {label: "host installer fallback", relativePath: "rootfs/home/cloud-compose/install-docker-plugins.sh", expression: `DOCKER_COMPOSE_VERSION="\$\{DOCKER_COMPOSE_VERSION:-([^}]+)\}"`},
}
releaseTag := regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
diff --git a/internal/contracttest/template_version_test.go b/internal/contracttest/template_version_test.go
index 4b7245c..40d227b 100644
--- a/internal/contracttest/template_version_test.go
+++ b/internal/contracttest/template_version_test.go
@@ -30,35 +30,35 @@ func TestTemplateVersionContract(t *testing.T) {
expectedVersions := map[string]map[string]string{
"default": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
},
"archivesspace": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-archivesspace": "v2.1.1",
},
"drupal": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-drupal": "v1.5.0",
},
"isle": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-drupal": "v1.5.0",
"sitectl-isle": "v1.6.0",
},
"ojs": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-ojs": "v1.4.0",
},
"omeka-classic": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-omeka-classic": "v1.4.0",
},
"omeka-s": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-omeka-s": "v1.4.0",
},
"wp": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-wp": "v2.1.0",
},
}
@@ -146,7 +146,7 @@ func TestTemplateVersionContract(t *testing.T) {
for _, packageName := range []string{"sitectl", "sitectl-wp", "sitectl-drupal"} {
requireContains(t, content, packageName, relativePath+" bin-pack package")
}
- requireContains(t, content, `sitectl = "v1.9.1"`, relativePath+" bin-pack core selector")
+ requireContains(t, content, `sitectl = "v1.12.7"`, relativePath+" bin-pack core selector")
requireContains(t, content, `sitectl-wp = "v2.1.0"`, relativePath+" bin-pack WordPress selector")
requireContains(t, content, `sitectl-drupal = "v1.5.0"`, relativePath+" bin-pack Drupal selector")
}
diff --git a/main.tf b/main.tf
index d11f765..8c5b638 100644
--- a/main.tf
+++ b/main.tf
@@ -163,8 +163,6 @@ module "gcp" {
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
diff --git a/modules/digitalocean/main.tf b/modules/digitalocean/main.tf
index 663bee2..b4ae53d 100644
--- a/modules/digitalocean/main.tf
+++ b/modules/digitalocean/main.tf
@@ -13,36 +13,33 @@ 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
- rootfs_test_source_archive_prefix = local.runtime.rootfs_test_source_archive_prefix
- offhost_backup_required = local.runtime.disaster_recovery.required
- offhost_backup_driver_path = local.runtime.disaster_recovery.driver_path
- ingress_port = local.compose.ingress_port
- primary_compose_project = local.compose.primary
- sitectl_ingress = local.compose.ingress
- docker_compose_repo = local.compose.repo
- docker_compose_branch = local.compose.branch
- compose_projects = local.compose.projects
- docker_compose_init = local.compose.init
- docker_compose_up = local.compose.up
- docker_compose_down = local.compose.down
- docker_compose_rollout = local.compose.rollout
- rollout_enabled = local.do.rollout.enabled
- rollout_release_url = local.do.rollout.release_url
- rollout_release_sha256 = local.do.rollout.release_sha256
- rollout_port = local.do.rollout.port
- rollout_jwks_uri = local.do.rollout.jwks_uri
- rollout_jwt_audience = local.do.rollout.jwt_audience
- rollout_custom_claims = local.do.rollout.custom_claims
+ 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
+ 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
@@ -120,12 +117,8 @@ resource "digitalocean_droplet" "cloud_compose" {
lifecycle {
precondition {
- # DigitalOcean accepts at most 64 KiB of raw user_data. Bounding the
- # base64 representation at 87,380 characters conservatively caps the
- # UTF-8 payload at 65,535 bytes without treating Unicode code points as
- # bytes. Archive mode keeps the bootstrap comfortably below this limit.
condition = length(base64encode(module.runtime.cloud_init)) <= 87380
- error_message = "DigitalOcean Droplet user_data is limited to 64 KiB. Set runtime.rootfs_archive_url and runtime.rootfs_archive_sha256 so the verified cloud-compose rootfs is fetched during boot instead of embedded in cloud-init."
+ error_message = "DigitalOcean Droplet user_data must stay below 64 KiB. Reduce unusually large SSH or runtime inputs."
}
precondition {
condition = alltrue([
diff --git a/modules/digitalocean/runtime_inputs.tftest.hcl b/modules/digitalocean/runtime_inputs.tftest.hcl
index 61ebac1..e33824a 100644
--- a/modules/digitalocean/runtime_inputs.tftest.hcl
+++ b/modules/digitalocean/runtime_inputs.tftest.hcl
@@ -22,9 +22,6 @@ run "merges_provider_neutral_and_provider_specific_ssh_users" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
users = {
shared = ["ssh-ed25519 AAAARUNTIME"]
runtime-only = ["ssh-ed25519 AAAANEUTRAL"]
@@ -52,9 +49,6 @@ run "exposes_independent_sitectl_package_versions" {
variables {
name = "do-contract"
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/isle.git"
}
@@ -85,9 +79,6 @@ run "rejects_reserved_extra_environment" {
variables {
name = "do-contract"
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
diff --git a/modules/digitalocean/variables.tf b/modules/digitalocean/variables.tf
index 2def7c6..d9a84ce 100644
--- a/modules/digitalocean/variables.tf
+++ b/modules/digitalocean/variables.tf
@@ -71,12 +71,8 @@ variable "digitalocean" {
variable "runtime" {
description = "Provider-neutral compose/runtime settings."
type = object({
- rootfs = optional(string, "")
- rootfs_archive_url = optional(string, "")
- rootfs_archive_sha256 = optional(string, "")
- # Internal hosted-smoke escape hatch. Public provider entrypoints must not expose it.
- rootfs_test_source_archive_prefix = optional(string, "")
- users = optional(map(list(string)), {})
+ rootfs = optional(string, "")
+ users = optional(map(list(string)), {})
disaster_recovery = optional(object({
required = optional(bool, false)
@@ -183,14 +179,6 @@ variable "runtime" {
})
default = {}
- validation {
- condition = (
- (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") &&
- (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256))))
- )
- 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)) &&
diff --git a/modules/gcp/main.tf b/modules/gcp/main.tf
index 64efa0e..f4f174c 100644
--- a/modules/gcp/main.tf
+++ b/modules/gcp/main.tf
@@ -22,14 +22,9 @@ terraform {
}
locals {
- rootFs = "${path.module}/../../rootfs"
- additional_rootfs = var.rootfs != "" ? var.rootfs : ""
- rootfs_archive_url = trimspace(var.rootfs_archive_url)
- rootfs_archive_sha256 = lower(trimspace(var.rootfs_archive_sha256))
- rootfs_archive_asset_url = split("#", split("?", local.rootfs_archive_url)[0])[0]
- rootfs_contract_sidecar_url = local.rootfs_archive_url == "" ? "" : replace(local.rootfs_archive_asset_url, "/[^/]+$/", "cloud-compose-rootfs.contract.sha256")
- rootfs_overlay_staging_path = "/var/lib/cloud-compose/rootfs-overlay"
- project_number = tostring(data.google_project.service.number)
+ rootFs = "${path.module}/../../rootfs"
+ additional_rootfs = var.rootfs != "" ? var.rootfs : ""
+ project_number = tostring(data.google_project.service.number)
single_compose_project = {
(var.name) = {
@@ -216,22 +211,7 @@ locals {
selected_subnetwork_project_matches = local.network_project_id == local.subnetwork_project_id
selected_subnetwork_region_matches = local.subnetwork_region == var.region
- # Archive mode fetches the packaged base rootfs at boot. A content contract
- # binds that archive to this exact module source before anything is copied.
- rootfs_contract_files = sort(tolist(fileset(local.rootFs, "**")))
- rootfs_contract_directories = sort(distinct(flatten([
- for file in local.rootfs_contract_files : dirname(file) == "." ? [] : [
- for index in range(length(split("/", dirname(file)))) :
- join("/", slice(split("/", dirname(file)), 0, index + 1))
- ]
- ])))
- rootfs_contract_sha256 = sha256(join("", [
- for entry in concat(
- [for directory in local.rootfs_contract_directories : "d\t0:0:755\t${directory}\n"],
- [for file in local.rootfs_contract_files : "f\t${filesha256("${local.rootFs}/${file}")}\t0:0:${endswith(file, ".sh") ? "755" : "644"}\t${file}\n"],
- ) : entry
- ]))
- base_files = local.rootfs_archive_url == "" ? fileset(local.rootFs, "**") : []
+ base_files = fileset(local.rootFs, "**")
# Get files from additional rootfs if path is provided
additional_files = local.additional_rootfs != "" ? fileset(local.additional_rootfs, "**") : []
@@ -246,25 +226,25 @@ locals {
},
{
for file in local.additional_files : file => {
- destination = local.rootfs_archive_url != "" ? "${local.rootfs_overlay_staging_path}/${file}" : "/${file}"
+ destination = "/${file}"
source = "${local.additional_rootfs}/${file}"
}
}
)
rootfs_file_permissions = {
for file in setunion(local.base_files, local.additional_files) :
- file => endswith(file, ".sh") || "/${file}" == var.offhost_backup_driver_path ? "0755" : "0644"
+ file => endswith(file, ".sh") || endswith(file, ".py") || "/${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)}
- owner: "root:root"
- permissions: ${jsonencode(local.rootfs_file_permissions[file])}
- encoding: gzip+base64
- content: ${jsonencode(base64gzip(file(config.source)))}
-EOT
- ])
+ rootfs_bundle_content = jsonencode({
+ version = 1
+ files = {
+ for file, config in local.all_files : file => {
+ mode = local.rootfs_file_permissions[file]
+ content = file(config.source)
+ }
+ }
+ })
docker_compose_scripts = join("\n", [
for name in ["init", "up", "down", "rollout"] : <<-EOT
- path: "/home/cloud-compose/${name}"
@@ -446,37 +426,26 @@ EOT
use_overlay = length(var.volume_names) > 0
prod_disk_url = var.overlay_source_instance != "" ? format("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/disks/%s-docker-volumes", var.project_id, var.zone, var.overlay_source_instance) : ""
gcp_filesystem_boothook = templatefile("${path.module}/../../templates/gcp-filesystem-boothook.sh.tftpl", {
- FILESYSTEM_PREP_SCRIPT_B64 = base64gzip(file("${local.rootFs}/home/cloud-compose/prepare-filesystem.sh")),
- FILESYSTEM_PERSIST_SCRIPT_B64 = base64gzip(file("${local.rootFs}/home/cloud-compose/persist-filesystems.sh")),
- FSTAB_RECONCILE_AWK_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/awk/reconcile-fstab.awk")),
- GCP_FILESYSTEM_BOOT_SCRIPT_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/libexec/gcp-filesystem-boot.sh")),
- FRESH_FILESYSTEM_IDENTITY = "v1:gcp-disk-id:${google_compute_disk.data.disk_id}",
- USE_OVERLAY = local.use_overlay,
+ BOOTSTRAP_SITECTL_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/libexec/bootstrap-sitectl.sh")),
+ SITECTL_VERSION = module.sitectl_runtime.package_versions["sitectl"],
+ FRESH_FILESYSTEM_IDENTITY = "v1:gcp-disk-id:${google_compute_disk.data.disk_id}",
+ USE_OVERLAY = local.use_overlay,
})
cloud_config_yaml = templatefile("${path.module}/../../templates/cloud-init.yml", {
- ROOTFS_ARCHIVE_SCRIPT_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/libexec/rootfs-archive.sh")),
- GCP_CLOUD_INIT_FINALIZE_SCRIPT_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh")),
- GCP_CLOUD_INIT_POST_SCRIPT_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh")),
- DIAGNOSTICS_SCRIPT_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/bin/cloud-compose-diagnostics.sh")),
- DIAGNOSTICS_SCRIPT_SHA256 = filesha256("${local.rootFs}/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"),
- INIT_COMMANDS_B64 = base64encode(local.initcmd_content),
- RUNCMD_B64 = base64encode(local.runcmd_content),
- WRITE_FILES_CONTENT = local.write_files_content,
- DOCKER_COMPOSE_SCRIPTS = local.docker_compose_scripts,
- COMPOSE_PROJECTS_FILE = local.compose_projects_file,
- ENV_FILE_CONTENT = local.env_file_content,
- APPLICATION_ENV_FILE_CONTENT = local.application_env_file_content,
- VAULT_AGENT_FILES = local.vault_agent_files,
- MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file,
- ROOTFS_ARCHIVE_ENABLED = local.rootfs_archive_url != "",
- ROOTFS_ARCHIVE_URL_B64 = base64encode(local.rootfs_archive_url),
- ROOTFS_ARCHIVE_SHA256 = local.rootfs_archive_sha256,
- ROOTFS_CONTRACT_SHA256 = local.rootfs_contract_sha256,
- ROOTFS_OVERLAY_DIR = local.rootfs_overlay_staging_path,
- ROLLOUT_ENABLED = tostring(var.rollout_enabled),
- POST_BOOTSTRAP_REQUIRED = local.post_bootstrap_required,
- CLOUD_COMPOSE_SSH_KEYS = try(var.users["cloud-compose"], []),
- SSH_USERS = { for username, ssh_keys in var.users : username => ssh_keys if username != "cloud-compose" },
+ ROOTFS_INSTALLER_B64 = base64gzip(file("${local.rootFs}/etc/cloud-compose/libexec/install-rootfs.py")),
+ ROOTFS_BUNDLE_B64 = base64gzip(local.rootfs_bundle_content),
+ INIT_COMMANDS_B64 = base64encode(local.initcmd_content),
+ RUNCMD_B64 = base64encode(local.runcmd_content),
+ DOCKER_COMPOSE_SCRIPTS = local.docker_compose_scripts,
+ COMPOSE_PROJECTS_FILE = local.compose_projects_file,
+ ENV_FILE_CONTENT = local.env_file_content,
+ APPLICATION_ENV_FILE_CONTENT = local.application_env_file_content,
+ VAULT_AGENT_FILES = local.vault_agent_files,
+ MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file,
+ ROLLOUT_ENABLED = tostring(var.rollout_enabled),
+ POST_BOOTSTRAP_REQUIRED = local.post_bootstrap_required,
+ CLOUD_COMPOSE_SSH_KEYS = try(var.users["cloud-compose"], []),
+ SSH_USERS = { for username, ssh_keys in var.users : username => ssh_keys if username != "cloud-compose" },
})
cloud_init_yaml = templatefile("${path.module}/../../templates/gcp-cloud-init.mime.tftpl", {
FILESYSTEM_BOOTHOOK = local.gcp_filesystem_boothook,
@@ -508,24 +477,6 @@ data "google_project" "service" {
project_id = var.project_id
}
-data "http" "rootfs_contract" {
- count = local.rootfs_archive_url != "" ? 1 : 0
-
- url = local.rootfs_contract_sidecar_url
- request_timeout_ms = 30000
-
- lifecycle {
- postcondition {
- condition = (
- self.status_code == 200 &&
- can(regex("^[0-9a-f]{64}\\n?$", self.response_body)) &&
- trimspace(self.response_body) == local.rootfs_contract_sha256
- )
- error_message = "The immutable rootfs release sidecar must contain exactly this module source's canonical rootfs contract digest. Publish or select a matching archive before replacing a VM."
- }
- }
-}
-
data "google_service_account" "vm" {
count = var.service_account_email != "" ? 1 : 0
@@ -877,7 +828,7 @@ resource "google_compute_instance" "cloud-compose" {
lifecycle {
precondition {
condition = length(data.cloudinit_config.ci.part[0].content) <= 245760
- error_message = "GCP user-data must stay within a 240 KiB review budget so the 256 KiB metadata-item limit retains bootstrap headroom. Select a verified rootfs archive or reduce unusually large runtime inputs."
+ error_message = "GCP user-data must stay within a 240 KiB review budget so the 256 KiB metadata-item limit retains bootstrap headroom. Reduce unusually large runtime inputs."
}
precondition {
condition = var.project_number == "" || var.project_number == local.project_number
@@ -931,20 +882,6 @@ resource "google_compute_instance" "cloud-compose" {
condition = replace(var.zone, "/-[a-z]$/", "") == var.region
error_message = "zone must belong to region."
}
- precondition {
- condition = (
- (local.rootfs_archive_url == "") == (local.rootfs_archive_sha256 == "") &&
- (local.rootfs_archive_sha256 == "" || can(regex("^[0-9a-f]{64}$", local.rootfs_archive_sha256)))
- )
- error_message = "rootfs_archive_url and a 64-character rootfs_archive_sha256 must be supplied together."
- }
- precondition {
- condition = (
- local.rootfs_archive_url == "" ||
- trimspace(try(data.http.rootfs_contract[0].response_body, "")) == local.rootfs_contract_sha256
- )
- error_message = "The rootfs archive release contract must match this module before a VM is replaced."
- }
precondition {
condition = contains(keys(local.compose_projects), local.primary_compose_project_key)
error_message = "primary_compose_project must be one of the compose_projects keys."
diff --git a/modules/gcp/runtime_contracts.tftest.hcl b/modules/gcp/runtime_contracts.tftest.hcl
index 5192190..a8bc1a7 100644
--- a/modules/gcp/runtime_contracts.tftest.hcl
+++ b/modules/gcp/runtime_contracts.tftest.hcl
@@ -102,19 +102,14 @@ run "disables_privileged_services_by_default" {
) &&
strcontains(
local.cloud_init_yaml,
- base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh")),
- ) &&
- strcontains(
- local.cloud_init_yaml,
- base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/awk/reconcile-fstab.awk")),
- ) &&
- strcontains(
- local.write_files_content,
- "- path: \"/etc/cloud-compose/jq/compose-validate-projects.jq\"",
+ base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/libexec/bootstrap-sitectl.sh")),
) &&
+ strcontains(local.cloud_init_yaml, "host filesystems") &&
+ strcontains(local.cloud_init_yaml, module.sitectl_runtime.package_versions["sitectl"]) &&
+ contains(keys(jsondecode(local.rootfs_bundle_content).files), "etc/cloud-compose/libexec/install-rootfs.py") &&
strcontains(local.cloud_init_yaml, "Content-Type: text/cloud-boothook")
)
- error_message = "GCP's early every-boot filesystem programs and root runtime environment must carry the same immutable data-disk identity and checked fstab reconciler."
+ error_message = "GCP's early every-boot filesystem program must bootstrap pinned sitectl and carry the immutable data-disk identity."
}
assert {
@@ -557,7 +552,7 @@ run "rejects_fractional_compose_project_port" {
expect_failures = [var.compose_projects]
}
-run "renders_verified_archive_before_downstream_overlay" {
+run "renders_rootfs_bundle_before_application_initialization" {
command = plan
variables {
@@ -566,8 +561,6 @@ run "renders_verified_archive_before_downstream_overlay" {
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"
@@ -577,39 +570,28 @@ run "renders_verified_archive_before_downstream_overlay" {
}
}
- override_data {
- target = data.http.rootfs_contract[0]
- values = {
- response_body = "4517a8a8a1a40a5e9d3f1ea2b18092216279c2bf85a0e3217486d27753bd0d65\n"
- status_code = 200
- }
- }
-
assert {
condition = (
strcontains(
local.cloud_init_yaml,
- base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/libexec/rootfs-archive.sh")),
+ base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/libexec/install-rootfs.py")),
) &&
- strcontains(local.cloud_init_yaml, local.rootfs_contract_sha256) &&
- data.http.rootfs_contract[0].url == "https://example.invalid/cloud-compose-rootfs.contract.sha256" &&
- strcontains(local.cloud_init_yaml, base64encode(var.rootfs_archive_url)) &&
- !strcontains(local.cloud_init_yaml, var.rootfs_archive_url)
+ strcontains(local.cloud_init_yaml, base64gzip(local.rootfs_bundle_content))
)
- error_message = "The GCP archive program must be transferred as a checked file and receive literal URL data plus the exact module rootfs contract."
+ error_message = "GCP cloud-init must transfer the checked rootfs installer and complete bundle."
}
assert {
condition = (
- strcontains(local.cloud_config_yaml, "runcmd:\n- [bash, /var/lib/cloud-compose/bootstrap/rootfs-archive.sh, install") &&
- strcontains(local.cloud_config_yaml, "\n- [bash, /var/lib/cloud-compose/bootstrap/gcp-cloud-init-finalize.sh")
+ strcontains(local.cloud_config_yaml, "runcmd:\n- [python3, /var/lib/cloud-compose/bootstrap/install-rootfs.py") &&
+ strcontains(local.cloud_config_yaml, "\n- [bash, /etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh")
)
- error_message = "The verified base archive and downstream overlay must be installed before GCP application initialization."
+ error_message = "The rootfs bundle must be installed before GCP application initialization."
}
assert {
condition = (
- strcontains(local.cloud_init_yaml, "/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose-overlay-marker") &&
+ jsondecode(local.rootfs_bundle_content).files["etc/cloud-compose-overlay-marker"].mode == "0644" &&
!strcontains(module.runtime_env.content, "NGINX_CLIENT_MAX_BODY_SIZE") &&
!strcontains(module.runtime_env.content, "PHP_UPLOAD_MAX_FILESIZE") &&
!contains(keys(local.host_env), "BASH_ENV") &&
@@ -624,69 +606,10 @@ run "renders_verified_archive_before_downstream_overlay" {
assert {
condition = (
- local.rootfs_file_permissions["etc/cloud-compose/libexec/custom-offhost-driver"] == "0755" &&
- local.rootfs_file_permissions["etc/cloud-compose-overlay-marker"] == "0644" &&
- strcontains(
- local.write_files_content,
- "- path: \"/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose/libexec/custom-offhost-driver\"\n owner: \"root:root\"\n permissions: \"0755\"",
- ) &&
- strcontains(
- local.write_files_content,
- "- path: \"/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose-overlay-marker\"\n owner: \"root:root\"\n permissions: \"0644\"",
- )
+ jsondecode(local.rootfs_bundle_content).files["etc/cloud-compose/libexec/custom-offhost-driver"].mode == "0755" &&
+ jsondecode(local.rootfs_bundle_content).files["etc/cloud-compose-overlay-marker"].mode == "0644"
)
- error_message = "Archive-backed GCP overlays must make only the configured off-host backup driver executable."
- }
-}
-
-run "rejects_rootfs_release_from_another_module_version" {
- command = plan
-
- variables {
- name = "gcp-contract"
- project_id = "test-project"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- }
-
- override_data {
- target = data.http.rootfs_contract[0]
- values = {
- response_body = "0000000000000000000000000000000000000000000000000000000000000000\n"
- status_code = 200
- }
- }
-
- expect_failures = [data.http.rootfs_contract[0]]
-}
-
-run "renders_embedded_offhost_backup_driver_executable" {
- command = plan
-
- variables {
- name = "gcp-embedded-driver"
- project_id = "test-project"
- project_number = "123456789"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs = "testdata/rootfs"
- offhost_backup_driver_path = "/etc/cloud-compose/libexec/custom-offhost-driver"
- }
-
- assert {
- condition = (
- local.rootfs_file_permissions["etc/cloud-compose/libexec/custom-offhost-driver"] == "0755" &&
- local.rootfs_file_permissions["etc/cloud-compose-overlay-marker"] == "0644" &&
- strcontains(
- local.write_files_content,
- "- path: \"/etc/cloud-compose/libexec/custom-offhost-driver\"\n owner: \"root:root\"\n permissions: \"0755\"",
- ) &&
- strcontains(
- local.write_files_content,
- "- path: \"/etc/cloud-compose-overlay-marker\"\n owner: \"root:root\"\n permissions: \"0644\"",
- )
- )
- error_message = "Embedded GCP overlays must make only the configured off-host backup driver executable."
+ error_message = "GCP rootfs overlays must make only the configured off-host backup driver executable."
}
}
@@ -750,44 +673,6 @@ run "rejects_insecure_rollout_trust_inputs" {
]
}
-run "rejects_archive_without_checksum" {
- 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"
- }
-
- override_data {
- target = data.http.rootfs_contract[0]
- values = {
- response_body = "4517a8a8a1a40a5e9d3f1ea2b18092216279c2bf85a0e3217486d27753bd0d65\n"
- status_code = 200
- }
- }
-
- expect_failures = [google_compute_instance.cloud-compose]
-}
-
-run "rejects_non_https_rootfs_archive" {
- command = plan
-
- variables {
- name = "gcp-contract"
- project_id = "test-project"
- project_number = "123456789"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs_archive_url = "http://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- }
-
- expect_failures = [var.rootfs_archive_url]
-}
-
run "supports_network_only_direct_vpc" {
command = plan
diff --git a/modules/gcp/variables.tf b/modules/gcp/variables.tf
index f9f33f6..77ea1d4 100644
--- a/modules/gcp/variables.tf
+++ b/modules/gcp/variables.tf
@@ -262,7 +262,7 @@ variable "docker_compose_branch" {
variable "docker_compose_init" {
type = list(string)
default = [
- "/home/cloud-compose/default-lifecycle.sh init"
+ "sitectl:default init"
]
nullable = false
description = "After cloning the docker compose git repo, any initialization that needs to happen before the docker compose project can start. One command per list value"
@@ -271,7 +271,7 @@ variable "docker_compose_init" {
variable "docker_compose_up" {
type = list(string)
default = [
- "/home/cloud-compose/default-lifecycle.sh up"
+ "sitectl:default up"
]
nullable = false
description = "Command to start the docker compose project"
@@ -280,7 +280,7 @@ variable "docker_compose_up" {
variable "docker_compose_down" {
type = list(string)
default = [
- "/home/cloud-compose/default-lifecycle.sh down"
+ "sitectl:default down"
]
nullable = false
description = "Command to stop the docker compose project"
@@ -289,7 +289,7 @@ variable "docker_compose_down" {
variable "docker_compose_rollout" {
type = list(string)
default = [
- "/home/cloud-compose/default-lifecycle.sh rollout"
+ "sitectl:default rollout"
]
nullable = false
description = "Commands used by rollout triggers. A validated GIT_COMMIT_SHA takes precedence over GIT_REF/GIT_BRANCH; without one, sitectl reconciles the current checkout."
@@ -583,26 +583,6 @@ variable "rootfs" {
description = "Path to additional rootfs files to copy into the VM. Files will be merged with the base rootfs. Example: '/path/to/custom/rootfs'"
}
-variable "rootfs_archive_url" {
- type = string
- default = ""
- description = "Optional immutable HTTPS cloud-compose rootfs release archive to fetch during boot instead of embedding the packaged rootfs. Must be set with rootfs_archive_sha256; planning also requires the adjacent cloud-compose-rootfs.contract.sha256 asset to match this module source."
-
- validation {
- condition = (
- trimspace(var.rootfs_archive_url) == "" ||
- can(regex("^https://[^[:space:]]+$", trimspace(var.rootfs_archive_url)))
- )
- error_message = "rootfs_archive_url must be empty or an HTTPS URL without whitespace."
- }
-}
-
-variable "rootfs_archive_sha256" {
- type = string
- default = ""
- description = "Required 64-character SHA-256 checksum when rootfs_archive_url is set."
-}
-
variable "offhost_backup_required" {
type = bool
default = false
diff --git a/modules/linode/main.tf b/modules/linode/main.tf
index 6c219bd..9ed505a 100644
--- a/modules/linode/main.tf
+++ b/modules/linode/main.tf
@@ -16,36 +16,33 @@ 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
- rootfs_test_source_archive_prefix = local.runtime.rootfs_test_source_archive_prefix
- offhost_backup_required = local.runtime.disaster_recovery.required
- offhost_backup_driver_path = local.runtime.disaster_recovery.driver_path
- ingress_port = local.compose.ingress_port
- primary_compose_project = local.compose.primary
- sitectl_ingress = local.compose.ingress
- docker_compose_repo = local.compose.repo
- docker_compose_branch = local.compose.branch
- compose_projects = local.compose.projects
- docker_compose_init = local.compose.init
- docker_compose_up = local.compose.up
- docker_compose_down = local.compose.down
- docker_compose_rollout = local.compose.rollout
- rollout_enabled = local.linode.rollout.enabled
- rollout_release_url = local.linode.rollout.release_url
- rollout_release_sha256 = local.linode.rollout.release_sha256
- rollout_port = local.linode.rollout.port
- rollout_jwks_uri = local.linode.rollout.jwks_uri
- rollout_jwt_audience = local.linode.rollout.jwt_audience
- rollout_custom_claims = local.linode.rollout.custom_claims
+ 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
+ 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
@@ -114,8 +111,8 @@ resource "linode_instance" "cloud_compose" {
error_message = "Let's Encrypt ingress requires ingress.domain and ingress.acme_email for each enabled compose project."
}
precondition {
- condition = length(base64gzip(module.runtime.cloud_init)) <= 16384
- error_message = "Linode metadata.user_data is limited to 16 KiB after gzip and base64 encoding. Use a verified runtime.rootfs_archive_url for the managed rootfs and reduce unusually large SSH or runtime inputs."
+ condition = ceil(length(base64gzip(module.runtime.cloud_init)) * 3 / 4) <= 16384
+ error_message = "Linode metadata.user_data must decode to no more than 16 KiB. Reduce unusually large SSH or runtime inputs."
}
precondition {
condition = !local.vault.agent_enabled || trimspace(local.vault.addr) != ""
diff --git a/modules/linode/runtime_inputs.tftest.hcl b/modules/linode/runtime_inputs.tftest.hcl
index 6f0b6ef..d4881ac 100644
--- a/modules/linode/runtime_inputs.tftest.hcl
+++ b/modules/linode/runtime_inputs.tftest.hcl
@@ -25,9 +25,6 @@ run "merges_provider_neutral_and_provider_specific_ssh_users" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
users = {
shared = ["ssh-ed25519 AAAARUNTIME"]
runtime-only = ["ssh-ed25519 AAAANEUTRAL"]
@@ -62,9 +59,6 @@ run "rejects_multiline_authorized_key" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
@@ -85,9 +79,6 @@ run "rejects_unsafe_authorized_username" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
@@ -117,9 +108,6 @@ run "rejects_public_rollout_listener" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
@@ -129,23 +117,6 @@ run "rejects_public_rollout_listener" {
expect_failures = [var.linode]
}
-run "rejects_archive_without_checksum" {
- command = plan
-
- variables {
- name = "contract-test"
- runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
- compose = {
- repo = "https://github.com/libops/wp.git"
- }
- }
- }
-
- expect_failures = [var.runtime]
-}
-
run "exposes_independent_sitectl_package_versions" {
command = plan
@@ -157,9 +128,6 @@ run "exposes_independent_sitectl_package_versions" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/isle.git"
}
@@ -195,9 +163,6 @@ run "rejects_reserved_extra_environment" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
}
@@ -210,7 +175,7 @@ run "rejects_reserved_extra_environment" {
expect_failures = [var.runtime]
}
-run "archive_bootstrap_fits_linode_metadata_limit" {
+run "bundled_bootstrap_fits_linode_metadata_limit" {
command = plan
variables {
@@ -229,9 +194,6 @@ run "archive_bootstrap_fits_linode_metadata_limit" {
}
}
runtime = {
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
compose = {
repo = "https://github.com/libops/wp.git"
branch = "main"
@@ -260,15 +222,7 @@ run "archive_bootstrap_fits_linode_metadata_limit" {
}
assert {
- condition = length(base64gzip(module.runtime.cloud_init)) <= 16384
- error_message = "A realistic archive-backed Linode bootstrap must fit within the 16 KiB encoded metadata limit."
- }
-
- assert {
- condition = (
- !strcontains(module.runtime.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/prepare-filesystem.sh")) &&
- !strcontains(module.runtime.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/persist-filesystems.sh"))
- )
- error_message = "The realistic Linode bootstrap must not regain embedded filesystem helper payloads."
+ condition = ceil(length(base64gzip(module.runtime.cloud_init)) * 3 / 4) <= 16384
+ error_message = "A realistic bundled Linode bootstrap must fit within the 16 KiB decoded metadata limit."
}
}
diff --git a/modules/linode/variables.tf b/modules/linode/variables.tf
index 71fed27..dd4e873 100644
--- a/modules/linode/variables.tf
+++ b/modules/linode/variables.tf
@@ -85,12 +85,8 @@ variable "linode" {
variable "runtime" {
description = "Provider-neutral compose/runtime settings."
type = object({
- rootfs = optional(string, "")
- rootfs_archive_url = optional(string, "")
- rootfs_archive_sha256 = optional(string, "")
- # Internal hosted-smoke escape hatch. Public provider entrypoints must not expose it.
- rootfs_test_source_archive_prefix = optional(string, "")
- users = optional(map(list(string)), {})
+ rootfs = optional(string, "")
+ users = optional(map(list(string)), {})
disaster_recovery = optional(object({
required = optional(bool, false)
@@ -197,14 +193,6 @@ variable "runtime" {
})
default = {}
- validation {
- condition = (
- (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") &&
- (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256))))
- )
- 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)) &&
diff --git a/modules/linux-vm-runtime/main.tf b/modules/linux-vm-runtime/main.tf
index 375ddd7..e47ac4f 100644
--- a/modules/linux-vm-runtime/main.tf
+++ b/modules/linux-vm-runtime/main.tf
@@ -1,12 +1,6 @@
locals {
- rootfs = "${path.module}/../../rootfs"
- additional_rootfs = var.rootfs != "" ? var.rootfs : ""
- rootfs_archive_url = trimspace(var.rootfs_archive_url)
- rootfs_archive_sha256 = lower(trimspace(var.rootfs_archive_sha256))
- rootfs_test_source_archive_prefix = trimspace(var.rootfs_test_source_archive_prefix)
- rootfs_archive_asset_url = split("#", split("?", local.rootfs_archive_url)[0])[0]
- rootfs_contract_sidecar_url = local.rootfs_archive_url == "" || local.rootfs_test_source_archive_prefix != "" ? "" : replace(local.rootfs_archive_asset_url, "/[^/]+$/", "cloud-compose-rootfs.contract.sha256")
- rootfs_overlay_staging_path = "/var/lib/cloud-compose/rootfs-overlay"
+ rootfs = "${path.module}/../../rootfs"
+ additional_rootfs = var.rootfs != "" ? var.rootfs : ""
single_compose_project = {
(var.name) = {
@@ -86,20 +80,21 @@ locals {
flatten([for _, app in local.compose_projects : app.sitectl_packages])
))
- rootfs_contract_files = sort(tolist(fileset(local.rootfs, "**")))
- rootfs_contract_directories = sort(distinct(flatten([
- for file in local.rootfs_contract_files : dirname(file) == "." ? [] : [
- for index in range(length(split("/", dirname(file)))) :
- join("/", slice(split("/", dirname(file)), 0, index + 1))
- ]
- ])))
- rootfs_contract_sha256 = sha256(join("", [
- for entry in concat(
- [for directory in local.rootfs_contract_directories : "d\t0:0:755\t${directory}\n"],
- [for file in local.rootfs_contract_files : "f\t${filesha256("${local.rootfs}/${file}")}\t0:0:${endswith(file, ".sh") ? "755" : "644"}\t${file}\n"],
- ) : entry
- ]))
- base_files = local.rootfs_archive_url == "" ? fileset(local.rootfs, "**") : []
+ gcp_only_files = toset([
+ "etc/cloud-compose/libexec/build-cos-make.sh",
+ "etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh",
+ "etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh",
+ "etc/cloud-compose/libexec/gcp-filesystem-boot.sh",
+ "etc/fluent-bit/fluent-bit.conf",
+ "etc/systemd/system/cloud-compose-internal-services.service",
+ "etc/systemd/system/cloud-compose-internal-services.timer",
+ "home/cloud-compose/install-dependencies-cos.sh",
+ "mnt/disks/data/libops-internal/docker-compose.yaml",
+ ])
+ base_files = setsubtract(
+ fileset(local.rootfs, "**"),
+ var.provider_name == "gcp" ? toset([]) : local.gcp_only_files,
+ )
additional_files = local.additional_rootfs != "" ? fileset(local.additional_rootfs, "**") : []
all_files = merge(
{ for file in local.base_files : file => "${local.rootfs}/${file}" },
@@ -107,18 +102,18 @@ 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"
+ file => endswith(file, ".sh") || endswith(file, ".py") || "/${file}" == var.offhost_backup_driver_path ? "0755" : "0644"
}
- write_files_content = join("\n", [
- for file, fullpath in local.all_files : <<-EOT
- - path: ${jsonencode(local.rootfs_archive_url != "" ? "${local.rootfs_overlay_staging_path}/${file}" : startswith(file, "mnt/disks/") ? "/var/lib/cloud-compose/mounted-rootfs/${file}" : "/${file}")}
- owner: "root:root"
- permissions: ${jsonencode(local.rootfs_file_permissions[file])}
- encoding: gzip+base64
- content: ${jsonencode(base64gzip(file(fullpath)))}
-EOT
- ])
+ rootfs_bundle_content = jsonencode({
+ version = 1
+ files = {
+ for file, fullpath in local.all_files : file => {
+ mode = local.rootfs_file_permissions[file]
+ content = file(fullpath)
+ }
+ }
+ })
docker_compose_scripts = join("\n", [
for name in ["init", "up", "down", "rollout"] : <<-EOT
@@ -275,49 +270,27 @@ EOT
content: ${jsonencode(base64gzip(jsonencode(var.extra_env)))}
EOT
cloud_init = templatefile("${path.module}/templates/cloud-init.yml", {
- CLOUD_COMPOSE_SSH_KEYS = var.cloud_compose_ssh_keys
- SSH_USERS = var.ssh_users
- ROOTFS_ARCHIVE_SCRIPT_B64 = base64gzip(file("${local.rootfs}/etc/cloud-compose/libexec/rootfs-archive.sh"))
- LINUX_CLOUD_INIT_SCRIPT_B64 = base64gzip(file("${local.rootfs}/etc/cloud-compose/libexec/linux-vm-cloud-init.sh"))
- LINUX_CLOUD_INIT_SCRIPT_SHA256 = filesha256("${local.rootfs}/etc/cloud-compose/libexec/linux-vm-cloud-init.sh")
- DIAGNOSTICS_SCRIPT_B64 = base64gzip(file("${local.rootfs}/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"))
- DIAGNOSTICS_SCRIPT_SHA256 = filesha256("${local.rootfs}/etc/cloud-compose/bin/cloud-compose-diagnostics.sh")
- DATA_DEVICE = var.data_device
- VOLUMES_DEVICE = var.volumes_device
- WRITE_FILES_CONTENT = local.write_files_content
- DOCKER_COMPOSE_SCRIPTS = local.docker_compose_scripts
- COMPOSE_PROJECTS_FILE = local.compose_projects_file
- ENV_FILE_CONTENT = local.env_file_content
- APPLICATION_ENV_FILE_CONTENT = local.application_env_file_content
- VAULT_AGENT_FILES = local.vault_agent_files
- MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file
- ROLLOUT_ENABLED = tostring(var.rollout_enabled)
- ROOTFS_ARCHIVE_ENABLED = local.rootfs_archive_url != ""
- ROOTFS_ARCHIVE_URL_B64 = base64encode(local.rootfs_archive_url)
- ROOTFS_ARCHIVE_SHA256 = local.rootfs_archive_sha256
- ROOTFS_CONTRACT_SHA256 = local.rootfs_contract_sha256
- ROOTFS_TEST_SOURCE_ARCHIVE_PREFIX = local.rootfs_test_source_archive_prefix
+ CLOUD_COMPOSE_SSH_KEYS = var.cloud_compose_ssh_keys
+ SSH_USERS = var.ssh_users
+ ROOTFS_INSTALLER_B64 = base64gzip(file("${local.rootfs}/etc/cloud-compose/libexec/install-rootfs.py"))
+ ROOTFS_BUNDLE_B64 = base64gzip(local.rootfs_bundle_content)
+ LINUX_CLOUD_INIT_SCRIPT_B64 = base64gzip(file("${local.rootfs}/etc/cloud-compose/libexec/linux-vm-cloud-init.sh"))
+ LINUX_CLOUD_INIT_SCRIPT_SHA256 = filesha256("${local.rootfs}/etc/cloud-compose/libexec/linux-vm-cloud-init.sh")
+ DIAGNOSTICS_SCRIPT_B64 = base64gzip(file("${local.rootfs}/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"))
+ DIAGNOSTICS_SCRIPT_SHA256 = filesha256("${local.rootfs}/etc/cloud-compose/bin/cloud-compose-diagnostics.sh")
+ DATA_DEVICE = var.data_device
+ VOLUMES_DEVICE = var.volumes_device
+ DOCKER_COMPOSE_SCRIPTS = local.docker_compose_scripts
+ COMPOSE_PROJECTS_FILE = local.compose_projects_file
+ ENV_FILE_CONTENT = local.env_file_content
+ APPLICATION_ENV_FILE_CONTENT = local.application_env_file_content
+ VAULT_AGENT_FILES = local.vault_agent_files
+ MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file
+ ROLLOUT_ENABLED = tostring(var.rollout_enabled)
+ SITECTL_VERSION = module.sitectl_runtime.package_versions["sitectl"]
})
}
-data "http" "rootfs_contract" {
- count = local.rootfs_archive_url != "" && local.rootfs_test_source_archive_prefix == "" ? 1 : 0
-
- url = local.rootfs_contract_sidecar_url
- request_timeout_ms = 30000
-
- lifecycle {
- postcondition {
- condition = (
- self.status_code == 200 &&
- can(regex("^[0-9a-f]{64}\\n?$", self.response_body)) &&
- trimspace(self.response_body) == local.rootfs_contract_sha256
- )
- error_message = "The immutable rootfs release sidecar must contain exactly this module source's canonical rootfs contract digest. Publish or select a matching archive before replacing a VM."
- }
- }
-}
-
module "sitectl_runtime" {
source = "../sitectl-runtime"
diff --git a/modules/linux-vm-runtime/outputs.tf b/modules/linux-vm-runtime/outputs.tf
index 4b2f973..98278f9 100644
--- a/modules/linux-vm-runtime/outputs.tf
+++ b/modules/linux-vm-runtime/outputs.tf
@@ -3,26 +3,8 @@ output "cloud_init" {
description = "Rendered cloud-init user data."
precondition {
- condition = (
- (local.rootfs_archive_url == "") == (local.rootfs_archive_sha256 == "") &&
- (local.rootfs_archive_sha256 == "" || can(regex("^[0-9a-f]{64}$", local.rootfs_archive_sha256)))
- )
- error_message = "rootfs_archive_url and a 64-character rootfs_archive_sha256 must be supplied together."
- }
- precondition {
- condition = (
- local.rootfs_archive_url == "" ||
- local.rootfs_test_source_archive_prefix != "" ||
- trimspace(try(data.http.rootfs_contract[0].response_body, "")) == local.rootfs_contract_sha256
- )
- error_message = "The rootfs archive release contract must match this module before cloud-init is rendered."
- }
- precondition {
- condition = (
- local.rootfs_test_source_archive_prefix == "" ||
- local.rootfs_archive_url == "https://github.com/libops/cloud-compose/archive/${trimprefix(local.rootfs_test_source_archive_prefix, "cloud-compose-")}.tar.gz"
- )
- error_message = "The test-only rootfs source archive must use the exact libops/cloud-compose commit named by rootfs_test_source_archive_prefix."
+ condition = length(local.cloud_init) <= 65535
+ error_message = "Rendered Cloud Compose user data exceeds the 65,535-byte provider limit; move implementation into sitectl or external documentation."
}
}
diff --git a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl
index 7eac825..1624721 100644
--- a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl
+++ b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl
@@ -37,24 +37,17 @@ run "renders_safe_ssh_values" {
assert {
condition = (
- strcontains(output.cloud_init, "path: \"/etc/cloud-compose/bin/cloud-compose-diagnostics.sh\"") &&
strcontains(output.cloud_init, "NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh state") &&
strcontains(output.cloud_init, "NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh status") &&
strcontains(output.cloud_init, "NOPASSWD:/etc/cloud-compose/bin/cloud-compose-diagnostics.sh dump") &&
- strcontains(
- local.write_files_content,
- "- path: \"/etc/cloud-compose/libexec/linux-vm-cloud-init.sh\"",
- ) &&
- strcontains(
- local.write_files_content,
- "- path: \"/etc/cloud-compose/jq/compose-validate-projects.jq\"",
- ) &&
- strcontains(
- local.write_files_content,
- "- path: \"/etc/cloud-compose/awk/reconcile-fstab.awk\"",
- )
+ contains(keys(jsondecode(local.rootfs_bundle_content).files), "etc/cloud-compose/bin/cloud-compose-diagnostics.sh") &&
+ contains(keys(jsondecode(local.rootfs_bundle_content).files), "etc/cloud-compose/libexec/linux-vm-cloud-init.sh") &&
+ contains(keys(jsondecode(local.rootfs_bundle_content).files), "etc/cloud-compose/libexec/bootstrap-sitectl.sh") &&
+ contains(keys(jsondecode(local.rootfs_bundle_content).files), "etc/cloud-compose/libexec/install-rootfs.py") &&
+ !contains(keys(jsondecode(local.rootfs_bundle_content).files), "home/cloud-compose/prepare-filesystem.sh") &&
+ !contains(keys(jsondecode(local.rootfs_bundle_content).files), "home/cloud-compose/persist-filesystems.sh")
)
- error_message = "Cloud-init must install root-owned diagnostics and checked jq/awk programs with exact passwordless sudo commands."
+ error_message = "Cloud-init must bundle root-owned diagnostics and the sitectl bootstrap with exact passwordless sudo commands."
}
}
@@ -223,7 +216,7 @@ run "rejects_unsafe_ssh_username" {
expect_failures = [var.ssh_users]
}
-run "renders_verified_rootfs_archive" {
+run "renders_bounded_rootfs_bundle" {
command = plan
variables {
@@ -234,8 +227,6 @@ run "renders_verified_rootfs_archive" {
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"
}
@@ -243,271 +234,28 @@ run "renders_verified_rootfs_archive" {
condition = (
strcontains(
output.cloud_init,
- base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/libexec/rootfs-archive.sh")),
+ base64gzip(file("${path.module}/../../rootfs/etc/cloud-compose/libexec/install-rootfs.py")),
) &&
- length(data.http.rootfs_contract) == 1 &&
- strcontains(output.cloud_init, local.rootfs_contract_sha256) &&
- data.http.rootfs_contract[0].url == "https://example.invalid/cloud-compose-rootfs.contract.sha256" &&
- strcontains(output.cloud_init, "[bash, /var/lib/cloud-compose/bootstrap/rootfs-archive.sh, prepare-linux") &&
- strcontains(output.cloud_init, "[bash, /var/lib/cloud-compose/bootstrap/linux-vm-cloud-init.sh")
- )
- error_message = "Archive-mode Linux cloud-init must transfer compressed checked bootstrap programs, bind the archive to the exact module rootfs, and invoke stable paths."
- }
-
- assert {
- condition = (
- !strcontains(
- local.write_files_content,
- base64gzip(file("${path.module}/../../rootfs/home/cloud-compose/prepare-filesystem.sh")),
- ) &&
- !strcontains(
- local.write_files_content,
- base64gzip(file("${path.module}/../../rootfs/home/cloud-compose/persist-filesystems.sh")),
- )
- )
- error_message = "Archive-backed cloud-init must not embed the filesystem helper payloads."
- }
-
- assert {
- condition = (
- strcontains(output.cloud_init, base64encode(var.rootfs_archive_url)) &&
- !strcontains(output.cloud_init, var.rootfs_archive_url)
- )
- 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(
- local.write_files_content,
- "- path: \"/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose/libexec/custom-offhost-driver\"\n owner: \"root:root\"\n permissions: \"0755\"",
- ) &&
- strcontains(
- local.write_files_content,
- "- path: \"/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose/unrelated-config\"\n owner: \"root:root\"\n permissions: \"0644\"",
- )
- )
- error_message = "Archive-backed Linux VM overlays must make only the configured off-host backup driver executable."
- }
-}
-
-run "renders_exact_current_source_archive_for_hosted_smoke" {
- command = plan
-
- variables {
- name = "contract-test"
- provider_name = "linode"
- region = "us-east"
- data_device = "/dev/test-data"
- volumes_device = "/dev/test-volumes"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
- }
-
- assert {
- condition = (
- length(data.http.rootfs_contract) == 0 &&
- strcontains(output.cloud_init, "prepare-linux-test-source") &&
- strcontains(output.cloud_init, jsonencode(var.rootfs_test_source_archive_prefix)) &&
- strcontains(output.cloud_init, local.rootfs_contract_sha256)
+ strcontains(output.cloud_init, base64gzip(local.rootfs_bundle_content)) &&
+ strcontains(output.cloud_init, "[python3, /var/lib/cloud-compose/bootstrap/install-rootfs.py") &&
+ strcontains(output.cloud_init, "[bash, /etc/cloud-compose/libexec/linux-vm-cloud-init.sh")
)
- error_message = "Hosted smoke source-archive mode must skip the unavailable release sidecar while binding the exact source rootfs to this module contract."
- }
-}
-
-run "rejects_source_archive_from_another_commit" {
- command = plan
-
- variables {
- name = "contract-test"
- provider_name = "linode"
- region = "us-east"
- data_device = "/dev/test-data"
- volumes_device = "/dev/test-volumes"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/2222222222222222222222222222222222222222.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
- }
-
- expect_failures = [output.cloud_init]
-}
-
-run "rejects_tag_named_source_archive_prefix" {
- command = plan
-
- variables {
- name = "contract-test"
- provider_name = "linode"
- region = "us-east"
- data_device = "/dev/test-data"
- volumes_device = "/dev/test-volumes"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/refs/tags/v1.2.3.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-v1.2.3"
- }
-
- expect_failures = [var.rootfs_test_source_archive_prefix]
-}
-
-run "rejects_arbitrary_test_source_archive_url" {
- command = plan
-
- variables {
- name = "contract-test"
- provider_name = "linode"
- region = "us-east"
- data_device = "/dev/test-data"
- volumes_device = "/dev/test-volumes"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs_archive_url = "https://example.invalid/1111111111111111111111111111111111111111.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- rootfs_test_source_archive_prefix = "cloud-compose-1111111111111111111111111111111111111111"
- }
-
- expect_failures = [output.cloud_init]
-}
-
-run "rejects_rootfs_release_from_another_module_version" {
- command = plan
-
- variables {
- name = "contract-test"
- provider_name = "linode"
- region = "us-east"
- data_device = "/dev/test-data"
- volumes_device = "/dev/test-volumes"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- }
-
- override_data {
- target = data.http.rootfs_contract[0]
- values = {
- response_body = "0000000000000000000000000000000000000000000000000000000000000000\n"
- status_code = 200
- }
- }
-
- expect_failures = [data.http.rootfs_contract[0]]
-}
-
-run "embeds_filesystem_helpers_without_archive" {
- command = plan
-
- variables {
- name = "contract-test"
- provider_name = "digitalocean"
- region = "nyc3"
- data_device = "/dev/test-data"
- volumes_device = "/dev/test-volumes"
- docker_compose_repo = "https://github.com/libops/wp.git"
- rootfs = "testdata/rootfs"
- offhost_backup_driver_path = "/etc/cloud-compose/libexec/custom-offhost-driver"
+ error_message = "Linux cloud-init must install the checked rootfs bundle before invoking the stable bootstrap path."
}
assert {
condition = (
- strcontains(
- local.write_files_content,
- base64gzip(file("${path.module}/../../rootfs/home/cloud-compose/prepare-filesystem.sh")),
- ) &&
- strcontains(
- local.write_files_content,
- base64gzip(file("${path.module}/../../rootfs/home/cloud-compose/persist-filesystems.sh")),
- ) &&
- !strcontains(local.write_files_content, "/var/lib/cloud-compose/rootfs-overlay/")
+ jsondecode(local.rootfs_bundle_content).files["etc/cloud-compose/libexec/custom-offhost-driver"].mode == "0755" &&
+ jsondecode(local.rootfs_bundle_content).files["etc/cloud-compose/unrelated-config"].mode == "0644" &&
+ jsondecode(local.rootfs_bundle_content).files["etc/cloud-compose/libexec/bootstrap-sitectl.sh"].mode == "0755" &&
+ strcontains(file("${path.module}/../../rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh"), "host filesystems") &&
+ strcontains(output.cloud_init, module.sitectl_runtime.package_versions["sitectl"])
)
- error_message = "Non-archive cloud-init must transfer the checked-in filesystem helpers without using the archive overlay."
+ error_message = "The rootfs bundle must preserve file modes and invoke the pinned sitectl filesystem runtime."
}
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 owner: \"root:root\"\n permissions: \"0755\"",
- ) &&
- strcontains(
- local.write_files_content,
- "- path: \"/etc/cloud-compose/unrelated-config\"\n owner: \"root:root\"\n permissions: \"0644\"",
- )
- )
- error_message = "Embedded Linux VM overlays must make only the configured off-host backup driver executable."
- }
-}
-
-run "rejects_rootfs_archive_without_checksum" {
- 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.tar.gz"
- rootfs_archive_sha256 = ""
+ condition = length(output.cloud_init) <= 65535
+ error_message = "Linux VM cloud-init must stay below the 64 KiB provider limit."
}
-
- expect_failures = [output.cloud_init]
-}
-
-run "rejects_non_https_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 = "http://example.invalid/cloud-compose.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- }
-
- expect_failures = [var.rootfs_archive_url]
-}
-
-run "rejects_rootfs_checksum_without_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 = ""
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- }
-
- expect_failures = [output.cloud_init]
-}
-
-run "rejects_malformed_rootfs_checksum" {
- 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.tar.gz"
- rootfs_archive_sha256 = "not-a-sha256"
- }
-
- expect_failures = [output.cloud_init]
}
diff --git a/modules/linux-vm-runtime/templates/cloud-init.yml b/modules/linux-vm-runtime/templates/cloud-init.yml
index 3d812f5..4c9a427 100644
--- a/modules/linux-vm-runtime/templates/cloud-init.yml
+++ b/modules/linux-vm-runtime/templates/cloud-init.yml
@@ -29,24 +29,16 @@ users:
%{ endfor ~}
write_files:
-- path: /var/lib/cloud-compose/bootstrap/rootfs-archive.sh
+- path: /var/lib/cloud-compose/bootstrap/install-rootfs.py
owner: root:root
permissions: "0700"
encoding: gzip+base64
- content: ${ROOTFS_ARCHIVE_SCRIPT_B64}
-%{ if ROOTFS_ARCHIVE_ENABLED ~}
-- path: /var/lib/cloud-compose/bootstrap/linux-vm-cloud-init.sh
- owner: root:root
- permissions: "0700"
- encoding: gzip+base64
- content: ${LINUX_CLOUD_INIT_SCRIPT_B64}
-%{ endif ~}
-- path: /var/lib/cloud-compose/bootstrap/cloud-compose-diagnostics.sh
+ content: ${ROOTFS_INSTALLER_B64}
+- path: /var/lib/cloud-compose/bootstrap/rootfs.json
owner: root:root
permissions: "0600"
encoding: gzip+base64
- content: ${DIAGNOSTICS_SCRIPT_B64}
-${WRITE_FILES_CONTENT}
+ content: ${ROOTFS_BUNDLE_B64}
${DOCKER_COMPOSE_SCRIPTS}
${COMPOSE_PROJECTS_FILE}
${ENV_FILE_CONTENT}
@@ -55,13 +47,5 @@ ${VAULT_AGENT_FILES}
${MANAGED_RUNTIME_ARTIFACTS_FILE}
runcmd:
-%{ if ROOTFS_ARCHIVE_ENABLED ~}
-%{ if ROOTFS_TEST_SOURCE_ARCHIVE_PREFIX != "" ~}
-- [bash, /var/lib/cloud-compose/bootstrap/rootfs-archive.sh, prepare-linux-test-source, ${jsonencode(ROOTFS_ARCHIVE_URL_B64)}, ${jsonencode(ROOTFS_ARCHIVE_SHA256)}, ${jsonencode(ROOTFS_CONTRACT_SHA256)}, ${jsonencode(LINUX_CLOUD_INIT_SCRIPT_SHA256)}, ${jsonencode(ROOTFS_TEST_SOURCE_ARCHIVE_PREFIX)}]
-%{ else ~}
-- [bash, /var/lib/cloud-compose/bootstrap/rootfs-archive.sh, prepare-linux, ${jsonencode(ROOTFS_ARCHIVE_URL_B64)}, ${jsonencode(ROOTFS_ARCHIVE_SHA256)}, ${jsonencode(ROOTFS_CONTRACT_SHA256)}, ${jsonencode(LINUX_CLOUD_INIT_SCRIPT_SHA256)}]
-%{ endif ~}
-- [bash, /var/lib/cloud-compose/bootstrap/linux-vm-cloud-init.sh, ${jsonencode(DATA_DEVICE)}, ${jsonencode(VOLUMES_DEVICE)}, "true", ${jsonencode(ROLLOUT_ENABLED)}, ${jsonencode(DIAGNOSTICS_SCRIPT_SHA256)}]
-%{ else ~}
-- [bash, /etc/cloud-compose/libexec/linux-vm-cloud-init.sh, ${jsonencode(DATA_DEVICE)}, ${jsonencode(VOLUMES_DEVICE)}, "false", ${jsonencode(ROLLOUT_ENABLED)}, ${jsonencode(DIAGNOSTICS_SCRIPT_SHA256)}]
-%{ endif ~}
+- [python3, /var/lib/cloud-compose/bootstrap/install-rootfs.py, /var/lib/cloud-compose/bootstrap/rootfs.json]
+- [bash, /etc/cloud-compose/libexec/linux-vm-cloud-init.sh, ${jsonencode(DATA_DEVICE)}, ${jsonencode(VOLUMES_DEVICE)}, ${jsonencode(ROLLOUT_ENABLED)}, ${jsonencode(SITECTL_VERSION)}]
diff --git a/modules/linux-vm-runtime/variables.tf b/modules/linux-vm-runtime/variables.tf
index c01d590..effdbe6 100644
--- a/modules/linux-vm-runtime/variables.tf
+++ b/modules/linux-vm-runtime/variables.tf
@@ -76,40 +76,6 @@ variable "rootfs" {
description = "Optional rootfs overlay. Files here override the packaged cloud-compose rootfs by relative path."
}
-variable "rootfs_archive_url" {
- type = string
- default = ""
- description = "Optional immutable HTTPS cloud-compose rootfs release archive to fetch during boot instead of embedding the packaged rootfs in cloud-init. Must be set with rootfs_archive_sha256; planning also requires the adjacent cloud-compose-rootfs.contract.sha256 asset to match this module source."
-
- validation {
- condition = (
- trimspace(var.rootfs_archive_url) == "" ||
- can(regex("^https://[^[:space:]]+$", trimspace(var.rootfs_archive_url)))
- )
- error_message = "rootfs_archive_url must be empty or an HTTPS URL without whitespace."
- }
-}
-
-variable "rootfs_archive_sha256" {
- type = string
- default = ""
- description = "Required 64-character SHA-256 checksum when rootfs_archive_url is set."
-}
-
-variable "rootfs_test_source_archive_prefix" {
- type = string
- default = ""
- description = "Test-only GitHub source-archive prefix for hosted smoke tests of an unreleased exact commit. Empty keeps the production canonical release archive and adjacent sidecar contract mandatory."
-
- validation {
- condition = (
- trimspace(var.rootfs_test_source_archive_prefix) == "" ||
- can(regex("^cloud-compose-[0-9a-f]{40}$", trimspace(var.rootfs_test_source_archive_prefix)))
- )
- error_message = "rootfs_test_source_archive_prefix must be empty or cloud-compose- followed by one exact lowercase 40-character commit SHA."
- }
-}
-
variable "offhost_backup_required" {
type = bool
default = false
@@ -223,7 +189,7 @@ variable "compose_projects" {
variable "docker_compose_init" {
type = list(string)
default = [
- "/home/cloud-compose/default-lifecycle.sh init"
+ "sitectl:default init"
]
nullable = false
description = "Commands run after a compose repository is cloned."
@@ -232,7 +198,7 @@ variable "docker_compose_init" {
variable "docker_compose_up" {
type = list(string)
default = [
- "/home/cloud-compose/default-lifecycle.sh up"
+ "sitectl:default up"
]
nullable = false
description = "Commands used to bring a compose project up."
@@ -241,7 +207,7 @@ variable "docker_compose_up" {
variable "docker_compose_down" {
type = list(string)
default = [
- "/home/cloud-compose/default-lifecycle.sh down"
+ "sitectl:default down"
]
nullable = false
description = "Commands used to stop a compose project."
@@ -250,7 +216,7 @@ variable "docker_compose_down" {
variable "docker_compose_rollout" {
type = list(string)
default = [
- "/home/cloud-compose/default-lifecycle.sh rollout"
+ "sitectl:default rollout"
]
nullable = false
description = "Commands used by rollout triggers. A validated GIT_COMMIT_SHA takes precedence over GIT_REF/GIT_BRANCH; without one, sitectl reconciles the current checkout."
diff --git a/providers/do/template_versions.tftest.hcl b/providers/do/template_versions.tftest.hcl
index 57f3df1..3eaf66e 100644
--- a/providers/do/template_versions.tftest.hcl
+++ b/providers/do/template_versions.tftest.hcl
@@ -3,20 +3,10 @@ mock_provider "digitalocean" {}
run "custom_package_set_merges_only_applicable_template_versions" {
command = plan
- override_data {
- target = module.digitalocean.module.runtime.data.http.rootfs_contract[0]
- values = {
- response_body = "4517a8a8a1a40a5e9d3f1ea2b18092216279c2bf85a0e3217486d27753bd0d65\n"
- status_code = 200
- }
- }
-
variables {
name = "template-versions"
template = "isle"
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
packages = ["sitectl", "sitectl-wp"]
package_versions = {
@@ -49,20 +39,10 @@ run "custom_package_set_merges_only_applicable_template_versions" {
run "explicit_core_only_package_set_disables_template_plugins" {
command = plan
- override_data {
- target = module.digitalocean.module.runtime.data.http.rootfs_contract[0]
- values = {
- response_body = "4517a8a8a1a40a5e9d3f1ea2b18092216279c2bf85a0e3217486d27753bd0d65\n"
- status_code = 200
- }
- }
-
variables {
name = "template-versions"
template = "isle"
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
packages = []
}
@@ -71,7 +51,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
}
error_message = "The DigitalOcean entrypoint must preserve an explicit core-only package set."
}
diff --git a/providers/do/variables.tf b/providers/do/variables.tf
index eab146b..a3834ed 100644
--- a/providers/do/variables.tf
+++ b/providers/do/variables.tf
@@ -82,10 +82,8 @@ variable "digitalocean" {
variable "runtime" {
description = "Provider-neutral compose/runtime settings."
type = object({
- rootfs = optional(string, "")
- rootfs_archive_url = optional(string, "")
- rootfs_archive_sha256 = optional(string, "")
- users = optional(map(list(string)), {})
+ rootfs = optional(string, "")
+ users = optional(map(list(string)), {})
disaster_recovery = optional(object({
required = optional(bool, false)
@@ -192,14 +190,6 @@ variable "runtime" {
})
default = {}
- validation {
- condition = (
- (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") &&
- (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256))))
- )
- 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)) &&
diff --git a/providers/gcp/main.tf b/providers/gcp/main.tf
index 0ff9757..f99d967 100644
--- a/providers/gcp/main.tf
+++ b/providers/gcp/main.tf
@@ -147,8 +147,6 @@ module "gcp" {
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
diff --git a/providers/gcp/template_versions.tftest.hcl b/providers/gcp/template_versions.tftest.hcl
index ee04d8a..05d88e3 100644
--- a/providers/gcp/template_versions.tftest.hcl
+++ b/providers/gcp/template_versions.tftest.hcl
@@ -67,7 +67,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
}
error_message = "The GCP entrypoint must preserve an explicit core-only package set."
}
diff --git a/providers/gcp/variables.tf b/providers/gcp/variables.tf
index df1ef48..4c90ff4 100644
--- a/providers/gcp/variables.tf
+++ b/providers/gcp/variables.tf
@@ -194,10 +194,8 @@ variable "gcp" {
variable "runtime" {
description = "Provider-neutral compose/runtime settings."
type = object({
- rootfs = optional(string, "")
- rootfs_archive_url = optional(string, "")
- rootfs_archive_sha256 = optional(string, "")
- users = optional(map(list(string)), {})
+ rootfs = optional(string, "")
+ users = optional(map(list(string)), {})
disaster_recovery = optional(object({
required = optional(bool, false)
@@ -305,14 +303,6 @@ variable "runtime" {
})
default = {}
- validation {
- condition = (
- (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") &&
- (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256))))
- )
- 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)) &&
diff --git a/providers/linode/template_versions.tftest.hcl b/providers/linode/template_versions.tftest.hcl
index 839c9ae..513b83a 100644
--- a/providers/linode/template_versions.tftest.hcl
+++ b/providers/linode/template_versions.tftest.hcl
@@ -3,14 +3,6 @@ mock_provider "linode" {}
run "custom_package_set_merges_only_applicable_template_versions" {
command = plan
- override_data {
- target = module.linode.module.runtime.data.http.rootfs_contract[0]
- values = {
- response_body = "4517a8a8a1a40a5e9d3f1ea2b18092216279c2bf85a0e3217486d27753bd0d65\n"
- status_code = 200
- }
- }
-
variables {
name = "template-versions"
template = "isle"
@@ -20,8 +12,6 @@ run "custom_package_set_merges_only_applicable_template_versions" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
packages = ["sitectl", "sitectl-wp"]
package_versions = {
@@ -54,14 +44,6 @@ run "custom_package_set_merges_only_applicable_template_versions" {
run "explicit_core_only_package_set_disables_template_plugins" {
command = plan
- override_data {
- target = module.linode.module.runtime.data.http.rootfs_contract[0]
- values = {
- response_body = "4517a8a8a1a40a5e9d3f1ea2b18092216279c2bf85a0e3217486d27753bd0d65\n"
- status_code = 200
- }
- }
-
variables {
name = "template-versions"
template = "isle"
@@ -71,8 +53,6 @@ run "explicit_core_only_package_set_disables_template_plugins" {
}
}
runtime = {
- rootfs_archive_url = "https://example.invalid/cloud-compose-rootfs.tar.gz"
- rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sitectl = {
packages = []
}
@@ -81,7 +61,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
}
error_message = "The Linode entrypoint must preserve an explicit core-only package set."
}
diff --git a/providers/linode/variables.tf b/providers/linode/variables.tf
index 8ab7190..1f5025b 100644
--- a/providers/linode/variables.tf
+++ b/providers/linode/variables.tf
@@ -97,10 +97,8 @@ variable "linode" {
variable "runtime" {
description = "Provider-neutral compose/runtime settings."
type = object({
- rootfs = optional(string, "")
- rootfs_archive_url = optional(string, "")
- rootfs_archive_sha256 = optional(string, "")
- users = optional(map(list(string)), {})
+ rootfs = optional(string, "")
+ users = optional(map(list(string)), {})
disaster_recovery = optional(object({
required = optional(bool, false)
@@ -207,14 +205,6 @@ variable "runtime" {
})
default = {}
- validation {
- condition = (
- (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") &&
- (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256))))
- )
- 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)) &&
diff --git a/rootfs/etc/cloud-compose/awk/compose-secret-files.awk b/rootfs/etc/cloud-compose/awk/compose-secret-files.awk
deleted file mode 100644
index 1fc8827..0000000
--- a/rootfs/etc/cloud-compose/awk/compose-secret-files.awk
+++ /dev/null
@@ -1,18 +0,0 @@
-/^[[:space:]]*services:/ {
- in_secrets = 0
-}
-
-/^[^[:space:]][^:]*:/ {
- if ($0 ~ /^secrets:/) {
- in_secrets = 1
- } else if (in_secrets) {
- in_secrets = 0
- }
-}
-
-in_secrets && /^[[:space:]]*file:[[:space:]]*/ {
- value = $0
- sub(/^[[:space:]]*file:[[:space:]]*/, "", value)
- gsub(/^["']|["']$/, "", value)
- print value
-}
diff --git a/rootfs/etc/cloud-compose/awk/reconcile-fstab.awk b/rootfs/etc/cloud-compose/awk/reconcile-fstab.awk
deleted file mode 100644
index 36b907c..0000000
--- a/rootfs/etc/cloud-compose/awk/reconcile-fstab.awk
+++ /dev/null
@@ -1,20 +0,0 @@
-$0 == begin { managed = 1; next }
-$0 == end { managed = 0; next }
-!managed {
- if (data_provider_mount != "" && $2 == data_provider_mount) {
- if ($1 == data_device) next
- conflict = 1
- }
- if (volumes_provider_mount != "" && $2 == volumes_provider_mount) {
- if ($1 == volumes_device) next
- conflict = 1
- }
- if ($2 == "/mnt/disks/data" ||
- $2 == "/mnt/disks/volumes" ||
- $2 == "/mnt/disks/data/docker/volumes" ||
- $2 == "/mnt/disks/prod-readonly") {
- conflict = 1
- }
- print
-}
-END { if (managed || conflict) exit 42 }
diff --git a/rootfs/etc/cloud-compose/awk/release-checksum-entry.awk b/rootfs/etc/cloud-compose/awk/release-checksum-entry.awk
deleted file mode 100644
index 9b68308..0000000
--- a/rootfs/etc/cloud-compose/awk/release-checksum-entry.awk
+++ /dev/null
@@ -1 +0,0 @@
-$2 == archive { print }
diff --git a/rootfs/etc/cloud-compose/awk/release-checksum.awk b/rootfs/etc/cloud-compose/awk/release-checksum.awk
deleted file mode 100644
index a00540a..0000000
--- a/rootfs/etc/cloud-compose/awk/release-checksum.awk
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- filename = $2
- sub(/^\*/, "", filename)
- if (filename == asset) {
- checksum = $1
- matches++
- }
-}
-END {
- if (matches != 1) {
- exit 1
- }
- print checksum
-}
diff --git a/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh b/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh
index ddbb352..f93281c 100755
--- a/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh
+++ b/rootfs/etc/cloud-compose/bin/cloud-compose-diagnostics.sh
@@ -1,217 +1,3 @@
-#!/bin/bash
-
+#!/usr/bin/env bash
set -euo pipefail
-
-# This program is reached through an exact sudoers command. Never resolve its
-# child commands from a caller-controlled tool directory.
-readonly PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
-export PATH
-
-readonly bootstrap_marker="/var/lib/cloud-compose/bootstrap-complete"
-readonly diagnostics_program="/etc/cloud-compose/bin/cloud-compose-diagnostics.sh"
-readonly jq_program_dir="/etc/cloud-compose/jq"
-readonly process_pattern='[/]home/cloud-compose/run[.]sh|[/]home/cloud-compose/[h]ost-conf[.]sh|[/]home/cloud-compose/[h]ost-init[.]sh|[/]home/cloud-compose/[a]pp-init[.]sh|[/]home/cloud-compose/[i]nstall-dependencies|[a]pt-get|[r]pm-ostree|[d]ocker run|[s]itectl|[g]it clone'
-
-usage() {
- echo "usage: ${diagnostics_program} state|status|dump" >&2
-}
-
-require_root() {
- if ((EUID != 0)); then
- echo "Cloud Compose diagnostics must run as root" >&2
- exit 1
- fi
-}
-
-unit_value() {
- local unit="$1" property="$2" value
-
- value="$(systemctl show --property="$property" --value -- "$unit" 2>/dev/null || true)"
- printf '%s\n' "${value:-unknown}"
-}
-
-bootstrap_state() {
- local bootstrap_load_state bootstrap_active_state bootstrap_sub_state
- local marker_dir_metadata marker_metadata marker_size marker_payload
-
- if [[ -f "$bootstrap_marker" && ! -L "$bootstrap_marker" ]]; then
- marker_dir_metadata="$(stat -c '%u:%g:%a:%F' -- "$(dirname -- "$bootstrap_marker")" 2>/dev/null || true)"
- marker_metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$bootstrap_marker" 2>/dev/null || true)"
- marker_size="$(stat -c '%s' -- "$bootstrap_marker" 2>/dev/null || true)"
- marker_payload=""
- IFS= read -r marker_payload <"$bootstrap_marker" || true
- if [[ "$marker_dir_metadata" == "0:0:755:directory" &&
- "$marker_metadata" == "0:0:644:1:regular file" &&
- "$marker_size" == "6" && "$marker_payload" == "ready" ]]; then
- echo complete
- return 0
- fi
- fi
-
- bootstrap_load_state="$(unit_value cloud-compose-bootstrap.service LoadState)"
- if [[ "$bootstrap_load_state" == "loaded" ]]; then
- bootstrap_active_state="$(unit_value cloud-compose-bootstrap.service ActiveState)"
- bootstrap_sub_state="$(unit_value cloud-compose-bootstrap.service SubState)"
- case "${bootstrap_active_state}:${bootstrap_sub_state}" in
- active:* | activating:* | *:auto-restart)
- echo active
- return 0
- ;;
- esac
- elif [[ "$bootstrap_load_state" == "not-found" ]] &&
- systemctl is-active --quiet cloud-compose.service; then
- # Compatibility with releases that predate the retryable bootstrap
- # unit and durable readiness marker.
- echo complete
- return 0
- fi
-
- if systemctl is-active --quiet cloud-final.service; then
- echo active
- return 0
- fi
- if pgrep -f -- "$process_pattern" >/dev/null; then
- echo active
- return 0
- fi
-
- echo idle
- return 1
-}
-
-unit_heartbeat() {
- local unit main_pid
-
- for unit in cloud-final.service cloud-compose-bootstrap.service cloud-compose.service; do
- echo "--- ${unit} state ---"
- systemctl show --no-pager \
- --property=LoadState \
- --property=ActiveState \
- --property=SubState \
- --property=Result \
- --property=MainPID \
- --property=ExecMainStatus \
- -- "$unit" 2>&1 || true
- main_pid="$(unit_value "$unit" MainPID)"
- if [[ "$main_pid" =~ ^[1-9][0-9]*$ ]]; then
- ps -p "$main_pid" -o pid=,ppid=,stat=,etime=,comm= 2>/dev/null || true
- fi
- done
-
- echo "--- active bootstrap process state ---"
- while IFS= read -r process_id; do
- [[ "$process_id" =~ ^[1-9][0-9]*$ ]] || continue
- ps -p "$process_id" -o pid=,ppid=,stat=,etime=,comm= 2>/dev/null || true
- done < <(pgrep -f -- "$process_pattern" 2>/dev/null || true)
-}
-
-diagnostic_status() {
- local cloud_init_status=0 state
-
- echo "--- Cloud Compose provisioning heartbeat ---"
- date -u '+%Y-%m-%dT%H:%M:%SZ'
- echo "--- cloud-init status ---"
- if command -v cloud-init >/dev/null 2>&1; then
- cloud-init status --long || cloud_init_status=$?
- else
- echo "cloud-init not installed"
- fi
- unit_heartbeat
- state="$(bootstrap_state 2>/dev/null || true)"
- echo "bootstrap-state: ${state:-unknown}"
- return "$cloud_init_status"
-}
-
-tail_regular_file() {
- local label="$1" path="$2" lines="$3"
-
- echo "--- ${label} ---"
- if [[ -f "$path" && ! -L "$path" ]]; then
- tail -n "$lines" -- "$path" 2>&1 || true
- else
- echo "${label} is not present as a regular file"
- fi
-}
-
-dump_compose_state() {
- local manifest="/home/cloud-compose/compose-projects.json"
- local docker_path encoded row app project_dir
-
- echo "--- docker ps ---"
- docker_path="$(command -v docker || true)"
- if [[ -z "$docker_path" ]]; then
- echo "docker is not installed"
- return 0
- fi
- "$docker_path" ps -a 2>&1 || true
-
- echo "--- docker compose project state ---"
- if [[ ! -f "$manifest" || -L "$manifest" ]] ||
- ! jq -e -f "$jq_program_dir/diagnostics-validate-compose-projects.jq" \
- "$manifest" >/dev/null 2>&1; then
- echo "Compose project manifest is unavailable or invalid"
- return 0
- fi
-
- while IFS= read -r encoded; do
- [[ -n "$encoded" ]] || continue
- row="$(printf '%s' "$encoded" | base64 -d)" || continue
- app="$(jq -er -f "$jq_program_dir/diagnostics-entry-app.jq" <<<"$row" 2>/dev/null || true)"
- project_dir="$(jq -er -f "$jq_program_dir/diagnostics-entry-project-dir.jq" <<<"$row" 2>/dev/null || true)"
- if [[ -z "$app" || -z "$project_dir" || ! -d "$project_dir" || -L "$project_dir" ]]; then
- echo "Skipping unavailable or unsafe Compose project: ${app:-unknown}"
- continue
- fi
- echo "--- docker compose ps: ${app} ---"
- # COS mounts /home with noexec. Select the verified plugin copy on the
- # executable data disk instead of allowing Docker to discover the
- # compatibility copy below /home/cloud-compose/.docker.
- runuser -u cloud-compose -- env \
- HOME=/home/cloud-compose \
- DOCKER_CONFIG=/mnt/disks/data/docker-config \
- "$docker_path" compose --project-directory "$project_dir" ps 2>&1 || true
- done < <(jq -r -f "$jq_program_dir/diagnostics-project-entries.jq" "$manifest")
-}
-
-diagnostic_dump() {
- diagnostic_status || true
- tail_regular_file "/var/log/cloud-init-output.log" "/var/log/cloud-init-output.log" 400
- tail_regular_file "/var/log/cloud-init.log" "/var/log/cloud-init.log" 400
- echo "--- cloud-init runcmd ---"
- if [[ -f /var/lib/cloud/instance/scripts/runcmd &&
- ! -L /var/lib/cloud/instance/scripts/runcmd ]]; then
- sed -n '1,240p' /var/lib/cloud/instance/scripts/runcmd 2>&1 || true
- else
- echo "cloud-init runcmd is not present as a regular file"
- fi
- echo "--- cloud-compose bootstrap unit ---"
- journalctl -u cloud-compose-bootstrap --no-pager -n 400 2>&1 || true
- tail_regular_file "legacy cloud-compose bootstrap log" "/home/cloud-compose/run.log" 400
- echo "--- cloud-compose unit ---"
- journalctl -u cloud-compose --no-pager -n 300 2>&1 || true
- echo "--- lifecycle lock permissions ---"
- stat -Lc '%A %a %U:%G %u:%g %n' \
- /run/lock/cloud-compose \
- /run/lock/cloud-compose/lifecycle.lock 2>&1 || true
- dump_compose_state
-}
-
-main() {
- require_root
- if [[ "$#" -ne 1 ]]; then
- usage
- return 2
- fi
-
- case "$1" in
- state) bootstrap_state ;;
- status) diagnostic_status ;;
- dump) diagnostic_dump ;;
- *)
- usage
- return 2
- ;;
- esac
-}
-
-main "$@"
+exec /etc/cloud-compose/libexec/sitectl-host.sh diagnostics "$@"
diff --git a/rootfs/etc/cloud-compose/jq/application-env-validate.jq b/rootfs/etc/cloud-compose/jq/application-env-validate.jq
deleted file mode 100644
index 6c8b533..0000000
--- a/rootfs/etc/cloud-compose/jq/application-env-validate.jq
+++ /dev/null
@@ -1,6 +0,0 @@
-type == "object" and
-all(to_entries[];
- (.key | explode | index(0) == null) and
- (.value | type == "string") and
- (.value | explode | index(0) == null)
-)
diff --git a/rootfs/etc/cloud-compose/jq/array-values-base64.jq b/rootfs/etc/cloud-compose/jq/array-values-base64.jq
deleted file mode 100644
index fec3d58..0000000
--- a/rootfs/etc/cloud-compose/jq/array-values-base64.jq
+++ /dev/null
@@ -1 +0,0 @@
-.[] | @base64
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-array.jq b/rootfs/etc/cloud-compose/jq/compose-app-array.jq
deleted file mode 100644
index bf25de2..0000000
--- a/rootfs/etc/cloud-compose/jq/compose-app-array.jq
+++ /dev/null
@@ -1,6 +0,0 @@
-(.[$app][$field] // []) as $values |
-if ($values | type) != "array" or any($values[]; type != "string") then
- error("invalid string array")
-else
- $values
-end
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-field.jq b/rootfs/etc/cloud-compose/jq/compose-app-field.jq
deleted file mode 100644
index e5aa9b8..0000000
--- a/rootfs/etc/cloud-compose/jq/compose-app-field.jq
+++ /dev/null
@@ -1,6 +0,0 @@
-(.[$app][$field] // "" | tostring) as $value |
-if $value | (explode | index(0) != null) or contains("\n") or contains("\r") then
- error("invalid scalar field")
-else
- $value
-end
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-ingress-array.jq b/rootfs/etc/cloud-compose/jq/compose-app-ingress-array.jq
deleted file mode 100644
index d7dfc3f..0000000
--- a/rootfs/etc/cloud-compose/jq/compose-app-ingress-array.jq
+++ /dev/null
@@ -1,6 +0,0 @@
-(.[$app].ingress[$field] // []) as $values |
-if ($values | type) != "array" or any($values[]; type != "string") then
- error("invalid ingress string array")
-else
- $values
-end
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-ingress-field.jq b/rootfs/etc/cloud-compose/jq/compose-app-ingress-field.jq
deleted file mode 100644
index 8f9424c..0000000
--- a/rootfs/etc/cloud-compose/jq/compose-app-ingress-field.jq
+++ /dev/null
@@ -1,6 +0,0 @@
-(.[$app].ingress[$field] // "" | tostring) as $value |
-if $value | (explode | index(0) != null) or contains("\n") or contains("\r") then
- error("invalid ingress scalar field")
-else
- $value
-end
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-verify-args-json.jq b/rootfs/etc/cloud-compose/jq/compose-app-verify-args-json.jq
deleted file mode 100644
index 272f433..0000000
--- a/rootfs/etc/cloud-compose/jq/compose-app-verify-args-json.jq
+++ /dev/null
@@ -1,8 +0,0 @@
-(.[$app].sitectl_verify_args // []) as $values |
-if ($values | type) != "array" or any($values[];
- type != "string" or (explode | index(0) != null) or contains("\n") or contains("\r")
-) then
- error("invalid verify args")
-else
- $values
-end
diff --git a/rootfs/etc/cloud-compose/jq/compose-app-verify-args.jq b/rootfs/etc/cloud-compose/jq/compose-app-verify-args.jq
deleted file mode 100644
index bb4a258..0000000
--- a/rootfs/etc/cloud-compose/jq/compose-app-verify-args.jq
+++ /dev/null
@@ -1,6 +0,0 @@
-(.[$app].sitectl_verify_args // []) as $values |
-if ($values | type) != "array" or any($values[]; type != "string") then
- error("invalid verify args")
-else
- $values | join(" ")
-end
diff --git a/rootfs/etc/cloud-compose/jq/compose-project-dirs-base64.jq b/rootfs/etc/cloud-compose/jq/compose-project-dirs-base64.jq
deleted file mode 100644
index f9f67fe..0000000
--- a/rootfs/etc/cloud-compose/jq/compose-project-dirs-base64.jq
+++ /dev/null
@@ -1 +0,0 @@
-.[] | .project_dir | @base64
diff --git a/rootfs/etc/cloud-compose/jq/compose-reject-host-network.jq b/rootfs/etc/cloud-compose/jq/compose-reject-host-network.jq
deleted file mode 100644
index c8d9c8d..0000000
--- a/rootfs/etc/cloud-compose/jq/compose-reject-host-network.jq
+++ /dev/null
@@ -1,12 +0,0 @@
-any(.services[];
- (.network_mode // "") == "host" or
- (
- (.build | type) == "object" and
- (
- (.build.network // "") == "host" or
- any((.build.entitlements // [])[];
- . == "network.host" or . == "security.insecure"
- )
- )
- )
-)
diff --git a/rootfs/etc/cloud-compose/jq/compose-services-object-validate.jq b/rootfs/etc/cloud-compose/jq/compose-services-object-validate.jq
deleted file mode 100644
index 696aa4b..0000000
--- a/rootfs/etc/cloud-compose/jq/compose-services-object-validate.jq
+++ /dev/null
@@ -1 +0,0 @@
-.services | type == "object"
diff --git a/rootfs/etc/cloud-compose/jq/compose-validate-projects.jq b/rootfs/etc/cloud-compose/jq/compose-validate-projects.jq
deleted file mode 100644
index 99e1efd..0000000
--- a/rootfs/etc/cloud-compose/jq/compose-validate-projects.jq
+++ /dev/null
@@ -1,26 +0,0 @@
-type == "object" and length > 0 and
-all(to_entries[]; . as $entry |
- ($entry.key | explode | index(0) == null) and
- ($entry.value | type == "object") and
- (all($entry.value | .. | select(type == "string");
- explode | index(0) == null)) and
- ($entry.value.docker_compose_repo | type == "string" and length > 0) and
- ($entry.value.docker_compose_branch | type == "string" and length > 0) and
- ($entry.value.project_dir | type == "string" and length > 0) and
- ($entry.value.compose_project_name | type == "string" and length > 0) and
- (all(["init_commands", "up_commands", "down_commands", "rollout_commands"][];
- . as $field |
- ($entry.value[$field] == null) or
- (($entry.value[$field] | type) == "array" and
- all($entry.value[$field][]; type == "string"))
- )) and
- (($entry.value.sitectl_verify_args == null) or
- (($entry.value.sitectl_verify_args | type) == "array" and
- all($entry.value.sitectl_verify_args[];
- type == "string" and
- (explode | index(0) == null) and
- (contains("\n") | not) and
- (contains("\r") | not)
- ))) and
- (($entry.value.ingress == null) or ($entry.value.ingress | type == "object"))
-)
diff --git a/rootfs/etc/cloud-compose/jq/diagnostics-entry-app.jq b/rootfs/etc/cloud-compose/jq/diagnostics-entry-app.jq
deleted file mode 100644
index edb6cdb..0000000
--- a/rootfs/etc/cloud-compose/jq/diagnostics-entry-app.jq
+++ /dev/null
@@ -1 +0,0 @@
-.key | select(type == "string" and length > 0)
diff --git a/rootfs/etc/cloud-compose/jq/diagnostics-entry-project-dir.jq b/rootfs/etc/cloud-compose/jq/diagnostics-entry-project-dir.jq
deleted file mode 100644
index f0b9664..0000000
--- a/rootfs/etc/cloud-compose/jq/diagnostics-entry-project-dir.jq
+++ /dev/null
@@ -1 +0,0 @@
-.value.project_dir | select(type == "string" and startswith("/mnt/disks/data/"))
diff --git a/rootfs/etc/cloud-compose/jq/diagnostics-project-entries.jq b/rootfs/etc/cloud-compose/jq/diagnostics-project-entries.jq
deleted file mode 100644
index 4785439..0000000
--- a/rootfs/etc/cloud-compose/jq/diagnostics-project-entries.jq
+++ /dev/null
@@ -1 +0,0 @@
-to_entries[] | @base64
diff --git a/rootfs/etc/cloud-compose/jq/diagnostics-validate-compose-projects.jq b/rootfs/etc/cloud-compose/jq/diagnostics-validate-compose-projects.jq
deleted file mode 100644
index a25876a..0000000
--- a/rootfs/etc/cloud-compose/jq/diagnostics-validate-compose-projects.jq
+++ /dev/null
@@ -1 +0,0 @@
-type == "object"
diff --git a/rootfs/etc/cloud-compose/jq/dr-backup-completed-at.jq b/rootfs/etc/cloud-compose/jq/dr-backup-completed-at.jq
deleted file mode 100644
index 9822734..0000000
--- a/rootfs/etc/cloud-compose/jq/dr-backup-completed-at.jq
+++ /dev/null
@@ -1 +0,0 @@
-.completed_at
diff --git a/rootfs/etc/cloud-compose/jq/dr-backup-remote-id.jq b/rootfs/etc/cloud-compose/jq/dr-backup-remote-id.jq
deleted file mode 100644
index f142fb1..0000000
--- a/rootfs/etc/cloud-compose/jq/dr-backup-remote-id.jq
+++ /dev/null
@@ -1 +0,0 @@
-.remote_id
diff --git a/rootfs/etc/cloud-compose/jq/dr-restore-completed-at.jq b/rootfs/etc/cloud-compose/jq/dr-restore-completed-at.jq
deleted file mode 100644
index 9822734..0000000
--- a/rootfs/etc/cloud-compose/jq/dr-restore-completed-at.jq
+++ /dev/null
@@ -1 +0,0 @@
-.completed_at
diff --git a/rootfs/etc/cloud-compose/jq/dr-restore-recovery-id.jq b/rootfs/etc/cloud-compose/jq/dr-restore-recovery-id.jq
deleted file mode 100644
index fcaabb5..0000000
--- a/rootfs/etc/cloud-compose/jq/dr-restore-recovery-id.jq
+++ /dev/null
@@ -1 +0,0 @@
-.recovery_id
diff --git a/rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq b/rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq
deleted file mode 100644
index 628e5a3..0000000
--- a/rootfs/etc/cloud-compose/jq/dr-validate-backup-receipt.jq
+++ /dev/null
@@ -1,16 +0,0 @@
-type == "object" and length == 10 and
-.schema_version == 1 and
-.kind == "cloud-compose.offhost-backup-receipt" and
-.operation_id == $operation_id and
-.manifest_sha256 == $manifest_sha256 and
-.status == "succeeded" and
-.encrypted == true and
-.off_host == true and
-(.completed_at | type == "string" and length == 20 and
- (explode | all(.[]; . >= 32 and . != 127))) and
-(.remote_id | type == "string" and length >= 1 and length <= 512 and
- (explode | all(.[]; . >= 32 and . != 127))) and
-(.coverage | type == "object" and length == 3 and
- .database == true and
- .application_files == true and
- .volume_topology == true)
diff --git a/rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq b/rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq
deleted file mode 100644
index 461bd52..0000000
--- a/rootfs/etc/cloud-compose/jq/dr-validate-restore-proof.jq
+++ /dev/null
@@ -1,19 +0,0 @@
-type == "object" and length == 13 and
-.schema_version == 1 and
-.kind == "cloud-compose.restore-test-proof" and
-.test_id == $test_id and
-.source_manifest_sha256 == $manifest_sha256 and
-.source_receipt_sha256 == $receipt_sha256 and
-.status == "succeeded" and
-.disposable_recovery == true and
-.recovery_destroyed == true and
-.integrity_verified == true and
-(.completed_at | type == "string" and length == 20 and
- (explode | all(.[]; . >= 32 and . != 127))) and
-(.recovery_id | type == "string" and length >= 1 and length <= 512 and
- (explode | all(.[]; . >= 32 and . != 127))) and
-(.coverage | type == "object" and length == 3 and
- .database == true and
- .application_files == true and
- .volume_topology == true) and
-(.source_encrypted == true)
diff --git a/rootfs/etc/cloud-compose/jq/gcp-metadata-private-ip.jq b/rootfs/etc/cloud-compose/jq/gcp-metadata-private-ip.jq
deleted file mode 100644
index 4693313..0000000
--- a/rootfs/etc/cloud-compose/jq/gcp-metadata-private-ip.jq
+++ /dev/null
@@ -1 +0,0 @@
-.instance.networkInterfaces[0].ip
diff --git a/rootfs/etc/cloud-compose/jq/gcp-metadata-public-ip.jq b/rootfs/etc/cloud-compose/jq/gcp-metadata-public-ip.jq
deleted file mode 100644
index 2a84839..0000000
--- a/rootfs/etc/cloud-compose/jq/gcp-metadata-public-ip.jq
+++ /dev/null
@@ -1 +0,0 @@
-.instance.networkInterfaces[0].accessConfigs[0].externalIp
diff --git a/rootfs/etc/cloud-compose/jq/github-latest-release-tag.jq b/rootfs/etc/cloud-compose/jq/github-latest-release-tag.jq
deleted file mode 100644
index 42f0fd4..0000000
--- a/rootfs/etc/cloud-compose/jq/github-latest-release-tag.jq
+++ /dev/null
@@ -1,2 +0,0 @@
-(.tag_name | select(type == "string" and length > 0 and (explode | index(0) == null))),
-"\u001f"
diff --git a/rootfs/etc/cloud-compose/jq/json-length.jq b/rootfs/etc/cloud-compose/jq/json-length.jq
deleted file mode 100644
index 7f5e3b6..0000000
--- a/rootfs/etc/cloud-compose/jq/json-length.jq
+++ /dev/null
@@ -1 +0,0 @@
-length
diff --git a/rootfs/etc/cloud-compose/jq/json-object-validate.jq b/rootfs/etc/cloud-compose/jq/json-object-validate.jq
deleted file mode 100644
index a25876a..0000000
--- a/rootfs/etc/cloud-compose/jq/json-object-validate.jq
+++ /dev/null
@@ -1 +0,0 @@
-type == "object"
diff --git a/rootfs/etc/cloud-compose/jq/nonempty-string-field.jq b/rootfs/etc/cloud-compose/jq/nonempty-string-field.jq
deleted file mode 100644
index 58b565d..0000000
--- a/rootfs/etc/cloud-compose/jq/nonempty-string-field.jq
+++ /dev/null
@@ -1 +0,0 @@
-.[$field] | select(type == "string" and length > 0)
diff --git a/rootfs/etc/cloud-compose/jq/object-entries-sorted-base64.jq b/rootfs/etc/cloud-compose/jq/object-entries-sorted-base64.jq
deleted file mode 100644
index 617be57..0000000
--- a/rootfs/etc/cloud-compose/jq/object-entries-sorted-base64.jq
+++ /dev/null
@@ -1 +0,0 @@
-to_entries | sort_by(.key)[] | @base64
diff --git a/rootfs/etc/cloud-compose/jq/object-field-delimited.jq b/rootfs/etc/cloud-compose/jq/object-field-delimited.jq
deleted file mode 100644
index d1b8407..0000000
--- a/rootfs/etc/cloud-compose/jq/object-field-delimited.jq
+++ /dev/null
@@ -1 +0,0 @@
-(.[$field]), "\u001f"
diff --git a/rootfs/etc/cloud-compose/jq/object-field.jq b/rootfs/etc/cloud-compose/jq/object-field.jq
deleted file mode 100644
index fff061d..0000000
--- a/rootfs/etc/cloud-compose/jq/object-field.jq
+++ /dev/null
@@ -1 +0,0 @@
-.[$field]
diff --git a/rootfs/etc/cloud-compose/jq/object-has-key.jq b/rootfs/etc/cloud-compose/jq/object-has-key.jq
deleted file mode 100644
index f9943a1..0000000
--- a/rootfs/etc/cloud-compose/jq/object-has-key.jq
+++ /dev/null
@@ -1 +0,0 @@
-has($key)
diff --git a/rootfs/etc/cloud-compose/jq/object-keys-base64.jq b/rootfs/etc/cloud-compose/jq/object-keys-base64.jq
deleted file mode 100644
index 2975208..0000000
--- a/rootfs/etc/cloud-compose/jq/object-keys-base64.jq
+++ /dev/null
@@ -1 +0,0 @@
-keys[] | @base64
diff --git a/rootfs/etc/cloud-compose/jq/object-keys.jq b/rootfs/etc/cloud-compose/jq/object-keys.jq
deleted file mode 100644
index d7d2124..0000000
--- a/rootfs/etc/cloud-compose/jq/object-keys.jq
+++ /dev/null
@@ -1 +0,0 @@
-keys[]
diff --git a/rootfs/etc/cloud-compose/jq/offhost-bind-sources.jq b/rootfs/etc/cloud-compose/jq/offhost-bind-sources.jq
deleted file mode 100644
index 880c92b..0000000
--- a/rootfs/etc/cloud-compose/jq/offhost-bind-sources.jq
+++ /dev/null
@@ -1 +0,0 @@
-.services[].volumes[]? | select(.type == "bind") | .source
diff --git a/rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq b/rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq
deleted file mode 100644
index 77633bd..0000000
--- a/rootfs/etc/cloud-compose/jq/offhost-build-application-coverage.jq
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- name: $app,
- databases: [{
- engine: "mariadb",
- format: "sql.gz",
- local_recovery_artifact: $dump_path,
- sha256: $dump_sha256,
- bytes: $dump_bytes
- }],
- application_files: {
- roots: [$project_dir],
- bind_mounts: [
- .services | to_entries[] as $service |
- ($service.value.volumes // [])[] |
- select(.type == "bind") |
- {service: $service.key, source: .source, target: .target, read_only: (.read_only // false)}
- ] | sort_by(.service, .source, .target)
- },
- volume_topology: {
- declared_named_volumes: ((.volumes // {}) | keys | sort),
- service_mounts: [
- .services | to_entries[] as $service |
- ($service.value.volumes // [])[] |
- {service: $service.key, type: .type, source: (.source // ""), target: .target, read_only: (.read_only // false)}
- ] | sort_by(.service, .type, .source, .target)
- }
-}
diff --git a/rootfs/etc/cloud-compose/jq/offhost-build-manifest.jq b/rootfs/etc/cloud-compose/jq/offhost-build-manifest.jq
deleted file mode 100644
index a1d4451..0000000
--- a/rootfs/etc/cloud-compose/jq/offhost-build-manifest.jq
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- schema_version: 1,
- kind: "cloud-compose.offhost-backup-manifest",
- operation_id: $operation_id,
- backup_date: $backup_date,
- provider: $provider,
- instance: $instance,
- required_coverage: ["database", "application_files", "volume_topology"],
- applications: (sort_by(.name))
-}
diff --git a/rootfs/etc/cloud-compose/jq/offhost-manifest-app-digests.jq b/rootfs/etc/cloud-compose/jq/offhost-manifest-app-digests.jq
deleted file mode 100644
index c3cef5b..0000000
--- a/rootfs/etc/cloud-compose/jq/offhost-manifest-app-digests.jq
+++ /dev/null
@@ -1 +0,0 @@
-.applications[] | [.name, .databases[0].sha256] | @tsv
diff --git a/rootfs/etc/cloud-compose/jq/offhost-validate-bind-roots.jq b/rootfs/etc/cloud-compose/jq/offhost-validate-bind-roots.jq
deleted file mode 100644
index 3d8ac7b..0000000
--- a/rootfs/etc/cloud-compose/jq/offhost-validate-bind-roots.jq
+++ /dev/null
@@ -1,8 +0,0 @@
-all(.services[].volumes[]?;
- .type != "bind" or
- (.source | type == "string" and
- (. == $data_root or startswith($data_root + "/") or
- . == $volumes_root or startswith($volumes_root + "/")) and
- (explode | all(.[]; . >= 32 and . != 127)) and
- (contains("//") | not) and
- length > 0))
diff --git a/rootfs/etc/cloud-compose/jq/offhost-validate-compose-config.jq b/rootfs/etc/cloud-compose/jq/offhost-validate-compose-config.jq
deleted file mode 100644
index 12b0534..0000000
--- a/rootfs/etc/cloud-compose/jq/offhost-validate-compose-config.jq
+++ /dev/null
@@ -1,11 +0,0 @@
-type == "object" and
-(.services | type == "object" and length > 0) and
-all(.services | to_entries[];
- ((.value.volumes // []) | type == "array") and
- all((.value.volumes // [])[];
- type == "object" and
- (.type | type == "string") and
- (.type == "volume" or .type == "bind" or .type == "tmpfs") and
- ((.source // "") | type == "string") and
- ((.target // "") | type == "string" and length > 0))) and
-((.volumes // {}) | type == "object")
diff --git a/rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq b/rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq
deleted file mode 100644
index f196ef2..0000000
--- a/rootfs/etc/cloud-compose/jq/offhost-validate-manifest.jq
+++ /dev/null
@@ -1,14 +0,0 @@
-.schema_version == 1 and
-.kind == "cloud-compose.offhost-backup-manifest" and
-(.applications | type == "array" and length == $app_count) and
-all(.applications[];
- (.name | type == "string" and length >= 1 and length <= 63 and
- (explode | all(.[]; . >= 32 and . != 127))) and
- (.databases | length == 1) and
- (.databases[0].sha256 | type == "string" and length == 64 and
- (explode | all(.[]; . >= 32 and . != 127))) and
- (.databases[0].bytes | type == "number" and . > 0) and
- (.application_files.roots | type == "array" and length > 0) and
- (.application_files.bind_mounts | type == "array") and
- (.volume_topology.declared_named_volumes | type == "array") and
- (.volume_topology.service_mounts | type == "array"))
diff --git a/rootfs/etc/cloud-compose/jq/rotation-audit.jq b/rootfs/etc/cloud-compose/jq/rotation-audit.jq
deleted file mode 100644
index 6eeb3a4..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-audit.jq
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- version: 1,
- phase: $phase,
- current_key_id: $current_key_id,
- new_key_id: $new_key_id,
- recovery_required: ($phase == "creating" or $phase == "creating-fresh"),
- candidate_key_ids: [$candidate_names[] | split("/")[-1]],
- created_at: $created_at,
- ready_at: $ready_at,
- disabled_at: $disabled_at,
- grace_remaining_seconds: $grace_remaining
-}
diff --git a/rootfs/etc/cloud-compose/jq/rotation-build-state.jq b/rootfs/etc/cloud-compose/jq/rotation-build-state.jq
deleted file mode 100644
index fd619a2..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-build-state.jq
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- version: 2,
- phase: $phase,
- service_account: $service_account,
- project_id: $project_id,
- credentials_file: $credentials_file,
- current_key_id: $current_key_id,
- new_key_id: $new_key_id,
- new_key_name: $new_key_name,
- baseline_key_names: $baseline_key_names,
- created_at: $created_at,
- ready_at: $ready_at,
- disabled_at: $disabled_at
-}
diff --git a/rootfs/etc/cloud-compose/jq/rotation-first-key-id.jq b/rootfs/etc/cloud-compose/jq/rotation-first-key-id.jq
deleted file mode 100644
index 34529ca..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-first-key-id.jq
+++ /dev/null
@@ -1 +0,0 @@
-.[0] | split("/")[-1]
diff --git a/rootfs/etc/cloud-compose/jq/rotation-idle-audit.jq b/rootfs/etc/cloud-compose/jq/rotation-idle-audit.jq
deleted file mode 100644
index 9c9b20e..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-idle-audit.jq
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- version: 1,
- phase: "idle",
- recovery_required: false,
- candidate_key_ids: [],
- grace_remaining_seconds: 0
-}
diff --git a/rootfs/etc/cloud-compose/jq/rotation-jwt-claims.jq b/rootfs/etc/cloud-compose/jq/rotation-jwt-claims.jq
deleted file mode 100644
index e431aa8..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-jwt-claims.jq
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- iss: $iss,
- scope: "https://www.googleapis.com/auth/cloud-platform",
- aud: $aud,
- iat: $iat,
- exp: ($iat + 3600)
-}
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-disabled.jq b/rootfs/etc/cloud-compose/jq/rotation-key-disabled.jq
deleted file mode 100644
index 9ce9b69..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-key-disabled.jq
+++ /dev/null
@@ -1 +0,0 @@
-any(.[]; .name == $name and .disabled == true)
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-ids-join.jq b/rootfs/etc/cloud-compose/jq/rotation-key-ids-join.jq
deleted file mode 100644
index 1ede644..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-key-ids-join.jq
+++ /dev/null
@@ -1 +0,0 @@
-[.[] | split("/")[-1]] | join(", ")
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-names-base64.jq b/rootfs/etc/cloud-compose/jq/rotation-key-names-base64.jq
deleted file mode 100644
index 9ee5619..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-key-names-base64.jq
+++ /dev/null
@@ -1 +0,0 @@
-.[].name | @base64
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-names-sorted.jq b/rootfs/etc/cloud-compose/jq/rotation-key-names-sorted.jq
deleted file mode 100644
index 7704945..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-key-names-sorted.jq
+++ /dev/null
@@ -1 +0,0 @@
-[.[].name] | sort
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-object-ids-join.jq b/rootfs/etc/cloud-compose/jq/rotation-key-object-ids-join.jq
deleted file mode 100644
index d9ce6ea..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-key-object-ids-join.jq
+++ /dev/null
@@ -1 +0,0 @@
-[.[].name | split("/")[-1]] | sort | join(", ")
diff --git a/rootfs/etc/cloud-compose/jq/rotation-key-present.jq b/rootfs/etc/cloud-compose/jq/rotation-key-present.jq
deleted file mode 100644
index cd6febc..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-key-present.jq
+++ /dev/null
@@ -1 +0,0 @@
-any(.[]; .name == $name)
diff --git a/rootfs/etc/cloud-compose/jq/rotation-new-key-names.jq b/rootfs/etc/cloud-compose/jq/rotation-new-key-names.jq
deleted file mode 100644
index 60a7881..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-new-key-names.jq
+++ /dev/null
@@ -1 +0,0 @@
-$after - $before | sort
diff --git a/rootfs/etc/cloud-compose/jq/rotation-normalize-user-keys.jq b/rootfs/etc/cloud-compose/jq/rotation-normalize-user-keys.jq
deleted file mode 100644
index 685da02..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-normalize-user-keys.jq
+++ /dev/null
@@ -1,15 +0,0 @@
-(.keys // []) as $keys |
-if ($keys | type) != "array" then error("invalid key list") else
- [$keys[] | select(.keyType == "USER_MANAGED")] as $user_keys |
- if ($user_keys | length) > 10 then error("too many user-managed keys")
- elif any($user_keys[];
- (.name | type) != "string" or
- (.name | startswith($prefix) | not) or
- ((.disabled // false) | type) != "boolean")
- then error("invalid user-managed key")
- elif ([$user_keys[].name] | unique | length) != ($user_keys | length)
- then error("duplicate user-managed key")
- else
- [$user_keys[] | {name: .name, disabled: (.disabled // false)}] | sort_by(.name)
- end
-end
diff --git a/rootfs/etc/cloud-compose/jq/rotation-remaining-baseline.jq b/rootfs/etc/cloud-compose/jq/rotation-remaining-baseline.jq
deleted file mode 100644
index cd22ed3..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-remaining-baseline.jq
+++ /dev/null
@@ -1 +0,0 @@
-$after | map(select(. as $name | $before | index($name))) | sort
diff --git a/rootfs/etc/cloud-compose/jq/rotation-validate-state.jq b/rootfs/etc/cloud-compose/jq/rotation-validate-state.jq
deleted file mode 100644
index cf4b1fa..0000000
--- a/rootfs/etc/cloud-compose/jq/rotation-validate-state.jq
+++ /dev/null
@@ -1,20 +0,0 @@
-select(
- .version == 2 and
- (.phase == "reconciling" or .phase == "creating-fresh" or
- .phase == "creating" or .phase == "staged" or .phase == "authenticated" or
- .phase == "ready" or .phase == "grace" or .phase == "rolling-back" or
- .phase == "rollback" or .phase == "revoke-new") and
- .service_account == $service_account and
- .project_id == $project_id and
- .credentials_file == $credentials_file and
- (.current_key_id | type == "string" and (explode | index(0) == null)) and
- (.new_key_id | type == "string" and (explode | index(0) == null)) and
- (.new_key_name | type == "string" and (explode | index(0) == null)) and
- (.baseline_key_names | type == "array") and
- (.baseline_key_names | length <= 10 and . == (sort | unique)) and
- all(.baseline_key_names[];
- type == "string" and (explode | index(0) == null)) and
- (.created_at | type == "number" and . >= 0 and floor == .) and
- (.ready_at | type == "number" and . >= 0 and floor == .) and
- (.disabled_at | type == "number" and . >= 0 and floor == .)
-)
diff --git a/rootfs/etc/cloud-compose/jq/service-account-credentials-valid.jq b/rootfs/etc/cloud-compose/jq/service-account-credentials-valid.jq
deleted file mode 100644
index cccc453..0000000
--- a/rootfs/etc/cloud-compose/jq/service-account-credentials-valid.jq
+++ /dev/null
@@ -1,8 +0,0 @@
-.type == "service_account" and
-.private_key_id == $key_id and
-.client_email == $service_account and
-.project_id == $project_id and
-.token_uri == "https://oauth2.googleapis.com/token" and
-(.private_key | type == "string" and
- startswith("-----BEGIN PRIVATE KEY-----") and
- contains("-----END PRIVATE KEY-----"))
diff --git a/rootfs/etc/cloud-compose/jq/service-account-key-id.jq b/rootfs/etc/cloud-compose/jq/service-account-key-id.jq
deleted file mode 100644
index 5539109..0000000
--- a/rootfs/etc/cloud-compose/jq/service-account-key-id.jq
+++ /dev/null
@@ -1,3 +0,0 @@
-(.private_key_id |
- select(type == "string" and length > 0 and (explode | index(0) == null))),
-"\u001f"
diff --git a/rootfs/etc/cloud-compose/jq/sitectl-package-version.jq b/rootfs/etc/cloud-compose/jq/sitectl-package-version.jq
deleted file mode 100644
index 42eaeb7..0000000
--- a/rootfs/etc/cloud-compose/jq/sitectl-package-version.jq
+++ /dev/null
@@ -1 +0,0 @@
-.[$package] // $fallback
diff --git a/rootfs/etc/cloud-compose/jq/sitectl-package-versions-validate.jq b/rootfs/etc/cloud-compose/jq/sitectl-package-versions-validate.jq
deleted file mode 100644
index 6c8b533..0000000
--- a/rootfs/etc/cloud-compose/jq/sitectl-package-versions-validate.jq
+++ /dev/null
@@ -1,6 +0,0 @@
-type == "object" and
-all(to_entries[];
- (.key | explode | index(0) == null) and
- (.value | type == "string") and
- (.value | explode | index(0) == null)
-)
diff --git a/rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq b/rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq
deleted file mode 100644
index fc24963..0000000
--- a/rootfs/etc/cloud-compose/jq/sitectl-verify-args.jq
+++ /dev/null
@@ -1,5 +0,0 @@
-if type == "array" and all(.[]; type == "string") then
- .[] | ("x" + @base64)
-else
- error("sitectl verify arguments must be an array of strings")
-end
diff --git a/rootfs/etc/cloud-compose/jq/string-array-validate.jq b/rootfs/etc/cloud-compose/jq/string-array-validate.jq
deleted file mode 100644
index 5083d4c..0000000
--- a/rootfs/etc/cloud-compose/jq/string-array-validate.jq
+++ /dev/null
@@ -1 +0,0 @@
-type == "array" and all(.[]; type == "string")
diff --git a/rootfs/etc/cloud-compose/jq/string-field-valid.jq b/rootfs/etc/cloud-compose/jq/string-field-valid.jq
deleted file mode 100644
index 60c5b0d..0000000
--- a/rootfs/etc/cloud-compose/jq/string-field-valid.jq
+++ /dev/null
@@ -1 +0,0 @@
-.[$field] | type == "string" and length > 0
diff --git a/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh b/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh
index c0ba4fb..4051052 100755
--- a/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh
+++ b/rootfs/etc/cloud-compose/libexec/bootstrap-required.sh
@@ -1,19 +1,7 @@
#!/usr/bin/env bash
-
-set -uo pipefail
-
-# shellcheck disable=SC1091
-if ! source /etc/cloud-compose/libexec/bootstrap-security.sh; then
- echo "Cloud Compose bootstrap security helper could not be loaded" >&2
- exit 255
-fi
-
-if ! cloud_compose_bootstrap_require_root; then
- exit 255
-fi
-if cloud_compose_bootstrap_marker_ready; then
- # ExecCondition exit 1 skips an already-complete oneshot without marking it
- # failed. Missing or invalid evidence returns zero below and must converge.
- exit 1
-fi
+set -u
+((EUID == 0)) || exit 255
+sitectl=/etc/cloud-compose/bin/bootstrap-sitectl
+[[ -x "$sitectl" && ! -L "$sitectl" ]] || sitectl=/home/cloud-compose/bin/sitectl
+"$sitectl" host marker valid /var/lib/cloud-compose/bootstrap-complete >/dev/null 2>&1 && exit 1
exit 0
diff --git a/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh b/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh
deleted file mode 100755
index 192f8aa..0000000
--- a/rootfs/etc/cloud-compose/libexec/bootstrap-security.sh
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/usr/bin/env bash
-
-readonly CLOUD_COMPOSE_BOOTSTRAP_MARKER="/var/lib/cloud-compose/bootstrap-complete"
-readonly CLOUD_COMPOSE_RUNTIME_HOME="/home/cloud-compose"
-
-cloud_compose_bootstrap_require_root() {
- if ((EUID != 0)); then
- echo "Cloud Compose bootstrap control must run as root" >&2
- return 1
- fi
-}
-
-cloud_compose_bootstrap_marker_ready() {
- local marker="${1:-$CLOUD_COMPOSE_BOOTSTRAP_MARKER}"
- local marker_dir marker_dir_metadata marker_metadata marker_size payload
-
- marker_dir="$(dirname -- "$marker")"
- [[ -d "$marker_dir" && ! -L "$marker_dir" && -f "$marker" && ! -L "$marker" ]] || return 1
- marker_dir_metadata="$(stat -c '%u:%g:%a:%F' -- "$marker_dir")" || return 1
- marker_metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$marker")" || return 1
- marker_size="$(stat -c '%s' -- "$marker")" || return 1
- [[ "$marker_dir_metadata" == "0:0:755:directory" &&
- "$marker_metadata" == "0:0:644:1:regular file" &&
- "$marker_size" == "6" ]] || return 1
- IFS= read -r payload <"$marker" || return 1
- [[ "$payload" == "ready" ]]
-}
-
-cloud_compose_secure_runtime_home() {
- local home_metadata program dispatcher metadata owner_uid group_gid mode links kind
- local -a required_programs programs dispatchers control_inputs
-
- cloud_compose_bootstrap_require_root || return 1
- if [[ -L "$CLOUD_COMPOSE_RUNTIME_HOME" || ! -d "$CLOUD_COMPOSE_RUNTIME_HOME" ]]; then
- echo "Cloud Compose runtime home is missing or redirected" >&2
- return 1
- fi
- home_metadata="$(stat -c '%a:%F' -- "$CLOUD_COMPOSE_RUNTIME_HOME")" || return 1
- if [[ ! "$home_metadata" =~ ^[0-7]{3,4}:directory$ ]]; then
- echo "Cloud Compose runtime home is not a real directory" >&2
- return 1
- fi
-
- # Close the historical user-owned parent boundary before inspecting any
- # program beneath it. A replacement file retains its non-root ownership and
- # is rejected below; a now-unwritable parent prevents another replacement.
- chown 0:0 "$CLOUD_COMPOSE_RUNTIME_HOME" || return 1
- chmod 0755 "$CLOUD_COMPOSE_RUNTIME_HOME" || return 1
-
- required_programs=(
- "$CLOUD_COMPOSE_RUNTIME_HOME/run.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/profile.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/bootstrap-helpers.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/host-conf.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/host-init.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/converge-app-filesystems.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/default-lifecycle.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/prepare-app-sources.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/rotate-keys-daily.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/vault-agent-init.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/app-init.sh"
- "$CLOUD_COMPOSE_RUNTIME_HOME/init"
- "$CLOUD_COMPOSE_RUNTIME_HOME/up"
- "$CLOUD_COMPOSE_RUNTIME_HOME/down"
- "$CLOUD_COMPOSE_RUNTIME_HOME/rollout"
- )
- for program in "${required_programs[@]}"; do
- if [[ -L "$program" || ! -f "$program" ]]; then
- echo "Required Cloud Compose bootstrap program is missing or redirected: $program" >&2
- return 1
- fi
- done
-
- shopt -s nullglob
- programs=("$CLOUD_COMPOSE_RUNTIME_HOME"/*.sh)
- shopt -u nullglob
- for program in "${programs[@]}"; do
- if [[ -L "$program" || ! -f "$program" ]]; then
- echo "Cloud Compose bootstrap program is not a regular file: $program" >&2
- return 1
- fi
- metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$program")" || return 1
- IFS=: read -r owner_uid group_gid mode links kind <<<"$metadata"
- if [[ "$owner_uid" != "0" || "$group_gid" != "0" || "$links" != "1" || "$kind" != "regular file" ||
- ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
- echo "Cloud Compose bootstrap program is not root-controlled: $program" >&2
- return 1
- fi
- chown 0:0 "$program" || return 1
- chmod 0755 "$program" || return 1
- done
-
- control_inputs=(
- "$CLOUD_COMPOSE_RUNTIME_HOME/.env"
- "$CLOUD_COMPOSE_RUNTIME_HOME/compose-projects.json"
- "$CLOUD_COMPOSE_RUNTIME_HOME/application-env.json"
- "$CLOUD_COMPOSE_RUNTIME_HOME/managed-runtime-artifacts.tsv"
- )
- for program in "${control_inputs[@]}"; do
- if [[ -L "$program" || ! -f "$program" ]]; then
- echo "Required Cloud Compose control input is missing or redirected: $program" >&2
- return 1
- fi
- metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$program")" || return 1
- IFS=: read -r owner_uid group_gid mode links kind <<<"$metadata"
- if [[ "$owner_uid" != "0" || "$links" != "1" || "$kind" != "regular file" ||
- ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
- echo "Cloud Compose control input is not root-controlled: $program" >&2
- return 1
- fi
- done
-
- dispatchers=(init up down rollout)
- for dispatcher in "${dispatchers[@]}"; do
- program="$CLOUD_COMPOSE_RUNTIME_HOME/$dispatcher"
- metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$program")" || return 1
- IFS=: read -r owner_uid group_gid mode links kind <<<"$metadata"
- if [[ "$owner_uid" != "0" || "$links" != "1" || "$kind" != "regular file" ||
- ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
- echo "Cloud Compose lifecycle dispatcher is not root-controlled: $program" >&2
- return 1
- fi
- done
-}
diff --git a/rootfs/etc/cloud-compose/libexec/bootstrap-sitectl.sh b/rootfs/etc/cloud-compose/libexec/bootstrap-sitectl.sh
new file mode 100755
index 0000000..dbfc77e
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/bootstrap-sitectl.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+version=""
+while (($#)); do
+ case "$1" in
+ --version) version="${2:-}"; shift 2 ;;
+ *) echo "Unknown bootstrap-sitectl argument: $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ "$version" == latest || "$version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] || {
+ echo "sitectl version must be latest or an exact release version" >&2
+ exit 2
+}
+case "$(uname -m)" in
+ x86_64 | amd64) arch=x86_64 ;;
+ aarch64 | arm64) arch=arm64 ;;
+ *) echo "Unsupported sitectl architecture: $(uname -m)" >&2; exit 1 ;;
+esac
+
+archive="sitectl_Linux_${arch}.tar.gz"
+if [[ "$version" == latest ]]; then
+ base="https://github.com/libops/sitectl/releases/latest/download"
+else
+ base="https://github.com/libops/sitectl/releases/download/${version}"
+fi
+tmp="$(mktemp -d /run/cloud-compose-sitectl.XXXXXXXXXX)"
+trap 'rm -rf -- "$tmp"' EXIT
+curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
+ --retry 5 --retry-all-errors --connect-timeout 10 --max-time 300 \
+ -o "$tmp/checksums.txt" -- "$base/checksums.txt"
+curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
+ --retry 5 --retry-all-errors --connect-timeout 10 --max-time 300 \
+ -o "$tmp/$archive" -- "$base/$archive"
+
+expected=""
+while read -r digest filename extra; do
+ filename="${filename#\*}"
+ if [[ "$filename" == "$archive" && -z "$extra" && "$digest" =~ ^[0-9a-f]{64}$ ]]; then
+ [[ -z "$expected" ]] || {
+ echo "Duplicate sitectl checksum entry for $archive" >&2
+ exit 1
+ }
+ expected="$digest"
+ fi
+done <"$tmp/checksums.txt"
+[[ -n "$expected" ]] || {
+ echo "Missing sitectl checksum entry for $archive" >&2
+ exit 1
+}
+printf '%s %s\n' "$expected" "$tmp/$archive" | sha256sum -c -
+
+sitectl_entries=0
+while IFS= read -r entry; do
+ if [[ "$entry" == sitectl ]]; then
+ sitectl_entries=$((sitectl_entries + 1))
+ fi
+done < <(tar -tzf "$tmp/$archive")
+[[ "$sitectl_entries" -eq 1 ]] || {
+ echo "sitectl archive must contain exactly one entry named sitectl" >&2
+ exit 1
+}
+tar -xzf "$tmp/$archive" -C "$tmp" -- sitectl
+[[ "$(stat -c '%F:%h' -- "$tmp/sitectl")" == "regular file:1" ]] || {
+ echo "Extracted sitectl must be a regular file with one hard link" >&2
+ exit 1
+}
+bootstrap_sitectl=/etc/cloud-compose/bin/bootstrap-sitectl
+install -d -m 0755 -o root -g root -- /etc/cloud-compose/bin
+install -m 0755 -o root -g root -- "$tmp/sitectl" "$bootstrap_sitectl"
+"$bootstrap_sitectl" host filesystems --help >/dev/null
+"$bootstrap_sitectl" host security secure-runtime --help >/dev/null
+for command in docker-plugins marker metadata-firewall overlays rollout-serve systemd vault-agent vault-readiness; do
+ "$bootstrap_sitectl" host "$command" --help >/dev/null
+done
diff --git a/rootfs/etc/cloud-compose/libexec/checked-programs.bash b/rootfs/etc/cloud-compose/libexec/checked-programs.bash
deleted file mode 100644
index c338767..0000000
--- a/rootfs/etc/cloud-compose/libexec/checked-programs.bash
+++ /dev/null
@@ -1,271 +0,0 @@
-# Shared trust boundary for jq, AWK, and sourced shell programs consumed by Cloud Compose.
-# This file is sourced from checked runtime scripts; it is not an entrypoint.
-
-cloud_compose_program_owner_path() {
- local owner_source="$1"
-
- readlink -f -- "$owner_source"
-}
-
-cloud_compose_validate_root_parent() {
- local current="$1" metadata owner group mode
-
- if [[ -L "$current" || ! -d "$current" ]]; then
- echo "Installed Cloud Compose program parent is missing or redirected: $current" >&2
- return 1
- fi
- metadata="$(stat -c '%u:%g:%a' -- "$current")" || return 1
- IFS=: read -r owner group mode <<<"$metadata"
- if [[ "$owner" != 0 || "$group" != 0 || ! "$mode" =~ ^[0-7]{3,4}$ ||
- $((8#$mode & 0022)) -ne 0 ]]; then
- echo "Installed Cloud Compose program parent is not root-controlled: $current" >&2
- return 1
- fi
-}
-
-cloud_compose_installed_home() {
- local resolved_home alias_metadata alias_target
- local -a parents
-
- if [[ -L /home ]]; then
- alias_metadata="$(stat -c '%u:%g:%h' -- /home)" || return 1
- alias_target="$(readlink -- /home)" || return 1
- if [[ "$alias_metadata" != "0:0:1" ||
- ( "$alias_target" != "var/home" && "$alias_target" != "/var/home" ) ]]; then
- echo "Installed Cloud Compose home uses an unsafe operating-system alias" >&2
- return 1
- fi
- elif [[ ! -d /home ]]; then
- echo "Installed Cloud Compose home parent is missing" >&2
- return 1
- fi
-
- resolved_home="$(readlink -f -- /home)" || return 1
- case "$resolved_home" in
- /home) parents=(/ /home /home/cloud-compose) ;;
- /var/home) parents=(/ /var /var/home /var/home/cloud-compose) ;;
- *)
- echo "Installed Cloud Compose home resolves outside a supported root-controlled path" >&2
- return 1
- ;;
- esac
- for parent in "${parents[@]}"; do
- cloud_compose_validate_root_parent "$parent" || return 1
- done
- printf '%s/cloud-compose\n' "$resolved_home"
-}
-
-cloud_compose_owner_is_installed() {
- local owner_path="$1" candidate_home installed_home
-
- candidate_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
- if [[ "$candidate_home" == "/" ]]; then
- echo "Installed Cloud Compose home resolves to the filesystem root" >&2
- return 2
- fi
- if [[ -z "$candidate_home" ||
- "$owner_path" != "${candidate_home%/}/"* ]]; then
- return 1
- fi
- installed_home="$(cloud_compose_installed_home)" || return 2
- [[ "$candidate_home" == "$installed_home" ]] || return 2
-}
-
-cloud_compose_validate_installed_program() {
- local program="$1" program_dir="$2" program_name current metadata owner group mode links
- local -a parents
-
- case "$program_dir" in
- /etc/cloud-compose/jq | /etc/cloud-compose/awk) ;;
- *)
- echo "Unsupported installed Cloud Compose program directory: $program_dir" >&2
- return 1
- ;;
- esac
- program_name="${program##*/}"
- [[ "$program" == "$program_dir/$program_name" &&
- "$program_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.(jq|awk)$ &&
- "$program" != *$'\n'* &&
- "$program" != *$'\r'* && ! "$program" =~ (^|/)\.\.?(/|$) ]] || {
- echo "Unsafe installed Cloud Compose program path: $program" >&2
- return 1
- }
-
- parents=(/ /etc /etc/cloud-compose "$program_dir")
- for current in "${parents[@]}"; do
- cloud_compose_validate_root_parent "$current" || return 1
- done
-
- if [[ -L "$program" || ! -f "$program" ]]; then
- echo "Installed Cloud Compose program is missing or redirected: $program" >&2
- return 1
- fi
- metadata="$(stat -c '%u:%g:%a:%h' -- "$program")" || return 1
- IFS=: read -r owner group mode links <<<"$metadata"
- if [[ "$owner" != 0 || "$group" != 0 || "$links" != 1 ||
- ! "$mode" =~ ^[0-7]{3,4}$ ||
- $((8#$mode & 0022)) -ne 0 ]]; then
- echo "Installed Cloud Compose program is not a single-link, root-controlled regular file: $program" >&2
- return 1
- fi
-}
-
-cloud_compose_validate_installed_program_dir() (
- local program_dir="$1" program
- local -a programs
-
- shopt -s nullglob dotglob
- case "$program_dir" in
- /etc/cloud-compose/jq) programs=("$program_dir"/*.jq) ;;
- /etc/cloud-compose/awk) programs=("$program_dir"/*.awk) ;;
- *)
- echo "Unsupported installed Cloud Compose program directory: $program_dir" >&2
- return 1
- ;;
- esac
- ((${#programs[@]} > 0)) || {
- echo "Installed Cloud Compose program directory is empty: $program_dir" >&2
- return 1
- }
- for program in "${programs[@]}"; do
- cloud_compose_validate_installed_program "$program" "$program_dir" || return 1
- done
-)
-
-cloud_compose_validate_installed_source() {
- local program="$1" resolved_program installed_home metadata owner group mode links
-
- [[ "$program" =~ ^/home/cloud-compose/[A-Za-z0-9][A-Za-z0-9._-]*\.sh$ ]] || {
- echo "Unsafe installed Cloud Compose source program path: $program" >&2
- return 1
- }
- installed_home="$(cloud_compose_installed_home)" || return 1
- resolved_program="$(readlink -f -- "$program")" || return 1
- [[ "$resolved_program" == "$installed_home/${program##*/}" ]] || {
- echo "Installed Cloud Compose source program resolves outside its fixed home" >&2
- return 1
- }
- if [[ -L "$program" || ! -f "$program" ]]; then
- echo "Installed Cloud Compose source program is missing or redirected: $program" >&2
- return 1
- fi
- metadata="$(stat -c '%u:%g:%a:%h' -- "$program")" || return 1
- IFS=: read -r owner group mode links <<<"$metadata"
- if [[ "$owner" != 0 || "$group" != 0 || "$links" != 1 ||
- ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
- echo "Installed Cloud Compose source program is not a single-link, root-controlled regular file: $program" >&2
- return 1
- fi
-}
-
-cloud_compose_bind_program_dir() {
- local owner_source="$1" variable_name="$2" installed_dir="$3" repository_dir="$4"
- shift 4
- local owner_path selected name program installed_status=0
-
- owner_path="$(cloud_compose_program_owner_path "$owner_source")" || return 1
- if cloud_compose_owner_is_installed "$owner_path"; then
- installed_status=0
- else
- installed_status=$?
- fi
- if ((installed_status > 1)); then
- return 1
- fi
- if ((installed_status == 0)); then
- if [[ -v $variable_name && "${!variable_name}" != "$installed_dir" ]]; then
- echo "$variable_name cannot override the installed Cloud Compose program directory" >&2
- return 1
- fi
- selected="$installed_dir"
- cloud_compose_validate_installed_program_dir "$selected" || return 1
- for name in "$@"; do
- [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.(jq|awk)$ ]] || {
- echo "Unsafe Cloud Compose program name: $name" >&2
- return 1
- }
- program="$selected/$name"
- cloud_compose_validate_installed_program "$program" "$selected" || return 1
- done
- if [[ ! -v $variable_name ]]; then
- printf -v "$variable_name" '%s' "$selected"
- fi
- readonly "$variable_name"
- else
- if [[ -v $variable_name && -n "${!variable_name}" ]]; then
- selected="${!variable_name}"
- else
- selected="$repository_dir"
- fi
- printf -v "$variable_name" '%s' "$selected"
- fi
-}
-
-cloud_compose_bind_program() {
- local owner_source="$1" variable_name="$2" installed_program="$3" repository_program="$4"
- local owner_path selected program_dir installed_status=0
-
- owner_path="$(cloud_compose_program_owner_path "$owner_source")" || return 1
- if cloud_compose_owner_is_installed "$owner_path"; then
- installed_status=0
- else
- installed_status=$?
- fi
- if ((installed_status > 1)); then
- return 1
- fi
- if ((installed_status == 0)); then
- if [[ -v $variable_name && "${!variable_name}" != "$installed_program" ]]; then
- echo "$variable_name cannot override the installed Cloud Compose program" >&2
- return 1
- fi
- selected="$installed_program"
- program_dir="${installed_program%/*}"
- cloud_compose_validate_installed_program "$selected" "$program_dir" || return 1
- if [[ ! -v $variable_name ]]; then
- printf -v "$variable_name" '%s' "$selected"
- fi
- readonly "$variable_name"
- else
- if [[ -v $variable_name && -n "${!variable_name}" ]]; then
- selected="${!variable_name}"
- else
- selected="$repository_program"
- fi
- printf -v "$variable_name" '%s' "$selected"
- fi
-}
-
-cloud_compose_bind_source_program() {
- local owner_source="$1" variable_name="$2" installed_program="$3" repository_program="$4"
- local owner_path selected installed_status=0
-
- owner_path="$(cloud_compose_program_owner_path "$owner_source")" || return 1
- if cloud_compose_owner_is_installed "$owner_path"; then
- installed_status=0
- else
- installed_status=$?
- fi
- if ((installed_status > 1)); then
- return 1
- fi
- if ((installed_status == 0)); then
- if [[ -v $variable_name && "${!variable_name}" != "$installed_program" ]]; then
- echo "$variable_name cannot override the installed Cloud Compose source program" >&2
- return 1
- fi
- selected="$installed_program"
- cloud_compose_validate_installed_source "$selected" || return 1
- if [[ ! -v $variable_name ]]; then
- printf -v "$variable_name" '%s' "$selected"
- fi
- readonly "$variable_name"
- else
- if [[ -v $variable_name && -n "${!variable_name}" ]]; then
- selected="${!variable_name}"
- else
- selected="$repository_program"
- fi
- printf -v "$variable_name" '%s' "$selected"
- fi
-}
diff --git a/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh b/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh
index 63eed17..294e89e 100755
--- a/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh
+++ b/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh
@@ -2,16 +2,12 @@
set -eu
-if [[ "$#" -ne 2 ]]; then
- echo "usage: gcp-cloud-init-finalize.sh INIT_COMMANDS_FILE DIAGNOSTICS_SHA256" >&2
+if [[ "$#" -ne 1 ]]; then
+ echo "usage: gcp-cloud-init-finalize.sh INIT_COMMANDS_FILE" >&2
exit 2
fi
init_commands_file="$1"
-diagnostics_sha256="$2"
-
-bash /var/lib/cloud-compose/bootstrap/rootfs-archive.sh \
- install-diagnostics "$diagnostics_sha256"
if [[ ! -f /run/cloud-compose-filesystems-ready ]]; then
echo "Cloud Compose filesystems were not prepared; refusing application initialization" >&2
exit 1
@@ -29,4 +25,4 @@ chmod 0775 /mnt/disks/volumes
install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops
rm -f /var/lib/cloud-compose/bootstrap-complete
/etc/cloud-compose/libexec/harden-bootstrap-paths.sh
-bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
+/etc/cloud-compose/bin/bootstrap-sitectl host systemd ensure-bootstrap
diff --git a/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh b/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh
index 16449d4..fe8314d 100755
--- a/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh
+++ b/rootfs/etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh
@@ -17,7 +17,7 @@ case "$rollout_enabled" in
;;
esac
-bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh
+/etc/cloud-compose/bin/bootstrap-sitectl host marker valid /var/lib/cloud-compose/bootstrap-complete
if [[ "$rollout_enabled" == "true" ]]; then
bash /home/cloud-compose/deploy-rollout.sh >>/home/cloud-compose/run.log 2>&1
fi
diff --git a/rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh b/rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh
deleted file mode 100755
index 55a7904..0000000
--- a/rootfs/etc/cloud-compose/libexec/gcp-filesystem-boot.sh
+++ /dev/null
@@ -1,70 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-if [[ "$#" -ne 5 ]]; then
- echo "usage: gcp-filesystem-boot.sh FRESH_FILESYSTEM_IDENTITY USE_OVERLAY PREP_PROGRAM PERSIST_PROGRAM FSTAB_RECONCILE_PROGRAM" >&2
- exit 2
-fi
-
-fresh_filesystem_identity="$1"
-use_overlay="$2"
-filesystem_prep="$3"
-filesystem_persist="$4"
-filesystem_reconcile="$5"
-
-require_root_owned_data_program() {
- local path="$1" metadata
-
- if [[ -L "$path" || ! -f "$path" ]]; then
- echo "Checked filesystem data program is missing or unsafe: $path" >&2
- return 1
- fi
- metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$path")" || return 1
- if [[ "$metadata" != "0:0:600:1:regular file" ]]; then
- echo "Checked filesystem data program is not an unlinked root-owned mode-0600 file: $path" >&2
- return 1
- fi
-}
-
-case "$use_overlay" in
- true | false) ;;
- *)
- echo "USE_OVERLAY must be true or false" >&2
- exit 2
- ;;
-esac
-
-require_root_owned_data_program "$filesystem_reconcile"
-
-rm -f /run/cloud-compose-filesystems-ready
-bash "$filesystem_prep" /dev/disk/by-id/google-data /mnt/disks/data \
- --publish-fresh-marker "$fresh_filesystem_identity"
-bash "$filesystem_prep" /dev/disk/by-id/google-docker-volumes /mnt/disks/volumes
-mkdir -p /mnt/disks/data/docker/volumes
-if ! mountpoint -q /mnt/disks/data/docker/volumes; then
- mount --bind /mnt/disks/volumes /mnt/disks/data/docker/volumes
-fi
-for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do
- if ! mountpoint -q -- "$required_mount"; then
- echo "Required cloud-compose mount is unavailable: $required_mount" >&2
- exit 1
- fi
-done
-
-if [[ "$use_overlay" == "true" ]]; then
- mkdir -p /mnt/disks/prod-readonly
- if ! mountpoint -q /mnt/disks/prod-readonly; then
- mount -o ro "$(readlink -f /dev/disk/by-id/google-prod-volumes)" \
- /mnt/disks/prod-readonly
- fi
- CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM="$filesystem_reconcile" bash "$filesystem_persist" \
- /dev/disk/by-id/google-data \
- /dev/disk/by-id/google-docker-volumes \
- /dev/disk/by-id/google-prod-volumes
-else
- CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM="$filesystem_reconcile" bash "$filesystem_persist" \
- /dev/disk/by-id/google-data \
- /dev/disk/by-id/google-docker-volumes
-fi
-install -m 0600 /dev/null /run/cloud-compose-filesystems-ready
diff --git a/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh b/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh
index eccc403..c1272f5 100755
--- a/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh
+++ b/rootfs/etc/cloud-compose/libexec/harden-bootstrap-paths.sh
@@ -1,106 +1,5 @@
#!/usr/bin/env bash
-
set -euo pipefail
-
-cloud_compose_home="/home/cloud-compose"
-
-if [[ -L "$cloud_compose_home" || ! -d "$cloud_compose_home" ]]; then
- echo "Cloud Compose home is unavailable or unsafe" >&2
- exit 1
-fi
-
-# cloud-init creates the operator account before all providers finish placing
-# the checked-in runtime files. Close that initial writable-home window before
-# any root process sources or executes those files. Files must already be
-# root-owned, regular, and unlinked before this script normalizes archive modes;
-# an operator replacement therefore fails closed instead of being blessed.
-chown root:root "$cloud_compose_home"
-chmod 0755 "$cloud_compose_home"
-
-require_root_owned_regular_file() {
- local path="$1"
- local metadata
-
- if [[ -L "$path" || ! -f "$path" ]]; then
- echo "Unsafe Cloud Compose bootstrap file: $path" >&2
- exit 1
- fi
- metadata="$(stat -c '%u:%h:%F' -- "$path")"
- if [[ "$metadata" != "0:1:regular file" ]]; then
- echo "Cloud Compose bootstrap file is not an unlinked root-owned regular file: $path" >&2
- exit 1
- fi
-}
-
-shopt -s nullglob
-programs=(
- "$cloud_compose_home"/*.sh
- "$cloud_compose_home"/*.jq
- "$cloud_compose_home"/*.awk
-)
-shopt -u nullglob
-for path in "${programs[@]}"; do
- require_root_owned_regular_file "$path"
-done
-
-for dispatcher in init up down rollout; do
- path="${cloud_compose_home}/${dispatcher}"
- if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then
- echo "Unsafe Cloud Compose dispatcher: $path" >&2
- exit 1
- fi
- if [[ -f "$path" ]]; then
- require_root_owned_regular_file "$path"
- fi
-done
-
-for input in .env compose-projects.json application-env.json managed-runtime-artifacts.tsv; do
- path="${cloud_compose_home}/${input}"
- if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then
- echo "Unsafe Cloud Compose input: $path" >&2
- exit 1
- fi
- if [[ -f "$path" ]]; then
- require_root_owned_regular_file "$path"
- fi
-done
-
-find "$cloud_compose_home" -mindepth 1 -maxdepth 1 -type f -name '*.sh' \
- -exec chown root:root {} + \
- -exec chmod 0755 {} +
-find "$cloud_compose_home" -mindepth 1 -maxdepth 1 -type f -name '*.jq' \
- -exec chown root:root {} + \
- -exec chmod 0644 {} +
-find "$cloud_compose_home" -mindepth 1 -maxdepth 1 -type f -name '*.awk' \
- -exec chown root:root {} + \
- -exec chmod 0644 {} +
-
-for dispatcher in init up down rollout; do
- path="${cloud_compose_home}/${dispatcher}"
- if [[ -f "$path" ]]; then
- chown root:root "$path"
- chmod 0755 "$path"
- fi
-done
-
-for input in .env compose-projects.json application-env.json managed-runtime-artifacts.tsv; do
- path="${cloud_compose_home}/${input}"
- if [[ -f "$path" ]]; then
- chown root:cloud-compose "$path"
- chmod 0640 "$path"
- fi
-done
-
-if [[ -L "${cloud_compose_home}/bin" ||
- ( -e "${cloud_compose_home}/bin" && ! -d "${cloud_compose_home}/bin" ) ]]; then
- echo "Unsafe Cloud Compose command directory" >&2
- exit 1
-fi
-install -d -m 0755 -o root -g root "${cloud_compose_home}/bin"
-
-home_identity="$(stat -Lc '%U:%G:%a' -- "$cloud_compose_home")"
-bin_identity="$(stat -Lc '%U:%G:%a' -- "${cloud_compose_home}/bin")"
-if [[ "$home_identity" != "root:root:755" || "$bin_identity" != "root:root:755" ]]; then
- echo "Cloud Compose privileged paths have unsafe ownership or modes" >&2
- exit 1
-fi
+sitectl=/etc/cloud-compose/bin/bootstrap-sitectl
+[[ -x "$sitectl" && ! -L "$sitectl" ]] || sitectl=/home/cloud-compose/bin/sitectl
+exec "$sitectl" host security secure-runtime
diff --git a/rootfs/etc/cloud-compose/libexec/install-rootfs.py b/rootfs/etc/cloud-compose/libexec/install-rootfs.py
new file mode 100755
index 0000000..aea6312
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/install-rootfs.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+
+import json
+import os
+import pathlib
+import pwd
+import re
+import stat
+import sys
+import tempfile
+
+SAFE_PATH = re.compile(r"^[A-Za-z0-9._/+@:-]+$")
+SAFE_MODES = {"0644", "0755"}
+
+
+def fail(message):
+ raise SystemExit(f"rootfs bundle: {message}")
+
+
+def destination(relative):
+ if not relative or relative.startswith("/") or not SAFE_PATH.fullmatch(relative):
+ fail(f"unsafe path: {relative!r}")
+ normalized = pathlib.PurePosixPath(relative)
+ parts = normalized.parts
+ if str(normalized) != relative:
+ fail(f"non-canonical path: {relative!r}")
+ if any(part in ("", ".", "..") for part in parts):
+ fail(f"unsafe path: {relative!r}")
+ prefix = "/var/lib/cloud-compose/mounted-rootfs" if relative.startswith("mnt/disks/") else ""
+ return pathlib.Path(prefix + "/" + relative)
+
+
+def ensure_parent(path):
+ current = pathlib.Path("/")
+ for part in path.parent.parts[1:]:
+ current /= part
+ try:
+ metadata = current.lstat()
+ except FileNotFoundError:
+ current.mkdir(mode=0o755)
+ metadata = current.lstat()
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
+ fail(f"unsafe parent: {current}")
+ if metadata.st_uid != 0 or stat.S_IMODE(metadata.st_mode) & 0o022:
+ fail(f"untrusted parent: {current}")
+
+
+def secure_runtime_home():
+ path = pathlib.Path("/home/cloud-compose")
+ try:
+ account = pwd.getpwnam("cloud-compose")
+ except KeyError:
+ fail("cloud-compose account is missing")
+ try:
+ descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ except OSError as error:
+ fail(f"unsafe runtime home: {error}")
+ try:
+ metadata = os.fstat(descriptor)
+ if (
+ metadata.st_uid not in (0, account.pw_uid)
+ or metadata.st_gid not in (0, account.pw_gid)
+ or stat.S_IMODE(metadata.st_mode) & 0o022
+ ):
+ fail("untrusted runtime home ownership or mode")
+ os.fchown(descriptor, 0, 0)
+ os.fchmod(descriptor, 0o755)
+ finally:
+ os.close(descriptor)
+
+
+def install(path, mode, content):
+ if mode not in SAFE_MODES:
+ fail(f"unsupported mode for {path}: {mode}")
+ if not isinstance(content, str):
+ fail(f"invalid content for {path}")
+ decoded = content.encode("utf-8")
+ ensure_parent(path)
+ if path.exists() or path.is_symlink():
+ metadata = path.lstat()
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
+ fail(f"unsafe destination: {path}")
+ descriptor, temporary = tempfile.mkstemp(prefix=".cloud-compose-", dir=path.parent)
+ try:
+ with os.fdopen(descriptor, "wb") as output:
+ output.write(decoded)
+ output.flush()
+ os.fsync(output.fileno())
+ os.fchmod(output.fileno(), int(mode, 8))
+ os.replace(temporary, path)
+ finally:
+ try:
+ os.unlink(temporary)
+ except FileNotFoundError:
+ pass
+
+
+def main():
+ if os.geteuid() != 0 or len(sys.argv) != 2:
+ fail("usage: install-rootfs.py BUNDLE")
+ with open(sys.argv[1], "r", encoding="utf-8") as source:
+ payload = json.load(source)
+ if set(payload) != {"version", "files"} or payload["version"] != 1 or not isinstance(payload["files"], dict):
+ fail("invalid bundle schema")
+ secure_runtime_home()
+ for relative in sorted(payload["files"]):
+ entry = payload["files"][relative]
+ if not isinstance(entry, dict) or set(entry) != {"mode", "content"}:
+ fail(f"invalid entry: {relative}")
+ install(destination(relative), entry["mode"], entry["content"])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh b/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh
index 68375ec..a72baf2 100755
--- a/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh
+++ b/rootfs/etc/cloud-compose/libexec/linux-vm-cloud-init.sh
@@ -2,46 +2,24 @@
set -euo pipefail
-if [[ "$#" -ne 5 ]]; then
- echo "usage: linux-vm-cloud-init.sh DATA_DEVICE VOLUMES_DEVICE ROOTFS_ARCHIVE_ENABLED ROLLOUT_ENABLED DIAGNOSTICS_SHA256" >&2
+if [[ "$#" -ne 4 ]]; then
+ echo "usage: linux-vm-cloud-init.sh DATA_DEVICE VOLUMES_DEVICE ROLLOUT_ENABLED SITECTL_VERSION" >&2
exit 2
fi
data_device="$1"
volumes_device="$2"
-rootfs_archive_enabled="$3"
-rollout_enabled="$4"
-diagnostics_sha256="$5"
-readonly bootstrap_dir=/var/lib/cloud-compose/bootstrap
-readonly archive_program="$bootstrap_dir/rootfs-archive.sh"
-readonly overlay_dir=/var/lib/cloud-compose/rootfs-overlay
-readonly filesystem_prep=/run/cloud-compose-prepare-filesystem
-readonly filesystem_persist=/run/cloud-compose-persist-filesystems
-readonly filesystem_reconcile=/run/cloud-compose-reconcile-fstab.awk
-
-require_root_owned_data_program() {
- local path="$1" metadata
-
- if [[ -L "$path" || ! -f "$path" ]]; then
- echo "Checked filesystem data program is missing or unsafe: $path" >&2
- return 1
- fi
- metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$path")" || return 1
- if [[ "$metadata" != "0:0:600:1:regular file" ]]; then
- echo "Checked filesystem data program is not an unlinked root-owned mode-0600 file: $path" >&2
- return 1
- fi
-}
-
-for boolean_name in rootfs_archive_enabled rollout_enabled; do
- case "${!boolean_name}" in
- true | false) ;;
- *)
- echo "${boolean_name^^} must be true or false" >&2
- exit 2
- ;;
- esac
-done
+rollout_enabled="$3"
+sitectl_version="$4"
+readonly sitectl=/etc/cloud-compose/bin/bootstrap-sitectl
+
+case "$rollout_enabled" in
+ true | false) ;;
+ *)
+ echo "ROLLOUT_ENABLED must be true or false" >&2
+ exit 2
+ ;;
+esac
if ! id -u cloud-compose >/dev/null 2>&1 || ! getent group docker >/dev/null 2>&1; then
echo "cloud-init did not create the cloud-compose user and docker group" >&2
exit 1
@@ -58,36 +36,16 @@ if [[ "$cloud_compose_in_docker_group" != "true" ]]; then
exit 1
fi
-if [[ "$rootfs_archive_enabled" == "false" ]]; then
- install -m 0600 -- /home/cloud-compose/prepare-filesystem.sh "$filesystem_prep"
- install -m 0600 -- /home/cloud-compose/persist-filesystems.sh "$filesystem_persist"
- install -m 0600 -- /etc/cloud-compose/awk/reconcile-fstab.awk "$filesystem_reconcile"
-fi
+bash /etc/cloud-compose/libexec/bootstrap-sitectl.sh \
+ --version "$sitectl_version"
+"$sitectl" host filesystems \
+ --data-device "$data_device" \
+ --volumes-device "$volumes_device" \
+ --fresh-identity fresh
-require_root_owned_data_program "$filesystem_reconcile"
-
-bash "$filesystem_prep" "$data_device" /mnt/disks/data --publish-fresh-marker
-bash "$filesystem_prep" "$volumes_device" /mnt/disks/volumes
-mkdir -p /mnt/disks/data/docker/volumes
-if ! mountpoint -q /mnt/disks/data/docker/volumes; then
- mount --bind /mnt/disks/volumes /mnt/disks/data/docker/volumes
-fi
-for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do
- if ! mountpoint -q -- "$required_mount"; then
- echo "Required cloud-compose mount is unavailable: $required_mount" >&2
- exit 1
- fi
-done
-CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM="$filesystem_reconcile" \
- bash "$filesystem_persist" "$data_device" "$volumes_device"
-
-if [[ "$rootfs_archive_enabled" == "true" ]]; then
- bash "$archive_program" install-staged "$overlay_dir"
-elif [[ -d /var/lib/cloud-compose/mounted-rootfs/mnt/disks ]]; then
+if [[ -d /var/lib/cloud-compose/mounted-rootfs/mnt/disks ]]; then
cp -a /var/lib/cloud-compose/mounted-rootfs/mnt/disks/. /mnt/disks/
fi
-
-bash "$archive_program" install-diagnostics "$diagnostics_sha256"
chown root:cloud-compose /mnt/disks/data
chmod 1775 /mnt/disks/data
chown cloud-compose:cloud-compose /mnt/disks/volumes
@@ -99,4 +57,4 @@ if [[ "$rollout_enabled" == "true" ]]; then
fi
rm -f /var/lib/cloud-compose/bootstrap-complete
/etc/cloud-compose/libexec/harden-bootstrap-paths.sh
-bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
+"$sitectl" host systemd ensure-bootstrap
diff --git a/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh b/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh
deleted file mode 100755
index 1526af1..0000000
--- a/rootfs/etc/cloud-compose/libexec/require-bootstrap-ready.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source /etc/cloud-compose/libexec/bootstrap-security.sh
-
-cloud_compose_bootstrap_require_root
-if ! cloud_compose_bootstrap_marker_ready; then
- echo "Cloud Compose bootstrap readiness evidence is missing or invalid" >&2
- exit 1
-fi
diff --git a/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh b/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh
deleted file mode 100755
index ff361db..0000000
--- a/rootfs/etc/cloud-compose/libexec/rootfs-archive.sh
+++ /dev/null
@@ -1,354 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-readonly stage_root=/run/cloud-compose-rootfs-stage
-readonly staged_rootfs="$stage_root/rootfs"
-readonly bootstrap_dir=/var/lib/cloud-compose/bootstrap
-readonly filesystem_prep=/run/cloud-compose-prepare-filesystem
-readonly filesystem_persist=/run/cloud-compose-persist-filesystems
-readonly filesystem_reconcile=/run/cloud-compose-reconcile-fstab.awk
-
-fail() {
- echo "rootfs archive: $*" >&2
- exit 1
-}
-
-canonical_file_mode() {
- case "$1" in
- *.sh) printf '755\n' ;;
- *) printf '644\n' ;;
- esac
-}
-
-require_safe_contract_path() {
- local relative_path="$1"
-
- [[ -n "$relative_path" && "$relative_path" != /* &&
- "$relative_path" != *$'\t'* && "$relative_path" != *$'\r'* &&
- "$relative_path" != *$'\n'* && "$relative_path" != *'//'*
- && ! "$relative_path" =~ (^|/)\.\.?(/|$) ]] ||
- fail "rootfs contract contains an unsafe path"
-}
-
-validate_rootfs_archive() {
- local archive_path="$1"
- local member_path normalized_path member_listing member_type
- local rootfs_present=false
-
- [[ -f "$archive_path" && ! -L "$archive_path" ]] ||
- fail "rootfs archive must be a regular file"
- while IFS= read -r member_path; do
- normalized_path="${member_path%/}"
- require_safe_contract_path "$normalized_path"
- [[ "$normalized_path" == rootfs || "$normalized_path" == rootfs/* ]] ||
- fail "rootfs archive contains a path outside rootfs"
- if [[ "$normalized_path" == rootfs ]]; then
- rootfs_present=true
- fi
- done < <(LC_ALL=C tar --quoting-style=literal -tzf "$archive_path")
- [[ "$rootfs_present" == "true" ]] ||
- fail "rootfs archive does not contain its rootfs directory"
-
- while IFS= read -r member_listing; do
- member_type="${member_listing:0:1}"
- [[ "$member_type" == "-" || "$member_type" == "d" ]] ||
- fail "rootfs archive contains a link or unsupported filesystem object"
- done < <(LC_ALL=C tar --numeric-owner --quoting-style=escape -tvzf "$archive_path")
-}
-
-validate_rootfs_test_source_archive() {
- local archive_path="$1" test_source_prefix="$2"
- local member_path normalized_path member_listing member_type
- local rootfs_present=false
-
- [[ -f "$archive_path" && ! -L "$archive_path" ]] ||
- fail "test-only rootfs source archive must be a regular file"
- [[ "$test_source_prefix" =~ ^cloud-compose-([0-9a-f]{40})$ ]] ||
- fail "test-only rootfs source archive prefix must identify one exact cloud-compose commit"
- while IFS= read -r member_path; do
- normalized_path="${member_path%/}"
- require_safe_contract_path "$normalized_path"
- [[ "$normalized_path" == "$test_source_prefix" ||
- "$normalized_path" == "$test_source_prefix/"* ]] ||
- fail "test-only rootfs source archive contains a path outside its exact commit prefix"
- if [[ "$normalized_path" == "$test_source_prefix/rootfs" ]]; then
- rootfs_present=true
- fi
- done < <(LC_ALL=C tar --quoting-style=literal -tzf "$archive_path")
- [[ "$rootfs_present" == "true" ]] ||
- fail "test-only rootfs source archive does not contain ${test_source_prefix}/rootfs"
-
- while IFS= read -r member_listing; do
- member_type="${member_listing:0:1}"
- [[ "$member_type" == "-" || "$member_type" == "d" ]] ||
- fail "test-only rootfs source archive contains a link or unsupported filesystem object"
- done < <(LC_ALL=C tar --numeric-owner --quoting-style=escape -tvzf "$archive_path")
-}
-
-rootfs_contract_sha256() {
- local rootfs_dir="$1"
- local contract_manifest="$2"
- local require_root_owner="${3:-true}"
- local absolute_path relative_path expected_mode metadata owner file_sha256 unsupported_path
-
- [[ -d "$rootfs_dir" && ! -L "$rootfs_dir" ]] ||
- fail "rootfs contract source must be a real directory"
- unsupported_path="$(find "$rootfs_dir" -mindepth 1 ! -type d ! -type f -print -quit)"
- if [[ -n "$unsupported_path" ]]; then
- fail "rootfs contract contains a symlink or unsupported filesystem object"
- fi
-
- : >"$contract_manifest"
- while IFS= read -r -d '' absolute_path; do
- relative_path="${absolute_path#"$rootfs_dir"/}"
- require_safe_contract_path "$relative_path"
- metadata="$(stat -c '%a:%F' -- "$absolute_path")"
- [[ "$metadata" == "755:directory" ]] ||
- fail "rootfs contract directory is not mode 0755: $relative_path"
- owner="$(stat -c '%u:%g' -- "$absolute_path")"
- [[ "$require_root_owner" != "true" || "$owner" == "0:0" ]] ||
- fail "rootfs contract directory is not root-owned: $relative_path"
- printf 'd\t0:0:755\t%s\n' "$relative_path" >>"$contract_manifest"
- done < <(find "$rootfs_dir" -mindepth 1 -type d -print0 | LC_ALL=C sort -z)
-
- while IFS= read -r -d '' absolute_path; do
- relative_path="${absolute_path#"$rootfs_dir"/}"
- require_safe_contract_path "$relative_path"
- expected_mode="$(canonical_file_mode "$relative_path")"
- metadata="$(stat -c '%a:%h:%F' -- "$absolute_path")"
- [[ "$metadata" == "${expected_mode}:1:regular file" ]] ||
- fail "rootfs contract file metadata is not canonical: $relative_path"
- owner="$(stat -c '%u:%g' -- "$absolute_path")"
- [[ "$require_root_owner" != "true" || "$owner" == "0:0" ]] ||
- fail "rootfs contract file is not root-owned: $relative_path"
- read -r file_sha256 _ < <(sha256sum -- "$absolute_path")
- printf 'f\t%s\t0:0:%s\t%s\n' \
- "$file_sha256" "$expected_mode" "$relative_path" >>"$contract_manifest"
- done < <(find "$rootfs_dir" -type f -print0 | LC_ALL=C sort -z)
-
- read -r contract_sha256 _ < <(sha256sum -- "$contract_manifest")
- printf '%s\n' "$contract_sha256"
-}
-
-require_safe_overlay_dir() {
- local overlay_dir="$1"
-
- if [[ -n "$overlay_dir" && "$overlay_dir" != "/var/lib/cloud-compose/rootfs-overlay" ]]; then
- fail "overlay directory must be /var/lib/cloud-compose/rootfs-overlay"
- fi
- if [[ -L "$overlay_dir" ]]; then
- fail "overlay directory must not be a symlink"
- fi
-}
-
-install_archive_tools() {
- if command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1; then
- return
- fi
-
- if command -v apt-get >/dev/null 2>&1; then
- export DEBIAN_FRONTEND=noninteractive
- apt-get update
- apt-get install -y ca-certificates curl tar
- elif command -v dnf >/dev/null 2>&1; then
- dnf install -y ca-certificates curl tar
- elif command -v rpm-ostree >/dev/null 2>&1; then
- rpm-ostree install --apply-live ca-certificates curl tar
- else
- fail "no supported package manager found to install curl and tar"
- fi
-}
-
-prepare_archive() {
- local archive_url_b64="$1" archive_sha256="$2" expected_contract_sha256="$3"
- local test_source_prefix="${4:-}"
- local archive_url extract_dir rootfs_dir required_command
- local contract_manifest contract_sha256 source_commit unsupported_path
-
- archive_url="$(printf '%s' "$archive_url_b64" | base64 -d)" || \
- fail "rootfs archive URL is not valid base64 data"
- [[ "$archive_url" == https://* ]] || fail "rootfs archive URL must use HTTPS"
- [[ "$archive_url" != *[[:space:]]* ]] || fail "rootfs archive URL must not contain whitespace"
- [[ "$archive_sha256" =~ ^[0-9a-f]{64}$ ]] || \
- fail "rootfs archive checksum must be a lowercase SHA-256 digest"
- [[ "$expected_contract_sha256" =~ ^[0-9a-f]{64}$ ]] || \
- fail "rootfs content contract must be a lowercase SHA-256 digest"
- if [[ -n "$test_source_prefix" ]]; then
- [[ "$test_source_prefix" =~ ^cloud-compose-([0-9a-f]{40})$ ]] ||
- fail "test-only rootfs source archive prefix must identify one exact cloud-compose commit"
- source_commit="${BASH_REMATCH[1]}"
- [[ "$archive_url" == "https://github.com/libops/cloud-compose/archive/${source_commit}.tar.gz" ]] ||
- fail "test-only rootfs source archive URL must select the exact commit named by its prefix"
- fi
-
- install_archive_tools
- for required_command in awk chmod curl find sha256sum sort stat tar; do
- command -v "$required_command" >/dev/null 2>&1 || \
- fail "$required_command is required to install the verified rootfs archive"
- done
-
- if [[ -L "$stage_root" ]]; then
- fail "rootfs archive stage must not be a symlink"
- fi
- rm -rf -- "$stage_root"
- install -d -m 0700 -o root -g root "$stage_root"
- extract_dir="$stage_root/extract"
- install -d -m 0700 -o root -g root "$extract_dir"
-
- curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
- --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 900 \
- --connect-timeout 10 --max-time 300 \
- -o "$stage_root/rootfs.tar.gz" -- "$archive_url"
- printf '%s %s\n' "$archive_sha256" "$stage_root/rootfs.tar.gz" | sha256sum -c -
- if [[ -n "$test_source_prefix" ]]; then
- validate_rootfs_test_source_archive "$stage_root/rootfs.tar.gz" "$test_source_prefix"
- tar --no-same-owner --same-permissions -xzf "$stage_root/rootfs.tar.gz" \
- -C "$extract_dir" "$test_source_prefix/rootfs"
- rootfs_dir="$extract_dir/$test_source_prefix/rootfs"
- # This test-only GitHub source path preserves Git modes rather than
- # canonical rootfs package modes. Normalize only the isolated rootfs
- # subtree; the exact byte/content contract remains authoritative.
- find "$rootfs_dir" -depth -type d -empty -delete
- find "$rootfs_dir" -type d -exec chmod 0755 -- {} +
- find "$rootfs_dir" -type f -exec chmod 0644 -- {} +
- find "$rootfs_dir" -type f -name '*.sh' -exec chmod 0755 -- {} +
- else
- validate_rootfs_archive "$stage_root/rootfs.tar.gz"
- tar --no-same-owner --same-permissions -xzf "$stage_root/rootfs.tar.gz" -C "$extract_dir"
- rootfs_dir="$extract_dir/rootfs"
- fi
- [[ -n "$rootfs_dir" && -d "$rootfs_dir" ]] || \
- fail "rootfs directory not found in $archive_url"
- unsupported_path="$(find "$rootfs_dir" -mindepth 1 ! -type d ! -type f -print -quit)"
- if [[ -n "$unsupported_path" ]]; then
- fail "verified rootfs archive contains a symlink or unsupported filesystem object"
- fi
- contract_manifest="$stage_root/rootfs-contract.tsv"
- contract_sha256="$(rootfs_contract_sha256 "$rootfs_dir" "$contract_manifest" true)"
- [[ "$contract_sha256" == "$expected_contract_sha256" ]] || \
- fail "rootfs archive paths, bytes, or canonical metadata do not match this cloud-compose module source"
- mv -- "$rootfs_dir" "$staged_rootfs"
- rm -f -- "$stage_root/rootfs.tar.gz"
- rm -rf -- "$extract_dir"
-
- [[ -f "$staged_rootfs/home/cloud-compose/prepare-filesystem.sh" &&
- -f "$staged_rootfs/home/cloud-compose/persist-filesystems.sh" &&
- -f "$staged_rootfs/etc/cloud-compose/awk/reconcile-fstab.awk" ]] || \
- fail "verified rootfs archive is missing filesystem preparation programs"
- install -m 0600 -- \
- "$staged_rootfs/home/cloud-compose/prepare-filesystem.sh" \
- "$filesystem_prep"
- install -m 0600 -- \
- "$staged_rootfs/home/cloud-compose/persist-filesystems.sh" \
- "$filesystem_persist"
- install -m 0600 -- \
- "$staged_rootfs/etc/cloud-compose/awk/reconcile-fstab.awk" \
- "$filesystem_reconcile"
-}
-
-install_staged_archive() {
- local overlay_dir="${1:-}"
-
- [[ -d "$staged_rootfs" && ! -L "$staged_rootfs" ]] || \
- fail "verified rootfs directory is unavailable during installation"
- require_safe_overlay_dir "$overlay_dir"
- cp -a "$staged_rootfs"/. /
- if [[ -n "$overlay_dir" && -d "$overlay_dir" ]]; then
- cp -a "$overlay_dir"/. /
- rm -rf -- "$overlay_dir"
- fi
- rm -rf -- "$stage_root"
-}
-
-verify_linux_bootstrap() {
- local expected_sha256="$1"
- local source="$bootstrap_dir/linux-vm-cloud-init.sh"
- local metadata
-
- [[ "$expected_sha256" =~ ^[0-9a-f]{64}$ ]] || \
- fail "Linux VM cloud-init checksum must be a lowercase SHA-256 digest"
- [[ -f "$source" && ! -L "$source" ]] || \
- fail "Linux VM cloud-init program is missing or redirected"
- metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$source")"
- [[ "$metadata" == "0:0:700:1:regular file" ]] || \
- fail "Linux VM cloud-init program is not an unlinked root-owned mode-0700 file"
- printf '%s %s\n' "$expected_sha256" "$source" | sha256sum -c -
-}
-
-install_bootstrap_diagnostics() {
- local expected_sha256="$1"
- local source="$bootstrap_dir/cloud-compose-diagnostics.sh"
- local destination=/etc/cloud-compose/bin/cloud-compose-diagnostics.sh
- local source_metadata destination_metadata
-
- [[ "$expected_sha256" =~ ^[0-9a-f]{64}$ ]] || \
- fail "diagnostics checksum must be a lowercase SHA-256 digest"
- [[ -f "$source" && ! -L "$source" ]] || \
- fail "staged diagnostics program is missing or redirected"
- source_metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$source")"
- [[ "$source_metadata" == "0:0:600:1:regular file" ]] || \
- fail "staged diagnostics program is not an unlinked root-owned mode-0600 file"
- printf '%s %s\n' "$expected_sha256" "$source" | sha256sum -c -
-
- for directory in /etc/cloud-compose /etc/cloud-compose/bin; do
- [[ ! -L "$directory" ]] || fail "diagnostics destination is redirected: $directory"
- install -d -m 0755 -o root -g root -- "$directory"
- done
- install -m 0755 -o root -g root -- "$source" "$destination"
- destination_metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$destination")"
- [[ "$destination_metadata" == "0:0:755:1:regular file" ]] || \
- fail "installed diagnostics program is not an unlinked root-owned mode-0755 file"
- printf '%s %s\n' "$expected_sha256" "$destination" | sha256sum -c -
-}
-
-action="${1:-}"
-case "$action" in
- contract)
- [[ "$#" -eq 2 ]] || fail "usage: rootfs-archive.sh contract ROOTFS_DIR"
- contract_tmp="$(mktemp)"
- trap 'rm -f -- "$contract_tmp"' EXIT
- rootfs_contract_sha256 "$2" "$contract_tmp" false
- ;;
- validate-archive)
- [[ "$#" -eq 2 ]] || fail "usage: rootfs-archive.sh validate-archive ARCHIVE"
- validate_rootfs_archive "$2"
- ;;
- prepare)
- [[ "$#" -eq 4 ]] || \
- fail "usage: rootfs-archive.sh prepare URL_B64 SHA256 ROOTFS_CONTRACT_SHA256"
- prepare_archive "$2" "$3" "$4"
- ;;
- prepare-linux)
- [[ "$#" -eq 5 ]] || \
- fail "usage: rootfs-archive.sh prepare-linux URL_B64 SHA256 ROOTFS_CONTRACT_SHA256 LINUX_BOOTSTRAP_SHA256"
- prepare_archive "$2" "$3" "$4"
- verify_linux_bootstrap "$5"
- ;;
- prepare-linux-test-source)
- [[ "$#" -eq 6 ]] || \
- fail "usage: rootfs-archive.sh prepare-linux-test-source URL_B64 SHA256 ROOTFS_CONTRACT_SHA256 LINUX_BOOTSTRAP_SHA256 TEST_SOURCE_PREFIX"
- prepare_archive "$2" "$3" "$4" "$6"
- verify_linux_bootstrap "$5"
- ;;
- install-staged)
- [[ "$#" -le 2 ]] || fail "usage: rootfs-archive.sh install-staged [OVERLAY_DIR]"
- install_staged_archive "${2:-}"
- ;;
- install)
- [[ "$#" -le 5 && "$#" -ge 4 ]] || \
- fail "usage: rootfs-archive.sh install URL_B64 SHA256 ROOTFS_CONTRACT_SHA256 [OVERLAY_DIR]"
- [[ -f /run/cloud-compose-filesystems-ready ]] || \
- fail "Cloud Compose filesystems were not prepared; refusing rootfs installation"
- prepare_archive "$2" "$3" "$4"
- install_staged_archive "${5:-}"
- ;;
- install-diagnostics)
- [[ "$#" -eq 2 ]] || \
- fail "usage: rootfs-archive.sh install-diagnostics DIAGNOSTICS_SHA256"
- install_bootstrap_diagnostics "$2"
- ;;
- *)
- fail "usage: rootfs-archive.sh contract|validate-archive|prepare|prepare-linux|prepare-linux-test-source|install-staged|install|install-diagnostics ..."
- ;;
-esac
diff --git a/rootfs/etc/cloud-compose/libexec/run-bootstrap.sh b/rootfs/etc/cloud-compose/libexec/run-bootstrap.sh
deleted file mode 100755
index eda9d3b..0000000
--- a/rootfs/etc/cloud-compose/libexec/run-bootstrap.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source /etc/cloud-compose/libexec/bootstrap-security.sh
-
-cloud_compose_secure_runtime_home
-exec /bin/bash /home/cloud-compose/run.sh
diff --git a/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh b/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh
deleted file mode 100755
index bc7b557..0000000
--- a/rootfs/etc/cloud-compose/libexec/run-lifecycle-program.sh
+++ /dev/null
@@ -1,93 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-validate_only=false
-if [[ "${1:-}" == "--validate" ]]; then
- validate_only=true
- shift
-fi
-
-if [[ "$#" -ne 2 ]]; then
- echo "usage: run-lifecycle-program.sh [--validate] LIFECYCLE PROGRAM" >&2
- exit 2
-fi
-
-lifecycle="$1"
-entry="$2"
-case "$lifecycle" in
- init | up | down | rollout) ;;
- *)
- echo "Unsupported Cloud Compose lifecycle: $lifecycle" >&2
- exit 2
- ;;
-esac
-
-case "$entry" in
- true)
- exit 0
- ;;
- false)
- [[ "$validate_only" == "true" ]] && exit 0
- exit 1
- ;;
- "/home/cloud-compose/default-lifecycle.sh $lifecycle")
- program=/home/cloud-compose/default-lifecycle.sh
- program_args=("$lifecycle")
- ;;
- *)
- program_dir="${CLOUD_COMPOSE_LIFECYCLE_PROGRAM_DIR:-/etc/cloud-compose/lifecycle.d}"
- if [[ "$program_dir" != /* || "$program_dir" == "/" ||
- "$program_dir" == *$'\n'* || "$program_dir" == *$'\r'* ||
- "$program_dir" =~ (^|/)\.\.?(/|$) ]]; then
- echo "Unsafe Cloud Compose lifecycle program directory: $program_dir" >&2
- exit 2
- fi
- program_name="${entry#"$program_dir"/}"
- if [[ "$entry" != "$program_dir/"* || "$program_name" == */* ||
- ! "$program_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
- echo "Lifecycle entries must name one checked program in ${program_dir}: $entry" >&2
- exit 2
- fi
- program="$entry"
- program_args=()
- ;;
-esac
-
-executor_uid="$(stat -c '%u' -- "${BASH_SOURCE[0]}")"
-program_parent="$(dirname -- "$program")"
-if [[ -L "$program_parent" || ! -d "$program_parent" || -L "$program" || ! -f "$program" ]]; then
- echo "Lifecycle program is missing or redirected: $program" >&2
- exit 1
-fi
-parent_metadata="$(stat -c '%u:%a:%F' -- "$program_parent")"
-program_metadata="$(stat -c '%u:%a:%h:%F' -- "$program")"
-IFS=: read -r parent_uid parent_mode parent_kind <<<"$parent_metadata"
-IFS=: read -r program_uid program_mode program_links program_kind <<<"$program_metadata"
-if [[ "$parent_uid" != "$executor_uid" || "$parent_kind" != "directory" ||
- ! "$parent_mode" =~ ^[0-7]{3,4}$ || $((8#$parent_mode & 0022)) -ne 0 ]]; then
- echo "Lifecycle program directory is not controlled by the executor owner: $program_parent" >&2
- exit 1
-fi
-if [[ "$program_uid" != "$executor_uid" || "$program_links" != "1" ||
- "$program_kind" != "regular file" || ! "$program_mode" =~ ^[0-7]{3,4}$ ||
- $((8#$program_mode & 0122)) -ne 0100 ]]; then
- echo "Lifecycle program is not an unlinked owner-executable file controlled by the executor owner: $program" >&2
- exit 1
-fi
-
-if [[ "$validate_only" == "true" ]]; then
- exit 0
-fi
-
-# Container-Optimized OS mounts /home with noexec. The built-in lifecycle
-# program is still an unlinked, root-owned regular file below the checked
-# root-owned home boundary, so open it through the fixed system interpreter
-# after validation instead of asking the kernel to execute it from that mount.
-# Keep custom programs on the direct-exec path so their reviewed interpreter or
-# binary contract is preserved. No manifest value is evaluated as shell source.
-if [[ "$program" == "/home/cloud-compose/default-lifecycle.sh" ]]; then
- exec /bin/bash -- "$program" "${program_args[@]}"
-fi
-
-exec "$program" "${program_args[@]}"
diff --git a/rootfs/etc/cloud-compose/libexec/run-root-program.sh b/rootfs/etc/cloud-compose/libexec/run-root-program.sh
deleted file mode 100755
index b5e37e3..0000000
--- a/rootfs/etc/cloud-compose/libexec/run-root-program.sh
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source /etc/cloud-compose/libexec/bootstrap-security.sh
-
-program="${1:-}"
-if [[ -z "$program" ]]; then
- echo "A Cloud Compose root program is required" >&2
- exit 2
-fi
-shift
-
-case "$program" in
- configure-metadata-firewall.sh | deploy-rollout.sh | docker-prune.sh | \
- libops-managed-runtime.sh | mount-overlays.sh | offhost-backup.sh | \
- restore-test.sh | rotate-keys-daily.sh | \
- vault-agent-readiness.sh) ;;
- *)
- echo "Unsupported Cloud Compose root program: $program" >&2
- exit 2
- ;;
-esac
-
-cloud_compose_secure_runtime_home
-exec /bin/bash "/home/cloud-compose/$program" "$@"
diff --git a/rootfs/etc/cloud-compose/libexec/sitectl-host.sh b/rootfs/etc/cloud-compose/libexec/sitectl-host.sh
new file mode 100755
index 0000000..969d957
--- /dev/null
+++ b/rootfs/etc/cloud-compose/libexec/sitectl-host.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# The installed profile is root-owned before any service uses this launcher.
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+
+sitectl=/home/cloud-compose/bin/sitectl
+if ((EUID == 0)) && [[ -x /etc/cloud-compose/bin/bootstrap-sitectl && ! -L /etc/cloud-compose/bin/bootstrap-sitectl ]]; then
+ sitectl=/etc/cloud-compose/bin/bootstrap-sitectl
+fi
+[[ -x "$sitectl" ]] || {
+ echo "Verified sitectl is not installed" >&2
+ exit 1
+}
+
+exec "$sitectl" host "$@"
diff --git a/rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh b/rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
deleted file mode 100755
index b38af25..0000000
--- a/rootfs/etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source /etc/cloud-compose/libexec/bootstrap-security.sh
-
-cloud_compose_secure_runtime_home
-exec /bin/bash /home/cloud-compose/start-cloud-compose-bootstrap.sh "$@"
diff --git a/rootfs/etc/systemd/system/cloud-compose-bootstrap.service b/rootfs/etc/systemd/system/cloud-compose-bootstrap.service
index 6277dda..97bb40d 100644
--- a/rootfs/etc/systemd/system/cloud-compose-bootstrap.service
+++ b/rootfs/etc/systemd/system/cloud-compose-bootstrap.service
@@ -13,7 +13,7 @@ StartLimitBurst=3
Type=oneshot
RemainAfterExit=yes
ExecCondition=/bin/bash /etc/cloud-compose/libexec/bootstrap-required.sh
-ExecStart=/bin/bash /etc/cloud-compose/libexec/run-bootstrap.sh
+ExecStart=/bin/bash /home/cloud-compose/run.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 701132d..0a54e1a 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 /etc/cloud-compose/libexec/run-root-program.sh docker-prune.sh
+ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh maintenance docker-prune
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 4a39855..1662433 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 /etc/cloud-compose/libexec/run-root-program.sh rotate-keys-daily.sh
+ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh keys daily
User=root
Group=root
TimeoutStartSec=1h
diff --git a/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service b/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service
index bc45f65..2724618 100644
--- a/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service
+++ b/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service
@@ -7,5 +7,5 @@ RequiresMountsFor=/mnt/disks/data
Type=oneshot
User=cloud-compose
Group=cloud-compose
-ExecStart=/bin/bash /home/cloud-compose/mariadb-backup.sh
+ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh backup mariadb
TimeoutStartSec=12h
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 e8742ab..eecf1a6 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 /etc/cloud-compose/libexec/run-root-program.sh configure-metadata-firewall.sh pre-docker
+ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh metadata-firewall 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 0333f5a..26d8e28 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 /etc/cloud-compose/libexec/run-root-program.sh configure-metadata-firewall.sh
+ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh metadata-firewall
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 c1d4069..d278485 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 /etc/cloud-compose/libexec/run-root-program.sh offhost-backup.sh
+ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh backup offhost
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 69f8544..9de0a61 100644
--- a/rootfs/etc/systemd/system/cloud-compose-overlay.service
+++ b/rootfs/etc/systemd/system/cloud-compose-overlay.service
@@ -7,8 +7,7 @@ RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volu
Type=oneshot
User=root
Group=root
-EnvironmentFile=-/home/cloud-compose/.env
-ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh mount-overlays.sh
+ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh overlays
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 baa3b0a..7720c3c 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 /etc/cloud-compose/libexec/run-root-program.sh restore-test.sh
+ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh backup restore-test
TimeoutStartSec=24h
StandardOutput=journal
StandardError=journal
diff --git a/rootfs/etc/systemd/system/cloud-compose-rollout.service b/rootfs/etc/systemd/system/cloud-compose-rollout.service
index 29abe6c..3261751 100644
--- a/rootfs/etc/systemd/system/cloud-compose-rollout.service
+++ b/rootfs/etc/systemd/system/cloud-compose-rollout.service
@@ -8,7 +8,7 @@ RequiresMountsFor=/mnt/disks/data
User=cloud-compose
Group=cloud-compose
WorkingDirectory=/mnt/disks/data
-ExecStart=/bin/bash /home/cloud-compose/run-rollout-service.sh
+ExecStart=/etc/cloud-compose/libexec/sitectl-host.sh rollout-serve
Restart=on-failure
RestartSec=5s
diff --git a/rootfs/etc/systemd/system/cloud-compose-vault-agent.service b/rootfs/etc/systemd/system/cloud-compose-vault-agent.service
index c2e74f9..43a1b63 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 /etc/cloud-compose/libexec/run-root-program.sh vault-agent-readiness.sh prepare
+ExecStartPre=/etc/cloud-compose/libexec/sitectl-host.sh vault-readiness prepare
ExecStart=/usr/local/bin/vault agent -config=/etc/vault-agent.d/cloud-compose.hcl
-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
+ExecStartPost=/etc/cloud-compose/libexec/sitectl-host.sh vault-readiness wait
+ExecStopPost=/etc/cloud-compose/libexec/sitectl-host.sh vault-readiness clear
Restart=on-failure
RestartSec=10s
TimeoutStartSec=2min
diff --git a/rootfs/etc/systemd/system/cloud-compose.service b/rootfs/etc/systemd/system/cloud-compose.service
index 25e7e8f..cbd3bd7 100644
--- a/rootfs/etc/systemd/system/cloud-compose.service
+++ b/rootfs/etc/systemd/system/cloud-compose.service
@@ -15,8 +15,8 @@ Type=oneshot
RemainAfterExit=yes
User=cloud-compose
Group=cloud-compose
-ExecStartPre=/bin/bash /home/cloud-compose/assert-app-initialized.sh
-ExecStartPre=/bin/bash /home/cloud-compose/assert-vault-ready.sh
+ExecStartPre=/etc/cloud-compose/libexec/sitectl-host.sh marker require-initialized /var/lib/cloud-compose/bootstrap-complete /run/cloud-compose-app-init-complete
+ExecStartPre=/etc/cloud-compose/libexec/sitectl-host.sh vault-agent assert-ready
ExecStart=/bin/bash /home/cloud-compose/up
ExecStop=/bin/bash /home/cloud-compose/down
Restart=on-failure
diff --git a/rootfs/etc/systemd/system/libops-managed-runtime.service b/rootfs/etc/systemd/system/libops-managed-runtime.service
deleted file mode 100644
index 94d6781..0000000
--- a/rootfs/etc/systemd/system/libops-managed-runtime.service
+++ /dev/null
@@ -1,15 +0,0 @@
-[Unit]
-Description=Update LibOps Managed Runtime
-After=network-online.target docker.service
-Wants=network-online.target
-RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes
-
-[Service]
-Type=oneshot
-User=root
-Group=root
-ExecStart=/bin/bash /etc/cloud-compose/libexec/run-root-program.sh libops-managed-runtime.sh update
-TimeoutStartSec=1h
-StandardOutput=journal
-StandardError=journal
-SyslogIdentifier=libops-managed-runtime
diff --git a/rootfs/etc/systemd/system/libops-managed-runtime.timer b/rootfs/etc/systemd/system/libops-managed-runtime.timer
deleted file mode 100644
index 4b3d509..0000000
--- a/rootfs/etc/systemd/system/libops-managed-runtime.timer
+++ /dev/null
@@ -1,12 +0,0 @@
-[Unit]
-Description=Run LibOps Managed Runtime Updates
-
-[Timer]
-OnBootSec=15min
-OnUnitInactiveSec=24h
-RandomizedDelaySec=30min
-Persistent=true
-Unit=libops-managed-runtime.service
-
-[Install]
-WantedBy=timers.target
diff --git a/rootfs/home/cloud-compose/app-init.sh b/rootfs/home/cloud-compose/app-init.sh
deleted file mode 100755
index a23aa3a..0000000
--- a/rootfs/home/cloud-compose/app-init.sh
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/usr/bin/env bash
-
-set -eou pipefail
-
-# shellcheck disable=SC1091
-source /home/cloud-compose/profile.sh
-export HOME
-cd /home/cloud-compose
-
-# shellcheck disable=SC1091
-source /home/cloud-compose/compose-apps.sh
-
-acquire_cloud_compose_lifecycle_lock "init"
-trap release_cloud_compose_lifecycle_lock EXIT
-
-apps=()
-compose_app_names_array apps
-for app in "${apps[@]}"; do
- source_compose_app_env "$app"
-
- if [[ ! -d "$DOCKER_COMPOSE_DIR/.git" ]]; then
- echo "Compose source was not prepared before application initialization: $DOCKER_COMPOSE_DIR" >&2
- exit 1
- fi
-
- pushd "$DOCKER_COMPOSE_DIR" >/dev/null
- scaffold_compose_app_defaults "$app"
- sync_compose_application_env
- update_compose_env COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME"
- update_compose_env SITE_NAME "${CLOUD_COMPOSE_INSTANCE_NAME:-${GCP_INSTANCE_NAME:-$app}}"
- update_compose_env COMPOSE_BIND_PORT "$COMPOSE_BIND_PORT"
- run_compose_app_lifecycle "$app" init
- configure_sitectl_app_features "$app"
- record_compose_managed_diff "$app"
- popd >/dev/null
-done
diff --git a/rootfs/home/cloud-compose/app-rollout.sh b/rootfs/home/cloud-compose/app-rollout.sh
deleted file mode 100755
index 4025243..0000000
--- a/rootfs/home/cloud-compose/app-rollout.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-
-set -eou pipefail
-
-# shellcheck disable=SC1091
-source /home/cloud-compose/profile.sh
-
-exec bash /home/cloud-compose/compose-dispatch.sh rollout
diff --git a/rootfs/home/cloud-compose/assert-app-initialized.sh b/rootfs/home/cloud-compose/assert-app-initialized.sh
deleted file mode 100755
index bf08842..0000000
--- a/rootfs/home/cloud-compose/assert-app-initialized.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_assert_initialized_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_assert_initialized_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_assert_initialized_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_assert_initialized_source _cc_assert_initialized_dir _cc_assert_initialized_installed_home
-if [[ -n "$_cc_assert_initialized_installed_home" &&
- ( "$_cc_assert_initialized_installed_home" == "/" ||
- "$_cc_assert_initialized_source" == "${_cc_assert_initialized_installed_home%/}/"* ) ]]; then
- _cc_assert_initialized_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_assert_initialized_checked_programs="$_cc_assert_initialized_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_assert_initialized_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_assert_initialized_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_assert_initialized_source" CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH \
- /home/cloud-compose/bootstrap-helpers.sh "$_cc_assert_initialized_dir/bootstrap-helpers.sh"
-bootstrap_helpers_path="$CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH"
-readonly bootstrap_helpers_path
-
-# shellcheck disable=SC1090
-source "$bootstrap_helpers_path"
-
-durable_marker="${CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER:-/var/lib/cloud-compose/bootstrap-complete}"
-boot_marker="${CLOUD_COMPOSE_APP_INIT_MARKER:-/run/cloud-compose-app-init-complete}"
-
-if cloud_compose_marker_exists "$durable_marker" ||
- cloud_compose_marker_exists "$boot_marker"; then
- exit 0
-fi
-
-echo "Cloud Compose application initialization has not completed for this boot" >&2
-exit 1
diff --git a/rootfs/home/cloud-compose/assert-vault-ready.sh b/rootfs/home/cloud-compose/assert-vault-ready.sh
deleted file mode 100755
index 33f3f48..0000000
--- a/rootfs/home/cloud-compose/assert-vault-ready.sh
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_assert_vault_ready_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_assert_vault_ready_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_assert_vault_ready_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_assert_vault_ready_source _cc_assert_vault_ready_dir _cc_assert_vault_ready_installed_home
-if [[ -n "$_cc_assert_vault_ready_installed_home" &&
- ( "$_cc_assert_vault_ready_installed_home" == "/" ||
- "$_cc_assert_vault_ready_source" == "${_cc_assert_vault_ready_installed_home%/}/"* ) ]]; then
- _cc_assert_vault_ready_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_assert_vault_ready_checked_programs="$_cc_assert_vault_ready_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_assert_vault_ready_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_assert_vault_ready_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_assert_vault_ready_source" \
- CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh \
- "$_cc_assert_vault_ready_dir/profile.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-readonly profile_path
-
-# shellcheck disable=SC1090
-source "$profile_path"
-
-case "${VAULT_AGENT_ENABLED:-false}" in
- false) exit 0 ;;
- true) ;;
- *)
- echo "VAULT_AGENT_ENABLED must be true or false" >&2
- exit 1
- ;;
-esac
-
-ready_marker="${VAULT_AGENT_READY_MARKER:-/run/cloud-compose/vault-agent.ready}"
-if [[ -L "$ready_marker" || ! -f "$ready_marker" ]]; then
- echo "Vault Agent is enabled but has not published readiness" >&2
- exit 1
-fi
-if ! systemctl is-active --quiet cloud-compose-vault-agent.service; then
- echo "Vault Agent is enabled but its service is not active" >&2
- exit 1
-fi
diff --git a/rootfs/home/cloud-compose/bootstrap-helpers.sh b/rootfs/home/cloud-compose/bootstrap-helpers.sh
deleted file mode 100755
index a83769a..0000000
--- a/rootfs/home/cloud-compose/bootstrap-helpers.sh
+++ /dev/null
@@ -1,202 +0,0 @@
-#!/usr/bin/env bash
-
-cloud_compose_marker_exists() {
- local marker="$1" marker_size payload
-
- [[ -f "$marker" && ! -L "$marker" ]] || return 1
- if [[ "$marker" == "/var/lib/cloud-compose/bootstrap-complete" ]]; then
- [[ "$(stat -c '%u:%g:%a:%F' -- "$(dirname -- "$marker")")" == "0:0:755:directory" &&
- "$(stat -c '%u:%g:%a:%h:%F' -- "$marker")" == "0:0:644:1:regular file" ]] || return 1
- marker_size="$(stat -c '%s' -- "$marker")" || return 1
- [[ "$marker_size" == "6" ]] || return 1
- IFS= read -r payload <"$marker" || return 1
- [[ "$payload" == "ready" ]]
- fi
-}
-
-cloud_compose_should_run_app_init() {
- local durable_marker="$1"
- local boot_marker="$2"
-
- cloud_compose_marker_exists "$durable_marker" ||
- ! cloud_compose_marker_exists "$boot_marker"
-}
-
-cloud_compose_publish_marker() (
- local marker="$1"
- local marker_dir tmp_marker
-
- marker_dir="$(dirname -- "$marker")"
- if [[ ! -d "$marker_dir" || -L "$marker_dir" ]]; then
- echo "Unsafe Cloud Compose marker directory: $marker_dir" >&2
- return 1
- fi
- if [[ "$marker" == "/var/lib/cloud-compose/bootstrap-complete" &&
- ( "$EUID" != "0" ||
- "$(stat -c '%u:%g:%a:%F' -- "$marker_dir")" != "0:0:755:directory" ) ]]; then
- echo "Durable Cloud Compose readiness requires a root-owned state directory" >&2
- return 1
- fi
-
- umask 022
- tmp_marker="$(mktemp "${marker}.tmp.XXXXXXXXXX")" || return 1
- if ! printf 'ready\n' >"$tmp_marker" ||
- ! chmod 0644 "$tmp_marker"; then
- rm -f -- "$tmp_marker"
- return 1
- fi
- if ((EUID == 0)) && ! chown 0:0 "$tmp_marker"; then
- rm -f -- "$tmp_marker"
- return 1
- fi
- if ! mv -fT -- "$tmp_marker" "$marker"; then
- rm -f -- "$tmp_marker"
- return 1
- fi
-)
-
-cloud_compose_consume_fresh_filesystem_marker() {
- local marker="$1"
- local expected_identity="$2"
- local marker_dir marker_identity marker_dir_identity marker_payload marker_size
-
- case "$expected_identity" in
- fresh) ;;
- v1:gcp-disk-id:*)
- if [[ ! "$expected_identity" =~ ^v1:gcp-disk-id:[0-9]{1,32}$ ]]; then
- echo "Unsafe fresh-filesystem identity" >&2
- return 2
- fi
- ;;
- *)
- echo "Unsafe fresh-filesystem identity" >&2
- return 2
- ;;
- esac
-
- if [[ "$marker" != /* || "$marker" == "/" || "$marker" == *$'\n'* ||
- "$marker" == *$'\r'* || "$marker" =~ (^|/)\.\.?(/|$) ]]; then
- echo "Unsafe fresh-filesystem marker path: $marker" >&2
- return 1
- fi
- if [[ ! -e "$marker" && ! -L "$marker" ]]; then
- return 0
- fi
- marker_dir="$(dirname -- "$marker")"
- if [[ -L "$marker_dir" || ! -d "$marker_dir" || -L "$marker" || ! -f "$marker" ]]; then
- echo "Unsafe fresh-filesystem marker: $marker" >&2
- return 1
- fi
- marker_dir_identity="$(stat -c '%u:%g:%a' -- "$marker_dir")" || return 1
- marker_identity="$(stat -c '%u:%g:%a:%h' -- "$marker")" || return 1
- if [[ "$marker_dir_identity" != "0:0:700" || "$marker_identity" != "0:0:600:1" ]]; then
- echo "Unsafe fresh-filesystem marker ownership or mode: $marker" >&2
- return 1
- fi
- marker_size="$(stat -c '%s' -- "$marker")" || return 1
- marker_payload=""
- if [[ "$marker_size" != "$((${#expected_identity} + 1))" ]] ||
- ! IFS= read -r marker_payload <"$marker" ||
- [[ "$marker_payload" != "$expected_identity" ]]; then
- echo "Fresh-filesystem marker does not match this disk incarnation" >&2
- return 1
- fi
- rm -f -- "$marker"
-}
-
-cloud_compose_validate_systemd_unit() {
- case "$1" in
- cloud-compose.service | cloud-compose-bootstrap.service) return 0 ;;
- *)
- echo "Unsupported Cloud Compose systemd unit: $1" >&2
- return 2
- ;;
- esac
-}
-
-cloud_compose_start_systemd_unit() {
- local unit="$1"
- local load_state active_state
-
- cloud_compose_validate_systemd_unit "$unit" || return
-
- load_state="$(systemctl show --property=LoadState --value -- "$unit")" || return 1
- if [[ "$load_state" != "loaded" ]]; then
- echo "Cloud Compose systemd unit is not loaded: $unit ($load_state)" >&2
- return 1
- fi
-
- systemctl enable -- "$unit"
- active_state="$(systemctl show --property=ActiveState --value -- "$unit")" || return 1
- if [[ "$active_state" == "active" || "$active_state" == "activating" ]]; then
- return 0
- fi
-
- if [[ "$active_state" == "failed" ]]; then
- systemctl reset-failed -- "$unit"
- fi
- systemctl start --no-block -- "$unit"
-}
-
-cloud_compose_wait_for_oneshot() {
- local unit="$1"
- local timeout_seconds="$2"
- local poll_seconds="${CLOUD_COMPOSE_SYSTEMD_POLL_SECONDS:-2}"
- local heartbeat_seconds="${CLOUD_COMPOSE_SYSTEMD_HEARTBEAT_SECONDS:-300}"
- local elapsed=0 active_state load_state
-
- cloud_compose_validate_systemd_unit "$unit" || return
- if [[ ! "$timeout_seconds" =~ ^[1-9][0-9]{0,4}$ ]] ||
- ((10#$timeout_seconds > 43200)); then
- echo "Cloud Compose systemd wait must be from 1 through 43200 seconds" >&2
- return 2
- fi
- if [[ ! "$poll_seconds" =~ ^[1-9][0-9]{0,2}$ ]] ||
- ((10#$poll_seconds > 300)); then
- echo "CLOUD_COMPOSE_SYSTEMD_POLL_SECONDS must be from 1 through 300 seconds" >&2
- return 2
- fi
- if [[ ! "$heartbeat_seconds" =~ ^[1-9][0-9]{0,3}$ ]] ||
- ((10#$heartbeat_seconds > 3600)); then
- echo "CLOUD_COMPOSE_SYSTEMD_HEARTBEAT_SECONDS must be from 1 through 3600 seconds" >&2
- return 2
- fi
-
- while ((elapsed < 10#$timeout_seconds)); do
- load_state="$(systemctl show --property=LoadState --value -- "$unit")" || return 1
- if [[ "$load_state" != "loaded" ]]; then
- echo "Cloud Compose systemd unit stopped being loaded: $unit ($load_state)" >&2
- return 1
- fi
-
- active_state="$(systemctl show --property=ActiveState --value -- "$unit")" || return 1
- if [[ "$active_state" == "active" ]]; then
- return 0
- fi
- if [[ "$active_state" == "failed" ]]; then
- echo "Cloud Compose systemd unit reached a terminal failed state: $unit" >&2
- systemctl status --no-pager --full -- "$unit" >&2 || true
- return 1
- fi
- sleep "$poll_seconds"
- elapsed=$((elapsed + 10#$poll_seconds))
- if ((elapsed % 10#$heartbeat_seconds < 10#$poll_seconds)); then
- echo "Still waiting for $unit after ${elapsed}s (active state: $active_state)" >&2
- systemctl show --no-pager \
- --property=ActiveState,SubState,Result,NRestarts,ExecMainCode,ExecMainStatus \
- -- "$unit" >&2 || true
- fi
- done
-
- echo "Timed out waiting ${timeout_seconds}s for $unit to become active" >&2
- systemctl status --no-pager --full -- "$unit" >&2 || true
- return 1
-}
-
-cloud_compose_start_and_wait_for_oneshot() {
- local unit="$1"
- local timeout_seconds="$2"
-
- cloud_compose_start_systemd_unit "$unit" &&
- cloud_compose_wait_for_oneshot "$unit" "$timeout_seconds"
-}
diff --git a/rootfs/home/cloud-compose/compose-apps.sh b/rootfs/home/cloud-compose/compose-apps.sh
deleted file mode 100755
index 17ca2e8..0000000
--- a/rootfs/home/cloud-compose/compose-apps.sh
+++ /dev/null
@@ -1,1193 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-COMPOSE_PROJECTS_FILE="${COMPOSE_PROJECTS_FILE:-/home/cloud-compose/compose-projects.json}"
-COMPOSE_APPS_ENV_DIR="${COMPOSE_APPS_ENV_DIR:-/home/cloud-compose/apps}"
-COMPOSE_APPS_STATE_DIR="${COMPOSE_APPS_STATE_DIR:-/home/cloud-compose/state}"
-CLOUD_COMPOSE_DATA_ROOT="${CLOUD_COMPOSE_DATA_ROOT:-/mnt/disks/data}"
-_cc_compose_apps_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_compose_apps_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_compose_apps_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_compose_apps_source _cc_compose_apps_dir _cc_compose_apps_installed_home
-if [[ -n "$_cc_compose_apps_installed_home" &&
- ( "$_cc_compose_apps_installed_home" == "/" ||
- "$_cc_compose_apps_source" == "${_cc_compose_apps_installed_home%/}/"* ) ]]; then
- _cc_compose_apps_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_compose_apps_checked_programs="$_cc_compose_apps_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_compose_apps_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_compose_apps_checked_programs"
-cloud_compose_bind_program_dir \
- "$_cc_compose_apps_source" \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR \
- /etc/cloud-compose/jq \
- "$_cc_compose_apps_dir/../../etc/cloud-compose/jq" \
- compose-validate-projects.jq \
- compose-app-field.jq \
- compose-app-array.jq \
- compose-app-verify-args.jq \
- compose-app-verify-args-json.jq \
- compose-app-ingress-field.jq \
- compose-app-ingress-array.jq \
- compose-reject-host-network.jq \
- compose-project-dirs-base64.jq \
- compose-services-object-validate.jq \
- array-values-base64.jq \
- object-has-key.jq \
- object-keys-base64.jq \
- object-keys.jq \
- string-array-validate.jq
-cloud_compose_bind_program \
- "$_cc_compose_apps_source" \
- CLOUD_COMPOSE_COMPOSE_SECRET_FILES_PROGRAM \
- /etc/cloud-compose/awk/compose-secret-files.awk \
- "$_cc_compose_apps_dir/../../etc/cloud-compose/awk/compose-secret-files.awk"
-COMPOSE_SECRET_FILES_PROGRAM="$CLOUD_COMPOSE_COMPOSE_SECRET_FILES_PROGRAM"
-readonly COMPOSE_SECRET_FILES_PROGRAM
-readonly COMPOSE_LIFECYCLE_EXECUTOR="/etc/cloud-compose/libexec/run-lifecycle-program.sh"
-
-run_compose_lifecycle_executor() {
- "$COMPOSE_LIFECYCLE_EXECUTOR" "$@"
-}
-
-shell_env_line() {
- local name="$1"
- local value="$2"
-
- printf '%s=%q\n' "$name" "$value"
-}
-
-validate_compose_app_name() {
- local app="$1"
-
- if [[ ! "$app" =~ ^[a-z][a-z0-9-]*$ ]]; then
- echo "Invalid cloud-compose app name: $app" >&2
- return 1
- fi
-}
-
-validate_compose_project_dir() {
- local project_dir="$1"
- local data_root resolved_project_dir resolved_data_root
-
- data_root="${CLOUD_COMPOSE_DATA_ROOT%/}"
- [[ -n "$data_root" && "$data_root" == /* && "$project_dir" == /* ]] || {
- echo "Compose project directory must be absolute: $project_dir" >&2
- return 1
- }
- [[ "$project_dir" != "$data_root" && "$project_dir" == "$data_root/"* ]] || {
- echo "Compose project directory is outside the managed data boundary: $project_dir" >&2
- return 1
- }
- [[ ! "$project_dir" =~ [[:cntrl:]] && "$project_dir" != *"//"* && "$project_dir" != */ ]] || {
- echo "Compose project directory contains an unsafe segment: $project_dir" >&2
- return 1
- }
- [[ ! "$project_dir" =~ (^|/)\.\.?(/|$) ]] || {
- echo "Compose project directory contains a dot segment: $project_dir" >&2
- return 1
- }
-
- resolved_data_root="$(realpath -m -- "$data_root")" || return 1
- resolved_project_dir="$(realpath -m -- "$project_dir")" || return 1
- [[ "$resolved_project_dir" == "$resolved_data_root/"* ]] || {
- echo "Compose project directory resolves outside the managed data boundary: $project_dir" >&2
- return 1
- }
-}
-
-validate_compose_projects_manifest() {
- if [[ -L "$COMPOSE_PROJECTS_FILE" || ! -f "$COMPOSE_PROJECTS_FILE" ]]; then
- echo "Cloud-compose project manifest is missing or unsafe: $COMPOSE_PROJECTS_FILE" >&2
- return 1
- fi
-
- if ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-validate-projects.jq" \
- "$COMPOSE_PROJECTS_FILE" >/dev/null; then
- echo "Invalid cloud-compose project manifest: $COMPOSE_PROJECTS_FILE" >&2
- return 1
- fi
-
- local encoded_app app encoded_project_dir project_dir
- while IFS= read -r encoded_app; do
- app="$(
- if ! printf '%s' "$encoded_app" | base64 --decode; then
- exit 1
- fi
- printf '\037'
- )" || return 1
- app="${app%$'\x1f'}"
- validate_compose_app_name "$app" || return 1
- done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-keys-base64.jq" \
- "$COMPOSE_PROJECTS_FILE")
-
- while IFS= read -r encoded_project_dir; do
- project_dir="$(
- if ! printf '%s' "$encoded_project_dir" | base64 --decode; then
- exit 1
- fi
- printf '\037'
- )" || return 1
- project_dir="${project_dir%$'\x1f'}"
- validate_compose_project_dir "$project_dir" || return 1
- done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-project-dirs-base64.jq" \
- "$COMPOSE_PROJECTS_FILE")
-}
-
-# Converge one already-existing manifest project without traversing or changing
-# ownership below it. The low-level helper accepts numeric IDs so its filesystem
-# behavior can be tested without requiring the production account locally; the
-# public wrapper always selects the fixed cloud-compose identity.
-_converge_compose_app_filesystem_for_ids() (
- local app="$1"
- local runtime_uid="$2"
- local runtime_gid="$3"
- local project_dir resolved_project_dir env_file env_path
- local project_fd project_fd_path project_identity
- local env_fd env_fd_path env_identity env_metadata
- local guard_uid="$EUID"
- local guard_gid
-
- if [[ ! "$runtime_uid" =~ ^[0-9]+$ || ! "$runtime_gid" =~ ^[0-9]+$ ]]; then
- echo "Invalid cloud-compose runtime account IDs" >&2
- return 2
- fi
- guard_gid="$(id -g)" || return 1
-
- compose_app_exists "$app" || return 1
- project_dir="$(compose_app_field "$app" project_dir)" || return 1
- validate_compose_project_dir "$project_dir" || return 1
-
- # A missing project is created later by the unprivileged source-preparation
- # phase. Existing persistent checkouts must be real directories at the
- # exact manifest path; do not follow even an in-boundary symlink as root.
- if [[ ! -e "$project_dir" && ! -L "$project_dir" ]]; then
- return 0
- fi
- if [[ -L "$project_dir" || ! -d "$project_dir" ]]; then
- echo "Compose project path is not a real directory: $project_dir" >&2
- return 1
- fi
- resolved_project_dir="$(realpath -e -- "$project_dir")" || return 1
- if [[ "$resolved_project_dir" != "$project_dir" ]]; then
- echo "Compose project path contains a symbolic-link component: $project_dir" >&2
- return 1
- fi
-
- # Operate through a verified descriptor. If an application account races a
- # path replacement, the descriptor cannot be redirected to another inode
- # and the final identity check fails closed.
- project_identity="$(stat -c '%d:%i' -- "$project_dir")" || return 1
- exec {project_fd}<"$project_dir" || return 1
- project_fd_path="/proc/${BASHPID}/fd/${project_fd}"
- if [[ "$(stat -Lc '%F:%d:%i' -- "$project_fd_path")" != "directory:${project_identity}" ||
- "$(stat -c '%d:%i' -- "$project_dir")" != "$project_identity" ]]; then
- echo "Compose project path changed while opening: $project_dir" >&2
- return 1
- fi
-
- # Freeze the opened directory before inspecting its child. The first chmod
- # removes ordinary write access; changing owner and repeating the chmod
- # closes a race with the former owner changing its mode concurrently. Only
- # state observed after the final root-owned/read-only transition is trusted.
- chmod 0555 "$project_fd_path" || return 1
- chown --dereference "${guard_uid}:${guard_gid}" "$project_fd_path" || return 1
- chmod 0555 "$project_fd_path" || return 1
- if [[ "$(stat -c '%d:%i' -- "$project_dir")" != "$project_identity" ||
- "$(stat -Lc '%u:%g:%a' -- "$project_fd_path")" != "${guard_uid}:${guard_gid}:555" ]]; then
- echo "Compose project path did not freeze safely: $project_dir" >&2
- return 1
- fi
-
- # Resolve .env through the verified directory descriptor. The directory is
- # no longer writable by the application identity, so this lstat-style
- # symlink rejection cannot race the subsequent open.
- env_path="${project_dir}/.env"
- env_file="${project_fd_path}/.env"
- if [[ ! -e "$env_file" && ! -L "$env_file" ]]; then
- chown --dereference "${runtime_uid}:${runtime_gid}" "$project_fd_path" || return 1
- chmod 0775 "$project_fd_path" || return 1
- return 0
- fi
- if [[ -L "$env_file" || ! -f "$env_file" ]]; then
- echo "Compose environment path is not a regular file: $env_path" >&2
- return 1
- fi
- env_metadata="$(stat -c '%F:%h' -- "$env_file")" || return 1
- if [[ "$env_metadata" != "regular file:1" ]]; then
- echo "Compose environment file must have exactly one link: $env_path" >&2
- return 1
- fi
-
- env_identity="$(stat -c '%d:%i' -- "$env_file")" || return 1
- exec {env_fd}<"$env_file" || return 1
- env_fd_path="/proc/${BASHPID}/fd/${env_fd}"
- if [[ "$(stat -Lc '%F:%h:%d:%i' -- "$env_fd_path")" != "regular file:1:${env_identity}" ||
- "$(stat -c '%d:%i' -- "$env_file")" != "$env_identity" ]]; then
- echo "Compose environment path changed while opening: $env_path" >&2
- return 1
- fi
- chown --dereference "${runtime_uid}:${runtime_gid}" "$env_fd_path" || return 1
- chmod 0640 "$env_fd_path" || return 1
- if [[ "$(stat -c '%d:%i' -- "$env_file")" != "$env_identity" ||
- "$(stat -Lc '%u:%g:%a:%h' -- "$env_fd_path")" != "${runtime_uid}:${runtime_gid}:640:1" ]]; then
- echo "Compose environment ownership did not converge safely: $env_path" >&2
- return 1
- fi
-
- chown --dereference "${runtime_uid}:${runtime_gid}" "$project_fd_path" || return 1
- chmod 0775 "$project_fd_path" || return 1
- if [[ "$(stat -c '%d:%i' -- "$project_dir")" != "$project_identity" ||
- "$(stat -Lc '%u:%g:%a' -- "$project_fd_path")" != "${runtime_uid}:${runtime_gid}:775" ]]; then
- echo "Compose project ownership did not converge safely: $project_dir" >&2
- return 1
- fi
-)
-
-converge_compose_app_filesystem() {
- local app="$1"
- local runtime_uid runtime_gid
-
- if ((EUID != 0)); then
- echo "Compose application filesystem convergence must run as root" >&2
- return 1
- fi
- runtime_uid="$(id -u cloud-compose)" || return 1
- runtime_gid="$(id -g cloud-compose)" || return 1
- _converge_compose_app_filesystem_for_ids "$app" "$runtime_uid" "$runtime_gid"
-}
-
-compose_app_exists() {
- local app="$1"
-
- validate_compose_app_name "$app" || return 1
- validate_compose_projects_manifest || return 1
- jq -e --arg key "$app" -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-has-key.jq" \
- "$COMPOSE_PROJECTS_FILE" >/dev/null || {
- echo "Cloud-compose app is not present in the manifest: $app" >&2
- return 1
- }
-}
-
-compose_app_names_array() {
- local result_name="$1"
- local names app
- local -n "result=$result_name"
-
- validate_compose_projects_manifest || return 1
- names="$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-keys.jq" \
- "$COMPOSE_PROJECTS_FILE")" || return 1
- result=()
- while IFS= read -r app; do
- validate_compose_app_name "$app" || return 1
- result+=("$app")
- done <<<"$names"
-}
-
-compose_app_names() {
- local -a apps=()
-
- compose_app_names_array apps || return 1
- printf '%s\n' "${apps[@]}"
-}
-
-compose_app_field() {
- local app="$1"
- local field="$2"
-
- compose_app_exists "$app" || return 1
- jq -er --arg app "$app" --arg field "$field" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-field.jq" \
- "$COMPOSE_PROJECTS_FILE"
-}
-
-compose_app_array_values() {
- local app="$1"
- local field="$2"
- local result_name="$3"
- local payload encoded_lines encoded value
- local -n "result=$result_name"
-
- compose_app_exists "$app" || return 1
- payload="$(jq -ce --arg app "$app" --arg field "$field" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-array.jq" \
- "$COMPOSE_PROJECTS_FILE")" || {
- echo "Invalid $field array for cloud-compose app $app" >&2
- return 1
- }
-
- result=()
- encoded_lines="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/array-values-base64.jq" \
- <<<"$payload")" || return 1
- if [[ -z "$encoded_lines" ]]; then
- return 0
- fi
- while IFS= read -r encoded; do
- value="$(
- if ! printf '%s' "$encoded" | base64 -d; then
- exit 1
- fi
- printf '\037'
- )" || return 1
- value="${value%$'\x1f'}"
- result+=("$value")
- done <<<"$encoded_lines"
-}
-
-compose_app_array() {
- local app="$1"
- local field="$2"
- local value
- local -a values=()
-
- compose_app_array_values "$app" "$field" values || return 1
- for value in "${values[@]}"; do
- printf '%s\n' "$value"
- done
-}
-
-compose_app_verify_args() {
- local app="$1"
-
- compose_app_exists "$app" || return 1
- jq -er --arg app "$app" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-verify-args.jq" \
- "$COMPOSE_PROJECTS_FILE"
-}
-
-compose_app_verify_args_json() {
- local app="$1"
-
- compose_app_exists "$app" || return 1
- jq -cer --arg app "$app" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-verify-args-json.jq" \
- "$COMPOSE_PROJECTS_FILE"
-}
-
-compose_app_ingress_field() {
- local app="$1"
- local field="$2"
-
- compose_app_exists "$app" || return 1
- jq -er --arg app "$app" --arg field "$field" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-ingress-field.jq" \
- "$COMPOSE_PROJECTS_FILE"
-}
-
-compose_app_ingress_array_values() {
- local app="$1"
- local field="$2"
- local result_name="$3"
- local payload encoded_lines encoded value
- local -n "result=$result_name"
-
- compose_app_exists "$app" || return 1
- payload="$(jq -ce --arg app "$app" --arg field "$field" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-app-ingress-array.jq" \
- "$COMPOSE_PROJECTS_FILE")" || {
- echo "Invalid ingress $field array for cloud-compose app $app" >&2
- return 1
- }
-
- result=()
- encoded_lines="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/array-values-base64.jq" \
- <<<"$payload")" || return 1
- if [[ -z "$encoded_lines" ]]; then
- return 0
- fi
- while IFS= read -r encoded; do
- value="$(
- if ! printf '%s' "$encoded" | base64 -d; then
- exit 1
- fi
- printf '\037'
- )" || return 1
- value="${value%$'\x1f'}"
- result+=("$value")
- done <<<"$encoded_lines"
-}
-
-compose_app_ingress_array() {
- local app="$1"
- local field="$2"
- local value
- local -a values=()
-
- compose_app_ingress_array_values "$app" "$field" values || return 1
- for value in "${values[@]}"; do
- printf '%s\n' "$value"
- done
-}
-
-sitectl_truthy() {
- local value
- value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
-
- case "$value" in
- 1 | true | yes | y | on | enabled)
- return 0
- ;;
- *)
- return 1
- ;;
- esac
-}
-
-random_chars() {
- local chars="$1"
- local length="$2"
- local value=""
-
- while [ "${#value}" -lt "$length" ]; do
- value="${value}$(openssl rand -base64 "$((length * 2))" | tr -dc "$chars")"
- done
-
- printf '%s' "${value:0:length}"
-}
-
-random_secret() {
- local length="$1"
-
- random_chars 'A-Za-z0-9' "$length"
-}
-
-scaffold_secret_file() {
- local file="$1"
- local mode="${2:-0640}"
-
- if [ -s "$file" ]; then
- return 0
- fi
-
- install -d -m 0700 "$(dirname "$file")"
- echo "Creating scaffold secret: ${file}" >&2
- random_secret 32 > "$file"
- chmod "$mode" "$file"
-}
-
-scaffold_drupal_salt() {
- local file="$1"
-
- if [ -s "$file" ]; then
- return 0
- fi
-
- install -d -m 0700 "$(dirname "$file")"
- echo "Creating scaffold secret: ${file}" >&2
- random_chars 'A-Za-z0-9_-' 74 > "$file"
- chmod 0640 "$file"
-}
-
-scaffold_jwt_keys() {
- local private_key="${1:-./secrets/JWT_PRIVATE_KEY}"
- local public_key="${2:-./secrets/JWT_PUBLIC_KEY}"
-
- install -d -m 0700 "$(dirname "$private_key")"
- if [ ! -s "$private_key" ]; then
- echo "Creating scaffold secret: ${private_key}" >&2
- openssl genrsa 2048 > "$private_key" 2>/dev/null
- chmod 0640 "$private_key"
- fi
- if [ ! -s "$public_key" ]; then
- echo "Creating scaffold secret: ${public_key}" >&2
- openssl rsa -pubout < "$private_key" > "$public_key" 2>/dev/null
- chmod 0644 "$public_key"
- fi
-}
-
-scaffold_local_certs() {
- local cert_dir="${1:-./certs}"
- local openssl_config
-
- if [ -s "${cert_dir}/cert.pem" ] && [ -s "${cert_dir}/rootCA.pem" ]; then
- return 0
- fi
-
- install -d -m 0755 "$cert_dir"
- openssl_config="$(mktemp)"
- cat > "$openssl_config" <<'EOF'
-[req]
-distinguished_name = req_distinguished_name
-prompt = no
-
-[req_distinguished_name]
-CN = localhost
-
-[v3_req]
-subjectAltName = @alt_names
-
-[alt_names]
-DNS.1 = localhost
-DNS.2 = *.localhost
-DNS.3 = islandora.io
-DNS.4 = *.islandora.io
-DNS.5 = islandora.info
-DNS.6 = *.islandora.info
-IP.1 = 127.0.0.1
-IP.2 = ::1
-EOF
-
- if [ ! -s "${cert_dir}/rootCA-key.pem" ] || [ ! -s "${cert_dir}/rootCA.pem" ]; then
- echo "Creating scaffold certificate authority in ${cert_dir}" >&2
- openssl req -x509 -newkey rsa:2048 -nodes \
- -keyout "${cert_dir}/rootCA-key.pem" \
- -out "${cert_dir}/rootCA.pem" \
- -subj "/CN=cloud-compose local root" \
- -days 3650 >/dev/null 2>&1
- fi
-
- if [ ! -s "${cert_dir}/privkey.pem" ] || [ ! -s "${cert_dir}/cert.pem" ]; then
- echo "Creating scaffold certificate in ${cert_dir}" >&2
- openssl req -newkey rsa:2048 -nodes \
- -keyout "${cert_dir}/privkey.pem" \
- -out "${cert_dir}/cert.csr" \
- -config "$openssl_config" >/dev/null 2>&1
- openssl x509 -req \
- -in "${cert_dir}/cert.csr" \
- -CA "${cert_dir}/rootCA.pem" \
- -CAkey "${cert_dir}/rootCA-key.pem" \
- -CAcreateserial \
- -out "${cert_dir}/cert.pem" \
- -days 825 \
- -sha256 \
- -extensions v3_req \
- -extfile "$openssl_config" >/dev/null 2>&1
- rm -f "${cert_dir}/cert.csr"
- fi
-
- rm -f "$openssl_config"
- chmod 0644 "${cert_dir}/cert.pem" "${cert_dir}/rootCA.pem"
- chmod 0640 "${cert_dir}/privkey.pem" "${cert_dir}/rootCA-key.pem" 2>/dev/null || true
-}
-
-compose_secret_files() {
- local compose_file
-
- for compose_file in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
- if [ ! -f "$compose_file" ]; then
- continue
- fi
-
- awk -f "$COMPOSE_SECRET_FILES_PROGRAM" "$compose_file"
- done | sort -u
-}
-
-scaffold_compose_app_defaults() {
- local app="$1"
- local file base generated=false
-
- echo "Scaffolding default compose files for ${app}"
- while IFS= read -r file; do
- if [ -z "$file" ]; then
- continue
- fi
-
- generated=true
- base="$(basename "$file")"
- case "$file" in
- ./*) ;;
- *) file="./${file}" ;;
- esac
-
- case "$base" in
- cert.pem | rootCA.pem | privkey.pem | rootCA-key.pem)
- scaffold_local_certs "$(dirname "$file")"
- ;;
- UID)
- install -d -m 0755 "$(dirname "$file")"
- id -u > "$file"
- chmod 0644 "$file"
- ;;
- DRUPAL_DEFAULT_SALT)
- scaffold_drupal_salt "$file"
- ;;
- JWT_PRIVATE_KEY)
- scaffold_jwt_keys "$file" "./secrets/JWT_PUBLIC_KEY"
- ;;
- JWT_PUBLIC_KEY)
- scaffold_jwt_keys "./secrets/JWT_PRIVATE_KEY" "$file"
- ;;
- *)
- scaffold_secret_file "$file"
- ;;
- esac
- done < <(compose_secret_files)
-
- if [ "$generated" = false ]; then
- echo "No compose secret files declared for ${app}"
- fi
-}
-
-write_compose_app_env() {
- local app="$1"
- local env_file="${COMPOSE_APPS_ENV_DIR}/${app}.env"
- local env_tmp
-
- compose_app_exists "$app" || return 1
- install -d -m 0750 "$COMPOSE_APPS_ENV_DIR"
- env_tmp="$(mktemp "${env_file}.tmp.XXXXXX")" || return 1
- {
- shell_env_line APP_NAME "$app"
- shell_env_line DOCKER_COMPOSE_REPO "$(compose_app_field "$app" docker_compose_repo)"
- shell_env_line DOCKER_COMPOSE_BRANCH "$(compose_app_field "$app" docker_compose_branch)"
- shell_env_line DOCKER_COMPOSE_DIR "$(compose_app_field "$app" project_dir)"
- shell_env_line COMPOSE_PROJECT_NAME "$(compose_app_field "$app" compose_project_name)"
- shell_env_line COMPOSE_BIND_PORT "$(compose_app_field "$app" ingress_port)"
- shell_env_line SITECTL_CONTEXT_NAME "$(compose_app_field "$app" sitectl_context_name)"
- shell_env_line SITECTL_PLUGIN "$(compose_app_field "$app" sitectl_plugin)"
- shell_env_line SITECTL_ENVIRONMENT "$(compose_app_field "$app" sitectl_environment)"
- # SITECTL_VERIFY_ARGS remains an empty compatibility scalar. Lifecycle
- # shells use the JSON array below through an exported sitectl wrapper so
- # an argument containing spaces remains exactly one argv element.
- shell_env_line SITECTL_VERIFY_ARGS ""
- shell_env_line SITECTL_VERIFY_ARGS_JSON "$(compose_app_verify_args_json "$app")"
- } > "$env_tmp"
- chown cloud-compose:cloud-compose "$env_tmp" 2>/dev/null || true
- chmod 0640 "$env_tmp"
- mv -f -- "$env_tmp" "$env_file"
-}
-
-source_compose_app_env() {
- local app="$1"
-
- compose_app_exists "$app" || return 1
- if ((EUID != 0)); then
- write_compose_app_env "$app" || return 1
- fi
-
- # Privileged callers must never source a shell file from the app-writable
- # env directory. Materialize the same validated manifest values as data.
- APP_NAME="$app"
- DOCKER_COMPOSE_REPO="$(compose_app_field "$app" docker_compose_repo)"
- DOCKER_COMPOSE_BRANCH="$(compose_app_field "$app" docker_compose_branch)"
- DOCKER_COMPOSE_DIR="$(compose_app_field "$app" project_dir)"
- COMPOSE_PROJECT_NAME="$(compose_app_field "$app" compose_project_name)"
- COMPOSE_BIND_PORT="$(compose_app_field "$app" ingress_port)"
- SITECTL_CONTEXT_NAME="$(compose_app_field "$app" sitectl_context_name)"
- SITECTL_PLUGIN="$(compose_app_field "$app" sitectl_plugin)"
- SITECTL_ENVIRONMENT="$(compose_app_field "$app" sitectl_environment)"
- SITECTL_VERIFY_ARGS=""
- SITECTL_VERIFY_ARGS_JSON="$(compose_app_verify_args_json "$app")"
- export APP_NAME DOCKER_COMPOSE_REPO DOCKER_COMPOSE_BRANCH DOCKER_COMPOSE_DIR
- export COMPOSE_PROJECT_NAME COMPOSE_BIND_PORT SITECTL_CONTEXT_NAME SITECTL_PLUGIN
- export SITECTL_ENVIRONMENT SITECTL_VERIFY_ARGS SITECTL_VERIFY_ARGS_JSON
-}
-
-configure_sitectl_verify_argv() {
- # Resolve eagerly when available, but allow lifecycle command sets that do
- # not invoke sitectl (for example a source-policy contract). The exported
- # wrapper fails at the actual call site if the executable is missing.
- SITECTL_EXECUTABLE="$(type -P sitectl || true)"
- if ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/string-array-validate.jq" \
- <<<"${SITECTL_VERIFY_ARGS_JSON:-[]}" >/dev/null; then
- echo "Invalid sitectl verify argument JSON for ${APP_NAME:-unknown app}" >&2
- return 1
- fi
-
- sitectl() {
- local encoded decoded executable
- local -a configured_verify_args=()
-
- executable="${SITECTL_EXECUTABLE:-}"
- if [[ -z "$executable" ]]; then
- executable="$(type -P sitectl || true)"
- fi
- if [[ -z "$executable" ]]; then
- echo "sitectl is not installed" >&2
- return 127
- fi
- if [[ "${1:-}" == "verify" ]]; then
- while IFS= read -r encoded; do
- [[ -n "$encoded" ]] || continue
- decoded="$(printf '%s' "$encoded" | base64 -d)" || return 1
- configured_verify_args+=("$decoded")
- done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/array-values-base64.jq" \
- <<<"${SITECTL_VERIFY_ARGS_JSON:-[]}")
- fi
- "$executable" "$@" "${configured_verify_args[@]}"
- }
- export SITECTL_EXECUTABLE SITECTL_VERIFY_ARGS SITECTL_VERIFY_ARGS_JSON
- export -f sitectl
-}
-
-target_compose_apps() {
- local lifecycle="$1"
- local -a apps=()
-
- target_compose_apps_array "$lifecycle" apps || return 1
- printf '%s\n' "${apps[@]}"
-}
-
-target_compose_apps_array() {
- local lifecycle="$1"
- local result_name="$2"
- local target="${CLOUD_COMPOSE_APP:-${COMPOSE_APP:-${APP_NAME:-}}}"
- local -n "result=$result_name"
-
- if [[ -z "$target" && "$lifecycle" == "rollout" ]]; then
- target="${CLOUD_COMPOSE_PRIMARY_APP:-}"
- fi
- if [[ -n "$target" ]]; then
- compose_app_exists "$target" || return 1
- result=("$target")
- return 0
- fi
- compose_app_names_array "$result_name"
-}
-
-compose_ref_is_full_commit() {
- [[ "$1" =~ ^[0-9a-fA-F]{40}$ ]]
-}
-
-validate_compose_git_source() {
- validate_compose_project_dir "$DOCKER_COMPOSE_DIR" || return 1
- if [[ -z "$DOCKER_COMPOSE_REPO" || "$DOCKER_COMPOSE_REPO" == -* ||
- "$DOCKER_COMPOSE_REPO" == *$'\n'* || "$DOCKER_COMPOSE_REPO" == *$'\r'* ]]; then
- echo "Invalid Compose repository location for $DOCKER_COMPOSE_DIR" >&2
- return 1
- fi
- if compose_ref_is_full_commit "$DOCKER_COMPOSE_BRANCH"; then
- return 0
- fi
- if [[ "$DOCKER_COMPOSE_BRANCH" == -* ]] ||
- ! git check-ref-format "refs/cloud-compose/$DOCKER_COMPOSE_BRANCH" >/dev/null 2>&1; then
- echo "Invalid Compose Git ref: $DOCKER_COMPOSE_BRANCH" >&2
- return 1
- fi
-}
-
-record_compose_app_head() {
- local app="$1"
- local head state_file state_tmp
-
- validate_compose_app_name "$app" || return 1
- head="$(git rev-parse --verify HEAD)"
- if [[ ! "$head" =~ ^[0-9a-f]{40}$ ]]; then
- echo "Could not resolve a full deployed Git commit for ${app}" >&2
- return 1
- fi
-
- install -d -m 0750 "$COMPOSE_APPS_STATE_DIR"
- state_file="${COMPOSE_APPS_STATE_DIR}/${app}.deployed-head"
- state_tmp="$(mktemp "${state_file}.tmp.XXXXXX")"
- printf '%s\n' "$head" > "$state_tmp"
- chmod 0640 "$state_tmp"
- chown cloud-compose:cloud-compose "$state_tmp" 2>/dev/null || true
- mv -f "$state_tmp" "$state_file"
-}
-
-compose_checkout_diff_digest() {
- local digest digest_output diff_file
-
- diff_file="$(mktemp)" || return 1
- if ! git diff --binary --full-index --no-color --no-ext-diff --no-textconv HEAD -- >"$diff_file"; then
- rm -f -- "$diff_file"
- echo "Could not fingerprint managed Compose changes" >&2
- return 1
- fi
- if ! digest_output="$(sha256sum "$diff_file")"; then
- rm -f -- "$diff_file"
- echo "Could not hash managed Compose changes" >&2
- return 1
- fi
- rm -f -- "$diff_file"
- digest="${digest_output%% *}"
- if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then
- echo "Managed Compose change fingerprint is invalid" >&2
- return 1
- fi
- printf '%s\n' "$digest"
-}
-
-record_compose_managed_diff() {
- local app="$1"
- local digest state_file state_tmp
-
- validate_compose_app_name "$app" || return 1
- digest="$(compose_checkout_diff_digest)" || return 1
- install -d -m 0750 "$COMPOSE_APPS_STATE_DIR"
- state_file="${COMPOSE_APPS_STATE_DIR}/${app}.managed-diff"
- state_tmp="$(mktemp "${state_file}.tmp.XXXXXX")"
- printf '%s\n' "$digest" >"$state_tmp"
- chmod 0640 "$state_tmp"
- chown cloud-compose:cloud-compose "$state_tmp" 2>/dev/null || true
- mv -f "$state_tmp" "$state_file"
-}
-
-verify_recorded_compose_managed_diff() {
- local app="$1"
- local current_digest recorded_digest state_file
-
- validate_compose_app_name "$app" || return 1
- state_file="${COMPOSE_APPS_STATE_DIR}/${app}.managed-diff"
- if [[ -L "$state_file" || ! -f "$state_file" ]]; then
- echo "Compose checkout contains tracked changes without recorded managed state: $state_file" >&2
- return 1
- fi
- recorded_digest="$(<"$state_file")"
- if [[ ! "$recorded_digest" =~ ^[0-9a-f]{64}$ ]]; then
- echo "Recorded managed Compose state is invalid for ${app}: $state_file" >&2
- return 1
- fi
- current_digest="$(compose_checkout_diff_digest)" || return 1
- if [[ "$current_digest" != "$recorded_digest" ]]; then
- echo "Compose checkout differs from its recorded sitectl-managed state; commit operator changes in the downstream fork before deployment" >&2
- return 1
- fi
-}
-
-restore_recorded_compose_managed_diff() {
- local app="$1"
-
- if git diff --quiet --ignore-submodules -- &&
- git diff --cached --quiet --ignore-submodules --; then
- return 0
- fi
- verify_recorded_compose_managed_diff "$app" || return 1
- git restore --source=HEAD --staged --worktree -- . || return 1
- if ! git diff --quiet --ignore-submodules -- ||
- ! git diff --cached --quiet --ignore-submodules --; then
- echo "Could not restore the committed Compose source before deployment" >&2
- return 1
- fi
-}
-
-checkout_exact_compose_commit() {
- local app="$1"
- local requested_commit="$2"
- local deployed_head
-
- retry_until_success git fetch --force --no-tags -- origin "$requested_commit" || return 1
- if ! git cat-file -e "${requested_commit}^{commit}" 2>/dev/null; then
- echo "Configured commit ${requested_commit} is not a commit in ${DOCKER_COMPOSE_REPO}" >&2
- return 1
- fi
-
- git checkout --detach "$requested_commit" || return 1
- deployed_head="$(git rev-parse --verify HEAD)" || return 1
- if [[ "${deployed_head,,}" != "${requested_commit,,}" ]]; then
- echo "Expected ${app} at ${requested_commit}, but Git checked out ${deployed_head}" >&2
- return 1
- fi
-
- echo "Checked out ${app} at pinned commit ${deployed_head} (detached HEAD)."
-}
-
-verify_compose_origin() {
- local actual_origin
-
- actual_origin="$(git remote get-url origin 2>/dev/null)" || {
- echo "Compose repository has no origin remote: $DOCKER_COMPOSE_DIR" >&2
- return 1
- }
- if [[ "$actual_origin" != "$DOCKER_COMPOSE_REPO" ]]; then
- echo "Compose origin mismatch for $DOCKER_COMPOSE_DIR: expected $DOCKER_COMPOSE_REPO, found $actual_origin" >&2
- return 1
- fi
-}
-
-verify_clean_compose_checkout() {
- local app="${1:-}"
- local untracked_compose_control
-
- if ! git diff --quiet --ignore-submodules -- ||
- ! git diff --cached --quiet --ignore-submodules --; then
- if [[ -z "$app" ]]; then
- echo "Compose checkout contains tracked or staged changes; commit them in the downstream fork before deployment" >&2
- return 1
- fi
- verify_recorded_compose_managed_diff "$app" || return 1
- fi
- untracked_compose_control="$(git ls-files --others --exclude-standard -- \
- ':(glob)**/compose*.yml' ':(glob)**/compose*.yaml' \
- ':(glob)**/docker-compose*.yml' ':(glob)**/docker-compose*.yaml')" || return 1
- if [[ -n "$untracked_compose_control" ]]; then
- echo "Compose checkout contains an untracked Compose control file: $untracked_compose_control" >&2
- return 1
- fi
-}
-
-reject_host_network_compose_services() {
- local config_json filter_status
-
- if [[ "${CLOUD_COMPOSE_PROVIDER:-}" != "gcp" ]]; then
- return 0
- fi
- if ! command -v docker >/dev/null 2>&1; then
- echo "Docker is required to validate Compose network isolation" >&2
- return 1
- fi
- config_json="$(docker compose config --format json)" || {
- echo "Could not render Compose configuration for metadata-isolation validation" >&2
- return 1
- }
- if ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-services-object-validate.jq" \
- <<<"$config_json" >/dev/null; then
- echo "Docker Compose returned an invalid service configuration" >&2
- return 1
- fi
- if jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/compose-reject-host-network.jq" \
- <<<"$config_json" >/dev/null; then
- echo "Host runtime/build networking and insecure BuildKit entitlements are not allowed on GCP because they bypass container metadata isolation" >&2
- return 1
- else
- filter_status=$?
- fi
- if [[ "$filter_status" -ne 1 ]]; then
- echo "Could not evaluate Compose network isolation with the checked jq program" >&2
- return 1
- fi
-}
-
-clone_or_update_compose_app() {
- local app="$1"
- local local_head fetched_head recorded_head=""
- local deployed_state_file="${COMPOSE_APPS_STATE_DIR}/${app}.deployed-head"
-
- source_compose_app_env "$app" || return 1
- validate_compose_git_source || return 1
-
- if [ ! -d "$DOCKER_COMPOSE_DIR/.git" ]; then
- echo "Directory '$DOCKER_COMPOSE_DIR' not found. Cloning repository for ${app}."
- mkdir -p "$DOCKER_COMPOSE_DIR" || return 1
- pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1
- if compose_ref_is_full_commit "$DOCKER_COMPOSE_BRANCH"; then
- git init . || { popd >/dev/null; return 1; }
- git remote add -- origin "$DOCKER_COMPOSE_REPO" || { popd >/dev/null; return 1; }
- checkout_exact_compose_commit "$app" "$DOCKER_COMPOSE_BRANCH" || { popd >/dev/null; return 1; }
- else
- retry_until_success git clone -b "$DOCKER_COMPOSE_BRANCH" -- "$DOCKER_COMPOSE_REPO" . || {
- popd >/dev/null
- return 1
- }
- fi
- verify_clean_compose_checkout "$app" || { popd >/dev/null; return 1; }
- if [ "$(id -u)" -eq 0 ]; then
- chown -R cloud-compose:cloud-compose . || { popd >/dev/null; return 1; }
- fi
- popd >/dev/null
- else
- pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1
- verify_compose_origin || { popd >/dev/null; return 1; }
- verify_clean_compose_checkout "$app" || { popd >/dev/null; return 1; }
- restore_recorded_compose_managed_diff "$app" || { popd >/dev/null; return 1; }
- if compose_ref_is_full_commit "$DOCKER_COMPOSE_BRANCH"; then
- checkout_exact_compose_commit "$app" "$DOCKER_COMPOSE_BRANCH" || { popd >/dev/null; return 1; }
- else
- retry_until_success git fetch --prune -- origin "$DOCKER_COMPOSE_BRANCH" || {
- popd >/dev/null
- return 1
- }
- local_head="$(git rev-parse --verify HEAD)" || { popd >/dev/null; return 1; }
- if ! git merge-base --is-ancestor HEAD FETCH_HEAD; then
- if [[ -e "$deployed_state_file" || -L "$deployed_state_file" ]]; then
- if [[ -L "$deployed_state_file" || ! -f "$deployed_state_file" ]]; then
- echo "Recorded Compose deployment state is unsafe for ${app}: $deployed_state_file" >&2
- popd >/dev/null
- return 1
- fi
- recorded_head="$(<"$deployed_state_file")"
- if [[ ! "$recorded_head" =~ ^[0-9a-f]{40}$ ]]; then
- echo "Recorded Compose deployment state is invalid for ${app}: $deployed_state_file" >&2
- popd >/dev/null
- return 1
- fi
- fi
- if [[ -n "$recorded_head" && "$local_head" == "$recorded_head" ]]; then
- # A successful rollout may intentionally leave a detached
- # feature/PR commit divergent from the baseline. Explicit
- # source preparation or init may restore that baseline, but
- # only when HEAD is exactly the commit we previously
- # recorded after a successful lifecycle.
- git checkout --detach FETCH_HEAD || { popd >/dev/null; return 1; }
- else
- echo "Local Compose HEAD is not an ancestor of origin/$DOCKER_COMPOSE_BRANCH and does not match the recorded deployment; refusing a local-ahead or divergent deployment" >&2
- popd >/dev/null
- return 1
- fi
- else
- git merge --ff-only FETCH_HEAD || { popd >/dev/null; return 1; }
- fi
- local_head="$(git rev-parse --verify HEAD)" || { popd >/dev/null; return 1; }
- fetched_head="$(git rev-parse --verify 'FETCH_HEAD^{commit}')" || { popd >/dev/null; return 1; }
- if [[ "$local_head" != "$fetched_head" ]]; then
- echo "Compose checkout did not converge exactly to fetched origin/$DOCKER_COMPOSE_BRANCH" >&2
- popd >/dev/null
- return 1
- fi
- fi
- verify_clean_compose_checkout "$app" || { popd >/dev/null; return 1; }
- popd >/dev/null
- fi
-}
-
-verify_existing_compose_app_checkout() {
- local app="$1"
- local current_head
-
- source_compose_app_env "$app" || return 1
- validate_compose_git_source || return 1
- if [[ ! -d "$DOCKER_COMPOSE_DIR/.git" ]]; then
- echo "Compose checkout is missing for ${app}: $DOCKER_COMPOSE_DIR" >&2
- return 1
- fi
-
- pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1
- verify_compose_origin || { popd >/dev/null; return 1; }
- verify_clean_compose_checkout "$app" || { popd >/dev/null; return 1; }
- current_head="$(git rev-parse --verify 'HEAD^{commit}')" || {
- echo "Compose checkout has no deployed commit for ${app}: $DOCKER_COMPOSE_DIR" >&2
- popd >/dev/null
- return 1
- }
- if [[ ! "$current_head" =~ ^[0-9a-f]{40}$ ]]; then
- echo "Compose checkout resolved an invalid commit for ${app}: $current_head" >&2
- popd >/dev/null
- return 1
- fi
- popd >/dev/null
-}
-
-configure_sitectl_app_features() {
- local app="$1"
- local letsencrypt bot_mitigation mode domain acme_email max_upload_size upload_timeout
- local trusted_ip
- local -a trusted_ips=()
-
- source_compose_app_env "$app"
-
- # The "core" plugin (cloud-compose's own default for any app without a
- # named CMS template) carries no component contract. sitectl itself
- # rejects "set" and "converge" against a pluginless context, so calling
- # them here would fail every app-init for a non-templated app. Only a
- # real plugin can own ingress/component desired state.
- if [[ -z "${SITECTL_PLUGIN:-}" || "$SITECTL_PLUGIN" == "core" ]]; then
- return 0
- fi
-
- letsencrypt="$(compose_app_ingress_field "$app" letsencrypt)"
- bot_mitigation="$(compose_app_ingress_field "$app" bot_mitigation)"
- mode="$(compose_app_ingress_field "$app" mode)"
- domain="$(compose_app_ingress_field "$app" domain)"
- acme_email="$(compose_app_ingress_field "$app" acme_email)"
- max_upload_size="$(compose_app_ingress_field "$app" max_upload_size)"
- upload_timeout="$(compose_app_ingress_field "$app" upload_timeout)"
-
- if sitectl_truthy "$letsencrypt" && [ -z "$mode" ]; then
- mode="https-letsencrypt"
- fi
-
- local ingress_args=(set ingress enabled --context "$SITECTL_CONTEXT_NAME" --yolo)
- if [ -n "$mode" ]; then
- ingress_args+=(--mode "$mode")
- fi
- if [ -n "$domain" ]; then
- ingress_args+=(--domain "$domain")
- fi
- if [ -n "$acme_email" ]; then
- ingress_args+=(--acme-email "$acme_email")
- fi
- compose_app_ingress_array_values "$app" trusted_ips trusted_ips || return 1
- for trusted_ip in "${trusted_ips[@]}"; do
- if [ -n "$trusted_ip" ]; then
- ingress_args+=(--trusted-ip "$trusted_ip")
- fi
- done
- if [ -n "$max_upload_size" ]; then
- ingress_args+=(--max-upload-size "$max_upload_size")
- fi
- if [ -n "$upload_timeout" ]; then
- ingress_args+=(--upload-timeout "$upload_timeout")
- fi
-
- # Always initialize component desired state, including when every ingress
- # option uses its default. Verification deliberately fails when
- # .libops/site.yaml is absent, and component set initializes every
- # registered component from its declared default before applying ingress.
- sitectl "${ingress_args[@]}"
- if sitectl_truthy "$bot_mitigation"; then
- sitectl set bot-mitigation on --context "$SITECTL_CONTEXT_NAME" --yolo
- fi
- sitectl converge --context "$SITECTL_CONTEXT_NAME" --yolo
-}
-
-run_compose_app_lifecycle() {
- local app="$1"
- local lifecycle="$2"
- local field="${lifecycle}_commands"
- local command command_status
- local -a commands=()
-
- case "$lifecycle" in
- init | up | down | rollout) ;;
- *)
- echo "Unsupported cloud-compose lifecycle: $lifecycle" >&2
- return 2
- ;;
- esac
-
- # Reject the entire program set before cloning, updating, or running
- # anything so a bad later selector cannot leave partial lifecycle state.
- compose_app_array_values "$app" "$field" commands || return 1
- for command in "${commands[@]}"; do
- [[ -n "$command" ]] || continue
- run_compose_lifecycle_executor --validate "$lifecycle" "$command" || return 1
- done
-
- case "$lifecycle" in
- init)
- # Initialization is the explicit baseline-source convergence phase.
- # It follows a configured moving ref or restores a configured pin.
- clone_or_update_compose_app "$app" || return 1
- ;;
- up | rollout)
- source_compose_app_env "$app" || return 1
- validate_compose_git_source || return 1
- if [[ -d "$DOCKER_COMPOSE_DIR/.git" ]]; then
- # Preserve the revision selected by the last successful
- # rollout. In particular, do not force a detached PR/feature
- # commit back through the configured baseline branch before a
- # service restart or the next rollout can operate on it.
- verify_existing_compose_app_checkout "$app" || return 1
- else
- clone_or_update_compose_app "$app" || return 1
- fi
- if [[ "$lifecycle" == "rollout" ]]; then
- pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1
- restore_recorded_compose_managed_diff "$app" || { popd >/dev/null; return 1; }
- popd >/dev/null
- fi
- ;;
- down)
- source_compose_app_env "$app" || return 1
- validate_compose_git_source || return 1
- ;;
- esac
-
- echo "Running cloud-compose ${lifecycle} for ${app}"
- configure_sitectl_verify_argv || return 1
- pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1
- if [[ "$lifecycle" != "down" ]]; then
- reject_host_network_compose_services || { popd >/dev/null; return 1; }
- fi
- for command in "${commands[@]}"; do
- if [ -z "$command" ]; then
- continue
- fi
- run_compose_lifecycle_executor "$lifecycle" "$command" || {
- command_status=$?
- popd >/dev/null
- return "$command_status"
- }
- done
- if [[ "$lifecycle" != "down" ]]; then
- record_compose_app_head "$app" || { popd >/dev/null; return 1; }
- fi
- if [[ "$lifecycle" == "rollout" ]]; then
- record_compose_managed_diff "$app" || { popd >/dev/null; return 1; }
- fi
- popd >/dev/null
-}
diff --git a/rootfs/home/cloud-compose/compose-dispatch.sh b/rootfs/home/cloud-compose/compose-dispatch.sh
deleted file mode 100755
index 0c97d3b..0000000
--- a/rootfs/home/cloud-compose/compose-dispatch.sh
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-lifecycle="${1:-}"
-if [ -z "$lifecycle" ]; then
- echo "usage: compose-dispatch.sh init|up|down|rollout" >&2
- exit 2
-fi
-
-# shellcheck disable=SC1091
-source /home/cloud-compose/profile.sh
-# shellcheck disable=SC1091
-source /home/cloud-compose/compose-apps.sh
-
-case "$lifecycle" in
- init | up | down | rollout) ;;
- *)
- echo "unknown lifecycle: ${lifecycle}" >&2
- exit 2
- ;;
-esac
-
-acquire_cloud_compose_lifecycle_lock "$lifecycle"
-
-# The provider-neutral rollout service exports its first request argument as
-# ROLLOUT_ARG1. Treat it as the optional manifest app key so one authenticated
-# endpoint can safely target any app on a bin-packed host. The manifest lookup
-# below remains the authority; arbitrary paths or compose project names are
-# never accepted.
-if [[ "$lifecycle" == "rollout" && -z "${CLOUD_COMPOSE_APP:-}" && -n "${ROLLOUT_ARG1:-}" ]]; then
- export CLOUD_COMPOSE_APP="$ROLLOUT_ARG1"
-fi
-
-apps=()
-target_compose_apps_array "$lifecycle" apps
-for app in "${apps[@]}"; do
- run_compose_app_lifecycle "$app" "$lifecycle"
-done
diff --git a/rootfs/home/cloud-compose/configure-metadata-firewall.sh b/rootfs/home/cloud-compose/configure-metadata-firewall.sh
deleted file mode 100755
index 70ee5c2..0000000
--- a/rootfs/home/cloud-compose/configure-metadata-firewall.sh
+++ /dev/null
@@ -1,107 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_configure_metadata_firewall_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_configure_metadata_firewall_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_configure_metadata_firewall_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_configure_metadata_firewall_source _cc_configure_metadata_firewall_dir _cc_configure_metadata_firewall_installed_home
-if [[ -n "$_cc_configure_metadata_firewall_installed_home" &&
- ( "$_cc_configure_metadata_firewall_installed_home" == "/" ||
- "$_cc_configure_metadata_firewall_source" == "${_cc_configure_metadata_firewall_installed_home%/}/"* ) ]]; then
- _cc_configure_metadata_firewall_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_configure_metadata_firewall_checked_programs="$_cc_configure_metadata_firewall_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_configure_metadata_firewall_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_configure_metadata_firewall_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_configure_metadata_firewall_source" \
- CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh \
- "$_cc_configure_metadata_firewall_dir/profile.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-readonly profile_path
-
-mode="${1:-full}"
-case "$mode" in
- full | pre-docker)
- ;;
- *)
- echo "Unknown metadata firewall mode: $mode" >&2
- exit 2
- ;;
-esac
-
-# shellcheck disable=SC1090
-source "$profile_path"
-
-case "${CLOUD_COMPOSE_PROVIDER:-}" in
- gcp)
- ;;
- "")
- echo "CLOUD_COMPOSE_PROVIDER is required before configuring metadata isolation" >&2
- exit 1
- ;;
- *)
- exit 0
- ;;
-esac
-
-iptables_bin="$(command -v iptables || true)"
-if [ -z "$iptables_bin" ]; then
- echo "iptables is required to isolate the GCP metadata service" >&2
- exit 1
-fi
-
-ensure_table_insert_rule() {
- local table="$1" chain="$2"
- shift 2
-
- if ! "$iptables_bin" -t "$table" -C "$chain" "$@" >/dev/null 2>&1; then
- "$iptables_bin" -t "$table" -I "$chain" 1 "$@"
- fi
-}
-
-ensure_insert_rule() {
- ensure_table_insert_rule filter "$@"
-}
-
-metadata_address="169.254.169.254/32"
-
-# This Docker-independent hook is installed before docker.service on subsequent
-# boots. Traffic arriving from a bridge crosses mangle/PREROUTING before Docker
-# can accept it in FORWARD, closing the restart-policy window after a crash.
-# Host-originated root metadata traffic does not traverse PREROUTING.
-ensure_table_insert_rule mangle PREROUTING -d "$metadata_address" -j DROP
-ensure_table_insert_rule mangle PREROUTING -d "$metadata_address" -p tcp --dport 53 -j ACCEPT
-ensure_table_insert_rule mangle PREROUTING -d "$metadata_address" -p udp --dport 53 -j ACCEPT
-
-# Install the Docker-independent host guard in the same early phase. Keep root
-# access for key rotation while blocking unprivileged processes from the
-# documented HTTP and Shielded VM HTTPS metadata endpoints. Other platform
-# traffic to this address (including NTP) remains available to the OS.
-ensure_insert_rule OUTPUT -m owner ! --uid-owner 0 -d "$metadata_address" -p tcp --dport 80 -j DROP
-ensure_insert_rule OUTPUT -m owner ! --uid-owner 0 -d "$metadata_address" -p tcp --dport 443 -j DROP
-
-if [ "$mode" = "pre-docker" ]; then
- exit 0
-fi
-
-if ! "$iptables_bin" -nL DOCKER-USER >/dev/null 2>&1; then
- echo "Docker did not create the DOCKER-USER chain; refusing to start without metadata isolation" >&2
- exit 1
-fi
-
-# DOCKER-USER is Docker's stable user-policy hook. The FORWARD rule is a
-# fail-closed fallback if another daemon bypasses or rewrites that hook. Neither
-# rule affects traffic originating from the host itself. Compute Engine also
-# serves VPC and private-zone DNS from this address, so permit only DNS ahead of
-# the catch-all metadata deny. Insert the deny first because -I prepends rules.
-ensure_insert_rule DOCKER-USER -d "$metadata_address" -j DROP
-ensure_insert_rule DOCKER-USER -d "$metadata_address" -p tcp --dport 53 -j ACCEPT
-ensure_insert_rule DOCKER-USER -d "$metadata_address" -p udp --dport 53 -j ACCEPT
-ensure_insert_rule FORWARD -d "$metadata_address" -j DROP
-ensure_insert_rule FORWARD -d "$metadata_address" -p tcp --dport 53 -j ACCEPT
-ensure_insert_rule FORWARD -d "$metadata_address" -p udp --dport 53 -j ACCEPT
diff --git a/rootfs/home/cloud-compose/converge-app-filesystems.sh b/rootfs/home/cloud-compose/converge-app-filesystems.sh
deleted file mode 100755
index 11f861b..0000000
--- a/rootfs/home/cloud-compose/converge-app-filesystems.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_converge_app_filesystems_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_converge_app_filesystems_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_converge_app_filesystems_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_converge_app_filesystems_source _cc_converge_app_filesystems_dir _cc_converge_app_filesystems_installed_home
-if [[ -n "$_cc_converge_app_filesystems_installed_home" &&
- ( "$_cc_converge_app_filesystems_installed_home" == "/" ||
- "$_cc_converge_app_filesystems_source" == "${_cc_converge_app_filesystems_installed_home%/}/"* ) ]]; then
- _cc_converge_app_filesystems_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_converge_app_filesystems_checked_programs="$_cc_converge_app_filesystems_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_converge_app_filesystems_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_converge_app_filesystems_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_converge_app_filesystems_source" \
- CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh \
- "$_cc_converge_app_filesystems_dir/profile.sh"
-cloud_compose_bind_source_program \
- "$_cc_converge_app_filesystems_source" \
- CLOUD_COMPOSE_COMPOSE_APPS_PATH \
- /home/cloud-compose/compose-apps.sh \
- "$_cc_converge_app_filesystems_dir/compose-apps.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-compose_apps_path="$CLOUD_COMPOSE_COMPOSE_APPS_PATH"
-readonly profile_path compose_apps_path
-
-if ((EUID != 0)); then
- echo "Compose application filesystem convergence must run as root" >&2
- exit 1
-fi
-
-cd "$_cc_converge_app_filesystems_dir"
-# shellcheck disable=SC1090
-source "$profile_path"
-# shellcheck disable=SC1090
-source "$compose_apps_path"
-
-apps=()
-compose_app_names_array apps
-for app in "${apps[@]}"; do
- converge_compose_app_filesystem "$app"
-done
diff --git a/rootfs/home/cloud-compose/default-lifecycle.sh b/rootfs/home/cloud-compose/default-lifecycle.sh
deleted file mode 100755
index 424a793..0000000
--- a/rootfs/home/cloud-compose/default-lifecycle.sh
+++ /dev/null
@@ -1,107 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_default_lifecycle_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_default_lifecycle_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_default_lifecycle_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_default_lifecycle_source _cc_default_lifecycle_dir _cc_default_lifecycle_installed_home
-if [[ -n "$_cc_default_lifecycle_installed_home" &&
- ( "$_cc_default_lifecycle_installed_home" == "/" ||
- "$_cc_default_lifecycle_source" == "${_cc_default_lifecycle_installed_home%/}/"* ) ]]; then
- _cc_default_lifecycle_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_default_lifecycle_checked_programs="$_cc_default_lifecycle_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_default_lifecycle_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_default_lifecycle_checked_programs"
-cloud_compose_bind_program \
- "$_cc_default_lifecycle_source" \
- CLOUD_COMPOSE_SITECTL_VERIFY_ARGS_PROGRAM \
- /etc/cloud-compose/jq/sitectl-verify-args.jq \
- "$_cc_default_lifecycle_dir/../../etc/cloud-compose/jq/sitectl-verify-args.jq"
-sitectl_verify_args_program="$CLOUD_COMPOSE_SITECTL_VERIFY_ARGS_PROGRAM"
-readonly sitectl_verify_args_program
-
-action="${1:-}"
-if [[ "$#" -ne 1 ]]; then
- echo "usage: default-lifecycle.sh init|up|down|rollout" >&2
- exit 2
-fi
-
-case "$action" in
- init | up | down | rollout) ;;
- *)
- echo "unknown default lifecycle action: $action" >&2
- exit 2
- ;;
-esac
-
-context="${SITECTL_CONTEXT_NAME:?SITECTL_CONTEXT_NAME is required}"
-
-run_sitectl() {
- if [[ -n "${SITECTL_EXECUTABLE:-}" ]]; then
- "${SITECTL_EXECUTABLE}" "$@"
- return
- fi
- command sitectl "$@"
-}
-
-verify_nonproduction() {
- local encoded decoded encoded_args
- local -a verify_args=()
-
- if [[ "${SITECTL_ENVIRONMENT:?SITECTL_ENVIRONMENT is required}" != "production" ]]; then
- if ! encoded_args="$(jq -r -f "$sitectl_verify_args_program" \
- <<<"${SITECTL_VERIFY_ARGS_JSON:-[]}")"; then
- echo "SITECTL_VERIFY_ARGS_JSON must be an array of strings" >&2
- return 1
- fi
- while IFS= read -r encoded; do
- [[ -n "$encoded" ]] || continue
- decoded="$(printf '%s' "${encoded#x}" | base64 -d)"
- verify_args+=("$decoded")
- done <<<"$encoded_args"
- run_sitectl verify --context "$context" "${verify_args[@]}"
- fi
-}
-
-case "$action" in
- init)
- run_sitectl config set-context "$context" \
- --type local \
- --project-dir "${DOCKER_COMPOSE_DIR:?DOCKER_COMPOSE_DIR is required}" \
- --site "${CLOUD_COMPOSE_INSTANCE_NAME:?CLOUD_COMPOSE_INSTANCE_NAME is required}" \
- --plugin "${SITECTL_PLUGIN:?SITECTL_PLUGIN is required}" \
- --environment "${SITECTL_ENVIRONMENT:?SITECTL_ENVIRONMENT is required}" \
- --compose-project-name "${COMPOSE_PROJECT_NAME:?COMPOSE_PROJECT_NAME is required}" \
- --docker-socket /var/run/docker.sock \
- --env-file .env \
- --yolo \
- --default
- ;;
- up)
- run_sitectl compose --context "$context" up -d --remove-orphans
- run_sitectl healthcheck --context "$context" --persist
- verify_nonproduction
- ;;
- down)
- run_sitectl compose --context "$context" down
- ;;
- rollout)
- commit_sha="${GIT_COMMIT_SHA:-}"
- if [[ -n "$commit_sha" && ! "$commit_sha" =~ ^[0-9a-f]{40}$ ]]; then
- echo "GIT_COMMIT_SHA must be an exact lowercase 40-character commit SHA" >&2
- exit 2
- fi
- target_ref="${commit_sha:-${GIT_REF:-${GIT_BRANCH:-}}}"
- if [[ -n "$target_ref" ]]; then
- run_sitectl deploy --context "$context" --ref "$target_ref"
- else
- run_sitectl deploy --context "$context" --skip-git
- fi
- run_sitectl healthcheck --context "$context" --persist
- verify_nonproduction
- ;;
-esac
diff --git a/rootfs/home/cloud-compose/disaster-recovery-lib.sh b/rootfs/home/cloud-compose/disaster-recovery-lib.sh
deleted file mode 100755
index 3175b5f..0000000
--- a/rootfs/home/cloud-compose/disaster-recovery-lib.sh
+++ /dev/null
@@ -1,227 +0,0 @@
-#!/usr/bin/env bash
-
-# Shared validation for the provider-neutral disaster-recovery driver contract.
-# The caller must enable `set -euo pipefail` before sourcing this file.
-
-_cc_dr_library_source="$(readlink -f -- "${BASH_SOURCE[0]}")" || {
- echo "Could not resolve the Cloud Compose disaster-recovery library path" >&2
- return 1 2>/dev/null || exit 1
-}
-_cc_dr_library_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || {
- echo "Could not resolve the Cloud Compose disaster-recovery library directory" >&2
- return 1 2>/dev/null || exit 1
-}
-_cc_dr_library_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_dr_library_source _cc_dr_library_dir _cc_dr_library_installed_home
-if [[ -n "$_cc_dr_library_installed_home" &&
- ( "$_cc_dr_library_installed_home" == "/" ||
- "$_cc_dr_library_source" == "${_cc_dr_library_installed_home%/}/"* ) ]]; then
- _cc_dr_library_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_dr_library_checked_programs="$_cc_dr_library_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_dr_library_checked_programs
-# shellcheck disable=SC1090
-if ! source "$_cc_dr_library_checked_programs"; then
- echo "Could not load the checked Cloud Compose program resolver" >&2
- return 1 2>/dev/null || exit 1
-fi
-if ! cloud_compose_bind_program_dir \
- "$_cc_dr_library_source" \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR \
- /etc/cloud-compose/jq \
- "$_cc_dr_library_dir/../../etc/cloud-compose/jq" \
- dr-validate-backup-receipt.jq \
- dr-backup-completed-at.jq \
- dr-backup-remote-id.jq \
- dr-validate-restore-proof.jq \
- dr-restore-completed-at.jq \
- dr-restore-recovery-id.jq; then
- return 1 2>/dev/null || exit 1
-fi
-
-CLOUD_COMPOSE_DR_STATE_ROOT="${CLOUD_COMPOSE_DR_STATE_ROOT:-/mnt/disks/data/.cloud-compose-disaster-recovery}"
-CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER="${CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER:-/etc/cloud-compose/libexec/offhost-backup-driver}"
-
-cloud_compose_dr_is_required() {
- case "${CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED:-false}" in
- true) return 0 ;;
- false) return 1 ;;
- *)
- echo "CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED must be true or false" >&2
- return 2
- ;;
- esac
-}
-
-cloud_compose_dr_validate_safe_absolute_path() {
- local path="$1" label="$2"
-
- if [[ ! "$path" =~ ^/[A-Za-z0-9._/+:-]+$ || "$path" == *"//"* ||
- "$path" =~ (^|/)\.\.?(/|$) ]]; then
- echo "$label must be a safe absolute path without whitespace or dot segments" >&2
- return 1
- fi
-}
-
-cloud_compose_dr_validate_utc_timestamp() {
- local value="$1" label="$2"
-
- if [[ ! "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then
- echo "$label must be an RFC 3339 UTC timestamp with whole-second precision" >&2
- return 1
- fi
-}
-
-cloud_compose_dr_validate_remote_id() {
- local value="$1" label="$2"
-
- if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511}$ ]]; then
- echo "$label contains unsupported characters or exceeds 512 bytes" >&2
- return 1
- fi
-}
-
-cloud_compose_dr_sha256_file() {
- local path="$1" output digest
-
- output="$(sha256sum -- "$path")" || return 1
- digest="${output%% *}"
- if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then
- echo "sha256sum returned an invalid digest for: $path" >&2
- return 1
- fi
- printf '%s\n' "$digest"
-}
-
-cloud_compose_dr_validate_driver() {
- local driver="${1:-$CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER}"
- local current component metadata owner mode kind links resolved
- local -a components
-
- cloud_compose_dr_validate_safe_absolute_path "$driver" "Off-host backup driver path" || return 1
- if [[ -L "$driver" || ! -f "$driver" || ! -x "$driver" ]]; then
- echo "Off-host backup driver must be a non-symlink executable file: $driver" >&2
- return 1
- fi
-
- resolved="$(realpath -e -- "$driver")" || return 1
- if [[ "$resolved" != "$driver" ]]; then
- echo "Off-host backup driver path must not traverse symbolic links: $driver" >&2
- return 1
- fi
-
- IFS='/' read -r -a components <<<"${driver#/}"
- current="/"
- for component in "${components[@]:0:${#components[@]}-1}"; do
- current="${current%/}/${component}"
- if [[ -L "$current" || ! -d "$current" ]]; then
- echo "Off-host backup driver parent must be a real directory: $current" >&2
- return 1
- fi
- metadata="$(stat -c '%u:%a:%F' -- "$current")" || return 1
- IFS=: read -r owner mode kind <<<"$metadata"
- if [[ "$owner" != "0" || "$kind" != "directory" || ! "$mode" =~ ^[0-7]{3,4}$ ||
- $((8#$mode & 0022)) -ne 0 ]]; then
- echo "Off-host backup driver parents must be root-owned and not group/world writable: $current" >&2
- return 1
- fi
- done
-
- metadata="$(stat -c '%u:%a:%h:%F' -- "$driver")" || return 1
- IFS=: read -r owner mode links kind <<<"$metadata"
- if [[ "$owner" != "0" || "$links" != "1" || "$kind" != "regular file" ||
- ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
- echo "Off-host backup driver must be a single-link, root-owned executable that is not group/world writable: $driver" >&2
- return 1
- fi
-}
-
-cloud_compose_dr_validate_json_file() {
- local path="$1" label="$2" metadata owner mode links kind size
-
- if [[ -L "$path" || ! -f "$path" ]]; then
- echo "$label is missing or unsafe" >&2
- return 1
- fi
- metadata="$(stat -c '%u:%a:%h:%F' -- "$path")" || return 1
- IFS=: read -r owner mode links kind <<<"$metadata"
- if [[ "$owner" != "0" || "$links" != "1" || "$kind" != "regular file" ||
- ! "$mode" =~ ^[0-7]{3,4}$ || $((8#$mode & 0022)) -ne 0 ]]; then
- echo "$label must be a single-link, root-owned regular file that is not group/world writable" >&2
- return 1
- fi
- size="$(wc -c <"$path")" || return 1
- if ((size < 2 || size > 65536)); then
- echo "$label must contain between 2 and 65536 bytes" >&2
- return 1
- fi
-}
-
-cloud_compose_dr_prepare_state_directory() {
- local path="$1" metadata owner mode kind
-
- cloud_compose_dr_validate_safe_absolute_path "$path" "Disaster-recovery state path" || return 1
- if [[ -L "$path" || ( -e "$path" && ! -d "$path" ) ]]; then
- echo "Disaster-recovery state path is unsafe: $path" >&2
- return 1
- fi
- install -d -m 0700 -o root -g root -- "$path" || return 1
- metadata="$(stat -c '%u:%a:%F' -- "$path")" || return 1
- IFS=: read -r owner mode kind <<<"$metadata"
- if [[ "$owner" != "0" || "$mode" != "700" || "$kind" != "directory" || -L "$path" ]]; then
- echo "Disaster-recovery state directories must be real root-owned mode-0700 directories: $path" >&2
- return 1
- fi
-}
-
-cloud_compose_dr_validate_backup_receipt() {
- local path="$1" operation_id="$2" manifest_sha256="$3" completed_at remote_id
-
- cloud_compose_dr_validate_json_file "$path" "Off-host backup receipt" || return 1
- jq -e \
- --arg operation_id "$operation_id" \
- --arg manifest_sha256 "$manifest_sha256" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-validate-backup-receipt.jq" \
- "$path" >/dev/null || {
- echo "Off-host backup driver returned an invalid or incomplete coverage receipt" >&2
- return 1
- }
- completed_at="$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-backup-completed-at.jq" "$path")" || return 1
- remote_id="$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-backup-remote-id.jq" "$path")" || return 1
- cloud_compose_dr_validate_utc_timestamp "$completed_at" "Off-host backup completion time" || return 1
- cloud_compose_dr_validate_remote_id "$remote_id" "Off-host backup remote id" || return 1
-}
-
-cloud_compose_dr_validate_restore_proof() {
- local path="$1" test_id="$2" manifest_sha256="$3" receipt_sha256="$4" completed_at recovery_id
-
- cloud_compose_dr_validate_json_file "$path" "Restore-test proof" || return 1
- jq -e \
- --arg test_id "$test_id" \
- --arg manifest_sha256 "$manifest_sha256" \
- --arg receipt_sha256 "$receipt_sha256" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-validate-restore-proof.jq" \
- "$path" >/dev/null || {
- echo "Off-host backup driver returned an invalid restore-test proof" >&2
- return 1
- }
- completed_at="$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-restore-completed-at.jq" "$path")" || return 1
- recovery_id="$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/dr-restore-recovery-id.jq" "$path")" || return 1
- cloud_compose_dr_validate_utc_timestamp "$completed_at" "Restore-test completion time" || return 1
- cloud_compose_dr_validate_remote_id "$recovery_id" "Restore-test recovery id" || return 1
-}
-
-cloud_compose_dr_run_driver() {
- local driver="$1"
- shift
-
- # Driver credentials and configuration are installed and resolved by the
- # operator-owned executable. Terraform-rendered host/application variables
- # are deliberately absent, and driver output is never copied to the journal.
- if ! env -i HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
- "$driver" "$@" /dev/null 2>&1; then
- echo "Off-host disaster-recovery driver failed; inspect its operator-owned diagnostics" >&2
- return 1
- fi
-}
diff --git a/rootfs/home/cloud-compose/docker-prune.sh b/rootfs/home/cloud-compose/docker-prune.sh
deleted file mode 100755
index 4ef168e..0000000
--- a/rootfs/home/cloud-compose/docker-prune.sh
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_docker_prune_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_docker_prune_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_docker_prune_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_docker_prune_source _cc_docker_prune_dir _cc_docker_prune_installed_home
-if [[ -n "$_cc_docker_prune_installed_home" &&
- ( "$_cc_docker_prune_installed_home" == "/" ||
- "$_cc_docker_prune_source" == "${_cc_docker_prune_installed_home%/}/"* ) ]]; then
- _cc_docker_prune_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_docker_prune_checked_programs="$_cc_docker_prune_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_docker_prune_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_docker_prune_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_docker_prune_source" \
- CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh \
- "$_cc_docker_prune_dir/profile.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-readonly profile_path
-
-# shellcheck disable=SC1090
-source "$profile_path"
-
-case "${CLOUD_COMPOSE_DOCKER_PRUNE_ENABLED:-false}" in
- true | TRUE | 1 | yes | YES) ;;
- *)
- echo "Cloud Compose Docker pruning is disabled"
- exit 0
- ;;
-esac
-
-prune_until="${CLOUD_COMPOSE_DOCKER_PRUNE_UNTIL:-168h}"
-if [[ ! "$prune_until" =~ ^[0-9]+(s|m|h)$ ]]; then
- echo "CLOUD_COMPOSE_DOCKER_PRUNE_UNTIL must be a Docker duration such as 168h" >&2
- exit 2
-fi
-
-lock_path="${CLOUD_COMPOSE_DOCKER_PRUNE_LOCK_PATH:-/run/cloud-compose-docker-prune.lock}"
-exec 9>"$lock_path"
-if command -v flock >/dev/null 2>&1 && ! flock -n 9; then
- echo "A Cloud Compose Docker prune is already running"
- exit 0
-fi
-
-echo "Pruning stopped containers, unused networks, dangling images, and build cache older than $prune_until"
-docker container prune --force --filter "until=$prune_until"
-docker network prune --force --filter "until=$prune_until"
-docker image prune --force --filter "until=$prune_until"
-docker builder prune --force --filter "until=$prune_until"
diff --git a/rootfs/home/cloud-compose/host-conf.sh b/rootfs/home/cloud-compose/host-conf.sh
index f29da2d..7ad5f82 100755
--- a/rootfs/home/cloud-compose/host-conf.sh
+++ b/rootfs/home/cloud-compose/host-conf.sh
@@ -4,6 +4,8 @@ set -eou pipefail
# shellcheck disable=SC1091
source /home/cloud-compose/profile.sh
+sitectl=/home/cloud-compose/bin/sitectl
+[[ -x "$sitectl" && ! -L "$sitectl" ]] || sitectl=/etc/cloud-compose/bin/bootstrap-sitectl
if [ -z "${CLOUD_COMPOSE_PROVIDER:-}" ]; then
echo "CLOUD_COMPOSE_PROVIDER is required" >&2
@@ -19,7 +21,7 @@ fi
# package content. Debian-family images install Docker below and receive the
# same policy immediately after that daemon is restarted.
if [ "$CLOUD_COMPOSE_PROVIDER" = "gcp" ] && command -v docker >/dev/null 2>&1; then
- bash /home/cloud-compose/configure-metadata-firewall.sh
+ "$sitectl" host metadata-firewall
fi
bash /home/cloud-compose/install-dependencies.sh
@@ -36,7 +38,7 @@ systemctl restart docker
if [ "$CLOUD_COMPOSE_PROVIDER" = "gcp" ]; then
# Docker may reprogram its chains during restart. Reassert the deny policy
# before any persistent application container is allowed to proceed.
- bash /home/cloud-compose/configure-metadata-firewall.sh
+ "$sitectl" host metadata-firewall
fi
# wait until our data-root /etc/docker/daemon.json setting is applied
@@ -54,7 +56,7 @@ while [ "$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)" != "/
sleep 2
done
-bash /home/cloud-compose/migrate-legacy-systemd-units.sh
+"$sitectl" host systemd migrate-legacy
systemctl daemon-reload
if [ "$CLOUD_COMPOSE_PROVIDER" = "gcp" ]; then
# Gate later Docker starts behind the early guard on stateful hosts, and make
@@ -68,4 +70,4 @@ else
cloud-compose-metadata-firewall.service >/dev/null 2>&1 || true
fi
-bash /home/cloud-compose/libops-managed-runtime.sh install-tools
+"$sitectl" host runtime install
diff --git a/rootfs/home/cloud-compose/host-init.sh b/rootfs/home/cloud-compose/host-init.sh
deleted file mode 100755
index fe4fe40..0000000
--- a/rootfs/home/cloud-compose/host-init.sh
+++ /dev/null
@@ -1,143 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source /home/cloud-compose/profile.sh
-
-_cc_host_init_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_host_init_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_host_init_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_host_init_source _cc_host_init_dir _cc_host_init_installed_home
-if [[ -n "$_cc_host_init_installed_home" &&
- ( "$_cc_host_init_installed_home" == "/" ||
- "$_cc_host_init_source" == "${_cc_host_init_installed_home%/}/"* ) ]]; then
- _cc_host_init_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_host_init_checked_programs="$_cc_host_init_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_host_init_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_host_init_checked_programs"
-cloud_compose_bind_program_dir \
- "$_cc_host_init_source" \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR \
- /etc/cloud-compose/jq \
- "$_cc_host_init_dir/../../etc/cloud-compose/jq" \
- gcp-metadata-public-ip.jq \
- gcp-metadata-private-ip.jq
-
-cleanup() {
- if [ -n "${metadata_file:-}" ]; then
- rm -f "$metadata_file"
- fi
- popd >/dev/null
-}
-
-pushd /home/cloud-compose >/dev/null
-
-trap cleanup EXIT
-
-if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then
- metadata_file="$(mktemp /home/cloud-compose/.metadata.XXXXXXXXXX)"
- curl -sf \
- -H "Metadata-Flavor: Google" \
- "http://metadata.google.internal/computeMetadata/v1/?recursive=true" >"$metadata_file"
-
- update_runtime_env_file .env GCP_PUBLIC_IP \
- "$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/gcp-metadata-public-ip.jq" \
- "$metadata_file")"
- update_runtime_env_file .env GCP_PRIVATE_IP \
- "$(jq -er -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/gcp-metadata-private-ip.jq" \
- "$metadata_file")"
-fi
-
-if [ "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" = "true" ]; then
- install -d -m 0755 /mnt/disks/data/libops-internal
- cp .env /mnt/disks/data/libops-internal/
- chown cloud-compose:cloud-compose /mnt/disks/data/libops-internal/.env
-fi
-
-# Root-owned services execute scripts and load control data from this tree.
-# Keep the home boundary itself non-writable and grant the application account
-# only explicit mutable subdirectories. In particular, sitectl owns its state
-# below .sitectl; scripts at the home boundary remain root-owned.
-chown root:root /home/cloud-compose
-chmod 0755 /home/cloud-compose
-for mutable_dir in \
- /home/cloud-compose/apps \
- /home/cloud-compose/state \
- /home/cloud-compose/.sitectl \
- /home/cloud-compose/.cache \
- /home/cloud-compose/.config \
- /home/cloud-compose/.local; do
- install -d -m 0750 -o cloud-compose -g cloud-compose "$mutable_dir"
-done
-if [[ -L /home/cloud-compose/bin || ! -d /home/cloud-compose/bin ||
- "$(stat -c '%u:%g:%a:%F' -- /home/cloud-compose/bin)" != "0:0:755:directory" ]]; then
- echo "Managed command directory was not secured by the runtime installer" >&2
- exit 1
-fi
-
-find /home/cloud-compose -maxdepth 1 -type f -name '*.sh' \
- -exec chown root:root {} + \
- -exec chmod 0755 {} +
-for dispatcher in init up down rollout; do
- dispatcher_path="/home/cloud-compose/$dispatcher"
- dispatcher_metadata="$(stat -c '%u:%g:%a:%h:%F' -- "$dispatcher_path")" || {
- echo "Unable to inspect Cloud Compose lifecycle dispatcher: $dispatcher_path" >&2
- exit 1
- }
- IFS=: read -r dispatcher_uid _ dispatcher_mode dispatcher_links dispatcher_kind \
- <<<"$dispatcher_metadata"
- if [[ -L "$dispatcher_path" || ! -f "$dispatcher_path" ||
- "$dispatcher_uid" != "0" || "$dispatcher_links" != "1" ||
- "$dispatcher_kind" != "regular file" || ! "$dispatcher_mode" =~ ^[0-7]{3,4}$ ||
- $((8#$dispatcher_mode & 0022)) -ne 0 ]]; then
- echo "Unsafe Cloud Compose lifecycle dispatcher: $dispatcher_path" >&2
- exit 1
- fi
- chown root:root "$dispatcher_path"
- chmod 0755 "$dispatcher_path"
-done
-for runtime_input in .env compose-projects.json application-env.json; do
- if [ -f "/home/cloud-compose/$runtime_input" ]; then
- chown root:cloud-compose "/home/cloud-compose/$runtime_input"
- chmod 0640 "/home/cloud-compose/$runtime_input"
- fi
-done
-groupadd --force docker
-# Pin a fixed UID/GID for cloud-compose. useradd's default dynamic allocation
-# picks the next free ID in the base image at creation time, which is not
-# stable across VM replacements on providers whose root filesystem (and
-# therefore /etc/passwd) does not persist across boots. A cloud-compose
-# account recreated with a different UID cannot read or write anything it
-# previously owned on the persistent data disk, breaking Compose bootstrap.
-# Chosen well outside the default useradd/groupadd auto-allocation range
-# (commonly capped under 60000) and Docker's default subuid/subgid remap
-# range (typically starting at 100000), to minimize collision risk across
-# arbitrary base images and providers.
-readonly cloud_compose_uid=770077
-readonly cloud_compose_gid=770077
-groupadd --force --gid "$cloud_compose_gid" cloud-compose
-if ! id -u cloud-compose >/dev/null 2>&1; then
- useradd --create-home --shell /bin/bash \
- --uid "$cloud_compose_uid" --gid "$cloud_compose_gid" \
- --groups docker cloud-compose
-elif ! id -nG cloud-compose | tr ' ' '\n' | grep -qx docker; then
- usermod --append --groups docker cloud-compose || {
- echo "Warning: failed to add cloud-compose to docker group" >&2
- id cloud-compose >&2 || true
- getent group docker >&2 || true
- }
-fi
-
-# Cloud-init prepares these mount roots before invoking run.sh, while the
-# Ansible and Salt adapters can install the runtime onto pre-mounted storage.
-# Normalize only the mount roots here so every entry path can create lifecycle
-# locks without granting recursive ownership of application or control data.
-# The sticky, root-owned data root protects root-only bootstrap attestations
-# while still allowing the cloud-compose group to create application paths.
-install -d -m 1775 -o root -g cloud-compose /mnt/disks/data
-install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/volumes
-install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops
diff --git a/rootfs/home/cloud-compose/install-dependencies-debian.sh b/rootfs/home/cloud-compose/install-dependencies-debian.sh
index e493c22..c6e88a8 100755
--- a/rootfs/home/cloud-compose/install-dependencies-debian.sh
+++ b/rootfs/home/cloud-compose/install-dependencies-debian.sh
@@ -8,14 +8,23 @@ source /home/cloud-compose/profile.sh
export DEBIAN_FRONTEND=noninteractive
retry_until_success apt-get update -qq
-retry_until_success apt-get install -y \
- ca-certificates \
- curl \
- docker.io \
- git \
- jq \
- make \
+packages=(
+ ca-certificates
+ curl
+ git
+ jq
+ make
openssl
+)
+
+# Ubuntu's docker.io depends on the distribution containerd package, which
+# conflicts with Docker CE's containerd.io. Reused images that already provide
+# a working Docker CLI must retain their existing, compatible Docker toolchain.
+if ! command -v docker >/dev/null 2>&1; then
+ packages+=(docker.io)
+fi
+
+retry_until_success apt-get install -y "${packages[@]}"
install -d /usr/local/lib/docker/cli-plugins
bash /home/cloud-compose/install-docker-plugins.sh
diff --git a/rootfs/home/cloud-compose/install-docker-plugins.sh b/rootfs/home/cloud-compose/install-docker-plugins.sh
index 23855d9..e972031 100755
--- a/rootfs/home/cloud-compose/install-docker-plugins.sh
+++ b/rootfs/home/cloud-compose/install-docker-plugins.sh
@@ -1,141 +1,7 @@
#!/usr/bin/env bash
-
set -euo pipefail
-
-_cc_docker_plugins_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_docker_plugins_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_docker_plugins_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_docker_plugins_source _cc_docker_plugins_dir _cc_docker_plugins_installed_home
-if [[ -n "$_cc_docker_plugins_installed_home" &&
- ( "$_cc_docker_plugins_installed_home" == "/" ||
- "$_cc_docker_plugins_source" == "${_cc_docker_plugins_installed_home%/}/"* ) ]]; then
- _cc_docker_plugins_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_docker_plugins_checked_programs="$_cc_docker_plugins_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_docker_plugins_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_docker_plugins_checked_programs"
-cloud_compose_bind_program \
- "$_cc_docker_plugins_source" \
- CLOUD_COMPOSE_RELEASE_CHECKSUM_PROGRAM \
- /etc/cloud-compose/awk/release-checksum.awk \
- "$_cc_docker_plugins_dir/../../etc/cloud-compose/awk/release-checksum.awk"
-release_checksum_program="$CLOUD_COMPOSE_RELEASE_CHECKSUM_PROGRAM"
-readonly release_checksum_program
-
-docker_arch() {
- case "$(uname -m)" in
- x86_64 | amd64) echo "x86_64" ;;
- aarch64 | arm64) echo "aarch64" ;;
- *)
- echo "unsupported architecture: $(uname -m)" >&2
- return 1
- ;;
- esac
-}
-
-buildx_arch() {
- case "$(uname -m)" in
- x86_64 | amd64) echo "amd64" ;;
- aarch64 | arm64) echo "arm64" ;;
- *)
- echo "unsupported architecture: $(uname -m)" >&2
- return 1
- ;;
- esac
-}
-
-release_checksum() {
- local manifest="$1"
- local asset="$2"
-
- awk -v asset="$asset" -f "$release_checksum_program" "$manifest"
-}
-
-validate_release_version() {
- local name="$1"
- local version="$2"
-
- if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
- echo "invalid ${name} release version: ${version}" >&2
- return 1
- fi
-}
-
-install_docker_cli_plugin() (
- local name="$1"
- local release_url="$2"
- local asset="$3"
- local path="$4"
- local tmp_dir manifest download expected actual
-
- mkdir -p "$(dirname "$path")"
- if [[ -d "$path" ]]; then
- echo "Refusing Docker CLI plugin directory at file path: ${path}" >&2
- return 1
- fi
- tmp_dir="$(mktemp -d "$(dirname "$path")/.${name}.XXXXXXXXXX")"
- trap 'rm -rf -- "$tmp_dir"' EXIT
- manifest="${tmp_dir}/checksums.txt"
- download="${tmp_dir}/${asset}"
-
- retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
- --connect-timeout 10 --max-time 300 -o "$manifest" -- "${release_url}/checksums.txt"
- if ! expected="$(release_checksum "$manifest" "$asset")" ||
- [[ ! "$expected" =~ ^[0-9a-f]{64}$ ]]; then
- echo "No unique SHA-256 checksum for ${asset} in the ${name} release manifest" >&2
- return 1
- fi
-
- if [[ -f "$path" && ! -L "$path" && -x "$path" ]]; then
- actual="$(sha256sum "$path")"
- actual="${actual%% *}"
- if [[ "$actual" == "$expected" ]]; then
- echo "Docker CLI plugin ${name} is already verified"
- return 0
- fi
- fi
-
- echo "Installing verified Docker CLI plugin ${name}"
- retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
- --connect-timeout 10 --max-time 300 -o "$download" -- "${release_url}/${asset}"
- printf '%s %s\n' "$expected" "$download" | sha256sum -c -
- chmod 0755 "$download"
- mv -f -- "$download" "$path"
-)
-
-install_docker_plugins() {
- local plugin_dir="${DOCKER_CLI_PLUGIN_DIR:-/usr/local/lib/docker/cli-plugins}"
- local compose_arch buildx_asset_arch
-
- # renovate: datasource=github-releases depName=docker-compose packageName=docker/compose versioning=semver
- DOCKER_COMPOSE_VERSION="${DOCKER_COMPOSE_VERSION:-v5.3.1}"
- # renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver
- DOCKER_BUILDX_VERSION="${DOCKER_BUILDX_VERSION:-v0.35.0}"
-
- validate_release_version docker-compose "$DOCKER_COMPOSE_VERSION"
- validate_release_version docker-buildx "$DOCKER_BUILDX_VERSION"
- compose_arch="$(docker_arch)"
- buildx_asset_arch="$(buildx_arch)"
- install_docker_cli_plugin \
- docker-compose \
- "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}" \
- "docker-compose-linux-${compose_arch}" \
- "${plugin_dir}/docker-compose"
- install_docker_cli_plugin \
- docker-buildx \
- "https://github.com/docker/buildx/releases/download/${DOCKER_BUILDX_VERSION}" \
- "buildx-${DOCKER_BUILDX_VERSION}.linux-${buildx_asset_arch}" \
- "${plugin_dir}/docker-buildx"
-}
-
-main() {
- # shellcheck disable=SC1091
- source /home/cloud-compose/profile.sh
- install_docker_plugins "$@"
-}
-
-if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
- main "$@"
-fi
+# shellcheck disable=SC1091
+source /home/cloud-compose/profile.sh
+sitectl=/home/cloud-compose/bin/sitectl
+[[ -x "$sitectl" && ! -L "$sitectl" ]] || sitectl=/etc/cloud-compose/bin/bootstrap-sitectl
+exec "$sitectl" host docker-plugins
diff --git a/rootfs/home/cloud-compose/libops-managed-runtime.sh b/rootfs/home/cloud-compose/libops-managed-runtime.sh
deleted file mode 100755
index 71cfb54..0000000
--- a/rootfs/home/cloud-compose/libops-managed-runtime.sh
+++ /dev/null
@@ -1,1056 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_managed_runtime_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_managed_runtime_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_managed_runtime_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_managed_runtime_source _cc_managed_runtime_dir _cc_managed_runtime_installed_home
-if [[ -n "$_cc_managed_runtime_installed_home" &&
- ( "$_cc_managed_runtime_installed_home" == "/" ||
- "$_cc_managed_runtime_source" == "${_cc_managed_runtime_installed_home%/}/"* ) ]]; then
- _cc_managed_runtime_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_managed_runtime_checked_programs="$_cc_managed_runtime_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_managed_runtime_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_managed_runtime_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_managed_runtime_source" \
- CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh \
- "$_cc_managed_runtime_dir/profile.sh"
-
-# shellcheck disable=SC1090
-source "$CLOUD_COMPOSE_PROFILE_PATH"
-
-# Reload the fixed resolver after the profile before binding data programs.
-# shellcheck disable=SC1090
-source "$_cc_managed_runtime_checked_programs"
-cloud_compose_bind_program_dir \
- "$_cc_managed_runtime_source" \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR \
- /etc/cloud-compose/jq \
- "$_cc_managed_runtime_dir/../../etc/cloud-compose/jq" \
- github-latest-release-tag.jq \
- sitectl-package-version.jq \
- sitectl-package-versions-validate.jq \
- object-field-delimited.jq \
- object-keys-base64.jq
-cloud_compose_bind_program \
- "$_cc_managed_runtime_source" \
- CLOUD_COMPOSE_RELEASE_CHECKSUM_ENTRY_PROGRAM \
- /etc/cloud-compose/awk/release-checksum-entry.awk \
- "$_cc_managed_runtime_dir/../../etc/cloud-compose/awk/release-checksum-entry.awk"
-
-LOG_PREFIX="[libops-managed-runtime]"
-STATE_DIR="/mnt/disks/data/libops-managed"
-BIN_DIR="${STATE_DIR}/bin"
-TMP_DIR="${STATE_DIR}/tmp"
-PACKAGE_STATE_DIR="${STATE_DIR}/packages"
-ARTIFACT_STATE_DIR="${STATE_DIR}/artifacts"
-ARTIFACT_MANIFEST="/home/cloud-compose/managed-runtime-artifacts.tsv"
-GITHUB_OWNER="${SITECTL_GITHUB_OWNER:-libops}"
-PUBLISHED_BIN_DIR="/home/cloud-compose/bin"
-
-log() {
- echo "${LOG_PREFIX} $*" >&2
-}
-
-enabled() {
- case "${LIBOPS_MANAGED_RUNTIME_ENABLED:-true}" in
- true | TRUE | 1 | yes | YES) return 0 ;;
- *) return 1 ;;
- esac
-}
-
-mkdirs() {
- local expected_uid expected_gid path mode allow_owner_migration spec
- local -a directory_specs
-
- expected_uid="$EUID"
- expected_gid="$(id -g)"
- if [[ "$STATE_DIR" == "/mnt/disks/data/libops-managed" ||
- "$PUBLISHED_BIN_DIR" == "/home/cloud-compose/bin" ]]; then
- if ((EUID != 0)); then
- log "production managed runtime directories require a root updater"
- return 1
- fi
- expected_uid=0
- expected_gid=0
- fi
-
- # The managed binary is published through /home/cloud-compose/bin and must
- # remain traversable after a root-owned bootstrap drops to cloud-compose.
- # Refuse redirected, non-directory, non-owner-controlled, or writable
- # state before creating package staging files beneath the shared data mount.
- directory_specs=(
- "$STATE_DIR:0755:false"
- "$BIN_DIR:0755:false"
- "$TMP_DIR:0700:false"
- "$PACKAGE_STATE_DIR:0700:false"
- "$ARTIFACT_STATE_DIR:0700:false"
- "$PUBLISHED_BIN_DIR:0755:true"
- )
- for spec in "${directory_specs[@]}"; do
- IFS=: read -r path mode allow_owner_migration <<<"$spec"
- prepare_managed_runtime_directory \
- "$path" "$mode" "$expected_uid" "$expected_gid" "$allow_owner_migration" || return 1
- done
-}
-
-prepare_managed_runtime_directory() {
- local path="$1" mode="$2" expected_uid="$3" expected_gid="$4"
- local allow_owner_migration="$5" metadata owner_uid group_gid actual_mode kind resolved desired_mode
- local created=false
-
- if [[ -L "$path" || ( -e "$path" && ! -d "$path" ) ]]; then
- log "managed runtime path is not a real directory: ${path}"
- return 1
- fi
- if [[ ! -e "$path" && ! -L "$path" ]]; then
- # mkdir is the creation boundary: if an unprivileged process wins the
- # name between inspection and creation, fail rather than adopting its
- # pre-populated directory with install -d.
- if ! mkdir -m "$mode" -- "$path"; then
- log "managed runtime directory appeared during creation: ${path}"
- return 1
- fi
- created=true
- fi
- if [[ "$created" != "true" ]]; then
- if [[ -L "$path" || ! -d "$path" ]]; then
- log "managed runtime path changed during validation: ${path}"
- return 1
- fi
- metadata="$(stat -c '%u:%g:%a:%F' -- "$path")" || return 1
- IFS=: read -r owner_uid group_gid actual_mode kind <<<"$metadata"
- if [[ "$kind" != "directory" || ! "$actual_mode" =~ ^[0-7]{3,4}$ ||
- $((8#$actual_mode & 0022)) -ne 0 ]]; then
- log "managed runtime directory is writable by another account: ${path}"
- return 1
- fi
- if [[ ( "$allow_owner_migration" != "true" || "$EUID" != "0" ) &&
- ( "$owner_uid" != "$expected_uid" || "$group_gid" != "$expected_gid" ) ]]; then
- log "managed runtime directory is not owned by the updater: ${path}"
- return 1
- fi
- if [[ "$allow_owner_migration" == "true" ]]; then
- # Close the legacy application-owned PATH directory before walking
- # its entries. The bootstrap libexec boundary has already made its
- # parent root-owned, so an old owner cannot race validation.
- if ((EUID == 0)); then
- install -d -m "$mode" -o "$expected_uid" -g "$expected_gid" -- "$path" || return 1
- else
- chmod "$mode" -- "$path" || return 1
- fi
- validate_published_bin_directory "$path" || return 1
- fi
- fi
-
- if ((EUID == 0)); then
- install -d -m "$mode" -o "$expected_uid" -g "$expected_gid" -- "$path" || return 1
- else
- chmod "$mode" -- "$path" || return 1
- fi
- resolved="$(readlink -f -- "$path")" || return 1
- desired_mode="$(printf '%o' "$((8#$mode))")"
- metadata="$(stat -c '%u:%g:%a:%F' -- "$path")" || return 1
- if [[ "$resolved" != "$path" || "$metadata" != "${expected_uid}:${expected_gid}:${desired_mode}:directory" ||
- -L "$path" ]]; then
- log "managed runtime directory did not converge safely: ${path}"
- return 1
- fi
-}
-
-validate_published_bin_directory() {
- local path="$1" entry name target
- local -a entries
-
- # /home/cloud-compose/bin was application-owned on older hosts. Preserve
- # only generated managed-tool links whose targets remain under the
- # validated root-owned package directory; reject every other inherited PATH
- # entry. COS publishes its verified static Make build here because /home
- # and /var are mounted noexec.
- shopt -s nullglob dotglob
- entries=("$path"/*)
- shopt -u nullglob dotglob
- for entry in "${entries[@]}"; do
- name="${entry##*/}"
- if [[ ! "$name" =~ ^(make|sitectl(-[a-z0-9]+)*)$ || ! -L "$entry" ]]; then
- log "published command directory contains an unmanaged entry: ${entry}"
- return 1
- fi
- target="$(readlink -- "$entry")" || return 1
- if [[ "$target" != "${BIN_DIR}/${name}" ]]; then
- log "published command has an unsafe target: ${entry}"
- return 1
- fi
- done
-}
-
-with_lock() {
- local action="$1"
- shift
-
- mkdirs || return 1
- if command -v flock >/dev/null 2>&1; then
- exec 9>"${STATE_DIR}/runtime.lock"
- if ! flock -n 9; then
- log "managed runtime update already running"
- return 0
- fi
- fi
-
- "$action" "$@"
-}
-
-machine_arch() {
- case "$(uname -m)" in
- x86_64 | amd64) echo "x86_64" ;;
- aarch64 | arm64) echo "arm64" ;;
- *)
- log "unsupported architecture: $(uname -m)"
- return 1
- ;;
- esac
-}
-
-release_base_url() {
- local package="$1"
- local version="$2"
-
- if [ "$version" = "latest" ]; then
- echo "https://github.com/${GITHUB_OWNER}/${package}/releases/latest/download"
- else
- echo "https://github.com/${GITHUB_OWNER}/${package}/releases/download/${version}"
- fi
-}
-
-latest_release_tag() {
- local package="$1"
- local metadata tag
- metadata="$(mktemp "${TMP_DIR}/${package}.latest.XXXXXX.json")"
-
- if ! retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
- --connect-timeout 10 --max-time 300 -o "$metadata" -- \
- "https://api.github.com/repos/${GITHUB_OWNER}/${package}/releases/latest"; then
- rm -f "$metadata"
- return 1
- fi
-
- if ! tag="$(jq -jr -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/github-latest-release-tag.jq" \
- "$metadata")"; then
- log "latest release metadata for ${package} did not contain a tag"
- rm -f "$metadata"
- return 1
- fi
- tag="${tag%$'\x1f'}"
- rm -f "$metadata"
- if [[ "$tag" == "latest" ]] || ! valid_sitectl_version "$tag"; then
- log "latest release metadata for ${package} contained an invalid tag"
- return 1
- fi
- printf '%s\n' "$tag"
-}
-
-installed_release_tag() {
- local package="$1"
- local state_file="${PACKAGE_STATE_DIR}/${package}.version"
-
- if [ -f "$state_file" ]; then
- cat "$state_file"
- fi
-}
-
-installed_release_sha256() {
- local package="$1"
- local state_file="${PACKAGE_STATE_DIR}/${package}.sha256"
-
- if [ -f "$state_file" ]; then
- cat "$state_file"
- fi
-}
-
-write_installed_release_state() {
- local package="$1"
- local version="$2"
- local binary="$3"
- local version_file="${PACKAGE_STATE_DIR}/${package}.version"
- local sha_file="${PACKAGE_STATE_DIR}/${package}.sha256"
- local version_tmp sha_tmp binary_sha
-
- binary_sha="$(sha256sum -- "$binary")" || return 1
- binary_sha="${binary_sha%% *}"
- version_tmp="$(mktemp "${version_file}.tmp.XXXXXX")" || return 1
- sha_tmp="$(mktemp "${sha_file}.tmp.XXXXXX")" || {
- rm -f -- "$version_tmp"
- return 1
- }
- printf '%s\n' "$version" >"$version_tmp"
- printf '%s\n' "$binary_sha" >"$sha_tmp"
- chmod 0640 "$version_tmp" "$sha_tmp"
- mv -f -- "$version_tmp" "$version_file"
- mv -f -- "$sha_tmp" "$sha_file"
-}
-
-install_release_package() {
- local package="$1"
- local target_bin_dir="${2:-$BIN_DIR}"
- local publish="${3:-true}"
- local version
- local arch archive base_url tag installed installed_sha actual_sha tmp checksum_file archive_listing binary target_tmp
-
- if [ -z "$package" ]; then
- return 0
- fi
- case "$package" in
- sitectl | sitectl-*) ;;
- *)
- log "skipping invalid sitectl package name: ${package}"
- return 1
- ;;
- esac
-
- version="$(sitectl_package_version "$package")"
-
- arch="$(machine_arch)"
- archive="${package}_Linux_${arch}.tar.gz"
- tag="$version"
- if [ "$version" = "latest" ]; then
- tag="$(latest_release_tag "$package")"
- if [ "$tag" = "latest" ] || ! valid_sitectl_version "$tag"; then
- log "latest release for ${package} returned an invalid tag: ${tag}"
- return 1
- fi
- fi
- base_url="$(release_base_url "$package" "$tag")"
- installed="$(installed_release_tag "$package" || true)"
- installed_sha="$(installed_release_sha256 "$package" || true)"
-
- mkdir -p -- "$target_bin_dir"
- if [ -n "$tag" ] && [ "$installed" = "$tag" ] &&
- [[ "$installed_sha" =~ ^[0-9a-f]{64}$ ]] &&
- [[ ! -L "${BIN_DIR}/${package}" && -f "${BIN_DIR}/${package}" && -x "${BIN_DIR}/${package}" ]]; then
- actual_sha="$(sha256sum -- "${BIN_DIR}/${package}")" || return 1
- actual_sha="${actual_sha%% *}"
- if [[ "$actual_sha" == "$installed_sha" ]]; then
- if [[ "$target_bin_dir" != "$BIN_DIR" ]]; then
- target_tmp="$(mktemp "${target_bin_dir}/.${package}.XXXXXX")" || return 1
- install -m 0755 "${BIN_DIR}/${package}" "$target_tmp" || {
- rm -f -- "$target_tmp"
- return 1
- }
- mv -f -- "$target_tmp" "${target_bin_dir}/${package}"
- elif [[ "$publish" == "true" ]]; then
- ln -sfn "${BIN_DIR}/${package}" "${PUBLISHED_BIN_DIR}/${package}"
- fi
- RELEASE_PACKAGE_TAG="$tag"
- RELEASE_PACKAGE_SHA256="$installed_sha"
- log "${package} is already ${tag}"
- return 0
- fi
- fi
-
- tmp="$(mktemp -d "${TMP_DIR}/${package}.XXXXXX")"
-
- log "installing ${package} from ${base_url}/${archive}"
- retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
- --connect-timeout 10 --max-time 300 -o "${tmp}/${archive}" -- "${base_url}/${archive}"
- retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
- --connect-timeout 10 --max-time 300 -o "${tmp}/checksums.txt" -- "${base_url}/checksums.txt"
-
- checksum_file="${tmp}/checksums.selected.txt"
- awk -v archive="$archive" -f "$CLOUD_COMPOSE_RELEASE_CHECKSUM_ENTRY_PROGRAM" \
- "${tmp}/checksums.txt" >"$checksum_file"
- if [[ "$(wc -l <"$checksum_file")" -ne 1 ]]; then
- log "release checksums must contain exactly one entry for ${archive}"
- rm -rf -- "$tmp"
- return 1
- fi
- (cd "$tmp" && sha256sum -c "$(basename "$checksum_file")")
-
- # Extract only the exact top-level binary. Release archives also contain
- # documentation, but no other member needs filesystem access on the host.
- archive_listing="${tmp}/archive.list"
- tar -tzf "${tmp}/${archive}" >"$archive_listing"
- if [[ "$(grep -Fxc -- "$package" "$archive_listing")" -ne 1 ]]; then
- log "release archive must contain exactly one top-level ${package} binary"
- rm -rf -- "$tmp"
- return 1
- fi
- tar -xzf "${tmp}/${archive}" -C "$tmp" -- "$package"
- binary="${tmp}/${package}"
- if [[ -L "$binary" || ! -f "$binary" ]]; then
- log "release archive member ${package} is not a regular file"
- rm -rf -- "$tmp"
- return 1
- fi
-
- target_tmp="$(mktemp "${target_bin_dir}/.${package}.XXXXXX")" || return 1
- install -m 0755 "$binary" "$target_tmp" || {
- rm -f -- "$target_tmp"
- rm -rf -- "$tmp"
- return 1
- }
- actual_sha="$(sha256sum -- "$target_tmp")" || {
- rm -f -- "$target_tmp"
- return 1
- }
- actual_sha="${actual_sha%% *}"
- mv -f -- "$target_tmp" "${target_bin_dir}/${package}"
- RELEASE_PACKAGE_TAG="$tag"
- RELEASE_PACKAGE_SHA256="$actual_sha"
- if [[ "$publish" == "true" ]]; then
- ln -sfn "${BIN_DIR}/${package}" "${PUBLISHED_BIN_DIR}/${package}"
- write_installed_release_state "$package" "$tag" "${target_bin_dir}/${package}"
- log "installed ${package} ${tag}"
- fi
- rm -rf -- "$tmp"
-}
-
-sitectl_package_list() {
- local packages="${SITECTL_PACKAGES:-sitectl}"
- local seen=" "
- local package
-
- for package in sitectl $packages; do
- if [ -z "$package" ]; then
- continue
- fi
- if [[ "$seen" == *" $package "* ]]; then
- continue
- fi
- seen="${seen}${package} "
- echo "$package"
- done
-}
-
-valid_sitectl_version() {
- local version="$1"
-
- [[ "$version" == "latest" || "$version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]
-}
-
-sitectl_package_versions_json() {
- if [ -n "${SITECTL_PACKAGE_VERSIONS:-}" ]; then
- printf '%s\n' "$SITECTL_PACKAGE_VERSIONS"
- else
- printf '%s\n' '{}'
- fi
-}
-
-sitectl_package_version() {
- local package="$1"
- local fallback="${SITECTL_VERSION:-latest}"
-
- jq -er --arg package "$package" --arg fallback "$fallback" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/sitectl-package-version.jq" \
- <<<"$(sitectl_package_versions_json)"
-}
-
-validate_sitectl_configuration() {
- local fallback="${SITECTL_VERSION:-latest}"
- local versions_json package encoded_override override override_version
- local -A installed_packages=()
-
- if ! valid_sitectl_version "$fallback"; then
- log "invalid SITECTL_VERSION: ${fallback}"
- return 1
- fi
-
- versions_json="$(sitectl_package_versions_json)"
- if ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/sitectl-package-versions-validate.jq" \
- <<<"$versions_json" >/dev/null; then
- log "SITECTL_PACKAGE_VERSIONS must be a JSON object of sitectl package names to latest or exact semantic-version release tags"
- return 1
- fi
-
- while read -r package; do
- if [[ ! "$package" =~ ^sitectl(-[a-z0-9]+)*$ ]]; then
- log "invalid package in SITECTL_PACKAGES: ${package}"
- return 1
- fi
- installed_packages["$package"]=true
- done < <(sitectl_package_list)
-
- while IFS= read -r encoded_override; do
- override="$(
- if ! printf '%s' "$encoded_override" | base64 --decode; then
- exit 1
- fi
- printf '\037'
- )" || return 1
- override="${override%$'\x1f'}"
- if [[ ! "$override" =~ ^sitectl(-[a-z0-9]+)*$ ]]; then
- log "SITECTL_PACKAGE_VERSIONS contains an invalid package name: ${override}"
- return 1
- fi
- override_version="$(jq -jr --arg field "$override" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" \
- <<<"$versions_json")" || return 1
- override_version="${override_version%$'\x1f'}"
- if ! valid_sitectl_version "$override_version"; then
- log "SITECTL_PACKAGE_VERSIONS contains an invalid release tag for ${override}: ${override_version}"
- return 1
- fi
- if [[ -z "${installed_packages[$override]:-}" ]]; then
- log "SITECTL_PACKAGE_VERSIONS contains an uninstalled package: ${override}"
- return 1
- fi
- done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-keys-base64.jq" \
- <<<"$versions_json")
-}
-
-validate_stale_managed_sitectl_package() {
- local package="$1"
- local version_file="${PACKAGE_STATE_DIR}/${package}.version"
- local sha_file="${PACKAGE_STATE_DIR}/${package}.sha256"
- local binary="${BIN_DIR}/${package}"
- local published="${PUBLISHED_BIN_DIR}/${package}"
- local version expected_sha actual_sha published_target
-
- if [[ "$package" == "sitectl" || ! "$package" =~ ^sitectl(-[a-z0-9]+)+$ ]]; then
- log "refusing to prune invalid managed package name: ${package}"
- return 1
- fi
- if [[ -L "$version_file" || ! -f "$version_file" ||
- -L "$sha_file" || ! -f "$sha_file" ||
- -L "$binary" || ! -f "$binary" || ! -x "$binary" ]]; then
- log "refusing to prune ${package}: binary and regular version/checksum state are required"
- return 1
- fi
-
- version="$(<"$version_file")"
- expected_sha="$(<"$sha_file")"
- if [[ "$version" == "latest" ]] || ! valid_sitectl_version "$version" ||
- [[ ! "$expected_sha" =~ ^[0-9a-f]{64}$ ]]; then
- log "refusing to prune ${package}: installed release state is invalid"
- return 1
- fi
- actual_sha="$(sha256sum -- "$binary")" || return 1
- actual_sha="${actual_sha%% *}"
- if [[ "$actual_sha" != "$expected_sha" ]]; then
- log "refusing to prune ${package}: binary does not match managed checksum state"
- return 1
- fi
-
- if [[ -e "$published" || -L "$published" ]]; then
- if [[ ! -L "$published" ]]; then
- log "refusing to prune ${package}: published command is not a managed symlink"
- return 1
- fi
- published_target="$(readlink -- "$published")" || return 1
- if [[ "$published_target" != "$binary" ]]; then
- log "refusing to prune ${package}: published command targets ${published_target}"
- return 1
- fi
- fi
-}
-
-install_sitectl_packages() {
- local package path name stage_dir payload_dir tags_dir backup_dir
- local promotion_failed=false
- local -a packages=() candidate_paths=() stale_packages=() transaction_packages=()
- local -A desired_packages=() stale_seen=()
-
- validate_sitectl_configuration
- mkdirs || return 1
-
- mapfile -t packages < <(sitectl_package_list)
- for package in "${packages[@]}"; do
- desired_packages["$package"]=true
- done
- stage_dir="$(mktemp -d "${TMP_DIR}/sitectl-generation.XXXXXX")" || return 1
- payload_dir="${stage_dir}/payload"
- tags_dir="${stage_dir}/tags"
- backup_dir="${stage_dir}/backup"
- mkdir -p "$payload_dir" "$tags_dir" "$backup_dir/bin" "$backup_dir/state"
-
- # Download or copy every verified package before replacing any live binary.
- # A later plugin failure therefore leaves the complete previous set active.
- for package in "${packages[@]}"; do
- if ! install_release_package "$package" "$payload_dir" false; then
- rm -rf -- "$stage_dir"
- return 1
- fi
- printf '%s\n' "$RELEASE_PACKAGE_TAG" >"${tags_dir}/${package}.version"
- printf '%s\n' "$RELEASE_PACKAGE_SHA256" >"${tags_dir}/${package}.sha256"
- done
-
- # Downloads and checksum verification do not mutate the live set. Take the
- # application lock only for the backup/promotion window so a slow release
- # download cannot block an unrelated restart, rollout, or database backup.
- if declare -F acquire_cloud_compose_lifecycle_lock >/dev/null 2>&1; then
- if ! acquire_cloud_compose_lifecycle_lock managed-runtime-package-update; then
- rm -rf -- "$stage_dir"
- return 1
- fi
- fi
-
- # Converge the complete managed plugin set, not just packages that are
- # still desired. Search each managed location so an incomplete old
- # generation fails closed instead of leaving a stale command discoverable.
- shopt -s nullglob
- candidate_paths=(
- "${PACKAGE_STATE_DIR}"/sitectl-*.version
- "${PACKAGE_STATE_DIR}"/sitectl-*.sha256
- "${BIN_DIR}"/sitectl-*
- "${PUBLISHED_BIN_DIR}"/sitectl-*
- )
- shopt -u nullglob
- for path in "${candidate_paths[@]}"; do
- name="$(basename -- "$path")"
- package="${name%.version}"
- package="${package%.sha256}"
- if [[ -n "${desired_packages[$package]:-}" || -n "${stale_seen[$package]:-}" ]]; then
- continue
- fi
- stale_seen["$package"]=true
- if ! validate_stale_managed_sitectl_package "$package"; then
- rm -rf -- "$stage_dir"
- if declare -F release_cloud_compose_lifecycle_lock >/dev/null 2>&1; then
- release_cloud_compose_lifecycle_lock
- fi
- return 1
- fi
- stale_packages+=("$package")
- done
- transaction_packages=("${packages[@]}" "${stale_packages[@]}")
-
- # Preserve the exact previous package/state set for rollback before the
- # short promotion phase. Missing markers distinguish absent old files from
- # zero-length/corrupt state that must also be restored exactly.
- mkdir -p "$backup_dir/published"
- for package in "${transaction_packages[@]}"; do
- if [[ -e "${BIN_DIR}/${package}" || -L "${BIN_DIR}/${package}" ]]; then
- cp -a -- "${BIN_DIR}/${package}" "${backup_dir}/bin/${package}" || promotion_failed=true
- else
- : >"${backup_dir}/bin/${package}.missing"
- fi
- for suffix in version sha256; do
- if [[ -e "${PACKAGE_STATE_DIR}/${package}.${suffix}" || -L "${PACKAGE_STATE_DIR}/${package}.${suffix}" ]]; then
- cp -a -- "${PACKAGE_STATE_DIR}/${package}.${suffix}" \
- "${backup_dir}/state/${package}.${suffix}" || promotion_failed=true
- else
- : >"${backup_dir}/state/${package}.${suffix}.missing"
- fi
- done
- if [[ -e "${PUBLISHED_BIN_DIR}/${package}" || -L "${PUBLISHED_BIN_DIR}/${package}" ]]; then
- cp -a -- "${PUBLISHED_BIN_DIR}/${package}" \
- "${backup_dir}/published/${package}" || promotion_failed=true
- else
- : >"${backup_dir}/published/${package}.missing"
- fi
- done
-
- if [[ "$promotion_failed" == "true" ]]; then
- log "could not preserve the current sitectl generation; package set was not changed"
- rm -rf -- "$stage_dir"
- if declare -F release_cloud_compose_lifecycle_lock >/dev/null 2>&1; then
- release_cloud_compose_lifecycle_lock
- fi
- return 1
- fi
-
- # Removing dropped plugins and promoting desired binaries are one locked
- # transaction. Rollback below restores both updated and removed packages.
- for package in "${stale_packages[@]}"; do
- if ! rm -f -- \
- "${BIN_DIR}/${package}" \
- "${PUBLISHED_BIN_DIR}/${package}" \
- "${PACKAGE_STATE_DIR}/${package}.version" \
- "${PACKAGE_STATE_DIR}/${package}.sha256"; then
- promotion_failed=true
- break
- fi
- log "removed stale managed package ${package}"
- done
- if [[ "$promotion_failed" != "true" ]]; then
- for package in "${packages[@]}"; do
- mv -f -- "${payload_dir}/${package}" "${BIN_DIR}/${package}" || {
- promotion_failed=true
- break
- }
- if ! write_installed_release_state "$package" "$(<"${tags_dir}/${package}.version")" "${BIN_DIR}/${package}" ||
- ! ln -sfn "${BIN_DIR}/${package}" "${PUBLISHED_BIN_DIR}/${package}"; then
- promotion_failed=true
- break
- fi
- done
- fi
-
- if [[ "$promotion_failed" == "true" ]]; then
- log "package-set promotion failed; restoring the previous sitectl generation"
- for package in "${transaction_packages[@]}"; do
- if [[ -e "${backup_dir}/bin/${package}.missing" ]]; then
- rm -f -- "${BIN_DIR}/${package}"
- elif [[ -e "${backup_dir}/bin/${package}" || -L "${backup_dir}/bin/${package}" ]]; then
- rm -f -- "${BIN_DIR}/${package}"
- cp -a -- "${backup_dir}/bin/${package}" "${BIN_DIR}/${package}" || true
- fi
- for suffix in version sha256; do
- if [[ -e "${backup_dir}/state/${package}.${suffix}.missing" ]]; then
- rm -f -- "${PACKAGE_STATE_DIR}/${package}.${suffix}"
- elif [[ -e "${backup_dir}/state/${package}.${suffix}" || -L "${backup_dir}/state/${package}.${suffix}" ]]; then
- rm -f -- "${PACKAGE_STATE_DIR}/${package}.${suffix}"
- cp -a -- "${backup_dir}/state/${package}.${suffix}" \
- "${PACKAGE_STATE_DIR}/${package}.${suffix}" || true
- fi
- done
- rm -f -- "${PUBLISHED_BIN_DIR}/${package}"
- if [[ -e "${backup_dir}/published/${package}" || -L "${backup_dir}/published/${package}" ]]; then
- cp -a -- "${backup_dir}/published/${package}" \
- "${PUBLISHED_BIN_DIR}/${package}" || true
- fi
- done
- rm -rf -- "$stage_dir"
- if declare -F release_cloud_compose_lifecycle_lock >/dev/null 2>&1; then
- release_cloud_compose_lifecycle_lock
- fi
- return 1
- fi
-
- rm -rf -- "$stage_dir"
- if declare -F release_cloud_compose_lifecycle_lock >/dev/null 2>&1; then
- release_cloud_compose_lifecycle_lock
- fi
-}
-
-validate_managed_artifact() {
- local name="$1" url="$2" sha="$3" path="$4"
- local mode="$5" owner="$6" group="$7" restart="$8"
-
- if [[ ! "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then
- log "invalid managed artifact name: ${name}"
- return 1
- fi
- if [[ ! "$url" =~ ^https://[^[:space:]]+$ ]]; then
- log "managed artifact ${name} must use an HTTPS URL without whitespace"
- return 1
- fi
- if [[ ! "$sha" =~ ^[0-9a-f]{64}$ ]]; then
- log "managed artifact ${name} has an invalid SHA-256 digest"
- return 1
- fi
- if [[ "$path" != /* || "$path" == "/" || "$path" == */ || "$path" == *//* ||
- "$path" =~ [[:cntrl:]] || "$path" =~ (^|/)\.\.?(/|$) ]]; then
- log "managed artifact ${name} has an unsafe target path"
- return 1
- fi
- if [[ ! "$mode" =~ ^0?[0-7]{3}$ ]]; then
- log "managed artifact ${name} has an invalid file mode"
- return 1
- fi
- if [[ ! "$owner" =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]] ||
- [[ ! "$group" =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]]; then
- log "managed artifact ${name} has an invalid owner or group"
- return 1
- fi
- if [[ -n "$restart" && ! "$restart" =~ ^[A-Za-z0-9][A-Za-z0-9_.@:-]*\.service$ ]]; then
- log "managed artifact ${name} has an invalid restart unit"
- return 1
- fi
-}
-
-managed_artifact_matches() {
- local path="$1" expected_sha="$2" actual_sha
-
- [[ ! -L "$path" && -f "$path" ]] || return 1
- actual_sha="$(sha256sum -- "$path")" || return 1
- [[ "${actual_sha%% *}" == "$expected_sha" ]]
-}
-
-managed_artifact_metadata_matches() {
- local path="$1" mode="$2" owner="$3" group="$4"
- local actual_mode actual_owner actual_group desired_mode
-
- [[ ! -L "$path" && -f "$path" ]] || return 1
- desired_mode="$(printf '%o' "$((8#$mode))")"
- actual_mode="$(stat -c %a -- "$path")" || return 1
- actual_owner="$(stat -c %U -- "$path")" || return 1
- actual_group="$(stat -c %G -- "$path")" || return 1
- [[ "$actual_mode" == "$desired_mode" && "$actual_owner" == "$owner" && "$actual_group" == "$group" ]]
-}
-
-preflight_managed_artifact_target() {
- local name="$1" path="$2" owner="$3" group="$4" target_dir
-
- target_dir="$(dirname -- "$path")"
- if ! managed_artifact_target_directory_safe "$target_dir"; then
- log "managed artifact ${name} target directory chain is missing, redirected, or writable by another account: ${target_dir}"
- return 1
- fi
- if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then
- log "managed artifact ${name} target is not a regular file: ${path}"
- return 1
- fi
- if ! getent passwd "$owner" >/dev/null; then
- log "managed artifact ${name} owner does not exist: ${owner}"
- return 1
- fi
- if ! getent group "$group" >/dev/null; then
- log "managed artifact ${name} group does not exist: ${group}"
- return 1
- fi
-}
-
-managed_artifact_target_directory_safe() {
- local target_dir="$1" resolved current owner_uid mode mode_value
- local installer_uid="$EUID"
-
- [[ "$target_dir" == /* && "$target_dir" != "/" && -d "$target_dir" && ! -L "$target_dir" ]] || return 1
- resolved="$(readlink -f -- "$target_dir")" || return 1
- [[ "$resolved" == "$target_dir" ]] || return 1
-
- current="$target_dir"
- while [[ "$current" != "/" ]]; do
- [[ -d "$current" && ! -L "$current" ]] || return 1
- owner_uid="$(stat -c %u -- "$current")" || return 1
- mode="$(stat -c %a -- "$current")" || return 1
- [[ "$mode" =~ ^[0-7]{3,4}$ ]] || return 1
- mode_value=$((8#$mode))
-
- # The production updater runs as root, so every directory component
- # must be root-controlled. A non-root contract runner may also trust
- # its own directories. Writable shared ancestors are safe only when
- # root-owned and sticky (for example /tmp).
- if [[ "$owner_uid" != "0" && "$owner_uid" != "$installer_uid" ]]; then
- return 1
- fi
- if (((mode_value & 8#022) != 0)); then
- if [[ "$owner_uid" != "0" ]] || (((mode_value & 8#1000) == 0)); then
- return 1
- fi
- fi
- current="$(dirname -- "$current")"
- done
-}
-
-write_artifact_state() {
- local state_file="$1" value="$2" state_tmp
-
- state_tmp="$(mktemp "${state_file}.tmp.XXXXXX")" || return 1
- printf '%s\n' "$value" >"$state_tmp"
- chmod 0640 "$state_tmp"
- mv -f -- "$state_tmp" "$state_file"
-}
-
-tab_separated_field_count() {
- local value="$1"
- local count=1
-
- while [[ "$value" == *$'\t'* ]]; do
- value="${value#*$'\t'}"
- ((count += 1))
- done
- printf '%s\n' "$count"
-}
-
-install_managed_artifacts() {
- local line name url sha path mode owner group restart index
- local state_file failed_state download_tmp install_tmp target_dir target_name backup
- local field_count
- local -a names=() urls=() shas=() paths=() modes=() owners=() groups=() restarts=()
- local -A seen_names=() seen_paths=()
-
- if [ ! -f "$ARTIFACT_MANIFEST" ]; then
- return 0
- fi
-
- # Validate and de-duplicate the complete manifest before touching the
- # network, an install target, or service state. A bad later row must not
- # leave earlier artifacts partially applied.
- while IFS= read -r line || [ -n "$line" ]; do
- if [ -z "$line" ] || [[ "$line" == \#* ]]; then
- continue
- fi
- field_count="$(tab_separated_field_count "$line")"
- if [[ "$field_count" != "8" ]]; then
- log "managed artifact manifest row must contain exactly eight tab-separated fields"
- return 1
- fi
- IFS=$'\t' read -r name url sha path mode owner group restart <<<"$line"
- validate_managed_artifact "$name" "$url" "$sha" "$path" "$mode" "$owner" "$group" "$restart" || return 1
- preflight_managed_artifact_target "$name" "$path" "$owner" "$group" || return 1
-
- if [[ -n "${seen_names[$name]:-}" ]]; then
- log "managed artifact manifest repeats name: $name"
- return 1
- fi
- if [[ -n "${seen_paths[$path]:-}" ]]; then
- log "managed artifact manifest repeats target path: $path"
- return 1
- fi
- seen_names["$name"]=1
- seen_paths["$path"]=1
- names+=("$name")
- urls+=("$url")
- shas+=("$sha")
- paths+=("$path")
- modes+=("$mode")
- owners+=("$owner")
- groups+=("$group")
- restarts+=("$restart")
- done < "$ARTIFACT_MANIFEST"
-
- for ((index = 0; index < ${#names[@]}; index++)); do
- name="${names[$index]}"
- url="${urls[$index]}"
- sha="${shas[$index]}"
- path="${paths[$index]}"
- mode="${modes[$index]}"
- owner="${owners[$index]}"
- group="${groups[$index]}"
- restart="${restarts[$index]}"
-
- state_file="${ARTIFACT_STATE_DIR}/${name}.sha256"
- failed_state="${ARTIFACT_STATE_DIR}/${name}.failed"
-
- if [ -f "$state_file" ] && [ "$(cat "$state_file")" = "$sha" ] &&
- managed_artifact_matches "$path" "$sha" &&
- managed_artifact_metadata_matches "$path" "$mode" "$owner" "$group"; then
- log "${name} artifact is already installed"
- continue
- fi
-
- target_dir="$(dirname -- "$path")"
- target_name="$(basename -- "$path")"
- download_tmp="$(mktemp "${TMP_DIR}/${name}.download.XXXXXX")"
- install_tmp="$(mktemp "${target_dir}/.${target_name}.cloud-compose.XXXXXX")" || {
- rm -f -- "$download_tmp"
- return 1
- }
- backup=""
- log "installing managed artifact ${name}"
- if ! retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
- --retry 5 --retry-all-errors --retry-delay 2 \
- --connect-timeout 10 --max-time 300 -o "$download_tmp" -- "$url" ||
- ! printf '%s %s\n' "$sha" "$download_tmp" | sha256sum -c - >/dev/null ||
- ! install -o "$owner" -g "$group" -m "$mode" "$download_tmp" "$install_tmp" ||
- ! managed_artifact_matches "$install_tmp" "$sha"; then
- rm -f -- "$download_tmp" "$install_tmp"
- log "managed artifact ${name} failed download or target verification"
- return 1
- fi
- rm -f -- "$download_tmp"
-
- if [[ -f "$path" ]]; then
- backup="$(mktemp "${target_dir}/.${target_name}.rollback.XXXXXX")" || {
- rm -f -- "$install_tmp"
- return 1
- }
- if ! cp -p -- "$path" "$backup"; then
- rm -f -- "$install_tmp" "$backup"
- log "managed artifact ${name} could not preserve the previous target"
- return 1
- fi
- fi
- if ! mv -f -- "$install_tmp" "$path" || ! managed_artifact_matches "$path" "$sha"; then
- rm -f -- "$install_tmp"
- if [[ -n "$backup" ]]; then
- mv -f -- "$backup" "$path" || \
- log "managed artifact ${name} also failed to restore its previous target"
- fi
- log "managed artifact ${name} failed atomic target verification"
- return 1
- fi
-
- if [ -n "$restart" ]; then
- if ! systemctl try-restart -- "$restart"; then
- if [[ -n "$backup" ]]; then
- if ! mv -f -- "$backup" "$path"; then
- log "managed artifact ${name} restart failed and its previous target could not be restored"
- write_artifact_state "$failed_state" "restart-and-rollback-failed ${sha} $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true
- return 1
- fi
- else
- rm -f -- "$path"
- fi
- write_artifact_state "$failed_state" "restart-failed ${sha} $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true
- systemctl try-restart -- "$restart" >/dev/null 2>&1 || true
- log "managed artifact ${name} restart failed; restored the previous target"
- return 1
- fi
- fi
- rm -f -- "$backup" "$failed_state"
- write_artifact_state "$state_file" "$sha"
- done
-}
-
-update_internal_services() {
- local services_dir="${LIBOPS_INTERNAL_SERVICES_DIR:-/mnt/disks/data/libops-internal}"
-
- case "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" in
- true | TRUE | 1 | yes | YES) ;;
- *) return 0 ;;
- esac
- case "${LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE:-false}" in
- true | TRUE | 1 | yes | YES) ;;
- *) return 0 ;;
- esac
-
- if [ ! -f "${services_dir}/docker-compose.yaml" ]; then
- return 0
- fi
- if ! command -v docker >/dev/null 2>&1; then
- log "docker is not available; skipping internal service update"
- return 0
- fi
-
- pushd "$services_dir" >/dev/null
- log "updating internal LibOps services"
- retry_until_success docker compose pull
- if systemctl is-active --quiet cloud-compose-internal-services.service; then
- systemctl restart cloud-compose-internal-services.service
- else
- log "internal LibOps services are inactive; leaving them stopped after pull"
- fi
- popd >/dev/null
-}
-
-run_install_tools() {
- if ! enabled; then
- log "managed runtime disabled"
- return 0
- fi
- install_sitectl_packages
- install_managed_artifacts
-}
-
-run_update() {
- if ! enabled; then
- log "managed runtime disabled"
- return 0
- fi
- install_sitectl_packages
- install_managed_artifacts
- update_internal_services
-}
-
-main() {
- local command="${1:-update}"
-
- if ((EUID != 0)); then
- log "managed runtime updates must run as root"
- return 1
- fi
-
- case "$command" in
- install-tools)
- with_lock run_install_tools
- ;;
- update)
- with_lock run_update
- ;;
- *)
- log "unknown command: ${command}"
- return 2
- ;;
- esac
-}
-
-if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
- main "$@"
-fi
diff --git a/rootfs/home/cloud-compose/lifecycle-entrypoint.sh b/rootfs/home/cloud-compose/lifecycle-entrypoint.sh
index 7516a14..68b4db1 100755
--- a/rootfs/home/cloud-compose/lifecycle-entrypoint.sh
+++ b/rootfs/home/cloud-compose/lifecycle-entrypoint.sh
@@ -13,4 +13,9 @@ esac
# shellcheck disable=SC1091
source /home/cloud-compose/profile.sh
-exec bash /home/cloud-compose/compose-dispatch.sh "$lifecycle"
+
+if [[ "$lifecycle" == "rollout" && -z "${CLOUD_COMPOSE_APP:-}" && -n "${ROLLOUT_ARG1:-}" ]]; then
+ export CLOUD_COMPOSE_APP="$ROLLOUT_ARG1"
+fi
+
+exec sitectl host apps lifecycle "$lifecycle"
diff --git a/rootfs/home/cloud-compose/mariadb-backup.sh b/rootfs/home/cloud-compose/mariadb-backup.sh
deleted file mode 100755
index c7513a7..0000000
--- a/rootfs/home/cloud-compose/mariadb-backup.sh
+++ /dev/null
@@ -1,132 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_mariadb_backup_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_mariadb_backup_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_mariadb_backup_source script_dir _cc_mariadb_backup_installed_home
-if [[ -n "$_cc_mariadb_backup_installed_home" &&
- ( "$_cc_mariadb_backup_installed_home" == "/" ||
- "$_cc_mariadb_backup_source" == "${_cc_mariadb_backup_installed_home%/}/"* ) ]]; then
- _cc_mariadb_backup_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_mariadb_backup_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_mariadb_backup_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_mariadb_backup_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_mariadb_backup_source" CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh "$script_dir/profile.sh"
-cloud_compose_bind_source_program \
- "$_cc_mariadb_backup_source" CLOUD_COMPOSE_COMPOSE_APPS_PATH \
- /home/cloud-compose/compose-apps.sh "$script_dir/compose-apps.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-compose_apps_path="$CLOUD_COMPOSE_COMPOSE_APPS_PATH"
-readonly profile_path compose_apps_path
-# shellcheck disable=SC1090
-source "$profile_path"
-# Reload the fixed resolver before sourcing the Compose library.
-# shellcheck disable=SC1090
-source "$_cc_mariadb_backup_checked_programs"
-# shellcheck disable=SC1090
-source "$compose_apps_path"
-
-BACKUP_ROOT="${MARIADB_BACKUP_ROOT:-/mnt/disks/data/backups/mariadb}"
-BACKUP_RETENTION_DAYS="${MARIADB_BACKUP_RETENTION_DAYS:-14}"
-today="$(date -u +%Y%m%d)"
-
-if [[ ! "$BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] || ((10#$BACKUP_RETENTION_DAYS < 1)); then
- echo "MARIADB_BACKUP_RETENTION_DAYS must be a positive integer" >&2
- exit 2
-fi
-
-acquire_cloud_compose_lifecycle_lock mariadb-backup
-
-# A backup timer must not turn on an application that an operator deliberately
-# stopped. Holding the lifecycle lock closes the race with a concurrent stop or
-# rollout before this state check.
-if ! systemctl is-active --quiet cloud-compose.service; then
- echo "Cloud Compose application service is inactive; skipping MariaDB backup"
- exit 0
-fi
-
-backup_app() (
- local app="$1"
- local backup_dir output staging_dir staging_output
-
- source_compose_app_env "$app"
- backup_dir="${BACKUP_ROOT}/${app}"
- if [[ -L "$BACKUP_ROOT" || ( -e "$BACKUP_ROOT" && ! -d "$BACKUP_ROOT" ) ]]; then
- echo "MariaDB backup root is unsafe: $BACKUP_ROOT" >&2
- return 1
- fi
- mkdir -p -- "$backup_dir"
- if [[ -L "$backup_dir" || ! -d "$backup_dir" ]]; then
- echo "MariaDB backup directory is unsafe: $backup_dir" >&2
- return 1
- fi
-
- # The app key is already constrained to a safe basename by the project
- # manifest, so it cannot change the backup destination.
- output="${backup_dir}/${today}-${app}.sql.gz"
- if [[ -e "$output" || -L "$output" ]]; then
- if [[ ! -L "$output" && -f "$output" && -s "$output" ]] && gzip -t -- "$output"; then
- echo "MariaDB backup already exists for ${app}: ${output}"
- return 0
- fi
- echo "Existing MariaDB backup is incomplete or unsafe: $output" >&2
- return 1
- fi
-
- staging_dir="$(mktemp -d "${backup_dir}/.${today}-${app}.staging.XXXXXX")" || return 1
- trap 'rm -rf -- "$staging_dir"' EXIT
- staging_output="${staging_dir}/backup.sql.gz"
-
- echo "Running MariaDB backup for ${app}"
- sitectl mariadb backup \
- --context "$SITECTL_CONTEXT_NAME" \
- --gzip \
- --output "$staging_output"
- if [[ -L "$staging_output" || ! -f "$staging_output" || ! -s "$staging_output" ]] ||
- ! gzip -t -- "$staging_output"; then
- echo "MariaDB backup did not produce a valid gzip artifact for ${app}" >&2
- return 1
- fi
- chmod 0640 "$staging_output" || return 1
- if [[ -e "$output" || -L "$output" ]]; then
- echo "MariaDB backup target appeared during staging: $output" >&2
- return 1
- fi
- mv -- "$staging_output" "$output" || return 1
- if [[ -L "$output" || ! -f "$output" || ! -s "$output" ]] || ! gzip -t -- "$output"; then
- echo "MariaDB backup was not published as a valid artifact for ${app}: ${output}" >&2
- return 1
- fi
- echo "MariaDB backup completed for ${app}: ${output}"
-)
-
-apps=()
-compose_app_names_array apps
-failures=0
-for app in "${apps[@]}"; do
- if ! backup_app "$app"; then
- echo "MariaDB backup failed for ${app}; continuing with remaining apps" >&2
- failures=$((failures + 1))
- fi
-done
-
-# Prune only regular, non-symlink dump files beneath each validated app
-# directory. This keeps a broken or high-churn app from filling the shared data
-# disk and taking down its bin-packed neighbors.
-for app in "${apps[@]}"; do
- backup_dir="${BACKUP_ROOT}/${app}"
- [[ -d "$backup_dir" && ! -L "$backup_dir" ]] || continue
- find "$backup_dir" -xdev -type f -name '*.sql.gz' -mtime "+${BACKUP_RETENTION_DAYS}" -delete
-done
-
-if ((failures > 0)); then
- echo "MariaDB backup completed with ${failures} failed app(s)" >&2
- exit 1
-fi
diff --git a/rootfs/home/cloud-compose/migrate-legacy-systemd-units.sh b/rootfs/home/cloud-compose/migrate-legacy-systemd-units.sh
deleted file mode 100755
index 8319c65..0000000
--- a/rootfs/home/cloud-compose/migrate-legacy-systemd-units.sh
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-unit_dir="${CLOUD_COMPOSE_SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
-
-remove_legacy_unit() {
- local unit="$1"
- shift
- local signature
- local unit_file="${unit_dir}/${unit}"
-
- # Never remove a host unit merely because its generic name collides. Only
- # migrate the exact unit shape previously shipped by Cloud Compose.
- if [[ -L "$unit_file" || ! -f "$unit_file" ]]; then
- return 0
- fi
- for signature in "$@"; do
- if ! grep -Fq -- "$signature" "$unit_file"; then
- return 0
- fi
- done
-
- systemctl disable --now "$unit" >/dev/null 2>&1 || true
- rm -f -- "$unit_file"
-}
-
-remove_legacy_unit cron.service \
- 'Description=cron' \
- 'ExecStart=/bin/bash /home/cloud-compose/cron.sh'
-remove_legacy_unit cron.timer \
- 'Description=cron' \
- 'OnBootSec=10m' \
- 'OnUnitInactiveSec=24h' \
- 'WakeSystem=true'
-remove_legacy_unit vault-agent.service \
- 'ConditionPathExists=/etc/vault-agent.d/cloud-compose.hcl' \
- 'ExecStart=/usr/local/bin/vault agent -config=/etc/vault-agent.d/cloud-compose.hcl'
-remove_legacy_unit internal-services.service \
- 'Description=Internal Services (Ping, Metrics, Power Management)' \
- 'WorkingDirectory=/mnt/disks/data/libops-internal'
-remove_legacy_unit internal-services.timer \
- 'Description=Delay Internal Services until 20m after initial boot' \
- 'OnBootSec=20min' \
- 'Unit=internal-services.service'
diff --git a/rootfs/home/cloud-compose/mount-overlays.sh b/rootfs/home/cloud-compose/mount-overlays.sh
deleted file mode 100755
index 8d96c14..0000000
--- a/rootfs/home/cloud-compose/mount-overlays.sh
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_mount_overlays_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_mount_overlays_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_mount_overlays_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_mount_overlays_source _cc_mount_overlays_dir _cc_mount_overlays_installed_home
-if [[ -n "$_cc_mount_overlays_installed_home" &&
- ( "$_cc_mount_overlays_installed_home" == "/" ||
- "$_cc_mount_overlays_source" == "${_cc_mount_overlays_installed_home%/}/"* ) ]]; then
- _cc_mount_overlays_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_mount_overlays_checked_programs="$_cc_mount_overlays_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_mount_overlays_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_mount_overlays_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_mount_overlays_source" \
- CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh \
- "$_cc_mount_overlays_dir/profile.sh"
-cloud_compose_bind_source_program \
- "$_cc_mount_overlays_source" \
- CLOUD_COMPOSE_OVERLAY_INIT_PATH \
- /home/cloud-compose/overlay-init.sh \
- "$_cc_mount_overlays_dir/overlay-init.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-overlay_script="$CLOUD_COMPOSE_OVERLAY_INIT_PATH"
-readonly profile_path overlay_script
-
-# shellcheck disable=SC1090
-source "$profile_path"
-
-if [[ -z "${DOCKER_VOLUME_OVERLAYS:-}" ]]; then
- exit 0
-fi
-if [[ "${CLOUD_COMPOSE_PROVIDER:-}" != "gcp" ]]; then
- echo "Docker volume overlays are supported only on GCP" >&2
- exit 1
-fi
-if ! mountpoint -q /mnt/disks/prod-readonly; then
- echo "The production overlay source is not mounted read-only" >&2
- exit 1
-fi
-
-read -r -a volumes <<<"$DOCKER_VOLUME_OVERLAYS"
-if [[ "${#volumes[@]}" -eq 0 ]]; then
- echo "DOCKER_VOLUME_OVERLAYS did not contain a volume name" >&2
- exit 1
-fi
-for volume in "${volumes[@]}"; do
- bash "$overlay_script" "$volume"
-done
diff --git a/rootfs/home/cloud-compose/offhost-backup.sh b/rootfs/home/cloud-compose/offhost-backup.sh
deleted file mode 100755
index 35a5989..0000000
--- a/rootfs/home/cloud-compose/offhost-backup.sh
+++ /dev/null
@@ -1,226 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_offhost_backup_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_offhost_backup_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_offhost_backup_source script_dir _cc_offhost_backup_installed_home
-if [[ -n "$_cc_offhost_backup_installed_home" &&
- ( "$_cc_offhost_backup_installed_home" == "/" ||
- "$_cc_offhost_backup_source" == "${_cc_offhost_backup_installed_home%/}/"* ) ]]; then
- _cc_offhost_backup_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_offhost_backup_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_offhost_backup_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_offhost_backup_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_offhost_backup_source" CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh "$script_dir/profile.sh"
-cloud_compose_bind_source_program \
- "$_cc_offhost_backup_source" CLOUD_COMPOSE_COMPOSE_APPS_PATH \
- /home/cloud-compose/compose-apps.sh "$script_dir/compose-apps.sh"
-cloud_compose_bind_source_program \
- "$_cc_offhost_backup_source" CLOUD_COMPOSE_DR_LIBRARY_PATH \
- /home/cloud-compose/disaster-recovery-lib.sh "$script_dir/disaster-recovery-lib.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-compose_apps_path="$CLOUD_COMPOSE_COMPOSE_APPS_PATH"
-dr_library_path="$CLOUD_COMPOSE_DR_LIBRARY_PATH"
-readonly profile_path compose_apps_path dr_library_path
-# shellcheck disable=SC1090
-source "$profile_path"
-# shellcheck disable=SC1090
-source "$compose_apps_path"
-# Restore the fixed binding functions before the final sourced dependency.
-# shellcheck disable=SC1090
-source "$_cc_offhost_backup_checked_programs"
-cloud_compose_bind_program_dir \
- "$_cc_offhost_backup_source" \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR \
- /etc/cloud-compose/jq \
- "$script_dir/../../etc/cloud-compose/jq" \
- offhost-validate-compose-config.jq \
- offhost-validate-bind-roots.jq \
- offhost-bind-sources.jq \
- offhost-build-application-coverage.jq \
- offhost-build-manifest.jq \
- offhost-validate-manifest.jq \
- offhost-manifest-app-digests.jq
-# shellcheck disable=SC1090
-source "$dr_library_path"
-
-if cloud_compose_dr_is_required; then
- :
-else
- status=$?
- if ((status == 1)); then
- echo "Off-host disaster recovery is not required; local MariaDB dumps remain same-disk recovery artifacts only"
- exit 0
- fi
- exit "$status"
-fi
-
-driver="$CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER"
-backup_root="${MARIADB_BACKUP_ROOT:-/mnt/disks/data/backups/mariadb}"
-data_root="${CLOUD_COMPOSE_DATA_ROOT:-/mnt/disks/data}"
-volumes_root="${CLOUD_COMPOSE_VOLUMES_ROOT:-/mnt/disks/volumes}"
-backup_date="$(date -u +%Y%m%d)"
-operation_id="${backup_date}-${CLOUD_COMPOSE_INSTANCE_NAME:-cloud-compose}"
-state_root="$CLOUD_COMPOSE_DR_STATE_ROOT"
-manifest_dir="$state_root/manifests"
-receipt_dir="$state_root/backup-receipts"
-staging_root="$state_root/staging"
-manifest_path="$manifest_dir/${operation_id}.json"
-receipt_path="$receipt_dir/${operation_id}.json"
-staging_dir=""
-
-cleanup() {
- if [[ -n "$staging_dir" ]]; then
- rm -rf -- "$staging_dir"
- fi
-}
-trap cleanup EXIT
-
-cloud_compose_dr_validate_driver "$driver"
-acquire_cloud_compose_lifecycle_lock offhost-backup
-
-for path in "$state_root" "$manifest_dir" "$receipt_dir" "$staging_root"; do
- cloud_compose_dr_prepare_state_directory "$path"
-done
-
-staging_dir="$(mktemp -d "$staging_root/.${operation_id}.XXXXXX")"
-chmod 0700 "$staging_dir"
-application_rows="$staging_dir/applications.jsonl"
-: >"$application_rows"
-chmod 0600 "$application_rows"
-
-apps=()
-compose_app_names_array apps
-if ((${#apps[@]} == 0)); then
- echo "Off-host backup requires at least one compose application" >&2
- exit 1
-fi
-
-for app in "${apps[@]}"; do
- source_compose_app_env "$app"
- project_dir="$DOCKER_COMPOSE_DIR"
- dump_path="${backup_root}/${app}/${backup_date}-${app}.sql.gz"
- staged_dump="$staging_dir/${app}.sql.gz"
- compose_config="$staging_dir/${app}.compose-config.json"
- application_row="$staging_dir/${app}.coverage.json"
-
- validate_compose_project_dir "$project_dir"
- if [[ -L "$project_dir" || ! -d "$project_dir" ]]; then
- echo "Application checkout is missing or unsafe for ${app}: $project_dir" >&2
- exit 1
- fi
- if [[ -L "$dump_path" || ! -f "$dump_path" || ! -s "$dump_path" ]]; then
- echo "Required local MariaDB recovery artifact is missing or unsafe for ${app}" >&2
- exit 1
- fi
- if [[ "$(stat -c '%h:%F' -- "$dump_path")" != "1:regular file" ]]; then
- echo "Required local MariaDB recovery artifact must have one link for ${app}" >&2
- exit 1
- fi
-
- # Copy through an already-open descriptor into the root-only handoff. This
- # prevents the privileged driver from following a later path replacement in
- # the application-owned local-backup directory.
- exec {dump_fd}<"$dump_path"
- dump_identity="$(stat -Lc '%d:%i' -- "/proc/${BASHPID}/fd/${dump_fd}")"
- if [[ "$(stat -c '%d:%i' -- "$dump_path")" != "$dump_identity" ]]; then
- echo "Local MariaDB recovery artifact changed while opening for ${app}" >&2
- exit 1
- fi
- cat <&"$dump_fd" >"$staged_dump"
- exec {dump_fd}<&-
- chmod 0400 "$staged_dump"
- if [[ ! -s "$staged_dump" ]] || ! gzip -t -- "$staged_dump"; then
- echo "Required local MariaDB recovery artifact is invalid for ${app}" >&2
- exit 1
- fi
- dump_sha256="$(cloud_compose_dr_sha256_file "$staged_dump")"
- dump_bytes="$(wc -c <"$staged_dump")"
-
- (
- cd -- "$project_dir"
- umask 077
- docker compose config --format json >"$compose_config"
- )
- if ! jq -e \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-validate-compose-config.jq" \
- "$compose_config" >/dev/null; then
- echo "Docker Compose returned unsafe or unsupported volume topology for ${app}" >&2
- exit 1
- fi
- if ! jq -e \
- --arg data_root "$data_root" \
- --arg volumes_root "$volumes_root" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-validate-bind-roots.jq" \
- "$compose_config" >/dev/null; then
- echo "Persistent bind topology escapes managed data roots for ${app}" >&2
- exit 1
- fi
- while IFS= read -r bind_source; do
- if [[ "$bind_source" =~ (^|/)\.\.?(/|$) ]]; then
- echo "Persistent bind topology contains a dot segment for ${app}" >&2
- exit 1
- fi
- done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-bind-sources.jq" "$compose_config")
-
- jq -cS \
- --arg app "$app" \
- --arg project_dir "$project_dir" \
- --arg dump_path "$staged_dump" \
- --arg dump_sha256 "$dump_sha256" \
- --argjson dump_bytes "$dump_bytes" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-build-application-coverage.jq" \
- "$compose_config" >"$application_row"
- cat "$application_row" >>"$application_rows"
- rm -f -- "$compose_config"
-done
-
-staged_manifest="$staging_dir/manifest.json"
-jq -cS -s \
- --arg operation_id "$operation_id" \
- --arg backup_date "$backup_date" \
- --arg provider "${CLOUD_COMPOSE_PROVIDER:-unknown}" \
- --arg instance "${CLOUD_COMPOSE_INSTANCE_NAME:-cloud-compose}" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-build-manifest.jq" \
- "$application_rows" >"$staged_manifest"
-chmod 0400 "$staged_manifest"
-
-if ! jq -e \
- --argjson app_count "${#apps[@]}" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-validate-manifest.jq" \
- "$staged_manifest" >/dev/null; then
- echo "Generated off-host coverage manifest is incomplete" >&2
- exit 1
-fi
-while IFS=$'\t' read -r manifest_app manifest_sha; do
- if [[ ! "$manifest_app" =~ ^[a-z][a-z0-9-]*$ || ! "$manifest_sha" =~ ^[0-9a-f]{64}$ ]]; then
- echo "Generated off-host coverage manifest contains an unsafe application name or digest" >&2
- exit 1
- fi
-done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/offhost-manifest-app-digests.jq" "$staged_manifest")
-
-manifest_sha256="$(cloud_compose_dr_sha256_file "$staged_manifest")"
-staged_receipt="$staging_dir/receipt.json"
-cloud_compose_dr_run_driver "$driver" backup \
- --manifest "$staged_manifest" \
- --manifest-sha256 "$manifest_sha256" \
- --operation-id "$operation_id" \
- --receipt "$staged_receipt"
-post_driver_manifest_sha256="$(cloud_compose_dr_sha256_file "$staged_manifest")"
-if [[ "$post_driver_manifest_sha256" != "$manifest_sha256" ]]; then
- echo "Off-host backup driver modified the immutable coverage manifest" >&2
- exit 1
-fi
-cloud_compose_dr_validate_backup_receipt "$staged_receipt" "$operation_id" "$manifest_sha256"
-
-chmod 0640 "$staged_manifest" "$staged_receipt"
-mv -f -- "$staged_manifest" "$manifest_path"
-mv -f -- "$staged_receipt" "$receipt_path"
-echo "Encrypted off-host disaster-recovery coverage proven for ${#apps[@]} application(s): $receipt_path"
diff --git a/rootfs/home/cloud-compose/overlay-init.sh b/rootfs/home/cloud-compose/overlay-init.sh
deleted file mode 100755
index 79033e0..0000000
--- a/rootfs/home/cloud-compose/overlay-init.sh
+++ /dev/null
@@ -1,172 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-log() {
- printf '[overlay-init] %s\n' "$*" >&2
-}
-
-usage() {
- echo "Usage: $0 VOLUME [true|false]" >&2
-}
-
-validate_root() {
- local root="$1" label="$2" canonical
-
- if [[ "$root" != /* || "$root" == "/" || "$root" =~ [[:space:]] ||
- "$root" == *","* || "$root" == *":"* || "$root" == *"\\"* ||
- "$root" =~ (^|/)\.\.?(/|$) ||
- -L "$root" || ! -d "$root" ]]; then
- log "$label root is missing or unsafe: $root"
- return 1
- fi
- canonical="$(readlink -f -- "$root")" || return 1
- if [[ "$canonical" != "$root" ]]; then
- log "$label root contains a symbolic-link traversal: $root"
- return 1
- fi
-}
-
-ensure_directory() {
- local path="$1" boundary="$2" canonical
-
- if [[ "$path" != "$boundary/"* || -L "$path" || ( -e "$path" && ! -d "$path" ) ]]; then
- log "Overlay directory is outside its boundary or unsafe: $path"
- return 1
- fi
- # Resolve the would-be path before creating it. `install -d` follows a
- # symbolic-link parent, so checking only the final directory would detect
- # traversal after already creating attacker-selected directories.
- canonical="$(realpath -m -- "$path")" || return 1
- if [[ "$canonical" != "$path" ]]; then
- log "Overlay directory contains a symbolic-link traversal: $path"
- return 1
- fi
- install -d -m 0755 -- "$path"
- if [[ -L "$path" || ! -d "$path" ]]; then
- log "Overlay directory changed during preparation: $path"
- return 1
- fi
- canonical="$(readlink -f -- "$path")" || return 1
- if [[ "$canonical" != "$path" ]]; then
- log "Overlay directory contains a symbolic-link traversal: $path"
- return 1
- fi
-}
-
-verify_overlay_mount() {
- local target="$1" lower="$2" upper="$3" work="$4"
- local filesystem options
-
- if ! mountpoint -q -- "$target"; then
- return 1
- fi
- filesystem="$(findmnt -n -o FSTYPE --target "$target")" || return 2
- options="$(findmnt -n -o OPTIONS --target "$target")" || return 2
- if [[ "$filesystem" != "overlay" ||
- ",$options," != *",lowerdir=$lower,"* ||
- ",$options," != *",upperdir=$upper,"* ||
- ",$options," != *",workdir=$work,"* ]]; then
- log "$target is mounted, but not from the expected overlay directories"
- return 2
- fi
-}
-
-clear_overlay_state() {
- local path
-
- for path in "$@"; do
- if [[ -L "$path" || ! -d "$path" ]]; then
- log "Refusing to clear unsafe overlay state directory: $path"
- return 1
- fi
- find "$path" -xdev -mindepth 1 -delete
- if [[ -n "$(find "$path" -xdev -mindepth 1 -print -quit)" ]]; then
- log "Overlay state directory could not be emptied: $path"
- return 1
- fi
- done
-}
-
-main() {
- local volume reset_flag perform_reset=false
- local volumes_root lower_root target lower_dir upper_dir work_dir
- local mount_status=0
-
- if [[ $# -lt 1 || $# -gt 2 ]]; then
- usage
- return 2
- fi
- volume="$1"
- reset_flag="${2:-false}"
- if [[ ! "$volume" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$ ]]; then
- log "Volume must be a safe Docker volume name"
- return 2
- fi
- case "${reset_flag,,}" in
- true | 1 | yes) perform_reset=true ;;
- false | 0 | no | "") perform_reset=false ;;
- *)
- log "Reset flag must be true or false"
- return 2
- ;;
- esac
-
- volumes_root="${CLOUD_COMPOSE_VOLUMES_ROOT:-/mnt/disks/volumes}"
- lower_root="${CLOUD_COMPOSE_OVERLAY_LOWER_ROOT:-/mnt/disks/prod-readonly}"
- validate_root "$volumes_root" "Volumes" || return 1
- validate_root "$lower_root" "Read-only lower" || return 1
-
- target="$volumes_root/$volume"
- lower_dir="$lower_root/$volume"
- upper_dir="$volumes_root/.overlay/$volume/upper"
- work_dir="$volumes_root/.overlay/$volume/work"
-
- ensure_directory "$target" "$volumes_root" || return 1
- ensure_directory "$upper_dir" "$volumes_root" || return 1
- ensure_directory "$work_dir" "$volumes_root" || return 1
- if [[ -L "$lower_dir" || ! -d "$lower_dir" || "$(readlink -f -- "$lower_dir")" != "$lower_dir" ]]; then
- log "Read-only lower directory is missing or unsafe: $lower_dir"
- return 1
- fi
- if [[ "$(stat -c %d -- "$upper_dir")" != "$(stat -c %d -- "$work_dir")" ]]; then
- log "Overlay upper and work directories must be on the same filesystem"
- return 1
- fi
-
- verify_overlay_mount "$target" "$lower_dir" "$upper_dir" "$work_dir" || mount_status=$?
- case "$mount_status" in
- 0)
- if [[ "$perform_reset" != "true" ]]; then
- log "Volume $volume is already mounted from the expected overlay"
- return 0
- fi
- log "Unmounting overlay volume $volume for reset"
- umount -- "$target"
- ;;
- 1)
- ;;
- *)
- return 1
- ;;
- esac
-
- if [[ "$perform_reset" == "true" ]]; then
- log "Clearing writable overlay state for $volume"
- clear_overlay_state "$upper_dir" "$work_dir" || return 1
- fi
-
- mount -t overlay overlay \
- -o "lowerdir=$lower_dir,upperdir=$upper_dir,workdir=$work_dir" \
- "$target"
- if ! verify_overlay_mount "$target" "$lower_dir" "$upper_dir" "$work_dir"; then
- umount -- "$target" >/dev/null 2>&1 || true
- log "Overlay mount verification failed for $volume"
- return 1
- fi
- log "Volume $volume is active"
-}
-
-if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
- main "$@"
-fi
diff --git a/rootfs/home/cloud-compose/persist-filesystems.sh b/rootfs/home/cloud-compose/persist-filesystems.sh
deleted file mode 100755
index fab4bf7..0000000
--- a/rootfs/home/cloud-compose/persist-filesystems.sh
+++ /dev/null
@@ -1,251 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_persist_filesystems_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_persist_filesystems_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_persist_filesystems_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_persist_filesystems_source _cc_persist_filesystems_dir _cc_persist_filesystems_installed_home
-if [[ -n "$_cc_persist_filesystems_installed_home" &&
- ( "$_cc_persist_filesystems_installed_home" == "/" ||
- "$_cc_persist_filesystems_source" == "${_cc_persist_filesystems_installed_home%/}/"* ) ]]; then
- # shellcheck disable=SC1091
- source /etc/cloud-compose/libexec/checked-programs.bash
- cloud_compose_bind_program \
- "$_cc_persist_filesystems_source" \
- CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM \
- /etc/cloud-compose/awk/reconcile-fstab.awk \
- /etc/cloud-compose/awk/reconcile-fstab.awk
- fstab_reconcile_program="$CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM"
-else
- # Early boot executes a verified root-owned copy from /run and passes its
- # separately verified awk program explicitly. Repository contracts use the
- # same override without weakening the installed /home path.
- fstab_reconcile_program="${CLOUD_COMPOSE_FSTAB_RECONCILE_PROGRAM:-$_cc_persist_filesystems_dir/../../etc/cloud-compose/awk/reconcile-fstab.awk}"
-fi
-readonly fstab_reconcile_program
-
-log() {
- printf '[filesystem-persist] %s\n' "$*" >&2
-}
-
-usage() {
- echo "Usage: $0 DATA_DEVICE VOLUMES_DEVICE [READ_ONLY_OVERLAY_DEVICE]" >&2
-}
-
-validate_device_path() {
- local value="$1"
-
- [[ "$value" =~ ^/dev/[A-Za-z0-9._/+:-]+$ ]] &&
- [[ "$value" != *$'\n'* ]] && [[ "$value" != *$'\r'* ]]
-}
-
-digitalocean_automatic_mount() {
- local device_path="$1" volume_name
-
- case "$device_path" in
- /dev/disk/by-id/scsi-0DO_Volume_*)
- volume_name="${device_path#/dev/disk/by-id/scsi-0DO_Volume_}"
- ;;
- *)
- return 1
- ;;
- esac
- if [[ ! "$volume_name" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$ ]]; then
- return 2
- fi
- printf '/mnt/%s\n' "${volume_name//-/_}"
-}
-
-remove_digitalocean_mount_unit() {
- local device_path="$1" action="${2:-remove}" provider_mount unit_name systemd_dir unit_path
- local link resolved_link
- local -a what_values where_values links
-
- provider_mount="$(digitalocean_automatic_mount "$device_path")" || return 0
- unit_name="mnt-${provider_mount#/mnt/}.mount"
- systemd_dir="${CLOUD_COMPOSE_SYSTEMD_DIR:-/etc/systemd/system}"
- if [[ "$systemd_dir" != /* || "$systemd_dir" == "/" || -L "$systemd_dir" ||
- "$systemd_dir" == *$'\n'* || "$systemd_dir" == *$'\r'* ]]; then
- log "Refusing unsafe systemd unit directory: $systemd_dir"
- return 1
- fi
- if [[ ! -d "$systemd_dir" ]]; then
- return 0
- fi
-
- unit_path="$systemd_dir/$unit_name"
- links=()
- shopt -s nullglob
- links=("$systemd_dir"/*.wants/"$unit_name")
- shopt -u nullglob
-
- if [[ ! -e "$unit_path" && ! -L "$unit_path" ]]; then
- if ((${#links[@]} > 0)); then
- log "DigitalOcean mount unit link exists without its expected unit: $unit_path"
- return 1
- fi
- return 0
- fi
- if [[ -L "$unit_path" || ! -f "$unit_path" ]]; then
- log "DigitalOcean mount unit is not a regular provider-owned file: $unit_path"
- return 1
- fi
-
- mapfile -t what_values < <(sed -n 's/^[[:space:]]*What=//p' "$unit_path")
- mapfile -t where_values < <(sed -n 's/^[[:space:]]*Where=//p' "$unit_path")
- if ((${#what_values[@]} != 1 || ${#where_values[@]} != 1)) ||
- [[ "${what_values[0]}" != "$device_path" || "${where_values[0]}" != "$provider_mount" ]]; then
- log "Refusing to remove unexpected DigitalOcean mount unit contents: $unit_path"
- return 1
- fi
-
- for link in "${links[@]}"; do
- if [[ -L "$(dirname -- "$link")" || ! -d "$(dirname -- "$link")" || ! -L "$link" ]]; then
- log "DigitalOcean mount unit wants entry is not a symlink: $link"
- return 1
- fi
- resolved_link="$(readlink -f -- "$link" 2>/dev/null || true)"
- if [[ "$resolved_link" != "$unit_path" ]]; then
- log "DigitalOcean mount unit wants link has an unexpected target: $link"
- return 1
- fi
- done
-
- if [[ "$action" == "validate" ]]; then
- return 0
- fi
-
- if command -v systemctl >/dev/null 2>&1; then
- if ! systemctl disable --now -- "$unit_name"; then
- log "Could not disable superseded DigitalOcean mount unit: $unit_name"
- return 1
- fi
- fi
-
- for link in "${links[@]}"; do
- rm -f -- "$link"
- done
- rm -f -- "$unit_path"
- log "Removed superseded provider mount unit $unit_name"
-}
-
-main() {
- local data_device volumes_device overlay_device fstab_path lock_path tmp status
- local data_provider_mount="" volumes_provider_mount=""
- local begin_marker="# BEGIN cloud-compose persistent mounts"
- local end_marker="# END cloud-compose persistent mounts"
-
- if [[ $# -lt 2 || $# -gt 3 ]]; then
- usage
- return 2
- fi
-
- data_device="$1"
- volumes_device="$2"
- overlay_device="${3:-}"
- if ! validate_device_path "$data_device" || ! validate_device_path "$volumes_device"; then
- log "Device paths must be safe absolute /dev paths"
- return 2
- fi
- if [[ "$data_device" == "$volumes_device" ]]; then
- log "Data and Docker-volume devices must be distinct"
- return 2
- fi
- if [[ -n "$overlay_device" ]]; then
- if ! validate_device_path "$overlay_device"; then
- log "Read-only overlay device must be a safe absolute /dev path"
- return 2
- fi
- if [[ "$overlay_device" == "$data_device" || "$overlay_device" == "$volumes_device" ]]; then
- log "Read-only overlay device must be distinct from writable devices"
- return 2
- fi
- fi
- if [[ "$data_device" == /dev/disk/by-id/scsi-0DO_Volume_* ]]; then
- data_provider_mount="$(digitalocean_automatic_mount "$data_device")" || {
- log "Invalid DigitalOcean data-volume device path: $data_device"
- return 2
- }
- fi
- if [[ "$volumes_device" == /dev/disk/by-id/scsi-0DO_Volume_* ]]; then
- volumes_provider_mount="$(digitalocean_automatic_mount "$volumes_device")" || {
- log "Invalid DigitalOcean Docker-volume device path: $volumes_device"
- return 2
- }
- fi
-
- fstab_path="${CLOUD_COMPOSE_FSTAB_PATH:-/etc/fstab}"
- lock_path="${CLOUD_COMPOSE_FSTAB_LOCK_PATH:-/run/cloud-compose-fstab.lock}"
- if [[ "$fstab_path" != /* || "$fstab_path" == "/" || -L "$fstab_path" ||
- "$fstab_path" == *$'\n'* || "$fstab_path" == *$'\r'* ]]; then
- log "Refusing unsafe fstab path: $fstab_path"
- return 2
- fi
- if [[ "$lock_path" != /* || "$lock_path" == "/" || "$lock_path" == *$'\n'* ||
- "$lock_path" == *$'\r'* ]]; then
- log "Refusing unsafe fstab lock path: $lock_path"
- return 2
- fi
- if [[ -e "$fstab_path" && ! -f "$fstab_path" ]]; then
- log "fstab path is not a regular file: $fstab_path"
- return 2
- fi
-
- install -d -m 0755 -- "$(dirname -- "$fstab_path")" "$(dirname -- "$lock_path")"
- touch "$fstab_path"
- exec 9>"$lock_path"
- if command -v flock >/dev/null 2>&1; then
- flock -x 9
- fi
-
- # Validate provider-owned persistence before mutating fstab. Validation is
- # repeated during removal to keep an unexpected local unit fail-closed.
- remove_digitalocean_mount_unit "$data_device" validate
- remove_digitalocean_mount_unit "$volumes_device" validate
-
- tmp="$(mktemp "${fstab_path}.tmp.XXXXXX")"
- trap 'rm -f -- "$tmp"' EXIT
- awk -v begin="$begin_marker" -v end="$end_marker" \
- -v data_device="$data_device" -v data_provider_mount="$data_provider_mount" \
- -v volumes_device="$volumes_device" -v volumes_provider_mount="$volumes_provider_mount" \
- -f "$fstab_reconcile_program" "$fstab_path" >"$tmp" || {
- status=$?
- if [[ "$status" -eq 42 ]]; then
- log "fstab contains an unterminated managed block or an unmanaged cloud-compose mount target"
- else
- log "Could not inspect $fstab_path"
- fi
- return 1
- }
-
- {
- printf '%s\n' "$begin_marker"
- printf '%s\t/mnt/disks/data\text4\tdefaults,nofail,x-systemd.device-timeout=120s\t0\t2\n' "$data_device"
- printf '%s\t/mnt/disks/volumes\text4\tdefaults,nofail,x-systemd.device-timeout=120s\t0\t2\n' "$volumes_device"
- printf '/mnt/disks/volumes\t/mnt/disks/data/docker/volumes\tnone\tbind,nofail,x-systemd.requires=/mnt/disks/volumes\t0\t0\n'
- if [[ -n "$overlay_device" ]]; then
- printf '%s\t/mnt/disks/prod-readonly\text4\tro,nofail,x-systemd.device-timeout=120s\t0\t2\n' "$overlay_device"
- fi
- printf '%s\n' "$end_marker"
- } >>"$tmp"
- # Retire the validated provider units before committing the replacement
- # fstab. If unit cleanup fails, the original provider persistence remains
- # intact and the prepared fstab is discarded by the EXIT trap.
- remove_digitalocean_mount_unit "$data_device"
- remove_digitalocean_mount_unit "$volumes_device"
-
- chmod --reference="$fstab_path" "$tmp"
- chown --reference="$fstab_path" "$tmp"
- mv -f -- "$tmp" "$fstab_path"
- trap - EXIT
-
- if command -v systemctl >/dev/null 2>&1; then
- systemctl daemon-reload
- fi
- log "Persistent data, Docker-volume, and bind mounts are recorded in $fstab_path"
-}
-
-if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
- main "$@"
-fi
diff --git a/rootfs/home/cloud-compose/prepare-app-sources.sh b/rootfs/home/cloud-compose/prepare-app-sources.sh
deleted file mode 100755
index 3d6b15d..0000000
--- a/rootfs/home/cloud-compose/prepare-app-sources.sh
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_prepare_sources_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_prepare_sources_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_prepare_sources_source script_dir _cc_prepare_sources_installed_home
-if [[ -n "$_cc_prepare_sources_installed_home" &&
- ( "$_cc_prepare_sources_installed_home" == "/" ||
- "$_cc_prepare_sources_source" == "${_cc_prepare_sources_installed_home%/}/"* ) ]]; then
- _cc_prepare_sources_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_prepare_sources_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_prepare_sources_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_prepare_sources_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_prepare_sources_source" CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh "$script_dir/profile.sh"
-cloud_compose_bind_source_program \
- "$_cc_prepare_sources_source" CLOUD_COMPOSE_COMPOSE_APPS_PATH \
- /home/cloud-compose/compose-apps.sh "$script_dir/compose-apps.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-compose_apps_path="$CLOUD_COMPOSE_COMPOSE_APPS_PATH"
-readonly profile_path compose_apps_path
-cd "$script_dir"
-# shellcheck disable=SC1090
-source "$profile_path"
-# Reload the fixed resolver before sourcing the Compose library.
-# shellcheck disable=SC1090
-source "$_cc_prepare_sources_checked_programs"
-# shellcheck disable=SC1090
-source "$compose_apps_path"
-
-apps=()
-compose_app_names_array apps
-for app in "${apps[@]}"; do
- # This phase performs source validation and checkout only. Application
- # scaffold/plugin lifecycle commands must wait for Vault readiness.
- clone_or_update_compose_app "$app"
-done
diff --git a/rootfs/home/cloud-compose/prepare-filesystem.sh b/rootfs/home/cloud-compose/prepare-filesystem.sh
deleted file mode 100755
index d5f4b75..0000000
--- a/rootfs/home/cloud-compose/prepare-filesystem.sh
+++ /dev/null
@@ -1,482 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-fresh_filesystem_pending_label="cc-fresh-pending"
-
-log() {
- printf '[filesystem-prep] %s\n' "$*" >&2
-}
-
-publish_fresh_filesystem_marker() {
- local mount_path="$1"
- local expected_identity="$2"
- local marker_dir="$mount_path/.cloud-compose"
- local marker="$marker_dir/fresh-filesystem"
- local marker_dir_identity marker_identity marker_payload marker_root_identity
- local marker_size unexpected
-
- case "$expected_identity" in
- fresh) ;;
- v1:gcp-disk-id:*)
- if [[ ! "$expected_identity" =~ ^v1:gcp-disk-id:[0-9]{1,32}$ ]]; then
- log "Fresh-filesystem identity is unsafe"
- return 2
- fi
- ;;
- *)
- log "Fresh-filesystem identity is unsafe"
- return 2
- ;;
- esac
-
- if [[ -e "$marker" || -L "$marker" ]]; then
- if [[ -L "$marker_dir" || ! -d "$marker_dir" ||
- -L "$marker" || ! -f "$marker" ]]; then
- log "Fresh-filesystem marker path is unsafe: $marker"
- return 1
- fi
- marker_dir_identity="$(stat -c '%u:%g:%a' -- "$marker_dir")" || return 1
- marker_identity="$(stat -c '%u:%g:%a:%h' -- "$marker")" || return 1
- if [[ "$marker_dir_identity" != "0:0:700" ||
- "$marker_identity" != "0:0:600:1" ]]; then
- log "Existing fresh-filesystem marker is not root-owned and private: $marker"
- return 1
- fi
- marker_size="$(stat -c '%s' -- "$marker")" || return 1
- marker_payload=""
- if [[ "$marker_size" != "$((${#expected_identity} + 1))" ]] ||
- ! IFS= read -r marker_payload <"$marker" ||
- [[ "$marker_payload" != "$expected_identity" ]]; then
- log "Existing fresh-filesystem marker does not match this disk incarnation"
- return 1
- fi
- return 0
- fi
-
- # Recover the only safe residue possible if the host stopped after making
- # the private marker directory but before creating its marker.
- if [[ -e "$marker_dir" || -L "$marker_dir" ]]; then
- if [[ -L "$marker_dir" || ! -d "$marker_dir" ]]; then
- log "Fresh-filesystem marker directory is unsafe: $marker_dir"
- return 1
- fi
- marker_dir_identity="$(stat -c '%u:%g:%a' -- "$marker_dir")" || return 1
- unexpected="$(find "$marker_dir" -mindepth 1 -maxdepth 1 -print -quit)" || return 1
- if [[ "$marker_dir_identity" != "0:0:700" || -n "$unexpected" ]]; then
- log "Fresh-filesystem marker directory is not an empty root-owned staging residue: $marker_dir"
- return 1
- fi
- rmdir -- "$marker_dir"
- sync
- fi
-
- marker_root_identity="$(stat -c '%u:%g:%a' -- "$mount_path")" || return 1
- if [[ "$marker_root_identity" != "0:0:755" ]]; then
- log "Fresh-filesystem root is not pristine: $mount_path ($marker_root_identity)"
- return 1
- fi
- unexpected="$(find "$mount_path" -mindepth 1 -maxdepth 1 \
- ! -name lost+found -print -quit)" || return 1
- if [[ -n "$unexpected" ]]; then
- log "Fresh-filesystem root contains unexpected data: $unexpected"
- return 1
- fi
- if [[ -e "$mount_path/lost+found" || -L "$mount_path/lost+found" ]]; then
- if [[ -L "$mount_path/lost+found" || ! -d "$mount_path/lost+found" ]]; then
- log "Fresh-filesystem lost+found path is unsafe"
- return 1
- fi
- marker_dir_identity="$(stat -c '%u:%g:%a' -- "$mount_path/lost+found")" || return 1
- unexpected="$(find "$mount_path/lost+found" -mindepth 1 -maxdepth 1 -print -quit)" || return 1
- if [[ "$marker_dir_identity" != "0:0:700" || -n "$unexpected" ]]; then
- log "Fresh-filesystem lost+found is not an empty root-owned directory"
- return 1
- fi
- fi
-
- if ! (umask 077 && mkdir -- "$marker_dir") ||
- ! (umask 077 && printf '%s\n' "$expected_identity" >"$marker"); then
- log "Fresh-filesystem marker path is unsafe: $marker"
- return 1
- fi
- marker_dir_identity="$(stat -c '%u:%g:%a' -- "$marker_dir")" || return 1
- marker_identity="$(stat -c '%u:%g:%a:%h' -- "$marker")" || return 1
- if [[ "$marker_dir_identity" != "0:0:700" ||
- "$marker_identity" != "0:0:600:1" ]]; then
- log "Published fresh-filesystem marker is not root-owned and private: $marker"
- return 1
- fi
-}
-
-usage() {
- echo "Usage: $0 DEVICE_PATH MOUNT_PATH [--publish-fresh-marker [IDENTITY]]" >&2
-}
-
-is_block_device() {
- [[ -b "$1" ]]
-}
-
-digitalocean_automatic_mount() {
- local device_path="$1" volume_name
-
- case "$device_path" in
- /dev/disk/by-id/scsi-0DO_Volume_*)
- volume_name="${device_path#/dev/disk/by-id/scsi-0DO_Volume_}"
- ;;
- *)
- return 1
- ;;
- esac
-
- if [[ ! "$volume_name" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$ ]]; then
- return 2
- fi
-
- # DigitalOcean's automatic format-and-mount convention replaces hyphens
- # in the volume name with underscores beneath /mnt.
- printf '/mnt/%s\n' "${volume_name//-/_}"
-}
-
-settle_digitalocean_automount() {
- local device_path="$1" settle_seconds="$2"
-
- # DigitalOcean's legacy automatic-format path is driven by udev and a
- # generated systemd mount unit. Device-node appearance alone does not mean
- # that workflow is finished, so wait for udev before inspecting or mutating
- # the filesystem.
- if command -v udevadm >/dev/null 2>&1; then
- if ! udevadm settle --timeout="$settle_seconds"; then
- log "DigitalOcean automount udev processing did not settle for $device_path"
- return 1
- fi
- fi
-}
-
-start_digitalocean_automount() {
- local device_path="$1" provider_mount="$2"
- local systemd_dir unit_name unit_path
- local -a what_values where_values
-
- systemd_dir="${CLOUD_COMPOSE_SYSTEMD_DIR:-/etc/systemd/system}"
- if [[ "$systemd_dir" != /* || "$systemd_dir" == "/" || -L "$systemd_dir" ||
- "$systemd_dir" == *$'\n'* || "$systemd_dir" == *$'\r'* ]]; then
- log "Refusing unsafe systemd unit directory: $systemd_dir"
- return 1
- fi
- if [[ ! -d "$systemd_dir" ]]; then
- return 0
- fi
-
- unit_name="mnt-$(basename -- "$provider_mount").mount"
- unit_path="$systemd_dir/$unit_name"
- if [[ ! -e "$unit_path" && ! -L "$unit_path" ]]; then
- return 0
- fi
- if [[ -L "$unit_path" || ! -f "$unit_path" ]]; then
- log "DigitalOcean mount unit is not a regular provider-owned file: $unit_path"
- return 1
- fi
-
- mapfile -t what_values < <(sed -n 's/^[[:space:]]*What=//p' "$unit_path")
- mapfile -t where_values < <(sed -n 's/^[[:space:]]*Where=//p' "$unit_path")
- if ((${#what_values[@]} != 1 || ${#where_values[@]} != 1)) ||
- [[ "${what_values[0]}" != "$device_path" || "${where_values[0]}" != "$provider_mount" ]]; then
- log "Refusing unexpected DigitalOcean mount unit contents: $unit_path"
- return 1
- fi
-
- if ! command -v systemctl >/dev/null 2>&1; then
- log "A DigitalOcean mount unit exists but systemctl is unavailable: $unit_path"
- return 1
- fi
- systemctl daemon-reload
- # systemctl start waits for the mount job unless explicitly told not to.
- # Completing it here closes the race between udev device discovery and an
- # offline fsck or format by cloud-compose.
- if ! systemctl start -- "$unit_name"; then
- log "DigitalOcean automount unit failed: $unit_name"
- return 1
- fi
-}
-
-device_mount_targets() {
- local device="$1" status=0 output
-
- output="$(findmnt -rn -o TARGET --source "$device" 2>/dev/null)" || status=$?
- case "$status" in
- 0 | 1) printf '%s' "$output" ;;
- *)
- log "Could not inspect existing mounts for $device"
- return 1
- ;;
- esac
-}
-
-require_device_unmounted() {
- local device="$1" operation="$2" targets
-
- targets="$(device_mount_targets "$device")" || return 1
- if [[ -n "$targets" ]]; then
- log "$device became mounted before $operation; refusing offline mutation: ${targets//$'\n'/ }"
- return 1
- fi
-}
-
-main() {
- local device_path mount_path device filesystem_type mounted_source resolved_mounted_source
- local already_mounted blkid_status fsck_status wait_seconds sleep_seconds settle_seconds
- local device_mounts_output provider_mount="" moved_source resolved_moved_source
- local target_contents filesystem_label
- local publish_fresh_marker=false fresh_marker_identity="" fresh_marker_pending=false
- local -a device_mounts
-
- if [[ $# -lt 2 || $# -gt 4 ]]; then
- usage
- return 2
- fi
- if [[ $# -ge 3 ]]; then
- if [[ "$3" != "--publish-fresh-marker" ]]; then
- usage
- return 2
- fi
- publish_fresh_marker=true
- fresh_marker_identity="${4:-fresh}"
- case "$fresh_marker_identity" in
- fresh) ;;
- v1:gcp-disk-id:*)
- if [[ ! "$fresh_marker_identity" =~ ^v1:gcp-disk-id:[0-9]{1,32}$ ]]; then
- usage
- return 2
- fi
- ;;
- *)
- usage
- return 2
- ;;
- esac
- fi
-
- device_path="$1"
- mount_path="$2"
-
- if [[ "$device_path" != /dev/* || "$device_path" == *$'\n'* || "$device_path" == *$'\r'* ]]; then
- log "Refusing unsafe device path: $device_path"
- return 2
- fi
- if [[ "$mount_path" != /* || "$mount_path" == "/" || "$mount_path" == *$'\n'* ||
- "$mount_path" == *$'\r'* || "$mount_path" =~ (^|/)\.\.?(/|$) ]]; then
- log "Refusing unsafe mount path: $mount_path"
- return 2
- fi
-
- wait_seconds="${FILESYSTEM_DEVICE_WAIT_SECONDS:-600}"
- if [[ ! "$wait_seconds" =~ ^[1-9][0-9]{0,2}$ ]] || ((10#$wait_seconds > 600)); then
- log "FILESYSTEM_DEVICE_WAIT_SECONDS must be an integer from 1 through 600"
- return 2
- fi
- while true; do
- device="$(readlink -f -- "$device_path" 2>/dev/null || true)"
- if [[ -n "$device" ]] && is_block_device "$device"; then
- break
- fi
- if ((10#$wait_seconds == 0)); then
- log "Block device did not appear before the wait deadline: $device_path"
- return 1
- fi
- sleep_seconds=2
- if ((10#$wait_seconds < sleep_seconds)); then
- sleep_seconds=$((10#$wait_seconds))
- fi
- log "Waiting for block device: $device_path"
- sleep "$sleep_seconds"
- wait_seconds=$((10#$wait_seconds - sleep_seconds))
- done
-
- settle_seconds="${FILESYSTEM_AUTOMOUNT_SETTLE_SECONDS:-60}"
- if [[ ! "$settle_seconds" =~ ^[1-9][0-9]{0,2}$ ]] || ((10#$settle_seconds > 600)); then
- log "FILESYSTEM_AUTOMOUNT_SETTLE_SECONDS must be an integer from 1 through 600"
- return 2
- fi
- if provider_mount="$(digitalocean_automatic_mount "$device_path")"; then
- settle_digitalocean_automount "$device_path" "$settle_seconds"
- else
- provider_mount=""
- fi
-
- already_mounted=false
- mkdir -p -- "$mount_path"
- if [[ -L "$mount_path" || ! -d "$mount_path" ]]; then
- log "Target mount path is not a safe directory: $mount_path"
- return 1
- fi
- device_mounts_output="$(device_mount_targets "$device")" || return 1
- device_mounts=()
- if [[ -n "$device_mounts_output" ]]; then
- mapfile -t device_mounts <<<"$device_mounts_output"
- fi
- # If the legacy provider unit exists but udev has not started it yet,
- # finish that job synchronously and inspect the resulting mount set before
- # any offline filesystem operation. Do not start it when another mount is
- # already present, which would create a duplicate alias.
- if ((${#device_mounts[@]} == 0)) && [[ -n "$provider_mount" ]]; then
- start_digitalocean_automount "$device_path" "$provider_mount"
- device_mounts_output="$(device_mount_targets "$device")" || return 1
- if [[ -n "$device_mounts_output" ]]; then
- mapfile -t device_mounts <<<"$device_mounts_output"
- fi
- fi
- if ((${#device_mounts[@]} > 1)); then
- log "$device is mounted at multiple targets; refusing to continue: ${device_mounts[*]}"
- return 1
- fi
-
- if ((${#device_mounts[@]} == 1)); then
- if [[ "${device_mounts[0]}" == "$mount_path" ]]; then
- mounted_source="$(findmnt -n -o SOURCE --target "$mount_path")" || {
- log "Could not determine the mounted source for $mount_path"
- return 1
- }
- resolved_mounted_source="$(readlink -f -- "$mounted_source" 2>/dev/null || true)"
- if [[ -z "$resolved_mounted_source" || "$resolved_mounted_source" != "$device" ]]; then
- log "$mount_path is already mounted from $mounted_source, expected $device"
- return 1
- fi
- already_mounted=true
- elif [[ -n "$provider_mount" && "${device_mounts[0]}" == "$provider_mount" &&
- ! -L "$provider_mount" && -d "$provider_mount" ]] && mountpoint -q -- "$provider_mount"; then
- mounted_source="$(findmnt -n -o SOURCE --target "$provider_mount")" || {
- log "Could not determine the mounted source for $provider_mount"
- return 1
- }
- resolved_mounted_source="$(readlink -f -- "$mounted_source" 2>/dev/null || true)"
- if [[ -z "$resolved_mounted_source" || "$resolved_mounted_source" != "$device" ]]; then
- log "$provider_mount is mounted from $mounted_source, expected $device"
- return 1
- fi
- target_contents="$(find "$mount_path" -mindepth 1 -maxdepth 1 -print -quit)" || {
- log "Could not safely inspect target mount directory: $mount_path"
- return 1
- }
- if [[ -n "$target_contents" ]]; then
- log "Target mount directory is not empty; refusing to hide its contents: $mount_path"
- return 1
- fi
-
- mount --move "$provider_mount" "$mount_path"
- device_mounts_output="$(device_mount_targets "$device")" || return 1
- device_mounts=()
- if [[ -n "$device_mounts_output" ]]; then
- mapfile -t device_mounts <<<"$device_mounts_output"
- fi
- if ((${#device_mounts[@]} != 1)) || [[ "${device_mounts[0]}" != "$mount_path" ]]; then
- log "Relocated mount did not become the device's only target: ${device_mounts[*]:-none}"
- mount --move "$mount_path" "$provider_mount" >/dev/null 2>&1 || true
- return 1
- fi
- moved_source="$(findmnt -n -o SOURCE --target "$mount_path")" || {
- log "Could not verify the relocated mount at $mount_path"
- mount --move "$mount_path" "$provider_mount" >/dev/null 2>&1 || true
- return 1
- }
- resolved_moved_source="$(readlink -f -- "$moved_source" 2>/dev/null || true)"
- if [[ -z "$resolved_moved_source" || "$resolved_moved_source" != "$device" ]]; then
- log "Relocated mount at $mount_path is from $moved_source, expected $device"
- mount --move "$mount_path" "$provider_mount" >/dev/null 2>&1 || true
- return 1
- fi
- rmdir -- "$provider_mount" 2>/dev/null || true
- already_mounted=true
- log "Moved provider-owned mount $provider_mount to $mount_path"
- else
- log "$device is mounted at unexpected target ${device_mounts[0]}; refusing to continue"
- return 1
- fi
- fi
-
- filesystem_type=""
- blkid_status=0
- filesystem_type="$(blkid -p -s TYPE -o value -- "$device" 2>/dev/null)" || blkid_status=$?
-
- case "$blkid_status" in
- 0)
- if [[ "$filesystem_type" != "ext4" ]]; then
- log "Existing filesystem on $device is '$filesystem_type', expected ext4; refusing to format"
- return 1
- fi
-
- if [[ "$already_mounted" != "true" ]]; then
- require_device_unmounted "$device" "filesystem check"
- fsck_status=0
- # resize2fs can require a complete offline check even when the
- # ext4 clean bit lets a normal preen return immediately. Force
- # that check while the device is still proven unmounted so a
- # preserved disk can be grown safely during a VM replacement.
- fsck.ext4 -f -p -- "$device" || fsck_status=$?
- case "$fsck_status" in
- 0 | 1)
- ;;
- 2 | 3)
- log "Filesystem check for $device requires a reboot; refusing to mount or format"
- return 2
- ;;
- *)
- log "Filesystem check for $device failed with status $fsck_status; refusing to format"
- return 1
- ;;
- esac
- fi
- if ! resize2fs -- "$device"; then
- log "Could not grow the ext4 filesystem on $device"
- return 1
- fi
- if [[ "$publish_fresh_marker" == "true" ]]; then
- filesystem_label="$(e2label "$device")" || {
- log "Could not inspect the ext4 label on $device"
- return 1
- }
- if [[ "$filesystem_label" == "$fresh_filesystem_pending_label" ]]; then
- fresh_marker_pending=true
- fi
- fi
- ;;
- 2)
- if [[ "$already_mounted" == "true" ]]; then
- log "Mounted device $device has no detectable filesystem signature"
- return 1
- fi
- log "No filesystem signature found on $device; creating ext4"
- require_device_unmounted "$device" "filesystem creation"
- if [[ "$publish_fresh_marker" == "true" ]]; then
- mkfs.ext4 -m 0 -E lazy_itable_init=1,lazy_journal_init=1,nodiscard \
- -L "$fresh_filesystem_pending_label" -- "$device"
- fresh_marker_pending=true
- else
- mkfs.ext4 -m 0 -E lazy_itable_init=1,lazy_journal_init=1,nodiscard -- "$device"
- fi
- ;;
- *)
- log "Could not safely inspect filesystem signatures on $device (blkid status $blkid_status)"
- return 1
- ;;
- esac
-
- if [[ "$already_mounted" != "true" ]]; then
- require_device_unmounted "$device" "cloud-compose mount"
- mount -o defaults -- "$device" "$mount_path"
- fi
- if [[ "$fresh_marker_pending" == "true" ]]; then
- publish_fresh_filesystem_marker "$mount_path" "$fresh_marker_identity"
- sync
- if ! e2label "$device" ""; then
- log "Published the fresh-filesystem marker but could not clear the pending ext4 label on $device"
- return 1
- fi
- sync
- log "Published fresh-filesystem marker at $mount_path/.cloud-compose/fresh-filesystem"
- fi
-}
-
-if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
- main "$@"
-fi
diff --git a/rootfs/home/cloud-compose/profile.sh b/rootfs/home/cloud-compose/profile.sh
index 7695c52..7dd5148 100755
--- a/rootfs/home/cloud-compose/profile.sh
+++ b/rootfs/home/cloud-compose/profile.sh
@@ -1,428 +1,69 @@
#!/usr/bin/env bash
-_cc_profile_source="$(readlink -f -- "${BASH_SOURCE[0]}")" || {
- echo "Could not resolve the Cloud Compose profile path" >&2
- return 1 2>/dev/null || exit 1
-}
-_cc_profile_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || {
- echo "Could not resolve the Cloud Compose profile directory" >&2
- return 1 2>/dev/null || exit 1
-}
-_cc_profile_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_profile_source _cc_profile_dir _cc_profile_installed_home
-if [[ -n "$_cc_profile_installed_home" &&
- ( "$_cc_profile_installed_home" == "/" ||
- "$_cc_profile_source" == "${_cc_profile_installed_home%/}/"* ) ]]; then
- _cc_profile_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_profile_checked_programs="$_cc_profile_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_profile_checked_programs
-# shellcheck disable=SC1090
-if ! source "$_cc_profile_checked_programs"; then
- echo "Could not load the checked Cloud Compose program resolver" >&2
- return 1 2>/dev/null || exit 1
-fi
-
decode_runtime_env_value() {
- local encoded="$1"
- local output="" character next index
-
+ local encoded="$1" output="" character next index
for ((index = 0; index < ${#encoded}; index++)); do
character="${encoded:index:1}"
- if [[ "$character" != "\\" ]]; then
- if [[ "$character" == '$' ]]; then
- if ((index + 1 >= ${#encoded})) || [[ "${encoded:index+1:1}" != '$' ]]; then
- echo "Unescaped dollar in cloud-compose environment value" >&2
- return 1
- fi
- output+='$'
- ((index += 1))
- continue
- fi
- if [[ "$character" == '"' || "$character" == $'\r' || "$character" == $'\t' ]]; then
- echo "Unescaped character in cloud-compose environment value: $character" >&2
- return 1
- fi
- output+="$character"
- continue
- fi
-
- if ((index + 1 >= ${#encoded})); then
- echo "Invalid trailing escape in cloud-compose environment value" >&2
+ if [[ "$character" == '$' ]]; then
+ [[ "${encoded:index+1:1}" == '$' ]] || return 1
+ output+='$'
+ ((index += 1))
+ elif [[ "$character" == "\\" ]]; then
+ next="${encoded:index+1:1}"
+ case "$next" in
+ "\\" | '"') output+="$next" ;;
+ n) output+=$'\n' ;;
+ r) output+=$'\r' ;;
+ t) output+=$'\t' ;;
+ *) return 1 ;;
+ esac
+ ((index += 1))
+ elif [[ "$character" == '"' || "$character" == $'\r' || "$character" == $'\t' ]]; then
return 1
+ else
+ output+="$character"
fi
- next="${encoded:index+1:1}"
- case "$next" in
- "\\" | '"')
- output+="$next"
- ((index += 1))
- ;;
- n)
- output+=$'\n'
- ((index += 1))
- ;;
- r)
- output+=$'\r'
- ((index += 1))
- ;;
- t)
- output+=$'\t'
- ((index += 1))
- ;;
- *)
- echo "Invalid escape in cloud-compose environment value: \\$next" >&2
- return 1
- ;;
- esac
done
-
RUNTIME_ENV_DECODED="$output"
}
-write_runtime_env_assignment() {
- local name="$1"
- local value="$2"
-
- if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
- echo "Invalid cloud-compose environment variable name: $name" >&2
- return 1
- fi
-
- value="${value//\\/\\\\}"
- value="${value//\"/\\\"}"
- value="${value//\$/\$\$}"
- value="${value//$'\n'/\\n}"
- value="${value//$'\r'/\\r}"
- value="${value//$'\t'/\\t}"
- printf '%s="%s"\n' "$name" "$value"
-}
-
load_runtime_env() {
- local file="$1"
- local line name encoded
-
- if [[ ! -f "$file" ]]; then
- echo "cloud-compose environment file not found: $file" >&2
- return 1
- fi
-
+ local file="${1:-/home/cloud-compose/.env}" line name encoded
+ [[ -f "$file" && ! -L "$file" ]] || return 1
while IFS= read -r line || [[ -n "$line" ]]; do
- if [[ -z "$line" || "$line" == \#* ]]; then
- continue
- fi
- if [[ ! "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=\"(.*)\"$ ]]; then
- echo "Invalid cloud-compose environment assignment" >&2
- return 1
- fi
-
+ [[ -z "$line" || "$line" == \#* ]] && continue
+ [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=\"(.*)\"$ ]] || return 1
name="${BASH_REMATCH[1]}"
encoded="${BASH_REMATCH[2]}"
- decode_runtime_env_value "$encoded" || return 1
- declare -gx -- "$name=$RUNTIME_ENV_DECODED" || return 1
+ if ! decode_runtime_env_value "$encoded"; then
+ echo "Invalid Cloud Compose environment encoding for $name" >&2
+ return 1
+ fi
+ declare -gx -- "$name=$RUNTIME_ENV_DECODED"
done <"$file"
}
-if ! load_runtime_env "${CLOUD_COMPOSE_ENV_FILE:-/home/cloud-compose/.env}"; then
- if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
- return 1
- fi
- exit 1
-fi
-
-if ! cloud_compose_bind_program_dir \
- "$_cc_profile_source" \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR \
- /etc/cloud-compose/jq \
- "$_cc_profile_dir/../../etc/cloud-compose/jq" \
- application-env-validate.jq \
- object-entries-sorted-base64.jq \
- object-field-delimited.jq; then
+load_runtime_env "${CLOUD_COMPOSE_ENV_FILE:-/home/cloud-compose/.env}" || {
+ echo "Invalid or missing Cloud Compose environment" >&2
return 1 2>/dev/null || exit 1
-fi
+}
if ((EUID == 0)); then
- # Root-owned systemd/bootstrap paths must never resolve commands from the
- # cloud-compose-writable tool directory.
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
else
export PATH="/home/cloud-compose/bin:$PATH"
fi
export DOCKER_CONFIG="${DOCKER_CONFIG:-/mnt/disks/data/docker-config}"
-DEFAULT_MAX_RETRIES=10
-DEFAULT_SLEEP_INCREMENT=5
-
-# helper to wrap commands that go over the network in "exponential" backoff
-# e.g. docker pull and git pull
retry_until_success() {
- if (($# == 0)); then
- echo "retry_until_success requires a command." >&2
- return 2
- fi
- local command_to_run=("$@")
- local operation="${1##*/}"
- local max_retries="${MAX_RETRIES:-$DEFAULT_MAX_RETRIES}"
- local sleep_increment="${SLEEP_INCREMENT:-$DEFAULT_SLEEP_INCREMENT}"
- local retries=0
- local exit_code
-
- if [[ ! "$max_retries" =~ ^[1-9][0-9]{0,2}$ ]] || ((10#$max_retries > 100)); then
- echo "MAX_RETRIES must be an integer from 1 through 100." >&2
- return 2
- fi
- if [[ ! "$sleep_increment" =~ ^[0-9]{1,4}$ ]] || ((10#$sleep_increment > 3600)); then
- echo "SLEEP_INCREMENT must be an integer from 0 through 3600 seconds." >&2
- return 2
- fi
-
- while true; do
- exit_code=0
- timeout 300 "${command_to_run[@]}" || exit_code=$?
-
- if [ "$exit_code" -eq 0 ]; then
- return 0
- fi
-
- retries=$((retries + 1))
-
- if [ "$retries" -ge "$max_retries" ]; then
- echo "FAILURE: Operation '$operation' failed after $max_retries attempts (last exit code: $exit_code)." >&2
- return 1
- fi
-
- local sleep_seconds=$((10#$sleep_increment * retries))
- echo "Operation '$operation' failed (exit code: $exit_code). Retrying in $sleep_seconds seconds... (attempt $retries/$max_retries)" >&2
- sleep "$sleep_seconds"
+ (($# > 0)) || return 2
+ local max="${MAX_RETRIES:-10}" increment="${SLEEP_INCREMENT:-5}" attempt status
+ [[ "$max" =~ ^[1-9][0-9]{0,2}$ && "$increment" =~ ^[0-9]{1,4}$ ]] || return 2
+ ((10#$max <= 100 && 10#$increment <= 3600)) || return 2
+ for ((attempt = 1; attempt <= max; attempt++)); do
+ timeout 300 "$@" && return 0
+ status=$?
+ ((attempt == max)) && return "$status"
+ sleep "$((increment * attempt))"
done
}
-
-# Serialize host lifecycle operations that can move a checkout, restart a
-# Compose stack, or read a live database. systemd-tmpfiles creates this shared
-# root/application lock before any privileged package update or app service.
-acquire_cloud_compose_lifecycle_lock() {
- local operation="${1:-operation}"
- local lock_file="/run/lock/cloud-compose/lifecycle.lock"
- local timeout_seconds="${CLOUD_COMPOSE_LIFECYCLE_LOCK_TIMEOUT_SECONDS:-900}"
- local lock_parent lock_fd_path lock_identity
-
- if [[ ! "$timeout_seconds" =~ ^[1-9][0-9]{0,4}$ ]] || ((10#$timeout_seconds > 43200)); then
- echo "CLOUD_COMPOSE_LIFECYCLE_LOCK_TIMEOUT_SECONDS must be an integer from 1 through 43200" >&2
- return 2
- fi
- if ! command -v flock >/dev/null 2>&1; then
- echo "flock is required to serialize cloud-compose lifecycle operations" >&2
- return 1
- fi
-
- lock_parent="$(dirname -- "$lock_file")"
- if [[ "$lock_parent" != "/run/lock/cloud-compose" || ! -d "$lock_parent" ||
- -L "$lock_parent" || -L "$lock_file" || ! -f "$lock_file" ]]; then
- echo "Unsafe cloud-compose lifecycle lock target: $lock_file" >&2
- return 1
- fi
- lock_identity="$(stat -Lc '%U:%G:%a' -- "$lock_parent" "$lock_file")" || return 1
- if [[ "$lock_identity" != $'root:cloud-compose:750\nroot:cloud-compose:660' ]]; then
- echo "Unsafe cloud-compose lifecycle lock ownership or mode: $lock_file" >&2
- return 1
- fi
-
- if ! exec 8<>"$lock_file"; then
- echo "Could not open shared cloud-compose lifecycle lock: $lock_file" >&2
- return 1
- fi
- lock_fd_path="/proc/${BASHPID}/fd/8"
- if [[ ! -f "$lock_fd_path" ]] ||
- [[ "$(stat -Lc '%d:%i' -- "$lock_fd_path")" != "$(stat -Lc '%d:%i' -- "$lock_file")" ]]; then
- echo "Cloud-compose lifecycle lock changed while opening: $lock_file" >&2
- exec 8>&-
- return 1
- fi
- if ! flock -w "$timeout_seconds" 8; then
- echo "Timed out waiting ${timeout_seconds}s for cloud-compose lifecycle lock during $operation" >&2
- exec 8>&-
- return 1
- fi
-}
-
-release_cloud_compose_lifecycle_lock() {
- if [[ -e /proc/${BASHPID}/fd/8 ]]; then
- flock -u 8 >/dev/null 2>&1 || true
- exec 8>&-
- fi
-}
-
-# Strictly update the host-owned cloud-compose environment contract.
-update_runtime_env_file() (
- local env_file="$1"
- local name="$2"
- local value="$3"
- local line existing_name tmp_file
-
- if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
- echo "Invalid cloud-compose environment variable name: $name" >&2
- return 1
- fi
- if [[ -L "$env_file" || ( -e "$env_file" && ! -f "$env_file" ) ]]; then
- echo "Refusing unsafe cloud-compose environment path: $env_file" >&2
- return 1
- fi
-
- umask 027
- tmp_file=$(mktemp "${env_file}.tmp.XXXXXXXXXX") || return 1
- trap 'rm -f -- "$tmp_file"' EXIT
-
- if [[ -f "$env_file" ]]; then
- while IFS= read -r line || [[ -n "$line" ]]; do
- if [[ -z "$line" || "$line" == \#* ]]; then
- printf '%s\n' "$line" >>"$tmp_file"
- continue
- fi
- if [[ ! "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=\"(.*)\"$ ]]; then
- echo "Invalid cloud-compose environment assignment" >&2
- return 1
- fi
- existing_name="${BASH_REMATCH[1]}"
- decode_runtime_env_value "${BASH_REMATCH[2]}" || return 1
- if [[ "$existing_name" != "$name" ]]; then
- printf '%s\n' "$line" >>"$tmp_file"
- fi
- done <"$env_file"
- fi
-
- write_runtime_env_assignment "$name" "$value" >>"$tmp_file" || return 1
- chmod 0640 "$tmp_file"
- mv -f -- "$tmp_file" "$env_file"
-)
-
-update_runtime_env() {
- update_runtime_env_file .env "$@"
-}
-
-# Compose application .env files use the broader Compose dotenv grammar and
-# may be owned by downstream forks. Preserve their contents as opaque data and
-# maintain one final, clearly marked override instead of parsing or sourcing
-# untrusted application values.
-update_compose_env() (
- local name="$1"
- local value="$2"
- local env_file=".env"
- local marker line managed_line tmp_file
-
- if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
- echo "Invalid Compose environment variable name: $name" >&2
- return 1
- fi
- if [[ -L "$env_file" || ( -e "$env_file" && ! -f "$env_file" ) ]]; then
- echo "Refusing unsafe Compose environment path: $env_file" >&2
- return 1
- fi
-
- marker="# cloud-compose managed: ${name}"
- umask 027
- tmp_file=$(mktemp "${env_file}.tmp.XXXXXXXXXX") || return 1
- trap 'rm -f -- "$tmp_file"' EXIT
-
- if [[ -f "$env_file" ]]; then
- while IFS= read -r line || [[ -n "$line" ]]; do
- if [[ "$line" == "$marker" ]]; then
- if ! IFS= read -r managed_line; then
- echo "Incomplete managed Compose environment assignment for $name" >&2
- return 1
- fi
- if [[ ! "$managed_line" =~ ^${name}=\".*\"$ ]]; then
- echo "Invalid managed Compose environment assignment for $name" >&2
- return 1
- fi
- continue
- fi
- printf '%s\n' "$line" >>"$tmp_file"
- done <"$env_file"
- fi
-
- printf '%s\n' "$marker" >>"$tmp_file"
- write_runtime_env_assignment "$name" "$value" >>"$tmp_file" || return 1
- chmod 0640 "$tmp_file"
- mv -f -- "$tmp_file" "$env_file"
-)
-
-# Reconcile application-only tuning values into the current Compose project's
-# dotenv file. The JSON file is parsed as data and is never exported into the
-# host process environment. A distinct marker lets later runs remove values
-# that were deleted from runtime.extra_env while preserving downstream dotenv
-# syntax and cloud-compose's final control-plane overrides.
-sync_compose_application_env() (
- local application_env_file="${1:-${CLOUD_COMPOSE_APPLICATION_ENV_FILE:-/home/cloud-compose/application-env.json}}"
- local env_file=".env"
- local entries_file tmp_file line managed_line application_name encoded entry_json name value
-
- if [[ -L "$application_env_file" || ! -f "$application_env_file" ]]; then
- echo "Compose application environment file is missing or unsafe: $application_env_file" >&2
- return 1
- fi
- if [[ -L "$env_file" || ( -e "$env_file" && ! -f "$env_file" ) ]]; then
- echo "Refusing unsafe Compose environment path: $env_file" >&2
- return 1
- fi
- if ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/application-env-validate.jq" \
- "$application_env_file" >/dev/null; then
- echo "Invalid Compose application environment data: $application_env_file" >&2
- return 1
- fi
-
- umask 027
- entries_file=$(mktemp "${TMPDIR:-/tmp}/cloud-compose-application-env.XXXXXXXXXX") || return 1
- tmp_file=$(mktemp "${env_file}.tmp.XXXXXXXXXX") || {
- rm -f -- "$entries_file"
- return 1
- }
- trap 'rm -f -- "$entries_file" "$tmp_file"' EXIT
-
- jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-entries-sorted-base64.jq" \
- "$application_env_file" >"$entries_file" || return 1
-
- if [[ -f "$env_file" ]]; then
- while IFS= read -r line || [[ -n "$line" ]]; do
- if [[ "$line" =~ ^\#\ cloud-compose\ application:\ ([A-Za-z_][A-Za-z0-9_]*)$ ]]; then
- application_name="${BASH_REMATCH[1]}"
- if ! IFS= read -r managed_line; then
- echo "Incomplete Compose application environment assignment for $application_name" >&2
- return 1
- fi
- if [[ ! "$managed_line" =~ ^${application_name}=\".*\"$ ]]; then
- echo "Invalid Compose application environment assignment for $application_name" >&2
- return 1
- fi
- continue
- fi
- printf '%s\n' "$line" >>"$tmp_file"
- done <"$env_file"
- fi
-
- while IFS= read -r encoded || [[ -n "$encoded" ]]; do
- [[ -n "$encoded" ]] || continue
- entry_json="$(printf '%s' "$encoded" | base64 -d)" || return 1
- name="$(jq -jr --arg field key \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" \
- <<<"$entry_json")" || return 1
- name="${name%$'\x1f'}"
- if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
- echo "Invalid Compose application environment data: $application_env_file" >&2
- return 1
- fi
- value="$(jq -jr --arg field value \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" \
- <<<"$entry_json")" || return 1
- value="${value%$'\x1f'}"
- printf '# cloud-compose application: %s\n' "$name" >>"$tmp_file"
- write_runtime_env_assignment "$name" "$value" >>"$tmp_file" || return 1
- done <"$entries_file"
-
- chmod 0640 "$tmp_file"
- mv -f -- "$tmp_file" "$env_file"
-)
-
-# Backward-compatible helper name used by older rootfs extensions.
-update_env() {
- update_compose_env "$@"
-}
diff --git a/rootfs/home/cloud-compose/restore-test.sh b/rootfs/home/cloud-compose/restore-test.sh
deleted file mode 100755
index 260114d..0000000
--- a/rootfs/home/cloud-compose/restore-test.sh
+++ /dev/null
@@ -1,106 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_restore_test_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_restore_test_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_restore_test_source script_dir _cc_restore_test_installed_home
-if [[ -n "$_cc_restore_test_installed_home" &&
- ( "$_cc_restore_test_installed_home" == "/" ||
- "$_cc_restore_test_source" == "${_cc_restore_test_installed_home%/}/"* ) ]]; then
- _cc_restore_test_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_restore_test_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_restore_test_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_restore_test_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_restore_test_source" CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh "$script_dir/profile.sh"
-cloud_compose_bind_source_program \
- "$_cc_restore_test_source" CLOUD_COMPOSE_DR_LIBRARY_PATH \
- /home/cloud-compose/disaster-recovery-lib.sh "$script_dir/disaster-recovery-lib.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-dr_library_path="$CLOUD_COMPOSE_DR_LIBRARY_PATH"
-readonly profile_path dr_library_path
-# shellcheck disable=SC1090
-source "$profile_path"
-# Restore the fixed binding functions before the final sourced dependency.
-# shellcheck disable=SC1090
-source "$_cc_restore_test_checked_programs"
-# shellcheck disable=SC1090
-source "$dr_library_path"
-
-if cloud_compose_dr_is_required; then
- :
-else
- status=$?
- if ((status == 1)); then
- echo "Off-host disaster recovery is not required; skipping scheduled restore test"
- exit 0
- fi
- exit "$status"
-fi
-
-driver="$CLOUD_COMPOSE_OFFHOST_BACKUP_DRIVER"
-state_root="$CLOUD_COMPOSE_DR_STATE_ROOT"
-manifest_dir="$state_root/manifests"
-receipt_dir="$state_root/backup-receipts"
-proof_dir="$state_root/restore-proofs"
-staging_root="$state_root/staging"
-staging_dir=""
-
-cleanup() {
- if [[ -n "$staging_dir" ]]; then
- rm -rf -- "$staging_dir"
- fi
-}
-trap cleanup EXIT
-
-cloud_compose_dr_validate_driver "$driver"
-for path in "$state_root" "$manifest_dir" "$receipt_dir" "$proof_dir" "$staging_root"; do
- cloud_compose_dr_prepare_state_directory "$path"
-done
-
-latest_receipt="$(find "$receipt_dir" -xdev -maxdepth 1 -type f -name '*.json' -printf '%f\n' | sort | tail -n 1)"
-if [[ -z "$latest_receipt" ]]; then
- echo "Scheduled restore test requires a validated off-host backup receipt" >&2
- exit 1
-fi
-operation_id="${latest_receipt%.json}"
-if [[ ! "$operation_id" =~ ^[0-9]{8}-[a-z][a-z0-9-]*$ ]]; then
- echo "Latest off-host backup receipt has an unsafe operation id" >&2
- exit 1
-fi
-receipt_path="$receipt_dir/$latest_receipt"
-manifest_path="$manifest_dir/$latest_receipt"
-if [[ -L "$manifest_path" || ! -f "$manifest_path" ]]; then
- echo "Scheduled restore test is missing the source coverage manifest" >&2
- exit 1
-fi
-cloud_compose_dr_validate_json_file "$manifest_path" "Off-host backup manifest"
-manifest_sha256="$(cloud_compose_dr_sha256_file "$manifest_path")"
-cloud_compose_dr_validate_backup_receipt "$receipt_path" "$operation_id" "$manifest_sha256"
-receipt_sha256="$(cloud_compose_dr_sha256_file "$receipt_path")"
-
-test_id="$(date -u +%Y%m%dT%H%M%SZ)-$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
-staging_dir="$(mktemp -d "$staging_root/.restore-${test_id}.XXXXXX")"
-chmod 0700 "$staging_dir"
-staged_proof="$staging_dir/proof.json"
-
-cloud_compose_dr_run_driver "$driver" restore-test \
- --manifest "$manifest_path" \
- --backup-receipt "$receipt_path" \
- --source-manifest-sha256 "$manifest_sha256" \
- --source-receipt-sha256 "$receipt_sha256" \
- --test-id "$test_id" \
- --proof "$staged_proof"
-cloud_compose_dr_validate_restore_proof \
- "$staged_proof" "$test_id" "$manifest_sha256" "$receipt_sha256"
-
-proof_path="$proof_dir/${test_id}.json"
-chmod 0640 "$staged_proof"
-mv -- "$staged_proof" "$proof_path"
-echo "Disposable restore test proved database, application-file, and volume-topology recovery: $proof_path"
diff --git a/rootfs/home/cloud-compose/rotate-keys-app.sh b/rootfs/home/cloud-compose/rotate-keys-app.sh
index 6977760..03762f4 100755
--- a/rootfs/home/cloud-compose/rotate-keys-app.sh
+++ b/rootfs/home/cloud-compose/rotate-keys-app.sh
@@ -1,409 +1,4 @@
#!/usr/bin/env bash
-
set -euo pipefail
-
-_cc_rotate_keys_app_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_rotate_keys_app_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_rotate_keys_app_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_rotate_keys_app_source _cc_rotate_keys_app_dir _cc_rotate_keys_app_installed_home
-if [[ -n "$_cc_rotate_keys_app_installed_home" &&
- ( "$_cc_rotate_keys_app_installed_home" == "/" ||
- "$_cc_rotate_keys_app_source" == "${_cc_rotate_keys_app_installed_home%/}/"* ) ]]; then
- _cc_rotate_keys_app_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_rotate_keys_app_checked_programs="$_cc_rotate_keys_app_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_rotate_keys_app_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_rotate_keys_app_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_rotate_keys_app_source" CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh "$_cc_rotate_keys_app_dir/profile.sh"
-cloud_compose_bind_source_program \
- "$_cc_rotate_keys_app_source" CLOUD_COMPOSE_ROTATE_KEYS_PATH \
- /home/cloud-compose/rotate-keys.sh "$_cc_rotate_keys_app_dir/rotate-keys.sh"
-cloud_compose_bind_source_program \
- "$_cc_rotate_keys_app_source" CLOUD_COMPOSE_COMPOSE_APPS_PATH \
- /home/cloud-compose/compose-apps.sh "$_cc_rotate_keys_app_dir/compose-apps.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-rotate_keys_script="$CLOUD_COMPOSE_ROTATE_KEYS_PATH"
-compose_apps_path="$CLOUD_COMPOSE_COMPOSE_APPS_PATH"
-readonly profile_path rotate_keys_script compose_apps_path
-
-# shellcheck disable=SC1090
-source "$profile_path"
-
-case "${CLOUD_COMPOSE_PROVIDER:-}" in
- gcp) ;;
- "")
- echo "CLOUD_COMPOSE_PROVIDER is required for service-account key rotation" >&2
- exit 1
- ;;
- *) exit 0 ;;
-esac
-
-# shellcheck disable=SC1090
-source "$compose_apps_path"
-
-# Reload the owner-relative resolver after both sourced dependencies.
-# shellcheck disable=SC1090
-source "$_cc_rotate_keys_app_checked_programs"
-cloud_compose_bind_program_dir \
- "$_cc_rotate_keys_app_source" \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR \
- /etc/cloud-compose/jq \
- "$_cc_rotate_keys_app_dir/../../etc/cloud-compose/jq" \
- service-account-key-id.jq \
- service-account-credentials-valid.jq
-
-APP_CREDENTIALS_FILE="${APP_CREDENTIALS_FILE:-/mnt/disks/data/cloud-compose/app/GOOGLE_APPLICATION_CREDENTIALS}"
-ROTATION_APP_CREDENTIAL_OWNER="${ROTATION_CREDENTIAL_OWNER-100}"
-ROTATION_CENTRAL_CREDENTIAL_OWNER="${ROTATION_CENTRAL_CREDENTIAL_OWNER-root}"
-ROTATION_CREDENTIAL_GROUP="${ROTATION_CREDENTIAL_GROUP-cloud-compose}"
-CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER="${CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER:-/mnt/disks/data/.cloud-compose/fresh-filesystem}"
-CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY="${CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY:-}"
-rotation_reconcile_orphans=false
-rotation_action="${1:-rotate}"
-
-if [[ -n "$ROTATION_CREDENTIAL_GROUP" &&
- ! "$ROTATION_CREDENTIAL_GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]]; then
- echo "ROTATION_CREDENTIAL_GROUP must be empty or a safe local group name" >&2
- exit 2
-fi
-if [[ ! "$ROTATION_CENTRAL_CREDENTIAL_OWNER" =~ ^([0-9]+|[a-z_][a-z0-9_-]{0,31}\$?)$ ]]; then
- echo "ROTATION_CENTRAL_CREDENTIAL_OWNER must be a numeric UID or safe local account name" >&2
- exit 2
-fi
-
-case "$rotation_action" in
- rotate | rollback | retire) ;;
- *)
- echo "Usage: $0 [rotate|rollback|retire]" >&2
- exit 2
- ;;
-esac
-
-case "${GCP_APP_CREDENTIALS_ENABLED:-false}" in
- true) app_credentials_enabled=true ;;
- false) app_credentials_enabled=false ;;
- *)
- echo "GCP_APP_CREDENTIALS_ENABLED must be true or false" >&2
- exit 2
- ;;
-esac
-
-case "${GCP_APP_SERVICE_ACCOUNT_MANAGED:-false}" in
- true) app_service_account_managed=true ;;
- false) app_service_account_managed=false ;;
- *)
- echo "GCP_APP_SERVICE_ACCOUNT_MANAGED must be true or false" >&2
- exit 2
- ;;
-esac
-
-rotate_keys() {
- ROTATION_CREDENTIAL_OWNER="$ROTATION_CENTRAL_CREDENTIAL_OWNER" \
- ROTATION_RECONCILE_ORPHANS="$rotation_reconcile_orphans" \
- CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER="$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" \
- CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY="$CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY" \
- bash "$rotate_keys_script" "$1" \
- "$GCP_APP_SERVICE_ACCOUNT_EMAIL" \
- "$GCP_PROJECT" \
- "$APP_CREDENTIALS_FILE"
-}
-
-require_inactive_app_service() {
- local load_state active_state
-
- load_state="$(systemctl show --property=LoadState --value -- cloud-compose.service)" || {
- echo "Could not determine whether cloud-compose.service is loaded" >&2
- return 1
- }
- active_state="$(systemctl show --property=ActiveState --value -- cloud-compose.service)" || {
- echo "Could not determine whether cloud-compose.service is inactive" >&2
- return 1
- }
- if [[ "$load_state" != "loaded" || "$active_state" != "inactive" ]]; then
- echo "Fresh-filesystem key reconciliation requires loaded, inactive cloud-compose.service; observed ${load_state}/${active_state}" >&2
- return 1
- fi
-}
-
-app_credential_key_id() {
- local file="$1" key_id
-
- key_id="$(jq -jr -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/service-account-key-id.jq" \
- "$file")" || return 1
- key_id="${key_id%$'\x1f'}"
- [[ "$key_id" =~ ^[A-Za-z0-9_-]+$ ]] || return 1
- printf '%s\n' "$key_id"
-}
-
-validate_app_credentials() {
- local file="$1" key_id
-
- [[ ! -L "$file" && -f "$file" ]] || return 1
- key_id="$(app_credential_key_id "$file")" || return 1
- jq -e \
- --arg key_id "$key_id" \
- --arg service_account "$GCP_APP_SERVICE_ACCOUNT_EMAIL" \
- --arg project_id "$GCP_PROJECT" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/service-account-credentials-valid.jq" \
- "$file" >/dev/null
-}
-
-restore_central_app_credentials() {
- local source="$1" target_dir target_tmp
-
- target_dir="$(dirname -- "$APP_CREDENTIALS_FILE")"
- if [[ -L "$target_dir" || ( -e "$target_dir" && ! -d "$target_dir" ) ]]; then
- echo "Application credential directory is unsafe: $target_dir" >&2
- return 1
- fi
- install -d -m 0750 -- "$target_dir"
- if [[ -L "$target_dir" || ! -d "$target_dir" ]]; then
- echo "Application credential directory changed during recovery: $target_dir" >&2
- return 1
- fi
-
- target_tmp="$(mktemp "${APP_CREDENTIALS_FILE}.tmp.XXXXXX")" || return 1
- if ! install -m 0440 "$source" "$target_tmp"; then
- rm -f -- "$target_tmp"
- return 1
- fi
- if ! touch -r "$source" "$target_tmp"; then
- rm -f -- "$target_tmp"
- return 1
- fi
- if ! chown -- "$ROTATION_CENTRAL_CREDENTIAL_OWNER" "$target_tmp"; then
- rm -f -- "$target_tmp"
- return 1
- fi
- if [[ -n "$ROTATION_CREDENTIAL_GROUP" ]] &&
- ! chgrp -- "$ROTATION_CREDENTIAL_GROUP" "$target_tmp"; then
- rm -f -- "$target_tmp"
- return 1
- fi
- if ! mv -f -- "$target_tmp" "$APP_CREDENTIALS_FILE"; then
- rm -f -- "$target_tmp"
- return 1
- fi
- validate_app_credentials "$APP_CREDENTIALS_FILE" || {
- echo "Recovered central application credentials failed validation" >&2
- return 1
- }
- echo "Recovered central application credentials from unanimous distributed copies"
-}
-
-recover_distributed_app_credentials() {
- local first_target="" target
- local -a pending_artifacts=(
- "${APP_CREDENTIALS_FILE}.rotation-pending.json"
- "${APP_CREDENTIALS_FILE}.rotation-staged.json"
- "${APP_CREDENTIALS_FILE}.rotation-previous.json"
- "${APP_CREDENTIALS_FILE}.rotation-replacement.json"
- )
-
- if [[ -f "$APP_CREDENTIALS_FILE" ]]; then
- return 0
- fi
- for target in "${pending_artifacts[@]}"; do
- if [[ -e "$target" || -L "$target" ]]; then
- if [[ "$target" == "${APP_CREDENTIALS_FILE}.rotation-pending.json" ]]; then
- return 0
- fi
- echo "Cannot recover central credentials while an orphaned rotation artifact remains: $target" >&2
- return 1
- fi
- done
-
- for target in "${app_credential_targets[@]}"; do
- if [[ ! -e "$target" && ! -L "$target" ]]; then
- continue
- fi
- if ! validate_app_credentials "$target"; then
- echo "Distributed application credentials are invalid: $target" >&2
- return 1
- fi
- if [[ -z "$first_target" ]]; then
- first_target="$target"
- elif ! cmp -s -- "$first_target" "$target"; then
- echo "Distributed application credentials disagree; refusing remote key reconciliation" >&2
- return 1
- fi
- done
- if [[ -n "$first_target" ]]; then
- if [[ "$app_service_account_managed" != "true" ]]; then
- echo "Caller-supplied app identities require operator-reviewed central credential recovery" >&2
- return 1
- fi
- restore_central_app_credentials "$first_target"
- fi
-}
-
-install_app_credentials() {
- local target="$1"
- local target_dir target_tmp
-
- target_dir="$(dirname -- "$target")"
- if [[ -L "$target_dir" || ( -e "$target_dir" && ! -d "$target_dir" ) ]]; then
- echo "Application credential directory is unsafe: $target_dir" >&2
- return 2
- fi
- install -d -m 0750 "$target_dir" || return 2
- if [[ -L "$target_dir" || ! -d "$target_dir" ]]; then
- echo "Application credential directory changed during preparation: $target_dir" >&2
- return 2
- fi
- if ! chown -- cloud-compose "$target_dir" || ! chmod 0750 "$target_dir"; then
- return 2
- fi
- if [[ -n "$ROTATION_CREDENTIAL_GROUP" ]] && ! chgrp -- "$ROTATION_CREDENTIAL_GROUP" "$target_dir"; then
- return 2
- fi
-
- if [[ -f "$target" && ! -L "$target" ]] && cmp -s -- "$APP_CREDENTIALS_FILE" "$target"; then
- if [[ -n "$ROTATION_APP_CREDENTIAL_OWNER" ]] && ! chown -- "$ROTATION_APP_CREDENTIAL_OWNER" "$target"; then
- return 2
- fi
- if [[ -n "$ROTATION_CREDENTIAL_GROUP" ]] && ! chgrp -- "$ROTATION_CREDENTIAL_GROUP" "$target"; then
- return 2
- fi
- if ! chmod 0440 "$target"; then
- return 2
- fi
- return 1
- fi
-
- target_tmp="$(mktemp "${target}.tmp.XXXXXX")" || return 2
- if ! install -m 0440 "$APP_CREDENTIALS_FILE" "$target_tmp"; then
- rm -f -- "$target_tmp"
- return 2
- fi
- if [[ -n "$ROTATION_APP_CREDENTIAL_OWNER" ]] && ! chown -- "$ROTATION_APP_CREDENTIAL_OWNER" "$target_tmp"; then
- rm -f -- "$target_tmp"
- return 2
- fi
- if [[ -n "$ROTATION_CREDENTIAL_GROUP" ]] && ! chgrp -- "$ROTATION_CREDENTIAL_GROUP" "$target_tmp"; then
- rm -f -- "$target_tmp"
- return 2
- fi
- if ! mv -f -- "$target_tmp" "$target"; then
- rm -f -- "$target_tmp"
- return 2
- fi
- return 0
-}
-
-apps=()
-app_credential_targets=()
-compose_app_names_array apps
-
-# Resolve every target before creating a cloud key. A malformed manifest or an
-# invalid app path must fail without changing IAM state.
-for app in "${apps[@]}"; do
- source_compose_app_env "$app"
- validate_compose_git_source
- app_credential_targets+=("$DOCKER_COMPOSE_DIR/secrets/GOOGLE_APPLICATION_CREDENTIALS")
-done
-
-credential_artifacts=(
- "$APP_CREDENTIALS_FILE"
- "${APP_CREDENTIALS_FILE}.rotation-pending.json"
- "${APP_CREDENTIALS_FILE}.rotation-staged.json"
- "${APP_CREDENTIALS_FILE}.rotation-previous.json"
- "${APP_CREDENTIALS_FILE}.rotation-replacement.json"
- "${APP_CREDENTIALS_FILE}.rotation.lock"
- "${app_credential_targets[@]}"
-)
-for target in "${credential_artifacts[@]}"; do
- if [[ -L "$target" || ( -e "$target" && ! -f "$target" ) ]]; then
- echo "Application credential artifact is unsafe: $target" >&2
- exit 2
- fi
-done
-for target in "${app_credential_targets[@]}"; do
- target_dir="$(dirname -- "$target")"
- if [[ -L "$target_dir" || ( -e "$target_dir" && ! -d "$target_dir" ) ]]; then
- echo "Application credential directory is unsafe: $target_dir" >&2
- exit 2
- fi
-done
-
-if [[ "$rotation_action" == "retire" ]]; then
- rotate_keys retire
- rm -f -- "${credential_artifacts[@]}"
- echo "Application file credentials retired"
- exit 0
-fi
-
-if [[ "$app_credentials_enabled" != "true" ]]; then
- for target in "${credential_artifacts[@]}"; do
- if [[ -e "$target" ]]; then
- echo "Managed app credentials are disabled but $target remains; re-enable credentials, run '$0 retire', then disable them" >&2
- exit 1
- fi
- done
- echo "Application file credentials are disabled"
- exit 0
-fi
-
-recover_distributed_app_credentials
-if [[ ! -e "$APP_CREDENTIALS_FILE" && ! -L "$APP_CREDENTIALS_FILE" &&
- "$app_service_account_managed" == "true" &&
- ( -e "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" || -L "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" ) ]]; then
- require_inactive_app_service
- rotation_reconcile_orphans=true
-fi
-
-install -d -m 0750 "$(dirname -- "$APP_CREDENTIALS_FILE")"
-if [[ "$rotation_action" == "rollback" ]]; then
- rotate_keys rollback
-else
- rotate_keys prepare
-fi
-phase="$(rotate_keys status)"
-
-if [[ "$phase" == "staged" ]]; then
- # Prove that IAM accepts the new private key before any running consumer is
- # switched to its credential inode. Propagation lag is handled with backoff.
- rotate_keys authenticate
- phase=authenticated
-fi
-
-if [[ -n "$ROTATION_CREDENTIAL_GROUP" ]]; then
- chgrp -- "$ROTATION_CREDENTIAL_GROUP" "$APP_CREDENTIALS_FILE"
-fi
-
-credentials_changed=false
-for target in "${app_credential_targets[@]}"; do
- if install_app_credentials "$target"; then
- credentials_changed=true
- else
- install_status=$?
- if [[ "$install_status" -ne 1 ]]; then
- echo "Failed to install application credentials at $target" >&2
- exit "$install_status"
- fi
- fi
-done
-
-if [[ "$phase" == "authenticated" || "$phase" == "rollback" || "$credentials_changed" == "true" ]]; then
- # A file bind mount keeps its original inode. Reload only a service that was
- # already active; rotation must never turn an intentionally stopped app on.
- if systemctl is-active --quiet cloud-compose.service; then
- systemctl restart cloud-compose.service
- fi
-fi
-
-if [[ "$phase" == "authenticated" ]]; then
- rotate_keys ready
- phase=ready
-fi
-if [[ "$phase" == "rollback" ]]; then
- rotate_keys rollback-ready
- phase=idle
-fi
-if [[ "$phase" == "ready" || "$phase" == "grace" ]]; then
- rotate_keys commit
-fi
+source /home/cloud-compose/profile.sh
+exec sitectl host keys app "${1:-rotate}"
diff --git a/rootfs/home/cloud-compose/rotate-keys-daily.sh b/rootfs/home/cloud-compose/rotate-keys-daily.sh
deleted file mode 100755
index 8d8e60e..0000000
--- a/rootfs/home/cloud-compose/rotate-keys-daily.sh
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_rotate_keys_daily_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_rotate_keys_daily_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_rotate_keys_daily_source script_dir _cc_rotate_keys_daily_installed_home
-if [[ -n "$_cc_rotate_keys_daily_installed_home" &&
- ( "$_cc_rotate_keys_daily_installed_home" == "/" ||
- "$_cc_rotate_keys_daily_source" == "${_cc_rotate_keys_daily_installed_home%/}/"* ) ]]; then
- _cc_rotate_keys_daily_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_rotate_keys_daily_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_rotate_keys_daily_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_rotate_keys_daily_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_rotate_keys_daily_source" CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh "$script_dir/profile.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-readonly profile_path
-
-# shellcheck disable=SC1090
-source "$profile_path"
-
-case "${CLOUD_COMPOSE_PROVIDER:-}" in
- gcp)
- ;;
- "")
- echo "CLOUD_COMPOSE_PROVIDER is required for service-account key rotation" >&2
- exit 1
- ;;
- *)
- exit 0
- ;;
-esac
-
-case "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" in
- true) bash "$script_dir/rotate-keys-internal.sh" ;;
- false) ;;
- *)
- echo "LIBOPS_INTERNAL_SERVICES_ENABLED must be true or false" >&2
- exit 2
- ;;
-esac
-
-# The app wrapper is also the fail-closed stale-credential check when managed
-# file credentials are disabled, so run it in both states.
-bash "$script_dir/rotate-keys-app.sh"
diff --git a/rootfs/home/cloud-compose/rotate-keys-internal.sh b/rootfs/home/cloud-compose/rotate-keys-internal.sh
deleted file mode 100755
index 6641109..0000000
--- a/rootfs/home/cloud-compose/rotate-keys-internal.sh
+++ /dev/null
@@ -1,130 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_rotate_keys_internal_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_rotate_keys_internal_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_rotate_keys_internal_source script_dir _cc_rotate_keys_internal_installed_home
-if [[ -n "$_cc_rotate_keys_internal_installed_home" &&
- ( "$_cc_rotate_keys_internal_installed_home" == "/" ||
- "$_cc_rotate_keys_internal_source" == "${_cc_rotate_keys_internal_installed_home%/}/"* ) ]]; then
- _cc_rotate_keys_internal_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_rotate_keys_internal_checked_programs="$script_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_rotate_keys_internal_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_rotate_keys_internal_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_rotate_keys_internal_source" CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh "$script_dir/profile.sh"
-cloud_compose_bind_source_program \
- "$_cc_rotate_keys_internal_source" CLOUD_COMPOSE_ROTATE_KEYS_PATH \
- /home/cloud-compose/rotate-keys.sh "$script_dir/rotate-keys.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-rotate_keys_script="$CLOUD_COMPOSE_ROTATE_KEYS_PATH"
-readonly profile_path rotate_keys_script
-
-# shellcheck disable=SC1090
-source "$profile_path"
-
-case "${CLOUD_COMPOSE_PROVIDER:-}" in
- gcp) ;;
- "")
- echo "CLOUD_COMPOSE_PROVIDER is required for service-account key rotation" >&2
- exit 1
- ;;
- *) exit 0 ;;
-esac
-
-INTERNAL_CREDENTIALS_FILE="${INTERNAL_CREDENTIALS_FILE:-/mnt/disks/data/libops-internal/GOOGLE_APPLICATION_CREDENTIALS}"
-ROTATION_CREDENTIAL_GROUP="${ROTATION_CREDENTIAL_GROUP-cloud-compose}"
-CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER="${CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER:-/mnt/disks/data/.cloud-compose/fresh-filesystem}"
-CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY="${CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY:-}"
-rotation_reconcile_orphans=false
-internal_service_account="internal-$GCP_INSTANCE_NAME@$GCP_PROJECT.iam.gserviceaccount.com"
-rotation_action="${1:-rotate}"
-
-if [[ -n "$ROTATION_CREDENTIAL_GROUP" &&
- ! "$ROTATION_CREDENTIAL_GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]]; then
- echo "ROTATION_CREDENTIAL_GROUP must be empty or a safe local group name" >&2
- exit 2
-fi
-
-case "$rotation_action" in
- rotate | rollback) ;;
- *)
- echo "Usage: $0 [rotate|rollback]" >&2
- exit 2
- ;;
-esac
-
-rotate_keys() {
- ROTATION_RECONCILE_ORPHANS="$rotation_reconcile_orphans" \
- CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER="$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" \
- CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY="$CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY" \
- bash "$rotate_keys_script" "$1" \
- "$internal_service_account" \
- "$GCP_PROJECT" \
- "$INTERNAL_CREDENTIALS_FILE"
-}
-
-require_inactive_internal_service() {
- local load_state active_state
-
- load_state="$(systemctl show --property=LoadState --value -- cloud-compose-internal-services.service)" || {
- echo "Could not determine whether cloud-compose-internal-services.service is loaded" >&2
- return 1
- }
- active_state="$(systemctl show --property=ActiveState --value -- cloud-compose-internal-services.service)" || {
- echo "Could not determine whether cloud-compose-internal-services.service is inactive" >&2
- return 1
- }
- if [[ "$load_state" != "loaded" || "$active_state" != "inactive" ]]; then
- echo "Fresh-filesystem key reconciliation requires loaded, inactive cloud-compose-internal-services.service; observed ${load_state}/${active_state}" >&2
- return 1
- fi
-}
-
-if [[ ! -e "$INTERNAL_CREDENTIALS_FILE" && ! -L "$INTERNAL_CREDENTIALS_FILE" &&
- ( -e "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" || -L "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" ) ]]; then
- require_inactive_internal_service
- rotation_reconcile_orphans=true
-fi
-
-if [[ "$rotation_action" == "rollback" ]]; then
- rotate_keys rollback
-else
- rotate_keys prepare
-fi
-phase="$(rotate_keys status)"
-
-if [[ "$phase" == "staged" ]]; then
- rotate_keys authenticate
- phase=authenticated
-fi
-
-if [[ -n "$ROTATION_CREDENTIAL_GROUP" ]]; then
- chgrp -- "$ROTATION_CREDENTIAL_GROUP" "$INTERNAL_CREDENTIALS_FILE"
-fi
-
-if [[ "$phase" == "authenticated" || "$phase" == "rollback" ]]; then
- # Recreate containers so their credential bind mounts reference the newly
- # installed inode, but do not start an intentionally inactive service.
- if systemctl is-active --quiet cloud-compose-internal-services.service; then
- systemctl restart cloud-compose-internal-services.service
- fi
-fi
-
-if [[ "$phase" == "authenticated" ]]; then
- rotate_keys ready
- phase=ready
-fi
-if [[ "$phase" == "rollback" ]]; then
- rotate_keys rollback-ready
- phase=idle
-fi
-if [[ "$phase" == "ready" || "$phase" == "grace" ]]; then
- rotate_keys commit
-fi
diff --git a/rootfs/home/cloud-compose/rotate-keys.sh b/rootfs/home/cloud-compose/rotate-keys.sh
deleted file mode 100755
index 7161577..0000000
--- a/rootfs/home/cloud-compose/rotate-keys.sh
+++ /dev/null
@@ -1,1384 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_rotate_keys_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_rotate_keys_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_rotate_keys_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_rotate_keys_source _cc_rotate_keys_dir _cc_rotate_keys_installed_home
-if [[ -n "$_cc_rotate_keys_installed_home" &&
- ( "$_cc_rotate_keys_installed_home" == "/" ||
- "$_cc_rotate_keys_source" == "${_cc_rotate_keys_installed_home%/}/"* ) ]]; then
- _cc_rotate_keys_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_rotate_keys_checked_programs="$_cc_rotate_keys_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_rotate_keys_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_rotate_keys_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_rotate_keys_source" \
- CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh \
- "$_cc_rotate_keys_dir/profile.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-readonly profile_path
-
-# shellcheck disable=SC1090
-source "$profile_path"
-
-# Reload the fixed resolver after the profile so an installed environment
-# cannot replace the binding functions used for checked data programs.
-# shellcheck disable=SC1090
-source "$_cc_rotate_keys_checked_programs"
-cloud_compose_bind_program_dir \
- "$_cc_rotate_keys_source" \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR \
- /etc/cloud-compose/jq \
- "$_cc_rotate_keys_dir/../../etc/cloud-compose/jq" \
- service-account-key-id.jq \
- rotation-build-state.jq \
- rotation-validate-state.jq \
- rotation-normalize-user-keys.jq \
- service-account-credentials-valid.jq \
- rotation-jwt-claims.jq \
- rotation-remaining-baseline.jq \
- rotation-new-key-names.jq \
- rotation-audit.jq \
- rotation-first-key-id.jq \
- rotation-idle-audit.jq \
- rotation-key-disabled.jq \
- rotation-key-ids-join.jq \
- rotation-key-names-base64.jq \
- rotation-key-names-sorted.jq \
- rotation-key-object-ids-join.jq \
- rotation-key-present.jq \
- array-values-base64.jq \
- json-length.jq \
- nonempty-string-field.jq \
- object-field-delimited.jq \
- object-field.jq \
- string-field-valid.jq
-
-log_info() {
- printf '[key-rotation] %s\n' "$1" >&2
-}
-
-log_error() {
- printf '[key-rotation] ERROR: %s\n' "$1" >&2
-}
-
-usage() {
- cat >&2 < maximum)); then
- log_error "$name must be an integer from $minimum through $maximum"
- return 1
- fi
-}
-
-validate_integer_setting ROTATION_MIN_AGE_SECONDS "$ROTATION_MIN_AGE_SECONDS" 0 315360000 || exit 2
-validate_integer_setting ROTATION_DISABLE_GRACE_SECONDS "$ROTATION_DISABLE_GRACE_SECONDS" 0 31536000 || exit 2
-validate_integer_setting ROTATION_AUTH_MAX_RETRIES "$ROTATION_AUTH_MAX_RETRIES" 1 20 || exit 2
-validate_integer_setting ROTATION_AUTH_SLEEP_SECONDS "$ROTATION_AUTH_SLEEP_SECONDS" 0 300 || exit 2
-validate_integer_setting ROTATION_RECOVERY_SETTLE_SECONDS "$ROTATION_RECOVERY_SETTLE_SECONDS" 0 3600 || exit 2
-if [[ -n "$ROTATION_CREDENTIAL_OWNER" &&
- ! "$ROTATION_CREDENTIAL_OWNER" =~ ^([0-9]+|[a-z_][a-z0-9_-]{0,31}\$?)$ ]]; then
- log_error "ROTATION_CREDENTIAL_OWNER must be empty, a numeric UID, or a safe local account name"
- exit 2
-fi
-case "$ROTATION_RECONCILE_ORPHANS" in
- true | false) ;;
- *)
- log_error "ROTATION_RECONCILE_ORPHANS must be true or false"
- exit 2
- ;;
-esac
-
-if [[ -L "$credentials_dir" || ( -e "$credentials_dir" && ! -d "$credentials_dir" ) ]]; then
- log_error "Credentials directory is unsafe: $credentials_dir"
- exit 1
-fi
-install -d -m 0750 -- "$credentials_dir"
-if [[ -L "$credentials_dir" || ! -d "$credentials_dir" ]]; then
- log_error "Credentials directory did not resolve to a regular directory: $credentials_dir"
- exit 1
-fi
-
-exec 9>"$ROTATION_LOCK"
-if command -v flock >/dev/null 2>&1 && ! flock -w 30 9; then
- log_error "Timed out waiting for rotation lock: $ROTATION_LOCK"
- exit 1
-fi
-
-STATE_PHASE=""
-STATE_CURRENT_KEY_ID=""
-STATE_NEW_KEY_ID=""
-STATE_NEW_KEY_NAME=""
-STATE_BASELINE_KEY_NAMES='[]'
-STATE_CREATED_AT=0
-STATE_READY_AT=0
-STATE_DISABLED_AT=0
-ACCESS_TOKEN=""
-KEY_OPERATION_RESULT=""
-EPHEMERAL_CREATE_RESPONSE=""
-
-cleanup_ephemeral_create_response() {
- if [[ -n "$EPHEMERAL_CREATE_RESPONSE" ]]; then
- rm -f -- "$EPHEMERAL_CREATE_RESPONSE"
- EPHEMERAL_CREATE_RESPONSE=""
- fi
-}
-
-trap cleanup_ephemeral_create_response EXIT
-trap 'exit 129' HUP
-trap 'exit 130' INT
-trap 'exit 143' TERM
-
-now_epoch() {
- date +%s
-}
-
-valid_iam_key_id() {
- local key_id="$1"
-
- [[ "$key_id" =~ ^[A-Za-z0-9_-]+$ ]]
-}
-
-credential_key_id() {
- local file="$1" key_id
-
- key_id="$(jq -jr -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/service-account-key-id.jq" \
- "$file")" || return 1
- key_id="${key_id%$'\x1f'}"
- valid_iam_key_id "$key_id" || return 1
- printf '%s\n' "$key_id"
-}
-
-valid_iam_key_name() {
- local key_name="$1"
- local prefix="$SA_RESOURCE/keys/"
- local key_id
-
- [[ "$key_name" == "$prefix"* ]] || return 1
- key_id="${key_name#"$prefix"}"
- valid_iam_key_id "$key_id" && [[ "$key_name" == "$prefix$key_id" ]]
-}
-
-require_fresh_reconciliation_authority() {
- local marker_dir marker_root marker_identity marker_dir_identity marker_payload
- local marker_root_identity marker_size
-
- if [[ "$ROTATION_RECONCILE_ORPHANS" != "true" ]]; then
- log_error "Managed orphan-key reconciliation was not authorized"
- return 1
- fi
- if [[ ! "$CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY" =~ ^v1:gcp-disk-id:[0-9]{1,32}$ ]]; then
- log_error "GCP fresh-filesystem identity is missing or unsafe"
- return 1
- fi
- if [[ "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" != /* ||
- "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" == "/" ||
- "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" == *$'\n'* ||
- "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" == *$'\r'* ||
- "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" =~ (^|/)\.\.?(/|$) ]]; then
- log_error "Fresh-filesystem marker path is unsafe: $CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER"
- return 1
- fi
- marker_dir="$(dirname -- "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER")"
- marker_root="$(dirname -- "$marker_dir")"
- if [[ -L "$marker_root" || ! -d "$marker_root" ||
- -L "$marker_dir" || ! -d "$marker_dir" ||
- -L "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" ||
- ! -f "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" ]]; then
- log_error "Fresh-filesystem marker is missing or unsafe: $CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER"
- return 1
- fi
- marker_root_identity="$(stat -c '%u:%a' -- "$marker_root")" || return 1
- marker_dir_identity="$(stat -c '%u:%g:%a' -- "$marker_dir")" || return 1
- marker_identity="$(stat -c '%u:%g:%a:%h' -- "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER")" || return 1
- if [[ "$marker_root_identity" != "0:1775" ||
- "$marker_dir_identity" != "0:0:700" ||
- "$marker_identity" != "0:0:600:1" ]]; then
- log_error "Fresh-filesystem marker ownership or mode is unsafe: $CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER"
- return 1
- fi
- marker_size="$(stat -c '%s' -- "$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER")" || return 1
- marker_payload=""
- if [[ "$marker_size" != "$((${#CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY} + 1))" ]] ||
- ! IFS= read -r marker_payload <"$CLOUD_COMPOSE_FRESH_FILESYSTEM_MARKER" ||
- [[ "$marker_payload" != "$CLOUD_COMPOSE_FRESH_FILESYSTEM_IDENTITY" ]]; then
- log_error "Fresh-filesystem marker does not match this GCP data disk incarnation"
- return 1
- fi
-}
-
-write_state() {
- local state_tmp
-
- state_tmp="$(mktemp "${PENDING_STATE}.tmp.XXXXXX")" || return 1
- if ! jq -n \
- --arg phase "$STATE_PHASE" \
- --arg service_account "$SERVICE_ACCOUNT" \
- --arg project_id "$PROJECT_ID" \
- --arg credentials_file "$CREDENTIALS_FILE" \
- --arg current_key_id "$STATE_CURRENT_KEY_ID" \
- --arg new_key_id "$STATE_NEW_KEY_ID" \
- --arg new_key_name "$STATE_NEW_KEY_NAME" \
- --argjson baseline_key_names "$STATE_BASELINE_KEY_NAMES" \
- --argjson created_at "$STATE_CREATED_AT" \
- --argjson ready_at "$STATE_READY_AT" \
- --argjson disabled_at "$STATE_DISABLED_AT" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-build-state.jq" \
- >"$state_tmp"; then
- rm -f -- "$state_tmp"
- return 1
- fi
- chmod 0600 "$state_tmp"
- mv -f -- "$state_tmp" "$PENDING_STATE"
- # IAM mutation must never outrun the exact state that makes its retry
- # idempotent. A global sync is available on COS and flushes both the
- # renamed file and its parent-directory entry.
- sync
-}
-
-load_state() {
- local payload encoded_baseline_name baseline_name
-
- if [[ -L "$PENDING_STATE" || ! -f "$PENDING_STATE" ]]; then
- log_error "Pending rotation state is missing or unsafe: $PENDING_STATE"
- return 1
- fi
- payload="$(jq -ce \
- --arg service_account "$SERVICE_ACCOUNT" \
- --arg project_id "$PROJECT_ID" \
- --arg credentials_file "$CREDENTIALS_FILE" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-validate-state.jq" \
- "$PENDING_STATE")" || {
- log_error "Pending rotation state is invalid or belongs to another target: $PENDING_STATE"
- return 1
- }
-
- STATE_PHASE="$(jq -r --arg field phase \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" <<<"$payload")"
- STATE_CURRENT_KEY_ID="$(jq -jr --arg field current_key_id \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" <<<"$payload")"
- STATE_CURRENT_KEY_ID="${STATE_CURRENT_KEY_ID%$'\x1f'}"
- STATE_NEW_KEY_ID="$(jq -jr --arg field new_key_id \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" <<<"$payload")"
- STATE_NEW_KEY_ID="${STATE_NEW_KEY_ID%$'\x1f'}"
- STATE_NEW_KEY_NAME="$(jq -jr --arg field new_key_name \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field-delimited.jq" <<<"$payload")"
- STATE_NEW_KEY_NAME="${STATE_NEW_KEY_NAME%$'\x1f'}"
- STATE_BASELINE_KEY_NAMES="$(jq -c --arg field baseline_key_names \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" <<<"$payload")"
- STATE_CREATED_AT="$(jq -r --arg field created_at \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" <<<"$payload")"
- STATE_READY_AT="$(jq -r --arg field ready_at \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" <<<"$payload")"
- STATE_DISABLED_AT="$(jq -r --arg field disabled_at \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" <<<"$payload")"
-
- if [[ -n "$STATE_CURRENT_KEY_ID" && ! "$STATE_CURRENT_KEY_ID" =~ ^[A-Za-z0-9_-]+$ ]]; then
- log_error "Pending rotation state contains an invalid previous key ID"
- return 1
- fi
- while IFS= read -r encoded_baseline_name; do
- baseline_name="$(
- if ! printf '%s' "$encoded_baseline_name" | base64 --decode; then
- exit 1
- fi
- printf '\037'
- )" || return 1
- baseline_name="${baseline_name%$'\x1f'}"
- if ! valid_iam_key_name "$baseline_name"; then
- log_error "Pending rotation state contains an invalid baseline key name"
- return 1
- fi
- done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/array-values-base64.jq" \
- <<<"$STATE_BASELINE_KEY_NAMES")
-
- case "$STATE_PHASE" in
- staged | authenticated | ready | grace | rolling-back | rollback | revoke-new)
- if [[ ! "$STATE_NEW_KEY_ID" =~ ^[A-Za-z0-9_-]+$ ]] ||
- [[ "$STATE_NEW_KEY_NAME" != "$SA_RESOURCE/keys/$STATE_NEW_KEY_ID" ]]; then
- log_error "Pending rotation state contains an invalid replacement key"
- return 1
- fi
- ;;
- esac
-}
-
-cleanup_state() {
- # A stale cleanup is safe: every preceding cloud mutation targets the
- # exact persisted key name and its retry accepts the already-absent state.
- # Durability barriers are required before mutations, not after removing
- # retry metadata for an operation that has already converged.
- rm -f -- "$STAGED_CREDENTIALS" "$PREVIOUS_CREDENTIALS" \
- "$REPLACEMENT_CREDENTIALS" "$PENDING_STATE"
-}
-
-fetch_access_token() {
- local token_response
-
- token_response="$(curl -fsS --connect-timeout 2 --max-time 10 \
- -H "Metadata-Flavor: Google" \
- "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token")" || {
- log_error "Failed to get access token from metadata server"
- return 1
- }
- ACCESS_TOKEN="$(jq -er --arg field access_token \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/nonempty-string-field.jq" \
- <<<"$token_response")" || {
- log_error "Metadata server returned an invalid access-token response"
- return 1
- }
-}
-
-write_access_header_file() {
- local header_tmp
-
- header_tmp="$(mktemp "${credentials_dir}/.iam-header.XXXXXX")" || return 1
- chmod 0600 "$header_tmp"
- printf 'Authorization: Bearer %s\n' "$ACCESS_TOKEN" >"$header_tmp"
- ACCESS_HEADER_FILE="$header_tmp"
-}
-
-list_user_keys() {
- local keys_response normalized_keys encoded_key_name key_name curl_status=0
-
- write_access_header_file || return 1
- keys_response="$(curl -fsS --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 120 \
- --max-filesize 1048576 \
- --connect-timeout 5 --max-time 30 \
- -H "@$ACCESS_HEADER_FILE" \
- "https://iam.googleapis.com/v1/$SA_RESOURCE/keys")" || curl_status=$?
- rm -f -- "$ACCESS_HEADER_FILE"
- if ((curl_status != 0)); then
- log_error "Failed to list service-account keys"
- return 1
- fi
- normalized_keys="$(jq -cer --arg prefix "$SA_RESOURCE/keys/" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-normalize-user-keys.jq" \
- <<<"$keys_response")" || {
- log_error "IAM returned an invalid user-managed key inventory"
- return 1
- }
- while IFS= read -r encoded_key_name; do
- key_name="$(
- if ! printf '%s' "$encoded_key_name" | base64 --decode; then
- exit 1
- fi
- printf '\037'
- )" || return 1
- key_name="${key_name%$'\x1f'}"
- if ! valid_iam_key_name "$key_name"; then
- log_error "IAM returned an invalid user-managed key name"
- return 1
- fi
- done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-names-base64.jq" \
- <<<"$normalized_keys")
- printf '%s\n' "$normalized_keys"
-}
-
-list_user_key_names() {
- list_user_keys | jq -c -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-names-sorted.jq"
-}
-
-key_remote_status() {
- local key_name="$1" keys
-
- keys="$(list_user_keys)" || return 1
- if ! jq -e --arg name "$key_name" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-present.jq" \
- <<<"$keys" >/dev/null; then
- printf 'absent\n'
- elif jq -e --arg name "$key_name" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-disabled.jq" \
- <<<"$keys" >/dev/null; then
- printf 'disabled\n'
- else
- printf 'enabled\n'
- fi
-}
-
-iam_mutation() {
- local method="$1" url="$2" status response_tmp curl_status=0
- local -a curl_args
-
- response_tmp="$(mktemp "${credentials_dir}/.iam-response.XXXXXX")" || return 1
- chmod 0600 "$response_tmp"
- write_access_header_file || {
- rm -f -- "$response_tmp"
- return 1
- }
- curl_args=(-sS --connect-timeout 5 --max-time 30 \
- -X "$method" \
- -H "@$ACCESS_HEADER_FILE" \
- -o "$response_tmp" -w '%{http_code}')
- if [[ "$method" == "POST" ]]; then
- curl_args+=(-H "Content-Type: application/json" --data '{}')
- fi
- status="$(curl "${curl_args[@]}" "$url")" || curl_status=$?
- rm -f -- "$response_tmp" "$ACCESS_HEADER_FILE"
- if ((curl_status != 0)); then
- return 1
- fi
- if [[ "$status" == "404" ]]; then
- KEY_OPERATION_RESULT=absent
- return 0
- fi
- if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
- KEY_OPERATION_RESULT=success
- return 0
- fi
- return 1
-}
-
-delete_key() {
- local key_name="$1"
-
- iam_mutation DELETE "https://iam.googleapis.com/v1/$key_name"
-}
-
-disable_key() {
- local key_name="$1" remote_status
-
- remote_status="$(key_remote_status "$key_name")" || return 1
- case "$remote_status" in
- absent)
- KEY_OPERATION_RESULT=absent
- return 0
- ;;
- disabled)
- KEY_OPERATION_RESULT=success
- return 0
- ;;
- enabled)
- iam_mutation POST "https://iam.googleapis.com/v1/${key_name}:disable"
- ;;
- esac
-}
-
-enable_key() {
- local key_name="$1" remote_status
-
- remote_status="$(key_remote_status "$key_name")" || return 1
- case "$remote_status" in
- absent)
- log_error "Cannot enable absent rollback key $key_name"
- return 1
- ;;
- enabled)
- KEY_OPERATION_RESULT=success
- return 0
- ;;
- disabled)
- iam_mutation POST "https://iam.googleapis.com/v1/${key_name}:enable"
- [[ "$KEY_OPERATION_RESULT" != "absent" ]]
- ;;
- esac
-}
-
-validate_credentials_key() {
- local file="$1" expected_key_id="$2"
-
- [[ ! -L "$file" && -f "$file" ]] || return 1
- jq -e \
- --arg key_id "$expected_key_id" \
- --arg service_account "$SERVICE_ACCOUNT" \
- --arg project_id "$PROJECT_ID" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/service-account-credentials-valid.jq" \
- "$file" >/dev/null
-}
-
-install_credentials_file() {
- local source="$1" target="$2" mode="${3:-0440}"
- local credential_owner="${4-${ROTATION_CREDENTIAL_OWNER}}" target_tmp
-
- target_tmp="$(mktemp "${target}.tmp.XXXXXX")" || return 1
- if ! install -m "$mode" "$source" "$target_tmp"; then
- rm -f -- "$target_tmp"
- return 1
- fi
- if [[ -n "$credential_owner" ]] && ! chown -- "$credential_owner" "$target_tmp"; then
- rm -f -- "$target_tmp"
- return 1
- fi
- mv -f -- "$target_tmp" "$target"
-}
-
-preserve_previous_credentials() {
- if [[ -z "$STATE_CURRENT_KEY_ID" ]]; then
- return 0
- fi
- if validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID"; then
- return 0
- fi
- if [[ -e "$PREVIOUS_CREDENTIALS" || -L "$PREVIOUS_CREDENTIALS" ]]; then
- log_error "Previous credential backup is invalid or unsafe: $PREVIOUS_CREDENTIALS"
- return 1
- fi
- validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID" || {
- log_error "Current credentials cannot be preserved before replacement"
- return 1
- }
- install_credentials_file "$CREDENTIALS_FILE" "$PREVIOUS_CREDENTIALS" 0400 root
-}
-
-install_staged_credentials() {
- validate_credentials_key "$STAGED_CREDENTIALS" "$STATE_NEW_KEY_ID" || {
- log_error "Staged replacement credentials are invalid"
- return 1
- }
- preserve_previous_credentials || return 1
- install_credentials_file "$STAGED_CREDENTIALS" "$CREDENTIALS_FILE" 0440 || return 1
- rm -f -- "$STAGED_CREDENTIALS"
-}
-
-base64url() {
- base64 | tr -d '\n=' | tr '+/' '-_'
-}
-
-authenticate_credentials_once() {
- local file="$1" expected_key_id="$2"
- local email token_uri private_key_tmp request_tmp response_tmp
- local header payload signing_input signature assertion now curl_status=0
-
- validate_credentials_key "$file" "$expected_key_id" || return 1
- command -v openssl >/dev/null 2>&1 || {
- log_error "openssl is required to authenticate replacement credentials"
- return 1
- }
- email="$(jq -er --arg field client_email \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" "$file")" || return 1
- token_uri="$(jq -er --arg field token_uri \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" "$file")" || return 1
- private_key_tmp="$(mktemp "${credentials_dir}/.auth-key.XXXXXX")" || return 1
- request_tmp="$(mktemp "${credentials_dir}/.auth-request.XXXXXX")" || {
- rm -f -- "$private_key_tmp"
- return 1
- }
- response_tmp="$(mktemp "${credentials_dir}/.auth-response.XXXXXX")" || {
- rm -f -- "$private_key_tmp" "$request_tmp"
- return 1
- }
- chmod 0600 "$private_key_tmp" "$request_tmp" "$response_tmp"
- jq -er --arg field private_key \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/object-field.jq" \
- "$file" >"$private_key_tmp" || {
- rm -f -- "$private_key_tmp" "$request_tmp" "$response_tmp"
- return 1
- }
-
- now="$(now_epoch)"
- header="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | base64url)"
- payload="$(jq -cn \
- --arg iss "$email" \
- --arg aud "$token_uri" \
- --argjson iat "$now" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-jwt-claims.jq" | base64url)"
- signing_input="${header}.${payload}"
- signature="$(printf '%s' "$signing_input" | openssl dgst -sha256 -sign "$private_key_tmp" | base64url)" || {
- rm -f -- "$private_key_tmp" "$request_tmp" "$response_tmp"
- return 1
- }
- assertion="${signing_input}.${signature}"
- printf 'grant_type=urn%%3Aietf%%3Aparams%%3Aoauth%%3Agrant-type%%3Ajwt-bearer&assertion=%s' \
- "$assertion" >"$request_tmp"
- curl -fsS --connect-timeout 5 --max-time 30 \
- -H 'Content-Type: application/x-www-form-urlencoded' \
- --data-binary "@$request_tmp" \
- -o "$response_tmp" \
- "$token_uri" || curl_status=$?
- rm -f -- "$private_key_tmp" "$request_tmp"
- if ((curl_status != 0)) ||
- ! jq -e --arg field access_token \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/string-field-valid.jq" \
- "$response_tmp" >/dev/null; then
- rm -f -- "$response_tmp"
- return 1
- fi
- rm -f -- "$response_tmp"
-}
-
-authenticate_credentials_with_backoff() {
- local file="$1" expected_key_id="$2" attempt sleep_seconds
-
- for ((attempt = 1; attempt <= 10#$ROTATION_AUTH_MAX_RETRIES; attempt++)); do
- if authenticate_credentials_once "$file" "$expected_key_id"; then
- log_info "Authenticated service-account key $expected_key_id"
- return 0
- fi
- if ((attempt == 10#$ROTATION_AUTH_MAX_RETRIES)); then
- break
- fi
- sleep_seconds=$((10#$ROTATION_AUTH_SLEEP_SECONDS * attempt))
- log_info "Replacement credential authentication is not ready; retrying in ${sleep_seconds}s (${attempt}/${ROTATION_AUTH_MAX_RETRIES})"
- sleep "$sleep_seconds"
- done
- log_error "Replacement credentials could not authenticate after $ROTATION_AUTH_MAX_RETRIES attempts"
- return 1
-}
-
-reset_after_definite_create_rejection() {
- if [[ "$STATE_PHASE" == "creating-fresh" ]]; then
- STATE_PHASE=reconciling
- STATE_BASELINE_KEY_NAMES='[]'
- STATE_CREATED_AT="$(now_epoch)"
- write_state
- else
- cleanup_state
- fi
-}
-
-definite_create_rejection_status() {
- case "$1" in
- 400 | 401 | 403 | 404 | 409 | 429) return 0 ;;
- *) return 1 ;;
- esac
-}
-
-prepare_rotation_runtime_dir() {
- local runtime_identity
-
- if [[ "$ROTATION_RUNTIME_DIR" != /* || "$ROTATION_RUNTIME_DIR" == "/" ||
- "$ROTATION_RUNTIME_DIR" == *$'\n'* || "$ROTATION_RUNTIME_DIR" == *$'\r'* ||
- "$ROTATION_RUNTIME_DIR" =~ (^|/)\.\.?(/|$) ||
- -L "$ROTATION_RUNTIME_DIR" ||
- ( -e "$ROTATION_RUNTIME_DIR" && ! -d "$ROTATION_RUNTIME_DIR" ) ]]; then
- log_error "Rotation runtime directory is unsafe: $ROTATION_RUNTIME_DIR"
- return 1
- fi
- install -d -m 0700 -- "$ROTATION_RUNTIME_DIR" || return 1
- if [[ -L "$ROTATION_RUNTIME_DIR" || ! -d "$ROTATION_RUNTIME_DIR" ]]; then
- log_error "Rotation runtime directory changed during preparation: $ROTATION_RUNTIME_DIR"
- return 1
- fi
- runtime_identity="$(stat -c '%u:%a' -- "$ROTATION_RUNTIME_DIR")" || return 1
- if [[ "$runtime_identity" != "${EUID}:700" ]]; then
- log_error "Rotation runtime directory has unsafe ownership or mode: $ROTATION_RUNTIME_DIR"
- return 1
- fi
-}
-
-create_replacement_key() {
- local response_tmp http_status="" curl_status=0
- local new_key_name="" new_key_id private_key_data="" staged_tmp
-
- prepare_rotation_runtime_dir || {
- reset_after_definite_create_rejection
- return 1
- }
- response_tmp="$(mktemp "${ROTATION_RUNTIME_DIR}/iam-create-response.XXXXXX")" || {
- reset_after_definite_create_rejection
- return 1
- }
- EPHEMERAL_CREATE_RESPONSE="$response_tmp"
- chmod 0600 "$response_tmp"
- if ! write_access_header_file; then
- cleanup_ephemeral_create_response
- reset_after_definite_create_rejection
- return 1
- fi
-
- log_info "Creating replacement key for $SERVICE_ACCOUNT"
- http_status="$(curl -sS --connect-timeout 5 --max-time 30 -X POST \
- -H "@$ACCESS_HEADER_FILE" \
- -H "Content-Type: application/json" \
- -o "$response_tmp" -w '%{http_code}' \
- "https://iam.googleapis.com/v1/$SA_RESOURCE/keys")" || curl_status=$?
- rm -f -- "$ACCESS_HEADER_FILE"
-
- new_key_name="$(jq -er --arg field name \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/nonempty-string-field.jq" \
- "$response_tmp" 2>/dev/null)" || new_key_name=""
- if [[ -z "$new_key_name" ]]; then
- cleanup_ephemeral_create_response
- if definite_create_rejection_status "$http_status"; then
- reset_after_definite_create_rejection
- log_error "IAM rejected key creation with HTTP $http_status; no key was created"
- else
- log_error "Key creation had an indeterminate result; pending state prevents another key until audited recovery"
- fi
- return 1
- fi
- new_key_id="${new_key_name##*/}"
- if [[ ! "$new_key_id" =~ ^[A-Za-z0-9_-]+$ ]] ||
- [[ "$new_key_name" != "$SA_RESOURCE/keys/$new_key_id" ]]; then
- cleanup_ephemeral_create_response
- log_error "Key creation response contained an unexpected resource name; use audited recovery"
- return 1
- fi
-
- STATE_NEW_KEY_ID="$new_key_id"
- STATE_NEW_KEY_NAME="$new_key_name"
- private_key_data="$(jq -er --arg field privateKeyData \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/nonempty-string-field.jq" \
- "$response_tmp" 2>/dev/null)" || private_key_data=""
- cleanup_ephemeral_create_response
- if [[ -z "$private_key_data" ]]; then
- STATE_PHASE=revoke-new
- write_state
- if delete_key "$new_key_name"; then
- cleanup_state
- fi
- log_error "Key creation response omitted replacement credentials"
- return 1
- fi
- if ((curl_status != 0)); then
- log_info "Recovered a complete replacement credential from an interrupted IAM response"
- fi
-
- staged_tmp="$(mktemp "${STAGED_CREDENTIALS}.tmp.XXXXXX")" || return 1
- if ! printf '%s' "$private_key_data" | base64 -d >"$staged_tmp" ||
- ! validate_credentials_key "$staged_tmp" "$new_key_id"; then
- rm -f -- "$staged_tmp"
- STATE_PHASE=revoke-new
- write_state
- if delete_key "$new_key_name"; then
- cleanup_state
- fi
- log_error "IAM returned invalid replacement credentials"
- return 1
- fi
- chmod 0600 "$staged_tmp"
- mv -f -- "$staged_tmp" "$STAGED_CREDENTIALS"
-
- STATE_PHASE=staged
- write_state
- if ! install_staged_credentials; then
- STATE_PHASE=revoke-new
- write_state
- if delete_key "$new_key_name"; then
- cleanup_state
- fi
- log_error "Could not safely preserve and install replacement credentials"
- return 1
- fi
- log_info "Installed replacement key $new_key_id; previous credentials remain available for rollback"
-}
-
-finish_orphan_reconciliation() {
- local encoded_key_name key_name current_names remaining_baseline unexpected
- local remaining_ids unexpected_ids
-
- while IFS= read -r encoded_key_name; do
- key_name="$(
- if ! printf '%s' "$encoded_key_name" | base64 --decode; then
- exit 1
- fi
- printf '\037'
- )" || return 1
- key_name="${key_name%$'\x1f'}"
- valid_iam_key_name "$key_name" || return 1
- log_info "Deleting fresh-filesystem orphan key ${key_name##*/}"
- if ! delete_key "$key_name"; then
- log_error "Failed to delete fresh-filesystem orphan key ${key_name##*/}; reconciliation will retry"
- return 1
- fi
- done < <(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/array-values-base64.jq" \
- <<<"$STATE_BASELINE_KEY_NAMES")
-
- current_names="$(list_user_key_names)" || return 1
- remaining_baseline="$(jq -cn \
- --argjson before "$STATE_BASELINE_KEY_NAMES" \
- --argjson after "$current_names" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-remaining-baseline.jq")" || return 1
- unexpected="$(jq -cn \
- --argjson before "$STATE_BASELINE_KEY_NAMES" \
- --argjson after "$current_names" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-new-key-names.jq")" || return 1
-
- if [[ "$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
- <<<"$unexpected")" != "0" ]]; then
- unexpected_ids="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-ids-join.jq" \
- <<<"$unexpected")" || return 1
- log_error "Unexpected concurrent user-managed keys appeared during fresh-filesystem reconciliation: $unexpected_ids"
- return 1
- fi
- if [[ "$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
- <<<"$remaining_baseline")" != "0" ]]; then
- remaining_ids="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-ids-join.jq" \
- <<<"$remaining_baseline")" || return 1
- log_error "Deleted orphan keys are still visible and will be retried: $remaining_ids"
- return 1
- fi
-
- STATE_PHASE=creating-fresh
- STATE_BASELINE_KEY_NAMES='[]'
- STATE_CREATED_AT="$(now_epoch)"
- write_state
- create_replacement_key
-}
-
-begin_orphan_reconciliation() {
- require_fresh_reconciliation_authority || return 1
- fetch_access_token || return 1
-
- STATE_PHASE=reconciling
- STATE_CURRENT_KEY_ID=""
- STATE_NEW_KEY_ID=""
- STATE_NEW_KEY_NAME=""
- STATE_BASELINE_KEY_NAMES="$(list_user_key_names)" || return 1
- STATE_CREATED_AT="$(now_epoch)"
- STATE_READY_AT=0
- STATE_DISABLED_AT=0
- write_state
- finish_orphan_reconciliation
-}
-
-resume_pending_prepare() {
- local recovered_key_id
-
- load_state || return 1
- case "$STATE_PHASE" in
- reconciling)
- require_fresh_reconciliation_authority || return 1
- fetch_access_token || return 1
- finish_orphan_reconciliation
- ;;
- creating | creating-fresh)
- if [[ "$STATE_PHASE" == "creating-fresh" ]]; then
- require_fresh_reconciliation_authority || return 1
- fi
- if [[ -L "$STAGED_CREDENTIALS" || ! -f "$STAGED_CREDENTIALS" ]]; then
- log_error "Key creation has an ambiguous result; run audit, then recover with an explicitly confirmed orphan key ID"
- return 1
- fi
- recovered_key_id="$(credential_key_id "$STAGED_CREDENTIALS")" || {
- log_error "Could not recover replacement key ID from staged credentials"
- return 1
- }
- STATE_NEW_KEY_ID="$recovered_key_id"
- STATE_NEW_KEY_NAME="$SA_RESOURCE/keys/$STATE_NEW_KEY_ID"
- STATE_PHASE=staged
- write_state
- install_staged_credentials
- log_info "Recovered and installed staged key $STATE_NEW_KEY_ID"
- ;;
- staged | authenticated | ready | grace)
- validate_credentials_key "$CREDENTIALS_FILE" "$STATE_NEW_KEY_ID" || {
- if [[ "$STATE_PHASE" == "staged" ]] && validate_credentials_key "$STAGED_CREDENTIALS" "$STATE_NEW_KEY_ID"; then
- install_staged_credentials || return 1
- else
- log_error "Replacement key $STATE_NEW_KEY_ID is not installed at $CREDENTIALS_FILE"
- return 1
- fi
- }
- if [[ -n "$STATE_CURRENT_KEY_ID" ]] && ! validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID"; then
- log_error "Previous credentials required for grace/rollback are missing or invalid"
- return 1
- fi
- log_info "Resuming $STATE_PHASE rotation for key $STATE_NEW_KEY_ID"
- ;;
- rolling-back)
- if ! validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID"; then
- validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID" || return 1
- install_credentials_file "$PREVIOUS_CREDENTIALS" "$CREDENTIALS_FILE" 0440 || return 1
- fi
- STATE_PHASE=rollback
- write_state
- log_info "Resumed rollback to previous key $STATE_CURRENT_KEY_ID"
- ;;
- rollback)
- validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID" || {
- log_error "Rollback credentials are not installed"
- return 1
- }
- ;;
- revoke-new)
- fetch_access_token || return 1
- if ! delete_key "$STATE_NEW_KEY_NAME"; then
- log_error "Failed to delete unusable replacement key $STATE_NEW_KEY_ID"
- return 1
- fi
- cleanup_state
- log_error "Cleaned up an unusable replacement key; retry rotation on the next run"
- return 1
- ;;
- esac
-}
-
-prepare_rotation() {
- local current_key_id="" file_modified_at file_age_seconds
- local baseline_key_names baseline_count baseline_ids
-
- if [[ -L "$CREDENTIALS_FILE" || ( -e "$CREDENTIALS_FILE" && ! -f "$CREDENTIALS_FILE" ) ]]; then
- log_error "Credentials path is unsafe: $CREDENTIALS_FILE"
- return 1
- fi
- if [[ -e "$PENDING_STATE" || -L "$PENDING_STATE" ]]; then
- resume_pending_prepare
- return
- fi
-
- if [[ -f "$CREDENTIALS_FILE" ]]; then
- file_modified_at="$(stat -c %Y "$CREDENTIALS_FILE" 2>/dev/null || stat -f %m "$CREDENTIALS_FILE" 2>/dev/null)" || {
- log_error "Could not determine credentials age: $CREDENTIALS_FILE"
- return 1
- }
- file_age_seconds=$(( $(now_epoch) - file_modified_at ))
- if ((file_age_seconds < 10#$ROTATION_MIN_AGE_SECONDS)); then
- log_info "Credentials are younger than the rotation interval"
- return 0
- fi
- current_key_id="$(credential_key_id "$CREDENTIALS_FILE")" || {
- log_error "Existing credentials file is invalid: $CREDENTIALS_FILE"
- return 1
- }
- validate_credentials_key "$CREDENTIALS_FILE" "$current_key_id" || {
- log_error "Existing credentials file does not match the rotation target: $CREDENTIALS_FILE"
- return 1
- }
- elif [[ "$ROTATION_RECONCILE_ORPHANS" == "true" ]]; then
- begin_orphan_reconciliation
- return
- fi
-
- fetch_access_token || return 1
- baseline_key_names="$(list_user_key_names)" || return 1
- baseline_count="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
- <<<"$baseline_key_names")" || return 1
- if ((10#$baseline_count >= 10)); then
- baseline_ids="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-ids-join.jq" \
- <<<"$baseline_key_names")" || return 1
- log_error "Service account already has the 10 user-managed keys allowed by IAM: $baseline_ids"
- return 1
- fi
- STATE_PHASE=creating
- STATE_CURRENT_KEY_ID="$current_key_id"
- STATE_NEW_KEY_ID=""
- STATE_NEW_KEY_NAME=""
- STATE_BASELINE_KEY_NAMES="$baseline_key_names"
- STATE_CREATED_AT="$(now_epoch)"
- STATE_READY_AT=0
- STATE_DISABLED_AT=0
- write_state
- create_replacement_key
-}
-
-authenticate_rotation() {
- load_state || return 1
- case "$STATE_PHASE" in
- authenticated | ready | grace)
- return 0
- ;;
- staged)
- authenticate_credentials_with_backoff "$CREDENTIALS_FILE" "$STATE_NEW_KEY_ID" || return 1
- STATE_PHASE=authenticated
- write_state
- ;;
- *)
- log_error "Rotation cannot authenticate from phase $STATE_PHASE"
- return 1
- ;;
- esac
-}
-
-mark_rotation_ready() {
- load_state || return 1
- case "$STATE_PHASE" in
- ready | grace)
- return 0
- ;;
- authenticated)
- validate_credentials_key "$CREDENTIALS_FILE" "$STATE_NEW_KEY_ID" || {
- log_error "Authenticated replacement credentials are no longer installed"
- return 1
- }
- STATE_PHASE=ready
- STATE_READY_AT="$(now_epoch)"
- write_state
- log_info "Consumers are ready on authenticated replacement key $STATE_NEW_KEY_ID"
- ;;
- *)
- log_error "Rotation cannot become ready from phase $STATE_PHASE"
- return 1
- ;;
- esac
-}
-
-commit_rotation() {
- local current_key_name elapsed
-
- load_state || return 1
- validate_credentials_key "$CREDENTIALS_FILE" "$STATE_NEW_KEY_ID" || {
- log_error "Replacement credentials are not installed"
- return 1
- }
-
- if [[ "$STATE_PHASE" == "ready" ]]; then
- if [[ -z "$STATE_CURRENT_KEY_ID" ]]; then
- cleanup_state
- log_info "No previous local key was known; replacement committed without revocation"
- return 0
- fi
- if [[ "$STATE_CURRENT_KEY_ID" == "$STATE_NEW_KEY_ID" ]]; then
- log_error "Refusing to disable the installed replacement key"
- return 1
- fi
- validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID" || {
- log_error "Previous credentials are unavailable; refusing to remove rollback capability"
- return 1
- }
-
- current_key_name="$SA_RESOURCE/keys/$STATE_CURRENT_KEY_ID"
- fetch_access_token || return 1
- log_info "Disabling previous local key $STATE_CURRENT_KEY_ID"
- if ! disable_key "$current_key_name"; then
- log_error "Failed to disable previous local key $STATE_CURRENT_KEY_ID; ready state will retry"
- return 1
- fi
- if [[ "$KEY_OPERATION_RESULT" == "absent" ]]; then
- cleanup_state
- log_info "Previous local key was already absent; replacement committed"
- return 0
- fi
- STATE_PHASE=grace
- STATE_DISABLED_AT="$(now_epoch)"
- write_state
- log_info "Previous key is disabled and retained for a ${ROTATION_DISABLE_GRACE_SECONDS}s rollback grace period"
- return 0
- fi
-
- if [[ "$STATE_PHASE" != "grace" ]]; then
- log_error "Rotation cannot commit from phase $STATE_PHASE"
- return 1
- fi
- elapsed=$(( $(now_epoch) - STATE_DISABLED_AT ))
- if ((elapsed < 10#$ROTATION_DISABLE_GRACE_SECONDS)); then
- log_info "Previous key remains in rollback grace for $((10#$ROTATION_DISABLE_GRACE_SECONDS - elapsed))s"
- return 0
- fi
-
- current_key_name="$SA_RESOURCE/keys/$STATE_CURRENT_KEY_ID"
- fetch_access_token || return 1
- log_info "Deleting previous disabled key $STATE_CURRENT_KEY_ID after rollback grace"
- if ! delete_key "$current_key_name"; then
- log_error "Failed to delete previous key $STATE_CURRENT_KEY_ID; grace state will retry"
- return 1
- fi
- cleanup_state
- log_info "Committed replacement key $STATE_NEW_KEY_ID"
-}
-
-begin_rollback() {
- local current_key_name
-
- load_state || return 1
- case "$STATE_PHASE" in
- rollback)
- validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID" || return 1
- log_info "Rollback credentials are already restored; resuming consumer reload"
- return 0
- ;;
- rolling-back)
- validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID" || return 1
- if ! validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID"; then
- install_credentials_file "$PREVIOUS_CREDENTIALS" "$CREDENTIALS_FILE" 0440 || return 1
- fi
- STATE_PHASE=rollback
- write_state
- log_info "Resumed interrupted rollback to previous key $STATE_CURRENT_KEY_ID"
- return 0
- ;;
- grace) ;;
- *)
- log_error "Rollback is available only while the previous key is in grace"
- return 1
- ;;
- esac
- validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID" || {
- log_error "Previous credential backup is unavailable or invalid"
- return 1
- }
- validate_credentials_key "$CREDENTIALS_FILE" "$STATE_NEW_KEY_ID" || {
- log_error "Replacement credentials are unavailable; refusing an ambiguous rollback"
- return 1
- }
-
- current_key_name="$SA_RESOURCE/keys/$STATE_CURRENT_KEY_ID"
- fetch_access_token || return 1
- log_info "Re-enabling previous key $STATE_CURRENT_KEY_ID for rollback"
- enable_key "$current_key_name" || return 1
- authenticate_credentials_with_backoff "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID" || return 1
-
- install_credentials_file "$CREDENTIALS_FILE" "$REPLACEMENT_CREDENTIALS" 0400 root || return 1
- STATE_PHASE=rolling-back
- write_state
- install_credentials_file "$PREVIOUS_CREDENTIALS" "$CREDENTIALS_FILE" 0440 || return 1
- STATE_PHASE=rollback
- write_state
- log_info "Previous credentials restored; consumers must reload them before rollback completion"
-}
-
-finish_rollback() {
- load_state || return 1
- if [[ "$STATE_PHASE" != "rollback" ]]; then
- log_error "Rollback consumers cannot become ready from phase $STATE_PHASE"
- return 1
- fi
- validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID" || return 1
- authenticate_credentials_with_backoff "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID" || return 1
- fetch_access_token || return 1
- log_info "Disabling abandoned replacement key $STATE_NEW_KEY_ID"
- if ! disable_key "$STATE_NEW_KEY_NAME"; then
- log_error "Failed to disable abandoned replacement key; rollback state will retry"
- return 1
- fi
- if [[ "$KEY_OPERATION_RESULT" != "absent" ]]; then
- log_info "Deleting abandoned replacement key $STATE_NEW_KEY_ID"
- delete_key "$STATE_NEW_KEY_NAME" || return 1
- fi
- cleanup_state
- log_info "Rollback to key $STATE_CURRENT_KEY_ID completed"
-}
-
-retire_credentials() {
- local current_key_id current_key_name remaining_keys remaining_key_ids
-
- if [[ -e "$PENDING_STATE" || -L "$PENDING_STATE" ]]; then
- log_error "Resolve the pending rotation before retiring credentials: $PENDING_STATE"
- return 1
- fi
- if [[ ! -e "$CREDENTIALS_FILE" && ! -L "$CREDENTIALS_FILE" ]]; then
- # Local absence alone cannot prove revocation. The file may have been
- # removed manually while its remote private key remained valid. List
- # only USER_MANAGED keys and fail closed until the operator audits any
- # remaining IDs; never guess which externally managed key to delete.
- fetch_access_token || return 1
- remaining_keys="$(list_user_keys)" || return 1
- if [[ "$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
- <<<"$remaining_keys")" == "0" ]]; then
- log_info "No local credential or remote user-managed key remains; retirement is complete"
- return 0
- fi
- remaining_key_ids="$(jq -r \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-key-object-ids-join.jq" \
- <<<"$remaining_keys")" || return 1
- log_error "No local credential is available to identify the managed key, but remote user-managed keys remain: $remaining_key_ids"
- log_error "Audit and explicitly revoke the remaining key IDs before disabling managed credentials"
- return 1
- fi
- if [[ -L "$CREDENTIALS_FILE" || ! -f "$CREDENTIALS_FILE" ]]; then
- log_error "Credentials path is unsafe: $CREDENTIALS_FILE"
- return 1
- fi
- current_key_id="$(credential_key_id "$CREDENTIALS_FILE")" || {
- log_error "Existing credentials file is invalid: $CREDENTIALS_FILE"
- return 1
- }
- validate_credentials_key "$CREDENTIALS_FILE" "$current_key_id" || {
- log_error "Existing credentials do not match the retirement target"
- return 1
- }
- current_key_name="$SA_RESOURCE/keys/$current_key_id"
- fetch_access_token || return 1
- log_info "Deleting retired service-account key $current_key_id"
- if ! delete_key "$current_key_name"; then
- log_error "Failed to delete retired key $current_key_id"
- return 1
- fi
- rm -f -- "$CREDENTIALS_FILE" "$STAGED_CREDENTIALS" "$PREVIOUS_CREDENTIALS" \
- "$REPLACEMENT_CREDENTIALS"
- log_info "Retired local credentials for $SERVICE_ACCOUNT"
-}
-
-creation_candidates() {
- local current_names
-
- fetch_access_token || return 1
- current_names="$(list_user_key_names)" || return 1
- jq -cn --argjson before "$STATE_BASELINE_KEY_NAMES" --argjson after "$current_names" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-new-key-names.jq"
-}
-
-rotation_audit() {
- local candidates='[]' grace_remaining=0 elapsed
-
- if [[ ! -e "$PENDING_STATE" && ! -L "$PENDING_STATE" ]]; then
- jq -n -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-idle-audit.jq"
- return 0
- fi
- load_state || return 1
- if [[ "$STATE_PHASE" == "creating" || "$STATE_PHASE" == "creating-fresh" ]]; then
- candidates="$(creation_candidates)" || return 1
- fi
- if [[ "$STATE_PHASE" == "grace" ]]; then
- elapsed=$(( $(now_epoch) - STATE_DISABLED_AT ))
- if ((elapsed < 10#$ROTATION_DISABLE_GRACE_SECONDS)); then
- grace_remaining=$((10#$ROTATION_DISABLE_GRACE_SECONDS - elapsed))
- fi
- fi
- jq -n \
- --arg phase "$STATE_PHASE" \
- --arg current_key_id "$STATE_CURRENT_KEY_ID" \
- --arg new_key_id "$STATE_NEW_KEY_ID" \
- --argjson candidate_names "$candidates" \
- --argjson created_at "$STATE_CREATED_AT" \
- --argjson ready_at "$STATE_READY_AT" \
- --argjson disabled_at "$STATE_DISABLED_AT" \
- --argjson grace_remaining "$grace_remaining" \
- -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-audit.jq"
-}
-
-recover_ambiguous_creation() {
- local candidates candidate_id creation_age
-
- load_state || return 1
- if [[ "$STATE_PHASE" != "creating" && "$STATE_PHASE" != "creating-fresh" ]]; then
- log_error "Audited recovery is valid only for an ambiguous creating state"
- return 1
- fi
- creation_age=$(( $(now_epoch) - STATE_CREATED_AT ))
- if ((creation_age < 10#$ROTATION_RECOVERY_SETTLE_SECONDS)); then
- rotation_audit
- log_error "Ambiguous creation must settle for ${ROTATION_RECOVERY_SETTLE_SECONDS}s before recovery; no key was changed"
- return 1
- fi
- candidates="$(creation_candidates)" || return 1
- if [[ "$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
- <<<"$candidates")" == "0" ]]; then
- if [[ "$STATE_PHASE" == "creating-fresh" ]]; then
- STATE_PHASE=reconciling
- STATE_BASELINE_KEY_NAMES='[]'
- write_state
- log_info "Audit found no key after fresh reconciliation; retained empty-baseline state for a safe retry"
- else
- cleanup_state
- log_info "Audit found no key beyond the pre-create baseline; cleared ambiguous state"
- fi
- return 0
- fi
- if [[ "$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-length.jq" \
- <<<"$candidates")" != "1" ]]; then
- rotation_audit
- log_error "Recovery is ambiguous because more than one post-baseline key exists; no key was changed"
- return 1
- fi
- candidate_id="$(jq -r -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/rotation-first-key-id.jq" \
- <<<"$candidates")"
- if [[ -z "$RECOVERY_KEY_ID" || "$RECOVERY_KEY_ID" != "$candidate_id" ]]; then
- rotation_audit
- log_error "Recovery requires the single audited candidate key ID as the final argument; no key was changed"
- return 1
- fi
- fetch_access_token || return 1
- log_info "Deleting explicitly confirmed orphan key $candidate_id from ambiguous creation"
- delete_key "$SA_RESOURCE/keys/$candidate_id" || return 1
- if [[ "$STATE_PHASE" == "creating-fresh" ]]; then
- STATE_PHASE=reconciling
- STATE_BASELINE_KEY_NAMES='[]'
- write_state
- log_info "Audited fresh-key recovery completed; retained empty-baseline state for a safe retry"
- else
- cleanup_state
- log_info "Audited recovery completed; a later run may create a new replacement"
- fi
-}
-
-rotation_status() {
- if [[ ! -e "$PENDING_STATE" && ! -L "$PENDING_STATE" ]]; then
- printf 'idle\n'
- return 0
- fi
- load_state || return 1
- printf '%s\n' "$STATE_PHASE"
-}
-
-case "$ACTION" in
- prepare) prepare_rotation ;;
- status) rotation_status ;;
- audit) rotation_audit ;;
- recover) recover_ambiguous_creation ;;
- authenticate) authenticate_rotation ;;
- ready) mark_rotation_ready ;;
- commit) commit_rotation ;;
- rollback) begin_rollback ;;
- rollback-ready) finish_rollback ;;
- retire) retire_credentials ;;
-esac
diff --git a/rootfs/home/cloud-compose/run-bootstrap.sh b/rootfs/home/cloud-compose/run-bootstrap.sh
deleted file mode 100755
index 20e6ab4..0000000
--- a/rootfs/home/cloud-compose/run-bootstrap.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-if ((EUID != 0)); then
- echo "Cloud Compose bootstrap must run as root" >&2
- exit 1
-fi
-
-exec bash /home/cloud-compose/run.sh
diff --git a/rootfs/home/cloud-compose/run-rollout-service.sh b/rootfs/home/cloud-compose/run-rollout-service.sh
deleted file mode 100755
index 7857425..0000000
--- a/rootfs/home/cloud-compose/run-rollout-service.sh
+++ /dev/null
@@ -1,66 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_run_rollout_service_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_run_rollout_service_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_run_rollout_service_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_run_rollout_service_source _cc_run_rollout_service_dir _cc_run_rollout_service_installed_home
-if [[ -n "$_cc_run_rollout_service_installed_home" &&
- ( "$_cc_run_rollout_service_installed_home" == "/" ||
- "$_cc_run_rollout_service_source" == "${_cc_run_rollout_service_installed_home%/}/"* ) ]]; then
- _cc_run_rollout_service_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_run_rollout_service_checked_programs="$_cc_run_rollout_service_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_run_rollout_service_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_run_rollout_service_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_run_rollout_service_source" \
- CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh \
- "$_cc_run_rollout_service_dir/profile.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-readonly profile_path
-
-# shellcheck disable=SC1090,SC1091
-source "$profile_path"
-
-# Reload the fixed resolver after the profile before binding data programs.
-# shellcheck disable=SC1090
-source "$_cc_run_rollout_service_checked_programs"
-cloud_compose_bind_program_dir \
- "$_cc_run_rollout_service_source" \
- CLOUD_COMPOSE_JQ_PROGRAM_DIR \
- /etc/cloud-compose/jq \
- "$_cc_run_rollout_service_dir/../../etc/cloud-compose/jq" \
- json-object-validate.jq
-
-unset BASH_ENV ENV LD_PRELOAD LD_LIBRARY_PATH
-PORT="${ROLLOUT_PORT:?ROLLOUT_PORT is required}"
-JWKS_URI="${ROLLOUT_JWKS_URI:?ROLLOUT_JWKS_URI is required}"
-JWT_AUD="${ROLLOUT_JWT_AUD:?ROLLOUT_JWT_AUD is required}"
-CUSTOM_CLAIMS="${ROLLOUT_CUSTOM_CLAIMS:-}"
-
-if [[ ! "$PORT" =~ ^[0-9]{1,5}$ ]] || ((10#$PORT < 1 || 10#$PORT > 65535)); then
- echo "ROLLOUT_PORT must be an integer from 1 through 65535" >&2
- exit 2
-fi
-if [[ ! "$JWKS_URI" =~ ^https://[^[:space:]]+$ ]]; then
- echo "ROLLOUT_JWKS_URI must be an HTTPS URL without whitespace" >&2
- exit 2
-fi
-if [[ "$JWT_AUD" == *$'\n'* || "$JWT_AUD" == *$'\r'* || -z "$JWT_AUD" ]]; then
- echo "ROLLOUT_JWT_AUD must be a non-empty single-line value" >&2
- exit 2
-fi
-if [[ -n "$CUSTOM_CLAIMS" ]] &&
- ! jq -e -f "$CLOUD_COMPOSE_JQ_PROGRAM_DIR/json-object-validate.jq" \
- <<<"$CUSTOM_CLAIMS" >/dev/null; then
- echo "ROLLOUT_CUSTOM_CLAIMS must be empty or a JSON object" >&2
- exit 2
-fi
-export PORT JWKS_URI JWT_AUD CUSTOM_CLAIMS
-
-exec "${CLOUD_COMPOSE_ROLLOUT_BIN:-/usr/local/bin/cloud-compose-rollout}"
diff --git a/rootfs/home/cloud-compose/run.sh b/rootfs/home/cloud-compose/run.sh
index dcd884a..7bffeb3 100755
--- a/rootfs/home/cloud-compose/run.sh
+++ b/rootfs/home/cloud-compose/run.sh
@@ -5,8 +5,8 @@ set -eou pipefail
# shellcheck disable=SC1091
source /home/cloud-compose/profile.sh
-# shellcheck disable=SC1091
-source /home/cloud-compose/bootstrap-helpers.sh
+sitectl=/home/cloud-compose/bin/sitectl
+[[ -x "$sitectl" && ! -L "$sitectl" ]] || sitectl=/etc/cloud-compose/bin/bootstrap-sitectl
run_as_cloud_compose() (
# Configuration-management entrypoints commonly launch run.sh from /root.
@@ -14,9 +14,9 @@ run_as_cloud_compose() (
# can safely use directory stacks and relative tool behavior.
cd /home/cloud-compose
if command -v runuser >/dev/null 2>&1; then
- runuser -u cloud-compose -- env HOME=/home/cloud-compose PATH="$PATH" "$@"
+ runuser -u cloud-compose -- env HOME=/home/cloud-compose PATH="/home/cloud-compose/bin:$PATH" "$@"
elif command -v sudo >/dev/null 2>&1; then
- sudo -u cloud-compose env HOME=/home/cloud-compose PATH="$PATH" "$@"
+ sudo -u cloud-compose env HOME=/home/cloud-compose PATH="/home/cloud-compose/bin:$PATH" "$@"
else
echo "Neither runuser nor sudo is available for cloud-compose app init" >&2
return 1
@@ -53,23 +53,28 @@ systemctl enable cloud-compose-overlay.service
systemctl restart cloud-compose-overlay.service
bash /home/cloud-compose/host-conf.sh
-bash /home/cloud-compose/host-init.sh
+"$sitectl" host configure
+managed_sitectl=/home/cloud-compose/bin/sitectl
+[[ -x "$managed_sitectl" ]] || {
+ echo "Managed sitectl runtime was not installed" >&2
+ exit 1
+}
# Persistent ignored files can retain metadata from an older VM generation.
# Repair only the exact manifest project directory and its existing .env before
# any unprivileged source or application lifecycle needs to write there.
-bash /home/cloud-compose/converge-app-filesystems.sh
+"$sitectl" host apps converge-filesystems
# Source preparation performs no application lifecycle work. It gives key
# rotation a validated destination without allowing app/plugin initialization
# to run before an explicitly enabled Vault Agent has authenticated.
-run_as_cloud_compose bash /home/cloud-compose/prepare-app-sources.sh
+run_as_cloud_compose "$managed_sitectl" host apps prepare
if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then
if [[ ! "$fresh_filesystem_identity" =~ ^v1:gcp-disk-id:[0-9]{1,32}$ ]]; then
echo "GCP fresh-filesystem identity is missing or unsafe" >&2
exit 1
fi
- bash /home/cloud-compose/rotate-keys-daily.sh
+ "$sitectl" host keys daily
fi
-cloud_compose_consume_fresh_filesystem_marker \
+"$sitectl" host marker consume-fresh \
"$fresh_filesystem_marker" "$fresh_filesystem_identity"
sync
if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then
@@ -84,27 +89,22 @@ else
fi
# An explicitly enabled Vault Agent is a startup dependency. Its systemd unit
# also publishes a readiness marker before the application service may start.
-bash /home/cloud-compose/vault-agent-init.sh
-if cloud_compose_should_run_app_init \
- "$durable_bootstrap_marker" "$current_boot_app_init_marker"; then
+"$sitectl" host vault-agent init
+if [[ "$("$sitectl" host marker app-status \
+ "$durable_bootstrap_marker" "$current_boot_app_init_marker")" == initialize ]]; then
rm -f -- "$current_boot_app_init_marker"
- run_as_cloud_compose bash /home/cloud-compose/app-init.sh
- cloud_compose_publish_marker "$current_boot_app_init_marker"
+ run_as_cloud_compose "$managed_sitectl" host apps lifecycle init
+ "$sitectl" host marker publish "$current_boot_app_init_marker"
else
echo "Application initialization already completed during this boot; resuming service convergence"
fi
-cloud_compose_start_and_wait_for_oneshot cloud-compose.service "$app_wait_seconds"
+"$sitectl" host systemd start-wait cloud-compose.service --timeout "${app_wait_seconds}s"
if runtime_enabled "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}"; then
systemctl enable --now cloud-compose-internal-services.timer
else
systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service >/dev/null 2>&1 || true
fi
-if runtime_enabled "${LIBOPS_MANAGED_RUNTIME_ENABLED:-true}"; then
- systemctl enable --now libops-managed-runtime.timer
-else
- systemctl disable --now libops-managed-runtime.timer libops-managed-runtime.service >/dev/null 2>&1 || true
-fi
if runtime_enabled "${CLOUD_COMPOSE_DOCKER_PRUNE_ENABLED:-false}"; then
systemctl enable --now cloud-compose-docker-prune.timer
else
@@ -116,4 +116,4 @@ if runtime_enabled "${CLOUD_COMPOSE_OFFHOST_BACKUP_REQUIRED:-false}"; then
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"
+"$sitectl" host marker publish "$durable_bootstrap_marker"
diff --git a/rootfs/home/cloud-compose/start-cloud-compose-bootstrap.sh b/rootfs/home/cloud-compose/start-cloud-compose-bootstrap.sh
deleted file mode 100755
index 1016806..0000000
--- a/rootfs/home/cloud-compose/start-cloud-compose-bootstrap.sh
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_start_bootstrap_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_start_bootstrap_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_start_bootstrap_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_start_bootstrap_source _cc_start_bootstrap_dir _cc_start_bootstrap_installed_home
-if [[ -n "$_cc_start_bootstrap_installed_home" &&
- ( "$_cc_start_bootstrap_installed_home" == "/" ||
- "$_cc_start_bootstrap_source" == "${_cc_start_bootstrap_installed_home%/}/"* ) ]]; then
- _cc_start_bootstrap_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_start_bootstrap_checked_programs="$_cc_start_bootstrap_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_start_bootstrap_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_start_bootstrap_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_start_bootstrap_source" CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH \
- /home/cloud-compose/bootstrap-helpers.sh "$_cc_start_bootstrap_dir/bootstrap-helpers.sh"
-bootstrap_helpers_path="$CLOUD_COMPOSE_BOOTSTRAP_HELPERS_PATH"
-readonly bootstrap_helpers_path
-
-# shellcheck disable=SC1090
-source "$bootstrap_helpers_path"
-
-durable_marker="${CLOUD_COMPOSE_BOOTSTRAP_COMPLETE_MARKER:-/var/lib/cloud-compose/bootstrap-complete}"
-wait_seconds="${CLOUD_COMPOSE_BOOTSTRAP_WAIT_SECONDS:-10800}"
-bootstrap_unit="cloud-compose-bootstrap.service"
-
-if cloud_compose_marker_exists "$durable_marker"; then
- exit 0
-fi
-
-systemctl daemon-reload
-active_state="$(systemctl show --property=ActiveState --value -- "$bootstrap_unit")"
-if [[ "$active_state" == "active" ]] &&
- ! cloud_compose_marker_exists "$durable_marker"; then
- # RemainAfterExit keeps a completed oneshot active. Configuration
- # management deliberately removes the durable marker when reviewed inputs
- # change, so restart that stale active instance rather than treating it as
- # an in-flight bootstrap.
- systemctl stop -- "$bootstrap_unit"
-fi
-cloud_compose_start_and_wait_for_oneshot "$bootstrap_unit" "$wait_seconds"
-if ! cloud_compose_marker_exists "$durable_marker"; then
- echo "Cloud Compose bootstrap service became active without publishing readiness" >&2
- exit 1
-fi
diff --git a/rootfs/home/cloud-compose/vault-agent-init.sh b/rootfs/home/cloud-compose/vault-agent-init.sh
deleted file mode 100755
index bf6d969..0000000
--- a/rootfs/home/cloud-compose/vault-agent-init.sh
+++ /dev/null
@@ -1,44 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source /home/cloud-compose/profile.sh
-
-case "${VAULT_AGENT_ENABLED:-false}" in
- false)
- echo "Vault Agent is disabled"
- systemctl disable --now cloud-compose-vault-agent.service >/dev/null 2>&1 || true
- rm -f -- /run/cloud-compose/vault-agent.ready
- exit 0
- ;;
- true) ;;
- *)
- echo "VAULT_AGENT_ENABLED must be true or false" >&2
- exit 1
- ;;
-esac
-
-if [ -L /etc/vault-agent.d/cloud-compose.hcl ] || [ ! -f /etc/vault-agent.d/cloud-compose.hcl ]; then
- echo "Vault Agent is enabled but its config is missing or unsafe" >&2
- systemctl disable --now cloud-compose-vault-agent.service >/dev/null 2>&1 || true
- exit 1
-fi
-
-if [ ! -x /usr/local/bin/vault ]; then
- echo "Vault Agent is enabled but the Vault binary is not installed" >&2
- systemctl disable --now cloud-compose-vault-agent.service >/dev/null 2>&1 || true
- exit 1
-fi
-
-bash /home/cloud-compose/vault-agent-readiness.sh prepare
-
-systemctl daemon-reload
-systemctl enable cloud-compose-vault-agent.service
-systemctl restart cloud-compose-vault-agent.service
-if ! systemctl is-active --quiet cloud-compose-vault-agent.service ||
- [ -L /run/cloud-compose/vault-agent.ready ] || [ ! -f /run/cloud-compose/vault-agent.ready ]; then
- echo "Vault Agent failed to initialize while explicitly enabled" >&2
- systemctl disable --now cloud-compose-vault-agent.service >/dev/null 2>&1 || true
- exit 1
-fi
diff --git a/rootfs/home/cloud-compose/vault-agent-readiness.sh b/rootfs/home/cloud-compose/vault-agent-readiness.sh
deleted file mode 100755
index 8549eb7..0000000
--- a/rootfs/home/cloud-compose/vault-agent-readiness.sh
+++ /dev/null
@@ -1,127 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-_cc_vault_agent_readiness_source="$(readlink -f -- "${BASH_SOURCE[0]}")"
-_cc_vault_agent_readiness_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
-_cc_vault_agent_readiness_installed_home="$(readlink -f -- /home/cloud-compose 2>/dev/null || true)"
-readonly _cc_vault_agent_readiness_source _cc_vault_agent_readiness_dir _cc_vault_agent_readiness_installed_home
-if [[ -n "$_cc_vault_agent_readiness_installed_home" &&
- ( "$_cc_vault_agent_readiness_installed_home" == "/" ||
- "$_cc_vault_agent_readiness_source" == "${_cc_vault_agent_readiness_installed_home%/}/"* ) ]]; then
- _cc_vault_agent_readiness_checked_programs=/etc/cloud-compose/libexec/checked-programs.bash
-else
- _cc_vault_agent_readiness_checked_programs="$_cc_vault_agent_readiness_dir/../../etc/cloud-compose/libexec/checked-programs.bash"
-fi
-readonly _cc_vault_agent_readiness_checked_programs
-# shellcheck disable=SC1090
-source "$_cc_vault_agent_readiness_checked_programs"
-cloud_compose_bind_source_program \
- "$_cc_vault_agent_readiness_source" \
- CLOUD_COMPOSE_PROFILE_PATH \
- /home/cloud-compose/profile.sh \
- "$_cc_vault_agent_readiness_dir/profile.sh"
-profile_path="$CLOUD_COMPOSE_PROFILE_PATH"
-readonly profile_path
-
-# shellcheck disable=SC1090
-source "$profile_path"
-
-READY_MARKER="/run/cloud-compose/vault-agent.ready"
-DEFAULT_TOKEN_PATH="/mnt/disks/data/vault/token"
-
-vault_safe_dir() {
- printf '/mnt/disks/data/vault\n'
-}
-
-validate_vault_token_path() {
- local safe_dir safe_parent canonical_parent token_path token_name
-
- safe_dir="$(vault_safe_dir)"
- safe_parent="$(dirname -- "$safe_dir")"
- token_path="${VAULT_AGENT_TOKEN_PATH:-$DEFAULT_TOKEN_PATH}"
- token_name="$(basename -- "$token_path")"
-
- if [[ "$token_path" != "$safe_dir/$token_name" ||
- ! "$token_name" =~ ^[A-Za-z0-9._-]+$ || "$token_name" == "." || "$token_name" == ".." ]]; then
- echo "Vault Agent token path must be one file directly inside $safe_dir" >&2
- return 1
- fi
- if [[ ! -d "$safe_parent" || -L "$safe_parent" ]]; then
- echo "Vault Agent safe-directory parent is missing or unsafe: $safe_parent" >&2
- return 1
- fi
- canonical_parent="$(readlink -f -- "$safe_parent")" || return 1
- if [[ "$canonical_parent" != "$safe_parent" ]]; then
- echo "Vault Agent safe-directory parent contains a symbolic-link traversal" >&2
- return 1
- fi
- if [[ -L "$safe_dir" || ( -e "$safe_dir" && ! -d "$safe_dir" ) ]]; then
- echo "Vault Agent safe directory is not a regular directory: $safe_dir" >&2
- return 1
- fi
- if [[ -L "$token_path" || ( -e "$token_path" && ! -f "$token_path" ) ]]; then
- echo "Vault Agent token target is unsafe: $token_path" >&2
- return 1
- fi
-
- VAULT_SAFE_DIR="$safe_dir"
- VAULT_TOKEN_PATH="$token_path"
-}
-
-prepare_vault_agent() {
- validate_vault_token_path || return 1
- rm -f -- "$READY_MARKER"
- # A pre-existing sink token does not prove the replacement agent has
- # authenticated. Remove only the validated dedicated token file so
- # ExecStartPost must observe a fresh write from this start.
- rm -f -- "$VAULT_TOKEN_PATH"
- install -d -m 0700 -o root -g root -- "$VAULT_SAFE_DIR"
- if [[ -L "$VAULT_SAFE_DIR" || ! -d "$VAULT_SAFE_DIR" ]]; then
- echo "Vault Agent safe directory changed during preparation" >&2
- return 1
- fi
-}
-
-wait_for_vault_agent() {
- local timeout_seconds deadline
-
- validate_vault_token_path || return 1
- timeout_seconds="${VAULT_AGENT_START_TIMEOUT_SECONDS:-60}"
- if [[ ! "$timeout_seconds" =~ ^[1-9][0-9]{0,2}$ ]] || ((10#$timeout_seconds > 300)); then
- echo "VAULT_AGENT_START_TIMEOUT_SECONDS must be an integer from 1 through 300" >&2
- return 2
- fi
- deadline=$((SECONDS + 10#$timeout_seconds))
- while ((SECONDS < deadline)); do
- if [[ ! -L "$VAULT_TOKEN_PATH" && -f "$VAULT_TOKEN_PATH" && -s "$VAULT_TOKEN_PATH" ]]; then
- install -d -m 0755 -- "$(dirname -- "$READY_MARKER")"
- : >"$READY_MARKER"
- chmod 0644 "$READY_MARKER"
- return 0
- fi
- sleep 1
- done
- echo "Vault Agent did not publish a token within ${timeout_seconds}s" >&2
- return 1
-}
-
-clear_vault_agent_ready() {
- rm -f -- "$READY_MARKER"
-}
-
-main() {
- case "${1:-}" in
- prepare) prepare_vault_agent ;;
- wait) wait_for_vault_agent ;;
- clear) clear_vault_agent_ready ;;
- *)
- echo "Usage: $0 prepare|wait|clear" >&2
- return 2
- ;;
- esac
-}
-
-if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
- main "$@"
-fi
diff --git a/salt/cloud-compose/README.md b/salt/cloud-compose/README.md
index 2008296..a851530 100644
--- a/salt/cloud-compose/README.md
+++ b/salt/cloud-compose/README.md
@@ -66,7 +66,7 @@ Runtime feature switches must be YAML booleans, not quoted strings; ambiguous
values are rejected before host mutation.
Lifecycle list entries are program selectors, not shell source. Use the
-built-in `/home/cloud-compose/default-lifecycle.sh ACTION`, `true`, `false`, or
+built-in `sitectl:default ACTION`, `true`, `false`, or
one argument-free root-owned executable immediately below
`/etc/cloud-compose/lifecycle.d`. Put multi-step logic and quoting inside that
checked program file.
diff --git a/salt/cloud-compose/init.sls b/salt/cloud-compose/init.sls
index 9cd2151..a4e3945 100644
--- a/salt/cloud-compose/init.sls
+++ b/salt/cloud-compose/init.sls
@@ -214,16 +214,16 @@
'upload_timeout': ''
} %}
{% set default_init = [
- '/home/cloud-compose/default-lifecycle.sh init'
+ 'sitectl:default init'
] %}
{% set default_up = [
- '/home/cloud-compose/default-lifecycle.sh up'
+ 'sitectl:default up'
] %}
{% set default_down = [
- '/home/cloud-compose/default-lifecycle.sh down'
+ 'sitectl:default down'
] %}
{% set default_rollout = [
- '/home/cloud-compose/default-lifecycle.sh rollout'
+ 'sitectl:default rollout'
] %}
{% set lifecycle_defaults = {
'init': default_init,
@@ -621,6 +621,15 @@ cloud-compose-user:
- pkg: cloud-compose-packages
{% endif %}
+cloud-compose-mount-parent:
+ file.directory:
+ - name: /mnt/disks
+ - user: root
+ - group: root
+ - mode: '0755'
+ - require:
+ - user: cloud-compose-user
+
cloud-compose-data-dirs:
file.directory:
- names:
@@ -633,6 +642,7 @@ cloud-compose-data-dirs:
- makedirs: True
- require:
- user: cloud-compose-user
+ - file: cloud-compose-mount-parent
{% if compose_projects %}
cloud-compose-project-dirs:
@@ -663,9 +673,7 @@ cloud-compose-privileged-program-directories:
- names:
- {{ home | json }}
- /etc/cloud-compose
- - /etc/cloud-compose/awk
- /etc/cloud-compose/bin
- - /etc/cloud-compose/jq
- /etc/cloud-compose/libexec
- user: root
- group: root
@@ -694,33 +702,6 @@ cloud-compose-rootfs-script-modes:
- file: cloud-compose-rootfs
- file: cloud-compose-privileged-program-directories
-cloud-compose-checked-program-resolver:
- file.managed:
- - name: /etc/cloud-compose/libexec/checked-programs.bash
- - source: salt://rootfs/etc/cloud-compose/libexec/checked-programs.bash
- - user: root
- - group: root
- - mode: '0644'
- - require:
- - file: cloud-compose-rootfs
- - file: cloud-compose-privileged-program-directories
-
-cloud-compose-rootfs-jq-modes:
- cmd.run:
- - name: find /etc/cloud-compose/jq -maxdepth 1 -type f -name '*.jq' -exec chown root:root {} + -exec chmod 0644 {} +
- - unless: test -z "$(find /etc/cloud-compose/jq -maxdepth 1 -type f -name '*.jq' \( ! -user root -o ! -group root -o ! -perm 0644 \) -print -quit)"
- - require:
- - file: cloud-compose-rootfs
- - file: cloud-compose-privileged-program-directories
-
-cloud-compose-rootfs-awk-modes:
- cmd.run:
- - name: find /etc/cloud-compose/awk -maxdepth 1 -type f -name '*.awk' -exec chown root:root {} + -exec chmod 0644 {} +
- - unless: test -z "$(find /etc/cloud-compose/awk -maxdepth 1 -type f -name '*.awk' \( ! -user root -o ! -group root -o ! -perm 0644 \) -print -quit)"
- - require:
- - file: cloud-compose-rootfs
- - file: cloud-compose-privileged-program-directories
-
{% for lifecycle in ['init', 'up', 'down', 'rollout'] %}
cloud-compose-lifecycle-{{ lifecycle }}:
file.managed:
@@ -791,6 +772,12 @@ cloud-compose-managed-runtime-artifacts:
- file: cloud-compose-rootfs
{% if run_bootstrap is sameas true %}
+cloud-compose-bootstrap-sitectl:
+ cmd.run:
+ - name: /etc/cloud-compose/libexec/bootstrap-sitectl.sh --version {{ sitectl_version | json }}
+ - require:
+ - cmd: cloud-compose-rootfs-script-modes
+
cloud-compose-bootstrap-paths-hardened:
cmd.run:
- name: /etc/cloud-compose/libexec/harden-bootstrap-paths.sh
@@ -800,9 +787,7 @@ cloud-compose-bootstrap-paths-hardened:
- file: cloud-compose-project-manifest
- file: cloud-compose-managed-runtime-artifacts
- cmd: cloud-compose-rootfs-script-modes
- - file: cloud-compose-checked-program-resolver
- - cmd: cloud-compose-rootfs-jq-modes
- - cmd: cloud-compose-rootfs-awk-modes
+ - cmd: cloud-compose-bootstrap-sitectl
{% for lifecycle in ['init', 'up', 'down', 'rollout'] %}
- file: cloud-compose-lifecycle-{{ lifecycle }}
{% endfor %}
@@ -819,7 +804,7 @@ cloud-compose-systemd-reload:
{% if rollout_enabled is sameas true %}
cloud-compose-rollout-service:
cmd.run:
- - name: bash /etc/cloud-compose/libexec/run-root-program.sh deploy-rollout.sh
+ - name: bash /home/cloud-compose/deploy-rollout.sh
- require:
- file: cloud-compose-env
- file: cloud-compose-application-env
@@ -828,9 +813,6 @@ cloud-compose-rollout-service:
- file: cloud-compose-rootfs
- cmd: cloud-compose-lifecycle-lock
- cmd: cloud-compose-rootfs-script-modes
- - file: cloud-compose-checked-program-resolver
- - cmd: cloud-compose-rootfs-jq-modes
- - cmd: cloud-compose-rootfs-awk-modes
- file: cloud-compose-lifecycle-init
- file: cloud-compose-lifecycle-up
- file: cloud-compose-lifecycle-down
@@ -851,10 +833,10 @@ cloud-compose-clear-bootstrap-marker:
{% if run_bootstrap is sameas true %}
cloud-compose-bootstrap:
cmd.run:
- - name: bash /etc/cloud-compose/libexec/start-cloud-compose-bootstrap.sh
+ - name: /etc/cloud-compose/libexec/sitectl-host.sh systemd ensure-bootstrap
- env:
CLOUD_COMPOSE_BOOTSTRAP_WAIT_SECONDS: {{ (bootstrap_wait_seconds | string) | json }}
- - unless: bash /etc/cloud-compose/libexec/require-bootstrap-ready.sh
+ - unless: /etc/cloud-compose/libexec/sitectl-host.sh marker valid /var/lib/cloud-compose/bootstrap-complete
- require:
{% if install_packages %}
- service: cloud-compose-docker
@@ -864,9 +846,6 @@ cloud-compose-bootstrap:
- file: cloud-compose-project-manifest
- file: cloud-compose-managed-runtime-artifacts
- cmd: cloud-compose-rootfs-script-modes
- - file: cloud-compose-checked-program-resolver
- - cmd: cloud-compose-rootfs-jq-modes
- - cmd: cloud-compose-rootfs-awk-modes
- cmd: cloud-compose-bootstrap-paths-hardened
{% for lifecycle in ['init', 'up', 'down', 'rollout'] %}
- file: cloud-compose-lifecycle-{{ lifecycle }}
diff --git a/template_versions.tftest.hcl b/template_versions.tftest.hcl
index 34a310e..b1112e0 100644
--- a/template_versions.tftest.hcl
+++ b/template_versions.tftest.hcl
@@ -35,7 +35,7 @@ run "default_template_uses_v1_core" {
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
}
error_message = "The default template must select the released sitectl v1 core."
}
@@ -63,7 +63,7 @@ run "non_isle_template_uses_v1_release_set" {
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
sitectl-wp = "v2.1.0"
}
error_message = "Non-ISLE templates must select their coordinated sitectl v1 release set."
@@ -92,7 +92,7 @@ run "isle_template_uses_v1_release_set" {
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
sitectl-drupal = "v1.5.0"
sitectl-isle = "v1.6.0"
}
@@ -196,7 +196,7 @@ run "custom_package_set_filters_template_versions" {
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
sitectl-wp = "v0.6.1"
}
error_message = "Template selectors for packages omitted by a custom package set must not reach the runtime."
@@ -223,7 +223,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.sitectl.packages == tolist(["sitectl"]) && local.sitectl.package_versions == {
- sitectl = "v1.9.1"
+ sitectl = "v1.12.7"
}
error_message = "An explicit core-only package set must not be mistaken for an omitted template package selection."
}
diff --git a/templates/apps.json b/templates/apps.json
index f14a602..6fe6f2a 100644
--- a/templates/apps.json
+++ b/templates/apps.json
@@ -6,7 +6,7 @@
"packages": ["sitectl"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.9.1"
+ "sitectl": "v1.12.7"
}
},
"templates": {
@@ -17,7 +17,7 @@
"packages": ["sitectl", "sitectl-archivesspace"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-archivesspace": "v2.1.1"
}
},
@@ -28,7 +28,7 @@
"packages": ["sitectl", "sitectl-ojs"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-ojs": "v1.4.0"
}
},
@@ -41,7 +41,7 @@
"ISLANDORA_TAG": "6.3.19"
},
"package_versions": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-drupal": "v1.5.0",
"sitectl-isle": "v1.6.0"
}
@@ -53,7 +53,7 @@
"packages": ["sitectl", "sitectl-drupal"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-drupal": "v1.5.0"
}
},
@@ -64,7 +64,7 @@
"packages": ["sitectl", "sitectl-wp"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-wp": "v2.1.0"
}
},
@@ -75,7 +75,7 @@
"packages": ["sitectl", "sitectl-omeka-s"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-omeka-s": "v1.4.0"
}
},
@@ -86,7 +86,7 @@
"packages": ["sitectl", "sitectl-omeka-classic"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.9.1",
+ "sitectl": "v1.12.7",
"sitectl-omeka-classic": "v1.4.0"
}
}
diff --git a/templates/cloud-init.yml b/templates/cloud-init.yml
index f51bb08..c13e6eb 100644
--- a/templates/cloud-init.yml
+++ b/templates/cloud-init.yml
@@ -29,26 +29,16 @@ users:
%{ endfor ~}
write_files:
-- path: /var/lib/cloud-compose/bootstrap/rootfs-archive.sh
+- path: /var/lib/cloud-compose/bootstrap/install-rootfs.py
owner: root:root
permissions: "0700"
encoding: gzip+base64
- content: ${ROOTFS_ARCHIVE_SCRIPT_B64}
-- path: /var/lib/cloud-compose/bootstrap/gcp-cloud-init-finalize.sh
- owner: root:root
- permissions: "0700"
- encoding: gzip+base64
- content: ${GCP_CLOUD_INIT_FINALIZE_SCRIPT_B64}
-- path: /var/lib/cloud-compose/bootstrap/gcp-cloud-init-post-bootstrap.sh
- owner: root:root
- permissions: "0700"
- encoding: gzip+base64
- content: ${GCP_CLOUD_INIT_POST_SCRIPT_B64}
-- path: /var/lib/cloud-compose/bootstrap/cloud-compose-diagnostics.sh
+ content: ${ROOTFS_INSTALLER_B64}
+- path: /var/lib/cloud-compose/bootstrap/rootfs.json
owner: root:root
permissions: "0600"
encoding: gzip+base64
- content: ${DIAGNOSTICS_SCRIPT_B64}
+ content: ${ROOTFS_BUNDLE_B64}
- path: /var/lib/cloud-compose/bootstrap/init-commands.sh
owner: root:root
permissions: "0600"
@@ -59,7 +49,6 @@ write_files:
permissions: "0600"
encoding: b64
content: ${jsonencode(RUNCMD_B64)}
-${WRITE_FILES_CONTENT}
${DOCKER_COMPOSE_SCRIPTS}
${COMPOSE_PROJECTS_FILE}
${ENV_FILE_CONTENT}
@@ -68,10 +57,8 @@ ${VAULT_AGENT_FILES}
${MANAGED_RUNTIME_ARTIFACTS_FILE}
runcmd:
-%{ if ROOTFS_ARCHIVE_ENABLED ~}
-- [bash, /var/lib/cloud-compose/bootstrap/rootfs-archive.sh, install, ${jsonencode(ROOTFS_ARCHIVE_URL_B64)}, ${jsonencode(ROOTFS_ARCHIVE_SHA256)}, ${jsonencode(ROOTFS_CONTRACT_SHA256)}, ${jsonencode(ROOTFS_OVERLAY_DIR)}]
-%{ endif ~}
-- [bash, /var/lib/cloud-compose/bootstrap/gcp-cloud-init-finalize.sh, /var/lib/cloud-compose/bootstrap/init-commands.sh, ${jsonencode(DIAGNOSTICS_SCRIPT_SHA256)}]
+- [python3, /var/lib/cloud-compose/bootstrap/install-rootfs.py, /var/lib/cloud-compose/bootstrap/rootfs.json]
+- [bash, /etc/cloud-compose/libexec/gcp-cloud-init-finalize.sh, /var/lib/cloud-compose/bootstrap/init-commands.sh]
%{ if POST_BOOTSTRAP_REQUIRED ~}
-- [bash, /var/lib/cloud-compose/bootstrap/gcp-cloud-init-post-bootstrap.sh, ${jsonencode(ROLLOUT_ENABLED)}, /var/lib/cloud-compose/bootstrap/runcmd.sh]
+- [bash, /etc/cloud-compose/libexec/gcp-cloud-init-post-bootstrap.sh, ${jsonencode(ROLLOUT_ENABLED)}, /var/lib/cloud-compose/bootstrap/runcmd.sh]
%{ endif ~}
diff --git a/templates/gcp-filesystem-boothook.sh.tftpl b/templates/gcp-filesystem-boothook.sh.tftpl
index 3641a7b..acb3619 100644
--- a/templates/gcp-filesystem-boothook.sh.tftpl
+++ b/templates/gcp-filesystem-boothook.sh.tftpl
@@ -3,20 +3,16 @@
set -euo pipefail
umask 077
-readonly filesystem_boot=/run/cloud-compose-gcp-filesystem-boot
-readonly filesystem_prep=/run/cloud-compose-prepare-filesystem
-readonly filesystem_persist=/run/cloud-compose-persist-filesystems
-readonly filesystem_reconcile=/run/cloud-compose-reconcile-fstab.awk
+readonly bootstrap_sitectl=/run/cloud-compose-bootstrap-sitectl.sh
+readonly sitectl=/etc/cloud-compose/bin/bootstrap-sitectl
-printf '%s' '${GCP_FILESYSTEM_BOOT_SCRIPT_B64}' | base64 -d | gzip -d >"$filesystem_boot"
-printf '%s' '${FILESYSTEM_PREP_SCRIPT_B64}' | base64 -d | gzip -d >"$filesystem_prep"
-printf '%s' '${FILESYSTEM_PERSIST_SCRIPT_B64}' | base64 -d | gzip -d >"$filesystem_persist"
-printf '%s' '${FSTAB_RECONCILE_AWK_B64}' | base64 -d | gzip -d >"$filesystem_reconcile"
-chmod 0600 "$filesystem_boot" "$filesystem_prep" "$filesystem_persist" "$filesystem_reconcile"
+printf '%s' '${BOOTSTRAP_SITECTL_B64}' | base64 -d | gzip -d >"$bootstrap_sitectl"
+chmod 0700 "$bootstrap_sitectl"
+bash "$bootstrap_sitectl" --version ${jsonencode(SITECTL_VERSION)}
-bash "$filesystem_boot" \
- ${jsonencode(FRESH_FILESYSTEM_IDENTITY)} \
- ${jsonencode(USE_OVERLAY ? "true" : "false")} \
- "$filesystem_prep" \
- "$filesystem_persist" \
- "$filesystem_reconcile"
+"$sitectl" host filesystems \
+ --data-device /dev/disk/by-id/google-data \
+ --volumes-device /dev/disk/by-id/google-docker-volumes \
+ --fresh-identity ${jsonencode(FRESH_FILESYSTEM_IDENTITY)} \
+ --ready-marker /run/cloud-compose-filesystems-ready%{ if USE_OVERLAY } \
+ --overlay-device /dev/disk/by-id/google-prod-volumes%{ endif }
diff --git a/tests/smoke/do/main.tf b/tests/smoke/do/main.tf
index 00a659f..f52a4e3 100644
--- a/tests/smoke/do/main.tf
+++ b/tests/smoke/do/main.tf
@@ -14,22 +14,17 @@ provider "digitalocean" {}
module "context" {
source = "../modules/context"
- cloud_provider = "digitalocean"
- template = var.template
- ssh_public_key = var.ssh_public_key
- operator_ssh_public_keys = var.operator_ssh_public_keys
- smoke_run_id = var.smoke_run_id
- docker_compose_branch = var.docker_compose_branch
- ingress_port = var.ingress_port
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz"
- rootfs_archive_sha256 = var.cloud_compose_source_sha256
- rootfs_test_source_archive_prefix = "cloud-compose-${var.cloud_compose_source_ref}"
- tags = var.tags
+ cloud_provider = "digitalocean"
+ template = var.template
+ ssh_public_key = var.ssh_public_key
+ operator_ssh_public_keys = var.operator_ssh_public_keys
+ smoke_run_id = var.smoke_run_id
+ docker_compose_branch = var.docker_compose_branch
+ ingress_port = var.ingress_port
+ tags = var.tags
}
module "app" {
- # Hosted smoke alone needs the exact-commit source-archive fixture. Keep that
- # test-only escape hatch below the public provider entrypoint.
source = "../../../modules/digitalocean"
name = module.context.name
diff --git a/tests/smoke/do/variables.tf b/tests/smoke/do/variables.tf
index 600811a..170e022 100644
--- a/tests/smoke/do/variables.tf
+++ b/tests/smoke/do/variables.tf
@@ -80,23 +80,3 @@ variable "tags" {
default = ["cloud-compose"]
description = "Extra provider tags applied to smoke-test resources."
}
-
-variable "cloud_compose_source_ref" {
- type = string
- description = "Exact lowercase cloud-compose commit whose source archive is fetched to keep DigitalOcean user_data below 64 KiB."
-
- validation {
- condition = can(regex("^[0-9a-f]{40}$", var.cloud_compose_source_ref))
- error_message = "cloud_compose_source_ref must be an exact lowercase 40-character commit SHA."
- }
-}
-
-variable "cloud_compose_source_sha256" {
- type = string
- description = "SHA-256 of the cloud-compose source archive selected by cloud_compose_source_ref."
-
- validation {
- condition = can(regex("^[0-9a-fA-F]{64}$", trimspace(var.cloud_compose_source_sha256)))
- error_message = "cloud_compose_source_sha256 must be a 64-character SHA-256 digest."
- }
-}
diff --git a/tests/smoke/linode/main.tf b/tests/smoke/linode/main.tf
index d985c33..9d150a1 100644
--- a/tests/smoke/linode/main.tf
+++ b/tests/smoke/linode/main.tf
@@ -14,22 +14,17 @@ provider "linode" {}
module "context" {
source = "../modules/context"
- cloud_provider = "linode"
- template = var.template
- ssh_public_key = var.ssh_public_key
- operator_ssh_public_keys = var.operator_ssh_public_keys
- smoke_run_id = var.smoke_run_id
- docker_compose_branch = var.docker_compose_branch
- ingress_port = var.ingress_port
- rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz"
- rootfs_archive_sha256 = var.cloud_compose_source_sha256
- rootfs_test_source_archive_prefix = "cloud-compose-${var.cloud_compose_source_ref}"
- tags = var.tags
+ cloud_provider = "linode"
+ template = var.template
+ ssh_public_key = var.ssh_public_key
+ operator_ssh_public_keys = var.operator_ssh_public_keys
+ smoke_run_id = var.smoke_run_id
+ docker_compose_branch = var.docker_compose_branch
+ ingress_port = var.ingress_port
+ tags = var.tags
}
module "app" {
- # Hosted smoke alone needs the exact-commit source-archive fixture. Keep that
- # test-only escape hatch below the public provider entrypoint.
source = "../../../modules/linode"
name = module.context.name
diff --git a/tests/smoke/linode/variables.tf b/tests/smoke/linode/variables.tf
index 17bb339..1080c76 100644
--- a/tests/smoke/linode/variables.tf
+++ b/tests/smoke/linode/variables.tf
@@ -80,23 +80,3 @@ variable "tags" {
default = ["cloud-compose"]
description = "Extra provider tags applied to smoke-test resources."
}
-
-variable "cloud_compose_source_ref" {
- type = string
- description = "Exact lowercase cloud-compose commit whose source archive is fetched by providers with metadata size limits."
-
- validation {
- condition = can(regex("^[0-9a-f]{40}$", var.cloud_compose_source_ref))
- error_message = "cloud_compose_source_ref must be an exact lowercase 40-character commit SHA."
- }
-}
-
-variable "cloud_compose_source_sha256" {
- type = string
- description = "SHA-256 of the cloud-compose source archive selected by cloud_compose_source_ref."
-
- validation {
- condition = can(regex("^[0-9a-fA-F]{64}$", trimspace(var.cloud_compose_source_sha256)))
- error_message = "cloud_compose_source_sha256 must be a 64-character SHA-256 digest."
- }
-}
diff --git a/tests/smoke/modules/context/main.tf b/tests/smoke/modules/context/main.tf
index 359ba3e..f58b01a 100644
--- a/tests/smoke/modules/context/main.tf
+++ b/tests/smoke/modules/context/main.tf
@@ -60,16 +60,10 @@ locals {
tags = distinct(concat(var.tags, ["cloud-compose-smoke", local.target], local.run_tag != "" ? [local.run_tag] : []))
ssh_keys = distinct(concat([var.ssh_public_key], var.operator_ssh_public_keys))
runtime_base = {
- rootfs_archive_url = var.rootfs_archive_url
- rootfs_archive_sha256 = var.rootfs_archive_sha256
- rootfs_test_source_archive_prefix = var.rootfs_test_source_archive_prefix
compose = {
repo = local.app_template.repo
branch = var.docker_compose_branch
ingress_port = var.ingress_port
- up = [
- "/home/cloud-compose/default-lifecycle.sh up"
- ]
}
sitectl = {
packages = local.app_template.packages
diff --git a/tests/smoke/modules/context/variables.tf b/tests/smoke/modules/context/variables.tf
index bd01af5..54f0d90 100644
--- a/tests/smoke/modules/context/variables.tf
+++ b/tests/smoke/modules/context/variables.tf
@@ -63,32 +63,6 @@ variable "ingress_port" {
description = "Host port exposed by Traefik."
}
-variable "rootfs_archive_url" {
- type = string
- default = ""
- description = "Optional cloud-compose rootfs archive URL for providers with metadata size limits. Must be set with rootfs_archive_sha256."
-}
-
-variable "rootfs_archive_sha256" {
- type = string
- default = ""
- description = "SHA-256 for rootfs_archive_url."
-}
-
-variable "rootfs_test_source_archive_prefix" {
- type = string
- default = ""
- description = "Explicit source-archive prefix used only by hosted smoke tests for an unreleased exact commit."
-
- validation {
- condition = (
- trimspace(var.rootfs_test_source_archive_prefix) == "" ||
- can(regex("^cloud-compose-[0-9a-f]{40}$", trimspace(var.rootfs_test_source_archive_prefix)))
- )
- error_message = "rootfs_test_source_archive_prefix must be empty or identify one exact lowercase 40-character commit SHA."
- }
-}
-
variable "tags" {
type = list(string)
default = ["cloud-compose"]
diff --git a/variables.tf b/variables.tf
index 5809f27..f91cf82 100644
--- a/variables.tf
+++ b/variables.tf
@@ -205,10 +205,8 @@ variable "gcp" {
variable "runtime" {
description = "Provider-neutral compose/runtime settings."
type = object({
- rootfs = optional(string, "")
- rootfs_archive_url = optional(string, "")
- rootfs_archive_sha256 = optional(string, "")
- users = optional(map(list(string)), {})
+ rootfs = optional(string, "")
+ users = optional(map(list(string)), {})
disaster_recovery = optional(object({
required = optional(bool, false)
@@ -316,14 +314,6 @@ variable "runtime" {
})
default = {}
- validation {
- condition = (
- (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") &&
- (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256))))
- )
- 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)) &&
From 80a4286a3e4be1092808d68982c3e4f50f13827f Mon Sep 17 00:00:00 2001
From: libops-agent <115990865+libops-agent@users.noreply.github.com>
Date: Mon, 24 Aug 2026 15:10:41 +0000
Subject: [PATCH 2/5] [patch] Pin sitectl v1.12.8
---
README.md | 2 +-
ci/config-management-smoke-assert.py | 2 +-
docs/examples.md | 4 ++--
docs/managed-runtime.md | 4 ++--
examples/binpack/main.tf | 2 +-
internal/contracttest/template_version_test.go | 18 +++++++++---------
providers/do/template_versions.tftest.hcl | 2 +-
providers/gcp/template_versions.tftest.hcl | 2 +-
providers/linode/template_versions.tftest.hcl | 2 +-
template_versions.tftest.hcl | 10 +++++-----
templates/apps.json | 16 ++++++++--------
11 files changed, 32 insertions(+), 32 deletions(-)
diff --git a/README.md b/README.md
index 63246d3..4cc6593 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ Renovate tracks both the Compose tags and sitectl release tags in the registry.
Its pull requests are promotion candidates: keep the complete compatible set
together and require the hosted application smoke before merging them.
-All presets pin sitectl v1.12.7 plus the latest reviewed application plugin.
+All presets pin sitectl v1.12.8 plus the latest reviewed application plugin.
ISLE selects the `libops/isle` v1.3.1 template, ArchivesSpace selects v1.0.1,
WordPress selects v1.1.1, and Drupal, OJS, Omeka Classic, and Omeka S select
v1.2.1. Keep each preset's complete template and package set together when
diff --git a/ci/config-management-smoke-assert.py b/ci/config-management-smoke-assert.py
index 7393408..2a48f0c 100644
--- a/ci/config-management-smoke-assert.py
+++ b/ci/config-management-smoke-assert.py
@@ -277,7 +277,7 @@ def assert_salt_runtime(
assert not Path("/tmp/cloud-compose-salt-command-injection").exists()
elif expected_name == "drupal-prod":
assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-drupal": "v1.5.0",
}
diff --git a/docs/examples.md b/docs/examples.md
index 3bcb927..8b14b95 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -131,7 +131,7 @@ healthcheck settings:
runtime = {
sitectl = {
package_versions = {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
sitectl-wp = "v2.1.0"
}
}
@@ -177,7 +177,7 @@ runtime = {
}
sitectl = {
package_versions = {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
sitectl-wp = "v2.1.0"
sitectl-drupal = "v1.5.0"
}
diff --git a/docs/managed-runtime.md b/docs/managed-runtime.md
index 1f32f29..41d4c3f 100644
--- a/docs/managed-runtime.md
+++ b/docs/managed-runtime.md
@@ -27,7 +27,7 @@ core and plugins independently. The provider-neutral
backward-compatible fallback only for an installed package with neither a
template selector nor an explicit per-package selector.
-All presets pin sitectl v1.12.7 with reviewed application plugins:
+All presets pin sitectl v1.12.8 with reviewed application plugins:
sitectl-archivesspace v2.1.1, sitectl-drupal v1.5.0, sitectl-isle v1.6.0,
sitectl-ojs v1.4.0, sitectl-omeka-classic v1.4.0, sitectl-omeka-s v1.4.0,
and sitectl-wp v2.1.0. ISLE includes both the Drupal and ISLE plugins. The
@@ -55,7 +55,7 @@ runtime = {
"sitectl-isle",
]
package_versions = {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
sitectl-drupal = "v1.5.0"
sitectl-isle = "v1.6.0"
}
diff --git a/examples/binpack/main.tf b/examples/binpack/main.tf
index a0554a9..cd3ce03 100644
--- a/examples/binpack/main.tf
+++ b/examples/binpack/main.tf
@@ -29,7 +29,7 @@ module "apps" {
}
sitectl = {
package_versions = {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
sitectl-wp = "v2.1.0"
sitectl-drupal = "v1.5.0"
}
diff --git a/internal/contracttest/template_version_test.go b/internal/contracttest/template_version_test.go
index 40d227b..7c8c3ab 100644
--- a/internal/contracttest/template_version_test.go
+++ b/internal/contracttest/template_version_test.go
@@ -30,35 +30,35 @@ func TestTemplateVersionContract(t *testing.T) {
expectedVersions := map[string]map[string]string{
"default": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
},
"archivesspace": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-archivesspace": "v2.1.1",
},
"drupal": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-drupal": "v1.5.0",
},
"isle": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-drupal": "v1.5.0",
"sitectl-isle": "v1.6.0",
},
"ojs": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-ojs": "v1.4.0",
},
"omeka-classic": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-omeka-classic": "v1.4.0",
},
"omeka-s": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-omeka-s": "v1.4.0",
},
"wp": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-wp": "v2.1.0",
},
}
@@ -146,7 +146,7 @@ func TestTemplateVersionContract(t *testing.T) {
for _, packageName := range []string{"sitectl", "sitectl-wp", "sitectl-drupal"} {
requireContains(t, content, packageName, relativePath+" bin-pack package")
}
- requireContains(t, content, `sitectl = "v1.12.7"`, relativePath+" bin-pack core selector")
+ requireContains(t, content, `sitectl = "v1.12.8"`, relativePath+" bin-pack core selector")
requireContains(t, content, `sitectl-wp = "v2.1.0"`, relativePath+" bin-pack WordPress selector")
requireContains(t, content, `sitectl-drupal = "v1.5.0"`, relativePath+" bin-pack Drupal selector")
}
diff --git a/providers/do/template_versions.tftest.hcl b/providers/do/template_versions.tftest.hcl
index 3eaf66e..dce12ba 100644
--- a/providers/do/template_versions.tftest.hcl
+++ b/providers/do/template_versions.tftest.hcl
@@ -51,7 +51,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
}
error_message = "The DigitalOcean entrypoint must preserve an explicit core-only package set."
}
diff --git a/providers/gcp/template_versions.tftest.hcl b/providers/gcp/template_versions.tftest.hcl
index 05d88e3..1d51a56 100644
--- a/providers/gcp/template_versions.tftest.hcl
+++ b/providers/gcp/template_versions.tftest.hcl
@@ -67,7 +67,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
}
error_message = "The GCP entrypoint must preserve an explicit core-only package set."
}
diff --git a/providers/linode/template_versions.tftest.hcl b/providers/linode/template_versions.tftest.hcl
index 513b83a..265fcf7 100644
--- a/providers/linode/template_versions.tftest.hcl
+++ b/providers/linode/template_versions.tftest.hcl
@@ -61,7 +61,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
}
error_message = "The Linode entrypoint must preserve an explicit core-only package set."
}
diff --git a/template_versions.tftest.hcl b/template_versions.tftest.hcl
index b1112e0..9e61f77 100644
--- a/template_versions.tftest.hcl
+++ b/template_versions.tftest.hcl
@@ -35,7 +35,7 @@ run "default_template_uses_v1_core" {
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
}
error_message = "The default template must select the released sitectl v1 core."
}
@@ -63,7 +63,7 @@ run "non_isle_template_uses_v1_release_set" {
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
sitectl-wp = "v2.1.0"
}
error_message = "Non-ISLE templates must select their coordinated sitectl v1 release set."
@@ -92,7 +92,7 @@ run "isle_template_uses_v1_release_set" {
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
sitectl-drupal = "v1.5.0"
sitectl-isle = "v1.6.0"
}
@@ -196,7 +196,7 @@ run "custom_package_set_filters_template_versions" {
assert {
condition = local.sitectl.package_versions == {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
sitectl-wp = "v0.6.1"
}
error_message = "Template selectors for packages omitted by a custom package set must not reach the runtime."
@@ -223,7 +223,7 @@ run "explicit_core_only_package_set_disables_template_plugins" {
assert {
condition = local.sitectl.packages == tolist(["sitectl"]) && local.sitectl.package_versions == {
- sitectl = "v1.12.7"
+ sitectl = "v1.12.8"
}
error_message = "An explicit core-only package set must not be mistaken for an omitted template package selection."
}
diff --git a/templates/apps.json b/templates/apps.json
index 6fe6f2a..c694cfd 100644
--- a/templates/apps.json
+++ b/templates/apps.json
@@ -6,7 +6,7 @@
"packages": ["sitectl"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.12.7"
+ "sitectl": "v1.12.8"
}
},
"templates": {
@@ -17,7 +17,7 @@
"packages": ["sitectl", "sitectl-archivesspace"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-archivesspace": "v2.1.1"
}
},
@@ -28,7 +28,7 @@
"packages": ["sitectl", "sitectl-ojs"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-ojs": "v1.4.0"
}
},
@@ -41,7 +41,7 @@
"ISLANDORA_TAG": "6.3.19"
},
"package_versions": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-drupal": "v1.5.0",
"sitectl-isle": "v1.6.0"
}
@@ -53,7 +53,7 @@
"packages": ["sitectl", "sitectl-drupal"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-drupal": "v1.5.0"
}
},
@@ -64,7 +64,7 @@
"packages": ["sitectl", "sitectl-wp"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-wp": "v2.1.0"
}
},
@@ -75,7 +75,7 @@
"packages": ["sitectl", "sitectl-omeka-s"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-omeka-s": "v1.4.0"
}
},
@@ -86,7 +86,7 @@
"packages": ["sitectl", "sitectl-omeka-classic"],
"extra_env": {},
"package_versions": {
- "sitectl": "v1.12.7",
+ "sitectl": "v1.12.8",
"sitectl-omeka-classic": "v1.4.0"
}
}
From 4b4f0e6a88c252edb398a62a95e078637941c537 Mon Sep 17 00:00:00 2001
From: libops-agent <115990865+libops-agent@users.noreply.github.com>
Date: Mon, 24 Aug 2026 15:17:02 +0000
Subject: [PATCH 3/5] [test] Make permanent HTTP retry contract deterministic
---
internal/providercleanup/cleanup_test.go | 29 ++++--------------------
1 file changed, 4 insertions(+), 25 deletions(-)
diff --git a/internal/providercleanup/cleanup_test.go b/internal/providercleanup/cleanup_test.go
index 97b2798..2015d56 100644
--- a/internal/providercleanup/cleanup_test.go
+++ b/internal/providercleanup/cleanup_test.go
@@ -433,8 +433,7 @@ func TestListRetryPolicyUsesActualHTTPResponses(t *testing.T) {
}
}
-func TestListRetryPolicyFailsFastOnPermanentHTTPResponses(t *testing.T) {
- t.Parallel()
+func TestPermanentListHTTPResponsesAreNotRetryable(t *testing.T) {
for _, status := range []int{
http.StatusBadRequest,
http.StatusUnauthorized,
@@ -445,29 +444,9 @@ func TestListRetryPolicyFailsFastOnPermanentHTTPResponses(t *testing.T) {
http.StatusLocked,
http.StatusFound,
} {
- status := status
- t.Run(http.StatusText(status), func(t *testing.T) {
- t.Parallel()
- server, requests := newStatusSequenceServer(t, []int{status}, `{"data":[],"page":1,"pages":1,"results":0}`)
- runner := Runner{
- Client: testClient(t, Linode, "token", server.URL+"/v4/"),
- Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)),
- Sleep: func(_ context.Context, _ time.Duration) error {
- t.Fatal("permanent list failure unexpectedly slept for a retry")
- return nil
- },
- }
- if _, err := runner.listWithRetry(context.Background(), Config{
- Provider: Linode,
- ListAttempts: 6,
- ListRetryDelay: time.Second,
- }, kindInstances); err == nil {
- t.Fatal("listWithRetry() unexpectedly accepted a permanent HTTP failure")
- }
- if got := requests.Load(); got != 1 {
- t.Fatalf("requests = %d; want 1", got)
- }
- })
+ if retryableStatus(http.MethodGet, status) {
+ t.Errorf("GET HTTP %d is unexpectedly retryable", status)
+ }
}
}
From c5a1256004afb348af233764985eb175539a2715 Mon Sep 17 00:00:00 2001
From: libops-agent <115990865+libops-agent@users.noreply.github.com>
Date: Mon, 24 Aug 2026 15:34:40 +0000
Subject: [PATCH 4/5] [test] Verify managed sitectl in cloud smoke
---
ci/cloud-smoke.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ci/cloud-smoke.sh b/ci/cloud-smoke.sh
index a54ec37..3838538 100755
--- a/ci/cloud-smoke.sh
+++ b/ci/cloud-smoke.sh
@@ -607,7 +607,7 @@ verify_sitectl_host_runtime() {
port="$(jq -r '.ssh_port' "$output_json")"
user="$(jq -r '.ssh_user' "$output_json")"
ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \
- 'test -x /home/cloud-compose/bin/sitectl && test -x /home/cloud-compose/lifecycle-entrypoint.sh && /home/cloud-compose/bin/sitectl --version'
+ 'test -x /home/cloud-compose/bin/sitectl && /home/cloud-compose/bin/sitectl --version'
}
run_target() (
From bb6edc1116dce6a992bab91d67019166ceac13c0 Mon Sep 17 00:00:00 2001
From: libops-agent <115990865+libops-agent@users.noreply.github.com>
Date: Mon, 24 Aug 2026 16:28:36 +0000
Subject: [PATCH 5/5] [patch] Preserve numeric Ansible ingress ports
---
.../roles/cloud_compose/templates/compose-projects.json.j2 | 6 +++++-
ci/config-management-smoke-assert.py | 2 ++
tests/config-management/ansible/smoke.yml | 1 +
3 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/ansible/roles/cloud_compose/templates/compose-projects.json.j2 b/ansible/roles/cloud_compose/templates/compose-projects.json.j2
index b76a821..435e727 100644
--- a/ansible/roles/cloud_compose/templates/compose-projects.json.j2
+++ b/ansible/roles/cloud_compose/templates/compose-projects.json.j2
@@ -1 +1,5 @@
-{{ _cc_compose_projects | to_nice_json }}
+{% set projects = {} %}
+{% for name, project in _cc_compose_projects.items() %}
+{% set _ = projects.update({name: project | combine({'ingress_port': project.ingress_port | int})}) %}
+{% endfor %}
+{{ projects | to_nice_json }}
diff --git a/ci/config-management-smoke-assert.py b/ci/config-management-smoke-assert.py
index 2a48f0c..a45bf93 100644
--- a/ci/config-management-smoke-assert.py
+++ b/ci/config-management-smoke-assert.py
@@ -136,6 +136,8 @@ def assert_ansible_runtime() -> None:
assert project["docker_compose_repo"] == "https://github.com/libops/isle"
assert project["project_dir"] == "/mnt/disks/data/libops/isle/isle-prod"
assert project["compose_project_name"] == "libops-isle-v1-3-1"
+ assert type(project["ingress_port"]) is int
+ assert project["ingress_port"] == 80
assert project["ingress"]["domain"] == "isle.example.edu"
assert project["sitectl_plugin"] == "isle"
assert project["init_commands"] == []
diff --git a/tests/config-management/ansible/smoke.yml b/tests/config-management/ansible/smoke.yml
index c2f33ca..11c29a7 100644
--- a/tests/config-management/ansible/smoke.yml
+++ b/tests/config-management/ansible/smoke.yml
@@ -125,6 +125,7 @@
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].docker_compose_repo == "https://github.com/libops/isle"'
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].project_dir == "/mnt/disks/data/libops/isle/isle-prod"'
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].compose_project_name == "libops-isle-v1-3-1"'
+ - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].ingress_port == 80'
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].ingress.domain == "isle.example.edu"'
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].sitectl_plugin == "isle"'
- '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].init_commands == []'