diff --git a/CHANGELOG.md b/CHANGELOG.md index a849891ee..17a7d477b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/configuration/index.test.ts b/app/configuration/index.test.ts index 646f19777..eb284e099 100644 --- a/app/configuration/index.test.ts +++ b/app/configuration/index.test.ts @@ -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'); diff --git a/app/configuration/index.ts b/app/configuration/index.ts index 983a896af..416f5c05c 100644 --- a/app/configuration/index.ts +++ b/app/configuration/index.ts @@ -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'; } diff --git a/app/event/audit-subscriptions.test.ts b/app/event/audit-subscriptions.test.ts index b3f2e594e..93422cf31 100644 --- a/app/event/audit-subscriptions.test.ts +++ b/app/event/audit-subscriptions.test.ts @@ -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()), - getAuditUpdateAvailableDedupeMs: mockGetUpdateAvailableDedupeMs, +const { mockInsertAudit, mockInc, mockGetAuditCounter } = vi.hoisted(() => ({ + mockInsertAudit: vi.fn(), + mockInc: vi.fn(), + mockGetAuditCounter: vi.fn(), })); vi.mock('../store/audit.js', () => ({ @@ -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(() => { @@ -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: { @@ -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', @@ -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', @@ -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: { @@ -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: { @@ -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 () => { diff --git a/app/event/audit-subscriptions.ts b/app/event/audit-subscriptions.ts index a0f748730..cb654b86f 100644 --- a/app/event/audit-subscriptions.ts +++ b/app/event/audit-subscriptions.ts @@ -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'; @@ -48,9 +47,8 @@ const securityAlertAuditSeenAt = new Map(); const agentDisconnectedAuditSeenAt = new Map(); const containerUnhealthyAuditSeenAt = new Map(); const maturityGateClearedAuditSeenAt = new Map(); -const updateAvailableAuditState = new Map(); +const updateAvailableAuditState = new Map(); const containerLifecycleAuditState = new Map(); -let updateAvailableAuditDedupeWindowMs: number | undefined; function normalizeLifecyclePolicy( policy: @@ -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) { @@ -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( @@ -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: '', @@ -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); @@ -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(), @@ -486,5 +489,4 @@ export function clearAuditSubscriptionCachesForTests(): void { maturityGateClearedAuditSeenAt.clear(); updateAvailableAuditState.clear(); containerLifecycleAuditState.clear(); - updateAvailableAuditDedupeWindowMs = undefined; } diff --git a/app/watchers/providers/docker/Docker.containers.additional-coverage-core.test.ts b/app/watchers/providers/docker/Docker.containers.additional-coverage-core.test.ts index 0a9018050..a0fd96d4d 100644 --- a/app/watchers/providers/docker/Docker.containers.additional-coverage-core.test.ts +++ b/app/watchers/providers/docker/Docker.containers.additional-coverage-core.test.ts @@ -27,6 +27,7 @@ vi.mock('axios'); vi.mock('./maintenance.js', () => ({ isInMaintenanceWindow: vi.fn(() => true), getNextMaintenanceWindow: vi.fn(() => undefined), + hasNarrowMinuteField: vi.fn(() => false), })); import { diff --git a/app/watchers/providers/docker/Docker.containers.additional-coverage-helpers.test.ts b/app/watchers/providers/docker/Docker.containers.additional-coverage-helpers.test.ts index 4b45826bf..a1029eb43 100644 --- a/app/watchers/providers/docker/Docker.containers.additional-coverage-helpers.test.ts +++ b/app/watchers/providers/docker/Docker.containers.additional-coverage-helpers.test.ts @@ -35,6 +35,7 @@ vi.mock('axios'); vi.mock('./maintenance.js', () => ({ isInMaintenanceWindow: vi.fn(() => true), getNextMaintenanceWindow: vi.fn(() => undefined), + hasNarrowMinuteField: vi.fn(() => false), })); import * as registry from '../../../registry/index.js'; diff --git a/app/watchers/providers/docker/Docker.containers.test.ts b/app/watchers/providers/docker/Docker.containers.test.ts index e8d9196e2..525f65836 100644 --- a/app/watchers/providers/docker/Docker.containers.test.ts +++ b/app/watchers/providers/docker/Docker.containers.test.ts @@ -61,6 +61,7 @@ vi.mock('axios'); vi.mock('./maintenance.js', () => ({ isInMaintenanceWindow: vi.fn(() => true), getNextMaintenanceWindow: vi.fn(() => undefined), + hasNarrowMinuteField: vi.fn(() => false), })); import axios from 'axios'; diff --git a/app/watchers/providers/docker/Docker.events.test.ts b/app/watchers/providers/docker/Docker.events.test.ts index 71be1cc6e..0307f3784 100644 --- a/app/watchers/providers/docker/Docker.events.test.ts +++ b/app/watchers/providers/docker/Docker.events.test.ts @@ -41,6 +41,7 @@ vi.mock('axios'); vi.mock('./maintenance.js', () => ({ isInMaintenanceWindow: vi.fn(() => true), getNextMaintenanceWindow: vi.fn(() => undefined), + hasNarrowMinuteField: vi.fn(() => false), })); vi.mock('./socket-version-probe.js', () => ({ probeSocketApiVersion: vi.fn().mockResolvedValue(undefined), diff --git a/app/watchers/providers/docker/Docker.test.ts b/app/watchers/providers/docker/Docker.test.ts index 1ca751a73..22ae96578 100644 --- a/app/watchers/providers/docker/Docker.test.ts +++ b/app/watchers/providers/docker/Docker.test.ts @@ -57,6 +57,7 @@ vi.mock('axios'); vi.mock('./maintenance.js', () => ({ isInMaintenanceWindow: vi.fn(() => true), getNextMaintenanceWindow: vi.fn(() => undefined), + hasNarrowMinuteField: vi.fn(() => false), })); vi.mock('./socket-version-probe.js', () => ({ probeSocketApiVersion: vi.fn().mockResolvedValue(undefined), @@ -279,6 +280,7 @@ describe('Docker Watcher', () => { // Setup maintenance helpers maintenance.isInMaintenanceWindow.mockReturnValue(true); maintenance.getNextMaintenanceWindow.mockReturnValue(undefined); + maintenance.hasNarrowMinuteField.mockReturnValue(false); // Setup parse mock mockParse.mockReturnValue({ @@ -688,6 +690,47 @@ describe('Docker Watcher', () => { expect(docker.watchFromCron).toHaveBeenCalledTimes(1); }); + + // Startup warning for minute-precise maintenance-window crons (#639). + test('should warn at init when maintenance window has a narrow minute field', async () => { + maintenance.hasNarrowMinuteField.mockReturnValue(true); + await docker.register('watcher', 'docker', 'test', { + maintenancewindow: '0 2-6 * * *', + }); + const mockLog = createMockLog(['info', 'warn', 'debug', 'error']); + docker.log = mockLog; + + await docker.init(); + + expect(maintenance.hasNarrowMinuteField).toHaveBeenCalledWith('0 2-6 * * *'); + expect(mockLog.warn).toHaveBeenCalledWith( + expect.stringContaining("Maintenance window '0 2-6 * * *' has a fixed minute field"), + ); + }); + + test('should not warn at init when maintenance window minute field is wildcard', async () => { + const mockLog = createMockLog(['info', 'warn', 'debug', 'error']); + maintenance.hasNarrowMinuteField.mockReturnValue(false); + await docker.register('watcher', 'docker', 'test', { + maintenancewindow: '* 2-3 * * *', + }); + docker.log = mockLog; + + await docker.init(); + + expect(mockLog.warn).not.toHaveBeenCalled(); + }); + + test('should not warn at init when no maintenance window is configured', async () => { + const mockLog = createMockLog(['info', 'warn', 'debug', 'error']); + await docker.register('watcher', 'docker', 'test', {}); + docker.log = mockLog; + + await docker.init(); + + expect(maintenance.hasNarrowMinuteField).not.toHaveBeenCalled(); + expect(mockLog.warn).not.toHaveBeenCalled(); + }); }); describe('Deregistration', () => { diff --git a/app/watchers/providers/docker/Docker.ts b/app/watchers/providers/docker/Docker.ts index 3e95c88fd..4dac39899 100644 --- a/app/watchers/providers/docker/Docker.ts +++ b/app/watchers/providers/docker/Docker.ts @@ -116,7 +116,11 @@ import { ddTagTransform, ddWatch, } from './label.js'; -import { getNextMaintenanceWindow, isInMaintenanceWindow } from './maintenance.js'; +import { + getNextMaintenanceWindow, + hasNarrowMinuteField, + isInMaintenanceWindow, +} from './maintenance.js'; import { createMutableOidcState, getRemoteAuthResolution as getRemoteAuthResolutionState, @@ -616,6 +620,7 @@ class Docker extends Watcher { async init() { this.ensureLogger(); this.isWatcherDeregistered = false; + this.warnIfNarrowMaintenanceWindow(); await this.initWatcher(); this.log.info(`Cron scheduled (${this.configuration.cron})`); this.watchCron = cron.schedule(this.configuration.cron, () => this.watchFromCron(), { @@ -649,6 +654,23 @@ class Docker extends Watcher { await initWatcherWithRemoteAuth(this.asRemoteAuthWatcher()); } + /** + * Warn once at init when the configured maintenance window has a fixed + * minute field, since it only opens the window for that exact minute per + * matching hour rather than the whole hour(s) (#639). + */ + warnIfNarrowMaintenanceWindow() { + const { maintenancewindow } = this.configuration; + if (maintenancewindow && hasNarrowMinuteField(maintenancewindow)) { + this.log.warn( + `Maintenance window '${maintenancewindow}' has a fixed minute field, so the window ` + + 'is only open during those exact minutes (cron is matched per minute, not as a ' + + "range). Use '*' in the minute field to keep the window open for the whole hour(s), " + + "e.g. '* 2-3 * * *'.", + ); + } + } + async recreateDockerClient() { await initWatcherWithRemoteAuth(this.asRemoteAuthWatcher()); } diff --git a/app/watchers/providers/docker/Docker.watch.test.ts b/app/watchers/providers/docker/Docker.watch.test.ts index f8a11228e..8ad817cc7 100644 --- a/app/watchers/providers/docker/Docker.watch.test.ts +++ b/app/watchers/providers/docker/Docker.watch.test.ts @@ -44,6 +44,7 @@ vi.mock('axios'); vi.mock('./maintenance.js', () => ({ isInMaintenanceWindow: vi.fn(() => true), getNextMaintenanceWindow: vi.fn(() => undefined), + hasNarrowMinuteField: vi.fn(() => false), })); import axios from 'axios'; diff --git a/app/watchers/providers/docker/maintenance.test.ts b/app/watchers/providers/docker/maintenance.test.ts index 787f57668..be63b4afd 100644 --- a/app/watchers/providers/docker/maintenance.test.ts +++ b/app/watchers/providers/docker/maintenance.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getNextMaintenanceWindow, isInMaintenanceWindow } from './maintenance.js'; +import { + getNextMaintenanceWindow, + hasNarrowMinuteField, + isInMaintenanceWindow, +} from './maintenance.js'; describe('isInMaintenanceWindow', () => { afterEach(() => { @@ -105,6 +109,32 @@ describe('getNextMaintenanceWindow', () => { }); }); +describe('hasNarrowMinuteField', () => { + it('should return true for a fixed minute value', () => { + expect(hasNarrowMinuteField('0 2-6 * * *')).toBe(true); + }); + + it('should return false for a wildcard minute field', () => { + expect(hasNarrowMinuteField('* 2-3 * * *')).toBe(false); + }); + + it('should return false for a step value minute field', () => { + expect(hasNarrowMinuteField('*/5 2-3 * * *')).toBe(false); + }); + + it('should return true for a minute list', () => { + expect(hasNarrowMinuteField('0,30 2 * * *')).toBe(true); + }); + + it('should return true for a minute range', () => { + expect(hasNarrowMinuteField('15-20 2 * * *')).toBe(true); + }); + + it('should return false for an empty string', () => { + expect(hasNarrowMinuteField('')).toBe(false); + }); +}); + describe('coverage callbacks', () => { it('should execute cron callback functions passed to createTask', async () => { vi.resetModules(); diff --git a/app/watchers/providers/docker/maintenance.ts b/app/watchers/providers/docker/maintenance.ts index 9a52935e4..983e51977 100644 --- a/app/watchers/providers/docker/maintenance.ts +++ b/app/watchers/providers/docker/maintenance.ts @@ -41,6 +41,27 @@ export function isInMaintenanceWindow( return task.timeMatcher.match(now); } +/** + * Detect whether a cron expression's minute field is "narrow", i.e. it matches + * only specific minute(s) rather than every minute of the matching hour(s). + * + * Maintenance windows are matched minute-by-minute (see isInMaintenanceWindow), + * 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. Step values such + * as "every 5th minute" are intentional and still contain '*', so they are + * not flagged. + * + * @param cronExpr - A standard 5-field cron expression (minute hour day month weekday) + * @returns true if the minute field does not contain '*' (a fixed value/list/range) + */ +export function hasNarrowMinuteField(cronExpr: string): boolean { + if (!cronExpr) { + return false; + } + const minuteField = cronExpr.trim().split(/\s+/)[0]; + return !minuteField?.includes('*'); +} + /** * Return the next date/time matching the maintenance window cron expression. * diff --git a/content/docs/current/api/portwing.mdx b/content/docs/current/api/portwing.mdx index 278085e47..1318f710e 100644 --- a/content/docs/current/api/portwing.mdx +++ b/content/docs/current/api/portwing.mdx @@ -235,6 +235,41 @@ The agent finishes with `dd:container_log_end` (`requestId`, `containerId`, opti Mixed-version fleets remain compatible. An older Portwing agent ignores `stream` and returns the legacy `dd:container_log_response`; Drydock renders that response as one stdout chunk and then ends the viewer. A newer agent still uses the legacy response for requests that omit `stream: true`. +### Exec session protocol + +This section is a protocol reference for agent implementers, not a usable end-user feature. drydock's controller implements the exec transport described below, but v1.6 exposes no exec UI or API for end users to actually open a session — nothing in the product calls it yet. Exec is Edge-only: it rides the same `portwing/1.0` WebSocket as everything else on this page, and there is no equivalent on the [standard inbound-agent](/docs/api/agent) HTTP protocol. + +Like container-log streaming, exec sessions are multiplexed over the same authenticated connection. drydock starts a session with: + +```json +{ + "type": "exec_start", + "data": { + "execId": "0190f3c2-1a2b-7c3d-8e4f-5a6b7c8d9e0f", + "containerId": "8d3f…", + "cmd": ["/bin/sh"], + "user": "root", + "cols": 80, + "rows": 24, + "tty": true + } +} +``` + +The agent confirms the process started with `exec_ready` (`execId`), then streams base64-encoded output chunks: + +```json +{ + "type": "exec_output", + "data": { + "execId": "0190f3c2-1a2b-7c3d-8e4f-5a6b7c8d9e0f", + "data": "bHMgLWxhCg==" + } +} +``` + +drydock sends stdin the same way with `exec_input` (`execId`, base64 `data`) and terminal resizes with `exec_resize` (`execId`, `cols`, `rows`). Either side ends the session with `exec_end` (`execId`, optional `reason`); drydock's current handler tears down the local session on receipt but does not yet inspect the `reason` field. Up to `MAX_EXEC_SESSIONS` (100) concurrent exec sessions are tracked per edge connection — `startExec` rejects once that limit is reached. + ### Protocol limits | Parameter | Value | diff --git a/content/docs/current/configuration/actions/update-eligibility.mdx b/content/docs/current/configuration/actions/update-eligibility.mdx index 3dd2be227..048dc8081 100644 --- a/content/docs/current/configuration/actions/update-eligibility.mdx +++ b/content/docs/current/configuration/actions/update-eligibility.mdx @@ -33,6 +33,32 @@ In `notify` mode the Update Status panel leads with **Notifications only — Dry If a container is eligible but an automatic update does not run, check that the global mode is `auto` and that a matching `docker` / `dockercompose` action trigger is configured to fire for that container. +## What the update dialog is telling you + +When you click **Update** on a container that has soft blockers, the confirm dialog appends them to its message: + +> This update is currently policy-blocked: +> • *(one line per active condition)* +> +> Click Update anyway to override. + +This is a heads-up, not a refusal. The button changes from **Update** to **Update anyway**, and clicking it applies the update immediately. Every reason listed there is a **soft** blocker, which by definition only defers drydock's *automatic* dispatch — your own manual update goes through. Cancel instead, and the container keeps waiting for the condition to clear on its own. + +A **hard** blocker never reaches this dialog: the Update button is disabled and the API rejects the request outright. + +### `maintenance-window-closed` in the update dialog + +> Outside maintenance window — auto update deferred until the window opens. + +Someone has set a maintenance window on this container's watcher (`DD_WATCHER_{watcher_name}_MAINTENANCE_WINDOW`), and right now the clock is outside it. Drydock is telling you it wouldn't have applied this update by itself yet. It has no bearing on the manual update you just asked for: click **Update anyway** and it runs. + +Two things commonly cause this line to show up more than expected: + +- **The window is narrower than it looks.** `MAINTENANCE_WINDOW` is matched minute by minute, so `0 2-3 * * *` is open only at 02:00 and 03:00 for one minute each, not from 2 AM to 4 AM. Use `* 2-3 * * *`. See [Watchers — Maintenance window](/docs/configuration/watchers#maintenance-window). +- **The timezone isn't yours.** The window is evaluated in `MAINTENANCE_WINDOW_TZ`, which defaults to `UTC`. + +This is also independent of the maturity gate. A candidate can be past its maturity soak and still sit outside the maintenance window: maturity decides whether the build is old enough to trust, the window decides when drydock is allowed to act. + ## Reasons reference | Reason code | Condition label | Severity | Why it fires | How to clear it | @@ -52,7 +78,7 @@ If a container is eligible but an automatic update does not run, check that the | `rollback-container` | **Rollback** | Hard | Container was created during a previous update as a rollback artifact and isn't itself updateable. | Not actionable — remove the rollback container manually if no longer needed. | | `active-operation` | **In progress** | Hard | An update is queued or already running for this container. | Wait for completion. | | `no-update-available` | **No actionable update** | Hard/internal | No actionable image update was detected. A pinned container may still have an informational newer-version insight; the insight does not bypass this internal short-circuit. | N/A | -| `maintenance-window-closed` | **Maintenance window closed** | Soft | Automatic-update dispatch evaluated the container while its watcher's maintenance window was closed. Drydock defers automatic updates outside the window regardless of whether the update was found by cron, manual watch, webhook, or post-update fast resync. Container-list and SSE responses enrich this live watcher state for both local and agent-owned containers. Manual UI/API updates pass no maintenance-window state and are not gated by this blocker. | Wait for the next maintenance window or adjust `DD_WATCHER_{watcher_name}_MAINTENANCE_WINDOW` / `_TZ`. | +| `maintenance-window-closed` | **Maintenance window closed** | Soft | Automatic-update dispatch evaluated the container while its watcher's maintenance window was closed. Drydock defers automatic updates outside the window regardless of whether the update was found by cron, manual watch, webhook, or post-update fast resync. Container-list and SSE responses enrich this live watcher state for both local and agent-owned containers. Manual UI/API updates pass no maintenance-window state and are not gated by this blocker. See [what the dialog is telling you](#maintenance-window-closed-in-the-update-dialog). | Click **Update anyway** to apply it now, wait for the next window, or adjust `DD_WATCHER_{watcher_name}_MAINTENANCE_WINDOW` / `_TZ` — remembering the expression is matched per minute, so `* 2-3 * * *` keeps the window open where `0 2-3 * * *` opens it for one minute. | The Update Status panel shows **Up to date** only when neither an actionable update nor an informational `updateInsight` exists. An insight-only pinned container instead shows an informational newer-version state with no update action. The panel also avoids duplicating `active-operation` when an in-progress operation badge already communicates the same state elsewhere. diff --git a/content/docs/current/configuration/agents/index.mdx b/content/docs/current/configuration/agents/index.mdx index 8c0451073..c41ce8e09 100644 --- a/content/docs/current/configuration/agents/index.mdx +++ b/content/docs/current/configuration/agents/index.mdx @@ -40,6 +40,9 @@ services: image: ghcr.io/codeswhat/portwing:latest restart: unless-stopped volumes: + # NOTE: :ro only read-protects the socket *file* — the agent still gets + # the full Docker Engine API through it. It is not an API boundary; see + # the warning below for the production setup. - /var/run/docker.sock:/var/run/docker.sock:ro environment: - DRYDOCK_URL=https://drydock.example.com @@ -53,6 +56,8 @@ secrets: file: ./portwing_ed25519.pem ``` +This example bind-mounts the raw Docker socket into the Portwing container for brevity, and `:ro` is not a security boundary — it only prevents modifying the socket file itself, while every Docker Engine API operation (create, exec, delete) remains available through it. In production, front it with a socket filter instead — see the [sockguard tab](/docs/guides/security#isolate-the-docker-socket) of the Security Hardening Guide, and sockguard's [`examples/compose/tri-tool/`](https://github.com/CodesWhat/sockguard/tree/main/examples/compose/tri-tool) bundle for a runnable compose stack that fronts an Edge agent's Docker socket with sockguard instead of a raw bind mount. + The Drydock controller needs the agent's public key registered before it connects. No Portwing-specific environment variable is required: ```yaml @@ -69,6 +74,8 @@ Generate the agent's keypair with `portwing keygen -comment "edge-host-01"`, the **Container logs work continuously over the authenticated edge tunnel.** Live viewers receive separate stdout/stderr chunks, may request timestamps, and cancel the upstream Docker stream when the viewer closes. Both ends enforce bounded queues so a stalled viewer or controller is disconnected instead of growing memory without limit. Older Portwing agents fall back to the bounded one-shot log response, so mixed-version fleets degrade gracefully. +**Update triggers are not yet proxied over Edge Mode.** `docker` and `dockercompose` action triggers only run on a standard (inbound HTTP) agent today — Edge Mode currently proxies discovery/watch state, continuous container logs, and container delete, but not update triggers. If you need to update containers on a NAT'd/firewalled host, configure it as a standard agent instead. + ## Quickstart A minimal two-host setup: one agent watching a remote host, one controller aggregating it. @@ -379,7 +386,7 @@ When the controller proxies an update to an agent, it posts to the agent-local ` - **Watchers**: Run on the Agent to discover containers. - **Registries**: Configured on the Agent to check for updates. - **Triggers**: - - `docker` and `dockercompose` triggers are configured and executed **on the Agent** (allowing update of remote containers). The controller automatically proxies update requests to the correct agent. + - `docker` and `dockercompose` triggers are configured and executed **on the Agent** (allowing update of remote containers). The controller automatically proxies update requests to the correct agent. **Exception:** this proxying only works for standard (inbound HTTP) agents — [Edge (Portwing) agents](#edge-agent-dial-out-portwing) don't yet support update triggers. - Notification triggers (e.g. `smtp`, `discord`) are configured and executed **on the Controller**. Notifications automatically include a `[server-name]` prefix identifying which server each update comes from. The controller name defaults to the detected Docker or Podman daemon host name when available, then the process hostname, and can be overridden with `DD_SERVER_NAME`. ## Troubleshooting diff --git a/content/docs/current/configuration/server/index.mdx b/content/docs/current/configuration/server/index.mdx index cb537dbe4..07e819b8c 100644 --- a/content/docs/current/configuration/server/index.mdx +++ b/content/docs/current/configuration/server/index.mdx @@ -37,7 +37,6 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; | `DD_SERVER_RATELIMIT_MAX` | ⚪ | Maximum requests accepted by the outer API limiter in each 15-minute window | integer (`>0`) | `1000` | | `DD_AUTH_LOCKOUT_MAX_TRACKED_IDENTITIES` | ⚪ | Maximum number of unique client identities held in the auth lockout state. Older entries are pruned when the cap is reached. | integer (`>0`) | `5000` | | `DD_AUTH_LOCKOUT_PRUNE_INTERVAL_MS` | ⚪ | Interval for pruning expired auth lockout entries from memory and the persisted lockout sidecar file | integer (`>0`) | `60000` | -| `DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS` | ⚪ | Suppress repeated `update-available` audit rows for the same agent, watcher, container, and version transition. A no-update report or a changed target starts a new transition immediately. Set to `0` to disable deduplication. | integer (`>=0`, milliseconds) | `3600000` (1 hour) | | `DD_SSE_MAX_CLIENTS` | ⚪ | Maximum total concurrent SSE connections across all sessions (global cap; individual sessions are also capped at 10). Connections beyond this limit receive a `429 Too Many Requests` response. | integer (`>0`) | `500` | | `DD_RUN_AS_ROOT` | ⚪ | Request break-glass root mode (requires `DD_ALLOW_INSECURE_ROOT=true`) | `true`, `false` | `false` | | `DD_ALLOW_INSECURE_ROOT` | ⚪ | Explicit acknowledgment for break-glass root mode | `true`, `false` | `false` | diff --git a/content/docs/current/configuration/watchers/index.mdx b/content/docs/current/configuration/watchers/index.mdx index 3a64b5311..960d252cc 100644 --- a/content/docs/current/configuration/watchers/index.mdx +++ b/content/docs/current/configuration/watchers/index.mdx @@ -25,7 +25,7 @@ The `docker` watcher lets you configure the Docker hosts you want to watch. | `DD_WATCHER_{watcher_name}_HOST` | ⚪ | Docker hostname or ip of the host to watch | | | | `DD_WATCHER_{watcher_name}_JITTER` | ⚪ | Jitter in ms applied to the CRON to better distribute the load on the registries (on the Hub at the first place) | > 0 | `60000` (1 minute) | | `DD_WATCHER_{watcher_name}_KEYFILE` | ⚪ | Key pem file path (only for TLS connection) | | | -| `DD_WATCHER_{watcher_name}_MAINTENANCE_WINDOW` | ⚪ | Allowed update schedule (checks outside this window are skipped) | [Valid CRON expression](https://crontab.guru/) | | +| `DD_WATCHER_{watcher_name}_MAINTENANCE_WINDOW` | ⚪ | Allowed automatic-update schedule (checks outside this window are skipped; manual updates are never gated). Matched per minute, so use `*` in the minute field — see [Maintenance window](#maintenance-window) | [Valid CRON expression](https://crontab.guru/) | | | `DD_WATCHER_{watcher_name}_MAINTENANCE_WINDOW_TZ` | ⚪ | Timezone used to evaluate `MAINTENANCE_WINDOW` | IANA timezone (e.g. `UTC`, `Europe/Paris`) | `UTC` | | `DD_WATCHER_{watcher_name}_MATURITY_MODE` | ⚪ | Default maturity policy for containers discovered by this watcher; a container label or UI/API override can replace it | `all`, `mature` | `all` (no gate) | | `DD_WATCHER_{watcher_name}_MATURITY_MIN_AGE_DAYS` | ⚪ | Default minimum image age when `MATURITY_MODE=mature`; a container label or UI/API override can replace it | integer (`1` to `365`) | `7` | @@ -52,7 +52,7 @@ The default cron is `0 */6 * * *` (every 6 hours). With many watched containers - If no watcher is configured, a default one named `local` will be automatically created (reading the Docker socket). To suppress this default watcher (e.g. when running as a controller-only node for remote agents), set `DD_LOCAL_WATCHER=false`. - Multiple watchers can be configured (if you have multiple Docker hosts to watch). Give them different names. - Socket configuration and host/port configuration are mutually exclusive. -- When `MAINTENANCE_WINDOW` is configured and a check is skipped outside the allowed schedule, drydock queues one pending check and runs it automatically when the next maintenance window opens. +- When `MAINTENANCE_WINDOW` is configured and a check is skipped outside the allowed schedule, drydock queues one pending check and runs it automatically when the next maintenance window opens. The window gates drydock's own automatic behavior only — manual updates from the UI or API always run. See [Maintenance window](#maintenance-window). - `wud.*` labels are ignored as of v1.6. To rewrite old compose files, run `node dist/index.js config migrate --dry-run` then `node dist/index.js config migrate --file `. - If watcher logger initialization fails, drydock automatically falls back to structured stderr JSON logs and increments `dd_watcher_logger_init_failures_total{type,name}`. Alert on non-zero increases to catch degraded logging early. - The watcher fan-out is capped at **10 concurrent `watchContainer` invocations** per cron cycle. On inventories with 40–200 containers this prevents a burst of simultaneous registry calls that would trigger rate limits despite the token bucket. @@ -684,7 +684,11 @@ docker run \ ### Maintenance window -Only allow update checks between 2 AM and 4 AM in Europe/Berlin: +A maintenance window restricts **when drydock is allowed to act on its own**. Outside the window, scheduled checks are skipped and automatic updates are deferred until the window next opens. Detection is not lost: drydock queues one pending check and runs it as soon as the window opens. + +The window never applies to you. **Manual updates from the UI or API are not gated by it** — the container's Update action stays available at any hour. When you use it outside the window, the confirm dialog notes that the update is currently policy-blocked with _"Outside maintenance window — auto update deferred until the window opens"_ and the button reads **Update anyway**. That is a heads-up, not a refusal. See [Update Eligibility & Blockers](/docs/configuration/actions/update-eligibility#maintenance-window-closed-in-the-update-dialog). + +Only allow automatic updates between 2 AM and 4 AM in Europe/Berlin: @@ -696,7 +700,7 @@ services: ... environment: - DD_WATCHER_LOCAL_CRON=0 * * * * - - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW=0 2-3 * * * + - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW=* 2-3 * * * - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW_TZ=Europe/Berlin ``` @@ -705,7 +709,7 @@ services: ```bash docker run \ -e DD_WATCHER_LOCAL_CRON="0 * * * *" \ - -e DD_WATCHER_LOCAL_MAINTENANCE_WINDOW="0 2-3 * * *" \ + -e DD_WATCHER_LOCAL_MAINTENANCE_WINDOW="* 2-3 * * *" \ -e DD_WATCHER_LOCAL_MAINTENANCE_WINDOW_TZ="Europe/Berlin" \ ... codeswhat/drydock @@ -713,6 +717,21 @@ docker run \ + +`MAINTENANCE_WINDOW` is not a start/end range. Drydock evaluates the expression **once per minute** and the window is open only during the minutes the cron matches. So `0 2-3 * * *` is open for exactly two minutes a day, 02:00 and 03:00, not from 2 AM to 4 AM. Use `*` in the minute field to keep the window open for whole hours: + +| Expression | Window is open | +| --- | --- | +| `* 2-3 * * *` | every minute from 02:00 to 03:59 | +| `*/5 2-3 * * *` | every 5th minute from 02:00 to 03:55 | +| `* 2-3 * * 6,0` | 02:00 to 03:59 on Saturday and Sunday | +| `0 2-3 * * *` | 02:00 and 03:00 only, one minute each | + +A minute-precise expression still works if your watcher `CRON` ticks on exactly those minutes, but the container list and the update dialog report the window as closed the rest of the time, which is confusing. Prefer the `*` form. + + +The evaluated timezone is `MAINTENANCE_WINDOW_TZ`, defaulting to `UTC` — not the host clock or your browser. The dashboard shows which watchers have a window and when the next one opens. + ## Image Set Presets Use `IMGSET` to define reusable defaults by image reference. This is useful when many containers need the same tag filters, link template, icon, or trigger routing. diff --git a/content/docs/current/faq/index.mdx b/content/docs/current/faq/index.mdx index 27a3cb2fb..0c5a10592 100644 --- a/content/docs/current/faq/index.mdx +++ b/content/docs/current/faq/index.mdx @@ -478,3 +478,17 @@ See the [Quick start](/docs/quickstart) for the recommended `DD_*` / `dd.*` conf ## Home Assistant wud-card or Homepage widget shows no data The Home Assistant [wud-card](https://github.com/angryvoegi/wud-card) and Homepage's native `whatsupdocker` widget both speak WUD's old unversioned, bare-array API shape rather than drydock's `/api/v1` envelope. Set `DD_COMPAT_WUDCARD=true` (default `false`) to reshape the handful of endpoints those integrations call back into the shape they expect — see [wud-card compatibility](/docs/configuration/server#wud-card-compatibility) for details, including the CORS setup needed if Home Assistant runs on a different origin than drydock. Homepage doesn't need CORS configuration, since it proxies the request server-side. + +## Why doesn't my edge agent apply updates? + +An Edge (Portwing) agent only proxies discovery/watch state, continuous container logs, and container delete over its WebSocket tunnel — `docker` and `dockercompose` action triggers are not yet proxied over Edge Mode. If update actions on an edge-connected host silently do nothing, or never show up as available in the UI, that's expected behavior in v1.6, not a misconfiguration. + +**Fix:** Configure update triggers on a standard (inbound HTTP) agent instead. See [Edge-agent dial-out (Portwing)](/docs/configuration/agents#edge-agent-dial-out-portwing) for exactly what Edge Mode does and doesn't proxy today. + +## How do I rotate or revoke an edge-agent key? + +1. Register the new key: `POST /api/v1/portwing/keys` with the new `pubkeyBase64` and a `label` (or add it to the file pointed at by `DD_PORTWING_AUTHORIZED_KEYS` and restart the controller). +2. Point the agent at the new private key and its `keyId`, and restart it — it reconnects and authenticates under the new key. +3. Once the agent is confirmed running on the new key, revoke the old one: `DELETE /api/v1/portwing/keys/:keyId`. This disconnects any live session still authenticated with it and frees the old key for audit-only retention. + +See the [Portwing Edge API](/docs/api/portwing#key-registry) for the full key-registry reference. diff --git a/content/docs/current/getting-started/index.mdx b/content/docs/current/getting-started/index.mdx index d347c4823..2a118f6e7 100644 --- a/content/docs/current/getting-started/index.mdx +++ b/content/docs/current/getting-started/index.mdx @@ -297,11 +297,13 @@ Restrict when auto-updates can run: ```yaml environment: - - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW=0 2-6 * * * # 2am–6am daily + - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW=* 2-6 * * * # 2am–6:59am daily - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW_TZ=America/New_York ``` -Updates detected outside the window are queued and applied when it opens. +Updates detected outside the window are queued and applied when it opens. The window only gates what drydock does on its own — you can still update any container manually at any time, and the confirm dialog will note the window is closed and offer **Update anyway**. + +The expression is matched minute by minute rather than as a start/end range, so keep `*` in the minute field: `0 2-6 * * *` would open the window for one minute at the top of each of those hours. See [Watchers — Maintenance window](/docs/configuration/watchers#maintenance-window). ### Lifecycle hooks @@ -385,7 +387,7 @@ services: - DD_WATCHER_LOCAL_HOST=socket-proxy - DD_WATCHER_LOCAL_PORT=2375 - DD_WATCHER_LOCAL_CRON=0 */6 * * * - - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW=0 2-6 * * * + - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW=* 2-6 * * * # Auth - DD_AUTH_BASIC_ADMIN_USER=admin diff --git a/content/docs/current/guides/security/index.mdx b/content/docs/current/guides/security/index.mdx index c33fec6a1..f105f2c78 100644 --- a/content/docs/current/guides/security/index.mdx +++ b/content/docs/current/guides/security/index.mdx @@ -93,6 +93,8 @@ services: The preset's allow-rules are the security boundary, in the same spirit as the proxy's environment flags — everything not explicitly allowed is denied. +sockguard also ships a separate [`portwing-with-exec.yaml`](https://github.com/CodesWhat/sockguard/blob/dev/v1.5/app/configs/portwing-with-exec.yaml) preset for deployments that need remote exec through the Edge agent (see the [exec session protocol](/docs/api/portwing#exec-session-protocol)). To activate it, replace the preset file the compose example mounts (swap `sockguard.yaml` for a copy of `portwing-with-exec.yaml`, or point the sockguard service's config mount at it) and restart sockguard — merely having the file present changes nothing, and the default preset above denies all exec. Exec authorization — privileged/root escalation and per-command allowlisting — is enforced by sockguard's exec policy, not by drydock. + diff --git a/content/docs/current/monitoring/index.mdx b/content/docs/current/monitoring/index.mdx index e157de687..6b2631d77 100644 --- a/content/docs/current/monitoring/index.mdx +++ b/content/docs/current/monitoring/index.mdx @@ -294,7 +294,7 @@ Operational notes: Use the audit API (`/api/v1/audit`) and Audit UI view to track runtime events alongside metrics. Current event coverage includes: -- `update-available` on a new update transition or changed target. Repeated identical watcher reports are deduplicated for one hour by default; configure the window with `DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS`. +- `update-available` on first detection, on a change of the update target, or on re-detection after the update resolves or the container is removed/recreated. Unchanged updates are never re-asserted just because time has passed. - `security-alert` when security scans detect high/critical issues. - `agent-disconnect` when a connected agent transitions to disconnected state. diff --git a/test/qa-compose.yml b/test/qa-compose.yml index 62cbcc748..0bc734903 100644 --- a/test/qa-compose.yml +++ b/test/qa-compose.yml @@ -94,8 +94,12 @@ services: # - DD_SECURITY_SBOM_FORMATS=spdx-json,cyclonedx-json # - DD_SECURITY_VERIFY_SIGNATURES=false # --- Maintenance window (QA) --- - # Uncomment to test; adjust cron to match/not-match current time - # - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW=0 2-6 * * * + # To test: also set DD_WATCHER_LOCAL_CRON above to a runnable schedule + # (it's parked at `0 0 29 2 *` and WATCHEVENTS=false, so nothing + # reevaluates the window otherwise), then pick a UTC window that + # matches/misses the current time. + # Matched per minute, so keep `*` in the minute field for a real window. + # - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW=* 2-6 * * * # - DD_WATCHER_LOCAL_MAINTENANCE_WINDOW_TZ=UTC # --- Remote Docker watcher (DinD) --- - DD_WATCHER_REMOTE_HOST=docker-remote diff --git a/ui/src/components/ConfirmDialog.vue b/ui/src/components/ConfirmDialog.vue index 625ae29c0..c5a3a1c98 100644 --- a/ui/src/components/ConfirmDialog.vue +++ b/ui/src/components/ConfirmDialog.vue @@ -74,6 +74,16 @@ onUnmounted(() => globalThis.removeEventListener('keydown', handleKeydown));
{{ current.message }} + + {{ current.link.label }} +
diff --git a/ui/src/composables/useConfirmDialog.ts b/ui/src/composables/useConfirmDialog.ts index 8dc7fb0d2..d7b9ba790 100644 --- a/ui/src/composables/useConfirmDialog.ts +++ b/ui/src/composables/useConfirmDialog.ts @@ -1,11 +1,18 @@ import { ref } from 'vue'; +interface ConfirmDialogLink { + href: string; + label: string; +} + interface ConfirmOptions { header: string; message: string; acceptLabel?: string; rejectLabel?: string; severity?: 'danger' | 'warn'; + /** Optional docs/help link rendered below the message body. */ + link?: ConfirmDialogLink; accept?: () => void | Promise; reject?: () => void; } diff --git a/ui/src/composables/useUpdateStatus.ts b/ui/src/composables/useUpdateStatus.ts index b2cef99e3..6eca7211d 100644 --- a/ui/src/composables/useUpdateStatus.ts +++ b/ui/src/composables/useUpdateStatus.ts @@ -76,7 +76,7 @@ const POLICY_REASONS = new Set([ 'maturity-not-reached', ]); -const ELIGIBILITY_DOCS = +export const ELIGIBILITY_DOCS = 'https://getdrydock.com/docs/configuration/actions/update-eligibility#reasons-reference'; function conditionAction( diff --git a/ui/src/locales/en/containerComponents.json b/ui/src/locales/en/containerComponents.json index d6a4f2861..1a238c94c 100644 --- a/ui/src/locales/en/containerComponents.json +++ b/ui/src/locales/en/containerComponents.json @@ -461,6 +461,7 @@ "messageTagChange": "Update {name}? This will change the image tag from :{currentTag} to :{newTag}{kind}.", "messageDigestChange": "Update {name}? A newer build of :{currentTag} is available (digest change).", "softBlockerSuffix": "\n\nThis update is currently policy-blocked:\n{list}\n\nClick Update anyway to override.", + "softBlockerLearnMore": "Learn more", "acceptLabel": "Update", "acceptLabelOverride": "Update anyway" }, diff --git a/ui/src/views/DashboardView.vue b/ui/src/views/DashboardView.vue index 56e12f656..cdfbfe06e 100644 --- a/ui/src/views/DashboardView.vue +++ b/ui/src/views/DashboardView.vue @@ -15,6 +15,7 @@ import AppIconButton from '@/components/AppIconButton.vue'; import { useBreakpoints } from '../composables/useBreakpoints'; import { useConfirmDialog } from '../composables/useConfirmDialog'; import { useToast } from '../composables/useToast'; +import { ELIGIBILITY_DOCS } from '../composables/useUpdateStatus'; import { useUpdateMode } from '../composables/useUpdateMode'; import { preferences } from '../preferences/store'; import { ROUTES } from '../router/routes'; @@ -611,6 +612,13 @@ function confirmDashboardUpdate(row: RecentUpdateRow) { ? t('containerComponents.confirmDialogs.update.acceptLabelOverride') : t('dashboardView.confirm.updateContainer.accept'), rejectLabel: t('common.cancel'), + link: + softBlockers.length > 0 + ? { + href: ELIGIBILITY_DOCS, + label: t('containerComponents.confirmDialogs.update.softBlockerLearnMore'), + } + : undefined, accept: async () => { if (!managedUpdatesAllowed.value) { toast.warning(t('containerComponents.updateStatus.summary.notify')); diff --git a/ui/src/views/containers/useContainerActions.ts b/ui/src/views/containers/useContainerActions.ts index 298675748..759ea2f72 100644 --- a/ui/src/views/containers/useContainerActions.ts +++ b/ui/src/views/containers/useContainerActions.ts @@ -6,6 +6,7 @@ import { useScanLifecycle } from '../../composables/useScanLifecycle'; import { useServerFeatures } from '../../composables/useServerFeatures'; import { useToast } from '../../composables/useToast'; import { useUpdateBatches } from '../../composables/useUpdateBatches'; +import { ELIGIBILITY_DOCS } from '../../composables/useUpdateStatus'; import { deleteContainer as apiDeleteContainer, refreshContainer as apiRefreshContainer, @@ -794,6 +795,13 @@ function createConfirmHandlers(args: { ? args.t('containerComponents.confirmDialogs.update.acceptLabelOverride') : args.t('containerComponents.confirmDialogs.update.acceptLabel'), severity: 'warn', + link: + softBlockers.length > 0 + ? { + href: ELIGIBILITY_DOCS, + label: args.t('containerComponents.confirmDialogs.update.softBlockerLearnMore'), + } + : undefined, accept: () => args.executeAction(target, apiUpdateContainer, { kind: 'update', diff --git a/ui/tests/components/ConfirmDialog.spec.ts b/ui/tests/components/ConfirmDialog.spec.ts index 1bb320ebd..e41958e90 100644 --- a/ui/tests/components/ConfirmDialog.spec.ts +++ b/ui/tests/components/ConfirmDialog.spec.ts @@ -93,4 +93,43 @@ describe('ConfirmDialog', () => { wrapper.unmount(); }); + + it('does not render a link when none is provided', async () => { + const wrapper = mount(ConfirmDialog); + showDialog(); + await nextTick(); + + expect(document.body.querySelector('[data-test="confirm-dialog-link"]')).toBeNull(); + + wrapper.unmount(); + }); + + it('renders a docs link below the message when provided', async () => { + const wrapper = mount(ConfirmDialog); + visible.value = true; + current.value = { + header: 'Update Container', + message: 'This update is currently policy-blocked.', + acceptLabel: 'Update anyway', + rejectLabel: 'Cancel', + link: { + href: 'https://getdrydock.com/docs/configuration/actions/update-eligibility#reasons-reference', + label: 'Learn more', + }, + }; + await nextTick(); + + const link = document.body.querySelector( + '[data-test="confirm-dialog-link"]', + ) as HTMLAnchorElement | null; + expect(link).toBeTruthy(); + expect(link?.getAttribute('href')).toBe( + 'https://getdrydock.com/docs/configuration/actions/update-eligibility#reasons-reference', + ); + expect(link?.getAttribute('target')).toBe('_blank'); + expect(link?.getAttribute('rel')).toBe('noopener noreferrer'); + expect(link?.textContent?.trim()).toBe('Learn more'); + + wrapper.unmount(); + }); }); diff --git a/ui/tests/views/DashboardView.spec.ts b/ui/tests/views/DashboardView.spec.ts index 13923df54..37582bfe3 100644 --- a/ui/tests/views/DashboardView.spec.ts +++ b/ui/tests/views/DashboardView.spec.ts @@ -2162,6 +2162,10 @@ describe('DashboardView', () => { expect(confirm.current.value?.message).toContain('Update is still maturing for 3 more days.'); expect(confirm.current.value?.acceptLabel).toBe('Update anyway'); + expect(confirm.current.value?.link?.href).toBe( + 'https://getdrydock.com/docs/configuration/actions/update-eligibility#reasons-reference', + ); + expect(confirm.current.value?.link?.label).toBe('Learn more'); }); it('shows the shared update-started toast when a single dashboard update starts successfully', async () => { @@ -2179,6 +2183,9 @@ describe('DashboardView', () => { const { toasts } = useToast(); await wrapper.find('[data-test="dashboard-update-btn"]').trigger('click'); + + expect(confirm.current.value?.link).toBeUndefined(); + await confirm.accept(); await flushPromises(); diff --git a/ui/tests/views/containers/useContainerActions.spec.ts b/ui/tests/views/containers/useContainerActions.spec.ts index e491a9a9e..72f89e77b 100644 --- a/ui/tests/views/containers/useContainerActions.spec.ts +++ b/ui/tests/views/containers/useContainerActions.spec.ts @@ -1823,10 +1823,12 @@ describe('useContainerActions', () => { const confirmCall = mocks.confirmRequire.mock.calls[0][0] as { header: string; acceptLabel: string; + link?: { href: string; label: string }; accept?: () => Promise; }; expect(confirmCall.header).toBe('Update Container'); expect(confirmCall.acceptLabel).toBe('Update'); + expect(confirmCall.link).toBeUndefined(); await confirmCall.accept?.(); expect(mocks.updateContainer).toHaveBeenCalledWith('container-1'); @@ -4061,11 +4063,16 @@ describe('useContainerActions', () => { const confirmCall = mocks.confirmRequire.mock.calls[0][0] as { message: string; acceptLabel: string; + link?: { href: string; label: string }; }; expect(confirmCall.message).toContain('• Container is snoozed until 2026-06-01'); expect(confirmCall.message).toContain('• Trigger is not in the include list'); expect(confirmCall.message).toContain('Click Update anyway to override.'); expect(confirmCall.acceptLabel).toBe('Update anyway'); + expect(confirmCall.link).toEqual({ + href: 'https://getdrydock.com/docs/configuration/actions/update-eligibility#reasons-reference', + label: 'Learn more', + }); }); it('sets inputError and returns early from scanContainer when container actions are disabled', async () => {