mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
fix(mesh): enumerate stacks on remote pilot nodes for opt-in sheet (#1025)
The mesh opt-in sheet showed "No stacks deployed on this node yet" for every remote pilot because GET /api/mesh/nodes/:nodeId/stacks called FileSystemService.getInstance(nodeId).getStacks() unconditionally, which always reads central's own filesystem regardless of which node the operator targeted. Fix dispatches through the proxy chain when the targeted node is remote, mirroring the C-3 pattern that already governs every other mesh endpoint needing data from a remote Sencho: - New endpoint GET /api/mesh/local-stacks (Admiral-gated) returns the bare stacks list from THIS Sencho's own filesystem. Mirrors the precedent set by /api/mesh/local-services/:stackName. - New MeshService.listLocalStacks() and MeshService.listStacksOnNode() helpers; the latter dispatches local vs remote and degrades to an empty list on transport failure (no proxy target, non-2xx response, malformed body). - Refactored route delegates the stack-name list to listStacksOnNode; central's mesh_stacks DB is still authoritative for the opt-in flag set per C-3. Tests cover the local path, the remote-OK path with header forwarding, non-2xx, no-target (pilot tunnel down), malformed bodies, and unknown node ids.
This commit is contained in:
@@ -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<string, string> };
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> => {
|
||||
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),
|
||||
})),
|
||||
|
||||
@@ -1031,6 +1031,44 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
||||
}
|
||||
}
|
||||
|
||||
public async listLocalStacks(): Promise<string[]> {
|
||||
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<string[]> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user