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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- **WebSocket log streams no longer reject anonymous-auth sessions** ([#636](https://github.com/CodesWhat/drydock/issues/636)). Both WS upgrade paths β€” the system log stream and the container log stream β€” gated on `isAuthenticatedSession()` requiring `session.passport.user`, which `passport-anonymous` never sets, so under `DD_ANONYMOUS_AUTH_CONFIRM=true` the log stream WebSocket always rejected the upgrade even though every REST endpoint worked. `isAuthenticatedSession` now also accepts the session when anonymous authentication is the registered mode.
- **Maturity clock: swallowed auth errors surfaced, per-container threshold respected** ([#604](https://github.com/CodesWhat/drydock/issues/604)). `getImagePublishedAt` failures β€” including GHCR/LSCR 401/403 auth errors β€” now log at `warn` instead of `debug`, so the maturity gate's silent fallback from the registry `publishedAt` to `updateDetectedAt` is no longer invisible. `getRawUpdateMaturityLevel` (`app/model/container.ts`) and `getContainerMaturityLevel` (`app/api/container/maturity-filter.ts`) now resolve each container's own `updatePolicy.maturityMinAgeDays` before falling back to the global `DD_UI_MATURITY_THRESHOLD_DAYS`, matching the gate's own `isUpdateSuppressed`/`isMaturityGatePending` logic so the hot/mature badge can no longer disagree with the gate in the same API response.
- **Container start/stop/restart/rollback return an explicit 501 instead of an ambiguous 404 for agent containers without lifecycle transport** ([#637](https://github.com/CodesWhat/drydock/issues/637)). `POST /:id/start|stop|restart` and `POST /:id/rollback` returned a bare 404 `No docker trigger found for this container` whenever the lookup missed, indistinguishable from "container not found" β€” for agent-owned containers this was the only signal the UI got. That lookup miss now returns 501 naming the likely cause (the agent's connection typically hasn't advertised `usesControllerDockerTransport`) when `container.agent` is set; non-agent containers still get the existing 404. This complements the native-transport support that shipped in rc.11 via [#651](https://github.com/CodesWhat/drydock/pull/651), which closed #637's core gap β€” this is the remaining explicit-error half.

### Security
Expand Down
37 changes: 37 additions & 0 deletions app/api/container/maturity-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,41 @@ describe('api/container/maturity-filter', () => {

expect(applyContainerMaturityFilter(containers, undefined)).toBe(containers);
});

test('applyContainerMaturityFilter uses per-container updatePolicy.maturityMinAgeDays over the global threshold on the uncached fallback path', () => {
const previousThreshold = process.env.DD_UI_MATURITY_THRESHOLD_DAYS;
process.env.DD_UI_MATURITY_THRESHOLD_DAYS = '7';

try {
const tenDaysMs = 10 * 24 * 60 * 60 * 1000;
const containers = [
{
id: 'c1',
updateAvailable: true,
updateAge: tenDaysMs,
updatePolicy: { maturityMinAgeDays: 30 },
} as unknown as Container,
{
id: 'c2',
updateAvailable: true,
updateAge: tenDaysMs,
} as unknown as Container,
];

// Global threshold is pinned to 7 days: c2 has no override so it's 'mature'.
// c1 overrides to 30 days, so the same 10-day-old update is still 'hot'.
expect(
applyContainerMaturityFilter(containers, 'hot').map((container) => container.id),
).toEqual(['c1']);
expect(
applyContainerMaturityFilter(containers, 'mature').map((container) => container.id),
).toEqual(['c2']);
} finally {
if (previousThreshold === undefined) {
delete process.env.DD_UI_MATURITY_THRESHOLD_DAYS;
} else {
process.env.DD_UI_MATURITY_THRESHOLD_DAYS = previousThreshold;
}
}
});
});
16 changes: 8 additions & 8 deletions app/api/container/maturity-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,9 @@ function resolveUiMaturityThresholdDays(): number {
);
}

function resolveUiMaturityThresholdMs(): number {
return maturityMinAgeDaysToMilliseconds(resolveUiMaturityThresholdDays());
}

function getContainerMaturityLevel(
container: Container,
uiMaturityThresholdMs: number,
uiMaturityThresholdDays: number,
): ContainerMaturityFilter | undefined {
const cachedLevel = container.updateMaturityLevel;
if (cachedLevel === 'hot' || cachedLevel === 'mature' || cachedLevel === 'established') {
Expand All @@ -48,7 +44,11 @@ function getContainerMaturityLevel(
if (updateAge >= maturityMinAgeDaysToMilliseconds(ESTABLISHED_UPDATE_AGE_DAYS)) {
return 'established';
}
return updateAge >= uiMaturityThresholdMs ? 'mature' : 'hot';
const maturityThresholdDays = resolveMaturityMinAgeDays(
container.updatePolicy?.maturityMinAgeDays,
uiMaturityThresholdDays,
);
return updateAge >= maturityMinAgeDaysToMilliseconds(maturityThresholdDays) ? 'mature' : 'hot';
}

export function applyContainerMaturityFilter(
Expand All @@ -59,8 +59,8 @@ export function applyContainerMaturityFilter(
return containers;
}

const uiMaturityThresholdMs = resolveUiMaturityThresholdMs();
const uiMaturityThresholdDays = resolveUiMaturityThresholdDays();
return containers.filter(
(container) => getContainerMaturityLevel(container, uiMaturityThresholdMs) === maturityFilter,
(container) => getContainerMaturityLevel(container, uiMaturityThresholdDays) === maturityFilter,
);
}
51 changes: 51 additions & 0 deletions app/model/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,57 @@ test('model should use DD_UI_MATURITY_THRESHOLD_DAYS for hot/mature cutoff', asy
}
});

test('model should use per-container updatePolicy.maturityMinAgeDays over DD_UI_MATURITY_THRESHOLD_DAYS for hot/mature cutoff', async () => {
const previousThreshold = process.env.DD_UI_MATURITY_THRESHOLD_DAYS;
vi.useFakeTimers();
try {
process.env.DD_UI_MATURITY_THRESHOLD_DAYS = '3';
const now = new Date('2026-03-15T12:00:00.000Z');
vi.setSystemTime(now);
const firstSeenAt = new Date(now.getTime() - daysToMs(10)).toISOString();

const containerValidated = container.validate({
id: 'container-123456789',
name: 'test',
watcher: 'test',
firstSeenAt,
updatePolicy: { maturityMinAgeDays: 30 },
image: {
id: 'image-123456789',
registry: {
name: 'hub',
url: 'https://hub',
},
name: 'organization/image',
tag: {
value: '1.0.0',
semver: true,
},
digest: {
watch: false,
repo: undefined,
},
architecture: 'arch',
os: 'os',
created: '2021-06-12T05:33:38.440Z',
},
result: {
tag: '1.0.1',
},
});

expect(containerValidated.updateAge).toBe(daysToMs(10));
expect(containerValidated.updateMaturityLevel).toBe('hot');
} finally {
vi.useRealTimers();
if (previousThreshold === undefined) {
delete process.env.DD_UI_MATURITY_THRESHOLD_DAYS;
} else {
process.env.DD_UI_MATURITY_THRESHOLD_DAYS = previousThreshold;
}
}
});

test('model should keep updateAvailable when remote tag changes past skipped value', async () => {
const containerValidated = container.validate({
id: 'container-123456789',
Expand Down
5 changes: 4 additions & 1 deletion app/model/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,10 @@ function getRawUpdateMaturityLevel(
return 'established';
}

const maturityThresholdDays = resolveUiMaturityThresholdDays();
const maturityThresholdDays = resolveMaturityMinAgeDays(
container.updatePolicy?.maturityMinAgeDays,
resolveUiMaturityThresholdDays(),
);
const maturityThresholdMs = maturityMinAgeDaysToMilliseconds(maturityThresholdDays);
return updateAge >= maturityThresholdMs ? 'mature' : 'hot';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ describe('Docker Watcher', () => {
const result = await docker.findNewVersion(container, mockLogChild);

expect(result).toEqual({ tag: '1.0.0' });
expect(mockLogChild.debug).toHaveBeenCalledWith(
expect(mockLogChild.warn).toHaveBeenCalledWith(
expect.stringContaining('publish date lookup failed'),
);
});
Expand Down
2 changes: 1 addition & 1 deletion app/watchers/providers/docker/Docker.containers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,7 @@ describe('Docker Watcher', () => {
const result = await docker.findNewVersion(container, mockLogChild);

expect(result).toEqual({ tag: '1.0.0' });
expect(mockLogChild.debug).toHaveBeenCalledWith(
expect(mockLogChild.warn).toHaveBeenCalledWith(
expect.stringContaining('publish date lookup failed'),
);
});
Expand Down
64 changes: 56 additions & 8 deletions app/watchers/providers/docker/image-comparison.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,28 @@ describe('image-comparison', () => {
expect(result.publishedAt).toBeUndefined();
expect(result.publishedAtTrusted).toBeUndefined();
expect(result.digest).toBe('sha256:def456');
expect(log.debug).toHaveBeenCalledWith(expect.stringContaining('registry timeout'));
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('registry timeout'));
});

test('digest-only with comparisonTag continues when getImagePublishedAt throws and logContainer.warn is absent', async () => {
mockGetState.mockReturnValue({
registry: {
hub: {
getTags: vi.fn().mockResolvedValue(['latest']),
getImageManifestDigest: createManifestLookup(),
normalizeImage: identityNormalizeImage,
getImagePublishedAt: vi.fn().mockRejectedValue(new Error('timeout')),
},
},
});
const log = { error: vi.fn(), debug: vi.fn() } as unknown as Parameters<
typeof findNewVersion
>[1];

const result = await findNewVersion(createDigestOnlyContainer() as never, log);

expect(result.publishedAt).toBeUndefined();
expect(result.digest).toBe('sha256:def456');
});

test('digest-only with comparisonTag does not set publishedAt when getImagePublishedAt returns non-string', async () => {
Expand Down Expand Up @@ -808,7 +829,7 @@ describe('image-comparison', () => {
expect(result.publishedAtTrusted).toBeUndefined();
});

test('logs debug and continues when getImagePublishedAt throws', async () => {
test('logs warn and continues when getImagePublishedAt throws', async () => {
mockGetState.mockReturnValue({
registry: {
hub: {
Expand All @@ -819,8 +840,35 @@ describe('image-comparison', () => {
},
},
});
const debugFn = vi.fn();
const log = { error: vi.fn(), warn: vi.fn(), debug: debugFn };
const warnFn = vi.fn();
const log = { error: vi.fn(), warn: warnFn, debug: vi.fn() };
const container = {
image: {
id: 'image-1',
registry: { name: 'hub' },
name: 'library/nginx',
tag: { value: '1.0.0', semver: false },
digest: { watch: false },
},
};
const result = await findNewVersion(container as never, log);
expect(result.publishedAt).toBeUndefined();
expect(warnFn).toHaveBeenCalledWith(expect.stringContaining('API error'));
});

test('logs warn on registry auth failure so it is not silently swallowed', async () => {
mockGetState.mockReturnValue({
registry: {
hub: {
getTags: vi.fn().mockResolvedValue(['1.1.0']),
getImageManifestDigest: createManifestLookup(),
normalizeImage: identityNormalizeImage,
getImagePublishedAt: vi.fn().mockRejectedValue(new Error('401 Unauthorized')),
},
},
});
const warnFn = vi.fn();
const log = { error: vi.fn(), warn: warnFn, debug: vi.fn() };
const container = {
image: {
id: 'image-1',
Expand All @@ -832,10 +880,10 @@ describe('image-comparison', () => {
};
const result = await findNewVersion(container as never, log);
expect(result.publishedAt).toBeUndefined();
expect(debugFn).toHaveBeenCalledWith(expect.stringContaining('API error'));
expect(warnFn).toHaveBeenCalledWith(expect.stringContaining('401 Unauthorized'));
});

test('continues silently when getImagePublishedAt throws and logContainer.debug is absent', async () => {
test('continues silently when getImagePublishedAt throws and logContainer.warn is absent', async () => {
mockGetState.mockReturnValue({
registry: {
hub: {
Expand All @@ -846,8 +894,8 @@ describe('image-comparison', () => {
},
},
});
// Intentionally omit debug from log to exercise the false branch of typeof logContainer.debug
const log = { error: vi.fn(), warn: vi.fn() } as unknown as Parameters<
// Intentionally omit warn from log to exercise the false branch of typeof logContainer.warn
const log = { error: vi.fn(), debug: vi.fn() } as unknown as Parameters<
typeof findNewVersion
>[1];
const container = {
Expand Down
8 changes: 5 additions & 3 deletions app/watchers/providers/docker/image-comparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,9 @@ export async function findNewVersion(
}
}
} catch (error: unknown) {
logContainer.debug(`Remote publish date lookup failed (${getErrorMessage(error)})`);
if (typeof logContainer.warn === 'function') {
logContainer.warn(`Remote publish date lookup failed (${getErrorMessage(error)})`);
}
}
} else {
logContainer.debug('Digest-only image β€” no registry tag candidate available');
Expand Down Expand Up @@ -423,8 +425,8 @@ export async function findNewVersion(
}
}
} catch (error: unknown) {
if (typeof logContainer.debug === 'function') {
logContainer.debug(`Remote publish date lookup failed (${getErrorMessage(error)})`);
if (typeof logContainer.warn === 'function') {
logContainer.warn(`Remote publish date lookup failed (${getErrorMessage(error)})`);
}
}

Expand Down
Loading