fix(mesh): recompose previously-meshed containers when alias set changes (#1090)

When a stack is opted into the mesh, every previously-meshed stack's
override.yml on disk gains the new alias, but the running containers
keep their stale /etc/hosts because extra_hosts is read at container
creation time. Cross-stack DNS for the new alias silently fails until
an operator manually redeploys each prior stack.

Complete the cascade: after regenerateOverridesAcrossFleet finishes,
fan out triggerRedeploy across every (node_id, stack_name) tuple in
mesh_stacks, skipping the just-opted-in pair (the explicit
triggerRedeploy at the end of optInStack covers it). Mirror the same
cascade in optOutStack so the dropped alias exits every container's
/etc/hosts.

triggerRedeploy already dispatches local vs remote, already wraps
runRedeploy in fire-and-forget error logging to the mesh activity
ring and audit log, and already enforces compose policy gates. The
cascade just reuses it.

regenerateAllOverrides (boot and manual /regen-overrides) is
intentionally NOT covered: a Sencho restart must not force a
fleet-wide recompose; override files alone are sufficient there.

Adds four unit tests in mesh-service.test.ts: cascade fires for every
prior meshed stack on opt-in, no double-redeploy of the just-opted-in
tuple, cascade is a no-op when no peers exist, cascade fires for
every survivor on opt-out.
This commit is contained in:
Anso
2026-05-17 19:02:02 -04:00
committed by GitHub
parent c2a23378aa
commit 1d7418a233
2 changed files with 186 additions and 0 deletions
+115
View File
@@ -222,6 +222,121 @@ describe('MeshService.optInStack', () => {
// was unlinked separately, so it must not appear in the cascade.
expect(pushed.find((p) => p.stackName === 'to-remove')).toBeUndefined();
});
it('optInStack cascades recompose to every previously meshed stack across the fleet', 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, 'local-existing', 'setup');
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] }]);
vi.spyOn(svc, 'pushOverrideToNode').mockResolvedValue(undefined);
const redeployed: Array<{ nodeId: number; stackName: string }> = [];
vi.spyOn(svc, 'triggerRedeploy').mockImplementation((nodeId, stackName) => {
redeployed.push({ nodeId, stackName });
});
await svc.optInStack(localNodeId, 'new-stack', 'tester');
// The cascade must redeploy every previously meshed stack so its
// container's /etc/hosts picks up the newly-added alias.
expect(redeployed).toContainEqual({ nodeId: localNodeId, stackName: 'local-existing' });
expect(redeployed).toContainEqual({ nodeId: remoteNodeId, stackName: 'remote-existing' });
// The just-opted-in stack is still redeployed separately by the
// explicit triggerRedeploy at the end of optInStack.
expect(redeployed).toContainEqual({ nodeId: localNodeId, stackName: 'new-stack' });
});
it('optInStack does not double-redeploy the just-opted-in tuple via the cascade path', 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] }]);
vi.spyOn(svc, 'pushOverrideToNode').mockResolvedValue(undefined);
const redeployed: Array<{ nodeId: number; stackName: string }> = [];
vi.spyOn(svc, 'triggerRedeploy').mockImplementation((nodeId, stackName) => {
redeployed.push({ nodeId, stackName });
});
await svc.optInStack(localNodeId, 'new-stack', 'tester');
// new-stack appears exactly once: the explicit redeploy at the end
// of optInStack. The cascade walks db.listMeshStacks() (which now
// includes new-stack) but its skip tuple drops it.
const newStackHits = redeployed.filter(
(r) => r.nodeId === localNodeId && r.stackName === 'new-stack',
);
expect(newStackHits).toHaveLength(1);
});
it('optInStack cascade is a no-op when no other meshed stacks exist', async () => {
const svc = MeshService.getInstance();
const db = DatabaseService.getInstance();
const localNodeId = db.getNodes()[0].id;
vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> }, 'inspectStackServices')
.mockResolvedValue([{ service: 'web', ports: [8080] }]);
vi.spyOn(svc, 'pushOverrideToNode').mockResolvedValue(undefined);
const redeployed: Array<{ nodeId: number; stackName: string }> = [];
vi.spyOn(svc, 'triggerRedeploy').mockImplementation((nodeId, stackName) => {
redeployed.push({ nodeId, stackName });
});
await svc.optInStack(localNodeId, 'first-stack', 'tester');
// Only the just-opted-in stack is redeployed; the cascade has no
// peers to recompose so the early return short-circuits before
// the summary activity entry fires.
expect(redeployed).toEqual([{ nodeId: localNodeId, stackName: 'first-stack' }]);
const cascadeEntries = svc.getActivity({ limit: 1000 })
.filter((e) => e.message.startsWith('mesh cascade recompose'));
expect(cascadeEntries).toHaveLength(0);
});
it('optOutStack cascades recompose to every other meshed stack', 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(localNodeId, 'local-survivor', 'setup');
db.insertMeshStack(remoteNodeId, 'remote-survivor', 'setup');
vi.spyOn(svc, 'pushOverrideToNode').mockResolvedValue(undefined);
vi.spyOn(svc as unknown as { removeOverrideFromNode: (n: number, s: string) => Promise<void> }, 'removeOverrideFromNode')
.mockResolvedValue(undefined);
const redeployed: Array<{ nodeId: number; stackName: string }> = [];
vi.spyOn(svc, 'triggerRedeploy').mockImplementation((nodeId, stackName) => {
redeployed.push({ nodeId, stackName });
});
await svc.optOutStack(localNodeId, 'to-remove', 'tester');
// Surviving meshed stacks must recompose so their /etc/hosts drops
// the now-removed alias.
expect(redeployed).toContainEqual({ nodeId: localNodeId, stackName: 'local-survivor' });
expect(redeployed).toContainEqual({ nodeId: remoteNodeId, stackName: 'remote-survivor' });
// The opted-out stack is still redeployed separately by the
// explicit triggerRedeploy at the end of optOutStack so its own
// container's /etc/hosts is cleared.
expect(redeployed).toContainEqual({ nodeId: localNodeId, stackName: 'to-remove' });
});
});
describe('MeshService activity log', () => {