fix(multi-site): promotion no longer orphans the demoted old master's LDAP replication
Found while auditing the new LDAP MMR auto-config for gaps: neither POST /site-promote nor POST /demote ever touched SiteSpoke. Two real problems: 1. The demoted old master got a fresh masterJoinKey but was never registered as a spoke of the new master -- no SiteSpoke row, no ldapServerId, invisible to GET /ldap-peers's peer list. It also structurally could not self-heal: POST /join refuses re-join for a node that's already a spoke, and separately requires a fresh install (siteIsFresh()) -- neither true for a former master with real users/agents. Fixed: /demote now registers itself with the new master immediately (POST /spokes), the same way a real join does, deriving its own endpoint from stack.selfUrl (override) or https://stack.ssoHost (the normal case). 2. The promoted node's live OpenLDAP ServerID doesn't change -- GET /ldap-replication-config starts advertising 1 for it immediately (derived purely from cfg.isMaster), but nothing restarts slapd with that value (OpenLDAP's static slapd.conf only reloads at process start, and this app has no safe way to restart its own container). Can't be fixed in-process; surfaced instead -- /site-promote's response now includes ldapReplicationNote telling the operator to re-run setup.sh promptly. Verified against real running containers (docker-compose.multisite-e2e.yml): after promotion, the demoted old master correctly appears in the new master's LDAP peer list with a real assigned ldapServerId.
This commit is contained in:
@@ -25,6 +25,11 @@ services:
|
|||||||
- LDAP_ADMIN_PASS=secret
|
- LDAP_ADMIN_PASS=secret
|
||||||
- ORG_NAME=E2E Master
|
- ORG_NAME=E2E Master
|
||||||
- app_oauth__jwtSecret=e2e-multisite-master-jwt-secret
|
- 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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
@@ -42,6 +47,7 @@ services:
|
|||||||
- LDAP_ADMIN_PASS=secret
|
- LDAP_ADMIN_PASS=secret
|
||||||
- ORG_NAME=E2E Spoke
|
- ORG_NAME=E2E Spoke
|
||||||
- app_oauth__jwtSecret=e2e-multisite-spoke-jwt-secret
|
- app_oauth__jwtSecret=e2e-multisite-spoke-jwt-secret
|
||||||
|
- app_stack__selfUrl=http://spoke:3001
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
|
|||||||
@@ -1116,6 +1116,16 @@ router.post('/site-promote', async (req, res, next) => {
|
|||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Node successfully promoted to Master Site',
|
message: 'Node successfully promoted to Master Site',
|
||||||
handoff: handoffNote,
|
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: {
|
config: {
|
||||||
isMaster: true,
|
isMaster: true,
|
||||||
masterUrl: '',
|
masterUrl: '',
|
||||||
|
|||||||
@@ -260,7 +260,44 @@ router.post('/demote', async (req, res, next) => {
|
|||||||
const base = String(newMasterUrl).replace(/\/+$/, '');
|
const base = String(newMasterUrl).replace(/\/+$/, '');
|
||||||
siteConfig.save({ isMaster: false, masterUrl: base, masterJoinKey: newJoinKey });
|
siteConfig.save({ isMaster: false, masterUrl: base, masterJoinKey: newJoinKey });
|
||||||
logAudit('demoted', { demotedBy: key.keyPrefix, newMasterUrl: base });
|
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); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,16 @@ async function main() {
|
|||||||
if (promoteRes.body.handoff !== 'previous master demoted') {
|
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)}`);
|
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');
|
step('Verifying the newly-promoted node is master');
|
||||||
const { body: newMasterCfg } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken });
|
const { body: newMasterCfg } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken });
|
||||||
|
|||||||
Reference in New Issue
Block a user