from __future__ import absolute_import from __future__ import unicode_literals import json import logging import six 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 NoSuchImageError from .service import parse_repository_tag log = logging.getLogger(__name__) SERVICE_KEYS = { 'command': 'Command', 'environment': 'Env', 'working_dir': 'WorkingDir', } VERSION = '0.1' def serialize_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") return json.dumps( to_bundle(config, image_digests), indent=2, sort_keys=True, ) def get_image_digests(project): return { service.name: get_image_digest(service) for service in project.services } def get_image_digest(service): 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)) repo, tag, 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: log.warn( "Compose needs to pull the image for '{s.name}' in order to create " "a bundle. This may result in a more recent image being used. " "It is recommended that you use an image tagged with a " "specific version to minimize the potential " "differences.".format(s=service)) digest = service.pull() else: try: digest = service.push() except: 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) identifier = '{repo}@{digest}'.format(repo=repo, digest=digest) # 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) return identifier def to_bundle(config, image_digests): 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_id): container_config = {'Image': image_id} for key, value in service_dict.items(): if key in ('build', 'image', 'ports', 'expose', 'networks'): pass elif key == 'environment': container_config['env'] = { envkey: envvalue for envkey, envvalue in value.items() if envvalue } elif key in SERVICE_KEYS: container_config[SERVICE_KEYS[key]] = value else: log.warn("Unsupported key '{}' in services.{} - ignoring".format(key, name)) container_config['Networks'] = make_service_networks(name, service_dict) ports = make_port_specs(service_dict) if ports: container_config['Ports'] = ports return container_config 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]), }