diff --git a/lib/datasource/docker/__snapshots__/common.spec.ts.snap b/lib/datasource/docker/__snapshots__/common.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..23bcfb457646278737201286dcad995d2f19f91a --- /dev/null +++ b/lib/datasource/docker/__snapshots__/common.spec.ts.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`datasource/docker/common getRegistryRepository handles local registries 1`] = ` +Object { + "registry": "https://registry:5000", + "repository": "org/package", +} +`; + +exports[`datasource/docker/common getRegistryRepository supports http registryUrls 1`] = ` +Object { + "registry": "http://my.local.registry/prefix", + "repository": "image", +} +`; + +exports[`datasource/docker/common getRegistryRepository supports registryUrls 1`] = ` +Object { + "registry": "https://my.local.registry/prefix", + "repository": "image", +} +`; + +exports[`datasource/docker/common getRegistryRepository supports schemeless registryUrls 1`] = ` +Object { + "registry": "https://my.local.registry/prefix", + "repository": "image", +} +`; diff --git a/lib/datasource/docker/__snapshots__/index.spec.ts.snap b/lib/datasource/docker/__snapshots__/index.spec.ts.snap index 1cbbf983b7e2fd9904110ab2037382a366b9e0c8..d0768acadcd25d47b7a05f305077865e2159b37f 100644 --- a/lib/datasource/docker/__snapshots__/index.spec.ts.snap +++ b/lib/datasource/docker/__snapshots__/index.spec.ts.snap @@ -419,34 +419,6 @@ Array [ ] `; -exports[`datasource/docker/index getRegistryRepository handles local registries 1`] = ` -Object { - "registry": "https://registry:5000", - "repository": "org/package", -} -`; - -exports[`datasource/docker/index getRegistryRepository supports http registryUrls 1`] = ` -Object { - "registry": "http://my.local.registry/prefix", - "repository": "image", -} -`; - -exports[`datasource/docker/index getRegistryRepository supports registryUrls 1`] = ` -Object { - "registry": "https://my.local.registry/prefix", - "repository": "image", -} -`; - -exports[`datasource/docker/index getRegistryRepository supports schemeless registryUrls 1`] = ` -Object { - "registry": "https://my.local.registry/prefix", - "repository": "image", -} -`; - exports[`datasource/docker/index getReleases adds library/ prefix for Docker Hub (explicit) 1`] = ` Array [ Object { diff --git a/lib/datasource/docker/common.spec.ts b/lib/datasource/docker/common.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ddca33389a5b99271cc2ed529eda41efb519104 --- /dev/null +++ b/lib/datasource/docker/common.spec.ts @@ -0,0 +1,56 @@ +import * as httpMock from '../../../test/http-mock'; +import { getName, mocked } from '../../../test/util'; +import * as _hostRules from '../../util/host-rules'; +import * as dockerCommon from './common'; + +const hostRules = mocked(_hostRules); + +jest.mock('@aws-sdk/client-ecr'); +jest.mock('../../util/host-rules'); + +describe(getName(), () => { + beforeEach(() => { + httpMock.setup(); + hostRules.find.mockReturnValue({ + username: 'some-username', + password: 'some-password', + }); + hostRules.hosts.mockReturnValue([]); + }); + + afterEach(() => { + jest.resetAllMocks(); + httpMock.reset(); + }); + + describe('getRegistryRepository', () => { + it('handles local registries', () => { + const res = dockerCommon.getRegistryRepository( + 'registry:5000/org/package', + 'https://index.docker.io' + ); + expect(res).toMatchSnapshot(); + }); + it('supports registryUrls', () => { + const res = dockerCommon.getRegistryRepository( + 'my.local.registry/prefix/image', + 'https://my.local.registry/prefix' + ); + expect(res).toMatchSnapshot(); + }); + it('supports http registryUrls', () => { + const res = dockerCommon.getRegistryRepository( + 'my.local.registry/prefix/image', + 'http://my.local.registry/prefix' + ); + expect(res).toMatchSnapshot(); + }); + it('supports schemeless registryUrls', () => { + const res = dockerCommon.getRegistryRepository( + 'my.local.registry/prefix/image', + 'my.local.registry/prefix' + ); + expect(res).toMatchSnapshot(); + }); + }); +}); diff --git a/lib/datasource/docker/common.ts b/lib/datasource/docker/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..f10ed2e8038da05ffd3545d0b2a74235a02fffeb --- /dev/null +++ b/lib/datasource/docker/common.ts @@ -0,0 +1,441 @@ +import { ECR, ECRClientConfig } from '@aws-sdk/client-ecr'; +import hasha from 'hasha'; +import wwwAuthenticate from 'www-authenticate'; +import { HOST_DISABLED } from '../../constants/error-messages'; +import { logger } from '../../logger'; +import { HostRule } from '../../types'; +import { ExternalHostError } from '../../types/errors/external-host-error'; +import * as packageCache from '../../util/cache/package'; +import * as hostRules from '../../util/host-rules'; +import { Http, HttpResponse } from '../../util/http'; +import type { OutgoingHttpHeaders } from '../../util/http/types'; +import { ensureTrailingSlash, trimTrailingSlash } from '../../util/url'; +import { MediaType, RegistryRepository } from './types'; + +export const id = 'docker'; +export const http = new Http(id); + +export const ecrRegex = /\d+\.dkr\.ecr\.([-a-z0-9]+)\.amazonaws\.com/; +export const defaultRegistryUrls = ['https://index.docker.io']; + +async function getECRAuthToken( + region: string, + opts: HostRule +): Promise<string | null> { + const config: ECRClientConfig = { region }; + if (opts.username && opts.password) { + config.credentials = { + accessKeyId: opts.username, + secretAccessKey: opts.password, + }; + } + 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 async function getAuthHeaders( + registry: string, + dockerRepository: string +): Promise<OutgoingHttpHeaders | null> { + try { + const apiCheckUrl = `${registry}/v2/`; + const apiCheckResponse = await http.get(apiCheckUrl, { + throwHttpErrors: false, + }); + if (apiCheckResponse.headers['www-authenticate'] === undefined) { + return {}; + } + const authenticateHeader = new wwwAuthenticate.parsers.WWW_Authenticate( + apiCheckResponse.headers['www-authenticate'] + ); + + const opts: HostRule & { + headers?: Record<string, string>; + } = hostRules.find({ hostType: id, url: apiCheckUrl }); + if (ecrRegex.test(registry)) { + const [, region] = ecrRegex.exec(registry); + const auth = await getECRAuthToken(region, opts); + if (auth) { + opts.headers = { authorization: `Basic ${auth}` }; + } + } else if (opts.username && opts.password) { + const auth = Buffer.from(`${opts.username}:${opts.password}`).toString( + 'base64' + ); + opts.headers = { authorization: `Basic ${auth}` }; + } + delete opts.username; + delete opts.password; + + if (authenticateHeader.scheme.toUpperCase() === 'BASIC') { + logger.debug(`Using Basic auth for docker registry ${dockerRepository}`); + await http.get(apiCheckUrl, opts); + return opts.headers; + } + + // prettier-ignore + const authUrl = `${String(authenticateHeader.parms.realm)}?service=${String(authenticateHeader.parms.service)}&scope=repository:${dockerRepository}:pull`; + logger.trace( + `Obtaining docker registry token for ${dockerRepository} using url ${authUrl}` + ); + 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( + { registry, dockerRepository }, + 'Unauthorized docker lookup' + ); + logger.debug({ err }); + return null; + } + if (err.statusCode === 403) { + logger.debug( + { registry, dockerRepository }, + 'Not allowed to access docker registry' + ); + logger.debug({ err }); + return null; + } + // prettier-ignore + if (err.name === 'RequestError' && registry.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization] + throw new ExternalHostError(err); + } + // prettier-ignore + if (err.statusCode === 429 && registry.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization] + throw new ExternalHostError(err); + } + if (err.statusCode >= 500 && err.statusCode < 600) { + throw new ExternalHostError(err); + } + if (err.message === HOST_DISABLED) { + logger.trace({ registry, dockerRepository, err }, 'Host disabled'); + return null; + } + logger.warn( + { registry, dockerRepository, err }, + 'Error obtaining docker token' + ); + return null; + } +} + +export function getRegistryRepository( + lookupName: string, + registryUrl: string +): RegistryRepository { + if (registryUrl !== defaultRegistryUrls[0]) { + const registryEndingWithSlash = ensureTrailingSlash( + registryUrl.replace(/^https?:\/\//, '') + ); + if (lookupName.startsWith(registryEndingWithSlash)) { + let registry = trimTrailingSlash(registryUrl); + if (!/^https?:\/\//.test(registry)) { + registry = `https://${registry}`; + } + return { + registry, + repository: lookupName.replace(registryEndingWithSlash, ''), + }; + } + } + let registry: string; + const split = lookupName.split('/'); + if (split.length > 1 && (split[0].includes('.') || split[0].includes(':'))) { + [registry] = split; + split.shift(); + } + let repository = split.join('/'); + if (!registry) { + registry = registryUrl; + } + if (registry === 'docker.io') { + registry = 'index.docker.io'; + } + if (!/^https?:\/\//.exec(registry)) { + registry = `https://${registry}`; + } + const opts = hostRules.find({ hostType: id, url: registry }); + if (opts?.insecureRegistry) { + registry = registry.replace('https', 'http'); + } + if (registry.endsWith('.docker.io') && !repository.includes('/')) { + repository = 'library/' + repository; + } + return { + registry, + repository, + }; +} + +function digestFromManifestStr(str: hasha.HashaInput): string { + return 'sha256:' + hasha(str, { algorithm: 'sha256' }); +} + +export function extractDigestFromResponse( + manifestResponse: HttpResponse +): string { + if (manifestResponse.headers['docker-content-digest'] === undefined) { + return digestFromManifestStr(manifestResponse.body); + } + return manifestResponse.headers['docker-content-digest'] as string; +} + +// TODO: debug why quay throws errors (#9612) +export async function getManifestResponse( + registry: string, + dockerRepository: string, + tag: string +): Promise<HttpResponse> { + logger.debug(`getManifestResponse(${registry}, ${dockerRepository}, ${tag})`); + try { + const headers = await getAuthHeaders(registry, dockerRepository); + if (!headers) { + logger.debug('No docker auth found - returning'); + return null; + } + headers.accept = + 'application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json'; + const url = `${registry}/v2/${dockerRepository}/manifests/${tag}`; + const manifestResponse = await http.get(url, { + headers, + }); + return manifestResponse; + } catch (err) /* istanbul ignore next */ { + if (err instanceof ExternalHostError) { + throw err; + } + if (err.statusCode === 401) { + logger.debug( + { registry, dockerRepository }, + 'Unauthorized docker lookup' + ); + logger.debug({ err }); + return null; + } + if (err.statusCode === 404) { + logger.debug( + { + err, + registry, + dockerRepository, + tag, + }, + 'Docker Manifest is unknown' + ); + return null; + } + // prettier-ignore + if (err.statusCode === 429 && registry.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization] + throw new ExternalHostError(err); + } + if (err.statusCode >= 500 && err.statusCode < 600) { + throw new ExternalHostError(err); + } + if (err.code === 'ETIMEDOUT') { + logger.debug( + { registry }, + 'Timeout when attempting to connect to docker registry' + ); + logger.debug({ err }); + return null; + } + logger.debug( + { + err, + registry, + dockerRepository, + tag, + }, + 'Unknown Error looking up docker manifest' + ); + return null; + } +} + +async function getConfigDigest( + registry: string, + dockerRepository: string, + tag: string +): Promise<string> { + const manifestResponse = await 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); + if (manifest.schemaVersion !== 2) { + logger.debug( + { registry, dockerRepository, tag }, + 'Manifest schema version is not 2' + ); + return null; + } + + if ( + manifest.mediaType === MediaType.manifestListV2 && + manifest.manifests.length + ) { + logger.trace( + { registry, dockerRepository, tag }, + 'Found manifest list, using first image' + ); + return getConfigDigest( + registry, + dockerRepository, + manifest.manifests[0].digest + ); + } + + if (manifest.mediaType === MediaType.manifestV2) { + return manifest.config?.digest || null; + } + + logger.debug({ manifest }, 'Invalid manifest - returning'); + return null; +} + +/* + * docker.getLabels + * + * This function will: + * - Return the labels for the requested image + */ + +export async function getLabels( + registry: string, + dockerRepository: string, + tag: string +): Promise<Record<string, string>> { + logger.debug(`getLabels(${registry}, ${dockerRepository}, ${tag})`); + const cacheNamespace = 'datasource-docker-labels'; + const cacheKey = `${registry}:${dockerRepository}:${tag}`; + const cachedResult = await packageCache.get<Record<string, string>>( + cacheNamespace, + cacheKey + ); + // istanbul ignore if + if (cachedResult !== undefined) { + return cachedResult; + } + try { + let labels: Record<string, string> = {}; + const configDigest = await getConfigDigest(registry, dockerRepository, tag); + if (!configDigest) { + return {}; + } + + const headers = await getAuthHeaders(registry, dockerRepository); + // istanbul ignore if: Should never be happen + if (!headers) { + logger.debug('No docker auth found - returning'); + return {}; + } + const url = `${registry}/v2/${dockerRepository}/blobs/${configDigest}`; + const configResponse = await http.get(url, { + headers, + }); + labels = JSON.parse(configResponse.body).config.Labels; + + if (labels) { + logger.debug( + { + labels, + }, + 'found labels in manifest' + ); + } + const cacheMinutes = 60; + await packageCache.set(cacheNamespace, cacheKey, labels, cacheMinutes); + 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( + { registry, dockerRepository, err }, + 'Unauthorized docker lookup' + ); + } else if (err.statusCode === 404) { + logger.warn( + { + err, + registry, + dockerRepository, + tag, + }, + 'Config Manifest is unknown' + ); + } else if ( + err.statusCode === 429 && + registry.endsWith('docker.io') // lgtm [js/incomplete-url-substring-sanitization] + ) { + logger.warn({ err }, 'docker registry failure: too many requests'); + } else if (err.statusCode >= 500 && err.statusCode < 600) { + logger.debug( + { + err, + registry, + dockerRepository, + tag, + }, + 'docker registry failure: internal error' + ); + } else if ( + err.code === 'ERR_TLS_CERT_ALTNAME_INVALID' || + err.code === 'ETIMEDOUT' + ) { + logger.debug({ registry, err }, 'Error connecting to docker registry'); + } else if (registry === 'https://quay.io') { + // istanbul ignore next + logger.debug( + 'Ignoring quay.io errors until they fully support v2 schema' + ); + } else { + logger.info( + { registry, dockerRepository, tag, err }, + 'Unknown error getting Docker labels' + ); + } + return {}; + } +} diff --git a/lib/datasource/docker/index.spec.ts b/lib/datasource/docker/index.spec.ts index 02968b500a8b323e44be5d187f264b95d5fb40c4..39918e8257c4d899d63ad220fedbfc15ea1edf5d 100644 --- a/lib/datasource/docker/index.spec.ts +++ b/lib/datasource/docker/index.spec.ts @@ -4,8 +4,8 @@ import * as httpMock from '../../../test/http-mock'; import { getName, mocked, partial } from '../../../test/util'; import { EXTERNAL_HOST_ERROR } from '../../constants/error-messages'; import * as _hostRules from '../../util/host-rules'; +import { id } from './common'; import { MediaType } from './types'; -import * as docker from '.'; const hostRules = mocked(_hostRules); @@ -57,36 +57,6 @@ describe(getName(), () => { httpMock.reset(); }); - describe('getRegistryRepository', () => { - it('handles local registries', () => { - const res = docker.getRegistryRepository( - 'registry:5000/org/package', - 'https://index.docker.io' - ); - expect(res).toMatchSnapshot(); - }); - it('supports registryUrls', () => { - const res = docker.getRegistryRepository( - 'my.local.registry/prefix/image', - 'https://my.local.registry/prefix' - ); - expect(res).toMatchSnapshot(); - }); - it('supports http registryUrls', () => { - const res = docker.getRegistryRepository( - 'my.local.registry/prefix/image', - 'http://my.local.registry/prefix' - ); - expect(res).toMatchSnapshot(); - }); - it('supports schemeless registryUrls', () => { - const res = docker.getRegistryRepository( - 'my.local.registry/prefix/image', - 'my.local.registry/prefix' - ); - expect(res).toMatchSnapshot(); - }); - }); describe('getDigest', () => { it('returns null if no token', async () => { httpMock @@ -421,7 +391,7 @@ describe(getName(), () => { .get('/library/node/tags/list?n=10000') .reply(403); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'node', }); expect(res).toBeNull(); @@ -450,7 +420,7 @@ describe(getName(), () => { .get('/user/9287/repos?page=3&per_page=100') .reply(200, { tags: ['latest'] }, {}); const config = { - datasource: docker.id, + datasource: id, depName: 'node', registryUrls: ['https://registry.company.com'], }; @@ -471,7 +441,7 @@ describe(getName(), () => { .get('/node/manifests/1.0.0') .reply(200, '', {}); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'registry.company.com/node', }); expect(res.releases).toHaveLength(1); @@ -491,7 +461,7 @@ describe(getName(), () => { .get('/node/manifests/undefined') .reply(200); await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: '123456789.dkr.ecr.us-east-1.amazonaws.com/node', }); expect(httpMock.getTrace()).toMatchSnapshot(); @@ -518,7 +488,7 @@ describe(getName(), () => { ) .reply(200, { token: 'some-token ' }); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'node', }); expect(res.releases).toHaveLength(1); @@ -546,7 +516,7 @@ describe(getName(), () => { ) .reply(200, { token: 'some-token ' }); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'docker.io/node', }); expect(res.releases).toHaveLength(1); @@ -572,7 +542,7 @@ describe(getName(), () => { .get('/kubernetes-dashboard-amd64/manifests/1.0.0') .reply(200); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'k8s.gcr.io/kubernetes-dashboard-amd64', }); expect(res.releases).toHaveLength(1); @@ -586,7 +556,7 @@ describe(getName(), () => { .get('/my/node/tags/list?n=10000') .replyWithError('error'); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'my/node', }); expect(res).toBeNull(); @@ -604,7 +574,7 @@ describe(getName(), () => { .get('/') .reply(403); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'node', }); expect(res).toBeNull(); @@ -635,7 +605,7 @@ describe(getName(), () => { }, }); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'registry.company.com/node', }); const trace = httpMock.getTrace(); @@ -673,7 +643,7 @@ describe(getName(), () => { }, }); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'registry.company.com/node', }); const trace = httpMock.getTrace(); @@ -695,7 +665,7 @@ describe(getName(), () => { mediaType: MediaType.manifestV1, }); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'registry.company.com/node', }); const trace = httpMock.getTrace(); @@ -714,7 +684,7 @@ describe(getName(), () => { .get('/node/manifests/latest') .reply(200, {}); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'registry.company.com/node', }); const trace = httpMock.getTrace(); @@ -749,7 +719,7 @@ describe(getName(), () => { config: {}, }); const res = await getPkgReleases({ - datasource: docker.id, + datasource: id, depName: 'registry.company.com/node', }); const trace = httpMock.getTrace(); diff --git a/lib/datasource/docker/index.ts b/lib/datasource/docker/index.ts index 20869bad554a34c781d3335c6ff0acb4afb2f85d..cd34dbc231ed3784f790e429e9ec8c0eb08301df 100644 --- a/lib/datasource/docker/index.ts +++ b/lib/datasource/docker/index.ts @@ -1,27 +1,28 @@ import URL from 'url'; -import { ECR, ECRClientConfig } from '@aws-sdk/client-ecr'; -import hasha from 'hasha'; import parseLinkHeader from 'parse-link-header'; -import wwwAuthenticate from 'www-authenticate'; -import { HOST_DISABLED } from '../../constants/error-messages'; import { logger } from '../../logger'; -import { HostRule } from '../../types'; import { ExternalHostError } from '../../types/errors/external-host-error'; import * as packageCache from '../../util/cache/package'; -import * as hostRules from '../../util/host-rules'; -import { Http, HttpResponse } from '../../util/http'; -import type { OutgoingHttpHeaders } from '../../util/http/types'; -import { ensureTrailingSlash, trimTrailingSlash } from '../../util/url'; import * as dockerVersioning from '../../versioning/docker'; import type { GetReleasesConfig, ReleaseResult } from '../types'; -import { Image, ImageList, MediaType, RegistryRepository } from './types'; +import { + defaultRegistryUrls, + ecrRegex, + extractDigestFromResponse, + getAuthHeaders, + getLabels, + getManifestResponse, + getRegistryRepository, + http, + id, +} from './common'; // TODO: add got typings when available (#9646) // TODO: replace www-authenticate with https://www.npmjs.com/package/auth-header (#9645) -export const id = 'docker'; +export { id }; export const customRegistrySupport = true; -export const defaultRegistryUrls = ['https://index.docker.io']; +export { defaultRegistryUrls }; export const defaultVersioning = dockerVersioning.id; export const registryStrategy = 'first'; @@ -52,381 +53,6 @@ export const defaultConfig = { }, }; -const http = new Http(id); - -const ecrRegex = /\d+\.dkr\.ecr\.([-a-z0-9]+)\.amazonaws\.com/; - -export function getRegistryRepository( - lookupName: string, - registryUrl: string -): RegistryRepository { - if (registryUrl !== defaultRegistryUrls[0]) { - const registryEndingWithSlash = ensureTrailingSlash( - registryUrl.replace(/^https?:\/\//, '') - ); - if (lookupName.startsWith(registryEndingWithSlash)) { - let registry = trimTrailingSlash(registryUrl); - if (!/^https?:\/\//.test(registry)) { - registry = `https://${registry}`; - } - return { - registry, - repository: lookupName.replace(registryEndingWithSlash, ''), - }; - } - } - let registry: string; - const split = lookupName.split('/'); - if (split.length > 1 && (split[0].includes('.') || split[0].includes(':'))) { - [registry] = split; - split.shift(); - } - let repository = split.join('/'); - if (!registry) { - registry = registryUrl; - } - if (registry === 'docker.io') { - registry = 'index.docker.io'; - } - if (!/^https?:\/\//.exec(registry)) { - registry = `https://${registry}`; - } - const opts = hostRules.find({ hostType: id, url: registry }); - if (opts?.insecureRegistry) { - registry = registry.replace('https', 'http'); - } - if (registry.endsWith('.docker.io') && !repository.includes('/')) { - repository = 'library/' + repository; - } - return { - registry, - repository, - }; -} - -async function getECRAuthToken( - region: string, - opts: HostRule -): Promise<string | null> { - const config: ECRClientConfig = { region }; - if (opts.username && opts.password) { - config.credentials = { - accessKeyId: opts.username, - secretAccessKey: opts.password, - }; - } - 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; -} - -async function getAuthHeaders( - registry: string, - dockerRepository: string -): Promise<OutgoingHttpHeaders | null> { - try { - const apiCheckUrl = `${registry}/v2/`; - const apiCheckResponse = await http.get(apiCheckUrl, { - throwHttpErrors: false, - }); - if (apiCheckResponse.headers['www-authenticate'] === undefined) { - return {}; - } - const authenticateHeader = new wwwAuthenticate.parsers.WWW_Authenticate( - apiCheckResponse.headers['www-authenticate'] - ); - - const opts: HostRule & { - headers?: Record<string, string>; - } = hostRules.find({ hostType: id, url: apiCheckUrl }); - if (ecrRegex.test(registry)) { - const [, region] = ecrRegex.exec(registry); - const auth = await getECRAuthToken(region, opts); - if (auth) { - opts.headers = { authorization: `Basic ${auth}` }; - } - } else if (opts.username && opts.password) { - const auth = Buffer.from(`${opts.username}:${opts.password}`).toString( - 'base64' - ); - opts.headers = { authorization: `Basic ${auth}` }; - } - delete opts.username; - delete opts.password; - - if (authenticateHeader.scheme.toUpperCase() === 'BASIC') { - logger.debug(`Using Basic auth for docker registry ${dockerRepository}`); - await http.get(apiCheckUrl, opts); - return opts.headers; - } - - // prettier-ignore - const authUrl = `${String(authenticateHeader.parms.realm)}?service=${String(authenticateHeader.parms.service)}&scope=repository:${dockerRepository}:pull`; - logger.trace( - `Obtaining docker registry token for ${dockerRepository} using url ${authUrl}` - ); - 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( - { registry, dockerRepository }, - 'Unauthorized docker lookup' - ); - logger.debug({ err }); - return null; - } - if (err.statusCode === 403) { - logger.debug( - { registry, dockerRepository }, - 'Not allowed to access docker registry' - ); - logger.debug({ err }); - return null; - } - // prettier-ignore - if (err.name === 'RequestError' && registry.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization] - throw new ExternalHostError(err); - } - // prettier-ignore - if (err.statusCode === 429 && registry.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization] - throw new ExternalHostError(err); - } - if (err.statusCode >= 500 && err.statusCode < 600) { - throw new ExternalHostError(err); - } - if (err.message === HOST_DISABLED) { - logger.trace({ registry, dockerRepository, err }, 'Host disabled'); - return null; - } - logger.warn( - { registry, dockerRepository, err }, - 'Error obtaining docker token' - ); - return null; - } -} - -function digestFromManifestStr(str: hasha.HashaInput): string { - return 'sha256:' + hasha(str, { algorithm: 'sha256' }); -} - -function extractDigestFromResponse(manifestResponse: HttpResponse): string { - if (manifestResponse.headers['docker-content-digest'] === undefined) { - return digestFromManifestStr(manifestResponse.body); - } - return manifestResponse.headers['docker-content-digest'] as string; -} - -// TODO: debug why quay throws errors (#9612) -async function getManifestResponse( - registry: string, - dockerRepository: string, - tag: string -): Promise<HttpResponse> { - logger.debug(`getManifestResponse(${registry}, ${dockerRepository}, ${tag})`); - try { - const headers = await getAuthHeaders(registry, dockerRepository); - if (!headers) { - logger.debug('No docker auth found - returning'); - return null; - } - headers.accept = - 'application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json'; - const url = `${registry}/v2/${dockerRepository}/manifests/${tag}`; - const manifestResponse = await http.get(url, { - headers, - }); - return manifestResponse; - } catch (err) /* istanbul ignore next */ { - if (err instanceof ExternalHostError) { - throw err; - } - if (err.statusCode === 401) { - logger.debug( - { registry, dockerRepository }, - 'Unauthorized docker lookup' - ); - logger.debug({ err }); - return null; - } - if (err.statusCode === 404) { - logger.debug( - { - err, - registry, - dockerRepository, - tag, - }, - 'Docker Manifest is unknown' - ); - return null; - } - // prettier-ignore - if (err.statusCode === 429 && registry.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization] - throw new ExternalHostError(err); - } - if (err.statusCode >= 500 && err.statusCode < 600) { - throw new ExternalHostError(err); - } - if (err.code === 'ETIMEDOUT') { - logger.debug( - { registry }, - 'Timeout when attempting to connect to docker registry' - ); - logger.debug({ err }); - return null; - } - logger.debug( - { - err, - registry, - dockerRepository, - tag, - }, - 'Unknown Error looking up docker manifest' - ); - return null; - } -} - -async function getConfigDigest( - registry: string, - dockerRepository: string, - tag: string -): Promise<string> { - const manifestResponse = await 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; - if (manifest.schemaVersion !== 2) { - logger.debug( - { registry, dockerRepository, tag }, - 'Manifest schema version is not 2' - ); - return null; - } - - if ( - manifest.mediaType === MediaType.manifestListV2 && - manifest.manifests.length - ) { - logger.trace( - { registry, dockerRepository, tag }, - 'Found manifest list, using first image' - ); - return getConfigDigest( - registry, - dockerRepository, - manifest.manifests[0].digest - ); - } - - if (manifest.mediaType === MediaType.manifestV2) { - return manifest.config?.digest || null; - } - - logger.debug({ manifest }, 'Invalid manifest - returning'); - return null; -} - -/** - * 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 - */ -export async function getDigest( - { registryUrl, lookupName }: GetReleasesConfig, - newValue?: string -): Promise<string | null> { - const { registry, repository } = getRegistryRepository( - lookupName, - registryUrl - ); - logger.debug(`getDigest(${registry}, ${repository}, ${newValue})`); - const newTag = newValue || 'latest'; - const cacheNamespace = 'datasource-docker-digest'; - const cacheKey = `${registry}:${repository}:${newTag}`; - let digest: string = null; - try { - const cachedResult = await packageCache.get<string>( - cacheNamespace, - cacheKey - ); - // istanbul ignore if - if (cachedResult !== undefined) { - return cachedResult; - } - const manifestResponse = await getManifestResponse( - registry, - repository, - newTag - ); - if (manifestResponse) { - digest = extractDigestFromResponse(manifestResponse) || null; - logger.debug({ digest }, 'Got docker digest'); - } - } catch (err) /* istanbul ignore next */ { - if (err instanceof ExternalHostError) { - throw err; - } - logger.debug( - { - err, - lookupName, - newTag, - }, - 'Unknown Error looking up docker image digest' - ); - } - const cacheMinutes = 30; - await packageCache.set(cacheNamespace, cacheKey, digest, cacheMinutes); - return digest; -} - async function getTags( registry: string, repository: string @@ -500,111 +126,62 @@ async function getTags( } } -/* - * docker.getLabels +/** + * docker.getDigest + * + * The `newValue` supplied here should be a valid tag for the docker image. * * This function will: - * - Return the labels for the requested image + * - Look up a sha256 digest for a tag on its registry + * - Return the digest as a string */ - -async function getLabels( - registry: string, - dockerRepository: string, - tag: string -): Promise<Record<string, string>> { - logger.debug(`getLabels(${registry}, ${dockerRepository}, ${tag})`); - const cacheNamespace = 'datasource-docker-labels'; - const cacheKey = `${registry}:${dockerRepository}:${tag}`; - const cachedResult = await packageCache.get<Record<string, string>>( - cacheNamespace, - cacheKey +export async function getDigest( + { registryUrl, lookupName }: GetReleasesConfig, + newValue?: string +): Promise<string | null> { + const { registry, repository } = getRegistryRepository( + lookupName, + registryUrl ); - // istanbul ignore if - if (cachedResult !== undefined) { - return cachedResult; - } + logger.debug(`getDigest(${registry}, ${repository}, ${newValue})`); + const newTag = newValue || 'latest'; + const cacheNamespace = 'datasource-docker-digest'; + const cacheKey = `${registry}:${repository}:${newTag}`; + let digest: string = null; try { - let labels: Record<string, string> = {}; - const configDigest = await getConfigDigest(registry, dockerRepository, tag); - if (!configDigest) { - return {}; - } - - const headers = await getAuthHeaders(registry, dockerRepository); - // istanbul ignore if: Should never be happen - if (!headers) { - logger.debug('No docker auth found - returning'); - return {}; + const cachedResult = await packageCache.get<string>( + cacheNamespace, + cacheKey + ); + // istanbul ignore if + if (cachedResult !== undefined) { + return cachedResult; } - const url = `${registry}/v2/${dockerRepository}/blobs/${configDigest}`; - const configResponse = await http.get(url, { - headers, - }); - labels = JSON.parse(configResponse.body).config.Labels; - - if (labels) { - logger.debug( - { - labels, - }, - 'found labels in manifest' - ); + const manifestResponse = await getManifestResponse( + registry, + repository, + newTag + ); + if (manifestResponse) { + digest = extractDigestFromResponse(manifestResponse) || null; + logger.debug({ digest }, 'Got docker digest'); } - const cacheMinutes = 60; - await packageCache.set(cacheNamespace, cacheKey, labels, cacheMinutes); - return labels; - } catch (err) /* istanbul ignore next: should be tested in future */ { + } catch (err) /* istanbul ignore next */ { if (err instanceof ExternalHostError) { throw err; } - if (err.statusCode === 400 || err.statusCode === 401) { - logger.debug( - { registry, dockerRepository, err }, - 'Unauthorized docker lookup' - ); - } else if (err.statusCode === 404) { - logger.warn( - { - err, - registry, - dockerRepository, - tag, - }, - 'Config Manifest is unknown' - ); - } else if ( - err.statusCode === 429 && - registry.endsWith('docker.io') // lgtm [js/incomplete-url-substring-sanitization] - ) { - logger.warn({ err }, 'docker registry failure: too many requests'); - } else if (err.statusCode >= 500 && err.statusCode < 600) { - logger.debug( - { - err, - registry, - dockerRepository, - tag, - }, - 'docker registry failure: internal error' - ); - } else if ( - err.code === 'ERR_TLS_CERT_ALTNAME_INVALID' || - err.code === 'ETIMEDOUT' - ) { - logger.debug({ registry, err }, 'Error connecting to docker registry'); - } else if (registry === 'https://quay.io') { - // istanbul ignore next - logger.debug( - 'Ignoring quay.io errors until they fully support v2 schema' - ); - } else { - logger.info( - { registry, dockerRepository, tag, err }, - 'Unknown error getting Docker labels' - ); - } - return {}; + logger.debug( + { + err, + lookupName, + newTag, + }, + 'Unknown Error looking up docker image digest' + ); } + const cacheMinutes = 30; + await packageCache.set(cacheNamespace, cacheKey, digest, cacheMinutes); + return digest; } /**