Fix gNMI telemetry: OpenConfig paths, json_ietf encoding, SSH config

- Switch Telegraf from native IOS-XR YANG paths to OpenConfig
  (openconfig-interfaces:interfaces/interface/state/counters)
- Use json_ietf encoding instead of proto (IOS-XR 24.3.1 compat)
- Target only CORE-01/CORE-02 (R9K routers blocked by CML mgmt net)
- Update all 3 Grafana dashboard queries to match OpenConfig field
  names (in-octets, out-octets, in-pkts, out-pkts, in-errors, etc.)
- Rewrite gnmi_grpc_config.py to use SSH/CLI via paramiko instead of
  NETCONF (IOS-XR 24.3.1 rejects NETCONF gRPC edit-config)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sam 2026-03-06 16:19:16 -07:00
parent 6b45f124f0
commit c28c9b2527
5 changed files with 100 additions and 83 deletions

View File

@ -8,7 +8,9 @@ telemetry data.
What this script applies per router:
- gRPC server on port 57400 with TLS disabled
- YANG model: Cisco-IOS-XR-man-ems-cfg
Uses SSH/CLI (paramiko) instead of NETCONF because IOS-XR 24.3.1
rejects the NETCONF edit-config for gRPC with "Need to enable GRPC first".
Router targets:
CORE-01 (10.100.0.100)
@ -16,11 +18,10 @@ Router targets:
R9K-01 (10.100.0.1) through R9K-07 (10.100.0.7)
"""
from ncclient import manager
import paramiko
import time
import sys
GRPC_NS = 'http://cisco.com/ns/yang/Cisco-IOS-XR-man-ems-cfg'
ROUTERS = [
('10.100.0.100', 'CORE-01'),
('10.100.0.200', 'CORE-02'),
@ -33,38 +34,47 @@ ROUTERS = [
('10.100.0.7', 'R9K-07'),
]
GRPC_CONFIG_XML = """
<config>
<grpc xmlns="http://cisco.com/ns/yang/Cisco-IOS-XR-man-ems-cfg">
<port>57400</port>
<no-tls/>
</grpc>
</config>
"""
USERNAME = 'webui'
PASSWORD = 'cisco'
GRPC_PORT = 57400
CONFIG_COMMANDS = [
'configure terminal',
'grpc',
f'port {GRPC_PORT}',
'no-tls',
'commit',
'end',
]
def configure_router(mgmt_ip, label):
"""Apply gRPC configuration via NETCONF edit-config + commit."""
"""Apply gRPC configuration via SSH CLI."""
print(f"\n{''*60}")
print(f" Configuring {label} ({mgmt_ip})")
print(f"{''*60}")
print(f" Applying: gRPC port=57400 no-tls")
print(f" Applying: gRPC port={GRPC_PORT} no-tls")
try:
with manager.connect(
host=mgmt_ip,
port=830,
username='webui',
password='cisco',
hostkey_verify=False,
device_params={'name': 'iosxr'},
timeout=20,
) as m:
print(" → Applying gRPC configuration...")
m.edit_config(target='candidate', config=GRPC_CONFIG_XML)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(mgmt_ip, username=USERNAME, password=PASSWORD, timeout=10)
shell = client.invoke_shell()
time.sleep(1)
shell.recv(65535) # clear banner
for cmd in CONFIG_COMMANDS:
shell.send(cmd + '\n')
time.sleep(1.5)
output = shell.recv(65535).decode()
client.close()
if 'error' in output.lower() or 'fail' in output.lower():
print(f" ✗ ERROR on {label}: {output.strip()}")
return False
print(" → Committing...")
m.commit()
print(f"{label} done.")
return True
@ -74,30 +84,33 @@ def configure_router(mgmt_ip, label):
def verify_router(mgmt_ip, label):
"""Re-read running config to confirm the grpc block is present."""
"""Verify gRPC configuration via SSH."""
try:
with manager.connect(
host=mgmt_ip, port=830, username='webui', password='cisco',
hostkey_verify=False, device_params={'name': 'iosxr'}, timeout=10
) as m:
filt_grpc = """<filter>
<grpc xmlns="http://cisco.com/ns/yang/Cisco-IOS-XR-man-ems-cfg">
<port/>
<no-tls/>
</grpc>
</filter>"""
r_grpc = m.get_config(source='running', filter=filt_grpc)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(mgmt_ip, username=USERNAME, password=PASSWORD, timeout=10)
has_grpc = '<port>57400</port>' in str(r_grpc)
has_notls = '<no-tls' in str(r_grpc)
shell = client.invoke_shell()
time.sleep(1)
shell.recv(65535)
g = '' if has_grpc else ''
shell.send('show running-config grpc\n')
time.sleep(3)
output = shell.recv(65535).decode()
client.close()
has_port = f'port {GRPC_PORT}' in output
has_notls = 'no-tls' in output
p = '' if has_port else ''
t = '' if has_notls else ''
status = 'OK' if (has_grpc and has_notls) else 'INCOMPLETE'
print(f" {label:8s} grpc-port={g} no-tls={t} [{status}]")
status = 'OK' if (has_port and has_notls) else 'INCOMPLETE'
print(f" {label:8s} grpc-port={p} no-tls={t} [{status}]")
return has_port and has_notls
except Exception as e:
print(f" {label:8s} verify error: {e}")
return False
def main():
@ -111,27 +124,34 @@ def main():
ok = configure_router(mgmt_ip, label)
results.append((mgmt_ip, label, ok))
# Summary
# Verification pass
print(f"\n{'='*60}")
print("Post-apply verification")
print('='*60)
print(f" {'Router':8s} {'gRPC Port':9s} {'No-TLS':6s} Status")
for mgmt_ip, label, ok in results:
if ok:
verify_router(mgmt_ip, label)
all_ok = True
for mgmt_ip, label, applied_ok in results:
if applied_ok:
if not verify_router(mgmt_ip, label):
all_ok = False
else:
print(f" {label:8s} skipped (apply failed)")
all_ok = False
failed = [label for _, label, ok in results if not ok]
print()
if failed:
print(f"FAILED: {', '.join(failed)}")
sys.exit(1)
else:
elif all_ok:
print("All routers configured successfully.")
print()
print("gRPC is now listening on port 57400 (no TLS) on all routers.")
print(f"gRPC is now listening on port {GRPC_PORT} (no TLS) on all routers.")
print("Next: start Telegraf with gNMI input plugin to begin collecting telemetry.")
else:
print("Some routers may have incomplete configuration. Check output above.")
sys.exit(1)
if __name__ == '__main__':

View File

@ -74,7 +74,7 @@
"targets": [
{
"datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r._field == \"bytes_received\" or r._field == \"bytes_sent\")\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))",
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r._field == \"in-octets\" or r._field == \"out-octets\")\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))",
"refId": "A"
}
],

View File

@ -59,7 +59,7 @@
"targets": [
{
"datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"input_errors\" or r._field == \"output_errors\" or r._field == \"crc_errors\")\n |> derivative(unit: 1s, nonNegative: true)",
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-errors\" or r._field == \"out-errors\" or r._field == \"in-fcs-errors\")\n |> derivative(unit: 1s, nonNegative: true)",
"refId": "A"
}
],
@ -84,7 +84,7 @@
"targets": [
{
"datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"input_drops\" or r._field == \"output_drops\")\n |> derivative(unit: 1s, nonNegative: true)",
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-discards\" or r._field == \"out-discards\")\n |> derivative(unit: 1s, nonNegative: true)",
"refId": "A"
}
],
@ -102,19 +102,19 @@
"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 100}]}
},
"overrides": [
{"matcher": {"id": "byName","options": "input_errors"},"properties": [{"id": "custom.displayMode","value": "color-background-solid"},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 100}]}}]},
{"matcher": {"id": "byName","options": "output_errors"},"properties": [{"id": "custom.displayMode","value": "color-background-solid"},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 100}]}}]},
{"matcher": {"id": "byName","options": "input_drops"},"properties": [{"id": "custom.displayMode","value": "color-background-solid"},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 100}]}}]},
{"matcher": {"id": "byName","options": "output_drops"},"properties": [{"id": "custom.displayMode","value": "color-background-solid"},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 100}]}}]}
{"matcher": {"id": "byName","options": "in-errors"},"properties": [{"id": "custom.displayMode","value": "color-background-solid"},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 100}]}}]},
{"matcher": {"id": "byName","options": "out-errors"},"properties": [{"id": "custom.displayMode","value": "color-background-solid"},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 100}]}}]},
{"matcher": {"id": "byName","options": "in-discards"},"properties": [{"id": "custom.displayMode","value": "color-background-solid"},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 100}]}}]},
{"matcher": {"id": "byName","options": "out-discards"},"properties": [{"id": "custom.displayMode","value": "color-background-solid"},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 100}]}}]}
]
},
"gridPos": {"h": 12,"w": 24,"x": 0,"y": 20},
"id": 3,
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true,"sortBy": [{"desc": true,"displayName": "input_errors"}]},
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true,"sortBy": [{"desc": true,"displayName": "in-errors"}]},
"targets": [
{
"datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"input_errors\" or r._field == \"output_errors\" or r._field == \"crc_errors\" or r._field == \"input_drops\" or r._field == \"output_drops\")\n |> last()\n |> pivot(rowKey: [\"_time\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> keep(columns: [\"source\", \"name\", \"input_errors\", \"output_errors\", \"crc_errors\", \"input_drops\", \"output_drops\"])\n |> sort(columns: [\"input_errors\"], desc: true)",
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-errors\" or r._field == \"out-errors\" or r._field == \"in-fcs-errors\" or r._field == \"in-discards\" or r._field == \"out-discards\")\n |> last()\n |> pivot(rowKey: [\"_time\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> keep(columns: [\"source\", \"name\", \"in-errors\", \"out-errors\", \"in-fcs-errors\", \"in-discards\", \"out-discards\"])\n |> sort(columns: [\"in-errors\"], desc: true)",
"refId": "A"
}
],

View File

@ -59,7 +59,7 @@
"targets": [
{
"datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"bytes_received\" or r._field == \"bytes_sent\")\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))",
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-octets\" or r._field == \"out-octets\")\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))",
"refId": "A"
}
],
@ -84,7 +84,7 @@
"targets": [
{
"datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"packets_received\" or r._field == \"packets_sent\")\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))",
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-pkts\" or r._field == \"out-pkts\")\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))",
"refId": "A"
}
],
@ -109,7 +109,7 @@
"targets": [
{
"datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"bytes_received\" or r._field == \"bytes_sent\")\n |> derivative(unit: 1s, nonNegative: true)\n |> group(columns: [\"source\", \"name\", \"_field\"])\n |> sum()\n |> group(columns: [\"source\", \"name\"])\n |> sum()\n |> group()\n |> sort(columns: [\"_value\"], desc: true)\n |> limit(n: 20)",
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-octets\" or r._field == \"out-octets\")\n |> derivative(unit: 1s, nonNegative: true)\n |> group(columns: [\"source\", \"name\", \"_field\"])\n |> sum()\n |> group(columns: [\"source\", \"name\"])\n |> sum()\n |> group()\n |> sort(columns: [\"_value\"], desc: true)\n |> limit(n: 20)",
"refId": "A"
}
],
@ -122,7 +122,7 @@
"id": 4,
"options": {
"code": {"language": "plaintext","showLineNumbers": false,"showMiniMap": false},
"content": "## Interface Utilization Dashboard\n\nThis dashboard displays real-time interface utilization metrics collected via **gNMI streaming telemetry** from IOS-XR routers.\n\n- **Data source:** InfluxDB (Telegraf gNMI input plugin)\n- **YANG model:** `Cisco-IOS-XR-infra-statsd-oper`\n- **Subscription path:** `/infra-statistics/interfaces/interface/latest/generic-counters`\n- **Sample interval:** 10 seconds\n\nUse the **Router** and **Interface** template variables at the top to filter the view.",
"content": "## Interface Utilization Dashboard\n\nThis dashboard displays real-time interface utilization metrics collected via **gNMI streaming telemetry** from IOS-XR routers.\n\n- **Data source:** InfluxDB (Telegraf gNMI input plugin)\n- **YANG model:** OpenConfig (`openconfig-interfaces`)\n- **Subscription path:** `/interfaces/interface/state/counters`\n- **Sample interval:** 10 seconds\n\nUse the **Router** and **Interface** template variables at the top to filter the view.",
"mode": "markdown"
},
"title": "About",

View File

@ -17,43 +17,40 @@
# INPUT PLUGINS #
###############################################################################
## CORE routers (directly reachable on port 57400 from host)
## R9K routers (10.100.0.1-7) are blocked by CML management network filtering
[[inputs.gnmi]]
addresses = [
"10.100.0.100:57400",
"10.100.0.200:57400",
"10.100.0.1:57400",
"10.100.0.2:57400",
"10.100.0.3:57400",
"10.100.0.4:57400",
"10.100.0.5:57400",
"10.100.0.6:57400",
"10.100.0.7:57400"
"10.100.0.200:57400"
]
username = "webui"
password = "cisco"
## Do not verify the server certificate
## No TLS (lab environment)
enable_tls = false
## gNMI encoding requested (one of: "proto", "json", "json_ietf", "bytes")
encoding = "proto"
## Use json_ietf encoding (supported by IOS-XR 24.3.1)
encoding = "json_ietf"
## Redial in case of failures after
redial = "10s"
## OpenConfig interface counters (bytes, packets, errors, discards)
[[inputs.gnmi.subscription]]
name = "interface_counters"
origin = "Cisco-IOS-XR-infra-statsd-oper"
path = "/infra-statistics/interfaces/interface/latest/generic-counters"
origin = "openconfig-interfaces"
path = "/interfaces/interface/state/counters"
subscription_mode = "sample"
sample_interval = "10s"
## OpenConfig interface state (admin/oper status, description, type)
[[inputs.gnmi.subscription]]
name = "interface_rates"
origin = "Cisco-IOS-XR-infra-statsd-oper"
path = "/infra-statistics/interfaces/interface/latest/data-rate"
name = "interface_state"
origin = "openconfig-interfaces"
path = "/interfaces/interface/state"
subscription_mode = "sample"
sample_interval = "10s"
sample_interval = "30s"
###############################################################################
# OUTPUT PLUGINS #