types.py 16 KB

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