- Change absolute imports to relative imports in gnmi module - Remove async context manager methods (pygnmi is synchronous) - Remove unimplemented --depth option from CLI - Update documentation to reflect sync-only usage
569 lines
18 KiB
Python
569 lines
18 KiB
Python
"""
|
|
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)",
|
|
)
|
|
def discover_get(
|
|
target: str,
|
|
username: str,
|
|
password: str,
|
|
insecure: bool,
|
|
output: str,
|
|
path: str,
|
|
data_type: str,
|
|
):
|
|
"""
|
|
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()
|