from __future__ import absolute_import from __future__ import unicode_literals import json import logging import six from docker.utils import split_command from docker.utils.ports import split_port from .cli.errors import UserError from .config.serialize import denormalize_config from .network import get_network_defs_for_service from .service import format_environment from .service import NoSuchImageError from .service import parse_repository_tag log = logging.getLogger(__name__) SERVICE_KEYS = { 'working_dir': 'WorkingDir', 'user': 'User', 'labels': 'Labels', } IGNORED_KEYS = {'build'} SUPPORTED_KEYS = { 'image', 'ports', 'expose', 'networks', 'command', 'environment', 'entrypoint', } | set(SERVICE_KEYS) VERSION = '0.1' class NeedsPush(Exception): def __init__(self, image_name): self.image_name = image_name class NeedsPull(Exception): def __init__(self, image_name, service_name): self.image_name = image_name self.service_name = service_name class MissingDigests(Exception): def __init__(self, needs_push, needs_pull): self.needs_push = needs_push self.needs_pull = needs_pull def serialize_bundle(config, image_digests): return json.dumps(to_bundle(config, image_digests), indent=2, sort_keys=True) def get_image_digests(project, allow_push=False): digests = {} needs_push = set() needs_pull = set() for service in project.services: try: digests[service.name] = get_image_digest( service, allow_push=allow_push, ) except NeedsPush as e: needs_push.add(e.image_name) except NeedsPull as e: needs_pull.add(e.service_name) if needs_push or needs_pull: raise MissingDigests(needs_push, needs_pull) return digests def get_image_digest(service, allow_push=False): if 'image' not in service.options: raise UserError( "Service '{s.name}' doesn't define an image tag. An image name is " "required to generate a proper image digest for the bundle. Specify " "an image repo and tag with the 'image' option.".format(s=service)) _, _, separator = parse_repository_tag(service.options['image']) # Compose file already uses a digest, no lookup required if separator == '@': return service.options['image'] try: image = service.image() except NoSuchImageError: action = 'build' if 'build' in service.options else 'pull' raise UserError( "Image not found for service '{service}'. " "You might need to run `docker-compose {action} {service}`." .format(service=service.name, action=action)) if image['RepoDigests']: # TODO: pick a digest based on the image tag if there are multiple # digests return image['RepoDigests'][0] if 'build' not in service.options: raise NeedsPull(service.image_name, service.name) if not allow_push: raise NeedsPush(service.image_name) return push_image(service) def push_image(service): try: digest = service.push() except Exception: log.error( "Failed to push image for service '{s.name}'. Please use an " "image tag that can be pushed to a Docker " "registry.".format(s=service)) raise if not digest: raise ValueError("Failed to get digest for %s" % service.name) repo, _, _ = parse_repository_tag(service.options['image']) identifier = '{repo}@{digest}'.format(repo=repo, digest=digest) # only do this if RepoDigests isn't already populated image = service.image() if not image['RepoDigests']: # Pull by digest so that image['RepoDigests'] is populated for next time # and we don't have to pull/push again service.client.pull(identifier) log.info("Stored digest for {}".format(service.image_name)) return identifier def to_bundle(config, image_digests): if config.networks: log.warn("Unsupported top level key 'networks' - ignoring") if config.volumes: log.warn("Unsupported top level key 'volumes' - ignoring") config = denormalize_config(config) return { 'Version': VERSION, 'Services': { name: convert_service_to_bundle( name, service_dict, image_digests[name], ) for name, service_dict in config['services'].items() }, } def convert_service_to_bundle(name, service_dict, image_digest): container_config = {'Image': image_digest} for key, value in service_dict.items(): if key in IGNORED_KEYS: continue if key not in SUPPORTED_KEYS: log.warn("Unsupported key '{}' in services.{} - ignoring".format(key, name)) continue if key == 'environment': container_config['Env'] = format_environment({ envkey: envvalue for envkey, envvalue in value.items() if envvalue }) continue if key in SERVICE_KEYS: container_config[SERVICE_KEYS[key]] = value continue set_command_and_args( container_config, service_dict.get('entrypoint', []), service_dict.get('command', [])) container_config['Networks'] = make_service_networks(name, service_dict) ports = make_port_specs(service_dict) if ports: container_config['Ports'] = ports return container_config # See https://github.com/docker/swarmkit/blob/agent/exec/container/container.go#L95 def set_command_and_args(config, entrypoint, command): if isinstance(entrypoint, six.string_types): entrypoint = split_command(entrypoint) if isinstance(command, six.string_types): command = split_command(command) if entrypoint: config['Command'] = entrypoint + command return if command: config['Args'] = command def make_service_networks(name, service_dict): networks = [] for network_name, network_def in get_network_defs_for_service(service_dict).items(): for key in network_def.keys(): log.warn( "Unsupported key '{}' in services.{}.networks.{} - ignoring" .format(key, name, network_name)) networks.append(network_name) return networks def make_port_specs(service_dict): ports = [] internal_ports = [ internal_port for port_def in service_dict.get('ports', []) for internal_port in split_port(port_def)[0] ] internal_ports += service_dict.get('expose', []) for internal_port in internal_ports: spec = make_port_spec(internal_port) if spec not in ports: ports.append(spec) return ports def make_port_spec(value): components = six.text_type(value).partition('/') return { 'Protocol': components[2] or 'tcp', 'Port': int(components[0]), }