mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +00:00
fix(fleet): resolve Stop-by-label stack labels across all nodes (#1382)
* fix(fleet): resolve Stop-by-label stack labels across all nodes The Fleet Actions "Stop by label" card only ever saw the control node's own stack labels. The stack-label routes are proxied, so each node stores its labels in its own database and the control holds no mirror for remote nodes. The suggestions, match-preview, and fleet-stop endpoints all read that nonexistent mirror, so remote-only labels were invisible and remote stacks were skipped before the remote was ever asked. Make all three authoritative across the fleet with a new collectFleetLabelSummaries helper: the local node reads its own database, and each remote is queried live through its labels and label-assignments endpoints over the proxy, with fail-closed parsing and per-node reachability. fleet-stop drops the mirror pre-check and always calls each reachable remote's local-stop receiver, reporting unreachable nodes at the node level so they never block the reachable ones. The picker now surfaces remote-only labels, aggregates shared names once with combined counts and the carrying node names, and flags incomplete coverage when a node is unreachable. The preview groups matches per node, lists unreachable nodes separately, and distinguishes no matching stacks from "label exists but no stacks" from "remote unavailable". * fix(fleet): harden Stop-by-label card against malformed responses Guard the match-preview and fleet-stop response bodies so a malformed but 200 reply degrades instead of crashing or misreporting. match-preview now validates the per-node shape before rendering (the preview reads it outside any try/catch) and logs a malformed body; fleet-stop distinguishes a non-array results body (a server bug, now logged and surfaced as an unexpected-response error) from a genuine empty fleet, and guards per-node stackResults. Docs: an unreachable or errored remote is reported once per node, not as a per-stack error row.
This commit is contained in:
@@ -158,7 +158,50 @@ describe('POST /api/fleet/labels/fleet-stop (pilot-agent dispatch)', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const pilotResult = res.body.results.find((r: { nodeId: number }) => r.nodeId === pilotNodeId);
|
||||
expect(pilotResult.stackResults[0].error).toMatch(/pilot tunnel/i);
|
||||
// A node with no reachable target is reported at the node level; there is no
|
||||
// control-side mirror to enumerate per-stack rows for an unreachable remote.
|
||||
expect(pilotResult.reachable).toBe(false);
|
||||
expect(pilotResult.matched).toBe(false);
|
||||
expect(pilotResult.stackResults).toEqual([]);
|
||||
expect(pilotResult.error).toMatch(/pilot tunnel/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/labels/suggestions (pilot-agent summary fan-out)', () => {
|
||||
it('reads each remote label set through the proxy target with conditional Authorization', async () => {
|
||||
mockTargets();
|
||||
const calls: Array<{ url: string; auth: string | undefined }> = [];
|
||||
mockFetch((url, init) => {
|
||||
const headers = (init?.headers as Record<string, string>) ?? {};
|
||||
calls.push({ url, auth: headers.Authorization });
|
||||
if (url.endsWith('/api/labels')) {
|
||||
return new Response(JSON.stringify([{ id: 1, node_id: 0, name: 'summary-label', color: 'teal' }]), {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.endsWith('/api/labels/assignments')) {
|
||||
return new Response(JSON.stringify({ svc: [{ id: 1, node_id: 0, name: 'summary-label', color: 'teal' }] }), {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/fleet/labels/suggestions')
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Both fan-out legs (labels + assignments) must use the proxy target with the
|
||||
// right auth: pilot loopback carries no Authorization, proxy carries Bearer.
|
||||
const pilotCalls = calls.filter(c => c.url.startsWith(PILOT_LOOPBACK));
|
||||
expect(pilotCalls.map(c => c.url).sort()).toEqual([`${PILOT_LOOPBACK}/api/labels`, `${PILOT_LOOPBACK}/api/labels/assignments`]);
|
||||
expect(pilotCalls.every(c => c.auth === undefined)).toBe(true);
|
||||
const proxyCalls = calls.filter(c => c.url.startsWith(PROXY_URL));
|
||||
expect(proxyCalls.map(c => c.url).sort()).toEqual([`${PROXY_URL}/api/labels`, `${PROXY_URL}/api/labels/assignments`]);
|
||||
expect(proxyCalls.every(c => c.auth === `Bearer ${PROXY_TOKEN}`)).toBe(true);
|
||||
// The label only exists on the remotes (live fan-out), never in the control DB.
|
||||
expect(res.body.suggestions.some((s: { name: string }) => s.name === 'summary-label')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user