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
11 changes: 11 additions & 0 deletions .changeset/tidy-donkeys-sweep.md
Original file line number Diff line number Diff line change
@@ -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' })
```
91 changes: 91 additions & 0 deletions src/testcontainers/Sweep.ts
Original file line number Diff line number Diff line change
@@ -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<compose.ReturnType> {
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<string>()
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
}
}
1 change: 1 addition & 0 deletions src/testcontainers/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * as Instance from './Instance.js'
export * as Sweep from './Sweep.js'
144 changes: 144 additions & 0 deletions src/testcontainers/sweep.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}

function client(parameters: {
containers: ContainerFixture[]
networks: Record<string, { Id: string }[]>
}) {
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'])
})
Loading