diff --git a/Makefile b/Makefile index 0dfc069b..6537e95a 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ PYPIREPO = pypitest DEPENDENCIES = robotframework pyyaml dill coverage Sphinx \ sphinxcontrib-napoleon sphinxcontrib-mockautodoc \ - sphinx-rtd-theme asyncssh PrettyTable "cryptography>=44.0" + sphinx-rtd-theme asyncssh PrettyTable "cryptography>=43.0" .PHONY: clean package distribute develop undevelop help devnet\ diff --git a/docs/changelog/2025/february.rst b/docs/changelog/2025/february.rst new file mode 100644 index 00000000..f91bcd29 --- /dev/null +++ b/docs/changelog/2025/february.rst @@ -0,0 +1,45 @@ +February 2025 +========== + +February 25 - Unicon v25.2 +------------------------ + + + +.. csv-table:: Module Versions + :header: "Modules", "Versions" + + ``unicon.plugins``, v25.2 + ``unicon``, v25.2 + + + + +Changelogs +^^^^^^^^^^ +-------------------------------------------------------------------------------- + Fix +-------------------------------------------------------------------------------- + +* router.connection_provider + * Modified disconnect + * Added sendline('exit') on disconnect + + +-------------------------------------------------------------------------------- + Fix +-------------------------------------------------------------------------------- + +* generic + * updated exception for Recover device using golden image if reload is failed. + + +-------------------------------------------------------------------------------- + New +-------------------------------------------------------------------------------- + +* iosxe + * Added to Configure Error Patterns + * Added the regex to match error pattern "127.0 / 255.0 is an invalid network." + + diff --git a/docs/changelog/2025/january.rst b/docs/changelog/2025/january.rst index 8c1ef435..c9c0b793 100644 --- a/docs/changelog/2025/january.rst +++ b/docs/changelog/2025/january.rst @@ -56,6 +56,4 @@ Changelogs * Added below config error patterns * % VLAN [] already in use * Added below config error patterns - * % VNI is either already in use or exceeds the maximum allowable VNIs. - - + * % VNI is either already in use or exceeds the maximum allowable VNIs. \ No newline at end of file diff --git a/docs/changelog/index.rst b/docs/changelog/index.rst index a0fa018b..f8818805 100644 --- a/docs/changelog/index.rst +++ b/docs/changelog/index.rst @@ -4,6 +4,7 @@ Changelog .. toctree:: :maxdepth: 2 + 2025/february 2025/january 2024/november 2024/october diff --git a/docs/changelog_plugins/2025/february.rst b/docs/changelog_plugins/2025/february.rst new file mode 100644 index 00000000..abc6f684 --- /dev/null +++ b/docs/changelog_plugins/2025/february.rst @@ -0,0 +1,36 @@ +February 2025 +========== + +February 25 - Unicon.Plugins v25.2 +------------------------ + + + +.. csv-table:: Module Versions + :header: "Modules", "Versions" + + ``unicon.plugins``, v25.2 + ``unicon``, v25.2 + + + + +Changelogs +^^^^^^^^^^ +-------------------------------------------------------------------------------- + Fix +-------------------------------------------------------------------------------- + +* generic + * Updates to setup patterns + * Update connection refused handler to clear line after max count + * Update token discovery to use rv1 parsers + * Update token discovery to handle standby locked devices + +* staros + * Update prompt pattern + +* iosxe + * Updated the enable, disable and maintenance states to support `(unlicensed)` prompt + + diff --git a/docs/changelog_plugins/2025/january.rst b/docs/changelog_plugins/2025/january.rst index e180b782..204d5a75 100644 --- a/docs/changelog_plugins/2025/january.rst +++ b/docs/changelog_plugins/2025/january.rst @@ -37,6 +37,4 @@ Changelogs * update syslog message pattern * unicon.plugins - * Fix syntax warning - - + * Fix syntax warning \ No newline at end of file diff --git a/docs/changelog_plugins/index.rst b/docs/changelog_plugins/index.rst index e663d67b..30cf5fe5 100644 --- a/docs/changelog_plugins/index.rst +++ b/docs/changelog_plugins/index.rst @@ -4,6 +4,7 @@ Plugins Changelog .. toctree:: :maxdepth: 2 + 2025/february 2025/january 2024/november 2024/october diff --git a/setup.py b/setup.py index 26355090..f05dcf46 100755 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ def version_info(*paths): install_requires = ['unicon {range}'.format(range = version_range), 'pyyaml', 'PrettyTable', - 'cryptography>=44.0'] + 'cryptography>=43.0'] # launch setup setup( diff --git a/src/unicon/plugins/__init__.py b/src/unicon/plugins/__init__.py index 2c4f89c7..83f3d5d2 100644 --- a/src/unicon/plugins/__init__.py +++ b/src/unicon/plugins/__init__.py @@ -1,4 +1,4 @@ -__version__ = '25.1' +__version__ = '25.2' supported_chassis = [ 'single_rp', diff --git a/src/unicon/plugins/generic/patterns.py b/src/unicon/plugins/generic/patterns.py index bc9099ce..e5177b26 100644 --- a/src/unicon/plugins/generic/patterns.py +++ b/src/unicon/plugins/generic/patterns.py @@ -51,8 +51,8 @@ def __init__(self): self.press_ctrlx = r"^(.*?)Press Ctrl\+x to Exit the session" self.connected = r'^(.*?)Connected.' - self.enter_basic_mgmt_setup = r'Would you like to enter basic management setup\? \[yes/no\]:' - self.kerberos_no_realm = r'^(.*)Kerberos:\s*No default realm defined for Kerberos!' + self.enter_basic_mgmt_setup = r'Would you like to enter basic management setup\? \[yes/no\]:\s*$' + self.kerberos_no_realm = r'^(.*)Kerberos:\s*No default realm defined for Kerberos!\s*$' self.passphrase_prompt = r'^.*Enter passphrase for key .*?:\s*?' diff --git a/src/unicon/plugins/generic/service_implementation.py b/src/unicon/plugins/generic/service_implementation.py index a216f867..77700048 100644 --- a/src/unicon/plugins/generic/service_implementation.py +++ b/src/unicon/plugins/generic/service_implementation.py @@ -1172,7 +1172,8 @@ def call_service(self, except Exception as e: if hasattr(con.device, 'clean') and hasattr(con.device.clean, 'device_recovery') and\ con.device.clean.device_recovery.get('golden_image'): - con.log.error(f'Reload failed booting device using golden image: {con.device.clean.device_recovery["golden_image"]}') + con.log.exception(f"Reload failed to install with file: {getattr(con.device.clean, 'images', [None])[0]}") + con.log.info(f'Booting the device using golden_image.') con.device.api.device_recovery_boot(golden_image=con.device.clean.device_recovery['golden_image']) con.log.info('Successfully booted the device using golden_image.') raise diff --git a/src/unicon/plugins/generic/settings.py b/src/unicon/plugins/generic/settings.py index 2bb32dce..1d63ef06 100644 --- a/src/unicon/plugins/generic/settings.py +++ b/src/unicon/plugins/generic/settings.py @@ -55,6 +55,7 @@ def __init__(self): self.CONSOLE_TIMEOUT = 60 self.BOOT_TIMEOUT = 600 self.MAX_BOOT_ATTEMPTS = 3 + self.CONNECTION_REFUSED_MAX_COUNT = 3 # Temporary enable secret used during setup # this is used if no password is available diff --git a/src/unicon/plugins/generic/statements.py b/src/unicon/plugins/generic/statements.py index ee3f4d74..b61354f8 100644 --- a/src/unicon/plugins/generic/statements.py +++ b/src/unicon/plugins/generic/statements.py @@ -43,13 +43,16 @@ def terminal_position_handler(spawn, session, context): spawn.send('\x1b[0;200R') -def connection_refused_handler(spawn): +def connection_refused_handler(spawn, context): """ handles connection refused scenarios """ - if spawn.device: - spawn.device.api.execute_clear_line() - spawn.device.connect() - return + context.setdefault('connection_refused_count', 0) + context['connection_refused_count'] += 1 + if context.get('connection_refused_count') < spawn.settings.CONNECTION_REFUSED_MAX_COUNT: + if spawn.device: + spawn.device.api.execute_clear_line() + spawn.device.connect() + return raise Exception('Connection refused to device %s' % (str(spawn))) diff --git a/src/unicon/plugins/iosxe/patterns.py b/src/unicon/plugins/iosxe/patterns.py index 5258d44a..01d6eafc 100644 --- a/src/unicon/plugins/iosxe/patterns.py +++ b/src/unicon/plugins/iosxe/patterns.py @@ -23,11 +23,11 @@ def __init__(self): self.want_continue_confirm = r'.*Do you want to continue\?\s*\[confirm]\s*$' self.want_continue_yes = r'.*Do you want to continue\?\s*\[y/n]\?\s*\[yes]:\s*$' self.disable_prompt = \ - r'^(.*?)(WLC|Router|RouterRP|Switch|ios|switch|%N)([0-9])?(\(standby\))?(-stby)?(-standby)?(\(boot\))?(\(recovery-mode\))?>\s?$' + r'^(.*?)(\(unlicensed\))?(WLC|Router|RouterRP|Switch|ios|switch|%N)([0-9])?(\(standby\))?(-stby)?(-standby)?(\(boot\))?(\(recovery-mode\))?>\s?$' self.enable_prompt = \ - r'^(.*?)(WLC|Router|RouterRP|Switch|ios|switch|%N)([0-9])?(\(standby\))?(-stby)?(-standby)?(\(boot\))?(\(recovery-mode\))?#[\s\x07]*$' + r'^(.*?)(\(unlicensed\))?(WLC|Router|RouterRP|Switch|ios|switch|%N)([0-9])?(\(standby\))?(-stby)?(-standby)?(\(boot\))?(\(recovery-mode\))?#[\s\x07]*$' self.maintenance_mode_prompt = \ - r'^(.*?)(WLC|Router|RouterRP|Switch|ios|switch|%N)([0-9])?(\(standby\))?(-stby)?(-standby)?(\(boot\))?\(maint-mode\)#[\s\x07]*$' + r'^(.*?)(\(unlicensed\))?(WLC|Router|RouterRP|Switch|ios|switch|%N)([0-9])?(\(standby\))?(-stby)?(-standby)?(\(boot\))?\(maint-mode\)#[\s\x07]*$' self.press_enter = ReloadPatterns().press_enter self.config_prompt = r'^(.*)\((?!.*pki-hexmode).*(con|cfg|ipsec-profile|ca-trustpoint|ca-certificate-map|cs-server|ca-profile|gkm-local-server|cloud|host-list|config-gkm-group|gkm-sa-ipsec|gdoi-coop-ks-config|wsma|enforce-rule|DDNS)\S*\)#\s?$' diff --git a/src/unicon/plugins/iosxe/settings.py b/src/unicon/plugins/iosxe/settings.py index 9293b2ff..dc9875fe 100644 --- a/src/unicon/plugins/iosxe/settings.py +++ b/src/unicon/plugins/iosxe/settings.py @@ -26,6 +26,7 @@ def __init__(self): r'routing table \S+ does not exist', r'^%\s*SR feature is not configured yet, please enable Segment-routing first.', r'^%\s*\S+ overlaps with \S+', + r'^\S+ / \S+ is an [Ii]nvalid network\.', r'^%\S+ is linked to a VRF. Enable \S+ on that VRF first.', r'% VRF \S+ not configured', r'% Incomplete command.', diff --git a/src/unicon/plugins/iosxe/statements.py b/src/unicon/plugins/iosxe/statements.py index ce46dea2..fa4d0569 100644 --- a/src/unicon/plugins/iosxe/statements.py +++ b/src/unicon/plugins/iosxe/statements.py @@ -230,10 +230,11 @@ def boot_finished_deco(func): ''' @wraps(func) - def wrapper(spawn, context, session): + def wrapper(spawn, session, context, **kwargs): + args = [a for a in [spawn, session, context] if a] if context: context.pop('boot_start_time', None) - return func(spawn) + return func(*args, **kwargs) return wrapper diff --git a/src/unicon/plugins/iosxr/settings.py b/src/unicon/plugins/iosxr/settings.py index 009e9620..8e11fc6f 100755 --- a/src/unicon/plugins/iosxr/settings.py +++ b/src/unicon/plugins/iosxr/settings.py @@ -49,6 +49,8 @@ def __init__(self): r'^%\s*Failed to commit.*' ] + self.HA_STANDBY_UNLOCK_COMMANDS = [] + self.EXECUTE_MATCHED_RETRIES = 1 self.EXECUTE_MATCHED_RETRY_SLEEP = 0.1 diff --git a/src/unicon/plugins/staros/patterns.py b/src/unicon/plugins/staros/patterns.py index fdec32be..0e888e2c 100644 --- a/src/unicon/plugins/staros/patterns.py +++ b/src/unicon/plugins/staros/patterns.py @@ -5,8 +5,8 @@ class StarosPatterns(GenericPatterns): def __init__(self): super().__init__() - self.exec_prompt = r'^(.*?)(\[\S+\]%N[#>])\s*$' - self.config_prompt = r'^(.*?)(\[\S+\]%N\(\S+\)[#>])\s*$' + self.exec_prompt = r'^(.*?)(\[\S+\] ?%N[#>])\s*$' + self.config_prompt = r'^(.*?)(\[\S+\] ?%N\(\S+\)[#>])\s*$' self.monitor_main_prompt = r'^(.*?\(Q\)uit,\s+ Prev Menu,\s+ Pause,\s+ Re-Display Options.*)$' self.monitor_sub_prompt = r'^(.*?\(B\)egin Protocol Decoding\s+\(Q\)uit,\s+ Prev Menu,\s+ Re-Display Options\s+Select:)\s*' self.yes_no_prompt = r'^(.*?)Are you sure \? \[Yes | No\]:\s*' diff --git a/src/unicon/plugins/tests/mock_data/iosxe/iosxe_mock_data.yaml b/src/unicon/plugins/tests/mock_data/iosxe/iosxe_mock_data.yaml index 6133a63e..05f6d924 100644 --- a/src/unicon/plugins/tests/mock_data/iosxe/iosxe_mock_data.yaml +++ b/src/unicon/plugins/tests/mock_data/iosxe/iosxe_mock_data.yaml @@ -1654,6 +1654,12 @@ transition_to_general_enable2: "": new_state: general_enable +connection_refused_loop: + commands: + "": + response: | + telnet: connect to address 127.0.0.1: Connection refused + confirm_abort_copy: preface: | @@ -1681,3 +1687,10 @@ general_enable_no_operating_mode: commands: <<: *gen_enable_cmds "show version | include operating mode" : "unexpected output" + +unlicensed_prompt: + prompt: "(unlicensed)UUT#" + commands: + "show version | include operating mode": "" + "end": + new_state: general_enable \ No newline at end of file diff --git a/src/unicon/plugins/tests/mock_data/staros/staros_mock_data.yaml b/src/unicon/plugins/tests/mock_data/staros/staros_mock_data.yaml index 309829ae..1569eb72 100644 --- a/src/unicon/plugins/tests/mock_data/staros/staros_mock_data.yaml +++ b/src/unicon/plugins/tests/mock_data/staros/staros_mock_data.yaml @@ -8,7 +8,7 @@ staros_connect: staros_exec: prompt: "[local]host_name# " - commands: + commands: &staros_exec_cmds "terminal length 0": "" "terminal width 512": "" "no timestamps": "" @@ -224,3 +224,16 @@ staros_call_finish: prompt: "" keys: <<: *monitor_keys + + + +staros_connect2: + preface: Escape character is '^]'. + prompt: "" + commands: + "": + new_state: staros_exec2 + +staros_exec2: + prompt: "[local] host_name# " + commands: *staros_exec_cmds diff --git a/src/unicon/plugins/tests/test_plugin_generic.py b/src/unicon/plugins/tests/test_plugin_generic.py index 4e2bbdac..836df53c 100644 --- a/src/unicon/plugins/tests/test_plugin_generic.py +++ b/src/unicon/plugins/tests/test_plugin_generic.py @@ -1477,6 +1477,47 @@ def test_connection_refused_handler_without_peripheral(self): d.disconnect() md.stop() + def test_connection_refused_handler_repeat(self): + md = MockDeviceTcpWrapper(device_os='iosxe', hostname='R1', + port=0, state='connection_refused_loop') + md.start() + template_testbed = """ + devices: + R1: + os: iosxe + credentials: + default: + username: cisco + password: cisco + connections: + defaults: + class: unicon.Unicon + a: + protocol: telnet + ip: 127.0.0.1 + port: {} + peripherals: + terminal_server: + ts: [20] + ts: + os: ios + credentials: + default: + username: cisco + password: cisco + connections: + cli: + command: mock_device_cli --os ios --state exec --hostname ts + """.format(md.ports[0]) + t = loader.load(template_testbed) + d = t.devices.R1 + with self.assertRaisesRegex(unicon.core.errors.ConnectionError, + 'failed to connect to R1'): + try: + d.connect() + finally: + d.disconnect() + md.stop() if __name__ == "__main__": diff --git a/src/unicon/plugins/tests/test_plugin_iosxe.py b/src/unicon/plugins/tests/test_plugin_iosxe.py index 4ba0c066..f939a86e 100644 --- a/src/unicon/plugins/tests/test_plugin_iosxe.py +++ b/src/unicon/plugins/tests/test_plugin_iosxe.py @@ -266,6 +266,18 @@ def test_operating_mode_check(self): finally: c.disconnect() + def test_unlicensed_prompt(self): + c = Connection(hostname='UUT', + start=['mock_device_cli --os iosxe --state unlicensed_prompt --hostname UUT'], + os='iosxe', + credentials=dict(default=dict(username='cisco', password='cisco')), + mit=True) + try: + c.connect() + self.assertEqual(c.spawn.match.last_match.group(2), '(unlicensed)') + finally: + c.disconnect() + class TestIosXEPluginExecute(unittest.TestCase): diff --git a/src/unicon/plugins/tests/test_plugin_staros.py b/src/unicon/plugins/tests/test_plugin_staros.py index 7abaa8bf..e5bd5a0e 100644 --- a/src/unicon/plugins/tests/test_plugin_staros.py +++ b/src/unicon/plugins/tests/test_plugin_staros.py @@ -68,6 +68,21 @@ def test_monitor(self): self.assertTrue('Call Finished - Waiting to trace next matching call' in r) +class TestStarosConnect(unittest.TestCase): + + def test_connect(self): + c = Connection(hostname='host_name', + start=['mock_device_cli --os staros --state staros_connect2'], + os='staros', + username='cisco', + tacacs_password='cisco', + connection_timeout=15, + mit=True) + try: + c.connect() + finally: + c.disconnect() + + if __name__ == "__main__": unittest.main() - diff --git a/src/unicon/plugins/utils.py b/src/unicon/plugins/utils.py index 9d3d9315..6535b125 100644 --- a/src/unicon/plugins/utils.py +++ b/src/unicon/plugins/utils.py @@ -22,10 +22,13 @@ from unicon.core.errors import CredentialsExhaustedError # Declare token types for abstract token discovery -TOKEN_TYPES = ['os', 'os_flavor', 'version', 'platform', 'model', 'pid'] +TOKEN_TYPES = ['os', 'os_flavor', 'version', 'platform', 'model', 'submodel', 'pid', 'chassis_type'] +OPTIONAL_TOKENS = ['os_flavor'] ShowVersion = None ShowInventory = None Uname = None +PID_TOKEN_FILE = Path(__file__).parent / 'pid_tokens.csv' + def _fallback_cred(context): return [context['default_cred_name']] \ @@ -234,10 +237,10 @@ def __init__(self, con, execute_target=None): # Import them during object initialization if not already imported global ShowVersion if not ShowVersion: - from genie.libs.parser.generic.show_platform import ShowVersion + from genie.libs.parser.generic.rv1.show_platform import ShowVersion global ShowInventory if not ShowInventory: - from genie.libs.parser.generic.show_platform import ShowInventory + from genie.libs.parser.generic.rv1.show_platform import ShowInventory global Uname if not Uname: from genie.libs.parser.generic.show_platform import Uname @@ -249,7 +252,7 @@ def __init__(self, con, execute_target=None): # Load the pid token lookup file self.pid_data = {} - self.pid_lookup_file = Path(__file__).parent / 'pid_tokens.csv' + self.pid_lookup_file = PID_TOKEN_FILE self.pid_data = load_token_csv_file(file_path=self.pid_lookup_file) # Attach commands and accompying classes for cleaner looping @@ -274,9 +277,10 @@ def update_learned_tokens(self, new_tokens, overwrite_existing_values=True): def all_tokens_learned(self): - for _,token_value in self.learned_tokens.items(): - if token_value == '' or token_value is None: - return False + for token ,token_value in self.learned_tokens.items(): + if token not in OPTIONAL_TOKENS: + if token_value == '' or token_value is None: + return False return True @@ -284,7 +288,7 @@ def lookup_tokens_using_pid(self, pid_to_check): try: data = self.pid_data[pid_to_check] except KeyError: - return None + return {'pid': pid_to_check} else: return { 'os': data['os'], @@ -368,6 +372,11 @@ def discover_tokens(self): if cmd == 'show inventory' and \ parsed_output.get('inventory_item_index', None): + + if parsed_output.get('chassis_type'): + self.learned_tokens['chassis_type'] = \ + parsed_output.get('chassis_type') + # Look though pids that were found with show inventory for _,entry_data in \ parsed_output['inventory_item_index'].items(): @@ -535,7 +544,11 @@ def show_results(self): def learn_device_tokens(self, overwrite_testbed_tokens=False): if not self.con.device: self.con.log.debug('No device object, cannot learn tokens') - return + return {} + + if self.con.state_machine.current_state == 'standby_locked': + self.con.log.info('Device is locked, cannot learn tokens') + return {} if overwrite_testbed_tokens: self.con.log.info('+++ Learning device tokens +++')