From 7ca3a4a9042d3d76ea11a06e1822d48dbb3df5a5 Mon Sep 17 00:00:00 2001 From: enixCode <58286681+enixCode@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:24:28 +0200 Subject: [PATCH] feat: cleanup dangling images + arm version 0.17.0 light-runner already reaps containers, volumes, cache images, networks and state files, but nothing pruned dangling images (untagged `` layers left by re-pulls and rebuilds), so disk crept up over time with no recourse. - Add DockerRunner.cleanupDanglingImages(): lists `dangling=true` and removes each without force. Never touches a tagged image, so base images and reusable tags survive; layers still referenced by a live image or container are refused by the daemon and skipped. Cache images stay under cleanupOrphanCache. - e2e test: commit an untagged image from alpine, assert the sweep removes it and leaves the tagged base image intact. - Bump TRACER_VERSION to 0.17.0 (was stuck at 0.16.2 from the prior release race) so OTel scope version matches the package again. This is the SDK half of closing the retention gap. light-run arms it (plus the existing reapOrphans/cleanupOrphanCache/cleanupOrphanNetworks/reconcileStates) on a periodic sweep. build with cc --- package.json | 2 +- src/DockerRunner.ts | 11 ++++ src/build.ts | 35 +++++++++++ src/runner/operator.ts | 10 ++- src/telemetry.ts | 2 +- test/e2e/images.test.ts | 63 +++++++++++++++++++ .../content/docs/api/classes/DockerRunner.md | 26 ++++++-- 7 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 test/e2e/images.test.ts diff --git a/package.json b/package.json index ffbd756..e015663 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/DockerRunner.ts b/src/DockerRunner.ts index 7aea252..e602a5d 100644 --- a/src/DockerRunner.ts +++ b/src/DockerRunner.ts @@ -123,6 +123,17 @@ export class DockerRunner { return operator.cleanupOrphanCache(opts); } + /* + * Remove dangling images: untagged `` 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 { + return operator.cleanupDanglingImages(); + } + /* * Remove networks whose name starts with `prefix` (default `light-runner-`) * that have no active container connections AND are older than `maxAgeMs` diff --git a/src/build.ts b/src/build.ts index 978f0fd..4b18e4d 100644 --- a/src/build.ts +++ b/src/build.ts @@ -261,3 +261,38 @@ export async function cleanupOrphanCache(opts: CleanupCacheOptions = {}): Promis return removed; } + +/* + * Remove dangling images: untagged `:` 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 { + 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; +} diff --git a/src/runner/operator.ts b/src/runner/operator.ts index 8ff9d16..aecdb90 100644 --- a/src/runner/operator.ts +++ b/src/runner/operator.ts @@ -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 { @@ -36,6 +40,10 @@ export function cleanupOrphanCache(opts: CleanupCacheOptions = {}): Promise { + return cleanupDanglingImagesImpl(); +} + export function cleanupOrphanNetworks(opts: CleanupNetworkOptions = {}): Promise { return cleanupNetworksImpl(opts); } diff --git a/src/telemetry.ts b/src/telemetry.ts index b6988a5..63c4bd5 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -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); diff --git a/test/e2e/images.test.ts b/test/e2e/images.test.ts new file mode 100644 index 0000000..8b311d6 --- /dev/null +++ b/test/e2e/images.test.ts @@ -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 { + 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 + // (`:`), 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()); + }); +}); diff --git a/website/content/docs/api/classes/DockerRunner.md b/website/content/docs/api/classes/DockerRunner.md index 5d03940..537b61a 100644 --- a/website/content/docs/api/classes/DockerRunner.md +++ b/website/content/docs/api/classes/DockerRunner.md @@ -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 @@ -66,13 +66,27 @@ Defined in: [src/DockerRunner.ts:142](https://github.com/enixCode/light-runner/b *** +### cleanupDanglingImages() + +```ts +static cleanupDanglingImages(): Promise; +``` + +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 @@ -112,7 +126,7 @@ Defined in: [src/DockerRunner.ts:122](https://github.com/enixCode/light-runner/b static cleanupOrphanNetworks(opts?): Promise; ``` -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 @@ -132,7 +146,7 @@ Defined in: [src/DockerRunner.ts:133](https://github.com/enixCode/light-runner/b static cleanupOrphanStates(): Promise; ``` -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 @@ -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 @@ -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