types.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  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 external(self):
  148. return self.source
  149. class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')):
  150. @classmethod
  151. def _parse_unix(cls, volume_config):
  152. parts = volume_config.split(':')
  153. if len(parts) > 3:
  154. raise ConfigurationError(
  155. "Volume %s has incorrect format, should be "
  156. "external:internal[:mode]" % volume_config)
  157. if len(parts) == 1:
  158. external = None
  159. internal = os.path.normpath(parts[0])
  160. else:
  161. external = os.path.normpath(parts[0])
  162. internal = os.path.normpath(parts[1])
  163. mode = 'rw'
  164. if len(parts) == 3:
  165. mode = parts[2]
  166. return cls(external, internal, mode)
  167. @classmethod
  168. def _parse_win32(cls, volume_config, normalize):
  169. # relative paths in windows expand to include the drive, eg C:\
  170. # so we join the first 2 parts back together to count as one
  171. mode = 'rw'
  172. def separate_next_section(volume_config):
  173. drive, tail = splitdrive(volume_config)
  174. parts = tail.split(':', 1)
  175. if drive:
  176. parts[0] = drive + parts[0]
  177. return parts
  178. parts = separate_next_section(volume_config)
  179. if len(parts) == 1:
  180. internal = parts[0]
  181. external = None
  182. else:
  183. external = parts[0]
  184. parts = separate_next_section(parts[1])
  185. external = os.path.normpath(external)
  186. internal = parts[0]
  187. if len(parts) > 1:
  188. if ':' in parts[1]:
  189. raise ConfigurationError(
  190. "Volume %s has incorrect format, should be "
  191. "external:internal[:mode]" % volume_config
  192. )
  193. mode = parts[1]
  194. if normalize:
  195. external = normalize_path_for_engine(external) if external else None
  196. return cls(external, internal, mode)
  197. @classmethod
  198. def parse(cls, volume_config, normalize=False):
  199. """Parse a volume_config path and split it into external:internal[:mode]
  200. parts to be returned as a valid VolumeSpec.
  201. """
  202. if IS_WINDOWS_PLATFORM:
  203. return cls._parse_win32(volume_config, normalize)
  204. else:
  205. return cls._parse_unix(volume_config)
  206. def repr(self):
  207. external = self.external + ':' if self.external else ''
  208. mode = ':' + self.mode if self.external else ''
  209. return '{ext}{v.internal}{mode}'.format(mode=mode, ext=external, v=self)
  210. @property
  211. def is_named_volume(self):
  212. res = self.external and not self.external.startswith(('.', '/', '~'))
  213. if not IS_WINDOWS_PLATFORM:
  214. return res
  215. return (
  216. res and not self.external.startswith('\\') and
  217. not win32_root_path_pattern.match(self.external)
  218. )
  219. class ServiceLink(namedtuple('_ServiceLink', 'target alias')):
  220. @classmethod
  221. def parse(cls, link_spec):
  222. target, _, alias = link_spec.partition(':')
  223. if not alias:
  224. alias = target
  225. return cls(target, alias)
  226. def repr(self):
  227. if self.target == self.alias:
  228. return self.target
  229. return '{s.target}:{s.alias}'.format(s=self)
  230. @property
  231. def merge_field(self):
  232. return self.alias
  233. class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode')):
  234. @classmethod
  235. def parse(cls, spec):
  236. if isinstance(spec, six.string_types):
  237. return cls(spec, None, None, None, None)
  238. return cls(
  239. spec.get('source'),
  240. spec.get('target'),
  241. spec.get('uid'),
  242. spec.get('gid'),
  243. spec.get('mode'),
  244. )
  245. @property
  246. def merge_field(self):
  247. return self.source
  248. def repr(self):
  249. return dict(
  250. [(k, v) for k, v in zip(self._fields, self) if v is not None]
  251. )
  252. class ServiceSecret(ServiceConfigBase):
  253. pass
  254. class ServiceConfig(ServiceConfigBase):
  255. pass
  256. class ServicePort(namedtuple('_ServicePort', 'target published protocol mode external_ip')):
  257. def __new__(cls, target, published, *args, **kwargs):
  258. try:
  259. if target:
  260. target = int(target)
  261. except ValueError:
  262. raise ConfigurationError('Invalid target port: {}'.format(target))
  263. if published:
  264. if isinstance(published, six.string_types) and '-' in published: # "x-y:z" format
  265. a, b = published.split('-', 1)
  266. try:
  267. int(a)
  268. int(b)
  269. except ValueError:
  270. raise ConfigurationError('Invalid published port: {}'.format(published))
  271. else:
  272. try:
  273. published = int(published)
  274. except ValueError:
  275. raise ConfigurationError('Invalid published port: {}'.format(published))
  276. return super(ServicePort, cls).__new__(
  277. cls, target, published, *args, **kwargs
  278. )
  279. @classmethod
  280. def parse(cls, spec):
  281. if isinstance(spec, cls):
  282. # When extending a service with ports, the port definitions have already been parsed
  283. return [spec]
  284. if not isinstance(spec, dict):
  285. result = []
  286. try:
  287. for k, v in build_port_bindings([spec]).items():
  288. if '/' in k:
  289. target, proto = k.split('/', 1)
  290. else:
  291. target, proto = (k, None)
  292. for pub in v:
  293. if pub is None:
  294. result.append(
  295. cls(target, None, proto, None, None)
  296. )
  297. elif isinstance(pub, tuple):
  298. result.append(
  299. cls(target, pub[1], proto, None, pub[0])
  300. )
  301. else:
  302. result.append(
  303. cls(target, pub, proto, None, None)
  304. )
  305. except ValueError as e:
  306. raise ConfigurationError(str(e))
  307. return result
  308. return [cls(
  309. spec.get('target'),
  310. spec.get('published'),
  311. spec.get('protocol'),
  312. spec.get('mode'),
  313. None
  314. )]
  315. @property
  316. def merge_field(self):
  317. return (self.target, self.published, self.external_ip, self.protocol)
  318. def repr(self):
  319. return dict(
  320. [(k, v) for k, v in zip(self._fields, self) if v is not None]
  321. )
  322. def legacy_repr(self):
  323. return normalize_port_dict(self.repr())
  324. def normalize_port_dict(port):
  325. return '{external_ip}{has_ext_ip}{published}{is_pub}{target}/{protocol}'.format(
  326. published=port.get('published', ''),
  327. is_pub=(':' if port.get('published') is not None or port.get('external_ip') else ''),
  328. target=port.get('target'),
  329. protocol=port.get('protocol', 'tcp'),
  330. external_ip=port.get('external_ip', ''),
  331. has_ext_ip=(':' if port.get('external_ip') else ''),
  332. )