From e2b665331c089cf18171980c5709a73670c2cc71 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Thu, 23 Jul 2026 10:28:36 -0400 Subject: [PATCH] [minor] Converge Compose application filesystems --- Makefile | 9 +- ansible/README.md | 5 +- ansible/roles/cloud_compose/tasks/main.yml | 4 +- ci/app-filesystem-convergence-contract.sh | 167 ++++++++++++++++++ ci/config-management-smoke-inner.sh | 10 +- ci/key-rotation-contract.sh | 11 +- ci/source-trust-contract.sh | 20 +++ docs/runtime-contracts.md | 29 ++- modules/gcp/main.tf | 2 +- modules/gcp/runtime_contracts.tftest.hcl | 24 ++- modules/linux-vm-runtime/main.tf | 2 +- .../runtime_inputs.tftest.hcl | 26 ++- rootfs/home/cloud-compose/compose-apps.sh | 123 +++++++++++++ .../cloud-compose/converge-app-filesystems.sh | 23 +++ rootfs/home/cloud-compose/rotate-keys-app.sh | 9 + rootfs/home/cloud-compose/run.sh | 4 + salt/cloud-compose/README.md | 5 +- salt/cloud-compose/init.sls | 4 +- tests/config-management/ansible/smoke.yml | 6 +- .../salt-pillar/drupal-prod.sls | 1 - .../config-management/salt-pillar/wp-prod.sls | 2 - 21 files changed, 459 insertions(+), 27 deletions(-) create mode 100755 ci/app-filesystem-convergence-contract.sh create mode 100644 rootfs/home/cloud-compose/converge-app-filesystems.sh diff --git a/Makefile b/Makefile index 6fe5a16..8611c4d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint lint-check actionlint shell-lint runtime-config-contract application-env-contract compose-runtime-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract sitectl-version-contract go-fmt-check go-vet go-contracts template-version-contract rollout-parity-contract rootfs-package-contract host-runtime-security cos-jq-portability-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract artifact-install-contract config-management-smoke cloud-compose-ci +.PHONY: lint lint-check actionlint shell-lint runtime-config-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract sitectl-version-contract go-fmt-check go-vet go-contracts template-version-contract rollout-parity-contract rootfs-package-contract host-runtime-security cos-jq-portability-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract artifact-install-contract config-management-smoke cloud-compose-ci .PHONY: terraform-fmt terraform-fmt-check terraform-validate terraform-validate-contract terraform-lint-check terraform-docs terraform-docs-check .PHONY: config-management-cloud-smoke config-management-cloud-smoke-ansible-drupal config-management-cloud-smoke-salt-drupal .PHONY: destroy-config-management-cloud-smoke destroy-config-management-cloud-smoke-ansible-drupal destroy-config-management-cloud-smoke-salt-drupal @@ -16,9 +16,9 @@ export CLOUD_COMPOSE_CI_BIN GO_MODULE_FILES := $(wildcard go.mod go.sum) GO_SOURCES := $(shell find cmd internal -type f -name '*.go') -lint: terraform-fmt actionlint shell-lint host-runtime-security cos-jq-portability-contract application-env-contract compose-runtime-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-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 rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check +lint: terraform-fmt actionlint shell-lint host-runtime-security cos-jq-portability-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-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 rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check -lint-check: terraform-fmt-check actionlint shell-lint host-runtime-security cos-jq-portability-contract application-env-contract compose-runtime-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-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 rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check +lint-check: terraform-fmt-check actionlint shell-lint host-runtime-security cos-jq-portability-contract application-env-contract compose-runtime-contract app-filesystem-convergence-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract source-trust-contract cloud-smoke-cleanup-contract hosted-cleanup-retry-contract gcp-upgrade-smoke-contract sitectl-version-contract template-version-contract rollout-parity-contract rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check actionlint: go run github.com/rhysd/actionlint/cmd/actionlint@$(ACTIONLINT_VERSION) @@ -52,6 +52,9 @@ application-env-contract: 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 diff --git a/ansible/README.md b/ansible/README.md index 603885c..444256f 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -14,7 +14,10 @@ of `/mnt/disks/data`. Before installing packages, creating accounts, or changing files, the role resolves existing symlinks on the target and rejects `/`, `/etc`, traversal, repeated/empty segments, control characters, and any symlink escape outside that boundary. The production boundary is intentionally not -configurable. +configurable. By default, the role derives +`/mnt/disks/data//`; changing a branch, tag, +or commit therefore reuses the same checkout. Keep an explicit `project_dir` +only when an established deployment path must remain unchanged. The same preflight validates `runtime.managed_runtime.artifacts` before writing the host manifest. Artifact names are safe basenames of at most 128 characters; diff --git a/ansible/roles/cloud_compose/tasks/main.yml b/ansible/roles/cloud_compose/tasks/main.yml index 5b5675f..835786b 100644 --- a/ansible/roles/cloud_compose/tasks/main.yml +++ b/ansible/roles/cloud_compose/tasks/main.yml @@ -186,7 +186,7 @@ - name: Derive cloud-compose project paths ansible.builtin.set_fact: _cc_repo_path: "{{ _cc_repo | regex_replace('^[^:]+://[^/]+/', '') | regex_replace('/+$', '') }}" - _cc_project_dir: "{{ _cc_compose.project_dir | default(cloud_compose_data_dir ~ '/' ~ (_cc_repo | regex_replace('^[^:]+://[^/]+/', '') | regex_replace('/+$', '')) ~ '/' ~ _cc_branch, true) }}" + _cc_project_dir: "{{ _cc_compose.project_dir | default(cloud_compose_data_dir ~ '/' ~ (_cc_repo | regex_replace('^[^:]+://[^/]+/', '') | regex_replace('/+$', '')) ~ '/' ~ cloud_compose_name, true) }}" _cc_compose_project_name: "{{ _cc_compose.compose_project_name | default(((_cc_repo | regex_replace('^[^:]+://[^/]+/', '') | regex_replace('/+$', '')) ~ '-' ~ _cc_branch) | lower | regex_replace('\\.git$', '') | regex_replace('[^a-zA-Z0-9]', '-') | regex_replace('-+', '-'), true) }}" _cc_explicit_projects: "{{ _cc_compose.projects | default({}, true) }}" _cc_managed_artifacts: "{{ _cc_managed.artifacts | default(cloud_compose_managed_artifacts, true) }}" @@ -269,7 +269,7 @@ docker_compose_repo: "{{ _cc_app_repo }}" docker_compose_branch: "{{ _cc_app_branch }}" repo_path: "{{ _cc_app_repo_path }}" - project_dir: "{{ item.value.project_dir | default(cloud_compose_data_dir ~ '/' ~ _cc_app_repo_path ~ '/' ~ _cc_app_branch, true) }}" + project_dir: "{{ item.value.project_dir | default(cloud_compose_data_dir ~ '/' ~ _cc_app_repo_path ~ '/' ~ item.key, true) }}" compose_project_name: "{{ item.value.compose_project_name | default((_cc_app_repo_path ~ '-' ~ _cc_app_branch) | lower | regex_replace('\\.git$', '') | regex_replace('[^a-zA-Z0-9]', '-') | regex_replace('-+', '-'), true) }}" ingress_port: "{{ item.value.ingress_port | default(_cc_ingress_port) }}" ingress: "{{ _cc_app_ingress }}" diff --git a/ci/app-filesystem-convergence-contract.sh b/ci/app-filesystem-convergence-contract.sh new file mode 100755 index 0000000..df09311 --- /dev/null +++ b/ci/app-filesystem-convergence-contract.sh @@ -0,0 +1,167 @@ +#!/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 "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" +project_dir="$CLOUD_COMPOSE_DATA_ROOT/repository/app" +mkdir -p "$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 "$project_dir" +printf 'PRESERVED=value\n' >"$project_dir/.env" +chmod 0400 "$project_dir/.env" +original_inode="$(stat -c '%d:%i' "$project_dir/.env")" +runtime_uid="$(id -u)" +runtime_gid="$(id -g)" +_converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid" + +[[ "$(<"$project_dir/.env")" == "PRESERVED=value" ]] || + fail "environment contents changed during metadata repair" +[[ "$(stat -c '%d:%i' "$project_dir/.env")" == "$original_inode" ]] || + fail "environment metadata repair replaced the file" +[[ "$(stat -c '%u:%g:%a:%h' "$project_dir/.env")" == "${runtime_uid}:${runtime_gid}:640:1" ]] || + fail "environment metadata did not converge" +[[ "$(stat -c '%u:%g:%a' "$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 "$project_dir/preserved" +printf 'unchanged\n' >"$project_dir/preserved/file" +chmod 0600 "$project_dir/preserved/file" +preserved_metadata="$(stat -c '%u:%g:%a:%i' "$project_dir/preserved/file")" +_converge_compose_app_filesystem_for_ids app "$runtime_uid" "$runtime_gid" +[[ "$(stat -c '%u:%g:%a:%i' "$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 "$project_dir/.env" "$project_dir/.env.raced" 2>/dev/null; then + ln -s "$outside_target" "$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 "$project_dir/.env" "$project_dir/.env.regular" +ln -s "$project_dir/.env.regular" "$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 "$project_dir" +rm "$project_dir/.env" + +mkdir "$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 "$project_dir" +rmdir "$project_dir/.env" + +ln "$project_dir/.env.regular" "$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 "$project_dir" +rm "$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/config-management-smoke-inner.sh b/ci/config-management-smoke-inner.sh index 63ef32d..edcb3aa 100755 --- a/ci/config-management-smoke-inner.sh +++ b/ci/config-management-smoke-inner.sh @@ -257,7 +257,7 @@ projects = json.loads(Path("/home/cloud-compose/compose-projects.json").read_tex project = projects["isle-prod"] assert env["CLOUD_COMPOSE_PROVIDER"] == "onprem" -assert env["DOCKER_COMPOSE_DIR"] == "/mnt/disks/data/libops/isle/main" +assert env["DOCKER_COMPOSE_DIR"] == "/mnt/disks/data/libops/isle/isle-prod" assert env["DOCKER_COMPOSE_REPO"] == "https://github.com/libops/isle" assert env["SITECTL_PLUGIN"] == "isle" assert "sitectl-isle" in env["SITECTL_PACKAGES"].split() @@ -267,14 +267,14 @@ assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == { "sitectl-isle": "v1.0.0", } assert project["docker_compose_repo"] == "https://github.com/libops/isle" -assert project["project_dir"] == "/mnt/disks/data/libops/isle/main" +assert project["project_dir"] == "/mnt/disks/data/libops/isle/isle-prod" assert project["ingress"]["domain"] == "isle.example.edu" assert project["sitectl_plugin"] == "isle" assert project["init_commands"] == [] assert project["up_commands"] == [] assert project["down_commands"] == ["app-down-command"] assert project["rollout_commands"] == ["global-rollout-command"] -assert Path("/mnt/disks/data/libops/isle/main").is_dir() +assert Path("/mnt/disks/data/libops/isle/isle-prod").is_dir() assert "BASH_ENV" not in env assert "LD_PRELOAD" not in env assert "PORT" not in env @@ -515,7 +515,7 @@ run_salt_case \ wp \ sitectl-wp \ wp.example.edu \ - /mnt/disks/data/libops/wp/main \ + /mnt/disks/data/libops/wp.git/wp-prod \ libops-wp-v1-0-0 run_salt_case \ @@ -525,7 +525,7 @@ run_salt_case \ drupal \ sitectl-drupal \ drupal.example.edu \ - /mnt/disks/data/libops/drupal/main \ + /mnt/disks/data/libops/drupal.git/drupal-prod \ libops-drupal-v1-0-0 run_invalid_salt_case() { diff --git a/ci/key-rotation-contract.sh b/ci/key-rotation-contract.sh index 4f0eb0d..8eff38f 100644 --- a/ci/key-rotation-contract.sh +++ b/ci/key-rotation-contract.sh @@ -54,6 +54,7 @@ cat >"$tmp/bin/chgrp" <<'EOF' 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' @@ -460,11 +461,19 @@ fi # Re-running during grace neither creates nor deletes a key. : >"$order_log" -bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null +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. diff --git a/ci/source-trust-contract.sh b/ci/source-trust-contract.sh index a4e426a..5c3c5cb 100644 --- a/ci/source-trust-contract.sh +++ b/ci/source-trust-contract.sh @@ -104,6 +104,26 @@ 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 diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md index f00d73b..7b6f185 100644 --- a/docs/runtime-contracts.md +++ b/docs/runtime-contracts.md @@ -241,7 +241,34 @@ Every Compose `project_dir` must be a normalized descendant of the fixed Ansible and Salt adapters apply the same validation before any filesystem mutation, and the host runtime resolves existing symlinks again before cloning or changing ownership. This boundary is intentionally not configurable on a -production host. +production host. When `project_dir` is omitted, every adapter derives the +stable path `/mnt/disks/data//`. The Git +branch, tag, or commit is deliberately not part of that path, so a ref update +or reconstructed VM reconciles the persistent checkout in place. Set +`project_dir` explicitly only when preserving an established deployment path +or separating checkouts beyond the repository/application identity is +required. + +Before dropping privileges during bootstrap, Cloud Compose also converges only +the exact manifest project directory and its existing `.env`: the directory is +`cloud-compose:cloud-compose` mode `0775`, and `.env` is mode `0640` with the +same owner. This repairs metadata retained from an older VM without recursively +changing checkout contents. A symbolic-link project path or a symbolic-link, +non-regular, or multiply linked `.env` fails closed. The file contents and +inode are preserved. The root bootstrap temporarily makes the verified project +directory read-only and root-owned while it performs the no-follow `.env` +validation, so an unprivileged process cannot substitute a link between the +check and metadata update. In particular, this heals a checkout initialized by +releases through 0.8.1, whose root-run application init could leave a mode +`0600` `.env` owned by root before later releases moved app lifecycle work to +the unprivileged account. + +Releases through 1.6 derived an omitted `project_dir` from the Git ref. Before +upgrading an existing deployment that relied on that default, set +`project_dir` explicitly to its current ref-scoped checkout if it must remain +at the same path. Cloud Compose does not guess whether an old checkout can be +moved safely. New deployments and callers that intentionally accept one fresh +checkout should omit the override and use the stable default. GCP snapshot overlays accept Docker volume basenames only. The overlay helper verifies that an existing mount uses the configured lower, upper, and work diff --git a/modules/gcp/main.tf b/modules/gcp/main.tf index 7236021..ebf901f 100644 --- a/modules/gcp/main.tf +++ b/modules/gcp/main.tf @@ -55,7 +55,7 @@ locals { project_dir = ( try(trimspace(app.project_dir), "") != "" ? try(trimspace(app.project_dir), "") - : "/mnt/disks/data/${trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/")}/${trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))}" + : "/mnt/disks/data/${trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/")}/${app_name}" ) compose_project_name = ( try(trimspace(app.compose_project_name), "") != "" diff --git a/modules/gcp/runtime_contracts.tftest.hcl b/modules/gcp/runtime_contracts.tftest.hcl index 307dd79..3b0ecc0 100644 --- a/modules/gcp/runtime_contracts.tftest.hcl +++ b/modules/gcp/runtime_contracts.tftest.hcl @@ -210,7 +210,7 @@ run "normalizes_minimal_compose_project" { local.compose_projects.wordpress.docker_compose_repo == "https://github.com/libops/wp.git" && local.compose_projects.wordpress.docker_compose_branch == "v1.0.0" && local.compose_projects.wordpress.repo_path == "libops/wp.git" && - local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/v1.0.0" && + local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/wordpress" && local.compose_projects.wordpress.compose_project_name == "libops-wp-v1-0-0" && local.compose_projects.wordpress.ingress_port == 80 && local.compose_projects.wordpress.ingress.letsencrypt == var.sitectl_ingress.letsencrypt && @@ -228,6 +228,28 @@ run "normalizes_minimal_compose_project" { } } +run "preserves_explicit_project_directory_during_default_migration" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + compose_projects = { + wordpress = { + docker_compose_repo = "https://github.com/libops/wp.git" + docker_compose_branch = "v1.0.0" + project_dir = "/mnt/disks/data/libops/wp.git/v1.0.0" + } + } + } + + assert { + condition = local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/v1.0.0" + error_message = "An explicit legacy project_dir must remain unchanged while callers migrate to the stable default." + } +} + run "distinguishes_inherited_and_explicit_core_only_project_packages" { command = plan diff --git a/modules/linux-vm-runtime/main.tf b/modules/linux-vm-runtime/main.tf index 0091d46..2ebadaf 100644 --- a/modules/linux-vm-runtime/main.tf +++ b/modules/linux-vm-runtime/main.tf @@ -34,7 +34,7 @@ locals { project_dir = ( try(trimspace(app.project_dir), "") != "" ? try(trimspace(app.project_dir), "") - : "/mnt/disks/data/${trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/")}/${trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))}" + : "/mnt/disks/data/${trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/")}/${app_name}" ) compose_project_name = ( try(trimspace(app.compose_project_name), "") != "" diff --git a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl index 4135776..7f1a4fe 100644 --- a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl +++ b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl @@ -51,7 +51,7 @@ run "normalizes_minimal_compose_project" { local.compose_projects.wordpress.docker_compose_repo == "https://github.com/libops/wp.git" && local.compose_projects.wordpress.docker_compose_branch == "v1.0.0" && local.compose_projects.wordpress.repo_path == "libops/wp.git" && - local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/v1.0.0" && + local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/wordpress" && local.compose_projects.wordpress.compose_project_name == "libops-wp-v1-0-0" && local.compose_projects.wordpress.ingress_port == 80 && local.compose_projects.wordpress.ingress.letsencrypt == var.sitectl_ingress.letsencrypt && @@ -69,6 +69,30 @@ run "normalizes_minimal_compose_project" { } } +run "preserves_explicit_project_directory_during_default_migration" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + compose_projects = { + wordpress = { + docker_compose_repo = "https://github.com/libops/wp.git" + docker_compose_branch = "v1.0.0" + project_dir = "/mnt/disks/data/libops/wp.git/v1.0.0" + } + } + } + + assert { + condition = local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/v1.0.0" + error_message = "An explicit legacy project_dir must remain unchanged while callers migrate to the stable default." + } +} + run "distinguishes_inherited_and_explicit_core_only_project_packages" { command = plan diff --git a/rootfs/home/cloud-compose/compose-apps.sh b/rootfs/home/cloud-compose/compose-apps.sh index f830a76..d1834b9 100644 --- a/rootfs/home/cloud-compose/compose-apps.sh +++ b/rootfs/home/cloud-compose/compose-apps.sh @@ -113,6 +113,129 @@ validate_compose_projects_manifest() { done < <(jq -r '.[] | .project_dir | @base64' "$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" diff --git a/rootfs/home/cloud-compose/converge-app-filesystems.sh b/rootfs/home/cloud-compose/converge-app-filesystems.sh new file mode 100644 index 0000000..02ac070 --- /dev/null +++ b/rootfs/home/cloud-compose/converge-app-filesystems.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ((EUID != 0)); then + echo "Compose application filesystem convergence must run as root" >&2 + exit 1 +fi + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}" +compose_apps_path="${CLOUD_COMPOSE_COMPOSE_APPS_PATH:-$script_dir/compose-apps.sh}" +cd "$script_dir" +# 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/rotate-keys-app.sh b/rootfs/home/cloud-compose/rotate-keys-app.sh index 9730c89..714cad1 100644 --- a/rootfs/home/cloud-compose/rotate-keys-app.sh +++ b/rootfs/home/cloud-compose/rotate-keys-app.sh @@ -85,6 +85,15 @@ install_app_credentials() { 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 diff --git a/rootfs/home/cloud-compose/run.sh b/rootfs/home/cloud-compose/run.sh index c6c58f2..ea2c52f 100644 --- a/rootfs/home/cloud-compose/run.sh +++ b/rootfs/home/cloud-compose/run.sh @@ -47,6 +47,10 @@ systemctl restart cloud-compose-overlay.service bash /home/cloud-compose/host-conf.sh bash /home/cloud-compose/host-init.sh +# 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 # 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. diff --git a/salt/cloud-compose/README.md b/salt/cloud-compose/README.md index 9c4937e..9a29088 100644 --- a/salt/cloud-compose/README.md +++ b/salt/cloud-compose/README.md @@ -15,7 +15,10 @@ of `/mnt/disks/data`. Before installing packages, creating accounts, or changing files, the formula resolves existing symlinks on the minion and rejects `/`, `/etc`, traversal, repeated/empty segments, control characters, and any symlink escape outside that boundary. The production boundary is intentionally not -configurable. +configurable. By default, the formula derives +`/mnt/disks/data//`; changing a branch, tag, +or commit therefore reuses the same checkout. Keep an explicit `project_dir` +only when an established deployment path must remain unchanged. The same preflight validates `runtime.managed_runtime.artifacts` before writing the host manifest. Artifact names are safe basenames of at most 128 characters; diff --git a/salt/cloud-compose/init.sls b/salt/cloud-compose/init.sls index 16a0ff5..e23a143 100644 --- a/salt/cloud-compose/init.sls +++ b/salt/cloud-compose/init.sls @@ -261,7 +261,7 @@ {% endfor %} {% set repo_path_source = repo | replace('https://github.com/', '') | replace('http://github.com/', '') | replace('git@github.com:', '') %} {% set repo_path = compose.get('repo_path') or (repo_path_source.strip('/') if repo_path_source else name) %} -{% set project_dir = compose.get('project_dir') or data_dir ~ '/' ~ repo_path ~ '/' ~ branch %} +{% set project_dir = compose.get('project_dir') or data_dir ~ '/' ~ repo_path ~ '/' ~ name %} {% set compose_project_name = compose.get('compose_project_name') or normalize_compose_project_name(repo_path, branch) %} {% set raw_explicit_projects = compose.get('projects', {}) %} {% if raw_explicit_projects is mapping %} @@ -342,7 +342,7 @@ 'docker_compose_repo': app_repo, 'docker_compose_branch': app_branch, 'repo_path': app_repo_path, - 'project_dir': app.get('project_dir') or data_dir ~ '/' ~ app_repo_path ~ '/' ~ app_branch, + 'project_dir': app.get('project_dir') or data_dir ~ '/' ~ app_repo_path ~ '/' ~ app_name, 'compose_project_name': app.get('compose_project_name') or normalize_compose_project_name(app_repo_path, app_branch), 'ingress_port': app_ingress_port, 'ingress': app_ingress, diff --git a/tests/config-management/ansible/smoke.yml b/tests/config-management/ansible/smoke.yml index 06761bf..57813e0 100644 --- a/tests/config-management/ansible/smoke.yml +++ b/tests/config-management/ansible/smoke.yml @@ -26,7 +26,6 @@ line one line two compose: - project_dir: /mnt/disks/data/libops/isle/main init: [] up: - global-up-command @@ -40,7 +39,6 @@ projects: isle-prod: docker_compose_repo: https://github.com/libops/isle - project_dir: /mnt/disks/data/libops/isle/main docker_compose_up: [] docker_compose_down: - app-down-command @@ -97,7 +95,7 @@ - >- 'CLOUD_COMPOSE_PROVIDER="onprem"' in (cloud_compose_env_file.content | b64decode) - >- - 'DOCKER_COMPOSE_DIR="/mnt/disks/data/libops/isle/main"' in (cloud_compose_env_file.content | b64decode) + 'DOCKER_COMPOSE_DIR="/mnt/disks/data/libops/isle/isle-prod"' in (cloud_compose_env_file.content | b64decode) - >- 'DOCKER_COMPOSE_REPO="https://github.com/libops/isle"' in (cloud_compose_env_file.content | b64decode) - >- @@ -118,7 +116,7 @@ - '(cloud_compose_application_env_file.content | b64decode | from_json).LD_PRELOAD == "/tmp/cloud-compose-ansible-untrusted-preload.so"' - '(cloud_compose_application_env_file.content | b64decode | from_json).PORT == "9999"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].docker_compose_repo == "https://github.com/libops/isle"' - - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].project_dir == "/mnt/disks/data/libops/isle/main"' + - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].project_dir == "/mnt/disks/data/libops/isle/isle-prod"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].compose_project_name == "libops-isle-v1-1-0"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].ingress.domain == "isle.example.edu"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].sitectl_plugin == "isle"' diff --git a/tests/config-management/salt-pillar/drupal-prod.sls b/tests/config-management/salt-pillar/drupal-prod.sls index b899ac9..07f54c2 100644 --- a/tests/config-management/salt-pillar/drupal-prod.sls +++ b/tests/config-management/salt-pillar/drupal-prod.sls @@ -8,7 +8,6 @@ cloud_compose: template: drupal runtime: compose: - project_dir: /mnt/disks/data/libops/drupal/main ingress: domain: drupal.example.edu acme_email: admin@example.edu diff --git a/tests/config-management/salt-pillar/wp-prod.sls b/tests/config-management/salt-pillar/wp-prod.sls index 3017390..cc2526e 100644 --- a/tests/config-management/salt-pillar/wp-prod.sls +++ b/tests/config-management/salt-pillar/wp-prod.sls @@ -24,7 +24,6 @@ cloud_compose: line one line two compose: - project_dir: /mnt/disks/data/libops/wp/main init: [] up: - global-up-command @@ -38,7 +37,6 @@ cloud_compose: projects: wp-prod: docker_compose_repo: https://github.com/libops/wp.git - project_dir: /mnt/disks/data/libops/wp/main docker_compose_up: [] docker_compose_down: - app-down-command