#!/usr/bin/env bash # Bootstraps an isolated test stack for the current git worktree. # # - Derives a slug from the worktree directory (or $1 if given). # - Allocates a deterministic port slot via FNV-32(slug) % 200, advancing on # collisions until a free range is found. # - Copies test/secrets/ from the main worktree if missing locally. # - Writes test/.env with COMPOSE_PROJECT_NAME, host port assignments, and # MC_* env overrides for the application's Viper config. # - Idempotent: if test/.env exists, prints the summary and exits 0. # # Usage: # ./test/bootstrap-stack.sh # slug = basename of worktree # ./test/bootstrap-stack.sh my-slug # explicit slug set -euo pipefail REPO_ROOT="$(git rev-parse --show-toplevel)" TEST_DIR="${REPO_ROOT}/test" ENV_FILE="${TEST_DIR}/.env" SECRETS_DIR="${TEST_DIR}/secrets" SLOT_COUNT=200 PORT_STEP=50 # ---------- helpers ---------- print_summary() { # Prints the URL/port table from a sourced .env. Caller must have sourced it. local slot="${SLOT:-?}" cat </dev/null 2>&1; then ss -ltn "( sport = :$1 )" 2>/dev/null | grep -q LISTEN elif command -v lsof >/dev/null 2>&1; then lsof -iTCP:"$1" -sTCP:LISTEN -n -P >/dev/null 2>&1 else # Fallback: bash /dev/tcp probe (returns 0 if connect succeeds) (echo > "/dev/tcp/127.0.0.1/$1") >/dev/null 2>&1 fi } slot_ports_free() { local slot="$1" local p for p in \ $((5432 + slot * PORT_STEP)) \ $((6379 + slot * PORT_STEP)) \ $((8080 + slot * PORT_STEP)) \ $((7233 + slot * PORT_STEP)) \ $((8233 + slot * PORT_STEP)) \ $((8081 + slot * PORT_STEP)); do if port_in_use "$p"; then return 1 fi done return 0 } find_main_worktree() { # First entry of `git worktree list --porcelain` is the main worktree. git worktree list --porcelain | awk '/^worktree / { print $2; exit }' } copy_secrets_if_missing() { if [[ -d "$SECRETS_DIR" ]] && [[ -n "$(ls -A "$SECRETS_DIR" 2>/dev/null)" ]]; then return 0 fi local main main="$(find_main_worktree)" if [[ -z "$main" || "$main" == "$REPO_ROOT" ]]; then cat >&2 </dev/null)" ]]; then cat >&2 </dev/null || echo "")" if [[ -n "$branch" && "$branch" != "HEAD" && "$branch" != "main" && "$branch" != "master" ]]; then echo "mc-${branch//\//-}"; return fi echo "$base" } SLUG="${1:-$(derive_slug)}" H="$(fnv32 "$SLUG")" START_SLOT=$(( H % SLOT_COUNT )) SLOT="$START_SLOT" attempts=0 while ! slot_ports_free "$SLOT"; do SLOT=$(( (SLOT + 1) % SLOT_COUNT )) attempts=$(( attempts + 1 )) if (( attempts >= SLOT_COUNT )); then echo "ERROR: no free port slot found after scanning all ${SLOT_COUNT} slots" >&2 exit 1 fi done if (( SLOT != START_SLOT )); then echo "Slot ${START_SLOT} ports were in use; advanced to slot ${SLOT}." fi # ---------- compute ports ---------- POSTGRES_PORT=$(( 5432 + SLOT * PORT_STEP )) VALKEY_PORT=$(( 6379 + SLOT * PORT_STEP )) KEYCLOAK_PORT=$(( 8080 + SLOT * PORT_STEP )) TEMPORAL_PORT=$(( 7233 + SLOT * PORT_STEP )) TEMPORAL_UI_PORT=$(( 8233 + SLOT * PORT_STEP )) MC_PORT=$(( 8081 + SLOT * PORT_STEP )) # FedWiki defaults to host port 80; offset onto the high range. FEDWIKI_PORT=$(( 8090 + SLOT * PORT_STEP )) # Per-stack hostnames — required for cookie isolation across stacks. # Cookies are scoped per domain (not per port; RFC 6265), so two stacks on # different localhost ports would share one cookie jar and clobber each # other's sessions. *.localhost resolves to loopback per RFC 6761. KC_HOSTNAME="keycloak-${SLUG}.localhost" MC_HOSTNAME="${SLUG}.localhost" TEMPORAL_UI_HOSTNAME="temporal-ui-${SLUG}.localhost" MC_BASE_URL="http://${MC_HOSTNAME}:${MC_PORT}" TEMPORAL_UI_URL="http://${TEMPORAL_UI_HOSTNAME}:${TEMPORAL_UI_PORT}" # ---------- secrets ---------- copy_secrets_if_missing # ---------- write .env ---------- cat > "$ENV_FILE" <