The farm is named under localtest.me, a public domain, because FedWiki's sign-in shares one session cookie across its sites and browsers do not scope a cookie to .localhost. A resolver that drops public names pointing at loopback (a router's DNS-rebind protection, some office resolvers and VPNs, or no network) made the app's farm call fail with "no such host", the sites walkthrough time out in its first phase, and the reachability guard report the farm as not composed (#236). The guard now looks the endpoint's host up first and fails the test with the host, the reason, and the two /etc/hosts lines that fix it. A name defined to point at this machine must resolve to loopback; any other host may resolve anywhere. bootstrap-stack.sh runs the same check on the farm's admin name when the fedwiki profile is on and warns on stderr without failing. test/AGENTS.md says which names must resolve here and why.
429 lines
16 KiB
Bash
Executable File
429 lines
16 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:
|
|
# cd test && ./bootstrap-stack.sh # slug = basename of worktree
|
|
# cd test && ./bootstrap-stack.sh my-slug # explicit slug
|
|
# The slug becomes COMPOSE_PROJECT_NAME, so an explicit one follows Compose's
|
|
# project-name rule: lowercase letters, digits, '-' and '_', starting with a
|
|
# letter or a digit. Any other argument, an option included, is refused
|
|
# before anything runs.
|
|
|
|
set -euo pipefail
|
|
|
|
if [[ $# -gt 1 ]] || { [[ $# -eq 1 ]] && ! [[ $1 =~ ^[a-z0-9][a-z0-9_-]*$ ]]; }; then
|
|
echo "usage: cd test && ./bootstrap-stack.sh [slug]" >&2
|
|
exit 2
|
|
fi
|
|
|
|
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:-}"
|
|
|
|
# The FedWiki farm's admin site. The farm is named under localtest.me, a public
|
|
# domain, so the name needs a DNS answer (see check_farm_name).
|
|
FARM_HOST="admin.localtest.me"
|
|
|
|
# ---------- 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://${FARM_HOST} (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 <<EOF
|
|
|
|
=== Test stack ready ===
|
|
Stack: ${COMPOSE_PROJECT_NAME}
|
|
Slot: ${slot}
|
|
Profiles: ${profiles:-none (core stack only)}
|
|
|
|
Member Console: ${MC_BASE_URL}
|
|
Keycloak: http://${KC_HOSTNAME}:${KEYCLOAK_PORT}
|
|
Temporal UI: ${TEMPORAL_UI_URL}
|
|
Temporal gRPC: localhost:${TEMPORAL_PORT}
|
|
${fedwiki_lines}
|
|
|
|
Postgres: localhost:${POSTGRES_PORT} (member_console / member_console)
|
|
databases: member_console (app), member_console_test and
|
|
member_console_e2e (tests; reset by test/reset-test-db.sh)
|
|
Valkey: localhost:${VALKEY_PORT}
|
|
${discourse_line}
|
|
|
|
Integration services are opt-in. COMPOSE_PROFILES in test/.env is the
|
|
composition knob: set it to fedwiki, discourse, both (comma-separated), or
|
|
neither, then uncomment that integration's MC_* block in the same file.
|
|
|
|
Next steps (run from test/):
|
|
docker compose up -d
|
|
set -a; . .env; set +a
|
|
go run .. start --config mc-config.yaml
|
|
|
|
To run the test suite (from the repo root), against its own pristine
|
|
databases rather than the app's:
|
|
make test
|
|
|
|
EOF
|
|
}
|
|
|
|
loopback_problem() {
|
|
# Prints why $1 does not resolve only to this machine; prints nothing when
|
|
# every address it resolves to is loopback.
|
|
local host="$1" addrs="" a bad=()
|
|
if command -v getent >/dev/null 2>&1; then
|
|
addrs="$(getent ahosts "$host" 2>/dev/null | awk '{ print $1 }' | sort -u || true)"
|
|
elif command -v dscacheutil >/dev/null 2>&1; then
|
|
addrs="$(dscacheutil -q host -a name "$host" 2>/dev/null | awk '/^(ip_address|ipv6_address):/ { print $2 }' | sort -u || true)"
|
|
else
|
|
echo "cannot be checked here (no getent or dscacheutil)"
|
|
return 0
|
|
fi
|
|
if [[ -z "$addrs" ]]; then
|
|
echo "does not resolve"
|
|
return 0
|
|
fi
|
|
while IFS= read -r a; do
|
|
case "$a" in
|
|
127.*|::1|::ffff:127.*) ;;
|
|
*) bad+=("$a") ;;
|
|
esac
|
|
done <<<"$addrs"
|
|
if ((${#bad[@]} > 0)); then
|
|
echo "resolves to ${bad[*]}, which is not this machine"
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
check_farm_name() {
|
|
# Warns on stderr when the farm's name does not resolve to this machine.
|
|
# Never changes the exit status.
|
|
profile_active fedwiki || return 0
|
|
local problem
|
|
problem="$(loopback_problem "$FARM_HOST")"
|
|
[[ -z "$problem" ]] && return 0
|
|
cat >&2 <<EOF
|
|
warning: ${FARM_HOST} ${problem}.
|
|
The FedWiki farm is reached at that name. Add these lines to /etc/hosts:
|
|
127.0.0.1 localtest.me ${FARM_HOST}
|
|
::1 localtest.me ${FARM_HOST}
|
|
See "Names that must resolve to this machine" in test/AGENTS.md.
|
|
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() {
|
|
# 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 <<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
|
|
check_farm_name
|
|
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 ))
|
|
# The session store is password-protected because valkey-password is required
|
|
# configuration; a per-worktree value keeps parallel stacks independent.
|
|
VALKEY_PASSWORD="test-$(printf '%s' "${SLUG}" | tr -c 'a-z0-9' '-')-session-store"
|
|
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" <<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.
|
|
#
|
|
# TEST-ONLY: throwaway local-development values — never reuse in a real deployment.
|
|
|
|
COMPOSE_PROJECT_NAME=${SLUG}
|
|
SLOT=${SLOT}
|
|
|
|
# --- Composition knob -------------------------------------------------------
|
|
# Which integrations this stack composes. Docker Compose reads this natively,
|
|
# so every command (up, ps, down) resolves the same set without flags.
|
|
#
|
|
# (empty) core stack only: Postgres, Valkey, Keycloak, Temporal
|
|
# fedwiki + the wiki farm chain and its Caddy TLS proxy (:443)
|
|
# discourse + the live forum, its Postgres, and its Redis
|
|
# fedwiki,discourse a full demo composition
|
|
#
|
|
# Composing an integration is separate from configuring the app for it:
|
|
# uncomment that integration's MC_* block below as well. Editing this one
|
|
# line by hand is the supported way to change composition (re-running
|
|
# bootstrap would not, and a teardown would drop the database).
|
|
COMPOSE_PROFILES=${COMPOSE_PROFILES}
|
|
|
|
# Host port bindings (consumed by test/compose.yaml)
|
|
POSTGRES_PORT=${POSTGRES_PORT}
|
|
VALKEY_PORT=${VALKEY_PORT}
|
|
VALKEY_PASSWORD=${VALKEY_PASSWORD}
|
|
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_VALKEY_PASSWORD=${VALKEY_PASSWORD}
|
|
MC_OIDC_IDP_ISSUER_URL=http://${KC_HOSTNAME}:${KEYCLOAK_PORT}/realms/test
|
|
MC_TEMPORAL_HOST=localhost:${TEMPORAL_PORT}
|
|
MC_TEMPORAL_OAUTH_TOKEN_URL=http://${KC_HOSTNAME}:${KEYCLOAK_PORT}/realms/test/protocol/openid-connect/token
|
|
|
|
# FedWiki farm (compose profile "fedwiki" — see test/seed/fedwiki/).
|
|
# App-side wiring is OPT-IN: uncomment when running with the fedwiki profile
|
|
# up. Left commented because farm-api-url + admin-token are a required-together
|
|
# config group — setting one without the other fails app boot validation, and
|
|
# pointing the app at a farm that is not composed would make every FedWiki
|
|
# surface error instead of standing down. mc-config.yaml deliberately carries
|
|
# none of these: the farm URL needs this stack's allocated port.
|
|
# FEDWIKI_HTTPS_PORT overrides Caddy's host 443 when another stack holds it.
|
|
#MC_FEDWIKI_FARM_API_URL=http://${FARM_HOST}:${FEDWIKI_PORT}
|
|
#MC_FEDWIKI_ADMIN_TOKEN=fwuat-6R-p8TMlBBrwH7bV8l5oqsdoSbij5-SFHS5oVRJzGGM
|
|
# Allowed site domains are NOT part of the farm-connection required-together
|
|
# group above: the create-form partial reads this independently of whether a
|
|
# farm is composed, so it is set (not commented) even in the core-only
|
|
# profile. Left unset, the create flow shows the zero-domains guard instead
|
|
# of a submittable form (ux-honest-surfaces 4.6) — this default lets dev
|
|
# stacks exercise the configured path without extra setup. Whitespace-
|
|
# separated if you need more than one domain (Viper casts a bare env string
|
|
# to a slice with strings.Fields). Actually provisioning a site still needs
|
|
# the farm (fedwiki profile + the two lines above uncommented).
|
|
MC_FEDWIKI_ALLOWED_DOMAINS=sites.localhost
|
|
# Periodic farm sync; needs Temporal up as well as the farm.
|
|
#MC_FEDWIKI_SYNC_ENABLED=true
|
|
|
|
# Test databases (consumed by test/reset-test-db.sh and the Go test suites).
|
|
# Deliberately NOT the app's member_console database: a running app's boot
|
|
# reconcilers would otherwise mutate state the tests assert on. Package tests
|
|
# and the in-process e2e suite are split too, because the e2e suite commits
|
|
# org-type defaults that package tests assert the absence of.
|
|
# POSTGRES_ADMIN_DSN targets the cluster's maintenance database, the only
|
|
# place a DROP/CREATE DATABASE can run from.
|
|
TEST_DATABASE_URL=postgres://member_console:member_console@localhost:${POSTGRES_PORT}/member_console_test?sslmode=disable
|
|
E2E_DATABASE_URL=postgres://member_console:member_console@localhost:${POSTGRES_PORT}/member_console_e2e?sslmode=disable
|
|
POSTGRES_ADMIN_DSN=postgres://member_console:member_console@localhost:${POSTGRES_PORT}/postgres?sslmode=disable
|
|
|
|
# Live Discourse (compose profile "discourse" — see test/seed/discourse/).
|
|
# Consumed by compose; harmless when the profile is not started.
|
|
DISCOURSE_PORT=${DISCOURSE_PORT}
|
|
DISCOURSE_HOSTNAME=${DISCOURSE_HOSTNAME}
|
|
# App-side wiring is OPT-IN: uncomment when running with the discourse
|
|
# profile up. Left commented because discourse-base-url + api-key are a
|
|
# required-together config group — setting base-url while the seeded key
|
|
# file doesn't exist yet would fail app boot validation.
|
|
#MC_DISCOURSE_BASE_URL=http://localhost:${DISCOURSE_PORT}
|
|
#MC_DISCOURSE_API_KEY_FILE=testdata/discourse/api-key
|
|
# The seed registers a forum webhook pointed at the app with this secret
|
|
# (compose default matches); uncomment so the app mounts the endpoint.
|
|
#MC_DISCOURSE_WEBHOOK_SECRET=test-only-webhook-secret
|
|
EOF
|
|
|
|
echo "Wrote ${ENV_FILE}."
|
|
|
|
# shellcheck disable=SC1090
|
|
set -a; . "$ENV_FILE"; set +a
|
|
print_summary
|
|
check_farm_name
|