Files
member-console/test/compose.yaml
T
cgalo5758 f9c9b3225e Run the console on a runtime user and migrate on a second connection string
Fourth section of #23. db-dsn now names the console's runtime user, and
the new db-migrate-dsn (or db-migrate-dsn-file) names the user that runs
migrations. start opens both, migrates on the migration connection,
grants console_runtime to the runtime user there (WITH INHERIT TRUE, the
name quoted by the database), closes it, and serves on the runtime pool
alone. With db-migrate-dsn left out, start runs no migration and refuses
when the schema is behind its code, which is how a host runs migrations
as a separate step; migrate up, down and status use the migration string
and refuse without it, with no fallback to db-dsn.

Both pools are opened from one pgx.ParseConfig: the migration pool's
search_path is public and both pools' role is none, set in the startup
parameters after every key of either name is removed case-insensitively,
so neither a setting the database or role carries nor a key the string
repeats in another case moves a session off its login or its path.
PendingMigrations reads each stream's ledger by qualified name inside a
transaction pinned to search_path pg_catalog, pg_temp.

The test stack runs on two users: postgres-seed creates
member_console_runtime in new and existing volumes, bootstrap-stack.sh
writes MC_DB_DSN as that user and MC_DB_MIGRATE_DSN as member_console,
and the four scripts refuse a .env without the migration string, with
the command that regenerates it. Scripts and fixtures that need a
superuser on the application database (the reset, the screens'
timestamp pinning) use the migration string.

This worktree's stack was regenerated with that command and the console
booted as member_console_runtime: its sessions were the runtime user's
only, no migration-user session stayed open, and the runtime user could
not insert into core.pool_provisions.

Full suite on reset databases: 4277 pass, 15 skip, none failed. The
walkthroughs' names, results and skip messages match the previous
section's run. make screens lists three changed screens, all data:
regenerated ids on operator-person-detail and
operator-billing-invoice-detail (the demo snapshot was rebuilt) and a
domain's age on operator-domains (294d to 295d); the baseline was not
accepted. make lint passes.

Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (36):
  dbRuntime runs the migrations on the runtime pool
  dbRuntime skips the boot grant
  dbRuntime leaves the migration pool open
  dbRuntime skips the pending check when db-migrate-dsn is unset
  dbRuntime migrates on db-dsn when db-migrate-dsn is unset
  GrantRuntimeRole without WITH INHERIT TRUE
  GrantRuntimeRole with %s in place of %I
  PendingMigrations takes a missing ledger as current
  PendingMigrations resolves the ledger without the public. qualifier
  the migration pool opened without search_path=public
  the migration pool opened without role=none
  the runtime pool opened without role=none
  ConnectMigration keeps the search_path and role the string carries
  dbMigration falls back to db-dsn
  ValidateStart accepts a malformed db-migrate-dsn
  db-migrate-dsn-file is not a database secret pair
  db-migrate-dsn-file is not a database secret pair (the migrate commands read db-dsn-file only)
  the bootstrap guard removed
  reset-test-db.sh guard removed
  seed-demo.sh guard removed
  reset-app-db.sh guard after its first psql
  startup parameters match their keys case-sensitively
  the migration pool opened without role=none (keys and options in the string)
  the runtime pool opened without role=none (keys and options in the string)
  the runtime pool replaces the search_path the string carries
  the string's spaces are written as plus signs
  ValidateStart reads db-migrate-dsn as set whatever it holds
  PendingMigrations lists only the versions the ledger shows when it is missing
  migrationDSN keeps whitespace
  migrate up accepts a whitespace-only db-migrate-dsn
  runtimeDSN accepts a whitespace-only db-dsn
  openDB runs the migrating boot for an unknown mode
  bootstrap-stack.sh trusts the exported MC_DB_MIGRATE_DSN
  reset-test-db.sh trusts the exported MC_DB_MIGRATE_DSN
  reset-app-db.sh trusts the exported MC_DB_MIGRATE_DSN
  seed-demo.sh trusts the exported MC_DB_MIGRATE_DSN
