mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +00:00
e176ae9f17
MeshService.inspectStackServices used DockerController.getInstance(nodeId)
to enumerate compose-labeled containers, but NodeRegistry.getDocker
explicitly throws for any node with type='remote' by design. The throw
was silently caught and an empty array returned, so opt-in for any
remote node failed with a misleading "no running services" error and
refreshAliasCache only ever populated LOCAL aliases. Cross-node mesh
routing was therefore impossible end-to-end regardless of pilot-tunnel
state.
Split the inspector. inspectLocalStackServices keeps the Dockerode
listContainers path and always queries the local Docker daemon; it is
public so the new route can call it. inspectStackServices is now a
dispatcher: local nodes fall through to the Dockerode path, remote
nodes (proxy mode and pilot-agent) HTTP-fetch
/api/mesh/local-services/:stackName against the URL resolved by
NodeRegistry.getProxyTarget, with the persisted node_proxy Bearer
token and license tier headers attached. The remote's MeshService
enumerates its own LOCAL Docker daemon and returns the
{service, ports[]} envelope.
refreshAliasCache now inspects every opted-in stack in parallel via
Promise.allSettled so a slow or unreachable remote does not stall the
60-second loop. The new /api/mesh/local-services/:stackName route is
gated by requireAdmiral plus isValidStackName and always queries the
caller's own Sencho instance.
Together with PR #989 (proxy-side bridge dispatch) and PR #990
(agent-side loopback auth), this makes the central, bridge, agent,
local-Sencho mesh control plane functional end-to-end for both
proxy-mode and pilot-agent remotes.
152 lines
6.3 KiB
TypeScript
152 lines
6.3 KiB
TypeScript
/**
|
|
* Regression guard for the C-3 fix: MeshService inspects remote nodes via the
|
|
* existing HTTP proxy chain rather than calling Dockerode directly (which
|
|
* NodeRegistry.getDocker explicitly throws for any remote node by design).
|
|
*
|
|
* Two behaviors covered:
|
|
* 1. For a local node, the dispatcher calls `inspectLocalStackServices`
|
|
* (which uses the local Dockerode).
|
|
* 2. For a remote node, the dispatcher fetches `/api/mesh/local-services/:stackName`
|
|
* against the resolved proxy target with the appropriate Authorization
|
|
* and license tier headers, parses the JSON envelope, and returns the
|
|
* decoded `services[]` array.
|
|
*/
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let MeshService: typeof import('../services/MeshService').MeshService;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ MeshService } = await import('../services/MeshService'));
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ NodeRegistry } = await import('../services/NodeRegistry'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.restoreAllMocks();
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore both spies and the global fetch patch so a sibling test file
|
|
// running in the same worker (e.g. fleet.test.ts) does not see a stale
|
|
// mocked fetch / getProxyTarget.
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('MeshService.inspectStackServices dispatch (C-3 fix)', () => {
|
|
it('uses the local Dockerode path for the local node', async () => {
|
|
const svc = MeshService.getInstance();
|
|
const db = DatabaseService.getInstance();
|
|
const localNodeId = db.getNodes()[0].id;
|
|
|
|
const localSpy = vi
|
|
.spyOn(svc, 'inspectLocalStackServices')
|
|
.mockResolvedValue([{ service: 'echo', ports: [9000] }]);
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
|
|
const out = await (svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> })
|
|
.inspectStackServices(localNodeId, 'audit-mesh-prod');
|
|
|
|
expect(localSpy).toHaveBeenCalledWith('audit-mesh-prod');
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(out).toEqual([{ service: 'echo', ports: [9000] }]);
|
|
});
|
|
|
|
it('fetches /api/mesh/local-services for remote nodes and forwards the proxy target headers', async () => {
|
|
const svc = MeshService.getInstance();
|
|
const db = DatabaseService.getInstance();
|
|
const remoteNodeId = db.addNode({
|
|
name: 'inspect-remote-test',
|
|
type: 'remote',
|
|
mode: 'proxy',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: 'https://remote.example.com:1852',
|
|
api_token: 'remote-tok',
|
|
});
|
|
|
|
// Force the registry to return a known target so we exercise the
|
|
// request shape rather than the registry's own resolution rules.
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
|
apiUrl: 'https://remote.example.com:1852',
|
|
apiToken: 'remote-tok',
|
|
});
|
|
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValue(new Response(
|
|
JSON.stringify({ services: [{ service: 'echo', ports: [9001] }] }),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
));
|
|
|
|
const out = await (svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> })
|
|
.inspectStackServices(remoteNodeId, 'audit-mesh-pilot');
|
|
|
|
expect(out).toEqual([{ service: 'echo', ports: [9001] }]);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const call = fetchMock.mock.calls[0];
|
|
expect(String(call[0])).toBe('https://remote.example.com:1852/api/mesh/local-services/audit-mesh-pilot');
|
|
const headers = (call[1] as { headers: Record<string, string> }).headers;
|
|
expect(headers['Authorization']).toBe('Bearer remote-tok');
|
|
expect(headers).toHaveProperty('x-sencho-tier');
|
|
expect(headers).toHaveProperty('x-sencho-variant');
|
|
|
|
db.deleteNode(remoteNodeId);
|
|
});
|
|
|
|
it('returns [] when the remote responds non-2xx', async () => {
|
|
const svc = MeshService.getInstance();
|
|
const db = DatabaseService.getInstance();
|
|
const remoteNodeId = db.addNode({
|
|
name: 'inspect-remote-fail',
|
|
type: 'remote',
|
|
mode: 'proxy',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: 'https://remote.example.com:1852',
|
|
api_token: 'remote-tok',
|
|
});
|
|
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
|
apiUrl: 'https://remote.example.com:1852',
|
|
apiToken: 'remote-tok',
|
|
});
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Internal Server Error', { status: 500 }));
|
|
|
|
const out = await (svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> })
|
|
.inspectStackServices(remoteNodeId, 'audit-mesh-pilot');
|
|
|
|
expect(out).toEqual([]);
|
|
db.deleteNode(remoteNodeId);
|
|
});
|
|
|
|
it('returns [] for a remote node with no active proxy target (e.g. pilot-agent tunnel down)', async () => {
|
|
const svc = MeshService.getInstance();
|
|
const db = DatabaseService.getInstance();
|
|
const remoteNodeId = db.addNode({
|
|
name: 'inspect-remote-down',
|
|
type: 'remote',
|
|
mode: 'pilot_agent',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: '',
|
|
api_token: '',
|
|
});
|
|
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null);
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
|
|
const out = await (svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> })
|
|
.inspectStackServices(remoteNodeId, 'audit-mesh-pilot');
|
|
|
|
expect(out).toEqual([]);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
db.deleteNode(remoteNodeId);
|
|
});
|
|
});
|