From c35a8eda4ed4e6b0b2a760f700a84fad1c368b56 Mon Sep 17 00:00:00 2001 From: Tom Vervoort Date: Sun, 1 Mar 2026 23:00:37 +0100 Subject: [PATCH] rework limiting digest checking for docker hub and allow with setting --- app/log/index.ts | 1 - app/package-lock.json | 24 ++- app/registries/BaseRegistry.ts | 24 ++- app/registries/Registry.test.ts | 24 ++- app/registries/Registry.ts | 37 +--- app/registries/providers/acr/Acr.test.ts | 20 +- app/registries/providers/acr/Acr.ts | 19 +- .../providers/custom/Custom.test.ts | 20 +- app/registries/providers/custom/Custom.ts | 21 +- app/registries/providers/ecr/Ecr.test.ts | 20 +- app/registries/providers/ecr/Ecr.ts | 20 +- app/registries/providers/gcr/Gcr.test.ts | 48 +---- app/registries/providers/gcr/Gcr.ts | 15 +- app/registries/providers/ghcr/Ghcr.test.ts | 30 ++- app/registries/providers/ghcr/Ghcr.ts | 17 +- app/registries/providers/gitea/Gitea.test.ts | 28 +-- app/registries/providers/gitea/Gitea.ts | 26 +-- .../providers/gitlab/Gitlab.test.ts | 16 +- app/registries/providers/gitlab/Gitlab.ts | 25 +-- app/registries/providers/hub/Hub.test.ts | 75 ++++++- app/registries/providers/hub/Hub.ts | 48 +++-- app/registries/providers/lscr/Lscr.test.ts | 20 +- app/registries/providers/lscr/Lscr.ts | 12 +- app/registries/providers/quay/Quay.test.ts | 20 +- app/registries/providers/quay/Quay.ts | 36 ++-- .../providers/trueforge/Trueforge.test.ts | 16 +- .../providers/trueforge/Trueforge.ts | 14 +- app/registry/Component.ts | 15 +- app/registry/index.ts | 8 +- app/triggers/providers/Trigger.ts | 15 +- app/watchers/providers/docker/Docker.test.ts | 191 +---------------- app/watchers/providers/docker/Docker.ts | 196 +++++------------- docs/configuration/registries/hub/README.md | 108 +++++++++- 33 files changed, 494 insertions(+), 715 deletions(-) diff --git a/app/log/index.ts b/app/log/index.ts index 33ef4b67..0fc3645d 100644 --- a/app/log/index.ts +++ b/app/log/index.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import bunyan from 'bunyan'; import { getLogLevel } from '../configuration'; diff --git a/app/package-lock.json b/app/package-lock.json index 655ed0bb..f2c78560 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -855,6 +855,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3519,6 +3520,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3700,6 +3702,7 @@ "integrity": "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", @@ -3739,6 +3742,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -3981,6 +3985,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4712,6 +4717,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -5884,6 +5890,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5944,6 +5951,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7076,7 +7084,6 @@ "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", "license": "ISC", - "peer": true, "engines": { "node": ">=4" } @@ -7087,7 +7094,6 @@ "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", "deprecated": "this library is no longer supported", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" @@ -8009,6 +8015,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8591,6 +8598,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -9511,7 +9519,6 @@ "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "*" } @@ -10099,6 +10106,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10455,7 +10463,6 @@ "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", "license": "Apache-2.0", - "peer": true, "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -10502,7 +10509,6 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", "license": "MIT", - "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -10517,7 +10523,6 @@ "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", "license": "MIT", - "peer": true, "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -10533,7 +10538,6 @@ "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", "license": "MIT", - "peer": true, "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -10549,7 +10553,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.6" } @@ -10559,7 +10562,6 @@ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -10574,7 +10576,6 @@ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", "license": "MIT", - "peer": true, "bin": { "uuid": "bin/uuid" } @@ -11603,6 +11604,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11764,6 +11766,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11997,6 +12000,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/app/registries/BaseRegistry.ts b/app/registries/BaseRegistry.ts index 7ddb1c2a..9dae7adf 100644 --- a/app/registries/BaseRegistry.ts +++ b/app/registries/BaseRegistry.ts @@ -1,4 +1,5 @@ -// @ts-nocheck +import { AxiosRequestConfig } from 'axios'; +import { ContainerImage } from '../model/container'; import Registry from './Registry'; /** @@ -8,7 +9,10 @@ class BaseRegistry extends Registry { /** * Common URL normalization for registries that need https:// prefix and /v2 suffix */ - normalizeImageUrl(image, registryUrl = null) { + normalizeImageUrl( + image: ContainerImage, + registryUrl: string | null = null, + ) { const imageNormalized = { ...image }; const url = registryUrl || image.registry.url; @@ -21,7 +25,10 @@ class BaseRegistry extends Registry { /** * Common Basic Auth implementation */ - async authenticateBasic(requestOptions, credentials) { + async authenticateBasic( + requestOptions: AxiosRequestConfig, + credentials?: string, + ) { const requestOptionsWithAuth = { ...requestOptions }; if (credentials) { requestOptionsWithAuth.headers.Authorization = `Basic ${credentials}`; @@ -32,7 +39,10 @@ class BaseRegistry extends Registry { /** * Common Bearer token authentication */ - async authenticateBearer(requestOptions, token) { + async authenticateBearer( + requestOptions: AxiosRequestConfig, + token?: string, + ) { const requestOptionsWithAuth = { ...requestOptions }; if (token) { requestOptionsWithAuth.headers.Authorization = `Bearer ${token}`; @@ -78,14 +88,14 @@ class BaseRegistry extends Registry { /** * Common URL pattern matching */ - matchUrlPattern(image, pattern) { - return pattern.test(image.registry.url); + matchUrlPattern(imageUrl: string, pattern: RegExp) { + return pattern.test(imageUrl); } /** * Common mask configuration for sensitive fields */ - maskSensitiveFields(fields) { + maskSensitiveFields(fields: string[]) { const masked = { ...this.configuration }; fields.forEach((field) => { if (masked[field]) { diff --git a/app/registries/Registry.test.ts b/app/registries/Registry.test.ts index 28e5e29a..aa767f61 100644 --- a/app/registries/Registry.test.ts +++ b/app/registries/Registry.test.ts @@ -24,7 +24,7 @@ test('getId should return registry type only', async () => { }); test('match should return false when not overridden', async () => { - expect(registry.match({})).toBeFalsy(); + expect(registry.match('')).toBeFalsy(); }); test('normalizeImage should return same image when not overridden', async () => { @@ -288,3 +288,25 @@ test('callRegistry should call authenticate', async () => { }); expect(spyAuthenticate).toHaveBeenCalledTimes(1); }); + +describe('shouldWatchDigest', () => { + test('should return true when label is true', () => { + const result = registry.shouldWatchDigest('true', 'image/name'); + expect(result).toBe(true); + }); + + test('should return true when label is TRUE (case insensitive)', () => { + const result = registry.shouldWatchDigest('TRUE', 'image/name'); + expect(result).toBe(true); + }); + + test('should return true without label', () => { + const result = registry.shouldWatchDigest(undefined, 'image/name'); + expect(result).toBe(true); + }); + + test('should return true with empty label', () => { + const result = registry.shouldWatchDigest('', 'image/name'); + expect(result).toBe(true); + }); +}); diff --git a/app/registries/Registry.ts b/app/registries/Registry.ts index f096f625..665bfe90 100644 --- a/app/registries/Registry.ts +++ b/app/registries/Registry.ts @@ -4,10 +4,6 @@ import Component from '../registry/Component'; import { getSummaryTags } from '../prometheus/registry'; import { ContainerImage } from '../model/container'; -export interface RegistryImage extends ContainerImage { - // Add any registry specific properties if needed -} - export interface RegistryManifest { digest?: string; version?: number; @@ -43,30 +39,30 @@ export interface RegistryManifestResponse { /** * Docker Registry Abstract class. */ -class Registry extends Component { +export class Registry extends Component { /** * Encode Bse64(login:password) - * @param login - * @param token - * @returns {string} */ static base64Encode(login: string, token: string) { return Buffer.from(`${login}:${token}`, 'utf-8').toString('base64'); } /** - * If this registry is responsible for the image (to be overridden). - * @param image the image - * @returns {boolean} + * Check if the digest label value is to be watched for this registry (to be overridden). */ - match(_image: ContainerImage): boolean { + shouldWatchDigest(_wudWatchDigestLabelValue: string, _image: string) { + return true; + } + + /** + * If this registry is responsible for the image url (to be overridden). + */ + match(_imageUrl: string): boolean { return false; } /** * Normalize image according to Registry Custom characteristics (to be overridden). - * @param image - * @returns {*} */ normalizeImage(image: ContainerImage): ContainerImage { return image; @@ -74,9 +70,6 @@ class Registry extends Component { /** * Authenticate and set authentication value to requestOptions. - * @param image - * @param requestOptions - * @returns {*} */ async authenticate( _image: ContainerImage, @@ -87,8 +80,6 @@ class Registry extends Component { /** * Get Tags. - * @param image - * @returns {*} */ async getTags(image: ContainerImage): Promise { this.log.debug(`Get ${image.name} tags`); @@ -118,9 +109,6 @@ class Registry extends Component { /** * Get tags page - * @param image - * @param lastItem - * @returns {Promise<*>} */ getTagsPage( image: ContainerImage, @@ -139,9 +127,6 @@ class Registry extends Component { /** * Get image manifest for a remote tag. - * @param image - * @param digest (optional) - * @returns {Promise} */ async getImageManifestDigest( image: ContainerImage, @@ -375,9 +360,7 @@ class Registry extends Component { /** * Return {username, pass } or undefined. - * @returns {} */ - async getAuthPull(): Promise< { username?: string; password?: string } | undefined > { diff --git a/app/registries/providers/acr/Acr.test.ts b/app/registries/providers/acr/Acr.test.ts index 56bb5140..2b282812 100644 --- a/app/registries/providers/acr/Acr.test.ts +++ b/app/registries/providers/acr/Acr.test.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +import { ContainerImage } from '../../../model/container'; import Acr from './Acr'; const acr = new Acr(); @@ -33,23 +33,11 @@ test('maskConfiguration should mask configuration secrets', async () => { }); test('match should return true when registry url is from acr', async () => { - expect( - acr.match({ - registry: { - url: 'test.azurecr.io', - }, - }), - ).toBeTruthy(); + expect(acr.match('test.azurecr.io')).toBeTruthy(); }); test('match should return false when registry url is not from acr', async () => { - expect( - acr.match({ - registry: { - url: 'est.notme.io', - }, - }), - ).toBeFalsy(); + expect(acr.match('est.notme.io')).toBeFalsy(); }); test('normalizeImage should return the proper registry v2 endpoint', async () => { @@ -59,7 +47,7 @@ test('normalizeImage should return the proper registry v2 endpoint', async () => registry: { url: 'test.azurecr.io/test/image', }, - }), + } as ContainerImage), ).toStrictEqual({ name: 'test/image', registry: { diff --git a/app/registries/providers/acr/Acr.ts b/app/registries/providers/acr/Acr.ts index ee4b233b..80ec924a 100644 --- a/app/registries/providers/acr/Acr.ts +++ b/app/registries/providers/acr/Acr.ts @@ -1,4 +1,5 @@ -// @ts-nocheck +import { AxiosRequestConfig } from 'axios'; +import { ContainerImage } from '../../../model/container'; import Registry from '../../Registry'; /** @@ -14,7 +15,6 @@ class Acr extends Registry { /** * Sanitize sensitive data - * @returns {*} */ maskConfiguration() { return { @@ -26,21 +26,17 @@ class Acr extends Registry { /** * Return true if image has not registryUrl. - * @param image the image - * @returns {boolean} */ - match(image) { - return /^.*\.?azurecr.io$/.test(image.registry.url); + match(imageUrl: string) { + return /^.*\.?azurecr.io$/.test(imageUrl); } /** * Normalize image according to AWS ECR characteristics. - * @param image - * @returns {*} */ - normalizeImage(image) { + normalizeImage(image: ContainerImage) { const imageNormalized = image; if (!imageNormalized.registry.url.startsWith('https://')) { imageNormalized.registry.url = `https://${imageNormalized.registry.url}/v2`; @@ -48,7 +44,10 @@ class Acr extends Registry { return imageNormalized; } - async authenticate(image, requestOptions) { + async authenticate( + image: ContainerImage, + requestOptions: AxiosRequestConfig, + ) { const requestOptionsWithAuth = requestOptions; requestOptionsWithAuth.headers.Authorization = `Basic ${Acr.base64Encode(this.configuration.clientid, this.configuration.clientsecret)}`; return requestOptionsWithAuth; diff --git a/app/registries/providers/custom/Custom.test.ts b/app/registries/providers/custom/Custom.test.ts index 2d80eab0..3de360b4 100644 --- a/app/registries/providers/custom/Custom.test.ts +++ b/app/registries/providers/custom/Custom.test.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +import { ContainerImage } from '../../../model/container'; import Custom from './Custom'; const custom = new Custom(); @@ -41,23 +41,11 @@ test('maskConfiguration should mask configuration secrets', async () => { }); test('match should return true when registry url is from custom', async () => { - expect( - custom.match({ - registry: { - url: 'localhost:5000', - }, - }), - ).toBeTruthy(); + expect(custom.match('localhost:5000')).toBeTruthy(); }); test('match should return false when registry url is not from custom', async () => { - expect( - custom.match({ - registry: { - url: 'est.notme.io', - }, - }), - ).toBeFalsy(); + expect(custom.match('est.notme.io')).toBeFalsy(); }); test('normalizeImage should return the proper registry v2 endpoint', async () => { @@ -67,7 +55,7 @@ test('normalizeImage should return the proper registry v2 endpoint', async () => registry: { url: 'localhost:5000/test/image', }, - }), + } as ContainerImage), ).toStrictEqual({ name: 'test/image', registry: { diff --git a/app/registries/providers/custom/Custom.ts b/app/registries/providers/custom/Custom.ts index 965494b3..d9aa4b76 100644 --- a/app/registries/providers/custom/Custom.ts +++ b/app/registries/providers/custom/Custom.ts @@ -1,11 +1,13 @@ -// @ts-nocheck +import { AnySchema } from 'joi'; import BaseRegistry from '../../BaseRegistry'; +import { ContainerImage } from '../../../model/container'; +import { AxiosRequestConfig } from 'axios'; /** * Docker Custom Registry V2 integration. */ class Custom extends BaseRegistry { - getConfigurationSchema() { + getConfigurationSchema(): AnySchema { return this.joi.alternatives([ this.joi.string().allow(''), this.joi.object().keys({ @@ -40,25 +42,24 @@ class Custom extends BaseRegistry { /** * Return true if image has no registry url. - * @param image the image - * @returns {boolean} */ - match(image) { - return this.configuration.url.indexOf(image.registry.url) !== -1; + match(imageUrl: string) { + return this.configuration.url.indexOf(imageUrl) !== -1; } /** * Normalize images according to Custom characteristics. - * @param image - * @returns {*} */ - normalizeImage(image) { + normalizeImage(image: ContainerImage) { const imageNormalized = image; imageNormalized.registry.url = `${this.configuration.url}/v2`; return imageNormalized; } - async authenticate(image, requestOptions) { + async authenticate( + image: ContainerImage, + requestOptions: AxiosRequestConfig, + ) { return this.authenticateBasic( requestOptions, this.getAuthCredentials(), diff --git a/app/registries/providers/ecr/Ecr.test.ts b/app/registries/providers/ecr/Ecr.test.ts index 9957c297..01e0bae7 100644 --- a/app/registries/providers/ecr/Ecr.test.ts +++ b/app/registries/providers/ecr/Ecr.test.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +import { ContainerImage } from '../../../model/container'; import Ecr from './Ecr'; jest.mock('aws-sdk/clients/ecr', () => @@ -63,23 +63,11 @@ test('validatedConfiguration should throw error when secretaccesskey is missing' }); test('match should return true when registry url is from ecr', async () => { - expect( - ecr.match({ - registry: { - url: '123456789.dkr.ecr.eu-west-1.amazonaws.com', - }, - }), - ).toBeTruthy(); + expect(ecr.match('123456789.dkr.ecr.eu-west-1.amazonaws.com')).toBeTruthy(); }); test('match should return false when registry url is not from ecr', async () => { - expect( - ecr.match({ - registry: { - url: '123456789.dkr.ecr.eu-west-1.acme.com', - }, - }), - ).toBeFalsy(); + expect(ecr.match('123456789.dkr.ecr.eu-west-1.acme.com')).toBeFalsy(); }); test('maskConfiguration should mask configuration secrets', async () => { @@ -97,7 +85,7 @@ test('normalizeImage should return the proper registry v2 endpoint', async () => registry: { url: '123456789.dkr.ecr.eu-west-1.amazonaws.com/test/image', }, - }), + } as ContainerImage), ).toStrictEqual({ name: 'test/image', registry: { diff --git a/app/registries/providers/ecr/Ecr.ts b/app/registries/providers/ecr/Ecr.ts index 751c3f5e..fa96a3f9 100644 --- a/app/registries/providers/ecr/Ecr.ts +++ b/app/registries/providers/ecr/Ecr.ts @@ -1,10 +1,10 @@ -// @ts-nocheck import ECR from 'aws-sdk/clients/ecr'; import maintenanceMode from 'aws-sdk/lib/maintenance_mode_message'; // @ts-ignore maintenanceMode.suppress = true; // Disable aws sdk maintenance mode message at startup -import axios from 'axios'; +import axios, { AxiosRequestConfig } from 'axios'; import Registry from '../../Registry'; +import { ContainerImage } from '../../../model/container'; const ECR_PUBLIC_GALLERY_HOSTNAME = 'public.ecr.aws'; @@ -38,24 +38,21 @@ class Ecr extends Registry { /** * Return true if image has not registryUrl. - * @param image the image - * @returns {boolean} */ - match(image) { + match(imageUrl: string) { return ( - /^.*\.dkr\.ecr\..*\.amazonaws\.com$/.test(image.registry.url) || - image.registry.url === ECR_PUBLIC_GALLERY_HOSTNAME + /^.*\.dkr\.ecr\..*\.amazonaws\.com$/.test(imageUrl) || + imageUrl === ECR_PUBLIC_GALLERY_HOSTNAME ); } /** * Normalize image according to AWS ECR characteristics. * @param image - * @returns {*} */ - normalizeImage(image) { + normalizeImage(image: ContainerImage) { const imageNormalized = image; if (!imageNormalized.registry.url.startsWith('https://')) { imageNormalized.registry.url = `https://${imageNormalized.registry.url}/v2`; @@ -63,7 +60,10 @@ class Ecr extends Registry { return imageNormalized; } - async authenticate(image, requestOptions) { + async authenticate( + image: ContainerImage, + requestOptions: AxiosRequestConfig, + ) { const requestOptionsWithAuth = requestOptions; // Private registry if (this.configuration.accesskeyid) { diff --git a/app/registries/providers/gcr/Gcr.test.ts b/app/registries/providers/gcr/Gcr.test.ts index 21a5715e..984e0d36 100644 --- a/app/registries/providers/gcr/Gcr.test.ts +++ b/app/registries/providers/gcr/Gcr.test.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +import { ContainerImage } from '../../../model/container'; import Gcr from './Gcr'; jest.mock('axios', () => @@ -41,44 +41,14 @@ test('maskConfiguration should mask configuration secrets', async () => { }); test('match should return true when registry url is from gcr', async () => { - expect( - gcr.match({ - registry: { - url: 'gcr.io', - }, - }), - ).toBeTruthy(); - expect( - gcr.match({ - registry: { - url: 'us.gcr.io', - }, - }), - ).toBeTruthy(); - expect( - gcr.match({ - registry: { - url: 'eu.gcr.io', - }, - }), - ).toBeTruthy(); - expect( - gcr.match({ - registry: { - url: 'asia.gcr.io', - }, - }), - ).toBeTruthy(); + expect(gcr.match('gcr.io')).toBeTruthy(); + expect(gcr.match('us.gcr.io')).toBeTruthy(); + expect(gcr.match('eu.gcr.io')).toBeTruthy(); + expect(gcr.match('asia.gcr.io')).toBeTruthy(); }); test('match should return false when registry url is not from gcr', async () => { - expect( - gcr.match({ - registry: { - url: 'grr.io', - }, - }), - ).toBeFalsy(); + expect(gcr.match('grr.io')).toBeFalsy(); }); test('normalizeImage should return the proper registry v2 endpoint', async () => { @@ -88,7 +58,7 @@ test('normalizeImage should return the proper registry v2 endpoint', async () => registry: { url: 'eu.gcr.io/test/image', }, - }), + } as ContainerImage), ).toStrictEqual({ name: 'test/image', registry: { @@ -98,7 +68,9 @@ test('normalizeImage should return the proper registry v2 endpoint', async () => }); test('authenticate should call ecr auth endpoint', async () => { - expect(gcr.authenticate({}, { headers: {} })).resolves.toEqual({ + expect( + gcr.authenticate({} as ContainerImage, { headers: {} }), + ).resolves.toEqual({ headers: { Authorization: 'Bearer xxxxx', }, diff --git a/app/registries/providers/gcr/Gcr.ts b/app/registries/providers/gcr/Gcr.ts index 5b617944..2f8fe5f2 100644 --- a/app/registries/providers/gcr/Gcr.ts +++ b/app/registries/providers/gcr/Gcr.ts @@ -1,6 +1,6 @@ -// @ts-nocheck -import axios from 'axios'; +import axios, { AxiosRequestConfig } from 'axios'; import BaseRegistry from '../../BaseRegistry'; +import { ContainerImage } from '../../../model/container'; /** * Google Container Registry integration. @@ -20,15 +20,18 @@ class Gcr extends BaseRegistry { return this.maskSensitiveFields(['privatekey']); } - match(image) { - return this.matchUrlPattern(image, /^.*\.?gcr.io$/); + match(imageUrl: string) { + return this.matchUrlPattern(imageUrl, /^.*\.?gcr.io$/); } - normalizeImage(image) { + normalizeImage(image: ContainerImage) { return this.normalizeImageUrl(image); } - async authenticate(image, requestOptions) { + async authenticate( + image: ContainerImage, + requestOptions: AxiosRequestConfig, + ) { if (!this.configuration.clientemail) { return requestOptions; } diff --git a/app/registries/providers/ghcr/Ghcr.test.ts b/app/registries/providers/ghcr/Ghcr.test.ts index 107eaff1..1640c1b0 100644 --- a/app/registries/providers/ghcr/Ghcr.test.ts +++ b/app/registries/providers/ghcr/Ghcr.test.ts @@ -1,8 +1,9 @@ -// @ts-nocheck +import { ContainerImage } from '../../../model/container'; +import { ComponentConfiguration } from '../../../registry/Component'; import Ghcr from './Ghcr'; describe('GitHub Container Registry', () => { - let ghcr; + let ghcr: Ghcr; beforeEach(async () => { ghcr = new Ghcr(); @@ -18,12 +19,15 @@ describe('GitHub Container Registry', () => { }); test('should match registry', async () => { - expect(ghcr.match({ registry: { url: 'ghcr.io' } })).toBe(true); - expect(ghcr.match({ registry: { url: 'docker.io' } })).toBe(false); + expect(ghcr.match('ghcr.io')).toBe(true); + expect(ghcr.match('docker.io')).toBe(false); }); test('should normalize image name', async () => { - const image = { name: 'user/repo', registry: { url: 'ghcr.io' } }; + const image = { + name: 'user/repo', + registry: { url: 'ghcr.io' }, + } as ContainerImage; const normalized = ghcr.normalizeImage(image); expect(normalized.name).toBe('user/repo'); expect(normalized.registry.url).toBe('https://ghcr.io/v2'); @@ -33,7 +37,7 @@ describe('GitHub Container Registry', () => { const image = { name: 'user/repo', registry: { url: 'https://ghcr.io/v2' }, - }; + } as ContainerImage; const normalized = ghcr.normalizeImage(image); expect(normalized.registry.url).toBe('https://ghcr.io/v2'); }); @@ -62,7 +66,7 @@ describe('GitHub Container Registry', () => { test('should authenticate with token', async () => { ghcr.configuration = { token: 'test-token' }; - const image = { name: 'user/repo' }; + const image = { name: 'user/repo' } as ContainerImage; const requestOptions = { headers: {} }; const result = await ghcr.authenticate(image, requestOptions); @@ -75,7 +79,7 @@ describe('GitHub Container Registry', () => { test('should authenticate without token', async () => { ghcr.configuration = {}; - const image = { name: 'user/repo' }; + const image = { name: 'user/repo' } as ContainerImage; const requestOptions = { headers: {} }; const result = await ghcr.authenticate(image, requestOptions); @@ -85,8 +89,14 @@ describe('GitHub Container Registry', () => { }); test('should validate string configuration', async () => { - expect(() => ghcr.validateConfiguration('')).not.toThrow(); - expect(() => ghcr.validateConfiguration('some-string')).not.toThrow(); + expect(() => + ghcr.validateConfiguration('' as unknown as ComponentConfiguration), + ).not.toThrow(); + expect(() => + ghcr.validateConfiguration( + 'some-string' as unknown as ComponentConfiguration, + ), + ).not.toThrow(); }); test('should return undefined auth pull when missing username', async () => { diff --git a/app/registries/providers/ghcr/Ghcr.ts b/app/registries/providers/ghcr/Ghcr.ts index 9531892a..fad7b4b2 100644 --- a/app/registries/providers/ghcr/Ghcr.ts +++ b/app/registries/providers/ghcr/Ghcr.ts @@ -1,11 +1,13 @@ -// @ts-nocheck +import { AxiosRequestConfig } from 'axios'; +import { ContainerImage } from '../../../model/container'; import BaseRegistry from '../../BaseRegistry'; +import { AnySchema } from 'joi'; /** * Github Container Registry integration. */ class Ghcr extends BaseRegistry { - getConfigurationSchema() { + getConfigurationSchema(): AnySchema { return this.joi.alternatives([ this.joi.string().allow(''), this.joi.object().keys({ @@ -19,15 +21,18 @@ class Ghcr extends BaseRegistry { return this.maskSensitiveFields(['token']); } - match(image) { - return this.matchUrlPattern(image, /^.*\.?ghcr.io$/); + match(imageUrl: string) { + return this.matchUrlPattern(imageUrl, /^.*\.?ghcr.io$/); } - normalizeImage(image) { + normalizeImage(image: ContainerImage) { return this.normalizeImageUrl(image); } - async authenticate(image, requestOptions) { + async authenticate( + image: ContainerImage, + requestOptions: AxiosRequestConfig, + ) { const token = Buffer.from( this.configuration.token || ':', 'utf-8', diff --git a/app/registries/providers/gitea/Gitea.test.ts b/app/registries/providers/gitea/Gitea.test.ts index 657ee8ce..c5b06952 100644 --- a/app/registries/providers/gitea/Gitea.test.ts +++ b/app/registries/providers/gitea/Gitea.test.ts @@ -35,23 +35,11 @@ test('validatedConfiguration should throw error when auth is not base64', async }); test('match should return true when registry url is from gitea', async () => { - expect( - gitea.match({ - registry: { - url: 'gitea.acme.com', - }, - }), - ).toBeTruthy(); + expect(gitea.match('gitea.acme.com')).toBeTruthy(); }); test('match should return false when registry url is not from custom', async () => { - expect( - gitea.match({ - registry: { - url: 'gitea.notme.io', - }, - }), - ).toBeFalsy(); + expect(gitea.match('gitea.notme.io')).toBeFalsy(); }); test('normalizeImage should return the proper registry v2 endpoint', async () => { @@ -116,22 +104,14 @@ test('match should handle URLs with different protocols', async () => { const giteaWithHttp = new Gitea(); giteaWithHttp.configuration = { url: 'http://gitea.acme.com' }; - expect( - giteaWithHttp.match({ - registry: { url: 'https://gitea.acme.com' }, - }), - ).toBeTruthy(); + expect(giteaWithHttp.match('https://gitea.acme.com')).toBeTruthy(); }); test('match should be case insensitive', async () => { const giteaUpper = new Gitea(); giteaUpper.configuration = { url: 'https://GITEA.ACME.COM' }; - expect( - giteaUpper.match({ - registry: { url: 'gitea.acme.com' }, - }), - ).toBeTruthy(); + expect(giteaUpper.match('gitea.acme.com')).toBeTruthy(); }); test('should authenticate with credentials', async () => { diff --git a/app/registries/providers/gitea/Gitea.ts b/app/registries/providers/gitea/Gitea.ts index c025fa0b..6a1cbd24 100644 --- a/app/registries/providers/gitea/Gitea.ts +++ b/app/registries/providers/gitea/Gitea.ts @@ -1,6 +1,6 @@ -// @ts-nocheck -import axios from 'axios'; +import axios, { AxiosRequestConfig } from 'axios'; import Custom from '../custom/Custom'; +import { ContainerImage } from '../../../model/container'; /** * Gitea Container Registry integration. @@ -35,7 +35,7 @@ class Gitea extends Custom { /** * Custom init behavior. */ - init() { + async init() { // Prepend the URL with https protocol if protocol is missing if (!this.configuration.url.toLowerCase().startsWith('http')) { this.configuration.url = `https://${this.configuration.url}`; @@ -44,25 +44,21 @@ class Gitea extends Custom { /** * Return true if image registry match gitea fqdn. - * @param image the image - * @returns {boolean} */ - match(image) { + match(imageUrl: string) { const fqdnConfigured = /(?:https?:\/\/)?(.*)/ .exec(this.configuration.url)[1] .toLowerCase(); const imageRegistryFqdn = /(?:https?:\/\/)?(.*)/ - .exec(image.registry.url)[1] + .exec(imageUrl)[1] .toLowerCase(); return fqdnConfigured === imageRegistryFqdn; } /** * Normalize image according to Gitea Container Registry characteristics. - * @param image - * @returns {*} */ - normalizeImage(image) { + normalizeImage(image: ContainerImage) { const imageNormalized = image; imageNormalized.registry.url = `${this.configuration.url}/v2`; return imageNormalized; @@ -70,12 +66,12 @@ class Gitea extends Custom { /** * Authenticate to Gitea/Forgejo Container Registry. - * @param image - * @param requestOptions - * @returns {Promise<*>} */ - async authenticate(image, requestOptions) { - const axiosConfig = { + async authenticate( + image: ContainerImage, + requestOptions: AxiosRequestConfig, + ) { + const axiosConfig: AxiosRequestConfig = { method: 'GET', url: `${this.configuration.url}/v2/token?service=container_registry&scope=repository:${image.name}:pull`, headers: { diff --git a/app/registries/providers/gitlab/Gitlab.test.ts b/app/registries/providers/gitlab/Gitlab.test.ts index ac853e86..3605abeb 100644 --- a/app/registries/providers/gitlab/Gitlab.test.ts +++ b/app/registries/providers/gitlab/Gitlab.test.ts @@ -49,13 +49,7 @@ test('maskConfiguration should mask configuration secrets', async () => { }); test('match should return true when registry url is from gitlab.com', async () => { - expect( - gitlab.match({ - registry: { - url: 'gitlab.com', - }, - }), - ).toBeTruthy(); + expect(gitlab.match('gitlab.com')).toBeTruthy(); }); test('match should return true when registry url is from custom gitlab', async () => { @@ -65,13 +59,7 @@ test('match should return true when registry url is from custom gitlab', async ( authurl: 'https://custom.com', token: 'abcdef', }; - expect( - gitlabCustom.match({ - registry: { - url: 'custom.com', - }, - }), - ).toBeTruthy(); + expect(gitlabCustom.match('custom.com')).toBeTruthy(); }); test('authenticate should perform authenticate request', async () => { diff --git a/app/registries/providers/gitlab/Gitlab.ts b/app/registries/providers/gitlab/Gitlab.ts index 0482c019..a6fdbbcf 100644 --- a/app/registries/providers/gitlab/Gitlab.ts +++ b/app/registries/providers/gitlab/Gitlab.ts @@ -1,6 +1,6 @@ -// @ts-nocheck -import axios from 'axios'; +import axios, { AxiosRequestConfig } from 'axios'; import Registry from '../../Registry'; +import { ContainerImage } from '../../../model/container'; /** * Docker Gitlab integration. @@ -8,7 +8,6 @@ import Registry from '../../Registry'; class Gitlab extends Registry { /** * Get the Gitlab configuration schema. - * @returns {*} */ getConfigurationSchema() { return this.joi.object().keys({ @@ -20,7 +19,6 @@ class Gitlab extends Registry { /** * Sanitize sensitive data - * @returns {*} */ maskConfiguration() { return { @@ -33,20 +31,16 @@ class Gitlab extends Registry { /** * Return true if image has no registry url. - * @param image the image - * @returns {boolean} */ - match(image) { - return this.configuration.url.indexOf(image.registry.url) !== -1; + match(imageUrl: string) { + return this.configuration.url.indexOf(imageUrl) !== -1; } /** * Normalize images according to Gitlab characteristics. - * @param image - * @returns {*} */ - normalizeImage(image) { + normalizeImage(image: ContainerImage) { const imageNormalized = image; if (!imageNormalized.registry.url.startsWith('https://')) { imageNormalized.registry.url = `https://${imageNormalized.registry.url}/v2`; @@ -56,11 +50,11 @@ class Gitlab extends Registry { /** * Authenticate to Gitlab. - * @param image - * @param requestOptions - * @returns {Promise<*>} */ - async authenticate(image, requestOptions) { + async authenticate( + image: ContainerImage, + requestOptions: AxiosRequestConfig, + ) { const request = { method: 'GET', url: `${this.configuration.authurl}/jwt/auth?service=container_registry&scope=repository:${image.name}:pull`, @@ -77,7 +71,6 @@ class Gitlab extends Registry { /** * Return empty username and personal access token value. - * @returns {{password: (string|undefined|*), username: string}} */ async getAuthPull() { return { diff --git a/app/registries/providers/hub/Hub.test.ts b/app/registries/providers/hub/Hub.test.ts index 9384055b..58d8440c 100644 --- a/app/registries/providers/hub/Hub.test.ts +++ b/app/registries/providers/hub/Hub.test.ts @@ -23,14 +23,10 @@ describe('Docker Hub Registry', () => { }); test('should match registry', async () => { - expect(hub.match({ registry: { url: 'registry-1.docker.io' } })).toBe( - true, - ); - expect(hub.match({ registry: { url: 'docker.io' } })).toBe(true); - expect(hub.match({ registry: { url: undefined } })).toBe(true); - expect(hub.match({ registry: { url: 'other.registry.com' } })).toBe( - false, - ); + expect(hub.match('registry-1.docker.io')).toBe(true); + expect(hub.match('docker.io')).toBe(true); + expect(hub.match(undefined)).toBe(true); + expect(hub.match('other.registry.com')).toBe(false); }); test('should normalize image name for official images', async () => { @@ -154,4 +150,67 @@ describe('Docker Hub Registry', () => { auth: 'd**********0', }); }); + + describe('shouldWatchDigest', () => { + test('should return false without label or config', () => { + const result = hub.shouldWatchDigest(undefined, 'library/nginx'); + expect(result).toBe(false); + }); + + test('should return true when label is true (semver flag handled upstream)', () => { + const result = hub.shouldWatchDigest('true', 'library/nginx'); + expect(result).toBe(true); + }); + + test('should return false for non-semver without label (throttling protection)', () => { + const result = hub.shouldWatchDigest(undefined, 'library/nginx'); + expect(result).toBe(false); + }); + + test('should return true when label is true (non-semver)', () => { + const result = hub.shouldWatchDigest('true', 'library/nginx'); + expect(result).toBe(true); + }); + + test('should return true when watchdigest config is true', async () => { + const hubWithWatchDigest = new Hub(); + await hubWithWatchDigest.register('registry', 'hub', 'test', { + watchdigest: true, + }); + const result = hubWithWatchDigest.shouldWatchDigest( + undefined, + 'library/nginx', + ); + expect(result).toBe(true); + }); + + test('should log warning when watching digest without suppressdigestwatchwarning', async () => { + const hubWithWatchDigest = new Hub(); + await hubWithWatchDigest.register('registry', 'hub', 'test', { + watchdigest: true, + }); + const mockWarn = jest.fn(); + hubWithWatchDigest.log = { warn: mockWarn }; + + hubWithWatchDigest.shouldWatchDigest(undefined, 'library/nginx'); + + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('throttled requests'), + ); + }); + + test('should not log warning when suppressdigestwatchwarning is true', async () => { + const hubWithWatchDigest = new Hub(); + await hubWithWatchDigest.register('registry', 'hub', 'test', { + watchdigest: true, + suppressdigestwatchwarning: true, + }); + const mockWarn = jest.fn(); + hubWithWatchDigest.log = { warn: mockWarn }; + + hubWithWatchDigest.shouldWatchDigest(undefined, 'library/nginx'); + + expect(mockWarn).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/registries/providers/hub/Hub.ts b/app/registries/providers/hub/Hub.ts index e9c8fb7b..8b035698 100644 --- a/app/registries/providers/hub/Hub.ts +++ b/app/registries/providers/hub/Hub.ts @@ -1,12 +1,12 @@ -// @ts-nocheck -import axios from 'axios'; +import axios, { AxiosRequestConfig } from 'axios'; import Custom from '../custom/Custom'; +import { ContainerImage } from '../../../model/container'; /** * Docker Hub integration. */ class Hub extends Custom { - init() { + async init() { this.configuration.url = 'https://registry-1.docker.io'; if (this.configuration.token) { this.configuration.password = this.configuration.token; @@ -15,7 +15,6 @@ class Hub extends Custom { /** * Get the Hub configuration schema. - * @returns {*} */ getConfigurationSchema() { return this.joi.alternatives([ @@ -25,13 +24,28 @@ class Hub extends Custom { password: this.joi.string(), token: this.joi.string(), auth: this.joi.string().base64(), + watchdigest: this.joi.bool().default(false), + suppressdigestwatchwarning: this.joi.bool().default(false), }), ]); } + shouldWatchDigest(wudWatchDigestLabelValue: string, image: string) { + const shouldWatch = + (wudWatchDigestLabelValue && + wudWatchDigestLabelValue.toLowerCase() === 'true') || + this.configuration.watchdigest === true; + if (shouldWatch && !this.configuration.suppressdigestwatchwarning) { + this.log.warn( + `Watching digest for image ${image} may result in throttled requests`, + ); + } + + return shouldWatch; + } + /** * Sanitize sensitive data - * @returns {*} */ maskConfiguration() { return { @@ -46,22 +60,16 @@ class Hub extends Custom { /** * Return true if image has no registry url. - * @param image the image - * @returns {boolean} */ - match(image) { - return ( - !image.registry.url || /^.*\.?docker.io$/.test(image.registry.url) - ); + match(imageUrl: string) { + return !imageUrl || /^.*\.?docker.io$/.test(imageUrl); } /** * Normalize images according to Hub characteristics. - * @param image - * @returns {*} */ - normalizeImage(image) { + normalizeImage(image: ContainerImage) { const imageNormalized = super.normalizeImage(image); if (imageNormalized.name) { imageNormalized.name = imageNormalized.name.includes('/') @@ -73,12 +81,12 @@ class Hub extends Custom { /** * Authenticate to Hub. - * @param image - * @param requestOptions - * @returns {Promise<*>} */ - async authenticate(image, requestOptions) { - const axiosConfig = { + async authenticate( + image: ContainerImage, + requestOptions: AxiosRequestConfig, + ) { + const axiosConfig: AxiosRequestConfig = { method: 'GET', url: `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${image.name}:pull&grant_type=password`, headers: { @@ -98,7 +106,7 @@ class Hub extends Custom { return requestOptionsWithAuth; } - getImageFullName(image, tagOrDigest) { + getImageFullName(image: ContainerImage, tagOrDigest: string) { let fullName = super.getImageFullName(image, tagOrDigest); fullName = fullName.replace(/registry-1.docker.io\//, ''); fullName = fullName.replace(/library\//, ''); diff --git a/app/registries/providers/lscr/Lscr.test.ts b/app/registries/providers/lscr/Lscr.test.ts index 74135c6d..8335ae2a 100644 --- a/app/registries/providers/lscr/Lscr.test.ts +++ b/app/registries/providers/lscr/Lscr.test.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +import { ContainerImage } from '../../../model/container'; import Lscr from './Lscr'; jest.mock('axios', () => @@ -34,23 +34,11 @@ test('validatedConfiguration should throw error when configuration is missing', }); test('match should return true when registry url is from lscr', async () => { - expect( - lscr.match({ - registry: { - url: 'lscr.io', - }, - }), - ).toBeTruthy(); + expect(lscr.match('lscr.io')).toBeTruthy(); }); test('match should return false when registry url is not from lscr', async () => { - expect( - lscr.match({ - registry: { - url: 'wrong.io', - }, - }), - ).toBeFalsy(); + expect(lscr.match('wrong.io')).toBeFalsy(); }); test('normalizeImage should return the proper registry v2 endpoint', async () => { @@ -60,7 +48,7 @@ test('normalizeImage should return the proper registry v2 endpoint', async () => registry: { url: 'lscr.io/test/image', }, - }), + } as ContainerImage), ).toStrictEqual({ name: 'test/image', registry: { diff --git a/app/registries/providers/lscr/Lscr.ts b/app/registries/providers/lscr/Lscr.ts index 9083be43..50aa1b8d 100644 --- a/app/registries/providers/lscr/Lscr.ts +++ b/app/registries/providers/lscr/Lscr.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +import { ContainerImage } from '../../../model/container'; import Ghcr from '../ghcr/Ghcr'; /** @@ -14,21 +14,17 @@ class Lscr extends Ghcr { /** * Return true if image has not registry url. - * @param image the image - * @returns {boolean} */ - match(image) { - return /^.*\.?lscr.io$/.test(image.registry.url); + match(imageUrl: string) { + return /^.*\.?lscr.io$/.test(imageUrl); } /** * Normalize image according to Github Container Registry characteristics. - * @param image - * @returns {*} */ - normalizeImage(image) { + normalizeImage(image: ContainerImage) { const imageNormalized = image; if (!imageNormalized.registry.url.startsWith('https://')) { imageNormalized.registry.url = `https://${imageNormalized.registry.url}/v2`; diff --git a/app/registries/providers/quay/Quay.test.ts b/app/registries/providers/quay/Quay.test.ts index 692833e7..4f578615 100644 --- a/app/registries/providers/quay/Quay.test.ts +++ b/app/registries/providers/quay/Quay.test.ts @@ -5,7 +5,9 @@ import log from '../../../log'; jest.mock('axios'); axios.mockImplementation(() => ({ - token: 'token', + data: { + token: 'token', + }, })); const quay = new Quay(); @@ -56,23 +58,11 @@ test('maskConfiguration should mask authentication configuration secrets', async }); test('match should return true when registry url is from quay.io', async () => { - expect( - quay.match({ - registry: { - url: 'quay.io', - }, - }), - ).toBeTruthy(); + expect(quay.match('quay.io')).toBeTruthy(); }); test('match should return false when registry url is not from quay.io', async () => { - expect( - quay.match({ - registry: { - url: 'error.io', - }, - }), - ).toBeFalsy(); + expect(quay.match('error.io')).toBeFalsy(); }); test('normalizeImage should return the proper registry v2 endpoint', async () => { diff --git a/app/registries/providers/quay/Quay.ts b/app/registries/providers/quay/Quay.ts index 06e3f86e..2e2fa2ed 100644 --- a/app/registries/providers/quay/Quay.ts +++ b/app/registries/providers/quay/Quay.ts @@ -1,6 +1,6 @@ -// @ts-nocheck -import axios from 'axios'; +import axios, { AxiosRequestConfig } from 'axios'; import Registry from '../../Registry'; +import { ContainerImage } from '../../../model/container'; /** * Quay.io Registry integration. @@ -22,7 +22,6 @@ class Quay extends Registry { /** * Sanitize sensitive data - * @returns {*} */ maskConfiguration() { return { @@ -35,21 +34,15 @@ class Quay extends Registry { /** * Return true if image has not registry url. - * @param image the image - * @returns {boolean} */ - - match(image) { - return /^.*\.?quay\.io$/.test(image.registry.url); + match(imageUrl: string) { + return /^.*\.?quay\.io$/.test(imageUrl); } /** * Normalize image according to Github Container Registry characteristics. - * @param image - * @returns {*} */ - - normalizeImage(image) { + normalizeImage(image: ContainerImage) { const imageNormalized = image; if (!imageNormalized.registry.url.startsWith('https://')) { imageNormalized.registry.url = `https://${imageNormalized.registry.url}/v2`; @@ -57,9 +50,12 @@ class Quay extends Registry { return imageNormalized; } - async authenticate(image, requestOptions) { + async authenticate( + image: ContainerImage, + requestOptions: AxiosRequestConfig, + ) { const requestOptionsWithAuth = requestOptions; - let token; + let token: string | undefined; // Add Authorization if any const credentials = this.getAuthCredentials(); @@ -73,8 +69,8 @@ class Quay extends Registry { }, }; try { - const response = await axios(request); - token = response.token; + const response = await axios<{ token: string }>(request); + token = response.data.token; } catch (e) { this.log.warn( `Error when trying to get an access token (${e.message})`, @@ -91,7 +87,6 @@ class Quay extends Registry { /** * Return Base64 credentials if any. - * @returns {string|undefined|*} */ getAuthCredentials() { if (this.configuration.namespace && this.configuration.account) { @@ -105,7 +100,6 @@ class Quay extends Registry { /** * Return username / password for Docker(+compose) triggers usage - * @return {{password: string, username: string}|undefined} */ async getAuthPull() { if (this.configuration.namespace && this.configuration.account) { @@ -117,7 +111,11 @@ class Quay extends Registry { return undefined; } - getTagsPage(image, lastItem, link) { + getTagsPage( + image: ContainerImage, + _lastItem: string | undefined = undefined, + link: string | undefined = undefined, + ) { // Default items per page (not honoured by all registries) const itemsPerPage = 1000; let nextOrLast = ''; diff --git a/app/registries/providers/trueforge/Trueforge.test.ts b/app/registries/providers/trueforge/Trueforge.test.ts index 9df01bc5..40db72cf 100644 --- a/app/registries/providers/trueforge/Trueforge.test.ts +++ b/app/registries/providers/trueforge/Trueforge.test.ts @@ -34,23 +34,11 @@ test('validatedConfiguration should throw error when configuration is missing', }); test('match should return true when registry url is from trueforge', async () => { - expect( - trueforge.match({ - registry: { - url: 'oci.trueforge.org', - }, - }), - ).toBeTruthy(); + expect(trueforge.match('oci.trueforge.org')).toBeTruthy(); }); test('match should return false when registry url is not from trueforge', async () => { - expect( - trueforge.match({ - registry: { - url: 'wrong.io', - }, - }), - ).toBeFalsy(); + expect(trueforge.match('wrong.io')).toBeFalsy(); }); test('normalizeImage should return the proper registry v2 endpoint', async () => { diff --git a/app/registries/providers/trueforge/Trueforge.ts b/app/registries/providers/trueforge/Trueforge.ts index 746c9a4e..2b14b319 100644 --- a/app/registries/providers/trueforge/Trueforge.ts +++ b/app/registries/providers/trueforge/Trueforge.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +import { ContainerImage } from '../../../model/container'; import Ghcr from '../ghcr/Ghcr'; /** @@ -14,21 +14,15 @@ class Trueforge extends Ghcr { /** * Return true if image has not registry url. - * @param image the image - * @returns {boolean} */ - - match(image) { - return /^.*\.?oci.trueforge.org$/.test(image.registry.url); + match(imageUrl: string) { + return /^.*\.?oci.trueforge.org$/.test(imageUrl); } /** * Normalize image according to Github Container Registry characteristics. - * @param image - * @returns {*} */ - - normalizeImage(image) { + normalizeImage(image: ContainerImage) { const imageNormalized = image; if (!imageNormalized.registry.url.startsWith('https://')) { imageNormalized.registry.url = `https://${imageNormalized.registry.url}/v2`; diff --git a/app/registry/Component.ts b/app/registry/Component.ts index f9471cd4..60d20671 100644 --- a/app/registry/Component.ts +++ b/app/registry/Component.ts @@ -54,7 +54,6 @@ class Component { /** * Deregister the component. - * @returns {Promise} */ async deregister(): Promise { this.log.info('Deregister component'); @@ -63,10 +62,8 @@ class Component { } /** - * Deregistger the component (do nothing by default). - * @returns {Promise} + * Deregister the component (do nothing by default). */ - async deregisterComponent(): Promise { // Do nothing by default } @@ -75,7 +72,7 @@ class Component { * Validate the configuration of the component. * * @param configuration the configuration - * @returns {*} or throw a validation error + * @returns or throw a validation error */ validateConfiguration( configuration: ComponentConfiguration, @@ -91,9 +88,8 @@ class Component { /** * Get the component configuration schema. * Can be overridden by the component implementation class - * @returns {*} */ - getConfigurationSchema(): joi.ObjectSchema { + getConfigurationSchema(): joi.AnySchema { return this.joi.object(); } @@ -101,12 +97,10 @@ class Component { * Init the component. * Can be overridden by the component implementation class */ - async init(): Promise {} /** * Sanitize sensitive data - * @returns {*} */ maskConfiguration( configuration?: ComponentConfiguration, @@ -116,7 +110,6 @@ class Component { /** * Get Component ID. - * @returns {string} */ getId(): string { return `${this.type}.${this.name}`; @@ -127,7 +120,7 @@ class Component { * @param value the value to mask * @param nb the number of chars to keep start/end * @param char the replacement char - * @returns {string|undefined} the masked string + * @returns the masked string */ static mask( value: string | undefined, diff --git a/app/registry/index.ts b/app/registry/index.ts index ee2bef8c..96cc40ea 100644 --- a/app/registry/index.ts +++ b/app/registry/index.ts @@ -19,10 +19,10 @@ import Registry from '../registries/Registry'; import Authentication from '../authentications/providers/Authentication'; export interface RegistryState { - trigger: { [key: string]: Trigger }; - watcher: { [key: string]: Watcher }; - registry: { [key: string]: Registry }; - authentication: { [key: string]: Authentication }; + trigger: Record; + watcher: Record; + registry: Record; + authentication: Record; } type ComponentKind = keyof RegistryState; diff --git a/app/triggers/providers/Trigger.ts b/app/triggers/providers/Trigger.ts index d8d3c4f4..3f00449e 100644 --- a/app/triggers/providers/Trigger.ts +++ b/app/triggers/providers/Trigger.ts @@ -2,6 +2,7 @@ import Component, { ComponentConfiguration } from '../../registry/Component'; import * as event from '../../event'; import { getTriggerCounter } from '../../prometheus/trigger'; import { fullName, Container } from '../../model/container'; +import { AlternativesSchema, ObjectSchema } from 'joi'; export interface TriggerConfiguration extends ComponentConfiguration { auto?: boolean; @@ -192,7 +193,7 @@ class Trigger extends Component { } /** - * Inccrease the Prometheus trigger counter with the provided status. + * Increase the Prometheus trigger counter with the provided status. * @param status the trigger result status */ increasePrometheusTriggerCounter(status: string) { @@ -208,8 +209,6 @@ class Trigger extends Component { /** * Handle container reports (batch mode). - * @param containerReports - * @returns {Promise} */ async handleContainerReports(containerReports: ContainerReport[]) { // Filter on containers with update available and passing trigger threshold @@ -292,8 +291,6 @@ class Trigger extends Component { /** * Return true if must trigger on this container. - * @param containerResult - * @returns {boolean} */ mustTrigger(containerResult: Container) { const { triggerInclude, triggerExclude } = containerResult; @@ -333,13 +330,11 @@ class Trigger extends Component { /** * Override method to merge with common Trigger options (threshold...). - * @param configuration - * @returns {*} */ validateConfiguration( configuration: TriggerConfiguration, ): TriggerConfiguration { - const schema = this.getConfigurationSchema(); + const schema = this.getConfigurationSchema() as ObjectSchema; const schemaWithDefaultOptions = schema.append({ auto: this.joi.bool().default(true), threshold: this.joi @@ -388,15 +383,13 @@ class Trigger extends Component { /** * Init Trigger. Can be overridden in trigger implementation class. */ - - initTrigger() { + async initTrigger() { // do nothing by default } /** * Trigger method. Must be overridden in trigger implementation class. */ - trigger(containerWithResult: Container) { // do nothing by default this.log.warn( diff --git a/app/watchers/providers/docker/Docker.test.ts b/app/watchers/providers/docker/Docker.test.ts index 215dd520..751c04e5 100644 --- a/app/watchers/providers/docker/Docker.test.ts +++ b/app/watchers/providers/docker/Docker.test.ts @@ -603,11 +603,12 @@ describe('Docker Watcher', () => { getTags: jest .fn() .mockResolvedValue(['1.0.0', '1.1.0', '2.0.0']), + shouldWatchDigest: jest.fn(() => false), }; registry.getState.mockReturnValue({ registry: { hub: mockRegistry }, }); - const mockLogChild = { error: jest.fn() }; + const mockLogChild = { error: jest.fn(), warn: jest.fn() }; const result = await docker.findNewVersion(container, mockLogChild); @@ -624,7 +625,7 @@ describe('Docker Watcher', () => { }, }; registry.getState.mockReturnValue({ registry: {} }); - const mockLogChild = { error: jest.fn() }; + const mockLogChild = { error: jest.fn(), warn: jest.fn() }; try { await docker.findNewVersion(container, mockLogChild); @@ -654,6 +655,7 @@ describe('Docker Watcher', () => { .mockResolvedValueOnce({ digest: 'sha256:manifest123', }), + shouldWatchDigest: jest.fn(() => true), }; registry.getState.mockReturnValue({ registry: { hub: mockRegistry }, @@ -686,6 +688,7 @@ describe('Docker Watcher', () => { created: '2023-01-01', version: 1, }), + shouldWatchDigest: jest.fn(() => true), }; registry.getState.mockReturnValue({ registry: { hub: mockRegistry }, @@ -720,6 +723,7 @@ describe('Docker Watcher', () => { 'v2.0.0-beta', 'latest', ]), + shouldWatchDigest: jest.fn(() => false), }; registry.getState.mockReturnValue({ registry: { hub: mockRegistry }, @@ -748,6 +752,7 @@ describe('Docker Watcher', () => { '1.1', // 2 parts, should be kept (but lower) '2', // 1 part, should be filtered out ]), + shouldWatchDigest: jest.fn(() => false), }; registry.getState.mockReturnValue({ registry: { hub: mockRegistry }, @@ -805,6 +810,7 @@ describe('Docker Watcher', () => { normalizeImage: jest.fn((img) => img), getId: () => 'hub', match: () => true, + shouldWatchDigest: jest.fn(() => false), }; registry.getState.mockReturnValue({ registry: { hub: mockRegistry }, @@ -855,6 +861,7 @@ describe('Docker Watcher', () => { normalizeImage: jest.fn((img) => img), getId: () => 'hub', match: () => true, + shouldWatchDigest: jest.fn(() => false), }; registry.getState.mockReturnValue({ registry: { hub: mockRegistry }, @@ -897,6 +904,7 @@ describe('Docker Watcher', () => { normalizeImage: jest.fn((img) => img), getId: () => 'hub', match: () => true, + shouldWatchDigest: jest.fn(() => false), }; registry.getState.mockReturnValue({ registry: { hub: mockRegistry }, @@ -962,6 +970,7 @@ describe('Docker Watcher', () => { normalizeImage: jest.fn((img) => img), getId: () => 'hub', match: () => true, + shouldWatchDigest: jest.fn(() => false), }; registry.getState.mockReturnValue({ registry: { hub: mockRegistry }, @@ -1087,181 +1096,3 @@ describe('Docker Watcher', () => { }); }); }); - -describe('isDigestToWatch Logic', () => { - let docker; - let mockImage; - - beforeEach(async () => { - // 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 = async (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 containerModule = await import('../../../model/container'); - const validateContainer = containerModule.validate; - // @ts-ignore - validateContainer.mockImplementation((c) => c); - - return container; - }; - - // Case 1: Explicit Label present - test('should watch digest if label is true (semver)', async () => { - const container = await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await setupTest({}, undefined, 'latest', false); // Implicit - const result = await docker.addImageDetailsToContainer(container); - expect(result.image.digest.watch).toBe(false); - }); -}); diff --git a/app/watchers/providers/docker/Docker.ts b/app/watchers/providers/docker/Docker.ts index b6273a72..3d9c412d 100644 --- a/app/watchers/providers/docker/Docker.ts +++ b/app/watchers/providers/docker/Docker.ts @@ -25,17 +25,16 @@ import { wudTriggerExclude, } from './label'; import * as storeContainer from '../../../store/container'; -import log from '../../../log'; import { validate as validateContainer, fullName, Container, - ContainerImage, } from '../../../model/container'; import * as registry from '../../../registry'; import { getWatchContainerGauge } from '../../../prometheus/watcher'; import Watcher from '../../Watcher'; import { ComponentConfiguration } from '../../../registry/Component'; +import Logger from 'bunyan'; export interface DockerWatcherConfiguration extends ComponentConfiguration { socket: string; @@ -61,7 +60,6 @@ const DEBOUNCED_WATCH_CRON_MS = 5000; /** * Return all supported registries - * @returns {*} */ function getRegistries() { return registry.getState().registry; @@ -69,9 +67,6 @@ function getRegistries() { /** * Filter candidate tags (based on tag name). - * @param container - * @param tags - * @returns {*} */ function getTagCandidates( container: Container, @@ -109,7 +104,7 @@ function getTagCandidates( } // If user has not specified custom include regex, default to keep current prefix - // Prefix is almost-always standardised around "must stay the same" for tags + // Prefix is almost-always standardized around "must stay the same" for tags if (!container.includeTags) { const currentTag = container.image.tag.value; const match = currentTag.match(/^(.*?)(\d+.*)$/); @@ -190,27 +185,8 @@ function getTagCandidates( return filteredTags; } -function normalizeContainer(container: Container) { - const containerWithNormalizedImage = container; - const registryProvider = Object.values(getRegistries()).find((provider) => - provider.match(container.image), - ); - if (!registryProvider) { - log.warn(`${fullName(container)} - No Registry Provider found`); - containerWithNormalizedImage.image.registry.name = 'unknown'; - } else { - containerWithNormalizedImage.image = registryProvider.normalizeImage( - container.image, - ); - containerWithNormalizedImage.image.registry.name = - registryProvider.getId(); - } - return validateContainer(containerWithNormalizedImage); -} - /** * Get the Docker Registry by name. - * @param registryName */ function getRegistry(registryName: string) { const registryToReturn = getRegistries()[registryName]; @@ -222,9 +198,6 @@ function getRegistry(registryName: string) { /** * Get old containers to prune. - * @param newContainers - * @param containersFromTheStore - * @returns {*[]|*} */ function getOldContainers( newContainers: Container[], @@ -243,8 +216,6 @@ function getOldContainers( /** * Prune old containers from the store. - * @param newContainers - * @param containersFromTheStore */ function pruneOldContainers( newContainers: Container[], @@ -272,8 +243,6 @@ function getContainerName(container: any) { /** * Get image repo digest. - * @param containerImage - * @returns {*} digest */ function getRepoDigest(containerImage: any) { if ( @@ -291,7 +260,6 @@ function getRepoDigest(containerImage: any) { * Return true if container must be watched. * @param wudWatchLabelValue the value of the wud.watch label * @param watchByDefault true if containers must be watched by default - * @returns {boolean} */ function isContainerToWatch( wudWatchLabelValue: string, @@ -302,44 +270,6 @@ function isContainerToWatch( : watchByDefault; } -/** - * Return true if container digest must be watched. - * @param {string} wudWatchDigestLabelValue - the value of wud.watch.digest label - * @param {object} parsedImage - object containing at least `domain` property - * @returns {boolean} - */ -function isDigestToWatch( - wudWatchDigestLabelValue: string, - parsedImage: any, - isSemver: boolean, -) { - const domain = parsedImage.domain; - const isDockerHub = - !domain || - domain === '' || - domain === 'docker.io' || - domain.endsWith('.docker.io'); - - if ( - wudWatchDigestLabelValue !== undefined && - wudWatchDigestLabelValue !== '' - ) { - 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 (isSemver) { - return false; - } - - return !isDockerHub; -} - /** * Docker Watcher Component. */ @@ -351,29 +281,7 @@ class Docker extends Watcher { public watchCronTimeout: any; public watchCronDebounced: any; public listenDockerEventsTimeout: any; - - ensureLogger() { - if (!this.log) { - try { - this.log = log.child({ - component: `watcher.docker.${this.name || 'default'}`, - }); - } catch (error) { - // Fallback to silent logger if log module fails - this.log = { - // @ts-ignore Unused implementation - info: () => {}, - // @ts-ignore Unused implementation - warn: () => {}, - // @ts-ignore Unused implementation - error: () => {}, - // @ts-ignore Unused implementation - debug: () => {}, - child: () => this.log, - }; - } - } - } + public dockerEventsStream: any; getConfigurationSchema() { return joi.object().keys({ @@ -397,7 +305,6 @@ class Docker extends Watcher { * Init the Watcher. */ async init() { - this.ensureLogger(); this.initWatcher(); if (this.configuration.watchdigest !== undefined) { this.log.warn( @@ -458,7 +365,6 @@ class Docker extends Watcher { /** * Deregister the component. - * @returns {Promise} */ async deregisterComponent() { if (this.watchCron) { @@ -476,10 +382,8 @@ class Docker extends Watcher { /** * Listen and react to docker events. - * @return {Promise} */ async listenDockerEvents() { - this.ensureLogger(); if (!this.log || typeof this.log.info !== 'function') { return; } @@ -524,11 +428,8 @@ class Docker extends Watcher { /** * Process a docker event. - * @param dockerEventChunk - * @return {Promise} */ async onDockerEvent(dockerEventChunk: any) { - this.ensureLogger(); let dockerEvent; try { dockerEvent = JSON.parse(dockerEventChunk.toString()); @@ -576,10 +477,8 @@ class Docker extends Watcher { /** * Watch containers (called by cron scheduled tasks). - * @returns {Promise<*[]>} */ async watchFromCron() { - this.ensureLogger(); if (!this.log || typeof this.log.info !== 'function') { return []; } @@ -602,7 +501,6 @@ class Docker extends Watcher { ).length; const stats = `${containerReportsCount} containers watched, ${containerErrorsCount} errors, ${containerUpdatesCount} available updates`; - this.ensureLogger(); if (this.log && typeof this.log.info === 'function') { this.log.info(`Cron finished (${stats})`); } @@ -611,10 +509,8 @@ class Docker extends Watcher { /** * Watch main method. - * @returns {Promise<*[]>} */ async watch() { - this.ensureLogger(); let containers: Container[] = []; // Dispatch event to notify start watching @@ -647,11 +543,8 @@ class Docker extends Watcher { /** * Watch a Container. - * @param container - * @returns {Promise<*>} */ async watchContainer(container: Container) { - this.ensureLogger(); // Child logger for the container to process const logContainer = this.log.child({ container: fullName(container) }); const containerWithResult = container; @@ -682,10 +575,8 @@ class Docker extends Watcher { /** * Get all containers to watch. - * @returns {Promise} */ async getContainers(): Promise { - this.ensureLogger(); const listContainersOptions: Dockerode.ContainerListOptions = {}; if (this.configuration.watchall) { listContainersOptions.all = true; @@ -695,13 +586,13 @@ class Docker extends Watcher { ); // Filter on containers to watch - const filteredContainers = containers.filter((container: any) => + const filteredContainers = containers.filter((container) => isContainerToWatch( container.Labels[wudWatch], this.configuration.watchbydefault, ), ); - const containerPromises = filteredContainers.map((container: any) => + const containerPromises = filteredContainers.map((container) => this.addImageDetailsToContainer( container, container.Labels[wudTagInclude], @@ -714,7 +605,7 @@ class Docker extends Watcher { container.Labels[wudTriggerExclude], ).catch((e) => { this.log.warn( - `Failed to fetch image detail for container ${container.Id}: ${e.message}`, + `Failed to fetch image detail for container ${container.Id}: ${e.message} - ${e.stack}`, ); return e; }), @@ -761,7 +652,7 @@ class Docker extends Watcher { * Find new version for a Container. */ - async findNewVersion(container: Container, logContainer: any) { + async findNewVersion(container: Container, logContainer: Logger) { const registryProvider = getRegistry(container.image.registry.name); const result: any = { tag: container.image.tag.value }; if (!registryProvider) { @@ -770,6 +661,19 @@ class Docker extends Watcher { ); return result; } else { + const watchDigest = + !container.image.tag.semver && + registryProvider.shouldWatchDigest( + container.labels?.[wudWatchDigest], + container.image.name, + ); + + if (!container.image.tag.semver && !watchDigest) { + this.log.warn( + `Image ${container.image.name} is not a semver and digest watching is disabled so wud won't report any update. Please review the configuration to enable digest watching for this container or exclude this container from being watched`, + ); + } + // Get all available tags const tags = await registryProvider.getTags(container.image); @@ -781,7 +685,7 @@ class Docker extends Watcher { ); // Must watch digest? => Find local/remote digests on registry - if (container.image.digest.watch && container.image.digest.repo) { + if (watchDigest && container.image.digest.repo) { // If we have a tag candidate BUT we also watch digest // (case where local=`mongo:8` and remote=`mongo:8.0.0`), // Then get the digest of the tag candidate @@ -832,14 +736,6 @@ class Docker extends Watcher { /** * Add image detail to Container. - * @param container - * @param includeTags - * @param excludeTags - * @param transformTags - * @param linkTemplate - * @param displayName - * @param displayIcon - * @returns {Promise} */ async addImageDetailsToContainer( container: any, @@ -860,7 +756,6 @@ class Docker extends Watcher { containerInStore !== undefined && containerInStore.error === undefined ) { - this.ensureLogger(); this.log.debug(`Container ${containerInStore.id} already in store`); return containerInStore; } @@ -882,7 +777,6 @@ class Docker extends Watcher { let imageNameToParse = container.Image; if (imageNameToParse.includes('sha256:')) { if (!image.RepoTags || image.RepoTags.length === 0) { - this.ensureLogger(); this.log.warn( `Cannot get a reliable tag for this image [${imageNameToParse}]`, ); @@ -903,20 +797,26 @@ class Docker extends Watcher { }; } - const parsedTag = parseSemver(transformTag(transformTags, tagName)); - const isSemver = parsedTag !== null && parsedTag !== undefined; - const watchDigest = isDigestToWatch( - container.Labels[wudWatchDigest], - parsedImage, - isSemver, + const registryProvider = Object.values(getRegistries()).find( + (registry) => registry.match(parsedImage.domain), ); - if (!isSemver && !watchDigest) { - this.ensureLogger(); + + if (!registryProvider) { this.log.warn( - "Image is not a semver and digest watching is disabled so wud won't report any update. Please review the configuration to enable digest watching for this container or exclude this container from being watched", + `${container.Image} - ${parsedImage.domain} - No Registry Provider found`, ); + return; } - return normalizeContainer({ + const parsedTag = parseSemver(transformTag(transformTags, tagName)); + const isSemver = parsedTag !== null && parsedTag !== undefined; + const watchDigest = + !isSemver && + registryProvider.shouldWatchDigest( + container.Labels[wudWatchDigest], + parsedImage.path, + ); + + return this.normalizeContainer({ id: containerId, name: containerName, status, @@ -960,11 +860,8 @@ class Docker extends Watcher { /** * Process a Container with result and map to a containerReport. - * @param containerWithResult - * @return {*} */ mapContainerToContainerReport(containerWithResult: Container) { - this.ensureLogger(); const logContainer = this.log.child({ container: fullName(containerWithResult), }); @@ -995,6 +892,25 @@ class Docker extends Watcher { } return containerReport; } + + private normalizeContainer(container: Container) { + const containerWithNormalizedImage = container; + const registryProvider = Object.values(getRegistries()).find( + (provider) => provider.match(container.image.registry.url), + ); + if (!registryProvider) { + this.log.warn( + `${fullName(container)} - No Registry Provider found`, + ); + containerWithNormalizedImage.image.registry.name = 'unknown'; + } else { + containerWithNormalizedImage.image = + registryProvider.normalizeImage(container.image); + containerWithNormalizedImage.image.registry.name = + registryProvider.getId(); + } + return validateContainer(containerWithNormalizedImage); + } } export default Docker; diff --git a/docs/configuration/registries/hub/README.md b/docs/configuration/registries/hub/README.md index fca8e45b..242233ee 100644 --- a/docs/configuration/registries/hub/README.md +++ b/docs/configuration/registries/hub/README.md @@ -1,9 +1,11 @@ # HUB (Docker Hub incl private repositories) + ![logo](docker.png) The `hub` registry lets you configure [Docker Hub](https://hub.docker.com/) integration. Currently, the supported credentials are: + - Docker Hub auth + Docker Hub Access Token - Docker Base64 credentials (like in [.docker/config.json](https://docs.docker.com/engine/reference/commandline/auth/)) - Docker Hub auth + Docker Hub password (not recommended) @@ -13,28 +15,34 @@ Don't forget to configure authentication if you're using [Docker Hub Private Rep ### Variables -| Env var | Required | Description | Supported values | Default value when missing | -| ---------------------------------- |:--------------:| ------------------------------------------------------------------------------------ | -------------------------------------------------------- | -------------------------- | -| `WUD_REGISTRY_HUB_PUBLIC_LOGIN` | :white_circle: | A valid Docker Hub Login | WUD_REGISTRY_HUB_PUBLIC_TOKEN must be defined | | -| `WUD_REGISTRY_HUB_PUBLIC_PASSWORD` | :white_circle: | A valid Docker Hub Token | WUD_REGISTRY_HUB_PUBLIC_LOGIN must be defined | | -| `WUD_REGISTRY_HUB_PUBLIC_TOKEN` | :white_circle: | A valid Docker Hub Token (deprecated; replaced by `WUD_REGISTRY_HUB_PUBLIC_PASSWORD` | WUD_REGISTRY_HUB_PUBLIC_LOGIN must be defined | | -| `WUD_REGISTRY_HUB_PUBLIC_AUTH` | :white_circle: | A valid Docker Hub Base64 Auth String | WUD_REGISTRY_HUB_PUBLIC_LOGIN/TOKEN must not be defined | | +| Env var | Required | Description | Supported values | Default value when missing | +| ---------------------------------------------------- | :------------: | ------------------------------------------------------------------------------------- | ------------------------------------------------------- | -------------------------- | +| `WUD_REGISTRY_HUB_PUBLIC_LOGIN` | :white_circle: | A valid Docker Hub Login | WUD_REGISTRY_HUB_PUBLIC_TOKEN must be defined | | +| `WUD_REGISTRY_HUB_PUBLIC_PASSWORD` | :white_circle: | A valid Docker Hub Token | WUD_REGISTRY_HUB_PUBLIC_LOGIN must be defined | | +| `WUD_REGISTRY_HUB_PUBLIC_TOKEN` | :white_circle: | A valid Docker Hub Token (deprecated; replaced by `WUD_REGISTRY_HUB_PUBLIC_PASSWORD`) | WUD_REGISTRY_HUB_PUBLIC_LOGIN must be defined | | +| `WUD_REGISTRY_HUB_PUBLIC_AUTH` | :white_circle: | A valid Docker Hub Base64 Auth String | WUD_REGISTRY_HUB_PUBLIC_LOGIN/TOKEN must not be defined | | +| `WUD_REGISTRY_HUB_PUBLIC_WATCHDIGEST` | :white_circle: | Watch digests | `true`, `false` | `false` | +| `WUD_REGISTRY_HUB_PUBLIC_SUPPRESSDIGESTWATCHWARNING` | :white_circle: | Suppress digest watch warning | `true`, `false` | `false` | ### Examples #### Configure Authentication using Login/Token ##### 1. Login to your [Docker Hub Account](https://hub.docker.com/) + ![image](hub_login.png) ##### 2. Go to your [Security Settings](https://hub.docker.com/settings/security) + - Create a new Access Token - Copy it and use it as the `WUD_REGISTRY_HUB_PUBLIC_TOKEN` value ![image](hub_token.png) + #### **Docker Compose** + ```yaml services: whatsupdocker: @@ -44,7 +52,9 @@ services: - WUD_REGISTRY_HUB_PUBLIC_LOGIN=mylogin - WUD_REGISTRY_HUB_PUBLIC_PASSWORD=fb4d5db9-e64d-3648-8846-74d0846e55de ``` + #### **Docker** + ```bash docker run \ -e WUD_REGISTRY_HUB_PUBLIC_LOGIN="mylogin" @@ -52,23 +62,29 @@ docker run \ ... getwud/wud ``` + #### Configure Authentication using Base64 encoded credentials ##### 1. Create an Access Token + [See above](registries/hub/?id=configure-authentication-using-logintoken) ##### 2. Encode with Base64 + Concatenate `$auth:$password` and [encode with Base64](https://www.base64encode.org/). For example, + - if your auth is `johndoe` - and your password is `2c1bd872-efb6-4f3a-81aa-724518a0a592` - the resulting encoded string would be `am9obmRvZToyYzFiZDg3Mi1lZmI2LTRmM2EtODFhYS03MjQ1MThhMGE1OTI=` + #### **Docker Compose** + ```yaml services: whatsupdocker: @@ -77,11 +93,91 @@ services: environment: - WUD_REGISTRY_HUB_PUBLIC_AUTH=am9obmRvZToyYzFiZDg3Mi1lZmI2LTRmM2EtODFhYS03MjQ1MThhMGE1OTI= ``` + #### **Docker** + ```bash docker run \ -e WUD_REGISTRY_HUB_PUBLIC_AUTH="am9obmRvZToyYzFiZDg3Mi1lZmI2LTRmM2EtODFhYS03MjQ1MThhMGE1OTI=" ... getwud/wud ``` + + +#### Enable digest watching + +By default, WUD tracks container updates by comparing image tags (e.g., `latest`, `1.2.3`). However, you can also enable digest watching to detect updates even when the tag hasn't changed. + +?> Digest watching is useful for tracking images with non-semantic tags like `latest`, `stable`, or `nightly` where the underlying image content can change without a tag change. + + + +#### **Docker Compose** + +```yaml +services: + whatsupdocker: + image: getwud/wud + ... + environment: + - WUD_REGISTRY_HUB_PUBLIC_WATCHDIGEST=true +``` + +#### **Docker** + +```bash +docker run \ + -e WUD_REGISTRY_HUB_PUBLIC_WATCHDIGEST="true" \ + ... + getwud/wud +``` + + + +### Docker Hub Rate Limiting + +!> Docker Hub enforces [rate limits](https://docs.docker.com/docker-hub/download-rate-limit/) on API requests which can affect WUD's ability to check for updates, especially when digest watching is enabled. + +#### Understanding Docker Hub Rate Limits + +Docker Hub limits the number of container image pulls and manifest requests based on your account type: + +- **Anonymous users**: 100 pulls per 6 hours per IP address +- **Authenticated users**: 200 pulls per 6 hours +- **Pro/Team subscriptions**: Higher or unlimited pulls + +#### How Rate Limiting Affects WUD + +When digest watching is enabled (`WUD_REGISTRY_HUB_PUBLIC_WATCHDIGEST=true`), WUD makes additional API requests to the Docker Registry to check image manifests. This can consume your Docker Hub quota more quickly. + +#### Recommendations to Avoid Rate Limit Issues + +1. **Authenticate with Docker Hub** - Use a personal access token to increase your quota from 100 to 200 requests per 6 hours + + ```yaml + - WUD_REGISTRY_HUB_PUBLIC_LOGIN=mylogin + - WUD_REGISTRY_HUB_PUBLIC_PASSWORD=your-access-token + ``` + +2. **Slow down the watcher frequency** - Adjust the CRON schedule to check less frequently + + ```yaml + - WUD_WATCHER_LOCAL_CRON=0 20 * * * # at 8 PM + ``` + +3. **Enable digest watching selectively** - Use the `wud.watch.digest` label on specific containers instead of enabling it globally + + ```yaml + labels: + - wud.watch.digest=true + ``` + +4. **Consider upgrading** - Docker Hub Pro or Team subscriptions provide higher rate limits + +5. **Suppress warnings** - If you're aware of the limitations and want to suppress warnings: + ```yaml + - WUD_REGISTRY_HUB_PUBLIC_SUPPRESSDIGESTWATCHWARNING=true + ``` + +?> If you encounter rate limit errors, you can check your current usage at [Docker Hub Rate Limit Info](https://hub.docker.com/usage/pulls)