Files
renovate/lib/modules/datasource/docker/index.spec.ts
James Griffiths 1afa293dda fix(datasource/docker): adjust tag caching for private registries (#45348)
* fix(docker): adjust tag caching for private registries

Tag caching for private registries currently has two issues, observed in
issues #45249 and #40734:
1. `getTags` has no `cacheable` guard (unlike `getReleases`)
2. `getTags` caches `null` tag results

These two combined mean that regardless of `cachePrivatePackages`
settings, private registry tag lookups which fail due to transient/auth
errors are cached. This results in the cache being "poisoned" by
misconfigured jobs, with well-configured jobs unable to recover. This is
especially relevant for the hosted app where caching is shared by
different tenants.

This commit:
1. Aligns `getTags` caching with `getReleases`, ensuring caching
   behaviour is driven by `cachePrivatePackages`.
2. Prevents `null` tag results being cached, allowing for recovery
   regardless of `cachePrivatePackages` configuration.

Note that I have _not_ applied (2) to `getReleases`, because
1. `getReleases` has a slightly different contract in that it sets
   `fallback: true` to intentionally cache failures
2. I have not observed any actual failures in `getReleases` due to (20,
   so it hasn't been empirically proven as requiring a fix
3. `getTags` is internal so is a lower-risk change

Fixes
- #45249

Relates to
- #45249
- #40734

* refactor(docker): undefined for no-auth

Previously `getAuthHeaders()` used `null` as an auth-failed sentinel,
which was inconsistent with `withCache()`, and led to the original "fix"
where nulls were explicitly omitted from the cache.

Instead this implements the suggestion from https://github.com/renovatebot/renovate/pull/45348#issuecomment-5340524967
to uniformly return undefined for `getTags` auth errors, which allows
the cache to recognise failures itself. Then cacheability ends up being
conditional on dockerhub as always.

This also rectifies a mismatch between auth-failure signals, where other
functions like `_getImageConfig` were returning `undefined` when headers
were unavailable.

IMPORTANT: once this lands, since there aren't any cache invalidations,
the existing issue will still be observed until the cache TTL expires
and old `null` entries are evicted.

* fix: whoops, sort out the vacuous tests

I failed to realise caching isn't automatically orchestrated in these
tests. As an eagle-eyed observer [pointed out][1], this meant the tests
weren't actually exercising anything.

This commit fixes that by adding a new test block which does the cache
orchestration using a temp dir cache. I've also opted to add assertions
against the mock server's http trace counter directly to ensure the mock
really is/isn't being hit. This should also serve to make the tests fail
fast if the underlying impl flow (auth probe -> get tags) is adjusted in
the future.

I've also brought in some caching dependencies into the test file, but
those are used elsewhere too (e.g. `withCache` tests).

This time, I have actually verified the tests which we'd expect to fail
do fail against main. Note that the first 'caches tags for Docker Hub'
is expected to pass on both branches (the control test).

```
     ❯ getTags caching (3)
       ✓ caches tags for Docker Hub 21ms 249 MB heap used
       × does not cache tags for a private registry 20ms 212 MB heap used
       × does not cache failed lookups when cachePrivatePackages is enabled 15ms 215 MB heap used
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  lib/modules/datasource/docker/index.spec.ts > modules/datasource/docker/index > getTags caching > does not cache tags for a private registry
AssertionError: expected [ '1.0.0' ] to deeply equal [ '1.0.0', '2.0.0' ]

- Expected
+ Received

  [
    "1.0.0",
-   "2.0.0",
  ]

 ❯ lib/modules/datasource/docker/index.spec.ts:3694:72
    3692|       expect(httpMock.getTrace()).toHaveLength(2);
    3693|
    3694|       expect(await ds.getTags('https://registry.company.com', 'node')).toEqual([
       |                                                                        ^
    3695|         '1.0.0',
    3696|         '2.0.0',

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/4]⎯

 FAIL  lib/modules/datasource/docker/index.spec.ts > modules/datasource/docker/index > getTags caching > does not cache tags for a private registry
Error: *** Unused HTTP mocks ***

- GET https://registry.company.com/v2/node/tags/list

Requests done:

- GET https://registry.company.com/v2/node/tags/list?n=10000 [200]
- GET https://registry.company.com/v2/node/tags/list?n=10000 [200]

---

Renovate testing strategy requires that every HTTP request
has a corresponding mock.

This error occurs because some of the created mocks are unused.

In most cases, you simply need to remove them.

+++
 ❯ <test> lib/modules/datasource/docker/index.spec.ts:3675:9
    3673|     });
    3674|
    3675|     it('does not cache tags for a private registry', async () => {
       |         ^
    3676|       httpMock
    3677|         .scope('https://registry.company.com/v2')

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/4]⎯

 FAIL  lib/modules/datasource/docker/index.spec.ts > modules/datasource/docker/index > getTags caching > does not cache failed lookups when cachePrivatePackages is enabled
AssertionError: expected null to be undefined

- Expected:
undefined

+ Received:
null

 ❯ lib/modules/datasource/docker/index.spec.ts:3717:9
    3715|       expect(
    3716|         await ds.getTags('https://registry.company.com', 'node'),
    3717|       ).toBeUndefined();
       |         ^
    3718|       expect(httpMock.getTrace()).toHaveLength(1);
    3719|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/4]⎯

 FAIL  lib/modules/datasource/docker/index.spec.ts > modules/datasource/docker/index > getTags caching > does not cache failed lookups when cachePrivatePackages is enabled
Error: *** Unused HTTP mocks ***

- GET https://registry.company.com/v2/node/tags/list

Requests done:

- GET https://registry.company.com/v2/node/tags/list?n=10000 [403]

---

Renovate testing strategy requires that every HTTP request
has a corresponding mock.

This error occurs because some of the created mocks are unused.

In most cases, you simply need to remove them.

+++
 ❯ <test> lib/modules/datasource/docker/index.spec.ts:3701:9
    3699|     });
    3700|
    3701|     it('does not cache failed lookups when cachePrivatePackages is enabled', async () => {
       |         ^
    3702|       GlobalConfig.set({ cachePrivatePackages: true });
    3703|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/4]⎯

 Test Files  1 failed (1)
      Tests  2 failed | 313 passed (315)
```

[1]: https://github.com/renovatebot/renovate/pull/45348#pullrequestreview-5092478059

* oxlint-fix

---------

Co-authored-by: Jamie Tanna <jamie.tanna@mend.io>
2026-09-07 12:07:52 +00:00

3728 lines
118 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('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('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'] }, {})
// The mock replies below should never be reached if caching works
// as expected.
// They are marked as optional to avoid 'unused HTTP mocks' errors.
.get('/library/node/tags/list?n=10000')
.optionally()
.reply(200, '', {})
.get('/library/node/tags/list?n=10000')
.optionally()
.reply(200, { tags: ['9.9.9'] }, {});
const ds = new DockerDatasource();
await expect(ds.getTags(DOCKER_HUB, 'library/node')).resolves.toEqual([
'1.0.0',
]);
expect(httpMock.getTrace()).toHaveLength(2);
// Second call pulls from the cache which was hydrated by the first call,
// resulting in no additional requests to the HTTP mock.
await expect(ds.getTags(DOCKER_HUB, 'library/node')).resolves.toEqual([
'1.0.0',
]);
expect(httpMock.getTrace()).toHaveLength(2);
});
it('does not cache tags for a private registry', async () => {
httpMock
.scope('https://registry.company.com/v2')
.get('/node/tags/list?n=10000')
.reply(200, '', {}) // Auth probe
.get('/node/tags/list?n=10000')
.reply(200, { tags: ['1.0.0'] }, {})
.get('/node/tags/list?n=10000')
.reply(200, '', {}) // Auth probe
.get('/node/tags/list?n=10000')
.reply(200, { tags: ['1.0.0', '2.0.0'] }, {});
const ds = new DockerDatasource();
await expect(
ds.getTags('https://registry.company.com', 'node'),
).resolves.toEqual(['1.0.0']);
expect(httpMock.getTrace()).toHaveLength(2);
await expect(
ds.getTags('https://registry.company.com', 'node'),
).resolves.toEqual(['1.0.0', '2.0.0']);
expect(httpMock.getTrace()).toHaveLength(4);
});
it('does not cache failed lookups when cachePrivatePackages is enabled', async () => {
GlobalConfig.set({ cachePrivatePackages: true });
httpMock
.scope('https://registry.company.com/v2')
.get('/node/tags/list?n=10000')
.reply(403) // Failed auth probe
.get('/node/tags/list?n=10000')
.reply(200, '', {}) // Auth probe
.get('/node/tags/list?n=10000')
.reply(200, { tags: ['1.0.0'] }, {});
const ds = new DockerDatasource();
await expect(
ds.getTags('https://registry.company.com', 'node'),
).resolves.toBeUndefined();
expect(httpMock.getTrace()).toHaveLength(1);
// The previous auth error (`undefined`) didn't get cached, so the second
// lookup hits the mock as expected.
await expect(
ds.getTags('https://registry.company.com', 'node'),
).resolves.toEqual(['1.0.0']);
expect(httpMock.getTrace()).toHaveLength(3);
});
});
});