1"""
2Functions to translate input for container creation
3"""
4
5import os
6
7from salt.exceptions import SaltInvocationError
8
9from . import helpers
10
11ALIASES = {
12    "cmd": "command",
13    "cpuset": "cpuset_cpus",
14    "dns_option": "dns_opt",
15    "env": "environment",
16    "expose": "ports",
17    "interactive": "stdin_open",
18    "ipc": "ipc_mode",
19    "label": "labels",
20    "memory": "mem_limit",
21    "memory_swap": "memswap_limit",
22    "publish": "port_bindings",
23    "publish_all": "publish_all_ports",
24    "restart": "restart_policy",
25    "rm": "auto_remove",
26    "sysctl": "sysctls",
27    "security_opts": "security_opt",
28    "ulimit": "ulimits",
29    "user_ns_mode": "userns_mode",
30    "volume": "volumes",
31    "workdir": "working_dir",
32}
33ALIASES_REVMAP = {y: x for x, y in ALIASES.items()}
34
35
36def _merge_keys(kwargs):
37    """
38    The log_config is a mixture of the CLI options --log-driver and --log-opt
39    (which we support in Salt as log_driver and log_opt, respectively), but it
40    must be submitted to the host config in the format {'Type': log_driver,
41    'Config': log_opt}. So, we need to construct this argument to be passed to
42    the API from those two arguments.
43    """
44    log_driver = kwargs.pop("log_driver", helpers.NOTSET)
45    log_opt = kwargs.pop("log_opt", helpers.NOTSET)
46    if "log_config" not in kwargs:
47        if log_driver is not helpers.NOTSET or log_opt is not helpers.NOTSET:
48            kwargs["log_config"] = {
49                "Type": log_driver if log_driver is not helpers.NOTSET else "none",
50                "Config": log_opt if log_opt is not helpers.NOTSET else {},
51            }
52
53
54def _post_processing(kwargs, skip_translate, invalid):
55    """
56    Additional container-specific post-translation processing
57    """
58    # Don't allow conflicting options to be set
59    if kwargs.get("port_bindings") is not None and kwargs.get("publish_all_ports"):
60        kwargs.pop("port_bindings")
61        invalid["port_bindings"] = "Cannot be used when publish_all_ports=True"
62    if kwargs.get("hostname") is not None and kwargs.get("network_mode") == "host":
63        kwargs.pop("hostname")
64        invalid["hostname"] = "Cannot be used when network_mode=True"
65
66    # Make sure volumes and ports are defined to match the binds and port_bindings
67    if kwargs.get("binds") is not None and (
68        skip_translate is True
69        or all(x not in skip_translate for x in ("binds", "volume", "volumes"))
70    ):
71        # Make sure that all volumes defined in "binds" are included in the
72        # "volumes" param.
73        auto_volumes = []
74        if isinstance(kwargs["binds"], dict):
75            for val in kwargs["binds"].values():
76                try:
77                    if "bind" in val:
78                        auto_volumes.append(val["bind"])
79                except TypeError:
80                    continue
81        else:
82            if isinstance(kwargs["binds"], list):
83                auto_volume_defs = kwargs["binds"]
84            else:
85                try:
86                    auto_volume_defs = helpers.split(kwargs["binds"])
87                except AttributeError:
88                    auto_volume_defs = []
89            for val in auto_volume_defs:
90                try:
91                    auto_volumes.append(helpers.split(val, ":")[1])
92                except IndexError:
93                    continue
94        if auto_volumes:
95            actual_volumes = kwargs.setdefault("volumes", [])
96            actual_volumes.extend([x for x in auto_volumes if x not in actual_volumes])
97            # Sort list to make unit tests more reliable
98            actual_volumes.sort()
99
100    if kwargs.get("port_bindings") is not None and all(
101        x not in skip_translate for x in ("port_bindings", "expose", "ports")
102    ):
103        # Make sure that all ports defined in "port_bindings" are included in
104        # the "ports" param.
105        ports_to_bind = list(kwargs["port_bindings"])
106        if ports_to_bind:
107            ports_to_open = set(kwargs.get("ports", []))
108            ports_to_open.update([helpers.get_port_def(x) for x in ports_to_bind])
109            kwargs["ports"] = list(ports_to_open)
110
111    if "ports" in kwargs and all(x not in skip_translate for x in ("expose", "ports")):
112        # TCP ports should only be passed as the port number. Normalize the
113        # input so a port definition of 80/tcp becomes just 80 instead of
114        # (80, 'tcp').
115        for index, _ in enumerate(kwargs["ports"]):
116            try:
117                if kwargs["ports"][index][1] == "tcp":
118                    kwargs["ports"][index] = ports_to_open[index][0]
119            except TypeError:
120                continue
121
122
123# Functions below must match names of docker-py arguments
124def auto_remove(val, **kwargs):  # pylint: disable=unused-argument
125    return helpers.translate_bool(val)
126
127
128def binds(val, **kwargs):  # pylint: disable=unused-argument
129    """
130    On the CLI, these are passed as multiple instances of a given CLI option.
131    In Salt, we accept these as a comma-delimited list but the API expects a
132    Python list.
133    """
134    if not isinstance(val, dict):
135        if not isinstance(val, list):
136            try:
137                val = helpers.split(val)
138            except AttributeError:
139                raise SaltInvocationError(
140                    "'{}' is not a dictionary or list of bind definitions".format(val)
141                )
142    return val
143
144
145def blkio_weight(val, **kwargs):  # pylint: disable=unused-argument
146    return helpers.translate_int(val)
147
148
149def blkio_weight_device(val, **kwargs):  # pylint: disable=unused-argument
150    """
151    CLI input is a list of PATH:WEIGHT pairs, but the API expects a list of
152    dictionaries in the format [{'Path': path, 'Weight': weight}]
153    """
154    val = helpers.map_vals(val, "Path", "Weight")
155    for item in val:
156        try:
157            item["Weight"] = int(item["Weight"])
158        except (TypeError, ValueError):
159            raise SaltInvocationError(
160                "Weight '{Weight}' for path '{Path}' is not an integer".format(**item)
161            )
162    return val
163
164
165def cap_add(val, **kwargs):  # pylint: disable=unused-argument
166    return helpers.translate_stringlist(val)
167
168
169def cap_drop(val, **kwargs):  # pylint: disable=unused-argument
170    return helpers.translate_stringlist(val)
171
172
173def command(val, **kwargs):  # pylint: disable=unused-argument
174    return helpers.translate_command(val)
175
176
177def cpuset_cpus(val, **kwargs):  # pylint: disable=unused-argument
178    return helpers.translate_str(val)
179
180
181def cpuset_mems(val, **kwargs):  # pylint: disable=unused-argument
182    return helpers.translate_str(val)
183
184
185def cpu_group(val, **kwargs):  # pylint: disable=unused-argument
186    return helpers.translate_int(val)
187
188
189def cpu_period(val, **kwargs):  # pylint: disable=unused-argument
190    return helpers.translate_int(val)
191
192
193def cpu_shares(val, **kwargs):  # pylint: disable=unused-argument
194    return helpers.translate_int(val)
195
196
197def detach(val, **kwargs):  # pylint: disable=unused-argument
198    return helpers.translate_bool(val)
199
200
201def device_read_bps(val, **kwargs):  # pylint: disable=unused-argument
202    return helpers.translate_device_rates(val, numeric_rate=False)
203
204
205def device_read_iops(val, **kwargs):  # pylint: disable=unused-argument
206    return helpers.translate_device_rates(val, numeric_rate=True)
207
208
209def device_write_bps(val, **kwargs):  # pylint: disable=unused-argument
210    return helpers.translate_device_rates(val, numeric_rate=False)
211
212
213def device_write_iops(val, **kwargs):  # pylint: disable=unused-argument
214    return helpers.translate_device_rates(val, numeric_rate=True)
215
216
217def devices(val, **kwargs):  # pylint: disable=unused-argument
218    return helpers.translate_stringlist(val)
219
220
221def dns_opt(val, **kwargs):  # pylint: disable=unused-argument
222    return helpers.translate_stringlist(val)
223
224
225def dns_search(val, **kwargs):  # pylint: disable=unused-argument
226    return helpers.translate_stringlist(val)
227
228
229def dns(val, **kwargs):
230    val = helpers.translate_stringlist(val)
231    if kwargs.get("validate_ip_addrs", True):
232        for item in val:
233            helpers.validate_ip(item)
234    return val
235
236
237def domainname(val, **kwargs):  # pylint: disable=unused-argument
238    return helpers.translate_str(val)
239
240
241def entrypoint(val, **kwargs):  # pylint: disable=unused-argument
242    return helpers.translate_command(val)
243
244
245def environment(val, **kwargs):  # pylint: disable=unused-argument
246    return helpers.translate_key_val(val, delimiter="=")
247
248
249def extra_hosts(val, **kwargs):
250    val = helpers.translate_key_val(val, delimiter=":")
251    if kwargs.get("validate_ip_addrs", True):
252        for key in val:
253            helpers.validate_ip(val[key])
254    return val
255
256
257def group_add(val, **kwargs):  # pylint: disable=unused-argument
258    return helpers.translate_stringlist(val)
259
260
261def host_config(val, **kwargs):  # pylint: disable=unused-argument
262    return helpers.translate_dict(val)
263
264
265def hostname(val, **kwargs):  # pylint: disable=unused-argument
266    return helpers.translate_str(val)
267
268
269def ipc_mode(val, **kwargs):  # pylint: disable=unused-argument
270    return helpers.translate_str(val)
271
272
273def isolation(val, **kwargs):  # pylint: disable=unused-argument
274    return helpers.translate_str(val)
275
276
277def labels(val, **kwargs):  # pylint: disable=unused-argument
278    return helpers.translate_labels(val)
279
280
281def links(val, **kwargs):  # pylint: disable=unused-argument
282    return helpers.translate_key_val(val, delimiter=":")
283
284
285def log_driver(val, **kwargs):  # pylint: disable=unused-argument
286    return helpers.translate_str(val)
287
288
289def log_opt(val, **kwargs):  # pylint: disable=unused-argument
290    return helpers.translate_key_val(val, delimiter="=")
291
292
293def lxc_conf(val, **kwargs):  # pylint: disable=unused-argument
294    return helpers.translate_key_val(val, delimiter="=")
295
296
297def mac_address(val, **kwargs):  # pylint: disable=unused-argument
298    return helpers.translate_str(val)
299
300
301def mem_limit(val, **kwargs):  # pylint: disable=unused-argument
302    return helpers.translate_bytes(val)
303
304
305def mem_swappiness(val, **kwargs):  # pylint: disable=unused-argument
306    return helpers.translate_int(val)
307
308
309def memswap_limit(val, **kwargs):  # pylint: disable=unused-argument
310    return helpers.translate_bytes(val)
311
312
313def name(val, **kwargs):  # pylint: disable=unused-argument
314    return helpers.translate_str(val)
315
316
317def network_disabled(val, **kwargs):  # pylint: disable=unused-argument
318    return helpers.translate_bool(val)
319
320
321def network_mode(val, **kwargs):  # pylint: disable=unused-argument
322    return helpers.translate_str(val)
323
324
325def oom_kill_disable(val, **kwargs):  # pylint: disable=unused-argument
326    return helpers.translate_bool(val)
327
328
329def oom_score_adj(val, **kwargs):  # pylint: disable=unused-argument
330    return helpers.translate_int(val)
331
332
333def pid_mode(val, **kwargs):  # pylint: disable=unused-argument
334    return helpers.translate_str(val)
335
336
337def pids_limit(val, **kwargs):  # pylint: disable=unused-argument
338    return helpers.translate_int(val)
339
340
341def port_bindings(val, **kwargs):
342    """
343    On the CLI, these are passed as multiple instances of a given CLI option.
344    In Salt, we accept these as a comma-delimited list but the API expects a
345    Python dictionary mapping ports to their bindings. The format the API
346    expects is complicated depending on whether or not the external port maps
347    to a different internal port, or if the port binding is for UDP instead of
348    TCP (the default). For reference, see the "Port bindings" section in the
349    docker-py documentation at the following URL:
350    http://docker-py.readthedocs.io/en/stable/api.html
351    """
352    validate_ip_addrs = kwargs.get("validate_ip_addrs", True)
353    if not isinstance(val, dict):
354        if not isinstance(val, list):
355            try:
356                val = helpers.split(val)
357            except AttributeError:
358                val = helpers.split(str(val))
359
360        for idx, item in enumerate(val):
361            if not isinstance(item, str):
362                val[idx] = str(item)
363
364        def _format_port(port_num, proto):
365            return str(port_num) + "/udp" if proto.lower() == "udp" else port_num
366
367        bindings = {}
368        for binding in val:
369            bind_parts = helpers.split(binding, ":")
370            num_bind_parts = len(bind_parts)
371            if num_bind_parts == 1:
372                # Single port or port range being passed through (no
373                # special mapping)
374                container_port = str(bind_parts[0])
375                if container_port == "":
376                    raise SaltInvocationError("Empty port binding definition found")
377                container_port, _, proto = container_port.partition("/")
378                try:
379                    start, end = helpers.get_port_range(container_port)
380                except ValueError as exc:
381                    # Using __str__() to avoid deprecation warning for using
382                    # the message attribute of the ValueError.
383                    raise SaltInvocationError(exc.__str__())
384                bind_vals = [
385                    (_format_port(port_num, proto), None)
386                    for port_num in range(start, end + 1)
387                ]
388            elif num_bind_parts == 2:
389                if bind_parts[0] == "":
390                    raise SaltInvocationError(
391                        "Empty host port in port binding definition '{}'".format(
392                            binding
393                        )
394                    )
395                if bind_parts[1] == "":
396                    raise SaltInvocationError(
397                        "Empty container port in port binding definition '{}'".format(
398                            binding
399                        )
400                    )
401                container_port, _, proto = bind_parts[1].partition("/")
402                try:
403                    cport_start, cport_end = helpers.get_port_range(container_port)
404                    hport_start, hport_end = helpers.get_port_range(bind_parts[0])
405                except ValueError as exc:
406                    # Using __str__() to avoid deprecation warning for
407                    # using the message attribute of the ValueError.
408                    raise SaltInvocationError(exc.__str__())
409                if (hport_end - hport_start) != (cport_end - cport_start):
410                    # Port range is mismatched
411                    raise SaltInvocationError(
412                        "Host port range ({}) does not have the same "
413                        "number of ports as the container port range "
414                        "({})".format(bind_parts[0], container_port)
415                    )
416                cport_list = list(range(cport_start, cport_end + 1))
417                hport_list = list(range(hport_start, hport_end + 1))
418                bind_vals = [
419                    (_format_port(item, proto), hport_list[ind])
420                    for ind, item in enumerate(cport_list)
421                ]
422            elif num_bind_parts == 3:
423                host_ip, host_port = bind_parts[0:2]
424                if validate_ip_addrs:
425                    helpers.validate_ip(host_ip)
426                container_port, _, proto = bind_parts[2].partition("/")
427                try:
428                    cport_start, cport_end = helpers.get_port_range(container_port)
429                except ValueError as exc:
430                    # Using __str__() to avoid deprecation warning for
431                    # using the message attribute of the ValueError.
432                    raise SaltInvocationError(exc.__str__())
433                cport_list = list(range(cport_start, cport_end + 1))
434                if host_port == "":
435                    hport_list = [None] * len(cport_list)
436                else:
437                    try:
438                        hport_start, hport_end = helpers.get_port_range(host_port)
439                    except ValueError as exc:
440                        # Using __str__() to avoid deprecation warning for
441                        # using the message attribute of the ValueError.
442                        raise SaltInvocationError(exc.__str__())
443                    hport_list = list(range(hport_start, hport_end + 1))
444
445                    if (hport_end - hport_start) != (cport_end - cport_start):
446                        # Port range is mismatched
447                        raise SaltInvocationError(
448                            "Host port range ({}) does not have the same "
449                            "number of ports as the container port range "
450                            "({})".format(host_port, container_port)
451                        )
452
453                bind_vals = [
454                    (
455                        _format_port(val, proto),
456                        (host_ip,)
457                        if hport_list[idx] is None
458                        else (host_ip, hport_list[idx]),
459                    )
460                    for idx, val in enumerate(cport_list)
461                ]
462            else:
463                raise SaltInvocationError(
464                    "'{}' is an invalid port binding definition (at most "
465                    "3 components are allowed, found {})".format(
466                        binding, num_bind_parts
467                    )
468                )
469
470            for cport, bind_def in bind_vals:
471                if cport not in bindings:
472                    bindings[cport] = bind_def
473                else:
474                    if isinstance(bindings[cport], list):
475                        # Append to existing list of bindings for this
476                        # container port.
477                        bindings[cport].append(bind_def)
478                    else:
479                        bindings[cport] = [bindings[cport], bind_def]
480                    for idx, val in enumerate(bindings[cport]):
481                        if val is None:
482                            # Now that we are adding multiple
483                            # bindings
484                            try:
485                                # Convert 1234/udp to 1234
486                                bindings[cport][idx] = int(cport.split("/")[0])
487                            except AttributeError:
488                                # Port was tcp, the AttributeError
489                                # signifies that the split failed
490                                # because the port number was
491                                # already defined as an integer.
492                                # Just use the cport.
493                                bindings[cport][idx] = cport
494        val = bindings
495    return val
496
497
498def ports(val, **kwargs):  # pylint: disable=unused-argument
499    """
500    Like cap_add, cap_drop, etc., this option can be specified multiple times,
501    and each time can be a port number or port range. Ultimately, the API
502    expects a list, but elements in the list are ints when the port is TCP, and
503    a tuple (port_num, 'udp') when the port is UDP.
504    """
505    if not isinstance(val, list):
506        try:
507            val = helpers.split(val)
508        except AttributeError:
509            if isinstance(val, int):
510                val = [val]
511            else:
512                raise SaltInvocationError(
513                    "'{}' is not a valid port definition".format(val)
514                )
515    new_ports = set()
516    for item in val:
517        if isinstance(item, int):
518            new_ports.add(item)
519            continue
520        try:
521            item, _, proto = item.partition("/")
522        except AttributeError:
523            raise SaltInvocationError(
524                "'{}' is not a valid port definition".format(item)
525            )
526        try:
527            range_start, range_end = helpers.get_port_range(item)
528        except ValueError as exc:
529            # Using __str__() to avoid deprecation warning for using
530            # the "message" attribute of the ValueError.
531            raise SaltInvocationError(exc.__str__())
532        new_ports.update(
533            [helpers.get_port_def(x, proto) for x in range(range_start, range_end + 1)]
534        )
535    return list(new_ports)
536
537
538def privileged(val, **kwargs):  # pylint: disable=unused-argument
539    return helpers.translate_bool(val)
540
541
542def publish_all_ports(val, **kwargs):  # pylint: disable=unused-argument
543    return helpers.translate_bool(val)
544
545
546def read_only(val, **kwargs):  # pylint: disable=unused-argument
547    return helpers.translate_bool(val)
548
549
550def restart_policy(val, **kwargs):  # pylint: disable=unused-argument
551    """
552    CLI input is in the format NAME[:RETRY_COUNT] but the API expects {'Name':
553    name, 'MaximumRetryCount': retry_count}. We will use the 'fill' kwarg here
554    to make sure the mapped result uses '0' for the count if this optional
555    value was omitted.
556    """
557    val = helpers.map_vals(val, "Name", "MaximumRetryCount", fill="0")
558    # map_vals() converts the input into a list of dicts, but the API
559    # wants just a dict, so extract the value from the single-element
560    # list. If there was more than one element in the list, then
561    # invalid input was passed (i.e. a comma-separated list, when what
562    # we wanted was a single value).
563    if len(val) != 1:
564        raise SaltInvocationError("Only one policy is permitted")
565    val = val[0]
566    try:
567        # The count needs to be an integer
568        val["MaximumRetryCount"] = int(val["MaximumRetryCount"])
569    except (TypeError, ValueError):
570        # Non-numeric retry count passed
571        raise SaltInvocationError(
572            "Retry count '{}' is non-numeric".format(val["MaximumRetryCount"])
573        )
574    return val
575
576
577def security_opt(val, **kwargs):  # pylint: disable=unused-argument
578    return helpers.translate_stringlist(val)
579
580
581def shm_size(val, **kwargs):  # pylint: disable=unused-argument
582    return helpers.translate_bytes(val)
583
584
585def stdin_open(val, **kwargs):  # pylint: disable=unused-argument
586    return helpers.translate_bool(val)
587
588
589def stop_signal(val, **kwargs):  # pylint: disable=unused-argument
590    return helpers.translate_str(val)
591
592
593def stop_timeout(val, **kwargs):  # pylint: disable=unused-argument
594    return helpers.translate_int(val)
595
596
597def storage_opt(val, **kwargs):  # pylint: disable=unused-argument
598    return helpers.translate_key_val(val, delimiter="=")
599
600
601def sysctls(val, **kwargs):  # pylint: disable=unused-argument
602    return helpers.translate_key_val(val, delimiter="=")
603
604
605def tmpfs(val, **kwargs):  # pylint: disable=unused-argument
606    return helpers.translate_dict(val)
607
608
609def tty(val, **kwargs):  # pylint: disable=unused-argument
610    return helpers.translate_bool(val)
611
612
613def ulimits(val, **kwargs):  # pylint: disable=unused-argument
614    val = helpers.translate_stringlist(val)
615    for idx, item in enumerate(val):
616        if not isinstance(item, dict):
617            try:
618                ulimit_name, limits = helpers.split(item, "=", 1)
619                comps = helpers.split(limits, ":", 1)
620            except (AttributeError, ValueError):
621                raise SaltInvocationError(
622                    "Ulimit definition '{}' is not in the format "
623                    "type=soft_limit[:hard_limit]".format(item)
624                )
625            if len(comps) == 1:
626                comps *= 2
627            soft_limit, hard_limit = comps
628            try:
629                val[idx] = {
630                    "Name": ulimit_name,
631                    "Soft": int(soft_limit),
632                    "Hard": int(hard_limit),
633                }
634            except (TypeError, ValueError):
635                raise SaltInvocationError(
636                    "Limit '{}' contains non-numeric value(s)".format(item)
637                )
638    return val
639
640
641def user(val, **kwargs):  # pylint: disable=unused-argument
642    """
643    This can be either a string or a numeric uid
644    """
645    if not isinstance(val, int):
646        # Try to convert to integer. This will fail if the value is a
647        # username. This is OK, as we check below to make sure that the
648        # value is either a string or integer. Trying to convert to an
649        # integer first though will allow us to catch the edge case in
650        # which a quoted uid is passed (e.g. '1000').
651        try:
652            val = int(val)
653        except (TypeError, ValueError):
654            pass
655    if not isinstance(val, (int, str)):
656        raise SaltInvocationError("Value must be a username or uid")
657    elif isinstance(val, int) and val < 0:
658        raise SaltInvocationError("'{}' is an invalid uid".format(val))
659    return val
660
661
662def userns_mode(val, **kwargs):  # pylint: disable=unused-argument
663    return helpers.translate_str(val)
664
665
666def volume_driver(val, **kwargs):  # pylint: disable=unused-argument
667    return helpers.translate_str(val)
668
669
670def volumes(val, **kwargs):  # pylint: disable=unused-argument
671    """
672    Should be a list of absolute paths
673    """
674    val = helpers.translate_stringlist(val)
675    for item in val:
676        if not os.path.isabs(item):
677            raise SaltInvocationError("'{}' is not an absolute path".format(item))
678    return val
679
680
681def volumes_from(val, **kwargs):  # pylint: disable=unused-argument
682    return helpers.translate_stringlist(val)
683
684
685def working_dir(val, **kwargs):  # pylint: disable=unused-argument
686    """
687    Must be an absolute path
688    """
689    try:
690        is_abs = os.path.isabs(val)
691    except AttributeError:
692        is_abs = False
693    if not is_abs:
694        raise SaltInvocationError("'{}' is not an absolute path".format(val))
695    return val
696