mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 06:35:29 +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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user