mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 19:27:41 +00:00
fix(mesh): auto-fallback through candidate subnets when default overlaps (#1156)
The default mesh subnet 172.30.0.0/24 is fully contained in linuxserver/* default networks (sonarr_default 172.30.0.0/16, etc.), so libnetwork rejects the IPAM allocation with "Pool overlaps with other one on this address space" on a typical homelab Docker host. The single hard-coded default left first-run operators with a silently broken mesh. MeshService.setupMeshNetwork now resolves the subnet via three paths: 1. Operator-explicit (SENCHO_MESH_SUBNET set): use that subnet, strict. Pre-existing sencho_mesh with a different subnet still raises subnet_mismatch. 2. Adopt-existing (env unset, sencho_mesh already on the daemon): adopt the existing subnet. Docker is the source of truth across restarts. 3. Candidate iteration (env unset, no existing network): walk 172.30.0.0/24, 172.31.0.0/24, 10.42.0.0/24, 10.43.0.0/24 in order. First subnet Docker accepts wins. If every candidate overlaps, record subnet_overlap with a message naming every attempt. The dashboard's Fleet Heartbeat card now surfaces the down state via a compact banner above the per-node rows, plus a "mesh down" counter suffix on the right of the title. The existing Routing-tab banner is extracted into a shared MeshDataPlaneBanner component with tab and card variants. Dashboard polling is gated on Admiral tier so non-paid users do not fire the Admiral-only /mesh/status endpoint. Six new tests in mesh-setup-error-classification cover: iterates past first overlap, all candidates overlap, adopts existing network, inspectNetwork non-404 failure classified as attach_failed, env-matches- existing skip-create, and operator-explicit strict (no fallback). Fixes F-1 in the pre-1.0 audit. Closes the silent-failure mode that left the mesh down on the most common homelab Docker layout.
This commit is contained in:
@@ -656,36 +656,6 @@ describe('getSenchoIpFromSubnet', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshService.ensureMeshNetwork', () => {
|
||||
it('refuses to continue when sencho_mesh exists with a different subnet', async () => {
|
||||
const svc = MeshService.getInstance();
|
||||
const dcModule = await import('../services/DockerController');
|
||||
const fakeController = {
|
||||
createNetwork: vi.fn().mockRejectedValue({ statusCode: 409, message: 'network already exists' }),
|
||||
inspectNetwork: vi.fn().mockResolvedValue({ IPAM: { Config: [{ Subnet: '10.99.0.0/24' }] } }),
|
||||
};
|
||||
vi.spyOn(dcModule.default, 'getInstance').mockReturnValue(fakeController as unknown as ReturnType<typeof dcModule.default.getInstance>);
|
||||
|
||||
await expect(
|
||||
(svc as unknown as { ensureMeshNetwork: (s: string) => Promise<void> }).ensureMeshNetwork('172.30.0.0/24'),
|
||||
).rejects.toThrow(/exists with subnet 10\.99\.0\.0\/24/);
|
||||
});
|
||||
|
||||
it('treats 409 with matching subnet as idempotent success', async () => {
|
||||
const svc = MeshService.getInstance();
|
||||
const dcModule = await import('../services/DockerController');
|
||||
const fakeController = {
|
||||
createNetwork: vi.fn().mockRejectedValue({ statusCode: 409, message: 'network already exists' }),
|
||||
inspectNetwork: vi.fn().mockResolvedValue({ IPAM: { Config: [{ Subnet: '172.30.0.0/24' }] } }),
|
||||
};
|
||||
vi.spyOn(dcModule.default, 'getInstance').mockReturnValue(fakeController as unknown as ReturnType<typeof dcModule.default.getInstance>);
|
||||
|
||||
await expect(
|
||||
(svc as unknown as { ensureMeshNetwork: (s: string) => Promise<void> }).ensureMeshNetwork('172.30.0.0/24'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshService.optInStack rollback', () => {
|
||||
it('rolls back the DB row when the just-inserted stack fails to push its override', async () => {
|
||||
const svc = MeshService.getInstance();
|
||||
|
||||
@@ -204,3 +204,127 @@ describe('MeshService.setupMeshNetwork failure classification', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user