diff --git a/lib/datasource/docker/common.spec.ts b/lib/datasource/docker/common.spec.ts deleted file mode 100644 index 37d8c922f0992a6ac427f7155d3e1eb60de9e8a3..0000000000000000000000000000000000000000 --- a/lib/datasource/docker/common.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -import * as httpMock from '../../../test/http-mock'; -import { 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('datasource/docker/common', () => { - beforeEach(() => { - hostRules.find.mockReturnValue({ - username: 'some-username', - password: 'some-password', - }); - hostRules.hosts.mockReturnValue([]); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('getRegistryRepository', () => { - it('handles local registries', () => { - const res = dockerCommon.getRegistryRepository( - 'registry:5000/org/package', - 'https://index.docker.io' - ); - expect(res).toMatchInlineSnapshot(` - Object { - "dockerRepository": "org/package", - "registryHost": "https://registry:5000", - } - `); - }); - it('supports registryUrls', () => { - const res = dockerCommon.getRegistryRepository( - 'my.local.registry/prefix/image', - 'https://my.local.registry/prefix' - ); - expect(res).toMatchInlineSnapshot(` - Object { - "dockerRepository": "prefix/image", - "registryHost": "https://my.local.registry", - } - `); - }); - it('supports http registryUrls', () => { - const res = dockerCommon.getRegistryRepository( - 'my.local.registry/prefix/image', - 'http://my.local.registry/prefix' - ); - expect(res).toMatchInlineSnapshot(` - Object { - "dockerRepository": "prefix/image", - "registryHost": "http://my.local.registry", - } - `); - }); - it('supports schemeless registryUrls', () => { - const res = dockerCommon.getRegistryRepository( - 'my.local.registry/prefix/image', - 'my.local.registry/prefix' - ); - expect(res).toMatchInlineSnapshot(` - Object { - "dockerRepository": "prefix/image", - "registryHost": "https://my.local.registry", - } - `); - }); - }); - describe('getAuthHeaders', () => { - beforeEach(() => { - httpMock - .scope('https://my.local.registry') - .get('/v2/', undefined, { badheaders: ['authorization'] }) - .reply(401, '', { 'www-authenticate': 'Authenticate you must' }); - hostRules.hosts.mockReturnValue([]); - }); - - it('returns "authType token" if both provided', async () => { - hostRules.find.mockReturnValue({ - authType: 'some-authType', - token: 'some-token', - }); - - const headers = await dockerCommon.getAuthHeaders( - 'https://my.local.registry', - 'https://my.local.registry/prefix' - ); - - expect(headers).toMatchInlineSnapshot(` -Object { - "authorization": "some-authType some-token", -} -`); - }); - - it('returns "Bearer token" if only token provided', async () => { - hostRules.find.mockReturnValue({ - token: 'some-token', - }); - - const headers = await dockerCommon.getAuthHeaders( - 'https://my.local.registry', - 'https://my.local.registry/prefix' - ); - - expect(headers).toMatchInlineSnapshot(` -Object { - "authorization": "Bearer some-token", -} -`); - }); - - it('fails', async () => { - httpMock.clear(false); - - httpMock - .scope('https://my.local.registry') - .get('/v2/', undefined, { badheaders: ['authorization'] }) - .reply(401, '', {}); - - const headers = await dockerCommon.getAuthHeaders( - 'https://my.local.registry', - 'https://my.local.registry/prefix' - ); - - expect(headers).toBeNull(); - }); - }); -}); diff --git a/lib/datasource/docker/common.ts b/lib/datasource/docker/common.ts deleted file mode 100644 index d8b4783ba34b17e22008e8d9ae8e5e54cb98e5e4..0000000000000000000000000000000000000000 --- a/lib/datasource/docker/common.ts +++ /dev/null @@ -1,524 +0,0 @@ -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 } from '../../constants/error-messages'; -import { logger } from '../../logger'; -import type { 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, HttpOptions, HttpResponse } from '../../util/http'; -import type { HttpError, OutgoingHttpHeaders } from '../../util/http/types'; -import { regEx } from '../../util/regex'; -import { - ensureTrailingSlash, - parseUrl, - trimTrailingSlash, -} from '../../util/url'; -import { MediaType, RegistryRepository } from './types'; - -export const id = 'docker'; -export const http = new Http(id); - -export const ecrRegex = regEx(/\d+\.dkr\.ecr\.([-a-z0-9]+)\.amazonaws\.com/); -const DOCKER_HUB = 'https://index.docker.io'; -export const defaultRegistryUrls = [DOCKER_HUB]; - -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, - ...(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 async function getAuthHeaders( - registryHost: string, - dockerRepository: string -): Promise<OutgoingHttpHeaders | null> { - try { - const apiCheckUrl = `${registryHost}/v2/`; - const apiCheckResponse = await http.get(apiCheckUrl, { - throwHttpErrors: false, - noAuth: true, - }); - - if (apiCheckResponse.statusCode === 200) { - logger.debug({ registryHost }, 'No registry auth required'); - return {}; - } - if ( - apiCheckResponse.statusCode !== 401 || - !is.nonEmptyString(apiCheckResponse.headers['www-authenticate']) - ) { - logger.warn( - { registryHost, res: apiCheckResponse }, - 'Invalid registry response' - ); - return null; - } - - const authenticateHeader = parse( - apiCheckResponse.headers['www-authenticate'] - ); - - const opts: HostRule & HttpOptions = hostRules.find({ - hostType: 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) || - !is.string(authenticateHeader.params.service) || - parseUrl(authenticateHeader.params.realm) === null - ) { - logger.trace( - { registryHost, dockerRepository, authenticateHeader }, - `Invalid realm, testing direct auth` - ); - return opts.headers; - } - - const authUrl = `${authenticateHeader.params.realm}?service=${authenticateHeader.params.service}&scope=repository:${dockerRepository}:pull`; - 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; - } - // prettier-ignore - if (err.name === 'RequestError' && registryHost.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization] - throw new ExternalHostError(err); - } - // prettier-ignore - if (err.statusCode === 429 && registryHost.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({ registryHost, dockerRepository, err }, 'Host disabled'); - return null; - } - logger.warn( - { registryHost, dockerRepository, err }, - 'Error obtaining docker token' - ); - return null; - } -} - -export function getRegistryRepository( - lookupName: string, - registryUrl: string -): RegistryRepository { - if (registryUrl !== DOCKER_HUB) { - const registryEndingWithSlash = ensureTrailingSlash( - registryUrl.replace(regEx(/^https?:\/\//), '') - ); - if (lookupName.startsWith(registryEndingWithSlash)) { - let registryHost = trimTrailingSlash(registryUrl); - if (!regEx(/^https?:\/\//).test(registryHost)) { - registryHost = `https://${registryHost}`; - } - let dockerRepository = lookupName.replace(registryEndingWithSlash, ''); - const fullUrl = `${registryHost}/${dockerRepository}`; - const { origin, pathname } = parseUrl(fullUrl); - registryHost = origin; - dockerRepository = pathname.substring(1); - return { - registryHost, - dockerRepository, - }; - } - } - let registryHost: string; - const split = lookupName.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: 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); -} - -// TODO: debug why quay throws errors (#9612) -export async function getManifestResponse( - registryHost: string, - dockerRepository: string, - tag: string, - mode: 'head' | 'get' = 'get' -): Promise<HttpResponse> { - logger.debug( - `getManifestResponse(${registryHost}, ${dockerRepository}, ${tag})` - ); - try { - const headers = await getAuthHeaders(registryHost, 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 = `${registryHost}/v2/${dockerRepository}/manifests/${tag}`; - const manifestResponse = await 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; - } - // prettier-ignore - if (err.statusCode === 429 && registryHost.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( - { 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; - } -} - -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( - registryHost: string, - dockerRepository: string, - tag: string -): Promise<Record<string, string>> { - logger.debug(`getLabels(${registryHost}, ${dockerRepository}, ${tag})`); - const cacheNamespace = 'datasource-docker-labels'; - const cacheKey = `${registryHost}:${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( - registryHost, - dockerRepository, - tag - ); - if (!configDigest) { - return {}; - } - - const headers = await getAuthHeaders(registryHost, dockerRepository); - // istanbul ignore if: Should never be happen - if (!headers) { - logger.debug('No docker auth found - returning'); - return {}; - } - const url = `${registryHost}/v2/${dockerRepository}/blobs/${configDigest}`; - const configResponse = await http.get(url, { - headers, - noAuth: true, - }); - 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( - { 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 && - registryHost.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, - 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 {}; - } -} - -export function isECRMaxResultsError(err: HttpError): boolean { - return !!( - err.response?.statusCode === 405 && - err.response?.headers?.['docker-distribution-api-version'] && - // https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_DescribeRepositories.html#ECR-DescribeRepositories-request-maxResults - err.response.body?.['errors']?.[0]?.message?.includes( - 'Member must have value less than or equal to 1000' - ) - ); -} diff --git a/lib/datasource/docker/index.spec.ts b/lib/datasource/docker/index.spec.ts index c302053f96e7caae8f9026caa1a297d693567cd8..c5825477cef933d57a7cefa7915c9da43f61447a 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 { 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 { getAuthHeaders, getRegistryRepository, id } from '.'; const hostRules = mocked(_hostRules); @@ -55,6 +55,117 @@ describe('datasource/docker/index', () => { jest.resetAllMocks(); }); + describe('getRegistryRepository', () => { + it('handles local registries', () => { + const res = getRegistryRepository( + 'registry:5000/org/package', + 'https://index.docker.io' + ); + expect(res).toMatchInlineSnapshot(` + Object { + "dockerRepository": "org/package", + "registryHost": "https://registry:5000", + } + `); + }); + it('supports registryUrls', () => { + const res = getRegistryRepository( + 'my.local.registry/prefix/image', + 'https://my.local.registry/prefix' + ); + expect(res).toMatchInlineSnapshot(` + Object { + "dockerRepository": "prefix/image", + "registryHost": "https://my.local.registry", + } + `); + }); + it('supports http registryUrls', () => { + const res = getRegistryRepository( + 'my.local.registry/prefix/image', + 'http://my.local.registry/prefix' + ); + expect(res).toMatchInlineSnapshot(` + Object { + "dockerRepository": "prefix/image", + "registryHost": "http://my.local.registry", + } + `); + }); + it('supports schemeless registryUrls', () => { + const res = getRegistryRepository( + 'my.local.registry/prefix/image', + 'my.local.registry/prefix' + ); + expect(res).toMatchInlineSnapshot(` + Object { + "dockerRepository": "prefix/image", + "registryHost": "https://my.local.registry", + } + `); + }); + }); + describe('getAuthHeaders', () => { + beforeEach(() => { + httpMock + .scope('https://my.local.registry') + .get('/v2/', undefined, { badheaders: ['authorization'] }) + .reply(401, '', { 'www-authenticate': 'Authenticate you must' }); + hostRules.hosts.mockReturnValue([]); + }); + + it('returns "authType token" if both provided', async () => { + hostRules.find.mockReturnValue({ + authType: 'some-authType', + token: 'some-token', + }); + + const headers = await getAuthHeaders( + 'https://my.local.registry', + 'https://my.local.registry/prefix' + ); + + expect(headers).toMatchInlineSnapshot(` +Object { + "authorization": "some-authType some-token", +} +`); + }); + + it('returns "Bearer token" if only token provided', async () => { + hostRules.find.mockReturnValue({ + token: 'some-token', + }); + + const headers = await getAuthHeaders( + 'https://my.local.registry', + 'https://my.local.registry/prefix' + ); + + expect(headers).toMatchInlineSnapshot(` +Object { + "authorization": "Bearer some-token", +} +`); + }); + + it('fails', async () => { + httpMock.clear(false); + + httpMock + .scope('https://my.local.registry') + .get('/v2/', undefined, { badheaders: ['authorization'] }) + .reply(401, '', {}); + + const headers = await getAuthHeaders( + 'https://my.local.registry', + 'https://my.local.registry/prefix' + ); + + expect(headers).toBeNull(); + }); + }); + describe('getDigest', () => { it('returns null if no token', async () => { httpMock diff --git a/lib/datasource/docker/index.ts b/lib/datasource/docker/index.ts index b8532165b9615c558858328caedf6258bf9edd9b..5cc024bbc3404b3de803ced1ffe320c5486c6bc7 100644 --- a/lib/datasource/docker/index.ts +++ b/lib/datasource/docker/index.ts @@ -1,38 +1,569 @@ 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 } from '../../constants/error-messages'; import { logger } from '../../logger'; +import type { 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, HttpOptions, HttpResponse } from '../../util/http'; import { HttpError } from '../../util/http/types'; +import type { OutgoingHttpHeaders } from '../../util/http/types'; import { hasKey } from '../../util/object'; import { regEx } from '../../util/regex'; -import { ensurePathPrefix, parseLinkHeader } from '../../util/url'; +import { + ensurePathPrefix, + ensureTrailingSlash, + parseLinkHeader, + parseUrl, + trimTrailingSlash, +} from '../../util/url'; import { api as dockerVersioning, id as dockerVersioningId, } from '../../versioning/docker'; import type { GetReleasesConfig, ReleaseResult } from '../types'; -import { - defaultRegistryUrls, - ecrRegex, - extractDigestFromResponseBody, - getAuthHeaders, - getLabels, - getManifestResponse, - getRegistryRepository, - http, - id, - isECRMaxResultsError, -} from './common'; -import { getTagsQuayRegistry } from './quay'; +import { MediaType, RegistryRepository } from './types'; + +export const ecrRegex = regEx(/\d+\.dkr\.ecr\.([-a-z0-9]+)\.amazonaws\.com/); + +export const id = 'docker'; +export const http = new Http(id); + +const DOCKER_HUB = 'https://index.docker.io'; +export const defaultRegistryUrls = [DOCKER_HUB]; // TODO: add got typings when available (#9646) -export { id }; export const customRegistrySupport = true; -export { defaultRegistryUrls }; export const defaultVersioning = dockerVersioningId; export const registryStrategy = 'first'; +function isDockerHost(host: string): boolean { + const regex = regEx(/(?:^|\.)docker\.io$/); + return regex.test(host); +} + +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, + ...(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 async function getAuthHeaders( + registryHost: string, + dockerRepository: string +): Promise<OutgoingHttpHeaders | null> { + try { + const apiCheckUrl = `${registryHost}/v2/`; + const apiCheckResponse = await http.get(apiCheckUrl, { + throwHttpErrors: false, + noAuth: true, + }); + + if (apiCheckResponse.statusCode === 200) { + logger.debug({ registryHost }, 'No registry auth required'); + return {}; + } + if ( + apiCheckResponse.statusCode !== 401 || + !is.nonEmptyString(apiCheckResponse.headers['www-authenticate']) + ) { + logger.warn( + { registryHost, res: apiCheckResponse }, + 'Invalid registry response' + ); + return null; + } + + const authenticateHeader = parse( + apiCheckResponse.headers['www-authenticate'] + ); + + const opts: HostRule & HttpOptions = hostRules.find({ + hostType: 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) || + !is.string(authenticateHeader.params.service) || + parseUrl(authenticateHeader.params.realm) === null + ) { + logger.trace( + { registryHost, dockerRepository, authenticateHeader }, + `Invalid realm, testing direct auth` + ); + return opts.headers; + } + + const authUrl = `${authenticateHeader.params.realm}?service=${authenticateHeader.params.service}&scope=repository:${dockerRepository}:pull`; + 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 === 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( + lookupName: string, + registryUrl: string +): RegistryRepository { + if (registryUrl !== DOCKER_HUB) { + const registryEndingWithSlash = ensureTrailingSlash( + registryUrl.replace(regEx(/^https?:\/\//), '') + ); + if (lookupName.startsWith(registryEndingWithSlash)) { + let registryHost = trimTrailingSlash(registryUrl); + if (!regEx(/^https?:\/\//).test(registryHost)) { + registryHost = `https://${registryHost}`; + } + let dockerRepository = lookupName.replace(registryEndingWithSlash, ''); + const fullUrl = `${registryHost}/${dockerRepository}`; + const { origin, pathname } = parseUrl(fullUrl); + registryHost = origin; + dockerRepository = pathname.substring(1); + return { + registryHost, + dockerRepository, + }; + } + } + let registryHost: string; + const split = lookupName.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: 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); +} + +// TODO: debug why quay throws errors (#9612) +export async function getManifestResponse( + registryHost: string, + dockerRepository: string, + tag: string, + mode: 'head' | 'get' = 'get' +): Promise<HttpResponse> { + logger.debug( + `getManifestResponse(${registryHost}, ${dockerRepository}, ${tag})` + ); + try { + const headers = await getAuthHeaders(registryHost, 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 = `${registryHost}/v2/${dockerRepository}/manifests/${tag}`; + const manifestResponse = await 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; + } +} + +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( + registryHost: string, + dockerRepository: string, + tag: string +): Promise<Record<string, string>> { + logger.debug(`getLabels(${registryHost}, ${dockerRepository}, ${tag})`); + const cacheNamespace = 'datasource-docker-labels'; + const cacheKey = `${registryHost}:${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( + registryHost, + dockerRepository, + tag + ); + if (!configDigest) { + return {}; + } + + const headers = await getAuthHeaders(registryHost, dockerRepository); + // istanbul ignore if: Should never be happen + if (!headers) { + logger.debug('No docker auth found - returning'); + return {}; + } + const url = `${registryHost}/v2/${dockerRepository}/blobs/${configDigest}`; + const configResponse = await http.get(url, { + headers, + noAuth: true, + }); + 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( + { 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 {}; + } +} + +export function isECRMaxResultsError(err: HttpError): boolean { + return !!( + err.response?.statusCode === 405 && + err.response?.headers?.['docker-distribution-api-version'] && + // https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_DescribeRepositories.html#ECR-DescribeRepositories-request-maxResults + err.response.body?.['errors']?.[0]?.message?.includes( + 'Member must have value less than or equal to 1000' + ) + ); +} + +export async function 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 = pageUrl(page); + do { + const res = await http.getJson<{ + tags: { name: string }[]; + has_additional: boolean; + }>(url, {}); + const pageTags = res.body.tags.map((tag) => tag.name); + tags = tags.concat(pageTags); + page += 1; + url = res.body.has_additional ? pageUrl(page) : null; + } while (url && page < 20); + return tags; +} + export const defaultConfig = { commitMessageTopic: '{{{depName}}} Docker tag', commitMessageExtra: @@ -145,7 +676,7 @@ async function getTags( return getTags(registryHost, 'library/' + dockerRepository); } // prettier-ignore - if (err.statusCode === 429 && registryHost.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization] + if (err.statusCode === 429 && isDockerHost(registryHost)) { logger.warn( { registryHost, dockerRepository, err }, 'docker registry failure: too many requests' @@ -153,7 +684,7 @@ async function getTags( throw new ExternalHostError(err); } // prettier-ignore - if (err.statusCode === 401 && registryHost.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization] + if (err.statusCode === 401 && isDockerHost(registryHost)) { logger.warn( { registryHost, dockerRepository, err }, 'docker registry failure: unauthorized' diff --git a/lib/datasource/docker/quay.ts b/lib/datasource/docker/quay.ts deleted file mode 100644 index 248a90d8b2a06cc912da27af2d33de42e6656aa0..0000000000000000000000000000000000000000 --- a/lib/datasource/docker/quay.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { http } from './common'; - -export async function 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 = pageUrl(page); - do { - const res = await http.getJson<{ - tags: { name: string }[]; - has_additional: boolean; - }>(url, {}); - const pageTags = res.body.tags.map((tag) => tag.name); - tags = tags.concat(pageTags); - page += 1; - url = res.body.has_additional ? pageUrl(page) : null; - } while (url && page < 20); - return tags; -}