diff --git a/.changeset/compose-dynamic-ip-range.md b/.changeset/compose-dynamic-ip-range.md new file mode 100644 index 000000000..5486aa8a0 --- /dev/null +++ b/.changeset/compose-dynamic-ip-range.md @@ -0,0 +1,9 @@ +--- +"nostream": patch +--- + +fix(compose): keep dynamic container addresses off the static ones + +`docker-compose.yml` pins `nostream`, `nostream-db` and `nostream-cache` to `10.10.10.2`–`.4`, but dynamic allocation drew from the bottom of the same `/24`. The observability stack has no dependency that orders it after `nostream`, so `otel-collector` could be given `.2` and `nostream` then failed to start with `Address already in use` — most reliably after a host reboot, when the daemon starts containers in arbitrary order. + +Dynamic allocation is now confined to `10.10.10.128/26`. The subnet and gateway are unchanged, so `10.10.10.1` stays the trusted proxy, and the overlay pins (`.5`, `.252`, `.253`) stay outside the pool. diff --git a/docker-compose.yml b/docker-compose.yml index 564b3a717..b803246c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -210,7 +210,17 @@ networks: ipam: driver: default config: + # Dynamic addresses are confined to .128/26 so they can never land on a + # static one: nostream is .2, and the observability stack takes the first + # three free addresses — which were .2, .3 and .4, taken by nostream, + # nostream-db and nostream-cache. Docker allocates from the bottom of the + # subnet, and otel-collector has no dependency that would order it after + # nostream, so whichever container came up first won the address and the + # other failed with "Address already in use". The overlays pin .5 + # (local), .252 (i2p) and .253 (tor) for the same reason. - subnet: 10.10.10.0/24 + gateway: 10.10.10.1 + ip_range: 10.10.10.128/26 volumes: cache: diff --git a/test/unit/ci/compose-network.spec.ts b/test/unit/ci/compose-network.spec.ts new file mode 100644 index 000000000..e013c44f8 --- /dev/null +++ b/test/unit/ci/compose-network.spec.ts @@ -0,0 +1,109 @@ +import chai from 'chai' +import { readFileSync, readdirSync } from 'fs' +import yaml from 'js-yaml' +import { join } from 'path' + +const { expect } = chai + +/** + * Docker allocates dynamic addresses from the bottom of a subnet, while several + * services are pinned by `ipv4_address`. Without an `ip_range` the two pools + * overlap, and which container wins is decided by creation order — so + * `otel-collector` could take `10.10.10.2` from `nostream` and the relay then + * fails with "Address already in use" (#929). These checks hold the pools apart. + */ + +type Service = { + networks?: Record | string[] +} + +type Network = { + ipam?: { config?: Array<{ subnet?: string; gateway?: string; ip_range?: string }> } +} + +type Compose = { services?: Record; networks?: Record } + +const composeFiles = (): string[] => + readdirSync(process.cwd()) + .filter((file) => /^docker-compose(\.[\w-]+)?\.ya?ml$/.test(file)) + .sort() + +const load = (file: string): Compose => + yaml.load(readFileSync(join(process.cwd(), file), 'utf-8')) as Compose + +/** Every static address a compose file pins, as `file: service -> address`. */ +const staticAddresses = (): Array<{ file: string; service: string; address: string }> => + composeFiles().flatMap((file) => { + const services = load(file).services ?? {} + + return Object.entries(services).flatMap(([name, service]) => { + const networks = service.networks + if (!networks || Array.isArray(networks) || typeof networks === 'string') return [] + + return Object.values(networks) + .filter((entry): entry is { ipv4_address?: string } => typeof entry === 'object' && entry !== null) + .filter((entry) => typeof entry.ipv4_address === 'string') + .map((entry) => ({ file, service: name, address: entry.ipv4_address as string })) + }) + }) + +const ipv4ToInt = (address: string): number => + address.split('.').reduce((total, octet) => total * 256 + Number(octet), 0) + +const parseCidr = (cidr: string): { base: number; size: number } => { + const [address, bits] = cidr.split('/') + + return { base: ipv4ToInt(address), size: 2 ** (32 - Number(bits)) } +} + +const contains = (cidr: string, address: string): boolean => { + const { base, size } = parseCidr(cidr) + const value = ipv4ToInt(address) + + return value >= base && value < base + size +} + +describe('docker-compose network pools', () => { + const defaultNetwork = load('docker-compose.yml').networks?.default + const ipam = defaultNetwork?.ipam?.config?.[0] ?? {} + + it('declares an ip_range beside the static addresses', () => { + expect(ipam.subnet, 'docker-compose.yml must declare a subnet').to.be.a('string') + expect( + ipam.ip_range, + 'docker-compose.yml must confine dynamic allocation with ip_range, or it can hand a pinned address to another container' + ).to.be.a('string') + }) + + it('keeps the dynamic range inside the subnet', () => { + const { base, size } = parseCidr(ipam.ip_range as string) + const subnet = parseCidr(ipam.subnet as string) + + expect(base).to.be.at.least(subnet.base) + expect(base + size).to.be.at.most(subnet.base + subnet.size) + }) + + it('keeps the gateway outside the dynamic range', () => { + expect(contains(ipam.subnet as string, ipam.gateway as string), 'gateway must be in the subnet').to.equal(true) + expect(contains(ipam.ip_range as string, ipam.gateway as string), 'gateway must not be allocatable').to.equal( + false + ) + }) + + it('keeps every static address outside the dynamic range', () => { + const addresses = staticAddresses() + + expect(addresses.length, 'no static addresses found — the extraction is broken').to.be.greaterThan(0) + + for (const { file, service, address } of addresses) { + expect( + contains(ipam.subnet as string, address), + `${file}: ${service} pins ${address}, which is outside the declared subnet` + ).to.equal(true) + expect( + contains(ipam.ip_range as string, address), + `${file}: ${service} pins ${address}, which is inside the dynamic pool ${String(ipam.ip_range)}` + ).to.equal(false) + } + }) +})