mirror of https://github.com/renovatebot/renovate
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import is from '@sindresorhus/is';
|
|
import { parse } from 'auth-header';
|
|
import {
|
|
HOST_DISABLED,
|
|
PAGE_NOT_FOUND_ERROR,
|
|
} from '../../../constants/error-messages';
|
|
import { logger } from '../../../logger';
|
|
import type { HostRule } from '../../../types';
|
|
import { ExternalHostError } from '../../../types/errors/external-host-error';
|
|
import { coerceArray } from '../../../util/array';
|
|
import { detectPlatform } from '../../../util/common';
|
|
import { parseGitUrl } from '../../../util/git/url';
|
|
import { toSha256 } from '../../../util/hash';
|
|
import * as hostRules from '../../../util/host-rules';
|
|
import type { Http } from '../../../util/http';
|
|
import type {
|
|
HttpOptions,
|
|
HttpResponse,
|
|
OutgoingHttpHeaders,
|
|
} from '../../../util/http/types';
|
|
import { regEx } from '../../../util/regex';
|
|
import { addSecretForSanitizing } from '../../../util/sanitize';
|
|
import {
|
|
ensureTrailingSlash,
|
|
parseUrl,
|
|
trimTrailingSlash,
|
|
} from '../../../util/url';
|
|
import { api as dockerVersioning } from '../../versioning/docker';
|
|
import { getGoogleAuthToken } from '../util';
|
|
import { ecrRegex, getECRAuthToken } from './ecr';
|
|
import { googleRegex } from './google';
|
|
import type { OciHelmConfig } from './schema';
|
|
import type { RegistryRepository } from './types';
|
|
|
|
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 | null> {
|
|
try {
|
|
const options = {
|
|
throwHttpErrors: false,
|
|
noAuth: true,
|
|
};
|
|
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.getJson(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);
|
|
}
|
|
if (
|
|
apiCheckResponse.statusCode !== 401 ||
|
|
!is.nonEmptyString(apiCheckResponse.headers['www-authenticate'])
|
|
) {
|
|
logger.warn(
|
|
{ apiCheckUrl, res: apiCheckResponse },
|
|
'Invalid registry response',
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const authenticateHeader = parse(
|
|
apiCheckResponse.headers['www-authenticate'],
|
|
);
|
|
|
|
const opts: HostRule & HttpOptions = hostRules.find({
|
|
hostType: dockerDatasourceId,
|
|
url: apiCheckUrl,
|
|
});
|
|
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, opts);
|
|
if (auth) {
|
|
opts.headers = { authorization: `Basic ${auth}` };
|
|
}
|
|
} else if (
|
|
googleRegex.test(registryHost) &&
|
|
typeof opts.username === 'undefined' &&
|
|
typeof opts.password === 'undefined' &&
|
|
typeof opts.token === 'undefined'
|
|
) {
|
|
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 (opts.username && opts.password) {
|
|
logger.once.debug(`hostRules: basic auth for ${registryHost}`);
|
|
logger.trace(
|
|
{ registryHost, dockerRepository },
|
|
`Using basic auth for Docker registry`,
|
|
);
|
|
const auth = Buffer.from(`${opts.username}:${opts.password}`).toString(
|
|
'base64',
|
|
);
|
|
opts.headers = { authorization: `Basic ${auth}` };
|
|
} else if (opts.token) {
|
|
const authType = opts.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} ${opts.token}` };
|
|
}
|
|
delete opts.username;
|
|
delete opts.password;
|
|
delete opts.token;
|
|
|
|
// 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"
|
|
if (
|
|
authenticateHeader.scheme.toUpperCase() !== 'BEARER' ||
|
|
!is.string(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 ?? null;
|
|
}
|
|
|
|
const authUrl = new URL(`${authenticateHeader.params.realm}`);
|
|
|
|
// repo isn't known to server yet, so causing wrong scope `repository:user/image:pull`
|
|
if (
|
|
is.string(authenticateHeader.params.scope) &&
|
|
!apiCheckUrl.endsWith('/v2/')
|
|
) {
|
|
authUrl.searchParams.append('scope', authenticateHeader.params.scope);
|
|
} else {
|
|
authUrl.searchParams.append(
|
|
'scope',
|
|
`repository:${dockerRepository}:pull`,
|
|
);
|
|
}
|
|
|
|
if (is.string(authenticateHeader.params.service)) {
|
|
authUrl.searchParams.append('service', authenticateHeader.params.service);
|
|
}
|
|
|
|
logger.trace(
|
|
{ registryHost, dockerRepository, authUrl: authUrl.href },
|
|
`Obtaining docker registry token`,
|
|
);
|
|
opts.noAuth = true;
|
|
const authResponse = (
|
|
await http.getJson<{ token?: string; access_token?: string }>(
|
|
authUrl.href,
|
|
opts,
|
|
)
|
|
).body;
|
|
|
|
const token = authResponse.token ?? authResponse.access_token;
|
|
// istanbul ignore if
|
|
if (!token) {
|
|
logger.warn('Failed to obtain docker registry token');
|
|
return null;
|
|
}
|
|
// sanitize token
|
|
addSecretForSanitizing(token);
|
|
return {
|
|
authorization: `Bearer ${token}`,
|
|
};
|
|
} catch (err) /* istanbul ignore next */ {
|
|
if (err.host === 'quay.io') {
|
|
// TODO: debug why quay throws errors (#9604)
|
|
return null;
|
|
}
|
|
if (err.statusCode === 401) {
|
|
logger.debug(
|
|
{ registryHost, dockerRepository },
|
|
'Unauthorized docker lookup',
|
|
);
|
|
logger.debug({ err });
|
|
return null;
|
|
}
|
|
if (err.statusCode === 403) {
|
|
logger.debug(
|
|
{ registryHost, dockerRepository },
|
|
'Not allowed to access docker registry',
|
|
);
|
|
logger.debug({ err });
|
|
return null;
|
|
}
|
|
if (err.name === 'RequestError' && isDockerHost(registryHost)) {
|
|
throw new ExternalHostError(err);
|
|
}
|
|
if (err.statusCode === 429 && isDockerHost(registryHost)) {
|
|
throw new ExternalHostError(err);
|
|
}
|
|
if (err.statusCode >= 500 && err.statusCode < 600) {
|
|
throw new ExternalHostError(err);
|
|
}
|
|
if (err.message === PAGE_NOT_FOUND_ERROR) {
|
|
throw err;
|
|
}
|
|
if (err.message === HOST_DISABLED) {
|
|
logger.trace({ registryHost, dockerRepository, err }, 'Host disabled');
|
|
return null;
|
|
}
|
|
logger.warn(
|
|
{ registryHost, dockerRepository, err },
|
|
'Error obtaining docker token',
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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 { origin, pathname } = parseUrl(fullUrl)!;
|
|
registryHost = origin;
|
|
dockerRepository = 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 } =
|
|
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];
|
|
}
|