From a44c707f97645ed63ee3c213300d7af39ceb8b0c Mon Sep 17 00:00:00 2001 From: Michael Elrom Date: Wed, 11 Nov 2026 08:46:00 -0500 Subject: [PATCH] feat(openconfig-grpc): add gNMI/gNOI device driver Add an OpenConfig/gRPC product folder: - device-drivers/grpc: Gateway 5 Python driver with 32 services covering gNMI Capabilities/Get/Set/Subscribe, gNOI RPCs, server reflection and the Config Manager broker actions (is-alive, get-config, set-config, run-command). Connection pooling is off by default. - Large set-config payloads are delivered as a file on Gateway 5.5.3+, so configurations above ~128 KB are supported. - Studio Project "gRPC Driver Test": create inventory, test driver, delete inventory, plus command templates. - Config Manager parser (grpc) and a sample golden configuration. - Root README: add OpenConfig to the vendor list and broaden the device-drivers description. Arista EOS README: point to the gRPC driver. --- Arista/EOS/README.md | 2 +- .../gRPC/Configuration Parsers/grpc.json | 45 + .../gRPC Golden - Baseline.json | 199 ++ OpenConfig/gRPC/README.md | 116 + .../gRPC Driver Test.project.json | 2328 +++++++++++++++++ OpenConfig/gRPC/device-drivers/grpc/README.md | 496 ++++ .../gRPC/device-drivers/grpc/gnoi/__init__.py | 4 + .../device-drivers/grpc/gnoi/bgp/__init__.py | 2 + .../device-drivers/grpc/gnoi/bgp/bgp.proto | 46 + .../device-drivers/grpc/gnoi/bgp/bgp_pb2.py | 33 + .../grpc/gnoi/bgp/bgp_pb2_grpc.py | 73 + .../grpc/gnoi/common/__init__.py | 0 .../grpc/gnoi/common/common.proto | 42 + .../grpc/gnoi/common/common_pb2.py | 28 + .../grpc/gnoi/common/common_pb2_grpc.py | 4 + .../grpc/gnoi/interface/__init__.py | 2 + .../grpc/gnoi/interface/interface.proto | 62 + .../grpc/gnoi/interface/interface_pb2.py | 40 + .../grpc/gnoi/interface/interface_pb2_grpc.py | 157 ++ .../grpc/gnoi/layer2/__init__.py | 2 + .../grpc/gnoi/layer2/layer2.proto | 103 + .../grpc/gnoi/layer2/layer2_pb2.py | 49 + .../grpc/gnoi/layer2/layer2_pb2_grpc.py | 242 ++ .../grpc/gnoi/system/__init__.py | 2 + .../grpc/gnoi/system/system.proto | 311 +++ .../grpc/gnoi/system/system_pb2.py | 22 + .../grpc/gnoi/system/system_pb2_grpc.py | 74 + .../grpc/gnoi/types/__init__.py | 0 .../grpc/gnoi/types/types.proto | 73 + .../grpc/gnoi/types/types_pb2.py | 41 + .../grpc/gnoi/types/types_pb2_grpc.py | 4 + .../grpc/grpc_driver/__init__.py | 3 + .../grpc/grpc_driver/cfgtext.py | 175 ++ .../device-drivers/grpc/grpc_driver/errors.py | 29 + .../grpc/grpc_driver/executor.py | 49 + .../device-drivers/grpc/grpc_driver/gnmi.py | 408 +++ .../grpc/grpc_driver/gnmi100/README.md | 13 + .../grpc/grpc_driver/gnmi100/__init__.py | 0 .../grpc/grpc_driver/gnmi100/gnmi.proto | 463 ++++ .../grpc/grpc_driver/gnmi100/gnmi_ext.proto | 192 ++ .../grpc/grpc_driver/gnmi100/gnmi_ext_pb2.py | 60 + .../grpc/grpc_driver/gnmi100/gnmi_pb2.py | 114 + .../grpc/grpc_driver/ops_cfgtext.py | 56 + .../grpc/grpc_driver/ops_command.py | 69 + .../grpc/grpc_driver/ops_gnmi.py | 213 ++ .../grpc/grpc_driver/ops_gnoi.py | 143 + .../grpc/grpc_driver/ops_reflect.py | 91 + .../grpc/grpc_driver/ops_subscribe.py | 105 + .../device-drivers/grpc/grpc_driver/pool.py | 161 ++ .../device-drivers/grpc/grpc_driver/poold.py | 226 ++ .../grpc/grpc_driver/registry.py | 94 + .../grpc/grpc_driver/session.py | 185 ++ .../gRPC/device-drivers/grpc/import.yaml | 796 ++++++ OpenConfig/gRPC/device-drivers/grpc/main.py | 237 ++ .../grpc/plugins/_example_plugin.py | 20 + .../gRPC/device-drivers/grpc/requirements.txt | 4 + .../grpc/tests/test_service_manifest.py | 65 + README.md | 3 +- 58 files changed, 8574 insertions(+), 2 deletions(-) create mode 100644 OpenConfig/gRPC/Configuration Parsers/grpc.json create mode 100644 OpenConfig/gRPC/Golden Configurations/gRPC Golden - Baseline.json create mode 100644 OpenConfig/gRPC/README.md create mode 100644 OpenConfig/gRPC/Studio Projects/gRPC Driver Test.project.json create mode 100644 OpenConfig/gRPC/device-drivers/grpc/README.md create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/__init__.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/__init__.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp.proto create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp_pb2.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp_pb2_grpc.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/common/__init__.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common.proto create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common_pb2.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common_pb2_grpc.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/__init__.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface.proto create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface_pb2.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface_pb2_grpc.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/__init__.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2.proto create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2_pb2.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2_pb2_grpc.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/system/__init__.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system.proto create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system_pb2.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system_pb2_grpc.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/types/__init__.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types.proto create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types_pb2.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types_pb2_grpc.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/__init__.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/cfgtext.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/errors.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/executor.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/README.md create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/__init__.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi.proto create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_ext.proto create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_ext_pb2.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_pb2.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_cfgtext.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_command.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_gnmi.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_gnoi.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_reflect.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_subscribe.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/pool.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/poold.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/registry.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/grpc_driver/session.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/import.yaml create mode 100644 OpenConfig/gRPC/device-drivers/grpc/main.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/plugins/_example_plugin.py create mode 100644 OpenConfig/gRPC/device-drivers/grpc/requirements.txt create mode 100644 OpenConfig/gRPC/device-drivers/grpc/tests/test_service_manifest.py diff --git a/Arista/EOS/README.md b/Arista/EOS/README.md index 45aaace5..9b6f4020 100644 --- a/Arista/EOS/README.md +++ b/Arista/EOS/README.md @@ -2,7 +2,7 @@ Arista EOS is the network operating system running Arista's switches and routers — a single Linux-based image spanning campus, data center, and cloud networking hardware, with a programmatic API (eAPI) and extensible CLI. -This project provides a Studio Project covering software upgrade, port turn-up, golden configuration compliance, and inventory management for Arista EOS devices — see **Projects** below. +This project provides a Studio Project covering software upgrade, port turn-up, golden configuration compliance, and inventory management for Arista EOS devices — see **Projects** below. EOS also supports gNMI and gNOI; for a vendor-neutral gRPC device driver, see [OpenConfig gRPC](../../OpenConfig/gRPC/). **Requirements:** Itential Platform >= 6.4 · Itential Gateway >= 5.0 diff --git a/OpenConfig/gRPC/Configuration Parsers/grpc.json b/OpenConfig/gRPC/Configuration Parsers/grpc.json new file mode 100644 index 00000000..c6f3c1d2 --- /dev/null +++ b/OpenConfig/gRPC/Configuration Parsers/grpc.json @@ -0,0 +1,45 @@ +{ + "id": "6acbae01daaa2cc9f39d4ec1", + "name": "grpc", + "template": "f5-bigip", + "lexRules": [ + [ + "\\s+", + "whitespace" + ], + [ + "{", + "open_statement_block" + ], + [ + "}", + "close_statement_block" + ], + [ + ";", + "statement_terminator" + ], + [ + "\\/\\*(?:[^*]|\\*[^/])*\\*\\/", + "comment" + ], + [ + "([^\\s\";{}#]|\\/(?!\\*))+", + "word" + ], + [ + "\"([^\\\\\"]|\\\\.)*\"", + "word" + ], + [ + "#[^\\r\\n]*", + "comment" + ] + ], + "updated": "2026-10-11T15:40:49.703Z", + "created": "2026-10-11T15:40:49.671Z", + "gbac": { + "read": [], + "write": [] + } +} \ No newline at end of file diff --git a/OpenConfig/gRPC/Golden Configurations/gRPC Golden - Baseline.json b/OpenConfig/gRPC/Golden Configurations/gRPC Golden - Baseline.json new file mode 100644 index 00000000..a9f161a9 --- /dev/null +++ b/OpenConfig/gRPC/Golden Configurations/gRPC Golden - Baseline.json @@ -0,0 +1,199 @@ +{ + "data": [ + { + "_id": "6acbae08daaa2cc9f39d4ec4", + "name": "gRPC Golden - Baseline", + "description": "Golden config tree for the grpc device type", + "treeId": "6acbae08daaa2cc9f39d4ec3", + "version": "initial", + "deviceType": "grpc", + "root": { + "name": "base", + "attributes": { + "devices": [], + "deviceGroups": [], + "remediationWorkflow": null, + "configId": "6acbae08daaa2cc9f39d4ec2", + "export": { + "_id": "6acbae08daaa2cc9f39d4ec2", + "deviceType": "grpc", + "lines": [ + { + "id": "6acbb74cb9f43d73", + "words": [ + { + "type": "literal", + "value": "system" + } + ], + "lines": [ + { + "id": "6acbb74cc106c3a3", + "words": [ + { + "type": "literal", + "value": "config" + } + ], + "lines": [ + { + "id": "6acbb74cd05e10e9", + "words": [ + { + "type": "literal", + "value": "hostname" + }, + { + "type": "regex", + "value": "grpc-r.*" + } + ], + "lines": [], + "evalMode": "required", + "fixMode": "manual", + "severity": "warning", + "ordering": "none", + "membership": "default" + }, + { + "id": "6acbb74ceb254d90", + "words": [ + { + "type": "literal", + "value": "domain-name" + }, + { + "type": "literal", + "value": "lab.itential.io" + } + ], + "lines": [], + "evalMode": "required", + "fixMode": "manual", + "severity": "warning", + "ordering": "none", + "membership": "default" + } + ], + "evalMode": "required", + "fixMode": "manual", + "severity": "warning", + "ordering": "none", + "membership": "default" + } + ], + "evalMode": "required", + "fixMode": "manual", + "severity": "warning", + "ordering": "none", + "membership": "default" + }, + { + "id": "6acbb74cecdbae9a", + "words": [ + { + "type": "literal", + "value": "interfaces" + } + ], + "lines": [ + { + "id": "6acbb74cce9f079c", + "words": [ + { + "type": "literal", + "value": "interface[name=Ethernet1]" + } + ], + "lines": [ + { + "id": "6acbb74c3285bef4", + "words": [ + { + "type": "literal", + "value": "config" + } + ], + "lines": [ + { + "id": "6acbb74c6f5b07de", + "words": [ + { + "type": "literal", + "value": "mtu" + }, + { + "type": "literal", + "value": "9000" + } + ], + "lines": [], + "evalMode": "required", + "fixMode": "manual", + "severity": "warning", + "ordering": "none", + "membership": "default" + }, + { + "id": "6acbb74cb1a5879f", + "words": [ + { + "type": "literal", + "value": "enabled" + }, + { + "type": "literal", + "value": "true" + } + ], + "lines": [], + "evalMode": "required", + "fixMode": "manual", + "severity": "warning", + "ordering": "none", + "membership": "default" + } + ], + "evalMode": "required", + "fixMode": "manual", + "severity": "warning", + "ordering": "none", + "membership": "default" + } + ], + "evalMode": "required", + "fixMode": "manual", + "severity": "warning", + "ordering": "none", + "membership": "default" + } + ], + "evalMode": "required", + "fixMode": "manual", + "severity": "warning", + "ordering": "none", + "membership": "default" + } + ], + "template": "system {\n config {\n hostname {/grpc-r.*/};\n domain-name lab.itential.io;\n }\n}\ninterfaces {\n interface[name=Ethernet1] {\n config {\n mtu 9000;\n enabled true;\n }\n }\n}\n", + "created": "2026-10-11T15:40:56.189Z", + "createdBy": "6aa81494cced17e2080dcf15", + "lastUpdated": "2026-10-11T16:20:27.734Z", + "lastUpdatedBy": "6aa81494cced17e2080dcf15" + } + }, + "children": [] + }, + "created": "2026-10-11T15:40:56.188Z", + "createdBy": "6aa81494cced17e2080dcf15", + "lastUpdated": "2026-10-11T15:55:10.055Z", + "lastUpdatedBy": "6aa81494cced17e2080dcf15", + "gbac": { + "read": [], + "write": [] + }, + "variables": {}, + "tags": [] + } + ] +} \ No newline at end of file diff --git a/OpenConfig/gRPC/README.md b/OpenConfig/gRPC/README.md new file mode 100644 index 00000000..c4886d22 --- /dev/null +++ b/OpenConfig/gRPC/README.md @@ -0,0 +1,116 @@ +# gRPC + +gRPC is the transport behind gNMI and gNOI, the OpenConfig protocols for reading and writing device configuration and state (gNMI) and running operational commands such as reboot, ping and time (gNOI). Any network operating system that exposes a gNMI or gNOI server can be driven the same way, whichever vendor's YANG models it serves. + +## Table of Contents + +- [gRPC](#grpc) + - [Table of Contents](#table-of-contents) + - [Contents](#contents) + - [Inventory Manager Configuration](#inventory-manager-configuration) + - [Node Attributes](#node-attributes) + - [Device Drivers](#device-drivers) + - [gRPC Driver](#grpc-driver) + - [Projects](#projects) + - [gRPC Driver Test](#grpc-driver-test) + - [Configuration Parsers](#configuration-parsers) + - [gRPC Parser](#grpc-parser) + - [Golden Configurations](#golden-configurations) + - [gRPC Golden - Baseline](#grpc-golden---baseline) + +## Contents + +| Asset | Description | +|---|---| +| [device-drivers/grpc](./device-drivers/grpc/) | IG5 Python gNMI/gNOI driver: is-alive, get-config, set-config, run-command, gNMI Get/Set/Subscribe, gNOI RPCs, reflection | +| [Studio Projects/gRPC Driver Test](./Studio%20Projects/gRPC%20Driver%20Test.project.json) | Studio Project: create an inventory, test the driver, clean up | +| [Configuration Parsers/grpc.json](./Configuration%20Parsers/grpc.json) | Config Manager parser defining the `grpc` device type | +| [Golden Configurations/gRPC Golden - Baseline](./Golden%20Configurations/gRPC%20Golden%20-%20Baseline.json) | Golden config tree for the `grpc` device type | + +--- + +## Inventory Manager Configuration + +Wire the four Config Manager broker actions (`is-alive`, `get-config`, `set-config`, `run-command`) to the `grpc-*` services when you create or update an inventory. The mapping is in the [driver README](./device-drivers/grpc/README.md#installing-on-a-gateway-5-server). + +### Node Attributes + +Set these attributes on each node in Inventory Manager. Replace the host, credentials and `itential_platform` with your device's values: + +```json +{ + "name": "my-gnmi-device", + "attributes": { + "itential_host": "192.0.2.1", + "itential_port": 57400, + "itential_driver": "grpc", + "itential_platform": "cisco_xr", + "itential_user": "username", + "itential_password": "changeme" + } +} +``` + +Driver options such as TLS certificates, timeouts and pooling go under `itential_driver_options.grpc`. The [driver README](./device-drivers/grpc/README.md#options) lists every option and its default. + +If the same node is also managed over SSH, keep `itential_port` at 22 and set `itential_driver_options.grpc.port` instead. See [Ports](./device-drivers/grpc/README.md#ports-itential_port-and-the-grpc-port). + +The gRPC driver doesn't read `itential_platform`. Set it to your device's type, for example `cisco_xr`. Config Manager uses it to match the node to a parser (the `grpc` parser doesn't filter by device type), and netmiko uses it if the node is also managed over SSH. + +--- + +## Device Drivers + +### gRPC Driver + +A Gateway 5 driver with one `python-script` service per operation (32 in total), selected by the `GRPC_OP` environment variable. It covers: + +- **gNMI:** Capabilities, Get, Set and Subscribe. +- **gNOI:** System, Layer2, BGP and Interface RPCs, plus any other gRPC service through server reflection. +- **Config Manager:** the four broker contracts, wired to the services below. + +| Inventory Manager action | Service | +|---|---| +| `is-alive` | `grpc-is-alive` | +| `get-config` | `grpc-gnmi-get-config-text` | +| `set-config` | `grpc-gnmi-set-config-text` | +| `run-command` | `grpc-run-command` | + +Connection pooling is `off` by default. See the [driver README](./device-drivers/grpc/README.md) for the operations, pool modes, inventory attributes and install steps. + +**Large configurations:** use Gateway 5.5.3 or later. From that version, the `config` input of the set-config services is delivered to the driver as a file, so full-device configurations larger than about 128 KB are supported. + +**Dependencies:** `pygnmi`, `grpcio`, `grpcio-reflection`, `protobuf` (see `requirements.txt`) + +--- + +## Projects + +### gRPC Driver Test + +A Studio Project for trying the driver against a gRPC device. + +- **gRPC - Create Inventory** builds an Inventory Manager inventory with the actions mapped to the `grpc-*` services, and adds one placeholder node, `my-gnmi-device`. Before you run it, edit the **populateInventory** task to use a real device (see [Node Attributes](#node-attributes)). The inventory's actions point at the Gateway 5 cluster `cluster-itential`; if your cluster has a different name, change the `cluster_id` values in the **createInventory** task. +- **gRPC - Test Driver** runs the driver against the device selected by tag. It makes real changes: a gNMI Set writes a description and an MTU of 9000 on `Ethernet1`, a Config Manager-style `set-config` writes another description, and a gNOI ping targets `10.0.0.1`. Change those interface and address values in the workflow to suit your device, and use a lab device. +- **gRPC - Delete Inventory** removes the nodes and the inventory. +- Command templates: **gRPC - Device Info** · **gRPC - IOS XR Checks** · **gRPC - Expected Failure**, all run through the `run-command` action. + +**Dependencies:** the `grpc-*` services registered in IG5 (see Device Drivers above) · a gNMI/gNOI-enabled device + +--- + +## Configuration Parsers + +### gRPC Parser + +Defines the `grpc` device type so Config Manager can parse configuration retrieved through the driver's `get-config-text` service. + +--- + +## Golden Configurations + +### gRPC Golden - Baseline + +A golden config tree for the `grpc` device type. It is a starting point built from sample values: the hostname pattern (`grpc-r.*`), the domain name (`lab.itential.io`) and the `Ethernet1` MTU are example data. Edit them to match your devices before running a compliance check. + +**Dependencies:** `grpc` parser · Config Manager enabled · `grpc-gnmi-get-config-text` registered in IG5 diff --git a/OpenConfig/gRPC/Studio Projects/gRPC Driver Test.project.json b/OpenConfig/gRPC/Studio Projects/gRPC Driver Test.project.json new file mode 100644 index 00000000..b19c515e --- /dev/null +++ b/OpenConfig/gRPC/Studio Projects/gRPC Driver Test.project.json @@ -0,0 +1,2328 @@ +{ + "_id": "6acbb5b9297ad269ea17de4b", + "name": "gRPC Driver Test", + "description": "Create inventory, test the gRPC device driver, clean up", + "components": [ + { + "iid": 0, + "type": "workflow", + "reference": "220142b2-b710-4e45-9ee9-ee16e8c43786", + "folder": "/", + "document": { + "name": "gRPC - Create Inventory", + "description": "", + "type": "automation", + "font_size": 12, + "canvasVersion": 3, + "tasks": { + "workflow_start": { + "name": "workflow_start", + "summary": "workflow_start", + "groups": [], + "x": 0, + "y": 0.5, + "nodeLocation": { + "x": 0, + "y": 0 + } + }, + "c001": { + "name": "createInventory", + "canvasName": "createInventory", + "summary": "Create the inventory with gRPC driver actions", + "description": "Create the inventory with gRPC driver actions", + "location": "Application", + "locationType": null, + "app": "InventoryManager", + "type": "automatic", + "displayName": "InventoryManager", + "variables": { + "incoming": { + "name": "$var.job.inventoryName", + "description": "gRPC (gNMI/gNOI) network", + "groups": [ + "admin_group" + ], + "tags": [ + "grpc" + ], + "actions": [ + { + "name": "is-alive", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-is-alive", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "gnmi-capabilities", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-gnmi-capabilities", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "gnmi-get", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-gnmi-get", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "gnmi-get-config", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-gnmi-get-config", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "get-config", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-gnmi-get-config-text", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "gnmi-set", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-gnmi-set", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "run-command", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-run-command", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "set-config", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-gnmi-set-config-text", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "set-config-json", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-gnmi-set-config", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "gnoi-call", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-gnoi-call", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "gnoi-time", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-gnoi-time", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "gnoi-reboot", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-gnoi-reboot", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "reflection-call", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-reflection-call", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "pool-status", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-pool-status", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + }, + { + "name": "pool-stop", + "action_type": "iag5-service", + "action_config": { + "service_name": "grpc-pool-stop", + "cluster_id": "cluster-itential" + }, + "action_parameters": {} + } + ], + "createBrokerActions": false, + "defaultClusterId": "$var.job.clusterId" + }, + "outgoing": { + "response": "$var.job.createdInventory" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 120 + } + }, + "c002": { + "name": "populateInventory", + "canvasName": "populateInventory", + "summary": "Add your device to the inventory", + "description": "Add your device to the inventory", + "location": "Application", + "locationType": null, + "app": "InventoryManager", + "type": "automatic", + "displayName": "InventoryManager", + "variables": { + "incoming": { + "inventory_identifier": "$var.job.inventoryName", + "nodes": [ + { + "name": "my-gnmi-device", + "attributes": { + "itential_host": "192.0.2.1", + "itential_port": 57400, + "itential_user": "username", + "itential_password": "changeme", + "itential_driver": "grpc", + "itential_platform": "cisco_xr", + "itential_driver_options": { + "grpc": { + "insecure": false, + "pool": "off", + "timeout": 10, + "rpc_timeout": 30 + } + } + }, + "tags": [ + "grpc" + ] + } + ] + }, + "outgoing": { + "response": "$var.job.populated" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 240 + } + }, + "workflow_end": { + "name": "workflow_end", + "summary": "workflow_end", + "groups": [], + "x": 1, + "y": 0.5, + "nodeLocation": { + "x": 0, + "y": 360 + } + } + }, + "transitions": { + "workflow_start": { + "c001": { + "state": "success", + "type": "standard" + } + }, + "c001": { + "c002": { + "state": "success", + "type": "standard" + } + }, + "c002": { + "workflow_end": { + "state": "success", + "type": "standard" + } + }, + "workflow_end": {} + }, + "inputSchema": { + "type": "object", + "properties": { + "inventoryName": { + "type": "string", + "minLength": 1, + "description": "The name of the inventory" + }, + "clusterId": { + "type": "string", + "description": "Default cluster ID for built-in broker actions. Required when createBrokerActions is enabled." + } + }, + "required": [ + "inventoryName", + "clusterId" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "inventoryName": { + "type": "string", + "minLength": 1, + "description": "The name of the inventory" + }, + "clusterId": { + "type": "string", + "description": "Default cluster ID for built-in broker actions. Required when createBrokerActions is enabled." + }, + "_id": { + "type": "string", + "pattern": "^[0-9a-f]{24}$" + }, + "initiator": { + "type": "string" + }, + "createdInventory": { + "type": "object", + "properties": { + "status": { + "type": "string", + "examples": [ + "Success" + ] + }, + "result": { + "type": "object", + "description": "The created inventory document", + "allOf": [ + { + "title": "Inventory Database Schema", + "description": "An Inventory document as it is stored in the database", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "The name of the inventory" + }, + "version": { + "type": "string", + "format": "date-time" + }, + "groups": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "action_type": { + "type": "string", + "minLength": 1 + }, + "action_config": { + "type": "object" + }, + "action_parameters": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string" + }, + "updated_by": { + "type": "string" + } + }, + "required": [ + "name", + "action_type" + ] + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag IDs associated with this inventory" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string" + }, + "updated_by": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + { + "type": "object", + "properties": { + "_id": { + "type": "string", + "description": "MongoDB document ID" + } + } + } + ] + }, + "message": { + "type": "string", + "description": "Success message" + } + } + }, + "populated": { + "type": "object", + "properties": { + "status": { + "type": "string", + "examples": [ + "Success" + ] + }, + "result": { + "type": "object", + "properties": { + "inventory": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Inventory ObjectId" + }, + "name": { + "type": "string", + "description": "Inventory name" + } + } + }, + "statistics": { + "type": "object", + "properties": { + "total": { + "type": "number", + "description": "Total nodes processed" + }, + "inserted": { + "type": "number", + "description": "Number of nodes inserted" + }, + "skipped": { + "type": "number", + "description": "Number of nodes skipped" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of error messages for failed operations" + } + } + } + } + }, + "message": { + "type": "string", + "description": "Success message with operation summary" + } + } + } + } + }, + "last_updated": "2026-10-11T16:13:45.694Z", + "last_updated_by": { + "provenance": "LDAP", + "username": "admin@itential", + "firstname": "admin@itential", + "inactive": false + }, + "lastUpdatedVersion": "5.55.5", + "uuid": "e485ea6b-5852-41fa-ba65-89c472deccb7", + "created": "2026-10-11T16:13:45.611Z", + "created_by": { + "provenance": "LDAP", + "username": "admin@itential", + "firstname": "admin@itential", + "inactive": false + }, + "createdVersion": "5.55.5", + "scenarios": [], + "tags": [], + "groups": [], + "migrationVersion": 8 + } + }, + { + "iid": 1, + "type": "workflow", + "reference": "5ddd4834-cacb-443d-87a1-69252c3e7668", + "folder": "/", + "document": { + "name": "gRPC - Test Driver", + "description": "", + "type": "automation", + "font_size": 12, + "canvasVersion": 3, + "tasks": { + "workflow_start": { + "name": "workflow_start", + "summary": "workflow_start", + "groups": [], + "x": 0, + "y": 0.5, + "nodeLocation": { + "x": 0, + "y": 0 + } + }, + "f001": { + "name": "buildInventoryFilter", + "canvasName": "buildInventoryFilter", + "summary": "Select the device by tag", + "description": "Select the device by tag", + "location": "Application", + "locationType": null, + "app": "InventoryManager", + "type": "automatic", + "displayName": "InventoryManager", + "variables": { + "incoming": { + "inventoryIdentifiers": "$var.job.inventoryIdentifiers", + "tagIdentifiers": "$var.job.tagIdentifiers", + "tagMatchType": "any", + "outputFormat": "explicit" + }, + "outgoing": { + "response": "" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 120 + } + }, + "a01": { + "name": "runService", + "canvasName": "runService", + "summary": "is-alive (channel + Capabilities)", + "description": "is-alive (channel + Capabilities)", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-is-alive", + "clusterId": "$var.job.clusterId", + "params": {}, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.isAlive" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 240 + } + }, + "a02": { + "name": "runService", + "canvasName": "runService", + "summary": "gNMI Capabilities", + "description": "gNMI Capabilities", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-gnmi-capabilities", + "clusterId": "$var.job.clusterId", + "params": {}, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.capabilities" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 360 + } + }, + "a03": { + "name": "runService", + "canvasName": "runService", + "summary": "gNMI Get (before)", + "description": "gNMI Get (before)", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-gnmi-get", + "clusterId": "$var.job.clusterId", + "params": { + "path": [ + "/interfaces/interface[name=Ethernet1]/config" + ] + }, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.getBefore" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 480 + } + }, + "a04": { + "name": "runService", + "canvasName": "runService", + "summary": "gNMI Set", + "description": "gNMI Set", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-gnmi-set", + "clusterId": "$var.job.clusterId", + "params": { + "update": [ + { + "path": "/interfaces/interface[name=Ethernet1]/config", + "value": { + "description": "set-by-workflow", + "mtu": 9000 + } + } + ] + }, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.set" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 600 + } + }, + "a05": { + "name": "runService", + "canvasName": "runService", + "summary": "gNMI Get (after - verify Set)", + "description": "gNMI Get (after - verify Set)", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-gnmi-get", + "clusterId": "$var.job.clusterId", + "params": { + "path": [ + "/interfaces/interface[name=Ethernet1]/config" + ] + }, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.getAfter" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 720 + } + }, + "a06": { + "name": "runService", + "canvasName": "runService", + "summary": "Config Manager style set-config", + "description": "Config Manager style set-config", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-gnmi-set-config", + "clusterId": "$var.job.clusterId", + "params": { + "config": "[{\"parents\": [\"interfaces\", \"interface[name=Ethernet1]\", \"config\"], \"old\": \"\", \"new\": \"{\\\"description\\\": \\\"set-config-by-workflow\\\"}\"}]" + }, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.setConfig" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 840 + } + }, + "a07": { + "name": "runService", + "canvasName": "runService", + "summary": "gNMI Get (verify set-config)", + "description": "gNMI Get (verify set-config)", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-gnmi-get", + "clusterId": "$var.job.clusterId", + "params": { + "path": [ + "/interfaces/interface[name=Ethernet1]/config" + ] + }, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.getAfterSetConfig" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 960 + } + }, + "a08": { + "name": "runService", + "canvasName": "runService", + "summary": "gNOI System.Time", + "description": "gNOI System.Time", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-gnoi-time", + "clusterId": "$var.job.clusterId", + "params": {}, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.gnoiTime" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 1080 + } + }, + "a09": { + "name": "runService", + "canvasName": "runService", + "summary": "gNOI System.Ping (server streaming)", + "description": "gNOI System.Ping (server streaming)", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-gnoi-call", + "clusterId": "$var.job.clusterId", + "params": { + "rpc": "system.Ping", + "request": "{\"destination\": \"10.0.0.1\", \"count\": 2}" + }, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.gnoiPing" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 1200 + } + }, + "a10": { + "name": "runService", + "canvasName": "runService", + "summary": "Reflection-based generic call", + "description": "Reflection-based generic call", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-reflection-call", + "clusterId": "$var.job.clusterId", + "params": { + "method": "gnoi.system.System/Time" + }, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.reflectionTime" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 1320 + } + }, + "a12": { + "name": "runService", + "canvasName": "runService", + "summary": "run-command: CLI, gNMI and gNOI commands (last one fails on purpose)", + "description": "run-command: CLI, gNMI and gNOI commands (last one fails on purpose)", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-run-command", + "clusterId": "$var.job.clusterId", + "params": { + "command": "show version\nshow ip interface brief\ngnmi capabilities\ngnoi system.Time\nshow bogus" + }, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.runCommand" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 1440 + } + }, + "a11": { + "name": "runService", + "canvasName": "runService", + "summary": "Connection pool status", + "description": "Connection pool status", + "location": "Application", + "locationType": null, + "app": "GatewayManager", + "type": "automatic", + "displayName": "GatewayManager", + "variables": { + "incoming": { + "serviceName": "grpc-pool-status", + "clusterId": "$var.job.clusterId", + "params": {}, + "inventory": "$var.f001.response" + }, + "outgoing": { + "result": "$var.job.poolStatus" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 1560 + } + }, + "workflow_end": { + "name": "workflow_end", + "summary": "workflow_end", + "groups": [], + "x": 1, + "y": 0.5, + "nodeLocation": { + "x": 0, + "y": 1680 + } + } + }, + "transitions": { + "workflow_start": { + "f001": { + "state": "success", + "type": "standard" + } + }, + "f001": { + "a01": { + "state": "success", + "type": "standard" + } + }, + "a01": { + "a02": { + "state": "success", + "type": "standard" + } + }, + "a02": { + "a03": { + "state": "success", + "type": "standard" + } + }, + "a03": { + "a04": { + "state": "success", + "type": "standard" + } + }, + "a04": { + "a05": { + "state": "success", + "type": "standard" + } + }, + "a05": { + "a06": { + "state": "success", + "type": "standard" + } + }, + "a06": { + "a07": { + "state": "success", + "type": "standard" + } + }, + "a07": { + "a08": { + "state": "success", + "type": "standard" + } + }, + "a08": { + "a09": { + "state": "success", + "type": "standard" + } + }, + "a09": { + "a10": { + "state": "success", + "type": "standard" + } + }, + "a10": { + "a12": { + "state": "success", + "type": "standard" + } + }, + "a12": { + "a11": { + "state": "success", + "type": "standard" + } + }, + "a11": { + "workflow_end": { + "state": "success", + "type": "standard" + } + }, + "workflow_end": {} + }, + "inputSchema": { + "type": "object", + "properties": { + "inventoryIdentifiers": { + "type": "array" + }, + "tagIdentifiers": { + "type": "array" + }, + "clusterId": { + "type": [ + "array", + "boolean", + "null", + "number", + "object", + "string" + ] + } + }, + "required": [ + "inventoryIdentifiers", + "tagIdentifiers", + "clusterId" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "inventoryIdentifiers": { + "type": "array" + }, + "tagIdentifiers": { + "type": "array" + }, + "clusterId": { + "type": [ + "array", + "boolean", + "null", + "number", + "object", + "string" + ] + }, + "_id": { + "type": "string", + "pattern": "^[0-9a-f]{24}$" + }, + "initiator": { + "type": "string" + }, + "isAlive": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "capabilities": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "getBefore": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "set": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "getAfter": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "setConfig": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "getAfterSetConfig": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "gnoiTime": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "gnoiPing": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "reflectionTime": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "runCommand": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + }, + "poolStatus": { + "type": "object", + "title": "result", + "properties": { + "id": { + "type": "string", + "examples": [ + "task-12345" + ] + }, + "jsonrpc": { + "type": "string", + "examples": [ + "2.0" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "error" + ] + }, + "result": { + "type": "object", + "properties": { + "return_code": { + "type": "integer", + "description": "Process exit code (0 = success, >=1 = error)", + "examples": [ + 0 + ] + }, + "stdout": { + "type": "string", + "description": "Raw standard output from the service execution", + "examples": [ + "{\"count\": 3}\n" + ] + }, + "stderr": { + "type": "string", + "description": "Standard error from the service execution", + "examples": [ + "" + ] + }, + "stdout_json": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "Parsed JSON from stdout, present only when stdout is valid JSON" + } + } + } + } + } + } + }, + "last_updated": "2026-10-11T16:13:45.895Z", + "last_updated_by": { + "provenance": "LDAP", + "username": "admin@itential", + "firstname": "admin@itential", + "inactive": false + }, + "lastUpdatedVersion": "5.55.5", + "uuid": "cb317c34-0edc-4581-b429-adf7891628b1", + "created": "2026-10-11T16:13:45.799Z", + "created_by": { + "provenance": "LDAP", + "username": "admin@itential", + "firstname": "admin@itential", + "inactive": false + }, + "createdVersion": "5.55.5", + "scenarios": [], + "tags": [], + "groups": [], + "migrationVersion": 8 + } + }, + { + "iid": 2, + "type": "workflow", + "reference": "22c80444-a849-4af7-a805-fa75614b03b7", + "folder": "/", + "document": { + "name": "gRPC - Delete Inventory", + "description": "", + "type": "automation", + "font_size": 12, + "canvasVersion": 3, + "tasks": { + "workflow_start": { + "name": "workflow_start", + "summary": "workflow_start", + "groups": [], + "x": 0, + "y": 0.5, + "nodeLocation": { + "x": 0, + "y": 0 + } + }, + "d001": { + "name": "deleteInventory", + "canvasName": "deleteInventory", + "summary": "Delete the inventory and its nodes", + "description": "Delete the inventory and its nodes", + "location": "Application", + "locationType": null, + "app": "InventoryManager", + "type": "automatic", + "displayName": "InventoryManager", + "variables": { + "incoming": { + "identifier": "$var.job.inventoryName" + }, + "outgoing": { + "response": "$var.job.deleted" + } + }, + "actor": "Pronghorn", + "groups": [], + "nodeLocation": { + "x": 0, + "y": 120 + } + }, + "workflow_end": { + "name": "workflow_end", + "summary": "workflow_end", + "groups": [], + "x": 1, + "y": 0.5, + "nodeLocation": { + "x": 0, + "y": 240 + } + } + }, + "transitions": { + "workflow_start": { + "d001": { + "state": "success", + "type": "standard" + } + }, + "d001": { + "workflow_end": { + "state": "success", + "type": "standard" + } + }, + "workflow_end": {} + }, + "inputSchema": { + "type": "object", + "properties": { + "inventoryName": { + "title": "inventoryIdentifier", + "description": "Inventory identifier - can be ObjectId or name", + "oneOf": [ + { + "description": "A string which is formatted like an ObjectId", + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" + }, + { + "type": "string", + "minLength": 1, + "description": "The name of the document", + "not": { + "description": "A string which is formatted like an ObjectId", + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" + } + } + ] + } + }, + "required": [ + "inventoryName" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "inventoryName": { + "title": "inventoryIdentifier", + "description": "Inventory identifier - can be ObjectId or name", + "oneOf": [ + { + "description": "A string which is formatted like an ObjectId", + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" + }, + { + "type": "string", + "minLength": 1, + "description": "The name of the document", + "not": { + "description": "A string which is formatted like an ObjectId", + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" + } + } + ] + }, + "_id": { + "type": "string", + "pattern": "^[0-9a-f]{24}$" + }, + "initiator": { + "type": "string" + }, + "deleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "examples": [ + "Success" + ] + }, + "result": { + "type": "object", + "description": "The deleted inventory document", + "allOf": [ + { + "title": "Inventory Database Schema", + "description": "An Inventory document as it is stored in the database", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "The name of the inventory" + }, + "version": { + "type": "string", + "format": "date-time" + }, + "groups": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "action_type": { + "type": "string", + "minLength": 1 + }, + "action_config": { + "type": "object" + }, + "action_parameters": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string" + }, + "updated_by": { + "type": "string" + } + }, + "required": [ + "name", + "action_type" + ] + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag IDs associated with this inventory" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string" + }, + "updated_by": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + { + "type": "object", + "properties": { + "_id": { + "type": "string", + "description": "MongoDB document ID" + } + } + } + ] + }, + "message": { + "type": "string", + "description": "Success message" + } + } + } + } + }, + "last_updated": "2026-10-11T16:13:46.110Z", + "last_updated_by": { + "provenance": "LDAP", + "username": "admin@itential", + "firstname": "admin@itential", + "inactive": false + }, + "lastUpdatedVersion": "5.55.5", + "uuid": "df8ff116-9007-4ab4-8d66-409982643d4f", + "created": "2026-10-11T16:13:46.016Z", + "created_by": { + "provenance": "LDAP", + "username": "admin@itential", + "firstname": "admin@itential", + "inactive": false + }, + "createdVersion": "5.55.5", + "scenarios": [], + "tags": [], + "groups": [], + "migrationVersion": 8 + } + }, + { + "iid": 3, + "type": "mopCommandTemplate", + "reference": "@6acbb5b9297ad269ea17de4b: gRPC - Device Info", + "folder": "/", + "document": { + "tags": [], + "name": "gRPC - Device Info", + "description": "CLI, gNMI and gNOI commands through run-command", + "os": "", + "passRule": true, + "ignoreWarnings": null, + "commands": [ + { + "command": "show version", + "passRule": true, + "rules": [ + { + "rule": "\"success\": true", + "eval": "contains", + "severity": "error" + } + ] + }, + { + "command": "gnmi capabilities", + "passRule": true, + "rules": [ + { + "rule": "gnmi_version", + "eval": "contains", + "severity": "error" + } + ] + }, + { + "command": "gnmi get /system/config", + "passRule": true, + "rules": [ + { + "rule": "", + "eval": "contains", + "severity": "error" + } + ] + }, + { + "command": "gnoi system.Time", + "passRule": true, + "rules": [ + { + "rule": "time", + "eval": "contains", + "severity": "error" + } + ] + } + ], + "created": 1791735226237, + "createdBy": "admin@itential", + "lastUpdated": 1792512599986, + "lastUpdatedBy": "admin@itential" + } + }, + { + "iid": 4, + "type": "mopCommandTemplate", + "reference": "@6acbb5b9297ad269ea17de4b: gRPC - IOS XR Checks", + "folder": "/", + "document": { + "tags": [], + "name": "gRPC - IOS XR Checks", + "description": "CLI-origin commands for Cisco IOS XR-style devices. Variable: interface", + "os": "", + "passRule": true, + "ignoreWarnings": null, + "commands": [ + { + "command": "show version", + "passRule": true, + "rules": [ + { + "rule": "Cisco IOS XR", + "eval": "contains", + "severity": "error" + } + ] + }, + { + "command": "show ip interface brief", + "passRule": true, + "rules": [ + { + "rule": "", + "eval": "contains", + "severity": "error" + } + ] + }, + { + "command": "show interface ", + "passRule": true, + "rules": [ + { + "rule": "is up", + "eval": "contains", + "severity": "error" + } + ] + } + ], + "created": 1791735226462, + "createdBy": "admin@itential", + "lastUpdated": 1791735226548, + "lastUpdatedBy": "admin@itential" + } + }, + { + "iid": 5, + "type": "mopCommandTemplate", + "reference": "@6acbb5b9297ad269ea17de4b: gRPC - Expected Failure", + "folder": "/", + "document": { + "tags": [], + "name": "gRPC - Expected Failure", + "description": "An unknown CLI command: the device error comes back in the template result", + "os": "", + "passRule": true, + "ignoreWarnings": null, + "commands": [ + { + "command": "show bogus", + "passRule": true, + "rules": [ + { + "rule": "unknown command", + "eval": "contains", + "severity": "error" + } + ] + } + ], + "created": 1791735226619, + "createdBy": "admin@itential", + "lastUpdated": 1792512644959, + "lastUpdatedBy": "admin@itential" + } + } + ], + "folders": [ + { + "iid": 0, + "nodeType": "component" + }, + { + "iid": 2, + "nodeType": "component" + }, + { + "iid": 1, + "nodeType": "component" + }, + { + "iid": 3, + "nodeType": "component" + }, + { + "iid": 4, + "nodeType": "component" + }, + { + "iid": 5, + "nodeType": "component" + } + ], + "createdBy": { + "_id": "6aa81494cced17e2080dcf15", + "provenance": "LDAP", + "username": "admin@itential" + }, + "lastUpdatedBy": { + "_id": "6aa81494cced17e2080dcf15", + "provenance": "LDAP", + "username": "admin@itential" + }, + "created": "2026-10-11T16:13:45.372Z", + "lastUpdated": "2026-10-20T16:10:44.967Z", + "iid": 326, + "thumbnail": "data:image/png;base64,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", + "backgroundColor": "#FFFFFF", + "referencedComponentHashes": [] +} \ No newline at end of file diff --git a/OpenConfig/gRPC/device-drivers/grpc/README.md b/OpenConfig/gRPC/device-drivers/grpc/README.md new file mode 100644 index 00000000..017c85fc --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/README.md @@ -0,0 +1,496 @@ +# grpc — Gateway 5 Python script service + +gRPC driver for network devices: **gNMI** (Capabilities, Get, Set), **gNOI** +(System, Layer2, BGP, Interface) and **any other gRPC service** through server +reflection. It is one `main.py` with one service per operation, behavior +selected by an env var (`GRPC_OP`), and connection details taken from the +Inventory Manager record on stdin. + +## Table of Contents + +- [Config Manager services](#config-manager-services) +- [Prerequisites](#prerequisites) +- [Installing on a Gateway 5 server](#installing-on-a-gateway-5-server) +- [Inventory attributes](#inventory-attributes) + - [Options](#options) + - [Ports: `itential_port` and the gRPC port](#ports-itential_port-and-the-grpc-port) +- [Advanced services](#advanced-services) + - [Connection and discovery](#connection-and-discovery) + - [gNMI Get](#gnmi-get) + - [gNMI Set](#gnmi-set) + - [gNMI Subscribe](#gnmi-subscribe) + - [gNOI](#gnoi) +- [Invocation model](#invocation-model) +- [Extending the driver](#extending-the-driver) +- [Connection pooling](#connection-pooling) + - [What the pool guarantees](#what-the-pool-guarantees) + - [Scope: per runner, not cross-server](#scope-per-runner-not-cross-server) + - [Operating the pool](#operating-the-pool) +- [Output and errors](#output-and-errors) +- [Device behaviors handled](#device-behaviors-handled) +- [Local development](#local-development) + +## Config Manager services + +These are the services Inventory Manager and Config Manager need. Inventory +Manager maps its `is-alive`, `get-config`, `set-config` and `run-command` actions +to services by name, so these entry points keep their names, inputs and output +shapes. Do not rename them. Backups and Golden Config use the `-text` pair. The +mapping is in [Installing on a Gateway 5 server](#installing-on-a-gateway-5-server). + +| Service | `GRPC_OP` | Purpose | +|---|---|---| +| `grpc-is-alive` | `is-alive` | Channel + gNMI Capabilities round trip. Prints bare `true` / `false`. | +| `grpc-gnmi-set-config` | `set-config` | Config Manager broker entry point. `config` is `[{parents, old, new}]`: `parents` joins into the path, `new` (JSON) is written, `old` with empty `new` deletes. `mode` = `update` (default), `replace` or `union_replace`; also `origin`, `force`. Returns `[{"result": true, ...}]` per change. | +| `grpc-gnmi-get-config-text` | `get-config-text` | Configuration as brace-style text, for Config Manager backups and Golden Config. Returns bare text. | +| `grpc-gnmi-set-config-text` | `set-config-text` | Config Manager remediation with brace-text changes (`[{parents, old, new}]` with text in `old` / `new`). Same `mode`, `origin`, `force` and result shape as `set-config`. | +| `grpc-run-command` | `run-command` | Config Manager / Command Templates entry point. `command` is newline-separated. Each line is `gnmi capabilities`, `gnmi get `, `gnoi [json]`, or anything else, which is sent as a CLI-origin gNMI Get (e.g. `show version` on IOS XR). Returns `{success, host, results:[{command, output, success}]}`; one failing command does not hide the others. | + +## Prerequisites + +gNMI (and gNOI where you use it) enabled on the device and reachable from +Gateway 5. The driver runs on the host's default `python3`; dependencies install from `requirements.txt` via the +service's `runtime.req-file`. + +**Large configurations:** Gateway 5 passes service inputs to Python scripts as command-line arguments, which Linux limits to about 128 KB each. On Gateway 5.5.3 or later, the `config` input of `grpc-gnmi-set-config` and `grpc-gnmi-set-config-text` is delivered to the driver as a file (path in `GRPC_CONFIG_FILE`), so full-device configurations larger than that limit are supported. This is declared on the `config` property in [`import.yaml`](import.yaml). + +## Installing on a Gateway 5 server + +**1. Create the Gateway 5 services.** Each operation is a +[Python script service](https://docs.itential.com/itential-gateway/5/iagctl/create-service-python-script). +[`import.yaml`](import.yaml) defines the repository, the input decorators and all 32 +services. Copy this driver folder into your own Git repository, update the `repositories` +entry and each service's `working-directory` (`OpenConfig/gRPC/device-drivers/grpc`) to +match, then import it: + +```bash +export GATEWAY_APPLICATION_MODE=client +iagctl db import import.yaml --force +``` + +You can also import the file from +[Gateway Manager](https://docs.itential.com/docs/gateway-manager) in Itential Platform. +The runner needs Python with network access to install `requirements.txt`, and a +route to each device's gNMI port (usually 57400). + +**2. Set up Inventory Manager.** Add the connection attributes to each device +([Inventory attributes](#inventory-attributes)), then map the four actions to the +[Config Manager services](#config-manager-services) +(see [Inventory Manager](https://docs.itential.com/itential-platform/inventory-manager/overview) +and [Configuration Manager](https://docs.itential.com/itential-platform/configuration-manager/overview)): + +| Inventory Manager action | Service | +|---|---| +| `is-alive` | `grpc-is-alive` | +| `get-config` | `grpc-gnmi-get-config-text` | +| `set-config` | `grpc-gnmi-set-config-text` | +| `run-command` | `grpc-run-command` | + +`set-config` maps to the `-text` service because Config Manager sends configuration as +text. Map it to `grpc-gnmi-set-config` instead if your workflows send JSON changes. + +## Inventory attributes + +The minimum a node needs is a host, a port and credentials. This is enough for a +lab device or a first connection: + +```json +{ + "name": "my-gnmi-device", + "attributes": { + "itential_host": "192.0.2.1", + "itential_port": 57400, + "itential_user": "username", + "itential_password": "changeme" + } +} +``` + +`insecure` is `false` by default, so the driver still connects with TLS. With no +certificate options set, it downloads the certificate the device presents when it +connects and trusts that one. The server is not checked against a certificate +authority, so for production set the certificate options. This node uses mutual TLS +with a CA, a client certificate and a client key, all read on the Gateway runner: + +```json +{ + "name": "my-gnmi-device", + "attributes": { + "itential_host": "192.0.2.1", + "itential_port": 57400, + "itential_user": "username", + "itential_password": "changeme", + "itential_driver_options": { + "grpc": { + "path_root": "/etc/ssl/ca.pem", + "path_cert": "/etc/ssl/client.pem", + "path_key": "/etc/ssl/client.key" + } + } + } +} +``` + +If the device certificate's name doesn't match the address you connect to, which is +common with self-signed certificates and IP addresses, set `override` or `skip_verify`. + +Everything else goes under `itential_driver_options.grpc` and is optional. This +example connects to a lab device without TLS and turns on pooling: + +```json +{ + "name": "my-gnmi-device", + "attributes": { + "itential_host": "192.0.2.1", + "itential_port": 57400, + "itential_user": "username", + "itential_password": "changeme", + "itential_driver_options": { + "grpc": { + "insecure": true, + "pool": "auto", + "rpc_timeout": 60 + } + } + } +} +``` + +### Options + +| Attribute | Default | Description | +|---|---|---| +| `itential_host` | required | Device address. | +| `itential_port` | `57400` | Device port. See [Ports](#ports-itential_port-and-the-grpc-port) when the node is shared with another protocol. | +| `itential_user` / `itential_password` | required unless `token` is set | Credentials sent as gNMI metadata. | +| `grpc.port` | `itential_port` | gRPC port for this driver only. Takes precedence over `itential_port`. | +| `grpc.token` | none | Access token, used instead of a user and password. | +| `grpc.insecure` | `false` | Connect without TLS. | +| `grpc.skip_verify` | `false` | Accept a certificate whose name doesn't match the host, by using the certificate's own common name and subject alternative names as the target name. | +| `grpc.path_cert` | none | With no `path_key`, a certificate file to trust in place of the one downloaded from the device. With `path_key` and `path_root` as well, the client certificate for mutual TLS. | +| `grpc.path_key` / `grpc.path_root` | none | Client private key and CA certificate, used together with `path_cert` for mutual TLS. | +| `grpc.override` | none | Server name to use when checking the certificate, if it differs from the host. | +| `grpc.timeout` | `10` | Connect timeout in seconds. | +| `grpc.rpc_timeout` | `30` | Deadline in seconds for each operation. | +| `grpc.grpc_options` | keepalive and 64 MB message limits | Extra gRPC channel options, as an object or list of pairs, e.g. `{"grpc.max_send_message_length": 104857600}`. Values you set replace the defaults. | +| `grpc.pool` | `off` | Connection pooling mode: `off`, `auto` or `required`. See [Connection pooling](#connection-pooling). | +| `grpc.max_concurrent` | `8` | Most concurrent operations against one host, port and user. See [Connection pooling](#connection-pooling). | + +Certificate paths are read on the Gateway runner, not on the Platform server. + +### Ports: `itential_port` and the gRPC port + +One inventory node can be reached over more than one protocol, depending on +which actions it is assigned. For example, Config Manager backups may use +`netmiko` (SSH, port 22) for some actions while `get-config` and `set-config` +use this driver (gNMI, typically 57400). Those protocols listen on different +ports, so a node can carry two: + +- **`itential_port`** is the standard attribute shared by every driver on the + node. Config Manager shows it in the Port field. For a node that is also + managed over SSH it is usually `22`. +- **`itential_driver_options.grpc.port`** is the gRPC port for this driver only. + +The driver picks the first of these that is set: + +1. `--port` (local testing) +2. `itential_driver_options.grpc.port` +3. `itential_port` +4. `57400` + +So set `grpc.port` whenever the node is shared with another protocol. If the +node is used only through this driver, `itential_port` alone is enough and +`grpc.port` can be left out. If `grpc.port` is missing on a shared node, the +driver will use the SSH port from `itential_port` and fail to connect. + + +## Advanced services + +Beyond the Config Manager services, the driver registers one Gateway 5 service per +operation, 32 in total. They are optional and call the device directly from +workflows or `iagctl`, for cases Config Manager doesn't cover: connection checks, +explicit gNMI Get and Set, Subscribe, gNOI RPCs and reflection. +[`import.yaml`](import.yaml) is checked against the Python operation registry by +`tests/test_service_manifest.py`, so a service without an operation, or an +operation without a service, fails the test. + +### Connection and discovery + +| Service | `GRPC_OP` | Purpose | +|---|---|---| +| `grpc-gnmi-capabilities` | `capabilities` | gNMI Capabilities. | +| `grpc-reflection-call` | `call` | Any unary or server-streaming method via reflection: `method=gnoi.system.System/Time payload='{}'`. | +| `grpc-pool-status` / `grpc-pool-stop` | `pool-status` / `pool-stop` | Inspect or stop the pool daemon. | + +### gNMI Get + +Every Get takes `path` (repeatable), `encoding`, `prefix`, `target`, `origin` and `raw`. + +| Service | `GRPC_OP` | Purpose | +|---|---|---| +| `grpc-gnmi-get` | `get` | gNMI Get with an explicit `datatype` (`all`, `config`, `state`, `operational`). | +| `grpc-gnmi-get-config` | `get-config` | Get type `CONFIG`. | +| `grpc-gnmi-get-state` | `get-state` | Get type `STATE`. | +| `grpc-gnmi-get-operational` | `get-operational` | Get type `OPERATIONAL`. | +| `grpc-gnmi-get-all` | `get-all` | Get type `ALL`. | + +### gNMI Set + +| Service | `GRPC_OP` | Purpose | +|---|---|---| +| `grpc-gnmi-set` | `set` | Generic Set: `update` / `replace` / `union_replace` as `[{path, value}]`, `delete` as `[path]`, plus `origin`, `raw`, `force`. Applied atomically by the device. `union_replace` (gNMI 0.10.0) is refused unless the device advertises 0.10.0+ (older targets silently ignore the field); `force=true` sends it anyway. | +| `grpc-gnmi-update-config` | `update-config` | Set with only `update[]` (merge). | +| `grpc-gnmi-replace-config` | `replace-config` | Set with only `replace[]`. | +| `grpc-gnmi-union-replace-config` | `union-replace-config` | Set with only `union_replace[]`. | +| `grpc-gnmi-delete-config` | `delete-config` | Set with only `delete[]`. | +| `grpc-gnmi-update-all` / `-replace-all` / `-union-replace-all` | `update-all` / `replace-all` / `union-replace-all` | The same verbs at the root path `/` with one `value`. **Require `confirm=true`**: a root replace rewrites the whole configuration, so the service refuses without it. | + +The dedicated services accept only their own verb (plus `encoding`, `prefix`, +`target`, `origin`, `raw`, `force`), so a service named `update-config` can never +send a `replace`. None of the Set services are retried. + +### gNMI Subscribe + +Bounded by design: a `python-script` service has to finish, so nothing here +runs forever. Every Subscribe takes `path` (repeatable), `encoding`, `prefix`, +`target`, `origin`, `raw` and `updates_only`, and returns +`{"notification": [...], "sync_responses": n, "stop_reason": "..."}`. + +| Service | `GRPC_OP` | Behavior | +|---|---|---| +| `grpc-gnmi-subscribe-once` | `subscribe-once` | Mode `ONCE`: one snapshot, returned when the device sends `sync_response`. | +| `grpc-gnmi-subscribe-poll` | `subscribe-poll` | Mode `POLL`: sends `count` Poll messages `interval` seconds apart and returns after `count` `sync_response`s. | +| `grpc-gnmi-subscribe-stream` | `subscribe-stream` | Mode `STREAM`: collects for `duration` seconds (default 10) or `max_updates` notifications (default 1000), then cancels. `stream_mode` is `sample` (default), `on_change` or `target_defined`; also `sample_interval`, `heartbeat_interval`, `suppress_redundant`. | + +`stop_reason` is `complete` (ONCE and POLL finished), `duration`, `max_updates` +or `device_closed`. `duration` is capped by `GRPC_SUBSCRIBE_MAX_DURATION` +(default 300 s) and `max_updates` at 100000. These operations legitimately +outlive `rpc_timeout`, so each declares its own deadline (`duration` + +`rpc_timeout` for a stream) instead of being cut off at 30 s; the pool daemon +waits for it too. A Subscribe holds one `max_concurrent` slot for its whole +run, so keep streams short on devices with a tight session limit. They are +marked idempotent: a dead channel is retried once. + +For POLL, the driver expects one `sync_response` for each Poll message it sends. + +### gNOI + +| Service | `GRPC_OP` | Purpose | +|---|---|---| +| `grpc-gnoi-call` | `gnoi` | Any gNOI RPC by name: `rpc=system.Time`, `rpc=layer2.ClearSpanningTree request='{...}'`. Streaming RPCs return a list (capped at 1000 messages). | +| `grpc-gnoi-time` | `gnoi-time` | `system.Time`. | +| `grpc-gnoi-ping` | `gnoi-ping` | `system.Ping`: `destination` (required), `source`, `count`, `interval`, `wait`, `size`, `do_not_fragment`, `do_not_resolve`, `l3protocol`. Replies come back as a list. | +| `grpc-gnoi-traceroute` | `gnoi-traceroute` | `system.Traceroute`: `destination` (required), `source`, `initial_ttl`, `max_ttl`, `wait`, `do_not_fragment`, `do_not_resolve`, `l3protocol`, `l4protocol`. | +| `grpc-gnoi-reboot` | `gnoi-reboot` | `system.Reboot` (`method`, `delay` ns, `message`). Never retried. | +| `grpc-gnoi-reboot-status` | `gnoi-reboot-status` | `system.RebootStatus`. | +| `grpc-gnoi-cancel-reboot` | `gnoi-cancel-reboot` | `system.CancelReboot` (`message`). Not retried. | + +The ping, traceroute and reboot services build the request from the protobuf +descriptor, so each field above is the proto field name. Anything nested goes in +`request` as JSON; explicit inputs win over `request`. + +These gNOI RPCs (ping, traceroute, reboot, +cancel_reboot, reboot_status, time, clear_lldp_interface, clear_bgp_neighbor, +clear_interface_counters, clear_neighbor_discovery, clear_spanning_tree, +wake_on_lan, switch_control_processor) are all reachable through `gnoi`, which +reads the protobuf service descriptors instead of needing a wrapper per RPC. +`grpc-gnoi-call` with a bad `rpc` lists every valid name. + +**Software upload (`system.SetPackage`)** is a client-streaming RPC (package +metadata, then a stream of chunks, then a hash). The driver sends client-streaming +RPCs as a single message, so use the device's own tooling for image transfer. + +## Invocation model + +**One service per operation**, all pointing at `main.py` with a different +`GRPC_OP`. `import.yaml` registers them and their input schemas (booleans are +`"true"`/`"false"` strings; unset optional fields arrive as empty strings and +are ignored). + +```bash +iagctl run service python-script grpc-is-alive +iagctl run service python-script grpc-gnmi-get --set path='["/interfaces/interface[name=eth0]/state"]' --set datatype=state +iagctl run service python-script grpc-gnoi-call --set rpc=system.Time +iagctl run service python-script grpc-reflection-call --set method=gnoi.system.System/Time +``` + +Inventory Manager action mapping: + +```json +{ + "name": "get-config", + "action_type": "iag5-service", + "action_config": { "service_name": "grpc-gnmi-get-config", "cluster_id": "cluster-itential" }, + "action_parameters": {} +} +``` + +Connection flags (`--host --port --user --password --insecure --skip-verify +--path-cert --path-key --path-root --override --token --timeout --rpc-timeout +--pool`) override the inventory, for local testing: + +```bash +python main.py --op get --host 192.0.2.1 --port 57400 --user username --password "$PW" --insecure true --path / +``` + +## Extending the driver + +Everything is registered in `grpc_driver/registry.py`. Three extension points, +none of which touch the core: + +**1. New operation.** Drop `plugins/.py` (see `plugins/_example_plugin.py`): + +```python +from grpc_driver.registry import operation + +@operation("interface-counters", idempotent=True) +def interface_counters(session, params): + return session.gnmi.get(path=[f"/interfaces/interface[name={params['name']}]/state/counters"], datatype="state") +``` + +`session` is a connected channel (`session.gnmi`, `session.channel`, +`session.metadata`, `session.stub(name)`); `params` are the `--key value` flags. +Mark `idempotent=True` only if replaying is safe. Add a service to `import.yaml` +with `GRPC_OP: interface-counters` and a decorator for its inputs. Pooling +works for plugin operations automatically. + +**2. New gRPC service with compiled stubs.** +`register_service("healthz", healthz_pb2, healthz_pb2_grpc.HealthzStub, "Healthz")` +makes every RPC callable as `grpc-gnoi-call rpc=healthz.`. + +**3. No stubs at all.** `grpc-reflection-call` works against any server with +reflection enabled. Descriptors are cached on the pooled channel, so repeat +calls skip the reflection round trips. + +Plugin search path: `plugins/`, each directory in `GRPC_PLUGIN_DIR` +(`os.pathsep`-separated), and each module or `.py` file in `GRPC_PLUGINS` +(comma-separated). + +The vendored `gnoi/` package holds the gNOI protobuf stubs. + +## Connection pooling + +Opening a channel costs a TCP connection, a TLS handshake and a Capabilities +round trip, and some devices cap concurrent sessions per user. + +A `python-script` service is a fresh process per call, so a pool held inside +`main.py` is empty every time. Real pooling needs something that outlives the +call, so this driver ships one: + +``` + Gateway 5 call 1 ─ python main.py ─┐ + Gateway 5 call 2 ─ python main.py ─┼─ unix socket ─▶ pool daemon ══ 1 warm HTTP/2 channel ══▶ device + Gateway 5 call 3 ─ python main.py ─┘ (JSON lines) (background) per device identity +``` + +- **`off` (default)** — always a private channel, one connection per call. + Pooling is opt-in because it needs a background daemon that outlives each + script call, which not every Gateway 5 runtime allows. +- **`auto`** — use the daemon, starting it on first use. If it cannot be + reached or started, fall back to a private channel for that one call. The + result reports which happened (`--envelope true` → `pool.mode` = `pooled` | + `fallback`, `pool.reused`). +- **`required`** — fail rather than fall back; use it when you want a broken + pool to be loud. + +Set it per device with `itential_driver_options.grpc.pool`, or globally with +the `GRPC_POOL` env var on the service. A `--pool` flag overrides both. + +### What the pool guarantees + +| Concern | Behavior | +|---|---| +| Pool key | `sha256` of host, port, credentials, TLS and gRPC options. A rotated password or changed option opens a new channel; it can never reuse a stale one. | +| Concurrency | One channel per key, shared. gRPC multiplexes concurrent calls over a single HTTP/2 connection, so a size-N pool per device would add nothing. | +| Liveness | Channel state is tracked; unhealthy channels are replaced. Keepalive pings (60 s) are on by default. | +| Dead channel | A call on a *reused* channel that fails with `UNAVAILABLE` / `UNAUTHENTICATED` evicts it and is retried once on a fresh one — **only for operations marked idempotent** (get, capabilities, is-alive, time, ...). `set`, `set-config`, the dedicated Set services, `gnoi-reboot` and anything else are never replayed. | +| Hung device | pygnmi sets no per-RPC deadline, so every operation runs under `rpc_timeout` (default 30 s); Subscribe operations declare a longer deadline of their own (see [gNMI Subscribe](#gnmi-subscribe)). On expiry the channel is closed, which cancels the in-flight RPC, and the channel is evicted. | +| Lifecycle | Idle channels close after `GRPC_POOL_IDLE_TTL` (300 s); channels are recycled after `GRPC_POOL_MAX_AGE` (3600 s); the daemon exits after `GRPC_POOL_DAEMON_IDLE` (600 s) with no channels; `GRPC_POOL_MAX_SIZE` (32) evicts least-recently-used idle channels. | +| Ambiguous failure | If the daemon dies *after* receiving a request, the call fails instead of falling back — a config push is never run twice. Fallback only happens when nothing was sent. | +| Code updates | The socket name includes a hash of the driver code, so a re-pulled repo gets a fresh daemon. Old ones idle out. | +| Security | Socket directory is `0700` and the socket `0600` (same uid only). Credentials sit in daemon memory for the life of a channel and are never written to disk. Logs go to `.log`. | + +### Scope: per runner, not cross-server + +**This is per-runner pooling, not a cross-server or cross-runner solution.** +The daemon is local to the host or container where the script executes, and +it is reached over a unix socket, so two runners can never share a channel. + +| Gateway 5 topology | Pool | What a device sees | +|---|---|---| +| Single gateway, embedded runner | One daemon | 1 channel, up to `max_concurrent` operations in flight | +| Server plus separate runners (shared or physical hosts) | One daemon per runner host. The server executes nothing, so it holds no pool. | One channel per runner that touches the device | +| Virtual cluster, containerized runners | One daemon per runner container, lost when the container restarts or scales in (a cold reconnect, harmless) | One channel per runner replica | + +Consequences: + +- **Pooling still pays off.** Within one runner, repeat calls skip the TCP, + TLS and Capabilities round trip, and a burst of calls shares one channel + instead of opening one per call. + The saving is smaller when devices are spread thinly across many runners, + because each runner warms its own channel. +- **`max_concurrent` is per daemon, not global.** Devices cap sessions per + user across all runners. With N runners that can reach a device, it can see + up to N × `max_concurrent` operations in flight. Set `max_concurrent` to the + device's limit divided by N. +- **Runner placement decides the hit rate.** If Gateway 5 spreads one device's + jobs across runners, each runner opens its own channel and the device sees + one source per runner. Pin devices to runners where you can. +- **No coordination between runners.** There is no shared state, lock or + global session budget. If you need one, put a shared proxy in front of the + devices instead of extending the pool. +- Tune `GRPC_POOL_*` on each runner. Keep `GRPC_POOL_IDLE_TTL` below any idle + timeout on the device or a firewall. + +### Operating the pool + +- **Confirm the daemon persists between calls.** It is started + detached (`start_new_session`). If your Gateway 5 runtime ends child + processes when a script exits, `auto` falls back to one channel per call. + To check on your deployment: + + ```bash + iagctl run service python-script grpc-gnmi-get --set path='["/"]' # twice, against a device + iagctl run service python-script grpc-pool-status # hits should be 1, uses 2 + ``` + + `running: false` after a call means the runtime killed it; set `GRPC_POOL=off` + (the default) to stop paying for the spawn attempt, or run the daemon as a real service + (`python -m grpc_driver.poold` under systemd or as a sidecar container, with + `GRPC_POOL_SOCKET` pointing at a shared volume). +- Credentials are passed to the daemon per request, so the daemon holds a + password for as long as the channel is alive. +- Unix sockets are capped at ~104 bytes; the default path is under `/tmp` for + that reason. + +## Output and errors + +- Success: the operation's result as JSON on stdout, exit `0`. + `is-alive` prints bare `true`/`false` (no newline) for gw-manager. +- Failure: exit `1`, the envelope on stderr + (`success`, `op`, `host`, `device_name`, `error`, `error_type`, `pool`). + `error_type` is the lower-cased gRPC status (`unavailable`, `unauthenticated`, + `unimplemented`, ...) or `timeout` / `invalid_input` / `unknown_rpc` — the + same distinctions an HTTP client would see as 408, 501 or security errors. +- `--envelope true` prints the full envelope on success too. + +## Device behaviors handled + +- **`union_replace`**: replaces the whole config with the union of its updates; the driver refuses it against gNMI < 0.10.0 (unless `force`). +- **Native / multi-origin paths** (`origin:/path`, `cli:` text, prefix/path origin mismatch). +- **CLI paths** with `/` in interface names and mixed case: never split or case-folded. +- **Boolean and numeric Set values**. +- **Large payloads**: 64 MB send/receive message limits by default (stock pygnmi fails at 4 MB). +- **Session limits**: pooled mode keeps one peer; `max_concurrent` (default 8, per host:port:user) rejects with `busy` rather than exhausting the device. +- **Reboot subcomponents**. + +## Local development + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt pytest pyyaml +python -m pytest tests -q +``` + +`tests/test_service_manifest.py` checks that every service in `import.yaml` names a +registered operation, and the reverse. The driver itself does not import `pyyaml`. diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/__init__.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/__init__.py new file mode 100644 index 00000000..a315b3e9 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/__init__.py @@ -0,0 +1,4 @@ +from .system import * # noqa +from .interface import * # noqa +from .bgp import * # noqa +from .layer2 import * # noqa \ No newline at end of file diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/__init__.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/__init__.py new file mode 100644 index 00000000..1d01b100 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/__init__.py @@ -0,0 +1,2 @@ +from .bgp_pb2_grpc import BGPStub # noqa +from .bgp_pb2 import * # noqa \ No newline at end of file diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp.proto b/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp.proto new file mode 100644 index 00000000..4415df04 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp.proto @@ -0,0 +1,46 @@ +// +// Copyright 2017 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +import "types/types.proto"; + +package gnoi.bgp; + +// The current semantic version of the gNOI BGP service. +option (types.gnoi_version) = "0.1.0"; + +service BGP { + // ClearBGPNeighbor clears a BGP session. + rpc ClearBGPNeighbor(ClearBGPNeighborRequest) returns (ClearBGPNeighborResponse) {} + +} + +message ClearBGPNeighborRequest { + string address = 1; // IPv4 or IPv6 BGP neighbor address to clear. + // Routing instance containing the neighbor. Defaults to the global routing + // table. + string routing_instance = 2; + enum Mode { + SOFT = 0; // Send route-refresh and reapply policy. + SOFTIN = 1; // Re-apply inbound policy on stored Adj-RIB-In. + HARD = 2; // Teardown and restart TCP connection. + } + Mode mode = 3; // Which mode to use for the clear operation. +} + +message ClearBGPNeighborResponse { +} diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp_pb2.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp_pb2.py new file mode 100644 index 00000000..e4cb52bb --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp_pb2.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: bgp/bgp.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + +from gnoi.types import types_pb2 as types_dot_types__pb2 + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n\rbgp/bgp.proto\x12\x08gnoi.bgp\x1a\x11types/types.proto\"\xa2\x01\n\x17\x43learBGPNeighborRequest\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x18\n\x10routing_instance\x18\x02 \x01(\t\x12\x34\n\x04mode\x18\x03 \x01(\x0e\x32&.gnoi.bgp.ClearBGPNeighborRequest.Mode\"&\n\x04Mode\x12\x08\n\x04SOFT\x10\x00\x12\n\n\x06SOFTIN\x10\x01\x12\x08\n\x04HARD\x10\x02\"\x1a\n\x18\x43learBGPNeighborResponse2b\n\x03\x42GP\x12[\n\x10\x43learBGPNeighbor\x12!.gnoi.bgp.ClearBGPNeighborRequest\x1a\".gnoi.bgp.ClearBGPNeighborResponse\"\x00\x42\x08\xd2>\x05\x30.1.0b\x06proto3' +) + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'bgp.bgp_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\322>\0050.1.0' + _CLEARBGPNEIGHBORREQUEST._serialized_start = 47 + _CLEARBGPNEIGHBORREQUEST._serialized_end = 209 + _CLEARBGPNEIGHBORREQUEST_MODE._serialized_start = 171 + _CLEARBGPNEIGHBORREQUEST_MODE._serialized_end = 209 + _CLEARBGPNEIGHBORRESPONSE._serialized_start = 211 + _CLEARBGPNEIGHBORRESPONSE._serialized_end = 237 + _BGP._serialized_start = 239 + _BGP._serialized_end = 337 +# @@protoc_insertion_point(module_scope) diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp_pb2_grpc.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp_pb2_grpc.py new file mode 100644 index 00000000..4fa36893 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/bgp/bgp_pb2_grpc.py @@ -0,0 +1,73 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from gnoi.bgp import bgp_pb2 as bgp_dot_bgp__pb2 + + +class BGPStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.ClearBGPNeighbor = channel.unary_unary( + '/gnoi.bgp.BGP/ClearBGPNeighbor', + request_serializer=bgp_dot_bgp__pb2.ClearBGPNeighborRequest. + SerializeToString, + response_deserializer=bgp_dot_bgp__pb2.ClearBGPNeighborResponse. + FromString, + ) + + +class BGPServicer(object): + """Missing associated documentation comment in .proto file.""" + + def ClearBGPNeighbor(self, request, context): + """ClearBGPNeighbor clears a BGP session. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_BGPServicer_to_server(servicer, server): + rpc_method_handlers = { + 'ClearBGPNeighbor': + grpc.unary_unary_rpc_method_handler( + servicer.ClearBGPNeighbor, + request_deserializer=bgp_dot_bgp__pb2.ClearBGPNeighborRequest. + FromString, + response_serializer=bgp_dot_bgp__pb2.ClearBGPNeighborResponse. + SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'gnoi.bgp.BGP', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler, )) + + +# This class is part of an EXPERIMENTAL API. +class BGP(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def ClearBGPNeighbor(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, target, '/gnoi.bgp.BGP/ClearBGPNeighbor', + bgp_dot_bgp__pb2.ClearBGPNeighborRequest.SerializeToString, + bgp_dot_bgp__pb2.ClearBGPNeighborResponse.FromString, options, + channel_credentials, insecure, call_credentials, compression, + wait_for_ready, timeout, metadata) diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/__init__.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common.proto b/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common.proto new file mode 100644 index 00000000..9547a60a --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common.proto @@ -0,0 +1,42 @@ +// +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +package gnoi.common; + +import "types/types.proto"; + +// RemoteDownload defines the details for a device to initiate a file transfer +// from or to a remote location. +message RemoteDownload { + // The path information containing where to download the data from or to. + // For HTTP(S), this will be the URL (i.e. foo.com/file.tbz2). + // For SFTP and SCP, this will be the address:/path/to/file + // (i.e. host.foo.com:/bar/baz). + string path = 1; + + enum Protocol { + UNKNOWN = 0; + SFTP = 1; + HTTP = 2; + HTTPS = 3; + SCP = 4; + } + Protocol protocol = 2; + + types.Credentials credentials = 3; +} diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common_pb2.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common_pb2.py new file mode 100644 index 00000000..b75d3da3 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common_pb2.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: common/common.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from gnoi.types import types_pb2 as types_dot_types__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13\x63ommon/common.proto\x12\x0bgnoi.common\x1a\x11types/types.proto\"\xc5\x01\n\x0eRemoteDownload\x12\x0c\n\x04path\x18\x01 \x01(\t\x12\x36\n\x08protocol\x18\x02 \x01(\x0e\x32$.gnoi.common.RemoteDownload.Protocol\x12,\n\x0b\x63redentials\x18\x03 \x01(\x0b\x32\x17.gnoi.types.Credentials\"?\n\x08Protocol\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x08\n\x04SFTP\x10\x01\x12\x08\n\x04HTTP\x10\x02\x12\t\n\x05HTTPS\x10\x03\x12\x07\n\x03SCP\x10\x04\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'common.common_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _REMOTEDOWNLOAD._serialized_start=56 + _REMOTEDOWNLOAD._serialized_end=253 + _REMOTEDOWNLOAD_PROTOCOL._serialized_start=190 + _REMOTEDOWNLOAD_PROTOCOL._serialized_end=253 +# @@protoc_insertion_point(module_scope) diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common_pb2_grpc.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common_pb2_grpc.py new file mode 100644 index 00000000..2daafffe --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/common/common_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/__init__.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/__init__.py new file mode 100644 index 00000000..96ff6e33 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/__init__.py @@ -0,0 +1,2 @@ +from .interface_pb2_grpc import InterfaceStub # noqa +from .interface_pb2 import * # noqa \ No newline at end of file diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface.proto b/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface.proto new file mode 100644 index 00000000..6ca27ba3 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface.proto @@ -0,0 +1,62 @@ +// +// Copyright 2017 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +package gnoi.interface; + +import "types/types.proto"; + +option (types.gnoi_version) = "0.1.0"; + +service Interface { + // SetLoopbackMode is used to set the mode of loopback on a interface. + rpc SetLoopbackMode(SetLoopbackModeRequest) returns (SetLoopbackModeResponse) {} + + // GetLoopbackMode is used to get the mode of loopback on a interface. + rpc GetLoopbackMode(GetLoopbackModeRequest) returns (GetLoopbackModeResponse) {} + + // ClearInterfaceCounters will reset the counters for the provided interface. + rpc ClearInterfaceCounters(ClearInterfaceCountersRequest) returns (ClearInterfaceCountersResponse) {} + +} + +// SetLoopbackModeRequest requests the provide interface be have its loopback mode +// set to mode. Mode may be vendor specific. For example, on a transport +// device, available modes are "none", "mac", "phy", "phy_remote", +// "framer_facility", and "framer_terminal". +message SetLoopbackModeRequest { + types.Path interface = 1; + string mode = 2; +} + +message SetLoopbackModeResponse { +} + +message GetLoopbackModeRequest { + types.Path interface = 1; +} + +message GetLoopbackModeResponse { + string mode = 1; +} + +message ClearInterfaceCountersRequest { + repeated types.Path interface = 1; +} + +message ClearInterfaceCountersResponse { +} diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface_pb2.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface_pb2.py new file mode 100644 index 00000000..75129817 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface_pb2.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: interface/interface.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + +from gnoi.types import types_pb2 as types_dot_types__pb2 + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n\x19interface/interface.proto\x12\x0egnoi.interface\x1a\x11types/types.proto\"K\n\x16SetLoopbackModeRequest\x12#\n\tinterface\x18\x01 \x01(\x0b\x32\x10.gnoi.types.Path\x12\x0c\n\x04mode\x18\x02 \x01(\t\"\x19\n\x17SetLoopbackModeResponse\"=\n\x16GetLoopbackModeRequest\x12#\n\tinterface\x18\x01 \x01(\x0b\x32\x10.gnoi.types.Path\"\'\n\x17GetLoopbackModeResponse\x12\x0c\n\x04mode\x18\x01 \x01(\t\"D\n\x1d\x43learInterfaceCountersRequest\x12#\n\tinterface\x18\x01 \x03(\x0b\x32\x10.gnoi.types.Path\" \n\x1e\x43learInterfaceCountersResponse2\xd2\x02\n\tInterface\x12\x64\n\x0fSetLoopbackMode\x12&.gnoi.interface.SetLoopbackModeRequest\x1a\'.gnoi.interface.SetLoopbackModeResponse\"\x00\x12\x64\n\x0fGetLoopbackMode\x12&.gnoi.interface.GetLoopbackModeRequest\x1a\'.gnoi.interface.GetLoopbackModeResponse\"\x00\x12y\n\x16\x43learInterfaceCounters\x12-.gnoi.interface.ClearInterfaceCountersRequest\x1a..gnoi.interface.ClearInterfaceCountersResponse\"\x00\x42\x08\xd2>\x05\x30.1.0b\x06proto3' +) + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'interface.interface_pb2', + globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\322>\0050.1.0' + _SETLOOPBACKMODEREQUEST._serialized_start = 64 + _SETLOOPBACKMODEREQUEST._serialized_end = 139 + _SETLOOPBACKMODERESPONSE._serialized_start = 141 + _SETLOOPBACKMODERESPONSE._serialized_end = 166 + _GETLOOPBACKMODEREQUEST._serialized_start = 168 + _GETLOOPBACKMODEREQUEST._serialized_end = 229 + _GETLOOPBACKMODERESPONSE._serialized_start = 231 + _GETLOOPBACKMODERESPONSE._serialized_end = 270 + _CLEARINTERFACECOUNTERSREQUEST._serialized_start = 272 + _CLEARINTERFACECOUNTERSREQUEST._serialized_end = 340 + _CLEARINTERFACECOUNTERSRESPONSE._serialized_start = 342 + _CLEARINTERFACECOUNTERSRESPONSE._serialized_end = 374 + _INTERFACE._serialized_start = 377 + _INTERFACE._serialized_end = 715 +# @@protoc_insertion_point(module_scope) diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface_pb2_grpc.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface_pb2_grpc.py new file mode 100644 index 00000000..cfad99b9 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/interface/interface_pb2_grpc.py @@ -0,0 +1,157 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from gnoi.interface import interface_pb2 as interface_dot_interface__pb2 + + +class InterfaceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.SetLoopbackMode = channel.unary_unary( + '/gnoi.interface.Interface/SetLoopbackMode', + request_serializer=interface_dot_interface__pb2. + SetLoopbackModeRequest.SerializeToString, + response_deserializer=interface_dot_interface__pb2. + SetLoopbackModeResponse.FromString, + ) + self.GetLoopbackMode = channel.unary_unary( + '/gnoi.interface.Interface/GetLoopbackMode', + request_serializer=interface_dot_interface__pb2. + GetLoopbackModeRequest.SerializeToString, + response_deserializer=interface_dot_interface__pb2. + GetLoopbackModeResponse.FromString, + ) + self.ClearInterfaceCounters = channel.unary_unary( + '/gnoi.interface.Interface/ClearInterfaceCounters', + request_serializer=interface_dot_interface__pb2. + ClearInterfaceCountersRequest.SerializeToString, + response_deserializer=interface_dot_interface__pb2. + ClearInterfaceCountersResponse.FromString, + ) + + +class InterfaceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def SetLoopbackMode(self, request, context): + """SetLoopbackMode is used to set the mode of loopback on a interface. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def GetLoopbackMode(self, request, context): + """GetLoopbackMode is used to get the mode of loopback on a interface. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ClearInterfaceCounters(self, request, context): + """ClearInterfaceCounters will reset the counters for the provided interface. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_InterfaceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'SetLoopbackMode': + grpc.unary_unary_rpc_method_handler( + servicer.SetLoopbackMode, + request_deserializer=interface_dot_interface__pb2. + SetLoopbackModeRequest.FromString, + response_serializer=interface_dot_interface__pb2. + SetLoopbackModeResponse.SerializeToString, + ), + 'GetLoopbackMode': + grpc.unary_unary_rpc_method_handler( + servicer.GetLoopbackMode, + request_deserializer=interface_dot_interface__pb2. + GetLoopbackModeRequest.FromString, + response_serializer=interface_dot_interface__pb2. + GetLoopbackModeResponse.SerializeToString, + ), + 'ClearInterfaceCounters': + grpc.unary_unary_rpc_method_handler( + servicer.ClearInterfaceCounters, + request_deserializer=interface_dot_interface__pb2. + ClearInterfaceCountersRequest.FromString, + response_serializer=interface_dot_interface__pb2. + ClearInterfaceCountersResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'gnoi.interface.Interface', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler, )) + + +# This class is part of an EXPERIMENTAL API. +class Interface(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def SetLoopbackMode(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, target, '/gnoi.interface.Interface/SetLoopbackMode', + interface_dot_interface__pb2.SetLoopbackModeRequest. + SerializeToString, + interface_dot_interface__pb2.SetLoopbackModeResponse.FromString, + options, channel_credentials, insecure, call_credentials, + compression, wait_for_ready, timeout, metadata) + + @staticmethod + def GetLoopbackMode(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, target, '/gnoi.interface.Interface/GetLoopbackMode', + interface_dot_interface__pb2.GetLoopbackModeRequest. + SerializeToString, + interface_dot_interface__pb2.GetLoopbackModeResponse.FromString, + options, channel_credentials, insecure, call_credentials, + compression, wait_for_ready, timeout, metadata) + + @staticmethod + def ClearInterfaceCounters(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, target, + '/gnoi.interface.Interface/ClearInterfaceCounters', + interface_dot_interface__pb2.ClearInterfaceCountersRequest. + SerializeToString, interface_dot_interface__pb2. + ClearInterfaceCountersResponse.FromString, options, + channel_credentials, insecure, call_credentials, compression, + wait_for_ready, timeout, metadata) diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/__init__.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/__init__.py new file mode 100644 index 00000000..75b9ae7d --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/__init__.py @@ -0,0 +1,2 @@ +from .layer2_pb2_grpc import Layer2Stub # noqa +from .layer2_pb2 import * # noqa \ No newline at end of file diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2.proto b/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2.proto new file mode 100644 index 00000000..be658539 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2.proto @@ -0,0 +1,103 @@ +// +// Copyright 2017 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +package gnoi.layer2; + +import "types/types.proto"; + +option (types.gnoi_version) = "0.1.0"; + +service Layer2 { + // ClearNeighborDiscovery will clear either a specific neighbor entry or + // clear the entire table based on parameters provided. + // TODO: This method is subject to deprecation once OpenConfig models this + // state. + rpc ClearNeighborDiscovery(ClearNeighborDiscoveryRequest) returns (ClearNeighborDiscoveryResponse) {} + + // ClearSpanningTree will reset a blocked spanning tree interface. + // TODO: This method is subject to deprecation once OpenConfig models this + // state. + rpc ClearSpanningTree(ClearSpanningTreeRequest) returns (ClearSpanningTreeResponse) {} + + // PerformBERT will perform a BERT operation on a port. The stream will + // return the current state of the operation as well as the ID for the + // operation. + rpc PerformBERT(PerformBERTRequest) returns (stream PerformBERTResponse) {} + + // ClearLLDPInterface will clear all LLDP adjacencies on the provided + // interface. + rpc ClearLLDPInterface(ClearLLDPInterfaceRequest) returns (ClearLLDPInterfaceResponse) {} + + // SendWakeOnLAN will send a WOL event on the requested interface. + rpc SendWakeOnLAN(SendWakeOnLANRequest) returns (SendWakeOnLANResponse) {} + +} + +message ClearNeighborDiscoveryRequest { + types.L3Protocol protocol = 1; + string address = 2; +} + +message ClearNeighborDiscoveryResponse { +} + + +message ClearSpanningTreeRequest { + types.Path interface = 1; +} + +message ClearSpanningTreeResponse { +} + +message PerformBERTRequest { + // ID for retrieving a previous BERT run data - optional. + string id = 1; + types.Path interface = 2; +} + +message PerformBERTResponse { + string id = 1; + enum BERTState { + UNKNOWN = 0; + DISABLED = 1; + RUNNING = 2; + COMPLETE = 3; + ERROR = 4; + } + BERTState state = 2; + int64 elapsed_period = 3; // BERT test length in nanoseconds. + bytes pattern = 4; // Pattern used for the BERT test. + int64 errors = 5; // Number of errors experienced since the start of the BERT test. + int64 received_bits = 6; // Number of bits received since the start of the BERT test.} +} + +message ClearLLDPInterfaceRequest { + types.Path interface = 1; +} + +message ClearLLDPInterfaceResponse { +} + +message SendWakeOnLANRequest { + types.Path interface = 1; + string address = 2; // IP address of the WOL target. + bytes mac_address = 3; // MAC address of the target. +} + +message SendWakeOnLANResponse { +} diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2_pb2.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2_pb2.py new file mode 100644 index 00000000..a7b56c6f --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2_pb2.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: layer2/layer2.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from gnoi.types import types_pb2 as types_dot_types__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13layer2/layer2.proto\x12\x0bgnoi.layer2\x1a\x11types/types.proto\"Z\n\x1d\x43learNeighborDiscoveryRequest\x12(\n\x08protocol\x18\x01 \x01(\x0e\x32\x16.gnoi.types.L3Protocol\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x01(\t\" \n\x1e\x43learNeighborDiscoveryResponse\"?\n\x18\x43learSpanningTreeRequest\x12#\n\tinterface\x18\x01 \x01(\x0b\x32\x10.gnoi.types.Path\"\x1b\n\x19\x43learSpanningTreeResponse\"E\n\x12PerformBERTRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12#\n\tinterface\x18\x02 \x01(\x0b\x32\x10.gnoi.types.Path\"\xfa\x01\n\x13PerformBERTResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\x39\n\x05state\x18\x02 \x01(\x0e\x32*.gnoi.layer2.PerformBERTResponse.BERTState\x12\x16\n\x0e\x65lapsed_period\x18\x03 \x01(\x03\x12\x0f\n\x07pattern\x18\x04 \x01(\x0c\x12\x0e\n\x06\x65rrors\x18\x05 \x01(\x03\x12\x15\n\rreceived_bits\x18\x06 \x01(\x03\"L\n\tBERTState\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x0c\n\x08\x44ISABLED\x10\x01\x12\x0b\n\x07RUNNING\x10\x02\x12\x0c\n\x08\x43OMPLETE\x10\x03\x12\t\n\x05\x45RROR\x10\x04\"@\n\x19\x43learLLDPInterfaceRequest\x12#\n\tinterface\x18\x01 \x01(\x0b\x32\x10.gnoi.types.Path\"\x1c\n\x1a\x43learLLDPInterfaceResponse\"a\n\x14SendWakeOnLANRequest\x12#\n\tinterface\x18\x01 \x01(\x0b\x32\x10.gnoi.types.Path\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x01(\t\x12\x13\n\x0bmac_address\x18\x03 \x01(\x0c\"\x17\n\x15SendWakeOnLANResponse2\xfc\x03\n\x06Layer2\x12s\n\x16\x43learNeighborDiscovery\x12*.gnoi.layer2.ClearNeighborDiscoveryRequest\x1a+.gnoi.layer2.ClearNeighborDiscoveryResponse\"\x00\x12\x64\n\x11\x43learSpanningTree\x12%.gnoi.layer2.ClearSpanningTreeRequest\x1a&.gnoi.layer2.ClearSpanningTreeResponse\"\x00\x12T\n\x0bPerformBERT\x12\x1f.gnoi.layer2.PerformBERTRequest\x1a .gnoi.layer2.PerformBERTResponse\"\x00\x30\x01\x12g\n\x12\x43learLLDPInterface\x12&.gnoi.layer2.ClearLLDPInterfaceRequest\x1a\'.gnoi.layer2.ClearLLDPInterfaceResponse\"\x00\x12X\n\rSendWakeOnLAN\x12!.gnoi.layer2.SendWakeOnLANRequest\x1a\".gnoi.layer2.SendWakeOnLANResponse\"\x00\x42\x08\xd2>\x05\x30.1.0b\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'layer2.layer2_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\322>\0050.1.0' + _CLEARNEIGHBORDISCOVERYREQUEST._serialized_start=55 + _CLEARNEIGHBORDISCOVERYREQUEST._serialized_end=145 + _CLEARNEIGHBORDISCOVERYRESPONSE._serialized_start=147 + _CLEARNEIGHBORDISCOVERYRESPONSE._serialized_end=179 + _CLEARSPANNINGTREEREQUEST._serialized_start=181 + _CLEARSPANNINGTREEREQUEST._serialized_end=244 + _CLEARSPANNINGTREERESPONSE._serialized_start=246 + _CLEARSPANNINGTREERESPONSE._serialized_end=273 + _PERFORMBERTREQUEST._serialized_start=275 + _PERFORMBERTREQUEST._serialized_end=344 + _PERFORMBERTRESPONSE._serialized_start=347 + _PERFORMBERTRESPONSE._serialized_end=597 + _PERFORMBERTRESPONSE_BERTSTATE._serialized_start=521 + _PERFORMBERTRESPONSE_BERTSTATE._serialized_end=597 + _CLEARLLDPINTERFACEREQUEST._serialized_start=599 + _CLEARLLDPINTERFACEREQUEST._serialized_end=663 + _CLEARLLDPINTERFACERESPONSE._serialized_start=665 + _CLEARLLDPINTERFACERESPONSE._serialized_end=693 + _SENDWAKEONLANREQUEST._serialized_start=695 + _SENDWAKEONLANREQUEST._serialized_end=792 + _SENDWAKEONLANRESPONSE._serialized_start=794 + _SENDWAKEONLANRESPONSE._serialized_end=817 + _LAYER2._serialized_start=820 + _LAYER2._serialized_end=1328 +# @@protoc_insertion_point(module_scope) diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2_pb2_grpc.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2_pb2_grpc.py new file mode 100644 index 00000000..693b7de7 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/layer2/layer2_pb2_grpc.py @@ -0,0 +1,242 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from gnoi.layer2 import layer2_pb2 as layer2_dot_layer2__pb2 + + +class Layer2Stub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.ClearNeighborDiscovery = channel.unary_unary( + '/gnoi.layer2.Layer2/ClearNeighborDiscovery', + request_serializer=layer2_dot_layer2__pb2. + ClearNeighborDiscoveryRequest.SerializeToString, + response_deserializer=layer2_dot_layer2__pb2. + ClearNeighborDiscoveryResponse.FromString, + ) + self.ClearSpanningTree = channel.unary_unary( + '/gnoi.layer2.Layer2/ClearSpanningTree', + request_serializer=layer2_dot_layer2__pb2.ClearSpanningTreeRequest. + SerializeToString, + response_deserializer=layer2_dot_layer2__pb2. + ClearSpanningTreeResponse.FromString, + ) + self.PerformBERT = channel.unary_stream( + '/gnoi.layer2.Layer2/PerformBERT', + request_serializer=layer2_dot_layer2__pb2.PerformBERTRequest. + SerializeToString, + response_deserializer=layer2_dot_layer2__pb2.PerformBERTResponse. + FromString, + ) + self.ClearLLDPInterface = channel.unary_unary( + '/gnoi.layer2.Layer2/ClearLLDPInterface', + request_serializer=layer2_dot_layer2__pb2. + ClearLLDPInterfaceRequest.SerializeToString, + response_deserializer=layer2_dot_layer2__pb2. + ClearLLDPInterfaceResponse.FromString, + ) + self.SendWakeOnLAN = channel.unary_unary( + '/gnoi.layer2.Layer2/SendWakeOnLAN', + request_serializer=layer2_dot_layer2__pb2.SendWakeOnLANRequest. + SerializeToString, + response_deserializer=layer2_dot_layer2__pb2.SendWakeOnLANResponse. + FromString, + ) + + +class Layer2Servicer(object): + """Missing associated documentation comment in .proto file.""" + + def ClearNeighborDiscovery(self, request, context): + """ClearNeighborDiscovery will clear either a specific neighbor entry or + clear the entire table based on parameters provided. + TODO: This method is subject to deprecation once OpenConfig models this + state. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ClearSpanningTree(self, request, context): + """ClearSpanningTree will reset a blocked spanning tree interface. + TODO: This method is subject to deprecation once OpenConfig models this + state. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def PerformBERT(self, request, context): + """PerformBERT will perform a BERT operation on a port. The stream will + return the current state of the operation as well as the ID for the + operation. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ClearLLDPInterface(self, request, context): + """ClearLLDPInterface will clear all LLDP adjacencies on the provided + interface. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def SendWakeOnLAN(self, request, context): + """SendWakeOnLAN will send a WOL event on the requested interface. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_Layer2Servicer_to_server(servicer, server): + rpc_method_handlers = { + 'ClearNeighborDiscovery': + grpc.unary_unary_rpc_method_handler( + servicer.ClearNeighborDiscovery, + request_deserializer=layer2_dot_layer2__pb2. + ClearNeighborDiscoveryRequest.FromString, + response_serializer=layer2_dot_layer2__pb2. + ClearNeighborDiscoveryResponse.SerializeToString, + ), + 'ClearSpanningTree': + grpc.unary_unary_rpc_method_handler( + servicer.ClearSpanningTree, + request_deserializer=layer2_dot_layer2__pb2. + ClearSpanningTreeRequest.FromString, + response_serializer=layer2_dot_layer2__pb2. + ClearSpanningTreeResponse.SerializeToString, + ), + 'PerformBERT': + grpc.unary_stream_rpc_method_handler( + servicer.PerformBERT, + request_deserializer=layer2_dot_layer2__pb2.PerformBERTRequest. + FromString, + response_serializer=layer2_dot_layer2__pb2.PerformBERTResponse. + SerializeToString, + ), + 'ClearLLDPInterface': + grpc.unary_unary_rpc_method_handler( + servicer.ClearLLDPInterface, + request_deserializer=layer2_dot_layer2__pb2. + ClearLLDPInterfaceRequest.FromString, + response_serializer=layer2_dot_layer2__pb2. + ClearLLDPInterfaceResponse.SerializeToString, + ), + 'SendWakeOnLAN': + grpc.unary_unary_rpc_method_handler( + servicer.SendWakeOnLAN, + request_deserializer=layer2_dot_layer2__pb2.SendWakeOnLANRequest. + FromString, + response_serializer=layer2_dot_layer2__pb2.SendWakeOnLANResponse. + SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'gnoi.layer2.Layer2', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler, )) + + +# This class is part of an EXPERIMENTAL API. +class Layer2(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def ClearNeighborDiscovery(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, target, '/gnoi.layer2.Layer2/ClearNeighborDiscovery', + layer2_dot_layer2__pb2.ClearNeighborDiscoveryRequest. + SerializeToString, + layer2_dot_layer2__pb2.ClearNeighborDiscoveryResponse.FromString, + options, channel_credentials, insecure, call_credentials, + compression, wait_for_ready, timeout, metadata) + + @staticmethod + def ClearSpanningTree(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, target, '/gnoi.layer2.Layer2/ClearSpanningTree', + layer2_dot_layer2__pb2.ClearSpanningTreeRequest.SerializeToString, + layer2_dot_layer2__pb2.ClearSpanningTreeResponse.FromString, + options, channel_credentials, insecure, call_credentials, + compression, wait_for_ready, timeout, metadata) + + @staticmethod + def PerformBERT(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, target, '/gnoi.layer2.Layer2/PerformBERT', + layer2_dot_layer2__pb2.PerformBERTRequest.SerializeToString, + layer2_dot_layer2__pb2.PerformBERTResponse.FromString, options, + channel_credentials, insecure, call_credentials, compression, + wait_for_ready, timeout, metadata) + + @staticmethod + def ClearLLDPInterface(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, target, '/gnoi.layer2.Layer2/ClearLLDPInterface', + layer2_dot_layer2__pb2.ClearLLDPInterfaceRequest.SerializeToString, + layer2_dot_layer2__pb2.ClearLLDPInterfaceResponse.FromString, + options, channel_credentials, insecure, call_credentials, + compression, wait_for_ready, timeout, metadata) + + @staticmethod + def SendWakeOnLAN(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, target, '/gnoi.layer2.Layer2/SendWakeOnLAN', + layer2_dot_layer2__pb2.SendWakeOnLANRequest.SerializeToString, + layer2_dot_layer2__pb2.SendWakeOnLANResponse.FromString, options, + channel_credentials, insecure, call_credentials, compression, + wait_for_ready, timeout, metadata) diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/__init__.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/__init__.py new file mode 100644 index 00000000..b912ce96 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/__init__.py @@ -0,0 +1,2 @@ +from .system_pb2_grpc import SystemStub +from .system_pb2 import * # noqa \ No newline at end of file diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system.proto b/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system.proto new file mode 100644 index 00000000..a9d341e3 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system.proto @@ -0,0 +1,311 @@ +// +// Copyright 2017 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Generic Network Operation Interface, GNOI, defines a set of RPC's used for +// the operational aspects of network targets. These services are meant to be +// used in conjunction with GNMI for all target state and operational aspects +// of a network target. The gnoi.system.Service is the only mandatory vendor +// implementation. + +syntax = "proto3"; + +package gnoi.system; + +import "common/common.proto"; +import "types/types.proto"; + +option (types.gnoi_version) = "1.0.0"; + +// The gNOI service is a collection of operational RPC's that allow for the +// management of a target outside of the configuration and telemetry pipeline. +service System { + // Ping executes the ping command on the target and streams back + // the results. Some targets may not stream any results until all + // results are in. If a packet count is not explicitly provided, + // 5 is used. + rpc Ping(PingRequest) returns (stream PingResponse) {} + + // Traceroute executes the traceroute command on the target and streams back + // the results. Some targets may not stream any results until all + // results are in. If a hop count is not explicitly provided, + // 30 is used. + rpc Traceroute(TracerouteRequest) returns (stream TracerouteResponse) {} + + // Time returns the current time on the target. Time is typically used to + // test if a target is actually responding. + rpc Time(TimeRequest) returns (TimeResponse) {} + + // SetPackage places a software package (possibly including bootable images) + // on the target. The file is sent in sequential messages, each message + // up to 64KB of data. A final message must be sent that includes the hash + // of the data sent. An error is returned if the location does not exist or + // there is an error writing the data. If no checksum is received, the target + // must assume the operation is incomplete and remove the partially + // transmitted file. The target should initially write the file to a temporary + // location so a failure does not destroy the original file. + rpc SetPackage(stream SetPackageRequest) returns (SetPackageResponse) {} + + // SwitchControlProcessor will switch from the current route processor to the + // provided route processor. If the current route processor is the same as the + // one provided it is a NOOP. If the target does not exist an error is + // returned. + rpc SwitchControlProcessor(SwitchControlProcessorRequest) returns (SwitchControlProcessorResponse) {} + + // Reboot causes the target to reboot, possibly at some point in the future. + // If the method of reboot is not supported then the Reboot RPC will fail. + // If the reboot is immediate the command will block until the subcomponents + // have restarted. + // If a reboot on the active control processor is pending the service must + // reject all other reboot requests. + // If a reboot request for active control processor is initiated with other + // pending reboot requests it must be rejected. + rpc Reboot(RebootRequest) returns (RebootResponse) {} + + // RebootStatus returns the status of reboot for the target. + rpc RebootStatus(RebootStatusRequest) returns (RebootStatusResponse) {} + + // CancelReboot cancels any pending reboot request. + rpc CancelReboot(CancelRebootRequest) returns (CancelRebootResponse) {} + + // TODO(hines): Add RotateCertificate workflow + // TODO(hines): Add SetSSHPrivateKey +} + +message SwitchControlProcessorRequest { + types.Path control_processor = 1; +} + +message SwitchControlProcessorResponse { + types.Path control_processor = 1; + string version = 2; // Current software version. + int64 uptime = 3; // Uptime in nanoseconds since epoch. +} + +// A RebootRequest requests the specified target be rebooted using the specified +// method aftar the specified delay. Only the DEFAULT method with a delay of 0 +// is guaranteed to be accepted for all target types. +message RebootRequest { + RebootMethod method = 1; + // Delay in nanoseconds before issuing reboot. + uint64 delay = 2; + // Informational reason for the reboot. + string message = 3; + // Optional sub-components to reboot. + repeated types.Path subcomponents = 4; + // Force reboot if sanity checks fail. (ex. uncommited configuration) + bool force = 5; +} + +message RebootResponse { +} + +// A RebootMethod determines what should be done with a target when a Reboot is +// requested. Only the COLD method is required to be supported by all +// targets. Methods the target does not support should result in failure. +// +// It is vendor defined if a WARM reboot is the same as an NSF reboot. +enum RebootMethod { + UNKNOWN = 0; // Invalid default method. + COLD = 1; // Shutdown and restart OS and all hardware. + POWERDOWN = 2; // Halt and power down, if possible. + HALT = 3; // Halt, if possible. + WARM = 4; // Reload configuration but not underlying hardware. + NSF = 5; // Non-stop-forwarding reboot, if possible. + reserved 6; // RESET method is deprecated in favor of the gNOI + // FactoryReset.Start(). + POWERUP = 7; // Apply power, no-op if power is already on. +} + +// A CancelRebootRequest requests the cancelation of any outstanding reboot +// request. +message CancelRebootRequest { + string message = 1; // informational reason for the cancel + repeated types.Path subcomponents = 2; // optional sub-components. +} + +message CancelRebootResponse { +} + +message RebootStatusRequest { + repeated types.Path subcomponents = 1; // optional sub-component. +} + +message RebootStatusResponse { + bool active = 1; // If reboot is active. + uint64 wait = 2; // Time left until reboot. + uint64 when = 3; // Time to reboot in nanoseconds since the epoch. + string reason = 4; // Reason for reboot. + uint32 count = 5; // Number of reboots since active. +} + +// A TimeRequest requests the current time accodring to the target. +message TimeRequest { +} + +message TimeResponse { + uint64 time = 1; // Current time in nanoseconds since epoch. +} + +// A PingRequest describes the ping operation to perform. Only the destination +// fields is required. Any field not specified is set to a reasonable server +// specified value. Not all fields are supported by all vendors. +// +// A count of 0 defaults to a vendor specified value, typically 5. A count of +// -1 means continue until the RPC times out or is canceled. +// +// If the interval is -1 then a flood ping is issued. +// +// If the size is 0, the vendor default size will be used (typically 56 bytes). +message PingRequest { + string destination = 1; // Destination address to ping. required. + string source = 2; // Source address to ping from. + int32 count = 3; // Number of packets. + int64 interval = 4; // Nanoseconds between requests. + int64 wait = 5; // Nanoseconds to wait for a response. + int32 size = 6; // Size of request packet. (excluding ICMP header) + bool do_not_fragment = 7; // Set the do not fragment bit. (IPv4 destinations) + bool do_not_resolve = 8; // Do not try resolve the address returned. + types.L3Protocol l3protocol = 9; // Layer3 protocol requested for the ping. +} + +// A PingResponse represents either the reponse to a single ping packet +// (the bytes field is non-zero) or the summary statistics (sent is non-zero). +// +// For a single ping packet, time is the round trip time, in nanoseconds. For +// summary statistics, it is the time spent by the ping operation. The time is +// not always present in summary statistics. The std_dev is not always present +// in summary statistics. +message PingResponse { + string source = 1; // Source of received bytes. + int64 time = 2; + + int32 sent = 3; // Total packets sent. + int32 received = 4; // Total packets received. + int64 min_time = 5; // Minimum round trip time in nanoseconds. + int64 avg_time = 6; // Average round trip time in nanoseconds. + int64 max_time = 7; // Maximum round trip time in nanoseconds. + int64 std_dev = 8; // Standard deviation in round trip time. + + int32 bytes = 11; // Bytes received. + int32 sequence = 12; // Sequence number of received packet. + int32 ttl = 13; // Remaining time to live value. +} + +// A TracerouteRequest describes the traceroute operation to perform. Only the +// destination field is required. Any field not specified is set to a +// reasonable server specified value. Not all fields are supported by all +// vendors. +// +// If the hop_count is -1 the traceroute will continue forever. +// +message TracerouteRequest { + string source = 1; // Source address to ping from. + string destination = 2; // Destination address to ping. + uint32 initial_ttl = 3; // Initial TTL. (default=1) + int32 max_ttl = 4; // Maximum number of hops. (default=30) + int64 wait = 5; // Nanoseconds to wait for a response. + bool do_not_fragment = 6; // Set the do not fragment bit. (IPv4 destinations) + bool do_not_resolve = 7; // Do not try resolve the address returned. + types.L3Protocol l3protocol = 8; // Layer-3 protocol requested for the ping. + enum L4Protocol { + ICMP = 0; // Use ICMP ECHO for probes. + TCP = 1; // Use TCP SYN for probes. + UDP = 2; // Use UDP for probes. + } + L4Protocol l4protocol = 9; +} + +// A TraceRouteResponse contains the result of a single traceoute packet. +// +// There may be an optional initial response that provides information about the +// traceroute request itself and contains at least one of the fields in the the +// initial block of fields and none of the fields following that block. All +// subsequent responses should not contain any of these fields. +// +// Typically multiple responses are received for each hop, as the packets are +// received. +// +// The mpls field maps names to values. Example names include "Label", "CoS", +// "TTL", "S", and "MRU". +// [Perhaps we should list the canonical names that must be used when +// applicable]. +message TracerouteResponse { + // The following fields are only filled in for the first message. + // If any of these fields are specified, all fields following this + // block are left unspecified. + string destination_name = 1; + string destination_address = 2; + int32 hops = 3; + int32 packet_size = 4; + + // State is the resulting state of a single traceoroute packet. + enum State { + DEFAULT = 0; // Normal hop response. + NONE = 1; // No response. + UNKNOWN = 2; // Unknown response state. + ICMP = 3; // See icmp_code field. + HOST_UNREACHABLE = 4; // Host unreachable. + NETWORK_UNREACHABLE = 5; // Network unreachable. + PROTOCOL_UNREACHABLE = 6; // Protocol unreachable. + SOURCE_ROUTE_FAILED = 7; // Source route failed. + FRAGMENTATION_NEEDED = 8; // Fragmentation needed. + PROHIBITED = 9; // Communication administratively prohibited. + PRECEDENCE_VIOLATION = 10; // Host precedence violation. + PRECEDENCE_CUTOFF = 11; // Precedence cutoff in effect. + } + + // The following fields provide the disposition of a single traceroute + // packet. + int32 hop = 5; // Hop number. required. + string address = 6; // Address of responding hop. required. + string name = 7; // Name of responding hop. + int64 rtt = 8; // Round trip time in nanoseconds. + State state = 9; // State of this hop. + int32 icmp_code = 10; // Code terminating hop. + map mpls = 11; // MPLS key/value pairs. + repeated int32 as_path = 12; // AS path. +} + +// Package defines a single package file to be placed on the target. +message Package { + // Destination path and filename of the package. + string filename = 1; + // Version of the package. (vendor internal name) + string version = 4; + // Indicates that the package should be made active after receipt on + // the device. For system image packages, the new image is expected to + // be active after a reboot. + bool activate = 5; + // Details for the device to download the package from a remote location. + common.RemoteDownload remote_download = 6; +} + +// SetPackageRequest will place the package onto the target and optionally mark +// it as the next bootable image. The initial message must be a package +// message containing the filename and information about the file. Following the +// initial message the contents are then streamed in maximum 64k chunks. The +// final message must be a hash message contains the hash of the file contents. +message SetPackageRequest { + oneof request { + Package package = 1; + bytes contents = 2; + types.HashType hash = 3; // Verification hash of data. + } +} + + +message SetPackageResponse { +} diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system_pb2.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system_pb2.py new file mode 100644 index 00000000..8c932290 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system_pb2.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: system/system.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + +from gnoi.common import common_pb2 as common_dot_common__pb2 +from gnoi.types import types_pb2 as types_dot_types__pb2 + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n\x13system/system.proto\x12\x0bgnoi.system\x1a\x13\x63ommon/common.proto\x1a\x11types/types.proto\"L\n\x1dSwitchControlProcessorRequest\x12+\n\x11\x63ontrol_processor\x18\x01 \x01(\x0b\x32\x10.gnoi.types.Path\"n\n\x1eSwitchControlProcessorResponse\x12+\n\x11\x63ontrol_processor\x18\x01 \x01(\x0b\x32\x10.gnoi.types.Path\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0e\n\x06uptime\x18\x03 \x01(\x03\"\x92\x01\n\rRebootRequest\x12)\n\x06method\x18\x01 \x01(\x0e\x32\x19.gnoi.system.RebootMethod\x12\r\n\x05\x64\x65lay\x18\x02 \x01(\x04\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\'\n\rsubcomponents\x18\x04 \x03(\x0b\x32\x10.gnoi.types.Path\x12\r\n\x05\x66orce\x18\x05 \x01(\x08\"\x10\n\x0eRebootResponse\"O\n\x13\x43\x61ncelRebootRequest\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\'\n\rsubcomponents\x18\x02 \x03(\x0b\x32\x10.gnoi.types.Path\"\x16\n\x14\x43\x61ncelRebootResponse\">\n\x13RebootStatusRequest\x12\'\n\rsubcomponents\x18\x01 \x03(\x0b\x32\x10.gnoi.types.Path\"a\n\x14RebootStatusResponse\x12\x0e\n\x06\x61\x63tive\x18\x01 \x01(\x08\x12\x0c\n\x04wait\x18\x02 \x01(\x04\x12\x0c\n\x04when\x18\x03 \x01(\x04\x12\x0e\n\x06reason\x18\x04 \x01(\t\x12\r\n\x05\x63ount\x18\x05 \x01(\r\"\r\n\x0bTimeRequest\"\x1c\n\x0cTimeResponse\x12\x0c\n\x04time\x18\x01 \x01(\x04\"\xcc\x01\n\x0bPingRequest\x12\x13\n\x0b\x64\x65stination\x18\x01 \x01(\t\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\x05\x12\x10\n\x08interval\x18\x04 \x01(\x03\x12\x0c\n\x04wait\x18\x05 \x01(\x03\x12\x0c\n\x04size\x18\x06 \x01(\x05\x12\x17\n\x0f\x64o_not_fragment\x18\x07 \x01(\x08\x12\x16\n\x0e\x64o_not_resolve\x18\x08 \x01(\x08\x12*\n\nl3protocol\x18\t \x01(\x0e\x32\x16.gnoi.types.L3Protocol\"\xc1\x01\n\x0cPingResponse\x12\x0e\n\x06source\x18\x01 \x01(\t\x12\x0c\n\x04time\x18\x02 \x01(\x03\x12\x0c\n\x04sent\x18\x03 \x01(\x05\x12\x10\n\x08received\x18\x04 \x01(\x05\x12\x10\n\x08min_time\x18\x05 \x01(\x03\x12\x10\n\x08\x61vg_time\x18\x06 \x01(\x03\x12\x10\n\x08max_time\x18\x07 \x01(\x03\x12\x0f\n\x07std_dev\x18\x08 \x01(\x03\x12\r\n\x05\x62ytes\x18\x0b \x01(\x05\x12\x10\n\x08sequence\x18\x0c \x01(\x05\x12\x0b\n\x03ttl\x18\r \x01(\x05\"\xb2\x02\n\x11TracerouteRequest\x12\x0e\n\x06source\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65stination\x18\x02 \x01(\t\x12\x13\n\x0binitial_ttl\x18\x03 \x01(\r\x12\x0f\n\x07max_ttl\x18\x04 \x01(\x05\x12\x0c\n\x04wait\x18\x05 \x01(\x03\x12\x17\n\x0f\x64o_not_fragment\x18\x06 \x01(\x08\x12\x16\n\x0e\x64o_not_resolve\x18\x07 \x01(\x08\x12*\n\nl3protocol\x18\x08 \x01(\x0e\x32\x16.gnoi.types.L3Protocol\x12=\n\nl4protocol\x18\t \x01(\x0e\x32).gnoi.system.TracerouteRequest.L4Protocol\"(\n\nL4Protocol\x12\x08\n\x04ICMP\x10\x00\x12\x07\n\x03TCP\x10\x01\x12\x07\n\x03UDP\x10\x02\"\xdc\x04\n\x12TracerouteResponse\x12\x18\n\x10\x64\x65stination_name\x18\x01 \x01(\t\x12\x1b\n\x13\x64\x65stination_address\x18\x02 \x01(\t\x12\x0c\n\x04hops\x18\x03 \x01(\x05\x12\x13\n\x0bpacket_size\x18\x04 \x01(\x05\x12\x0b\n\x03hop\x18\x05 \x01(\x05\x12\x0f\n\x07\x61\x64\x64ress\x18\x06 \x01(\t\x12\x0c\n\x04name\x18\x07 \x01(\t\x12\x0b\n\x03rtt\x18\x08 \x01(\x03\x12\x34\n\x05state\x18\t \x01(\x0e\x32%.gnoi.system.TracerouteResponse.State\x12\x11\n\ticmp_code\x18\n \x01(\x05\x12\x37\n\x04mpls\x18\x0b \x03(\x0b\x32).gnoi.system.TracerouteResponse.MplsEntry\x12\x0f\n\x07\x61s_path\x18\x0c \x03(\x05\x1a+\n\tMplsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xf2\x01\n\x05State\x12\x0b\n\x07\x44\x45\x46\x41ULT\x10\x00\x12\x08\n\x04NONE\x10\x01\x12\x0b\n\x07UNKNOWN\x10\x02\x12\x08\n\x04ICMP\x10\x03\x12\x14\n\x10HOST_UNREACHABLE\x10\x04\x12\x17\n\x13NETWORK_UNREACHABLE\x10\x05\x12\x18\n\x14PROTOCOL_UNREACHABLE\x10\x06\x12\x17\n\x13SOURCE_ROUTE_FAILED\x10\x07\x12\x18\n\x14\x46RAGMENTATION_NEEDED\x10\x08\x12\x0e\n\nPROHIBITED\x10\t\x12\x18\n\x14PRECEDENCE_VIOLATION\x10\n\x12\x15\n\x11PRECEDENCE_CUTOFF\x10\x0b\"t\n\x07Package\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12\x10\n\x08\x61\x63tivate\x18\x05 \x01(\x08\x12\x34\n\x0fremote_download\x18\x06 \x01(\x0b\x32\x1b.gnoi.common.RemoteDownload\"\x81\x01\n\x11SetPackageRequest\x12\'\n\x07package\x18\x01 \x01(\x0b\x32\x14.gnoi.system.PackageH\x00\x12\x12\n\x08\x63ontents\x18\x02 \x01(\x0cH\x00\x12$\n\x04hash\x18\x03 \x01(\x0b\x32\x14.gnoi.types.HashTypeH\x00\x42\t\n\x07request\"\x14\n\x12SetPackageResponse*d\n\x0cRebootMethod\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x08\n\x04\x43OLD\x10\x01\x12\r\n\tPOWERDOWN\x10\x02\x12\x08\n\x04HALT\x10\x03\x12\x08\n\x04WARM\x10\x04\x12\x07\n\x03NSF\x10\x05\x12\x0b\n\x07POWERUP\x10\x07\"\x04\x08\x06\x10\x06\x32\x96\x05\n\x06System\x12?\n\x04Ping\x12\x18.gnoi.system.PingRequest\x1a\x19.gnoi.system.PingResponse\"\x00\x30\x01\x12Q\n\nTraceroute\x12\x1e.gnoi.system.TracerouteRequest\x1a\x1f.gnoi.system.TracerouteResponse\"\x00\x30\x01\x12=\n\x04Time\x12\x18.gnoi.system.TimeRequest\x1a\x19.gnoi.system.TimeResponse\"\x00\x12Q\n\nSetPackage\x12\x1e.gnoi.system.SetPackageRequest\x1a\x1f.gnoi.system.SetPackageResponse\"\x00(\x01\x12s\n\x16SwitchControlProcessor\x12*.gnoi.system.SwitchControlProcessorRequest\x1a+.gnoi.system.SwitchControlProcessorResponse\"\x00\x12\x43\n\x06Reboot\x12\x1a.gnoi.system.RebootRequest\x1a\x1b.gnoi.system.RebootResponse\"\x00\x12U\n\x0cRebootStatus\x12 .gnoi.system.RebootStatusRequest\x1a!.gnoi.system.RebootStatusResponse\"\x00\x12U\n\x0c\x43\x61ncelReboot\x12 .gnoi.system.CancelRebootRequest\x1a!.gnoi.system.CancelRebootResponse\"\x00\x42\x08\xd2>\x05\x31.0.0b\x06proto3' +) + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'system.system_pb2', + globals()) +# @@protoc_insertion_point(module_scope) diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system_pb2_grpc.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system_pb2_grpc.py new file mode 100644 index 00000000..c92cb048 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/system/system_pb2_grpc.py @@ -0,0 +1,74 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc # noqa + +from gnoi.system import system_pb2 as system_dot_system__pb2 + + +class SystemStub(object): + """The gNOI service is a collection of operational RPC's that allow for the + management of a target outside of the configuration and telemetry pipeline. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Ping = channel.unary_stream( + '/gnoi.system.System/Ping', + request_serializer=system_dot_system__pb2.PingRequest. + SerializeToString, + response_deserializer=system_dot_system__pb2.PingResponse. + FromString, + ) + self.Traceroute = channel.unary_stream( + '/gnoi.system.System/Traceroute', + request_serializer=system_dot_system__pb2.TracerouteRequest. + SerializeToString, + response_deserializer=system_dot_system__pb2.TracerouteResponse. + FromString, + ) + self.Time = channel.unary_unary( + '/gnoi.system.System/Time', + request_serializer=system_dot_system__pb2.TimeRequest. + SerializeToString, + response_deserializer=system_dot_system__pb2.TimeResponse. + FromString, + ) + self.SetPackage = channel.stream_unary( + '/gnoi.system.System/SetPackage', + request_serializer=system_dot_system__pb2.SetPackageRequest. + SerializeToString, + response_deserializer=system_dot_system__pb2.SetPackageResponse. + FromString, + ) + self.SwitchControlProcessor = channel.unary_unary( + '/gnoi.system.System/SwitchControlProcessor', + request_serializer=system_dot_system__pb2. + SwitchControlProcessorRequest.SerializeToString, + response_deserializer=system_dot_system__pb2. + SwitchControlProcessorResponse.FromString, + ) + self.Reboot = channel.unary_unary( + '/gnoi.system.System/Reboot', + request_serializer=system_dot_system__pb2.RebootRequest. + SerializeToString, + response_deserializer=system_dot_system__pb2.RebootResponse. + FromString, + ) + self.RebootStatus = channel.unary_unary( + '/gnoi.system.System/RebootStatus', + request_serializer=system_dot_system__pb2.RebootStatusRequest. + SerializeToString, + response_deserializer=system_dot_system__pb2.RebootStatusResponse. + FromString, + ) + self.CancelReboot = channel.unary_unary( + '/gnoi.system.System/CancelReboot', + request_serializer=system_dot_system__pb2.CancelRebootRequest. + SerializeToString, + response_deserializer=system_dot_system__pb2.CancelRebootResponse. + FromString, + ) diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/__init__.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types.proto b/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types.proto new file mode 100644 index 00000000..a5baa43d --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types.proto @@ -0,0 +1,73 @@ +// +// Copyright 2017 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +import "google/protobuf/descriptor.proto"; + +package gnoi.types; + +// Define a protobuf FileOption that defines the gNOI service version. +extend google.protobuf.FileOptions { + // The gNOI service semantic version. + string gnoi_version = 1002; +} + +// Generic Layer 3 Protocol enumeration. +enum L3Protocol { + UNSPECIFIED = 0; + IPV4 = 1; + IPV6 = 2; +} + +// HashType defines the valid hash methods for data verification. UNSPECIFIED +// should be treated an error. +message HashType { + enum HashMethod { + UNSPECIFIED = 0; + SHA256 = 1; + SHA512 = 2; + MD5 = 3; + } + HashMethod method = 1; + bytes hash = 2; +} + +// Path encodes a data tree path as a series of repeated strings, with +// each element of the path representing a data tree node name and the +// associated attributes. +// Reference: gNMI Specification Section 2.2.2. +message Path { + string origin = 2; // Label to disambiguate path. + repeated PathElem elem = 3; // Elements of the path. +} + +// PathElem encodes an element of a gNMI path, along with any attributes (keys) +// that may be associated with it. +// Reference: gNMI Specification Section 2.2.2. +message PathElem { + string name = 1; // The name of the element in the path. + map key = 2; // Map of key (attribute) name to value. +} + +// Credentials defines credentials needed to perform authentication on a device. +message Credentials { + string username = 1; + oneof password { + string cleartext = 2; + HashType hashed = 3; + } +} diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types_pb2.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types_pb2.py new file mode 100644 index 00000000..f5ed6ee0 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types_pb2.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: types/types.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11types/types.proto\x12\ngnoi.types\x1a google/protobuf/descriptor.proto\"\x89\x01\n\x08HashType\x12/\n\x06method\x18\x01 \x01(\x0e\x32\x1f.gnoi.types.HashType.HashMethod\x12\x0c\n\x04hash\x18\x02 \x01(\x0c\">\n\nHashMethod\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\n\n\x06SHA256\x10\x01\x12\n\n\x06SHA512\x10\x02\x12\x07\n\x03MD5\x10\x03\":\n\x04Path\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\"\n\x04\x65lem\x18\x03 \x03(\x0b\x32\x14.gnoi.types.PathElem\"p\n\x08PathElem\x12\x0c\n\x04name\x18\x01 \x01(\t\x12*\n\x03key\x18\x02 \x03(\x0b\x32\x1d.gnoi.types.PathElem.KeyEntry\x1a*\n\x08KeyEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"h\n\x0b\x43redentials\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x13\n\tcleartext\x18\x02 \x01(\tH\x00\x12&\n\x06hashed\x18\x03 \x01(\x0b\x32\x14.gnoi.types.HashTypeH\x00\x42\n\n\x08password*1\n\nL3Protocol\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\x08\n\x04IPV4\x10\x01\x12\x08\n\x04IPV6\x10\x02:3\n\x0cgnoi_version\x12\x1c.google.protobuf.FileOptions\x18\xea\x07 \x01(\tb\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'types.types_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension(gnoi_version) + + DESCRIPTOR._options = None + _PATHELEM_KEYENTRY._options = None + _PATHELEM_KEYENTRY._serialized_options = b'8\001' + _L3PROTOCOL._serialized_start=487 + _L3PROTOCOL._serialized_end=536 + _HASHTYPE._serialized_start=68 + _HASHTYPE._serialized_end=205 + _HASHTYPE_HASHMETHOD._serialized_start=143 + _HASHTYPE_HASHMETHOD._serialized_end=205 + _PATH._serialized_start=207 + _PATH._serialized_end=265 + _PATHELEM._serialized_start=267 + _PATHELEM._serialized_end=379 + _PATHELEM_KEYENTRY._serialized_start=337 + _PATHELEM_KEYENTRY._serialized_end=379 + _CREDENTIALS._serialized_start=381 + _CREDENTIALS._serialized_end=485 +# @@protoc_insertion_point(module_scope) diff --git a/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types_pb2_grpc.py b/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types_pb2_grpc.py new file mode 100644 index 00000000..2daafffe --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/gnoi/types/types_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/__init__.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/__init__.py new file mode 100644 index 00000000..de1e6e04 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/__init__.py @@ -0,0 +1,3 @@ +"""Itential Gateway 5 gRPC device driver (gNMI + gNOI + generic gRPC).""" + +__version__ = "0.1.0" diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/cfgtext.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/cfgtext.py new file mode 100644 index 00000000..c08c5994 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/cfgtext.py @@ -0,0 +1,175 @@ +"""Brace-style text <-> gNMI JSON tree. + +Configuration Manager's Golden Config parses ``get-config`` output as a string. A JSON tree can't be used +there, so the config is rendered as curly-brace text (``name value;`` leaves, ``name { ... }`` containers) +that the stock brace parser reads. Keyed list entries keep their gNMI spelling, ``interface[name=eth0] {``, +so a block's parents join straight back into a gNMI path. Strings that would read back as numbers or booleans +are quoted, so types survive a round trip. +""" + +import json +import re + +from grpc_driver.errors import DriverError + +_BARE = re.compile(r'^[^\s";{}#\\]+$') +_TOKEN = re.compile( + r'\s+|/\*.*?\*/|#[^\r\n]*|(?P"(?:[^"\\]|\\.)*")|(?P[{};])|(?P[^\s";{}#]+)', re.S) +_INDENT = " " + + +# ---------------------------------------------------------------------- render + +def _scalar(value): + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return json.dumps(value) + text = str(value) + if _BARE.match(text) and text not in ("[", "]") and not _coerces(text): + return text + return json.dumps(text) + + +def _coerces(text): + return text in ("true", "false", "null") or _number(text) is not None + + +def _number(text): + try: + return int(text) + except ValueError: + pass + try: + return float(text) if re.match(r"^-?\d+\.\d+$", text) else None + except ValueError: + return None + + +def _entries(value): + """Yield (block name, child) for a dict; list items are named from their ``name`` key.""" + for key, child in value.items(): + if isinstance(child, list) and child and all(isinstance(i, dict) for i in child): + for index, item in enumerate(child): + label = item.get("name", item.get("id", index)) + yield f"{key}[{'name' if 'name' in item or 'id' not in item else 'id'}={label}]", item + else: + yield key, child + + +def render(tree, depth=0): + pad = _INDENT * depth + lines = [] + for key, child in _entries(tree): + if isinstance(child, dict): + if child: + lines.append(f"{pad}{key} {{") + lines.append(render(child, depth + 1)) + lines.append(f"{pad}}}") + else: + lines.append(f"{pad}{key} {{") + lines.append(f"{pad}}}") + elif isinstance(child, list): + lines.append(f"{pad}{key} [ {' '.join(_scalar(i) for i in child)} ];") + else: + lines.append(f"{pad}{key} {_scalar(child)};") + return "\n".join(line for line in lines if line != "") + + +def merge(into, value): + if isinstance(value, dict) and isinstance(into, dict): + for k, v in value.items(): + into[k] = merge(into.get(k), v) if k in into else v + return into + return value + + +def tree_from_notifications(response, path_elems): + """Fold a gnmi.get() response into one dict. ``path_elems`` turns a path string into key names.""" + tree = {} + for note in (response or {}).get("notification", []): + prefix = note.get("prefix") + for upd in note.get("update", []): + if "val" not in upd: + continue + names = path_elems(prefix) + path_elems(upd.get("path")) + node = upd["val"] + for name in reversed(names): + node = {name: node} + merge(tree, node) + return tree + + +# ----------------------------------------------------------------------- parse + +def _tokens(text): + for match in _TOKEN.finditer(text): + if match.group("quoted") is not None: + yield "word", json.loads(match.group("quoted")), True + elif match.group("punct") is not None: + yield "punct", match.group("punct"), False + elif match.group("word") is not None: + yield "word", match.group("word"), False + + +def _value(word, quoted): + if quoted: + return word + if word == "true": + return True + if word == "false": + return False + if word == "null": + return None + number = _number(word) + return word if number is None else number + + +def parse(text): + """Brace text to a dict. Statements end with ``;``; a bare fragment is read one statement per line.""" + text = text or "" + if not re.search(r"[;{}]", text): + text = "\n".join(line.rstrip() + ";" for line in text.splitlines() if line.strip()) + root = {} + stack, words, items = [root], [], None + + def statement(): + nonlocal words + if not words: + return + key, rest = words[0][0], words[1:] + if not rest: + stack[-1].setdefault(key, {}) + elif len(rest) == 1 and isinstance(rest[0], list): + stack[-1][key] = rest[0] + elif len(rest) == 1: + stack[-1][key] = _value(*rest[0]) + else: + stack[-1][key] = " ".join(w[0] for w in rest) + words = [] + + for kind, tok, quoted in _tokens(text): + if kind == "word" and not quoted and tok == "[": + items = [] + elif kind == "word" and not quoted and tok == "]": + words.append(items or []) + items = None + elif kind == "word": + (items if items is not None else words).append(_value(tok, quoted) if items is not None else (tok, quoted)) + elif tok == "{": + name = " ".join(w[0] for w in words) + stack.append(stack[-1].setdefault(name, {})) + words = [] + elif tok == "}": + statement() + if len(stack) == 1: + raise DriverError("Unbalanced '}' in configuration text", "invalid_input") + stack.pop() + else: + statement() + statement() + if len(stack) != 1: + raise DriverError("Unterminated '{' in configuration text", "invalid_input") + return root diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/errors.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/errors.py new file mode 100644 index 00000000..d5dbccba --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/errors.py @@ -0,0 +1,29 @@ +import grpc + + +class DriverError(Exception): + """Raised for failures that should be reported as a structured error.""" + + def __init__(self, message, error_type="driver_error", evict=False): + super().__init__(message) + self.error_type = error_type + # True when the pooled channel that produced the error can no longer be trusted. + self.evict = evict + + +def classify(exc): + """Return (error_type, message, evict) for any exception raised by an operation.""" + if isinstance(exc, DriverError): + return exc.error_type, str(exc), exc.evict + wrapped = getattr(exc, "orig_exc", None) # pygnmi wraps RpcError in gNMIException + if isinstance(wrapped, grpc.RpcError): + return classify(wrapped) + if isinstance(exc, grpc.FutureTimeoutError): + return "timeout", "Timed out establishing the gRPC channel", True + if isinstance(exc, grpc.RpcError): + code = exc.code() if callable(getattr(exc, "code", None)) else None + name = code.name.lower() if code else "rpc_error" + details = exc.details() if callable(getattr(exc, "details", None)) else str(exc) + evict = code in (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.UNAUTHENTICATED) + return name, f"{name}: {details}", evict + return type(exc).__name__, str(exc), False diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/executor.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/executor.py new file mode 100644 index 00000000..8a26e86d --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/executor.py @@ -0,0 +1,49 @@ +"""Run one operation against a session, with the retry rules that make pooling safe.""" + +from grpc_driver import registry +from grpc_driver.errors import DriverError, classify +from grpc_driver.session import Session, call_with_deadline + + +def run_on_session(session, op_name, params): + op = registry.OPERATIONS.get(op_name) + if op is None: + raise DriverError(f"Unknown operation '{op_name}'. Available: {', '.join(sorted(registry.OPERATIONS))}", + "unknown_operation") + seconds = registry.deadline_for(op_name, params, session.spec) + return op, call_with_deadline(session, lambda: op.fn(session, params), seconds) + + +def execute_direct(spec, op_name, params): + """One channel for exactly one operation (the Gateway 5 default when no pool is available).""" + session = Session(spec).open() + try: + _, result = run_on_session(session, op_name, params) + return result, {"mode": "direct", "reused": False} + finally: + session.close() + + +def execute_pooled(pool, spec, op_name, params): + with pool.slot(spec): + return _execute_pooled(pool, spec, op_name, params) + + +def _execute_pooled(pool, spec, op_name, params): + """Lease a channel from ``pool``. A reused channel that turns out dead is replaced once, for idempotent ops only.""" + attempts = 2 + for attempt in range(attempts): + session, reused = pool.acquire(spec) + evict = False + try: + op, result = run_on_session(session, op_name, params) + return result, {"mode": "pooled", "reused": reused} + except Exception as exc: # noqa: BLE001 + _, _, evict = classify(exc) + retry = reused and evict and attempt + 1 < attempts and \ + registry.OPERATIONS.get(op_name) is not None and registry.OPERATIONS[op_name].idempotent + if not retry: + raise + finally: + pool.release(spec, session, evict=evict) + raise AssertionError("unreachable") diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi.py new file mode 100644 index 00000000..5f326ca1 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi.py @@ -0,0 +1,408 @@ +"""gNMI wire layer: path parsing and Get/Set on the vendored gNMI 0.10.0 protos. + +pygnmi (0.8.x) still opens the channel and answers Capabilities, but it cannot +send ``union_replace``, cannot express a raw CLI path, and its path parser +splits CLI text on ``/``. Requests are built here instead and sent on the +pooled channel; responses are shaped like pygnmi's so existing consumers see the +same JSON. +""" + +import json +import queue +import re +import threading +import time + +import grpc + +from grpc_driver.errors import DriverError +from grpc_driver.gnmi100 import gnmi_pb2 as pb + +RAW_ORIGINS = ("cli",) +ENCODINGS = {"json": pb.JSON, "bytes": pb.BYTES, "proto": pb.PROTO, "ascii": pb.ASCII, "json_ietf": pb.JSON_IETF} +OP_NAMES = {1: "DELETE", 2: "REPLACE", 3: "UPDATE", 4: "UNION_REPLACE"} +UNION_REPLACE_MIN = (0, 10, 0) +_ORIGIN_PREFIX = re.compile(r"^/?([^:/\[\]\s]+):") + + +# ----------------------------------------------------------------------- paths + +def _split_elements(text): + """Split ``a/b[k=v][k2=v2]/c`` on ``/`` outside ``[...]``. Backslash escapes ``]``, ``[`` and ``\\``.""" + elems, name, keys, i, n = [], [], [], 0, len(text) + + def flush(): + if name or keys: + elems.append(("".join(name), dict(keys))) + name.clear() + keys.clear() + + while i < n: + ch = text[i] + if ch == "\\" and i + 1 < n: + name.append(text[i + 1]) + i += 2 + elif ch == "[": + j, body = i + 1, [] + while j < n and text[j] != "]": + if text[j] == "\\" and j + 1 < n: + body.append(text[j + 1]) + j += 2 + else: + body.append(text[j]) + j += 1 + if j >= n: + raise DriverError(f"Invalid gNMI path '{text}': unterminated '['", "invalid_input") + key, eq, value = "".join(body).partition("=") + if not eq or not key: + raise DriverError(f"Invalid gNMI path '{text}': key must look like [name=value]", "invalid_input") + keys.append((key, value)) + i = j + 1 + elif ch == "/": + flush() + i += 1 + else: + name.append(ch) + i += 1 + flush() + return elems + + +def parse_path(path, origin=None, raw=False, raw_origins=RAW_ORIGINS): + """Build a gNMI Path. + + Accepted: ``/a/b[k=v]``; ``origin:/a/b`` and ``origin://`` (origin set, empty path); + ``module:a/b`` (pygnmi compatible: the part before the first ``:`` is the origin); + ``cli:show interface TenGigE0/0/0/3`` or ``origin='cli'`` (one verbatim element, never split or + case-folded); and structured ``{"origin":..., "elem":[{"name":..., "key":{...}}]}`` or + ``{"origin":..., "path": "..."}``. Case is always preserved. + """ + if isinstance(path, dict): + origin = path.get("origin", origin) + if "elem" in path: + out = pb.Path(origin=origin or "", target=path.get("target") or "") + for e in path["elem"]: + out.elem.add(name=e["name"], key={k: str(v) for k, v in (e.get("key") or {}).items()}) + return out + out = parse_path(path.get("path", ""), origin=origin, raw=path.get("raw", raw), raw_origins=raw_origins) + if path.get("target"): + out.target = path["target"] + return out + + text = "" if path is None else str(path).strip() + out = pb.Path() + match = _ORIGIN_PREFIX.match(text) + explicit = match.group(1) if match else None + effective = explicit or origin or "" + if raw or effective in raw_origins: + body = text[match.end():].lstrip("/") if match else text + out.origin = effective + if body: + out.elem.add(name=body) + return out + out.origin = effective + for name, key in _split_elements(text[match.end():] if match else text): + out.elem.add(name=name, key=key) + return out + + +def render_path(path): + """pygnmi-style path text; an origin, when present, is kept as ``origin:/...``.""" + parts = [] + for e in path.elem: + parts.append(e.name + "".join(f"[{k}={v}]" for k, v in sorted(e.key.items()))) + body = "/".join(parts) + if path.origin: + return f"{path.origin}:/{body}" + return body or None + + +# ---------------------------------------------------------------------- values + +def _json_bytes(value): + if isinstance(value, (bytes, bytearray)): + return bytes(value) + if isinstance(value, str): + try: + json.loads(value) + return value.encode() + except ValueError: + pass + return json.dumps(value).encode() + + +def typed_value(value, encoding): + enc = (encoding or "json_ietf").lower() + if enc == "json": + return pb.TypedValue(json_val=_json_bytes(value)) + if enc == "json_ietf": + return pb.TypedValue(json_ietf_val=_json_bytes(value)) + if enc == "ascii": + return pb.TypedValue(ascii_val=value if isinstance(value, str) else json.dumps(value)) + data = value.encode() if isinstance(value, str) else json.dumps(value).encode() + if enc == "bytes": + return pb.TypedValue(bytes_val=data) + if enc == "proto": + return pb.TypedValue(proto_bytes=data) + raise DriverError(f"Unsupported encoding '{encoding}'", "invalid_input") + + +def _decode(tv): + from pygnmi.client import process_potentially_json_value + + kind = tv.WhichOneof("value") + if kind in ("json_ietf_val", "json_val"): + return process_potentially_json_value(getattr(tv, kind)) + if kind == "leaflist_val": + return [_decode(e) for e in tv.leaflist_val.element] + if kind in ("decimal_val", "any_val"): + return str(getattr(tv, kind)) + return getattr(tv, kind) if kind else None + + +# ---------------------------------------------------------------------- calls + +def _pair(item, what): + if isinstance(item, dict): + path, value = item.get("path"), item.get("value") + elif isinstance(item, (list, tuple)) and len(item) == 2: + path, value = item + else: + raise DriverError(f"{what} entries must be [path, value] or {{path, value}}", "invalid_input") + return path, value + + +def _updates(items, encoding, origin, raw, what): + out = [] + for item in items or []: + path, value = _pair(item, what) + out.append(pb.Update(path=parse_path(path, origin, raw), val=typed_value(value, encoding))) + return out + + +def _encoding(session, requested): + name = (requested or getattr(session.gnmi, "_gNMIclient__encoding", None) or "json").lower() + if name not in ENCODINGS: + raise DriverError(f"encoding must be one of {sorted(ENCODINGS)}", "invalid_input") + return name + + +def _rpc_error(session, exc): + code = exc.code() + return DriverError( + f"GRPC ERROR Host: {session.spec.label()}, Error: {exc.details()}", + code.name.lower(), + evict=code in (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.UNAUTHENTICATED), + ) + + +def _unary(session, method, request, response_cls): + key = ("callable", method) + call = session.cache.get(key) + if call is None: + call = session.channel.unary_unary( + f"/gnmi.gNMI/{method}", + request_serializer=type(request).SerializeToString, + response_deserializer=response_cls.FromString, + ) + session.cache[key] = call + try: + return call(request, metadata=session.metadata, timeout=session.spec.rpc_timeout) + except grpc.RpcError as exc: + raise _rpc_error(session, exc) + + +def get(session, paths, datatype="all", encoding=None, prefix="", target=None, origin=None, raw=False): + request = pb.GetRequest( + prefix=parse_path(prefix, origin=None), + path=[parse_path(p, origin, raw) for p in (paths or ["/"])], + type=getattr(pb.GetRequest, datatype.upper()), + encoding=ENCODINGS[_encoding(session, encoding)], + ) + if target: + request.prefix.target = target + response = _unary(session, "Get", request, pb.GetResponse) + out = {} + if response.notification: + out["notification"] = [notification_dict(n) for n in response.notification] + return out + + +def notification_dict(n): + """One gNMI Notification in pygnmi's JSON shape (shared by Get and Subscribe).""" + item = { + "timestamp": n.timestamp or 0, + "prefix": render_path(n.prefix) if (n.prefix.elem or n.prefix.origin) else None, + "alias": None, + "atomic": n.atomic, + } + if n.update: + item["update"] = [ + {"path": render_path(u.path) if (u.path.elem or u.path.origin) else None, + **({"val": _decode(u.val)} if u.HasField("val") else {})} + for u in n.update + ] + if n.delete: + item["delete"] = [render_path(p) for p in n.delete] + return item + + +def parse_version(text): + try: + return tuple(int(p) for p in str(text).split(".")[:3]) + except ValueError: + return None + + +def device_version(session): + if "gnmi_version" not in session.cache: + caps = session.gnmi.capabilities() or {} + session.cache["gnmi_version"] = caps.get("gnmi_version") + return session.cache["gnmi_version"] + + +def require_union_replace(session): + advertised = device_version(session) + parsed = parse_version(advertised) + if parsed is None or parsed < UNION_REPLACE_MIN: + raise DriverError( + f"{session.spec.label()} advertises gNMI {advertised or 'unknown'}; union_replace needs 0.10.0 or newer. " + "An older target ignores the field and applies nothing. Pass force=true to send it anyway.", + "unsupported", + ) + + +def set_(session, update=None, replace=None, union_replace=None, delete=None, encoding=None, prefix="", + target=None, origin=None, raw=False, force=False): + if union_replace and not force: + require_union_replace(session) + enc = _encoding(session, encoding) + request = pb.SetRequest( + prefix=parse_path(prefix), + delete=[parse_path(p, origin, raw) for p in delete or []], + replace=_updates(replace, enc, origin, raw, "replace"), + update=_updates(update, enc, origin, raw, "update"), + union_replace=_updates(union_replace, enc, origin, raw, "union_replace"), + ) + if target: + request.prefix.target = target + response = _unary(session, "Set", request, pb.SetResponse) + out = { + "timestamp": response.timestamp or 0, + "prefix": render_path(response.prefix) if (response.prefix.elem or response.prefix.origin) else None, + } + if response.response: + out["response"] = [ + {"path": render_path(r.path) if (r.path.elem or r.path.origin) else None, + "op": OP_NAMES.get(r.op, "UNDEFINED")} + for r in response.response + ] + return out + + +# ------------------------------------------------------------------- subscribe + +SUBSCRIBE_MODES = ("once", "poll", "stream") +STREAM_MODES = {"sample": pb.SAMPLE, "on_change": pb.ON_CHANGE, "target_defined": pb.TARGET_DEFINED} +LIST_MODES = {"once": pb.SubscriptionList.ONCE, "poll": pb.SubscriptionList.POLL, "stream": pb.SubscriptionList.STREAM} +NANOS = 1_000_000_000 + + +def subscribe(session, mode, paths, *, duration=0, call_timeout=30, max_updates=1000, poll_count=1, + poll_interval=1.0, stream_mode="sample", sample_interval=0, heartbeat_interval=0, + suppress_redundant=False, updates_only=False, encoding=None, prefix="", target=None, + origin=None, raw=False): + """Run one bounded gNMI Subscribe and return the notifications it produced. + + ``once`` reads until the device's sync_response. ``poll`` sends ``poll_count`` Poll messages, + ``poll_interval`` seconds apart, and reads one sync_response per poll. ``stream`` collects until + ``duration`` seconds pass or ``max_updates`` notifications arrive, then cancels the RPC. + ``stop_reason`` is ``complete``, ``duration``, ``max_updates`` or ``device_closed``. + """ + if mode == "stream" and stream_mode not in STREAM_MODES: + raise DriverError(f"stream_mode must be one of {sorted(STREAM_MODES)}", "invalid_input") + sub_mode = STREAM_MODES[stream_mode] if mode == "stream" else pb.SAMPLE + subs = [ + pb.Subscription( + path=parse_path(p, origin, raw), mode=sub_mode, + sample_interval=int(sample_interval * NANOS) if mode == "stream" else 0, + suppress_redundant=bool(suppress_redundant) if mode == "stream" else False, + heartbeat_interval=int(heartbeat_interval * NANOS) if mode == "stream" else 0, + ) + for p in (paths or ["/"]) + ] + sub_list = pb.SubscriptionList( + prefix=parse_path(prefix), subscription=subs, mode=LIST_MODES[mode], + encoding=ENCODINGS[_encoding(session, encoding)], updates_only=bool(updates_only), + ) + if target: + sub_list.prefix.target = target + + outbox = queue.Queue() + outbox.put(pb.SubscribeRequest(subscribe=sub_list)) + + def requests(): + while True: + item = outbox.get() + if item is None: + return + yield item + + call = session.channel.stream_stream( + "/gnmi.gNMI/Subscribe", + request_serializer=pb.SubscribeRequest.SerializeToString, + response_deserializer=pb.SubscribeResponse.FromString, + ) + stream = call(requests(), metadata=session.metadata, timeout=call_timeout) + + state = {"reason": None} + notifications, syncs = [], 0 + + def stop(reason): + if state["reason"] is None: + state["reason"] = reason + stream.cancel() + + timer = None + if mode == "stream": + timer = threading.Timer(duration, stop, args=("duration",)) + timer.daemon = True + timer.start() + elif mode == "poll": + def poller(): + for i in range(poll_count): + if state["reason"]: + return + outbox.put(pb.SubscribeRequest(poll=pb.Poll())) + if i + 1 < poll_count: + time.sleep(poll_interval) + threading.Thread(target=poller, daemon=True).start() + + try: + for response in stream: + kind = response.WhichOneof("response") + if kind == "update": + notifications.append(notification_dict(response.update)) + if mode == "stream" and len(notifications) >= max_updates: + stop("max_updates") + break + elif kind == "sync_response": + syncs += 1 + if mode == "once" or (mode == "poll" and syncs >= poll_count): + state["reason"] = state["reason"] or "complete" + break + elif kind == "error": + raise DriverError(f"GRPC ERROR Host: {session.spec.label()}, Error: {response.error.message}", + "subscribe_error") + else: + state["reason"] = state["reason"] or ("complete" if mode == "once" else "device_closed") + except grpc.RpcError as exc: + if not (state["reason"] and exc.code() == grpc.StatusCode.CANCELLED): + raise _rpc_error(session, exc) + finally: + if timer is not None: + timer.cancel() + outbox.put(None) + stream.cancel() + + return {"notification": notifications, "sync_responses": syncs, "stop_reason": state["reason"] or "complete"} diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/README.md b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/README.md new file mode 100644 index 00000000..4dc1ae0c --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/README.md @@ -0,0 +1,13 @@ +gNMI 0.10.0 protobuf definitions, vendored so the driver can send `union_replace` +(pygnmi bundles only the 0.8.0 stubs). + +Source: https://github.com/openconfig/gnmi (proto/gnmi, proto/gnmi_ext) at commit +a4e40e60426e5b30ce29fd8115809d2dbe47584f (spec 0.10.0). + +Local edits, so these load beside pygnmi's 0.8.0 stubs in one process: +- proto packages renamed `gnmi` -> `gnmi100`, `gnmi_ext` -> `gnmi100_ext` +- gnmi_ext import localised, file-level `gnmi_service` option removed +The wire format is unchanged. RPC paths stay `/gnmi.gNMI/`. + +Regenerate: python -m grpc_tools.protoc -I. --python_out=. gnmi100/gnmi.proto gnmi100/gnmi_ext.proto +(then change the `from gnmi100 import gnmi_ext_pb2` line to `from grpc_driver.gnmi100 import gnmi_ext_pb2`) diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/__init__.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi.proto b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi.proto new file mode 100644 index 00000000..824a3aef --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi.proto @@ -0,0 +1,463 @@ +// +// Copyright 2016 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +syntax = "proto3"; + +import "google/protobuf/any.proto"; +import "google/protobuf/descriptor.proto"; + +import "gnmi100/gnmi_ext.proto"; + +// Package gNMI defines a service specification for the gRPC Network Management +// Interface. This interface is defined to be a standard interface via which +// a network management system ("client") can subscribe to state values, +// retrieve snapshots of state information, and manipulate the state of a data +// tree supported by a device ("target"). +// +// This document references the gNMI Specification which can be found at +// http://github.com/openconfig/reference/blob/master/rpc/gnmi +package gnmi100; + +// Define a protobuf FileOption that defines the gNMI service version. + +// gNMI_service is the current version of the gNMI service, returned through +// the Capabilities RPC. + +option go_package = "github.com/openconfig/gnmi/proto/gnmi"; +option java_multiple_files = true; +option java_outer_classname = "GnmiProto"; +option java_package = "com.github.gnmi.proto"; + + +service gNMI { + // Capabilities allows the client to retrieve the set of capabilities that + // is supported by the target. This allows the target to validate the + // service version that is implemented and retrieve the set of models that + // the target supports. The models can then be specified in subsequent RPCs + // to restrict the set of data that is utilized. + // Reference: gNMI Specification Section 3.2 + rpc Capabilities(CapabilityRequest) returns (CapabilityResponse); + // Retrieve a snapshot of data from the target. A Get RPC requests that the + // target snapshots a subset of the data tree as specified by the paths + // included in the message and serializes this to be returned to the + // client using the specified encoding. + // Reference: gNMI Specification Section 3.3 + rpc Get(GetRequest) returns (GetResponse); + // Set allows the client to modify the state of data on the target. The + // paths to modified along with the new values that the client wishes + // to set the value to. + // Reference: gNMI Specification Section 3.4 + rpc Set(SetRequest) returns (SetResponse); + // Subscribe allows a client to request the target to send it values + // of particular paths within the data tree. These values may be streamed + // at a particular cadence (STREAM), sent one off on a long-lived channel + // (POLL), or sent as a one-off retrieval (ONCE). + // Reference: gNMI Specification Section 3.5 + rpc Subscribe(stream SubscribeRequest) returns (stream SubscribeResponse); +} + +// Notification is a re-usable message that is used to encode data from the +// target to the client. A Notification carries two types of changes to the data +// tree: +// - Deleted values (delete) - a set of paths that have been removed from the +// data tree. +// - Updated values (update) - a set of path-value pairs indicating the path +// whose value has changed in the data tree. +// Reference: gNMI Specification Section 2.1 +message Notification { + int64 timestamp = 1; // Timestamp in nanoseconds since Epoch. + Path prefix = 2; // Prefix used for paths in the message. + repeated Update update = 4; // Data elements that have changed values. + repeated Path delete = 5; // Data elements that have been deleted. + // This notification contains a set of paths that are always updated together + // referenced by a globally unique prefix. + bool atomic = 6; + // Reserved field numbers and identifiers. + reserved "alias"; + reserved 3; +} + +// Update is a re-usable message that is used to store a particular Path, +// Value pair. +// Reference: gNMI Specification Section 2.1 +message Update { + Path path = 1; // The path (key) for the update. + Value value = 2 [deprecated = true]; // The value (value) for the update. + TypedValue val = 3; // The explicitly typed update value. + uint32 duplicates = 4; // Number of coalesced duplicates. +} + +// TypedValue is used to encode a value being sent between the client and +// target (originated by either entity). +message TypedValue { + // One of the fields within the val oneof is populated with the value + // of the update. The type of the value being included in the Update + // determines which field should be populated. In the case that the + // encoding is a particular form of the base protobuf type, a specific + // field is used to store the value (e.g., json_val). + oneof value { + string string_val = 1; // String value. + int64 int_val = 2; // Integer value. + uint64 uint_val = 3; // Unsigned integer value. + bool bool_val = 4; // Bool value. + bytes bytes_val = 5; // Arbitrary byte sequence value. + float float_val = 6 [deprecated = true]; // Deprecated - use double_val. + double double_val = 14; // Floating point value. + Decimal64 decimal_val = 7 + [deprecated = true]; // Deprecated - use double_val. + ScalarArray leaflist_val = 8; // Mixed type scalar array value. + google.protobuf.Any any_val = 9; // protobuf.Any encoded bytes. + bytes json_val = 10; // JSON-encoded text. + bytes json_ietf_val = 11; // JSON-encoded text per RFC7951. + string ascii_val = 12; // Arbitrary ASCII text. + // Protobuf binary encoded bytes. The message type is not included. + // See the specification at + // github.com/openconfig/reference/blob/master/rpc/gnmi/protobuf-vals.md + // for a complete specification. [Experimental] + bytes proto_bytes = 13; + } +} + +// Path encodes a data tree path as a series of repeated strings, with +// each element of the path representing a data tree node name and the +// associated attributes. +// Reference: gNMI Specification Section 2.2.2. +message Path { + // Elements of the path are no longer encoded as a string, but rather within + // the elem field as a PathElem message. + repeated string element = 1 [deprecated = true]; + string origin = 2; // Label to disambiguate path. + repeated PathElem elem = 3; // Elements of the path. + string target = 4; // The name of the target + // (Sec. 2.2.2.1) +} + +// PathElem encodes an element of a gNMI path, along with any attributes (keys) +// that may be associated with it. +// Reference: gNMI Specification Section 2.2.2. +message PathElem { + string name = 1; // The name of the element in the path. + map key = 2; // Map of key (attribute) name to value. +} + +// Value encodes a data tree node's value - along with the way in which +// the value is encoded. This message is deprecated by gNMI 0.3.0. +// Reference: gNMI Specification Section 2.2.3. +message Value { + option deprecated = true; + + bytes value = 1; // Value of the variable being transmitted. + Encoding type = 2; // Encoding used for the value field. +} + +// Encoding defines the value encoding formats that are supported by the gNMI +// protocol. These encodings are used by both the client (when sending Set +// messages to modify the state of the target) and the target when serializing +// data to be returned to the client (in both Subscribe and Get RPCs). +// Reference: gNMI Specification Section 2.3 +enum Encoding { + JSON = 0; // JSON encoded text. + BYTES = 1; // Arbitrarily encoded bytes. + PROTO = 2; // Encoded according to scalar values of TypedValue. + ASCII = 3; // ASCII text of an out-of-band agreed format. + JSON_IETF = 4; // JSON encoded text as per RFC7951. +} + +// Error message previously utilised to return errors to the client. Deprecated +// in favour of using the google.golang.org/genproto/googleapis/rpc/status +// message in the RPC response. +// Reference: gNMI Specification Section 2.5 +message Error { + option deprecated = true; + + uint32 code = 1; // Canonical gRPC error code. + string message = 2; // Human readable error. + google.protobuf.Any data = 3; // Optional additional information. +} + +// Decimal64 is used to encode a fixed precision decimal number. The value +// is expressed as a set of digits with the precision specifying the +// number of digits following the decimal point in the digit set. +// This message is deprecated in favor of encoding all floating point types +// as double precision. +message Decimal64 { + option deprecated = true; + + int64 digits = 1; // Set of digits. + uint32 precision = 2; // Number of digits following the decimal point. +} + +// ScalarArray is used to encode a mixed-type array of values. +message ScalarArray { + // The set of elements within the array. Each TypedValue message should + // specify only elements that have a field identifier of 1-7 (i.e., the + // values are scalar values). + repeated TypedValue element = 1; +} + +// SubscribeRequest is the message sent by the client to the target when +// initiating a subscription to a set of paths within the data tree. The +// request field must be populated and the initial message must specify a +// SubscriptionList to initiate a subscription. +// Reference: gNMI Specification Section 3.5.1.1 +message SubscribeRequest { + oneof request { + SubscriptionList subscribe = 1; // Specify the paths within a subscription. + Poll poll = 3; // Trigger a polled update. + } + // Extension messages associated with the SubscribeRequest. See the + // gNMI extension specification for further definition. + repeated gnmi100_ext.Extension extension = 5; + // Reserved field numbers and identifiers. + reserved 4; + reserved "aliases"; +} + +// Poll is sent within a SubscribeRequest to trigger the device to +// send telemetry updates for the paths that are associated with the +// subscription. +// Reference: gNMI Specification Section Section 3.5.1.4 +message Poll {} + +// SubscribeResponse is the message used by the target within a Subscribe RPC. +// The target includes a Notification message which is used to transmit values +// of the path(s) that are associated with the subscription. The same message +// is to indicate that the target has sent all data values once (is +// synchronized). +// Reference: gNMI Specification Section 3.5.1.4 +message SubscribeResponse { + oneof response { + Notification update = 1; // Changed or sampled value for a path. + // Indicate target has sent all values associated with the subscription + // at least once. + bool sync_response = 3; + // Deprecated in favour of google.golang.org/genproto/googleapis/rpc/status + Error error = 4 [deprecated = true]; + } + // Extension messages associated with the SubscribeResponse. See the + // gNMI extension specification for further definition. + repeated gnmi100_ext.Extension extension = 5; +} + +// SubscriptionList is used within a Subscribe message to specify the list of +// paths that the client wishes to subscribe to. The message consists of a +// list of (possibly prefixed) paths, and options that relate to the +// subscription. +// Reference: gNMI Specification Section 3.5.1.2 +message SubscriptionList { + Path prefix = 1; // Prefix used for paths. + repeated Subscription subscription = 2; // Set of subscriptions to create. + QOSMarking qos = 4; // DSCP marking to be used. + // Mode of the subscription. + enum Mode { + STREAM = 0; // Values streamed by the target (Sec. 3.5.1.5.2). + ONCE = 1; // Values sent once-off by the target (Sec. 3.5.1.5.1). + POLL = 2; // Values sent in response to a poll request (Sec. 3.5.1.5.3). + } + Mode mode = 5; + // Whether elements of the schema that are marked as eligible for aggregation + // should be aggregated or not. + bool allow_aggregation = 6; + // The set of schemas that define the elements of the data tree that should + // be sent by the target. + repeated ModelData use_models = 7; + // The encoding that the target should use within the Notifications generated + // corresponding to the SubscriptionList. + Encoding encoding = 8; + // An optional field to specify that only updates to current state should be + // sent to a client. If set, the initial state is not sent to the client but + // rather only the sync message followed by any subsequent updates to the + // current state. For ONCE and POLL modes, this causes the server to send only + // the sync message (Sec. 3.5.2.3). + bool updates_only = 9; + // Reserved field numbers and identifiers. + reserved 3; + reserved "use_aliases"; +} + +// Subscription is a single request within a SubscriptionList. The path +// specified is interpreted (along with the prefix) as the elements of the data +// tree that the client is subscribing to. The mode determines how the target +// should trigger updates to be sent. +// Reference: gNMI Specification Section 3.5.1.3 +message Subscription { + Path path = 1; // The data tree path. + SubscriptionMode mode = 2; // Subscription mode to be used. + uint64 sample_interval = 3; // ns between samples in SAMPLE mode. + // Indicates whether values that have not changed should be sent in a SAMPLE + // subscription. + bool suppress_redundant = 4; + // 1. A heartbeat interval MAY be specified along with an “on change” + // subscription - in this case, the value of the data item(s) MUST be re-sent + // once per heartbeat interval regardless of whether the value has changed or + // not. + // 2. A heartbeat_interval MAY be specified to modify the behavior of + // suppress_redundant in a sampled subscription. In this case, the + // target MUST generate one telemetry update per heartbeat interval, + // regardless of whether the suppress_redundant flag is set to true. + // This value is specified as an unsigned 64-bit integer in nanoseconds + uint64 heartbeat_interval = 5; +} + +// SubscriptionMode is the mode of the subscription, specifying how the +// target must return values in a subscription. +// Reference: gNMI Specification Section 3.5.1.3 +enum SubscriptionMode { + TARGET_DEFINED = 0; // The target selects the relevant mode for each element. + ON_CHANGE = 1; // The target sends an update on element value change. + SAMPLE = 2; // The target samples values according to the interval. +} + +// QOSMarking specifies the DSCP value to be set on transmitted telemetry +// updates from the target. +// Reference: gNMI Specification Section 3.5.1.2 +message QOSMarking { + uint32 marking = 1; +} + +// SetRequest is sent from a client to the target to update values in the data +// tree. Paths are either deleted by the client, or modified by means of being +// updated, or replaced. Where a replace is used, unspecified values are +// considered to be replaced, whereas when update is used the changes are +// considered to be incremental. The set of changes that are specified within +// a single SetRequest are considered to be a transaction. +// Reference: gNMI Specification Section 3.4.1 +message SetRequest { + Path prefix = 1; // Prefix used for paths in the message. + repeated Path delete = 2; // Paths to be deleted from the data tree. + repeated Update replace = 3; // Updates specifying elements to be replaced. + repeated Update update = 4; // Updates specifying elements to updated. + // Updates specifying elements to union and then replace the data tree. + // See the gNMI specification at + // https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-specification.md + // for details. + repeated Update union_replace = 6; + // Extension messages associated with the SetRequest. See the + // gNMI extension specification for further definition. + repeated gnmi100_ext.Extension extension = 5; +} + +// SetResponse is the response to a SetRequest, sent from the target to the +// client. It reports the result of the modifications to the data tree that were +// specified by the client. Errors for this RPC should be reported using the +// https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto +// message in the RPC return. The gnmi.Error message can be used to add +// additional details where required. Reference: gNMI Specification +// Section 3.4.2 +message SetResponse { + Path prefix = 1; // Prefix used for paths. + // A set of responses specifying the result of the operations specified in + // the SetRequest. + repeated UpdateResult response = 2; + Error message = 3 + [deprecated = true]; // The overall status of the transaction. + int64 timestamp = 4; // Timestamp of transaction (ns since epoch). + // Extension messages associated with the SetResponse. See the + // gNMI extension specification for further definition. + repeated gnmi100_ext.Extension extension = 5; +} + +// UpdateResult is used within the SetResponse message to communicate the +// result of an operation specified within a SetRequest message. +// Reference: gNMI Specification Section 3.4.2 +message UpdateResult { + // The operation that was associated with the Path specified. + enum Operation { + INVALID = 0; + DELETE = 1; // The result relates to a delete of Path. + REPLACE = 2; // The result relates to a replace of Path. + UPDATE = 3; // The result relates to an update of Path. + UNION_REPLACE = 4; // The result of a union_replace of Path or CLI origin. + } + // Deprecated timestamp for the UpdateResult, this field has been + // replaced by the timestamp within the SetResponse message, since + // all mutations effected by a set should be applied as a single + // transaction. + int64 timestamp = 1 [deprecated = true]; + Path path = 2; // Path associated with the update. + Error message = 3 [deprecated = true]; // Status of the update operation. + Operation op = 4; // Update operation type. +} + +// GetRequest is sent when a client initiates a Get RPC. It is used to specify +// the set of data elements for which the target should return a snapshot of +// data. The use_models field specifies the set of schema modules that are to +// be used by the target - where use_models is not specified then the target +// must use all schema models that it has. +// Reference: gNMI Specification Section 3.3.1 +message GetRequest { + Path prefix = 1; // Prefix used for paths. + repeated Path path = 2; // Paths requested by the client. + // Type of elements within the data tree. + enum DataType { + ALL = 0; // All data elements. + CONFIG = 1; // Config (rw) only elements. + STATE = 2; // State (ro) only elements. + // Data elements marked in the schema as operational. This refers to data + // elements whose value relates to the state of processes or interactions + // running on the device. + OPERATIONAL = 3; + } + DataType type = 3; // The type of data being requested. + Encoding encoding = 5; // Encoding to be used. + repeated ModelData use_models = 6; // The schema models to be used. + // Extension messages associated with the GetRequest. See the + // gNMI extension specification for further definition. + repeated gnmi100_ext.Extension extension = 7; +} + +// GetResponse is used by the target to respond to a GetRequest from a client. +// The set of Notifications corresponds to the data values that are requested +// by the client in the GetRequest. +// Reference: gNMI Specification Section 3.3.2 +message GetResponse { + repeated Notification notification = 1; // Data values. + Error error = 2 [deprecated = true]; // Errors that occurred in the Get. + // Extension messages associated with the GetResponse. See the + // gNMI extension specification for further definition. + repeated gnmi100_ext.Extension extension = 3; +} + +// CapabilityRequest is sent by the client in the Capabilities RPC to request +// that the target reports its capabilities. +// Reference: gNMI Specification Section 3.2.1 +message CapabilityRequest { + // Extension messages associated with the CapabilityRequest. See the + // gNMI extension specification for further definition. + repeated gnmi100_ext.Extension extension = 1; +} + +// CapabilityResponse is used by the target to report its capabilities to the +// client within the Capabilities RPC. +// Reference: gNMI Specification Section 3.2.2 +message CapabilityResponse { + repeated ModelData supported_models = 1; // Supported schema models. + repeated Encoding supported_encodings = 2; // Supported encodings. + string gNMI_version = 3; // Supported gNMI version. + // Extension messages associated with the CapabilityResponse. See the + // gNMI extension specification for further definition. + repeated gnmi100_ext.Extension extension = 4; +} + +// ModelData is used to describe a set of schema modules. It can be used in a +// CapabilityResponse where a target reports the set of modules that it +// supports, and within the SubscribeRequest and GetRequest messages to specify +// the set of models from which data tree elements should be reported. +// Reference: gNMI Specification Section 3.2.3 +message ModelData { + string name = 1; // Name of the model. + string organization = 2; // Organization publishing the model. + string version = 3; // Semantic version of the model. +} diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_ext.proto b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_ext.proto new file mode 100644 index 00000000..570e2eeb --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_ext.proto @@ -0,0 +1,192 @@ +// +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +syntax = "proto3"; + +import "google/protobuf/duration.proto"; + +// Package gnmi_ext defines a set of extensions messages which can be optionally +// included with the request and response messages of gNMI RPCs. A set of +// well-known extensions are defined within this file, along with a registry for +// extensions defined outside of this package. +package gnmi100_ext; + +option go_package = "github.com/openconfig/gnmi/proto/gnmi_ext"; + +// The Extension message contains a single gNMI extension. +message Extension { + oneof ext { + RegisteredExtension registered_ext = 1; // A registered extension. + // Well known extensions. + MasterArbitration master_arbitration = 2; // Master arbitration extension. + History history = 3; // History extension. + Commit commit = 4; // Commit confirmed extension. + Depth depth = 5; // Depth extension. + ConfigSubscription config_subscription = 6; // Config Subscription extension. + } +} + +// The RegisteredExtension message defines an extension which is defined outside +// of this file. +message RegisteredExtension { + ExtensionID id = 1; // The unique ID assigned to this extension. + bytes msg = 2; // The binary-marshalled protobuf extension payload. +} + +// RegisteredExtension is an enumeration acting as a registry for extensions +// defined by external sources. +enum ExtensionID { + EID_UNSET = 0; + // New extensions are to be defined within this enumeration - their definition + // MUST link to a reference describing their implementation. + + // An experimental extension that may be used during prototyping of a new + // extension. + EID_EXPERIMENTAL = 999; +} + +// MasterArbitration is used to select the master among multiple gNMI clients +// with the same Roles. The client with the largest election_id is honored as +// the master. +// The document about gNMI master arbitration can be found at +// https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-master-arbitration.md +message MasterArbitration { + Role role = 1; + Uint128 election_id = 2; +} + +// Representation of unsigned 128-bit integer. +message Uint128 { + uint64 high = 1; + uint64 low = 2; +} + +// There can be one master for each role. The role is identified by its id. +message Role { + string id = 1; + // More fields can be added if needed, for example, to specify what paths the + // role can read/write. +} + +// The History extension allows clients to request historical data. Its +// spec can be found at +// https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-history.md +message History { + oneof request { + int64 snapshot_time = 1; // Nanoseconds since the epoch + TimeRange range = 2; + } +} + +message TimeRange { + int64 start = 1; // Nanoseconds since the epoch + int64 end = 2; // Nanoseconds since the epoch +} + +// Commit confirmed extension allows automated revert of the configuration after +// certain duration if an explicit confirmation is not issued. It allows +// explicit cancellation of the commit during the rollback window. There cannot +// be more than one commit active at a given time. The document about gNMI +// commit confirmed can be found at +// https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-commit-confirmed.md +message Commit { + // ID is provided by the client during the commit request. During confirm and + // cancel actions the provided ID should match the ID provided during commit. + // If ID is not passed in any actions server shall return error. + // Required. + string id = 1; + oneof action { + // commit action creates a new commit. If a commit is on-going, server + // returns error. + CommitRequest commit = 2; + // confirm action will confirm an on-going commit, the ID provided during + // confirm should match the on-going commit ID. + CommitConfirm confirm = 3; + // cancel action will cancel an on-going commit, the ID provided during + // cancel should match the on-going commit ID. + CommitCancel cancel = 4; + // set rollback duration action sets the rollback duration of an on-going commit + // to a new value. + // The ID provided with the Commit message should match the on-going commit ID. + CommitSetRollbackDuration set_rollback_duration = 5; + } +} + +// CommitRequest is used to create a new confirmed commit. It hold additional +// parameter requried for commit action. +message CommitRequest { + // Maximum duration to wait for a confirmaton before reverting the commit. + google.protobuf.Duration rollback_duration = 1; +} + +// CommitConfirm is used to confirm an on-going commit. It hold additional +// parameter requried for confirm action. +message CommitConfirm {} + +// CommitCancel is used to cancel an on-going commit. It hold additional +// parameter requried for cancel action. +message CommitCancel {} + +// CommitSetRollbackDuration is used to set the existing rollback duration value +// of an on-going commit to a new desired value. +message CommitSetRollbackDuration { + // Maximum duration to wait for a confirmaton before reverting the commit. + google.protobuf.Duration rollback_duration = 1; +} + +// Depth allows clients to specify the depth of the subtree to be returned in +// the response. The depth is specified as the number of levels below the +// specified path. +// The depth is applied to all paths in the Get or Subscribe request. +// The document about gNMI depth can be found at +// https://github.com/openconfig/reference/tree/master/rpc/gnmi/gnmi-depth.md +message Depth { + // The level of the subtree to be returned in the response. + // Value of 0 means no depth limit and behaves the same as if the extension + // was not specified. + // Value of 1 means only the specified path and its direct children will be + // returned. + uint32 level = 1; +} + +// ConfigSubscription extension allows clients to subscribe to configuration +// schema nodes only. +message ConfigSubscription { + oneof action { + // ConfigSubscriptionStart is sent by the client in the SubscribeRequest + ConfigSubscriptionStart start = 1; + // ConfigSubscriptionSyncDone is sent by the server in the SubscribeResponse + ConfigSubscriptionSyncDone sync_done = 2; + } +} + +// ConfigSubscriptionStart is used to indicate to a target that for a given set +// of paths in the SubscribeRequest, the client wishes to receive updates +// for the configuration schema nodes only. +message ConfigSubscriptionStart {} + +// ConfigSubscriptionSyncDone is sent by the server in the SubscribeResponse +// after all the updates for the configuration schema nodes have been sent. +message ConfigSubscriptionSyncDone { + // ID of a commit confirm operation as assigned by the client + // see Commit Confirm extension for more details. + string commit_confirm_id = 1; + // ID of a commit as might be assigned by the server + // when registering a commit operation. + string server_commit_id = 2; + // If true indicates that the server is done processing the updates related to the + // commit_confirm_id and/or server_commit_id. + bool done = 3; +} diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_ext_pb2.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_ext_pb2.py new file mode 100644 index 00000000..26180a03 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_ext_pb2.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: gnmi100/gnmi_ext.proto +# Protobuf Python Version: 4.25.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16gnmi100/gnmi_ext.proto\x12\x0bgnmi100_ext\x1a\x1egoogle/protobuf/duration.proto\"\xc1\x02\n\tExtension\x12:\n\x0eregistered_ext\x18\x01 \x01(\x0b\x32 .gnmi100_ext.RegisteredExtensionH\x00\x12<\n\x12master_arbitration\x18\x02 \x01(\x0b\x32\x1e.gnmi100_ext.MasterArbitrationH\x00\x12\'\n\x07history\x18\x03 \x01(\x0b\x32\x14.gnmi100_ext.HistoryH\x00\x12%\n\x06\x63ommit\x18\x04 \x01(\x0b\x32\x13.gnmi100_ext.CommitH\x00\x12#\n\x05\x64\x65pth\x18\x05 \x01(\x0b\x32\x12.gnmi100_ext.DepthH\x00\x12>\n\x13\x63onfig_subscription\x18\x06 \x01(\x0b\x32\x1f.gnmi100_ext.ConfigSubscriptionH\x00\x42\x05\n\x03\x65xt\"H\n\x13RegisteredExtension\x12$\n\x02id\x18\x01 \x01(\x0e\x32\x18.gnmi100_ext.ExtensionID\x12\x0b\n\x03msg\x18\x02 \x01(\x0c\"_\n\x11MasterArbitration\x12\x1f\n\x04role\x18\x01 \x01(\x0b\x32\x11.gnmi100_ext.Role\x12)\n\x0b\x65lection_id\x18\x02 \x01(\x0b\x32\x14.gnmi100_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\"V\n\x07History\x12\x17\n\rsnapshot_time\x18\x01 \x01(\x03H\x00\x12\'\n\x05range\x18\x02 \x01(\x0b\x32\x16.gnmi100_ext.TimeRangeH\x00\x42\t\n\x07request\"\'\n\tTimeRange\x12\r\n\x05start\x18\x01 \x01(\x03\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x03\"\xf1\x01\n\x06\x43ommit\x12\n\n\x02id\x18\x01 \x01(\t\x12,\n\x06\x63ommit\x18\x02 \x01(\x0b\x32\x1a.gnmi100_ext.CommitRequestH\x00\x12-\n\x07\x63onfirm\x18\x03 \x01(\x0b\x32\x1a.gnmi100_ext.CommitConfirmH\x00\x12+\n\x06\x63\x61ncel\x18\x04 \x01(\x0b\x32\x19.gnmi100_ext.CommitCancelH\x00\x12G\n\x15set_rollback_duration\x18\x05 \x01(\x0b\x32&.gnmi100_ext.CommitSetRollbackDurationH\x00\x42\x08\n\x06\x61\x63tion\"E\n\rCommitRequest\x12\x34\n\x11rollback_duration\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x0f\n\rCommitConfirm\"\x0e\n\x0c\x43ommitCancel\"Q\n\x19\x43ommitSetRollbackDuration\x12\x34\n\x11rollback_duration\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x16\n\x05\x44\x65pth\x12\r\n\x05level\x18\x01 \x01(\r\"\x93\x01\n\x12\x43onfigSubscription\x12\x35\n\x05start\x18\x01 \x01(\x0b\x32$.gnmi100_ext.ConfigSubscriptionStartH\x00\x12<\n\tsync_done\x18\x02 \x01(\x0b\x32\'.gnmi100_ext.ConfigSubscriptionSyncDoneH\x00\x42\x08\n\x06\x61\x63tion\"\x19\n\x17\x43onfigSubscriptionStart\"_\n\x1a\x43onfigSubscriptionSyncDone\x12\x19\n\x11\x63ommit_confirm_id\x18\x01 \x01(\t\x12\x18\n\x10server_commit_id\x18\x02 \x01(\t\x12\x0c\n\x04\x64one\x18\x03 \x01(\x08*3\n\x0b\x45xtensionID\x12\r\n\tEID_UNSET\x10\x00\x12\x15\n\x10\x45ID_EXPERIMENTAL\x10\xe7\x07\x42+Z)github.com/openconfig/gnmi/proto/gnmi_extb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'gnmi100.gnmi_ext_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + _globals['DESCRIPTOR']._options = None + _globals['DESCRIPTOR']._serialized_options = b'Z)github.com/openconfig/gnmi/proto/gnmi_ext' + _globals['_EXTENSIONID']._serialized_start=1482 + _globals['_EXTENSIONID']._serialized_end=1533 + _globals['_EXTENSION']._serialized_start=72 + _globals['_EXTENSION']._serialized_end=393 + _globals['_REGISTEREDEXTENSION']._serialized_start=395 + _globals['_REGISTEREDEXTENSION']._serialized_end=467 + _globals['_MASTERARBITRATION']._serialized_start=469 + _globals['_MASTERARBITRATION']._serialized_end=564 + _globals['_UINT128']._serialized_start=566 + _globals['_UINT128']._serialized_end=602 + _globals['_ROLE']._serialized_start=604 + _globals['_ROLE']._serialized_end=622 + _globals['_HISTORY']._serialized_start=624 + _globals['_HISTORY']._serialized_end=710 + _globals['_TIMERANGE']._serialized_start=712 + _globals['_TIMERANGE']._serialized_end=751 + _globals['_COMMIT']._serialized_start=754 + _globals['_COMMIT']._serialized_end=995 + _globals['_COMMITREQUEST']._serialized_start=997 + _globals['_COMMITREQUEST']._serialized_end=1066 + _globals['_COMMITCONFIRM']._serialized_start=1068 + _globals['_COMMITCONFIRM']._serialized_end=1083 + _globals['_COMMITCANCEL']._serialized_start=1085 + _globals['_COMMITCANCEL']._serialized_end=1099 + _globals['_COMMITSETROLLBACKDURATION']._serialized_start=1101 + _globals['_COMMITSETROLLBACKDURATION']._serialized_end=1182 + _globals['_DEPTH']._serialized_start=1184 + _globals['_DEPTH']._serialized_end=1206 + _globals['_CONFIGSUBSCRIPTION']._serialized_start=1209 + _globals['_CONFIGSUBSCRIPTION']._serialized_end=1356 + _globals['_CONFIGSUBSCRIPTIONSTART']._serialized_start=1358 + _globals['_CONFIGSUBSCRIPTIONSTART']._serialized_end=1383 + _globals['_CONFIGSUBSCRIPTIONSYNCDONE']._serialized_start=1385 + _globals['_CONFIGSUBSCRIPTIONSYNCDONE']._serialized_end=1480 +# @@protoc_insertion_point(module_scope) diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_pb2.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_pb2.py new file mode 100644 index 00000000..175a4e29 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/gnmi100/gnmi_pb2.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: gnmi100/gnmi.proto +# Protobuf Python Version: 4.25.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 +from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 +from grpc_driver.gnmi100 import gnmi_ext_pb2 as gnmi100_dot_gnmi__ext__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x12gnmi100/gnmi.proto\x12\x07gnmi100\x1a\x19google/protobuf/any.proto\x1a google/protobuf/descriptor.proto\x1a\x16gnmi100/gnmi_ext.proto\"\x9d\x01\n\x0cNotification\x12\x11\n\ttimestamp\x18\x01 \x01(\x03\x12\x1d\n\x06prefix\x18\x02 \x01(\x0b\x32\r.gnmi100.Path\x12\x1f\n\x06update\x18\x04 \x03(\x0b\x32\x0f.gnmi100.Update\x12\x1d\n\x06\x64\x65lete\x18\x05 \x03(\x0b\x32\r.gnmi100.Path\x12\x0e\n\x06\x61tomic\x18\x06 \x01(\x08J\x04\x08\x03\x10\x04R\x05\x61lias\"~\n\x06Update\x12\x1b\n\x04path\x18\x01 \x01(\x0b\x32\r.gnmi100.Path\x12!\n\x05value\x18\x02 \x01(\x0b\x32\x0e.gnmi100.ValueB\x02\x18\x01\x12 \n\x03val\x18\x03 \x01(\x0b\x32\x13.gnmi100.TypedValue\x12\x12\n\nduplicates\x18\x04 \x01(\r\"\x89\x03\n\nTypedValue\x12\x14\n\nstring_val\x18\x01 \x01(\tH\x00\x12\x11\n\x07int_val\x18\x02 \x01(\x03H\x00\x12\x12\n\x08uint_val\x18\x03 \x01(\x04H\x00\x12\x12\n\x08\x62ool_val\x18\x04 \x01(\x08H\x00\x12\x13\n\tbytes_val\x18\x05 \x01(\x0cH\x00\x12\x17\n\tfloat_val\x18\x06 \x01(\x02\x42\x02\x18\x01H\x00\x12\x14\n\ndouble_val\x18\x0e \x01(\x01H\x00\x12-\n\x0b\x64\x65\x63imal_val\x18\x07 \x01(\x0b\x32\x12.gnmi100.Decimal64B\x02\x18\x01H\x00\x12,\n\x0cleaflist_val\x18\x08 \x01(\x0b\x32\x14.gnmi100.ScalarArrayH\x00\x12\'\n\x07\x61ny_val\x18\t \x01(\x0b\x32\x14.google.protobuf.AnyH\x00\x12\x12\n\x08json_val\x18\n \x01(\x0cH\x00\x12\x17\n\rjson_ietf_val\x18\x0b \x01(\x0cH\x00\x12\x13\n\tascii_val\x18\x0c \x01(\tH\x00\x12\x15\n\x0bproto_bytes\x18\r \x01(\x0cH\x00\x42\x07\n\x05value\"\\\n\x04Path\x12\x13\n\x07\x65lement\x18\x01 \x03(\tB\x02\x18\x01\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x1f\n\x04\x65lem\x18\x03 \x03(\x0b\x32\x11.gnmi100.PathElem\x12\x0e\n\x06target\x18\x04 \x01(\t\"m\n\x08PathElem\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\'\n\x03key\x18\x02 \x03(\x0b\x32\x1a.gnmi100.PathElem.KeyEntry\x1a*\n\x08KeyEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\";\n\x05Value\x12\r\n\x05value\x18\x01 \x01(\x0c\x12\x1f\n\x04type\x18\x02 \x01(\x0e\x32\x11.gnmi100.Encoding:\x02\x18\x01\"N\n\x05\x45rror\x12\x0c\n\x04\x63ode\x18\x01 \x01(\r\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\"\n\x04\x64\x61ta\x18\x03 \x01(\x0b\x32\x14.google.protobuf.Any:\x02\x18\x01\"2\n\tDecimal64\x12\x0e\n\x06\x64igits\x18\x01 \x01(\x03\x12\x11\n\tprecision\x18\x02 \x01(\r:\x02\x18\x01\"3\n\x0bScalarArray\x12$\n\x07\x65lement\x18\x01 \x03(\x0b\x32\x13.gnmi100.TypedValue\"\xa6\x01\n\x10SubscribeRequest\x12.\n\tsubscribe\x18\x01 \x01(\x0b\x32\x19.gnmi100.SubscriptionListH\x00\x12\x1d\n\x04poll\x18\x03 \x01(\x0b\x32\r.gnmi100.PollH\x00\x12)\n\textension\x18\x05 \x03(\x0b\x32\x16.gnmi100_ext.ExtensionB\t\n\x07requestJ\x04\x08\x04\x10\x05R\x07\x61liases\"\x06\n\x04Poll\"\xb1\x01\n\x11SubscribeResponse\x12\'\n\x06update\x18\x01 \x01(\x0b\x32\x15.gnmi100.NotificationH\x00\x12\x17\n\rsync_response\x18\x03 \x01(\x08H\x00\x12#\n\x05\x65rror\x18\x04 \x01(\x0b\x32\x0e.gnmi100.ErrorB\x02\x18\x01H\x00\x12)\n\textension\x18\x05 \x03(\x0b\x32\x16.gnmi100_ext.ExtensionB\n\n\x08response\"\xe7\x02\n\x10SubscriptionList\x12\x1d\n\x06prefix\x18\x01 \x01(\x0b\x32\r.gnmi100.Path\x12+\n\x0csubscription\x18\x02 \x03(\x0b\x32\x15.gnmi100.Subscription\x12 \n\x03qos\x18\x04 \x01(\x0b\x32\x13.gnmi100.QOSMarking\x12,\n\x04mode\x18\x05 \x01(\x0e\x32\x1e.gnmi100.SubscriptionList.Mode\x12\x19\n\x11\x61llow_aggregation\x18\x06 \x01(\x08\x12&\n\nuse_models\x18\x07 \x03(\x0b\x32\x12.gnmi100.ModelData\x12#\n\x08\x65ncoding\x18\x08 \x01(\x0e\x32\x11.gnmi100.Encoding\x12\x14\n\x0cupdates_only\x18\t \x01(\x08\"&\n\x04Mode\x12\n\n\x06STREAM\x10\x00\x12\x08\n\x04ONCE\x10\x01\x12\x08\n\x04POLL\x10\x02J\x04\x08\x03\x10\x04R\x0buse_aliases\"\xa5\x01\n\x0cSubscription\x12\x1b\n\x04path\x18\x01 \x01(\x0b\x32\r.gnmi100.Path\x12\'\n\x04mode\x18\x02 \x01(\x0e\x32\x19.gnmi100.SubscriptionMode\x12\x17\n\x0fsample_interval\x18\x03 \x01(\x04\x12\x1a\n\x12suppress_redundant\x18\x04 \x01(\x08\x12\x1a\n\x12heartbeat_interval\x18\x05 \x01(\x04\"\x1d\n\nQOSMarking\x12\x0f\n\x07marking\x18\x01 \x01(\r\"\xe0\x01\n\nSetRequest\x12\x1d\n\x06prefix\x18\x01 \x01(\x0b\x32\r.gnmi100.Path\x12\x1d\n\x06\x64\x65lete\x18\x02 \x03(\x0b\x32\r.gnmi100.Path\x12 \n\x07replace\x18\x03 \x03(\x0b\x32\x0f.gnmi100.Update\x12\x1f\n\x06update\x18\x04 \x03(\x0b\x32\x0f.gnmi100.Update\x12&\n\runion_replace\x18\x06 \x03(\x0b\x32\x0f.gnmi100.Update\x12)\n\textension\x18\x05 \x03(\x0b\x32\x16.gnmi100_ext.Extension\"\xb8\x01\n\x0bSetResponse\x12\x1d\n\x06prefix\x18\x01 \x01(\x0b\x32\r.gnmi100.Path\x12\'\n\x08response\x18\x02 \x03(\x0b\x32\x15.gnmi100.UpdateResult\x12#\n\x07message\x18\x03 \x01(\x0b\x32\x0e.gnmi100.ErrorB\x02\x18\x01\x12\x11\n\ttimestamp\x18\x04 \x01(\x03\x12)\n\textension\x18\x05 \x03(\x0b\x32\x16.gnmi100_ext.Extension\"\xe6\x01\n\x0cUpdateResult\x12\x15\n\ttimestamp\x18\x01 \x01(\x03\x42\x02\x18\x01\x12\x1b\n\x04path\x18\x02 \x01(\x0b\x32\r.gnmi100.Path\x12#\n\x07message\x18\x03 \x01(\x0b\x32\x0e.gnmi100.ErrorB\x02\x18\x01\x12+\n\x02op\x18\x04 \x01(\x0e\x32\x1f.gnmi100.UpdateResult.Operation\"P\n\tOperation\x12\x0b\n\x07INVALID\x10\x00\x12\n\n\x06\x44\x45LETE\x10\x01\x12\x0b\n\x07REPLACE\x10\x02\x12\n\n\x06UPDATE\x10\x03\x12\x11\n\rUNION_REPLACE\x10\x04\"\xa9\x02\n\nGetRequest\x12\x1d\n\x06prefix\x18\x01 \x01(\x0b\x32\r.gnmi100.Path\x12\x1b\n\x04path\x18\x02 \x03(\x0b\x32\r.gnmi100.Path\x12*\n\x04type\x18\x03 \x01(\x0e\x32\x1c.gnmi100.GetRequest.DataType\x12#\n\x08\x65ncoding\x18\x05 \x01(\x0e\x32\x11.gnmi100.Encoding\x12&\n\nuse_models\x18\x06 \x03(\x0b\x32\x12.gnmi100.ModelData\x12)\n\textension\x18\x07 \x03(\x0b\x32\x16.gnmi100_ext.Extension\";\n\x08\x44\x61taType\x12\x07\n\x03\x41LL\x10\x00\x12\n\n\x06\x43ONFIG\x10\x01\x12\t\n\x05STATE\x10\x02\x12\x0f\n\x0bOPERATIONAL\x10\x03\"\x88\x01\n\x0bGetResponse\x12+\n\x0cnotification\x18\x01 \x03(\x0b\x32\x15.gnmi100.Notification\x12!\n\x05\x65rror\x18\x02 \x01(\x0b\x32\x0e.gnmi100.ErrorB\x02\x18\x01\x12)\n\textension\x18\x03 \x03(\x0b\x32\x16.gnmi100_ext.Extension\">\n\x11\x43\x61pabilityRequest\x12)\n\textension\x18\x01 \x03(\x0b\x32\x16.gnmi100_ext.Extension\"\xb3\x01\n\x12\x43\x61pabilityResponse\x12,\n\x10supported_models\x18\x01 \x03(\x0b\x32\x12.gnmi100.ModelData\x12.\n\x13supported_encodings\x18\x02 \x03(\x0e\x32\x11.gnmi100.Encoding\x12\x14\n\x0cgNMI_version\x18\x03 \x01(\t\x12)\n\textension\x18\x04 \x03(\x0b\x32\x16.gnmi100_ext.Extension\"@\n\tModelData\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0corganization\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t*D\n\x08\x45ncoding\x12\x08\n\x04JSON\x10\x00\x12\t\n\x05\x42YTES\x10\x01\x12\t\n\x05PROTO\x10\x02\x12\t\n\x05\x41SCII\x10\x03\x12\r\n\tJSON_IETF\x10\x04*A\n\x10SubscriptionMode\x12\x12\n\x0eTARGET_DEFINED\x10\x00\x12\r\n\tON_CHANGE\x10\x01\x12\n\n\x06SAMPLE\x10\x02\x32\xfb\x01\n\x04gNMI\x12G\n\x0c\x43\x61pabilities\x12\x1a.gnmi100.CapabilityRequest\x1a\x1b.gnmi100.CapabilityResponse\x12\x30\n\x03Get\x12\x13.gnmi100.GetRequest\x1a\x14.gnmi100.GetResponse\x12\x30\n\x03Set\x12\x13.gnmi100.SetRequest\x1a\x14.gnmi100.SetResponse\x12\x46\n\tSubscribe\x12\x19.gnmi100.SubscribeRequest\x1a\x1a.gnmi100.SubscribeResponse(\x01\x30\x01\x42K\n\x15\x63om.github.gnmi.protoB\tGnmiProtoP\x01Z%github.com/openconfig/gnmi/proto/gnmib\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'gnmi100.gnmi_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + _globals['DESCRIPTOR']._options = None + _globals['DESCRIPTOR']._serialized_options = b'\n\025com.github.gnmi.protoB\tGnmiProtoP\001Z%github.com/openconfig/gnmi/proto/gnmi' + _globals['_UPDATE'].fields_by_name['value']._options = None + _globals['_UPDATE'].fields_by_name['value']._serialized_options = b'\030\001' + _globals['_TYPEDVALUE'].fields_by_name['float_val']._options = None + _globals['_TYPEDVALUE'].fields_by_name['float_val']._serialized_options = b'\030\001' + _globals['_TYPEDVALUE'].fields_by_name['decimal_val']._options = None + _globals['_TYPEDVALUE'].fields_by_name['decimal_val']._serialized_options = b'\030\001' + _globals['_PATH'].fields_by_name['element']._options = None + _globals['_PATH'].fields_by_name['element']._serialized_options = b'\030\001' + _globals['_PATHELEM_KEYENTRY']._options = None + _globals['_PATHELEM_KEYENTRY']._serialized_options = b'8\001' + _globals['_VALUE']._options = None + _globals['_VALUE']._serialized_options = b'\030\001' + _globals['_ERROR']._options = None + _globals['_ERROR']._serialized_options = b'\030\001' + _globals['_DECIMAL64']._options = None + _globals['_DECIMAL64']._serialized_options = b'\030\001' + _globals['_SUBSCRIBERESPONSE'].fields_by_name['error']._options = None + _globals['_SUBSCRIBERESPONSE'].fields_by_name['error']._serialized_options = b'\030\001' + _globals['_SETRESPONSE'].fields_by_name['message']._options = None + _globals['_SETRESPONSE'].fields_by_name['message']._serialized_options = b'\030\001' + _globals['_UPDATERESULT'].fields_by_name['timestamp']._options = None + _globals['_UPDATERESULT'].fields_by_name['timestamp']._serialized_options = b'\030\001' + _globals['_UPDATERESULT'].fields_by_name['message']._options = None + _globals['_UPDATERESULT'].fields_by_name['message']._serialized_options = b'\030\001' + _globals['_GETRESPONSE'].fields_by_name['error']._options = None + _globals['_GETRESPONSE'].fields_by_name['error']._serialized_options = b'\030\001' + _globals['_ENCODING']._serialized_start=3567 + _globals['_ENCODING']._serialized_end=3635 + _globals['_SUBSCRIPTIONMODE']._serialized_start=3637 + _globals['_SUBSCRIPTIONMODE']._serialized_end=3702 + _globals['_NOTIFICATION']._serialized_start=117 + _globals['_NOTIFICATION']._serialized_end=274 + _globals['_UPDATE']._serialized_start=276 + _globals['_UPDATE']._serialized_end=402 + _globals['_TYPEDVALUE']._serialized_start=405 + _globals['_TYPEDVALUE']._serialized_end=798 + _globals['_PATH']._serialized_start=800 + _globals['_PATH']._serialized_end=892 + _globals['_PATHELEM']._serialized_start=894 + _globals['_PATHELEM']._serialized_end=1003 + _globals['_PATHELEM_KEYENTRY']._serialized_start=961 + _globals['_PATHELEM_KEYENTRY']._serialized_end=1003 + _globals['_VALUE']._serialized_start=1005 + _globals['_VALUE']._serialized_end=1064 + _globals['_ERROR']._serialized_start=1066 + _globals['_ERROR']._serialized_end=1144 + _globals['_DECIMAL64']._serialized_start=1146 + _globals['_DECIMAL64']._serialized_end=1196 + _globals['_SCALARARRAY']._serialized_start=1198 + _globals['_SCALARARRAY']._serialized_end=1249 + _globals['_SUBSCRIBEREQUEST']._serialized_start=1252 + _globals['_SUBSCRIBEREQUEST']._serialized_end=1418 + _globals['_POLL']._serialized_start=1420 + _globals['_POLL']._serialized_end=1426 + _globals['_SUBSCRIBERESPONSE']._serialized_start=1429 + _globals['_SUBSCRIBERESPONSE']._serialized_end=1606 + _globals['_SUBSCRIPTIONLIST']._serialized_start=1609 + _globals['_SUBSCRIPTIONLIST']._serialized_end=1968 + _globals['_SUBSCRIPTIONLIST_MODE']._serialized_start=1911 + _globals['_SUBSCRIPTIONLIST_MODE']._serialized_end=1949 + _globals['_SUBSCRIPTION']._serialized_start=1971 + _globals['_SUBSCRIPTION']._serialized_end=2136 + _globals['_QOSMARKING']._serialized_start=2138 + _globals['_QOSMARKING']._serialized_end=2167 + _globals['_SETREQUEST']._serialized_start=2170 + _globals['_SETREQUEST']._serialized_end=2394 + _globals['_SETRESPONSE']._serialized_start=2397 + _globals['_SETRESPONSE']._serialized_end=2581 + _globals['_UPDATERESULT']._serialized_start=2584 + _globals['_UPDATERESULT']._serialized_end=2814 + _globals['_UPDATERESULT_OPERATION']._serialized_start=2734 + _globals['_UPDATERESULT_OPERATION']._serialized_end=2814 + _globals['_GETREQUEST']._serialized_start=2817 + _globals['_GETREQUEST']._serialized_end=3114 + _globals['_GETREQUEST_DATATYPE']._serialized_start=3055 + _globals['_GETREQUEST_DATATYPE']._serialized_end=3114 + _globals['_GETRESPONSE']._serialized_start=3117 + _globals['_GETRESPONSE']._serialized_end=3253 + _globals['_CAPABILITYREQUEST']._serialized_start=3255 + _globals['_CAPABILITYREQUEST']._serialized_end=3317 + _globals['_CAPABILITYRESPONSE']._serialized_start=3320 + _globals['_CAPABILITYRESPONSE']._serialized_end=3499 + _globals['_MODELDATA']._serialized_start=3501 + _globals['_MODELDATA']._serialized_end=3565 + _globals['_GNMI']._serialized_start=3705 + _globals['_GNMI']._serialized_end=3956 +# @@protoc_insertion_point(module_scope) diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_cfgtext.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_cfgtext.py new file mode 100644 index 00000000..b690bd4a --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_cfgtext.py @@ -0,0 +1,56 @@ +"""Configuration Manager entry points that speak brace-style text (for Golden Config).""" + +from grpc_driver import cfgtext, gnmi +from grpc_driver.errors import DriverError +from grpc_driver.ops_gnmi import as_bool, as_json, as_list +from grpc_driver.registry import operation + + +def _names(path): + if not path: + return [] + return [name + "".join(f"[{k}={v}]" for k, v in sorted(key.items())) for name, key in gnmi._split_elements(path)] + + +@operation("get-config-text", idempotent=True, + description="Configuration as brace-style text, for Config Manager backups and Golden Config") +def get_config_text(session, params): + response = gnmi.get( + session, + as_list(params.get("path")) or ["/"], + datatype="config", + encoding=params.get("encoding") or None, + origin=params.get("origin") or None, + raw=as_bool(params.get("raw")), + ) + return cfgtext.render(cfgtext.tree_from_notifications(response, _names)) + "\n" + + +def _segment(line): + return str(line).strip().strip("/") + + +@operation("set-config-text", + description="Config Manager remediation with brace-text changes: [{parents, old, new}] applied as one gNMI Set") +def set_config_text(session, params): + changes = as_json(params.get("config") or params.get("changes") or params.get("config_content"), "config") + if not isinstance(changes, list) or not changes: + raise DriverError("config must be a non-empty JSON array of {parents, old, new}", "invalid_input") + mode = (params.get("mode") or "update").lower() + if mode not in ("update", "replace", "union_replace"): + raise DriverError("mode must be update, replace or union_replace", "invalid_input") + apply, delete = [], [] + for change in changes: + base = "/" + "/".join(_segment(p) for p in change.get("parents") or [] if _segment(p)) + new, old = change.get("new"), change.get("old") + if new not in (None, ""): + apply.append((base, cfgtext.parse(new))) + elif old not in (None, ""): + prefix = base.rstrip("/") + delete.extend(f"{prefix}/{key}" for key in cfgtext.parse(old)) + if not (apply or delete): + raise DriverError("no change had a 'new' value to apply or an 'old' value to delete", "invalid_input") + gnmi.set_(session, **{mode: apply}, delete=delete, + origin=params.get("origin") or None, force=as_bool(params.get("force"))) + return [{"result": True, "parents": c.get("parents") or [], "old": c.get("old") or "", "new": c.get("new")} + for c in changes] diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_command.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_command.py new file mode 100644 index 00000000..5d463479 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_command.py @@ -0,0 +1,69 @@ +"""run-command: the action Command Templates call. One command per line. + + show version CLI command: gNMI Get with origin "cli" (Cisco IOS XR, Arista, ...) + gnmi capabilities gNMI Capabilities + gnmi get /system/config gNMI Get (a native path may carry an origin, e.g. Cisco-IOS-XR-ifmgr-cfg:interfaces) + gnoi system.Time any gNOI RPC, optionally followed by a JSON request body + gnoi system.Ping {"destination": "10.0.0.1", "count": 2} +""" + +import json + +from grpc_driver import gnmi +from grpc_driver.errors import classify +from grpc_driver.ops_gnmi import as_json, as_list +from grpc_driver.ops_gnoi import _method, invoke +from grpc_driver.registry import operation + + +def split_commands(params): + raw = params.get("command") + if raw in (None, ""): + raw = params.get("commands") + lines = [] + for item in as_list(raw): + lines += [line.strip() for line in str(item).splitlines()] + return [line for line in lines if line] + + +def _text(value): + return value if isinstance(value, str) else json.dumps(value, indent=2, default=str) + + +def _run_one(session, line): + head, _, rest = line.partition(" ") + rest = rest.strip() + if head == "gnmi": + verb, _, arg = rest.partition(" ") + if verb == "capabilities": + return _text(session.gnmi.capabilities()) + if verb == "get" and arg.strip(): + out = gnmi.get(session, [arg.strip()]) + values = [u.get("val") for n in out.get("notification", []) for u in n.get("update", [])] + return _text(values[0] if len(values) == 1 else values) + raise ValueError("gnmi commands: 'gnmi capabilities' or 'gnmi get '") + if head == "gnoi": + rpc, _, body = rest.partition(" ") + if not rpc: + raise ValueError("gnoi commands: 'gnoi [json request]'") + alias, _, stub_cls, descriptor = _method(rpc) + return _text(invoke(session, alias, stub_cls, descriptor, as_json(body.strip() or None, "request"))) + out = gnmi.get(session, [line], origin="cli") + values = [u.get("val") for n in out.get("notification", []) for u in n.get("update", [])] + return "\n".join(_text(v) for v in values if v is not None) + + +@operation("run-command", description="Command Templates: CLI (gNMI cli origin), 'gnmi ...' and 'gnoi ...' commands") +def run_command(session, params): + commands = split_commands(params) + if not commands: + return {"success": False, "host": session.spec.host, "error": "command is required for action=run-command", + "results": []} + results = [] + for line in commands: + try: + results.append({"command": line, "output": _run_one(session, line), "success": True}) + except Exception as exc: # noqa: BLE001 - one failing command must not hide the others + _, message, _ = classify(exc) + results.append({"command": line, "output": message, "success": False}) + return {"success": all(r["success"] for r in results), "host": session.spec.host, "results": results} diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_gnmi.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_gnmi.py new file mode 100644 index 00000000..ddaffd84 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_gnmi.py @@ -0,0 +1,213 @@ +"""gNMI operations (channel and Capabilities via pygnmi; Get/Set via the vendored 0.10.0 wire layer).""" + +import json + +from grpc_driver import gnmi +from grpc_driver.errors import DriverError +from grpc_driver.registry import operation + +DATATYPES = ("all", "config", "state", "operational") + + +def as_bool(value, default=False): + if value is None or value == "": + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes") + + +def as_list(value): + """Accept a list, a JSON array string, or a single scalar; return a list.""" + if value is None or value == "": + return [] + if isinstance(value, list): + return value + if isinstance(value, str): + text = value.strip() + if text.startswith("["): + try: + return json.loads(text) + except json.JSONDecodeError: + pass + return [value] + return [value] + + +def as_json(value, what="value"): + if value is None or value == "": + return None + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError as exc: + raise DriverError(f"{what} is not valid JSON: {exc}", "invalid_input") + return value + + +def _pairs(items, what): + """Normalise [[path, value], ...] or [{"path":..., "value":...}, ...] into pygnmi tuples.""" + out = [] + for item in as_list(items) if not isinstance(items, list) else items: + if isinstance(item, dict): + path, value = item.get("path"), item.get("value") + elif isinstance(item, (list, tuple)) and len(item) == 2: + path, value = item + else: + raise DriverError(f"{what} entries must be [path, value] or {{path, value}}", "invalid_input") + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + pass + out.append((path, value)) + return out + + +@operation("capabilities", idempotent=True, description="gNMI Capabilities") +def capabilities(session, params): + return session.gnmi.capabilities() + + +@operation("is-alive", idempotent=True, description="Channel + gNMI Capabilities round trip") +def is_alive(session, params): + caps = session.gnmi.capabilities() + if caps is None: + raise DriverError("Device did not answer gNMI Capabilities", "unavailable", evict=True) + return {"alive": True, "gnmi_version": caps.get("gnmi_version")} + + +@operation("get", idempotent=True, description="gNMI Get (origin / raw CLI paths supported)") +def get(session, params): + paths = as_list(params.get("path")) or ["/"] + datatype = (params.get("datatype") or "all").lower() + if datatype not in DATATYPES: + raise DriverError(f"datatype must be one of {DATATYPES}", "invalid_input") + return gnmi.get( + session, + paths, + datatype=datatype, + encoding=params.get("encoding") or None, + prefix=params.get("prefix") or "", + target=params.get("target") or None, + origin=params.get("origin") or None, + raw=as_bool(params.get("raw")), + ) + + +@operation("get-config", idempotent=True, description="gNMI Get restricted to configuration data") +def get_config(session, params): + return get(session, {**params, "datatype": "config"}) + + +@operation("set", description="gNMI Set (update / replace / union_replace / delete, applied atomically by the device)") +def set_(session, params): + update = _pairs(params.get("update"), "update") + replace = _pairs(params.get("replace"), "replace") + union_replace = _pairs(params.get("union_replace"), "union_replace") + delete = as_list(params.get("delete")) + if not (update or replace or union_replace or delete): + raise DriverError("set needs at least one of update, replace, union_replace or delete", "invalid_input") + return gnmi.set_( + session, + update=update, + replace=replace, + union_replace=union_replace, + delete=delete, + encoding=params.get("encoding") or None, + prefix=params.get("prefix") or "", + target=params.get("target") or None, + origin=params.get("origin") or None, + raw=as_bool(params.get("raw")), + force=as_bool(params.get("force")), + ) + + +# ---------------------------------------------------------------- dedicated services +# Thin entry points over ``get`` and ``set`` so each gNMI verb is its own Gateway 5 service with its own inputs. + +SET_COMMON = ("encoding", "prefix", "target", "origin", "raw", "force") + + +def _register_get(name, datatype): + @operation(name, idempotent=True, description=f"gNMI Get type={datatype.upper()}") + def _get(session, params): + return get(session, {**params, "datatype": datatype}) + + return _get + + +def _register_set(name, key, summary): + @operation(name, description=f"gNMI Set {summary}") + def _set(session, params): + if params.get(key) in (None, "", []): + raise DriverError(f"{key} is required", "invalid_input") + return set_(session, {k: v for k, v in params.items() if k in SET_COMMON or k == key}) + + return _set + + +def _register_set_all(name, key, summary): + @operation(name, description=f"gNMI Set {summary} at the root path '/'") + def _set_all(session, params): + if not as_bool(params.get("confirm")): + raise DriverError(f"{name} writes at the root path '/'; pass confirm=true to proceed", "invalid_input") + value = as_json(params.get("value"), "value") + if value is None: + raise DriverError("value is required", "invalid_input") + keep = {k: v for k, v in params.items() if k in SET_COMMON} + return set_(session, {**keep, key: [{"path": "/", "value": value}]}) + + return _set_all + + +for _name, _datatype in (("get-state", "state"), ("get-all", "all"), ("get-operational", "operational")): + _register_get(_name, _datatype) + +for _name, _key, _summary in ( + ("update-config", "update", "update[] (merge)"), + ("replace-config", "replace", "replace[] (replace the target subtree)"), + ("union-replace-config", "union_replace", "union_replace[] (gNMI 0.10.0+)"), +): + _register_set(_name, _key, _summary) + +_register_set("delete-config", "delete", "delete[] (remove the target paths)") + +for _name, _key, _summary in ( + ("update-all", "update", "update[]"), + ("replace-all", "replace", "replace[]"), + ("union-replace-all", "union_replace", "union_replace[]"), +): + _register_set_all(_name, _key, _summary) + + +@operation("set-config", description="Config Manager remediation: [{parents, old, new}] applied as one gNMI Set") +def set_config(session, params): + raw = params.get("config") or params.get("changes") or params.get("config_content") + changes = as_json(raw, "config") + if not isinstance(changes, list) or not changes: + raise DriverError("config must be a non-empty JSON array of {parents, old, new}", "invalid_input") + mode = (params.get("mode") or "update").lower() + if mode not in ("update", "replace", "union_replace"): + raise DriverError("mode must be update, replace or union_replace", "invalid_input") + apply, delete = [], [] + for change in changes: + path = "/" + "/".join(str(p).strip("/") for p in change.get("parents") or []) + new, old = change.get("new"), change.get("old") + if new not in (None, ""): + apply.append((path, as_json(new, "new") if isinstance(new, str) and new.lstrip()[:1] in "{[" else new)) + elif old not in (None, ""): + delete.append(path) + if not (apply or delete): + raise DriverError("no change had a 'new' value to apply or an 'old' value to delete", "invalid_input") + gnmi.set_( + session, + **{mode: apply}, + delete=delete, + origin=params.get("origin") or None, + force=as_bool(params.get("force")), + ) + return [ + {"result": True, "parents": c.get("parents") or [], "old": c.get("old") or "", "new": c.get("new")} + for c in changes + ] diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_gnoi.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_gnoi.py new file mode 100644 index 00000000..63f8759a --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_gnoi.py @@ -0,0 +1,143 @@ +"""gNOI (and any other compiled gRPC service) driven from protobuf service descriptors. + +Every RPC of every registered service becomes callable as ``.`` +with a JSON request body, so adding a service is one ``register_service`` call +instead of one hand-written wrapper per RPC. +""" + +import json + +from google.protobuf import json_format +from google.protobuf.descriptor import FieldDescriptor + +from grpc_driver import registry +from grpc_driver.errors import DriverError +from grpc_driver.ops_gnmi import as_bool, as_json +from grpc_driver.registry import operation, register_service, register_stub + +from gnoi.bgp import bgp_pb2, bgp_pb2_grpc +from gnoi.interface import interface_pb2, interface_pb2_grpc +from gnoi.layer2 import layer2_pb2, layer2_pb2_grpc +from gnoi.system import system_pb2, system_pb2_grpc + +for alias, pb2, grpc_mod, service in ( + ("system", system_pb2, system_pb2_grpc, "System"), + ("bgp", bgp_pb2, bgp_pb2_grpc, "BGP"), + ("interface", interface_pb2, interface_pb2_grpc, "Interface"), + ("layer2", layer2_pb2, layer2_pb2_grpc, "Layer2"), +): + register_service(alias, pb2, getattr(grpc_mod, service + "Stub"), service) + +READ_ONLY = {"system.Time", "system.Ping", "system.Traceroute", "system.RebootStatus"} +STREAM_LIMIT = 1000 + + +def _method(rpc): + try: + alias, name = rpc.split(".", 1) + pb2, stub_cls, service = registry.SERVICES[alias] + descriptor = pb2.DESCRIPTOR.services_by_name[service].methods_by_name[name] + except (ValueError, KeyError): + known = sorted( + f"{a}.{m.name}" + for a, (pb2, _, svc) in registry.SERVICES.items() + for m in pb2.DESCRIPTOR.services_by_name[svc].methods + ) + raise DriverError(f"Unknown rpc '{rpc}'. Available: {', '.join(known)}", "unknown_rpc") + return alias, name, stub_cls, descriptor + + +def invoke(session, alias, stub_cls, descriptor, request): + """Call one RPC described by a protobuf method descriptor and return JSON-ready data.""" + stub_key = f"svc:{alias}" + if stub_key not in registry.STUBS: + register_stub(stub_key, stub_cls) + rpc = getattr(session.stub(stub_key), descriptor.name) + request_cls = descriptor.input_type._concrete_class + message = json_format.ParseDict(request or {}, request_cls()) + metadata = session.metadata + timeout = session.spec.rpc_timeout + arg = iter([message]) if descriptor.client_streaming else message + response = rpc(arg, metadata=metadata, timeout=timeout) + if descriptor.server_streaming: + out = [] + for item in response: + out.append(json.loads(json_format.MessageToJson(item))) + if len(out) >= STREAM_LIMIT: + response.cancel() + break + return out + return json.loads(json_format.MessageToJson(response)) + + +@operation("gnoi", description="Invoke a gNOI RPC, e.g. rpc=system.Time request='{}'") +def gnoi(session, params): + rpc = params.get("rpc") + if not rpc: + raise DriverError("rpc is required, e.g. system.Time", "invalid_input") + alias, _, stub_cls, descriptor = _method(rpc) + return invoke(session, alias, stub_cls, descriptor, as_json(params.get("request"), "request")) + + +@operation("gnoi-time", idempotent=True, description="gNOI system.Time") +def gnoi_time(session, params): + return gnoi(session, {"rpc": "system.Time"}) + + +@operation("gnoi-reboot", description="gNOI system.Reboot") +def gnoi_reboot(session, params): + request = as_json(params.get("request"), "request") or {} + if "method" not in request and params.get("method"): + request["method"] = params["method"] + if "message" not in request and params.get("message"): + request["message"] = params["message"] + if "delay" not in request and params.get("delay"): + request["delay"] = int(params["delay"]) + return gnoi(session, {"rpc": "system.Reboot", "request": request}) + + +# ---------------------------------------------------------------- dedicated services +# One service per common system RPC. The request is built from the protobuf descriptor, so flat inputs +# (destination, count, message, ...) map by field name; anything nested goes in ``request`` as JSON. + +_INT_TYPES = (FieldDescriptor.TYPE_INT32, FieldDescriptor.TYPE_INT64, + FieldDescriptor.TYPE_UINT32, FieldDescriptor.TYPE_UINT64) + + +def _flat_request(descriptor, params): + request = dict(as_json(params.get("request"), "request") or {}) + for field in descriptor.input_type.fields: + raw = params.get(field.name) + if field.name in request or raw in (None, ""): + continue + if field.type == FieldDescriptor.TYPE_BOOL: + request[field.name] = as_bool(raw) + elif field.type in _INT_TYPES: + try: + request[field.name] = int(raw) + except (TypeError, ValueError): + raise DriverError(f"{field.name} must be an integer", "invalid_input") + elif field.type in (FieldDescriptor.TYPE_STRING, FieldDescriptor.TYPE_ENUM): + request[field.name] = str(raw) + return request + + +def _register_rpc(name, rpc, description, idempotent=False, required=()): + @operation(name, idempotent=idempotent, description=description) + def _call(session, params): + alias, _, stub_cls, descriptor = _method(rpc) + request = _flat_request(descriptor, params) + for field in required: + if field not in request: + raise DriverError(f"{field} is required", "invalid_input") + return invoke(session, alias, stub_cls, descriptor, request) + + return _call + + +_register_rpc("gnoi-ping", "system.Ping", "gNOI system.Ping (streamed replies returned as a list)", + idempotent=True, required=("destination",)) +_register_rpc("gnoi-traceroute", "system.Traceroute", "gNOI system.Traceroute (streamed hops returned as a list)", + idempotent=True, required=("destination",)) +_register_rpc("gnoi-reboot-status", "system.RebootStatus", "gNOI system.RebootStatus", idempotent=True) +_register_rpc("gnoi-cancel-reboot", "system.CancelReboot", "gNOI system.CancelReboot") diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_reflect.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_reflect.py new file mode 100644 index 00000000..3e78f05f --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_reflect.py @@ -0,0 +1,91 @@ +"""Call any gRPC method without compiled stubs, using server reflection.""" + +import json + +import grpc +from google.protobuf import descriptor_pb2, descriptor_pool, json_format, message_factory +from grpc_reflection.v1alpha import reflection_pb2, reflection_pb2_grpc + +from grpc_driver.errors import DriverError +from grpc_driver.ops_gnmi import as_json +from grpc_driver.registry import operation + + +def _pool(session): + """Descriptor pool for this channel, built once and kept for the life of a pooled session.""" + return session.cache.setdefault("reflection_pool", descriptor_pool.DescriptorPool()) + + +def _load_symbol(session, pool, symbol): + stub = reflection_pb2_grpc.ServerReflectionStub(session.channel) + + def ask(**kw): + request = reflection_pb2.ServerReflectionRequest(**kw) + for response in stub.ServerReflectionInfo(iter([request]), metadata=session.metadata, + timeout=session.spec.rpc_timeout): + return response + raise DriverError("Empty reflection response", "reflection_failed") + + response = ask(file_containing_symbol=symbol) + if response.HasField("error_response"): + raise DriverError(f"Reflection: {response.error_response.error_message}", "reflection_failed") + protos = [descriptor_pb2.FileDescriptorProto.FromString(b) for b in response.file_descriptor_response.file_descriptor_proto] + pending = {p.name: p for p in protos} + + def add(name): + proto = pending.pop(name, None) + if proto is None: + return + for dep in proto.dependency: + if dep not in pending: + try: + pool.FindFileByName(dep) + continue + except KeyError: + extra = ask(file_by_filename=dep) + for b in extra.file_descriptor_response.file_descriptor_proto: + fp = descriptor_pb2.FileDescriptorProto.FromString(b) + pending.setdefault(fp.name, fp) + add(dep) + try: + pool.Add(proto) + except Exception: + pass + + for name in list(pending): + add(name) + + +@operation("call", description="Call any gRPC method via server reflection, e.g. method=gnoi.system.System/Time") +def call(session, params): + method = params.get("method") or "" + if "/" not in method: + raise DriverError("method must look like package.Service/Method", "invalid_input") + service, name = method.strip("/").split("/", 1) + pool = _pool(session) + try: + service_desc = pool.FindServiceByName(service) + except KeyError: + _load_symbol(session, pool, service) + try: + service_desc = pool.FindServiceByName(service) + except KeyError: + raise DriverError(f"Service {service} not found via reflection", "unknown_rpc") + if name not in service_desc.methods_by_name: + raise DriverError(f"{service} has no method {name}", "unknown_rpc") + desc = service_desc.methods_by_name[name] + if desc.client_streaming: + raise DriverError("Client-streaming methods are not supported by 'call'", "unsupported") + + request_cls = message_factory.GetMessageClass(desc.input_type) + response_cls = message_factory.GetMessageClass(desc.output_type) + request = json_format.ParseDict(as_json(params.get("payload"), "payload") or {}, request_cls()) + path = f"/{service}/{name}" + kwargs = dict(request_serializer=request_cls.SerializeToString, + response_deserializer=response_cls.FromString) + metadata, timeout = session.metadata, session.spec.rpc_timeout + if desc.server_streaming: + stream = session.channel.unary_stream(path, **kwargs)(request, metadata=metadata, timeout=timeout) + return [json.loads(json_format.MessageToJson(m)) for m in stream] + reply = session.channel.unary_unary(path, **kwargs)(request, metadata=metadata, timeout=timeout) + return json.loads(json_format.MessageToJson(reply)) diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_subscribe.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_subscribe.py new file mode 100644 index 00000000..92f78346 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/ops_subscribe.py @@ -0,0 +1,105 @@ +"""gNMI Subscribe operations: ONCE, POLL and a bounded STREAM. + +A Gateway 5 ``python-script`` service has to finish, so STREAM collects for ``duration`` seconds +(or ``max_updates`` notifications) and returns what it saw. Each operation declares its own deadline +because it legitimately outlives the connection's ``rpc_timeout``. +""" + +import os + +from grpc_driver import gnmi +from grpc_driver.errors import DriverError +from grpc_driver.ops_gnmi import as_bool, as_list +from grpc_driver.registry import operation + +DEFAULT_DURATION = 10.0 +DEFAULT_MAX_UPDATES = 1000 +HARD_MAX_UPDATES = 100000 + + +def _number(params, key, default, minimum=0.0, kind=float): + raw = params.get(key) + if raw in (None, ""): + return default + try: + value = kind(raw) + except (TypeError, ValueError): + raise DriverError(f"{key} must be a number", "invalid_input") + if value < minimum: + raise DriverError(f"{key} must be >= {minimum}", "invalid_input") + return value + + +def _max_duration(): + return float(os.environ.get("GRPC_SUBSCRIBE_MAX_DURATION", "300")) + + +def _common(params): + return dict( + paths=as_list(params.get("path")) or ["/"], + encoding=params.get("encoding") or None, + prefix=params.get("prefix") or "", + target=params.get("target") or None, + origin=params.get("origin") or None, + raw=as_bool(params.get("raw")), + ) + + +def _poll_settings(params): + return _number(params, "count", 1, minimum=1, kind=int), _number(params, "interval", 1.0) + + +def _stream_duration(params): + duration = _number(params, "duration", DEFAULT_DURATION, minimum=0.1) + if duration > _max_duration(): + raise DriverError(f"duration may not exceed {_max_duration():g}s (GRPC_SUBSCRIBE_MAX_DURATION)", "invalid_input") + return duration + + +def _once_deadline(params, spec): + return spec.rpc_timeout + + +def _poll_deadline(params, spec): + count, interval = _poll_settings(params) + return count * interval + spec.rpc_timeout + + +def _stream_deadline(params, spec): + return _stream_duration(params) + spec.rpc_timeout + + +@operation("subscribe-once", idempotent=True, deadline=_once_deadline, + description="gNMI Subscribe mode=ONCE: one snapshot, returned when the device sends sync_response") +def subscribe_once(session, params): + return gnmi.subscribe(session, "once", call_timeout=session.spec.rpc_timeout, + updates_only=as_bool(params.get("updates_only")), **_common(params)) + + +@operation("subscribe-poll", idempotent=True, deadline=_poll_deadline, + description="gNMI Subscribe mode=POLL: `count` client-triggered polls, `interval` seconds apart") +def subscribe_poll(session, params): + count, interval = _poll_settings(params) + return gnmi.subscribe(session, "poll", poll_count=count, poll_interval=interval, + call_timeout=_poll_deadline(params, session.spec), + updates_only=as_bool(params.get("updates_only")), **_common(params)) + + +@operation("subscribe-stream", idempotent=True, deadline=_stream_deadline, + description="gNMI Subscribe mode=STREAM, collected for `duration` seconds or `max_updates` notifications") +def subscribe_stream(session, params): + duration = _stream_duration(params) + max_updates = _number(params, "max_updates", DEFAULT_MAX_UPDATES, minimum=1, kind=int) + if max_updates > HARD_MAX_UPDATES: + raise DriverError(f"max_updates may not exceed {HARD_MAX_UPDATES}", "invalid_input") + return gnmi.subscribe( + session, "stream", + duration=duration, max_updates=max_updates, + call_timeout=duration + session.spec.rpc_timeout, + stream_mode=(params.get("stream_mode") or "sample").lower(), + sample_interval=_number(params, "sample_interval", 0.0), + heartbeat_interval=_number(params, "heartbeat_interval", 0.0), + suppress_redundant=as_bool(params.get("suppress_redundant")), + updates_only=as_bool(params.get("updates_only")), + **_common(params), + ) diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/pool.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/pool.py new file mode 100644 index 00000000..576a266b --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/pool.py @@ -0,0 +1,161 @@ +"""Channel pool: one long-lived, multiplexed gRPC channel per device identity.""" + +import contextlib +import threading +import time + +from grpc_driver.errors import DriverError +from grpc_driver.session import Session + + +class _Limit: + def __init__(self, size): + self.size = size + self.sem = threading.BoundedSemaphore(size) + self.in_flight = 0 + self.waiting = 0 + self.rejected = 0 + + +class ChannelPool: + def __init__(self, max_size=32, idle_ttl=300, max_age=3600, factory=Session): + self.max_size = max_size + self.idle_ttl = idle_ttl + self.max_age = max_age + self._factory = factory + self._sessions = {} # key -> Session + self._leases = {} # key -> in-flight operations + self._key_locks = {} # key -> lock serialising channel creation + self._limits = {} # (host, port, user) -> _Limit + self._lock = threading.Lock() + self.hits = 0 + self.misses = 0 + + def acquire(self, spec): + """Return (session, reused). Creates and connects a channel on first use.""" + key = spec.key() + with self._lock: + key_lock = self._key_locks.setdefault(key, threading.Lock()) + with key_lock: + with self._lock: + session = self._sessions.get(key) + if session and self._usable(session): + self._lease(key, session) + self.hits += 1 + return session, True + stale = self._sessions.pop(key, None) + if stale: + stale.close() + session = self._factory(spec).open() # connect outside the global lock + with self._lock: + self.misses += 1 + self._make_room(exclude=key) + self._sessions[key] = session + self._lease(key, session) + return session, False + + @contextlib.contextmanager + def slot(self, spec, wait=None): + """Bound concurrent operations per host:port:user so a burst never exceeds the device's session limit.""" + with self._lock: + limit = self._limits.setdefault(spec.limit_key(), _Limit(max(1, int(spec.max_concurrent)))) + limit.waiting += 1 + wait = spec.rpc_timeout if wait is None else wait + got = limit.sem.acquire(timeout=wait) + with self._lock: + limit.waiting -= 1 + if got: + limit.in_flight += 1 + else: + limit.rejected += 1 + if not got: + raise DriverError( + f"{spec.label()} already has {limit.size} operations in flight for user '{spec.username}'; " + f"gave up waiting after {wait}s", + "busy", + ) + try: + yield + finally: + with self._lock: + limit.in_flight -= 1 + limit.sem.release() + + def release(self, spec, session, evict=False): + key = spec.key() + with self._lock: + self._leases[key] = max(0, self._leases.get(key, 1) - 1) + session.last_used = time.time() + current = self._sessions.get(key) is session + if evict and current: + del self._sessions[key] + if evict or not current: + session.close() + + def _lease(self, key, session): + self._leases[key] = self._leases.get(key, 0) + 1 + session.last_used = time.time() + session.uses += 1 + + def _usable(self, session): + return session.healthy() and (time.time() - session.created) < self.max_age + + def _make_room(self, exclude): + while len(self._sessions) >= self.max_size: + idle = [(s.last_used, k) for k, s in self._sessions.items() + if k != exclude and not self._leases.get(k)] + if not idle: + return # everything is busy; allow a temporary overshoot rather than fail + _, victim = min(idle) + self._sessions.pop(victim).close() + + def reap(self): + """Close idle or unhealthy channels. Returns how many were closed.""" + now = time.time() + with self._lock: + doomed = [k for k, s in self._sessions.items() + if not self._leases.get(k) and ( + now - s.last_used > self.idle_ttl or not self._usable(s))] + sessions = [self._sessions.pop(k) for k in doomed] + for s in sessions: + s.close() + return len(sessions) + + def close_all(self): + with self._lock: + sessions, self._sessions = list(self._sessions.values()), {} + for s in sessions: + s.close() + + def size(self): + with self._lock: + return len(self._sessions) + + def stats(self): + now = time.time() + with self._lock: + return { + "channels": [ + { + "target": s.spec.label(), + "username": s.spec.username, + "key": k[:8], + "age_s": round(now - s.created), + "idle_s": round(now - s.last_used), + "uses": s.uses, + "in_flight": self._leases.get(k, 0), + "healthy": s.healthy(), + } + for k, s in self._sessions.items() + ], + "limits": [ + {"target": f"{h}:{p}", "username": u, "max_concurrent": l.size, + "in_flight": l.in_flight, "waiting": l.waiting, "rejected": l.rejected} + for (h, p, u), l in self._limits.items() + ], + "hits": self.hits, + "misses": self.misses, + "max_size": self.max_size, + "idle_ttl_s": self.idle_ttl, + "max_age_s": self.max_age, + } diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/poold.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/poold.py new file mode 100644 index 00000000..08897105 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/poold.py @@ -0,0 +1,226 @@ +"""Pool daemon and its client. + +Gateway 5 starts a fresh python process for every service call, so an +in-process pool can never outlive a call. The daemon is a small background +process that owns the pool; each one-shot call talks to it over a unix socket +(newline-delimited JSON), borrows a warm channel and exits. The daemon exits +by itself after ``GRPC_POOL_DAEMON_IDLE`` seconds with no channels. +""" + +import fcntl +import hashlib +import json +import os +import socket +import socketserver +import subprocess +import sys +import tempfile +import threading +import time + +from grpc_driver import registry +from grpc_driver.errors import classify +from grpc_driver.executor import execute_pooled +from grpc_driver.pool import ChannelPool +from grpc_driver.session import ConnSpec + +PROTOCOL = 1 +MAX_REQUEST = 16 * 1024 * 1024 +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +class PoolUnavailable(Exception): + """Nothing was sent: it is safe to fall back to a direct connection.""" + + +class PoolError(Exception): + """The request may have reached the daemon: never replay it on another path.""" + + +def code_hash(): + """Hash of the driver code, so a re-pulled repo never talks to a daemon running old code.""" + digest = hashlib.sha1() + paths = [os.path.join(BASE_DIR, "main.py")] + for root in [os.path.join(BASE_DIR, "grpc_driver")] + registry.plugin_dirs(BASE_DIR): + if os.path.isdir(root): + paths += [os.path.join(root, f) for f in sorted(os.listdir(root)) if f.endswith(".py")] + for path in paths: + try: + with open(path, "rb") as fh: + digest.update(fh.read()) + except OSError: + pass + return digest.hexdigest()[:8] + + +def socket_path(): + path = os.environ.get("GRPC_POOL_SOCKET") + if not path: + # /tmp, not TMPDIR: unix socket paths are capped at ~104 bytes and macOS TMPDIR alone is ~50. + base = "/tmp" if os.path.isdir("/tmp") else tempfile.gettempdir() + directory = os.path.join(base, f"itential-grpc-pool-{os.getuid()}") + try: + os.makedirs(directory, mode=0o700, exist_ok=True) + os.chmod(directory, 0o700) + except OSError as exc: + raise PoolUnavailable(f"cannot create socket directory {directory}: {exc}") + path = os.path.join(directory, f"p-{code_hash()}.sock") + if len(path.encode()) > 100: + raise PoolUnavailable(f"socket path too long for AF_UNIX ({len(path)} bytes): {path}") + return path + + +def _env_int(name, default): + try: + return int(os.environ.get(name, default)) + except ValueError: + return default + + +class _Handler(socketserver.StreamRequestHandler): + def handle(self): + server = self.server + server.touch() + try: + line = self.rfile.readline(MAX_REQUEST) + request = json.loads(line) + reply = server.dispatch(request) + except Exception as exc: # noqa: BLE001 + etype, message, _ = classify(exc) + reply = {"ok": False, "error_type": etype, "error": message} + self.wfile.write((json.dumps(reply, default=str) + "\n").encode()) + server.touch() + + +class PoolServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): + daemon_threads = True + + def __init__(self, path, pool, idle_exit): + self.pool = pool + self.idle_exit = idle_exit + self.last_activity = time.time() + self.started = time.time() + self.path = path + super().__init__(path, _Handler) + os.chmod(path, 0o600) + + def touch(self): + self.last_activity = time.time() + + def dispatch(self, request): + if request.get("v") != PROTOCOL: + return {"ok": False, "error_type": "protocol", "error": "protocol version mismatch"} + op = request.get("op") + if op == "__status__": + status = self.pool.stats() + status.update(pid=os.getpid(), uptime_s=round(time.time() - self.started), socket=self.path, + daemon_idle_exit_s=self.idle_exit) + return {"ok": True, "result": status} + if op == "__stop__": + threading.Thread(target=self.shutdown, daemon=True).start() + return {"ok": True, "result": {"stopping": True}} + spec = ConnSpec.from_dict(request["conn"]) + try: + result, meta = execute_pooled(self.pool, spec, op, request.get("params") or {}) + return {"ok": True, "result": result, "pool": meta} + except Exception as exc: # noqa: BLE001 + etype, message, _ = classify(exc) + return {"ok": False, "error_type": etype, "error": message} + + +def _reaper(server, interval=10): + while True: + time.sleep(interval) + server.pool.reap() + idle = time.time() - server.last_activity + if server.pool.size() == 0 and idle > server.idle_exit: + server.shutdown() + return + + +def serve(): + path = socket_path() + lock = open(path + ".lock", "w") + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + return # another daemon already owns this socket + if os.path.exists(path): + os.unlink(path) # stale socket from a crashed daemon; we hold the lock + registry.load_plugins(BASE_DIR) + pool = ChannelPool( + max_size=_env_int("GRPC_POOL_MAX_SIZE", 32), + idle_ttl=_env_int("GRPC_POOL_IDLE_TTL", 300), + max_age=_env_int("GRPC_POOL_MAX_AGE", 3600), + ) + server = PoolServer(path, pool, _env_int("GRPC_POOL_DAEMON_IDLE", 600)) + threading.Thread(target=_reaper, args=(server,), daemon=True).start() + try: + server.serve_forever() + finally: + pool.close_all() + server.server_close() + try: + os.unlink(path) + except OSError: + pass + + +def _roundtrip(path, payload, timeout): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(timeout) + try: + sock.connect(path) + except OSError as exc: + raise PoolUnavailable(str(exc)) + try: + sock.sendall((json.dumps(payload) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = sock.recv(65536) + if not chunk: + break + buf += chunk + except OSError as exc: + raise PoolError(f"lost contact with the pool daemon mid-request: {exc}") + if not buf: + raise PoolError("pool daemon closed the connection without a reply") + return json.loads(buf) + + +def _spawn(path): + try: + log = open(path + ".log", "a") + except OSError as exc: + raise PoolUnavailable(f"cannot open daemon log: {exc}") + env = dict(os.environ, PYTHONPATH=BASE_DIR + os.pathsep + os.environ.get("PYTHONPATH", "")) + subprocess.Popen( + [sys.executable, "-m", "grpc_driver.poold"], + cwd=BASE_DIR, env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=log, + start_new_session=True, close_fds=True, + ) + + +def request(payload, spawn=True, wait=5.0, timeout=60.0): + """Send ``payload`` to the daemon, starting it if needed. Raises PoolUnavailable if it cannot be reached.""" + path = socket_path() + payload = dict(payload, v=PROTOCOL) + try: + return _roundtrip(path, payload, timeout) + except PoolUnavailable: + if not spawn: + raise + _spawn(path) + deadline = time.time() + wait + while time.time() < deadline: + time.sleep(0.1) + try: + return _roundtrip(path, payload, timeout) + except PoolUnavailable: + continue + raise PoolUnavailable(f"pool daemon did not start within {wait}s (see {path}.log)") + + +if __name__ == "__main__": + serve() diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/registry.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/registry.py new file mode 100644 index 00000000..3dedceef --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/registry.py @@ -0,0 +1,94 @@ +"""Operation, gNOI-service and plugin registries. + +Everything the driver can do is registered here, so extending the driver means +dropping a module into ``plugins/`` (or listing it in ``GRPC_PLUGINS``) that +calls ``@operation`` / ``register_service`` / ``register_stub``. +""" + +import importlib +import importlib.util +import os +import sys +from dataclasses import dataclass +from typing import Callable + +OPERATIONS = {} +SERVICES = {} +STUBS = {} + + +@dataclass +class Operation: + name: str + fn: Callable + idempotent: bool = False + description: str = "" + deadline: Callable = None + + +def operation(name, idempotent=False, description="", deadline=None): + """Register ``fn(session, params) -> JSON-serialisable`` as operation ``name``. + + ``idempotent`` operations are retried once on a fresh channel when a reused + pooled channel turns out to be dead; everything else fails fast so a + config change or reboot is never replayed. + + ``deadline(params, spec) -> seconds`` overrides the connection's + ``rpc_timeout`` for operations that legitimately run longer, such as a + bounded gNMI Subscribe stream. + """ + + def wrap(fn): + OPERATIONS[name] = Operation(name, fn, idempotent, description or (fn.__doc__ or "").strip(), deadline) + return fn + + return wrap + + +def deadline_for(name, params, spec): + """Seconds an operation may run: its own deadline, else the connection's ``rpc_timeout``.""" + op = OPERATIONS.get(name) + if op is not None and op.deadline is not None: + return op.deadline(params, spec) + return spec.rpc_timeout + + +def register_service(alias, pb2_module, stub_cls, service_name): + """Expose every RPC of a compiled gRPC service as ``.`` for the ``gnoi`` operation.""" + SERVICES[alias] = (pb2_module, stub_cls, service_name) + + +def register_stub(name, stub_cls): + STUBS[name] = stub_cls + + +def _import_path(path): + name = "grpc_plugin_" + os.path.splitext(os.path.basename(path))[0] + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + + +def plugin_dirs(base_dir): + dirs = [os.path.join(base_dir, "plugins")] + extra = os.environ.get("GRPC_PLUGIN_DIR", "") + dirs += [d for d in extra.split(os.pathsep) if d] + return dirs + + +def load_plugins(base_dir): + """Import built-in operation modules, then every plugin module.""" + for mod in ("grpc_driver.ops_gnmi", "grpc_driver.ops_gnoi", "grpc_driver.ops_subscribe", "grpc_driver.ops_reflect", "grpc_driver.ops_command", "grpc_driver.ops_cfgtext"): + importlib.import_module(mod) + for directory in plugin_dirs(base_dir): + if not os.path.isdir(directory): + continue + for fname in sorted(os.listdir(directory)): + if fname.endswith(".py") and not fname.startswith("_"): + _import_path(os.path.join(directory, fname)) + for item in filter(None, (s.strip() for s in os.environ.get("GRPC_PLUGINS", "").split(","))): + if item.endswith(".py") and os.path.isfile(item): + _import_path(item) + else: + importlib.import_module(item) diff --git a/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/session.py b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/session.py new file mode 100644 index 00000000..2b0ed110 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/grpc_driver/session.py @@ -0,0 +1,185 @@ +"""One authenticated gRPC channel to one device, plus the stubs built on top of it.""" + +import hashlib +import json +import threading +import time +from dataclasses import asdict, dataclass, field, fields + +import grpc + +from grpc_driver import registry +from grpc_driver.errors import DriverError + +KEEPALIVE_OPTIONS = ( + ("grpc.keepalive_time_ms", 60000), + ("grpc.keepalive_timeout_ms", 20000), + ("grpc.keepalive_permit_without_calls", 1), +) +# gRPC's 4MB default rejects large running configs. +MESSAGE_SIZE_OPTIONS = ( + ("grpc.max_receive_message_length", 64 * 1024 * 1024), + ("grpc.max_send_message_length", 64 * 1024 * 1024), +) +# Not part of a channel's identity: changing them must not open a second channel. +NON_IDENTITY_FIELDS = ("timeout", "rpc_timeout", "max_concurrent") + + +@dataclass(frozen=True) +class ConnSpec: + host: str + port: int = 57400 + username: str = "" + password: str = "" + insecure: bool = False + skip_verify: bool = False + path_cert: str = None + path_key: str = None + path_root: str = None + override: str = None + token: str = None + timeout: int = 10 # seconds to establish the channel + rpc_timeout: int = 30 # overall deadline for one operation + max_concurrent: int = 8 # in-flight operations per host:port:user (devices cap sessions per user) + grpc_options: tuple = field(default_factory=tuple) + + def key(self): + """Pool key. Includes credentials so a rotated password never reuses a stale channel.""" + identity = {k: v for k, v in asdict(self).items() if k not in NON_IDENTITY_FIELDS} + blob = json.dumps(identity, sort_keys=True, default=str) + return hashlib.sha256(blob.encode()).hexdigest() + + def limit_key(self): + return (self.host, self.port, self.username) + + def to_dict(self): + d = asdict(self) + d["grpc_options"] = [list(o) for o in self.grpc_options] + return d + + @classmethod + def from_dict(cls, data): + known = {f.name for f in fields(cls)} + data = {k: v for k, v in data.items() if k in known} + data["grpc_options"] = tuple(tuple(o) for o in data.get("grpc_options", ())) + return cls(**data) + + def label(self): + return f"{self.host}:{self.port}" + + +class Session: + """A connected channel. Safe to share between threads: gRPC multiplexes calls over one HTTP/2 connection.""" + + def __init__(self, spec): + self.spec = spec + self.created = time.time() + self.last_used = self.created + self.uses = 0 + self.cache = {} # per-channel memo (e.g. reflection descriptors) + self._client = None + self._stubs = {} + self._state = None + self._lock = threading.Lock() + + def open(self): + from pygnmi.client import gNMIclient + + options = list(self.spec.grpc_options) + have = {k for k, _ in options} + options += [o for o in KEEPALIVE_OPTIONS + MESSAGE_SIZE_OPTIONS if o[0] not in have] + try: + client = gNMIclient( + target=(self.spec.host, self.spec.port), + username=self.spec.username, + password=self.spec.password, + insecure=self.spec.insecure, + skip_verify=self.spec.skip_verify, + path_cert=self.spec.path_cert, + path_key=self.spec.path_key, + path_root=self.spec.path_root, + override=self.spec.override, + token=self.spec.token, + gnmi_timeout=self.spec.timeout, + grpc_options=options, + ) + client.connect(timeout=self.spec.timeout) + except grpc.FutureTimeoutError: + raise DriverError( + f"Timed out after {self.spec.timeout}s connecting to {self.spec.label()}", "timeout" + ) + self._client = client + self.channel.subscribe(self._on_state, try_to_connect=False) + return self + + def _on_state(self, state): + self._state = state + + @property + def gnmi(self): + return self._client + + @property + def channel(self): + return self._client._gNMIclient__channel + + @property + def metadata(self): + return self._client._gNMIclient__metadata + + def healthy(self): + return self._client is not None and self._state not in ( + grpc.ChannelConnectivity.TRANSIENT_FAILURE, + grpc.ChannelConnectivity.SHUTDOWN, + ) + + def stub(self, name): + with self._lock: + if name not in self._stubs: + cls = registry.STUBS.get(name) + if cls is None: + raise DriverError(f"Unknown stub '{name}'", "unknown_stub") + self._stubs[name] = cls(self.channel) + return self._stubs[name] + + def close(self): + client, self._client = self._client, None + if client is not None: + try: + self.channel_unsubscribe(client) + client.close() + except Exception: + pass + + def channel_unsubscribe(self, client): + try: + client._gNMIclient__channel.unsubscribe(self._on_state) + except Exception: + pass + + +def call_with_deadline(session, fn, seconds): + """Run ``fn()`` and give up after ``seconds``. + + pygnmi issues RPCs without a deadline, so a wedged device would otherwise + hang the process (or a pool thread) forever. On expiry the channel is + closed, which cancels the in-flight RPC. + """ + box = {} + + def target(): + try: + box["result"] = fn() + except BaseException as exc: # noqa: BLE001 - re-raised in the caller + box["error"] = exc + + worker = threading.Thread(target=target, daemon=True) + worker.start() + worker.join(seconds) + if worker.is_alive(): + session.close() + worker.join(2) + raise DriverError(f"Operation exceeded the {seconds}s deadline", "timeout", evict=True) + if "error" in box: + raise box["error"] + return box["result"] diff --git a/OpenConfig/gRPC/device-drivers/grpc/import.yaml b/OpenConfig/gRPC/device-drivers/grpc/import.yaml new file mode 100644 index 00000000..09387761 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/import.yaml @@ -0,0 +1,796 @@ +# Sample Gateway 5 import file for the gRPC (gNMI/gNOI) driver: the repository, +# the input decorators and all 32 services. +# +# Copy this driver folder into your own Git repository, then update the +# `repositories` entry and each service's `working-directory` +# (OpenConfig/gRPC/device-drivers/grpc) to match. +# +# export GATEWAY_APPLICATION_MODE=client +# iagctl db import import.yaml --force + +repositories: + - name: itential-assets + description: Itential community assets repository + url: https://github.com/itential/assets.git + reference: devel + +decorators: + - name: grpc-gnmi-get-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-get inputs + type: object + properties: + path: {"type": "array", "items": {"type": "string"}, "description": "gNMI paths to read (default [\"/\"])"} + datatype: {"type": "string", "description": "Data class to return", "enum": ["all", "config", "state", "operational"]} + encoding: {"type": "string", "description": "Override the encoding negotiated from Capabilities (json, json_ietf, bytes, proto, ascii)"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig, a native YANG module (Cisco-IOS-XR-ifmgr-cfg), or cli"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element (not split on / and case preserved)"} + additionalProperties: false + + - name: grpc-gnmi-get-config-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-get-config inputs + type: object + properties: + path: {"type": "array", "items": {"type": "string"}, "description": "gNMI paths to read (default [\"/\"])"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + origin: {"type": "string", "description": "Path origin applied to paths without one"} + additionalProperties: false + + - name: grpc-gnmi-set-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-set inputs + type: object + properties: + update: {"type": "array", "description": "Paths to merge, as [{path, value}]", "items": {"type": "object", "properties": {"path": {"type": "string"}, "value": {"description": "JSON value to write (object, string, number or boolean)"}}, "required": ["path", "value"], "additionalProperties": false}} + replace: {"type": "array", "description": "Paths to replace, as [{path, value}]", "items": {"type": "object", "properties": {"path": {"type": "string"}, "value": {"description": "JSON value to write (object, string, number or boolean)"}}, "required": ["path", "value"], "additionalProperties": false}} + union_replace: {"type": "array", "description": "Replace the whole configuration with the union of these [{path, value}] (gNMI 0.10.0+; refused on older targets unless force=true)", "items": {"type": "object", "properties": {"path": {"type": "string"}, "value": {"description": "JSON value to write (object, string, number or boolean)"}}, "required": ["path", "value"], "additionalProperties": false}} + delete: {"type": "array", "items": {"type": "string"}, "description": "Paths to delete"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig or a native YANG module"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + force: {"type": "string", "enum": ["true", "false"], "description": "true = send union_replace even if the device does not advertise gNMI 0.10.0 (an older device ignores it and applies nothing)"} + additionalProperties: false + + - name: grpc-gnmi-set-config-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-set-config inputs + type: object + properties: + config: {"type": "string", "x-itential-payload-type": "file", "x-itential-payload-target": "GRPC_CONFIG_FILE", "description": "JSON-encoded Config Manager changes array [{parents, old, new}]. parents joins into the gNMI path; new (JSON) is written; old with an empty new deletes the path."} + mode: {"type": "string", "enum": ["update", "replace", "union_replace"], "description": "How changes are written (default update). union_replace replaces the whole configuration with the union of the changes."} + origin: {"type": "string", "description": "Path origin for every change, e.g. a native YANG module"} + force: {"type": "string", "enum": ["true", "false"], "description": "Send union_replace even to a device that does not advertise gNMI 0.10.0"} + required: ["config"] + additionalProperties: false + + - name: grpc-run-command-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-run-command inputs + type: object + properties: + command: {"type": "string", "description": "One command per line. Plain text is a CLI command (gNMI cli origin). 'gnmi get ', 'gnmi capabilities' and 'gnoi [json]' call gNMI/gNOI."} + required: ["command"] + additionalProperties: false + + - name: grpc-gnoi-call-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnoi-call inputs + type: object + properties: + rpc: {"type": "string", "description": "gNOI RPC as ., e.g. \"system.Time\" or \"layer2.ClearSpanningTree\""} + request: {"type": "string", "description": "JSON request body (default {})"} + required: ["rpc"] + additionalProperties: false + + - name: grpc-gnoi-reboot-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnoi-reboot inputs + type: object + properties: + method: {"type": "string", "description": "RebootMethod enum name, e.g. COLD, WARM, POWERDOWN"} + delay: {"type": "integer", "description": "Delay in nanoseconds before rebooting"} + message: {"type": "string", "description": "Reason recorded on the device"} + request: {"type": "string", "description": "Full JSON RebootRequest (overrides the fields above)"} + additionalProperties: false + + - name: grpc-reflection-call-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-reflection-call inputs + type: object + properties: + method: {"type": "string", "description": "Fully-qualified method, e.g. \"gnoi.system.System/Time\""} + payload: {"type": "string", "description": "JSON request body (default {})"} + required: ["method"] + additionalProperties: false + + - name: grpc-gnmi-get-state-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-get-state inputs + type: object + properties: + path: {"type": "array", "items": {"type": "string"}, "description": "gNMI paths (default [\"/\"])"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig, a native YANG module, or cli"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element (not split on / and case preserved)"} + additionalProperties: false + + - name: grpc-gnmi-get-all-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-get-all inputs + type: object + properties: + path: {"type": "array", "items": {"type": "string"}, "description": "gNMI paths (default [\"/\"])"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig, a native YANG module, or cli"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element (not split on / and case preserved)"} + additionalProperties: false + + - name: grpc-gnmi-get-operational-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-get-operational inputs + type: object + properties: + path: {"type": "array", "items": {"type": "string"}, "description": "gNMI paths (default [\"/\"])"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig, a native YANG module, or cli"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element (not split on / and case preserved)"} + additionalProperties: false + + - name: grpc-gnmi-update-config-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-update-config inputs + type: object + properties: + update: {"type": "array", "description": "Paths to merge, as [{path, value}]", "items": {"type": "object", "properties": {"path": {"type": "string"}, "value": {"description": "JSON value to write (object, string, number or boolean)"}}, "required": ["path", "value"], "additionalProperties": false}} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig or a native YANG module"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + force: {"type": "string", "enum": ["true", "false"], "description": "true = send union_replace even if the device does not advertise gNMI 0.10.0"} + required: ["update"] + additionalProperties: false + + - name: grpc-gnmi-replace-config-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-replace-config inputs + type: object + properties: + replace: {"type": "array", "description": "Paths to replace, as [{path, value}]", "items": {"type": "object", "properties": {"path": {"type": "string"}, "value": {"description": "JSON value to write (object, string, number or boolean)"}}, "required": ["path", "value"], "additionalProperties": false}} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig or a native YANG module"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + force: {"type": "string", "enum": ["true", "false"], "description": "true = send union_replace even if the device does not advertise gNMI 0.10.0"} + required: ["replace"] + additionalProperties: false + + - name: grpc-gnmi-union-replace-config-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-union-replace-config inputs + type: object + properties: + union_replace: {"type": "array", "description": "Replace the whole configuration with the union of these [{path, value}]", "items": {"type": "object", "properties": {"path": {"type": "string"}, "value": {"description": "JSON value to write (object, string, number or boolean)"}}, "required": ["path", "value"], "additionalProperties": false}} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig or a native YANG module"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + force: {"type": "string", "enum": ["true", "false"], "description": "true = send union_replace even if the device does not advertise gNMI 0.10.0"} + required: ["union_replace"] + additionalProperties: false + + - name: grpc-gnmi-delete-config-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-delete-config inputs + type: object + properties: + delete: {"type": "array", "items": {"type": "string"}, "description": "Paths to delete"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig or a native YANG module"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + force: {"type": "string", "enum": ["true", "false"], "description": "true = send union_replace even if the device does not advertise gNMI 0.10.0"} + required: ["delete"] + additionalProperties: false + + - name: grpc-gnmi-update-all-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-update-all inputs + type: object + properties: + value: {"type": "object", "description": "JSON object written at the root path '/'"} + confirm: {"type": "string", "enum": ["true", "false"], "description": "Must be true: this writes at the root path '/'"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig or a native YANG module"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + force: {"type": "string", "enum": ["true", "false"], "description": "true = send union_replace even if the device does not advertise gNMI 0.10.0"} + required: ["value", "confirm"] + additionalProperties: false + + - name: grpc-gnmi-replace-all-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-replace-all inputs + type: object + properties: + value: {"type": "object", "description": "JSON object written at the root path '/'"} + confirm: {"type": "string", "enum": ["true", "false"], "description": "Must be true: this writes at the root path '/'"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig or a native YANG module"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + force: {"type": "string", "enum": ["true", "false"], "description": "true = send union_replace even if the device does not advertise gNMI 0.10.0"} + required: ["value", "confirm"] + additionalProperties: false + + - name: grpc-gnmi-union-replace-all-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-union-replace-all inputs + type: object + properties: + value: {"type": "object", "description": "JSON object written at the root path '/'"} + confirm: {"type": "string", "enum": ["true", "false"], "description": "Must be true: this writes at the root path '/'"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one, e.g. openconfig or a native YANG module"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + force: {"type": "string", "enum": ["true", "false"], "description": "true = send union_replace even if the device does not advertise gNMI 0.10.0"} + required: ["value", "confirm"] + additionalProperties: false + + - name: grpc-gnmi-subscribe-once-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-subscribe-once inputs + type: object + properties: + path: {"type": "array", "items": {"type": "string"}, "description": "gNMI paths (default [\"/\"])"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + updates_only: {"type": "string", "enum": ["true", "false"], "description": "true = skip the initial snapshot and return only changes"} + additionalProperties: false + + - name: grpc-gnmi-subscribe-poll-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-subscribe-poll inputs + type: object + properties: + path: {"type": "array", "items": {"type": "string"}, "description": "gNMI paths (default [\"/\"])"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + updates_only: {"type": "string", "enum": ["true", "false"], "description": "true = skip the initial snapshot and return only changes"} + count: {"type": "integer", "minimum": 1, "description": "Number of polls (default 1)"} + interval: {"type": "number", "minimum": 0, "description": "Seconds between polls (default 1)"} + additionalProperties: false + + - name: grpc-gnmi-subscribe-stream-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnmi-subscribe-stream inputs + type: object + properties: + path: {"type": "array", "items": {"type": "string"}, "description": "gNMI paths (default [\"/\"])"} + encoding: {"type": "string", "description": "Override the negotiated encoding"} + prefix: {"type": "string", "description": "gNMI path prefix"} + target: {"type": "string", "description": "gNMI target name (for proxies)"} + origin: {"type": "string", "description": "Path origin applied to paths without one"} + raw: {"type": "string", "enum": ["true", "false"], "description": "true = each path is one verbatim element"} + updates_only: {"type": "string", "enum": ["true", "false"], "description": "true = skip the initial snapshot and return only changes"} + stream_mode: {"type": "string", "enum": ["sample", "on_change", "target_defined"], "description": "Subscription mode (default sample)"} + duration: {"type": "number", "minimum": 0.1, "description": "Seconds to collect (default 10, capped by GRPC_SUBSCRIBE_MAX_DURATION)"} + max_updates: {"type": "integer", "minimum": 1, "description": "Stop after this many notifications (default 1000)"} + sample_interval: {"type": "number", "minimum": 0, "description": "Seconds between samples (0 = device default)"} + heartbeat_interval: {"type": "number", "minimum": 0, "description": "Seconds between heartbeats for on_change"} + suppress_redundant: {"type": "string", "enum": ["true", "false"], "description": "true = only send a sample when the value changed"} + additionalProperties: false + + - name: grpc-gnoi-ping-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnoi-ping inputs + type: object + properties: + destination: {"type": "string", "description": "Destination address or hostname"} + source: {"type": "string", "description": "Source address"} + count: {"type": "integer", "description": "Number of probes"} + interval: {"type": "integer", "description": "Nanoseconds between probes"} + wait: {"type": "integer", "description": "Nanoseconds to wait for a reply"} + size: {"type": "integer", "description": "Packet size in bytes"} + do_not_fragment: {"type": "string", "enum": ["true", "false"], "description": "Set the DF bit"} + do_not_resolve: {"type": "string", "enum": ["true", "false"], "description": "Do not resolve addresses to names"} + l3protocol: {"type": "string", "description": "Layer-3 protocol enum name, e.g. IPV4 or IPV6"} + request: {"type": "string", "description": "JSON request body for fields not exposed above (e.g. nested ones); explicit inputs win"} + required: ["destination"] + additionalProperties: false + + - name: grpc-gnoi-traceroute-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnoi-traceroute inputs + type: object + properties: + destination: {"type": "string", "description": "Destination address or hostname"} + source: {"type": "string", "description": "Source address"} + initial_ttl: {"type": "integer", "description": "First TTL"} + max_ttl: {"type": "integer", "description": "Maximum TTL (hops)"} + wait: {"type": "integer", "description": "Nanoseconds to wait per hop"} + do_not_fragment: {"type": "string", "enum": ["true", "false"], "description": "Set the DF bit"} + do_not_resolve: {"type": "string", "enum": ["true", "false"], "description": "Do not resolve addresses to names"} + l3protocol: {"type": "string", "description": "Layer-3 protocol enum name, e.g. IPV4 or IPV6"} + l4protocol: {"type": "string", "description": "Layer-4 protocol enum name, e.g. ICMP, TCP or UDP"} + request: {"type": "string", "description": "JSON request body for fields not exposed above (e.g. nested ones); explicit inputs win"} + required: ["destination"] + additionalProperties: false + + - name: grpc-gnoi-reboot-status-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnoi-reboot-status inputs + type: object + properties: + request: {"type": "string", "description": "JSON request body for fields not exposed above (e.g. nested ones); explicit inputs win"} + additionalProperties: false + + - name: grpc-gnoi-cancel-reboot-input + schema: + $schema: https://json-schema.org/draft/2020-12/schema + title: grpc-gnoi-cancel-reboot inputs + type: object + properties: + message: {"type": "string", "description": "Reason recorded on the device"} + request: {"type": "string", "description": "JSON request body for fields not exposed above (e.g. nested ones); explicit inputs win"} + additionalProperties: false + +services: + - name: grpc-is-alive + type: python-script + description: gRPC — Channel + gNMI Capabilities round trip; prints true/false + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + runtime: + req-file: requirements.txt + env: + GRPC_OP: is-alive + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-capabilities + type: python-script + description: gRPC — gNMI Capabilities + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + runtime: + req-file: requirements.txt + env: + GRPC_OP: capabilities + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-get + type: python-script + description: gRPC — gNMI Get + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-get-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: get + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-get-config + type: python-script + description: gRPC — gNMI Get restricted to configuration data + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-get-config-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: get-config + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-set + type: python-script + description: gRPC — gNMI Set — update / replace / union_replace / delete (atomic on the device) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-set-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: set + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-set-config + type: python-script + description: gRPC — Config Manager remediation via IM iag5-service action (broker entry point) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-set-config-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: set-config + tags: [driver, grpc, gnmi, broker] + + - name: grpc-run-command + type: python-script + description: gRPC — Command Templates entry point (CLI via gNMI cli origin, plus gnmi/gnoi commands) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-run-command-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: run-command + tags: [driver, grpc, gnmi, broker] + + - name: grpc-gnoi-call + type: python-script + description: gRPC — Invoke any gNOI RPC by name (system.*, layer2.*, bgp.*, interface.* and plugin services) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnoi-call-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: gnoi + tags: [driver, grpc, gnmi] + + - name: grpc-gnoi-time + type: python-script + description: gRPC — gNOI system.Time + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + runtime: + req-file: requirements.txt + env: + GRPC_OP: gnoi-time + tags: [driver, grpc, gnmi] + + - name: grpc-gnoi-reboot + type: python-script + description: gRPC — gNOI system.Reboot + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnoi-reboot-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: gnoi-reboot + tags: [driver, grpc, gnmi] + + - name: grpc-reflection-call + type: python-script + description: gRPC — Call any gRPC method through server reflection (no compiled stubs needed) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-reflection-call-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: call + tags: [driver, grpc, gnmi] + + - name: grpc-pool-status + type: python-script + description: gRPC — Show the connection-pool daemon's channels and hit/miss counters + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + runtime: + req-file: requirements.txt + env: + GRPC_OP: pool-status + tags: [driver, grpc, gnmi, pool] + + - name: grpc-pool-stop + type: python-script + description: gRPC — Stop the connection-pool daemon and close its channels + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + runtime: + req-file: requirements.txt + env: + GRPC_OP: pool-stop + tags: [driver, grpc, gnmi, pool] + + - name: grpc-gnmi-get-config-text + type: python-script + description: gRPC — configuration as brace-style text (Config Manager backups, Golden Config) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-get-config-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: get-config-text + tags: [driver, grpc, gnmi, broker] + + - name: grpc-gnmi-set-config-text + type: python-script + description: gRPC — Config Manager remediation with brace-text changes (Golden Config) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-set-config-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: set-config-text + tags: [driver, grpc, gnmi, broker] + + - name: grpc-gnmi-get-state + type: python-script + description: gRPC — gNMI Get type=STATE (operational state only) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-get-state-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: get-state + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-get-all + type: python-script + description: gRPC — gNMI Get type=ALL (configuration and state) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-get-all-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: get-all + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-get-operational + type: python-script + description: gRPC — gNMI Get type=OPERATIONAL + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-get-operational-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: get-operational + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-update-config + type: python-script + description: gRPC — gNMI Set update[] (merge) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-update-config-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: update-config + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-replace-config + type: python-script + description: gRPC — gNMI Set replace[] (replace the target subtree) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-replace-config-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: replace-config + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-union-replace-config + type: python-script + description: gRPC — gNMI Set union_replace[] (gNMI 0.10.0+) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-union-replace-config-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: union-replace-config + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-delete-config + type: python-script + description: gRPC — gNMI Set delete[] (remove the target paths) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-delete-config-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: delete-config + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-update-all + type: python-script + description: gRPC — gNMI Set update at the root path '/' (requires confirm=true) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-update-all-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: update-all + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-replace-all + type: python-script + description: gRPC — gNMI Set replace at the root path '/' (requires confirm=true) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-replace-all-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: replace-all + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-union-replace-all + type: python-script + description: gRPC — gNMI Set union_replace at the root path '/' (requires confirm=true) + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-union-replace-all-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: union-replace-all + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-subscribe-once + type: python-script + description: "gRPC — gNMI Subscribe ONCE: one snapshot" + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-subscribe-once-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: subscribe-once + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-subscribe-poll + type: python-script + description: "gRPC — gNMI Subscribe POLL: count polls, interval seconds apart" + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-subscribe-poll-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: subscribe-poll + tags: [driver, grpc, gnmi] + + - name: grpc-gnmi-subscribe-stream + type: python-script + description: gRPC — gNMI Subscribe STREAM, bounded by duration and max_updates + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnmi-subscribe-stream-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: subscribe-stream + tags: [driver, grpc, gnmi] + + - name: grpc-gnoi-ping + type: python-script + description: gRPC — gNOI system.Ping + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnoi-ping-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: gnoi-ping + tags: [driver, grpc, gnmi] + + - name: grpc-gnoi-traceroute + type: python-script + description: gRPC — gNOI system.Traceroute + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnoi-traceroute-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: gnoi-traceroute + tags: [driver, grpc, gnmi] + + - name: grpc-gnoi-reboot-status + type: python-script + description: gRPC — gNOI system.RebootStatus + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnoi-reboot-status-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: gnoi-reboot-status + tags: [driver, grpc, gnmi] + + - name: grpc-gnoi-cancel-reboot + type: python-script + description: gRPC — gNOI system.CancelReboot + repository: itential-assets + working-directory: OpenConfig/gRPC/device-drivers/grpc + filename: main.py + decorator: grpc-gnoi-cancel-reboot-input + runtime: + req-file: requirements.txt + env: + GRPC_OP: gnoi-cancel-reboot + tags: [driver, grpc, gnmi] diff --git a/OpenConfig/gRPC/device-drivers/grpc/main.py b/OpenConfig/gRPC/device-drivers/grpc/main.py new file mode 100644 index 00000000..ff81ccc5 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/main.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Itential Gateway 5 gRPC device driver. + +One entry point, many operations (selected with --op or the GRPC_OP env var). +Connection details come from the Inventory Manager record piped to stdin; +command line flags override them. See README.md. +""" + +import argparse +import contextlib +import json +import os +import sys + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, BASE_DIR) + +from grpc_driver import poold, registry # noqa: E402 +from grpc_driver.errors import classify # noqa: E402 +from grpc_driver.executor import execute_direct # noqa: E402 +from grpc_driver.ops_gnmi import as_bool # noqa: E402 +from grpc_driver.session import ConnSpec # noqa: E402 + +CONTROL_OPS = ("pool-status", "pool-stop") +POOL_MODES = ("auto", "off", "required") + + +def _read_stdin_inventory(): + if sys.stdin is None or sys.stdin.isatty(): + return None + try: + data = json.loads(sys.stdin.read() or "null") + nodes = (data or {}).get("inventory_nodes") or [] + return nodes[0] if nodes else None + except (json.JSONDecodeError, AttributeError): + return None + + +def _load_payload_file(params, key, env_var): + """Gateway 5.5.3+ delivers large decorator inputs as a file whose path is in an env var.""" + path = os.environ.get(env_var) + if path: + with open(path, encoding="utf-8") as fh: + params[key] = fh.read() + + +def build_parser(): + p = argparse.ArgumentParser(description=__doc__, allow_abbrev=False) + p.add_argument("--op", default=os.environ.get("GRPC_OP"), help="Operation. Defaults to the GRPC_OP env var.") + p.add_argument("--host") + p.add_argument("--port", type=int) + p.add_argument("--user") + p.add_argument("--password") + p.add_argument("--insecure") + p.add_argument("--skip-verify", "--skip_verify", dest="skip_verify") + p.add_argument("--path-cert", "--path_cert", dest="path_cert") + p.add_argument("--path-key", "--path_key", dest="path_key") + p.add_argument("--path-root", "--path_root", dest="path_root") + p.add_argument("--override") + p.add_argument("--token") + p.add_argument("--timeout", type=int, help="Seconds to establish the channel") + p.add_argument("--rpc-timeout", "--rpc_timeout", dest="rpc_timeout", type=int, + help="Overall deadline for the operation") + p.add_argument("--max-concurrent", "--max_concurrent", dest="max_concurrent", type=int, + help="Max in-flight operations per host:port:user (default 8)") + p.add_argument("--pool", choices=POOL_MODES, help="off (default), auto, or required") + p.add_argument("--envelope", help="true = print the full result envelope instead of just the result") + return p + + +def _split_extra(tokens): + """Fold leftover ``--key value`` tokens into the operation's params (repeated keys become lists).""" + params, i = {}, 0 + while i < len(tokens): + token = tokens[i] + if not token.startswith("--"): + i += 1 + continue + key, eq, value = token[2:].partition("=") + if not eq: + if i + 1 < len(tokens) and not tokens[i + 1].startswith("--"): + value = tokens[i + 1] + i += 1 + else: + value = "true" + key = key.replace("-", "_") + if key in params: + params[key] = (params[key] if isinstance(params[key], list) else [params[key]]) + [value] + else: + params[key] = value + i += 1 + # The IM/MOP framework injects empty strings for unset optional fields. + return {k: v for k, v in params.items() if v != ""} + + +def _opts(node): + attrs = (node or {}).get("attributes") or {} + options = (attrs.get("itential_driver_options") or {}).get("grpc") or {} + return attrs, options + + +def _grpc_options(raw): + if isinstance(raw, dict): + return tuple(raw.items()) + return tuple(tuple(item) for item in (raw or [])) + + +def resolve_connection(args, node): + attrs, options = _opts(node) + + def pick(flag, attr_key=None, opt_key=None, default=None): + value = getattr(args, flag, None) + if value in (None, ""): + value = attrs.get(attr_key) if attr_key else None + if value in (None, "") and opt_key: + value = options.get(opt_key) + return default if value in (None, "") else value + + host = pick("host", "itential_host") + user = pick("user", "itential_user", default="") + password = pick("password", "itential_password", default="") + token = pick("token", opt_key="token") + if not host: + raise SystemExit("host is required (inventory itential_host or --host)") + if not token and not (user and password): + raise SystemExit("credentials are required (itential_user/itential_password, or a token in driver options)") + + return ConnSpec( + host=host, + port=int(pick("port", opt_key="port") or pick("port", "itential_port", default=57400)), + username=user, + password=password, + insecure=as_bool(pick("insecure", opt_key="insecure")), + skip_verify=as_bool(pick("skip_verify", opt_key="skip_verify")), + path_cert=pick("path_cert", opt_key="path_cert"), + path_key=pick("path_key", opt_key="path_key"), + path_root=pick("path_root", opt_key="path_root"), + override=pick("override", opt_key="override"), + token=token, + timeout=int(pick("timeout", opt_key="timeout", default=10)), + rpc_timeout=int(pick("rpc_timeout", opt_key="rpc_timeout", default=30)), + max_concurrent=int(pick("max_concurrent", opt_key="max_concurrent", default=8)), + grpc_options=_grpc_options(options.get("grpc_options")), + ) + + +def pool_mode(args, node): + _, options = _opts(node) + mode = args.pool or options.get("pool") or os.environ.get("GRPC_POOL") or "off" + return mode if mode in POOL_MODES else "off" + + +def run_operation(spec, op, params, mode): + """Execute through the pool daemon when allowed, otherwise on a private channel.""" + if mode != "off": + payload = {"op": op, "conn": spec.to_dict(), "params": params} + try: + reply = poold.request(payload, timeout=spec.timeout + registry.deadline_for(op, params, spec) + 10) + except poold.PoolUnavailable as exc: + if mode == "required": + raise RuntimeError(f"connection pool required but unavailable: {exc}") + result, meta = execute_direct(spec, op, params) + meta["mode"] = "fallback" + meta["reason"] = str(exc) + return result, meta + except poold.PoolError as exc: + raise RuntimeError(str(exc)) + if not reply.get("ok"): + raise RemoteError(reply.get("error_type", "error"), reply.get("error", "unknown error")) + return reply["result"], reply.get("pool", {}) + return execute_direct(spec, op, params) + + +class RemoteError(Exception): + def __init__(self, error_type, message): + super().__init__(message) + self.error_type = error_type + + +def control(op): + action = "__status__" if op == "pool-status" else "__stop__" + try: + reply = poold.request({"op": action}, spawn=False, timeout=10) + except (poold.PoolUnavailable, poold.PoolError): + return {"running": False} + return dict(reply.get("result") or {}, running=True) if action == "__status__" else reply.get("result") + + +def emit(envelope, op, show_envelope): + if op == "is-alive" and not show_envelope: + sys.stdout.write("true" if envelope["success"] else "false") # gw-manager parses the bare string + elif show_envelope or not envelope["success"]: + print(json.dumps(envelope, indent=2, default=str)) + else: + result = envelope["result"] + print(result if isinstance(result, str) else json.dumps(result, indent=2, default=str)) + if not envelope["success"]: + print(json.dumps(envelope, default=str), file=sys.stderr) + + +def main(argv=None): + parser = build_parser() + args, extra = parser.parse_known_args(argv) + registry.load_plugins(BASE_DIR) + + op = args.op + if op in CONTROL_OPS: + print(json.dumps(control(op), indent=2)) + return 0 + if not op or op not in registry.OPERATIONS: + parser.error(f"--op / GRPC_OP must be one of: {', '.join(sorted(registry.OPERATIONS) + list(CONTROL_OPS))}") + + node = _read_stdin_inventory() + params = _split_extra(extra) + _load_payload_file(params, "config", "GRPC_CONFIG_FILE") + show_envelope = as_bool(args.envelope) + envelope = {"success": False, "op": op, "host": None, "device_name": (node or {}).get("name"), + "result": None, "error": None, "error_type": None, "pool": None} + try: + spec = resolve_connection(args, node) + envelope["host"] = spec.host + with contextlib.redirect_stdout(sys.stderr): # keep third-party prints out of the result stream + result, meta = run_operation(spec, op, params, pool_mode(args, node)) + envelope.update(success=True, result=result, pool=meta) + except RemoteError as exc: + envelope.update(error=str(exc), error_type=exc.error_type) + except SystemExit as exc: + envelope.update(error=str(exc), error_type="invalid_input") + except Exception as exc: # noqa: BLE001 + etype, message, _ = classify(exc) + envelope.update(error=message, error_type=etype) + emit(envelope, op, show_envelope) + return 0 if envelope["success"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/OpenConfig/gRPC/device-drivers/grpc/plugins/_example_plugin.py b/OpenConfig/gRPC/device-drivers/grpc/plugins/_example_plugin.py new file mode 100644 index 00000000..ee88d849 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/plugins/_example_plugin.py @@ -0,0 +1,20 @@ +"""Example plugin. Copy to plugins/.py (no leading underscore) to enable. + +Gives you two new operations without touching the driver: + * ``interface-counters``: a vendor-specific gNMI helper + * a new gRPC service reachable through ``gnoi`` as ``healthz.`` +""" + +from grpc_driver.registry import operation + +# from grpc_driver.registry import register_service +# from my_protos import healthz_pb2, healthz_pb2_grpc +# register_service("healthz", healthz_pb2, healthz_pb2_grpc.HealthzStub, "Healthz") +# -> grpc-gnoi-call with rpc=healthz.Get request='{...}' + + +@operation("interface-counters", idempotent=True, description="Read counters for one interface") +def interface_counters(session, params): + name = params.get("name") + path = f"/interfaces/interface[name={name}]/state/counters" + return session.gnmi.get(path=[path], datatype="state") diff --git a/OpenConfig/gRPC/device-drivers/grpc/requirements.txt b/OpenConfig/gRPC/device-drivers/grpc/requirements.txt new file mode 100644 index 00000000..01cae7c0 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/requirements.txt @@ -0,0 +1,4 @@ +pygnmi>=0.8.14 +grpcio>=1.60 +grpcio-reflection>=1.60 +protobuf>=4.25 diff --git a/OpenConfig/gRPC/device-drivers/grpc/tests/test_service_manifest.py b/OpenConfig/gRPC/device-drivers/grpc/tests/test_service_manifest.py new file mode 100644 index 00000000..b5491809 --- /dev/null +++ b/OpenConfig/gRPC/device-drivers/grpc/tests/test_service_manifest.py @@ -0,0 +1,65 @@ +"""The Gateway 5 service manifest and the Python operation registry must describe the same set of operations. + +The manifest is the ``import.yaml`` beside main.py. Every service names its operation in ``runtime.env.GRPC_OP``. +""" + +import os +import sys + +import pytest + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, BASE) + +from grpc_driver import registry # noqa: E402 + +yaml = pytest.importorskip("yaml") + +registry.load_plugins(BASE) + +SAMPLE = os.path.join(BASE, "import.yaml") + +import main # noqa: E402 + +# Control operations handled by main.py itself, outside the registry. +KNOWN_OPS = set(registry.OPERATIONS) | set(main.CONTROL_OPS) + + +def load(path=SAMPLE): + with open(path) as handle: + return yaml.safe_load(handle) + + +def grpc_services(path=SAMPLE): + return [s for s in load(path).get("services", []) if (s.get("runtime", {}).get("env") or {}).get("GRPC_OP")] + + +def test_every_service_points_at_a_real_operation(): + unknown = {s["name"]: s["runtime"]["env"]["GRPC_OP"] for s in grpc_services() + if s["runtime"]["env"]["GRPC_OP"] not in KNOWN_OPS} + assert not unknown, f"services whose GRPC_OP is not registered: {unknown}" + + +def test_every_operation_has_a_service(): + exposed = {s["runtime"]["env"]["GRPC_OP"] for s in grpc_services()} + missing = sorted(KNOWN_OPS - exposed) + assert not missing, f"operations with no service in the manifest: {missing}" + + +def test_service_names_and_ops_are_unique(): + services = grpc_services() + names = [s["name"] for s in services] + ops = [s["runtime"]["env"]["GRPC_OP"] for s in services] + assert len(names) == len(set(names)) and len(ops) == len(set(ops)) + + +def test_decorators_exist_and_describe_known_inputs(): + manifest = load() + decorators = {d["name"]: d["schema"] for d in manifest.get("decorators", [])} + for s in grpc_services(): + ref = s.get("decorator") + if ref: + assert ref in decorators, f"{s['name']} references missing decorator {ref}" + schema = decorators[ref] + for key in schema.get("required", []): + assert key in schema["properties"], f"{ref} requires undeclared input {key}" diff --git a/README.md b/README.md index 58ded810..ae14e6f5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Assets are organized by vendor and product. Each folder may contain one or more | **Studio Projects** | Bundles of related automation assets (workflows, forms, templates, and transformations) for a specific use case | | **OpenAPIs** | JSON-based definitions imported as Integration Models that specify how Itential Platform connects to external APIs, databases, and systems | | **Golden Configurations** | Config Manager compliance trees for auditing device configuration drift | -| **device-drivers** | Netmiko-based drivers for connecting Itential Gateway to physical and virtual devices | +| **device-drivers** | Itential Gateway 5 drivers (Python scripts) for connecting to physical and virtual devices over CLI, REST, NETCONF, or gRPC | | **Configuration Parsers** | Scripts for parsing structured output from device CLI commands | | **secret-providers** | Custom secret-provider plugin so Itential Gateway resolves credentials from an external secrets manager at runtime, instead of storing them in Gateway's own encrypted store — usable for both device inventory credentials and Gateway-executed Integration Model instances | @@ -95,6 +95,7 @@ Assets are organized by vendor and product. Each folder may contain one or more | **Okta** | Identity management | | **OP5** | Naemon/Nagios-core network and infrastructure monitoring (op5 Monitor, by ITRS Group) | | **OpenAI** | AI / LLM APIs | +| **OpenConfig** | gRPC (gNMI / gNOI) | | **OpsRamp** | IT operations management (monitoring, alerting, incident management) | | **OWASP ZAP** | Web application security scanner | | **Paessler** | PRTG monitoring |