feat(multi-site): auto-assign LDAP ServerID + replication hosts at join time
OpenLDAP N-way multi-master replication (docs/replication.md) required an operator to hand-set LDAP_SERVER_ID (unique per site) and LDAP_REPLICATION_HOSTS (every OTHER site's LDAP URL, kept in sync by hand across every node) -- real coordination work, and easy to get wrong or let drift as sites are added. Automates the coordination the master is already in a position to do: - SiteSpoke gets ldapServerId, auto-assigned (next free from 2 upward, 1 reserved for the master) at registration and reused across re-registrations -- same pattern as jump-host's mesh index. - ldapHost is derived from each site's already-known HTTP(S) endpoint (same hostname, port 636) rather than a separately-configured field that could drift from it. - New utils/ldap_replication.js (nextFreeLdapServerId, ldapHostFor), shared between the spoke-facing GET /api/site/ldap-peers (Bearer site join key, returns this caller's own ID + every peer) and the master-local GET /directory-admin/ldap-replication-config (computes its own config directly from SiteSpoke, no HTTP round-trip needed). Verified against real running containers (docker-compose.multisite-e2e.yml): after a real join, the master's computed config correctly includes the spoke as a peer with an assigned ID, and the spoke's own fetched config matches that ID and correctly excludes itself from its own peer list. Known limitation, documented in docs/replication.md: the master's own LDAP_REPLICATION_HOSTS only gets recomputed when ITS setup.sh is re-run (or an admin re-applies it directly) -- there's no live push to an already-running master when a new spoke joins. A spoke's own config is re-checked on every setup.sh run, which is the common/recurring event; the master side is a documented manual step for now rather than a live hot-reload (which would need OpenLDAP's dynamic cn=config backend -- a bigger change, deliberately out of scope here to avoid risking a live directory's LDAP replication on undertested config).
This commit is contained in:
@@ -205,6 +205,30 @@ async function main() {
|
||||
if (spokeCfg.config.isMaster !== false) fail(`spoke should be isMaster:false after join, got ${JSON.stringify(spokeCfg.config)}`);
|
||||
if (!spokeCfg.config.masterUrl) fail('spoke should have masterUrl set after join');
|
||||
|
||||
step('Verifying the master computed its own LDAP replication config (ServerID 1 + the new spoke as a peer)');
|
||||
const { body: masterLdapCfg } = await api(MASTER_URL, '/api/directory-admin/ldap-replication-config', { token: masterToken });
|
||||
if (masterLdapCfg.ldapServerId !== 1) fail(`master's own ldapServerId should be 1, got ${JSON.stringify(masterLdapCfg)}`);
|
||||
const spokePeer = (masterLdapCfg.peers || []).find(p => p.ldapHost === 'ldaps://spoke:636');
|
||||
if (!spokePeer || typeof spokePeer.ldapServerId !== 'number') {
|
||||
fail(`master's peer list should include the spoke at ldaps://spoke:636 with an assigned ldapServerId, got ${JSON.stringify(masterLdapCfg.peers)}`);
|
||||
}
|
||||
|
||||
step('Verifying the spoke can fetch its own assigned LDAP ServerID + peer list from the master');
|
||||
const spokeLdapPeersResp = await fetch(`${MASTER_URL}/api/site/ldap-peers?endpoint=${encodeURIComponent('http://spoke:3001')}`, {
|
||||
headers: { Authorization: 'Bearer ' + joinKey }
|
||||
});
|
||||
const spokeLdapCfg = await spokeLdapPeersResp.json();
|
||||
if (spokeLdapPeersResp.status !== 200) fail(`GET /api/site/ldap-peers failed: ${spokeLdapPeersResp.status} ${JSON.stringify(spokeLdapCfg)}`);
|
||||
if (spokeLdapCfg.ldapServerId !== spokePeer.ldapServerId) {
|
||||
fail(`spoke's own reported ldapServerId (${spokeLdapCfg.ldapServerId}) should match what the master's peer list assigned it (${spokePeer.ldapServerId})`);
|
||||
}
|
||||
const masterAsPeer = (spokeLdapCfg.peers || []).find(p => p.ldapServerId === 1);
|
||||
if (!masterAsPeer || masterAsPeer.ldapHost !== 'ldaps://master:636') {
|
||||
fail(`spoke's peer list should include the master (ServerID 1, ldaps://master:636), got ${JSON.stringify(spokeLdapCfg.peers)}`);
|
||||
}
|
||||
const selfInOwnPeerList = (spokeLdapCfg.peers || []).some(p => p.ldapServerId === spokeLdapCfg.ldapServerId);
|
||||
if (selfInOwnPeerList) fail(`spoke's own peer list should not include itself, got ${JSON.stringify(spokeLdapCfg.peers)}`);
|
||||
|
||||
step('Verifying the spoke adopted the master\'s pre-join catalog');
|
||||
const spokeResources = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken });
|
||||
const adopted = (spokeResources.body.results || spokeResources.body.resources || spokeResources.body || []);
|
||||
|
||||
Reference in New Issue
Block a user