diff --git a/.changeset/tidy-donkeys-sweep.md b/.changeset/tidy-donkeys-sweep.md new file mode 100644 index 0000000..84fcf07 --- /dev/null +++ b/.changeset/tidy-donkeys-sweep.md @@ -0,0 +1,11 @@ +--- +'prool': patch +--- + +Added `Sweep.compose` to `prool/testcontainers` for removing Compose containers and networks orphaned by interrupted runs. + +```ts +import { Sweep } from 'prool/testcontainers' + +await Sweep.compose({ composeFile: 'test/compose.yaml' }) +``` diff --git a/src/testcontainers/Sweep.ts b/src/testcontainers/Sweep.ts new file mode 100644 index 0000000..783c7df --- /dev/null +++ b/src/testcontainers/Sweep.ts @@ -0,0 +1,91 @@ +import { resolve } from 'node:path' +import { + type ContainerRuntimeClient, + getContainerRuntimeClient, +} from 'testcontainers' + +/** + * Removes Compose containers and networks that interrupted runs left behind. + * + * Testcontainers' reaper only cleans sessions whose reaper container outlives + * them; a killed daemon, host reboot, or removed reaper orphans its sessions + * permanently. Run this before creating a pool to clear that residue. + * + * Only sessions created from `composeFile` are touched, so other projects' + * sessions survive. Running containers and sessions younger than `minAgeMs` + * are kept: a concurrent run's containers pass through `created` on startup. + * + * @example + * ```ts + * import { Sweep } from 'prool/testcontainers' + * + * await Sweep.compose({ composeFile: 'test/compose.yaml' }) + * ``` + */ +export async function compose( + parameters: compose.Parameters, +): Promise { + const { + client = await getContainerRuntimeClient(), + minAgeMs = 60_000, + now = Date.now(), + } = parameters + // Compose stamps each session with the absolute path of its config file. + const configFile = resolve(parameters.composeFile) + const stale = await client.container.dockerode.listContainers({ + all: true, + filters: { + label: [`com.docker.compose.project.config_files=${configFile}`], + status: ['created', 'dead', 'exited'], + }, + }) + + const projects = new Set() + let containers = 0 + for (const info of stale) { + if (now / 1_000 - info.Created < minAgeMs / 1_000) continue + const project = info.Labels['com.docker.compose.project'] + if (project) projects.add(project) + // Removals race the reaper and sibling sweeps; a missing container is fine. + await client.container + .remove(client.container.getById(info.Id)) + .then(() => containers++) + .catch(() => {}) + } + + let networks = 0 + for (const project of projects) { + const orphaned = await client.container.dockerode.listNetworks({ + filters: { label: [`com.docker.compose.project=${project}`] }, + }) + // A network still serving a live container refuses removal; leave it. + for (const network of orphaned) + await client.container.dockerode + .getNetwork(network.Id) + .remove() + .then(() => networks++) + .catch(() => {}) + } + + return { containers, networks } +} + +export declare namespace compose { + export type Parameters = { + /** Container runtime client. Defaults to the Testcontainers runtime. */ + client?: ContainerRuntimeClient | undefined + /** Path to the Compose file whose sessions are swept. */ + composeFile: string + /** Sessions younger than this are kept, in milliseconds. @default 60_000 */ + minAgeMs?: number | undefined + /** Current epoch milliseconds, for deterministic tests. */ + now?: number | undefined + } + + export type ReturnType = { + /** Containers removed. */ + containers: number + /** Networks removed. */ + networks: number + } +} diff --git a/src/testcontainers/index.ts b/src/testcontainers/index.ts index 91946a9..63bc233 100644 --- a/src/testcontainers/index.ts +++ b/src/testcontainers/index.ts @@ -1 +1,2 @@ export * as Instance from './Instance.js' +export * as Sweep from './Sweep.js' diff --git a/src/testcontainers/sweep.test.ts b/src/testcontainers/sweep.test.ts new file mode 100644 index 0000000..500bd8d --- /dev/null +++ b/src/testcontainers/sweep.test.ts @@ -0,0 +1,144 @@ +import { resolve } from 'node:path' +import { Sweep } from 'prool/testcontainers' +import type { ContainerRuntimeClient } from 'testcontainers' +import { expect, test, vi } from 'vitest' + +type ContainerFixture = { + Created: number + Id: string + Labels: Record +} + +function client(parameters: { + containers: ContainerFixture[] + networks: Record +}) { + const containerFilters: unknown[] = [] + const removedContainers: string[] = [] + const removedNetworks: string[] = [] + const remove = vi.fn(async (container: { id: string }) => { + removedContainers.push(container.id) + }) + const runtime = { + container: { + dockerode: { + getNetwork(id: string) { + return { + async remove() { + removedNetworks.push(id) + }, + } + }, + async listContainers(options: { filters: unknown }) { + containerFilters.push(options.filters) + return parameters.containers + }, + async listNetworks(options: { + filters: { label: [`com.docker.compose.project=${string}`] } + }) { + const project = options.filters.label[0].split('=')[1] as string + return parameters.networks[project] ?? [] + }, + }, + getById: (id: string) => ({ id }), + remove, + }, + } + return { + client: runtime as unknown as ContainerRuntimeClient, + containerFilters, + remove, + removedContainers, + removedNetworks, + } +} + +test('removes stale sessions and their networks, scoped by compose file', async () => { + const { client: runtime, ...calls } = client({ + containers: [ + { + Created: 100, + Id: 'stale-postgres', + Labels: { 'com.docker.compose.project': 'testcontainers-aaa' }, + }, + { + Created: 100, + Id: 'stale-api', + Labels: { 'com.docker.compose.project': 'testcontainers-aaa' }, + }, + { + Created: 980, + Id: 'young-postgres', + Labels: { 'com.docker.compose.project': 'testcontainers-bbb' }, + }, + ], + networks: { + 'testcontainers-aaa': [{ Id: 'network-aaa' }], + }, + }) + + const result = await Sweep.compose({ + client: runtime, + composeFile: 'test/compose.yaml', + now: 1_000_000, + }) + + expect(result).toEqual({ containers: 2, networks: 1 }) + expect(calls.removedContainers).toEqual(['stale-postgres', 'stale-api']) + expect(calls.removedNetworks).toEqual(['network-aaa']) + // Docker filters restrict the listing to this file's stopped sessions. + expect(calls.containerFilters).toEqual([ + { + label: [ + `com.docker.compose.project.config_files=${resolve('test/compose.yaml')}`, + ], + status: ['created', 'dead', 'exited'], + }, + ]) +}) + +test('keeps sessions younger than the age floor', async () => { + const { client: runtime, ...calls } = client({ + containers: [ + { + Created: 970, + Id: 'starting-postgres', + Labels: { 'com.docker.compose.project': 'testcontainers-ccc' }, + }, + ], + networks: { 'testcontainers-ccc': [{ Id: 'network-ccc' }] }, + }) + + const result = await Sweep.compose({ + client: runtime, + composeFile: 'compose.yaml', + now: 1_000_000, + }) + + expect(result).toEqual({ containers: 0, networks: 0 }) + expect(calls.removedContainers).toEqual([]) + expect(calls.removedNetworks).toEqual([]) +}) + +test('counts only removals that succeed', async () => { + const { client: runtime, ...calls } = client({ + containers: [ + { + Created: 100, + Id: 'stale-postgres', + Labels: { 'com.docker.compose.project': 'testcontainers-ddd' }, + }, + ], + networks: { 'testcontainers-ddd': [{ Id: 'network-ddd' }] }, + }) + calls.remove.mockRejectedValueOnce(new Error('no such container')) + + const result = await Sweep.compose({ + client: runtime, + composeFile: 'compose.yaml', + now: 1_000_000, + }) + + expect(result).toEqual({ containers: 0, networks: 1 }) + expect(calls.removedNetworks).toEqual(['network-ddd']) +})