mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
64bf6344a3
* fix(mesh): reserve Sencho static IP via IPAM auxiliary address (F-13)
Sencho pins itself to <network>+2 on sencho_mesh, but the IPAM block only
declared Subnet, so Docker freely handed that address to any meshed
workload that restarted while Sencho was offline. A real-world hit on
arrapps-prod during a Sencho upgrade had tautulli grab 172.30.0.2, which
blocked the new Sencho container's mesh attach with "Address already in
use" and left compose in a half-state needing manual disconnect/recreate.
The fix reserves <network>+2 via AuxiliaryAddresses on the IPAM Config
when creating sencho_mesh. Aux-listed addresses are removed from the
auto-allocatable pool, so Docker refuses to hand the IP to any container
that does not explicitly request it. Sencho's own attach via
connectContainerToNetwork({ ipv4Address }) is unaffected because
explicit pins still bind aux-reserved addresses. Workload containers
without a pinned IP get .3 and up.
Wire format verified against the Docker Engine REST v1.33 OpenAPI spec:
the JSON key on POST /networks/create (and on the inspect response) is
AuxiliaryAddresses inside each IPAM.Config item, value { sencho: <ip> }.
Adopt-existing path: when Sencho boots against a sencho_mesh that
pre-dates this reservation, the data plane still comes up but a one-time
warn fires (mesh.enable activity at level: 'warn' plus a [Mesh] console
line so docker logs surfaces it). The advisory explains the squat risk
and gives the recreate recipe.
Tests: 5 cases added to mesh-setup-error-classification.test.ts covering
the explicit-env create payload, the candidate-iteration winner payload,
the adopt-legacy warn (env-unset), the adopt-already-reserved silent
path, and the TOCTOU 409 race-winner adopt-legacy warn.
Docs: one sentence added to docs/features/sencho-mesh.mdx under
"Customising the mesh subnet" describing the reservation positively.
No tier/role/capability/flag gates touched (no frontend changes).
* fix(mesh): use IPRange upper-half bias instead of aux-address reservation
The initial F-13 fix used IPAM AuxiliaryAddresses to reserve <network>+2
on sencho_mesh. Empirical probe against Docker 29.4.3 confirmed this
also blocks explicit pins via EndpointConfig.IPAMConfig.IPv4Address:
libnetwork's RequestAddress() rejects a preferred-address request when
the bit is already set by the aux reservation. Result: the freshly-
reserved network refuses Sencho's own ensureSelfAttached, and the data
plane never comes up.
The pivot uses IPRange instead. IPRange constrains Docker's auto-
allocation to the configured CIDR; preferred-address requests via
RequestAddress(prefAddress) skip the IPRange check and only consult
the subnet-wide bitmap. So setting IPRange to the upper half of the
subnet (e.g. 172.30.0.128/25 for 172.30.0.0/24) biases workloads
without an explicit IP to <network>+128 and up, while Sencho's
explicit pin to <network>+2 still succeeds.
Verified on Docker 29.4.3 with the same workstation that produced the
original audit:
- `docker run --rm --network N --ip 10.99.99.2` against a network
with `--aux-address sencho=10.99.99.2` → "Address already in use"
(rejects explicit pin).
- Same `--ip` against a network with `--ip-range 10.99.99.128/25` →
succeeds (10.99.99.2 is outside the range but inside the subnet).
- Auto-allocated workload on the IPRange network lands at .129.
Adopt-existing legacy detection now compares IPRange instead of
AuxiliaryAddresses. inspectExistingMeshSubnet returns { subnet,
ipRange } and the warn fires when ipRange differs from the expected
upper-half CIDR. Same once-per-process semantics as before.
createMeshNetwork now derives the IPRange via a new
getMeshIpRangeFromSubnet helper. The five regression tests assert
IPRange = <network>+128/<prefix+1> in the create payload and the
expected/actual IPRange in the legacy-warn details.
635 lines
26 KiB
TypeScript
635 lines
26 KiB
TypeScript
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
import type { MeshActivityEvent, MeshDataPlaneStatus } from '../services/MeshService';
|
|
|
|
let tmpDir: string;
|
|
let MeshService: typeof import('../services/MeshService').MeshService;
|
|
let DockerController: typeof import('../services/DockerController').default;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ MeshService } = await import('../services/MeshService'));
|
|
({ default: DockerController } = await import('../services/DockerController'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
type MutableSvc = {
|
|
activity: MeshActivityEvent[];
|
|
senchoIp: string | null;
|
|
meshSubnet: string;
|
|
networkSetupError: string | null;
|
|
dataPlaneStatus: MeshDataPlaneStatus;
|
|
};
|
|
|
|
let prevSubnetEnv: string | undefined;
|
|
let prevHostnameEnv: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
prevSubnetEnv = process.env.SENCHO_MESH_SUBNET;
|
|
prevHostnameEnv = process.env.HOSTNAME;
|
|
const svc = MeshService.getInstance() as unknown as MutableSvc;
|
|
svc.activity = [];
|
|
svc.senchoIp = null;
|
|
svc.meshSubnet = '172.30.0.0/24';
|
|
svc.networkSetupError = null;
|
|
svc.dataPlaneStatus = {
|
|
ok: false,
|
|
reason: 'not_started',
|
|
message: 'mesh data plane has not initialized yet',
|
|
subnet: '',
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (prevSubnetEnv === undefined) delete process.env.SENCHO_MESH_SUBNET;
|
|
else process.env.SENCHO_MESH_SUBNET = prevSubnetEnv;
|
|
if (prevHostnameEnv === undefined) delete process.env.HOSTNAME;
|
|
else process.env.HOSTNAME = prevHostnameEnv;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function callSetup(svc: import('../services/MeshService').MeshService): Promise<void> {
|
|
return (svc as unknown as { setupMeshNetwork: () => Promise<void> }).setupMeshNetwork();
|
|
}
|
|
|
|
function lastDisable(svc: import('../services/MeshService').MeshService): MeshActivityEvent | undefined {
|
|
const all = (svc as unknown as MutableSvc).activity;
|
|
return all.filter((e) => e.type === 'mesh.disable').slice(-1)[0];
|
|
}
|
|
|
|
type FakeController = {
|
|
createNetwork: ReturnType<typeof vi.fn>;
|
|
inspectNetwork: ReturnType<typeof vi.fn>;
|
|
connectContainerToNetwork: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
function mockDocker(overrides: Partial<FakeController> = {}): FakeController {
|
|
const fake: FakeController = {
|
|
createNetwork: vi.fn().mockResolvedValue(undefined),
|
|
inspectNetwork: vi.fn(),
|
|
connectContainerToNetwork: vi.fn().mockResolvedValue(undefined),
|
|
...overrides,
|
|
};
|
|
vi.spyOn(DockerController, 'getInstance').mockReturnValue(
|
|
fake as unknown as ReturnType<typeof DockerController.getInstance>,
|
|
);
|
|
return fake;
|
|
}
|
|
|
|
describe('MeshService.setupMeshNetwork failure classification', () => {
|
|
it('classifies an invalid CIDR as subnet_invalid', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = 'not-a-cidr';
|
|
process.env.HOSTNAME = 'sencho';
|
|
mockDocker();
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.reason).toBe('subnet_invalid');
|
|
expect(status.subnet).toBe('not-a-cidr');
|
|
const entry = lastDisable(svc);
|
|
expect(entry?.level).toBe('error');
|
|
expect(entry?.details?.reason).toBe('subnet_invalid');
|
|
});
|
|
|
|
it('classifies a Docker pool-overlap error as subnet_overlap', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
mockDocker({
|
|
createNetwork: vi.fn().mockRejectedValue(
|
|
Object.assign(new Error('Pool overlaps with other one on this address space'), { statusCode: 500 }),
|
|
),
|
|
});
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.reason).toBe('subnet_overlap');
|
|
expect(status.subnet).toBe('10.42.0.0/24');
|
|
expect(status.message).toMatch(/overlap/i);
|
|
const entry = lastDisable(svc);
|
|
expect(entry?.level).toBe('error');
|
|
expect(entry?.details?.reason).toBe('subnet_overlap');
|
|
});
|
|
|
|
it('classifies an existing-network mismatch as subnet_mismatch', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
mockDocker({
|
|
createNetwork: vi.fn().mockRejectedValue({ statusCode: 409, message: 'network already exists' }),
|
|
inspectNetwork: vi.fn().mockResolvedValue({ IPAM: { Config: [{ Subnet: '172.30.0.0/24' }] } }),
|
|
});
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.reason).toBe('subnet_mismatch');
|
|
expect(status.message).toMatch(/exists with subnet/i);
|
|
});
|
|
|
|
it('classifies an address-already-in-use attach error as ip_in_use', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
mockDocker({
|
|
connectContainerToNetwork: vi.fn().mockRejectedValue(new Error('Address already in use')),
|
|
});
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
expect(svc.getDataPlaneStatus().reason).toBe('ip_in_use');
|
|
expect(lastDisable(svc)?.level).toBe('error');
|
|
});
|
|
|
|
it('classifies a generic attach failure as attach_failed', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
mockDocker({
|
|
connectContainerToNetwork: vi.fn().mockRejectedValue(new Error('Docker daemon explosion')),
|
|
});
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
expect(svc.getDataPlaneStatus().reason).toBe('attach_failed');
|
|
});
|
|
|
|
it('records the HOSTNAME-unset path as not_in_docker at level warn', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
delete process.env.HOSTNAME;
|
|
mockDocker();
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.reason).toBe('not_in_docker');
|
|
const entry = lastDisable(svc);
|
|
expect(entry?.level).toBe('warn');
|
|
expect(entry?.details?.reason).toBe('not_in_docker');
|
|
});
|
|
|
|
it('records the 404-on-inspect path as not_in_docker at level warn', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'laptop-hostname';
|
|
mockDocker({
|
|
connectContainerToNetwork: vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such container' }),
|
|
});
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.reason).toBe('not_in_docker');
|
|
expect(lastDisable(svc)?.level).toBe('warn');
|
|
});
|
|
|
|
it('records success as ok', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
mockDocker();
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(true);
|
|
expect(status.reason).toBe('ok');
|
|
expect(status.subnet).toBe('10.42.0.0/24');
|
|
expect(status.message).toBeNull();
|
|
});
|
|
|
|
it('preserves the legacy networkSetupError getter on failure', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
mockDocker({
|
|
createNetwork: vi.fn().mockRejectedValue(
|
|
Object.assign(new Error('Pool overlaps with other one on this address space'), { statusCode: 500 }),
|
|
),
|
|
});
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
expect(svc.getNetworkSetupError()).toMatch(/overlap/i);
|
|
});
|
|
});
|
|
|
|
describe('MeshService.setupMeshNetwork subnet auto-fallback', () => {
|
|
it('iterates past the first overlapping candidate when SENCHO_MESH_SUBNET is unset', async () => {
|
|
delete process.env.SENCHO_MESH_SUBNET;
|
|
process.env.HOSTNAME = 'sencho';
|
|
const overlap = Object.assign(
|
|
new Error('Pool overlaps with other one on this address space'),
|
|
{ statusCode: 500 },
|
|
);
|
|
const createNetwork = vi.fn()
|
|
.mockRejectedValueOnce(overlap)
|
|
.mockResolvedValueOnce(undefined);
|
|
const inspectNetwork = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network' });
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(true);
|
|
expect(status.subnet).toBe('172.31.0.0/24');
|
|
expect(createNetwork).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('records subnet_overlap with every tried candidate when all candidates overlap', async () => {
|
|
delete process.env.SENCHO_MESH_SUBNET;
|
|
process.env.HOSTNAME = 'sencho';
|
|
const overlap = Object.assign(
|
|
new Error('Pool overlaps with other one on this address space'),
|
|
{ statusCode: 500 },
|
|
);
|
|
const createNetwork = vi.fn().mockRejectedValue(overlap);
|
|
const inspectNetwork = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network' });
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.reason).toBe('subnet_overlap');
|
|
expect(createNetwork).toHaveBeenCalledTimes(4);
|
|
expect(status.message).toContain('172.30.0.0/24');
|
|
expect(status.message).toContain('172.31.0.0/24');
|
|
expect(status.message).toContain('10.42.0.0/24');
|
|
expect(status.message).toContain('10.43.0.0/24');
|
|
expect(status.message).toMatch(/SENCHO_MESH_SUBNET/);
|
|
});
|
|
|
|
it('adopts an existing sencho_mesh subnet when SENCHO_MESH_SUBNET is unset', async () => {
|
|
delete process.env.SENCHO_MESH_SUBNET;
|
|
process.env.HOSTNAME = 'sencho';
|
|
const createNetwork = vi.fn();
|
|
const inspectNetwork = vi.fn().mockResolvedValue({
|
|
IPAM: { Config: [{ Subnet: '192.168.42.0/24' }] },
|
|
});
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(true);
|
|
expect(status.subnet).toBe('192.168.42.0/24');
|
|
expect(createNetwork).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('classifies a non-404 inspectNetwork failure as attach_failed without trying to create', async () => {
|
|
delete process.env.SENCHO_MESH_SUBNET;
|
|
process.env.HOSTNAME = 'sencho';
|
|
const createNetwork = vi.fn();
|
|
const inspectNetwork = vi.fn().mockRejectedValue(
|
|
Object.assign(new Error('daemon unresponsive'), { statusCode: 500 }),
|
|
);
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.reason).toBe('attach_failed');
|
|
expect(createNetwork).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips create when SENCHO_MESH_SUBNET matches the existing sencho_mesh subnet', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '172.30.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
const createNetwork = vi.fn();
|
|
const inspectNetwork = vi.fn().mockResolvedValue({
|
|
IPAM: { Config: [{ Subnet: '172.30.0.0/24' }] },
|
|
});
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(true);
|
|
expect(status.subnet).toBe('172.30.0.0/24');
|
|
expect(createNetwork).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the operator-explicit path strict (no candidate fallback)', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
const overlap = Object.assign(
|
|
new Error('Pool overlaps with other one on this address space'),
|
|
{ statusCode: 500 },
|
|
);
|
|
const createNetwork = vi.fn().mockRejectedValue(overlap);
|
|
const inspectNetwork = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network' });
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.reason).toBe('subnet_overlap');
|
|
expect(status.subnet).toBe('10.42.0.0/24');
|
|
expect(createNetwork).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('bails the candidate loop on a non-overlap createNetwork failure', async () => {
|
|
delete process.env.SENCHO_MESH_SUBNET;
|
|
process.env.HOSTNAME = 'sencho';
|
|
const overlap = Object.assign(
|
|
new Error('Pool overlaps with other one on this address space'),
|
|
{ statusCode: 500 },
|
|
);
|
|
const daemonErr = Object.assign(
|
|
new Error('daemon attach error: out of inodes'),
|
|
{ statusCode: 500 },
|
|
);
|
|
// First candidate overlaps (recoverable, advance), second hits a
|
|
// generic daemon error (unrecoverable, bail).
|
|
const createNetwork = vi.fn()
|
|
.mockRejectedValueOnce(overlap)
|
|
.mockRejectedValueOnce(daemonErr);
|
|
const inspectNetwork = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network' });
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.reason).toBe('attach_failed');
|
|
// Stops at the second candidate; does not advance to the third.
|
|
expect(createNetwork).toHaveBeenCalledTimes(2);
|
|
expect(status.subnet).toBe('172.31.0.0/24');
|
|
});
|
|
|
|
it('classifies an unparseable existing subnet as subnet_invalid on the adopt path', async () => {
|
|
delete process.env.SENCHO_MESH_SUBNET;
|
|
process.env.HOSTNAME = 'sencho';
|
|
const createNetwork = vi.fn();
|
|
const inspectNetwork = vi.fn().mockResolvedValue({
|
|
IPAM: { Config: [{ Subnet: 'not-a-cidr' }] },
|
|
});
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.reason).toBe('subnet_invalid');
|
|
expect(status.subnet).toBe('not-a-cidr');
|
|
expect(createNetwork).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('classifies a generic explicit-env createNetwork failure as attach_failed', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
const daemonErr = Object.assign(
|
|
new Error('daemon attach error: out of inodes'),
|
|
{ statusCode: 500 },
|
|
);
|
|
const createNetwork = vi.fn().mockRejectedValue(daemonErr);
|
|
const inspectNetwork = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network' });
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.reason).toBe('attach_failed');
|
|
expect(status.subnet).toBe('10.42.0.0/24');
|
|
});
|
|
|
|
it('treats a TOCTOU 409 in the explicit-env path as idempotent when the race-winner subnet matches', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
const conflict = Object.assign(
|
|
new Error('network with name sencho_mesh already exists'),
|
|
{ statusCode: 409 },
|
|
);
|
|
const createNetwork = vi.fn().mockRejectedValue(conflict);
|
|
// First inspect (pre-create probe): 404. Second inspect (post-409
|
|
// race-winner check): the matching subnet.
|
|
const inspectNetwork = vi.fn()
|
|
.mockRejectedValueOnce({ statusCode: 404, message: 'no such network' })
|
|
.mockResolvedValueOnce({ IPAM: { Config: [{ Subnet: '10.42.0.0/24' }] } });
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(true);
|
|
expect(status.subnet).toBe('10.42.0.0/24');
|
|
});
|
|
|
|
it('treats a TOCTOU 409 in the explicit-env path as subnet_mismatch when the race-winner differs', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
const conflict = Object.assign(
|
|
new Error('network with name sencho_mesh already exists'),
|
|
{ statusCode: 409 },
|
|
);
|
|
const createNetwork = vi.fn().mockRejectedValue(conflict);
|
|
const inspectNetwork = vi.fn()
|
|
.mockRejectedValueOnce({ statusCode: 404, message: 'no such network' })
|
|
.mockResolvedValueOnce({ IPAM: { Config: [{ Subnet: '172.30.0.0/24' }] } });
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.reason).toBe('subnet_mismatch');
|
|
});
|
|
});
|
|
|
|
describe('MeshService static IP reservation (F-13)', () => {
|
|
it('emits IPRange for the upper half of the subnet when creating with an explicit env subnet', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
const createNetwork = vi.fn().mockResolvedValue(undefined);
|
|
const inspectNetwork = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network' });
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
expect(svc.getDataPlaneStatus().ok).toBe(true);
|
|
expect(createNetwork).toHaveBeenCalledTimes(1);
|
|
const payload = createNetwork.mock.calls[0][0] as {
|
|
IPAM?: { Config?: Array<{ Subnet?: string; IPRange?: string }> };
|
|
};
|
|
expect(payload.IPAM?.Config?.[0]?.Subnet).toBe('10.42.0.0/24');
|
|
expect(payload.IPAM?.Config?.[0]?.IPRange).toBe('10.42.0.128/25');
|
|
});
|
|
|
|
it('emits the IPRange on the winning candidate when env is unset and the first candidate overlaps', async () => {
|
|
delete process.env.SENCHO_MESH_SUBNET;
|
|
process.env.HOSTNAME = 'sencho';
|
|
const overlap = Object.assign(
|
|
new Error('Pool overlaps with other one on this address space'),
|
|
{ statusCode: 500 },
|
|
);
|
|
const createNetwork = vi.fn()
|
|
.mockRejectedValueOnce(overlap)
|
|
.mockResolvedValueOnce(undefined);
|
|
const inspectNetwork = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network' });
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
expect(svc.getDataPlaneStatus().ok).toBe(true);
|
|
expect(svc.getDataPlaneStatus().subnet).toBe('172.31.0.0/24');
|
|
expect(createNetwork).toHaveBeenCalledTimes(2);
|
|
const winningPayload = createNetwork.mock.calls[1][0] as {
|
|
IPAM?: { Config?: Array<{ Subnet?: string; IPRange?: string }> };
|
|
};
|
|
expect(winningPayload.IPAM?.Config?.[0]?.Subnet).toBe('172.31.0.0/24');
|
|
expect(winningPayload.IPAM?.Config?.[0]?.IPRange).toBe('172.31.0.128/25');
|
|
});
|
|
|
|
it('emits a legacy warn when adopting a sencho_mesh without the upper-half IPRange', async () => {
|
|
delete process.env.SENCHO_MESH_SUBNET;
|
|
process.env.HOSTNAME = 'sencho';
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const createNetwork = vi.fn();
|
|
// Legacy network: Subnet only, no IPRange (Docker defaults auto-
|
|
// allocation to the entire subnet, including .2).
|
|
const inspectNetwork = vi.fn().mockResolvedValue({
|
|
IPAM: { Config: [{ Subnet: '172.30.0.0/24' }] },
|
|
});
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(true);
|
|
expect(status.subnet).toBe('172.30.0.0/24');
|
|
expect(createNetwork).not.toHaveBeenCalled();
|
|
|
|
const all = (svc as unknown as { activity: MeshActivityEvent[] }).activity;
|
|
const warns = all.filter((e) => e.level === 'warn' && /without the upper-half IPAM IPRange/.test(e.message));
|
|
expect(warns).toHaveLength(1);
|
|
expect(warns[0].details).toMatchObject({
|
|
subnet: '172.30.0.0/24',
|
|
expectedIpRange: '172.30.0.128/25',
|
|
actualIpRange: null,
|
|
});
|
|
|
|
const meshLines = warnSpy.mock.calls
|
|
.map((args) => String(args[0]))
|
|
.filter((line) => /\[Mesh\] sencho_mesh adopted without the upper-half IPAM IPRange/.test(line));
|
|
expect(meshLines.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('stays silent on the adopt path when the existing sencho_mesh already carries the upper-half IPRange', async () => {
|
|
delete process.env.SENCHO_MESH_SUBNET;
|
|
process.env.HOSTNAME = 'sencho';
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const createNetwork = vi.fn();
|
|
const inspectNetwork = vi.fn().mockResolvedValue({
|
|
IPAM: {
|
|
Config: [{
|
|
Subnet: '172.30.0.0/24',
|
|
IPRange: '172.30.0.128/25',
|
|
}],
|
|
},
|
|
});
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const status = svc.getDataPlaneStatus();
|
|
expect(status.ok).toBe(true);
|
|
|
|
const all = (svc as unknown as { activity: MeshActivityEvent[] }).activity;
|
|
const warns = all.filter((e) => e.level === 'warn' && /IPAM IPRange/.test(e.message));
|
|
expect(warns).toHaveLength(0);
|
|
|
|
const meshLines = warnSpy.mock.calls
|
|
.map((args) => String(args[0]))
|
|
.filter((line) => /IPAM IPRange/.test(line));
|
|
expect(meshLines).toHaveLength(0);
|
|
});
|
|
|
|
it('emits the legacy warn when a TOCTOU 409 race-winner is a legacy network', async () => {
|
|
// Operator-explicit subnet matches a race-winner that pre-dates the
|
|
// upper-half IPRange bias: another process created sencho_mesh
|
|
// between our initial inspect and our create. We adopt it, the data
|
|
// plane comes up, and the warn fires because the race-winner's IPAM
|
|
// block has no IPRange entry. Exercises the subtle assignment in
|
|
// setupMeshNetwork that reassigns existingSubnet = raceWinner.
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const conflict = Object.assign(
|
|
new Error('network with name sencho_mesh already exists'),
|
|
{ statusCode: 409 },
|
|
);
|
|
const createNetwork = vi.fn().mockRejectedValue(conflict);
|
|
const inspectNetwork = vi.fn()
|
|
.mockRejectedValueOnce({ statusCode: 404, message: 'no such network' })
|
|
.mockResolvedValueOnce({ IPAM: { Config: [{ Subnet: '10.42.0.0/24' }] } });
|
|
mockDocker({ createNetwork, inspectNetwork });
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
expect(svc.getDataPlaneStatus().ok).toBe(true);
|
|
const all = (svc as unknown as { activity: MeshActivityEvent[] }).activity;
|
|
const warns = all.filter((e) => e.level === 'warn' && /without the upper-half IPAM IPRange/.test(e.message));
|
|
expect(warns).toHaveLength(1);
|
|
expect(warns[0].details).toMatchObject({
|
|
subnet: '10.42.0.0/24',
|
|
expectedIpRange: '10.42.0.128/25',
|
|
actualIpRange: null,
|
|
});
|
|
const meshLines = warnSpy.mock.calls
|
|
.map((args) => String(args[0]))
|
|
.filter((line) => /\[Mesh\] sencho_mesh adopted without the upper-half IPAM IPRange/.test(line));
|
|
expect(meshLines.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('MeshService console.log mirror (F-5)', () => {
|
|
it('mirrors a setup failure to console.error with the [Mesh] prefix', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
process.env.HOSTNAME = 'sencho';
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
mockDocker({
|
|
createNetwork: vi.fn().mockRejectedValue(
|
|
Object.assign(new Error('Pool overlaps with other one on this address space'), { statusCode: 500 }),
|
|
),
|
|
});
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const meshLines = errSpy.mock.calls
|
|
.map((args) => String(args[0]))
|
|
.filter((line) => line.startsWith('[Mesh] data plane unavailable'));
|
|
expect(meshLines.length).toBeGreaterThan(0);
|
|
expect(meshLines[0]).toContain('subnet_overlap');
|
|
expect(meshLines[0]).toContain('10.42.0.0/24');
|
|
expect(meshLines[0]).toMatch(/overlap/i);
|
|
});
|
|
|
|
it('mirrors a not_in_docker condition to console.warn (expected dev-mode case)', async () => {
|
|
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
|
|
delete process.env.HOSTNAME;
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
mockDocker();
|
|
|
|
const svc = MeshService.getInstance();
|
|
await callSetup(svc);
|
|
|
|
const meshLines = warnSpy.mock.calls
|
|
.map((args) => String(args[0]))
|
|
.filter((line) => line.startsWith('[Mesh] data plane unavailable'));
|
|
expect(meshLines.length).toBeGreaterThan(0);
|
|
expect(meshLines[0]).toContain('not_in_docker');
|
|
});
|
|
});
|