renovate/lib/datasource/docker/index.js
2019-07-18 22:37:43 +02:00

509 lines
14 KiB
JavaScript

import is from '@sindresorhus/is';
const hasha = require('hasha');
const URL = require('url');
const parseLinkHeader = require('parse-link-header');
const wwwAuthenticate = require('www-authenticate');
const { logger } = require('../../logger');
const got = require('../../util/got');
const hostRules = require('../../util/host-rules');
export { getDigest, getPkgReleases };
/**
*
* @param {string} lookupName
* @param {string[]=} registryUrls
*/
function getRegistryRepository(lookupName, registryUrls) {
/** @type string */
let registry;
const split = lookupName.split('/');
if (split.length > 1 && split[0].includes('.')) {
[registry] = split;
split.shift();
}
let repository = split.join('/');
if (!registry && is.nonEmptyArray(registryUrls)) {
[registry] = registryUrls;
}
if (!registry || registry === 'docker.io') {
registry = 'index.docker.io';
}
if (!registry.match('^https?://')) {
registry = `https://${registry}`;
}
if (registry.endsWith('.docker.io') && !repository.includes('/')) {
repository = 'library/' + repository;
}
return {
registry,
repository,
};
}
async function getAuthHeaders(registry, repository) {
try {
const apiCheckUrl = `${registry}/v2/`;
const apiCheckResponse = await got(apiCheckUrl, { throwHttpErrors: false });
if (apiCheckResponse.headers['www-authenticate'] === undefined) {
return {};
}
const authenticateHeader = new wwwAuthenticate.parsers.WWW_Authenticate(
apiCheckResponse.headers['www-authenticate']
);
/** @type any */
const opts = hostRules.find({ hostType: 'docker', url: apiCheckUrl });
opts.json = true;
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 ${repository}`);
await got(apiCheckUrl, opts);
return opts.headers;
}
// prettier-ignore
const authUrl = `${authenticateHeader.parms.realm}?service=${authenticateHeader.parms.service}&scope=repository:${repository}:pull`;
logger.trace(
`Obtaining docker registry token for ${repository} using url ${authUrl}`
);
const { token } = (await got(authUrl, opts)).body;
// 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.statusCode === 401) {
logger.info(
{ registry, dockerRepository: repository },
'Unauthorized docker lookup'
);
logger.debug({ err });
return null;
}
if (err.statusCode === 403) {
logger.info(
{ registry, dockerRepository: repository },
'Not allowed to access docker registry'
);
logger.debug({ err });
return null;
}
if (err.statusCode === 429 && registry.endsWith('docker.io')) {
logger.warn({ err }, 'docker registry failure: too many requests');
throw new Error('registry-failure');
}
if (err.statusCode >= 500 && err.statusCode < 600) {
logger.warn({ err }, 'docker registry failure: internal error');
throw new Error('registry-failure');
}
logger.warn(
{ registry, dockerRepository: repository, err },
'Error obtaining docker token'
);
return null;
}
}
function digestFromManifestStr(str) {
return 'sha256:' + hasha(str, { algorithm: 'sha256' });
}
function extractDigestFromResponse(manifestResponse) {
if (manifestResponse.headers['docker-content-digest'] === undefined) {
return digestFromManifestStr(manifestResponse.body);
}
return manifestResponse.headers['docker-content-digest'];
}
async function getManifestResponse(registry, repository, tag) {
logger.debug(`getManifestResponse(${registry}, ${repository}, ${tag})`);
try {
const headers = await getAuthHeaders(registry, repository);
if (!headers) {
logger.info('No docker auth found - returning');
return null;
}
headers.accept = 'application/vnd.docker.distribution.manifest.v2+json';
const url = `${registry}/v2/${repository}/manifests/${tag}`;
const manifestResponse = await got(url, {
headers,
});
return manifestResponse;
} catch (err) /* istanbul ignore next */ {
if (err.message === 'registry-failure') {
throw err;
}
if (err.statusCode === 401) {
logger.info(
{ registry, dockerRepository: repository },
'Unauthorized docker lookup'
);
logger.debug({ err });
return null;
}
if (err.statusCode === 404) {
logger.info(
{
err,
registry,
dockerRepository: repository,
tag,
},
'Docker Manifest is unknown'
);
return null;
}
if (err.statusCode === 429 && registry.endsWith('docker.io')) {
logger.warn({ err }, 'docker registry failure: too many requests');
throw new Error('registry-failure');
}
if (err.statusCode >= 500 && err.statusCode < 600) {
logger.info(
{
err,
registry,
dockerRepository: repository,
tag,
},
'docker registry failure: internal error'
);
throw new Error('registry-failure');
}
if (err.code === 'ETIMEDOUT') {
logger.info(
{ registry },
'Timeout when attempting to connect to docker registry'
);
logger.debug({ err });
return null;
}
logger.info(
{
err,
registry,
dockerRepository: repository,
tag,
},
'Unknown Error looking up docker manifest'
);
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
* @param {{registryUrls? : string[], lookupName: string}} args
* @param {string=} newValue
*/
async function getDigest({ registryUrls, lookupName }, newValue) {
const { registry, repository } = getRegistryRepository(
lookupName,
registryUrls
);
logger.debug(`getDigest(${registry}, ${repository}, ${newValue})`);
const newTag = newValue || 'latest';
try {
const cacheNamespace = 'datasource-docker-digest';
const cacheKey = `${registry}:${repository}:${newTag}`;
const cachedResult = await renovateCache.get(cacheNamespace, cacheKey);
// istanbul ignore if
if (cachedResult) {
return cachedResult;
}
const manifestResponse = await getManifestResponse(
registry,
repository,
newTag
);
if (!manifestResponse) {
return null;
}
const digest = extractDigestFromResponse(manifestResponse);
logger.debug({ digest }, 'Got docker digest');
const cacheMinutes = 30;
await renovateCache.set(cacheNamespace, cacheKey, digest, cacheMinutes);
return digest;
} catch (err) /* istanbul ignore next */ {
if (err.message === 'registry-failure') {
throw err;
}
logger.info(
{
err,
lookupName,
newTag,
},
'Unknown Error looking up docker image digest'
);
return null;
}
}
async function getTags(registry, repository) {
let tags = [];
try {
const cacheNamespace = 'datasource-docker-tags';
const cacheKey = `${registry}:${repository}`;
const cachedResult = await renovateCache.get(cacheNamespace, cacheKey);
// istanbul ignore if
if (cachedResult) {
return cachedResult;
}
let url = `${registry}/v2/${repository}/tags/list?n=10000`;
const headers = await getAuthHeaders(registry, repository);
if (!headers) {
logger.debug('Failed to get authHeaders for getTags lookup');
return null;
}
let page = 1;
do {
const res = await got(url, { json: true, headers });
tags = tags.concat(res.body.tags);
const linkHeader = parseLinkHeader(res.headers.link);
url =
linkHeader && linkHeader.next
? URL.resolve(url, linkHeader.next.url)
: null;
page += 1;
} while (url && page < 20);
const cacheMinutes = 15;
await renovateCache.set(cacheNamespace, cacheKey, tags, cacheMinutes);
return tags;
} catch (err) /* istanbul ignore next */ {
if (err.message === 'registry-failure') {
throw err;
}
logger.debug(
{
err,
},
'docker.getTags() error'
);
if (err.statusCode === 404 && !repository.includes('/')) {
logger.info(
`Retrying Tags for ${registry}/${repository} using library/ prefix`
);
return getTags(registry, 'library/' + repository);
}
if (err.statusCode === 401 || err.statusCode === 403) {
logger.info(
{ registry, dockerRepository: repository, err },
'Not authorised to look up docker tags'
);
return null;
}
if (err.statusCode === 429 && registry.endsWith('docker.io')) {
logger.warn(
{ registry, dockerRepository: repository, err },
'docker registry failure: too many requests'
);
throw new Error('registry-failure');
}
if (err.statusCode >= 500 && err.statusCode < 600) {
logger.warn(
{ registry, dockerRepository: repository, err },
'docker registry failure: internal error'
);
throw new Error('registry-failure');
}
if (err.code === 'ETIMEDOUT') {
logger.info(
{ registry },
'Timeout when attempting to connect to docker registry'
);
return null;
}
logger.warn(
{ registry, dockerRepository: repository, err },
'Error getting docker image tags'
);
return null;
}
}
/*
* docker.getLabels
*
* This function will:
* - Return the labels for the requested image
*/
// istanbul ignore next
async function getLabels(registry, repository, tag) {
logger.debug(`getLabels(${registry}, ${repository}, ${tag})`);
const cacheNamespace = 'datasource-docker-labels';
const cacheKey = `${registry}:${repository}:${tag}`;
const cachedResult = await renovateCache.get(cacheNamespace, cacheKey);
// istanbul ignore if
if (cachedResult) {
return cachedResult;
}
try {
const manifestResponse = await getManifestResponse(
registry,
repository,
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
if (!manifestResponse) {
logger.info(
{
registry,
dockerRepository: repository,
tag,
},
'docker registry failure: failed to get manifest for tag'
);
return {};
}
const manifest = JSON.parse(manifestResponse.body);
// istanbul ignore if
if (manifest.schemaVersion !== 2) {
logger.debug(
{ registry, dockerRepository: repository, tag, manifest },
'Manifest schema version is not 2'
);
return {};
}
let labels = {};
const configDigest = manifest.config.digest;
const headers = await getAuthHeaders(registry, repository);
if (!headers) {
logger.info('No docker auth found - returning');
return {};
}
const url = `${registry}/v2/${repository}/blobs/${configDigest}`;
const configResponse = await got(url, {
headers,
hooks: {
beforeRedirect: [
options => {
if (
options.search &&
options.search.indexOf('X-Amz-Algorithm') !== -1
) {
// docker registry is hosted on amazon, redirect url includes authentication.
// eslint-disable-next-line no-param-reassign
delete options.headers.authorization;
}
},
],
},
});
labels = JSON.parse(configResponse.body).config.Labels;
if (labels) {
logger.debug(
{
labels,
},
'found labels in manifest'
);
}
const cacheMinutes = 60;
await renovateCache.set(cacheNamespace, cacheKey, labels, cacheMinutes);
return labels;
} catch (err) {
if (err.message === 'registry-failure') {
throw err;
}
if (err.statusCode === 401) {
logger.info(
{ registry, dockerRepository: repository },
'Unauthorized docker lookup'
);
logger.debug({ err });
} else if (err.statusCode === 404) {
logger.warn(
{
err,
registry,
dockerRepository: repository,
tag,
},
'Config Manifest is unknown'
);
} else if (err.statusCode === 429 && registry.endsWith('docker.io')) {
logger.warn({ err }, 'docker registry failure: too many requests');
} else if (err.statusCode >= 500 && err.statusCode < 600) {
logger.warn(
{
err,
registry,
dockerRepository: repository,
tag,
},
'docker registry failure: internal error'
);
} else if (err.code === 'ETIMEDOUT') {
logger.info(
{ registry },
'Timeout when attempting to connect to docker registry'
);
logger.debug({ err });
} else {
logger.warn({ err }, 'Unknown error getting Docker labels');
}
return {};
}
}
/**
* docker.getPkgReleases
*
* 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
* @param {{lookupName :string, registryUrls?: string[] }} args
*/
async function getPkgReleases({ lookupName, registryUrls }) {
const { registry, repository } = getRegistryRepository(
lookupName,
registryUrls
);
const tags = await getTags(registry, repository);
if (!tags) {
return null;
}
const releases = tags.map(version => ({ version }));
const ret = {
dockerRegistry: registry,
dockerRepository: repository,
releases,
};
const latestTag = tags.includes('latest') ? 'latest' : tags[tags.length - 1];
const labels = await getLabels(registry, repository, latestTag);
// istanbul ignore if
if (labels && 'org.opencontainers.image.source' in labels) {
ret.sourceUrl = labels['org.opencontainers.image.source'];
}
return ret;
}