types.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. """
  2. Types for objects parsed from the configuration.
  3. """
  4. from __future__ import absolute_import
  5. from __future__ import unicode_literals
  6. import os
  7. import re
  8. from collections import namedtuple
  9. import six
  10. from docker.utils.ports import build_port_bindings
  11. from ..const import COMPOSEFILE_V1 as V1
  12. from .errors import ConfigurationError
  13. from compose.const import IS_WINDOWS_PLATFORM
  14. from compose.utils import splitdrive
  15. win32_root_path_pattern = re.compile(r'^[A-Za-z]\:\\.*')
  16. class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode type')):
  17. # TODO: drop service_names arg when v1 is removed
  18. @classmethod
  19. def parse(cls, volume_from_config, service_names, version):
  20. func = cls.parse_v1 if version == V1 else cls.parse_v2
  21. return func(service_names, volume_from_config)
  22. @classmethod
  23. def parse_v1(cls, service_names, volume_from_config):
  24. parts = volume_from_config.split(':')
  25. if len(parts) > 2:
  26. raise ConfigurationError(
  27. "volume_from {} has incorrect format, should be "
  28. "service[:mode]".format(volume_from_config))
  29. if len(parts) == 1:
  30. source = parts[0]
  31. mode = 'rw'
  32. else:
  33. source, mode = parts
  34. type = 'service' if source in service_names else 'container'
  35. return cls(source, mode, type)
  36. @classmethod
  37. def parse_v2(cls, service_names, volume_from_config):
  38. parts = volume_from_config.split(':')
  39. if len(parts) > 3:
  40. raise ConfigurationError(
  41. "volume_from {} has incorrect format, should be one of "
  42. "'<service name>[:<mode>]' or "
  43. "'container:<container name>[:<mode>]'".format(volume_from_config))
  44. if len(parts) == 1:
  45. source = parts[0]
  46. return cls(source, 'rw', 'service')
  47. if len(parts) == 2:
  48. if parts[0] == 'container':
  49. type, source = parts
  50. return cls(source, 'rw', type)
  51. source, mode = parts
  52. return cls(source, mode, 'service')
  53. if len(parts) == 3:
  54. type, source, mode = parts
  55. if type not in ('service', 'container'):
  56. raise ConfigurationError(
  57. "Unknown volumes_from type '{}' in '{}'".format(
  58. type,
  59. volume_from_config))
  60. return cls(source, mode, type)
  61. def repr(self):
  62. return '{v.type}:{v.source}:{v.mode}'.format(v=self)
  63. def parse_restart_spec(restart_config):
  64. if not restart_config:
  65. return None
  66. parts = restart_config.split(':')
  67. if len(parts) > 2:
  68. raise ConfigurationError(
  69. "Restart %s has incorrect format, should be "
  70. "mode[:max_retry]" % restart_config)
  71. if len(parts) == 2:
  72. name, max_retry_count = parts
  73. else:
  74. name, = parts
  75. max_retry_count = 0
  76. return {'Name': name, 'MaximumRetryCount': int(max_retry_count)}
  77. def serialize_restart_spec(restart_spec):
  78. if not restart_spec:
  79. return ''
  80. parts = [restart_spec['Name']]
  81. if restart_spec['MaximumRetryCount']:
  82. parts.append(six.text_type(restart_spec['MaximumRetryCount']))
  83. return ':'.join(parts)
  84. def parse_extra_hosts(extra_hosts_config):
  85. if not extra_hosts_config:
  86. return {}
  87. if isinstance(extra_hosts_config, dict):
  88. return dict(extra_hosts_config)
  89. if isinstance(extra_hosts_config, list):
  90. extra_hosts_dict = {}
  91. for extra_hosts_line in extra_hosts_config:
  92. # TODO: validate string contains ':' ?
  93. host, ip = extra_hosts_line.split(':', 1)
  94. extra_hosts_dict[host.strip()] = ip.strip()
  95. return extra_hosts_dict
  96. def normalize_path_for_engine(path):
  97. """Windows paths, c:\my\path\shiny, need to be changed to be compatible with
  98. the Engine. Volume paths are expected to be linux style /c/my/path/shiny/
  99. """
  100. drive, tail = splitdrive(path)
  101. if drive:
  102. path = '/' + drive.lower().rstrip(':') + tail
  103. return path.replace('\\', '/')
  104. class MountSpec(object):
  105. options_map = {
  106. 'volume': {
  107. 'nocopy': 'no_copy'
  108. },
  109. 'bind': {
  110. 'propagation': 'propagation'
  111. }
  112. }
  113. _fields = ['type', 'source', 'target', 'read_only', 'consistency']
  114. @classmethod
  115. def parse(cls, mount_dict, normalize=False):
  116. if mount_dict.get('source'):
  117. mount_dict['source'] = os.path.normpath(mount_dict['source'])
  118. if normalize:
  119. mount_dict['source'] = normalize_path_for_engine(mount_dict['source'])
  120. return cls(**mount_dict)
  121. def __init__(self, type, source=None, target=None, read_only=None, consistency=None, **kwargs):
  122. self.type = type
  123. self.source = source
  124. self.target = target
  125. self.read_only = read_only
  126. self.consistency = consistency
  127. self.options = None
  128. if self.type in kwargs:
  129. self.options = kwargs[self.type]
  130. def as_volume_spec(self):
  131. mode = 'ro' if self.read_only else 'rw'
  132. return VolumeSpec(external=self.source, internal=self.target, mode=mode)
  133. def legacy_repr(self):
  134. return self.as_volume_spec().repr()
  135. def repr(self):
  136. res = {}
  137. for field in self._fields:
  138. if getattr(self, field, None):
  139. res[field] = getattr(self, field)
  140. if self.options:
  141. res[self.type] = self.options
  142. return res
  143. @property
  144. def is_named_volume(self):
  145. return self.type == 'volume' and self.source
  146. @property
  147. def is_tmpfs(self):
  148. return self.type == 'tmpfs'
  149. @property
  150. def external(self):
  151. return self.source
  152. class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')):
  153. @classmethod
  154. def _parse_unix(cls, volume_config):
  155. parts = volume_config.split(':')
  156. if len(parts) > 3:
  157. raise ConfigurationError(
  158. "Volume %s has incorrect format, should be "
  159. "external:internal[:mode]" % volume_config)
  160. if len(parts) == 1:
  161. external = None
  162. internal = os.path.normpath(parts[0])
  163. else:
  164. external = os.path.normpath(parts[0])
  165. internal = os.path.normpath(parts[1])
  166. mode = 'rw'
  167. if len(parts) == 3:
  168. mode = parts[2]
  169. return cls(external, internal, mode)
  170. @classmethod
  171. def _parse_win32(cls, volume_config, normalize):
  172. # relative paths in windows expand to include the drive, eg C:\
  173. # so we join the first 2 parts back together to count as one
  174. mode = 'rw'
  175. def separate_next_section(volume_config):
  176. drive, tail = splitdrive(volume_config)
  177. parts = tail.split(':', 1)
  178. if drive:
  179. parts[0] = drive + parts[0]
  180. return parts
  181. parts = separate_next_section(volume_config)
  182. if len(parts) == 1:
  183. internal = parts[0]
  184. external = None
  185. else:
  186. external = parts[0]
  187. parts = separate_next_section(parts[1])
  188. external = os.path.normpath(external)
  189. internal = parts[0]
  190. if len(parts) > 1:
  191. if ':' in parts[1]:
  192. raise ConfigurationError(
  193. "Volume %s has incorrect format, should be "
  194. "external:internal[:mode]" % volume_config
  195. )
  196. mode = parts[1]
  197. if normalize:
  198. external = normalize_path_for_engine(external) if external else None
  199. return cls(external, internal, mode)
  200. @classmethod
  201. def parse(cls, volume_config, normalize=False):
  202. """Parse a volume_config path and split it into external:internal[:mode]
  203. parts to be returned as a valid VolumeSpec.
  204. """
  205. if IS_WINDOWS_PLATFORM:
  206. return cls._parse_win32(volume_config, normalize)
  207. else:
  208. return cls._parse_unix(volume_config)
  209. def repr(self):
  210. external = self.external + ':' if self.external else ''
  211. mode = ':' + self.mode if self.external else ''
  212. return '{ext}{v.internal}{mode}'.format(mode=mode, ext=external, v=self)
  213. @property
  214. def is_named_volume(self):
  215. res = self.external and not self.external.startswith(('.', '/', '~'))
  216. if not IS_WINDOWS_PLATFORM:
  217. return res
  218. return (
  219. res and not self.external.startswith('\\') and
  220. not win32_root_path_pattern.match(self.external)
  221. )
  222. class ServiceLink(namedtuple('_ServiceLink', 'target alias')):
  223. @classmethod
  224. def parse(cls, link_spec):
  225. target, _, alias = link_spec.partition(':')
  226. if not alias:
  227. alias = target
  228. return cls(target, alias)
  229. def repr(self):
  230. if self.target == self.alias:
  231. return self.target
  232. return '{s.target}:{s.alias}'.format(s=self)
  233. @property
  234. def merge_field(self):
  235. return self.alias
  236. class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode name')):
  237. @classmethod
  238. def parse(cls, spec):
  239. if isinstance(spec, six.string_types):
  240. return cls(spec, None, None, None, None, None)
  241. return cls(
  242. spec.get('source'),
  243. spec.get('target'),
  244. spec.get('uid'),
  245. spec.get('gid'),
  246. spec.get('mode'),
  247. spec.get('name')
  248. )
  249. @property
  250. def merge_field(self):
  251. return self.source
  252. def repr(self):
  253. return dict(
  254. [(k, v) for k, v in zip(self._fields, self) if v is not None]
  255. )
  256. class ServiceSecret(ServiceConfigBase):
  257. pass
  258. class ServiceConfig(ServiceConfigBase):
  259. pass
  260. class ServicePort(namedtuple('_ServicePort', 'target published protocol mode external_ip')):
  261. def __new__(cls, target, published, *args, **kwargs):
  262. try:
  263. if target:
  264. target = int(target)
  265. except ValueError:
  266. raise ConfigurationError('Invalid target port: {}'.format(target))
  267. if published:
  268. if isinstance(published, six.string_types) and '-' in published: # "x-y:z" format
  269. a, b = published.split('-', 1)
  270. try:
  271. int(a)
  272. int(b)
  273. except ValueError:
  274. raise ConfigurationError('Invalid published port: {}'.format(published))
  275. else:
  276. try:
  277. published = int(published)
  278. except ValueError:
  279. raise ConfigurationError('Invalid published port: {}'.format(published))
  280. return super(ServicePort, cls).__new__(
  281. cls, target, published, *args, **kwargs
  282. )
  283. @classmethod
  284. def parse(cls, spec):
  285. if isinstance(spec, cls):
  286. # When extending a service with ports, the port definitions have already been parsed
  287. return [spec]
  288. if not isinstance(spec, dict):
  289. result = []
  290. try:
  291. for k, v in build_port_bindings([spec]).items():
  292. if '/' in k:
  293. target, proto = k.split('/', 1)
  294. else:
  295. target, proto = (k, None)
  296. for pub in v:
  297. if pub is None:
  298. result.append(
  299. cls(target, None, proto, None, None)
  300. )
  301. elif isinstance(pub, tuple):
  302. result.append(
  303. cls(target, pub[1], proto, None, pub[0])
  304. )
  305. else:
  306. result.append(
  307. cls(target, pub, proto, None, None)
  308. )
  309. except ValueError as e:
  310. raise ConfigurationError(str(e))
  311. return result
  312. return [cls(
  313. spec.get('target'),
  314. spec.get('published'),
  315. spec.get('protocol'),
  316. spec.get('mode'),
  317. None
  318. )]
  319. @property
  320. def merge_field(self):
  321. return (self.target, self.published, self.external_ip, self.protocol)
  322. def repr(self):
  323. return dict(
  324. [(k, v) for k, v in zip(self._fields, self) if v is not None]
  325. )
  326. def legacy_repr(self):
  327. return normalize_port_dict(self.repr())
  328. def normalize_port_dict(port):
  329. return '{external_ip}{has_ext_ip}{published}{is_pub}{target}/{protocol}'.format(
  330. published=port.get('published', ''),
  331. is_pub=(':' if port.get('published') is not None or port.get('external_ip') else ''),
  332. target=port.get('target'),
  333. protocol=port.get('protocol', 'tcp'),
  334. external_ip=port.get('external_ip', ''),
  335. has_ext_ip=(':' if port.get('external_ip') else ''),
  336. )