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