diff --git a/backend/src/__tests__/capability-registry-pilot.test.ts b/backend/src/__tests__/capability-registry-pilot.test.ts index 5642106e..0900a7c7 100644 --- a/backend/src/__tests__/capability-registry-pilot.test.ts +++ b/backend/src/__tests__/capability-registry-pilot.test.ts @@ -4,9 +4,10 @@ * - 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. + * - applyPilotModeCapabilityFilter strips host-console (whose central->pilot + * WS upgrade path is not yet wired) but leaves self-update in place so a + * Compose-deployed pilot can advertise it and the Fleet Update flow can + * route through NodeRegistry.getProxyTarget(). */ import { afterEach, describe, expect, it, vi } from 'vitest'; import axios from 'axios'; @@ -67,27 +68,33 @@ describe('fetchRemoteMeta Authorization header', () => { describe('applyPilotModeCapabilityFilter', () => { afterEach(() => { enableCapability('host-console'); - enableCapability('self-update'); }); - it('removes host-console and self-update from active capabilities', () => { + it('removes host-console 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('leaves self-update in place so Compose-deployed pilots can advertise it', () => { + expect(CAPABILITIES).toContain('self-update'); + + applyPilotModeCapabilityFilter(); + const active = getActiveCapabilities(); + + expect(active).toContain('self-update'); + }); + 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); + expect(active.length).toBe(CAPABILITIES.length - 1); }); }); diff --git a/backend/src/__tests__/fleet-pilot-update.test.ts b/backend/src/__tests__/fleet-pilot-update.test.ts new file mode 100644 index 00000000..cb25e73d --- /dev/null +++ b/backend/src/__tests__/fleet-pilot-update.test.ts @@ -0,0 +1,235 @@ +/** + * Regression guard: POST /api/fleet/nodes/:id/update and POST /api/fleet/update-all + * route through NodeRegistry.getProxyTarget so pilot-agent nodes (which carry no + * node.api_url / node.api_token) can receive remote update commands. + * + * Pre-fix: + * - Single update on a pilot returned 503 "Remote node not configured." + * - Update-all filtered every pilot row out before dispatch. + * + * Post-fix: each route dispatches against target.apiUrl (the loopback URL for + * pilots, the configured api_url for proxy-mode remotes), and emits a + * mode-aware 503 when the target is unavailable. + */ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import type { RemoteMeta } from '../services/CapabilityRegistry'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +const LOOPBACK = 'http://127.0.0.1:54322'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let pilotNodeId: number; +let proxyNodeId: number; +let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry; +let FleetUpdateTrackerService: typeof import('../services/FleetUpdateTrackerService').FleetUpdateTrackerService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let LicenseService: typeof import('../services/LicenseService').LicenseService; + +const META_ONLINE_OUTDATED: RemoteMeta = { + version: '0.83.0', + capabilities: ['stacks', 'self-update'], + startedAt: 1, + updateError: null, + online: true, +}; + +const META_OFFLINE: RemoteMeta = { + version: null, + capabilities: [], + startedAt: null, + updateError: null, + online: false, +}; + +const META_NO_SELF_UPDATE: RemoteMeta = { + ...META_ONLINE_OUTDATED, + capabilities: ['stacks'], +}; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ NodeRegistry } = await import('../services/NodeRegistry')); + ({ FleetUpdateTrackerService } = await import('../services/FleetUpdateTrackerService')); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ LicenseService } = await import('../services/LicenseService')); + + const db = DatabaseService.getInstance(); + pilotNodeId = db.addNode({ + name: 'pilot-update-test', + type: 'remote', + mode: 'pilot_agent', + compose_dir: '/tmp', + is_default: false, + api_url: '', + api_token: '', + }); + db.updateNode(pilotNodeId, { + pilot_last_seen: Date.now(), + pilot_agent_version: '0.83.0', + }); + + proxyNodeId = db.addNode({ + name: 'proxy-update-test', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: 'http://192.168.1.99:1852', + api_token: 'proxy-token', + }); + + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + authHeader = `Bearer ${token}`; +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +afterEach(() => { + vi.restoreAllMocks(); + const tracker = FleetUpdateTrackerService.getInstance(); + for (const [id] of tracker.entries()) tracker.delete(id); +}); + +function mockTargetForPilot() { + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => { + if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '' }; + if (id === proxyNodeId) return { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token' }; + return null; + }); +} + +function mockTargetUnreachable() { + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null); +} + +function mockMeta(meta: RemoteMeta) { + vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta); +} + +function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise) { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => handler(String(input), init)); +} + +describe('POST /api/fleet/nodes/:nodeId/update (pilot-agent)', () => { + it('dispatches /api/system/update via the loopback target and returns 202', async () => { + mockTargetForPilot(); + mockMeta(META_ONLINE_OUTDATED); + let postedUrl: string | undefined; + let postedHeaders: Record | undefined; + mockFetch((url, init) => { + postedUrl = url; + postedHeaders = (init?.headers as Record) ?? undefined; + return new Response('', { status: 202 }); + }); + + const res = await request(app) + .post(`/api/fleet/nodes/${pilotNodeId}/update`) + .set('Authorization', authHeader); + + expect(res.status).toBe(202); + expect(postedUrl).toBe(`${LOOPBACK}/api/system/update`); + expect(postedHeaders).not.toHaveProperty('Authorization'); + + const tracker = FleetUpdateTrackerService.getInstance().get(pilotNodeId); + expect(tracker?.status).toBe('updating'); + }); + + it('returns 503 with a pilot-tunnel-disconnected message when target is null', async () => { + mockTargetUnreachable(); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const res = await request(app) + .post(`/api/fleet/nodes/${pilotNodeId}/update`) + .set('Authorization', authHeader); + + expect(res.status).toBe(503); + expect(res.body?.error).toMatch(/pilot tunnel/i); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns 503 with the self-update-unsupported message when capability missing', async () => { + mockTargetForPilot(); + mockMeta(META_NO_SELF_UPDATE); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const res = await request(app) + .post(`/api/fleet/nodes/${pilotNodeId}/update`) + .set('Authorization', authHeader); + + expect(res.status).toBe(503); + expect(res.body?.error).toMatch(/does not support self-update/i); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns 503 with unreachable message when meta.online is false', async () => { + mockTargetForPilot(); + mockMeta(META_OFFLINE); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const res = await request(app) + .post(`/api/fleet/nodes/${pilotNodeId}/update`) + .set('Authorization', authHeader); + + expect(res.status).toBe(503); + expect(res.body?.error).toMatch(/unreachable/i); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('POST /api/fleet/update-all (pilot-agent mixed fleet)', () => { + // /update-all is requirePaid; spy the license tier so the test DB does not + // need a real activation row. + function mockPaidTier() { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + } + + it('includes the pilot node in the candidate set and dispatches through its target', async () => { + mockPaidTier(); + mockTargetForPilot(); + mockMeta(META_ONLINE_OUTDATED); + const postedUrls: string[] = []; + mockFetch((url) => { + postedUrls.push(url); + return new Response('', { status: 202 }); + }); + + const res = await request(app) + .post('/api/fleet/update-all') + .set('Authorization', authHeader); + + expect(res.status).toBe(202); + expect(res.body.updating).toContain('pilot-update-test'); + expect(res.body.updating).toContain('proxy-update-test'); + expect(postedUrls).toContain(`${LOOPBACK}/api/system/update`); + expect(postedUrls).toContain('http://192.168.1.99:1852/api/system/update'); + }); + + it('skips remotes whose target resolves to null and never calls /api/system/update on them', async () => { + mockPaidTier(); + mockTargetUnreachable(); + // /update-all also calls api.github.com to compute the compare target; + // pin the assertion to the route's own dispatch surface. + const systemUpdateCalls: string[] = []; + mockFetch((url) => { + if (url.endsWith('/api/system/update')) systemUpdateCalls.push(url); + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }); + + const res = await request(app) + .post('/api/fleet/update-all') + .set('Authorization', authHeader); + + expect(res.status).toBe(202); + expect(res.body.updating).toEqual([]); + expect(res.body.skipped).toEqual(expect.arrayContaining(['pilot-update-test', 'proxy-update-test'])); + expect(systemUpdateCalls).toEqual([]); + }); +}); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index cb58b9fd..61a5587a 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -12,7 +12,7 @@ import DockerController from '../services/DockerController'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; import SelfUpdateService from '../services/SelfUpdateService'; -import { fetchRemoteMeta, getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry'; +import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry'; import { authMiddleware } from '../middleware/auth'; import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierGates'; import { scheduleLocalUpdate } from './license'; @@ -837,6 +837,19 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp } }); +// Pilot loopback targets carry an empty apiToken because the tunnel bridge +// re-injects admin auth; sending a malformed `Bearer ` header would 401 on +// the pilot's local Express. Omit the header in that case. +function postSystemUpdate(target: { apiUrl: string; apiToken: string }) { + const headers: Record = { 'Content-Type': 'application/json' }; + if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; + return fetch(`${target.apiUrl.replace(/\/$/, '')}/api/system/update`, { + method: 'POST', + headers, + signal: AbortSignal.timeout(10000), + }); +} + fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { @@ -865,7 +878,7 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r console.log('[Fleet] Update triggered for node', node.name, node.type); if (isDebugEnabled()) { - console.debug('[Fleet:debug] Update trigger details:', { nodeId, name: node.name, type: node.type, hasUrl: !!node.api_url, hasToken: !!node.api_token }); + console.debug('[Fleet:debug] Update trigger details:', { nodeId, name: node.name, type: node.type, mode: node.mode }); } if (node.type === 'local') { @@ -878,12 +891,16 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r return; } - 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) { + const msg = node.mode === 'pilot_agent' + ? `Pilot tunnel to "${node.name}" is disconnected.` + : 'Remote node not configured.'; + res.status(503).json({ error: msg }); return; } - const meta = await fetchRemoteMeta(node.api_url, node.api_token); + const meta = await NodeRegistry.getInstance().fetchMetaForNode(node.id); if (isDebugEnabled()) { console.debug('[Fleet:debug] Remote meta for update:', { nodeId, online: meta.online, version: meta.version, capabilities: meta.capabilities, startedAt: meta.startedAt }); } @@ -896,14 +913,7 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r return; } - const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/update`, { - method: 'POST', - headers: { - Authorization: `Bearer ${node.api_token}`, - 'Content-Type': 'application/json', - }, - signal: AbortSignal.timeout(10000), - }); + const response = await postSystemUpdate(target); if (!response.ok) { const err = await response.json().catch(() => ({})); @@ -939,11 +949,12 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon console.log('[Fleet] Update-all triggered,', nodes.length, 'nodes registered'); if (debug) console.debug('[Fleet:debug] Update-all compare target:', { gatewayVersion, compareVersion, compareValid }); + const registry = NodeRegistry.getInstance(); const candidates = nodes.filter(node => { if (node.type === 'local') return false; const tracker = updateTracker.get(node.id); if (tracker?.status === 'updating') return false; - if (!node.api_url || !node.api_token) return false; + if (registry.getProxyTarget(node.id) === null) return false; // Clear terminal states so they can be re-triggered. if (tracker && (tracker.status === 'timeout' || tracker.status === 'failed' || tracker.status === 'completed')) { updateTracker.delete(node.id); @@ -952,7 +963,9 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon }); const results = await Promise.allSettled(candidates.map(async (node) => { - const meta = await fetchRemoteMeta(node.api_url!, node.api_token!); + const target = registry.getProxyTarget(node.id); + if (!target) return { name: node.name, triggered: false }; + const meta = await registry.fetchMetaForNode(node.id); if (!meta.online) { return { name: node.name, triggered: false }; } @@ -962,11 +975,7 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon if (isValidVersion(meta.version) && compareValid && !semver.lt(meta.version, compareVersion!)) { return { name: node.name, triggered: false }; } - const response = await fetch(`${node.api_url!.replace(/\/$/, '')}/api/system/update`, { - method: 'POST', - headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' }, - signal: AbortSignal.timeout(10000), - }); + const response = await postSystemUpdate(target); if (response.ok) { updateTracker.set(node.id, updateTracker.create('updating', meta.version, meta.startedAt)); return { name: node.name, triggered: true }; diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 7d0df575..fc297c9c 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -101,10 +101,14 @@ export function getActiveCapabilities(): readonly string[] { * 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. + * + * `self-update` is intentionally NOT here: a pilot deployed via Docker Compose + * picks up the compose labels SelfUpdateService.initialize() needs and toggles + * the capability on locally; the Fleet Update flow then routes through + * NodeRegistry.getProxyTarget() so the tunnel carries the trigger. */ const PILOT_DISABLED_CAPABILITIES: readonly Capability[] = [ 'host-console', - 'self-update', ]; /** Disable capabilities that require a central->pilot path that is not yet wired. */