feat(site): multi-site join server endpoints + persisted site role + emoji fix
Server endpoints for joining a spoke to a master directory (MULTI_SITE_SPEC.md).
This pass is server-only; setup.sh wiring and the UI are the next layer.
- Site join keys (SiteJoinKey model, stj_ prefix): mint/revoke/delete/list,
hashed at rest, shown once — the same model as agent join keys.
- POST /api/site/export (master, Bearer stj_ key, no admin session): returns the
local LDAP tree (slapcat LDIF) + resource catalog + siteSlug + baseDn.
- POST /api/site/join (spoke, admin): { masterUrl, joinKey } pulls the master
export, imports resources (upsert by slug) + LDAP (ldapadd -c), and persists
the spoke role. Refused if already a spoke.
- Persisted site role: utils/site_config.js keeps isMaster/masterUrl/siteSlug in
/config/site.json (env seeds defaults); site-status/site-promote now use it.
- Unit tests (site_join, site_config) with in-memory stubs, wired into npm test.
- docs/site-join.md + docs router entry.
- Repairs the corrupted multi-site emojis (crown/bolt) in directory.ejs.
- .gitguardian.yml ignores the generic-password false positive on reading the
LDAP bind credential from runtime config (never a hardcoded secret).
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
|
||||
// Persisted multi-site role (MULTI_SITE_SPEC.md). Whether this node is the
|
||||
// master authority, which site it belongs to, and the master it replicates
|
||||
// from live in /config/site.json so they survive restarts (the old code kept
|
||||
// them in Node memory, so a container recreate silently reverted a spoke back
|
||||
// to "master").
|
||||
//
|
||||
// Boot-time defaults come from the environment (IS_MASTER / MASTER_URL /
|
||||
// SITE_SLUG, which docker-compose passes); a written site.json overrides for
|
||||
// the life of the deployment. site-promote and the site-join flow both write
|
||||
// here.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Overridable so tests can point at a temp file instead of /config/site.json.
|
||||
function configFile() {
|
||||
return process.env.SITE_CONFIG_FILE || '/config/site.json';
|
||||
}
|
||||
|
||||
function envDefaults() {
|
||||
return {
|
||||
isMaster: process.env.IS_MASTER ? process.env.IS_MASTER === 'true' : true,
|
||||
masterUrl: process.env.MASTER_URL || '',
|
||||
siteSlug: process.env.SITE_SLUG || 'site-default',
|
||||
wanConnected: true
|
||||
};
|
||||
}
|
||||
|
||||
let current = null;
|
||||
|
||||
function load() {
|
||||
const env = envDefaults();
|
||||
const file = configFile();
|
||||
try {
|
||||
if (fs.existsSync(file)) {
|
||||
const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return { ...env, ...saved };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[site] could not read ' + file + ': ' + e.message);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// get returns the current site config.
|
||||
function get() {
|
||||
if (!current) current = load();
|
||||
return { ...current };
|
||||
}
|
||||
|
||||
// save merges a patch and persists it to the site config file.
|
||||
function save(patch) {
|
||||
current = { ...get(), ...patch };
|
||||
const file = configFile();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify(current, null, 2) + '\n');
|
||||
} catch (e) {
|
||||
console.error('[site] could not write ' + file + ': ' + e.message);
|
||||
throw e;
|
||||
}
|
||||
return get();
|
||||
}
|
||||
|
||||
module.exports = { get, save, configFile };
|
||||
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
|
||||
// Pure, testable helpers for the multi-site join flow (MULTI_SITE_SPEC.md).
|
||||
// routes/api_site.js wires these to Express + the live models + slapcat/ldapadd;
|
||||
// tests exercise importDirectory with in-memory model stubs.
|
||||
|
||||
// scalarResource reduces a Resource row to its scalar columns so it can be
|
||||
// re-created on a spoke without dragging hasMany relation fields along.
|
||||
function scalarResource(r) {
|
||||
const o = (r && r.toJSON) ? r.toJSON() : (r || {});
|
||||
return {
|
||||
id: o.id,
|
||||
kind: o.kind,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
owner: o.owner || null,
|
||||
description: o.description || null,
|
||||
metadata: o.metadata || {},
|
||||
created_by: o.created_by || null,
|
||||
created_on: o.created_on || null,
|
||||
updated_by: o.updated_by || null,
|
||||
updated_on: o.updated_on || null
|
||||
};
|
||||
}
|
||||
|
||||
function scalarEdge(e) {
|
||||
const o = (e && e.toJSON) ? e.toJSON() : (e || {});
|
||||
return {
|
||||
id: o.id,
|
||||
parentId: o.parentId,
|
||||
childId: o.childId,
|
||||
relation: o.relation
|
||||
};
|
||||
}
|
||||
|
||||
// importDirectory adopts a master's resource catalog into the local SQLite
|
||||
// store. Resources are upserted by slug (create if absent, update if a local
|
||||
// row already exists — the master is authoritative for the shared catalog),
|
||||
// then all edges are recreated. Model stubs are injected for testability.
|
||||
async function importDirectory({ Resource, ResourceEdge, exportData }) {
|
||||
const resources = (exportData && exportData.resources) || [];
|
||||
const edges = (exportData && exportData.edges) || [];
|
||||
|
||||
const bySlug = {};
|
||||
try {
|
||||
const existing = await Resource.list();
|
||||
(existing || []).forEach(r => { bySlug[r.slug] = r; });
|
||||
} catch (e) {
|
||||
// Resource.list is unavailable (fresh DB?) — treat as empty.
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const raw of resources) {
|
||||
const s = scalarResource(raw);
|
||||
if (!s.slug) continue;
|
||||
const local = bySlug[s.slug];
|
||||
if (local) {
|
||||
try { await Resource.update(local.id, s); updated++; } catch (e) { /* row raced; ignore */ }
|
||||
} else {
|
||||
try { await Resource.create(s); created++; } catch (e) { /* duplicate-slug race; ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Edges: clear + recreate so the graph matches the master exactly.
|
||||
try {
|
||||
const existingEdges = await ResourceEdge.list();
|
||||
for (const e of existingEdges || []) {
|
||||
await ResourceEdge.delete(e.id).catch(() => {});
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
let edgeCount = 0;
|
||||
for (const raw of edges) {
|
||||
const s = scalarEdge(raw);
|
||||
if (!s.parentId || !s.childId) continue;
|
||||
try { await ResourceEdge.create({ id: s.id, parentId: s.parentId, childId: s.childId, relation: s.relation || 'runs_on' }); edgeCount++; } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return { created, updated, edgeCount };
|
||||
}
|
||||
|
||||
// ldapAddArgs builds the argv for importing an LDIF into the local slapd with
|
||||
// the app's admin bind. `-c` continues past "entry already exists" (the spoke
|
||||
// keeps its own cn=admin / base DN).
|
||||
function ldapAddArgs({ bindDN, ldapCred, ldifFile, ldapUrl }) {
|
||||
return [
|
||||
'-c', '-x',
|
||||
'-H', ldapUrl || 'ldap://localhost',
|
||||
'-D', bindDN,
|
||||
'-w', ldapCred,
|
||||
'-f', ldifFile
|
||||
];
|
||||
}
|
||||
|
||||
// baseDnFrom derives the LDAP base DN from the app's admin bindDN
|
||||
// (cn=admin,dc=example,dc=com -> dc=example,dc=com) unless the stack config
|
||||
// already provides it (conf.stack.ldapBaseDn, written by setup.sh).
|
||||
function baseDnFrom(conf) {
|
||||
if (conf.stack && conf.stack.ldapBaseDn) return conf.stack.ldapBaseDn;
|
||||
const m = String((conf.ldap && conf.ldap.bindDN) || '').match(/^cn=[^,]+,(.+)$/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
module.exports = { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom };
|
||||
Reference in New Issue
Block a user