Files
fabric-orchestrator/src/cli.py
darnodo 5b3517c355 fix: resolve import paths, remove async context managers, clean up CLI
- 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
2025-12-31 17:44:21 +01:00

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()