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