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