diff --git a/backend/src/__tests__/mesh-list-stacks-remote.test.ts b/backend/src/__tests__/mesh-list-stacks-remote.test.ts new file mode 100644 index 00000000..1f75596a --- /dev/null +++ b/backend/src/__tests__/mesh-list-stacks-remote.test.ts @@ -0,0 +1,182 @@ +/** + * Regression guard for F8: MeshService.listStacksOnNode dispatches local-vs-remote + * the same way as inspectStackServices. + * + * - Local node → reads the LOCAL filesystem via FileSystemService.getStacks(). + * - Remote node → fetches `/api/mesh/local-stacks` against the resolved proxy + * target with the appropriate Authorization and license tier headers, + * parses the JSON envelope, and returns the decoded `stacks[]` array. + * + * Pre-fix the route in `routes/mesh.ts` called FileSystemService.getInstance(nodeId) + * unconditionally, which always reads central's own filesystem regardless of + * whether the targeted node was local or remote. Result: the mesh opt-in sheet + * showed "No stacks deployed on this node yet" for every remote pilot. + */ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let MeshService: typeof import('../services/MeshService').MeshService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry; +let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ MeshService } = await import('../services/MeshService')); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ NodeRegistry } = await import('../services/NodeRegistry')); + ({ FileSystemService } = await import('../services/FileSystemService')); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('MeshService.listStacksOnNode dispatch (F8)', () => { + it('uses the local filesystem path for the local node', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + + const fsSpy = vi + .spyOn(FileSystemService.prototype, 'getStacks') + .mockResolvedValue(['audit-mesh-prod', 'whoami']); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const out = await svc.listStacksOnNode(localNodeId); + + expect(out).toEqual(['audit-mesh-prod', 'whoami']); + expect(fsSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('fetches /api/mesh/local-stacks for remote nodes and forwards the proxy target headers', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'list-stacks-remote-test', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: 'https://remote.example.com:1852', + api_token: 'remote-tok', + }); + + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ + apiUrl: 'https://remote.example.com:1852', + apiToken: 'remote-tok', + }); + + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response( + JSON.stringify({ stacks: ['audit-mesh-pilot', 'monitor'] }), + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + + const out = await svc.listStacksOnNode(remoteNodeId); + + expect(out).toEqual(['audit-mesh-pilot', 'monitor']); + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0]; + expect(String(call[0])).toBe('https://remote.example.com:1852/api/mesh/local-stacks'); + const init = call[1] as { method: string; headers: Record }; + expect(init.method).toBe('GET'); + expect(init.headers['Authorization']).toBe('Bearer remote-tok'); + expect(init.headers).toHaveProperty('x-sencho-tier'); + expect(init.headers).toHaveProperty('x-sencho-variant'); + + db.deleteNode(remoteNodeId); + }); + + it('returns [] when the remote responds non-2xx', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'list-stacks-remote-fail', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: 'https://remote.example.com:1852', + api_token: 'remote-tok', + }); + + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ + apiUrl: 'https://remote.example.com:1852', + apiToken: 'remote-tok', + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Internal Server Error', { status: 500 })); + + const out = await svc.listStacksOnNode(remoteNodeId); + + expect(out).toEqual([]); + db.deleteNode(remoteNodeId); + }); + + it('returns [] for a remote node with no active proxy target (e.g. pilot-agent tunnel down)', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'list-stacks-remote-down', + type: 'remote', + mode: 'pilot_agent', + compose_dir: '/tmp', + is_default: false, + api_url: '', + api_token: '', + }); + + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const out = await svc.listStacksOnNode(remoteNodeId); + + expect(out).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + db.deleteNode(remoteNodeId); + }); + + it('defends against malformed remote bodies', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'list-stacks-remote-malformed', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: 'https://remote.example.com:1852', + api_token: 'remote-tok', + }); + + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ + apiUrl: 'https://remote.example.com:1852', + apiToken: 'remote-tok', + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( + JSON.stringify({ stacks: ['ok-string', 42, null, { not: 'a string' }] }), + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + + const out = await svc.listStacksOnNode(remoteNodeId); + + expect(out).toEqual(['ok-string']); + db.deleteNode(remoteNodeId); + }); + + it('returns [] for an unknown node id', async () => { + const svc = MeshService.getInstance(); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const out = await svc.listStacksOnNode(999_999); + expect(out).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/routes/mesh.ts b/backend/src/routes/mesh.ts index e981a419..896bb0cc 100644 --- a/backend/src/routes/mesh.ts +++ b/backend/src/routes/mesh.ts @@ -111,6 +111,24 @@ meshRouter.get('/local-services/:stackName', async (req: Request, res: Response) } }); +/** + * Returns the LOCAL Sencho's compose stacks. Always queries this + * instance's own filesystem regardless of `x-node-id`. Central calls this + * endpoint against each remote node via the existing proxy chain + * (`NodeRegistry.getProxyTarget`) so the mesh opt-in sheet can show the + * stacks deployed on the remote pilot rather than central's own list. + */ +meshRouter.get('/local-stacks', async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + try { + const stacks = await MeshService.getInstance().listLocalStacks(); + res.json({ stacks }); + } catch (err) { + console.warn('[mesh] /local-stacks failed:', sanitizeForLog((err as Error).message)); + res.status(500).json({ error: 'Failed to list local stacks' }); + } +}); + const MAX_ALIASES_PER_PUSH = 1024; function parsePortAlias(entry: unknown): MeshGlobalAlias | null { @@ -207,10 +225,9 @@ meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Pro try { const db = DatabaseService.getInstance(); const optedIn = new Set(db.listMeshStacks(nodeId).map((s) => s.stack_name)); - const fsSvc = (await import('../services/FileSystemService')).FileSystemService.getInstance(nodeId); - const stacks = await fsSvc.getStacks(); + const stacks = await MeshService.getInstance().listStacksOnNode(nodeId); res.json({ - stacks: stacks.map((stackName: string) => ({ + stacks: stacks.map((stackName) => ({ name: stackName, optedIn: optedIn.has(stackName), })), diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index 213f4b84..4f9734e1 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -1031,6 +1031,44 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } } + public async listLocalStacks(): Promise { + const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + return FileSystemService.getInstance(localNodeId).getStacks(); + } + + /** + * Local nodes read the filesystem; remote nodes fetch their own + * Sencho's `/api/mesh/local-stacks` because the remote's compose + * directory is not visible from central (pilot's filesystem lives + * on a different host). + */ + public async listStacksOnNode(nodeId: number): Promise { + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node) return []; + if (node.type !== 'remote') return this.listLocalStacks(); + + try { + const res = await this.proxyFetch(nodeId, 'GET', '/api/mesh/local-stacks', undefined, 5_000); + if (!res.ok) { + console.error(`[MeshService] listStacksOnNode: HTTP ${res.status} from node ${nodeId} (${sanitizeForLog(node.name)})`); + return []; + } + const body = await res.json() as { stacks?: unknown }; + if (!Array.isArray(body.stacks)) return []; + return body.stacks.filter((s): s is string => typeof s === 'string'); + } catch (err) { + // proxyFetch throws MeshError('push_failed') when getProxyTarget + // returns null (e.g. pilot tunnel offline). Treat the same as a + // non-OK response: empty list. + if (err instanceof MeshError && err.code === 'push_failed') { + console.warn(`[MeshService] listStacksOnNode: no proxy target for node ${nodeId} (${sanitizeForLog(node.name)})`); + return []; + } + console.error('[MeshService] listStacksOnNode remote unreachable:', sanitizeForLog((err as Error).message)); + return []; + } + } + /** * Build a `fetch` against a remote Sencho's API with the bearer token * and the proxy tier/variant headers in place. Centralizes the header