Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
)
from cisco_sccfm_cli.commands.inventory.options import config_path_option, format_option
from cisco_sccfm_cli.utils import with_spinner
from cisco_sccfm_core import AsaCommandLineService
from cisco_sccfm_core import AsaCommandLineService, is_failed_cli_result


class AsaExecuteCliCommand(AsaDeviceTargetCommand):
Expand All @@ -30,7 +30,10 @@ def name(self) -> str:

@property
def help_text(self) -> str:
return "Execute CLI commands on ASA devices."
return (
"Execute CLI commands on ASA devices. Exits with status 1 if SCC Firewall Manager "
"reports a command failure on any device; every device's result is still printed."
)

def build_params(self) -> Sequence[click.Parameter]:
return [
Expand Down Expand Up @@ -115,6 +118,12 @@ def _render_results(
script=script,
output_format=format,
)
failed = [result for result in results if is_failed_cli_result(result)]
if failed:
names = ", ".join(_device_label(result.device_uid, uid_to_device) for result in failed)
raise click.ClickException(
f"CLI commands failed on {len(failed)} of {len(results)} device(s): {names}"
)

def _validate_script_filters(
self,
Expand All @@ -127,3 +136,8 @@ def _validate_script_filters(
has_script_file = bool(script_file)
if has_script == has_script_file:
ctx.fail("Provide exactly one of --script or --script-file.")


def _device_label(device_uid: str, uid_to_device: dict[str, Device]) -> str:
device = uid_to_device.get(device_uid)
return device.name if device else device_uid
2 changes: 2 additions & 0 deletions cisco_sccfm_cli/commands/inventory/devices/asa/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
AsaListNotOnVersionCommand,
)
from cisco_sccfm_cli.commands.inventory.devices.asa.onboard import AsaOnboardCommand
from cisco_sccfm_cli.commands.inventory.devices.asa.read_config import AsaReadConfigCommand
from cisco_sccfm_cli.commands.inventory.devices.asa.shun import AsaShunCommand
from cisco_sccfm_cli.commands.inventory.devices.asa.smartlicense.command import SmartlicenseCommand
from cisco_sccfm_cli.commands.inventory.devices.asa.upgrade import AsaUpgradeCommand
Expand Down Expand Up @@ -53,6 +54,7 @@ def __init__(self, console: Console) -> None:
SmartlicenseCommand(console),
AsaOnboardCommand(console),
AsaListLocalUsersCommand(console),
AsaReadConfigCommand(console),
AsaShunCommand(console),
AsaUpgradeCommand(console),
AsaUserCommand(console),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Copyright 2026 Cisco Systems, Inc. and its affiliates
#
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

__all__ = ["AsaReadConfigCommand"]

from cisco_sccfm_cli.commands.inventory.devices.asa.read_config.command import (
AsaReadConfigCommand,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copyright 2026 Cisco Systems, Inc. and its affiliates
#
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

from typing import Any, Sequence, cast

import click
from scc_firewall_manager_sdk import CdoTransaction

from cisco_sccfm_cli.commands.inventory.devices.asa.shared import (
AsaDeviceTargetCommand,
asa_check_option,
asa_device_filter_params,
)
from cisco_sccfm_cli.commands.inventory.options import (
config_path_option,
format_option,
timeout_option,
wait_option,
)
from cisco_sccfm_cli.utils import print_json
from cisco_sccfm_core.services.inventory import AsaConfigReadService


class AsaReadConfigCommand(AsaDeviceTargetCommand):
@property
def name(self) -> str:
return "read-config"

@property
def help_text(self) -> str:
return (
"Read the running configuration of ASA devices into SCC Firewall Manager. "
"Use after changing a device outside SCC Firewall Manager (for example with "
"'cli execute'). The read replaces SCC Firewall Manager's copy of the "
"configuration, so changes staged there but not yet deployed can be lost."
)

def build_params(self) -> Sequence[click.Parameter]:
return [
*asa_device_filter_params(
include_device_name=True,
query_help_text="Filter devices to read by a Lucene query.",
device_uids_help_text="List of device UIDs to read.",
),
asa_check_option(),
wait_option(),
timeout_option(),
format_option(),
config_path_option(),
]

def handle(self, ctx: click.Context, **kwargs: Any) -> None:
output_format = cast(str, kwargs.get("format"))
config = self.get_profile(ctx=ctx, **kwargs)
targets = self.resolve_asa_targets_from_kwargs(
ctx=ctx,
kwargs=kwargs,
config=config,
include_device_name=True,
)

if cast(bool, kwargs.get("check", False)):
self.report_check_targets(
targets, output_format=output_format, operation="configuration read"
)
return
if not targets.device_uids:
raise click.ClickException("No ASA devices matched the filter.")

transaction = AsaConfigReadService(config=config).read_configuration(
device_uids=targets.device_uids
)
transaction = self.wait_for_transaction(
config=config,
transaction=transaction,
spinner_text="Reading ASA configuration..." if output_format != "json" else None,
**kwargs,
)
self._render(
transaction, device_count=len(targets.device_uids), output_format=output_format
)

def _render(
self, transaction: CdoTransaction, *, device_count: int, output_format: str
) -> None:
if self.is_failed_transaction(transaction):
self.print_failed_transaction_details(cdo_transaction=transaction, format=output_format)
if output_format == "json":
print_json(transaction.to_dict())
return
self.console.print(
f"[green]✓[/green] Configuration read for {device_count} device(s): "
f"{transaction.cdo_transaction_status}"
)
self.console.print(f"[bold]Transaction UID:[/bold] {transaction.transaction_uid}")
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,97 @@ def stub_asa_cli_init(self: AsaCommandLineService, config: Any) -> None:
assert payload["matched_devices"] == 1
assert payload["devices"][0]["uid"] == "uid-asa-1"
assert payload["devices"][0]["device_type"] == "ASA"


def _patch_execute_with_results(
monkeypatch: MonkeyPatch, devices: list[Device], results: list[CdoCliResult]
) -> None:
def fake_get_devices(
self: InventoryService, *, limit: int, offset: int, query: str | None = None
) -> DevicePage:
return DevicePage(count=len(devices), items=devices)

def fake_execute_cli(
self: AsaCommandLineService, *, device_uids: list[str], asa_commands: list[str]
) -> list[CdoCliResult]:
return results

def stub_asa_cli_init(self: AsaCommandLineService, config: Any) -> None:
return None

monkeypatch.setattr(InventoryService, "get_devices", fake_get_devices)
monkeypatch.setattr(AsaCommandLineService, "execute_cli", fake_execute_cli)
monkeypatch.setattr(AsaCommandLineService, "__init__", stub_asa_cli_init)


def _results_with_one_failure() -> list[CdoCliResult]:
return [
CdoCliResult(uid="r-1", device_uid="uid-1", result="", error_msg=None),
CdoCliResult(uid="r-2", device_uid="uid-2", result="Command failed", error_msg=None),
]


def test_should_exit_non_zero_and_keep_json_results_when_a_device_fails(
cli_runner: CliRunner,
default_config: Config,
mock_inventory_service: None,
monkeypatch: MonkeyPatch,
sample_devices: list[Device],
) -> None:
"""A per-device 'Command failed' must not be reported as success."""
results = _results_with_one_failure()
_patch_execute_with_results(monkeypatch, sample_devices, results)

result = cli_runner.invoke(
cli,
[
"inventory",
"devices",
"asa",
"cli",
"execute",
"-u",
"uid-1",
"-u",
"uid-2",
"--script",
"access-list acl permit ip any any",
"--format",
"json",
],
)

assert result.exit_code == 1
assert json.loads(result.stdout) == [item.model_dump(mode="json") for item in results]
assert "CLI commands failed on 1 of 2 device(s): edge-nva" in result.stderr


def test_should_exit_non_zero_after_printing_table_when_a_device_fails(
cli_runner: CliRunner,
default_config: Config,
mock_inventory_service: None,
monkeypatch: MonkeyPatch,
sample_devices: list[Device],
) -> None:
_patch_execute_with_results(monkeypatch, sample_devices, _results_with_one_failure())

result = cli_runner.invoke(
cli,
[
"inventory",
"devices",
"asa",
"cli",
"execute",
"-u",
"uid-1",
"-u",
"uid-2",
"--script",
"access-list acl permit ip any any",
],
)

assert result.exit_code == 1
assert "Command failed" in result.stdout
assert "CLI commands failed on 1 of 2 device(s): edge-nva" in result.stderr
Loading
Loading