keep specific images from being pruned with a dockhand.prune=false label

This commit is contained in:
Jarek Krochmalski committed 2026-08-23 10:21:00 +02:00
1 parent 70491423dc
commit 2791e8c36d
3 files changed
+71 -6

No files matched your search

+9 -6
View File
@@ -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
);
+29
View File
@@ -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<string, string[]> = dangling
? { dangling: ['true'] }
: { dangling: ['false'], 'label!': [PRUNE_KEEP_LABEL] };
return encodeURIComponent(JSON.stringify(filters));
}
+33
View File
@@ -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<string, string[]> {
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');
});
});