From 301770321e981caec3d2a56249b1c5f7e3b3ce9c Mon Sep 17 00:00:00 2001 From: wmantly Date: Mon, 10 Aug 2026 09:12:47 -0700 Subject: [PATCH 1/2] feat(setup): first-run site join via CFG_MASTER_DIRECTORY_URL / CFG_MASTER_DIRECTORY_JOIN_KEY Multi-site join wiring (server + UI landed in theta-directory v2.3.0): - bootstrap/site-join.js: runs inside the sso-manager container (same self-contained rule as bootstrap.js); logs in as the bootstrap admin and calls /api/site/join. Idempotent: an already-joined node reports 'already a spoke'. - setup.sh step 5b: if setup.env sets CFG_MASTER_DIRECTORY_URL + CFG_MASTER_DIRECTORY_JOIN_KEY, run the join after the bootstrap. Only honored on first run (ensure_config reads setup.env once and ignores it once ./config/ exists), so an already-populated directory can never be merged. - setup.env.example documents both vars. - lint.yml also node --check's site-join.js. --- .github/workflows/lint.yml | 6 ++- bootstrap/site-join.js | 82 ++++++++++++++++++++++++++++++++++++++ setup.env.example | 11 +++++ setup.sh | 16 ++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 bootstrap/site-join.js diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8a2f2b7..86ab0da 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,7 +28,7 @@ jobs: run: shellcheck -S warning setup.sh bootstrap-syntax: - name: Syntax check bootstrap.js + name: Syntax check bootstrap scripts runs-on: ubuntu-latest steps: - name: Checkout code @@ -40,7 +40,9 @@ jobs: node-version: 22.x - name: Syntax check - run: node --check bootstrap/bootstrap.js + run: | + node --check bootstrap/bootstrap.js + node --check bootstrap/site-join.js - name: Jump-host LDAP config consistency run: node test/check_jump_ldap_tls.js diff --git a/bootstrap/site-join.js b/bootstrap/site-join.js new file mode 100644 index 0000000..7218b35 --- /dev/null +++ b/bootstrap/site-join.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/* + * theta-suite site-join — runs inside the sso-manager container to adopt a + * master site's directory as a read-only spoke. Invoked by setup.sh when + * setup.env sets CFG_MASTER_DIRECTORY_URL + CFG_MASTER_DIRECTORY_JOIN_KEY: + * + * docker compose exec sso-manager node /bootstrap/site-join.js \ + * https://sso.master.example.com stj_9f2e... + * + * Self-contained (Node built-ins + global fetch), same rule as bootstrap.js — + * it does NOT require the SSO's internal models. It logs in as the bootstrap + * admin (reading /config/sso-secrets.js) and calls the SSO's own + * /api/site/join, which imports the master's resource catalog + LDAP tree and + * persists the spoke role in /config/site.json. + * + * Output (stdout, KEY=VALUE for setup.sh): JOINED, SITE_SLUG, RESOURCES, LDAP. + * Progress logs go to stderr. + */ +'use strict'; + +const sso = require('/config/sso-secrets.js'); + +const ADMIN_UID = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin'; +const ADMIN_USER_PASS = (sso.bootstrap && sso.bootstrap.adminPass) || ''; +const SSO_INTERNAL = 'http://localhost:3001'; + +const masterUrl = process.argv[2]; +const joinKey = process.argv[3]; + +function log(msg) { console.error('[site-join] ' + msg); } + +async function main() { + if (!masterUrl || !joinKey) { + throw new Error('usage: node /bootstrap/site-join.js '); + } + + // 1. Login as the bootstrap admin (validates the password end-to-end). + const loginRes = await fetch(`${SSO_INTERNAL}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uid: ADMIN_UID, password: ADMIN_USER_PASS }), + }); + if (!loginRes.ok) { + throw new Error(`admin login failed (${loginRes.status}): ${await loginRes.text().catch(() => '')}`); + } + const loginData = await loginRes.json(); + const token = loginData.token; + if (!token) throw new Error('admin login returned no token'); + log(`Logged in as ${ADMIN_UID}`); + + // 2. Join the master. + const res = await fetch(`${SSO_INTERNAL}/api/site/join`, { + method: 'POST', + headers: { 'auth-token': token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterUrl, joinKey }), + }); + const text = await res.text().catch(() => ''); + let data = null; + try { data = JSON.parse(text); } catch (e) { /* not JSON */ } + if (!res.ok) { + // A node that already joined is a no-op, not a failure (idempotent setup). + if (res.status === 400 && data && /already a spoke/i.test(data.message || '')) { + log('Already a spoke — nothing to do.'); + console.log('JOINED=already'); + return; + } + throw new Error(`join failed (${res.status}): ${(data && data.message) || text}`); + } + + log(`Joined master site ${masterUrl} as ${data.siteSlug || '?'}`); + console.log([ + `JOINED=yes`, + `SITE_SLUG=${data.siteSlug || ''}`, + `RESOURCES=${(data.resources && data.resources.created) || 0}`, + `LDAP=${(data.ldap && data.ldap.note) || ''}` + ].join(' ')); +} + +main().catch((e) => { + console.error('[site-join] FAILED: ' + e.message); + process.exit(1); +}); diff --git a/setup.env.example b/setup.env.example index eb728ac..ab3422c 100644 --- a/setup.env.example +++ b/setup.env.example @@ -48,6 +48,17 @@ CFG_DOMAIN=example.com # under an OU-style prefix). Leave unset to use the DN built from CFG_DOMAIN: #CFG_BASE_DN=dc=example,dc=com +# ── Multi-Site: join an existing (master) directory ────────────────────────── +# To run THIS deployment as a read-only SPOKE of an existing Theta Directory +# instead of seeding a fresh one, set the master's URL and a site join key +# (mint one on the master: Directory -> the Master Site modal -> Site Join Keys +# -> Mint key). Honored ONLY on a first-run bring-up (before ./config/ exists), +# so it can never merge an already-populated directory; re-runs ignore it. +# The spoke adopts the master's users/groups/resources and persists its spoke +# role in ./config/site.json (isMaster=false, masterUrl, siteSlug). +#CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com +#CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e... + # ── Optional outbound HTTP(S) proxy ────────────────────────────────────────── # For an isolated/offline/corporate-network test host that only reaches the # internet through an upstream HTTP proxy — NOT the theta42 "proxy" app. diff --git a/setup.sh b/setup.sh index c2d7c96..50fdeed 100755 --- a/setup.sh +++ b/setup.sh @@ -1153,6 +1153,22 @@ else info "OAuth client registered + creds written into $CONFIG_DIR/proxy-secrets.js." fi +# ── 5b. Multi-site: join an existing master directory (first-run only) ──────── +# setup.env: CFG_MASTER_DIRECTORY_URL + CFG_MASTER_DIRECTORY_JOIN_KEY (mint a +# site join key on the master). Only honored on a first-run bring-up: ensure_config +# reads setup.env once and ignores it once ./config/ exists, so an already-running +# directory can never be merged into a master's. Idempotent — a node that already +# joined reports "already a spoke" and setup continues. +if [[ -n "${CFG_MASTER_DIRECTORY_URL:-}" && -n "${CFG_MASTER_DIRECTORY_JOIN_KEY:-}" ]]; then + info "Joining master site ${CFG_MASTER_DIRECTORY_URL} (CFG_MASTER_DIRECTORY_*)..." + if ! "${COMPOSE[@]}" exec -T sso-manager node /bootstrap/site-join.js \ + "$CFG_MASTER_DIRECTORY_URL" "$CFG_MASTER_DIRECTORY_JOIN_KEY"; then + die "site join failed — check the master URL + site join key (mint one on the master's Site Join Keys card)." + fi +else + info "No CFG_MASTER_DIRECTORY_URL/CFG_MASTER_DIRECTORY_JOIN_KEY — running as a fresh master site." +fi + # ── 6. Start the proxy, wait for health ─────────────────────────────────────── # PROXY_GIT_COMMIT: same reasoning as SSO_GIT_COMMIT above. PROXY_GIT_COMMIT="$(git -C proxy rev-parse --short HEAD 2>/dev/null || echo unknown)" From ea75e94b3eb14e99bc2ffbeb99302db18fbc8950 Mon Sep 17 00:00:00 2001 From: wmantly Date: Mon, 10 Aug 2026 09:33:01 -0700 Subject: [PATCH 2/2] ci(lint): keep job name 'Syntax check bootstrap.js' for branch protection Renaming the job broke the master protection rule, which requires a check named exactly 'Syntax check bootstrap.js'. The job still checks both bootstrap scripts, just under the protected name. --- .github/workflows/lint.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 86ab0da..f19b63c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,7 +28,9 @@ jobs: run: shellcheck -S warning setup.sh bootstrap-syntax: - name: Syntax check bootstrap scripts + # Keep this job name stable: branch protection on master requires a status + # check named exactly "Syntax check bootstrap.js". + name: Syntax check bootstrap.js runs-on: ubuntu-latest steps: - name: Checkout code