diff --git a/Dockerfile.dashboard b/Dockerfile.dashboard index 7b7e8b77..0e923738 100644 --- a/Dockerfile.dashboard +++ b/Dockerfile.dashboard @@ -16,6 +16,11 @@ RUN npm run build FROM node:22-slim AS production WORKDIR /app +# `cascade.managed=true` is the contract the router's dangling-image cleanup +# loop filters on (src/router/dangling-image-cleanup.ts). Without this LABEL, +# the loop matches zero images and reclaims nothing — see PR #1256. +LABEL cascade.managed=true + # Install curl for health checks RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 45839b92..186aefa9 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -2,6 +2,11 @@ FROM node:22-slim WORKDIR /app +# `cascade.managed=true` is the contract the router's dangling-image cleanup +# loop filters on (src/router/dangling-image-cleanup.ts). Without this LABEL, +# the loop matches zero images and reclaims nothing — see PR #1256. +LABEL cascade.managed=true + # Install backend deps (needed for type imports from src/api) COPY package*.json .npmrc ./ RUN npm ci --ignore-scripts diff --git a/Dockerfile.router b/Dockerfile.router index 1fe39de1..5d933c4a 100644 --- a/Dockerfile.router +++ b/Dockerfile.router @@ -15,6 +15,11 @@ RUN npm run build FROM node:22-slim AS production WORKDIR /app +# `cascade.managed=true` is the contract the router's dangling-image cleanup +# loop filters on (src/router/dangling-image-cleanup.ts). Without this LABEL, +# the loop matches zero images and reclaims nothing — see PR #1256. +LABEL cascade.managed=true + # Install curl for health checks RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.selfhosted b/Dockerfile.selfhosted index 0c58cbb8..c57c21fd 100644 --- a/Dockerfile.selfhosted +++ b/Dockerfile.selfhosted @@ -34,6 +34,11 @@ CMD ["npx", "drizzle-kit", "migrate"] FROM node:22-slim AS production WORKDIR /app +# `cascade.managed=true` is the contract the router's dangling-image cleanup +# loop filters on (src/router/dangling-image-cleanup.ts). Without this LABEL, +# the loop matches zero images and reclaims nothing — see PR #1256. +LABEL cascade.managed=true + RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* COPY package*.json .npmrc ./ diff --git a/Dockerfile.worker b/Dockerfile.worker index 664be5ed..d2db9c80 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -15,6 +15,11 @@ RUN npm run build FROM node:22-bookworm AS production WORKDIR /app +# `cascade.managed=true` is the contract the router's dangling-image cleanup +# loop filters on (src/router/dangling-image-cleanup.ts). Without this LABEL, +# the loop matches zero images and reclaims nothing — see PR #1256. +LABEL cascade.managed=true + # Install pnpm globally (some repos use pnpm) RUN npm install -g pnpm --force diff --git a/src/router/dangling-image-cleanup.ts b/src/router/dangling-image-cleanup.ts index 6ed21a53..8f546400 100644 --- a/src/router/dangling-image-cleanup.ts +++ b/src/router/dangling-image-cleanup.ts @@ -15,6 +15,19 @@ * from being reaped — see the regression test of the same name in * `tests/unit/router/dangling-image-cleanup.test.ts`. Never widen the scope. * + * The label is applied by the cascade Dockerfiles themselves (every + * `Dockerfile.` at repo root carries `LABEL cascade.managed=true` in + * its production stage). PR #1243 originally shipped this loop without that + * Dockerfile contract — the loop was a no-op for days because no built + * image carried the label, and dangling rebuilds accumulated unchecked. + * The same regression test pins both halves of the contract: the filter + * shape AND the per-Dockerfile LABEL directive. + * + * The CONTAINER label of the same name (`cascade.managed=true`, set in + * `container-manager.ts` on every `docker run`) is a separate surface used + * by `orphan-cleanup.ts` to scope container reaping. It does not propagate + * to images; the Dockerfile LABEL is the only path to image-level matching. + * * The 5-min snapshot eviction loop and the 5-min orphan-container cleanup * loop are unaffected; this loop runs at 30 min because dangling * accumulation is gradual and `force: false` rmi is cheap. diff --git a/tests/unit/router/dangling-image-cleanup.test.ts b/tests/unit/router/dangling-image-cleanup.test.ts index d607aa46..d478cecd 100644 --- a/tests/unit/router/dangling-image-cleanup.test.ts +++ b/tests/unit/router/dangling-image-cleanup.test.ts @@ -1,3 +1,5 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- @@ -244,6 +246,52 @@ describe('dangling-image-cleanup', () => { }); }); + describe('Dockerfile LABEL contract — static guard', () => { + // Counterpart to the scan-filter regression guard above. The filter + // only matches images carrying the `cascade.managed=true` label, and + // the only way a built image gets that label is via a `LABEL` + // directive in the Dockerfile. PR #1243 shipped the cleanup loop + // without this contract — the loop was a no-op for days because no + // image carried the label. If a new `Dockerfile.` lands at the + // repo root without the directive, the cleanup loop silently stops + // reclaiming that service's dangling rebuilds. This test fails + // loudly the moment that happens. + const REPO_ROOT = join(__dirname, '..', '..', '..'); + const dockerfiles = readdirSync(REPO_ROOT) + .filter((name) => name.startsWith('Dockerfile.')) + .sort(); + + it('finds the expected cascade Dockerfiles at repo root (sanity)', () => { + // Sanity: if this assertion fails the glob is broken or someone + // renamed the Dockerfiles, and the per-file assertions below + // would silently pass on an empty list. + expect(dockerfiles.length).toBeGreaterThanOrEqual(5); + expect(dockerfiles).toEqual( + expect.arrayContaining([ + 'Dockerfile.dashboard', + 'Dockerfile.frontend', + 'Dockerfile.router', + 'Dockerfile.selfhosted', + 'Dockerfile.worker', + ]), + ); + }); + + it.each([ + 'Dockerfile.router', + 'Dockerfile.worker', + 'Dockerfile.dashboard', + 'Dockerfile.frontend', + 'Dockerfile.selfhosted', + ])('%s declares LABEL cascade.managed=true so dangling rebuilds match the cleanup filter', (filename) => { + const contents = readFileSync(join(REPO_ROOT, filename), 'utf8'); + // Match `LABEL cascade.managed=true` (with optional quotes + // around the value). Tolerates `LABEL k=v k2=v2` chains. + const labelRegex = /^\s*LABEL\b[^\n]*\bcascade\.managed=("?)true\1/im; + expect(contents).toMatch(labelRegex); + }); + }); + describe('startDanglingImageCleanup / stopDanglingImageCleanup', () => { it('starts a periodic cleanup scan', () => { expect(() => startDanglingImageCleanup()).not.toThrow();