Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion ansible/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repository path>/<application key>`; 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;
Expand Down
4 changes: 2 additions & 2 deletions ansible/roles/cloud_compose/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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) }}"
Expand Down Expand Up @@ -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 }}"
Expand Down
167 changes: 167 additions & 0 deletions ci/app-filesystem-convergence-contract.sh
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 5 additions & 5 deletions ci/config-management-smoke-inner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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 \
Expand All @@ -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() {
Expand Down
11 changes: 10 additions & 1 deletion ci/key-rotation-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions ci/source-trust-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading