Files
member-console/test/bootstrap-stack.sh
Christian Galo 535810c2ef Seed FedWiki fixtures from Keycloak
Add a fedwiki-render compose service and render.sh to resolve real
Keycloak user UUIDs and render .tpl templates into testdata on compose
up.
Convert hardcoded FedWiki testdata into templates, add seed-stack.sh
helper,
and update compose/env and .gitignore to run seeding before starting
fedwiki.
2026-04-29 15:55:40 -05:00

255 lines
7.8 KiB
Bash
Executable File

#!/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 <<EOF
=== Test stack ready ===
Stack: ${COMPOSE_PROJECT_NAME}
Slot: ${slot}
Member Console: ${MC_BASE_URL}
Keycloak: http://${KC_HOSTNAME}:${KEYCLOAK_PORT}
Temporal UI: ${TEMPORAL_UI_URL}
Temporal gRPC: localhost:${TEMPORAL_PORT}
FedWiki: http://localhost:${FEDWIKI_PORT}
FedWiki API: ${MC_FEDWIKI_FARM_API_URL}
Postgres: localhost:${POSTGRES_PORT} (member_console / member_console)
Valkey: localhost:${VALKEY_PORT}
Next steps (run from test/):
docker compose up -d
set -a; . .env; set +a
go run .. start --config mc-config.yaml
EOF
}
fnv32() {
# FNV-1a 32-bit hash, pure shell. Output: decimal integer.
local s="$1"
local h=2166136261
local i c
for ((i = 0; i < ${#s}; i++)); do
c=$(printf '%d' "'${s:i:1}")
h=$(( (h ^ c) & 0xFFFFFFFF ))
h=$(( (h * 16777619) & 0xFFFFFFFF ))
done
echo "$h"
}
port_in_use() {
# Returns 0 if any process is listening on the given TCP port.
if command -v ss >/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 <<EOF
ERROR: test/secrets/ is missing in this worktree and could not be copied
from a main worktree (this appears to be the main worktree itself,
or no main worktree was found).
Expected files:
test/secrets/stripe-api-key
test/secrets/stripe-webhook-secret
Obtain Stripe test-mode credentials from the team and place them in
test/secrets/ before running bootstrap again.
EOF
return 1
fi
local src="${main}/test/secrets"
if [[ ! -d "$src" ]] || [[ -z "$(ls -A "$src" 2>/dev/null)" ]]; then
cat >&2 <<EOF
ERROR: Source main worktree at ${main} has no test/secrets/ directory
(or it is empty). Cannot propagate credentials.
Populate ${src}/ first, then re-run bootstrap.
EOF
return 1
fi
echo "Copying test/secrets/ from ${src}..."
mkdir -p "$SECRETS_DIR"
cp -r "${src}/." "${SECRETS_DIR}/"
}
# ---------- idempotent re-run ----------
if [[ -f "$ENV_FILE" ]]; then
echo "test/.env already exists — using existing port assignments."
# shellcheck disable=SC1090
set -a; . "$ENV_FILE"; set +a
print_summary
exit 0
fi
# ---------- slug + slot ----------
derive_slug() {
# If basename of the worktree is the generic "member-console" (the case for
# the main worktree and for `git worktree add` invocations that don't pass a
# custom path), prefer the parent directory name when it looks like a
# worktree-specific label. Falls back to the branch name, then basename.
local base parent branch
base="$(basename "$REPO_ROOT")"
parent="$(basename "$(dirname "$REPO_ROOT")")"
if [[ "$base" != "member-console" ]]; then
echo "$base"; return
fi
if [[ "$parent" != "member-console" && "$parent" != "Repos" && "$parent" != "worktrees" && -n "$parent" ]]; then
echo "mc-${parent}"; return
fi
branch="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 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" <<EOF
# Generated by test/bootstrap-stack.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Slug: ${SLUG} Slot: ${SLOT}
# Re-run bootstrap to refresh; teardown removes this file.
COMPOSE_PROJECT_NAME=${SLUG}
SLOT=${SLOT}
# Host port bindings (consumed by test/compose.yaml)
POSTGRES_PORT=${POSTGRES_PORT}
VALKEY_PORT=${VALKEY_PORT}
KEYCLOAK_PORT=${KEYCLOAK_PORT}
TEMPORAL_PORT=${TEMPORAL_PORT}
TEMPORAL_UI_PORT=${TEMPORAL_UI_PORT}
FEDWIKI_PORT=${FEDWIKI_PORT}
# Per-stack URLs (consumed by compose + Keycloak seed)
KC_HOSTNAME=${KC_HOSTNAME}
MC_BASE_URL=${MC_BASE_URL}
TEMPORAL_UI_URL=${TEMPORAL_UI_URL}
# Viper overrides for mc-config.yaml (MC_ prefix, dash → underscore)
MC_PORT=${MC_PORT}
MC_BASE_URL=${MC_BASE_URL}
MC_DB_DSN=postgres://member_console:member_console@localhost:${POSTGRES_PORT}/member_console?sslmode=disable
MC_VALKEY_ADDR=localhost:${VALKEY_PORT}
MC_OIDC_IDP_ISSUER_URL=http://${KC_HOSTNAME}:${KEYCLOAK_PORT}/realms/master
MC_TEMPORAL_HOST=localhost:${TEMPORAL_PORT}
MC_TEMPORAL_OAUTH_TOKEN_URL=http://${KC_HOSTNAME}:${KEYCLOAK_PORT}/realms/master/protocol/openid-connect/token
MC_FEDWIKI_FARM_API_URL=http://admin.localtest.me:${FEDWIKI_PORT}
EOF
echo "Wrote ${ENV_FILE}."
# shellcheck disable=SC1090
set -a; . "$ENV_FILE"; set +a
print_summary