1#!/usr/local/bin/python3.8
2#
3# This file is part of Ansible
4#
5# Ansible is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# Ansible is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
17#
18from __future__ import absolute_import, division, print_function
19
20__metaclass__ = type
21
22
23DOCUMENTATION = """
24module: nxos_config
25extends_documentation_fragment:
26- cisco.nxos.nxos
27author: Peter Sprygada (@privateip)
28short_description: Manage Cisco NXOS configuration sections
29description:
30- Cisco NXOS configurations use a simple block indent file syntax for segmenting configuration
31  into sections.  This module provides an implementation for working with NXOS configuration
32  sections in a deterministic way.  This module works with either CLI or NXAPI transports.
33version_added: 1.0.0
34options:
35  lines:
36    description:
37    - The ordered set of commands that should be configured in the section. The commands
38      must be the exact same commands as found in the device running-config to ensure idempotency
39      and correct diff. Be sure to note the configuration command syntax as some commands are
40      automatically modified by the device config parser.
41    type: list
42    aliases:
43    - commands
44    elements: str
45  parents:
46    description:
47    - The ordered set of parents that uniquely identify the section or hierarchy the
48      commands should be checked against.  If the parents argument is omitted, the
49      commands are checked against the set of top level or global commands.
50    type: list
51    elements: str
52  src:
53    description:
54    - The I(src) argument provides a path to the configuration file to load into the
55      remote system.  The path can either be a full system path to the configuration
56      file if the value starts with / or relative to the root of the implemented role
57      or playbook. This argument is mutually exclusive with the I(lines) and I(parents)
58      arguments. The configuration lines in the source file should be similar to how it
59      will appear if present in the running-configuration of the device including indentation
60      to ensure idempotency and correct diff.
61    type: path
62  replace_src:
63    description:
64    - The I(replace_src) argument provides path to the configuration file to load
65      into the remote system. This argument is used to replace the entire config with
66      a flat-file. This is used with argument I(replace) with value I(config). This
67      is mutually exclusive with the I(lines) and I(src) arguments. This argument
68      will only work for NX-OS versions that support `config replace`. Use I(nxos_file_copy)
69      module to copy the flat file to remote device and then use the path with this argument.
70      The configuration lines in the file should be similar to how it
71      will appear if present in the running-configuration of the device including the indentation
72      to ensure idempotency and correct diff.
73    type: str
74  before:
75    description:
76    - The ordered set of commands to push on to the command stack if a change needs
77      to be made.  This allows the playbook designer the opportunity to perform configuration
78      commands prior to pushing any changes without affecting how the set of commands
79      are matched against the system.
80    type: list
81    elements: str
82  after:
83    description:
84    - The ordered set of commands to append to the end of the command stack if a change
85      needs to be made.  Just like with I(before) this allows the playbook designer
86      to append a set of commands to be executed after the command set.
87    type: list
88    elements: str
89  match:
90    description:
91    - Instructs the module on the way to perform the matching of the set of commands
92      against the current device config.  If match is set to I(line), commands are
93      matched line by line.  If match is set to I(strict), command lines are matched
94      with respect to position.  If match is set to I(exact), command lines must be
95      an equal match.  Finally, if match is set to I(none), the module will not attempt
96      to compare the source configuration with the running configuration on the remote
97      device.
98    default: line
99    choices:
100    - line
101    - strict
102    - exact
103    - none
104    type: str
105  replace:
106    description:
107    - Instructs the module on the way to perform the configuration on the device.  If
108      the replace argument is set to I(line) then the modified lines are pushed to
109      the device in configuration mode.  If the replace argument is set to I(block)
110      then the entire command block is pushed to the device in configuration mode
111      if any line is not correct. replace I(config) will only work for NX-OS versions
112      that support `config replace`.
113    default: line
114    choices:
115    - line
116    - block
117    - config
118    type: str
119  backup:
120    description:
121    - This argument will cause the module to create a full backup of the current C(running-config)
122      from the remote device before any changes are made. If the C(backup_options)
123      value is not given, the backup file is written to the C(backup) folder in the
124      playbook root directory or role root directory, if playbook is part of an ansible
125      role. If the directory does not exist, it is created.
126    type: bool
127    default: no
128  running_config:
129    description:
130    - The module, by default, will connect to the remote device and retrieve the current
131      running-config to use as a base for comparing against the contents of source.  There
132      are times when it is not desirable to have the task get the current running-config
133      for every task in a playbook.  The I(running_config) argument allows the implementer
134      to pass in the configuration to use as the base config for comparison.
135      The configuration lines for this option should be similar to how it will appear if present
136      in the running-configuration of the device including the indentation to ensure idempotency
137      and correct diff.
138    aliases:
139    - config
140    type: str
141  defaults:
142    description:
143    - The I(defaults) argument will influence how the running-config is collected
144      from the device.  When the value is set to true, the command used to collect
145      the running-config is append with the all keyword.  When the value is set to
146      false, the command is issued without the all keyword
147    type: bool
148    default: no
149  save_when:
150    description:
151    - When changes are made to the device running-configuration, the changes are not
152      copied to non-volatile storage by default.  Using this argument will change
153      that before.  If the argument is set to I(always), then the running-config will
154      always be copied to the startup-config and the I(modified) flag will always
155      be set to True.  If the argument is set to I(modified), then the running-config
156      will only be copied to the startup-config if it has changed since the last save
157      to startup-config.  If the argument is set to I(never), the running-config will
158      never be copied to the startup-config.  If the argument is set to I(changed),
159      then the running-config will only be copied to the startup-config if the task
160      has made a change. I(changed) was added in Ansible 2.6.
161    default: never
162    choices:
163    - always
164    - never
165    - modified
166    - changed
167    type: str
168  diff_against:
169    description:
170    - When using the C(ansible-playbook --diff) command line argument the module can
171      generate diffs against different sources.
172    - When this option is configure as I(startup), the module will return the diff
173      of the running-config against the startup-config.
174    - When this option is configured as I(intended), the module will return the diff
175      of the running-config against the configuration provided in the C(intended_config)
176      argument.
177    - When this option is configured as I(running), the module will return the before
178      and after diff of the running-config with respect to any changes made to the
179      device configuration.
180    choices:
181    - startup
182    - intended
183    - running
184    type: str
185  diff_ignore_lines:
186    description:
187    - Use this argument to specify one or more lines that should be ignored during
188      the diff.  This is used for lines in the configuration that are automatically
189      updated by the system.  This argument takes a list of regular expressions or
190      exact line matches.
191    type: list
192    elements: str
193  intended_config:
194    description:
195    - The C(intended_config) provides the master configuration that the node should
196      conform to and is used to check the final running-config against. This argument
197      will not modify any settings on the remote device and is strictly used to check
198      the compliance of the current device's configuration against.  When specifying
199      this argument, the task should also modify the C(diff_against) value and set
200      it to I(intended). The configuration lines for this value should be similar to how it
201      will appear if present in the running-configuration of the device including the indentation
202      to ensure correct diff.
203    type: str
204  backup_options:
205    description:
206    - This is a dict object containing configurable options related to backup file
207      path. The value of this option is read only when C(backup) is set to I(True),
208      if C(backup) is set to I(false) this option will be silently ignored.
209    suboptions:
210      filename:
211        description:
212        - The filename to be used to store the backup configuration. If the filename
213          is not given it will be generated based on the hostname, current time and
214          date in format defined by <hostname>_config.<current-date>@<current-time>
215        type: str
216      dir_path:
217        description:
218        - This option provides the path ending with directory name in which the backup
219          configuration file will be stored. If the directory does not exist it will
220          be created and the filename is either the value of C(filename) or default
221          filename as described in C(filename) options description. If the path value
222          is not given in that case a I(backup) directory will be created in the current
223          working directory and backup configuration will be copied in C(filename)
224          within I(backup) directory.
225        type: path
226    type: dict
227notes:
228- Unsupported for Cisco MDS
229- Abbreviated commands are NOT idempotent, see
230  U(https://docs.ansible.com/ansible/latest/network/user_guide/faq.html#why-do-the-config-modules-always-return-changed-true-with-abbreviated-commands).
231- To ensure idempotency and correct diff the configuration lines in the relevant module options should be similar to how they
232  appear if present in the running configuration on device including the indentation.
233"""
234
235EXAMPLES = """
236- name: configure top level configuration and save it
237  cisco.nxos.nxos_config:
238    lines: hostname {{ inventory_hostname }}
239    save_when: modified
240
241- name: diff the running-config against a provided config
242  cisco.nxos.nxos_config:
243    diff_against: intended
244    intended_config: "{{ lookup('file', 'master.cfg') }}"
245
246- cisco.nxos.nxos_config:
247    lines:
248    - 10 permit ip 192.0.2.1/32 any log
249    - 20 permit ip 192.0.2.2/32 any log
250    - 30 permit ip 192.0.2.3/32 any log
251    - 40 permit ip 192.0.2.4/32 any log
252    - 50 permit ip 192.0.2.5/32 any log
253    parents: ip access-list test
254    before: no ip access-list test
255    match: exact
256
257- cisco.nxos.nxos_config:
258    lines:
259    - 10 permit ip 192.0.2.1/32 any log
260    - 20 permit ip 192.0.2.2/32 any log
261    - 30 permit ip 192.0.2.3/32 any log
262    - 40 permit ip 192.0.2.4/32 any log
263    parents: ip access-list test
264    before: no ip access-list test
265    replace: block
266
267- name: replace config with flat file
268  cisco.nxos.nxos_config:
269    replace_src: config.txt
270    replace: config
271
272- name: for idempotency, use full-form commands
273  cisco.nxos.nxos_config:
274    lines:
275      # - shut
276    - shutdown
277    # parents: int eth1/1
278    parents: interface Ethernet1/1
279
280- name: configurable backup path
281  cisco.nxos.nxos_config:
282    backup: yes
283    backup_options:
284      filename: backup.cfg
285      dir_path: /home/user
286"""
287
288RETURN = """
289commands:
290  description: The set of commands that will be pushed to the remote device
291  returned: always
292  type: list
293  sample: ['hostname foo', 'vlan 1', 'name default']
294updates:
295  description: The set of commands that will be pushed to the remote device
296  returned: always
297  type: list
298  sample: ['hostname foo', 'vlan 1', 'name default']
299backup_path:
300  description: The full path to the backup file
301  returned: when backup is yes
302  type: str
303  sample: /playbooks/ansible/backup/nxos_config.2016-07-16@22:28:34
304filename:
305  description: The name of the backup file
306  returned: when backup is yes and filename is not specified in backup options
307  type: str
308  sample: nxos_config.2016-07-16@22:28:34
309shortname:
310  description: The full path to the backup file excluding the timestamp
311  returned: when backup is yes and filename is not specified in backup options
312  type: str
313  sample: /playbooks/ansible/backup/nxos_config
314date:
315  description: The date extracted from the backup file name
316  returned: when backup is yes
317  type: str
318  sample: "2016-07-16"
319time:
320  description: The time extracted from the backup file name
321  returned: when backup is yes
322  type: str
323  sample: "22:28:34"
324"""
325from ansible.module_utils._text import to_text
326from ansible.module_utils.basic import AnsibleModule
327from ansible.module_utils.connection import ConnectionError
328from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.config import (
329    NetworkConfig,
330    dumps,
331)
332from ansible_collections.cisco.nxos.plugins.module_utils.network.nxos.nxos import (
333    get_config,
334    load_config,
335    run_commands,
336    get_connection,
337)
338from ansible_collections.cisco.nxos.plugins.module_utils.network.nxos.nxos import (
339    nxos_argument_spec,
340)
341from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import (
342    to_list,
343)
344
345
346def get_running_config(module, config=None, flags=None):
347    contents = module.params["running_config"]
348    if not contents:
349        if config:
350            contents = config
351        else:
352            contents = get_config(module, flags=flags)
353    return contents
354
355
356def get_candidate(module):
357    candidate = ""
358    if module.params["src"]:
359        if module.params["replace"] != "config":
360            candidate = module.params["src"]
361    if module.params["replace"] == "config":
362        candidate = "config replace {0}".format(module.params["replace_src"])
363    elif module.params["lines"]:
364        candidate_obj = NetworkConfig(indent=2)
365        parents = module.params["parents"] or list()
366        candidate_obj.add(module.params["lines"], parents=parents)
367        candidate = dumps(candidate_obj, "raw")
368    return candidate
369
370
371def execute_show_commands(module, commands, output="text"):
372    cmds = []
373    for command in to_list(commands):
374        cmd = {"command": command, "output": output}
375        cmds.append(cmd)
376    body = run_commands(module, cmds)
377    return body
378
379
380def save_config(module, result):
381    result["changed"] = True
382    if not module.check_mode:
383        cmd = {
384            "command": "copy running-config startup-config",
385            "output": "text",
386        }
387        run_commands(module, [cmd])
388    else:
389        module.warn(
390            "Skipping command `copy running-config startup-config` "
391            "due to check_mode.  Configuration not copied to "
392            "non-volatile storage"
393        )
394
395
396def main():
397    """ main entry point for module execution
398    """
399    backup_spec = dict(filename=dict(), dir_path=dict(type="path"))
400    argument_spec = dict(
401        src=dict(type="path"),
402        replace_src=dict(),
403        lines=dict(aliases=["commands"], type="list", elements="str"),
404        parents=dict(type="list", elements="str"),
405        before=dict(type="list", elements="str"),
406        after=dict(type="list", elements="str"),
407        match=dict(
408            default="line", choices=["line", "strict", "exact", "none"]
409        ),
410        replace=dict(default="line", choices=["line", "block", "config"]),
411        running_config=dict(aliases=["config"]),
412        intended_config=dict(),
413        defaults=dict(type="bool", default=False),
414        backup=dict(type="bool", default=False),
415        backup_options=dict(type="dict", options=backup_spec),
416        save_when=dict(
417            choices=["always", "never", "modified", "changed"], default="never"
418        ),
419        diff_against=dict(choices=["running", "startup", "intended"]),
420        diff_ignore_lines=dict(type="list", elements="str"),
421    )
422
423    argument_spec.update(nxos_argument_spec)
424
425    mutually_exclusive = [("lines", "src", "replace_src"), ("parents", "src")]
426
427    required_if = [
428        ("match", "strict", ["lines"]),
429        ("match", "exact", ["lines"]),
430        ("replace", "block", ["lines"]),
431        ("replace", "config", ["replace_src"]),
432        ("diff_against", "intended", ["intended_config"]),
433    ]
434
435    module = AnsibleModule(
436        argument_spec=argument_spec,
437        mutually_exclusive=mutually_exclusive,
438        required_if=required_if,
439        supports_check_mode=True,
440    )
441
442    warnings = list()
443
444    result = {"changed": False, "warnings": warnings}
445
446    config = None
447
448    diff_ignore_lines = module.params["diff_ignore_lines"]
449    path = module.params["parents"]
450    connection = get_connection(module)
451    contents = None
452    flags = ["all"] if module.params["defaults"] else []
453    replace_src = module.params["replace_src"]
454    if replace_src:
455        if module.params["replace"] != "config":
456            module.fail_json(
457                msg="replace: config is required with replace_src"
458            )
459
460    if module.params["backup"] or (
461        module._diff and module.params["diff_against"] == "running"
462    ):
463        contents = get_config(module, flags=flags)
464        config = NetworkConfig(indent=2, contents=contents)
465        if module.params["backup"]:
466            result["__backup__"] = contents
467
468    if any((module.params["src"], module.params["lines"], replace_src)):
469        match = module.params["match"]
470        replace = module.params["replace"]
471
472        commit = not module.check_mode
473        candidate = get_candidate(module)
474        running = get_running_config(module, contents, flags=flags)
475        if replace_src:
476            commands = candidate.split("\n")
477            result["commands"] = result["updates"] = commands
478            if commit:
479                load_config(module, commands, replace=replace_src)
480
481            result["changed"] = True
482        else:
483            try:
484                response = connection.get_diff(
485                    candidate=candidate,
486                    running=running,
487                    diff_match=match,
488                    diff_ignore_lines=diff_ignore_lines,
489                    path=path,
490                    diff_replace=replace,
491                )
492            except ConnectionError as exc:
493                module.fail_json(
494                    msg=to_text(exc, errors="surrogate_then_replace")
495                )
496
497            config_diff = response["config_diff"]
498            if config_diff:
499                commands = config_diff.split("\n")
500
501                if module.params["before"]:
502                    commands[:0] = module.params["before"]
503
504                if module.params["after"]:
505                    commands.extend(module.params["after"])
506
507                result["commands"] = commands
508                result["updates"] = commands
509
510                if commit:
511                    load_config(module, commands, replace=replace_src)
512
513                result["changed"] = True
514
515    running_config = module.params["running_config"]
516    startup_config = None
517
518    if module.params["save_when"] == "always":
519        save_config(module, result)
520    elif module.params["save_when"] == "modified":
521        output = execute_show_commands(
522            module, ["show running-config", "show startup-config"]
523        )
524
525        running_config = NetworkConfig(
526            indent=2, contents=output[0], ignore_lines=diff_ignore_lines
527        )
528        startup_config = NetworkConfig(
529            indent=2, contents=output[1], ignore_lines=diff_ignore_lines
530        )
531
532        if running_config.sha1 != startup_config.sha1:
533            save_config(module, result)
534    elif module.params["save_when"] == "changed" and result["changed"]:
535        save_config(module, result)
536
537    if module._diff:
538        if not running_config:
539            output = execute_show_commands(module, "show running-config")
540            contents = output[0]
541        else:
542            contents = running_config
543
544        # recreate the object in order to process diff_ignore_lines
545        running_config = NetworkConfig(
546            indent=2, contents=contents, ignore_lines=diff_ignore_lines
547        )
548
549        if module.params["diff_against"] == "running":
550            if module.check_mode:
551                module.warn(
552                    "unable to perform diff against running-config due to check mode"
553                )
554                contents = None
555            else:
556                contents = config.config_text
557
558        elif module.params["diff_against"] == "startup":
559            if not startup_config:
560                output = execute_show_commands(module, "show startup-config")
561                contents = output[0]
562            else:
563                contents = startup_config.config_text
564
565        elif module.params["diff_against"] == "intended":
566            contents = module.params["intended_config"]
567
568        if contents is not None:
569            base_config = NetworkConfig(
570                indent=2, contents=contents, ignore_lines=diff_ignore_lines
571            )
572
573            if running_config.sha1 != base_config.sha1:
574                if module.params["diff_against"] == "intended":
575                    before = running_config
576                    after = base_config
577                elif module.params["diff_against"] in ("startup", "running"):
578                    before = base_config
579                    after = running_config
580
581                result.update(
582                    {
583                        "changed": True,
584                        "diff": {"before": str(before), "after": str(after)},
585                    }
586                )
587
588    if result.get("changed") and any(
589        (module.params["src"], module.params["lines"])
590    ):
591        msg = (
592            "To ensure idempotency and correct diff the input configuration lines should be"
593            " similar to how they appear if present in"
594            " the running configuration on device"
595        )
596        if module.params["src"]:
597            msg += " including the indentation"
598        if "warnings" in result:
599            result["warnings"].append(msg)
600        else:
601            result["warnings"] = msg
602
603    module.exit_json(**result)
604
605
606if __name__ == "__main__":
607    main()
608