bundle.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. from __future__ import absolute_import
  2. from __future__ import unicode_literals
  3. import json
  4. import logging
  5. import six
  6. from docker.utils import split_command
  7. from docker.utils.ports import split_port
  8. from .cli.errors import UserError
  9. from .config.serialize import denormalize_config
  10. from .network import get_network_defs_for_service
  11. from .service import format_environment
  12. from .service import NoSuchImageError
  13. from .service import parse_repository_tag
  14. log = logging.getLogger(__name__)
  15. SERVICE_KEYS = {
  16. 'working_dir': 'WorkingDir',
  17. 'user': 'User',
  18. 'labels': 'Labels',
  19. }
  20. IGNORED_KEYS = {'build'}
  21. SUPPORTED_KEYS = {
  22. 'image',
  23. 'ports',
  24. 'expose',
  25. 'networks',
  26. 'command',
  27. 'environment',
  28. 'entrypoint',
  29. } | set(SERVICE_KEYS)
  30. VERSION = '0.1'
  31. class NeedsPush(Exception):
  32. def __init__(self, image_name):
  33. self.image_name = image_name
  34. class NeedsPull(Exception):
  35. def __init__(self, image_name, service_name):
  36. self.image_name = image_name
  37. self.service_name = service_name
  38. class MissingDigests(Exception):
  39. def __init__(self, needs_push, needs_pull):
  40. self.needs_push = needs_push
  41. self.needs_pull = needs_pull
  42. def serialize_bundle(config, image_digests):
  43. return json.dumps(to_bundle(config, image_digests), indent=2, sort_keys=True)
  44. def get_image_digests(project, allow_push=False):
  45. digests = {}
  46. needs_push = set()
  47. needs_pull = set()
  48. for service in project.services:
  49. try:
  50. digests[service.name] = get_image_digest(
  51. service,
  52. allow_push=allow_push,
  53. )
  54. except NeedsPush as e:
  55. needs_push.add(e.image_name)
  56. except NeedsPull as e:
  57. needs_pull.add(e.service_name)
  58. if needs_push or needs_pull:
  59. raise MissingDigests(needs_push, needs_pull)
  60. return digests
  61. def get_image_digest(service, allow_push=False):
  62. if 'image' not in service.options:
  63. raise UserError(
  64. "Service '{s.name}' doesn't define an image tag. An image name is "
  65. "required to generate a proper image digest for the bundle. Specify "
  66. "an image repo and tag with the 'image' option.".format(s=service))
  67. _, _, separator = parse_repository_tag(service.options['image'])
  68. # Compose file already uses a digest, no lookup required
  69. if separator == '@':
  70. return service.options['image']
  71. digest = get_digest(service)
  72. if digest:
  73. return digest
  74. if 'build' not in service.options:
  75. raise NeedsPull(service.image_name, service.name)
  76. if not allow_push:
  77. raise NeedsPush(service.image_name)
  78. return push_image(service)
  79. def get_digest(service):
  80. digest = None
  81. try:
  82. image = service.image()
  83. # TODO: pick a digest based on the image tag if there are multiple
  84. # digests
  85. if image['RepoDigests']:
  86. digest = image['RepoDigests'][0]
  87. except NoSuchImageError:
  88. try:
  89. # Fetch the image digest from the registry
  90. distribution = service.get_image_registry_data()
  91. if distribution['Descriptor']['digest']:
  92. digest = '{image_name}@{digest}'.format(
  93. image_name=service.image_name,
  94. digest=distribution['Descriptor']['digest']
  95. )
  96. except NoSuchImageError:
  97. raise UserError(
  98. "Digest not found for service '{service}'. "
  99. "Repository does not exist or may require 'docker login'"
  100. .format(service=service.name))
  101. return digest
  102. def push_image(service):
  103. try:
  104. digest = service.push()
  105. except Exception:
  106. log.error(
  107. "Failed to push image for service '{s.name}'. Please use an "
  108. "image tag that can be pushed to a Docker "
  109. "registry.".format(s=service))
  110. raise
  111. if not digest:
  112. raise ValueError("Failed to get digest for %s" % service.name)
  113. repo, _, _ = parse_repository_tag(service.options['image'])
  114. identifier = '{repo}@{digest}'.format(repo=repo, digest=digest)
  115. # only do this if RepoDigests isn't already populated
  116. image = service.image()
  117. if not image['RepoDigests']:
  118. # Pull by digest so that image['RepoDigests'] is populated for next time
  119. # and we don't have to pull/push again
  120. service.client.pull(identifier)
  121. log.info("Stored digest for {}".format(service.image_name))
  122. return identifier
  123. def to_bundle(config, image_digests):
  124. if config.networks:
  125. log.warning("Unsupported top level key 'networks' - ignoring")
  126. if config.volumes:
  127. log.warning("Unsupported top level key 'volumes' - ignoring")
  128. config = denormalize_config(config)
  129. return {
  130. 'Version': VERSION,
  131. 'Services': {
  132. name: convert_service_to_bundle(
  133. name,
  134. service_dict,
  135. image_digests[name],
  136. )
  137. for name, service_dict in config['services'].items()
  138. },
  139. }
  140. def convert_service_to_bundle(name, service_dict, image_digest):
  141. container_config = {'Image': image_digest}
  142. for key, value in service_dict.items():
  143. if key in IGNORED_KEYS:
  144. continue
  145. if key not in SUPPORTED_KEYS:
  146. log.warning("Unsupported key '{}' in services.{} - ignoring".format(key, name))
  147. continue
  148. if key == 'environment':
  149. container_config['Env'] = format_environment({
  150. envkey: envvalue for envkey, envvalue in value.items()
  151. if envvalue
  152. })
  153. continue
  154. if key in SERVICE_KEYS:
  155. container_config[SERVICE_KEYS[key]] = value
  156. continue
  157. set_command_and_args(
  158. container_config,
  159. service_dict.get('entrypoint', []),
  160. service_dict.get('command', []))
  161. container_config['Networks'] = make_service_networks(name, service_dict)
  162. ports = make_port_specs(service_dict)
  163. if ports:
  164. container_config['Ports'] = ports
  165. return container_config
  166. # See https://github.com/docker/swarmkit/blob/agent/exec/container/container.go#L95
  167. def set_command_and_args(config, entrypoint, command):
  168. if isinstance(entrypoint, six.string_types):
  169. entrypoint = split_command(entrypoint)
  170. if isinstance(command, six.string_types):
  171. command = split_command(command)
  172. if entrypoint:
  173. config['Command'] = entrypoint + command
  174. return
  175. if command:
  176. config['Args'] = command
  177. def make_service_networks(name, service_dict):
  178. networks = []
  179. for network_name, network_def in get_network_defs_for_service(service_dict).items():
  180. for key in network_def.keys():
  181. log.warning(
  182. "Unsupported key '{}' in services.{}.networks.{} - ignoring"
  183. .format(key, name, network_name))
  184. networks.append(network_name)
  185. return networks
  186. def make_port_specs(service_dict):
  187. ports = []
  188. internal_ports = [
  189. internal_port
  190. for port_def in service_dict.get('ports', [])
  191. for internal_port in split_port(port_def)[0]
  192. ]
  193. internal_ports += service_dict.get('expose', [])
  194. for internal_port in internal_ports:
  195. spec = make_port_spec(internal_port)
  196. if spec not in ports:
  197. ports.append(spec)
  198. return ports
  199. def make_port_spec(value):
  200. components = six.text_type(value).partition('/')
  201. return {
  202. 'Protocol': components[2] or 'tcp',
  203. 'Port': int(components[0]),
  204. }