types.py 14 KB

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