Merge pull request #202 from theta42/feat/multi-site-join-setup

feat(setup): first-run site join via CFG_MASTER_DIRECTORY_URL / CFG_MASTER_DIRECTORY_JOIN_KEY
This commit is contained in:
2026-08-10 09:34:14 -07:00
committed by GitHub
4 changed files with 114 additions and 1 deletions
+5 -1
View File
@@ -28,6 +28,8 @@ jobs:
run: shellcheck -S warning setup.sh
bootstrap-syntax:
# 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:
@@ -40,7 +42,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
+82
View File
@@ -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 <masterUrl> <joinKey>');
}
// 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);
});
+11
View File
@@ -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.
+16
View File
@@ -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)"