mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
fix(fleet): route remote update trigger through getProxyTarget (#1123)
POST /api/fleet/nodes/:id/update and POST /api/fleet/update-all read node.api_url + node.api_token directly, so a pilot-agent row (which carries neither) returned "Remote node not configured." and was filtered out of bulk update. Both routes now resolve the target via NodeRegistry.getProxyTarget(), which returns the loopback URL for an active pilot tunnel and the configured api_url for proxy-mode remotes. fetchMetaForNode replaces the direct fetchRemoteMeta call for the self-update capability check. self-update is removed from PILOT_DISABLED_CAPABILITIES so a Compose-deployed pilot can advertise it; the host-console filter stays. SelfUpdateService still gates the local-end capability on the container actually carrying docker-compose labels, so a docker-run pilot self-disables and the Fleet UI sees a clean 503 instead of an ambiguous failure. Tests: new fleet-pilot-update covers the four single-node branches (success via loopback, null target, no self-update capability, meta offline) and two update-all cases (mixed-fleet dispatch, all-targets- null skip). capability-registry-pilot rewired to assert the new filter set.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Response>) {
|
||||
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<string, string> | undefined;
|
||||
mockFetch((url, init) => {
|
||||
postedUrl = url;
|
||||
postedHeaders = (init?.headers as Record<string, string>) ?? 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([]);
|
||||
});
|
||||
});
|
||||
+29
-20
@@ -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<string, string> = { '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<void> => {
|
||||
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 };
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user