fix(mesh): cascade override regen across all meshed nodes on opt-in/opt-out (#1079)

Opting a stack into mesh on node A now propagates the new alias to every
meshed node's override file, not just stacks on node A. Same on opt-out.

Pre-fix, optInStack and optOutStack called regenerateOverridesForNode
which only walks db.listMeshStacks(nodeId). Other meshed stacks on other
nodes did not learn about the new alias until something else re-pushed
their overrides; operators worked around it with a manual
POST /api/mesh/regen-overrides plus a redeploy of each affected stack.

New regenerateOverridesAcrossFleet helper iterates db.listMeshStacks()
(no arg, fleet-wide) under Promise.allSettled, skipping the
(nodeId, stackName) tuple opt-in just pushed loudly. Per-stack failures
surface as forwarder.error activity events, matching the pattern used by
regenerateAllOverrides. Offline remote nodes leave stale overrides until
the next opt-in/opt-out, the next tunnel reconnect, or a manual rerun of
the regen-overrides endpoint.

regenerateOverridesForNode stays for the tunnel-up retry listener: a
reconnecting pilot only needs its own stacks re-pushed, not the fleet.

Tests: redirect five existing spies in mesh-service.test.ts to the new
method; add two new cases asserting cross-node cascade for opt-in and
opt-out. 52 tests in mesh-service.test.ts pass.
This commit is contained in:
Anso
2026-05-17 03:48:58 -04:00
committed by GitHub
parent de725cc4c1
commit a32183d198
2 changed files with 107 additions and 11 deletions
+62 -5
View File
@@ -57,7 +57,7 @@ describe('MeshService.optInStack', () => {
vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> }, 'inspectStackServices')
.mockResolvedValue([{ service: 'db', ports: [5432] }]);
vi.spyOn(svc as unknown as { regenerateOverridesForNode: (n: number) => Promise<void> }, 'regenerateOverridesForNode')
vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: (n?: number, s?: string) => Promise<void> }, 'regenerateOverridesAcrossFleet')
.mockResolvedValue(undefined);
const db = DatabaseService.getInstance();
@@ -80,7 +80,7 @@ describe('MeshService.optInStack', () => {
{ service: 'web', ports: [] },
{ service: 'db', ports: [] },
]);
vi.spyOn(svc as unknown as { regenerateOverridesForNode: (n: number) => Promise<void> }, 'regenerateOverridesForNode')
vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: (n?: number, s?: string) => Promise<void> }, 'regenerateOverridesAcrossFleet')
.mockResolvedValue(undefined);
await expect(svc.optInStack(localNodeId, 'silent', 'tester'))
@@ -94,7 +94,7 @@ describe('MeshService.optInStack', () => {
const svc = MeshService.getInstance();
vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> }, 'inspectStackServices')
.mockResolvedValue([{ service: 'db', ports: [5432] }]);
vi.spyOn(svc as unknown as { regenerateOverridesForNode: (n: number) => Promise<void> }, 'regenerateOverridesForNode')
vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: (n?: number, s?: string) => Promise<void> }, 'regenerateOverridesAcrossFleet')
.mockResolvedValue(undefined);
vi.spyOn(svc as unknown as { removeStackOverride: (n: number, s: string) => Promise<void> }, 'removeStackOverride')
.mockResolvedValue(undefined);
@@ -165,6 +165,63 @@ describe('MeshService.optInStack', () => {
db.deleteNode(remoteNodeId);
}
});
it('optInStack cascades override regen to every meshed node, not just the source node', async () => {
const svc = MeshService.getInstance();
const db = DatabaseService.getInstance();
const localNodeId = db.getNodes()[0].id;
const remoteNodeId = db.addNode({
name: 'remote-pilot', type: 'remote', mode: 'pilot_agent',
compose_dir: '/tmp', is_default: false, api_url: '', api_token: '',
});
db.insertMeshStack(remoteNodeId, 'remote-existing', 'setup');
vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> }, 'inspectStackServices')
.mockResolvedValue([{ service: 'web', ports: [8080] }]);
const pushed: Array<{ nodeId: number; stackName: string }> = [];
vi.spyOn(svc, 'pushOverrideToNode').mockImplementation(async (nodeId, stackName) => {
pushed.push({ nodeId, stackName });
});
vi.spyOn(svc as unknown as { triggerRedeploy: (n: number, s: string, a: string) => void }, 'triggerRedeploy')
.mockImplementation(() => { /* noop */ });
await svc.optInStack(localNodeId, 'new-stack', 'tester');
expect(pushed).toContainEqual({ nodeId: localNodeId, stackName: 'new-stack' });
// Other meshed nodes' existing stacks must regenerate so they pick
// up the new alias entry.
expect(pushed).toContainEqual({ nodeId: remoteNodeId, stackName: 'remote-existing' });
});
it('optOutStack cascades override regen to every other meshed node', async () => {
const svc = MeshService.getInstance();
const db = DatabaseService.getInstance();
const localNodeId = db.getNodes()[0].id;
const remoteNodeId = db.addNode({
name: 'remote-pilot', type: 'remote', mode: 'pilot_agent',
compose_dir: '/tmp', is_default: false, api_url: '', api_token: '',
});
db.insertMeshStack(localNodeId, 'to-remove', 'setup');
db.insertMeshStack(remoteNodeId, 'remote-existing', 'setup');
const pushed: Array<{ nodeId: number; stackName: string }> = [];
vi.spyOn(svc, 'pushOverrideToNode').mockImplementation(async (nodeId, stackName) => {
pushed.push({ nodeId, stackName });
});
vi.spyOn(svc as unknown as { removeOverrideFromNode: (n: number, s: string) => Promise<void> }, 'removeOverrideFromNode')
.mockResolvedValue(undefined);
vi.spyOn(svc as unknown as { triggerRedeploy: (n: number, s: string, a: string) => void }, 'triggerRedeploy')
.mockImplementation(() => { /* noop */ });
await svc.optOutStack(localNodeId, 'to-remove', 'tester');
// Other meshed nodes' existing stacks must regenerate so they drop
// the removed alias entry.
expect(pushed).toContainEqual({ nodeId: remoteNodeId, stackName: 'remote-existing' });
// The removed row was deleted before the cascade ran, and its file
// was unlinked separately, so it must not appear in the cascade.
expect(pushed.find((p) => p.stackName === 'to-remove')).toBeUndefined();
});
});
describe('MeshService activity log', () => {
@@ -439,7 +496,7 @@ describe('MeshService.optInStack guard rails (network setup)', () => {
const svc = MeshService.getInstance();
vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> }, 'inspectStackServices')
.mockResolvedValue([{ service: 'web', ports: [1852] }]);
vi.spyOn(svc as unknown as { regenerateOverridesForNode: (n: number) => Promise<void> }, 'regenerateOverridesForNode')
vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: (n?: number, s?: string) => Promise<void> }, 'regenerateOverridesAcrossFleet')
.mockResolvedValue(undefined);
const db = DatabaseService.getInstance();
@@ -589,7 +646,7 @@ describe('MeshService.regenerateAllOverrides (F6: boot-time regen)', () => {
const pushSpy = vi.spyOn(svc, 'pushOverrideToNode').mockResolvedValue(undefined);
vi.spyOn(svc as unknown as { triggerRedeploy: (n: number, s: string, a: string) => void }, 'triggerRedeploy')
.mockImplementation(() => { /* noop */ });
vi.spyOn(svc as unknown as { regenerateOverridesForNode: (n: number, skip?: string) => Promise<void> }, 'regenerateOverridesForNode')
vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: (n?: number, skip?: string) => Promise<void> }, 'regenerateOverridesAcrossFleet')
.mockResolvedValue(undefined);
const [summary] = await Promise.all([