Skip to content
Snippets Groups Projects
Select Git revision
  • 3edd582b646a52245c769061d752f21e093f2927
  • master default
2 results

index.js

Blame
  • index.ts 18.22 KiB
    import { OutgoingHttpHeaders } from 'http';
    import URL from 'url';
    import is from '@sindresorhus/is';
    import AWS from 'aws-sdk';
    import hasha from 'hasha';
    import parseLinkHeader from 'parse-link-header';
    import wwwAuthenticate from 'www-authenticate';
    import { logger } from '../../logger';
    import { HostRule } from '../../types';
    import * as globalCache from '../../util/cache/global';
    import * as hostRules from '../../util/host-rules';
    import { Http, HttpResponse } from '../../util/http';
    import { DatasourceError, GetReleasesConfig, ReleaseResult } from '../common';
    
    // TODO: add got typings when available
    // TODO: replace www-authenticate with https://www.npmjs.com/package/auth-header ?
    
    export const id = 'docker';
    
    export const defaultConfig = {
      managerBranchPrefix: 'docker-',
      commitMessageTopic: '{{{depName}}} Docker tag',
      major: { enabled: false },
      commitMessageExtra:
        'to v{{#if isMajor}}{{{newMajor}}}{{else}}{{{newVersion}}}{{/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',
        },
      },
      group: {
        commitMessageTopic: '{{{groupName}}} Docker tags',
      },
    };
    
    const http = new Http(id);
    
    const ecrRegex = /\d+\.dkr\.ecr\.([-a-z0-9]+)\.amazonaws\.com/;
    
    export interface RegistryRepository {
      registry: string;
      repository: string;
    }
    
    export function getRegistryRepository(
      lookupName: string,
      registryUrls: string[]
    ): RegistryRepository {
      if (is.nonEmptyArray(registryUrls)) {
        const dockerRegistry = registryUrls[0]
          .replace('https://', '')
          .replace(/\/?$/, '/');
        if (lookupName.startsWith(dockerRegistry)) {
          return {
            registry: dockerRegistry,
            repository: lookupName.replace(dockerRegistry, ''),
          };
        }
      }
      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 && is.nonEmptyArray(registryUrls)) {
        [registry] = registryUrls;
      }
      if (!registry || registry === 'docker.io') {
        registry = 'index.docker.io';
      }
      if (!/^https?:\/\//.exec(registry)) {
        registry = `https://${registry}`;
      }
      const opts = hostRules.find({ hostType: id, url: registry });
      if (opts && opts.insecureRegistry) {
        registry = registry.replace('https', 'http');
      }
      if (registry.endsWith('.docker.io') && !repository.includes('/')) {
        repository = 'library/' + repository;
      }
      return {
        registry,
        repository,
      };
    }
    
    function getECRAuthToken(
      region: string,
      opts: HostRule
    ): Promise<string | null> {
      const config = { region, accessKeyId: undefined, secretAccessKey: undefined };
      if (opts.username && opts.password) {
        config.accessKeyId = opts.username;
        config.secretAccessKey = opts.password;
      }
      const ecr = new AWS.ECR(config);
      return new Promise<string>((resolve) => {
        ecr.getAuthorizationToken({}, (err, data) => {
          if (err) {
            logger.trace({ err }, 'err');
            logger.debug('ECR getAuthorizationToken error');
            resolve(null);
          } else {
            const authorizationToken =
              data &&
              data.authorizationData &&
              data.authorizationData[0] &&
              data.authorizationData[0].authorizationToken;
            if (authorizationToken) {
              resolve(authorizationToken);
            } else {
              logger.warn(
                'Could not extract authorizationToken from ECR getAuthorizationToken response'
              );
              resolve(null);
            }
          }
        });
      });
    }
    
    async function getAuthHeaders(
      registry: string,
      repository: 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 });
        opts.json = true;
        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 ${repository}`);
          await http.get(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 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
          return null;
        }
        if (err.statusCode === 401) {
          logger.debug(
            { registry, dockerRepository: repository },
            'Unauthorized docker lookup'
          );
          logger.debug({ err });
          return null;
        }
        if (err.statusCode === 403) {
          logger.debug(
            { registry, dockerRepository: repository },
            '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 DatasourceError(err);
        }
        // prettier-ignore
        if (err.statusCode === 429 && registry.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization]
          throw new DatasourceError(err);
        }
        if (err.statusCode >= 500 && err.statusCode < 600) {
          throw new DatasourceError(err);
        }
        logger.warn(
          { registry, dockerRepository: repository, 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;
    }
    
    async function getManifestResponse(
      registry: string,
      repository: string,
      tag: string
    ): Promise<HttpResponse> {
      logger.debug(`getManifestResponse(${registry}, ${repository}, ${tag})`);
      try {
        const headers = await getAuthHeaders(registry, repository);
        if (!headers) {
          logger.debug('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 http.get(url, {
          headers,
        });
        return manifestResponse;
      } catch (err) /* istanbul ignore next */ {
        if (err instanceof DatasourceError) {
          throw err;
        }
        if (err.statusCode === 401) {
          logger.debug(
            { registry, dockerRepository: repository },
            'Unauthorized docker lookup'
          );
          logger.debug({ err });
          return null;
        }
        if (err.statusCode === 404) {
          logger.debug(
            {
              err,
              registry,
              dockerRepository: repository,
              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 DatasourceError(err);
        }
        if (err.statusCode >= 500 && err.statusCode < 600) {
          throw new DatasourceError(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: 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
     */
    export async function getDigest(
      { registryUrls, lookupName }: GetReleasesConfig,
      newValue?: string
    ): Promise<string | null> {
      const { registry, repository } = getRegistryRepository(
        lookupName,
        registryUrls
      );
      logger.debug(`getDigest(${registry}, ${repository}, ${newValue})`);
      const newTag = newValue || 'latest';
      const cacheNamespace = 'datasource-docker-digest';
      const cacheKey = `${registry}:${repository}:${newTag}`;
      let digest = null;
      try {
        const cachedResult = await globalCache.get(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 DatasourceError) {
          throw err;
        }
        logger.debug(
          {
            err,
            lookupName,
            newTag,
          },
          'Unknown Error looking up docker image digest'
        );
      }
      const cacheMinutes = 30;
      await globalCache.set(cacheNamespace, cacheKey, digest, cacheMinutes);
      return digest;
    }
    
    async function getTags(
      registry: string,
      repository: string
    ): Promise<string[] | null> {
      let tags: string[] = [];
      try {
        const cacheNamespace = 'datasource-docker-tags';
        const cacheKey = `${registry}:${repository}`;
        const cachedResult = await globalCache.get<string[]>(
          cacheNamespace,
          cacheKey
        );
        // istanbul ignore if
        if (cachedResult !== undefined) {
          return cachedResult;
        }
        // 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(registry) ? 1000 : 10000;
        let url = `${registry}/v2/${repository}/tags/list?n=${limit}`;
        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 http.getJson<{ tags: string[] }>(url, { headers });
          tags = tags.concat(res.body.tags);
          const linkHeader = parseLinkHeader(res.headers.link as string);
          url =
            linkHeader && linkHeader.next
              ? URL.resolve(url, linkHeader.next.url)
              : null;
          page += 1;
        } while (url && page < 20);
        const cacheMinutes = 15;
        await globalCache.set(cacheNamespace, cacheKey, tags, cacheMinutes);
        return tags;
      } catch (err) /* istanbul ignore next */ {
        if (err instanceof DatasourceError) {
          throw err;
        }
        logger.debug(
          {
            err,
          },
          'docker.getTags() error'
        );
        if (err.statusCode === 404 && !repository.includes('/')) {
          logger.debug(
            `Retrying Tags for ${registry}/${repository} using library/ prefix`
          );
          return getTags(registry, 'library/' + repository);
        }
        if (err.statusCode === 401 || err.statusCode === 403) {
          logger.debug(
            { registry, dockerRepository: repository, err },
            'Not authorised to look up docker tags'
          );
          return null;
        }
        // prettier-ignore
        if (err.statusCode === 429 && registry.endsWith('docker.io')) { // lgtm [js/incomplete-url-substring-sanitization]
          logger.warn(
            { registry, dockerRepository: repository, err },
            'docker registry failure: too many requests'
          );
          throw new DatasourceError(err);
        }
        if (err.statusCode >= 500 && err.statusCode < 600) {
          logger.warn(
            { registry, dockerRepository: repository, err },
            'docker registry failure: internal error'
          );
          throw new DatasourceError(err);
        }
        if (err.code === 'ETIMEDOUT') {
          logger.debug(
            { 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: string,
      repository: string,
      tag: string
    ): Promise<Record<string, string>> {
      logger.debug(`getLabels(${registry}, ${repository}, ${tag})`);
      const cacheNamespace = 'datasource-docker-labels';
      const cacheKey = `${registry}:${repository}:${tag}`;
      const cachedResult = await globalCache.get<Record<string, string>>(
        cacheNamespace,
        cacheKey
      );
      // istanbul ignore if
      if (cachedResult !== undefined) {
        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.debug(
            {
              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 schema version is not 2'
          );
          return {};
        }
        let labels: Record<string, string> = {};
        const configDigest = manifest.config.digest;
        const headers = await getAuthHeaders(registry, repository);
        if (!headers) {
          logger.debug('No docker auth found - returning');
          return {};
        }
        const url = `${registry}/v2/${repository}/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 globalCache.set(cacheNamespace, cacheKey, labels, cacheMinutes);
        return labels;
      } catch (err) {
        if (err instanceof DatasourceError) {
          throw err;
        }
        if (err.statusCode === 400 || err.statusCode === 401) {
          logger.debug(
            { registry, dockerRepository: repository, err },
            'Unauthorized docker lookup'
          );
        } 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') // 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: repository,
              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: repository, tag, err },
            'Unknown error getting Docker labels'
          );
        }
        return {};
      }
    }
    
    /**
     * 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
     */
    export async function getReleases({
      lookupName,
      registryUrls,
    }: GetReleasesConfig): Promise<ReleaseResult | null> {
      const { registry, repository } = getRegistryRepository(
        lookupName,
        registryUrls
      );
      const tags = await getTags(registry, repository);
      if (!tags) {
        return null;
      }
      const releases = tags.map((version) => ({ version }));
      const ret: ReleaseResult = {
        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;
    }