mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
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.
This commit is contained in:
@@ -663,3 +663,90 @@ describe('removeContainers', () => {
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Network connect / disconnect helpers ───────────────────────────────
|
||||
|
||||
describe('DockerController - connectContainerToNetwork', () => {
|
||||
it('attaches a container to a network with no static IP', async () => {
|
||||
const connect = vi.fn().mockResolvedValue(undefined);
|
||||
mockDocker.getNetwork.mockReturnValue({ connect });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234');
|
||||
|
||||
expect(mockDocker.getNetwork).toHaveBeenCalledWith('sencho_mesh');
|
||||
expect(connect).toHaveBeenCalledWith({ Container: 'sencho-host-1234' });
|
||||
});
|
||||
|
||||
it('attaches with a static IPv4 address when provided', async () => {
|
||||
const connect = vi.fn().mockResolvedValue(undefined);
|
||||
mockDocker.getNetwork.mockReturnValue({ connect });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234', { ipv4Address: '172.30.0.2' });
|
||||
|
||||
expect(connect).toHaveBeenCalledWith({
|
||||
Container: 'sencho-host-1234',
|
||||
EndpointConfig: { IPAMConfig: { IPv4Address: '172.30.0.2' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('treats 403 already-connected as success (idempotent)', async () => {
|
||||
const connect = vi.fn().mockRejectedValue({ statusCode: 403, message: 'endpoint already exists' });
|
||||
mockDocker.getNetwork.mockReturnValue({ connect });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rethrows non-idempotent errors', async () => {
|
||||
const connect = vi.fn().mockRejectedValue({ statusCode: 500, message: 'server error' });
|
||||
mockDocker.getNetwork.mockReturnValue({ connect });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({
|
||||
statusCode: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows a 403 whose message is unrelated to already-attached', async () => {
|
||||
const connect = vi.fn().mockRejectedValue({ statusCode: 403, message: 'host-mode container cannot join network' });
|
||||
mockDocker.getNetwork.mockReturnValue({ connect });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerController - disconnectContainerFromNetwork', () => {
|
||||
it('detaches a container from a network with force=true', async () => {
|
||||
const disconnect = vi.fn().mockResolvedValue(undefined);
|
||||
mockDocker.getNetwork.mockReturnValue({ disconnect });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234');
|
||||
|
||||
expect(mockDocker.getNetwork).toHaveBeenCalledWith('sencho_mesh');
|
||||
expect(disconnect).toHaveBeenCalledWith({ Container: 'sencho-host-1234', Force: true });
|
||||
});
|
||||
|
||||
it('treats 404 not-connected as success (idempotent)', async () => {
|
||||
const disconnect = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network endpoint' });
|
||||
mockDocker.getNetwork.mockReturnValue({ disconnect });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rethrows non-idempotent errors', async () => {
|
||||
const disconnect = vi.fn().mockRejectedValue({ statusCode: 500, message: 'server error' });
|
||||
mockDocker.getNetwork.mockReturnValue({ disconnect });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({
|
||||
statusCode: 500,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user