Files
Ekklesia/scripts/test-migrations-integration.sh
T
ekklesiaandClaude Opus 5 27088fc054
CI / verify (pull_request) Successful in 28s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 41s
fix: run the TLS integration harness under a containerized job
The `migration-smoke` job fails on the Gitea runner at
`docker port ekklesia-database-tls-<pid> 5432` with
`no public port '5432' published`; the later invalid-URL and
`ECONNREFUSED 127.0.0.1:5432` failures all cascade from it.

Gitea executes the job inside a container and drives sibling containers
through the runner's Docker daemon. The harness held two host-only
assumptions, both reproduced in a nested job container and fixed here.

Bind-mount source paths are resolved by the daemon against the runner
host's filesystem. The certificate directory is created in the job
container's `/tmp`, which does not exist there, so the daemon silently
creates an empty directory, the entrypoint's `cp` fails, the container
exits, and `docker port` reports no published port for an exited
container. The material is now transferred with `docker cp` into the
created-but-not-yet-started container, which crosses the daemon boundary
correctly in both topologies, and only the server leaf and its key are
transferred — both CA private keys stay in the harness directory.

A published port lands on the runner host's loopback, a different
network namespace from the job container's; measured, a container the
daemon published at `127.0.0.1:32907` is `ECONNREFUSED` at that address
from inside the job container. Under a containerized job the harness now
publishes nothing and joins both containers to one isolated user-defined
network, reaching PostgreSQL by container DNS on 5432. Direct-host runs
keep a random port pinned to loopback via Docker's `127.0.0.1::5432`
form. `compose_job_network_job_container` exports the topology decision
the integration script already made, so there is still exactly one
detector.

Every TLS assertion is preserved, and the certificate identity is
generated from the host actually connected to — `localhost` directly, the
container's DNS name otherwise. The mismatch and chain-only controls use
the TLS container's address on the isolated network rather than a
hardcoded one, and the certificate carries no IP SAN, so verify-full
still fails with `ERR_TLS_CERT_ALTNAME_INVALID`.

Correcting one measurement in passing: chain-only verification does not
skip the identity check. For an IP host `pg` omits `servername` and hands
`tls.connect` a bare socket with no host, and `net.Socket._host` is null
for an IP literal, so Node checks the fixed fallback name `localhost` —
never the address dialled. A certificate whose only SAN is
`DNS:example.invalid`, reached chain-only over `127.0.0.1`, is rejected
with `Host: localhost. is not in the cert's altnames`. The direct-host
control therefore only ever passed because its SAN was `DNS:localhost`.
The SAN set now always includes that fallback name so the control proves
the same gap in both topologies, and the stale claim is corrected in the
transport doc comment. No production behavior changes.

Teardown also removes the container's anonymous volume, the isolated
network, and the job container's attachment, after success and failure
alike.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 21:42:26 -05:00

105 lines
3.4 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repository_root"
# shellcheck source=scripts/lib/compose-job-network.sh
source "$repository_root/scripts/lib/compose-job-network.sh"
command -v docker >/dev/null || {
echo "Docker is required for the PostgreSQL migration integration suite." >&2
exit 1
}
command -v pnpm >/dev/null || {
echo "pnpm is required for the PostgreSQL migration integration suite." >&2
exit 1
}
if ! docker compose version >/dev/null 2>&1; then
echo "Docker Compose is required for the PostgreSQL migration integration suite." >&2
exit 1
fi
if ! compose_job_network_initialize; then
echo "The current Docker job container could not be identified." >&2
exit 1
fi
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$"
project="${raw_project//[^a-zA-Z0-9_-]/-}"
project="${project,,}"
export POSTGRES_DB="ekklesia_migration_test"
export POSTGRES_USER="ekklesia_migration_test"
export POSTGRES_PASSWORD="temporary-$project-${RANDOM}-${RANDOM}"
export POSTGRES_PORT="0"
compose=(
docker compose
--project-name "$project"
--env-file .env.example
--file infrastructure/compose.yaml
)
cleanup() {
local exit_status=$?
trap - EXIT INT TERM
if ! compose_job_network_disconnect; then
echo "PostgreSQL migration test network cleanup failed." >&2
if ((exit_status == 0)); then
exit_status=1
fi
fi
if ! "${compose[@]}" down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1; then
echo "PostgreSQL migration test infrastructure cleanup failed." >&2
if ((exit_status == 0)); then
exit_status=1
fi
fi
exit "$exit_status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if ! "${compose[@]}" config --quiet >/dev/null 2>&1; then
echo "PostgreSQL migration test infrastructure configuration is invalid." >&2
exit 1
fi
if ! "${compose[@]}" up --detach --wait --wait-timeout 120 postgres >/dev/null 2>&1; then
echo "Disposable PostgreSQL did not become ready." >&2
exit 1
fi
if [[ "$COMPOSE_JOB_NETWORK_RUNNING_IN_CONTAINER" == "true" ]]; then
if ! compose_job_network_connect postgres "${compose[@]}"; then
echo "The migration test job could not join the isolated PostgreSQL network." >&2
exit 1
fi
database_host="postgres"
database_port="5432"
else
if ! postgres_binding="$("${compose[@]}" port postgres 5432 2>/dev/null)"; then
echo "The disposable PostgreSQL loopback port could not be determined." >&2
exit 1
fi
if [[ ! "$postgres_binding" =~ ^127\.0\.0\.1:([0-9]+)$ ]]; then
echo "The disposable PostgreSQL port is not bound safely to loopback." >&2
exit 1
fi
database_host="127.0.0.1"
database_port="${BASH_REMATCH[1]}"
if ((database_port < 1 || database_port > 65535)); then
echo "The disposable PostgreSQL loopback port is invalid." >&2
exit 1
fi
fi
# The TLS suite provisions its own disposable PostgreSQL rather than a Compose service, so it
# cannot call `compose_job_network_connect`. It receives the same topology decision this script
# already made, instead of detecting the topology a second time.
EKKLESIA_TEST_JOB_CONTAINER="$(compose_job_network_job_container)" \
EKKLESIA_MIGRATION_TEST_DATABASE_URL="postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@$database_host:$database_port/$POSTGRES_DB" \
pnpm exec vitest run --config vitest.migrations.integration.config.ts