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

397 lines
13 KiB
TypeScript

import { isNonEmptyString, isString, isUndefined } from '@sindresorhus/is';
import {
HOST_DISABLED,
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 { coerceArray } from '../../../util/array.ts';
import { detectPlatform } from '../../../util/common.ts';
import { parseGitUrl } from '../../../util/git/url.ts';
import { toSha256 } from '../../../util/hash.ts';
import * as hostRules from '../../../util/host-rules.ts';
import { memCacheProvider } from '../../../util/http/cache/memory-http-cache-provider.ts';
import type { Http } from '../../../util/http/index.ts';
import type {
HttpOptions,
HttpResponse,
OutgoingHttpHeaders,
} from '../../../util/http/types.ts';
import type { ParamsChallenge } from '../../../util/http/www-authenticate.ts';
import { BearerScheme, parse } from '../../../util/http/www-authenticate.ts';
import { coerceObject } from '../../../util/object.ts';
import { regEx } from '../../../util/regex.ts';
import { addSecretForSanitizing } from '../../../util/sanitize.ts';
import {
ensureTrailingSlash,
parseUrl,
trimTrailingSlash,
} from '../../../util/url.ts';
import { api as dockerVersioning } from '../../versioning/docker/index.ts';
import { getGoogleAuthToken } from '../util.ts';
import { ecrRegex, getECRAuthToken, isECRMaxResultsResponse } from './ecr.ts';
import { googleRegex } from './google.ts';
import type { OciHelmConfig } from './schema.ts';
import { RegistryAuthToken } from './schema.ts';
import type { RegistryRepository } from './types.ts';
export const dockerDatasourceId = 'docker';
export const imageUrlLabel = 'org.opencontainers.image.url';
export const sourceLabel = 'org.opencontainers.image.source';
export const sourceLabels = [sourceLabel, 'org.label-schema.vcs-url'] as const;
export const gitRefLabel = 'org.opencontainers.image.revision';
export const DOCKER_HUB = 'https://index.docker.io';
export function isDockerHost(host: string): boolean {
const regex = regEx(/(?:^|\.)docker\.io$/);
return regex.test(host);
}
export async function getAuthHeaders(
http: Http,
registryHost: string,
dockerRepository: string,
apiCheckUrl = `${registryHost}/v2/`,
): Promise<OutgoingHttpHeaders | undefined> {
try {
const options = {
throwHttpErrors: false,
noAuth: true,
cacheProvider: memCacheProvider,
};
const apiCheckResponse = apiCheckUrl.endsWith('/v2/')
? await http.get(apiCheckUrl, options)
: // use json request, as this will be cached for tags, so it returns json
// TODO: add cache test
await http.getJsonUnchecked(apiCheckUrl, options);
if (apiCheckResponse.statusCode === 200) {
logger.debug(`No registry auth required for ${apiCheckUrl}`);
return {};
}
if (apiCheckResponse.statusCode === 404) {
logger.debug(`Page Not Found ${apiCheckUrl}`);
// throw error up to be caught and potentially retried with library/ prefix
throw new Error(PAGE_NOT_FOUND_ERROR);
}
// Some ECR-compatible private registries (e.g. corporate Docker proxies) reject
// n>1000 with 405 even on the auth-probe request. Fall back to probing the base
// /v2/ endpoint so getAuthHeaders can still obtain a valid token; the main fetch
// loop already retries with n=1000 when it encounters this same error.
if (isECRMaxResultsResponse(apiCheckResponse)) {
logger.debug(
{ apiCheckUrl },
'Registry rejected n>1000 on auth probe; retrying auth check via base /v2/ endpoint',
);
return getAuthHeaders(http, registryHost, dockerRepository);
}
if (
apiCheckResponse.statusCode !== 401 ||
!isNonEmptyString(apiCheckResponse.headers['www-authenticate'])
) {
logger.warn(
{ apiCheckUrl, res: apiCheckResponse },
'Invalid registry response',
);
return undefined;
}
const rule = hostRules.find({
hostType: dockerDatasourceId,
url: apiCheckUrl,
});
const opts: HttpOptions = {};
if (ecrRegex.test(registryHost)) {
logger.once.debug(`hostRules: ecr auth for ${registryHost}`);
logger.trace(
{ registryHost, dockerRepository },
`Using ecr auth for Docker registry`,
);
const [, region] = coerceArray(ecrRegex.exec(registryHost));
const auth = await getECRAuthToken(region, rule);
if (auth) {
opts.headers = { authorization: `Basic ${auth}` };
}
} else if (
googleRegex.test(registryHost) &&
isUndefined(rule.username) &&
isUndefined(rule.password) &&
isUndefined(rule.token)
) {
logger.once.debug(`hostRules: google auth for ${registryHost}`);
logger.trace(
{ registryHost, dockerRepository },
`Using google auth for Docker registry`,
);
const auth = await getGoogleAuthToken();
if (auth) {
opts.headers = { authorization: `Basic ${auth}` };
} else {
logger.once.debug(
{ registryHost, dockerRepository },
'Could not get Google access token, using no auth',
);
}
} else if (rule.username && rule.password) {
logger.once.debug(`hostRules: basic auth for ${registryHost}`);
logger.trace(
{ registryHost, dockerRepository },
`Using basic auth for Docker registry`,
);
const auth = Buffer.from(`${rule.username}:${rule.password}`).toString(
'base64',
);
opts.headers = { authorization: `Basic ${auth}` };
} else if (rule.token) {
const authType = rule.authType ?? 'Bearer';
logger.once.debug(
`hostRules: ${authType} token auth for ${registryHost}`,
);
logger.trace(
{ registryHost, dockerRepository },
`Using ${authType} token for Docker registry`,
);
opts.headers = { authorization: `${authType} ${rule.token}` };
}
const challenges = parse(apiCheckResponse.headers['www-authenticate']);
const authenticateHeader = challenges.find(
(c): c is ParamsChallenge => c.scheme === BearerScheme,
);
// If realm isn't an url, we should directly use auth header
// Can happen when we get a Basic auth or some other auth type
// * WWW-Authenticate: Basic realm="Artifactory Realm"
// * Www-Authenticate: Basic realm="https://123456789.dkr.ecr.eu-central-1.amazonaws.com/",service="ecr.amazonaws.com"
// * www-authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"
// * www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io"
// * www-authenticate: Bearer realm="https://codeberg.org/v2/token",service="container_registry",scope="*",Basic realm="https://codeberg.org/v2",service="container_registry",scope="*"
if (
!authenticateHeader ||
!isString(authenticateHeader.params?.realm) ||
parseUrl(authenticateHeader.params.realm) === null
) {
logger.once.debug(`hostRules: testing direct auth for ${registryHost}`);
logger.trace(
{ registryHost, dockerRepository, authenticateHeader },
`Invalid realm, testing direct auth`,
);
return opts.headers ?? undefined;
}
// already guarded by above clause
const authUrl = parseUrl(`${authenticateHeader.params.realm}`)!;
// repo isn't known to server yet, so causing wrong scope `repository:user/image:pull`
if (
isString(authenticateHeader.params.scope) &&
!apiCheckUrl.endsWith('/v2/')
) {
authUrl.searchParams.append('scope', authenticateHeader.params.scope);
} else {
authUrl.searchParams.append(
'scope',
`repository:${dockerRepository}:pull`,
);
}
if (isString(authenticateHeader.params.service)) {
authUrl.searchParams.append('service', authenticateHeader.params.service);
}
logger.trace(
{ registryHost, dockerRepository, authUrl: authUrl.href },
`Obtaining docker registry token`,
);
opts.noAuth = true;
opts.cacheProvider = memCacheProvider;
const authResponse = (
await http.getJson(authUrl.href, opts, RegistryAuthToken)
).body;
const token = authResponse.token ?? authResponse.access_token;
/* v8 ignore next -- TODO: add test */
if (!token) {
logger.warn('Failed to obtain docker registry token');
return undefined;
}
// sanitize token
addSecretForSanitizing(token);
return {
authorization: `Bearer ${token}`,
};
} catch (err) /* istanbul ignore next */ {
/* v8 ignore if -- quay.io errors are swallowed pending #9604, not reproduced in specs */
if (err.host === 'quay.io') {
// TODO: debug why quay throws errors (#9604)
return undefined;
}
/* v8 ignore if -- registry auth rejection is logged and swallowed, not mocked in specs */
if (err.statusCode === 401) {
logger.debug(
{ registryHost, dockerRepository },
'Unauthorized docker lookup',
);
logger.debug({ err });
return undefined;
}
/* v8 ignore if -- registry permission rejection is logged and swallowed, not mocked in specs */
if (err.statusCode === 403) {
logger.debug(
{ registryHost, dockerRepository },
'Not allowed to access docker registry',
);
logger.debug({ err });
return undefined;
}
if (err.name === 'RequestError' && isDockerHost(registryHost)) {
throw new ExternalHostError(err);
}
/* v8 ignore if -- Docker Hub rate limiting maps to ExternalHostError, not mocked in specs */
if (err.statusCode === 429 && isDockerHost(registryHost)) {
throw new ExternalHostError(err);
}
/* v8 ignore if -- registry server errors map to ExternalHostError, not mocked in specs */
if (err.statusCode >= 500 && err.statusCode < 600) {
throw new ExternalHostError(err);
}
if (err.message === PAGE_NOT_FOUND_ERROR) {
throw err;
}
/* v8 ignore if -- hostRules-disabled host is swallowed silently, not mocked in specs */
if (err.message === HOST_DISABLED) {
logger.trace({ registryHost, dockerRepository, err }, 'Host disabled');
return undefined;
}
logger.warn(
{ registryHost, dockerRepository, err },
'Error obtaining docker token',
);
return undefined;
}
}
export function getRegistryRepository(
packageName: string,
registryUrl: string,
): RegistryRepository {
if (registryUrl !== DOCKER_HUB) {
const registryEndingWithSlash = ensureTrailingSlash(
registryUrl.replace(regEx(/^https?:\/\//), ''),
);
if (packageName.startsWith(registryEndingWithSlash)) {
let registryHost = trimTrailingSlash(registryUrl);
if (!regEx(/^https?:\/\//).test(registryHost)) {
registryHost = `https://${registryHost}`;
}
let dockerRepository = packageName.replace(registryEndingWithSlash, '');
const fullUrl = `${registryHost}/${dockerRepository}`;
const parsedFullUrl = parseUrl(fullUrl);
if (!parsedFullUrl) {
return { registryHost, dockerRepository };
}
registryHost = parsedFullUrl.origin;
dockerRepository = parsedFullUrl.pathname.substring(1);
return {
registryHost,
dockerRepository,
};
}
}
let registryHost = registryUrl;
const split = packageName.split('/');
if (split.length > 1 && (split[0].includes('.') || split[0].includes(':'))) {
[registryHost] = split;
split.shift();
}
let dockerRepository = split.join('/');
if (!regEx(/^https?:\/\//).test(registryHost)) {
registryHost = `https://${registryHost}`;
}
const { path, base } = coerceObject(
regEx(/^(?<base>https:\/\/[^/]+)\/(?<path>.+)$/).exec(registryHost)?.groups,
);
if (base && path) {
registryHost = base;
dockerRepository = `${trimTrailingSlash(path)}/${dockerRepository}`;
}
registryHost = registryHost
.replace('https://docker.io', 'https://index.docker.io')
.replace('https://registry-1.docker.io', 'https://index.docker.io');
const opts = hostRules.find({
hostType: dockerDatasourceId,
url: registryHost,
});
if (opts?.insecureRegistry) {
registryHost = registryHost.replace('https', 'http');
}
if (registryHost.endsWith('.docker.io') && !dockerRepository.includes('/')) {
dockerRepository = `library/${dockerRepository}`;
}
return {
registryHost,
dockerRepository,
};
}
export function extractDigestFromResponseBody(
manifestResponse: HttpResponse,
): string {
return `sha256:${toSha256(manifestResponse.body)}`;
}
export function findLatestStable(tags: string[]): string | null {
let stable: string | null = null;
for (const tag of tags) {
if (!dockerVersioning.isValid(tag) || !dockerVersioning.isStable(tag)) {
continue;
}
if (!stable || dockerVersioning.isGreaterThan(tag, stable)) {
stable = tag;
}
}
return stable;
}
const chartRepo = regEx(/charts?|helm|helm-charts?/i);
function isPossibleChartRepo(url: string): boolean {
if (detectPlatform(url) === null) {
return false;
}
const parsed = parseGitUrl(url);
return chartRepo.test(parsed.name);
}
export function findHelmSourceUrl(release: OciHelmConfig): string | null {
if (release.home && isPossibleChartRepo(release.home)) {
return release.home;
}
if (!release.sources?.length) {
return null;
}
for (const url of release.sources) {
if (isPossibleChartRepo(url)) {
return url;
}
}
// fallback
return release.sources[0];
}