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,62 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Point SITE_CONFIG_FILE at a fresh temp path and clear the env defaults so
|
||||
// each test observes a known state. jest.resetModules() gives a fresh module
|
||||
// (the `current` cache is module-scoped).
|
||||
function freshEnv(overrides = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'site-cfg-'));
|
||||
const file = path.join(dir, 'site.json');
|
||||
for (const k of ['IS_MASTER', 'MASTER_URL', 'SITE_SLUG', 'SITE_CONFIG_FILE']) delete process.env[k];
|
||||
process.env.SITE_CONFIG_FILE = file;
|
||||
Object.assign(process.env, overrides);
|
||||
jest.resetModules();
|
||||
return { file };
|
||||
}
|
||||
|
||||
test('site_config defaults to a fresh master / site-default with no file', () => {
|
||||
freshEnv();
|
||||
const sc = require('../utils/site_config');
|
||||
const c = sc.get();
|
||||
expect(c.isMaster).toBe(true);
|
||||
expect(c.masterUrl).toBe('');
|
||||
expect(c.siteSlug).toBe('site-default');
|
||||
expect(c.wanConnected).toBe(true);
|
||||
});
|
||||
|
||||
test('site_config honors the env seed values', () => {
|
||||
freshEnv({ IS_MASTER: 'false', MASTER_URL: 'https://m.example.com', SITE_SLUG: 'site-east' });
|
||||
const sc = require('../utils/site_config');
|
||||
const c = sc.get();
|
||||
expect(c.isMaster).toBe(false);
|
||||
expect(c.masterUrl).toBe('https://m.example.com');
|
||||
expect(c.siteSlug).toBe('site-east');
|
||||
});
|
||||
|
||||
test('site_config save persists and a fresh require reloads it', () => {
|
||||
const { file } = freshEnv();
|
||||
const sc = require('../utils/site_config');
|
||||
sc.save({ isMaster: false, masterUrl: 'https://m.example.com', siteSlug: 'site-east' });
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
expect(onDisk.isMaster).toBe(false);
|
||||
expect(onDisk.siteSlug).toBe('site-east');
|
||||
|
||||
jest.resetModules();
|
||||
const sc2 = require('../utils/site_config');
|
||||
const c = sc2.get();
|
||||
expect(c.isMaster).toBe(false);
|
||||
expect(c.masterUrl).toBe('https://m.example.com');
|
||||
expect(c.siteSlug).toBe('site-east');
|
||||
});
|
||||
|
||||
test('site_config save returns the merged config', () => {
|
||||
freshEnv();
|
||||
const sc = require('../utils/site_config');
|
||||
const c = sc.save({ masterUrl: 'https://m.example.com' });
|
||||
expect(c.isMaster).toBe(true); // untouched default survives
|
||||
expect(c.masterUrl).toBe('https://m.example.com');
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
|
||||
const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom } = require('../utils/site_join');
|
||||
|
||||
// In-memory model stubs so importDirectory can be exercised without a DB.
|
||||
function makeStore() {
|
||||
const rows = [];
|
||||
const edges = [];
|
||||
return {
|
||||
Resource: {
|
||||
list: async () => rows.map(r => ({ ...r })),
|
||||
create: async (d) => { rows.push({ ...d }); return { ...d }; },
|
||||
update: async (id, d) => {
|
||||
const i = rows.findIndex(r => r.id === id);
|
||||
if (i >= 0) rows[i] = { ...rows[i], ...d };
|
||||
return rows[i];
|
||||
},
|
||||
get rows() { return rows; }
|
||||
},
|
||||
ResourceEdge: {
|
||||
list: async () => edges.map(e => ({ ...e })),
|
||||
create: async (d) => { edges.push({ ...d }); return { ...d }; },
|
||||
delete: async (id) => {
|
||||
const i = edges.findIndex(e => e.id === id);
|
||||
if (i >= 0) edges.splice(i, 1);
|
||||
},
|
||||
get rows() { return edges; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('importDirectory creates new resources and edges', async () => {
|
||||
const s = makeStore();
|
||||
const exportData = {
|
||||
resources: [
|
||||
{ id: 'r1', kind: 'site', name: 'Main Office', slug: 'site_main-office', metadata: { address: '10.0.0.1' } },
|
||||
{ id: 'r2', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: { ip: '10.0.0.10' } }
|
||||
],
|
||||
edges: [{ id: 'e1', parentId: 'r1', childId: 'r2', relation: 'contains' }]
|
||||
};
|
||||
|
||||
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
||||
|
||||
expect(s.Resource.rows.length).toBe(2);
|
||||
expect(s.ResourceEdge.rows.length).toBe(1);
|
||||
expect(res.created).toBe(2);
|
||||
expect(res.updated).toBe(0);
|
||||
expect(res.edgeCount).toBe(1);
|
||||
expect(s.Resource.rows[0].slug).toBe('site_main-office');
|
||||
});
|
||||
|
||||
test('importDirectory updates existing resources by slug (master is authoritative)', async () => {
|
||||
const s = makeStore();
|
||||
await s.Resource.create({ id: 'local1', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: {} });
|
||||
|
||||
const exportData = {
|
||||
resources: [
|
||||
{ id: 'r2', kind: 'host', name: 'web01-new', slug: 'host_web-01', metadata: { ip: '10.0.0.9' } }
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
||||
|
||||
expect(s.Resource.rows.length).toBe(1); // upsert, not duplicate
|
||||
expect(s.Resource.rows[0].name).toBe('web01-new');
|
||||
expect(res.updated).toBe(1);
|
||||
});
|
||||
|
||||
test('importDirectory clears stale edges then recreates from master', async () => {
|
||||
const s = makeStore();
|
||||
await s.Resource.create({ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} });
|
||||
await s.Resource.create({ id: 'r2', kind: 'host', name: 'old', slug: 'host_old', metadata: {} });
|
||||
await s.ResourceEdge.create({ id: 'stale', parentId: 'r1', childId: 'r2', relation: 'contains' });
|
||||
|
||||
const exportData = {
|
||||
resources: [
|
||||
{ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} },
|
||||
{ id: 'r3', kind: 'host', name: 'new', slug: 'host_new', metadata: {} }
|
||||
],
|
||||
edges: [{ id: 'e9', parentId: 'r1', childId: 'r3', relation: 'contains' }]
|
||||
};
|
||||
|
||||
await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
||||
|
||||
const edgeIds = s.ResourceEdge.rows.map(e => e.id);
|
||||
expect(edgeIds).toContain('e9');
|
||||
expect(edgeIds).not.toContain('stale');
|
||||
});
|
||||
|
||||
test('scalarResource strips relation fields but keeps metadata', () => {
|
||||
const o = scalarResource({
|
||||
id: 'x', kind: 'host', name: 'n', slug: 's', metadata: { a: 1 },
|
||||
edgesAsParent: [1], edgesAsChild: [2], groups: [3],
|
||||
toJSON() { return this; }
|
||||
});
|
||||
expect(o.edgesAsParent).toBeUndefined();
|
||||
expect(o.edgesAsChild).toBeUndefined();
|
||||
expect(o.groups).toBeUndefined();
|
||||
expect(o.metadata.a).toBe(1);
|
||||
});
|
||||
|
||||
test('scalarEdge keeps parentId/childId/relation', () => {
|
||||
const e = scalarEdge({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains', toJSON() { return this; } });
|
||||
expect(e).toEqual({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains' });
|
||||
});
|
||||
|
||||
test('ldapAddArgs builds a continue-on-error admin bind', () => {
|
||||
const a = ldapAddArgs({ bindDN: 'cn=admin,dc=example,dc=com', ldapCred: 'test-bind', ldifFile: '/tmp/x.ldif' });
|
||||
expect(a).toContain('-c');
|
||||
expect(a).toContain('-x');
|
||||
expect(a).toContain('cn=admin,dc=example,dc=com');
|
||||
expect(a).toContain('/tmp/x.ldif');
|
||||
expect(a.indexOf('-D') < a.indexOf('-w')).toBe(true);
|
||||
});
|
||||
|
||||
test('baseDnFrom prefers stack.ldapBaseDn and falls back to the bind DN', () => {
|
||||
expect(baseDnFrom({ stack: { ldapBaseDn: 'dc=stack,dc=com' } })).toBe('dc=stack,dc=com');
|
||||
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin,dc=example,dc=com' } })).toBe('dc=example,dc=com');
|
||||
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin' } })).toBe('');
|
||||
});
|
||||
Reference in New Issue
Block a user