# Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Test anta._runner.py.""" from __future__ import annotations import logging import os from collections import defaultdict from pathlib import Path from typing import ClassVar import pytest import respx from pydantic import ValidationError from anta._runner import AntaRunContext, AntaRunFilters, AntaRunner from anta.catalog import AntaCatalog, AntaTestDefinition from anta.inventory import AntaInventory from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.result_manager import ResultManager from anta.result_manager.models import TestResult as AntaTestResult from anta.settings import DEFAULT_MAX_CONCURRENCY, DEFAULT_NOFILE, AntaRunnerSettings from anta.tests.routing.generic import VerifyRoutingTableEntry DATA_DIR: Path = Path(__file__).parent.parent.resolve() / "data" class TestAntaRunner: """Test AntaRunner class.""" def test_init_with_default_settings(self, caplog: pytest.LogCaptureFixture) -> None: """Test initialization with default settings.""" caplog.set_level(logging.DEBUG) default_settings = {"nofile": DEFAULT_NOFILE, "max_concurrency": DEFAULT_MAX_CONCURRENCY} runner = AntaRunner() assert f"AntaRunner initialized with settings: {default_settings}" in caplog.messages assert runner._settings def test_init_with_custom_env_settings(self, caplog: pytest.LogCaptureFixture, setenvvar: pytest.MonkeyPatch) -> None: """Test initialization with custom env settings.""" caplog.set_level(logging.DEBUG) desired_settings = {"nofile": 1048576, "max_concurrency": 10000} setenvvar.setenv("ANTA_NOFILE", str(desired_settings["nofile"])) setenvvar.setenv("ANTA_MAX_CONCURRENCY", str(desired_settings["max_concurrency"])) runner = AntaRunner() assert f"AntaRunner initialized with settings: {desired_settings}" in caplog.messages assert runner._settings def test_init_with_provided_settings(self, caplog: pytest.LogCaptureFixture) -> None: """Test initialization with provided settings.""" caplog.set_level(logging.DEBUG) desired_settings = AntaRunnerSettings(nofile=1048576, max_concurrency=10000) runner = AntaRunner(settings=desired_settings) assert f"AntaRunner initialized with settings: {desired_settings.model_dump()}" in caplog.messages assert runner._settings async def test_dry_run(self, caplog: pytest.LogCaptureFixture) -> None: """Test AntaRunner.run() in dry-run.""" caplog.set_level(logging.INFO) inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta") catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml") runner = AntaRunner() ctx = await runner.run(inventory, catalog, dry_run=True) # Validate the final context attributes assert ctx.selected_inventory == ctx.inventory == inventory assert len(ctx.manager) > 0 assert ctx.manager.status == "unset" assert ctx.total_tests_scheduled > 0 assert ctx.total_devices_filtered_by_tags == 0 assert ctx.total_devices_unreachable == 0 assert ctx.total_devices_selected_for_testing == ctx.total_devices_in_inventory == len(inventory) assert ctx.duration is not None assert "Dry-run mode, exiting before running the tests." in caplog.messages @pytest.mark.parametrize( ("filters", "expected_devices", "expected_tests"), [ pytest.param( AntaRunFilters(devices=None, tests=None, tags=None), 3, 27, id="all-tests", ), pytest.param( AntaRunFilters(devices=None, tests=None, tags={"leaf"}), 2, 6, id="1-tag", ), pytest.param( AntaRunFilters(devices=None, tests=None, tags={"leaf", "spine"}), 3, 9, id="2-tags", ), pytest.param( AntaRunFilters(devices=None, tests={"VerifyMlagStatus", "VerifyUptime"}, tags=None), 3, 5, id="filtered-tests", ), pytest.param( AntaRunFilters(devices=None, tests={"VerifyMlagStatus", "VerifyUptime"}, tags={"leaf"}), 2, 4, id="1-tag-filtered-tests", ), pytest.param( AntaRunFilters(devices={"leaf1"}, tests=None, tags=None), 1, 9, id="filtered-devices", ), pytest.param( AntaRunFilters(devices=None, tests=None, tags={"invalid"}), 0, 0, id="invalid-tag", ), pytest.param( AntaRunFilters(devices={"invalid"}, tests=None, tags=None), 0, 0, id="invalid-device", ), pytest.param( AntaRunFilters(devices=None, tests={"invalid"}, tags=None), 3, 0, id="invalid-test", ), pytest.param( AntaRunFilters(devices=None, tests=None, tags={"dc1"}), 1, 0, id="device-tag-no-tests", ), ], ) async def test_run_filters(self, caplog: pytest.LogCaptureFixture, filters: AntaRunFilters, expected_devices: int, expected_tests: int) -> None: """Test AntaRunner.run() with different filters.""" caplog.set_level(logging.WARNING) inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta") catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml") runner = AntaRunner() ctx = await runner.run(inventory, catalog, filters=filters, dry_run=True) # Gather the warning message warning_msg = None if expected_devices == 0: msg = "The inventory is empty after filtering by tags/devices. " if filters.devices: msg += f"Devices filter: {', '.join(sorted(filters.devices))}. " if filters.tags: msg += f"Tags filter: {', '.join(sorted(filters.tags))}. " msg += "Exiting ..." elif expected_tests == 0: msg = "No tests scheduled to run after filtering by tags/tests. " if filters.tests: msg += f"Tests filter: {', '.join(sorted(filters.tests))}. " if filters.tags: msg += f"Tags filter: {', '.join(sorted(filters.tags))}. " msg += "Exiting ..." if warning_msg is not None: assert msg in ctx.warnings_at_setup assert msg in caplog.messages assert ctx.total_tests_scheduled == expected_tests assert ctx.total_devices_selected_for_testing == expected_devices async def test_run_invalid_filters(self) -> None: """Test AntaRunner.run() with invalid filters.""" inventory = AntaInventory() catalog = AntaCatalog() runner = AntaRunner() with pytest.raises(ValidationError, match="1 validation error for AntaRunFilters"): await runner.run(inventory, catalog, filters=AntaRunFilters(devices="invalid"), dry_run=True) # type: ignore[arg-type] async def test_run_provided_manager(self) -> None: """Test AntaRunner.run() with a provided ResultManager instance.""" inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta") catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml") manager = ResultManager() runner = AntaRunner() ctx = await runner.run(inventory, catalog, manager, dry_run=True) assert isinstance(ctx.manager, ResultManager) assert ctx.manager is manager assert len(manager) == 27 async def test_run_provided_manager_not_empty(self, caplog: pytest.LogCaptureFixture) -> None: """Test AntaRunner.run() with a provided non-empty ResultManager instance.""" caplog.set_level(logging.WARNING) inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta") catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml") manager = ResultManager() test = AntaTestResult(name="DC1-LEAF1A", test="VerifyNTP", categories=["system"], description="NTP Test") runner = AntaRunner() manager.add(test) ctx = await runner.run(inventory, catalog, manager, dry_run=True) assert isinstance(ctx.manager, ResultManager) assert ctx.manager is manager assert len(manager) == 28 assert len(manager.device_stats) == ctx.total_devices_selected_for_testing + 1 warning_msg = ( "Appending new results to the provided ResultManager which already holds 1 results. Statistics in this run context are for the current execution only." ) assert warning_msg in ctx.warnings_at_setup assert warning_msg in caplog.messages async def test_run_empty_catalog(self, caplog: pytest.LogCaptureFixture) -> None: """Test AntaRunner.run() with an empty AntaCatalog.""" caplog.set_level(logging.WARNING) inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta") catalog = AntaCatalog() runner = AntaRunner() ctx = await runner.run(inventory, catalog) warning_msg = "The list of tests is empty. Exiting ..." assert warning_msg in ctx.warnings_at_setup assert warning_msg in caplog.messages async def test_run_empty_inventory(self, caplog: pytest.LogCaptureFixture) -> None: """Test AntaRunner.run() with an empty AntaInventory.""" caplog.set_level(logging.WARNING) inventory = AntaInventory() catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml") runner = AntaRunner() ctx = await runner.run(inventory, catalog) warning_msg = "The initial inventory is empty. Exiting ..." assert warning_msg in ctx.warnings_at_setup assert warning_msg in caplog.messages @pytest.mark.parametrize("inventory", [{"reachable": False}], indirect=True) async def test_run_no_reachable_devices(self, caplog: pytest.LogCaptureFixture, inventory: AntaInventory) -> None: """Test AntaRunner.run() with an empty AntaInventory.""" caplog.set_level(logging.WARNING) catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml") runner = AntaRunner() ctx = await runner.run(inventory, catalog) assert ctx.total_devices_unreachable == ctx.total_devices_in_inventory assert "device-0" in ctx.devices_unreachable_at_setup warning_msg = "No reachable devices found for testing after connectivity checks. Exiting ..." assert warning_msg in ctx.warnings_at_setup assert warning_msg in caplog.messages async def test_run_invalid_anta_test(self, caplog: pytest.LogCaptureFixture) -> None: """Test AntaRunner.run() with a provided non-empty ResultManager instance.""" caplog.set_level(logging.CRITICAL) class InvalidTest(AntaTest): """ANTA test that raises an exception when test is called.""" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] def test(self) -> None: # type: ignore[override] """Test function.""" msg = "Test not implemented" raise NotImplementedError(msg) inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta") test_definition = AntaTestDefinition(test=InvalidTest, inputs=None) catalog = AntaCatalog(tests=[test_definition]) runner = AntaRunner() ctx = await runner.run(inventory, catalog, dry_run=True) assert len(ctx.manager) == 0 error_msg = ( f"There is an error when creating test {__name__}.InvalidTest.\n" "If this is not a custom test implementation: " "Please reach out to the maintainer team or open an issue on Github: https://github.com/aristanetworks/anta.\n" "NotImplementedError: Test not implemented" ) assert error_msg in caplog.messages async def test_log_run_information_default(self, caplog: pytest.LogCaptureFixture) -> None: """Test AntaRunner._log_run_information with default settings.""" caplog.set_level(logging.INFO) inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta") catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml") runner = AntaRunner() await runner.run(inventory, catalog, dry_run=True) expected_output = [ "ANTA NRFU Dry Run Information", "Devices:", " Total in initial inventory: 3", " Selected for testing: 3", "Total number of selected tests: 27", ] for line in expected_output: assert line in caplog.text async def test_log_run_information_concurrency_limit(self, caplog: pytest.LogCaptureFixture) -> None: """Test AntaRunner._log_run_information with higher tests count than concurrency limit.""" caplog.set_level(logging.WARNING) inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta") catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml") runner_settings = AntaRunnerSettings(max_concurrency=20) runner = AntaRunner(settings=runner_settings) ctx = await runner.run(inventory, catalog, dry_run=True) warning_msg = "Tests count (27) exceeds concurrent limit (20). Tests will be throttled. Please consult the ANTA FAQ." assert warning_msg in ctx.warnings_at_setup assert warning_msg in caplog.messages @pytest.mark.skipif(os.name != "posix", reason="Very unlikely to happen on non-POSIX systems due to sys.maxsize") async def test_log_run_information_file_descriptor_limit(self, caplog: pytest.LogCaptureFixture) -> None: """Test AntaRunner._log_run_information with higher connections count than file descriptor limit.""" caplog.set_level(logging.WARNING) inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta") catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml") runner_settings = AntaRunnerSettings(nofile=128) runner = AntaRunner(settings=runner_settings) ctx = await runner.run(inventory, catalog, dry_run=True) warning_msg = "Potential connections (300) exceeds file descriptor limit (128). Connection errors may occur. Please consult the ANTA FAQ." assert warning_msg in ctx.warnings_at_setup assert warning_msg in caplog.messages @pytest.mark.parametrize(("inventory"), [{"count": 3}], indirect=True) @respx.mock async def test_run(self, inventory: AntaInventory) -> None: """Test AntaRunner.run().""" # Mock the eAPI requests respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}, json__params__cmds__0__cmd="show ip route vrf default").respond( json={"result": [{"vrfs": {"default": {"routes": {}}}}]} ) tests = [AntaTestDefinition(test=VerifyRoutingTableEntry, inputs={"routes": [f"10.1.0.{i}"], "collect": "all"}) for i in range(5)] catalog = AntaCatalog(tests=tests) runner = AntaRunner() ctx = await runner.run(inventory, catalog) assert ctx.total_devices_selected_for_testing == 3 assert ctx.total_tests_scheduled == 15 assert len(ctx.warnings_at_setup) == 0 assert len(ctx.manager) == 15 for result in ctx.manager.results: assert result.result == "failure" # pylint: disable=too-few-public-methods class TestAntaRunContext: """Test AntaRunContext class.""" def test_init(self) -> None: """Test initialization.""" inventory = AntaInventory() catalog = AntaCatalog() manager = ResultManager() filters = AntaRunFilters() ctx = AntaRunContext(inventory, catalog, manager, filters) # Test initialized attributes assert ctx.inventory is inventory assert ctx.catalog is catalog assert ctx.manager is manager assert ctx.filters is filters assert not ctx.dry_run assert isinstance(ctx.selected_inventory, AntaInventory) assert len(ctx.selected_inventory) == 0 assert isinstance(ctx.selected_tests, defaultdict) assert len(ctx.selected_tests) == 0 assert isinstance(ctx.devices_filtered_at_setup, list) assert len(ctx.devices_filtered_at_setup) == 0 assert isinstance(ctx.devices_unreachable_at_setup, list) assert len(ctx.devices_unreachable_at_setup) == 0 assert isinstance(ctx.warnings_at_setup, list) assert len(ctx.warnings_at_setup) == 0 assert ctx.start_time is None assert ctx.end_time is None # Test properties assert ctx.total_devices_in_inventory == 0 assert ctx.total_devices_filtered_by_tags == 0 assert ctx.total_devices_unreachable == 0 assert ctx.total_devices_selected_for_testing == 0 assert ctx.total_tests_scheduled == 0 assert ctx.duration is None