From 4f9f3201d6215b7d6d156b9b035f92117c4eccdd Mon Sep 17 00:00:00 2001 From: Antonio Vargas Date: Sat, 20 Dec 2025 10:50:06 -0800 Subject: [PATCH 1/4] Fix: semver filtering - Attempting to run regex on boolean field: container.image.tag.semver - Throwing instead of returning filtered list of tags --- app/registries/providers/trueforge/trueforge.test.js | 4 ++-- app/watchers/providers/docker/Docker.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/registries/providers/trueforge/trueforge.test.js b/app/registries/providers/trueforge/trueforge.test.js index 50e559ce..8ff0c87a 100644 --- a/app/registries/providers/trueforge/trueforge.test.js +++ b/app/registries/providers/trueforge/trueforge.test.js @@ -1,4 +1,4 @@ -const trueforge = require('./trueforge'); +const Trueforge = require('./trueforge'); jest.mock('axios', () => jest.fn().mockImplementation(() => ({ @@ -6,7 +6,7 @@ jest.mock('axios', () => })), ); -const trueforge = new trueforge(); +const trueforge = new Trueforge(); trueforge.configuration = { username: 'user', token: 'token', diff --git a/app/watchers/providers/docker/Docker.js b/app/watchers/providers/docker/Docker.js index d46ec807..7071c9fd 100644 --- a/app/watchers/providers/docker/Docker.js +++ b/app/watchers/providers/docker/Docker.js @@ -120,7 +120,7 @@ function getTagCandidates(container, tags, logContainer) { ); // Remove prefix and suffix (keep only digits and dots) - const numericPart = container.image.tag.semver.match(/(\d+(\.\d+)*)/); + const numericPart = container.image.tag.value.match(/(\d+(\.\d+)*)/); if (numericPart) { const referenceGroups = numericPart[0].split('.').length; @@ -158,7 +158,7 @@ function getTagCandidates(container, tags, logContainer) { // Non semver tag -> do not propose any other registry tag filteredTags = []; } - throw new Error(`Tag is Neither Semver or not-Semver ${container.image.tag.value}`); + return filteredTags; } function normalizeContainer(container) { From 7d615946d129027c8fd08bf45c074c8519ee55be Mon Sep 17 00:00:00 2001 From: Antonio Vargas Date: Sat, 20 Dec 2025 12:35:58 -0800 Subject: [PATCH 2/4] Test: add unit test for semver part filtering --- app/watchers/providers/docker/Docker.test.js | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/watchers/providers/docker/Docker.test.js b/app/watchers/providers/docker/Docker.test.js index 11a1ca34..9edcb10d 100644 --- a/app/watchers/providers/docker/Docker.test.js +++ b/app/watchers/providers/docker/Docker.test.js @@ -691,6 +691,41 @@ describe('Docker Watcher', () => { expect(mockRegistry.getTags).toHaveBeenCalled(); }); + + test('should filter tags with different number of semver parts', async () => { + const container = { + image: { + registry: { name: 'hub' }, + tag: { value: '1.2', semver: true }, + digest: { watch: false }, + }, + }; + const mockRegistry = { + getTags: jest + .fn() + .mockResolvedValue([ + '1.2.1', // 3 parts, should be filtered out + '1.3', // 2 parts, should be kept + '1.1', // 2 parts, should be kept (but lower) + '2', // 1 part, should be filtered out + ]), + }; + registry.getState.mockReturnValue({ + registry: { hub: mockRegistry }, + }); + + // Mock isGreater to return true for 1.3 > 1.2 + mockTag.isGreater.mockImplementation((t1, t2) => { + if (t1 === '1.3' && t2 === '1.2') return true; + return false; + }); + + const mockLogChild = { error: jest.fn(), warn: jest.fn() }; + + const result = await docker.findNewVersion(container, mockLogChild); + + expect(result).toEqual({ tag: '1.3' }); + }); }); describe('Container Details', () => { From fd1545cf0abb615a0c76ae1a489f306444606a5c Mon Sep 17 00:00:00 2001 From: Antonio Vargas Date: Sat, 20 Dec 2025 15:54:50 -0800 Subject: [PATCH 3/4] Fix PR #842 BUG: The logic calling `isDigestToWatch` should pass parsedImage but instead is passing the domain CHANGE: The label `wud.watch.digest` should be respected regardless of registry --- app/watchers/providers/docker/Docker.js | 31 ++- app/watchers/providers/docker/Docker.test.js | 259 ++++++++++++++++--- 2 files changed, 249 insertions(+), 41 deletions(-) diff --git a/app/watchers/providers/docker/Docker.js b/app/watchers/providers/docker/Docker.js index 7071c9fd..c423a9ac 100644 --- a/app/watchers/providers/docker/Docker.js +++ b/app/watchers/providers/docker/Docker.js @@ -270,22 +270,32 @@ function isContainerToWatch(wudWatchLabelValue, watchByDefault) { * @param {object} parsedImage - object containing at least `domain` property * @returns {boolean} */ -function isDigestToWatch(wudWatchDigestLabelValue, parsedImage) { - let result = true; +function isDigestToWatch(wudWatchDigestLabelValue, parsedImage, isSemver) { + const domain = parsedImage.domain; + const isDockerHub = + !domain || + domain === '' || + domain === 'docker.io' || + domain.endsWith('.docker.io'); if ( - parsedImage.domain === "docker.io" || - parsedImage.domain === "registry-1.docker.io" || - parsedImage.domain === '' + wudWatchDigestLabelValue !== undefined && + wudWatchDigestLabelValue !== '' ) { - result = false; + const shouldWatch = wudWatchDigestLabelValue.toLowerCase() === 'true'; + if (shouldWatch && isDockerHub) { + log.warn( + `Watching digest for image ${parsedImage.path} with domain ${domain} may result in throttled requests`, + ); + } + return shouldWatch; } - if (wudWatchDigestLabelValue) { - result = wudWatchDigestLabelValue.toLowerCase() === 'true'; + if (isSemver) { + return false; } - return result; + return !isDockerHub; } @@ -806,7 +816,8 @@ class Docker extends Component { const isSemver = parsedTag !== null && parsedTag !== undefined; const watchDigest = isDigestToWatch( container.Labels[wudWatchDigest], - parsedImage.domain, + parsedImage, + isSemver, ); if (!isSemver && !watchDigest) { this.ensureLogger(); diff --git a/app/watchers/providers/docker/Docker.test.js b/app/watchers/providers/docker/Docker.test.js index 9edcb10d..e4254a03 100644 --- a/app/watchers/providers/docker/Docker.test.js +++ b/app/watchers/providers/docker/Docker.test.js @@ -787,6 +787,56 @@ describe('Docker Watcher', () => { expect(result).toBeDefined(); }); + test('should handle container with implicit docker hub image (no domain)', async () => { + await docker.register('watcher', 'docker', 'test', {}); + const container = { + Id: '123', + Image: 'prom/prometheus:v3.8.0', + Names: ['/prometheus'], + State: 'running', + Labels: {}, + }; + const imageDetails = { + RepoTags: ['prom/prometheus:v3.8.0'], + Architecture: 'amd64', + Os: 'linux', + Created: '2023-01-01', + Id: 'image123', + }; + mockImage.inspect.mockResolvedValue(imageDetails); + // Mock parse to return undefined domain (simulating parse-docker-image-name behavior) + mockParse.mockReturnValue({ + domain: undefined, + path: 'prom/prometheus', + tag: 'v3.8.0', + }); + + // Mock registry to handle unknown/docker hub + const mockRegistry = { + normalizeImage: jest.fn((img) => img), + getId: () => 'hub', + match: () => true, + }; + registry.getState.mockReturnValue({ + registry: { hub: mockRegistry }, + }); + + const { + validate: validateContainer, + } = require('../../../model/container'); + validateContainer.mockReturnValue({ + id: '123', + name: 'prometheus', + image: { architecture: 'amd64' }, + }); + + const result = await docker.addImageDetailsToContainer(container); + + expect(result).toBeDefined(); + // Verify parse was called + expect(mockParse).toHaveBeenCalledWith('prom/prometheus:v3.8.0'); + }); + test('should handle container with SHA256 image', async () => { await docker.register('watcher', 'docker', 'test', {}); const container = { @@ -979,37 +1029,6 @@ describe('Docker Watcher', () => { expect(image.RepoDigests.length).toBe(0); }); - test('should determine if container should be watched', () => { - expect('true'.toLowerCase() === 'true').toBe(true); - expect('false'.toLowerCase() === 'true').toBe(false); - expect(undefined !== undefined && undefined !== '').toBe(false); - }); - - test('should determine digest watching for semver', () => { - const isSemver = true; - const watchDigestLabel = 'true'; - let result = false; - if (isSemver) { - if (watchDigestLabel !== undefined && watchDigestLabel !== '') { - result = watchDigestLabel.toLowerCase() === 'true'; - } - } - expect(result).toBe(true); - }); - - test('should determine digest watching for non-semver', () => { - const isSemver = false; - const watchDigestLabel = undefined; - let result = false; - if (!isSemver) { - result = true; - if (watchDigestLabel !== undefined && watchDigestLabel !== '') { - result = watchDigestLabel.toLowerCase() === 'true'; - } - } - expect(result).toBe(true); - }); - test('should get old containers for pruning', () => { const newContainers = [{ id: '1' }, { id: '2' }]; const storeContainers = [{ id: '1' }, { id: '3' }]; @@ -1029,3 +1048,181 @@ describe('Docker Watcher', () => { }); }); }); + +describe('isDigestToWatch Logic', () => { + let docker; + let mockImage; + + beforeEach(() => { + // Setup dockerode mock + const mockDockerApi = { + getImage: jest.fn(), + }; + mockDockerode.mockImplementation(() => mockDockerApi); + + mockImage = { + inspect: jest.fn(), + }; + mockDockerApi.getImage.mockReturnValue(mockImage); + + // Setup store mock + storeContainer.getContainer.mockReturnValue(undefined); + storeContainer.insertContainer.mockImplementation((c) => c); + storeContainer.updateContainer.mockImplementation((c) => c); + + // Setup registry mock + registry.getState.mockReturnValue({ registry: {} }); + + // Setup event mock + event.emitContainerReport.mockImplementation(() => {}); + + // Setup prometheus mock + const mockGauge = { set: jest.fn() }; + mockPrometheus.getWatchContainerGauge.mockReturnValue(mockGauge); + + // Setup fullName mock + fullName.mockReturnValue('test_container'); + + docker = new Docker(); + docker.name = 'test'; + docker.dockerApi = mockDockerApi; + docker.ensureLogger(); + }); + + // Helper to setup the environment for addImageDetailsToContainer + const setupTest = (labels, domain, tag, isSemver = false) => { + const container = { + Id: '123', + Image: `${domain ? domain + '/' : ''}repo/image:${tag}`, + Names: ['/test'], + State: 'running', + Labels: labels || {}, + }; + const imageDetails = { + Id: 'image123', + Architecture: 'amd64', + Os: 'linux', + Created: '2023-01-01', + RepoDigests: ['repo/image@sha256:abc'], + RepoTags: [`${domain ? domain + '/' : ''}repo/image:${tag}`] + }; + mockImage.inspect.mockResolvedValue(imageDetails); + // Mock parse to return appropriate structure + mockParse.mockReturnValue({ + domain: domain, + path: 'repo/image', + tag: tag, + }); + + // Mock semver check + if (isSemver) { + mockTag.parse.mockReturnValue({ major: 1, minor: 0, patch: 0 }); + } else { + mockTag.parse.mockReturnValue(null); + } + + const mockRegistry = { + normalizeImage: jest.fn((img) => img), + getId: () => 'registry', + match: () => true, + }; + registry.getState.mockReturnValue({ + registry: { registry: mockRegistry }, + }); + + const { + validate: validateContainer, + } = require('../../../model/container'); + validateContainer.mockImplementation(c => c); + + return container; + }; + + // Case 1: Explicit Label present + test('should watch digest if label is true (semver)', async () => { + const container = setupTest( + { 'wud.watch.digest': 'true' }, + 'my.registry', + '1.0.0', + true, + ); + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(true); + }); + + test('should watch digest if label is true (non-semver)', async () => { + const container = setupTest( + { 'wud.watch.digest': 'true' }, + 'my.registry', + 'latest', + false, + ); + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(true); + }); + + test('should NOT watch digest if label is false (semver)', async () => { + const container = setupTest( + { 'wud.watch.digest': 'false' }, + 'my.registry', + '1.0.0', + true, + ); + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(false); + }); + + test('should NOT watch digest if label is false (non-semver)', async () => { + const container = setupTest( + { 'wud.watch.digest': 'false' }, + 'my.registry', + 'latest', + false, + ); + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(false); + }); + + // Case 2: Semver (no label) -> default false + test('should NOT watch digest by default for semver images', async () => { + const container = setupTest({}, 'my.registry', '1.0.0', true); + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(false); + }); + + test('should NOT watch digest by default for semver images (Docker Hub)', async () => { + const container = setupTest({}, 'docker.io', '1.0.0', true); + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(false); + }); + + // Case 3: Non-Semver (no label) -> default true, EXCEPT Docker Hub + test('should watch digest by default for non-semver images (Custom Registry)', async () => { + const container = setupTest({}, 'my.registry', 'latest', false); + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(true); + }); + + test('should NOT watch digest by default for non-semver images (Docker Hub Explicit)', async () => { + const container = setupTest({}, 'docker.io', 'latest', false); + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(false); + }); + + test('should NOT watch digest by default for non-semver images (Docker Hub Registry-1)', async () => { + const container = setupTest( + {}, + 'registry-1.docker.io', + 'latest', + false, + ); + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(false); + }); + + test('should NOT watch digest by default for non-semver images (Docker Hub Implicit)', async () => { + const container = setupTest({}, undefined, 'latest', false); // Implicit + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(false); + }); +}); From ff249ddf88b349d594bc73e5604c28f286f29d16 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Fri, 9 Jan 2026 14:53:54 +0100 Subject: [PATCH 4/4] Adding UI build step into Docker build process --- .dockerignore | 10 +--------- Dockerfile | 48 ++++++++++++++++++++++++------------------------ 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/.dockerignore b/.dockerignore index 6420ada2..bfa34103 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,14 +20,6 @@ jest.config.js nodemon.json **/.DS_Store **/Thumbs.db -ui/src/ -ui/public/ + ui/tests/ ui/coverage/ -ui/node_modules/ -ui/package*.json -ui/.eslintrc.js -ui/babel.config.js -ui/jest.config.js -ui/vue.config.js -ui/.browserslistrc diff --git a/Dockerfile b/Dockerfile index 4fe413db..07771e6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,23 @@ # Common Stage FROM node:24-alpine AS base +WORKDIR /home/node/app + +# App dependency stage +FROM base AS app-dependencies +COPY app/package* ./ +RUN npm ci --omit=dev --omit=optional --no-audit --no-fund --no-update-notifier + +# UI stage - building UI assets +FROM base AS ui-dependencies +COPY ./ui ./ +RUN pwd && tree . +RUN npm ci --no-audit --no-fund --no-update-notifier && npm run build + +# Release stage +FROM base AS release LABEL maintainer="fmartinou" EXPOSE 3000 - ARG WUD_VERSION=unknown ENV WORKDIR=/home/node/app @@ -12,36 +26,22 @@ ENV WUD_VERSION=$WUD_VERSION HEALTHCHECK --interval=30s --timeout=5s CMD if [[ -z ${WUD_SERVER_ENABLED} || ${WUD_SERVER_ENABLED} == 'true' ]]; then curl --fail http://localhost:${WUD_SERVER_PORT:-3000}/health || exit 1; else exit 0; fi; -WORKDIR /home/node/app - +# Setup directory structure RUN mkdir /store # Add useful stuff -RUN apk add --no-cache tzdata openssl curl git jq bash - -# Dependencies stage -FROM base AS dependencies +# RUN apk add --no-cache tzdata openssl curl git jq bash +RUN apk add --no-cache tzdata openssl curl bash -# Copy app package.json -COPY app/package* ./ - -# Install dependencies -RUN npm ci --omit=dev --omit=optional --no-audit --no-fund --no-update-notifier - -# Release stage -FROM base AS release - -# Default entrypoint COPY Docker.entrypoint.sh /usr/bin/entrypoint.sh RUN chmod +x /usr/bin/entrypoint.sh -ENTRYPOINT ["/usr/bin/entrypoint.sh"] -CMD ["node", "index"] -## Copy node_modules -COPY --from=dependencies /home/node/app/node_modules ./node_modules +## Copy dependencies and artifacts +COPY --from=app-dependencies /home/node/app/node_modules ./node_modules +COPY --from=ui-dependencies /home/node/app/dist/ ./ui -# Copy app +# Copy app source COPY app/ ./ -# Copy ui -COPY ui/dist/ ./ui +ENTRYPOINT ["/usr/bin/entrypoint.sh"] +CMD ["node", "index"]