feat(build): configure project tooling and update dependencies
- Replace placeholder deps with click, pygnmi, and rich for CLI functionality - Add fabric-orch CLI entry point via project.scripts - Configure hatchling as build backend with wheel/sdist targets - Add ruff linter configuration for code quality - Add dev dependency group with ruff - Alphabetize yang module imports
This commit is contained in:
3
src/__init__.py
Normal file
3
src/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Fabric Orchestrator source package.
|
||||
"""
|
||||
575
src/cli.py
Normal file
575
src/cli.py
Normal file
@@ -0,0 +1,575 @@
|
||||
"""
|
||||
Fabric Orchestrator CLI.
|
||||
|
||||
This module provides the CLI for fabric-orchestrator using Click.
|
||||
Main command groups:
|
||||
- discover: YANG path exploration and discovery tools
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.json import JSON
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from src.gnmi import GNMIClient, GNMIConnectionError, GNMIError, GNMIPathError
|
||||
|
||||
# =============================================================================
|
||||
# Constants
|
||||
# =============================================================================
|
||||
|
||||
console = Console()
|
||||
error_console = Console(stderr=True)
|
||||
|
||||
# Environment variable defaults
|
||||
ENV_TARGET = "GNMI_TARGET"
|
||||
ENV_USERNAME = "GNMI_USERNAME"
|
||||
ENV_PASSWORD = "GNMI_PASSWORD"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Output Handlers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def output_result(
|
||||
data: Any,
|
||||
output_format: str = "pretty",
|
||||
title: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Output result in specified format.
|
||||
|
||||
Args:
|
||||
data: Data to output
|
||||
output_format: Format - "pretty", "json", or "file:path"
|
||||
title: Optional title for pretty output
|
||||
"""
|
||||
# Convert to JSON string
|
||||
json_str = json.dumps(data, indent=2, default=str)
|
||||
|
||||
if output_format == "json":
|
||||
# Raw JSON to stdout
|
||||
click.echo(json_str)
|
||||
elif output_format.startswith("file:"):
|
||||
# Save to file
|
||||
file_path = output_format[5:]
|
||||
Path(file_path).write_text(json_str)
|
||||
console.print(f"[green]✓[/green] Results saved to [bold]{file_path}[/bold]")
|
||||
else:
|
||||
# Pretty print with rich
|
||||
if title:
|
||||
console.print(Panel(JSON(json_str), title=title, border_style="blue"))
|
||||
else:
|
||||
console.print(JSON(json_str))
|
||||
|
||||
|
||||
def parse_target(target: str) -> tuple[str, int]:
|
||||
"""Parse target string into host and port."""
|
||||
if ":" in target:
|
||||
host, port_str = target.rsplit(":", 1)
|
||||
return host, int(port_str)
|
||||
return target, 6030
|
||||
|
||||
|
||||
def handle_error(e: Exception, context: str = "") -> None:
|
||||
"""Handle and display error."""
|
||||
if isinstance(e, GNMIConnectionError):
|
||||
error_console.print(f"[red]✗ Connection Error:[/red] {e}")
|
||||
error_console.print(
|
||||
"\n[dim]Hints:[/dim]\n"
|
||||
" • Check if the target is reachable\n"
|
||||
" • Verify gNMI is enabled on the device\n"
|
||||
" • Check credentials\n"
|
||||
" • Try --insecure flag for self-signed certs"
|
||||
)
|
||||
elif isinstance(e, GNMIPathError):
|
||||
error_console.print(f"[red]✗ Path Error:[/red] {e}")
|
||||
error_console.print(
|
||||
"\n[dim]Hints:[/dim]\n"
|
||||
" • Use 'discover capabilities' to see supported models\n"
|
||||
" • Check path syntax (e.g., /interfaces/interface[name=Ethernet1])\n"
|
||||
" • Try a parent path first"
|
||||
)
|
||||
elif isinstance(e, GNMIError):
|
||||
error_console.print(f"[red]✗ gNMI Error:[/red] {e}")
|
||||
else:
|
||||
error_console.print(f"[red]✗ Error:[/red] {e}")
|
||||
|
||||
if context:
|
||||
error_console.print(f"[dim]Context: {context}[/dim]")
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI Groups
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="0.1.0", prog_name="fabric-orch")
|
||||
def cli():
|
||||
"""
|
||||
Fabric Orchestrator - Terraform-like management for Arista EVPN-VXLAN fabrics.
|
||||
Use 'fabric-orch discover' commands to explore YANG paths on devices.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@cli.group()
|
||||
def discover():
|
||||
"""
|
||||
YANG path discovery and exploration tools.
|
||||
|
||||
Use these commands to explore YANG paths, get device capabilities,
|
||||
and interactively discover configuration and state data.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Common Options
|
||||
# =============================================================================
|
||||
|
||||
def common_options(f):
|
||||
"""Common options for all discover commands."""
|
||||
f = click.option(
|
||||
"--target", "-t",
|
||||
envvar=ENV_TARGET,
|
||||
required=True,
|
||||
help="Target device (host:port). Env: GNMI_TARGET",
|
||||
)(f)
|
||||
f = click.option(
|
||||
"--username", "-u",
|
||||
envvar=ENV_USERNAME,
|
||||
default="admin",
|
||||
help="Username for authentication. Env: GNMI_USERNAME",
|
||||
)(f)
|
||||
f = click.option(
|
||||
"--password", "-p",
|
||||
envvar=ENV_PASSWORD,
|
||||
default="admin",
|
||||
help="Password for authentication. Env: GNMI_PASSWORD",
|
||||
)(f)
|
||||
f = click.option(
|
||||
"--insecure/--no-insecure",
|
||||
default=True,
|
||||
help="Skip TLS verification (default: True for lab)",
|
||||
)(f)
|
||||
f = click.option(
|
||||
"--output", "-o",
|
||||
default="pretty",
|
||||
help="Output format: pretty, json, or file:path",
|
||||
)(f)
|
||||
return f
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Discover Commands
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@discover.command("capabilities")
|
||||
@common_options
|
||||
def discover_capabilities(
|
||||
target: str,
|
||||
username: str,
|
||||
password: str,
|
||||
insecure: bool,
|
||||
output: str,
|
||||
):
|
||||
"""
|
||||
List device capabilities and supported YANG models.
|
||||
|
||||
Example:
|
||||
fabric-orch discover capabilities --target leaf1:6030
|
||||
"""
|
||||
host, port = parse_target(target)
|
||||
|
||||
try:
|
||||
with GNMIClient(
|
||||
host=host,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password,
|
||||
insecure=insecure,
|
||||
) as client:
|
||||
caps = client.capabilities()
|
||||
|
||||
if output == "pretty":
|
||||
# Pretty print capabilities
|
||||
console.print(
|
||||
f"\n[bold blue]Device Capabilities[/bold blue] - {target}\n"
|
||||
)
|
||||
console.print(f"gNMI Version: [green]{caps.get('gnmi_version', 'unknown')}[/green]")
|
||||
|
||||
# Encodings
|
||||
encodings = caps.get("supported_encodings", [])
|
||||
console.print(f"Supported Encodings: [cyan]{', '.join(encodings)}[/cyan]")
|
||||
|
||||
# Models table
|
||||
models = caps.get("supported_models", [])
|
||||
if models:
|
||||
console.print(f"\n[bold]Supported Models ({len(models)}):[/bold]\n")
|
||||
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Model Name", style="cyan")
|
||||
table.add_column("Organization")
|
||||
table.add_column("Version", style="green")
|
||||
|
||||
for model in sorted(models, key=lambda x: x.get("name", "")):
|
||||
table.add_row(
|
||||
model.get("name", ""),
|
||||
model.get("organization", ""),
|
||||
model.get("version", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
else:
|
||||
output_result(caps, output, "Device Capabilities")
|
||||
|
||||
except Exception as e:
|
||||
handle_error(e, f"Getting capabilities from {target}")
|
||||
|
||||
|
||||
@discover.command("get")
|
||||
@common_options
|
||||
@click.option(
|
||||
"--path", "-P",
|
||||
required=True,
|
||||
help="YANG path to get data from",
|
||||
)
|
||||
@click.option(
|
||||
"--type", "-T",
|
||||
"data_type",
|
||||
type=click.Choice(["config", "state", "all"]),
|
||||
default="all",
|
||||
help="Type of data to retrieve (default: all)",
|
||||
)
|
||||
@click.option(
|
||||
"--depth", "-d",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Maximum depth to explore (not implemented yet)",
|
||||
)
|
||||
def discover_get(
|
||||
target: str,
|
||||
username: str,
|
||||
password: str,
|
||||
insecure: bool,
|
||||
output: str,
|
||||
path: str,
|
||||
data_type: str,
|
||||
depth: int | None,
|
||||
):
|
||||
"""
|
||||
Get configuration or state data at a YANG path.
|
||||
|
||||
Examples:
|
||||
# Get all data at path
|
||||
fabric-orch discover get --target leaf1:6030 --path "/interfaces/interface[name=Ethernet1]"
|
||||
|
||||
# Get config only
|
||||
fabric-orch discover get --target leaf1:6030 --path "/interfaces" --type config
|
||||
|
||||
# Get state only
|
||||
fabric-orch discover get --target leaf1:6030 --path "/interfaces/interface[name=Ethernet1]/state" --type state
|
||||
|
||||
# Save to file
|
||||
fabric-orch discover get --target leaf1:6030 --path "/" --output file:result.json
|
||||
"""
|
||||
host, port = parse_target(target)
|
||||
|
||||
try:
|
||||
with GNMIClient(
|
||||
host=host,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password,
|
||||
insecure=insecure,
|
||||
) as client:
|
||||
result = client.get(path, data_type=data_type)
|
||||
|
||||
if output == "pretty":
|
||||
console.print(
|
||||
f"\n[bold blue]GET {path}[/bold blue] ({data_type})\n"
|
||||
)
|
||||
output_result(result, "pretty")
|
||||
else:
|
||||
output_result(result, output, f"GET {path}")
|
||||
|
||||
except Exception as e:
|
||||
handle_error(e, f"Getting data at {path}")
|
||||
|
||||
|
||||
@discover.command("set")
|
||||
@common_options
|
||||
@click.option(
|
||||
"--path", "-P",
|
||||
required=True,
|
||||
help="YANG path to set",
|
||||
)
|
||||
@click.option(
|
||||
"--value", "-v",
|
||||
required=True,
|
||||
help="Value to set (JSON string or simple value)",
|
||||
)
|
||||
@click.option(
|
||||
"--operation", "-O",
|
||||
type=click.Choice(["update", "replace", "delete"]),
|
||||
default="update",
|
||||
help="Set operation type (default: update)",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run/--no-dry-run",
|
||||
default=True,
|
||||
help="Dry-run mode - don't apply changes (default: True)",
|
||||
)
|
||||
def discover_set(
|
||||
target: str,
|
||||
username: str,
|
||||
password: str,
|
||||
insecure: bool,
|
||||
output: str,
|
||||
path: str,
|
||||
value: str,
|
||||
operation: str,
|
||||
dry_run: bool,
|
||||
):
|
||||
"""
|
||||
Set configuration at a YANG path.
|
||||
|
||||
By default runs in dry-run mode. Use --no-dry-run to actually apply changes.
|
||||
|
||||
Examples:
|
||||
# Dry-run (default)
|
||||
fabric-orch discover set --target leaf1:6030 \\
|
||||
--path "/interfaces/interface[name=Ethernet1]/config/description" \\
|
||||
--value "Uplink to Spine"
|
||||
|
||||
# Actually apply
|
||||
fabric-orch discover set --target leaf1:6030 \\
|
||||
--path "/interfaces/interface[name=Ethernet1]/config/description" \\
|
||||
--value "Uplink to Spine" \\
|
||||
--no-dry-run
|
||||
|
||||
# Delete config
|
||||
fabric-orch discover set --target leaf1:6030 \\
|
||||
--path "/interfaces/interface[name=Ethernet1]/config/description" \\
|
||||
--value "" --operation delete --no-dry-run
|
||||
"""
|
||||
host, port = parse_target(target)
|
||||
|
||||
if dry_run:
|
||||
console.print("[yellow]⚠ DRY-RUN MODE[/yellow] - No changes will be applied\n")
|
||||
else:
|
||||
console.print("[red]⚠ LIVE MODE[/red] - Changes WILL be applied!\n")
|
||||
|
||||
try:
|
||||
with GNMIClient(
|
||||
host=host,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password,
|
||||
insecure=insecure,
|
||||
) as client:
|
||||
result = client.set(
|
||||
path=path,
|
||||
value=value,
|
||||
operation=operation,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
if output == "pretty":
|
||||
if dry_run:
|
||||
console.print(f"[bold blue]SET (dry-run)[/bold blue] {path}\n")
|
||||
console.print(f"Operation: [cyan]{operation}[/cyan]")
|
||||
console.print(f"Value: [green]{value}[/green]")
|
||||
console.print("\n[dim]Use --no-dry-run to apply this change[/dim]")
|
||||
else:
|
||||
console.print(f"[bold green]SET (applied)[/bold green] {path}\n")
|
||||
output_result(result, "pretty")
|
||||
else:
|
||||
output_result(result, output, f"SET {path}")
|
||||
|
||||
except Exception as e:
|
||||
handle_error(e, f"Setting data at {path}")
|
||||
|
||||
|
||||
@discover.command("subscribe")
|
||||
@common_options
|
||||
@click.option(
|
||||
"--path", "-P",
|
||||
required=True,
|
||||
multiple=True,
|
||||
help="YANG path(s) to subscribe to (can specify multiple)",
|
||||
)
|
||||
@click.option(
|
||||
"--mode", "-m",
|
||||
type=click.Choice(["on-change", "sample", "target-defined"]),
|
||||
default="on-change",
|
||||
help="Subscription mode (default: on-change)",
|
||||
)
|
||||
@click.option(
|
||||
"--interval", "-i",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Sample interval in seconds (for sample mode)",
|
||||
)
|
||||
@click.option(
|
||||
"--count", "-c",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Number of updates to receive before exiting",
|
||||
)
|
||||
def discover_subscribe(
|
||||
target: str,
|
||||
username: str,
|
||||
password: str,
|
||||
insecure: bool,
|
||||
output: str,
|
||||
path: tuple[str, ...],
|
||||
mode: str,
|
||||
interval: int,
|
||||
count: int | None,
|
||||
):
|
||||
"""
|
||||
Subscribe to YANG path updates.
|
||||
|
||||
Note: For Arista EOS, use native paths WITHOUT module prefixes
|
||||
for ON_CHANGE subscriptions.
|
||||
|
||||
Examples:
|
||||
# Subscribe to interface state changes
|
||||
fabric-orch discover subscribe --target leaf1:6030 \\
|
||||
--path "/interfaces/interface/state/oper-status" \\
|
||||
--mode on-change
|
||||
|
||||
# Sample interface counters every 10 seconds
|
||||
fabric-orch discover subscribe --target leaf1:6030 \\
|
||||
--path "/interfaces/interface[name=Ethernet1]/state/counters" \\
|
||||
--mode sample --interval 10
|
||||
|
||||
# Subscribe to multiple paths
|
||||
fabric-orch discover subscribe --target leaf1:6030 \\
|
||||
--path "/interfaces/interface/state/oper-status" \\
|
||||
--path "/network-instances/network-instance/protocols/protocol/bgp/neighbors/neighbor/state/session-state"
|
||||
"""
|
||||
host, port = parse_target(target)
|
||||
paths = list(path)
|
||||
|
||||
console.print(f"\n[bold blue]Subscribing to {len(paths)} path(s)[/bold blue]\n")
|
||||
for p in paths:
|
||||
console.print(f" • {p}")
|
||||
console.print(f"\nMode: [cyan]{mode}[/cyan]")
|
||||
if mode == "sample":
|
||||
console.print(f"Interval: [cyan]{interval}s[/cyan]")
|
||||
console.print("\n[dim]Press Ctrl+C to stop[/dim]\n")
|
||||
console.print("─" * 60)
|
||||
|
||||
try:
|
||||
with GNMIClient(
|
||||
host=host,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password,
|
||||
insecure=insecure,
|
||||
) as client:
|
||||
subscription = client.subscribe(
|
||||
paths=paths,
|
||||
mode="stream",
|
||||
stream_mode=mode,
|
||||
sample_interval=interval,
|
||||
)
|
||||
|
||||
update_count = 0
|
||||
try:
|
||||
for update in subscription:
|
||||
update_count += 1
|
||||
|
||||
if output == "pretty":
|
||||
console.print(f"\n[green]Update #{update_count}[/green]")
|
||||
output_result(update, "pretty")
|
||||
else:
|
||||
output_result(update, output)
|
||||
|
||||
if count is not None and update_count >= count:
|
||||
console.print(f"\n[yellow]Reached {count} updates, stopping[/yellow]")
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print(f"\n\n[yellow]Stopped after {update_count} updates[/yellow]")
|
||||
|
||||
except Exception as e:
|
||||
handle_error(e, f"Subscribing to {paths}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Additional Commands
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@discover.command("paths")
|
||||
def discover_paths():
|
||||
"""
|
||||
Show commonly used YANG paths for Arista EOS.
|
||||
|
||||
This displays a reference of validated paths that can be used
|
||||
with the get, set, and subscribe commands.
|
||||
"""
|
||||
console.print("\n[bold blue]Common YANG Paths for Arista EOS[/bold blue]\n")
|
||||
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Category", style="cyan")
|
||||
table.add_column("Path")
|
||||
table.add_column("Notes", style="dim")
|
||||
|
||||
paths = [
|
||||
("Interfaces", "/interfaces/interface[name=Ethernet1]/state", "Full interface state"),
|
||||
("Interfaces", "/interfaces/interface[name=Ethernet1]/state/oper-status", "Operational status"),
|
||||
("Interfaces", "/interfaces/interface[name=Ethernet1]/state/counters", "Interface counters"),
|
||||
("Loopbacks", "/interfaces/interface[name=Loopback0]", "Loopback interface"),
|
||||
("VLANs", "/network-instances/network-instance[name=default]/vlans/vlan", "All VLANs"),
|
||||
("BGP", "/network-instances/network-instance[name=default]/protocols/protocol[identifier=BGP][name=BGP]/bgp/neighbors/neighbor/state", "All BGP neighbors"),
|
||||
("VXLAN", "/interfaces/interface[name=Vxlan1]/arista-vxlan/vlan-to-vnis", "VLAN-to-VNI mappings"),
|
||||
("VXLAN", "/interfaces/interface[name=Vxlan1]/arista-vxlan/config", "VXLAN config"),
|
||||
("MLAG", "/arista/eos/mlag/config", "MLAG config (config only)"),
|
||||
("EVPN", "/arista/eos/evpn", "EVPN config (config only)"),
|
||||
("System", "/system/config/hostname", "System hostname"),
|
||||
]
|
||||
|
||||
for category, path, notes in paths:
|
||||
table.add_row(category, path, notes)
|
||||
|
||||
console.print(table)
|
||||
|
||||
console.print("\n[bold]Path Syntax Tips:[/bold]")
|
||||
console.print(" • Use brackets for keys: [name=Ethernet1]")
|
||||
console.print(" • Multiple keys: [identifier=BGP][name=BGP]")
|
||||
console.print(" • Wildcards not supported in GET, but can omit keys for all")
|
||||
console.print("\n[bold]Subscription Notes:[/bold]")
|
||||
console.print(" • Use native paths WITHOUT module prefix for ON_CHANGE")
|
||||
console.print(" • ✅ /interfaces/interface[name=Ethernet1]/state")
|
||||
console.print(" • ❌ /openconfig-interfaces:interfaces/...")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Entry Point
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
183
src/gnmi/README.md
Normal file
183
src/gnmi/README.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# gNMI Client Module
|
||||
|
||||
This module provides a high-level gNMI client wrapper for interacting with Arista devices using pygnmi.
|
||||
|
||||
## Features
|
||||
|
||||
- Connection management with context manager support
|
||||
- Get capabilities (list supported YANG models)
|
||||
- Get configuration and state data at any YANG path
|
||||
- Set configuration with dry-run support
|
||||
- Subscribe to path updates (on-change, sample modes)
|
||||
- Error handling with meaningful exceptions
|
||||
|
||||
## Installation
|
||||
|
||||
Ensure you have the required dependencies:
|
||||
|
||||
```bash
|
||||
uv add pygnmi click rich
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Connection
|
||||
|
||||
```python
|
||||
from gnmi import GNMIClient
|
||||
|
||||
# Using context manager (recommended)
|
||||
with GNMIClient(
|
||||
host="172.16.0.50",
|
||||
port=6030,
|
||||
username="admin",
|
||||
password="admin",
|
||||
insecure=True
|
||||
) as client:
|
||||
# Use the client
|
||||
caps = client.capabilities()
|
||||
print(caps)
|
||||
```
|
||||
|
||||
### Get Capabilities
|
||||
|
||||
```python
|
||||
with GNMIClient(host="172.16.0.50", port=6030) as client:
|
||||
# Get raw capabilities
|
||||
caps = client.capabilities()
|
||||
print(f"gNMI Version: {caps['gnmi_version']}")
|
||||
|
||||
# Get parsed models
|
||||
models = client.get_models()
|
||||
for model in models:
|
||||
print(f" {model.name} ({model.organization}) v{model.version}")
|
||||
```
|
||||
|
||||
### Get Data
|
||||
|
||||
```python
|
||||
with GNMIClient(host="172.16.0.50", port=6030) as client:
|
||||
# Get all data (config + state)
|
||||
result = client.get("/interfaces/interface[name=Ethernet1]")
|
||||
|
||||
# Get config only
|
||||
result = client.get(
|
||||
"/interfaces/interface[name=Ethernet1]",
|
||||
data_type="config"
|
||||
)
|
||||
|
||||
# Get state only
|
||||
result = client.get(
|
||||
"/interfaces/interface[name=Ethernet1]/state",
|
||||
data_type="state"
|
||||
)
|
||||
|
||||
# Get multiple paths
|
||||
result = client.get([
|
||||
"/interfaces/interface[name=Ethernet1]/state",
|
||||
"/interfaces/interface[name=Ethernet2]/state",
|
||||
])
|
||||
```
|
||||
|
||||
### Set Configuration
|
||||
|
||||
```python
|
||||
with GNMIClient(host="172.16.0.50", port=6030) as client:
|
||||
# Dry-run (default) - shows what would be set
|
||||
result = client.set(
|
||||
path="/interfaces/interface[name=Ethernet1]/config/description",
|
||||
value="Uplink to Spine",
|
||||
dry_run=True
|
||||
)
|
||||
|
||||
# Actually apply the change
|
||||
result = client.set(
|
||||
path="/interfaces/interface[name=Ethernet1]/config/description",
|
||||
value="Uplink to Spine",
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
# Replace operation
|
||||
result = client.set(
|
||||
path="/interfaces/interface[name=Ethernet1]/config",
|
||||
value={"description": "New config", "enabled": True},
|
||||
operation="replace",
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
# Delete operation
|
||||
result = client.set(
|
||||
path="/interfaces/interface[name=Ethernet1]/config/description",
|
||||
value=None,
|
||||
operation="delete",
|
||||
dry_run=False
|
||||
)
|
||||
```
|
||||
|
||||
### Subscribe to Updates
|
||||
|
||||
```python
|
||||
with GNMIClient(host="172.16.0.50", port=6030) as client:
|
||||
# Subscribe to interface state changes
|
||||
subscription = client.subscribe(
|
||||
paths="/interfaces/interface/state/oper-status",
|
||||
mode="stream",
|
||||
stream_mode="on-change"
|
||||
)
|
||||
|
||||
# Process updates
|
||||
for update in subscription:
|
||||
print(update)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The module provides specific exceptions for different error types:
|
||||
|
||||
```python
|
||||
from gnmi import GNMIClient, GNMIError, GNMIConnectionError, GNMIPathError
|
||||
|
||||
try:
|
||||
with GNMIClient(host="172.16.0.50", port=6030) as client:
|
||||
result = client.get("/invalid/path")
|
||||
except GNMIConnectionError as e:
|
||||
print(f"Connection failed: {e}")
|
||||
except GNMIPathError as e:
|
||||
print(f"Invalid path: {e}")
|
||||
except GNMIError as e:
|
||||
print(f"gNMI error: {e}")
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The CLI supports these environment variables for defaults:
|
||||
|
||||
- `GNMI_TARGET` - Target device (host:port)
|
||||
- `GNMI_USERNAME` - Username for authentication
|
||||
- `GNMI_PASSWORD` - Password for authentication
|
||||
|
||||
## Path Format Notes
|
||||
|
||||
For Arista EOS, use native paths **without** module prefixes for subscriptions:
|
||||
|
||||
```python
|
||||
# ✅ Correct - native path
|
||||
"/interfaces/interface[name=Ethernet1]/state"
|
||||
|
||||
# ❌ Incorrect - with module prefix (may fail for subscriptions)
|
||||
"/openconfig-interfaces:interfaces/interface[name=Ethernet1]/state"
|
||||
```
|
||||
|
||||
## Validated Paths
|
||||
|
||||
These paths have been validated against Arista cEOS 4.35.0F:
|
||||
|
||||
| Category | Path | Notes |
|
||||
|----------|------|-------|
|
||||
| Interfaces | `/interfaces/interface[name=Ethernet1]/state` | Full state |
|
||||
| BGP | `/network-instances/network-instance[name=default]/protocols/protocol[identifier=BGP][name=BGP]/bgp/neighbors/neighbor/state` | All neighbors |
|
||||
| VXLAN | `/interfaces/interface[name=Vxlan1]/arista-vxlan/vlan-to-vnis` | VNI mappings |
|
||||
| MLAG | `/arista/eos/mlag/config` | Config only |
|
||||
| EVPN | `/arista/eos/evpn` | Config only |
|
||||
|
||||
See `docs/yang-paths.md` for complete path documentation.
|
||||
23
src/gnmi/__init__.py
Normal file
23
src/gnmi/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
gNMI Client Module for Fabric Orchestrator.
|
||||
|
||||
This module provides a gNMI client wrapper for interacting with
|
||||
Arista devices using pygnmi.
|
||||
|
||||
Usage:
|
||||
from gnmi import GNMIClient
|
||||
|
||||
async with GNMIClient(
|
||||
host="leaf1",
|
||||
port=6030,
|
||||
username="admin",
|
||||
password="admin",
|
||||
insecure=True
|
||||
) as client:
|
||||
caps = await client.capabilities()
|
||||
print(caps)
|
||||
"""
|
||||
|
||||
from src.gnmi.client import GNMIClient, GNMIConnectionError, GNMIError, GNMIPathError
|
||||
|
||||
__all__ = ["GNMIClient", "GNMIError", "GNMIConnectionError", "GNMIPathError"]
|
||||
396
src/gnmi/client.py
Normal file
396
src/gnmi/client.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
gNMI Client Wrapper for Fabric Orchestrator.
|
||||
|
||||
This module provides a high-level gNMI client using pygnmi with:
|
||||
- Connection management
|
||||
- Async context manager support
|
||||
- Error handling with meaningful exceptions
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from pygnmi.client import gNMIclient
|
||||
|
||||
# =============================================================================
|
||||
# Exceptions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GNMIError(Exception):
|
||||
"""Base exception for gNMI operations."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GNMIConnectionError(GNMIError):
|
||||
"""Raised when connection to device fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GNMIPathError(GNMIError):
|
||||
"""Raised when path is invalid or not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Types
|
||||
# =============================================================================
|
||||
|
||||
|
||||
DataType = Literal["config", "state", "all"]
|
||||
SetOperation = Literal["update", "replace", "delete"]
|
||||
SubscribeMode = Literal["once", "stream", "poll"]
|
||||
StreamMode = Literal["on-change", "sample", "target-defined"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Capability:
|
||||
"""Represents a gNMI capability/model."""
|
||||
|
||||
name: str
|
||||
organization: str
|
||||
version: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.organization}) v{self.version}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionUpdate:
|
||||
"""Represents a subscription update."""
|
||||
|
||||
path: str
|
||||
value: Any
|
||||
timestamp: int
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# gNMI Client
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GNMIClient:
|
||||
"""
|
||||
gNMI Client wrapper using pygnmi.
|
||||
|
||||
Provides high-level methods for gNMI operations with proper
|
||||
error handling and context manager support.
|
||||
|
||||
Usage:
|
||||
async with GNMIClient(host="leaf1", port=6030, ...) as client:
|
||||
caps = await client.capabilities()
|
||||
|
||||
Or synchronously:
|
||||
with GNMIClient(host="leaf1", port=6030, ...) as client:
|
||||
caps = client.capabilities()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int = 6030,
|
||||
username: str = "admin",
|
||||
password: str = "admin",
|
||||
insecure: bool = True,
|
||||
skip_verify: bool = True,
|
||||
timeout: int = 10,
|
||||
):
|
||||
"""
|
||||
Initialize gNMI client.
|
||||
|
||||
Args:
|
||||
host: Target hostname or IP
|
||||
port: gNMI port (default: 6030)
|
||||
username: Username for authentication
|
||||
password: Password for authentication
|
||||
insecure: Skip TLS verification (default: True for lab)
|
||||
skip_verify: Skip certificate verification
|
||||
timeout: Connection timeout in seconds
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.insecure = insecure
|
||||
self.skip_verify = skip_verify
|
||||
self.timeout = timeout
|
||||
self._client: gNMIclient | None = None
|
||||
|
||||
@property
|
||||
def target(self) -> str:
|
||||
"""Get target string (host:port)."""
|
||||
return f"{self.host}:{self.port}"
|
||||
|
||||
def _ensure_connected(self) -> gNMIclient:
|
||||
"""Ensure client is connected."""
|
||||
if self._client is None:
|
||||
raise GNMIConnectionError("Client not connected. Use context manager.")
|
||||
return self._client
|
||||
|
||||
def __enter__(self) -> GNMIClient:
|
||||
"""Enter context manager (sync)."""
|
||||
try:
|
||||
self._client = gNMIclient(
|
||||
target=(self.host, self.port),
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
insecure=self.insecure,
|
||||
skip_verify=self.skip_verify,
|
||||
)
|
||||
self._client.__enter__()
|
||||
return self
|
||||
except Exception as e:
|
||||
raise GNMIConnectionError(
|
||||
f"Failed to connect to {self.target}: {e}"
|
||||
) from e
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""Exit context manager (sync)."""
|
||||
if self._client:
|
||||
try:
|
||||
self._client.__exit__(exc_type, exc_val, exc_tb)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
self._client = None
|
||||
|
||||
async def __aenter__(self) -> GNMIClient:
|
||||
"""Enter async context manager."""
|
||||
return self.__enter__()
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""Exit async context manager."""
|
||||
self.__exit__(exc_type, exc_val, exc_tb)
|
||||
|
||||
# =========================================================================
|
||||
# gNMI Operations
|
||||
# =========================================================================
|
||||
|
||||
def capabilities(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get device capabilities.
|
||||
|
||||
Returns:
|
||||
Dictionary containing:
|
||||
- gnmi_version: gNMI protocol version
|
||||
- supported_models: List of supported YANG models
|
||||
- supported_encodings: List of supported encodings
|
||||
"""
|
||||
client = self._ensure_connected()
|
||||
try:
|
||||
result = client.capabilities()
|
||||
return result
|
||||
except Exception as e:
|
||||
raise GNMIError(f"Failed to get capabilities: {e}") from e
|
||||
|
||||
def get_models(self) -> list[Capability]:
|
||||
"""
|
||||
Get list of supported YANG models.
|
||||
|
||||
Returns:
|
||||
List of Capability objects
|
||||
"""
|
||||
caps = self.capabilities()
|
||||
models = []
|
||||
for model in caps.get("supported_models", []):
|
||||
models.append(
|
||||
Capability(
|
||||
name=model.get("name", ""),
|
||||
organization=model.get("organization", ""),
|
||||
version=model.get("version", ""),
|
||||
)
|
||||
)
|
||||
return models
|
||||
|
||||
def get(
|
||||
self,
|
||||
path: str | list[str],
|
||||
data_type: DataType = "all",
|
||||
encoding: str = "json_ietf",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get data at path.
|
||||
|
||||
Args:
|
||||
path: YANG path or list of paths
|
||||
data_type: Type of data to retrieve:
|
||||
- "config": Configuration data only
|
||||
- "state": State/operational data only
|
||||
- "all": Both config and state (default)
|
||||
encoding: Data encoding (default: json_ietf)
|
||||
|
||||
Returns:
|
||||
Dictionary with path data
|
||||
"""
|
||||
client = self._ensure_connected()
|
||||
paths = [path] if isinstance(path, str) else path
|
||||
|
||||
# Map data_type to pygnmi datatype parameter
|
||||
datatype_map = {
|
||||
"config": "config",
|
||||
"state": "state",
|
||||
"all": "all",
|
||||
}
|
||||
datatype = datatype_map.get(data_type, "all")
|
||||
|
||||
try:
|
||||
result = client.get(path=paths, datatype=datatype, encoding=encoding)
|
||||
return result
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "not found" in error_msg or "invalid" in error_msg:
|
||||
raise GNMIPathError(f"Path not found or invalid: {path}") from e
|
||||
raise GNMIError(f"Failed to get data at {path}: {e}") from e
|
||||
|
||||
def set(
|
||||
self,
|
||||
path: str,
|
||||
value: Any,
|
||||
operation: SetOperation = "update",
|
||||
encoding: str = "json_ietf",
|
||||
dry_run: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Set configuration at path.
|
||||
|
||||
Args:
|
||||
path: YANG path
|
||||
value: Value to set (dict or JSON string)
|
||||
operation: Set operation type:
|
||||
- "update": Merge with existing config
|
||||
- "replace": Replace existing config
|
||||
- "delete": Delete config at path
|
||||
encoding: Data encoding (default: json_ietf)
|
||||
dry_run: If True, only validate without applying (default: True)
|
||||
|
||||
Returns:
|
||||
Result of set operation
|
||||
"""
|
||||
if dry_run:
|
||||
# For dry-run, just return what would be set
|
||||
return {
|
||||
"dry_run": True,
|
||||
"operation": operation,
|
||||
"path": path,
|
||||
"value": value,
|
||||
"message": "Dry-run mode - no changes applied",
|
||||
}
|
||||
|
||||
client = self._ensure_connected()
|
||||
|
||||
# Parse value if it's a JSON string
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
pass # Keep as string if not valid JSON
|
||||
|
||||
try:
|
||||
if operation == "delete":
|
||||
result = client.set(delete=[path])
|
||||
elif operation == "replace":
|
||||
result = client.set(replace=[(path, value)])
|
||||
else: # update
|
||||
result = client.set(update=[(path, value)])
|
||||
return result
|
||||
except Exception as e:
|
||||
raise GNMIError(f"Failed to set {path}: {e}") from e
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
paths: str | list[str],
|
||||
mode: SubscribeMode = "stream",
|
||||
stream_mode: StreamMode = "on-change",
|
||||
sample_interval: int = 10,
|
||||
encoding: str = "json_ietf",
|
||||
) -> Any:
|
||||
"""
|
||||
Subscribe to path updates.
|
||||
|
||||
Args:
|
||||
paths: YANG path(s) to subscribe to
|
||||
mode: Subscription mode:
|
||||
- "once": Get data once and close
|
||||
- "stream": Continuous updates
|
||||
- "poll": Poll on demand
|
||||
stream_mode: Stream mode (for mode="stream"):
|
||||
- "on-change": Update on value change
|
||||
- "sample": Periodic sampling
|
||||
- "target-defined": Device decides
|
||||
sample_interval: Interval in seconds for sample mode
|
||||
encoding: Data encoding
|
||||
|
||||
Yields:
|
||||
Subscription updates
|
||||
"""
|
||||
client = self._ensure_connected()
|
||||
path_list = [paths] if isinstance(paths, str) else paths
|
||||
|
||||
# Build subscription list
|
||||
subscribe_list = []
|
||||
for p in path_list:
|
||||
sub = {
|
||||
"path": p,
|
||||
"mode": stream_mode.replace("-", "_"), # on-change -> on_change
|
||||
}
|
||||
if stream_mode == "sample":
|
||||
sub["sample_interval"] = sample_interval * 1_000_000_000 # nanoseconds
|
||||
subscribe_list.append(sub)
|
||||
|
||||
subscribe_request = {
|
||||
"subscription": subscribe_list,
|
||||
"mode": mode,
|
||||
"encoding": encoding,
|
||||
}
|
||||
|
||||
try:
|
||||
return client.subscribe2(subscribe=subscribe_request)
|
||||
except Exception as e:
|
||||
raise GNMIError(f"Failed to subscribe to {paths}: {e}") from e
|
||||
|
||||
# =========================================================================
|
||||
# Utility Methods
|
||||
# =========================================================================
|
||||
|
||||
def explore(self, path: str = "/", depth: int | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Explore available paths under given path.
|
||||
|
||||
Args:
|
||||
path: Base path to explore (default: root)
|
||||
depth: Maximum depth to explore (None = unlimited)
|
||||
|
||||
Returns:
|
||||
Dictionary with discovered paths and data
|
||||
"""
|
||||
result = self.get(path, data_type="all")
|
||||
|
||||
if depth is not None and depth > 0:
|
||||
# Limit depth by filtering result
|
||||
# This is a simplified implementation
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def validate_path(self, path: str) -> bool:
|
||||
"""
|
||||
Check if path exists on device.
|
||||
|
||||
Args:
|
||||
path: YANG path to validate
|
||||
|
||||
Returns:
|
||||
True if path exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
self.get(path)
|
||||
return True
|
||||
except GNMIPathError:
|
||||
return False
|
||||
except GNMIError:
|
||||
return False
|
||||
@@ -6,18 +6,18 @@ managing Arista EVPN-VXLAN fabrics via gNMI.
|
||||
"""
|
||||
|
||||
from .paths import (
|
||||
BGP,
|
||||
EVPN,
|
||||
MLAG,
|
||||
VXLAN,
|
||||
AfiSafi,
|
||||
FabricSubscriptions,
|
||||
Interfaces,
|
||||
Loopbacks,
|
||||
VLANs,
|
||||
BGP,
|
||||
AfiSafi,
|
||||
VXLAN,
|
||||
MLAG,
|
||||
EVPN,
|
||||
PortChannel,
|
||||
System,
|
||||
SubscriptionPath,
|
||||
FabricSubscriptions,
|
||||
System,
|
||||
VLANs,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -16,7 +16,6 @@ Usage:
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Interfaces (OpenConfig)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user