chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exit 0 only when the capability probe itself succeeds and advertises verify.
|
||||
# A failed help command and a successful response without verify are both
|
||||
# unsupported, matching the historical e2e-install-test.sh conditional.
|
||||
set -uo pipefail
|
||||
|
||||
gateway_help_rc=0
|
||||
gateway_help="$(mosaic gateway --help 2>&1)" || gateway_help_rc=$?
|
||||
[[ "$gateway_help_rc" -eq 0 ]] || exit 1
|
||||
grep -q 'verify' <<<"$gateway_help"
|
||||
Executable
+184
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─── Mosaic Stack — End-to-End Install Test ────────────────────────────────────
|
||||
#
|
||||
# Runs a clean-container install test to verify the full first-run flow:
|
||||
# tools/install.sh -> mosaic wizard (non-interactive)
|
||||
# -> mosaic gateway install
|
||||
# -> mosaic gateway verify
|
||||
#
|
||||
# Usage:
|
||||
# bash tools/e2e-install-test.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - Docker (skips gracefully if not available)
|
||||
# - Run from the repository root
|
||||
#
|
||||
# How it works:
|
||||
# 1. Mounts the repository into a node:22-alpine container.
|
||||
# 2. Installs prerequisites (bash, curl, jq, git) inside the container.
|
||||
# 3. Runs `bash tools/install.sh --yes --no-auto-launch` to install the
|
||||
# framework and CLI from the Gitea registry.
|
||||
# 4. Runs `mosaic wizard --non-interactive` to set up SOUL/USER.
|
||||
# 5. Runs `mosaic gateway install` with piped defaults (non-interactive).
|
||||
# 6. Runs `mosaic gateway verify` and checks its exit code.
|
||||
# NOTE: `mosaic gateway verify` is a new command added in the
|
||||
# feat/mosaic-first-run-ux branch. If the installed CLI version
|
||||
# pre-dates this branch (does not have `gateway verify`), the test
|
||||
# marks this step as EXPECTED-SKIP and reports the installed version.
|
||||
# 7. Reports PASS or FAIL with a summary.
|
||||
#
|
||||
# To run manually:
|
||||
# cd /path/to/mosaic-stack
|
||||
# bash tools/e2e-install-test.sh
|
||||
#
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE="node:22-alpine"
|
||||
CONTAINER_NAME="mosaic-e2e-install-$$"
|
||||
|
||||
# ─── Colour helpers ───────────────────────────────────────────────────────────
|
||||
if [[ -t 1 ]]; then
|
||||
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' BOLD=$'\033[1m' RESET=$'\033[0m'
|
||||
else
|
||||
R="" G="" Y="" BOLD="" RESET=""
|
||||
fi
|
||||
|
||||
info() { echo "${BOLD}[e2e]${RESET} $*"; }
|
||||
ok() { echo "${G}[PASS]${RESET} $*"; }
|
||||
fail() { echo "${R}[FAIL]${RESET} $*" >&2; }
|
||||
warn() { echo "${Y}[WARN]${RESET} $*"; }
|
||||
|
||||
# ─── Docker availability check ────────────────────────────────────────────────
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "Docker not found — skipping e2e install test."
|
||||
warn "Install Docker and re-run this script to exercise the full install flow."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! docker info &>/dev/null 2>&1; then
|
||||
warn "Docker daemon is not running or not accessible — skipping e2e install test."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Docker available — proceeding with e2e install test."
|
||||
info "Repo root: ${REPO_ROOT}"
|
||||
info "Container image: ${IMAGE}"
|
||||
|
||||
# ─── Inline script that runs INSIDE the container ────────────────────────────
|
||||
INNER_SCRIPT="$(mktemp /tmp/mosaic-e2e-inner-XXXXXX.sh)"
|
||||
trap 'rm -f "$INNER_SCRIPT"' EXIT
|
||||
|
||||
cat > "$INNER_SCRIPT" <<'INNER_SCRIPT_EOF'
|
||||
#!/bin/sh
|
||||
# Bootstrap: /bin/sh until bash is installed, then re-exec.
|
||||
set -e
|
||||
|
||||
echo "=== [inner] Installing system prerequisites ==="
|
||||
apk add --no-cache bash curl jq git 2>/dev/null || \
|
||||
apt-get install -y -q bash curl jq git 2>/dev/null || true
|
||||
|
||||
# Re-exec under bash.
|
||||
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1; then
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
|
||||
# ── bash from here ────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== [inner] Node.js / npm versions ==="
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
echo "=== [inner] Setting up npm global prefix ==="
|
||||
export NPM_PREFIX="/root/.npm-global"
|
||||
mkdir -p "$NPM_PREFIX/bin"
|
||||
npm config set prefix "$NPM_PREFIX" 2>/dev/null || true
|
||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
||||
|
||||
echo "=== [inner] Running install.sh --yes --no-auto-launch ==="
|
||||
# Install both framework and CLI from the Gitea registry.
|
||||
MOSAIC_SKIP_SKILLS_SYNC=1 \
|
||||
MOSAIC_ASSUME_YES=1 \
|
||||
bash /repo/tools/install.sh --yes --no-auto-launch
|
||||
|
||||
INSTALLED_VERSION="$(mosaic --version 2>/dev/null || echo 'unknown')"
|
||||
echo "[inner] mosaic CLI installed: ${INSTALLED_VERSION}"
|
||||
|
||||
echo "=== [inner] Running mosaic wizard (non-interactive) ==="
|
||||
mosaic wizard \
|
||||
--non-interactive \
|
||||
--name "test-agent" \
|
||||
--user-name "tester" \
|
||||
--pronouns "they/them" \
|
||||
--timezone "UTC" || {
|
||||
echo "[WARN] mosaic wizard exited non-zero — continuing"
|
||||
}
|
||||
|
||||
echo "=== [inner] Running mosaic gateway install ==="
|
||||
# Feed non-interactive answers:
|
||||
# "1" → storage tier: local
|
||||
# "" → port: accept default (14242)
|
||||
# "" → ANTHROPIC_API_KEY: skip
|
||||
# "" → CORS origin: accept default
|
||||
# Then admin bootstrap: name, email, password
|
||||
printf '1\n\n\n\nTest Admin\[email protected]\ntestpassword123\n' \
|
||||
| mosaic gateway install
|
||||
INSTALL_EXIT="$?"
|
||||
if [ "${INSTALL_EXIT}" -ne 0 ]; then
|
||||
echo "[ERR] mosaic gateway install exited ${INSTALL_EXIT}"
|
||||
mosaic gateway status 2>/dev/null || true
|
||||
exit "${INSTALL_EXIT}"
|
||||
fi
|
||||
|
||||
echo "=== [inner] Running mosaic gateway verify ==="
|
||||
# `gateway verify` was added in feat/mosaic-first-run-ux.
|
||||
# If the installed version pre-dates this, skip gracefully.
|
||||
if ! bash /repo/tools/e2e-gateway-verify-supported.sh; then
|
||||
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
|
||||
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
|
||||
echo "[SKIP] Re-run after the new version is published to validate this step."
|
||||
# Treat as pass — the install flow itself worked.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mosaic gateway verify
|
||||
VERIFY_EXIT="$?"
|
||||
echo "=== [inner] verify exit code: ${VERIFY_EXIT} ==="
|
||||
exit "${VERIFY_EXIT}"
|
||||
INNER_SCRIPT_EOF
|
||||
|
||||
chmod +x "$INNER_SCRIPT"
|
||||
|
||||
# ─── Pull image ───────────────────────────────────────────────────────────────
|
||||
info "Pulling ${IMAGE}…"
|
||||
docker pull "${IMAGE}" --quiet
|
||||
|
||||
# ─── Run container ────────────────────────────────────────────────────────────
|
||||
info "Starting container ${CONTAINER_NAME}…"
|
||||
|
||||
EXIT_CODE=0
|
||||
docker run --rm \
|
||||
--name "${CONTAINER_NAME}" \
|
||||
--volume "${REPO_ROOT}:/repo:ro" \
|
||||
--volume "${INNER_SCRIPT}:/e2e-inner.sh:ro" \
|
||||
--network host \
|
||||
"${IMAGE}" \
|
||||
/bin/sh /e2e-inner.sh \
|
||||
|| EXIT_CODE=$?
|
||||
|
||||
# ─── Report ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
if [[ "$EXIT_CODE" -eq 0 ]]; then
|
||||
ok "End-to-end install test PASSED (exit ${EXIT_CODE})"
|
||||
else
|
||||
fail "End-to-end install test FAILED (exit ${EXIT_CODE})"
|
||||
echo ""
|
||||
echo " Troubleshooting:"
|
||||
echo " - Review the output above for the failing step."
|
||||
echo " - Re-run with bash -x tools/e2e-install-test.sh for verbose trace."
|
||||
echo " - Run mosaic gateway logs inside a manual container for daemon output."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,254 @@
|
||||
# Federation Test Harness
|
||||
|
||||
Local two-gateway federation test infrastructure for Mosaic Stack M3+.
|
||||
|
||||
This harness boots two real gateway instances (`gateway-a`, `gateway-b`) on a
|
||||
shared Docker bridge network, each backed by its own Postgres (pgvector) +
|
||||
Valkey, sharing a single Step-CA. It is the test bed for all M3+ federation
|
||||
E2E tests.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker with Compose v2 (`docker compose version` ≥ 2.20)
|
||||
- pnpm (for running via repo scripts)
|
||||
- `infra/step-ca/dev-password` must exist (copy from `infra/step-ca/dev-password.example`)
|
||||
|
||||
## Network Topology
|
||||
|
||||
```
|
||||
Host machine
|
||||
├── localhost:14001 → gateway-a (Server A — home / requesting)
|
||||
├── localhost:14002 → gateway-b (Server B — work / serving)
|
||||
├── localhost:15432 → postgres-a
|
||||
├── localhost:15433 → postgres-b
|
||||
├── localhost:16379 → valkey-a
|
||||
├── localhost:16380 → valkey-b
|
||||
└── localhost:19000 → step-ca (shared CA)
|
||||
|
||||
Docker network: fed-test-net (bridge)
|
||||
gateway-a ←──── mTLS ────→ gateway-b
|
||||
↘ ↗
|
||||
step-ca
|
||||
```
|
||||
|
||||
Ports are chosen to avoid collision with the base dev stack (5433, 6380, 14242, 9000).
|
||||
|
||||
## Starting the Harness
|
||||
|
||||
```bash
|
||||
# From repo root
|
||||
docker compose -f tools/federation-harness/docker-compose.two-gateways.yml up -d
|
||||
|
||||
# Wait for all services to be healthy (~60-90s on first boot due to NestJS cold start)
|
||||
docker compose -f tools/federation-harness/docker-compose.two-gateways.yml ps
|
||||
```
|
||||
|
||||
## Seeding Test Data
|
||||
|
||||
The seed script provisions three grant scope variants (A, B, C) and walks the
|
||||
full enrollment flow so Server A ends up with active peers pointing at Server B.
|
||||
|
||||
```bash
|
||||
# Assumes stack is already running
|
||||
pnpm tsx tools/federation-harness/seed.ts
|
||||
|
||||
# Or boot + seed in one step
|
||||
pnpm tsx tools/federation-harness/seed.ts --boot
|
||||
```
|
||||
|
||||
### Scope Variants
|
||||
|
||||
| Variant | Resources | Filters | Excluded | Purpose |
|
||||
| ------- | ------------------ | ---------------------------------- | ----------- | ------------------------------- |
|
||||
| A | tasks, notes | include_personal: true | (none) | Personal data federation |
|
||||
| B | tasks | include_teams: ['T1'], no personal | (none) | Team-scoped, no personal |
|
||||
| C | tasks, credentials | include_personal: true | credentials | Sanity: excluded wins over list |
|
||||
|
||||
## Using from Vitest
|
||||
|
||||
```ts
|
||||
import {
|
||||
bootHarness,
|
||||
tearDownHarness,
|
||||
serverA,
|
||||
serverB,
|
||||
seed,
|
||||
} from '../../tools/federation-harness/harness.js';
|
||||
import type { HarnessHandle } from '../../tools/federation-harness/harness.js';
|
||||
|
||||
let handle: HarnessHandle;
|
||||
|
||||
beforeAll(async () => {
|
||||
handle = await bootHarness();
|
||||
}, 180_000); // allow 3 min for Docker pull + NestJS cold start
|
||||
|
||||
afterAll(async () => {
|
||||
await tearDownHarness(handle);
|
||||
});
|
||||
|
||||
test('variant A: list tasks returns personal tasks', async () => {
|
||||
// NOTE: Only 'all' is supported for now — per-variant narrowing is M3-11.
|
||||
const seedResult = await seed(handle, 'all');
|
||||
const a = serverA(handle);
|
||||
|
||||
const res = await fetch(`${a.baseUrl}/api/federation/tasks`, {
|
||||
headers: { 'x-federation-grant': seedResult.grants.variantA.id },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
```
|
||||
|
||||
> **Note:** `seed()` bootstraps a fresh admin user on each gateway via
|
||||
> `POST /api/bootstrap/setup`. Both gateways must have zero users (pristine DB).
|
||||
> If either gateway already has users, `seed()` throws with a clear error.
|
||||
> Reset state with `docker compose down -v`.
|
||||
|
||||
The `bootHarness()` function is **idempotent**: if both gateways are already
|
||||
healthy, it reuses the running stack and returns `ownedStack: false`. Tests
|
||||
should not call `tearDownHarness` when `ownedStack` is false unless they
|
||||
explicitly want to shut down a shared stack.
|
||||
|
||||
## Vitest Config (pnpm test:federation)
|
||||
|
||||
Add to `vitest.config.ts` at repo root (or a dedicated config):
|
||||
|
||||
```ts
|
||||
// vitest.federation.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['**/*.federation.test.ts'],
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 180_000,
|
||||
reporters: ['verbose'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Then add to root `package.json`:
|
||||
|
||||
```json
|
||||
"test:federation": "vitest run --config vitest.federation.config.ts"
|
||||
```
|
||||
|
||||
## Nuking State
|
||||
|
||||
```bash
|
||||
# Remove containers AND volumes (ephemeral state — CA keys, DBs, everything)
|
||||
docker compose -f tools/federation-harness/docker-compose.two-gateways.yml down -v
|
||||
```
|
||||
|
||||
On next `up`, Step-CA re-initialises from scratch and generates new CA keys.
|
||||
|
||||
## Step-CA Root Certificate
|
||||
|
||||
The CA root lives in the `fed-harness-step-ca` Docker volume at
|
||||
`/home/step/certs/root_ca.crt`. To extract it to the host:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v fed-harness-step-ca:/home/step \
|
||||
alpine cat /home/step/certs/root_ca.crt > /tmp/fed-harness-root-ca.crt
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port conflicts
|
||||
|
||||
Default host ports: 14001, 14002, 15432, 15433, 16379, 16380, 19000.
|
||||
Override via environment variables before `docker compose up`:
|
||||
|
||||
```bash
|
||||
GATEWAY_A_HOST_PORT=14101 GATEWAY_B_HOST_PORT=14102 \
|
||||
docker compose -f tools/federation-harness/docker-compose.two-gateways.yml up -d
|
||||
```
|
||||
|
||||
### Image pull failures
|
||||
|
||||
The gateway image is digest-pinned to:
|
||||
|
||||
```
|
||||
git.mosaicstack.dev/mosaicstack/stack/gateway@sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02
|
||||
```
|
||||
|
||||
(sha-9f1a081, post-#491 IMG-FIX)
|
||||
|
||||
If the registry is unreachable, Docker will use the locally cached image if
|
||||
present. If no local image exists, the compose up will fail with a pull error.
|
||||
In that case:
|
||||
|
||||
1. Ensure you can reach `git.mosaicstack.dev` (VPN, DNS, etc.).
|
||||
2. Log in: `docker login git.mosaicstack.dev`
|
||||
3. Pull manually: `docker pull git.mosaicstack.dev/mosaicstack/stack/gateway@sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02`
|
||||
|
||||
### NestJS cold start
|
||||
|
||||
Gateway containers take 40–60 seconds to become healthy on first boot (Node.js
|
||||
module resolution + NestJS DI bootstrap). The `start_period: 60s` in the
|
||||
compose healthcheck covers this. `bootHarness()` polls for up to 3 minutes.
|
||||
|
||||
### Step-CA startup
|
||||
|
||||
Step-CA initialises on first boot (generates CA keys). This takes ~5-10s.
|
||||
The `start_period: 30s` in the healthcheck covers it. Both gateways wait for
|
||||
Step-CA to be healthy before starting (`depends_on: step-ca: condition: service_healthy`).
|
||||
|
||||
### dev-password missing
|
||||
|
||||
The Step-CA container requires `infra/step-ca/dev-password` to be mounted.
|
||||
Copy the example and set a local password:
|
||||
|
||||
```bash
|
||||
cp infra/step-ca/dev-password.example infra/step-ca/dev-password
|
||||
# Edit the file to set your preferred dev CA password
|
||||
```
|
||||
|
||||
The file is `.gitignore`d — do not commit it.
|
||||
|
||||
## Image Digest Note
|
||||
|
||||
The gateway image is pinned to `sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02`
|
||||
(sha-9f1a081). This is the digest promoted by PR #491 (IMG-FIX). The `latest`
|
||||
tag is forbidden per Mosaic image policy. When a new gateway build is promoted,
|
||||
update the digest in `docker-compose.two-gateways.yml` and in this file.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### BETTER_AUTH_URL enrollment URL bug (upstream production code — not yet fixed)
|
||||
|
||||
`apps/gateway/src/federation/federation.controller.ts:145` constructs the
|
||||
enrollment URL using `process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242'`.
|
||||
This is an upstream bug: `BETTER_AUTH_URL` is the Better Auth origin (typically
|
||||
the web app), not the gateway's own base URL. In non-harness deployments this
|
||||
produces an enrollment URL pointing to the wrong host or port.
|
||||
|
||||
**How the harness handles this:**
|
||||
|
||||
1. **In-cluster calls (container-to-container):** The compose file sets
|
||||
`BETTER_AUTH_URL: 'http://gateway-b:3000'` so the enrollment URL returned by
|
||||
the gateway uses the Docker internal hostname. This lets other containers in the
|
||||
`fed-test-net` network resolve and reach Server B's enrollment endpoint.
|
||||
|
||||
2. **Host-side URL rewrite (seed script):** The `seed.ts` script runs on the host
|
||||
machine where `gateway-b` is not a resolvable hostname. Before calling
|
||||
`fetch(enrollmentUrl, ...)`, the seed script rewrites the URL: it extracts only
|
||||
the token path segment from `enrollmentUrl` and reassembles the URL using the
|
||||
host-accessible `serverBUrl` (default: `http://localhost:14002`). This lets the
|
||||
seed script redeem enrollment tokens from the host without being affected by the
|
||||
in-cluster hostname in the returned URL.
|
||||
|
||||
**TODO:** Fix `federation.controller.ts` to derive the enrollment URL from its own
|
||||
listening address (e.g. `GATEWAY_BASE_URL` env var or a dedicated
|
||||
`FEDERATION_ENROLLMENT_BASE_URL` env var) rather than reusing `BETTER_AUTH_URL`.
|
||||
Tracked as a follow-up to PR #505 — do not bundle with harness changes.
|
||||
|
||||
## Permanent Infrastructure
|
||||
|
||||
This harness is designed to outlive M3 and be reused by M4+ milestone tests.
|
||||
It is not a throwaway scaffold — treat it as production test infrastructure:
|
||||
|
||||
- Keep it idempotent.
|
||||
- Do not hardcode test assumptions in the harness layer (put them in tests).
|
||||
- Update the seed script when new scope variants are needed.
|
||||
- The README and harness should be kept in sync as the federation API evolves.
|
||||
@@ -0,0 +1,247 @@
|
||||
# tools/federation-harness/docker-compose.two-gateways.yml
|
||||
#
|
||||
# Two-gateway federation test harness — local-only, no Portainer/Swarm needed.
|
||||
#
|
||||
# USAGE (manual):
|
||||
# docker compose -f tools/federation-harness/docker-compose.two-gateways.yml up -d
|
||||
# docker compose -f tools/federation-harness/docker-compose.two-gateways.yml down -v
|
||||
#
|
||||
# USAGE (from harness.ts):
|
||||
# const handle = await bootHarness();
|
||||
# ...
|
||||
# await tearDownHarness(handle);
|
||||
#
|
||||
# TOPOLOGY:
|
||||
# gateway-a — "home" instance (Server A, the requesting side)
|
||||
# └── postgres-a (pgvector/pg17, port 15432)
|
||||
# └── valkey-a (port 16379)
|
||||
# gateway-b — "work" instance (Server B, the serving side)
|
||||
# └── postgres-b (pgvector/pg17, port 15433)
|
||||
# └── valkey-b (port 16380)
|
||||
# step-ca — shared CA for both gateways (port 19000)
|
||||
#
|
||||
# All services share the `fed-test-net` bridge network.
|
||||
# Host port ranges (15432-15433, 16379-16380, 14001-14002, 19000) are chosen
|
||||
# to avoid collision with the base dev stack (5433, 6380, 14242, 9000).
|
||||
#
|
||||
# IMAGE:
|
||||
# Pinned to the immutable digest sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02
|
||||
# (sha-9f1a081, post-#491 IMG-FIX, smoke-tested locally).
|
||||
# Update this digest only after a new CI build is promoted to the registry.
|
||||
#
|
||||
# STEP-CA:
|
||||
# Single shared Step-CA instance. Both gateways connect to it.
|
||||
# CA volume is ephemeral per `docker compose down -v`; regenerated on next up.
|
||||
# The harness seed script provisions the CA roots cross-trust after first boot.
|
||||
|
||||
services:
|
||||
# ─── Shared Certificate Authority ────────────────────────────────────────────
|
||||
step-ca:
|
||||
image: smallstep/step-ca:0.27.4
|
||||
container_name: fed-harness-step-ca
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '${STEP_CA_HOST_PORT:-19000}:9000'
|
||||
volumes:
|
||||
- step_ca_data:/home/step
|
||||
- ../../infra/step-ca/init.sh:/usr/local/bin/mosaic-step-ca-init.sh:ro
|
||||
- ../../infra/step-ca/templates:/etc/step-ca-templates:ro
|
||||
- ../../infra/step-ca/dev-password:/run/secrets/ca_password:ro
|
||||
entrypoint: ['/bin/sh', '/usr/local/bin/mosaic-step-ca-init.sh']
|
||||
networks:
|
||||
- fed-test-net
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'step',
|
||||
'ca',
|
||||
'health',
|
||||
'--ca-url',
|
||||
'https://localhost:9000',
|
||||
'--root',
|
||||
'/home/step/certs/root_ca.crt',
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# ─── Server A — Home / Requesting Gateway ────────────────────────────────────
|
||||
postgres-a:
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: fed-harness-postgres-a
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '${PG_A_HOST_PORT:-15432}:5432'
|
||||
environment:
|
||||
POSTGRES_USER: mosaic
|
||||
POSTGRES_PASSWORD: mosaic
|
||||
POSTGRES_DB: mosaic
|
||||
volumes:
|
||||
- pg_a_data:/var/lib/postgresql/data
|
||||
- ../../infra/pg-init:/docker-entrypoint-initdb.d:ro
|
||||
networks:
|
||||
- fed-test-net
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U mosaic']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
valkey-a:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: fed-harness-valkey-a
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '${VALKEY_A_HOST_PORT:-16379}:6379'
|
||||
volumes:
|
||||
- valkey_a_data:/data
|
||||
networks:
|
||||
- fed-test-net
|
||||
healthcheck:
|
||||
test: ['CMD', 'valkey-cli', 'ping']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
gateway-a:
|
||||
image: git.mosaicstack.dev/mosaicstack/stack/gateway@sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02
|
||||
# Tag for human reference: sha-9f1a081 (post-#491 IMG-FIX; smoke-tested locally)
|
||||
container_name: fed-harness-gateway-a
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '${GATEWAY_A_HOST_PORT:-14001}:3000'
|
||||
environment:
|
||||
MOSAIC_TIER: federated
|
||||
DATABASE_URL: postgres://mosaic:mosaic@postgres-a:5432/mosaic
|
||||
VALKEY_URL: redis://valkey-a:6379
|
||||
GATEWAY_PORT: '3000'
|
||||
GATEWAY_CORS_ORIGIN: 'http://localhost:14001'
|
||||
BETTER_AUTH_SECRET: harness-secret-server-a-do-not-use-in-prod
|
||||
BETTER_AUTH_URL: 'http://gateway-a:3000'
|
||||
STEP_CA_URL: 'https://step-ca:9000'
|
||||
FEDERATION_PEER_HOSTNAME: gateway-a
|
||||
# Bootstrap password for POST /api/bootstrap/setup — used by seed.ts to create
|
||||
# the first admin user. Only valid on a pristine (zero-user) database.
|
||||
# Not the same as ADMIN_API_KEY — there is no static API key in the gateway.
|
||||
ADMIN_BOOTSTRAP_PASSWORD: harness-admin-password-a
|
||||
depends_on:
|
||||
postgres-a:
|
||||
condition: service_healthy
|
||||
valkey-a:
|
||||
condition: service_healthy
|
||||
step-ca:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- fed-test-net
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'node',
|
||||
'-e',
|
||||
"require('http').get('http://127.0.0.1:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
# ─── Server B — Work / Serving Gateway ──────────────────────────────────────
|
||||
postgres-b:
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: fed-harness-postgres-b
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '${PG_B_HOST_PORT:-15433}:5432'
|
||||
environment:
|
||||
POSTGRES_USER: mosaic
|
||||
POSTGRES_PASSWORD: mosaic
|
||||
POSTGRES_DB: mosaic
|
||||
volumes:
|
||||
- pg_b_data:/var/lib/postgresql/data
|
||||
- ../../infra/pg-init:/docker-entrypoint-initdb.d:ro
|
||||
networks:
|
||||
- fed-test-net
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U mosaic']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
valkey-b:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: fed-harness-valkey-b
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '${VALKEY_B_HOST_PORT:-16380}:6379'
|
||||
volumes:
|
||||
- valkey_b_data:/data
|
||||
networks:
|
||||
- fed-test-net
|
||||
healthcheck:
|
||||
test: ['CMD', 'valkey-cli', 'ping']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
gateway-b:
|
||||
image: git.mosaicstack.dev/mosaicstack/stack/gateway@sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02
|
||||
# Tag for human reference: sha-9f1a081 (post-#491 IMG-FIX; smoke-tested locally)
|
||||
container_name: fed-harness-gateway-b
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '${GATEWAY_B_HOST_PORT:-14002}:3000'
|
||||
environment:
|
||||
MOSAIC_TIER: federated
|
||||
DATABASE_URL: postgres://mosaic:mosaic@postgres-b:5432/mosaic
|
||||
VALKEY_URL: redis://valkey-b:6379
|
||||
GATEWAY_PORT: '3000'
|
||||
GATEWAY_CORS_ORIGIN: 'http://localhost:14002'
|
||||
BETTER_AUTH_SECRET: harness-secret-server-b-do-not-use-in-prod
|
||||
BETTER_AUTH_URL: 'http://gateway-b:3000'
|
||||
STEP_CA_URL: 'https://step-ca:9000'
|
||||
FEDERATION_PEER_HOSTNAME: gateway-b
|
||||
# Bootstrap password for POST /api/bootstrap/setup — used by seed.ts to create
|
||||
# the first admin user. Only valid on a pristine (zero-user) database.
|
||||
# Not the same as ADMIN_API_KEY — there is no static API key in the gateway.
|
||||
ADMIN_BOOTSTRAP_PASSWORD: harness-admin-password-b
|
||||
depends_on:
|
||||
postgres-b:
|
||||
condition: service_healthy
|
||||
valkey-b:
|
||||
condition: service_healthy
|
||||
step-ca:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- fed-test-net
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'node',
|
||||
'-e',
|
||||
"require('http').get('http://127.0.0.1:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
networks:
|
||||
fed-test-net:
|
||||
name: fed-test-net
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
step_ca_data:
|
||||
name: fed-harness-step-ca
|
||||
pg_a_data:
|
||||
name: fed-harness-pg-a
|
||||
valkey_a_data:
|
||||
name: fed-harness-valkey-a
|
||||
pg_b_data:
|
||||
name: fed-harness-pg-b
|
||||
valkey_b_data:
|
||||
name: fed-harness-valkey-b
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* tools/federation-harness/harness.ts
|
||||
*
|
||||
* Vitest-consumable helpers for the two-gateway federation harness.
|
||||
*
|
||||
* USAGE (in a vitest test file):
|
||||
*
|
||||
* import { bootHarness, tearDownHarness, serverA, serverB, seed } from
|
||||
* '../../tools/federation-harness/harness.js';
|
||||
*
|
||||
* let handle: HarnessHandle;
|
||||
*
|
||||
* beforeAll(async () => {
|
||||
* handle = await bootHarness();
|
||||
* }, 180_000);
|
||||
*
|
||||
* afterAll(async () => {
|
||||
* await tearDownHarness(handle);
|
||||
* });
|
||||
*
|
||||
* test('variant A — list tasks', async () => {
|
||||
* const seedResult = await seed(handle, 'all');
|
||||
* const a = serverA(handle);
|
||||
* const res = await fetch(`${a.baseUrl}/api/federation/list/tasks`, {
|
||||
* headers: { Authorization: `Bearer ${seedResult.adminTokenA}` },
|
||||
* });
|
||||
* expect(res.status).toBe(200);
|
||||
* });
|
||||
*
|
||||
* NOTE: The `seed()` helper currently only supports scenario='all'. Passing any
|
||||
* other value throws immediately. Per-variant narrowing is deferred to M3-11.
|
||||
*
|
||||
* ESM / NodeNext: all imports use .js extensions.
|
||||
*/
|
||||
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runSeed, type SeedResult } from './seed.js';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GatewayAccessor {
|
||||
/** Base URL reachable from the host machine, e.g. http://localhost:14001 */
|
||||
baseUrl: string;
|
||||
/** Bootstrap password used for POST /api/bootstrap/setup on a pristine gateway */
|
||||
bootstrapPassword: string;
|
||||
/** Internal Docker network hostname (for container-to-container calls) */
|
||||
internalHostname: string;
|
||||
}
|
||||
|
||||
export interface HarnessHandle {
|
||||
/** Server A accessor */
|
||||
a: GatewayAccessor;
|
||||
/** Server B accessor */
|
||||
b: GatewayAccessor;
|
||||
/** Absolute path to the docker-compose file */
|
||||
composeFile: string;
|
||||
/** Whether this instance booted the stack (vs. reusing an existing one) */
|
||||
ownedStack: boolean;
|
||||
/** Optional seed result if seed() was called */
|
||||
seedResult?: SeedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario to seed. Currently only 'all' is implemented; per-variant narrowing
|
||||
* is tracked as M3-11. Passing any other value throws immediately with a clear
|
||||
* error rather than silently over-seeding.
|
||||
*/
|
||||
export type SeedScenario = 'variantA' | 'variantB' | 'variantC' | 'all';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const COMPOSE_FILE = resolve(__dirname, 'docker-compose.two-gateways.yml');
|
||||
|
||||
const GATEWAY_A_URL = process.env['GATEWAY_A_URL'] ?? 'http://localhost:14001';
|
||||
const GATEWAY_B_URL = process.env['GATEWAY_B_URL'] ?? 'http://localhost:14002';
|
||||
const ADMIN_BOOTSTRAP_PASSWORD_A =
|
||||
process.env['ADMIN_BOOTSTRAP_PASSWORD_A'] ?? 'harness-admin-password-a';
|
||||
const ADMIN_BOOTSTRAP_PASSWORD_B =
|
||||
process.env['ADMIN_BOOTSTRAP_PASSWORD_B'] ?? 'harness-admin-password-b';
|
||||
|
||||
const READINESS_TIMEOUT_MS = 180_000;
|
||||
const READINESS_POLL_MS = 3_000;
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function isGatewayHealthy(baseUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(5_000) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll both gateways in parallel until both are healthy or the shared deadline
|
||||
* expires. Polling in parallel (rather than sequentially) avoids the bug where
|
||||
* a slow gateway-a consumes all of the readiness budget before gateway-b is
|
||||
* checked.
|
||||
*/
|
||||
async function waitForStack(handle: HarnessHandle): Promise<void> {
|
||||
const gateways: Array<{ label: string; url: string }> = [
|
||||
{ label: 'gateway-a', url: handle.a.baseUrl },
|
||||
{ label: 'gateway-b', url: handle.b.baseUrl },
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
gateways.map(async (gw) => {
|
||||
// Each gateway gets its own independent deadline.
|
||||
const deadline = Date.now() + READINESS_TIMEOUT_MS;
|
||||
process.stdout.write(`[harness] Waiting for ${gw.label}...`);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await isGatewayHealthy(gw.url)) {
|
||||
process.stdout.write(` ready\n`);
|
||||
return;
|
||||
}
|
||||
if (Date.now() + READINESS_POLL_MS > deadline) {
|
||||
throw new Error(
|
||||
`[harness] ${gw.label} did not become healthy within ${READINESS_TIMEOUT_MS.toString()}ms`,
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, READINESS_POLL_MS));
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[harness] ${gw.label} did not become healthy within ${READINESS_TIMEOUT_MS.toString()}ms`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isStackRunning(): boolean {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'docker',
|
||||
['compose', '-f', COMPOSE_FILE, 'ps', '--format', 'json'],
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
);
|
||||
|
||||
if (!output.trim()) return false;
|
||||
|
||||
// Parse JSON lines — each running service emits a JSON object per line
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
const runningServices = lines.filter((line) => {
|
||||
try {
|
||||
const obj = JSON.parse(line) as { State?: string };
|
||||
return obj.State === 'running';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Expect at least gateway-a and gateway-b running
|
||||
return runningServices.length >= 2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Boot the harness stack.
|
||||
*
|
||||
* Idempotent: if the stack is already running and both gateways are healthy,
|
||||
* this function reuses the existing stack and returns a handle with
|
||||
* `ownedStack: false`. Callers that set `ownedStack: false` should NOT call
|
||||
* `tearDownHarness` unless they explicitly want to tear down a pre-existing stack.
|
||||
*
|
||||
* If the stack is not running, it starts it with `docker compose up -d` and
|
||||
* waits for both gateways to pass their /api/health probe.
|
||||
*/
|
||||
export async function bootHarness(): Promise<HarnessHandle> {
|
||||
const handle: HarnessHandle = {
|
||||
a: {
|
||||
baseUrl: GATEWAY_A_URL,
|
||||
bootstrapPassword: ADMIN_BOOTSTRAP_PASSWORD_A,
|
||||
internalHostname: 'gateway-a',
|
||||
},
|
||||
b: {
|
||||
baseUrl: GATEWAY_B_URL,
|
||||
bootstrapPassword: ADMIN_BOOTSTRAP_PASSWORD_B,
|
||||
internalHostname: 'gateway-b',
|
||||
},
|
||||
composeFile: COMPOSE_FILE,
|
||||
ownedStack: false,
|
||||
};
|
||||
|
||||
// Check if both gateways are already healthy
|
||||
const [aHealthy, bHealthy] = await Promise.all([
|
||||
isGatewayHealthy(handle.a.baseUrl),
|
||||
isGatewayHealthy(handle.b.baseUrl),
|
||||
]);
|
||||
|
||||
if (aHealthy && bHealthy) {
|
||||
console.log('[harness] Stack already running — reusing existing stack.');
|
||||
handle.ownedStack = false;
|
||||
return handle;
|
||||
}
|
||||
|
||||
console.log('[harness] Starting federation harness stack...');
|
||||
execSync(`docker compose -f "${COMPOSE_FILE}" up -d`, { stdio: 'inherit' });
|
||||
handle.ownedStack = true;
|
||||
|
||||
await waitForStack(handle);
|
||||
console.log('[harness] Stack is ready.');
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the harness stack.
|
||||
*
|
||||
* Runs `docker compose down -v` to remove containers AND volumes (ephemeral state).
|
||||
* Only tears down if `handle.ownedStack` is true unless `force` is set.
|
||||
*/
|
||||
export async function tearDownHarness(
|
||||
handle: HarnessHandle,
|
||||
opts?: { force?: boolean },
|
||||
): Promise<void> {
|
||||
if (!handle.ownedStack && !opts?.force) {
|
||||
console.log(
|
||||
'[harness] Stack not owned by this handle — skipping teardown (pass force: true to override).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[harness] Tearing down federation harness stack...');
|
||||
execSync(`docker compose -f "${handle.composeFile}" down -v`, { stdio: 'inherit' });
|
||||
console.log('[harness] Stack torn down.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Server A accessor from a harness handle.
|
||||
* Convenience wrapper for test readability.
|
||||
*/
|
||||
export function serverA(handle: HarnessHandle): GatewayAccessor {
|
||||
return handle.a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Server B accessor from a harness handle.
|
||||
* Convenience wrapper for test readability.
|
||||
*/
|
||||
export function serverB(handle: HarnessHandle): GatewayAccessor {
|
||||
return handle.b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the harness with test data for one or more scenarios.
|
||||
*
|
||||
* @param handle The harness handle returned by bootHarness().
|
||||
* @param scenario Which scope variants to provision. Currently only 'all' is
|
||||
* supported — passing any other value throws immediately with a
|
||||
* clear error. Per-variant narrowing is tracked as M3-11.
|
||||
*
|
||||
* Returns a SeedResult with grant IDs, peer IDs, and admin tokens for each
|
||||
* gateway, which test assertions can reference.
|
||||
*
|
||||
* IMPORTANT: The harness assumes a pristine database on both gateways. The seed
|
||||
* bootstraps an admin user on each gateway via POST /api/bootstrap/setup. If
|
||||
* either gateway already has users, seed() throws with a clear error message.
|
||||
* Run 'docker compose down -v' to reset state.
|
||||
*/
|
||||
export async function seed(
|
||||
handle: HarnessHandle,
|
||||
scenario: SeedScenario = 'all',
|
||||
): Promise<SeedResult> {
|
||||
if (scenario !== 'all') {
|
||||
throw new Error(
|
||||
`seed: scenario narrowing not yet implemented; pass "all" for now. ` +
|
||||
`Got: "${scenario}". Per-variant narrowing is tracked as M3-11.`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await runSeed({
|
||||
serverAUrl: handle.a.baseUrl,
|
||||
serverBUrl: handle.b.baseUrl,
|
||||
adminBootstrapPasswordA: handle.a.bootstrapPassword,
|
||||
adminBootstrapPasswordB: handle.b.bootstrapPassword,
|
||||
});
|
||||
|
||||
handle.seedResult = result;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* tools/federation-harness/seed.ts
|
||||
*
|
||||
* Provisions test data for the two-gateway federation harness.
|
||||
* Run via: tsx tools/federation-harness/seed.ts
|
||||
*
|
||||
* What this script does:
|
||||
* 1. (Optional) Boots the compose stack if --boot flag is passed.
|
||||
* 2. Waits for both gateways to be healthy.
|
||||
* 3. Bootstraps an admin user + token on each gateway via POST /api/bootstrap/setup.
|
||||
* 4. Creates three grants on Server B matching the M3 acceptance test scenarios:
|
||||
* - Scope variant A: tasks + notes, include_personal: true
|
||||
* - Scope variant B: tasks only, include_teams: ['T1'], exclude T2
|
||||
* - Scope variant C: tasks + credentials in resources, credentials excluded (sanity)
|
||||
* 5. For each grant, walks the full enrollment flow:
|
||||
* a. Server B creates a peer keypair (represents the requesting side).
|
||||
* b. Server B creates the grant referencing that peer.
|
||||
* c. Server B issues an enrollment token.
|
||||
* d. Server A creates its own peer keypair (represents its view of B).
|
||||
* e. Server A redeems the enrollment token at Server B's enrollment endpoint,
|
||||
* submitting A's CSR → receives signed cert back.
|
||||
* f. Server A stores the cert on its peer record → peer becomes active.
|
||||
* 6. Inserts representative test tasks/notes/credentials on Server B.
|
||||
*
|
||||
* IMPORTANT: This script uses the real admin REST API — no direct DB writes.
|
||||
* It exercises the full enrollment flow as M3 acceptance tests will.
|
||||
*
|
||||
* ESM / NodeNext: all imports use .js extensions.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const COMPOSE_FILE = resolve(__dirname, 'docker-compose.two-gateways.yml');
|
||||
|
||||
/** Base URLs as seen from the host machine (mapped host ports). */
|
||||
const SERVER_A_URL = process.env['GATEWAY_A_URL'] ?? 'http://localhost:14001';
|
||||
const SERVER_B_URL = process.env['GATEWAY_B_URL'] ?? 'http://localhost:14002';
|
||||
|
||||
/**
|
||||
* Bootstrap passwords used when calling POST /api/bootstrap/setup on each
|
||||
* gateway. Each gateway starts with zero users and requires a one-time setup
|
||||
* call before any admin-guarded endpoints can be used.
|
||||
*/
|
||||
const ADMIN_BOOTSTRAP_PASSWORD_A =
|
||||
process.env['ADMIN_BOOTSTRAP_PASSWORD_A'] ?? 'harness-admin-password-a';
|
||||
const ADMIN_BOOTSTRAP_PASSWORD_B =
|
||||
process.env['ADMIN_BOOTSTRAP_PASSWORD_B'] ?? 'harness-admin-password-b';
|
||||
|
||||
const READINESS_TIMEOUT_MS = 120_000;
|
||||
const READINESS_POLL_MS = 3_000;
|
||||
|
||||
// ─── Scope variant definitions (for M3 acceptance tests) ─────────────────────
|
||||
|
||||
/** Scope variant A — tasks + notes, personal data included. */
|
||||
export const SCOPE_VARIANT_A = {
|
||||
resources: ['tasks', 'notes'],
|
||||
filters: {
|
||||
tasks: { include_personal: true },
|
||||
notes: { include_personal: true },
|
||||
},
|
||||
excluded_resources: [] as string[],
|
||||
max_rows_per_query: 500,
|
||||
};
|
||||
|
||||
/** Scope variant B — tasks only, team T1 only, no personal. */
|
||||
export const SCOPE_VARIANT_B = {
|
||||
resources: ['tasks'],
|
||||
filters: {
|
||||
tasks: { include_teams: ['T1'], include_personal: false },
|
||||
},
|
||||
excluded_resources: [] as string[],
|
||||
max_rows_per_query: 500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Scope variant C — tasks + credentials in resources list, but credentials
|
||||
* explicitly in excluded_resources. Sanity test: credentials must still be
|
||||
* inaccessible even though they appear in resources.
|
||||
*/
|
||||
export const SCOPE_VARIANT_C = {
|
||||
resources: ['tasks', 'credentials'],
|
||||
filters: {
|
||||
tasks: { include_personal: true },
|
||||
},
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 500,
|
||||
};
|
||||
|
||||
// ─── Inline types (no import from packages/types — M3-01 branch not yet merged) ─
|
||||
|
||||
interface AdminFetchOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
adminToken: string;
|
||||
}
|
||||
|
||||
interface PeerRecord {
|
||||
peerId: string;
|
||||
csrPem: string;
|
||||
}
|
||||
|
||||
interface GrantRecord {
|
||||
id: string;
|
||||
status: string;
|
||||
scope: unknown;
|
||||
}
|
||||
|
||||
interface EnrollmentTokenResult {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
enrollmentUrl: string;
|
||||
}
|
||||
|
||||
interface EnrollmentRedeemResult {
|
||||
certPem: string;
|
||||
certChainPem: string;
|
||||
}
|
||||
|
||||
interface BootstrapResult {
|
||||
adminUserId: string;
|
||||
adminToken: string;
|
||||
}
|
||||
|
||||
export interface SeedResult {
|
||||
serverAUrl: string;
|
||||
serverBUrl: string;
|
||||
adminTokenA: string;
|
||||
adminTokenB: string;
|
||||
adminUserIdA: string;
|
||||
adminUserIdB: string;
|
||||
grants: {
|
||||
variantA: GrantRecord;
|
||||
variantB: GrantRecord;
|
||||
variantC: GrantRecord;
|
||||
};
|
||||
peers: {
|
||||
variantA: PeerRecord & { grantId: string };
|
||||
variantB: PeerRecord & { grantId: string };
|
||||
variantC: PeerRecord & { grantId: string };
|
||||
};
|
||||
}
|
||||
|
||||
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Authenticated admin fetch. Sends `Authorization: Bearer <adminToken>` which
|
||||
* is the only path supported by AdminGuard (DB-backed sha256 token lookup).
|
||||
* No `x-admin-key` header path exists in the gateway.
|
||||
*/
|
||||
async function adminFetch<T>(baseUrl: string, path: string, opts: AdminFetchOptions): Promise<T> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method: opts.method ?? 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${opts.adminToken}`,
|
||||
},
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '(no body)');
|
||||
throw new Error(`${opts.method ?? 'GET'} ${url} → ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ─── Admin bootstrap ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bootstrap an admin user on a pristine gateway.
|
||||
*
|
||||
* Steps:
|
||||
* 1. GET /api/bootstrap/status — confirms needsSetup === true.
|
||||
* 2. POST /api/bootstrap/setup with { name, email, password } — returns
|
||||
* { user, token: { plaintext } }.
|
||||
*
|
||||
* The harness assumes a fresh DB. If needsSetup is false the harness fails
|
||||
* fast with a clear error rather than proceeding with an unknown token.
|
||||
*/
|
||||
async function bootstrapAdmin(
|
||||
baseUrl: string,
|
||||
label: string,
|
||||
password: string,
|
||||
): Promise<BootstrapResult> {
|
||||
console.log(`[seed] Bootstrapping admin on ${label} (${baseUrl})...`);
|
||||
|
||||
// 1. Check status
|
||||
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
|
||||
if (!statusRes.ok) {
|
||||
throw new Error(`[seed] GET ${baseUrl}/api/bootstrap/status → ${statusRes.status.toString()}`);
|
||||
}
|
||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||
|
||||
if (!status.needsSetup) {
|
||||
throw new Error(
|
||||
`[seed] ${label} at ${baseUrl} already has users (needsSetup=false). ` +
|
||||
`The harness requires a pristine database. Run 'docker compose down -v' to reset.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Bootstrap
|
||||
const setupRes = await fetch(`${baseUrl}/api/bootstrap/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `Harness Admin (${label})`,
|
||||
email: `harness-admin-${label.toLowerCase().replace(/\s+/g, '-')}@example.invalid`,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!setupRes.ok) {
|
||||
const body = await setupRes.text().catch(() => '(no body)');
|
||||
throw new Error(
|
||||
`[seed] POST ${baseUrl}/api/bootstrap/setup → ${setupRes.status.toString()}: ${body}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await setupRes.json()) as {
|
||||
user: { id: string };
|
||||
token: { plaintext: string };
|
||||
};
|
||||
|
||||
console.log(`[seed] ${label} admin user: ${result.user.id}`);
|
||||
console.log(`[seed] ${label} admin token: ${result.token.plaintext.slice(0, 8)}...`);
|
||||
|
||||
return {
|
||||
adminUserId: result.user.id,
|
||||
adminToken: result.token.plaintext,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Readiness probe ──────────────────────────────────────────────────────────
|
||||
|
||||
async function waitForGateway(baseUrl: string, label: string): Promise<void> {
|
||||
const deadline = Date.now() + READINESS_TIMEOUT_MS;
|
||||
let lastError: string = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(5_000) });
|
||||
if (res.ok) {
|
||||
console.log(`[seed] ${label} is ready (${baseUrl})`);
|
||||
return;
|
||||
}
|
||||
lastError = `HTTP ${res.status.toString()}`;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, READINESS_POLL_MS));
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[seed] ${label} did not become ready within ${READINESS_TIMEOUT_MS.toString()}ms — last error: ${lastError}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Enrollment flow ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk the full enrollment flow for one grant.
|
||||
*
|
||||
* The correct two-sided flow (matching the data model's FK semantics):
|
||||
*
|
||||
* 1. On Server B: POST /api/admin/federation/peers/keypair
|
||||
* → peerId_B (Server B's peer record representing the requesting side)
|
||||
* 2. On Server B: POST /api/admin/federation/grants with peerId: peerId_B
|
||||
* → grant (FK to Server B's own federation_peers table — no violation)
|
||||
* 3. On Server B: POST /api/admin/federation/grants/:id/tokens
|
||||
* → enrollmentUrl pointing back to Server B
|
||||
* 4. On Server A: POST /api/admin/federation/peers/keypair
|
||||
* → peerId_A + csrPem_A (Server A's local record of Server B)
|
||||
* 5. Server A → Server B: POST enrollmentUrl with { csrPem: csrPem_A }
|
||||
* → certPem signed by Server B's CA
|
||||
* 6. On Server A: PATCH /api/admin/federation/peers/:peerId_A/cert with certPem
|
||||
* → Server A's peer record transitions to active
|
||||
*
|
||||
* Returns the activated grant (from Server B) and Server A's peer record.
|
||||
*/
|
||||
async function enrollGrant(opts: {
|
||||
label: string;
|
||||
subjectUserId: string;
|
||||
scope: unknown;
|
||||
adminTokenA: string;
|
||||
adminTokenB: string;
|
||||
serverAUrl: string;
|
||||
serverBUrl: string;
|
||||
}): Promise<{ grant: GrantRecord; peer: PeerRecord & { grantId: string } }> {
|
||||
const { label, subjectUserId, scope, adminTokenA, adminTokenB, serverAUrl, serverBUrl } = opts;
|
||||
console.log(`\n[seed] Enrolling grant for scope variant ${label}...`);
|
||||
|
||||
// 1. Create peer keypair on Server B (represents the requesting peer from B's perspective)
|
||||
const peerB = await adminFetch<PeerRecord>(serverBUrl, '/api/admin/federation/peers/keypair', {
|
||||
method: 'POST',
|
||||
adminToken: adminTokenB,
|
||||
body: {
|
||||
commonName: `harness-peer-${label.toLowerCase()}-from-b`,
|
||||
displayName: `Harness Peer ${label} (Server A as seen from B)`,
|
||||
endpointUrl: serverAUrl,
|
||||
},
|
||||
});
|
||||
console.log(`[seed] Created peer on B: ${peerB.peerId}`);
|
||||
|
||||
// 2. Create grant on Server B referencing B's own peer record
|
||||
const grant = await adminFetch<GrantRecord>(serverBUrl, '/api/admin/federation/grants', {
|
||||
method: 'POST',
|
||||
adminToken: adminTokenB,
|
||||
body: {
|
||||
peerId: peerB.peerId,
|
||||
subjectUserId,
|
||||
scope,
|
||||
},
|
||||
});
|
||||
console.log(`[seed] Created grant on B: ${grant.id} (status: ${grant.status})`);
|
||||
|
||||
// 3. Generate enrollment token on Server B
|
||||
const tokenResult = await adminFetch<EnrollmentTokenResult>(
|
||||
serverBUrl,
|
||||
`/api/admin/federation/grants/${grant.id}/tokens`,
|
||||
{ method: 'POST', adminToken: adminTokenB, body: { ttlSeconds: 900 } },
|
||||
);
|
||||
console.log(`[seed] Enrollment token: ${tokenResult.token.slice(0, 8)}...`);
|
||||
console.log(`[seed] Enrollment URL: ${tokenResult.enrollmentUrl}`);
|
||||
|
||||
// 4. Create peer keypair on Server A (Server A's local record of Server B)
|
||||
const peerA = await adminFetch<PeerRecord>(serverAUrl, '/api/admin/federation/peers/keypair', {
|
||||
method: 'POST',
|
||||
adminToken: adminTokenA,
|
||||
body: {
|
||||
commonName: `harness-peer-${label.toLowerCase()}-from-a`,
|
||||
displayName: `Harness Peer ${label} (Server B as seen from A)`,
|
||||
endpointUrl: serverBUrl,
|
||||
},
|
||||
});
|
||||
console.log(`[seed] Created peer on A: ${peerA.peerId}`);
|
||||
|
||||
// 5. Redeem token at Server B's enrollment endpoint with A's CSR.
|
||||
// The enrollment endpoint is not admin-guarded — the one-time token IS the credential.
|
||||
//
|
||||
// The enrollmentUrl returned by the gateway is built using BETTER_AUTH_URL which
|
||||
// resolves to the in-cluster Docker hostname (gateway-b:3000). That URL is only
|
||||
// reachable from other containers, not from the host machine running this script.
|
||||
// We rewrite the host portion to use the host-accessible serverBUrl so the
|
||||
// seed script can reach the endpoint from the host.
|
||||
const parsedEnrollment = new URL(tokenResult.enrollmentUrl);
|
||||
const tokenSegment = parsedEnrollment.pathname.split('/').pop()!;
|
||||
const redeemUrl = `${serverBUrl}/api/federation/enrollment/${tokenSegment}`;
|
||||
console.log(`[seed] Rewritten redeem URL (host-accessible): ${redeemUrl}`);
|
||||
const redeemRes = await fetch(redeemUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ csrPem: peerA.csrPem }),
|
||||
});
|
||||
|
||||
if (!redeemRes.ok) {
|
||||
const body = await redeemRes.text().catch(() => '(no body)');
|
||||
throw new Error(`Enrollment redemption failed: ${redeemRes.status.toString()} — ${body}`);
|
||||
}
|
||||
|
||||
const redeemResult = (await redeemRes.json()) as EnrollmentRedeemResult;
|
||||
console.log(`[seed] Cert issued (${redeemResult.certPem.length.toString()} bytes)`);
|
||||
|
||||
// 6. Store cert on Server A's peer record → transitions to active
|
||||
await adminFetch<unknown>(serverAUrl, `/api/admin/federation/peers/${peerA.peerId}/cert`, {
|
||||
method: 'PATCH',
|
||||
adminToken: adminTokenA,
|
||||
body: { certPem: redeemResult.certPem },
|
||||
});
|
||||
console.log(`[seed] Cert stored on A — peer ${peerA.peerId} is now active`);
|
||||
|
||||
// Verify grant flipped to active on B
|
||||
const activeGrant = await adminFetch<GrantRecord>(
|
||||
serverBUrl,
|
||||
`/api/admin/federation/grants/${grant.id}`,
|
||||
{ adminToken: adminTokenB },
|
||||
);
|
||||
console.log(`[seed] Grant status on B: ${activeGrant.status}`);
|
||||
|
||||
return { grant: activeGrant, peer: { ...peerA, grantId: grant.id } };
|
||||
}
|
||||
|
||||
// ─── Test data insertion ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert representative test data on Server B via its admin APIs.
|
||||
*
|
||||
* NOTE: The gateway's task/note/credential APIs require an authenticated user
|
||||
* session. For the harness, we seed via admin-level endpoints if available,
|
||||
* or document the gap here for M3-11 to fill in with proper user session seeding.
|
||||
*
|
||||
* ASSUMPTION: Server B exposes POST /api/admin/tasks (or similar) for test data.
|
||||
* If that endpoint does not yet exist, this function logs a warning and skips
|
||||
* without failing — M3-11 will add the session-based seeding path.
|
||||
*/
|
||||
async function seedTestData(
|
||||
subjectUserId: string,
|
||||
scopeLabel: string,
|
||||
serverBUrl: string,
|
||||
adminTokenB: string,
|
||||
): Promise<void> {
|
||||
console.log(`\n[seed] Seeding test data on Server B for ${scopeLabel}...`);
|
||||
|
||||
const testTasks = [
|
||||
{
|
||||
title: `${scopeLabel} Task 1`,
|
||||
description: 'Federation harness test task',
|
||||
userId: subjectUserId,
|
||||
},
|
||||
{
|
||||
title: `${scopeLabel} Task 2`,
|
||||
description: 'Team-scoped test task',
|
||||
userId: subjectUserId,
|
||||
teamId: 'T1',
|
||||
},
|
||||
];
|
||||
|
||||
const testNotes = [
|
||||
{
|
||||
title: `${scopeLabel} Note 1`,
|
||||
content: 'Personal note for federation test',
|
||||
userId: subjectUserId,
|
||||
},
|
||||
];
|
||||
|
||||
// Attempt to insert — tolerate 404 (endpoint not yet implemented)
|
||||
for (const task of testTasks) {
|
||||
try {
|
||||
await adminFetch<unknown>(serverBUrl, '/api/admin/tasks', {
|
||||
method: 'POST',
|
||||
adminToken: adminTokenB,
|
||||
body: task,
|
||||
});
|
||||
console.log(`[seed] Inserted task: "${task.title}"`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('404') || msg.includes('Cannot POST')) {
|
||||
console.warn(
|
||||
`[seed] WARN: /api/admin/tasks not found — skipping task insertion (expected until M3-11)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
for (const note of testNotes) {
|
||||
try {
|
||||
await adminFetch<unknown>(serverBUrl, '/api/admin/notes', {
|
||||
method: 'POST',
|
||||
adminToken: adminTokenB,
|
||||
body: note,
|
||||
});
|
||||
console.log(`[seed] Inserted note: "${note.title}"`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('404') || msg.includes('Cannot POST')) {
|
||||
console.warn(
|
||||
`[seed] WARN: /api/admin/notes not found — skipping note insertion (expected until M3-11)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[seed] Test data seeding for ${scopeLabel} complete.`);
|
||||
}
|
||||
|
||||
// ─── Main entrypoint ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function runSeed(opts?: {
|
||||
serverAUrl?: string;
|
||||
serverBUrl?: string;
|
||||
adminBootstrapPasswordA?: string;
|
||||
adminBootstrapPasswordB?: string;
|
||||
subjectUserIds?: { variantA: string; variantB: string; variantC: string };
|
||||
}): Promise<SeedResult> {
|
||||
const aUrl = opts?.serverAUrl ?? SERVER_A_URL;
|
||||
const bUrl = opts?.serverBUrl ?? SERVER_B_URL;
|
||||
const passwordA = opts?.adminBootstrapPasswordA ?? ADMIN_BOOTSTRAP_PASSWORD_A;
|
||||
const passwordB = opts?.adminBootstrapPasswordB ?? ADMIN_BOOTSTRAP_PASSWORD_B;
|
||||
|
||||
// Use provided or default subject user IDs.
|
||||
// In a real run these would be real user UUIDs from Server B's DB.
|
||||
// For the harness, the admin bootstrap user on Server B is used as the subject.
|
||||
// These are overridden after bootstrap if opts.subjectUserIds is not provided.
|
||||
const subjectIds = opts?.subjectUserIds;
|
||||
|
||||
console.log('[seed] Waiting for gateways to be ready...');
|
||||
await Promise.all([waitForGateway(aUrl, 'Server A'), waitForGateway(bUrl, 'Server B')]);
|
||||
|
||||
// Bootstrap admin users on both gateways (requires pristine DBs).
|
||||
console.log('\n[seed] Bootstrapping admin accounts...');
|
||||
const [bootstrapA, bootstrapB] = await Promise.all([
|
||||
bootstrapAdmin(aUrl, 'Server A', passwordA),
|
||||
bootstrapAdmin(bUrl, 'Server B', passwordB),
|
||||
]);
|
||||
|
||||
// Default subject user IDs to the admin user on Server B (guaranteed to exist).
|
||||
const resolvedSubjectIds = subjectIds ?? {
|
||||
variantA: bootstrapB.adminUserId,
|
||||
variantB: bootstrapB.adminUserId,
|
||||
variantC: bootstrapB.adminUserId,
|
||||
};
|
||||
|
||||
// Enroll all three scope variants sequentially to avoid race conditions on
|
||||
// the step-ca signing queue. Parallel enrollment would work too but
|
||||
// sequential is easier to debug when something goes wrong.
|
||||
console.log('\n[seed] Enrolling scope variants...');
|
||||
const resultA = await enrollGrant({
|
||||
label: 'A',
|
||||
subjectUserId: resolvedSubjectIds.variantA,
|
||||
scope: SCOPE_VARIANT_A,
|
||||
adminTokenA: bootstrapA.adminToken,
|
||||
adminTokenB: bootstrapB.adminToken,
|
||||
serverAUrl: aUrl,
|
||||
serverBUrl: bUrl,
|
||||
});
|
||||
const resultB = await enrollGrant({
|
||||
label: 'B',
|
||||
subjectUserId: resolvedSubjectIds.variantB,
|
||||
scope: SCOPE_VARIANT_B,
|
||||
adminTokenA: bootstrapA.adminToken,
|
||||
adminTokenB: bootstrapB.adminToken,
|
||||
serverAUrl: aUrl,
|
||||
serverBUrl: bUrl,
|
||||
});
|
||||
const resultC = await enrollGrant({
|
||||
label: 'C',
|
||||
subjectUserId: resolvedSubjectIds.variantC,
|
||||
scope: SCOPE_VARIANT_C,
|
||||
adminTokenA: bootstrapA.adminToken,
|
||||
adminTokenB: bootstrapB.adminToken,
|
||||
serverAUrl: aUrl,
|
||||
serverBUrl: bUrl,
|
||||
});
|
||||
|
||||
// Seed test data on Server B for each scope variant
|
||||
await Promise.all([
|
||||
seedTestData(resolvedSubjectIds.variantA, 'A', bUrl, bootstrapB.adminToken),
|
||||
seedTestData(resolvedSubjectIds.variantB, 'B', bUrl, bootstrapB.adminToken),
|
||||
seedTestData(resolvedSubjectIds.variantC, 'C', bUrl, bootstrapB.adminToken),
|
||||
]);
|
||||
|
||||
const result: SeedResult = {
|
||||
serverAUrl: aUrl,
|
||||
serverBUrl: bUrl,
|
||||
adminTokenA: bootstrapA.adminToken,
|
||||
adminTokenB: bootstrapB.adminToken,
|
||||
adminUserIdA: bootstrapA.adminUserId,
|
||||
adminUserIdB: bootstrapB.adminUserId,
|
||||
grants: {
|
||||
variantA: resultA.grant,
|
||||
variantB: resultB.grant,
|
||||
variantC: resultC.grant,
|
||||
},
|
||||
peers: {
|
||||
variantA: resultA.peer,
|
||||
variantB: resultB.peer,
|
||||
variantC: resultC.peer,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('\n[seed] Seed complete.');
|
||||
console.log('[seed] Summary:');
|
||||
console.log(` Variant A grant: ${result.grants.variantA.id} (${result.grants.variantA.status})`);
|
||||
console.log(` Variant B grant: ${result.grants.variantB.id} (${result.grants.variantB.status})`);
|
||||
console.log(` Variant C grant: ${result.grants.variantC.id} (${result.grants.variantC.status})`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── CLI entry ────────────────────────────────────────────────────────────────
|
||||
|
||||
const isCli =
|
||||
process.argv[1] != null &&
|
||||
fileURLToPath(import.meta.url).endsWith(process.argv[1]!.split('/').pop()!);
|
||||
|
||||
if (isCli) {
|
||||
const shouldBoot = process.argv.includes('--boot');
|
||||
|
||||
if (shouldBoot) {
|
||||
console.log('[seed] --boot flag detected — starting compose stack...');
|
||||
execSync(`docker compose -f "${COMPOSE_FILE}" up -d`, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
runSeed()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[seed] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
FAKE_BIN="$TMP/bin"
|
||||
HOME_DIR="$TMP/home"
|
||||
PREFIX="$TMP/prefix"
|
||||
MOSAIC_HOME="$TMP/mosaic"
|
||||
STATE="$TMP/state"
|
||||
LOG="$TMP/npm.log"
|
||||
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||
REAL_NODE="$(command -v node)"
|
||||
export MOSAIC_TEST_REAL_NODE="$REAL_NODE"
|
||||
|
||||
cat > "$FAKE_BIN/node" <<'FAKE_NODE'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$*" == *'process.versions.node.split'* ]]; then
|
||||
printf '%s' "${MOSAIC_TEST_NODE_MAJOR:-22}"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${1:-}" == "--version" ]]; then
|
||||
printf 'v%s.0.0\n' "${MOSAIC_TEST_NODE_MAJOR:-22}"
|
||||
exit 0
|
||||
fi
|
||||
exec "${MOSAIC_TEST_REAL_NODE:?}" "$@"
|
||||
FAKE_NODE
|
||||
chmod +x "$FAKE_BIN/node"
|
||||
|
||||
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
STATE="${MOSAIC_TEST_STATE:?}"
|
||||
echo "$*" >> "$LOG"
|
||||
|
||||
if [[ "$1" == "view" ]]; then
|
||||
case "$2 $3" in
|
||||
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
||||
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
||||
"@mosaicstack/mosaic version") echo "0.0.48" ;;
|
||||
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "install" ]]; then
|
||||
case "$*" in
|
||||
*"@mosaicstack/[email protected]"*)
|
||||
echo "0.0.49-next.999" > "$STATE/mosaic"
|
||||
;;
|
||||
*"@mosaicstack/[email protected]"*)
|
||||
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
||||
echo "forced gateway install failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "0.0.7-next.999" > "$STATE/gateway"
|
||||
;;
|
||||
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
||||
echo "0.0.0-source" > "$STATE/mosaic"
|
||||
;;
|
||||
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
||||
echo "0.0.0-source" > "$STATE/gateway"
|
||||
;;
|
||||
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "ls" ]]; then
|
||||
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
|
||||
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
|
||||
node -e '
|
||||
const cli = process.argv[1];
|
||||
const gateway = process.argv[2];
|
||||
const dependencies = {};
|
||||
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
|
||||
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
|
||||
process.stdout.write(JSON.stringify({ dependencies }));
|
||||
' "$cli" "$gateway"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected npm command: $*" >&2
|
||||
exit 1
|
||||
FAKE_NPM
|
||||
chmod +x "$FAKE_BIN/npm"
|
||||
|
||||
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# The fake tar creates the source tree; curl only needs to keep the pipe alive.
|
||||
exit 0
|
||||
FAKE_CURL
|
||||
chmod +x "$FAKE_BIN/curl"
|
||||
|
||||
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dest=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-C) dest="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$dest" ]]; then
|
||||
echo "fake tar missing -C destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
|
||||
FAKE_TAR
|
||||
chmod +x "$FAKE_BIN/tar"
|
||||
|
||||
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
echo "pnpm $*" >> "$LOG"
|
||||
|
||||
if [[ "$1" == "pack" ]]; then
|
||||
out=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pack-destination) out="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$out" ]]; then
|
||||
echo "fake pnpm pack missing destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$out"
|
||||
case "$PWD" in
|
||||
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
|
||||
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
|
||||
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# install/build commands are no-ops in this harness.
|
||||
exit 0
|
||||
FAKE_PNPM
|
||||
chmod +x "$FAKE_BIN/pnpm"
|
||||
|
||||
reset_state() {
|
||||
: > "$LOG"
|
||||
rm -f "$STATE"/*
|
||||
}
|
||||
|
||||
reset_state
|
||||
echo "[test] --next rejects Node 20 before any install action"
|
||||
if OUTPUT="$(
|
||||
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
|
||||
MOSAIC_TEST_NODE_MAJOR=20 PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"; then
|
||||
echo "expected Node 20 next-lane install to fail" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -qF 'Node.js >= 22 required for the --next lane' <<<"$OUTPUT"
|
||||
[[ ! -s "$LOG" ]] || { echo "Node 20 gate ran npm actions" >&2; exit 1; }
|
||||
|
||||
reset_state
|
||||
echo "[test] --next fast path pins resolved package versions"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
|
||||
echo "expected exact-version installs, found mutable @next install" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -qF 'Downloading source from next' <<<"$OUTPUT"; then
|
||||
echo "fast path unexpectedly fell back to source" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reset_state
|
||||
echo "[test] fast path failure falls back to source build"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
||||
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
||||
grep -qF 'Downloading source from next' <<<"$OUTPUT"
|
||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
|
||||
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
||||
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
||||
)"
|
||||
|
||||
grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT"
|
||||
if grep -qF '@next version' "$LOG"; then
|
||||
echo "explicit ref should not query @next dist-tags" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reset_state
|
||||
echo "[test] --check --next warns on mismatched prerelease pipeline suffixes"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next
|
||||
)"
|
||||
|
||||
grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT"
|
||||
|
||||
echo "[test] installer next lane tests passed"
|
||||
Executable
+1170
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
# tools/matrix-presence-harness — RFC-001 P1 validation (A1–A5)
|
||||
|
||||
> **DEV-ONLY. LOCAL SANDBOX.** Drives the local `infra/matrix` Synapse only.
|
||||
> Never point it at live infra.
|
||||
|
||||
The dev validation harness for the presence slice. It is the **minimal P1
|
||||
provisioner** + an end-to-end demo that proves the acceptance criteria against
|
||||
a real Synapse.
|
||||
|
||||
## Pieces
|
||||
|
||||
- `provision.ts` — the **minimal provisioner**: registers ≥3 agent MXIDs, creates
|
||||
the fleet presence room, joins the agents. Reuses the existing tested
|
||||
`@mosaicstack/appservice` intent library (register / createRoom / join). It is
|
||||
deliberately **not** the P2 appservice (no auto-enroll / taxonomy / token minting).
|
||||
- `agent-proc.ts` — one presence agent as its **own OS process** (via
|
||||
`@mosaicstack/comms`): joins the fleet room and heartbeats `mosaic.presence`.
|
||||
Standalone so the harness can `SIGKILL` it for a true hard-kill (A3).
|
||||
- `validate.ts` — provisions, spawns the agents, then asserts:
|
||||
- **A2** ≥3 agents show **online** in the fleet room.
|
||||
- **A3** a `SIGKILL`'d agent flips to **offline within `dark_threshold`**,
|
||||
deterministically (heartbeat-age based, not native-presence timeout), while
|
||||
survivors stay online.
|
||||
- **A4** prints the human-readable fleet liveness board.
|
||||
- `run.sh` — wires dev secrets + env and runs `validate.ts` over the self-signed
|
||||
TLS endpoint (A1). Exits non-zero on any failed assertion.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
../../infra/matrix/dev-up.sh # bring Synapse up first
|
||||
./run.sh # provision + validate (A2/A3/A4)
|
||||
```
|
||||
|
||||
Tunables (env): `HEARTBEAT_INTERVAL_MS` (1000), `MISS_TOLERANCE` (2),
|
||||
`DARK_THRESHOLD_MS` (6000), `AGENT_SLUGS` (`alpha,bravo,charlie`),
|
||||
`VICTIM_SLUG` (`charlie`), `MATRIX_CS_URL` (defaults to the TLS endpoint).
|
||||
|
||||
## Note on `NODE_TLS_REJECT_UNAUTHORIZED=0`
|
||||
|
||||
`run.sh` sets this **only** because the dev homeserver uses a self-signed cert.
|
||||
It is a dev convenience for exercising the SDK over TLS and must never be used
|
||||
against a real CA-issued endpoint.
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* tools/matrix-presence-harness/agent-proc.ts
|
||||
*
|
||||
* *** DEV harness — LOCAL SANDBOX ONLY. ***
|
||||
*
|
||||
* Runs ONE presence agent as its own OS process: joins the fleet presence
|
||||
* room and heartbeats `mosaic.presence` via @mosaicstack/comms (RFC-001 P1).
|
||||
* Kept as a standalone process so the validation harness can `kill -9` it to
|
||||
* prove A3 (hard-kill -> offline within dark_threshold) against a real crash,
|
||||
* not a graceful shutdown.
|
||||
*
|
||||
* Auth in DEV is Application-Service masquerade: the process presents the
|
||||
* as_token and acts as its own @agent-<slug> MXID. (Per-agent minted tokens
|
||||
* are P2, RFC-001 §2.2/§8.)
|
||||
*
|
||||
* ESM / NodeNext: .js import extensions.
|
||||
*/
|
||||
import { MinimalMatrixClient, PresenceAgent, type LivenessPolicy } from '@mosaicstack/comms';
|
||||
|
||||
const env = (k: string, fallback?: string): string => {
|
||||
const v = process.env[k] ?? fallback;
|
||||
if (v === undefined) throw new Error(`missing env ${k}`);
|
||||
return v;
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const homeserverUrl = env('MATRIX_CS_URL');
|
||||
const asToken = env('MOSAIC_AS_TOKEN');
|
||||
const serverName = env('MATRIX_SERVER_NAME');
|
||||
const slug = env('AGENT_SLUG');
|
||||
const roomId = env('FLEET_ROOM_ID');
|
||||
const intervalMs = Number(env('HEARTBEAT_INTERVAL_MS', '1000'));
|
||||
|
||||
const mxid = `@agent-${slug}:${serverName}`;
|
||||
const policy: LivenessPolicy = {
|
||||
heartbeatIntervalMs: intervalMs,
|
||||
missTolerance: Number(env('MISS_TOLERANCE', '2')),
|
||||
darkThresholdMs: Number(env('DARK_THRESHOLD_MS', '6000')),
|
||||
};
|
||||
|
||||
const client = new MinimalMatrixClient({
|
||||
homeserverUrl,
|
||||
accessToken: asToken,
|
||||
actAsUserId: mxid,
|
||||
});
|
||||
const agent = new PresenceAgent({
|
||||
client,
|
||||
agent: { mxid, slug, harness: 'claude-code' },
|
||||
roomId,
|
||||
intervalMs,
|
||||
policy,
|
||||
onError: (err) => console.error(`[${slug}] heartbeat error:`, (err as Error).message),
|
||||
});
|
||||
|
||||
await agent.connect();
|
||||
agent.start();
|
||||
console.log(`[${slug}] pid=${process.pid} mxid=${mxid} heartbeating every ${intervalMs}ms`);
|
||||
|
||||
// Graceful stop only on SIGTERM; the A3 test uses SIGKILL (no cleanup runs).
|
||||
process.on('SIGTERM', () => {
|
||||
void agent.stop().finally(() => process.exit(0));
|
||||
});
|
||||
// Keep the event loop alive indefinitely.
|
||||
setInterval(() => {}, 1 << 30);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('agent-proc fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@mosaicstack/matrix-presence-harness",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "DEV-ONLY validation harness for RFC-001 P1 (Matrix presence). Not shipped.",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"validate": "./run.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/appservice": "workspace:*",
|
||||
"@mosaicstack/comms": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* tools/matrix-presence-harness/provision.ts
|
||||
*
|
||||
* *** DEV harness — LOCAL SANDBOX ONLY. ***
|
||||
*
|
||||
* The MINIMAL P1 provisioner (RFC-001 §10 P1): its ONLY job is to register a
|
||||
* few agent MXIDs, create the fleet presence room, and join the agents. It is
|
||||
* deliberately NOT the P2 appservice (no auto-enroll, no room taxonomy, no
|
||||
* token minting, no introductions).
|
||||
*
|
||||
* It reuses the existing, tested `@mosaicstack/appservice` core library
|
||||
* (AppserviceIntent: register / createRoom / join over the AS API) rather than
|
||||
* reinventing Matrix plumbing.
|
||||
*
|
||||
* ESM / NodeNext: .js import extensions.
|
||||
*/
|
||||
import { AppserviceIntent, type AppserviceConfig } from '@mosaicstack/appservice';
|
||||
|
||||
export interface ProvisionOptions {
|
||||
homeserverUrl: string;
|
||||
serverName: string;
|
||||
asToken: string;
|
||||
hsToken: string;
|
||||
agentSlugs: string[];
|
||||
fleetAlias?: string; // localpart, default "mosaic-fleet"
|
||||
}
|
||||
|
||||
export interface ProvisionResult {
|
||||
roomId: string;
|
||||
senderUserId: string;
|
||||
agents: { slug: string; mxid: string }[];
|
||||
}
|
||||
|
||||
/** Register agents, create the fleet presence room, join everyone. Idempotent. */
|
||||
export async function provision(opts: ProvisionOptions): Promise<ProvisionResult> {
|
||||
const cfg: AppserviceConfig = {
|
||||
homeserverUrl: opts.homeserverUrl,
|
||||
domain: opts.serverName,
|
||||
asToken: opts.asToken,
|
||||
hsToken: opts.hsToken,
|
||||
};
|
||||
const intent = new AppserviceIntent(cfg);
|
||||
const fleetAlias = opts.fleetAlias ?? 'mosaic-fleet';
|
||||
|
||||
// 1. Register the virtual agent MXIDs (bypasses enable_registration:false).
|
||||
const agents = [];
|
||||
for (const slug of opts.agentSlugs) {
|
||||
const mxid = await intent.ensureRegistered(slug);
|
||||
await intent.setDisplayName(slug, `agent ${slug} (DEV)`);
|
||||
agents.push({ slug, mxid });
|
||||
}
|
||||
|
||||
// 2. Create the single fleet presence room, inviting all agents (RFC-001 §4.6).
|
||||
// Idempotent: if the alias already exists, reuse that room instead.
|
||||
let roomId: string;
|
||||
try {
|
||||
const created = await intent.createRoom({
|
||||
name: 'Fleet Presence (DEV)',
|
||||
alias: fleetAlias,
|
||||
topic: 'RFC-001 P1 — mosaic.presence heartbeats. Who is alive?',
|
||||
invite: agents.map((a) => a.mxid),
|
||||
});
|
||||
roomId = created.roomId;
|
||||
} catch (err) {
|
||||
// Alias already taken (re-run): resolve the existing room id.
|
||||
const aliasFq = `#${fleetAlias}:${opts.serverName}`;
|
||||
roomId = await resolveAlias(opts, aliasFq);
|
||||
void err;
|
||||
}
|
||||
|
||||
// 3. Join every agent into the fleet room (invite + join; idempotent).
|
||||
for (const { slug } of agents) {
|
||||
await intent.ensureJoined(roomId, slug);
|
||||
}
|
||||
|
||||
return { roomId, senderUserId: intent.senderUserId, agents };
|
||||
}
|
||||
|
||||
async function resolveAlias(
|
||||
opts: Pick<ProvisionOptions, 'homeserverUrl' | 'asToken'>,
|
||||
aliasFq: string,
|
||||
): Promise<string> {
|
||||
const url = `${opts.homeserverUrl.replace(/\/$/, '')}/_matrix/client/v3/directory/room/${encodeURIComponent(aliasFq)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${opts.asToken}` } });
|
||||
if (!res.ok) throw new Error(`resolveAlias ${aliasFq} -> ${res.status}`);
|
||||
const data = (await res.json()) as { room_id?: string };
|
||||
if (!data.room_id) throw new Error(`resolveAlias ${aliasFq} returned no room_id`);
|
||||
return data.room_id;
|
||||
}
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# run.sh — RFC-001 P1 presence validation harness (A1–A5) *** DEV-ONLY ***
|
||||
# ============================================================================
|
||||
#
|
||||
# Assumes the dev Synapse is already up (infra/matrix/dev-up.sh). Provisions 3
|
||||
# agents, heartbeats them, proves A2/A3/A4 against the LOCAL dev homeserver,
|
||||
# exercising @mosaicstack/comms over the self-signed TLS endpoint (A1).
|
||||
#
|
||||
# NODE_TLS_REJECT_UNAUTHORIZED=0 is set ONLY because the dev cert is
|
||||
# self-signed. This is a DEV convenience and must never be used in prod.
|
||||
# ----------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "${HERE}/../.." && pwd)"
|
||||
DATA="${REPO}/infra/matrix/.data"
|
||||
|
||||
if [[ ! -f "${DATA}/dev-secrets.env" ]]; then
|
||||
echo "run.sh: ${DATA}/dev-secrets.env not found — run infra/matrix/dev-up.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
set -a; source "${DATA}/dev-secrets.env"; set +a
|
||||
|
||||
export MATRIX_SERVER_NAME="${MATRIX_SERVER_NAME:-matrix.localhost}"
|
||||
export MATRIX_TLS_PORT="${MATRIX_TLS_PORT:-18448}"
|
||||
# Exercise the SDK over the self-signed TLS listener (A1). Use the plain HTTP
|
||||
# port instead by exporting MATRIX_CS_URL=http://127.0.0.1:18008 before running.
|
||||
export MATRIX_CS_URL="${MATRIX_CS_URL:-https://127.0.0.1:${MATRIX_TLS_PORT}}"
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
|
||||
export HEARTBEAT_INTERVAL_MS="${HEARTBEAT_INTERVAL_MS:-1000}"
|
||||
export MISS_TOLERANCE="${MISS_TOLERANCE:-2}"
|
||||
export DARK_THRESHOLD_MS="${DARK_THRESHOLD_MS:-6000}"
|
||||
export AGENT_SLUGS="${AGENT_SLUGS:-alpha,bravo,charlie}"
|
||||
export VICTIM_SLUG="${VICTIM_SLUG:-charlie}"
|
||||
|
||||
shopt -s nullglob
|
||||
TSX_CANDIDATES=("${REPO}"/node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs)
|
||||
shopt -u nullglob
|
||||
TSX_CLI="${TSX_CANDIDATES[0]:-}"
|
||||
if [[ -z "${TSX_CLI}" ]]; then
|
||||
echo "run.sh: tsx not found under node_modules — run pnpm install first" >&2
|
||||
exit 1
|
||||
fi
|
||||
export TSX_CLI
|
||||
|
||||
echo "[run] CS=${MATRIX_CS_URL} server_name=${MATRIX_SERVER_NAME} dark_threshold=${DARK_THRESHOLD_MS}ms"
|
||||
cd "${REPO}"
|
||||
exec node "${TSX_CLI}" "${HERE}/validate.ts"
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* tools/matrix-presence-harness/validate.ts
|
||||
*
|
||||
* *** DEV harness — LOCAL SANDBOX ONLY. Never point at live infra. ***
|
||||
*
|
||||
* End-to-end proof of RFC-001 P1 acceptance criteria against the local dev
|
||||
* Synapse (infra/matrix):
|
||||
*
|
||||
* A2 provision >=3 agents, they heartbeat, all show ONLINE in the fleet
|
||||
* presence room.
|
||||
* A3 hard-kill (SIGKILL) one agent's process; assert it flips to OFFLINE
|
||||
* within dark_threshold, DETERMINISTICALLY (heartbeat-age based, not
|
||||
* native-presence-timeout), while the survivors stay ONLINE.
|
||||
* A4 dump the human-readable fleet liveness board.
|
||||
*
|
||||
* Exits 0 on success, 1 on any failed assertion.
|
||||
* ESM / NodeNext: .js import extensions.
|
||||
*/
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
FleetLivenessReader,
|
||||
MinimalMatrixClient,
|
||||
type AgentLiveness,
|
||||
type LivenessPolicy,
|
||||
} from '@mosaicstack/comms';
|
||||
|
||||
import { provision } from './provision.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const env = (k: string, fallback?: string): string => {
|
||||
const v = process.env[k] ?? fallback;
|
||||
if (v === undefined) throw new Error(`missing env ${k}`);
|
||||
return v;
|
||||
};
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
const board = (rows: AgentLiveness[]): Record<string, string> =>
|
||||
Object.fromEntries(rows.map((r) => [r.slug, r.status]));
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const homeserverUrl = env('MATRIX_CS_URL');
|
||||
const asToken = env('MOSAIC_AS_TOKEN');
|
||||
const hsToken = env('MOSAIC_HS_TOKEN', 'unused-in-p1');
|
||||
const serverName = env('MATRIX_SERVER_NAME');
|
||||
const intervalMs = Number(env('HEARTBEAT_INTERVAL_MS', '1000'));
|
||||
const missTolerance = Number(env('MISS_TOLERANCE', '2'));
|
||||
const darkThresholdMs = Number(env('DARK_THRESHOLD_MS', '6000'));
|
||||
const slugs = env('AGENT_SLUGS', 'alpha,bravo,charlie').split(',');
|
||||
const victim = env('VICTIM_SLUG', 'charlie');
|
||||
|
||||
const policy: LivenessPolicy = {
|
||||
heartbeatIntervalMs: intervalMs,
|
||||
missTolerance,
|
||||
darkThresholdMs,
|
||||
};
|
||||
const failures: string[] = [];
|
||||
const assert = (ok: boolean, msg: string): void => {
|
||||
console.log(` ${ok ? 'PASS' : 'FAIL'} ${msg}`);
|
||||
if (!ok) failures.push(msg);
|
||||
};
|
||||
|
||||
// ── Provision (register agents, create fleet room, join) ──────────────────
|
||||
console.log(`\n[validate] provisioning ${slugs.length} agents + fleet presence room...`);
|
||||
const { roomId, senderUserId } = await provision({
|
||||
homeserverUrl,
|
||||
serverName,
|
||||
asToken,
|
||||
hsToken,
|
||||
agentSlugs: slugs,
|
||||
});
|
||||
console.log(`[validate] fleet room ${roomId} (sender ${senderUserId})`);
|
||||
|
||||
// Reader masquerades as the AS sender (a room member) to read heartbeats.
|
||||
const reader = new FleetLivenessReader({
|
||||
client: new MinimalMatrixClient({
|
||||
homeserverUrl,
|
||||
accessToken: asToken,
|
||||
actAsUserId: senderUserId,
|
||||
}),
|
||||
roomId,
|
||||
policy,
|
||||
});
|
||||
|
||||
// ── Spawn each agent as its own OS process ────────────────────────────────
|
||||
const procs = new Map<string, ChildProcess>();
|
||||
for (const slug of slugs) {
|
||||
// `--import tsx` runs agent-proc.ts IN-PROCESS (no tsx grandchild), so the
|
||||
// spawned pid IS the agent — a SIGKILL to it is a true hard-kill (A3).
|
||||
// cwd=HERE so the `tsx` specifier resolves against the harness node_modules.
|
||||
const child = spawn('node', ['--import', 'tsx', resolve(HERE, 'agent-proc.ts')], {
|
||||
cwd: HERE,
|
||||
env: {
|
||||
...process.env,
|
||||
AGENT_SLUG: slug,
|
||||
FLEET_ROOM_ID: roomId,
|
||||
MATRIX_CS_URL: homeserverUrl,
|
||||
MOSAIC_AS_TOKEN: asToken,
|
||||
MATRIX_SERVER_NAME: serverName,
|
||||
HEARTBEAT_INTERVAL_MS: String(intervalMs),
|
||||
MISS_TOLERANCE: String(missTolerance),
|
||||
DARK_THRESHOLD_MS: String(darkThresholdMs),
|
||||
},
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
procs.set(slug, child);
|
||||
}
|
||||
|
||||
const hardKill = (pid: number): void => {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL'); // pid is the in-process agent — a true kill
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
for (const [, c] of procs) {
|
||||
if (c.pid) hardKill(c.pid);
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
|
||||
try {
|
||||
// ── A2: let a few beats flow, assert all online ─────────────────────────
|
||||
console.log(`\n[validate] A2 — waiting for heartbeats (${intervalMs}ms interval)...`);
|
||||
await sleep(intervalMs * 4 + 1000);
|
||||
const a2 = await reader.read();
|
||||
console.log('[validate] board:', board(a2));
|
||||
for (const slug of slugs) {
|
||||
assert(a2.find((r) => r.slug === slug)?.status === 'online', `A2 ${slug} is online`);
|
||||
}
|
||||
assert(a2.length >= 3, 'A2 >=3 agents present in fleet room');
|
||||
|
||||
// ── A4: human-readable board ────────────────────────────────────────────
|
||||
console.log('\n[validate] A4 — fleet liveness board (human view):');
|
||||
console.log(await reader.formatBoard());
|
||||
|
||||
// ── A3: hard-kill the victim, measure flip-to-offline latency ───────────
|
||||
const victimProc = procs.get(victim);
|
||||
if (!victimProc?.pid) throw new Error(`no pid for victim ${victim}`);
|
||||
console.log(
|
||||
`\n[validate] A3 — SIGKILL ${victim} (pid ${victimProc.pid}); dark_threshold=${darkThresholdMs}ms`,
|
||||
);
|
||||
const killTs = Date.now();
|
||||
hardKill(victimProc.pid);
|
||||
|
||||
let victimOffline: AgentLiveness | undefined;
|
||||
let detectMs = 0;
|
||||
const deadline = killTs + darkThresholdMs + 6000;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(400);
|
||||
const rows = await reader.read();
|
||||
const v = rows.find((r) => r.slug === victim);
|
||||
const elapsed = Date.now() - killTs;
|
||||
if (v) {
|
||||
console.log(
|
||||
` t+${String(elapsed).padStart(5)}ms ${victim}=${v.status} (age=${v.ageMs}ms) ` +
|
||||
`survivors=${slugs
|
||||
.filter((s) => s !== victim)
|
||||
.map((s) => `${s}:${rows.find((r) => r.slug === s)?.status}`)
|
||||
.join(',')}`,
|
||||
);
|
||||
if (v.status === 'offline') {
|
||||
victimOffline = v;
|
||||
detectMs = elapsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert(!!victimOffline, `A3 ${victim} flipped to offline after kill`);
|
||||
if (victimOffline) {
|
||||
// Deterministic: the flip is driven by heartbeat age reaching dark_threshold.
|
||||
assert(
|
||||
victimOffline.ageMs >= darkThresholdMs,
|
||||
`A3 flip is heartbeat-deterministic (age ${victimOffline.ageMs}ms >= dark_threshold ${darkThresholdMs}ms)`,
|
||||
);
|
||||
assert(
|
||||
detectMs <= darkThresholdMs + 2000,
|
||||
`A3 detected within dark_threshold + poll slack (detected at t+${detectMs}ms)`,
|
||||
);
|
||||
}
|
||||
// Survivors unaffected.
|
||||
const finalRows = await reader.read();
|
||||
for (const slug of slugs.filter((s) => s !== victim)) {
|
||||
assert(
|
||||
finalRows.find((r) => r.slug === slug)?.status === 'online',
|
||||
`A3 survivor ${slug} still online`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n[validate] final board after kill:');
|
||||
console.log(await reader.formatBoard());
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n[validate] ${failures.length === 0 ? 'ALL PASSED ✅' : `FAILED ❌ (${failures.length})`}`,
|
||||
);
|
||||
process.exit(failures.length === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[validate] fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user