mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
8dd0fce621
When a pilot-agent node was the active node, the UI rendered "does not advertise this capability" across most tabs, a perpetual "Update available" badge, and a Fleet card body with blank CPU/RAM/Disk and "No stacks found". The cause was central-side aggregators in /api/fleet/* and /api/nodes/:id/meta only fanning out to proxy-mode remotes via node.api_url + node.api_token, which are null for pilot-agent. Route every affected aggregator through NodeRegistry.getProxyTarget so the loopback URL backed by the active pilot tunnel is used uniformly: - /api/nodes/:id/meta and /api/fleet/update-status fetch via the new NodeRegistry.fetchMetaForNode helper (resolves the target, delegates to fetchRemoteMeta, returns the shared OFFLINE_META on null). - fetchRemoteNodeOverview, /api/fleet/configuration, /api/fleet/node/:nodeId/stacks, and the stack-containers drilldown fetch through target.apiUrl with conditional Authorization. - fetchRemoteMeta omits the Authorization header when the token is empty (pilot-agent loopback) instead of sending a malformed Bearer string. - Pilot-agent rows preserve pilot_last_seen and mirror it into last_successful_contact so the Fleet "last seen" cell renders the recent tunnel timestamp during a brief reconnect. Pilot-mode capability filter excludes capabilities whose central-pilot path is not yet wired (host-console, self-update). Without this, the Console tab would surface for an Admiral pilot session and click through to central's host because the WS upgrade handler still gates on api_url + api_token. Filtered capabilities are removed at boot via applyPilotModeCapabilityFilter when SENCHO_MODE=pilot. Cache invalidation on tunnel-up: the meta cache for a reconnecting pilot is dropped so the next request rebuilds capabilities and version through the live bridge instead of waiting for the 3-minute TTL. The namespace constant moves to helpers/cacheInvalidation.ts alongside the new invalidateRemoteMetaCache helper. Husky commit-msg hook: add the missing shebang and a .gitattributes rule pinning .husky/* to LF line endings so commits do not fail with "Exec format error" on Windows shells where autocrlf=true converts the hook to CRLF. Tests cover Authorization-header behavior, pilot-mode filter idempotency, fetchMetaForNode dispatch (offline target, pilot-agent loopback, proxy-mode), and the four affected fleet routes for pilot-agent both when the tunnel is up and when it is down.
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
/**
|
|
* F9 regression guard: fleet aggregator routes return real data for
|
|
* pilot-agent nodes (capabilities, version, metrics, stacks, drilldown,
|
|
* configuration) by dispatching through NodeRegistry.getProxyTarget instead
|
|
* of reading node.api_url/api_token directly.
|
|
*
|
|
* Pre-fix:
|
|
* - GET /api/fleet/overview returned stats=null/systemStats=null/stacks=null
|
|
* for pilot-agent rows.
|
|
* - GET /api/fleet/node/:id/stacks 503'd with "Remote node not configured".
|
|
* - GET /api/fleet/node/:id/stacks/:stack/containers 503'd the same way.
|
|
* - GET /api/fleet/update-status reported version=null and the Fleet card
|
|
* showed perpetual "Update available".
|
|
* - GET /api/fleet/configuration reported configuration=null.
|
|
*
|
|
* Post-fix: each surface fetches through the loopback URL when a pilot
|
|
* tunnel is active and degrades to a mode-aware offline shape when not.
|
|
*/
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authHeader: string;
|
|
let pilotNodeId: number;
|
|
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
|
|
const LOOPBACK = 'http://127.0.0.1:54321';
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
({ NodeRegistry } = await import('../services/NodeRegistry'));
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
|
|
pilotNodeId = DatabaseService.getInstance().addNode({
|
|
name: 'pilot-parity-test',
|
|
type: 'remote',
|
|
mode: 'pilot_agent',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: '',
|
|
api_token: '',
|
|
});
|
|
// Mark as recently seen so offline-status branches that key on
|
|
// pilot_last_seen render the expected shape.
|
|
DatabaseService.getInstance().updateNode(pilotNodeId, {
|
|
pilot_last_seen: Date.now(),
|
|
pilot_agent_version: '0.76.7',
|
|
});
|
|
|
|
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
|
authHeader = `Bearer ${token}`;
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function mockTargetActive() {
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => {
|
|
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '' };
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function mockTargetOffline() {
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null);
|
|
}
|
|
|
|
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
const url = typeof input === 'string'
|
|
? input
|
|
: input instanceof URL ? input.toString() : (input as Request).url;
|
|
return handler(url, init);
|
|
});
|
|
}
|
|
|
|
describe('GET /api/fleet/node/:nodeId/stacks (pilot-agent)', () => {
|
|
it('returns the pilot stacks via the loopback target', async () => {
|
|
mockTargetActive();
|
|
mockFetch((url) => {
|
|
expect(url).toBe(`${LOOPBACK}/api/stacks`);
|
|
return new Response(JSON.stringify(['audit-mesh-pilot', 'monitor']), {
|
|
status: 200, headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
|
|
const res = await request(app)
|
|
.get(`/api/fleet/node/${pilotNodeId}/stacks`)
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual(['audit-mesh-pilot', 'monitor']);
|
|
});
|
|
|
|
it('returns 503 with a pilot-tunnel-disconnected message when no target is available', async () => {
|
|
mockTargetOffline();
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
|
|
const res = await request(app)
|
|
.get(`/api/fleet/node/${pilotNodeId}/stacks`)
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body?.error).toMatch(/pilot tunnel/i);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('omits Authorization when target.apiToken is empty', async () => {
|
|
mockTargetActive();
|
|
let observedHeaders: Record<string, string> | undefined;
|
|
mockFetch((_url, init) => {
|
|
observedHeaders = (init?.headers as Record<string, string> | undefined) ?? undefined;
|
|
return new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } });
|
|
});
|
|
|
|
await request(app)
|
|
.get(`/api/fleet/node/${pilotNodeId}/stacks`)
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(observedHeaders).toBeDefined();
|
|
expect(observedHeaders).not.toHaveProperty('Authorization');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/fleet/node/:nodeId/stacks/:stackName/containers (pilot-agent)', () => {
|
|
it('returns containers via the loopback target', async () => {
|
|
mockTargetActive();
|
|
mockFetch((url) => {
|
|
expect(url).toBe(`${LOOPBACK}/api/stacks/audit-mesh-pilot/containers`);
|
|
return new Response(
|
|
JSON.stringify([{ id: 'c1', name: 'audit-mesh-pilot-echo-1', state: 'running' }]),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
});
|
|
|
|
const res = await request(app)
|
|
.get(`/api/fleet/node/${pilotNodeId}/stacks/audit-mesh-pilot/containers`)
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveLength(1);
|
|
expect(res.body[0].name).toBe('audit-mesh-pilot-echo-1');
|
|
});
|
|
|
|
it('returns 503 with pilot-tunnel-disconnected when no target is available', async () => {
|
|
mockTargetOffline();
|
|
const res = await request(app)
|
|
.get(`/api/fleet/node/${pilotNodeId}/stacks/audit-mesh-pilot/containers`)
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body?.error).toMatch(/pilot tunnel/i);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/fleet/overview (pilot-agent)', () => {
|
|
it('populates stats, systemStats, and stacks for pilot-agent rows when the tunnel is up', async () => {
|
|
mockTargetActive();
|
|
mockFetch((url) => {
|
|
if (url === `${LOOPBACK}/api/stats`) {
|
|
return new Response(
|
|
JSON.stringify({ active: 3, managed: 2, unmanaged: 1, exited: 0, total: 3 }),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
if (url === `${LOOPBACK}/api/system/stats`) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
cpu: { usage: '12.3', cores: 4 },
|
|
memory: { total: 8000000000, used: 2000000000, free: 6000000000, usagePercent: '25.0' },
|
|
disk: { total: 10, used: 5, free: 5, usagePercent: '50.0' },
|
|
}),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
if (url === `${LOOPBACK}/api/stacks`) {
|
|
return new Response(JSON.stringify(['audit-mesh-pilot']), {
|
|
status: 200, headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response('not found', { status: 404 });
|
|
});
|
|
|
|
const res = await request(app).get('/api/fleet/overview').set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(200);
|
|
const pilotRow = (res.body as Array<Record<string, unknown>>).find(r => r.id === pilotNodeId);
|
|
expect(pilotRow).toBeDefined();
|
|
expect(pilotRow!.status).toBe('online');
|
|
expect(pilotRow!.stats).toEqual({ active: 3, managed: 2, unmanaged: 1, exited: 0, total: 3 });
|
|
expect(pilotRow!.systemStats).toMatchObject({
|
|
cpu: { usage: '12.3', cores: 4 },
|
|
memory: { usagePercent: '25.0' },
|
|
});
|
|
expect(pilotRow!.stacks).toEqual(['audit-mesh-pilot']);
|
|
expect(pilotRow!.pilot_last_seen).toBeTypeOf('number');
|
|
});
|
|
|
|
it('falls back to an offline shape when the tunnel is down, preserving pilot_last_seen', async () => {
|
|
mockTargetOffline();
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
|
|
const res = await request(app).get('/api/fleet/overview').set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(200);
|
|
const pilotRow = (res.body as Array<Record<string, unknown>>).find(r => r.id === pilotNodeId);
|
|
expect(pilotRow).toBeDefined();
|
|
expect(pilotRow!.stats).toBeNull();
|
|
expect(pilotRow!.systemStats).toBeNull();
|
|
expect(pilotRow!.stacks).toBeNull();
|
|
expect(pilotRow!.pilot_last_seen).toBeTypeOf('number');
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('GET /api/fleet/update-status (pilot-agent)', () => {
|
|
it('reports the pilot version via fetchMetaForNode', async () => {
|
|
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockImplementation(async (id: number) => {
|
|
if (id === pilotNodeId) {
|
|
return {
|
|
version: '0.76.7',
|
|
capabilities: ['stacks', 'containers'],
|
|
startedAt: 1700000000,
|
|
updateError: null,
|
|
online: true,
|
|
};
|
|
}
|
|
return { version: null, capabilities: [], startedAt: null, updateError: null, online: false };
|
|
});
|
|
|
|
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(200);
|
|
const nodes = (res.body as { nodes: Array<Record<string, unknown>> }).nodes;
|
|
const pilotRow = nodes.find(n => n.nodeId === pilotNodeId);
|
|
expect(pilotRow).toBeDefined();
|
|
expect(pilotRow!.version).toBe('0.76.7');
|
|
});
|
|
|
|
it('reports null version when the pilot meta fetch returns offline', async () => {
|
|
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue({
|
|
version: null,
|
|
capabilities: [],
|
|
startedAt: null,
|
|
updateError: null,
|
|
online: false,
|
|
});
|
|
|
|
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(200);
|
|
const nodes = (res.body as { nodes: Array<Record<string, unknown>> }).nodes;
|
|
const pilotRow = nodes.find(n => n.nodeId === pilotNodeId);
|
|
expect(pilotRow!.version).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('GET /api/fleet/configuration (pilot-agent)', () => {
|
|
it('fetches the dashboard configuration via the loopback target', async () => {
|
|
mockTargetActive();
|
|
mockFetch((url) => {
|
|
expect(url).toBe(`${LOOPBACK}/api/dashboard/configuration`);
|
|
return new Response(
|
|
JSON.stringify({ ssoConfigured: false, alertsConfigured: true }),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
});
|
|
|
|
const res = await request(app).get('/api/fleet/configuration').set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(200);
|
|
const pilotRow = (res.body as Array<Record<string, unknown>>).find(r => r.id === pilotNodeId);
|
|
expect(pilotRow).toBeDefined();
|
|
expect(pilotRow!.status).toBe('online');
|
|
expect(pilotRow!.configuration).toMatchObject({ alertsConfigured: true });
|
|
});
|
|
|
|
it('returns offline configuration=null when the tunnel is down', async () => {
|
|
mockTargetOffline();
|
|
const res = await request(app).get('/api/fleet/configuration').set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(200);
|
|
const pilotRow = (res.body as Array<Record<string, unknown>>).find(r => r.id === pilotNodeId);
|
|
expect(pilotRow!.status).toBe('offline');
|
|
expect(pilotRow!.configuration).toBeNull();
|
|
});
|
|
});
|