mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-27 15:58:48 +00:00
ac2fc77641
* refactor(http-mock): remove request tracing * test: replace remaining HTTP trace assertions
3827 lines
122 KiB
TypeScript
3827 lines
122 KiB
TypeScript
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<GetAuthorizationTokenCommandOutput> = {},
|
|
) {
|
|
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<any>(
|
|
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<any>(
|
|
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<any>(
|
|
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<any>(
|
|
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<any>(
|
|
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<any>(
|
|
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('returns null when a non-list manifest carries no digest header', 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',
|
|
},
|
|
});
|
|
|
|
const res = await getDigest(
|
|
{
|
|
datasource: 'docker',
|
|
packageName: 'some-dep',
|
|
currentDigest,
|
|
},
|
|
'some-new-value',
|
|
);
|
|
|
|
// no header to read, so the digest falls back to the manifest hash
|
|
expect(res).toBe(
|
|
'sha256:4dfd2fd0672c541746a28371af8d2e31e83dede2fa716c4d4344358dc181ebca',
|
|
);
|
|
});
|
|
|
|
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<DockerHubCache>({
|
|
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<DockerHubCache>({
|
|
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: '<https://attacker.example.com/v2/steal/tags/list?n=10000>; 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: '<https://mirror.example.com/v2/node/tags/list?n=10000&last=1.0.0>; 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: '<https://attacker.example.com/v2/steal/tags/list?n=10000>; 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('fetches all pages for Red Hat registry', async () => {
|
|
GlobalConfig.set({ dockerMaxPages: 2 });
|
|
const rhUrl = 'https://registry.access.redhat.com/v2';
|
|
httpMock
|
|
.scope(rhUrl)
|
|
.get('/hi/go-builder/tags/list?n=10000')
|
|
.reply(200, '', {})
|
|
.get('/hi/go-builder/tags/list?n=10000')
|
|
.reply(
|
|
200,
|
|
{ tags: ['0.1.0'] },
|
|
{
|
|
link: `<${rhUrl}/hi/go-builder/tags/list?n=100&last=0.1.0>; rel="next", `,
|
|
},
|
|
)
|
|
.get('/hi/go-builder/tags/list?n=100&last=0.1.0')
|
|
.reply(
|
|
200,
|
|
{ tags: ['0.2.0'] },
|
|
{
|
|
link: `<${rhUrl}/hi/go-builder/tags/list?n=100&last=0.2.0>; rel="next", `,
|
|
},
|
|
)
|
|
.get('/hi/go-builder/tags/list?n=100&last=0.2.0')
|
|
.reply(200, { tags: ['9.9.9'] }, {})
|
|
.get('/')
|
|
.reply(200, '', {})
|
|
.get('/hi/go-builder/manifests/9.9.9')
|
|
.reply(200, '', {});
|
|
|
|
const res = await getPkgReleases({
|
|
datasource: DockerDatasource.id,
|
|
packageName: 'hi/go-builder',
|
|
registryUrls: ['https://registry.access.redhat.com'],
|
|
});
|
|
|
|
expect(res?.releases?.map((release) => release.version)).toEqual([
|
|
'0.1.0',
|
|
'0.2.0',
|
|
'9.9.9',
|
|
]);
|
|
});
|
|
|
|
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 `</virtual-mirror/library/node/tags/list?n=10000&last=10000>; 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: '</library/node/tags/list?n=10000&last=10000>; 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: '</library/node/tags/list?n=10000&last=20000>; 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<ReturnType<typeof tmpDir>>;
|
|
|
|
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'] }, {});
|
|
|
|
const ds = new DockerDatasource();
|
|
|
|
await expect(ds.getTags(DOCKER_HUB, 'library/node')).resolves.toEqual([
|
|
'1.0.0',
|
|
]);
|
|
|
|
// 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',
|
|
]);
|
|
});
|
|
|
|
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']);
|
|
|
|
await expect(
|
|
ds.getTags('https://registry.company.com', 'node'),
|
|
).resolves.toEqual(['1.0.0', '2.0.0']);
|
|
});
|
|
|
|
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();
|
|
|
|
// 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']);
|
|
});
|
|
});
|
|
});
|