diff --git a/README.md b/README.md index c559a3d..d7f0a9b 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,20 @@ current migrations, starts 2 web and 2 worker replicas, checks the configured public-origin proxy behavior and cross-node PubSub, compares application-table counts, and removes its containers, networks, volume, and one-run image. +Verify that the tracked Git revision can deploy from a clean directory with a +new environment, independent generated secrets, dynamic ports, and no +pre-existing image or volume: + +```bash +./scripts/clean-deploy-verify.sh +``` + +The drill starts an isolated 2-web/2-worker Compose project, verifies current +and repeated migrations, HTTP and Mailpit routing, the four-node BEAM cluster, +and cross-replica PubSub, then removes its exact project, volumes, image, and +temporary tracked-file archive. It does not read or change the ordinary +Compose `.env` or database. + For the reproducible encrypted S3-compatible drill, first start the isolated load project and then run: diff --git a/compose.portability.yaml b/compose.portability.yaml new file mode 100644 index 0000000..25b3189 --- /dev/null +++ b/compose.portability.yaml @@ -0,0 +1,12 @@ +services: + migrate: + image: ${PORTABILITY_IMAGE:?Set PORTABILITY_IMAGE for the isolated portability drill} + pull_policy: never + + web: + image: ${PORTABILITY_IMAGE:?Set PORTABILITY_IMAGE for the isolated portability drill} + pull_policy: never + + worker: + image: ${PORTABILITY_IMAGE:?Set PORTABILITY_IMAGE for the isolated portability drill} + pull_policy: never diff --git a/docs/operations.md b/docs/operations.md index f27f72d..c29d141 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -193,6 +193,35 @@ containers are outside the generated project scope. Non-secret evidence is retained under ignored mode-`0700` `output/upgrade-rehearsal//`, with files mode `0600`. +## Clean tracked-revision deployment drill + +Run: + +```bash +./scripts/clean-deploy-verify.sh +``` + +The script archives the current tracked Git revision into a new temporary +directory. It verifies that the archive contains no local `.env`, Git +metadata, generated output, or existing E2E/load environment. It generates +independent one-run PostgreSQL, Phoenix, handover, BEAM-cookie, and metrics +secrets with mode `0600`; optional GitHub OAuth and push delivery stay +disabled. + +The drill uses a unique Compose project, application image, router/service +name, internal network, PostgreSQL volume, database, and dynamic host ports. +It requires exactly two healthy web and two running worker replicas, all +tracked migrations, seeded categories, an idempotent repeated migration, +working readiness/home/registration/Mailpit routes, four connected BEAM +nodes, and a cross-replica PubSub message. It then removes the exact project +including volumes, its one-run image, and the temporary archive, and verifies +that those scoped resources are absent. Evidence is retained under +`output/portability//`. + +This is a local Docker portability observation. It does not establish +production SMTP, OAuth, push-provider, TLS, database-HA, storage, capacity, or +jurisdictional readiness. + ## Service checks ```bash diff --git a/scripts/clean-deploy-verify.sh b/scripts/clean-deploy-verify.sh new file mode 100755 index 0000000..b342447 --- /dev/null +++ b/scripts/clean-deploy-verify.sh @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +for command in curl docker git jq openssl tar; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command is unavailable: $command" >&2 + exit 1 + fi +done + +run_id="$(date -u +%Y%m%d%H%M%S)-$$-$(openssl rand -hex 3)" +compact_id=${run_id//-/} +project="wnh_portability_$compact_id" +image="who-need-help:portability-$run_id" +output_dir="$ROOT/output/portability/$run_id" +workspace=$(mktemp -d "${TMPDIR:-/tmp}/wnh-portability.$run_id.XXXXXX") +env_file="$workspace/.env" + +case "$project:$image:$workspace" in + wnh_portability_*:who-need-help:portability-*:"${TMPDIR:-/tmp}"/wnh-portability.*) ;; + *) + echo "Refusing unexpected portability-drill identifiers." >&2 + exit 1 + ;; +esac + +mkdir -p "$output_dir" +chmod 700 "$ROOT/output" "$ROOT/output/portability" "$output_dir" + +compose=( + docker compose + --project-name "$project" + --project-directory "$workspace" + --env-file "$env_file" + --file "$workspace/compose.yaml" + --file "$workspace/compose.portability.yaml" +) + +cleanup() { + local status=$? + local cleanup_status=0 + + trap - EXIT HUP INT TERM + + if [[ -f "$env_file" ]]; then + if [[ "$status" -ne 0 ]]; then + "${compose[@]}" ps --all >"$output_dir/compose-ps-on-failure.txt" 2>&1 || true + "${compose[@]}" logs --no-color >"$output_dir/logs-on-failure.txt" 2>&1 || true + fi + + if ! "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1; then + echo "Could not remove the isolated portability Compose project." >&2 + cleanup_status=1 + fi + fi + + if docker image inspect "$image" >/dev/null 2>&1; then + if ! docker image rm "$image" >/dev/null 2>&1; then + echo "Could not remove the isolated portability image." >&2 + cleanup_status=1 + fi + fi + + remaining_containers=$( + docker ps --all --quiet \ + --filter "label=com.docker.compose.project=$project" + ) + remaining_volumes=$( + docker volume ls --quiet \ + --filter "label=com.docker.compose.project=$project" + ) + remaining_networks=$( + docker network ls --quiet \ + --filter "label=com.docker.compose.project=$project" + ) + + if [[ -n "$remaining_containers$remaining_volumes$remaining_networks" ]]; then + echo "Isolated portability resources remain after cleanup." >&2 + cleanup_status=1 + fi + + if [[ "$workspace" == "${TMPDIR:-/tmp}"/wnh-portability.* ]]; then + rm -rf -- "$workspace" + else + echo "Refusing to remove an unexpected workspace path: $workspace" >&2 + cleanup_status=1 + fi + + if [[ "$cleanup_status" -eq 0 ]]; then + printf 'project_resources=absent\nimage=absent\nworkspace=absent\n' \ + >"$output_dir/cleanup.txt" + fi + + if [[ "$status" -eq 0 && "$cleanup_status" -ne 0 ]]; then + status=1 + fi + + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +if [[ -n "$( + docker ps --all --quiet \ + --filter "label=com.docker.compose.project=$project" +)" ]] || + [[ -n "$( + docker volume ls --quiet \ + --filter "label=com.docker.compose.project=$project" + )" ]] || + [[ -n "$( + docker network ls --quiet \ + --filter "label=com.docker.compose.project=$project" + )" ]] || + docker image inspect "$image" >/dev/null 2>&1; then + echo "The generated portability identifiers already exist." >&2 + exit 1 +fi + +source_commit=$(git rev-parse --verify HEAD) +git archive --format=tar "$source_commit" | tar -xf - -C "$workspace" + +if [[ -e "$workspace/.git" || -e "$workspace/output" || -e "$workspace/.env.load" || + -e "$workspace/.env.e2e" ]]; then + echo "The tracked archive unexpectedly contains local state." >&2 + exit 1 +fi + +postgres_user="wnh_port_$(openssl rand -hex 6)" +postgres_db="wnh_port_$(openssl rand -hex 6)" +postgres_password=$(openssl rand -hex 32) +secret_key_base=$(openssl rand -hex 64) +handover_secret=$(openssl rand -hex 64) +release_cookie=$(openssl rand -hex 64) +metrics_token=$(openssl rand -hex 32) + +cat >"$env_file" <"$output_dir/environment.txt" + +"${compose[@]}" config --quiet +printf 'tracked_archive_config=valid\n' >"$output_dir/config.txt" + +"${compose[@]}" up --detach --wait --build +"${compose[@]}" ps --all --format json >"$output_dir/compose-ps.jsonl" + +web_count=$("${compose[@]}" ps --status running --quiet web | wc -l) +worker_count=$("${compose[@]}" ps --status running --quiet worker | wc -l) +db_count=$("${compose[@]}" ps --status running --quiet db | wc -l) +proxy_count=$("${compose[@]}" ps --status running --quiet proxy | wc -l) +mailpit_count=$("${compose[@]}" ps --status running --quiet mailpit | wc -l) +migrate_exit=$( + "${compose[@]}" ps --all --format json migrate | + jq -sr 'if length == 1 then .[0].ExitCode else null end' +) + +if [[ "$web_count" -ne 2 || "$worker_count" -ne 2 || + "$db_count" -ne 1 || "$proxy_count" -ne 1 || + "$mailpit_count" -ne 1 || "$migrate_exit" != "0" ]]; then + echo "The clean deployment did not produce the expected topology." >&2 + exit 1 +fi + +mapfile -t app_containers < <("${compose[@]}" ps --quiet web worker) +if [[ "${#app_containers[@]}" -ne 4 ]]; then + echo "Expected four running application containers." >&2 + exit 1 +fi + +expected_image_id=$(docker image inspect "$image" --format '{{.Id}}') +for container in "${app_containers[@]}"; do + actual_image_id=$(docker inspect "$container" --format '{{.Image}}') + if [[ "$actual_image_id" != "$expected_image_id" ]]; then + echo "An application container is not using the archived-source image." >&2 + exit 1 + fi +done +printf '%s\n' "$expected_image_id" >"$output_dir/application-image-id.txt" + +expected_migrations=$( + find "$workspace/priv/repo/migrations" -type f -name '[0-9]*.exs' | + wc -l +) +"${compose[@]}" exec -T db \ + psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ + --username "$postgres_user" --dbname "$postgres_db" \ + --command "SELECT version FROM schema_migrations ORDER BY version;" \ + >"$output_dir/migrations-before-repeat.txt" +actual_migrations=$(wc -l <"$output_dir/migrations-before-repeat.txt") + +if [[ "$actual_migrations" -ne "$expected_migrations" ]]; then + echo "Fresh migration count is $actual_migrations; expected $expected_migrations." >&2 + exit 1 +fi + +category_count_before=$( + "${compose[@]}" exec -T db \ + psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ + --username "$postgres_user" --dbname "$postgres_db" \ + --command "SELECT count(*) FROM categories;" +) + +if [[ "$category_count_before" -le 0 ]]; then + echo "Fresh migration did not seed any product category." >&2 + exit 1 +fi + +"${compose[@]}" run --rm migrate >"$output_dir/repeated-migrate.txt" + +"${compose[@]}" exec -T db \ + psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ + --username "$postgres_user" --dbname "$postgres_db" \ + --command "SELECT version FROM schema_migrations ORDER BY version;" \ + >"$output_dir/migrations-after-repeat.txt" +category_count_after=$( + "${compose[@]}" exec -T db \ + psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ + --username "$postgres_user" --dbname "$postgres_db" \ + --command "SELECT count(*) FROM categories;" +) + +if ! diff -u "$output_dir/migrations-before-repeat.txt" \ + "$output_dir/migrations-after-repeat.txt" >"$output_dir/migrations-repeat.diff" || + [[ "$category_count_before" -ne "$category_count_after" ]]; then + echo "The repeated migration changed migration or category counts." >&2 + exit 1 +fi + +{ + printf 'expected_migrations=%s\n' "$expected_migrations" + printf 'actual_migrations=%s\n' "$actual_migrations" + printf 'categories_before_repeat=%s\n' "$category_count_before" + printf 'categories_after_repeat=%s\n' "$category_count_after" +} >"$output_dir/database.txt" + +published=$("${compose[@]}" port proxy 80 | head -n 1) +http_port=${published##*:} +mailpit_published=$("${compose[@]}" port mailpit 8025 | head -n 1) +mailpit_port=${mailpit_published##*:} + +if [[ ! "$http_port" =~ ^[1-9][0-9]*$ || + ! "$mailpit_port" =~ ^[1-9][0-9]*$ ]]; then + echo "Compose did not publish dynamic local ports." >&2 + exit 1 +fi + +curl --fail-with-body --silent --show-error \ + --header 'Host: portability.local' \ + "http://127.0.0.1:$http_port/healthz/ready" \ + >"$output_dir/readiness.json" +curl --fail-with-body --silent --show-error \ + --header 'Host: portability.local' \ + "http://127.0.0.1:$http_port/" \ + >"$output_dir/home.html" +curl --fail-with-body --silent --show-error \ + --header 'Host: portability.local' \ + "http://127.0.0.1:$http_port/users/register" \ + >"$output_dir/register.html" +curl --fail-with-body --silent --show-error \ + "http://127.0.0.1:$mailpit_port/api/v1/info" \ + >"$output_dir/mailpit.json" + +( + cd "$workspace" + COMPOSE_PROJECT_NAME="$project" \ + ./scripts/verify-realtime-cluster.sh compose +) >"$output_dir/pubsub-probe.txt" + +"${compose[@]}" exec -T web /app/bin/who_need_help rpc ' + nodes = [node() | Node.list()] |> Enum.sort() + result = %{node_count: length(nodes), nodes: nodes} + IO.inspect(result) + if result.node_count != 4, do: exit({:unexpected_cluster_size, result}) +' >"$output_dir/cluster-size.txt" + +docker inspect "${app_containers[@]}" | + jq '[ + .[] | { + name: .Name, + image: .Image, + restart_count: .RestartCount, + oom_killed: .State.OOMKilled, + status: .State.Status, + health: (.State.Health.Status // null) + } + ]' >"$output_dir/application-containers.json" + +if ! jq -e ' + length == 4 and + all( + .status == "running" and + .restart_count == 0 and + .oom_killed == false and + (.health == null or .health == "healthy") + ) +' "$output_dir/application-containers.json" >/dev/null; then + echo "A clean-deployment application container is unhealthy." >&2 + exit 1 +fi + +printf '%s\n' \ + "tracked archive clean deployment passed: $project" \ + "2 web + 2 worker replicas, current migrations, repeated migrate, HTTP, Mailpit, cluster, and PubSub passed"