Files
sencho/backend/src/__tests__/mesh-compose-override.test.ts
T
Anso 23bbee4f45 feat(mesh): replace host-mode with shared sencho_mesh Docker network (#1009)
* feat(mesh): replace host-mode with shared sencho_mesh Docker network

Phase D of the mesh redesign: drop the operator's `network_mode: host`
requirement and the `host-gateway` extra_hosts pattern that did not work
on cloud iptables-restrictive distros (OCI, etc.) or Docker Desktop.

Each Sencho creates a shared `sencho_mesh` Docker bridge network on
boot (default subnet 172.30.0.0/24, override via SENCHO_MESH_SUBNET),
pins itself at `<network>+2`, and attaches every meshed user service to
the same bridge. Compose overrides now emit IP-based `extra_hosts` plus
a top-level `networks` block declaring `sencho_mesh` external.

Override delivery: central renders for local stacks; for remote stacks
it sends the fleet alias list to the remote's new `PUT /api/mesh/local-
override/:stackName` endpoint, which renders against the remote's OWN
local senchoIp and writes under its OWN DATA_DIR. Each node may use a
different subnet without coordination beyond the env var.

Opt-in / opt-out now trigger an automatic redeploy of the affected
stack via the existing deploy code path (local: ComposeService; remote:
HTTP POST through proxyFetch). The frontend opt-in sheet shows a
confirmation modal (ConfirmModal) before the mutation. Failed
redeploys emit both a mesh activity event and a durable audit-log row.

Hardening:
- Reserve port 1852 at opt-in (prevents user containers from racing
  the Sencho API listener).
- ensureMeshNetwork refuses to continue if `sencho_mesh` exists with a
  mismatched subnet rather than silently routing to the wrong IP.
- Idempotent network connect/disconnect helpers in DockerController.
- optInStack rolls back the DB row if the just-inserted stack's
  override push fails (no half-states surviving across calls).
- regenerateOverridesForNode runs in parallel and skips the just-
  pushed stack on opt-in.

Operator template: drop `network_mode: host`, restore
`ports: ["1852:1852"]`. Mesh now works identically on Linux LAN, OCI,
and Docker Desktop without firewall changes.

Docs: rewrite docs/features/sencho-mesh.mdx around the shared bridge
network, document SENCHO_MESH_SUBNET, surface the host-network-service
opt-in restriction, and cross-link with the Pilot Agent docs.

BREAKING CHANGE: the operator's `docker-compose.yml` no longer uses
`network_mode: host`. After upgrading, redeploy any meshed stacks once
so they pick up the new IP-based override and join `sencho_mesh`.

* fix(mesh): wrap stackName with path.basename in local-override fs ops

CodeQL flagged js/path-injection on the new applyLocalOverride and
removeLocalOverride methods because they are publicly reachable and
its data-flow model does not recognize isValidStackName /
isPathWithinBase as sanitizers. The validation IS sufficient (the
allowlist regex blocks path separators, the path-prefix check blocks
escape), but path.basename is a model CodeQL recognizes and is purely
defensive: for any input that already passes isValidStackName,
basename is the identity.
2026-05-09 00:11:09 -04:00

105 lines
3.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import * as YAML from 'yaml';
import { buildAliasHosts, generateOverrideYaml, SENCHO_MESH_NETWORK } from '../services/MeshComposeOverride';
const SENCHO_IP = '172.30.0.2';
describe('generateOverrideYaml', () => {
it('emits services with extra_hosts pointing to the Sencho mesh IP', () => {
const yaml = generateOverrideYaml({
services: ['web', 'cache'],
aliases: [
{ host: 'db.api.opsix.sencho' },
{ host: 'etl.worker.opsix.sencho' },
],
senchoIp: SENCHO_IP,
});
const parsed = YAML.parse(yaml) as Record<string, unknown>;
const services = parsed.services as Record<string, { extra_hosts: string[]; networks: 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:${SENCHO_IP}`,
`etl.worker.opsix.sencho:${SENCHO_IP}`,
]);
}
});
it('attaches every service to the sencho_mesh network', () => {
const yaml = generateOverrideYaml({
services: ['web', 'cache', 'worker'],
aliases: [{ host: 'db.api.opsix.sencho' }],
senchoIp: SENCHO_IP,
});
const parsed = YAML.parse(yaml) as Record<string, unknown>;
const services = parsed.services as Record<string, { networks: string[] }>;
for (const svc of ['web', 'cache', 'worker']) {
expect(services[svc].networks).toEqual([SENCHO_MESH_NETWORK]);
}
});
it('declares sencho_mesh as an external network at the top level', () => {
const yaml = generateOverrideYaml({
services: ['web'],
aliases: [],
senchoIp: SENCHO_IP,
});
const parsed = YAML.parse(yaml) as Record<string, unknown>;
const networks = parsed.networks as Record<string, { external: boolean }>;
expect(networks[SENCHO_MESH_NETWORK]).toEqual({ external: true });
});
it('still attaches services to the network when no aliases exist yet', () => {
const yaml = generateOverrideYaml({
services: ['web'],
aliases: [],
senchoIp: SENCHO_IP,
});
const parsed = YAML.parse(yaml) as Record<string, unknown>;
const services = parsed.services as Record<string, { extra_hosts?: string[]; networks: string[] }>;
expect(services.web.extra_hosts).toBeUndefined();
expect(services.web.networks).toEqual([SENCHO_MESH_NETWORK]);
});
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' },
],
senchoIp: SENCHO_IP,
});
const b = generateOverrideYaml({
services: ['cache', 'web'],
aliases: [
{ host: 'a.x.y.sencho' },
{ host: 'b.x.y.sencho' },
],
senchoIp: SENCHO_IP,
});
expect(a).toBe(b);
});
it('uses the senchoIp argument verbatim in extra_hosts entries', () => {
const customIp = '10.42.7.99';
const yaml = generateOverrideYaml({
services: ['web'],
aliases: [{ host: 'svc.stack.node.sencho' }],
senchoIp: customIp,
});
expect(yaml).toContain(`svc.stack.node.sencho:${customIp}`);
});
});
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']);
});
});