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