#!/usr/bin/env bash # End-to-end smoke test of the reference deployment (ADR 0044, ADR 0048): deploy/compose.yaml with # deploy/compose.proxy.yaml, the one generic platform image, installation-owned module storage, and # a disposable PostgreSQL and SeaweedFS that exist only for this run. CI runs it; it is safe to run # on a workstation. # # bash scripts/smoke-deploy.sh builds the image first # EKKLESIA_SMOKE_IMAGE= bash scripts/smoke-deploy.sh # EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh # # Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile. # It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the # default) serves the Dashboard, the API, and displays from one hostname with Caddyfile.example. # `split` serves the Dashboard on its own hostname with Caddyfile.split.example and then exercises # ADR 0048 end to end with that same image: # # - it boots with no modules; # - the repository's module template is packaged by the canonical packager as an Ekklesia Module # Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build # environment), verified by the same image's own verifier, extracted to staging, installed into # module storage, its migrations planned and explicitly applied, and the API restarted; # - a real headless Chromium drives the Dashboard, the module, its display, and sign-out; # - the module is removed and the API restarted: Core still runs, migration planning reports the # module's retained history, and its data survives; # - the exact same package is verified and installed again, and the API restarted: the module # returns with its previous data. # # EKKLESIA_SMOKE_IMAGE uses the given image instead of building one. It must be a generic image # (ADR 0048): modules come from module storage, never from the image. # # Opt-in, split topology only: EKKLESIA_SMOKE_DISPLAY_DEVICE=1 adds the display-device scenarios in # scripts/lib/deploy-smoke-display.mjs (pairing a kiosk browser with a persistent profile, a clean # restart, two pages, injected network failures, revocation), each with its own time budget. # EKKLESIA_SMOKE_DISPLAY_SCENARIOS selects a subset; EKKLESIA_SMOKE_WATCH_MS and # EKKLESIA_SMOKE_RECYCLE_WATCH_MS change how long they watch. Several scenarios expect Signage. # # TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test # run has no public hostname. Never use that option in an installation. # # Follows the documented first boot: TLS material, secrets, data services, an explicit migration # apply against this run's own fresh database, then the API and proxy. Everything it starts, and # every file it writes, is removed on exit. # # Bind mounts are resolved by the Docker daemon, on its host. When this script runs inside a CI job # container that drives the host's Docker, its own files are not at those paths, so every file a # container mounts is staged under an identical path in the Docker host's /tmp first. set -Eeuo pipefail repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repository_root" # shellcheck source=scripts/lib/compose-diagnostics.sh source "$repository_root/scripts/lib/compose-diagnostics.sh" # shellcheck source=scripts/lib/ci-project.sh source "$repository_root/scripts/lib/ci-project.sh" for command in docker openssl awk; do command -v "$command" >/dev/null || { echo "smoke-deploy: $command is required." >&2 exit 1 } done docker compose version >/dev/null 2>&1 || { echo "smoke-deploy: Docker Compose is required." >&2 exit 1 } # The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither # root nor sudo on the host. postgres_image="postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15" # The split topology's real browser: headless Chromium, pinned by its multi-architecture index digest. browser_image="chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04" topology="${EKKLESIA_SMOKE_TOPOLOGY:-same-origin}" case "$topology" in same-origin | split) ;; *) echo "smoke-deploy: EKKLESIA_SMOKE_TOPOLOGY must be same-origin or split." >&2 exit 1 ;; esac run_id="$(openssl rand -hex 4)" # Unique per invocation, and findable by run and job if this script is killed before its trap runs # (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries # the same name in EKKLESIA_CI_LABEL. project="$(ci_project_name "deploy-${topology}")" work="$(mktemp -d)" image="${EKKLESIA_SMOKE_IMAGE:-}" built_image="" # `hostname` is always the API's (and the display host's); the Dashboard has its own only when split. if [[ "$topology" == split ]]; then hostname="api.smoke.test" dashboard_hostname="dash.smoke.test" caddyfile_example="deploy/Caddyfile.split.example" else hostname="ekklesia.smoke.test" dashboard_hostname="" caddyfile_example="deploy/Caddyfile.example" fi dashboard_origin="https://${dashboard_hostname:-$hostname}" storage_hostname="storage.smoke.test" browser_container="" # choose_edge_network: a per-run edge subnet that no network on this Docker daemon already uses, so # concurrent runs sharing a runner's daemon do not collide. Two runs can still pick the same free # subnet at the same moment; the first `up` below detects that and chooses again. Fixed addresses come # from the upper half, outside Docker's dynamic range, exactly as documented. choose_edge_network() { local in_use candidate attempt in_use="$( docker network ls --quiet | xargs -r docker network inspect --format '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' 2>/dev/null | sed -n 's#^10\.249\.\([0-9]*\)\.0/24$#\1#p' )" || in_use="" for attempt in $(seq 1 100); do candidate=$((RANDOM % 200 + 20)) if ! grep -qx "$candidate" <<<"$in_use"; then octet="$candidate" edge_subnet="10.249.${octet}.0/24" edge_range="10.249.${octet}.0/25" proxy_address="10.249.${octet}.254" tunnel_address="10.249.${octet}.253" # A test-only client address, also outside the dynamic range, so its limiter bucket is its own. session_client_address="10.249.${octet}.252" # The headless browser's fixed address, likewise (split topology only). browser_address="10.249.${octet}.251" # The installed-module checks' own client address, so their sign-ins have their own bucket. module_client_address="10.249.${octet}.249" return 0 fi done echo "smoke-deploy: no free 10.249.x.0/24 edge subnet on this Docker daemon." >&2 exit 1 } choose_edge_network # daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work` # on a workstation; a staged copy on the Docker host when this script runs in a job container. if [[ -f /.dockerenv ]]; then daemon_work="/tmp/${project}" docker run --rm --entrypoint mkdir -v /tmp:/host-tmp "$postgres_image" -p "/host-tmp/${project}" # stage : copy it to the same relative path under daemon_work, keeping # numeric ownership and modes. stage() { tar -C "$work" -cf - "$1" | docker run --rm -i --entrypoint tar -v "$daemon_work:/w" "$postgres_image" -xpf - -C /w } else daemon_work="$work" stage() { :; } fi compose=( docker compose --project-name "$project" --project-directory "$daemon_work/deploy" --env-file "$work/compose.env" --file deploy/compose.yaml --file deploy/compose.proxy.yaml ) cleanup() { local status=$? trap - EXIT INT TERM if [[ -n "$browser_container" ]]; then docker rm --force "$browser_container" >/dev/null 2>&1 || true fi if [[ -n "${kiosk_container:-}" ]]; then docker ps --all --quiet --filter "name=${project}-display-" | xargs -r docker rm --force \ >/dev/null 2>&1 || true docker rm --force "$kiosk_container" >/dev/null 2>&1 || true docker volume rm --force "$kiosk_volume" >/dev/null 2>&1 || true fi if [[ -f "$work/compose.env" ]]; then "${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1 fi # Some files are owned by the PostgreSQL and SeaweedFS uids by now; remove them as root, in the # same image that set that ownership. In a job container the daemon cannot see `work` (and the # job itself runs as root), so only the staged copy on the Docker host needs this. if [[ "$daemon_work" == "$work" ]]; then docker run --rm --entrypoint rm -v "$work:/work" "$postgres_image" -rf /work/tls /work/env \ /work/modules /work/staging >/dev/null 2>&1 || true else docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \ >/dev/null 2>&1 || true fi rm -rf "$work" || true if [[ -n "$built_image" ]]; then docker image rm --force "$built_image" >/dev/null 2>&1 || true fi exit "$status" } trap cleanup EXIT INT TERM step() { echo "==> $*" } fail() { echo "smoke-deploy: $*" >&2 exit 1 } if [[ -z "$image" ]]; then # The one generic platform image, for both topologies. Nothing about either topology, and no # module, is built into it. step "Building the generic platform image" built_image="ekklesia-deploy-smoke:${run_id}" DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" \ --label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \ . >/dev/null image="$built_image" fi if [[ "$topology" == split ]]; then # The module template as a Module Package v1, from the canonical packager. Never a second platform # image: the package is installed into module storage below, after the image verifies it. step "Packaging the module template with the canonical Ekklesia packager" source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}" [[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package." DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \ --label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \ --output "type=local,dest=${work}/module-package" . >/dev/null || fail "the module package did not build." package_archive="$(cd "$work/module-package" && ls -- *-any.tar.gz)" [[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ && -f "$work/module-package/${package_archive}.sha256" ]] || fail "the packager did not produce exactly one archive and its digest." echo "ok packaged ${package_archive} ($(wc -c <"$work/module-package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/module-package/${package_archive}.sha256"))" fi # The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here. cp -R deploy "$work/deploy" stage deploy # Installation-owned module storage (ADR 0048), empty, as an operator creates it before the first # `up`: Compose refuses to start `api` or `migrate` without it, and never creates it. mkdir -m 755 "$work/modules" stage modules if [[ "$topology" == split ]]; then stage module-package # TEST-ONLY staging the verifier extracts into, as the image's unprivileged user. Nothing here is # module storage: the install step below moves a verified tree into module storage as root. mkdir -m 777 "$work/staging" stage staging fi step "Creating the installation-private CA and server certificates" init_status=0 sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$? if ((init_status != 0 && init_status != 77)); then fail "init-tls.sh failed to create TLS material." fi stage tls # The root-only ownership pass, exactly as an operator would re-run the script with root. ownership_output="$( docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \ -v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls )" || fail "init-tls.sh could not apply ownership." database_ca_line="$(grep '^EKKLESIA_DATABASE_CA=' <<<"$ownership_output")" || fail "init-tls.sh did not print the EKKLESIA_DATABASE_CA line." # A second run must create nothing and still succeed. rerun_output="$( docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \ -v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls )" || fail "init-tls.sh failed on a second run." grep -q 'nothing to create' <<<"$rerun_output" || fail "init-tls.sh is not idempotent." step "Writing env files from the examples, with freshly generated secrets" secrets="$(sh deploy/scripts/gen-secrets.sh)" secret() { awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' <<<"$secrets" } postgres_password="$(secret POSTGRES_PASSWORD)" s3_access_key="$(secret S3_ACCESS_KEY)" s3_secret_key="$(secret S3_SECRET_KEY)" identity_secret="$(secret EKKLESIA_IDENTITY_SECRET)" # Masked in any diagnostics below, along with anything else that looks like a credential. COMPOSE_DIAGNOSTICS_SECRETS=("$postgres_password" "$s3_access_key" "$s3_secret_key" "$identity_secret") mkdir -p "$work/env" # set_env : replaces an uncommented KEY= line, keeping the value byte-exact. set_env() { KEY="$2" VALUE="$3" awk ' BEGIN { found = 0 } index($0, ENVIRON["KEY"] "=") == 1 { print ENVIRON["KEY"] "=" ENVIRON["VALUE"]; found = 1; next } { print } END { if (!found) print ENVIRON["KEY"] "=" ENVIRON["VALUE"] } ' "$1" >"$1.next" mv "$1.next" "$1" } for name in ekklesia postgres object-storage; do cp "deploy/env/${name}.env.example" "$work/env/${name}.env" done ekklesia_env="$work/env/ekklesia.env" set_env "$ekklesia_env" DATABASE_URL "postgresql://ekklesia:${postgres_password}@postgres:5432/ekklesia" set_env "$ekklesia_env" EKKLESIA_DATABASE_CA "${database_ca_line#EKKLESIA_DATABASE_CA=}" set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}" set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "$dashboard_origin" if [[ "$topology" == split ]]; then set_env "$ekklesia_env" EKKLESIA_DASHBOARD_ORIGIN "$dashboard_origin" fi set_env "$ekklesia_env" EKKLESIA_IDENTITY_SECRET "$identity_secret" set_env "$ekklesia_env" S3_DELIVERY_ENDPOINT "https://${storage_hostname}" set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key" set_env "$ekklesia_env" S3_SECRET_KEY "$s3_secret_key" set_env "$work/env/postgres.env" POSTGRES_PASSWORD "$postgres_password" set_env "$work/env/object-storage.env" AWS_ACCESS_KEY_ID "$s3_access_key" set_env "$work/env/object-storage.env" AWS_SECRET_ACCESS_KEY "$s3_secret_key" chmod 600 "$work"/env/*.env # TEST-ONLY: Caddy's internal CA instead of ACME. Inserted as the first global option. awk '!done && $0 == "{" { print; print "\tlocal_certs"; done = 1; next } { print }' \ "$caddyfile_example" >"$work/Caddyfile" grep -q $'^\tlocal_certs$' "$work/Caddyfile" || fail "could not enable local_certs for the test proxy." stage Caddyfile cat >"$work/compose.env" <&1 )"; then break fi if [[ "$up_output" == *"overlap"* ]] && ((attempt < 5)); then echo " edge subnet ${edge_subnet} was taken by a concurrent run; choosing another" "${compose[@]}" down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || true choose_edge_network set_env "$work/compose.env" EKKLESIA_EDGE_SUBNET "$edge_subnet" set_env "$work/compose.env" EKKLESIA_EDGE_DYNAMIC_RANGE "$edge_range" set_env "$work/compose.env" EKKLESIA_PROXY_ADDRESS "$proxy_address" set_env "$work/compose.env" EKKLESIA_TUNNEL_ADDRESS "$tunnel_address" continue fi compose_report_not_ready "$up_output" "${compose[@]}" fail "PostgreSQL and object storage did not become healthy." done step "Planning, then explicitly applying, migrations against this run's fresh database" "${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed." "${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null || fail "migrate (apply) failed." step "Starting the API and the proxy" if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 2>&1)"; then compose_report_not_ready "$up_output" "${compose[@]}" fail "the API and proxy did not become healthy." fi # Caddy creates its internal CA on first start. The test clients trust it, plus the installation CA # for their direct connection to object-storage. for _ in $(seq 1 30); do if "${compose[@]}" exec -T proxy cat /data/caddy/pki/authorities/local/root.crt \ >"$work/caddy-root.crt" 2>/dev/null && [[ -s "$work/caddy-root.crt" ]]; then break fi sleep 1 done [[ -s "$work/caddy-root.crt" ]] || fail "the test proxy's internal CA did not appear." cat "$work/caddy-root.crt" "$work/tls/ca.crt" >"$work/client-ca.pem" cp scripts/lib/deploy-smoke-client.mjs "$work/client.mjs" stage client-ca.pem stage client.mjs edge_network="${project}_edge" host_mappings=(--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}") if [[ -n "$dashboard_hostname" ]]; then host_mappings+=(--add-host "${dashboard_hostname}:${proxy_address}") fi client() { local mode="$1" shift docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ --label "${EKKLESIA_CI_LABEL}=${project}" \ --network "$edge_network" "$@" "${host_mappings[@]}" \ -e NODE_EXTRA_CA_CERTS=/smoke/client-ca.pem -e SMOKE_HOSTNAME="$hostname" \ -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \ -e SMOKE_STORAGE_HOSTNAME="$storage_hostname" \ -e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \ -v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \ -v "$daemon_work/client-ca.pem:/smoke/client-ca.pem:ro" \ --entrypoint node "$image" /smoke/client.mjs "$mode" } step "Checking the API, Dashboard, storage delivery, and client-address chain through the proxy" client edge || fail "edge checks failed." step "Checking the tunnel connector's client-address path from its fixed address" client tunnel --ip "$tunnel_address" || fail "tunnel checks failed." step "Creating the initial owner with the image's own CLI, then signing in and out through the proxy" owner_email="owner@smoke.test" owner_password="$(openssl rand -hex 16)" printf '%s' "$owner_password" | "${compose[@]}" run --rm --no-deps -T migrate node core/dist/cli/create-initial-owner.js \ --email "$owner_email" >/dev/null || fail "creating the initial owner failed." echo "ok the initial owner was created from the image (password on standard input)" client session --ip "$session_client_address" \ -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" || fail "session checks failed." psql_as_superuser() { "${compose[@]}" exec -T postgres psql -U ekklesia -d ekklesia -Atc "$1" } # module_storage