From 2791e8c36d89bfbcc375038490eb65ed4084afc7 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sun, 23 Aug 2026 10:21:00 +0200 Subject: [PATCH] keep specific images from being pruned with a dockhand.prune=false label --- src/lib/server/docker.ts | 15 ++++++++------ src/lib/server/image-prune-core.ts | 29 ++++++++++++++++++++++++++ tests/image-prune-core.test.ts | 33 ++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 src/lib/server/image-prune-core.ts create mode 100644 tests/image-prune-core.test.ts diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 88bc33d..833a81f 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -15,6 +15,7 @@ import * as tls from 'node:tls'; import { createHash } from 'node:crypto'; import { pumpWebStreamToWritable } from './stream-pump'; import { toWebReadableStream } from './node-readable-stream'; +import { buildImagePruneFilters } from './image-prune-core'; import { computeRequestTimeoutMs } from './backups/request-timeout'; import { helperWaitDeadline } from './helper-wait-core'; import type { Environment } from './db'; @@ -4605,19 +4606,21 @@ export async function pruneImages(dangling = true, envId?: number | null) { // scanner images are always tagged so they can't be dangling anyway. if (dangling) { return dockerJsonRequest( - `/images/prune?filters=${encodeURIComponent('{"dangling":["true"]}')}`, + `/images/prune?filters=${buildImagePruneFilters(true)}`, { method: 'POST' }, envId ); } - // dangling=false: "prune all unused." When the scanner-protection setting - // is on, shield grype + trivy with stopped holder containers (Docker's - // "in use" check keeps them) then tear them down in a finally (#625). + // dangling=false: "prune all unused." The label filter makes Docker skip any + // image tagged dockhand.prune=false (#1391). When the scanner-protection + // setting is on, ALSO shield grype + trivy + backup helper with stopped + // holder containers (Docker's "in use" check keeps them) then tear them down + // in a finally (#625). Scanner images are third-party so we can't label them. const protect = (await getSetting('protect_scanner_images')) !== false; if (!protect) { return dockerJsonRequest( - `/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`, + `/images/prune?filters=${buildImagePruneFilters(false)}`, { method: 'POST' }, envId ); @@ -4652,7 +4655,7 @@ export async function pruneImages(dangling = true, envId?: number | null) { try { return await dockerJsonRequest( - `/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`, + `/images/prune?filters=${buildImagePruneFilters(false)}`, { method: 'POST' }, envId ); diff --git a/src/lib/server/image-prune-core.ts b/src/lib/server/image-prune-core.ts new file mode 100644 index 0000000..a8b3545 --- /dev/null +++ b/src/lib/server/image-prune-core.ts @@ -0,0 +1,29 @@ +// Pure helpers for image pruning. Kept dependency-free (no db / better-sqlite3) +// so they can be unit-tested directly, mirroring volume-prune-core.ts. + +/** + * Image label that opts an image out of an "all unused" prune. Tag an image + * with `dockhand.prune=false` (e.g. `docker build --label dockhand.prune=false`) + * and Docker's own prune skips it - no holder container needed. + */ +export const PRUNE_KEEP_LABEL = 'dockhand.prune=false'; + +/** + * Build the `filters=` query value for a Docker `/images/prune` call. + * + * @param dangling true = only untagged images (keep-label does not apply, since + * dangling images have no labels of interest); false = all unused, and images + * carrying PRUNE_KEEP_LABEL are excluded. + * + * IMPORTANT: over the Docker ENGINE API the label negation is a SEPARATE filter + * key `label!` (`{"label!":["dockhand.prune=false"]}`). The `label!=value` form + * is the docker CLI surface and is a no-op over the API - verified against a + * live daemon (both Docker 27 and 20.10 / API 1.41). A plain `label` key would + * invert the meaning (prune ONLY matching images). + */ +export function buildImagePruneFilters(dangling: boolean): string { + const filters: Record = dangling + ? { dangling: ['true'] } + : { dangling: ['false'], 'label!': [PRUNE_KEEP_LABEL] }; + return encodeURIComponent(JSON.stringify(filters)); +} diff --git a/tests/image-prune-core.test.ts b/tests/image-prune-core.test.ts new file mode 100644 index 0000000..2e19600 --- /dev/null +++ b/tests/image-prune-core.test.ts @@ -0,0 +1,33 @@ +// @ts-expect-error -- bun:test is a runtime built-in with no types installed +import { describe, test, expect } from 'bun:test'; +import { buildImagePruneFilters, PRUNE_KEEP_LABEL } from '../src/lib/server/image-prune-core'; + +function decode(filters: string): Record { + return JSON.parse(decodeURIComponent(filters)); +} + +describe('buildImagePruneFilters', () => { + test('dangling=true has no label filter (untagged images have no keep label)', () => { + expect(decode(buildImagePruneFilters(true))).toEqual({ dangling: ['true'] }); + }); + + test('dangling=false excludes keep-labelled images via the label! key', () => { + // The negation MUST be the separate `label!` key, not a `!=` value prefix - + // the API ignores the `!=` form (verified against a live daemon). + expect(decode(buildImagePruneFilters(false))).toEqual({ + dangling: ['false'], + 'label!': ['dockhand.prune=false'] + }); + }); + + test('does NOT use a plain "label" key (which would invert the meaning)', () => { + const f = decode(buildImagePruneFilters(false)); + expect(f['label']).toBeUndefined(); + expect(f['label!']).toEqual([PRUNE_KEEP_LABEL]); + }); + + test('the produced value is URL-encoded', () => { + expect(buildImagePruneFilters(false)).not.toContain('{'); + expect(buildImagePruneFilters(false)).toContain('%7B'); + }); +});