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
+38
View File
@@ -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