| 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): |
| self.image_name = image_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.image_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) |
| |
| if not allow_push: |
| raise NeedsPush(service.image_name) |
| |
| return push_image(service) |
| |
| |
| def push_image(service): |
| 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) |
| |
| 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]), |
| } |