diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a586c6b..cfdbdde 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 @@ -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/CLAUDE.md b/CLAUDE.md index 261dd3e..a1c09cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,22 @@ 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 + +### 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 @@ -102,4 +133,4 @@ INITIALIZING → READY → EXECUTING_COMMAND → READY - 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: diff --git a/src/drivers.act b/src/drivers.act index 4e4ab6d..4587c6d 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" @@ -70,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] @@ -126,11 +132,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 +193,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 +208,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 +236,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 +270,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,26 +336,52 @@ class _BaseDriver(_Driver): """Get device-specific commit commands - must be implemented by subclasses""" raise NotImplementedError("_get_commit_commands must be implemented by subclasses") - 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 _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], 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 self._config_callback = cb self._config_commands = config.copy() - self._transition_to_state(DRIVER_STATE_ENTERING_CONFIG) self._output_buffer = "" self._session_log = "" + # Check if we need to do pre-config operations (e.g., archive checkpoint for IOS XE) + 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_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 +409,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 +450,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 +593,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_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 new file mode 100644 index 0000000..fb717de --- /dev/null +++ b/src/router_example_iosxe.act @@ -0,0 +1,347 @@ +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: 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("Verifying archive configuration...") + c.cmd(on_archive_check, "show archive") + else: + 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 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 verified:", response_str[:200]) + after 1.0: execute_commands() + + 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/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/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 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