Files
p2ns/test-scripts/multi-master.test.js
T
Raven Scott b1a02e2e84 feat(experimental)!: multi-master one ledger (Option B)
Remove the last layer of soft centralization: a lone `--master` on empty
storage could still implicitly genesis a separate Autopass and split the
network on the same TOPIC_SEED. Genesis is now explicit (`--master
--genesis`); secondary masters pair via invite into the same dnsPass as
joiners, with a shared network manifest, split-brain diagnostics, and
writer-aware quorum.

- Genesis vs secondary master boot paths; auto-adopt manifest on upgrade
- Masters without dnsPass accept invites; masters with pass ignore them
- NETWORK_MANIFEST_FILE, P2NS_GENESIS, MASTER_LOAD_DOMAINS, MASTER_INVITE_ONLY
- core.status networkId; admin diagnostics; docs and multi-master tests

BREAKING: operators must run one genesis per network; additional masters
use `node p2ns.js --master` (not `--genesis`) on empty storage.
2026-05-29 05:34:50 -04:00

286 lines
9.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Multi-master Option B: genesis + secondary masters share one ledger (simulated).
* Run: node test-scripts/multi-master.test.js
* Env: MULTI_MASTER_COUNT=4 (default 4) — total master nodes in scenario
*/
const assert = require('assert');
const path = require('path');
const os = require('os');
const fs = require('fs').promises;
const { createCoreSwarmHandlers } = require('../includes/core/core-swarm-handlers');
const { getActivePeerCount } = require('../includes/core/core');
const networkManifest = require('../includes/infrastructure/network-manifest');
const state = require('../includes/infrastructure/state');
const MASTER_COUNT = Math.max(2, parseInt(process.env.MULTI_MASTER_COUNT || '4', 10));
const SHARED_DISCOVERY_HEX = 'ee'.repeat(32);
const SHARED_WRITER_HEX = 'ff'.repeat(32);
const TOPIC_SEED = 'p2ns-dns';
function resetNetworkState() {
state.masterPendingPass = false;
state.isGenesis = false;
state.networkManifest = null;
state.dnsPass = null;
state.peerNetworkStatus = new Map();
state.networkWriterPeers = new Set();
state.splitBrainWarnings = [];
}
function createSharedLedger() {
const discoveryKey = Buffer.from(SHARED_DISCOVERY_HEX, 'hex');
const writerKey = Buffer.from(SHARED_WRITER_HEX, 'hex');
return {
discoveryKey,
writerKey,
base: { key: writerKey, writable: true, update: async () => {} },
opened: true,
ready: async () => {}
};
}
function makeHandlerCtx(sharedPass, options = {}) {
let localPass = options.initialPass || null;
const localState = {
consecutiveInviteFailures: 0,
masterPendingPass: !!options.pendingPass
};
const handlers = createCoreSwarmHandlers({
Autopass: {
pair: (_store, inviteWire) => {
assert.ok(inviteWire, 'invite wire required');
return { finished: async () => sharedPass };
}
},
store: {},
core: { writable: true, update: async () => {} },
state: localState,
isMaster: true,
getDnsPass: () => localPass,
setDnsPass: (p) => {
localPass = p;
},
connectedPeers: new Set(options.connectedPeers || []),
peerChannels: new Map(),
failedInvitePeers: new Set(),
pendingInviteAcks: new Map(),
pendingInviteRequestHandlers: new Set(),
isProcessingInvite: () => false,
acquireInviteLock: async () => {},
releaseInviteLock: () => {},
withTimeout: (p) => p,
getCurrentInvitePromise: () => null,
setCurrentInvitePromise: () => {},
getCurrentPairOperation: () => null,
setCurrentPairOperation: () => {},
setupDomainsWatcher: () => {},
shouldLoadDomains: () => !!options.loadDomains,
listDomains: async () => [],
doAutoVotes: () => {},
setupListeners: () => {},
coreRpc: { inviteAck: () => true },
channelManager: { getChannelInfo: () => null, waitForBidirectionalOpen: async () => true },
CORE_DOMAIN: 'p2ns.core'
});
return {
handlers,
localState,
getPass: () => localPass
};
}
/**
* Mirrors initializeMasterDnsPass() branch for empty vs existing storage.
*/
function resolveMasterBootBranch({ hasData, isGenesis, existingManifest, cleanStorage }) {
if (isGenesis && existingManifest && !cleanStorage) {
return { action: 'exit', reason: 'genesis_manifest_exists' };
}
if (!hasData && !isGenesis) {
return { action: 'pending_pass' };
}
return { action: 'open_autopass' };
}
async function testBootBranches() {
assert.deepStrictEqual(
resolveMasterBootBranch({ hasData: false, isGenesis: false, existingManifest: null, cleanStorage: false }),
{ action: 'pending_pass' }
);
assert.deepStrictEqual(
resolveMasterBootBranch({ hasData: false, isGenesis: true, existingManifest: null, cleanStorage: false }),
{ action: 'open_autopass' }
);
assert.deepStrictEqual(
resolveMasterBootBranch({ hasData: true, isGenesis: false, existingManifest: { version: 1 }, cleanStorage: false }),
{ action: 'open_autopass' }
);
assert.deepStrictEqual(
resolveMasterBootBranch({
hasData: true,
isGenesis: true,
existingManifest: { version: 1 },
cleanStorage: false
}),
{ action: 'exit', reason: 'genesis_manifest_exists' }
);
}
async function testGenesisManifestAndFourMasters() {
resetNetworkState();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'p2ns-multi-master-'));
const manifestPath = path.join(dir, 'network.json');
const sharedPass = createSharedLedger();
const identity = networkManifest.extractNetworkIdentity(sharedPass, 'aa'.repeat(64));
state.networkManifest = await networkManifest.writeManifest(manifestPath, {
...identity,
topicSeed: TOPIC_SEED,
isGenesis: true
});
state.isGenesis = true;
state.dnsPass = sharedPass;
const genesis = makeHandlerCtx(sharedPass, { initialPass: sharedPass, loadDomains: true });
const secondaries = [];
for (let i = 0; i < MASTER_COUNT - 1; i++) {
secondaries.push(
makeHandlerCtx(sharedPass, {
pendingPass: true,
connectedPeers: ['genesis-peer']
})
);
}
const genesisStatus = await genesis.handlers.onCoreStatus();
assert.strictEqual(genesisStatus.dnsPassInitialized, true);
assert.strictEqual(genesisStatus.networkId, SHARED_DISCOVERY_HEX);
assert.strictEqual(genesisStatus.canProvideInvite, true);
assert.strictEqual(genesisStatus.masterPendingPass, false);
for (let i = 0; i < secondaries.length; i++) {
const sec = secondaries[i];
const peerId = `master-secondary-${i + 2}`;
const pendingStatus = await sec.handlers.onCoreStatus();
assert.strictEqual(pendingStatus.dnsPassInitialized, false, `${peerId} should start without pass`);
assert.strictEqual(sec.localState.masterPendingPass, true);
await sec.handlers.processInviteWire(peerId, `invite-wire-${i}`);
assert.strictEqual(sec.getPass(), sharedPass, `${peerId} must share genesis ledger`);
assert.strictEqual(sec.localState.masterPendingPass, false, `${peerId} clears pending after pair`);
const pairedStatus = await sec.handlers.onCoreStatus();
assert.strictEqual(pairedStatus.dnsPassInitialized, true);
assert.strictEqual(pairedStatus.canProvideInvite, true, `${peerId} is master and can invite`);
networkManifest.recordPeerNetworkStatus(peerId, {
networkId: SHARED_DISCOVERY_HEX,
dnsPassInitialized: true,
nodeType: 'master'
});
}
assert.strictEqual(
state.networkWriterPeers.size,
MASTER_COUNT - 1,
`expected ${MASTER_COUNT - 1} remote writer masters`
);
assert.strictEqual(
getActivePeerCount(),
MASTER_COUNT,
`quorum peer count should be ${MASTER_COUNT} (remote writers + local)`
);
assert.strictEqual(state.splitBrainWarnings.length, 0);
await fs.rm(dir, { recursive: true, force: true });
}
async function testSplitBrainWithFourMasters() {
resetNetworkState();
const sharedPass = createSharedLedger();
state.networkManifest = { networkId: SHARED_DISCOVERY_HEX, topicSeed: TOPIC_SEED };
state.dnsPass = sharedPass;
for (let i = 0; i < MASTER_COUNT - 1; i++) {
const peerId = `writer-${i}`;
const networkId = i === 1 ? 'bad'.repeat(16) : SHARED_DISCOVERY_HEX;
networkManifest.recordPeerNetworkStatus(peerId, {
networkId,
dnsPassInitialized: true,
nodeType: 'master'
});
}
assert.ok(state.splitBrainWarnings.length >= 1, 'mismatched networkId should warn');
assert.ok(
state.splitBrainWarnings.some((w) => w.includes('Split network detected')),
'split-brain message expected'
);
}
async function testMasterWithPassIgnoresExtraInvite() {
resetNetworkState();
const sharedPass = createSharedLedger();
let pairCalls = 0;
let localPass = sharedPass;
const handlers = createCoreSwarmHandlers({
Autopass: {
pair: () => {
pairCalls++;
return { finished: async () => sharedPass };
}
},
store: {},
core: { writable: true, update: async () => {} },
state: { consecutiveInviteFailures: 0 },
isMaster: true,
getDnsPass: () => localPass,
setDnsPass: (p) => { localPass = p; },
connectedPeers: new Set(),
peerChannels: new Map(),
failedInvitePeers: new Set(),
pendingInviteAcks: new Map(),
pendingInviteRequestHandlers: new Set(),
isProcessingInvite: () => false,
acquireInviteLock: async () => {},
releaseInviteLock: () => {},
withTimeout: (p) => p,
getCurrentInvitePromise: () => null,
setCurrentInvitePromise: () => {},
getCurrentPairOperation: () => null,
setCurrentPairOperation: () => {},
setupDomainsWatcher: () => {},
listDomains: async () => [],
doAutoVotes: () => {},
setupListeners: () => {},
coreRpc: { inviteAck: () => true },
channelManager: { getChannelInfo: () => null, waitForBidirectionalOpen: async () => true },
CORE_DOMAIN: 'p2ns.core'
});
await handlers.processInviteWire('extra-peer', 'should-not-pair');
assert.strictEqual(pairCalls, 0);
}
async function run() {
await testBootBranches();
await testGenesisManifestAndFourMasters();
await testSplitBrainWithFourMasters();
await testMasterWithPassIgnoresExtraInvite();
console.log(`multi-master.test.js: all passed (${MASTER_COUNT} masters scenario)`);
}
run()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});