├── .github ├── CODEOWNERS ├── workflows │ ├── labeler.yaml │ └── diode-napalm-agent-lint-tests.yml └── pull_request_labeler.yaml ├── diode-napalm-agent ├── sample.env ├── tests │ ├── __init__.py │ ├── test_parser.py │ ├── test_client.py │ ├── test_translate.py │ ├── test_discovery.py │ └── test_cli.py ├── diode_napalm │ ├── cli │ │ ├── __init__.py │ │ └── cli.py │ ├── __init__.py │ ├── version.py │ ├── client.py │ ├── discovery.py │ ├── parser.py │ └── translate.py ├── config.sample.yaml ├── pyproject.toml ├── README.md └── LICENSE ├── SECURITY.md ├── .gitignore ├── README.md └── LICENSE /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @leoparente @mfiedorowicz @natm 2 | -------------------------------------------------------------------------------- /diode-napalm-agent/sample.env: -------------------------------------------------------------------------------- 1 | DIODE_API_KEY=CHANGE_ME.1 2 | USER=admin 3 | ARISTA_PASSWORD=CHANGE_ME.3 4 | -------------------------------------------------------------------------------- /diode-napalm-agent/tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """NetBox Labs - Tests namespace.""" 4 | -------------------------------------------------------------------------------- /diode-napalm-agent/diode_napalm/cli/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """NetBox Labs - CLI namespace.""" 4 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please send any suspected vulnerability report to security@netboxlabs.com 6 | -------------------------------------------------------------------------------- /diode-napalm-agent/diode_napalm/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """NetBox Labs - Diode NAPALM namespace.""" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ 2 | .idea/ 3 | 4 | # VS Code 5 | .vscode 6 | 7 | # Environments 8 | .env 9 | .venv 10 | env/ 11 | venv/ 12 | ENV/ 13 | env.bak/ 14 | venv.bak/ 15 | 16 | # macOS 17 | .DS_Store 18 | 19 | # Python 20 | __pycache__/ 21 | *.py[cod] 22 | *$py.class 23 | .Python 24 | build/ 25 | dist/ 26 | .eggs/ 27 | *.egg-info/ 28 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yaml: -------------------------------------------------------------------------------- 1 | name: PR labeler 2 | on: 3 | - pull_request_target 4 | 5 | jobs: 6 | triage: 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/labeler@v5 14 | with: 15 | configuration-path: '.github/pull_request_labeler.yaml' 16 | -------------------------------------------------------------------------------- /diode-napalm-agent/diode_napalm/version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """Version stamp.""" 4 | 5 | # These properties are injected at build time by the build process. 6 | 7 | __commit_hash__ = "unknown" 8 | __track__ = "dev" 9 | __version__ = "0.0.0" 10 | 11 | 12 | def version_display(): 13 | """Display the version, track and hash together.""" 14 | return f"v{__version__}-{__track__}-{__commit_hash__}" 15 | 16 | 17 | def version_semver(): 18 | """Semantic version.""" 19 | return __version__ 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Diode Agents 2 | > **ℹ️ Note:** The official Netbox Discovery Agent is maintained at [netboxlabs/orb-agent](https://github.com/netboxlabs/orb-agent). This repository contains sample or experimental agents built using the Diode SDK. 3 | 4 | This repository contains a collection of sample agents that leverage the [Diode SDK](https://github.com/netboxlabs/diode-sdk-python) to interact with the [Diode](https://netboxlabs.com/blog/introducing-diode-streamlining-data-ingestion-in-netbox/) server. 5 | 6 | * [Diode NAPALM agent](diode-napalm-agent/README.md) 7 | -------------------------------------------------------------------------------- /diode-napalm-agent/config.sample.yaml: -------------------------------------------------------------------------------- 1 | diode: 2 | config: 3 | target: grpc://localhost:8080/diode 4 | api_key: ${DIODE_API_KEY} 5 | policies: 6 | discovery_1: 7 | config: 8 | netbox: 9 | site: New York NY 10 | data: 11 | - hostname: 192.168.0.32 12 | username: ${USER} 13 | password: admin 14 | - driver: eos 15 | hostname: 127.0.0.1 16 | username: admin 17 | password: ${ARISTA_PASSWORD} 18 | optional_args: 19 | enable_password: ${ARISTA_PASSWORD} 20 | -------------------------------------------------------------------------------- /.github/pull_request_labeler.yaml: -------------------------------------------------------------------------------- 1 | github-actions: 2 | - changed-files: 3 | - any-glob-to-any-file: 4 | - '**/.github/workflows/*' 5 | - '**/.github/workflows/**/*' 6 | - '**/.github/dependabot.yaml' 7 | - '**/.github/pull_request_labeler.yaml' 8 | 9 | dependencies: 10 | - changed-files: 11 | - any-glob-to-any-file: 12 | - '**/pyproject.toml' 13 | - '**/poetry.lock' 14 | - '**/requirements.txt' 15 | 16 | markdown: 17 | - changed-files: 18 | - any-glob-to-any-file: '**/*.md' 19 | 20 | python: 21 | - changed-files: 22 | - any-glob-to-any-file: '**/*.py' 23 | -------------------------------------------------------------------------------- /.github/workflows/diode-napalm-agent-lint-tests.yml: -------------------------------------------------------------------------------- 1 | name: diode-napalm-agent - lint and tests 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | paths: 6 | - "diode-napalm-agent/**" 7 | push: 8 | branches: 9 | - "!release" 10 | paths: 11 | - "diode-napalm-agent/**" 12 | 13 | concurrency: 14 | group: ${{ github.workflow }} 15 | cancel-in-progress: false 16 | 17 | env: 18 | AGENT_DIR: diode-napalm-agent 19 | 20 | jobs: 21 | tests: 22 | runs-on: ubuntu-latest 23 | timeout-minutes: 5 24 | strategy: 25 | matrix: 26 | python-version: [ "3.10", "3.11", "3.12" ] 27 | defaults: 28 | run: 29 | working-directory: ${{ env.AGENT_DIR }} 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Setup Python 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: ${{ matrix.python }} 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install . 40 | pip install .[dev] 41 | pip install .[test] 42 | 43 | - name: Run tests with coverage 44 | run: | 45 | set -o pipefail 46 | pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=diode_napalm/ | tee pytest-coverage.txt 47 | 48 | - name: Pytest coverage comment 49 | uses: MishaKav/pytest-coverage-comment@main 50 | with: 51 | pytest-coverage-path: ${{ env.AGENT_DIR }}/pytest-coverage.txt 52 | junitxml-path: ${{ env.AGENT_DIR }}/pytest.xml 53 | 54 | - name: Lint with Ruff 55 | run: | 56 | ruff check --output-format=github diode_napalm/ tests/ 57 | continue-on-error: true 58 | -------------------------------------------------------------------------------- /diode-napalm-agent/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "diode-napalm-agent" 3 | version = "0.0.1" # Overwritten during the build process 4 | description = "NetBox Labs, Diode NAPALM Agent" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | license = { file = "LICENSE" } 8 | authors = [ 9 | {name = "NetBox Labs", email = "support@netboxlabs.com" } 10 | ] 11 | maintainers = [ 12 | {name = "NetBox Labs", email = "support@netboxlabs.com" } 13 | ] 14 | 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Intended Audience :: Developers", 18 | "Topic :: Software Development :: Build Tools", 19 | "License :: OSI Approved :: Apache Software License", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3 :: Only", 22 | 'Programming Language :: Python :: 3.10', 23 | 'Programming Language :: Python :: 3.11', 24 | 'Programming Language :: Python :: 3.12', 25 | ] 26 | 27 | dependencies = [ 28 | "importlib-metadata~=8.5", 29 | "napalm~=5.0", 30 | "netboxlabs-diode-sdk~=0.4", 31 | "pydantic~=2.9", 32 | "python-dotenv~=1.0", 33 | ] 34 | 35 | [project.optional-dependencies] 36 | dev = ["black", "check-manifest", "ruff"] 37 | test = ["coverage", "pytest", "pytest-cov"] 38 | 39 | [project.urls] 40 | "Homepage" = "https://netboxlabs.com/" 41 | 42 | [project.scripts] 43 | diode-napalm-agent = "diode_napalm.cli.cli:main" 44 | 45 | [tool.setuptools] 46 | packages = [ 47 | "diode_napalm", 48 | "diode_napalm.cli", 49 | ] 50 | 51 | [build-system] 52 | requires = ["setuptools>=43.0.0", "wheel"] 53 | build-backend = "setuptools.build_meta" 54 | 55 | 56 | [tool.ruff] 57 | line-length = 140 58 | 59 | [tool.ruff.format] 60 | quote-style = "double" 61 | indent-style = "space" 62 | 63 | [tool.ruff.lint] 64 | select = ["C", "D", "E", "F", "I", "R", "UP", "W"] 65 | ignore = ["F401", "D203", "D212", "D400", "D401", "D404", "RET504"] -------------------------------------------------------------------------------- /diode-napalm-agent/diode_napalm/client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """Diode SDK Client for NAPALM.""" 4 | 5 | import logging 6 | import threading 7 | 8 | from netboxlabs.diode.sdk import DiodeClient 9 | 10 | from diode_napalm.translate import translate_data 11 | from diode_napalm.version import version_semver 12 | 13 | APP_NAME = "diode-napalm-agent" 14 | APP_VERSION = version_semver() 15 | 16 | # Set up logging 17 | logging.basicConfig(level=logging.INFO) 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class Client: 22 | """ 23 | Singleton class for managing the Diode client for NAPALM. 24 | 25 | This class ensures only one instance of the Diode client is created and provides methods 26 | to initialize the client and ingest data. 27 | 28 | Attributes 29 | ---------- 30 | diode_client (DiodeClient): Instance of the DiodeClient. 31 | 32 | """ 33 | 34 | _instance = None 35 | _lock = threading.Lock() 36 | 37 | def __new__(cls): 38 | """ 39 | Create a new instance of the Client if one does not already exist. 40 | 41 | Returns 42 | ------- 43 | Client: The singleton instance of the Client. 44 | 45 | """ 46 | if cls._instance is None: 47 | with cls._lock: 48 | if cls._instance is None: 49 | cls._instance = super().__new__(cls) 50 | return cls._instance 51 | 52 | def __init__(self): 53 | """Initialize the Client instance with no Diode client.""" 54 | if not hasattr(self, "diode_client"): # Prevent reinitialization 55 | self.diode_client = None 56 | 57 | def init_client(self, target: str, api_key: str | None = None): 58 | """ 59 | Initialize the Diode client with the specified target, API key, and TLS verification. 60 | 61 | Args: 62 | ---- 63 | target (str): The target endpoint for the Diode client. 64 | api_key (Optional[str]): The API key for authentication (default is None). 65 | 66 | """ 67 | with self._lock: 68 | self.diode_client = DiodeClient( 69 | target=target, 70 | app_name=APP_NAME, 71 | app_version=APP_VERSION, 72 | api_key=api_key, 73 | ) 74 | 75 | def ingest(self, hostname: str, data: dict): 76 | """ 77 | Ingest data using the Diode client after translating it. 78 | 79 | Args: 80 | ---- 81 | hostname (str): The device hostname. 82 | data (dict): The data to be ingested. 83 | 84 | Raises: 85 | ------ 86 | ValueError: If the Diode client is not initialized. 87 | 88 | """ 89 | if self.diode_client is None: 90 | raise ValueError("Diode client not initialized") 91 | 92 | with self._lock: 93 | response = self.diode_client.ingest(translate_data(data)) 94 | 95 | if response.errors: 96 | logger.error(f"ERROR ingestion failed for {hostname} : {response.errors}") 97 | else: 98 | logger.info(f"Hostname {hostname}: Successful ingestion") 99 | -------------------------------------------------------------------------------- /diode-napalm-agent/diode_napalm/discovery.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """Discover the correct NAPALM Driver.""" 4 | 5 | import logging 6 | 7 | import importlib_metadata 8 | from napalm import get_network_driver 9 | 10 | # Set up logging 11 | logging.basicConfig(level=logging.INFO) 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def napalm_driver_list() -> list[str]: 16 | """ 17 | List the available NAPALM drivers. 18 | 19 | This function scans the installed Python packages to identify NAPALM drivers, 20 | appending their names (with the 'napalm-' prefix removed and hyphens replaced 21 | with underscores) to a list of known drivers. 22 | 23 | Returns 24 | ------- 25 | List[str]: A list of strings representing the names of available NAPALM drivers. 26 | The list includes some predefined driver names and dynamically 27 | discovered driver names from the installed packages. 28 | 29 | """ 30 | napalm_packages = ["ios", "eos", "junos", "nxos"] 31 | prefix = "napalm_" 32 | for dist in importlib_metadata.packages_distributions(): 33 | if dist.startswith(prefix): 34 | napalm_packages.append(dist[len(prefix) :]) 35 | return napalm_packages 36 | 37 | 38 | supported_drivers = napalm_driver_list() 39 | 40 | 41 | def set_napalm_logs_level(level: int): 42 | """ 43 | Set the logging level for NAPALM and related libraries. 44 | 45 | Args: 46 | ---- 47 | level (int): The logging level to set. Typically, this can be one of the 48 | standard logging levels (e.g., logging.DEBUG, logging.INFO, 49 | logging.WARNING, logging.ERROR, logging.CRITICAL). 50 | 51 | This function adjusts the logging levels for the "napalm", "ncclient","paramiko" 52 | and "pyeapi" loggers to the specified level, which is useful for controlling the 53 | verbosity of log output from these libraries. 54 | 55 | """ 56 | logging.getLogger("napalm").setLevel(level) 57 | logging.getLogger("ncclient").setLevel(level) 58 | logging.getLogger("paramiko").setLevel(level) 59 | logging.getLogger("pyeapi").setLevel(level) 60 | 61 | 62 | def discover_device_driver(info: dict) -> str: 63 | """ 64 | Discover the correct NAPALM driver for the given device information. 65 | 66 | Args: 67 | ---- 68 | info (dict): A dictionary containing device connection information. 69 | Expected keys are 'hostname', 'username', 'password', 'timeout', 70 | and 'optional_args'. 71 | 72 | Returns: 73 | ------- 74 | str: The name of the driver that successfully connects and identifies 75 | the device. Returns an empty string if no suitable driver is found. 76 | 77 | """ 78 | set_napalm_logs_level(logging.CRITICAL) 79 | for driver in supported_drivers: 80 | try: 81 | logger.info(f"Hostname {info.hostname}: Trying '{driver}' driver") 82 | np_driver = get_network_driver(driver) 83 | with np_driver( 84 | info.hostname, 85 | info.username, 86 | info.password, 87 | info.timeout, 88 | info.optional_args, 89 | ) as device: 90 | device_info = device.get_facts() 91 | if device_info.get("serial_number", "Unknown").lower() == "unknown": 92 | logger.info( 93 | f"Hostname {info.hostname}: '{driver}' driver did not work" 94 | ) 95 | continue 96 | set_napalm_logs_level(logging.INFO) 97 | return driver 98 | except Exception as e: 99 | logger.info( 100 | f"Hostname {info.hostname}: '{driver}' driver did not work. Exception: {str(e)}" 101 | ) 102 | set_napalm_logs_level(logging.INFO) 103 | return "" 104 | -------------------------------------------------------------------------------- /diode-napalm-agent/tests/test_parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """NetBox Labs - Parser Unit Tests.""" 4 | 5 | import os 6 | from pathlib import Path 7 | from unittest.mock import mock_open, patch 8 | 9 | import pytest 10 | 11 | from diode_napalm.parser import ( 12 | Config, 13 | ParseException, 14 | parse_config, 15 | parse_config_file, 16 | resolve_env_vars, 17 | ) 18 | 19 | 20 | @pytest.fixture 21 | def valid_yaml(): 22 | """Valid Yaml Generator.""" 23 | return """ 24 | diode: 25 | config: 26 | target: "target_value" 27 | api_key: "api_key_value" 28 | tls_verify: true 29 | policies: 30 | policy1: 31 | config: 32 | netbox: 33 | site: "New York" 34 | data: 35 | - driver: "ios" 36 | hostname: "router1" 37 | username: "admin" 38 | password: "password" 39 | """ 40 | 41 | 42 | @pytest.fixture 43 | def invalid_yaml(): 44 | """Invalid Yaml Generator.""" 45 | return """ 46 | diode: 47 | config: 48 | target: "target_value" 49 | api_key: "api_key_value" 50 | tls_verify: true 51 | policies: 52 | policy1: 53 | config: 54 | netbox: 55 | site: "New York" 56 | data: 57 | - driver: "ios" 58 | hostname: "router1" 59 | username: "admin" 60 | # Missing password field 61 | """ 62 | 63 | 64 | def test_parse_valid_config(valid_yaml): 65 | """Ensure we can parse a valid configuration.""" 66 | config = parse_config(valid_yaml) 67 | assert isinstance(config, Config) 68 | assert config.diode.config.target == "target_value" 69 | assert config.diode.policies["policy1"].data[0].hostname == "router1" 70 | 71 | 72 | def test_parse_invalid_config(invalid_yaml): 73 | """Ensure an invalid configuration raises a ParseException.""" 74 | with pytest.raises(ParseException): 75 | parse_config(invalid_yaml) 76 | 77 | 78 | @patch("builtins.open", new_callable=mock_open, read_data="valid_yaml") 79 | def test_parse_config_file(mock_file, valid_yaml): 80 | """Ensure we can parse a configuration file.""" 81 | with patch( 82 | "diode_napalm.parser.parse_config", return_value=parse_config(valid_yaml) 83 | ): 84 | config = parse_config_file(Path("fake_path.yaml")) 85 | assert config.config.target == "target_value" 86 | mock_file.assert_called_once_with(Path("fake_path.yaml")) 87 | 88 | 89 | @patch("builtins.open", new_callable=mock_open, read_data="invalid_yaml") 90 | def test_parse_config_file_parse_exception(mock_file): 91 | """Ensure a ParseException in parse_config is propagated.""" 92 | with patch( 93 | "diode_napalm.parser.parse_config", 94 | side_effect=ParseException("Test Parse Exception"), 95 | ): 96 | with pytest.raises(ParseException): 97 | parse_config_file(Path("fake_path.yaml")) 98 | mock_file.assert_called_once_with(Path("fake_path.yaml")) 99 | 100 | 101 | @patch.dict(os.environ, {"API_KEY": "env_api_key"}) 102 | def test_resolve_env_vars(): 103 | """Ensure environment variables are resolved correctly.""" 104 | config_with_env_var = {"api_key": "${API_KEY}"} 105 | resolved_config = resolve_env_vars(config_with_env_var) 106 | assert resolved_config["api_key"] == "env_api_key" 107 | 108 | 109 | def test_resolve_env_vars_no_env(): 110 | """Ensure missing environment variables are handled correctly.""" 111 | config_with_no_env_var = {"api_key": "${MISSING_KEY}"} 112 | resolved_config = resolve_env_vars(config_with_no_env_var) 113 | assert resolved_config["api_key"] == "${MISSING_KEY}" 114 | 115 | 116 | def test_parse_config_file_exception(): 117 | """Ensure file parsing errors are handled correctly.""" 118 | with pytest.raises(Exception): 119 | parse_config_file(Path("non_existent_file.yaml")) 120 | -------------------------------------------------------------------------------- /diode-napalm-agent/tests/test_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """NetBox Labs - Client Unit Tests.""" 4 | 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | 9 | from diode_napalm.client import Client 10 | from diode_napalm.translate import translate_data 11 | 12 | 13 | @pytest.fixture 14 | def sample_data(): 15 | """Sample data for testing ingestion.""" 16 | return { 17 | "device": { 18 | "hostname": "router1", 19 | "model": "ISR4451", 20 | "vendor": "Cisco", 21 | "serial_number": "123456789", 22 | "site": "New York", 23 | "driver": "ios", 24 | }, 25 | "interface": { 26 | "GigabitEthernet0/0": { 27 | "is_enabled": True, 28 | "mtu": 1500, 29 | "mac_address": "00:1C:58:29:4A:71", 30 | "speed": 1000, 31 | "description": "Uplink Interface", 32 | } 33 | }, 34 | "interface_ip": { 35 | "GigabitEthernet0/0": {"ipv4": {"192.0.2.1": {"prefix_length": 24}}} 36 | }, 37 | "driver": "ios", 38 | "site": "New York", 39 | } 40 | 41 | 42 | @pytest.fixture 43 | def mock_version_semver(): 44 | """Mock the version_semver function.""" 45 | with patch("diode_napalm.client.version_semver", return_value="0.0.0") as mock: 46 | yield mock 47 | 48 | 49 | @pytest.fixture 50 | def mock_diode_client_class(): 51 | """Mock the DiodeClient class.""" 52 | with patch("diode_napalm.client.DiodeClient") as mock: 53 | yield mock 54 | 55 | 56 | def test_init_client(mock_diode_client_class, mock_version_semver): 57 | """Test the initialization of the Diode client.""" 58 | client = Client() 59 | client.init_client(target="https://example.com", api_key="dummy_api_key") 60 | 61 | mock_diode_client_class.assert_called_once_with( 62 | target="https://example.com", 63 | app_name="diode-napalm-agent", 64 | app_version=mock_version_semver(), 65 | api_key="dummy_api_key", 66 | ) 67 | 68 | 69 | def test_ingest_success(mock_diode_client_class, sample_data): 70 | """Test successful data ingestion.""" 71 | client = Client() 72 | client.init_client(target="https://example.com", api_key="dummy_api_key") 73 | 74 | mock_diode_instance = mock_diode_client_class.return_value 75 | mock_diode_instance.ingest.return_value.errors = [] 76 | hostname = sample_data["device"]["hostname"] 77 | 78 | with patch( 79 | "diode_napalm.client.translate_data", return_value=translate_data(sample_data) 80 | ) as mock_translate_data: 81 | client.ingest(hostname, sample_data) 82 | mock_translate_data.assert_called_once_with(sample_data) 83 | mock_diode_instance.ingest.assert_called_once() 84 | 85 | 86 | def test_ingest_failure(mock_diode_client_class, sample_data): 87 | """Test data ingestion with errors.""" 88 | client = Client() 89 | client.init_client(target="https://example.com", api_key="dummy_api_key") 90 | 91 | mock_diode_instance = mock_diode_client_class.return_value 92 | mock_diode_instance.ingest.return_value.errors = ["Error1", "Error2"] 93 | hostname = sample_data["device"]["hostname"] 94 | 95 | with patch( 96 | "diode_napalm.client.translate_data", return_value=translate_data(sample_data) 97 | ) as mock_translate_data: 98 | client.ingest(hostname, sample_data) 99 | mock_translate_data.assert_called_once_with(sample_data) 100 | mock_diode_instance.ingest.assert_called_once() 101 | 102 | assert len(mock_diode_instance.ingest.return_value.errors) > 0 103 | 104 | 105 | def test_ingest_without_initialization(): 106 | """Test ingestion without client initialization raises ValueError.""" 107 | Client._instance = None # Reset the Client singleton instance 108 | client = Client() 109 | with pytest.raises(ValueError, match="Diode client not initialized"): 110 | client.ingest("", {}) 111 | -------------------------------------------------------------------------------- /diode-napalm-agent/diode_napalm/parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """Parse Diode Agent Config file.""" 4 | 5 | import os 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | import yaml 10 | from pydantic import BaseModel, Field, ValidationError 11 | 12 | 13 | class ParseException(Exception): 14 | """Custom exception for parsing errors.""" 15 | 16 | pass 17 | 18 | 19 | class Napalm(BaseModel): 20 | """Model for NAPALM configuration.""" 21 | 22 | driver: str | None = Field(default=None, description="Driver name, optional") 23 | hostname: str 24 | username: str 25 | password: str 26 | timeout: int = 60 27 | optional_args: dict[str, Any] | None = Field( 28 | default=None, description="Optional arguments" 29 | ) 30 | 31 | 32 | class DiscoveryConfig(BaseModel): 33 | """Model for discovery configuration.""" 34 | 35 | netbox: dict[str, str] 36 | 37 | 38 | class Policy(BaseModel): 39 | """Model for a policy configuration.""" 40 | 41 | config: DiscoveryConfig 42 | data: list[Napalm] 43 | 44 | 45 | class DiodeConfig(BaseModel): 46 | """Model for Diode configuration.""" 47 | 48 | target: str 49 | api_key: str 50 | 51 | 52 | class Diode(BaseModel): 53 | """Model for Diode containing configuration and policies.""" 54 | 55 | config: DiodeConfig 56 | policies: dict[str, Policy] 57 | 58 | 59 | class Config(BaseModel): 60 | """Top-level model for the entire configuration.""" 61 | 62 | diode: Diode 63 | 64 | 65 | def resolve_env_vars(config): 66 | """ 67 | Recursively resolve environment variables in the configuration. 68 | 69 | Args: 70 | ---- 71 | config (dict): The configuration dictionary. 72 | 73 | Returns: 74 | ------- 75 | dict: The configuration dictionary with environment variables resolved. 76 | 77 | """ 78 | if isinstance(config, dict): 79 | return {k: resolve_env_vars(v) for k, v in config.items()} 80 | if isinstance(config, list): 81 | return [resolve_env_vars(i) for i in config] 82 | if isinstance(config, str) and config.startswith("${") and config.endswith("}"): 83 | env_var = config[2:-1] 84 | return os.getenv(env_var, config) 85 | return config 86 | 87 | 88 | def parse_config(config_data: str): 89 | """ 90 | Parse the YAML configuration data into a Config object. 91 | 92 | Args: 93 | ---- 94 | config_data (str): The YAML configuration data as a string. 95 | 96 | Returns: 97 | ------- 98 | Config: The parsed configuration object. 99 | 100 | Raises: 101 | ------ 102 | ParseException: If there is an error in parsing the YAML or validating the data. 103 | 104 | """ 105 | try: 106 | # Parse the YAML configuration data 107 | config_dict = yaml.safe_load(config_data) 108 | # Resolve environment variables 109 | resolved_config = resolve_env_vars(config_dict) 110 | # Parse the data into the Config model 111 | config = Config(**resolved_config) 112 | return config 113 | except yaml.YAMLError as e: 114 | raise ParseException(f"YAML ERROR: {e}") 115 | except ValidationError as e: 116 | raise ParseException("Validation ERROR:", e) 117 | 118 | 119 | def parse_config_file(file_path: Path) -> Diode: 120 | """ 121 | Parse the Diode configuration file and return the Diode configuration object. 122 | 123 | This function reads the content of the specified YAML configuration file, 124 | parses it into a `Config` object, and returns the `Diode` part of the configuration. 125 | 126 | Args: 127 | ---- 128 | file_path (Path): The path to the YAML configuration file. 129 | 130 | Returns: 131 | ------- 132 | Diode: The `Diode` configuration object extracted from the parsed configuration. 133 | 134 | Raises: 135 | ------ 136 | ParseException: If there is an error parsing the YAML content or validating the data. 137 | Exception: If there is an error opening the file or any other unexpected error. 138 | 139 | """ 140 | try: 141 | with open(file_path) as f: 142 | cfg = parse_config(f.read()) 143 | except ParseException: 144 | raise 145 | except Exception as e: 146 | raise Exception(f"Unable to open config file {file_path}: {e.args[1]}") 147 | return cfg.diode 148 | -------------------------------------------------------------------------------- /diode-napalm-agent/README.md: -------------------------------------------------------------------------------- 1 | # Diode NAPALM Agent 2 | 3 | The Diode NAPALM Agent is a lightweight network device discovery tool that uses [NAPALM](https://github.com/napalm-automation/napalm) to streamline data entry into NetBox through the [Diode](https://github.com/netboxlabs/diode) ingestion service. 4 | 5 | # Get started 6 | 7 | This is a basic set of instructions to get started using Diode NAPALM agent on a local machine. 8 | 9 | ## Requirements 10 | 11 | The Diode NAPALM Agent requires a Python runtime environment and has the following requirements: 12 | - importlib-metadata~=8.5 13 | - napalm~=5.0 14 | - netboxlabs-diode-sdk~=0.4 15 | - pydantic~=2.9 16 | - python-dotenv~=1.0 17 | 18 | Instructions on installing the Diode SDK Python can be found [here](https://github.com/netboxlabs/diode-sdk-python). 19 | 20 | ## Installation 21 | 22 | Clone the agent repository: 23 | 24 | ```bash 25 | git clone https://github.com/netboxlabs/diode-agent.git 26 | cd diode-agent/ 27 | ``` 28 | 29 | Create a Python virtual environment and install the agent: 30 | 31 | ```bash 32 | python3 -m venv venv 33 | source venv/bin/activate 34 | pip install ./diode-napalm-agent --no-cache-dir 35 | ``` 36 | 37 | ### Create a discovery configuration file 38 | 39 | A configuration file needs to be created with an inventory of devices to be discovered. An example (`config.sample.yaml`) is provided in the agent repository. The `config` section needs to be updated to reflect your Diode server environment and the `data` section should include a list of all devices (and their credentials) to be discovered. 40 | 41 | ```yaml 42 | diode: 43 | config: 44 | target: grpc://localhost:8080/diode 45 | api_key: ${DIODE_API_KEY} 46 | policies: 47 | discovery_1: 48 | config: 49 | netbox: 50 | site: New York NY 51 | data: 52 | - hostname: 192.168.0.32 53 | username: ${USER} 54 | password: admin 55 | - driver: eos 56 | hostname: 127.0.0.1 57 | username: admin 58 | password: ${ARISTA_PASSWORD} 59 | optional_args: 60 | enable_password: ${ARISTA_PASSWORD} 61 | ``` 62 | 63 | Variables (using `${ENV}` syntax) can be referenced in the configuration file from environmental variables or from a provided `.env` file. 64 | 65 | The `driver` device attribute is optional. If not specified, the agent will attempt to find a match from NAPALM supported and installed drivers. 66 | 67 | Detailed information about `optional_args` can be found in the NAPALM [documentation](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). 68 | 69 | 70 | ## Running the agent 71 | 72 | Usage: 73 | 74 | ``` 75 | usage: diode-napalm-agent [-h] [-V] -c config.yaml [-e .env] [-w N] 76 | 77 | Diode Agent for NAPALM 78 | 79 | options: 80 | -h, --help show this help message and exit 81 | -V, --version Display Diode Agent, NAPALM and Diode SDK versions 82 | -c config.yaml, --config config.yaml 83 | Agent yaml configuration file 84 | -e .env, --env .env File containing environment variables 85 | -w N, --workers N Number of workers to be used 86 | ``` 87 | 88 | Run `diode-napalm-agent` with a discovery configuration file named `config.yaml`: 89 | 90 | ```bash 91 | diode-napalm-agent -c config.yaml 92 | ``` 93 | 94 | ### Supported drivers 95 | 96 | The default supported drivers are the natively supported [NAPALM](https://napalm.readthedocs.io/en/latest/#supported-network-operating-systems) drivers: 97 | 98 | - Arista EOS ("eos") 99 | - Cisco IOS ("ios") 100 | - Cisco IOS-XR ("iosxr") 101 | - Cisco NX-OS ("nxos") 102 | - Juniper JunOS ("junos") 103 | 104 | For NAPALM [community drivers](https://github.com/napalm-automation-community) installed in the environment, they can be referenced in the agent policy and will be used for automatic driver matching if no driver is specified. 105 | 106 | ### Supported Netbox Object Types 107 | 108 | The Diode NAPALM agent tries to fetch information from network devices about the following NetBox object types: 109 | 110 | - [DCIM.Device](https://netboxlabs.com/docs/netbox/en/stable/models/dcim/device/) 111 | - [DCIM.DeviceType](https://netboxlabs.com/docs/netbox/en/stable/models/dcim/devicetype/) 112 | - [DCIM.Interface](https://netboxlabs.com/docs/netbox/en/stable/models/dcim/interface/) 113 | - [DCIM.Platform](https://netboxlabs.com/docs/netbox/en/stable/models/dcim/platform/) 114 | - [IPAM.IPAddress](https://netboxlabs.com/docs/netbox/en/stable/models/ipam/ipaddress/) 115 | - [IPAM.Prefix](https://netboxlabs.com/docs/netbox/en/stable/models/ipam/prefix/) 116 | 117 | ## License 118 | 119 | Distributed under the Apache 2.0 License. See [LICENSE.txt](./diode-proto/LICENSE.txt) for more information. 120 | -------------------------------------------------------------------------------- /diode-napalm-agent/tests/test_translate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """NetBox Labs - Translate Unit Tests.""" 4 | 5 | import pytest 6 | 7 | from diode_napalm.translate import ( 8 | translate_data, 9 | translate_device, 10 | translate_interface, 11 | translate_interface_ips, 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def sample_device_info(): 17 | """Sample device information for testing.""" 18 | return { 19 | "hostname": "router1", 20 | "model": "ISR4451", 21 | "vendor": "Cisco", 22 | "serial_number": "123456789", 23 | "site": "New York", 24 | "driver": "ios", 25 | "interface_list": ["GigabitEthernet0/0"], 26 | } 27 | 28 | 29 | @pytest.fixture 30 | def sample_interface_info(): 31 | """Sample interface information for testing.""" 32 | return { 33 | "GigabitEthernet0/0": { 34 | "is_enabled": True, 35 | "mtu": 1500, 36 | "mac_address": "00:1C:58:29:4A:71", 37 | "speed": 1000, 38 | "description": "Uplink Interface", 39 | } 40 | } 41 | 42 | 43 | @pytest.fixture 44 | def sample_interface_overflows_info(): 45 | """Sample interface information for testing.""" 46 | return { 47 | "GigabitEthernet0/0": { 48 | "is_enabled": True, 49 | "mtu": 150000000000, 50 | "mac_address": "00:1C:58:29:4A:71", 51 | "speed": 10000000000, 52 | "description": "Uplink Interface", 53 | } 54 | } 55 | 56 | 57 | @pytest.fixture 58 | def sample_interfaces_ip(): 59 | """Sample interface IPs for testing.""" 60 | return {"GigabitEthernet0/0": {"ipv4": {"192.0.2.1": {"prefix_length": 24}}}} 61 | 62 | 63 | def test_translate_device(sample_device_info): 64 | """Ensure device translation is correct.""" 65 | device = translate_device(sample_device_info) 66 | assert device.name == "router1" 67 | assert device.device_type.model == "ISR4451" 68 | assert device.platform.name == "ios" 69 | assert device.serial == "123456789" 70 | assert device.site.name == "New York" 71 | 72 | 73 | def test_translate_interface(sample_device_info, sample_interface_info): 74 | """Ensure interface translation is correct.""" 75 | device = translate_device(sample_device_info) 76 | interface = translate_interface( 77 | device, "GigabitEthernet0/0", sample_interface_info["GigabitEthernet0/0"] 78 | ) 79 | assert interface.device.name == "router1" 80 | assert interface.name == "GigabitEthernet0/0" 81 | assert interface.enabled is True 82 | assert interface.mtu == 1500 83 | assert interface.mac_address == "00:1C:58:29:4A:71" 84 | assert interface.speed == 1000000 85 | assert interface.description == "Uplink Interface" 86 | 87 | 88 | def test_translate_interface_with_overflow_data( 89 | sample_device_info, sample_interface_overflows_info 90 | ): 91 | """Ensure interface translation is correct.""" 92 | device = translate_device(sample_device_info) 93 | interface = translate_interface( 94 | device, 95 | "GigabitEthernet0/0", 96 | sample_interface_overflows_info["GigabitEthernet0/0"], 97 | ) 98 | assert interface.device.name == "router1" 99 | assert interface.name == "GigabitEthernet0/0" 100 | assert interface.enabled is True 101 | assert interface.mtu == 0 102 | assert interface.mac_address == "00:1C:58:29:4A:71" 103 | assert interface.speed == 0 104 | assert interface.description == "Uplink Interface" 105 | 106 | 107 | def test_translate_interface_ips( 108 | sample_device_info, sample_interface_info, sample_interfaces_ip 109 | ): 110 | """Ensure interface IPs translation is correct.""" 111 | device = translate_device(sample_device_info) 112 | interface = translate_interface( 113 | device, "GigabitEthernet0/0", sample_interface_info["GigabitEthernet0/0"] 114 | ) 115 | ip_entities = list(translate_interface_ips(interface, sample_interfaces_ip)) 116 | assert len(ip_entities) == 2 117 | assert ip_entities[0].prefix.prefix == "192.0.2.0/24" 118 | assert ip_entities[1].ip_address.address == "192.0.2.1/24" 119 | 120 | 121 | def test_translate_data( 122 | sample_device_info, sample_interface_info, sample_interfaces_ip 123 | ): 124 | """Ensure data translation is correct.""" 125 | data = { 126 | "device": sample_device_info, 127 | "interface": sample_interface_info, 128 | "interface_ip": sample_interfaces_ip, 129 | "driver": "ios", 130 | "site": "New York", 131 | } 132 | entities = list(translate_data(data)) 133 | assert len(entities) == 4 134 | assert entities[0].device.name == "router1" 135 | assert entities[1].interface.name == "GigabitEthernet0/0" 136 | assert entities[2].prefix.prefix == "192.0.2.0/24" 137 | assert entities[3].ip_address.address == "192.0.2.1/24" 138 | -------------------------------------------------------------------------------- /diode-napalm-agent/tests/test_discovery.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """NetBox Labs - Discovery Unit Tests.""" 4 | 5 | import logging 6 | from types import SimpleNamespace 7 | from unittest.mock import MagicMock, patch 8 | 9 | import pytest 10 | 11 | from diode_napalm.discovery import ( 12 | discover_device_driver, 13 | napalm_driver_list, 14 | set_napalm_logs_level, 15 | supported_drivers, 16 | ) 17 | 18 | 19 | @pytest.fixture 20 | def mock_get_network_driver(): 21 | """Mock the get_network_driver function from napalm.""" 22 | with patch("diode_napalm.discovery.get_network_driver") as mock: 23 | yield mock 24 | 25 | 26 | @pytest.fixture 27 | def mock_importlib_metadata_packages_distributions(): 28 | """Mock the importlib_metadata.packages_distributions function.""" 29 | with patch( 30 | "diode_napalm.discovery.importlib_metadata.packages_distributions" 31 | ) as mock: 32 | yield mock 33 | 34 | 35 | @pytest.fixture 36 | def mock_loggers(): 37 | """Mock the logging.getLogger function for various loggers.""" 38 | with patch("diode_napalm.discovery.logging.getLogger") as mock: 39 | yield mock 40 | 41 | 42 | def test_discover_device_driver_success(mock_get_network_driver): 43 | """ 44 | Test successful discovery of a NAPALM driver. 45 | 46 | Args: 47 | ---- 48 | mock_get_network_driver: Mocked get_network_driver function. 49 | 50 | """ 51 | mock_driver_instance = MagicMock() 52 | mock_driver_instance.get_facts.return_value = {"serial_number": "ABC123"} 53 | 54 | mock_get_network_driver.side_effect = [ 55 | MagicMock(return_value=mock_driver_instance) 56 | ] * len(supported_drivers) 57 | 58 | info = SimpleNamespace( 59 | hostname="testhost", 60 | username="testuser", 61 | password="testpass", 62 | timeout=10, 63 | optional_args={}, 64 | ) 65 | 66 | driver = discover_device_driver(info) 67 | assert driver in supported_drivers, "Expected one of the supported drivers" 68 | 69 | 70 | def test_discover_device_driver_no_serial_number(mock_get_network_driver): 71 | """ 72 | Test discovery when no serial number is found. 73 | 74 | Args: 75 | ---- 76 | mock_get_network_driver: Mocked get_network_driver function. 77 | 78 | """ 79 | 80 | def side_effect(): 81 | mock_driver_instance = MagicMock() 82 | mock_driver_instance.get_facts.return_value = {"serial_number": "Unknown"} 83 | return mock_driver_instance 84 | 85 | mock_get_network_driver.side_effect = side_effect 86 | 87 | info = SimpleNamespace( 88 | hostname="testhost", 89 | username="testuser", 90 | password="testpass", 91 | timeout=10, 92 | optional_args={}, 93 | ) 94 | 95 | driver = discover_device_driver(info) 96 | assert driver == "", "Expected no driver to be found" 97 | 98 | 99 | def test_discover_device_driver_exception(mock_get_network_driver): 100 | """ 101 | Test discovery when exceptions are raised. 102 | 103 | Args: 104 | ---- 105 | mock_get_network_driver: Mocked get_network_driver function. 106 | 107 | """ 108 | mock_get_network_driver.side_effect = Exception("Connection failed") 109 | 110 | info = SimpleNamespace( 111 | hostname="testhost", 112 | username="testuser", 113 | password="testpass", 114 | timeout=10, 115 | optional_args={}, 116 | ) 117 | 118 | driver = discover_device_driver(info) 119 | assert driver == "", "Expected no driver to be found due to exception" 120 | 121 | 122 | def test_discover_device_driver_mixed_results(mock_get_network_driver): 123 | """ 124 | Test discovery with mixed results from drivers. 125 | 126 | Args: 127 | ---- 128 | mock_get_network_driver: Mocked get_network_driver function. 129 | 130 | """ 131 | 132 | def side_effect(driver_name): 133 | if driver_name == "nxos": 134 | mock_driver_instance = MagicMock() 135 | mock_driver_instance.get_facts.return_value = {"serial_number": "ABC123"} 136 | return mock_driver_instance 137 | raise Exception("Connection failed") 138 | 139 | mock_get_network_driver.side_effect = side_effect 140 | 141 | info = SimpleNamespace( 142 | hostname="testhost", 143 | username="testuser", 144 | password="testpass", 145 | timeout=10, 146 | optional_args={}, 147 | ) 148 | 149 | driver = discover_device_driver(info) 150 | assert driver == "nxos", "Expected the 'ios' driver to be found" 151 | 152 | 153 | def test_napalm_driver_list(mock_importlib_metadata_packages_distributions): 154 | """ 155 | Test the napalm_driver_list function to ensure it correctly lists available NAPALM drivers. 156 | 157 | Args: 158 | ---- 159 | mock_importlib_metadata_packages_distributions: Mocked importlib_metadata.packages_distributions function. 160 | 161 | """ 162 | mock_distributions = [ 163 | "napalm_srl", 164 | "napalm_fake_driver", 165 | ] 166 | mock_importlib_metadata_packages_distributions.return_value = mock_distributions 167 | expected_drivers = ["ios", "eos", "junos", "nxos", "srl", "fake_driver"] 168 | drivers = napalm_driver_list() 169 | assert drivers == expected_drivers, f"Expected {expected_drivers}, got {drivers}" 170 | 171 | 172 | def test_set_napalm_logs_level(mock_loggers): 173 | """ 174 | Test setting the logging level for NAPALM and related libraries. 175 | 176 | Args: 177 | ---- 178 | mock_loggers: Mocked loggers for various libraries. 179 | 180 | """ 181 | set_napalm_logs_level(logging.DEBUG) 182 | 183 | for logger in mock_loggers.values(): 184 | logger.setLevel.assert_called_once_with(logging.DEBUG) 185 | -------------------------------------------------------------------------------- /diode-napalm-agent/diode_napalm/translate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """Translate from NAPALM output format to Diode SDK entities.""" 4 | 5 | import ipaddress 6 | from collections.abc import Iterable 7 | 8 | from netboxlabs.diode.sdk.ingester import ( 9 | Device, 10 | DeviceType, 11 | Entity, 12 | Interface, 13 | IPAddress, 14 | Platform, 15 | Prefix, 16 | ) 17 | 18 | 19 | def int32_overflows(number: int) -> bool: 20 | """ 21 | Check if an integer is overflowing the int32 range. 22 | 23 | Args: 24 | ---- 25 | number (int): The integer to check. 26 | 27 | Returns: 28 | ------- 29 | bool: True if the integer is overflowing the int32 range, False otherwise. 30 | 31 | """ 32 | INT32_MIN = -2147483648 33 | INT32_MAX = 2147483647 34 | return not (INT32_MIN <= number <= INT32_MAX) 35 | 36 | 37 | def translate_device(device_info: dict) -> Device: 38 | """ 39 | Translate device information from NAPALM format to Diode SDK Device entity. 40 | 41 | Args: 42 | ---- 43 | device_info (dict): Dictionary containing device information. 44 | 45 | Returns: 46 | ------- 47 | Device: Translated Device entity. 48 | 49 | """ 50 | device = Device( 51 | name=device_info.get("hostname"), 52 | device_type=DeviceType( 53 | model=device_info.get("model"), manufacturer=device_info.get("vendor") 54 | ), 55 | platform=Platform( 56 | name=device_info.get("driver"), manufacturer=device_info.get("vendor") 57 | ), 58 | serial=device_info.get("serial_number"), 59 | status="active", 60 | site=device_info.get("site"), 61 | ) 62 | return device 63 | 64 | 65 | def translate_interface( 66 | device: Device, if_name: str, interface_info: dict 67 | ) -> Interface: 68 | """ 69 | Translate interface information from NAPALM format to Diode SDK Interface entity. 70 | 71 | Args: 72 | ---- 73 | device (Device): The device to which the interface belongs. 74 | if_name (str): The name of the interface. 75 | interface_info (dict): Dictionary containing interface information. 76 | 77 | Returns: 78 | ------- 79 | Interface: Translated Interface entity. 80 | 81 | """ 82 | interface = Interface( 83 | device=device, 84 | name=if_name, 85 | enabled=interface_info.get("is_enabled"), 86 | mac_address=interface_info.get("mac_address"), 87 | description=interface_info.get("description"), 88 | ) 89 | 90 | # Convert napalm interface speed from Mbps to Netbox Kbps 91 | speed = int(interface_info.get("speed")) * 1000 92 | if not int32_overflows(speed): 93 | interface.speed = speed 94 | 95 | mtu = interface_info.get("mtu") 96 | if not int32_overflows(mtu): 97 | interface.mtu = mtu 98 | 99 | return interface 100 | 101 | 102 | def translate_interface_ips( 103 | interface: Interface, interfaces_ip: dict 104 | ) -> Iterable[Entity]: 105 | """ 106 | Translate IP address and Prefixes information for an interface. 107 | 108 | Args: 109 | ---- 110 | interface (Interface): The interface entity. 111 | if_name (str): The name of the interface. 112 | interfaces_ip (dict): Dictionary containing interface IP information. 113 | 114 | Returns: 115 | ------- 116 | Iterable[Entity]: Iterable of translated IP address and Prefixes entities. 117 | 118 | """ 119 | ip_entities = [] 120 | 121 | for if_ip_name, ip_info in interfaces_ip.items(): 122 | if interface.name in if_ip_name: 123 | for ip_version, default_prefix in (("ipv4", 32), ("ipv6", 128)): 124 | for ip, details in ip_info.get(ip_version, {}).items(): 125 | ip_address = f"{ip}/{details.get('prefix_length', default_prefix)}" 126 | network = ipaddress.ip_network(ip_address, strict=False) 127 | ip_entities.append( 128 | Entity( 129 | prefix=Prefix( 130 | prefix=str(network), site=interface.device.site 131 | ) 132 | ) 133 | ) 134 | ip_entities.append( 135 | Entity( 136 | ip_address=IPAddress( 137 | address=ip_address, interface=interface 138 | ) 139 | ) 140 | ) 141 | 142 | return ip_entities 143 | 144 | 145 | def translate_data(data: dict) -> Iterable[Entity]: 146 | """ 147 | Translate data from NAPALM format to Diode SDK entities. 148 | 149 | Args: 150 | ---- 151 | data (dict): Dictionary containing data to be translated. 152 | 153 | Returns: 154 | ------- 155 | Iterable[Entity]: Iterable of translated entities. 156 | 157 | """ 158 | entities = [] 159 | 160 | device_info = data.get("device", {}) 161 | interfaces = data.get("interface", {}) 162 | interfaces_ip = data.get("interface_ip", {}) 163 | if device_info: 164 | device_info["driver"] = data.get("driver") 165 | device_info["site"] = data.get("site") 166 | device = translate_device(device_info) 167 | entities.append(Entity(device=device)) 168 | 169 | interface_list = device_info.get("interface_list", []) 170 | for if_name, interface_info in interfaces.items(): 171 | if if_name in interface_list: 172 | interface = translate_interface(device, if_name, interface_info) 173 | entities.append(Entity(interface=interface)) 174 | entities.extend(translate_interface_ips(interface, interfaces_ip)) 175 | 176 | return entities 177 | -------------------------------------------------------------------------------- /diode-napalm-agent/diode_napalm/cli/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """Diode NAPALM Agent CLI.""" 4 | 5 | import argparse 6 | import logging 7 | import sys 8 | from concurrent.futures import ThreadPoolExecutor, as_completed 9 | from importlib.metadata import version 10 | 11 | import netboxlabs.diode.sdk.version as SdkVersion 12 | from dotenv import load_dotenv 13 | from napalm import get_network_driver 14 | 15 | from diode_napalm.client import Client 16 | from diode_napalm.discovery import discover_device_driver, supported_drivers 17 | from diode_napalm.parser import ( 18 | Diode, 19 | DiscoveryConfig, 20 | Napalm, 21 | Policy, 22 | parse_config_file, 23 | ) 24 | from diode_napalm.version import version_semver 25 | 26 | # Set up logging 27 | logging.basicConfig(level=logging.INFO) 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | def run_driver(info: Napalm, config: DiscoveryConfig): 32 | """ 33 | Run the device driver code for a single info item. 34 | 35 | Args: 36 | ---- 37 | info: Information data for the device. 38 | config: Configuration data containing site information. 39 | 40 | """ 41 | if info.driver is None: 42 | logger.info(f"Hostname {info.hostname}: Driver not informed, discovering it") 43 | info.driver = discover_device_driver(info) 44 | if not info.driver: 45 | raise Exception( 46 | f"Hostname {info.hostname}: Not able to discover device driver" 47 | ) 48 | elif info.driver not in supported_drivers: 49 | try: 50 | np_driver = get_network_driver(info.driver) 51 | except Exception: 52 | raise Exception( 53 | f"Hostname {info.hostname}: specified driver '{info.driver}' was not found in the current installed drivers list: " 54 | f"{supported_drivers}.\nHINT: If '{info.driver}' is a napalm community driver, try to perform the following command:" 55 | f"\n\n\tpip install napalm-{info.driver.replace('_', '-')}\n" 56 | ) 57 | 58 | logger.info(f"Hostname {info.hostname}: Get driver '{info.driver}'") 59 | np_driver = get_network_driver(info.driver) 60 | logger.info(f"Hostname {info.hostname}: Getting information") 61 | with np_driver( 62 | info.hostname, info.username, info.password, info.timeout, info.optional_args 63 | ) as device: 64 | data = { 65 | "driver": info.driver, 66 | "site": config.netbox.get("site", None), 67 | "device": device.get_facts(), 68 | "interface": device.get_interfaces(), 69 | "interface_ip": device.get_interfaces_ip(), 70 | } 71 | Client().ingest(info.hostname, data) 72 | 73 | 74 | def start_policy(name: str, cfg: Policy, max_workers: int): 75 | """ 76 | Start the policy for the given configuration. 77 | 78 | Args: 79 | ---- 80 | name: Policy name 81 | cfg: Configuration data for the policy. 82 | max_workers: Maximum number of threads in the pool. 83 | 84 | """ 85 | with ThreadPoolExecutor(max_workers=max_workers) as executor: 86 | futures = [executor.submit(run_driver, info, cfg.config) for info in cfg.data] 87 | 88 | for future in as_completed(futures): 89 | try: 90 | future.result() 91 | except Exception as e: 92 | logger.error(f"Error while processing policy {name}: {e}") 93 | 94 | 95 | def start_agent(cfg: Diode, workers: int): 96 | """ 97 | Start the diode client and execute policies. 98 | 99 | Args: 100 | ---- 101 | cfg: Configuration data containing policies. 102 | workers: Number of workers to be used in the thread pool. 103 | 104 | """ 105 | client = Client() 106 | client.init_client(target=cfg.config.target, api_key=cfg.config.api_key) 107 | for policy_name in cfg.policies: 108 | start_policy(policy_name, cfg.policies.get(policy_name), workers) 109 | 110 | 111 | def main(): 112 | """ 113 | Main entry point for the Diode NAPALM Agent CLI. 114 | 115 | Parses command-line arguments and starts the agent. 116 | """ 117 | parser = argparse.ArgumentParser(description="Diode Agent for NAPALM") 118 | parser.add_argument( 119 | "-V", 120 | "--version", 121 | action="version", 122 | version=f"Diode Agent version: {version_semver()}, NAPALM version: {version('napalm')}, " 123 | f"Diode SDK version: {SdkVersion.version_semver()}", 124 | help="Display Diode Agent, NAPALM and Diode SDK versions", 125 | ) 126 | parser.add_argument( 127 | "-c", 128 | "--config", 129 | metavar="config.yaml", 130 | help="Agent yaml configuration file", 131 | type=str, 132 | required=True, 133 | ) 134 | parser.add_argument( 135 | "-e", 136 | "--env", 137 | metavar=".env", 138 | help="File containing environment variables", 139 | type=str, 140 | ) 141 | parser.add_argument( 142 | "-w", 143 | "--workers", 144 | metavar="N", 145 | help="Number of workers to be used", 146 | type=int, 147 | default=2, 148 | ) 149 | args = parser.parse_args() 150 | 151 | if hasattr(args, "env") and args.env is not None: 152 | if not load_dotenv(args.env, override=True): 153 | sys.exit( 154 | f"ERROR: Unable to load environment variables from file {args.env}" 155 | ) 156 | 157 | try: 158 | config = parse_config_file(args.config) 159 | start_agent(config, args.workers) 160 | except (KeyboardInterrupt, RuntimeError): 161 | pass 162 | except Exception as e: 163 | sys.exit(f"ERROR: Unable to start agent: {e}") 164 | 165 | 166 | if __name__ == "__main__": 167 | main() 168 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /diode-napalm-agent/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /diode-napalm-agent/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """NetBox Labs - CLI Unit Tests.""" 4 | 5 | import sys 6 | from unittest.mock import MagicMock, patch 7 | 8 | import pytest 9 | 10 | from diode_napalm.cli.cli import main, run_driver, start_agent, start_policy 11 | from diode_napalm.parser import DiscoveryConfig, Napalm, Policy 12 | 13 | 14 | @pytest.fixture 15 | def mock_parse_args(): 16 | """ 17 | Fixture to mock argparse.ArgumentParser.parse_args. 18 | 19 | Mocks the parse_args method to control CLI arguments. 20 | """ 21 | with patch("diode_napalm.cli.cli.argparse.ArgumentParser.parse_args") as mock: 22 | yield mock 23 | 24 | 25 | @pytest.fixture 26 | def mock_load_dotenv(): 27 | """ 28 | Fixture to mock the load_dotenv function from dotenv. 29 | 30 | Mocks the load_dotenv method to simulate loading environment variables. 31 | """ 32 | with patch("diode_napalm.cli.cli.load_dotenv") as mock: 33 | yield mock 34 | 35 | 36 | @pytest.fixture 37 | def mock_parse_config_file(): 38 | """ 39 | Fixture to mock the parse_config_file function. 40 | 41 | Mocks the parse_config_file method to simulate loading a configuration file. 42 | """ 43 | with patch("diode_napalm.cli.cli.parse_config_file") as mock: 44 | yield mock 45 | 46 | 47 | @pytest.fixture 48 | def mock_start_agent(): 49 | """ 50 | Fixture to mock the start_agent function. 51 | 52 | Mocks the start_agent method to control its behavior during tests. 53 | """ 54 | with patch("diode_napalm.cli.cli.start_agent") as mock: 55 | yield mock 56 | 57 | 58 | @pytest.fixture 59 | def mock_start_policy(): 60 | """ 61 | Fixture to mock the start_policy function. 62 | 63 | Mocks the start_policy method to control its behavior during tests. 64 | """ 65 | with patch("diode_napalm.cli.cli.start_policy") as mock: 66 | yield mock 67 | 68 | 69 | @pytest.fixture 70 | def mock_client(): 71 | """ 72 | Fixture to mock the Client class. 73 | 74 | Mocks the Client class to control its behavior during tests. 75 | """ 76 | with patch("diode_napalm.cli.cli.Client") as mock: 77 | yield mock 78 | 79 | 80 | @pytest.fixture 81 | def mock_get_network_driver(): 82 | """ 83 | Fixture to mock the get_network_driver function. 84 | 85 | Mocks the get_network_driver function to control its behavior during tests. 86 | """ 87 | with patch("diode_napalm.cli.cli.get_network_driver") as mock: 88 | yield mock 89 | 90 | 91 | @pytest.fixture 92 | def mock_discover_device_driver(): 93 | """ 94 | Fixture to mock the discover_device_driver function. 95 | 96 | Mocks the discover_device_driver function to control its behavior during tests. 97 | """ 98 | with patch("diode_napalm.cli.cli.discover_device_driver") as mock: 99 | yield mock 100 | 101 | 102 | @pytest.fixture 103 | def mock_thread_pool_executor(): 104 | """ 105 | Fixture to mock the ThreadPoolExecutor class. 106 | 107 | Mocks the ThreadPoolExecutor class to control its behavior during tests. 108 | """ 109 | with patch("diode_napalm.cli.cli.ThreadPoolExecutor") as mock: 110 | yield mock 111 | 112 | 113 | @pytest.fixture 114 | def mock_as_completed(): 115 | """ 116 | Fixture to mock the as_completed function. 117 | 118 | Mocks the as_completed function to control its behavior during tests. 119 | """ 120 | with patch("diode_napalm.cli.cli.as_completed") as mock: 121 | yield mock 122 | 123 | 124 | def test_main_keyboard_interrupt(mock_parse_args, mock_parse_config_file): 125 | """ 126 | Test handling of KeyboardInterrupt in main. 127 | 128 | Args: 129 | ---- 130 | mock_parse_args: Mocked parse_args function. 131 | mock_parse_config_file: Mocked parse_config_file function. 132 | 133 | """ 134 | mock_parse_args.return_value = MagicMock(config="config.yaml", env=None, workers=2) 135 | mock_parse_config_file.side_effect = KeyboardInterrupt 136 | 137 | with patch.object(sys, "exit", side_effect=Exception("Test Exit")): 138 | try: 139 | main() 140 | except Exception as e: 141 | assert str(e) == "Test Exit" 142 | 143 | 144 | def test_main_with_config_and_env( 145 | mock_parse_args, mock_load_dotenv, mock_parse_config_file, mock_start_agent 146 | ): 147 | """Test running the CLI with a configuration file and environment file.""" 148 | mock_parse_args.return_value = MagicMock( 149 | config="config.yaml", env=".env", workers=2 150 | ) 151 | mock_load_dotenv.return_value = True 152 | mock_parse_config_file.return_value = MagicMock() 153 | 154 | with patch.object(sys, "exit", side_effect=Exception("Test Exit")): 155 | try: 156 | main() 157 | except Exception as e: 158 | assert str(e) == "Test Exit" 159 | 160 | mock_load_dotenv.assert_called_once_with(".env", override=True) 161 | mock_parse_config_file.assert_called_once_with("config.yaml") 162 | mock_start_agent.assert_called_once() 163 | 164 | 165 | def test_main_with_config_no_env( 166 | mock_parse_args, mock_load_dotenv, mock_parse_config_file, mock_start_agent 167 | ): 168 | """Test running the CLI with a configuration file and no environment file.""" 169 | mock_parse_args.return_value = MagicMock(config="config.yaml", env=None, workers=2) 170 | mock_parse_config_file.return_value = MagicMock() 171 | 172 | with patch.object(sys, "exit", side_effect=Exception("Test Exit")): 173 | try: 174 | main() 175 | except Exception as e: 176 | assert str(e) == "Test Exit" 177 | 178 | mock_load_dotenv.assert_not_called() 179 | mock_parse_config_file.assert_called_once_with("config.yaml") 180 | mock_start_agent.assert_called_once() 181 | 182 | 183 | def test_main_load_dotenv_failure(mock_parse_args, mock_load_dotenv): 184 | """Test CLI failure when loading environment variables fails.""" 185 | mock_parse_args.return_value = MagicMock( 186 | config="config.yaml", env=".env", workers=2 187 | ) 188 | mock_load_dotenv.return_value = False 189 | 190 | with patch.object(sys, "exit", side_effect=Exception("Test Exit")) as mock_exit: 191 | try: 192 | main() 193 | except Exception as e: 194 | assert str(e) == "Test Exit" 195 | 196 | mock_load_dotenv.assert_called_once_with(".env", override=True) 197 | mock_exit.assert_called_once_with( 198 | "ERROR: Unable to load environment variables from file .env" 199 | ) 200 | 201 | 202 | def test_main_start_agent_failure( 203 | mock_parse_args, mock_parse_config_file, mock_start_agent 204 | ): 205 | """Test CLI failure when starting the agent.""" 206 | mock_parse_args.return_value = MagicMock(config="config.yaml", env=None, workers=2) 207 | mock_parse_config_file.return_value = MagicMock() 208 | mock_start_agent.side_effect = Exception("Test Start Agent Failure") 209 | 210 | with patch.object(sys, "exit", side_effect=Exception("Test Exit")) as mock_exit: 211 | try: 212 | main() 213 | except Exception as e: 214 | assert str(e) == "Test Exit" 215 | 216 | mock_parse_config_file.assert_called_once_with("config.yaml") 217 | mock_start_agent.assert_called_once() 218 | mock_exit.assert_called_once_with( 219 | "ERROR: Unable to start agent: Test Start Agent Failure" 220 | ) 221 | 222 | 223 | def test_main_no_config_file(mock_parse_args): 224 | """ 225 | Test running the CLI without a configuration file. 226 | 227 | Args: 228 | ---- 229 | mock_parse_args: Mocked parse_args function. 230 | 231 | """ 232 | mock_parse_args.return_value = MagicMock(config=None, env=None, workers=2) 233 | 234 | with patch.object(sys, "exit", side_effect=Exception("Test Exit")) as mock_exit: 235 | try: 236 | main() 237 | except Exception as e: 238 | print(f"Caught exception: {str(e)}") # Debug statement 239 | assert str(e) == "Test Exit" 240 | 241 | mock_exit.assert_called_once() 242 | 243 | 244 | def test_main_missing_policy(mock_parse_args, mock_parse_config_file): 245 | """ 246 | Test handling of missing policy in start_agent. 247 | 248 | Args: 249 | ---- 250 | mock_parse_args: Mocked parse_args function. 251 | mock_parse_config_file: Mocked parse_config_file function. 252 | 253 | """ 254 | mock_parse_args.return_value = MagicMock(config="config.yaml", env=None, workers=2) 255 | mock_cfg = MagicMock() 256 | mock_cfg.policies = {"policy1": None} # Simulating a missing policy 257 | mock_parse_config_file.return_value = mock_cfg 258 | 259 | with patch.object(sys, "exit", side_effect=Exception("Test Exit")): 260 | try: 261 | main() 262 | except Exception as e: 263 | assert str(e) == "Test Exit" 264 | 265 | 266 | def test_main_load_dotenv_exception(mock_parse_args): 267 | """ 268 | Test CLI failure when an exception occurs while loading environment variables. 269 | 270 | Args: 271 | ---- 272 | mock_parse_args: Mocked parse_args function. 273 | 274 | """ 275 | mock_parse_args.return_value = MagicMock( 276 | config="config.yaml", env=".env", workers=2 277 | ) 278 | 279 | with patch("dotenv.load_dotenv", side_effect=Exception("Load dotenv error")): 280 | with patch.object(sys, "exit", side_effect=Exception("Test Exit")) as mock_exit: 281 | try: 282 | main() 283 | except Exception as e: 284 | assert str(e) == "Test Exit" 285 | 286 | mock_exit.assert_called_once_with( 287 | "ERROR: Unable to load environment variables from file .env" 288 | ) 289 | 290 | 291 | def test_run_driver_exception(mock_discover_device_driver): 292 | """ 293 | Test run_driver function when the device driver is not discovered. 294 | 295 | Args: 296 | ---- 297 | mock_discover_device_driver: Mocked discover_device_driver function. 298 | 299 | """ 300 | info = Napalm( 301 | driver=None, 302 | hostname="test_host", 303 | username="user", 304 | password="pass", 305 | timeout=10, 306 | optional_args={}, 307 | ) 308 | config = DiscoveryConfig(netbox={"site": "test_site"}) 309 | 310 | mock_discover_device_driver.return_value = None 311 | 312 | with pytest.raises(Exception) as excinfo: 313 | run_driver(info, config) 314 | 315 | assert ( 316 | str(excinfo.value) 317 | == f"Hostname {info.hostname}: Not able to discover device driver" 318 | ) 319 | 320 | 321 | def test_run_driver_no_driver( 322 | mock_client, mock_get_network_driver, mock_discover_device_driver 323 | ): 324 | """ 325 | Test run_driver function when driver is not provided. 326 | 327 | Args: 328 | ---- 329 | mock_client: Mocked Client class. 330 | mock_get_network_driver: Mocked get_network_driver function. 331 | mock_discover_device_driver: Mocked discover_device_driver function. 332 | 333 | """ 334 | info = Napalm( 335 | driver=None, 336 | hostname="test_host", 337 | username="user", 338 | password="pass", 339 | timeout=10, 340 | optional_args={}, 341 | ) 342 | config = DiscoveryConfig(netbox={"site": "test_site"}) 343 | 344 | mock_discover_device_driver.return_value = "test_driver" 345 | mock_np_driver = MagicMock() 346 | mock_get_network_driver.return_value = mock_np_driver 347 | 348 | run_driver(info, config) 349 | 350 | mock_discover_device_driver.assert_called_once_with(info) 351 | mock_get_network_driver.assert_called_once_with("test_driver") 352 | mock_np_driver.assert_called_once_with("test_host", "user", "pass", 10, {}) 353 | mock_client().ingest.assert_called_once() 354 | 355 | 356 | def test_run_driver_with_not_intalled_driver( 357 | mock_get_network_driver, mock_discover_device_driver 358 | ): 359 | """ 360 | Test run_driver function when driver is provided but not installed. 361 | 362 | Args: 363 | ---- 364 | mock_get_network_driver: Mocked get_network_driver function. 365 | mock_discover_device_driver: Mocked discover_device_driver function. 366 | 367 | """ 368 | info = Napalm( 369 | driver="not_installed", 370 | hostname="test_host", 371 | username="user", 372 | password="pass", 373 | timeout=10, 374 | optional_args={}, 375 | ) 376 | config = DiscoveryConfig(netbox={"site": "test_site"}) 377 | 378 | mock_get_network_driver.side_effect = Exception("Driver not found") 379 | 380 | with pytest.raises(Exception) as excinfo: 381 | run_driver(info, config) 382 | 383 | mock_discover_device_driver.assert_not_called() 384 | mock_get_network_driver.assert_called_once() 385 | 386 | assert str(excinfo.value).startswith( 387 | f"Hostname {info.hostname}: specified driver '{info.driver}' was not found in the current installed drivers list:" 388 | ) 389 | 390 | 391 | def test_run_driver_with_driver( 392 | mock_client, mock_get_network_driver, mock_discover_device_driver 393 | ): 394 | """ 395 | Test run_driver function when driver is already provided. 396 | 397 | Args: 398 | ---- 399 | mock_client: Mocked Client class. 400 | mock_get_network_driver: Mocked get_network_driver function. 401 | mock_discover_device_driver: Mocked discover_device_driver function. 402 | 403 | """ 404 | info = Napalm( 405 | driver="ios", 406 | hostname="test_host", 407 | username="user", 408 | password="pass", 409 | timeout=10, 410 | optional_args={}, 411 | ) 412 | config = DiscoveryConfig(netbox={"site": "test_site"}) 413 | 414 | mock_np_driver = MagicMock() 415 | mock_get_network_driver.return_value = mock_np_driver 416 | 417 | run_driver(info, config) 418 | 419 | mock_discover_device_driver.assert_not_called() 420 | mock_get_network_driver.assert_called_once_with("ios") 421 | mock_np_driver.assert_called_once_with("test_host", "user", "pass", 10, {}) 422 | mock_client().ingest.assert_called_once() 423 | 424 | 425 | def test_start_agent(mock_client, mock_start_policy): 426 | """ 427 | Test the start_agent function to ensure it initializes the client and starts policies. 428 | 429 | Args: 430 | ---- 431 | mock_client: Mocked Client class. 432 | mock_start_policy: Mocked start_policy function. 433 | 434 | """ 435 | # Mock the configuration data 436 | cfg = MagicMock() 437 | cfg.config.target = "http://example.com" 438 | cfg.config.api_key = "dummy_api_key" 439 | cfg.policies = {"policy1": MagicMock(), "policy2": MagicMock()} 440 | 441 | workers = 3 442 | 443 | # Call the start_agent function 444 | start_agent(cfg, workers) 445 | 446 | # Verify that the client was initialized correctly 447 | mock_client().init_client.assert_called_once_with( 448 | target="http://example.com", api_key="dummy_api_key" 449 | ) 450 | 451 | # Verify that start_policy was called for each policy 452 | mock_start_policy.assert_any_call("policy1", cfg.policies["policy1"], workers) 453 | mock_start_policy.assert_any_call("policy2", cfg.policies["policy2"], workers) 454 | assert mock_start_policy.call_count == 2 455 | 456 | 457 | def test_start_policy(mock_thread_pool_executor, mock_as_completed): 458 | """ 459 | Test start_policy function with different configurations. 460 | 461 | Args: 462 | ---- 463 | mock_thread_pool_executor: Mocked ThreadPoolExecutor class. 464 | mock_as_completed: Mocked as_completed funtion. 465 | 466 | """ 467 | cfg = Policy( 468 | config=DiscoveryConfig(netbox={"site": "test_site"}), 469 | data=[ 470 | Napalm( 471 | driver="driver", 472 | hostname="host", 473 | username="user", 474 | password="pass", 475 | timeout=10, 476 | optional_args={}, 477 | ) 478 | ], 479 | ) 480 | max_workers = 2 481 | 482 | mock_future = MagicMock() 483 | mock_future.result.return_value = None 484 | mock_executor = MagicMock() 485 | mock_executor.submit.return_value = mock_future 486 | mock_thread_pool_executor.return_value = mock_executor 487 | mock_as_completed.return_value = [mock_future] 488 | 489 | start_policy("policy", cfg, max_workers) 490 | 491 | mock_thread_pool_executor.assert_called_once_with(max_workers=2) 492 | mock_future.result.assert_called_once() 493 | 494 | 495 | def test_start_policy_exception(mock_thread_pool_executor, mock_as_completed): 496 | """ 497 | Test start_policy function with different configurations. 498 | 499 | Args: 500 | ---- 501 | mock_thread_pool_executor: Mocked ThreadPoolExecutor class. 502 | mock_as_completed: Mocked as_completed funtion. 503 | 504 | """ 505 | cfg = Policy( 506 | config=DiscoveryConfig(netbox={"site": "test_site"}), 507 | data=[ 508 | Napalm( 509 | driver="driver", 510 | hostname="host", 511 | username="user", 512 | password="pass", 513 | timeout=10, 514 | optional_args={}, 515 | ) 516 | ], 517 | ) 518 | max_workers = 2 519 | 520 | mock_future = MagicMock() 521 | mock_future.result.side_effect = Exception("Test exception") 522 | mock_executor = MagicMock() 523 | mock_executor.submit.return_value = mock_future 524 | mock_thread_pool_executor.return_value = mock_executor 525 | mock_as_completed.return_value = [mock_future] 526 | 527 | start_policy("policy", cfg, max_workers) 528 | 529 | mock_thread_pool_executor.assert_called_once_with(max_workers=2) 530 | mock_future.result.assert_called_once() 531 | --------------------------------------------------------------------------------