Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/compose-dynamic-ip-range.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
109 changes: 109 additions & 0 deletions test/unit/ci/compose-network.spec.ts
Original file line number Diff line number Diff line change
@@ -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, { ipv4_address?: string } | string> | string[]
}

type Network = {
ipam?: { config?: Array<{ subnet?: string; gateway?: string; ip_range?: string }> }
}

type Compose = { services?: Record<string, Service>; networks?: Record<string, Network> }

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)
}
})
})
Loading