1#
2# (c) 2017 Red Hat Inc.
3#
4# This file is part of Ansible
5#
6# Ansible is free software: you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation, either version 3 of the License, or
9# (at your option) any later version.
10#
11# Ansible is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
18#
19from __future__ import absolute_import, division, print_function
20
21__metaclass__ = type
22
23DOCUMENTATION = """
24---
25author: Ansible Networking Team
26cliconf: ios
27short_description: Use ios cliconf to run command on Cisco IOS platform
28description:
29  - This ios plugin provides low level abstraction apis for
30    sending and receiving CLI commands from Cisco IOS network devices.
31version_added: "2.4"
32"""
33
34import re
35import time
36import json
37
38from ansible.errors import AnsibleConnectionFailure
39from ansible.module_utils._text import to_text
40from ansible.module_utils.common._collections_compat import Mapping
41from ansible.module_utils.six import iteritems
42from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.config import (
43    NetworkConfig,
44    dumps,
45)
46from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import (
47    to_list,
48)
49from ansible.plugins.cliconf import CliconfBase, enable_mode
50
51
52class Cliconf(CliconfBase):
53    @enable_mode
54    def get_config(self, source="running", flags=None, format=None):
55        if source not in ("running", "startup"):
56            raise ValueError(
57                "fetching configuration from %s is not supported" % source
58            )
59
60        if format:
61            raise ValueError(
62                "'format' value %s is not supported for get_config" % format
63            )
64
65        if not flags:
66            flags = []
67        if source == "running":
68            cmd = "show running-config "
69        else:
70            cmd = "show startup-config "
71
72        cmd += " ".join(to_list(flags))
73        cmd = cmd.strip()
74
75        return self.send_command(cmd)
76
77    def get_diff(
78        self,
79        candidate=None,
80        running=None,
81        diff_match="line",
82        diff_ignore_lines=None,
83        path=None,
84        diff_replace="line",
85    ):
86        """
87        Generate diff between candidate and running configuration. If the
88        remote host supports onbox diff capabilities ie. supports_onbox_diff in that case
89        candidate and running configurations are not required to be passed as argument.
90        In case if onbox diff capability is not supported candidate argument is mandatory
91        and running argument is optional.
92        :param candidate: The configuration which is expected to be present on remote host.
93        :param running: The base configuration which is used to generate diff.
94        :param diff_match: Instructs how to match the candidate configuration with current device configuration
95                      Valid values are 'line', 'strict', 'exact', 'none'.
96                      'line' - commands are matched line by line
97                      'strict' - command lines are matched with respect to position
98                      'exact' - command lines must be an equal match
99                      'none' - will not compare the candidate configuration with the running configuration
100        :param diff_ignore_lines: Use this argument to specify one or more lines that should be
101                                  ignored during the diff.  This is used for lines in the configuration
102                                  that are automatically updated by the system.  This argument takes
103                                  a list of regular expressions or exact line matches.
104        :param path: The ordered set of parents that uniquely identify the section or hierarchy
105                     the commands should be checked against.  If the parents argument
106                     is omitted, the commands are checked against the set of top
107                    level or global commands.
108        :param diff_replace: Instructs on the way to perform the configuration on the device.
109                        If the replace argument is set to I(line) then the modified lines are
110                        pushed to the device in configuration mode.  If the replace argument is
111                        set to I(block) then the entire command block is pushed to the device in
112                        configuration mode if any line is not correct.
113        :return: Configuration diff in  json format.
114               {
115                   'config_diff': '',
116                   'banner_diff': {}
117               }
118
119        """
120        diff = {}
121        device_operations = self.get_device_operations()
122        option_values = self.get_option_values()
123
124        if candidate is None and device_operations["supports_generate_diff"]:
125            raise ValueError(
126                "candidate configuration is required to generate diff"
127            )
128
129        if diff_match not in option_values["diff_match"]:
130            raise ValueError(
131                "'match' value %s in invalid, valid values are %s"
132                % (diff_match, ", ".join(option_values["diff_match"]))
133            )
134
135        if diff_replace not in option_values["diff_replace"]:
136            raise ValueError(
137                "'replace' value %s in invalid, valid values are %s"
138                % (diff_replace, ", ".join(option_values["diff_replace"]))
139            )
140
141        # prepare candidate configuration
142        candidate_obj = NetworkConfig(indent=1)
143        want_src, want_banners = self._extract_banners(candidate)
144        candidate_obj.load(want_src)
145
146        if running and diff_match != "none":
147            # running configuration
148            have_src, have_banners = self._extract_banners(running)
149            running_obj = NetworkConfig(
150                indent=1, contents=have_src, ignore_lines=diff_ignore_lines
151            )
152            configdiffobjs = candidate_obj.difference(
153                running_obj, path=path, match=diff_match, replace=diff_replace
154            )
155
156        else:
157            configdiffobjs = candidate_obj.items
158            have_banners = {}
159
160        diff["config_diff"] = (
161            dumps(configdiffobjs, "commands") if configdiffobjs else ""
162        )
163        banners = self._diff_banners(want_banners, have_banners)
164        diff["banner_diff"] = banners if banners else {}
165        return diff
166
167    @enable_mode
168    def edit_config(
169        self, candidate=None, commit=True, replace=None, comment=None
170    ):
171        resp = {}
172        operations = self.get_device_operations()
173        self.check_edit_config_capability(
174            operations, candidate, commit, replace, comment
175        )
176
177        results = []
178        requests = []
179        if commit:
180            self.send_command("configure terminal")
181            for line in to_list(candidate):
182                if not isinstance(line, Mapping):
183                    line = {"command": line}
184
185                cmd = line["command"]
186                if cmd != "end" and cmd[0] != "!":
187                    results.append(self.send_command(**line))
188                    requests.append(cmd)
189
190            self.send_command("end")
191        else:
192            raise ValueError("check mode is not supported")
193
194        resp["request"] = requests
195        resp["response"] = results
196        return resp
197
198    def edit_macro(
199        self, candidate=None, commit=True, replace=None, comment=None
200    ):
201        """
202        ios_config:
203          lines: "{{ macro_lines }}"
204          parents: "macro name {{ macro_name }}"
205          after: '@'
206          match: line
207          replace: block
208        """
209        resp = {}
210        operations = self.get_device_operations()
211        self.check_edit_config_capability(
212            operations, candidate, commit, replace, comment
213        )
214
215        results = []
216        requests = []
217        if commit:
218            commands = ""
219            self.send_command("config terminal")
220            time.sleep(0.1)
221            # first item: macro command
222            commands += candidate.pop(0) + "\n"
223            multiline_delimiter = candidate.pop(-1)
224            for line in candidate:
225                commands += " " + line + "\n"
226            commands += multiline_delimiter + "\n"
227            obj = {"command": commands, "sendonly": True}
228            results.append(self.send_command(**obj))
229            requests.append(commands)
230
231            time.sleep(0.1)
232            self.send_command("end", sendonly=True)
233            time.sleep(0.1)
234            results.append(self.send_command("\n"))
235            requests.append("\n")
236
237        resp["request"] = requests
238        resp["response"] = results
239        return resp
240
241    def get(
242        self,
243        command=None,
244        prompt=None,
245        answer=None,
246        sendonly=False,
247        output=None,
248        newline=True,
249        check_all=False,
250    ):
251        if not command:
252            raise ValueError("must provide value of command to execute")
253        if output:
254            raise ValueError(
255                "'output' value %s is not supported for get" % output
256            )
257
258        return self.send_command(
259            command=command,
260            prompt=prompt,
261            answer=answer,
262            sendonly=sendonly,
263            newline=newline,
264            check_all=check_all,
265        )
266
267    def get_device_info(self):
268        device_info = {}
269
270        device_info["network_os"] = "ios"
271        reply = self.get(command="show version")
272        data = to_text(reply, errors="surrogate_or_strict").strip()
273
274        match = re.search(r"Version (\S+)", data)
275        if match:
276            device_info["network_os_version"] = match.group(1).strip(",")
277
278        model_search_strs = [
279            r"^[Cc]isco (.+) \(revision",
280            r"^[Cc]isco (\S+).+bytes of .*memory",
281        ]
282        for item in model_search_strs:
283            match = re.search(item, data, re.M)
284            if match:
285                version = match.group(1).split(" ")
286                device_info["network_os_model"] = version[0]
287                break
288
289        match = re.search(r"^(.+) uptime", data, re.M)
290        if match:
291            device_info["network_os_hostname"] = match.group(1)
292
293        match = re.search(r'image file is "(.+)"', data)
294        if match:
295            device_info["network_os_image"] = match.group(1)
296
297        return device_info
298
299    def get_device_operations(self):
300        return {
301            "supports_diff_replace": True,
302            "supports_commit": False,
303            "supports_rollback": False,
304            "supports_defaults": True,
305            "supports_onbox_diff": False,
306            "supports_commit_comment": False,
307            "supports_multiline_delimiter": True,
308            "supports_diff_match": True,
309            "supports_diff_ignore_lines": True,
310            "supports_generate_diff": True,
311            "supports_replace": False,
312        }
313
314    def get_option_values(self):
315        return {
316            "format": ["text"],
317            "diff_match": ["line", "strict", "exact", "none"],
318            "diff_replace": ["line", "block"],
319            "output": [],
320        }
321
322    def get_capabilities(self):
323        result = super(Cliconf, self).get_capabilities()
324        result["rpc"] += [
325            "edit_banner",
326            "get_diff",
327            "run_commands",
328            "get_defaults_flag",
329        ]
330        result["device_operations"] = self.get_device_operations()
331        result.update(self.get_option_values())
332        return json.dumps(result)
333
334    def edit_banner(
335        self, candidate=None, multiline_delimiter="@", commit=True
336    ):
337        """
338        Edit banner on remote device
339        :param banners: Banners to be loaded in json format
340        :param multiline_delimiter: Line delimiter for banner
341        :param commit: Boolean value that indicates if the device candidate
342               configuration should be  pushed in the running configuration or discarded.
343        :param diff: Boolean flag to indicate if configuration that is applied on remote host should
344                     generated and returned in response or not
345        :return: Returns response of executing the configuration command received
346             from remote host
347        """
348        resp = {}
349        banners_obj = json.loads(candidate)
350        results = []
351        requests = []
352        if commit:
353            for key, value in iteritems(banners_obj):
354                key += " %s" % multiline_delimiter
355                self.send_command("config terminal", sendonly=True)
356                for cmd in [key, value, multiline_delimiter]:
357                    obj = {"command": cmd, "sendonly": True}
358                    results.append(self.send_command(**obj))
359                    requests.append(cmd)
360
361                self.send_command("end", sendonly=True)
362                time.sleep(0.1)
363                results.append(self.send_command("\n"))
364                requests.append("\n")
365
366        resp["request"] = requests
367        resp["response"] = results
368
369        return resp
370
371    def run_commands(self, commands=None, check_rc=True):
372        if commands is None:
373            raise ValueError("'commands' value is required")
374
375        responses = list()
376        for cmd in to_list(commands):
377            if not isinstance(cmd, Mapping):
378                cmd = {"command": cmd}
379
380            output = cmd.pop("output", None)
381            if output:
382                raise ValueError(
383                    "'output' value %s is not supported for run_commands"
384                    % output
385                )
386
387            try:
388                out = self.send_command(**cmd)
389            except AnsibleConnectionFailure as e:
390                if check_rc:
391                    raise
392                out = getattr(e, "err", to_text(e))
393
394            responses.append(out)
395
396        return responses
397
398    def get_defaults_flag(self):
399        """
400        The method identifies the filter that should be used to fetch running-configuration
401        with defaults.
402        :return: valid default filter
403        """
404        out = self.get("show running-config ?")
405        out = to_text(out, errors="surrogate_then_replace")
406
407        commands = set()
408        for line in out.splitlines():
409            if line.strip():
410                commands.add(line.strip().split()[0])
411
412        if "all" in commands:
413            return "all"
414        else:
415            return "full"
416
417    def set_cli_prompt_context(self):
418        """
419        Make sure we are in the operational cli mode
420        :return: None
421        """
422        if self._connection.connected:
423            out = self._connection.get_prompt()
424
425            if out is None:
426                raise AnsibleConnectionFailure(
427                    message=u"cli prompt is not identified from the last received"
428                    u" response window: %s"
429                    % self._connection._last_recv_window
430                )
431
432            if re.search(
433                r"config.*\)#",
434                to_text(out, errors="surrogate_then_replace").strip(),
435            ):
436                self._connection.queue_message(
437                    "vvvv", "wrong context, sending end to device"
438                )
439                self._connection.send_command("end")
440
441    def _extract_banners(self, config):
442        banners = {}
443        banner_cmds = re.findall(r"^banner (\w+)", config, re.M)
444        for cmd in banner_cmds:
445            regex = r"banner %s \^C(.+?)(?=\^C)" % cmd
446            match = re.search(regex, config, re.S)
447            if match:
448                key = "banner %s" % cmd
449                banners[key] = match.group(1).strip()
450
451        for cmd in banner_cmds:
452            regex = r"banner %s \^C(.+?)(?=\^C)" % cmd
453            match = re.search(regex, config, re.S)
454            if match:
455                config = config.replace(str(match.group(1)), "")
456
457        config = re.sub(r"banner \w+ \^C\^C", "!! banner removed", config)
458        return config, banners
459
460    def _diff_banners(self, want, have):
461        candidate = {}
462        for key, value in iteritems(want):
463            if value != have.get(key):
464                candidate[key] = value
465        return candidate
466