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