diff --git a/changes/288.added b/changes/288.added new file mode 100644 index 00000000..80ad085b --- /dev/null +++ b/changes/288.added @@ -0,0 +1 @@ +Added Cisco NSO integration diff --git a/development/creds.example.env b/development/creds.example.env index fef92421..e269244a 100644 --- a/development/creds.example.env +++ b/development/creds.example.env @@ -69,7 +69,7 @@ NAUTOBOT_TOWER_PASSWORD="admin" # - Grafana -------------------------- # GRAFANA_API_KEY="changeme" -# - IPFabric --------------------- +# - IPFabric ------------------------- # IPFABRIC_API_TOKEN="changeme" # - Cisco Meraki --------------------- @@ -77,3 +77,7 @@ NAUTOBOT_TOWER_PASSWORD="admin" # - Palo Alto Panorama --------------- # PANORAMA_PASSWORD="changeme" + +# - Cisco NSO ------------------------ +# NSO_USERNAME="changeme" +# NSO_PASSWORD="changeme" \ No newline at end of file diff --git a/development/development.env b/development/development.env index 9d9b4636..82706ab8 100644 --- a/development/development.env +++ b/development/development.env @@ -94,3 +94,9 @@ NAUTOBOT_CHATOPS_ENABLE_MERAKI="False" NAUTOBOT_CHATOPS_ENABLE_PANORAMA="False" PANORAMA_HOST="https://panorama.example.com" PANORAMA_USER="admin" + +# - Cisco NSO ------------------------ +NAUTOBOT_CHATOPS_ENABLE_NSO="False" +# NSO_URL="https://nso.example.com" +# NSO_USERNAME="admin" +# NSO_PASSWORD="admin" diff --git a/development/mattermost/dump.sql b/development/mattermost/dump.sql index f353bb8d..26260c29 100644 --- a/development/mattermost/dump.sql +++ b/development/mattermost/dump.sql @@ -1612,6 +1612,7 @@ INSERT INTO public.commands VALUES ('urmj87oqbbynir8nzc33by1f4c', '3wxwh3m8mjrzx INSERT INTO public.commands VALUES ('mw51fho6ojd3fxqmrgfkt9xykw', 'ppm316za33ritm3xgpobcmmgre', 1698322479271, 1698322479271, 0, '5md7bsji6pbijpxitu8bwezaqy', 'rkpuunm3rp8fffhzkjxw63usyc', 'ipfabric', 'P', '', '', false, '', '', 'IP Fabric', 'IP Fabric Slash Command', 'http://nautobot:8080/api/plugins/chatops/mattermost/slash_command/', ''); INSERT INTO public.commands VALUES ('11cwjihduffn3ceybits6n5zty', '11ix54hycjr4dmxcgw4d77qc4w', 1698322532951, 1698322532951, 0, '5md7bsji6pbijpxitu8bwezaqy', 'rkpuunm3rp8fffhzkjxw63usyc', 'meraki', 'P', '', '', false, '', '', 'Cisco Meraki', 'Cisco Meraki Slash Command', 'http://nautobot:8080/api/plugins/chatops/mattermost/slash_command/', ''); INSERT INTO public.commands VALUES ('p43ko8rim3r89d78yexbg3fiww', 'fh1kbk45xtgm8r48jzr39ru1ww', 1698322585580, 1698322585580, 0, '5md7bsji6pbijpxitu8bwezaqy', 'rkpuunm3rp8fffhzkjxw63usyc', 'panorama', 'P', '', '', false, '', '', 'Panorama', 'Panorama Slash Command', 'http://nautobot:8080/api/plugins/chatops/mattermost/slash_command/', ''); +INSERT INTO public.commands VALUES ('qbxb8a33cimdw77hyxjeuyg80i', 'j9bcga71hl4lreaczecen7i5dz', 1698322527456, 1698322527456, 0, '5md7bsji6pbijpxitu8bwezaqy', 'rkpuunm3rp8fffhzkjxw63usyc', 'nso', 'P', '', '', false, '', '', 'Cisco NSO', 'Cisco NSO Slash Command', 'http://nautobot:8080/api/plugins/chatops/mattermost/slash_command/', ''); -- diff --git a/development/mattermost/nautobot_bootstrap.py b/development/mattermost/nautobot_bootstrap.py index 032abce6..bdd6462c 100644 --- a/development/mattermost/nautobot_bootstrap.py +++ b/development/mattermost/nautobot_bootstrap.py @@ -36,6 +36,7 @@ "meraki": "11ix54hycjr4dmxcgw4d77qc4w", # nosec "nautobot": "ncygprhkt3rrxr4rkytcaa7c9c", # nosec "panorama": "fh1kbk45xtgm8r48jzr39ru1ww", # nosec + "nso": "j9bcga71hl4lreaczecen7i5dz", # nosec } for command, token in _COMMAND_TOKENS.items(): @@ -48,7 +49,7 @@ with contextlib.suppress(ObjectDoesNotExist): admin = User.objects.get(name="admin") ChatOpsAccountLink.objects.update_or_create( - user_id="jactwicuqb8bu8pau8mgjydzeo", + user_id="w7uyhzuo7fnfueen6og9cxmn9h", platform=PlatformChoices.MATTERMOST, defaults={"nautobot_user": admin}, ) diff --git a/development/nautobot_config.py b/development/nautobot_config.py index 06a7fb25..825ea553 100644 --- a/development/nautobot_config.py +++ b/development/nautobot_config.py @@ -205,6 +205,12 @@ "panorama_host": os.environ.get("PANORAMA_HOST"), "panorama_password": os.environ.get("PANORAMA_PASSWORD"), "panorama_user": os.environ.get("PANORAMA_USER"), + # - Cisco NSO ------------------------ + "enable_nso": is_truthy(os.getenv("NAUTOBOT_CHATOPS_ENABLE_NSO")), + "nso_url": os.environ.get("NSO_URL"), + "nso_username": os.environ.get("NSO_USERNAME"), + "nso_password": os.environ.get("NSO_PASSWORD"), + "nso_request_timeout": os.environ.get("NSO_REQUEST_TIMEOUT", 60), }, } diff --git a/docs/admin/install.md b/docs/admin/install.md index 5dcf3ddb..93e198d9 100644 --- a/docs/admin/install.md +++ b/docs/admin/install.md @@ -159,4 +159,5 @@ Set up integrations using the specific guides: - [Grafana](./integrations/grafana.md) - [IPFabric](./integrations/ipfabric.md) - [Cisco Meraki](./integrations/meraki.md) +- [Cisco NSO](./integrations/nso.md) - [Palo Alto Panorama](./integrations/panorama.md) diff --git a/docs/admin/integrations/nso.md b/docs/admin/integrations/nso.md new file mode 100644 index 00000000..10e7a0e2 --- /dev/null +++ b/docs/admin/integrations/nso.md @@ -0,0 +1,56 @@ +# Cisco NSO Integration Setup + +This guide will walk you through steps to set up Cisco NSO integration with the `nautobot_chatops` App. + +## Prerequisites + +Before configuring the integration, please ensure the following: + +- `nautobot-chatops` App was installed. + ```shell + pip install nautobot-chatops + ``` +- `nautobot-chatops` App is set up with at least one [enabled chat platform](../install.md#chat-platforms-configuration) and [tested](./../install.md#test-your-chatbot). +- [Cisco NSO](https://developer.cisco.com/docs/nso-guides-6.2/#!installation/installation) application installed and configured. + +## Command Setup + +Create a top-level command named `nso` in your enabled chat platform. For detailed instructions related to your specific chat platform, refer to the [platform specific set up](../install.md#chat-platforms-configuration). + +## Configuration + +You must define the following values in your `nautobot_config.py` file: + +| Configuration Setting | Mandatory? | Default | Notes | Available on Admin Config | +| --------------------- | ---------- | ------- | ----- | ------------------------- | +| `enable_nso` | **Yes** | False | Enable Cisco NSO integration. | Yes | +| `nso_url` | **Yes** | | Base url that the Cisco NSO application is hosted at. | No | +| `nso_username` | **Yes** | | Cisco NSO username. | No | +| `nso_password` | **Yes** | | Cisco NSO password. | No | +| `nso_request_timeout` | | 60 | Timeout of the API request to Cisco NSO. | No | + +Below is an example snippet from `development/nautobot_config.py` that demonstrates how to enable and configure Cisco NSO integration: + +```python +PLUGINS = ["nautobot_chatops"] + +PLUGINS_CONFIG = { + "nautobot_chatops": { + ... + "enable_nso": True, + "nso_url": os.environ.get("NSO_URL"), + "nso_username": os.environ.get("NSO_USERNAME"), + "nso_password": os.environ.get("NSO_PASSWORD"), + "nso_request_timeout": os.environ.get("NSO_REQUEST_TIMEOUT", 60), + } +} +``` + +## Computed Fields + +Optionally, a computed field might be created to display NSO status on a device details page. Please note, that it might delay the page load depending on NSO response time. + +![Add a new computed field](../../images/nso-07.png) +![Computed fields list](../../images/nso-08.png) +![Device details 1](../../images/nso-09.png) +![Device details 2](../../images/nso-10.png) \ No newline at end of file diff --git a/docs/images/nso-01.png b/docs/images/nso-01.png new file mode 100644 index 00000000..11527e30 Binary files /dev/null and b/docs/images/nso-01.png differ diff --git a/docs/images/nso-02.png b/docs/images/nso-02.png new file mode 100644 index 00000000..189a680c Binary files /dev/null and b/docs/images/nso-02.png differ diff --git a/docs/images/nso-03.png b/docs/images/nso-03.png new file mode 100644 index 00000000..12807c8b Binary files /dev/null and b/docs/images/nso-03.png differ diff --git a/docs/images/nso-04.png b/docs/images/nso-04.png new file mode 100644 index 00000000..400a49b7 Binary files /dev/null and b/docs/images/nso-04.png differ diff --git a/docs/images/nso-05.png b/docs/images/nso-05.png new file mode 100644 index 00000000..cc9caac9 Binary files /dev/null and b/docs/images/nso-05.png differ diff --git a/docs/images/nso-06.png b/docs/images/nso-06.png new file mode 100644 index 00000000..59e92b96 Binary files /dev/null and b/docs/images/nso-06.png differ diff --git a/docs/images/nso-07.png b/docs/images/nso-07.png new file mode 100644 index 00000000..33ddc9b2 Binary files /dev/null and b/docs/images/nso-07.png differ diff --git a/docs/images/nso-08.png b/docs/images/nso-08.png new file mode 100644 index 00000000..5fb55544 Binary files /dev/null and b/docs/images/nso-08.png differ diff --git a/docs/images/nso-09.png b/docs/images/nso-09.png new file mode 100644 index 00000000..a4a1fe86 Binary files /dev/null and b/docs/images/nso-09.png differ diff --git a/docs/images/nso-10.png b/docs/images/nso-10.png new file mode 100644 index 00000000..a30ba118 Binary files /dev/null and b/docs/images/nso-10.png differ diff --git a/docs/user/app_getting_started.md b/docs/user/app_getting_started.md index 89bb62ed..f6c519b3 100644 --- a/docs/user/app_getting_started.md +++ b/docs/user/app_getting_started.md @@ -58,4 +58,5 @@ The `nautobot-chatops` package includes multiple integrations. Each integration - [Grafana](./integrations/grafana.md) - [IPFabric](./integrations/ipfabric.md) - [Cisco Meraki](./integrations/meraki.md) +- [Cisco NSO](./integrations/nso.md) - [Palo Alto Panorama](./integrations/panorama.md) diff --git a/docs/user/integrations/nso.md b/docs/user/integrations/nso.md new file mode 100644 index 00000000..7280a960 --- /dev/null +++ b/docs/user/integrations/nso.md @@ -0,0 +1,30 @@ +# Cisco NSO Chat Commands + +## `/nso` Command + +Interact with Cisco NSO by utilizing the following sub-commands: + +| Command | Arguments | Description | +| ----------------- | ---------------------------------- | --------------------------------------| +| `ping` | `[device-name]` | Ping a device. | +| `connect` | `[device-name]` | Check device connection with NSO. | +| `check-sync` | `[device-name]` `[compare-config]` | Check sync between current device config and stored in NSO. | +| `run-command-set` | `[device-name]` `[commands]` | Run predefined set of commands on a device. | + +!!! note + All sub-commands are intended to be used with the `/nso` prefix. + +## Screenshots + +![Commands list](../../images/nso-01.png) + +![Ping command](../../images/nso-02.png) + +![Check-sync command](../../images/nso-03.png) + +![Check-sync command, compare config](../../images/nso-04.png) + +![Run remote commands](../../images/nso-05.png) + +![Run remote commands result](../../images/nso-06.png) + diff --git a/mkdocs.yml b/mkdocs.yml index 73677dc0..07e200cd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -115,6 +115,7 @@ nav: - "user/integrations/ipfabric.md" - "user/integrations/meraki.md" - "user/integrations/panorama.md" + - "user/integrations/nso.md" - Administrator Guide: - Install and Configure: "admin/install.md" - Platforms: @@ -130,6 +131,7 @@ nav: - "admin/integrations/ipfabric.md" - "admin/integrations/meraki.md" - "admin/integrations/panorama.md" + - "admin/integrations/nso.md" - Upgrade: "admin/upgrade.md" - Uninstall: "admin/uninstall.md" - Compatibility Matrix: "admin/compatibility_matrix.md" diff --git a/nautobot_chatops/__init__.py b/nautobot_chatops/__init__.py index b191b59e..ab18bba1 100644 --- a/nautobot_chatops/__init__.py +++ b/nautobot_chatops/__init__.py @@ -105,7 +105,7 @@ class NautobotChatOpsConfig(NautobotAppConfig): "grafana_default_timespan": "", "grafana_org_id": 1, "grafana_default_tz": "", - # - IPFabric --------------------- + # - IPFabric ------------------------- "ipfabric_api_token": "", "ipfabric_host": "", "ipfabric_timeout": "", @@ -116,6 +116,11 @@ class NautobotChatOpsConfig(NautobotAppConfig): "panorama_host": "", "panorama_password": "", "panorama_user": "", + # - Cisco NSO ------------------------ + "nso_url": "", + "nso_username": "", + "nso_password": "", + "nso_request_timeout": "", } constance_config = { "fallback_chatops_user": ConstanceConfigItem(default="chatbot", help_text="Enable Mattermost Chat Platform."), @@ -142,6 +147,7 @@ class NautobotChatOpsConfig(NautobotAppConfig): "enable_panorama": ConstanceConfigItem( default=False, help_text="Enable Panorama Integration.", field_type=bool ), + "enable_nso": ConstanceConfigItem(default=False, help_text="Enable NSO Integration.", field_type=bool), } caching_config = {} diff --git a/nautobot_chatops/integrations/nso/__init__.py b/nautobot_chatops/integrations/nso/__init__.py new file mode 100644 index 00000000..d5aa34f2 --- /dev/null +++ b/nautobot_chatops/integrations/nso/__init__.py @@ -0,0 +1 @@ +"""Nautobot ChatOps NSO Integration.""" diff --git a/nautobot_chatops/integrations/nso/exceptions.py b/nautobot_chatops/integrations/nso/exceptions.py new file mode 100644 index 00000000..dd637295 --- /dev/null +++ b/nautobot_chatops/integrations/nso/exceptions.py @@ -0,0 +1,17 @@ +"""Custom Exceptions for the nautobot_chatops.integrations.nso plugin.""" + + +class CommunicationError(Exception): + """Error communicating with NSO.""" + + +class DeviceNotFound(Exception): + """Device not found in NSO.""" + + +class DeviceNotSupported(Exception): + """Device not supported in NSO.""" + + +class DeviceLocked(Exception): + """Device not reachable in NSO.""" diff --git a/nautobot_chatops/integrations/nso/jinja_filters.py b/nautobot_chatops/integrations/nso/jinja_filters.py new file mode 100644 index 00000000..50688bf0 --- /dev/null +++ b/nautobot_chatops/integrations/nso/jinja_filters.py @@ -0,0 +1,14 @@ +"""Custom filters for nautobot_chatops.integrations.nso.""" +from django_jinja import library +from nautobot_chatops.integrations.nso.nso import NSOClient + + +@library.filter +def get_nso_sync_status(device_name): + """Pull NSO sync status for specified device.""" + nso = NSOClient() + try: + response = nso.sync_status(device_name) + return response + except Exception: # pylint: disable=W0703 + return "N/A" diff --git a/nautobot_chatops/integrations/nso/nso.py b/nautobot_chatops/integrations/nso/nso.py new file mode 100644 index 00000000..a4a9b77c --- /dev/null +++ b/nautobot_chatops/integrations/nso/nso.py @@ -0,0 +1,215 @@ +"""All interactions with nso.""" + +import logging +import requests +from django.conf import settings +from rest_framework import status + +from nautobot_chatops.integrations.nso.exceptions import ( + CommunicationError, + DeviceNotFound, + DeviceNotSupported, + DeviceLocked, +) + + +logger = logging.getLogger("nautobot.plugin.nso") + +# Import config vars from nautobot_config.py +PLUGIN_SETTINGS = settings.PLUGINS_CONFIG["nautobot_chatops"] + +SLASH_COMMAND = "nso" +REQUEST_TIMEOUT_SEC = PLUGIN_SETTINGS["nso_request_timeout"] + +NSO_URL = PLUGIN_SETTINGS["nso_url"] +NSO_USERNAME = PLUGIN_SETTINGS["nso_username"] +NSO_PASSWORD = PLUGIN_SETTINGS["nso_password"] + + +class NSOClient: + """Representation and methods for interacting with nso.""" + + def __init__(self, url=NSO_URL, username=NSO_USERNAME, password=NSO_PASSWORD): + """Initialization of nso class.""" + self.url = url + self.auth = (username, password) + self.headers = {"Accept": "application/yang-data+json", "Content-Type": "application/yang-data+json"} + + @property + def base_url(self): + """Base URL for RESTCONF.""" + return f"{self.url}/restconf/data" + + def __device_operation(self, device, operation): + """Basic device operations. + + Args: + device (str): Device name + operation (str): Device operation, such as ping, connect, sync-to + + Returns: + The response. + + Raises: + CommunicationError: Raises an exception when response code != 200 or 404. + DeviceNotFound: Raises an exception when response code == 404. + """ + url = f"{self.base_url}/tailf-ncs:devices/device={device}/{operation}" + + response = requests.post(url, headers=self.headers, auth=self.auth, timeout=REQUEST_TIMEOUT_SEC) + + if response.ok: + json_response = response.json() + out = json_response.get("tailf-ncs:output", {}) + if "result" in out: + return out["result"] + if "diff" in out: + return out["diff"] + return out + if response.status_code == status.HTTP_404_NOT_FOUND: + json_response = response.json() + out = json_response.get("ietf-restconf:errors", {}) + if "error" in out: + if "error-message" in out["error"][0]: + if out["error"][0]["error-message"] == "uri keypath not found": + raise DeviceNotFound(f"Device {device} does not exist in NSO") + raise CommunicationError(f"Error communicating to NSO! ({response.status_code})") + + def ping(self, device): + """Ping device operation. + + Args: + device (str): Device name + + Returns: + The response. + """ + return self.__device_operation(device, "ping") + + def connect(self, device): + """Connect device operation. + + Args: + device (str): Device name + + Returns: + The response. + """ + return self.__device_operation(device, "connect") + + def sync_to(self, device): + """Sync to device operation. + + Args: + device (str): Device name + + Returns: + The response. + """ + return self.__device_operation(device, "sync-to") + + def sync_from(self, device): + """Sync from device operation. + + Args: + device (str): Device name + + Returns: + The response. + """ + return self.__device_operation(device, "sync-from") + + def sync_status(self, device): + """Check sync device operation. + + Args: + device (str): Device name + + Returns: + The response. + """ + return self.__device_operation(device, "check-sync") + + def sync_status_all(self): + """Check sync of all devices operation. + + Returns: + The response. + + Raises: + CommunicationError: Raises an exception when response code != 200. + """ + url = f"{self.base_url}/tailf-ncs:devices/check-sync" + + response = requests.post(url, headers=self.headers, auth=self.auth, timeout=REQUEST_TIMEOUT_SEC) + + if response.ok: + json_response = response.json() + out = json_response.get("tailf-ncs:output", {}) + if "sync-result" in out: + return out["sync-result"] + return out + raise CommunicationError(f"Error communicating to NSO! ({response.status_code})") + + def compare_config(self, device): + """Compare config device operation. + + Args: + device (str): Device name + + Returns: + The response. + """ + return self.__device_operation(device, "compare-config") + + def live_status(self, device, command): + """Executes a show command. + + Args: + device (str): Device name + command (str): The show command + + Returns: + The response. + """ + # Get device type + url = f"{self.base_url}/tailf-ncs:devices/device={device}/device-type" + response = requests.get(url, headers=self.headers, auth=self.auth, timeout=REQUEST_TIMEOUT_SEC) + + if not response.ok: + json_response = response.json() + out = json_response.get("ietf-restconf:errors", {}) + if "error" in out: + if "error-message" in out["error"][0]: + if out["error"][0]["error-message"] == "uri keypath not found": + raise DeviceNotFound(f"Device {device} does not exist in NSO") + raise CommunicationError(f"Error communicating to NSO! ({response.status_code})") + + json_response = response.json() + ned_id = json_response["tailf-ncs:device-type"]["cli"]["ned-id"] + if "nx" in ned_id: + ned_id_live = "tailf-ned-cisco-nx-stats" + elif "ios" in ned_id: + ned_id_live = "tailf-ned-cisco-ios-stats" + else: + raise DeviceNotSupported(f"Device type is not supported! ({ned_id})") + + url = f"{self.base_url}/tailf-ncs:devices/device={device}" + url += f"/live-status/{ned_id_live}:exec/show" + + command = command.replace("show ", "") + body = {"input": {"args": command}} + + response = requests.post(url, headers=self.headers, auth=self.auth, json=body, timeout=REQUEST_TIMEOUT_SEC) + + if response.ok: + json_response = response.json() + return json_response[f"{ned_id_live}:output"]["result"] + if response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR: + json_response = response.json() + out = json_response.get("ietf-restconf:errors", {}) + if "error" in out: + if "error-message" in out["error"][0]: + if "southbound locked" in out["error"][0]["error-message"]: + raise DeviceLocked(f"Device {device} is southbound locked") + raise CommunicationError(f"Error communicating to NSO! ({response.status_code})") diff --git a/nautobot_chatops/integrations/nso/worker.py b/nautobot_chatops/integrations/nso/worker.py new file mode 100644 index 00000000..2da09bb2 --- /dev/null +++ b/nautobot_chatops/integrations/nso/worker.py @@ -0,0 +1,246 @@ +"""Worker functions implementing Nautobot "nso" command and subcommands.""" +from django.core.exceptions import ObjectDoesNotExist +from nautobot.dcim.models import Device +from nautobot.core.settings_funcs import is_truthy + +from nautobot_chatops.choices import CommandStatusChoices +from nautobot_chatops.workers import handle_subcommands, subcommand_of +from nautobot_chatops.integrations.nso.nso import NSOClient, REQUEST_TIMEOUT_SEC, SLASH_COMMAND + + +def nso_logo(dispatcher): + """Construct an image_element containing the locally hosted Cisco NSO logo.""" + return dispatcher.image_element(dispatcher.static_url("nso/nso-logo.png"), alt_text="Cisco NSO Logo") + + +def nso(subcommand, **kwargs): + """Interact with nso plugin.""" + return handle_subcommands("nso", subcommand, **kwargs) + + +@subcommand_of("nso") +def ping(dispatcher, device_name=None): + """Ping a device.""" + if not device_name: + devices = Device.objects.all() + choices = [(device.name, device.name) for device in devices] + dispatcher.prompt_from_menu("nso ping", "Select a device.", choices) + return False + + nso_client = NSOClient() + ping_result = nso_client.ping(device_name) + + if "100% packet loss" in ping_result: + dispatcher.send_markdown(f"❌ NSO Ping of Device {device_name} failed.") + return CommandStatusChoices.STATUS_FAILED + if "Device does not exist in NSO" in ping_result: + dispatcher.send_markdown(f"❌ Device {device_name} doesn't exist in NSO.") + return CommandStatusChoices.STATUS_FAILED + dispatcher.send_markdown(f"✅ NSO Ping of Device {device_name} was successful.") + return CommandStatusChoices.STATUS_SUCCEEDED + + +@subcommand_of("nso") +def connect(dispatcher, device_name=None): + """Get connect status of device.""" + if not device_name: + devices = Device.objects.all() + choices = [(device.name, device.name) for device in devices] + dispatcher.prompt_from_menu("nso connect", "Select a device.", choices) + return False + + nso_client = NSOClient() + ping_result = nso_client.connect(device_name) + + if not ping_result: + dispatcher.send_markdown(f"❌ NSO Connection to Device {device_name} failed.") + return CommandStatusChoices.STATUS_FAILED + if "Device does not exist in NSO" in str(ping_result): + dispatcher.send_markdown(f"❌ Device {device_name} does not exist in NSO.") + return CommandStatusChoices.STATUS_FAILED + dispatcher.send_markdown(f"✅ NSO Connection to Device {device_name} was successful.") + return CommandStatusChoices.STATUS_SUCCEEDED + + +@subcommand_of("nso") +def check_sync(dispatcher, device_name=None, compare_config=None): + """Get sync status of device.""" + nso_client = NSOClient() + + if not device_name: + devices = Device.objects.all() + choices = [(device.name, device.name) for device in devices] + choices.insert(0, ("all", "all")) + + dispatcher.prompt_from_menu("nso check-sync", "Select a device.", choices) + return False + + if device_name == "all": + dispatcher.send_markdown(f"Stand by {dispatcher.user_mention()}, I'm getting that sync status report for you.") + sync_data = nso_client.sync_status_all() + + dispatcher.send_blocks( + [ + *dispatcher.command_response_header( + "nso", "check_sync", [("Device Name", device_name)], "information", nso_logo(dispatcher) + ) + ] + ) + + header = ["Device Name", "NSO Sync Status"] + rows = [(item["device"], item["result"]) for item in sync_data] + + dispatcher.send_large_table(header, rows) + return CommandStatusChoices.STATUS_SUCCEEDED + + # If user chose to compare config on the device and NSO, return compare_config + if device_name and compare_config: + if is_truthy(compare_config): + dispatcher.send_markdown(f"Checking configuration of device {device_name}.") + compared_config = nso_client.compare_config(device_name) + if len(compared_config) < 1000: + dispatcher.send_markdown(f"```{compared_config}\n```") + else: + dispatcher.send_snippet(compared_config) + else: + dispatcher.send_warning("Device configuration will not be checked.") + return CommandStatusChoices.STATUS_SUCCEEDED + + # Check sync status for chosen device + sync_status = nso_client.sync_status(device_name) + + if sync_status == "in-sync": + dispatcher.send_markdown(f"✅ Device {device_name} configuration is in sync with NSO.") + return CommandStatusChoices.STATUS_SUCCEEDED + + if sync_status == "out-of-sync": + dispatcher.send_markdown(f"❌ Device {device_name} configuration is out of sync with NSO.") + dispatcher.prompt_from_menu( + f"nso check-sync {device_name}", "Would you like to compare the config?", [("Yes", "Yes"), ("No", "No")] + ) + return False + + dispatcher.send_markdown(f"❌ Device {device_name} NSO status: {sync_status}") + + return CommandStatusChoices.STATUS_SUCCEEDED + + +def _run_command_helper(dispatcher, device_name, device_commands, sub_command): + """Helper function for run-commands and run-command-set NSO commands.""" + # Create a dialog prompt for any missing parameters in the command. + dialog_list = [] + + all_choices = [(device.name, device.name) for device in Device.objects.all()] + if not device_name: + # Handle Slack 100 items menu limit + if dispatcher.platform_slug == "slack": + all_choices = dispatcher.get_prompt_from_menu_choices(choices=all_choices) + + dialog_list.append( + { + "type": "select", + "label": "Select target device", + "default": ("", "-----"), + "choices": all_choices, + } + ) + elif device_name.startswith("menu_offset-"): + dialog_list.append( + { + "type": "select", + "label": "Select target device", + "default": ("", "-----"), + "choices": dispatcher.get_prompt_from_menu_choices( + choices=all_choices, offset=int(device_name.replace("menu_offset-", "")) + ), + } + ) + else: + try: + nautobot_device = Device.objects.get(name=device_name) + except ObjectDoesNotExist: + dispatcher.send_error(f"{device_name} does not exist in Nautobot!") + return False + dialog_list.append( + { + "type": "select", + "label": "Select target device", + "default": (nautobot_device.name, nautobot_device.name), + "choices": all_choices, + } + ) + + if not device_commands: + if sub_command == "run-commands": + dialog_list.append({"type": "text", "label": "Comma separated device commands.", "default": ""}) + if sub_command == "run-command-set": + cmd_choices = [ + ("display ip addresses and routing table", "show ip interface brief, show ip route"), + ("display routing table", "show ip route"), + ("display OS version", "show version"), + ("display device neighbors", "show cdp neighbor"), + ("display mac table", "show mac address-table"), + ("list interfaces", "show interface brief"), + ("display bgp neighbors", "show ip bgp sum"), + ] + dialog_list.append( + {"type": "select", "label": "Select command set", "choices": cmd_choices, "default": (None, None)} + ) + + dispatcher.multi_input_dialog( + command=SLASH_COMMAND, + sub_command=sub_command, + dialog_title="Run raw commands", + dialog_list=dialog_list, + ) + return False + + # Split and strip device_commands, exclude empty results + stripped_device_commands = [cmd.strip() for cmd in device_commands.split(",") if cmd.strip() != ""] + + # Send a Markdown-formatted text message. + dispatcher.send_markdown( + f"Standby {dispatcher.user_mention()} while I get that info from Cisco NSO.\n" + f"Please be patient as this can take up to {REQUEST_TIMEOUT_SEC} seconds.", + ephemeral=True, + ) + + # Send a "typing" indicator to show that work is in progress. + dispatcher.send_busy_indicator() + + nso_client = NSOClient() + + # Response we're going to send to the chat client. This will get built as commands + # are run through NSO. + response = "" + for cmd in stripped_device_commands: + # Pull data from NSO using the live_status operand. + data = nso_client.live_status(device_name, cmd) + response += f"\n! {'-' * 30}\n! Command '{cmd}' results:\n! {'-' * 30}\n{data}" + + dispatcher.send_blocks( + [ + *dispatcher.command_response_header( + SLASH_COMMAND, + f'{sub_command} {device_name} "{device_commands}"', + [], + "Cisco NSO Live Status", + nso_logo(dispatcher), + ) + ] + ) + + # If it's a short response, we can add it as a code-block. If it's a bit larger, we will need to stream + # it as a file attachment. + if len(response) < 1000: + dispatcher.send_markdown(f"```{response}\n```") + return True + + dispatcher.send_snippet(response) + return True + + +@subcommand_of("nso") +def run_command_set(dispatcher, device_name, device_commands): + """Select a predefined command set to run on a device using Cisco NSO.""" + return _run_command_helper(dispatcher, device_name, device_commands, "run-command-set") diff --git a/nautobot_chatops/static/nso/nso-logo.png b/nautobot_chatops/static/nso/nso-logo.png new file mode 100644 index 00000000..6d716575 Binary files /dev/null and b/nautobot_chatops/static/nso/nso-logo.png differ diff --git a/nautobot_chatops/templates/nautobot_chatops_nso/nsocommandfilter.html b/nautobot_chatops/templates/nautobot_chatops_nso/nsocommandfilter.html new file mode 100644 index 00000000..844551fe --- /dev/null +++ b/nautobot_chatops/templates/nautobot_chatops_nso/nsocommandfilter.html @@ -0,0 +1,38 @@ +{% extends 'generic/object_retrieve.html' %} +{% load helpers %} + +{% block content_left_page %} +
+
+ Command Filter +
+ + + + + + + + + + + + + +
Command + {{ object.command }} +
Role{{ object.role|hyperlinked_object }}
Platform{{ object.platform|hyperlinked_object }}
+
+
+
+ Comments +
+
+ {% if object.comments %} + {{ object.comments|render_markdown }} + {% else %} + None + {% endif %} +
+
+{% endblock content_left_page %} \ No newline at end of file diff --git a/nautobot_chatops/tests/nso/__init__.py b/nautobot_chatops/tests/nso/__init__.py new file mode 100644 index 00000000..351ad7ce --- /dev/null +++ b/nautobot_chatops/tests/nso/__init__.py @@ -0,0 +1 @@ +"""Unit tests for nautobot_chatops.integrations.nso plugin.""" diff --git a/nautobot_chatops/tests/nso/fixtures/compare_config_responses.json b/nautobot_chatops/tests/nso/fixtures/compare_config_responses.json new file mode 100644 index 00000000..ad16fb9d --- /dev/null +++ b/nautobot_chatops/tests/nso/fixtures/compare_config_responses.json @@ -0,0 +1,28 @@ +[ + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=in_sync_device/compare-config", + "method": "POST", + "status": 200, + "response_json": {} + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=out_of_sync_device/compare-config", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "diff": "\n devices {\n device out_of_sync_device {\n config {\n interface {\n+ Loopback 100 {\n+ description \"Test Change\";\n+ }\n }\n }\n }\n }\n" + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=offline_device/compare-config", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "info": "Device c3 is southbound locked" + } + } + } +] \ No newline at end of file diff --git a/nautobot_chatops/tests/nso/fixtures/connect_device_responses.json b/nautobot_chatops/tests/nso/fixtures/connect_device_responses.json new file mode 100644 index 00000000..9e39422e --- /dev/null +++ b/nautobot_chatops/tests/nso/fixtures/connect_device_responses.json @@ -0,0 +1,40 @@ +[ + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=good_device/connect", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": true, + "info": "(admin) Connected to good_device - 127.0.0.1:10022" + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=offline_device/connect", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": false, + "info": "Device c3 is southbound locked" + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=nonexistent_device/connect", + "method": "POST", + "status": 404, + "response_json": { + "ietf-restconf:errors": { + "error": [ + { + "error-type": "application", + "error-tag": "invalid-value", + "error-message": "uri keypath not found" + } + ] + } + } + } +] \ No newline at end of file diff --git a/nautobot_chatops/tests/nso/fixtures/live_status_responses.json b/nautobot_chatops/tests/nso/fixtures/live_status_responses.json new file mode 100644 index 00000000..96d0469b --- /dev/null +++ b/nautobot_chatops/tests/nso/fixtures/live_status_responses.json @@ -0,0 +1,142 @@ +[ + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=good_device/device-type", + "method": "GET", + "status": 200, + "response_json": { + "tailf-ncs:device-type": { + "cli": { + "ned-id": "cisco-ios-cli-6.77:cisco-ios-cli-6.77" + } + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=good_device/live-status/tailf-ned-cisco-ios-stats:exec/show", + "method": "POST", + "status": 200, + "body": { + "input": { + "args": "version" + } + }, + "response_json": { + "tailf-ned-cisco-ios-stats:output": { + "result": "\r\nCisco IOS Software, NETSIM\r\ngood_device# " + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=offline_device/device-type", + "method": "GET", + "status": 200, + "response_json": { + "tailf-ncs:device-type": { + "cli": { + "ned-id": "cisco-ios-cli-6.77:cisco-ios-cli-6.77" + } + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=offline_device/live-status/tailf-ned-cisco-ios-stats:exec/show", + "method": "POST", + "status": 500, + "body": { + "input": { + "args": "version" + } + }, + "response_json": { + "ietf-restconf:errors": { + "error": [ + { + "error-type": "application", + "error-tag": "operation-failed", + "error-path": "/tailf-ncs:devices/device[name='offline_device']/live-status/tailf-ned-cisco-ios-stats:exec/show", + "error-message": "Device offline_device is southbound locked" + } + ] + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=nonexistent_device/device-type", + "method": "GET", + "status": 404, + "response_json": { + "ietf-restconf:errors": { + "error": [ + { + "error-type": "application", + "error-tag": "invalid-value", + "error-message": "uri keypath not found" + } + ] + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=good_device_nx/device-type", + "method": "GET", + "status": 200, + "response_json": { + "tailf-ncs:device-type": { + "cli": { + "ned-id": "cisco-nx-cli-3.0:cisco-nx-cli-3.0" + } + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=good_device_nx/live-status/tailf-ned-cisco-nx-stats:exec/show", + "method": "POST", + "status": 200, + "body": { + "input": { + "args": "version" + } + }, + "response_json": { + "tailf-ned-cisco-nx-stats:output": { + "result": "\r\nCisco NX-OS Software, NETSIM\r\ngood_device_nx# " + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=unsupported_device_asa/device-type", + "method": "GET", + "status": 200, + "response_json": { + "tailf-ncs:device-type": { + "cli": { + "ned-id": "cisco-asa-cli-6.6:cisco-asa-cli-6.6" + } + } + } + }, + { + "url": "https://nso.error/restconf/data/tailf-ncs:devices/device=error_device_type/device-type", + "method": "GET", + "status": 404, + "response_json": {} + }, + { + "url": "https://nso.error/restconf/data/tailf-ncs:devices/device=error_live_status/device-type", + "method": "GET", + "status": 200, + "response_json": { + "tailf-ncs:device-type": { + "cli": { + "ned-id": "cisco-ios-cli-6.77:cisco-ios-cli-6.77" + } + } + } + }, + { + "url": "https://nso.error/restconf/data/tailf-ncs:devices/device=error_live_status/live-status/tailf-ned-cisco-ios-stats:exec/show", + "method": "POST", + "status": 404, + "response_json": {} + } +] \ No newline at end of file diff --git a/nautobot_chatops/tests/nso/fixtures/ping_device_responses.json b/nautobot_chatops/tests/nso/fixtures/ping_device_responses.json new file mode 100644 index 00000000..c8f98b1e --- /dev/null +++ b/nautobot_chatops/tests/nso/fixtures/ping_device_responses.json @@ -0,0 +1,44 @@ +[ + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=good_device/ping", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": "PING 10.0.0.2 (10.0.0.2) 56(84) bytes of data.\n64 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.014 ms\n\n--- 10.0.0.2 ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 0.014/0.014/0.014/0.000 ms\n" + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=offline_device/ping", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": "PING 10.0.0.2 (10.0.0.2) 56(84) bytes of data.\n\n--- 10.0.0.2 ping statistics ---\n1 packets transmitted, 0 received, 100% packet loss, time 0ms\n\n" + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=nonexistent_device/ping", + "method": "POST", + "status": 404, + "response_json": { + "ietf-restconf:errors": { + "error": [ + { + "error-type": "application", + "error-tag": "invalid-value", + "error-message": "uri keypath not found" + } + ] + } + } + }, + { + "url": "https://nso.error/restconf/data/tailf-ncs:devices/device=error_device/ping", + "method": "POST", + "status": 404, + "response_json": {} + } +] \ No newline at end of file diff --git a/nautobot_chatops/tests/nso/fixtures/sync_from_responses.json b/nautobot_chatops/tests/nso/fixtures/sync_from_responses.json new file mode 100644 index 00000000..2f2fa7db --- /dev/null +++ b/nautobot_chatops/tests/nso/fixtures/sync_from_responses.json @@ -0,0 +1,23 @@ +[ + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=good_device/sync-from", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": true + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=offline_device/sync-from", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": false, + "info": "Device c3 is southbound locked" + } + } + } +] \ No newline at end of file diff --git a/nautobot_chatops/tests/nso/fixtures/sync_status_responses.json b/nautobot_chatops/tests/nso/fixtures/sync_status_responses.json new file mode 100644 index 00000000..57edacc6 --- /dev/null +++ b/nautobot_chatops/tests/nso/fixtures/sync_status_responses.json @@ -0,0 +1,61 @@ +[ + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=in_sync_device/check-sync", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": "in-sync" + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=out_of_sync_device/check-sync", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": "out-of-sync" + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=offline_device/check-sync", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": "locked" + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/check-sync", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "sync-result": [ + { + "device": "in_sync_device", + "result": "in-sync" + }, + { + "device": "out_of_sync_device", + "result": "out-of-sync" + }, + { + "device": "offline_device", + "result": "locked" + } + ] + } + } + }, + { + "url": "https://nso.error/restconf/data/tailf-ncs:devices/check-sync", + "method": "POST", + "status": 404, + "response_json": {} + } +] \ No newline at end of file diff --git a/nautobot_chatops/tests/nso/fixtures/sync_to_responses.json b/nautobot_chatops/tests/nso/fixtures/sync_to_responses.json new file mode 100644 index 00000000..9416a586 --- /dev/null +++ b/nautobot_chatops/tests/nso/fixtures/sync_to_responses.json @@ -0,0 +1,23 @@ +[ + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=good_device/sync-to", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": true + } + } + }, + { + "url": "https://nso.test/restconf/data/tailf-ncs:devices/device=offline_device/sync-to", + "method": "POST", + "status": 200, + "response_json": { + "tailf-ncs:output": { + "result": false, + "info": "Device c3 is southbound locked" + } + } + } +] \ No newline at end of file diff --git a/nautobot_chatops/tests/nso/test_nso.py b/nautobot_chatops/tests/nso/test_nso.py new file mode 100644 index 00000000..995a787f --- /dev/null +++ b/nautobot_chatops/tests/nso/test_nso.py @@ -0,0 +1,162 @@ +"""Test rundeck client.""" +# Disable protected access, since..ya know. We need to test them. # pylint: disable=protected-access + +from os import path +import json +import responses +from django.test import SimpleTestCase + +from nautobot_chatops.integrations.nso.nso import NSOClient as nso +from nautobot_chatops.integrations.nso.exceptions import ( + CommunicationError, + DeviceNotFound, + DeviceNotSupported, + DeviceLocked, +) + + +HERE = path.abspath(path.dirname(__file__)) + + +def load_api_calls(_responses, fixture): + """Load the API calls into memory for mocking.""" + with open(f"{HERE}/fixtures/{fixture}.json", "r", encoding="utf-8") as file_: + api_calls = json.load(file_) + + for api_call in api_calls: + if api_call["method"] == "GET": + _responses.add( + _responses.GET, + api_call["url"], + json=api_call["response_json"], + status=api_call["status"], + headers={"Content-Type": "application/yang-data+xml"}, + ) + continue + + if api_call["method"] == "POST": + _responses.add( + _responses.POST, + api_call["url"], + json=api_call["response_json"], + match=[_responses.matchers.json_params_matcher(api_call["body"])] if "body" in api_call else [], + status=api_call["status"], + headers={"Content-Type": "application/yang-data+xml"}, + ) + continue + + +class TestNSO(SimpleTestCase): + """Test Base NSO Client and Calls.""" + + def setUp(self): + """Setup.""" + self.url = "https://nso.test" + self.username = "username" + self.password = "password" # nosec + self.nso = nso(self.url, self.username, self.password) + + self.error_url = "https://nso.error" + self.error_nso = nso(self.error_url, self.username, self.password) + + @responses.activate + def test_ping(self): + """Test Ping Device API Call.""" + + load_api_calls(responses, "ping_device_responses") + + self.assertIn("1 packets transmitted, 1 received, 0% packet loss", self.nso.ping("good_device")) + self.assertIn("100% packet loss", self.nso.ping("offline_device")) + # self.assertIn("Device does not exist in NSO", self.nso.ping("nonexistent_device")) + with self.assertRaises(DeviceNotFound): + self.nso.ping("nonexistent_device") + with self.assertRaises(CommunicationError): + self.error_nso.ping("error_device") + + @responses.activate + def test_connect(self): + """Test Connect to Device API Call.""" + + load_api_calls(responses, "connect_device_responses") + + self.assertEqual(self.nso.connect("good_device"), True) + self.assertEqual(self.nso.connect("offline_device"), False) + # self.assertEqual(self.nso.connect("nonexistent_device"), "Device does not exist in NSO") + with self.assertRaises(DeviceNotFound): + self.nso.connect("nonexistent_device") + + @responses.activate + def test_sync_to(self): + """Test Sync to Device API Call.""" + + load_api_calls(responses, "sync_to_responses") + + self.assertEqual(self.nso.sync_to("good_device"), True) + self.assertEqual(self.nso.sync_to("offline_device"), False) + + @responses.activate + def test_sync_from(self): + """Test Sync from Device API Call.""" + + load_api_calls(responses, "sync_from_responses") + + self.assertEqual(self.nso.sync_from("good_device"), True) + self.assertEqual(self.nso.sync_from("offline_device"), False) + + @responses.activate + def test_sync_status(self): + """Test Check Sync Status of Device API Call.""" + + load_api_calls(responses, "sync_status_responses") + + self.assertEqual(self.nso.sync_status("in_sync_device"), "in-sync") + self.assertEqual(self.nso.sync_status("out_of_sync_device"), "out-of-sync") + self.assertEqual(self.nso.sync_status("offline_device"), "locked") + + @responses.activate + def test_sync_status_all(self): + """Test Check Sync Status of All Devices API Call.""" + + load_api_calls(responses, "sync_status_responses") + response_sync_status_all = [ + {"device": "in_sync_device", "result": "in-sync"}, + {"device": "out_of_sync_device", "result": "out-of-sync"}, + {"device": "offline_device", "result": "locked"}, + ] + self.assertEqual(self.nso.sync_status_all(), response_sync_status_all) + with self.assertRaises(CommunicationError): + self.error_nso.sync_status_all() + + @responses.activate + def test_compare_config(self): + """Test Compare Config of Device API Call.""" + + load_api_calls(responses, "compare_config_responses") + + self.assertEqual(self.nso.compare_config("in_sync_device"), {}) + self.assertIn("Test Change", self.nso.compare_config("out_of_sync_device")) + self.assertEqual(self.nso.compare_config("offline_device"), {"info": "Device c3 is southbound locked"}) + + @responses.activate + def test_live_status(self): + """Test Compare Config of Device API Call.""" + + load_api_calls(responses, "live_status_responses") + + self.assertEqual( + self.nso.live_status("good_device", "show version"), "\r\nCisco IOS Software, NETSIM\r\ngood_device# " + ) + self.assertEqual( + self.nso.live_status("good_device_nx", "show version"), + "\r\nCisco NX-OS Software, NETSIM\r\ngood_device_nx# ", + ) + with self.assertRaises(DeviceLocked): + self.nso.live_status("offline_device", "show version") + with self.assertRaises(DeviceNotSupported): + self.nso.live_status("unsupported_device_asa", "show version") + with self.assertRaises(DeviceNotFound): + self.nso.live_status("nonexistent_device", "show version") + with self.assertRaises(CommunicationError): + self.error_nso.live_status("error_device_type", "show version") + with self.assertRaises(CommunicationError): + self.error_nso.live_status("error_live_status", "show version") diff --git a/poetry.lock b/poetry.lock index 27b63337..2f76ab43 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3792,6 +3792,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -3799,8 +3800,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -3817,6 +3825,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -3824,6 +3833,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -4031,6 +4041,25 @@ files = [ [package.dependencies] requests = ">=2.0.1,<3.0.0" +[[package]] +name = "responses" +version = "0.24.1" +description = "A utility library for mocking out the `requests` Python library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "responses-0.24.1-py3-none-any.whl", hash = "sha256:a2b43f4c08bfb9c9bd242568328c65a34b318741d3fab884ac843c5ceeb543f9"}, + {file = "responses-0.24.1.tar.gz", hash = "sha256:b127c6ca3f8df0eb9cc82fd93109a3007a86acb24871834c47b77765152ecf8c"}, +] + +[package.dependencies] +pyyaml = "*" +requests = ">=2.30.0,<3.0" +urllib3 = ">=1.25.10,<3.0" + +[package.extras] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] + [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -4976,4 +5005,4 @@ panorama = ["defusedxml", "ipaddr", "netmiko", "netutils", "pan-os-python"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.12" -content-hash = "352d2cdc13f37b487004c249b50f331e5dfeec1aa37bed959a1da26a08348a82" +content-hash = "2494c3e26a2bd784e48c4ed707faa4c9667f047d4e160a58cffc8138bce6b637" diff --git a/pyproject.toml b/pyproject.toml index 163d94ed..667b0da5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ include = [ "meraki" = "nautobot_chatops.integrations.meraki.worker:cisco_meraki" "nautobot" = "nautobot_chatops.workers.nautobot:nautobot" "panorama" = "nautobot_chatops.integrations.panorama.worker:panorama" +"nso" = "nautobot_chatops.integrations.nso.worker:nso" [tool.poetry.dependencies] Markdown = "!=3.3.5" @@ -95,6 +96,7 @@ toml = "*" towncrier = "~23.6.0" to-json-schema = "*" jsonschema = "*" +responses = "^0.24.0" [tool.poetry.extras] all = [