diff --git a/.gitattributes b/.gitattributes index 971f0af1..a1a163ef 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,8 @@ # Force LF line endings for shell scripts regardless of the developer's OS. # Shell scripts with CRLF endings will fail with "exec format error" in Linux containers. *.sh text eol=lf + +# Husky git hooks are POSIX shell scripts without the .sh extension. They run +# via /usr/bin/env sh, so the same eol=lf rule must apply or autocrlf=true +# breaks commit/push on Windows shells that exec the file directly. +.husky/* text eol=lf diff --git a/.husky/commit-msg b/.husky/commit-msg index da994831..a1101127 100644 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1,2 @@ +#!/usr/bin/env sh npx --no -- commitlint --edit "$1" diff --git a/backend/src/__tests__/capability-registry-pilot.test.ts b/backend/src/__tests__/capability-registry-pilot.test.ts new file mode 100644 index 00000000..5642106e --- /dev/null +++ b/backend/src/__tests__/capability-registry-pilot.test.ts @@ -0,0 +1,93 @@ +/** + * F9 regression guard for CapabilityRegistry: + * + * - fetchRemoteMeta omits the Authorization header when the apiToken is + * empty so the loopback bridge (used by pilot-agent proxy targets) is + * not handed a malformed `Bearer ` header. + * - applyPilotModeCapabilityFilter strips capabilities whose central->pilot + * path is not yet wired (host-console, self-update) so the frontend + * cannot offer them on a pilot-active session. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; + +import { + CAPABILITIES, + applyPilotModeCapabilityFilter, + enableCapability, + fetchRemoteMeta, + getActiveCapabilities, +} from '../services/CapabilityRegistry'; + +describe('fetchRemoteMeta Authorization header', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sends Authorization: Bearer when token is non-empty', async () => { + const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: { version: '0.76.7', capabilities: ['stacks'], startedAt: 1, updateError: null }, + }); + + await fetchRemoteMeta('https://remote.example.com:1852', 'real-token'); + + expect(getSpy).toHaveBeenCalledTimes(1); + const init = getSpy.mock.calls[0][1] as { headers: Record }; + expect(init.headers).toEqual({ Authorization: 'Bearer real-token' }); + }); + + it('omits Authorization entirely when token is empty (pilot-agent loopback)', async () => { + const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: { version: '0.76.7', capabilities: ['stacks'], startedAt: 1, updateError: null }, + }); + + await fetchRemoteMeta('http://127.0.0.1:54321', ''); + + expect(getSpy).toHaveBeenCalledTimes(1); + const init = getSpy.mock.calls[0][1] as { headers: Record }; + expect(init.headers).toEqual({}); + expect(init.headers).not.toHaveProperty('Authorization'); + }); + + it('returns OFFLINE_META shape on transport failure', async () => { + vi.spyOn(axios, 'get').mockRejectedValue(new Error('connect ECONNREFUSED')); + + const meta = await fetchRemoteMeta('http://127.0.0.1:54321', ''); + + expect(meta).toEqual({ + version: null, + capabilities: [], + startedAt: null, + updateError: null, + online: false, + }); + }); +}); + +describe('applyPilotModeCapabilityFilter', () => { + afterEach(() => { + enableCapability('host-console'); + enableCapability('self-update'); + }); + + it('removes host-console and self-update from active capabilities', () => { + expect(CAPABILITIES).toContain('host-console'); + expect(CAPABILITIES).toContain('self-update'); + + applyPilotModeCapabilityFilter(); + const active = getActiveCapabilities(); + + expect(active).not.toContain('host-console'); + expect(active).not.toContain('self-update'); + expect(active).toContain('stacks'); + }); + + it('is idempotent (safe to call multiple times)', () => { + applyPilotModeCapabilityFilter(); + applyPilotModeCapabilityFilter(); + const active = getActiveCapabilities(); + + expect(active).not.toContain('host-console'); + expect(active.length).toBe(CAPABILITIES.length - 2); + }); +}); diff --git a/backend/src/__tests__/fleet-pilot-agent-parity.test.ts b/backend/src/__tests__/fleet-pilot-agent-parity.test.ts new file mode 100644 index 00000000..69ad5972 --- /dev/null +++ b/backend/src/__tests__/fleet-pilot-agent-parity.test.ts @@ -0,0 +1,297 @@ +/** + * 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) { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: Parameters[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 | undefined; + mockFetch((_url, init) => { + observedHeaders = (init?.headers as Record | 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>).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>).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> }).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> }).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>).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>).find(r => r.id === pilotNodeId); + expect(pilotRow!.status).toBe('offline'); + expect(pilotRow!.configuration).toBeNull(); + }); +}); diff --git a/backend/src/__tests__/node-registry-fetch-meta.test.ts b/backend/src/__tests__/node-registry-fetch-meta.test.ts new file mode 100644 index 00000000..cb3f1376 --- /dev/null +++ b/backend/src/__tests__/node-registry-fetch-meta.test.ts @@ -0,0 +1,131 @@ +/** + * F9 regression guard for NodeRegistry.fetchMetaForNode: + * + * - Resolves getProxyTarget for the node and delegates to fetchRemoteMeta. + * - Pilot-agent with active tunnel resolves to a loopback URL with empty + * token; the request must reach fetchRemoteMeta with that exact shape. + * - Null target (proxy-mode missing api_url/api_token, or pilot-agent + * tunnel disconnected) returns OFFLINE_META without touching the network. + */ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ NodeRegistry } = await import('../services/NodeRegistry')); + ({ DatabaseService } = await import('../services/DatabaseService')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('NodeRegistry.fetchMetaForNode', () => { + it('returns OFFLINE_META when getProxyTarget is null', async () => { + const reg = NodeRegistry.getInstance(); + const db = DatabaseService.getInstance(); + const nodeId = db.addNode({ + name: 'meta-pilot-down', + type: 'remote', + mode: 'pilot_agent', + compose_dir: '/tmp', + is_default: false, + api_url: '', + api_token: '', + }); + + vi.spyOn(reg, 'getProxyTarget').mockReturnValue(null); + const axiosSpy = vi.spyOn(axios, 'get'); + + const meta = await reg.fetchMetaForNode(nodeId); + + expect(meta).toEqual({ + version: null, + capabilities: [], + startedAt: null, + updateError: null, + online: false, + }); + expect(axiosSpy).not.toHaveBeenCalled(); + db.deleteNode(nodeId); + }); + + it('delegates to fetchRemoteMeta against the loopback URL for pilot-agent', async () => { + const reg = NodeRegistry.getInstance(); + const db = DatabaseService.getInstance(); + const nodeId = db.addNode({ + name: 'meta-pilot-up', + type: 'remote', + mode: 'pilot_agent', + compose_dir: '/tmp', + is_default: false, + api_url: '', + api_token: '', + }); + + vi.spyOn(reg, 'getProxyTarget').mockReturnValue({ + apiUrl: 'http://127.0.0.1:54321', + apiToken: '', + }); + const axiosSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: { + version: '0.76.7', + capabilities: ['stacks', 'containers'], + startedAt: 1234, + updateError: null, + }, + }); + + const meta = await reg.fetchMetaForNode(nodeId); + + expect(meta.version).toBe('0.76.7'); + expect(meta.capabilities).toEqual(['stacks', 'containers']); + expect(meta.online).toBe(true); + + expect(axiosSpy).toHaveBeenCalledTimes(1); + const url = axiosSpy.mock.calls[0][0]; + expect(url).toBe('http://127.0.0.1:54321/api/meta'); + const init = axiosSpy.mock.calls[0][1] as { headers: Record }; + expect(init.headers).toEqual({}); + + db.deleteNode(nodeId); + }); + + it('forwards Authorization for proxy-mode targets with non-empty tokens', async () => { + const reg = NodeRegistry.getInstance(); + const db = DatabaseService.getInstance(); + const nodeId = db.addNode({ + name: 'meta-proxy', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: 'https://remote.example.com:1852', + api_token: 'real-token', + }); + + vi.spyOn(reg, 'getProxyTarget').mockReturnValue({ + apiUrl: 'https://remote.example.com:1852', + apiToken: 'real-token', + }); + const axiosSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: { version: '0.76.7', capabilities: [], startedAt: 1, updateError: null }, + }); + + await reg.fetchMetaForNode(nodeId); + + const init = axiosSpy.mock.calls[0][1] as { headers: Record }; + expect(init.headers).toEqual({ Authorization: 'Bearer real-token' }); + + db.deleteNode(nodeId); + }); +}); diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts index 3152162b..c4e07410 100644 --- a/backend/src/bootstrap/startup.ts +++ b/backend/src/bootstrap/startup.ts @@ -15,9 +15,16 @@ import { SchedulerService } from '../services/SchedulerService'; import { MfaService } from '../services/MfaService'; import { MeshService } from '../services/MeshService'; import { BlueprintReconciler } from '../services/BlueprintReconciler'; +import { applyPilotModeCapabilityFilter } from '../services/CapabilityRegistry'; +import { PilotTunnelManager } from '../services/PilotTunnelManager'; +import { invalidateRemoteMetaCache } from '../helpers/cacheInvalidation'; import { sweepStaleTempDirs as sweepStaleGitTempDirs } from '../services/GitSourceService'; import { PORT } from '../helpers/constants'; +function isPilotMode(): boolean { + return process.env.SENCHO_MODE === 'pilot'; +} + /** * Pilot-agent hosts never run the first-run setup wizard, so the wizard * path that normally generates `auth_jwt_secret` (routes/auth.ts) never @@ -32,7 +39,7 @@ import { PORT } from '../helpers/constants'; * Returns true when a fresh secret was written, false otherwise. */ export function ensurePilotJwtSecret(): boolean { - if (process.env.SENCHO_MODE !== 'pilot') return false; + if (!isPilotMode()) return false; const dbSvc = DatabaseService.getInstance(); if (dbSvc.getGlobalSettings().auth_jwt_secret) return false; const generated = crypto.randomBytes(64).toString('hex'); @@ -59,6 +66,10 @@ export async function startServer(server: Server): Promise { ensurePilotJwtSecret(); + if (isPilotMode()) { + applyPilotModeCapabilityFilter(); + } + // Initialize the license service before any tier-gated code can run. LicenseService.getInstance().initialize(); @@ -76,6 +87,11 @@ export async function startServer(server: Server): Promise { }); BlueprintReconciler.getInstance().start(); + // Drop the cached /api/meta entry on tunnel reconnect so the next + // /api/nodes/:id/meta refetches fresh capabilities and version through + // the live loopback bridge instead of waiting for the 3-minute TTL. + PilotTunnelManager.getInstance().on('tunnel-up', invalidateRemoteMetaCache); + // Async initializers are independent of each other; run in parallel // so total boot time is the slowest one rather than the sum. await Promise.all([ @@ -92,7 +108,7 @@ export async function startServer(server: Server): Promise { console.warn('[Trivy] Temp dir sweep failed:', (err as Error).message); }); - const isPilotAgent = process.env.SENCHO_MODE === 'pilot'; + const isPilotAgent = isPilotMode(); const listenHost = isPilotAgent ? '127.0.0.1' : undefined; server.listen(PORT, listenHost, () => { diff --git a/backend/src/helpers/cacheInvalidation.ts b/backend/src/helpers/cacheInvalidation.ts index e7e6d34f..dab87578 100644 --- a/backend/src/helpers/cacheInvalidation.ts +++ b/backend/src/helpers/cacheInvalidation.ts @@ -1,5 +1,7 @@ import { CacheService } from '../services/CacheService'; +export const REMOTE_META_NAMESPACE = 'remote-meta'; + /** * Drop the per-node caches affected by a stack or container mutation so the * next dashboard poll shows fresh state instead of stale reads. @@ -14,3 +16,12 @@ export function invalidateNodeCaches(nodeId: number): void { cache.invalidate(`stack-statuses:${nodeId}`); cache.invalidate('project-name-map'); } + +/** + * Drop the cached `/api/meta` response for a remote node. Triggered on pilot + * tunnel reconnect so the next request rebuilds capabilities and version + * through the live loopback bridge instead of waiting for the TTL. + */ +export function invalidateRemoteMetaCache(nodeId: number): void { + CacheService.getInstance().invalidate(`${REMOTE_META_NAMESPACE}:${nodeId}`); +} diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 66dcb869..535c95e1 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -222,39 +222,55 @@ async function fetchLocalNodeOverview(node: Node): Promise { } } +function pilotLastSeenSeconds(node: Node): number | null { + return node.mode === 'pilot_agent' && node.pilot_last_seen + ? Math.floor(node.pilot_last_seen / 1000) + : null; +} + +function noTargetMessage(node: Node): string { + return node.mode === 'pilot_agent' + ? `Pilot tunnel to "${node.name}" is disconnected. Operations resume when the agent reconnects.` + : 'Remote node not configured'; +} + +function offlineRemoteOverview(node: Node, status: 'online' | 'offline'): FleetNodeOverview { + const pilotSeen = pilotLastSeenSeconds(node); + // For pilot-agent rows the tunnel heartbeat is the contact signal. Mirror + // it into last_successful_contact so the Fleet "last seen" cell renders + // the recent tunnel timestamp instead of a stale HTTP-success time. + const lastContact = pilotSeen ?? node.last_successful_contact ?? null; + return { + id: node.id, + name: node.name, + type: node.type, + mode: node.mode, + status, + stats: null, + systemStats: null, + stacks: null, + last_successful_contact: lastContact, + pilot_last_seen: pilotSeen, + cordoned: node.cordoned, + cordoned_at: node.cordoned_at, + cordoned_reason: node.cordoned_reason, + }; +} + async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise { - // Pilot-agent nodes: use pilot_last_seen as the contact signal; no HTTP fetch. - if (node.mode === 'pilot_agent') { - return { - id: node.id, - name: node.name, - type: node.type, - mode: node.mode, - status: node.pilot_last_seen ? 'online' : 'offline', - stats: null, - systemStats: null, - stacks: null, - last_successful_contact: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null, - pilot_last_seen: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null, - cordoned: node.cordoned, - cordoned_at: node.cordoned_at, - cordoned_reason: node.cordoned_reason, - }; + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) { + // Soft-online keeps the Fleet card from flapping during a brief pilot + // tunnel reconnect: a recent pilot_last_seen still counts as reachable. + const status: 'online' | 'offline' = + node.mode === 'pilot_agent' && node.pilot_last_seen ? 'online' : 'offline'; + return offlineRemoteOverview(node, status); } - if (!node.api_url || !node.api_token) { - return { - id: node.id, name: node.name, type: node.type, status: 'offline', - stats: null, systemStats: null, stacks: null, - last_successful_contact: node.last_successful_contact ?? null, - cordoned: node.cordoned, - cordoned_at: node.cordoned_at, - cordoned_reason: node.cordoned_reason, - }; - } - - const baseUrl = node.api_url.replace(/\/$/, ''); - const headers = { Authorization: `Bearer ${node.api_token}` }; + const baseUrl = target.apiUrl.replace(/\/$/, ''); + const headers: Record = target.apiToken + ? { Authorization: `Bearer ${target.apiToken}` } + : {}; const t0 = Date.now(); try { @@ -309,20 +325,14 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise last_successful_contact: isOnline ? Math.floor(completedAt / 1000) : node.last_successful_contact ?? null, + pilot_last_seen: pilotLastSeenSeconds(node), cordoned: node.cordoned, cordoned_at: node.cordoned_at, cordoned_reason: node.cordoned_reason, }; } catch (error) { console.error(`[Fleet] Remote node ${node.name} error:`, error); - return { - id: node.id, name: node.name, type: node.type, mode: node.mode, status: 'offline', - stats: null, systemStats: null, stacks: null, - last_successful_contact: node.last_successful_contact ?? null, - cordoned: node.cordoned, - cordoned_at: node.cordoned_at, - cordoned_reason: node.cordoned_reason, - }; + return offlineRemoteOverview(node, 'offline'); } } @@ -552,16 +562,17 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp }; } - if (!node.api_url || !node.api_token) { + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) { return { id: node.id, name: node.name, type: 'remote', status: 'offline', configuration: null }; } try { const resp = await fetch( - `${node.api_url.replace(/\/$/, '')}/api/dashboard/configuration`, + `${target.apiUrl.replace(/\/$/, '')}/api/dashboard/configuration`, { headers: { - Authorization: `Bearer ${node.api_token}`, + ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}), [PROXY_TIER_HEADER]: localTier, [PROXY_VARIANT_HEADER]: localVariant ?? '', }, @@ -606,12 +617,13 @@ fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res } if (node.type === 'remote') { - if (!node.api_url || !node.api_token) { - res.status(503).json({ error: 'Remote node not configured' }); + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) { + res.status(503).json({ error: noTargetMessage(node) }); return; } - const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/stacks`, { - headers: { Authorization: `Bearer ${node.api_token}` }, + const response = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/stacks`, { + headers: target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}, signal: AbortSignal.timeout(10000), }); if (!response.ok) { @@ -649,12 +661,13 @@ fleetRouter.get('/node/:nodeId/stacks/:stackName/containers', authMiddleware, as } if (node.type === 'remote') { - if (!node.api_url || !node.api_token) { - res.status(503).json({ error: 'Remote node not configured' }); + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) { + res.status(503).json({ error: noTargetMessage(node) }); return; } - const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(stackName)}/containers`, { - headers: { Authorization: `Bearer ${node.api_token}` }, + const response = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(stackName)}/containers`, { + headers: target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}, signal: AbortSignal.timeout(10000), }); if (!response.ok) { @@ -696,8 +709,8 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp let remoteOnline = false; if (node.type === 'local') { version = gatewayVersion; - } else if (node.api_url && node.api_token) { - const meta = await fetchRemoteMeta(node.api_url, node.api_token); + } else { + const meta = await NodeRegistry.getInstance().fetchMetaForNode(node.id); version = meta.version; remoteStartedAt = meta.startedAt; remoteUpdateError = meta.updateError; diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index d0fef48b..38e43684 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -9,7 +9,8 @@ import { enrollmentLimiter } from '../middleware/rateLimiters'; import { DatabaseService } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; import { CacheService } from '../services/CacheService'; -import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta, type RemoteMeta } from '../services/CapabilityRegistry'; +import { REMOTE_META_NAMESPACE } from '../helpers/cacheInvalidation'; +import { CAPABILITIES, getSenchoVersion, type RemoteMeta } from '../services/CapabilityRegistry'; import { PilotTunnelManager } from '../services/PilotTunnelManager'; import { PilotCloseCode } from '../pilot/protocol'; import { FleetUpdateTrackerService } from '../services/FleetUpdateTrackerService'; @@ -18,7 +19,6 @@ import { isValidRemoteUrl } from '../utils/validation'; import { getErrorMessage } from '../utils/errors'; const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.'; -const REMOTE_META_NAMESPACE = 'remote-meta'; const REMOTE_META_CACHE_TTL = 3 * 60 * 1000; function mintPilotEnrollment(nodeId: number, req: Request): { token: string; expiresAt: number; dockerRun: string } { @@ -354,18 +354,12 @@ nodesRouter.get('/:id/meta', authMiddleware, async (req: Request, res: Response) return; } - const baseUrl = node.api_url?.replace(/\/$/, ''); - if (!baseUrl || !node.api_token) { - res.json({ version: null, capabilities: [] }); - return; - } - const cacheKey = `${REMOTE_META_NAMESPACE}:${id}`; const meta = await CacheService.getInstance().getOrFetch( cacheKey, REMOTE_META_CACHE_TTL, async () => { - const fetched = await fetchRemoteMeta(baseUrl, node.api_token!); + const fetched = await NodeRegistry.getInstance().fetchMetaForNode(id); if (fetched.version === null) { throw new Error('Remote meta fetch returned null version'); } diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 1bf5a1d6..7d0df575 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -79,7 +79,7 @@ export interface RemoteMeta { online: boolean; } -// Runtime capability overrides — services call disableCapability() during init +// Runtime capability overrides; services call disableCapability() during init. const disabledCapabilities = new Set(); export function disableCapability(c: Capability): void { @@ -96,11 +96,36 @@ export function getActiveCapabilities(): readonly string[] { return CAPABILITIES.filter(c => !disabledCapabilities.has(c)); } +/** + * Capabilities a pilot-agent process should hide from its own /api/meta because + * the central->pilot path for them is not yet wired through the reverse tunnel. + * Surfacing them would let the frontend offer a tab whose click silently falls + * through to central's local handler. + */ +const PILOT_DISABLED_CAPABILITIES: readonly Capability[] = [ + 'host-console', + 'self-update', +]; + +/** Disable capabilities that require a central->pilot path that is not yet wired. */ +export function applyPilotModeCapabilityFilter(): void { + for (const cap of PILOT_DISABLED_CAPABILITIES) disableCapability(cap); +} + +/** Shared offline shape returned when a remote node is unreachable. */ +export const OFFLINE_META: RemoteMeta = { + version: null, + capabilities: [], + startedAt: null, + updateError: null, + online: false, +}; + /** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */ export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise { try { const res = await axios.get(`${baseUrl.replace(/\/$/, '')}/api/meta`, { - headers: { Authorization: `Bearer ${apiToken}` }, + headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {}, timeout: 5000, }); const rawVersion: string | undefined = res.data.version; @@ -113,6 +138,6 @@ export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promis }; } catch (err) { console.warn(`[CapabilityRegistry] Failed to fetch meta from ${baseUrl}:`, (err as Error).message); - return { version: null, capabilities: [], startedAt: null, updateError: null, online: false }; + return { ...OFFLINE_META }; } } diff --git a/backend/src/services/NodeRegistry.ts b/backend/src/services/NodeRegistry.ts index fbc5b110..2126990d 100644 --- a/backend/src/services/NodeRegistry.ts +++ b/backend/src/services/NodeRegistry.ts @@ -2,7 +2,7 @@ import Docker from 'dockerode'; import axios from 'axios'; import { EventEmitter } from 'events'; import { DatabaseService, Node } from './DatabaseService'; -import { fetchRemoteMeta } from './CapabilityRegistry'; +import { fetchRemoteMeta, OFFLINE_META, RemoteMeta } from './CapabilityRegistry'; import { PilotTunnelManager } from './PilotTunnelManager'; /** @@ -119,6 +119,17 @@ export class NodeRegistry extends EventEmitter { return { apiUrl: node.api_url, apiToken: node.api_token }; } + /** + * Fetch /api/meta from a remote node, dispatching through the proxy + * target. Returns OFFLINE_META when no target is reachable (proxy-mode + * missing api_url/api_token, or pilot-agent tunnel disconnected). + */ + public async fetchMetaForNode(nodeId: number): Promise { + const target = this.getProxyTarget(nodeId); + if (!target) return { ...OFFLINE_META }; + return fetchRemoteMeta(target.apiUrl, target.apiToken); + } + /** * Test connectivity to a specific node. * - Local: pings the Docker daemon directly