# -*- coding: utf-8 -*- """Support the Deis workflow by manipulating and publishing Docker images.""" import logging import os import backoff from django.conf import settings from rest_framework.exceptions import PermissionDenied import docker import docker.constants from docker.auth import auth from docker.errors import APIError import requests logger = logging.getLogger(__name__) class RegistryException(Exception): pass class DockerClient(object): """Use the Docker API to pull, tag, build, and push images to deis-registry.""" def __init__(self): timeout = os.environ.get('DOCKER_CLIENT_TIMEOUT', docker.constants.DEFAULT_TIMEOUT_SECONDS) self.client = docker.Client(version='auto', timeout=timeout) self.registry = settings.REGISTRY_HOST + ':' + str(settings.REGISTRY_PORT) def login(self, repository, creds=None): """Log into a registry if auth is provided""" if not creds: return # parse out the hostname since repo variable is hostname + path registry, _ = auth.resolve_repository_name(repository) registry_auth = { 'username': None, 'password': None, 'email': None, 'registry': registry } registry_auth.update(creds) if not registry_auth['username'] or not registry_auth['password']: msg = 'Registry auth requires a username and a password' logger.error(msg) raise PermissionDenied(msg) logger.info('Logging into Registry {} with username {}'.format(repository, registry_auth['username'])) # noqa response = self.client.login(**registry_auth) success = response.get('Status') == 'Login Succeeded' or response.get('username') == registry_auth['username'] # noqa if not success: raise PermissionDenied('Could not log into {} with username {}'.format(repository, registry_auth['username'])) # noqa logger.info('Successfully logged into {} with {}'.format(repository, registry_auth['username'])) # noqa def get_port(self, target, deis_registry=False, creds=None): """ Get a port from a Docker image """ # get the target repository name and tag name, _ = docker.utils.parse_repository_tag(target) # strip any "http://host.domain:port" prefix from the target repository name, # since we always publish to the Deis registry repo, name = auth.split_repo_name(name) # log into pull repo if not deis_registry: self.login(repo, creds) info = self.inspect_image(target) if 'ExposedPorts' not in info['Config']: return None port = int(list(info['Config']['ExposedPorts'].keys())[0].split('/')[0]) return port def publish_release(self, source, target, deis_registry=False, creds=None): """Update a source Docker image with environment config and publish it to deis-registry.""" # get the source repository name and tag src_name, src_tag = docker.utils.parse_repository_tag(source) # get the target repository name and tag name, tag = docker.utils.parse_repository_tag(target) # strip any "http://host.domain:port" prefix from the target repository name, # since we always publish to the Deis registry repo, name = auth.split_repo_name(name) # pull the source image from the registry # NOTE: this relies on an implementation detail of deis-builder, that # the image has been uploaded already to deis-registry if deis_registry: repo = "{}/{}".format(self.registry, src_name) else: repo = src_name try: # log into pull repo if creds is not None: self.login(repo, creds) # pull image from source repository self.pull(repo, src_tag) # tag the image locally without the repository URL image = "{}:{}".format(src_name, src_tag) self.tag(image, "{}/{}".format(self.registry, name), tag=tag) # push the image to deis-registry self.push("{}/{}".format(self.registry, name), tag) except APIError as e: raise RegistryException(str(e)) @backoff.on_exception(backoff.expo, Exception, max_tries=3) def pull(self, repo, tag): """Pull a Docker image into the local storage graph.""" check_blacklist(repo) logger.info("Pulling Docker image {}:{}".format(repo, tag)) stream = self.client.pull(repo, tag=tag, stream=True, decode=True) log_output(stream, 'pull', repo, tag) @backoff.on_exception(backoff.expo, Exception, max_tries=3) def push(self, repo, tag): """Push a local Docker image to a registry.""" logger.info("Pushing Docker image {}:{}".format(repo, tag)) stream = self.client.push(repo, tag=tag, stream=True, decode=True) log_output(stream, 'push', repo, tag) @backoff.on_exception(backoff.expo, Exception, max_tries=3) def tag(self, image, repo, tag): """Tag a local Docker image with a new name and tag.""" check_blacklist(repo) logger.info("Tagging Docker image {} as {}:{}".format(image, repo, tag)) if not self.client.tag(image, repo, tag=tag, force=True): raise RegistryException('Tagging {} as {}:{} failed'.format(image, repo, tag)) @backoff.on_exception(backoff.expo, Exception, max_tries=3) def inspect_image(self, target): """ Inspect docker image to gather information from it try thrice to find the port before raising exception as docker-py is flaky """ # image already includes the tag, so we split it out here repo, tag = docker.utils.parse_repository_tag(target) # make sure image is pulled locally already self.pull(repo, tag=tag) # inspect the image return self.client.inspect_image(target) def check_blacklist(repo): """Check a Docker repository name for collision with deis/* components.""" blacklisted = [ # NOTE: keep this list up to date! 'builder', 'controller', 'database', 'dockerbuilder', 'etcd', 'minio', 'registry', 'router', 'slugbuilder', 'slugrunner', 'workflow', 'workflow-manager', ] if any("deis/{}".format(c) in repo for c in blacklisted): raise PermissionDenied("Repository name {} is not allowed, as it is reserved by Deis".format(repo)) # noqa def log_output(stream, operation, repo, tag): """Log a stream at DEBUG level, and raise RegistryException if it contains an error""" try: for chunk in stream: # error handling requires looking at the response body if 'error' in chunk: stream_error(chunk, operation, repo, tag) except requests.packages.urllib3.exceptions.ReadTimeoutError as e: message = 'Operation {} timed out for image {}:{}'.format(operation, repo, tag) raise RegistryException(message) from e def stream_error(chunk, operation, repo, tag): """Translate docker stream errors into a more digestable format""" # grab the generic error and strip the useless Error: portion message = chunk['error'].replace('Error: ', '') # not all errors provide the code if 'code' in chunk['errorDetail']: # permission denied on the repo if chunk['errorDetail']['code'] == 403: message = 'Permission Denied attempting to {} image {}:{}'.format(operation, repo, tag) raise RegistryException(message) def publish_release(source, target, deis_registry, creds=None): return DockerClient().publish_release(source, target, deis_registry, creds) def get_port(target, deis_registry, creds=None): return DockerClient().get_port(target, deis_registry, creds)