mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-31 17:51:49 +00:00
1111 lines
32 KiB
TypeScript
1111 lines
32 KiB
TypeScript
import URL from 'url';
|
|
import { ECR } from '@aws-sdk/client-ecr';
|
|
import type { ECRClientConfig } from '@aws-sdk/client-ecr';
|
|
import is from '@sindresorhus/is';
|
|
import { parse } from 'auth-header';
|
|
import hasha from 'hasha';
|
|
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 { cache } from '../../../util/cache/package/decorator';
|
|
import * as hostRules from '../../../util/host-rules';
|
|
import { Http, HttpError } from '../../../util/http';
|
|
import type {
|
|
HttpOptions,
|
|
HttpResponse,
|
|
OutgoingHttpHeaders,
|
|
} from '../../../util/http/types';
|
|
import { hasKey } from '../../../util/object';
|
|
import { regEx } from '../../../util/regex';
|
|
import {
|
|
ensurePathPrefix,
|
|
ensureTrailingSlash,
|
|
joinUrlParts,
|
|
parseLinkHeader,
|
|
parseUrl,
|
|
trimTrailingSlash,
|
|
} from '../../../util/url';
|
|
import {
|
|
api as dockerVersioning,
|
|
id as dockerVersioningId,
|
|
} from '../../versioning/docker';
|
|
import { Datasource } from '../datasource';
|
|
import type { DigestConfig, GetReleasesConfig, ReleaseResult } from '../types';
|
|
import { gitRefLabel, sourceLabels } from './common';
|
|
import {
|
|
Image,
|
|
ImageConfig,
|
|
ImageList,
|
|
MediaType,
|
|
OciImage,
|
|
OciImageList,
|
|
RegistryRepository,
|
|
} from './types';
|
|
|
|
export const DOCKER_HUB = 'https://index.docker.io';
|
|
|
|
export const ecrRegex = regEx(/\d+\.dkr\.ecr\.([-a-z0-9]+)\.amazonaws\.com/);
|
|
|
|
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({ apiCheckUrl }, 'No registry auth required');
|
|
return {};
|
|
}
|
|
if (apiCheckResponse.statusCode === 404) {
|
|
logger.debug({ apiCheckUrl }, 'Page Not Found');
|
|
// 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: DockerDatasource.id,
|
|
url: apiCheckUrl,
|
|
});
|
|
if (ecrRegex.test(registryHost)) {
|
|
logger.trace(
|
|
{ registryHost, dockerRepository },
|
|
`Using ecr auth for Docker registry`
|
|
);
|
|
const [, region] = ecrRegex.exec(registryHost) ?? [];
|
|
const auth = await getECRAuthToken(region, opts);
|
|
if (auth) {
|
|
opts.headers = { authorization: `Basic ${auth}` };
|
|
}
|
|
} else if (opts.username && opts.password) {
|
|
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.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.trace(
|
|
{ registryHost, dockerRepository, authenticateHeader },
|
|
`Invalid realm, testing direct auth`
|
|
);
|
|
return opts.headers ?? null;
|
|
}
|
|
|
|
let scope = `repository:${dockerRepository}:pull`;
|
|
// repo isn't known to server yet, so causing wrong scope `repository:user/image:pull`
|
|
if (
|
|
is.string(authenticateHeader.params.scope) &&
|
|
!apiCheckUrl.endsWith('/v2/')
|
|
) {
|
|
scope = authenticateHeader.params.scope;
|
|
}
|
|
|
|
let service = authenticateHeader.params.service;
|
|
if (!is.string(service)) {
|
|
service = '';
|
|
}
|
|
const authUrl = `${authenticateHeader.params.realm}?service=${service}&scope=${scope}`;
|
|
logger.trace(
|
|
{ registryHost, dockerRepository, authUrl },
|
|
`Obtaining docker registry token`
|
|
);
|
|
opts.noAuth = true;
|
|
const authResponse = (
|
|
await http.getJson<{ token?: string; access_token?: string }>(
|
|
authUrl,
|
|
opts
|
|
)
|
|
).body;
|
|
|
|
const token = authResponse.token ?? authResponse.access_token;
|
|
// istanbul ignore if
|
|
if (!token) {
|
|
logger.warn('Failed to obtain docker registry token');
|
|
return null;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
async function getECRAuthToken(
|
|
region: string | undefined,
|
|
opts: HostRule
|
|
): Promise<string | null> {
|
|
const config: ECRClientConfig = { region };
|
|
if (opts.username && opts.password) {
|
|
config.credentials = {
|
|
accessKeyId: opts.username,
|
|
secretAccessKey: opts.password,
|
|
...(opts.token && { sessionToken: opts.token }),
|
|
};
|
|
}
|
|
|
|
const ecr = new ECR(config);
|
|
try {
|
|
const data = await ecr.getAuthorizationToken({});
|
|
const authorizationToken = data?.authorizationData?.[0]?.authorizationToken;
|
|
if (authorizationToken) {
|
|
return authorizationToken;
|
|
}
|
|
logger.warn(
|
|
'Could not extract authorizationToken from ECR getAuthorizationToken response'
|
|
);
|
|
} catch (err) {
|
|
logger.trace({ err }, 'err');
|
|
logger.debug('ECR getAuthorizationToken error');
|
|
}
|
|
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: string | undefined;
|
|
const split = packageName.split('/');
|
|
if (split.length > 1 && (split[0].includes('.') || split[0].includes(':'))) {
|
|
[registryHost] = split;
|
|
split.shift();
|
|
}
|
|
let dockerRepository = split.join('/');
|
|
if (!registryHost) {
|
|
registryHost = registryUrl.replace(
|
|
'https://docker.io',
|
|
'https://index.docker.io'
|
|
);
|
|
}
|
|
if (registryHost === 'docker.io') {
|
|
registryHost = 'index.docker.io';
|
|
}
|
|
if (!regEx(/^https?:\/\//).exec(registryHost)) {
|
|
registryHost = `https://${registryHost}`;
|
|
}
|
|
const opts = hostRules.find({
|
|
hostType: DockerDatasource.id,
|
|
url: registryHost,
|
|
});
|
|
if (opts?.insecureRegistry) {
|
|
registryHost = registryHost.replace('https', 'http');
|
|
}
|
|
if (registryHost.endsWith('.docker.io') && !dockerRepository.includes('/')) {
|
|
dockerRepository = 'library/' + dockerRepository;
|
|
}
|
|
return {
|
|
registryHost,
|
|
dockerRepository,
|
|
};
|
|
}
|
|
|
|
function digestFromManifestStr(str: hasha.HashaInput): string {
|
|
return 'sha256:' + hasha(str, { algorithm: 'sha256' });
|
|
}
|
|
|
|
export function extractDigestFromResponseBody(
|
|
manifestResponse: HttpResponse
|
|
): string {
|
|
return digestFromManifestStr(manifestResponse.body);
|
|
}
|
|
|
|
export function isECRMaxResultsError(err: HttpError): boolean {
|
|
const resp = err.response as HttpResponse<any> | undefined;
|
|
return !!(
|
|
resp?.statusCode === 405 &&
|
|
resp.headers?.['docker-distribution-api-version'] &&
|
|
// https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_DescribeRepositories.html#ECR-DescribeRepositories-request-maxResults
|
|
resp.body?.['errors']?.[0]?.message?.includes(
|
|
'Member must have value less than or equal to 1000'
|
|
)
|
|
);
|
|
}
|
|
|
|
const defaultConfig = {
|
|
commitMessageTopic: '{{{depName}}} Docker tag',
|
|
commitMessageExtra:
|
|
'to {{#if isMajor}}{{{prettyNewMajor}}}{{else}}{{{prettyNewVersion}}}{{/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',
|
|
},
|
|
},
|
|
};
|
|
|
|
function findLatestStable(tags: string[]): string | null {
|
|
const versions = tags
|
|
.filter((v) => dockerVersioning.isValid(v) && dockerVersioning.isStable(v))
|
|
.sort((a, b) => dockerVersioning.sortVersions(a, b));
|
|
|
|
return versions.pop() ?? tags.slice(-1).pop() ?? null;
|
|
}
|
|
|
|
export class DockerDatasource extends Datasource {
|
|
static readonly id = 'docker';
|
|
|
|
override readonly defaultVersioning = dockerVersioningId;
|
|
|
|
override readonly defaultRegistryUrls = [DOCKER_HUB];
|
|
|
|
override readonly defaultConfig = defaultConfig;
|
|
|
|
constructor() {
|
|
super(DockerDatasource.id);
|
|
}
|
|
|
|
// TODO: debug why quay throws errors (#9612)
|
|
private async getManifestResponse(
|
|
registryHost: string,
|
|
dockerRepository: string,
|
|
tag: string,
|
|
mode: 'head' | 'get' = 'get'
|
|
): Promise<HttpResponse | null> {
|
|
logger.debug(
|
|
`getManifestResponse(${registryHost}, ${dockerRepository}, ${tag})`
|
|
);
|
|
try {
|
|
const headers = await getAuthHeaders(
|
|
this.http,
|
|
registryHost,
|
|
dockerRepository
|
|
);
|
|
if (!headers) {
|
|
logger.warn('No docker auth found - returning');
|
|
return null;
|
|
}
|
|
headers.accept = [
|
|
MediaType.manifestListV2,
|
|
MediaType.manifestV2,
|
|
MediaType.ociManifestV1,
|
|
MediaType.ociManifestIndexV1,
|
|
].join(', ');
|
|
const url = `${registryHost}/v2/${dockerRepository}/manifests/${tag}`;
|
|
const manifestResponse = await this.http[mode](url, {
|
|
headers,
|
|
noAuth: true,
|
|
});
|
|
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;
|
|
}
|
|
}
|
|
|
|
@cache({
|
|
namespace: 'datasource-docker-imageconfig',
|
|
key: (
|
|
registryHost: string,
|
|
dockerRepository: string,
|
|
configDigest: string
|
|
) => `${registryHost}:${dockerRepository}@${configDigest}`,
|
|
ttlMinutes: 1440 * 28,
|
|
})
|
|
public async getImageConfig(
|
|
registryHost: string,
|
|
dockerRepository: string,
|
|
configDigest: string
|
|
): Promise<HttpResponse<ImageConfig> | undefined> {
|
|
logger.trace(
|
|
`getImageConfig(${registryHost}, ${dockerRepository}, ${configDigest})`
|
|
);
|
|
|
|
const headers = await getAuthHeaders(
|
|
this.http,
|
|
registryHost,
|
|
dockerRepository
|
|
);
|
|
// istanbul ignore if: 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<ImageConfig>(url, {
|
|
headers,
|
|
noAuth: true,
|
|
});
|
|
}
|
|
|
|
private async getConfigDigest(
|
|
registry: string,
|
|
dockerRepository: string,
|
|
tag: string
|
|
): Promise<string | 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
|
|
// istanbul ignore if
|
|
if (!manifestResponse) {
|
|
return null;
|
|
}
|
|
const manifest = JSON.parse(manifestResponse.body) as
|
|
| ImageList
|
|
| Image
|
|
| OciImageList
|
|
| OciImage;
|
|
if (manifest.schemaVersion !== 2) {
|
|
logger.debug(
|
|
{ registry, dockerRepository, tag },
|
|
'Manifest schema version is not 2'
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (manifest.mediaType === MediaType.manifestListV2) {
|
|
if (manifest.manifests.length) {
|
|
logger.trace(
|
|
{ registry, dockerRepository, tag },
|
|
'Found manifest list, using first image'
|
|
);
|
|
return this.getConfigDigest(
|
|
registry,
|
|
dockerRepository,
|
|
manifest.manifests[0].digest
|
|
);
|
|
} else {
|
|
logger.debug(
|
|
{ manifest },
|
|
'Invalid manifest list with no manifests - returning'
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (
|
|
manifest.mediaType === MediaType.manifestV2 &&
|
|
is.string(manifest.config?.digest)
|
|
) {
|
|
return manifest.config?.digest;
|
|
}
|
|
|
|
// OCI image lists are not required to specify a mediaType
|
|
if (
|
|
manifest.mediaType === MediaType.ociManifestIndexV1 ||
|
|
(!manifest.mediaType && hasKey('manifests', manifest))
|
|
) {
|
|
if (manifest.manifests.length) {
|
|
logger.trace(
|
|
{ registry, dockerRepository, tag },
|
|
'Found manifest index, using first image'
|
|
);
|
|
return this.getConfigDigest(
|
|
registry,
|
|
dockerRepository,
|
|
manifest.manifests[0].digest
|
|
);
|
|
} else {
|
|
logger.debug(
|
|
{ manifest },
|
|
'Invalid manifest index with no manifests - returning'
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// OCI manifests are not required to specify a mediaType
|
|
if (
|
|
(manifest.mediaType === MediaType.ociManifestV1 ||
|
|
(!manifest.mediaType && hasKey('config', manifest))) &&
|
|
is.string(manifest.config?.digest)
|
|
) {
|
|
return manifest.config?.digest;
|
|
}
|
|
|
|
logger.debug({ manifest }, 'Invalid manifest - returning');
|
|
return null;
|
|
}
|
|
|
|
@cache({
|
|
namespace: 'datasource-docker-architecture',
|
|
key: (
|
|
registryHost: string,
|
|
dockerRepository: string,
|
|
currentDigest: string
|
|
) => `${registryHost}:${dockerRepository}@${currentDigest}`,
|
|
ttlMinutes: 1440 * 28,
|
|
})
|
|
public async getImageArchitecture(
|
|
registryHost: string,
|
|
dockerRepository: string,
|
|
currentDigest: string
|
|
): Promise<string | null | undefined> {
|
|
try {
|
|
const manifestResponse = await this.getManifestResponse(
|
|
registryHost,
|
|
dockerRepository,
|
|
currentDigest,
|
|
'head'
|
|
);
|
|
|
|
if (
|
|
manifestResponse?.headers['content-type'] !== MediaType.manifestV2 &&
|
|
manifestResponse?.headers['content-type'] !== MediaType.ociManifestV1
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const configDigest = await this.getConfigDigest(
|
|
registryHost,
|
|
dockerRepository,
|
|
currentDigest
|
|
);
|
|
if (!configDigest) {
|
|
return null;
|
|
}
|
|
|
|
const configResponse = await this.getImageConfig(
|
|
registryHost,
|
|
dockerRepository,
|
|
configDigest
|
|
);
|
|
if (configResponse) {
|
|
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;
|
|
}
|
|
|
|
/*
|
|
* docker.getLabels
|
|
*
|
|
* This function will:
|
|
* - Return the labels for the requested image
|
|
*/
|
|
@cache({
|
|
namespace: 'datasource-docker-labels',
|
|
key: (registryHost: string, dockerRepository: string, tag: string) =>
|
|
`${registryHost}:${dockerRepository}:${tag}`,
|
|
ttlMinutes: 60,
|
|
})
|
|
public async getLabels(
|
|
registryHost: string,
|
|
dockerRepository: string,
|
|
tag: string
|
|
): Promise<Record<string, string>> {
|
|
logger.debug(`getLabels(${registryHost}, ${dockerRepository}, ${tag})`);
|
|
try {
|
|
let labels: Record<string, string> = {};
|
|
const configDigest = await this.getConfigDigest(
|
|
registryHost,
|
|
dockerRepository,
|
|
tag
|
|
);
|
|
if (!configDigest) {
|
|
return {};
|
|
}
|
|
|
|
const headers = await getAuthHeaders(
|
|
this.http,
|
|
registryHost,
|
|
dockerRepository
|
|
);
|
|
// istanbul ignore if: Should never happen
|
|
if (!headers) {
|
|
logger.warn('No docker auth found - returning');
|
|
return {};
|
|
}
|
|
const url = `${registryHost}/v2/${dockerRepository}/blobs/${configDigest}`;
|
|
const configResponse = await this.http.get(url, {
|
|
headers,
|
|
noAuth: true,
|
|
});
|
|
labels = JSON.parse(configResponse.body).config.Labels;
|
|
|
|
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 {};
|
|
}
|
|
}
|
|
|
|
private async getTagsQuayRegistry(
|
|
registry: string,
|
|
repository: string
|
|
): Promise<string[]> {
|
|
let tags: string[] = [];
|
|
const limit = 100;
|
|
|
|
const pageUrl = (page: number): string =>
|
|
`${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) {
|
|
interface QuayRestDockerTags {
|
|
tags: {
|
|
name: string;
|
|
}[];
|
|
has_additional: boolean;
|
|
}
|
|
|
|
// typescript issue :-/
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
const res = (await this.http.getJson<QuayRestDockerTags>(
|
|
url
|
|
)) as HttpResponse<QuayRestDockerTags>;
|
|
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[] | null> {
|
|
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
|
|
const limit = ecrRegex.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 null;
|
|
}
|
|
let page = 1;
|
|
let foundMaxResultsError = false;
|
|
do {
|
|
let res: HttpResponse<{ tags: string[] }>;
|
|
try {
|
|
res = await this.http.getJson<{ tags: string[] }>(url, {
|
|
headers,
|
|
noAuth: true,
|
|
});
|
|
} 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);
|
|
url = linkHeader?.next ? URL.resolve(url, linkHeader.next.url) : null;
|
|
page += 1;
|
|
} while (url && page < 20);
|
|
return tags;
|
|
}
|
|
|
|
@cache({
|
|
namespace: 'datasource-docker-tags',
|
|
key: (registryHost: string, dockerRepository: string) =>
|
|
`${registryHost}:${dockerRepository}`,
|
|
})
|
|
public async getTags(
|
|
registryHost: string,
|
|
dockerRepository: string
|
|
): Promise<string[] | null> {
|
|
try {
|
|
const isQuay = regEx(/^https:\/\/quay\.io(?::[1-9][0-9]{0,4})?$/i).test(
|
|
registryHost
|
|
);
|
|
let tags: string[] | null;
|
|
if (isQuay) {
|
|
tags = await this.getTagsQuayRegistry(registryHost, dockerRepository);
|
|
} else {
|
|
tags = await this.getDockerApiTags(registryHost, dockerRepository);
|
|
}
|
|
return tags;
|
|
} catch (err) /* istanbul ignore next */ {
|
|
if (err instanceof ExternalHostError) {
|
|
throw 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);
|
|
}
|
|
// prettier-ignore
|
|
if (err.statusCode === 429 && isDockerHost(registryHost)) {
|
|
logger.warn(
|
|
{ registryHost, dockerRepository, err },
|
|
'docker registry failure: too many requests'
|
|
);
|
|
throw new ExternalHostError(err);
|
|
}
|
|
// prettier-ignore
|
|
if (err.statusCode === 401 && isDockerHost(registryHost)) {
|
|
logger.warn(
|
|
{ registryHost, dockerRepository, err },
|
|
'docker registry failure: unauthorized'
|
|
);
|
|
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);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
@cache({
|
|
namespace: 'datasource-docker-digest',
|
|
key: (
|
|
{ registryUrl, packageName, currentDigest }: DigestConfig,
|
|
newValue?: string
|
|
) => {
|
|
const newTag = newValue ?? 'latest';
|
|
const { registryHost, dockerRepository } = getRegistryRepository(
|
|
packageName,
|
|
registryUrl!
|
|
);
|
|
const digest = currentDigest ? `@${currentDigest}` : '';
|
|
return `${registryHost}:${dockerRepository}:${newTag}${digest}`;
|
|
},
|
|
})
|
|
override async getDigest(
|
|
{ registryUrl, packageName, currentDigest }: DigestConfig,
|
|
newValue?: string
|
|
): Promise<string | null> {
|
|
const { registryHost, dockerRepository } = getRegistryRepository(
|
|
packageName,
|
|
registryUrl!
|
|
);
|
|
logger.debug(
|
|
// TODO: types (#7154)
|
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
|
`getDigest(${registryHost}, ${dockerRepository}, ${newValue})`
|
|
);
|
|
const newTag = newValue ?? 'latest';
|
|
let digest: string | null = null;
|
|
try {
|
|
let architecture: string | null | undefined = null;
|
|
if (currentDigest) {
|
|
architecture = await this.getImageArchitecture(
|
|
registryHost,
|
|
dockerRepository,
|
|
currentDigest
|
|
);
|
|
}
|
|
|
|
let manifestResponse: HttpResponse | null = null;
|
|
if (!architecture) {
|
|
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 (
|
|
architecture ||
|
|
(manifestResponse &&
|
|
!hasKey('docker-content-digest', manifestResponse.headers))
|
|
) {
|
|
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 manifestList = JSON.parse(manifestResponse.body) as
|
|
| ImageList
|
|
| Image
|
|
| OciImageList
|
|
| OciImage;
|
|
if (
|
|
manifestList.schemaVersion === 2 &&
|
|
(manifestList.mediaType === MediaType.manifestListV2 ||
|
|
manifestList.mediaType === MediaType.ociManifestIndexV1 ||
|
|
(!manifestList.mediaType && hasKey('manifests', manifestList)))
|
|
) {
|
|
for (const manifest of manifestList.manifests) {
|
|
if (manifest.platform['architecture'] === architecture) {
|
|
digest = manifest.digest;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!digest) {
|
|
digest = extractDigestFromResponseBody(manifestResponse!);
|
|
}
|
|
}
|
|
|
|
if (manifestResponse) {
|
|
logger.debug({ digest }, 'Got docker 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;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
async getReleases({
|
|
packageName,
|
|
registryUrl,
|
|
}: GetReleasesConfig): Promise<ReleaseResult | null> {
|
|
const { registryHost, dockerRepository } = getRegistryRepository(
|
|
packageName,
|
|
registryUrl!
|
|
);
|
|
const tags = await this.getTags(registryHost, dockerRepository);
|
|
if (!tags) {
|
|
return null;
|
|
}
|
|
const releases = tags.map((version) => ({ version }));
|
|
const ret: ReleaseResult = {
|
|
registryUrl: registryHost,
|
|
releases,
|
|
};
|
|
|
|
const latestTag = tags.includes('latest')
|
|
? 'latest'
|
|
: findLatestStable(tags);
|
|
|
|
// istanbul ignore if: needs test
|
|
if (!latestTag) {
|
|
return ret;
|
|
}
|
|
const labels = await this.getLabels(
|
|
registryHost,
|
|
dockerRepository,
|
|
latestTag
|
|
);
|
|
if (labels) {
|
|
if (is.nonEmptyString(labels[gitRefLabel])) {
|
|
ret.gitRef = labels[gitRefLabel];
|
|
}
|
|
for (const label of sourceLabels) {
|
|
if (is.nonEmptyString(labels[label])) {
|
|
ret.sourceUrl = labels[label];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
}
|