1import re 2import copy 3 4# https://stackoverflow.com/questions/5121931/in-python-how-can-you-load-yaml-mappings-as-ordereddicts 5 6# stdlib 7import os 8from inspect import isclass 9from lxml import etree 10import json 11 12# local 13from jnpr.junos.exception import RpcError 14from jnpr.junos.utils.start_shell import StartShell 15from jnpr.junos.factory.state_machine import StateMachine 16from jnpr.junos.factory.to_json import TableJSONEncoder 17 18from jinja2 import Template 19 20HAS_NTC_TEMPLATE = False 21try: 22 from ntc_templates import parse as ntc_parse 23 24 HAS_NTC_TEMPLATE = True 25except: 26 pass 27 28import logging 29 30logger = logging.getLogger("jnpr.junos.factory.cmdtable") 31 32 33class CMDTable(object): 34 def __init__(self, dev=None, raw=None, path=None, template_dir=None): 35 """ 36 :dev: Device instance 37 :raw: string blob of the command output 38 :path: file path to XML, to be used rather than :dev: 39 :template_dir: To look for textfsm templates in this folder first 40 """ 41 self._dev = dev 42 self.xml = None 43 self.view = None 44 self.ITEM_FILTER = "name" 45 self._key_list = [] 46 self._path = path 47 self._parser = None 48 self.output = None 49 self.data = raw 50 self.template_dir = template_dir 51 52 # ------------------------------------------------------------------------- 53 # PUBLIC METHODS 54 # ------------------------------------------------------------------------- 55 56 def get(self, *vargs, **kvargs): 57 """ 58 Retrieve the XML (string blob under <output> tag of table data from the 59 Device instance and returns back the Table instance - for call-chaining 60 purposes. 61 62 If the Table was created with a :path: rather than a Device, 63 then this method will load the string blob from that file. In this 64 case, the \*vargs, and \**kvargs are not used. 65 66 ALIAS: __call__ 67 68 :vargs: 69 [0] is the table :arg_key: value. This is used so that 70 the caller can retrieve just one item from the table without 71 having to know the Junos RPC argument. 72 73 :kvargs: 74 these are the name/value pairs relating to the specific Junos 75 command attached to the table. For example, if the command 76 is 'show ithrottle id {{ id }}', there is a parameter 'id'. 77 Any valid command argument can be passed to :kvargs: to further filter 78 the results of the :get(): operation. neato! 79 80 NOTES: 81 If you need to create a 'stub' for unit-testing 82 purposes, you want to create a subclass of your table and 83 overload this methods. 84 """ 85 self._clearkeys() 86 87 if self._path and self.data: 88 raise AttributeError("path and data are mutually exclusive") 89 if self._path is not None: 90 # for loading from local file-path 91 with open(self._path, "r") as fp: 92 self.data = fp.read().strip() 93 if self.data.startswith("<output>") and self.data.endswith("</output>"): 94 self.data = etree.fromstring(self.data).text 95 96 if "target" in kvargs: 97 self.TARGET = kvargs["target"] 98 99 if "key" in kvargs: 100 self.KEY = kvargs["key"] 101 102 if "key_items" in kvargs: 103 self.KEY_ITEMS = kvargs["key_items"] 104 105 if "filters" in kvargs: 106 self.VIEW.FILTERS = ( 107 [kvargs["filters"]] 108 if isinstance(kvargs["filters"], str) 109 else kvargs["filters"] 110 ) 111 112 cmd_args = self.CMD_ARGS.copy() 113 if "args" in kvargs and isinstance(kvargs["args"], dict): 114 cmd_args.update(kvargs["args"]) 115 116 if len(cmd_args) > 0: 117 self.GET_CMD = Template(self.GET_CMD).render(**cmd_args) 118 119 if self.data is None: 120 # execute the Junos RPC to retrieve the table 121 if hasattr(self, "TARGET"): 122 if self.TARGET is None: 123 raise ValueError('"target" value not provided') 124 rpc_args = { 125 "target": self.TARGET, 126 "command": self.GET_CMD, 127 "timeout": "0", 128 } 129 try: 130 self.xml = getattr(self.RPC, "request_pfe_execute")(**rpc_args) 131 self.data = self.xml.text 132 ver_info = self._dev.facts.get("version_info") 133 if ver_info and ver_info.major[0] <= 15: 134 # Junos <=15.x output has output something like below 135 # 136 # <rpc-reply> 137 # <output> 138 # SENT: Ukern command: show memory 139 # GOT: 140 # GOT: ID Base Total(b) Free(b) Used(b) 141 # GOT: -- -------- --------- --------- --------- 142 # GOT: 0 44e72078 1882774284 1689527364 193246920 143 # GOT: 1 b51ffb88 67108860 57651900 9456960 144 # GOT: 2 bcdfffe0 52428784 52428784 0 145 # GOT: 3 b91ffb88 62914556 62914556 0 146 # LOCAL: End of file 147 # </output> 148 # </rpc-reply> 149 # hence need to do cleanup 150 self.data = self.data.replace("GOT: ", "") 151 except RpcError: 152 with StartShell(self.D) as ss: 153 ret = ss.run( 154 'cprod -A %s -c "%s"' % (self.TARGET, self.GET_CMD) 155 ) 156 if ret[0]: 157 self.data = ret[1] 158 else: 159 self.xml = self.RPC.cli(self.GET_CMD) 160 self.data = self.xml.text 161 162 if self.USE_TEXTFSM: 163 if HAS_NTC_TEMPLATE: 164 self.output = self._parse_textfsm( 165 platform=self.PLATFORM, command=self.GET_CMD, raw=self.data 166 ) 167 else: 168 raise ImportError( 169 "ntc_template is missing. Need to be installed explicitly." 170 ) 171 else: 172 # state machine 173 sm = StateMachine(self) 174 self.output = sm.parse(self.data.splitlines()) 175 176 # returning self for call-chaining purposes, yo! 177 return self 178 179 # ------------------------------------------------------------------------- 180 # PROPERTIES 181 # ------------------------------------------------------------------------- 182 183 @property 184 def D(self): 185 """ the Device instance """ 186 return self._dev 187 188 @property 189 def CLI(self): 190 """ the Device.cli instance """ 191 return self.D.cli 192 193 @property 194 def RPC(self): 195 """ the Device.rpc instance """ 196 return self.D.rpc 197 198 @property 199 def view(self): 200 """ returns the current view assigned to this table """ 201 return self._view 202 203 @view.setter 204 def view(self, cls): 205 """ assigns a new view to the table """ 206 if cls is None: 207 self._view = None 208 return 209 210 if not isclass(cls): 211 raise ValueError("Must be given RunstatView class") 212 213 self._view = cls 214 215 @property 216 def hostname(self): 217 return self.D.hostname 218 219 @property 220 def key_list(self): 221 """ the list of keys, as property for caching """ 222 return self._key_list 223 224 # ------------------------------------------------------------------------- 225 # PRIVATE METHODS 226 # ------------------------------------------------------------------------- 227 228 def _assert_data(self): 229 if self.data is None: 230 raise RuntimeError("Table is empty, use get()") 231 232 def _clearkeys(self): 233 self._key_list = [] 234 235 # ------------------------------------------------------------------------- 236 # PUBLIC METHODS 237 # ------------------------------------------------------------------------- 238 239 # ------------------------------------------------------------------------ 240 # keys 241 # ------------------------------------------------------------------------ 242 243 def _keys(self): 244 """ return a list of data item keys from the data string """ 245 246 return self.output.keys() 247 248 def keys(self): 249 # if the key_list has been cached, then use it 250 if len(self.key_list): 251 return self.key_list 252 253 # otherwise, build the list of keys into the cache 254 self._key_list = self._keys() 255 return self._key_list 256 257 # ------------------------------------------------------------------------ 258 # values 259 # ------------------------------------------------------------------------ 260 261 def values(self): 262 """ returns list of table entry items() """ 263 264 self._assert_data() 265 return self.output.values() 266 267 # ------------------------------------------------------------------------ 268 # items 269 # ------------------------------------------------------------------------ 270 271 def items(self): 272 """ returns list of tuple(name,values) for each table entry """ 273 return self.output.items() 274 275 def to_json(self): 276 """ 277 :returns: JSON encoded string of entire Table contents 278 """ 279 return json.dumps(self, cls=TableJSONEncoder) 280 281 # ------------------------------------------------------------------------- 282 # OVERLOADS 283 # ------------------------------------------------------------------------- 284 285 __call__ = get 286 287 def __repr__(self): 288 cls_name = self.__class__.__name__ 289 source = self.D.hostname if self.D is not None else self._path 290 291 if self.data is None: 292 return "%s:%s - Table empty" % (cls_name, source) 293 else: 294 n_items = len(self.keys()) 295 return "%s:%s: %s items" % (cls_name, source, n_items) 296 297 def __len__(self): 298 self._assert_data() 299 return len(self.keys()) 300 301 def __iter__(self): 302 """ iterate over each time in the table """ 303 self._assert_data() 304 305 for key, value in self.output.items(): 306 yield key, value 307 308 def __getitem__(self, value): 309 """ 310 returns a table item. If a table view is set (should be by default) 311 then the item will be converted to the view upon return. if there is 312 no table view, then the XML object will be returned. 313 314 :value: 315 for <string>, this will perform a select based on key-name 316 for <tuple>, this will perform a select based on compsite key-name 317 for <int>, this will perform a select based by position, like <list> 318 [0] is the first item 319 [-1] is the last item 320 when it is a <slice> then this will return a <list> of View widgets 321 """ 322 self._assert_data() 323 return self.output[value] 324 325 def __contains__(self, key): 326 """ membership for use with 'in' """ 327 return bool(key in self.keys()) 328 329 # ------------------------------------------------------------------------ 330 # textfsm 331 # ------------------------------------------------------------------------ 332 333 def _parse_textfsm(self, platform=None, command=None, raw=None): 334 """ 335 textfsm returns list of dict, make it JSON/dict 336 337 :param platform: vendor platform, for ex cisco_xr 338 :param command: cli command to be parsed 339 :param raw: string blob output from the cli command execution 340 :return: dict of parsed data. 341 """ 342 attrs = dict(Command=command, Platform=platform) 343 344 template = None 345 template_dir = None 346 if self.template_dir is not None: 347 # we dont need index file for lookup 348 index = None 349 template_path = os.path.join( 350 self.template_dir, 351 "{}_{}.textfsm".format(platform, "_".join(command.split())), 352 ) 353 if not os.path.exists(template_path): 354 msg = "Template file %s missing" % template_path 355 logger.error(msg) 356 raise FileNotFoundError(msg) 357 else: 358 template = template_path 359 template_dir = self.template_dir 360 if template_dir is None: 361 index = "index" 362 template_dir = ntc_parse._get_template_dir() 363 364 cli_table = ntc_parse.clitable.CliTable(index, template_dir) 365 try: 366 cli_table.ParseCmd(raw, attrs, template) 367 except ntc_parse.clitable.CliTableError as ex: 368 logger.error( 369 'Unable to parse command "%s" on platform %s' % (command, platform) 370 ) 371 raise ex 372 return self._filter_output(cli_table) 373 374 def _filter_output(self, cli_table): 375 """ 376 textfsm return list of list, covert it into more consumable format 377 378 :param cli_table: CLiTable object from textfsm 379 :return: dict of key, fields and its values, list of dict when key is None 380 """ 381 self._set_key(cli_table) 382 383 fields = self.VIEW.FIELDS if self.VIEW is not None else {} 384 reverse_fields = {val: key for key, val in fields.items()} 385 if self.KEY is None: 386 cli_table_size = cli_table.size 387 if cli_table_size > 1: 388 raise KeyError( 389 "Key is Mandatory for parsed o/p of %s " "length" % cli_table_size 390 ) 391 elif cli_table_size == 1: 392 temp_dict = self._parse_row(cli_table[1], cli_table, reverse_fields) 393 logger.debug("For Null Key, data returned: {}".format(temp_dict)) 394 return temp_dict 395 output = {} 396 for row in cli_table: 397 temp_dict = self._parse_row(row, cli_table, reverse_fields) 398 logger.debug("data at index {} is {}".format(row.row, temp_dict)) 399 if isinstance(self.KEY, list): 400 key_list = [] 401 for key in self.KEY: 402 if key not in fields: 403 key_list.append(temp_dict.pop(key)) 404 else: 405 key_list.append(temp_dict[key]) 406 output[tuple(key_list)] = temp_dict 407 elif self.KEY in temp_dict: 408 if self.KEY not in fields: 409 output[temp_dict.pop(self.KEY)] = temp_dict 410 else: 411 output[temp_dict[self.KEY]] = temp_dict 412 else: 413 logger.debug("Key {} not present in {}".format(self.KEY, temp_dict)) 414 return output 415 416 def _parse_row(self, row, cli_table, reverse_fields): 417 temp_dict = {} 418 for index, element in enumerate(row): 419 key = cli_table.header[index] 420 if self.KEY and key in self.KEY: 421 temp_dict[key] = element 422 if reverse_fields: 423 if key in reverse_fields: 424 temp_dict[reverse_fields[key]] = element 425 else: 426 temp_dict[key] = element 427 return temp_dict 428 429 def _set_key(self, cli_table): 430 """ 431 Preference to user provided key, then template and at last default 432 Checks and update if we need KEY from template file 433 434 :param cli_table: CLiTable object from textfsm 435 :return: 436 """ 437 if self.KEY == "name" and len(cli_table._keys) > 0: 438 template_keys = list(cli_table._keys) 439 self.KEY = template_keys[0] if len(template_keys) == 1 else template_keys 440 logger.debug("KEY being used: {}".format(self.KEY)) 441