types.py 15 KB

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