From c5d1607379f00e398567d46c050c17e1dc0382f8 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Fri, 17 Apr 2026 04:23:47 -0500 Subject: [PATCH 1/2] fix: align new commands with actual SDK 0.13.0 model schemas - device params take serial numbers (14-15 digits), not names - local config: use real model fields (local_version, timestamp, serial, md5) - incidents: use real model fields (incident_id, title, raised_time) - incidents: extract search results from .data, not .incidents - incidents: alerts use alert_id/title/state, remediations is a string - update mock data, CLI table columns, tests, and docs Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/cli/incidents/index.md | 13 ++++- docs/cli/local/index.md | 12 ++--- docs/cli/operations/index.md | 18 +++---- src/scm_cli/commands/incidents.py | 34 +++++++------ src/scm_cli/commands/local.py | 22 ++++----- src/scm_cli/commands/operations.py | 18 +++---- src/scm_cli/utils/sdk_client.py | 77 ++++++++++++++---------------- tests/test_incidents_commands.py | 12 ++--- tests/test_local_commands.py | 20 ++++---- 9 files changed, 116 insertions(+), 110 deletions(-) diff --git a/docs/cli/incidents/index.md b/docs/cli/incidents/index.md index e9afa7b..588d9bc 100644 --- a/docs/cli/incidents/index.md +++ b/docs/cli/incidents/index.md @@ -36,7 +36,18 @@ scm incidents show INC-2026-04-001 scm incidents show INC-2026-04-001 --json ``` -Shows full incident detail including alerts and remediation steps. Use `--json` for the complete structured output. +Shows full incident detail including alerts and remediations. Use `--json` for the complete structured output. + +**Fields:** + +- `incident_id` - Unique incident identifier +- `title` - Incident description +- `severity` - critical, high, medium, low, informational +- `status` - open, closed, in_progress +- `raised_time` - Epoch timestamp when incident was raised +- `updated_time` - Epoch timestamp of last update +- `alerts` - Associated alerts with `alert_id`, `title`, `severity`, and `state` +- `remediations` - Remediation guidance (string) **Arguments:** diff --git a/docs/cli/local/index.md b/docs/cli/local/index.md index 670b7df..31d7449 100644 --- a/docs/cli/local/index.md +++ b/docs/cli/local/index.md @@ -7,25 +7,25 @@ Manage local device configuration versions and downloads. ### List Config Versions ```bash -scm local list --device fw-01 +scm local list --device 007951000123456 ``` -Lists available configuration versions for a device, showing version number, date, author, and description. +Lists available configuration versions for a device, showing version number, timestamp, serial number, and MD5 hash. **Options:** | Option | Required | Description | |--------|----------|-------------| -| `--device`, `-d` | Yes | Device name | +| `--device`, `-d` | Yes | Device serial number (14-15 digits) | ### Download Config ```bash # Output to stdout -scm local download --device fw-01 --version 42 +scm local download --device 007951000123456 --version 42 # Save to file -scm local download --device fw-01 --version 42 --output config.xml +scm local download --device 007951000123456 --version 42 --output config.xml ``` Downloads a specific configuration version as XML. Outputs to stdout by default; use `--output` to write to a file. @@ -34,6 +34,6 @@ Downloads a specific configuration version as XML. Outputs to stdout by default; | Option | Required | Description | |--------|----------|-------------| -| `--device`, `-d` | Yes | Device name | +| `--device`, `-d` | Yes | Device serial number (14-15 digits) | | `--version`, `-v` | Yes | Config version number | | `--output`, `-o` | No | Output file path (default: stdout) | diff --git a/docs/cli/operations/index.md b/docs/cli/operations/index.md index c239e73..b15aead 100644 --- a/docs/cli/operations/index.md +++ b/docs/cli/operations/index.md @@ -8,51 +8,51 @@ All operation commands support: | Option | Default | Description | |--------|---------|-------------| -| `--device`, `-d` | Required | Device name | +| `--device`, `-d` | Required | Device serial number (14-15 digits) | | `--async` | `False` | Return job ID without waiting | | `--timeout`, `-t` | `300` | Sync polling timeout in seconds | ### Route Table ```bash -scm operations route-table --device fw-01 -scm operations route-table --device fw-01 --async +scm operations route-table --device 007951000123456 +scm operations route-table --device 007951000123456 --async ``` ### FIB Table ```bash -scm operations fib-table --device fw-01 +scm operations fib-table --device 007951000123456 ``` ### DNS Proxy ```bash -scm operations dns-proxy --device fw-01 +scm operations dns-proxy --device 007951000123456 ``` ### Network Interfaces ```bash -scm operations interfaces --device fw-01 +scm operations interfaces --device 007951000123456 ``` ### Device Rules ```bash -scm operations device-rules --device fw-01 +scm operations device-rules --device 007951000123456 ``` ### BGP Export ```bash -scm operations bgp-export --device fw-01 +scm operations bgp-export --device 007951000123456 ``` ### Logging Status ```bash -scm operations logging-status --device fw-01 +scm operations logging-status --device 007951000123456 ``` ### Job Status diff --git a/src/scm_cli/commands/incidents.py b/src/scm_cli/commands/incidents.py index e6d86b1..4781f0d 100644 --- a/src/scm_cli/commands/incidents.py +++ b/src/scm_cli/commands/incidents.py @@ -69,8 +69,8 @@ def list_incidents( table.add_column("Status", style="white") table.add_column("Severity", style="white") table.add_column("Product", style="white") - table.add_column("Summary", style="dim", max_width=40) - table.add_column("Created", style="white") + table.add_column("Title", style="dim", max_width=40) + table.add_column("Raised", style="white") severity_styles = {"critical": "red bold", "high": "red", "medium": "yellow", "low": "green", "informational": "dim"} @@ -80,12 +80,12 @@ def list_incidents( status_val = inc.get("status", "") status_style = "green" if status_val == "closed" else ("yellow" if status_val == "in_progress" else "white") table.add_row( - str(inc.get("id", "")), + str(inc.get("incident_id", "")), f"[{status_style}]{status_val}[/{status_style}]", f"[{sev_style}]{sev}[/{sev_style}]", str(inc.get("product", "")), - str(inc.get("summary", "")), - str(inc.get("created", "")), + str(inc.get("title", "")), + str(inc.get("raised_time", "")), ) console.print(table) @@ -118,28 +118,26 @@ def show_incident( typer.echo(json.dumps(incident, indent=2)) return - typer.echo(f"\nIncident: {incident.get('id', incident_id)}") + typer.echo(f"\nIncident: {incident.get('incident_id', incident_id)}") typer.echo(f"Status: {incident.get('status', '')}") typer.echo(f"Severity: {incident.get('severity', '')}") typer.echo(f"Product: {incident.get('product', '')}") - typer.echo(f"Created: {incident.get('created', '')}") - typer.echo(f"Updated: {incident.get('updated', '')}") - typer.echo(f"Summary: {incident.get('summary', '')}") + typer.echo(f"Raised: {incident.get('raised_time', '')}") + typer.echo(f"Updated: {incident.get('updated_time', '')}") + typer.echo(f"Title: {incident.get('title', '')}") alerts = incident.get("alerts", []) if alerts: typer.echo(f"\nAlerts ({len(alerts)}):") for i, alert in enumerate(alerts, 1): sev = alert.get("severity", "") - desc = alert.get("description", "") - ts = alert.get("timestamp", "") - typer.echo(f" {i}. [{sev}] {desc} {ts}") - - remediation = incident.get("remediation", []) - if remediation: - typer.echo("\nRemediation:") - for i, step in enumerate(remediation, 1): - typer.echo(f" {i}. {step}") + title = alert.get("title", "") + state = alert.get("state", "") + typer.echo(f" {i}. [{sev}] {title} ({state})") + + remediations = incident.get("remediations", "") + if remediations: + typer.echo(f"\nRemediations:\n {remediations}") typer.echo() diff --git a/src/scm_cli/commands/local.py b/src/scm_cli/commands/local.py index 9d17659..a5af817 100644 --- a/src/scm_cli/commands/local.py +++ b/src/scm_cli/commands/local.py @@ -24,7 +24,7 @@ # COMMAND OPTIONS # ============================================================================================================================================================================================= -DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device name") +DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device serial number") # ============================================================================================================================================================================================= # LOCAL CONFIG COMMANDS @@ -41,7 +41,7 @@ def list_versions( Examples -------- - scm local list --device fw-01 + scm local list --device 007951000123456 """ try: @@ -53,16 +53,16 @@ def list_versions( table = Table(title=f"Config Versions — {device}") table.add_column("Version", style="cyan") - table.add_column("Date", style="white") - table.add_column("Author", style="green") - table.add_column("Description", style="dim") + table.add_column("Timestamp", style="white") + table.add_column("Serial", style="green") + table.add_column("MD5", style="dim") for v in versions: table.add_row( - str(v.get("version", "")), - str(v.get("date", "")), - str(v.get("author", "")), - str(v.get("description", "")), + str(v.get("local_version", "")), + str(v.get("timestamp", "")), + str(v.get("serial", "")), + str(v.get("md5", "")), ) console.print(table) @@ -85,8 +85,8 @@ def download_config( Examples -------- - scm local download --device fw-01 --version 42 - scm local download --device fw-01 --version 42 --output config.xml + scm local download --device 007951000123456 --version 42 + scm local download --device 007951000123456 --version 42 --output config.xml """ try: diff --git a/src/scm_cli/commands/operations.py b/src/scm_cli/commands/operations.py index efe113e..e34e429 100644 --- a/src/scm_cli/commands/operations.py +++ b/src/scm_cli/commands/operations.py @@ -22,7 +22,7 @@ # COMMAND OPTIONS # ============================================================================================================================================================================================= -DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device name") +DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device serial number") ASYNC_OPTION = typer.Option(False, "--async", help="Return job ID without waiting for completion") TIMEOUT_OPTION = typer.Option(300, "--timeout", "-t", help="Sync polling timeout in seconds") @@ -89,8 +89,8 @@ def route_table(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, ti Examples -------- - scm operations route-table --device fw-01 - scm operations route-table --device fw-01 --async + scm operations route-table --device 007951000123456 + scm operations route-table --device 007951000123456 --async """ _run_operation(device, "route-table", async_mode, timeout) @@ -102,7 +102,7 @@ def fib_table(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, time Examples -------- - scm operations fib-table --device fw-01 + scm operations fib-table --device 007951000123456 """ _run_operation(device, "fib-table", async_mode, timeout) @@ -114,7 +114,7 @@ def dns_proxy(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, time Examples -------- - scm operations dns-proxy --device fw-01 + scm operations dns-proxy --device 007951000123456 """ _run_operation(device, "dns-proxy", async_mode, timeout) @@ -126,7 +126,7 @@ def interfaces(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, tim Examples -------- - scm operations interfaces --device fw-01 + scm operations interfaces --device 007951000123456 """ _run_operation(device, "interfaces", async_mode, timeout) @@ -138,7 +138,7 @@ def device_rules(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, t Examples -------- - scm operations device-rules --device fw-01 + scm operations device-rules --device 007951000123456 """ _run_operation(device, "device-rules", async_mode, timeout) @@ -150,7 +150,7 @@ def bgp_export(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, tim Examples -------- - scm operations bgp-export --device fw-01 + scm operations bgp-export --device 007951000123456 """ _run_operation(device, "bgp-export", async_mode, timeout) @@ -162,7 +162,7 @@ def logging_status(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, Examples -------- - scm operations logging-status --device fw-01 + scm operations logging-status --device 007951000123456 """ _run_operation(device, "logging-status", async_mode, timeout) diff --git a/src/scm_cli/utils/sdk_client.py b/src/scm_cli/utils/sdk_client.py index 08a3787..c4a2f86 100644 --- a/src/scm_cli/utils/sdk_client.py +++ b/src/scm_cli/utils/sdk_client.py @@ -16037,7 +16037,7 @@ def list_local_config_versions(self, device: str) -> list[dict[str, Any]]: """List configuration versions for a device. Args: - device: Device name to list versions for. + device: Device serial number (14-15 digits). Returns: list[dict[str, Any]]: List of config version objects. @@ -16047,9 +16047,9 @@ def list_local_config_versions(self, device: str) -> list[dict[str, Any]]: if not self.client: return [ - {"version": 42, "date": "2026-04-15 14:30", "author": "admin", "description": "Policy update"}, - {"version": 41, "date": "2026-04-14 09:12", "author": "auto-commit", "description": "Scheduled push"}, - {"version": 40, "date": "2026-04-13 11:45", "author": "admin", "description": "Initial config"}, + {"id": "cfg-001", "serial": device, "local_version": "42", "timestamp": "2026-04-15T14:30:00Z", "xfmed_version": "42", "md5": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"}, + {"id": "cfg-002", "serial": device, "local_version": "41", "timestamp": "2026-04-14T09:12:00Z", "xfmed_version": "41", "md5": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"}, + {"id": "cfg-003", "serial": device, "local_version": "40", "timestamp": "2026-04-13T11:45:00Z", "xfmed_version": "40", "md5": "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6"}, ] try: @@ -16062,7 +16062,7 @@ def download_local_config(self, device: str, version: str) -> bytes: """Download a configuration version as raw XML. Args: - device: Device name. + device: Device serial number (14-15 digits). version: Config version number to download. Returns: @@ -16119,7 +16119,7 @@ def dispatch_device_operation( """Dispatch a device operation job. Args: - device: Device name to run operation on. + device: Device serial number (14-15 digits). operation: Operation type (route-table, fib-table, etc.). sync: If True, poll until completion. If False, return job_id immediately. timeout: Timeout in seconds for sync polling. @@ -16188,7 +16188,7 @@ def get_device_operation_status(self, job_id: str) -> dict[str, Any]: return { "job_id": job_id, "state": "completed", - "device": "fw-01", + "device": "007951000123456", "operation": "route-table", "started": "2026-04-16 10:30:00", "completed": "2026-04-16 10:30:42", @@ -16206,57 +16206,52 @@ def get_device_operation_status(self, job_id: str) -> dict[str, Any]: _MOCK_INCIDENTS = [ { - "id": "INC-2026-04-001", + "incident_id": "INC-2026-04-001", "status": "open", "severity": "high", "product": "Prisma Access", - "summary": "Suspicious lateral movement detected from 10.1.2.50", - "created": "2026-04-15 08:23:00", - "updated": "2026-04-16 02:15:00", + "title": "Suspicious lateral movement detected from 10.1.2.50", + "raised_time": 1744700580, + "updated_time": 1744764900, + "category": "lateral-movement", "alerts": [ - {"severity": "high", "description": "Unusual SMB traffic from 10.1.2.50 to 10.1.2.100", "timestamp": "2026-04-15 08:23"}, - {"severity": "high", "description": "Credential dumping tool detected on 10.1.2.50", "timestamp": "2026-04-15 08:25"}, - {"severity": "medium", "description": "DNS tunneling attempt from 10.1.2.50", "timestamp": "2026-04-15 08:30"}, - ], - "remediation": [ - "Isolate host 10.1.2.50 from network", - "Reset credentials for affected accounts", - "Scan 10.1.2.100 for indicators of compromise", + {"alert_id": "ALT-001", "severity": "high", "title": "Unusual SMB traffic from 10.1.2.50 to 10.1.2.100", "state": "open", "updated_time": 1744700580, "domain": "10.1.2.0/24"}, + {"alert_id": "ALT-002", "severity": "high", "title": "Credential dumping tool detected on 10.1.2.50", "state": "open", "updated_time": 1744700700, "domain": "10.1.2.0/24"}, + {"alert_id": "ALT-003", "severity": "medium", "title": "DNS tunneling attempt from 10.1.2.50", "state": "open", "updated_time": 1744701000, "domain": "10.1.2.0/24"}, ], + "description": "Lateral movement detected from host 10.1.2.50 involving SMB, credential dumping, and DNS tunneling.", + "remediations": "Isolate host 10.1.2.50 from network. Reset credentials for affected accounts. Scan 10.1.2.100 for indicators of compromise.", }, { - "id": "INC-2026-04-002", + "incident_id": "INC-2026-04-002", "status": "open", "severity": "critical", "product": "NGFW", - "summary": "C2 callback detected from internal host", - "created": "2026-04-14 16:45:00", - "updated": "2026-04-15 09:00:00", + "title": "C2 callback detected from internal host", + "raised_time": 1744644300, + "updated_time": 1744702800, + "category": "command-and-control", "alerts": [ - {"severity": "critical", "description": "Known C2 domain contacted by 10.2.1.30", "timestamp": "2026-04-14 16:45"}, - {"severity": "high", "description": "Encrypted payload exfiltration attempt", "timestamp": "2026-04-14 16:50"}, - ], - "remediation": [ - "Block C2 domain at firewall", - "Isolate 10.2.1.30", - "Forensic analysis of affected host", + {"alert_id": "ALT-004", "severity": "critical", "title": "Known C2 domain contacted by 10.2.1.30", "state": "open", "updated_time": 1744644300, "domain": "malware.example"}, + {"alert_id": "ALT-005", "severity": "high", "title": "Encrypted payload exfiltration attempt", "state": "open", "updated_time": 1744644600, "domain": "10.2.1.0"}, ], + "description": "C2 callback and data exfiltration attempt from host 10.2.1.30.", + "remediations": "Block C2 domain at firewall. Isolate 10.2.1.30. Perform forensic analysis of affected host.", }, { - "id": "INC-2026-03-088", + "incident_id": "INC-2026-03-088", "status": "closed", "severity": "medium", "product": "Prisma Access", - "summary": "Policy violation — data exfiltration attempt", - "created": "2026-03-28 14:00:00", - "updated": "2026-03-29 11:30:00", + "title": "Policy violation — data exfiltration attempt", + "raised_time": 1743170400, + "updated_time": 1743247800, + "category": "data-exfiltration", "alerts": [ - {"severity": "medium", "description": "Large file upload to unapproved cloud storage", "timestamp": "2026-03-28 14:00"}, - ], - "remediation": [ - "User counseling completed", - "DLP policy updated to block unapproved storage", + {"alert_id": "ALT-006", "severity": "medium", "title": "Large file upload to unapproved storage", "state": "closed", "updated_time": 1743170400, "domain": "cloud.example"}, ], + "description": "User uploaded large files to unapproved cloud storage service.", + "remediations": "User counseling completed. DLP policy updated to block unapproved storage.", }, ] @@ -16288,7 +16283,7 @@ def list_incidents( if product: kwargs["product"] = [product] response = self.client.incidents.search(**kwargs) - return json.loads(response.model_dump_json(exclude_unset=True)).get("incidents", []) + return json.loads(response.model_dump_json(exclude_unset=True)).get("data", []) except Exception as e: self._handle_api_exception("searching", "N/A", "incidents", e) @@ -16298,7 +16293,7 @@ def get_incident(self, incident_id: str) -> dict[str, Any]: if not self.client: for inc in self._MOCK_INCIDENTS: - if inc["id"] == incident_id: + if inc["incident_id"] == incident_id: return inc return self._MOCK_INCIDENTS[0] diff --git a/tests/test_incidents_commands.py b/tests/test_incidents_commands.py index 1ca5f5a..c4c7555 100644 --- a/tests/test_incidents_commands.py +++ b/tests/test_incidents_commands.py @@ -34,7 +34,7 @@ def test_list_incidents_mock(self): result = client.list_incidents() assert isinstance(result, list) assert len(result) >= 2 - assert "id" in result[0] + assert "incident_id" in result[0] assert "status" in result[0] assert "severity" in result[0] @@ -51,9 +51,9 @@ def test_get_incident_mock(self): client.logger = __import__("logging").getLogger("test") result = client.get_incident(incident_id="INC-2026-04-001") assert isinstance(result, dict) - assert "id" in result + assert "incident_id" in result assert "alerts" in result - assert "remediation" in result + assert "remediations" in result assert len(result["alerts"]) > 0 @@ -100,12 +100,12 @@ def test_show_incident_detail(self, runner, mock_incidents_env): assert "INC-2026-04-001" in result.output assert "Suspicious lateral movement" in result.output assert "Alerts" in result.output - assert "Remediation" in result.output + assert "Remediation" in result.output or "remediations" in result.output.lower() def test_show_incident_json(self, runner, mock_incidents_env): result = runner.invoke(app, ["incidents", "show", "INC-2026-04-001", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) - assert data["id"] == "INC-2026-04-001" + assert data["incident_id"] == "INC-2026-04-001" assert "alerts" in data - assert "remediation" in data + assert "remediations" in data diff --git a/tests/test_local_commands.py b/tests/test_local_commands.py index 5230a68..fe062a4 100644 --- a/tests/test_local_commands.py +++ b/tests/test_local_commands.py @@ -33,11 +33,13 @@ def test_list_local_config_versions_mock(self): client.client = None client.logger = __import__("logging").getLogger("test") - result = client.list_local_config_versions(device="fw-01") + result = client.list_local_config_versions(device="007951000123456") assert isinstance(result, list) assert len(result) > 0 - assert "version" in result[0] - assert "date" in result[0] + assert "local_version" in result[0] + assert "timestamp" in result[0] + assert "serial" in result[0] + assert "md5" in result[0] def test_download_local_config_mock(self): """download_local_config returns mock XML bytes when no client.""" @@ -45,7 +47,7 @@ def test_download_local_config_mock(self): client.client = None client.logger = __import__("logging").getLogger("test") - result = client.download_local_config(device="fw-01", version=42) + result = client.download_local_config(device="007951000123456", version=42) assert isinstance(result, bytes) assert b" Date: Fri, 17 Apr 2026 04:28:03 -0500 Subject: [PATCH 2/2] fix: resolve device names to serial numbers automatically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resolve_device_serial() to SCMClient — accepts device name or serial - Serial numbers (14-15 digits) pass through, names resolved via device API - Wire resolution into local config and device operations methods - Update CLI help text to "Device name or serial number" - Align mock data with real SDK 0.13.0 model schemas - Bump version to 1.3.2 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/about/release-notes.md | 9 +++++ pyproject.toml | 2 +- src/scm_cli/commands/local.py | 2 +- src/scm_cli/commands/operations.py | 2 +- src/scm_cli/utils/sdk_client.py | 62 +++++++++++++++++++++++++----- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index dbf4431..3bb3d3f 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -2,6 +2,15 @@ This page contains the release history of the Strata Cloud Manager CLI, with the most recent releases at the top. +## Version 1.3.2 + +**Released:** April 2026 + +### Fixed + +- **Device Name Resolution**: `scm local` and `scm operations` commands now accept device names (e.g., `austin-fw1`) in addition to serial numbers. The CLI automatically resolves device names to serial numbers via the SCM device API before dispatching operations. Serial numbers (14-15 digits) are passed through directly. +- **SDK Model Alignment**: Updated mock data and live API calls to match actual SDK 0.13.0 Pydantic model field names for local config, device operations, and incidents. + ## Version 1.3.1 **Released:** April 2026 diff --git a/pyproject.toml b/pyproject.toml index 6a75a6b..b837eaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pan-scm-cli" -version = "1.3.1" +version = "1.3.2" description = "CICD and Network Engineer-friendly CLI tool for Palo Alto Networks Strata Cloud Manager" authors = ["Calvin Remsburg "] readme = "README.md" diff --git a/src/scm_cli/commands/local.py b/src/scm_cli/commands/local.py index a5af817..b8667c5 100644 --- a/src/scm_cli/commands/local.py +++ b/src/scm_cli/commands/local.py @@ -24,7 +24,7 @@ # COMMAND OPTIONS # ============================================================================================================================================================================================= -DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device serial number") +DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device name or serial number") # ============================================================================================================================================================================================= # LOCAL CONFIG COMMANDS diff --git a/src/scm_cli/commands/operations.py b/src/scm_cli/commands/operations.py index e34e429..7f46c40 100644 --- a/src/scm_cli/commands/operations.py +++ b/src/scm_cli/commands/operations.py @@ -22,7 +22,7 @@ # COMMAND OPTIONS # ============================================================================================================================================================================================= -DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device serial number") +DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device name or serial number") ASYNC_OPTION = typer.Option(False, "--async", help="Return job ID without waiting for completion") TIMEOUT_OPTION = typer.Option(300, "--timeout", "-t", help="Sync polling timeout in seconds") diff --git a/src/scm_cli/utils/sdk_client.py b/src/scm_cli/utils/sdk_client.py index c4a2f86..a0401a0 100644 --- a/src/scm_cli/utils/sdk_client.py +++ b/src/scm_cli/utils/sdk_client.py @@ -16029,6 +16029,45 @@ def fetch_bpa_report(self, report_url: str) -> dict[str, Any]: response.raise_for_status() return response.json() + # ====================================================================================================================================================================================== + # DEVICE SERIAL RESOLUTION + # ====================================================================================================================================================================================== + + _SERIAL_PATTERN = __import__("re").compile(r"^\d{14,15}$") + + def resolve_device_serial(self, device: str) -> str: + """Resolve a device name or serial number to a serial number. + + Args: + device: Device name or serial number. + + Returns: + str: The 14-15 digit device serial number. + + Raises: + ValueError: If the device cannot be found. + + """ + if self._SERIAL_PATTERN.match(device): + return device + + self.logger.info(f"Resolving device name '{device}' to serial number") + + if not self.client: + return "007951000123456" + + try: + result = self.client.device.fetch(name=device) + if result is None: + raise ValueError(f"Device '{device}' not found in SCM") + serial = result.id + self.logger.info(f"Resolved '{device}' to serial {serial}") + return serial + except ValueError: + raise + except Exception as e: + self._handle_api_exception("resolving", "N/A", f"device {device}", e) + # ====================================================================================================================================================================================== # LOCAL CONFIG METHODS # ====================================================================================================================================================================================== @@ -16037,7 +16076,7 @@ def list_local_config_versions(self, device: str) -> list[dict[str, Any]]: """List configuration versions for a device. Args: - device: Device serial number (14-15 digits). + device: Device name or serial number (resolved automatically). Returns: list[dict[str, Any]]: List of config version objects. @@ -16047,13 +16086,14 @@ def list_local_config_versions(self, device: str) -> list[dict[str, Any]]: if not self.client: return [ - {"id": "cfg-001", "serial": device, "local_version": "42", "timestamp": "2026-04-15T14:30:00Z", "xfmed_version": "42", "md5": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"}, - {"id": "cfg-002", "serial": device, "local_version": "41", "timestamp": "2026-04-14T09:12:00Z", "xfmed_version": "41", "md5": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"}, - {"id": "cfg-003", "serial": device, "local_version": "40", "timestamp": "2026-04-13T11:45:00Z", "xfmed_version": "40", "md5": "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6"}, + {"id": "cfg-001", "serial": "007951000123456", "local_version": "42", "timestamp": "2026-04-15T14:30:00Z", "xfmed_version": "42", "md5": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"}, + {"id": "cfg-002", "serial": "007951000123456", "local_version": "41", "timestamp": "2026-04-14T09:12:00Z", "xfmed_version": "41", "md5": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"}, + {"id": "cfg-003", "serial": "007951000123456", "local_version": "40", "timestamp": "2026-04-13T11:45:00Z", "xfmed_version": "40", "md5": "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6"}, ] try: - results = self.client.local_config.list_versions(device=device) + serial = self.resolve_device_serial(device) + results = self.client.local_config.list_versions(device=serial) return [json.loads(r.model_dump_json(exclude_unset=True)) for r in results] except Exception as e: self._handle_api_exception("listing", "N/A", f"local config versions for {device}", e) @@ -16062,8 +16102,8 @@ def download_local_config(self, device: str, version: str) -> bytes: """Download a configuration version as raw XML. Args: - device: Device serial number (14-15 digits). - version: Config version number to download. + device: Device name or serial number (resolved automatically). + version: Config version ID to download. Returns: bytes: Raw XML configuration data. @@ -16075,7 +16115,8 @@ def download_local_config(self, device: str, version: str) -> bytes: return b'\n\n \n \n \n \n \n' try: - return self.client.local_config.download(device=device, version=version) + serial = self.resolve_device_serial(device) + return self.client.local_config.download(device=serial, version=version) except Exception as e: self._handle_api_exception("downloading", "N/A", f"local config v{version} for {device}", e) @@ -16119,7 +16160,7 @@ def dispatch_device_operation( """Dispatch a device operation job. Args: - device: Device serial number (14-15 digits). + device: Device name or serial number (resolved automatically). operation: Operation type (route-table, fib-table, etc.). sync: If True, poll until completion. If False, return job_id immediately. timeout: Timeout in seconds for sync polling. @@ -16147,6 +16188,7 @@ def dispatch_device_operation( } try: + serial = self.resolve_device_serial(device) op_method_map = { "route-table": "route_table", "fib-table": "fib_table", @@ -16158,7 +16200,7 @@ def dispatch_device_operation( } method_name = op_method_map.get(operation, operation.replace("-", "_")) method = getattr(self.client.device_operations, method_name) - result = method(devices=[device], sync=sync, timeout=timeout) + result = method(devices=[serial], sync=sync, timeout=timeout) result_dict = json.loads(result.model_dump_json(exclude_unset=True)) if sync: result_dict["status"] = "completed"