import type { GetAuthorizationTokenCommandOutput } from '@aws-sdk/client-ecr'; import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr'; import { mockClient } from 'aws-sdk-client-mock'; import { codeBlock } from 'common-tags'; import * as _googleAuth from 'google-auth-library'; import { dir as tmpDir } from 'tmp-promise'; import { hostRules } from '~test/host-rules.ts'; import * as httpMock from '~test/http-mock.ts'; import { logger, partial } from '~test/util.ts'; import { range } from '../../../../lib/util/range.ts'; import { GlobalConfig } from '../../../config/global.ts'; import { EXTERNAL_HOST_ERROR } from '../../../constants/error-messages.ts'; import * as packageCache from '../../../util/cache/package/index.ts'; import { getDigest, getPkgReleases } from '../index.ts'; import { DOCKER_HUB } from './common.ts'; import { DockerHubCache } from './dockerhub-cache.ts'; import { DockerDatasource } from './index.ts'; const googleAuth = vi.mocked(_googleAuth, true); vi.mock('google-auth-library'); const ecrMock = mockClient(ECRClient); const baseUrl = 'https://index.docker.io/v2'; const authUrl = 'https://auth.docker.io'; const gcrUrl = 'https://eu.gcr.io/v2'; const garUrl = 'https://europe-docker.pkg.dev/v2'; const dockerHubUrl = 'https://hub.docker.com/v2/repositories'; const amazonHosts = [ // partition: aws { host: '123456789.dkr.ecr.us-east-1.amazonaws.com', region: 'us-east-1' }, { host: '123456789.dkr.ecr-fips.us-east-1.amazonaws.com', region: 'us-east-1', }, { host: '123456789.dkr-ecr.us-east-1.on.aws', region: 'us-east-1' }, { host: '123456789.dkr-ecr-fips.us-east-1.on.aws', region: 'us-east-1' }, // partition: aws-cn { host: '123456789.dkr.ecr.cn-north-1.amazonaws.com.cn', region: 'cn-north-1', }, { host: '123456789.dkr.ecr-fips.cn-north-1.amazonaws.com.cn', region: 'cn-north-1', }, { host: '123456789.dkr-ecr.cn-north-1.on.amazonwebservices.com.cn', region: 'cn-north-1', }, { host: '123456789.dkr-ecr-fips.cn-north-1.on.amazonwebservices.com.cn', region: 'cn-north-1', }, // partition: aws-eusc { host: '123456789.dkr.ecr.eusc-de-east-1.amazonaws.eu', region: 'eusc-de-east-1', }, { host: '123456789.dkr.ecr-fips.eusc-de-east-1.amazonaws.eu', region: 'eusc-de-east-1', }, { host: '123456789.dkr-ecr.eusc-de-east-1.on.amazonwebservices.eu', region: 'eusc-de-east-1', }, { host: '123456789.dkr-ecr-fips.eusc-de-east-1.on.amazonwebservices.eu', region: 'eusc-de-east-1', }, // partition: aws-iso { host: '123456789.dkr.ecr.us-iso-east-1.c2s.ic.gov', region: 'us-iso-east-1', }, { host: '123456789.dkr.ecr-fips.us-iso-east-1.c2s.ic.gov', region: 'us-iso-east-1', }, { host: '123456789.dkr-ecr.us-iso-east-1.on.aws.ic.gov', region: 'us-iso-east-1', }, { host: '123456789.dkr-ecr-fips.us-iso-east-1.on.aws.ic.gov', region: 'us-iso-east-1', }, // partition: aws-iso-b { host: '123456789.dkr.ecr.us-isob-east-1.sc2s.sgov.gov', region: 'us-isob-east-1', }, { host: '123456789.dkr.ecr-fips.us-isob-east-1.sc2s.sgov.gov', region: 'us-isob-east-1', }, { host: '123456789.dkr-ecr.us-isob-east-1.on.aws.scloud', region: 'us-isob-east-1', }, { host: '123456789.dkr-ecr-fips.us-isob-east-1.on.aws.scloud', region: 'us-isob-east-1', }, // partition: aws-iso-e { host: '123456789.dkr.ecr.eu-isoe-west-1.scloud.adc-e.uk', region: 'eu-isoe-west-1', }, { host: '123456789.dkr.ecr-fips.eu-isoe-west-1.cloud.adc-e.uk', region: 'eu-isoe-west-1', }, { host: '123456789.dkr-ecr.eu-isoe-west-1.on.cloud-aws.adc-e.uk', region: 'eu-isoe-west-1', }, { host: '123456789.dkr-ecr-fips.eu-isoe-west-1.on.cloud-aws.adc-e.uk', region: 'eu-isoe-west-1', }, // partition: aws-iso-f { host: '123456789.dkr.ecr.us-isof-east-1.csp.hci.ic.gov', region: 'us-isof-east-1', }, { host: '123456789.dkr.ecr-fips.us-isof-east-1.csp.hci.ic.gov', region: 'us-isof-east-1', }, { host: '123456789.dkr-ecr.us-isof-east-1.on.aws.hci.ic.gov', region: 'us-isof-east-1', }, { host: '123456789.dkr-ecr-fips.us-isof-east-1.on.aws.hci.ic.gov', region: 'us-isof-east-1', }, // partition: aws-us-gov { host: '123456789.dkr.ecr.us-gov-east-1.amazonaws.com', region: 'us-gov-east-1', }, { host: '123456789.dkr.ecr-fips.us-gov-east-1.amazonaws.com', region: 'us-gov-east-1', }, { host: '123456789.dkr-ecr.us-gov-east-1.on.aws', region: 'us-gov-east-1' }, { host: '123456789.dkr-ecr-fips.us-gov-east-1.on.aws', region: 'us-gov-east-1', }, ]; function mockEcrAuthResolve( res: Partial = {}, ) { ecrMock.on(GetAuthorizationTokenCommand).resolvesOnce(res); } function mockEcrAuthReject(msg: string) { ecrMock.on(GetAuthorizationTokenCommand).rejectsOnce(new Error(msg)); } describe('modules/datasource/docker/index', () => { beforeEach(() => { GlobalConfig.reset(); ecrMock.reset(); hostRules.add({ username: 'some-username', password: 'some-password', }); vi.stubEnv('RENOVATE_X_DOCKER_HUB_TAGS_DISABLE', undefined); vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', undefined); vi.stubEnv('RENOVATE_X_DOCKER_PAGINATION_ALLOW_CROSS_ORIGIN', undefined); vi.stubEnv('RENOVATE_X_NUGET_PAGINATION_ALLOW_CROSS_ORIGIN', undefined); }); describe('getDigest', () => { it('returns null if errored', async () => { httpMock .scope(baseUrl) .get('/', undefined, { badheaders: ['authorization'] }) .reply(200, { token: 'abc' }) .head('/library/some-dep/manifests/some-new-value', undefined, { reqheaders: { authorization: 'Bearer abc' }, }) .replyWithError('error'); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep' }, 'some-new-value', ); expect(res).toBeNull(); }); it('returns null if empty header', async () => { httpMock .scope(baseUrl) .get('/', undefined, { badheaders: ['authorization'] }) .reply(200, { token: 'some-token' }) .head('/library/some-dep/manifests/some-new-value') .reply(200, undefined, { 'docker-content-digest': '' }); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep' }, 'some-new-value', ); expect(res).toBeNull(); }); it('returns digest', async () => { httpMock .scope(baseUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head('/library/some-dep/manifests/latest') .reply(200, {}, { 'docker-content-digest': 'some-digest' }); httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .reply(200, { token: 'some-token' }); hostRules.clear(); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', }, '', ); expect(res).toBe('some-digest'); }); it('falls back to body for digest', async () => { httpMock .scope(baseUrl) .get('/') .twice() .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head('/library/some-dep/manifests/some-new-value') .reply(200, undefined, {}) .get('/library/some-dep/manifests/some-new-value') .reply( 200, codeBlock` { "signatures": [ { "header": { "jwk": { "crv": "P-256", "kid": "DB2X:GSG2:72H3:AE3R:KCMI:Y77E:W7TF:ERHK:V5HR:JJ2Y:YMS6:HFGJ", "kty": "EC", "x": "jyr9-xZBorSC9fhqNsmfU_Ud31wbaZ-bVGz0HmySvbQ", "y": "vkE6qZCCvYRWjSUwgAOvibQx_s8FipYkAiHS0VnAFNs" }, "alg": "ES256" }, "signature": "yUXzEiPzg_SlQlqGW43H6oMgYuz30zSkj2qauQc_kbyI9RQHucYAKs_lBSFaQdDrtgW-1iDZSP9eExKP8ANSyA", "protected": "eyJmb3JtYXRMZW5ndGgiOjgzMDAsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxOC0wMi0wNVQxNDoyMDoxOVoifQ" } ] } `, { 'content-type': 'text/plain', }, ); httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .twice() .reply(200, { token: 'some-token' }); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep' }, 'some-new-value', ); expect(res).toBe( 'sha256:b3d6068234f3a18ebeedd2dab81e67b6a192e81192a099df4112ecfc7c3be84f', ); }); it('supports docker insecure registry', async () => { httpMock .scope(baseUrl.replace('https', 'http')) .get('/', undefined, { badheaders: ['authorization'] }) .reply(200) .head('/library/some-dep/manifests/latest') .reply(200, '', { 'docker-content-digest': 'some-digest' }); hostRules.clear(); hostRules.add({ insecureRegistry: true }); const res = await getDigest({ datasource: 'docker', packageName: 'some-dep', }); expect(res).toBe('some-digest'); }); it('supports basic authentication', async () => { httpMock .scope(baseUrl) .get('/', undefined, { badheaders: ['authorization'] }) .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/library/some-dep/manifests/some-tag') .matchHeader( 'authorization', 'Basic c29tZS11c2VybmFtZTpzb21lLXBhc3N3b3Jk', ) .reply(200, '', { 'docker-content-digest': 'some-digest' }); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep' }, 'some-tag', ); expect(res).toBe('some-digest'); }); it('returns null for 403 with basic authentication', async () => { httpMock .scope(baseUrl) .get('/', undefined, { badheaders: ['authorization'] }) .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/library/some-dep/manifests/some-tag') .reply(403); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep' }, 'some-tag', ); expect(res).toBeNull(); }); it('does not cache null digest results', async () => { const registryUrl = 'https://registry.company.com/v2'; const packageName = 'registry.company.com/cache-poison'; httpMock .scope(registryUrl) .get('/', undefined, { badheaders: ['authorization'] }) .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/cache-poison/manifests/some-tag') .reply(403); httpMock .scope(registryUrl) .get('/', undefined, { badheaders: ['authorization'] }) .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/cache-poison/manifests/some-tag') .reply(200, '', { 'docker-content-digest': 'sha256:some-digest' }); await expect( getDigest({ datasource: 'docker', packageName }, 'some-tag'), ).resolves.toBeNull(); await expect( getDigest({ datasource: 'docker', packageName }, 'some-tag'), ).resolves.toBe('sha256:some-digest'); }); it.each(amazonHosts)( 'passes credentials to ECR client for host $host', async ({ host, region }) => { httpMock .scope(`https://${host}/v2`) .get('/') .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/node/manifests/some-tag') .matchHeader('authorization', 'Basic test_token') .reply(200, '', { 'docker-content-digest': 'some-digest' }); mockEcrAuthResolve({ authorizationData: [{ authorizationToken: 'test_token' }], }); await expect( getDigest( { datasource: 'docker', packageName: `${host}/node`, }, 'some-tag', ), ).resolves.toBe('some-digest'); const ecr = ecrMock.call(0).thisValue as ECRClient; await expect(ecr.config.region()).resolves.toBe(region); await expect(ecr.config.credentials()).resolves.toEqual({ $source: { CREDENTIALS_CODE: 'e', }, accessKeyId: 'some-username', secretAccessKey: 'some-password', }); }, ); it.each(amazonHosts)( 'passes session token to ECR client for host $host', async ({ host, region }) => { httpMock .scope(`https://${host}/v2`) .get('/') .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/node/manifests/some-tag') .matchHeader('authorization', 'Basic test_token') .reply(200, '', { 'docker-content-digest': 'some-digest' }); hostRules.clear(); hostRules.add({ username: 'some-username', password: 'some-password', token: 'some-session-token', }); mockEcrAuthResolve({ authorizationData: [{ authorizationToken: 'test_token' }], }); await expect( getDigest( { datasource: 'docker', packageName: `${host}/node`, }, 'some-tag', ), ).resolves.toBe('some-digest'); const ecr = ecrMock.call(0).thisValue as ECRClient; await expect(ecr.config.region()).resolves.toBe(region); await expect(ecr.config.credentials()).resolves.toEqual({ $source: { CREDENTIALS_CODE: 'e', }, accessKeyId: 'some-username', secretAccessKey: 'some-password', sessionToken: 'some-session-token', }); }, ); it.each(amazonHosts)( 'supports ECR authentication for host $host', async ({ host }) => { httpMock .scope(`https://${host}/v2`) .get('/') .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/node/manifests/some-tag') .matchHeader('authorization', 'Basic test') .reply(200, '', { 'docker-content-digest': 'some-digest' }); mockEcrAuthResolve({ authorizationData: [{ authorizationToken: 'test' }], }); const res = await getDigest( { datasource: 'docker', packageName: `${host}/node`, }, 'some-tag', ); expect(res).toBe('some-digest'); }, ); it.each(amazonHosts)( 'continues without token if ECR authentication could not be extracted for host $host', async ({ host }) => { httpMock.scope(`https://${host}/v2`).get('/').reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }); mockEcrAuthResolve(); const res = await getDigest( { datasource: 'docker', packageName: `${host}/node`, }, 'some-tag', ); expect(res).toBeNull(); }, ); it.each(amazonHosts)( 'continues without token if ECR authentication fails for host $host', async ({ host }) => { hostRules.clear(); httpMock.scope(`https://${host}/v2`).get('/').reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }); mockEcrAuthReject('some error'); const res = await getDigest( { datasource: 'docker', packageName: `${host}/node`, }, 'some-tag', ); expect(res).toBeNull(); }, ); it.each(amazonHosts)( 'supports ECR authentication for private repositories for host $host', async ({ host }) => { httpMock .scope(`https://${host}/v2`) .get('/') .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/node/manifests/some-tag') .matchHeader('authorization', 'Basic QVdTOnNvbWUtcGFzc3dvcmQ=') .reply(200, '', { 'docker-content-digest': 'some-digest' }); hostRules.clear(); hostRules.add({ username: 'AWS', password: 'some-password', }); const res = await getDigest( { datasource: 'docker', packageName: `${host}/node`, }, 'some-tag', ); expect(res).toBe('some-digest'); }, ); it('supports Google ADC authentication for gcr', async () => { httpMock .scope(gcrUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/some-project/some-package/manifests/some-tag') .matchHeader( 'authorization', 'Basic b2F1dGgyYWNjZXNzdG9rZW46c29tZS10b2tlbg==', ) .reply(200, '', { 'docker-content-digest': 'some-digest' }); googleAuth.GoogleAuth.mockImplementationOnce( // TODO: fix typing vi.fn( class { getAccessToken = vi.fn().mockResolvedValue('some-token'); }, ), ); hostRules.clear(); const res = await getDigest( { datasource: 'docker', packageName: 'eu.gcr.io/some-project/some-package', }, 'some-tag', ); expect(res).toBe('some-digest'); expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(1); }); it('supports Google ADC authentication for gar', async () => { httpMock .scope(garUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/some-project/some-repo/some-package/manifests/some-tag') .matchHeader( 'authorization', 'Basic b2F1dGgyYWNjZXNzdG9rZW46c29tZS10b2tlbg==', ) .reply(200, '', { 'docker-content-digest': 'some-digest' }); googleAuth.GoogleAuth.mockImplementationOnce( // TODO: fix typing vi.fn( class GoogleAuth { getAccessToken = vi.fn().mockResolvedValue('some-token'); }, ), ); hostRules.clear(); const res = await getDigest( { datasource: 'docker', packageName: 'europe-docker.pkg.dev/some-project/some-repo/some-package', }, 'some-tag', ); expect(res).toBe('some-digest'); expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(1); }); it('supports basic authentication for gcr', async () => { httpMock .scope(gcrUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/some-project/some-package/manifests/some-tag') .matchHeader( 'authorization', 'Basic c29tZS11c2VybmFtZTpzb21lLXBhc3N3b3Jk', ) .reply(200, '', { 'docker-content-digest': 'some-digest' }); googleAuth.GoogleAuth.mockImplementationOnce( // TODO: fix typing vi.fn( class GoogleAuth { getAccessToken = vi.fn().mockResolvedValue('some-token'); }, ), ); const res = await getDigest( { datasource: 'docker', packageName: 'eu.gcr.io/some-project/some-package', }, 'some-tag', ); expect(res).toBe('some-digest'); expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(0); }); it('supports basic authentication for gar', async () => { httpMock .scope(garUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .head('/some-project/some-repo/some-package/manifests/some-tag') .matchHeader( 'authorization', 'Basic c29tZS11c2VybmFtZTpzb21lLXBhc3N3b3Jk', ) .reply(200, '', { 'docker-content-digest': 'some-digest' }); googleAuth.GoogleAuth.mockImplementationOnce( // TODO: fix typing vi.fn( class GoogleAuth { getAccessToken = vi.fn().mockResolvedValue('some-token'); }, ), ); const res = await getDigest( { datasource: 'docker', packageName: 'europe-docker.pkg.dev/some-project/some-repo/some-package', }, 'some-tag', ); expect(res).toBe('some-digest'); expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(0); }); it('supports public gcr', async () => { httpMock .scope(gcrUrl) .get('/') .reply(200) .head('/google.com/some-project/some-package/manifests/some-tag') .reply(200, '', { 'docker-content-digest': 'some-digest' }); hostRules.clear(); const res = await getDigest( { datasource: 'docker', registryUrl: 'https://eu.gcr.io', lookupName: 'google.com/some-project/some-package', packageName: 'eu.gcr.io/google.com/some-project/some-package', }, 'some-tag', ); expect(res).toBe('some-digest'); expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(0); }); it('supports public gar', async () => { httpMock .scope(garUrl) .get('/') .reply(200) .head('/some-project/some-repo/some-package/manifests/some-tag') .reply(200, '', { 'docker-content-digest': 'some-digest' }); hostRules.clear(); const res = await getDigest( { datasource: 'docker', packageName: 'europe-docker.pkg.dev/some-project/some-repo/some-package', }, 'some-tag', ); expect(res).toBe('some-digest'); expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(0); }); it('continues without token if Google ADC fails for gcr', async () => { hostRules.clear(); httpMock.scope(gcrUrl).get('/').reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }); googleAuth.GoogleAuth.mockImplementationOnce( // TODO: fix typing vi.fn( class GoogleAuth { getAccessToken = vi.fn(); }, ), ); const res = await getDigest( { datasource: 'docker', packageName: 'eu.gcr.io/some-project/some-package', }, 'some-tag', ); expect(res).toBeNull(); expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(1); }); it('continues without token if Google ADC fails for gar', async () => { hostRules.clear(); httpMock.scope(garUrl).get('/').reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }); googleAuth.GoogleAuth.mockImplementationOnce( // TODO: fix typing vi.fn( class GoogleAuth { getAccessToken = vi.fn().mockRejectedValue('some-error'); }, ), ); const res = await getDigest( { datasource: 'docker', packageName: 'europe-docker.pkg.dev/some-project/some-repo/some-package', }, 'some-tag', ); expect(res).toBeNull(); expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(1); }); it('continues without token, when no header is present', async () => { httpMock .scope(baseUrl) .get('/') .reply(200, '', { 'content-type': 'text/plain', }) .head('/library/some-dep/manifests/some-new-value') .reply(200, {}, { 'docker-content-digest': 'some-digest' }); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep' }, 'some-new-value', ); expect(res).toBe('some-digest'); }); it('supports token with no service', async () => { httpMock .scope(baseUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",scope=""', }) .head('/library/some-other-dep/manifests/8.0.0-alpine') .reply(200, {}, { 'docker-content-digest': 'some-digest' }); httpMock .scope(authUrl) .get('/token?scope=repository:library/some-other-dep:pull') .reply(200, { access_token: 'test' }); const res = await getDigest( { datasource: 'docker', packageName: 'some-other-dep' }, '8.0.0-alpine', ); expect(res).toBe('some-digest'); }); it('supports scoped names', async () => { httpMock .scope(baseUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-other-dep:pull"', }) .head('/library/some-other-dep/manifests/8.0.0-alpine') .reply(200, {}, { 'docker-content-digest': 'some-digest' }); httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-other-dep:pull', ) .reply(200, { access_token: 'test' }); const res = await getDigest( { datasource: 'docker', packageName: 'some-other-dep' }, '8.0.0-alpine', ); expect(res).toBe('some-digest'); }); it('should throw error for 429', async () => { httpMock .scope(baseUrl) .get('/') .replyWithError(httpMock.error({ statusCode: 429 })); await expect( getDigest({ datasource: 'docker', packageName: 'some-dep' }, 'latest'), ).rejects.toThrow(EXTERNAL_HOST_ERROR); }); it('should throw error for 5xx', async () => { httpMock .scope(baseUrl) .get('/') .replyWithError(httpMock.error({ statusCode: 504 })); await expect( getDigest({ datasource: 'docker', packageName: 'some-dep' }, 'latest'), ).rejects.toThrow(EXTERNAL_HOST_ERROR); }); it('supports architecture-specific digest', async () => { const currentDigest = 'sha256:81c09f6d42c2db8121bcd759565ea244cedc759f36a0f090ec7da9de4f7f8fe4'; httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .times(4) .reply(200, { token: 'some-token' }); httpMock .scope(baseUrl) .get('/') .times(3) .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head(`/library/some-dep/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.docker.distribution.manifest.v2+json', }) .get(`/library/some-dep/manifests/${currentDigest}`) .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/library/some-dep/blobs/some-config-digest') .reply(200, { architecture: 'amd64', }); httpMock .scope(baseUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .get('/library/some-dep/manifests/some-new-value') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.list.v2+json', manifests: [ { digest: 'sha256:c3fe2aac7e4f47270eeff0fdd35cb9bad674105eaa1663942645ca58399a2dbc', mediaType: 'application/vnd.docker.distribution.manifest.v2+json', platform: { architecture: 'arm', os: 'linux', variant: 'v6', }, }, { digest: 'sha256:78fa4d63fec4e647f00908f24cda05af101aa9702700f613c7f82a96a267d801', mediaType: 'application/vnd.docker.distribution.manifest.v2+json', platform: { architecture: '386', os: 'linux', }, }, { digest: 'sha256:81093b981e72a54d488d5a60780006d82f7cc02d248d88ff71ff4137b0f51176', mediaType: 'application/vnd.docker.distribution.manifest.v2+json', platform: { architecture: 'amd64', os: 'linux', }, }, ], }); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest, }, 'some-new-value', ); expect(logger.logger.debug).toHaveBeenCalledWith( `Current digest ${currentDigest} relates to architecture amd64`, ); expect(res).toBe( 'sha256:81093b981e72a54d488d5a60780006d82f7cc02d248d88ff71ff4137b0f51176', ); }); it('supports architecture-specific digest whithout manifest list', async () => { const currentDigest = 'sha256:81c09f6d42c2db8121bcd759565ea244cedc759f36a0f090ec7da9de4f7f8fe4'; httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .times(4) .reply(200, { token: 'some-token' }); httpMock .scope(baseUrl) .get('/') .times(3) .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head(`/library/some-dep/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.docker.distribution.manifest.v2+json', }) .get(`/library/some-dep/manifests/${currentDigest}`) .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/library/some-dep/blobs/some-config-digest') .reply(200, { architecture: 'amd64', }); httpMock .scope(baseUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .get('/library/some-dep/manifests/some-new-value') .reply( 200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { mediaType: 'application/vnd.docker.container.image.v1+json', size: 2917, digest: 'sha256:4591c431eb2fcf90ebb32476db6cfe342617fc3d3ca9653b9e0c47859cac1cf9', }, }, { 'docker-content-digest': 'some-new-digest', }, ); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest, }, 'some-new-value', ); expect(logger.logger.debug).toHaveBeenCalledWith( `Current digest ${currentDigest} relates to architecture amd64`, ); expect(res).toBe('some-new-digest'); }); it('handles missing architecture-specific digest', async () => { const currentDigest = 'sha256:81c09f6d42c2db8121bcd759565ea244cedc759f36a0f090ec7da9de4f7f8fe4'; httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .times(5) .reply(200, { token: 'some-token' }); httpMock .scope(baseUrl) .get('/') .times(3) .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head(`/library/some-dep/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.docker.distribution.manifest.v2+json', }) .get(`/library/some-dep/manifests/${currentDigest}`) .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/library/some-dep/blobs/some-config-digest') .reply(200, { config: {} }); httpMock .scope(baseUrl) .get('/') .twice() .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head('/library/some-dep/manifests/some-new-value') .reply(200, undefined, {}) .get('/library/some-dep/manifests/some-new-value') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.list.v2+json', manifests: [ { digest: 'sha256:c3fe2aac7e4f47270eeff0fdd35cb9bad674105eaa1663942645ca58399a2dbc', mediaType: 'application/vnd.docker.distribution.manifest.v2+json', platform: { architecture: 'arm', os: 'linux', variant: 'v6', }, }, { digest: 'sha256:78fa4d63fec4e647f00908f24cda05af101aa9702700f613c7f82a96a267d801', mediaType: 'application/vnd.docker.distribution.manifest.v2+json', platform: { architecture: '386', os: 'linux', }, }, { digest: 'sha256:81093b981e72a54d488d5a60780006d82f7cc02d248d88ff71ff4137b0f51176', mediaType: 'application/vnd.docker.distribution.manifest.v2+json', platform: { architecture: 'amd64', os: 'linux', }, }, ], }); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest, }, 'some-new-value', ); expect(logger.logger.debug).toHaveBeenCalledWith( `Current digest ${currentDigest} relates to architecture null`, ); expect(res).toBe( 'sha256:5194622ded36da4097a53c4ec9d85bba370d9e826e88a74fa910c46ddbf3208c', ); }); it('treats empty string architecture as no architecture', async () => { const currentDigest = 'sha256:81c09f6d42c2db8121bcd759565ea244cedc759f36a0f090ec7da9de4f7f8fe4'; httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .times(4) .reply(200, { token: 'some-token' }); httpMock .scope(baseUrl) .get('/') .times(3) .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head(`/library/some-dep/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.docker.distribution.manifest.v2+json', }) .get(`/library/some-dep/manifests/${currentDigest}`) .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/library/some-dep/blobs/some-config-digest') .reply(200, { architecture: '', // Empty string architecture }); httpMock .scope(baseUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head('/library/some-dep/manifests/some-new-value') .reply(200, undefined, { 'docker-content-digest': 'sha256:some-digest-from-head-request', }); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest, }, 'some-new-value', ); // Should log empty string same as null expect(logger.logger.debug).toHaveBeenCalledWith( `Current digest ${currentDigest} relates to architecture `, ); // Should use the digest from HEAD request (no architecture-specific logic) expect(res).toBe('sha256:some-digest-from-head-request'); }); it('supports architecture-specific digest in OCI manifests with media type', async () => { const currentDigest = 'sha256:0101010101010101010101010101010101010101010101010101010101010101'; httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .times(4) .reply(200, { token: 'some-token' }); httpMock .scope(baseUrl) .get('/') .times(3) .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head(`/library/some-dep/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.oci.image.manifest.v1+json', }) .get(`/library/some-dep/manifests/${currentDigest}`) .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.oci.image.config.v1+json', }, }) .get('/library/some-dep/blobs/some-config-digest') .reply(200, { architecture: 'amd64', }); httpMock .scope(baseUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .get('/library/some-dep/manifests/some-new-value') .reply( 200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.index.v1+json', manifests: [ { digest: 'some-new-image-digest', mediaType: 'application/vnd.oci.image.manifest.v1+json', platform: { architecture: 'amd64', }, }, ], }, { 'content-type': 'text/plain', }, ); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest, }, 'some-new-value', ); expect(logger.logger.debug).toHaveBeenCalledWith( `Current digest ${currentDigest} relates to architecture amd64`, ); expect(res).toBe('some-new-image-digest'); }); it('supports architecture-specific digest in OCI manifests without media type', async () => { const currentDigest = 'sha256:0101010101010101010101010101010101010101010101010101010101010101'; httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .times(4) .reply(200, { token: 'some-token' }); httpMock .scope(baseUrl) .get('/') .times(3) .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head(`/library/some-dep/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.oci.image.manifest.v1+json', }) .get(`/library/some-dep/manifests/${currentDigest}`) .reply(200, { schemaVersion: 2, config: { digest: 'some-config-digest', mediaType: 'application/vnd.oci.image.config.v1+json', }, }) .get('/library/some-dep/blobs/some-config-digest') .reply(200, { architecture: 'amd64', }); httpMock .scope(baseUrl) .get('/') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .get('/library/some-dep/manifests/some-new-value') .reply(200, { schemaVersion: 2, manifests: [ { digest: 'some-new-image-digest', mediaType: 'application/vnd.oci.image.manifest.v1+json', platform: { architecture: 'amd64', }, }, ], }); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest, }, 'some-new-value', ); expect(logger.logger.debug).toHaveBeenCalledWith( `Current digest ${currentDigest} relates to architecture amd64`, ); expect(res).toBe('some-new-image-digest'); }); it('handles error while retrieving manifest list for architecture-specific digest', async () => { const currentDigest = 'sha256:81c09f6d42c2db8121bcd759565ea244cedc759f36a0f090ec7da9de4f7f8fe4'; httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .times(4) .reply(200, { token: 'some-token' }); httpMock .scope(baseUrl) .get('/') .twice() .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head(`/library/some-dep/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.docker.distribution.manifest.v2+json', }) .get(`/library/some-dep/manifests/${currentDigest}`) .reply(404, {}); httpMock .scope(baseUrl) .get('/') .twice() .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head('/library/some-dep/manifests/some-new-value') .reply(200, undefined, {}) .get('/library/some-dep/manifests/some-new-value') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.list.v2+json', manifests: [ { digest: 'sha256:c3fe2aac7e4f47270eeff0fdd35cb9bad674105eaa1663942645ca58399a2dbc', platform: { architecture: 'arm', os: 'linux', variant: 'v6', }, }, { digest: 'sha256:78fa4d63fec4e647f00908f24cda05af101aa9702700f613c7f82a96a267d801', platform: { architecture: '386', os: 'linux', }, }, { digest: 'sha256:81093b981e72a54d488d5a60780006d82f7cc02d248d88ff71ff4137b0f51176', platform: { architecture: 'amd64', os: 'linux', }, }, ], }); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest, }, 'some-new-value', ); expect(res).toBe( 'sha256:ee75deb1a41bb998e52a116707a6e22a91904cba0c1d6e6c76cf04923efff2d8', ); }); it('handles error while retrieving image config blob', async () => { const currentDigest = 'sha256:0101010101010101010101010101010101010101010101010101010101010101'; httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .times(3) .reply(200, { token: 'some-token' }); httpMock .scope(baseUrl) .get('/') .times(3) .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head(`/library/some-dep/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.oci.image.manifest.v1+json', }) .get(`/library/some-dep/manifests/${currentDigest}`) .reply(200, { schemaVersion: 2, config: { digest: 'some-config-digest', mediaType: 'application/vnd.oci.image.config.v1+json', }, }) .get('/library/some-dep/blobs/some-config-digest') .reply(404, {}); httpMock .scope(baseUrl) .get('/', undefined, { badheaders: ['authorization'] }) .reply(200, '', {}) .head('/library/some-dep/manifests/some-new-value', undefined, { badheaders: ['authorization'], }) .reply(401); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest, }, 'some-new-value', ); expect(res).toBeNull(); }); it('returns null if digest refers to manifest list and new value invalid', async () => { httpMock .scope(baseUrl) .get('/', undefined, { badheaders: ['authorization'] }) .reply(200, { token: 'some-token' }) .head( '/library/some-dep/manifests/sha256:0101010101010101010101010101010101010101010101010101010101010101', ) .reply(404, {}); httpMock .scope(baseUrl) .get('/', undefined, { badheaders: ['authorization'] }) .reply(200, '', {}) .head( '/library/some-dep/manifests/sha256:fafafafafafafafafafafafafafafafafafafafafafafafafafafafafafafafa', undefined, { badheaders: ['authorization'], }, ) .reply(401); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest: 'sha256:0101010101010101010101010101010101010101010101010101010101010101', }, 'sha256:fafafafafafafafafafafafafafafafafafafafafafafafafafafafafafafafa', ); expect(res).toBeNull(); }); it('falls back to library/ prefix on non-namespaced images with existing digest', async () => { const currentDigest = 'sha256:0000000000000000000000000000000000000000000000000000000000000000', newDigest = 'sha256:1111111111111111111111111111111111111111111111111111111111111111'; httpMock .scope('https://registry.company.com/v2') .get('/') .times(4) .reply(200) .head(`/some-dep/manifests/${currentDigest}`) .reply(500) .head(`/some-dep/manifests/3.17`) .reply(404) .head(`/library/some-dep/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.docker.distribution.manifest.list.v2+json', 'docker-content-digest': currentDigest, }) .head('/library/some-dep/manifests/3.17') .reply(200, '', { 'content-type': 'application/vnd.docker.distribution.manifest.list.v2+json', 'docker-content-digest': newDigest, }); hostRules.clear(); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest, registryUrls: ['https://registry.company.com'], }, '3.17', ); expect(res).toBe(newDigest); }); it('uses Docker Hub tag cache digest without HEAD request', async () => { vi.spyOn(DockerHubCache, 'init').mockResolvedValueOnce( partial({ getDigestForTag: vi.fn().mockReturnValue('sha256:cached-digest'), getArchDigestForTag: vi.fn().mockReturnValue(null), }), ); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep' }, 'some-tag', ); expect(res).toBe('sha256:cached-digest'); }); it('uses Docker Hub tag cache arch digest when currentDigest is arch-specific', async () => { const currentDigest = 'sha256:0101010101010101010101010101010101010101010101010101010101010101'; httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/some-dep:pull', ) .times(3) .reply(200, { token: 'some-token' }); httpMock .scope(baseUrl) .get('/') .times(3) .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/some-dep:pull"', }) .head(`/library/some-dep/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.oci.image.manifest.v1+json', }) .get(`/library/some-dep/manifests/${currentDigest}`) .reply(200, { schemaVersion: 2, config: { digest: 'some-config-digest', mediaType: 'application/vnd.oci.image.config.v1+json', }, }) .get('/library/some-dep/blobs/some-config-digest') .reply(200, { architecture: 'amd64', }); vi.spyOn(DockerHubCache, 'init').mockResolvedValueOnce( partial({ getDigestForTag: vi.fn().mockReturnValue(null), getArchDigestForTag: vi.fn().mockReturnValue('sha256:cached-amd64'), }), ); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', currentDigest, }, 'some-tag', ); expect(res).toBe('sha256:cached-amd64'); }); it('falls back to library/ prefix on non-namespaced images without existing digest', async () => { const newDigest = 'sha256:1111111111111111111111111111111111111111111111111111111111111111'; httpMock .scope('https://registry.company.com/v2') .get('/') .times(2) .reply(200) .head(`/some-dep/manifests/3.17`) .reply(404) .head('/library/some-dep/manifests/3.17') .reply(200, '', { 'content-type': 'application/vnd.docker.distribution.manifest.list.v2+json', 'docker-content-digest': newDigest, }); hostRules.clear(); const res = await getDigest( { datasource: 'docker', packageName: 'some-dep', registryUrls: ['https://registry.company.com'], }, '3.17', ); expect(res).toBe(newDigest); }); }); describe('getImageArchitecture', () => { it('does not cache null architecture results', async () => { const datasource = new DockerDatasource(); const registryHost = 'https://registry.company.com'; const dockerRepository = 'cache-arch'; const currentDigest = 'sha256:0101010101010101010101010101010101010101010101010101010101010101'; httpMock .scope(`${registryHost}/v2`) .get('/') .reply(200) .head(`/cache-arch/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.docker.distribution.manifest.list.v2+json', }); httpMock .scope(`${registryHost}/v2`) .get('/') .times(3) .reply(200) .head(`/cache-arch/manifests/${currentDigest}`) .reply(200, '', { 'content-type': 'application/vnd.docker.distribution.manifest.v2+json', }) .get(`/cache-arch/manifests/${currentDigest}`) .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'sha256:config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/cache-arch/blobs/sha256:config-digest') .reply(200, { architecture: 'amd64', }); await expect( datasource.getImageArchitecture( registryHost, dockerRepository, currentDigest, ), ).resolves.toBeNull(); await expect( datasource.getImageArchitecture( registryHost, dockerRepository, currentDigest, ), ).resolves.toBe('amd64'); }); }); describe('getReleases', () => { it('returns null if no token', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_TAGS_DISABLE', 'true'); httpMock .scope(baseUrl) .get('/library/node/tags/list?n=10000') .reply(200, '', {}) .get('/library/node/tags/list?n=10000') .reply(403); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'node', registryUrls: ['https://docker.io'], }); expect(res).toBeNull(); }); it('uses custom registry with registryUrls', async () => { const tags = ['1.0.0']; httpMock .scope('https://registry.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply(200, { tags }, {}) .get('/') .reply(200) .get('/node/manifests/1.0.0') .reply(200); const config = { datasource: DockerDatasource.id, packageName: 'node', registryUrls: ['https://registry.company.com'], }; const res = await getPkgReleases(config); expect(res?.releases).toHaveLength(1); }); // as this could lead to a Server-Side Request Forgery (SSRF), but could also be misconfiguration it('does not follow pagination links to a different origin', async () => { const tags = ['1.0.0']; httpMock .scope('https://registry.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply( 200, { tags }, { link: '; rel="next", ', }, ) .get('/') .reply(200) .get('/node/manifests/1.0.0') .reply(200); const config = { datasource: DockerDatasource.id, packageName: 'node', registryUrls: ['https://registry.company.com'], }; const res = await getPkgReleases(config); expect(res?.releases).toHaveLength(1); expect(logger.logger.once.warn).toHaveBeenCalledWith( { registryHost: 'https://registry.company.com', nextUrl: 'https://attacker.example.com/v2/steal/tags/list?n=10000', }, 'Ignoring cross-origin or invalid Docker registry tags pagination link', ); }); it('follows cross-origin tags pagination when the datasource is opted in', async () => { vi.stubEnv('RENOVATE_X_DOCKER_PAGINATION_ALLOW_CROSS_ORIGIN', 'true'); httpMock .scope('https://registry.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply( 200, { tags: ['1.0.0'] }, { link: '; rel="next", ', }, ) .get('/') .reply(200) .get('/node/manifests/2.0.0') .reply(200); httpMock .scope('https://mirror.example.com/v2') .get('/node/tags/list?n=10000&last=1.0.0') .reply(200, { tags: ['2.0.0'] }, {}); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'node', registryUrls: ['https://registry.company.com'], }); expect(res?.releases).toMatchObject([ { version: '1.0.0' }, { version: '2.0.0' }, ]); expect(logger.logger.once.warn).toHaveBeenCalledOnce(); }); it('does not opt docker in when only another datasource is opted in', async () => { vi.stubEnv('RENOVATE_X_NUGET_PAGINATION_ALLOW_CROSS_ORIGIN', 'true'); httpMock .scope('https://registry.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply( 200, { tags: ['1.0.0'] }, { link: '; rel="next", ', }, ) .get('/') .reply(200) .get('/node/manifests/1.0.0') .reply(200); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'node', registryUrls: ['https://registry.company.com'], }); expect(res?.releases).toHaveLength(1); }); it('uses custom max pages', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', 'true'); GlobalConfig.set({ dockerMaxPages: 2 }); vi.stubEnv('RENOVATE_X_DOCKER_HUB_TAGS_DISABLE', 'true'); httpMock .scope(baseUrl) .get('/library/node/tags/list?n=10000') .reply(200, '', {}) .get('/library/node/tags/list?n=10000') .reply( 200, { tags: ['1.0.0'] }, { link: `<${baseUrl}/library/node/tags/list?n=1&page=1>; rel="next", `, }, ) .get('/library/node/tags/list?n=1&page=1') .reply( 200, { tags: ['1.0.1'] }, { link: `<${baseUrl}/library/node/tags/list?n=1&page=2>; rel="next", `, }, ); const config = { datasource: DockerDatasource.id, packageName: 'node', }; const res = await getPkgReleases(config); expect(res?.releases).toHaveLength(2); }); it('uses custom registry in packageName', async () => { const tags = ['1.0.0']; httpMock .scope('https://registry.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply(200, { tags }, {}) .get('/') .reply(200, '', {}) .get('/node/manifests/1.0.0') .reply(200, '', {}); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res?.releases).toHaveLength(1); }); it('uses quay api', async () => { const tags = [{ name: '5.0.12' }]; httpMock .scope('https://quay.io') .get( '/api/v1/repository/bitnami/redis/tag/?limit=100&page=1&onlyActiveTags=true', ) .reply(200, { tags, has_additional: true }) .get( '/api/v1/repository/bitnami/redis/tag/?limit=100&page=2&onlyActiveTags=true', ) .reply(200, { tags: [], has_additional: false }) .get('/v2/') .reply(200, '', {}) .get('/v2/bitnami/redis/manifests/5.0.12') .reply(200, '', {}); const config = { datasource: DockerDatasource.id, packageName: 'bitnami/redis', registryUrls: ['https://quay.io'], }; const res = await getPkgReleases(config); expect(res?.releases).toHaveLength(1); }); it('uses quay api 2', async () => { const tags = [{ name: '5.0.12' }]; httpMock .scope('https://quay.io') .get( '/api/v1/repository/bitnami/redis/tag/?limit=100&page=1&onlyActiveTags=true', ) .reply(200, { tags, has_additional: true }) .get( '/api/v1/repository/bitnami/redis/tag/?limit=100&page=2&onlyActiveTags=true', ) .reply(200, { tags: [], has_additional: false }) .get('/v2/') .reply(200, '', {}) .get('/v2/bitnami/redis/manifests/5.0.12') .reply(200, '', {}); const config = { datasource: DockerDatasource.id, packageName: 'redis', registryUrls: ['https://quay.io/bitnami'], }; const res = await getPkgReleases(config); expect(res?.releases).toHaveLength(1); }); it('uses quay api and test error', async () => { httpMock .scope('https://quay.io') .get( '/api/v1/repository/bitnami/redis/tag/?limit=100&page=1&onlyActiveTags=true', ) .reply(500); const config = { datasource: DockerDatasource.id, packageName: 'bitnami/redis', registryUrls: ['https://quay.io'], }; await expect(getPkgReleases(config)).rejects.toThrow(EXTERNAL_HOST_ERROR); }); it('uses quay api with fallback from v1 to v2 on 401 Unauthorized', async () => { httpMock .scope('https://quay.io') .get( '/api/v1/repository/bitnami/redis/tag/?limit=100&page=1&onlyActiveTags=true', ) .reply(401, 'Unauthorized') .get('/v2/') .reply(200, '', {}) .get('/v2/bitnami/redis/tags/list?n=10000') .reply(200, {}) .get('/v2/bitnami/redis/tags/list?n=10000') .reply(200, { tags: ['1.0.0', '2.0.0'] }) .get('/v2/bitnami/redis/manifests/2.0.0') .reply(200, '', {}); const config = { datasource: DockerDatasource.id, packageName: 'bitnami/redis', registryUrls: ['https://quay.io'], }; const res = await getPkgReleases(config); expect(res?.releases).toHaveLength(2); // Verify the debug log for fallback was called expect(logger.logger.debug).toHaveBeenCalledWith( expect.objectContaining({ registryHost: 'https://quay.io', dockerRepository: 'bitnami/redis', }), 'Quay v1 API unauthorized, falling back to Docker v2 API', ); }); it('jfrog artifactory - retry tags for official images by injecting `/library` after repository and before image', async () => { const tags1 = [...range(1, 10000)].map((i) => `${i}.0.0`); const tags2 = [...range(10000, 20000)].map((i) => `${i}.0.0`); const tags3 = [...range(20000, 20050)].map((i) => `${i}.0.0`); httpMock .scope('https://org.jfrog.io/v2') .get('/virtual-mirror/node/tags/list?n=10000') .reply(200, '', { 'x-jfrog-version': 'Artifactory/7.42.2 74202900' }) .get('/virtual-mirror/node/tags/list?n=10000') .reply(404, '', { 'x-jfrog-version': 'Artifactory/7.42.2 74202900' }) .get('/virtual-mirror/library/node/tags/list?n=10000') .reply(200, '', {}) .get('/virtual-mirror/library/node/tags/list?n=10000') // Note the Link is incorrect and should be `; rel="next", ` // Artifactory incorrectly returns a next link without the virtual repository name // this is due to a bug in Artifactory https://jfrog.atlassian.net/browse/RTFACT-18971 .reply( 200, { tags: tags1 }, { 'x-jfrog-version': 'Artifactory/7.42.2 74202900', link: '; rel="next", ', }, ) .get('/virtual-mirror/library/node/tags/list?n=10000&last=10000') .reply( 200, { tags: tags2 }, { 'x-jfrog-version': 'Artifactory/7.42.2 74202900', link: '; rel="next", ', }, ) .get('/virtual-mirror/library/node/tags/list?n=10000&last=20000') .reply( 200, { tags: tags3 }, { 'x-jfrog-version': 'Artifactory/7.42.2 74202900', }, ) .get('/') .reply(200, '', {}) .get('/virtual-mirror/node/manifests/20050.0.0') .reply(200, '', {}); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'org.jfrog.io/virtual-mirror/node', }); expect(res?.releases).toHaveLength(20050); }); it.each(amazonHosts)( 'uses lower tag limit for ECR deps for host $host', async ({ host }) => { httpMock .scope(`https://${host}/v2`) .get('/node/tags/list?n=1000') .reply(200, '', {}) // The tag limit parameter `n` needs to be limited to 1000 for ECR // See https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_DescribeRepositories.html#ECR-DescribeRepositories-request-maxResults .get('/node/tags/list?n=1000') .reply(200, { tags: ['some'] }, {}) .get('/') .reply(200, '', {}) .get('/node/manifests/some') .reply(200); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: `${host}/node`, }), ).resolves.toEqual({ lookupName: 'node', registryUrl: `https://${host}`, releases: [], }); }, ); it.each([{ host: 'public.ecr.aws' }, { host: 'ecr-public.aws.com' }])( 'uses lower tag limit for ECR Public deps for host $host', async ({ host }) => { httpMock .scope(`https://${host}`) .get('/v2/amazonlinux/amazonlinux/tags/list?n=1000') .reply(401, '', { 'www-authenticate': `Bearer realm="https://${host}/token",service="public.ecr.aws",scope="aws"`, }) .get('/token?service=public.ecr.aws&scope=aws') .reply(200, { token: 'test' }); httpMock .scope(`https://${host}`, { reqheaders: { authorization: 'Bearer test', }, }) // The tag limit parameter `n` needs to be limited to 1000 for ECR Public // See https://docs.aws.amazon.com/AmazonECRPublic/latest/APIReference/API_DescribeRepositories.html#ecrpublic-DescribeRepositories-request-maxResults .get('/v2/amazonlinux/amazonlinux/tags/list?n=1000') .reply(200, { tags: ['some'] }, {}); httpMock .scope(`https://${host}`) .get('/v2/') .reply(401, '', { 'www-authenticate': `Bearer realm="https://${host}/token",service="public.ecr.aws",scope="aws"`, }) .get( '/token?service=public.ecr.aws&scope=repository:amazonlinux/amazonlinux:pull', ) .reply(200, { token: 'test' }); httpMock .scope(`https://${host}`, { reqheaders: { authorization: 'Bearer test', }, }) .get('/v2/amazonlinux/amazonlinux/manifests/some') .reply(200); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: `${host}/amazonlinux/amazonlinux`, }), ).resolves.toEqual({ lookupName: 'amazonlinux/amazonlinux', registryUrl: `https://${host}`, releases: [], }); }, ); describe('when making requests that interact with an ECR proxy', () => { it('resolves requests to ECR proxy', async () => { httpMock .scope('https://ecr-proxy.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply( 405, { errors: [ { code: 'UNSUPPORTED', message: "Invalid parameter at 'maxResults' failed to satisfy constraint: 'Member must have value less than or equal to 1000'", }, ], }, { 'Docker-Distribution-Api-Version': 'registry/2.0', }, ) .get('/') .reply(200) .get('/node/tags/list?n=1000') .reply(200, { tags: ['some'] }, {}) .get('/node/manifests/some') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/') .reply(200) .get('/node/blobs/some-config-digest') .reply(200, { architecture: 'amd64', config: { Labels: { 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', }, }, }); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ecr-proxy.company.com/node', }), ).resolves.toEqual({ lookupName: 'node', registryUrl: 'https://ecr-proxy.company.com', releases: [], sourceUrl: 'https://github.com/renovatebot/renovate', }); }); it('resolves requests to ECR proxy when auth probe itself returns 405 maxResults error', async () => { const maxResultsErrorBody = { errors: [ { code: 'UNSUPPORTED', message: "Invalid parameter at 'maxResults' failed to satisfy constraint: 'Member must have value less than or equal to 1000'", }, ], }; httpMock .scope('https://ecr-proxy.company.com/v2') // auth probe for tags URL → 405 (triggers fallback to /v2/ base) .get('/node/tags/list?n=10000') .reply(405, maxResultsErrorBody, { 'Docker-Distribution-Api-Version': 'registry/2.0', }) // fallback auth probe via base /v2/ → 200 (no auth needed) .get('/') .reply(200) // actual tag fetch → 405 (triggers isECRMaxResultsError retry with n=1000) .get('/node/tags/list?n=10000') .reply(405, maxResultsErrorBody, { 'Docker-Distribution-Api-Version': 'registry/2.0', }) .get('/node/tags/list?n=1000') .reply(200, { tags: ['some'] }, {}) // auth probe for manifest fetch .get('/') .reply(200) .get('/node/manifests/some') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) // auth probe for blob fetch .get('/') .reply(200) .get('/node/blobs/some-config-digest') .reply(200, { architecture: 'amd64', config: { Labels: { 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', }, }, }); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ecr-proxy.company.com/node', }), ).resolves.toEqual({ lookupName: 'node', registryUrl: 'https://ecr-proxy.company.com', releases: [], sourceUrl: 'https://github.com/renovatebot/renovate', }); }); it('returns null when it receives ECR max results error more than once', async () => { const maxResultsErrorBody = { errors: [ { code: 'UNSUPPORTED', message: "Invalid parameter at 'maxResults' failed to satisfy constraint: 'Member must have value less than or equal to 1000'", }, ], }; httpMock .scope('https://ecr-proxy.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply(405, maxResultsErrorBody, { 'Docker-Distribution-Api-Version': 'registry/2.0', }) .get('/node/tags/list?n=1000') .reply(405, maxResultsErrorBody, { 'Docker-Distribution-Api-Version': 'registry/2.0', }); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ecr-proxy.company.com/node', }), ).resolves.toBeNull(); }); it('returns null when the response code is not 405', async () => { httpMock .scope('https://ecr-proxy.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply( 401, { body: { errors: [ { code: 'UNSUPPORTED', message: "Invalid parameter at 'maxResults' failed to satisfy constraint: 'Member must have value less than or equal to 1000'", }, ], }, }, { 'Docker-Distribution-Api-Version': 'registry/2.0', }, ); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ecr-proxy.company.com/node', }), ).resolves.toBeNull(); }); it('returns null when no response headers are present', async () => { httpMock .scope('https://ecr-proxy.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply(405, { errors: [ { code: 'UNSUPPORTED', message: "Invalid parameter at 'maxResults' failed to satisfy constraint: 'Member must have value less than or equal to 1000'", }, ], }); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ecr-proxy.company.com/node', }), ).resolves.toBeNull(); }); it('returns null when the expected docker header is missing', async () => { httpMock .scope('https://ecr-proxy.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply( 405, { errors: [ { code: 'UNSUPPORTED', message: "Invalid parameter at 'maxResults' failed to satisfy constraint: 'Member must have value less than or equal to 1000'", }, ], }, { 'Irrelevant-Header': 'irrelevant-value', }, ); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ecr-proxy.company.com/node', }), ).resolves.toBeNull(); }); it('returns null when the response body does not contain an errors object', async () => { httpMock .scope('https://ecr-proxy.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply( 405, {}, { 'Docker-Distribution-Api-Version': 'registry/2.0', }, ); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ecr-proxy.company.com/node', }), ).resolves.toBeNull(); }); it('returns null when the response body does not contain errors', async () => { httpMock .scope('https://ecr-proxy.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply( 405, { errors: [], }, { 'Docker-Distribution-Api-Version': 'registry/2.0', }, ); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ecr-proxy.company.com/node', }), ).resolves.toBeNull(); }); it('returns null when the the response errors does not have a message property', async () => { httpMock .scope('https://ecr-proxy.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply( 405, { errors: [ { code: 'UNSUPPORTED', }, ], }, { 'Docker-Distribution-Api-Version': 'registry/2.0', }, ); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ecr-proxy.company.com/node', }), ).resolves.toBeNull(); }); it('returns null when the the error message does not have the expected max results error', async () => { httpMock .scope('https://ecr-proxy.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) .get('/node/tags/list?n=10000') .reply( 405, { errors: [ { code: 'UNSUPPORTED', message: 'Some unrelated error message', }, ], }, { 'Docker-Distribution-Api-Version': 'registry/2.0', }, ); await expect( getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ecr-proxy.company.com/node', }), ).resolves.toBeNull(); }); }); it('Uses Docker Hub tags for registry-1.docker.io', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', 'true'); httpMock .scope(dockerHubUrl) .get('/library/node/tags?page_size=1000&ordering=last_updated') .reply(200, { count: 2, next: `${dockerHubUrl}/library/node/tags?page=2&page_size=1000&ordering=last_updated`, results: [ { id: 2, last_updated: '2021-01-01T00:00:00.000Z', name: '1.0.0', tag_last_pushed: '2021-01-01T00:00:00.000Z', digest: 'aaa', }, ], }) .get('/library/node/tags?page=2&page_size=1000&ordering=last_updated') .reply(200, { count: 2, results: [ { id: 1, last_updated: '2020-01-01T00:00:00.000Z', name: '0.9.0', tag_last_pushed: '2020-01-01T00:00:00.000Z', digest: 'bbb', }, ], }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry-1.docker.io/library/node', }); expect(res?.releases).toMatchObject([ { version: '0.9.0', releaseTimestamp: '2020-01-01T00:00:00.000Z', }, { version: '1.0.0', releaseTimestamp: '2021-01-01T00:00:00.000Z', }, ]); }); it('Uses custom page limit for Docker hub repository tags', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', 'true'); GlobalConfig.set({ dockerMaxPages: 2 }); httpMock .scope(dockerHubUrl) .get('/library/node/tags?page_size=1000&ordering=last_updated') .reply(200, { count: 5, next: `${dockerHubUrl}/library/node/tags?page=2&page_size=1000&ordering=last_updated`, results: [ { id: 5, last_updated: '2021-01-01T00:00:00.000Z', name: '1.0.0', tag_last_pushed: '2021-01-01T00:00:00.000Z', digest: 'aaa', }, ], }) .get('/library/node/tags?page=2&page_size=1000&ordering=last_updated') .reply(200, { count: 5, next: `${dockerHubUrl}/library/node/tags?page=3&page_size=1000&ordering=last_updated`, results: [ { id: 4, last_updated: '2020-01-01T00:00:00.000Z', name: '0.9.0', tag_last_pushed: '2020-01-01T00:00:00.000Z', digest: 'bbb', }, ], }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry-1.docker.io/library/node', }); expect(res).toMatchObject({ releases: [ { version: '0.9.0', releaseTimestamp: '2020-01-01T00:00:00.000Z', }, { version: '1.0.0', releaseTimestamp: '2021-01-01T00:00:00.000Z', }, ], }); }); // as this could lead to a Server-Side Request Forgery (SSRF), but could also be misconfiguration it('does not follow Docker Hub tags pagination to a different origin', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', 'true'); httpMock .scope(dockerHubUrl) .get('/library/node/tags?page_size=1000&ordering=last_updated') .reply(200, { count: 5, next: 'https://attacker.example.com/v2/repositories/library/node/tags?page=2&page_size=1000&ordering=last_updated', results: [ { id: 5, last_updated: '2021-01-01T00:00:00.000Z', name: '1.0.0', tag_last_pushed: '2021-01-01T00:00:00.000Z', digest: 'aaa', }, ], }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry-1.docker.io/library/node', }); expect(res).toMatchObject({ releases: [ { version: '1.0.0', releaseTimestamp: '2021-01-01T00:00:00.000Z', }, ], }); expect(logger.logger.once.warn).toHaveBeenCalledWith( { dockerRepository: 'library/node', nextUrl: 'https://attacker.example.com/v2/repositories/library/node/tags?page=2&page_size=1000&ordering=last_updated', }, 'Ignoring cross-origin or invalid Docker Hub tags pagination link', ); }); it('follows cross-origin Docker Hub pagination when the datasource is opted in', async () => { vi.stubEnv('RENOVATE_X_DOCKER_PAGINATION_ALLOW_CROSS_ORIGIN', 'true'); vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', 'true'); httpMock .scope(dockerHubUrl) .get('/library/node/tags?page_size=1000&ordering=last_updated') .reply(200, { count: 2, next: 'https://mirror.example.com/v2/repositories/library/node/tags?page=2&page_size=1000&ordering=last_updated', results: [ { id: 1, last_updated: '2021-01-01T00:00:00.000Z', name: '1.0.0', tag_last_pushed: '2021-01-01T00:00:00.000Z', }, ], }); httpMock .scope('https://mirror.example.com') .get( '/v2/repositories/library/node/tags?page=2&page_size=1000&ordering=last_updated', ) .reply(200, { count: 2, next: null, results: [ { id: 2, last_updated: '2022-01-01T00:00:00.000Z', name: '2.0.0', tag_last_pushed: '2022-01-01T00:00:00.000Z', }, ], }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry-1.docker.io/library/node', }); expect(res?.releases).toMatchObject([ { version: '1.0.0' }, { version: '2.0.0' }, ]); expect(logger.logger.once.warn).toHaveBeenCalledOnce(); }); it('adds library/ prefix for Docker Hub (implicit)', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', 'true'); const tags = ['1.0.0']; httpMock .scope(dockerHubUrl) .get('/library/node/tags?page_size=1000&ordering=last_updated') .reply(404); httpMock .scope(baseUrl) .get('/library/node/tags/list?n=10000') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/node:pull"', }) .get('/library/node/tags/list?n=10000') .reply(200, { tags }, {}); httpMock .scope(authUrl) .get( '/token?service=registry.docker.io&scope=repository:library/node:pull', ) .reply(200, { token: 'test' }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'node', }); expect(res?.releases).toHaveLength(1); }); it('adds library/ prefix for Docker Hub (explicit)', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', 'true'); httpMock .scope(dockerHubUrl) .get('/library/node/tags?page_size=1000&ordering=last_updated') .reply(200, { next: `${dockerHubUrl}/library/node/tags?page=2&page_size=1000&ordering=last_updated`, count: 2, results: [ { id: 2, last_updated: '2021-01-01T00:00:00.000Z', name: '1.0.0', tag_last_pushed: '2021-01-01T00:00:00.000Z', digest: 'aaa', }, ], }) .get('/library/node/tags?page=2&page_size=1000&ordering=last_updated') .reply(200, { count: 2, results: [ { id: 1, last_updated: '2020-01-01T00:00:00.000Z', name: '0.9.0', tag_last_pushed: '2020-01-01T00:00:00.000Z', digest: 'bbb', }, ], }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'docker.io/node', }); expect(res?.releases).toMatchObject([ { version: '0.9.0', releaseTimestamp: '2020-01-01T00:00:00.000Z', }, { version: '1.0.0', releaseTimestamp: '2021-01-01T00:00:00.000Z', }, ]); }); it('sets releaseTimestamp on digests from Docker Hub', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', 'true'); httpMock .scope(dockerHubUrl) .get('/library/node/tags?page_size=1000&ordering=last_updated') .reply(200, { next: `${dockerHubUrl}/library/node/tags?page=2&page_size=1000&ordering=last_updated`, count: 2, results: [ { id: 2, last_updated: '2021-01-01T00:00:00.000Z', name: '1.0.0', // no tag_last_pushed digest: 'aaa', }, ], }) .get('/library/node/tags?page=2&page_size=1000&ordering=last_updated') .reply(200, { count: 2, results: [ { id: 1, last_updated: '2020-01-01T00:00:00.000Z', name: '0.9.0', tag_last_pushed: '2020-01-01T00:00:00.000Z', digest: 'bbb', }, ], }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'docker.io/node', }); expect(res?.releases).toMatchObject([ { version: '0.9.0', releaseTimestamp: '2020-01-01T00:00:00.000Z', }, { version: '1.0.0', // no releaseTimestamp }, ]); // Digest is not propagated — getDigest() resolves per-arch at lookup time expect(res?.releases[0]).not.toHaveProperty('newDigest'); expect(res?.releases[1]).not.toHaveProperty('newDigest'); expect(res?.releases[1]).not.toHaveProperty('releaseTimestamp'); }); it('adds no library/ prefix for other registries', async () => { const tags = ['1.0.0']; httpMock .scope('https://k8s.gcr.io/v2/') .get('/kubernetes-dashboard-amd64/tags/list?n=10000') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://k8s.gcr.io/v2/token",service="k8s.gcr.io"', }) .get( '/token?service=k8s.gcr.io&scope=repository:kubernetes-dashboard-amd64:pull', ) .reply(200, { token: 'some-token ' }) .get('/kubernetes-dashboard-amd64/tags/list?n=10000') .reply(200, { tags }, {}) .get('/') .reply(200) .get('/kubernetes-dashboard-amd64/manifests/1.0.0') .reply(200); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'k8s.gcr.io/kubernetes-dashboard-amd64', }); expect(res?.releases).toHaveLength(1); }); it('returns null on error', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_TAGS_DISABLE', 'true'); httpMock .scope(baseUrl) .get('/my/node/tags/list?n=10000') .reply(200) .get('/my/node/tags/list?n=10000') .replyWithError('error'); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'my/node', }); expect(res).toBeNull(); }); it('strips trailing slash from registry', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_TAGS_DISABLE', 'true'); httpMock .scope(baseUrl) .get('/my/node/tags/list?n=10000') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:my/node:pull"', }) .get('/my/node/tags/list?n=10000') .reply(200, { tags: ['1.0.0'] }, {}) .get('/') .reply(200) .get('/my/node/manifests/1.0.0') .reply(200); httpMock .scope(authUrl) .get('/token?service=registry.docker.io&scope=repository:my/node:pull') .reply(200, { token: 'some-token ' }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'my/node', registryUrls: ['https://index.docker.io/'], }); expect(res?.releases).toHaveLength(1); }); it('returns null if no auth', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_TAGS_DISABLE', 'true'); hostRules.clear(); httpMock .scope(baseUrl) .get('/library/node/tags/list?n=10000') .reply(401, undefined, { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'node', }); expect(res).toBeNull(); }); it('supports labels', async () => { httpMock .scope('https://registry.company.com/v2') .get('/') .times(2) .reply(200) .get('/node/tags/list?n=10000') .reply(200) .get('/node/tags/list?n=10000') .reply(200, { tags: [ '2.0.0', '2-alpine', '1-alpine', '1.0.0', '1.2.3', '1.2.3-alpine', 'abc', ], }) .get('/node/manifests/2-alpine') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/node/blobs/some-config-digest') .reply(200, { architecture: 'amd64', config: { Labels: { 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', 'org.opencontainers.image.revision': 'ab7ddb5e3c5c3b402acd7c3679d4e415f8092dde', 'org.opencontainers.image.url': 'https://www.mend.io/renovate/', }, }, }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res).toEqual({ lookupName: 'node', registryUrl: 'https://registry.company.com', releases: [ { version: '1.0.0', }, { version: '1.2.3-alpine', }, { version: '1.2.3', }, { version: '1-alpine', }, { version: '2.0.0', }, { version: '2-alpine', }, ], sourceUrl: 'https://github.com/renovatebot/renovate', homepage: 'https://www.mend.io/renovate/', gitRef: 'ab7ddb5e3c5c3b402acd7c3679d4e415f8092dde', }); }); it('supports labels - handle missing config prop on blob response', async () => { httpMock .scope('https://registry.company.com/v2') .get('/') .times(2) .reply(200) .get('/node/tags/list?n=10000') .reply(200) .get('/node/tags/list?n=10000') .reply(200, { tags: ['2-alpine'], }) .get('/node/manifests/2-alpine') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/node/blobs/some-config-digest') .reply(200, { architecture: 'amd64' }); // DockerDatasource.getLabels() inner response const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res).toEqual({ lookupName: 'node', registryUrl: 'https://registry.company.com', releases: [ { version: '2-alpine', }, ], }); expect(logger.logger.debug).toHaveBeenCalledWith( expect.anything(), `manifest blob response body missing the "config" property`, ); expect(logger.logger.info).not.toHaveBeenCalledExactlyOnceWith( expect.anything(), 'Unknown error getting Docker labels', ); }); it('supports manifest lists', async () => { httpMock .scope('https://registry.company.com/v2') .get('/') .times(3) .reply(200) .get('/node/tags/list?n=10000') .reply(200) .get('/node/tags/list?n=10000') .reply(200, { tags: ['abc'] }) .get('/node/manifests/abc') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.list.v2+json', manifests: [ { digest: 'some-image-digest', mediaType: 'application/vnd.docker.distribution.manifest.v2+json', }, ], }) .get('/node/manifests/some-image-digest') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/node/blobs/some-config-digest') .reply(200, { architecture: 'amd64', config: { Labels: { 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', }, }, }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res).toEqual({ lookupName: 'node', registryUrl: 'https://registry.company.com', releases: [], sourceUrl: 'https://github.com/renovatebot/renovate', }); }); it('ignores empty manifest lists', async () => { httpMock .scope('https://registry.company.com/v2') .get('/') .reply(200) .get('/node/tags/list?n=10000') .reply(200) .get('/node/tags/list?n=10000') .reply(200, { tags: ['latest'] }) .get('/node/manifests/latest') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.list.v2+json', manifests: [], }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res).toEqual({ lookupName: 'node', registryUrl: 'https://registry.company.com', releases: [], }); }); it('ignores unsupported manifest', async () => { httpMock .scope('https://registry.company.com/v2') .get('/') .reply(200) .get('/node/tags/list?n=10000') .reply(200) .get('/node/tags/list?n=10000') .reply(200, { tags: ['latest'] }) .get('/node/manifests/latest') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v1+json', }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res).toEqual({ lookupName: 'node', registryUrl: 'https://registry.company.com', releases: [], }); }); it('ignores unsupported schema version', async () => { httpMock .scope('https://registry.company.com/v2') .get('/') .reply(200) .get('/node/tags/list?n=10000') .reply(200) .get('/node/tags/list?n=10000') .reply(200, { tags: ['latest'] }) .get('/node/manifests/latest') .reply(200, {}); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res).toEqual({ lookupName: 'node', registryUrl: 'https://registry.company.com', releases: [], }); }); it('supports OCI manifests with media type', async () => { httpMock .scope('https://registry.company.com/v2') .get('/') .times(3) .reply(200) .get('/node/tags/list?n=10000') .reply(200) .get('/node/tags/list?n=10000') .reply(200, { tags: ['1'] }) .get('/node/manifests/1') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.index.v1+json', manifests: [ { digest: 'some-image-digest', mediaType: 'application/vnd.oci.image.manifest.v1+json', }, ], }) .get('/node/manifests/some-image-digest') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.oci.image.config.v1+json', }, }) .get('/node/blobs/some-config-digest') .reply(200, { architecture: 'amd64', config: { Labels: { 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', }, }, }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res).toEqual({ lookupName: 'node', registryUrl: 'https://registry.company.com', releases: [ { version: '1', }, ], sourceUrl: 'https://github.com/renovatebot/renovate', }); }); it('supports OCI manifests without media type', async () => { httpMock .scope('https://registry.company.com/v2') .get('/') .times(3) .reply(200) .get('/node/tags/list?n=10000') .reply(200) .get('/node/tags/list?n=10000') .reply(200, { tags: ['1'] }) .get('/node/manifests/1') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.index.v1+json', manifests: [ { digest: 'some-image-digest', mediaType: 'application/vnd.oci.image.manifest.v1+json', }, ], }) .get('/node/manifests/some-image-digest') .reply(200, { schemaVersion: 2, config: { digest: 'some-config-digest', mediaType: 'application/vnd.oci.image.config.v1+json', }, }) .get('/node/blobs/some-config-digest') .reply(200, { architecture: 'amd64', config: { Labels: { 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', }, }, }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res).toEqual({ lookupName: 'node', registryUrl: 'https://registry.company.com', releases: [ { version: '1', }, ], sourceUrl: 'https://github.com/renovatebot/renovate', }); }); it('ignores empty OCI manifest indexes', async () => { httpMock .scope('https://registry.company.com/v2') .get('/') .reply(200) .get('/node/tags/list?n=10000') .reply(200) .get('/node/tags/list?n=10000') .reply(200, { tags: ['latest'] }) .get('/node/manifests/latest') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.index.v1+json', manifests: [], }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res).toEqual({ lookupName: 'node', registryUrl: 'https://registry.company.com', releases: [], }); }); it('supports redirect', async () => { httpMock .scope('https://registry.company.com/v2', { badheaders: ['authorization'], }) .get('/') .times(2) .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }) .get('/node/tags/list?n=10000') .reply(401, '', { 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', }); httpMock .scope('https://registry.company.com/v2', { reqheaders: { authorization: 'Basic c29tZS11c2VybmFtZTpzb21lLXBhc3N3b3Jk', }, }) .get('/node/tags/list?n=10000') .reply(200, { tags: ['latest'] }) .get('/node/manifests/latest') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/node/blobs/some-config-digest') .reply(302, undefined, { location: 'https://abc.s3.amazon.com/some-config-digest?X-Amz-Algorithm=xxxx', }); httpMock .scope('https://abc.s3.amazon.com', { badheaders: ['authorization'] }) .get('/some-config-digest') .query({ 'X-Amz-Algorithm': 'xxxx' }) .reply(200, { architecture: 'amd64', config: {}, }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'registry.company.com/node', }); expect(res).toEqual({ lookupName: 'node', registryUrl: 'https://registry.company.com', releases: [], }); }); it('supports ghcr', async () => { httpMock .scope('https://ghcr.io/v2', { badheaders: ['authorization'], }) .get('/') .twice() .reply(401, '', { 'www-authenticate': 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"', }) .get('/visualon/drone-git/tags/list?n=10000') .reply(401, '', { 'www-authenticate': 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:visualon/drone-git:pull"', }); httpMock .scope('https://ghcr.io') .get('/token?service=ghcr.io&scope=repository:visualon/drone-git:pull') .times(3) .reply(200, { token: 'abc' }); httpMock .scope('https://ghcr.io/v2', { reqheaders: { authorization: 'Bearer abc', }, }) .get('/visualon/drone-git/tags/list?n=10000') .reply(200, { tags: ['latest', '1.0.0'] }) .get('/visualon/drone-git/manifests/latest') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.docker.distribution.manifest.v2+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.docker.container.image.v1+json', }, }) .get('/visualon/drone-git/blobs/some-config-digest') .reply(200, { architecture: 'amd64', config: { Labels: { 'org.opencontainers.image.source': 'https://github.com/visualon/drone-git', }, }, }); const res = await getPkgReleases({ datasource: DockerDatasource.id, packageName: 'ghcr.io/visualon/drone-git', }); expect(res).toEqual({ lookupName: 'visualon/drone-git', registryUrl: 'https://ghcr.io', sourceUrl: 'https://github.com/visualon/drone-git', releases: [{ version: '1.0.0' }], }); }); }); describe('getLabels', () => { const ds = new DockerDatasource(); it('uses annotations for oci image', async () => { httpMock .scope('https://ghcr.io/v2') .get('/') .reply(200) .get('/node/manifests/2-alpine') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.oci.image.config.v1+json', }, annotations: { 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', 'org.opencontainers.image.revision': 'ab7ddb5e3c5c3b402acd7c3679d4e415f8092dde', }, }); await expect( ds.getLabels('https://ghcr.io', 'node', '2-alpine'), ).resolves.toEqual({ 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', 'org.opencontainers.image.revision': 'ab7ddb5e3c5c3b402acd7c3679d4e415f8092dde', }); }); it('uses annotations for oci helm', async () => { httpMock .scope('https://ghcr.io/v2') .get('/') .reply(200) .get('/node/manifests/2-alpine') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.cncf.helm.config.v1+json', }, annotations: { 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', 'org.opencontainers.image.revision': 'ab7ddb5e3c5c3b402acd7c3679d4e415f8092dde', }, }); await expect( ds.getLabels('https://ghcr.io', 'node', '2-alpine'), ).resolves.toEqual({ 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', 'org.opencontainers.image.revision': 'ab7ddb5e3c5c3b402acd7c3679d4e415f8092dde', }); }); it('uses sources for oci helm', async () => { httpMock .scope('https://ghcr.io/v2') .get('/') .twice() .reply(200) .get('/harbor/manifests/16.7.2') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.cncf.helm.config.v1+json', }, }) .get('/harbor/blobs/some-config-digest') .reply(200, { name: 'harbor', version: '16.7.2', home: 'https://github.com/bitnami/charts/tree/main/bitnami/harbor', }); await expect( ds.getLabels('https://ghcr.io', 'harbor', '16.7.2'), ).resolves.toEqual({ 'org.opencontainers.image.source': 'https://github.com/bitnami/charts/tree/main/bitnami/harbor', }); }); it('uses descriptor annotations for docker hub library images', async () => { httpMock .scope('https://index.docker.io/v2') .get('/') .reply(200) .get('/library/convertigo/manifests/8.4.3') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.index.v1+json', manifests: [ { digest: 'sha256:attestation', mediaType: 'application/vnd.oci.image.manifest.v1+json', platform: { architecture: 'unknown', os: 'unknown', }, annotations: { 'vnd.docker.reference.type': 'attestation-manifest', }, }, { digest: 'sha256:amd64', mediaType: 'application/vnd.oci.image.manifest.v1+json', platform: { architecture: 'amd64', os: 'linux', }, annotations: { 'org.opencontainers.image.source': 'https://github.com/convertigo/convertigo.git#7b29f6f312a4582ccc7dd325dcf8f425ac8dfdbd:docker/default', 'org.opencontainers.image.revision': '7b29f6f312a4582ccc7dd325dcf8f425ac8dfdbd', 'org.opencontainers.image.version': '8.4.3', }, }, ], }); await expect( ds.getLabels('https://index.docker.io', 'library/convertigo', '8.4.3'), ).resolves.toEqual({ 'org.opencontainers.image.source': 'https://github.com/convertigo/convertigo.git#7b29f6f312a4582ccc7dd325dcf8f425ac8dfdbd:docker/default', 'org.opencontainers.image.revision': '7b29f6f312a4582ccc7dd325dcf8f425ac8dfdbd', 'org.opencontainers.image.version': '8.4.3', }); }); it('uses annotations for docker hub', async () => { httpMock .scope('https://index.docker.io/v2') .get('/') .reply(200) .get('/renovate/renovate/manifests/37.405.1-full') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', config: { digest: 'sha256:a3e34dca3519abb558a58384414cc69b6afbbb80e3992064f3e1c24e069c9168', mediaType: 'application/vnd.oci.image.config.v1+json', }, annotations: { 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', 'org.opencontainers.image.revision': 'e11f9d9882395deaf5fbbb81b3327cb8c2ef069c', }, }); await expect( ds.getLabels( 'https://index.docker.io', 'renovate/renovate', '37.405.1-full', ), ).resolves.toEqual({ 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', 'org.opencontainers.image.revision': 'e11f9d9882395deaf5fbbb81b3327cb8c2ef069c', }); }); it('skips docker hub labels', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', 'true'); httpMock.scope('https://index.docker.io/v2'); await expect( ds.getLabels( 'https://index.docker.io', 'renovate/renovate', '37.405.1-full', ), ).resolves.toEqual({}); }); it('does not skip non docker hub registry labels', async () => { vi.stubEnv('RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP', 'true'); httpMock .scope('https://ghcr.io/v2') .get('/') .reply(200) .get('/node/manifests/2-alpine') .reply(200, { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', config: { digest: 'some-config-digest', mediaType: 'application/vnd.oci.image.config.v1+json', }, annotations: { 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', 'org.opencontainers.image.revision': 'ab7ddb5e3c5c3b402acd7c3679d4e415f8092dde', }, }); await expect( ds.getLabels('https://ghcr.io', 'node', '2-alpine'), ).resolves.toEqual({ 'org.opencontainers.image.source': 'https://github.com/renovatebot/renovate', 'org.opencontainers.image.revision': 'ab7ddb5e3c5c3b402acd7c3679d4e415f8092dde', }); }); }); describe('getTags caching', () => { let dirResult: Awaited>; beforeEach(async () => { dirResult = await tmpDir({ unsafeCleanup: true }); // Unlike in with-cache.spec.ts, here we explicitly _don't_ configure // `memCache` because it memoizes GET responses. This would stop subsequent // lookups from actually exercising the package cache. await packageCache.init({ cacheDir: dirResult.path }); }); afterEach(async () => { await packageCache.cleanup({}); await dirResult.cleanup(); }); it('caches tags for Docker Hub', async () => { httpMock .scope(baseUrl) .get('/library/node/tags/list?n=10000') .reply(200, '', {}) // Auth probe .get('/library/node/tags/list?n=10000') .reply(200, { tags: ['1.0.0'] }, {}) // The mock replies below should never be reached if caching works // as expected. // They are marked as optional to avoid 'unused HTTP mocks' errors. .get('/library/node/tags/list?n=10000') .optionally() .reply(200, '', {}) .get('/library/node/tags/list?n=10000') .optionally() .reply(200, { tags: ['9.9.9'] }, {}); const ds = new DockerDatasource(); await expect(ds.getTags(DOCKER_HUB, 'library/node')).resolves.toEqual([ '1.0.0', ]); expect(httpMock.getTrace()).toHaveLength(2); // Second call pulls from the cache which was hydrated by the first call, // resulting in no additional requests to the HTTP mock. await expect(ds.getTags(DOCKER_HUB, 'library/node')).resolves.toEqual([ '1.0.0', ]); expect(httpMock.getTrace()).toHaveLength(2); }); it('does not cache tags for a private registry', async () => { httpMock .scope('https://registry.company.com/v2') .get('/node/tags/list?n=10000') .reply(200, '', {}) // Auth probe .get('/node/tags/list?n=10000') .reply(200, { tags: ['1.0.0'] }, {}) .get('/node/tags/list?n=10000') .reply(200, '', {}) // Auth probe .get('/node/tags/list?n=10000') .reply(200, { tags: ['1.0.0', '2.0.0'] }, {}); const ds = new DockerDatasource(); await expect( ds.getTags('https://registry.company.com', 'node'), ).resolves.toEqual(['1.0.0']); expect(httpMock.getTrace()).toHaveLength(2); await expect( ds.getTags('https://registry.company.com', 'node'), ).resolves.toEqual(['1.0.0', '2.0.0']); expect(httpMock.getTrace()).toHaveLength(4); }); it('does not cache failed lookups when cachePrivatePackages is enabled', async () => { GlobalConfig.set({ cachePrivatePackages: true }); httpMock .scope('https://registry.company.com/v2') .get('/node/tags/list?n=10000') .reply(403) // Failed auth probe .get('/node/tags/list?n=10000') .reply(200, '', {}) // Auth probe .get('/node/tags/list?n=10000') .reply(200, { tags: ['1.0.0'] }, {}); const ds = new DockerDatasource(); await expect( ds.getTags('https://registry.company.com', 'node'), ).resolves.toBeUndefined(); expect(httpMock.getTrace()).toHaveLength(1); // The previous auth error (`undefined`) didn't get cached, so the second // lookup hits the mock as expected. await expect( ds.getTags('https://registry.company.com', 'node'), ).resolves.toEqual(['1.0.0']); expect(httpMock.getTrace()).toHaveLength(3); }); }); });