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