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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "light-runner",
"version": "0.16.2",
"version": "0.17.0",
"description": "Run isolated Docker containers from Node.js",
"type": "module",
"main": "./dist/index.js",
Expand Down
11 changes: 11 additions & 0 deletions src/DockerRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ export class DockerRunner {
return operator.cleanupOrphanCache(opts);
}

/*
* Remove dangling images: untagged `<none>` layers left behind by re-pulls
* and rebuilds. Never touches a tagged image, so base images and reusable
* tags survive; any layer still referenced by a live image or container is
* refused by the daemon and skipped. Returns the count removed. Cache images
* (`run[]`-derived) are handled by cleanupOrphanCache().
*/
static async cleanupDanglingImages(): Promise<number> {
return operator.cleanupDanglingImages();
}

/*
* Remove networks whose name starts with `prefix` (default `light-runner-`)
* that have no active container connections AND are older than `maxAgeMs`
Expand Down
35 changes: 35 additions & 0 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,38 @@ export async function cleanupOrphanCache(opts: CleanupCacheOptions = {}): Promis

return removed;
}

/*
* Remove dangling images: untagged `<none>:<none>` layers left behind when a
* re-pull or rebuild moves a tag to a new image and orphans the old one. This
* is the safe half of `docker image prune`: it never touches a tagged image,
* so base images and reusable tags survive, and any layer still referenced by
* a live image or container is refused by the daemon and skipped. Cache images
* (`run[]`-derived, tagged + labelled) are handled separately by
* cleanupOrphanCache(). Returns the count removed; individual failures (in
* use, already gone, daemon refusal) are swallowed so a partial sweep is never
* surfaced as an error.
*/
export async function cleanupDanglingImages(): Promise<number> {
let images: Array<{ Id: string }>;
try {
images = (await docker.listImages({
filters: { dangling: ['true'] },
})) as Array<{ Id: string }>;
} catch {
return 0;
}

let removed = 0;
for (const info of images) {
try {
// No `force`: a layer still referenced by another image or a live
// container errors out and is left alone.
await docker.getImage(info.Id).remove();
removed += 1;
} catch {
/* in use / already removed / daemon refusal - skip, next sweep retries */
}
}
return removed;
}
10 changes: 9 additions & 1 deletion src/runner/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
* run enumeration. Pure free functions; DockerRunner forwards its static
* methods here. Nothing in this module is part of a single run's lifecycle.
*/
import { cleanupOrphanCache as cleanupCacheImpl, type CleanupCacheOptions } from '../build.js';
import {
cleanupDanglingImages as cleanupDanglingImagesImpl,
cleanupOrphanCache as cleanupCacheImpl,
type CleanupCacheOptions,
} from '../build.js';
import { RUN_ID_LABEL, reapAgeMs } from '../constants.js';
import { docker, pingDaemon } from '../docker.js';
import {
Expand Down Expand Up @@ -36,6 +40,10 @@ export function cleanupOrphanCache(opts: CleanupCacheOptions = {}): Promise<numb
return cleanupCacheImpl(opts);
}

export function cleanupDanglingImages(): Promise<number> {
return cleanupDanglingImagesImpl();
}

