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
5 changes: 5 additions & 0 deletions Dockerfile.dashboard
Original file line number Diff line number Diff line change
Expand Up @@ -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/*

Expand Down
5 changes: 5 additions & 0 deletions Dockerfile.frontend
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile.router
Original file line number Diff line number Diff line change
Expand Up @@ -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/*

Expand Down
5 changes: 5 additions & 0 deletions Dockerfile.selfhosted
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile.worker
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions src/router/dangling-image-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<svc>` 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.
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/router/dangling-image-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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.<svc>` 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();
Expand Down
Loading