2026-10-23 08:01:14 -04:00

464 lines
18 KiB
YAML

# Docker Compose file for testing Keycloak, Temporal, and Fedwiki integration
# Remember, this is for testing purposes only and not for production use
services:
# Session Store
valkey:
image: valkey/valkey:8.1
# The session store is authenticated here the way it must be in a real
# deployment: valkey-password is required configuration, so the test stack
# models the shipped posture rather than a laxer one. bootstrap-stack.sh
# generates VALKEY_PASSWORD per worktree.
command: ["valkey-server", "--requirepass", "${VALKEY_PASSWORD:-member-console-test}"]
ports:
- "${VALKEY_PORT:-6379}:6379"
# Member Console DB
postgres:
image: postgres:18.1
environment:
- POSTGRES_USER=member_console
- POSTGRES_PASSWORD=member_console
- POSTGRES_DB=member_console
volumes:
- ./testdata/postgres:/var/lib/postgresql
ports:
- "${POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U member_console -d member_console"]
interval: 5s
timeout: 5s
retries: 20
# Creates the runtime login the application serves from (member_console is
# the migration user). Runs on every `docker compose up`, so an existing
# volume gains the login without a teardown.
postgres-seed:
image: postgres:18.1
depends_on:
postgres:
condition: service_healthy
environment:
- PGPASSWORD=member_console
volumes:
- ./seed/postgres/seed-runtime-user.sh:/seed/seed-runtime-user.sh:ro
restart: "no"
entrypoint: ["/bin/bash", "/seed/seed-runtime-user.sh"]
# Identity Provider
keycloak:
image: quay.io/keycloak/keycloak:26.4.7
command: start-dev
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
- KC_HEALTH_ENABLED=true
- KC_HOSTNAME=${KC_HOSTNAME:-keycloak.localhost}
- KC_HOSTNAME_STRICT=false
healthcheck:
test:
- "CMD-SHELL"
- |
exec 3<>/dev/tcp/127.0.0.1/9000;
echo -e "GET /health/ready HTTP/1.1\r\nHost: localhost:9000\r\nConnection: close\r\n\r\n" >&3;
if cat <&3 | grep -q "\"status\": \"UP\""; then
exit 0
else
exit 1
fi
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
default:
aliases:
- ${KC_HOSTNAME:-keycloak.localhost}
ports:
- "${KEYCLOAK_PORT:-8080}:8080"
# Seed Keycloak with clients, roles, and test users
keycloak-seed:
image: alpine/curl:latest
depends_on:
keycloak:
condition: service_healthy
volumes:
- ./seed/keycloak/seed-keycloak.sh:/seed/seed-keycloak.sh:ro
environment:
- KC_URL=http://keycloak:8080
- KC_ADMIN_USER=admin
- KC_ADMIN_PASSWORD=admin
- MC_BASE_URL=${MC_BASE_URL:-http://localhost:8081}
- TEMPORAL_UI_URL=${TEMPORAL_UI_URL:-http://localhost:8233}
# Host-facing forum URL for the "discourse" OIDC client's redirect
# URIs. Seeded unconditionally (the client is inert when the
# profile-gated forum isn't running).
- DISCOURSE_URL=http://${DISCOURSE_HOSTNAME:-discourse.localhost}:${DISCOURSE_PORT:-9292}
entrypoint:
[
"/bin/sh",
"-c",
"apk add --no-cache jq bash >/dev/null 2>&1 && bash /seed/seed-keycloak.sh",
]
networks:
default:
# Temporal
temporal-db:
image: postgres:18.1
environment:
- POSTGRES_USER=temporal
- POSTGRES_PASSWORD=temporal
- POSTGRES_DB=temporal
volumes:
- ./testdata/temporal:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U temporal -d temporal"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
temporal:
image: temporalio/auto-setup:1.29.1
# temporal-db must be *accepting connections* (not merely started) before
# auto-setup runs schema setup, or the container fails and exits; and keycloak
# must be serving before temporal's first JWKS fetch, or the token key cache
# starts empty. Short-form `depends_on` waits only for container start, so
# gate on service health for both.
depends_on:
temporal-db:
condition: service_healthy
keycloak:
condition: service_healthy
command: "autosetup"
environment:
- SERVICES=frontend:history:matching:worker:internal-frontend
- DB=postgres12
- DB_PORT=5432
- POSTGRES_USER=temporal
- POSTGRES_PWD=temporal
- POSTGRES_SEEDS=temporal-db
- SKIP_DEFAULT_NAMESPACE_CREATION=true
- TEMPORAL_AUTH_AUTHORIZER=default
- TEMPORAL_AUTH_CLAIM_MAPPER=default
- TEMPORAL_JWT_KEY_SOURCE1=http://${KC_HOSTNAME:-keycloak.localhost}:8080/realms/test/protocol/openid-connect/certs
- USE_INTERNAL_FRONTEND=true
ports:
- "${TEMPORAL_PORT:-7233}:7233"
temporal-admin-tools:
image: temporalio/admin-tools:1.29
depends_on:
- temporal
environment:
- TEMPORAL_ADDRESS=temporal:7236
- TEMPORAL_CLI_ADDRESS=temporal:7236
# Registers the "default" namespace via the internal frontend (port 7236
# bypasses JWT auth). Idempotent: a "namespace already exists" failure is
# treated as success. Runs once on `up` and exits.
temporal-seed:
image: temporalio/admin-tools:1.29
depends_on:
- temporal
environment:
- TEMPORAL_ADDRESS=temporal:7236
- TEMPORAL_CLI_ADDRESS=temporal:7236
restart: "no"
entrypoint:
- /bin/sh
- -c
- |
set -eu
for i in $$(seq 1 60); do
if temporal operator namespace describe -n default >/dev/null 2>&1; then
echo "namespace 'default' already exists"; exit 0
fi
out=$$(temporal operator namespace create -n default --retention 24h 2>&1) && rc=0 || rc=$$?
echo "$$out"
if [ "$$rc" -eq 0 ]; then
echo "namespace 'default' created"; exit 0
fi
if echo "$$out" | grep -q "already exists"; then
echo "namespace 'default' already exists"; exit 0
fi
echo "waiting for temporal frontend... ($$i/60)"; sleep 2
done
echo "ERROR: temporal-seed gave up" >&2; exit 1
temporal-ui:
image: temporalio/ui:2.41.0
depends_on:
temporal:
condition: service_started
keycloak:
condition: service_healthy
# Wait for the seed to create the app realm + temporal-ui client — Temporal UI
# does OIDC discovery on /realms/test at startup and exits if it 404s.
keycloak-seed:
condition: service_completed_successfully
environment:
- TEMPORAL_ADDRESS=temporal:7233
- TEMPORAL_UI_PORT=8233
- TEMPORAL_CORS_ORIGINS=${TEMPORAL_UI_URL:-http://localhost:8233}
- TEMPORAL_AUTH_ENABLED=true
- TEMPORAL_AUTH_PROVIDER_URL=http://${KC_HOSTNAME:-keycloak.localhost}:${KEYCLOAK_PORT:-8080}/realms/test
- TEMPORAL_AUTH_ISSUER_URL=http://${KC_HOSTNAME:-keycloak.localhost}:${KEYCLOAK_PORT:-8080}/realms/test
- TEMPORAL_AUTH_CLIENT_ID=temporal-ui
- TEMPORAL_AUTH_CLIENT_SECRET=TEST-ONLY-temporal-client-secret
- TEMPORAL_AUTH_CALLBACK_URL=${TEMPORAL_UI_URL:-http://localhost:8233}/auth/sso/callback
- TEMPORAL_AUTH_SCOPES=openid,profile,email
- LOG_LEVEL=debug
# Temporal UI's OIDC login fetches discovery from inside this container and the
# browser then follows the same endpoints, so both must reach Keycloak at the
# exact host-facing URL (${KC_HOSTNAME}:${KEYCLOAK_PORT}) — otherwise the token
# issuer won't match. host-gateway resolves that hostname to the host, where the
# port is published; without it the compose network alias resolves the name to
# Keycloak's container, whose published port is not reachable in-network.
extra_hosts:
- "${KC_HOSTNAME:-keycloak.localhost}:host-gateway"
ports:
- "${TEMPORAL_UI_PORT:-8233}:8233"
# ---------------------------------------------------------------------------
# FedWiki farm — OPT-IN via the `fedwiki` compose profile. Gated for the same
# reason Discourse is (below): the stack must compose cleanly for a deployment
# that runs no wiki farm, and the app has booted FedWiki-less since
# integration-config-parity made farm-api-url/admin-token a required-together
# group that is valid when empty. The whole chain carries the profile —
# fedwiki-init, fedwiki-render, fedwiki, and the caddy TLS proxy, which exists
# solely to front this farm and nothing else depends on.
#
# Select it through the COMPOSE_PROFILES knob in test/.env (bootstrap writes
# it), not with a per-command --profile flag: seed-stack.sh and
# teardown-stack.sh resolve the same composition from the environment.
fedwiki-init:
profiles: [fedwiki]
image: busybox
# Copy seed tree, then strip render.sh and *.tpl files — those are
# rendered into /data by the fedwiki-render service after KC seed.
# chown to uid 1000 so the fedwiki container's `node` user can mkdir
# site subdirs (pages/, recycle/, ...) at runtime.
command: sh -c 'cp -rn /seed/. /data/ && find /data -name "*.tpl" -delete && rm -f /data/render.sh && chown -R 1000:1000 /data'
volumes:
- ./seed/fedwiki:/seed:ro
- ./testdata/fedwiki:/data
# Resolves real Keycloak UUIDs and templates seed/fedwiki/*.tpl into
# testdata/fedwiki/. Must run after keycloak-seed completes (users
# exist) and after fedwiki-init completes (directory structure in
# place). Writes files as root; fedwiki-init's chown -R 1000:1000
# runs first, but any files this service writes will be root-owned.
# The fedwiki node user (uid 1000) needs read-only access to these,
# which 644/755 root-owned files satisfy.
fedwiki-render:
profiles: [fedwiki]
image: alpine/curl:latest
depends_on:
keycloak-seed:
condition: service_completed_successfully
fedwiki-init:
condition: service_completed_successfully
volumes:
- ./seed/fedwiki:/seed:ro
- ./testdata/fedwiki:/data
environment:
- KC_URL=http://keycloak:8080
- KC_ADMIN_REALM=master
- KC_REALM=test
- KC_ADMIN_USER=admin
- KC_ADMIN_PASSWORD=admin
# Browser-facing Keycloak host/port — rendered into config.json's
# oauth2_discoveryUrl so the OIDC endpoints match what the browser uses.
- KC_HOSTNAME=${KC_HOSTNAME:-keycloak.localhost}
- KEYCLOAK_PORT=${KEYCLOAK_PORT:-8080}
entrypoint:
[
"/bin/sh",
"-c",
"apk add --no-cache jq gettext >/dev/null 2>&1 && sh /seed/render.sh",
]
networks:
default:
fedwiki:
profiles: [fedwiki]
image: git.coopcloud.tech/wiki-cafe/fedwiki-oci-image:0.41.0-rc.2-3
depends_on:
fedwiki-init:
condition: service_completed_successfully
fedwiki-render:
condition: service_completed_successfully
# Port 80 keeps the host out of OAuth callback URLs, so wiki-security-social's
# better-auth callback (http://<site>.localtest.me/auth/oauth2/callback/oauth2)
# has no port to mismatch. Composable still layers useraccesstokens on top of
# the social auth_provider so the farmmanager admin API keeps its bearer-token path.
command: wiki -p 80 --farm --security_type composable --auth_provider wiki-security-social --authz_enhancers wiki-plugin-useraccesstokens
# better-auth fetches OIDC discovery from inside this container, and the
# browser then follows the same endpoints — so fedwiki must reach Keycloak at
# the exact host-facing URL the browser uses (${KC_HOSTNAME}:${KEYCLOAK_PORT}).
# host-gateway resolves that hostname to the host, where the port is published.
extra_hosts:
- "${KC_HOSTNAME:-keycloak.localhost}:host-gateway"
volumes:
- ./testdata/fedwiki:/home/node/.wiki
ports:
- "${FEDWIKI_PORT:-80}:80"
# TLS termination for the FedWiki farm. wiki-security-social (better-auth) sets
# Secure cookies, which browsers only store over HTTPS — so browsers reach farm
# sites through Caddy on 443. member-console keeps using fedwiki's plain-HTTP
# admin API directly (bearer token, no cookies), so it needs no cert trust.
# Caddy's internal CA signs the wildcard cert; trust the root for manual
# browsing (see seed/caddy/Caddyfile). Host 443 ⇒ one HTTPS farm at a time —
# but only among stacks that select the `fedwiki` profile; a core-only stack
# binds nothing on 443.
caddy:
profiles: [fedwiki]
image: caddy:2-alpine
depends_on:
- fedwiki
ports:
- "${FEDWIKI_HTTPS_PORT:-443}:443"
volumes:
- ./seed/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./testdata/caddy:/data
# ---------------------------------------------------------------------------
# Live Discourse — OPT-IN via `docker compose --profile discourse up -d`.
# Profile-gated because the image is ~1.5 GB and first boot (migrations)
# takes minutes; the default stack stays fast. Mirrors the Co-op Cloud
# recipe's topology (coop-cloud/discourse: official discourse/discourse
# image + discourse/postgres + redis sidecars) so the test forum matches
# what production deployments run. The in-process fake
# (internal/integrations/discourse/discoursetest) remains the default for
# unit/DB tests; this is for walkthroughs and live API verification.
discourse-db:
profiles: [discourse]
# discourse/postgres = pgvector + Discourse's postgres management layer
# (auto-pg_upgrade on version bumps). Same image the recipe pins.
image: discourse/postgres:pg18
environment:
# Internal-only network; trust keeps the seed and app connecting
# without password juggling (recipe does the same).
- POSTGRES_HOST_AUTH_METHOD=trust
- POSTGRES_DB=discourse
- DB_USER=discourse
- DB_PASSWORD=discourse
entrypoint: ["/bin/bash", "-c", "exec run-postgres.sh postgres"]
volumes:
# The image expects the whole cluster tree mounted here (not the data
# subdir) — versioned PGDATA lives at /var/lib/postgresql/18/docker.
- ./testdata/discourse/postgres:/var/lib/postgresql
healthcheck:
test: "pg_isready -U discourse -d discourse"
interval: 5s
timeout: 5s
retries: 20
start_period: 30s
discourse-redis:
profiles: [discourse]
image: redis:7.4-alpine
healthcheck:
test: "redis-cli ping | grep -q PONG"
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
# One-shot: migrates the DB, then seeds an active+approved admin, mints a
# master API key into ./testdata/discourse/api-key, enables the bundled
# OIDC authenticator, and creates the non-automatic walkthrough group.
# Runs BEFORE the app service so Discourse boots into a migrated DB.
discourse-seed:
profiles: [discourse]
image: discourse/discourse:3.5.3
depends_on:
discourse-db:
condition: service_healthy
discourse-redis:
condition: service_healthy
restart: "no"
environment:
- DISCOURSE_HOSTNAME=${DISCOURSE_HOSTNAME:-discourse.localhost}
- DISCOURSE_DB_HOST=discourse-db
- DISCOURSE_DB_PORT=5432
- DISCOURSE_DB_NAME=discourse
- DISCOURSE_DB_USERNAME=discourse
- DISCOURSE_DB_PASSWORD=discourse
- DISCOURSE_REDIS_HOST=discourse-redis
- DISCOURSE_REDIS_PORT=6379
# Webhook → the member-console app on the host (see the discourse
# service's extra_hosts). Port must track the worktree's MC_PORT slot.
- MC_WEBHOOK_URL=http://member-console.localhost:${MC_PORT:-8081}/webhooks/discourse
- MC_WEBHOOK_SECRET=${MC_DISCOURSE_WEBHOOK_SECRET:-test-only-webhook-secret}
# Browser SSO: OIDC discovery must use the host-facing Keycloak URL so
# the endpoints it advertises are reachable from the user's browser;
# the discourse service's extra_hosts makes the same URL resolvable
# for server-side fetches. The published forum port feeds the hidden
# `port` site setting so the OmniAuth callback URL is host-reachable.
- KC_HOSTNAME=${KC_HOSTNAME:-keycloak.localhost}
- KEYCLOAK_PORT=${KEYCLOAK_PORT:-8080}
- DISCOURSE_SSO_PORT=${DISCOURSE_PORT:-9292}
volumes:
- ./seed/discourse/seed-forum.sh:/mc-seed/seed-forum.sh:ro
- ./testdata/discourse:/mc-seed-out
entrypoint: ["/bin/bash", "/mc-seed/seed-forum.sh"]
discourse:
profiles: [discourse]
# Official web-only image (published since 2025-08; "experimental" per
# Docker Hub, but it's what the coop-cloud recipe deploys). Tag matches
# the recipe pin. CMD /sbin/boot brings up nginx+rails on port 80.
image: discourse/discourse:3.5.3
depends_on:
discourse-db:
condition: service_healthy
discourse-redis:
condition: service_healthy
discourse-seed:
condition: service_completed_successfully
environment:
- DISCOURSE_HOSTNAME=${DISCOURSE_HOSTNAME:-discourse.localhost}
- DISCOURSE_DEVELOPER_EMAILS=admin@example.com
- DISCOURSE_DB_HOST=discourse-db
- DISCOURSE_DB_PORT=5432
- DISCOURSE_DB_NAME=discourse
- DISCOURSE_DB_USERNAME=discourse
- DISCOURSE_DB_PASSWORD=discourse
- DISCOURSE_REDIS_HOST=discourse-redis
- DISCOURSE_REDIS_PORT=6379
volumes:
- ./testdata/discourse/shared:/shared
# No-op the image's SSL bootstrap: stock install-ssl reconfigures
# nginx to `listen 443 ssl` against a cert that doesn't exist and
# crash-loops; the test stack talks plain HTTP (recipe does the same
# behind Traefik).
- ./seed/discourse/install-ssl-noop.sh:/etc/runit/1.d/install-ssl:ro
networks:
default:
aliases:
- ${DISCOURSE_HOSTNAME:-discourse.localhost}
# Webhook deliveries target the app running on the HOST under its
# canonical hostname (anything else bounces off the app's redirect).
# Keycloak likewise: the OIDC plugin fetches discovery/token endpoints
# server-side at the same host-facing URL the browser uses (same
# pattern as temporal-ui and fedwiki).
extra_hosts:
- "member-console.localhost:host-gateway"
- "${KC_HOSTNAME:-keycloak.localhost}:host-gateway"
ports:
- "${DISCOURSE_PORT:-9292}:80"
healthcheck:
test: "curl -fsS http://localhost/srv/status || exit 1"
interval: 15s
timeout: 10s
retries: 10
# First boot after migrations still does site-setting warmup; the
# recipe allows 25m (covers upgrade migrations) — fresh test boots
# are far faster but keep generous headroom.
start_period: 10m