Merge pull request #207 from theta42/fix-promotion-ldap-orphan

fix(multi-site): promotion no longer orphans the demoted old master's LDAP replication
This commit is contained in:
2026-08-10 20:24:29 -07:00
committed by GitHub
4 changed files with 64 additions and 1 deletions
+6
View File
@@ -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://<stack.ssoHost> 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
+10
View File
@@ -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: '',
+38 -1
View File
@@ -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://<ssoHost>" 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); }
});
+10
View File
@@ -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 });