Files
member-console/test/bootstrap-stack.sh
T
cgalo5758 dbd3a89099 Name the unresolved host when the FedWiki farm's name does not resolve
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.
2026-10-17 11:23:03 -05:00

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