Files
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

1316 lines
39 KiB
TypeScript

import { isNonEmptyString } from '@sindresorhus/is';
import { GlobalConfig } from '../../../config/global.ts';
import { PAGE_NOT_FOUND_ERROR } from '../../../constants/error-messages.ts';
import { logger } from '../../../logger/index.ts';
import { ExternalHostError } from '../../../types/errors/external-host-error.ts';
import { withCache } from '../../../util/cache/package/with-cache.ts';
import { getEnv } from '../../../util/env.ts';
import { memCacheProvider } from '../../../util/http/cache/memory-http-cache-provider.ts';
import { HttpError } from '../../../util/http/index.ts';
import type { HttpResponse } from '../../../util/http/types.ts';
import { hasKey } from '../../../util/object.ts';
import { type AsyncResult, Result } from '../../../util/result.ts';
import { isDockerDigest } from '../../../util/string-match.ts';
import { asTimestamp } from '../../../util/timestamp.ts';
import {
ensurePathPrefix,
joinUrlParts,
parseLinkHeader,
parseUrl,
} from '../../../util/url.ts';
import { id as dockerVersioningId } from '../../versioning/docker/index.ts';
import { Datasource } from '../datasource.ts';
import type {
DigestConfig,
GetReleasesConfig,
Release,
ReleaseResult,
} from '../types.ts';
import {
isArtifactoryServer,
isCrossOriginPaginationAllowed,
resolvePaginationUrl,
} from '../util.ts';
import {
DOCKER_HUB,
dockerDatasourceId,
extractDigestFromResponseBody,
findHelmSourceUrl,
findLatestStable,
getAuthHeaders,
getRegistryRepository,
gitRefLabel,
imageUrlLabel,
isDockerHost,
sourceLabel,
sourceLabels,
} from './common.ts';
import { DockerHubCache } from './dockerhub-cache.ts';
import { ecrPublicRegex, ecrRegex, isECRMaxResultsError } from './ecr.ts';
import type {
DistributionManifest,
Manifest,
OciImageManifest,
} from './schema.ts';
import {
DockerHubTagsPage,
ManifestJson,
OciHelmConfig,
OciImageConfig,
QuayTagsResponse,
RegistryTagsList,
} from './schema.ts';
const defaultConfig = {
commitMessageTopic: '{{{depName}}} Docker tag',
commitMessageExtra:
'to {{#if isPinDigest}}{{{newDigestShort}}}{{else}}{{#if isMajor}}{{{prettyNewMajor}}}{{else}}{{{prettyNewVersion}}}{{/if}}{{/if}}',
digest: {
branchTopic: '{{{depNameSanitized}}}-{{{currentValue}}}',
commitMessageExtra: 'to {{newDigestShort}}',
commitMessageTopic:
'{{{depName}}}{{#if currentValue}}:{{{currentValue}}}{{/if}} Docker digest',
group: {
commitMessageTopic: '{{{groupName}}}',
commitMessageExtra: '',
},
},
pin: {
commitMessageExtra: '',
groupName: 'Docker digests',
group: {
commitMessageTopic: '{{{groupName}}}',
branchTopic: 'digests-pin',
},
},
};
export class DockerDatasource extends Datasource {
static readonly id = dockerDatasourceId;
override readonly defaultVersioning = dockerVersioningId;
override readonly defaultRegistryUrls = [DOCKER_HUB];
override readonly defaultConfig = defaultConfig;
override readonly releaseTimestampSupport = true;
override readonly releaseTimestampNote =
'Only supported on Docker Hub: The release timestamp is determined from the `tag_last_pushed` field in the results. **NOTE**: Currently, digests will receive the same release timestamp as the `tag_last_pushed`, which means that digests may appear newer than they are - see https://github.com/renovatebot/renovate/issues/38659';
override readonly sourceUrlSupport = 'package';
override readonly sourceUrlNote =
'The source URL is determined from the `org.opencontainers.image.source` and `org.label-schema.vcs-url` labels present in the metadata of the **latest stable** image found on the Docker registry.';
constructor() {
super(DockerDatasource.id);
}
// TODO: debug why quay throws errors (#9612)
private async getManifestResponse(
registryHost: string,
dockerRepository: string,
tag: string,
mode: 'head' | 'getText' = 'getText',
): Promise<HttpResponse | null> {
logger.debug(
`getManifestResponse(${registryHost}, ${dockerRepository}, ${tag}, ${mode})`,
);
try {
const headers = await getAuthHeaders(
this.http,
registryHost,
dockerRepository,
);
if (!headers) {
logger.warn('No docker auth found - returning');
return null;
}
headers.accept = [
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.oci.image.index.v1+json',
].join(', ');
const url = `${registryHost}/v2/${dockerRepository}/manifests/${tag}`;
const manifestResponse = await this.http[mode](url, {
headers,
noAuth: true,
cacheProvider: memCacheProvider,
});
return manifestResponse;
} catch (err) /* istanbul ignore next */ {
if (err instanceof ExternalHostError) {
throw err;
}
if (err.statusCode === 401) {
logger.debug(
{ registryHost, dockerRepository },
'Unauthorized docker lookup',
);
logger.debug({ err });
return null;
}
if (err.statusCode === 404) {
logger.debug(
{
err,
registryHost,
dockerRepository,
tag,
},
'Docker Manifest is unknown',
);
return null;
}
if (err.statusCode === 429 && isDockerHost(registryHost)) {
throw new ExternalHostError(err);
}
if (err.statusCode >= 500 && err.statusCode < 600) {
throw new ExternalHostError(err);
}
if (err.code === 'ETIMEDOUT') {
logger.debug(
{ registryHost },
'Timeout when attempting to connect to docker registry',
);
logger.debug({ err });
return null;
}
logger.debug(
{
err,
registryHost,
dockerRepository,
tag,
},
'Unknown Error looking up docker manifest',
);
return null;
}
}
private async _getImageConfig(
registryHost: string,
dockerRepository: string,
configDigest: string,
): Promise<HttpResponse<OciImageConfig> | undefined> {
logger.trace(
`getImageConfig(${registryHost}, ${dockerRepository}, ${configDigest})`,
);
const headers = await getAuthHeaders(
this.http,
registryHost,
dockerRepository,
);
/* v8 ignore next -- should never happen */
if (!headers) {
logger.warn('No docker auth found - returning');
return undefined;
}
const url = joinUrlParts(
registryHost,
'v2',
dockerRepository,
'blobs',
configDigest,
);
return await this.http.getJson(
url,
{
headers,
noAuth: true,
},
OciImageConfig,
);
}
getImageConfig(
registryHost: string,
dockerRepository: string,
configDigest: string,
): Promise<HttpResponse<OciImageConfig> | undefined> {
return withCache(
{
namespace: 'datasource-docker-imageconfig',
key: `${registryHost}:${dockerRepository}@${configDigest}`,
ttlMinutes: 1440 * 28,
},
() => this._getImageConfig(registryHost, dockerRepository, configDigest),
);
}
private async _getHelmConfig(
registryHost: string,
dockerRepository: string,
configDigest: string,
): Promise<HttpResponse<OciHelmConfig> | undefined> {
logger.trace(
`getImageConfig(${registryHost}, ${dockerRepository}, ${configDigest})`,
);
const headers = await getAuthHeaders(
this.http,
registryHost,
dockerRepository,
);
/* v8 ignore next -- should never happen */
if (!headers) {
logger.warn('No docker auth found - returning');
return undefined;
}
const url = joinUrlParts(
registryHost,
'v2',
dockerRepository,
'blobs',
configDigest,
);
return await this.http.getJson(
url,
{
headers,
noAuth: true,
},
OciHelmConfig,
);
}
getHelmConfig(
registryHost: string,
dockerRepository: string,
configDigest: string,
): Promise<HttpResponse<OciHelmConfig> | undefined> {
return withCache(
{
namespace: 'datasource-docker-imageconfig',
key: `${registryHost}:${dockerRepository}@${configDigest}`,
ttlMinutes: 1440 * 28,
},
() => this._getHelmConfig(registryHost, dockerRepository, configDigest),
);
}
private async getConfigDigest(
registry: string,
dockerRepository: string,
tag: string,
): Promise<string | null> {
return (
(await this.getManifest(registry, dockerRepository, tag))?.config
?.digest ?? null
);
}
private async getManifestDocument(
registry: string,
dockerRepository: string,
tag: string,
): Promise<Manifest | null> {
const manifestResponse = await this.getManifestResponse(
registry,
dockerRepository,
tag,
);
// If getting the manifest fails here, then abort
// This means that the latest tag doesn't have a manifest, which shouldn't
// be possible
/* v8 ignore next -- should never happen */
if (!manifestResponse) {
return null;
}
// Softfail on invalid manifests
const parsed = ManifestJson.safeParse(manifestResponse.body);
if (!parsed.success) {
logger.debug(
{
registry,
dockerRepository,
tag,
body: manifestResponse.body,
headers: manifestResponse.headers,
err: parsed.error,
},
'Invalid manifest response',
);
return null;
}
return parsed.data;
}
private async resolveImageManifest(
registry: string,
dockerRepository: string,
tag: string,
manifest: Manifest,
): Promise<OciImageManifest | DistributionManifest | null> {
switch (manifest.mediaType) {
case 'application/vnd.docker.distribution.manifest.v2+json':
case 'application/vnd.oci.image.manifest.v1+json':
return manifest;
case 'application/vnd.docker.distribution.manifest.list.v2+json':
case 'application/vnd.oci.image.index.v1+json':
if (!manifest.manifests.length) {
logger.debug(
{ manifest },
'Invalid manifest list with no manifests - returning',
);
return null;
}
logger.trace(
{ registry, dockerRepository, tag },
'Found manifest list, using first image',
);
return this.getManifest(
registry,
dockerRepository,
manifest.manifests[0].digest,
);
// istanbul ignore next: can't happen
default:
return null;
}
}
private async getManifest(
registry: string,
dockerRepository: string,
tag: string,
): Promise<OciImageManifest | DistributionManifest | null> {
const manifest = await this.getManifestDocument(
registry,
dockerRepository,
tag,
);
if (!manifest) {
return null;
}
return this.resolveImageManifest(registry, dockerRepository, tag, manifest);
}
private async _getImageArchitecture(
registryHost: string,
dockerRepository: string,
currentDigest: string,
): Promise<string | null | undefined> {
try {
let manifestResponse: HttpResponse | null;
try {
manifestResponse = await this.getManifestResponse(
registryHost,
dockerRepository,
currentDigest,
'head',
);
} catch (_err) {
const err =
_err instanceof ExternalHostError
? _err.err
: /* istanbul ignore next: can never happen */ _err;
if (
typeof err.statusCode === 'number' &&
err.statusCode >= 500 &&
err.statusCode < 600
) {
// querying the digest manifest for a non existent image leads to a 500 statusCode
return null;
}
/* istanbul ignore next */
throw _err;
}
if (
manifestResponse?.headers['content-type'] !==
'application/vnd.docker.distribution.manifest.v2+json' &&
manifestResponse?.headers['content-type'] !==
'application/vnd.oci.image.manifest.v1+json'
) {
return null;
}
const configDigest = await this.getConfigDigest(
registryHost,
dockerRepository,
currentDigest,
);
if (!configDigest) {
return null;
}
const configResponse = await this.getImageConfig(
registryHost,
dockerRepository,
configDigest,
);
// TODO: fix me, architecture is required in spec
if (
configResponse &&
('config' in configResponse.body ||
'architecture' in configResponse.body)
) {
const architecture = configResponse.body.architecture ?? null;
logger.debug(
`Current digest ${currentDigest} relates to architecture ${
architecture ?? 'null'
}`,
);
return architecture;
}
} catch (err) /* istanbul ignore next */ {
if (err.statusCode !== 404 || err.message === PAGE_NOT_FOUND_ERROR) {
throw err;
}
logger.debug(
{ registryHost, dockerRepository, currentDigest, err },
'Unknown error getting image architecture',
);
}
return undefined;
}
getImageArchitecture(
registryHost: string,
dockerRepository: string,
currentDigest: string,
): Promise<string | null | undefined> {
return withCache(
{
namespace: 'datasource-docker-architecture',
key: `${registryHost}:${dockerRepository}@${currentDigest}`,
ttlMinutes: 1440 * 28,
shouldCacheResult: isNonEmptyString,
},
() =>
this._getImageArchitecture(
registryHost,
dockerRepository,
currentDigest,
),
);
}
/*
* docker.getLabels
*
* This function will:
* - Return the labels for the requested image
*/
private async _getLabels(
registryHost: string,
dockerRepository: string,
tag: string,
): Promise<Record<string, string> | undefined> {
logger.debug(`getLabels(${registryHost}, ${dockerRepository}, ${tag})`);
// Skip Docker Hub image if RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP is set
if (
getEnv().RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP &&
registryHost === DOCKER_HUB
) {
logger.debug(
'Docker Hub image - skipping label lookup due to RENOVATE_X_DOCKER_HUB_DISABLE_LABEL_LOOKUP',
);
return {};
}
try {
let labels: Record<string, string> | undefined = {};
const manifestDocument = await this.getManifestDocument(
registryHost,
dockerRepository,
tag,
);
if (!manifestDocument) {
logger.debug(
{ registryHost, dockerRepository, tag },
'No manifest found',
);
return undefined;
}
if ('annotations' in manifestDocument && manifestDocument.annotations) {
labels = manifestDocument.annotations;
}
if ('manifests' in manifestDocument) {
const descriptorAnnotations = manifestDocument.manifests
.map((descriptor) => descriptor.annotations)
.find(
(annotations) =>
isNonEmptyString(annotations?.[sourceLabel]) &&
isNonEmptyString(annotations?.[gitRefLabel]),
);
if (descriptorAnnotations) {
labels = { ...labels, ...descriptorAnnotations };
}
}
if (
'manifests' in manifestDocument &&
labels[sourceLabel] &&
labels[gitRefLabel]
) {
return labels;
}
const manifest = await this.resolveImageManifest(
registryHost,
dockerRepository,
tag,
manifestDocument,
);
if (!manifest) {
return undefined;
}
if ('annotations' in manifest && manifest.annotations) {
labels = { ...labels, ...manifest.annotations };
}
switch (manifest.config.mediaType) {
case 'application/vnd.cncf.helm.config.v1+json': {
if (labels[sourceLabel]) {
// we already have the source url, so no need to pull the config
return labels;
}
const configResponse = await this.getHelmConfig(
registryHost,
dockerRepository,
manifest.config.digest,
);
if (configResponse) {
// Helm chart
const url = findHelmSourceUrl(configResponse.body);
if (url) {
labels[sourceLabel] = url;
}
}
break;
}
case 'application/vnd.oci.image.config.v1+json':
case 'application/vnd.docker.container.image.v1+json': {
if (labels[sourceLabel] && labels[gitRefLabel]) {
// we already have the source url, so no need to pull the config
return labels;
}
const configResponse = await this.getImageConfig(
registryHost,
dockerRepository,
manifest.config.digest,
);
/* v8 ignore next -- should never happen */
if (!configResponse) {
return labels;
}
const body = configResponse.body;
if (body.config) {
labels = { ...labels, ...body.config.Labels };
} else {
logger.debug(
{ headers: configResponse.headers, body },
`manifest blob response body missing the "config" property`,
);
}
break;
}
}
if (labels) {
logger.debug(
{
labels,
},
'found labels in manifest',
);
}
return labels;
} catch (err) /* istanbul ignore next: should be tested in future */ {
if (err instanceof ExternalHostError) {
throw err;
}
if (err.statusCode === 400 || err.statusCode === 401) {
logger.debug(
{ registryHost, dockerRepository, err },
'Unauthorized docker lookup',
);
} else if (err.statusCode === 404) {
logger.warn(
{
err,
registryHost,
dockerRepository,
tag,
},
'Config Manifest is unknown',
);
} else if (err.statusCode === 429 && isDockerHost(registryHost)) {
logger.warn({ err }, 'docker registry failure: too many requests');
} else if (err.statusCode >= 500 && err.statusCode < 600) {
logger.debug(
{
err,
registryHost,
dockerRepository,
tag,
},
'docker registry failure: internal error',
);
} else if (
err.code === 'ERR_TLS_CERT_ALTNAME_INVALID' ||
err.code === 'ETIMEDOUT'
) {
logger.debug(
{ registryHost, err },
'Error connecting to docker registry',
);
} else if (registryHost === 'https://quay.io') {
// istanbul ignore next
logger.debug(
'Ignoring quay.io errors until they fully support v2 schema',
);
} else {
logger.info(
{ registryHost, dockerRepository, tag, err },
'Unknown error getting Docker labels',
);
}
return {};
}
}
getLabels(
registryHost: string,
dockerRepository: string,
tag: string,
): Promise<Record<string, string> | undefined> {
return withCache(
{
namespace: 'datasource-docker-labels',
key: `${registryHost}:${dockerRepository}:${tag}`,
ttlMinutes: 24 * 60,
},
() => this._getLabels(registryHost, dockerRepository, tag),
);
}
private async getTagsQuayRegistry(
registry: string,
repository: string,
): Promise<string[]> {
let tags: string[] = [];
const limit = 100;
function pageUrl(page: number): string {
return `${registry}/api/v1/repository/${repository}/tag/?limit=${limit}&page=${page}&onlyActiveTags=true`;
}
let page = 1;
let url: string | null = pageUrl(page);
while (url && page <= 20) {
const res: HttpResponse<QuayTagsResponse> = await this.http.getJson(
url,
QuayTagsResponse,
);
const pageTags = res.body.tags.map((tag) => tag.name);
tags = tags.concat(pageTags);
page += 1;
url = res.body.has_additional ? pageUrl(page) : null;
}
return tags;
}
private async getDockerApiTags(
registryHost: string,
dockerRepository: string,
): Promise<string[] | undefined> {
let tags: string[] = [];
// AWS ECR limits the maximum number of results to 1000
// See https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_DescribeRepositories.html#ECR-DescribeRepositories-request-maxResults
// See https://docs.aws.amazon.com/AmazonECRPublic/latest/APIReference/API_DescribeRepositories.html#ecrpublic-DescribeRepositories-request-maxResults
const limit =
ecrRegex.test(registryHost) || ecrPublicRegex.test(registryHost)
? 1000
: 10000;
let url: string | null =
`${registryHost}/${dockerRepository}/tags/list?n=${limit}`;
url = ensurePathPrefix(url, '/v2');
const headers = await getAuthHeaders(
this.http,
registryHost,
dockerRepository,
url,
);
if (!headers) {
logger.debug('Failed to get authHeaders for getTags lookup');
return undefined;
}
let page = 0;
const hostsNeedingAllPages = [
'https://ghcr.io', // GHCR sorts from oldest to newest, so we need to get all pages
'https://quay.io', // Quay sorts from oldest to newest, so we need to get all pages
'https://cgr.dev', // Chainguard sorts lexically and publishes a tag per build, so current versions sort past the page limit
];
const pages = hostsNeedingAllPages.includes(registryHost)
? 1000
: GlobalConfig.get('dockerMaxPages');
logger.trace({ registryHost, dockerRepository, pages }, 'docker.getTags');
const allowCrossOrigin = isCrossOriginPaginationAllowed(dockerDatasourceId);
let foundMaxResultsError = false;
do {
let res: HttpResponse<RegistryTagsList>;
try {
res = await this.http.getJson(
url,
{ headers, noAuth: true },
RegistryTagsList,
);
} catch (err) {
if (
!foundMaxResultsError &&
err instanceof HttpError &&
isECRMaxResultsError(err)
) {
const maxResults = 1000;
url = `${registryHost}/${dockerRepository}/tags/list?n=${maxResults}`;
url = ensurePathPrefix(url, '/v2');
foundMaxResultsError = true;
continue;
}
throw err;
}
tags = tags.concat(res.body.tags);
const linkHeader = parseLinkHeader(res.headers.link);
if (isArtifactoryServer(res)) {
// Artifactory bug: next link comes back without virtual-repo prefix (RTFACT-18971)
if (linkHeader?.next?.last) {
// parse the current URL, strip any old "last" param, then set the new one
const parsed = parseUrl(url);
// v8 ignore if: url is always a valid HTTP URL as `ensurePathPrefix`
if (!parsed) {
url = null;
break;
}
parsed.searchParams.delete('last');
parsed.searchParams.set('last', linkHeader.next.last);
url = parsed.href;
} else {
url = null;
}
} else if (linkHeader?.next?.url) {
// Resolve the relative-or-absolute next link, not following cross-origin requests unless explicitly opted in
const nextUrl = resolvePaginationUrl(
url,
linkHeader.next.url,
allowCrossOrigin,
);
if (!nextUrl) {
// make sure that users are aware if there are any (potentially malicious, or misconfigured) pagination links being returned
logger.once.warn(
{ registryHost, nextUrl: linkHeader.next.url },
'Ignoring cross-origin or invalid Docker registry tags pagination link',
);
}
url = nextUrl;
} else {
url = null;
}
page += 1;
} while (url && page < pages);
return tags;
}
private async _getTags(
registryHost: string,
dockerRepository: string,
): Promise<string[] | undefined> {
try {
const isQuay = registryHost === 'https://quay.io';
let tags: string[] | undefined;
if (isQuay) {
try {
// Due to pagination and sorting limits on Quay Docker v2 API implementation we try the Quay v1 API first
tags = await this.getTagsQuayRegistry(registryHost, dockerRepository);
} catch (err) {
// If we get a 401 Unauthorized error (v1 API requires separate auth for private images), fall back to Docker v2 API
if (err.statusCode === 401) {
logger.debug(
{ registryHost, dockerRepository },
'Quay v1 API unauthorized, falling back to Docker v2 API',
);
tags = await this.getDockerApiTags(registryHost, dockerRepository);
} else {
throw err;
}
}
} else {
tags = await this.getDockerApiTags(registryHost, dockerRepository);
}
return tags;
} catch (_err) /* istanbul ignore next */ {
const err = _err instanceof ExternalHostError ? _err.err : _err;
if (
(err.statusCode === 404 || err.message === PAGE_NOT_FOUND_ERROR) &&
!dockerRepository.includes('/')
) {
logger.debug(
`Retrying Tags for ${registryHost}/${dockerRepository} using library/ prefix`,
);
return this.getTags(registryHost, `library/${dockerRepository}`);
}
// JFrog Artifactory - Retry handling when resolving Docker Official Images
// These follow the format of {{registryHost}}{{jFrogRepository}}/library/{{dockerRepository}}
if (
(err.statusCode === 404 || err.message === PAGE_NOT_FOUND_ERROR) &&
isArtifactoryServer(err.response) &&
dockerRepository.split('/').length === 2
) {
logger.debug(
`JFrog Artifactory: Retrying Tags for ${registryHost}/${dockerRepository} using library/ path between JFrog virtual repository and image`,
);
const dockerRepositoryParts = dockerRepository.split('/');
const jfrogRepository = dockerRepositoryParts[0];
const dockerImage = dockerRepositoryParts[1];
return this.getTags(
registryHost,
`${jfrogRepository}/library/${dockerImage}`,
);
}
if (err.statusCode === 429 && isDockerHost(registryHost)) {
logger.warn(
{ registryHost, dockerRepository, err },
'docker registry failure: too many requests',
);
throw new ExternalHostError(err);
}
if (err.statusCode >= 500 && err.statusCode < 600) {
logger.warn(
{ registryHost, dockerRepository, err },
'docker registry failure: internal error',
);
throw new ExternalHostError(err);
}
const errorCodes = ['ECONNRESET', 'ETIMEDOUT'];
if (errorCodes.includes(err.code)) {
logger.warn(
{ registryHost, dockerRepository, err },
'docker registry connection failure',
);
throw new ExternalHostError(err);
}
if (isDockerHost(registryHost)) {
logger.info({ err }, 'Docker Hub lookup failure');
}
throw _err;
}
}
getTags(
registryHost: string,
dockerRepository: string,
): Promise<string[] | undefined> {
return withCache(
{
namespace: 'datasource-docker-tags',
key: `${registryHost}:${dockerRepository}`,
cacheable: registryHost === DOCKER_HUB,
},
() => this._getTags(registryHost, dockerRepository),
);
}
/**
* docker.getDigest
*
* The `newValue` supplied here should be a valid tag for the docker image.
*
* This function will:
* - Look up a sha256 digest for a tag on its registry
* - Return the digest as a string
*/
private async _getDigest(
{ registryUrl, lookupName, packageName, currentDigest }: DigestConfig,
newValue?: string,
): Promise<string | null> {
let registryHost: string;
let dockerRepository: string;
if (registryUrl && lookupName) {
// Reuse the resolved values from getReleases()
registryHost = registryUrl;
dockerRepository = lookupName;
} else {
// Resolve values independently
({ registryHost, dockerRepository } = getRegistryRepository(
packageName,
registryUrl!,
));
}
logger.debug(
// TODO: types (#22198)
`getDigest(${registryHost}, ${dockerRepository}, ${newValue})`,
);
const newTag = isNonEmptyString(newValue) ? newValue : 'latest';
let digest: string | null = null;
try {
let architecture: string | null | undefined = null;
if (currentDigest && isDockerDigest(currentDigest)) {
architecture = await this.getImageArchitecture(
registryHost,
dockerRepository,
currentDigest,
);
}
let manifestResponse: HttpResponse | null = null;
if (!architecture) {
// Reuse the digest cached from the Docker Hub tag API
if (registryHost === DOCKER_HUB) {
const cache = await DockerHubCache.init(dockerRepository);
const cachedDigest = cache.getDigestForTag(newTag);
if (cachedDigest) {
return cachedDigest;
}
}
manifestResponse = await this.getManifestResponse(
registryHost,
dockerRepository,
newTag,
'head',
);
if (
manifestResponse &&
hasKey('docker-content-digest', manifestResponse.headers)
) {
digest =
(manifestResponse.headers['docker-content-digest'] as string) ||
null;
}
}
if (
isNonEmptyString(architecture) ||
(manifestResponse &&
!hasKey('docker-content-digest', manifestResponse.headers))
) {
// Reuse the per-arch digest cached from the Docker Hub tag API
if (isNonEmptyString(architecture) && registryHost === DOCKER_HUB) {
const cache = await DockerHubCache.init(dockerRepository);
const cachedDigest = cache.getArchDigestForTag(newTag, architecture);
if (cachedDigest) {
return cachedDigest;
}
}
logger.debug(
{ registryHost, dockerRepository },
'Architecture-specific digest or missing docker-content-digest header - pulling full manifest',
);
manifestResponse = await this.getManifestResponse(
registryHost,
dockerRepository,
newTag,
);
if (architecture && manifestResponse) {
const parsed = ManifestJson.safeParse(manifestResponse.body);
/* istanbul ignore else: hard to test */
if (parsed.success) {
const manifestList = parsed.data;
if (
manifestList.mediaType ===
'application/vnd.docker.distribution.manifest.list.v2+json' ||
manifestList.mediaType ===
'application/vnd.oci.image.index.v1+json'
) {
for (const manifest of manifestList.manifests) {
if (manifest.platform?.architecture === architecture) {
digest = manifest.digest;
break;
}
}
// TODO: return null if no matching architecture digest found
// https://github.com/renovatebot/renovate/discussions/22639
} else if (
hasKey('docker-content-digest', manifestResponse.headers)
) {
// TODO: return null if no matching architecture, requires to fetch the config manifest
// https://github.com/renovatebot/renovate/discussions/22639
digest = manifestResponse.headers[
'docker-content-digest'
] as string;
}
} else {
logger.debug(
{
registryHost,
dockerRepository,
newTag,
body: manifestResponse.body,
headers: manifestResponse.headers,
err: parsed.error,
},
'Failed to parse manifest response',
);
}
}
if (!digest) {
logger.debug(
{ registryHost, dockerRepository, newTag },
'Extraction digest from manifest response body is deprecated',
);
digest = extractDigestFromResponseBody(manifestResponse!);
}
}
if (
!manifestResponse &&
!dockerRepository.includes('/') &&
!packageName.includes('/')
) {
logger.debug(
`Retrying Digest for ${registryHost}/${dockerRepository} using library/ prefix`,
);
return this.getDigest(
{
registryUrl,
packageName: `library/${packageName}`,
currentDigest,
},
newValue,
);
}
if (manifestResponse) {
// TODO: fix types (#22198)
logger.debug(`Got docker digest ${digest!}`);
}
} catch (err) /* istanbul ignore next */ {
if (err instanceof ExternalHostError) {
throw err;
}
logger.debug(
{
err,
packageName,
newTag,
},
'Unknown Error looking up docker image digest',
);
}
return digest;
}
override getDigest(
config: DigestConfig,
newValue?: string,
): Promise<string | null> {
const newTag = newValue ?? 'latest';
const { registryHost, dockerRepository } = getRegistryRepository(
config.packageName,
config.registryUrl!,
);
const digest = config.currentDigest ? `@${config.currentDigest}` : '';
return withCache(
{
namespace: 'datasource-docker-digest',
key: `${registryHost}:${dockerRepository}:${newTag}${digest}`,
fallback: true,
shouldCacheResult: isNonEmptyString,
},
() => this._getDigest(config, newValue),
);
}
private async _getDockerHubTags(
dockerRepository: string,
): Promise<Release[] | null> {
let url = `https://hub.docker.com/v2/repositories/${dockerRepository}/tags?page_size=1000&ordering=last_updated`;
const cache = await DockerHubCache.init(dockerRepository);
const maxPages = GlobalConfig.get('dockerMaxPages');
const allowCrossOrigin = isCrossOriginPaginationAllowed(dockerDatasourceId);
let page = 0,
needNextPage = true;
while (needNextPage && page < maxPages) {
const { val, err } = await this.http
.getJsonSafe(url, DockerHubTagsPage)
.unwrap();
if (err) {
logger.debug({ err }, `Docker: error fetching data from DockerHub`);
return null;
}
page++;
const { results, next, count } = val;
needNextPage = cache.reconcile(results, count);
if (!next) {
break;
}
// Only follow the `next` link when it's on the same origin, unless explicitly opted in
const nextUrl = resolvePaginationUrl(url, next, allowCrossOrigin);
if (!nextUrl) {
logger.once.warn(
{ dockerRepository, nextUrl: next },
'Ignoring cross-origin or invalid Docker Hub tags pagination link',
);
break;
}
url = nextUrl;
}
await cache.save();
const items = cache.getItems();
return items.map(({ name: version, tag_last_pushed }) => {
const release: Release = { version };
const releaseTimestamp = asTimestamp(tag_last_pushed);
if (releaseTimestamp) {
release.releaseTimestamp = releaseTimestamp;
}
// Digest is intentionally not propagated — the Docker Hub tag API
// returns the manifest-list digest, which would bypass arch-aware
// resolution in `getDigest()`. `getDigest()` consults the same cache
// as a shortcut when no arch resolution is needed.
return release;
});
}
getDockerHubTags(dockerRepository: string): Promise<Release[] | null> {
return withCache(
{
namespace: 'datasource-docker-hub-tags',
key: `${dockerRepository}`,
},
() => this._getDockerHubTags(dockerRepository),
);
}
/**
* docker.getReleases
*
* A docker image usually looks something like this: somehost.io/owner/repo:8.1.0-alpine
* In the above:
* - 'somehost.io' is the registry
* - 'owner/repo' is the package name
* - '8.1.0-alpine' is the tag
*
* This function will filter only tags that contain a semver version
*/
private async _getReleases({
packageName,
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
const { registryHost, dockerRepository } = getRegistryRepository(
packageName,
registryUrl!,
);
type TagsResultType = AsyncResult<
Release[],
NonNullable<Error | 'tags-error' | 'dockerhub-error'>
>;
const getTags = (): TagsResultType =>
Result.wrapNullable(
this.getTags(registryHost, dockerRepository),
'tags-error' as const,
).transform((tags) => tags.map((version) => ({ version })));
const getDockerHubTags = (): TagsResultType =>
Result.wrapNullable(
this.getDockerHubTags(dockerRepository),
'dockerhub-error' as const,
).catch(getTags);
const tagsResult =
registryHost === DOCKER_HUB &&
!getEnv().RENOVATE_X_DOCKER_HUB_TAGS_DISABLE
? getDockerHubTags()
: getTags();
const { val: releases, err } = await tagsResult.unwrap();
if (err instanceof Error) {
throw err;
} else if (err) {
return null;
}
const ret: ReleaseResult = {
registryUrl: registryHost,
releases,
};
if (dockerRepository !== packageName) {
// This will be reused later if a getDigest() call is made
ret.lookupName = dockerRepository;
}
const tags = releases.map((release) => release.version);
const latestTag = tags.includes('latest')
? 'latest'
: (findLatestStable(tags) ?? tags.at(-1));
/* v8 ignore next -- TODO: add test */
if (!latestTag) {
return ret;
}
const labels = await this.getLabels(
registryHost,
dockerRepository,
latestTag,
);
if (labels) {
if (isNonEmptyString(labels[gitRefLabel])) {
ret.gitRef = labels[gitRefLabel];
}
for (const label of sourceLabels) {
if (isNonEmptyString(labels[label])) {
ret.sourceUrl = labels[label];
break;
}
}
if (isNonEmptyString(labels[imageUrlLabel])) {
ret.homepage = labels[imageUrlLabel];
}
}
return ret;
}
getReleases(config: GetReleasesConfig): Promise<ReleaseResult | null> {
const { registryHost, dockerRepository } = getRegistryRepository(
config.packageName,
config.registryUrl!,
);
return withCache(
{
namespace: 'datasource-docker-releases-v2',
key: `${registryHost}:${dockerRepository}`,
cacheable: registryHost === DOCKER_HUB,
fallback: true,
},
() => this._getReleases(config),
);
}
}