export function cleanupOrphanNetworks(opts: CleanupNetworkOptions = {}): Promise<number> {
return cleanupNetworksImpl(opts);
}
Expand Down
2 changes: 1 addition & 1 deletion src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { context, trace, SpanStatusCode, type Attributes, type Span, type Tracer
*/

const TRACER_NAME = 'light-runner';
const TRACER_VERSION = '0.16.2';
const TRACER_VERSION = '0.17.0';

export const tracer: Tracer = trace.getTracer(TRACER_NAME, TRACER_VERSION);

Expand Down
63 changes: 63 additions & 0 deletions test/e2e/images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { cleanupDanglingImages } from '../../src/build.js';
import { docker } from '../../src/docker.js';

// Probe the daemon, not just the CLI: this test pulls + commits, so it needs a
// live daemon (`docker --version` returns 0 even when the daemon is down).
const dockerAvailable = spawnSync('docker', ['info'], { stdio: 'ignore' }).status === 0;
const maybe = dockerAvailable ? describe : describe.skip;

const BASE = 'alpine:3.19';

function pull(image: string): Promise<void> {
return new Promise((resolve, reject) => {
docker.pull(image, (err: unknown, stream: NodeJS.ReadableStream) => {
if (err) return reject(err as Error);
docker.modem.followProgress(stream, (e: unknown) =>
e ? reject(e as Error) : resolve(),
);
});
});
}

maybe('cleanupDanglingImages', () => {
let danglingId: string | undefined;

before(async () => {
await pull(BASE);
});

after(async () => {
// Best-effort: drop the committed image if the test left it behind.
if (danglingId) {
await docker.getImage(danglingId).remove({ force: true }).catch(() => {});
}
});

it('removes an untagged image and leaves tagged base images', async () => {
// Commit a container with no repo/tag: the resulting image is dangling
// (`<none>:<none>`), the exact shape left behind by a re-pull/rebuild.
const container = await docker.createContainer({ Image: BASE, Cmd: ['true'] });
try {
const committed = (await container.commit()) as { Id: string };
danglingId = committed.Id;
} finally {
await container.remove({ force: true }).catch(() => {});
}

// Sanity: the dangling image exists before cleanup.
await assert.doesNotReject(() => docker.getImage(danglingId as string).inspect());

const removed = await cleanupDanglingImages();
assert.ok(removed >= 1, `expected at least one image removed, got ${removed}`);

// The dangling image is gone.
await assert.rejects(() => docker.getImage(danglingId as string).inspect());
danglingId = undefined;

// The tagged base image survives (never touched by a dangling sweep).
await assert.doesNotReject(() => docker.getImage(BASE).inspect());
});
});
26 changes: 20 additions & 6 deletions website/content/docs/api/classes/DockerRunner.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Defined in: [src/DockerRunner.ts:29](https://github.com/enixCode/light-runner/bl
static attach(id): Execution | null;
```

Defined in: [src/DockerRunner.ts:142](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L142)
Defined in: [src/DockerRunner.ts:153](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L153)

#### Parameters

Expand All @@ -66,13 +66,27 @@ Defined in: [src/DockerRunner.ts:142](https://github.com/enixCode/light-runner/b

***

### cleanupDanglingImages()

```ts
static cleanupDanglingImages(): Promise<number>;
```

Defined in: [src/DockerRunner.ts:133](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L133)

#### Returns

`Promise`\<`number`\>

***

### cleanupOldStates()

```ts
static cleanupOldStates(maxBytes?): number;
```

Defined in: [src/DockerRunner.ts:157](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L157)
Defined in: [src/DockerRunner.ts:168](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L168)

#### Parameters

Expand Down Expand Up @@ -112,7 +126,7 @@ Defined in: [src/DockerRunner.ts:122](https://github.com/enixCode/light-runner/b
static cleanupOrphanNetworks(opts?): Promise<number>;
```

Defined in: [src/DockerRunner.ts:133](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L133)
Defined in: [src/DockerRunner.ts:144](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L144)

#### Parameters

Expand All @@ -132,7 +146,7 @@ Defined in: [src/DockerRunner.ts:133](https://github.com/enixCode/light-runner/b
static cleanupOrphanStates(): Promise<number>;
```

Defined in: [src/DockerRunner.ts:167](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L167)
Defined in: [src/DockerRunner.ts:178](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L178)

#### Returns

Expand Down Expand Up @@ -174,7 +188,7 @@ Defined in: [src/DockerRunner.ts:109](https://github.com/enixCode/light-runner/b
static list(): RunState[];
```

Defined in: [src/DockerRunner.ts:146](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L146)
Defined in: [src/DockerRunner.ts:157](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L157)

#### Returns

Expand All @@ -191,7 +205,7 @@ static reapOrphans(): Promise<{
}>;
```

Defined in: [src/DockerRunner.ts:178](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L178)
Defined in: [src/DockerRunner.ts:189](https://github.com/enixCode/light-runner/blob/main/src/DockerRunner.ts#L189)

#### Returns

Expand Down