1"""
2Functions to translate input in the docker CLI format to the format desired by
3by the API.
4"""
5
6import os
7
8import salt.utils.data
9import salt.utils.network
10from salt.exceptions import SaltInvocationError
11
12NOTSET = object()
13
14
15def split(item, sep=",", maxsplit=-1):
16    return [x.strip() for x in item.split(sep, maxsplit)]
17
18
19def get_port_def(port_num, proto="tcp"):
20    """
21    Given a port number and protocol, returns the port definition expected by
22    docker-py. For TCP ports this is simply an integer, for UDP ports this is
23    (port_num, 'udp').
24
25    port_num can also be a string in the format 'port_num/udp'. If so, the
26    "proto" argument will be ignored. The reason we need to be able to pass in
27    the protocol separately is because this function is sometimes invoked on
28    data derived from a port range (e.g. '2222-2223/udp'). In these cases the
29    protocol has already been stripped off and the port range resolved into the
30    start and end of the range, and get_port_def() is invoked once for each
31    port number in that range. So, rather than munge udp ports back into
32    strings before passing them to this function, the function will see if it
33    has a string and use the protocol from it if present.
34
35    This function does not catch the TypeError or ValueError which would be
36    raised if the port number is non-numeric. This function either needs to be
37    run on known good input, or should be run within a try/except that catches
38    these two exceptions.
39    """
40    try:
41        port_num, _, port_num_proto = port_num.partition("/")
42    except AttributeError:
43        pass
44    else:
45        if port_num_proto:
46            proto = port_num_proto
47    try:
48        if proto.lower() == "udp":
49            return int(port_num), "udp"
50    except AttributeError:
51        pass
52    return int(port_num)
53
54
55def get_port_range(port_def):
56    """
57    Given a port number or range, return a start and end to that range. Port
58    ranges are defined as a string containing two numbers separated by a dash
59    (e.g. '4505-4506').
60
61    A ValueError will be raised if bad input is provided.
62    """
63    if isinstance(port_def, int):
64        # Single integer, start/end of range is the same
65        return port_def, port_def
66    try:
67        comps = [int(x) for x in split(port_def, "-")]
68        if len(comps) == 1:
69            range_start = range_end = comps[0]
70        else:
71            range_start, range_end = comps
72        if range_start > range_end:
73            raise ValueError("start > end")
74    except (TypeError, ValueError) as exc:
75        if exc.__str__() == "start > end":
76            msg = (
77                "Start of port range ({}) cannot be greater than end of "
78                "port range ({})".format(range_start, range_end)
79            )
80        else:
81            msg = "'{}' is non-numeric or an invalid port range".format(port_def)
82        raise ValueError(msg)
83    else:
84        return range_start, range_end
85
86
87def map_vals(val, *names, **extra_opts):
88    """
89    Many arguments come in as a list of VAL1:VAL2 pairs, but map to a list
90    of dicts in the format {NAME1: VAL1, NAME2: VAL2}. This function
91    provides common code to handle these instances.
92    """
93    fill = extra_opts.pop("fill", NOTSET)
94    expected_num_elements = len(names)
95    val = translate_stringlist(val)
96    for idx, item in enumerate(val):
97        if not isinstance(item, dict):
98            elements = [x.strip() for x in item.split(":")]
99            num_elements = len(elements)
100            if num_elements < expected_num_elements:
101                if fill is NOTSET:
102                    raise SaltInvocationError(
103                        "'{}' contains {} value(s) (expected {})".format(
104                            item, num_elements, expected_num_elements
105                        )
106                    )
107                elements.extend([fill] * (expected_num_elements - num_elements))
108            elif num_elements > expected_num_elements:
109                raise SaltInvocationError(
110                    "'{}' contains {} value(s) (expected {})".format(
111                        item,
112                        num_elements,
113                        expected_num_elements
114                        if fill is NOTSET
115                        else "up to {}".format(expected_num_elements),
116                    )
117                )
118            val[idx] = dict(zip(names, elements))
119    return val
120
121
122def validate_ip(val):
123    try:
124        if not salt.utils.network.is_ip(val):
125            raise SaltInvocationError("'{}' is not a valid IP address".format(val))
126    except RuntimeError:
127        pass
128
129
130def validate_subnet(val):
131    try:
132        if not salt.utils.network.is_subnet(val):
133            raise SaltInvocationError("'{}' is not a valid subnet".format(val))
134    except RuntimeError:
135        pass
136
137
138def translate_str(val):
139    return str(val) if not isinstance(val, str) else val
140
141
142def translate_int(val):
143    if not isinstance(val, int):
144        try:
145            val = int(val)
146        except (TypeError, ValueError):
147            raise SaltInvocationError("'{}' is not an integer".format(val))
148    return val
149
150
151def translate_bool(val):
152    return bool(val) if not isinstance(val, bool) else val
153
154
155def translate_dict(val):
156    """
157    Not really translating, just raising an exception if it's not a dict
158    """
159    if not isinstance(val, dict):
160        raise SaltInvocationError("'{}' is not a dictionary".format(val))
161    return val
162
163
164def translate_command(val):
165    """
166    Input should either be a single string, or a list of strings. This is used
167    for the two args that deal with commands ("command" and "entrypoint").
168    """
169    if isinstance(val, str):
170        return val
171    elif isinstance(val, list):
172        for idx, item in enumerate(val):
173            if not isinstance(item, str):
174                val[idx] = str(item)
175    else:
176        # Make sure we have a string
177        val = str(val)
178    return val
179
180
181def translate_bytes(val):
182    """
183    These values can be expressed as an integer number of bytes, or a string
184    expression (i.e. 100mb, 1gb, etc.).
185    """
186    try:
187        val = int(val)
188    except (TypeError, ValueError):
189        if not isinstance(val, str):
190            val = str(val)
191    return val
192
193
194def translate_stringlist(val):
195    """
196    On the CLI, these are passed as multiple instances of a given CLI option.
197    In Salt, we accept these as a comma-delimited list but the API expects a
198    Python list. This function accepts input and returns it back as a Python
199    list of strings. If the input is a string which is a comma-separated list
200    of items, split that string and return it.
201    """
202    if not isinstance(val, list):
203        try:
204            val = split(val)
205        except AttributeError:
206            val = split(str(val))
207    for idx, item in enumerate(val):
208        if not isinstance(item, str):
209            val[idx] = str(item)
210    return val
211
212
213def translate_device_rates(val, numeric_rate=True):
214    """
215    CLI input is a list of PATH:RATE pairs, but the API expects a list of
216    dictionaries in the format [{'Path': path, 'Rate': rate}]
217    """
218    val = map_vals(val, "Path", "Rate")
219    for item in val:
220        try:
221            is_abs = os.path.isabs(item["Path"])
222        except AttributeError:
223            is_abs = False
224        if not is_abs:
225            raise SaltInvocationError("Path '{Path}' is not absolute".format(**item))
226
227        # Attempt to convert to an integer. Will fail if rate was specified as
228        # a shorthand (e.g. 1mb), this is OK as we will check to make sure the
229        # value is an integer below if that is what is required.
230        try:
231            item["Rate"] = int(item["Rate"])
232        except (TypeError, ValueError):
233            pass
234
235        if numeric_rate:
236            try:
237                item["Rate"] = int(item["Rate"])
238            except ValueError:
239                raise SaltInvocationError(
240                    "Rate '{Rate}' for path '{Path}' is non-numeric".format(**item)
241                )
242    return val
243
244
245def translate_key_val(val, delimiter="="):
246    """
247    CLI input is a list of key/val pairs, but the API expects a dictionary in
248    the format {key: val}
249    """
250    if isinstance(val, dict):
251        return val
252    val = translate_stringlist(val)
253    new_val = {}
254    for item in val:
255        try:
256            lvalue, rvalue = split(item, delimiter, 1)
257        except (AttributeError, TypeError, ValueError):
258            raise SaltInvocationError(
259                "'{}' is not a key{}value pair".format(item, delimiter)
260            )
261        new_val[lvalue] = rvalue
262    return new_val
263
264
265def translate_labels(val):
266    """
267    Can either be a list of label names, or a list of name=value pairs. The API
268    can accept either a list of label names or a dictionary mapping names to
269    values, so the value we translate will be different depending on the input.
270    """
271    if not isinstance(val, dict):
272        if not isinstance(val, list):
273            val = split(val)
274        new_val = {}
275        for item in val:
276            if isinstance(item, dict):
277                if len(item) != 1:
278                    raise SaltInvocationError("Invalid label(s)")
279                key = next(iter(item))
280                val = item[key]
281            else:
282                try:
283                    key, val = split(item, "=", 1)
284                except ValueError:
285                    key = item
286                    val = ""
287            if not isinstance(key, str):
288                key = str(key)
289            if not isinstance(val, str):
290                val = str(val)
291            new_val[key] = val
292        val = new_val
293    return val
294