From a325c07383f9f071df8cc80284e18de6caa7814b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Zago=C5=BEen?= Date: Tue, 7 Oct 2025 16:08:19 +0200 Subject: [PATCH 1/4] Add Cisco IOS XE driver On Cisco IOS XE the configration is immediately "commited" when a CLI command is accepted in a configuration session. This means that simply exiting an active configuration session is not sufficient to abort configuration because of an error. We use the "config archive" feature to instruct the device to keep track of CLI commands entered. The driver creates an archive checkpoint before each configuration change and maintains the history in a stack. This is then used for both automatic (error recovery) and user-triggered rollback operations. The config archive must be enabled on the device prior to starting the configuration. As it stands today, this is the responsibility of the user. The configuration is: archive log config logging enable path bootflash:archive --- CLAUDE.md | 71 ++-- src/drivers.act | 230 +++++++++++-- src/router_example_iosxe.act | 316 ++++++++++++++++++ src/test_drivers.act | 15 +- .../commit_failure_automatic_rollback | 2 +- .../driver_state_transition_validation | 4 +- 6 files changed, 591 insertions(+), 47 deletions(-) create mode 100644 src/router_example_iosxe.act diff --git a/CLAUDE.md b/CLAUDE.md index 261dd3e..50c9ec1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Build project**: `acton build --dev` - **Run tests**: `acton test` +## Testing + +### Running Tests +- **Run all tests**: `acton test` +- **Run all tests from a module**: `acton test --module ` + - Example: `acton test --module test_drivers` +- **Run specific test**: `acton test --name ` (use function name WITHOUT `_test_` prefix) + - Example: For function `_test_driver_state_transition_validation`, run: `acton test --name driver_state_transition_validation` +- **Update golden files**: `acton test --name --golden-update` + - Updates expected output when test behavior changes intentionally + - Note: The test will show as "FAIL" once while updating the golden file - this is expected behavior +- **Golden files location**: `test/golden//` + ## Project Structure This is the netcli package - a CLI client library system built on Acton with the following architecture: @@ -34,21 +47,40 @@ The project provides CLI client libraries for network device management: ### Router Driver System (`src/drivers.act`) - **Formal State Machine Architecture**: Implements a complete state machine for router device interactions -- **Multi-Platform Support**: Base driver with Juniper JUNOS and Cisco IOS XR implementations +- **Multi-Platform Support**: Base driver with Juniper JUNOS, Cisco IOS XR, and Cisco IOS XE implementations - **State-Based Validation**: Prevents invalid operations and ensures proper command sequencing - **Enhanced Error Handling**: Centralized error management with automatic cleanup and recovery **Driver State Machine**: ``` -INITIALIZING → READY → EXECUTING_COMMAND → READY - ↓ ↓ - ENTERING_CONFIG → CONFIG_MODE → APPLYING_CONFIG → TRANSACTION_ACTIVE - ↓ ↓ ↓ ↓ - ROLLING_BACK ABORTING_CONFIG COMMITTING ABORTING_CONFIG - ↓ ↓ ↓ ↓ - READY ←← READY ←← READY ←← READY - ↓ - ERROR → READY/DISCONNECTED +State transitions: + +INITIALIZING ──→ READY + │ + ├──→ EXECUTING_COMMAND ──→ READY + │ + ├──→ ENTERING_CONFIG ──→ CONFIG_MODE ──┬──→ APPLYING_CONFIG ──→ COMMITTING ──┬──→ READY + │ │ │ + │ └──→ READY (exit) └──→ ABORTING_CONFIG ──→ READY + │ (on failure) + └──→ ROLLING_BACK ──→ READY + +Special transitions (from any state): +- Any state ──→ ERROR ──→ READY (recovery) +- Any state ──→ DISCONNECTED ──→ INITIALIZING (reconnect) + +Valid state transitions: +- INITIALIZING: → READY, ERROR, DISCONNECTED +- READY: → EXECUTING_COMMAND, ENTERING_CONFIG, ROLLING_BACK, ERROR, DISCONNECTED +- EXECUTING_COMMAND: → READY, ERROR, DISCONNECTED +- ENTERING_CONFIG: → CONFIG_MODE, ERROR, DISCONNECTED +- CONFIG_MODE: → APPLYING_CONFIG, ABORTING_CONFIG, READY, ERROR, DISCONNECTED +- APPLYING_CONFIG: → COMMITTING, ERROR, DISCONNECTED +- COMMITTING: → READY, ABORTING_CONFIG, ERROR, DISCONNECTED +- ABORTING_CONFIG: → READY, ERROR, DISCONNECTED +- ROLLING_BACK: → READY, ERROR, DISCONNECTED +- ERROR: → READY, DISCONNECTED +- DISCONNECTED: → INITIALIZING ``` **Driver States**: @@ -56,12 +88,11 @@ INITIALIZING → READY → EXECUTING_COMMAND → READY - `READY`: Ready for commands or configuration operations - `EXECUTING_COMMAND`: Processing a single command - `ENTERING_CONFIG`: Transitioning to configuration mode -- `CONFIG_MODE`: In device configuration mode +- `CONFIG_MODE`: In device configuration mode, ready to apply configuration - `APPLYING_CONFIG`: Applying configuration commands sequentially -- `TRANSACTION_ACTIVE`: Configuration applied but not committed (can commit or abort) -- `COMMITTING`: Committing configuration changes -- `ABORTING_CONFIG`: Discarding current configuration changes -- `ROLLING_BACK`: Reverting to previous committed configuration +- `COMMITTING`: Committing/saving configuration changes (platform-specific) +- `ABORTING_CONFIG`: Rolling back after commit failure (automatic) +- `ROLLING_BACK`: Explicit rollback to previous configuration (user-requested) - `ERROR`: Error state with automatic cleanup - `DISCONNECTED`: SSH connection lost @@ -74,15 +105,15 @@ INITIALIZING → READY → EXECUTING_COMMAND → READY - **Rollback Operations**: Support for reverting to previous configurations - **Platform Abstraction**: Device-specific prompt patterns and command syntax -**Transaction Management**: -- **configure()**: Enter config mode and apply changes → TRANSACTION_ACTIVE state -- **commit_transaction()**: Commit active transaction to device (internal driver method) -- **abort_configuration()**: Discard current uncommitted changes -- **rollback_configuration(commits_back)**: Revert to previous configuration state +**Key Operations**: +- **execute_command(command)**: Execute a single operational command +- **configure_and_commit(config_list)**: Apply configuration atomically with automatic rollback on failure +- **rollback_configuration(commits_back)**: Revert to a previous configuration state **Platform-Specific Commands**: - **Juniper JUNOS**: `rollback`, `rollback N`, `commit`, `exit` - **Cisco IOS XR**: `abort`, `rollback configuration last N`, `commit`, `end` +- **Cisco IOS XE**: `archive config` (checkpoint), `configure replace` (rollback), `write memory` (save) ### Usage Examples - `src/router_example.act`: Router client examples for both Juniper and Cisco platforms diff --git a/src/drivers.act b/src/drivers.act index 4e4ab6d..1cae7bf 100644 --- a/src/drivers.act +++ b/src/drivers.act @@ -1,6 +1,7 @@ import logging import ssh import re +import testing import time """Router driver auto-detection and base classes""" @@ -45,11 +46,13 @@ class TestSSHWrapper(SSHClientWrapper): DEVICE_TYPE_UNKNOWN: str = "unknown" DEVICE_TYPE_JUNIPER: str = "juniper_junos" DEVICE_TYPE_CISCO_IOSXR: str = "cisco_iosxr" +DEVICE_TYPE_CISCO_IOSXE: str = "cisco_iosxe" # Driver states DRIVER_STATE_INITIALIZING: str = "initializing" DRIVER_STATE_READY: str = "ready" DRIVER_STATE_EXECUTING_COMMAND: str = "executing_command" +DRIVER_STATE_PRE_CONFIG: str = "pre_config" # New state for pre-config operations DRIVER_STATE_ENTERING_CONFIG: str = "entering_config" DRIVER_STATE_CONFIG_MODE: str = "config_mode" DRIVER_STATE_APPLYING_CONFIG: str = "applying_config" @@ -126,11 +129,12 @@ class _BaseDriver(_Driver): """Check if state transition is valid""" valid_transitions = { DRIVER_STATE_INITIALIZING: [DRIVER_STATE_READY, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], - DRIVER_STATE_READY: [DRIVER_STATE_EXECUTING_COMMAND, DRIVER_STATE_ENTERING_CONFIG, DRIVER_STATE_ROLLING_BACK, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], + DRIVER_STATE_READY: [DRIVER_STATE_EXECUTING_COMMAND, DRIVER_STATE_PRE_CONFIG, DRIVER_STATE_ENTERING_CONFIG, DRIVER_STATE_ROLLING_BACK, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], DRIVER_STATE_EXECUTING_COMMAND: [DRIVER_STATE_READY, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], + DRIVER_STATE_PRE_CONFIG: [DRIVER_STATE_ENTERING_CONFIG, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], DRIVER_STATE_ENTERING_CONFIG: [DRIVER_STATE_CONFIG_MODE, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], DRIVER_STATE_CONFIG_MODE: [DRIVER_STATE_APPLYING_CONFIG, DRIVER_STATE_ABORTING_CONFIG, DRIVER_STATE_READY, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], - DRIVER_STATE_APPLYING_CONFIG: [DRIVER_STATE_COMMITTING, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], + DRIVER_STATE_APPLYING_CONFIG: [DRIVER_STATE_COMMITTING, DRIVER_STATE_ABORTING_CONFIG, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], DRIVER_STATE_COMMITTING: [DRIVER_STATE_READY, DRIVER_STATE_ABORTING_CONFIG, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], DRIVER_STATE_ABORTING_CONFIG: [DRIVER_STATE_READY, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], DRIVER_STATE_ROLLING_BACK: [DRIVER_STATE_READY, DRIVER_STATE_ERROR, DRIVER_STATE_DISCONNECTED], @@ -186,6 +190,7 @@ class _BaseDriver(_Driver): def _handle_prompt_received(self) -> None: """Handle when prompt is received""" + self.log.debug("_handle_prompt_received", {"state": self._state, "output_buffer": self._output_buffer}) if self._state == DRIVER_STATE_EXECUTING_COMMAND: # Extract command output cmd = self._current_command @@ -200,6 +205,18 @@ class _BaseDriver(_Driver): self._transition_to_state(DRIVER_STATE_READY) self._output_buffer = "" + elif self._state == DRIVER_STATE_PRE_CONFIG: + # Pre-config command completed, now enter config mode + # Store any output from pre-config command (e.g., archive name for IOS XE) + if self._handle_pre_config_output(self._output_buffer): + self._output_buffer = "" + + # Now transition to entering config mode + self._transition_to_state(DRIVER_STATE_ENTERING_CONFIG) + config_cmds = self._get_config_commands() + for cmd in config_cmds: + self.ssh_client.send_str(cmd + "\n") + elif self._state == DRIVER_STATE_ENTERING_CONFIG: self._transition_to_state(DRIVER_STATE_CONFIG_MODE) # Start applying config commands @@ -216,23 +233,29 @@ class _BaseDriver(_Driver): self._output_buffer = "" elif self._state == DRIVER_STATE_APPLYING_CONFIG: - if len(self._config_commands) > 0: + # Check if the last command failed before proceeding + if self._commit_failed(self._output_buffer): + self.log.warning("Configuration command failed", {"output": self._output_buffer}) + self._handle_configuration_failure() + self._output_buffer = "" + elif len(self._config_commands) > 0: # Send next config command cmd = self._config_commands.pop(0) self.ssh_client.send_str(cmd + "\n") + self._output_buffer = "" else: # All config commands sent, proceed to commit self._transition_to_state(DRIVER_STATE_COMMITTING) commit_cmds = self._get_commit_commands() for cmd in commit_cmds: self.ssh_client.send_str(cmd + "\n") - self._output_buffer = "" + self._output_buffer = "" elif self._state == DRIVER_STATE_COMMITTING: # Check if commit succeeded or failed if self._commit_failed(self._output_buffer): - # Commit failed - trigger automatic rollback - self._handle_commit_failure() + # Commit failed - trigger abort/rollback + self._handle_configuration_failure() else: # Configuration committed successfully config_callback = self._config_callback @@ -244,11 +267,11 @@ class _BaseDriver(_Driver): self._output_buffer = "" elif self._state == DRIVER_STATE_ABORTING_CONFIG: - # Configuration aborted successfully (automatic rollback after commit failure) + # Configuration aborted successfully config_callback = self._config_callback if config_callback is not None: - # Report commit failure but successful rollback - abort_error = Exception("Configuration commit failed but was automatically rolled back") + # Report configuration failure and abort result + abort_error = Exception("Configuration failed and was aborted") config_callback(abort_error, self._session_log) self._config_callback = None @@ -310,6 +333,14 @@ class _BaseDriver(_Driver): """Get device-specific commit commands - must be implemented by subclasses""" raise NotImplementedError("_get_commit_commands must be implemented by subclasses") + def _get_pre_config_commands(self) -> list[str]: + """Get commands to execute before entering config mode - override in subclasses if needed""" + return [] + + proc def _handle_pre_config_output(self, output: str) -> bool: + """Handle output from pre-config commands - override in subclasses if needed""" + return True + def configure_and_commit(self, cb: action(err: ?Exception, session_log: str) -> None, config: list[str]) -> None: """Apply configuration and commit atomically with automatic rollback on failure""" if self._state != DRIVER_STATE_READY: @@ -318,18 +349,27 @@ class _BaseDriver(_Driver): self._config_callback = cb self._config_commands = config.copy() - self._transition_to_state(DRIVER_STATE_ENTERING_CONFIG) self._output_buffer = "" self._session_log = "" - # Enter config mode using device-specific commands - config_cmds = self._get_config_commands() - for cmd in config_cmds: - self.ssh_client.send_str(cmd + "\n") + # Check if we need to do pre-config operations (e.g., archive checkpoint for IOS XE) + pre_config_cmds = self._get_pre_config_commands() + if len(pre_config_cmds) > 0: + # Transition to PRE_CONFIG state and execute pre-config commands + self._transition_to_state(DRIVER_STATE_PRE_CONFIG) + for cmd in pre_config_cmds: + self.ssh_client.send_str(cmd + "\n") + else: + # No pre-config needed, go straight to entering config mode + self._transition_to_state(DRIVER_STATE_ENTERING_CONFIG) + # Enter config mode using device-specific commands + config_cmds = self._get_config_commands() + for cmd in config_cmds: + self.ssh_client.send_str(cmd + "\n") - def _handle_commit_failure(self) -> None: - """Handle commit failure by automatically rolling back""" - self.log.warning("Commit failed, automatically rolling back configuration") + def _handle_configuration_failure(self) -> None: + """Handle configuration failure - platform specific behavior""" + self.log.warning("Configuration failed, aborting configuration") self._transition_to_state(DRIVER_STATE_ABORTING_CONFIG) # Send device-specific abort commands @@ -357,11 +397,11 @@ class _BaseDriver(_Driver): for cmd in rollback_cmds: self.ssh_client.send_str(cmd + "\n") - def _get_abort_commands(self) -> list[str]: + proc def _get_abort_commands(self) -> list[str]: """Get device-specific abort commands - must be implemented by subclasses""" raise NotImplementedError("_get_abort_commands must be implemented by subclasses") - def _get_rollback_commands(self, commits_back: int) -> list[str]: + proc def _get_rollback_commands(self, commits_back: int) -> list[str]: """Get device-specific rollback commands - must be implemented by subclasses""" raise NotImplementedError("_get_rollback_commands must be implemented by subclasses") @@ -398,6 +438,8 @@ def create_driver(device_type: str, ssh_client: SSHClientWrapper, log: logging.L return _JuniperDriver(device_type, ssh_client, log) elif device_type == DEVICE_TYPE_CISCO_IOSXR: return _CiscoIOSXRDriver(device_type, ssh_client, log) + elif device_type == DEVICE_TYPE_CISCO_IOSXE: + return _CiscoIOSXEDriver(device_type, ssh_client, log) else: log.warning("Unsupported device type", {"device_type": device_type}) return None @@ -539,3 +581,153 @@ class _CiscoIOSXRDriver(_BaseDriver): "vendor": "Cisco", "os": "IOS XR" } + +class _CiscoIOSXEDriver(_BaseDriver): + """ + Cisco IOS XE driver implementation + + Reuses base driver functionality with platform-specific: + - Prompt patterns + - Error patterns + - Command syntax + - Pre-config archive checkpoint with rollback on failure + """ + + _archive_checkpoint_stack: list[str] # Stack of archive checkpoints for rollback + + def __init__(self, device_type: str, ssh_client: SSHClientWrapper, log: logging.Logger): + _BaseDriver.__init__(self, device_type, ssh_client, log) + self._archive_checkpoint_stack = [] + + def initialize(self) -> None: + """Initialize Cisco IOS XE driver""" + self.log.info("Initializing Cisco IOS XE driver") + + # Send initialization commands + init_commands = [ + "terminal length 0", # Disable pagination + "terminal width 0" # Disable line wrapping + ] + + for cmd in init_commands: + self.ssh_client.send_str(cmd + "\n") + + self._transition_to_state(DRIVER_STATE_READY) + + def _is_device_prompt(self, text: str) -> bool: + """Check if text contains IOS XE prompt""" + lines = text.split('\n') + for line in lines: + stripped = line.strip() + if len(stripped) > 0: + # Exec mode: hostname# or hostname> + match = re.match(r"^[\w\-\.]+[>#]\s*$", stripped) + if match is not None: + return True + # Config mode: hostname(config)# or hostname(config-if)# + match = re.match(r"^[\w\-\.]+\([\w\-]+\)#\s*$", stripped) + if match is not None: + return True + return False + + def _commit_failed(self, output: str) -> bool: + """Check if IOS XE configuration command failed""" + output_lower = output.lower() + return ("% invalid" in output_lower or + "% error" in output_lower or + "% incomplete" in output_lower or + "% unrecognized" in output_lower or + "% ambiguous" in output_lower) + + def _get_config_commands(self) -> list[str]: + """Get IOS XE config mode commands""" + return ["configure terminal"] + + def _get_commit_commands(self) -> list[str]: + """Get IOS XE commit commands (exit and save)""" + return ["end", "write memory"] + + def _get_abort_commands(self) -> list[str]: + """Get IOS XE abort commands - rollback to last checkpoint if available, otherwise just exit""" + if len(self._archive_checkpoint_stack) > 0: + # Rollback to the last checkpoint we created before this configuration + checkpoint = self._archive_checkpoint_stack.pop(-1) + self.log.debug("Rolling back to checkpoint on abort", {"checkpoint": checkpoint}) + return ["end", "configure replace " + checkpoint + " force"] + else: + # No checkpoint available, just exit config mode + self.log.warning("No checkpoint available for rollback on abort") + return ["end"] + + def _get_rollback_commands(self, commits_back: int) -> list[str]: + """Get IOS XE rollback commands using checkpoint stack or relative numbering""" + # First, try to use the checkpoint stack for precise rollback + stack_size = len(self._archive_checkpoint_stack) + if commits_back <= stack_size: + # We have the checkpoint in our stack, use it for precise rollback + checkpoint_index = stack_size - commits_back + checkpoint = self._archive_checkpoint_stack[checkpoint_index] + self.log.debug("Rolling back to checkpoint from stack", { + "commits_back": commits_back, + "checkpoint": checkpoint, + "stack_size": stack_size + }) + # Remove checkpoints that will be rolled back + self._archive_checkpoint_stack = self._archive_checkpoint_stack[:checkpoint_index + 1] + return ["configure replace " + checkpoint + " force"] + raise ValueError("Not enough checkpoints in the stack {commits_back} > {stack_size}") + + def _get_pre_config_commands(self) -> list[str]: + """Get IOS XE pre-config commands - create archive checkpoint""" + return ["archive config", "show archive"] + + def _handle_pre_config_output(self, output: str) -> bool: + """Extract the archive checkpoint name from the output and push to stack""" + # Parse the output to get the archive name + archive_name = _get_iosxe_archive_name(output) + if archive_name is not None: + self._archive_checkpoint_stack.append(archive_name) + self.log.debug("Created archive checkpoint", { + "name": archive_name, + "stack_size": len(self._archive_checkpoint_stack) + }) + return True + else: + self.log.warning("Could not determine archive checkpoint name") + return False + + def get_device_info(self) -> dict[str, str]: + """Get Cisco IOS XE device info""" + return { + "device_type": DEVICE_TYPE_CISCO_IOSXE, + "vendor": "Cisco", + "os": "IOS XE" + } + + +def _get_iosxe_archive_name(show_archive: str): + for line in show_archive.splitlines(): + if "Most Recent" in line: + parts = re.split(r"\s+", line.strip()) + return parts[1] + + +def _test_get_iosxe_archive_name(): + show_archive = """xe#show archive +The maximum archive configurations allowed is 10. +There are currently 8 archive configurations saved. +The next archive file will be named bootflash:archive--8 + Archive # Name + 1 bootflash:archive-Oct--8-06-37-00.779-0 + 2 bootflash:archive-Oct--8-09-03-54.041-1 + 3 bootflash:archive-Oct--8-09-03-57.685-2 + 4 bootflash:archive-Oct--8-09-21-53.231-3 + 5 bootflash:archive-Oct--8-09-21-57.158-4 + 6 bootflash:archive-Oct--8-09-21-57.485-5 + 7 bootflash:archive-Oct--8-09-22-02.266-6 + 8 bootflash:archive-Oct--8-09-38-57.217-7 <- Most Recent + 9 + 10 +""" + archive = _get_iosxe_archive_name(show_archive) + testing.assertEqual(archive, "bootflash:archive-Oct--8-09-38-57.217-7") diff --git a/src/router_example_iosxe.act b/src/router_example_iosxe.act new file mode 100644 index 0000000..24e5ba1 --- /dev/null +++ b/src/router_example_iosxe.act @@ -0,0 +1,316 @@ +import argparse +import logging +import router_client + + +actor IOSXEExample(wc, on_done, username: str, password: str, address: str, port: int): + """Example demonstrating Cisco IOS XE router client usage""" + + # Create logging handler + log_handler = logging.Handler("iosxe_router_example") + log_handler.set_output_level(logging.DEBUG) + log_handler.add_sink(logging.StdoutSink()) + + var client: ?router_client.Client = None + + def on_command_result(next): + def wrap(err: ?Exception, response: ?str): + """Handle command execution results""" + if err is not None: + print("Command failed:", str(err)) + else: + print("Command result:", response if response is not None else "No response") + next() + return wrap + + def on_config_success(response: str) -> None: + """Handle successful configuration""" + print("Configuration applied and saved successfully:", response) + # Proceed to show configuration after successful apply + show_config() + + def on_config_error(err: Exception, response: str) -> None: + """Handle configuration errors""" + print("Configuration failed:", str(err)) + if len(response) > 0: + print("Response:", response) + # Skip to test_bad_config on error + test_bad_config() + + def start_example() -> None: + """Start the Cisco IOS XE router client example""" + print("=== Cisco IOS XE Router Client Example ===") + print("Note: IOS XE applies configuration changes immediately") + print("Archive feature will be used for rollback support") + print("") + + # Create client with Cisco IOS XE parameters + client = router_client.Client( + auth=wc, + address=address, + port=port, + username=username, + password=password, + device_type="cisco_iosxe", + log_handler=log_handler + ) + + # Wait a bit for connection to establish + after 2.0: check_archive_config() + + def check_archive_config() -> None: + """Check if archive is configured on the device""" + c = client + if c is not None and c.is_connected(): + print("Checking archive configuration...") + c.cmd(on_archive_check, "show archive") + after 2.0: execute_commands() + else: + print("Client not connected yet, retrying...") + after 1.0: check_archive_config() + + def on_archive_check(err: ?Exception, response: ?str) -> None: + """Check archive configuration result""" + if err is not None: + print("Archive check failed:", str(err)) + else: + response_str = response if response is not None else "" + if "Archive feature not enabled" in response_str: + print("WARNING: Archive feature not configured on device") + print("Rollback functionality may not work without archive configuration") + print("To configure archive, run these commands manually:") + print(" archive") + print(" path flash:archive-config") + print(" maximum 10") + print(" write-memory") + else: + print("Archive configuration found:", response_str[:200]) + + def execute_commands() -> None: + """Execute some example commands""" + c = client + if c is not None and c.is_connected(): + print("\n=== Executing show commands ===") + print("Executing show version command...") + c.cmd(on_command_result(show_interfaces), "show version") + else: + print("Client not connected") + + def show_interfaces() -> None: + """Show interface status""" + c = client + if c is not None and c.is_connected(): + print("Executing show ip interface brief...") + c.cmd(on_command_result(execute_config), "show ip interface brief") + else: + print("Client not connected") + + def execute_config() -> None: + """Execute configuration example""" + c = client + if c is not None and c.is_connected(): + print("\n=== Applying configuration ===") + print("Note: IOS XE will apply changes immediately") + print("Archive checkpoint will be created automatically") + + config_commands = [ + "interface Loopback100", + "description Test loopback for IOS XE", + "ip address 10.10.10.10 255.255.255.255", + "no shutdown" + ] + + print("Applying configuration:") + for cmd in config_commands: + print(f" {cmd}") + + c.configure_and_commit(on_config_success, on_config_error, config_commands) + else: + print("Client not connected for config") + + def show_config(): + """Show current configuration""" + c = client + if c is not None and c.is_connected(): + print("\n=== Verifying configuration ===") + print("Fetching current configuration...") + c.cmd(on_command_result(verify_interface_exists), "show running-config interface Loopback100") + else: + print("Client not connected for config fetch") + + def on_interface_check(err: ?Exception, response: ?str) -> None: + """Handle interface verification results""" + if err is not None: + print("Interface check failed:", str(err)) + else: + response_str = response if response is not None else "" + if "Loopback100" in response_str and "10.10.10.10" in response_str: + print("✓ VERIFICATION: Loopback100 interface exists with IP 10.10.10.10") + else: + print("✗ VERIFICATION: Loopback100 interface not found or missing IP") + print("Interface details:", response_str) + test_bad_config() # Proceed to next test + + def verify_interface_exists(): + """Verify that Loopback100 interface exists after configuration""" + c = client + if c is not None and c.is_connected(): + print("\n=== Verifying Loopback100 interface exists ===") + c.cmd(on_interface_check, "show ip interface Loopback100") + else: + print("Client not connected for interface verification") + + def test_bad_config() -> None: + """Test configuration error handling with automatic rollback""" + c = client + if c is not None and c.is_connected(): + print("\n=== Testing error handling ===") + print("Applying invalid configuration to test automatic rollback...") + + bad_config = [ + "interface Loopback101", + "ip address invalid.address" # This will fail + ] + + c.configure_and_commit(on_bad_config_success, on_bad_config_error, bad_config) + else: + print("Client not connected for error test") + + def on_bad_config_success(response: str) -> None: + """This should not be called for bad config""" + print("ERROR: Bad configuration was accepted (unexpected):", response) + test_rollback() # Continue to rollback test anyway + + def on_bad_config_error(err: Exception, response: str) -> None: + """Expected error handler for bad configuration""" + print("✓ Configuration error detected as expected:", str(err)) + if "rolled back" in str(err).lower(): + print("✓ Automatic rollback completed") + if len(response) > 0: + print("Error details:", response[:200]) + test_rollback() # Proceed to rollback test + + def on_rollback_result(err: ?Exception, session_log: str) -> None: + """Handle rollback results""" + if err is not None: + print("Rollback failed:", str(err)) + if len(session_log) > 0: + print("Session log:", session_log) + else: + print("✓ Rollback completed successfully") + if len(session_log) > 0 and len(session_log) < 500: + print("Rollback output:", session_log) + show_config_after_rollback() # Proceed to verify rollback + + def test_rollback() -> None: + """Test explicit rollback functionality""" + c = client + if c is not None and c.is_connected(): + print("\n=== Testing Explicit Rollback ===") + print("Rolling back to remove Loopback100 configuration...") + print("Note: This requires archive to be configured on the device") + c.rollback_commits(on_rollback_result, 1) + else: + print("Client not connected for rollback test") + + def show_config_after_rollback(): + """Show configuration after rollback to verify changes""" + c = client + if c is not None and c.is_connected(): + print("\n=== Verifying rollback results ===") + print("Fetching configuration after rollback...") + c.cmd(on_command_result(verify_interface_removed), "show running-config interface Loopback100") + else: + print("Client not connected for post-rollback config fetch") + + def on_interface_check_after_rollback(err: ?Exception, response: ?str) -> None: + """Handle interface verification after rollback""" + if err is not None: + # In IOS XE, if interface doesn't exist, we might get an error + print("Interface check after rollback:", str(err)) + if "Invalid" in str(err) or "does not exist" in str(err): + print("✓ VERIFICATION: Loopback100 interface removed after rollback") + else: + response_str = response if response is not None else "" + # Check if the interface still has our test configuration + if "10.10.10.10" not in response_str and "Test loopback" not in response_str: + print("✓ VERIFICATION: Loopback100 configuration rolled back successfully") + else: + print("✗ VERIFICATION: Loopback100 still has test configuration") + if len(response_str) > 0: + print("Post-rollback interface status:", response_str[:200]) + show_archive_log() # Proceed to show archive log + + def verify_interface_removed(): + """Verify that Loopback100 interface configuration is removed after rollback""" + c = client + if c is not None and c.is_connected(): + print("\n=== Final verification ===") + c.cmd(on_interface_check_after_rollback, "show ip interface Loopback100") + else: + print("Client not connected for post-rollback interface verification") + + def show_archive_log() -> None: + """Show archive log to see configuration changes""" + c = client + if c is not None and c.is_connected(): + print("\n=== Archive log ===") + c.cmd(on_archive_log, "show archive log config all") + else: + print("Client not connected for archive log") + cleanup() # If not connected, proceed to cleanup anyway + + def on_archive_log(err: ?Exception, response: ?str) -> None: + """Handle archive log output""" + if err is not None: + print("Could not fetch archive log:", str(err)) + else: + response_str = response if response is not None else "" + if len(response_str) > 0: + print("Recent configuration changes:") + # Show first 500 chars of archive log + print(response_str[:500]) + if len(response_str) > 500: + print("... (truncated)") + cleanup() # Proceed to cleanup + + def cleanup() -> None: + """Clean up the example""" + print("\n=== Cleanup ===") + c = client + if c is not None: + c.disconnect() + print("Cisco IOS XE example completed") + on_done() + + # Start the example + start_example() + +actor main(env): + """Main entry point for Cisco IOS XE example""" + def done(): + env.exit(0) + + def _parse_args(): + p = argparse.Parser() + p.add_option("username", "str", default="admin", help="Username for authentication", short="u") + p.add_option("password", "str", default="admin", help="Password for authentication", short="p") + p.add_option("address", "str", default="localhost", help="Router IP address or hostname", short="a") + p.add_option("port", "int", default=22, help="SSH port number", short="P") + return p.parse(env.argv) + + try: + args = _parse_args() + + username = args.get_str("username") + password = args.get_str("password") + address = args.get_str("address") + port = args.get_int("port") + + IOSXEExample(env.cap, done, username, password, address, port) + except argparse.PrintUsage as exc: + print(exc.error_message) + env.exit(0) + except argparse.ArgumentError as exc: + print(exc.error_message, err=True) + env.exit(1) diff --git a/src/test_drivers.act b/src/test_drivers.act index d7b3118..0764ab0 100644 --- a/src/test_drivers.act +++ b/src/test_drivers.act @@ -193,10 +193,10 @@ actor _test_commit_failure_automatic_rollback(t: testing.AsyncT): t.failure(Exception("Expected error due to commit failure")) return - # Verify error indicates rollback occurred + # Verify error indicates configuration was aborted error_msg = str(err) - if "automatically rolled back" not in error_msg: - t.failure(Exception("Expected rollback error message, got: " + error_msg)) + if "Configuration failed and was aborted" not in error_msg: + t.failure(Exception("Expected abort error message, got: " + error_msg)) return # Return error message, session log, and commands @@ -218,9 +218,12 @@ actor _test_driver_state_transition_validation(t: testing.AsyncT): # Test valid transitions valid_tests = [ (drivers.DRIVER_STATE_READY, drivers.DRIVER_STATE_EXECUTING_COMMAND), + (drivers.DRIVER_STATE_READY, drivers.DRIVER_STATE_PRE_CONFIG), (drivers.DRIVER_STATE_READY, drivers.DRIVER_STATE_ENTERING_CONFIG), + (drivers.DRIVER_STATE_PRE_CONFIG, drivers.DRIVER_STATE_ENTERING_CONFIG), (drivers.DRIVER_STATE_EXECUTING_COMMAND, drivers.DRIVER_STATE_READY), - (drivers.DRIVER_STATE_CONFIG_MODE, drivers.DRIVER_STATE_APPLYING_CONFIG) + (drivers.DRIVER_STATE_CONFIG_MODE, drivers.DRIVER_STATE_APPLYING_CONFIG), + (drivers.DRIVER_STATE_APPLYING_CONFIG, drivers.DRIVER_STATE_ABORTING_CONFIG) ] for from_state, to_state in valid_tests: @@ -232,7 +235,9 @@ actor _test_driver_state_transition_validation(t: testing.AsyncT): invalid_tests = [ (drivers.DRIVER_STATE_EXECUTING_COMMAND, drivers.DRIVER_STATE_CONFIG_MODE), (drivers.DRIVER_STATE_APPLYING_CONFIG, drivers.DRIVER_STATE_EXECUTING_COMMAND), - (drivers.DRIVER_STATE_COMMITTING, drivers.DRIVER_STATE_ENTERING_CONFIG) + (drivers.DRIVER_STATE_COMMITTING, drivers.DRIVER_STATE_ENTERING_CONFIG), + (drivers.DRIVER_STATE_PRE_CONFIG, drivers.DRIVER_STATE_CONFIG_MODE), + (drivers.DRIVER_STATE_PRE_CONFIG, drivers.DRIVER_STATE_READY) ] for from_state, to_state in invalid_tests: diff --git a/test/golden/test_drivers/commit_failure_automatic_rollback b/test/golden/test_drivers/commit_failure_automatic_rollback index 190b9f1..55ed388 100644 --- a/test/golden/test_drivers/commit_failure_automatic_rollback +++ b/test/golden/test_drivers/commit_failure_automatic_rollback @@ -1,4 +1,4 @@ -error:Exception: Configuration commit failed but was automatically rolled back +error:Exception: Configuration failed and was aborted session_log:configure user@device# set invalid config user@device# commit diff --git a/test/golden/test_drivers/driver_state_transition_validation b/test/golden/test_drivers/driver_state_transition_validation index 88ef4e3..3344212 100644 --- a/test/golden/test_drivers/driver_state_transition_validation +++ b/test/golden/test_drivers/driver_state_transition_validation @@ -1,2 +1,2 @@ -valid_transitions:['ready->executing_command:valid', 'ready->entering_config:valid', 'executing_command->ready:valid', 'config_mode->applying_config:valid'] -invalid_transitions:['executing_command->config_mode:invalid', 'applying_config->executing_command:invalid', 'committing->entering_config:invalid'] \ No newline at end of file +valid_transitions:['ready->executing_command:valid', 'ready->pre_config:valid', 'ready->entering_config:valid', 'pre_config->entering_config:valid', 'executing_command->ready:valid', 'config_mode->applying_config:valid', 'applying_config->aborting_config:valid'] +invalid_transitions:['executing_command->config_mode:invalid', 'applying_config->executing_command:invalid', 'committing->entering_config:invalid', 'pre_config->config_mode:invalid', 'pre_config->ready:invalid'] \ No newline at end of file From f3623ea1047cef22bd61ad8240ce21250693d1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Zago=C5=BEen?= Date: Mon, 20 Oct 2025 13:33:18 +0200 Subject: [PATCH 2/4] Stop using --dev for acton build --- .github/workflows/test.yml | 2 +- CLAUDE.md | 4 ++-- Makefile | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a586c6b..9b0a362 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: ~/.cache/acton out key: acton-${{ hashFiles('**/build.act.json') }}-${{ steps.setup-acton.outputs.version }} - - run: acton build --dev + - run: acton build - run: acton test - run: acton test perf - uses: actions/upload-artifact@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 50c9ec1..a1c09cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build Commands -- **Build project**: `acton build --dev` +- **Build project**: `acton build` - **Run tests**: `acton test` ## Testing @@ -133,4 +133,4 @@ Valid state transitions: - Buffer class changes have highest impact but require careful testing - All changes should maintain core functionality and test coverage - Focus on simplicity and maintainability over preserving existing APIs -- Before writing code, prepare a detailed plan for changes \ No newline at end of file +- Before writing code, prepare a detailed plan for changes diff --git a/Makefile b/Makefile index 794a722..dc6145b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build build: - acton build --dev $(DEP_OVERRIDES) $(TARGET) + acton build $(DEP_OVERRIDES) $(TARGET) .PHONY: build-linux build-linux: From 5463e43870504142ddaa3d4b8de2d8c63af4594a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Zago=C5=BEen?= Date: Mon, 20 Oct 2025 15:32:31 +0200 Subject: [PATCH 3/4] Run quicklab-xe in CI --- .github/workflows/test.yml | 2 +- test/common/containers.mk | 13 +++++++++++-- test/common/quicklab.mk | 24 +++++++++++------------ test/quicklab-xe/Makefile | 40 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 15 deletions(-) create mode 100644 test/quicklab-xe/Makefile diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b0a362..cfdbdde 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: strategy: fail-fast: false matrix: - TESTENV: [quicklab-crpd, quicklab-xrd] + TESTENV: [quicklab-crpd, quicklab-xrd, quicklab-xe] runs-on: ubuntu-24.04 env: IMAGE_PATH: ${{ secrets.IMAGE_PATH || format('ghcr.io/{0}/', github.repository) }} diff --git a/test/common/containers.mk b/test/common/containers.mk index 7d3eaa9..a769a1e 100644 --- a/test/common/containers.mk +++ b/test/common/containers.mk @@ -1,4 +1,4 @@ -.PHONY: $(addprefix platform-wait-,$(ROUTERS_XR) $(ROUTERS_CRPD)) +.PHONY: $(addprefix platform-wait-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)) $(addprefix platform-wait-,$(ROUTERS_XR)): # Wait for "smartlicserver[212]: %LICENSE-SMART_LIC-3-COMM_FAILED : Communications failure with the Cisco Smart License Utility (CSLU) : Unable to resolve server hostname/domain name" to appear in the container logs @@ -10,10 +10,19 @@ $(addprefix platform-wait-,$(ROUTERS_CRPD)): timeout --foreground $(WAIT) bash -c "until docker logs $(TESTENV)-$(@:platform-wait-%=%) 2>&1 | grep -q 'Server listening on unix:/var/run/japi_na-grpcd'; do sleep 1; done" docker run $(INTERACTIVE) --rm --network container:$(TESTENV)-netcli ghcr.io/notconf/notconf:debug netconf-console2 --host $(@:platform-wait-%=%) --port 830 --user clab --pass clab@123 --hello -.PHONY: cli $(addprefix platform-cli-,$(ROUTERS_XR) $(ROUTERS_CRPD)) +$(addprefix platform-wait-,$(ROUTERS_XE)): +# Wait for "Startup complete" to appear in the vrnetlab container logs + timeout --foreground $(WAIT) bash -c "until docker logs $(TESTENV)-$(@:platform-wait-%=%) 2>&1 | grep -q 'Startup complete'; do sleep 1; done" + +.PHONY: cli $(addprefix platform-cli-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)) $(addprefix platform-cli-,$(ROUTERS_XR)): docker exec -it $(TESTENV)-$(subst platform-cli-,,$@) /pkg/bin/xr_cli.sh $(addprefix platform-cli-,$(ROUTERS_CRPD)): docker exec -it $(TESTENV)-$(subst platform-cli-,,$@) cli + +$(addprefix platform-cli-,$(ROUTERS_XE)): + @CONTAINER_IP=$$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(TESTENV)-$(@:platform-cli-%=%)); \ + echo "Connecting to IOS XE console at $$CONTAINER_IP:5000"; \ + telnet $$CONTAINER_IP 5000 diff --git a/test/common/quicklab.mk b/test/common/quicklab.mk index c05f5bf..43c5f85 100644 --- a/test/common/quicklab.mk +++ b/test/common/quicklab.mk @@ -34,9 +34,9 @@ start: build-netcli-image stop: $(CLAB_BIN) destroy --topo $(TESTENV:netcli-%=%).clab.yml --log-level debug -.PHONY: wait $(addprefix wait-,$(ROUTERS_XR) $(ROUTERS_CRPD)) +.PHONY: wait $(addprefix wait-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)) WAIT?=60 -wait: $(addprefix platform-wait-,$(ROUTERS_XR) $(ROUTERS_CRPD)) +wait: $(addprefix platform-wait-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)) copy: docker cp ../../out/bin/$(TEST_BINARY) $(TESTENV)-netcli:/$(TEST_BINARY) @@ -52,11 +52,11 @@ endif shell: docker exec -it $(TESTENV)-netcli bash -l -.PHONY: $(addprefix cli-,$(ROUTERS_XR) $(ROUTERS_CRPD)) -$(addprefix cli-,$(ROUTERS_XR) $(ROUTERS_CRPD)): cli-%: platform-cli-% +.PHONY: $(addprefix cli-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)) +$(addprefix cli-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)): cli-%: platform-cli-% -.PHONY: $(addprefix get-dev-config-,$(ROUTERS_XR) $(ROUTERS_CRPD)) -$(addprefix get-dev-config-,$(ROUTERS_XR) $(ROUTERS_CRPD)): +.PHONY: $(addprefix get-dev-config-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)) +$(addprefix get-dev-config-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)): docker run $(INTERACTIVE) --rm --network container:$(TESTENV)-netcli ghcr.io/notconf/notconf:debug netconf-console2 --host $(@:get-dev-config-%=%) --port 830 --user clab --pass clab@123 --get-config # Filter to remove Junos metadata attributes that change with each commit @@ -64,18 +64,18 @@ FILTER_JUNOS_METADATA = sed 's/ junos:commit-seconds="[0-9]*"//g; s/ junos:commi .phony: test test:: - $(MAKE) $(addprefix get-dev-config-,$(ROUTERS_XR) $(ROUTERS_CRPD)) | $(FILTER_JUNOS_METADATA) > config-snapshot-before.txt - for router in $(ROUTERS_XR) $(ROUTERS_CRPD); do \ + $(MAKE) $(addprefix get-dev-config-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)) | $(FILTER_JUNOS_METADATA) > config-snapshot-before.txt + for router in $(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE); do \ docker exec $(INTERACTIVE) $(TESTENV)-netcli /$(TEST_BINARY) --address $$router --port 22 --username clab --password clab@123; \ done - $(MAKE) $(addprefix get-dev-config-,$(ROUTERS_XR) $(ROUTERS_CRPD)) | $(FILTER_JUNOS_METADATA) > config-snapshot-after.txt + $(MAKE) $(addprefix get-dev-config-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)) | $(FILTER_JUNOS_METADATA) > config-snapshot-after.txt diff -u config-snapshot-before.txt config-snapshot-after.txt .PHONY: save-logs -save-logs: $(addprefix save-logs-,$(ROUTERS_XR) $(ROUTERS_CRPD)) +save-logs: $(addprefix save-logs-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)) -.PHONY: $(addprefix save-logs-,$(ROUTERS_XR) $(ROUTERS_CRPD)) -$(addprefix save-logs-,$(ROUTERS_XR) $(ROUTERS_CRPD)): +.PHONY: $(addprefix save-logs-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)) +$(addprefix save-logs-,$(ROUTERS_XR) $(ROUTERS_CRPD) $(ROUTERS_XE)): mkdir -p logs docker logs --timestamps $(TESTENV)-$(@:save-logs-%=%) > logs/$(@:save-logs-%=%)_docker.log 2>&1 $(MAKE) get-dev-config-$(@:save-logs-%=%) > logs/$(@:save-logs-%=%)_netconf.log || true diff --git a/test/quicklab-xe/Makefile b/test/quicklab-xe/Makefile new file mode 100644 index 0000000..473f46a --- /dev/null +++ b/test/quicklab-xe/Makefile @@ -0,0 +1,40 @@ +TESTENV=netcli-quicklab-xe +TEST_BINARY=router_example_iosxe + +# Define router types +ROUTERS_XR= +ROUTERS_CRPD= +ROUTERS_XE=xe1 + +# Image path for vrnetlab containers +IMAGE_PATH ?= ghcr.io/orchestron-orchestrator/ +WAIT=240 + +# Include common makefiles first +include ../common/clab.mk +include ../common/containers.mk +include ../common/quicklab.mk + +# Override start target to start both XE container and netcli container +.PHONY: start +start: build-netcli-image + @echo "Creating network $(TESTENV)..." + docker network create $(TESTENV) || true + @echo "Starting IOS XE container..." + docker run -td --name $(TESTENV)-xe1 --network-alias xe1 --rm --privileged \ + --network $(TESTENV) \ + $(IMAGE_PATH)vrnetlab/vr-c8000v:17.15.03a --username clab --password clab@123 --trace + @echo "Starting netcli container..." + docker run -td --name $(TESTENV)-netcli --rm \ + --network $(TESTENV) \ + netcli-base bash -c "while true; do sleep 3600; done" + @echo "Waiting for IOS XE to boot (this may take 2-3 minutes)..." + +# Override stop target to stop both containers and remove network +.PHONY: stop +stop: + @echo "Stopping containers..." + @docker stop $(TESTENV)-netcli || true + @docker stop $(TESTENV)-xe1 || true + @echo "Removing network..." + @docker network rm $(TESTENV) || true From c35e8ada1e1bcb7315940ea57b33d5c5b3670485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Zago=C5=BEen?= Date: Mon, 20 Oct 2025 22:00:29 +0200 Subject: [PATCH 4/4] Add skip_pre_config parameter to configure_and_commit Allows configuring archive on IOS XE without creating a checkpoint. Archive is required for checkpoint creation but must be configured first, so the first configure_and_commit is executed without the option to rollback. --- src/drivers.act | 44 +++++++++++++++++---------- src/router_client.act | 7 +++-- src/router_example_iosxe.act | 59 +++++++++++++++++++++++++++--------- 3 files changed, 78 insertions(+), 32 deletions(-) diff --git a/src/drivers.act b/src/drivers.act index 1cae7bf..4587c6d 100644 --- a/src/drivers.act +++ b/src/drivers.act @@ -73,7 +73,10 @@ class _Driver(object): get_state: proc() -> str handle_data: proc(data: bytes) -> None execute_command: proc(cb: action(err: ?Exception, response: ?str) -> None, command: str) -> None - configure_and_commit: proc(cb: action(err: ?Exception, session_log: str) -> None, config: list[str]) -> None + # TODO: default arg? + #configure_and_commit: proc(cb: action(err: ?Exception, session_log: str) -> None, config: list[str], skip_pre_config: bool) -> None + def configure_and_commit(self, cb: action(err: ?Exception, session_log: str) -> None, config: list[str], skip_pre_config: bool=False) -> None: + raise NotImplementedError() rollback_configuration: proc(cb: action(err: ?Exception, session_log: str) -> None, commits_back: int) -> None get_device_info: proc() -> dict[str, str] @@ -341,8 +344,15 @@ class _BaseDriver(_Driver): """Handle output from pre-config commands - override in subclasses if needed""" return True - def configure_and_commit(self, cb: action(err: ?Exception, session_log: str) -> None, config: list[str]) -> None: - """Apply configuration and commit atomically with automatic rollback on failure""" + def configure_and_commit(self, cb: action(err: ?Exception, session_log: str) -> None, config: list[str], skip_pre_config: bool=False) -> None: + """Apply configuration and commit atomically with automatic rollback on failure + + Args: + cb: Callback function + config: Configuration commands + skip_pre_config: Skip pre-configuration steps (e.g., archive checkpoint). + WARNING: This disables automatic rollback on failure! + """ if self._state != DRIVER_STATE_READY: cb(Exception("Driver not ready - current state: " + self._state), "") return @@ -353,19 +363,21 @@ class _BaseDriver(_Driver): self._session_log = "" # Check if we need to do pre-config operations (e.g., archive checkpoint for IOS XE) - pre_config_cmds = self._get_pre_config_commands() - if len(pre_config_cmds) > 0: - # Transition to PRE_CONFIG state and execute pre-config commands - self._transition_to_state(DRIVER_STATE_PRE_CONFIG) - for cmd in pre_config_cmds: - self.ssh_client.send_str(cmd + "\n") - else: - # No pre-config needed, go straight to entering config mode - self._transition_to_state(DRIVER_STATE_ENTERING_CONFIG) - # Enter config mode using device-specific commands - config_cmds = self._get_config_commands() - for cmd in config_cmds: - self.ssh_client.send_str(cmd + "\n") + if not skip_pre_config: + pre_config_cmds = self._get_pre_config_commands() + if len(pre_config_cmds) > 0: + # Transition to PRE_CONFIG state and execute pre-config commands + self._transition_to_state(DRIVER_STATE_PRE_CONFIG) + for cmd in pre_config_cmds: + self.ssh_client.send_str(cmd + "\n") + return # Will continue from PRE_CONFIG state handler + + # Skip pre-config or no pre-config needed, go straight to entering config mode + self._transition_to_state(DRIVER_STATE_ENTERING_CONFIG) + # Enter config mode using device-specific commands + config_cmds = self._get_config_commands() + for cmd in config_cmds: + self.ssh_client.send_str(cmd + "\n") def _handle_configuration_failure(self) -> None: """Handle configuration failure - platform specific behavior""" diff --git a/src/router_client.act b/src/router_client.act index 16231a9..43e3d14 100644 --- a/src/router_client.act +++ b/src/router_client.act @@ -90,7 +90,8 @@ actor Client(auth: WorldCap, def configure_and_commit(on_success: action(response: str) -> None, on_error: action(err: Exception, response: str) -> None, - config: list[str]) -> None: + config: list[str], + skip_pre_config: bool = False) -> None: """ Apply configuration and commit atomically @@ -102,6 +103,8 @@ actor Client(auth: WorldCap, on_success: Callback for successful commit (receives response text) on_error: Callback for any failure (receives error and response text) config: List of configuration commands + skip_pre_config: Skip pre-configuration steps (e.g., archive checkpoint). + WARNING: This disables automatic rollback on failure! """ if _driver is not None and is_connected(): def _on_complete(err: ?Exception, session_log: str) -> None: @@ -110,7 +113,7 @@ actor Client(auth: WorldCap, else: on_success(session_log) - _driver.configure_and_commit(_on_complete, config) + _driver.configure_and_commit(_on_complete, config, skip_pre_config) else: on_error(Exception("Not connected or driver not initialized"), "") diff --git a/src/router_example_iosxe.act b/src/router_example_iosxe.act index 24e5ba1..fb717de 100644 --- a/src/router_example_iosxe.act +++ b/src/router_example_iosxe.act @@ -56,35 +56,66 @@ actor IOSXEExample(wc, on_done, username: str, password: str, address: str, port ) # Wait a bit for connection to establish - after 2.0: check_archive_config() + after 2.0: configure_archive() + + def configure_archive() -> None: + """Configure archive on the device""" + c = client + if c is not None and c.is_connected(): + print("Configuring archive for rollback support...") + print("Note: Using skip_pre_config=True, no automatic rollback for this operation") + + archive_config = [ + "archive", + "log config", + "logging enable", + "exit", + "path bootflash:archive", + "exit" + ] + + # Use skip_pre_config=True since we're configuring archive itself + c.configure_and_commit(on_archive_configured, on_archive_config_error, archive_config, True) + else: + print("Client not connected yet, retrying...") + after 1.0: configure_archive() + + def on_archive_configured(response: str) -> None: + """Handle successful archive configuration""" + print("Archive configured successfully") + after 1.0: check_archive_config() + + def on_archive_config_error(err: Exception, response: str) -> None: + """Handle archive configuration error""" + print("ERROR: Archive configuration failed:", str(err)) + print("Cannot continue without archive support") + cleanup() def check_archive_config() -> None: """Check if archive is configured on the device""" c = client if c is not None and c.is_connected(): - print("Checking archive configuration...") + print("Verifying archive configuration...") c.cmd(on_archive_check, "show archive") - after 2.0: execute_commands() else: - print("Client not connected yet, retrying...") - after 1.0: check_archive_config() + print("Client not connected") + cleanup() def on_archive_check(err: ?Exception, response: ?str) -> None: """Check archive configuration result""" if err is not None: print("Archive check failed:", str(err)) + print("Cannot verify archive configuration") + cleanup() else: response_str = response if response is not None else "" - if "Archive feature not enabled" in response_str: - print("WARNING: Archive feature not configured on device") - print("Rollback functionality may not work without archive configuration") - print("To configure archive, run these commands manually:") - print(" archive") - print(" path flash:archive-config") - print(" maximum 10") - print(" write-memory") + if "Archive feature not enabled" in response_str or "No archive configurations" in response_str: + print("ERROR: Archive feature still not configured on device") + print("Cannot continue without archive support") + cleanup() else: - print("Archive configuration found:", response_str[:200]) + print("Archive configuration verified:", response_str[:200]) + after 1.0: execute_commands() def execute_commands() -> None: """Execute some example commands"""