├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── TODO.rst ├── conftest.py ├── docs ├── Makefile ├── README.rst ├── _static │ └── logo_128.png ├── api.rst ├── auth.rst ├── changelog.rst ├── cli.rst ├── conf.py ├── config.rst ├── development.rst ├── index.rst └── python_api.rst ├── pynsot ├── __init__.py ├── app.py ├── client.py ├── commands │ ├── __init__.py │ ├── callbacks.py │ ├── cmd_attributes.py │ ├── cmd_changes.py │ ├── cmd_circuits.py │ ├── cmd_devices.py │ ├── cmd_interfaces.py │ ├── cmd_networks.py │ ├── cmd_protocol_types.py │ ├── cmd_protocols.py │ ├── cmd_sites.py │ ├── cmd_values.py │ └── types.py ├── constants.py ├── dotfile.py ├── models.py ├── serializers.py ├── util.py └── version.py ├── pytest.ini ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── app ├── __init__.py ├── test_circuits.py ├── test_protocol_types.py └── test_protocols.py ├── fixtures ├── __init__.py ├── circuits.py ├── protocol_types.py └── protocols.py ├── generate_test_data.py ├── nsot_settings.py ├── test_app.py ├── test_client.py ├── test_dotfile.py ├── test_models.py ├── test_util.py └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | nsot.sqlite 2 | 3 | __pycache__ 4 | 5 | MANIFEST 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Packages 12 | *.egg 13 | *.egg-info 14 | dist 15 | build 16 | eggs 17 | parts 18 | var 19 | sdist 20 | develop-eggs 21 | .installed.cfg 22 | lib 23 | lib64 24 | 25 | # Installer logs 26 | pip-log.txt 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | nosetests.xml 32 | 33 | # Translations 34 | *.mo 35 | 36 | # Mr Developer 37 | .mr.developer.cfg 38 | .project 39 | .pydevproject 40 | 41 | docs/_build 42 | .*sw? 43 | bin/pynsot 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | python: 4 | - "2.7" 5 | - "3.8" 6 | 7 | install: 8 | - pip install -r requirements-dev.txt 9 | - pip install . 10 | 11 | script: 12 | - flake8 13 | - py.test -v tests/ 14 | 15 | after_success: curl -X POST https://readthedocs.org/build/pynsot 16 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ######### 2 | Changelog 3 | ######### 4 | 5 | Version History 6 | =============== 7 | 8 | .. _v1.4.1: 9 | 10 | 1.4.1 (2019-10-25) 11 | 12 | * Fix bugs introduced in v1.4.0: 13 | * Add in python3 backport of configparser to requirements.txt 14 | * Support global pynsotrc in /etc 15 | 16 | .. _v1.4.0: 17 | 18 | 1.4.0 (2019-10-24) 19 | 20 | * Add alpha Python3 support 21 | 22 | .. _v1.3.2: 23 | 24 | 1.3.2 (2019-03-11) 25 | 26 | * Update pynsot to use Click 7.x and explicitly name subcommands with 27 | underscores as such so they don't get updated to use hyphens. 28 | * Fix #159 - Add ``--limit`` and ``--offset`` to ``Protocol`` and 29 | ``ProtocolType`` CLI. These CLI opt ions are standard across all resource 30 | types with their ``list`` subcommands, but they were forgotten with the new 31 | ``Protocol`` and ``ProtocolType`` objects. 32 | 33 | .. _v1.3.1: 34 | 35 | 1.3.1 (2018-09-11) 36 | ------------------ 37 | 38 | * Fixes NSoT Issue #121: Enabling `force_delete` flag for deleting parent networks. 39 | * Adds `development` section to docs to explain versioning and the release process. 40 | * Fixes miscellaneous typos. 41 | 42 | .. _v1.3.0: 43 | 44 | 1.3.0 (2018-03-20) 45 | ------------------ 46 | 47 | * Implements protocols and protocol_types CLI capability (added in NSoT v1.3.0). 48 | * Enhancement to ``-g/--grep`` to include all object fields. 49 | * Fixes #149: Add set queries to `nsot circuits list`. 50 | 51 | .. _v1.2.1: 52 | 53 | 1.2.1 (2017-09-07) 54 | ------------------ 55 | 56 | * Implements #142: Sorts the ``Attributes`` column in the output of the 57 | ``list`` subcommand, similar to the output of the ``list`` subcommand 58 | with the ``-g`` flag. 59 | 60 | .. _v1.2.0: 61 | 62 | 1.2.0 (2017-07-28) 63 | ------------------ 64 | 65 | .. danger:: 66 | 67 | This release requires NSoT version 1.2.0 and is **BACKWARDS INCOMPATIBLE** 68 | with previous NSoT versions. 69 | 70 | * Adds support for natural keys when creating/updating related objects (added in 71 | NSoT v1.2.0) 72 | * Interfaces may now be created/updated by referencing the device 73 | hostname or device ID 74 | * Circuits may now be created/updated by referencing the interfaces by 75 | natural key (slug) OR interface ID 76 | * The visual display of Networks, Interfaces, Circuits has been updated to be 77 | more compact/concise 78 | 79 | + Networks 80 | 81 | - cidr is now displayed instead of network_address/prefix_length 82 | - parent cidr is now displayed instead of parent_id 83 | 84 | + Interfaces 85 | 86 | - name_slug is now displayed instead of device_id/name 87 | - parent name is now displayed instead of parent_id 88 | 89 | + Circuits 90 | 91 | - interface slugs are now displayed instead of ID numbers 92 | 93 | * The string "(Key)" is now displayed in the header for the natural key field 94 | of a resource on list views 95 | 96 | .. _v1.1.4: 97 | 98 | 1.1.4 (2017-05-31) 99 | ------------------ 100 | 101 | * Add commands for Interface tree traversal (added in NSoT 1.1.4) 102 | 103 | .. _v1.1.3: 104 | 105 | 1.1.3 (2017-02-21) 106 | ------------------ 107 | 108 | * Fix #119 - Add ability to use set queries on `list` subcommands (#133) 109 | 110 | .. _v1.1.2: 111 | 112 | 1.1.2 (2017-02-06) 113 | ------------------ 114 | 115 | * Add support for strict allocations (added in NSoT v1.1.2) 116 | * Change requirements.txt to use Compatible Release version specifiers 117 | 118 | .. _v1.1.1: 119 | 120 | 1.1.1 (2017-01-31) 121 | ------------------ 122 | 123 | * Corrected the spelling for the ``descendants`` sub-command on Networks. The 124 | old misspelled form of ``descendents`` will display a warning to users. 125 | 126 | .. _v1.1.0: 127 | 128 | 1.1.0 (2017-01-30) 129 | ------------------ 130 | 131 | * Adds support for Circuit objects (added in NSoT v1.1) 132 | 133 | .. _v1.0.2: 134 | 135 | 1.0.2 (2017-01-23) 136 | ------------------ 137 | 138 | * Bump NSoT requirement to v1.0.13, fix tests that were broken 139 | * Fix #125 - Support natural key when working with interface. Interfaces can 140 | now be referred to using `device_name:interface_name` in addition to the 141 | unique ID given by the database. 142 | 143 | .. _v1.0.1: 144 | 145 | 1.0.1 (2016-12-12) 146 | ------------------ 147 | 148 | * Network objects are now properly sorted by network hierarchy instead of by 149 | alphanumeric order. 150 | * Streamlined the way that objects are displayed by natural key to simplify 151 | future feature development. 152 | 153 | .. _v1.0: 154 | 155 | 1.0 (2016-04-27) 156 | ---------------- 157 | 158 | * OFFICIAL VERSION 1.0! 159 | * Fully compatible with NSoT REST API version 1 160 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Dropbox, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include TODO.rst 4 | include requirements.txt 5 | include requirements-dev.txt 6 | recursive-include tests * 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ###### 2 | pyNSoT 3 | ###### 4 | 5 | .. image:: https://raw.githubusercontent.com/dropbox/pynsot/master/docs/_static/logo_128.png 6 | :alt: Network Source of Truth 7 | :width: 128px 8 | 9 | |Build Status| |Documentation Status| |PyPI Status| 10 | 11 | .. _Network Source of Truth (NSoT): https://nsot.readthedocs.io 12 | 13 | .. |Build Status| image:: https://img.shields.io/travis/dropbox/pynsot/master.svg?style=flat 14 | :target: https://travis-ci.org/dropbox/pynsot 15 | :width: 88px 16 | :height: 20px 17 | .. |Documentation Status| image:: https://readthedocs.org/projects/pynsot/badge/?version=latest&style=flat 18 | :target: https://readthedocs.io/projects/pynsot/?badge=latest 19 | :width: 76px 20 | :height: 20px 21 | .. |PyPI Status| image:: https://img.shields.io/pypi/v/pynsot.svg?style=flat 22 | :target: https://pypi.python.org/pypi/pynsot 23 | :width: 68px 24 | :height: 20px 25 | 26 | Python client library and command-line utility for the `Network Source of 27 | Truth `_ (NSoT) IPAM and source of truth 28 | database REST API. 29 | 30 | Read the docs at http://pynsot.readthedocs.io 31 | -------------------------------------------------------------------------------- /TODO.rst: -------------------------------------------------------------------------------- 1 | ######### 2 | TODO list 3 | ######### 4 | 5 | Client 6 | ====== 7 | 8 | + Implement a "collection" model object for working with paginated lists of 9 | results. 10 | + Impelement a way to authenticate or refresh tokens as transparently as 11 | possible. 12 | + Need a way to differentiate authentication methods by way of plugins. 13 | 14 | CLI 15 | === 16 | 17 | + Implement dotfile for storing user defaults and API token information. 18 | + Add ``--no-header`` for text-processing tools when dispalying tabulated results. 19 | + Need a way to differentiate authentication methods by way of plugins. 20 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | # Prvent import of django settings when using python 3. 2 | # This can be removed once NSOT supports python 3. 3 | from __future__ import absolute_import 4 | import sys 5 | import os 6 | 7 | if sys.version_info[0] < 3: 8 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.nsot_settings" 9 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | @echo " coverage to run coverage check of the documentation (if enabled)" 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | .PHONY: html 55 | html: 56 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 59 | 60 | .PHONY: dirhtml 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | .PHONY: singlehtml 67 | singlehtml: 68 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 69 | @echo 70 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 71 | 72 | .PHONY: pickle 73 | pickle: 74 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 75 | @echo 76 | @echo "Build finished; now you can process the pickle files." 77 | 78 | .PHONY: json 79 | json: 80 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 81 | @echo 82 | @echo "Build finished; now you can process the JSON files." 83 | 84 | .PHONY: htmlhelp 85 | htmlhelp: 86 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 87 | @echo 88 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 89 | ".hhp project file in $(BUILDDIR)/htmlhelp." 90 | 91 | .PHONY: qthelp 92 | qthelp: 93 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 94 | @echo 95 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 96 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 97 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pynsot.qhcp" 98 | @echo "To view the help file:" 99 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pynsot.qhc" 100 | 101 | .PHONY: applehelp 102 | applehelp: 103 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 104 | @echo 105 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 106 | @echo "N.B. You won't be able to view it unless you put it in" \ 107 | "~/Library/Documentation/Help or install it in your application" \ 108 | "bundle." 109 | 110 | .PHONY: devhelp 111 | devhelp: 112 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 113 | @echo 114 | @echo "Build finished." 115 | @echo "To view the help file:" 116 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pynsot" 117 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pynsot" 118 | @echo "# devhelp" 119 | 120 | .PHONY: epub 121 | epub: 122 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 123 | @echo 124 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 125 | 126 | .PHONY: latex 127 | latex: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo 130 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 131 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 132 | "(use \`make latexpdf' here to do that automatically)." 133 | 134 | .PHONY: latexpdf 135 | latexpdf: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo "Running LaTeX files through pdflatex..." 138 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 139 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 140 | 141 | .PHONY: latexpdfja 142 | latexpdfja: 143 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 144 | @echo "Running LaTeX files through platex and dvipdfmx..." 145 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 146 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 147 | 148 | .PHONY: text 149 | text: 150 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 151 | @echo 152 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 153 | 154 | .PHONY: man 155 | man: 156 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 157 | @echo 158 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 159 | 160 | .PHONY: texinfo 161 | texinfo: 162 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 163 | @echo 164 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 165 | @echo "Run \`make' in that directory to run these through makeinfo" \ 166 | "(use \`make info' here to do that automatically)." 167 | 168 | .PHONY: info 169 | info: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo "Running Texinfo files through makeinfo..." 172 | make -C $(BUILDDIR)/texinfo info 173 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 174 | 175 | .PHONY: gettext 176 | gettext: 177 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 178 | @echo 179 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 180 | 181 | .PHONY: changes 182 | changes: 183 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 184 | @echo 185 | @echo "The overview file is in $(BUILDDIR)/changes." 186 | 187 | .PHONY: linkcheck 188 | linkcheck: 189 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 190 | @echo 191 | @echo "Link check complete; look for any errors in the above output " \ 192 | "or in $(BUILDDIR)/linkcheck/output.txt." 193 | 194 | .PHONY: doctest 195 | doctest: 196 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 197 | @echo "Testing of doctests in the sources finished, look at the " \ 198 | "results in $(BUILDDIR)/doctest/output.txt." 199 | 200 | .PHONY: coverage 201 | coverage: 202 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 203 | @echo "Testing of coverage in the sources finished, look at the " \ 204 | "results in $(BUILDDIR)/coverage/python.txt." 205 | 206 | .PHONY: xml 207 | xml: 208 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 209 | @echo 210 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 211 | 212 | .PHONY: pseudoxml 213 | pseudoxml: 214 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 215 | @echo 216 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 217 | -------------------------------------------------------------------------------- /docs/README.rst: -------------------------------------------------------------------------------- 1 | Docs 2 | ==== 3 | 4 | These docs are rendered using Sphinx. After making changes, make sure to run 5 | ``make html`` before committing. 6 | -------------------------------------------------------------------------------- /docs/_static/logo_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/pynsot/34a6debd49c8f48f56527dec54e0be08dd5bd8a9/docs/_static/logo_128.png -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. module:: pynsot 5 | 6 | This part of the documentation is for API reference 7 | 8 | Clients 9 | ------- 10 | 11 | .. automodule:: pynsot.client 12 | 13 | .. autofunction:: get_api_client 14 | .. autoclass:: BaseClient 15 | :members: 16 | 17 | .. autoclass:: EmailHeaderClient 18 | :members: 19 | 20 | .. autoclass:: AuthTokenClient 21 | :members: 22 | 23 | .. autoclass:: BaseClientAuth 24 | :members: 25 | 26 | .. autoclass:: EmailHeaderAuthentication 27 | :members: 28 | 29 | .. autoclass:: AuthTokenAuthentication 30 | :members: 31 | 32 | 33 | API Models 34 | ---------- 35 | 36 | .. automodule:: pynsot.models 37 | 38 | .. autoclass:: Resource 39 | :members: 40 | 41 | .. autoclass:: Network 42 | :members: 43 | 44 | .. autoclass:: Device 45 | :members: 46 | 47 | .. autoclass:: Interface 48 | :members: 49 | 50 | Utilities 51 | --------- 52 | 53 | .. autofunction:: pynsot.util.get_result 54 | 55 | Dotfile 56 | ------- 57 | 58 | .. autoclass:: pynsot.dotfile.Dotfile 59 | -------------------------------------------------------------------------------- /docs/auth.rst: -------------------------------------------------------------------------------- 1 | Authentication 2 | ============== 3 | 4 | NSoT supports two methods of authentication and these are implemented in the 5 | client: 6 | 7 | 1. ``auth_token`` 8 | 2. ``auth_header`` 9 | 10 | The client default is ``auth_token``, but ``auth_header`` is more flexible for 11 | "zero touch" use. 12 | 13 | If sticking with the defaults, you'll need to retrieve your key from 14 | ``/profile`` in the web interface. 15 | 16 | Refer to :ref:`config_ref` for setting these in your ``pynsotrc``. 17 | 18 | Python Client 19 | ------------- 20 | 21 | Assuming your configuration is correct, the CLI interface doesn't need anything 22 | special to make authentication work. The following only applies to retrieving a 23 | client instance in Python. 24 | 25 | .. code-block:: python 26 | 27 | from pynsot.client import AuthTokenClient, EmailHeaderClient, get_api_client 28 | 29 | # This is the preferred method, returning the appropriate client according 30 | # to your dotfile if no arguments are supplied. 31 | # 32 | # Alteratively you can override options by passing url, auth_method, and 33 | # other kwargs. See `help(get_api_client) for more details 34 | c = get_api_client() 35 | 36 | # OR using the client objects directly 37 | 38 | email = 'jathan@localhost' 39 | secret_key = 'qONJrNpTX0_9v7H_LN1JlA0u4gdTs4rRMQklmQF9WF4=' 40 | url = 'http://localhost:8990/api' 41 | c = AuthTokenClient(url, email=email, secret_key=secret_key) 42 | 43 | # Email Header Client 44 | domain = 'localhost' 45 | auth_header = 'X-NSoT-Email' 46 | c = EmailHeaderClient( 47 | url, 48 | email=email, 49 | default_domain=domain, 50 | auth_header=auth_header, 51 | ) 52 | 53 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: 2 | ../CHANGELOG.rst 3 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pynsot documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Mar 10 17:18:55 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import sphinx_rtd_theme 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # eeds_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | 'sphinx.ext.todo', 35 | 'sphinx.ext.coverage', 36 | 'sphinx.ext.viewcode', 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # The suffix(es) of source filenames. 43 | # You can specify multiple suffix as a list of string: 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = '.rst' 46 | 47 | # The encoding of source files. 48 | # ource_encoding = 'utf-8-sig' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = u'pynsot' 55 | copyright = u'2016, Jathan McCollum' 56 | author = u'Jathan McCollum' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | __version__ = 'placeholder' 63 | execfile(os.path.join(os.path.abspath('..'), 'pynsot/version.py')) 64 | 65 | # The short X.Y version. 66 | version = __version__ 67 | # The full version, including alpha/beta/rc tags. 68 | release = __version__ 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | # 73 | # This is also used if you do content translation via gettext catalogs. 74 | # Usually you set "language" from the command line for these cases. 75 | language = None 76 | 77 | # There are two options for replacing |today|: either, you set today to some 78 | # non-false value, then it is used: 79 | # oday = '' 80 | # Else, today_fmt is used as the format for a strftime call. 81 | # oday_fmt = '%B %d, %Y' 82 | 83 | # List of patterns, relative to source directory, that match files and 84 | # directories to ignore when looking for source files. 85 | exclude_patterns = ['_build'] 86 | 87 | # The reST default role (used for this markup: `text`) to use for all 88 | # documents. 89 | # efault_role = None 90 | 91 | # If true, '()' will be appended to :func: etc. cross-reference text. 92 | # dd_function_parentheses = True 93 | 94 | # If true, the current module name will be prepended to all description 95 | # unit titles (such as .. function::). 96 | # dd_module_names = True 97 | 98 | # If true, sectionauthor and moduleauthor directives will be shown in the 99 | # output. They are ignored by default. 100 | # how_authors = False 101 | 102 | # The name of the Pygments (syntax highlighting) style to use. 103 | pygments_style = 'sphinx' 104 | 105 | # A list of ignored prefixes for module index sorting. 106 | # odindex_common_prefix = [] 107 | 108 | # If true, keep warnings as "system message" paragraphs in the built documents. 109 | # eep_warnings = False 110 | 111 | # If true, `todo` and `todoList` produce output, else they produce nothing. 112 | todo_include_todos = True 113 | 114 | 115 | # -- Options for HTML output ---------------------------------------------- 116 | 117 | # The theme to use for HTML and HTML Help pages. See the documentation for 118 | # a list of builtin themes. 119 | # html_theme = 'alabaster' 120 | html_theme = 'sphinx_rtd_theme' 121 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 122 | 123 | # Theme options are theme-specific and customize the look and feel of a theme 124 | # further. For a list of options available for each theme, see the 125 | # documentation. 126 | # html_theme_options = {} 127 | 128 | # Add any paths that contain custom themes here, relative to this directory. 129 | # html_theme_path = [] 130 | 131 | # The name for this set of Sphinx documents. If None, it defaults to 132 | # " v documentation". 133 | # html_title = None 134 | 135 | # A shorter title for the navigation bar. Default is the same as html_title. 136 | # html_short_title = None 137 | 138 | # The name of an image file (relative to this directory) to place at the top 139 | # of the sidebar. 140 | # html_logo = None 141 | html_logo = '_static/logo_128.png' 142 | 143 | # The name of an image file (relative to this directory) to use as a favicon of 144 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 145 | # 32x32 pixels large. 146 | # html_favicon = None 147 | 148 | # Add any paths that contain custom static files (such as style sheets) here, 149 | # relative to this directory. They are copied after the builtin static files, 150 | # so a file named "default.css" will overwrite the builtin "default.css". 151 | html_static_path = ['_static'] 152 | 153 | # Add any extra paths that contain custom files (such as robots.txt or 154 | # .htaccess) here, relative to this directory. These files are copied 155 | # directly to the root of the documentation. 156 | # html_extra_path = [] 157 | 158 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 159 | # using the given strftime format. 160 | # html_last_updated_fmt = '%b %d, %Y' 161 | 162 | # If true, SmartyPants will be used to convert quotes and dashes to 163 | # typographically correct entities. 164 | # html_use_smartypants = True 165 | 166 | # Custom sidebar templates, maps document names to template names. 167 | # html_sidebars = {} 168 | 169 | # Additional templates that should be rendered to pages, maps page names to 170 | # template names. 171 | # html_additional_pages = {} 172 | 173 | # If false, no module index is generated. 174 | # html_domain_indices = True 175 | 176 | # If false, no index is generated. 177 | # html_use_index = True 178 | 179 | # If true, the index is split into individual pages for each letter. 180 | # html_split_index = False 181 | 182 | # If true, links to the reST sources are added to the pages. 183 | # html_show_sourcelink = True 184 | 185 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 186 | # html_show_sphinx = True 187 | 188 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 189 | # html_show_copyright = True 190 | 191 | # If true, an OpenSearch description file will be output, and all pages will 192 | # contain a tag referring to it. The value of this option must be the 193 | # base URL from which the finished HTML is served. 194 | # html_use_opensearch = '' 195 | 196 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 197 | # html_file_suffix = None 198 | 199 | # Language to be used for generating the HTML full-text search index. 200 | # Sphinx supports the following languages: 201 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 202 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 203 | # html_search_language = 'en' 204 | 205 | # A dictionary with options for the search language support, empty by default. 206 | # Now only 'ja' uses this config value 207 | # html_search_options = {'type': 'default'} 208 | 209 | # The name of a javascript file (relative to the configuration directory) that 210 | # implements a search results scorer. If empty, the default will be used. 211 | # html_search_scorer = 'scorer.js' 212 | 213 | # Output file base name for HTML help builder. 214 | htmlhelp_basename = 'pynsotdoc' 215 | 216 | # -- Options for LaTeX output --------------------------------------------- 217 | 218 | latex_elements = { 219 | # The paper size ('letterpaper' or 'a4paper'). 220 | # papersize': 'letterpaper', 221 | 222 | # The font size ('10pt', '11pt' or '12pt'). 223 | # pointsize': '10pt', 224 | 225 | # Additional stuff for the LaTeX preamble. 226 | # preamble': '', 227 | 228 | # Latex figure (float) alignment 229 | # figure_align': 'htbp', 230 | } 231 | 232 | # Grouping the document tree into LaTeX files. List of tuples 233 | # (source start file, target name, title, 234 | # author, documentclass [howto, manual, or own class]). 235 | latex_documents = [ 236 | (master_doc, 'pynsot.tex', u'pynsot Documentation', 237 | u'Jathan McCollum', 'manual'), 238 | ] 239 | 240 | # The name of an image file (relative to this directory) to place at the top of 241 | # the title page. 242 | # atex_logo = None 243 | 244 | # For "manual" documents, if this is true, then toplevel headings are parts, 245 | # not chapters. 246 | # atex_use_parts = False 247 | 248 | # If true, show page references after internal links. 249 | # atex_show_pagerefs = False 250 | 251 | # If true, show URL addresses after external links. 252 | # atex_show_urls = False 253 | 254 | # Documents to append as an appendix to all manuals. 255 | # atex_appendices = [] 256 | 257 | # If false, no module index is generated. 258 | # atex_domain_indices = True 259 | 260 | 261 | # -- Options for manual page output --------------------------------------- 262 | 263 | # One entry per manual page. List of tuples 264 | # (source start file, name, description, authors, manual section). 265 | man_pages = [ 266 | (master_doc, 'pynsot', u'pynsot Documentation', 267 | [author], 1) 268 | ] 269 | 270 | # If true, show URL addresses after external links. 271 | # an_show_urls = False 272 | 273 | 274 | # -- Options for Texinfo output ------------------------------------------- 275 | 276 | # Grouping the document tree into Texinfo files. List of tuples 277 | # (source start file, target name, title, author, 278 | # dir menu entry, description, category) 279 | texinfo_documents = [ 280 | (master_doc, 'pynsot', u'pynsot Documentation', 281 | author, 'pynsot', 'One line description of project.', 282 | 'Miscellaneous'), 283 | ] 284 | 285 | # Documents to append as an appendix to all manuals. 286 | # exinfo_appendices = [] 287 | 288 | # If false, no module index is generated. 289 | # exinfo_domain_indices = True 290 | 291 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 292 | # exinfo_show_urls = 'footnote' 293 | 294 | # If true, do not generate a @detailmenu in the "Top" node's menu. 295 | # exinfo_no_detailmenu = False 296 | -------------------------------------------------------------------------------- /docs/config.rst: -------------------------------------------------------------------------------- 1 | ############# 2 | Configuration 3 | ############# 4 | 5 | Configuration Basics 6 | ==================== 7 | 8 | Configuration for pynsot consists of a single INI with two possible locations: 9 | 10 | 1. ``/etc/pynsotrc`` 11 | 2. ``~/.pynsotrc`` 12 | 13 | The files are discovered and loaded in order, with the settings found in each 14 | location being merged together. The home directory takes precedence. 15 | 16 | Configuration elements must be under the ``pynsot`` section. 17 | 18 | If you don't create this file, running ``nsot`` will prompt you to create one 19 | interactively. 20 | 21 | Like so:: 22 | 23 | $ nsot sites list 24 | /home/jathan/.pynsotrc not found; would you like to create it? [Y/n]: y 25 | Please enter URL: http://localhost:8990/api 26 | Please enter SECRET_KEY: qONJrNpTX0_9v7H_LN1JlA0u4gdTs4rRMQklmQF9WF4= 27 | Please enter EMAIL: jathan@localhost 28 | 29 | .. _example_config: 30 | 31 | Example Configuration 32 | ===================== 33 | 34 | .. code-block:: ini 35 | 36 | [pynsot] 37 | auth_header = X-NSoT-Email 38 | auth_method = auth_header 39 | default_site = 1 40 | default_domain = company.com 41 | url = https://nsot.company.com/api 42 | 43 | .. _config_ref: 44 | 45 | Configuration Reference 46 | ======================= 47 | 48 | .. list-table:: 49 | :header-rows: 1 50 | 51 | * - Key 52 | - Value 53 | - Default 54 | - Required 55 | * - url 56 | - API URL. (e.g. http://localhost:8990/api) 57 | - 58 | - Yes 59 | * - email 60 | - User email 61 | - ``$USER@{default_domain}`` 62 | - No 63 | * - api_version 64 | - API version to use. (e.g. ``1.0``) 65 | - ``None`` 66 | - No 67 | * - auth_method 68 | - ``auth_token`` or ``auth_header`` 69 | - 70 | - Yes 71 | * - secret_key 72 | - Secret Key from your user profile 73 | - 74 | - No 75 | * - default_site 76 | - Default ``site_id`` if not provided w/ ``-s`` 77 | - 78 | - No 79 | * - auth_header 80 | - HTTP header used for proxy authentication 81 | - ``X-NSoT-Email`` 82 | - No 83 | * - default_domain 84 | - Domain for email address 85 | - ``localhost`` 86 | - No 87 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Versioning 2 | ---------- 3 | 4 | We use `semantic versioning `_. Version numbers will 5 | follow this format:: 6 | 7 | {Major version}.{Minor version}.{Revision number}.{Build number (optional)} 8 | 9 | Patch version numbers (0.0.x) are used for changes that are API compatible. You 10 | should be able to upgrade between minor point releases without any other code 11 | changes. 12 | 13 | Minor version numbers (0.x.0) may include API changes, in line with the 14 | :ref:`deprecation-policy`. You should read the release notes carefully before 15 | upgrading between minor point releases. 16 | 17 | Major version numbers (x.0.0) are reserved for substantial project milestones. 18 | 19 | .. _release-process: 20 | 21 | Release Process 22 | --------------- 23 | 24 | When a new version is to be cut from the commits made on the ``develop`` 25 | branch, the following process should be followed. This is meant to be done by 26 | project maintainers, who have push access to the parent repository. 27 | 28 | #. Create a branch off of the ``develop`` branch called ``release-vX.Y.Z`` 29 | where ``vX.Y.Z`` is the version you are releasing 30 | #. Update the version in ``pynsot/version.py``. 31 | 32 | 3. Update ``CHANGELOG.rst`` with what has changed since the last version. A 33 | one-line summary for each change is sufficient, and often the summary from 34 | each PR merge works. 35 | #. Commit these changes to your branch. 36 | #. Open a release Pull Request against the ``master`` branch 37 | #. On GitHub, merge the release Pull Request into ``master`` 38 | #. Locally, merge the release branch into ``develop`` and push that ``develop`` 39 | branch up 40 | #. Switch to the ``master`` branch and pull the latest updates (with the PR you 41 | just merged) 42 | #. Create a new git tag with this verison in the format of ``vX.Y.Z`` 43 | #. Push up the new tag 44 | 45 | .. code-block:: bash 46 | 47 | # 'upstream' here is the name of the remote, it may also be 'origin' 48 | $ git push --tags upstream 49 | 50 | 11. Create a new package and push it up to PyPI (where ``{version}`` is the 51 | current release version): 52 | 53 | .. code-block:: bash 54 | 55 | $ python setup.py sdist 56 | $ twine upload dist/pynsot-{version}.tar.gz 57 | 58 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ########################################### 2 | PyNSoT - The Network Source of Truth Client 3 | ########################################### 4 | 5 | PyNSoT is the official client library and command-line utility for the 6 | `Network Source of Truth (NSoT)`_ network source-of-truth and IPAM database. 7 | For more information on the core project, please follow the link above. 8 | 9 | |Build Status| |Documentation Status| |PyPI Status| 10 | 11 | .. _Network Source of Truth (NSoT): https://nsot.readthedocs.io 12 | 13 | .. |Build Status| image:: https://img.shields.io/travis/dropbox/pynsot/master.svg?style=flat 14 | :target: https://travis-ci.org/dropbox/pynsot 15 | :width: 88px 16 | :height: 20px 17 | .. |Documentation Status| image:: https://readthedocs.org/projects/pynsot/badge/?version=latest&style=flat 18 | :target: https://readthedocs.org/projects/pynsot/?badge=latest 19 | :width: 76px 20 | :height: 20px 21 | .. |PyPI Status| image:: https://img.shields.io/pypi/v/pynsot.svg?style=flat 22 | :target: https://pypi.python.org/pypi/pynsot 23 | :width: 68px 24 | :height: 20px 25 | 26 | **Table of Contents**: 27 | 28 | .. contents:: 29 | :local: 30 | :depth: 2 31 | 32 | Installation 33 | ============ 34 | 35 | Assuming you've got Python 2.7 and ``pip``, all you have to do is: 36 | 37 | .. code-block:: bash 38 | 39 | $ pip install pynsot 40 | 41 | We realize that this might not work for you. More detailed install instructions 42 | are Coming Soon™. 43 | 44 | Quick Start 45 | =========== 46 | 47 | How do you use it? Here are some basic examples to get you started. 48 | 49 | .. important:: 50 | These examples assume you've already installed and configured ``pynsot``. 51 | For a detailed walkthrough, please visit :doc:`config` and then head over 52 | to the :doc:`cli` docs. 53 | 54 | Create a Site 55 | ------------- 56 | 57 | Sites are namespaces for all other objects. Before you can do anything you'll 58 | need a Site: 59 | 60 | .. code-block:: bash 61 | 62 | $ nsot sites add --name 'My Site' 63 | 64 | These examples also assume the use of a ``default_site`` so that you don't have 65 | to provide the ``-s/--site-id`` argument on every query. If this is your only 66 | site, just add ``default_site = 1`` to your ``pynsotrc`` file. 67 | 68 | If you're throughoughly lost already, check out the :ref:`example_config`. 69 | 70 | CLI Example 71 | ----------- 72 | 73 | Here's an example of a few basic CLI lookups after adding 74 | several networks: 75 | 76 | .. code-block:: bash 77 | 78 | # Add a handful of networks 79 | $ nsot networks add -c 192.168.0.0/16 -a owner=jathan 80 | $ nsot networks add -c 192.168.0.0/24 81 | $ nsot networks add -c 192.168.0.0/25 82 | $ nsot networks add -c 192.168.0.1/32 83 | $ nsot networks add -c 172.16.0.0/12 84 | $ nsot networks add -c 10.0.0.0/24 85 | $ nsot networks add -c 10.1.0.0/24 86 | 87 | # And start looking them up! 88 | $ nsot networks list 89 | +-------------------------------------------------------------------------+ 90 | | ID Network Prefix Is IP? IP Ver. Parent ID Attributes | 91 | +-------------------------------------------------------------------------+ 92 | | 1 192.168.0.0 16 False 4 None owner=jathan | 93 | | 2 10.0.0.0 16 False 4 None owner=jathan | 94 | | 3 172.16.0.0 12 False 4 None | 95 | | 4 10.0.0.0 24 False 4 2 | 96 | | 5 10.1.0.0 24 False 4 2 | 97 | +-------------------------------------------------------------------------+ 98 | 99 | $ nsot networks list --include-ips 100 | +-------------------------------------------------------------------------+ 101 | | ID Network Prefix Is IP? IP Ver. Parent ID Attributes | 102 | +-------------------------------------------------------------------------+ 103 | | 1 192.168.0.0 16 False 4 None owner=jathan | 104 | | 2 10.0.0.0 16 False 4 None owner=jathan | 105 | | 3 172.16.0.0 12 False 4 None | 106 | | 4 10.0.0.0 24 False 4 2 | 107 | | 5 10.1.0.0 24 False 4 2 | 108 | | 6 192.168.0.1 32 True 4 1 | 109 | +-------------------------------------------------------------------------+ 110 | 111 | $ nsot networks list --include-ips --no-include-networks 112 | +-----------------------------------------------------------------------+ 113 | | ID Network Prefix Is IP? IP Ver. Parent ID Attributes | 114 | +-----------------------------------------------------------------------+ 115 | | 6 192.168.0.1 32 True 4 1 | 116 | +-----------------------------------------------------------------------+ 117 | 118 | $ nsot networks list --cidr 192.168.0.0/16 subnets 119 | +-----------------------------------------------------------------------+ 120 | | ID Network Prefix Is IP? IP Ver. Parent ID Attributes | 121 | +-----------------------------------------------------------------------+ 122 | | 6 192.168.0.0 24 False 4 1 | 123 | | 7 192.168.0.0 25 False 4 6 | 124 | +-----------------------------------------------------------------------+ 125 | 126 | $ nsot networks list -c 192.168.0.0/24 supernets 127 | +-------------------------------------------------------------------------+ 128 | | ID Network Prefix Is IP? IP Ver. Parent ID Attributes | 129 | +-------------------------------------------------------------------------+ 130 | | 1 192.168.0.0 16 False 4 None owner=jathan | 131 | | cluster= | 132 | | foo=baz | 133 | +-------------------------------------------------------------------------+ 134 | 135 | API Example 136 | ----------- 137 | 138 | And for the Python API? Run some Python! 139 | 140 | If you want a more detailed walkthrough, check out the :doc:`python_api` guide. 141 | 142 | .. code-block:: python 143 | 144 | from pynsot.client import get_api_client 145 | 146 | # get_api_client() is a magical function that returns the proper client 147 | # according to your ``pynsotrc`` configuration 148 | c = get_api_client() 149 | nets = c.sites(1).networks.get() 150 | subnets = c.sites(1).networks('192.168.0.0/16').subnets.get() 151 | supernets = c.sites(1).networks('192.168.0.0/24').supernets.get() 152 | 153 | Documentation 154 | ============= 155 | 156 | .. toctree:: 157 | :maxdepth: 2 158 | 159 | config 160 | cli 161 | auth 162 | python_api 163 | 164 | API Reference 165 | ============= 166 | 167 | If you are looking for information on a specific function, class, or 168 | method, go forward. 169 | 170 | .. toctree:: 171 | :maxdepth: 2 172 | 173 | api 174 | 175 | Miscellaneous Pages 176 | =================== 177 | 178 | TBD 179 | 180 | .. toctree:: 181 | :maxdepth: 2 182 | 183 | changelog 184 | 185 | 186 | Logo_ by Vecteezy_ is licensed under `CC BY-SA 3.0`_ 187 | 188 | .. _Logo: https://www.iconfinder.com/icons/532251 189 | .. _Vecteezy: https://www.iconfinder.com/Vecteezy 190 | .. _CC BY-SA 3.0: http://creativecommons.org/licenses/by-sa/3.0/ 191 | -------------------------------------------------------------------------------- /pynsot/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .version import __version__ 3 | from . import client 4 | 5 | 6 | __all__ = ('__version__', 'client') 7 | -------------------------------------------------------------------------------- /pynsot/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/pynsot/34a6debd49c8f48f56527dec54e0be08dd5bd8a9/pynsot/commands/__init__.py -------------------------------------------------------------------------------- /pynsot/commands/callbacks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Callbacks used in handling command plugins. 5 | """ 6 | from __future__ import unicode_literals 7 | from __future__ import absolute_import 8 | import ast 9 | import click 10 | import csv 11 | import logging 12 | 13 | from six import string_types 14 | import six 15 | 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | # Objects that do not have attribtues 20 | NO_ATTRIBUTES = ('attributes',) 21 | 22 | 23 | def process_site_id(ctx, param, value): 24 | """ 25 | Callback to attempt to get site_id from ``~/.pynsotrc`` if it's not 26 | provided using -s/--site-id. 27 | """ 28 | log.debug('GOT DEFAULT_SITE: %s' % ctx.obj.api.default_site) 29 | log.debug('GOT PROVIDED SITE_ID: %s' % value) 30 | 31 | # Try to get site_id from the app config, or complain that it's not set. 32 | if value is None: 33 | default_site = ctx.obj.api.default_site 34 | if default_site is None: 35 | raise click.UsageError('Missing option "-s" / "--site-id".') 36 | value = default_site 37 | else: 38 | log.debug('Setting provided site_id as default_site.') 39 | ctx.obj.api.default_site = value 40 | return value 41 | 42 | 43 | def process_constraints(data, constraint_fields): 44 | """ 45 | Callback to move constrained fields from incoming data into a 'constraints' 46 | key. 47 | 48 | :param data: 49 | Incoming argument dict 50 | 51 | :param constraint_fields: 52 | Constrained fields to move into 'constraints' dict 53 | """ 54 | # Always use a list so that we can handle bulk operations 55 | objects = data if isinstance(data, list) else [data] 56 | 57 | # Enforce that pattern is a string. 58 | if data['pattern'] is None: 59 | data['pattern'] = '' 60 | 61 | for obj in objects: 62 | constraints = {} 63 | for c_field in constraint_fields: 64 | try: 65 | c_value = obj.pop(c_field) 66 | except KeyError: 67 | continue 68 | else: 69 | # If the value is not set, translate it to False 70 | if c_value is None: 71 | c_value = False 72 | constraints[c_field] = c_value 73 | obj['constraints'] = constraints 74 | return data 75 | 76 | 77 | def transform_attributes(ctx, param, value): 78 | """Callback to turn attributes arguments into a dict.""" 79 | attrs = {} 80 | log.debug('TRANSFORM_ATTRIBUTES [IN]: %r' % (value,)) 81 | 82 | # If this is a simple string, make it a list. 83 | if isinstance(value, string_types): 84 | value = [value] 85 | 86 | # Flatten the attributes using a set to eliminate any duplicates. 87 | items = set(value) 88 | 89 | # Prepare the context object for storing attribute actions 90 | parent = ctx.find_root() 91 | if not hasattr(parent, '_attributes'): 92 | parent._attributes = [] 93 | 94 | for attr in items: 95 | key, _, val = attr.partition('=') 96 | if not key: 97 | msg = 'Invalid attribute: %s; format should be key=value' % (attr,) 98 | raise click.UsageError(msg) 99 | 100 | # Cast integers to strings (fix #24) 101 | if isinstance(val, int): 102 | val = str(val) 103 | 104 | log.debug(' name = %r', key) 105 | log.debug('value = %r', val) 106 | parent._attributes.append((key, val)) 107 | attrs[key] = val 108 | 109 | log.debug('TRANSFORM_ATTRIBUTES [OUT]: %r' % (attrs,)) 110 | return attrs 111 | 112 | 113 | def transform_event(ctx, param, value): 114 | """Callback to transform event into title case.""" 115 | if value is not None: 116 | return value.title() 117 | return value 118 | 119 | 120 | def transform_resource_name(ctx, param, value): 121 | """Callback to transform resource_name into title case.""" 122 | if value is not None: 123 | return value.title() 124 | return value 125 | 126 | 127 | def process_bulk_add(ctx, param, value): 128 | """ 129 | Callback to parse bulk addition of objects from a colon-delimited file. 130 | 131 | Format: 132 | 133 | + The first line of the file must be the field names. 134 | + Attribute pairs must be commma-separated, and in format k=v 135 | + The attributes must exist! 136 | """ 137 | if value is None: 138 | return value 139 | 140 | # This is our object name (e.g. 'devices') 141 | parent_resource_name = ctx.obj.parent_resource_name 142 | objects = [] 143 | 144 | # Value is already an open file handle 145 | reader = csv.DictReader(value, delimiter=b':') 146 | for row in reader: 147 | lineno = reader.line_num 148 | 149 | # Make sure the file is correctly formatted. 150 | if len(row) != len(reader.fieldnames): 151 | msg = 'File has wrong number of fields on line %d' % (lineno,) 152 | raise click.BadParameter(msg) 153 | 154 | # Transform attributes for eligible resource types 155 | if parent_resource_name not in NO_ATTRIBUTES: 156 | # FIXME(jathan): This IS going to break at some point. We need to 157 | # considered how to take in complex a/v pairs in this context. 158 | # Naively split on ',' for now. 159 | incoming_attributes = row['attributes'].split(',') 160 | outgoing_attributes = transform_attributes( 161 | ctx, param, incoming_attributes 162 | ) 163 | row['attributes'] = outgoing_attributes 164 | 165 | # Transform True, False into booleans 166 | log.debug('FILE ROW: %r', row) 167 | for key, val in six.iteritems(row): 168 | # Don't evaluate dicts 169 | if isinstance(val, dict): 170 | continue 171 | 172 | # Evaluate strings and if they are booleans, convert them. 173 | if not isinstance(val, string_types): 174 | msg = 'Error parsing file on line %d' % (lineno,) 175 | raise click.BadParameter(msg) 176 | if val.title() in ('True', 'False'): 177 | row[key] = ast.literal_eval(val) 178 | objects.append(row) 179 | 180 | log.debug('PARSED BULK DATA = %r' % (objects,)) 181 | 182 | # Return a list of dicts 183 | return objects 184 | 185 | 186 | def get_resource_by_natural_key(ctx, data, resource_name, resource=None): 187 | """ 188 | Attempt to return the reource_id for an object. 189 | 190 | :param ctx: 191 | Context from the calling command 192 | 193 | :param data: 194 | Query parameters used for object lookup 195 | 196 | :param resource_name: 197 | The API resource name (for display) 198 | 199 | :param resource: 200 | The API resource client object 201 | """ 202 | resource_id = None 203 | obj = None 204 | 205 | # Look up the object by natural key (e.g. cidr) 206 | obj = ctx.obj.get_single_object(data, resource=resource) 207 | 208 | # If the object was found, get its id 209 | if obj is not None: 210 | resource_id = obj['id'] 211 | 212 | # If it's not found, error out. 213 | if resource_id is None: 214 | raise click.UsageError( 215 | 'No matching %s found; try lookup using option "-i" / "--id".' % 216 | (resource_name,) 217 | ) 218 | 219 | return resource_id 220 | 221 | 222 | def list_subcommand(ctx, display_fields=None, my_name=None, grep_name=None, 223 | with_parent=True, return_results=False): 224 | """ 225 | Determine params and a resource object to pass to ``ctx.obj.list()`` 226 | 227 | This is used for mapping sub-commands to nested API resources. 228 | 229 | For example:: 230 | 231 | nsot networks list -s 1 -c 10.0.0.0/8 subnets 232 | 233 | Would be mapped to:: 234 | 235 | GET /api/sites/1/networks/5/subnets/ 236 | 237 | If ``return_results`` is set, ``data`` will be to ``ctx.obj.detail()`` 238 | and the results will be directly returned instead. 239 | 240 | :param ctx: 241 | Context from the calling command 242 | 243 | :param display_fields: 244 | Display fields used to list object results. 245 | 246 | :param my_name: 247 | (Optional) Overload the App's resource_name to match this value 248 | 249 | :param grep_name: 250 | (Optional) Overload the App's grep_name to match this value 251 | 252 | :param with_parent: 253 | Whether to treat the nested lookup as a detail view (default) using the 254 | parent resource id, or as a list view without an id lookup. 255 | 256 | :param return_results: 257 | Whether to pass the results to ``list()`` or pass them to ``detail()`` 258 | and return them. 259 | """ 260 | if display_fields is None and not return_results: 261 | raise SyntaxError( 262 | 'Display fields must be provided when not returning results.' 263 | ) 264 | 265 | # Gather our args from our parent and ourself 266 | data = ctx.parent.params 267 | data.update(ctx.params) 268 | 269 | # This will be used for resource lookup for detail routes. 270 | parent_resource_id = data.pop('id') 271 | 272 | # Prepare the app and rebase the API to include site_id. 273 | app = ctx.obj 274 | app.rebase(data) 275 | 276 | # Use our name, parent's command name, and the API object to retrieve the 277 | # endpoint resource used to call this endpoint. 278 | parent_resource_name = app.parent_resource_name # e.g. 'networks' 279 | 280 | # If we've provided my_name, overload the App's resource_name to match 281 | # it. 282 | if my_name is None: 283 | my_name = ctx.info_name # e.g. 'supernets' 284 | 285 | # e.g. /api/sites/1/networks/ 286 | parent_resource = getattr(app.api, parent_resource_name) 287 | 288 | # Make sure that parent_resource_id is provided. This seems complicated 289 | # because we want to maintain dynamism across resource types. 290 | if with_parent: 291 | if parent_resource_id is None: 292 | if data.get('query'): 293 | # Enforce that we get a single item back from the query, the 294 | # server will return a non-2xx response on != 1 items returned, 295 | # which we surface as an error to the user 296 | data['unique'] = True 297 | 298 | parent_resource_id = ctx.obj.set_query( 299 | data, resource=parent_resource)[0]['id'] 300 | else: 301 | parent_resource_id = get_resource_by_natural_key( 302 | ctx, data, parent_resource_name, parent_resource 303 | ) 304 | 305 | # e.g. /api/sites/1/networks/5/supernets/ 306 | my_resource = getattr(parent_resource(parent_resource_id), my_name) 307 | 308 | # Otherwise, just treat this as a list endpoint with no parent lookup. 309 | else: 310 | my_resource = getattr(parent_resource, my_name) 311 | 312 | app.resource_name = my_name 313 | app.grep_name = grep_name or my_name 314 | 315 | # If return_results, just pass data to my_resource.get() and return 316 | # whatever comes back. 317 | if return_results: 318 | return app.detail(data, resource=my_resource) 319 | 320 | app.list(data, display_fields=display_fields, resource=my_resource) 321 | -------------------------------------------------------------------------------- /pynsot/commands/cmd_attributes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Sub-command for Attributes. 5 | 6 | In all cases ``data = ctx.params`` when calling the appropriate action method 7 | on ``ctx.obj``. (e.g. ``ctx.obj.add(ctx.params)``) 8 | 9 | Also, ``action = ctx.info_name`` *might* reliably contain the name of the 10 | action function, but still not sure about that. If so, every function could be 11 | fundamentally simplified to this:: 12 | 13 | getattr(ctx.obj, ctx.info_name)(ctx.params) 14 | """ 15 | from __future__ import unicode_literals 16 | from __future__ import absolute_import 17 | import click 18 | import logging 19 | 20 | from six import string_types 21 | from . import callbacks 22 | 23 | 24 | log = logging.getLogger(__name__) 25 | 26 | # Ordered list of 2-tuples of (field, display_name) used to translate object 27 | # field names oto their human-readable form when calling .print_list(). 28 | DISPLAY_FIELDS = ( 29 | ('id', 'ID'), 30 | ('name', 'Name'), 31 | ('resource_name', 'Resource'), 32 | ('required', 'Required?'), 33 | ('multi', 'Multi?'), 34 | ('description', 'Description'), 35 | ) 36 | 37 | # Fields to display when viewing a single record. 38 | VERBOSE_FIELDS = ( 39 | ('id', 'ID'), 40 | ('name', 'Name'), 41 | ('resource_name', 'Resource'), 42 | ('required', 'Required?'), 43 | ('display', 'Display?'), 44 | ('multi', 'Multi?'), 45 | ('constraints', 'Constraints'), 46 | ('description', 'Description'), 47 | ) 48 | 49 | # Sub-fields for Attribute constraints 50 | CONSTRAINT_FIELDS = ('allow_empty', 'pattern', 'valid_values') 51 | 52 | 53 | # Main group 54 | @click.group() 55 | @click.pass_context 56 | def cli(ctx): 57 | """ 58 | Attribute objects. 59 | 60 | Attributes are arbitrary key/value pairs that can be assigned to various 61 | resources. If an attribute is required then additions/updates for that 62 | resource will require that attribute be present. Existing resources will 63 | not be forcefully validated until update. 64 | """ 65 | 66 | 67 | # Add 68 | @cli.command() 69 | @click.option( 70 | '--allow-empty', 71 | help='Constrain whether to allow this Attribute to have an empty value.', 72 | is_flag=True, 73 | ) 74 | @click.option( 75 | '-b', 76 | '--bulk-add', 77 | metavar='FILENAME', 78 | help='Bulk add Attributes from the specified colon-delimited file.', 79 | type=click.File('rb'), 80 | callback=callbacks.process_bulk_add, 81 | ) 82 | @click.option( 83 | '-d', 84 | '--description', 85 | default='', 86 | metavar='DESC', 87 | help='A helpful description of the Attribute.', 88 | ) 89 | @click.option( 90 | '--display', 91 | help='Whether this Attribute should be be displayed by default in UIs.', 92 | is_flag=True, 93 | ) 94 | @click.option( 95 | '--multi', 96 | help='Whether the attribute should be treated as a list type.', 97 | is_flag=True, 98 | ) 99 | @click.option( 100 | '-n', 101 | '--name', 102 | metavar='NAME', 103 | help='The name of the Attribute. [required]', 104 | ) 105 | @click.option( 106 | '-p', 107 | '--pattern', 108 | help='Constrain attribute values to this regex pattern.', 109 | default=None, 110 | ) 111 | @click.option( 112 | '--required', 113 | help='Whether this Attribute should be required.', 114 | is_flag=True, 115 | ) 116 | @click.option( 117 | '-r', 118 | '--resource-name', 119 | metavar='RESOURCE', 120 | help='The resource type this Attribute is for (e.g. Device). [required]', 121 | callback=callbacks.transform_resource_name, 122 | ) 123 | @click.option( 124 | '-s', 125 | '--site-id', 126 | metavar='SITE_ID', 127 | type=int, 128 | help='Unique ID of the Site this Attribute is under. [required]', 129 | callback=callbacks.process_site_id, 130 | ) 131 | @click.option( 132 | '-V', 133 | '--valid-values', 134 | metavar='VALUE', 135 | help=( 136 | 'Constrain valid values for this Attribute. May be specified ' 137 | 'multiple times.' 138 | ), 139 | multiple=True, 140 | ) 141 | @click.pass_context 142 | def add(ctx, allow_empty, bulk_add, description, display, multi, name, pattern, 143 | resource_name, required, valid_values, site_id): 144 | """ 145 | Add a new Attribute. 146 | 147 | You must provide a Site ID using the -s/--site-id option. 148 | 149 | When adding a new Attribute, you must provide a value for the -n/--name 150 | and -r/--resource-name options. 151 | """ 152 | data = bulk_add or ctx.params 153 | 154 | # Enforce required options 155 | if bulk_add is None: 156 | if name is None: 157 | raise click.UsageError('Missing option "-n" / "--name".') 158 | if resource_name is None: 159 | raise click.UsageError('Missing option "-r" / "--resource-name".') 160 | 161 | # Handle the constraint fields 162 | data = callbacks.process_constraints( 163 | data, constraint_fields=CONSTRAINT_FIELDS 164 | ) 165 | ctx.obj.add(data) 166 | 167 | 168 | # List 169 | @cli.command() 170 | @click.option( 171 | '-i', 172 | '--id', 173 | metavar='ID', 174 | help='Unique ID of the Attribute being retrieved.', 175 | ) 176 | @click.option( 177 | '--display/--no-display', 178 | help='Filter to Attributes meant to be displayed.', 179 | default=None, 180 | ) 181 | @click.option( 182 | '-l', 183 | '--limit', 184 | metavar='LIMIT', 185 | type=int, 186 | help='Limit result to N resources.', 187 | ) 188 | @click.option( 189 | '--multi/--no-multi', 190 | help='Filter on whether Attributes are list type or not.', 191 | default=None, 192 | ) 193 | @click.option( 194 | '-n', 195 | '--name', 196 | metavar='NAME', 197 | help='Filter to Attribute with this name.', 198 | ) 199 | @click.option( 200 | '-N', 201 | '--natural-key', 202 | is_flag=True, 203 | help='Display list results by their natural key', 204 | default=False, 205 | show_default=True, 206 | ) 207 | @click.option( 208 | '-o', 209 | '--offset', 210 | metavar='OFFSET', 211 | type=int, 212 | help='Skip the first N resources.', 213 | ) 214 | @click.option( 215 | '--required/--no-required', 216 | help='Filter to Attributes that are required.', 217 | default=None, 218 | ) 219 | @click.option( 220 | '-r', 221 | '--resource-name', 222 | metavar='RESOURCE', 223 | help='Filter to Attributes for a specific resource (e.g. Network)', 224 | callback=callbacks.transform_resource_name, 225 | ) 226 | @click.option( 227 | '-s', 228 | '--site-id', 229 | metavar='SITE_ID', 230 | type=int, 231 | help='Unique ID of the Site this Attribute is under. [required]', 232 | callback=callbacks.process_site_id, 233 | ) 234 | @click.pass_context 235 | def list(ctx, id, display, limit, multi, name, natural_key, offset, required, 236 | resource_name, site_id): 237 | """ 238 | List existing Attributes for a Site. 239 | 240 | You must provide a Site ID using the -s/--site-id option. 241 | 242 | When listing Attributes, all objects are displayed by default. You may 243 | optionally lookup a single Attribute by name using the -n/--name option or 244 | by ID using the -i/--id option. 245 | 246 | You may limit the number of results using the -l/--limit option. 247 | """ 248 | data = ctx.params 249 | 250 | # If we provide ID, be verbose 251 | if id is not None or all([name, resource_name]): 252 | display_fields = VERBOSE_FIELDS 253 | else: 254 | display_fields = DISPLAY_FIELDS 255 | 256 | ctx.obj.list( 257 | data, display_fields=display_fields, verbose_fields=VERBOSE_FIELDS 258 | ) 259 | 260 | 261 | # Remove 262 | @cli.command() 263 | @click.option( 264 | '-i', 265 | '--id', 266 | metavar='ID', 267 | type=int, 268 | help='Unique ID of the Attribute being deleted.', 269 | required=True, 270 | ) 271 | @click.option( 272 | '-s', 273 | '--site-id', 274 | metavar='SITE_ID', 275 | type=int, 276 | help='Unique ID of the Site this Attribute is under. [required]', 277 | callback=callbacks.process_site_id, 278 | ) 279 | @click.pass_context 280 | def remove(ctx, id, site_id): 281 | """ 282 | Remove an Attribute. 283 | 284 | You must provide a Site ID using the -s/--site-id option. 285 | 286 | When removing an Attribute, you must provide the unique ID using -i/--id. 287 | You may retrieve the ID for an Attribute by looking it up by name for a 288 | given Site: 289 | 290 | nsot attributes list --name --site 291 | """ 292 | data = ctx.params 293 | ctx.obj.remove(**data) 294 | 295 | 296 | # Update 297 | @cli.command() 298 | @click.option( 299 | '--allow-empty/--no-allow-empty', 300 | help='Constrain whether to allow this Attribute to have an empty value.', 301 | default=None, 302 | ) 303 | @click.option( 304 | '-d', 305 | '--description', 306 | metavar='DESC', 307 | help='A helpful description of the Attribute.', 308 | ) 309 | @click.option( 310 | '--display/--no-display', 311 | help='Whether this Attribute should be be displayed by default in UIs.', 312 | default=None, 313 | ) 314 | @click.option( 315 | '-i', 316 | '--id', 317 | metavar='ID', 318 | type=int, 319 | help='Unique ID of the Attribute being updated.', 320 | ) 321 | @click.option( 322 | '--multi/--no-multi', 323 | help='Whether the Attribute should be treated as a list type.', 324 | default=None, 325 | ) 326 | @click.option( 327 | '-n', 328 | '--name', 329 | metavar='NAME', 330 | help='The name of the Attribute.', 331 | ) 332 | @click.option( 333 | '-p', 334 | '--pattern', 335 | help='Constrain attribute values to this regex pattern.', 336 | default=None, 337 | ) 338 | @click.option( 339 | '--required/--no-required', 340 | help='Whether this Attribute should be required.', 341 | default=None, 342 | ) 343 | @click.option( 344 | '-r', 345 | '--resource-name', 346 | metavar='RESOURCE', 347 | help='The resource type this Attribute is for (e.g. Device).', 348 | callback=callbacks.transform_resource_name, 349 | ) 350 | @click.option( 351 | '-s', 352 | '--site-id', 353 | metavar='SITE_ID', 354 | type=int, 355 | help='Unique ID of the Site this Attribute is under. [required]', 356 | callback=callbacks.process_site_id, 357 | ) 358 | @click.option( 359 | '-V', 360 | '--valid-values', 361 | metavar='VALUE', 362 | help=( 363 | 'Constrain valid values for this Attribute. May be specified ' 364 | 'multiple times.' 365 | ), 366 | multiple=True, 367 | ) 368 | @click.pass_context 369 | def update(ctx, allow_empty, description, display, id, multi, name, pattern, 370 | required, resource_name, site_id, valid_values): 371 | """ 372 | Update an Attribute. 373 | 374 | You must provide a Site ID using the -s/--site-id option. 375 | 376 | When updating an Attribute you may either provide the unique ID (-i/--id) 377 | OR you may provide the name (-n/--name) and resource_name 378 | (-r/--resource-name) together in lieu of ID to uniquely identify the 379 | attribute. You must also provide at least one of the optional arguments. 380 | 381 | The values for name (-n/--name) and resource_name (-r/--resource-name) 382 | cannot be updated and are used for object lookup only. 383 | 384 | If any of the constraints are supplied (--allow-empty/--no-allow-empty, 385 | -p/--pattern, -v/--valid-values) ALL constraint values will be initialized 386 | unless explicitly provided. (This is currently a limitation in the server 387 | API that will be addressed in a future release.) 388 | """ 389 | # If name or resource_name are provided, both must be provided 390 | if (name and not resource_name) or (resource_name and not name): 391 | raise click.UsageError( 392 | '-n/--name and -r/--resource-name must be provided together.' 393 | ) 394 | 395 | # Otherwise ID must be provided. 396 | elif (not name and not resource_name) and not id: 397 | raise click.UsageError('Missing option "-i" / "--id".') 398 | 399 | # One of the optional arguments must be provided (non-False) to proceed. 400 | optional_args = ('allow_empty', 'description', 'display', 'multi', 401 | 'pattern', 'required', 'valid_values') 402 | provided = [] 403 | for opt in optional_args: 404 | val = ctx.params[opt] 405 | 406 | # If a flag is provided or a param is a string (e.g. pattern), or if 407 | # it's a tuple with 1 or more item, it's been provided. 408 | if any([ 409 | val in (True, False), 410 | isinstance(val, string_types), 411 | (isinstance(val, tuple) and len(val) > 0) 412 | ]): 413 | provided.append(opt) 414 | 415 | # If none of them were provided, complain. 416 | if not provided: 417 | raise click.UsageError( 418 | 'You must supply at least one the optional arguments.' 419 | ) 420 | 421 | # Only update the constraints if any of them are updated. Otherwise leave 422 | # them alone. 423 | if any([ 424 | allow_empty is not None, 425 | isinstance(pattern, string_types), 426 | valid_values 427 | ]): 428 | log.debug('Constraint field provided; updating constraints...') 429 | data = callbacks.process_constraints( 430 | ctx.params, constraint_fields=CONSTRAINT_FIELDS 431 | ) 432 | else: 433 | data = ctx.params 434 | 435 | ctx.obj.update(data) 436 | -------------------------------------------------------------------------------- /pynsot/commands/cmd_changes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Sub-command for Changes. 5 | 6 | In all cases ``data = ctx.params`` when calling the appropriate action method 7 | on ``ctx.obj``. (e.g. ``ctx.obj.add(ctx.params)``) 8 | 9 | Also, ``action = ctx.info_name`` *might* reliably contain the name of the 10 | action function, but still not sure about that. If so, every function could be 11 | fundamentally simplified to this:: 12 | 13 | getattr(ctx.obj, ctx.info_name)(ctx.params) 14 | """ 15 | 16 | from __future__ import unicode_literals 17 | 18 | from __future__ import absolute_import 19 | import click 20 | from . import callbacks 21 | 22 | 23 | __author__ = 'Jathan McCollum' 24 | __maintainer__ = 'Jathan McCollum' 25 | __email__ = 'jathan@dropbox.com' 26 | __copyright__ = 'Copyright (c) 2015 Dropbox, Inc.' 27 | 28 | 29 | # Ordered list of 2-tuples of (field, display_name) used to translate object 30 | # field names oto their human-readable form when calling .print_list(). 31 | DISPLAY_FIELDS = ( 32 | ('id', 'ID'), 33 | ('change_at', 'Change At'), 34 | ('user', 'User'), 35 | ('event', 'Event'), 36 | ('resource_name', 'Resource'), 37 | ('resource_id', 'Obj'), 38 | # ('site_id', 'Site ID'), 39 | # ('site', 'Site'), 40 | ) 41 | 42 | # Fields to display when viewing a single record. 43 | VERBOSE_FIELDS = ( 44 | ('change_at', 'Change At'), 45 | ('user', 'User'), 46 | ('event', 'Event'), 47 | ('resource_name', 'Resource'), 48 | ('resource_id', 'ID'), 49 | ('resource', 'Data'), 50 | ) 51 | 52 | 53 | # Main group 54 | @click.group() 55 | @click.pass_context 56 | def cli(ctx): 57 | """ 58 | Change events. 59 | 60 | All add, remove, and update events are logged as a Change. A Change 61 | includes information such as the change time, user, and the full resource 62 | after modification. Changes are immutable and can only be removed by 63 | deleting the entire Site. 64 | """ 65 | 66 | 67 | # List 68 | @cli.command() 69 | @click.option( 70 | '-e', 71 | '--event', 72 | metavar='EVENT', 73 | help='Filter result to specific event.', 74 | callback=callbacks.transform_event, 75 | ) 76 | @click.option( 77 | '-i', 78 | '--id', 79 | metavar='ID', 80 | type=int, 81 | help='Unique ID of the Change being retrieved.', 82 | ) 83 | @click.option( 84 | '-l', 85 | '--limit', 86 | metavar='LIMIT', 87 | type=int, 88 | help='Limit result to N resources.', 89 | ) 90 | @click.option( 91 | '-o', 92 | '--offset', 93 | metavar='OFFSET', 94 | type=int, 95 | help='Skip the first N resources.', 96 | ) 97 | @click.option( 98 | '-R', 99 | '--resource-id', 100 | metavar='RESOURCE_ID', 101 | type=int, 102 | help='Filter to Changes for a specific resource ID (e.g. Network ID 1)', 103 | ) 104 | @click.option( 105 | '-r', 106 | '--resource-name', 107 | metavar='RESOURCE_NAME', 108 | help='Filter to Changes for a specific resource name (e.g. Network)', 109 | callback=callbacks.transform_resource_name, 110 | ) 111 | @click.option( 112 | '-s', 113 | '--site-id', 114 | metavar='SITE_ID', 115 | type=int, 116 | help='Unique ID of the Site this Change is under. [required]', 117 | callback=callbacks.process_site_id, 118 | ) 119 | @click.pass_context 120 | def list(ctx, event, id, limit, offset, resource_id, resource_name, site_id): 121 | """ 122 | List Change events for a Site. 123 | 124 | You must provide a Site ID using the -s/--site-id option. 125 | 126 | When listing Changes, all events are displayed by default. You may 127 | optionally lookup a single Change by ID using the -i/--id option. 128 | 129 | You may limit the number of results using the -l/--limit option. 130 | """ 131 | data = ctx.params 132 | 133 | # If we provide ID, be verbose 134 | if id is not None: 135 | display_fields = VERBOSE_FIELDS 136 | else: 137 | display_fields = DISPLAY_FIELDS 138 | 139 | ctx.obj.list(data, display_fields=display_fields) 140 | -------------------------------------------------------------------------------- /pynsot/commands/cmd_circuits.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Sub-command for Circuits 5 | """ 6 | 7 | from __future__ import absolute_import, unicode_literals 8 | import click 9 | import logging 10 | 11 | from pynsot.util import slugify 12 | from . import callbacks, types 13 | from .cmd_networks import DISPLAY_FIELDS as NETWORK_DISPLAY_FIELDS 14 | from .cmd_interfaces import DISPLAY_FIELDS as INTERFACE_DISPLAY_FIELDS 15 | from .cmd_devices import DISPLAY_FIELDS as DEVICE_DISPLAY_FIELDS 16 | 17 | 18 | # Logger 19 | log = logging.getLogger(__name__) 20 | 21 | # Ordered list of 2-tuples of (field, display_name) used to translate object 22 | # field names oto their human-readable form when calling .print_list(). 23 | DISPLAY_FIELDS = ( 24 | ('id', 'ID'), 25 | ('name', 'Name (Key)'), 26 | ('endpoint_a', 'Endpoint A'), 27 | ('endpoint_z', 'Endpoint Z'), 28 | ('attributes', 'Attributes'), 29 | ) 30 | 31 | # Fields to display when viewing a single record. 32 | VERBOSE_FIELDS = DISPLAY_FIELDS 33 | 34 | 35 | # Main group 36 | @click.group() 37 | @click.pass_context 38 | def cli(ctx): 39 | """ 40 | Circuit objects. 41 | 42 | A Circuit resource represents a connection between two interfaces on a 43 | device. 44 | """ 45 | 46 | 47 | # Add 48 | # TODO(npegg): support specifying interfaces using the natural key instead of 49 | # by ID when the API supports that 50 | @cli.command() 51 | @click.option( 52 | '-a', 53 | '--attributes', 54 | metavar='ATTRS', 55 | help='A key/value pair attached to this Circuit (format: key=value).', 56 | multiple=True, 57 | callback=callbacks.transform_attributes, 58 | ) 59 | @click.option( 60 | '-A', 61 | '--endpoint-a', 62 | metavar='INTERFACE_ID', 63 | required=True, 64 | type=types.NATURAL_KEY, 65 | help='Unique ID or key of the interface of the A side of the Circuit', 66 | ) 67 | @click.option( 68 | '-n', 69 | '--name', 70 | metavar='NAME', 71 | type=str, 72 | help='The name of the Circuit.', 73 | ) 74 | @click.option( 75 | '-s', 76 | '--site-id', 77 | metavar='SITE_ID', 78 | type=int, 79 | help='Unique ID of the Site this Circuit is under. [required]', 80 | callback=callbacks.process_site_id, 81 | ) 82 | @click.option( 83 | '-Z', 84 | '--endpoint-z', 85 | metavar='INTERFACE_ID', 86 | type=types.NATURAL_KEY, 87 | help='Unique ID or key of the interface on the Z side of the Circuit', 88 | ) 89 | @click.pass_context 90 | def add(ctx, attributes, endpoint_a, name, site_id, endpoint_z): 91 | """ 92 | Add a new Circuit. 93 | 94 | You must provide the A side of the circuit using the -A/--endpoint-a 95 | option. The Z side is recommended but may be left blank, such as in cases 96 | where it is not an Interface that is tracked by NSoT (like a provider's 97 | interface). 98 | 99 | For the -A/--endpoint-a and -Z/--endpoint-z options, you may provide either 100 | the Interface ID or its natural key. 101 | 102 | The name (-n/--name) is optional. If it is not specified, it will be 103 | generated for you in the form of: 104 | {device_a}:{interface_a}_{device_z}:{interface_z} 105 | 106 | If you wish to add attributes, you may specify the -a/--attributes 107 | option once for each key/value pair. 108 | """ 109 | 110 | data = ctx.params 111 | 112 | # Remove empty values to facilitate default assignment 113 | if name is None: 114 | data.pop('name') 115 | if endpoint_z is None: 116 | data.pop('endpoint_z') 117 | 118 | ctx.obj.add(data) 119 | 120 | 121 | # List 122 | @cli.group(invoke_without_command=True) 123 | @click.option( 124 | '-a', 125 | '--attributes', 126 | metavar='ATTRS', 127 | help='Filter Circuits by matching attributes (format: key=value).', 128 | multiple=True, 129 | ) 130 | @click.option( 131 | '-A', 132 | '--endpoint-a', 133 | metavar='INTERFACE_ID', 134 | type=types.NATURAL_KEY, 135 | help='Filter to Circuits with endpoint_a interfaces that match this ID' 136 | ) 137 | @click.option( 138 | '-g', 139 | '--grep', 140 | is_flag=True, 141 | help='Display list results in a grep-friendly format.', 142 | default=False, 143 | show_default=True, 144 | ) 145 | @click.option( 146 | '-i', 147 | '--id', 148 | metavar='ID', 149 | type=types.NATURAL_KEY, 150 | help='Unique ID or natural key of the Circuit being retrieved.', 151 | ) 152 | @click.option( 153 | '-l', 154 | '--limit', 155 | metavar='LIMIT', 156 | help='Limit result to N resources.', 157 | ) 158 | @click.option( 159 | '-n', 160 | '--name', 161 | metavar='NAME', 162 | help='Filter to Circuits matching this name.', 163 | ) 164 | @click.option( 165 | '-N', 166 | '--natural-key', 167 | is_flag=True, 168 | help='Display list results by their natural key', 169 | default=False, 170 | show_default=True, 171 | ) 172 | @click.option( 173 | '-o', 174 | '--offset', 175 | metavar='OFFSET', 176 | help='Skip the first N resources.', 177 | ) 178 | @click.option( 179 | '-q', 180 | '--query', 181 | metavar='QUERY', 182 | help='Perform a set query using Attributes and output matching Circuits.' 183 | ) 184 | @click.option( 185 | '-s', 186 | '--site-id', 187 | metavar='SITE_ID', 188 | help='Unique ID of the Site this Circuit is under.', 189 | callback=callbacks.process_site_id, 190 | ) 191 | @click.option( 192 | '-Z', 193 | '--endpoint-z', 194 | metavar='INTERFACE_ID', 195 | type=types.NATURAL_KEY, 196 | help='Filter to Circuits with endpoint_z interfaces that match this ID' 197 | ) 198 | @click.pass_context 199 | def list(ctx, attributes, endpoint_a, endpoint_z, grep, id, limit, name, 200 | natural_key, offset, query, site_id): 201 | """ 202 | List existing Circuits for a Site. 203 | 204 | You must either have a Site ID configured in your .pysnotrc file or specify 205 | one using the -s/--site-id option. 206 | 207 | When listing Circuits, all objects are displayed by default. You optionally 208 | may look up a single Circuit by ID or Name using the -i/--id option. 209 | 210 | You may limit the number of results using the -l/--limit option. 211 | """ 212 | 213 | # If we get a name as an identifier, slugify it 214 | if ctx.params.get('id') and not ctx.params['id'].isdigit(): 215 | ctx.params['id'] = slugify(ctx.params['id']) 216 | 217 | # Don't list interfaces if a subcommand is invoked 218 | if ctx.invoked_subcommand is None: 219 | if query is not None: 220 | ctx.obj.natural_keys_by_query(ctx.params) 221 | else: 222 | ctx.obj.list(ctx.params, display_fields=DISPLAY_FIELDS) 223 | 224 | 225 | @list.command() 226 | @click.pass_context 227 | def addresses(ctx, *args, **kwargs): 228 | """ Show addresses for the Interfaces on this Circuit. """ 229 | 230 | callbacks.list_subcommand( 231 | ctx, display_fields=NETWORK_DISPLAY_FIELDS, my_name=ctx.info_name 232 | ) 233 | 234 | 235 | @list.command() 236 | @click.pass_context 237 | def devices(ctx, *args, **kwargs): 238 | """ Show Devices connected by this Circuit. """ 239 | 240 | callbacks.list_subcommand( 241 | ctx, display_fields=DEVICE_DISPLAY_FIELDS, my_name=ctx.info_name 242 | ) 243 | 244 | 245 | @list.command() 246 | @click.pass_context 247 | def interfaces(ctx, *args, **kwargs): 248 | """ Show Interfaces connected by this Circuit. """ 249 | 250 | callbacks.list_subcommand( 251 | ctx, display_fields=INTERFACE_DISPLAY_FIELDS, my_name=ctx.info_name 252 | ) 253 | 254 | 255 | # Update 256 | @cli.command() 257 | @click.option( 258 | '-a', 259 | '--attributes', 260 | metavar='ATTRS', 261 | help='A key/value pair attached to this Circuit (format: key=value).', 262 | multiple=True, 263 | callback=callbacks.transform_attributes, 264 | ) 265 | @click.option( 266 | '-A', 267 | '--endpoint-a', 268 | metavar='INTERFACE_ID', 269 | type=types.NATURAL_KEY, 270 | help='Unique ID or key of the interface of the A side of the Circuit', 271 | ) 272 | @click.option( 273 | '-i', 274 | '--id', 275 | metavar='ID', 276 | type=types.NATURAL_KEY, 277 | help='Unique ID or natural key of the Circuit being retrieved.', 278 | required=True, 279 | ) 280 | @click.option( 281 | '-n', 282 | '--name', 283 | metavar='NAME', 284 | type=str, 285 | help='The name of the Circuit.', 286 | ) 287 | @click.option( 288 | '-s', 289 | '--site-id', 290 | metavar='SITE_ID', 291 | type=int, 292 | help='Unique ID of the Site this Circuit is under. [required]', 293 | callback=callbacks.process_site_id, 294 | ) 295 | @click.option( 296 | '-Z', 297 | '--endpoint-z', 298 | metavar='INTERFACE_ID', 299 | type=types.NATURAL_KEY, 300 | help='Unique ID or key of the interface on the Z side of the Circuit', 301 | ) 302 | @click.option( 303 | '--add-attributes', 304 | 'attr_action', 305 | flag_value='add', 306 | default=True, 307 | help=( 308 | 'Causes attributes to be added. This is the default and providing it ' 309 | 'will have no effect.' 310 | ) 311 | ) 312 | @click.option( 313 | '--delete-attributes', 314 | 'attr_action', 315 | flag_value='delete', 316 | help=( 317 | 'Causes attributes to be deleted instead of updated. If combined with' 318 | 'with --multi the attribute will be deleted if either no value is ' 319 | 'provided, or if attribute no longer has an valid values.' 320 | ), 321 | ) 322 | @click.option( 323 | '--replace-attributes', 324 | 'attr_action', 325 | flag_value='replace', 326 | help=( 327 | 'Causes attributes to be replaced instead of updated. If combined ' 328 | 'with --multi, the entire list will be replaced.' 329 | ), 330 | ) 331 | @click.pass_context 332 | def update(ctx, attributes, endpoint_a, id, name, site_id, endpoint_z, 333 | attr_action): 334 | """ 335 | Update a Circuit. 336 | 337 | You must either have a Site ID configured in your .pysnotrc file or specify 338 | one using the -s/--site-id option. 339 | 340 | When updating a Circuit you must provide the ID (-i/--id) and at least 341 | one of the optional arguments. The ID can either be the numeric ID of the 342 | Circuit or the natural key. (Example: lax-r1:ae0_jfk-r2:ae0) 343 | 344 | For the -A/--endpoint-a and -Z/--endpoint-z options, you may provide either 345 | the Interface ID or its natural key. 346 | 347 | The -a/--attributes option may be provided multiple times, once for each 348 | key-value pair. 349 | 350 | When modifying attributes you have three actions to choose from: 351 | 352 | * Add (--add-attributes). This is the default behavior that will add 353 | attributes if they don't exist, or update them if they do. 354 | 355 | * Delete (--delete-attributes). This will cause attributes to be 356 | deleted. If combined with --multi the attribute will be deleted if 357 | either no value is provided, or if the attribute no longer contains a 358 | valid value. 359 | 360 | * Replace (--replace-attributes). This will cause attributes to 361 | replaced. If combined with --multi and multiple attributes of the same 362 | name are provided, only the last value provided will be used. 363 | """ 364 | 365 | # If we get a name as an identifier, slugify it 366 | if ctx.params.get('id') and not ctx.params['id'].isdigit(): 367 | ctx.params['id'] = slugify(ctx.params['id']) 368 | 369 | if not any([attributes, endpoint_a, name, endpoint_z]): 370 | msg = 'You must supply at least one of the optional arguments.' 371 | raise click.UsageError(msg) 372 | 373 | ctx.obj.update(ctx.params) 374 | 375 | 376 | # Remove 377 | @cli.command() 378 | @click.option( 379 | '-i', 380 | '--id', 381 | metavar='ID', 382 | help='Unique ID or natural key of the Circuit being deleted.', 383 | type=types.NATURAL_KEY, 384 | required=True, 385 | ) 386 | @click.option( 387 | '-s', 388 | '--site-id', 389 | metavar='SITE_ID', 390 | type=int, 391 | help='Unique ID of the Site this Circuit is under.', 392 | callback=callbacks.process_site_id, 393 | ) 394 | @click.pass_context 395 | def remove(ctx, id, site_id): 396 | """ 397 | Remove a Circuit. 398 | 399 | You must either have a Site ID configured in your .pysnotrc file or specify 400 | one using the -s/--site-id option. 401 | 402 | When removing Circuits, all objects are displayed by default. You 403 | optionally may look up a single Circuit by ID or Name using the -i/--id 404 | option. 405 | """ 406 | 407 | # If we get a name as an identifier, slugify it 408 | if ctx.params.get('id') and not ctx.params['id'].isdigit(): 409 | ctx.params['id'] = slugify(ctx.params['id']) 410 | 411 | ctx.obj.remove(**ctx.params) 412 | -------------------------------------------------------------------------------- /pynsot/commands/cmd_devices.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Sub-command for Devices. 5 | 6 | In all cases ``data = ctx.params`` when calling the appropriate action method 7 | on ``ctx.obj``. (e.g. ``ctx.obj.add(ctx.params)``) 8 | 9 | Also, ``action = ctx.info_name`` *might* reliably contain the name of the 10 | action function, but still not sure about that. If so, every function could be 11 | fundamentally simplified to this:: 12 | 13 | getattr(ctx.obj, ctx.info_name)(ctx.params) 14 | """ 15 | 16 | from __future__ import unicode_literals 17 | 18 | from __future__ import absolute_import 19 | import click 20 | from . import callbacks 21 | 22 | 23 | # Ordered list of 2-tuples of (field, display_name) used to translate object 24 | # field names oto their human-readable form when calling .print_list(). 25 | DISPLAY_FIELDS = ( 26 | ('id', 'ID'), 27 | ('hostname', 'Hostname (Key)'), 28 | # ('site_id': 'Site ID'), 29 | ('attributes', 'Attributes'), 30 | ) 31 | 32 | 33 | # Main group 34 | @click.group() 35 | @click.pass_context 36 | def cli(ctx): 37 | """ 38 | Device objects. 39 | 40 | A device represents various hardware components on your network such as 41 | routers, switches, console servers, PDUs, servers, etc. 42 | 43 | Devices also support arbitrary attributes similar to Networks. 44 | """ 45 | 46 | 47 | # Add 48 | @cli.command() 49 | @click.option( 50 | '-a', 51 | '--attributes', 52 | metavar='ATTRS', 53 | help='A key/value pair attached to this Device (format: key=value).', 54 | multiple=True, 55 | callback=callbacks.transform_attributes, 56 | ) 57 | @click.option( 58 | '-b', 59 | '--bulk-add', 60 | metavar='FILENAME', 61 | help='Bulk add Devices from the specified colon-delimited file.', 62 | type=click.File('rb'), 63 | callback=callbacks.process_bulk_add, 64 | ) 65 | @click.option( 66 | '-H', 67 | '--hostname', 68 | metavar='HOSTNAME', 69 | help='The hostname of the Device. [required]', 70 | ) 71 | @click.option( 72 | '-s', 73 | '--site-id', 74 | metavar='SITE_ID', 75 | type=int, 76 | help='Unique ID of the Site this Device is under. [required]', 77 | callback=callbacks.process_site_id, 78 | ) 79 | @click.pass_context 80 | def add(ctx, attributes, bulk_add, hostname, site_id): 81 | """ 82 | Add a new Device. 83 | 84 | You must provide a Site ID using the -s/--site-id option. 85 | 86 | When adding a new Device, you must provide a value for the -H/--hostname 87 | option. 88 | 89 | If you wish to add attributes, you may specify the -a/--attributes 90 | option once for each key/value pair. 91 | """ 92 | data = bulk_add or ctx.params 93 | 94 | # Enforce required options 95 | if bulk_add is None: 96 | if hostname is None: 97 | raise click.UsageError('Missing option "-H" / "--hostname".') 98 | 99 | ctx.obj.add(data) 100 | 101 | 102 | # List 103 | # @cli.command() 104 | @cli.group(invoke_without_command=True) 105 | @click.option( 106 | '-a', 107 | '--attributes', 108 | metavar='ATTRS', 109 | help='Filter Devices by matching attributes (format: key=value).', 110 | multiple=True, 111 | ) 112 | @click.option( 113 | '-d', 114 | '--delimited', 115 | is_flag=True, 116 | help='Display set query results separated by commas vs. newlines.', 117 | default=False, 118 | show_default=True, 119 | ) 120 | @click.option( 121 | '-g', 122 | '--grep', 123 | is_flag=True, 124 | help='Display list results in a grep-friendly format.', 125 | default=False, 126 | show_default=True, 127 | ) 128 | @click.option( 129 | '-H', 130 | '--hostname', 131 | metavar='HOSTNAME', 132 | help='Filter by hostname of the Device.', 133 | ) 134 | @click.option( 135 | '-i', 136 | '--id', 137 | metavar='ID', 138 | type=int, 139 | help='Unique ID of the Device being retrieved.', 140 | ) 141 | @click.option( 142 | '-N', 143 | '--natural-key', 144 | is_flag=True, 145 | help='Display list results by their natural key', 146 | default=False, 147 | show_default=True, 148 | ) 149 | @click.option( 150 | '-l', 151 | '--limit', 152 | metavar='LIMIT', 153 | type=int, 154 | help='Limit result to N resources.', 155 | ) 156 | @click.option( 157 | '-o', 158 | '--offset', 159 | metavar='OFFSET', 160 | help='Skip the first N resources.', 161 | ) 162 | @click.option( 163 | '-q', 164 | '--query', 165 | metavar='QUERY', 166 | help='Perform a set query using Attributes and output matching hostnames.', 167 | ) 168 | @click.option( 169 | '-s', 170 | '--site-id', 171 | metavar='SITE_ID', 172 | type=int, 173 | help='Unique ID of the Site this Device is under. [required]', 174 | callback=callbacks.process_site_id, 175 | ) 176 | @click.pass_context 177 | def list(ctx, attributes, delimited, grep, hostname, id, limit, natural_key, 178 | offset, query, site_id): 179 | """ 180 | List existing Devices for a Site. 181 | 182 | You must provide a Site ID using the -s/--site-id option. 183 | 184 | When listing Devices, all objects are displayed by default. You may 185 | optionally lookup a single Device by ID using the -i/--id option. 186 | 187 | You may limit the number of results using the -l/--limit option. 188 | """ 189 | data = ctx.params 190 | data.pop('delimited') # We don't want this going to the server. 191 | 192 | if ctx.invoked_subcommand is None: 193 | if query is not None: 194 | ctx.obj.natural_keys_by_query(data, delimited) 195 | else: 196 | ctx.obj.list(data, display_fields=DISPLAY_FIELDS) 197 | 198 | 199 | @list.command() 200 | @click.pass_context 201 | def interfaces(ctx, *args, **kwargs): 202 | """Get interfaces for a Device.""" 203 | from .cmd_interfaces import DISPLAY_FIELDS as INTERFACE_DISPLAY_FIELDS 204 | callbacks.list_subcommand( 205 | ctx, display_fields=INTERFACE_DISPLAY_FIELDS, my_name='interfaces' 206 | ) 207 | 208 | 209 | # Remove 210 | @cli.command() 211 | @click.option( 212 | '-H', 213 | '--hostname', 214 | 'id', 215 | metavar='HOSTNAME', 216 | type=str, 217 | help='The hostname of the Device being deleted.', 218 | ) 219 | @click.option( 220 | '-i', 221 | '--id', 222 | metavar='ID', 223 | help='Unique ID of the Device being deleted.', 224 | required=True, 225 | ) 226 | @click.option( 227 | '-s', 228 | '--site-id', 229 | metavar='SITE_ID', 230 | type=int, 231 | help='Unique ID of the Site this Device is under. [required]', 232 | callback=callbacks.process_site_id, 233 | ) 234 | @click.pass_context 235 | def remove(ctx, id, site_id): 236 | """ 237 | Remove a Device. 238 | 239 | You must provide a Site ID using the -s/--site-id option. 240 | 241 | When removing a Device, you must either provide the unique ID using 242 | -i/--id, or the hostname of the Device using -H/--hostname. 243 | 244 | If both are provided, -H/--hostname will be ignored. 245 | """ 246 | data = ctx.params 247 | ctx.obj.remove(**data) 248 | 249 | 250 | # Update 251 | @cli.command() 252 | @click.option( 253 | '-a', 254 | '--attributes', 255 | metavar='ATTRS', 256 | help='A key/value pair attached to this network (format: key=value).', 257 | multiple=True, 258 | callback=callbacks.transform_attributes, 259 | ) 260 | @click.option( 261 | '-H', 262 | '--hostname', 263 | metavar='HOSTNAME', 264 | help='The hostname of the Device.', 265 | ) 266 | @click.option( 267 | '-i', 268 | '--id', 269 | metavar='ID', 270 | type=int, 271 | help='Unique ID of the Device being updated.', 272 | ) 273 | @click.option( 274 | '-s', 275 | '--site-id', 276 | metavar='SITE_ID', 277 | type=int, 278 | help='Unique ID of the Site this Device is under. [required]', 279 | callback=callbacks.process_site_id, 280 | ) 281 | @click.option( 282 | '--add-attributes', 283 | 'attr_action', 284 | flag_value='add', 285 | default=True, 286 | help=( 287 | 'Causes attributes to be added. This is the default and providing it ' 288 | 'will have no effect.' 289 | ) 290 | ) 291 | @click.option( 292 | '--delete-attributes', 293 | 'attr_action', 294 | flag_value='delete', 295 | help=( 296 | 'Causes attributes to be deleted instead of updated. If combined with' 297 | 'with --multi the attribute will be deleted if either no value is ' 298 | 'provided, or if attribute no longer has an valid values.' 299 | ), 300 | ) 301 | @click.option( 302 | '--replace-attributes', 303 | 'attr_action', 304 | flag_value='replace', 305 | help=( 306 | 'Causes attributes to be replaced instead of updated. If combined ' 307 | ' with --multi, the entire list will be replaced.' 308 | ), 309 | ) 310 | @click.option( 311 | '--multi', 312 | is_flag=True, 313 | help='Treat the specified attributes as a list type.', 314 | ) 315 | @click.pass_context 316 | def update(ctx, attributes, hostname, id, site_id, attr_action, multi): 317 | """ 318 | Update a Device. 319 | 320 | You must provide a Site ID using the -s/--site-id option. 321 | 322 | When updating a Device you must provide either the unique ID (-i/--id) or 323 | hostname (-H/--hostname) and at least one of the optional arguments. If 324 | -i/--id is provided -H/--hostname will be ignored. 325 | 326 | If you desire to update the hostname field, you must provide -i/--id to 327 | uniquely identify the Device. 328 | 329 | The -a/--attributes option may be provided multiple times, once for each 330 | key-value pair. 331 | 332 | When modifying attributes you have three actions to choose from: 333 | 334 | * Add (--add-attributes). This is the default behavior that will add 335 | attributes if they don't exist, or update them if they do. 336 | 337 | * Delete (--delete-attributes). This will cause attributes to be 338 | deleted. If combined with --multi the attribute will be deleted if 339 | either no value is provided, or if the attribute no longer contains a 340 | valid value. 341 | 342 | * Replace (--replace-attributes). This will cause attributes to 343 | replaced. If combined with --multi and multiple attributes of the same 344 | name are provided, only the last value provided will be used. 345 | """ 346 | if not any([attributes, hostname]): 347 | msg = 'You must supply at least one of the optional arguments.' 348 | raise click.UsageError(msg) 349 | 350 | if not id and not hostname: 351 | raise click.UsageError( 352 | 'You must provide -H/--hostname when not providing -i/--id.' 353 | ) 354 | 355 | data = ctx.params 356 | ctx.obj.update(data) 357 | -------------------------------------------------------------------------------- /pynsot/commands/cmd_protocol_types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Sub-command for Protocol Types. 5 | 6 | In all cases ``data = ctx.params`` when calling the appropriate action method 7 | on ``ctx.obj``. (e.g. ``ctx.obj.add(ctx.params)``) 8 | 9 | Also, ``action = ctx.info_name`` *might* reliably contain the name of the 10 | action function, but still not sure about that. If so, every function could be 11 | fundamentally simplified to this:: 12 | 13 | getattr(ctx.obj, ctx.info_name)(ctx.params) 14 | """ 15 | 16 | from __future__ import unicode_literals 17 | from __future__ import absolute_import 18 | import click 19 | import logging 20 | 21 | from . import callbacks, types 22 | 23 | # Logger 24 | log = logging.getLogger(__name__) 25 | 26 | # Ordered list of 2-tuples of (field, display_name) used to translate object 27 | # field names oto their human-readable form when calling .print_list(). 28 | DISPLAY_FIELDS = ( 29 | ('id', 'ID'), 30 | ('name', 'Name'), 31 | ('description', 'Description'), 32 | ('required_attributes', 'Required Attributes'), 33 | ) 34 | 35 | # Fields to display when viewing a single record. 36 | VERBOSE_FIELDS = ( 37 | ('id', 'ID'), 38 | ('name', 'Name'), 39 | ('description', 'Description'), 40 | ('required_attributes', 'Required Attributes'), 41 | ('site', 'Site ID'), 42 | ) 43 | 44 | 45 | # Main group 46 | @click.group() 47 | @click.pass_context 48 | def cli(ctx): 49 | """ 50 | Protocol Type objects. 51 | 52 | A Protocol Type resource can represent a network protocol type (e.g. bgp, 53 | is-is, ospf, etc.) 54 | 55 | Protocol Types can have any number of required attributes as defined below. 56 | """ 57 | 58 | 59 | # Add 60 | @cli.command() 61 | @click.option( 62 | '-r', 63 | '--required-attributes', 64 | metavar='ATTRIBUTE', 65 | type=str, 66 | help=('''The name of a Protocol attribute. This option can be providedi 67 | multiple times, once per attribute.'''), 68 | multiple=True, 69 | ) 70 | @click.option( 71 | '-d', 72 | '--description', 73 | metavar='DESCRIPTION', 74 | type=str, 75 | help='The description for this Protocol Type.', 76 | ) 77 | @click.option( 78 | '-n', 79 | '--name', 80 | metavar='NAME', 81 | type=str, 82 | help='The name of the Protocol Type.', 83 | required=True, 84 | ) 85 | @click.option( 86 | '-s', 87 | '--site-id', 88 | metavar='SITE_ID', 89 | type=int, 90 | help='Unique ID of the Site this Protocol Type is under. [required]', 91 | callback=callbacks.process_site_id, 92 | ) 93 | @click.pass_context 94 | def add(ctx, required_attributes, description, name, site_id): 95 | """ 96 | Add a new Protocol Type. 97 | 98 | You must provide a Protocol Type name or ID using the UPDATE option. 99 | 100 | When adding a new Protocol Type, you must provide a value for the -n/--name 101 | option. 102 | 103 | Examples: OSPF, BGP, etc. 104 | 105 | You may also provide required Protocol attributes, you may specify the 106 | -r/--required-attributes option once for each attribute. The Protocol 107 | attributes must exist before adding them to a protocol type. 108 | 109 | You must provide a Site ID using the -s/--site-id option. 110 | """ 111 | data = ctx.params 112 | 113 | # Remove if empty; allow default assignment 114 | if description is None: 115 | data.pop('description') 116 | 117 | ctx.obj.add(data) 118 | 119 | 120 | # List 121 | @cli.group(invoke_without_command=True) 122 | @click.option( 123 | '-d', 124 | '--description', 125 | metavar='DESCRIPTION', 126 | type=str, 127 | help='Filter by Protocol Type matching this description.', 128 | ) 129 | @click.option( 130 | '-i', 131 | '--id', 132 | metavar='ID', 133 | type=types.NATURAL_KEY, 134 | help='Unique ID of the Protocol Type being retrieved.', 135 | ) 136 | @click.option( 137 | '-l', 138 | '--limit', 139 | metavar='LIMIT', 140 | type=int, 141 | help='Limit result to N resources.', 142 | ) 143 | @click.option( 144 | '-n', 145 | '--name', 146 | metavar='NAME', 147 | help='Filter to Protocol Type matching this name.' 148 | ) 149 | @click.option( 150 | '-o', 151 | '--offset', 152 | metavar='OFFSET', 153 | help='Skip the first N resources.', 154 | ) 155 | @click.option( 156 | '-s', 157 | '--site-id', 158 | metavar='SITE_ID', 159 | help='Unique ID of the Site this Protocol Type is under.', 160 | callback=callbacks.process_site_id, 161 | ) 162 | @click.pass_context 163 | def list(ctx, description, id, limit, name, offset, site_id): 164 | """ 165 | List existing Protocol Types for a Site. 166 | 167 | You must provide a Site ID using the -s/--site-id option. 168 | 169 | When listing Protocol Types, all objects are displayed by default. You 170 | optionally may lookup a single Protocol Types by ID using the -i/--id 171 | option. The ID can either be the numeric ID of the Protocol Type. 172 | """ 173 | data = ctx.params 174 | 175 | # If we provide ID, show more fields 176 | if any([id, name]): 177 | display_fields = VERBOSE_FIELDS 178 | else: 179 | display_fields = DISPLAY_FIELDS 180 | 181 | # If we aren't passing a sub-command, just call list(), otherwise let it 182 | # fallback to default behavior. 183 | if ctx.invoked_subcommand is None: 184 | ctx.obj.list( 185 | data, display_fields=display_fields, 186 | verbose_fields=VERBOSE_FIELDS 187 | ) 188 | 189 | 190 | # Remove 191 | @cli.command() 192 | @click.option( 193 | '-i', 194 | '--id', 195 | metavar='ID', 196 | type=types.NATURAL_KEY, 197 | help='Unique ID of the Protocol Type being deleted.', 198 | required=True, 199 | ) 200 | @click.option( 201 | '-s', 202 | '--site-id', 203 | metavar='SITE_ID', 204 | type=int, 205 | help='Unique ID of the Site this Protocol Type is under.', 206 | callback=callbacks.process_site_id, 207 | ) 208 | @click.pass_context 209 | def remove(ctx, id, site_id): 210 | """ 211 | Remove an Protocol Type. 212 | 213 | You must provide a Site ID using the -s/--site-id option. 214 | 215 | When removing an Protocol Type, you must provide the ID of the Protocol 216 | Type using -i/--id. 217 | 218 | You may retrieve the ID for an Protocol Type by parsing it from the list of 219 | Protocol Types for a given Site: 220 | 221 | nsot protocol_types list --site-id | grep 222 | """ 223 | data = ctx.params 224 | ctx.obj.remove(**data) 225 | 226 | 227 | # Update 228 | @cli.command() 229 | @click.option( 230 | '-r', 231 | '--required-attributes', 232 | metavar='ATTRIBUTE', 233 | type=str, 234 | help=('''The name of a Protocol attribute. This option can be provided multiple 235 | times, once per attribute.'''), 236 | multiple=True, 237 | callback=callbacks.transform_attributes, 238 | ) 239 | @click.option( 240 | '-d', 241 | '--description', 242 | metavar='DESCRIPTION', 243 | type=str, 244 | help='The description for this Protocol Type.', 245 | ) 246 | @click.option( 247 | '-i', 248 | '--id', 249 | metavar='ID', 250 | type=types.NATURAL_KEY, 251 | help='Unique ID of the Protocol Type being updated.', 252 | required=True, 253 | ) 254 | @click.option( 255 | '-n', 256 | '--name', 257 | metavar='NAME', 258 | type=str, 259 | help='The name of the Protocol Type.', 260 | ) 261 | @click.option( 262 | '-s', 263 | '--site-id', 264 | metavar='SITE_ID', 265 | type=int, 266 | help='Unique ID of the Site this Protocol Type is under.', 267 | callback=callbacks.process_site_id, 268 | ) 269 | @click.pass_context 270 | def update(ctx, required_attributes, description, id, name, site_id): 271 | """ 272 | Update an Protocol Type. 273 | 274 | You must provide a Site ID using the -s/--site-id option. 275 | 276 | When updating an Protocol Type you must provide the ID (-i/--id). The ID 277 | can either be the numeric ID of the Protocol Type or the name. 278 | 279 | The -a/--attributes option may be provided multiple times, once for each 280 | key-value pair. 281 | 282 | When modifying attributes you have three actions to choose from: 283 | 284 | * Add (--add-attributes). This is the default behavior that will add 285 | attributes if they don't exist, or update them if they do. 286 | 287 | * Delete (--delete-attributes). This will cause attributes to be 288 | deleted. If combined with --multi the attribute will be deleted if 289 | either no value is provided, or if the attribute no longer contains a 290 | valid value. 291 | 292 | * Replace (--replace-attributes). This will cause attributes to 293 | replaced. If combined with --multi and multiple attributes of the same 294 | name are provided, only the last value provided will be used. 295 | """ 296 | data = ctx.params 297 | ctx.obj.update(data) 298 | -------------------------------------------------------------------------------- /pynsot/commands/cmd_protocols.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Sub-command for Protocols. 5 | 6 | In all cases ``data = ctx.params`` when calling the appropriate action method 7 | on ``ctx.obj``. (e.g. ``ctx.obj.add(ctx.params)``) 8 | 9 | Also, ``action = ctx.info_name`` *might* reliably contain the name of the 10 | action function, but still not sure about that. If so, every function could be 11 | fundamentally simplified to this:: 12 | 13 | getattr(ctx.obj, ctx.info_name)(ctx.params) 14 | """ 15 | 16 | from __future__ import unicode_literals 17 | from __future__ import absolute_import 18 | import click 19 | import logging 20 | 21 | from . import callbacks, types 22 | 23 | 24 | # Logger 25 | log = logging.getLogger(__name__) 26 | 27 | # Ordered list of 2-tuples of (field, display_name) used to translate object 28 | # field names oto their human-readable form when calling .print_list(). 29 | DISPLAY_FIELDS = ( 30 | ('id', 'ID'), 31 | ('device', 'Device'), 32 | ('type', 'Type'), 33 | ('interface', 'Interface'), 34 | ('circuit', 'Circuit'), 35 | ('attributes', 'Attributes'), 36 | ) 37 | 38 | # Fields to display when viewing a single record. 39 | VERBOSE_FIELDS = ( 40 | ('id', 'ID'), 41 | ('device', 'Device'), 42 | ('type', 'Type'), 43 | ('interface', 'Interface'), 44 | ('circuit', 'Circuit'), 45 | ('auth_string', 'Auth_String'), 46 | ('description', 'Description'), 47 | ('site', 'Site'), 48 | ('attributes', 'Attributes'), 49 | ) 50 | 51 | 52 | # Main group 53 | @click.group() 54 | @click.pass_context 55 | def cli(ctx): 56 | """ 57 | Protocol objects. 58 | 59 | A Protocol resource can represent a network routing protocol. 60 | 61 | """ 62 | 63 | 64 | # Add 65 | @cli.command() 66 | @click.option( 67 | '-u', 68 | '--auth_string', 69 | metavar='AUTH_STRING', 70 | default='', 71 | help='The authentication string (such as MD5 sum).', 72 | ) 73 | @click.option( 74 | '-a', 75 | '--attributes', 76 | metavar='ATTRS', 77 | help='A key/value pair attached to this Protocol (format: key=value).', 78 | multiple=True, 79 | callback=callbacks.transform_attributes, 80 | ) 81 | @click.option( 82 | '-c', 83 | '--circuit', 84 | metavar='CIRCUIT', 85 | help='The circuit that this protocol is running over.', 86 | ) 87 | @click.option( 88 | '-D', 89 | '--device', 90 | metavar='DEVICE', 91 | type=types.NATURAL_KEY, 92 | help=( 93 | 'Unique ID of the Device to which this Protocol is ' 94 | 'running on.' 95 | ), 96 | required=True, 97 | ) 98 | @click.option( 99 | '-e', 100 | '--description', 101 | metavar='DESCRIPTION', 102 | type=str, 103 | help='The description for this Protocol.', 104 | ) 105 | @click.option( 106 | '-I', 107 | '--interface', 108 | metavar='INTERFACE', 109 | type=str, 110 | help=( 111 | 'The Interface this Protocol is running on. Either interface' 112 | 'or circuit must be populated.' 113 | ), 114 | ) 115 | @click.option( 116 | '-s', 117 | '--site-id', 118 | metavar='SITE_ID', 119 | type=int, 120 | help=( 121 | 'Unique ID of the Site this Protocol is under.' 122 | 'If not set, this will be inherited off of the device\'s site' 123 | ), 124 | callback=callbacks.process_site_id, 125 | ) 126 | @click.option( 127 | '-t', 128 | '--type', 129 | metavar='TYPE', 130 | type=types.NATURAL_KEY, 131 | help='The type of the protocol.', 132 | required=True, 133 | ) 134 | @click.pass_context 135 | def add(ctx, auth_string, attributes, circuit, device, description, interface, 136 | site_id, type): 137 | """ 138 | Add a new Protocol. 139 | 140 | You must provide a Device hostname or ID using the -D/--device option. 141 | 142 | You must provide the type of the protocol (e.g. OSPF, BGP, etc.) 143 | 144 | If you wish to add attributes, you may specify the -a/--attributes 145 | option once for each key/value pair. 146 | 147 | """ 148 | data = ctx.params 149 | 150 | if interface is None and circuit is None: 151 | raise click.UsageError( 152 | '''Must have interface "-i" / "--interface" or 153 | circuit "-c" / "--circuit" populated''' 154 | ) 155 | 156 | if description is None: 157 | data.pop('description') 158 | 159 | ctx.obj.add(data) 160 | 161 | 162 | # List 163 | @cli.group(invoke_without_command=True) 164 | @click.option( 165 | '-a', 166 | '--attributes', 167 | metavar='ATTRS', 168 | help='A key/value pair attached to this Protocol (format: key=value).', 169 | multiple=True, 170 | ) 171 | @click.option( 172 | '-u', 173 | '--auth_string', 174 | metavar='AUTH_STRING', 175 | default='', 176 | help='The authentication string (such as MD5 sum).', 177 | ) 178 | @click.option( 179 | '-c', 180 | '--circuit', 181 | metavar='CIRCUIT', 182 | help='The circuit that this protocol is running over.', 183 | ) 184 | @click.option( 185 | '-d', 186 | '--delimited', 187 | is_flag=True, 188 | help='Display set query results separated by commas vs. newlines.', 189 | default=False, 190 | show_default=True, 191 | ) 192 | @click.option( 193 | '-e', 194 | '--description', 195 | metavar='DESCRIPTION', 196 | type=str, 197 | help='Filter by Protocols matching this description.', 198 | ) 199 | @click.option( 200 | '-D', 201 | '--device', 202 | metavar='DEVICE', 203 | type=types.NATURAL_KEY, 204 | help=( 205 | 'Unique ID of the Device to which this Protocol is ' 206 | 'running on.' 207 | ), 208 | ) 209 | @click.option( 210 | '-g', 211 | '--grep', 212 | is_flag=True, 213 | help='Display list results in a grep-friendly format.', 214 | default=False, 215 | show_default=True, 216 | ) 217 | @click.option( 218 | '-i', 219 | '--id', 220 | metavar='ID', 221 | help='Unique ID of the Protocol being retrieved.', 222 | ) 223 | @click.option( 224 | '-I', 225 | '--interface', 226 | metavar='INTERFACE', 227 | type=types.NATURAL_KEY, 228 | help=( 229 | 'The Interface this Protocol is running on. Either interface' 230 | 'or circuit must be populated.' 231 | ), 232 | ) 233 | @click.option( 234 | '-l', 235 | '--limit', 236 | metavar='LIMIT', 237 | type=int, 238 | help='Limit result to N resources.', 239 | ) 240 | @click.option( 241 | '-o', 242 | '--offset', 243 | metavar='OFFSET', 244 | help='Skip the first N resources.', 245 | ) 246 | @click.option( 247 | '-q', 248 | '--query', 249 | metavar='QUERY', 250 | help='Perform a set query using Attributes and output matching Protocols.' 251 | ) 252 | @click.option( 253 | '-s', 254 | '--site-id', 255 | metavar='SITE_ID', 256 | help='Unique ID of the Site this Protocol is under.', 257 | callback=callbacks.process_site_id, 258 | ) 259 | @click.option( 260 | '-t', 261 | '--type', 262 | metavar='TYPE', 263 | type=types.NATURAL_KEY, 264 | help='The protocol type', 265 | ) 266 | @click.pass_context 267 | def list(ctx, attributes, auth_string, circuit, delimited, description, device, 268 | grep, id, interface, limit, offset, query, site_id, type): 269 | """ 270 | List existing Protocols for a Site. 271 | 272 | You must provide a Site ID using the -s/--site-id option. 273 | 274 | You must provide the protocol type. 275 | 276 | When listing Protocols, all objects are displayed by default. You 277 | optionally may lookup a single Protocols by ID using the -i/--id option. 278 | 279 | You may look up the protocols for a single Device using -D/--device which 280 | can be either a hostname or ID for a Device. 281 | """ 282 | data = ctx.params 283 | data.pop('delimited') # We don't want this going to the server. 284 | 285 | # If we provide ID, show more fields 286 | if id is not None: 287 | display_fields = VERBOSE_FIELDS 288 | else: 289 | display_fields = DISPLAY_FIELDS 290 | 291 | # If we aren't passing a sub-command, just call list(), otherwise let it 292 | # fallback to default behavior. 293 | if ctx.invoked_subcommand is None: 294 | if query is not None: 295 | ctx.obj.natural_keys_by_query(data, delimited) 296 | else: 297 | ctx.obj.list( 298 | data, display_fields=display_fields, 299 | verbose_fields=VERBOSE_FIELDS 300 | ) 301 | 302 | 303 | # Remove 304 | @cli.command() 305 | @click.option( 306 | '-i', 307 | '--id', 308 | metavar='ID', 309 | help='Unique ID of the Protocol being deleted.', 310 | required=True, 311 | ) 312 | @click.option( 313 | '-s', 314 | '--site-id', 315 | metavar='SITE_ID', 316 | type=int, 317 | help='Unique ID of the Site this Protocol is under.', 318 | callback=callbacks.process_site_id, 319 | required=True, 320 | ) 321 | @click.pass_context 322 | def remove(ctx, id, site_id): 323 | """ 324 | Remove a Protocol. 325 | 326 | You must provide a Site ID using the -s/--site-id option. 327 | 328 | When removing an Protocol, you must provide the ID of the Protocol using 329 | -i/--id. 330 | 331 | You may retrieve the ID for an Protocol by parsing it from the list of 332 | Protocols for a given Site: 333 | 334 | nsot interfaces list --site-id | grep 335 | """ 336 | data = ctx.params 337 | ctx.obj.remove(**data) 338 | 339 | 340 | # Update 341 | @cli.command() 342 | @click.option( 343 | '-a', 344 | '--attributes', 345 | metavar='ATTRS', 346 | help='A key/value pair attached to this Protocol (format: key=value).', 347 | multiple=True, 348 | callback=callbacks.transform_attributes, 349 | ) 350 | @click.option( 351 | '-u', 352 | '--auth_string', 353 | metavar='AUTH_STRING', 354 | default='', 355 | help='The authentication string (such as MD5 sum).', 356 | ) 357 | @click.option( 358 | '-c', 359 | '--circuit', 360 | metavar='CIRCUIT', 361 | help='The circuit that this protocol is running over.', 362 | ) 363 | @click.option( 364 | '-e', 365 | '--description', 366 | metavar='DESCRIPTION', 367 | type=str, 368 | help='The description for this Protocol.', 369 | ) 370 | @click.option( 371 | '-D', 372 | '--device', 373 | metavar='DEVICE', 374 | type=types.NATURAL_KEY, 375 | help=( 376 | 'Unique ID of the Device to which this Protocol is ' 377 | 'running on.' 378 | ), 379 | ) 380 | @click.option( 381 | '-i', 382 | '--id', 383 | metavar='ID', 384 | type=int, 385 | help='Unique ID or natural key of the Protocol being updated.', 386 | required=True, 387 | ) 388 | @click.option( 389 | '-I', 390 | '--interface', 391 | metavar='INTERFACE', 392 | type=types.NATURAL_KEY, 393 | help=( 394 | 'The Interface this Protocol is running on. Either interface' 395 | 'or circuit must be populated.' 396 | ), 397 | ) 398 | @click.option( 399 | '-s', 400 | '--site-id', 401 | metavar='SITE_ID', 402 | type=int, 403 | help='Unique ID of the Site this Protocol is under.', 404 | callback=callbacks.process_site_id, 405 | ) 406 | @click.option( 407 | '-t', 408 | '--type', 409 | metavar='TYPE', 410 | type=types.NATURAL_KEY, 411 | help='The Protocol type ID.', 412 | ) 413 | @click.option( 414 | '--add-attributes', 415 | 'attr_action', 416 | flag_value='add', 417 | default=True, 418 | help=( 419 | 'Causes attributes to be added. This is the default and providing it ' 420 | 'will have no effect.' 421 | ) 422 | ) 423 | @click.option( 424 | '--delete-attributes', 425 | 'attr_action', 426 | flag_value='delete', 427 | help=( 428 | 'Causes attributes to be deleted instead of updated. If combined with' 429 | 'with --multi the attribute will be deleted if either no value is ' 430 | 'provided, or if attribute no longer has an valid values.' 431 | ), 432 | ) 433 | @click.option( 434 | '--replace-attributes', 435 | 'attr_action', 436 | flag_value='replace', 437 | help=( 438 | 'Causes attributes to be replaced instead of updated. If combined ' 439 | 'with --multi, the entire list will be replaced.' 440 | ), 441 | ) 442 | @click.option( 443 | '--multi', 444 | is_flag=True, 445 | help='Treat the specified attributes as a list type.', 446 | ) 447 | @click.pass_context 448 | def update(ctx, attributes, auth_string, circuit, description, device, id, 449 | interface, site_id, type, attr_action, multi): 450 | """ 451 | Update a Protocol. 452 | 453 | You must provide a Site ID using the -s/--site-id option. 454 | 455 | You must provide the type of the protocol (e.g. OSPF, BGP, etc.) 456 | 457 | When updating an Protocol you must provide the ID (-i/--id) and at least 458 | one of the optional arguments. 459 | 460 | The -a/--attributes option may be provided multiple times, once for each 461 | key-value pair. 462 | 463 | When modifying attributes you have three actions to choose from: 464 | 465 | * Add (--add-attributes). This is the default behavior that will add 466 | attributes if they don't exist, or update them if they do. 467 | 468 | * Delete (--delete-attributes). This will cause attributes to be 469 | deleted. If combined with --multi the attribute will be deleted if 470 | either no value is provided, or if the attribute no longer contains a 471 | valid value. 472 | 473 | * Replace (--replace-attributes). This will cause attributes to 474 | replaced. If combined with --multi and multiple attributes of the same 475 | name are provided, only the last value provided will be used. 476 | """ 477 | if not any([attributes, description, type, auth_string, circuit, 478 | interface]): 479 | msg = 'You must supply at least one of the optional arguments.' 480 | raise click.UsageError(msg) 481 | ctx.obj.update(ctx.params) 482 | -------------------------------------------------------------------------------- /pynsot/commands/cmd_sites.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Sub-command for Sites. 5 | 6 | In all cases ``data = ctx.params`` when calling the appropriate action method 7 | on ``ctx.obj``. (e.g. ``ctx.obj.add(ctx.params)``) 8 | 9 | Also, ``action = ctx.info_name`` *might* reliably contain the name of the 10 | action function, but still not sure about that. If so, every function could be 11 | fundamentally simplified to this:: 12 | 13 | getattr(ctx.obj, ctx.info_name)(ctx.params) 14 | """ 15 | 16 | from __future__ import unicode_literals 17 | 18 | import click 19 | 20 | 21 | __author__ = 'Jathan McCollum' 22 | __maintainer__ = 'Jathan McCollum' 23 | __email__ = 'jathan@dropbox.com' 24 | __copyright__ = 'Copyright (c) 2015 Dropbox, Inc.' 25 | 26 | 27 | # Ordered list of 2-tuples of (field, display_name) used to translate object 28 | # field names oto their human-readable form when calling .print_list(). 29 | DISPLAY_FIELDS = ( 30 | ('id', 'ID'), 31 | ('name', 'Name'), 32 | ('description', 'Description'), 33 | ) 34 | 35 | 36 | # Main group 37 | @click.group() 38 | @click.pass_context 39 | def cli(ctx): 40 | """ 41 | Site objects. 42 | 43 | Sites are the top-level resource from which all other resources descend. In 44 | other words, Sites contain Attributes, Changes, Devices, and Networks. 45 | 46 | Sites function as unique namespaces that can contain other resources. Sites 47 | allow an organization to have multiple instances of potentially conflicting 48 | resources. This could be beneficial for isolating corporeate vs. production 49 | environments, or pulling in the IP space of an acquisition. 50 | """ 51 | 52 | 53 | # Add 54 | @cli.command() 55 | @click.option( 56 | '-d', 57 | '--description', 58 | default='', 59 | metavar='DESC', 60 | help='A helpful description for the Site.', 61 | ) 62 | @click.option( 63 | '-n', 64 | '--name', 65 | metavar='NAME', 66 | help='The name of the Site.', 67 | required=True, 68 | ) 69 | @click.pass_context 70 | def add(ctx, description, name): 71 | """ 72 | Add a new Site. 73 | 74 | When adding a Site, you must provide a name using the -n/--name option. 75 | 76 | You may provide a helpful description for the Site using the 77 | -d/--description option. 78 | """ 79 | data = ctx.params 80 | ctx.obj.add(data) 81 | 82 | 83 | # List 84 | @cli.command() 85 | @click.option( 86 | '-i', 87 | '--id', 88 | metavar='ID', 89 | type=int, 90 | help='Unique ID of the Site to retrieve.', 91 | ) 92 | @click.option( 93 | '-l', 94 | '--limit', 95 | metavar='LIMIT', 96 | type=int, 97 | help='Limit results to N resources.', 98 | ) 99 | @click.option( 100 | '-n', 101 | '--name', 102 | metavar='NAME', 103 | help='Filter by Site name.' 104 | ) 105 | @click.option( 106 | '-N', 107 | '--natural-key', 108 | is_flag=True, 109 | help='Display list results by their natural key', 110 | default=False, 111 | show_default=True, 112 | ) 113 | @click.option( 114 | '-o', 115 | '--offset', 116 | metavar='OFFSET', 117 | type=int, 118 | help='Skip the first N resources.', 119 | ) 120 | @click.pass_context 121 | def list(ctx, id, limit, name, natural_key, offset): 122 | """ 123 | List existing Sites. 124 | 125 | When listing Sites, all objects are displayed by default. You may 126 | optionally lookup a single Site by name using the -n/--name option or by ID 127 | using the -i/--id option. 128 | 129 | You may limit the number of results using the -l/--limit option. 130 | 131 | NOTE: The -i/--id option will take precedence causing all other options 132 | to be ignored. 133 | """ 134 | data = ctx.params 135 | ctx.obj.list(data, display_fields=DISPLAY_FIELDS) 136 | 137 | 138 | # Remove 139 | @cli.command() 140 | @click.option( 141 | '-i', 142 | '--id', 143 | metavar='ID', 144 | type=int, 145 | help='Unique ID of the Site that should be removed.', 146 | required=True, 147 | ) 148 | @click.pass_context 149 | def remove(ctx, id): 150 | """ 151 | Remove a Site. 152 | 153 | When removing a site, you must provide the unique ID using -i/--id. You may 154 | retrieve the ID for a Site by looking it up by name using: 155 | 156 | nsot sites list --name 157 | """ 158 | data = ctx.params 159 | ctx.obj.remove(**data) 160 | 161 | 162 | # Update 163 | @cli.command() 164 | @click.option( 165 | '-d', 166 | '--description', 167 | metavar='DESC', 168 | help='A helpful description for the Site.', 169 | ) 170 | @click.option( 171 | '-i', 172 | '--id', 173 | metavar='ID', 174 | type=int, 175 | help='Unique ID of the Site that should be updated.', 176 | required=True, 177 | ) 178 | @click.option( 179 | '-n', 180 | '--name', 181 | metavar='NAME', 182 | help='The name of the Site.', 183 | ) 184 | @click.pass_context 185 | def update(ctx, description, id, name): 186 | """ 187 | Update a Site. 188 | 189 | When updating a Site you must provide the unique ID (-i/--id) and at least 190 | one of the -n/--name or -d/--description arguments. 191 | """ 192 | if name is None and description is None: 193 | msg = 'You must supply at least one of -n/--name or -d/--description' 194 | raise click.UsageError(msg) 195 | 196 | data = ctx.params 197 | ctx.obj.update(data) 198 | -------------------------------------------------------------------------------- /pynsot/commands/cmd_values.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Sub-command for Values. 5 | 6 | In all cases ``data = ctx.params`` when calling the appropriate action method 7 | on ``ctx.obj``. (e.g. ``ctx.obj.add(ctx.params)``) 8 | 9 | Also, ``action = ctx.info_name`` *might* reliably contain the name of the 10 | action function, but still not sure about that. If so, every function could be 11 | fundamentally simplified to this:: 12 | 13 | getattr(ctx.obj, ctx.info_name)(ctx.params) 14 | """ 15 | 16 | from __future__ import unicode_literals 17 | 18 | from __future__ import absolute_import 19 | import click 20 | from . import callbacks 21 | 22 | 23 | __author__ = 'Jathan McCollum' 24 | __maintainer__ = 'Jathan McCollum' 25 | __email__ = 'jathan@dropbox.com' 26 | __copyright__ = 'Copyright (c) 2016 Dropbox, Inc.' 27 | 28 | 29 | # Ordered list of 2-tuples of (field, display_name) used to translate object 30 | # field names oto their human-readable form when calling .print_list(). 31 | DISPLAY_FIELDS = ( 32 | ('name', 'Name'), 33 | ('value', 'Value'), 34 | ('resource_name', 'Resource'), 35 | ('resource_id', 'Resource ID'), 36 | # ('site_id': 'Site ID'), 37 | ) 38 | 39 | # Fields to display when viewing a single record. 40 | VERBOSE_FIELDS = ( 41 | ('id', 'ID'), 42 | ('name', 'Name'), 43 | ('value', 'Value'), 44 | ('resource_name', 'Resource'), 45 | ('resource_id', 'Resource ID'), 46 | ('attribute', 'Attribute'), 47 | ) 48 | 49 | 50 | # Main group 51 | @click.group() 52 | @click.pass_context 53 | def cli(ctx): 54 | """ 55 | Value objects. 56 | 57 | Values are assigned by Attribute to various resource objects. 58 | """ 59 | 60 | 61 | # List 62 | @cli.command() 63 | @click.option( 64 | '-n', 65 | '--name', 66 | metavar='NAME', 67 | required=True, 68 | help='Filter to Attribute with this name.', 69 | ) 70 | @click.option( 71 | '-r', 72 | '--resource-name', 73 | metavar='RESOURCE_NAME', 74 | help='Filter to Values for a specific resource name (e.g. Device)', 75 | callback=callbacks.transform_resource_name, 76 | ) 77 | @click.option( 78 | '-s', 79 | '--site-id', 80 | metavar='SITE_ID', 81 | type=int, 82 | help='Unique ID of the Site this Attribute is under. [required]', 83 | callback=callbacks.process_site_id, 84 | ) 85 | @click.pass_context 86 | def list(ctx, name, resource_name, site_id): 87 | """ 88 | List existing Values by Attribute name for a Site. 89 | 90 | You must provide a Name using the -n/--name option. 91 | 92 | You must provide a Site ID using the -s/--site-id option. 93 | 94 | When listing Values, all matching Attributes across all resource types are 95 | returned by default. You may optionally filter by a single resource name by 96 | using the -r/--resource-name option. 97 | """ 98 | data = ctx.params 99 | 100 | # Fetch the matching values directly from the API client, filter 101 | # duplicates, and sort the output. 102 | results = ctx.obj.api.sites(site_id).values.get(**data) 103 | values = {d['value'] for d in results} 104 | click.echo('\n'.join(sorted(values))) 105 | -------------------------------------------------------------------------------- /pynsot/commands/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Custom Click parameter types. 5 | """ 6 | from __future__ import unicode_literals 7 | 8 | import click 9 | from ..util import validate_cidr 10 | 11 | 12 | class NetworkIdParamType(click.ParamType): 13 | """Custom paramer type that supports Network ID or CIDR.""" 14 | name = 'network identifier' 15 | 16 | def convert(self, value, param, ctx): 17 | if value is None: 18 | return 19 | 20 | tests = [int, validate_cidr] 21 | win = False 22 | for test in tests: 23 | try: 24 | win = test(value) 25 | except Exception: 26 | pass 27 | else: 28 | if not win: 29 | continue 30 | return value 31 | 32 | else: 33 | self.fail('%s is not an valid integer or CIDR' % value, param, ctx) 34 | 35 | def __repr__(self): 36 | return 'NETWORK_ID' 37 | 38 | 39 | class NaturalKeyParamType(click.ParamType): 40 | """Custom paramer type that supports ID or natural key.""" 41 | name = 'natural key' 42 | 43 | def convert(self, value, param, ctx): 44 | if value is None: 45 | return 46 | 47 | tests = [int, str] 48 | win = False 49 | for test in tests: 50 | try: 51 | win = test(value) 52 | except Exception: 53 | pass 54 | else: 55 | if not win: 56 | continue 57 | return value 58 | 59 | else: 60 | self.fail( 61 | '%s is not an valid integer or natural key' % value, param, ctx 62 | ) 63 | 64 | def __repr__(self): 65 | return 'NATURAL_KEY' 66 | 67 | 68 | # Constants for these types 69 | NETWORK_ID = NetworkIdParamType() 70 | NATURAL_KEY = NaturalKeyParamType() 71 | -------------------------------------------------------------------------------- /pynsot/constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Constant values used across the project. 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | from __future__ import absolute_import 9 | import os 10 | 11 | 12 | __author__ = 'Jathan McCollum' 13 | __maintainer__ = 'Jathan McCollum' 14 | __email__ = 'jathan@dropbox.com' 15 | __copyright__ = 'Copyright (c) 2016 Dropbox, Inc.' 16 | 17 | 18 | # Header used for passthrough authentication. 19 | AUTH_HEADER = 'X-NSoT-Email' 20 | 21 | # Default authentication method 22 | DEFAULT_AUTH_METHOD = 'auth_token' 23 | 24 | # Mapping of required field names and default values we want to be in the 25 | # dotfile. 26 | REQUIRED_FIELDS = { 27 | 'auth_method': ['auth_token', 'auth_header'], 28 | 'url': None, 29 | } 30 | 31 | # Fields that map to specific auth_methods. 32 | SPECIFIC_FIELDS = { 33 | 'auth_header': { 34 | 'default_domain': 'localhost', 35 | 'auth_header': AUTH_HEADER, 36 | } 37 | } 38 | 39 | # Mapping of optional field names and default values (if any) 40 | OPTIONAL_FIELDS = { 41 | 'default_site': None, 42 | 'api_version': None, 43 | } 44 | 45 | # Path stuff 46 | USER_HOME = os.path.expanduser('~') 47 | DOTFILE_NAME = '.pynsotrc' 48 | DOTFILE_USER_PATH = os.path.join(USER_HOME, DOTFILE_NAME) 49 | DOTFILE_GLOBAL_PATH = '/etc/pynsotrc' 50 | DOTFILE_PERMS = 0o600 # -rw------- 51 | 52 | # Config section name 53 | SECTION_NAME = 'pynsot' 54 | -------------------------------------------------------------------------------- /pynsot/dotfile.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Handle the read, write, and generation of the .pynsotrc config file. 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | from __future__ import absolute_import 9 | from configparser import RawConfigParser, ConfigParser 10 | import click 11 | import copy 12 | import logging 13 | import os 14 | 15 | from . import constants 16 | import six 17 | 18 | 19 | __author__ = 'Jathan McCollum' 20 | __maintainer__ = 'Jathan McCollum' 21 | __email__ = 'jathan@dropbox.com' 22 | __copyright__ = 'Copyright (c) 2015-2016 Dropbox, Inc.' 23 | 24 | 25 | # Logging object 26 | log = logging.getLogger(__name__) 27 | 28 | 29 | __all__ = ( 30 | 'DotfileError', 'Dotfile', 31 | ) 32 | 33 | 34 | class DotfileError(Exception): 35 | """Raised when something with the dotfile fails.""" 36 | 37 | 38 | class Dotfile(object): 39 | """Create, read, and write a dotfile.""" 40 | def __init__(self, filepath=constants.DOTFILE_USER_PATH, **kwargs): 41 | self.filepath = filepath 42 | 43 | def read(self, **kwargs): 44 | """ 45 | Read ``~/.pynsotrc`` and return it as a dict. 46 | """ 47 | config = {} 48 | if (not os.path.exists(self.filepath) and 49 | not os.path.exists(constants.DOTFILE_GLOBAL_PATH)): 50 | p = '%s not found; would you like to create it?' % (self.filepath,) 51 | if click.confirm(p, default=True, abort=True): 52 | config_data = self.get_config_data(**kwargs) 53 | self.write(config_data) # Write config to disk 54 | config = config_data # Return the contents 55 | else: 56 | parser = ConfigParser() 57 | if os.path.exists(constants.DOTFILE_GLOBAL_PATH): 58 | parser.read(constants.DOTFILE_GLOBAL_PATH) 59 | if os.path.exists(self.filepath): 60 | # these settings will override any settings from global file 61 | parser.read(self.filepath) 62 | if constants.SECTION_NAME in parser: 63 | config = parser[constants.SECTION_NAME] 64 | 65 | # If we have configuration values, validate the permissions and 66 | # presence of fields in the dotfile. 67 | auth_method = config.get('auth_method') 68 | if auth_method == 'auth_token': 69 | self.validate_perms() 70 | required_fields = self.get_required_fields(auth_method) 71 | self.validate_fields(config, required_fields) 72 | 73 | return config 74 | 75 | def validate_perms(self): 76 | """Make sure dotfile ownership and permissions are correct.""" 77 | if not os.path.exists(self.filepath): 78 | return None 79 | 80 | # Ownership 81 | s = os.stat(self.filepath) 82 | if s.st_uid != os.getuid(): 83 | raise DotfileError( 84 | '%s: %s must be owned by you' % ( 85 | constants.DOTFILE_NAME, self.filepath 86 | ) 87 | ) 88 | 89 | # Permissions 90 | self.enforce_perms() 91 | 92 | def validate_fields(self, field_names, required_fields): 93 | """ 94 | Make sure all the fields are set. 95 | 96 | :param field_names: 97 | List of field names to validate 98 | 99 | :param required_fields: 100 | List of required field names to check against 101 | """ 102 | for rname in sorted(required_fields): 103 | if rname not in field_names: 104 | msg = '%s: Missing required field: %s' % (self.filepath, rname) 105 | raise DotfileError(msg) 106 | 107 | def enforce_perms(self, perms=constants.DOTFILE_PERMS): 108 | """ 109 | Enforce permissions on the dotfile. 110 | 111 | :param perms: 112 | Octal number representing permissions to enforce 113 | """ 114 | log.debug('Enforcing permissions %o on %s' % (perms, self.filepath)) 115 | os.chmod(self.filepath, perms) 116 | 117 | def write(self, config_data, filepath=None): 118 | """ 119 | Create a dotfile from keyword arguments. 120 | 121 | :param config_data: 122 | Dict of config settings 123 | 124 | :param filepath: 125 | (Optional) Path to write 126 | """ 127 | if filepath is None: 128 | filepath = self.filepath 129 | config = RawConfigParser() 130 | section = constants.SECTION_NAME 131 | config.add_section(section) 132 | 133 | # Set the config settings 134 | for key, val in six.iteritems(config_data): 135 | config.set(section, key, val) 136 | 137 | with open(filepath, 'w') as dotfile: 138 | config.write(dotfile) 139 | 140 | self.enforce_perms() 141 | log.debug('wrote %s' % filepath) 142 | 143 | @classmethod 144 | def get_required_fields(cls, auth_method, required_fields=None): 145 | """ 146 | Return union of all required fields for ``auth_method``. 147 | 148 | :param auth_method: 149 | Authentication method 150 | 151 | :param required_fields: 152 | Mapping of required field names to default values 153 | """ 154 | if required_fields is None: 155 | required_fields = copy.deepcopy(constants.REQUIRED_FIELDS) 156 | 157 | from .client import AUTH_CLIENTS # To avoid circular import 158 | 159 | # Fields that do not have default values, which are specific to an 160 | # auth_method. 161 | auth_fields = AUTH_CLIENTS[auth_method].required_arguments 162 | non_specific_fields = dict.fromkeys(auth_fields) 163 | required_fields.update(non_specific_fields) 164 | 165 | # Fields that MAY have default values, which are specific to an 166 | # auth_method, if any. 167 | specific_fields = constants.SPECIFIC_FIELDS.get(auth_method, {}) 168 | required_fields.update(specific_fields) 169 | 170 | return required_fields 171 | 172 | @classmethod 173 | def get_config_data(cls, required_fields=None, optional_fields=None, 174 | **kwargs): 175 | """ 176 | Enumerate required fields and prompt for ones that weren't provided if 177 | they don't have default values. 178 | 179 | :param required_fields: 180 | Mapping of required field names to default values 181 | 182 | :param optional_fields: 183 | Mapping of optional field names to default values 184 | 185 | :param kwargs: 186 | Dict of prepared config settings 187 | 188 | :returns: 189 | Dict of config data 190 | """ 191 | if required_fields is None: 192 | required_fields = constants.REQUIRED_FIELDS 193 | 194 | if optional_fields is None: 195 | optional_fields = constants.OPTIONAL_FIELDS 196 | 197 | config_data = {} 198 | 199 | # Base required fields 200 | cls.process_fields(config_data, required_fields, **kwargs) 201 | 202 | # Auth-related fields 203 | auth_method = config_data['auth_method'] 204 | auth_fields = cls.get_required_fields(auth_method) 205 | cls.process_fields(config_data, auth_fields, **kwargs) 206 | 207 | # Optional fields 208 | cls.process_fields( 209 | config_data, optional_fields, optional=True, **kwargs 210 | ) 211 | 212 | return config_data 213 | 214 | @staticmethod 215 | def process_fields(config_data, field_items, optional=False, **kwargs): 216 | """ 217 | Enumerate fields to update ``config_data``. 218 | 219 | Any fields not already in ``config_data`` will be prompted for a value 220 | from ``field_items``. 221 | 222 | :param config_data: 223 | Dict of config data 224 | 225 | :param field_items: 226 | Dict of desired fields to be populated 227 | 228 | :param optional: 229 | Whether to consider values for ``field_items`` as optional 230 | 231 | :param kwargs: 232 | Keyword arguments of prepared values 233 | """ 234 | for field, default_value in six.iteritems(field_items): 235 | prompt = 'Please enter %s' % (field,) 236 | 237 | # If it's already in the config data, move on. 238 | if field in config_data: 239 | continue 240 | 241 | # If the field was not provided 242 | if field not in kwargs: 243 | # If it does not have a default value prompt for it 244 | if default_value is None: 245 | if not optional: 246 | value = click.prompt(prompt, type=str) 247 | else: 248 | prompt += ' (optional)' 249 | value = click.prompt( 250 | prompt, default='', type=str, show_default=False 251 | ) 252 | 253 | # If the default_value is a string, prompt for it, but present 254 | # it as a default. 255 | elif isinstance(default_value, six.string_types): 256 | value = click.prompt( 257 | prompt, type=str, default=default_value 258 | ) 259 | 260 | # If it's a list of options, present the choices. 261 | elif isinstance(default_value, list): 262 | choices = ', '.join(default_value) 263 | prompt = 'Please choose %s [%s]' % (field, choices) 264 | while True: 265 | value = click.prompt(prompt, type=str) 266 | if value not in default_value: 267 | click.echo('Invalid choice: %s' % value) 268 | continue 269 | break 270 | else: 271 | raise RuntimeError( 272 | 'Unexpected error condition when processing config ' 273 | 'fields.' 274 | ) 275 | 276 | # Or, use the value provided in kwargs 277 | elif field in kwargs: 278 | value = kwargs[field] 279 | 280 | # Otherwise, use the default value 281 | else: 282 | value = default_value 283 | 284 | # If a value wasn't set, don't save it. 285 | if value == '': 286 | continue 287 | 288 | config_data[field] = value 289 | 290 | return config_data 291 | -------------------------------------------------------------------------------- /pynsot/serializers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Specialized serializers for NSoT API client. 5 | 6 | This is an example of how you would use this with the Client object, to make it 7 | return objects instead of dicts:: 8 | 9 | >>> serializer = ModelSerializer() 10 | >>> api = Client(url, serializer=serializer) 11 | >>> obj = api.sites(1).get() 12 | >>> obj 13 | 14 | """ 15 | 16 | from __future__ import unicode_literals 17 | 18 | from __future__ import absolute_import 19 | from slumber.serialize import JsonSerializer 20 | from .import models 21 | 22 | 23 | __author__ = 'Jathan McCollum' 24 | __maintainer__ = 'Jathan McCollum' 25 | __email__ = 'jathan@dropbox.com' 26 | __copyright__ = 'Copyright (c) 2015-2016 Dropbox, Inc.' 27 | 28 | 29 | class ModelSerializer(JsonSerializer): 30 | """This serializes to a model instead of a dict.""" 31 | key = 'model' 32 | 33 | def get_serializer(self, *args, **kwargs): 34 | return self 35 | 36 | def loads(self, data): 37 | obj_data = super(ModelSerializer, self).loads(data) 38 | return models.ApiModel(obj_data) 39 | -------------------------------------------------------------------------------- /pynsot/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Utilities and stuff. 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | 9 | import netaddr 10 | 11 | 12 | def get_result(response): 13 | """ 14 | Get the desired result from an API response. 15 | 16 | :param response: Requests API response object 17 | """ 18 | try: 19 | payload = response.json() 20 | except AttributeError: 21 | payload = response 22 | 23 | if 'results' in payload: 24 | return payload['results'] 25 | 26 | # Or just return the payload... (next-gen) 27 | return payload 28 | 29 | 30 | def validate_cidr(cidr): 31 | """ 32 | Return whether ``cidr`` is valid. 33 | 34 | :param cidr: 35 | IPv4/IPv6 address 36 | """ 37 | try: 38 | netaddr.IPNetwork(cidr) 39 | except (TypeError, netaddr.AddrFormatError): 40 | return False 41 | else: 42 | return True 43 | 44 | 45 | def dict_to_cidr(obj): 46 | """ 47 | Take an dict of a Network object and return a cidr-formatted string. 48 | 49 | :param obj: 50 | Dict of an Network object 51 | """ 52 | return '%s/%s' % (obj['network_address'], obj['prefix_length']) 53 | 54 | 55 | def slugify(s): 56 | """ 57 | Slugify a string for use in URLs. This mirrors ``nsot.util.slugify()``. 58 | 59 | :param s: 60 | String to slugify 61 | """ 62 | 63 | disallowed_chars = ['/'] 64 | replacement = '_' 65 | 66 | for char in disallowed_chars: 67 | s = s.replace(char, replacement) 68 | 69 | return s 70 | -------------------------------------------------------------------------------- /pynsot/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.4.2' 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | django_find_project = false 3 | python_paths = . 4 | addopts = -vv 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | fake-factory~=0.5.0 3 | flake8~=3.7.8 4 | ipdb~=0.9.3 5 | ipython>=3.1.0 6 | nsot>=1.4.6 7 | py~=1.5.2 8 | pytest~=3.4.1 9 | pytest-django~=3.1.2 10 | pytest-pythonpath~=0.6.0 11 | Sphinx~=1.3.6 12 | sphinx-autobuild~=0.6.0 13 | sphinx-rtd-theme~=0.1.9 14 | attrs==19.1.0 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click~=8.1.7 2 | configparser~=4.0.2 3 | PTable~=0.9.2 4 | netaddr~=0.8.0 5 | requests~=2.20.0 6 | six~=1.10.0 7 | slumber~=0.7.1 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = tests/*,docs 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import absolute_import 4 | from setuptools import find_packages, setup 5 | 6 | exec(open('pynsot/version.py').read()) 7 | 8 | with open('requirements.txt') as requirements: 9 | required = requirements.read().splitlines() 10 | 11 | kwargs = { 12 | 'name': 'pynsot', 13 | 'version': str(__version__), # noqa 14 | 'packages': find_packages(exclude=['tests']), 15 | 'description': 'Python interface for Network Source of Truth (nsot)', 16 | 'author': 'Jathan McCollum', 17 | 'maintainer': 'Jathan McCollum', 18 | 'author_email': 'jathan@dropbox.com', 19 | 'maintainer_email': 'jathan@dropbox.com', 20 | 'license': 'Apache', 21 | 'install_requires': required, 22 | 'url': 'https://github.com/dropbox/pynsot', 23 | 'entry_points': """ 24 | [console_scripts] 25 | nsot=pynsot.app:app 26 | snot=pynsot.app:app 27 | """, 28 | 'classifiers': [ 29 | 'Programming Language :: Python', 30 | 'Topic :: Software Development', 31 | 'Topic :: Software Development :: Libraries', 32 | 'Topic :: Software Development :: Libraries :: Python Modules', 33 | ] 34 | } 35 | 36 | setup(**kwargs) 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/pynsot/34a6debd49c8f48f56527dec54e0be08dd5bd8a9/tests/__init__.py -------------------------------------------------------------------------------- /tests/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/pynsot/34a6debd49c8f48f56527dec54e0be08dd5bd8a9/tests/app/__init__.py -------------------------------------------------------------------------------- /tests/app/test_circuits.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Test Circuits in the CLI app. 5 | """ 6 | 7 | from __future__ import absolute_import, unicode_literals 8 | import logging 9 | 10 | import pytest 11 | 12 | from tests.fixtures import (attribute, attributes, client, config, device, 13 | interface, network, runner, site, site_client) 14 | from tests.fixtures.circuits import (circuit, circuit_attributes, 15 | attributeless_circuit, device_a, device_z, 16 | interface_a, interface_z) 17 | from tests.util import assert_output, assert_outputs 18 | 19 | 20 | log = logging.getLogger(__name__) 21 | 22 | 23 | def test_circuits_add(runner, interface_a, interface_z): 24 | """ Test adding a normal circuit """ 25 | 26 | with runner.isolated_filesystem(): 27 | # Add a circuit with interfaces on each end 28 | result = runner.run( 29 | 'circuits add -A {0} -Z {1} -n add_test1'.format( 30 | interface_a['id'], 31 | interface_z['id'] 32 | ) 33 | ) 34 | assert_output(result, ['Added circuit!']) 35 | 36 | # Verify the circuit was created by listing 37 | result = runner.run('circuits list') 38 | assert_output(result, ['add_test1']) 39 | 40 | 41 | def test_circuits_add_single_sided(runner, interface_a): 42 | """ Add a circuit with no remote end """ 43 | 44 | with runner.isolated_filesystem(): 45 | result = runner.run( 46 | 'circuits add -A {0} -n add_test2'.format(interface_a['id']) 47 | ) 48 | 49 | assert_output(result, ['Added circuit!']) 50 | 51 | result = runner.run('circuits list') 52 | assert_output(result, ['add_test2']) 53 | 54 | 55 | def test_circuits_add_intf_reuse(runner, interface_a): 56 | """ 57 | Try creating two circuits with the same interface, which should fail 58 | """ 59 | 60 | with runner.isolated_filesystem(): 61 | cmd = 'circuits add -A {0} -n {1}' 62 | 63 | result = runner.run(cmd.format(interface_a['id'], 'circuit1')) 64 | assert result.exit_code == 0 65 | 66 | result = runner.run(cmd.format(interface_a['id'], 'bad_circuit')) 67 | assert result.exit_code != 0 68 | assert 'A-side endpoint Interface already exists' in result.output 69 | 70 | 71 | def test_circuits_add_dupe_name(runner, interface_a, interface_z): 72 | """ 73 | Try creating two circuits with the same name, which should fail 74 | """ 75 | 76 | with runner.isolated_filesystem(): 77 | cmd = 'circuits add -A {0} -n foo' 78 | 79 | result = runner.run(cmd.format(interface_a['id'])) 80 | assert result.exit_code == 0 81 | 82 | result = runner.run(cmd.format(interface_z['id'])) 83 | assert result.exit_code != 0 84 | assert 'circuit with this name already exists' in result.output 85 | 86 | 87 | def test_circuits_list(runner, circuit): 88 | """ Make sure we can list out a circuit """ 89 | 90 | circuit_name = 'test_circuit' 91 | 92 | with runner.isolated_filesystem(): 93 | result = runner.run('circuits list') 94 | assert_output(result, [circuit_name]) 95 | 96 | result = runner.run('circuits list -i {}'.format(circuit_name)) 97 | assert_output(result, [circuit_name]) 98 | 99 | 100 | def test_circuits_list_query(runner, circuit, attributeless_circuit): 101 | with runner.isolated_filesystem(): 102 | # Should result in an error 103 | result = runner.run('circuits list -q "doesnt=exist"') 104 | assert result.exit_code != 0 105 | assert 'Attribute matching query does not exist' in result.output 106 | 107 | # Test some basic set queries with the two circuits 108 | result = runner.run('circuits list -q "owner=alice"') 109 | assert result.output == "test_circuit\n" 110 | 111 | result = runner.run('circuits list -q "-owner=alice"') 112 | assert result.output == "attributeless_circuit\n" 113 | 114 | 115 | def test_circuits_list_nonexistant(runner): 116 | """ Listing a non-existant circuit should fail """ 117 | 118 | with runner.isolated_filesystem(): 119 | result = runner.run('circuits list -i nopenopenope') 120 | 121 | assert result.exit_code != 0 122 | assert 'No such Circuit found' in result.output 123 | 124 | 125 | def test_circuits_list_natural_key_output(runner, circuit): 126 | """ Natural key output should just list the circuit names """ 127 | 128 | with runner.isolated_filesystem(): 129 | result = runner.run('circuits list -N') 130 | 131 | assert result.exit_code == 0 132 | assert result.output == "test_circuit\n" 133 | 134 | 135 | def test_circuits_list_grep_output(runner, circuit): 136 | """ grep output should list circuit names with all the attributes """ 137 | 138 | expected_output = ( 139 | "test_circuit owner=alice\n" 140 | "test_circuit vendor=lasers go pew pew\n" 141 | "test_circuit endpoint_a=foo-bar01:eth0\n" 142 | "test_circuit endpoint_z=foo-bar02:eth0\n" 143 | "test_circuit id=9\n" 144 | "test_circuit name=test_circuit\n" 145 | "test_circuit name_slug=test_circuit\n" 146 | ) 147 | 148 | with runner.isolated_filesystem(): 149 | result = runner.run('circuits list -g') 150 | 151 | assert result.exit_code == 0 152 | assert result.output == expected_output 153 | 154 | 155 | def test_circuits_list_addresses(runner, circuit, interface_a, interface_z): 156 | """ Test listing out a circuit's interface addresses """ 157 | 158 | with runner.isolated_filesystem(): 159 | result = runner.run('circuits list -i {} addresses'.format( 160 | circuit['id'] 161 | )) 162 | 163 | assert_outputs( 164 | result, 165 | [ 166 | [interface_a['addresses'][0].split('/')[0]], 167 | [interface_z['addresses'][0].split('/')[0]] 168 | ] 169 | ) 170 | 171 | 172 | def test_circuits_list_devices(runner, circuit, device_a, device_z): 173 | """ Test listing out a circuit's devices """ 174 | 175 | with runner.isolated_filesystem(): 176 | result = runner.run('circuits list -i {} devices'.format( 177 | circuit['id'] 178 | )) 179 | 180 | assert_outputs( 181 | result, 182 | [ 183 | [device_a['hostname']], 184 | [device_z['hostname']] 185 | ] 186 | ) 187 | 188 | 189 | def test_circuits_list_interfaces(runner, circuit, interface_a, interface_z): 190 | """ Test listing out a circuit's interfaces """ 191 | 192 | with runner.isolated_filesystem(): 193 | result = runner.run('circuits list -i {} interfaces'.format( 194 | circuit['id'] 195 | )) 196 | 197 | assert_outputs( 198 | result, 199 | [ 200 | [interface_a['device_hostname'], interface_a['name']], 201 | [interface_z['device_hostname'], interface_z['name']] 202 | ] 203 | ) 204 | 205 | 206 | def test_circuits_subcommand_query(runner, circuit): 207 | """ Make sure we can run a subcommand given a unique set query """ 208 | 209 | with runner.isolated_filesystem(): 210 | result = runner.run('circuits list -q owner=alice interfaces') 211 | assert result.exit_code == 0 212 | 213 | 214 | def test_circuits_remove(runner, circuit): 215 | """ Make sure we can remove an existing circuit """ 216 | 217 | circuit_name = 'test_circuit' 218 | 219 | with runner.isolated_filesystem(): 220 | result = runner.run('circuits remove -i {}'.format(circuit_name)) 221 | assert result.exit_code == 0 222 | 223 | 224 | def test_circuits_update_name(runner, circuit): 225 | """ Test update by changing the circuit name """ 226 | 227 | old_name = 'test_circuit' 228 | new_name = 'awesome_circuit' 229 | 230 | with runner.isolated_filesystem(): 231 | result = runner.run('circuits update -i {} -n {}'.format( 232 | old_name, 233 | new_name 234 | )) 235 | assert result.exit_code == 0 236 | 237 | # Make sure we can look up the circuit by its new name 238 | result = runner.run('circuits list -i {}'.format(new_name)) 239 | assert_output(result, [new_name]) 240 | 241 | # Make sure the old name doesn't exist 242 | result = runner.run('circuits list -i {}'.format(old_name)) 243 | assert result.exit_code != 0 244 | assert 'No such Circuit found' in result.output 245 | 246 | 247 | def test_circuits_update_interface(runner, circuit, interface): 248 | """ Test updating a circuit's Z side interface """ 249 | 250 | with runner.isolated_filesystem(): 251 | result = runner.run('circuits update -i {0} -Z {1}'.format( 252 | circuit['name'], interface['id'] 253 | )) 254 | assert_output(result, ['Updated circuit!']) 255 | -------------------------------------------------------------------------------- /tests/app/test_protocol_types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Test Circuits in the CLI app. 5 | """ 6 | 7 | from __future__ import absolute_import, unicode_literals 8 | import logging 9 | 10 | import pytest 11 | 12 | from tests.fixtures import (attribute, attributes, client, config, device, 13 | interface, network, site, site_client) 14 | 15 | from tests.fixtures.circuits import circuit 16 | 17 | from tests.fixtures.protocol_types import (protocol_type, protocol_types, 18 | protocol_attribute, 19 | protocol_attribute2) 20 | 21 | from tests.util import CliRunner, assert_output 22 | 23 | log = logging.getLogger(__name__) 24 | 25 | 26 | def test_protocol_types_add(site_client, protocol_attribute): 27 | """Test ``nsot protocol_types add``.""" 28 | 29 | runner = CliRunner(site_client.config) 30 | with runner.isolated_filesystem(): 31 | # Add a protocol_type by name. 32 | result = runner.run("protocol_types add -n bgp") 33 | assert result.exit_code == 0 34 | assert 'Added protocol_type!' in result.output 35 | 36 | # Verify addition. 37 | result = runner.run('protocol_types list') 38 | assert result.exit_code == 0 39 | assert 'bgp' in result.output 40 | 41 | # Add a protocol with same name and fail. 42 | result = runner.run("protocol_types add -n bgp") 43 | expected_output = 'The fields site, name must make a unique set.' 44 | assert result.exit_code != 0 45 | assert expected_output in result.output 46 | 47 | # Add second protocol_type by name. 48 | result = runner.run("protocol_types add -n ospf -d 'OSPF is the best'") 49 | assert result.exit_code == 0 50 | assert 'Added protocol_type!' in result.output 51 | 52 | # Verify default site is assigned and verify description. 53 | site_id = str(protocol_attribute['site_id']) 54 | result = runner.run('protocol_types list -i 2') 55 | assert result.exit_code == 0 56 | assert site_id in result.output 57 | assert 'OSPF is the best' in result.output 58 | 59 | # Add a third protocol type with required_attribute. 60 | result = runner.run('protocol_types add -n tcp -r %s' % protocol_attribute['name']) 61 | assert result.exit_code == 0 62 | assert 'Added protocol_type!' in result.output 63 | 64 | # Verify protocol attribute is assigned. 65 | result = runner.run('protocol_types list -n tcp') 66 | assert result.exit_code == 0 67 | assert protocol_attribute['name'] in result.output 68 | 69 | 70 | def test_protocol_types_list(site_client, protocol_type, protocol_attribute, protocol_attribute2): 71 | """Test ``nsot protocol_types list``""" 72 | 73 | runner = CliRunner(site_client.config) 74 | with runner.isolated_filesystem(): 75 | # Basic List. 76 | result = runner.run('protocol_types list') 77 | assert result.exit_code == 0 78 | assert protocol_type['name'] in result.output 79 | 80 | # Test -d/--description 81 | result = runner.run('protocol_types list -d "%s"' % protocol_type['description']) 82 | assert result.exit_code == 0 83 | assert protocol_type['description'] in result.output 84 | 85 | # Test -n/--name 86 | result = runner.run('protocol_types list -n %s' % protocol_type['name']) 87 | assert result.exit_code == 0 88 | assert protocol_type['name'] in result.output 89 | 90 | # Test -s/--site 91 | result = runner.run('protocol_types list -s %s' % protocol_type['site']) 92 | assert result.exit_code == 0 93 | assert protocol_type['name'] in result.output 94 | 95 | # Test -i/--id 96 | result = runner.run('protocol_types list -i %s' % protocol_type['id']) 97 | assert result.exit_code == 0 98 | assert protocol_type['name'] in result.output 99 | 100 | 101 | def test_protocol_types_list_limit(site_client, protocol_types): 102 | """ 103 | If ``--limit 2`` is used, we should only see the first two Protocol objects 104 | """ 105 | limit = 2 106 | runner = CliRunner(site_client.config) 107 | 108 | with runner.isolated_filesystem(): 109 | result = runner.run('protocol_types list -l {}'.format(limit)) 110 | assert result.exit_code == 0 111 | 112 | expected_types = protocol_types[:limit] 113 | unexpected_types = protocol_types[limit:] 114 | 115 | for t in expected_types: 116 | assert t['name'] in result.output 117 | for t in unexpected_types: 118 | assert t['name'] not in result.output 119 | 120 | 121 | def test_protocol_types_list_offset(site_client, protocol_types): 122 | """ 123 | If ``--limit 2`` and ``--offset 2`` are used, we should only see the third 124 | and fourth Protocol objects that were created 125 | """ 126 | limit = 2 127 | offset = 2 128 | runner = CliRunner(site_client.config) 129 | 130 | with runner.isolated_filesystem(): 131 | result = runner.run( 132 | 'protocol_types list -l {} -o {}'.format(limit, offset) 133 | ) 134 | assert result.exit_code == 0 135 | 136 | expected_types = protocol_types[offset:limit+offset] 137 | unexpected_types = protocol_types[limit+offset:] 138 | 139 | for t in expected_types: 140 | assert t['name'] in result.output 141 | for t in unexpected_types: 142 | assert t['name'] not in result.output 143 | 144 | 145 | def test_protocol_types_update(site_client, protocol_type, protocol_attribute): 146 | """Test ``nsot protocol_types update``""" 147 | 148 | pt_id = protocol_type['id'] 149 | 150 | runner = CliRunner(site_client.config) 151 | with runner.isolated_filesystem(): 152 | # Try to change the name 153 | result = runner.run('protocol_types update -n Cake -i %s' % (pt_id)) 154 | assert result.exit_code == 0 155 | assert 'Updated protocol_type!' in result.output 156 | 157 | # Update the description 158 | result = runner.run('protocol_types update -d Rise -i %s' % (pt_id)) 159 | assert result.exit_code == 0 160 | assert 'Updated protocol_type!' in result.output 161 | 162 | # Assert the Cake Rises 163 | result = runner.run('protocol_types list -i %s' % pt_id) 164 | assert result.exit_code == 0 165 | assert 'Cake' in result.output 166 | assert 'Rise' in result.output 167 | 168 | # Test add attributes 169 | result = runner.run('protocol_types update -r %s -i %s' % ( 170 | protocol_attribute ['name'], 171 | pt_id, 172 | ) 173 | ) 174 | assert result.exit_code == 0 175 | assert 'Updated protocol_type!' in result.output 176 | 177 | # Assert the attribute was added 178 | result = runner.run('protocol_types list -i %s' % pt_id) 179 | assert result.exit_code == 0 180 | assert protocol_attribute['name'] in result.output 181 | 182 | 183 | def test_protocol_types_remove(site_client, protocol_type): 184 | """Test ``nsot protocol_types remove``""" 185 | 186 | runner = CliRunner(site_client.config) 187 | with runner.isolated_filesystem(): 188 | result = runner.run( 189 | 'protocol_types remove -i %s' % (protocol_type['id']) 190 | ) 191 | assert result.exit_code == 0 192 | assert 'Removed protocol_type!' in result.output 193 | -------------------------------------------------------------------------------- /tests/app/test_protocols.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Test Protocols in the CLI app. 5 | """ 6 | 7 | from __future__ import absolute_import, unicode_literals 8 | import logging 9 | 10 | import pytest 11 | 12 | from tests.fixtures import (attribute, attributes, client, config, device, 13 | interface, network, protocol_type, site, 14 | site_client) 15 | from tests.fixtures.circuits import (circuit, circuit_attributes, interface_a, 16 | interface_z, device_a, device_z) 17 | from tests.fixtures.protocols import (protocol, protocols, protocol_attribute, 18 | protocol_attribute2) 19 | 20 | from tests.util import CliRunner, assert_output 21 | 22 | log = logging.getLogger(__name__) 23 | 24 | 25 | def test_protocols_add(site_client, device_a, interface_a, site, protocol_type): 26 | """Test ``nsot protocol add``.""" 27 | 28 | device_id = str(device_a['id']) 29 | interface_id = str(interface_a['id']) 30 | 31 | runner = CliRunner(site_client.config) 32 | with runner.isolated_filesystem(): 33 | # Add a protocol. 34 | result = runner.run( 35 | "protocols add -t bgp -D %s -I %s -e 'my new proto'" % (device_id, interface_id) 36 | ) 37 | assert result.exit_code == 0 38 | assert 'Added protocol!' in result.output 39 | 40 | # Verify addition. 41 | result = runner.run('protocols list') 42 | assert result.exit_code == 0 43 | assert 'bgp' in result.output 44 | assert device_a['hostname'] in result.output 45 | assert 'my new proto' in result.output 46 | 47 | # Add a second protocol with attributes. 48 | attributes = 'foo=test_attribute' 49 | result = runner.run( 50 | "protocols add -t bgp -D %s -I %s -a %s" % (device_id, interface_id, attributes) 51 | ) 52 | assert result.exit_code == 0 53 | assert 'Added protocol!' in result.output 54 | 55 | # Verify addition. 56 | result = runner.run('protocols list') 57 | assert result.exit_code == 0 58 | assert 'bgp' in result.output 59 | assert device_a['hostname'] in result.output 60 | assert attributes in result.output 61 | 62 | 63 | def test_protocols_list(site_client, device_a, interface_a, site, circuit, protocol): 64 | """Test ``nsot protocols list``""" 65 | 66 | device_id = str(device_a['id']) 67 | interface_id = str(interface_a['id']) 68 | protocol_id = str(protocol['id']) 69 | 70 | runner = CliRunner(site_client.config) 71 | with runner.isolated_filesystem(): 72 | result = runner.run('protocols list') 73 | assert result.exit_code == 0 74 | assert 'bgp' in result.output 75 | 76 | # test -t/--type 77 | result = runner.run('protocols list -t bgp') 78 | assert result.exit_code == 0 79 | assert 'bgp' in result.output 80 | 81 | # Test -D/--device 82 | result = runner.run('protocols list -D %s' % device_id) 83 | assert result.exit_code == 0 84 | assert device_a['hostname'] in result.output 85 | 86 | # Test -I/--interface 87 | result = runner.run('protocols list -I %s' % interface_id) 88 | assert result.exit_code == 0 89 | assert interface_a['name_slug'] in result.output 90 | 91 | # Test -a/--attributes 92 | result = runner.run('protocols list -a foo=test_protocol') 93 | assert result.exit_code == 0 94 | assert protocol['attributes']['foo'] in result.output 95 | 96 | # Test -c/--circuit 97 | result = runner.run('protocols list -c %s' % circuit['name']) 98 | assert result.exit_code == 0 99 | assert circuit['name'] in result.output 100 | 101 | # Test -e/--description 102 | result = runner.run('protocols list -e "%s"' % protocol['description']) 103 | assert result.exit_code == 0 104 | assert protocol['description'] in result.output 105 | 106 | # Test -I/--id 107 | result = runner.run('protocols list -i %s' % protocol_id) 108 | assert result.exit_code == 0 109 | assert protocol_id in result.output 110 | 111 | # Test -q/--query 112 | slug = '{device}:{type}:{id}'.format(**protocol) 113 | result = runner.run('protocols list -q foo=test_protocol') 114 | assert result.exit_code == 0 115 | assert slug in result.output 116 | 117 | 118 | def test_protocols_list_limit(site_client, protocols): 119 | """ 120 | If ``--limit 2`` is used, we should only see the first two Protocol objects 121 | """ 122 | limit = 2 123 | runner = CliRunner(site_client.config) 124 | 125 | with runner.isolated_filesystem(): 126 | result = runner.run('protocols list -l {}'.format(limit)) 127 | assert result.exit_code == 0 128 | 129 | expected_protocols = protocols[:limit] 130 | unexpected_protocols = protocols[limit:] 131 | 132 | for p in expected_protocols: 133 | assert p['device'] in result.output 134 | for p in unexpected_protocols: 135 | assert p['device'] not in result.output 136 | 137 | 138 | def test_protocols_list_offset(site_client, protocols): 139 | """ 140 | If ``--limit 2`` and ``--offset 2`` are used, we should only see the third 141 | and fourth Protocol objects that were created 142 | """ 143 | limit = 2 144 | offset = 2 145 | runner = CliRunner(site_client.config) 146 | 147 | with runner.isolated_filesystem(): 148 | result = runner.run('protocols list -l {} -o {}'.format(limit, offset)) 149 | assert result.exit_code == 0 150 | 151 | expected_protocols = protocols[offset:limit+offset] 152 | unexpected_protocols = protocols[limit+offset:] 153 | 154 | for p in expected_protocols: 155 | assert p['device'] in result.output 156 | for p in unexpected_protocols: 157 | assert p['device'] not in result.output 158 | 159 | 160 | def test_protocols_update(site_client, interface_a, device_a, site, circuit, protocol, protocol_attribute): 161 | site_id = str(protocol['site']) 162 | proto_id = protocol['id'] 163 | runner = CliRunner(site_client.config) 164 | with runner.isolated_filesystem(): 165 | # Update description 166 | result = runner.run('protocols update -i %s -e "bees buzz"' % proto_id) 167 | assert result.exit_code == 0 168 | assert 'Updated protocol!' in result.output 169 | 170 | # Ensure that buzz is not the bizness 171 | result = runner.run('protocols list') 172 | assert result.exit_code == 0 173 | assert 'buzz' in result.output 174 | assert 'bizness' not in result.output 175 | 176 | # Add an attribute 177 | result = runner.run( 178 | 'protocols update -i %s --add-attributes -a boo=test_attribute' % proto_id 179 | ) 180 | assert result.exit_code == 0 181 | assert 'Updated protocol!' in result.output 182 | 183 | result = runner.run('protocols list') 184 | assert result.exit_code == 0 185 | assert 'test_attribute' in result.output 186 | 187 | # Add an attribute without using --add-attributes. 188 | result = runner.run( 189 | 'protocols update -i %s -a boo=test_attribute' % proto_id 190 | ) 191 | assert result.exit_code == 0 192 | assert 'Updated protocol!' in result.output 193 | 194 | result = runner.run('protocols list') 195 | assert result.exit_code == 0 196 | assert 'test_attribute' in result.output 197 | 198 | # Delete an attribute 199 | result = runner.run( 200 | 'protocols update -i %s --delete-attributes -a foo=test_protocol' % proto_id 201 | ) 202 | assert result.exit_code == 0 203 | assert 'Updated protocol!' in result.output 204 | 205 | result = runner.run('protocols list') 206 | assert result.exit_code == 0 207 | assert 'test_protocol' not in result.output 208 | 209 | # Replace an attribute 210 | result = runner.run( 211 | 'protocols update -i %s --replace-attributes -a foo=test_replace' % proto_id 212 | ) 213 | assert result.exit_code == 0 214 | assert 'Updated protocol!' in result.output 215 | 216 | result = runner.run('protocols list') 217 | assert result.exit_code == 0 218 | assert 'test_protocol' not in result.output 219 | assert 'test_replace' in result.output 220 | 221 | 222 | def test_protocols_remove(site_client, protocol): 223 | runner = CliRunner(site_client.config) 224 | 225 | with runner.isolated_filesystem(): 226 | result = runner.run('protocols remove -i %s -s %s' % (protocol['id'], protocol['site'])) 227 | assert result.exit_code == 0 228 | 229 | result = runner.run('protocols list') 230 | assert result.exit_code == 0 231 | assert 'No protocol found' in result.output 232 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Make dummy data and fixtures and stuff. 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | from __future__ import absolute_import 9 | import logging 10 | import os 11 | 12 | import pytest 13 | from pytest_django.fixtures import live_server, django_user_model 14 | 15 | from pynsot.client import get_api_client 16 | from tests.util import CliRunner 17 | 18 | __all__ = ('django_user_model', 'live_server') 19 | 20 | 21 | # Logger 22 | log = logging.getLogger(__name__) 23 | 24 | # API version to use for the API client 25 | API_VERSION = os.getenv('NSOT_API_VERSION') 26 | 27 | # Dummy config data used for testing auth_header authentication. 28 | AUTH_HEADER_CONFIG = { 29 | 'auth_method': 'auth_header', 30 | 'default_domain': 'localhost', 31 | 'auth_header': 'X-NSoT-Email', 32 | } 33 | 34 | # This is used to test dotfile settings. 35 | DOTFILE_CONFIG_DATA = { 36 | 'auth_token': { 37 | 'email': 'jathan@localhost', 38 | 'url': 'http://localhost:8990/api', 39 | 'auth_method': 'auth_token', 40 | 'secret_key': 'MJMOl9W7jqQK3h-quiUR-cSUeuyDRhbn2ca5E31sH_I=', 41 | }, 42 | 'auth_header': { 43 | 'email': 'jathan@localhost', 44 | 'url': 'http://localhost:8990/api', 45 | 'auth_method': 'auth_header', 46 | 'default_domain': 'localhost', 47 | 'auth_header': 'X-NSoT-Email', 48 | } 49 | } 50 | 51 | 52 | @pytest.fixture 53 | def config(live_server, django_user_model): 54 | """Create a user and return an auth_token config matching that user.""" 55 | user = django_user_model.objects.create( 56 | email='jathan@localhost', is_superuser=True, is_staff=True 57 | ) 58 | data = { 59 | 'email': user.email, 60 | 'secret_key': user.secret_key, 61 | 'auth_method': 'auth_token', 62 | 'url': live_server.url + '/api', 63 | # 'api_version': API_VERSION, 64 | 'api_version': '1.0', # Hard-coded. 65 | } 66 | 67 | return data 68 | 69 | 70 | @pytest.fixture 71 | def auth_header_config(config): 72 | """Return an auth_header config.""" 73 | config.pop('secret_key') 74 | config.update(AUTH_HEADER_CONFIG) 75 | return config 76 | 77 | 78 | @pytest.fixture 79 | def client(config): 80 | """Create and return an admin client.""" 81 | api = get_api_client(extra_args=config, use_dotfile=False) 82 | api.config = config 83 | return api 84 | 85 | 86 | @pytest.fixture 87 | def site(client): 88 | """Returns a Site object.""" 89 | return client.sites.post({'name': 'Foo', 'description': 'Foo site.'}) 90 | 91 | 92 | @pytest.fixture 93 | def site_client(client, site): 94 | """Returns a client tied to a specific site.""" 95 | client.config['default_site'] = site['id'] 96 | client.default_site = site['id'] 97 | return client 98 | 99 | 100 | @pytest.fixture 101 | def runner(site_client): 102 | return CliRunner(site_client.config) 103 | 104 | 105 | @pytest.fixture 106 | def attribute(site_client): 107 | """Return an Attribute object.""" 108 | return site_client.sites(site_client.default_site).attributes.post( 109 | {'name': 'foo', 'resource_name': 'Device'} 110 | ) 111 | 112 | 113 | @pytest.fixture 114 | def attributes(site_client): 115 | """ A bunch of attributes for each resource type """ 116 | 117 | results = [] 118 | resources = ( 119 | 'Circuit', 120 | 'Device', 121 | 'Interface', 122 | 'Network', 123 | 'Protocol', 124 | ) 125 | 126 | for r in resources: 127 | attr = site_client.sites(site_client.default_site).attributes.post( 128 | {'name': 'foo', 'resource_name': r} 129 | ) 130 | 131 | results.append(attr) 132 | 133 | return results 134 | 135 | 136 | @pytest.fixture 137 | def device(site_client, attributes): 138 | """Return a Device object.""" 139 | return site_client.sites(site_client.default_site).devices.post( 140 | { 141 | 'hostname': 'foo-bar1', 142 | 'attributes': { 143 | 'foo': 'test_device' 144 | }, 145 | } 146 | ) 147 | 148 | 149 | @pytest.fixture 150 | def network(site_client, attributes): 151 | """Return a Network object.""" 152 | return site_client.sites(site_client.default_site).networks.post( 153 | { 154 | 'cidr': '10.20.30.0/24', 155 | 'attributes': { 156 | 'foo': 'test_network' 157 | } 158 | } 159 | ) 160 | 161 | 162 | @pytest.fixture 163 | def interface(site_client, attributes, device, network): 164 | """ 165 | Return an Interface object. 166 | 167 | Interface is bound to ``device`` with an address assigned from ``network``. 168 | """ 169 | device_id = device['id'] 170 | return site_client.sites(site_client.default_site).interfaces.post( 171 | { 172 | 'name': 'eth0', 173 | 'addresses': ['10.20.30.1/32'], 174 | 'device': device_id, 175 | 'attributes': {'foo': 'test_interface'}, 176 | } 177 | ) 178 | 179 | 180 | @pytest.fixture 181 | def protocol_type(site_client): 182 | """ 183 | Return a Protocol Type Object. 184 | """ 185 | return site_client.sites(site_client.default_site).protocol_types.post( 186 | { 187 | 'name': 'bgp', 188 | } 189 | ) 190 | -------------------------------------------------------------------------------- /tests/fixtures/circuits.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | import pytest 6 | 7 | from tests.fixtures import site_client 8 | from six.moves import map 9 | 10 | 11 | @pytest.fixture 12 | def circuit_attributes(site_client): 13 | attr_names = [ 14 | 'owner', 15 | 'vendor' 16 | ] 17 | 18 | attrs = ({'name': a, 'resource_name': 'Circuit'} for a in attr_names) 19 | client = site_client.sites(site_client.default_site).attributes 20 | return list(map(client.post, attrs)) 21 | 22 | 23 | @pytest.fixture 24 | def device_a(site_client): 25 | """ Device for the A side of the circuit """ 26 | 27 | return site_client.sites(site_client.default_site).devices.post( 28 | {'hostname': 'foo-bar01'} 29 | ) 30 | 31 | 32 | @pytest.fixture 33 | def device_z(site_client): 34 | """ Device for the Z side of the circuit """ 35 | 36 | return site_client.sites(site_client.default_site).devices.post( 37 | {'hostname': 'foo-bar02'} 38 | ) 39 | 40 | 41 | @pytest.fixture 42 | def interface_a(site_client, device_a, network): 43 | """ Interface for the A side of the circuit """ 44 | 45 | return site_client.sites(site_client.default_site).interfaces.post({ 46 | 'device': device_a['id'], 47 | 'name': 'eth0', 48 | 'addresses': ['10.20.30.2/32'], 49 | }) 50 | 51 | 52 | @pytest.fixture 53 | def interface_z(site_client, device_z, network): 54 | """ Interface for the Z side of the circuit """ 55 | 56 | return site_client.sites(site_client.default_site).interfaces.post({ 57 | 'device': device_z['id'], 58 | 'name': 'eth0', 59 | 'addresses': ['10.20.30.3/32'], 60 | }) 61 | 62 | 63 | @pytest.fixture 64 | def circuit(site_client, circuit_attributes, interface_a, interface_z): 65 | """ Circuit connecting interface_a to interface_z """ 66 | 67 | return site_client.sites(site_client.default_site).circuits.post({ 68 | 'name': 'test_circuit', 69 | 'endpoint_a': interface_a['id'], 70 | 'endpoint_z': interface_z['id'], 71 | 'attributes': { 72 | 'owner': 'alice', 73 | 'vendor': 'lasers go pew pew', 74 | }, 75 | }) 76 | 77 | 78 | @pytest.fixture 79 | def dangling_circuit(site_client, interface): 80 | """ 81 | Circuit where we only own the local site, remote (Z) end is a vendor 82 | """ 83 | 84 | return site_client.sites(site_client.default_site).circuits.post({ 85 | 'name': 'remote_vendor_circuit', 86 | 'endpoint_a': interface['id'], 87 | }) 88 | 89 | 90 | @pytest.fixture 91 | def attributeless_circuit(site_client, interface): 92 | """ Circuit with no attributes set """ 93 | 94 | return site_client.sites(site_client.default_site).circuits.post({ 95 | 'name': 'attributeless_circuit', 96 | 'endpoint_a': interface['id'], 97 | 'attributes': {}, 98 | }) 99 | -------------------------------------------------------------------------------- /tests/fixtures/protocol_types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | import pytest 6 | 7 | from tests.fixtures import site_client 8 | from six.moves import range 9 | 10 | @pytest.fixture 11 | def protocol_type(site_client, protocol_attribute, protocol_attribute2): 12 | """ 13 | Return a Protocol Type Object. 14 | """ 15 | return site_client.sites(site_client.default_site).protocol_types.post( 16 | { 17 | 'name': 'bgp', 18 | 'required-attributes': ['boo', 'foo',], 19 | 'description': 'bgp is my bestie', 20 | } 21 | ) 22 | 23 | 24 | @pytest.fixture 25 | def protocol_attribute(site_client): 26 | return site_client.sites(site_client.default_site).attributes.post( 27 | { 28 | 'name':'boo', 29 | 'value': 'test_attribute', 30 | 'resource_name': 'Protocol', 31 | } 32 | ) 33 | 34 | @pytest.fixture 35 | def protocol_attribute2(site_client): 36 | return site_client.sites(site_client.default_site).attributes.post( 37 | { 38 | 'name':'foo', 39 | 'value': 'test_attribute', 40 | 'resource_name': 'Protocol', 41 | } 42 | ) 43 | 44 | 45 | @pytest.fixture 46 | def protocol_types(site_client): 47 | """ 48 | A group of ProtocolTypes for testing limit/offset/etc. 49 | """ 50 | protocol_types = [] 51 | site = site_client.sites(site_client.default_site) 52 | 53 | for i in range(1, 6): 54 | protocol_type = site.protocol_types.post({ 55 | 'name': 'type{}'.format(i), 56 | }) 57 | protocol_types.append(protocol_type) 58 | 59 | return protocol_types 60 | -------------------------------------------------------------------------------- /tests/fixtures/protocols.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | import pytest 6 | 7 | from tests.fixtures import (protocol_type, site_client) 8 | 9 | from .circuits import (circuit, device_a, interface_a) 10 | from six.moves import range 11 | 12 | @pytest.fixture 13 | def protocol(site_client, device_a, interface_a, circuit, protocol_type): 14 | """ 15 | Return a Protocol Object. 16 | """ 17 | device_id = device_a['id'] 18 | interface_slug = interface_a['name_slug'] 19 | return site_client.sites(site_client.default_site).protocols.post( 20 | { 21 | 'device': device_id, 22 | 'type': 'bgp', 23 | 'interface': interface_slug, 24 | 'attributes': {'foo': 'test_protocol'}, 25 | 'circuit': circuit['name'], 26 | 'description': 'bgp is the best', 27 | } 28 | ) 29 | 30 | @pytest.fixture 31 | def protocol_attribute(site_client): 32 | return site_client.sites(site_client.default_site).attributes.post( 33 | { 34 | 'name':'boo', 35 | 'value': 'test_attribute', 36 | 'resource_name': 'Protocol', 37 | } 38 | ) 39 | 40 | @pytest.fixture 41 | def protocol_attribute2(site_client): 42 | return site_client.sites(site_client.default_site).attributes.post( 43 | { 44 | 'name':'foo', 45 | 'value': 'test_protocol', 46 | 'resource_name': 'Protocol', 47 | } 48 | ) 49 | 50 | 51 | @pytest.fixture 52 | def protocols(site_client, protocol_type, protocol_attribute2): 53 | """ 54 | A group of Protocol objects for testing limit/offest/etc. 55 | """ 56 | protocols = [] 57 | site = site_client.sites(site_client.default_site) 58 | for i in range(1, 6): 59 | device_name = 'device{:02d}'.format(i) 60 | interface_name = 'Ethernet1/{}'.format(i) 61 | 62 | device = site.devices.post({'hostname': device_name}) 63 | interface = site.interfaces.post({ 64 | 'device': device['id'], 65 | 'name': interface_name, 66 | }) 67 | 68 | protocol = site.protocols.post({ 69 | 'type': protocol_type['name'], 70 | 'device': device['id'], 71 | 'interface': interface['id'], 72 | 'attributes': {'foo': 'bar'}, 73 | }) 74 | protocols.append(protocol) 75 | 76 | return protocols 77 | -------------------------------------------------------------------------------- /tests/generate_test_data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import unicode_literals 3 | 4 | """ 5 | Generate fixtures for NSoT dev/testing. 6 | """ 7 | 8 | from __future__ import absolute_import 9 | from __future__ import print_function 10 | import netaddr 11 | 12 | # from . import util as fx 13 | from . import util as fx 14 | from pynsot import client 15 | 16 | 17 | # API_URL = 'http://localhost:8990/api' 18 | API_URL = 'http://localhost:8000/api' 19 | # API_URL = 'http://localhost:80/api' 20 | EMAIL = 'admin@localhost' 21 | 22 | api = client.EmailHeaderClient(API_URL, email=EMAIL) 23 | site_obj = api.sites.post({'name': 'Test Site'})['data']['site'] 24 | site = api.sites(site_obj['id']) 25 | 26 | 27 | ############## 28 | # Attributes # 29 | ############## 30 | 31 | # Populate attributes 32 | resource_names = ('Device', 'Network', 'Interface') 33 | attributes = [] 34 | for resource_name in resource_names: 35 | attrs = fx.enumerate_attributes(resource_name) 36 | attributes.extend(attrs) 37 | 38 | # Create Attribute objects 39 | site.attributes.post(attributes) 40 | print('Populated Attributes.') 41 | 42 | 43 | ############ 44 | # Networks # 45 | ############ 46 | 47 | # What's our supernet to derive from? 48 | supernet = netaddr.IPNetwork('10.16.32.0/20') # 16x /24 49 | # supernet = netaddr.IPNetwork('10.16.32.0/23') # 2x /24 50 | 51 | # Populate the /20 52 | parents = fx.generate_networks(ipv4list=[str(supernet)]) 53 | 54 | # Populate the /24s 55 | networks = fx.generate_networks(ipv4list=(str(n) for n in supernet.subnet(24))) 56 | 57 | # Populate the /32s 58 | addresses = [] 59 | for net in supernet.subnet(24): 60 | hosts = (str(h) for h in net.iter_hosts()) 61 | addresses.extend(fx.generate_networks(ipv4list=hosts)) 62 | 63 | # Create Network objects 64 | for items in (parents, networks, addresses): 65 | site.networks.post(items) 66 | print('Populated Networks.') 67 | 68 | 69 | ########### 70 | # Devices # 71 | ########### 72 | # 16 hosts per /24 73 | devices = fx.generate_devices(16 * len(networks)) 74 | 75 | # Create Device objects 76 | site.devices.post(devices) 77 | print('Populated Devices.') 78 | 79 | 80 | ############## 81 | # Interfaces # 82 | ############## 83 | # Get list of device ids 84 | dev_resp = site.devices.get() 85 | device_ids = [dev['id'] for dev in dev_resp['data']['devices']] 86 | 87 | # Get list of IP addreses and make them an iterable for take() 88 | ip_list = [a['cidr'] for a in addresses] 89 | ip_iter = iter(ip_list) 90 | 91 | # 16 interfaces per host 92 | interfaces = [] 93 | for device_id in device_ids: 94 | my_ips = fx.take_n(16, ip_iter) 95 | my_interfaces = fx.generate_interfaces(device_id, address_pool=my_ips) 96 | interfaces.extend(my_interfaces) 97 | 98 | # Create Interface objects 99 | site.interfaces.post(interfaces) 100 | print('Populated Interfaces.') 101 | -------------------------------------------------------------------------------- /tests/nsot_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | This configuration file is just Python code. You may override any global 3 | defaults by specifying them here. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/1.8/topics/settings/ 7 | 8 | For the full list of settings and their values, see 9 | https://docs.djangoproject.com/en/1.8/ref/settings/ 10 | """ 11 | 12 | 13 | from __future__ import absolute_import 14 | from nsot.conf.settings import * # noqa 15 | import os.path 16 | import django 17 | 18 | 19 | # Path where the config is found. 20 | CONF_ROOT = os.path.dirname(__file__) 21 | 22 | # A boolean that turns on/off debug mode. Never deploy a site into production 23 | # with DEBUG turned on. 24 | # Default: False 25 | DEBUG = False 26 | 27 | ############ 28 | # Database # 29 | ############ 30 | # https://docs.djangoproject.com/en/dev/ref/settings/#databases 31 | DATABASES = { 32 | 'default': { 33 | 'ENGINE': 'django.db.backends.sqlite3', 34 | 'NAME': os.path.join(CONF_ROOT, 'nsot.sqlite3'), 35 | 'USER': 'nsot', 36 | 'PASSWORD': '', 37 | 'HOST': '', 38 | 'PORT': '', 39 | } 40 | } 41 | 42 | ############### 43 | # Application # 44 | ############### 45 | 46 | # The address on which the application will listen. 47 | # Default: localhost 48 | NSOT_HOST = 'localhost' 49 | 50 | # The port on which the application will be accessed. 51 | # Default: 8990 52 | NSOT_PORT = 8990 53 | 54 | # The number of gunicorn worker processes for handling requests. 55 | # Default: 4 56 | NSOT_NUM_WORKERS = 4 57 | 58 | # Timeout in seconds before gunicorn workers are killed/restarted. 59 | # Default: 30 60 | NSOT_WORKER_TIMEOUT = 30 61 | 62 | # If True, serve static files directly from the app. 63 | # Default: True 64 | SERVE_STATIC_FILES = True 65 | 66 | ############ 67 | # Security # 68 | ############ 69 | 70 | # A URL-safe base64-encoded 32-byte key. This must be kept secret. Anyone with 71 | # this key is able to create and read messages. This key is used for 72 | # encryption/decryption of sessions and auth tokens. A unique key is randomly 73 | # generated for you when you utilize ``nsot-server init`` 74 | # https://cryptography.io/en/latest/fernet/#cryptography.fernet.Fernet.generate_key 75 | SECRET_KEY = u'fMK68NKgazLCjjTXjDtthhoRUS8IV4lwD-9G7iVd2Xs=' 76 | 77 | # Header to check for Authenticated Email. This is intended for use behind an 78 | # authenticating reverse proxy. 79 | USER_AUTH_HEADER = 'X-NSoT-Email' 80 | 81 | # The age, in seconds, until an AuthToken granted by the API will expire. 82 | # Default: 600 83 | AUTH_TOKEN_EXPIRY = 600 # 10 minutes 84 | 85 | # A list of strings representing the host/domain names that this Django site 86 | # can serve. This is a security measure to prevent an attacker from poisoning 87 | # caches and triggering password reset emails with links to malicious hosts by 88 | # submitting requests with a fake HTTP Host header, which is possible even 89 | # under many seemingly-safe web server configurations. 90 | # https://docs.djangoproject.com/en/1.8/ref/settings/#allowed-hosts 91 | ALLOWED_HOSTS = ['*'] 92 | 93 | # Force django setup to finish before executing tests. This is needed because we don't 94 | # set DJANGO_SETTINGS_MODULE in pytest.ini. 95 | django.setup() 96 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Test the API client. 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | from __future__ import absolute_import 9 | import logging 10 | import pytest 11 | 12 | from pynsot.util import get_result 13 | from .fixtures import config, client 14 | 15 | 16 | __all__ = ('client', 'config', 'pytest') 17 | 18 | 19 | log = logging.getLogger(__name__) 20 | 21 | 22 | def test_authentication(client): 23 | """Test manual client authentication.""" 24 | auth = { 25 | 'email': client.config['email'], 26 | 'secret_key': client.config['secret_key'] 27 | } 28 | # Good 29 | resp = client.authenticate.post(auth) 30 | result = get_result(resp) 31 | assert 'auth_token' in result 32 | 33 | 34 | def test_sites(client): 35 | """Test working with sites using the client.""" 36 | site = client.sites.post({'name': 'Foo'}) 37 | assert client.sites.get() == [site] 38 | assert client.sites(site['id']).get() == site 39 | -------------------------------------------------------------------------------- /tests/test_dotfile.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Test the dotfile. 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | from __future__ import absolute_import 9 | import copy 10 | import logging 11 | import os 12 | import tempfile 13 | import unittest 14 | 15 | from pynsot import constants, dotfile 16 | 17 | from .fixtures import DOTFILE_CONFIG_DATA 18 | 19 | 20 | log = logging.getLogger(__name__) 21 | 22 | 23 | class TestDotFile(unittest.TestCase): 24 | def setUp(self): 25 | """Automatically create a tempfile for each test.""" 26 | fd, filepath = tempfile.mkstemp() 27 | self.filepath = filepath 28 | self.config_data = copy.deepcopy(DOTFILE_CONFIG_DATA) 29 | 30 | self.config_path = os.path.expanduser('~/.pynsotrc') 31 | self.backup_path = self.config_path + '.orig' 32 | self.backed_up = False 33 | if os.path.exists(self.config_path): 34 | log.debug('Config found, backing up...') 35 | os.rename(self.config_path, self.backup_path) 36 | self.backed_up = True 37 | 38 | def test_read_success(self): 39 | """Test that file can be read.""" 40 | config = dotfile.Dotfile(self.filepath) 41 | config.write(self.config_data['auth_token']) 42 | config.read() 43 | 44 | def test_read_failure(self): 45 | """Test that that a missing file raises an error.""" 46 | self.filepath = tempfile.mktemp() # Doesn't create the temp file 47 | config = dotfile.Dotfile(self.filepath) 48 | self.assertRaises( 49 | IOError, # This means it's trying to read from stdin 50 | config.read, 51 | ) 52 | 53 | def test_write(self): 54 | """Test that file can be written.""" 55 | config = dotfile.Dotfile(self.filepath) 56 | config.write(self.config_data['auth_token']) 57 | 58 | def test_validate_perms_success(self): 59 | """Test that file permissions are ok.""" 60 | self.filepath = tempfile.mktemp() # Doesn't create a temp file 61 | config = dotfile.Dotfile(self.filepath) 62 | config.write({}) 63 | config.validate_perms() 64 | 65 | def test_validate_fields_auth_token(self): 66 | """Test that auth_token fields check out.""" 67 | config = dotfile.Dotfile(self.filepath) 68 | 69 | self._validate_test_fields('auth_token', config) 70 | 71 | def test_validate_fields_auth_header(self): 72 | """Test that auth_header fields check out.""" 73 | config = dotfile.Dotfile(self.filepath) 74 | 75 | self._validate_test_fields('auth_header', config) 76 | 77 | def _validate_test_fields(self, auth_method, config): 78 | config_data = self.config_data[auth_method] 79 | 80 | # We're not testing optional fields, yo. 81 | for optional_field in constants.OPTIONAL_FIELDS: 82 | config_data.pop(optional_field, None) 83 | 84 | # We're going to test every required field. 85 | fields = sorted(config_data) 86 | my_config = {} 87 | 88 | # Fetch the required_fields for this auth_method 89 | required_fields = config.get_required_fields(auth_method) 90 | 91 | # Iterate through the sorted list of fields to make sure that each one 92 | # raises an error as expected. 93 | err = 'Missing required field: ' 94 | for field in fields: 95 | with self.assertRaisesRegexp(dotfile.DotfileError, err + field): 96 | config.read() 97 | config.validate_fields(my_config, required_fields) 98 | 99 | my_config[field] = config_data[field] 100 | config.write(my_config) 101 | 102 | def tearDown(self): 103 | """Delete a tempfile if it exists. Otherwise carry on.""" 104 | try: 105 | os.remove(self.filepath) 106 | except OSError: 107 | pass 108 | 109 | if self.backed_up: 110 | log.debug('Restoring original config.') 111 | os.rename(self.backup_path, self.config_path) # Restore original 112 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Test the Models 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | from __future__ import absolute_import 9 | import pytest 10 | 11 | from pytest import raises 12 | 13 | from pynsot.models import Resource, Network, Device, Interface 14 | from pynsot.util import get_result 15 | from .fixtures import config, client, site 16 | 17 | __all__ = ('client', 'config', 'pytest', 'site') 18 | 19 | 20 | def test_fail_abc(client): 21 | '''Test that abc is doing its job''' 22 | class A(Resource): 23 | pass 24 | 25 | with raises(TypeError): 26 | a = A(client=client, site_id=1) 27 | return a 28 | 29 | 30 | def test_correct_site(client, site): 31 | '''Tests that a resource gets sent to the correct site_id''' 32 | n = Network(client=client, site_id=site['id'], cidr='8.8.8.0/24') 33 | assert n.ensure() 34 | assert n.existing_resource()['site_id'] == site['id'] 35 | 36 | from pynsot.util import get_result 37 | manual = get_result(client.sites(site['id']).networks('8.8.8.0/24').get()) 38 | assert manual['site_id'] == site['id'] 39 | 40 | 41 | def test_args(client, site): 42 | '''Tests for exceptions that should be thrown in certain arg combos''' 43 | with raises(TypeError): 44 | # not including cidr or raw 45 | Network(client=client, site=site['id']) 46 | with raises(TypeError): 47 | # including raw, but not raw['site_id'] 48 | Network(client=client, raw={}) 49 | with raises(TypeError): 50 | # not including hostname or raw 51 | Device(client=client, site=site['id']) 52 | with raises(TypeError): 53 | # not including name+device or raw 54 | Interface(client=client, site=site['id']) 55 | 56 | 57 | def test_raw_precedence(client, site): 58 | '''Makes sure raw kwarg site_id takes precedence''' 59 | # first create a resource to get raw from 60 | n = Network(client=client, site_id=site['id'], cidr='8.8.8.0/24') 61 | assert n.ensure() 62 | 63 | # Make sure that raw settings take precedence 64 | raw = n.existing_resource() 65 | n2 = Network(client=client, site=999, raw=raw) 66 | assert n2['site_id'] != 999 67 | 68 | 69 | def test_existing(client, site): 70 | '''Test functionality around existing resource and caching of the result''' 71 | site_id = site['id'] 72 | c = client 73 | 74 | n = Network(client=c, site_id=site_id, cidr='8.8.8.0/24') 75 | assert n.purge() 76 | assert not n.exists() 77 | assert n._existing_resource == {} 78 | assert n.existing_resource() == n._existing_resource 79 | assert n.ensure() 80 | # Make sure cache clearing works 81 | assert n._existing_resource == {} 82 | assert n.exists() 83 | assert n.existing_resource() == n._existing_resource 84 | 85 | 86 | def test_net_closest_parent(client, site): 87 | '''Test that Network.closest_parent returns instance of Network or dict''' 88 | site_id = site['id'] 89 | c = client 90 | 91 | parent = Network(client=c, site_id=site_id, cidr='8.8.8.0/24') 92 | assert parent.ensure() 93 | 94 | child = Network(client=c, site_id=site_id, cidr='8.8.8.8/32') 95 | assert child.closest_parent() == parent 96 | 97 | orphan = Network(client=c, site_id=site_id, cidr='1.1.1.1/32') 98 | assert not orphan.closest_parent() 99 | assert orphan.closest_parent() == {} 100 | 101 | 102 | def test_dict(): 103 | '''Test methods/behavior that should work like a dictionary''' 104 | n = Network(site_id=1, cidr='8.8.8.0/24') 105 | assert n['site_id'] == 1 106 | n['site_id'] = 2 107 | assert n['site_id'] == 2 108 | 109 | assert list(n.keys()) 110 | assert list(n.items()) 111 | assert dict(n) 112 | 113 | 114 | def test_payload_not_none_raw_and_not(client, site): 115 | '''Make sure payload gets set fine for both EZ and raw approaches 116 | 117 | Also make sure both instances are the same, using comparisons 118 | ''' 119 | n = Network(client=client, site_id=site['id'], cidr='8.8.8.0/24') 120 | assert n.payload 121 | assert n.ensure() 122 | 123 | n2 = Network( 124 | raw=get_result( 125 | client.sites(site['id']).networks('8.8.8.0/24').get() 126 | ) 127 | ) 128 | 129 | assert n2.payload 130 | 131 | # Test some magic methods on raw-init'd instance 132 | assert len(n2) 133 | assert n == n2 134 | assert list(n2.keys()) 135 | assert list(n2.items()) 136 | 137 | 138 | def test_clear_cache_on_change(client, site): 139 | '''Test that cache is cleared on any change to the instance''' 140 | site_id = site['id'] 141 | c = client 142 | 143 | n = Network(client=c, site_id=site_id, cidr='8.8.8.0/24') 144 | assert n.ensure() 145 | assert n.exists() 146 | 147 | non_existing_site = get_result(c.sites.get())[-1]['id'] + 1000 148 | n['site_id'] = non_existing_site 149 | # First assert that the cache property was cleared 150 | assert not n._existing_resource 151 | assert not n.existing_resource() 152 | # assert that the resource isn't successfully looked up if site doesn't 153 | # match 154 | assert not n.exists() 155 | # Change site back and test 156 | n['site_id'] = site_id 157 | assert n.exists() 158 | 159 | 160 | def test_ip4_host(): 161 | '''Test to make sure IPv4 host works fine''' 162 | net = '8.8.8.8/32' 163 | n = Network(site_id=1, cidr=net) 164 | assert n['network_address'] == '8.8.8.8' 165 | assert n['prefix_length'] == 32 166 | assert n.identifier == net 167 | assert n['site_id'] == 1 168 | assert n.is_host 169 | assert n.resource_name == 'networks' 170 | assert n['state'] == 'assigned' 171 | assert dict(n) 172 | assert n['attributes'] == {} 173 | assert n['network_address'] 174 | 175 | 176 | def test_ip4_net(client): 177 | '''Test to make sure IPv4 subnet works fine''' 178 | net = '8.8.8.0/24' 179 | n = Network(client=client, site_id=1, cidr=net) 180 | assert n['network_address'] == '8.8.8.0' 181 | assert n['prefix_length'] == 24 182 | assert n.identifier == net 183 | assert n['site_id'] == 1 184 | assert not n.is_host 185 | assert n.resource_name == 'networks' 186 | assert n['state'] == 'allocated' 187 | assert dict(n) 188 | assert n['network_address'] 189 | 190 | 191 | def test_ip6_net(client): 192 | '''Test to make sure IPv6 subnet works fine''' 193 | net = '2001::/64' 194 | n = Network(client=client, site_id=1, cidr=net) 195 | assert n['network_address'] == '2001::' 196 | assert n['prefix_length'] == 64 197 | assert n.identifier == net 198 | assert n['site_id'] == 1 199 | assert not n.is_host 200 | assert n.resource_name == 'networks' 201 | assert n['state'] == 'allocated' 202 | assert dict(n) 203 | assert n['network_address'] 204 | 205 | 206 | def test_ip6_host(client): 207 | '''Test to make sure IPv6 host works fine''' 208 | net = '2001::1/128' 209 | n = Network(client=client, site_id=1, cidr=net) 210 | assert n['network_address'] == '2001::1' 211 | assert n['prefix_length'] == 128 212 | assert n.identifier == net 213 | assert n['site_id'] == 1 214 | assert n.is_host 215 | assert n.resource_name == 'networks' 216 | assert n['state'] == 'assigned' 217 | assert dict(n) 218 | assert n['network_address'] 219 | 220 | 221 | def test_device(client): 222 | '''Test to make sure device works fine''' 223 | name = 'pytest' 224 | d = Device(client=client, site_id=1, hostname=name) 225 | assert d['hostname'] == name 226 | assert d.identifier == name 227 | assert d['site_id'] == 1 228 | assert d.resource_name == 'devices' 229 | assert dict(d) 230 | assert d['hostname'] 231 | assert d['attributes'] == {} 232 | d['attributes'] = {'desc': 'test host'} 233 | 234 | 235 | def test_device_eq(client): 236 | '''Test to make sure device comparison works fine''' 237 | name = 'pytest' 238 | d1 = Device(client=client, site_id=1, hostname=name) 239 | d2 = Device(client=client, site_id=1, hostname=name) 240 | assert d1 == d2 241 | 242 | 243 | def test_interface(client): 244 | '''Test to make sure interface init works fine''' 245 | name = 'eth0' 246 | i = Interface(client=client, site_id=1, name=name, device=1) 247 | assert i['name'] == name 248 | assert i['device'] == 1 249 | assert i['site_id'] == 1 250 | assert i.resource_name == 'interfaces' 251 | assert dict(i) 252 | assert i['name'] 253 | assert i['attributes'] == {} 254 | i['attributes'] = {'desc': 'test host'} 255 | 256 | 257 | def test_interface_eq(client): 258 | '''Test to make sure interface comparison works fine''' 259 | name = 'eth0' 260 | i1 = Interface(client=client, site_id=1, name=name, device=1) 261 | i2 = Interface(client=client, site_id=1, name=name, device=1) 262 | assert i1 == i2 263 | 264 | 265 | def test_ip4_send(client, site): 266 | '''Test upstream write actions for IPv4''' 267 | 268 | site_id = site['id'] 269 | subnet = Network(client=client, site_id=site_id, cidr='254.0.0.0/24') 270 | host = Network(client=client, site_id=site_id, cidr='254.0.0.1/32') 271 | 272 | assert subnet.purge() 273 | assert not subnet.exists() 274 | assert subnet.ensure() 275 | assert subnet.exists() 276 | assert host.purge() 277 | assert not host.exists() 278 | assert host.ensure() 279 | assert host.exists() 280 | 281 | host.purge() 282 | subnet.purge() 283 | assert not all([subnet.exists(), host.exists()]) 284 | 285 | 286 | def test_device_send(client, site): 287 | '''Test upstream write actions for devices''' 288 | site_id = site['id'] 289 | name = 'pytest' 290 | d = Device(client=client, site_id=site_id, hostname=name) 291 | assert d.purge() 292 | assert not d.exists() 293 | assert d.ensure() 294 | assert d.exists() 295 | assert d.purge() 296 | assert not d.exists() 297 | 298 | 299 | # This section is commented out because some changes need make to interfaces to 300 | # better support friendly-specifying hostname vs. device ID during 301 | # instantiation. 302 | # 303 | # def test_interface_send(client, site): 304 | # '''Test upstream write actions for interfaces''' 305 | # site_id = site['id'] 306 | # host = 'intftest' 307 | # d = Device(client=client, site_id=site_id, hostname=host) 308 | # assert d.ensure() 309 | # 310 | # i = Interface(client=client, site_id=site_id, name='eth0', device=host) 311 | # 312 | # assert i.purge() 313 | # assert not i.exists() 314 | # assert i.ensure() 315 | # assert i.exists() 316 | # assert i.purge() 317 | # assert not i.exists() 318 | # 319 | # assert d.purge() 320 | # assert not d.exists() 321 | # 322 | # 323 | # def test_interface_wo_device(client, site): 324 | # '''Test to make sure device is set to 0 if doesnt exist''' 325 | # site_id = site['id'] 326 | # host = 'doesnt-exist-yet' 327 | # d = Device(client=client, site_id=site_id, hostname=host) 328 | # assert d.purge() 329 | # 330 | # i = Interface(client=client, site_id=site_id, name='eth0', device=host) 331 | # assert i 332 | # assert i['device'] == 0 333 | # 334 | # assert d.ensure() 335 | # 336 | # # The device id is checked every time __iter__ is called 337 | # assert i['device'] != 0 338 | # 339 | # d.purge() 340 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Test the utils lib. 5 | """ 6 | 7 | from __future__ import absolute_import 8 | import pytest # noqa 9 | 10 | from pynsot.util import slugify, validate_cidr 11 | 12 | 13 | def test_validate_cidr(): 14 | """Test ``validate_cidr()``.""" 15 | # IPv4 16 | assert validate_cidr('0.0.0.0/0') 17 | assert validate_cidr('1.2.3.4/32') 18 | 19 | # IPv6 20 | assert validate_cidr('::/0') 21 | assert validate_cidr('fe8::/10') 22 | 23 | # Bad 24 | assert not validate_cidr('bogus') 25 | assert not validate_cidr(None) 26 | assert not validate_cidr(object()) 27 | assert not validate_cidr({}) 28 | assert not validate_cidr([]) 29 | 30 | 31 | def test_slugify(): 32 | cases = [ 33 | ('/', '_'), 34 | ('my cool string', 'my cool string'), 35 | ('Ethernet1/2', 'Ethernet1_2'), 36 | ( 37 | 'foo-bar1:xe-0/0/0.0_foo-bar2:xe-0/0/0.0', 38 | 'foo-bar1:xe-0_0_0.0_foo-bar2:xe-0_0_0.0' 39 | ), 40 | ] 41 | 42 | for case, expected in cases: 43 | assert slugify(case) == expected 44 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Utilities for testing. 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | from __future__ import absolute_import 9 | from __future__ import print_function 10 | import collections 11 | import contextlib 12 | from itertools import islice 13 | import logging 14 | import os 15 | import random 16 | import shlex 17 | import shutil 18 | import socket 19 | import struct 20 | import tempfile 21 | 22 | from pynsot.app import app 23 | from pynsot import client 24 | from pynsot import dotfile 25 | 26 | from click.testing import CliRunner as BaseCliRunner 27 | import six 28 | from six.moves import range 29 | 30 | 31 | log = logging.getLogger(__name__) 32 | 33 | # Phony attributes to randomly generate for testing. 34 | ATTRIBUTE_DATA = { 35 | 'lifecycle': ['monitored', 'ignored'], 36 | 'owner': ['jathan', 'gary', 'lisa', 'jimmy', 'bart', 'bob', 'alice'], 37 | 'metro': ['lax', 'iad', 'sjc', 'tyo'], 38 | 'foo': ['bar', 'baz', 'spam'], 39 | } 40 | 41 | # Used to store Attribute/value pairs 42 | Attribute = collections.namedtuple('Attribute', 'name value') 43 | 44 | # Hard-code the app name as 'nsot' to match the CLI util. 45 | app.name = 'nsot' 46 | 47 | 48 | class CliRunner(BaseCliRunner): 49 | """ 50 | Subclass of CliRunner that also creates a .pynsotrc in the isolated 51 | filesystem. 52 | """ 53 | def __init__(self, client_config, *args, **kwargs): 54 | self.client_config = client_config 55 | super(CliRunner, self).__init__(*args, **kwargs) 56 | 57 | @contextlib.contextmanager 58 | def isolated_filesystem(self): 59 | """ 60 | A context manager that creates a temporary folder and changes 61 | the current working directory to it for isolated filesystem tests. 62 | """ 63 | # If user config is found, back it up for duration of each test. 64 | config_path = os.path.expanduser('~/.pynsotrc') 65 | backup_path = config_path + '.orig' 66 | backed_up = False 67 | if os.path.exists(config_path): 68 | log.debug('Config found, backing up...') 69 | os.rename(config_path, backup_path) 70 | backed_up = True 71 | 72 | cwd = os.getcwd() 73 | t = tempfile.mkdtemp() 74 | os.chdir(t) 75 | rcfile = dotfile.Dotfile(config_path) 76 | rcfile.write(self.client_config) 77 | try: 78 | yield t 79 | finally: 80 | os.chdir(cwd) 81 | if backed_up: 82 | log.debug('Restoring original config.') 83 | os.rename(backup_path, config_path) # Restore original 84 | try: 85 | shutil.rmtree(t) 86 | except (OSError, IOError): 87 | pass 88 | 89 | def run(self, command, **kwargs): 90 | """ 91 | Shortcut to invoke to parse command and pass app along. 92 | 93 | :param command: 94 | Command args e.g. 'devices list' 95 | 96 | :param kwargs: 97 | Extra keyword arguments to pass to ``invoke()`` 98 | """ 99 | cmd_parts = shlex.split(command) 100 | result = self.invoke(app, cmd_parts, **kwargs) 101 | return result 102 | 103 | 104 | def assert_output(result, expected, exit_code=0): 105 | """ 106 | Assert that output matches the conditions. 107 | 108 | :param result: 109 | CliRunner result object 110 | 111 | :param expected: 112 | List/tuple of expected outputs 113 | 114 | :param exit_code: 115 | Expected exit code 116 | """ 117 | if not isinstance(expected, (tuple, list)): 118 | raise TypeError('Expected must be a list or tuple') 119 | 120 | assert result.exit_code == exit_code 121 | output = result.output.splitlines() 122 | 123 | for line in output: 124 | # Assert that the expected items are found on the same line 125 | if not all((e in line) for e in expected): 126 | continue 127 | else: 128 | log.info('matched: %r', (expected,)) 129 | break 130 | else: 131 | assert False 132 | 133 | 134 | def assert_outputs(result, expected_list, exit_code=0): 135 | """ 136 | Assert output over a list of of lists of expected outputs. 137 | 138 | :param result: 139 | CliRunner result object 140 | 141 | :param expected_list: 142 | List of lists/tuples of expected outputs 143 | 144 | :param exit_code: 145 | Expected exit code 146 | """ 147 | for expected in expected_list: 148 | assert_output(result, expected, exit_code) 149 | 150 | 151 | def rando(): 152 | """Flip a coin.""" 153 | return random.choice((True, False)) 154 | 155 | 156 | def take_n(n, iterable): 157 | "Return first n items of the iterable as a list" 158 | return list(islice(iterable, n)) 159 | 160 | 161 | def generate_hostnames(num_items=100): 162 | """ 163 | Generate a random list of hostnames. 164 | 165 | :param num_items: 166 | Number of items to generate 167 | """ 168 | 169 | for i in range(1, num_items + 1): 170 | yield 'host%s' % i 171 | 172 | 173 | def generate_ipv4(): 174 | """Generate a random IPv4 address.""" 175 | return socket.inet_ntoa(struct.pack('>I', random.randint(1, 0xffffffff))) 176 | 177 | 178 | def generate_ipv4list(num_items=100, include_hosts=False): 179 | """ 180 | Generate a list of unique IPv4 addresses. This is a total hack. 181 | 182 | :param num_items: 183 | Number of items to generate 184 | 185 | :param include_hosts: 186 | Whether to include /32 addresses 187 | """ 188 | ipset = set() 189 | # Keep iterating and hack together cidr prefixes if we detect empty 190 | # trailing octects. This is so lame that we'll mostly just end up with a 191 | # bunch of /24 networks. 192 | while len(ipset) < num_items: 193 | ip = generate_ipv4() 194 | if ip.startswith('0'): 195 | continue 196 | 197 | if ip.endswith('.0.0.0'): 198 | prefix = '/8' 199 | elif ip.endswith('.0.0'): 200 | prefix = '/16' 201 | elif ip.endswith('.0'): 202 | prefix = '/24' 203 | elif include_hosts: 204 | prefix = '/32' 205 | else: 206 | continue 207 | 208 | ip += prefix 209 | ipset.add(ip) 210 | 211 | return sorted(ipset) 212 | 213 | 214 | def enumerate_attributes(resource_name, attributes=None): 215 | if attributes is None: 216 | attributes = ATTRIBUTE_DATA 217 | 218 | for name in attributes: 219 | yield {'name': name, 'resource_name': resource_name} 220 | 221 | 222 | def generate_attributes(attributes=None, as_dict=True): 223 | """ 224 | Randomly choose attributes and values for testing. 225 | 226 | :param attributes: 227 | Dictionary of attribute names and values 228 | 229 | :param as_dict: 230 | If set return a dict vs. list of Attribute objects 231 | """ 232 | if attributes is None: 233 | attributes = ATTRIBUTE_DATA 234 | 235 | attrs = [] 236 | for attr_name, attr_values in six.iteritems(attributes): 237 | if random.choice((True, False)): 238 | attr_value = random.choice(attr_values) 239 | attrs.append(Attribute(attr_name, attr_value)) 240 | 241 | if as_dict: 242 | attrs = dict(attrs) 243 | 244 | return attrs 245 | 246 | 247 | def generate_devices(num_items=100, with_attributes=True): 248 | """ 249 | Return a list of dicts for Device creation. 250 | 251 | :param num_items: 252 | Number of items to generate 253 | 254 | :param with_attributes: 255 | Whether to include Attributes 256 | """ 257 | hostnames = generate_hostnames(num_items) 258 | 259 | devices = [] 260 | for hostname in hostnames: 261 | item = {'hostname': hostname} 262 | if with_attributes: 263 | item['attributes'] = generate_attributes() 264 | 265 | devices.append(item) 266 | 267 | return devices 268 | 269 | 270 | def generate_interface(name, device_id=None, with_attributes=True, 271 | addresses=None): 272 | """ 273 | Return a list of dicts for Interface creation. 274 | 275 | :param device_id: 276 | The device_id for the Interface 277 | 278 | :param with_attributes: 279 | Whether to include Attributes 280 | 281 | :param addresses: 282 | List of addresses to assign to the Interface 283 | """ 284 | speeds = (100, 1000, 10000, 40000) 285 | types = (6, 135, 136, 161) 286 | if addresses is None: 287 | addresses = [] 288 | 289 | item = { 290 | 'name': name, 291 | # 'device_id': device_id, 292 | 'device': device_id, 293 | 'speed': random.choice(speeds), 294 | 'type': random.choice(types), 295 | } 296 | 297 | if with_attributes: 298 | item['attributes'] = generate_attributes() 299 | if addresses: 300 | item['addresses'] = addresses 301 | 302 | return item 303 | 304 | 305 | def generate_interfaces(device_id=None, with_attributes=True, 306 | address_pool=None): 307 | """ 308 | Return a list of dicts for Interface creation. 309 | 310 | Will generate as many Interfaces as there are in the address_pool. 311 | 312 | :param device_id: 313 | The device_id for the Interface 314 | 315 | :param num_items: 316 | Number of items to generate 317 | 318 | :param with_attributes: 319 | Whether to include Attributes 320 | 321 | :param address_pool: 322 | Pool of addresses to assign 323 | """ 324 | prefix = 'eth' 325 | 326 | if address_pool is None: 327 | address_pool = [] 328 | 329 | interfaces = [] 330 | for num, address in enumerate(address_pool): 331 | name = prefix + str(num) 332 | 333 | # Currently hard-coded to 1 address per interface. 334 | addresses = [address] 335 | 336 | item = generate_interface( 337 | name, device_id, with_attributes=with_attributes, 338 | addresses=addresses 339 | ) 340 | interfaces.append(item) 341 | 342 | return interfaces 343 | 344 | 345 | def generate_networks(num_items=100, with_attributes=True, include_hosts=False, 346 | ipv4list=None): 347 | """ 348 | Return a list of dicts for Network creation. 349 | 350 | :param num_items: 351 | Number of items to generate 352 | 353 | :param with_attributes: 354 | Whether to include Attributes 355 | """ 356 | if ipv4list is None: 357 | ipv4list = generate_ipv4list(num_items, include_hosts=include_hosts) 358 | 359 | networks = [] 360 | for cidr in ipv4list: 361 | item = {'cidr': str(cidr)} 362 | if with_attributes: 363 | item['attributes'] = generate_attributes() 364 | 365 | networks.append(item) 366 | 367 | return networks 368 | 369 | 370 | def populate_sites(site_data): 371 | """Populate sites from fixture data.""" 372 | api = client.Client('http://localhost:8990/api', email='jathan@localhost') 373 | 374 | results = [] 375 | for d in site_data: 376 | try: 377 | result = api.sites.post(d) 378 | except Exception as err: 379 | print(err, d['name']) 380 | else: 381 | results.append(result) 382 | 383 | print('Created', len(results), 'sites.') 384 | 385 | 386 | def rando_set_action(): 387 | """Return a random set theory query action.""" 388 | return random.choice(['+', '-', '']) 389 | 390 | 391 | def rando_set_query(): 392 | """Return a random set theory query string.""" 393 | action = rando_set_action() 394 | return ' '.join( 395 | action + '%s=%s' % (k, v) for k, v in six.iteritems(generate_attributes()) 396 | ) 397 | --------------------------------------------------------------------------------