types.py 16 KB

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