1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# (c) 2018, Ansible by Red Hat, inc
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7from __future__ import absolute_import, division, print_function
8
9__metaclass__ = type
10
11
12ANSIBLE_METADATA = {
13    "metadata_version": "1.1",
14    "status": ["preview"],
15    "supported_by": "network",
16}
17
18
19DOCUMENTATION = """module: cli_config
20author: Trishna Guha (@trishnaguha)
21notes:
22- The commands will be returned only for platforms that do not support onbox diff.
23  The C(--diff) option with the playbook will return the difference in configuration
24  for devices that has support for onbox diff
25short_description: Push text based configuration to network devices over network_cli
26description:
27- This module provides platform agnostic way of pushing text based configuration to
28  network devices over network_cli connection plugin.
29extends_documentation_fragment:
30- ansible.netcommon.network_agnostic
31options:
32  config:
33    description:
34    - The config to be pushed to the network device. This argument is mutually exclusive
35      with C(rollback) and either one of the option should be given as input. The
36      config should have indentation that the device uses.
37    type: str
38  commit:
39    description:
40    - The C(commit) argument instructs the module to push the configuration to the
41      device. This is mapped to module check mode.
42    type: bool
43  replace:
44    description:
45    - If the C(replace) argument is set to C(yes), it will replace the entire running-config
46      of the device with the C(config) argument value. For devices that support replacing
47      running configuration from file on device like NXOS/JUNOS, the C(replace) argument
48      takes path to the file on the device that will be used for replacing the entire
49      running-config. The value of C(config) option should be I(None) for such devices.
50      Nexus 9K devices only support replace. Use I(net_put) or I(nxos_file_copy) in
51      case of NXOS module to copy the flat file to remote device and then use set
52      the fullpath to this argument.
53    type: str
54  backup:
55    description:
56    - This argument will cause the module to create a full backup of the current running
57      config from the remote device before any changes are made. If the C(backup_options)
58      value is not given, the backup file is written to the C(backup) folder in the
59      playbook root directory or role root directory, if playbook is part of an ansible
60      role. If the directory does not exist, it is created.
61    type: bool
62    default: 'no'
63  rollback:
64    description:
65    - The C(rollback) argument instructs the module to rollback the current configuration
66      to the identifier specified in the argument.  If the specified rollback identifier
67      does not exist on the remote device, the module will fail. To rollback to the
68      most recent commit, set the C(rollback) argument to 0. This option is mutually
69      exclusive with C(config).
70  commit_comment:
71    description:
72    - The C(commit_comment) argument specifies a text string to be used when committing
73      the configuration. If the C(commit) argument is set to False, this argument
74      is silently ignored. This argument is only valid for the platforms that support
75      commit operation with comment.
76    type: str
77  defaults:
78    description:
79    - The I(defaults) argument will influence how the running-config is collected
80      from the device.  When the value is set to true, the command used to collect
81      the running-config is append with the all keyword.  When the value is set to
82      false, the command is issued without the all keyword.
83    default: 'no'
84    type: bool
85  multiline_delimiter:
86    description:
87    - This argument is used when pushing a multiline configuration element to the
88      device. It specifies the character to use as the delimiting character. This
89      only applies to the configuration action.
90    type: str
91  diff_replace:
92    description:
93    - Instructs the module on the way to perform the configuration on the device.
94      If the C(diff_replace) argument is set to I(line) then the modified lines are
95      pushed to the device in configuration mode. If the argument is set to I(block)
96      then the entire command block is pushed to the device in configuration mode
97      if any line is not correct. Note that this parameter will be ignored if the
98      platform has onbox diff support.
99    choices:
100    - line
101    - block
102    - config
103  diff_match:
104    description:
105    - Instructs the module on the way to perform the matching of the set of commands
106      against the current device config. If C(diff_match) is set to I(line), commands
107      are matched line by line. If C(diff_match) is set to I(strict), command lines
108      are matched with respect to position. If C(diff_match) is set to I(exact), command
109      lines must be an equal match. Finally, if C(diff_match) is set to I(none), the
110      module will not attempt to compare the source configuration with the running
111      configuration on the remote device. Note that this parameter will be ignored
112      if the platform has onbox diff support.
113    choices:
114    - line
115    - strict
116    - exact
117    - none
118  diff_ignore_lines:
119    description:
120    - Use this argument to specify one or more lines that should be ignored during
121      the diff. This is used for lines in the configuration that are automatically
122      updated by the system. This argument takes a list of regular expressions or
123      exact line matches. Note that this parameter will be ignored if the platform
124      has onbox diff support.
125  backup_options:
126    description:
127    - This is a dict object containing configurable options related to backup file
128      path. The value of this option is read only when C(backup) is set to I(yes),
129      if C(backup) is set to I(no) this option will be silently ignored.
130    suboptions:
131      filename:
132        description:
133        - The filename to be used to store the backup configuration. If the filename
134          is not given it will be generated based on the hostname, current time and
135          date in format defined by <hostname>_config.<current-date>@<current-time>
136      dir_path:
137        description:
138        - This option provides the path ending with directory name in which the backup
139          configuration file will be stored. If the directory does not exist it will
140          be first created and the filename is either the value of C(filename) or
141          default filename as described in C(filename) options description. If the
142          path value is not given in that case a I(backup) directory will be created
143          in the current working directory and backup configuration will be copied
144          in C(filename) within I(backup) directory.
145        type: path
146    type: dict
147"""
148
149EXAMPLES = """
150- name: configure device with config
151  cli_config:
152    config: "{{ lookup('template', 'basic/config.j2') }}"
153
154- name: multiline config
155  cli_config:
156    config: |
157      hostname foo
158      feature nxapi
159
160- name: configure device with config with defaults enabled
161  cli_config:
162    config: "{{ lookup('template', 'basic/config.j2') }}"
163    defaults: yes
164
165- name: Use diff_match
166  cli_config:
167    config: "{{ lookup('file', 'interface_config') }}"
168    diff_match: none
169
170- name: nxos replace config
171  cli_config:
172    replace: 'bootflash:nxoscfg'
173
174- name: junos replace config
175  cli_config:
176    replace: '/var/home/ansible/junos01.cfg'
177
178- name: commit with comment
179  cli_config:
180    config: set system host-name foo
181    commit_comment: this is a test
182
183- name: configurable backup path
184  cli_config:
185    config: "{{ lookup('template', 'basic/config.j2') }}"
186    backup: yes
187    backup_options:
188      filename: backup.cfg
189      dir_path: /home/user
190"""
191
192RETURN = """
193commands:
194  description: The set of commands that will be pushed to the remote device
195  returned: always
196  type: list
197  sample: ['interface Loopback999', 'no shutdown']
198backup_path:
199  description: The full path to the backup file
200  returned: when backup is yes
201  type: str
202  sample: /playbooks/ansible/backup/hostname_config.2016-07-16@22:28:34
203"""
204
205import json
206
207from ansible.module_utils.basic import AnsibleModule
208from ansible.module_utils.connection import Connection
209from ansible.module_utils._text import to_text
210
211
212def validate_args(module, device_operations):
213    """validate param if it is supported on the platform
214    """
215    feature_list = [
216        "replace",
217        "rollback",
218        "commit_comment",
219        "defaults",
220        "multiline_delimiter",
221        "diff_replace",
222        "diff_match",
223        "diff_ignore_lines",
224    ]
225
226    for feature in feature_list:
227        if module.params[feature]:
228            supports_feature = device_operations.get("supports_%s" % feature)
229            if supports_feature is None:
230                module.fail_json(
231                    "This platform does not specify whether %s is supported or not. "
232                    "Please report an issue against this platform's cliconf plugin."
233                    % feature
234                )
235            elif not supports_feature:
236                module.fail_json(
237                    msg="Option %s is not supported on this platform" % feature
238                )
239
240
241def run(
242    module, device_operations, connection, candidate, running, rollback_id
243):
244    result = {}
245    resp = {}
246    config_diff = []
247    banner_diff = {}
248
249    replace = module.params["replace"]
250    commit_comment = module.params["commit_comment"]
251    multiline_delimiter = module.params["multiline_delimiter"]
252    diff_replace = module.params["diff_replace"]
253    diff_match = module.params["diff_match"]
254    diff_ignore_lines = module.params["diff_ignore_lines"]
255
256    commit = not module.check_mode
257
258    if replace in ("yes", "true", "True"):
259        replace = True
260    elif replace in ("no", "false", "False"):
261        replace = False
262
263    if (
264        replace is not None
265        and replace not in [True, False]
266        and candidate is not None
267    ):
268        module.fail_json(
269            msg="Replace value '%s' is a configuration file path already"
270            " present on the device. Hence 'replace' and 'config' options"
271            " are mutually exclusive" % replace
272        )
273
274    if rollback_id is not None:
275        resp = connection.rollback(rollback_id, commit)
276        if "diff" in resp:
277            result["changed"] = True
278
279    elif device_operations.get("supports_onbox_diff"):
280        if diff_replace:
281            module.warn(
282                "diff_replace is ignored as the device supports onbox diff"
283            )
284        if diff_match:
285            module.warn(
286                "diff_mattch is ignored as the device supports onbox diff"
287            )
288        if diff_ignore_lines:
289            module.warn(
290                "diff_ignore_lines is ignored as the device supports onbox diff"
291            )
292
293        if candidate and not isinstance(candidate, list):
294            candidate = candidate.strip("\n").splitlines()
295
296        kwargs = {
297            "candidate": candidate,
298            "commit": commit,
299            "replace": replace,
300            "comment": commit_comment,
301        }
302        resp = connection.edit_config(**kwargs)
303
304        if "diff" in resp:
305            result["changed"] = True
306
307    elif device_operations.get("supports_generate_diff"):
308        kwargs = {"candidate": candidate, "running": running}
309        if diff_match:
310            kwargs.update({"diff_match": diff_match})
311        if diff_replace:
312            kwargs.update({"diff_replace": diff_replace})
313        if diff_ignore_lines:
314            kwargs.update({"diff_ignore_lines": diff_ignore_lines})
315
316        diff_response = connection.get_diff(**kwargs)
317
318        config_diff = diff_response.get("config_diff")
319        banner_diff = diff_response.get("banner_diff")
320
321        if config_diff:
322            if isinstance(config_diff, list):
323                candidate = config_diff
324            else:
325                candidate = config_diff.splitlines()
326
327            kwargs = {
328                "candidate": candidate,
329                "commit": commit,
330                "replace": replace,
331                "comment": commit_comment,
332            }
333            if commit:
334                connection.edit_config(**kwargs)
335            result["changed"] = True
336            result["commands"] = config_diff.split("\n")
337
338        if banner_diff:
339            candidate = json.dumps(banner_diff)
340
341            kwargs = {"candidate": candidate, "commit": commit}
342            if multiline_delimiter:
343                kwargs.update({"multiline_delimiter": multiline_delimiter})
344            if commit:
345                connection.edit_banner(**kwargs)
346            result["changed"] = True
347
348    if module._diff:
349        if "diff" in resp:
350            result["diff"] = {"prepared": resp["diff"]}
351        else:
352            diff = ""
353            if config_diff:
354                if isinstance(config_diff, list):
355                    diff += "\n".join(config_diff)
356                else:
357                    diff += config_diff
358            if banner_diff:
359                diff += json.dumps(banner_diff)
360            result["diff"] = {"prepared": diff}
361
362    return result
363
364
365def main():
366    """main entry point for execution
367    """
368    backup_spec = dict(filename=dict(), dir_path=dict(type="path"))
369    argument_spec = dict(
370        backup=dict(default=False, type="bool"),
371        backup_options=dict(type="dict", options=backup_spec),
372        config=dict(type="str"),
373        commit=dict(type="bool"),
374        replace=dict(type="str"),
375        rollback=dict(type="int"),
376        commit_comment=dict(type="str"),
377        defaults=dict(default=False, type="bool"),
378        multiline_delimiter=dict(type="str"),
379        diff_replace=dict(choices=["line", "block", "config"]),
380        diff_match=dict(choices=["line", "strict", "exact", "none"]),
381        diff_ignore_lines=dict(type="list"),
382    )
383
384    mutually_exclusive = [("config", "rollback")]
385    required_one_of = [["backup", "config", "rollback"]]
386
387    module = AnsibleModule(
388        argument_spec=argument_spec,
389        mutually_exclusive=mutually_exclusive,
390        required_one_of=required_one_of,
391        supports_check_mode=True,
392    )
393
394    result = {"changed": False}
395
396    connection = Connection(module._socket_path)
397    capabilities = module.from_json(connection.get_capabilities())
398
399    if capabilities:
400        device_operations = capabilities.get("device_operations", dict())
401        validate_args(module, device_operations)
402    else:
403        device_operations = dict()
404
405    if module.params["defaults"]:
406        if "get_default_flag" in capabilities.get("rpc"):
407            flags = connection.get_default_flag()
408        else:
409            flags = "all"
410    else:
411        flags = []
412
413    candidate = module.params["config"]
414    candidate = (
415        to_text(candidate, errors="surrogate_then_replace")
416        if candidate
417        else None
418    )
419    running = connection.get_config(flags=flags)
420    rollback_id = module.params["rollback"]
421
422    if module.params["backup"]:
423        result["__backup__"] = running
424
425    if candidate or rollback_id or module.params["replace"]:
426        try:
427            result.update(
428                run(
429                    module,
430                    device_operations,
431                    connection,
432                    candidate,
433                    running,
434                    rollback_id,
435                )
436            )
437        except Exception as exc:
438            module.fail_json(msg=to_text(exc))
439
440    module.exit_json(**result)
441
442
443if __name__ == "__main__":
444    main()
445