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: vyos
27short_description: Use vyos cliconf to run command on VyOS platform
28description:
29  - This vyos plugin provides low level abstraction apis for
30    sending and receiving CLI commands from VyOS network devices.
31version_added: "2.4"
32"""
33
34import re
35import json
36
37from ansible.errors import AnsibleConnectionFailure
38from ansible.module_utils._text import to_text
39from ansible.module_utils.common._collections_compat import Mapping
40from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.config import (
41    NetworkConfig,
42)
43from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import (
44    to_list,
45)
46from ansible.plugins.cliconf import CliconfBase
47
48
49class Cliconf(CliconfBase):
50    def get_device_info(self):
51        device_info = {}
52
53        device_info["network_os"] = "vyos"
54        reply = self.get("show version")
55        data = to_text(reply, errors="surrogate_or_strict").strip()
56
57        match = re.search(r"Version:\s*(.*)", data)
58        if match:
59            device_info["network_os_version"] = match.group(1)
60
61        match = re.search(r"HW model:\s*(\S+)", data)
62        if match:
63            device_info["network_os_model"] = match.group(1)
64
65        reply = self.get("show host name")
66        device_info["network_os_hostname"] = to_text(
67            reply, errors="surrogate_or_strict"
68        ).strip()
69
70        return device_info
71
72    def get_config(self, flags=None, format=None):
73        if format:
74            option_values = self.get_option_values()
75            if format not in option_values["format"]:
76                raise ValueError(
77                    "'format' value %s is invalid. Valid values of format are %s"
78                    % (format, ", ".join(option_values["format"]))
79                )
80
81        if not flags:
82            flags = []
83
84        if format == "text":
85            command = "show configuration"
86        else:
87            command = "show configuration commands"
88
89        command += " ".join(to_list(flags))
90        command = command.strip()
91
92        out = self.send_command(command)
93        return out
94
95    def edit_config(
96        self, candidate=None, commit=True, replace=None, comment=None
97    ):
98        resp = {}
99        operations = self.get_device_operations()
100        self.check_edit_config_capability(
101            operations, candidate, commit, replace, comment
102        )
103
104        results = []
105        requests = []
106        self.send_command("configure")
107        for cmd in to_list(candidate):
108            if not isinstance(cmd, Mapping):
109                cmd = {"command": cmd}
110
111            results.append(self.send_command(**cmd))
112            requests.append(cmd["command"])
113        out = self.get("compare")
114        out = to_text(out, errors="surrogate_or_strict")
115        diff_config = out if not out.startswith("No changes") else None
116
117        if diff_config:
118            if commit:
119                try:
120                    self.commit(comment)
121                except AnsibleConnectionFailure as e:
122                    msg = "commit failed: %s" % e.message
123                    self.discard_changes()
124                    raise AnsibleConnectionFailure(msg)
125                else:
126                    self.send_command("exit")
127            else:
128                self.discard_changes()
129        else:
130            self.send_command("exit")
131            if (
132                to_text(
133                    self._connection.get_prompt(), errors="surrogate_or_strict"
134                )
135                .strip()
136                .endswith("#")
137            ):
138                self.discard_changes()
139
140        if diff_config:
141            resp["diff"] = diff_config
142        resp["response"] = results
143        resp["request"] = requests
144        return resp
145
146    def get(
147        self,
148        command=None,
149        prompt=None,
150        answer=None,
151        sendonly=False,
152        output=None,
153        newline=True,
154        check_all=False,
155    ):
156        if not command:
157            raise ValueError("must provide value of command to execute")
158        if output:
159            raise ValueError(
160                "'output' value %s is not supported for get" % output
161            )
162
163        return self.send_command(
164            command=command,
165            prompt=prompt,
166            answer=answer,
167            sendonly=sendonly,
168            newline=newline,
169            check_all=check_all,
170        )
171
172    def commit(self, comment=None):
173        if comment:
174            command = 'commit comment "{0}"'.format(comment)
175        else:
176            command = "commit"
177        self.send_command(command)
178
179    def discard_changes(self):
180        self.send_command("exit discard")
181
182    def get_diff(
183        self,
184        candidate=None,
185        running=None,
186        diff_match="line",
187        diff_ignore_lines=None,
188        path=None,
189        diff_replace=None,
190    ):
191        diff = {}
192        device_operations = self.get_device_operations()
193        option_values = self.get_option_values()
194
195        if candidate is None and device_operations["supports_generate_diff"]:
196            raise ValueError(
197                "candidate configuration is required to generate diff"
198            )
199
200        if diff_match not in option_values["diff_match"]:
201            raise ValueError(
202                "'match' value %s in invalid, valid values are %s"
203                % (diff_match, ", ".join(option_values["diff_match"]))
204            )
205
206        if diff_replace:
207            raise ValueError("'replace' in diff is not supported")
208
209        if diff_ignore_lines:
210            raise ValueError("'diff_ignore_lines' in diff is not supported")
211
212        if path:
213            raise ValueError("'path' in diff is not supported")
214
215        set_format = candidate.startswith("set") or candidate.startswith(
216            "delete"
217        )
218        candidate_obj = NetworkConfig(indent=4, contents=candidate)
219        if not set_format:
220            config = [c.line for c in candidate_obj.items]
221            commands = list()
222            # this filters out less specific lines
223            for item in config:
224                for index, entry in enumerate(commands):
225                    if item.startswith(entry):
226                        del commands[index]
227                        break
228                commands.append(item)
229
230            candidate_commands = [
231                "set %s" % cmd.replace(" {", "") for cmd in commands
232            ]
233
234        else:
235            candidate_commands = str(candidate).strip().split("\n")
236
237        if diff_match == "none":
238            diff["config_diff"] = list(candidate_commands)
239            return diff
240
241        running_commands = [
242            str(c).replace("'", "") for c in running.splitlines()
243        ]
244
245        updates = list()
246        visited = set()
247
248        for line in candidate_commands:
249            item = str(line).replace("'", "")
250
251            if not item.startswith("set") and not item.startswith("delete"):
252                raise ValueError(
253                    "line must start with either `set` or `delete`"
254                )
255
256            elif item.startswith("set") and item not in running_commands:
257                updates.append(line)
258
259            elif item.startswith("delete"):
260                if not running_commands:
261                    updates.append(line)
262                else:
263                    item = re.sub(r"delete", "set", item)
264                    for entry in running_commands:
265                        if entry.startswith(item) and line not in visited:
266                            updates.append(line)
267                            visited.add(line)
268
269        diff["config_diff"] = list(updates)
270        return diff
271
272    def run_commands(self, commands=None, check_rc=True):
273        if commands is None:
274            raise ValueError("'commands' value is required")
275
276        responses = list()
277        for cmd in to_list(commands):
278            if not isinstance(cmd, Mapping):
279                cmd = {"command": cmd}
280
281            output = cmd.pop("output", None)
282            if output:
283                raise ValueError(
284                    "'output' value %s is not supported for run_commands"
285                    % output
286                )
287
288            try:
289                out = self.send_command(**cmd)
290            except AnsibleConnectionFailure as e:
291                if check_rc:
292                    raise
293                out = getattr(e, "err", e)
294
295            responses.append(out)
296
297        return responses
298
299    def get_device_operations(self):
300        return {
301            "supports_diff_replace": False,
302            "supports_commit": True,
303            "supports_rollback": False,
304            "supports_defaults": False,
305            "supports_onbox_diff": True,
306            "supports_commit_comment": True,
307            "supports_multiline_delimiter": False,
308            "supports_diff_match": True,
309            "supports_diff_ignore_lines": False,
310            "supports_generate_diff": False,
311            "supports_replace": False,
312        }
313
314    def get_option_values(self):
315        return {
316            "format": ["text", "set"],
317            "diff_match": ["line", "none"],
318            "diff_replace": [],
319            "output": [],
320        }
321
322    def get_capabilities(self):
323        result = super(Cliconf, self).get_capabilities()
324        result["rpc"] += [
325            "commit",
326            "discard_changes",
327            "get_diff",
328            "run_commands",
329        ]
330        result["device_operations"] = self.get_device_operations()
331        result.update(self.get_option_values())
332        return json.dumps(result)
333
334    def set_cli_prompt_context(self):
335        """
336        Make sure we are in the operational cli mode
337        :return: None
338        """
339        if self._connection.connected:
340            self._update_cli_prompt_context(
341                config_context="#", exit_command="exit discard"
342            )
343