1"""
2Cisco IOS configuration manipulation helpers
3
4.. versionadded:: 2019.2.0
5
6This module provides a collection of helper functions for Cisco IOS style
7configuration manipulation. This module does not have external dependencies
8and can be used from any Proxy or regular Minion.
9"""
10
11import difflib
12
13import salt.utils.dictdiffer
14import salt.utils.dictupdate
15from salt.exceptions import SaltException
16
17# Import Salt modules
18from salt.utils.odict import OrderedDict
19
20# ------------------------------------------------------------------------------
21# module properties
22# ------------------------------------------------------------------------------
23
24__virtualname__ = "iosconfig"
25__proxyenabled__ = ["*"]
26
27# ------------------------------------------------------------------------------
28# helper functions -- will not be exported
29# ------------------------------------------------------------------------------
30
31
32def _attach_data_to_path(obj, ele, data):
33    if ele not in obj:
34        obj[ele] = OrderedDict()
35        obj[ele] = data
36    else:
37        obj[ele].update(data)
38
39
40def _attach_data_to_path_tags(obj, path, data, list_=False):
41    if "#list" not in obj:
42        obj["#list"] = []
43    path = [path]
44    obj_tmp = obj
45    first = True
46    while True:
47        obj_tmp["#text"] = " ".join(path)
48        path_item = path.pop(0)
49        if not path:
50            break
51        else:
52            if path_item not in obj_tmp:
53                obj_tmp[path_item] = OrderedDict()
54            obj_tmp = obj_tmp[path_item]
55
56            if first and list_:
57                obj["#list"].append({path_item: obj_tmp})
58                first = False
59    if path_item in obj_tmp:
60        obj_tmp[path_item].update(data)
61    else:
62        obj_tmp[path_item] = data
63    obj_tmp[path_item]["#standalone"] = True
64
65
66def _parse_text_config(config_lines, with_tags=False, current_indent=0, nested=False):
67    struct_cfg = OrderedDict()
68    while config_lines:
69        line = config_lines.pop(0)
70        if not line.strip() or line.lstrip().startswith("!"):
71            # empty or comment
72            continue
73        current_line = line.lstrip()
74        leading_spaces = len(line) - len(current_line)
75        if leading_spaces > current_indent:
76            current_block = _parse_text_config(
77                config_lines,
78                current_indent=leading_spaces,
79                with_tags=with_tags,
80                nested=True,
81            )
82            if with_tags:
83                _attach_data_to_path_tags(
84                    struct_cfg, current_line, current_block, nested
85                )
86            else:
87                _attach_data_to_path(struct_cfg, current_line, current_block)
88        elif leading_spaces < current_indent:
89            config_lines.insert(0, line)
90            break
91        else:
92            if not nested:
93                current_block = _parse_text_config(
94                    config_lines,
95                    current_indent=leading_spaces,
96                    with_tags=with_tags,
97                    nested=True,
98                )
99                if with_tags:
100                    _attach_data_to_path_tags(
101                        struct_cfg, current_line, current_block, nested
102                    )
103                else:
104                    _attach_data_to_path(struct_cfg, current_line, current_block)
105            else:
106                config_lines.insert(0, line)
107                break
108    return struct_cfg
109
110
111def _get_diff_text(old, new):
112    """
113    Returns the diff of two text blobs.
114    """
115    diff = difflib.unified_diff(old.splitlines(1), new.splitlines(1))
116    return "".join([x.replace("\r", "") for x in diff])
117
118
119def _print_config_text(tree, indentation=0):
120    """
121    Return the config as text from a config tree.
122    """
123    config = ""
124    for key, value in tree.items():
125        config += "{indent}{line}\n".format(indent=" " * indentation, line=key)
126        if value:
127            config += _print_config_text(value, indentation=indentation + 1)
128    return config
129
130
131# ------------------------------------------------------------------------------
132# callable functions
133# ------------------------------------------------------------------------------
134
135
136def tree(config=None, path=None, with_tags=False, saltenv="base"):
137    """
138    Transform Cisco IOS style configuration to structured Python dictionary.
139    Depending on the value of the ``with_tags`` argument, this function may
140    provide different views, valuable in different situations.
141
142    config
143        The configuration sent as text. This argument is ignored when ``path``
144        is configured.
145
146    path
147        Absolute or remote path from where to load the configuration text. This
148        argument allows any URI supported by
149        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
150        ``https://``, ``s3://``, ``ftp:/``, etc.
151
152    with_tags: ``False``
153        Whether this function should return a detailed view, with tags.
154
155    saltenv: ``base``
156        Salt fileserver environment from which to retrieve the file.
157        Ignored if ``path`` is not a ``salt://`` URL.
158
159    CLI Example:
160
161    .. code-block:: bash
162
163        salt '*' iosconfig.tree path=salt://path/to/my/config.txt
164        salt '*' iosconfig.tree path=https://bit.ly/2mAdq7z
165    """
166    if path:
167        config = __salt__["cp.get_file_str"](path, saltenv=saltenv)
168        if config is False:
169            raise SaltException("{} is not available".format(path))
170    config_lines = config.splitlines()
171    return _parse_text_config(config_lines, with_tags=with_tags)
172
173
174def clean(config=None, path=None, saltenv="base"):
175    """
176    Return a clean version of the config, without any special signs (such as
177    ``!`` as an individual line) or empty lines, but just lines with significant
178    value in the configuration of the network device.
179
180    config
181        The configuration sent as text. This argument is ignored when ``path``
182        is configured.
183
184    path
185        Absolute or remote path from where to load the configuration text. This
186        argument allows any URI supported by
187        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
188        ``https://``, ``s3://``, ``ftp:/``, etc.
189
190    saltenv: ``base``
191        Salt fileserver environment from which to retrieve the file.
192        Ignored if ``path`` is not a ``salt://`` URL.
193
194    CLI Example:
195
196    .. code-block:: bash
197
198        salt '*' iosconfig.clean path=salt://path/to/my/config.txt
199        salt '*' iosconfig.clean path=https://bit.ly/2mAdq7z
200    """
201    config_tree = tree(config=config, path=path, saltenv=saltenv)
202    return _print_config_text(config_tree)
203
204
205def merge_tree(
206    initial_config=None,
207    initial_path=None,
208    merge_config=None,
209    merge_path=None,
210    saltenv="base",
211):
212    """
213    Return the merge tree of the ``initial_config`` with the ``merge_config``,
214    as a Python dictionary.
215
216    initial_config
217        The initial configuration sent as text. This argument is ignored when
218        ``initial_path`` is set.
219
220    initial_path
221        Absolute or remote path from where to load the initial configuration
222        text. This argument allows any URI supported by
223        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
224        ``https://``, ``s3://``, ``ftp:/``, etc.
225
226    merge_config
227        The config to be merged into the initial config, sent as text. This
228        argument is ignored when ``merge_path`` is set.
229
230    merge_path
231        Absolute or remote path from where to load the merge configuration
232        text. This argument allows any URI supported by
233        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
234        ``https://``, ``s3://``, ``ftp:/``, etc.
235
236    saltenv: ``base``
237        Salt fileserver environment from which to retrieve the file.
238        Ignored if ``initial_path`` or ``merge_path`` is not a ``salt://`` URL.
239
240    CLI Example:
241
242    .. code-block:: bash
243
244        salt '*' iosconfig.merge_tree initial_path=salt://path/to/running.cfg merge_path=salt://path/to/merge.cfg
245    """
246    merge_tree = tree(config=merge_config, path=merge_path, saltenv=saltenv)
247    initial_tree = tree(config=initial_config, path=initial_path, saltenv=saltenv)
248    return salt.utils.dictupdate.merge(initial_tree, merge_tree)
249
250
251def merge_text(
252    initial_config=None,
253    initial_path=None,
254    merge_config=None,
255    merge_path=None,
256    saltenv="base",
257):
258    """
259    Return the merge result of the ``initial_config`` with the ``merge_config``,
260    as plain text.
261
262    initial_config
263        The initial configuration sent as text. This argument is ignored when
264        ``initial_path`` is set.
265
266    initial_path
267        Absolute or remote path from where to load the initial configuration
268        text. This argument allows any URI supported by
269        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
270        ``https://``, ``s3://``, ``ftp:/``, etc.
271
272    merge_config
273        The config to be merged into the initial config, sent as text. This
274        argument is ignored when ``merge_path`` is set.
275
276    merge_path
277        Absolute or remote path from where to load the merge configuration
278        text. This argument allows any URI supported by
279        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
280        ``https://``, ``s3://``, ``ftp:/``, etc.
281
282    saltenv: ``base``
283        Salt fileserver environment from which to retrieve the file.
284        Ignored if ``initial_path`` or ``merge_path`` is not a ``salt://`` URL.
285
286    CLI Example:
287
288    .. code-block:: bash
289
290        salt '*' iosconfig.merge_text initial_path=salt://path/to/running.cfg merge_path=salt://path/to/merge.cfg
291    """
292    candidate_tree = merge_tree(
293        initial_config=initial_config,
294        initial_path=initial_path,
295        merge_config=merge_config,
296        merge_path=merge_path,
297        saltenv=saltenv,
298    )
299    return _print_config_text(candidate_tree)
300
301
302def merge_diff(
303    initial_config=None,
304    initial_path=None,
305    merge_config=None,
306    merge_path=None,
307    saltenv="base",
308):
309    """
310    Return the merge diff, as text, after merging the merge config into the
311    initial config.
312
313    initial_config
314        The initial configuration sent as text. This argument is ignored when
315        ``initial_path`` is set.
316
317    initial_path
318        Absolute or remote path from where to load the initial configuration
319        text. This argument allows any URI supported by
320        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
321        ``https://``, ``s3://``, ``ftp:/``, etc.
322
323    merge_config
324        The config to be merged into the initial config, sent as text. This
325        argument is ignored when ``merge_path`` is set.
326
327    merge_path
328        Absolute or remote path from where to load the merge configuration
329        text. This argument allows any URI supported by
330        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
331        ``https://``, ``s3://``, ``ftp:/``, etc.
332
333    saltenv: ``base``
334        Salt fileserver environment from which to retrieve the file.
335        Ignored if ``initial_path`` or ``merge_path`` is not a ``salt://`` URL.
336
337    CLI Example:
338
339    .. code-block:: bash
340
341        salt '*' iosconfig.merge_diff initial_path=salt://path/to/running.cfg merge_path=salt://path/to/merge.cfg
342    """
343    if initial_path:
344        initial_config = __salt__["cp.get_file_str"](initial_path, saltenv=saltenv)
345    candidate_config = merge_text(
346        initial_config=initial_config,
347        merge_config=merge_config,
348        merge_path=merge_path,
349        saltenv=saltenv,
350    )
351    clean_running_dict = tree(config=initial_config)
352    clean_running = _print_config_text(clean_running_dict)
353    return _get_diff_text(clean_running, candidate_config)
354
355
356def diff_tree(
357    candidate_config=None,
358    candidate_path=None,
359    running_config=None,
360    running_path=None,
361    saltenv="base",
362):
363    """
364    Return the diff, as Python dictionary, between the candidate and the running
365    configuration.
366
367    candidate_config
368        The candidate configuration sent as text. This argument is ignored when
369        ``candidate_path`` is set.
370
371    candidate_path
372        Absolute or remote path from where to load the candidate configuration
373        text. This argument allows any URI supported by
374        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
375        ``https://``, ``s3://``, ``ftp:/``, etc.
376
377    running_config
378        The running configuration sent as text. This argument is ignored when
379        ``running_path`` is set.
380
381    running_path
382        Absolute or remote path from where to load the running configuration
383        text. This argument allows any URI supported by
384        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
385        ``https://``, ``s3://``, ``ftp:/``, etc.
386
387    saltenv: ``base``
388        Salt fileserver environment from which to retrieve the file.
389        Ignored if ``candidate_path`` or ``running_path`` is not a
390        ``salt://`` URL.
391
392    CLI Example:
393
394    .. code-block:: bash
395
396        salt '*' iosconfig.diff_tree candidate_path=salt://path/to/candidate.cfg running_path=salt://path/to/running.cfg
397    """
398    candidate_tree = tree(config=candidate_config, path=candidate_path, saltenv=saltenv)
399    running_tree = tree(config=running_config, path=running_path, saltenv=saltenv)
400    return salt.utils.dictdiffer.deep_diff(running_tree, candidate_tree)
401
402
403def diff_text(
404    candidate_config=None,
405    candidate_path=None,
406    running_config=None,
407    running_path=None,
408    saltenv="base",
409):
410    """
411    Return the diff, as text, between the candidate and the running config.
412
413    candidate_config
414        The candidate configuration sent as text. This argument is ignored when
415        ``candidate_path`` is set.
416
417    candidate_path
418        Absolute or remote path from where to load the candidate configuration
419        text. This argument allows any URI supported by
420        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
421        ``https://``, ``s3://``, ``ftp:/``, etc.
422
423    running_config
424        The running configuration sent as text. This argument is ignored when
425        ``running_path`` is set.
426
427    running_path
428        Absolute or remote path from where to load the running configuration
429        text. This argument allows any URI supported by
430        :py:func:`cp.get_url <salt.modules.cp.get_url>`), e.g., ``salt://``,
431        ``https://``, ``s3://``, ``ftp:/``, etc.
432
433    saltenv: ``base``
434        Salt fileserver environment from which to retrieve the file.
435        Ignored if ``candidate_path`` or ``running_path`` is not a
436        ``salt://`` URL.
437
438    CLI Example:
439
440    .. code-block:: bash
441
442        salt '*' iosconfig.diff_text candidate_path=salt://path/to/candidate.cfg running_path=salt://path/to/running.cfg
443    """
444    candidate_text = clean(
445        config=candidate_config, path=candidate_path, saltenv=saltenv
446    )
447    running_text = clean(config=running_config, path=running_path, saltenv=saltenv)
448    return _get_diff_text(running_text, candidate_text)
449