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:
Anso
2026-05-10 04:01:39 -04:00
committed by GitHub
parent b941fe5732
commit b8af40d2b4
3 changed files with 240 additions and 3 deletions
+20 -3
View File
@@ -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),
})),