From 0da702b6961348d1db9e0ba975a87f116797a2a2 Mon Sep 17 00:00:00 2001 From: dlyssenko <88390151+dlyssenko@users.noreply.github.com> Date: Tue, 6 Jun 2023 13:42:22 +0200 Subject: [PATCH] Release 1.0.0 (#264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Try importing ujson and rapidjson before json ujson and rapidjson are libraries that are faster to encode and decode JSON objects * Implement configure_session() to send "configure session", diff() for "show session-config diff", commit() for "commit", and abort() for "abort" * Bump version * Fix issue #186, fix typo in client.py * Amend tests for #186 * Amend remove vlan to vni mapping CLI * Add config session documentation * Add config session documentation * Remove config session doc from wrong dir * adding streaming option for commands * Correct typo "mutlicast" * Adding Power support(ppc64le)with continuous integration/testing to the project for architecture independent * Inorrect import from 'collections' will stop working in Python 3.9 Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3, and in 3.9 it will stop working Solving * Fix python2 dependancy on collections * Bump Python version to 3.9 for CI * Fix #189 #190 #195 * Bump version to rc * fixed issue #198 * #197 - updated vlans.getall() to handle vlan grouppings * updated vlans.get() to return range/list of vlan ids (instead of input regex), plus updated relevant unit tests * fixed import warning in abstract module * updated documentation to avoid using localhost for off-box connections * Refactor deprecated unittest aliases for Python 3.11 compatibility. * Update configfile.rst * fix for the issue #213 * Ignore subinterfaces on switchports.getall() Signed-off-by: DavidVentura * Stop on whitespace * extended config for unit testing issue #213 * Add a test * Add new value from #213 * merge tests * address perf issues on section splitting * fix bad test * Add option to not include defaults with node.running_config Currently, the Node(conn).running_config always includes config defaults, this is not always wanted. For example if you want to compare the running_config to the startup_config, it is easier to not have defaults included in the running_config. With this option you can do something like this; ``` In [3]: conn = client.connect(**params) In [4]: node = client.Node(conn, config_defaults=False) In [5]: node.running_config.splitlines()[3:] == node.startup_config.splitlines()[4:] Out[5]: True ``` * updated client descritpion to reflect issue #222 * mitor fix in the description for reST markdown * another minor update for client.connect description * Add support for passwords that cannot be used to login * added sphinx_rtd_theme to dev-requrements.txt * docs: fixing #231 * fixing collection.Iterables in test_utils.py for py3.10 * fixing collection.Iterables in test_utils.py for py3.10 - minor improvement * fix for issue #234 - allow passing ssl context to pyeapi * minor markdown fix in client module description * fix passwd exposure in traceback issue * fixed issue where sphinx failed to generate API modules doc, plus made it python3 ready * readying pyeapi for github actions + minor style fixes * Migrate from Travis CI to GitHub workflows * extended python-version list for CI with 2.7 and 3.10 * removed password from showing in debugs * added pep8 E128 to ignore list - to adopt to Arista coding style * fixed my update to accomodate to unit tests and possibly invalid jsons * minor typo fixes in comments * one more minor typo fix in comments * Update ci.yml self approving - trivial change * Update test_client.py self-approved - this is non-functional, only syntactic change to keep flake8 happy * Update eapilib.py self-approving: non-functional, only syntactic changes to keep flake8 happier. * Update ci.yml adding flake8 upgrade, as it crashes in some python versions * fix for issue #210 - getall() fails in ipinterfaces module * fix for issue #193 - flood list for vxlan in interfaces returns wrong data * enhanced `section` to match subsections and removed py2.7 from CI * minor syntax change to keep flake8 happy * added a doc string and dressed up the function, no functional change * few more space adjustments to please flake8 * Store and pass credentials as header tuple * Support session-based authorization Create a Session API mixin for Http and Https connection methods that uses the /login endpoint to store a Cookie for all future connections. This is useful when using centralized AAA to reduce the load of repeated command calls. Note that the cookie does have a lifetime, and that the connection will stop working once the cookie lifetime expires, unless the authentication method is manually called again * provisioning for providing CLI variants to handle deprecated cli transitions * moved cli varinats functionality to clint.py module so that users could use it too * fixed issue raising the exception properly for the last failed command * fixed again raising the exception properly for the last failed command * updated documentation * space adjustment in the docstring * provided comments on enforce_verification param deprecation * removed python 3.5 and 3.6 from the workflows - unsupported * fix issue #161 - add parsing of secondary ip from interfaces * making flake8 happy * Include error message in CommandError for commands that cause internal exceptions * _parse_bgp_as regex to match as and asdot * Checking if asn is asdot or not, and return string if asdot and int if not * typo * testing dumb shit * dumby dumb dumb * dafuq am i doing * maybe just maybe * i hate this shit i just wanna do neworking again :( * lemme be a network engineer again please i wanna die * play stupid games win stupid prices * var typo * testing * testing suggested changes * intendation error * Update ipinterfaces.py Replace IP MTU magic numbers with constants and provide references to RFCs * Update ipinterfaces.py * fixing doc build issues, errors, and providing docstring interpolation decorator * fixing indents * moved decorator's descriptin into the doc string * removed deprecated spanningtree module * removed all outdated python version checks * minor improvement for docstr decorator * Removed reference to python2 (#262) * Removed reference to python2 * Removed support for lower versions of python * Updated EOS version to the minimum supported * Addressed formatting and error handling comments * Refactored extra python2 check * removed couple deprecations * removed one more deprecation * Created release-notes-1.0.0.rst * Updated release-notes.rst * Update VERSION * Update __init__.py * Update release-notes-1.0.0.rst fixed a capital letter. --------- Signed-off-by: DavidVentura Co-authored-by: Xavier Hardy Co-authored-by: Shintaro Kojima Co-authored-by: andriirr <58480764+andriirr@users.noreply.github.com> Co-authored-by: Andrii Rykhlivskyi Co-authored-by: mharista Co-authored-by: Bharath Ravindranath Co-authored-by: Will Hargrave Co-authored-by: Ubuntu Co-authored-by: Petr KlĂ­ma Co-authored-by: Dmitry Lyssenko Co-authored-by: Dmitry Lyssenko Co-authored-by: Karthikeyan Singaravelan Co-authored-by: DavidVentura Co-authored-by: erikl Co-authored-by: Jeremiah Millay Co-authored-by: steve ulrich Co-authored-by: Alarig Le Lay Co-authored-by: Stefano Rivera Co-authored-by: Brandon Ewing Co-authored-by: Dylan Kaplan Co-authored-by: bh-rr <131744852+bh-rr@users.noreply.github.com> Co-authored-by: Greg Skinner Co-authored-by: chieto-arista <133874211+chieto-arista@users.noreply.github.com> --- .github/workflows/ci.yml | 33 +++ .travis.yml | 21 -- Makefile | 6 +- VERSION | 2 +- dev-requirements.txt | 6 +- docs/Makefile | 2 +- docs/conf.py | 4 +- docs/configfile.rst | 15 + docs/configsessions.rst | 52 ++++ docs/generate_modules.py | 4 +- docs/release-notes-0.8.4.rst | 23 ++ docs/release-notes-1.0.0.rst | 64 +++++ docs/release-notes.rst | 3 + docs/requirements.rst | 4 +- pyeapi/__init__.py | 2 +- pyeapi/api/abstract.py | 6 +- pyeapi/api/bgp.py | 11 +- pyeapi/api/interfaces.py | 12 +- pyeapi/api/ipinterfaces.py | 50 ++-- pyeapi/api/ospf.py | 24 +- pyeapi/api/spanningtree.py | 37 --- pyeapi/api/switchports.py | 4 +- pyeapi/api/users.py | 18 +- pyeapi/api/vlans.py | 22 +- pyeapi/client.py | 246 ++++++++++++++--- pyeapi/eapilib.py | 226 +++++++++++---- pyeapi/utils.py | 67 ++++- setup.py | 5 +- test/fixtures/running_config.text | 424 +++++++++++++++++++++++++++++ test/system/test_api_interfaces.py | 4 +- test/system/test_client.py | 5 +- test/unit/test_api_interfaces.py | 4 +- test/unit/test_api_ipinterfaces.py | 11 +- test/unit/test_api_switchports.py | 6 + test/unit/test_api_users.py | 5 + test/unit/test_api_vlans.py | 8 +- test/unit/test_api_vrfs.py | 2 +- test/unit/test_client.py | 26 +- test/unit/test_utils.py | 14 +- 39 files changed, 1220 insertions(+), 258 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml create mode 100644 docs/configsessions.rst create mode 100644 docs/release-notes-0.8.4.rst create mode 100644 docs/release-notes-1.0.0.rst delete mode 100644 pyeapi/api/spanningtree.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e5c1d17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +--- +name: CI +on: [ pull_request, workflow_dispatch ] +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10'] + fail-fast: false + name: Test on Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install wheel + run: pip install wheel + - name: Install dev requirements + run: pip install -r dev-requirements.txt + - name: Upgrade flake8 + run: pip install --upgrade flake8 + - name: Clean + run: make clean + - name: pep8 + run: make pep8 + - name: flake8 + run: make flake8 + - name: check + run: make check + - name: unittest + run: make unittest diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a253f08..0000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -language: python - -python: - - '2.7' - - '3.8' - -install: pip install -r dev-requirements.txt - -script: - - make clean - - make pep8 - - make flake8 - - make check - - make unittest - -notifications: - email: - - eosplus-dev@arista.com - -after_success: - coveralls diff --git a/Makefile b/Makefile index 76150fa..4b445c0 100644 --- a/Makefile +++ b/Makefile @@ -31,14 +31,14 @@ VERSION := $(shell cat VERSION) all: clean check pep8 flake8 tests pep8: - pycodestyle -r --ignore=E402,E731,E501,E221,W291,W391,E302,E251,E203,W293,E231,E303,E201,E225,E261,E241 pyeapi/ test/ + pycodestyle -r --ignore=E402,E731,E501,E221,W291,W391,E302,E251,E203,W293,E231,E303,E201,E202,E225,E261,E241,E128 pyeapi/ test/ pyflakes: pyflakes pyeapi/ test/ flake8: - flake8 --ignore=E302,E303,E402,E731,W391 --exit-zero pyeapi/ - flake8 --ignore=E302,E303,E402,E731,W391,N802 --max-line-length=100 test/ + flake8 --ignore=E128,E201,E202,E302,E303,E402,E731,W391 --exit-zero pyeapi/ + flake8 --ignore=E128,E201,E202,E302,E303,E402,E731,W391,N802 --max-line-length=100 test/ check: check-manifest diff --git a/VERSION b/VERSION index ee94dd8..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.3 +1.0.0 diff --git a/dev-requirements.txt b/dev-requirements.txt index 903885c..55ef3ec 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -9,5 +9,7 @@ coverage sphinx sphinxcontrib-napoleon flake8 -flake8-print -flake8-debugger +#commenting below 2 lines until switchover to python3 is complete +#flake8-print +#flake8-debugger +sphinx_rtd_theme diff --git a/docs/Makefile b/docs/Makefile index da55edf..e7c16e6 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -8,7 +8,7 @@ PAPER = BUILDDIR = _build APIDIR = api_modules CLIENTDIR = client_modules -CWD := $(shell pwd) +CWD := '$(shell pwd)' # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) diff --git a/docs/conf.py b/docs/conf.py index 39f076b..d83565c 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, '../pyeapi') +sys.path.insert(0, os.path.abspath('..') ) # -- General configuration ------------------------------------------------ @@ -35,7 +35,7 @@ 'sphinx.ext.coverage', 'sphinx.ext.viewcode', 'sphinx.ext.doctest', - 'sphinxcontrib.napoleon' + 'sphinx.ext.napoleon' ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/configfile.rst b/docs/configfile.rst index b34a304..3d44c29 100644 --- a/docs/configfile.rst +++ b/docs/configfile.rst @@ -119,6 +119,16 @@ As the table above indicates, a pyeapi configuration file is required in [connection:localhost] transport: http_local +Pay attention: once ``eapi.conf`` exists, the respective protocol method must be +configured on the box. E.g., for the above ``eapi.conf`` sample, the following +configuration must also exist: + +.. code-block:: console + + switch(config)#management http-server + switch(config-mgmt-http-server)#protocol http localhost + + Using HTTP or HTTPS =================== @@ -172,6 +182,11 @@ As the table above indicates, a pyeapi configuration file is required in username: admin password: admin +.. Note:: avoid using ``localhost`` name in the connection description (i.e.: ``[connection:localhost]``). + The name ``localhost`` is reserved solely for ``on-box`` connection method and it won't work when + connecting ``off-box`` + + Using HTTPS with Certificates ============================= diff --git a/docs/configsessions.rst b/docs/configsessions.rst new file mode 100644 index 0000000..0cad212 --- /dev/null +++ b/docs/configsessions.rst @@ -0,0 +1,52 @@ +Using Config Sessions via Python Client for eAPI +======================================================= + +Config Sessions can be used via Pyeapi. Config sessions allow a block of config +to be added as one operation instead of as individual lines. Configurations applied +in this manner allow the user to abort all the config being applied if an error occurs. + +Using Config Sessions: + +.. code-block:: python + + import pyeapi + node = pyeapi.connect_to('veos01') + vlans = node.api('vlans') + + node.configure_session() + + node.diff() # Sends "configure session 9c27d0e8-afef-4afd-95ae-3e3200bb7a3e" and "show session-config diff" + + """ + => + --- system:/running-config + +++ session:/9c27d0e8-afef-4afd-95ae-3e3200bb7a3e-session-config + @@ -32,7 +32,7 @@ + ! + clock timezone Asia/Tokyo + ! + -vlan 1000,3001-3006 + +vlan 100,1000,3001-3006 + ! + interface Port-Channel1 + switchport trunk allowed vlan 3001-3003 + """ + + node.abort() # Sends "configure session 9c27d0e8-afef-4afd-95ae-3e3200bb7a3e" and "abort" + # or + node.commit() # Sends "configure session 9c27d0e8-afef-4afd-95ae-3e3200bb7a3e" and "commit" + +Config Session with invalid config line: + +.. code-block:: python + + node = pyeapi.connect_to('veos01') + interfaces = node.api('interfaces') + node.configure_session() + + if not (interfaces.configure(['interface Eth6', 'no switchport', 'ip address 172.16.0.1/30']) and \ + interfaces.configure(['interface Eth7', 'no switchport', 'ip address 172.16.0.1/30'])): + node.abort() # This aborts everything!! + +For more detailed information about using Configure Sessions in EOS, reference the user +manual for the version of EOS running on your switch. diff --git a/docs/generate_modules.py b/docs/generate_modules.py index b4aa0a3..3032fde 100755 --- a/docs/generate_modules.py +++ b/docs/generate_modules.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/python3 from os import listdir, path, makedirs from os.path import isfile, join, exists @@ -22,7 +22,7 @@ def get_module_names(p): mods = list() mods = [f.split('.')[0] for f in listdir(p) if isfile(join(p, f)) and not f.endswith('.pyc') and not f.startswith('__')] - print len(mods) + print( len(mods) ) return mods def process_modules(modules): diff --git a/docs/release-notes-0.8.4.rst b/docs/release-notes-0.8.4.rst new file mode 100644 index 0000000..6d49749 --- /dev/null +++ b/docs/release-notes-0.8.4.rst @@ -0,0 +1,23 @@ +Release 0.8.4 +------------- + +2020-11-13 + +New Modules +^^^^^^^^^^^ + + +Enhancements +^^^^^^^^^^^^ + +* Add streaming capability for eapi interface + +* Add Power ppc64le support with CI + +Fixed +^^^^^ + +Known Caveats +^^^^^^^^^^^^^ + + diff --git a/docs/release-notes-1.0.0.rst b/docs/release-notes-1.0.0.rst new file mode 100644 index 0000000..136222d --- /dev/null +++ b/docs/release-notes-1.0.0.rst @@ -0,0 +1,64 @@ +Release 1.0.0 +------------- + +2023-06-06 + +- This is a Python3 (3.7 onwards) release only (Python2 is no longer supported) +- Arista EOS 4.22 or later required + +New Modules +^^^^^^^^^^^ + +Enhancements +^^^^^^^^^^^^ + +Fixed +^^^^^ + +* Let ``config`` method to raise ``ConnectionError`` exception (`198 `_) + The fix ensures that the behavior is consistent with other methods. +* Fixed parsing of VLAN groupings by ``vlans.getall()`` (`200 `_) + The fix allows handling a case when multiple VLANs in the configs may be grouped under a common (group) name. +* Enhanced ``vlans.get()`` to return an actual list of VLANs (`202 `_) + The method used to return the argument itself (e.g., an RE: ``200-.*``), now the actual list of matched VLANs is returned +* Fixed a corner crash in ``portsVlans.getall()`` (`213 `_) + The crash may occur when the switchport is configured with a VLAN profile +* Improved ``switchports.getall()`` behavior (`216 `_) + The method will not consider subinterfaces anymore +* Improved JSON parsing speed (`166 `_) + User may improve the speed by using ``ujson`` or ``rapidjson`` modules. The standard ``json`` is used as a fallback. +* Allow user to specify an SSL context (`234 `_) + Provided the argument option ``context`` to specify an SSL context in ``connection()`` method. +* Fixed user password vulnerability in tracebacks (`238 `_) + A user password is exposed in a traceback, which could occur due to invalid credentials. +* Added support for login password exclusively for ssh-key authentication (`227 `_) + Catching up with EOS CLI which already supports such login password. +* Fixed user password vulnerability in debugs (`242 `_) + A user password was exposed in user enabled debugs. With this commit the user password is masked out now. +* Added option not to include config defaults (`221 `_) + Reading running-config or startup-config with default values is not always a desirable behavior. This commit adds an option to control such behavior. +* Fixed a corner crash in ``ipinterfaces`` module (`210 `_) + Fixed a crash when MTU value is not present in the interface configuration (this is rather a corner case condition). +* Fixed a bug where vxlan floodlist might return a bogus data (`193 `_) + Fixed the issue with ``interfaces`` module, where ``get()`` method returned vxlan data structure with ``flood_list`` parsed incorrectly. +* Improved performance of config parsing (`220 `_) + Drastically improved perfromance of config parsing by cacheing section splitting results. This fix also tightens the prior relaxed section match behavior by allowing matching only section line (as vs entire section's content) +* Enhanced PR (`220 `_) allowing to match sub-sections (`245 `_) + After PR #220, matching subsections was impossible (only entire section could have been matched). This enhancement brings back such functionality. +* Added support for a session based authentication (`203 `_) + Added a couple new transport options (``http_session`` and ``https_session``) to ``connect()`` method to support a session based authentication. +* Added support infrastructure to handle deprecated EOS CLI (`247 `_) + The added ``CliVariants`` class helps managing transitions from deprecated EOS CLI (by allowing specifying both, new and deprecated CLI variants). +* Added support for parsing secondary ip addresses (`251 `_) + The added fix extends the return result for ``get()`` method of ``ipinterfaces`` module with the parsed secondary ip addresses (if present). +* A minor fix to include internal exception handling (`252 `_) + With this fix the ``pyeapi.eapilib.CommandError`` exception will elaborate on the causes of internal errors. +* Enhance parsing of BGP router section with ``asdot`` notation (`256 `_) + Allow matching and parsing ``router bgp`` sections if spelled with ``asdot`` notation. +* removed and updated deprecations (`204 `_, `212 `_, `235 `_, `260 `_, `262 `_, `263 `_) +* documentation fixes and updates (`209 `_, `225 `_, `239 `_, `257 `_, `259 `_) + +Known Caveats +^^^^^^^^^^^^^ + + diff --git a/docs/release-notes.rst b/docs/release-notes.rst index 84d6746..f70f0aa 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -6,6 +6,9 @@ Release Notes :maxdepth: 2 :titlesonly: + release-notes-1.0.0.rst + release-notes-0.8.4.rst + release-notes-0.8.3.rst release-notes-0.8.2.rst release-notes-0.8.1.rst release-notes-0.8.0.rst diff --git a/docs/requirements.rst b/docs/requirements.rst index fe7cb19..fbb30ae 100644 --- a/docs/requirements.rst +++ b/docs/requirements.rst @@ -2,10 +2,10 @@ Requirements ############ -* Arista EOS 4.12 or later +* Arista EOS 4.22 or later * Arista eAPI enabled for at least one transport (see Official EOS Config Guide at arista.com for details) -* Python 2.7 or 3.4+ (Python 3 support is work in progress) +* Python 3.7+ * Pyeapi requires the netaddr Python module .. Note:: netaddr gets installed automatically if you use pip to install pyeapi diff --git a/pyeapi/__init__.py b/pyeapi/__init__.py index 8708596..0f86b58 100644 --- a/pyeapi/__init__.py +++ b/pyeapi/__init__.py @@ -29,7 +29,7 @@ # OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN # IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # -__version__ = '0.8.3' +__version__ = '1.0.0' __author__ = 'Arista EOS+' diff --git a/pyeapi/api/abstract.py b/pyeapi/api/abstract.py index f0a4eec..35dd6a1 100644 --- a/pyeapi/api/abstract.py +++ b/pyeapi/api/abstract.py @@ -40,9 +40,9 @@ ultimately derive from BaseEntity which provides some common functions to make building API modules easier. """ -from collections import Callable, Mapping -from pyeapi.eapilib import CommandError, ConnectionError +from collections.abc import Callable, Mapping +from pyeapi.eapilib import CommandError from pyeapi.utils import make_iterable @@ -123,7 +123,7 @@ def configure(self, commands): try: self.node.config(commands) return True - except (CommandError, ConnectionError): + except (CommandError): return False def command_builder(self, string, value=None, default=None, disable=None): diff --git a/pyeapi/api/bgp.py b/pyeapi/api/bgp.py index 8322212..31b2582 100644 --- a/pyeapi/api/bgp.py +++ b/pyeapi/api/bgp.py @@ -78,8 +78,8 @@ def get(self): return response def _parse_bgp_as(self, config): - match = re.search(r'^router bgp (\d+)', config) - return dict(bgp_as=int(match.group(1))) + as_num = re.search(r'(?<=^router bgp ).*', config).group(0) + return { 'bgp_as': int(as_num) if as_num.isnumeric() else as_num } def _parse_router_id(self, config): match = re.search(r'router-id ([^\s]+)', config) @@ -214,10 +214,9 @@ def _parse_peer_group(self, config, name): return dict(peer_group=value) def _parse_remote_as(self, config, name): - regexp = r'neighbor {} remote-as (\d+)'.format(name) - match = re.search(regexp, config) - value = match.group(1) if match else None - return dict(remote_as=value) + remote_as_re = rf'(?<=neighbor {name} remote-as ).*' + match = re.search(remote_as_re, config) + return { 'remote_as': match.group(0) if match else None} def _parse_send_community(self, config, name): exp = 'no neighbor {} send-community'.format(name) diff --git a/pyeapi/api/interfaces.py b/pyeapi/api/interfaces.py index c6f88c2..467d752 100644 --- a/pyeapi/api/interfaces.py +++ b/pyeapi/api/interfaces.py @@ -831,7 +831,7 @@ def get(self, name): * udp_port (int): The vxlan udp-port value * vlans (dict): The vlan to vni mappings * flood_list (list): The list of global VTEP flood list - * multicast_decap (bool): If the mutlicast decap + * multicast_decap (bool): If the multicast decap feature is configured Args: @@ -884,7 +884,7 @@ def _parse_multicast_group(self, config): return dict(multicast_group=value) def _parse_multicast_decap(self, config): - value = 'vxlan mutlicast-group decap' in config + value = 'vxlan multicast-group decap' in config return dict(multicast_decap=bool(value)) def _parse_udp_port(self, config): @@ -911,7 +911,7 @@ def _parse_vlans(self, config): return dict(vlans=values) def _parse_flood_list(self, config): - match = re.search(r'vxlan flood vtep (.+)$', config, re.M) + match = re.search(r'^ *vxlan flood vtep +([\d. ]+)$', config, re.M) values = list() if match: values = match.group(1).split(' ') @@ -978,7 +978,7 @@ def set_multicast_decap(self, name, default=False, True if the operation succeeds otherwise False """ string = 'vxlan multicast-group decap' - if(default or disable): + if default or disable: cmds = self.command_builder(string, value=None, default=default, disable=disable) else: @@ -1064,7 +1064,7 @@ def update_vlan(self, name, vid, vni): True if the command completes successfully """ - cmd = 'vxlan vlan %s vni %s' % (vid, vni) + cmd = 'vxlan vlan add %s vni %s' % (vid, vni) return self.configure_interface(name, cmd) def remove_vlan(self, name, vid): @@ -1080,7 +1080,7 @@ def remove_vlan(self, name, vid): True if the command completes successfully """ - return self.configure_interface(name, 'no vxlan vlan %s vni' % vid) + return self.configure_interface(name, 'vxlan vlan remove %s vni' % vid) INTERFACE_CLASS_MAP = { diff --git a/pyeapi/api/ipinterfaces.py b/pyeapi/api/ipinterfaces.py index 958a387..d71f7d9 100644 --- a/pyeapi/api/ipinterfaces.py +++ b/pyeapi/api/ipinterfaces.py @@ -36,33 +36,39 @@ Parameters: name (string): The interface name the configuration is in reference - to. The interface name is the full interface identifier + to. The interface name is the full interface identifier. address (string): The interface IP address in the form of address/len. mtu (integer): The interface MTU value. The MTU value accepts - integers in the range of 68 to 65535 bytes + integers in the range of 68 to 65535 bytes. See RFC 791 and + RFC 8200 for more information. """ import re from pyeapi.api import EntityCollection +from pyeapi.utils import _interpolate_docstr +IP_MTU_MIN = 68 +IP_MTU_MAX = 65535 SWITCHPORT_RE = re.compile(r'no switchport$', re.M) -class Ipinterfaces(EntityCollection): +class Ipinterfaces( EntityCollection ): - def get(self, name): + def get( self, name ): """Returns the specific IP interface properties The Ipinterface resource returns the following: * name (str): The name of the interface * address (str): The IP address of the interface in the form - of A.B.C.D/E + of A.B.C.D/E (None if no ip configured) + * secondary (list): The list of secondary IP addresses of the + interface (if any configured) * mtu (int): The configured value for IP MTU. @@ -75,22 +81,21 @@ def get(self, name): the current configuration of the node. If the specified interface does not exist then None is returned. """ - config = self.get_block('interface %s' % name) - - if name[0:2] in ['Et', 'Po'] and not SWITCHPORT_RE.search(config, - re.M): + config = self.get_block( 'interface %s' % name ) + if name[ 0:2 ] in [ + 'Et', 'Po' ] and not SWITCHPORT_RE.search( config, re.M ): return None - resource = dict(name=name) - resource.update(self._parse_address(config)) - resource.update(self._parse_mtu(config)) + resource = dict( name=name ) + resource.update( self._parse_address(config) ) + resource.update( self._parse_mtu(config) ) return resource - def _parse_address(self, config): + def _parse_address( self, config ): """Parses the config block and returns the ip address value - The provided configuration block is scaned and the configured value - for the IP address is returned as a dict object. If the IP address + The provided configuration block is scanned and the configured value + for the IP address is returned as a dict object. If the IP address value is not configured, then None is returned for the value Args: @@ -99,9 +104,11 @@ def _parse_address(self, config): Return: dict: A dict object intended to be merged into the resource dict """ - match = re.search(r'ip address ([^\s]+)', config) - value = match.group(1) if match else None - return dict(address=value) + match = re.findall( r'ip address ([^\s]+)', config, re.M ) + primary, secondary = ( match[0], + match[1:] ) if match else ( None, None ) + return dict( address=primary, + secondary=secondary ) if secondary else dict( address=primary ) def _parse_mtu(self, config): """Parses the config block and returns the configured IP MTU value @@ -117,7 +124,7 @@ def _parse_mtu(self, config): dict: A dict object intended to be merged into the resource dict """ match = re.search(r'mtu (\d+)', config) - return dict(mtu=int(match.group(1))) + return dict( mtu=int(match.group( 1 )) if match else None ) def getall(self): """ Returns all of the IP interfaces found in the running-config @@ -211,6 +218,7 @@ def set_address(self, name, value=None, default=False, disable=False): default=default, disable=disable)) return self.configure(commands) + @_interpolate_docstr( 'IP_MTU_MIN', 'IP_MTU_MAX' ) def set_mtu(self, name, value=None, default=False, disable=False): """ Configures the interface IP MTU @@ -219,7 +227,7 @@ def set_mtu(self, name, value=None, default=False, disable=False): config to value (integer): The MTU value to set the interface to. Accepted - values include 68 to 65535 + values include IP_MTU_MIN to IP_MTU_MAX default (bool): Configures the mtu parameter to its default value using the EOS CLI default command @@ -237,7 +245,7 @@ def set_mtu(self, name, value=None, default=False, disable=False): """ if value is not None: value = int(value) - if not 68 <= value <= 65535: + if not IP_MTU_MIN <= value <= IP_MTU_MAX: raise ValueError('invalid mtu value') commands = ['interface %s' % name] diff --git a/pyeapi/api/ospf.py b/pyeapi/api/ospf.py index a1c50ef..4dd013d 100644 --- a/pyeapi/api/ospf.py +++ b/pyeapi/api/ospf.py @@ -55,16 +55,20 @@ def get(self, vrf=None): vrf (str): VRF name to return OSPF routing config for Returns: dict: - keys: router_id (int): OSPF router-id - vrf (str): VRF of the OSPF process - networks (dict): All networks that - are advertised in OSPF - ospf_process_id (int): OSPF proc id - redistribution (dict): All protocols that - are configured to be - redistributed in OSPF - shutdown (bool): Gives the current shutdown - off the process + keys: + router_id (int): OSPF router-id + + vrf (str): VRF of the OSPF process + networks (dict): All networks that + are advertised in OSPF + + ospf_process_id (int): OSPF proc id + + redistribution (dict): All protocols that + are configured to be redistributed in OSPF + + shutdown (bool): Gives the current shutdown + off the process """ match = '^router ospf .*' if vrf: diff --git a/pyeapi/api/spanningtree.py b/pyeapi/api/spanningtree.py deleted file mode 100644 index 871bf34..0000000 --- a/pyeapi/api/spanningtree.py +++ /dev/null @@ -1,37 +0,0 @@ -# -# Copyright (c) 2014, Arista Networks, Inc. -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are -# met: -# -# Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# -# Neither the name of Arista Networks nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL ARISTA NETWORKS -# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR -# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, -# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE -# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -# IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -import warnings - -warnings.warn("Api module spanningtree is depricated. Please update api " - "calls to use stp instead") - -from pyeapi.api.stp import instance # NOQA diff --git a/pyeapi/api/switchports.py b/pyeapi/api/switchports.py index caaed6f..12be13f 100644 --- a/pyeapi/api/switchports.py +++ b/pyeapi/api/switchports.py @@ -126,7 +126,7 @@ def _parse_access_vlan(self, config): resource dict """ value = re.search(r'switchport access vlan (\d+)', config) - return dict(access_vlan=value.group(1)) + return dict(access_vlan=value.group(1) if value else None) def _parse_trunk_native_vlan(self, config): """Scans the specified config and parse the trunk native vlan value @@ -166,7 +166,7 @@ def getall(self): A Python dictionary object that represents all configured switchports in the current running configuration """ - interfaces_re = re.compile(r'(?<=^interface\s)([Et|Po].+)$', re.M) + interfaces_re = re.compile(r'(?<=^interface\s)([Et|Po][^.\s]+)$', re.M) response = dict() for name in interfaces_re.findall(self.config): diff --git a/pyeapi/api/users.py b/pyeapi/api/users.py index 5bc1411..db2fd56 100644 --- a/pyeapi/api/users.py +++ b/pyeapi/api/users.py @@ -46,7 +46,7 @@ This parameter is mutually exclusive with secret and is used in conjunction with format. format (string): Configures the format of the secret value. Accepted - values for format are "cleartext", "md5" and "sha512" + values for format are "cleartext", "md5", "nologin" and "sha512" """ @@ -55,7 +55,7 @@ from pyeapi.api import EntityCollection DEFAULT_ENCRYPTION = 'cleartext' -ENCRYPTION_MAP = {'cleartext': 0, 'md5': 5, 'sha512': 'sha512'} +ENCRYPTION_MAP = {'cleartext': 0, 'md5': 5, 'sha512': 'sha512', 'nologin': '*'} def isprivilege(value): @@ -163,8 +163,8 @@ def create(self, name, nopassword=None, secret=None, encryption=None): secret (str): The secret (password) to assign to this user encryption (str): Specifies how the secret is encoded. Valid - values are "cleartext", "md5", "sha512". The default is - "cleartext" + values are "cleartext", "md5", "nologin", "sha512". + The default is "cleartext" Returns: True if the operation was successful otherwise False @@ -172,7 +172,7 @@ def create(self, name, nopassword=None, secret=None, encryption=None): Raises: TypeError: if the required arguments are not satisfied """ - if secret is not None: + if secret is not None or encryption == 'nologin': return self.create_with_secret(name, secret, encryption) elif nopassword is True: return self.create_with_nopassword(name) @@ -189,8 +189,8 @@ def create_with_secret(self, name, secret, encryption): secret (str): The secret (password) to assign to this user encryption (str): Specifies how the secret is encoded. Valid - values are "cleartext", "md5", "sha512". The default is - "cleartext" + values are "cleartext", "md5", "nologin" and "sha512". + The default is "cleartext" Returns: True if the operation was successful otherwise False @@ -200,9 +200,11 @@ def create_with_secret(self, name, secret, encryption): enc = ENCRYPTION_MAP[encryption] except KeyError: raise TypeError('encryption must be one of "cleartext", "md5"' - ' or "sha512"') + ' "nologin" or "sha512"') cmd = 'username %s secret %s %s' % (name, enc, secret) + if encryption == 'nologin': + cmd = 'username %s secret %s' % (name, enc) return self.configure(cmd) def create_with_nopassword(self, name): diff --git a/pyeapi/api/vlans.py b/pyeapi/api/vlans.py index 237c991..610d41d 100644 --- a/pyeapi/api/vlans.py +++ b/pyeapi/api/vlans.py @@ -54,6 +54,7 @@ from pyeapi.api import EntityCollection from pyeapi.utils import make_iterable +VLAN_ID_RE = re.compile(r'(?:vlan\s)(?P.*)$', re.M) NAME_RE = re.compile(r'(?:name\s)(?P.*)$', re.M) STATE_RE = re.compile(r'(?:state\s)(?P.*)$', re.M) TRUNK_GROUP_RE = re.compile(r'(?:trunk\sgroup\s)(?P.*)$', re.M) @@ -104,13 +105,29 @@ def get(self, value): if not config: return None - response = dict(vlan_id=value) + response = dict(vlan_id=self._parse_vlan_id(config)) response.update(self._parse_name(config)) response.update(self._parse_state(config)) response.update(self._parse_trunk_groups(config)) return response + def _parse_vlan_id(self, config): + """ _parse_vlan_id scans the provided configuration block and extracts + the vlan id. The config block is expected to always return the + vlan id. The return dict is intended to be merged into the response + dict. + + Args: + config (str): The vlan configuration block from the nodes running + configuration + + Returns: + Str: vlan id (or range/list of vlan ids) + """ + value = VLAN_ID_RE.search(config).group('value') + return value + def _parse_name(self, config): """ _parse_name scans the provided configuration block and extracts the vlan name. The config block is expected to always return the @@ -166,7 +183,8 @@ def getall(self): A dict object of Vlan attributes """ - vlans_re = re.compile(r'(?<=^vlan\s)(\d+)', re.M) + # RE to find standalone and grouped (ranged, enumerated) vlans (#197) + vlans_re = re.compile(r'(?<=^vlan\s)[\d,\-]+', re.M) response = dict() for vid in vlans_re.findall(self.config): diff --git a/pyeapi/client.py b/pyeapi/client.py index ff17b6b..2de2fce 100644 --- a/pyeapi/client.py +++ b/pyeapi/client.py @@ -48,7 +48,7 @@ >>> import pyeapi >>> conn = pyeapi.connect(host='10.1.1.1', transport='http') - >>> conn.execute(['show verson']) + >>> conn.execute(['show version']) {u'jsonrpc': u'2.0', u'result': [{u'memTotal': 2028008, u'version': u'4.14.5F', u'internalVersion': u'4.14.5F-2209869.4145F', u'serialNumber': u'', u'systemMacAddress': u'00:0c:29:f5:d2:7d', u'bootupTimestamp': @@ -90,23 +90,22 @@ contains the settings for nodes used by the connect_to function. """ +from uuid import uuid4 import os import re -try: - # Try Python 3.x import first - # Note: SafeConfigParser is deprecated and replaced by ConfigParser - from configparser import ConfigParser as SafeConfigParser - from configparser import Error as SafeConfigParserError -except ImportError: - # Use Python 2.7 import as a fallback - from ConfigParser import SafeConfigParser - from ConfigParser import Error as SafeConfigParserError +from functools import lru_cache -from pyeapi.utils import load_module, make_iterable, debug +# Note: SafeConfigParser is deprecated and replaced by ConfigParser +from configparser import ConfigParser as SafeConfigParser +from configparser import Error as SafeConfigParserError + +from pyeapi.utils import load_module, make_iterable, debug, CliVariants from pyeapi.eapilib import HttpEapiConnection, HttpsEapiConnection from pyeapi.eapilib import HttpsEapiCertConnection +from pyeapi.eapilib import HttpEapiSessionConnection +from pyeapi.eapilib import HttpsEapiSessionConnection from pyeapi.eapilib import SocketEapiConnection, HttpLocalEapiConnection from pyeapi.eapilib import CommandError @@ -116,8 +115,10 @@ 'socket': SocketEapiConnection, 'http_local': HttpLocalEapiConnection, 'http': HttpEapiConnection, + 'http_session': HttpEapiSessionConnection, 'https': HttpsEapiConnection, - 'https_certs': HttpsEapiCertConnection + 'https_certs': HttpsEapiCertConnection, + 'https_session': HttpsEapiSessionConnection, } DEFAULT_TRANSPORT = 'https' @@ -392,7 +393,8 @@ def make_connection(transport, **kwargs): def connect(transport=None, host='localhost', username='admin', password='', port=None, key_file=None, cert_file=None, - ca_file=None, timeout=60, return_node=False, **kwargs): + ca_file=None, timeout=60, return_node=False, context=None, + **kwargs): """ Creates a connection using the supplied settings This function will create a connection to an Arista EOS node using @@ -400,8 +402,9 @@ def connect(transport=None, host='localhost', username='admin', Args: transport (str): Specifies the type of connection transport to use. - Valid values for the connection are socket, http_local, http, and - https. The default value is specified in DEFAULT_TRANSPORT + Valid values for the connection are socket, http_local, http, + https, https_certs, http_session, and https_session. The default + value is specified in DEFAULT_TRANSPORT host (str): The IP addres or DNS host name of the connection device. The default value is 'localhost' username (str): The username to pass to the device to authenticate @@ -415,6 +418,7 @@ def connect(transport=None, host='localhost', username='admin', cert_file (str): Path to PEM formatted cert file for ssl validation ca_file (str): Path to CA PEM formatted cert file for ssl validation timeout (int): timeout + context (ssl.SSLContext): ssl object's context. The default is None return_node (bool): Returns a Node object if True, otherwise returns an EapiConnection object. @@ -422,12 +426,34 @@ def connect(transport=None, host='localhost', username='admin', Returns: An instance of an EapiConnection object for the specified transport. + Note: + Python 3.10 increases security strength of the TLS stack by among other + things using a stronger (than 3.9) default cipher suite. Thus programs + relying on the https transport and using the default cypher suite that + used to work in prior python versions may fail with the error: + ``[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure``. + The solution to that issue is to configure the https web server to use + a stronger cipher suite. + + If the solution is not attainable, then a work-around might be + considered (weighing all due implications) - one could pass an ssl + context where cipher can be specified:: + + import pyeapi + import ssl + ... + ctx = ssl.create_default_context() + ctx.set_ciphers('DEFAULT') # set a preferred cipher + ctx.check_hostname = False # for the sake of example + ctx.verify_mode = ssl.CERT_NONE # do it w/o certificate + ... + cc = pyeapi.client.connect( host=host_name, context=ctx ) """ transport = transport or DEFAULT_TRANSPORT connection = make_connection(transport, host=host, username=username, password=password, key_file=key_file, cert_file=cert_file, ca_file=ca_file, - port=port, timeout=timeout) + port=port, timeout=timeout, context=context) if return_node: return Node(connection, transport=transport, host=host, username=username, password=password, key_file=key_file, @@ -456,6 +482,8 @@ class Node(object): autorefresh (bool): If True, the running-config and startup-config are refreshed on config events. If False, then the config properties must be manually refreshed. + config_defaults (bool): If True, the default config options will be + shown in the running-config output settings (dict): Provides access to the settings used to create the Node instance. @@ -471,9 +499,11 @@ def __init__(self, connection, **kwargs): self._version = None self._version_number = None self._model = None + self._session_name = None self._enablepwd = kwargs.get('enablepwd') self.autorefresh = kwargs.get('autorefresh', True) + self.config_defaults = kwargs.get('config_defaults', True) self.settings = kwargs def __str__(self): @@ -490,7 +520,8 @@ def connection(self): def running_config(self): if self._running_config is not None: return self._running_config - self._running_config = self.get_config(params='all', + params = 'all' if self.config_defaults else None + self._running_config = self.get_config(params=params, as_string=True) return self._running_config @@ -560,15 +591,31 @@ def config(self, commands, **kwargs): """Configures the node with the specified commands This method is used to send configuration commands to the node. It - will take either a string or a list and prepend the necessary commands - to put the session into config mode. + will take either a string, list or CliVariants type and prepend the + necessary commands to put the session into config mode. + pyeapi.utils.CliVariants facilitates alternative executions to commands + sequence until one variant succeeds or all fail Args: - commands (str, list): The commands to send to the node in config - mode. If the commands argument is a string it will be cast to - a list. - The list of commands will also be prepended with the - necessary commands to put the session in config mode. + commands (str, list, CliVariants): The commands to send to the node + in config mode. If the commands argument is an str or + CliVariants type, it will be cast to a list. + The list of commands will also be prepended with the necessary + commands to put the session in config mode. + CliVariants could be part of a list too, however only a single + occurrence of CliVariants type in commands is supported. + CliVariants type facilitates execution of alternative commands + sequences, e.g.: + ``config( [cli1, CliVariants( cli2, cli3 ), cli4] )`` + the example above can be translated into following sequence: + ``config( [cli1, cli2, cli4] )`` + ``config( [cli1, cli3, cli4] )`` + CliVariants accepts 2 or more arguments of str, list type, or + their mix. Each argument to CliVariants will be joined with the + rest of commands and all command sequences will be tried until + one variant succeeds. If all variants fail the last failure + exception will be re-raised. + **kwargs: Additional keyword arguments for expanded eAPI functionality. Only supported eAPI params are used in building the request @@ -578,6 +625,37 @@ def config(self, commands, **kwargs): output from each command. The function will strip the response from any commands it prepends. """ + def variant_cli_idx( cmds ): + # return index of first occurrence of CliVariants type in cmds + try: + return [ type(v) for v in cmds ].index( CliVariants ) + except (ValueError): + return -1 + + cfg_call = self._configure_session if self._session_name \ + else self._configure_terminal + + if isinstance( commands, CliVariants ): + commands = [ commands ] + idx = variant_cli_idx( commands ) + if idx == -1: + return cfg_call( commands, **kwargs ) + + # commands contain CliVariants obj, e.g.: [ '...', CliVariants, ... ] + err = None + for variant in commands[ idx ].variants: + cmd = commands[ :idx ] + variant + commands[ idx + 1: ] + try: + return cfg_call( cmd, **kwargs ) + except (CommandError) as exp: + err = exp + raise err # re-raising last occurred CommandError + + + def _configure_terminal(self, commands, **kwargs): + """Configures the node with the specified commands with leading + "configure terminal" + """ commands = make_iterable(commands) commands = list(commands) @@ -593,6 +671,72 @@ def config(self, commands, **kwargs): return response + def _configure_session(self, commands, **kwargs): + """Configures the node with the specified commands with leading + "configure session " + """ + if not self._session_name: + raise CommandError(-1, 'Not currently in a session') + + commands = make_iterable(commands) + commands = list(commands) + + # push the configure command onto the command stack + commands.insert(0, 'configure session %s' % self._session_name) + response = self.run_commands(commands, **kwargs) + + # pop the configure command output off the stack + response.pop(0) + + return response + + @lru_cache(maxsize=None) + def _chunkify( self, config, indent=0 ): + """parse device config and return a dict holding sections and + sub-sections: + - a section always begins with a line with zero indents, + - a sub-section always begins with an indented line + a (sub)section typically contains a begin line (with a lower indent) + and a body (with a higher indent). A section might be degenerative (no + body, just the section line itself), while sub-sections always contain + a sub-section line plus some body). E.g., here's a snippet of a section + dict: + { ... + 'spanning-tree mode none': 'spanning-tree mode none\n', + ... + 'mac security': 'mac security\n profile PR\n cipher aes256-gcm', + ' profile PR': ' profile PR\n cipher aes256-gcm' + ... } + + it's imperative that the most outer call is made with indent=0, as the + indent parameter defines processing of nested sub-sections, i.e., if + indent > 0, then it's a recursive call and `config` argument contains + last parsed (sub)section, which in turn may contain sub-sections + """ + def is_subsection_present( section, indent ): + return any( [line[ indent ] == ' ' for line in section] ) + sections = {} + key = None + for line in config.splitlines( keepends=True )[ indent > 0: ]: + # indent > 0: no need processing subsection line, which is 1st line + if line[ indent ] == ' ': # section continuation + sections[key] += line + continue + # new section is found (if key is not None) + if key: # process prior (last recorded) section + lines = sections[key].splitlines()[ 1: ] + if len( lines ): # section may contain sub-sections + ind = len( lines[0] ) - len( lines[0].lstrip() ) + if is_subsection_present( lines, ind ): + subs = self._chunkify( sections[key], indent=ind ) + subs.update( sections ) + sections = subs + elif indent > 0: # record only subsections + del sections[key] + key = line.rstrip() + sections[key] = line + return sections + def section(self, regex, config='running_config'): """Returns a section of the config @@ -608,18 +752,14 @@ def section(self, regex, config='running_config'): """ if config in ['running_config', 'startup_config']: config = getattr(self, config) - match = re.search(regex, config, re.M) - if not match: + chunked = self._chunkify(config) + r = re.compile(regex) + matching_keys = [k for k in chunked.keys() if r.search(k)] + if len(matching_keys) == 0: raise TypeError('config section not found') - block_start, line_end = match.regs[0] - - match = re.search(r'^[^\s]', config[line_end:], re.M) - if not match: - raise TypeError('could not find end block') - _, block_end = match.regs[0] - - block_end = line_end + block_end - return config[block_start:block_end] + matching_key = matching_keys[0] + match = chunked[matching_key] + return match def enable(self, commands, encoding='json', strict=False, send_enable=True, **kwargs): @@ -824,6 +964,42 @@ def refresh(self): self._running_config = None self._startup_config = None + def configure_session(self): + """Enter a config session + """ + self._session_name = self._session_name or uuid4() + + def diff(self): + """Returns session-config diffs in text encoding + + Note: "show session-config diffs" doesn't support json encoding + """ + response = self._configure_session( + ['show session-config diffs'], encoding='text' ) + + return response[0]['output'] + + def commit(self): + """Commits the current config session + """ + return self._configure_and_exit_session(['commit']) + + def abort(self): + """Aborts the current config session + """ + return self._configure_session(['abort']) + + def _configure_and_exit_session(self, commands, **kwargs): + response = self._configure_session(commands, **kwargs) + + if self.autorefresh: + self.refresh() + + # Exit the current config session + self._session_name = None + + return response + def connect_to(name): """Creates a node instance based on an entry from the config diff --git a/pyeapi/eapilib.py b/pyeapi/eapilib.py index 3e0f679..140d054 100644 --- a/pyeapi/eapilib.py +++ b/pyeapi/eapilib.py @@ -36,8 +36,6 @@ for sending and receiving calls over eAPI using a HTTP/S transport. """ -import sys -import json import socket import base64 import logging @@ -45,11 +43,15 @@ import re try: - # Try Python 3.x import first - from http.client import HTTPConnection, HTTPSConnection + import ujson as json except ImportError: - # Use Python 2.7 import as a fallback - from httplib import HTTPConnection, HTTPSConnection + try: + import rapidjson as json + except ImportError: + import json + +from http.client import HTTPConnection, HTTPSConnection +from http.cookies import SimpleCookie from pyeapi.utils import make_iterable @@ -64,9 +66,6 @@ def https_connection_factory(path, host, port, context=None, timeout=60): - # ignore ssl context for python versions before 2.7.9 - if sys.hexversion < 34015728: - return HttpsConnection(path, host, port, timeout=timeout) return HttpsConnection(path, host, port, context=context, timeout=timeout) @@ -106,9 +105,14 @@ class CommandError(EapiError): """ def __init__(self, code, message, **kwargs): cmd_err = kwargs.get('command_error') - if int(code) in [1000, 1002, 1004]: + if int(code) in [1000, 1001, 1002, 1004]: msg_fmt = 'Error [{}]: {} [{}]'.format(code, message, cmd_err) else: + # error code 1005: 'Command unauthorized: user has insufficient + # permissions to run the command'. The message contains a user + # sensitive input, which has to be removed + if int(code) == 1005: + message = re.sub(r"(?<=input=)[^ ]+", r')', message) msg_fmt = 'Error [{}]: {}'.format(code, message) super(CommandError, self).__init__(msg_fmt) @@ -239,8 +243,9 @@ def connect(self): Redefined/copied and extended from httplib.py:1105 (Python 2.6.x). This is needed to pass cert_reqs=ssl.CERT_REQUIRED as parameter - to ssl.wrap_socket(), which forces SSL to check server certificate - against our client certificate. + to ssl.wrap_socket() (Now changed to ssl.SSLContext.wrap_socket() + as the former has been deprecated from python 3.7), which forces + SSL to check server certificate against our client certificate. """ sock = socket.create_connection((self.host, self.port), self.timeout) if self._tunnel_host: @@ -248,13 +253,14 @@ def connect(self): self._tunnel() # If there's no CA File, don't force Server Certificate Check if self.ca_file: - self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, - ca_certs=self.ca_file, - cert_reqs=ssl.CERT_REQUIRED) + self.sock = ssl.SSLContext.wrap_socket(sock, self.key_file, + self.cert_file, + ca_certs=self.ca_file, + cert_reqs=ssl.CERT_REQUIRED) else: - self.sock = ssl.wrap_socket(sock, self.key_file, - self.cert_file, - cert_reqs=ssl.CERT_NONE) + self.sock = ssl.SSLContext.wrap_socket(sock, self.key_file, + self.cert_file, + cert_reqs=ssl.CERT_NONE) class EapiConnection(object): @@ -291,20 +297,11 @@ def authentication(self, username, password): """ _auth_text = '{}:{}'.format(username, password) + _auth_bin = base64.encodebytes(_auth_text.encode()) + _auth = _auth_bin.decode().replace('\n', '') + self._auth = ("Authorization", "Basic %s" % _auth) - # Work around for Python 2.7/3.x compatibility - if int(sys.version[0]) > 2: - # For Python 3.x - _auth_bin = base64.encodebytes(_auth_text.encode()) - _auth = _auth_bin.decode() - _auth = _auth.replace('\n', '') - self._auth = _auth - else: - # For Python 2.7 - _auth = base64.encodestring(_auth_text) - self._auth = str(_auth).replace('\n', '') - - _LOGGER.debug('Autentication string is: {}:***'.format(username)) + _LOGGER.debug('Authentication string is: {}:***'.format(username)) def request(self, commands, encoding=None, reqid=None, **kwargs): """Generates an eAPI request object @@ -348,12 +345,16 @@ def request(self, commands, encoding=None, reqid=None, **kwargs): commands = make_iterable(commands) reqid = id(self) if reqid is None else reqid params = {'version': 1, 'cmds': commands, 'format': encoding} + streaming = False if 'autoComplete' in kwargs: params['autoComplete'] = kwargs['autoComplete'] if 'expandAliases' in kwargs: params['expandAliases'] = kwargs['expandAliases'] + if 'streaming' in kwargs: + streaming = kwargs['streaming'] return json.dumps({'jsonrpc': '2.0', 'method': 'runCmds', - 'params': params, 'id': str(reqid)}) + 'params': params, 'id': str(reqid), + 'streaming': streaming}) def send(self, data): """Sends the eAPI request to the destination node @@ -417,29 +418,20 @@ def send(self, data): code and error message from the eAPI response. """ try: - _LOGGER.debug('Request content: {}'.format(data)) + _LOGGER.debug( + 'Request content: {}'.format(self._sanitize_request( data )) ) # debug('eapi_request: %s' % data) self.transport.putrequest('POST', '/command-api') - self.transport.putheader('Content-type', 'application/json-rpc') self.transport.putheader('Content-length', '%d' % len(data)) if self._auth: - self.transport.putheader('Authorization', - 'Basic %s' % self._auth) - - if int(sys.version[0]) > 2: - # For Python 3.x compatibility - data = data.encode() + self.transport.putheader(*self._auth) + data = data.encode() self.transport.endheaders(message_body=data) - - try: # Python 2.7: use buffering of HTTP responses - response = self.transport.getresponse(buffering=True) - except TypeError: # Python 2.6: older, and 3.x on - response = self.transport.getresponse() - + response = self.transport.getresponse() response_content = response.read() _LOGGER.debug('Response: status:{status}, reason:{reason}'.format( status=response.status, @@ -450,10 +442,8 @@ def send(self, data): raise ConnectionError(str(self), '%s. %s' % (response.reason, response_content)) - # Work around for Python 2.7/3.x compatibility - if not type(response_content) == str: - # For Python 3.x - decode bytes into string - response_content = response_content.decode() + # Python 3.7 json.loads() works with bytes or strings, + # thus no decoding is required decoded = json.loads(response_content) _LOGGER.debug('eapi_response: %s' % decoded) @@ -470,8 +460,8 @@ def send(self, data): return decoded - # socket.error is deprecated in python 3 and replaced with OSError. - except (socket.error, OSError) as exc: + # removed socket.error as it's deprecated in python 3 + except OSError as exc: _LOGGER.exception(exc) self.socket_error = exc self.error = exc @@ -485,6 +475,87 @@ def send(self, data): finally: self.transport.close() + + def _sanitize_request( self, data ): + """remove user-sensitive input from data response""" + try: + data_json = json.loads( data ) + match = self._find_sub_json( + data_json, {'cmd': 'enable', 'input': ()} ) + if match: + match.entry[ match.idx ][ 'input' ] = '' + return json.dumps( data_json ) + except ValueError: + pass + return data + + + def _find_sub_json( self, jsn, sbj, instance=0 ): + """finds a subset (sbj) in json. `sbj` must be a subset and json must + not be atomic. Wildcard(s) in `sbj` can be specified with tuple type. + A json label cannot be wildcarded. A single wildcard represent a single + json entry. E.g.: + + _find_sub_json( jsn, { 'foo': () } ) + + Returned value is a Match class with attributes: + - entry: an iterable containing a matching `sbj` + - idx: index or key pointing to the match in the iterable + If no match found None is returned - that way is possible to get a + reference to the sought json and modify it, e.g: + + match = _find_sub_json( jsn, { 'foo':(), 'bar': [123, (), ()] } ) + if match: + match.entry[ match.idx ][ 'foo' ] = 'bar' + + It's also possible to specify an occurrence of the match via `instance` + parameter - by default a first found match is returned""" + class Match(): + def __init__( self, entry, idx ): + self.entry = entry + self.idx = idx + + def is_iterable( val ): + return True if isinstance( val, (list, dict) ) else False + + def is_atomic( val ): + return not is_iterable( val ) + + def is_match( jsn, sbj ): + if isinstance( sbj, tuple ): # sbj is a wildcard + return True + if is_atomic( sbj ): + return False if is_iterable( jsn ) else sbj == jsn + if type( jsn ) is not type( sbj ) or len( jsn ) != len( sbj ): + return False + for left, right in zip( + sorted(jsn.items() if isinstance( jsn, dict ) + else enumerate( jsn )), + sorted(sbj.items() if isinstance( sbj, dict ) + else enumerate( sbj )) ): + if left[ 0 ] != right[ 0 ]: + return False + if not is_match( left[ 1 ], right[ 1 ]): + return False + return True + + if is_atomic( jsn ): + return None + instance = [ instance ] if isinstance( instance, int ) else instance + for key, val in jsn.items() if isinstance( jsn, + dict ) else enumerate( jsn ): + if is_match( val, sbj ): + if instance[ 0 ] > 0: + instance[ 0 ] -= 1 + else: + return Match( jsn, key ) + if is_iterable( val ): + match = self._find_sub_json( val, sbj, instance ) + if match: + return match + return None + + def _parse_error_message(self, message): """Parses the eAPI failure response message @@ -509,7 +580,11 @@ def _parse_error_message(self, message): out = None if 'data' in message['error']: - err = ' '.join(message['error']['data'][-1]['errors']) + err = [] + for dct in message['error']['data']: + err.extend( + ['%s: %s' % ( k, repr(v) ) for k, v in dct.items()] ) + err = ', '.join(err) out = message['error']['data'] return code, msg, err, out @@ -550,7 +625,7 @@ def execute(self, commands, encoding='json', **kwargs): response = self.send(request) return response - except(ConnectionError, CommandError, TypeError) as exc: + except (ConnectionError, CommandError, TypeError) as exc: exc.commands = commands self.error = exc raise @@ -590,6 +665,9 @@ def __init__(self, host, port=None, path=None, username=None, path = path or DEFAULT_HTTP_PATH enforce_verification = kwargs.get('enforce_verification') + # after fix #236 (allowing passing ssl context), this parameter + # is deprecated - will be release noted and removed in the respective + # release versions if context is None and not enforce_verification: context = self.disable_certificate_verification() @@ -627,3 +705,43 @@ def __init__(self, host, port=None, path=None, key_file=None, key_file=key_file, cert_file=cert_file, ca_file=ca_file, timeout=timeout) + + +class SessionApiConnection(object): + def authentication(self, username, password): + try: + data = json.dumps({"username": username, "password": password}) + self.transport.putrequest("POST", "/login") + self.transport.putheader("Content-type", "application/json") + self.transport.putheader("Content-length", "%d" % len(data)) + + data = data.encode() + self.transport.endheaders(message_body=data) + resp = self.transport.getresponse() + if resp.status != 200: + raise ConnectionError(str(self), '%s. %s' % (resp.reason, + resp.read())) + session = SimpleCookie(resp.getheader("Set-Cookie")) + self._auth = ("Cookie", session.output(header="", attrs=[])) + + except OSError as exc: + _LOGGER.exception(exc) + self.socket_error = exc + self.error = exc + error_msg = f'Socket error during eAPI authentication: {exc}' + raise ConnectionError(str(self), error_msg) + except ValueError as exc: + _LOGGER.exception(exc) + self.socket_error = None + self.error = exc + raise ConnectionError(str(self), 'unable to connect to eAPI') + finally: + self.transport.close() + + +class HttpEapiSessionConnection(SessionApiConnection, HttpEapiConnection): + pass + + +class HttpsEapiSessionConnection(SessionApiConnection, HttpsEapiConnection): + pass diff --git a/pyeapi/utils.py b/pyeapi/utils.py index 14d7931..7ad2c0b 100644 --- a/pyeapi/utils.py +++ b/pyeapi/utils.py @@ -35,16 +35,10 @@ import inspect import logging import logging.handlers -import collections -from itertools import tee +from collections.abc import Iterable +from itertools import tee, zip_longest -try: - # Try Python 3.x import first - from itertools import zip_longest -except ImportError: - # Use Python 2.7 import as a fallback - from itertools import izip_longest as zip_longest _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) @@ -158,19 +152,16 @@ def make_iterable(value): iterable in the form of a list. Args: - value (object): An valid Python object + value (object): A valid Python object Returns: An iterable object of type list """ - if sys.version_info <= (3, 0): - # Convert unicode values to strings for Python 2 - if isinstance(value, unicode): - value = str(value) - if isinstance(value, str) or isinstance(value, dict): + if isinstance(value, str) or isinstance( + value, dict) or isinstance(value, CliVariants): value = [value] - if not isinstance(value, collections.Iterable): + if not isinstance(value, Iterable): raise TypeError('value must be an iterable object') return value @@ -240,3 +231,49 @@ def collapse_range(arg, value_delimiter=',', range_delimiter='-'): else: values.extend([v1]) return [str(x) for x in values] + + +class CliVariants: + """ + Provides an interface for cli variants (typically to handle a transition + period for a deprecated cli) + + Instance must be initialized either with 2 or more str variants: + ``CliVariants( 'new cli', 'legacy cli' )``, + or with 2 or more sequences of cli (or a mix of list and str types), e.g.: + ``CliVariants( ['new cli1', 'new cli2'], 'alt cli3', 'legacy cli4' )`` + """ + def __init__(self, *cli): + assert len( cli ) >= 2, 'must be initialized with 2 or more arguments' + self.variants = [ v if not isinstance(v, + str) and isinstance(v, Iterable) else [v] for v in cli ] + + +def _interpolate_docstr( *tkns ): + """Docstring decorator. + SYNOPSIS: + + MIN_MTU=68 + MAX_MTU=65535 + + @_interpolate_docstr( 'MIN_MTU', 'MAX_MTU' ) + def mtu_check( val ): + "check mtu against its min value (MIN_MTU) and max value (MAX_MTU)" + ... + + print( mtu_check.__doc__ ) + check mtu against its min value (68) and max value (65535) + """ + def docstr_decorator( user_fn ): + """update user_fn_wrapper doc string with the interpolated user_fn's + """ + def user_fn_wrapper( *args, **kwargs ): + return user_fn( *args, **kwargs ) + module = sys.modules[ user_fn.__module__ ] + docstr = user_fn.__doc__ + for tkn in tkns: + sval = str( getattr(module, tkn) ) + docstr = docstr.replace( tkn, sval ) + user_fn_wrapper.__doc__ = docstr + return user_fn_wrapper + return docstr_decorator diff --git a/setup.py b/setup.py index 3048963..6f5b9d4 100644 --- a/setup.py +++ b/setup.py @@ -46,10 +46,7 @@ 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3 :: Only' ], keywords='networking arista eos eapi', diff --git a/test/fixtures/running_config.text b/test/fixtures/running_config.text index 1f609f4..73c07a3 100644 --- a/test/fixtures/running_config.text +++ b/test/fixtures/running_config.text @@ -394,6 +394,11 @@ vlan 100 state active no private-vlan ! +vlan 200-202,204 + name grouping + state active + no private-vlan +! vlan 300 name VLAN0300 state active @@ -1444,6 +1449,107 @@ interface Ethernet7 no switchport tap default group no switchport tool group ! +interface Ethernet48.2044 + description some port description + no shutdown + default load-interval + logging event link-status use-global + encapsulation dot1q vlan 2044 + snmp trap link-change + vrf DAT + no ip proxy-arp + no ip local-proxy-arp + no arp gratuitous accept + ip address 100.76.1.41/31 + no ip verify unicast + no ip directed-broadcast + ip attached-routes + default arp aging timeout + default ipv6 nd cache expire + no bfd echo + no bfd authentication mode + default ip dhcp relay all-subnets + no ip helper-address + no ipv6 dhcp relay destination + no ipv6 dhcp relay add vendor-option ccap-core + no ipv6 dhcp relay install routes + ip dhcp relay information option circuit-id Ethernet48.2044 + no dhcp server ipv4 + no dhcp server ipv6 + no ip attached-host route export + no ipv6 attached-host route export + no ip igmp + ip igmp version 3 + ip igmp last-member-query-count 2 + ip igmp last-member-query-interval 10 + igmp query-max-response-time 100 + ip igmp query-interval 125 + ip igmp startup-query-count 2 + ip igmp startup-query-interval 310 + ip igmp router-alert optional connected + no ip igmp host-proxy + no ipv6 enable + default ipv6 nd dad + no ipv6 address + no ipv6 nd ra rx accept default-route + ipv6 attached-routes + no ipv6 verify unicast + no ipv6 nd ra disabled + ipv6 nd ra interval msec 200000 + ipv6 nd ra lifetime 1800 + no ipv6 nd ra mtu suppress + no ipv6 nd managed-config-flag + no ipv6 nd other-config-flag + ipv6 nd reachable-time 0 + ipv6 nd router-preference medium + ipv6 nd ra dns-servers lifetime 300 + ipv6 nd ra dns-suffixes lifetime 300 + ipv6 nd ra hop-limit 64 + no tcp mss ceiling + no multicast ipv4 source route export + no multicast ipv6 source route export + no multicast ipv4 static + no multicast ipv6 static + mfib ipv4 fastdrop + no mld + no mld static-group access-list + mld query-interval 125 + mld query-response-interval 10 + no mld startup-query-interval + mld startup-query-count 2 + mld robustness 2 + mld last-listener-query-interval 1 + mld last-listener-query-count 2 + mpls ip + default ntp serve + no pim ipv4 sparse-mode + no pim ipv4 bidirectional + no pim ipv4 border-router + pim ipv4 hello interval 30 + pim ipv4 hello count 3.5 + pim ipv4 dr-priority 1 + pim ipv4 join-prune interval 60 + pim ipv4 join-prune count 3.5 + no pim ipv4 neighbor filter + default pim ipv4 bfd + no pim ipv4 join-prune transport sctp + no pim ipv4 local-interface + no pim ipv4 non-dr install-oifs + no pim ipv6 sparse-mode + no pim ipv6 border-router + pim ipv6 hello interval 30 + pim ipv6 hello count 3.5 + pim ipv6 dr-priority 1 + pim ipv6 join-prune interval 60 + pim ipv6 join-prune count 3.5 + no pim ipv6 neighbor filter + default pim ipv6 bfd + no pim bsr ipv4 border + no pim bsr ipv6 border + no rip v2 multicast disable + no node-segment ipv4 index + no node-segment ipv6 index +! interface Loopback0 no description no shutdown @@ -1475,6 +1581,324 @@ interface Loopback0 bfd interval 300 min_rx 300 multiplier 3 default ntp serve ! +interface Loopback2 + description test fixture with secondary ip + ip address 2.2.2.2/32 + ip address 3.255.255.1/24 secondary + ip address 4.255.255.1/24 secondary +! +interface Ethernet8 + ! the interface config is added separately covering test for issue #213 + ! it might be inconsistend with the rest of config, though all unit tests pass + profile VLAN20 + no description + no shutdown + default load-interval + logging event link-status use-global + no dcbx mode + no l2-protocol forwarding profile + no link-debounce + no flowcontrol send + no flowcontrol receive + no mac timestamp + no speed + no l2 mtu + no l2 mru + default logging event congestion-drops + default unidirectional + no traffic-loopback + no mac-address + default error-correction encoding + no error-correction reed-solomon bypass + phy media 25gbase-cr negotiation standard consortium ieee + switchport trunk native vlan 1 + no switchport phone vlan + no switchport phone trunk + no switchport vlan translation in required + no switchport dot1q vlan tag + switchport trunk allowed vlan 1-4094 + switchport mode access + switchport dot1q ethertype 0x8100 + switchport mac address learning + default bridge mac-address-table aging timeout + no switchport vlan forwarding accept all + switchport + no encapsulation dot1q vlan + default switchport source-interface tx + no switchport trunk private-vlan secondary + no switchport pvlan mapping + no l2-protocol encapsulation dot1q vlan 0 + no flow tracker hardware + no flow tracker sampled + no flow-spec ipv4 ipv6 + snmp trap link-change + no address locking ipv4 + no ip proxy-arp + no ip local-proxy-arp + no arp gratuitous accept + no ip address + no ip verify unicast + no ip directed-broadcast + ip attached-routes + default arp aging timeout + default arp cache dynamic capacity + default ipv6 nd cache expire + default ipv6 nd cache dynamic capacity + bfd interval 300 min_rx 300 multiplier 3 + no bfd echo + no bfd authentication mode + default ip dhcp relay all-subnets + default ipv6 dhcp relay all-subnets + no ip helper-address + no ipv6 dhcp relay destination + no ipv6 dhcp relay add vendor-option ccap-core + no ipv6 dhcp relay install routes + ip dhcp relay information option circuit-id Ethernet1 + no dhcp relay ipv4 disabled + no dhcp relay ipv6 disabled + no dhcp server ipv4 + no dhcp server ipv6 + no ip igmp + ip igmp version 3 + ip igmp last-member-query-count 2 + ip igmp last-member-query-interval 10 + igmp query-max-response-time 100 + ip igmp query-interval 125 + ip igmp startup-query-count 2 + ip igmp startup-query-interval 310 + ip igmp router-alert optional connected + no ip igmp host-proxy + no ipv6 enable + default ipv6 nd dad + no ipv6 address + no ipv6 nd ra rx accept default-route + ipv6 attached-routes + no ipv6 verify unicast + no ipv6 nd ra disabled + ipv6 nd ra interval msec 200000 + ipv6 nd ra lifetime 1800 + no ipv6 nd ra mtu suppress + no ipv6 nd ra ns-interval unspecified + no ipv6 nd managed-config-flag + no ipv6 nd other-config-flag + ipv6 nd reachable-time 0 + ipv6 nd router-preference medium + ipv6 nd ra dns-servers lifetime 300 + ipv6 nd ra dns-suffixes lifetime 300 + ipv6 nd ra hop-limit 64 + no tcp mss ceiling + no lacp port-id + no channel-group + lacp timer normal + lacp timer multiplier 3 + lacp port-priority 32768 + default lacp rate-limit + no forwarding port-channel transmit disabled + lldp transmit + lldp receive + no lldp tlv transmit power-via-mdi fallback allocated-power + no lldp tlv transmit ztp vlan 2 + no lldp med network-policy + no mdns ipv4 + no multicast ipv4 static + no multicast ipv6 static + mfib ipv4 fastdrop + no mld + no mld static-group access-list + mld query-interval 125 + mld query-response-interval 10 + no mld startup-query-interval + mld startup-query-count 2 + mld robustness 2 + mld last-listener-query-interval 1 + mld last-listener-query-count 2 + mpls ip + default mrp leave-timer + no msrp + no mvrp + default ntp serve + no phy transmitter clock shift + no phy link detection aggressive + no phy link transmitter recovery + no phy link training + no phy link stabilization remote-fault disabled + no phy media base-t negotiation port type + no phy link serdes mapping direct + no phy media base-t polarity pair a + no phy media base-t polarity pair b + no phy media base-t polarity pair c + no phy media base-t polarity pair d + no pim ipv4 sparse-mode + no pim ipv4 bidirectional + no pim ipv4 border-router + pim ipv4 hello interval 30 + pim ipv4 hello count 3.5 + pim ipv4 dr-priority 1 + pim ipv4 join-prune interval 60 + pim ipv4 join-prune count 3.5 + no pim ipv4 neighbor filter + default pim ipv4 bfd + no pim ipv4 join-prune transport sctp + no pim ipv4 local-interface + no pim ipv4 non-dr install-oifs + no pim ipv6 sparse-mode + no pim ipv6 border-router + pim ipv6 hello interval 30 + pim ipv6 hello count 3.5 + pim ipv6 dr-priority 1 + pim ipv6 join-prune interval 60 + pim ipv6 join-prune count 3.5 + no pim ipv6 neighbor filter + default pim ipv6 bfd + no pim bsr ipv4 border + no pim bsr ipv6 border + no poe priority + no poe pairset + default poe reboot action + default poe link down action + default poe shutdown action + poe + no poe limit + poe negotiation lldp + no poe legacy detect + switchport port-security mac-address maximum 1 + no switchport port-security + default qos trust + qos cos 0 + qos dscp 0 + no shape rate + no priority-flow-control + priority-flow-control pause watchdog + no priority-flow-control priority 0 + no priority-flow-control priority 1 + no priority-flow-control priority 2 + no priority-flow-control priority 3 + no priority-flow-control priority 4 + no priority-flow-control priority 5 + no priority-flow-control priority 6 + no priority-flow-control priority 7 + no priority-flow-control pause watchdog port action + no priority-flow-control pause watchdog port timer + ! + tx-queue 0 + priority strict + no bandwidth percent + no shape rate + no bandwidth guaranteed + no random-detect ecn minimum-threshold + no random-detect drop + no random-detect non-ect + no random-detect ecn count + ! + tx-queue 1 + priority strict + no bandwidth percent + no shape rate + no bandwidth guaranteed + no random-detect ecn minimum-threshold + no random-detect drop + no random-detect non-ect + no random-detect ecn count + ! + tx-queue 2 + priority strict + no bandwidth percent + no shape rate + no bandwidth guaranteed + no random-detect ecn minimum-threshold + no random-detect drop + no random-detect non-ect + no random-detect ecn count + ! + tx-queue 3 + priority strict + no bandwidth percent + no shape rate + no bandwidth guaranteed + no random-detect ecn minimum-threshold + no random-detect drop + no random-detect non-ect + no random-detect ecn count + ! + tx-queue 4 + priority strict + no bandwidth percent + no shape rate + no bandwidth guaranteed + no random-detect ecn minimum-threshold + no random-detect drop + no random-detect non-ect + no random-detect ecn count + ! + tx-queue 5 + priority strict + no bandwidth percent + no shape rate + no bandwidth guaranteed + no random-detect ecn minimum-threshold + no random-detect drop + no random-detect non-ect + no random-detect ecn count + ! + tx-queue 6 + priority strict + no bandwidth percent + no shape rate + no bandwidth guaranteed + no random-detect ecn minimum-threshold + no random-detect drop + no random-detect non-ect + no random-detect ecn count + ! + tx-queue 7 + priority strict + no bandwidth percent + no shape rate + no bandwidth guaranteed + no random-detect ecn minimum-threshold + no random-detect drop + no random-detect non-ect + no random-detect ecn count + no rip v2 multicast disable + no rip authentication algorithm md5 tx key-id + default sflow enable + default sflow egress enable + no node-segment ipv4 index + no node-segment ipv6 index + no storm-control broadcast + no storm-control multicast + no storm-control unknown-unicast + no storm-control all + logging event storm-control discards use-global + no spanning-tree portfast + spanning-tree portfast auto + no spanning-tree link-type + no spanning-tree bpduguard + no spanning-tree bpdufilter + no spanning-tree cost + spanning-tree port-priority 128 + no spanning-tree guard + no spanning-tree bpduguard rate-limit + logging event spanning-tree use-global + switchport tap native vlan 1 + no switchport tap identity + no switchport tap encapsulation vxlan strip + no switchport tap encapsulation gre strip + no switchport tap encapsulation gre protocol strip + no switchport tap mpls pop all + no switchport tool mpls pop all + no switchport tool encapsulation vn-tag strip + no switchport tool encapsulation dot1br strip + switchport tap allowed vlan 1-4095 + switchport tool allowed vlan 1-4095 + no switchport tool identity + no switchport tap truncation + no switchport tool truncation + no switchport tap default group + no switchport tap default interface + no switchport tool group + no switchport tool dot1q remove outer +! interface Management1 no description no shutdown diff --git a/test/system/test_api_interfaces.py b/test/system/test_api_interfaces.py index 5b3ed60..ab7edb5 100644 --- a/test/system/test_api_interfaces.py +++ b/test/system/test_api_interfaces.py @@ -635,7 +635,7 @@ def test_update_vlan(self): api = dut.api('interfaces') instance = api.update_vlan('Vxlan1', '10', '10') self.assertTrue(instance) - self.contains('vxlan vlan 10 vni 10', dut) + self.contains('vxlan vlan add 10 vni 10', dut) def test_remove_vlan(self): for dut in self.duts: @@ -643,7 +643,7 @@ def test_remove_vlan(self): api = dut.api('interfaces') instance = api.remove_vlan('Vxlan1', '10') self.assertTrue(instance) - self.notcontains('vxlan vlan 10 vni 10', dut) + self.notcontains('vxlan vlan remove 10 vni 10', dut) if __name__ == '__main__': diff --git a/test/system/test_client.py b/test/system/test_client.py index c36c418..63d840c 100644 --- a/test/system/test_client.py +++ b/test/system/test_client.py @@ -271,6 +271,9 @@ def setUp(self): name = name.split(':')[1] self.duts.append(pyeapi.client.connect_to(name)) + if not hasattr(self, 'assertRegex'): + self.assertRegex = self.assertRegexpMatches + def test_exception_trace(self): # Send commands that will return an error and validate the errors @@ -321,7 +324,7 @@ def test_exception_trace(self): self.assertIsNotNone(exc.command_error) self.assertIsNotNone(exc.output) self.assertIsNotNone(exc.commands) - self.assertRegexpMatches(exc.message, regex) + self.assertRegex(exc.message, regex) if __name__ == '__main__': diff --git a/test/unit/test_api_interfaces.py b/test/unit/test_api_interfaces.py index 45a156b..1e19a49 100644 --- a/test/unit/test_api_interfaces.py +++ b/test/unit/test_api_interfaces.py @@ -501,12 +501,12 @@ def test_set_udp_port_with_default(self): self.eapi_positive_config_test(func, cmds) def test_update_vlan(self): - cmds = ['interface Vxlan1', 'vxlan vlan 10 vni 10'] + cmds = ['interface Vxlan1', 'vxlan vlan add 10 vni 10'] func = function('update_vlan', 'Vxlan1', 10, 10) self.eapi_positive_config_test(func, cmds) def test_remove_vlan(self): - cmds = ['interface Vxlan1', 'no vxlan vlan 10 vni'] + cmds = ['interface Vxlan1', 'vxlan vlan remove 10 vni'] func = function('remove_vlan', 'Vxlan1', 10) self.eapi_positive_config_test(func, cmds) diff --git a/test/unit/test_api_ipinterfaces.py b/test/unit/test_api_ipinterfaces.py index 00ed1b4..9350da6 100644 --- a/test/unit/test_api_ipinterfaces.py +++ b/test/unit/test_api_ipinterfaces.py @@ -53,14 +53,19 @@ def __init__(self, *args, **kwargs): self.config = open(get_fixture('running_config.text')).read() def test_get(self): - result = self.instance.get('Loopback0') - values = dict(name='Loopback0', address='1.1.1.1/32', mtu=1500) + result = self.instance.get( 'Loopback0' ) + values = dict( name='Loopback0', address='1.1.1.1/32', mtu=1500 ) + self.assertEqual( result, values ) + # test interface with secondary ip + result = self.instance.get( 'Loopback2' ) + values = dict( name='Loopback2', address='2.2.2.2/32', + secondary=['3.255.255.1/24', '4.255.255.1/24'], mtu=None ) self.assertEqual(result, values) def test_getall(self): result = self.instance.getall() self.assertIsInstance(result, dict) - self.assertEqual(len(result), 3) + self.assertEqual(len(result), 4) def test_instance_functions(self): for intf in self.INTERFACES: diff --git a/test/unit/test_api_switchports.py b/test/unit/test_api_switchports.py index c8944fd..b87980c 100644 --- a/test/unit/test_api_switchports.py +++ b/test/unit/test_api_switchports.py @@ -58,8 +58,14 @@ def test_get(self): self.assertEqual(sorted(result.keys()), sorted(keys)) def test_getall(self): + expected = sorted(['Port-Channel10', + 'Ethernet1', 'Ethernet2', + 'Ethernet3', 'Ethernet4', + 'Ethernet5', 'Ethernet6', + 'Ethernet7', 'Ethernet8']) result = self.instance.getall() self.assertIsInstance(result, dict) + self.assertEqual(sorted(self.instance.getall().keys()), expected) def test_instance_functions(self): for intf in self.INTERFACES: diff --git a/test/unit/test_api_users.py b/test/unit/test_api_users.py index 6afaa08..d0e4787 100644 --- a/test/unit/test_api_users.py +++ b/test/unit/test_api_users.py @@ -75,6 +75,11 @@ def test_create_with_secret_md5(self): func = function('create', 'test', secret='pass', encryption='md5') self.eapi_positive_config_test(func, cmds) + def test_create_with_secret_nologin(self): + cmds = 'username test secret *' + func = function('create', 'test', secret='', encryption='nologin') + self.eapi_positive_config_test(func, cmds) + def test_create_with_secret_sha512(self): cmds = 'username test secret sha512 pass' func = function('create', 'test', secret='pass', encryption='sha512') diff --git a/test/unit/test_api_vlans.py b/test/unit/test_api_vlans.py index f7d42ac..b054a3c 100644 --- a/test/unit/test_api_vlans.py +++ b/test/unit/test_api_vlans.py @@ -63,13 +63,19 @@ def test_get(self): trunk_groups=[]) self.assertEqual(vlan, result) + # ensure capturing grouppped vlans + result = self.instance.get('200-.*') + vlan = dict(vlan_id='200-202,204', name='grouping', state='active', + trunk_groups=[]) + self.assertEqual(vlan, result) + def test_get_not_configured(self): self.assertIsNone(self.instance.get('1000')) def test_getall(self): result = self.instance.getall() self.assertIsInstance(result, dict) - self.assertEqual(len(result), 4) + self.assertEqual(len(result), 5) def test_vlan_functions(self): for name in ['create', 'delete', 'default']: diff --git a/test/unit/test_api_vrfs.py b/test/unit/test_api_vrfs.py index b01628c..714001d 100644 --- a/test/unit/test_api_vrfs.py +++ b/test/unit/test_api_vrfs.py @@ -54,7 +54,7 @@ def test_get(self): ipv4_routing=True, ipv6_routing=False) self.assertEqual(vrf, result) result2 = self.instance.get('test') - vrf2 = dict(rd='200:500', vrf_name='test', description='!', + vrf2 = dict(rd='200:500', vrf_name='test', description='', ipv4_routing=False, ipv6_routing=True) self.assertEqual(vrf2, result2) diff --git a/test/unit/test_client.py b/test/unit/test_client.py index b5c6ecc..d094905 100644 --- a/test/unit/test_client.py +++ b/test/unit/test_client.py @@ -311,7 +311,7 @@ def test_connect_types(self, connection): transports = list(pyeapi.client.TRANSPORTS.keys()) kwargs = dict(host='localhost', username='admin', password='', port=None, key_file=None, cert_file=None, - ca_file=None, timeout=60) + ca_file=None, timeout=60, context=None) for transport in transports: pyeapi.client.connect(transport) @@ -333,6 +333,19 @@ def test_node_hasattr_connection(self): node = pyeapi.client.Node(None) self.assertTrue(hasattr(node, 'connection')) + @patch('pyeapi.client.Node.get_config') + def test_node_calls_running_config_with_all_by_default(self, get_config_mock): + node = pyeapi.client.Node(None) + _ = node.running_config + get_config_mock.assert_called_once_with(params='all', as_string=True) + + @patch('pyeapi.client.Node.get_config') + def test_node_calls_running_config_without_params_if_config_defaults_false( + self, get_config_mock): + node = pyeapi.client.Node(None, config_defaults=False) + _ = node.running_config + get_config_mock.assert_called_once_with(params=None, as_string=True) + def test_node_returns_running_config(self): node = pyeapi.client.Node(None) get_config_mock = Mock(name='get_config') @@ -410,7 +423,7 @@ def test_connect_default_type(self): pyeapi.client.connect() kwargs = dict(host='localhost', username='admin', password='', port=None, key_file=None, cert_file=None, - ca_file=None, timeout=60) + ca_file=None, timeout=60, context=None) transport.assert_called_once_with(**kwargs) def test_connect_return_node(self): @@ -423,7 +436,8 @@ def test_connect_return_node(self): timeout=60, return_node=True) kwargs = dict(host='192.168.1.16', username='eapi', password='password', port=None, key_file=None, - cert_file=None, ca_file=None, timeout=60) + cert_file=None, ca_file=None, timeout=60, + context=None) transport.assert_called_once_with(**kwargs) self.assertIsNone(node._enablepwd) @@ -438,7 +452,8 @@ def test_connect_return_node_enablepwd(self): return_node=True) kwargs = dict(host='192.168.1.16', username='eapi', password='password', port=None, key_file=None, - cert_file=None, ca_file=None, timeout=60) + cert_file=None, ca_file=None, timeout=60, + context=None) transport.assert_called_once_with(**kwargs) self.assertEqual(node._enablepwd, 'enablepwd') @@ -450,7 +465,8 @@ def test_connect_to_with_config(self): node = pyeapi.client.connect_to('test1') kwargs = dict(host='192.168.1.16', username='eapi', password='password', port=None, key_file=None, - cert_file=None, ca_file=None, timeout=60) + cert_file=None, ca_file=None, timeout=60, + context=None) transport.assert_called_once_with(**kwargs) self.assertEqual(node._enablepwd, 'enablepwd') diff --git a/test/unit/test_utils.py b/test/unit/test_utils.py index 46d2d01..907b48c 100644 --- a/test/unit/test_utils.py +++ b/test/unit/test_utils.py @@ -1,10 +1,13 @@ +import sys import unittest -import collections from mock import patch, Mock - import pyeapi.utils +if sys.version_info < (3, 3): + from collections import Iterable +else: + from collections.abc import Iterable class TestUtils(unittest.TestCase): @@ -23,19 +26,20 @@ def test_load_module_raises_import_error(self, mock_import_module): def test_make_iterable_from_string(self): result = pyeapi.utils.make_iterable('test') - self.assertIsInstance(result, collections.Iterable) + self.assertIsInstance(result, Iterable) self.assertEqual(len(result), 1) def test_make_iterable_from_unicode(self): result = pyeapi.utils.make_iterable(u'test') - self.assertIsInstance(result, collections.Iterable) + self.assertIsInstance(result, Iterable) self.assertEqual(len(result), 1) def test_make_iterable_from_iterable(self): result = pyeapi.utils.make_iterable(['test']) - self.assertIsInstance(result, collections.Iterable) + self.assertIsInstance(result, Iterable) self.assertEqual(len(result), 1) + def test_make_iterable_raises_type_error(self): with self.assertRaises(TypeError): pyeapi.utils.make_iterable(object())