Skip to content
Closed
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Startup warning for minute-precise maintenance-window crons.** `DD_WATCHER_{name}_MAINTENANCE_WINDOW` is matched minute-by-minute, so a fixed minute field like `0 2-6 * * *` only opens the window for one minute per matching hour instead of the whole hour range β€” a common copy-paste trap reported in [Discussion #639](https://github.com/CodesWhat/drydock/discussions/639). The Docker watcher now logs a one-time warning at init when the configured window's minute field doesn't contain `*`, pointing at the fix (`* 2-3 * * *`). Step values like `*/5` are intentional and are not flagged.
- **"Learn more" link in the update confirm dialog for policy-blocked overrides.** When overriding a soft-blocked update, the confirm dialog now links to the [update-eligibility reasons reference](https://getdrydock.com/docs/configuration/actions/update-eligibility#reasons-reference), matching the link already shown in the Update Status panel. ([Discussion #639](https://github.com/CodesWhat/drydock/discussions/639))

### Fixed

- **Unchanged `update-available` audit entries are no longer re-recorded on a timer.** Previously an unchanged pending update was re-written to the audit log every time the 1-hour dedupe window lapsed, bloating the audit log for fleets with long-pending updates (a fleet with 10 persistent pending updates wrote 140 rows in 24h). Audit rows are now recorded only on first detection or when the update target/kind changes. The RC-only `DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS` variable is removed.
- **Maintenance-window documentation corrected and made findable** ([Discussion #639](https://github.com/CodesWhat/drydock/discussions/639)). Every documented example used a minute-precise cron (`0 2-6 * * *`) while describing it as an hourly range ("2am–6am daily"). `MAINTENANCE_WINDOW` is matched minute by minute, so that expression opens the window for one minute at the top of each listed hour and reports "closed" the rest of the day. Examples now use `* 2-6 * * *`, with a table of correct forms in [Watchers](https://getdrydock.com/docs/configuration/watchers#maintenance-window). The update confirm dialog's own wording ("This update is currently policy-blocked", "Outside maintenance window β€” auto update deferred until the window opens", "Update anyway") appeared nowhere in the docs, so searching for the on-screen message returned nothing; [Update Eligibility & Blockers](https://getdrydock.com/docs/configuration/actions/update-eligibility) now documents that dialog verbatim and states plainly that a soft blocker never gates a manual update.

## [1.6.0-rc.9] β€” 2026-07-28

### Added
Expand Down
22 changes: 0 additions & 22 deletions app/configuration/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,28 +39,6 @@ test('getLogLevel should return info by default', async () => {
expect(configuration.getLogLevel()).toStrictEqual('info');
});

test('getAuditUpdateAvailableDedupeMs should default to one hour', () => {
delete configuration.ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS;
expect(configuration.getAuditUpdateAvailableDedupeMs()).toBe(60 * 60 * 1000);
});

test('getAuditUpdateAvailableDedupeMs should accept a non-negative integer', () => {
configuration.ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS = '120000';
expect(configuration.getAuditUpdateAvailableDedupeMs()).toBe(120_000);
delete configuration.ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS;
});

test.each(['-1', '1.5', 'not-a-number'])(
'getAuditUpdateAvailableDedupeMs should reject invalid values (%s)',
(value) => {
configuration.ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS = value;
expect(() => configuration.getAuditUpdateAvailableDedupeMs()).toThrow(
'DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS must be a non-negative integer',
);
delete configuration.ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS;
},
);

test('getLogLevel should return debug when overridden', async () => {
configuration.ddEnvVars.DD_LOG_LEVEL = 'debug';
expect(configuration.getLogLevel()).toStrictEqual('debug');
Expand Down
15 changes: 0 additions & 15 deletions app/configuration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,21 +198,6 @@ export function getLogLevel() {
return ddEnvVars.DD_LOG_LEVEL || 'info';
}

const DEFAULT_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS = 60 * 60 * 1000;

export function getAuditUpdateAvailableDedupeMs(): number {
const rawValue = ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS?.trim();
if (rawValue === undefined || rawValue === '') {
return DEFAULT_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS;
}

const parsedValue = Number(rawValue);
if (!Number.isSafeInteger(parsedValue) || parsedValue < 0) {
throw new Error('DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS must be a non-negative integer');
}
return parsedValue;
}

export function getLogFormat() {
return ddEnvVars.DD_LOG_FORMAT?.toLowerCase() === 'json' ? 'json' : 'text';
}
Expand Down
118 changes: 82 additions & 36 deletions app/event/audit-subscriptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,10 @@ import type {
SecurityAlertEventPayload,
} from './index.js';

const { mockInsertAudit, mockInc, mockGetAuditCounter, mockGetUpdateAvailableDedupeMs } =
vi.hoisted(() => ({
mockInsertAudit: vi.fn(),
mockInc: vi.fn(),
mockGetAuditCounter: vi.fn(),
mockGetUpdateAvailableDedupeMs: vi.fn(() => 60 * 60 * 1000),
}));

vi.mock('../configuration/index.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../configuration/index.js')>()),
getAuditUpdateAvailableDedupeMs: mockGetUpdateAvailableDedupeMs,
const { mockInsertAudit, mockInc, mockGetAuditCounter } = vi.hoisted(() => ({
mockInsertAudit: vi.fn(),
mockInc: vi.fn(),
mockGetAuditCounter: vi.fn(),
}));

vi.mock('../store/audit.js', () => ({
Expand Down Expand Up @@ -158,7 +151,6 @@ describe('audit-subscriptions dedupe windows', () => {
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
clearAuditSubscriptionCachesForTests();
mockGetAuditCounter.mockReturnValue({ inc: mockInc });
mockGetUpdateAvailableDedupeMs.mockReturnValue(60 * 60 * 1000);
});

afterEach(() => {
Expand Down Expand Up @@ -228,7 +220,7 @@ describe('audit-subscriptions dedupe windows', () => {
expect(mockInc).not.toHaveBeenCalled();
});

test('deduplicates repeated update-available reports inside the configured window', async () => {
test('deduplicates repeated update-available reports with an unchanged signature', async () => {
const { containerReportHandler } = setupAuditSubscriptions();
const report = {
container: {
Expand All @@ -242,14 +234,34 @@ describe('audit-subscriptions dedupe windows', () => {
} as ContainerReport;

await containerReportHandler(report);
vi.advanceTimersByTime(60 * 60 * 1000 - 1);
await containerReportHandler(report);

expect(mockInsertAudit).toHaveBeenCalledTimes(1);
expect(mockInc).toHaveBeenCalledTimes(1);
});

test('records a changed update target immediately inside the dedupe window', async () => {
test('does not re-record an unchanged update after elapsed time', async () => {
const { containerReportHandler } = setupAuditSubscriptions();
const report = {
container: {
name: 'api',
watcher: 'docker',
agent: 'edge-a',
updateAvailable: true,
updateKind: { localValue: '1.0.0', remoteValue: '1.1.0' },
},
changed: false,
} as ContainerReport;

await containerReportHandler(report);
vi.advanceTimersByTime(10 * 60 * 60 * 1000);
await containerReportHandler(report);

expect(mockInsertAudit).toHaveBeenCalledTimes(1);
expect(mockInc).toHaveBeenCalledTimes(1);
});

test('records a changed update target immediately', async () => {
const { containerReportHandler } = setupAuditSubscriptions();
const container = {
name: 'api',
Expand Down Expand Up @@ -357,7 +369,7 @@ describe('audit-subscriptions dedupe windows', () => {
);
});

test('records a new no-to-yes update transition inside the dedupe window', async () => {
test('records a new no-to-yes update transition immediately', async () => {
const { containerReportHandler } = setupAuditSubscriptions();
const container = {
name: 'api',
Expand All @@ -378,7 +390,7 @@ describe('audit-subscriptions dedupe windows', () => {
expect(mockInc).toHaveBeenCalledTimes(2);
});

test('records the same update again after the configured dedupe window', async () => {
test('retries after audit persistence failure', async () => {
const { containerReportHandler } = setupAuditSubscriptions();
const report = {
container: {
Expand All @@ -388,18 +400,48 @@ describe('audit-subscriptions dedupe windows', () => {
updateAvailable: true,
updateKind: { localValue: '1.0.0', remoteValue: '1.1.0' },
},
changed: false,
changed: true,
} as ContainerReport;

await containerReportHandler(report);
vi.advanceTimersByTime(60 * 60 * 1000);
mockInsertAudit.mockImplementationOnce(() => {
throw new Error('audit write failed');
});

await expect(containerReportHandler(report)).rejects.toThrow('audit write failed');
await containerReportHandler(report);

expect(mockInsertAudit).toHaveBeenCalledTimes(2);
expect(mockInc).toHaveBeenCalledTimes(1);
});

test('captures the configured update dedupe window when the first update report is handled', async () => {
mockGetUpdateAvailableDedupeMs.mockReturnValue(1_000);
test('forgets update-available state when a container is removed', async () => {
const { containerReportHandler, containerRemovedHandler } = setupAuditSubscriptions();
const report = {
container: {
name: 'api',
watcher: 'docker',
agent: 'edge-a',
updateAvailable: true,
updateKind: { localValue: '1.0.0', remoteValue: '1.1.0' },
},
changed: true,
} as ContainerReport;

await containerReportHandler(report);
containerRemovedHandler({
name: 'api',
watcher: 'docker',
agent: 'edge-a',
} as ContainerLifecycleEventPayload);
mockInsertAudit.mockClear();
mockInc.mockClear();
await containerReportHandler(report);

expect(mockInsertAudit).toHaveBeenCalledTimes(1);
expect(mockInc).toHaveBeenCalledTimes(1);
});

test('records once after simulated restart', async () => {
const { containerReportHandler } = setupAuditSubscriptions();
const report = {
container: {
Expand All @@ -409,39 +451,43 @@ describe('audit-subscriptions dedupe windows', () => {
updateAvailable: true,
updateKind: { localValue: '1.0.0', remoteValue: '1.1.0' },
},
changed: false,
changed: true,
} as ContainerReport;

expect(mockGetUpdateAvailableDedupeMs).not.toHaveBeenCalled();
await containerReportHandler(report);
expect(mockGetUpdateAvailableDedupeMs).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1_000);
clearAuditSubscriptionCachesForTests();
await containerReportHandler(report);

expect(mockGetUpdateAvailableDedupeMs).toHaveBeenCalledTimes(1);
expect(mockInsertAudit).toHaveBeenCalledTimes(2);
expect(mockInc).toHaveBeenCalledTimes(2);
});

test('prunes stale update identities while accepting a later transition', async () => {
test('does not churn a stable fleet of pending updates at scale', async () => {
const { containerReportHandler } = setupAuditSubscriptions();
const reportFor = (name: string) =>
const reportFor = (index: number) =>
({
container: {
name,
name: `web-${index}`,
watcher: 'docker',
agent: 'edge-a',
updateAvailable: true,
updateKind: { localValue: '1.0.0', remoteValue: '1.1.0' },
},
changed: true,
changed: false,
}) as ContainerReport;

await containerReportHandler(reportFor('old-api'));
vi.advanceTimersByTime(2 * 60 * 60 * 1000 + 1);
await containerReportHandler(reportFor('new-api'));
for (let index = 0; index < 10_001; index += 1) {
await containerReportHandler(reportFor(index));
}
mockInsertAudit.mockClear();
mockInc.mockClear();

for (let index = 0; index < 10_001; index += 1) {
await containerReportHandler(reportFor(index));
}

expect(mockInsertAudit).toHaveBeenCalledTimes(2);
expect(mockInc).toHaveBeenCalledTimes(2);
expect(mockInsertAudit).not.toHaveBeenCalled();
expect(mockInc).not.toHaveBeenCalled();
});

test('deduplicates security alerts that repeat before 5 minutes', async () => {
Expand Down
54 changes: 28 additions & 26 deletions app/event/audit-subscriptions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getAuditUpdateAvailableDedupeMs } from '../configuration/index.js';
import { type ContainerReport, getContainerIdentityKey } from '../model/container.js';
import { getAuditCounter } from '../prometheus/audit.js';
import * as auditStore from '../store/audit.js';
Expand Down Expand Up @@ -48,9 +47,8 @@ const securityAlertAuditSeenAt = new Map<string, number>();
const agentDisconnectedAuditSeenAt = new Map<string, number>();
const containerUnhealthyAuditSeenAt = new Map<string, number>();
const maturityGateClearedAuditSeenAt = new Map<string, number>();
const updateAvailableAuditState = new Map<string, { signature: string; seenAt: number }>();
const updateAvailableAuditState = new Map<string, string>();
const containerLifecycleAuditState = new Map<string, { signature: string; lastSeenAt: number }>();
let updateAvailableAuditDedupeWindowMs: number | undefined;

function normalizeLifecyclePolicy(
policy:
Expand Down Expand Up @@ -165,11 +163,6 @@ function shouldRecordContainerLifecycleUpdate(container: ContainerLifecycleEvent
return { record: true, identity, signature };
}

function getUpdateAvailableAuditDedupeWindowMs(): number {
updateAvailableAuditDedupeWindowMs ??= getAuditUpdateAvailableDedupeMs();
return updateAvailableAuditDedupeWindowMs;
}

function getUpdateAvailableAuditIdentity(containerReport: ContainerReport): string | undefined {
const container = containerReport.container;
if (!container?.name) {
Expand All @@ -178,38 +171,32 @@ function getUpdateAvailableAuditIdentity(containerReport: ContainerReport): stri
return getContainerIdentityKey(container) ?? container.name;
}

function shouldRecordUpdateAvailableAudit(containerReport: ContainerReport): boolean {
function shouldRecordUpdateAvailableAudit(containerReport: ContainerReport): {
record: boolean;
identity?: string;
signature?: string;
} {
const identity = getUpdateAvailableAuditIdentity(containerReport);
if (!identity) {
return false;
return { record: false };
}

if (!containerReport.container?.updateAvailable) {
updateAvailableAuditState.delete(identity);
return false;
return { record: false };
}

const now = Date.now();
const dedupeWindowMs = getUpdateAvailableAuditDedupeWindowMs();
const updateKind = containerReport.container.updateKind;
const signature = JSON.stringify([
updateKind?.kind ?? '',
updateKind?.localValue ?? '',
updateKind?.remoteValue ?? '',
]);
const previousState = updateAvailableAuditState.get(identity);
if (previousState?.signature === signature && now - previousState.seenAt < dedupeWindowMs) {
return false;
if (updateAvailableAuditState.get(identity) === signature) {
return { record: false };
}

updateAvailableAuditState.set(identity, { signature, seenAt: now });
const oldestAllowedTimestamp = now - dedupeWindowMs * 2;
for (const [cachedIdentity, state] of updateAvailableAuditState.entries()) {
if (state.seenAt < oldestAllowedTimestamp) {
updateAvailableAuditState.delete(cachedIdentity);
}
}
return true;
return { record: true, identity, signature };
}

function getContainerUpdateAppliedEventContainerName(
Expand Down Expand Up @@ -258,7 +245,8 @@ function isDuplicateAuditEvent(

export function registerAuditLogSubscriptions(registrars: AuditSubscriptionRegistrars): void {
registrars.registerContainerReport(async (containerReport) => {
if (shouldRecordUpdateAvailableAudit(containerReport)) {
const updateAvailableAudit = shouldRecordUpdateAvailableAudit(containerReport);
if (updateAvailableAudit.record) {
const containerIdentityKey = getContainerIdentityKey(containerReport.container!);
auditStore.insertAudit({
id: '',
Expand All @@ -273,6 +261,12 @@ export function registerAuditLogSubscriptions(registrars: AuditSubscriptionRegis
semverDiff: containerReport.container.updateKind?.semverDiff,
status: 'info',
});
if (updateAvailableAudit.identity && updateAvailableAudit.signature) {
updateAvailableAuditState.set(
updateAvailableAudit.identity,
updateAvailableAudit.signature,
);
}
getAuditCounter()?.inc({ action: 'update-available' });
}
}, AUDIT_HANDLER_OPTIONS);
Expand Down Expand Up @@ -457,6 +451,15 @@ export function registerAuditLogSubscriptions(registrars: AuditSubscriptionRegis
if (containerIdentityKey) {
containerLifecycleAuditState.delete(containerIdentityKey);
}
// Mirrors getUpdateAvailableAuditIdentity's derivation exactly (rather than
// reusing containerIdentityKey above, which may prefer a compose-aware
// identityKey field the update-available path never looks at) so a removed
// container's update-available state is actually forgotten, not orphaned.
const updateAvailableIdentityKey =
getContainerIdentityKey(containerRemoved) ?? containerRemoved.name;
if (updateAvailableIdentityKey) {
updateAvailableAuditState.delete(updateAvailableIdentityKey);
}
auditStore.insertAudit({
id: '',
timestamp: new Date().toISOString(),
Expand Down Expand Up @@ -486,5 +489,4 @@ export function clearAuditSubscriptionCachesForTests(): void {
maturityGateClearedAuditSeenAt.clear();
updateAvailableAuditState.clear();
containerLifecycleAuditState.clear();
updateAvailableAuditDedupeWindowMs = undefined;
}
Loading
Loading