├── tests ├── __init__.py ├── README.md ├── test_auth.py └── test_util.py ├── scripts ├── .gitignore ├── Dockerfile └── gen_certs.sh ├── examples ├── .gitignore ├── requirements.txt ├── load_subscribe_dump.py ├── subscribe_dump.py ├── subscribe_onchange.py └── custom.py ├── src └── cisco_gnmi │ ├── proto │ ├── gnmi_ext_pb2_grpc.py │ ├── __init__.py │ ├── gnmi_pb2_grpc.py │ └── gnmi_ext_pb2.py │ ├── __init__.py │ ├── auth.py │ ├── util.py │ ├── nx.py │ ├── xe.py │ ├── builder.py │ ├── cli.py │ ├── xr.py │ └── client.py ├── .gitmodules ├── NOTICE ├── requirements.txt ├── Pipfile ├── hygiene.sh ├── update_protos.sh ├── .gitignore ├── Makefile ├── setup.py ├── LICENSE └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/.gitignore: -------------------------------------------------------------------------------- 1 | certs/ -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | gnmi_sub.json 2 | -------------------------------------------------------------------------------- /examples/requirements.txt: -------------------------------------------------------------------------------- 1 | cisco_gnmi -------------------------------------------------------------------------------- /src/cisco_gnmi/proto/gnmi_ext_pb2_grpc.py: -------------------------------------------------------------------------------- 1 | # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! 2 | import grpc 3 | 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "github.com/openconfig/gnmi"] 2 | path = github.com/openconfig/gnmi 3 | url = https://github.com/openconfig/gnmi 4 | branch = master 5 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | cisco-gnmi-python 2 | Copyright (c) 2020 Cisco Systems, Inc. and/or its affiliates 3 | 4 | This project includes software developed at Cisco Systems, Inc. and/or its affiliates. 5 | -------------------------------------------------------------------------------- /scripts/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | RUN apk add --no-cache --virtual .build-deps gcc musl-dev libffi-dev openssl-dev g++ 3 | RUN pip install cisco-gnmi 4 | ENTRYPOINT [ "cisco-gnmi" ] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | asn1crypto==0.24.0 3 | cffi==1.12.3 4 | cryptography==2.7 5 | enum34==1.1.6 6 | futures==3.3.0 ; python_version < '3.2' 7 | grpcio==1.24.0 8 | ipaddress==1.0.22 ; python_version < '3' 9 | protobuf==3.9.2 10 | pycparser==2.19 11 | six==1.12.0 12 | pytest==5.2.1 13 | pytest-cov==2.8.1 14 | pytest-mock==1.11.1 15 | coverage==4.5.4 16 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | Clone the project and enter the project directory: 4 | ``` 5 | git clone git@github.com:cisco-ie/cisco-gnmi-python.git 6 | cd cisco-gnmi-python 7 | ``` 8 | 9 | ## Pre-requisite 10 | 11 | - Setup the developer virtual environment 12 | ``` 13 | make setup 14 | ``` 15 | 16 | ## Usage 17 | 18 | ### How to run tests 19 | 20 | ``` 21 | make test 22 | ``` 23 | 24 | ### How to open test coverage 25 | 26 | ``` 27 | make coverage 28 | ``` 29 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | grpcio-tools = "*" 8 | googleapis-common-protos = "*" 9 | pylint = "*" 10 | twine = "*" 11 | setuptools = "*" 12 | wheel = "*" 13 | 14 | [packages] 15 | grpcio = "*" 16 | protobuf = "*" 17 | six = "*" 18 | cryptography = "*" 19 | pytest = "*" 20 | pytest-cov = "*" 21 | pytest-mock = "*" 22 | pytest-coverage = "*" 23 | 24 | [requires] 25 | python_version = "3.6" 26 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | from src.cisco_gnmi.auth import CiscoAuthPlugin 4 | 5 | 6 | def test_call(): 7 | username = "grpc-username" 8 | password = "grpc-password" 9 | 10 | mock_call = mock.MagicMock(spec=CiscoAuthPlugin.__call__) 11 | 12 | instance = CiscoAuthPlugin(username, password) 13 | result = instance.__call__( 14 | [(username, "testUsr"), (password, "testPass")], CiscoAuthPlugin 15 | ) 16 | mock_call.assert_not_called() 17 | -------------------------------------------------------------------------------- /hygiene.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Script which autoformats (black) and lints (pylint) code 3 | echo "Running black ..." 4 | FORMAT_COMMAND="black --safe --verbose --exclude proto src/cisco_gnmi tests/" 5 | if black &> /dev/null; then 6 | eval $FORMAT_COMMAND 7 | elif pipenv run black &> /dev/null; then 8 | eval "pipenv run $FORMAT_COMMAND" 9 | else 10 | echo "black formatter not found on system, proceeding to pylint..." 11 | fi 12 | echo "Running pylint ..." 13 | # Many failures due to protos being runtime-functional and difficult to lint. 14 | pipenv run pylint --disable no-member --disable wrong-import-position --disable bad-continuation src/cisco_gnmi/*.py 15 | -------------------------------------------------------------------------------- /src/cisco_gnmi/proto/__init__.py: -------------------------------------------------------------------------------- 1 | """Copyright 2019 Cisco Systems 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | The contents of this file are licensed under the Apache License, Version 2.0 12 | (the "License"); you may not use this file except in compliance with the 13 | License. You may obtain a copy of the License at 14 | 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations under 21 | the License. 22 | """ 23 | 24 | 25 | from . import gnmi_pb2_grpc 26 | from . import gnmi_pb2 -------------------------------------------------------------------------------- /src/cisco_gnmi/__init__.py: -------------------------------------------------------------------------------- 1 | """Copyright 2019 Cisco Systems 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | The contents of this file are licensed under the Apache License, Version 2.0 12 | (the "License"); you may not use this file except in compliance with the 13 | License. You may obtain a copy of the License at 14 | 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations under 21 | the License. 22 | """ 23 | 24 | """This library wraps gNMI functionality to ease usage in Python programs.""" 25 | import os 26 | # Workaround for out-of-date proto files 27 | os.environ['PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION'] = 'python' 28 | 29 | from .client import Client 30 | from .xr import XRClient 31 | from .nx import NXClient 32 | from .xe import XEClient 33 | from .builder import ClientBuilder 34 | 35 | __version__ = "1.0.16" 36 | -------------------------------------------------------------------------------- /src/cisco_gnmi/auth.py: -------------------------------------------------------------------------------- 1 | """Copyright 2019 Cisco Systems 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | The contents of this file are licensed under the Apache License, Version 2.0 12 | (the "License"); you may not use this file except in compliance with the 13 | License. You may obtain a copy of the License at 14 | 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations under 21 | the License. 22 | """ 23 | 24 | import grpc 25 | 26 | 27 | class CiscoAuthPlugin(grpc.AuthMetadataPlugin): 28 | """A gRPC AuthMetadataPlugin which adds username/password metadata to each call.""" 29 | 30 | def __init__(self, username, password): 31 | super(CiscoAuthPlugin, self).__init__() 32 | self.username = username 33 | self.password = password 34 | 35 | def __call__(self, context, callback): 36 | callback([("username", self.username), ("password", self.password)], None) 37 | -------------------------------------------------------------------------------- /update_protos.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "Updating proto sources..." 3 | git submodule update --remote 4 | cp github.com/openconfig/gnmi/proto/gnmi/*.proto src/cisco_gnmi/proto/ 5 | cp github.com/openconfig/gnmi/proto/gnmi_ext/*.proto src/cisco_gnmi/proto/ 6 | echo "Fixing proto imports..." 7 | python -c " 8 | with open('src/cisco_gnmi/proto/gnmi.proto', 'r') as gnmi_fd: 9 | file_content = gnmi_fd.read() 10 | file_content = file_content.replace('github.com/openconfig/gnmi/proto/gnmi_ext/', '', 1) 11 | with open('src/cisco_gnmi/proto/gnmi.proto', 'w') as gnmi_fd: 12 | gnmi_fd.write(file_content) 13 | " 14 | echo "Compiling protos..." 15 | pipenv run python -m grpc_tools.protoc --proto_path=src/cisco_gnmi/proto --python_out=src/cisco_gnmi/proto --grpc_python_out=src/cisco_gnmi/proto gnmi.proto gnmi_ext.proto 16 | echo "Fixing compiled Python imports..." 17 | python -c " 18 | with open('src/cisco_gnmi/proto/gnmi_pb2_grpc.py', 'r') as gnmi_fd: 19 | file_content = gnmi_fd.read() 20 | file_content = file_content.replace('import gnmi_pb2', 'from . import gnmi_pb2', 1) 21 | with open('src/cisco_gnmi/proto/gnmi_pb2_grpc.py', 'w') as gnmi_fd: 22 | gnmi_fd.write(file_content) 23 | with open('src/cisco_gnmi/proto/gnmi_pb2.py', 'r') as gnmi_fd: 24 | file_content = gnmi_fd.read() 25 | file_content = file_content.replace('import gnmi_ext_pb2', 'from . import gnmi_ext_pb2', 1) 26 | with open('src/cisco_gnmi/proto/gnmi_pb2.py', 'w') as gnmi_fd: 27 | gnmi_fd.write(file_content) 28 | " 29 | echo "Cleaning up..." 30 | rm src/cisco_gnmi/proto/*.proto 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Custom 2 | .vscode/ 3 | Pipfile.lock 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEST_DIR=tests 2 | #PYPI_URL=https://test.pypi.org/legacy/ 3 | PYPI_URL=https://upload.pypi.org/legacy/ 4 | DEFAULT_PYTHON_VERSION=three 5 | 6 | ## Sets up the virtual environment via pipenv. 7 | .PHONY: setup 8 | setup: 9 | pipenv --$(DEFAULT_PYTHON_VERSION) install --dev 10 | 11 | ## Removes everything including virtual environments. 12 | .PHONY: clean 13 | clean: mostlyclean 14 | -pipenv --rm 15 | rm -f Pipfile.lock 16 | 17 | ## Removes test and packaging outputs. 18 | .PHONY: mostlyclean 19 | mostlyclean: 20 | rm -rf .coverage htmlcov/ .pytest_cache/ build/ dist/ 21 | 22 | ## Runs tests. 23 | .PHONY: test 24 | test: 25 | pipenv run pytest $(TEST_DIR) -v -s --disable-warnings 26 | 27 | ## Creates coverage report. 28 | .PHONY: coverage 29 | coverage: 30 | pipenv run pytest --cov=src/ --cov-report=term-missing --cov-report=html --disable-warnings 31 | open htmlcov/index.html || xdg-open htmlcov/index.html 32 | 33 | ## Packages for both Python 2 and 3 for PyPi. 34 | .PHONY: dist 35 | dist: mostlyclean 36 | -pipenv --rm 37 | pipenv --three install --dev --skip-lock 38 | pipenv run python setup.py sdist bdist_wheel 39 | pipenv --rm 40 | pipenv --two install --dev --skip-lock 41 | pipenv run python setup.py sdist bdist_wheel 42 | pipenv --rm 43 | pipenv --$(DEFAULT_PYTHON_VERSION) install --dev 44 | 45 | ## Uploads packages to PyPi. 46 | .PHONY: upload 47 | upload: 48 | pipenv run twine upload --repository-url $(PYPI_URL) dist/* 49 | 50 | ## Alias for packaging and upload together. 51 | .PHONY: pypi 52 | pypi: dist upload 53 | 54 | ## This help message. 55 | .PHONY: help 56 | help: 57 | @printf "\nUsage:\n"; 58 | 59 | @awk '{ \ 60 | if ($$0 ~ /^.PHONY: [a-zA-Z\-\_0-9]+$$/) { \ 61 | helpCommand = substr($$0, index($$0, ":") + 2); \ 62 | if (helpMessage) { \ 63 | printf "\033[36m%-20s\033[0m %s\n", \ 64 | helpCommand, helpMessage; \ 65 | helpMessage = ""; \ 66 | } \ 67 | } else if ($$0 ~ /^[a-zA-Z\-\_0-9.]+:/) { \ 68 | helpCommand = substr($$0, 0, index($$0, ":")); \ 69 | if (helpMessage) { \ 70 | printf "\033[36m%-20s\033[0m %s\n", \ 71 | helpCommand, helpMessage; \ 72 | helpMessage = ""; \ 73 | } \ 74 | } else if ($$0 ~ /^##/) { \ 75 | if (helpMessage) { \ 76 | helpMessage = helpMessage"\n "substr($$0, 3); \ 77 | } else { \ 78 | helpMessage = substr($$0, 3); \ 79 | } \ 80 | } else { \ 81 | if (helpMessage) { \ 82 | print "\n "helpMessage"\n" \ 83 | } \ 84 | helpMessage = ""; \ 85 | } \ 86 | }' \ 87 | $(MAKEFILE_LIST) -------------------------------------------------------------------------------- /scripts/gen_certs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Derived from https://www.cisco.com/c/en/us/td/docs/ios-xml/ios/prog/configuration/1612/b_1612_programmability_cg/grpc_network_management_interface.html#id_89031 3 | 4 | CERT_BASE="certs" 5 | 6 | if [ -z $4 ]; then 7 | echo "Usage: gen_certs.sh []" 8 | exit 1 9 | fi 10 | 11 | server_hostname=$1 12 | server_ip=$2 13 | client_hostname=$3 14 | client_ip=$4 15 | password=$5 16 | 17 | mkdir -p $CERT_BASE 18 | 19 | function print_red () { 20 | printf "\033[0;31m$1 ...\033[0m\n" 21 | } 22 | 23 | # Setting up a CA 24 | if [ -f "$CERT_BASE/rootCA.key" ] && [ -f "$CERT_BASE/rootCA.pem" ]; then 25 | print_red "SKIPPING rootCA generation, already exist" 26 | else 27 | print_red "GENERATING rootCA" 28 | openssl genrsa -out $CERT_BASE/rootCA.key 2048 29 | openssl req -subj /C=/ST=/L=/O=/CN=rootCA -x509 -new -nodes -key $CERT_BASE/rootCA.key -sha256 -days 1095 -out $CERT_BASE/rootCA.pem 30 | fi 31 | 32 | # Setting up device cert and key 33 | print_red "GENERATING device certificates with CN $server_hostname and IP $server_ip" 34 | openssl genrsa -out $CERT_BASE/device.key 2048 35 | openssl req -subj /C=/ST=/L=/O=/CN=$server_hostname -new -key $CERT_BASE/device.key -out $CERT_BASE/device.csr 36 | openssl x509 -req -in $CERT_BASE/device.csr -CA $CERT_BASE/rootCA.pem -CAkey $CERT_BASE/rootCA.key -CAcreateserial -out $CERT_BASE/device.crt -days 1095 -sha256 -extfile <(printf "%s" "subjectAltName=DNS:$server_hostname,IP:$server_ip") 37 | 38 | # Encrypt device key 39 | if [ ! -z $password ]; then 40 | print_red "ENCRYPTING device certificates and bundling with password" 41 | # DES 3 for device, needed for input to IOS XE 42 | openssl rsa -des3 -in $CERT_BASE/device.key -out $CERT_BASE/device.des3.key -passout pass:$password 43 | # PKCS #12 for device, needed for NX-OS 44 | # Uncertain if this is correct 45 | openssl pkcs12 -export -out $CERT_BASE/device.pfx -inkey $CERT_BASE/device.key -in $CERT_BASE/device.crt -certfile $CERT_BASE/rootCA.pem -password pass:$password 46 | else 47 | print_red "SKIPPING device key encryption" 48 | fi 49 | 50 | print_red "GENERATING client certificates with CN $client_hostname and IP $client_ip" 51 | openssl genrsa -out $CERT_BASE/client.key 2048 52 | openssl req -subj /C=/ST=/L=/O=/CN=$client_hostname -new -key $CERT_BASE/client.key -out $CERT_BASE/client.csr 53 | openssl x509 -req -in $CERT_BASE/client.csr -CA $CERT_BASE/rootCA.pem -CAkey $CERT_BASE/rootCA.key -CAcreateserial -out $CERT_BASE/client.crt -days 1095 -sha256 -extfile <(printf "%s" "subjectAltName=DNS:$client_hostname,IP:$client_ip") 54 | -------------------------------------------------------------------------------- /examples/load_subscribe_dump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Copyright 2020 Cisco Systems 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | The contents of this file are licensed under the Apache License, Version 2.0 13 | (the "License"); you may not use this file except in compliance with the 14 | License. You may obtain a copy of the License at 15 | 16 | http://www.apache.org/licenses/LICENSE-2.0 17 | 18 | Unless required by applicable law or agreed to in writing, software 19 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 20 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 21 | License for the specific language governing permissions and limitations under 22 | the License. 23 | """ 24 | 25 | """This is effectively just demo code to load the output of subscribe_dump.py 26 | """ 27 | import argparse 28 | import os 29 | import logging 30 | import json 31 | import cisco_gnmi 32 | from google.protobuf import json_format, text_format 33 | 34 | 35 | def main(): 36 | logging.basicConfig(level=logging.INFO) 37 | logging.info("Demo of loading protobufs from files.") 38 | args = setup_args() 39 | src_proto_array = load_proto_file(args.protos_file) 40 | parsed_proto_array = [] 41 | for proto_msg in src_proto_array: 42 | parsed_proto = None 43 | if args.text_format is True: 44 | parsed_proto = text_format.Parse( 45 | proto_msg, cisco_gnmi.proto.gnmi_pb2.SubscribeResponse() 46 | ) 47 | else: 48 | if args.raw_json: 49 | parsed_proto = json_format.Parse( 50 | proto_msg, cisco_gnmi.proto.gnmi_pb2.SubscribeResponse() 51 | ) 52 | else: 53 | parsed_proto = json_format.ParseDict( 54 | proto_msg, cisco_gnmi.proto.gnmi_pb2.SubscribeResponse() 55 | ) 56 | parsed_proto_array.append(parsed_proto) 57 | logging.info("Parsed %i formatted messages into objects!", len(parsed_proto_array)) 58 | 59 | 60 | def load_proto_file(filename): 61 | if not filename.endswith(".json"): 62 | raise Exception("Expected JSON file (array of messages) from proto_dump.py") 63 | proto_array = None 64 | with open(filename, "r") as protos_fd: 65 | proto_array = json.load(protos_fd) 66 | if not isinstance(proto_array, (list)): 67 | raise Exception("Expected array of messages from file!") 68 | return proto_array 69 | 70 | 71 | def setup_args(): 72 | parser = argparse.ArgumentParser(description="Proto Load Example") 73 | parser.add_argument("protos_file", help="File containing protos.", type=str) 74 | parser.add_argument( 75 | "-text_format", 76 | help="Protos are in text format instead of JSON.", 77 | action="store_true", 78 | ) 79 | parser.add_argument( 80 | "-raw_json", 81 | help="Do not serialize to dict, but directly to JSON.", 82 | action="store_true", 83 | ) 84 | return parser.parse_args() 85 | 86 | 87 | if __name__ == "__main__": 88 | main() 89 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest_mock import mocker 3 | from src.cisco_gnmi.proto import gnmi_pb2 4 | from src.cisco_gnmi import util 5 | from src.cisco_gnmi.util import urlparse, ssl, x509, default_backend 6 | 7 | 8 | def test_gen_target_netloc_valueerror(): 9 | with pytest.raises(ValueError): 10 | util.gen_target_netloc("http://www.test.com", "test_prefix://", 8000) 11 | 12 | 13 | def test_gen_target_netloc_parsed_none(): 14 | 15 | mock_netloc = "//www.testing.com:8080" 16 | mock_parsed_netloc = urlparse(mock_netloc) 17 | 18 | result = util.gen_target_netloc("www.testing.com", "http://", 8080) 19 | assert mock_parsed_netloc == result 20 | 21 | 22 | def test_validate_proto_enum_exception_one(): 23 | 24 | enum = gnmi_pb2.SubscriptionMode 25 | 26 | with pytest.raises(Exception): 27 | util.validate_proto_enum("test", "INVALID_VALUE", "test", enum) 28 | 29 | 30 | def test_validate_proto_enum_exception_two(): 31 | 32 | enum = gnmi_pb2.SubscriptionMode 33 | fake_subset = [3] 34 | 35 | with pytest.raises(Exception): 36 | util.validate_proto_enum("test", 2, "test", enum, subset=fake_subset) 37 | 38 | 39 | def test_validate_proto_enum_exception_three(): 40 | 41 | enum = gnmi_pb2.SubscriptionMode 42 | fake_subset = ["ON_CHANGE", "SAMPLE"] 43 | 44 | with pytest.raises(Exception): 45 | util.validate_proto_enum( 46 | "test", "TARGET_DEFINED", "test", enum, subset=fake_subset 47 | ) 48 | 49 | 50 | def test_validate_proto_enum_element_in_subset_one(): 51 | 52 | enum = gnmi_pb2.SubscriptionMode 53 | fake_subset = ["ON_CHANGE", "SAMPLE"] 54 | 55 | result = util.validate_proto_enum("test", 2, "test", enum, subset=fake_subset) 56 | assert 2 == result 57 | 58 | 59 | def test_validate_proto_enum_element_in_subset_two(): 60 | 61 | enum = gnmi_pb2.SubscriptionMode 62 | fake_subset = [2, 0] 63 | 64 | result = util.validate_proto_enum( 65 | "test", "TARGET_DEFINED", "test", enum, subset=fake_subset 66 | ) 67 | assert 0 == result 68 | 69 | 70 | def test_validate_proto_enum_value_returned_one(): 71 | 72 | enum = gnmi_pb2.SubscriptionMode 73 | 74 | result = util.validate_proto_enum("test", "ON_CHANGE", "test", enum) 75 | assert 1 == result 76 | 77 | 78 | def test_validate_proto_enum_value_returned_two(): 79 | 80 | enum = gnmi_pb2.SubscriptionMode 81 | 82 | result = util.validate_proto_enum("test", 1, "test", enum) 83 | assert 1 == result 84 | 85 | 86 | def test_get_cert_from_target(): 87 | 88 | target_netloc = {"hostname": "cisco.com", "port": 443} 89 | 90 | expected_ssl_cert = ssl.get_server_certificate( 91 | (target_netloc.get("hostname"), target_netloc.get("port")) 92 | ) 93 | 94 | expected_ssl_cert.encode("utf-8") 95 | 96 | target = util.gen_target_netloc("cisco.com:443") 97 | result = util.get_cert_from_target((target)).decode("utf-8") 98 | 99 | assert expected_ssl_cert == result 100 | 101 | 102 | def test_get_cn_from_cert_returned_value_invalid_entry(mocker): 103 | 104 | mock_cert_parsed = mocker.patch.object(x509, "load_pem_x509_certificate") 105 | result = util.get_cn_from_cert("INVALID_ENTRY") 106 | 107 | assert None == result 108 | 109 | 110 | def test_get_cn_from_cert_returned_value(mocker): 111 | pass 112 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Copyright 2020 Cisco Systems 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | The contents of this file are licensed under the Apache License, Version 2.0 13 | (the "License"); you may not use this file except in compliance with the 14 | License. You may obtain a copy of the License at 15 | 16 | http://www.apache.org/licenses/LICENSE-2.0 17 | 18 | Unless required by applicable law or agreed to in writing, software 19 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 20 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 21 | License for the specific language governing permissions and limitations under 22 | the License. 23 | """ 24 | 25 | """Derived from Flask 26 | https://github.com/pallets/flask/blob/master/setup.py 27 | """ 28 | 29 | import io 30 | import re 31 | 32 | from setuptools import find_packages 33 | from setuptools import setup 34 | 35 | with io.open("README.md", "rt", encoding="utf8") as f: 36 | readme = f.read() 37 | 38 | with io.open("src/cisco_gnmi/__init__.py", "rt", encoding="utf8") as f: 39 | version = re.search(r'__version__ = "(.*?)"', f.read()).group(1) 40 | 41 | setup( 42 | name="cisco_gnmi", 43 | version=version, 44 | url="https://github.com/cisco-ie/cisco-gnmi-python", 45 | project_urls={ 46 | "Code": "https://github.com/cisco-ie/cisco-gnmi-python", 47 | "Issue Tracker": "https://github.com/cisco-ie/cisco-gnmi-python/issues", 48 | }, 49 | license="Apache License (2.0)", 50 | author="Cisco Innovation Edge", 51 | author_email="cisco-ie@cisco.com", 52 | maintainer="Cisco Innovation Edge", 53 | maintainer_email="cisco-ie@cisco.com", 54 | description="This library wraps gNMI functionality to ease usage with Cisco implementations.", 55 | long_description=readme, 56 | long_description_content_type="text/markdown", 57 | classifiers=[ 58 | "Development Status :: 5 - Production/Stable", 59 | "Topic :: System :: Networking", 60 | "Topic :: System :: Networking :: Monitoring", 61 | "Intended Audience :: Developers", 62 | "License :: OSI Approved :: Apache Software License", 63 | "Operating System :: OS Independent", 64 | "Programming Language :: Python", 65 | "Programming Language :: Python :: 2", 66 | "Programming Language :: Python :: 2.7", 67 | "Programming Language :: Python :: 3", 68 | "Programming Language :: Python :: 3.5", 69 | "Programming Language :: Python :: 3.6", 70 | "Programming Language :: Python :: 3.7", 71 | ], 72 | packages=find_packages("src"), 73 | package_dir={"": "src"}, 74 | include_package_data=True, 75 | python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", 76 | install_requires=[ 77 | "grpcio", 78 | "protobuf", 79 | "six", 80 | "cryptography", 81 | ], 82 | extras_require={ 83 | "dev": [ 84 | "grpcio-tools", 85 | "googleapis-common-protos", 86 | "pylint", 87 | "twine", 88 | "setuptools", 89 | "wheel", 90 | "pytest", 91 | "pytest-cov", 92 | "pytest-mock", 93 | "coverage", 94 | ], 95 | }, 96 | entry_points={"console_scripts": ["cisco-gnmi = cisco_gnmi.cli:main"]}, 97 | ) 98 | -------------------------------------------------------------------------------- /examples/subscribe_dump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Copyright 2020 Cisco Systems 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | The contents of this file are licensed under the Apache License, Version 2.0 13 | (the "License"); you may not use this file except in compliance with the 14 | License. You may obtain a copy of the License at 15 | 16 | http://www.apache.org/licenses/LICENSE-2.0 17 | 18 | Unless required by applicable law or agreed to in writing, software 19 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 20 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 21 | License for the specific language governing permissions and limitations under 22 | the License. 23 | """ 24 | 25 | """This demoes a gNMI subscription and dumping messages to a file.""" 26 | import json 27 | import logging 28 | import argparse 29 | from getpass import getpass 30 | from google.protobuf import json_format, text_format 31 | from cisco_gnmi import ClientBuilder 32 | 33 | 34 | def main(): 35 | logging.basicConfig(level=logging.INFO) 36 | args = setup_args() 37 | username = input("Username: ") 38 | password = getpass() 39 | logging.info("Connecting to %s as %s ...", args.netloc, args.os) 40 | client = ( 41 | ClientBuilder(args.netloc) 42 | .set_os(args.os) 43 | .set_secure_from_target() 44 | .set_ssl_target_override() 45 | .set_call_authentication(username, password) 46 | .construct() 47 | ) 48 | formatted_messages = [] 49 | try: 50 | logging.info("Subscribing to %s ...", args.xpath) 51 | sub_args = {"xpath_subscriptions": args.xpath} 52 | if args.encoding: 53 | sub_args["encoding"] = args.encoding 54 | for message in client.subscribe_xpaths(**sub_args): 55 | if message.sync_response and not args.no_stop: 56 | logging.warning("Stopping on sync_response.") 57 | break 58 | formatted_message = None 59 | if args.text_format is True: 60 | formatted_message = text_format.MessageToString(message) 61 | else: 62 | if args.raw_json: 63 | formatted_message = json_format.MessageToJson(message) 64 | else: 65 | formatted_message = json_format.MessageToDict(message) 66 | logging.info(formatted_message) 67 | formatted_messages.append(formatted_message) 68 | except KeyboardInterrupt: 69 | logging.warning("Stopping on interrupt.") 70 | except Exception: 71 | logging.exception("Stopping due to exception!") 72 | finally: 73 | logging.info("Writing to %s ...", args.protos_file) 74 | with open(args.protos_file, "w") as protos_fd: 75 | json.dump( 76 | formatted_messages, 77 | protos_fd, 78 | sort_keys=True, 79 | indent=4, 80 | separators=(",", ": "), 81 | ) 82 | 83 | 84 | def setup_args(): 85 | parser = argparse.ArgumentParser(description="gNMI Proto Dump Example") 86 | parser.add_argument("netloc", help=":", type=str) 87 | parser.add_argument( 88 | "-os", 89 | help="OS to use.", 90 | type=str, 91 | default="IOS XR", 92 | choices=list(ClientBuilder.os_class_map.keys()), 93 | ) 94 | parser.add_argument( 95 | "-xpath", 96 | help="XPath to subscribe to.", 97 | type=str, 98 | default="/interfaces/interface/state/counters", 99 | ) 100 | parser.add_argument( 101 | "-protos_file", help="File to write protos.", type=str, default="gnmi_sub.json" 102 | ) 103 | parser.add_argument( 104 | "-no_stop", help="Do not stop on sync_response.", action="store_true" 105 | ) 106 | parser.add_argument( 107 | "-encoding", help="gNMI subscription encoding.", type=str, nargs="?" 108 | ) 109 | parser.add_argument( 110 | "-text_format", 111 | help="Protos are in text format instead of JSON.", 112 | action="store_true", 113 | ) 114 | parser.add_argument( 115 | "-raw_json", 116 | help="Do not serialize to dict, but directly to JSON.", 117 | action="store_true", 118 | ) 119 | return parser.parse_args() 120 | 121 | 122 | if __name__ == "__main__": 123 | main() 124 | -------------------------------------------------------------------------------- /src/cisco_gnmi/proto/gnmi_pb2_grpc.py: -------------------------------------------------------------------------------- 1 | # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! 2 | import grpc 3 | 4 | from . import gnmi_pb2 as gnmi__pb2 5 | 6 | 7 | class gNMIStub(object): 8 | # missing associated documentation comment in .proto file 9 | pass 10 | 11 | def __init__(self, channel): 12 | """Constructor. 13 | 14 | Args: 15 | channel: A grpc.Channel. 16 | """ 17 | self.Capabilities = channel.unary_unary( 18 | '/gnmi.gNMI/Capabilities', 19 | request_serializer=gnmi__pb2.CapabilityRequest.SerializeToString, 20 | response_deserializer=gnmi__pb2.CapabilityResponse.FromString, 21 | ) 22 | self.Get = channel.unary_unary( 23 | '/gnmi.gNMI/Get', 24 | request_serializer=gnmi__pb2.GetRequest.SerializeToString, 25 | response_deserializer=gnmi__pb2.GetResponse.FromString, 26 | ) 27 | self.Set = channel.unary_unary( 28 | '/gnmi.gNMI/Set', 29 | request_serializer=gnmi__pb2.SetRequest.SerializeToString, 30 | response_deserializer=gnmi__pb2.SetResponse.FromString, 31 | ) 32 | self.Subscribe = channel.stream_stream( 33 | '/gnmi.gNMI/Subscribe', 34 | request_serializer=gnmi__pb2.SubscribeRequest.SerializeToString, 35 | response_deserializer=gnmi__pb2.SubscribeResponse.FromString, 36 | ) 37 | 38 | 39 | class gNMIServicer(object): 40 | # missing associated documentation comment in .proto file 41 | pass 42 | 43 | def Capabilities(self, request, context): 44 | """Capabilities allows the client to retrieve the set of capabilities that 45 | is supported by the target. This allows the target to validate the 46 | service version that is implemented and retrieve the set of models that 47 | the target supports. The models can then be specified in subsequent RPCs 48 | to restrict the set of data that is utilized. 49 | Reference: gNMI Specification Section 3.2 50 | """ 51 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 52 | context.set_details('Method not implemented!') 53 | raise NotImplementedError('Method not implemented!') 54 | 55 | def Get(self, request, context): 56 | """Retrieve a snapshot of data from the target. A Get RPC requests that the 57 | target snapshots a subset of the data tree as specified by the paths 58 | included in the message and serializes this to be returned to the 59 | client using the specified encoding. 60 | Reference: gNMI Specification Section 3.3 61 | """ 62 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 63 | context.set_details('Method not implemented!') 64 | raise NotImplementedError('Method not implemented!') 65 | 66 | def Set(self, request, context): 67 | """Set allows the client to modify the state of data on the target. The 68 | paths to modified along with the new values that the client wishes 69 | to set the value to. 70 | Reference: gNMI Specification Section 3.4 71 | """ 72 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 73 | context.set_details('Method not implemented!') 74 | raise NotImplementedError('Method not implemented!') 75 | 76 | def Subscribe(self, request_iterator, context): 77 | """Subscribe allows a client to request the target to send it values 78 | of particular paths within the data tree. These values may be streamed 79 | at a particular cadence (STREAM), sent one off on a long-lived channel 80 | (POLL), or sent as a one-off retrieval (ONCE). 81 | Reference: gNMI Specification Section 3.5 82 | """ 83 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 84 | context.set_details('Method not implemented!') 85 | raise NotImplementedError('Method not implemented!') 86 | 87 | 88 | def add_gNMIServicer_to_server(servicer, server): 89 | rpc_method_handlers = { 90 | 'Capabilities': grpc.unary_unary_rpc_method_handler( 91 | servicer.Capabilities, 92 | request_deserializer=gnmi__pb2.CapabilityRequest.FromString, 93 | response_serializer=gnmi__pb2.CapabilityResponse.SerializeToString, 94 | ), 95 | 'Get': grpc.unary_unary_rpc_method_handler( 96 | servicer.Get, 97 | request_deserializer=gnmi__pb2.GetRequest.FromString, 98 | response_serializer=gnmi__pb2.GetResponse.SerializeToString, 99 | ), 100 | 'Set': grpc.unary_unary_rpc_method_handler( 101 | servicer.Set, 102 | request_deserializer=gnmi__pb2.SetRequest.FromString, 103 | response_serializer=gnmi__pb2.SetResponse.SerializeToString, 104 | ), 105 | 'Subscribe': grpc.stream_stream_rpc_method_handler( 106 | servicer.Subscribe, 107 | request_deserializer=gnmi__pb2.SubscribeRequest.FromString, 108 | response_serializer=gnmi__pb2.SubscribeResponse.SerializeToString, 109 | ), 110 | } 111 | generic_handler = grpc.method_handlers_generic_handler( 112 | 'gnmi.gNMI', rpc_method_handlers) 113 | server.add_generic_rpc_handlers((generic_handler,)) 114 | -------------------------------------------------------------------------------- /examples/subscribe_onchange.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Copyright 2020 Cisco Systems 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | The contents of this file are licensed under the Apache License, Version 2.0 13 | (the "License"); you may not use this file except in compliance with the 14 | License. You may obtain a copy of the License at 15 | 16 | http://www.apache.org/licenses/LICENSE-2.0 17 | 18 | Unless required by applicable law or agreed to in writing, software 19 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 20 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 21 | License for the specific language governing permissions and limitations under 22 | the License. 23 | """ 24 | 25 | """This demoes a gNMI subscription and dumping messages to a file. 26 | Targets IOS XR syslog as demo. 27 | TODO: Refactor library so ON_CHANGE is functionally simpler. 28 | """ 29 | import json 30 | import logging 31 | import argparse 32 | from getpass import getpass 33 | from google.protobuf import json_format, text_format 34 | from cisco_gnmi import ClientBuilder, proto 35 | 36 | 37 | def main(): 38 | logging.basicConfig(level=logging.INFO) 39 | args = setup_args() 40 | username = input("Username: ") 41 | password = getpass() 42 | logging.info("Connecting to %s as %s ...", args.netloc, args.os) 43 | client = ( 44 | ClientBuilder(args.netloc) 45 | .set_os(args.os) 46 | .set_secure_from_target() 47 | .set_ssl_target_override() 48 | .set_call_authentication(username, password) 49 | .construct() 50 | ) 51 | formatted_messages = [] 52 | try: 53 | logging.info("Subscribing to %s ...", args.xpath) 54 | sub_args = {"xpath_subscriptions": args.xpath, "sub_mode": "ON_CHANGE"} 55 | if args.encoding: 56 | sub_args["encoding"] = args.encoding 57 | if not args.process_all: 58 | logging.info("Ignoring messages before sync_response.") 59 | synced = False 60 | for message in client.subscribe_xpaths(**sub_args): 61 | if message.sync_response: 62 | synced = True 63 | logging.info("Synced with latest state.") 64 | continue 65 | if not synced and not args.process_all: 66 | continue 67 | formatted_message = None 68 | if args.text_format is True: 69 | formatted_message = text_format.MessageToString(message) 70 | else: 71 | if args.raw_json: 72 | formatted_message = json_format.MessageToJson(message) 73 | else: 74 | formatted_message = json_format.MessageToDict(message) 75 | logging.info(formatted_message) 76 | formatted_messages.append(formatted_message) 77 | except KeyboardInterrupt: 78 | logging.warning("Stopping on interrupt.") 79 | except Exception: 80 | logging.exception("Stopping due to exception!") 81 | finally: 82 | logging.info("Writing to %s ...", args.protos_file) 83 | with open(args.protos_file, "w") as protos_fd: 84 | json.dump( 85 | formatted_messages, 86 | protos_fd, 87 | sort_keys=True, 88 | indent=4, 89 | separators=(",", ": "), 90 | ) 91 | 92 | 93 | def setup_args(): 94 | parser = argparse.ArgumentParser(description="gNMI Subscribe Dump Example") 95 | parser.add_argument("netloc", help=":", type=str) 96 | parser.add_argument( 97 | "-os", 98 | help="OS to use.", 99 | type=str, 100 | default="IOS XR", 101 | choices=list(ClientBuilder.os_class_map.keys()), 102 | ) 103 | parser.add_argument( 104 | "-xpath", 105 | help="XPath to subscribe to.", 106 | type=str, 107 | default="Cisco-IOS-XR-infra-syslog-oper:syslog/messages/message", 108 | ) 109 | parser.add_argument( 110 | "-protos_file", help="File to write protos.", type=str, default="gnmi_sub.json" 111 | ) 112 | parser.add_argument( 113 | "-process_all", 114 | help="Process all the way through sync_response.", 115 | action="store_true", 116 | ) 117 | parser.add_argument( 118 | "-encoding", help="gNMI subscription encoding.", type=str, default="PROTO" 119 | ) 120 | parser.add_argument( 121 | "-text_format", 122 | help="Protos are in text format instead of JSON.", 123 | action="store_true", 124 | ) 125 | parser.add_argument( 126 | "-raw_json", 127 | help="Do not serialize to dict, but directly to JSON.", 128 | action="store_true", 129 | ) 130 | return parser.parse_args() 131 | 132 | 133 | if __name__ == "__main__": 134 | main() 135 | -------------------------------------------------------------------------------- /src/cisco_gnmi/util.py: -------------------------------------------------------------------------------- 1 | """Copyright 2019 Cisco Systems 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | The contents of this file are licensed under the Apache License, Version 2.0 12 | (the "License"); you may not use this file except in compliance with the 13 | License. You may obtain a copy of the License at 14 | 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations under 21 | the License. 22 | """ 23 | 24 | """Contains useful functionality generally applicable for manipulation of cisco_gnmi.""" 25 | 26 | import logging 27 | import ssl 28 | 29 | from cryptography import x509 30 | from cryptography.hazmat.backends import default_backend 31 | 32 | try: 33 | # Python 3 34 | from urllib.parse import urlparse 35 | except ImportError: 36 | # Python 2 37 | from urlparse import urlparse 38 | 39 | 40 | LOGGER = logging.getLogger(__name__) 41 | logger = LOGGER 42 | 43 | 44 | def gen_target_netloc(target, netloc_prefix="//", default_port=9339): 45 | """Parses and validates a supplied target URL for gRPC calls. 46 | Uses urllib to parse the netloc property from the URL. 47 | netloc property is, effectively, fqdn/hostname:port. 48 | This provides some level of URL validation and flexibility. 49 | 9339 is IANA reserved port for gNMI/gNOI/... 50 | Returns netloc property of target. 51 | """ 52 | if netloc_prefix not in target: 53 | target = netloc_prefix + target 54 | parsed_target = urlparse(target) 55 | if not parsed_target.netloc: 56 | raise ValueError("Unable to parse netloc from target URL %s!" % target) 57 | if parsed_target.scheme: 58 | LOGGER.debug("Scheme identified in target, ignoring and using netloc.") 59 | target_netloc = parsed_target 60 | if parsed_target.port is None: 61 | ported_target = "%s:%i" % (parsed_target.hostname, default_port) 62 | LOGGER.debug("No target port detected, reassembled to %s.", ported_target) 63 | target_netloc = gen_target_netloc(ported_target) 64 | return target_netloc 65 | 66 | 67 | def validate_proto_enum( 68 | value_name, value, enum_name, enum, subset=None, return_name=False 69 | ): 70 | """Helper function to validate an enum against the proto enum wrapper.""" 71 | enum_value = None 72 | if value not in enum.keys() and value not in enum.values(): 73 | raise Exception( 74 | "{name}={value} not in {enum_name} enum! Please try any of {options}.".format( 75 | name=value_name, 76 | value=str(value), 77 | enum_name=enum_name, 78 | options=str(enum.keys()), 79 | ) 80 | ) 81 | if value in enum.keys(): 82 | enum_value = enum.Value(value) 83 | else: 84 | enum_value = value 85 | if subset: 86 | resolved_subset = [] 87 | for element in subset: 88 | if element in enum.keys(): 89 | resolved_subset.append(enum.Value(element)) 90 | elif element in enum.values(): 91 | resolved_subset.append(element) 92 | else: 93 | raise Exception( 94 | "Subset element {element} not in {enum_name}!".format( 95 | element=element, enum_name=enum_name 96 | ) 97 | ) 98 | if enum_value not in resolved_subset: 99 | raise Exception( 100 | "{name}={value} ({actual_value}) not in subset {subset} ({actual_subset})!".format( 101 | name=value_name, 102 | value=value, 103 | actual_value=enum_value, 104 | subset=subset, 105 | actual_subset=resolved_subset, 106 | ) 107 | ) 108 | return enum_value if not return_name else enum.Name(enum_value) 109 | 110 | 111 | def get_cert_from_target(target_netloc): 112 | """Retrieves the SSL certificate from a secure server.""" 113 | return ssl.get_server_certificate( 114 | (target_netloc.hostname, target_netloc.port) 115 | ).encode("utf-8") 116 | 117 | 118 | def get_cn_from_cert(cert_pem): 119 | """Attempts to derive the CN from a supplied certficate. 120 | Defaults to first found if multiple CNs identified. 121 | """ 122 | cert_cn = None 123 | cert_parsed = x509.load_pem_x509_certificate(cert_pem, default_backend()) 124 | cert_cns = cert_parsed.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME) 125 | if len(cert_cns) > 0: 126 | if len(cert_cns) > 1: 127 | LOGGER.warning( 128 | "Multiple CNs found for certificate, defaulting to the first one." 129 | ) 130 | cert_cn = cert_cns[0].value 131 | LOGGER.debug("Using %s as certificate CN.", cert_cn) 132 | else: 133 | LOGGER.warning("No CN found for certificate.") 134 | return cert_cn 135 | -------------------------------------------------------------------------------- /examples/custom.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Copyright 2020 Cisco Systems 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | The contents of this file are licensed under the Apache License, Version 2.0 13 | (the "License"); you may not use this file except in compliance with the 14 | License. You may obtain a copy of the License at 15 | 16 | http://www.apache.org/licenses/LICENSE-2.0 17 | 18 | Unless required by applicable law or agreed to in writing, software 19 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 20 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 21 | License for the specific language governing permissions and limitations under 22 | the License. 23 | """ 24 | 25 | """Custom usage, no wrapper. 26 | Because we're not using a wrapper, we are going to need to build our own protos. 27 | """ 28 | 29 | import json 30 | from getpass import getpass 31 | from cisco_gnmi import ClientBuilder, proto 32 | 33 | """First let's build a Client. We are not going to specify an OS 34 | name here resulting in just the base Client returned without any OS 35 | convenience methods. Client does have some level of "convenience" built-in 36 | insofar as it doesn't take direct Requests (SubscribeRequest) etc. 37 | To directly use the gNMI RPCs access via client.service.(). 38 | So - either: 39 | * Pass args to the client.() methods. 40 | * Pass full Request protos to client.service.() 41 | This code passes args to the client.() methods. 42 | """ 43 | target = input("Host/Port: ") 44 | username = input("Username: ") 45 | password = getpass() 46 | client = ( 47 | ClientBuilder(target) 48 | .set_secure_from_target() 49 | .set_ssl_target_override() 50 | .set_call_authentication(username, password) 51 | .construct() 52 | ) 53 | """Capabilities is an easy RPC to test.""" 54 | input("Press Enter for Capabilities...") 55 | capabilities = client.capabilities() 56 | print(capabilities) 57 | """Let's build a Get! 58 | client.get() expects a list of Paths as the primary method of interaction. 59 | client.parse_xpath_to_gnmi_path is a convenience method to..parse an XPath to a Path. 60 | Generally OS wrappers will override this function to specialize on origins, etc. 61 | But we are not using a wrapper, and if using OpenConfig pathing we don't need an origin. 62 | """ 63 | input("Press Enter for Get...") 64 | get_path = client.parse_xpath_to_gnmi_path("/interfaces/interface/state/counters") 65 | get_response = client.get([get_path], data_type="STATE", encoding="JSON_IETF") 66 | print(get_response) 67 | """Let's build a sampled Subscribe! 68 | client.subscribe() accepts an iterable of SubscriptionLists 69 | """ 70 | input("Press Enter for Subscribe SAMPLE...") 71 | subscription_list = proto.gnmi_pb2.SubscriptionList() 72 | subscription_list.mode = proto.gnmi_pb2.SubscriptionList.Mode.Value("STREAM") 73 | subscription_list.encoding = proto.gnmi_pb2.Encoding.Value("PROTO") 74 | sampled_subscription = proto.gnmi_pb2.Subscription() 75 | sampled_subscription.path.CopyFrom( 76 | client.parse_xpath_to_gnmi_path("/interfaces/interface/state/counters") 77 | ) 78 | sampled_subscription.mode = proto.gnmi_pb2.SubscriptionMode.Value("SAMPLE") 79 | sampled_subscription.sample_interval = 10 * int(1e9) 80 | subscription_list.subscription.extend([sampled_subscription]) 81 | for subscribe_response in client.subscribe([subscription_list]): 82 | print(subscribe_response) 83 | break 84 | """Now let's do ON_CHANGE. Just have to put SubscriptionMode to ON_CHANGE.""" 85 | input("Press Enter for Subscribe ON_CHANGE...") 86 | subscription_list = proto.gnmi_pb2.SubscriptionList() 87 | subscription_list.mode = proto.gnmi_pb2.SubscriptionList.Mode.Value("STREAM") 88 | subscription_list.encoding = proto.gnmi_pb2.Encoding.Value("PROTO") 89 | onchange_subscription = proto.gnmi_pb2.Subscription() 90 | onchange_subscription.path.CopyFrom( 91 | client.parse_xpath_to_gnmi_path( 92 | "/syslog/messages/message", origin="Cisco-IOS-XR-infra-syslog-oper" 93 | ) 94 | ) 95 | onchange_subscription.mode = proto.gnmi_pb2.SubscriptionMode.Value("ON_CHANGE") 96 | subscription_list.subscription.extend([onchange_subscription]) 97 | synced = False 98 | for subscribe_response in client.subscribe([subscription_list]): 99 | if subscribe_response.sync_response: 100 | synced = True 101 | print("Synced. Now perform action that will create a changed value.") 102 | print("If using XR syslog as written, just try SSH'ing to device.") 103 | continue 104 | if not synced: 105 | continue 106 | print(subscribe_response) 107 | break 108 | """Let's build a Set! 109 | client.set() expects updates, replaces, and/or deletes to be provided. 110 | updates is a list of Updates 111 | replaces is a list of Updates 112 | deletes is a list of Paths 113 | Let's do an update. 114 | """ 115 | input("Press Enter for Set update...") 116 | set_update = proto.gnmi_pb2.Update() 117 | # This is the fully modeled JSON we want to update with 118 | update_json = json.loads( 119 | """ 120 | { 121 | "openconfig-interfaces:interfaces": { 122 | "interface": [ 123 | { 124 | "name": "Loopback9339" 125 | } 126 | ] 127 | } 128 | } 129 | """ 130 | ) 131 | # Let's just do an update from the very top element 132 | top_element = next(iter(update_json.keys())) 133 | set_update.path.CopyFrom(client.parse_xpath_to_gnmi_path(top_element)) 134 | # Remove the top element from the config since it's now in Path 135 | update_json = update_json.pop(top_element) 136 | # Set our update payload 137 | set_update.val.json_ietf_val = json.dumps(update_json).encode("utf-8") 138 | set_result = client.set(updates=[set_update]) 139 | print(set_result) 140 | # This may all seem somewhat obtuse, and that's what the client wrappers are for. 141 | -------------------------------------------------------------------------------- /src/cisco_gnmi/proto/gnmi_ext_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: gnmi_ext.proto 4 | 5 | import sys 6 | _b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) 7 | from google.protobuf.internal import enum_type_wrapper 8 | from google.protobuf import descriptor as _descriptor 9 | from google.protobuf import message as _message 10 | from google.protobuf import reflection as _reflection 11 | from google.protobuf import symbol_database as _symbol_database 12 | # @@protoc_insertion_point(imports) 13 | 14 | _sym_db = _symbol_database.Default() 15 | 16 | 17 | 18 | 19 | DESCRIPTOR = _descriptor.FileDescriptor( 20 | name='gnmi_ext.proto', 21 | package='gnmi_ext', 22 | syntax='proto3', 23 | serialized_options=None, 24 | serialized_pb=_b('\n\x0egnmi_ext.proto\x12\x08gnmi_ext\"\x86\x01\n\tExtension\x12\x37\n\x0eregistered_ext\x18\x01 \x01(\x0b\x32\x1d.gnmi_ext.RegisteredExtensionH\x00\x12\x39\n\x12master_arbitration\x18\x02 \x01(\x0b\x32\x1b.gnmi_ext.MasterArbitrationH\x00\x42\x05\n\x03\x65xt\"E\n\x13RegisteredExtension\x12!\n\x02id\x18\x01 \x01(\x0e\x32\x15.gnmi_ext.ExtensionID\x12\x0b\n\x03msg\x18\x02 \x01(\x0c\"Y\n\x11MasterArbitration\x12\x1c\n\x04role\x18\x01 \x01(\x0b\x32\x0e.gnmi_ext.Role\x12&\n\x0b\x65lection_id\x18\x02 \x01(\x0b\x32\x11.gnmi_ext.Uint128\"$\n\x07Uint128\x12\x0c\n\x04high\x18\x01 \x01(\x04\x12\x0b\n\x03low\x18\x02 \x01(\x04\"\x12\n\x04Role\x12\n\n\x02id\x18\x01 \x01(\t*3\n\x0b\x45xtensionID\x12\r\n\tEID_UNSET\x10\x00\x12\x15\n\x10\x45ID_EXPERIMENTAL\x10\xe7\x07\x62\x06proto3') 25 | ) 26 | 27 | _EXTENSIONID = _descriptor.EnumDescriptor( 28 | name='ExtensionID', 29 | full_name='gnmi_ext.ExtensionID', 30 | filename=None, 31 | file=DESCRIPTOR, 32 | values=[ 33 | _descriptor.EnumValueDescriptor( 34 | name='EID_UNSET', index=0, number=0, 35 | serialized_options=None, 36 | type=None), 37 | _descriptor.EnumValueDescriptor( 38 | name='EID_EXPERIMENTAL', index=1, number=999, 39 | serialized_options=None, 40 | type=None), 41 | ], 42 | containing_type=None, 43 | serialized_options=None, 44 | serialized_start=385, 45 | serialized_end=436, 46 | ) 47 | _sym_db.RegisterEnumDescriptor(_EXTENSIONID) 48 | 49 | ExtensionID = enum_type_wrapper.EnumTypeWrapper(_EXTENSIONID) 50 | EID_UNSET = 0 51 | EID_EXPERIMENTAL = 999 52 | 53 | 54 | 55 | _EXTENSION = _descriptor.Descriptor( 56 | name='Extension', 57 | full_name='gnmi_ext.Extension', 58 | filename=None, 59 | file=DESCRIPTOR, 60 | containing_type=None, 61 | fields=[ 62 | _descriptor.FieldDescriptor( 63 | name='registered_ext', full_name='gnmi_ext.Extension.registered_ext', index=0, 64 | number=1, type=11, cpp_type=10, label=1, 65 | has_default_value=False, default_value=None, 66 | message_type=None, enum_type=None, containing_type=None, 67 | is_extension=False, extension_scope=None, 68 | serialized_options=None, file=DESCRIPTOR), 69 | _descriptor.FieldDescriptor( 70 | name='master_arbitration', full_name='gnmi_ext.Extension.master_arbitration', index=1, 71 | number=2, type=11, cpp_type=10, label=1, 72 | has_default_value=False, default_value=None, 73 | message_type=None, enum_type=None, containing_type=None, 74 | is_extension=False, extension_scope=None, 75 | serialized_options=None, file=DESCRIPTOR), 76 | ], 77 | extensions=[ 78 | ], 79 | nested_types=[], 80 | enum_types=[ 81 | ], 82 | serialized_options=None, 83 | is_extendable=False, 84 | syntax='proto3', 85 | extension_ranges=[], 86 | oneofs=[ 87 | _descriptor.OneofDescriptor( 88 | name='ext', full_name='gnmi_ext.Extension.ext', 89 | index=0, containing_type=None, fields=[]), 90 | ], 91 | serialized_start=29, 92 | serialized_end=163, 93 | ) 94 | 95 | 96 | _REGISTEREDEXTENSION = _descriptor.Descriptor( 97 | name='RegisteredExtension', 98 | full_name='gnmi_ext.RegisteredExtension', 99 | filename=None, 100 | file=DESCRIPTOR, 101 | containing_type=None, 102 | fields=[ 103 | _descriptor.FieldDescriptor( 104 | name='id', full_name='gnmi_ext.RegisteredExtension.id', index=0, 105 | number=1, type=14, cpp_type=8, label=1, 106 | has_default_value=False, default_value=0, 107 | message_type=None, enum_type=None, containing_type=None, 108 | is_extension=False, extension_scope=None, 109 | serialized_options=None, file=DESCRIPTOR), 110 | _descriptor.FieldDescriptor( 111 | name='msg', full_name='gnmi_ext.RegisteredExtension.msg', index=1, 112 | number=2, type=12, cpp_type=9, label=1, 113 | has_default_value=False, default_value=_b(""), 114 | message_type=None, enum_type=None, containing_type=None, 115 | is_extension=False, extension_scope=None, 116 | serialized_options=None, file=DESCRIPTOR), 117 | ], 118 | extensions=[ 119 | ], 120 | nested_types=[], 121 | enum_types=[ 122 | ], 123 | serialized_options=None, 124 | is_extendable=False, 125 | syntax='proto3', 126 | extension_ranges=[], 127 | oneofs=[ 128 | ], 129 | serialized_start=165, 130 | serialized_end=234, 131 | ) 132 | 133 | 134 | _MASTERARBITRATION = _descriptor.Descriptor( 135 | name='MasterArbitration', 136 | full_name='gnmi_ext.MasterArbitration', 137 | filename=None, 138 | file=DESCRIPTOR, 139 | containing_type=None, 140 | fields=[ 141 | _descriptor.FieldDescriptor( 142 | name='role', full_name='gnmi_ext.MasterArbitration.role', index=0, 143 | number=1, type=11, cpp_type=10, label=1, 144 | has_default_value=False, default_value=None, 145 | message_type=None, enum_type=None, containing_type=None, 146 | is_extension=False, extension_scope=None, 147 | serialized_options=None, file=DESCRIPTOR), 148 | _descriptor.FieldDescriptor( 149 | name='election_id', full_name='gnmi_ext.MasterArbitration.election_id', index=1, 150 | number=2, type=11, cpp_type=10, label=1, 151 | has_default_value=False, default_value=None, 152 | message_type=None, enum_type=None, containing_type=None, 153 | is_extension=False, extension_scope=None, 154 | serialized_options=None, file=DESCRIPTOR), 155 | ], 156 | extensions=[ 157 | ], 158 | nested_types=[], 159 | enum_types=[ 160 | ], 161 | serialized_options=None, 162 | is_extendable=False, 163 | syntax='proto3', 164 | extension_ranges=[], 165 | oneofs=[ 166 | ], 167 | serialized_start=236, 168 | serialized_end=325, 169 | ) 170 | 171 | 172 | _UINT128 = _descriptor.Descriptor( 173 | name='Uint128', 174 | full_name='gnmi_ext.Uint128', 175 | filename=None, 176 | file=DESCRIPTOR, 177 | containing_type=None, 178 | fields=[ 179 | _descriptor.FieldDescriptor( 180 | name='high', full_name='gnmi_ext.Uint128.high', index=0, 181 | number=1, type=4, cpp_type=4, label=1, 182 | has_default_value=False, default_value=0, 183 | message_type=None, enum_type=None, containing_type=None, 184 | is_extension=False, extension_scope=None, 185 | serialized_options=None, file=DESCRIPTOR), 186 | _descriptor.FieldDescriptor( 187 | name='low', full_name='gnmi_ext.Uint128.low', index=1, 188 | number=2, type=4, cpp_type=4, label=1, 189 | has_default_value=False, default_value=0, 190 | message_type=None, enum_type=None, containing_type=None, 191 | is_extension=False, extension_scope=None, 192 | serialized_options=None, file=DESCRIPTOR), 193 | ], 194 | extensions=[ 195 | ], 196 | nested_types=[], 197 | enum_types=[ 198 | ], 199 | serialized_options=None, 200 | is_extendable=False, 201 | syntax='proto3', 202 | extension_ranges=[], 203 | oneofs=[ 204 | ], 205 | serialized_start=327, 206 | serialized_end=363, 207 | ) 208 | 209 | 210 | _ROLE = _descriptor.Descriptor( 211 | name='Role', 212 | full_name='gnmi_ext.Role', 213 | filename=None, 214 | file=DESCRIPTOR, 215 | containing_type=None, 216 | fields=[ 217 | _descriptor.FieldDescriptor( 218 | name='id', full_name='gnmi_ext.Role.id', index=0, 219 | number=1, type=9, cpp_type=9, label=1, 220 | has_default_value=False, default_value=_b("").decode('utf-8'), 221 | message_type=None, enum_type=None, containing_type=None, 222 | is_extension=False, extension_scope=None, 223 | serialized_options=None, file=DESCRIPTOR), 224 | ], 225 | extensions=[ 226 | ], 227 | nested_types=[], 228 | enum_types=[ 229 | ], 230 | serialized_options=None, 231 | is_extendable=False, 232 | syntax='proto3', 233 | extension_ranges=[], 234 | oneofs=[ 235 | ], 236 | serialized_start=365, 237 | serialized_end=383, 238 | ) 239 | 240 | _EXTENSION.fields_by_name['registered_ext'].message_type = _REGISTEREDEXTENSION 241 | _EXTENSION.fields_by_name['master_arbitration'].message_type = _MASTERARBITRATION 242 | _EXTENSION.oneofs_by_name['ext'].fields.append( 243 | _EXTENSION.fields_by_name['registered_ext']) 244 | _EXTENSION.fields_by_name['registered_ext'].containing_oneof = _EXTENSION.oneofs_by_name['ext'] 245 | _EXTENSION.oneofs_by_name['ext'].fields.append( 246 | _EXTENSION.fields_by_name['master_arbitration']) 247 | _EXTENSION.fields_by_name['master_arbitration'].containing_oneof = _EXTENSION.oneofs_by_name['ext'] 248 | _REGISTEREDEXTENSION.fields_by_name['id'].enum_type = _EXTENSIONID 249 | _MASTERARBITRATION.fields_by_name['role'].message_type = _ROLE 250 | _MASTERARBITRATION.fields_by_name['election_id'].message_type = _UINT128 251 | DESCRIPTOR.message_types_by_name['Extension'] = _EXTENSION 252 | DESCRIPTOR.message_types_by_name['RegisteredExtension'] = _REGISTEREDEXTENSION 253 | DESCRIPTOR.message_types_by_name['MasterArbitration'] = _MASTERARBITRATION 254 | DESCRIPTOR.message_types_by_name['Uint128'] = _UINT128 255 | DESCRIPTOR.message_types_by_name['Role'] = _ROLE 256 | DESCRIPTOR.enum_types_by_name['ExtensionID'] = _EXTENSIONID 257 | _sym_db.RegisterFileDescriptor(DESCRIPTOR) 258 | 259 | Extension = _reflection.GeneratedProtocolMessageType('Extension', (_message.Message,), { 260 | 'DESCRIPTOR' : _EXTENSION, 261 | '__module__' : 'gnmi_ext_pb2' 262 | # @@protoc_insertion_point(class_scope:gnmi_ext.Extension) 263 | }) 264 | _sym_db.RegisterMessage(Extension) 265 | 266 | RegisteredExtension = _reflection.GeneratedProtocolMessageType('RegisteredExtension', (_message.Message,), { 267 | 'DESCRIPTOR' : _REGISTEREDEXTENSION, 268 | '__module__' : 'gnmi_ext_pb2' 269 | # @@protoc_insertion_point(class_scope:gnmi_ext.RegisteredExtension) 270 | }) 271 | _sym_db.RegisterMessage(RegisteredExtension) 272 | 273 | MasterArbitration = _reflection.GeneratedProtocolMessageType('MasterArbitration', (_message.Message,), { 274 | 'DESCRIPTOR' : _MASTERARBITRATION, 275 | '__module__' : 'gnmi_ext_pb2' 276 | # @@protoc_insertion_point(class_scope:gnmi_ext.MasterArbitration) 277 | }) 278 | _sym_db.RegisterMessage(MasterArbitration) 279 | 280 | Uint128 = _reflection.GeneratedProtocolMessageType('Uint128', (_message.Message,), { 281 | 'DESCRIPTOR' : _UINT128, 282 | '__module__' : 'gnmi_ext_pb2' 283 | # @@protoc_insertion_point(class_scope:gnmi_ext.Uint128) 284 | }) 285 | _sym_db.RegisterMessage(Uint128) 286 | 287 | Role = _reflection.GeneratedProtocolMessageType('Role', (_message.Message,), { 288 | 'DESCRIPTOR' : _ROLE, 289 | '__module__' : 'gnmi_ext_pb2' 290 | # @@protoc_insertion_point(class_scope:gnmi_ext.Role) 291 | }) 292 | _sym_db.RegisterMessage(Role) 293 | 294 | 295 | # @@protoc_insertion_point(module_scope) 296 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/cisco_gnmi/nx.py: -------------------------------------------------------------------------------- 1 | """Copyright 2019 Cisco Systems 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | The contents of this file are licensed under the Apache License, Version 2.0 12 | (the "License"); you may not use this file except in compliance with the 13 | License. You may obtain a copy of the License at 14 | 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations under 21 | the License. 22 | """ 23 | 24 | """Wrapper for NX-OS to simplify usage of gNMI implementation.""" 25 | 26 | import json 27 | import logging 28 | 29 | from six import string_types 30 | from .client import Client, proto, util 31 | 32 | 33 | LOGGER = logging.getLogger(__name__) 34 | logger = LOGGER 35 | 36 | 37 | class NXClient(Client): 38 | """NX-OS-specific wrapper for gNMI functionality. 39 | 40 | Returns direct responses from base Client methods. 41 | 42 | Methods 43 | ------- 44 | subscribe_xpaths(...) 45 | Convenience wrapper for subscribe() which helps construct subscriptions for specified xpaths. 46 | 47 | Examples 48 | -------- 49 | >>> from cisco_gnmi import ClientBuilder 50 | >>> client = ClientBuilder('127.0.0.1:9339').set_os( 51 | ... 'NX-OS' 52 | ... ).set_secure_from_file().set_ssl_target_override().set_call_authentication( 53 | ... 'admin', 54 | ... 'its_a_secret' 55 | ... ).construct() 56 | >>> capabilities = client.capabilities() 57 | >>> print(capabilities) 58 | """ 59 | 60 | def delete_xpaths(self, xpaths, prefix=None): 61 | """A convenience wrapper for set() which constructs Paths from supplied xpaths 62 | to be passed to set() as the delete parameter. 63 | 64 | Parameters 65 | ---------- 66 | xpaths : iterable of str 67 | XPaths to specify to be deleted. 68 | If prefix is specified these strings are assumed to be the suffixes. 69 | prefix : str 70 | The XPath prefix to apply to all XPaths for deletion. 71 | 72 | Returns 73 | ------- 74 | set() 75 | """ 76 | if isinstance(xpaths, string_types): 77 | xpaths = [xpaths] 78 | paths = [] 79 | # prefix is not supported on NX yet 80 | prefix = None 81 | for xpath in xpaths: 82 | if prefix: 83 | if prefix.endswith("/") and xpath.startswith("/"): 84 | xpath = "{prefix}{xpath}".format( 85 | prefix=prefix[:-1], xpath=xpath[1:] 86 | ) 87 | elif prefix.endswith("/") or xpath.startswith("/"): 88 | xpath = "{prefix}{xpath}".format(prefix=prefix, xpath=xpath) 89 | else: 90 | xpath = "{prefix}/{xpath}".format(prefix=prefix, xpath=xpath) 91 | paths.append(self.parse_xpath_to_gnmi_path(xpath)) 92 | return self.set(deletes=paths) 93 | 94 | def set_json( 95 | self, 96 | update_json_configs=None, 97 | replace_json_configs=None, 98 | ietf=False, 99 | prefix=None, 100 | ): 101 | """A convenience wrapper for set() which assumes JSON payloads and constructs desired messages. 102 | All parameters are optional, but at least one must be present. 103 | 104 | This method expects JSON in the same format as what you might send via the native gRPC interface 105 | with a fully modeled configuration which is then parsed to meet the gNMI implementation. 106 | 107 | Parameters 108 | ---------- 109 | update_json_configs : iterable of JSON configurations, optional 110 | JSON configs to apply as updates. 111 | replace_json_configs : iterable of JSON configurations, optional 112 | JSON configs to apply as replacements. 113 | ietf : bool, optional 114 | Use JSON_IETF vs JSON. 115 | prefix : proto.gnmi_pb2.Path, optional 116 | A common path prepended to all path elements in the message. This reduces message size by 117 | removing redundent path elements. Smaller message == improved thoughput. 118 | 119 | Returns 120 | ------- 121 | set() 122 | """ 123 | # JSON_IETF and prefix are not supported on NX yet 124 | ietf = False 125 | prefix = None 126 | 127 | if not any([update_json_configs, replace_json_configs]): 128 | raise Exception("Must supply at least one set of configurations to method!") 129 | 130 | def check_configs(name, configs): 131 | if isinstance(configs, string_types): 132 | logger.debug("Handling %s as JSON string.", name) 133 | try: 134 | configs = json.loads(configs) 135 | except: 136 | raise Exception("{name} is invalid JSON!".format(name=name)) 137 | configs = [configs] 138 | elif isinstance(configs, dict): 139 | logger.debug("Handling %s as already serialized JSON object.", name) 140 | configs = [configs] 141 | elif not isinstance(configs, (list, set)): 142 | raise Exception( 143 | "{name} must be an iterable of configs!".format(name=name) 144 | ) 145 | return configs 146 | 147 | def create_updates(name, configs): 148 | if not configs: 149 | return None 150 | configs = check_configs(name, configs) 151 | updates = [] 152 | for config in configs: 153 | if not isinstance(config, dict): 154 | raise Exception("config must be a JSON object!") 155 | if len(config.keys()) > 1: 156 | raise Exception("config should only target one YANG module!") 157 | top_element = next(iter(config.keys())) 158 | update = proto.gnmi_pb2.Update() 159 | update.path.CopyFrom(self.parse_xpath_to_gnmi_path(top_element)) 160 | config = config.pop(top_element) 161 | if ietf: 162 | update.val.json_ietf_val = json.dumps(config).encode("utf-8") 163 | else: 164 | update.val.json_val = json.dumps(config).encode("utf-8") 165 | updates.append(update) 166 | return updates 167 | 168 | updates = create_updates("update_json_configs", update_json_configs) 169 | replaces = create_updates("replace_json_configs", replace_json_configs) 170 | return self.set(prefix=prefix, updates=updates, replaces=replaces) 171 | 172 | def get_xpaths(self, xpaths, data_type="ALL", encoding="JSON"): 173 | """A convenience wrapper for get() which forms proto.gnmi_pb2.Path from supplied xpaths. 174 | 175 | Parameters 176 | ---------- 177 | xpaths : iterable of str or str 178 | An iterable of XPath strings to request data of 179 | If simply a str, wraps as a list for convenience 180 | data_type : proto.gnmi_pb2.GetRequest.DataType, optional 181 | A direct value or key from the GetRequest.DataType enum 182 | [ALL, CONFIG, STATE, OPERATIONAL] 183 | encoding : proto.gnmi_pb2.GetRequest.Encoding, optional 184 | A direct value or key from the Encoding enum 185 | [JSON] is only setting supported at this time 186 | 187 | Returns 188 | ------- 189 | get() 190 | """ 191 | supported_encodings = ["JSON"] 192 | encoding = util.validate_proto_enum( 193 | "encoding", 194 | encoding, 195 | "Encoding", 196 | proto.gnmi_pb2.Encoding, 197 | supported_encodings, 198 | ) 199 | gnmi_path = None 200 | if isinstance(xpaths, (list, set)): 201 | gnmi_path = map(self.parse_xpath_to_gnmi_path, set(xpaths)) 202 | elif isinstance(xpaths, string_types): 203 | gnmi_path = [self.parse_xpath_to_gnmi_path(xpaths)] 204 | else: 205 | raise Exception( 206 | "xpaths must be a single xpath string or iterable of xpath strings!" 207 | ) 208 | return self.get(gnmi_path, data_type=data_type, encoding=encoding) 209 | 210 | def subscribe_xpaths( 211 | self, 212 | xpath_subscriptions, 213 | request_mode="STREAM", 214 | sub_mode="SAMPLE", 215 | encoding="PROTO", 216 | sample_interval=Client._NS_IN_S * 10, 217 | suppress_redundant=False, 218 | heartbeat_interval=None, 219 | prefix=None, 220 | ): 221 | """A convenience wrapper of subscribe() which aids in building of SubscriptionRequest 222 | with request as subscribe SubscriptionList. This method accepts an iterable of simply xpath strings, 223 | dictionaries with Subscription attributes for more granularity, or already built Subscription 224 | objects and builds the SubscriptionList. Fields not supplied will be defaulted with the default arguments 225 | to the method. 226 | 227 | Generates a single SubscribeRequest. 228 | 229 | Parameters 230 | ---------- 231 | xpath_subscriptions : str or iterable of str, dict, Subscription 232 | An iterable which is parsed to form the Subscriptions in the SubscriptionList to be passed 233 | to SubscriptionRequest. Strings are parsed as XPaths and defaulted with the default arguments, 234 | dictionaries are treated as dicts of args to pass to the Subscribe init, and Subscription is 235 | treated as simply a pre-made Subscription. 236 | request_mode : proto.gnmi_pb2.SubscriptionList.Mode, optional 237 | Indicates whether STREAM to stream from target, 238 | ONCE to stream once (like a get), 239 | POLL to respond to POLL. 240 | [STREAM, ONCE, POLL] 241 | sub_mode : proto.gnmi_pb2.SubscriptionMode, optional 242 | The default SubscriptionMode on a per Subscription basis in the SubscriptionList. 243 | ON_CHANGE only streams updates when changes occur. 244 | SAMPLE will stream the subscription at a regular cadence/interval. 245 | [ON_CHANGE, SAMPLE] 246 | encoding : proto.gnmi_pb2.Encoding, optional 247 | A member of the proto.gnmi_pb2.Encoding enum specifying desired encoding of returned data 248 | [JSON, PROTO] 249 | sample_interval : int, optional 250 | Default nanoseconds for sample to occur. 251 | Defaults to 10 seconds. 252 | suppress_redundant : bool, optional 253 | Indicates whether values that have not changed should be sent in a SAMPLE subscription. 254 | heartbeat_interval : int, optional 255 | Specifies the maximum allowable silent period in nanoseconds when 256 | suppress_redundant is in use. The target should send a value at least once 257 | in the period specified. 258 | prefix: proto.Path, optional 259 | Prefix path that can be used as a general path to prepend to all Path elements. (not supported on NX) 260 | 261 | Returns 262 | ------- 263 | subscribe() 264 | """ 265 | supported_request_modes = ["STREAM", "ONCE", "POLL"] 266 | request_mode = util.validate_proto_enum( 267 | "mode", 268 | request_mode, 269 | "SubscriptionList.Mode", 270 | proto.gnmi_pb2.SubscriptionList.Mode, 271 | subset=supported_request_modes, 272 | return_name=True, 273 | ) 274 | supported_encodings = ["JSON", "PROTO"] 275 | encoding = util.validate_proto_enum( 276 | "encoding", 277 | encoding, 278 | "Encoding", 279 | proto.gnmi_pb2.Encoding, 280 | subset=supported_encodings, 281 | return_name=True, 282 | ) 283 | supported_sub_modes = ["ON_CHANGE", "SAMPLE"] 284 | sub_mode = util.validate_proto_enum( 285 | "sub_mode", 286 | sub_mode, 287 | "SubscriptionMode", 288 | proto.gnmi_pb2.SubscriptionMode, 289 | subset=supported_sub_modes, 290 | return_name=True, 291 | ) 292 | return super(NXClient, self).subscribe_xpaths( 293 | xpath_subscriptions, 294 | request_mode, 295 | sub_mode, 296 | encoding, 297 | sample_interval, 298 | suppress_redundant, 299 | heartbeat_interval, 300 | prefix, 301 | ) 302 | 303 | @classmethod 304 | def parse_xpath_to_gnmi_path(cls, xpath, origin=None): 305 | """Attempts to determine whether origin should be YANG (device) or DME. 306 | """ 307 | if origin is None: 308 | if any( 309 | map( 310 | xpath.startswith, 311 | [ 312 | "Cisco-NX-OS-device", 313 | "/Cisco-NX-OS-device", 314 | "cisco-nx-os-device", 315 | "/cisco-nx-os-device", 316 | ], 317 | ) 318 | ): 319 | origin = "device" 320 | # Remove the module 321 | xpath = xpath.split(":", 1)[1] 322 | else: 323 | origin = "openconfig" 324 | return super(NXClient, cls).parse_xpath_to_gnmi_path(xpath, origin) 325 | -------------------------------------------------------------------------------- /src/cisco_gnmi/xe.py: -------------------------------------------------------------------------------- 1 | """Copyright 2019 Cisco Systems 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | The contents of this file are licensed under the Apache License, Version 2.0 12 | (the "License"); you may not use this file except in compliance with the 13 | License. You may obtain a copy of the License at 14 | 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations under 21 | the License. 22 | """ 23 | 24 | """Wrapper for IOS XE to simplify usage of gNMI implementation.""" 25 | 26 | import json 27 | import logging 28 | 29 | from six import string_types 30 | from .client import Client, proto, util 31 | 32 | 33 | LOGGER = logging.getLogger(__name__) 34 | logger = LOGGER 35 | 36 | 37 | class XEClient(Client): 38 | """IOS XE-specific wrapper for gNMI functionality. 39 | Assumes IOS XE 16.12+ 40 | 41 | Returns direct responses from base Client methods. 42 | 43 | Methods 44 | ------- 45 | delete_xpaths(...) 46 | Convenience wrapper for set() which constructs Paths from XPaths for deletion. 47 | get_xpaths(...) 48 | Convenience wrapper for get() which helps construct get requests for specified xpaths. 49 | set_json(...) 50 | Convenience wrapper for set() which assumes model-based JSON payloads. 51 | subscribe_xpaths(...) 52 | Convenience wrapper for subscribe() which helps construct subscriptions for specified xpaths. 53 | 54 | Examples 55 | -------- 56 | >>> from cisco_gnmi import ClientBuilder 57 | >>> client = ClientBuilder('127.0.0.1:9339').set_os( 58 | ... 'IOS XE' 59 | ... ).set_secure_from_file( 60 | ... 'rootCA.pem', 61 | ... 'client.key', 62 | ... 'client.crt' 63 | ... ).set_ssl_target_override().set_call_authentication( 64 | ... 'admin', 65 | ... 'its_a_secret' 66 | ... ).construct() 67 | >>> capabilities = client.capabilities() 68 | >>> print(capabilities) 69 | ... 70 | >>> get_response = client.get_xpaths('/interfaces/interface') 71 | >>> print(get_response) 72 | ... 73 | >>> subscribe_response = client.subscribe_xpaths('/interfaces/interface') 74 | >>> for message in subscribe_response: print(message) 75 | ... 76 | >>> config = '{"Cisco-IOS-XE-native:native": {"hostname": "gnmi_test"}}' 77 | >>> set_response = client.set_json(config) 78 | >>> print(set_response) 79 | ... 80 | >>> delete_response = client.delete_xpaths('/Cisco-IOS-XE-native:native/hostname') 81 | """ 82 | 83 | def delete_xpaths(self, xpaths, prefix=None): 84 | """A convenience wrapper for set() which constructs Paths from supplied xpaths 85 | to be passed to set() as the delete parameter. 86 | 87 | Parameters 88 | ---------- 89 | xpaths : iterable of str 90 | XPaths to specify to be deleted. 91 | If prefix is specified these strings are assumed to be the suffixes. 92 | prefix : str 93 | The XPath prefix to apply to all XPaths for deletion. 94 | 95 | Returns 96 | ------- 97 | set() 98 | """ 99 | if isinstance(xpaths, string_types): 100 | xpaths = [xpaths] 101 | paths = [] 102 | for xpath in xpaths: 103 | if prefix: 104 | if prefix.endswith("/") and xpath.startswith("/"): 105 | xpath = "{prefix}{xpath}".format( 106 | prefix=prefix[:-1], xpath=xpath[1:] 107 | ) 108 | elif prefix.endswith("/") or xpath.startswith("/"): 109 | xpath = "{prefix}{xpath}".format(prefix=prefix, xpath=xpath) 110 | else: 111 | xpath = "{prefix}/{xpath}".format(prefix=prefix, xpath=xpath) 112 | paths.append(self.parse_xpath_to_gnmi_path(xpath)) 113 | return self.set(deletes=paths) 114 | 115 | def set_json( 116 | self, 117 | update_json_configs=None, 118 | replace_json_configs=None, 119 | ietf=True, 120 | prefix=None, 121 | ): 122 | """A convenience wrapper for set() which assumes JSON payloads and constructs desired messages. 123 | All parameters are optional, but at least one must be present. 124 | 125 | This method expects JSON in the same format as what you might send via the native gRPC interface 126 | with a fully modeled configuration which is then parsed to meet the gNMI implementation. 127 | 128 | Parameters 129 | ---------- 130 | update_json_configs : iterable of JSON configurations, optional 131 | JSON configs to apply as updates. 132 | replace_json_configs : iterable of JSON configurations, optional 133 | JSON configs to apply as replacements. 134 | ietf : bool, optional 135 | Use JSON_IETF vs JSON. 136 | 137 | Returns 138 | ------- 139 | set() 140 | """ 141 | if not any([update_json_configs, replace_json_configs]): 142 | raise Exception("Must supply at least one set of configurations to method!") 143 | 144 | def check_configs(name, configs): 145 | if isinstance(configs, string_types): 146 | LOGGER.debug("Handling %s as JSON string.", name) 147 | try: 148 | configs = json.loads(configs) 149 | except: 150 | raise Exception("{name} is invalid JSON!".format(name=name)) 151 | configs = [configs] 152 | elif isinstance(configs, dict): 153 | LOGGER.debug("Handling %s as already serialized JSON object.", name) 154 | configs = [configs] 155 | elif not isinstance(configs, (list, set)): 156 | raise Exception( 157 | "{name} must be an iterable of configs!".format(name=name) 158 | ) 159 | return configs 160 | 161 | def create_updates(name, configs): 162 | if not configs: 163 | return None 164 | configs = check_configs(name, configs) 165 | updates = [] 166 | for config in configs: 167 | if not isinstance(config, dict): 168 | raise Exception("config must be a JSON object!") 169 | if len(config.keys()) > 1: 170 | raise Exception("config should only target one YANG module!") 171 | top_element = next(iter(config.keys())) 172 | update = proto.gnmi_pb2.Update() 173 | update.path.CopyFrom(self.parse_xpath_to_gnmi_path(top_element)) 174 | config = config.pop(top_element) 175 | if ietf: 176 | update.val.json_ietf_val = json.dumps(config).encode("utf-8") 177 | else: 178 | update.val.json_val = json.dumps(config).encode("utf-8") 179 | updates.append(update) 180 | return updates 181 | 182 | updates = create_updates("update_json_configs", update_json_configs) 183 | replaces = create_updates("replace_json_configs", replace_json_configs) 184 | return self.set(prefix=prefix, updates=updates, replaces=replaces) 185 | 186 | def get_xpaths(self, xpaths, data_type="ALL", encoding="JSON_IETF"): 187 | """A convenience wrapper for get() which forms proto.gnmi_pb2.Path from supplied xpaths. 188 | 189 | Parameters 190 | ---------- 191 | xpaths : iterable of str or str 192 | An iterable of XPath strings to request data of 193 | If simply a str, wraps as a list for convenience 194 | data_type : proto.gnmi_pb2.GetRequest.DataType, optional 195 | A direct value or key from the GetRequest.DataType enum 196 | [ALL, CONFIG, STATE, OPERATIONAL] 197 | encoding : proto.gnmi_pb2.GetRequest.Encoding, optional 198 | A direct value or key from the Encoding enum 199 | [JSON, JSON_IETF] 200 | 201 | Returns 202 | ------- 203 | get() 204 | """ 205 | supported_encodings = ["JSON", "JSON_IETF"] 206 | encoding = util.validate_proto_enum( 207 | "encoding", 208 | encoding, 209 | "Encoding", 210 | proto.gnmi_pb2.Encoding, 211 | supported_encodings, 212 | ) 213 | gnmi_path = None 214 | if isinstance(xpaths, (list, set)): 215 | gnmi_path = map(self.parse_xpath_to_gnmi_path, set(xpaths)) 216 | elif isinstance(xpaths, string_types): 217 | gnmi_path = [self.parse_xpath_to_gnmi_path(xpaths)] 218 | else: 219 | raise Exception( 220 | "xpaths must be a single xpath string or iterable of xpath strings!" 221 | ) 222 | return self.get(gnmi_path, data_type=data_type, encoding=encoding) 223 | 224 | def subscribe_xpaths( 225 | self, 226 | xpath_subscriptions, 227 | request_mode="STREAM", 228 | sub_mode="SAMPLE", 229 | encoding="JSON_IETF", 230 | sample_interval=Client._NS_IN_S * 10, 231 | suppress_redundant=False, 232 | heartbeat_interval=None, 233 | prefix=None, 234 | ): 235 | """A convenience wrapper of subscribe() which aids in building of SubscriptionRequest 236 | with request as subscribe SubscriptionList. This method accepts an iterable of simply xpath strings, 237 | dictionaries with Subscription attributes for more granularity, or already built Subscription 238 | objects and builds the SubscriptionList. Fields not supplied will be defaulted with the default arguments 239 | to the method. 240 | 241 | Generates a single SubscribeRequest. 242 | 243 | Parameters 244 | ---------- 245 | xpath_subscriptions : str or iterable of str, dict, Subscription 246 | An iterable which is parsed to form the Subscriptions in the SubscriptionList to be passed 247 | to SubscriptionRequest. Strings are parsed as XPaths and defaulted with the default arguments, 248 | dictionaries are treated as dicts of args to pass to the Subscribe init, and Subscription is 249 | treated as simply a pre-made Subscription. 250 | request_mode : proto.gnmi_pb2.SubscriptionList.Mode, optional 251 | Indicates whether STREAM to stream from target. 252 | [STREAM] 253 | sub_mode : proto.gnmi_pb2.SubscriptionMode, optional 254 | The default SubscriptionMode on a per Subscription basis in the SubscriptionList. 255 | SAMPLE will stream the subscription at a regular cadence/interval. 256 | [SAMPLE] 257 | encoding : proto.gnmi_pb2.Encoding, optional 258 | A member of the proto.gnmi_pb2.Encoding enum specifying desired encoding of returned data 259 | [JSON_IETF] 260 | sample_interval : int, optional 261 | Default nanoseconds for sample to occur. 262 | Defaults to 10 seconds. 263 | suppress_redundant : bool, optional 264 | Indicates whether values that have not changed should be sent in a SAMPLE subscription. 265 | heartbeat_interval : int, optional 266 | Specifies the maximum allowable silent period in nanoseconds when 267 | suppress_redundant is in use. The target should send a value at least once 268 | in the period specified. 269 | prefix : proto.gnmi_pb2.Path, optional 270 | A common path prepended to all path elements in the message. This reduces message size by 271 | removing redundent path elements. Smaller message == improved thoughput. 272 | 273 | Returns 274 | ------- 275 | subscribe() 276 | """ 277 | supported_request_modes = ["STREAM"] 278 | request_mode = util.validate_proto_enum( 279 | "mode", 280 | request_mode, 281 | "SubscriptionList.Mode", 282 | proto.gnmi_pb2.SubscriptionList.Mode, 283 | subset=supported_request_modes, 284 | return_name=True, 285 | ) 286 | supported_encodings = ["JSON_IETF"] 287 | encoding = util.validate_proto_enum( 288 | "encoding", 289 | encoding, 290 | "Encoding", 291 | proto.gnmi_pb2.Encoding, 292 | subset=supported_encodings, 293 | return_name=True, 294 | ) 295 | supported_sub_modes = ["SAMPLE"] 296 | sub_mode = util.validate_proto_enum( 297 | "sub_mode", 298 | sub_mode, 299 | "SubscriptionMode", 300 | proto.gnmi_pb2.SubscriptionMode, 301 | subset=supported_sub_modes, 302 | return_name=True, 303 | ) 304 | return super(XEClient, self).subscribe_xpaths( 305 | xpath_subscriptions, 306 | request_mode, 307 | sub_mode, 308 | encoding, 309 | sample_interval, 310 | suppress_redundant, 311 | heartbeat_interval, 312 | prefix, 313 | ) 314 | 315 | @classmethod 316 | def parse_xpath_to_gnmi_path(cls, xpath, origin=None): 317 | """Naively tries to intelligently (non-sequitur!) origin 318 | Otherwise assume rfc7951 319 | legacy is not considered 320 | """ 321 | if origin is None: 322 | # naive but effective 323 | if ":" not in xpath: 324 | origin = "openconfig" 325 | else: 326 | origin = "rfc7951" 327 | return super(XEClient, cls).parse_xpath_to_gnmi_path(xpath, origin) 328 | -------------------------------------------------------------------------------- /src/cisco_gnmi/builder.py: -------------------------------------------------------------------------------- 1 | """Copyright 2019 Cisco Systems 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | The contents of this file are licensed under the Apache License, Version 2.0 12 | (the "License"); you may not use this file except in compliance with the 13 | License. You may obtain a copy of the License at 14 | 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations under 21 | the License. 22 | """ 23 | 24 | """Builder to ease constructing cisco_gnmi.Client derived classes.""" 25 | 26 | import logging 27 | 28 | import grpc 29 | from . import Client, XRClient, NXClient, XEClient 30 | from .auth import CiscoAuthPlugin 31 | from .util import gen_target_netloc, get_cert_from_target, get_cn_from_cert 32 | 33 | 34 | LOGGER = logging.getLogger(__name__) 35 | logger = LOGGER 36 | 37 | 38 | class ClientBuilder(object): 39 | """Builder for the creation of a gNMI client. 40 | Supports construction of base Client and XRClient. 41 | Returns itself after each build stage to support chained initialization. 42 | 43 | Methods 44 | ------- 45 | set_target(...) 46 | Specifies the network element to build a client for. 47 | set_os(...) 48 | Specifies which OS wrapper to deliver. 49 | set_secure(...) 50 | Specifies that a secure gRPC channel should be used. 51 | set_secure_from_file(...) 52 | Loads certificates from file system for secure gRPC channel. 53 | set_secure_from_target(...) 54 | Attempts to utilize available certificate from target for secure gRPC channel. 55 | set_call_authentication(...) 56 | Specifies username/password to utilize for authentication. 57 | set_ssl_target_override(...) 58 | Sets the gRPC option to override the SSL target name. 59 | set_channel_option(...) 60 | Sets a gRPC channel option. Implies knowledge of channel options. 61 | construct() 62 | Constructs and returns the built Client. 63 | _reset() 64 | Resets builder to baseline state. 65 | 66 | Examples 67 | -------- 68 | >>> from cisco_gnmi import ClientBuilder 69 | >>> client = ClientBuilder( 70 | ... '127.0.0.1:9339' 71 | ... ).set_os( 72 | ... 'IOS XR' 73 | ... ).set_secure_from_target().set_ssl_target_override().set_authentication( 74 | ... 'admin', 75 | ... 'its_a_secret' 76 | ... ).construct() 77 | >>> capabilities = client.capabilities() 78 | >>> print(capabilities) 79 | """ 80 | 81 | os_class_map = { 82 | None: Client, 83 | "None": Client, 84 | "IOS XR": XRClient, 85 | "XR": XRClient, 86 | "NX-OS": NXClient, 87 | "NX": NXClient, 88 | "IOS XE": XEClient, 89 | "XE": XEClient, 90 | } 91 | 92 | def __init__(self, target): 93 | """Initializes the builder, most initialization is done via set_* methods. 94 | A target is always required, thus a member of the constructor. 95 | 96 | Parameters 97 | ---------- 98 | target : str 99 | The target address of the network element to interact with. 100 | Expects a URL-like form, e.g. 127.0.0.1:9339 101 | """ 102 | self.set_target(target) 103 | self._reset() 104 | 105 | def set_target(self, target): 106 | """Specifies the network element to build a client for. 107 | 108 | Parameters 109 | ---------- 110 | target : str 111 | The target address of the network element to interact with. 112 | Expects a URL-like form, e.g. 127.0.0.1:9339 113 | 114 | Returns 115 | ------- 116 | self 117 | """ 118 | self.__target = target 119 | self.__target_netloc = gen_target_netloc(self.__target) 120 | return self 121 | 122 | def set_os(self, name=None): 123 | """Sets which OS to target which maps to an OS wrapper class. 124 | 125 | Parameters 126 | ---------- 127 | name : str 128 | "IOS XR" maps to the XRClient class. 129 | "NX-OS" maps to the NXClient class. 130 | "IOS XE" maps to the XEClient class. 131 | None maps to the base Client class which simply wraps the gNMI stub. 132 | ["IOS XR", "NX-OS", "IOS XE", None] 133 | 134 | Returns 135 | ------- 136 | self 137 | """ 138 | if name not in self.os_class_map.keys(): 139 | raise Exception("OS not supported!") 140 | else: 141 | LOGGER.debug("Using %s wrapper.", name or "Client") 142 | self.__client_class = self.os_class_map[name] 143 | return self 144 | 145 | def set_secure( 146 | self, root_certificates=None, private_key=None, certificate_chain=None 147 | ): 148 | """Simply sets the fields to be expected by grpc.ssl_channel_credentials(...). 149 | Setting this method disallows following set_secure(...) or _set_insecure() calls. 150 | 151 | Parameters 152 | ---------- 153 | root_certificates : str or None 154 | private_key : str or None 155 | certificate_chain : str or None 156 | 157 | Returns 158 | ------- 159 | self 160 | """ 161 | self.__secure = True 162 | self.__root_certificates = root_certificates 163 | self.__private_key = private_key 164 | self.__certificate_chain = certificate_chain 165 | return self 166 | 167 | def _set_insecure(self): 168 | """Sets the flag to use an insecure channel. 169 | THIS IS AGAINST SPECIFICATION and should not 170 | be used unless necessary and secure transport 171 | is already well understood. 172 | 173 | Returns 174 | ------- 175 | self 176 | """ 177 | self.__secure = False 178 | return self 179 | 180 | def set_secure_from_file( 181 | self, root_certificates=None, private_key=None, certificate_chain=None 182 | ): 183 | """Wraps set_secure(...) but treats arguments as file paths. 184 | 185 | Parameters 186 | ---------- 187 | root_certicates : str or None 188 | private_key : str or None 189 | certificate_chain : str or None 190 | 191 | Returns 192 | ------- 193 | self 194 | """ 195 | 196 | def load_cert(file_path): 197 | cert_content = None 198 | if file_path is not None: 199 | with open(file_path, "rb") as cert_fd: 200 | cert_content = cert_fd.read() 201 | return cert_content 202 | 203 | if root_certificates: 204 | root_certificates = load_cert(root_certificates) 205 | if private_key: 206 | private_key = load_cert(private_key) 207 | if certificate_chain: 208 | certificate_chain = load_cert(certificate_chain) 209 | self.set_secure(root_certificates, private_key, certificate_chain) 210 | return self 211 | 212 | def set_secure_from_target(self): 213 | """Wraps set_secure(...) but loads root certificates from target. 214 | In effect, simply uses the target's certificate to create an encrypted channel. 215 | 216 | TODO: This may not work with IOS XE and NX-OS, uncertain. 217 | 218 | Returns 219 | ------- 220 | self 221 | """ 222 | root_certificates = get_cert_from_target(self.__target_netloc) 223 | self.set_secure(root_certificates) 224 | return self 225 | 226 | def set_call_authentication(self, username, password): 227 | """Sets the username and password to utilize for authentication.""" 228 | self.__username = username 229 | self.__password = password 230 | return self 231 | 232 | def set_ssl_target_override(self, ssl_target_name_override=None): 233 | """Sets the gRPC option to override the SSL target name with the specified value. 234 | 235 | If None supplied then the option will be derived from the CN of the root certificate. 236 | set_secure_from_target().set_ssl_target_override() effectively creates an encrypted 237 | but insecure channel. 238 | The above behavior attempts to replicate the Go gNMI reference -insecure parameter. 239 | This is not recommended other than for testing purposes. 240 | 241 | Parameters 242 | ---------- 243 | ssl_target_name_override : str or None 244 | Value for grpc.ssl_target_name_override 245 | 246 | Returns 247 | ------- 248 | self 249 | """ 250 | self.__ssl_target_name_override = ssl_target_name_override 251 | return self 252 | 253 | def set_channel_option(self, name, value): 254 | """Sets a gRPC channel option. This method implies understanding of gRPC channels. 255 | If the option is found, the value is overwritten. 256 | 257 | Parameters 258 | ---------- 259 | name : str 260 | The gRPC channel option name. 261 | value : ? 262 | The value of the named option. 263 | 264 | Returns 265 | ------- 266 | self 267 | """ 268 | new_option = (name, value) 269 | if not self.__channel_options: 270 | self.__channel_options = [new_option] 271 | else: 272 | found_index = None 273 | for index, option in enumerate(self.__channel_options): 274 | if option[0] == name: 275 | found_index = index 276 | break 277 | if found_index is not None: 278 | LOGGER.warning("Found existing channel option %s, overwriting!", name) 279 | self.__channel_options[found_index] = new_option 280 | else: 281 | self.__channel_options.append(new_option) 282 | return self 283 | 284 | def construct(self, return_channel=False): 285 | """Constructs and returns the desired Client object. 286 | The instance of this class will reset to default values for further building. 287 | 288 | Returns 289 | ------- 290 | Client or NXClient or XEClient or XRClient 291 | """ 292 | channel = None 293 | if self.__secure: 294 | LOGGER.debug("Using secure channel.") 295 | channel_metadata_creds = None 296 | if self.__username and self.__password: 297 | LOGGER.debug("Using username/password call authentication.") 298 | channel_metadata_creds = grpc.metadata_call_credentials( 299 | CiscoAuthPlugin(self.__username, self.__password) 300 | ) 301 | channel_ssl_creds = grpc.ssl_channel_credentials( 302 | self.__root_certificates, self.__private_key, self.__certificate_chain 303 | ) 304 | channel_creds = None 305 | if channel_ssl_creds and channel_metadata_creds: 306 | LOGGER.debug("Using SSL/metadata authentication composite credentials.") 307 | channel_creds = grpc.composite_channel_credentials( 308 | channel_ssl_creds, channel_metadata_creds 309 | ) 310 | else: 311 | LOGGER.debug( 312 | "Using SSL credentials, no channel metadata authentication." 313 | ) 314 | channel_creds = channel_ssl_creds 315 | if self.__ssl_target_name_override is not False: 316 | if self.__ssl_target_name_override is None: 317 | if not self.__root_certificates: 318 | raise Exception("Deriving override requires root certificate!") 319 | self.__ssl_target_name_override = get_cn_from_cert( 320 | self.__root_certificates 321 | ) 322 | LOGGER.warning( 323 | "Overriding SSL option from certificate could increase MITM susceptibility!" 324 | ) 325 | self.set_channel_option( 326 | "grpc.ssl_target_name_override", self.__ssl_target_name_override 327 | ) 328 | channel = grpc.secure_channel( 329 | self.__target_netloc.netloc, channel_creds, self.__channel_options 330 | ) 331 | else: 332 | LOGGER.warning( 333 | "Insecure gRPC channel is against gNMI specification, personal data may be compromised." 334 | ) 335 | channel = grpc.insecure_channel(self.__target_netloc.netloc) 336 | if self.__client_class is None: 337 | self.set_os() 338 | client = None 339 | if self.__secure: 340 | client = self.__client_class(channel) 341 | else: 342 | client = self.__client_class( 343 | channel, 344 | default_call_metadata=[ 345 | ("username", self.__username), 346 | ("password", self.__password), 347 | ], 348 | ) 349 | self._reset() 350 | if return_channel: 351 | return client, channel 352 | else: 353 | return client 354 | 355 | def _reset(self): 356 | """Resets the builder. 357 | 358 | Returns 359 | ------- 360 | self 361 | """ 362 | self.set_target(self.__target) 363 | self.__client_class = None 364 | self.__root_certificates = None 365 | self.__private_key = None 366 | self.__certificate_chain = None 367 | self.__username = None 368 | self.__password = None 369 | self.__channel_options = None 370 | self.__ssl_target_name_override = False 371 | self.__secure = True 372 | return self 373 | -------------------------------------------------------------------------------- /src/cisco_gnmi/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Copyright 2020 Cisco Systems 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | The contents of this file are licensed under the Apache License, Version 2.0 13 | (the "License"); you may not use this file except in compliance with the 14 | License. You may obtain a copy of the License at 15 | 16 | http://www.apache.org/licenses/LICENSE-2.0 17 | 18 | Unless required by applicable law or agreed to in writing, software 19 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 20 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 21 | License for the specific language governing permissions and limitations under 22 | the License. 23 | """ 24 | 25 | """ 26 | Wraps gNMI RPCs with a reasonably useful CLI for interacting with network elements. 27 | Supports Capabilities, Subscribe, Get, and Set. 28 | 29 | Command parsing sourced from this wonderful blog by Chase Seibert 30 | https://chase-seibert.github.io/blog/2014/03/21/python-multilevel-argparse.html 31 | """ 32 | import json 33 | import logging 34 | import argparse 35 | from getpass import getpass 36 | from google.protobuf import json_format, text_format 37 | from . import ClientBuilder, proto, __version__ 38 | from google.protobuf.internal import enum_type_wrapper 39 | import sys 40 | 41 | 42 | def main(): 43 | # Using a map so we don't have function overlap e.g. set() 44 | rpc_map = { 45 | "capabilities": gnmi_capabilities, 46 | "subscribe": gnmi_subscribe, 47 | "get": gnmi_get, 48 | "set": gnmi_set, 49 | } 50 | parser = argparse.ArgumentParser( 51 | description="gNMI CLI demonstrating cisco_gnmi library usage.", 52 | usage=""" 53 | cisco-gnmi [] 54 | 55 | Version {version} 56 | 57 | Supported RPCs: 58 | {supported_rpcs} 59 | 60 | cisco-gnmi capabilities 127.0.0.1:57500 61 | cisco-gnmi get 127.0.0.1:57500 -xpath /interfaces/interface/state/counters 62 | cisco-gnmi set 127.0.0.1:57500 -update_json_config newconfig.json 63 | cisco-gnmi subscribe 127.0.0.1:57500 -xpath /interfaces/interface/state/counters -dump_file intfcounters.proto.txt 64 | 65 | See --help for RPC options. 66 | """.format( 67 | version=__version__, supported_rpcs="\n".join(sorted(list(rpc_map.keys()))) 68 | ), 69 | ) 70 | parser.add_argument("rpc", help="gNMI RPC to perform against network element.") 71 | args = parser.parse_args(sys.argv[1:2]) 72 | if args.rpc not in rpc_map.keys(): 73 | logging.error( 74 | "%s not in supported RPCs: %s!", args.rpc, ", ".join(rpc_map.keys()) 75 | ) 76 | parser.print_help() 77 | exit(1) 78 | try: 79 | rpc_map[args.rpc]() 80 | except Exception: 81 | logging.exception("Error during usage!") 82 | exit(1) 83 | 84 | 85 | def gnmi_capabilities(): 86 | parser = argparse.ArgumentParser( 87 | description="Performs Capabilities RPC against network element." 88 | ) 89 | args = __common_args_handler(parser) 90 | client = __gen_client(args) 91 | capability_response = client.capabilities() 92 | logging.info(__format_message(capability_response)) 93 | 94 | 95 | def gnmi_subscribe(): 96 | """Performs a streaming Subscribe against network element. 97 | """ 98 | parser = argparse.ArgumentParser( 99 | description="Performs Subscribe RPC against network element." 100 | ) 101 | parser.add_argument( 102 | "-xpath", help="XPath to subscribe to.", type=str, action="append" 103 | ) 104 | parser.add_argument( 105 | "-interval", 106 | help="Sample interval in seconds for Subscription. Defaults to 10.", 107 | type=int, 108 | default=10, 109 | ) 110 | parser.add_argument( 111 | "-mode", 112 | help="SubscriptionMode for Subscription. Defaults to SAMPLE.", 113 | default="SAMPLE", 114 | choices=proto.gnmi_pb2.SubscriptionMode.keys(), 115 | ) 116 | parser.add_argument( 117 | "-req_mode", 118 | help="SubscriptionList.Mode mode for Subscriptions. Defaults to STREAM.", 119 | default="STREAM", 120 | choices=proto.gnmi_pb2.SubscriptionList.Mode.keys(), 121 | ) 122 | parser.add_argument( 123 | "-suppress_redundant", 124 | help="Suppress redundant information in Subscription.", 125 | action="store_true", 126 | ) 127 | parser.add_argument( 128 | "-heartbeat_interval", help="Heartbeat interval in seconds.", type=int 129 | ) 130 | parser.add_argument( 131 | "-dump_file", 132 | help="Filename to dump to. Defaults to stdout.", 133 | type=str, 134 | default="stdout", 135 | ) 136 | parser.add_argument( 137 | "-dump_json", 138 | help="Dump as JSON instead of textual protos.", 139 | action="store_true", 140 | ) 141 | parser.add_argument( 142 | "-sync_stop", help="Stop on sync_response.", action="store_true" 143 | ) 144 | parser.add_argument( 145 | "-sync_start", 146 | help="Start processing messages after sync_response.", 147 | action="store_true", 148 | ) 149 | parser.add_argument( 150 | "-encoding", 151 | help="gNMI Encoding. Defaults to whatever Client wrapper prefers.", 152 | type=str, 153 | choices=proto.gnmi_pb2.Encoding.keys(), 154 | ) 155 | args = __common_args_handler(parser) 156 | # Set default XPath outside of argparse due to default being persistent in argparse. 157 | if not args.xpath: 158 | args.xpath = ["/interfaces/interface/state/counters"] 159 | client = __gen_client(args) 160 | # Take care not to override options unnecessarily. 161 | kwargs = {} 162 | if args.encoding: 163 | kwargs["encoding"] = args.encoding 164 | if args.interval: 165 | kwargs["sample_interval"] = args.interval * int(1e9) 166 | if args.mode: 167 | kwargs["sub_mode"] = args.mode 168 | if args.req_mode: 169 | kwargs["request_mode"] = args.req_mode 170 | if args.suppress_redundant: 171 | kwargs["suppress_redundant"] = args.suppress_redundant 172 | if args.heartbeat_interval: 173 | kwargs["heartbeat_interval"] = args.heartbeat_interval * int(1e9) 174 | try: 175 | logging.debug( 176 | "Dumping responses to %s as %s ...", 177 | args.dump_file, 178 | "JSON" if args.dump_json else "textual proto", 179 | ) 180 | logging.debug("Subscribing to:\n%s", "\n".join(args.xpath)) 181 | synced = False 182 | for subscribe_response in client.subscribe_xpaths(args.xpath, **kwargs): 183 | logging.debug("SubscribeResponse received.") 184 | if subscribe_response.sync_response: 185 | logging.debug("sync_response received.") 186 | if args.sync_stop: 187 | logging.warning("Stopping on sync_response.") 188 | break 189 | synced = True 190 | if not synced and args.sync_start: 191 | continue 192 | formatted_message = __format_message(subscribe_response) 193 | if args.dump_file == "stdout": 194 | logging.info(formatted_message) 195 | else: 196 | with open(args.dump_file, "a") as dump_fd: 197 | dump_fd.write(formatted_message) 198 | except KeyboardInterrupt: 199 | logging.warning("Stopping on interrupt.") 200 | except Exception: 201 | logging.exception("Stopping due to exception!") 202 | 203 | 204 | def gnmi_get(): 205 | """Provides Get RPC usage. Assumes JSON or JSON_IETF style configurations. 206 | """ 207 | parser = argparse.ArgumentParser( 208 | description="Performs Get RPC against network element." 209 | ) 210 | parser.add_argument("-xpath", help="XPaths to Get.", type=str, action="append") 211 | parser.add_argument( 212 | "-encoding", 213 | help="gNMI Encoding.", 214 | type=str, 215 | choices=proto.gnmi_pb2.Encoding.keys(), 216 | ) 217 | parser.add_argument( 218 | "-data_type", 219 | help="gNMI GetRequest DataType", 220 | type=str, 221 | choices=enum_type_wrapper.EnumTypeWrapper( 222 | proto.gnmi_pb2._GETREQUEST_DATATYPE 223 | ).keys(), 224 | ) 225 | parser.add_argument( 226 | "-dump_json", 227 | help="Dump as JSON instead of textual protos.", 228 | action="store_true", 229 | ) 230 | args = __common_args_handler(parser) 231 | # Set default XPath outside of argparse due to default being persistent in argparse. 232 | if not args.xpath: 233 | args.xpath = ["/interfaces/interface/state/counters"] 234 | client = __gen_client(args) 235 | kwargs = {} 236 | if args.encoding: 237 | kwargs["encoding"] = args.encoding 238 | if args.data_type: 239 | kwargs["data_type"] = args.data_type 240 | get_response = client.get_xpaths(args.xpath, **kwargs) 241 | logging.info(__format_message(get_response)) 242 | 243 | 244 | def gnmi_set(): 245 | """Provides Set RPC usage. Assumes JSON or JSON_IETF style configurations. 246 | Applies update/replace operations, and then delete operations. 247 | TODO: This is the least well understood/implemented. Need to validate if there is an OOO for update/replace/delete. 248 | """ 249 | parser = argparse.ArgumentParser( 250 | description="Performs Set RPC against network element." 251 | ) 252 | parser.add_argument( 253 | "-update_json_config", help="JSON-modeled config to apply as an update." 254 | ) 255 | parser.add_argument( 256 | "-replace_json_config", help="JSON-modeled config to apply as a replace." 257 | ) 258 | parser.add_argument( 259 | "-delete_xpath", help="XPaths to delete.", type=str, action="append" 260 | ) 261 | parser.add_argument( 262 | "-no_ietf", help="JSON is not IETF conformant.", action="store_true" 263 | ) 264 | parser.add_argument( 265 | "-dump_json", 266 | help="Dump as JSON instead of textual protos.", 267 | action="store_true", 268 | ) 269 | args = __common_args_handler(parser) 270 | if not any([args.update_json_config, args.replace_json_config, args.delete_xpath]): 271 | raise Exception("Must specify update, replace, or delete parameters!") 272 | 273 | def load_json_file(filename): 274 | config = None 275 | with open(filename, "r") as config_fd: 276 | config = json.load(config_fd) 277 | return json.dumps(config) 278 | 279 | if args.update_json_config or args.replace_json_config: 280 | kwargs = {} 281 | if args.update_json_config: 282 | kwargs["update_json_configs"] = load_json_file(args.update_json_config) 283 | if args.replace_json_config: 284 | kwargs["replace_json_configs"] = load_json_file(args.replace_json_config) 285 | if args.no_ietf: 286 | kwargs["ietf"] = False 287 | client = __gen_client(args) 288 | set_response = client.set_json(**kwargs) 289 | logging.info(__format_message(set_response)) 290 | if args.delete_xpath: 291 | if getattr(client, "delete_xpaths", None) is not None: 292 | delete_response = client.delete_xpaths(args.xpath) 293 | logging.info(__format_message(delete_response)) 294 | else: 295 | raise Exception( 296 | "Convenience delete_xpaths is not supported in the client library!" 297 | ) 298 | 299 | 300 | def __gen_client(args): 301 | builder = ClientBuilder(args.netloc) 302 | builder.set_os(args.os) 303 | builder.set_call_authentication(args.username, args.password) 304 | if args.insecure: 305 | builder._set_insecure() 306 | elif not any([args.root_certificates, args.private_key, args.certificate_chain]): 307 | builder.set_secure_from_target() 308 | else: 309 | builder.set_secure_from_file( 310 | args.root_certificates, args.private_key, args.certificate_chain 311 | ) 312 | if args.ssl_target_override: 313 | builder.set_ssl_target_override(args.ssl_target_override) 314 | elif args.auto_ssl_target_override: 315 | builder.set_ssl_target_override() 316 | return builder.construct() 317 | 318 | 319 | def __format_message(message, as_json=False): 320 | formatted_message = None 321 | if as_json: 322 | formatted_message = json_format.MessageToJson(message, sort_keys=True) 323 | else: 324 | formatted_message = text_format.MessageToString(message) 325 | return formatted_message 326 | 327 | 328 | def __common_args_handler(parser): 329 | """Ideally would be a decorator.""" 330 | parser.add_argument("netloc", help=":", type=str) 331 | parser.add_argument( 332 | "-os", 333 | help="OS wrapper to utilize. Defaults to IOS XR.", 334 | type=str, 335 | default="IOS XR", 336 | choices=list(ClientBuilder.os_class_map.keys()), 337 | ) 338 | parser.add_argument( 339 | "-root_certificates", help="Root certificates for secure connection." 340 | ) 341 | parser.add_argument("-private_key", help="Private key for secure connection.") 342 | parser.add_argument( 343 | "-certificate_chain", help="Certificate chain for secure connection." 344 | ) 345 | parser.add_argument("-ssl_target_override", help="gRPC SSL target override option.") 346 | parser.add_argument( 347 | "-auto_ssl_target_override", 348 | help="Use root_certificates first CN as grpc.ssl_target_name_override.", 349 | action="store_true", 350 | ) 351 | parser.add_argument("-debug", help="Print debug messages.", action="store_true") 352 | parser.add_argument("-insecure", help=argparse.SUPPRESS, action="store_true") 353 | args = parser.parse_args(sys.argv[2:]) 354 | logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) 355 | args.username = input("Username: ") 356 | args.password = getpass() 357 | return args 358 | 359 | 360 | if __name__ == "__main__": 361 | main() 362 | -------------------------------------------------------------------------------- /src/cisco_gnmi/xr.py: -------------------------------------------------------------------------------- 1 | """Copyright 2019 Cisco Systems 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | The contents of this file are licensed under the Apache License, Version 2.0 12 | (the "License"); you may not use this file except in compliance with the 13 | License. You may obtain a copy of the License at 14 | 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations under 21 | the License. 22 | """ 23 | 24 | """Wrapper for IOS XR to simplify usage of gNMI implementation.""" 25 | 26 | import json 27 | import logging 28 | 29 | from six import string_types 30 | from .client import Client, proto, util 31 | 32 | 33 | LOGGER = logging.getLogger(__name__) 34 | logger = LOGGER 35 | 36 | 37 | class XRClient(Client): 38 | """IOS XR-specific wrapper for gNMI functionality. 39 | 40 | Returns direct responses from base Client methods. 41 | 42 | Methods 43 | ------- 44 | delete_xpaths(...) 45 | Convenience wrapper for set() which constructs Paths from XPaths for deletion. 46 | get_xpaths(...) 47 | Convenience wrapper for get() which helps construct get requests for specified xpaths. 48 | set_json(...) 49 | Convenience wrapper for set() which assumes model-based JSON payloads. 50 | subscribe_xpaths(...) 51 | Convenience wrapper for subscribe() which helps construct subscriptions for specified xpaths. 52 | 53 | Examples 54 | -------- 55 | >>> from cisco_gnmi import ClientBuilder 56 | >>> client = ClientBuilder('127.0.0.1:9339').set_os( 57 | ... 'IOS XR' 58 | ... ).set_secure_from_file( 59 | ... 'ems.pem', 60 | ... ).set_ssl_target_override().set_call_authentication( 61 | ... 'admin', 62 | ... 'its_a_secret' 63 | ... ).construct() 64 | >>> capabilities = client.capabilities() 65 | >>> print(capabilities) 66 | ... 67 | >>> get_response = client.get_xpaths('interfaces/interface') 68 | >>> print(get_response) 69 | ... 70 | >>> subscribe_response = client.subscribe_xpaths('interfaces/interface') 71 | >>> for message in subscribe_response: print(message) 72 | ... 73 | >>> config = '{"Cisco-IOS-XR-shellutil-cfg:host-names": [{"host-name": "gnmi_test"}]}' 74 | >>> set_response = client.set_json(config) 75 | >>> print(set_response) 76 | ... 77 | >>> delete_response = client.delete_xpaths('Cisco-IOS-XR-shellutil-cfg:host-names/host-name') 78 | """ 79 | 80 | def delete_xpaths(self, xpaths, prefix=None): 81 | """A convenience wrapper for set() which constructs Paths from supplied xpaths 82 | to be passed to set() as the delete parameter. 83 | 84 | Parameters 85 | ---------- 86 | xpaths : iterable of str 87 | XPaths to specify to be deleted. 88 | If prefix is specified these strings are assumed to be the suffixes. 89 | prefix : str 90 | The XPath prefix to apply to all XPaths for deletion. 91 | 92 | Returns 93 | ------- 94 | set() 95 | """ 96 | if isinstance(xpaths, string_types): 97 | xpaths = [xpaths] 98 | paths = [] 99 | for xpath in xpaths: 100 | if prefix: 101 | if prefix.endswith("/") and xpath.startswith("/"): 102 | xpath = "{prefix}{xpath}".format( 103 | prefix=prefix[:-1], xpath=xpath[1:] 104 | ) 105 | elif prefix.endswith("/") or xpath.startswith("/"): 106 | xpath = "{prefix}{xpath}".format(prefix=prefix, xpath=xpath) 107 | else: 108 | xpath = "{prefix}/{xpath}".format(prefix=prefix, xpath=xpath) 109 | paths.append(self.parse_xpath_to_gnmi_path(xpath)) 110 | return self.set(deletes=paths) 111 | 112 | def set_json(self, update_json_configs=None, replace_json_configs=None, ietf=True): 113 | """A convenience wrapper for set() which assumes JSON payloads and constructs desired messages. 114 | All parameters are optional, but at least one must be present. 115 | 116 | This method expects JSON in the same format as what you might send via the native gRPC interface 117 | with a fully modeled configuration which is then parsed to meet the gNMI implementation. 118 | 119 | Parameters 120 | ---------- 121 | update_json_configs : iterable of JSON configurations, optional 122 | JSON configs to apply as updates. 123 | replace_json_configs : iterable of JSON configurations, optional 124 | JSON configs to apply as replacements. 125 | ietf : bool, optional 126 | Use JSON_IETF vs JSON. 127 | 128 | Returns 129 | ------- 130 | set() 131 | """ 132 | if not any([update_json_configs, replace_json_configs]): 133 | raise Exception("Must supply at least one set of configurations to method!") 134 | 135 | def check_configs(name, configs): 136 | if isinstance(name, string_types): 137 | LOGGER.debug("Handling %s as JSON string.", name) 138 | try: 139 | configs = json.loads(configs) 140 | except: 141 | raise Exception("{name} is invalid JSON!".format(name=name)) 142 | configs = [configs] 143 | elif isinstance(name, dict): 144 | LOGGER.debug("Handling %s as already serialized JSON object.", name) 145 | configs = [configs] 146 | elif not isinstance(configs, (list, set)): 147 | raise Exception( 148 | "{name} must be an iterable of configs!".format(name=name) 149 | ) 150 | return configs 151 | 152 | def create_updates(name, configs): 153 | if not configs: 154 | return None 155 | configs = check_configs(name, configs) 156 | updates = [] 157 | for config in configs: 158 | if not isinstance(config, dict): 159 | raise Exception("config must be a JSON object!") 160 | if len(config.keys()) > 1: 161 | raise Exception("config should only target one YANG module!") 162 | top_element = next(iter(config.keys())) 163 | top_element_split = top_element.split(":") 164 | if len(top_element_split) < 2: 165 | raise Exception( 166 | "Top level config element {} should be module prefixed!".format( 167 | top_element 168 | ) 169 | ) 170 | if len(top_element_split) > 2: 171 | raise Exception( 172 | "Top level config element {} appears malformed!".format( 173 | top_element 174 | ) 175 | ) 176 | origin = top_element_split[0] 177 | element = top_element_split[1] 178 | config = config.pop(top_element) 179 | update = proto.gnmi_pb2.Update() 180 | update.path.CopyFrom(self.parse_xpath_to_gnmi_path(element, origin)) 181 | if ietf: 182 | update.val.json_ietf_val = json.dumps(config).encode("utf-8") 183 | else: 184 | update.val.json_val = json.dumps(config).encode("utf-8") 185 | updates.append(update) 186 | return updates 187 | 188 | updates = create_updates("update_json_configs", update_json_configs) 189 | replaces = create_updates("replace_json_configs", replace_json_configs) 190 | return self.set(updates=updates, replaces=replaces) 191 | 192 | def get_xpaths(self, xpaths, data_type="ALL", encoding="JSON_IETF"): 193 | """A convenience wrapper for get() which forms proto.gnmi_pb2.Path from supplied xpaths. 194 | 195 | Parameters 196 | ---------- 197 | xpaths : iterable of str or str 198 | An iterable of XPath strings to request data of 199 | If simply a str, wraps as a list for convenience 200 | data_type : proto.gnmi_pb2.GetRequest.DataType, optional 201 | A direct value or key from the GetRequest.DataType enum 202 | [ALL, CONFIG, STATE, OPERATIONAL] 203 | encoding : proto.gnmi_pb2.GetRequest.Encoding, optional 204 | A direct value or key from the Encoding enum 205 | [JSON, BYTES, PROTO, ASCII, JSON_IETF] 206 | 207 | Returns 208 | ------- 209 | get() 210 | """ 211 | gnmi_path = None 212 | if isinstance(xpaths, (list, set)): 213 | gnmi_path = map(self.parse_xpath_to_gnmi_path, set(xpaths)) 214 | elif isinstance(xpaths, string_types): 215 | gnmi_path = [self.parse_xpath_to_gnmi_path(xpaths)] 216 | else: 217 | raise Exception( 218 | "xpaths must be a single xpath string or iterable of xpath strings!" 219 | ) 220 | return self.get(gnmi_path, data_type=data_type, encoding=encoding) 221 | 222 | def get_cli(self, commands): 223 | """A convenience wrapper for get() which forms proto.gnmi_pb2.Path from supplied CLI commands. 224 | IOS XR appears to be the only OS with this functionality. 225 | 226 | Parameters 227 | ---------- 228 | commands : iterable of str or str 229 | An iterable of CLI commands as strings to request data of 230 | If simply a str, wraps as a list for convenience 231 | 232 | Returns 233 | ------- 234 | get() 235 | """ 236 | gnmi_path = None 237 | if isinstance(commands, (list, set)): 238 | gnmi_path = list(map(self.parse_cli_to_gnmi_path, commands)) 239 | elif isinstance(commands, string_types): 240 | gnmi_path = [self.parse_cli_to_gnmi_path(commands)] 241 | else: 242 | raise Exception( 243 | "commands must be a single CLI command string or iterable of CLI commands as strings!" 244 | ) 245 | return self.get(gnmi_path, encoding="ASCII") 246 | 247 | def subscribe_xpaths( 248 | self, 249 | xpath_subscriptions, 250 | request_mode="STREAM", 251 | sub_mode="SAMPLE", 252 | encoding="PROTO", 253 | sample_interval=Client._NS_IN_S * 10, 254 | suppress_redundant=False, 255 | heartbeat_interval=None, 256 | prefix=None, 257 | ): 258 | """A convenience wrapper of subscribe() which aids in building of SubscriptionRequest 259 | with request as subscribe SubscriptionList. This method accepts an iterable of simply xpath strings, 260 | dictionaries with Subscription attributes for more granularity, or already built Subscription 261 | objects and builds the SubscriptionList. Fields not supplied will be defaulted with the default arguments 262 | to the method. 263 | 264 | Generates a single SubscribeRequest. 265 | 266 | Parameters 267 | ---------- 268 | xpath_subscriptions : str or iterable of str, dict, Subscription 269 | An iterable which is parsed to form the Subscriptions in the SubscriptionList to be passed 270 | to SubscriptionRequest. Strings are parsed as XPaths and defaulted with the default arguments, 271 | dictionaries are treated as dicts of args to pass to the Subscribe init, and Subscription is 272 | treated as simply a pre-made Subscription. 273 | request_mode : proto.gnmi_pb2.SubscriptionList.Mode, optional 274 | Indicates whether STREAM to stream from target, 275 | ONCE to stream once (like a get), 276 | POLL to respond to POLL. 277 | [STREAM, ONCE, POLL] 278 | sub_mode : proto.gnmi_pb2.SubscriptionMode, optional 279 | The default SubscriptionMode on a per Subscription basis in the SubscriptionList. 280 | TARGET_DEFINED indicates that the target (like device/destination) should stream 281 | information however it knows best. This instructs the target to decide between ON_CHANGE 282 | or SAMPLE - e.g. the device gNMI server may understand that we only need RIB updates 283 | as an ON_CHANGE basis as opposed to SAMPLE, and we don't have to explicitly state our 284 | desired behavior. 285 | ON_CHANGE only streams updates when changes occur. 286 | SAMPLE will stream the subscription at a regular cadence/interval. 287 | [ON_CHANGE, SAMPLE] 288 | encoding : proto.gnmi_pb2.Encoding, optional 289 | A member of the proto.gnmi_pb2.Encoding enum specifying desired encoding of returned data 290 | [PROTO] 291 | sample_interval : int, optional 292 | Default nanoseconds for sample to occur. 293 | Defaults to 10 seconds. 294 | suppress_redundant : bool, optional 295 | Indicates whether values that have not changed should be sent in a SAMPLE subscription. 296 | heartbeat_interval : int, optional 297 | Specifies the maximum allowable silent period in nanoseconds when 298 | suppress_redundant is in use. The target should send a value at least once 299 | in the period specified. 300 | prefix: proto.Path, optional 301 | Prefix path that can be used as a general path to prepend to all Path elements. (might not be supported on XR) 302 | 303 | Returns 304 | ------- 305 | subscribe() 306 | """ 307 | supported_request_modes = ["STREAM", "ONCE", "POLL"] 308 | request_mode = util.validate_proto_enum( 309 | "mode", 310 | request_mode, 311 | "SubscriptionList.Mode", 312 | proto.gnmi_pb2.SubscriptionList.Mode, 313 | subset=supported_request_modes, 314 | return_name=True, 315 | ) 316 | supported_encodings = ["PROTO"] 317 | encoding = util.validate_proto_enum( 318 | "encoding", 319 | encoding, 320 | "Encoding", 321 | proto.gnmi_pb2.Encoding, 322 | subset=supported_encodings, 323 | return_name=True, 324 | ) 325 | supported_sub_modes = ["ON_CHANGE", "SAMPLE"] 326 | sub_mode = util.validate_proto_enum( 327 | "sub_mode", 328 | sub_mode, 329 | "SubscriptionMode", 330 | proto.gnmi_pb2.SubscriptionMode, 331 | subset=supported_sub_modes, 332 | return_name=True, 333 | ) 334 | return super(XRClient, self).subscribe_xpaths( 335 | xpath_subscriptions, 336 | request_mode, 337 | sub_mode, 338 | encoding, 339 | sample_interval, 340 | suppress_redundant, 341 | heartbeat_interval, 342 | prefix, 343 | ) 344 | 345 | @classmethod 346 | def parse_xpath_to_gnmi_path(cls, xpath, origin=None): 347 | """No origin specified implies openconfig 348 | Otherwise origin is expected to be the module name 349 | """ 350 | if origin is None: 351 | # naive but effective 352 | if xpath.startswith("openconfig") or ":" not in xpath: 353 | # openconfig 354 | origin = None 355 | else: 356 | # module name 357 | origin, xpath = xpath.split(":", 1) 358 | origin = origin.strip("/") 359 | return super(XRClient, cls).parse_xpath_to_gnmi_path(xpath, origin) 360 | 361 | @classmethod 362 | def parse_cli_to_gnmi_path(cls, command): 363 | """Parses a CLI command to proto.gnmi_pb2.Path. 364 | IOS XR appears to be the only OS with this functionality. 365 | 366 | The CLI command becomes a path element. 367 | """ 368 | if not isinstance(command, string_types): 369 | raise Exception("command must be a string!") 370 | path = proto.gnmi_pb2.Path() 371 | curr_elem = proto.gnmi_pb2.PathElem() 372 | curr_elem.name = command 373 | path.elem.extend([curr_elem]) 374 | return path 375 | -------------------------------------------------------------------------------- /src/cisco_gnmi/client.py: -------------------------------------------------------------------------------- 1 | """Copyright 2019 Cisco Systems 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | The contents of this file are licensed under the Apache License, Version 2.0 12 | (the "License"); you may not use this file except in compliance with the 13 | License. You may obtain a copy of the License at 14 | 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations under 21 | the License. 22 | """ 23 | 24 | """Python gNMI wrapper to ease usage of gNMI.""" 25 | 26 | import logging 27 | from xml.etree.ElementPath import xpath_tokenizer_re 28 | from six import string_types 29 | 30 | from . import proto 31 | from . import util 32 | 33 | 34 | LOGGER = logging.getLogger(__name__) 35 | logger = LOGGER 36 | 37 | 38 | class Client(object): 39 | """gNMI gRPC wrapper client to ease usage of gNMI. 40 | 41 | Returns relatively raw response data. Response data may be accessed according 42 | to the gNMI specification. 43 | 44 | Methods 45 | ------- 46 | capabilities() 47 | Retrieve meta information about version, supported models, etc. 48 | get(...) 49 | Get a snapshot of config, state, operational, or all forms of data. 50 | set(...) 51 | Update, replace, or delete configuration. 52 | subscribe(...) 53 | Stream snapshots of data from the device. 54 | 55 | Examples 56 | -------- 57 | >>> import grpc 58 | >>> from cisco_gnmi import Client 59 | >>> from cisco_gnmi.auth import CiscoAuthPlugin 60 | >>> channel = grpc.secure_channel( 61 | ... '127.0.0.1:9339', 62 | ... grpc.composite_channel_credentials( 63 | ... grpc.ssl_channel_credentials(), 64 | ... grpc.metadata_call_credentials( 65 | ... CiscoAuthPlugin( 66 | ... 'admin', 67 | ... 'its_a_secret' 68 | ... ) 69 | ... ) 70 | ... ) 71 | ... ) 72 | >>> client = Client(channel) 73 | >>> capabilities = client.capabilities() 74 | >>> print(capabilities) 75 | """ 76 | 77 | """Defining property due to gRPC timeout being based on a C long type. 78 | Should really define this based on architecture. 79 | 32-bit C long max value. "Infinity". 80 | """ 81 | _C_MAX_LONG = 2147483647 82 | 83 | # gNMI uses nanoseconds, baseline to seconds 84 | _NS_IN_S = int(1e9) 85 | 86 | def __init__(self, grpc_channel, timeout=_C_MAX_LONG, default_call_metadata=None): 87 | """gNMI initialization wrapper which simply wraps some aspects of the gNMI stub. 88 | 89 | Parameters 90 | ---------- 91 | grpc_channel : grpc.Channel 92 | The gRPC channel to initialize the gNMI stub with. 93 | Use ClientBuilder if unfamiliar with gRPC. 94 | timeout : uint 95 | Timeout for gRPC functionality. 96 | default_call_metadata : list 97 | Metadata to be sent with each gRPC call. 98 | """ 99 | self.service = proto.gnmi_pb2_grpc.gNMIStub(grpc_channel) 100 | self.default_call_metadata = default_call_metadata 101 | self._channel = grpc_channel 102 | 103 | def capabilities(self): 104 | """Capabilities allows the client to retrieve the set of capabilities that 105 | is supported by the target. This allows the target to validate the 106 | service version that is implemented and retrieve the set of models that 107 | the target supports. The models can then be specified in subsequent RPCs 108 | to restrict the set of data that is utilized. 109 | Reference: gNMI Specification Section 3.2 110 | 111 | Returns 112 | ------- 113 | proto.gnmi_pb2.CapabilityResponse 114 | """ 115 | message = proto.gnmi_pb2.CapabilityRequest() 116 | LOGGER.debug(str(message)) 117 | response = self.service.Capabilities( 118 | message, metadata=self.default_call_metadata 119 | ) 120 | return response 121 | 122 | def get( 123 | self, 124 | paths, 125 | prefix=None, 126 | data_type="ALL", 127 | encoding="JSON_IETF", 128 | use_models=None, 129 | extension=None, 130 | ): 131 | """A snapshot of the requested data that exists on the target. 132 | 133 | Parameters 134 | ---------- 135 | paths : iterable of proto.gnmi_pb2.Path 136 | An iterable of Paths to request data of. 137 | prefix : proto.gnmi_pb2.Path, optional 138 | A path to prefix all Paths in paths 139 | data_type : proto.gnmi_pb2.GetRequest.DataType, optional 140 | A member of the GetRequest.DataType enum to specify what datastore to target 141 | [ALL, CONFIG, STATE, OPERATIONAL] 142 | encoding : proto.gnmi_pb2.Encoding, optional 143 | A member of the proto.gnmi_pb2.Encoding enum specifying desired encoding of returned data 144 | [JSON, BYTES, PROTO, ASCII, JSON_IETF] 145 | use_models : iterable of proto.gnmi_pb2.ModelData, optional 146 | extension : iterable of proto.gnmi_ext.Extension, optional 147 | 148 | Returns 149 | ------- 150 | proto.gnmi_pb2.GetResponse 151 | """ 152 | data_type = util.validate_proto_enum( 153 | "data_type", 154 | data_type, 155 | "GetRequest.DataType", 156 | proto.gnmi_pb2.GetRequest.DataType, 157 | ) 158 | encoding = util.validate_proto_enum( 159 | "encoding", encoding, "Encoding", proto.gnmi_pb2.Encoding 160 | ) 161 | request = proto.gnmi_pb2.GetRequest() 162 | if not isinstance(paths, (list, set, map)): 163 | raise Exception("paths must be an iterable containing Path(s)!") 164 | request.path.extend(paths) 165 | request.type = data_type 166 | request.encoding = encoding 167 | if prefix: 168 | request.prefix = prefix 169 | if use_models: 170 | request.use_models = use_models 171 | if extension: 172 | request.extension = extension 173 | 174 | LOGGER.debug(str(request)) 175 | 176 | get_response = self.service.Get(request, metadata=self.default_call_metadata) 177 | return get_response 178 | 179 | def set( 180 | self, prefix=None, updates=None, replaces=None, deletes=None, extensions=None 181 | ): 182 | """Modifications to the configuration of the target. 183 | 184 | Parameters 185 | ---------- 186 | prefix : proto.gnmi_pb2.Path, optional 187 | The Path to prefix all other Paths defined within other messages 188 | updates : iterable of iterable of proto.gnmi_pb2.Update, optional 189 | The Updates to update configuration with. 190 | replaces : iterable of proto.gnmi_pb2.Update, optional 191 | The Updates which replaces other configuration. 192 | The main difference between replace and update is replace will remove non-referenced nodes. 193 | deletes : iterable of proto.gnmi_pb2.Path, optional 194 | The Paths which refers to elements for deletion. 195 | extensions : iterable of proto.gnmi_ext.Extension, optional 196 | 197 | Returns 198 | ------- 199 | proto.gnmi_pb2.SetResponse 200 | """ 201 | request = proto.gnmi_pb2.SetRequest() 202 | if prefix: 203 | request.prefix.CopyFrom(prefix) 204 | test_list = [updates, replaces, deletes] 205 | if not any(test_list): 206 | raise Exception("At least update, replace, or delete must be specified!") 207 | for item in test_list: 208 | if not item: 209 | continue 210 | if not isinstance(item, (list, set)): 211 | raise Exception("updates, replaces, and deletes must be iterables!") 212 | if updates: 213 | request.update.extend(updates) 214 | if replaces: 215 | request.replace.extend(replaces) 216 | if deletes: 217 | request.delete.extend(deletes) 218 | if extensions: 219 | request.extension.extend(extensions) 220 | 221 | LOGGER.debug(str(request)) 222 | 223 | response = self.service.Set(request, metadata=self.default_call_metadata) 224 | return response 225 | 226 | def subscribe(self, request_iter, extensions=None): 227 | """Subscribe allows a client to request the target to send it values 228 | of particular paths within the data tree. These values may be streamed 229 | at a particular cadence (STREAM), sent one off on a long-lived channel 230 | (POLL), or sent as a one-off retrieval (ONCE). 231 | Reference: gNMI Specification Section 3.5 232 | 233 | Parameters 234 | ---------- 235 | request_iter : iterable of proto.gnmi_pb2.SubscriptionList or proto.gnmi_pb2.Poll or proto.gnmi_pb2.AliasList 236 | The requests to embed as the SubscribeRequest, oneof the above. 237 | subscribe RPC is a streaming request thus can arbitrarily generate SubscribeRequests into request_iter 238 | to use the same bi-directional streaming connection if already open. 239 | extensions : iterable of proto.gnmi_ext.Extension, optional 240 | 241 | Returns 242 | ------- 243 | generator of SubscriptionResponse 244 | """ 245 | 246 | def validate_request(request): 247 | subscribe_request = proto.gnmi_pb2.SubscribeRequest() 248 | if isinstance(request, proto.gnmi_pb2.SubscriptionList): 249 | subscribe_request.subscribe.CopyFrom(request) 250 | elif isinstance(request, proto.gnmi_pb2.Poll): 251 | subscribe_request.poll.CopyFrom(request) 252 | elif isinstance(request, proto.gnmi_pb2.AliasList): 253 | subscribe_request.aliases.CopyFrom(request) 254 | else: 255 | raise Exception( 256 | "request must be a SubscriptionList, Poll, or AliasList!" 257 | ) 258 | if extensions: 259 | subscribe_request.extensions.extend(extensions) 260 | 261 | LOGGER.debug(str(subscribe_request)) 262 | 263 | return subscribe_request 264 | 265 | response_stream = self.service.Subscribe( 266 | (validate_request(request) for request in request_iter), 267 | metadata=self.default_call_metadata, 268 | ) 269 | return response_stream 270 | 271 | def subscribe_xpaths( 272 | self, 273 | xpath_subscriptions, 274 | request_mode="STREAM", 275 | sub_mode="SAMPLE", 276 | encoding="JSON", 277 | sample_interval=_NS_IN_S * 10, 278 | suppress_redundant=False, 279 | heartbeat_interval=None, 280 | prefix=None, 281 | ): 282 | """A convenience wrapper of subscribe() which aids in building of SubscriptionRequest 283 | with request as subscribe SubscriptionList. This method accepts an iterable of simply xpath strings, 284 | dictionaries with Subscription attributes for more granularity, or already built Subscription 285 | objects and builds the SubscriptionList. Fields not supplied will be defaulted with the default arguments 286 | to the method. 287 | 288 | Generates a single SubscribeRequest. 289 | 290 | Parameters 291 | ---------- 292 | xpath_subscriptions : str or iterable of str, dict, Subscription 293 | An iterable which is parsed to form the Subscriptions in the SubscriptionList to be passed 294 | to SubscriptionRequest. Strings are parsed as XPaths and defaulted with the default arguments, 295 | dictionaries are treated as dicts of args to pass to the Subscribe init, and Subscription is 296 | treated as simply a pre-made Subscription. 297 | request_mode : proto.gnmi_pb2.SubscriptionList.Mode, optional 298 | Indicates whether STREAM to stream from target, 299 | ONCE to stream once (like a get), 300 | POLL to respond to POLL. 301 | [STREAM, ONCE, POLL] 302 | sub_mode : proto.gnmi_pb2.SubscriptionMode, optional 303 | The default SubscriptionMode on a per Subscription basis in the SubscriptionList. 304 | TARGET_DEFINED indicates that the target (like device/destination) should stream 305 | information however it knows best. This instructs the target to decide between ON_CHANGE 306 | or SAMPLE - e.g. the device gNMI server may understand that we only need RIB updates 307 | as an ON_CHANGE basis as opposed to SAMPLE, and we don't have to explicitly state our 308 | desired behavior. 309 | ON_CHANGE only streams updates when changes occur. 310 | SAMPLE will stream the subscription at a regular cadence/interval. 311 | [TARGET_DEFINED, ON_CHANGE, SAMPLE] 312 | encoding : proto.gnmi_pb2.Encoding, optional 313 | A member of the proto.gnmi_pb2.Encoding enum specifying desired encoding of returned data 314 | [JSON, BYTES, PROTO, ASCII, JSON_IETF] 315 | sample_interval : int, optional 316 | Default nanoseconds for SAMPLE to occur. 317 | Defaults to 10 seconds. 318 | suppress_redundant : bool, optional 319 | Indicates whether values that have not changed should be sent in a SAMPLE subscription. 320 | heartbeat_interval : int, optional 321 | Specifies the maximum allowable silent period in nanoseconds when 322 | suppress_redundant is in use. The target should send a value at least once 323 | in the period specified. Also applies in ON_CHANGE. 324 | prefix : proto.gnmi_pb2.Path, optional 325 | A common path prepended to all path elements in the message. This reduces message size by 326 | removing redundent path elements. Smaller message == improved thoughput. 327 | 328 | Returns 329 | ------- 330 | subscribe() 331 | """ 332 | subscription_list = proto.gnmi_pb2.SubscriptionList() 333 | subscription_list.mode = util.validate_proto_enum( 334 | "mode", 335 | request_mode, 336 | "SubscriptionList.Mode", 337 | proto.gnmi_pb2.SubscriptionList.Mode, 338 | ) 339 | subscription_list.encoding = util.validate_proto_enum( 340 | "encoding", encoding, "Encoding", proto.gnmi_pb2.Encoding 341 | ) 342 | if prefix: 343 | subscription_list.prefix.CopyFrom(prefix) 344 | if isinstance( 345 | xpath_subscriptions, (string_types, dict, proto.gnmi_pb2.Subscription) 346 | ): 347 | xpath_subscriptions = [xpath_subscriptions] 348 | subscriptions = [] 349 | for xpath_subscription in xpath_subscriptions: 350 | subscription = None 351 | if isinstance(xpath_subscription, proto.gnmi_pb2.Subscription): 352 | subscription = xpath_subscription 353 | elif isinstance(xpath_subscription, string_types): 354 | subscription = proto.gnmi_pb2.Subscription() 355 | subscription.path.CopyFrom( 356 | self.parse_xpath_to_gnmi_path(xpath_subscription) 357 | ) 358 | subscription.mode = util.validate_proto_enum( 359 | "sub_mode", 360 | sub_mode, 361 | "SubscriptionMode", 362 | proto.gnmi_pb2.SubscriptionMode, 363 | ) 364 | if sub_mode == "SAMPLE": 365 | subscription.sample_interval = sample_interval 366 | elif isinstance(xpath_subscription, dict): 367 | subscription_dict = {} 368 | if "path" not in xpath_subscription.keys(): 369 | raise Exception("path must be specified in dict!") 370 | if isinstance(xpath_subscription["path"], proto.gnmi_pb2.Path): 371 | subscription_dict["path"] = xpath_subscription["path"] 372 | elif isinstance(xpath_subscription["path"], string_types): 373 | subscription_dict["path"] = self.parse_xpath_to_gnmi_path( 374 | xpath_subscription["path"] 375 | ) 376 | else: 377 | raise Exception("path must be string or Path proto!") 378 | sub_mode_name = ( 379 | sub_mode 380 | if "mode" not in xpath_subscription.keys() 381 | else xpath_subscription["mode"] 382 | ) 383 | subscription_dict["mode"] = util.validate_proto_enum( 384 | "sub_mode", 385 | sub_mode, 386 | "SubscriptionMode", 387 | proto.gnmi_pb2.SubscriptionMode, 388 | ) 389 | if sub_mode_name == "SAMPLE": 390 | subscription_dict["sample_interval"] = ( 391 | sample_interval 392 | if "sample_interval" not in xpath_subscription.keys() 393 | else xpath_subscription["sample_interval"] 394 | ) 395 | if "suppress_redundant" in xpath_subscription.keys(): 396 | subscription_dict["suppress_redundant"] = xpath_subscription[ 397 | "suppress_redundant" 398 | ] 399 | if sub_mode_name != "TARGET_DEFINED": 400 | if "heartbeat_interval" in xpath_subscription.keys(): 401 | subscription_dict["heartbeat_interval"] = xpath_subscription[ 402 | "heartbeat_interval" 403 | ] 404 | subscription = proto.gnmi_pb2.Subscription(**subscription_dict) 405 | else: 406 | raise Exception("path must be string, dict, or Subscription proto!") 407 | subscriptions.append(subscription) 408 | subscription_list.subscription.extend(subscriptions) 409 | return self.subscribe([subscription_list]) 410 | 411 | @classmethod 412 | def parse_xpath_to_gnmi_path(cls, xpath, origin=None): 413 | """Parses an XPath to proto.gnmi_pb2.Path. 414 | This function should be overridden by any child classes for origin logic. 415 | 416 | Effectively wraps the std XML XPath tokenizer and traverses 417 | the identified groups. Parsing robustness needs to be validated. 418 | Probably best to formalize as a state machine sometime. 419 | TODO: Formalize tokenizer traversal via state machine. 420 | """ 421 | if not isinstance(xpath, string_types): 422 | raise Exception("xpath must be a string!") 423 | path = proto.gnmi_pb2.Path() 424 | if origin: 425 | if not isinstance(origin, string_types): 426 | raise Exception("origin must be a string!") 427 | path.origin = origin 428 | curr_elem = proto.gnmi_pb2.PathElem() 429 | in_filter = False 430 | just_filtered = False 431 | curr_key = None 432 | # TODO: Lazy 433 | xpath = xpath.strip("/") 434 | xpath_elements = xpath_tokenizer_re.findall(xpath) 435 | path_elems = [] 436 | for index, element in enumerate(xpath_elements): 437 | # stripped initial /, so this indicates a completed element 438 | if element[0] == "/": 439 | if not curr_elem.name: 440 | raise Exception( 441 | "Current PathElem has no name yet is trying to be pushed to path! Invalid XPath?" 442 | ) 443 | path_elems.append(curr_elem) 444 | curr_elem = proto.gnmi_pb2.PathElem() 445 | continue 446 | # We are entering a filter 447 | elif element[0] == "[": 448 | in_filter = True 449 | continue 450 | # We are exiting a filter 451 | elif element[0] == "]": 452 | in_filter = False 453 | continue 454 | # If we're not in a filter then we're a PathElem name 455 | elif not in_filter: 456 | curr_elem.name = element[1] 457 | # Skip blank spaces 458 | elif not any([element[0], element[1]]): 459 | continue 460 | # If we're in the filter and just completed a filter expr, 461 | # "and" as a junction should just be ignored. 462 | elif in_filter and just_filtered and element[1] == "and": 463 | just_filtered = False 464 | continue 465 | # Otherwise we're in a filter and this term is a key name 466 | elif curr_key is None: 467 | curr_key = element[1] 468 | continue 469 | # Otherwise we're an operator or the key value 470 | elif curr_key is not None: 471 | # I think = is the only possible thing to support with PathElem syntax as is 472 | if element[0] in [">", "<"]: 473 | raise Exception("Only = supported as filter operand!") 474 | if element[0] == "=": 475 | continue 476 | else: 477 | # We have a full key here, put it in the map 478 | if curr_key in curr_elem.key.keys(): 479 | raise Exception("Key already in key map!") 480 | curr_elem.key[curr_key] = element[0].strip("'\"") 481 | curr_key = None 482 | just_filtered = True 483 | # Keys/filters in general should be totally cleaned up at this point. 484 | if curr_key: 485 | raise Exception("Hanging key filter! Incomplete XPath?") 486 | # If we have a dangling element that hasn't been completed due to no 487 | # / element then let's just append the final element. 488 | if curr_elem: 489 | path_elems.append(curr_elem) 490 | curr_elem = None 491 | if any([curr_elem, curr_key, in_filter]): 492 | raise Exception("Unfinished elements in XPath parsing!") 493 | path.elem.extend(path_elems) 494 | return path 495 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cisco-gnmi-python 2 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 3 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 4 | 5 | This library wraps gNMI functionality to ease usage with Cisco implementations in Python programs. Derived from [openconfig/gnmi](https://github.com/openconfig/gnmi/tree/master/proto). 6 | 7 | **This is not an officially supported Cisco product.** This library is intended to serve as a gNMI client reference implementation and streamline development with Cisco products. 8 | 9 | - [cisco-gnmi-python](#cisco-gnmi-python) 10 | - [Usage](#usage) 11 | - [cisco-gnmi CLI](#cisco-gnmi-cli) 12 | - [ClientBuilder](#clientbuilder) 13 | - [Initialization Examples](#initialization-examples) 14 | - [Client](#client) 15 | - [NXClient](#nxclient) 16 | - [XEClient](#xeclient) 17 | - [XRClient](#xrclient) 18 | - [gNMI](#gnmi) 19 | - [Development](#development) 20 | - [Get Source](#get-source) 21 | - [Code Hygiene](#code-hygiene) 22 | - [Recompile Protobufs](#recompile-protobufs) 23 | - [CLI Usage](#cli-usage) 24 | - [Capabilities](#capabilities) 25 | - [Usage](#usage-1) 26 | - [Output](#output) 27 | - [Get](#get) 28 | - [Usage](#usage-2) 29 | - [Output](#output-1) 30 | - [Set](#set) 31 | - [Usage](#usage-3) 32 | - [Output](#output-2) 33 | - [Subscribe](#subscribe) 34 | - [Usage](#usage-4) 35 | - [Output](#output-3) 36 | - [Licensing](#licensing) 37 | - [Issues](#issues) 38 | - [Related Projects](#related-projects) 39 | 40 | ## Usage 41 | ```bash 42 | pip install cisco-gnmi 43 | python -c "import cisco_gnmi; print(cisco_gnmi)" 44 | cisco-gnmi --help 45 | ``` 46 | 47 | This library covers the gNMI defined `Capabilities`, `Get`, `Set`, and `Subscribe` RPCs, and helper clients provide OS-specific recommendations. A CLI (`cisco-gnmi`) is also available upon installation. As commonalities and differences are identified between OS functionality this library will be refactored as necessary. 48 | 49 | Several examples of library usage are available in [`examples/`](examples/). The `cisco-gnmi` CLI script found at [`src/cisco_gnmi/cli.py`](src/cisco_gnmi/cli.py) may also be useful. 50 | 51 | It is *highly* recommended that users of the library learn [Google Protocol Buffers](https://developers.google.com/protocol-buffers/) syntax to significantly ease usage. Understanding how to read Protocol Buffers, and reference [`gnmi.proto`](https://github.com/openconfig/gnmi/blob/master/proto/gnmi/gnmi.proto), will be immensely useful for utilizing gNMI and any other gRPC interface. 52 | 53 | ### cisco-gnmi CLI 54 | Since `v1.0.5` a gNMI CLI is available as `cisco-gnmi` when this module is installed. `Capabilities`, `Get`, rudimentary `Set`, and `Subscribe` are supported. The CLI may be useful for simply interacting with a Cisco gNMI service, and also serves as a reference for how to use this `cisco_gnmi` library. CLI usage is documented at the bottom of this README in [CLI Usage](#cli-usage). 55 | 56 | ### ClientBuilder 57 | Since `v1.0.0` a builder pattern is available with `ClientBuilder`. `ClientBuilder` provides several `set_*` methods which define the intended `Client` connectivity and a `construct` method to construct and return the desired `Client`. There are several major methods involved here: 58 | 59 | ``` 60 | set_target(...) 61 | Specifies the network element to build a client for. 62 | set_os(...) 63 | Specifies which OS wrapper to deliver. 64 | set_secure(...) 65 | Specifies that a secure gRPC channel should be used. 66 | set_secure_from_file(...) 67 | Loads certificates from file system for secure gRPC channel. 68 | set_secure_from_target(...) 69 | Attempts to utilize available certificate from target for secure gRPC channel. 70 | set_call_authentication(...) 71 | Specifies username/password to utilize for authentication. 72 | set_ssl_target_override(...) 73 | Sets the gRPC option to override the SSL target name. 74 | set_channel_option(...) 75 | Sets a gRPC channel option. Implies knowledge of channel options. 76 | construct() 77 | Constructs and returns the built Client. 78 | ``` 79 | 80 | #### Initialization Examples 81 | `ClientBuilder` can be chained for initialization or instantiated line-by-line. 82 | 83 | ```python 84 | from cisco_gnmi import ClientBuilder 85 | 86 | builder = ClientBuilder('127.0.0.1:9339') 87 | builder.set_os('IOS XR') 88 | builder.set_secure_from_target() 89 | builder.set_call_authentication('admin', 'its_a_secret') 90 | client = builder.construct() 91 | 92 | # Or... 93 | 94 | client = ClientBuilder('127.0.0.1:9339').set_os('IOS XR').set_secure_from_target().set_call_authentication('admin', 'its_a_secret').construct() 95 | ``` 96 | 97 | Using an encrypted channel automatically getting the certificate from the device, quick for testing: 98 | 99 | ```python 100 | from cisco_gnmi import ClientBuilder 101 | 102 | client = ClientBuilder( 103 | '127.0.0.1:9339' 104 | ).set_os('IOS XR').set_secure_from_target().set_call_authentication( 105 | 'admin', 106 | 'its_a_secret' 107 | ).construct() 108 | ``` 109 | 110 | Using an owned root certificate on the filesystem: 111 | 112 | ```python 113 | from cisco_gnmi import ClientBuilder 114 | 115 | client = ClientBuilder( 116 | '127.0.0.1:9339' 117 | ).set_os('IOS XR').set_secure_from_file( 118 | 'ems.pem' 119 | ).set_call_authentication( 120 | 'admin', 121 | 'its_a_secret' 122 | ).construct() 123 | ``` 124 | 125 | Passing certificate content to method: 126 | 127 | ```python 128 | from cisco_gnmi import ClientBuilder 129 | 130 | # Note reading as bytes 131 | with open('ems.pem', 'rb') as cert_fd: 132 | root_cert = cert_fd.read() 133 | 134 | client = ClientBuilder( 135 | '127.0.0.1:9339' 136 | ).set_os('IOS XR').set_secure( 137 | root_cert 138 | ).set_call_authentication( 139 | 'admin', 140 | 'its_a_secret' 141 | ).construct() 142 | ``` 143 | 144 | Usage with root certificate, private key, and cert chain: 145 | 146 | ```python 147 | from cisco_gnmi import ClientBuilder 148 | 149 | client = ClientBuilder( 150 | '127.0.0.1:9339' 151 | ).set_os('IOS XE').set_secure_from_file( 152 | root_certificates='rootCA.pem', 153 | private_key='client.key', 154 | certificate_chain='client.crt', 155 | ).set_call_authentication( 156 | 'admin', 157 | 'its_a_secret' 158 | ).construct() 159 | ``` 160 | 161 | 162 | ### Client 163 | `Client` is a very barebones class simply implementing `capabilities`, `get`, `set`, and `subscribe` methods. It provides some context around the expectation for what should be supplied to these RPC functions and helpers for validation. 164 | 165 | Methods are documented in [`src/cisco_gnmi/client.py`](src/cisco_gnmi/client.py). 166 | 167 | ### NXClient 168 | `NXClient` inherits from `Client` and provides several wrapper methods which aid with NX-OS gNMI implementation usage. These are `subscribe_xpaths`, and the removal of `get` and `set` as they are not yet supported operations. These methods have some helpers and constraints around what is supported by the implementation. 169 | 170 | Methods and usage examples are documented in [`src/cisco_gnmi/nx.py`](src/cisco_gnmi/nx.py). 171 | 172 | ### XEClient 173 | `XEClient` inherits from `Client` and provides several wrapper methods which aid with IOS XE gNMI implementation usage. These are `delete_xpaths`, `get_xpaths`, `set_json`, and `subscribe_xpaths`. These methods have some helpers and constraints around what is supported by the implementation. 174 | 175 | Methods and usage examples are documented in [`src/cisco_gnmi/xe.py`](src/cisco_gnmi/xe.py). 176 | 177 | ### XRClient 178 | `XRClient` inherits from `Client` and provides several wrapper methods which aid with IOS XR gNMI implementation usage. These are `delete_xpaths`, `get_xpaths`, `set_json`, and `subscribe_xpaths`. These methods have some helpers and constraints around what is supported by the implementation. 179 | 180 | Methods and usage examples are documented in [`src/cisco_gnmi/xr.py`](src/cisco_gnmi/xr.py). 181 | 182 | ## gNMI 183 | gRPC Network Management Interface (gNMI) is a service defining an interface for a network management system (NMS) to interact with a network element. It may be thought of as akin to NETCONF or other control protocols which define operations and behaviors. The scope of gNMI is relatively simple - it seeks to "[[define](https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-specification.md)] a gRPC-based protocol for the modification and retrieval of configuration from a target device, as well as the control and generation of telemetry streams from a target device to a data collection system. The intention is that a single gRPC service definition can cover both configuration and telemetry - allowing a single implementation on the target, as well as a single NMS element to interact with the device via telemetry and configuration RPCs". 184 | 185 | gNMI is a specification developed by [OpenConfig](https://openconfig.net), an operator-driven working-group. It is important to note that gNMI only defines a protocol of behavior - not data models. This is akin to SNMP/MIBs and NETCONF/YANG. SNMP and NETCONF are respectively decoupled from the data itself in MIBs and YANG modules. gNMI is a control protocol, not a standardization of data. OpenConfig does develop standard data models as well, and does have some specialized behavior with OpenConfig originating models, but the data models themselves are out of the scope of gNMI. 186 | 187 | ## Development 188 | Requires Python and utilizes `pipenv` for environment management. Manual usage of `pip`/`virtualenv` is not covered. Uses `black` for code formatting and `pylint` for code linting. `black` is not explicitly installed as it requires Python 3.6+. 189 | 190 | ### Get Source 191 | ```bash 192 | git clone https://github.com/cisco-ie/cisco-gnmi-python.git 193 | cd cisco-gnmi-python 194 | # If pipenv not installed, install! 195 | pip install --user pipenv 196 | # Now use Makefile... 197 | make setup 198 | # Or pipenv manually if make not present 199 | pipenv --three install --dev 200 | # Enter virtual environment 201 | pipenv shell 202 | # Work work 203 | exit 204 | ``` 205 | 206 | ### Code Hygiene 207 | We use [`black`](https://github.com/ambv/black) for code formatting and [`pylint`](https://www.pylint.org/) for code linting. `hygiene.sh` will run `black` against all of the code under `gnmi/` except for `protoc` compiled protobufs, and run `pylint` against Python files directly under `gnmi/`. They don't totally agree, so we're not looking for perfection here. `black` is not automatically installed due to requiring Python 3.6+. `hygiene.sh` will check for regular path availability and via `pipenv`, and otherwise falls directly to `pylint`. If `black` usage is desired, please install it into `pipenv` if using Python 3.6+ or separate methods e.g. `brew install black`. 208 | 209 | ```bash 210 | # If using Python 3.6+ 211 | pipenv install --dev black 212 | # Otherwise... 213 | ./hygiene.sh 214 | ``` 215 | 216 | ### Recompile Protobufs 217 | If a new `gnmi.proto` definition is released, use `update_protos.sh` to recompile. If breaking changes are introduced the wrapper library must be updated. 218 | 219 | ```bash 220 | ./update_protos.sh 221 | ``` 222 | 223 | ## CLI Usage 224 | The below details the current `cisco-gnmi` usage options. Please note that `Set` operations may be destructive to operations and should be tested in lab conditions. 225 | 226 | ``` 227 | cisco-gnmi --help 228 | usage: 229 | cisco-gnmi [] 230 | 231 | Supported RPCs: 232 | capabilities 233 | subscribe 234 | get 235 | set 236 | 237 | cisco-gnmi capabilities 127.0.0.1:57500 238 | cisco-gnmi get 127.0.0.1:57500 239 | cisco-gnmi set 127.0.0.1:57500 -delete_xpath Cisco-IOS-XR-shellutil-cfg:host-names/host-name 240 | cisco-gnmi subscribe 127.0.0.1:57500 -debug -auto_ssl_target_override -dump_file intfcounters.proto.txt 241 | 242 | See --help for RPC options. 243 | 244 | 245 | gNMI CLI demonstrating library usage. 246 | 247 | positional arguments: 248 | rpc gNMI RPC to perform against network element. 249 | 250 | optional arguments: 251 | -h, --help show this help message and exit 252 | ``` 253 | 254 | ### Capabilities 255 | This command will output the `CapabilitiesResponse` to `stdout`. 256 | ``` 257 | cisco-gnmi capabilities 127.0.0.1:57500 -auto_ssl_target_override 258 | ``` 259 | 260 | #### Usage 261 | ``` 262 | cisco-gnmi capabilities --help 263 | usage: cisco-gnmi [-h] [-os {None,IOS XR,NX-OS,IOS XE}] 264 | [-root_certificates ROOT_CERTIFICATES] 265 | [-private_key PRIVATE_KEY] 266 | [-certificate_chain CERTIFICATE_CHAIN] 267 | [-ssl_target_override SSL_TARGET_OVERRIDE] 268 | [-auto_ssl_target_override] [-debug] 269 | netloc 270 | 271 | Performs Capabilities RPC against network element. 272 | 273 | positional arguments: 274 | netloc : 275 | 276 | optional arguments: 277 | -h, --help show this help message and exit 278 | -os {None,IOS XR,NX-OS,IOS XE} 279 | OS wrapper to utilize. Defaults to IOS XR. 280 | -root_certificates ROOT_CERTIFICATES 281 | Root certificates for secure connection. 282 | -private_key PRIVATE_KEY 283 | Private key for secure connection. 284 | -certificate_chain CERTIFICATE_CHAIN 285 | Certificate chain for secure connection. 286 | -ssl_target_override SSL_TARGET_OVERRIDE 287 | gRPC SSL target override option. 288 | -auto_ssl_target_override 289 | Use root_certificates first CN as 290 | grpc.ssl_target_name_override. 291 | -debug Print debug messages. 292 | ``` 293 | 294 | #### Output 295 | ``` 296 | [cisco-gnmi-python] cisco-gnmi capabilities redacted:57500 -auto_ssl_target_override 297 | Username: admin 298 | Password: 299 | WARNING:root:Overriding SSL option from certificate could increase MITM susceptibility! 300 | INFO:root:supported_models { 301 | name: "Cisco-IOS-XR-qos-ma-oper" 302 | organization: "Cisco Systems, Inc." 303 | version: "2019-04-05" 304 | } 305 | ... 306 | ``` 307 | 308 | ### Get 309 | This command will output the `GetResponse` to `stdout`. `-xpath` may be specified multiple times to specify multiple `Path`s for the `GetRequest`. 310 | ``` 311 | cisco-gnmi get 127.0.0.1:57500 -os "IOS XR" -xpath /interfaces/interface/state/counters -auto_ssl_target_override 312 | ``` 313 | 314 | #### Usage 315 | ``` 316 | cisco-gnmi get --help 317 | usage: cisco-gnmi [-h] [-xpath XPATH] 318 | [-encoding {JSON,BYTES,PROTO,ASCII,JSON_IETF}] 319 | [-data_type {ALL,CONFIG,STATE,OPERATIONAL}] [-dump_json] 320 | [-os {None,IOS XR,NX-OS,IOS XE}] 321 | [-root_certificates ROOT_CERTIFICATES] 322 | [-private_key PRIVATE_KEY] 323 | [-certificate_chain CERTIFICATE_CHAIN] 324 | [-ssl_target_override SSL_TARGET_OVERRIDE] 325 | [-auto_ssl_target_override] [-debug] 326 | netloc 327 | 328 | Performs Get RPC against network element. 329 | 330 | positional arguments: 331 | netloc : 332 | 333 | optional arguments: 334 | -h, --help show this help message and exit 335 | -xpath XPATH XPaths to Get. 336 | -encoding {JSON,BYTES,PROTO,ASCII,JSON_IETF} 337 | gNMI Encoding. 338 | -data_type {ALL,CONFIG,STATE,OPERATIONAL} 339 | gNMI GetRequest DataType 340 | -dump_json Dump as JSON instead of textual protos. 341 | -os {None,IOS XR,NX-OS,IOS XE} 342 | OS wrapper to utilize. Defaults to IOS XR. 343 | -root_certificates ROOT_CERTIFICATES 344 | Root certificates for secure connection. 345 | -private_key PRIVATE_KEY 346 | Private key for secure connection. 347 | -certificate_chain CERTIFICATE_CHAIN 348 | Certificate chain for secure connection. 349 | -ssl_target_override SSL_TARGET_OVERRIDE 350 | gRPC SSL target override option. 351 | -auto_ssl_target_override 352 | Use root_certificates first CN as 353 | grpc.ssl_target_name_override. 354 | -debug Print debug messages. 355 | ``` 356 | 357 | #### Output 358 | ``` 359 | [cisco-gnmi-python] cisco-gnmi get redacted:57500 -os "IOS XR" -xpath /interfaces/interface/state/counters -auto_ssl_target_override 360 | Username: admin 361 | Password: 362 | WARNING:root:Overriding SSL option from certificate could increase MITM susceptibility! 363 | INFO:root:notification { 364 | timestamp: 1585607100869287743 365 | update { 366 | path { 367 | elem { 368 | name: "interfaces" 369 | } 370 | elem { 371 | name: "interface" 372 | } 373 | elem { 374 | name: "state" 375 | } 376 | elem { 377 | name: "counters" 378 | } 379 | } 380 | val { 381 | json_ietf_val: "{\"in-unicast-pkts\":\"0\",\"in-octets\":\"0\"... 382 | ``` 383 | 384 | ### Set 385 | Please note that `Set` operations may be destructive to operations and should be tested in lab conditions. Behavior is not fully validated. 386 | 387 | #### Usage 388 | ``` 389 | cisco-gnmi set --help 390 | usage: cisco-gnmi [-h] [-update_json_config UPDATE_JSON_CONFIG] 391 | [-replace_json_config REPLACE_JSON_CONFIG] 392 | [-delete_xpath DELETE_XPATH] [-no_ietf] [-dump_json] 393 | [-os {None,IOS XR,NX-OS,IOS XE}] 394 | [-root_certificates ROOT_CERTIFICATES] 395 | [-private_key PRIVATE_KEY] 396 | [-certificate_chain CERTIFICATE_CHAIN] 397 | [-ssl_target_override SSL_TARGET_OVERRIDE] 398 | [-auto_ssl_target_override] [-debug] 399 | netloc 400 | 401 | Performs Set RPC against network element. 402 | 403 | positional arguments: 404 | netloc : 405 | 406 | optional arguments: 407 | -h, --help show this help message and exit 408 | -update_json_config UPDATE_JSON_CONFIG 409 | JSON-modeled config to apply as an update. 410 | -replace_json_config REPLACE_JSON_CONFIG 411 | JSON-modeled config to apply as a replace. 412 | -delete_xpath DELETE_XPATH 413 | XPaths to delete. 414 | -no_ietf JSON is not IETF conformant. 415 | -dump_json Dump as JSON instead of textual protos. 416 | -os {None,IOS XR,NX-OS,IOS XE} 417 | OS wrapper to utilize. Defaults to IOS XR. 418 | -root_certificates ROOT_CERTIFICATES 419 | Root certificates for secure connection. 420 | -private_key PRIVATE_KEY 421 | Private key for secure connection. 422 | -certificate_chain CERTIFICATE_CHAIN 423 | Certificate chain for secure connection. 424 | -ssl_target_override SSL_TARGET_OVERRIDE 425 | gRPC SSL target override option. 426 | -auto_ssl_target_override 427 | Use root_certificates first CN as 428 | grpc.ssl_target_name_override. 429 | -debug Print debug messages. 430 | ``` 431 | 432 | #### Output 433 | Let's create a harmless loopback interface based from [`openconfig-interfaces.yang`](https://github.com/openconfig/public/blob/master/release/models/interfaces/openconfig-interfaces.yang). 434 | 435 | `config.json` 436 | ```json 437 | { 438 | "openconfig-interfaces:interfaces": { 439 | "interface": [ 440 | { 441 | "name": "Loopback9339" 442 | } 443 | ] 444 | } 445 | } 446 | ``` 447 | 448 | ``` 449 | [cisco-gnmi-python] cisco-gnmi set redacted:57500 -os "IOS XR" -auto_ssl_target_override -update_json_config config.json 450 | Username: admin 451 | Password: 452 | WARNING:root:Overriding SSL option from certificate could increase MITM susceptibility! 453 | INFO:root:response { 454 | path { 455 | origin: "openconfig-interfaces" 456 | elem { 457 | name: "interfaces" 458 | } 459 | } 460 | message { 461 | } 462 | op: UPDATE 463 | } 464 | message { 465 | } 466 | timestamp: 1585715036783451369 467 | ``` 468 | 469 | And on IOS XR...a loopback interface! 470 | ``` 471 | ... 472 | interface Loopback9339 473 | ! 474 | ... 475 | ``` 476 | 477 | ### Subscribe 478 | This command will output the `SubscribeResponse` to `stdout` or `-dump_file`. `-xpath` may be specified multiple times to specify multiple `Path`s for the `GetRequest`. 479 | 480 | ``` 481 | cisco-gnmi subscribe 127.0.0.1:57500 -os "IOS XR" -xpath /interfaces/interface/state/counters -auto_ssl_target_override 482 | ``` 483 | 484 | #### Usage 485 | ``` 486 | cisco-gnmi subscribe --help 487 | usage: cisco-gnmi [-h] [-xpath XPATH] [-interval INTERVAL] 488 | [-mode {TARGET_DEFINED,ON_CHANGE,SAMPLE}] 489 | [-suppress_redundant] 490 | [-heartbeat_interval HEARTBEAT_INTERVAL] 491 | [-dump_file DUMP_FILE] [-dump_json] [-sync_stop] 492 | [-sync_start] [-encoding {JSON,BYTES,PROTO,ASCII,JSON_IETF}] 493 | [-os {None,IOS XR,NX-OS,IOS XE}] 494 | [-root_certificates ROOT_CERTIFICATES] 495 | [-private_key PRIVATE_KEY] 496 | [-certificate_chain CERTIFICATE_CHAIN] 497 | [-ssl_target_override SSL_TARGET_OVERRIDE] 498 | [-auto_ssl_target_override] [-debug] 499 | netloc 500 | 501 | Performs Subscribe RPC against network element. 502 | 503 | positional arguments: 504 | netloc : 505 | 506 | optional arguments: 507 | -h, --help show this help message and exit 508 | -xpath XPATH XPath to subscribe to. 509 | -interval INTERVAL Sample interval in seconds for Subscription. Defaults 510 | to 10. 511 | -mode {TARGET_DEFINED,ON_CHANGE,SAMPLE} 512 | SubscriptionMode for Subscription. Defaults to SAMPLE. 513 | -suppress_redundant Suppress redundant information in Subscription. 514 | -heartbeat_interval HEARTBEAT_INTERVAL 515 | Heartbeat interval in seconds. 516 | -dump_file DUMP_FILE Filename to dump to. Defaults to stdout. 517 | -dump_json Dump as JSON instead of textual protos. 518 | -sync_stop Stop on sync_response. 519 | -sync_start Start processing messages after sync_response. 520 | -encoding {JSON,BYTES,PROTO,ASCII,JSON_IETF} 521 | gNMI Encoding. Defaults to whatever Client wrapper 522 | prefers. 523 | -os {None,IOS XR,NX-OS,IOS XE} 524 | OS wrapper to utilize. Defaults to IOS XR. 525 | -root_certificates ROOT_CERTIFICATES 526 | Root certificates for secure connection. 527 | -private_key PRIVATE_KEY 528 | Private key for secure connection. 529 | -certificate_chain CERTIFICATE_CHAIN 530 | Certificate chain for secure connection. 531 | -ssl_target_override SSL_TARGET_OVERRIDE 532 | gRPC SSL target override option. 533 | -auto_ssl_target_override 534 | Use root_certificates first CN as 535 | grpc.ssl_target_name_override. 536 | -debug Print debug messages. 537 | ``` 538 | 539 | #### Output 540 | ``` 541 | [cisco-gnmi-python] cisco-gnmi subscribe redacted:57500 -os "IOS XR" -xpath /interfaces/interface/state/counters -auto_ssl_target_override 542 | Username: admin 543 | Password: 544 | WARNING:root:Overriding SSL option from certificate could increase MITM susceptibility! 545 | INFO:root:Dumping responses to stdout as textual proto ... 546 | INFO:root:Subscribing to: 547 | /interfaces/interface/state/counters 548 | INFO:root:update { 549 | timestamp: 1585607768601000000 550 | prefix { 551 | origin: "openconfig" 552 | elem { 553 | name: "interfaces" 554 | } 555 | elem { 556 | name: "interface" 557 | key { 558 | key: "name" 559 | value: "Null0" 560 | } 561 | } 562 | elem { 563 | name: "state" 564 | } 565 | elem { 566 | name: "counters" 567 | } 568 | } 569 | update { 570 | path { 571 | elem { 572 | name: "in-octets" 573 | } 574 | } 575 | val { 576 | uint_val: 0 577 | } 578 | } 579 | ... 580 | ``` 581 | 582 | ## Licensing 583 | `cisco-gnmi-python` is licensed as [Apache License, Version 2.0](LICENSE). 584 | 585 | ## Issues 586 | Open an issue :) 587 | 588 | ## Related Projects 589 | 1. [openconfig/gnmi](https://github.com/openconfig/gnmi) 590 | 2. [google/gnxi](https://github.com/google/gnxi) 591 | 3. [Telegraf Cisco gNMI Plugin](https://github.com/influxdata/telegraf/tree/master/plugins/inputs/cisco_telemetry_gnmi) 592 | --------------------------------------------------------------------------------