diff --git a/docker-compose.multisite-e2e.yml b/docker-compose.multisite-e2e.yml index 3f343e7..0e0b5b4 100644 --- a/docker-compose.multisite-e2e.yml +++ b/docker-compose.multisite-e2e.yml @@ -25,6 +25,11 @@ services: - LDAP_ADMIN_PASS=secret - ORG_NAME=E2E Master - app_oauth__jwtSecret=e2e-multisite-master-jwt-secret + # POST /demote's self-registration (routes/api_site.js) needs a real + # reachable endpoint for this container; stack.selfUrl overrides the + # normal https:// derivation, which isn't reachable + # here (plain HTTP, no TLS/proxy in front, non-443 port). + - app_stack__selfUrl=http://master:3001 healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"] interval: 2s @@ -42,6 +47,7 @@ services: - LDAP_ADMIN_PASS=secret - ORG_NAME=E2E Spoke - app_oauth__jwtSecret=e2e-multisite-spoke-jwt-secret + - app_stack__selfUrl=http://spoke:3001 healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"] interval: 2s diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 99beb19..8a99ec4 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -1116,6 +1116,16 @@ router.post('/site-promote', async (req, res, next) => { status: 'ok', message: 'Node successfully promoted to Master Site', handoff: handoffNote, + // This node's own OpenLDAP ServerID stays whatever it was as a spoke + // (e.g. 2) until `setup.sh` is re-run here -- GET + // /ldap-replication-config will immediately start advertising 1 for + // this node (the master's reserved ID) since that's derived purely + // from cfg.isMaster, but nothing restarts slapd with the new value + // automatically (OpenLDAP's static slapd.conf is only read at process + // start, and this app has no safe way to restart its own container). + // Surfaced here + on the Multi-Site modal so an operator promoting a + // site knows to re-run setup.sh promptly, not just assume it's done. + ldapReplicationNote: 'Re-run setup.sh on this node to apply its new LDAP ServerID (1) and pick up the current spoke peer list -- OpenLDAP config only reloads at process start.', config: { isMaster: true, masterUrl: '', diff --git a/nodejs/routes/api_site.js b/nodejs/routes/api_site.js index 113e109..9671b2d 100644 --- a/nodejs/routes/api_site.js +++ b/nodejs/routes/api_site.js @@ -260,7 +260,44 @@ router.post('/demote', async (req, res, next) => { const base = String(newMasterUrl).replace(/\/+$/, ''); siteConfig.save({ isMaster: false, masterUrl: base, masterJoinKey: newJoinKey }); logAudit('demoted', { demotedBy: key.keyPrefix, newMasterUrl: base }); - res.json({ status: 'ok', message: 'Demoted to spoke of ' + base }); + + // Register with the new master immediately, the same way a real /join + // does (POST /spokes) -- without this, a demoted former master was + // orphaned: it had a masterJoinKey but no SiteSpoke entry on the new + // master (so no ldapServerId, no live replication push target), and + // structurally could never self-heal via /join (which refuses re-join + // for a node that's already a spoke, and requires a fresh install -- + // neither true for a former master with real users/agents). Best-effort: + // failing to register here must not fail the demotion itself, same + // reasoning as a normal join's optional live-replication registration. + let registrationNote = 'not attempted (no stack.ssoHost/stack.selfUrl configured to register with)'; + // stack.selfUrl is a full-URL override (scheme + port) for environments + // where "https://" isn't the real reachable address -- the + // multisite e2e test harness (plain HTTP, docker-network hostnames, + // no TLS/proxy in front) is exactly that case; every real deployment + // just relies on the ssoHost derivation. + const selfUrl = (conf.stack && conf.stack.selfUrl) || (conf.stack && conf.stack.ssoHost && `https://${conf.stack.ssoHost}`); + if (selfUrl) { + try { + const regResp = await fetch(base + '/api/site/spokes', { + method: 'POST', + headers: { Authorization: 'Bearer ' + newJoinKey, 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: selfUrl, siteSlug: cfg.siteSlug }) + }); + if (regResp.ok) { + const regBody = await regResp.json(); + if (regBody.pushToken) siteConfig.save({ replicationPushToken: regBody.pushToken }); + registrationNote = 'registered as a spoke of the new master'; + } else { + registrationNote = 'registration failed: HTTP ' + regResp.status; + } + } catch (e) { + registrationNote = 'registration failed: ' + e.message; + } + } + logAudit('demoted_self_registered', { newMasterUrl: base, registrationNote }); + + res.json({ status: 'ok', message: 'Demoted to spoke of ' + base, registration: { note: registrationNote } }); } catch (e) { next(e); } }); diff --git a/test/multisite_join_e2e.js b/test/multisite_join_e2e.js index 0d31312..40b7fd9 100644 --- a/test/multisite_join_e2e.js +++ b/test/multisite_join_e2e.js @@ -281,6 +281,16 @@ async function main() { if (promoteRes.body.handoff !== 'previous master demoted') { fail(`expected the old master to be demoted as part of promotion, got handoff=${JSON.stringify(promoteRes.body.handoff)}`); } + if (!promoteRes.body.ldapReplicationNote) { + fail('expected /site-promote to surface a note that this node\'s LDAP ServerID needs a setup.sh re-run to apply'); + } + + step('Verifying the demoted old master auto-registered itself as a real spoke of the new master (not orphaned)'); + const { body: newMasterLdapCfg } = await api(SPOKE_URL, '/api/directory-admin/ldap-replication-config', { token: spokeToken }); + const oldMasterAsPeer = (newMasterLdapCfg.peers || []).find(p => p.ldapHost === 'ldaps://master:636'); + if (!oldMasterAsPeer || typeof oldMasterAsPeer.ldapServerId !== 'number') { + fail(`the demoted old master should appear as a registered peer with an assigned ldapServerId, got ${JSON.stringify(newMasterLdapCfg.peers)}`); + } step('Verifying the newly-promoted node is master'); const { body: newMasterCfg } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken });