1from copy import deepcopy 2import re 3 4from lxml import etree 5from lxml.builder import E 6from jnpr.junos.factory.table import Table 7from jnpr.junos import jxml 8from jnpr.junos.utils.config import Config 9 10 11class CfgTable(Table): 12 13 __isfrozen = False 14 15 # ----------------------------------------------------------------------- 16 # CONSTRUCTOR 17 # ----------------------------------------------------------------------- 18 def __init__(self, dev=None, xml=None, path=None, mode=None): 19 Table.__init__(self, dev, xml, path) # call parent constructor 20 21 self._init_get() 22 self._data_dict = self.DEFINE # crutch 23 self.ITEM_NAME_XPATH = self._data_dict.get("key", "name") 24 self.view = self._data_dict.get("view") 25 self._options = self._data_dict.get("options") 26 self.mode = mode 27 if "set" in self._data_dict: 28 Config.__init__(self, dev, mode) # call parent constructor 29 30 self._init_set() 31 if self._view: 32 self.fields = self._view.FIELDS.copy() 33 else: 34 raise ValueError( 35 "%s set table view is not defined.\n" % (self.__class__.__name__) 36 ) 37 if "key-field" in self._data_dict: 38 key_name = self._data_dict.get("key-field", None) 39 if isinstance(key_name, list): 40 self.key_field = key_name 41 elif isinstance(key_name, str): 42 self.key_field = [key_name] 43 else: 44 raise TypeError( 45 "Key-field %s is of invalid type %s.\n" 46 % (key_name, type(key_name)) 47 ) 48 else: 49 raise ValueError("Table should have key-field attribute defined\n") 50 self._type = "set" 51 self._init_field() 52 else: 53 self._type = "get" 54 self.ITEM_XPATH = self._data_dict[self._type] 55 56 # no new attributes. 57 self._freeze() 58 59 # ----------------------------------------------------------------------- 60 # PROPERTIES 61 # ----------------------------------------------------------------------- 62 63 @property 64 def required_keys(self): 65 """ 66 return a list of the keys required when invoking :get(): 67 and :get_keys(): 68 """ 69 return self._data_dict.get("required_keys") 70 71 @property 72 def keys_required(self): 73 """ True/False - if this Table requires keys """ 74 return self.required_keys is not None 75 76 # ----------------------------------------------------------------------- 77 # PRIVATE METHODS 78 # ----------------------------------------------------------------------- 79 def _init_get(self): 80 self._get_xpath = None 81 82 # for debug purposes 83 self._get_cmd = None 84 self._get_opt = None 85 86 def _init_set(self): 87 self._insert_node = None 88 89 # lxml object of configuration xml 90 self._config_xml_req = None 91 92 # To check if field value is set. 93 self._is_field_set = False 94 95 # for debug purposes 96 self._load_rsp = None 97 self._commit_rsp = None 98 99 def _buildxml(self, namesonly=False): 100 """ 101 Return an lxml Element starting with <configuration> and comprised 102 of the elements specified in the xpath expression. 103 104 For example, and xpath = 'interfaces/interface' would produce: 105 106 <configuration> 107 <interfaces> 108 <interface/> 109 </interfaces> 110 </configuration> 111 112 If :namesonly: is True, then the XML will be encoded only to 113 retrieve the XML name keys at the 'end' of the XPath expression. 114 115 This return value can then be passed to dev.rpc.get_config() 116 to retrieve the specifc data 117 """ 118 xpath = self._data_dict[self._type] 119 self._get_xpath = "//configuration/" + xpath 120 top = E("configuration") 121 dot = top 122 for name in xpath.split("/"): 123 dot.append(E(name)) 124 dot = dot[0] 125 126 if namesonly is True: 127 dot.attrib["recurse"] = "false" 128 return top 129 130 def _build_config_xml(self, top): 131 """ 132 used to encode the field values into the configuration XML 133 for set table,each of the field=<value> pairs are defined by user: 134 """ 135 for field_name, opt in self.fields.items(): 136 dot = top 137 # create an XML element with the key/value 138 field_value = getattr(self, field_name, None) 139 # If field value is not set ignore it 140 if field_value is None: 141 continue 142 143 if isinstance(field_value, (list, tuple, set)): 144 [self._validate_value(field_name, v, opt) for v in field_value] 145 else: 146 self._validate_value(field_name, field_value, opt) 147 148 field_dict = self.fields[field_name] 149 150 if "group" in field_dict: 151 group_xpath = self._view.GROUPS[field_dict["group"]] 152 dot = self._encode_xpath(top, group_xpath.split("/")) 153 154 lxpath = field_dict["xpath"].split("/") 155 if len(lxpath) > 1: 156 dot = self._encode_xpath(top, lxpath[0 : len(lxpath) - 1]) 157 158 add_field = self._grindfield(lxpath[-1], field_value) 159 for _add in add_field: 160 if len(_add.attrib) > 0: 161 for i in dot.getiterator(): 162 if i.tag == _add.tag: 163 i.attrib.update(_add.attrib) 164 break 165 else: 166 dot.append(_add) 167 elif field_name in self.key_field: 168 dot.insert(0, _add) 169 else: 170 dot.append(_add) 171 172 def _validate_value(self, field_name, value, opt): 173 """ 174 Validate value set for field against the constraints and 175 data type check define in yml table/view defination. 176 177 :param field_name: Name of field as mentioned in yaml table/view 178 :param value: Value set by user for field_name. 179 :param opt: Dictionary of data type and constraint check. 180 :return: 181 """ 182 183 def _get_field_type(ftype): 184 ft = { 185 "str": str, 186 "int": int, 187 "float": float, 188 "bool": bool, 189 }.get(ftype, None) 190 191 if ft is None: 192 raise TypeError("Unsupported type %s\n" % (ftype)) 193 return ft 194 195 def _validate_enum_value(field_name, value, enum_value): 196 if isinstance(enum_value, list): 197 if value not in enum_value: 198 raise ValueError( 199 "Invalid value %s assigned " "to field %s" % (value, field_name) 200 ) 201 elif isinstance(enum_value, str): 202 if not value == enum_value: 203 raise ValueError( 204 "Invalid value %s assigned " "to field %s" % (value, field_name) 205 ) 206 else: 207 raise TypeError( 208 "Value of enum should " "be either a string or list of strings.\n" 209 ) 210 211 def _validate_type(field_name, value, opt): 212 if isinstance(opt["type"], dict): 213 if "enum" in opt["type"]: 214 _validate_enum_value(field_name, value, opt["type"]["enum"]) 215 else: 216 # More user defined type check can be added in future. 217 # raise execption for now. 218 raise TypeError("Unsupported type %s\n" % (opt["type"])) 219 220 elif isinstance(opt["type"], str): 221 field_type = _get_field_type(opt["type"]) 222 if not isinstance(value, field_type): 223 raise TypeError( 224 "Invalid value %s asigned to field %s," 225 " value should be of type %s\n" 226 % (value, field_name, field_type) 227 ) 228 else: 229 raise TypeError( 230 "Invalid value %s, should be either of" 231 " type string or dictionary.\n" % (opt["type"]) 232 ) 233 234 def _validate_min_max_value(field_name, value, opt): 235 if isinstance(value, (int, float)): 236 if value < opt["minValue"] or value > opt["maxValue"]: 237 raise ValueError( 238 "Invalid value %s assigned " 239 "to field %s.\n" % (value, field_name) 240 ) 241 elif isinstance(value, str): 242 if len(value) < opt["minValue"] or len(value) > opt["maxValue"]: 243 raise ValueError( 244 "Invalid value %s assigned " 245 "to field %s.\n" % (value, field_name) 246 ) 247 248 if isinstance(value, dict): 249 # in case user want to pass operation attr for ex: 250 # <unit operation="delete"/> 251 pass 252 elif isinstance(value, (list, tuple, dict, set)): 253 raise ValueError("%s value is invalid %s\n" % (field_name, value)) 254 else: 255 if "type" in opt: 256 _validate_type(field_name, value, opt) 257 if ("minValue" or "maxValue") in opt: 258 _validate_min_max_value(field_name, value, opt) 259 260 def _grindkey(self, key_xpath, key_value): 261 """ returns list of XML elements for key values """ 262 simple = lambda: [E(key_xpath.replace("_", "-"), key_value)] 263 composite = lambda: [ 264 E(xp.replace("_", "-"), xv) for xp, xv in zip(key_xpath, key_value) 265 ] 266 return simple() if isinstance(key_xpath, str) else composite() 267 268 def _grindxpath(self, key_xpath, key_value): 269 """ returns xpath elements for key values """ 270 simple = lambda: "[{}='{}']".format(key_xpath.replace("_", "-"), key_value) 271 composite = lambda: "[{}]".format( 272 " and ".join( 273 [ 274 "{}='{}'".format(xp.replace("_", "-"), xv) 275 for xp, xv in zip(key_xpath, key_value) 276 ] 277 ) 278 ) 279 return simple() if isinstance(key_xpath, str) else composite() 280 281 def _grindfield(self, xpath, value): 282 """ returns list of xml elements for field name-value pairs """ 283 lst = [] 284 if isinstance(value, (list, tuple, set)): 285 for v in value: 286 lst.append(E(xpath.replace("_", "-"), str(v))) 287 elif isinstance(value, bool): 288 if value is True: 289 lst.append(E(xpath.replace("_", "-"))) 290 elif value is False: 291 lst.append(E(xpath.replace("_", "-"), {"operation": "delete"})) 292 elif isinstance(value, dict): 293 lst.append(E(xpath.replace("_", "-"), value)) 294 else: 295 lst.append(E(xpath.replace("_", "-"), str(value))) 296 return lst 297 298 def _encode_requiredkeys(self, get_cmd, kvargs): 299 """ 300 used to encode the required_keys values into the XML get-command. 301 each of the required_key=<value> pairs are defined in :kvargs: 302 """ 303 rqkeys = self._data_dict["required_keys"] 304 for key_name in self.required_keys: 305 # create an XML element with the key/value 306 key_value = kvargs.get(key_name) 307 if key_value is None: 308 raise ValueError("Missing required-key: '%s'" % (key_name)) 309 key_xpath = rqkeys[key_name] 310 add_keylist_xml = self._grindkey(key_xpath, key_value) 311 312 # now link this item into the XML command, where key_name 313 # designates the XML parent element 314 key_name = key_name.replace("_", "-") 315 dot = get_cmd.find(".//" + key_name) 316 if dot is None: 317 raise RuntimeError( 318 "Unable to find parent XML for key: '%s'" % (key_name) 319 ) 320 for _at, _add in enumerate(add_keylist_xml): 321 dot.insert(_at, _add) 322 323 # Add required key values to _get_xpath 324 xid = re.search(r"\b{}\b".format(key_name), self._get_xpath).start() + len( 325 key_name 326 ) 327 328 self._get_xpath = ( 329 self._get_xpath[:xid] 330 + self._grindxpath(key_xpath, key_value) 331 + self._get_xpath[xid:] 332 ) 333 334 def _encode_namekey(self, get_cmd, dot, namekey_value): 335 """ 336 encodes the specific namekey_value into the get command so that the 337 returned XML configuration is the complete hierarchy of data. 338 """ 339 namekey_xpath = self._data_dict.get("key", "name") 340 keylist_xml = self._grindkey(namekey_xpath, namekey_value) 341 for _add in keylist_xml: 342 dot.append(_add) 343 344 def _encode_getfields(self, get_cmd, dot): 345 for field_xpath in self._data_dict["get_fields"]: 346 dot.append(E(field_xpath)) 347 348 def _encode_xpath(self, top, lst): 349 """ 350 Create xml element hierarchy for given field. Return container 351 node to which field and its value will appended as child elements. 352 """ 353 dot = top 354 for index in range(1, len(lst) + 1): 355 xp = "/".join(lst[0:index]) 356 if not len(top.xpath(xp)): 357 dot.append(E(lst[index - 1])) 358 dot = dot.find(lst[index - 1]) 359 return dot 360 361 def _keyspec(self): 362 """ returns tuple (keyname-xpath, item-xpath) """ 363 return (self._data_dict.get("key", "name"), self._data_dict[self._type]) 364 365 def _init_field(self): 366 """ 367 Initialize fields of set table to it's default value 368 (if mentioned in yml Table/View) else set to None. 369 """ 370 for fname, opt in self.fields.items(): 371 self.__dict__[fname] = opt["default"] if "default" in opt else None 372 373 def _mandatory_check(self): 374 """ Mandatory checks for set table/view """ 375 for key in self.key_field: 376 value = getattr(self, key) 377 if value is None: 378 raise ValueError("%s key-field value is not set.\n" % (key)) 379 380 def _freeze(self): 381 """ 382 Freeze class object so that user cannot add new attributes (fields). 383 """ 384 self.__isfrozen = True 385 386 def _unfreeze(self): 387 """ 388 Unfreeze class object, should be called from within class only. 389 """ 390 self.__isfrozen = False 391 392 # ---------------------------------------------------------------------- 393 # reset - Assign 'set' Table field values to default or None 394 # ---------------------------------------------------------------------- 395 def reset(self): 396 """ 397 Initialize fields of set table to it's default value 398 (if mentioned in Table/View) else set to None. 399 """ 400 return self._init_field() 401 402 # ---------------------------------------------------------------------- 403 # get_table_xml - retrieve lxml configuration object for set table 404 # ---------------------------------------------------------------------- 405 def get_table_xml(self): 406 """ 407 It returns lxml object of configuration xml that is generated 408 from table data (field=value) pairs. To get a valid xml this 409 method should be used after append() is called. 410 """ 411 return self._config_xml_req 412 413 # ---------------------------------------------------------------------- 414 # append - append Table data to lxml configuration object 415 # ---------------------------------------------------------------------- 416 def append(self): 417 """ 418 It creates lxml nodes with field name as xml tag and its value 419 given by user as text of xml node. The generated xml nodes are 420 appended to configuration xml at appropriate hierarchy. 421 422 .. warning:: 423 xml node that are appended cannot be changed later hence 424 care should be taken to assign correct value to table fields 425 before calling append. 426 """ 427 428 # mandatory check for 'set' table fields 429 self._mandatory_check() 430 431 set_cmd = self._buildxml() 432 top = set_cmd.find(self._data_dict[self._type]) 433 self._build_config_xml(top) 434 if self._config_xml_req is None: 435 self._config_xml_req = set_cmd 436 self._insert_node = top.getparent() 437 else: 438 self._insert_node.extend(top.getparent()) 439 440 self.reset() # Reset field values 441 self._is_field_set = False 442 443 # ---------------------------------------------------------------------- 444 # get - retrieve Table data 445 # ---------------------------------------------------------------------- 446 447 def get(self, *vargs, **kvargs): 448 """ 449 Retrieve configuration data for this table. By default all child 450 keys of the table are loaded. This behavior can be overridden by 451 with kvargs['nameonly']=True 452 453 :param str vargs[0]: identifies a unique item in the table, 454 same as calling with :kvargs['key']: value 455 456 :param str namesonly: 457 *OPTIONAL* True/False*, when set to True will cause only the 458 the name-keys to be retrieved. 459 460 :param str key: 461 *OPTIONAL* identifies a unique item in the table 462 463 :param dict options: 464 *OPTIONAL* options to pass to get-configuration. By default 465 {'inherit': 'inherit', 'groups': 'groups'} is sent. 466 """ 467 if self._lxml is not None: 468 return self 469 470 if self._path is not None: 471 # for loading from local file-path 472 self.xml = etree.parse(self._path).getroot() 473 return self 474 475 if self.keys_required is True and not len(kvargs): 476 raise ValueError("This table has required-keys\n", self.required_keys) 477 478 self._clearkeys() 479 480 # determine if we need to get only the names of keys, or all of the 481 # hierarchical data from the config. The caller can explicitly set 482 # :namesonly: in the call. 483 484 if "namesonly" in kvargs: 485 namesonly = kvargs.get("namesonly") 486 else: 487 namesonly = False 488 489 get_cmd = self._buildxml(namesonly=namesonly) 490 491 # if this table requires additional keys, for the hierarchical 492 # use-cases then make sure these are provided by the caller. Then 493 # encode them into the 'get-cmd' XML 494 495 if self.keys_required is True: 496 self._encode_requiredkeys(get_cmd, kvargs) 497 498 try: 499 # see if the caller provided a named item. this must 500 # be an actual name of a thing, and not an index number. 501 # ... at least for now ... 502 named_item = kvargs.get("key") or vargs[0] 503 dot = get_cmd.find(self._data_dict[self._type]) 504 self._encode_namekey(get_cmd, dot, named_item) 505 506 if "get_fields" in self._data_dict: 507 self._encode_getfields(get_cmd, dot) 508 509 except: 510 # caller not requesting a specific table item 511 pass 512 513 # Check for options in get 514 if "options" in kvargs: 515 options = kvargs.get("options") or {} 516 else: 517 if self._options is not None: 518 options = self._options 519 else: 520 options = jxml.INHERIT_GROUPS 521 522 # for debug purposes 523 self._get_cmd = get_cmd 524 self._get_opt = options 525 526 # retrieve the XML configuration 527 # Check to see if running on box 528 if self._dev.ON_JUNOS: 529 try: 530 from junos import Junos_Configuration 531 532 # If part of commit script use the context 533 if Junos_Configuration is not None: 534 # Convert the onbox XML to ncclient reply 535 config = jxml.conf_transform( 536 deepcopy(jxml.cscript_conf(Junos_Configuration)), 537 subSelectionXPath=self._get_xpath, 538 ) 539 self.xml = config.getroot() 540 else: 541 self.xml = self.RPC.get_config(get_cmd, options=options) 542 # If onbox import missing fallback to RPC - possibly raise 543 # exception in future 544 except ImportError: 545 self.xml = self.RPC.get_config(get_cmd, options=options) 546 else: 547 self.xml = self.RPC.get_config(get_cmd, options=options) 548 549 # return self for call-chaining, yo! 550 return self 551 552 # ----------------------------------------------------------------------- 553 # set - configure Table data in running configuration. 554 # ----------------------------------------------------------------------- 555 556 def set(self, **kvargs): 557 """ 558 Load configuration data in running db. 559 It performs following operation in sequence. 560 561 * lock(): Locks candidate configuration db. 562 * load(): Load structured configuration xml in candidate db. 563 * commit(): Commit configuration to runnning db. 564 * unlock(): Unlock candidate db. 565 566 This method should be used after append() is called to 567 get the desired results. 568 569 :param bool overwrite: 570 Determines if the contents completely replace the existing 571 configuration. Default is ``False``. 572 573 :param bool merge: 574 If set to ``True`` will set the load-config action to merge. 575 the default load-config action is 'replace' 576 577 :param str comment: If provided logs this comment with the commit. 578 579 :param int confirm: If provided activates confirm safeguard with 580 provided value as timeout (minutes). 581 582 :param int timeout: If provided the command will wait for completion 583 using the provided value as timeout (seconds). 584 By default the device timeout is used. 585 586 :param bool sync: On dual control plane systems, requests that 587 the candidate configuration on one control plane 588 be copied to the other control plane, checked for 589 correct syntax, and committed on both Routing 590 Engines. 591 592 :param bool force_sync: On dual control plane systems, forces the 593 candidate configuration on one control plane 594 to be copied to the other control plane. 595 596 :param bool full: When true requires all the daemons to check and 597 evaluate the new configuration. 598 599 :param bool detail: When true return commit detail as XML 600 601 :returns: Class object: 602 603 :raises: ConfigLoadError: 604 When errors detected while loading 605 configuration. You can use the Exception errs 606 variable to identify the specific problems 607 608 CommitError: 609 When errors detected in candidate configuration. 610 You can use the Exception errs variable 611 to identify the specific problems 612 613 RuntimeError: 614 If field value is set and append() is not 615 invoked before calling this method, it will 616 raise an exception with appropriate error 617 message. 618 619 .. warning:: 620 If the function does not receive a reply prior to the timeout 621 a RpcTimeoutError will be raised. It is possible the commit 622 was successful. Manual verification may be required. 623 """ 624 if self._is_field_set: 625 raise RuntimeError( 626 "Field value is changed, append() " "must be called before set()" 627 ) 628 629 self.lock() 630 631 try: 632 # Invoke config class load() api, with xml object. 633 self._load_rsp = super(CfgTable, self).load(self._config_xml_req, **kvargs) 634 self._commit_rsp = self.commit(**kvargs) 635 finally: 636 self.unlock() 637 638 return self 639 640 # ----------------------------------------------------------------------- 641 # OVERLOADS 642 # ----------------------------------------------------------------------- 643 644 def __setitem__(self, t_field, value): 645 """ 646 implements []= to set Field value 647 """ 648 if t_field in self.fields: 649 # pass 'up' to standard setattr method 650 self.__setattr__(t_field, value) 651 else: 652 raise ValueError("Unknown field: %s" % (t_field)) 653 654 def __setattr__(self, attribute, value): 655 if self.__isfrozen and not hasattr(self, attribute): 656 raise ValueError("Unknown field: %s" % (attribute)) 657 else: 658 # pass 'up' to standard setattr method 659 object.__setattr__(self, attribute, value) 660 if hasattr(self, "fields") and attribute in self.fields: 661 object.__setattr__(self, "_is_field_set", True) 662 663 def __enter__(self): 664 return super(CfgTable, self).__enter__() 665 666 def __exit__(self, exc_type, exc_val, exc_tb): 667 return super(CfgTable, self).__exit__(exc_type, exc_val, exc_tb) 668 669 # ----------------------------------------------------------------------- 670 # load - configure Table data in candidate configuration. 671 # ----------------------------------------------------------------------- 672 673 def load(self, **kvargs): 674 """ 675 Load configuration xml having table data (field=value) 676 in candidate db. 677 This method should be used after append() is called to 678 get the desired results. 679 680 :param bool overwrite: 681 Determines if the contents completely replace the existing 682 configuration. Default is ``False``. 683 684 :param bool merge: 685 If set to ``True`` will set the load-config action to merge. 686 the default load-config action is 'replace' 687 688 :returns: Class object. 689 690 :raises: ConfigLoadError: 691 When errors detected while loading 692 configuration. You can use the Exception errs 693 variable to identify the specific problems 694 RuntimeError: 695 If field value is set and append() is not 696 invoked before calling this method, it will 697 raise an exception with appropriate error 698 message. 699 """ 700 if self._is_field_set: 701 raise RuntimeError( 702 "Field value is changed, append() " "must be called before load()" 703 ) 704 705 # pass up to config class load() api, with xml object as vargs[0]. 706 self._load_rsp = super(CfgTable, self).load(self._config_xml_req, **kvargs) 707 return self 708