#!/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 # Composition knob (see the .env template below). Honours a generation-time # override — `COMPOSE_PROFILES=fedwiki,discourse ./bootstrap-stack.sh` bakes a # demo composition into the generated .env. Default is core-only. COMPOSE_PROFILES="${COMPOSE_PROFILES:-}" # ---------- helpers ---------- profile_active() { # True when $1 appears in the (comma-separated) COMPOSE_PROFILES knob. local want="$1" case ",${COMPOSE_PROFILES:-}," in *",${want},"*) return 0 ;; *) return 1 ;; esac } print_summary() { # Prints the URL/port table from a sourced .env. Caller must have sourced it. local slot="${SLOT:-?}" local profiles="${COMPOSE_PROFILES:-}" local fedwiki_lines discourse_line if profile_active fedwiki; then fedwiki_lines=" FedWiki: http://localhost:${FEDWIKI_PORT:-?} FedWiki (HTTPS): https://admin.localtest.me (browser login; Caddy on :${FEDWIKI_HTTPS_PORT:-443})" else fedwiki_lines=" FedWiki: not composed (add 'fedwiki' to COMPOSE_PROFILES in .env)" fi if profile_active discourse; then discourse_line=" Discourse: http://localhost:${DISCOURSE_PORT:-?}" else discourse_line=" Discourse: not composed (add 'discourse' to COMPOSE_PROFILES in .env)" fi 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() { # Probes the base port of every service the stack can compose, including the # profile-gated integrations: a slot is only "free" if it is free for the # composition a developer might later opt into, not just today's profiles. 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)) \ $((8090 + slot * PORT_STEP)) \ $((9292 + 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 )) # Live Discourse (optional compose profile "discourse"). DISCOURSE_PORT=$(( 9292 + 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" DISCOURSE_HOSTNAME="discourse-${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" <