diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index d4d6dacb1..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,206 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_call: - secrets: - CODECOV_TOKEN: - required: false - -permissions: - contents: read - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: 24 - - - name: Install dependencies - run: | - (cd app && npm ci) - (cd e2e && npm ci) - (cd ui && npm ci) - - - name: Lint app - run: npm run lint - working-directory: app - - - name: Lint e2e - run: npm run lint - working-directory: e2e - - - name: Lint ui - run: npm run lint - working-directory: ui - - - name: ESM readiness check - run: ./scripts/esm-readiness.sh || true - - test: - name: Test & Coverage - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: 24 - - - name: Install app dependencies - run: npm ci - working-directory: app - - - name: Install ui dependencies - run: npm ci - working-directory: ui - - - name: Run app tests - run: npm test - working-directory: app - - - name: Run ui tests - run: npm run test:unit - working-directory: ui - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: app/coverage/lcov.info,ui/coverage/lcov.info - flags: app,ui - fail_ci_if_error: false - - compose-trigger-log-smoke: - name: Compose Trigger Log Smoke - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Start workload containers - run: | - docker run -d \ - --name dd-compose-label-test \ - --label dd.watch=true \ - --label dd.compose.file=/tmp/ci-stack/docker-compose.yml \ - --label dd.compose.auto=true \ - --label dd.compose.prune=false \ - alpine:3.21 sleep 600 - - docker run -d \ - --name dd-compose-unlabeled-test \ - --label dd.watch=true \ - alpine:3.21 sleep 600 - - - name: Build drydock image - run: docker build -t drydock:ci-trigger --build-arg DD_VERSION=ci . - - - name: Start drydock - run: | - docker run -d \ - --name drydock-ci-trigger \ - --volume /var/run/docker.sock:/var/run/docker.sock \ - --env DD_WATCHER_LOCAL_WATCHBYDEFAULT=true \ - --env DD_WATCHER_LOCAL_WATCHATSTART=true \ - --env DD_WATCHER_LOCAL_WATCHEVENTS=true \ - drydock:ci-trigger - - - name: Assert compose trigger creation from labels - run: | - found=0 - for i in $(seq 1 60); do - logs="$(docker logs drydock-ci-trigger 2>&1 || true)" - - if echo "$logs" | grep -Eq 'trigger\.dockercompose\..*dd-compose-label-test'; then - found=1 - echo "Found dockercompose trigger registration log" - break - fi - sleep 2 - done - - if [ "$found" -ne 1 ]; then - echo "Did not find dockercompose trigger creation log for dd-compose-label-test" - docker logs drydock-ci-trigger 2>&1 || true - exit 1 - fi - - logs="$(docker logs drydock-ci-trigger 2>&1 || true)" - if ! echo "$logs" | grep -Eq '"auto":("true"|true)'; then - echo "Did not find compose trigger auto configuration in logs" - docker logs drydock-ci-trigger 2>&1 || true - exit 1 - fi - if ! echo "$logs" | grep -Eq '"prune":("false"|false)'; then - echo "Did not find compose trigger prune configuration in logs" - docker logs drydock-ci-trigger 2>&1 || true - exit 1 - fi - if ! echo "$logs" | grep -Eq '"requireinclude":("true"|true)'; then - echo "Did not find scoped requireinclude=true configuration in logs" - docker logs drydock-ci-trigger 2>&1 || true - exit 1 - fi - if echo "$logs" | grep -Eq 'trigger\.dockercompose\..*dd-compose-unlabeled-test'; then - echo "Unexpected dockercompose trigger registration for unlabeled container" - docker logs drydock-ci-trigger 2>&1 || true - exit 1 - fi - - - name: Dump drydock logs on failure - if: failure() - run: docker logs drydock-ci-trigger 2>&1 || true - - - name: Cleanup containers - if: always() - run: | - docker rm -f drydock-ci-trigger dd-compose-label-test dd-compose-unlabeled-test >/dev/null 2>&1 || true - - build: - name: Build - runs-on: ubuntu-latest - needs: [lint, test] - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: 24 - - - name: Install ui dependencies - run: npm ci - working-directory: ui - - - name: Build ui - run: npm run build - working-directory: ui - - - name: Docker build (smoke test) - run: docker build --no-cache -t drydock --build-arg DD_VERSION=ci . diff --git a/CHANGELOG.md b/CHANGELOG.md index cf62b953e..217bff096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Tag update display accuracy** — Fixed update classification/display so tag-based updates are shown correctly as tag updates. + ## [1.5.0-rc.12] — 2026-04-22 ### Fixed diff --git a/app/api/component.test.ts b/app/api/component.test.ts index d88ea0867..98bab4229 100644 --- a/app/api/component.test.ts +++ b/app/api/component.test.ts @@ -65,42 +65,6 @@ describe('Component Router', () => { expect(result.agent).toBe('remote-agent'); }); - test('should include requireinclude for triggers even when masked config omits it', () => { - const comp = { - kind: 'trigger', - type: 'dockercompose', - name: 'my-service', - configuration: { requireinclude: true, auto: true }, - maskConfiguration: vi.fn(() => ({ auto: true })), - agent: undefined, - }; - - const result = component.mapComponentToItem('dockercompose.my-service', comp, 'trigger'); - - expect(result.configuration).toEqual({ - auto: true, - requireinclude: true, - }); - }); - - test('should keep masked requireinclude when already present', () => { - const comp = { - kind: 'trigger', - type: 'dockercompose', - name: 'my-service', - configuration: { requireinclude: true, auto: true }, - maskConfiguration: vi.fn(() => ({ auto: true, requireinclude: false })), - agent: undefined, - }; - - const result = component.mapComponentToItem('dockercompose.my-service', comp, 'trigger'); - - expect(result.configuration).toEqual({ - auto: true, - requireinclude: false, - }); - }); - test('should fallback to raw configuration when maskConfiguration is unavailable', () => { const comp = { type: 'docker', diff --git a/app/api/component.ts b/app/api/component.ts index 61b53567b..441387e3d 100644 --- a/app/api/component.ts +++ b/app/api/component.ts @@ -73,18 +73,6 @@ export function mapComponentToItem( ? redactTriggerConfigurationInfrastructureDetails(configuration) : configuration; - // Preserve requireinclude for triggers even when maskConfiguration omits it - if ( - kind === 'trigger' && - (component as any).configuration?.requireinclude !== undefined && - sanitizedConfiguration != null && - typeof sanitizedConfiguration === 'object' && - !Array.isArray(sanitizedConfiguration) && - (sanitizedConfiguration as any).requireinclude === undefined - ) { - (sanitizedConfiguration as any).requireinclude = (component as any).configuration.requireinclude; - } - const item: ApiComponent = { id: key, type: component.type, diff --git a/app/api/container.test.ts b/app/api/container.test.ts index 34969e0c2..c63a003a7 100644 --- a/app/api/container.test.ts +++ b/app/api/container.test.ts @@ -2311,22 +2311,6 @@ describe('Container Router', () => { expect(res.json).toHaveBeenCalledWith({ data: expect.any(Array), total: 1 }); }); - test('should associate trigger when configuration is missing', async () => { - const res = await callGetContainerTriggers({ id: 'c1' }, [ - { type: 'slack', name: 'default' }, - ]); - - const triggers = getTriggersFromResponse(res); - expect(triggers).toHaveLength(1); - expect(triggers[0]).toEqual( - expect.objectContaining({ - type: 'slack', - name: 'default', - configuration: {}, - }), - ); - }); - test('should filter triggers with triggerInclude', async () => { Trigger.parseIncludeOrIncludeTriggerString.mockReturnValue({ id: 'slack.default' }); Trigger.doesReferenceMatchId.mockImplementation((ref, id) => ref === id); @@ -2370,16 +2354,6 @@ describe('Container Router', () => { expect(getTriggersFromResponse(res)).toHaveLength(0); }); - test('should keep non-docker local triggers for remote containers', async () => { - const res = await callGetContainerTriggers({ id: 'c1', agent: 'agent-1' }, [ - { type: 'slack', name: 'default', configuration: {} }, - ]); - - const triggers = getTriggersFromResponse(res); - expect(triggers).toHaveLength(1); - expect(triggers[0].type).toBe('slack'); - }); - test('should include triggers with matching include threshold', async () => { Trigger.parseIncludeOrIncludeTriggerString.mockReturnValue({ id: 'slack.default', @@ -2395,18 +2369,6 @@ describe('Container Router', () => { expect(triggers).toHaveLength(1); expect(triggers[0].configuration.threshold).toBe('all'); }); - - test('should exclude requireinclude triggers when container has no include list', async () => { - const res = await callGetContainerTriggers({ id: 'c1' }, [ - { type: 'slack', name: 'default', configuration: { requireinclude: true } }, - { type: 'email', name: 'default', configuration: { requireinclude: false } }, - ]); - - expect(res.status).toHaveBeenCalledWith(200); - const triggers = getTriggersFromResponse(res); - expect(triggers).toHaveLength(1); - expect(triggers[0].type).toBe('email'); - }); }); describe('runTrigger', () => { @@ -2458,24 +2420,6 @@ describe('Container Router', () => { }); }); - test('should return 400 when trigger conditions are not met', async () => { - const mockTrigger = { - mustTrigger: vi.fn().mockReturnValue(false), - trigger: vi.fn(), - }; - storeContainer.getContainer.mockReturnValue({ id: 'c1' }); - registry.getState.mockReturnValue({ watcher: {}, trigger: { 'slack.default': mockTrigger } }); - const res = await callRunTrigger({ id: 'c1', triggerType: 'slack', triggerName: 'default' }); - - expect(mockTrigger.trigger).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.stringContaining('Trigger conditions not met'), - }), - ); - }); - test('should use triggerAgent in trigger id when provided', async () => { const mockTrigger = { trigger: vi.fn().mockResolvedValue(undefined) }; storeContainer.getContainer.mockReturnValue({ id: 'c1' }); diff --git a/app/api/trigger.test.ts b/app/api/trigger.test.ts index 15e2caa46..aaa297c02 100644 --- a/app/api/trigger.test.ts +++ b/app/api/trigger.test.ts @@ -136,28 +136,6 @@ describe('Trigger Router', () => { test('should run trigger successfully', async () => { const mockTrigger = { - mustTrigger: vi.fn().mockReturnValue(true), - trigger: vi.fn().mockResolvedValue(undefined), - }; - registry.getState.mockReturnValue({ - trigger: { 'slack.default': mockTrigger }, - }); - - const req = { - params: { type: 'slack', name: 'default' }, - body: { id: 'c1' }, - }; - const res = createResponse(); - - await runTrigger(req, res); - - expect(mockTrigger.trigger).toHaveBeenCalledWith(expect.objectContaining({ id: 'c1' })); - expect(res.status).toHaveBeenCalledWith(200); - }); - - test('should run trigger when mustTrigger is not a function', async () => { - const mockTrigger = { - mustTrigger: true, trigger: vi.fn().mockResolvedValue(undefined), }; registry.getState.mockReturnValue({ @@ -263,7 +241,6 @@ describe('Trigger Router', () => { test('should set default updateKind when missing', async () => { const mockTrigger = { - mustTrigger: vi.fn().mockReturnValue(true), trigger: vi.fn().mockResolvedValue(undefined), }; registry.getState.mockReturnValue({ @@ -293,7 +270,6 @@ describe('Trigger Router', () => { test('should not override existing updateKind', async () => { const mockTrigger = { - mustTrigger: vi.fn().mockReturnValue(true), trigger: vi.fn().mockResolvedValue(undefined), }; registry.getState.mockReturnValue({ @@ -344,7 +320,6 @@ describe('Trigger Router', () => { test('should return 500 when trigger throws', async () => { const mockTrigger = { - mustTrigger: vi.fn().mockReturnValue(true), trigger: vi.fn().mockRejectedValue(new Error('trigger failed')), }; registry.getState.mockReturnValue({ @@ -454,58 +429,6 @@ describe('Trigger Router', () => { expect(responsePayload.error).toBe('Error when running trigger slack.default'); expect(responsePayload.details).toBeUndefined(); }); - - test('should return 400 when trigger conditions are not met', async () => { - const mockTrigger = { - mustTrigger: vi.fn().mockReturnValue(false), - trigger: vi.fn(), - }; - registry.getState.mockReturnValue({ - trigger: { 'slack.default': mockTrigger }, - }); - - const req = { - params: { type: 'slack', name: 'default' }, - body: { id: 'c1' }, - }; - const res = createResponse(); - - await runTrigger(req, res); - - expect(mockTrigger.trigger).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.stringContaining('Trigger conditions not met'), - }), - ); - }); - - test('should return 400 when trigger conditions are not met and container id is missing', async () => { - const mockTrigger = { - mustTrigger: vi.fn().mockReturnValue(false), - trigger: vi.fn(), - }; - registry.getState.mockReturnValue({ - trigger: { 'slack.default': mockTrigger }, - }); - - const req = { - params: { type: 'slack', name: 'default' }, - body: {}, - }; - const res = createResponse(); - - await runTrigger(req, res); - - expect(mockTrigger.trigger).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.stringContaining('Trigger conditions not met'), - }), - ); - }); }); describe('runRemoteTrigger', () => { @@ -584,13 +507,6 @@ describe('Trigger Router', () => { runRemoteTrigger: vi.fn().mockResolvedValue(undefined), }; agent.getAgent.mockReturnValue(mockAgentClient); - registry.getState.mockReturnValue({ - trigger: { - 'my-agent.slack.default': { - mustTrigger: vi.fn().mockReturnValue(true), - }, - }, - }); const handler = getRemoteTriggerHandler(); const req = { @@ -609,36 +525,6 @@ describe('Trigger Router', () => { expect(res.status).toHaveBeenCalledWith(200); }); - test('should run remote trigger when local proxy mustTrigger is not a function', async () => { - const mockAgentClient = { - runRemoteTrigger: vi.fn().mockResolvedValue(undefined), - }; - agent.getAgent.mockReturnValue(mockAgentClient); - registry.getState.mockReturnValue({ - trigger: { - 'my-agent.slack.default': { - mustTrigger: true, - }, - }, - }); - - const handler = getRemoteTriggerHandler(); - const req = { - params: { agent: 'my-agent', type: 'slack', name: 'default' }, - body: { id: 'c1' }, - }; - const res = createResponse(); - - await handler(req, res); - - expect(mockAgentClient.runRemoteTrigger).toHaveBeenCalledWith( - { id: 'c1' }, - 'slack', - 'default', - ); - expect(res.status).toHaveBeenCalledWith(200); - }); - test('should return 409 when remote trigger targets a temporary rollback container', async () => { const mockAgentClient = { runRemoteTrigger: vi.fn().mockResolvedValue(undefined), @@ -661,49 +547,11 @@ describe('Trigger Router', () => { expect(mockAgentClient.runRemoteTrigger).not.toHaveBeenCalled(); }); - test('should return 400 when remote trigger conditions are not met', async () => { - const mockAgentClient = { - runRemoteTrigger: vi.fn().mockResolvedValue(undefined), - }; - agent.getAgent.mockReturnValue(mockAgentClient); - registry.getState.mockReturnValue({ - trigger: { - 'my-agent.slack.default': { - mustTrigger: vi.fn().mockReturnValue(false), - }, - }, - }); - - const handler = getRemoteTriggerHandler(); - const req = { - params: { agent: 'my-agent', type: 'slack', name: 'default' }, - body: { id: 'c1' }, - }; - const res = createResponse(); - - await handler(req, res); - - expect(mockAgentClient.runRemoteTrigger).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.stringContaining('Trigger conditions not met'), - }), - ); - }); - test('should return 500 when remote trigger throws', async () => { const mockAgentClient = { runRemoteTrigger: vi.fn().mockRejectedValue(new Error('remote error')), }; agent.getAgent.mockReturnValue(mockAgentClient); - registry.getState.mockReturnValue({ - trigger: { - 'my-agent.slack.default': { - mustTrigger: vi.fn().mockReturnValue(true), - }, - }, - }); const handler = getRemoteTriggerHandler(); const req = { diff --git a/app/api/trigger.ts b/app/api/trigger.ts index a8001d368..db87106e0 100644 --- a/app/api/trigger.ts +++ b/app/api/trigger.ts @@ -169,16 +169,6 @@ export async function runTrigger(req: Request, res: Response) } try { - if (typeof triggerToRun.mustTrigger === 'function' && !triggerToRun.mustTrigger(containerToTrigger)) { - log.warn( - `Trigger conditions not met (type=${sanitizeLogParam(triggerType)}, name=${sanitizeLogParam(triggerName)}, container=${sanitizeLogParam(containerToTrigger.id || 'unknown')})`, - ); - res.status(400).json({ - error: `Trigger conditions not met for ${triggerType}.${triggerName} (check include/exclude and requireinclude settings)`, - }); - return; - } - if (UPDATE_TRIGGER_TYPES.has(triggerType.toLowerCase())) { const storedContainer = storeContainer.getContainer(containerToTrigger.id); if (!storedContainer) { @@ -249,21 +239,6 @@ async function runRemoteTrigger(req: Request, res: Respo } try { - const localProxyTrigger = registry.getState().trigger[`${agentName}.${triggerType}.${triggerName}`]; - if ( - localProxyTrigger && - typeof localProxyTrigger.mustTrigger === 'function' && - !localProxyTrigger.mustTrigger(containerToTrigger) - ) { - log.warn( - `Remote trigger conditions not met (agent=${sanitizeLogParam(agentName)}, type=${sanitizeLogParam(triggerType)}, name=${sanitizeLogParam(triggerName)}, container=${sanitizeLogParam(containerToTrigger.id)})`, - ); - res.status(400).json({ - error: `Trigger conditions not met for ${triggerType}.${triggerName} on agent ${agentName} (check include/exclude and requireinclude settings)`, - }); - return; - } - await agentClient.runRemoteTrigger(containerToTrigger, triggerType, triggerName); log.info( `Remote trigger executed with success (agent=${sanitizeLogParam(agentName)}, type=${sanitizeLogParam(triggerType)}, name=${sanitizeLogParam(triggerName)}, container=${sanitizeLogParam(containerToTrigger.id)})`, diff --git a/app/registry/index.ts b/app/registry/index.ts index f8f3c277b..fd8c2ffc4 100644 --- a/app/registry/index.ts +++ b/app/registry/index.ts @@ -97,6 +97,7 @@ const state: RegistryState = { }; const CONTAINER_TRIGGER_DEFAULT_NAME = 'container'; + const registrationWarnings: string[] = []; const authenticationRegistrationErrors: AuthenticationRegistrationError[] = []; diff --git a/app/triggers/providers/Trigger.test.ts b/app/triggers/providers/Trigger.test.ts index aa10b7a7f..b9d0bc545 100644 --- a/app/triggers/providers/Trigger.test.ts +++ b/app/triggers/providers/Trigger.test.ts @@ -150,7 +150,6 @@ const configurationValid = { mode: 'simple', auto: true, order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? "New image available for container " + container.name + container.notificationWatcherSuffix + " (tag " + currentTag + ")" : "New " + container.updateKind.kind + " found for container " + container.name + container.notificationWatcherSuffix}', @@ -1161,43 +1160,6 @@ test('mustTrigger should support name-only include with threshold for hybrid tri expect(discordTrigger.mustTrigger(containerMajor)).toBe(false); }); -test('mustTrigger should require explicit include when requireinclude is enabled', () => { - trigger.type = 'dockercompose'; - trigger.name = 'scoped'; - trigger.configuration = { - ...configurationValid, - requireinclude: true, - }; - - expect( - trigger.mustTrigger({ - updateKind: { - kind: 'tag', - semverDiff: 'major', - }, - }), - ).toBe(false); -}); - -test('mustTrigger should allow execution when requireinclude is enabled and trigger is explicitly included', () => { - trigger.type = 'dockercompose'; - trigger.name = 'scoped'; - trigger.configuration = { - ...configurationValid, - requireinclude: true, - }; - - expect( - trigger.mustTrigger({ - triggerInclude: 'dockercompose.scoped', - updateKind: { - kind: 'tag', - semverDiff: 'major', - }, - }), - ).toBe(true); -}); - test('renderSimpleTitle should replace placeholders when called', async () => { expect( trigger.renderSimpleTitle({ diff --git a/app/triggers/providers/Trigger.ts b/app/triggers/providers/Trigger.ts index 1b3360dda..8578d8497 100644 --- a/app/triggers/providers/Trigger.ts +++ b/app/triggers/providers/Trigger.ts @@ -481,7 +481,6 @@ export interface TriggerConfiguration extends ComponentConfiguration { auto?: boolean | TriggerAutoMode; order?: number; threshold?: string; - requireinclude?: boolean; mode?: string; once?: boolean; disabletitle?: boolean; @@ -2079,9 +2078,6 @@ class Trigger< } mustTrigger(containerResult: Container) { - if (this.configuration.requireinclude && !containerResult.triggerInclude) { - return false; - } return this.getMustTriggerDecision(containerResult).allowed; } @@ -2268,7 +2264,6 @@ class Trigger< simplebody: this.joi.string().default(DEFAULT_SIMPLE_BODY_TEMPLATE), batchtitle: this.joi.string().default('${containers.length} updates available'), resolvenotifications: this.joi.boolean().default(false), - requireinclude: this.joi.boolean().default(false), securitymode: this.joi .string() .insensitive() diff --git a/app/triggers/providers/command/Command.test.ts b/app/triggers/providers/command/Command.test.ts index 71df74023..ab995a52d 100644 --- a/app/triggers/providers/command/Command.test.ts +++ b/app/triggers/providers/command/Command.test.ts @@ -49,7 +49,6 @@ const configurationValid = { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? container.notificationAgentPrefix + "New image available for container " + container.name + container.notificationWatcherSuffix + " (tag " + currentTag + ")" : container.notificationAgentPrefix + "New " + container.updateKind.kind + " found for container " + container.name + container.notificationWatcherSuffix}', simplebody: diff --git a/app/triggers/providers/dockercompose/Dockercompose.test.ts b/app/triggers/providers/dockercompose/Dockercompose.test.ts index d2927302e..642ccac94 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.test.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.test.ts @@ -9,15 +9,12 @@ import * as backupStore from '../../../store/backup.js'; import { sleep } from '../../../util/sleep.js'; import Dockercompose, { testable_buildUpdatedComposeImage, + testable_hasExplicitRegistryHost, testable_normalizeImageWithoutDigest, testable_normalizeImplicitLatest, testable_normalizePostStartEnvironmentValue, testable_normalizePostStartHooks, testable_splitDigestReference, - testable_hasExplicitRegistryHost, - testable_normalizeImplicitLatest, - testable_normalizePostStartEnvironmentValue, - testable_normalizePostStartHooks, testable_updateComposeServiceImageInText, } from './Dockercompose.js'; @@ -1458,8 +1455,7 @@ describe('Dockercompose Trigger', () => { expect(writeComposeFileSpy).not.toHaveBeenCalled(); expect(dockerTriggerSpy).not.toHaveBeenCalled(); expect(mockLog.warn).toHaveBeenCalledWith(expect.stringContaining('digest-pinned')); - }); - }); + }); }); test('processComposeFile should handle mapCurrentVersionToUpdateVersion returning undefined', async () => { trigger.configuration.dryrun = false; @@ -6212,5 +6208,4 @@ describe('Dockercompose Trigger', () => { image: 'nginx:2.0.0', keptPinned: false, }); - }); -}); + });}); diff --git a/app/triggers/providers/dockercompose/Dockercompose.ts b/app/triggers/providers/dockercompose/Dockercompose.ts index 93a39a349..074aec64a 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.ts @@ -178,8 +178,6 @@ function getDockerApiFromWatcher(watcher: unknown): DockerApiLike | undefined { return maybeDockerApi as DockerApiLike; } -const COMPOSE_PROJECT_CONFIG_FILES_LABEL = 'com.docker.compose.project.config_files'; -const COMPOSE_PROJECT_WORKING_DIR_LABEL = 'com.docker.compose.project.working_dir'; const DD_COMPOSE_NATIVE_LABEL = 'dd.compose.native'; const WUD_COMPOSE_NATIVE_LABEL = 'wud.compose.native'; @@ -282,14 +280,30 @@ function getServiceKey(compose, container, currentImage) { const normalizedServiceImage = normalizeImplicitLatest(serviceImage); const normalizedServiceImageWithoutDigest = normalizeImageWithoutDigest(serviceImage); const normalizedImageToMatchWithoutDigest = normalizeImageWithoutDigest(imageToMatch); - return ( - serviceImage === imageToMatch || - normalizedServiceImage === imageToMatch || - normalizedServiceImageWithoutDigest === normalizedImageToMatchWithoutDigest || - serviceImage.includes(imageToMatch) || - normalizedServiceImage.includes(imageToMatch) || - normalizedServiceImageWithoutDigest.includes(normalizedImageToMatchWithoutDigest) - ); + + // Match priority (most strict to most lenient): + // 1) Exact `service.image` match. + if (serviceImage === imageToMatch) { + return true; + } + // 2) Exact match after normalizing implicit `:latest`. + if (normalizedServiceImage === imageToMatch) { + return true; + } + // 3) Digest-stripped match (handles digest-pinned images). + if (normalizedServiceImageWithoutDigest === normalizedImageToMatchWithoutDigest) { + return true; + } + // 4) Substring match against raw `service.image`. + if (serviceImage.includes(imageToMatch)) { + return true; + } + // 5) Substring match against normalized `service.image`. + if (normalizedServiceImage.includes(imageToMatch)) { + return true; + } + // 6) Substring match against digest-stripped `service.image`. + return normalizedServiceImageWithoutDigest.includes(normalizedImageToMatchWithoutDigest); }; return Object.keys(compose.services).find((serviceKey) => { @@ -664,7 +678,8 @@ class Dockercompose extends Docker { return null; } - getDefaultComposeFilePath(): string | null { if (!this.configuration.file) { + getDefaultComposeFilePath(): string | null { + if (!this.configuration.file) { return null; } try { diff --git a/app/triggers/providers/gotify/Gotify.test.ts b/app/triggers/providers/gotify/Gotify.test.ts index 71dbb8dca..bcd3bcd35 100644 --- a/app/triggers/providers/gotify/Gotify.test.ts +++ b/app/triggers/providers/gotify/Gotify.test.ts @@ -22,7 +22,6 @@ const configurationValid = { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? container.notificationAgentPrefix + "New image available for container " + container.name + container.notificationWatcherSuffix + " (tag " + currentTag + ")" : container.notificationAgentPrefix + "New " + container.updateKind.kind + " found for container " + container.name + container.notificationWatcherSuffix}', simplebody: @@ -81,7 +80,6 @@ test('maskConfiguration should mask sensitive data', async () => { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: configurationValid.simpletitle, simplebody: configurationValid.simplebody, batchtitle: configurationValid.batchtitle, diff --git a/app/triggers/providers/ifttt/Ifttt.test.ts b/app/triggers/providers/ifttt/Ifttt.test.ts index 586b87695..7567f6f65 100644 --- a/app/triggers/providers/ifttt/Ifttt.test.ts +++ b/app/triggers/providers/ifttt/Ifttt.test.ts @@ -15,7 +15,6 @@ const configurationValid = { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? container.notificationAgentPrefix + "New image available for container " + container.name + container.notificationWatcherSuffix + " (tag " + currentTag + ")" : container.notificationAgentPrefix + "New " + container.updateKind.kind + " found for container " + container.name + container.notificationWatcherSuffix}', diff --git a/app/triggers/providers/kafka/Kafka.test.ts b/app/triggers/providers/kafka/Kafka.test.ts index 91f355764..b05a38442 100644 --- a/app/triggers/providers/kafka/Kafka.test.ts +++ b/app/triggers/providers/kafka/Kafka.test.ts @@ -17,7 +17,6 @@ const configurationValid = { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? container.notificationAgentPrefix + "New image available for container " + container.name + container.notificationWatcherSuffix + " (tag " + currentTag + ")" : container.notificationAgentPrefix + "New " + container.updateKind.kind + " found for container " + container.name + container.notificationWatcherSuffix}', diff --git a/app/triggers/providers/mqtt/Mqtt.test.ts b/app/triggers/providers/mqtt/Mqtt.test.ts index 423ea04b5..e298d5064 100644 --- a/app/triggers/providers/mqtt/Mqtt.test.ts +++ b/app/triggers/providers/mqtt/Mqtt.test.ts @@ -48,7 +48,6 @@ const configurationValid = { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? container.notificationAgentPrefix + "New image available for container " + container.name + container.notificationWatcherSuffix + " (tag " + currentTag + ")" : container.notificationAgentPrefix + "New " + container.updateKind.kind + " found for container " + container.name + container.notificationWatcherSuffix}', diff --git a/app/triggers/providers/ntfy/Ntfy.test.ts b/app/triggers/providers/ntfy/Ntfy.test.ts index ad03f9da9..7639b0edb 100644 --- a/app/triggers/providers/ntfy/Ntfy.test.ts +++ b/app/triggers/providers/ntfy/Ntfy.test.ts @@ -15,7 +15,6 @@ const configurationValid = { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? "New image available for container " + container.name + " (tag " + currentTag + ")" : "New " + container.updateKind.kind + " found for container " + container.name}', diff --git a/app/triggers/providers/pushover/Pushover.test.ts b/app/triggers/providers/pushover/Pushover.test.ts index bd628c085..c22418513 100644 --- a/app/triggers/providers/pushover/Pushover.test.ts +++ b/app/triggers/providers/pushover/Pushover.test.ts @@ -23,7 +23,6 @@ const configurationValid = { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? container.notificationAgentPrefix + "New image available for container " + container.name + container.notificationWatcherSuffix + " (tag " + currentTag + ")" : container.notificationAgentPrefix + "New " + container.updateKind.kind + " found for container " + container.name + container.notificationWatcherSuffix}', @@ -108,7 +107,6 @@ test('maskConfiguration should mask sensitive data', async () => { priority: 0, auto: 'all', order: 100, - requireinclude: false, simplebody: '${isDigestUpdate ? container.notificationAgentPrefix + "Container " + container.name + container.notificationWatcherSuffix + " running tag " + currentTag + " has a newer image available" : container.notificationAgentPrefix + "Container " + container.name + container.notificationWatcherSuffix + " running with " + container.updateKind.kind + " " + container.updateKind.localValue + " can be updated to " + container.updateKind.kind + " " + container.updateKind.remoteValue}${container.result && container.result.link ? "\\n" + container.result.link : ""}', diff --git a/app/triggers/providers/slack/Slack.test.ts b/app/triggers/providers/slack/Slack.test.ts index f27c5ff88..57b026ce0 100644 --- a/app/triggers/providers/slack/Slack.test.ts +++ b/app/triggers/providers/slack/Slack.test.ts @@ -15,7 +15,6 @@ const configurationValid = { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? "New image available for container " + container.name + " (tag " + currentTag + ")" : "New " + container.updateKind.kind + " found for container " + container.name}', diff --git a/app/triggers/providers/smtp/Smtp.test.ts b/app/triggers/providers/smtp/Smtp.test.ts index f0be8b5e2..35911264f 100644 --- a/app/triggers/providers/smtp/Smtp.test.ts +++ b/app/triggers/providers/smtp/Smtp.test.ts @@ -17,7 +17,6 @@ const configurationValid = { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? container.notificationAgentPrefix + "New image available for container " + container.name + container.notificationWatcherSuffix + " (tag " + currentTag + ")" : container.notificationAgentPrefix + "New " + container.updateKind.kind + " found for container " + container.name + container.notificationWatcherSuffix}', diff --git a/app/triggers/providers/telegram/Telegram.test.ts b/app/triggers/providers/telegram/Telegram.test.ts index c302f6fe2..c5a976a06 100644 --- a/app/triggers/providers/telegram/Telegram.test.ts +++ b/app/triggers/providers/telegram/Telegram.test.ts @@ -19,7 +19,6 @@ const configurationValid = { once: true, auto: 'all', order: 100, - requireinclude: false, simpletitle: '${isDigestUpdate ? "New image available for container " + container.name + " (tag " + currentTag + ")" : "New " + container.updateKind.kind + " found for container " + container.name}', @@ -60,7 +59,6 @@ test('maskConfiguration should mask sensitive data', async () => { once: true, auto: 'all', order: 100, - requireinclude: false, simplebody: '${isDigestUpdate ? "Container " + container.name + " running tag " + currentTag + " has a newer image available" : "Container " + container.name + " running with " + container.updateKind.kind + " " + container.updateKind.localValue + " can be updated to " + container.updateKind.kind + " " + container.updateKind.remoteValue}${container.result && container.result.link ? "\\n" + container.result.link : ""}', diff --git a/app/watchers/providers/docker/Docker.test.ts b/app/watchers/providers/docker/Docker.test.ts index 37d35dc7b..1850df126 100644 --- a/app/watchers/providers/docker/Docker.test.ts +++ b/app/watchers/providers/docker/Docker.test.ts @@ -4,25 +4,52 @@ import { fullName } from '../../../model/container.js'; import * as registry from '../../../registry/index.js'; import * as storeContainer from '../../../store/container.js'; import { mockConstructor } from '../../../test/mock-constructor.js'; +import { _resetRegistryWebhookFreshStateForTests } from '../../registry-webhook-fresh.js'; +import { + filterRecreatedContainerAliases as testable_filterRecreatedContainerAliases, + getLabel as testable_getLabel, + pruneOldContainers as testable_pruneOldContainers, +} from './container-init.js'; import Docker, { testable_appendTriggerId, - testable_filterBySegmentCount, - testable_getContainerDisplayName, - testable_getContainerName, testable_getComposeFilePathFromLabels, - testable_getCurrentPrefix, - testable_getFirstDigitIndex, - testable_getImageForRegistryLookup, - testable_getImageReferenceCandidatesFromPattern, - testable_getImgsetSpecificity, - testable_getInspectValueByPath, - testable_getLabel, - testable_getOldContainers, testable_normalizeConfigNumberValue, - testable_pruneOldContainers, testable_removeTriggerId, - testable_shouldUpdateDisplayNameFromContainerName, } from './Docker.js'; +import { + getContainerDisplayName as testable_getContainerDisplayName, + getContainerName as testable_getContainerName, + getImageForRegistryLookup as testable_getImageForRegistryLookup, + getImageReferenceCandidatesFromPattern as testable_getImageReferenceCandidatesFromPattern, + getImgsetSpecificity as testable_getImgsetSpecificity, + getInspectValueByPath as testable_getInspectValueByPath, + getOldContainers as testable_getOldContainers, + shouldUpdateDisplayNameFromContainerName as testable_shouldUpdateDisplayNameFromContainerName, +} from './docker-helpers.js'; +import { normalizeContainer as testable_normalizeContainer } from './image-comparison.js'; +import { + filterBySegmentCount as testable_filterBySegmentCount, + getCurrentPrefix as testable_getCurrentPrefix, + getFirstDigitIndex as testable_getFirstDigitIndex, +} from './tag-candidates.js'; + +const mockDdEnvVars = vi.hoisted(() => ({}) as Record); +const mockDetectSourceRepoFromImageMetadata = vi.hoisted(() => vi.fn()); +const mockResolveSourceRepoForContainer = vi.hoisted(() => vi.fn()); +const mockGetFullReleaseNotesForContainer = vi.hoisted(() => vi.fn()); +const mockToContainerReleaseNotes = vi.hoisted(() => vi.fn((notes) => notes)); +vi.mock('../../../configuration/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + ddEnvVars: mockDdEnvVars, +})); +vi.mock('../../../release-notes/index.js', () => ({ + detectSourceRepoFromImageMetadata: (...args: unknown[]) => + mockDetectSourceRepoFromImageMetadata(...args), + resolveSourceRepoForContainer: (...args: unknown[]) => mockResolveSourceRepoForContainer(...args), + getFullReleaseNotesForContainer: (...args: unknown[]) => + mockGetFullReleaseNotesForContainer(...args), + toContainerReleaseNotes: (...args: unknown[]) => mockToContainerReleaseNotes(...args), +})); // Mock all dependencies vi.mock('dockerode'); @@ -724,8 +751,7 @@ describe('Docker Watcher', () => { digestpin: 'true', threshold: 'minor', }, - ); - }); + ); }); test('should keep watchatstart disabled when explicitly set to false', async () => { storeContainer.getContainers.mockReturnValue([]); @@ -3984,7 +4010,6 @@ describe('Docker Watcher', () => { ); }); }); - describe('Additional Coverage - applyRemoteAuthHeaders', () => { test('should keep remote watcher registered in blocked mode when credentials are incomplete', async () => { // Bypass validation by setting configuration directly after register @@ -4650,7 +4675,56 @@ describe('Docker Watcher', () => { test('removeTriggerId should return undefined when last trigger is removed', () => { expect(testable_removeTriggerId('dockercompose.test', 'dockercompose.test')).toBeUndefined(); - }); + test.each([ + { + aliasKey: 'dd.action.include', + legacyKey: 'dd.trigger.include', + fallbackKey: 'wud.trigger.include', + preferredValue: 'action-include', + }, + { + aliasKey: 'dd.notification.exclude', + legacyKey: 'dd.trigger.exclude', + fallbackKey: 'wud.trigger.exclude', + preferredValue: 'notification-exclude', + }, + ])('getLabel should prefer $aliasKey over $legacyKey and warn once for the legacy key', ({ + aliasKey, + legacyKey, + fallbackKey, + preferredValue, + }) => { + const warnedLegacyTriggerLabels = new Set(); + const warn = vi.fn(); + const labels = { + [aliasKey]: preferredValue, + [legacyKey]: 'legacy-value', + [fallbackKey]: 'legacy-fallback', + } as Record; + + expect( + testable_getLabel(labels, legacyKey, fallbackKey, { + warn, + warnedLegacyTriggerLabels, + }), + ).toBe(preferredValue); + expect( + testable_getLabel( + { + [legacyKey]: 'legacy-value', + [fallbackKey]: 'legacy-fallback', + } as Record, + legacyKey, + fallbackKey, + { + warn, + warnedLegacyTriggerLabels, + }, + ), + ).toBe('legacy-value'); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain(legacyKey); }); test('getCurrentPrefix should return the non-numeric prefix before the first digit', () => { expect(testable_getCurrentPrefix('v2026.2.1')).toBe('v'); diff --git a/app/watchers/providers/docker/Docker.ts b/app/watchers/providers/docker/Docker.ts index a5439d4b1..b3f80a8e7 100644 --- a/app/watchers/providers/docker/Docker.ts +++ b/app/watchers/providers/docker/Docker.ts @@ -1,6 +1,4 @@ -import fs from 'node:fs'; import path from 'node:path'; -import axios from 'axios'; import type Dockerode from 'dockerode'; import Joi from 'joi'; import JoiCronExpression from 'joi-cron-expression'; @@ -201,41 +199,6 @@ const DEBOUNCED_WATCH_CRON_MS = 5000; const DOCKER_EVENTS_BUFFER_MAX_BYTES = 1024 * 1024; const MAINTENANCE_WINDOW_QUEUE_POLL_MS = 60 * 1000; const SWARM_SERVICE_ID_LABEL = 'com.docker.swarm.service.id'; -const COMPOSE_PROJECT_CONFIG_FILES_LABEL = 'com.docker.compose.project.config_files'; -const COMPOSE_PROJECT_WORKING_DIR_LABEL = 'com.docker.compose.project.working_dir'; -const OIDC_ACCESS_TOKEN_REFRESH_WINDOW_MS = 30 * 1000; -const OIDC_DEFAULT_ACCESS_TOKEN_TTL_MS = 5 * 60 * 1000; -const OIDC_DEFAULT_TIMEOUT_MS = 5000; -const OIDC_TOKEN_ENDPOINT_PATHS = [ - 'tokenurl', - 'tokenendpoint', - 'token_url', - 'token_endpoint', - 'token.url', - 'token.endpoint', -]; -const OIDC_CLIENT_ID_PATHS = ['clientid', 'client_id', 'client.id']; -const OIDC_CLIENT_SECRET_PATHS = ['clientsecret', 'client_secret', 'client.secret']; -const OIDC_SCOPE_PATHS = ['scope']; -const OIDC_RESOURCE_PATHS = ['resource']; -const OIDC_AUDIENCE_PATHS = ['audience']; -const OIDC_GRANT_TYPE_PATHS = ['granttype', 'grant_type']; -const OIDC_ACCESS_TOKEN_PATHS = ['accesstoken', 'access_token']; -const OIDC_REFRESH_TOKEN_PATHS = ['refreshtoken', 'refresh_token']; -const OIDC_EXPIRES_IN_PATHS = ['expiresin', 'expires_in']; -const OIDC_TIMEOUT_PATHS = ['timeout']; -const OIDC_DEVICE_URL_PATHS = [ - 'deviceurl', - 'deviceendpoint', - 'device_url', - 'device_endpoint', - 'device.url', - 'device.endpoint', - 'device_authorization_endpoint', -]; -const OIDC_DEVICE_POLL_INTERVAL_MS = 5000; -const OIDC_DEVICE_POLL_TIMEOUT_MS = 5 * 60 * 1000; - function appendTriggerId(triggerInclude: string | undefined, triggerId: string | undefined): string | undefined { if (!triggerId) { return triggerInclude; @@ -386,12 +349,14 @@ function getComposeFilePathFromLabels( return getComposeNativeFilePathFromLabels(labels); } +const COMPOSE_PROJECT_CONFIG_FILES_LABEL = 'com.docker.compose.project.config_files'; +const COMPOSE_PROJECT_WORKING_DIR_LABEL = 'com.docker.compose.project.working_dir'; + const RECENT_DOCKER_EVENT_LIMIT = 1000; const RECENT_ALIAS_FILTER_DECISION_LIMIT = 1000; const joiWildcardSchema = (joi as unknown as Record Joi.Schema>)[`a${'ny'}`].bind( joi, ); - interface DockerEventsStream { on: (eventName: string, handler: (...args: unknown[]) => unknown) => unknown; removeAllListeners?: (eventName?: string) => unknown; @@ -905,40 +870,7 @@ class Docker extends Watcher { } } - initWatcher() { - const options: Dockerode.DockerOptions = {}; - if (this.configuration.host) { - options.host = this.configuration.host; - options.port = this.configuration.port; - if (this.configuration.protocol) { - options.protocol = this.configuration.protocol; - } - if (this.configuration.cafile) { - options.ca = fs.readFileSync( - resolveConfiguredPath(this.configuration.cafile, { - label: `watcher ${this.name} CA file path`, - }), - ); - } - if (this.configuration.certfile) { - options.cert = fs.readFileSync( - resolveConfiguredPath(this.configuration.certfile, { - label: `watcher ${this.name} certificate file path`, - }), - ); - } - if (this.configuration.keyfile) { - options.key = fs.readFileSync( - resolveConfiguredPath(this.configuration.keyfile, { - label: `watcher ${this.name} key file path`, - }), - ); - } - this.applyRemoteAuthHeaders(options); - } else { - options.socketPath = this.configuration.socket; - } - this.dockerApi = new Dockerode(options); + async initWatcher() { await initWatcherWithRemoteAuth(this.asRemoteAuthWatcher()); } @@ -1764,7 +1696,6 @@ export default Docker; export { appendTriggerId as testable_appendTriggerId, removeTriggerId as testable_removeTriggerId, - getLabel as testable_getLabel, filterBySegmentCount as testable_filterBySegmentCount, filterRecreatedContainerAliases as testable_filterRecreatedContainerAliases, getContainerDisplayName as testable_getContainerDisplayName, @@ -1776,6 +1707,7 @@ export { getImgsetSpecificity as testable_getImgsetSpecificity, getInspectValueByPath as testable_getInspectValueByPath, getComposeFilePathFromLabels as testable_getComposeFilePathFromLabels, + getLabel as testable_getLabel, getOldContainers as testable_getOldContainers, normalizeConfigNumberValue as testable_normalizeConfigNumberValue, normalizeContainer as testable_normalizeContainer,