feat(fleet): sencho mesh in traffic and routing tab (#858)

* feat(fleet): sencho mesh in traffic and routing tab

Lights up Sencho Mesh: cross-node container forwarding rendered as if the
container next to you were on localhost. Builds on the dormant TCP frame
plumbing from the prior PR (pilot tunnel TCP frames + sencho-mesh sidecar
package) and exposes the Admiral-only orchestrator surface.

Backend
- New mesh_stacks table (per-node opt-ins) + nodes.mesh_enabled column
  via DatabaseService.migrateMeshTables.
- MeshService singleton: sidecar lifecycle via Dockerode, opt-in/out with
  cascading override regeneration, request-based resolver from sidecar
  control WS, cross-node TCP forwarding via PilotTunnelManager (same-node
  fast path included), in-memory 1000-event activity ring buffer with
  durable mirror to audit_log for state-change events, per-node and
  per-route diagnostics, and the Test upstream probe.
- MeshComposeOverride: pure YAML generator that injects extra_hosts using
  host-gateway. The user's docker-compose.yml is never mutated; overrides
  live under DATA_DIR/mesh/overrides.
- ComposeService deploy/update splice the override file when the stack
  is opted in; non-mesh stacks behave identically to today.
- Pilot agent resolveMeshTarget consults the local mesh_stacks table
  (defense in depth) and resolves Compose containers via Dockerode.
- /api/mesh router with 13 Admiral-gated endpoints covering status,
  enable/disable, stack opt-in/out, alias listing, per-route diagnostic,
  Test upstream probe, per-node diagnostic, sidecar restart, activity
  log paginated and SSE.
- meshControl WS slot at /api/mesh/control validates the mesh_sidecar
  JWT minted by MeshService; dispatched as upgrade slot 2 (canonical
  order preserved).

Frontend
- New Traffic Routing tab in FleetView, gated by isAdmiral and wrapped
  in AdmiralGate. Tab uses the cyan brand glyph and italic-serif state
  typography from the audit.
- RoutingTab masthead with mesh activity drawer, per-node card grid
  with TogglePill, alias rows with five-state pill taxonomy
  (healthy / degraded / unreachable / tunnel-down / not-authorized),
  inline Test buttons.
- Four sheets: opt-in picker with port-collision inline error,
  per-route detail with diagnostic + filtered activity, per-node
  diagnostics with active streams + resolver cache + restart action,
  fleet-wide activity log with filters.
- meshRouteState helper centralizes pill-state mapping; pure-function
  tests cover all five states.

Docs
- User docs at /docs/features/sencho-mesh.mdx covering opt-in,
  troubleshooting, security model (4 guarantees + 4 explicit
  non-guarantees), and V1 limitations.
- Internal architecture and runbook pages.
- websocket-dispatch internal doc updated with the new slot.

* fix(mesh): validate stack name before path use; fix test DB lifecycle

Two surgical fixes against the prior PR.

Path-injection (CodeQL js/path-injection): MeshService.optInStack,
optOutStack, ensureStackOverride, and removeStackOverride now validate
stackName via isValidStackName from utils/validation, reject malicious
names at the API boundary, and additionally check isPathWithinBase on
the resolved override file path for defense in depth. The dataflow from
req.params.stackName to fs.writeFile no longer reaches an unsanitized
path expression.

Test DB lifecycle: mesh-service.test.ts used per-test setupTestDb /
cleanupTestDb, which deletes the temp dir while DatabaseService still
holds an open SQLite handle. On Linux CI this raises
SQLITE_READONLY_DBMOVED on the next prepare() because the inode has
been unlinked. Switched to file-scoped beforeAll/afterAll matching
agents-routes.test.ts, with a per-test beforeEach that truncates
mesh_stacks plus non-default nodes and resets the MeshService singleton
in-memory state. Adds a new test case asserting the path-traversal
rejection.

* fix(compose): use discovered compose filename instead of hardcoded docker-compose.yml

composeArgs() hardcoded `-f docker-compose.yml` for every deploy. Sencho
writes its canonical compose file as `compose.yaml`, so any stack created
via the UI failed to deploy with `open ...docker-compose.yml: no such
file or directory`.

When no mesh override applies, drop the explicit `-f` so docker compose's
built-in discovery resolves the actual filename. When an override exists,
look up the real base filename via FileSystemService.getComposeFilename()
and pass both files explicitly.

Also hoist the MeshService import to module top now that the dependency
is known to be acyclic, and revert the matching unit-test assertion.
This commit is contained in:
Anso
2026-05-01 01:50:53 -04:00
committed by GitHub
parent 6893ece898
commit 7663f4cd8b
27 changed files with 2667 additions and 13 deletions
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import * as YAML from 'yaml';
import { buildAliasHosts, generateOverrideYaml } from '../services/MeshComposeOverride';
describe('generateOverrideYaml', () => {
it('emits services with extra_hosts pointing to host-gateway', () => {
const yaml = generateOverrideYaml({
services: ['web', 'cache'],
aliases: [
{ host: 'db.api.opsix.sencho' },
{ host: 'etl.worker.opsix.sencho' },
],
});
const parsed = YAML.parse(yaml) as Record<string, unknown>;
expect(parsed.networks).toBeUndefined();
const services = parsed.services as Record<string, { extra_hosts: string[] }>;
expect(Object.keys(services).sort()).toEqual(['cache', 'web']);
for (const svc of ['web', 'cache']) {
expect(services[svc].extra_hosts).toEqual([
'db.api.opsix.sencho:host-gateway',
'etl.worker.opsix.sencho:host-gateway',
]);
}
});
it('emits empty service stubs when no aliases exist yet', () => {
const yaml = generateOverrideYaml({
services: ['web'],
aliases: [],
});
const parsed = YAML.parse(yaml) as Record<string, unknown>;
const services = parsed.services as Record<string, { extra_hosts?: string[] }>;
expect(services.web.extra_hosts).toBeUndefined();
});
it('produces stable output regardless of input ordering', () => {
const a = generateOverrideYaml({
services: ['web', 'cache'],
aliases: [
{ host: 'b.x.y.sencho' },
{ host: 'a.x.y.sencho' },
],
});
const b = generateOverrideYaml({
services: ['cache', 'web'],
aliases: [
{ host: 'a.x.y.sencho' },
{ host: 'b.x.y.sencho' },
],
});
expect(a).toBe(b);
});
});
describe('buildAliasHosts', () => {
it('maps services to alias hostnames', () => {
const out = buildAliasHosts({
nodeName: 'opsix',
stackName: 'api',
services: [{ service: 'db', ports: [5432] }, { service: 'cache', ports: [6379] }],
});
expect(out).toEqual(['db.api.opsix.sencho', 'cache.api.opsix.sencho']);
});
});
+178
View File
@@ -0,0 +1,178 @@
import { afterAll, beforeAll, beforeEach, 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;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ MeshService } = await import('../services/MeshService'));
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM mesh_stacks').run();
db.prepare('DELETE FROM nodes WHERE is_default = 0').run();
const svc = MeshService.getInstance() as unknown as {
aliasCache: Map<string, unknown>;
aliasByPort: Map<number, unknown>;
activity: unknown[];
activeStreams: Map<number, unknown>;
routeErrorMap: Map<string, unknown>;
routeLatencyMap: Map<string, unknown>;
};
svc.aliasCache = new Map();
svc.aliasByPort = new Map();
svc.activity = [];
svc.activeStreams = new Map();
svc.routeErrorMap = new Map();
svc.routeLatencyMap = new Map();
vi.restoreAllMocks();
});
describe('MeshService.optInStack', () => {
it('writes a mesh_stacks row and rejects duplicate ports', async () => {
const svc = MeshService.getInstance();
vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> }, 'inspectStackServices')
.mockResolvedValue([{ service: 'db', ports: [5432] }]);
vi.spyOn(svc as unknown as { regenerateOverridesForNode: (n: number) => Promise<void> }, 'regenerateOverridesForNode')
.mockResolvedValue(undefined);
const db = DatabaseService.getInstance();
const localNodeId = db.getNodes()[0].id;
await svc.optInStack(localNodeId, 'api', 'tester');
expect(db.isMeshStackEnabled(localNodeId, 'api')).toBe(true);
await expect(svc.optInStack(localNodeId, 'shadow', 'tester'))
.rejects.toThrow(/port 5432 is already claimed/);
});
it('opt-out removes the row and the override', async () => {
const svc = MeshService.getInstance();
vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise<unknown> }, 'inspectStackServices')
.mockResolvedValue([{ service: 'db', ports: [5432] }]);
vi.spyOn(svc as unknown as { regenerateOverridesForNode: (n: number) => Promise<void> }, 'regenerateOverridesForNode')
.mockResolvedValue(undefined);
vi.spyOn(svc as unknown as { removeStackOverride: (n: number, s: string) => Promise<void> }, 'removeStackOverride')
.mockResolvedValue(undefined);
const db = DatabaseService.getInstance();
const localNodeId = db.getNodes()[0].id;
await svc.optInStack(localNodeId, 'api', 'tester');
await svc.optOutStack(localNodeId, 'api', 'tester');
expect(db.isMeshStackEnabled(localNodeId, 'api')).toBe(false);
});
it('rejects an invalid stack name (path traversal attempt)', async () => {
const svc = MeshService.getInstance();
const db = DatabaseService.getInstance();
const localNodeId = db.getNodes()[0].id;
await expect(svc.optInStack(localNodeId, '../../etc/passwd', 'tester'))
.rejects.toThrow(/invalid stack name/);
expect(db.isMeshStackEnabled(localNodeId, '../../etc/passwd')).toBe(false);
});
});
describe('MeshService activity log', () => {
it('keeps the most recent events under the 1000-cap', () => {
const svc = MeshService.getInstance();
for (let i = 0; i < 1100; i++) {
svc.logActivity({ source: 'mesh', level: 'info', type: 'opt_in', message: `evt-${i}` });
}
const all = svc.getActivity({ limit: 2000 });
expect(all.length).toBe(1000);
expect(all[0].message).toBe('evt-100');
expect(all[all.length - 1].message).toBe('evt-1099');
});
it('filters by alias / source / level', () => {
const svc = MeshService.getInstance();
svc.logActivity({ source: 'mesh', level: 'info', type: 'opt_in', alias: 'a.b.c.sencho', message: 'a' });
svc.logActivity({ source: 'pilot', level: 'error', type: 'tunnel.fail', alias: 'a.b.c.sencho', message: 'b' });
svc.logActivity({ source: 'sidecar', level: 'info', type: 'route.resolve.ok', alias: 'x.y.z.sencho', message: 'c' });
expect(svc.getActivity({ alias: 'a.b.c.sencho' }).length).toBe(2);
expect(svc.getActivity({ source: 'pilot' }).length).toBe(1);
expect(svc.getActivity({ level: 'error' }).length).toBe(1);
});
it('subscribeActivity fires for new events and unsubscribes cleanly', () => {
const svc = MeshService.getInstance();
const seen: string[] = [];
const unsubscribe = svc.subscribeActivity((e) => seen.push(e.message));
svc.logActivity({ source: 'mesh', level: 'info', type: 'mesh.enable', message: 'one' });
unsubscribe();
svc.logActivity({ source: 'mesh', level: 'info', type: 'mesh.disable', message: 'two' });
expect(seen).toEqual(['one']);
});
});
describe('MeshService.testUpstream tunnel-down path', () => {
it('returns ok:false where=pilot_tunnel when no tunnel is registered', async () => {
const svc = MeshService.getInstance();
const db = DatabaseService.getInstance();
const localNodeId = db.getNodes()[0].id;
const remoteNodeId = db.addNode({
name: 'opsix', type: 'remote', is_default: false,
compose_dir: '/tmp', api_url: 'https://opsix.example',
api_token: 'tok', mode: 'pilot_agent',
});
(svc as unknown as { aliasCache: Map<string, unknown> }).aliasCache = new Map([
['db.api.opsix.sencho', {
host: 'db.api.opsix.sencho',
nodeId: remoteNodeId,
nodeName: 'opsix',
stackName: 'api',
serviceName: 'db',
port: 5432,
}],
]);
db.insertMeshStack(remoteNodeId, 'api', 'tester');
const result = await svc.testUpstream('db.api.opsix.sencho', localNodeId);
expect(result.ok).toBe(false);
expect(result.where).toBe('pilot_tunnel');
expect(result.code).toBe('tunnel_down');
});
it('returns ok:false where=agent_resolve when target stack is not opted in', async () => {
const svc = MeshService.getInstance();
const db = DatabaseService.getInstance();
const localNodeId = db.getNodes()[0].id;
(svc as unknown as { aliasCache: Map<string, unknown> }).aliasCache = new Map([
['db.api.opsix.sencho', {
host: 'db.api.opsix.sencho',
nodeId: localNodeId,
nodeName: 'opsix',
stackName: 'api',
serviceName: 'db',
port: 5432,
}],
]);
const result = await svc.testUpstream('db.api.opsix.sencho', localNodeId);
expect(result.ok).toBe(false);
expect(result.where).toBe('agent_resolve');
expect(result.code).toBe('denied');
});
it('returns ok:false where=sidecar when alias is unknown', async () => {
const svc = MeshService.getInstance();
const localNodeId = DatabaseService.getInstance().getNodes()[0].id;
const result = await svc.testUpstream('nonexistent.sencho', localNodeId);
expect(result.ok).toBe(false);
expect(result.where).toBe('sidecar');
expect(result.code).toBe('no_route');
});
});