types.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  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. class MountSpec(object):
  108. options_map = {
  109. 'volume': {
  110. 'nocopy': 'no_copy'
  111. },
  112. 'bind': {
  113. 'propagation': 'propagation'
  114. },
  115. 'tmpfs': {
  116. 'size': 'tmpfs_size'
  117. }
  118. }
  119. _fields = ['type', 'source', 'target', 'read_only', 'consistency']
  120. @classmethod
  121. def parse(cls, mount_dict, normalize=False, win_host=False):
  122. normpath = ntpath.normpath if win_host else os.path.normpath
  123. if mount_dict.get('source'):
  124. if mount_dict['type'] == 'tmpfs':
  125. raise ConfigurationError('tmpfs mounts can not specify a source')
  126. mount_dict['source'] = normpath(mount_dict['source'])
  127. if normalize:
  128. mount_dict['source'] = normalize_path_for_engine(mount_dict['source'])
  129. return cls(**mount_dict)
  130. def __init__(self, type, source=None, target=None, read_only=None, consistency=None, **kwargs):
  131. self.type = type
  132. self.source = source
  133. self.target = target
  134. self.read_only = read_only
  135. self.consistency = consistency
  136. self.options = None
  137. if self.type in kwargs:
  138. self.options = kwargs[self.type]
  139. def as_volume_spec(self):
  140. mode = 'ro' if self.read_only else 'rw'
  141. return VolumeSpec(external=self.source, internal=self.target, mode=mode)
  142. def legacy_repr(self):
  143. return self.as_volume_spec().repr()
  144. def repr(self):
  145. res = {}
  146. for field in self._fields:
  147. if getattr(self, field, None):
  148. res[field] = getattr(self, field)
  149. if self.options:
  150. res[self.type] = self.options
  151. return res
  152. @property
  153. def is_named_volume(self):
  154. return self.type == 'volume' and self.source
  155. @property
  156. def is_tmpfs(self):
  157. return self.type == 'tmpfs'
  158. @property
  159. def external(self):
  160. return self.source
  161. class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')):
  162. win32 = False
  163. @classmethod
  164. def _parse_unix(cls, volume_config):
  165. parts = volume_config.split(':')
  166. if len(parts) > 3:
  167. raise ConfigurationError(
  168. "Volume %s has incorrect format, should be "
  169. "external:internal[:mode]" % volume_config)
  170. if len(parts) == 1:
  171. external = None
  172. internal = os.path.normpath(parts[0])
  173. else:
  174. external = os.path.normpath(parts[0])
  175. internal = os.path.normpath(parts[1])
  176. mode = 'rw'
  177. if len(parts) == 3:
  178. mode = parts[2]
  179. return cls(external, internal, mode)
  180. @classmethod
  181. def _parse_win32(cls, volume_config, normalize):
  182. # relative paths in windows expand to include the drive, eg C:\
  183. # so we join the first 2 parts back together to count as one
  184. mode = 'rw'
  185. def separate_next_section(volume_config):
  186. drive, tail = splitdrive(volume_config)
  187. parts = tail.split(':', 1)
  188. if drive:
  189. parts[0] = drive + parts[0]
  190. return parts
  191. parts = separate_next_section(volume_config)
  192. if len(parts) == 1:
  193. internal = parts[0]
  194. external = None
  195. else:
  196. external = parts[0]
  197. parts = separate_next_section(parts[1])
  198. external = ntpath.normpath(external)
  199. internal = parts[0]
  200. if len(parts) > 1:
  201. if ':' in parts[1]:
  202. raise ConfigurationError(
  203. "Volume %s has incorrect format, should be "
  204. "external:internal[:mode]" % volume_config
  205. )
  206. mode = parts[1]
  207. if normalize:
  208. external = normalize_path_for_engine(external) if external else None
  209. result = cls(external, internal, mode)
  210. result.win32 = True
  211. return result
  212. @classmethod
  213. def parse(cls, volume_config, normalize=False, win_host=False):
  214. """Parse a volume_config path and split it into external:internal[:mode]
  215. parts to be returned as a valid VolumeSpec.
  216. """
  217. if IS_WINDOWS_PLATFORM or win_host:
  218. return cls._parse_win32(volume_config, normalize)
  219. else:
  220. return cls._parse_unix(volume_config)
  221. def repr(self):
  222. external = self.external + ':' if self.external else ''
  223. mode = ':' + self.mode if self.external else ''
  224. return '{ext}{v.internal}{mode}'.format(mode=mode, ext=external, v=self)
  225. @property
  226. def is_named_volume(self):
  227. res = self.external and not self.external.startswith(('.', '/', '~'))
  228. if not self.win32:
  229. return res
  230. return (
  231. res and not self.external.startswith('\\') and
  232. not win32_root_path_pattern.match(self.external)
  233. )
  234. class ServiceLink(namedtuple('_ServiceLink', 'target alias')):
  235. @classmethod
  236. def parse(cls, link_spec):
  237. target, _, alias = link_spec.partition(':')
  238. if not alias:
  239. alias = target
  240. return cls(target, alias)
  241. def repr(self):
  242. if self.target == self.alias:
  243. return self.target
  244. return '{s.target}:{s.alias}'.format(s=self)
  245. @property
  246. def merge_field(self):
  247. return self.alias
  248. class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode name')):
  249. @classmethod
  250. def parse(cls, spec):
  251. if isinstance(spec, six.string_types):
  252. return cls(spec, None, None, None, None, None)
  253. return cls(
  254. spec.get('source'),
  255. spec.get('target'),
  256. spec.get('uid'),
  257. spec.get('gid'),
  258. spec.get('mode'),
  259. spec.get('name')
  260. )
  261. @property
  262. def merge_field(self):
  263. return self.source
  264. def repr(self):
  265. return dict(
  266. [(k, v) for k, v in zip(self._fields, self) if v is not None]
  267. )
  268. class ServiceSecret(ServiceConfigBase):
  269. pass
  270. class ServiceConfig(ServiceConfigBase):
  271. pass
  272. class ServicePort(namedtuple('_ServicePort', 'target published protocol mode external_ip')):
  273. def __new__(cls, target, published, *args, **kwargs):
  274. try:
  275. if target:
  276. target = int(target)
  277. except ValueError:
  278. raise ConfigurationError('Invalid target port: {}'.format(target))
  279. if published:
  280. if isinstance(published, six.string_types) and '-' in published: # "x-y:z" format
  281. a, b = published.split('-', 1)
  282. try:
  283. int(a)
  284. int(b)
  285. except ValueError:
  286. raise ConfigurationError('Invalid published port: {}'.format(published))
  287. else:
  288. try:
  289. published = int(published)
  290. except ValueError:
  291. raise ConfigurationError('Invalid published port: {}'.format(published))
  292. return super(ServicePort, cls).__new__(
  293. cls, target, published, *args, **kwargs
  294. )
  295. @classmethod
  296. def parse(cls, spec):
  297. if isinstance(spec, cls):
  298. # When extending a service with ports, the port definitions have already been parsed
  299. return [spec]
  300. if not isinstance(spec, dict):
  301. result = []
  302. try:
  303. for k, v in build_port_bindings([spec]).items():
  304. if '/' in k:
  305. target, proto = k.split('/', 1)
  306. else:
  307. target, proto = (k, None)
  308. for pub in v:
  309. if pub is None:
  310. result.append(
  311. cls(target, None, proto, None, None)
  312. )
  313. elif isinstance(pub, tuple):
  314. result.append(
  315. cls(target, pub[1], proto, None, pub[0])
  316. )
  317. else:
  318. result.append(
  319. cls(target, pub, proto, None, None)
  320. )
  321. except ValueError as e:
  322. raise ConfigurationError(str(e))
  323. return result
  324. return [cls(
  325. spec.get('target'),
  326. spec.get('published'),
  327. spec.get('protocol'),
  328. spec.get('mode'),
  329. None
  330. )]
  331. @property
  332. def merge_field(self):
  333. return (self.target, self.published, self.external_ip, self.protocol)
  334. def repr(self):
  335. return dict(
  336. [(k, v) for k, v in zip(self._fields, self) if v is not None]
  337. )
  338. def legacy_repr(self):
  339. return normalize_port_dict(self.repr())
  340. class GenericResource(namedtuple('_GenericResource', 'kind value')):
  341. @classmethod
  342. def parse(cls, dct):
  343. if 'discrete_resource_spec' not in dct:
  344. raise ConfigurationError(
  345. 'generic_resource entry must include a discrete_resource_spec key'
  346. )
  347. if 'kind' not in dct['discrete_resource_spec']:
  348. raise ConfigurationError(
  349. 'generic_resource entry must include a discrete_resource_spec.kind subkey'
  350. )
  351. return cls(
  352. dct['discrete_resource_spec']['kind'],
  353. dct['discrete_resource_spec'].get('value')
  354. )
  355. def repr(self):
  356. return {
  357. 'discrete_resource_spec': {
  358. 'kind': self.kind,
  359. 'value': self.value,
  360. }
  361. }
  362. @property
  363. def merge_field(self):
  364. return self.kind
  365. def normalize_port_dict(port):
  366. return '{external_ip}{has_ext_ip}{published}{is_pub}{target}/{protocol}'.format(
  367. published=port.get('published', ''),
  368. is_pub=(':' if port.get('published') is not None or port.get('external_ip') else ''),
  369. target=port.get('target'),
  370. protocol=port.get('protocol', 'tcp'),
  371. external_ip=port.get('external_ip', ''),
  372. has_ext_ip=(':' if port.get('external_ip') else ''),
  373. )
  374. class SecurityOpt(namedtuple('_SecurityOpt', 'value src_file')):
  375. @classmethod
  376. def parse(cls, value):
  377. # based on https://github.com/docker/cli/blob/9de1b162f/cli/command/container/opts.go#L673-L697
  378. con = value.split('=', 2)
  379. if len(con) == 1 and con[0] != 'no-new-privileges':
  380. if ':' not in value:
  381. raise ConfigurationError('Invalid security_opt: {}'.format(value))
  382. con = value.split(':', 2)
  383. if con[0] == 'seccomp' and con[1] != 'unconfined':
  384. try:
  385. with open(unquote_path(con[1]), 'r') as f:
  386. seccomp_data = json.load(f)
  387. except (IOError, ValueError) as e:
  388. raise ConfigurationError('Error reading seccomp profile: {}'.format(e))
  389. return cls(
  390. 'seccomp={}'.format(json.dumps(seccomp_data)), con[1]
  391. )
  392. return cls(value, None)
  393. def repr(self):
  394. if self.src_file is not None:
  395. return 'seccomp:{}'.format(self.src_file)
  396. return self.value