From 37b780208e0ae0f8e754d6e1023de434ca12de39 Mon Sep 17 00:00:00 2001 From: Jeff Qiu Date: Fri, 14 Jun 2024 01:45:58 -0400 Subject: [PATCH 01/90] ci: Update secret-scanner to use public action (#719) * Update secret-scanner.yml --- .github/workflows/secret-scanner.yml | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 821095300..80a0fe752 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -10,21 +10,6 @@ jobs: scan_secret: name: Scan incoming changes runs-on: ubuntu-latest - container: - image: ghcr.io/aristanetworks/secret-scanner-service:main - options: --name sss-scanner - steps: - - name: Checkout ${{ github.ref }} - # Hitting https://github.com/actions/checkout/issues/334 so trying v1 - uses: actions/checkout@v1 - with: - fetch-depth: 0 + steps: - name: Run scanner - run: | - git config --global --add safe.directory $GITHUB_WORKSPACE - scanner commit . github ${{ github.repository }} \ - --markdown-file job_summary.md \ - ${{ github.event_name == 'pull_request' && format('--since-commit {0}', github.event.pull_request.base.sha) || ''}} - - name: Write result to summary - run: cat ./job_summary.md >> $GITHUB_STEP_SUMMARY - if: ${{ always() }} + uses: aristanetworks/secret-scanner-service-public@main From b6f5e2f434f6e07f6c30f18579d646a583a3c158 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 11:24:37 +0200 Subject: [PATCH 02/90] ci: pre-commit autoupdate (#720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.8 → v0.4.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.8...v0.4.9) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8a31d2b45..161911832 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.8 + rev: v0.4.9 hooks: - id: ruff name: Run Ruff linter From fde7bea3adf7c49ed215b469abbaff5d86717e26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 10:59:45 +0200 Subject: [PATCH 03/90] ci: bump docker/build-push-action from 5 to 6 (#729) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v5...v6) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/on-demand.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/on-demand.yml b/.github/workflows/on-demand.yml index 85e7c416a..695a0c642 100644 --- a/.github/workflows/on-demand.yml +++ b/.github/workflows/on-demand.yml @@ -39,7 +39,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: Dockerfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b9088f0c..d32be46df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,7 +100,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: Dockerfile From b3b56c75debafcc5bc1e9d2fff7e260c02a43548 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jun 2024 14:38:36 +0200 Subject: [PATCH 04/90] chore: bump mike from 2.1.1 to 2.1.2 (#731) Bumps [mike](https://github.com/jimporter/mike) from 2.1.1 to 2.1.2. - [Release notes](https://github.com/jimporter/mike/releases) - [Changelog](https://github.com/jimporter/mike/blob/master/CHANGES.md) - [Commits](https://github.com/jimporter/mike/compare/v2.1.1...v2.1.2) --- updated-dependencies: - dependency-name: mike dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 49f699dac..b5fed0df0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dev = [ doc = [ "fontawesome_markdown", "griffe", - "mike==2.1.1", + "mike==2.1.2", "mkdocs-autorefs>=0.4.1", "mkdocs-bootswatch>=1.1", "mkdocs-git-revision-date-localized-plugin>=1.1.0", From 5caf42ffcc72c027f5c25879a3931ae240831bf6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 25 Jun 2024 14:49:01 +0200 Subject: [PATCH 05/90] bump: pre-commit autoupdate (#730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.9 → v0.4.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.9...v0.4.10) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Guillaume Mulocher --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 161911832..8cf3967a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.9 + rev: v0.4.10 hooks: - id: ruff name: Run Ruff linter From 802ff7ee94b89d4c93177e0945420f3530ff6a36 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Tue, 25 Jun 2024 15:59:50 +0200 Subject: [PATCH 06/90] doc: Remove moving banner from doc (#725) Co-authored-by: Thomas Grimonet --- docs/overrides/main.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/overrides/main.html b/docs/overrides/main.html index ad3693fde..d70fb9859 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -16,6 +16,8 @@ {{app}} {% endblock %} +{# Keeping this for future announcement if required {% block announce %} ANTA code has moved to a new house in aristanetworks organization and so has the documentation. Please update your bookmark to use anta.arista.com {% endblock %} +#} From 3382dfe8c0567c0189d312332ce9ddfd1a1e32fb Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 28 Jun 2024 16:40:32 +0200 Subject: [PATCH 07/90] feat(anta): Enable asyncio DEBUGs when ANTA_DEBUG is true (#726) --- anta/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/anta/__init__.py b/anta/__init__.py index e7111e94b..666084312 100644 --- a/anta/__init__.py +++ b/anta/__init__.py @@ -20,7 +20,10 @@ __copyright__ = "Copyright 2022-2024, Arista Networks, Inc." # ANTA Debug Mode environment variable -__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true") +__DEBUG__ = os.environ.get("ANTA_DEBUG", "").lower() == "true" +if __DEBUG__: + # enable asyncio DEBUG mode when __DEBUG__ is enabled + os.environ["PYTHONASYNCIODEBUG"] = "1" # Source: https://rich.readthedocs.io/en/stable/appendix/colors.html From 81291848116f0ae1f52e536dab1b443d40d7f689 Mon Sep 17 00:00:00 2001 From: Mahesh Kumar <122076792+MaheshGSLAB@users.noreply.github.com> Date: Fri, 28 Jun 2024 20:52:26 +0530 Subject: [PATCH 08/90] feat(anta.tests): Added testcase to validate STUN server status (#728) --- anta/tests/stun.py | 39 +++++++++++++++++++ examples/tests.yaml | 1 + tests/units/anta_tests/test_stun.py | 59 ++++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/anta/tests/stun.py b/anta/tests/stun.py index a8e8d9eb9..f06b5a0ab 100644 --- a/anta/tests/stun.py +++ b/anta/tests/stun.py @@ -115,3 +115,42 @@ def test(self) -> None: if actual_stun_data != expected_stun_data: failed_log = get_failed_logs(expected_stun_data, actual_stun_data) self.result.is_failure(f"For STUN source `{source_address}:{source_port}`:{failed_log}") + + +class VerifyStunServer(AntaTest): + """ + Verifies the STUN server status is enabled and running. + + Expected Results + ---------------- + * Success: The test will pass if the STUN server status is enabled and running. + * Failure: The test will fail if the STUN server is disabled or not running. + + Examples + -------- + ```yaml + anta.tests.stun: + - VerifyStunServer: + ``` + """ + + name = "VerifyStunServer" + description = "Verifies the STUN server status is enabled and running." + categories: ClassVar[list[str]] = ["stun"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show stun server status", revision=1)] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyStunServer.""" + command_output = self.instance_commands[0].json_output + status_disabled = not command_output.get("enabled") + not_running = command_output.get("pid") == 0 + + if status_disabled and not_running: + self.result.is_failure("STUN server status is disabled and not running.") + elif status_disabled: + self.result.is_failure("STUN server status is disabled.") + elif not_running: + self.result.is_failure("STUN server is not running.") + else: + self.result.is_success() diff --git a/examples/tests.yaml b/examples/tests.yaml index cb3d19b5f..83a9523ed 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -402,6 +402,7 @@ anta.tests.stun: public_address: 100.64.3.21 source_port: 4500 public_port: 6006 + - VerifyStunServer: anta.tests.system: - VerifyUptime: diff --git a/tests/units/anta_tests/test_stun.py b/tests/units/anta_tests/test_stun.py index 2c873650c..0c5fdc143 100644 --- a/tests/units/anta_tests/test_stun.py +++ b/tests/units/anta_tests/test_stun.py @@ -7,7 +7,7 @@ from typing import Any -from anta.tests.stun import VerifyStunClient +from anta.tests.stun import VerifyStunClient, VerifyStunServer from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 DATA: list[dict[str, Any]] = [ @@ -173,4 +173,61 @@ ], }, }, + { + "name": "success", + "test": VerifyStunServer, + "eos_data": [ + { + "enabled": True, + "pid": 1895, + } + ], + "inputs": {}, + "expected": {"result": "success"}, + }, + { + "name": "failure-disabled", + "test": VerifyStunServer, + "eos_data": [ + { + "enabled": False, + "pid": 1895, + } + ], + "inputs": {}, + "expected": { + "result": "failure", + "messages": ["STUN server status is disabled."], + }, + }, + { + "name": "failure-not-running", + "test": VerifyStunServer, + "eos_data": [ + { + "enabled": True, + "pid": 0, + } + ], + "inputs": {}, + "expected": { + "result": "failure", + "messages": ["STUN server is not running."], + }, + }, + { + "name": "failure-not-running-disabled", + "test": VerifyStunServer, + "eos_data": [ + { + "enabled": False, + "pid": 0, + } + ], + "inputs": {}, + "expected": { + "result": "failure", + "messages": ["STUN server status is disabled and not running."], + }, + }, ] From 2e2f491b13d55d49a7ef37cc3b6a80887be4121f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Jun 2024 11:23:02 -0400 Subject: [PATCH 09/90] chore: update ruff requirement from <0.5.0,>=0.3.7 to >=0.5.0,<0.6.0 (#732) --- .pre-commit-config.yaml | 2 +- anta/runner.py | 2 +- asynceapi/config_session.py | 2 +- pyproject.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8cf3967a7..40534de17 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.10 + rev: v0.5.0 hooks: - id: ruff name: Run Ruff linter diff --git a/anta/runner.py b/anta/runner.py index 75391da8d..898296372 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -50,7 +50,7 @@ def adjust_rlimit_nofile() -> tuple[int, int]: limits = resource.getrlimit(resource.RLIMIT_NOFILE) logger.debug("Initial limit numbers for open file descriptors for the current ANTA process: Soft Limit: %s | Hard Limit: %s", limits[0], limits[1]) - nofile = nofile if limits[1] > nofile else limits[1] + nofile = min(limits[1], nofile) logger.debug("Setting soft limit for open file descriptors for the current ANTA process to %s", nofile) resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, limits[1])) return resource.getrlimit(resource.RLIMIT_NOFILE) diff --git a/asynceapi/config_session.py b/asynceapi/config_session.py index 4054f14bf..558b7fed4 100644 --- a/asynceapi/config_session.py +++ b/asynceapi/config_session.py @@ -278,7 +278,7 @@ async def load_file(self, filename: str, *, replace: bool = False) -> None: commands.append(f"copy {filename} session-config") res: list[dict[str, Any]] = await self._cli(commands=commands) # type: ignore[assignment] # JSON outformat of multiple commands returns list[dict[str, Any]] - checks_re = re.compile(r"error|abort|invalid", flags=re.I) + checks_re = re.compile(r"error|abort|invalid", flags=re.IGNORECASE) messages = res[-1]["messages"] if any(map(checks_re.search, messages)): diff --git a/pyproject.toml b/pyproject.toml index b5fed0df0..71dbdc657 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dev = [ "pytest-html>=3.2.0", "pytest-metadata>=3.0.0", "pytest>=7.4.0", - "ruff>=0.3.7,<0.5.0", + "ruff>=0.5.0,<0.6.0", "tox>=4.10.0,<5.0.0", "types-PyYAML", "types-pyOpenSSL", From 508a09c5da52cd812bf124f657353b8df2288607 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 16:31:03 +0200 Subject: [PATCH 10/90] ci: bump amannn/action-semantic-pull-request from 5.5.2 to 5.5.3 (#733) Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 5.5.2 to 5.5.3. - [Release notes](https://github.com/amannn/action-semantic-pull-request/releases) - [Changelog](https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md) - [Commits](https://github.com/amannn/action-semantic-pull-request/compare/v5.5.2...v5.5.3) --- updated-dependencies: - dependency-name: amannn/action-semantic-pull-request dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pr-triage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 75c2b8973..40dd696e5 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -22,7 +22,7 @@ jobs: steps: # Please look up the latest version from # https://github.com/amannn/action-semantic-pull-request/releases - - uses: amannn/action-semantic-pull-request@v5.5.2 + - uses: amannn/action-semantic-pull-request@v5.5.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From 488e9b7eb92c502c7b509b97d9774a2cf1513c9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 16:35:30 +0200 Subject: [PATCH 11/90] ci: bump toshimaru/auto-author-assign from 2.1.0 to 2.1.1 (#734) Bumps [toshimaru/auto-author-assign](https://github.com/toshimaru/auto-author-assign) from 2.1.0 to 2.1.1. - [Release notes](https://github.com/toshimaru/auto-author-assign/releases) - [Changelog](https://github.com/toshimaru/auto-author-assign/blob/main/CHANGELOG.md) - [Commits](https://github.com/toshimaru/auto-author-assign/compare/v2.1.0...v2.1.1) --- updated-dependencies: - dependency-name: toshimaru/auto-author-assign dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pr-triage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 40dd696e5..cdc2bca28 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -13,7 +13,7 @@ jobs: # https://github.com/marketplace/actions/auto-author-assign runs-on: ubuntu-latest steps: - - uses: toshimaru/auto-author-assign@v2.1.0 + - uses: toshimaru/auto-author-assign@v2.1.1 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" From 6c43a925bd083228db570cdcfa18050dc1ebf74a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 17:16:54 +0200 Subject: [PATCH 12/90] chore: pre-commit autoupdate (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/pycqa/pylint: v3.2.3 → v3.2.4](https://github.com/pycqa/pylint/compare/v3.2.3...v3.2.4) - [github.com/pre-commit/mirrors-mypy: v1.10.0 → v1.10.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.0...v1.10.1) * ci: Adjust typing for newer mypy --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: gmuloc --- .pre-commit-config.yaml | 4 ++-- anta/result_manager/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40534de17..48b24a9b7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,7 +52,7 @@ repos: name: Run Ruff formatter - repo: https://github.com/pycqa/pylint - rev: "v3.2.3" + rev: "v3.2.4" hooks: - id: pylint name: Check code style with pylint @@ -80,7 +80,7 @@ repos: types: [text] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.0 + rev: v1.10.1 hooks: - id: mypy name: Check typing with mypy diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index b3679653e..a5fcf9755 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -125,7 +125,7 @@ def add(self, result: TestResult) -> None: """ def _update_status(test_status: TestStatus) -> None: - result_validator = TypeAdapter(TestStatus) + result_validator: TypeAdapter[TestStatus] = TypeAdapter(TestStatus) result_validator.validate_python(test_status) if test_status == "error": self.error_status = True From e9aff4a05dc4a98dd3e1aad1d4eba48d4ab30523 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Thu, 4 Jul 2024 12:56:17 +0200 Subject: [PATCH 13/90] doc: Adjust Args to Parameters to comply with numpy (#724) --- anta/catalog.py | 24 ++++++++++++------------ anta/cli/get/utils.py | 8 ++++---- anta/cli/utils.py | 4 ++-- anta/decorators.py | 16 ++++++++-------- anta/device.py | 32 ++++++++++++++++---------------- anta/inventory/__init__.py | 28 ++++++++++++++-------------- anta/logger.py | 8 ++++---- anta/models.py | 16 ++++++++-------- anta/reporter/__init__.py | 28 ++++++++++++++-------------- anta/result_manager/__init__.py | 16 ++++++++-------- anta/result_manager/models.py | 20 ++++++++++---------- anta/runner.py | 20 ++++++++++---------- anta/tests/logging.py | 4 ++-- anta/tests/routing/bgp.py | 12 ++++++------ anta/tests/routing/isis.py | 12 ++++++++---- anta/tests/routing/ospf.py | 12 ++++++------ anta/tools.py | 12 ++++++------ 17 files changed, 138 insertions(+), 134 deletions(-) diff --git a/anta/catalog.py b/anta/catalog.py index 142640ecb..e94b8f750 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -254,8 +254,8 @@ def __init__( ) -> None: """Instantiate an AntaCatalog instance. - Args: - ---- + Parameters + ---------- tests: A list of AntaTestDefinition instances. filename: The path from which the catalog is loaded. @@ -301,8 +301,8 @@ def tests(self, value: list[AntaTestDefinition]) -> None: def parse(filename: str | Path) -> AntaCatalog: """Create an AntaCatalog instance from a test catalog file. - Args: - ---- + Parameters + ---------- filename: Path to test catalog YAML file """ @@ -325,8 +325,8 @@ def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> Anta It is the data structure returned by `yaml.load()` function of a valid YAML Test Catalog file. - Args: - ---- + Parameters + ---------- data: Python dictionary used to instantiate the AntaCatalog instance filename: value to be set as AntaCatalog instance attribute @@ -359,8 +359,8 @@ def from_list(data: ListAntaTestTuples) -> AntaCatalog: See ListAntaTestTuples type alias for details. - Args: - ---- + Parameters + ---------- data: Python list used to instantiate the AntaCatalog instance """ @@ -375,8 +375,8 @@ def from_list(data: ListAntaTestTuples) -> AntaCatalog: def merge(self, catalog: AntaCatalog) -> AntaCatalog: """Merge two AntaCatalog instances. - Args: - ---- + Parameters + ---------- catalog: AntaCatalog instance to merge to this instance. Returns @@ -427,8 +427,8 @@ def build_indexes(self, filtered_tests: set[str] | None = None) -> None: def get_tests_by_tags(self, tags: set[str], *, strict: bool = False) -> set[AntaTestDefinition]: """Return all tests that match a given set of tags, according to the specified strictness. - Args: - ---- + Parameters + ---------- tags: The tags to filter tests by. If empty, return all tests without tags. strict: If True, returns only tests that contain all specified tags (intersection). If False, returns tests that contain any of the specified tags (union). diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py index 5308f4414..ba4d886d5 100644 --- a/anta/cli/get/utils.py +++ b/anta/cli/get/utils.py @@ -82,8 +82,8 @@ def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str, *, verify_ce TODO: need to handle requests error - Args: - ---- + Parameters + ---------- cvp_ip: IP address of CloudVision. cvp_username: Username to connect to CloudVision. cvp_password: Password to connect to CloudVision. @@ -161,8 +161,8 @@ def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | Non def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None: """Create an ANTA inventory from an Ansible inventory YAML file. - Args: - ---- + Parameters + ---------- inventory: Ansible Inventory file to read output: ANTA inventory file to generate. ansible_group: Ansible group from where to extract data. diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 71d99b8ae..f769de7a7 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -60,8 +60,8 @@ def exit_with_code(ctx: click.Context) -> None: * 1 if status is `failure` * 2 if status is `error`. - Args: - ---- + Parameters + ---------- ctx: Click Context """ diff --git a/anta/decorators.py b/anta/decorators.py index dc57e13ec..c9f8b6d28 100644 --- a/anta/decorators.py +++ b/anta/decorators.py @@ -20,8 +20,8 @@ def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: """Return a decorator to log a message of WARNING severity when a test is deprecated. - Args: - ---- + Parameters + ---------- new_tests: A list of new test classes that should replace the deprecated test. Returns @@ -33,8 +33,8 @@ def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: def decorator(function: F) -> F: """Actual decorator that logs the message. - Args: - ---- + Parameters + ---------- function: The test function to be decorated. Returns @@ -64,8 +64,8 @@ def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]: This decorator factory generates a decorator that will check the hardware model of the device the test is run on. If the model is in the list of platforms specified, the test will be skipped. - Args: - ---- + Parameters + ---------- platforms: List of hardware models on which the test should be skipped. Returns @@ -77,8 +77,8 @@ def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]: def decorator(function: F) -> F: """Actual decorator that either runs the test or skips it based on the device's hardware model. - Args: - ---- + Parameters + ---------- function: The test function to be decorated. Returns diff --git a/anta/device.py b/anta/device.py index f0ec6a00c..087f3b57b 100644 --- a/anta/device.py +++ b/anta/device.py @@ -55,8 +55,8 @@ class AntaDevice(ABC): def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bool = False) -> None: """Initialize an AntaDevice. - Args: - ---- + Parameters + ---------- name: Device name. tags: Tags for this device. disable_cache: Disable caching for all commands for this device. @@ -130,8 +130,8 @@ async def _collect(self, command: AntaCommand, *, collection_id: str | None = No exception and implement proper logging, the `output` attribute of the `AntaCommand` object passed as argument would be `None` in this case. - Args: - ---- + Parameters + ---------- command: The command to collect. collection_id: An identifier used to build the eAPI request ID. """ @@ -147,8 +147,8 @@ async def collect(self, command: AntaCommand, *, collection_id: str | None = Non When caching is NOT enabled, either at the device or command level, the method directly collects the output via the private `_collect` method without interacting with the cache. - Args: - ---- + Parameters + ---------- command: The command to collect. collection_id: An identifier used to build the eAPI request ID. """ @@ -170,8 +170,8 @@ async def collect(self, command: AntaCommand, *, collection_id: str | None = Non async def collect_commands(self, commands: list[AntaCommand], *, collection_id: str | None = None) -> None: """Collect multiple commands. - Args: - ---- + Parameters + ---------- commands: The commands to collect. collection_id: An identifier used to build the eAPI request ID. """ @@ -192,8 +192,8 @@ async def copy(self, sources: list[Path], destination: Path, direction: Literal[ It is not mandatory to implement this for a valid AntaDevice subclass. - Args: - ---- + Parameters + ---------- sources: List of files to copy to or from the device. destination: Local or remote destination when copying the files. Can be a folder. direction: Defines if this coroutine copies files to or from the device. @@ -237,8 +237,8 @@ def __init__( ) -> None: """Instantiate an AsyncEOSDevice. - Args: - ---- + Parameters + ---------- host: Device FQDN or IP. username: Username to connect to eAPI and SSH. password: Password to connect to eAPI and SSH. @@ -313,8 +313,8 @@ async def _collect(self, command: AntaCommand, *, collection_id: str | None = No Gain privileged access using the `enable_password` attribute of the `AntaDevice` instance if populated. - Args: - ---- + Parameters + ---------- command: The command to collect. collection_id: An identifier used to build the eAPI request ID. """ @@ -405,8 +405,8 @@ async def refresh(self) -> None: async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: """Copy files to and from the device using asyncssh.scp(). - Args: - ---- + Parameters + ---------- sources: List of files to copy to or from the device. destination: Local or remote destination when copying the files. Can be a folder. direction: Defines if this coroutine copies files to or from the device. diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py index 5e66d84b2..46609676a 100644 --- a/anta/inventory/__init__.py +++ b/anta/inventory/__init__.py @@ -44,8 +44,8 @@ def __str__(self) -> str: def _update_disable_cache(kwargs: dict[str, Any], *, inventory_disable_cache: bool) -> dict[str, Any]: """Return new dictionary, replacing kwargs with added disable_cache value from inventory_value if disable_cache has not been set by CLI. - Args: - ---- + Parameters + ---------- inventory_disable_cache: The value of disable_cache in the inventory kwargs: The kwargs to instantiate the device @@ -62,8 +62,8 @@ def _parse_hosts( ) -> None: """Parse the host section of an AntaInventoryInput and add the devices to the inventory. - Args: - ---- + Parameters + ---------- inventory_input: AntaInventoryInput used to parse the devices inventory: AntaInventory to add the parsed devices to **kwargs: Additional keyword arguments to pass to the device constructor @@ -91,8 +91,8 @@ def _parse_networks( ) -> None: """Parse the network section of an AntaInventoryInput and add the devices to the inventory. - Args: - ---- + Parameters + ---------- inventory_input: AntaInventoryInput used to parse the devices inventory: AntaInventory to add the parsed devices to **kwargs: Additional keyword arguments to pass to the device constructor @@ -124,8 +124,8 @@ def _parse_ranges( ) -> None: """Parse the range section of an AntaInventoryInput and add the devices to the inventory. - Args: - ---- + Parameters + ---------- inventory_input: AntaInventoryInput used to parse the devices inventory: AntaInventory to add the parsed devices to **kwargs: Additional keyword arguments to pass to the device constructor @@ -175,8 +175,8 @@ def parse( The inventory devices are AsyncEOSDevice instances. - Args: - ---- + Parameters + ---------- filename: Path to device inventory YAML file. username: Username to use to connect to devices. password: Password to use to connect to devices. @@ -254,8 +254,8 @@ def devices(self) -> list[AntaDevice]: def get_inventory(self, *, established_only: bool = False, tags: set[str] | None = None, devices: set[str] | None = None) -> AntaInventory: """Return a filtered inventory. - Args: - ---- + Parameters + ---------- established_only: Whether or not to include only established devices. tags: Tags to filter devices. devices: Names to filter devices. @@ -293,8 +293,8 @@ def __setitem__(self, key: str, value: AntaDevice) -> None: def add_device(self, device: AntaDevice) -> None: """Add a device to final inventory. - Args: - ---- + Parameters + ---------- device: Device object to be added """ diff --git a/anta/logger.py b/anta/logger.py index e532ace2c..b64fbe7b4 100644 --- a/anta/logger.py +++ b/anta/logger.py @@ -49,8 +49,8 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: If a file is provided and logging level is DEBUG, only the logging level INFO and higher will be logged to stdout while all levels will be logged in the file. - Args: - ---- + Parameters + ---------- level: ANTA logging level file: Send logs to a file @@ -104,8 +104,8 @@ def anta_log_exception(exception: BaseException, message: str | None = None, cal If `anta.__DEBUG__` is True then the `logger.exception` method is called to get the traceback, otherwise `logger.error` is called. - Args: - ---- + Parameters + ---------- exception: The Exception being logged. message: An optional message. calling_logger: A logger to which the exception should be logged. If not present, the logger in this file is used. diff --git a/anta/models.py b/anta/models.py index c44f7e8b4..499fb3536 100644 --- a/anta/models.py +++ b/anta/models.py @@ -95,8 +95,8 @@ def render(self, **params: str | int | bool) -> AntaCommand: Keep the parameters used in the AntaTemplate instance. - Args: - ---- + Parameters + ---------- params: dictionary of variables with string values to render the Python f-string Returns @@ -245,8 +245,8 @@ class AntaTemplateRenderError(RuntimeError): def __init__(self, template: AntaTemplate, key: str) -> None: """Initialize an AntaTemplateRenderError. - Args: - ---- + Parameters + ---------- template: The AntaTemplate instance that failed to render key: Key that has not been provided to render the template @@ -381,8 +381,8 @@ def __init__( ) -> None: """AntaTest Constructor. - Args: - ---- + Parameters + ---------- device: AntaDevice instance on which the test will be run inputs: dictionary of attributes used to instantiate the AntaTest.Input instance eos_data: Populate outputs of the test commands instead of collecting from devices. @@ -556,8 +556,8 @@ async def wrapper( ) -> TestResult: """Inner function for the anta_test decorator. - Args: - ---- + Parameters + ---------- self: The test instance. eos_data: Populate outputs of the test commands instead of collecting from devices. This list must have the same length and order than the `instance_commands` instance attribute. diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index 63a1fe5b2..685608dc2 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -30,8 +30,8 @@ class ReportTable: def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str: """Split list to multi-lines string. - Args: - ---- + Parameters + ---------- usr_list (list[str]): List of string to concatenate delimiter (str, optional): A delimiter to use to start string. Defaults to None. @@ -49,8 +49,8 @@ def _build_headers(self, headers: list[str], table: Table) -> Table: First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER - Args: - ---- + Parameters + ---------- headers: List of headers. table: A rich Table instance. @@ -72,8 +72,8 @@ def _build_headers(self, headers: list[str], table: Table) -> Table: def _color_result(self, status: TestStatus) -> str: """Return a colored string based on the status value. - Args: - ---- + Parameters + ---------- status (TestStatus): status value to color. Returns @@ -89,8 +89,8 @@ def report_all(self, manager: ResultManager, title: str = "All tests results") - Create table with full output: Host / Test / Status / Message - Args: - ---- + Parameters + ---------- manager: A ResultManager instance. title: Title for the report. Defaults to 'All tests results'. @@ -123,8 +123,8 @@ def report_summary_tests( Create table with full output: Test | Number of success | Number of failure | Number of error | List of nodes in error or failure - Args: - ---- + Parameters + ---------- manager: A ResultManager instance. tests: List of test names to include. None to select all tests. title: Title of the report. @@ -171,8 +171,8 @@ def report_summary_devices( Create table with full output: Host | Number of success | Number of failure | Number of error | List of nodes in error or failure - Args: - ---- + Parameters + ---------- manager: A ResultManager instance. devices: List of device names to include. None to select all devices. title: Title of the report. @@ -239,8 +239,8 @@ def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip } ] - Args: - ---- + Parameters + ---------- data: List of results from ResultManager.results trim_blocks: enable trim_blocks for J2 rendering. lstrip_blocks: enable lstrip_blocks for J2 rendering. diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index a5fcf9755..4278c0da3 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -119,8 +119,8 @@ def json(self) -> str: def add(self, result: TestResult) -> None: """Add a result to the ResultManager instance. - Args: - ---- + Parameters + ---------- result: TestResult to add to the ResultManager instance. """ @@ -145,8 +145,8 @@ def get_status(self, *, ignore_error: bool = False) -> str: def filter(self, hide: set[TestStatus]) -> ResultManager: """Get a filtered ResultManager based on test status. - Args: - ---- + Parameters + ---------- hide: set of TestStatus literals to select tests to hide based on their status. Returns @@ -160,8 +160,8 @@ def filter(self, hide: set[TestStatus]) -> ResultManager: def filter_by_tests(self, tests: set[str]) -> ResultManager: """Get a filtered ResultManager that only contains specific tests. - Args: - ---- + Parameters + ---------- tests: Set of test names to filter the results. Returns @@ -175,8 +175,8 @@ def filter_by_tests(self, tests: set[str]) -> ResultManager: def filter_by_devices(self, devices: set[str]) -> ResultManager: """Get a filtered ResultManager that only contains specific devices. - Args: - ---- + Parameters + ---------- devices: Set of device names to filter the results. Returns diff --git a/anta/result_manager/models.py b/anta/result_manager/models.py index c53947ee4..e1171c88a 100644 --- a/anta/result_manager/models.py +++ b/anta/result_manager/models.py @@ -36,8 +36,8 @@ class TestResult(BaseModel): def is_success(self, message: str | None = None) -> None: """Set status to success. - Args: - ---- + Parameters + ---------- message: Optional message related to the test """ @@ -46,8 +46,8 @@ def is_success(self, message: str | None = None) -> None: def is_failure(self, message: str | None = None) -> None: """Set status to failure. - Args: - ---- + Parameters + ---------- message: Optional message related to the test """ @@ -56,8 +56,8 @@ def is_failure(self, message: str | None = None) -> None: def is_skipped(self, message: str | None = None) -> None: """Set status to skipped. - Args: - ---- + Parameters + ---------- message: Optional message related to the test """ @@ -66,8 +66,8 @@ def is_skipped(self, message: str | None = None) -> None: def is_error(self, message: str | None = None) -> None: """Set status to error. - Args: - ---- + Parameters + ---------- message: Optional message related to the test """ @@ -76,8 +76,8 @@ def is_error(self, message: str | None = None) -> None: def _set_status(self, status: TestStatus, message: str | None = None) -> None: """Set status and insert optional message. - Args: - ---- + Parameters + ---------- status: status of the test message: optional message diff --git a/anta/runner.py b/anta/runner.py index 898296372..df4c70cc4 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -59,8 +59,8 @@ def adjust_rlimit_nofile() -> tuple[int, int]: def log_cache_statistics(devices: list[AntaDevice]) -> None: """Log cache statistics for each device in the inventory. - Args: - ---- + Parameters + ---------- devices: List of devices in the inventory. """ for device in devices: @@ -78,8 +78,8 @@ def log_cache_statistics(devices: list[AntaDevice]) -> None: async def setup_inventory(inventory: AntaInventory, tags: set[str] | None, devices: set[str] | None, *, established_only: bool) -> AntaInventory | None: """Set up the inventory for the ANTA run. - Args: - ---- + Parameters + ---------- inventory: AntaInventory object that includes the device(s). tags: Tags to filter devices from the inventory. devices: Devices on which to run tests. None means all devices. @@ -116,8 +116,8 @@ def prepare_tests( ) -> defaultdict[AntaDevice, set[AntaTestDefinition]] | None: """Prepare the tests to run. - Args: - ---- + Parameters + ---------- inventory: AntaInventory object that includes the device(s). catalog: AntaCatalog object that includes the list of tests. tests: Tests to run against devices. None means all tests. @@ -160,8 +160,8 @@ def prepare_tests( def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]]) -> list[Coroutine[Any, Any, TestResult]]: """Get the coroutines for the ANTA run. - Args: - ---- + Parameters + ---------- selected_tests: A mapping of devices to the tests to run. The selected tests are generated by the `prepare_tests` function. Returns @@ -205,8 +205,8 @@ async def main( # noqa: PLR0913 Use this as an entrypoint to the test framework in your script. ResultManager object gets updated with the test results. - Args: - ---- + Parameters + ---------- manager: ResultManager object to populate with the test results. inventory: AntaInventory object that includes the device(s). catalog: AntaCatalog object that includes the list of tests. diff --git a/anta/tests/logging.py b/anta/tests/logging.py index b05b0a0dd..b520fc1e1 100644 --- a/anta/tests/logging.py +++ b/anta/tests/logging.py @@ -25,8 +25,8 @@ def _get_logging_states(logger: logging.Logger, command_output: str) -> str: """Parse `show logging` output and gets operational logging states used in the tests in this module. - Args: - ---- + Parameters + ---------- logger: The logger object. command_output: The `show logging` output. diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 41c24958b..a29216b2d 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -24,8 +24,8 @@ def _add_bgp_failures(failures: dict[tuple[str, str | None], dict[str, Any]], af Note: This function modifies `failures` in-place. - Args: - ---- + Parameters + ---------- failures: The dictionary to which the failure will be added. afi: The address family identifier. vrf: The VRF name. @@ -63,8 +63,8 @@ def _add_bgp_failures(failures: dict[tuple[str, str | None], dict[str, Any]], af def _check_peer_issues(peer_data: dict[str, Any] | None) -> dict[str, Any]: """Check for issues in BGP peer data. - Args: - ---- + Parameters + ---------- peer_data: The BGP peer data dictionary nested in the `show bgp summary` command. Returns @@ -104,8 +104,8 @@ def _add_bgp_routes_failure( It identifies any missing routes as well as any routes that are invalid or inactive. The results are returned in a dictionary. - Args: - ---- + Parameters + ---------- bgp_routes: The list of expected routes. bgp_output: The BGP output from the device. peer: The IP address of the BGP peer. diff --git a/anta/tests/routing/isis.py b/anta/tests/routing/isis.py index afa75b548..dee472571 100644 --- a/anta/tests/routing/isis.py +++ b/anta/tests/routing/isis.py @@ -638,7 +638,8 @@ def _check_tunnel_type(self, via_input: VerifyISISSegmentRoutingTunnels.Input.En """ Check if the tunnel type specified in `via_input` matches any of the tunnel types in `eos_entry`. - Args: + Parameters + ---------- via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input tunnel type to check. eos_entry (dict[str, Any]): The EOS entry containing the tunnel types. @@ -662,7 +663,8 @@ def _check_tunnel_nexthop(self, via_input: VerifyISISSegmentRoutingTunnels.Input """ Check if the tunnel nexthop matches the given input. - Args: + Parameters + ---------- via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input via object. eos_entry (dict[str, Any]): The EOS entry dictionary. @@ -686,7 +688,8 @@ def _check_tunnel_interface(self, via_input: VerifyISISSegmentRoutingTunnels.Inp """ Check if the tunnel interface exists in the given EOS entry. - Args: + Parameters + ---------- via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input via object. eos_entry (dict[str, Any]): The EOS entry dictionary. @@ -710,7 +713,8 @@ def _check_tunnel_id(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entr """ Check if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias. - Args: + Parameters + ---------- via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input vias to check. eos_entry (dict[str, Any]): The EOS entry to compare against. diff --git a/anta/tests/routing/ospf.py b/anta/tests/routing/ospf.py index 5910bf04e..342ada2f4 100644 --- a/anta/tests/routing/ospf.py +++ b/anta/tests/routing/ospf.py @@ -18,8 +18,8 @@ def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int: """Count the number of OSPF neighbors. - Args: - ---- + Parameters + ---------- ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command. Returns @@ -37,8 +37,8 @@ def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int: def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]: """Return the OSPF neighbors whose adjacency state is not `full`. - Args: - ---- + Parameters + ---------- ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command. Returns @@ -63,8 +63,8 @@ def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dic def _get_ospf_max_lsa_info(ospf_process_json: dict[str, Any]) -> list[dict[str, Any]]: """Return information about OSPF instances and their LSAs. - Args: - ---- + Parameters + ---------- ospf_process_json: OSPF process information in JSON format. Returns diff --git a/anta/tools.py b/anta/tools.py index b3760da0c..55748b492 100644 --- a/anta/tools.py +++ b/anta/tools.py @@ -32,8 +32,8 @@ def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, An Returns the failed log or an empty string if there is no difference between the expected and actual output. - Args: - ---- + Parameters + ---------- expected_output (dict): Expected output of a test. actual_output (dict): Actual output of a test @@ -302,8 +302,8 @@ def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]: profile is conditionally enabled based on the presence of ANTA_CPROFILE environment variable. Expect to decorate an async function. - Args: - ---- + Parameters + ---------- sort_by (str): The criterion to sort the profiling results. Default is 'cumtime'. Returns @@ -318,8 +318,8 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: If `ANTA_CPROFILE` is set, cProfile is enabled and dumps the stats to the file. - Args: - ---- + Parameters + ---------- *args: Arbitrary positional arguments. **kwargs: Arbitrary keyword arguments. From 0f88b28d824a21994a3993102d047d95bdd84c74 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Thu, 4 Jul 2024 13:06:36 +0200 Subject: [PATCH 14/90] fix(anta.cli): Evaluate nrfu subcommands args before running the tests (#707) --- anta/cli/nrfu/__init__.py | 31 ++++++++++--------------------- anta/cli/nrfu/commands.py | 6 +++++- anta/cli/nrfu/utils.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 6a67e609f..27f8588e7 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations -import asyncio from typing import TYPE_CHECKING, get_args import click @@ -13,11 +12,7 @@ from anta.cli.nrfu import commands from anta.cli.utils import AliasedGroup, catalog_options, inventory_options from anta.custom_types import TestStatus -from anta.models import AntaTest from anta.result_manager import ResultManager -from anta.runner import main - -from .utils import anta_progress_bar, print_settings if TYPE_CHECKING: from anta.catalog import AntaCatalog @@ -37,6 +32,7 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: """Ignore MissingParameter exception when parsing arguments if `--help` is present for a subcommand.""" # Adding a flag for potential callbacks ctx.ensure_object(dict) + ctx.obj["args"] = args if "--help" in args: ctx.obj["_anta_help"] = True @@ -125,29 +121,22 @@ def nrfu( # If help is invoke somewhere, skip the command if ctx.obj.get("_anta_help"): return + # We use ctx.obj to pass stuff to the next Click functions ctx.ensure_object(dict) ctx.obj["result_manager"] = ResultManager() ctx.obj["ignore_status"] = ignore_status ctx.obj["ignore_error"] = ignore_error ctx.obj["hide"] = set(hide) if hide else None - print_settings(inventory, catalog) - with anta_progress_bar() as AntaTest.progress: - asyncio.run( - main( - ctx.obj["result_manager"], - inventory, - catalog, - tags=tags, - devices=set(device) if device else None, - tests=set(test) if test else None, - dry_run=dry_run, - ) - ) - if dry_run: - return + ctx.obj["catalog"] = catalog + ctx.obj["inventory"] = inventory + ctx.obj["tags"] = tags + ctx.obj["device"] = device + ctx.obj["test"] = test + ctx.obj["dry_run"] = dry_run + # Invoke `anta nrfu table` if no command is passed - if ctx.invoked_subcommand is None: + if not ctx.invoked_subcommand: ctx.invoke(commands.table) diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index 4dd779b41..7581116c6 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -13,7 +13,7 @@ from anta.cli.utils import exit_with_code -from .utils import print_jinja, print_json, print_table, print_text +from .utils import print_jinja, print_json, print_table, print_text, run_tests logger = logging.getLogger(__name__) @@ -32,6 +32,7 @@ def table( group_by: Literal["device", "test"] | None, ) -> None: """ANTA command to check network states with table result.""" + run_tests(ctx) print_table(ctx, group_by=group_by) exit_with_code(ctx) @@ -48,6 +49,7 @@ def table( ) def json(ctx: click.Context, output: pathlib.Path | None) -> None: """ANTA command to check network state with JSON result.""" + run_tests(ctx) print_json(ctx, output=output) exit_with_code(ctx) @@ -56,6 +58,7 @@ def json(ctx: click.Context, output: pathlib.Path | None) -> None: @click.pass_context def text(ctx: click.Context) -> None: """ANTA command to check network states with text result.""" + run_tests(ctx) print_text(ctx) exit_with_code(ctx) @@ -80,5 +83,6 @@ def text(ctx: click.Context) -> None: ) def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None: """ANTA command to check network state with templated report.""" + run_tests(ctx) print_jinja(results=ctx.obj["result_manager"], template=template, output=output) exit_with_code(ctx) diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index 2eeeacb76..d4cd1317d 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -5,6 +5,7 @@ from __future__ import annotations +import asyncio import json import logging from typing import TYPE_CHECKING, Literal @@ -14,7 +15,9 @@ from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn from anta.cli.console import console +from anta.models import AntaTest from anta.reporter import ReportJinja, ReportTable +from anta.runner import main if TYPE_CHECKING: import pathlib @@ -28,6 +31,37 @@ logger = logging.getLogger(__name__) +def run_tests(ctx: click.Context) -> None: + """Run the tests.""" + # Digging up the parameters from the parent context + if ctx.parent is None: + ctx.exit() + nrfu_ctx_params = ctx.parent.params + tags = nrfu_ctx_params["tags"] + device = nrfu_ctx_params["device"] or None + test = nrfu_ctx_params["test"] or None + dry_run = nrfu_ctx_params["dry_run"] + + catalog = ctx.obj["catalog"] + inventory = ctx.obj["inventory"] + + print_settings(inventory, catalog) + with anta_progress_bar() as AntaTest.progress: + asyncio.run( + main( + ctx.obj["result_manager"], + inventory, + catalog, + tags=tags, + devices=set(device) if device else None, + tests=set(test) if test else None, + dry_run=dry_run, + ) + ) + if dry_run: + ctx.exit() + + def _get_result_manager(ctx: click.Context) -> ResultManager: """Get a ResultManager instance based on Click context.""" return ctx.obj["result_manager"].filter(ctx.obj.get("hide")) if ctx.obj.get("hide") is not None else ctx.obj["result_manager"] From b8fbeac6bc18c10724c7b8fef7ca7f2cd3fa9390 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Thu, 4 Jul 2024 17:29:12 +0200 Subject: [PATCH 15/90] doc: Remove special note for AVD (#738) --- docs/requirements-and-installation.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/requirements-and-installation.md b/docs/requirements-and-installation.md index ffcb9aadd..ebe39c482 100644 --- a/docs/requirements-and-installation.md +++ b/docs/requirements-and-installation.md @@ -31,8 +31,6 @@ pip install anta !!! Warning * This command alone **will not** install the ANTA CLI requirements. - * When using ANTA mode in [AVD](https://avd.arista.com) `eos_validate` role, (currently in preview), ensure you install the documented supported ANTA version for your AVD version.
- The latest documented version can be found at: https://avd.arista.com/stable/roles/eos_validate_state/ANTA-Preview.html ### Install ANTA CLI as an application with `pipx` From 2329e8fc5adb6a91de925f8a9ba7351553338c62 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Thu, 4 Jul 2024 17:54:10 +0200 Subject: [PATCH 16/90] ci: Move sonarlint analysis to Github action for coverage and remote PR support (#737) --- .github/workflows/code-testing.yml | 2 +- .github/workflows/sonar.yml | 35 +++++++++++++++++++ ...oud.properties => sonar-project.properties | 5 +++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sonar.yml rename .sonarcloud.properties => sonar-project.properties (74%) diff --git a/.github/workflows/code-testing.yml b/.github/workflows/code-testing.yml index d8b2879a9..4a63f5677 100644 --- a/.github/workflows/code-testing.yml +++ b/.github/workflows/code-testing.yml @@ -122,7 +122,7 @@ jobs: test-documentation: name: Build offline documentation for testing runs-on: ubuntu-20.04 - needs: [lint-python, type-python, test-python] + needs: [test-python] steps: - uses: actions/checkout@v4 - name: Setup Python diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 000000000..b6e801b0b --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,35 @@ +--- +name: Analysis with Sonarlint and publish to SonarCloud +on: + push: + branches: + - main + # Need to do this to be able to have coverage on PR across forks. + pull_request_target: + +# TODO this can be made better by running only coverage, it happens that today +# in tox gh-actions we have configured 3.11 to run the report side in +# pyproject.toml + +jobs: + sonarcloud: + name: Run Sonarlint analysis and upload to SonarCloud. + runs-on: ubuntu-latest + needs: [test-python] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + - name: Install dependencies + run: pip install tox tox-gh-actions + - name: "Run pytest via tox for ${{ matrix.python }}" + run: tox + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.sonarcloud.properties b/sonar-project.properties similarity index 74% rename from .sonarcloud.properties rename to sonar-project.properties index 4cd2ea5e9..0abd967fb 100644 --- a/.sonarcloud.properties +++ b/sonar-project.properties @@ -1,3 +1,6 @@ +sonar.projectKey=aristanetworks_anta +sonar.organization=aristanetworks-1 + # Path to sources sonar.sources=anta/,asynceapi/ #sonar.exclusions= @@ -16,3 +19,5 @@ sonar.python.version=3.9,3.10,3.11,3.12 # Exclusions for copy-paste detection #sonar.cpd.exclusions=, + +sonar.python.coverage.reportPaths=.coverage.xml From 490b14a1161a9b14943c8e991a52e6bf96eff385 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Thu, 4 Jul 2024 17:55:18 +0200 Subject: [PATCH 17/90] ci: Update sonar.yml --- .github/workflows/sonar.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index b6e801b0b..5d7b1b9a4 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -15,7 +15,6 @@ jobs: sonarcloud: name: Run Sonarlint analysis and upload to SonarCloud. runs-on: ubuntu-latest - needs: [test-python] steps: - uses: actions/checkout@v4 with: From b0cce019b88e26f9069c4044a46bef96f7dbb724 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 5 Jul 2024 11:13:32 +0200 Subject: [PATCH 18/90] ci: Adjust coverage run options for sonarcloud (#741) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 71dbdc657..bce5a3c5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -180,6 +180,7 @@ filterwarnings = [ branch = true source = ["anta"] parallel = true +relative_files = true [tool.coverage.report] # Regexes for lines to exclude from consideration From 61ddc06361141d6d0d030666a8715d40108a67d1 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 5 Jul 2024 12:10:51 +0200 Subject: [PATCH 19/90] ci: Test for PR info (#744) --- .github/workflows/sonar.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 5d7b1b9a4..b4ead4986 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -32,3 +32,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.scm.revision=${{ github.event.pull_request.head.sha }} + -Dsonar.pullrequest.key=${{ github.event.number }} + -Dsonar.pullrequest.branch=${{ github.event.head.ref }} + -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} From 864e251ddd2db0462a7ca6d494f1d9326ea92402 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 5 Jul 2024 12:19:49 +0200 Subject: [PATCH 20/90] ci: Fix variable name --- .github/workflows/sonar.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index b4ead4986..587e8fc81 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -36,5 +36,5 @@ jobs: args: > -Dsonar.scm.revision=${{ github.event.pull_request.head.sha }} -Dsonar.pullrequest.key=${{ github.event.number }} - -Dsonar.pullrequest.branch=${{ github.event.head.ref }} + -Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} From 6deafdda7b40d82a1afa0fd83ac73d1a4cbd8f84 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 5 Jul 2024 12:58:09 +0200 Subject: [PATCH 21/90] ci: Enable verbose mode in debug mode for sonarcloud --- .github/workflows/sonar.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 587e8fc81..e1e2b6a8f 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -38,3 +38,5 @@ jobs: -Dsonar.pullrequest.key=${{ github.event.number }} -Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} + # To trigger verbose when debugging in Github Action + -Dsonar.verbose=${{ secrets.ACTIONS_STEP_DEBUG }} From b065e640914f4e3bd6a0672888527860f3e8ee2f Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 5 Jul 2024 14:00:04 +0200 Subject: [PATCH 22/90] ci: Fix workflow syntax --- .github/workflows/sonar.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index e1e2b6a8f..109828071 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -33,10 +33,10 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: + # Using ACTION_STEP_DEBUG to trigger verbose when debugging in Github Action args: > -Dsonar.scm.revision=${{ github.event.pull_request.head.sha }} -Dsonar.pullrequest.key=${{ github.event.number }} -Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} - # To trigger verbose when debugging in Github Action -Dsonar.verbose=${{ secrets.ACTIONS_STEP_DEBUG }} From ca66c4d258c151baacea6b360155f26d62593b1d Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 5 Jul 2024 15:58:39 +0200 Subject: [PATCH 23/90] feat(anta): Add support for JSON catalogs (#739) --- anta/catalog.py | 30 ++++++++--- anta/cli/nrfu/__init__.py | 2 + anta/cli/utils.py | 14 ++++- docs/cli/check.md | 10 ++-- docs/snippets/anta_nrfu_help.txt | 6 ++- docs/usage-inventory-catalog.md | 66 ++++++++++++++++++++++- tests/data/test_catalog.json | 11 ++++ tests/data/test_catalog_invalid_json.json | 1 + tests/units/cli/nrfu/test__init__.py | 7 +++ tests/units/test_catalog.py | 30 +++++++++-- 10 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 tests/data/test_catalog.json create mode 100644 tests/data/test_catalog_invalid_json.json diff --git a/anta/catalog.py b/anta/catalog.py index e94b8f750..30bd34066 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -10,14 +10,14 @@ import math from collections import defaultdict from inspect import isclass +from json import load as json_load from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Union -import yaml from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_serializer, model_validator from pydantic.types import ImportString from pydantic_core import PydanticCustomError -from yaml import YAMLError, safe_load +from yaml import YAMLError, safe_dump, safe_load from anta.logger import anta_log_exception from anta.models import AntaTest @@ -238,7 +238,16 @@ def yaml(self) -> str: # This could be improved. # https://github.com/pydantic/pydantic/issues/1043 # Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml - return yaml.safe_dump(yaml.safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf) + return safe_dump(safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf) + + def to_json(self) -> str: + """Return a JSON representation string of this model. + + Returns + ------- + The JSON representation string of this model. + """ + return self.model_dump_json(serialize_as_any=True, exclude_unset=True, indent=2) class AntaCatalog: @@ -298,19 +307,24 @@ def tests(self, value: list[AntaTestDefinition]) -> None: self._tests = value @staticmethod - def parse(filename: str | Path) -> AntaCatalog: + def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") -> AntaCatalog: """Create an AntaCatalog instance from a test catalog file. Parameters ---------- - filename: Path to test catalog YAML file + filename: Path to test catalog YAML or JSON fil + file_format: Format of the file, either 'yaml' or 'json' """ + if file_format not in ["yaml", "json"]: + message = f"'{file_format}' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported." + raise ValueError(message) + try: file: Path = filename if isinstance(filename, Path) else Path(filename) with file.open(encoding="UTF-8") as f: - data = safe_load(f) - except (TypeError, YAMLError, OSError) as e: + data = safe_load(f) if file_format == "yaml" else json_load(f) + except (TypeError, YAMLError, OSError, ValueError) as e: message = f"Unable to parse ANTA Test Catalog file '{filename}'" anta_log_exception(e, message, logger) raise diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 27f8588e7..381f6c1bc 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -116,6 +116,7 @@ def nrfu( ignore_status: bool, ignore_error: bool, dry_run: bool, + catalog_format: str = "yaml", ) -> None: """Run ANTA tests on selected inventory devices.""" # If help is invoke somewhere, skip the command @@ -129,6 +130,7 @@ def nrfu( ctx.obj["ignore_error"] = ignore_error ctx.obj["hide"] = set(hide) if hide else None ctx.obj["catalog"] = catalog + ctx.obj["catalog_format"] = catalog_format ctx.obj["inventory"] = inventory ctx.obj["tags"] = tags ctx.obj["device"] = device diff --git a/anta/cli/utils.py b/anta/cli/utils.py index f769de7a7..6d31e55ae 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -268,7 +268,7 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]: "-c", envvar="ANTA_CATALOG", show_envvar=True, - help="Path to the test catalog YAML file", + help="Path to the test catalog file", type=click.Path( file_okay=True, dir_okay=False, @@ -278,19 +278,29 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]: ), required=True, ) + @click.option( + "--catalog-format", + envvar="ANTA_CATALOG_FORMAT", + show_envvar=True, + help="Format of the catalog file, either 'yaml' or 'json'", + default="yaml", + type=click.Choice(["yaml", "json"], case_sensitive=False), + ) @click.pass_context @functools.wraps(f) def wrapper( ctx: click.Context, *args: tuple[Any], catalog: Path, + catalog_format: str, **kwargs: dict[str, Any], ) -> Any: # If help is invoke somewhere, do not parse catalog if ctx.obj.get("_anta_help"): return f(*args, catalog=None, **kwargs) try: - c = AntaCatalog.parse(catalog) + file_format = catalog_format.lower() + c = AntaCatalog.parse(catalog, file_format=file_format) # type: ignore[arg-type] except (TypeError, ValueError, YAMLError, OSError): ctx.exit(ExitCode.USAGE_ERROR) return f(*args, catalog=c, **kwargs) diff --git a/docs/cli/check.md b/docs/cli/check.md index d7dea620b..257ac73d8 100644 --- a/docs/cli/check.md +++ b/docs/cli/check.md @@ -27,10 +27,12 @@ Commands: ```bash Usage: anta check catalog [OPTIONS] - Check that the catalog is valid + Check that the catalog is valid. Options: - -c, --catalog FILE Path to the test catalog YAML file [env var: - ANTA_CATALOG; required] - --help Show this message and exit. + -c, --catalog FILE Path to the test catalog file [env var: + ANTA_CATALOG; required] + --catalog-format [yaml|json] Format of the catalog file, either 'yaml' or + 'json' [env var: ANTA_CATALOG_FORMAT] + --help Show this message and exit. ``` diff --git a/docs/snippets/anta_nrfu_help.txt b/docs/snippets/anta_nrfu_help.txt index 68cb4b8cb..0717daa9e 100644 --- a/docs/snippets/anta_nrfu_help.txt +++ b/docs/snippets/anta_nrfu_help.txt @@ -29,8 +29,10 @@ Options: ANTA_INVENTORY; required] --tags TEXT List of tags using comma as separator: tag1,tag2,tag3. [env var: ANTA_TAGS] - -c, --catalog FILE Path to the test catalog YAML file [env - var: ANTA_CATALOG; required] + -c, --catalog FILE Path to the test catalog file [env var: + ANTA_CATALOG; required] + --catalog-format [yaml|json] Format of the catalog file, either 'yaml' or + 'json' [env var: ANTA_CATALOG_FORMAT] -d, --device TEXT Run tests on a specific device. Can be provided multiple times. -t, --test TEXT Run a specific test. Can be provided diff --git a/docs/usage-inventory-catalog.md b/docs/usage-inventory-catalog.md index f46993366..fd6aec320 100644 --- a/docs/usage-inventory-catalog.md +++ b/docs/usage-inventory-catalog.md @@ -78,7 +78,7 @@ A test catalog is an instance of the [AntaCatalog](./api/catalog.md#anta.catalog In addition to the inventory file, you also have to define a catalog of tests to execute against your devices. This catalog list all your tests, their inputs and their tags. -A valid test catalog file must have the following structure: +A valid test catalog file must have the following structure in either YAML or JSON: ```yaml --- : @@ -86,6 +86,16 @@ A valid test catalog file must have the following structure: ``` +```json +{ + "": [ + { + "": + } + ] +} +``` + ### Example ```yaml @@ -108,6 +118,43 @@ anta.tests.connectivity: custom_field: "Test run by John Doe" ``` +or equivalent in JSON: + +```json +{ + "anta.tests.connectivity": [ + { + "VerifyReachability": { + "result_overwrite": { + "description": "Test with overwritten description", + "categories": [ + "Overwritten category 1" + ], + "custom_field": "Test run by John Doe" + }, + "filters": { + "tags": [ + "leaf" + ] + }, + "hosts": [ + { + "destination": "1.1.1.1", + "source": "Management0", + "vrf": "MGMT" + }, + { + "destination": "8.8.8.8", + "source": "Management0", + "vrf": "MGMT" + } + ] + } + } + ] +} +``` + It is also possible to nest Python module definition: ```yaml anta.tests: @@ -165,7 +212,7 @@ anta.tests.software: - VerifyEOSVersion: ``` -It will load the test `VerifyEOSVersion` located in `anta.tests.software`. But since this test has mandatory inputs, we need to provide them as a dictionary in the YAML file: +It will load the test `VerifyEOSVersion` located in `anta.tests.software`. But since this test has mandatory inputs, we need to provide them as a dictionary in the YAML or JSON file: ```yaml anta.tests.software: @@ -176,6 +223,21 @@ anta.tests.software: - 4.26.1F ``` +```json +{ + "anta.tests.software": [ + { + "VerifyEOSVersion": { + "versions": [ + "4.25.4M", + "4.31.1F" + ] + } + } + ] +} +``` + The following example is a very minimal test catalog: ```yaml diff --git a/tests/data/test_catalog.json b/tests/data/test_catalog.json new file mode 100644 index 000000000..298fcb4f4 --- /dev/null +++ b/tests/data/test_catalog.json @@ -0,0 +1,11 @@ +{ + "anta.tests.software": [ + { + "VerifyEOSVersion": { + "versions": [ + "4.31.1F" + ] + } + } + ] +} diff --git a/tests/data/test_catalog_invalid_json.json b/tests/data/test_catalog_invalid_json.json new file mode 100644 index 000000000..65b8c5bec --- /dev/null +++ b/tests/data/test_catalog_invalid_json.json @@ -0,0 +1 @@ +{aasas"anta.tests.software":[{"VerifyEOSVersion":{"versions":["4.31.1F"]}}]} diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py index a9dcd9cb8..83369f344 100644 --- a/tests/units/cli/nrfu/test__init__.py +++ b/tests/units/cli/nrfu/test__init__.py @@ -49,6 +49,13 @@ def test_anta_nrfu_dry_run(click_runner: CliRunner) -> None: assert "Dry-run" in result.output +def test_anta_nrfu_wrong_catalog_format(click_runner: CliRunner) -> None: + """Test anta nrfu --dry-run, catalog is given via env.""" + result = click_runner.invoke(anta, ["nrfu", "--dry-run", "--catalog-format", "toto"]) + assert result.exit_code == ExitCode.USAGE_ERROR + assert "Invalid value for '--catalog-format': 'toto' is not one of 'yaml', 'json'." in result.output + + def test_anta_password_required(click_runner: CliRunner) -> None: """Test that password is provided.""" env = default_anta_env() diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py index 1c7ca8a0b..76358dd4a 100644 --- a/tests/units/test_catalog.py +++ b/tests/units/test_catalog.py @@ -5,6 +5,7 @@ from __future__ import annotations +from json import load as json_load from pathlib import Path from typing import Any @@ -42,6 +43,14 @@ (VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"])), ], }, + { + "name": "test_catalog", + "filename": "test_catalog.json", + "file_format": "json", + "tests": [ + (VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"])), + ], + }, { "name": "test_catalog_with_tags", "filename": "test_catalog_with_tags.yml", @@ -83,6 +92,18 @@ }, ] CATALOG_PARSE_FAIL_DATA: list[dict[str, Any]] = [ + { + "name": "undefined_tests", + "filename": "test_catalog_wrong_format.toto", + "file_format": "toto", + "error": "'toto' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported.", + }, + { + "name": "invalid_json", + "filename": "test_catalog_invalid_json.json", + "file_format": "json", + "error": "JSONDecodeError", + }, { "name": "undefined_tests", "filename": "test_catalog_with_undefined_tests.yml", @@ -185,7 +206,7 @@ class TestAntaCatalog: @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) def test_parse(self, catalog_data: dict[str, Any]) -> None: """Instantiate AntaCatalog from a file.""" - catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / catalog_data["filename"]) + catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / catalog_data["filename"], file_format=catalog_data.get("file_format", "yaml")) assert len(catalog.tests) == len(catalog_data["tests"]) for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]): @@ -211,7 +232,8 @@ def test_from_dict(self, catalog_data: dict[str, Any]) -> None: """Instantiate AntaCatalog from a dict.""" file = DATA_DIR / catalog_data["filename"] with file.open(encoding="UTF-8") as file: - data = safe_load(file) + file_format = catalog_data.get("file_format", "yaml") + data = safe_load(file) if file_format == "yaml" else json_load(file) catalog: AntaCatalog = AntaCatalog.from_dict(data) assert len(catalog.tests) == len(catalog_data["tests"]) @@ -224,8 +246,8 @@ def test_from_dict(self, catalog_data: dict[str, Any]) -> None: @pytest.mark.parametrize("catalog_data", CATALOG_PARSE_FAIL_DATA, ids=generate_test_ids_list(CATALOG_PARSE_FAIL_DATA)) def test_parse_fail(self, catalog_data: dict[str, Any]) -> None: """Errors when instantiating AntaCatalog from a file.""" - with pytest.raises((ValidationError, TypeError)) as exec_info: - AntaCatalog.parse(DATA_DIR / catalog_data["filename"]) + with pytest.raises((ValidationError, TypeError, ValueError, OSError)) as exec_info: + AntaCatalog.parse(DATA_DIR / catalog_data["filename"], file_format=catalog_data.get("file_format", "yaml")) if isinstance(exec_info.value, ValidationError): assert catalog_data["error"] in exec_info.value.errors()[0]["msg"] else: From 327201151c3f4fa1aedaf153c58fbbe800147efc Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 5 Jul 2024 17:35:39 +0200 Subject: [PATCH 24/90] ci: Checking out the PR --- .github/workflows/sonar.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 109828071..81db36e58 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -14,10 +14,12 @@ on: jobs: sonarcloud: name: Run Sonarlint analysis and upload to SonarCloud. + if: github.repository == 'aristanetworks/anta' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Setup Python uses: actions/setup-python@v5 From 552cdf6bcca31c198c841d3990ed9b4cafe37fbd Mon Sep 17 00:00:00 2001 From: Thomas Grimonet Date: Mon, 8 Jul 2024 09:54:00 +0200 Subject: [PATCH 25/90] fix(anta.cli): Update --hide description under anta nrfu (#750) --- anta/cli/nrfu/__init__.py | 2 +- docs/snippets/anta_nrfu_help.txt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 381f6c1bc..596654686 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -92,7 +92,7 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: default=None, type=click.Choice(HIDE_STATUS, case_sensitive=False), multiple=True, - help="Group result by test or device.", + help="Hide results by type: success / failure / error / skipped'.", required=False, ) @click.option( diff --git a/docs/snippets/anta_nrfu_help.txt b/docs/snippets/anta_nrfu_help.txt index 0717daa9e..48bb15b9b 100644 --- a/docs/snippets/anta_nrfu_help.txt +++ b/docs/snippets/anta_nrfu_help.txt @@ -43,7 +43,8 @@ Options: or 1 if any test failed. [env var: ANTA_NRFU_IGNORE_ERROR] --hide [success|failure|error|skipped] - Group result by test or device. + Hide result by type: success / failure / + error / skipped'. --dry-run Run anta nrfu command but stop before starting to execute the tests. Considers all devices as connected. [env var: From e8e2038d8792223c560a7d8f2416bed928e368ed Mon Sep 17 00:00:00 2001 From: Thomas Grimonet Date: Mon, 8 Jul 2024 11:34:38 +0200 Subject: [PATCH 26/90] fix: Add missing testcases in example folder (#749) * fix: Add missing testcases for ISIS in example folder * fix: Add missing testcases for anta.tests.configuration.VerifyRunningConfigLines in example folder * doc: Update example README with missing files * Apply suggestions from code review --------- Co-authored-by: Guillaume Mulocher --- examples/README.md | 24 +++++++++++++++++++++++- examples/tests.yaml | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/examples/README.md b/examples/README.md index 0800b79c9..b07cef28b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,12 +1,34 @@ +# ANTA Example files + +This section provides some examples about how to use ANTA as listed in [the documentation](https://anta.arista.com) + ## Device Inventory +- Filename: [`inventory.yaml`](./inventory.yaml) + The file [inventory.yaml](inventory.yaml) is an example of [device inventory](https://anta.arista.com/stable/usage-inventory-catalog/#create-an-inventory-file). ## Test Catalog +- Filename: [`tests.yaml`](./tests.yaml) + The file [tests.yaml](tests.yaml) is an example of a [test catalog](https://anta.arista.com/stable/usage-inventory-catalog/#test-catalog). This file should contain all the tests implemented in [anta.tests](../anta/tests) with arbitrary parameters. -## eos-commands.yaml file +## Commands to get from snapshot + +- Filename: [`eos-commands.yaml file`](./eos-commands.yaml) The file [eos-commands.yaml](eos-commands.yaml) is an example of input given with the `--commands-list` option to the [anta exec snapshot](https://anta.arista.com/stable/cli/exec/#collect-a-set-of-commands) command. + +## ANTA runner in Python + +- Filename: [`anta_runner.py`](./anta_runner.py) + +The file is an example demonstrating how to run ANTA using a python script + +## ANTA template for results rendering + +- Filename: [`template.j2`](./template.j2) + +This file is a simple Jinja2 file to customize ANTA CLI output as documented in [anta documentation](https://anta.arista.com/stable/cli/nrfu/#performing-nrfu-with-custom-reports) \ No newline at end of file diff --git a/examples/tests.yaml b/examples/tests.yaml index 83a9523ed..4d09fa14a 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -87,6 +87,10 @@ anta.tests.bfd: anta.tests.configuration: - VerifyZeroTouch: - VerifyRunningConfigDiffs: + - VerifyRunningConfigLines: + regex_patterns: + - "^enable password.*$" + - "bla bla" anta.tests.connectivity: - VerifyReachability: @@ -574,4 +578,31 @@ anta.tests.routing: - name: Ethernet1 mode: point-to-point vrf: default - # level is set to 2 by default \ No newline at end of file + # level is set to 2 by default + - VerifyISISSegmentRoutingAdjacencySegments: + instances: + - name: CORE-ISIS + vrf: default + segments: + - interface: Ethernet2 + address: 10.0.1.3 + sid_origin: dynamic + - VerifyISISSegmentRoutingDataplane: + instances: + - name: CORE-ISIS + vrf: default + dataplane: MPLS + - VerifyISISSegmentRoutingTunnels: + entries: + # Check only endpoint + - endpoint: 1.0.0.122/32 + # Check endpoint and via TI-LFA + - endpoint: 1.0.0.13/32 + vias: + - type: tunnel + tunnel_id: ti-lfa + # Check endpoint and via IP routers + - endpoint: 1.0.0.14/32 + vias: + - type: ip + nexthop: 1.1.1.1 From 2594c1cf9e8420b87b7297db0d60514070fbb82d Mon Sep 17 00:00:00 2001 From: Mahesh Kumar <122076792+MaheshGSLAB@users.noreply.github.com> Date: Mon, 8 Jul 2024 17:35:37 +0530 Subject: [PATCH 27/90] feat(anta.tests): Added testcase to validate hardware flow tracker (#722) issue-710: Added the validation for hardware flow tracker --- anta/tests/flow_tracking.py | 186 +++++++++ docs/api/tests.flow_tracking.md | 20 + docs/api/tests.md | 1 + examples/tests.yaml | 15 + mkdocs.yml | 1 + tests/units/anta_tests/test_flow_tracking.py | 391 +++++++++++++++++++ 6 files changed, 614 insertions(+) create mode 100644 anta/tests/flow_tracking.py create mode 100644 docs/api/tests.flow_tracking.md create mode 100644 tests/units/anta_tests/test_flow_tracking.py diff --git a/anta/tests/flow_tracking.py b/anta/tests/flow_tracking.py new file mode 100644 index 000000000..bab8860e6 --- /dev/null +++ b/anta/tests/flow_tracking.py @@ -0,0 +1,186 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module related to the flow tracking tests.""" + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import ClassVar + +from pydantic import BaseModel + +from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools import get_failed_logs + + +def validate_record_export(record_export: dict[str, str], tracker_info: dict[str, str]) -> str: + """ + Validate the record export configuration against the tracker info. + + Args: + record_export (dict): The expected record export configuration. + tracker_info (dict): The actual tracker info from the command output. + + Returns + ------- + str : A failure message if the record export configuration does not match, otherwise blank string. + """ + failed_log = "" + actual_export = {"inactive timeout": tracker_info.get("inactiveTimeout"), "interval": tracker_info.get("activeInterval")} + expected_export = {"inactive timeout": record_export.get("on_inactive_timeout"), "interval": record_export.get("on_interval")} + if actual_export != expected_export: + failed_log = get_failed_logs(expected_export, actual_export) + return failed_log + + +def validate_exporters(exporters: list[dict[str, str]], tracker_info: dict[str, str]) -> str: + """ + Validate the exporter configurations against the tracker info. + + Args: + exporters (list[dict]): The list of expected exporter configurations. + tracker_info (dict): The actual tracker info from the command output. + + Returns + ------- + str: Failure message if any exporter configuration does not match. + """ + failed_log = "" + for exporter in exporters: + exporter_name = exporter["name"] + actual_exporter_info = tracker_info["exporters"].get(exporter_name) + if not actual_exporter_info: + failed_log += f"\nExporter `{exporter_name}` is not configured." + continue + + expected_exporter_data = {"local interface": exporter["local_interface"], "template interval": exporter["template_interval"]} + actual_exporter_data = {"local interface": actual_exporter_info["localIntf"], "template interval": actual_exporter_info["templateInterval"]} + + if expected_exporter_data != actual_exporter_data: + failed_msg = get_failed_logs(expected_exporter_data, actual_exporter_data) + failed_log += f"\nExporter `{exporter_name}`: {failed_msg}" + return failed_log + + +class VerifyHardwareFlowTrackerStatus(AntaTest): + """ + Verifies if hardware flow tracking is running and an input tracker is active. + + This test optionally verifies the tracker interval/timeout and exporter configuration. + + Expected Results + ---------------- + * Success: The test will pass if hardware flow tracking is running and an input tracker is active. + * Failure: The test will fail if hardware flow tracking is not running, an input tracker is not active, + or the tracker interval/timeout and exporter configuration does not match the expected values. + + Examples + -------- + ```yaml + anta.tests.flow_tracking: + - VerifyFlowTrackingHardware: + trackers: + - name: FLOW-TRACKER + record_export: + on_inactive_timeout: 70000 + on_interval: 300000 + exporters: + - name: CV-TELEMETRY + local_interface: Loopback0 + template_interval: 3600000 + ``` + """ + + name = "VerifyHardwareFlowTrackerStatus" + description = ( + "Verifies if hardware flow tracking is running and an input tracker is active. Optionally verifies the tracker interval/timeout and exporter configuration." + ) + categories: ClassVar[list[str]] = ["flow tracking"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show flow tracking hardware tracker {name}", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyHardwareFlowTrackerStatus test.""" + + trackers: list[FlowTracker] + """List of flow trackers to verify.""" + + class FlowTracker(BaseModel): + """Detail of a flow tracker.""" + + name: str + """Name of the flow tracker.""" + + record_export: RecordExport | None = None + """Record export configuration for the flow tracker.""" + + exporters: list[Exporter] | None = None + """List of exporters for the flow tracker.""" + + class RecordExport(BaseModel): + """Record export configuration.""" + + on_inactive_timeout: int + """Timeout in milliseconds for exporting records when inactive.""" + + on_interval: int + """Interval in milliseconds for exporting records.""" + + class Exporter(BaseModel): + """Detail of an exporter.""" + + name: str + """Name of the exporter.""" + + local_interface: str + """Local interface used by the exporter.""" + + template_interval: int + """Template interval in milliseconds for the exporter.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each hardware tracker.""" + return [template.render(name=tracker.name) for tracker in self.inputs.trackers] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyHardwareFlowTrackerStatus.""" + self.result.is_success() + for command, tracker_input in zip(self.instance_commands, self.inputs.trackers): + hardware_tracker_name = command.params.name + record_export = tracker_input.record_export.model_dump() if tracker_input.record_export else None + exporters = [exporter.model_dump() for exporter in tracker_input.exporters] if tracker_input.exporters else None + command_output = command.json_output + + # Check if hardware flow tracking is configured + if not command_output.get("running"): + self.result.is_failure("Hardware flow tracking is not running.") + return + + # Check if the input hardware tracker is configured + tracker_info = command_output["trackers"].get(hardware_tracker_name) + if not tracker_info: + self.result.is_failure(f"Hardware flow tracker `{hardware_tracker_name}` is not configured.") + continue + + # Check if the input hardware tracker is active + if not tracker_info.get("active"): + self.result.is_failure(f"Hardware flow tracker `{hardware_tracker_name}` is not active.") + continue + + # Check the input hardware tracker timeouts + failure_msg = "" + if record_export: + record_export_failure = validate_record_export(record_export, tracker_info) + if record_export_failure: + failure_msg += record_export_failure + + # Check the input hardware tracker exporters' configuration + if exporters: + exporters_failure = validate_exporters(exporters, tracker_info) + if exporters_failure: + failure_msg += exporters_failure + + if failure_msg: + self.result.is_failure(f"{hardware_tracker_name}: {failure_msg}\n") diff --git a/docs/api/tests.flow_tracking.md b/docs/api/tests.flow_tracking.md new file mode 100644 index 000000000..0df0b1dc8 --- /dev/null +++ b/docs/api/tests.flow_tracking.md @@ -0,0 +1,20 @@ +--- +anta_title: ANTA catalog for flow tracking tests +--- + + +::: anta.tests.flow_tracking + options: + show_root_heading: false + show_root_toc_entry: false + show_bases: false + merge_init_into_class: false + anta_hide_test_module_description: true + show_labels: true + filters: + - "!test" + - "!render" diff --git a/docs/api/tests.md b/docs/api/tests.md index b11f0badb..2775a01ee 100644 --- a/docs/api/tests.md +++ b/docs/api/tests.md @@ -18,6 +18,7 @@ Here are the tests that we currently provide: - [Configuration](tests.configuration.md) - [Connectivity](tests.connectivity.md) - [Field Notice](tests.field_notices.md) +- [Flow Tracking](tests.flow_tracking.md) - [GreenT](tests.greent.md) - [Hardware](tests.hardware.md) - [Interfaces](tests.interfaces.md) diff --git a/examples/tests.yaml b/examples/tests.yaml index 4d09fa14a..c0ab625bf 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -114,6 +114,21 @@ anta.tests.field_notices: - VerifyFieldNotice44Resolution: - VerifyFieldNotice72Resolution: +anta.tests.flow_tracking: + - VerifyHardwareFlowTrackerStatus: + trackers: + - name: FLOW-TRACKER + record_export: + on_inactive_timeout: 700000 + on_interval: 3000000 + exporters: + - name: CV-TELEMETRY + local_interface: Loopback11 + template_interval: 3600 + - name: CVP-TELEMETRY + local_interface: Loopback01 + template_interval: 36000000 + anta.tests.greent: - VerifyGreenT: - VerifyGreenTCounters: diff --git a/mkdocs.yml b/mkdocs.yml index db08e737d..291fb2bda 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -193,6 +193,7 @@ nav: - Configuration: api/tests.configuration.md - Connectivity: api/tests.connectivity.md - Field Notices: api/tests.field_notices.md + - Flow Tracking: api/test.flow_tracking.md - GreenT: api/tests.greent.md - Hardware: api/tests.hardware.md - Interfaces: api/tests.interfaces.md diff --git a/tests/units/anta_tests/test_flow_tracking.py b/tests/units/anta_tests/test_flow_tracking.py new file mode 100644 index 000000000..21b47222a --- /dev/null +++ b/tests/units/anta_tests/test_flow_tracking.py @@ -0,0 +1,391 @@ +# Copyright (c) 2023-2024 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 inputs for anta.tests.flow_tracking.""" + +from __future__ import annotations + +from typing import Any + +from anta.tests.flow_tracking import VerifyHardwareFlowTrackerStatus +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyHardwareFlowTrackerStatus, + "eos_data": [ + { + "trackers": { + "FLOW-TRACKER": { + "active": True, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + { + "trackers": { + "HARDWARE-TRACKER": { + "active": True, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + ], + "inputs": {"trackers": [{"name": "FLOW-TRACKER"}, {"name": "HARDWARE-TRACKER"}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-with-optional-field", + "test": VerifyHardwareFlowTrackerStatus, + "eos_data": [ + { + "trackers": { + "FLOW-TRACKER": { + "active": True, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + { + "trackers": { + "HARDWARE-TRACKER": { + "active": True, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + ], + "inputs": { + "trackers": [ + { + "name": "FLOW-TRACKER", + "record_export": {"on_inactive_timeout": 60000, "on_interval": 300000}, + "exporters": [{"name": "CV-TELEMETRY", "local_interface": "Loopback0", "template_interval": 3600000}], + }, + { + "name": "HARDWARE-TRACKER", + "record_export": {"on_inactive_timeout": 60000, "on_interval": 300000}, + "exporters": [{"name": "CVP-TELEMETRY", "local_interface": "Loopback10", "template_interval": 3600000}], + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-flow-tracking-not-running", + "test": VerifyHardwareFlowTrackerStatus, + "eos_data": [{"trackers": {}, "running": False}], + "inputs": {"trackers": [{"name": "FLOW-TRACKER"}]}, + "expected": { + "result": "failure", + "messages": ["Hardware flow tracking is not running."], + }, + }, + { + "name": "failure-tracker-not-configured", + "test": VerifyHardwareFlowTrackerStatus, + "eos_data": [ + { + "trackers": { + "HARDWARE-TRACKER": { + "active": True, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}}, + } + }, + "running": True, + } + ], + "inputs": {"trackers": [{"name": "FLOW-Sample"}]}, + "expected": { + "result": "failure", + "messages": ["Hardware flow tracker `FLOW-Sample` is not configured."], + }, + }, + { + "name": "failure-tracker-not-active", + "test": VerifyHardwareFlowTrackerStatus, + "eos_data": [ + { + "trackers": { + "FLOW-TRACKER": { + "active": False, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + { + "trackers": { + "HARDWARE-TRACKER": { + "active": False, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + ], + "inputs": { + "trackers": [ + { + "name": "FLOW-TRACKER", + "record_export": {"on_inactive_timeout": 60000, "on_interval": 300000}, + "exporters": [{"name": "CV-TELEMETRY", "local_interface": "Loopback0", "template_interval": 3600000}], + }, + { + "name": "HARDWARE-TRACKER", + "record_export": {"on_inactive_timeout": 60000, "on_interval": 300000}, + "exporters": [{"name": "CVP-TELEMETRY", "local_interface": "Loopback10", "template_interval": 3600000}], + }, + ] + }, + "expected": { + "result": "failure", + "messages": ["Hardware flow tracker `FLOW-TRACKER` is not active.", "Hardware flow tracker `HARDWARE-TRACKER` is not active."], + }, + }, + { + "name": "failure-incorrect-record-export", + "test": VerifyHardwareFlowTrackerStatus, + "eos_data": [ + { + "trackers": { + "FLOW-TRACKER": { + "active": True, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + { + "trackers": { + "HARDWARE-TRACKER": { + "active": True, + "inactiveTimeout": 6000, + "activeInterval": 30000, + "exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + ], + "inputs": { + "trackers": [ + { + "name": "FLOW-TRACKER", + "record_export": {"on_inactive_timeout": 6000, "on_interval": 30000}, + }, + { + "name": "HARDWARE-TRACKER", + "record_export": {"on_inactive_timeout": 60000, "on_interval": 300000}, + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "FLOW-TRACKER: \n" + "Expected `6000` as the inactive timeout, but found `60000` instead.\nExpected `30000` as the interval, but found `300000` instead.\n", + "HARDWARE-TRACKER: \n" + "Expected `60000` as the inactive timeout, but found `6000` instead.\nExpected `300000` as the interval, but found `30000` instead.\n", + ], + }, + }, + { + "name": "failure-incorrect-exporters", + "test": VerifyHardwareFlowTrackerStatus, + "eos_data": [ + { + "trackers": { + "FLOW-TRACKER": { + "active": True, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": { + "CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}, + "CVP-FLOW": {"localIntf": "Loopback0", "templateInterval": 3600000}, + }, + } + }, + "running": True, + }, + { + "trackers": { + "HARDWARE-TRACKER": { + "active": True, + "inactiveTimeout": 6000, + "activeInterval": 30000, + "exporters": { + "CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}, + "Hardware-flow": {"localIntf": "Loopback10", "templateInterval": 3600000}, + }, + } + }, + "running": True, + }, + ], + "inputs": { + "trackers": [ + { + "name": "FLOW-TRACKER", + "exporters": [ + {"name": "CV-TELEMETRY", "local_interface": "Loopback0", "template_interval": 3600000}, + {"name": "CVP-FLOW", "local_interface": "Loopback10", "template_interval": 3500000}, + ], + }, + { + "name": "HARDWARE-TRACKER", + "exporters": [ + {"name": "Hardware-flow", "local_interface": "Loopback99", "template_interval": 3000000}, + {"name": "Reverse-flow", "local_interface": "Loopback101", "template_interval": 3000000}, + ], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "FLOW-TRACKER: \n" + "Exporter `CVP-FLOW`: \n" + "Expected `Loopback10` as the local interface, but found `Loopback0` instead.\n" + "Expected `3500000` as the template interval, but found `3600000` instead.\n", + "HARDWARE-TRACKER: \n" + "Exporter `Hardware-flow`: \n" + "Expected `Loopback99` as the local interface, but found `Loopback10` instead.\n" + "Expected `3000000` as the template interval, but found `3600000` instead.\n" + "Exporter `Reverse-flow` is not configured.\n", + ], + }, + }, + { + "name": "failure-all-type", + "test": VerifyHardwareFlowTrackerStatus, + "eos_data": [ + { + "trackers": { + "HARDWARE-TRACKER": { + "active": True, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + { + "trackers": { + "FLOW-TRIGGER": { + "active": False, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + { + "trackers": { + "HARDWARE-FLOW": { + "active": True, + "inactiveTimeout": 6000, + "activeInterval": 30000, + "exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}}, + } + }, + "running": True, + }, + { + "trackers": { + "FLOW-TRACKER2": { + "active": True, + "inactiveTimeout": 60000, + "activeInterval": 300000, + "exporters": { + "CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}, + "CVP-FLOW": {"localIntf": "Loopback0", "templateInterval": 3600000}, + }, + } + }, + "running": True, + }, + { + "trackers": { + "HARDWARE-TRACKER2": { + "active": True, + "inactiveTimeout": 6000, + "activeInterval": 30000, + "exporters": { + "CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}, + "Hardware-flow": {"localIntf": "Loopback10", "templateInterval": 3600000}, + }, + } + }, + "running": True, + }, + ], + "inputs": { + "trackers": [ + {"name": "FLOW-Sample"}, + { + "name": "FLOW-TRIGGER", + "record_export": {"on_inactive_timeout": 60000, "on_interval": 300000}, + "exporters": [{"name": "CV-TELEMETRY", "local_interface": "Loopback0", "template_interval": 3600000}], + }, + { + "name": "HARDWARE-FLOW", + "record_export": {"on_inactive_timeout": 60000, "on_interval": 300000}, + }, + { + "name": "FLOW-TRACKER2", + "exporters": [ + {"name": "CV-TELEMETRY", "local_interface": "Loopback0", "template_interval": 3600000}, + {"name": "CVP-FLOW", "local_interface": "Loopback10", "template_interval": 3500000}, + ], + }, + { + "name": "HARDWARE-TRACKER2", + "exporters": [ + {"name": "Hardware-flow", "local_interface": "Loopback99", "template_interval": 3000000}, + {"name": "Reverse-flow", "local_interface": "Loopback101", "template_interval": 3000000}, + ], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Hardware flow tracker `FLOW-Sample` is not configured.", + "Hardware flow tracker `FLOW-TRIGGER` is not active.", + "HARDWARE-FLOW: \n" + "Expected `60000` as the inactive timeout, but found `6000` instead.\nExpected `300000` as the interval, but found `30000` instead.\n", + "FLOW-TRACKER2: \nExporter `CVP-FLOW`: \n" + "Expected `Loopback10` as the local interface, but found `Loopback0` instead.\n" + "Expected `3500000` as the template interval, but found `3600000` instead.\n", + "HARDWARE-TRACKER2: \nExporter `Hardware-flow`: \n" + "Expected `Loopback99` as the local interface, but found `Loopback10` instead.\n" + "Expected `3000000` as the template interval, but found `3600000` instead.\n" + "Exporter `Reverse-flow` is not configured.\n", + ], + }, + }, +] From 9735efb29d4594ab234ed4ba3533b3c9d2b29a5e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:23:49 +0200 Subject: [PATCH 28/90] bump: pre-commit autoupdate (#751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.0 → v0.5.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.0...v0.5.1) - [github.com/pycqa/pylint: v3.2.4 → v3.2.5](https://github.com/pycqa/pylint/compare/v3.2.4...v3.2.5) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 48b24a9b7..cd451b16e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.5.1 hooks: - id: ruff name: Run Ruff linter @@ -52,7 +52,7 @@ repos: name: Run Ruff formatter - repo: https://github.com/pycqa/pylint - rev: "v3.2.4" + rev: "v3.2.5" hooks: - id: pylint name: Check code style with pylint From 25ec15f92866e2919c2ad5c968a213df4bdff439 Mon Sep 17 00:00:00 2001 From: Thomas Grimonet Date: Wed, 17 Jul 2024 15:46:58 +0200 Subject: [PATCH 29/90] feat(anta): Add support of CSV file export (#672) --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Guillaume Mulocher Co-authored-by: Carl Baillargeon --- anta/cli/nrfu/__init__.py | 1 + anta/cli/nrfu/commands.py | 29 +++++-- anta/cli/nrfu/utils.py | 12 +++ anta/reporter/__init__.py | 41 ++++++---- anta/reporter/csv_reporter.py | 109 ++++++++++++++++++++++++++ docs/cli/nrfu.md | 22 ++++++ docs/imgs/anta_nrfu_csv.png | Bin 0 -> 193322 bytes docs/snippets/anta_nrfu_help.txt | 3 +- tests/units/cli/nrfu/test_commands.py | 20 +++++ tests/units/reporter/test_csv.py | 93 ++++++++++++++++++++++ 10 files changed, 309 insertions(+), 21 deletions(-) create mode 100644 anta/reporter/csv_reporter.py create mode 100644 docs/imgs/anta_nrfu_csv.png create mode 100644 tests/units/reporter/test_csv.py diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 596654686..a85277102 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -143,6 +143,7 @@ def nrfu( nrfu.add_command(commands.table) +nrfu.add_command(commands.csv) nrfu.add_command(commands.json) nrfu.add_command(commands.text) nrfu.add_command(commands.tpl_report) diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index 7581116c6..cd750cb85 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -13,7 +13,7 @@ from anta.cli.utils import exit_with_code -from .utils import print_jinja, print_json, print_table, print_text, run_tests +from .utils import print_jinja, print_json, print_table, print_text, run_tests, save_to_csv logger = logging.getLogger(__name__) @@ -27,10 +27,7 @@ help="Group result by test or device.", required=False, ) -def table( - ctx: click.Context, - group_by: Literal["device", "test"] | None, -) -> None: +def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> None: """ANTA command to check network states with table result.""" run_tests(ctx) print_table(ctx, group_by=group_by) @@ -63,6 +60,28 @@ def text(ctx: click.Context) -> None: exit_with_code(ctx) +@click.command() +@click.pass_context +@click.option( + "--csv-output", + type=click.Path( + file_okay=True, + dir_okay=False, + exists=False, + writable=True, + path_type=pathlib.Path, + ), + show_envvar=True, + required=False, + help="Path to save report as a CSV file", +) +def csv(ctx: click.Context, csv_output: pathlib.Path) -> None: + """ANTA command to check network states with CSV result.""" + run_tests(ctx) + save_to_csv(ctx, csv_file=csv_output) + exit_with_code(ctx) + + @click.command() @click.pass_context @click.option( diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index d4cd1317d..284c9b709 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -15,8 +15,10 @@ from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn from anta.cli.console import console +from anta.cli.utils import ExitCode from anta.models import AntaTest from anta.reporter import ReportJinja, ReportTable +from anta.reporter.csv_reporter import ReportCsv from anta.runner import main if TYPE_CHECKING: @@ -122,6 +124,16 @@ def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib. file.write(report) +def save_to_csv(ctx: click.Context, csv_file: pathlib.Path) -> None: + """Save results to a CSV file.""" + try: + ReportCsv.generate(results=_get_result_manager(ctx), csv_filename=csv_file) + console.print(f"CSV report saved to {csv_file} ✅", style="cyan") + except OSError: + console.print(f"Failed to save CSV report to {csv_file} ❌", style="cyan") + ctx.exit(ExitCode.USAGE_ERROR) + + # Adding our own ANTA spinner - overriding rich SPINNERS for our own # so ignore warning for redefinition rich.spinner.SPINNERS = { # type: ignore[attr-defined] diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index 685608dc2..7c911f243 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from jinja2 import Template @@ -27,6 +28,19 @@ class ReportTable: """TableReport Generate a Table based on TestResult.""" + @dataclass() + class Headers: # pylint: disable=too-many-instance-attributes + """Headers for the table report.""" + + device: str = "Device" + test_case: str = "Test Name" + number_of_success: str = "# of success" + number_of_failure: str = "# of failure" + number_of_skipped: str = "# of skipped" + number_of_errors: str = "# of errors" + list_of_error_nodes: str = "List of failed or error nodes" + list_of_error_tests: str = "List of failed or error test cases" + def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str: """Split list to multi-lines string. @@ -62,9 +76,6 @@ def _build_headers(self, headers: list[str], table: Table) -> Table: for idx, header in enumerate(headers): if idx == 0: table.add_column(header, justify="left", style=RICH_COLOR_PALETTE.HEADER, no_wrap=True) - elif header == "Test Name": - # We always want the full test name - table.add_column(header, justify="left", no_wrap=True) else: table.add_column(header, justify="left") return table @@ -135,12 +146,12 @@ def report_summary_tests( """ table = Table(title=title, show_lines=True) headers = [ - "Test Case", - "# of success", - "# of skipped", - "# of failure", - "# of errors", - "List of failed or error nodes", + self.Headers.test_case, + self.Headers.number_of_success, + self.Headers.number_of_skipped, + self.Headers.number_of_failure, + self.Headers.number_of_errors, + self.Headers.list_of_error_nodes, ] table = self._build_headers(headers=headers, table=table) for test in manager.get_tests(): @@ -183,12 +194,12 @@ def report_summary_devices( """ table = Table(title=title, show_lines=True) headers = [ - "Device", - "# of success", - "# of skipped", - "# of failure", - "# of errors", - "List of failed or error test cases", + self.Headers.device, + self.Headers.number_of_success, + self.Headers.number_of_skipped, + self.Headers.number_of_failure, + self.Headers.number_of_errors, + self.Headers.list_of_error_tests, ] table = self._build_headers(headers=headers, table=table) for device in manager.get_devices(): diff --git a/anta/reporter/csv_reporter.py b/anta/reporter/csv_reporter.py new file mode 100644 index 000000000..221cbec81 --- /dev/null +++ b/anta/reporter/csv_reporter.py @@ -0,0 +1,109 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""CSV Report management for ANTA.""" + +# pylint: disable = too-few-public-methods +from __future__ import annotations + +import csv +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from anta.logger import anta_log_exception + +if TYPE_CHECKING: + import pathlib + + from anta.result_manager import ResultManager + from anta.result_manager.models import TestResult + +logger = logging.getLogger(__name__) + + +class ReportCsv: + """Build a CSV report.""" + + @dataclass() + class Headers: + """Headers for the CSV report.""" + + device: str = "Device" + test_name: str = "Test Name" + test_status: str = "Test Status" + messages: str = "Message(s)" + description: str = "Test description" + categories: str = "Test category" + + @classmethod + def split_list_to_txt_list(cls, usr_list: list[str], delimiter: str = " - ") -> str: + """Split list to multi-lines string. + + Parameters + ---------- + usr_list: List of string to concatenate + delimiter: A delimiter to use to start string. Defaults to None. + + Returns + ------- + str: Multi-lines string + + """ + return f"{delimiter}".join(f"{line}" for line in usr_list) + + @classmethod + def convert_to_list(cls, result: TestResult) -> list[str]: + """ + Convert a TestResult into a list of string for creating file content. + + Args: + ---- + results: A TestResult to convert into list. + """ + message = cls.split_list_to_txt_list(result.messages) if len(result.messages) > 0 else "" + categories = cls.split_list_to_txt_list(result.categories) if len(result.categories) > 0 else "None" + return [ + str(result.name), + result.test, + result.result, + message, + result.description, + categories, + ] + + @classmethod + def generate(cls, results: ResultManager, csv_filename: pathlib.Path) -> None: + """Build CSV flle with tests results. + + Parameter + --------- + results: A ResultManager instance. + csv_filename: File path where to save CSV data. + + Raise + ----- + OSError if any is raised while writing the CSV file. + """ + headers = [ + cls.Headers.device, + cls.Headers.test_name, + cls.Headers.test_status, + cls.Headers.messages, + cls.Headers.description, + cls.Headers.categories, + ] + + try: + with csv_filename.open(mode="w", encoding="utf-8") as csvfile: + csvwriter = csv.writer( + csvfile, + delimiter=",", + ) + csvwriter.writerow(headers) + for entry in results.results: + csvwriter.writerow(cls.convert_to_list(entry)) + except OSError as exc: + message = f"OSError caught while writing the CSV file '{csv_filename.resolve()}'." + anta_log_exception(exc, message, logger) + raise diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index 90b4a4045..afed25949 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -145,6 +145,28 @@ anta nrfu --tags LEAF json ``` ![$1anta nrfu json results](../imgs/anta-nrfu-json-output.png){ loading=lazy width="1600" } +## Performing NRFU and saving results in a CSV file. + +The `csv` command in NRFU testing is useful for generating a CSV file with all tests result. This file can be easily analyzed and filtered by operator for reporting purposes. + +### Command overview + +```bash +anta nrfu csv --help +Usage: anta nrfu csv [OPTIONS] + + ANTA command to check network states with CSV result. + +Options: + --csv-output FILE Path to save report as a CSV file [env var: + ANTA_NRFU_CSV_CSV_OUTPUT] + --help Show this message and exit. +``` + +### Example + +![anta nrfu csv results](../imgs/anta_nrfu_csv.png){ loading=lazy width="1600" } + ## Performing NRFU with custom reports ANTA offers a CLI option for creating custom reports. This leverages the Jinja2 template system, allowing you to tailor reports to your specific needs. diff --git a/docs/imgs/anta_nrfu_csv.png b/docs/imgs/anta_nrfu_csv.png new file mode 100644 index 0000000000000000000000000000000000000000..5e4129aadd7f2842ccf6db82cbf4e1ba7b0d6be1 GIT binary patch literal 193322 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 bWYgkGq;;XsD_qZ{|h)C7-T5$){lz|3f|v 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≠Xeexv5j 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�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 None: result = click_runner.invoke(anta, ["nrfu", "tpl-report", "--template", str(DATA_DIR / "template.j2")]) assert result.exit_code == ExitCode.OK assert "* VerifyEOSVersion is SUCCESS for dummy" in result.output + + +def test_anta_nrfu_csv(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu csv.""" + csv_output = tmp_path / "test.csv" + result = click_runner.invoke(anta, ["nrfu", "csv", "--csv-output", str(csv_output)]) + assert result.exit_code == ExitCode.OK + assert "CSV report saved to" in result.output + assert csv_output.exists() + + +def test_anta_nrfu_csv_failure(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu csv.""" + csv_output = tmp_path / "test.csv" + with patch("anta.reporter.csv_reporter.ReportCsv.generate", side_effect=OSError()): + result = click_runner.invoke(anta, ["nrfu", "csv", "--csv-output", str(csv_output)]) + assert result.exit_code == ExitCode.USAGE_ERROR + assert "Failed to save CSV report to" in result.output + assert not csv_output.exists() diff --git a/tests/units/reporter/test_csv.py b/tests/units/reporter/test_csv.py new file mode 100644 index 000000000..e0a9d4e1f --- /dev/null +++ b/tests/units/reporter/test_csv.py @@ -0,0 +1,93 @@ +# Copyright (c) 2023-2024 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.report.csv_reporter.py.""" + +# pylint: disable=too-few-public-methods + +import csv +import pathlib +from typing import Any, Callable + +import pytest + +from anta.reporter.csv_reporter import ReportCsv +from anta.result_manager import ResultManager + + +class TestReportCsv: + """Tester for ReportCsv class.""" + + def compare_csv_and_result(self, rows: list[Any], index: int, result_manager: ResultManager) -> None: + """Compare CSV and TestResult.""" + assert rows[index + 1][0] == result_manager.results[index].name + assert rows[index + 1][1] == result_manager.results[index].test + assert rows[index + 1][2] == result_manager.results[index].result + assert rows[index + 1][3] == ReportCsv().split_list_to_txt_list(result_manager.results[index].messages) + assert rows[index + 1][4] == result_manager.results[index].description + assert rows[index + 1][5] == ReportCsv().split_list_to_txt_list(result_manager.results[index].categories) + + def test_report_csv_generate( + self, + result_manager_factory: Callable[[int], ResultManager], + tmp_path: pathlib.Path, + ) -> None: + """Test CSV reporter.""" + max_test_entries = 10 + + # Create a temporary CSV file path + csv_filename = tmp_path / "test.csv" + + # Create a ResultManager instance with dummy test results + result_manager = result_manager_factory(max_test_entries) + # Test usecase with list of messages + result_manager.results[0].messages = ["Message 1", "Message 2"] + # Test usecase with list of categories + result_manager.results[1].messages = ["Cat 1", "Cat 2"] + + # Generate the CSV report + ReportCsv.generate(result_manager, csv_filename) + + # Read the generated CSV file + with pathlib.Path.open(csv_filename, encoding="utf-8") as csvfile: + reader = csv.reader(csvfile, delimiter=",") + rows = list(reader) + + # Assert the headers + assert rows[0] == [ + ReportCsv.Headers.device, + ReportCsv.Headers.test_name, + ReportCsv.Headers.test_status, + ReportCsv.Headers.messages, + ReportCsv.Headers.description, + ReportCsv.Headers.categories, + ] + + # Assert the test result rows + for index in [0, max_test_entries - 1]: + self.compare_csv_and_result(rows, index, result_manager) + + # Assert number of lines: Number of TestResults + CSV Headers + assert len(rows) == len(result_manager.results) + 1 + + def test_report_csv_generate_os_error( + self, + result_manager_factory: Callable[[int], ResultManager], + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Test CSV reporter OSError.""" + # Create a ResultManager instance with dummy test results + max_test_entries = 10 + result_manager = result_manager_factory(max_test_entries) + + # Create a temporary CSV file path and make tmp_path read_only + tmp_path.chmod(0o400) + csv_filename = tmp_path / "read_only.csv" + + with pytest.raises(OSError, match="Permission denied"): + # Generate the CSV report + ReportCsv.generate(result_manager, csv_filename) + + assert len(caplog.record_tuples) == 1 + assert "OSError caught while writing the CSV file" in caplog.text From bb0b2ba6517447eb427683d3183725a69f7e240c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 17 Jul 2024 15:47:58 +0200 Subject: [PATCH 30/90] bump: pre-commit autoupdate (#755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.1 → v0.5.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.1...v0.5.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cd451b16e..3541244c1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.1 + rev: v0.5.2 hooks: - id: ruff name: Run Ruff linter From 7c61fe64a72f1a618c87983266fcd65fd7f796ed Mon Sep 17 00:00:00 2001 From: pvinci-arista <102165259+pvinci-arista@users.noreply.github.com> Date: Thu, 18 Jul 2024 10:32:50 -0400 Subject: [PATCH 31/90] doc: correct environment example (#759) --- docs/cli/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 107976a10..5f1540095 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -35,7 +35,7 @@ To set them as environment variables: export ANTA_USERNAME=admin export ANTA_PASSWORD=arista123 export ANTA_INVENTORY=inventory.yml -export ANTA_INVENTORY=tests.yml +export ANTA_CATALOG=tests.yml ``` Then, run the CLI without options: From 1ef92f6b6741ab63a74f33133b18c53e9b18eb91 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Thu, 25 Jul 2024 10:57:45 +0200 Subject: [PATCH 32/90] ci: Ignore cryptography>43.0.0 warnings (#765) * ci: Ignore cryptography>43.0.0 warnings * bump: Bump ruff to 0.5.4 * ci: Fix ruff new issues * ci: Fix mypy new issues --- .pre-commit-config.yaml | 2 +- anta/models.py | 2 +- anta/tests/routing/bgp.py | 2 +- asynceapi/device.py | 2 +- asynceapi/errors.py | 2 +- pyproject.toml | 5 ++++- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3541244c1..a257bfd39 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.2 + rev: v0.5.4 hooks: - id: ruff name: Run Ruff linter diff --git a/anta/models.py b/anta/models.py index 499fb3536..e2cf49857 100644 --- a/anta/models.py +++ b/anta/models.py @@ -57,7 +57,7 @@ class AntaTemplate: # pylint: disable=too-few-public-methods - def __init__( # noqa: PLR0913 + def __init__( self, template: str, version: Literal[1, "latest"] = "latest", diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index a29216b2d..7bd39ddcc 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -123,7 +123,7 @@ def _add_bgp_routes_failure( # Iterate over the expected BGP routes for route in bgp_routes: str_route = str(route) - failure = {"bgp_peers": {peer: {vrf: {route_type: {str_route: Any}}}}} + failure: dict[str, Any] = {"bgp_peers": {peer: {vrf: {route_type: {}}}}} # Check if the route is missing in the BGP output if str_route not in bgp_output: diff --git a/asynceapi/device.py b/asynceapi/device.py index 04ec3ab7c..ca206d3e4 100644 --- a/asynceapi/device.py +++ b/asynceapi/device.py @@ -54,7 +54,7 @@ class Device(httpx.AsyncClient): EAPI_OFMT_OPTIONS = ("json", "text") EAPI_DEFAULT_OFMT = "json" - def __init__( # noqa: PLR0913 # pylint: disable=too-many-arguments + def __init__( # pylint: disable=too-many-arguments self, host: str | None = None, username: str | None = None, diff --git a/asynceapi/errors.py b/asynceapi/errors.py index 614427a1a..020d3dc2f 100644 --- a/asynceapi/errors.py +++ b/asynceapi/errors.py @@ -24,7 +24,7 @@ class EapiCommandError(RuntimeError): not_exec: a list of commands that were not executed """ - def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: # noqa: PLR0913 # pylint: disable=too-many-arguments + def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: # pylint: disable=too-many-arguments """Initialize for the EapiCommandError exception.""" self.failed = failed self.errmsg = errmsg diff --git a/pyproject.toml b/pyproject.toml index bce5a3c5b..e8b4feba2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dev = [ "pytest-html>=3.2.0", "pytest-metadata>=3.0.0", "pytest>=7.4.0", - "ruff>=0.5.0,<0.6.0", + "ruff>=0.5.4,<0.6.0", "tox>=4.10.0,<5.0.0", "types-PyYAML", "types-pyOpenSSL", @@ -172,6 +172,9 @@ filterwarnings = [ "default:pkg_resources is deprecated:DeprecationWarning", # Need to investigate the following - only occuring when running the full pytest suite "ignore:Exception ignored in.*:pytest.PytestUnraisableExceptionWarning", + # Ignore cryptography >=43.0.0 warnings until asyncssh issue is fixed + "ignore:ARC4:cryptography.utils.CryptographyDeprecationWarning", + "ignore:TripleDES:cryptography.utils.CryptographyDeprecationWarning", ] From eee49de8f0d8bf506f6350bc5b418c8a7cb77c5b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 26 Jul 2024 10:07:25 +0200 Subject: [PATCH 33/90] ci: pre-commit autoupdate (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.2 → v0.5.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.2...v0.5.4) - [github.com/pycqa/pylint: v3.2.5 → v3.2.6](https://github.com/pycqa/pylint/compare/v3.2.5...v3.2.6) - [github.com/pre-commit/mirrors-mypy: v1.10.1 → v1.11.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.1...v1.11.0) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Guillaume Mulocher --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a257bfd39..1c6282fa8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,7 +52,7 @@ repos: name: Run Ruff formatter - repo: https://github.com/pycqa/pylint - rev: "v3.2.5" + rev: "v3.2.6" hooks: - id: pylint name: Check code style with pylint @@ -80,7 +80,7 @@ repos: types: [text] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.1 + rev: v1.11.0 hooks: - id: mypy name: Check typing with mypy From 1d89ded2b81d2b5209b0cf872fc76899b6c3a2f2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 30 Jul 2024 09:03:31 +0200 Subject: [PATCH 34/90] bump: pre-commit autoupdate (#771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.4 → v0.5.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.4...v0.5.5) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c6282fa8..4e981ae8b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.4 + rev: v0.5.5 hooks: - id: ruff name: Run Ruff linter From f525ffba5867ee42942a2cd398674691d25038b1 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 8 Aug 2024 18:38:28 +0530 Subject: [PATCH 35/90] feat(anta): Refactor VerifyReachability test case to add coverage for DF Bit, packet size (#761) --- anta/tests/connectivity.py | 24 ++++++++++- examples/tests.yaml | 4 ++ tests/units/anta_tests/test_connectivity.py | 44 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/anta/tests/connectivity.py b/anta/tests/connectivity.py index 06cf8eaeb..c0c6f731b 100644 --- a/anta/tests/connectivity.py +++ b/anta/tests/connectivity.py @@ -33,16 +33,24 @@ class VerifyReachability(AntaTest): - source: Management0 destination: 1.1.1.1 vrf: MGMT + df_bit: True + size: 100 - source: Management0 destination: 8.8.8.8 vrf: MGMT + df_bit: True + size: 100 ``` """ name = "VerifyReachability" description = "Test the network reachability to one or many destination IP(s)." categories: ClassVar[list[str]] = ["connectivity"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}", revision=1)] + # Removing the between '{size}' and '{df_bit}' to compensate the df-bit set default value + # i.e if df-bit kept disable then it will add redundant space in between the command + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaTemplate(template="ping vrf {vrf} {destination} source {source} size {size}{df_bit} repeat {repeat}", revision=1) + ] class Input(AntaTest.Input): """Input model for the VerifyReachability test.""" @@ -61,15 +69,27 @@ class Host(BaseModel): """VRF context. Defaults to `default`.""" repeat: int = 2 """Number of ping repetition. Defaults to 2.""" + size: int = 100 + """Specify datagram size. Defaults to 100.""" + df_bit: bool = False + """Enable do not fragment bit in IP header. Defaults to False.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: """Render the template for each host in the input list.""" - return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts] + commands = [] + for host in self.inputs.hosts: + # Enables do not fragment bit in IP header if needed else keeping disable. + # Adding the at start to compensate change in AntaTemplate + df_bit = " df-bit" if host.df_bit else "" + command = template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat, size=host.size, df_bit=df_bit) + commands.append(command) + return commands @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyReachability.""" failures = [] + for command in self.instance_commands: src = command.params.source dst = command.params.destination diff --git a/examples/tests.yaml b/examples/tests.yaml index c0ab625bf..c479c8739 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -98,9 +98,13 @@ anta.tests.connectivity: - source: Management1 destination: 1.1.1.1 vrf: MGMT + df_bit: True + size: 100 - source: Management1 destination: 8.8.8.8 vrf: MGMT + df_bit: True + size: 100 - VerifyLLDPNeighbors: neighbors: - port: Ethernet1 diff --git a/tests/units/anta_tests/test_connectivity.py b/tests/units/anta_tests/test_connectivity.py index bd3081135..4cc57676c 100644 --- a/tests/units/anta_tests/test_connectivity.py +++ b/tests/units/anta_tests/test_connectivity.py @@ -99,6 +99,28 @@ ], "expected": {"result": "success"}, }, + { + "name": "success-df-bit-size", + "test": VerifyReachability, + "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "Management0", "repeat": 5, "size": 1500, "df_bit": True}]}, + "eos_data": [ + { + "messages": [ + """PING 10.0.0.1 (10.0.0.1) from 172.20.20.6 : 1472(1500) bytes of data. + 1480 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=0.085 ms + 1480 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=0.020 ms + 1480 bytes from 10.0.0.1: icmp_seq=3 ttl=64 time=0.019 ms + 1480 bytes from 10.0.0.1: icmp_seq=4 ttl=64 time=0.018 ms + 1480 bytes from 10.0.0.1: icmp_seq=5 ttl=64 time=0.017 ms + + --- 10.0.0.1 ping statistics --- + 5 packets transmitted, 5 received, 0% packet loss, time 0ms + rtt min/avg/max/mdev = 0.017/0.031/0.085/0.026 ms, ipg/ewma 0.061/0.057 ms""", + ], + }, + ], + "expected": {"result": "success"}, + }, { "name": "failure-ip", "test": VerifyReachability, @@ -167,6 +189,28 @@ ], "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('Management0', '10.0.0.11')]"]}, }, + { + "name": "failure-size", + "test": VerifyReachability, + "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "Management0", "repeat": 5, "size": 1501, "df_bit": True}]}, + "eos_data": [ + { + "messages": [ + """PING 10.0.0.1 (10.0.0.1) from 172.20.20.6 : 1473(1501) bytes of data. + ping: local error: message too long, mtu=1500 + ping: local error: message too long, mtu=1500 + ping: local error: message too long, mtu=1500 + ping: local error: message too long, mtu=1500 + ping: local error: message too long, mtu=1500 + + --- 10.0.0.1 ping statistics --- + 5 packets transmitted, 0 received, +5 errors, 100% packet loss, time 40ms + """, + ], + }, + ], + "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('Management0', '10.0.0.1')]"]}, + }, { "name": "success", "test": VerifyLLDPNeighbors, From 6e1e7674b5640d93a0f478777033e1bf29fb8bee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Aug 2024 09:02:47 +0200 Subject: [PATCH 36/90] chore: bump mike from 2.1.2 to 2.1.3 (#788) Bumps [mike](https://github.com/jimporter/mike) from 2.1.2 to 2.1.3. - [Release notes](https://github.com/jimporter/mike/releases) - [Changelog](https://github.com/jimporter/mike/blob/master/CHANGES.md) - [Commits](https://github.com/jimporter/mike/compare/v2.1.2...v2.1.3) --- updated-dependencies: - dependency-name: mike dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e8b4feba2..6cafa5b7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dev = [ doc = [ "fontawesome_markdown", "griffe", - "mike==2.1.2", + "mike==2.1.3", "mkdocs-autorefs>=0.4.1", "mkdocs-bootswatch>=1.1", "mkdocs-git-revision-date-localized-plugin>=1.1.0", From 383d48b23165f113aad74a3a814debfe1dd9e6b9 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Wed, 14 Aug 2024 03:03:56 -0400 Subject: [PATCH 37/90] chore: Update VSCode settings.json for Ruff (#787) --- .vscode/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index dd63eea0d..60150c6d1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "ruff.enable": true, + "ruff.configuration": "pyproject.toml", "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "pylint.importStrategy": "fromEnvironment", From b60fa6c640bc09bc62b5066498528f216d05c75e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 14 Aug 2024 09:18:33 +0200 Subject: [PATCH 38/90] chore: Bump pre-commit for ruff and mypy (#779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.5 → v0.5.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.5...v0.5.7) - [github.com/pre-commit/mirrors-mypy: v1.11.0 → v1.11.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.0...v1.11.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Thomas Grimonet --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e981ae8b..6f632b99f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.5 + rev: v0.5.7 hooks: - id: ruff name: Run Ruff linter @@ -80,7 +80,7 @@ repos: types: [text] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.0 + rev: v1.11.1 hooks: - id: mypy name: Check typing with mypy From af5831065963e14a53f6b2a4ead4a7e3014220e6 Mon Sep 17 00:00:00 2001 From: David Lobato Date: Wed, 14 Aug 2024 16:59:41 +0100 Subject: [PATCH 39/90] feat(anta.tests): Optimize VerifyRoutingTableEntry by quering all routes for a vrf. (#682) --- anta/tests/routing/generic.py | 35 +++++-- .../units/anta_tests/routing/test_generic.py | 91 +++++++++++++++++++ 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/anta/tests/routing/generic.py b/anta/tests/routing/generic.py index 89d4bc56f..cd9cf0d24 100644 --- a/anta/tests/routing/generic.py +++ b/anta/tests/routing/generic.py @@ -7,7 +7,8 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from ipaddress import IPv4Address, ip_interface +from functools import cache +from ipaddress import IPv4Address, IPv4Interface from typing import ClassVar, Literal from pydantic import model_validator @@ -131,7 +132,10 @@ class VerifyRoutingTableEntry(AntaTest): name = "VerifyRoutingTableEntry" description = "Verifies that the provided routes are present in the routing table of a specified VRF." categories: ClassVar[list[str]] = ["routing"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4), + AntaTemplate(template="show ip route vrf {vrf}", revision=4), + ] class Input(AntaTest.Input): """Input model for the VerifyRoutingTableEntry test.""" @@ -140,20 +144,35 @@ class Input(AntaTest.Input): """VRF context. Defaults to `default` VRF.""" routes: list[IPv4Address] """List of routes to verify.""" + collect: Literal["one", "all"] = "one" + """Route collect behavior: one=one route per command, all=all routes in vrf per command. Defaults to `one`""" def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each route in the input list.""" - return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes] + """Render the template for the input vrf.""" + if template == VerifyRoutingTableEntry.commands[0] and self.inputs.collect == "one": + return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes] + + if template == VerifyRoutingTableEntry.commands[1] and self.inputs.collect == "all": + return [template.render(vrf=self.inputs.vrf)] + + return [] + + @staticmethod + @cache + def ip_interface_ip(route: str) -> IPv4Address: + """Return the IP address of the provided ip route with mask.""" + return IPv4Interface(route).ip @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyRoutingTableEntry.""" - missing_routes = [] + commands_output_route_ips = set() for command in self.instance_commands: - vrf, route = command.params.vrf, command.params.route - if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(next(iter(routes))).ip: - missing_routes.append(str(route)) + command_output_vrf = command.json_output["vrfs"][self.inputs.vrf] + commands_output_route_ips |= {self.ip_interface_ip(route) for route in command_output_vrf["routes"]} + + missing_routes = [str(route) for route in self.inputs.routes if route not in commands_output_route_ips] if not missing_routes: self.result.is_success() diff --git a/tests/units/anta_tests/routing/test_generic.py b/tests/units/anta_tests/routing/test_generic.py index 36658f5b2..621cf22ad 100644 --- a/tests/units/anta_tests/routing/test_generic.py +++ b/tests/units/anta_tests/routing/test_generic.py @@ -130,6 +130,48 @@ "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]}, "expected": {"result": "success"}, }, + { + "name": "success-collect-all", + "test": VerifyRoutingTableEntry, + "eos_data": [ + { + "vrfs": { + "default": { + "routingDisabled": False, + "allRoutesProgrammedHardware": True, + "allRoutesProgrammedKernel": True, + "defaultRouteState": "notSet", + "routes": { + "10.1.0.1/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.4", "interface": "Ethernet1"}], + }, + "10.1.0.2/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}], + }, + }, + }, + }, + }, + ], + "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"], "collect": "all"}, + "expected": {"result": "success"}, + }, { "name": "failure-missing-route", "test": VerifyRoutingTableEntry, @@ -226,4 +268,53 @@ "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]}, "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']"]}, }, + { + "name": "failure-wrong-route-collect-all", + "test": VerifyRoutingTableEntry, + "eos_data": [ + { + "vrfs": { + "default": { + "routingDisabled": False, + "allRoutesProgrammedHardware": True, + "allRoutesProgrammedKernel": True, + "defaultRouteState": "notSet", + "routes": { + "10.1.0.1/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.4", "interface": "Ethernet1"}], + }, + "10.1.0.55/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}], + }, + }, + }, + }, + }, + ], + "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"], "collect": "all"}, + "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']"]}, + }, + { + "name": "collect-input-error", + "test": VerifyRoutingTableEntry, + "eos_data": {}, + "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"], "collect": "not-valid"}, + "expected": {"result": "error", "messages": ["Inputs are not valid"]}, + }, ] From 2258078a282087b29efccca1b06f8cb608cc1943 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 15 Aug 2024 03:32:02 +0530 Subject: [PATCH 40/90] feat(anta): Added the test case to verify Update error counters for BGP neighbors (#775) --- anta/custom_types.py | 1 + anta/tests/routing/bgp.py | 93 +++++- examples/tests.yaml | 7 + tests/units/anta_tests/routing/test_bgp.py | 319 +++++++++++++++++++++ 4 files changed, 419 insertions(+), 1 deletion(-) diff --git a/anta/custom_types.py b/anta/custom_types.py index a0a0631d0..8a9070579 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -167,3 +167,4 @@ def validate_regex(value: str) -> str: Hostname = Annotated[str, Field(pattern=REGEXP_TYPE_HOSTNAME)] Port = Annotated[int, Field(ge=1, le=65535)] RegexString = Annotated[str, AfterValidator(validate_regex)] +BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"] diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 7bd39ddcc..68225a6c9 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -14,7 +14,7 @@ from pydantic.v1.utils import deep_update from pydantic_extra_types.mac_address import MacAddress -from anta.custom_types import Afi, MultiProtocolCaps, Safi, Vni +from anta.custom_types import Afi, BgpUpdateError, MultiProtocolCaps, Safi, Vni from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import get_item, get_value @@ -1226,3 +1226,94 @@ def test(self) -> None: self.result.is_success() else: self.result.is_failure(f"Following BGP peers are not configured or hold and keep-alive timers are not correct:\n{failures}") + + +class VerifyBGPPeerUpdateErrors(AntaTest): + """Verifies BGP update error counters for the provided BGP IPv4 peer(s). + + By default, all update error counters will be checked for any non-zero values. + An optional list of specific update error counters can be provided for granular testing. + + Note: For "disabledAfiSafi" error counter field, checking that it's not "None" versus 0. + + Expected Results + ---------------- + * Success: The test will pass if the BGP peer's update error counter(s) are zero/None. + * Failure: The test will fail if the BGP peer's update error counter(s) are non-zero/not None/Not Found or + peer is not configured. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerUpdateErrors: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + update_error_filter: + - inUpdErrWithdraw + ``` + """ + + name = "VerifyBGPPeerUpdateErrors" + description = "Verifies the update error counters of a BGP IPv4 peer." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerUpdateErrors test.""" + + bgp_peers: list[BgpPeer] + """List of BGP peers""" + + class BgpPeer(BaseModel): + """Model for a BGP peer.""" + + peer_address: IPv4Address + """IPv4 address of a BGP peer.""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + update_errors: list[BgpUpdateError] | None = None + """Optional list of update error counters to be verified. If not provided, test will verifies all the update error counters.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each BGP peer in the input list.""" + return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerUpdateErrors.""" + failures: dict[Any, Any] = {} + + for command, input_entry in zip(self.instance_commands, self.inputs.bgp_peers): + peer = command.params.peer + vrf = command.params.vrf + update_error_counters = input_entry.update_errors + + # Verify BGP peer. + if not (peer_list := get_value(command.json_output, f"vrfs.{vrf}.peerList")) or (peer_detail := get_item(peer_list, "peerAddress", peer)) is None: + failures[peer] = {vrf: "Not configured"} + continue + + # Getting the BGP peer's error counters output. + error_counters_output = peer_detail.get("peerInUpdateErrors", {}) + + # In case update error counters not provided, It will check all the update error counters. + if not update_error_counters: + update_error_counters = error_counters_output + + # verifying the error counters. + error_counters_not_ok = { + ("disabledAfiSafi" if error_counter == "disabledAfiSafi" else error_counter): value + for error_counter in update_error_counters + if (value := error_counters_output.get(error_counter, "Not Found")) != "None" and value != 0 + } + if error_counters_not_ok: + failures[peer] = {vrf: error_counters_not_ok} + + # Check if any failures + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"The following BGP peers are not configured or have non-zero update error counters:\n{failures}") diff --git a/examples/tests.yaml b/examples/tests.yaml index c479c8739..4386d08a9 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -567,6 +567,13 @@ anta.tests.routing: vrf: default hold_time: 180 keep_alive_time: 60 + - VerifyBGPPeerUpdateErrors: + bgp_peers: + - peer_address: 10.100.0.8 + vrf: default + update_errors: + - inUpdErrWithdraw + - inUpdErrIgnore ospf: - VerifyOSPFNeighborState: - VerifyOSPFNeighborCount: diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index e712e12a8..34f83ff66 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -19,6 +19,7 @@ VerifyBGPPeerMPCaps, VerifyBGPPeerRouteRefreshCap, VerifyBGPPeersHealth, + VerifyBGPPeerUpdateErrors, VerifyBGPSpecificPeers, VerifyBGPTimers, VerifyEVPNType2Route, @@ -3722,4 +3723,322 @@ ], }, }, + { + "name": "success", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 0, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 0, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-found", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + {"vrfs": {}}, + {"vrfs": {}}, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero update error counters:\n" + "{'10.100.0.8': {'default': 'Not configured'}, '10.100.0.9': {'MGMT': 'Not configured'}}" + ], + }, + }, + { + "name": "failure-errors", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 0, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "ipv4Unicast", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 1, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero update error counters:\n" + "{'10.100.0.8': {'default': {'disabledAfiSafi': 'ipv4Unicast'}}, " + "'10.100.0.9': {'MGMT': {'inUpdErrWithdraw': 1}}}" + ], + }, + }, + { + "name": "failure-not-found", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": {}, + }, + { + "vrfs": {}, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero update error counters:\n" + "{'10.100.0.8': {'default': 'Not configured'}, '10.100.0.9': {'MGMT': 'Not configured'}}" + ], + }, + }, + { + "name": "success-all-error-counters", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 0, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 0, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default"}, + {"peer_address": "10.100.0.9", "vrf": "MGMT"}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-all-error-counters", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 1, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "ipv4Unicast", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 1, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 1, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + { + "peer_address": "10.100.0.9", + "vrf": "MGMT", + "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi", "inUpdErrDisableAfiSafi"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero update error counters:\n" + "{'10.100.0.8': {'default': {'inUpdErrWithdraw': 1, 'disabledAfiSafi': 'ipv4Unicast'}}, " + "'10.100.0.9': {'MGMT': {'inUpdErrWithdraw': 1, 'inUpdErrDisableAfiSafi': 1}}}" + ], + }, + }, + { + "name": "failure-all-not-found", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "peerInUpdateErrors": { + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "ipv4Unicast", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 1, + "inUpdErrIgnore": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + { + "peer_address": "10.100.0.9", + "vrf": "MGMT", + "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi", "inUpdErrDisableAfiSafi"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero update error counters:\n" + "{'10.100.0.8': {'default': {'inUpdErrWithdraw': 'Not Found', 'disabledAfiSafi': 'ipv4Unicast'}}, " + "'10.100.0.9': {'MGMT': {'inUpdErrWithdraw': 1, 'inUpdErrDisableAfiSafi': 'Not Found'}}}" + ], + }, + }, ] From c37c089e37bf90388a61d18475c2e089828be27c Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 15 Aug 2024 06:46:53 +0530 Subject: [PATCH 41/90] feat(anta): Added the test case to verify Inbound/outbound stats for BGP neighbors (#778) --- anta/custom_types.py | 30 ++ anta/tests/routing/bgp.py | 89 +++++- examples/tests.yaml | 11 + tests/units/anta_tests/routing/test_bgp.py | 316 +++++++++++++++++++++ 4 files changed, 445 insertions(+), 1 deletion(-) diff --git a/anta/custom_types.py b/anta/custom_types.py index 8a9070579..56c213977 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -167,4 +167,34 @@ def validate_regex(value: str) -> str: Hostname = Annotated[str, Field(pattern=REGEXP_TYPE_HOSTNAME)] Port = Annotated[int, Field(ge=1, le=65535)] RegexString = Annotated[str, AfterValidator(validate_regex)] +BgpDropStats = Literal[ + "inDropAsloop", + "inDropClusterIdLoop", + "inDropMalformedMpbgp", + "inDropOrigId", + "inDropNhLocal", + "inDropNhAfV6", + "prefixDroppedMartianV4", + "prefixDroppedMaxRouteLimitViolatedV4", + "prefixDroppedMartianV6", + "prefixDroppedMaxRouteLimitViolatedV6", + "prefixLuDroppedV4", + "prefixLuDroppedMartianV4", + "prefixLuDroppedMaxRouteLimitViolatedV4", + "prefixLuDroppedV6", + "prefixLuDroppedMartianV6", + "prefixLuDroppedMaxRouteLimitViolatedV6", + "prefixEvpnDroppedUnsupportedRouteType", + "prefixBgpLsDroppedReceptionUnsupported", + "outDropV4LocalAddr", + "outDropV6LocalAddr", + "prefixVpnIpv4DroppedImportMatchFailure", + "prefixVpnIpv4DroppedMaxRouteLimitViolated", + "prefixVpnIpv6DroppedImportMatchFailure", + "prefixVpnIpv6DroppedMaxRouteLimitViolated", + "prefixEvpnDroppedImportMatchFailure", + "prefixEvpnDroppedMaxRouteLimitViolated", + "prefixRtMembershipDroppedLocalAsReject", + "prefixRtMembershipDroppedMaxRouteLimitViolated", +] BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"] diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 68225a6c9..6a7002356 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -14,7 +14,7 @@ from pydantic.v1.utils import deep_update from pydantic_extra_types.mac_address import MacAddress -from anta.custom_types import Afi, BgpUpdateError, MultiProtocolCaps, Safi, Vni +from anta.custom_types import Afi, BgpDropStats, BgpUpdateError, MultiProtocolCaps, Safi, Vni from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import get_item, get_value @@ -1228,6 +1228,93 @@ def test(self) -> None: self.result.is_failure(f"Following BGP peers are not configured or hold and keep-alive timers are not correct:\n{failures}") +class VerifyBGPPeerDropStats(AntaTest): + """Verifies BGP NLRI drop statistics for the provided BGP IPv4 peer(s). + + By default, all drop statistics counters will be checked for any non-zero values. + An optional list of specific drop statistics can be provided for granular testing. + + Expected Results + ---------------- + * Success: The test will pass if the BGP peer's drop statistic(s) are zero. + * Failure: The test will fail if the BGP peer's drop statistic(s) are non-zero/Not Found or peer is not configured. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerDropStats: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + drop_stats: + - inDropAsloop + - prefixEvpnDroppedUnsupportedRouteType + ``` + """ + + name = "VerifyBGPPeerDropStats" + description = "Verifies the NLRI drop statistics of a BGP IPv4 peer(s)." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerDropStats test.""" + + bgp_peers: list[BgpPeer] + """List of BGP peers""" + + class BgpPeer(BaseModel): + """Model for a BGP peer.""" + + peer_address: IPv4Address + """IPv4 address of a BGP peer.""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + drop_stats: list[BgpDropStats] | None = None + """Optional list of drop statistics to be verified. If not provided, test will verifies all the drop statistics.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each BGP peer in the input list.""" + return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerDropStats.""" + failures: dict[Any, Any] = {} + + for command, input_entry in zip(self.instance_commands, self.inputs.bgp_peers): + peer = command.params.peer + vrf = command.params.vrf + drop_statistics = input_entry.drop_stats + + # Verify BGP peer + if not (peer_list := get_value(command.json_output, f"vrfs.{vrf}.peerList")) or (peer_detail := get_item(peer_list, "peerAddress", peer)) is None: + failures[peer] = {vrf: "Not configured"} + continue + + # Verify BGP peer's drop stats + drop_stats_output = peer_detail.get("dropStats", {}) + + # In case drop stats not provided, It will check all drop statistics + if not drop_statistics: + drop_statistics = drop_stats_output + + # Verify BGP peer's drop stats + drop_stats_not_ok = { + drop_stat: drop_stats_output.get(drop_stat, "Not Found") for drop_stat in drop_statistics if drop_stats_output.get(drop_stat, "Not Found") + } + if any(drop_stats_not_ok): + failures[peer] = {vrf: drop_stats_not_ok} + + # Check if any failures + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n{failures}") + + class VerifyBGPPeerUpdateErrors(AntaTest): """Verifies BGP update error counters for the provided BGP IPv4 peer(s). diff --git a/examples/tests.yaml b/examples/tests.yaml index 4386d08a9..c4248cf75 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -567,6 +567,17 @@ anta.tests.routing: vrf: default hold_time: 180 keep_alive_time: 60 + - VerifyBGPPeerDropStats: + bgp_peers: + - peer_address: 10.101.0.4 + vrf: default + drop_stats: + - inDropAsloop + - inDropClusterIdLoop + - inDropMalformedMpbgp + - inDropOrigId + - inDropNhLocal + - inDropNhAfV6 - VerifyBGPPeerUpdateErrors: bgp_peers: - peer_address: 10.100.0.8 diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index 34f83ff66..47db8e60b 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -15,6 +15,7 @@ VerifyBGPExchangedRoutes, VerifyBGPPeerASNCap, VerifyBGPPeerCount, + VerifyBGPPeerDropStats, VerifyBGPPeerMD5Auth, VerifyBGPPeerMPCaps, VerifyBGPPeerRouteRefreshCap, @@ -3723,6 +3724,321 @@ ], }, }, + { + "name": "success", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 0, + "inDropNhLocal": 0, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 0, + "inDropNhLocal": 0, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "10.100.0.8", + "vrf": "default", + "drop_stats": ["prefixDroppedMartianV4", "prefixDroppedMaxRouteLimitViolatedV4", "prefixDroppedMartianV6"], + }, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "drop_stats": ["inDropClusterIdLoop", "inDropOrigId", "inDropNhLocal"]}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-found", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + {"vrfs": {}}, + {"vrfs": {}}, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "10.100.0.8", + "vrf": "default", + "drop_stats": ["prefixDroppedMartianV4", "prefixDroppedMaxRouteLimitViolatedV4", "prefixDroppedMartianV6"], + }, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "drop_stats": ["inDropClusterIdLoop", "inDropOrigId", "inDropNhLocal"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n" + "{'10.100.0.8': {'default': 'Not configured'}, '10.100.0.9': {'MGMT': 'Not configured'}}" + ], + }, + }, + { + "name": "failure", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 1, + "inDropNhLocal": 1, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 1, + "prefixDroppedMaxRouteLimitViolatedV4": 1, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 1, + "inDropNhLocal": 1, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "10.100.0.8", + "vrf": "default", + "drop_stats": ["prefixDroppedMartianV4", "prefixDroppedMaxRouteLimitViolatedV4", "prefixDroppedMartianV6"], + }, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "drop_stats": ["inDropClusterIdLoop", "inDropOrigId", "inDropNhLocal"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n" + "{'10.100.0.8': {'default': {'prefixDroppedMartianV4': 1, 'prefixDroppedMaxRouteLimitViolatedV4': 1}}, " + "'10.100.0.9': {'MGMT': {'inDropOrigId': 1, 'inDropNhLocal': 1}}}" + ], + }, + }, + { + "name": "success-all-drop-stats", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 0, + "inDropNhLocal": 0, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 0, + "inDropNhLocal": 0, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default"}, + {"peer_address": "10.100.0.9", "vrf": "MGMT"}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-all-drop-stats", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "dropStats": { + "inDropAsloop": 3, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 1, + "inDropNhLocal": 1, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 1, + "prefixDroppedMaxRouteLimitViolatedV4": 1, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "dropStats": { + "inDropAsloop": 2, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 1, + "inDropNhLocal": 1, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default"}, + {"peer_address": "10.100.0.9", "vrf": "MGMT"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n" + "{'10.100.0.8': {'default': {'inDropAsloop': 3, 'inDropOrigId': 1, 'inDropNhLocal': 1, " + "'prefixDroppedMartianV4': 1, 'prefixDroppedMaxRouteLimitViolatedV4': 1}}, " + "'10.100.0.9': {'MGMT': {'inDropAsloop': 2, 'inDropOrigId': 1, 'inDropNhLocal': 1}}}" + ], + }, + }, + { + "name": "failure-drop-stat-not-found", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "dropStats": { + "inDropAsloop": 3, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 1, + "inDropNhLocal": 1, + "inDropNhAfV6": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 1, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "drop_stats": ["inDropAsloop", "inDropOrigId", "inDropNhLocal", "prefixDroppedMartianV4"]} + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n" + "{'10.100.0.8': {'default': {'inDropAsloop': 3, 'inDropOrigId': 1, 'inDropNhLocal': 1, 'prefixDroppedMartianV4': 'Not Found'}}}" + ], + }, + }, { "name": "success", "test": VerifyBGPPeerUpdateErrors, From e9925d351c1515a56e762b51129534aae6c2f7c7 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Mon, 19 Aug 2024 13:29:53 -0400 Subject: [PATCH 42/90] fix(anta): Add upper bound on Griffe requirement for v1 (#794) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6cafa5b7f..ecfcba289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ dev = [ ] doc = [ "fontawesome_markdown", - "griffe", + "griffe >=0.46,<1.0.0", "mike==2.1.3", "mkdocs-autorefs>=0.4.1", "mkdocs-bootswatch>=1.1", From 08e945b5bf1cc110e4200cf45a391c7b284da6ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Aug 2024 13:40:37 -0400 Subject: [PATCH 43/90] chore: update ruff requirement from <0.6.0,>=0.5.4 to >=0.5.4,<0.7.0 (#790) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Carl Baillargeon --- .pre-commit-config.yaml | 2 +- asynceapi/aio_portcheck.py | 2 +- pyproject.toml | 2 +- tests/lib/fixture.py | 16 ++++++++-------- tests/units/cli/exec/test_utils.py | 2 +- tests/units/test_device.py | 8 ++++---- tests/units/test_runner.py | 12 ++++++------ 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6f632b99f..75d4388a1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.7 + rev: v0.6.1 hooks: - id: ruff name: Run Ruff linter diff --git a/asynceapi/aio_portcheck.py b/asynceapi/aio_portcheck.py index 79f4562fa..fd8e7aee2 100644 --- a/asynceapi/aio_portcheck.py +++ b/asynceapi/aio_portcheck.py @@ -33,7 +33,7 @@ # ----------------------------------------------------------------------------- -async def port_check_url(url: URL, timeout: int = 5) -> bool: +async def port_check_url(url: URL, timeout: int = 5) -> bool: # noqa: ASYNC109 """ Open the port designated by the URL given the timeout in seconds. diff --git a/pyproject.toml b/pyproject.toml index ecfcba289..e64ee80df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dev = [ "pytest-html>=3.2.0", "pytest-metadata>=3.0.0", "pytest>=7.4.0", - "ruff>=0.5.4,<0.6.0", + "ruff>=0.5.4,<0.7.0", "tox>=4.10.0,<5.0.0", "types-PyYAML", "types-pyOpenSSL", diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 17943edc3..b0205b8bb 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -58,7 +58,7 @@ } -@pytest.fixture() +@pytest.fixture def device(request: pytest.FixtureRequest) -> Iterator[AntaDevice]: """Return an AntaDevice instance with mocked abstract method.""" @@ -78,7 +78,7 @@ def _collect(command: AntaCommand, *args: Any, **kwargs: Any) -> None: # noqa: yield dev -@pytest.fixture() +@pytest.fixture def test_inventory() -> AntaInventory: """Return the test_inventory.""" env = default_anta_env() @@ -93,7 +93,7 @@ def test_inventory() -> AntaInventory: # tests.unit.test_device.py fixture -@pytest.fixture() +@pytest.fixture def async_device(request: pytest.FixtureRequest) -> AsyncEOSDevice: """Return an AsyncEOSDevice instance.""" kwargs = { @@ -110,7 +110,7 @@ def async_device(request: pytest.FixtureRequest) -> AsyncEOSDevice: # tests.units.result_manager fixtures -@pytest.fixture() +@pytest.fixture def test_result_factory(device: AntaDevice) -> Callable[[int], TestResult]: """Return a anta.result_manager.models.TestResult object.""" # pylint: disable=redefined-outer-name @@ -128,7 +128,7 @@ def _create(index: int = 0) -> TestResult: return _create -@pytest.fixture() +@pytest.fixture def list_result_factory(test_result_factory: Callable[[int], TestResult]) -> Callable[[int], list[TestResult]]: """Return a list[TestResult] with 'size' TestResult instantiated using the test_result_factory fixture.""" # pylint: disable=redefined-outer-name @@ -140,7 +140,7 @@ def _factory(size: int = 0) -> list[TestResult]: return _factory -@pytest.fixture() +@pytest.fixture def result_manager_factory(list_result_factory: Callable[[int], list[TestResult]]) -> Callable[[int], ResultManager]: """Return a ResultManager factory that takes as input a number of tests.""" # pylint: disable=redefined-outer-name @@ -155,7 +155,7 @@ def _factory(number: int = 0) -> ResultManager: # tests.units.cli fixtures -@pytest.fixture() +@pytest.fixture def temp_env(tmp_path: Path) -> dict[str, str | None]: """Fixture that create a temporary ANTA inventory. @@ -169,7 +169,7 @@ def temp_env(tmp_path: Path) -> dict[str, str | None]: return env -@pytest.fixture() +@pytest.fixture # Disabling C901 - too complex as we like our runner like this def click_runner(capsys: pytest.CaptureFixture[str]) -> Iterator[CliRunner]: # noqa: C901 """Return a click.CliRunner for cli testing.""" diff --git a/tests/units/cli/exec/test_utils.py b/tests/units/cli/exec/test_utils.py index ad1a78ab1..f4c0cc5fd 100644 --- a/tests/units/cli/exec/test_utils.py +++ b/tests/units/cli/exec/test_utils.py @@ -23,7 +23,7 @@ # TODO: complete test cases -@pytest.mark.asyncio() +@pytest.mark.asyncio @pytest.mark.parametrize( ("inventory_state", "per_device_command_output", "tags"), [ diff --git a/tests/units/test_device.py b/tests/units/test_device.py index e8a0c5f86..d3c50cc8e 100644 --- a/tests/units/test_device.py +++ b/tests/units/test_device.py @@ -613,7 +613,7 @@ class TestAntaDevice: """Test for anta.device.AntaDevice Abstract class.""" - @pytest.mark.asyncio() + @pytest.mark.asyncio @pytest.mark.parametrize( ("device", "command_data", "expected_data"), ((d["device"], d["command"], d["expected"]) for d in COLLECT_DATA), @@ -693,7 +693,7 @@ def test__eq(self, data: dict[str, Any]) -> None: else: assert device1 != device2 - @pytest.mark.asyncio() + @pytest.mark.asyncio @pytest.mark.parametrize( ("async_device", "patch_kwargs", "expected"), ((d["device"], d["patch_kwargs"], d["expected"]) for d in REFRESH_DATA), @@ -712,7 +712,7 @@ async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[di assert async_device.established == expected["established"] assert async_device.hw_model == expected["hw_model"] - @pytest.mark.asyncio() + @pytest.mark.asyncio @pytest.mark.parametrize( ("async_device", "command", "expected"), ((d["device"], d["command"], d["expected"]) for d in ASYNCEAPI_COLLECT_DATA), @@ -745,7 +745,7 @@ async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, A assert cmd.output == expected["output"] assert cmd.errors == expected["errors"] - @pytest.mark.asyncio() + @pytest.mark.asyncio @pytest.mark.parametrize( ("async_device", "copy"), ((d["device"], d["copy"]) for d in ASYNCEAPI_COPY_DATA), diff --git a/tests/units/test_runner.py b/tests/units/test_runner.py index 955149d09..53d0bf758 100644 --- a/tests/units/test_runner.py +++ b/tests/units/test_runner.py @@ -24,7 +24,7 @@ FAKE_CATALOG: AntaCatalog = AntaCatalog.from_list([(FakeTest, None)]) -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_runner_empty_tests(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: """Test that when the list of tests is empty, a log is raised. @@ -40,7 +40,7 @@ async def test_runner_empty_tests(caplog: pytest.LogCaptureFixture, test_invento assert "The list of tests is empty, exiting" in caplog.records[0].message -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_runner_empty_inventory(caplog: pytest.LogCaptureFixture) -> None: """Test that when the Inventory is empty, a log is raised. @@ -55,7 +55,7 @@ async def test_runner_empty_inventory(caplog: pytest.LogCaptureFixture) -> None: assert "The inventory is empty, exiting" in caplog.records[1].message -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_runner_no_selected_device(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: """Test that when the list of established device. @@ -140,7 +140,7 @@ def side_effect_setrlimit(resource_id: int, limits: tuple[int, int]) -> None: setrlimit_mock.assert_called_once_with(resource.RLIMIT_NOFILE, (16384, 1048576)) -@pytest.mark.asyncio() +@pytest.mark.asyncio @pytest.mark.parametrize( ("tags", "expected_tests_count", "expected_devices_count"), [ @@ -173,7 +173,7 @@ async def test_prepare_tests( assert sum(len(tests) for tests in selected_tests.values()) == expected_tests_count -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_prepare_tests_with_specific_tests(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: """Test the runner prepare_tests function with specific tests.""" logger.setup_logging(logger.Log.INFO) @@ -187,7 +187,7 @@ async def test_prepare_tests_with_specific_tests(caplog: pytest.LogCaptureFixtur assert sum(len(tests) for tests in selected_tests.values()) == 5 -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_runner_dry_run(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: """Test that when dry_run is True, no tests are run. From b9f95aebae28e9a182f0ae8b3aba8f9ee257816a Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Wed, 21 Aug 2024 23:29:57 +0530 Subject: [PATCH 44/90] feat(anta): Added test case to verify registered protocol for IPv4 BFD peers (#773) --- anta/custom_types.py | 1 + anta/tests/bfd.py | 82 +++++++++++++++++- examples/tests.yaml | 7 ++ tests/units/anta_tests/test_bfd.py | 131 ++++++++++++++++++++++++++++- 4 files changed, 217 insertions(+), 4 deletions(-) diff --git a/anta/custom_types.py b/anta/custom_types.py index 56c213977..153fd7011 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -198,3 +198,4 @@ def validate_regex(value: str) -> str: "prefixRtMembershipDroppedMaxRouteLimitViolated", ] BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"] +BfdProtocol = Literal["bgp", "isis", "lag", "ospf", "ospfv3", "pim", "route-input", "static-bfd", "static-route", "vrrp", "vxlan"] diff --git a/anta/tests/bfd.py b/anta/tests/bfd.py index f19e9cc92..0b171a6d2 100644 --- a/anta/tests/bfd.py +++ b/anta/tests/bfd.py @@ -13,7 +13,7 @@ from pydantic import BaseModel, Field -from anta.custom_types import BfdInterval, BfdMultiplier +from anta.custom_types import BfdInterval, BfdMultiplier, BfdProtocol from anta.models import AntaCommand, AntaTest from anta.tools import get_value @@ -45,7 +45,7 @@ class VerifyBFDSpecificPeers(AntaTest): name = "VerifyBFDSpecificPeers" description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF." categories: ClassVar[list[str]] = ["bfd"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=4)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyBFDSpecificPeers test.""" @@ -126,7 +126,7 @@ class VerifyBFDPeersIntervals(AntaTest): name = "VerifyBFDPeersIntervals" description = "Verifies the timers of the IPv4 BFD peers in the specified VRF." categories: ClassVar[list[str]] = ["bfd"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=4)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyBFDPeersIntervals test.""" @@ -285,3 +285,79 @@ def test(self) -> None: if up_failures: up_failures_str = "\n".join(up_failures) self.result.is_failure(f"\nFollowing BFD peers were down:\n{up_failures_str}") + + +class VerifyBFDPeersRegProtocols(AntaTest): + """Verifies that IPv4 BFD peer(s) have the specified protocol(s) registered. + + Expected Results + ---------------- + * Success: The test will pass if IPv4 BFD peers are registered with the specified protocol(s). + * Failure: The test will fail if IPv4 BFD peers are not found or the specified protocol(s) are not registered for the BFD peer(s). + + Examples + -------- + ```yaml + anta.tests.bfd: + - VerifyBFDPeersRegProtocols: + bfd_peers: + - peer_address: 192.0.255.7 + vrf: default + protocols: + - bgp + ``` + """ + + name = "VerifyBFDPeersRegProtocols" + description = "Verifies that IPv4 BFD peer(s) have the specified protocol(s) registered." + categories: ClassVar[list[str]] = ["bfd"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyBFDPeersRegProtocols test.""" + + bfd_peers: list[BFDPeer] + """List of IPv4 BFD peers.""" + + class BFDPeer(BaseModel): + """Model for an IPv4 BFD peer.""" + + peer_address: IPv4Address + """IPv4 address of a BFD peer.""" + vrf: str = "default" + """Optional VRF for BFD peer. If not provided, it defaults to `default`.""" + protocols: list[BfdProtocol] + """List of protocols to be verified.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBFDPeersRegProtocols.""" + # Initialize failure messages + failures: dict[Any, Any] = {} + + # Iterating over BFD peers, extract the parameters and command output + for bfd_peer in self.inputs.bfd_peers: + peer = str(bfd_peer.peer_address) + vrf = bfd_peer.vrf + protocols = bfd_peer.protocols + bfd_output = get_value( + self.instance_commands[0].json_output, + f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", + separator="..", + ) + + # Check if BFD peer configured + if not bfd_output: + failures[peer] = {vrf: "Not Configured"} + continue + + # Check registered protocols + difference = set(protocols) - set(get_value(bfd_output, "peerStatsDetail.apps")) + + if difference: + failures[peer] = {vrf: sorted(difference)} + + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"The following BFD peers are not configured or have non-registered protocol(s):\n{failures}") diff --git a/examples/tests.yaml b/examples/tests.yaml index c4248cf75..58161972f 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -83,6 +83,13 @@ anta.tests.bfd: multiplier: 3 - VerifyBFDPeersHealth: down_threshold: 2 + - VerifyBFDPeersRegProtocols: + bfd_peers: + - peer_address: 192.0.255.8 + vrf: default + protocols: + - bgp + - isis anta.tests.configuration: - VerifyZeroTouch: diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py index 54dc7a05e..b3ab5609a 100644 --- a/tests/units/anta_tests/test_bfd.py +++ b/tests/units/anta_tests/test_bfd.py @@ -10,7 +10,7 @@ # pylint: disable=C0413 # because of the patch above -from anta.tests.bfd import VerifyBFDPeersHealth, VerifyBFDPeersIntervals, VerifyBFDSpecificPeers +from anta.tests.bfd import VerifyBFDPeersHealth, VerifyBFDPeersIntervals, VerifyBFDPeersRegProtocols, VerifyBFDSpecificPeers from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 DATA: list[dict[str, Any]] = [ @@ -519,4 +519,133 @@ ], }, }, + { + "name": "success", + "test": VerifyBFDPeersRegProtocols, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 108328132, + "peerStatsDetail": { + "role": "active", + "apps": ["ospf"], + }, + } + } + } + } + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.70": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 108328132, + "peerStatsDetail": { + "role": "active", + "apps": ["bgp"], + }, + } + } + } + } + }, + } + } + ], + "inputs": { + "bfd_peers": [ + {"peer_address": "192.0.255.7", "vrf": "default", "protocols": ["ospf"]}, + {"peer_address": "192.0.255.70", "vrf": "MGMT", "protocols": ["bgp"]}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyBFDPeersRegProtocols, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "up", + "peerStatsDetail": { + "role": "active", + "apps": ["ospf"], + }, + } + } + } + } + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.70": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 0, + "peerStatsDetail": { + "role": "active", + "apps": ["bgp"], + }, + } + } + } + } + }, + } + } + ], + "inputs": { + "bfd_peers": [ + {"peer_address": "192.0.255.7", "vrf": "default", "protocols": ["isis"]}, + {"peer_address": "192.0.255.70", "vrf": "MGMT", "protocols": ["isis"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BFD peers are not configured or have non-registered protocol(s):\n" + "{'192.0.255.7': {'default': ['isis']}, " + "'192.0.255.70': {'MGMT': ['isis']}}" + ], + }, + }, + { + "name": "failure-not-found", + "test": VerifyBFDPeersRegProtocols, + "eos_data": [ + { + "vrfs": { + "default": {}, + "MGMT": {}, + } + } + ], + "inputs": { + "bfd_peers": [ + {"peer_address": "192.0.255.7", "vrf": "default", "protocols": ["isis"]}, + {"peer_address": "192.0.255.70", "vrf": "MGMT", "protocols": ["isis"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BFD peers are not configured or have non-registered protocol(s):\n" + "{'192.0.255.7': {'default': 'Not Configured'}, '192.0.255.70': {'MGMT': 'Not Configured'}}" + ], + }, + }, ] From 61e206efb5636fc6d7498bd50a3a0aacceb45ee3 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 22 Aug 2024 00:09:06 +0530 Subject: [PATCH 45/90] feat(anta): Added the test case to verify the Entropy source security (#780) --- anta/tests/security.py | 34 +++++++++++++++++++++++++ examples/tests.yaml | 1 + tests/units/anta_tests/test_security.py | 15 +++++++++++ 3 files changed, 50 insertions(+) diff --git a/anta/tests/security.py b/anta/tests/security.py index 4eb4d6415..ae5b9bebd 100644 --- a/anta/tests/security.py +++ b/anta/tests/security.py @@ -820,3 +820,37 @@ def test(self) -> None: self.result.is_failure( f"IPv4 security connection `source:{source_input} destination:{destination_input} vrf:{vrf}` for peer `{peer}` is not found." ) + + +class VerifyHardwareEntropy(AntaTest): + """ + Verifies hardware entropy generation is enabled on device. + + Expected Results + ---------------- + * Success: The test will pass if hardware entropy generation is enabled. + * Failure: The test will fail if hardware entropy generation is not enabled. + + Examples + -------- + ```yaml + anta.tests.security: + - VerifyHardwareEntropy: + ``` + """ + + name = "VerifyHardwareEntropy" + description = "Verifies hardware entropy generation is enabled on device." + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management security")] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyHardwareEntropy.""" + command_output = self.instance_commands[0].json_output + + # Check if hardware entropy generation is enabled. + if not command_output.get("hardwareEntropyEnabled"): + self.result.is_failure("Hardware entropy generation is disabled.") + else: + self.result.is_success() diff --git a/examples/tests.yaml b/examples/tests.yaml index 58161972f..c5f87fae7 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -354,6 +354,7 @@ anta.tests.security: destination_address: 100.64.2.2 - source_address: 172.18.3.2 destination_address: 172.18.2.2 + - VerifyHardwareEntropy: anta.tests.services: - VerifyHostname: diff --git a/tests/units/anta_tests/test_security.py b/tests/units/anta_tests/test_security.py index 3a732bdaa..eabc40bd8 100644 --- a/tests/units/anta_tests/test_security.py +++ b/tests/units/anta_tests/test_security.py @@ -15,6 +15,7 @@ VerifyAPISSLCertificate, VerifyBannerLogin, VerifyBannerMotd, + VerifyHardwareEntropy, VerifyIPSecConnHealth, VerifyIPv4ACL, VerifySpecificIPSecConn, @@ -1213,4 +1214,18 @@ ], }, }, + { + "name": "success", + "test": VerifyHardwareEntropy, + "eos_data": [{"cpuModel": "2.20GHz", "cryptoModule": "Crypto Module v3.0", "hardwareEntropyEnabled": True, "blockedNetworkProtocols": []}], + "inputs": {}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyHardwareEntropy, + "eos_data": [{"cpuModel": "2.20GHz", "cryptoModule": "Crypto Module v3.0", "hardwareEntropyEnabled": False, "blockedNetworkProtocols": []}], + "inputs": {}, + "expected": {"result": "failure", "messages": ["Hardware entropy generation is disabled."]}, + }, ] From 9e53cae88e03e49406210108928714ba56f90f5b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 27 Aug 2024 09:15:11 +0200 Subject: [PATCH 46/90] ci: pre-commit autoupdate (#801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.1 → v0.6.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.1...v0.6.2) - [github.com/pre-commit/mirrors-mypy: v1.11.1 → v1.11.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.1...v1.11.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75d4388a1..ceef2b6c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.1 + rev: v0.6.2 hooks: - id: ruff name: Run Ruff linter @@ -80,7 +80,7 @@ repos: types: [text] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.1 + rev: v1.11.2 hooks: - id: mypy name: Check typing with mypy From 484275d95d45e4c60b1520c94297dc448b403263 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Tue, 27 Aug 2024 20:01:36 -0400 Subject: [PATCH 47/90] feat(anta): Added merge_catalogs function (#802) --- anta/catalog.py | 25 ++++++++++++++++++++++++- docs/usage-inventory-catalog.md | 29 ++++++++++++++++++----------- tests/units/test_catalog.py | 19 +++++++++++++++++-- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/anta/catalog.py b/anta/catalog.py index 30bd34066..7ed4bc718 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -10,9 +10,11 @@ import math from collections import defaultdict from inspect import isclass +from itertools import chain from json import load as json_load from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Optional, Union +from warnings import warn from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_serializer, model_validator from pydantic.types import ImportString @@ -386,6 +388,21 @@ def from_list(data: ListAntaTestTuples) -> AntaCatalog: raise return AntaCatalog(tests) + @classmethod + def merge_catalogs(cls, catalogs: list[AntaCatalog]) -> AntaCatalog: + """Merge multiple AntaCatalog instances. + + Parameters + ---------- + catalogs: A list of AntaCatalog instances to merge. + + Returns + ------- + A new AntaCatalog instance containing the tests of all the input catalogs. + """ + combined_tests = list(chain(*(catalog.tests for catalog in catalogs))) + return cls(tests=combined_tests) + def merge(self, catalog: AntaCatalog) -> AntaCatalog: """Merge two AntaCatalog instances. @@ -397,7 +414,13 @@ def merge(self, catalog: AntaCatalog) -> AntaCatalog: ------- A new AntaCatalog instance containing the tests of the two instances. """ - return AntaCatalog(tests=self.tests + catalog.tests) + # TODO: Use a decorator to deprecate this method instead. See https://github.com/aristanetworks/anta/issues/754 + warn( + message="AntaCatalog.merge() is deprecated and will be removed in ANTA v2.0. Use AntaCatalog.merge_catalogs() instead.", + category=DeprecationWarning, + stacklevel=2, + ) + return self.merge_catalogs([self, catalog]) def dump(self) -> AntaCatalogFile: """Return an AntaCatalogFile instance from this AntaCatalog instance. diff --git a/docs/usage-inventory-catalog.md b/docs/usage-inventory-catalog.md index fd6aec320..5ae4cc923 100644 --- a/docs/usage-inventory-catalog.md +++ b/docs/usage-inventory-catalog.md @@ -309,7 +309,7 @@ Once you run `anta nrfu table`, you will see following output: ### Example script to merge catalogs -The following script reads all the files in `intended/test_catalogs/` with names `-catalog.yml` and merge them together inside one big catalog `anta-catalog.yml`. +The following script reads all the files in `intended/test_catalogs/` with names `-catalog.yml` and merge them together inside one big catalog `anta-catalog.yml` using the new `AntaCatalog.merge_catalogs()` class method. ```python #!/usr/bin/env python @@ -319,19 +319,26 @@ from pathlib import Path from anta.models import AntaTest -CATALOG_SUFFIX = '-catalog.yml' -CATALOG_DIR = 'intended/test_catalogs/' +CATALOG_SUFFIX = "-catalog.yml" +CATALOG_DIR = "intended/test_catalogs/" if __name__ == "__main__": - catalog = AntaCatalog() - for file in Path(CATALOG_DIR).glob('*'+CATALOG_SUFFIX): - c = AntaCatalog.parse(file) + catalogs = [] + for file in Path(CATALOG_DIR).glob("*" + CATALOG_SUFFIX): device = str(file).removesuffix(CATALOG_SUFFIX).removeprefix(CATALOG_DIR) - print(f"Merging test catalog for device {device}") - # Apply filters to all tests for this device - for test in c.tests: - test.inputs.filters = AntaTest.Input.Filters(tags=[device]) - catalog = catalog.merge(c) + print(f"Loading test catalog for device {device}") + catalog = AntaCatalog.parse(file) + # Add the device name as a tag to all tests in the catalog + for test in catalog.tests: + test.inputs.filters = AntaTest.Input.Filters(tags={device}) + catalogs.append(catalog) + + # Merge all catalogs + merged_catalog = AntaCatalog.merge_catalogs(catalogs) + + # Save the merged catalog to a file with open(Path('anta-catalog.yml'), "w") as f: f.write(catalog.dump().yaml()) ``` +!!! warning + The `AntaCatalog.merge()` method is deprecated and will be removed in ANTA v2.0. Please use the `AntaCatalog.merge_catalogs()` class method instead. diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py index 76358dd4a..13046f294 100644 --- a/tests/units/test_catalog.py +++ b/tests/units/test_catalog.py @@ -345,6 +345,17 @@ def test_get_tests_by_tags(self) -> None: tests = catalog.get_tests_by_tags(tags={"leaf", "spine"}, strict=True) assert len(tests) == 1 + def test_merge_catalogs(self) -> None: + """Test the merge_catalogs function.""" + # Load catalogs of different sizes + small_catalog = AntaCatalog.parse(DATA_DIR / "test_catalog.yml") + medium_catalog = AntaCatalog.parse(DATA_DIR / "test_catalog_medium.yml") + tagged_catalog = AntaCatalog.parse(DATA_DIR / "test_catalog_with_tags.yml") + + # Merge the catalogs and check the number of tests + final_catalog = AntaCatalog.merge_catalogs([small_catalog, medium_catalog, tagged_catalog]) + assert len(final_catalog.tests) == len(small_catalog.tests) + len(medium_catalog.tests) + len(tagged_catalog.tests) + def test_merge(self) -> None: """Test AntaCatalog.merge().""" catalog1: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog.yml") @@ -354,11 +365,15 @@ def test_merge(self) -> None: catalog3: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_medium.yml") assert len(catalog3.tests) == 228 - assert len(catalog1.merge(catalog2).tests) == 2 + with pytest.deprecated_call(): + merged_catalog = catalog1.merge(catalog2) + assert len(merged_catalog.tests) == 2 assert len(catalog1.tests) == 1 assert len(catalog2.tests) == 1 - assert len(catalog2.merge(catalog3).tests) == 229 + with pytest.deprecated_call(): + merged_catalog = catalog2.merge(catalog3) + assert len(merged_catalog.tests) == 229 assert len(catalog2.tests) == 1 assert len(catalog3.tests) == 228 From 90299d1ba32fc68b188541593af26aae34826a8a Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Tue, 27 Aug 2024 20:01:55 -0400 Subject: [PATCH 48/90] fix(anta): Remove JSON output when saving to a file (#800) --- anta/cli/nrfu/commands.py | 2 +- anta/cli/nrfu/utils.py | 21 ++++++++++----- docs/cli/nrfu.md | 6 ++--- tests/units/cli/nrfu/test_commands.py | 39 ++++++++++++++++++++++++++- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index cd750cb85..6043dbef9 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -42,7 +42,7 @@ def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> Non type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path), show_envvar=True, required=False, - help="Path to save report as a file", + help="Path to save report as a JSON file", ) def json(ctx: click.Context, output: pathlib.Path | None) -> None: """ANTA command to check network state with JSON result.""" diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index 284c9b709..cfc2e1ed1 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -94,14 +94,21 @@ def print_table(ctx: click.Context, group_by: Literal["device", "test"] | None = def print_json(ctx: click.Context, output: pathlib.Path | None = None) -> None: - """Print result in a json format.""" + """Print results as JSON. If output is provided, save to file instead.""" results = _get_result_manager(ctx) - console.print() - console.print(Panel("JSON results", style="cyan")) - rich.print_json(results.json) - if output is not None: - with output.open(mode="w", encoding="utf-8") as fout: - fout.write(results.json) + + if output is None: + console.print() + console.print(Panel("JSON results", style="cyan")) + rich.print_json(results.json) + else: + try: + with output.open(mode="w", encoding="utf-8") as file: + file.write(results.json) + console.print(f"JSON results saved to {output} ✅", style="cyan") + except OSError: + console.print(f"Failed to save JSON results to {output} ❌", style="cyan") + ctx.exit(ExitCode.USAGE_ERROR) def print_text(ctx: click.Context) -> None: diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index afed25949..2f4e7eedc 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -120,7 +120,7 @@ anta nrfu --test VerifyZeroTouch table ## Performing NRFU with JSON rendering -The JSON rendering command in NRFU testing is useful in generating a JSON output that can subsequently be passed on to another tool for reporting purposes. +The JSON rendering command in NRFU testing will generate an output of all test results in JSON format. ### Command overview @@ -131,12 +131,12 @@ Usage: anta nrfu json [OPTIONS] ANTA command to check network state with JSON result. Options: - -o, --output FILE Path to save report as a file [env var: + -o, --output FILE Path to save report as a JSON file [env var: ANTA_NRFU_JSON_OUTPUT] --help Show this message and exit. ``` -The `--output` option allows you to save the JSON report as a file. +The `--output` option allows you to save the JSON report as a file. If specified, no output will be displayed in the terminal. This is useful for further processing or integration with other tools. ### Example diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 8ad7745f4..803c8f803 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -8,7 +8,7 @@ import json import re from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from unittest.mock import patch from anta.cli import anta @@ -90,6 +90,43 @@ def test_anta_nrfu_json(click_runner: CliRunner) -> None: assert res["result"] == "success" +def test_anta_nrfu_json_output(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu json with output file.""" + json_output = tmp_path / "test.json" + result = click_runner.invoke(anta, ["nrfu", "json", "--output", str(json_output)]) + + # Making sure the output is not printed to stdout + match = re.search(r"\[\n {2}{[\s\S]+ {2}}\n\]", result.output) + assert match is None + + assert result.exit_code == ExitCode.OK + assert "JSON results saved to" in result.output + assert json_output.exists() + + +def test_anta_nrfu_json_output_failure(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu json with output file.""" + json_output = tmp_path / "test.json" + + original_open = Path.open + + def mock_path_open(*args: Any, **kwargs: Any) -> Path: # noqa: ANN401 + """Mock Path.open only for the json_output file of this test.""" + if args[0] == json_output: + msg = "Simulated OSError" + raise OSError(msg) + + # If not the json_output file, call the original Path.open + return original_open(*args, **kwargs) + + with patch("pathlib.Path.open", mock_path_open): + result = click_runner.invoke(anta, ["nrfu", "json", "--output", str(json_output)]) + + assert result.exit_code == ExitCode.USAGE_ERROR + assert "Failed to save JSON results to" in result.output + assert not json_output.exists() + + def test_anta_nrfu_template(click_runner: CliRunner) -> None: """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu", "tpl-report", "--template", str(DATA_DIR / "template.j2")]) From bb76a5ab23dddd7e9bb591522b7996c295d8b2b8 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Thu, 29 Aug 2024 03:43:38 -0400 Subject: [PATCH 49/90] doc: Fix merge_catalogs script (#804) fix(doc): Fix merge_catalogs script --- docs/usage-inventory-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage-inventory-catalog.md b/docs/usage-inventory-catalog.md index 5ae4cc923..d8a032f26 100644 --- a/docs/usage-inventory-catalog.md +++ b/docs/usage-inventory-catalog.md @@ -338,7 +338,7 @@ if __name__ == "__main__": # Save the merged catalog to a file with open(Path('anta-catalog.yml'), "w") as f: - f.write(catalog.dump().yaml()) + f.write(merged_catalog.dump().yaml()) ``` !!! warning The `AntaCatalog.merge()` method is deprecated and will be removed in ANTA v2.0. Please use the `AntaCatalog.merge_catalogs()` class method instead. From 7bb456000a3dadfedbe1d1df400edd8315867708 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Thu, 29 Aug 2024 08:14:06 -0400 Subject: [PATCH 50/90] feat(anta): Add Markdown report option to ANTA (#740) --- .pre-commit-config.yaml | 2 +- anta/cli/nrfu/__init__.py | 1 + anta/cli/nrfu/commands.py | 24 +- anta/cli/nrfu/utils.py | 17 + anta/constants.py | 19 ++ anta/reporter/__init__.py | 36 +- anta/reporter/md_reporter.py | 287 ++++++++++++++++ anta/result_manager/__init__.py | 154 ++++++++- anta/result_manager/models.py | 41 +++ docs/cli/nrfu.md | 25 +- docs/imgs/anta-nrfu-md-report-output.png | Bin 0 -> 165566 bytes docs/snippets/anta_nrfu_help.txt | 7 +- tests/data/test_md_report.md | 79 +++++ tests/data/test_md_report_results.json | 378 +++++++++++++++++++++ tests/lib/fixture.py | 32 +- tests/units/cli/nrfu/test__init__.py | 6 + tests/units/cli/nrfu/test_commands.py | 20 ++ tests/units/reporter/test_md_reporter.py | 54 +++ tests/units/result_manager/test__init__.py | 107 ++++++ 19 files changed, 1237 insertions(+), 52 deletions(-) create mode 100644 anta/constants.py create mode 100644 anta/reporter/md_reporter.py create mode 100644 docs/imgs/anta-nrfu-md-report-output.png create mode 100644 tests/data/test_md_report.md create mode 100644 tests/data/test_md_report_results.json create mode 100644 tests/units/reporter/test_md_reporter.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ceef2b6c5..f716fb97c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - name: Check and insert license on Markdown files id: insert-license files: .*\.md$ - # exclude: + exclude: ^tests/data/.*\.md$ args: - --license-filepath - .github/license-short.txt diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index a85277102..6263e845a 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -147,3 +147,4 @@ def nrfu( nrfu.add_command(commands.json) nrfu.add_command(commands.text) nrfu.add_command(commands.tpl_report) +nrfu.add_command(commands.md_report) diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index 6043dbef9..a5492680b 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -13,7 +13,7 @@ from anta.cli.utils import exit_with_code -from .utils import print_jinja, print_json, print_table, print_text, run_tests, save_to_csv +from .utils import print_jinja, print_json, print_table, print_text, run_tests, save_markdown_report, save_to_csv logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ required=False, ) def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> None: - """ANTA command to check network states with table result.""" + """ANTA command to check network state with table results.""" run_tests(ctx) print_table(ctx, group_by=group_by) exit_with_code(ctx) @@ -45,7 +45,7 @@ def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> Non help="Path to save report as a JSON file", ) def json(ctx: click.Context, output: pathlib.Path | None) -> None: - """ANTA command to check network state with JSON result.""" + """ANTA command to check network state with JSON results.""" run_tests(ctx) print_json(ctx, output=output) exit_with_code(ctx) @@ -54,7 +54,7 @@ def json(ctx: click.Context, output: pathlib.Path | None) -> None: @click.command() @click.pass_context def text(ctx: click.Context) -> None: - """ANTA command to check network states with text result.""" + """ANTA command to check network state with text results.""" run_tests(ctx) print_text(ctx) exit_with_code(ctx) @@ -105,3 +105,19 @@ def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path run_tests(ctx) print_jinja(results=ctx.obj["result_manager"], template=template, output=output) exit_with_code(ctx) + + +@click.command() +@click.pass_context +@click.option( + "--md-output", + type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path), + show_envvar=True, + required=True, + help="Path to save the report as a Markdown file", +) +def md_report(ctx: click.Context, md_output: pathlib.Path) -> None: + """ANTA command to check network state with Markdown report.""" + run_tests(ctx) + save_markdown_report(ctx, md_output=md_output) + exit_with_code(ctx) diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index cfc2e1ed1..748578dec 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -19,6 +19,7 @@ from anta.models import AntaTest from anta.reporter import ReportJinja, ReportTable from anta.reporter.csv_reporter import ReportCsv +from anta.reporter.md_reporter import MDReportGenerator from anta.runner import main if TYPE_CHECKING: @@ -141,6 +142,22 @@ def save_to_csv(ctx: click.Context, csv_file: pathlib.Path) -> None: ctx.exit(ExitCode.USAGE_ERROR) +def save_markdown_report(ctx: click.Context, md_output: pathlib.Path) -> None: + """Save the markdown report to a file. + + Parameters + ---------- + ctx: Click context containing the result manager. + md_output: Path to save the markdown report. + """ + try: + MDReportGenerator.generate(results=_get_result_manager(ctx), md_filename=md_output) + console.print(f"Markdown report saved to {md_output} ✅", style="cyan") + except OSError: + console.print(f"Failed to save Markdown report to {md_output} ❌", style="cyan") + ctx.exit(ExitCode.USAGE_ERROR) + + # Adding our own ANTA spinner - overriding rich SPINNERS for our own # so ignore warning for redefinition rich.spinner.SPINNERS = { # type: ignore[attr-defined] diff --git a/anta/constants.py b/anta/constants.py new file mode 100644 index 000000000..175a4adcc --- /dev/null +++ b/anta/constants.py @@ -0,0 +1,19 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Constants used in ANTA.""" + +from __future__ import annotations + +ACRONYM_CATEGORIES: set[str] = {"aaa", "mlag", "snmp", "bgp", "ospf", "vxlan", "stp", "igmp", "ip", "lldp", "ntp", "bfd", "ptp", "lanz", "stun", "vlan"} +"""A set of network protocol or feature acronyms that should be represented in uppercase.""" + +MD_REPORT_TOC = """**Table of Contents:** + +- [ANTA Report](#anta-report) + - [Test Results Summary](#test-results-summary) + - [Summary Totals](#summary-totals) + - [Summary Totals Device Under Test](#summary-totals-device-under-test) + - [Summary Totals Per Category](#summary-totals-per-category) + - [Test Results](#test-results)""" +"""Table of Contents for the Markdown report.""" diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index 7c911f243..c4e4f7bcf 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -154,21 +154,15 @@ def report_summary_tests( self.Headers.list_of_error_nodes, ] table = self._build_headers(headers=headers, table=table) - for test in manager.get_tests(): + for test, stats in sorted(manager.test_stats.items()): if tests is None or test in tests: - results = manager.filter_by_tests({test}).results - nb_failure = len([result for result in results if result.result == "failure"]) - nb_error = len([result for result in results if result.result == "error"]) - list_failure = [result.name for result in results if result.result in ["failure", "error"]] - nb_success = len([result for result in results if result.result == "success"]) - nb_skipped = len([result for result in results if result.result == "skipped"]) table.add_row( test, - str(nb_success), - str(nb_skipped), - str(nb_failure), - str(nb_error), - str(list_failure), + str(stats.devices_success_count), + str(stats.devices_skipped_count), + str(stats.devices_failure_count), + str(stats.devices_error_count), + ", ".join(stats.devices_failure), ) return table @@ -202,21 +196,15 @@ def report_summary_devices( self.Headers.list_of_error_tests, ] table = self._build_headers(headers=headers, table=table) - for device in manager.get_devices(): + for device, stats in sorted(manager.device_stats.items()): if devices is None or device in devices: - results = manager.filter_by_devices({device}).results - nb_failure = len([result for result in results if result.result == "failure"]) - nb_error = len([result for result in results if result.result == "error"]) - list_failure = [result.test for result in results if result.result in ["failure", "error"]] - nb_success = len([result for result in results if result.result == "success"]) - nb_skipped = len([result for result in results if result.result == "skipped"]) table.add_row( device, - str(nb_success), - str(nb_skipped), - str(nb_failure), - str(nb_error), - str(list_failure), + str(stats.tests_success_count), + str(stats.tests_skipped_count), + str(stats.tests_failure_count), + str(stats.tests_error_count), + ", ".join(stats.tests_failure), ) return table diff --git a/anta/reporter/md_reporter.py b/anta/reporter/md_reporter.py new file mode 100644 index 000000000..0cc5b03e2 --- /dev/null +++ b/anta/reporter/md_reporter.py @@ -0,0 +1,287 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Markdown report generator for ANTA test results.""" + +from __future__ import annotations + +import logging +import re +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, ClassVar + +from anta.constants import MD_REPORT_TOC +from anta.logger import anta_log_exception + +if TYPE_CHECKING: + from collections.abc import Generator + from io import TextIOWrapper + from pathlib import Path + + from anta.result_manager import ResultManager + +logger = logging.getLogger(__name__) + + +# pylint: disable=too-few-public-methods +class MDReportGenerator: + """Class responsible for generating a Markdown report based on the provided `ResultManager` object. + + It aggregates different report sections, each represented by a subclass of `MDReportBase`, + and sequentially generates their content into a markdown file. + + The `generate` class method will loop over all the section subclasses and call their `generate_section` method. + The final report will be generated in the same order as the `sections` list of the method. + """ + + @classmethod + def generate(cls, results: ResultManager, md_filename: Path) -> None: + """Generate and write the various sections of the markdown report. + + Parameters + ---------- + results: The ResultsManager instance containing all test results. + md_filename: The path to the markdown file to write the report into. + """ + try: + with md_filename.open("w", encoding="utf-8") as mdfile: + sections: list[MDReportBase] = [ + ANTAReport(mdfile, results), + TestResultsSummary(mdfile, results), + SummaryTotals(mdfile, results), + SummaryTotalsDeviceUnderTest(mdfile, results), + SummaryTotalsPerCategory(mdfile, results), + TestResults(mdfile, results), + ] + for section in sections: + section.generate_section() + except OSError as exc: + message = f"OSError caught while writing the Markdown file '{md_filename.resolve()}'." + anta_log_exception(exc, message, logger) + raise + + +class MDReportBase(ABC): + """Base class for all sections subclasses. + + Every subclasses must implement the `generate_section` method that uses the `ResultManager` object + to generate and write content to the provided markdown file. + """ + + def __init__(self, mdfile: TextIOWrapper, results: ResultManager) -> None: + """Initialize the MDReportBase with an open markdown file object to write to and a ResultManager instance. + + Parameters + ---------- + mdfile: An open file object to write the markdown data into. + results: The ResultsManager instance containing all test results. + """ + self.mdfile = mdfile + self.results = results + + @abstractmethod + def generate_section(self) -> None: + """Abstract method to generate a specific section of the markdown report. + + Must be implemented by subclasses. + """ + msg = "Must be implemented by subclasses" + raise NotImplementedError(msg) + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of a markdown table for a specific report section. + + Subclasses can implement this method to generate the content of the table rows. + """ + msg = "Subclasses should implement this method" + raise NotImplementedError(msg) + + def generate_heading_name(self) -> str: + """Generate a formatted heading name based on the class name. + + Returns + ------- + str: Formatted header name. + + Example + ------- + - `ANTAReport` will become ANTA Report. + - `TestResultsSummary` will become Test Results Summary. + """ + class_name = self.__class__.__name__ + + # Split the class name into words, keeping acronyms together + words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\W|$)|\d+", class_name) + + # Capitalize each word, but keep acronyms in all caps + formatted_words = [word if word.isupper() else word.capitalize() for word in words] + + return " ".join(formatted_words) + + def write_table(self, table_heading: list[str], *, last_table: bool = False) -> None: + """Write a markdown table with a table heading and multiple rows to the markdown file. + + Parameters + ---------- + table_heading: List of strings to join for the table heading. + last_table: Flag to determine if it's the last table of the markdown file to avoid unnecessary new line. Defaults to False. + """ + self.mdfile.write("\n".join(table_heading) + "\n") + for row in self.generate_rows(): + self.mdfile.write(row) + if not last_table: + self.mdfile.write("\n") + + def write_heading(self, heading_level: int) -> None: + """Write a markdown heading to the markdown file. + + The heading name used is the class name. + + Parameters + ---------- + heading_level: The level of the heading (1-6). + + Example + ------- + ## Test Results Summary + """ + # Ensure the heading level is within the valid range of 1 to 6 + heading_level = max(1, min(heading_level, 6)) + heading_name = self.generate_heading_name() + heading = "#" * heading_level + " " + heading_name + self.mdfile.write(f"{heading}\n\n") + + def safe_markdown(self, text: str | None) -> str: + """Escape markdown characters in the text to prevent markdown rendering issues. + + Parameters + ---------- + text: The text to escape markdown characters from. + + Returns + ------- + str: The text with escaped markdown characters. + """ + # Custom field from a TestResult object can be None + if text is None: + return "" + + # Replace newlines with spaces to keep content on one line + text = text.replace("\n", " ") + + # Replace backticks with single quotes + return text.replace("`", "'") + + +class ANTAReport(MDReportBase): + """Generate the `# ANTA Report` section of the markdown report.""" + + def generate_section(self) -> None: + """Generate the `# ANTA Report` section of the markdown report.""" + self.write_heading(heading_level=1) + toc = MD_REPORT_TOC + self.mdfile.write(toc + "\n\n") + + +class TestResultsSummary(MDReportBase): + """Generate the `## Test Results Summary` section of the markdown report.""" + + def generate_section(self) -> None: + """Generate the `## Test Results Summary` section of the markdown report.""" + self.write_heading(heading_level=2) + + +class SummaryTotals(MDReportBase): + """Generate the `### Summary Totals` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Total Tests | Total Tests Success | Total Tests Skipped | Total Tests Failure | Total Tests Error |", + "| ----------- | ------------------- | ------------------- | ------------------- | ------------------|", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the summary totals table.""" + yield ( + f"| {self.results.get_total_results()} " + f"| {self.results.get_total_results({'success'})} " + f"| {self.results.get_total_results({'skipped'})} " + f"| {self.results.get_total_results({'failure'})} " + f"| {self.results.get_total_results({'error'})} |\n" + ) + + def generate_section(self) -> None: + """Generate the `### Summary Totals` section of the markdown report.""" + self.write_heading(heading_level=3) + self.write_table(table_heading=self.TABLE_HEADING) + + +class SummaryTotalsDeviceUnderTest(MDReportBase): + """Generate the `### Summary Totals Devices Under Tests` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Device Under Test | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error | Categories Skipped | Categories Failed |", + "| ------------------| ----------- | ------------- | ------------- | ------------- | ----------- | -------------------| ------------------|", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the summary totals device under test table.""" + for device, stat in self.results.device_stats.items(): + total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count + categories_skipped = ", ".join(sorted(stat.categories_skipped)) + categories_failed = ", ".join(sorted(stat.categories_failed)) + yield ( + f"| {device} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} | {stat.tests_error_count} " + f"| {categories_skipped or '-'} | {categories_failed or '-'} |\n" + ) + + def generate_section(self) -> None: + """Generate the `### Summary Totals Devices Under Tests` section of the markdown report.""" + self.write_heading(heading_level=3) + self.write_table(table_heading=self.TABLE_HEADING) + + +class SummaryTotalsPerCategory(MDReportBase): + """Generate the `### Summary Totals Per Category` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Test Category | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error |", + "| ------------- | ----------- | ------------- | ------------- | ------------- | ----------- |", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the summary totals per category table.""" + for category, stat in self.results.sorted_category_stats.items(): + total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count + yield ( + f"| {category} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} " + f"| {stat.tests_error_count} |\n" + ) + + def generate_section(self) -> None: + """Generate the `### Summary Totals Per Category` section of the markdown report.""" + self.write_heading(heading_level=3) + self.write_table(table_heading=self.TABLE_HEADING) + + +class TestResults(MDReportBase): + """Generates the `## Test Results` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Device Under Test | Categories | Test | Description | Custom Field | Result | Messages |", + "| ----------------- | ---------- | ---- | ----------- | ------------ | ------ | -------- |", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the all test results table.""" + for result in self.results.get_results(sort_by=["name", "test"]): + messages = self.safe_markdown(", ".join(result.messages)) + categories = ", ".join(result.categories) + yield ( + f"| {result.name or '-'} | {categories or '-'} | {result.test or '-'} " + f"| {result.description or '-'} | {self.safe_markdown(result.custom_field) or '-'} | {result.result or '-'} | {messages or '-'} |\n" + ) + + def generate_section(self) -> None: + """Generate the `## Test Results` section of the markdown report.""" + self.write_heading(heading_level=2) + self.write_table(table_heading=self.TABLE_HEADING, last_table=True) diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index 4278c0da3..1900a28b1 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -6,14 +6,18 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING +from collections import defaultdict +from functools import cached_property +from itertools import chain +from typing import get_args from pydantic import TypeAdapter +from anta.constants import ACRONYM_CATEGORIES from anta.custom_types import TestStatus +from anta.result_manager.models import TestResult -if TYPE_CHECKING: - from anta.result_manager.models import TestResult +from .models import CategoryStats, DeviceStats, TestStats class ResultManager: @@ -94,6 +98,10 @@ def __init__(self) -> None: self.status: TestStatus = "unset" self.error_status = False + self.device_stats: defaultdict[str, DeviceStats] = defaultdict(DeviceStats) + self.category_stats: defaultdict[str, CategoryStats] = defaultdict(CategoryStats) + self.test_stats: defaultdict[str, TestStats] = defaultdict(TestStats) + def __len__(self) -> int: """Implement __len__ method to count number of results.""" return len(self._result_entries) @@ -105,38 +113,147 @@ def results(self) -> list[TestResult]: @results.setter def results(self, value: list[TestResult]) -> None: + """Set the list of TestResult.""" + # When setting the results, we need to reset the state of the current instance self._result_entries = [] self.status = "unset" self.error_status = False - for e in value: - self.add(e) + + # Also reset the stats attributes + self.device_stats = defaultdict(DeviceStats) + self.category_stats = defaultdict(CategoryStats) + self.test_stats = defaultdict(TestStats) + + for result in value: + self.add(result) @property def json(self) -> str: """Get a JSON representation of the results.""" return json.dumps([result.model_dump() for result in self._result_entries], indent=4) + @property + def sorted_category_stats(self) -> dict[str, CategoryStats]: + """A property that returns the category_stats dictionary sorted by key name.""" + return dict(sorted(self.category_stats.items())) + + @cached_property + def results_by_status(self) -> dict[TestStatus, list[TestResult]]: + """A cached property that returns the results grouped by status.""" + return {status: [result for result in self._result_entries if result.result == status] for status in get_args(TestStatus)} + + def _update_status(self, test_status: TestStatus) -> None: + """Update the status of the ResultManager instance based on the test status. + + Parameters + ---------- + test_status: TestStatus to update the ResultManager status. + """ + result_validator: TypeAdapter[TestStatus] = TypeAdapter(TestStatus) + result_validator.validate_python(test_status) + if test_status == "error": + self.error_status = True + return + if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}: + self.status = test_status + elif self.status == "success" and test_status == "failure": + self.status = "failure" + + def _update_stats(self, result: TestResult) -> None: + """Update the statistics based on the test result. + + Parameters + ---------- + result: TestResult to update the statistics. + """ + result.categories = [ + " ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in result.categories + ] + count_attr = f"tests_{result.result}_count" + + # Update device stats + device_stats: DeviceStats = self.device_stats[result.name] + setattr(device_stats, count_attr, getattr(device_stats, count_attr) + 1) + if result.result in ("failure", "error"): + device_stats.tests_failure.add(result.test) + device_stats.categories_failed.update(result.categories) + elif result.result == "skipped": + device_stats.categories_skipped.update(result.categories) + + # Update category stats + for category in result.categories: + category_stats: CategoryStats = self.category_stats[category] + setattr(category_stats, count_attr, getattr(category_stats, count_attr) + 1) + + # Update test stats + count_attr = f"devices_{result.result}_count" + test_stats: TestStats = self.test_stats[result.test] + setattr(test_stats, count_attr, getattr(test_stats, count_attr) + 1) + if result.result in ("failure", "error"): + test_stats.devices_failure.add(result.name) + def add(self, result: TestResult) -> None: """Add a result to the ResultManager instance. + The result is added to the internal list of results and the overall status + of the ResultManager instance is updated based on the added test status. + Parameters ---------- result: TestResult to add to the ResultManager instance. """ + self._result_entries.append(result) + self._update_status(result.result) + self._update_stats(result) - def _update_status(test_status: TestStatus) -> None: - result_validator: TypeAdapter[TestStatus] = TypeAdapter(TestStatus) - result_validator.validate_python(test_status) - if test_status == "error": - self.error_status = True - return - if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}: - self.status = test_status - elif self.status == "success" and test_status == "failure": - self.status = "failure" + # Every time a new result is added, we need to clear the cached property + self.__dict__.pop("results_by_status", None) - self._result_entries.append(result) - _update_status(result.result) + def get_results(self, status: set[TestStatus] | None = None, sort_by: list[str] | None = None) -> list[TestResult]: + """Get the results, optionally filtered by status and sorted by TestResult fields. + + If no status is provided, all results are returned. + + Parameters + ---------- + status: Optional set of TestStatus literals to filter the results. + sort_by: Optional list of TestResult fields to sort the results. + + Returns + ------- + List of TestResult. + """ + # Return all results if no status is provided, otherwise return results for multiple statuses + results = self._result_entries if status is None else list(chain.from_iterable(self.results_by_status.get(status, []) for status in status)) + + if sort_by: + accepted_fields = TestResult.model_fields.keys() + if not set(sort_by).issubset(set(accepted_fields)): + msg = f"Invalid sort_by fields: {sort_by}. Accepted fields are: {list(accepted_fields)}" + raise ValueError(msg) + results = sorted(results, key=lambda result: [getattr(result, field) for field in sort_by]) + + return results + + def get_total_results(self, status: set[TestStatus] | None = None) -> int: + """Get the total number of results, optionally filtered by status. + + If no status is provided, the total number of results is returned. + + Parameters + ---------- + status: Optional set of TestStatus literals to filter the results. + + Returns + ------- + Total number of results. + """ + if status is None: + # Return the total number of results + return sum(len(results) for results in self.results_by_status.values()) + + # Return the total number of results for multiple statuses + return sum(len(self.results_by_status.get(status, [])) for status in status) def get_status(self, *, ignore_error: bool = False) -> str: """Return the current status including error_status if ignore_error is False.""" @@ -153,8 +270,9 @@ def filter(self, hide: set[TestStatus]) -> ResultManager: ------- A filtered `ResultManager`. """ + possible_statuses = set(get_args(TestStatus)) manager = ResultManager() - manager.results = [test for test in self._result_entries if test.result not in hide] + manager.results = self.get_results(possible_statuses - hide) return manager def filter_by_tests(self, tests: set[str]) -> ResultManager: diff --git a/anta/result_manager/models.py b/anta/result_manager/models.py index e1171c88a..6abce0233 100644 --- a/anta/result_manager/models.py +++ b/anta/result_manager/models.py @@ -5,6 +5,8 @@ from __future__ import annotations +from dataclasses import dataclass, field + from pydantic import BaseModel from anta.custom_types import TestStatus @@ -89,3 +91,42 @@ def _set_status(self, status: TestStatus, message: str | None = None) -> None: def __str__(self) -> str: """Return a human readable string of this TestResult.""" return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}" + + +# Pylint does not treat dataclasses differently: https://github.com/pylint-dev/pylint/issues/9058 +# pylint: disable=too-many-instance-attributes +@dataclass +class DeviceStats: + """Device statistics for a run of tests.""" + + tests_success_count: int = 0 + tests_skipped_count: int = 0 + tests_failure_count: int = 0 + tests_error_count: int = 0 + tests_unset_count: int = 0 + tests_failure: set[str] = field(default_factory=set) + categories_failed: set[str] = field(default_factory=set) + categories_skipped: set[str] = field(default_factory=set) + + +@dataclass +class CategoryStats: + """Category statistics for a run of tests.""" + + tests_success_count: int = 0 + tests_skipped_count: int = 0 + tests_failure_count: int = 0 + tests_error_count: int = 0 + tests_unset_count: int = 0 + + +@dataclass +class TestStats: + """Test statistics for a run of tests.""" + + devices_success_count: int = 0 + devices_skipped_count: int = 0 + devices_failure_count: int = 0 + devices_error_count: int = 0 + devices_unset_count: int = 0 + devices_failure: set[str] = field(default_factory=set) diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index 2f4e7eedc..0de782551 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -45,7 +45,7 @@ Options `--device` and `--test` can be used to target one or multiple devices an ### Hide results -Option `--hide` can be used to hide test results in the output based on their status. The option can be repeated. Example: `anta nrfu --hide error --hide skipped`. +Option `--hide` can be used to hide test results in the output or report file based on their status. The option can be repeated. Example: `anta nrfu --hide error --hide skipped`. ## Performing NRFU with text rendering @@ -167,6 +167,29 @@ Options: ![anta nrfu csv results](../imgs/anta_nrfu_csv.png){ loading=lazy width="1600" } +## Performing NRFU and saving results in a Markdown file + +The `md-report` command in NRFU testing generates a comprehensive Markdown report containing various sections, including detailed statistics for devices and test categories. + +### Command overview + +```bash +anta nrfu md-report --help + +Usage: anta nrfu md-report [OPTIONS] + + ANTA command to check network state with Markdown report. + +Options: + --md-output FILE Path to save the report as a Markdown file [env var: + ANTA_NRFU_MD_REPORT_MD_OUTPUT; required] + --help Show this message and exit. +``` + +### Example + +![anta nrfu md-report results](../imgs/anta-nrfu-md-report-output.png){ loading=lazy width="1600" } + ## Performing NRFU with custom reports ANTA offers a CLI option for creating custom reports. This leverages the Jinja2 template system, allowing you to tailor reports to your specific needs. diff --git a/docs/imgs/anta-nrfu-md-report-output.png b/docs/imgs/anta-nrfu-md-report-output.png new file mode 100644 index 0000000000000000000000000000000000000000..984e76b5c6eab6d4b195cf3ddb60d8a63b379c9c GIT binary patch literal 165566 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�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%0A1j5MtH`efDNx%`@NqR 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 literal 0 HcmV?d00001 diff --git a/docs/snippets/anta_nrfu_help.txt b/docs/snippets/anta_nrfu_help.txt index 365da0474..cb23fa7ed 100644 --- a/docs/snippets/anta_nrfu_help.txt +++ b/docs/snippets/anta_nrfu_help.txt @@ -53,7 +53,8 @@ Options: Commands: csv ANTA command to check network state with CSV report. - json ANTA command to check network state with JSON result. - table ANTA command to check network states with table result. - text ANTA command to check network states with text result. + json ANTA command to check network state with JSON results. + md-report ANTA command to check network state with Markdown report. + table ANTA command to check network state with table results. + text ANTA command to check network state with text results. tpl-report ANTA command to check network state with templated report. diff --git a/tests/data/test_md_report.md b/tests/data/test_md_report.md new file mode 100644 index 000000000..9360dbc74 --- /dev/null +++ b/tests/data/test_md_report.md @@ -0,0 +1,79 @@ +# ANTA Report + +**Table of Contents:** + +- [ANTA Report](#anta-report) + - [Test Results Summary](#test-results-summary) + - [Summary Totals](#summary-totals) + - [Summary Totals Device Under Test](#summary-totals-device-under-test) + - [Summary Totals Per Category](#summary-totals-per-category) + - [Test Results](#test-results) + +## Test Results Summary + +### Summary Totals + +| Total Tests | Total Tests Success | Total Tests Skipped | Total Tests Failure | Total Tests Error | +| ----------- | ------------------- | ------------------- | ------------------- | ------------------| +| 30 | 7 | 2 | 19 | 2 | + +### Summary Totals Device Under Test + +| Device Under Test | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error | Categories Skipped | Categories Failed | +| ------------------| ----------- | ------------- | ------------- | ------------- | ----------- | -------------------| ------------------| +| DC1-SPINE1 | 15 | 2 | 2 | 10 | 1 | MLAG, VXLAN | AAA, BFD, BGP, Connectivity, Routing, SNMP, STP, Services, Software, System | +| DC1-LEAF1A | 15 | 5 | 0 | 9 | 1 | - | AAA, BFD, BGP, Connectivity, SNMP, STP, Services, Software, System | + +### Summary Totals Per Category + +| Test Category | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error | +| ------------- | ----------- | ------------- | ------------- | ------------- | ----------- | +| AAA | 2 | 0 | 0 | 2 | 0 | +| BFD | 2 | 0 | 0 | 2 | 0 | +| BGP | 2 | 0 | 0 | 2 | 0 | +| Connectivity | 4 | 0 | 0 | 2 | 2 | +| Interfaces | 2 | 2 | 0 | 0 | 0 | +| MLAG | 2 | 1 | 1 | 0 | 0 | +| Routing | 2 | 1 | 0 | 1 | 0 | +| SNMP | 2 | 0 | 0 | 2 | 0 | +| STP | 2 | 0 | 0 | 2 | 0 | +| Security | 2 | 2 | 0 | 0 | 0 | +| Services | 2 | 0 | 0 | 2 | 0 | +| Software | 2 | 0 | 0 | 2 | 0 | +| System | 2 | 0 | 0 | 2 | 0 | +| VXLAN | 2 | 1 | 1 | 0 | 0 | + +## Test Results + +| Device Under Test | Categories | Test | Description | Custom Field | Result | Messages | +| ----------------- | ---------- | ---- | ----------- | ------------ | ------ | -------- | +| DC1-LEAF1A | BFD | VerifyBFDSpecificPeers | Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF. | - | failure | Following BFD peers are not configured, status is not up or remote disc is zero: {'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}} | +| DC1-LEAF1A | BGP | VerifyBGPPeerCount | Verifies the count of BGP peers. | - | failure | Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Expected: 2, Actual: 1'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Expected: 3, Actual: 0'}}] | +| DC1-LEAF1A | Software | VerifyEOSVersion | Verifies the EOS version of the device. | - | failure | device is running version "4.31.1F-34554157.4311F (engineering build)" not in expected versions: ['4.25.4M', '4.26.1F'] | +| DC1-LEAF1A | Services | VerifyHostname | Verifies the hostname of a device. | - | failure | Expected 's1-spine1' as the hostname, but found 'DC1-LEAF1A' instead. | +| DC1-LEAF1A | Interfaces | VerifyInterfaceUtilization | Verifies that the utilization of interfaces is below a certain threshold. | - | success | - | +| DC1-LEAF1A | Connectivity | VerifyLLDPNeighbors | Verifies that the provided LLDP neighbors are connected properly. | - | failure | Wrong LLDP neighbor(s) on port(s): Ethernet1 DC1-SPINE1_Ethernet1 Ethernet2 DC1-SPINE2_Ethernet1 Port(s) not configured: Ethernet7 | +| DC1-LEAF1A | MLAG | VerifyMlagStatus | Verifies the health status of the MLAG configuration. | - | success | - | +| DC1-LEAF1A | System | VerifyNTP | Verifies if NTP is synchronised. | - | failure | The device is not synchronized with the configured NTP server(s): 'NTP is disabled.' | +| DC1-LEAF1A | Connectivity | VerifyReachability | Test the network reachability to one or many destination IP(s). | - | error | ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1 | +| DC1-LEAF1A | Routing | VerifyRoutingTableEntry | Verifies that the provided routes are present in the routing table of a specified VRF. | - | success | - | +| DC1-LEAF1A | STP | VerifySTPMode | Verifies the configured STP mode for a provided list of VLAN(s). | - | failure | Wrong STP mode configured for the following VLAN(s): [10, 20] | +| DC1-LEAF1A | SNMP | VerifySnmpStatus | Verifies if the SNMP agent is enabled. | - | failure | SNMP agent disabled in vrf default | +| DC1-LEAF1A | AAA | VerifyTacacsSourceIntf | Verifies TACACS source-interface for a specified VRF. | - | failure | Source-interface Management0 is not configured in VRF default | +| DC1-LEAF1A | Security | VerifyTelnetStatus | Verifies if Telnet is disabled in the default VRF. | - | success | - | +| DC1-LEAF1A | VXLAN | VerifyVxlan1Interface | Verifies the Vxlan1 interface status. | - | success | - | +| DC1-SPINE1 | BFD | VerifyBFDSpecificPeers | Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF. | - | failure | Following BFD peers are not configured, status is not up or remote disc is zero: {'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}} | +| DC1-SPINE1 | BGP | VerifyBGPPeerCount | Verifies the count of BGP peers. | - | failure | Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Not Configured', 'default': 'Expected: 3, Actual: 4'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Not Configured'}}, {'afi': 'evpn', 'vrfs': {'default': 'Expected: 2, Actual: 4'}}] | +| DC1-SPINE1 | Software | VerifyEOSVersion | Verifies the EOS version of the device. | - | failure | device is running version "4.31.1F-34554157.4311F (engineering build)" not in expected versions: ['4.25.4M', '4.26.1F'] | +| DC1-SPINE1 | Services | VerifyHostname | Verifies the hostname of a device. | - | failure | Expected 's1-spine1' as the hostname, but found 'DC1-SPINE1' instead. | +| DC1-SPINE1 | Interfaces | VerifyInterfaceUtilization | Verifies that the utilization of interfaces is below a certain threshold. | - | success | - | +| DC1-SPINE1 | Connectivity | VerifyLLDPNeighbors | Verifies that the provided LLDP neighbors are connected properly. | - | failure | Wrong LLDP neighbor(s) on port(s): Ethernet1 DC1-LEAF1A_Ethernet1 Ethernet2 DC1-LEAF1B_Ethernet1 Port(s) not configured: Ethernet7 | +| DC1-SPINE1 | MLAG | VerifyMlagStatus | Verifies the health status of the MLAG configuration. | - | skipped | MLAG is disabled | +| DC1-SPINE1 | System | VerifyNTP | Verifies if NTP is synchronised. | - | failure | The device is not synchronized with the configured NTP server(s): 'NTP is disabled.' | +| DC1-SPINE1 | Connectivity | VerifyReachability | Test the network reachability to one or many destination IP(s). | - | error | ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1 | +| DC1-SPINE1 | Routing | VerifyRoutingTableEntry | Verifies that the provided routes are present in the routing table of a specified VRF. | - | failure | The following route(s) are missing from the routing table of VRF default: ['10.1.0.2'] | +| DC1-SPINE1 | STP | VerifySTPMode | Verifies the configured STP mode for a provided list of VLAN(s). | - | failure | STP mode 'rapidPvst' not configured for the following VLAN(s): [10, 20] | +| DC1-SPINE1 | SNMP | VerifySnmpStatus | Verifies if the SNMP agent is enabled. | - | failure | SNMP agent disabled in vrf default | +| DC1-SPINE1 | AAA | VerifyTacacsSourceIntf | Verifies TACACS source-interface for a specified VRF. | - | failure | Source-interface Management0 is not configured in VRF default | +| DC1-SPINE1 | Security | VerifyTelnetStatus | Verifies if Telnet is disabled in the default VRF. | - | success | - | +| DC1-SPINE1 | VXLAN | VerifyVxlan1Interface | Verifies the Vxlan1 interface status. | - | skipped | Vxlan1 interface is not configured | diff --git a/tests/data/test_md_report_results.json b/tests/data/test_md_report_results.json new file mode 100644 index 000000000..b9ecc0c57 --- /dev/null +++ b/tests/data/test_md_report_results.json @@ -0,0 +1,378 @@ +[ + { + "name": "DC1-SPINE1", + "test": "VerifyTacacsSourceIntf", + "categories": [ + "AAA" + ], + "description": "Verifies TACACS source-interface for a specified VRF.", + "result": "failure", + "messages": [ + "Source-interface Management0 is not configured in VRF default" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyLLDPNeighbors", + "categories": [ + "Connectivity" + ], + "description": "Verifies that the provided LLDP neighbors are connected properly.", + "result": "failure", + "messages": [ + "Wrong LLDP neighbor(s) on port(s):\n Ethernet1\n DC1-LEAF1A_Ethernet1\n Ethernet2\n DC1-LEAF1B_Ethernet1\nPort(s) not configured:\n Ethernet7" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyBGPPeerCount", + "categories": [ + "BGP" + ], + "description": "Verifies the count of BGP peers.", + "result": "failure", + "messages": [ + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Not Configured', 'default': 'Expected: 3, Actual: 4'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Not Configured'}}, {'afi': 'evpn', 'vrfs': {'default': 'Expected: 2, Actual: 4'}}]" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifySTPMode", + "categories": [ + "STP" + ], + "description": "Verifies the configured STP mode for a provided list of VLAN(s).", + "result": "failure", + "messages": [ + "STP mode 'rapidPvst' not configured for the following VLAN(s): [10, 20]" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifySnmpStatus", + "categories": [ + "SNMP" + ], + "description": "Verifies if the SNMP agent is enabled.", + "result": "failure", + "messages": [ + "SNMP agent disabled in vrf default" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyRoutingTableEntry", + "categories": [ + "Routing" + ], + "description": "Verifies that the provided routes are present in the routing table of a specified VRF.", + "result": "failure", + "messages": [ + "The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyInterfaceUtilization", + "categories": [ + "Interfaces" + ], + "description": "Verifies that the utilization of interfaces is below a certain threshold.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyMlagStatus", + "categories": [ + "MLAG" + ], + "description": "Verifies the health status of the MLAG configuration.", + "result": "skipped", + "messages": [ + "MLAG is disabled" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyVxlan1Interface", + "categories": [ + "VXLAN" + ], + "description": "Verifies the Vxlan1 interface status.", + "result": "skipped", + "messages": [ + "Vxlan1 interface is not configured" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyBFDSpecificPeers", + "categories": [ + "BFD" + ], + "description": "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF.", + "result": "failure", + "messages": [ + "Following BFD peers are not configured, status is not up or remote disc is zero:\n{'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}}" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyNTP", + "categories": [ + "System" + ], + "description": "Verifies if NTP is synchronised.", + "result": "failure", + "messages": [ + "The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyReachability", + "categories": [ + "Connectivity" + ], + "description": "Test the network reachability to one or many destination IP(s).", + "result": "error", + "messages": [ + "ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyTelnetStatus", + "categories": [ + "Security" + ], + "description": "Verifies if Telnet is disabled in the default VRF.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyEOSVersion", + "categories": [ + "Software" + ], + "description": "Verifies the EOS version of the device.", + "result": "failure", + "messages": [ + "device is running version \"4.31.1F-34554157.4311F (engineering build)\" not in expected versions: ['4.25.4M', '4.26.1F']" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyHostname", + "categories": [ + "Services" + ], + "description": "Verifies the hostname of a device.", + "result": "failure", + "messages": [ + "Expected `s1-spine1` as the hostname, but found `DC1-SPINE1` instead." + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyTacacsSourceIntf", + "categories": [ + "AAA" + ], + "description": "Verifies TACACS source-interface for a specified VRF.", + "result": "failure", + "messages": [ + "Source-interface Management0 is not configured in VRF default" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyLLDPNeighbors", + "categories": [ + "Connectivity" + ], + "description": "Verifies that the provided LLDP neighbors are connected properly.", + "result": "failure", + "messages": [ + "Wrong LLDP neighbor(s) on port(s):\n Ethernet1\n DC1-SPINE1_Ethernet1\n Ethernet2\n DC1-SPINE2_Ethernet1\nPort(s) not configured:\n Ethernet7" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyBGPPeerCount", + "categories": [ + "BGP" + ], + "description": "Verifies the count of BGP peers.", + "result": "failure", + "messages": [ + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Expected: 2, Actual: 1'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Expected: 3, Actual: 0'}}]" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifySTPMode", + "categories": [ + "STP" + ], + "description": "Verifies the configured STP mode for a provided list of VLAN(s).", + "result": "failure", + "messages": [ + "Wrong STP mode configured for the following VLAN(s): [10, 20]" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifySnmpStatus", + "categories": [ + "SNMP" + ], + "description": "Verifies if the SNMP agent is enabled.", + "result": "failure", + "messages": [ + "SNMP agent disabled in vrf default" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyRoutingTableEntry", + "categories": [ + "Routing" + ], + "description": "Verifies that the provided routes are present in the routing table of a specified VRF.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyInterfaceUtilization", + "categories": [ + "Interfaces" + ], + "description": "Verifies that the utilization of interfaces is below a certain threshold.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyMlagStatus", + "categories": [ + "MLAG" + ], + "description": "Verifies the health status of the MLAG configuration.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyVxlan1Interface", + "categories": [ + "VXLAN" + ], + "description": "Verifies the Vxlan1 interface status.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyBFDSpecificPeers", + "categories": [ + "BFD" + ], + "description": "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF.", + "result": "failure", + "messages": [ + "Following BFD peers are not configured, status is not up or remote disc is zero:\n{'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}}" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyNTP", + "categories": [ + "System" + ], + "description": "Verifies if NTP is synchronised.", + "result": "failure", + "messages": [ + "The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyReachability", + "categories": [ + "Connectivity" + ], + "description": "Test the network reachability to one or many destination IP(s).", + "result": "error", + "messages": [ + "ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyTelnetStatus", + "categories": [ + "Security" + ], + "description": "Verifies if Telnet is disabled in the default VRF.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyEOSVersion", + "categories": [ + "Software" + ], + "description": "Verifies the EOS version of the device.", + "result": "failure", + "messages": [ + "device is running version \"4.31.1F-34554157.4311F (engineering build)\" not in expected versions: ['4.25.4M', '4.26.1F']" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyHostname", + "categories": [ + "Services" + ], + "description": "Verifies the hostname of a device.", + "result": "failure", + "messages": [ + "Expected `s1-spine1` as the hostname, but found `DC1-LEAF1A` instead." + ], + "custom_field": null + } +] diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index b0205b8bb..92210acfa 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -5,8 +5,10 @@ from __future__ import annotations +import json import logging import shutil +from pathlib import Path from typing import TYPE_CHECKING, Any, Callable from unittest.mock import patch @@ -23,12 +25,15 @@ if TYPE_CHECKING: from collections.abc import Iterator - from pathlib import Path from anta.models import AntaCommand logger = logging.getLogger(__name__) +DATA_DIR: Path = Path(__file__).parent.parent.resolve() / "data" + +JSON_RESULTS = "test_md_report_results.json" + DEVICE_HW_MODEL = "pytest" DEVICE_NAME = "pytest" COMMAND_OUTPUT = "retrieved" @@ -154,6 +159,31 @@ def _factory(number: int = 0) -> ResultManager: return _factory +@pytest.fixture +def result_manager() -> ResultManager: + """Return a ResultManager with 30 random tests loaded from a JSON file. + + Devices: DC1-SPINE1, DC1-LEAF1A + + - Total tests: 30 + - Success: 7 + - Skipped: 2 + - Failure: 19 + - Error: 2 + + See `tests/data/test_md_report_results.json` and `tests/data/test_md_report_all_tests.md` for details. + """ + manager = ResultManager() + + with (DATA_DIR / JSON_RESULTS).open("r", encoding="utf-8") as f: + results = json.load(f) + + for result in results: + manager.add(TestResult(**result)) + + return manager + + # tests.units.cli fixtures @pytest.fixture def temp_env(tmp_path: Path) -> dict[str, str | None]: diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py index 83369f344..7227a699f 100644 --- a/tests/units/cli/nrfu/test__init__.py +++ b/tests/units/cli/nrfu/test__init__.py @@ -120,3 +120,9 @@ def test_disable_cache(click_runner: CliRunner) -> None: if "disable_cache" in line: assert "True" in line assert result.exit_code == ExitCode.OK + + +def test_hide(click_runner: CliRunner) -> None: + """Test the `--hide` option of the `anta nrfu` command.""" + result = click_runner.invoke(anta, ["nrfu", "--hide", "success", "text"]) + assert "SUCCESS" not in result.output diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 803c8f803..72d5a0154 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -151,3 +151,23 @@ def test_anta_nrfu_csv_failure(click_runner: CliRunner, tmp_path: Path) -> None: assert result.exit_code == ExitCode.USAGE_ERROR assert "Failed to save CSV report to" in result.output assert not csv_output.exists() + + +def test_anta_nrfu_md_report(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu md-report.""" + md_output = tmp_path / "test.md" + result = click_runner.invoke(anta, ["nrfu", "md-report", "--md-output", str(md_output)]) + assert result.exit_code == ExitCode.OK + assert "Markdown report saved to" in result.output + assert md_output.exists() + + +def test_anta_nrfu_md_report_failure(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu md-report failure.""" + md_output = tmp_path / "test.md" + with patch("anta.reporter.md_reporter.MDReportGenerator.generate", side_effect=OSError()): + result = click_runner.invoke(anta, ["nrfu", "md-report", "--md-output", str(md_output)]) + + assert result.exit_code == ExitCode.USAGE_ERROR + assert "Failed to save Markdown report to" in result.output + assert not md_output.exists() diff --git a/tests/units/reporter/test_md_reporter.py b/tests/units/reporter/test_md_reporter.py new file mode 100644 index 000000000..a60773374 --- /dev/null +++ b/tests/units/reporter/test_md_reporter.py @@ -0,0 +1,54 @@ +# Copyright (c) 2023-2024 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.reporter.md_reporter.py.""" + +from __future__ import annotations + +from io import StringIO +from pathlib import Path + +import pytest + +from anta.reporter.md_reporter import MDReportBase, MDReportGenerator +from anta.result_manager import ResultManager + +DATA_DIR: Path = Path(__file__).parent.parent.parent.resolve() / "data" + + +def test_md_report_generate(tmp_path: Path, result_manager: ResultManager) -> None: + """Test the MDReportGenerator class.""" + md_filename = tmp_path / "test.md" + expected_report = "test_md_report.md" + + # Generate the Markdown report + MDReportGenerator.generate(result_manager, md_filename) + assert md_filename.exists() + + # Load the existing Markdown report to compare with the generated one + with (DATA_DIR / expected_report).open("r", encoding="utf-8") as f: + expected_content = f.read() + + # Check the content of the Markdown file + content = md_filename.read_text(encoding="utf-8") + + assert content == expected_content + + +def test_md_report_base() -> None: + """Test the MDReportBase class.""" + + class FakeMDReportBase(MDReportBase): + """Fake MDReportBase class.""" + + def generate_section(self) -> None: + pass + + results = ResultManager() + + with StringIO() as mock_file: + report = FakeMDReportBase(mock_file, results) + assert report.generate_heading_name() == "Fake MD Report Base" + + with pytest.raises(NotImplementedError, match="Subclasses should implement this method"): + report.generate_rows() diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py index 02c694c05..66a6cfb1d 100644 --- a/tests/units/result_manager/test__init__.py +++ b/tests/units/result_manager/test__init__.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import re from contextlib import AbstractContextManager, nullcontext from typing import TYPE_CHECKING, Callable @@ -71,6 +72,27 @@ def test_json(self, list_result_factory: Callable[[int], list[TestResult]]) -> N assert test.get("custom_field") is None assert test.get("result") == "success" + def test_sorted_category_stats(self, list_result_factory: Callable[[int], list[TestResult]]) -> None: + """Test ResultManager.sorted_category_stats.""" + result_manager = ResultManager() + results = list_result_factory(4) + + # Modify the categories to have a mix of different acronym categories + results[0].categories = ["ospf"] + results[1].categories = ["bgp"] + results[2].categories = ["vxlan"] + results[3].categories = ["system"] + + result_manager.results = results + + # Check the current categories order and name format + expected_order = ["OSPF", "BGP", "VXLAN", "System"] + assert list(result_manager.category_stats.keys()) == expected_order + + # Check the sorted categories order and name format + expected_order = ["BGP", "OSPF", "System", "VXLAN"] + assert list(result_manager.sorted_category_stats.keys()) == expected_order + @pytest.mark.parametrize( ("starting_status", "test_status", "expected_status", "expected_raise"), [ @@ -149,6 +171,91 @@ def test_add( assert result_manager.status == expected_status assert len(result_manager) == 1 + def test_add_clear_cache(self, result_manager: ResultManager, test_result_factory: Callable[[], TestResult]) -> None: + """Test ResultManager.add and make sure the cache is reset after adding a new test.""" + # Check the cache is empty + assert "results_by_status" not in result_manager.__dict__ + + # Access the cache + assert result_manager.get_total_results() == 30 + + # Check the cache is filled with the correct results count + assert "results_by_status" in result_manager.__dict__ + assert sum(len(v) for v in result_manager.__dict__["results_by_status"].values()) == 30 + + # Add a new test + result_manager.add(result=test_result_factory()) + + # Check the cache has been reset + assert "results_by_status" not in result_manager.__dict__ + + # Access the cache again + assert result_manager.get_total_results() == 31 + + # Check the cache is filled again with the correct results count + assert "results_by_status" in result_manager.__dict__ + assert sum(len(v) for v in result_manager.__dict__["results_by_status"].values()) == 31 + + def test_get_results(self, result_manager: ResultManager) -> None: + """Test ResultManager.get_results.""" + # Check for single status + success_results = result_manager.get_results(status={"success"}) + assert len(success_results) == 7 + assert all(r.result == "success" for r in success_results) + + # Check for multiple statuses + failure_results = result_manager.get_results(status={"failure", "error"}) + assert len(failure_results) == 21 + assert all(r.result in {"failure", "error"} for r in failure_results) + + # Check all results + all_results = result_manager.get_results() + assert len(all_results) == 30 + + def test_get_results_sort_by(self, result_manager: ResultManager) -> None: + """Test ResultManager.get_results with sort_by.""" + # Check all results with sort_by result + all_results = result_manager.get_results(sort_by=["result"]) + assert len(all_results) == 30 + assert [r.result for r in all_results] == ["error"] * 2 + ["failure"] * 19 + ["skipped"] * 2 + ["success"] * 7 + + # Check all results with sort_by device (name) + all_results = result_manager.get_results(sort_by=["name"]) + assert len(all_results) == 30 + assert all_results[0].name == "DC1-LEAF1A" + assert all_results[-1].name == "DC1-SPINE1" + + # Check multiple statuses with sort_by categories + success_skipped_results = result_manager.get_results(status={"success", "skipped"}, sort_by=["categories"]) + assert len(success_skipped_results) == 9 + assert success_skipped_results[0].categories == ["Interfaces"] + assert success_skipped_results[-1].categories == ["VXLAN"] + + # Check all results with bad sort_by + with pytest.raises( + ValueError, + match=re.escape( + "Invalid sort_by fields: ['bad_field']. Accepted fields are: ['name', 'test', 'categories', 'description', 'result', 'messages', 'custom_field']", + ), + ): + all_results = result_manager.get_results(sort_by=["bad_field"]) + + def test_get_total_results(self, result_manager: ResultManager) -> None: + """Test ResultManager.get_total_results.""" + # Test all results + assert result_manager.get_total_results() == 30 + + # Test single status + assert result_manager.get_total_results(status={"success"}) == 7 + assert result_manager.get_total_results(status={"failure"}) == 19 + assert result_manager.get_total_results(status={"error"}) == 2 + assert result_manager.get_total_results(status={"skipped"}) == 2 + + # Test multiple statuses + assert result_manager.get_total_results(status={"success", "failure"}) == 26 + assert result_manager.get_total_results(status={"success", "failure", "error"}) == 28 + assert result_manager.get_total_results(status={"success", "failure", "error", "skipped"}) == 30 + @pytest.mark.parametrize( ("status", "error_status", "ignore_error", "expected_status"), [ From 91a3c220a8155a55031f2a68f7487e50029fc0b5 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Thu, 29 Aug 2024 08:28:15 -0400 Subject: [PATCH 51/90] test(anta): Add extra unit test for md-report (#807) --- tests/units/cli/nrfu/test_commands.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 72d5a0154..27f01a78c 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -171,3 +171,27 @@ def test_anta_nrfu_md_report_failure(click_runner: CliRunner, tmp_path: Path) -> assert result.exit_code == ExitCode.USAGE_ERROR assert "Failed to save Markdown report to" in result.output assert not md_output.exists() + + +def test_anta_nrfu_md_report_with_hide(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu md-report with the `--hide` option.""" + md_output = tmp_path / "test.md" + result = click_runner.invoke(anta, ["nrfu", "--hide", "success", "md-report", "--md-output", str(md_output)]) + + assert result.exit_code == ExitCode.OK + assert "Markdown report saved to" in result.output + assert md_output.exists() + + with md_output.open("r", encoding="utf-8") as f: + content = f.read() + + # Use regex to find the "Total Tests Success" value + match = re.search(r"\| (\d+) \| (\d+) \| \d+ \| \d+ \| \d+ \|", content) + + assert match is not None + + total_tests = int(match.group(1)) + total_tests_success = int(match.group(2)) + + assert total_tests == 0 + assert total_tests_success == 0 From 30f731cad7646c9cc3108a8afa94219070e1b11b Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 29 Aug 2024 18:16:23 +0530 Subject: [PATCH 52/90] feat(anta): Updated VerifyBFDPeersIntervals for failure message to be in milliseconds like the inputs (#805) --- anta/tests/bfd.py | 24 ++++++++++++------------ tests/units/anta_tests/test_bfd.py | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/anta/tests/bfd.py b/anta/tests/bfd.py index 0b171a6d2..f42d80de7 100644 --- a/anta/tests/bfd.py +++ b/anta/tests/bfd.py @@ -157,34 +157,34 @@ def test(self) -> None: for bfd_peers in self.inputs.bfd_peers: peer = str(bfd_peers.peer_address) vrf = bfd_peers.vrf - - # Converting milliseconds intervals into actual value - tx_interval = bfd_peers.tx_interval * 1000 - rx_interval = bfd_peers.rx_interval * 1000 + tx_interval = bfd_peers.tx_interval + rx_interval = bfd_peers.rx_interval multiplier = bfd_peers.multiplier + + # Check if BFD peer configured bfd_output = get_value( self.instance_commands[0].json_output, f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", separator="..", ) - - # Check if BFD peer configured if not bfd_output: failures[peer] = {vrf: "Not Configured"} continue + # Convert interval timer(s) into milliseconds to be consistent with the inputs. bfd_details = bfd_output.get("peerStatsDetail", {}) - intervals_ok = ( - bfd_details.get("operTxInterval") == tx_interval and bfd_details.get("operRxInterval") == rx_interval and bfd_details.get("detectMult") == multiplier - ) + op_tx_interval = bfd_details.get("operTxInterval") // 1000 + op_rx_interval = bfd_details.get("operRxInterval") // 1000 + detect_multiplier = bfd_details.get("detectMult") + intervals_ok = op_tx_interval == tx_interval and op_rx_interval == rx_interval and detect_multiplier == multiplier # Check timers of BFD peer if not intervals_ok: failures[peer] = { vrf: { - "tx_interval": bfd_details.get("operTxInterval"), - "rx_interval": bfd_details.get("operRxInterval"), - "multiplier": bfd_details.get("detectMult"), + "tx_interval": op_tx_interval, + "rx_interval": op_rx_interval, + "multiplier": detect_multiplier, } } diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py index b3ab5609a..3b1b8b86a 100644 --- a/tests/units/anta_tests/test_bfd.py +++ b/tests/units/anta_tests/test_bfd.py @@ -163,8 +163,8 @@ "result": "failure", "messages": [ "Following BFD peers are not configured or timers are not correct:\n" - "{'192.0.255.7': {'default': {'tx_interval': 1300000, 'rx_interval': 1200000, 'multiplier': 4}}, " - "'192.0.255.70': {'MGMT': {'tx_interval': 120000, 'rx_interval': 120000, 'multiplier': 5}}}" + "{'192.0.255.7': {'default': {'tx_interval': 1300, 'rx_interval': 1200, 'multiplier': 4}}, " + "'192.0.255.70': {'MGMT': {'tx_interval': 120, 'rx_interval': 120, 'multiplier': 5}}}" ], }, }, From 7ff8043ce39d44d4793758e4dc093bec5d444b57 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 30 Aug 2024 16:32:43 +0200 Subject: [PATCH 53/90] refactor(anta): Change TestStatus to be an Enum for coding clarity (#758) Co-authored-by: Carl Baillargeon ' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.2 + rev: v0.6.3 hooks: - id: ruff name: Run Ruff linter diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 6263e845a..d573b49c7 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -5,14 +5,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, get_args +from typing import TYPE_CHECKING import click from anta.cli.nrfu import commands from anta.cli.utils import AliasedGroup, catalog_options, inventory_options -from anta.custom_types import TestStatus from anta.result_manager import ResultManager +from anta.result_manager.models import AntaTestStatus if TYPE_CHECKING: from anta.catalog import AntaCatalog @@ -49,7 +49,7 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: return super().parse_args(ctx, args) -HIDE_STATUS: list[str] = list(get_args(TestStatus)) +HIDE_STATUS: list[str] = list(AntaTestStatus) HIDE_STATUS.remove("unset") diff --git a/anta/custom_types.py b/anta/custom_types.py index 153fd7011..322fa4aca 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -112,9 +112,6 @@ def validate_regex(value: str) -> str: return value -# ANTA framework -TestStatus = Literal["unset", "success", "failure", "error", "skipped"] - # AntaTest.Input types AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)] Vlan = Annotated[int, Field(ge=0, le=4094)] diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index c4e4f7bcf..e74aaec5f 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -18,9 +18,8 @@ if TYPE_CHECKING: import pathlib - from anta.custom_types import TestStatus from anta.result_manager import ResultManager - from anta.result_manager.models import TestResult + from anta.result_manager.models import AntaTestStatus, TestResult logger = logging.getLogger(__name__) @@ -80,19 +79,19 @@ def _build_headers(self, headers: list[str], table: Table) -> Table: table.add_column(header, justify="left") return table - def _color_result(self, status: TestStatus) -> str: - """Return a colored string based on the status value. + def _color_result(self, status: AntaTestStatus) -> str: + """Return a colored string based on an AntaTestStatus. Parameters ---------- - status (TestStatus): status value to color. + status: AntaTestStatus enum to color. Returns ------- - str: the colored string + The colored string. """ - color = RICH_COLOR_THEME.get(status, "") + color = RICH_COLOR_THEME.get(str(status), "") return f"[{color}]{status}" if color != "" else str(status) def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table: diff --git a/anta/reporter/md_reporter.py b/anta/reporter/md_reporter.py index 0cc5b03e2..7b97fb176 100644 --- a/anta/reporter/md_reporter.py +++ b/anta/reporter/md_reporter.py @@ -12,6 +12,7 @@ from anta.constants import MD_REPORT_TOC from anta.logger import anta_log_exception +from anta.result_manager.models import AntaTestStatus if TYPE_CHECKING: from collections.abc import Generator @@ -203,10 +204,10 @@ def generate_rows(self) -> Generator[str, None, None]: """Generate the rows of the summary totals table.""" yield ( f"| {self.results.get_total_results()} " - f"| {self.results.get_total_results({'success'})} " - f"| {self.results.get_total_results({'skipped'})} " - f"| {self.results.get_total_results({'failure'})} " - f"| {self.results.get_total_results({'error'})} |\n" + f"| {self.results.get_total_results({AntaTestStatus.SUCCESS})} " + f"| {self.results.get_total_results({AntaTestStatus.SKIPPED})} " + f"| {self.results.get_total_results({AntaTestStatus.FAILURE})} " + f"| {self.results.get_total_results({AntaTestStatus.ERROR})} |\n" ) def generate_section(self) -> None: diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index 1900a28b1..95da45684 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -9,13 +9,9 @@ from collections import defaultdict from functools import cached_property from itertools import chain -from typing import get_args - -from pydantic import TypeAdapter from anta.constants import ACRONYM_CATEGORIES -from anta.custom_types import TestStatus -from anta.result_manager.models import TestResult +from anta.result_manager.models import AntaTestStatus, TestResult from .models import CategoryStats, DeviceStats, TestStats @@ -95,7 +91,7 @@ def __init__(self) -> None: error_status is set to True. """ self._result_entries: list[TestResult] = [] - self.status: TestStatus = "unset" + self.status: AntaTestStatus = AntaTestStatus.UNSET self.error_status = False self.device_stats: defaultdict[str, DeviceStats] = defaultdict(DeviceStats) @@ -116,7 +112,7 @@ def results(self, value: list[TestResult]) -> None: """Set the list of TestResult.""" # When setting the results, we need to reset the state of the current instance self._result_entries = [] - self.status = "unset" + self.status = AntaTestStatus.UNSET self.error_status = False # Also reset the stats attributes @@ -138,26 +134,24 @@ def sorted_category_stats(self) -> dict[str, CategoryStats]: return dict(sorted(self.category_stats.items())) @cached_property - def results_by_status(self) -> dict[TestStatus, list[TestResult]]: + def results_by_status(self) -> dict[AntaTestStatus, list[TestResult]]: """A cached property that returns the results grouped by status.""" - return {status: [result for result in self._result_entries if result.result == status] for status in get_args(TestStatus)} + return {status: [result for result in self._result_entries if result.result == status] for status in AntaTestStatus} - def _update_status(self, test_status: TestStatus) -> None: + def _update_status(self, test_status: AntaTestStatus) -> None: """Update the status of the ResultManager instance based on the test status. Parameters ---------- - test_status: TestStatus to update the ResultManager status. + test_status: AntaTestStatus to update the ResultManager status. """ - result_validator: TypeAdapter[TestStatus] = TypeAdapter(TestStatus) - result_validator.validate_python(test_status) if test_status == "error": self.error_status = True return if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}: self.status = test_status elif self.status == "success" and test_status == "failure": - self.status = "failure" + self.status = AntaTestStatus.FAILURE def _update_stats(self, result: TestResult) -> None: """Update the statistics based on the test result. @@ -209,14 +203,14 @@ def add(self, result: TestResult) -> None: # Every time a new result is added, we need to clear the cached property self.__dict__.pop("results_by_status", None) - def get_results(self, status: set[TestStatus] | None = None, sort_by: list[str] | None = None) -> list[TestResult]: + def get_results(self, status: set[AntaTestStatus] | None = None, sort_by: list[str] | None = None) -> list[TestResult]: """Get the results, optionally filtered by status and sorted by TestResult fields. If no status is provided, all results are returned. Parameters ---------- - status: Optional set of TestStatus literals to filter the results. + status: Optional set of AntaTestStatus enum members to filter the results. sort_by: Optional list of TestResult fields to sort the results. Returns @@ -235,14 +229,14 @@ def get_results(self, status: set[TestStatus] | None = None, sort_by: list[str] return results - def get_total_results(self, status: set[TestStatus] | None = None) -> int: + def get_total_results(self, status: set[AntaTestStatus] | None = None) -> int: """Get the total number of results, optionally filtered by status. If no status is provided, the total number of results is returned. Parameters ---------- - status: Optional set of TestStatus literals to filter the results. + status: Optional set of AntaTestStatus enum members to filter the results. Returns ------- @@ -259,18 +253,18 @@ def get_status(self, *, ignore_error: bool = False) -> str: """Return the current status including error_status if ignore_error is False.""" return "error" if self.error_status and not ignore_error else self.status - def filter(self, hide: set[TestStatus]) -> ResultManager: + def filter(self, hide: set[AntaTestStatus]) -> ResultManager: """Get a filtered ResultManager based on test status. Parameters ---------- - hide: set of TestStatus literals to select tests to hide based on their status. + hide: Set of AntaTestStatus enum members to select tests to hide based on their status. Returns ------- A filtered `ResultManager`. """ - possible_statuses = set(get_args(TestStatus)) + possible_statuses = set(AntaTestStatus) manager = ResultManager() manager.results = self.get_results(possible_statuses - hide) return manager diff --git a/anta/result_manager/models.py b/anta/result_manager/models.py index 6abce0233..2bb2aed2e 100644 --- a/anta/result_manager/models.py +++ b/anta/result_manager/models.py @@ -6,10 +6,26 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import Enum from pydantic import BaseModel -from anta.custom_types import TestStatus + +class AntaTestStatus(str, Enum): + """Test status Enum for the TestResult. + + NOTE: This could be updated to StrEnum when Python 3.11 is the minimum supported version in ANTA. + """ + + UNSET = "unset" + SUCCESS = "success" + FAILURE = "failure" + ERROR = "error" + SKIPPED = "skipped" + + def __str__(self) -> str: + """Override the __str__ method to return the value of the Enum, mimicking the behavior of StrEnum.""" + return self.value class TestResult(BaseModel): @@ -17,13 +33,13 @@ class TestResult(BaseModel): Attributes ---------- - name: Device name where the test has run. - test: Test name runs on the device. - categories: List of categories the TestResult belongs to, by default the AntaTest categories. - description: TestResult description, by default the AntaTest description. - result: Result of the test. Can be one of "unset", "success", "failure", "error" or "skipped". - messages: Message to report after the test if any. - custom_field: Custom field to store a string for flexibility in integrating with ANTA + name: Name of the device where the test was run. + test: Name of the test run on the device. + categories: List of categories the TestResult belongs to. Defaults to the AntaTest categories. + description: Description of the TestResult. Defaults to the AntaTest description. + result: Result of the test. Must be one of the AntaTestStatus Enum values: unset, success, failure, error or skipped. + messages: Messages to report after the test, if any. + custom_field: Custom field to store a string for flexibility in integrating with ANTA. """ @@ -31,7 +47,7 @@ class TestResult(BaseModel): test: str categories: list[str] description: str - result: TestStatus = "unset" + result: AntaTestStatus = AntaTestStatus.UNSET messages: list[str] = [] custom_field: str | None = None @@ -43,7 +59,7 @@ def is_success(self, message: str | None = None) -> None: message: Optional message related to the test """ - self._set_status("success", message) + self._set_status(AntaTestStatus.SUCCESS, message) def is_failure(self, message: str | None = None) -> None: """Set status to failure. @@ -53,7 +69,7 @@ def is_failure(self, message: str | None = None) -> None: message: Optional message related to the test """ - self._set_status("failure", message) + self._set_status(AntaTestStatus.FAILURE, message) def is_skipped(self, message: str | None = None) -> None: """Set status to skipped. @@ -63,7 +79,7 @@ def is_skipped(self, message: str | None = None) -> None: message: Optional message related to the test """ - self._set_status("skipped", message) + self._set_status(AntaTestStatus.SKIPPED, message) def is_error(self, message: str | None = None) -> None: """Set status to error. @@ -73,9 +89,9 @@ def is_error(self, message: str | None = None) -> None: message: Optional message related to the test """ - self._set_status("error", message) + self._set_status(AntaTestStatus.ERROR, message) - def _set_status(self, status: TestStatus, message: str | None = None) -> None: + def _set_status(self, status: AntaTestStatus, message: str | None = None) -> None: """Set status and insert optional message. Parameters diff --git a/asynceapi/aio_portcheck.py b/asynceapi/aio_portcheck.py index fd8e7aee2..79f4562fa 100644 --- a/asynceapi/aio_portcheck.py +++ b/asynceapi/aio_portcheck.py @@ -33,7 +33,7 @@ # ----------------------------------------------------------------------------- -async def port_check_url(url: URL, timeout: int = 5) -> bool: # noqa: ASYNC109 +async def port_check_url(url: URL, timeout: int = 5) -> bool: """ Open the port designated by the URL given the timeout in seconds. diff --git a/tests/units/reporter/test__init__.py b/tests/units/reporter/test__init__.py index 2fc62ce92..f0e44b41a 100644 --- a/tests/units/reporter/test__init__.py +++ b/tests/units/reporter/test__init__.py @@ -13,9 +13,9 @@ from anta import RICH_COLOR_PALETTE from anta.reporter import ReportJinja, ReportTable +from anta.result_manager.models import AntaTestStatus if TYPE_CHECKING: - from anta.custom_types import TestStatus from anta.result_manager import ResultManager @@ -73,15 +73,14 @@ def test__build_headers(self, headers: list[str]) -> None: @pytest.mark.parametrize( ("status", "expected_status"), [ - pytest.param("unknown", "unknown", id="unknown status"), - pytest.param("unset", "[grey74]unset", id="unset status"), - pytest.param("skipped", "[bold orange4]skipped", id="skipped status"), - pytest.param("failure", "[bold red]failure", id="failure status"), - pytest.param("error", "[indian_red]error", id="error status"), - pytest.param("success", "[green4]success", id="success status"), + pytest.param(AntaTestStatus.UNSET, "[grey74]unset", id="unset status"), + pytest.param(AntaTestStatus.SKIPPED, "[bold orange4]skipped", id="skipped status"), + pytest.param(AntaTestStatus.FAILURE, "[bold red]failure", id="failure status"), + pytest.param(AntaTestStatus.ERROR, "[indian_red]error", id="error status"), + pytest.param(AntaTestStatus.SUCCESS, "[green4]success", id="success status"), ], ) - def test__color_result(self, status: TestStatus, expected_status: str) -> None: + def test__color_result(self, status: AntaTestStatus, expected_status: str) -> None: """Test _build_headers.""" # pylint: disable=protected-access report = ReportTable() @@ -140,7 +139,7 @@ def test_report_summary_tests( new_results = [result.model_copy() for result in manager.results] for result in new_results: result.name = "test_device" - result.result = "failure" + result.result = AntaTestStatus.FAILURE report = ReportTable() kwargs = {"tests": [test] if test is not None else None, "title": title} @@ -175,7 +174,7 @@ def test_report_summary_devices( new_results = [result.model_copy() for result in manager.results] for result in new_results: result.name = dev or "test_device" - result.result = "failure" + result.result = AntaTestStatus.FAILURE manager.results = new_results report = ReportTable() diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py index 66a6cfb1d..802d4a4e3 100644 --- a/tests/units/result_manager/test__init__.py +++ b/tests/units/result_manager/test__init__.py @@ -13,9 +13,9 @@ import pytest from anta.result_manager import ResultManager, models +from anta.result_manager.models import AntaTestStatus if TYPE_CHECKING: - from anta.custom_types import TestStatus from anta.result_manager.models import TestResult @@ -56,7 +56,7 @@ def test_json(self, list_result_factory: Callable[[int], list[TestResult]]) -> N success_list = list_result_factory(3) for test in success_list: - test.result = "success" + test.result = AntaTestStatus.SUCCESS result_manager.results = success_list json_res = result_manager.json @@ -141,29 +141,27 @@ def test_sorted_category_stats(self, list_result_factory: Callable[[int], list[T nullcontext(), id="failure, add success", ), - pytest.param( - "unset", "unknown", None, pytest.raises(ValueError, match="Input should be 'unset', 'success', 'failure', 'error' or 'skipped'"), id="wrong status" - ), + pytest.param("unset", "unknown", None, pytest.raises(ValueError, match="'unknown' is not a valid AntaTestStatus"), id="wrong status"), ], ) def test_add( self, test_result_factory: Callable[[], TestResult], - starting_status: TestStatus, - test_status: TestStatus, + starting_status: str, + test_status: str, expected_status: str, expected_raise: AbstractContextManager[Exception], ) -> None: # pylint: disable=too-many-arguments """Test ResultManager_update_status.""" result_manager = ResultManager() - result_manager.status = starting_status + result_manager.status = AntaTestStatus(starting_status) assert result_manager.error_status is False assert len(result_manager) == 0 test = test_result_factory() - test.result = test_status with expected_raise: + test.result = AntaTestStatus(test_status) result_manager.add(test) if test_status == "error": assert result_manager.error_status is True @@ -199,12 +197,12 @@ def test_add_clear_cache(self, result_manager: ResultManager, test_result_factor def test_get_results(self, result_manager: ResultManager) -> None: """Test ResultManager.get_results.""" # Check for single status - success_results = result_manager.get_results(status={"success"}) + success_results = result_manager.get_results(status={AntaTestStatus.SUCCESS}) assert len(success_results) == 7 assert all(r.result == "success" for r in success_results) # Check for multiple statuses - failure_results = result_manager.get_results(status={"failure", "error"}) + failure_results = result_manager.get_results(status={AntaTestStatus.FAILURE, AntaTestStatus.ERROR}) assert len(failure_results) == 21 assert all(r.result in {"failure", "error"} for r in failure_results) @@ -226,7 +224,7 @@ def test_get_results_sort_by(self, result_manager: ResultManager) -> None: assert all_results[-1].name == "DC1-SPINE1" # Check multiple statuses with sort_by categories - success_skipped_results = result_manager.get_results(status={"success", "skipped"}, sort_by=["categories"]) + success_skipped_results = result_manager.get_results(status={AntaTestStatus.SUCCESS, AntaTestStatus.SKIPPED}, sort_by=["categories"]) assert len(success_skipped_results) == 9 assert success_skipped_results[0].categories == ["Interfaces"] assert success_skipped_results[-1].categories == ["VXLAN"] @@ -246,15 +244,15 @@ def test_get_total_results(self, result_manager: ResultManager) -> None: assert result_manager.get_total_results() == 30 # Test single status - assert result_manager.get_total_results(status={"success"}) == 7 - assert result_manager.get_total_results(status={"failure"}) == 19 - assert result_manager.get_total_results(status={"error"}) == 2 - assert result_manager.get_total_results(status={"skipped"}) == 2 + assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS}) == 7 + assert result_manager.get_total_results(status={AntaTestStatus.FAILURE}) == 19 + assert result_manager.get_total_results(status={AntaTestStatus.ERROR}) == 2 + assert result_manager.get_total_results(status={AntaTestStatus.SKIPPED}) == 2 # Test multiple statuses - assert result_manager.get_total_results(status={"success", "failure"}) == 26 - assert result_manager.get_total_results(status={"success", "failure", "error"}) == 28 - assert result_manager.get_total_results(status={"success", "failure", "error", "skipped"}) == 30 + assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS, AntaTestStatus.FAILURE}) == 26 + assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS, AntaTestStatus.FAILURE, AntaTestStatus.ERROR}) == 28 + assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS, AntaTestStatus.FAILURE, AntaTestStatus.ERROR, AntaTestStatus.SKIPPED}) == 30 @pytest.mark.parametrize( ("status", "error_status", "ignore_error", "expected_status"), @@ -266,7 +264,7 @@ def test_get_total_results(self, result_manager: ResultManager) -> None: ) def test_get_status( self, - status: TestStatus, + status: AntaTestStatus, error_status: bool, ignore_error: bool, expected_status: str, @@ -284,28 +282,28 @@ def test_filter(self, test_result_factory: Callable[[], TestResult], list_result success_list = list_result_factory(3) for test in success_list: - test.result = "success" + test.result = AntaTestStatus.SUCCESS result_manager.results = success_list test = test_result_factory() - test.result = "failure" + test.result = AntaTestStatus.FAILURE result_manager.add(test) test = test_result_factory() - test.result = "error" + test.result = AntaTestStatus.ERROR result_manager.add(test) test = test_result_factory() - test.result = "skipped" + test.result = AntaTestStatus.SKIPPED result_manager.add(test) assert len(result_manager) == 6 - assert len(result_manager.filter({"failure"})) == 5 - assert len(result_manager.filter({"error"})) == 5 - assert len(result_manager.filter({"skipped"})) == 5 - assert len(result_manager.filter({"failure", "error"})) == 4 - assert len(result_manager.filter({"failure", "error", "skipped"})) == 3 - assert len(result_manager.filter({"success", "failure", "error", "skipped"})) == 0 + assert len(result_manager.filter({AntaTestStatus.FAILURE})) == 5 + assert len(result_manager.filter({AntaTestStatus.ERROR})) == 5 + assert len(result_manager.filter({AntaTestStatus.SKIPPED})) == 5 + assert len(result_manager.filter({AntaTestStatus.FAILURE, AntaTestStatus.ERROR})) == 4 + assert len(result_manager.filter({AntaTestStatus.FAILURE, AntaTestStatus.ERROR, AntaTestStatus.SKIPPED})) == 3 + assert len(result_manager.filter({AntaTestStatus.SUCCESS, AntaTestStatus.FAILURE, AntaTestStatus.ERROR, AntaTestStatus.SKIPPED})) == 0 def test_get_by_tests(self, test_result_factory: Callable[[], TestResult], result_manager_factory: Callable[[int], ResultManager]) -> None: """Test ResultManager.get_by_tests.""" diff --git a/tests/units/result_manager/test_models.py b/tests/units/result_manager/test_models.py index 2276153f8..bc44ccfd8 100644 --- a/tests/units/result_manager/test_models.py +++ b/tests/units/result_manager/test_models.py @@ -9,6 +9,8 @@ import pytest +from anta.result_manager.models import AntaTestStatus + # Import as Result to avoid pytest collection from tests.data.json_data import TEST_RESULT_SET_STATUS from tests.lib.fixture import DEVICE_NAME @@ -45,7 +47,7 @@ def test__is_status_foo(self, test_result_factory: Callable[[int], Result], data assert data["message"] in testresult.messages # no helper for unset, testing _set_status if data["target"] == "unset": - testresult._set_status("unset", data["message"]) # pylint: disable=W0212 + testresult._set_status(AntaTestStatus.UNSET, data["message"]) # pylint: disable=W0212 assert testresult.result == data["target"] assert data["message"] in testresult.messages From 145b7c479b952ae02907972bda11821946072b1d Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Fri, 30 Aug 2024 11:00:13 -0400 Subject: [PATCH 54/90] fix(anta): Added support for dict commands in EapiCommandError (#803) --- asynceapi/device.py | 3 +- pyproject.toml | 4 +- tests/units/asynceapi/__init__.py | 4 ++ tests/units/asynceapi/conftest.py | 20 +++++++ tests/units/asynceapi/test_data.py | 88 ++++++++++++++++++++++++++++ tests/units/asynceapi/test_device.py | 88 ++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 tests/units/asynceapi/__init__.py create mode 100644 tests/units/asynceapi/conftest.py create mode 100644 tests/units/asynceapi/test_data.py create mode 100644 tests/units/asynceapi/test_device.py diff --git a/asynceapi/device.py b/asynceapi/device.py index ca206d3e4..394abe40d 100644 --- a/asynceapi/device.py +++ b/asynceapi/device.py @@ -271,10 +271,11 @@ async def jsonrpc_exec(self, jsonrpc: dict[str, Any]) -> list[dict[str, Any] | s len_data = len(cmd_data) err_at = len_data - 1 err_msg = err_data["message"] + failed_cmd = commands[err_at] raise EapiCommandError( passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])], - failed=commands[err_at]["cmd"], + failed=failed_cmd["cmd"] if isinstance(failed_cmd, dict) else failed_cmd, errors=cmd_data[err_at]["errors"], errmsg=err_msg, not_exec=commands[err_at + 1 :], diff --git a/pyproject.toml b/pyproject.toml index e64ee80df..85973774b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ dev = [ "pytest-cov>=4.1.0", "pytest-dependency", "pytest-html>=3.2.0", + "pytest-httpx>=0.30.0", "pytest-metadata>=3.0.0", "pytest>=7.4.0", "ruff>=0.5.4,<0.7.0", @@ -181,7 +182,8 @@ filterwarnings = [ [tool.coverage.run] branch = true -source = ["anta"] +# https://community.sonarsource.com/t/python-coverage-analysis-warning/62629/7 +include = ["anta/*", "asynceapi/*"] parallel = true relative_files = true diff --git a/tests/units/asynceapi/__init__.py b/tests/units/asynceapi/__init__.py new file mode 100644 index 000000000..d4282a31b --- /dev/null +++ b/tests/units/asynceapi/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Unit tests for the asynceapi client package used by ANTA.""" diff --git a/tests/units/asynceapi/conftest.py b/tests/units/asynceapi/conftest.py new file mode 100644 index 000000000..812d5b9cd --- /dev/null +++ b/tests/units/asynceapi/conftest.py @@ -0,0 +1,20 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Fixtures for the asynceapi client package.""" + +import pytest + +from asynceapi import Device + + +@pytest.fixture +def asynceapi_device() -> Device: + """Return an asynceapi Device instance.""" + return Device( + host="localhost", + username="admin", + password="admin", + proto="https", + port=443, + ) diff --git a/tests/units/asynceapi/test_data.py b/tests/units/asynceapi/test_data.py new file mode 100644 index 000000000..908d6084b --- /dev/null +++ b/tests/units/asynceapi/test_data.py @@ -0,0 +1,88 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Unit tests data for the asynceapi client package.""" + +SUCCESS_EAPI_RESPONSE = { + "jsonrpc": "2.0", + "id": "EapiExplorer-1", + "result": [ + { + "mfgName": "Arista", + "modelName": "cEOSLab", + "hardwareRevision": "", + "serialNumber": "5E9D49D20F09DA471333DD835835FD1A", + "systemMacAddress": "00:1c:73:2e:7b:a3", + "hwMacAddress": "00:00:00:00:00:00", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34554157.4311F (engineering build)", + "architecture": "i686", + "internalVersion": "4.31.1F-34554157.4311F", + "internalBuildId": "47114ca4-ae9f-4f32-8c1f-2864db93b7e8", + "imageFormatVersion": "1.0", + "imageOptimization": "None", + "cEosToolsVersion": "(unknown)", + "kernelVersion": "6.5.0-44-generic", + "bootupTimestamp": 1723429239.9352903, + "uptime": 1300202.749528885, + "memTotal": 65832112, + "memFree": 41610316, + "isIntlVersion": False, + }, + { + "utcTime": 1724729442.6863558, + "timezone": "EST", + "localTime": { + "year": 2024, + "month": 8, + "dayOfMonth": 26, + "hour": 22, + "min": 30, + "sec": 42, + "dayOfWeek": 0, + "dayOfYear": 239, + "daylightSavingsAdjust": 0, + }, + "clockSource": {"local": True}, + }, + ], +} +"""Successful eAPI JSON response.""" + +ERROR_EAPI_RESPONSE = { + "jsonrpc": "2.0", + "id": "EapiExplorer-1", + "error": { + "code": 1002, + "message": "CLI command 2 of 3 'bad command' failed: invalid command", + "data": [ + { + "mfgName": "Arista", + "modelName": "cEOSLab", + "hardwareRevision": "", + "serialNumber": "5E9D49D20F09DA471333DD835835FD1A", + "systemMacAddress": "00:1c:73:2e:7b:a3", + "hwMacAddress": "00:00:00:00:00:00", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34554157.4311F (engineering build)", + "architecture": "i686", + "internalVersion": "4.31.1F-34554157.4311F", + "internalBuildId": "47114ca4-ae9f-4f32-8c1f-2864db93b7e8", + "imageFormatVersion": "1.0", + "imageOptimization": "None", + "cEosToolsVersion": "(unknown)", + "kernelVersion": "6.5.0-44-generic", + "bootupTimestamp": 1723429239.9352903, + "uptime": 1300027.2297976017, + "memTotal": 65832112, + "memFree": 41595080, + "isIntlVersion": False, + }, + {"errors": ["Invalid input (at token 1: 'bad')"]}, + ], + }, +} +"""Error eAPI JSON response.""" + +JSONRPC_REQUEST_TEMPLATE = {"jsonrpc": "2.0", "method": "runCmds", "params": {"version": 1, "cmds": [], "format": "json"}, "id": "EapiExplorer-1"} +"""Template for JSON-RPC eAPI request. `cmds` must be filled by the parametrize decorator.""" diff --git a/tests/units/asynceapi/test_device.py b/tests/units/asynceapi/test_device.py new file mode 100644 index 000000000..8a140ee3b --- /dev/null +++ b/tests/units/asynceapi/test_device.py @@ -0,0 +1,88 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Unit tests the asynceapi.device module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from httpx import HTTPStatusError + +from asynceapi import Device, EapiCommandError + +from .test_data import ERROR_EAPI_RESPONSE, JSONRPC_REQUEST_TEMPLATE, SUCCESS_EAPI_RESPONSE + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "cmds", + [ + (["show version", "show clock"]), + ([{"cmd": "show version"}, {"cmd": "show clock"}]), + ([{"cmd": "show version"}, "show clock"]), + ], + ids=["simple_commands", "complex_commands", "mixed_commands"], +) +async def test_jsonrpc_exec_success( + asynceapi_device: Device, + httpx_mock: HTTPXMock, + cmds: list[str | dict[str, Any]], +) -> None: + """Test the Device.jsonrpc_exec method with a successful response. Simple and complex commands are tested.""" + jsonrpc_request: dict[str, Any] = JSONRPC_REQUEST_TEMPLATE.copy() + jsonrpc_request["params"]["cmds"] = cmds + + httpx_mock.add_response(json=SUCCESS_EAPI_RESPONSE) + + result = await asynceapi_device.jsonrpc_exec(jsonrpc=jsonrpc_request) + + assert result == SUCCESS_EAPI_RESPONSE["result"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "cmds", + [ + (["show version", "bad command", "show clock"]), + ([{"cmd": "show version"}, {"cmd": "bad command"}, {"cmd": "show clock"}]), + ([{"cmd": "show version"}, {"cmd": "bad command"}, "show clock"]), + ], + ids=["simple_commands", "complex_commands", "mixed_commands"], +) +async def test_jsonrpc_exec_eapi_command_error( + asynceapi_device: Device, + httpx_mock: HTTPXMock, + cmds: list[str | dict[str, Any]], +) -> None: + """Test the Device.jsonrpc_exec method with an error response. Simple and complex commands are tested.""" + jsonrpc_request: dict[str, Any] = JSONRPC_REQUEST_TEMPLATE.copy() + jsonrpc_request["params"]["cmds"] = cmds + + error_eapi_response: dict[str, Any] = ERROR_EAPI_RESPONSE.copy() + httpx_mock.add_response(json=error_eapi_response) + + with pytest.raises(EapiCommandError) as exc_info: + await asynceapi_device.jsonrpc_exec(jsonrpc=jsonrpc_request) + + assert exc_info.value.passed == [error_eapi_response["error"]["data"][0]] + assert exc_info.value.failed == "bad command" + assert exc_info.value.errors == ["Invalid input (at token 1: 'bad')"] + assert exc_info.value.errmsg == "CLI command 2 of 3 'bad command' failed: invalid command" + assert exc_info.value.not_exec == [jsonrpc_request["params"]["cmds"][2]] + + +@pytest.mark.asyncio +async def test_jsonrpc_exec_http_status_error(asynceapi_device: Device, httpx_mock: HTTPXMock) -> None: + """Test the Device.jsonrpc_exec method with an HTTPStatusError.""" + jsonrpc_request: dict[str, Any] = JSONRPC_REQUEST_TEMPLATE.copy() + jsonrpc_request["params"]["cmds"] = ["show version"] + + httpx_mock.add_response(status_code=500, text="Internal Server Error") + + with pytest.raises(HTTPStatusError): + await asynceapi_device.jsonrpc_exec(jsonrpc=jsonrpc_request) From 722b3e1828c472d0a62ff7acdd1ebd82ad00a6e6 Mon Sep 17 00:00:00 2001 From: Thomas Grimonet Date: Fri, 30 Aug 2024 17:06:36 +0200 Subject: [PATCH 55/90] bump(anta): Upgrade asyncssh to 2.16.0 to suppress deprecation warning from Cryptography (#777) * bump: Force cryptography to lower than 43.0.0 * fix: Suppress deprecation warning from Cryptography in asyncssh * Bump: asyncssh to 2.16.0 --------- Co-authored-by: Guillaume Mulocher --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 85973774b..7202d4839 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ description = "Arista Network Test Automation (ANTA) Framework" license = { file = "LICENSE" } dependencies = [ "aiocache>=0.12.2", - "asyncssh>=2.13.2", + "asyncssh>=2.16", "cvprac>=1.3.1", "eval-type-backport>=0.1.3", # Support newer typing features in older Python versions (required until Python 3.9 support is removed) "Jinja2>=3.1.2", From aa1fde8360d0a9786e42eb35a48c9d006abb4a6e Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 30 Aug 2024 17:09:16 +0200 Subject: [PATCH 56/90] feat(anta.cli): Remove --tags from debug commands (#727) --- anta/cli/debug/commands.py | 7 ++++-- anta/cli/debug/utils.py | 5 ++--- anta/cli/utils.py | 46 +++++++++++++++++++++++++++----------- docs/cli/debug.md | 9 +++----- 4 files changed, 43 insertions(+), 24 deletions(-) diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py index 14f168ba4..1304758a4 100644 --- a/anta/cli/debug/commands.py +++ b/anta/cli/debug/commands.py @@ -72,13 +72,16 @@ def run_template( revision: int, ) -> None: # pylint: disable=too-many-arguments + # Using \b for click + # ruff: noqa: D301 """Run arbitrary templated command to an ANTA device. Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters. - Example: + \b + Example ------- - anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1 + anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1 """ template_params = dict(zip(params[::2], params[1::2])) diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py index 04a7a38b1..4e20c5a74 100644 --- a/anta/cli/debug/utils.py +++ b/anta/cli/debug/utils.py @@ -11,7 +11,7 @@ import click -from anta.cli.utils import ExitCode, inventory_options +from anta.cli.utils import ExitCode, core_options if TYPE_CHECKING: from anta.inventory import AntaInventory @@ -22,7 +22,7 @@ def debug_options(f: Callable[..., Any]) -> Callable[..., Any]: """Click common options required to execute a command on a specific device.""" - @inventory_options + @core_options @click.option( "--ofmt", type=click.Choice(["json", "text"]), @@ -44,7 +44,6 @@ def wrapper( ctx: click.Context, *args: tuple[Any], inventory: AntaInventory, - tags: set[str] | None, device: str, **kwargs: Any, ) -> Any: diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 6d31e55ae..2f6e7d302 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -112,7 +112,7 @@ def resolve_command(self, ctx: click.Context, args: Any) -> Any: return cmd.name, cmd, args -def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]: +def core_options(f: Callable[..., Any]) -> Callable[..., Any]: """Click common options when requiring an inventory to interact with devices.""" @click.option( @@ -190,22 +190,12 @@ def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]: required=True, type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), ) - @click.option( - "--tags", - help="List of tags using comma as separator: tag1,tag2,tag3.", - show_envvar=True, - envvar="ANTA_TAGS", - type=str, - required=False, - callback=parse_tags, - ) @click.pass_context @functools.wraps(f) def wrapper( ctx: click.Context, *args: tuple[Any], inventory: Path, - tags: set[str] | None, username: str, password: str | None, enable_password: str | None, @@ -219,7 +209,7 @@ def wrapper( # pylint: disable=too-many-arguments # If help is invoke somewhere, do not parse inventory if ctx.obj.get("_anta_help"): - return f(*args, inventory=None, tags=tags, **kwargs) + return f(*args, inventory=None, **kwargs) if prompt: # User asked for a password prompt if password is None: @@ -255,7 +245,37 @@ def wrapper( ) except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError): ctx.exit(ExitCode.USAGE_ERROR) - return f(*args, inventory=i, tags=tags, **kwargs) + return f(*args, inventory=i, **kwargs) + + return wrapper + + +def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Click common options when requiring an inventory to interact with devices.""" + + @core_options + @click.option( + "--tags", + help="List of tags using comma as separator: tag1,tag2,tag3.", + show_envvar=True, + envvar="ANTA_TAGS", + type=str, + required=False, + callback=parse_tags, + ) + @click.pass_context + @functools.wraps(f) + def wrapper( + ctx: click.Context, + *args: tuple[Any], + tags: set[str] | None, + **kwargs: dict[str, Any], + ) -> Any: + # pylint: disable=too-many-arguments + # If help is invoke somewhere, do not parse inventory + if ctx.obj.get("_anta_help"): + return f(*args, tags=tags, **kwargs) + return f(*args, tags=tags, **kwargs) return wrapper diff --git a/docs/cli/debug.md b/docs/cli/debug.md index db5f4961d..376dffb14 100644 --- a/docs/cli/debug.md +++ b/docs/cli/debug.md @@ -52,8 +52,6 @@ Options: ANTA_DISABLE_CACHE] -i, --inventory FILE Path to the inventory YAML file. [env var: ANTA_INVENTORY; required] - --tags TEXT List of tags using comma as separator: - tag1,tag2,tag3. [env var: ANTA_TAGS] --ofmt [json|text] EOS eAPI format to use. can be text or json -v, --version [1|latest] EOS eAPI version -r, --revision INTEGER eAPI command revision @@ -97,8 +95,9 @@ Usage: anta debug run-template [OPTIONS] PARAMS... Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters. - Example: ------- anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' - vlan_id 1 + Example + ------- + anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1 Options: -u, --username TEXT Username to connect to EOS [env var: @@ -125,8 +124,6 @@ Options: ANTA_DISABLE_CACHE] -i, --inventory FILE Path to the inventory YAML file. [env var: ANTA_INVENTORY; required] - --tags TEXT List of tags using comma as separator: - tag1,tag2,tag3. [env var: ANTA_TAGS] --ofmt [json|text] EOS eAPI format to use. can be text or json -v, --version [1|latest] EOS eAPI version -r, --revision INTEGER eAPI command revision From 5f325803d5c25ada43a62a81fbff7c474a497395 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Sep 2024 09:28:00 +0200 Subject: [PATCH 57/90] ci: pre-commit autoupdate (#812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/pycqa/pylint: v3.2.6 → v3.2.7](https://github.com/pycqa/pylint/compare/v3.2.6...v3.2.7) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fbf8c3fd2..440265441 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,7 +52,7 @@ repos: name: Run Ruff formatter - repo: https://github.com/pycqa/pylint - rev: "v3.2.6" + rev: "v3.2.7" hooks: - id: pylint name: Check code style with pylint From e7da54a43d9bd47177205dadc0d14e7894f4fc45 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Wed, 4 Sep 2024 06:11:21 +0530 Subject: [PATCH 58/90] feat(anta): Added the test case to verify NTP associations functionality (#757) --- anta/tests/system.py | 96 +++++++++++++- examples/tests.yaml | 9 ++ tests/units/anta_tests/test_system.py | 183 ++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 1 deletion(-) diff --git a/anta/tests/system.py b/anta/tests/system.py index 49d2dd25d..486e5e1ed 100644 --- a/anta/tests/system.py +++ b/anta/tests/system.py @@ -8,10 +8,14 @@ from __future__ import annotations import re +from ipaddress import IPv4Address from typing import TYPE_CHECKING, ClassVar -from anta.custom_types import PositiveInteger +from pydantic import BaseModel, Field + +from anta.custom_types import Hostname, PositiveInteger from anta.models import AntaCommand, AntaTest +from anta.tools import get_failed_logs, get_value if TYPE_CHECKING: from anta.models import AntaTemplate @@ -299,3 +303,93 @@ def test(self) -> None: else: data = command_output.split("\n")[0] self.result.is_failure(f"The device is not synchronized with the configured NTP server(s): '{data}'") + + +class VerifyNTPAssociations(AntaTest): + """Verifies the Network Time Protocol (NTP) associations. + + Expected Results + ---------------- + * Success: The test will pass if the Primary NTP server (marked as preferred) has the condition 'sys.peer' and + all other NTP servers have the condition 'candidate'. + * Failure: The test will fail if the Primary NTP server (marked as preferred) does not have the condition 'sys.peer' or + if any other NTP server does not have the condition 'candidate'. + + Examples + -------- + ```yaml + anta.tests.system: + - VerifyNTPAssociations: + ntp_servers: + - server_address: 1.1.1.1 + preferred: True + stratum: 1 + - server_address: 2.2.2.2 + stratum: 2 + - server_address: 3.3.3.3 + stratum: 2 + ``` + """ + + name = "VerifyNTPAssociations" + description = "Verifies the Network Time Protocol (NTP) associations." + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp associations")] + + class Input(AntaTest.Input): + """Input model for the VerifyNTPAssociations test.""" + + ntp_servers: list[NTPServer] + """List of NTP servers.""" + + class NTPServer(BaseModel): + """Model for a NTP server.""" + + server_address: Hostname | IPv4Address + """The NTP server address as an IPv4 address or hostname. The NTP server name defined in the running configuration + of the device may change during DNS resolution, which is not handled in ANTA. Please provide the DNS-resolved server name. + For example, 'ntp.example.com' in the configuration might resolve to 'ntp3.example.com' in the device output.""" + preferred: bool = False + """Optional preferred for NTP server. If not provided, it defaults to `False`.""" + stratum: int = Field(ge=0, le=16) + """NTP stratum level (0 to 15) where 0 is the reference clock and 16 indicates unsynchronized. + Values should be between 0 and 15 for valid synchronization and 16 represents an out-of-sync state.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyNTPAssociations.""" + failures: str = "" + + if not (peer_details := get_value(self.instance_commands[0].json_output, "peers")): + self.result.is_failure("None of NTP peers are not configured.") + return + + # Iterate over each NTP server. + for ntp_server in self.inputs.ntp_servers: + server_address = str(ntp_server.server_address) + preferred = ntp_server.preferred + stratum = ntp_server.stratum + + # Check if NTP server details exists. + if (peer_detail := get_value(peer_details, server_address, separator="..")) is None: + failures += f"NTP peer {server_address} is not configured.\n" + continue + + # Collecting the expected NTP peer details. + expected_peer_details = {"condition": "candidate", "stratum": stratum} + if preferred: + expected_peer_details["condition"] = "sys.peer" + + # Collecting the actual NTP peer details. + actual_peer_details = {"condition": get_value(peer_detail, "condition"), "stratum": get_value(peer_detail, "stratumLevel")} + + # Collecting failures logs if any. + failure_logs = get_failed_logs(expected_peer_details, actual_peer_details) + if failure_logs: + failures += f"For NTP peer {server_address}:{failure_logs}\n" + + # Check if there are any failures. + if not failures: + self.result.is_success() + else: + self.result.is_failure(failures) diff --git a/examples/tests.yaml b/examples/tests.yaml index c5f87fae7..1ad8e28db 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -445,6 +445,15 @@ anta.tests.system: - VerifyMemoryUtilization: - VerifyFileSystemUtilization: - VerifyNTP: + - VerifyNTPAssociations: + ntp_servers: + - server_address: 1.1.1.1 + preferred: True + stratum: 1 + - server_address: 2.2.2.2 + stratum: 1 + - server_address: 3.3.3.3 + stratum: 1 anta.tests.vlan: - VerifyVlanInternalPolicy: diff --git a/tests/units/anta_tests/test_system.py b/tests/units/anta_tests/test_system.py index 6965461d6..54849b734 100644 --- a/tests/units/anta_tests/test_system.py +++ b/tests/units/anta_tests/test_system.py @@ -14,6 +14,7 @@ VerifyFileSystemUtilization, VerifyMemoryUtilization, VerifyNTP, + VerifyNTPAssociations, VerifyReloadCause, VerifyUptime, ) @@ -286,4 +287,186 @@ "inputs": None, "expected": {"result": "failure", "messages": ["The device is not synchronized with the configured NTP server(s): 'unsynchronised'"]}, }, + { + "name": "success", + "test": VerifyNTPAssociations, + "eos_data": [ + { + "peers": { + "1.1.1.1": { + "condition": "sys.peer", + "peerIpAddr": "1.1.1.1", + "stratumLevel": 1, + }, + "2.2.2.2": { + "condition": "candidate", + "peerIpAddr": "2.2.2.2", + "stratumLevel": 2, + }, + "3.3.3.3": { + "condition": "candidate", + "peerIpAddr": "3.3.3.3", + "stratumLevel": 2, + }, + } + } + ], + "inputs": { + "ntp_servers": [ + {"server_address": "1.1.1.1", "preferred": True, "stratum": 1}, + {"server_address": "2.2.2.2", "stratum": 2}, + {"server_address": "3.3.3.3", "stratum": 2}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "success-pool-name", + "test": VerifyNTPAssociations, + "eos_data": [ + { + "peers": { + "1.ntp.networks.com": { + "condition": "sys.peer", + "peerIpAddr": "1.1.1.1", + "stratumLevel": 1, + }, + "2.ntp.networks.com": { + "condition": "candidate", + "peerIpAddr": "2.2.2.2", + "stratumLevel": 2, + }, + "3.ntp.networks.com": { + "condition": "candidate", + "peerIpAddr": "3.3.3.3", + "stratumLevel": 2, + }, + } + } + ], + "inputs": { + "ntp_servers": [ + {"server_address": "1.ntp.networks.com", "preferred": True, "stratum": 1}, + {"server_address": "2.ntp.networks.com", "stratum": 2}, + {"server_address": "3.ntp.networks.com", "stratum": 2}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyNTPAssociations, + "eos_data": [ + { + "peers": { + "1.1.1.1": { + "condition": "candidate", + "peerIpAddr": "1.1.1.1", + "stratumLevel": 2, + }, + "2.2.2.2": { + "condition": "sys.peer", + "peerIpAddr": "2.2.2.2", + "stratumLevel": 2, + }, + "3.3.3.3": { + "condition": "sys.peer", + "peerIpAddr": "3.3.3.3", + "stratumLevel": 3, + }, + } + } + ], + "inputs": { + "ntp_servers": [ + {"server_address": "1.1.1.1", "preferred": True, "stratum": 1}, + {"server_address": "2.2.2.2", "stratum": 2}, + {"server_address": "3.3.3.3", "stratum": 2}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "For NTP peer 1.1.1.1:\nExpected `sys.peer` as the condition, but found `candidate` instead.\nExpected `1` as the stratum, but found `2` instead.\n" + "For NTP peer 2.2.2.2:\nExpected `candidate` as the condition, but found `sys.peer` instead.\n" + "For NTP peer 3.3.3.3:\nExpected `candidate` as the condition, but found `sys.peer` instead.\nExpected `2` as the stratum, but found `3` instead." + ], + }, + }, + { + "name": "failure-no-peers", + "test": VerifyNTPAssociations, + "eos_data": [{"peers": {}}], + "inputs": { + "ntp_servers": [ + {"server_address": "1.1.1.1", "preferred": True, "stratum": 1}, + {"server_address": "2.2.2.2", "stratum": 1}, + {"server_address": "3.3.3.3", "stratum": 1}, + ] + }, + "expected": { + "result": "failure", + "messages": ["None of NTP peers are not configured."], + }, + }, + { + "name": "failure-one-peer-not-found", + "test": VerifyNTPAssociations, + "eos_data": [ + { + "peers": { + "1.1.1.1": { + "condition": "sys.peer", + "peerIpAddr": "1.1.1.1", + "stratumLevel": 1, + }, + "2.2.2.2": { + "condition": "candidate", + "peerIpAddr": "2.2.2.2", + "stratumLevel": 1, + }, + } + } + ], + "inputs": { + "ntp_servers": [ + {"server_address": "1.1.1.1", "preferred": True, "stratum": 1}, + {"server_address": "2.2.2.2", "stratum": 1}, + {"server_address": "3.3.3.3", "stratum": 1}, + ] + }, + "expected": { + "result": "failure", + "messages": ["NTP peer 3.3.3.3 is not configured."], + }, + }, + { + "name": "failure-with-two-peers-not-found", + "test": VerifyNTPAssociations, + "eos_data": [ + { + "peers": { + "1.1.1.1": { + "condition": "candidate", + "peerIpAddr": "1.1.1.1", + "stratumLevel": 1, + } + } + } + ], + "inputs": { + "ntp_servers": [ + {"server_address": "1.1.1.1", "preferred": True, "stratum": 1}, + {"server_address": "2.2.2.2", "stratum": 1}, + {"server_address": "3.3.3.3", "stratum": 1}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "For NTP peer 1.1.1.1:\nExpected `sys.peer` as the condition, but found `candidate` instead.\n" + "NTP peer 2.2.2.2 is not configured.\nNTP peer 3.3.3.3 is not configured." + ], + }, + }, ] From 522ae9d710c76bf9f4b5a0e5eff078867bee003a Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 5 Sep 2024 02:04:14 +0530 Subject: [PATCH 59/90] feat(anta): Added strict check to VerifyBGPPeerMPCaps test to verify only mentioned multiprotocol capabilities of a BGP peer should be listed (#783) * issue_781 Added optionak check to verify multiprotocol capability * issue_781 handling review comments: added helper function for capabilities * issue_781 Handling review comments: updated strict check * issue_781: Handling review comments: updated docsting * issue_781 handling review comments : updated strict check in testcase --------- Co-authored-by: VitthalMagadum Co-authored-by: Carl Baillargeon --- anta/tests/routing/bgp.py | 20 ++- examples/tests.yaml | 1 + tests/units/anta_tests/routing/test_bgp.py | 146 +++++++++++++++++++++ 3 files changed, 164 insertions(+), 3 deletions(-) diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 6a7002356..70d2a6fcb 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -685,6 +685,8 @@ def test(self) -> None: class VerifyBGPPeerMPCaps(AntaTest): """Verifies the multiprotocol capabilities of a BGP peer in a specified VRF. + Supports `strict: True` to verify that only the specified capabilities are configured, requiring an exact match. + Expected Results ---------------- * Success: The test will pass if the BGP peer's multiprotocol capabilities are advertised, received, and enabled in the specified VRF. @@ -699,6 +701,7 @@ class VerifyBGPPeerMPCaps(AntaTest): bgp_peers: - peer_address: 172.30.11.1 vrf: default + strict: False capabilities: - ipv4Unicast ``` @@ -722,6 +725,8 @@ class BgpPeer(BaseModel): """IPv4 address of a BGP peer.""" vrf: str = "default" """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + strict: bool = False + """If True, requires exact matching of provided capabilities. Defaults to False.""" capabilities: list[MultiProtocolCaps] """List of multiprotocol capabilities to be verified.""" @@ -730,14 +735,14 @@ def test(self) -> None: """Main test function for VerifyBGPPeerMPCaps.""" failures: dict[str, Any] = {"bgp_peers": {}} - # Iterate over each bgp peer + # Iterate over each bgp peer. for bgp_peer in self.inputs.bgp_peers: peer = str(bgp_peer.peer_address) vrf = bgp_peer.vrf capabilities = bgp_peer.capabilities failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} - # Check if BGP output exists + # Check if BGP output exists. if ( not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None @@ -746,8 +751,17 @@ def test(self) -> None: failures = deep_update(failures, failure) continue - # Check each capability + # Fetching the capabilities output. bgp_output = get_value(bgp_output, "neighborCapabilities.multiprotocolCaps") + + if bgp_peer.strict and sorted(capabilities) != sorted(bgp_output): + failure["bgp_peers"][peer][vrf] = { + "status": f"Expected only `{', '.join(capabilities)}` capabilities should be listed but found `{', '.join(bgp_output)}` instead." + } + failures = deep_update(failures, failure) + continue + + # Check each capability for capability in capabilities: capability_output = bgp_output.get(capability) diff --git a/examples/tests.yaml b/examples/tests.yaml index 1ad8e28db..f5a5ca46b 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -546,6 +546,7 @@ anta.tests.routing: bgp_peers: - peer_address: 172.30.11.1 vrf: default + strict: False capabilities: - ipv4Unicast - VerifyBGPPeerASNCap: diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index 47db8e60b..b76939bd5 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -2200,6 +2200,152 @@ ], }, }, + { + "name": "success-strict", + "test": VerifyBGPPeerMPCaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": { + "ipv4Unicast": { + "advertised": True, + "received": True, + "enabled": True, + }, + "ipv4MplsLabels": { + "advertised": True, + "received": True, + "enabled": True, + }, + } + }, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "neighborCapabilities": { + "multiprotocolCaps": { + "ipv4Unicast": { + "advertised": True, + "received": True, + "enabled": True, + }, + "ipv4MplsVpn": { + "advertised": True, + "received": True, + "enabled": True, + }, + } + }, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + "strict": True, + "capabilities": ["Ipv4 Unicast", "ipv4 Mpls labels"], + }, + { + "peer_address": "172.30.11.10", + "vrf": "MGMT", + "strict": True, + "capabilities": ["ipv4 Unicast", "ipv4 MplsVpn"], + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-srict", + "test": VerifyBGPPeerMPCaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": { + "ipv4Unicast": { + "advertised": True, + "received": True, + "enabled": True, + }, + "ipv4MplsLabels": { + "advertised": True, + "received": True, + "enabled": True, + }, + } + }, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "neighborCapabilities": { + "multiprotocolCaps": { + "ipv4Unicast": { + "advertised": True, + "received": True, + "enabled": True, + }, + "ipv4MplsVpn": { + "advertised": False, + "received": True, + "enabled": True, + }, + } + }, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + "strict": True, + "capabilities": ["Ipv4 Unicast"], + }, + { + "peer_address": "172.30.11.10", + "vrf": "MGMT", + "strict": True, + "capabilities": ["ipv4MplsVpn", "L2vpnEVPN"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer multiprotocol capabilities are not found or not ok:\n{'bgp_peers': {'172.30.11.1': " + "{'default': {'status': 'Expected only `ipv4Unicast` capabilities should be listed but found `ipv4Unicast, ipv4MplsLabels` instead.'}}," + " '172.30.11.10': {'MGMT': {'status': 'Expected only `ipv4MplsVpn, l2VpnEvpn` capabilities should be listed but found `ipv4Unicast, " + "ipv4MplsVpn` instead.'}}}}" + ], + }, + }, { "name": "success", "test": VerifyBGPPeerASNCap, From abdaea152c515a1da8bdff17c6c753852566a443 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 5 Sep 2024 02:20:42 +0530 Subject: [PATCH 60/90] refactor(anta): Update VerifySnmpContact , VerifySnmpLocation tests to have a more human readable format for the test result failures messages (#806) --- anta/tests/snmp.py | 13 +++++++++++-- tests/units/anta_tests/test_snmp.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py index ac98bfd2f..c7329b6d7 100644 --- a/anta/tests/snmp.py +++ b/anta/tests/snmp.py @@ -11,6 +11,7 @@ from anta.custom_types import PositiveInteger from anta.models import AntaCommand, AntaTest +from anta.tools import get_value if TYPE_CHECKING: from anta.models import AntaTemplate @@ -183,8 +184,12 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpLocation.""" - location = self.instance_commands[0].json_output["location"]["location"] + # Verifies the SNMP location is configured. + if not (location := get_value(self.instance_commands[0].json_output, "location.location")): + self.result.is_failure("SNMP location is not configured.") + return + # Verifies the expected SNMP location. if location != self.inputs.location: self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.") else: @@ -222,8 +227,12 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpContact.""" - contact = self.instance_commands[0].json_output["contact"]["contact"] + # Verifies the SNMP contact is configured. + if not (contact := get_value(self.instance_commands[0].json_output, "contact.contact")): + self.result.is_failure("SNMP contact is not configured.") + return + # Verifies the expected SNMP contact. if contact != self.inputs.contact: self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.") else: diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py index b4d31521e..64c44382e 100644 --- a/tests/units/anta_tests/test_snmp.py +++ b/tests/units/anta_tests/test_snmp.py @@ -99,6 +99,20 @@ "messages": ["Expected `New York` as the location, but found `Europe` instead."], }, }, + { + "name": "failure-details-not-configured", + "test": VerifySnmpLocation, + "eos_data": [ + { + "location": {"location": ""}, + } + ], + "inputs": {"location": "New York"}, + "expected": { + "result": "failure", + "messages": ["SNMP location is not configured."], + }, + }, { "name": "success", "test": VerifySnmpContact, @@ -124,4 +138,18 @@ "messages": ["Expected `Bob@example.com` as the contact, but found `Jon@example.com` instead."], }, }, + { + "name": "failure-details-not-configured", + "test": VerifySnmpContact, + "eos_data": [ + { + "contact": {"contact": ""}, + } + ], + "inputs": {"contact": "Bob@example.com"}, + "expected": { + "result": "failure", + "messages": ["SNMP contact is not configured."], + }, + }, ] From 9f433ce562569799a06328f33d416191c84fcea4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 09:52:01 +0200 Subject: [PATCH 61/90] ci: pre-commit autoupdate (#824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.3 → v0.6.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.3...v0.6.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 440265441..9da8faaad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.3 + rev: v0.6.4 hooks: - id: ruff name: Run Ruff linter From e32821d612177e8506b1fdb0989d0213d9bb5c03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:20:11 +0200 Subject: [PATCH 62/90] doc: Make API documentation great again (#797) * chore: update griffe requirement from <1.0.0,>=0.46 to >=0.46,<2.0.0 Updates the requirements on [griffe](https://github.com/mkdocstrings/griffe) to permit the latest version. - [Release notes](https://github.com/mkdocstrings/griffe/releases) - [Changelog](https://github.com/mkdocstrings/griffe/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/griffe/compare/0.46.0...1.1.0) --- updated-dependencies: - dependency-name: griffe dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Doc: Fix documentation * Refactor: Address PR comments * Doc: Address more PR comments * chore: Add python handler min version for mkdocstring * Apply suggestions from code review * Update anta/models.py * Update anta/reporter/csv_reporter.py * doc: Adjust pyproject.toml as per ruff doc * doc: Reading the doc better * Fix doc * doc: Better ruff config * doc: Fix css issue * Update pyproject.toml --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: gmuloc Co-authored-by: Carl Baillargeon --- anta/catalog.py | 90 ++++++++---- anta/cli/get/utils.py | 27 ++-- anta/cli/nrfu/utils.py | 6 +- anta/cli/utils.py | 3 +- anta/custom_types.py | 14 +- anta/decorators.py | 24 ++-- anta/device.py | 135 ++++++++++++------ anta/inventory/__init__.py | 82 +++++++---- anta/inventory/models.py | 37 +++-- anta/logger.py | 15 +- anta/models.py | 123 ++++++++++------ anta/reporter/__init__.py | 61 +++++--- anta/reporter/csv_reporter.py | 40 ++++-- anta/reporter/md_reporter.py | 36 +++-- anta/result_manager/__init__.py | 121 +++++++++------- anta/result_manager/models.py | 39 +++-- anta/runner.py | 59 +++++--- anta/tests/flow_tracking.py | 24 ++-- anta/tests/logging.py | 9 +- anta/tests/routing/bgp.py | 87 ++++++----- anta/tests/routing/isis.py | 33 +++-- anta/tests/routing/ospf.py | 18 ++- anta/tools.py | 31 ++-- docs/advanced_usages/as-python-lib.md | 8 +- docs/advanced_usages/caching.md | 2 +- docs/api/device.md | 14 +- docs/api/models.md | 7 +- docs/api/result_manager.md | 2 - docs/api/result_manager_models.md | 2 - docs/cli/debug.md | 2 +- docs/cli/nrfu.md | 2 +- docs/getting-started.md | 2 +- docs/stylesheets/extra.material.css | 14 +- .../{anta_test.html => anta_test.html.jinja} | 10 +- .../material/{class.html => class.html.jinja} | 4 +- .../{docstring.html => docstring.html.jinja} | 0 mkdocs.yml | 4 +- pyproject.toml | 31 ++-- 38 files changed, 766 insertions(+), 452 deletions(-) rename docs/templates/python/material/{anta_test.html => anta_test.html.jinja} (96%) rename docs/templates/python/material/{class.html => class.html.jinja} (91%) rename docs/templates/python/material/{docstring.html => docstring.html.jinja} (100%) diff --git a/anta/catalog.py b/anta/catalog.py index 7ed4bc718..46b90d3e6 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -39,8 +39,12 @@ class AntaTestDefinition(BaseModel): """Define a test with its associated inputs. - test: An AntaTest concrete subclass - inputs: The associated AntaTest.Input subclass instance + Attributes + ---------- + test + An AntaTest concrete subclass. + inputs + The associated AntaTest.Input subclass instance. """ model_config = ConfigDict(frozen=True) @@ -60,6 +64,7 @@ def serialize_model(self) -> dict[str, AntaTest.Input]: Returns ------- + dict A dictionary representing the model. """ return {self.test.__name__: self.inputs} @@ -132,14 +137,14 @@ def check_inputs(self) -> AntaTestDefinition: class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods """Represents an ANTA Test Catalog File. - Example: + Example ------- - A valid test catalog file must have the following structure: - ``` - : - - : - - ``` + A valid test catalog file must have the following structure: + ``` + : + - : + + ``` """ @@ -149,16 +154,16 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition] def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]: """Allow the user to provide a data structure with nested Python modules. - Example: + Example ------- - ``` - anta.tests.routing: - generic: - - - bgp: - - - ``` - `anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules. + ``` + anta.tests.routing: + generic: + - + bgp: + - + ``` + `anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules. """ modules: dict[ModuleType, list[Any]] = {} @@ -234,6 +239,7 @@ def yaml(self) -> str: Returns ------- + str The YAML representation string of this model. """ # TODO: Pydantic and YAML serialization/deserialization is not supported natively. @@ -247,6 +253,7 @@ def to_json(self) -> str: Returns ------- + str The JSON representation string of this model. """ return self.model_dump_json(serialize_as_any=True, exclude_unset=True, indent=2) @@ -267,8 +274,10 @@ def __init__( Parameters ---------- - tests: A list of AntaTestDefinition instances. - filename: The path from which the catalog is loaded. + tests + A list of AntaTestDefinition instances. + filename + The path from which the catalog is loaded. """ self._tests: list[AntaTestDefinition] = [] @@ -314,8 +323,10 @@ def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") - Parameters ---------- - filename: Path to test catalog YAML or JSON fil - file_format: Format of the file, either 'yaml' or 'json' + filename + Path to test catalog YAML or JSON file. + file_format + Format of the file, either 'yaml' or 'json'. """ if file_format not in ["yaml", "json"]: @@ -343,7 +354,8 @@ def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> Anta Parameters ---------- - data: Python dictionary used to instantiate the AntaCatalog instance + data + Python dictionary used to instantiate the AntaCatalog instance. filename: value to be set as AntaCatalog instance attribute """ @@ -377,7 +389,8 @@ def from_list(data: ListAntaTestTuples) -> AntaCatalog: Parameters ---------- - data: Python list used to instantiate the AntaCatalog instance + data + Python list used to instantiate the AntaCatalog instance. """ tests: list[AntaTestDefinition] = [] @@ -394,10 +407,12 @@ def merge_catalogs(cls, catalogs: list[AntaCatalog]) -> AntaCatalog: Parameters ---------- - catalogs: A list of AntaCatalog instances to merge. + catalogs + A list of AntaCatalog instances to merge. Returns ------- + AntaCatalog A new AntaCatalog instance containing the tests of all the input catalogs. """ combined_tests = list(chain(*(catalog.tests for catalog in catalogs))) @@ -406,12 +421,18 @@ def merge_catalogs(cls, catalogs: list[AntaCatalog]) -> AntaCatalog: def merge(self, catalog: AntaCatalog) -> AntaCatalog: """Merge two AntaCatalog instances. + Warning + ------- + This method is deprecated and will be removed in ANTA v2.0. Use `AntaCatalog.merge_catalogs()` instead. + Parameters ---------- - catalog: AntaCatalog instance to merge to this instance. + catalog + AntaCatalog instance to merge to this instance. Returns ------- + AntaCatalog A new AntaCatalog instance containing the tests of the two instances. """ # TODO: Use a decorator to deprecate this method instead. See https://github.com/aristanetworks/anta/issues/754 @@ -427,6 +448,7 @@ def dump(self) -> AntaCatalogFile: Returns ------- + AntaCatalogFile An AntaCatalogFile instance containing tests of this AntaCatalog instance. """ root: dict[ImportString[Any], list[AntaTestDefinition]] = {} @@ -441,7 +463,9 @@ def build_indexes(self, filtered_tests: set[str] | None = None) -> None: If a `filtered_tests` set is provided, only the tests in this set will be indexed. This method populates two attributes: + - tag_to_tests: A dictionary mapping each tag to a set of tests that contain it. + - tests_without_tags: A set of tests that do not have any tags. Once the indexes are built, the `indexes_built` attribute is set to True. @@ -466,17 +490,21 @@ def get_tests_by_tags(self, tags: set[str], *, strict: bool = False) -> set[Anta Parameters ---------- - tags: The tags to filter tests by. If empty, return all tests without tags. - strict: If True, returns only tests that contain all specified tags (intersection). - If False, returns tests that contain any of the specified tags (union). + tags + The tags to filter tests by. If empty, return all tests without tags. + strict + If True, returns only tests that contain all specified tags (intersection). + If False, returns tests that contain any of the specified tags (union). Returns ------- - set[AntaTestDefinition]: A set of tests that match the given tags. + set[AntaTestDefinition] + A set of tests that match the given tags. Raises ------ - ValueError: If the indexes have not been built prior to method call. + ValueError + If the indexes have not been built prior to method call. """ if not self.indexes_built: msg = "Indexes have not been built yet. Call build_indexes() first." diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py index ba4d886d5..8f11676db 100644 --- a/anta/cli/get/utils.py +++ b/anta/cli/get/utils.py @@ -84,18 +84,24 @@ def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str, *, verify_ce Parameters ---------- - cvp_ip: IP address of CloudVision. - cvp_username: Username to connect to CloudVision. - cvp_password: Password to connect to CloudVision. - verify_cert: Enable or disable certificate verification when connecting to CloudVision. + cvp_ip + IP address of CloudVision. + cvp_username + Username to connect to CloudVision. + cvp_password + Password to connect to CloudVision. + verify_cert + Enable or disable certificate verification when connecting to CloudVision. Returns ------- - token(str): The token to use in further API calls to CloudVision. + str + The token to use in further API calls to CloudVision. Raises ------ - requests.ssl.SSLError: If the certificate verification fails + requests.ssl.SSLError + If the certificate verification fails. """ # use CVP REST API to generate a token @@ -163,9 +169,12 @@ def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: Parameters ---------- - inventory: Ansible Inventory file to read - output: ANTA inventory file to generate. - ansible_group: Ansible group from where to extract data. + inventory + Ansible Inventory file to read. + output + ANTA inventory file to generate. + ansible_group + Ansible group from where to extract data. """ try: diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index 748578dec..947c08901 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -147,8 +147,10 @@ def save_markdown_report(ctx: click.Context, md_output: pathlib.Path) -> None: Parameters ---------- - ctx: Click context containing the result manager. - md_output: Path to save the markdown report. + ctx + Click context containing the result manager. + md_output + Path to save the markdown report. """ try: MDReportGenerator.generate(results=_get_result_manager(ctx), md_filename=md_output) diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 2f6e7d302..19ffb113f 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -62,7 +62,8 @@ def exit_with_code(ctx: click.Context) -> None: Parameters ---------- - ctx: Click Context + ctx + Click Context. """ if ctx.obj.get("ignore_status"): diff --git a/anta/custom_types.py b/anta/custom_types.py index 322fa4aca..6747e7663 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -66,9 +66,9 @@ def interface_case_sensitivity(v: str) -> str: Examples -------- - - ethernet -> Ethernet - - vlan -> Vlan - - loopback -> Loopback + - ethernet -> Ethernet + - vlan -> Vlan + - loopback -> Loopback """ if isinstance(v, str) and v != "" and not v[0].isupper(): @@ -81,10 +81,10 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str: Examples -------- - - IPv4 Unicast - - L2vpnEVPN - - ipv4 MPLS Labels - - ipv4Mplsvpn + - IPv4 Unicast + - L2vpnEVPN + - ipv4 MPLS Labels + - ipv4Mplsvpn """ patterns = { diff --git a/anta/decorators.py b/anta/decorators.py index c9f8b6d28..f5608ef26 100644 --- a/anta/decorators.py +++ b/anta/decorators.py @@ -22,11 +22,13 @@ def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: Parameters ---------- - new_tests: A list of new test classes that should replace the deprecated test. + new_tests + A list of new test classes that should replace the deprecated test. Returns ------- - Callable[[F], F]: A decorator that can be used to wrap test functions. + Callable[[F], F] + A decorator that can be used to wrap test functions. """ @@ -35,11 +37,13 @@ def decorator(function: F) -> F: Parameters ---------- - function: The test function to be decorated. + function + The test function to be decorated. Returns ------- - F: The decorated function. + F + The decorated function. """ @@ -66,11 +70,13 @@ def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]: Parameters ---------- - platforms: List of hardware models on which the test should be skipped. + platforms + List of hardware models on which the test should be skipped. Returns ------- - Callable[[F], F]: A decorator that can be used to wrap test functions. + Callable[[F], F] + A decorator that can be used to wrap test functions. """ @@ -79,11 +85,13 @@ def decorator(function: F) -> F: Parameters ---------- - function: The test function to be decorated. + function + The test function to be decorated. Returns ------- - F: The decorated function. + F + The decorated function. """ diff --git a/anta/device.py b/anta/device.py index 087f3b57b..74b81d91e 100644 --- a/anta/device.py +++ b/anta/device.py @@ -42,13 +42,20 @@ class AntaDevice(ABC): Attributes ---------- - name: Device name - is_online: True if the device IP is reachable and a port can be open. - established: True if remote command execution succeeds. - hw_model: Hardware model of the device. - tags: Tags for this device. - cache: In-memory cache from aiocache library for this device (None if cache is disabled). - cache_locks: Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled. + name : str + Device name. + is_online : bool + True if the device IP is reachable and a port can be open. + established : bool + True if remote command execution succeeds. + hw_model : str + Hardware model of the device. + tags : set[str] + Tags for this device. + cache : Cache | None + In-memory cache from aiocache library for this device (None if cache is disabled). + cache_locks : dict + Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled. """ @@ -57,9 +64,12 @@ def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bo Parameters ---------- - name: Device name. - tags: Tags for this device. - disable_cache: Disable caching for all commands for this device. + name + Device name. + tags + Tags for this device. + disable_cache + Disable caching for all commands for this device. """ self.name: str = name @@ -132,8 +142,10 @@ async def _collect(self, command: AntaCommand, *, collection_id: str | None = No Parameters ---------- - command: The command to collect. - collection_id: An identifier used to build the eAPI request ID. + command + The command to collect. + collection_id + An identifier used to build the eAPI request ID. """ async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: @@ -149,8 +161,10 @@ async def collect(self, command: AntaCommand, *, collection_id: str | None = Non Parameters ---------- - command: The command to collect. - collection_id: An identifier used to build the eAPI request ID. + command + The command to collect. + collection_id + An identifier used to build the eAPI request ID. """ # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough # https://github.com/pylint-dev/pylint/issues/7258 @@ -172,8 +186,10 @@ async def collect_commands(self, commands: list[AntaCommand], *, collection_id: Parameters ---------- - commands: The commands to collect. - collection_id: An identifier used to build the eAPI request ID. + commands + The commands to collect. + collection_id + An identifier used to build the eAPI request ID. """ await asyncio.gather(*(self.collect(command=command, collection_id=collection_id) for command in commands)) @@ -182,9 +198,12 @@ async def refresh(self) -> None: """Update attributes of an AntaDevice instance. This coroutine must update the following attributes of AntaDevice: - - `is_online`: When the device IP is reachable and a port can be open - - `established`: When a command execution succeeds - - `hw_model`: The hardware model of the device + + - `is_online`: When the device IP is reachable and a port can be open. + + - `established`: When a command execution succeeds. + + - `hw_model`: The hardware model of the device. """ async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: @@ -194,9 +213,12 @@ async def copy(self, sources: list[Path], destination: Path, direction: Literal[ Parameters ---------- - sources: List of files to copy to or from the device. - destination: Local or remote destination when copying the files. Can be a folder. - direction: Defines if this coroutine copies files to or from the device. + sources + List of files to copy to or from the device. + destination + Local or remote destination when copying the files. Can be a folder. + direction + Defines if this coroutine copies files to or from the device. """ _ = (sources, destination, direction) @@ -209,11 +231,16 @@ class AsyncEOSDevice(AntaDevice): Attributes ---------- - name: Device name - is_online: True if the device IP is reachable and a port can be open - established: True if remote command execution succeeds - hw_model: Hardware model of the device - tags: Tags for this device + name : str + Device name. + is_online : bool + True if the device IP is reachable and a port can be open. + established : bool + True if remote command execution succeeds. + hw_model : str + Hardware model of the device. + tags : set[str] + Tags for this device. """ @@ -239,19 +266,32 @@ def __init__( Parameters ---------- - host: Device FQDN or IP. - username: Username to connect to eAPI and SSH. - password: Password to connect to eAPI and SSH. - name: Device name. - enable: Collect commands using privileged mode. - enable_password: Password used to gain privileged access on EOS. - port: eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'. - ssh_port: SSH port. - tags: Tags for this device. - timeout: Timeout value in seconds for outgoing API calls. - insecure: Disable SSH Host Key validation. - proto: eAPI protocol. Value can be 'http' or 'https'. - disable_cache: Disable caching for all commands for this device. + host + Device FQDN or IP. + username + Username to connect to eAPI and SSH. + password + Password to connect to eAPI and SSH. + name + Device name. + enable + Collect commands using privileged mode. + enable_password + Password used to gain privileged access on EOS. + port + eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'. + ssh_port + SSH port. + tags + Tags for this device. + timeout + Timeout value in seconds for outgoing API calls. + insecure + Disable SSH Host Key validation. + proto + eAPI protocol. Value can be 'http' or 'https'. + disable_cache + Disable caching for all commands for this device. """ if host is None: @@ -315,8 +355,10 @@ async def _collect(self, command: AntaCommand, *, collection_id: str | None = No Parameters ---------- - command: The command to collect. - collection_id: An identifier used to build the eAPI request ID. + command + The command to collect. + collection_id + An identifier used to build the eAPI request ID. """ commands: list[dict[str, str | int]] = [] if self.enable and self._enable_password is not None: @@ -407,9 +449,12 @@ async def copy(self, sources: list[Path], destination: Path, direction: Literal[ Parameters ---------- - sources: List of files to copy to or from the device. - destination: Local or remote destination when copying the files. Can be a folder. - direction: Defines if this coroutine copies files to or from the device. + sources + List of files to copy to or from the device. + destination + Local or remote destination when copying the files. Can be a folder. + direction + Defines if this coroutine copies files to or from the device. """ async with asyncssh.connect( diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py index 46609676a..29450be62 100644 --- a/anta/inventory/__init__.py +++ b/anta/inventory/__init__.py @@ -46,8 +46,10 @@ def _update_disable_cache(kwargs: dict[str, Any], *, inventory_disable_cache: bo Parameters ---------- - inventory_disable_cache: The value of disable_cache in the inventory - kwargs: The kwargs to instantiate the device + inventory_disable_cache + The value of disable_cache in the inventory. + kwargs + The kwargs to instantiate the device. """ updated_kwargs = kwargs.copy() @@ -64,9 +66,12 @@ def _parse_hosts( Parameters ---------- - inventory_input: AntaInventoryInput used to parse the devices - inventory: AntaInventory to add the parsed devices to - **kwargs: Additional keyword arguments to pass to the device constructor + inventory_input + AntaInventoryInput used to parse the devices. + inventory + AntaInventory to add the parsed devices to. + **kwargs + Additional keyword arguments to pass to the device constructor. """ if inventory_input.hosts is None: @@ -93,13 +98,17 @@ def _parse_networks( Parameters ---------- - inventory_input: AntaInventoryInput used to parse the devices - inventory: AntaInventory to add the parsed devices to - **kwargs: Additional keyword arguments to pass to the device constructor + inventory_input + AntaInventoryInput used to parse the devices. + inventory + AntaInventory to add the parsed devices to. + **kwargs + Additional keyword arguments to pass to the device constructor. Raises ------ - InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. + InventoryIncorrectSchemaError + Inventory file is not following AntaInventory Schema. """ if inventory_input.networks is None: @@ -126,13 +135,17 @@ def _parse_ranges( Parameters ---------- - inventory_input: AntaInventoryInput used to parse the devices - inventory: AntaInventory to add the parsed devices to - **kwargs: Additional keyword arguments to pass to the device constructor + inventory_input + AntaInventoryInput used to parse the devices. + inventory + AntaInventory to add the parsed devices to. + **kwargs + Additional keyword arguments to pass to the device constructor. Raises ------ - InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. + InventoryIncorrectSchemaError + Inventory file is not following AntaInventory Schema. """ if inventory_input.ranges is None: @@ -177,19 +190,29 @@ def parse( Parameters ---------- - filename: Path to device inventory YAML file. - username: Username to use to connect to devices. - password: Password to use to connect to devices. - enable_password: Enable password to use if required. - timeout: Timeout value in seconds for outgoing API calls. - enable: Whether or not the commands need to be run in enable mode towards the devices. - insecure: Disable SSH Host Key validation. - disable_cache: Disable cache globally. + filename + Path to device inventory YAML file. + username + Username to use to connect to devices. + password + Password to use to connect to devices. + enable_password + Enable password to use if required. + timeout + Timeout value in seconds for outgoing API calls. + enable + Whether or not the commands need to be run in enable mode towards the devices. + insecure + Disable SSH Host Key validation. + disable_cache + Disable cache globally. Raises ------ - InventoryRootKeyError: Root key of inventory is missing. - InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. + InventoryRootKeyError + Root key of inventory is missing. + InventoryIncorrectSchemaError + Inventory file is not following AntaInventory Schema. """ inventory = AntaInventory() @@ -256,12 +279,16 @@ def get_inventory(self, *, established_only: bool = False, tags: set[str] | None Parameters ---------- - established_only: Whether or not to include only established devices. - tags: Tags to filter devices. - devices: Names to filter devices. + established_only + Whether or not to include only established devices. + tags + Tags to filter devices. + devices + Names to filter devices. Returns ------- + AntaInventory An inventory with filtered AntaDevice objects. """ @@ -295,7 +322,8 @@ def add_device(self, device: AntaDevice) -> None: Parameters ---------- - device: Device object to be added + device + Device object to be added. """ self[device.name] = device diff --git a/anta/inventory/models.py b/anta/inventory/models.py index 5796ef700..2eea701f6 100644 --- a/anta/inventory/models.py +++ b/anta/inventory/models.py @@ -21,11 +21,16 @@ class AntaInventoryHost(BaseModel): Attributes ---------- - host: IP Address or FQDN of the device. - port: Custom eAPI port to use. - name: Custom name of the device. - tags: Tags of the device. - disable_cache: Disable cache for this device. + host : Hostname | IPvAnyAddress + IP Address or FQDN of the device. + port : Port | None + Custom eAPI port to use. + name : str | None + Custom name of the device. + tags : set[str] + Tags of the device. + disable_cache : bool + Disable cache for this device. """ @@ -43,9 +48,12 @@ class AntaInventoryNetwork(BaseModel): Attributes ---------- - network: Subnet to use for scanning. - tags: Tags of the devices in this network. - disable_cache: Disable cache for all devices in this network. + network : IPvAnyNetwork + Subnet to use for scanning. + tags : set[str] + Tags of the devices in this network. + disable_cache : bool + Disable cache for all devices in this network. """ @@ -61,10 +69,14 @@ class AntaInventoryRange(BaseModel): Attributes ---------- - start: IPv4 or IPv6 address for the beginning of the range. - stop: IPv4 or IPv6 address for the end of the range. - tags: Tags of the devices in this IP range. - disable_cache: Disable cache for all devices in this IP range. + start : IPvAnyAddress + IPv4 or IPv6 address for the beginning of the range. + stop : IPvAnyAddress + IPv4 or IPv6 address for the end of the range. + tags : set[str] + Tags of the devices in this IP range. + disable_cache : bool + Disable cache for all devices in this IP range. """ @@ -90,6 +102,7 @@ def yaml(self) -> str: Returns ------- + str The YAML representation string of this model. """ # TODO: Pydantic and YAML serialization/deserialization is not supported natively. diff --git a/anta/logger.py b/anta/logger.py index b64fbe7b4..54733fb73 100644 --- a/anta/logger.py +++ b/anta/logger.py @@ -51,8 +51,10 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: Parameters ---------- - level: ANTA logging level - file: Send logs to a file + level + ANTA logging level + file + Send logs to a file """ # Init root logger @@ -106,9 +108,12 @@ def anta_log_exception(exception: BaseException, message: str | None = None, cal Parameters ---------- - exception: The Exception being logged. - message: An optional message. - calling_logger: A logger to which the exception should be logged. If not present, the logger in this file is used. + exception + The Exception being logged. + message + An optional message. + calling_logger + A logger to which the exception should be logged. If not present, the logger in this file is used. """ if calling_logger is None: diff --git a/anta/models.py b/anta/models.py index e2cf49857..9a695bcd6 100644 --- a/anta/models.py +++ b/anta/models.py @@ -48,11 +48,16 @@ class AntaTemplate: Attributes ---------- - template: Python f-string. Example: 'show vlan {vlan_id}' - version: eAPI version - valid values are 1 or "latest". - revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version. - ofmt: eAPI output - json or text. - use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it. + template + Python f-string. Example: 'show vlan {vlan_id}'. + version + eAPI version - valid values are 1 or "latest". + revision + Revision of the command. Valid values are 1 to 99. Revision has precedence over version. + ofmt + eAPI output - json or text. + use_cache + Enable or disable caching for this AntaTemplate if the AntaDevice supports it. """ # pylint: disable=too-few-public-methods @@ -97,18 +102,20 @@ def render(self, **params: str | int | bool) -> AntaCommand: Parameters ---------- - params: dictionary of variables with string values to render the Python f-string + params + Dictionary of variables with string values to render the Python f-string. Returns ------- + AntaCommand The rendered AntaCommand. This AntaCommand instance have a template attribute that references this AntaTemplate instance. Raises ------ - AntaTemplateRenderError - If a parameter is missing to render the AntaTemplate instance. + AntaTemplateRenderError + If a parameter is missing to render the AntaTemplate instance. """ try: command = self.template.format(**params) @@ -141,15 +148,24 @@ class AntaCommand(BaseModel): Attributes ---------- - command: Device command - version: eAPI version - valid values are 1 or "latest". - revision: eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version. - ofmt: eAPI output - json or text. - output: Output of the command. Only defined if there was no errors. - template: AntaTemplate object used to render this command. - errors: If the command execution fails, eAPI returns a list of strings detailing the error(s). - params: Pydantic Model containing the variables values used to render the template. - use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it. + command + Device command. + version + eAPI version - valid values are 1 or "latest". + revision + eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version. + ofmt + eAPI output - json or text. + output + Output of the command. Only defined if there was no errors. + template + AntaTemplate object used to render this command. + errors + If the command execution fails, eAPI returns a list of strings detailing the error(s). + params + Pydantic Model containing the variables values used to render the template. + use_cache + Enable or disable caching for this AntaCommand if the AntaDevice supports it. """ @@ -214,9 +230,9 @@ def requires_privileges(self) -> bool: Raises ------ - RuntimeError - If the command has not been collected and has not returned an error. - AntaDevice.collect() must be called before this property. + RuntimeError + If the command has not been collected and has not returned an error. + AntaDevice.collect() must be called before this property. """ if not self.collected and not self.error: msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()." @@ -229,9 +245,9 @@ def supported(self) -> bool: Raises ------ - RuntimeError - If the command has not been collected and has not returned an error. - AntaDevice.collect() must be called before this property. + RuntimeError + If the command has not been collected and has not returned an error. + AntaDevice.collect() must be called before this property. """ if not self.collected and not self.error: msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()." @@ -247,8 +263,10 @@ def __init__(self, template: AntaTemplate, key: str) -> None: Parameters ---------- - template: The AntaTemplate instance that failed to render - key: Key that has not been provided to render the template + template + The AntaTemplate instance that failed to render. + key + Key that has not been provided to render the template. """ self.template = template @@ -297,11 +315,16 @@ def test(self) -> None: Attributes ---------- - device: AntaDevice instance on which this test is run - inputs: AntaTest.Input instance carrying the test inputs - instance_commands: List of AntaCommand instances of this test - result: TestResult instance representing the result of this test - logger: Python logger for this test instance + device + AntaDevice instance on which this test is run. + inputs + AntaTest.Input instance carrying the test inputs. + instance_commands + List of AntaCommand instances of this test. + result + TestResult instance representing the result of this test. + logger + Python logger for this test instance. """ # Mandatory class attributes @@ -332,7 +355,8 @@ class Input(BaseModel): Attributes ---------- - result_overwrite: Define fields to overwrite in the TestResult object + result_overwrite + Define fields to overwrite in the TestResult object. """ model_config = ConfigDict(extra="forbid") @@ -351,9 +375,12 @@ class ResultOverwrite(BaseModel): Attributes ---------- - description: overwrite TestResult.description - categories: overwrite TestResult.categories - custom_field: a free string that will be included in the TestResult object + description + Overwrite `TestResult.description`. + categories + Overwrite `TestResult.categories`. + custom_field + A free string that will be included in the TestResult object. """ @@ -367,7 +394,8 @@ class Filters(BaseModel): Attributes ---------- - tags: Tag of devices on which to run the test. + tags + Tag of devices on which to run the test. """ model_config = ConfigDict(extra="forbid") @@ -383,10 +411,13 @@ def __init__( Parameters ---------- - device: AntaDevice instance on which the test will be run - inputs: dictionary of attributes used to instantiate the AntaTest.Input instance - eos_data: Populate outputs of the test commands instead of collecting from devices. - This list must have the same length and order than the `instance_commands` instance attribute. + device + AntaDevice instance on which the test will be run. + inputs + Dictionary of attributes used to instantiate the AntaTest.Input instance. + eos_data + Populate outputs of the test commands instead of collecting from devices. + This list must have the same length and order than the `instance_commands` instance attribute. """ self.logger: logging.Logger = logging.getLogger(f"{self.module}.{self.__class__.__name__}") self.device: AntaDevice = device @@ -558,14 +589,18 @@ async def wrapper( Parameters ---------- - self: The test instance. - eos_data: Populate outputs of the test commands instead of collecting from devices. - This list must have the same length and order than the `instance_commands` instance attribute. - kwargs: Any keyword argument to pass to the test. + self + The test instance. + eos_data + Populate outputs of the test commands instead of collecting from devices. + This list must have the same length and order than the `instance_commands` instance attribute. + kwargs + Any keyword argument to pass to the test. Returns ------- - result: TestResult instance attribute populated with error status if any + TestResult + The TestResult instance attribute populated with error status if any. """ if self.result.result != "unset": diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index e74aaec5f..01baf3a6e 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -45,12 +45,15 @@ def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = N Parameters ---------- - usr_list (list[str]): List of string to concatenate - delimiter (str, optional): A delimiter to use to start string. Defaults to None. + usr_list : list[str] + List of string to concatenate. + delimiter : str, optional + A delimiter to use to start string. Defaults to None. Returns ------- - str: Multi-lines string + str + Multi-lines string. """ if delimiter is not None: @@ -64,11 +67,14 @@ def _build_headers(self, headers: list[str], table: Table) -> Table: Parameters ---------- - headers: List of headers. - table: A rich Table instance. + headers + List of headers. + table + A rich Table instance. Returns ------- + Table A rich `Table` instance with headers. """ @@ -84,11 +90,11 @@ def _color_result(self, status: AntaTestStatus) -> str: Parameters ---------- - status: AntaTestStatus enum to color. + status: AntaTestStatus enum to color. Returns ------- - The colored string. + The colored string. """ color = RICH_COLOR_THEME.get(str(status), "") @@ -101,11 +107,14 @@ def report_all(self, manager: ResultManager, title: str = "All tests results") - Parameters ---------- - manager: A ResultManager instance. - title: Title for the report. Defaults to 'All tests results'. + manager + A ResultManager instance. + title + Title for the report. Defaults to 'All tests results'. Returns ------- + Table A fully populated rich `Table` """ @@ -135,12 +144,16 @@ def report_summary_tests( Parameters ---------- - manager: A ResultManager instance. - tests: List of test names to include. None to select all tests. - title: Title of the report. + manager + A ResultManager instance. + tests + List of test names to include. None to select all tests. + title + Title of the report. Returns ------- + Table A fully populated rich `Table`. """ table = Table(title=title, show_lines=True) @@ -177,12 +190,16 @@ def report_summary_devices( Parameters ---------- - manager: A ResultManager instance. - devices: List of device names to include. None to select all devices. - title: Title of the report. + manager + A ResultManager instance. + devices + List of device names to include. None to select all devices. + title + Title of the report. Returns ------- + Table A fully populated rich `Table`. """ table = Table(title=title, show_lines=True) @@ -225,6 +242,9 @@ def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip Report is built based on a J2 template provided by user. Data structure sent to template is: + Example + ------- + ``` >>> print(ResultManager.json) [ { @@ -236,15 +256,20 @@ def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip description: ..., } ] + ``` Parameters ---------- - data: List of results from ResultManager.results - trim_blocks: enable trim_blocks for J2 rendering. - lstrip_blocks: enable lstrip_blocks for J2 rendering. + data + List of results from `ResultManager.results`. + trim_blocks + enable trim_blocks for J2 rendering. + lstrip_blocks + enable lstrip_blocks for J2 rendering. Returns ------- + str Rendered template """ diff --git a/anta/reporter/csv_reporter.py b/anta/reporter/csv_reporter.py index 221cbec81..570da9e6b 100644 --- a/anta/reporter/csv_reporter.py +++ b/anta/reporter/csv_reporter.py @@ -42,12 +42,15 @@ def split_list_to_txt_list(cls, usr_list: list[str], delimiter: str = " - ") -> Parameters ---------- - usr_list: List of string to concatenate - delimiter: A delimiter to use to start string. Defaults to None. + usr_list + List of string to concatenate. + delimiter + A delimiter to use to start string. Defaults to None. Returns ------- - str: Multi-lines string + str + Multi-lines string. """ return f"{delimiter}".join(f"{line}" for line in usr_list) @@ -57,9 +60,15 @@ def convert_to_list(cls, result: TestResult) -> list[str]: """ Convert a TestResult into a list of string for creating file content. - Args: - ---- - results: A TestResult to convert into list. + Parameters + ---------- + results + A TestResult to convert into list. + + Returns + ------- + list[str] + TestResult converted into a list. """ message = cls.split_list_to_txt_list(result.messages) if len(result.messages) > 0 else "" categories = cls.split_list_to_txt_list(result.categories) if len(result.categories) > 0 else "None" @@ -76,14 +85,17 @@ def convert_to_list(cls, result: TestResult) -> list[str]: def generate(cls, results: ResultManager, csv_filename: pathlib.Path) -> None: """Build CSV flle with tests results. - Parameter - --------- - results: A ResultManager instance. - csv_filename: File path where to save CSV data. - - Raise - ----- - OSError if any is raised while writing the CSV file. + Parameters + ---------- + results + A ResultManager instance. + csv_filename + File path where to save CSV data. + + Raises + ------ + OSError + if any is raised while writing the CSV file. """ headers = [ cls.Headers.device, diff --git a/anta/reporter/md_reporter.py b/anta/reporter/md_reporter.py index 7b97fb176..f4eadb2b5 100644 --- a/anta/reporter/md_reporter.py +++ b/anta/reporter/md_reporter.py @@ -41,8 +41,10 @@ def generate(cls, results: ResultManager, md_filename: Path) -> None: Parameters ---------- - results: The ResultsManager instance containing all test results. - md_filename: The path to the markdown file to write the report into. + results + The ResultsManager instance containing all test results. + md_filename + The path to the markdown file to write the report into. """ try: with md_filename.open("w", encoding="utf-8") as mdfile: @@ -74,8 +76,10 @@ def __init__(self, mdfile: TextIOWrapper, results: ResultManager) -> None: Parameters ---------- - mdfile: An open file object to write the markdown data into. - results: The ResultsManager instance containing all test results. + mdfile + An open file object to write the markdown data into. + results + The ResultsManager instance containing all test results. """ self.mdfile = mdfile self.results = results @@ -102,12 +106,13 @@ def generate_heading_name(self) -> str: Returns ------- - str: Formatted header name. + str + Formatted header name. Example ------- - - `ANTAReport` will become ANTA Report. - - `TestResultsSummary` will become Test Results Summary. + - `ANTAReport` will become ANTA Report. + - `TestResultsSummary` will become Test Results Summary. """ class_name = self.__class__.__name__ @@ -124,8 +129,10 @@ def write_table(self, table_heading: list[str], *, last_table: bool = False) -> Parameters ---------- - table_heading: List of strings to join for the table heading. - last_table: Flag to determine if it's the last table of the markdown file to avoid unnecessary new line. Defaults to False. + table_heading + List of strings to join for the table heading. + last_table + Flag to determine if it's the last table of the markdown file to avoid unnecessary new line. Defaults to False. """ self.mdfile.write("\n".join(table_heading) + "\n") for row in self.generate_rows(): @@ -140,11 +147,12 @@ def write_heading(self, heading_level: int) -> None: Parameters ---------- - heading_level: The level of the heading (1-6). + heading_level + The level of the heading (1-6). Example ------- - ## Test Results Summary + ## Test Results Summary """ # Ensure the heading level is within the valid range of 1 to 6 heading_level = max(1, min(heading_level, 6)) @@ -157,11 +165,13 @@ def safe_markdown(self, text: str | None) -> str: Parameters ---------- - text: The text to escape markdown characters from. + text + The text to escape markdown characters from. Returns ------- - str: The text with escaped markdown characters. + str + The text with escaped markdown characters. """ # Custom field from a TestResult object can be None if text is None: diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index 95da45684..b1fd9c2d4 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -21,52 +21,52 @@ class ResultManager: Examples -------- - Create Inventory: + Create Inventory: - inventory_anta = AntaInventory.parse( - filename='examples/inventory.yml', - username='ansible', - password='ansible', + inventory_anta = AntaInventory.parse( + filename='examples/inventory.yml', + username='ansible', + password='ansible', + ) + + Create Result Manager: + + manager = ResultManager() + + Run tests for all connected devices: + + for device in inventory_anta.get_inventory().devices: + manager.add( + VerifyNTP(device=device).test() + ) + manager.add( + VerifyEOSVersion(device=device).test(version='4.28.3M') ) - Create Result Manager: - - manager = ResultManager() - - Run tests for all connected devices: - - for device in inventory_anta.get_inventory().devices: - manager.add( - VerifyNTP(device=device).test() - ) - manager.add( - VerifyEOSVersion(device=device).test(version='4.28.3M') - ) - - Print result in native format: - - manager.results - [ - TestResult( - name="pf1", - test="VerifyZeroTouch", - categories=["configuration"], - description="Verifies ZeroTouch is disabled", - result="success", - messages=[], - custom_field=None, - ), - TestResult( - name="pf1", - test='VerifyNTP', - categories=["software"], - categories=['system'], - description='Verifies if NTP is synchronised.', - result='failure', - messages=["The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'"], - custom_field=None, - ), - ] + Print result in native format: + + manager.results + [ + TestResult( + name="pf1", + test="VerifyZeroTouch", + categories=["configuration"], + description="Verifies ZeroTouch is disabled", + result="success", + messages=[], + custom_field=None, + ), + TestResult( + name="pf1", + test='VerifyNTP', + categories=["software"], + categories=['system'], + description='Verifies if NTP is synchronised.', + result='failure', + messages=["The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'"], + custom_field=None, + ), + ] """ def __init__(self) -> None: @@ -143,7 +143,8 @@ def _update_status(self, test_status: AntaTestStatus) -> None: Parameters ---------- - test_status: AntaTestStatus to update the ResultManager status. + test_status + AntaTestStatus to update the ResultManager status. """ if test_status == "error": self.error_status = True @@ -158,7 +159,8 @@ def _update_stats(self, result: TestResult) -> None: Parameters ---------- - result: TestResult to update the statistics. + result + TestResult to update the statistics. """ result.categories = [ " ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in result.categories @@ -194,7 +196,8 @@ def add(self, result: TestResult) -> None: Parameters ---------- - result: TestResult to add to the ResultManager instance. + result + TestResult to add to the ResultManager instance. """ self._result_entries.append(result) self._update_status(result.result) @@ -210,12 +213,15 @@ def get_results(self, status: set[AntaTestStatus] | None = None, sort_by: list[s Parameters ---------- - status: Optional set of AntaTestStatus enum members to filter the results. - sort_by: Optional list of TestResult fields to sort the results. + status + Optional set of AntaTestStatus enum members to filter the results. + sort_by + Optional list of TestResult fields to sort the results. Returns ------- - List of TestResult. + list[TestResult] + List of results. """ # Return all results if no status is provided, otherwise return results for multiple statuses results = self._result_entries if status is None else list(chain.from_iterable(self.results_by_status.get(status, []) for status in status)) @@ -236,10 +242,12 @@ def get_total_results(self, status: set[AntaTestStatus] | None = None) -> int: Parameters ---------- - status: Optional set of AntaTestStatus enum members to filter the results. + status + Optional set of AntaTestStatus enum members to filter the results. Returns ------- + int Total number of results. """ if status is None: @@ -258,10 +266,12 @@ def filter(self, hide: set[AntaTestStatus]) -> ResultManager: Parameters ---------- - hide: Set of AntaTestStatus enum members to select tests to hide based on their status. + hide + Set of AntaTestStatus enum members to select tests to hide based on their status. Returns ------- + ResultManager A filtered `ResultManager`. """ possible_statuses = set(AntaTestStatus) @@ -274,10 +284,12 @@ def filter_by_tests(self, tests: set[str]) -> ResultManager: Parameters ---------- - tests: Set of test names to filter the results. + tests + Set of test names to filter the results. Returns ------- + ResultManager A filtered `ResultManager`. """ manager = ResultManager() @@ -289,10 +301,11 @@ def filter_by_devices(self, devices: set[str]) -> ResultManager: Parameters ---------- - devices: Set of device names to filter the results. + devices: Set of device names to filter the results. Returns ------- + ResultManager A filtered `ResultManager`. """ manager = ResultManager() @@ -304,6 +317,7 @@ def get_tests(self) -> set[str]: Returns ------- + set[str] Set of test names. """ return {str(result.test) for result in self._result_entries} @@ -313,6 +327,7 @@ def get_devices(self) -> set[str]: Returns ------- + set[str] Set of device names. """ return {str(result.name) for result in self._result_entries} diff --git a/anta/result_manager/models.py b/anta/result_manager/models.py index 2bb2aed2e..32975816c 100644 --- a/anta/result_manager/models.py +++ b/anta/result_manager/models.py @@ -33,13 +33,20 @@ class TestResult(BaseModel): Attributes ---------- - name: Name of the device where the test was run. - test: Name of the test run on the device. - categories: List of categories the TestResult belongs to. Defaults to the AntaTest categories. - description: Description of the TestResult. Defaults to the AntaTest description. - result: Result of the test. Must be one of the AntaTestStatus Enum values: unset, success, failure, error or skipped. - messages: Messages to report after the test, if any. - custom_field: Custom field to store a string for flexibility in integrating with ANTA. + name : str + Name of the device where the test was run. + test : str + Name of the test run on the device. + categories : list[str] + List of categories the TestResult belongs to. Defaults to the AntaTest categories. + description : str + Description of the TestResult. Defaults to the AntaTest description. + result : AntaTestStatus + Result of the test. Must be one of the AntaTestStatus Enum values: unset, success, failure, error or skipped. + messages : list[str] + Messages to report after the test, if any. + custom_field : str | None + Custom field to store a string for flexibility in integrating with ANTA. """ @@ -56,7 +63,8 @@ def is_success(self, message: str | None = None) -> None: Parameters ---------- - message: Optional message related to the test + message + Optional message related to the test. """ self._set_status(AntaTestStatus.SUCCESS, message) @@ -66,7 +74,8 @@ def is_failure(self, message: str | None = None) -> None: Parameters ---------- - message: Optional message related to the test + message + Optional message related to the test. """ self._set_status(AntaTestStatus.FAILURE, message) @@ -76,7 +85,8 @@ def is_skipped(self, message: str | None = None) -> None: Parameters ---------- - message: Optional message related to the test + message + Optional message related to the test. """ self._set_status(AntaTestStatus.SKIPPED, message) @@ -86,7 +96,8 @@ def is_error(self, message: str | None = None) -> None: Parameters ---------- - message: Optional message related to the test + message + Optional message related to the test. """ self._set_status(AntaTestStatus.ERROR, message) @@ -96,8 +107,10 @@ def _set_status(self, status: AntaTestStatus, message: str | None = None) -> Non Parameters ---------- - status: status of the test - message: optional message + status + Status of the test. + message + Optional message. """ self.result = status diff --git a/anta/runner.py b/anta/runner.py index df4c70cc4..e07cba94f 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -40,7 +40,8 @@ def adjust_rlimit_nofile() -> tuple[int, int]: Returns ------- - tuple[int, int]: The new soft and hard limits for open file descriptors. + tuple[int, int] + The new soft and hard limits for open file descriptors. """ try: nofile = int(os.environ.get("ANTA_NOFILE", DEFAULT_NOFILE)) @@ -61,7 +62,8 @@ def log_cache_statistics(devices: list[AntaDevice]) -> None: Parameters ---------- - devices: List of devices in the inventory. + devices + List of devices in the inventory. """ for device in devices: if device.cache_statistics is not None: @@ -80,13 +82,17 @@ async def setup_inventory(inventory: AntaInventory, tags: set[str] | None, devic Parameters ---------- - inventory: AntaInventory object that includes the device(s). - tags: Tags to filter devices from the inventory. - devices: Devices on which to run tests. None means all devices. + inventory + AntaInventory object that includes the device(s). + tags + Tags to filter devices from the inventory. + devices + Devices on which to run tests. None means all devices. Returns ------- - AntaInventory | None: The filtered inventory or None if there are no devices to run tests on. + AntaInventory | None + The filtered inventory or None if there are no devices to run tests on. """ if len(inventory) == 0: logger.info("The inventory is empty, exiting") @@ -118,13 +124,18 @@ def prepare_tests( Parameters ---------- - inventory: AntaInventory object that includes the device(s). - catalog: AntaCatalog object that includes the list of tests. - tests: Tests to run against devices. None means all tests. - tags: Tags to filter devices from the inventory. + inventory + AntaInventory object that includes the device(s). + catalog + AntaCatalog object that includes the list of tests. + tests + Tests to run against devices. None means all tests. + tags + Tags to filter devices from the inventory. Returns ------- + defaultdict[AntaDevice, set[AntaTestDefinition]] | None A mapping of devices to the tests to run or None if there are no tests to run. """ # Build indexes for the catalog. If `tests` is set, filter the indexes based on these tests @@ -162,10 +173,12 @@ def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinitio Parameters ---------- - selected_tests: A mapping of devices to the tests to run. The selected tests are generated by the `prepare_tests` function. + selected_tests + A mapping of devices to the tests to run. The selected tests are generated by the `prepare_tests` function. Returns ------- + list[Coroutine[Any, Any, TestResult]] The list of coroutines to run. """ coros = [] @@ -207,14 +220,22 @@ async def main( # noqa: PLR0913 Parameters ---------- - manager: ResultManager object to populate with the test results. - inventory: AntaInventory object that includes the device(s). - catalog: AntaCatalog object that includes the list of tests. - devices: Devices on which to run tests. None means all devices. These may come from the `--device / -d` CLI option in NRFU. - tests: Tests to run against devices. None means all tests. These may come from the `--test / -t` CLI option in NRFU. - tags: Tags to filter devices from the inventory. These may come from the `--tags` CLI option in NRFU. - established_only: Include only established device(s). - dry_run: Build the list of coroutine to run and stop before test execution. + manager + ResultManager object to populate with the test results. + inventory + AntaInventory object that includes the device(s). + catalog + AntaCatalog object that includes the list of tests. + devices + Devices on which to run tests. None means all devices. These may come from the `--device / -d` CLI option in NRFU. + tests + Tests to run against devices. None means all tests. These may come from the `--test / -t` CLI option in NRFU. + tags + Tags to filter devices from the inventory. These may come from the `--tags` CLI option in NRFU. + established_only + Include only established device(s). + dry_run + Build the list of coroutine to run and stop before test execution. """ # Adjust the maximum number of open file descriptors for the ANTA process limits = adjust_rlimit_nofile() diff --git a/anta/tests/flow_tracking.py b/anta/tests/flow_tracking.py index bab8860e6..5336cf14d 100644 --- a/anta/tests/flow_tracking.py +++ b/anta/tests/flow_tracking.py @@ -19,13 +19,17 @@ def validate_record_export(record_export: dict[str, str], tracker_info: dict[str """ Validate the record export configuration against the tracker info. - Args: - record_export (dict): The expected record export configuration. - tracker_info (dict): The actual tracker info from the command output. + Parameters + ---------- + record_export + The expected record export configuration. + tracker_info + The actual tracker info from the command output. Returns ------- - str : A failure message if the record export configuration does not match, otherwise blank string. + str + A failure message if the record export configuration does not match, otherwise blank string. """ failed_log = "" actual_export = {"inactive timeout": tracker_info.get("inactiveTimeout"), "interval": tracker_info.get("activeInterval")} @@ -39,13 +43,17 @@ def validate_exporters(exporters: list[dict[str, str]], tracker_info: dict[str, """ Validate the exporter configurations against the tracker info. - Args: - exporters (list[dict]): The list of expected exporter configurations. - tracker_info (dict): The actual tracker info from the command output. + Parameters + ---------- + exporters + The list of expected exporter configurations. + tracker_info + The actual tracker info from the command output. Returns ------- - str: Failure message if any exporter configuration does not match. + str + Failure message if any exporter configuration does not match. """ failed_log = "" for exporter in exporters: diff --git a/anta/tests/logging.py b/anta/tests/logging.py index b520fc1e1..c5202cce1 100644 --- a/anta/tests/logging.py +++ b/anta/tests/logging.py @@ -27,12 +27,15 @@ def _get_logging_states(logger: logging.Logger, command_output: str) -> str: Parameters ---------- - logger: The logger object. - command_output: The `show logging` output. + logger + The logger object. + command_output + The `show logging` output. Returns ------- - str: The operational logging states. + str + The operational logging states. """ log_states = command_output.partition("\n\nExternal configuration:")[0] diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 70d2a6fcb..3477fc8b2 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -26,31 +26,38 @@ def _add_bgp_failures(failures: dict[tuple[str, str | None], dict[str, Any]], af Parameters ---------- - failures: The dictionary to which the failure will be added. - afi: The address family identifier. - vrf: The VRF name. - safi: The subsequent address family identifier. - issue: A description of the issue. Can be of any type. - - Example: + failures + The dictionary to which the failure will be added. + afi + The address family identifier. + vrf + The VRF name. + safi + The subsequent address family identifier. + issue + A description of the issue. Can be of any type. + + Example ------- The `failures` dictionary will have the following structure: - { - ('afi1', 'safi1'): { - 'afi': 'afi1', - 'safi': 'safi1', - 'vrfs': { - 'vrf1': issue1, - 'vrf2': issue2 - } - }, - ('afi2', None): { - 'afi': 'afi2', - 'vrfs': { - 'vrf1': issue3 - } + ``` + { + ('afi1', 'safi1'): { + 'afi': 'afi1', + 'safi': 'safi1', + 'vrfs': { + 'vrf1': issue1, + 'vrf2': issue2 + } + }, + ('afi2', None): { + 'afi': 'afi2', + 'vrfs': { + 'vrf1': issue3 } } + } + ``` """ key = (afi, safi) @@ -65,21 +72,27 @@ def _check_peer_issues(peer_data: dict[str, Any] | None) -> dict[str, Any]: Parameters ---------- - peer_data: The BGP peer data dictionary nested in the `show bgp summary` command. + peer_data + The BGP peer data dictionary nested in the `show bgp summary` command. Returns ------- - dict: Dictionary with keys indicating issues or an empty dictionary if no issues. + dict + Dictionary with keys indicating issues or an empty dictionary if no issues. Raises ------ - ValueError: If any of the required keys ("peerState", "inMsgQueue", "outMsgQueue") are missing in `peer_data`, i.e. invalid BGP peer data. + ValueError + If any of the required keys ("peerState", "inMsgQueue", "outMsgQueue") are missing in `peer_data`, i.e. invalid BGP peer data. - Example: + Example ------- - {"peerNotFound": True} - {"peerState": "Idle", "inMsgQueue": 2, "outMsgQueue": 0} - {} + This can for instance return + ``` + {"peerNotFound": True} + {"peerState": "Idle", "inMsgQueue": 2, "outMsgQueue": 0} + {} + ``` """ if peer_data is None: @@ -106,15 +119,21 @@ def _add_bgp_routes_failure( Parameters ---------- - bgp_routes: The list of expected routes. - bgp_output: The BGP output from the device. - peer: The IP address of the BGP peer. - vrf: The name of the VRF for which the routes need to be verified. - route_type: The type of BGP routes. Defaults to 'advertised_routes'. + bgp_routes + The list of expected routes. + bgp_output + The BGP output from the device. + peer + The IP address of the BGP peer. + vrf + The name of the VRF for which the routes need to be verified. + route_type + The type of BGP routes. Defaults to 'advertised_routes'. Returns ------- - dict[str, dict[str, dict[str, dict[str, list[str]]]]]: A dictionary containing the missing routes and invalid or inactive routes. + dict[str, dict[str, dict[str, dict[str, list[str]]]]] + A dictionary containing the missing routes and invalid or inactive routes. """ # Prepare the failure routes dictionary diff --git a/anta/tests/routing/isis.py b/anta/tests/routing/isis.py index dee472571..344605d3a 100644 --- a/anta/tests/routing/isis.py +++ b/anta/tests/routing/isis.py @@ -20,13 +20,15 @@ def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int: """Count the number of isis neighbors. - Args - ---- - isis_neighbor_json: The JSON output of the `show isis neighbors` command. + Parameters + ---------- + isis_neighbor_json + The JSON output of the `show isis neighbors` command. Returns ------- - int: The number of isis neighbors. + int + The number of isis neighbors. """ count = 0 @@ -39,13 +41,15 @@ def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int: def _get_not_full_isis_neighbors(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]: """Return the isis neighbors whose adjacency state is not `up`. - Args - ---- - isis_neighbor_json: The JSON output of the `show isis neighbors` command. + Parameters + ---------- + isis_neighbor_json + The JSON output of the `show isis neighbors` command. Returns ------- - list[dict[str, Any]]: A list of isis neighbors whose adjacency state is not `UP`. + list[dict[str, Any]] + A list of isis neighbors whose adjacency state is not `UP`. """ return [ @@ -66,14 +70,17 @@ def _get_not_full_isis_neighbors(isis_neighbor_json: dict[str, Any]) -> list[dic def _get_full_isis_neighbors(isis_neighbor_json: dict[str, Any], neighbor_state: Literal["up", "down"] = "up") -> list[dict[str, Any]]: """Return the isis neighbors whose adjacency state is `up`. - Args - ---- - isis_neighbor_json: The JSON output of the `show isis neighbors` command. - neighbor_state: Value of the neihbor state we are looking for. Default up + Parameters + ---------- + isis_neighbor_json + The JSON output of the `show isis neighbors` command. + neighbor_state + Value of the neihbor state we are looking for. Defaults to `up`. Returns ------- - list[dict[str, Any]]: A list of isis neighbors whose adjacency state is not `UP`. + list[dict[str, Any]] + A list of isis neighbors whose adjacency state is not `UP`. """ return [ diff --git a/anta/tests/routing/ospf.py b/anta/tests/routing/ospf.py index 342ada2f4..3ffd81d53 100644 --- a/anta/tests/routing/ospf.py +++ b/anta/tests/routing/ospf.py @@ -20,11 +20,13 @@ def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int: Parameters ---------- - ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command. + ospf_neighbor_json + The JSON output of the `show ip ospf neighbor` command. Returns ------- - int: The number of OSPF neighbors. + int + The number of OSPF neighbors. """ count = 0 @@ -39,11 +41,13 @@ def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dic Parameters ---------- - ospf_neighbor_json: The JSON output of the `show ip ospf neighbor` command. + ospf_neighbor_json + The JSON output of the `show ip ospf neighbor` command. Returns ------- - list[dict[str, Any]]: A list of OSPF neighbors whose adjacency state is not `full`. + list[dict[str, Any]] + A list of OSPF neighbors whose adjacency state is not `full`. """ return [ @@ -65,11 +69,13 @@ def _get_ospf_max_lsa_info(ospf_process_json: dict[str, Any]) -> list[dict[str, Parameters ---------- - ospf_process_json: OSPF process information in JSON format. + ospf_process_json + OSPF process information in JSON format. Returns ------- - list[dict[str, Any]]: A list of dictionaries containing OSPF LSAs information. + list[dict[str, Any]] + A list of dictionaries containing OSPF LSAs information. """ return [ diff --git a/anta/tools.py b/anta/tools.py index 55748b492..00aad5afe 100644 --- a/anta/tools.py +++ b/anta/tools.py @@ -34,12 +34,15 @@ def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, An Parameters ---------- - expected_output (dict): Expected output of a test. - actual_output (dict): Actual output of a test + expected_output + Expected output of a test. + actual_output + Actual output of a test Returns ------- - str: Failed log of a test. + str + Failed log of a test. """ failed_logs = [] @@ -65,12 +68,15 @@ def custom_division(numerator: float, denominator: float) -> int | float: Parameters ---------- - numerator: The numerator. - denominator: The denominator. + numerator + The numerator. + denominator + The denominator. Returns ------- - Union[int, float]: The result of the division. + Union[int, float] + The result of the division. """ result = numerator / denominator return int(result) if result.is_integer() else result @@ -304,11 +310,13 @@ def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]: Parameters ---------- - sort_by (str): The criterion to sort the profiling results. Default is 'cumtime'. + sort_by + The criterion to sort the profiling results. Default is 'cumtime'. Returns ------- - Callable: The decorated function with conditional profiling. + Callable + The decorated function with conditional profiling. """ def decorator(func: F) -> F: @@ -320,11 +328,14 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: Parameters ---------- - *args: Arbitrary positional arguments. - **kwargs: Arbitrary keyword arguments. + *args + Arbitrary positional arguments. + **kwargs + Arbitrary keyword arguments. Returns ------- + Any The result of the function call. """ cprofile_file = os.environ.get("ANTA_CPROFILE") diff --git a/docs/advanced_usages/as-python-lib.md b/docs/advanced_usages/as-python-lib.md index f8d67348b..08bb818c1 100644 --- a/docs/advanced_usages/as-python-lib.md +++ b/docs/advanced_usages/as-python-lib.md @@ -11,11 +11,11 @@ ANTA is a Python library that can be used in user applications. This section des ## [AntaDevice](../api/device.md#anta.device.AntaDevice) Abstract Class -A device is represented in ANTA as a instance of a subclass of the [AntaDevice](../api/device.md### ::: anta.device.AntaDevice) abstract class. +A device is represented in ANTA as a instance of a subclass of the [AntaDevice](../api/device.md#anta.device.AntaDevice) abstract class. There are few abstract methods that needs to be implemented by child classes: - The [collect()](../api/device.md#anta.device.AntaDevice.collect) coroutine is in charge of collecting outputs of [AntaCommand](../api/models.md#anta.models.AntaCommand) instances. -- The [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutine is in charge of updating attributes of the [AntaDevice](../api/device.md### ::: anta.device.AntaDevice) instance. These attributes are used by [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) to filter out unreachable devices or by [AntaTest](../api/models.md#anta.models.AntaTest) to skip devices based on their hardware models. +- The [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutine is in charge of updating attributes of the [AntaDevice](../api/device.md#anta.device.AntaDevice) instance. These attributes are used by [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) to filter out unreachable devices or by [AntaTest](../api/models.md#anta.models.AntaTest) to skip devices based on their hardware models. The [copy()](../api/device.md#anta.device.AntaDevice.copy) coroutine is used to copy files to and from the device. It does not need to be implemented if tests are not using it. @@ -24,7 +24,7 @@ The [copy()](../api/device.md#anta.device.AntaDevice.copy) coroutine is used to The [AsyncEOSDevice](../api/device.md#anta.device.AsyncEOSDevice) class is an implementation of [AntaDevice](../api/device.md#anta.device.AntaDevice) for Arista EOS. It uses the [aio-eapi](https://github.com/jeremyschulman/aio-eapi) eAPI client and the [AsyncSSH](https://github.com/ronf/asyncssh) library. -- The [collect()](../api/device.md#anta.device.AsyncEOSDevice.collect) coroutine collects [AntaCommand](../api/models.md#anta.models.AntaCommand) outputs using eAPI. +- The [_collect()](../api/device.md#anta.device.AsyncEOSDevice._collect) coroutine collects [AntaCommand](../api/models.md#anta.models.AntaCommand) outputs using eAPI. - The [refresh()](../api/device.md#anta.device.AsyncEOSDevice.refresh) coroutine tries to open a TCP connection on the eAPI port and update the `is_online` attribute accordingly. If the TCP connection succeeds, it sends a `show version` command to gather the hardware model of the device and updates the `established` and `hw_model` attributes. - The [copy()](../api/device.md#anta.device.AsyncEOSDevice.copy) coroutine copies files to and from the device using the SCP protocol. @@ -35,7 +35,7 @@ The [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) class is a [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) provides methods to interact with the ANTA inventory: -- The [add_device()](../api/inventory.md#anta.inventory.AntaInventory.add_device) method adds an [AntaDevice](../api/device.md### ::: anta.device.AntaDevice) instance to the inventory. Adding an entry to [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) with a key different from the device name is not allowed. +- The [add_device()](../api/inventory.md#anta.inventory.AntaInventory.add_device) method adds an [AntaDevice](../api/device.md#anta.device.AntaDevice) instance to the inventory. Adding an entry to [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) with a key different from the device name is not allowed. - The [get_inventory()](../api/inventory.md#anta.inventory.AntaInventory.get_inventory) returns a new [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) instance with filtered out devices based on the method inputs. - The [connect_inventory()](../api/inventory.md#anta.inventory.AntaInventory.connect_inventory) coroutine will execute the [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutines of all the devices in the inventory. - The [parse()](../api/inventory.md#anta.inventory.AntaInventory.parse) static method creates an [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) instance from a YAML file and returns it. The devices are [AsyncEOSDevice](../api/device.md#anta.device.AsyncEOSDevice) instances. diff --git a/docs/advanced_usages/caching.md b/docs/advanced_usages/caching.md index ce4a7877c..7de310de7 100644 --- a/docs/advanced_usages/caching.md +++ b/docs/advanced_usages/caching.md @@ -47,7 +47,7 @@ There might be scenarios where caching is not wanted. You can disable caching in ```bash anta --disable-cache --username arista --password arista nrfu table ``` -2. Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` when defining the ANTA [Inventory](../usage-inventory-catalog.md#create-an-inventory-file) file: +2. Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` when defining the ANTA [Inventory](../usage-inventory-catalog.md#device-inventory) file: ```yaml anta_inventory: hosts: diff --git a/docs/api/device.md b/docs/api/device.md index 03cff192e..9401f59af 100644 --- a/docs/api/device.md +++ b/docs/api/device.md @@ -6,20 +6,18 @@ # AntaDevice base class -## UML representation - ![](../imgs/uml/anta.device.AntaDevice.jpeg) -### ::: anta.device.AntaDevice +## ::: anta.device.AntaDevice options: - filters: ["!^_[^_]", "!__(eq|rich_repr)__"] + filters: ["!^_[^_]", "!__(eq|rich_repr)__", "_collect"] # Async EOS device class -## UML representation - ![](../imgs/uml/anta.device.AsyncEOSDevice.jpeg) -### ::: anta.device.AsyncEOSDevice + + +## ::: anta.device.AsyncEOSDevice options: - filters: ["!^_[^_]", "!__(eq|rich_repr)__"] + filters: ["!^_[^_]", "!__(eq|rich_repr)__", "_collect"] diff --git a/docs/api/models.md b/docs/api/models.md index b0c1e916f..3175fce54 100644 --- a/docs/api/models.md +++ b/docs/api/models.md @@ -6,8 +6,6 @@ # Test definition -## UML Diagram - ![](../imgs/uml/anta.models.AntaTest.jpeg) ### ::: anta.models.AntaTest @@ -16,9 +14,8 @@ # Command definition -## UML Diagram - ![](../imgs/uml/anta.models.AntaCommand.jpeg) + ### ::: anta.models.AntaCommand !!! warning @@ -30,8 +27,6 @@ # Template definition -## UML Diagram - ![](../imgs/uml/anta.models.AntaTemplate.jpeg) ### ::: anta.models.AntaTemplate diff --git a/docs/api/result_manager.md b/docs/api/result_manager.md index 72e05aaf4..dca0a19dd 100644 --- a/docs/api/result_manager.md +++ b/docs/api/result_manager.md @@ -6,8 +6,6 @@ # Result Manager definition -## UML Diagram - ![](../imgs/uml/anta.result_manager.ResultManager.jpeg) ### ::: anta.result_manager.ResultManager diff --git a/docs/api/result_manager_models.md b/docs/api/result_manager_models.md index 096bd036b..d0ccc7983 100644 --- a/docs/api/result_manager_models.md +++ b/docs/api/result_manager_models.md @@ -6,8 +6,6 @@ # Test Result model -## UML Diagram - ![](../imgs/uml/anta.result_manager.models.TestResult.jpeg) ### ::: anta.result_manager.models.TestResult diff --git a/docs/cli/debug.md b/docs/cli/debug.md index 376dffb14..b0b8a164f 100644 --- a/docs/cli/debug.md +++ b/docs/cli/debug.md @@ -14,7 +14,7 @@ The ANTA CLI includes a set of debugging tools, making it easier to build and te These tools are especially helpful in building the tests, as they give a visual access to the output received from the eAPI. They also facilitate the extraction of output content for use in unit tests, as described in our [contribution guide](../contribution.md). !!! warning - The `debug` tools require a device from your inventory. Thus, you MUST use a valid [ANTA Inventory](../usage-inventory-catalog.md#create-an-inventory-file). + The `debug` tools require a device from your inventory. Thus, you MUST use a valid [ANTA Inventory](../usage-inventory-catalog.md#device-inventory). ## Executing an EOS command diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index 0de782551..579fbdeef 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -228,7 +228,7 @@ The template `./custom_template.j2` is a simple Jinja2 template: {% endfor %} ``` -The Jinja2 template has access to all `TestResult` elements and their values, as described in this [documentation](../api/result_manager_models.md#testresult-entry). +The Jinja2 template has access to all `TestResult` elements and their values, as described in this [documentation](../api/result_manager_models.md#anta.result_manager.models.TestResult). You can also save the report result to a file using the `--output` option: diff --git a/docs/getting-started.md b/docs/getting-started.md index 39b270ce1..c166ebe78 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -72,7 +72,7 @@ anta_inventory: tags: ['fabric', 'leaf'] ``` -> You can read more details about how to build your inventory [here](usage-inventory-catalog.md#create-an-inventory-file) +> You can read more details about how to build your inventory [here](usage-inventory-catalog.md#device-inventory) ## Test Catalog diff --git a/docs/stylesheets/extra.material.css b/docs/stylesheets/extra.material.css index 1724da961..44e7c1350 100644 --- a/docs/stylesheets/extra.material.css +++ b/docs/stylesheets/extra.material.css @@ -126,10 +126,8 @@ line-height: 1em; font-size: 1.3rem; margin: 1em 0; - /* font-weight: 700; */ letter-spacing: -.01em; color: var(--md-default-fg-color--light); - text-transform: capitalize; font-style: normal; font-weight: bold; } @@ -142,19 +140,15 @@ line-height: 1em; color: var(--md-default-fg-color--light); font-style: italic; - text-transform: capitalize; } .md-typeset h5, .md-typeset h6 { font-size: 0.9rem; margin: 1em 0; - /* font-weight: 700; */ letter-spacing: -.01em; - /* line-height: 2em; */ color: var(--md-default-fg-color--light); font-style: italic; - text-transform: capitalize; text-decoration: underline; } @@ -163,17 +157,13 @@ padding: .6rem .8rem; color: var(--md-default-fg-color); vertical-align: top; - /* background-color: var(--md-accent-bg-color); */ text-align: left; - /* min-width: 100%; */ - /* display: table; */ } .md-typeset table:not([class]) td { /* padding: .9375em 1.25em; */ border-collapse: collapse; vertical-align: center; text-align: left; - /* border-bottom: 1px solid var(--md-default-fg-color--light); */ } .md-typeset code { padding: 0 .2941176471em; @@ -250,3 +240,7 @@ div.doc-contents { padding-left: 25px; border-left: .05rem solid var(--md-typeset-table-color); } +h5.doc-heading { + /* Avoid to capitalize h5 headers for mkdocstrings */ + text-transform: none; +} diff --git a/docs/templates/python/material/anta_test.html b/docs/templates/python/material/anta_test.html.jinja similarity index 96% rename from docs/templates/python/material/anta_test.html rename to docs/templates/python/material/anta_test.html.jinja index ade0ba691..a40d86a24 100644 --- a/docs/templates/python/material/anta_test.html +++ b/docs/templates/python/material/anta_test.html.jinja @@ -31,7 +31,7 @@ {% endif %} {% with heading_level = heading_level + extra_level %} {% for attribute in attributes|order_members(config.members_order, members_list) %} - {% if members_list is not none or attribute.is_public(check_name=False) %} + {% if members_list is not none or attribute.is_public %} {% include attribute|get_template with context %} {% endif %} {% endfor %} @@ -60,7 +60,7 @@ {% include "attributes_table.html" with context %} {% set obj = old_obj %} {% else %} - {% if members_list is not none or class.is_public(check_name=False) %} + {% if members_list is not none or class.is_public %} {% include class|get_template with context %} {% endif %} {% endif %} @@ -82,7 +82,7 @@ {% with heading_level = heading_level + extra_level %} {% for function in functions|order_members(config.members_order, members_list) %} {% if not (obj.kind.value == "class" and function.name == "__init__" and config.merge_init_into_class) %} - {% if members_list is not none or function.is_public(check_name=False) %} + {% if members_list is not none or function.is_public %} {% include function|get_template with context %} {% endif %} {% endif %} @@ -104,7 +104,7 @@ {% endif %} {% with heading_level = heading_level + extra_level %} {% for module in modules|order_members(config.members_order.alphabetical, members_list) %} - {% if members_list is not none or module.is_public(check_name=False) %} + {% if members_list is not none or module.is_public %} {% include module|get_template with context %} {% endif %} {% endfor %} @@ -129,7 +129,7 @@ {% if not (obj.is_class and child.name == "__init__" and config.merge_init_into_class) %} - {% if members_list is not none or child.is_public(check_name=False) %} + {% if members_list is not none or child.is_public %} {% if child.is_attribute %} {% with attribute = child %} {% include attribute|get_template with context %} diff --git a/docs/templates/python/material/class.html b/docs/templates/python/material/class.html.jinja similarity index 91% rename from docs/templates/python/material/class.html rename to docs/templates/python/material/class.html.jinja index 940103b4f..1c1173ce4 100644 --- a/docs/templates/python/material/class.html +++ b/docs/templates/python/material/class.html.jinja @@ -1,4 +1,4 @@ -{% extends "_base/class.html" %} +{% extends "_base/class.html.jinja" %} {% set anta_test = namespace(found=false) %} {% for base in class.bases %} {% set basestr = base | string %} @@ -10,7 +10,7 @@ {% if anta_test.found %} {% set root = False %} {% set heading_level = heading_level + 1 %} - {% include "anta_test.html" with context %} + {% include "anta_test.html.jinja" with context %} {# render source after children - TODO make add flag to respect disabling it.. though do we want to disable?#}
Source code in diff --git a/docs/templates/python/material/docstring.html b/docs/templates/python/material/docstring.html.jinja similarity index 100% rename from docs/templates/python/material/docstring.html rename to docs/templates/python/material/docstring.html.jinja diff --git a/mkdocs.yml b/mkdocs.yml index 291fb2bda..e206ba218 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -153,7 +153,7 @@ markdown_extensions: separator: "-" # permalink: "#" permalink: true - # baselevel: 3 + baselevel: 2 - pymdownx.highlight - pymdownx.snippets: base_path: @@ -193,7 +193,7 @@ nav: - Configuration: api/tests.configuration.md - Connectivity: api/tests.connectivity.md - Field Notices: api/tests.field_notices.md - - Flow Tracking: api/test.flow_tracking.md + - Flow Tracking: api/tests.flow_tracking.md - GreenT: api/tests.greent.md - Hardware: api/tests.hardware.md - Interfaces: api/tests.interfaces.md diff --git a/pyproject.toml b/pyproject.toml index 7202d4839..cfcbdb6d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,18 +81,19 @@ dev = [ "yamllint>=1.32.0", ] doc = [ - "fontawesome_markdown", - "griffe >=0.46,<1.0.0", + "fontawesome_markdown>=0.2.6", + "griffe >=1.2.0", "mike==2.1.3", - "mkdocs-autorefs>=0.4.1", + "mkdocs>=1.6.1", + "mkdocs-autorefs>=1.2.0", "mkdocs-bootswatch>=1.1", - "mkdocs-git-revision-date-localized-plugin>=1.1.0", + "mkdocs-git-revision-date-localized-plugin>=1.2.8", "mkdocs-git-revision-date-plugin>=0.3.2", - "mkdocs-material-extensions>=1.0.3", - "mkdocs-material>=8.3.9", - "mkdocs>=1.3.1", - "mkdocstrings[python]>=0.20.0", - "mkdocs-glightbox>=0.4.0" + "mkdocs-glightbox>=0.4.0", + "mkdocs-material-extensions>=1.3.1", + "mkdocs-material>=9.5.34", + "mkdocstrings[python]>=0.26.0", + "mkdocstrings-python>=1.11.0" ] [project.urls] @@ -326,11 +327,17 @@ target-version = "py39" [tool.ruff.lint] # select all cause we like being suffering -select = ["ALL"] +select = ["ALL", + # By enabling a convention for docstrings, ruff automatically ignore some rules that need to be + # added back if we want them. + # https://docs.astral.sh/ruff/faq/#does-ruff-support-numpy-or-google-style-docstrings + # TODO: Augment the numpy convention rules to make sure we add all the params + # Uncomment below D417 + "D415", + # "D417", +] ignore = [ "ANN101", # Missing type annotation for `self` in method - we know what self is.. - "D203", # Ignoring conflicting D* warnings - one-blank-line-before-class - "D213", # Ignoring conflicting D* warnings - multi-line-summary-second-line "COM812", # Ignoring conflicting rules that may cause conflicts when used with the formatter "ISC001", # Ignoring conflicting rules that may cause conflicts when used with the formatter "TD002", # We don't have require authors in TODO From 02e8491a15089cc64d1a786a321f56bfaa593f27 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Wed, 11 Sep 2024 19:59:09 +0530 Subject: [PATCH 63/90] feat(anta): Added test case to verify Link Aggregation Control Protocol (LACP) functionality (#764) --- anta/custom_types.py | 8 ++ anta/tests/interfaces.py | 106 +++++++++++++++++- examples/tests.yaml | 6 ++ tests/units/anta_tests/test_interfaces.py | 124 ++++++++++++++++++++++ tests/units/test_custom_types.py | 19 ++++ 5 files changed, 262 insertions(+), 1 deletion(-) diff --git a/anta/custom_types.py b/anta/custom_types.py index 6747e7663..c1e1f6428 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -21,6 +21,8 @@ """Match EOS interface types like Ethernet1/1, Vlan1, Loopback1, etc.""" REGEXP_TYPE_VXLAN_SRC_INTERFACE = r"^(Loopback)([0-9]|[1-9][0-9]{1,2}|[1-7][0-9]{3}|8[01][0-9]{2}|819[01])$" """Match Vxlan source interface like Loopback10.""" +REGEX_TYPE_PORTCHANNEL = r"^Port-Channel[0-9]{1,6}$" +"""Match Port Channel interface like Port-Channel5.""" REGEXP_TYPE_HOSTNAME = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" """Match hostname like `my-hostname`, `my-hostname-1`, `my-hostname-1-2`.""" @@ -135,6 +137,12 @@ def validate_regex(value: str) -> str: BeforeValidator(interface_autocomplete), BeforeValidator(interface_case_sensitivity), ] +PortChannelInterface = Annotated[ + str, + Field(pattern=REGEX_TYPE_PORTCHANNEL), + BeforeValidator(interface_autocomplete), + BeforeValidator(interface_case_sensitivity), +] Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership", "path-selection", "link-state"] Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"] EncryptionAlgorithm = Literal["RSA", "ECDSA"] diff --git a/anta/tests/interfaces.py b/anta/tests/interfaces.py index dfbf15aa6..9ff1cf357 100644 --- a/anta/tests/interfaces.py +++ b/anta/tests/interfaces.py @@ -15,7 +15,7 @@ from pydantic_extra_types.mac_address import MacAddress from anta import GITHUB_SUGGESTION -from anta.custom_types import EthernetInterface, Interface, Percent, PositiveInteger +from anta.custom_types import EthernetInterface, Interface, Percent, PortChannelInterface, PositiveInteger from anta.decorators import skip_on_platforms from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import custom_division, get_failed_logs, get_item, get_value @@ -883,3 +883,107 @@ def test(self) -> None: output["speed"] = f"{custom_division(output['speed'], BPS_GBPS_CONVERSIONS)}Gbps" failed_log = get_failed_logs(expected_interface_output, actual_interface_output) self.result.is_failure(f"For interface {intf}:{failed_log}\n") + + +class VerifyLACPInterfacesStatus(AntaTest): + """Verifies the Link Aggregation Control Protocol (LACP) status of the provided interfaces. + + - Verifies that the interface is a member of the LACP port channel. + - Ensures that the synchronization is established. + - Ensures the interfaces are in the correct state for collecting and distributing traffic. + - Validates that LACP settings, such as timeouts, are correctly configured. (i.e The long timeout mode, also known as "slow" mode, is the default setting.) + + Expected Results + ---------------- + * Success: The test will pass if the provided interfaces are bundled in port channel and all specified parameters are correct. + * Failure: The test will fail if any interface is not bundled in port channel or any of specified parameter is not correct. + + Examples + -------- + ```yaml + anta.tests.interfaces: + - VerifyLACPInterfacesStatus: + interfaces: + - name: Ethernet1 + portchannel: Port-Channel100 + ``` + """ + + name = "VerifyLACPInterfacesStatus" + description = "Verifies the Link Aggregation Control Protocol(LACP) status of the provided interfaces." + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show lacp interface {interface}", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyLACPInterfacesStatus test.""" + + interfaces: list[LACPInterface] + """List of LACP member interface.""" + + class LACPInterface(BaseModel): + """Model for an LACP member interface.""" + + name: EthernetInterface + """Ethernet interface to validate.""" + portchannel: PortChannelInterface + """Port Channel in which the interface is bundled.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each interface in the input list.""" + return [template.render(interface=interface.name) for interface in self.inputs.interfaces] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyLACPInterfacesStatus.""" + self.result.is_success() + + # Member port verification parameters. + member_port_details = ["activity", "aggregation", "synchronization", "collecting", "distributing", "timeout"] + + # Iterating over command output for different interfaces + for command, input_entry in zip(self.instance_commands, self.inputs.interfaces): + interface = input_entry.name + portchannel = input_entry.portchannel + + # Verify if a PortChannel is configured with the provided interface + if not (interface_details := get_value(command.json_output, f"portChannels.{portchannel}.interfaces.{interface}")): + self.result.is_failure(f"Interface '{interface}' is not configured to be a member of LACP '{portchannel}'.") + continue + + # Verify the interface is bundled in port channel. + actor_port_status = interface_details.get("actorPortStatus") + if actor_port_status != "bundled": + message = f"For Interface {interface}:\nExpected `bundled` as the local port status, but found `{actor_port_status}` instead.\n" + self.result.is_failure(message) + continue + + # Collecting actor and partner port details + actor_port_details = interface_details.get("actorPortState", {}) + partner_port_details = interface_details.get("partnerPortState", {}) + + # Collecting actual interface details + actual_interface_output = { + "actor_port_details": {param: actor_port_details.get(param, "NotFound") for param in member_port_details}, + "partner_port_details": {param: partner_port_details.get(param, "NotFound") for param in member_port_details}, + } + + # Forming expected interface details + expected_details = {param: param != "timeout" for param in member_port_details} + expected_interface_output = {"actor_port_details": expected_details, "partner_port_details": expected_details} + + # Forming failure message + if actual_interface_output != expected_interface_output: + message = f"For Interface {interface}:\n" + actor_port_failed_log = get_failed_logs( + expected_interface_output.get("actor_port_details", {}), actual_interface_output.get("actor_port_details", {}) + ) + partner_port_failed_log = get_failed_logs( + expected_interface_output.get("partner_port_details", {}), actual_interface_output.get("partner_port_details", {}) + ) + + if actor_port_failed_log: + message += f"Actor port details:{actor_port_failed_log}\n" + if partner_port_failed_log: + message += f"Partner port details:{partner_port_failed_log}\n" + + self.result.is_failure(message) diff --git a/examples/tests.yaml b/examples/tests.yaml index f5a5ca46b..bb7d3b0d6 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -221,6 +221,12 @@ anta.tests.interfaces: - name: Eth2 auto: False speed: 2.5 + - VerifyLACPInterfacesStatus: + interfaces: + - name: Ethernet5 + portchannel: Port-Channel5 + - name: Ethernet6 + portchannel: Port-Channel5 anta.tests.lanz: - VerifyLANZ: diff --git a/tests/units/anta_tests/test_interfaces.py b/tests/units/anta_tests/test_interfaces.py index b8cf493da..c38ac89f2 100644 --- a/tests/units/anta_tests/test_interfaces.py +++ b/tests/units/anta_tests/test_interfaces.py @@ -21,6 +21,7 @@ VerifyIpVirtualRouterMac, VerifyL2MTU, VerifyL3MTU, + VerifyLACPInterfacesStatus, VerifyLoopbackCount, VerifyPortChannels, VerifyStormControlDrops, @@ -2441,4 +2442,127 @@ ], }, }, + { + "name": "success", + "test": VerifyLACPInterfacesStatus, + "eos_data": [ + { + "portChannels": { + "Port-Channel5": { + "interfaces": { + "Ethernet5": { + "actorPortStatus": "bundled", + "partnerPortState": { + "activity": True, + "timeout": False, + "aggregation": True, + "synchronization": True, + "collecting": True, + "distributing": True, + }, + "actorPortState": { + "activity": True, + "timeout": False, + "aggregation": True, + "synchronization": True, + "collecting": True, + "distributing": True, + }, + } + } + } + }, + "interface": "Ethernet5", + "orphanPorts": {}, + } + ], + "inputs": {"interfaces": [{"name": "Ethernet5", "portchannel": "Port-Channel5"}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-bundled", + "test": VerifyLACPInterfacesStatus, + "eos_data": [ + { + "portChannels": { + "Port-Channel5": { + "interfaces": { + "Ethernet5": { + "actorPortStatus": "No Aggregate", + } + } + } + }, + "interface": "Ethernet5", + "orphanPorts": {}, + } + ], + "inputs": {"interfaces": [{"name": "Ethernet5", "portchannel": "Po5"}]}, + "expected": { + "result": "failure", + "messages": ["For Interface Ethernet5:\nExpected `bundled` as the local port status, but found `No Aggregate` instead.\n"], + }, + }, + { + "name": "failure-no-details-found", + "test": VerifyLACPInterfacesStatus, + "eos_data": [ + { + "portChannels": {"Port-Channel5": {"interfaces": {}}}, + } + ], + "inputs": {"interfaces": [{"name": "Ethernet5", "portchannel": "Po 5"}]}, + "expected": { + "result": "failure", + "messages": ["Interface 'Ethernet5' is not configured to be a member of LACP 'Port-Channel5'."], + }, + }, + { + "name": "failure-lacp-params", + "test": VerifyLACPInterfacesStatus, + "eos_data": [ + { + "portChannels": { + "Port-Channel5": { + "interfaces": { + "Ethernet5": { + "actorPortStatus": "bundled", + "partnerPortState": { + "activity": False, + "timeout": False, + "aggregation": False, + "synchronization": False, + "collecting": True, + "distributing": True, + }, + "actorPortState": { + "activity": False, + "timeout": False, + "aggregation": False, + "synchronization": False, + "collecting": True, + "distributing": True, + }, + } + } + } + }, + "interface": "Ethernet5", + "orphanPorts": {}, + } + ], + "inputs": {"interfaces": [{"name": "Ethernet5", "portchannel": "port-channel 5"}]}, + "expected": { + "result": "failure", + "messages": [ + "For Interface Ethernet5:\n" + "Actor port details:\nExpected `True` as the activity, but found `False` instead." + "\nExpected `True` as the aggregation, but found `False` instead." + "\nExpected `True` as the synchronization, but found `False` instead." + "\nPartner port details:\nExpected `True` as the activity, but found `False` instead.\n" + "Expected `True` as the aggregation, but found `False` instead.\n" + "Expected `True` as the synchronization, but found `False` instead.\n" + ], + }, + }, ] diff --git a/tests/units/test_custom_types.py b/tests/units/test_custom_types.py index 8119849a6..e3dc09d25 100644 --- a/tests/units/test_custom_types.py +++ b/tests/units/test_custom_types.py @@ -17,6 +17,7 @@ from anta.custom_types import ( REGEX_BGP_IPV4_MPLS_VPN, REGEX_BGP_IPV4_UNICAST, + REGEX_TYPE_PORTCHANNEL, REGEXP_BGP_IPV4_MPLS_LABELS, REGEXP_BGP_L2VPN_AFI, REGEXP_EOS_BLACKLIST_CMDS, @@ -140,6 +141,22 @@ def test_regexp_type_vxlan_src_interface() -> None: assert re.match(REGEXP_TYPE_VXLAN_SRC_INTERFACE, "Loopback9000") is None +def test_regexp_type_portchannel() -> None: + """Test REGEX_TYPE_PORTCHANNEL.""" + # Test strings that should match the pattern + assert re.match(REGEX_TYPE_PORTCHANNEL, "Port-Channel5") is not None + assert re.match(REGEX_TYPE_PORTCHANNEL, "Port-Channel100") is not None + assert re.match(REGEX_TYPE_PORTCHANNEL, "Port-Channel999") is not None + assert re.match(REGEX_TYPE_PORTCHANNEL, "Port-Channel1000") is not None + + # Test strings that should not match the pattern + assert re.match(REGEX_TYPE_PORTCHANNEL, "Port-Channel") is None + assert re.match(REGEX_TYPE_PORTCHANNEL, "Port_Channel") is None + assert re.match(REGEX_TYPE_PORTCHANNEL, "Port_Channel1000") is None + assert re.match(REGEX_TYPE_PORTCHANNEL, "Port_Channel5/1") is None + assert re.match(REGEX_TYPE_PORTCHANNEL, "Port-Channel-100") is None + + def test_regexp_type_hostname() -> None: """Test REGEXP_TYPE_HOSTNAME.""" # Test strings that should match the pattern @@ -200,6 +217,8 @@ def test_interface_autocomplete_success() -> None: assert interface_autocomplete("eth2") == "Ethernet2" assert interface_autocomplete("po3") == "Port-Channel3" assert interface_autocomplete("lo4") == "Loopback4" + assert interface_autocomplete("Po1000") == "Port-Channel1000" + assert interface_autocomplete("Po 1000") == "Port-Channel1000" def test_interface_autocomplete_no_alias() -> None: From cff671ee9a9f8a5d861add8f598b54bc67fab153 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 19:02:58 +0200 Subject: [PATCH 64/90] ci: update ruff-pre-commit to v0.6.5 (#830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.4 → v0.6.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.4...v0.6.5) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9da8faaad..003c2e5c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.4 + rev: v0.6.5 hooks: - id: ruff name: Run Ruff linter From 900ebb7229fd7ea7a12238fe0bf7b9a783334db5 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Tue, 17 Sep 2024 21:07:49 +0530 Subject: [PATCH 65/90] feat(anta): Added test case to verify Inbound route map and Outbound route map in BGP neighbor details (#793) --- anta/tests/routing/bgp.py | 97 ++++++++++ examples/tests.yaml | 6 + tests/units/anta_tests/routing/test_bgp.py | 202 +++++++++++++++++++++ 3 files changed, 305 insertions(+) diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 3477fc8b2..344330d2c 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -1437,3 +1437,100 @@ def test(self) -> None: self.result.is_success() else: self.result.is_failure(f"The following BGP peers are not configured or have non-zero update error counters:\n{failures}") + + +class VerifyBgpRouteMaps(AntaTest): + """Verifies BGP inbound and outbound route-maps of BGP IPv4 peer(s). + + Expected Results + ---------------- + * Success: The test will pass if the correct route maps are applied in the correct direction (inbound or outbound) for IPv4 BGP peers in the specified VRF. + * Failure: The test will fail if BGP peers are not configured or any neighbor has an incorrect or missing route map in either the inbound or outbound direction. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBgpRouteMaps: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + inbound_route_map: RM-MLAG-PEER-IN + outbound_route_map: RM-MLAG-PEER-OUT + ``` + """ + + name = "VerifyBgpRouteMaps" + description = "Verifies BGP inbound and outbound route-maps of BGP IPv4 peer(s)." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBgpRouteMaps test.""" + + bgp_peers: list[BgpPeer] + """List of BGP peers""" + + class BgpPeer(BaseModel): + """Model for a BGP peer.""" + + peer_address: IPv4Address + """IPv4 address of a BGP peer.""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + inbound_route_map: str | None = None + """Inbound route map applied, defaults to None.""" + outbound_route_map: str | None = None + """Outbound route map applied, defaults to None.""" + + @model_validator(mode="after") + def validate_inputs(self: BaseModel) -> BaseModel: + """Validate the inputs provided to the BgpPeer class. + + At least one of 'inbound' or 'outbound' route-map must be provided. + """ + if not (self.inbound_route_map or self.outbound_route_map): + msg = "At least one of 'inbound_route_map' or 'outbound_route_map' must be provided." + raise ValueError(msg) + return self + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each BGP peer in the input list.""" + return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBgpRouteMaps.""" + failures: dict[Any, Any] = {} + + for command, input_entry in zip(self.instance_commands, self.inputs.bgp_peers): + peer = str(input_entry.peer_address) + vrf = input_entry.vrf + inbound_route_map = input_entry.inbound_route_map + outbound_route_map = input_entry.outbound_route_map + failure: dict[Any, Any] = {vrf: {}} + + # Verify BGP peer. + if not (peer_list := get_value(command.json_output, f"vrfs.{vrf}.peerList")) or (peer_detail := get_item(peer_list, "peerAddress", peer)) is None: + failures[peer] = {vrf: "Not configured"} + continue + + # Verify Inbound route-map + if inbound_route_map and (inbound_map := peer_detail.get("routeMapInbound", "Not Configured")) != inbound_route_map: + failure[vrf].update({"Inbound route-map": inbound_map}) + + # Verify Outbound route-map + if outbound_route_map and (outbound_map := peer_detail.get("routeMapOutbound", "Not Configured")) != outbound_route_map: + failure[vrf].update({"Outbound route-map": outbound_map}) + + if failure[vrf]: + failures[peer] = failure + + # Check if any failures + if not failures: + self.result.is_success() + else: + self.result.is_failure( + f"The following BGP peers are not configured or has an incorrect or missing route map in either the inbound or outbound direction:\n{failures}" + ) diff --git a/examples/tests.yaml b/examples/tests.yaml index bb7d3b0d6..1c8f223d7 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -609,6 +609,12 @@ anta.tests.routing: update_errors: - inUpdErrWithdraw - inUpdErrIgnore + - VerifyBgpRouteMaps: + bgp_peers: + - peer_address: 10.100.4.1 + vrf: default + inbound_route_map: RM-MLAG-PEER-IN + outbound_route_map: RM-MLAG-PEER-IN ospf: - VerifyOSPFNeighborState: - VerifyOSPFNeighborCount: diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index b76939bd5..9948a53be 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -21,6 +21,7 @@ VerifyBGPPeerRouteRefreshCap, VerifyBGPPeersHealth, VerifyBGPPeerUpdateErrors, + VerifyBgpRouteMaps, VerifyBGPSpecificPeers, VerifyBGPTimers, VerifyEVPNType2Route, @@ -4503,4 +4504,205 @@ ], }, }, + { + "name": "success", + "test": VerifyBgpRouteMaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "routeMapInbound": "RM-MLAG-PEER-IN", + "routeMapOutbound": "RM-MLAG-PEER-OUT", + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.10", + "routeMapInbound": "RM-MLAG-PEER-IN", + "routeMapOutbound": "RM-MLAG-PEER-OUT", + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "inbound_route_map": "RM-MLAG-PEER-IN", "outbound_route_map": "RM-MLAG-PEER-OUT"}, + {"peer_address": "10.100.0.10", "vrf": "MGMT", "inbound_route_map": "RM-MLAG-PEER-IN", "outbound_route_map": "RM-MLAG-PEER-OUT"}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-incorrect-route-map", + "test": VerifyBgpRouteMaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "routeMapInbound": "RM-MLAG-PEER", + "routeMapOutbound": "RM-MLAG-PEER", + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.10", + "routeMapInbound": "RM-MLAG-PEER", + "routeMapOutbound": "RM-MLAG-PEER", + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "inbound_route_map": "RM-MLAG-PEER-IN", "outbound_route_map": "RM-MLAG-PEER-OUT"}, + {"peer_address": "10.100.0.10", "vrf": "MGMT", "inbound_route_map": "RM-MLAG-PEER-IN", "outbound_route_map": "RM-MLAG-PEER-OUT"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or has an incorrect or missing route map in either the inbound or outbound direction:\n" + "{'10.100.0.8': {'default': {'Inbound route-map': 'RM-MLAG-PEER', 'Outbound route-map': 'RM-MLAG-PEER'}}, " + "'10.100.0.10': {'MGMT': {'Inbound route-map': 'RM-MLAG-PEER', 'Outbound route-map': 'RM-MLAG-PEER'}}}" + ], + }, + }, + { + "name": "failure-incorrect-inbound-map", + "test": VerifyBgpRouteMaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "routeMapInbound": "RM-MLAG-PEER", + "routeMapOutbound": "RM-MLAG-PEER", + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.10", + "routeMapInbound": "RM-MLAG-PEER", + "routeMapOutbound": "RM-MLAG-PEER", + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "inbound_route_map": "RM-MLAG-PEER-IN"}, + {"peer_address": "10.100.0.10", "vrf": "MGMT", "inbound_route_map": "RM-MLAG-PEER-IN"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or has an incorrect or missing route map in either the inbound or outbound direction:\n" + "{'10.100.0.8': {'default': {'Inbound route-map': 'RM-MLAG-PEER'}}, '10.100.0.10': {'MGMT': {'Inbound route-map': 'RM-MLAG-PEER'}}}" + ], + }, + }, + { + "name": "failure-route-maps-not-configured", + "test": VerifyBgpRouteMaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.10", + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "inbound_route_map": "RM-MLAG-PEER-IN", "outbound_route_map": "RM-MLAG-PEER-OUT"}, + {"peer_address": "10.100.0.10", "vrf": "MGMT", "inbound_route_map": "RM-MLAG-PEER-IN", "outbound_route_map": "RM-MLAG-PEER-OUT"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or has an incorrect or missing route map in either the inbound or outbound direction:\n" + "{'10.100.0.8': {'default': {'Inbound route-map': 'Not Configured', 'Outbound route-map': 'Not Configured'}}, " + "'10.100.0.10': {'MGMT': {'Inbound route-map': 'Not Configured', 'Outbound route-map': 'Not Configured'}}}" + ], + }, + }, + { + "name": "failure-peer-not-found", + "test": VerifyBgpRouteMaps, + "eos_data": [ + { + "vrfs": { + "default": {"peerList": []}, + }, + }, + { + "vrfs": { + "MGMT": {"peerList": []}, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "inbound_route_map": "RM-MLAG-PEER-IN"}, + {"peer_address": "10.100.0.10", "vrf": "MGMT", "inbound_route_map": "RM-MLAG-PEER-IN"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or has an incorrect or missing route map in either the inbound or outbound direction:\n" + "{'10.100.0.8': {'default': 'Not configured'}, '10.100.0.10': {'MGMT': 'Not configured'}}" + ], + }, + }, ] From 345bdb9315cbc4c1b8618430cca5aa405e4570dc Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Tue, 17 Sep 2024 21:25:16 +0530 Subject: [PATCH 66/90] feat(anta): Added test case to verify maximum total number of routes configured and optionally verifies the warning limit (#798) --- anta/tests/routing/bgp.py | 86 +++++++++++ examples/tests.yaml | 6 + tests/units/anta_tests/routing/test_bgp.py | 158 +++++++++++++++++++++ 3 files changed, 250 insertions(+) diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 344330d2c..97f919876 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -1534,3 +1534,89 @@ def test(self) -> None: self.result.is_failure( f"The following BGP peers are not configured or has an incorrect or missing route map in either the inbound or outbound direction:\n{failures}" ) + + +class VerifyBGPPeerRouteLimit(AntaTest): + """Verifies the maximum routes and optionally verifies the maximum routes warning limit for the provided BGP IPv4 peer(s). + + Expected Results + ---------------- + * Success: The test will pass if the BGP peer's maximum routes and, if provided, the maximum routes warning limit are equal to the given limits. + * Failure: The test will fail if the BGP peer's maximum routes do not match the given limit, or if the maximum routes warning limit is provided + and does not match the given limit, or if the peer is not configured. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerRouteLimit: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + maximum_routes: 12000 + warning_limit: 10000 + ``` + """ + + name = "VerifyBGPPeerRouteLimit" + description = "Verifies maximum routes and maximum routes warning limit for the provided BGP IPv4 peer(s)." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerRouteLimit test.""" + + bgp_peers: list[BgpPeer] + """List of BGP peers""" + + class BgpPeer(BaseModel): + """Model for a BGP peer.""" + + peer_address: IPv4Address + """IPv4 address of a BGP peer.""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + maximum_routes: int = Field(ge=0, le=4294967294) + """The maximum allowable number of BGP routes, `0` means unlimited.""" + warning_limit: int = Field(default=0, ge=0, le=4294967294) + """Optional maximum routes warning limit. If not provided, it defaults to `0` meaning no warning limit.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each BGP peer in the input list.""" + return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerRouteLimit.""" + failures: dict[Any, Any] = {} + + for command, input_entry in zip(self.instance_commands, self.inputs.bgp_peers): + peer = str(input_entry.peer_address) + vrf = input_entry.vrf + maximum_routes = input_entry.maximum_routes + warning_limit = input_entry.warning_limit + failure: dict[Any, Any] = {} + + # Verify BGP peer. + if not (peer_list := get_value(command.json_output, f"vrfs.{vrf}.peerList")) or (peer_detail := get_item(peer_list, "peerAddress", peer)) is None: + failures[peer] = {vrf: "Not configured"} + continue + + # Verify maximum routes configured. + if (actual_routes := peer_detail.get("maxTotalRoutes", "Not Found")) != maximum_routes: + failure["Maximum total routes"] = actual_routes + + # Verify warning limit if given. + if warning_limit and (actual_warning_limit := peer_detail.get("totalRoutesWarnLimit", "Not Found")) != warning_limit: + failure["Warning limit"] = actual_warning_limit + + # Updated failures if any. + if failure: + failures[peer] = {vrf: failure} + + # Check if any failures + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"The following BGP peer(s) are not configured or maximum routes and maximum routes warning limit is not correct:\n{failures}") diff --git a/examples/tests.yaml b/examples/tests.yaml index 1c8f223d7..954b5b736 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -615,6 +615,12 @@ anta.tests.routing: vrf: default inbound_route_map: RM-MLAG-PEER-IN outbound_route_map: RM-MLAG-PEER-IN + - VerifyBGPPeerRouteLimit: + bgp_peers: + - peer_address: 10.100.0.8 + vrf: default + maximum_routes: 12000 + warning_limit: 10000 ospf: - VerifyOSPFNeighborState: - VerifyOSPFNeighborCount: diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index 9948a53be..ae306cdff 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -18,6 +18,7 @@ VerifyBGPPeerDropStats, VerifyBGPPeerMD5Auth, VerifyBGPPeerMPCaps, + VerifyBGPPeerRouteLimit, VerifyBGPPeerRouteRefreshCap, VerifyBGPPeersHealth, VerifyBGPPeerUpdateErrors, @@ -4705,4 +4706,161 @@ ], }, }, + { + "name": "success", + "test": VerifyBGPPeerRouteLimit, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "maxTotalRoutes": 12000, + "totalRoutesWarnLimit": 10000, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "maxTotalRoutes": 10000, + "totalRoutesWarnLimit": 9000, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "maximum_routes": 12000, "warning_limit": 10000}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "maximum_routes": 10000}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-peer-not-found", + "test": VerifyBGPPeerRouteLimit, + "eos_data": [ + { + "vrfs": { + "default": {}, + }, + }, + { + "vrfs": { + "MGMT": {}, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "maximum_routes": 12000, "warning_limit": 10000}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "maximum_routes": 10000, "warning_limit": 9000}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peer(s) are not configured or maximum routes and maximum routes warning limit is not correct:\n" + "{'10.100.0.8': {'default': 'Not configured'}, '10.100.0.9': {'MGMT': 'Not configured'}}" + ], + }, + }, + { + "name": "failure-incorrect-max-routes", + "test": VerifyBGPPeerRouteLimit, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "maxTotalRoutes": 13000, + "totalRoutesWarnLimit": 11000, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "maxTotalRoutes": 11000, + "totalRoutesWarnLimit": 10000, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "maximum_routes": 12000, "warning_limit": 10000}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "maximum_routes": 10000, "warning_limit": 9000}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peer(s) are not configured or maximum routes and maximum routes warning limit is not correct:\n" + "{'10.100.0.8': {'default': {'Maximum total routes': 13000, 'Warning limit': 11000}}, " + "'10.100.0.9': {'MGMT': {'Maximum total routes': 11000, 'Warning limit': 10000}}}" + ], + }, + }, + { + "name": "failure-routes-not-found", + "test": VerifyBGPPeerRouteLimit, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "maxTotalRoutes": 12000, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "maximum_routes": 12000, "warning_limit": 10000}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "maximum_routes": 10000, "warning_limit": 9000}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peer(s) are not configured or maximum routes and maximum routes warning limit is not correct:\n" + "{'10.100.0.8': {'default': {'Warning limit': 'Not Found'}}, " + "'10.100.0.9': {'MGMT': {'Maximum total routes': 'Not Found', 'Warning limit': 'Not Found'}}}" + ], + }, + }, ] From 5a4cf40c2ef471e5e21ad95bf2ed1a60f2108726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Wed, 18 Sep 2024 15:20:58 +0200 Subject: [PATCH 67/90] fix(anta)!: Make multiple tags filtering work properly (and fall pytest cleaning) (#827) * fix: typo in VerifyLoggingSourceIntf * test: configure pytest-asyncio * test: fix duplicate unit tests in tests/units/anta_tests * test: misc cleanup * test: remove tests/data/json_data.py * test: move tests/lib/anta.py * test: update conftest.py * refactor: move default_anta_env() from tests.lib.utils * test: cleanup unused data * refactor: tests/units/result_manager * refactor: tests/units/test_device.py * chore: update gitignore * refactor: tests/units/test_catalog.py * refactor: cleanup pytest * refactor: addressing comments * refactor: cleanup outdated documentation * refactor: tests/units/inventory * refactor: tests/units/test_models.py * refactor: moved fixtures in their own module * fix: make Python<3.12 happy again * Ignore F401 in tests/units/anta_tests * chore: disable various pylint rules * docs: udpate contribution * chore: disable other pylint rules * Fix tests/units/anta_tests * Refactor: Oneliners for test data * Apply suggestions from code review * Update docs/contribution.md * Update tests/units/cli/conftest.py * test: add unit test for runner * Update tests/conftest.py * Update tests/units/test_runner.py * test: add more unit tests * test: update tests/units/test_models.py * doc: update outdated doc --------- Co-authored-by: gmuloc --- .github/generate_release.py | 2 +- .gitignore | 22 +- .pre-commit-config.yaml | 1 + .vscode/settings.json | 15 - anta/catalog.py | 2 +- anta/cli/_main.py | 2 +- anta/cli/debug/commands.py | 2 - anta/cli/debug/utils.py | 1 - anta/cli/exec/__init__.py | 2 +- anta/cli/exec/utils.py | 19 +- anta/cli/get/commands.py | 2 - anta/cli/nrfu/__init__.py | 1 - anta/cli/utils.py | 3 - anta/device.py | 32 +- anta/inventory/__init__.py | 1 - anta/models.py | 11 +- anta/runner.py | 14 +- anta/tests/logging.py | 6 +- anta/tools.py | 21 +- asynceapi/device.py | 6 +- asynceapi/errors.py | 2 +- docs/advanced_usages/as-python-lib.md | 183 +----- docs/advanced_usages/custom-tests.md | 16 + docs/cli/tag-management.md | 297 +++++----- docs/contribution.md | 59 +- docs/scripts/generate_svg.py | 3 +- pyproject.toml | 48 +- tests/conftest.py | 97 ++-- tests/data/json_data.py | 259 --------- tests/data/test_catalog_with_tags.yml | 12 +- tests/data/test_inventory.yml | 12 - tests/data/test_inventory_with_tags.yml | 12 + tests/data/toto.yml | 16 - tests/lib/__init__.py | 4 - tests/lib/anta.py | 34 -- tests/lib/fixture.py | 274 --------- tests/lib/utils.py | 41 -- .../show_ntp_status_text_synchronised.out | 1 - tests/mock_data/show_uptime_json_1000000.out | 1 - .../mock_data/show_version_json_4.27.1.1F.out | 1 - tests/units/__init__.py | 8 +- tests/units/anta_tests/__init__.py | 31 +- tests/units/anta_tests/conftest.py | 35 ++ tests/units/anta_tests/routing/test_bgp.py | 29 +- .../units/anta_tests/routing/test_generic.py | 2 +- tests/units/anta_tests/routing/test_isis.py | 2 +- tests/units/anta_tests/routing/test_ospf.py | 2 +- tests/units/anta_tests/test_aaa.py | 2 +- tests/units/anta_tests/test_avt.py | 2 +- tests/units/anta_tests/test_bfd.py | 4 +- tests/units/anta_tests/test_configuration.py | 2 +- tests/units/anta_tests/test_connectivity.py | 2 +- tests/units/anta_tests/test_field_notices.py | 2 +- tests/units/anta_tests/test_flow_tracking.py | 2 +- tests/units/anta_tests/test_greent.py | 2 +- tests/units/anta_tests/test_hardware.py | 2 +- tests/units/anta_tests/test_interfaces.py | 2 +- tests/units/anta_tests/test_lanz.py | 2 +- tests/units/anta_tests/test_logging.py | 2 +- tests/units/anta_tests/test_mlag.py | 2 +- tests/units/anta_tests/test_multicast.py | 2 +- tests/units/anta_tests/test_path_selection.py | 2 +- tests/units/anta_tests/test_profiles.py | 2 +- tests/units/anta_tests/test_ptp.py | 6 +- tests/units/anta_tests/test_security.py | 2 +- tests/units/anta_tests/test_services.py | 2 +- tests/units/anta_tests/test_snmp.py | 2 +- tests/units/anta_tests/test_software.py | 2 +- tests/units/anta_tests/test_stp.py | 2 +- tests/units/anta_tests/test_stun.py | 2 +- tests/units/anta_tests/test_system.py | 2 +- tests/units/anta_tests/test_vlan.py | 2 +- tests/units/anta_tests/test_vxlan.py | 8 +- tests/units/asynceapi/test_device.py | 3 - tests/units/cli/conftest.py | 133 +++++ tests/units/cli/debug/test_commands.py | 13 +- tests/units/cli/exec/test_utils.py | 223 +++++++- tests/units/cli/get/test_commands.py | 3 - tests/units/cli/get/test_utils.py | 1 - tests/units/cli/nrfu/test__init__.py | 7 +- tests/units/cli/nrfu/test_commands.py | 8 +- tests/units/conftest.py | 85 +++ tests/units/inventory/test__init__.py | 78 +++ tests/units/inventory/test_inventory.py | 82 --- tests/units/inventory/test_models.py | 523 +++++------------ tests/units/reporter/conftest.py | 8 + tests/units/reporter/test__init__.py | 6 - tests/units/result_manager/conftest.py | 85 +++ tests/units/result_manager/test__init__.py | 1 - .../test_files}/test_md_report_results.json | 0 tests/units/result_manager/test_models.py | 81 +-- tests/units/test_catalog.py | 322 +++++------ tests/units/test_device.py | 541 +++++++----------- tests/units/test_logger.py | 1 - tests/units/test_models.py | 111 ++-- tests/units/test_runner.py | 151 +++-- tests/units/test_tools.py | 3 - 97 files changed, 1763 insertions(+), 2416 deletions(-) delete mode 100644 tests/data/json_data.py delete mode 100644 tests/data/test_inventory.yml create mode 100644 tests/data/test_inventory_with_tags.yml delete mode 100644 tests/data/toto.yml delete mode 100644 tests/lib/__init__.py delete mode 100644 tests/lib/anta.py delete mode 100644 tests/lib/fixture.py delete mode 100644 tests/lib/utils.py delete mode 100644 tests/mock_data/show_ntp_status_text_synchronised.out delete mode 100644 tests/mock_data/show_uptime_json_1000000.out delete mode 100644 tests/mock_data/show_version_json_4.27.1.1F.out create mode 100644 tests/units/anta_tests/conftest.py create mode 100644 tests/units/cli/conftest.py create mode 100644 tests/units/conftest.py create mode 100644 tests/units/inventory/test__init__.py delete mode 100644 tests/units/inventory/test_inventory.py create mode 100644 tests/units/reporter/conftest.py create mode 100644 tests/units/result_manager/conftest.py rename tests/{data => units/result_manager/test_files}/test_md_report_results.json (100%) diff --git a/.github/generate_release.py b/.github/generate_release.py index 97f139b7f..8cd4337fc 100644 --- a/.github/generate_release.py +++ b/.github/generate_release.py @@ -30,7 +30,7 @@ class SafeDumper(yaml.SafeDumper): https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586. """ - # pylint: disable=R0901,W0613,W1113 + # pylint: disable=R0901 def increase_indent(self, flow=False, *args, **kwargs): return super().increase_indent(flow=flow, indentless=False) diff --git a/.gitignore b/.gitignore index a62de025c..29e00d517 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ __pycache__ *.pyc .pages -.coverage .pytest_cache +.mypy_cache +.ruff_cache +.cache build dist *.egg-info @@ -46,14 +48,13 @@ htmlcov/ .tox/ .nox/ .coverage +coverage_html_report .coverage.* -.cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ -.pytest_cache/ cover/ report.html @@ -97,17 +98,4 @@ venv.bak/ /site # VScode settings -.vscode -test.env -tech-support/ -tech-support/* -2* - -**/report.html -.*report.html - -# direnv file -.envrc - -clab-atd-anta/* -clab-atd-anta/ +.vscode \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 003c2e5c5..9944f5bfd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -69,6 +69,7 @@ repos: - types-pyOpenSSL - pylint_pydantic - pytest + - respx - repo: https://github.com/codespell-project/codespell rev: v2.3.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index 60150c6d1..ac8ba0b02 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,28 +3,13 @@ "ruff.configuration": "pyproject.toml", "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, - "pylint.importStrategy": "fromEnvironment", - "pylint.severity": { - "refactor": "Warning" - }, - "pylint.args": [ - "--load-plugins", - "pylint_pydantic", - "--rcfile=pyproject.toml" - ], "python.testing.pytestArgs": [ "tests" ], - "autoDocstring.docstringFormat": "numpy", - "autoDocstring.includeName": false, - "autoDocstring.includeExtendedSummary": true, - "autoDocstring.startOnNewLine": true, - "autoDocstring.guessTypes": true, "python.languageServer": "Pylance", "githubIssues.issueBranchTitle": "issues/${issueNumber}-${issueTitle}", "editor.formatOnPaste": true, "files.trimTrailingWhitespace": true, - "mypy.configFile": "pyproject.toml", "workbench.remoteIndicator.showExtensionRecommendations": true, } \ No newline at end of file diff --git a/anta/catalog.py b/anta/catalog.py index 46b90d3e6..66520f9fa 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -173,7 +173,7 @@ def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[Mo module_name = f".{module_name}" # noqa: PLW2901 try: module: ModuleType = importlib.import_module(name=module_name, package=package) - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # A test module is potentially user-defined code. # We need to catch everything if we want to have meaningful logs module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}" diff --git a/anta/cli/_main.py b/anta/cli/_main.py index 1211a42d9..d70f1cf56 100644 --- a/anta/cli/_main.py +++ b/anta/cli/_main.py @@ -61,7 +61,7 @@ def cli() -> None: """Entrypoint for pyproject.toml.""" try: anta(obj={}, auto_envvar_prefix="ANTA") - except Exception as exc: # pylint: disable=broad-exception-caught + except Exception as exc: # noqa: BLE001 anta_log_exception( exc, f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}", diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py index 1304758a4..e6e456e67 100644 --- a/anta/cli/debug/commands.py +++ b/anta/cli/debug/commands.py @@ -35,7 +35,6 @@ def run_cmd( version: Literal["1", "latest"], revision: int, ) -> None: - # pylint: disable=too-many-arguments """Run arbitrary command to an ANTA device.""" console.print(f"Run command [green]{command}[/green] on [red]{device.name}[/red]") # I do not assume the following line, but click make me do it @@ -71,7 +70,6 @@ def run_template( version: Literal["1", "latest"], revision: int, ) -> None: - # pylint: disable=too-many-arguments # Using \b for click # ruff: noqa: D301 """Run arbitrary templated command to an ANTA device. diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py index 4e20c5a74..454c3e640 100644 --- a/anta/cli/debug/utils.py +++ b/anta/cli/debug/utils.py @@ -48,7 +48,6 @@ def wrapper( **kwargs: Any, ) -> Any: # TODO: @gmuloc - tags come from context https://github.com/aristanetworks/anta/issues/584 - # pylint: disable=unused-argument # ruff: noqa: ARG001 if (d := inventory.get(device)) is None: logger.error("Device '%s' does not exist in Inventory", device) diff --git a/anta/cli/exec/__init__.py b/anta/cli/exec/__init__.py index 7f9b4c2b6..5fa6eb92a 100644 --- a/anta/cli/exec/__init__.py +++ b/anta/cli/exec/__init__.py @@ -9,7 +9,7 @@ @click.group("exec") -def _exec() -> None: # pylint: disable=redefined-builtin +def _exec() -> None: """Commands to execute various scripts on EOS devices.""" diff --git a/anta/cli/exec/utils.py b/anta/cli/exec/utils.py index a5f7da2b1..ce13622a7 100644 --- a/anta/cli/exec/utils.py +++ b/anta/cli/exec/utils.py @@ -10,16 +10,15 @@ import itertools import json import logging -import re from pathlib import Path from typing import TYPE_CHECKING, Literal from click.exceptions import UsageError from httpx import ConnectError, HTTPError -from anta.custom_types import REGEXP_PATH_MARKERS from anta.device import AntaDevice, AsyncEOSDevice from anta.models import AntaCommand +from anta.tools import safe_command from asynceapi import EapiCommandError if TYPE_CHECKING: @@ -52,7 +51,7 @@ async def clear(dev: AntaDevice) -> None: async def collect_commands( inv: AntaInventory, - commands: dict[str, str], + commands: dict[str, list[str]], root_dir: Path, tags: set[str] | None = None, ) -> None: @@ -61,17 +60,16 @@ async def collect_commands( async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None: outdir = Path() / root_dir / dev.name / outformat outdir.mkdir(parents=True, exist_ok=True) - safe_command = re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command) c = AntaCommand(command=command, ofmt=outformat) await dev.collect(c) if not c.collected: logger.error("Could not collect commands on device %s: %s", dev.name, c.errors) return if c.ofmt == "json": - outfile = outdir / f"{safe_command}.json" + outfile = outdir / f"{safe_command(command)}.json" content = json.dumps(c.json_output, indent=2) elif c.ofmt == "text": - outfile = outdir / f"{safe_command}.log" + outfile = outdir / f"{safe_command(command)}.log" content = c.text_output else: logger.error("Command outformat is not in ['json', 'text'] for command '%s'", command) @@ -83,6 +81,9 @@ async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "tex logger.info("Connecting to devices...") await inv.connect_inventory() devices = inv.get_inventory(established_only=True, tags=tags).devices + if not devices: + logger.info("No online device found. Exiting") + return logger.info("Collecting commands from remote devices") coros = [] if "json_format" in commands: @@ -134,8 +135,8 @@ async def collect(device: AntaDevice) -> None: if not isinstance(device, AsyncEOSDevice): msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now." raise UsageError(msg) - if device.enable and device._enable_password is not None: # pylint: disable=protected-access - commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access + if device.enable and device._enable_password is not None: + commands.append({"cmd": "enable", "input": device._enable_password}) elif device.enable: commands.append({"cmd": "enable"}) commands.extend( @@ -146,7 +147,7 @@ async def collect(device: AntaDevice) -> None: ) logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name) command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text") - await device._session.cli(commands=commands) # pylint: disable=protected-access + await device._session.cli(commands=commands) logger.info("Configured 'aaa authorization exec default local' on device %s", device.name) logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name) diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index bfe94e618..ea1cc7561 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -45,7 +45,6 @@ default=False, ) def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None, *, ignore_cert: bool) -> None: - # pylint: disable=too-many-arguments """Build ANTA inventory from CloudVision. NOTE: Only username/password authentication is supported for on-premises CloudVision instances. @@ -127,7 +126,6 @@ def inventory(inventory: AntaInventory, tags: set[str] | None, *, connected: boo @click.command @inventory_options def tags(inventory: AntaInventory, **kwargs: Any) -> None: - # pylint: disable=unused-argument """Get list of configured tags in user inventory.""" tags: set[str] = set() for device in inventory.values(): diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index d573b49c7..0272e0dba 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -103,7 +103,6 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: is_flag=True, default=False, ) -# pylint: disable=too-many-arguments def nrfu( ctx: click.Context, inventory: AntaInventory, diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 19ffb113f..a939c3220 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -40,7 +40,6 @@ class ExitCode(enum.IntEnum): def parse_tags(ctx: click.Context, param: Option, value: str | None) -> set[str] | None: - # pylint: disable=unused-argument # ruff: noqa: ARG001 """Click option callback to parse an ANTA inventory tags.""" if value is not None: @@ -207,7 +206,6 @@ def wrapper( disable_cache: bool, **kwargs: dict[str, Any], ) -> Any: - # pylint: disable=too-many-arguments # If help is invoke somewhere, do not parse inventory if ctx.obj.get("_anta_help"): return f(*args, inventory=None, **kwargs) @@ -272,7 +270,6 @@ def wrapper( tags: set[str] | None, **kwargs: dict[str, Any], ) -> Any: - # pylint: disable=too-many-arguments # If help is invoke somewhere, do not parse inventory if ctx.obj.get("_anta_help"): return f(*args, tags=tags, **kwargs) diff --git a/anta/device.py b/anta/device.py index 74b81d91e..35b7f4c04 100644 --- a/anta/device.py +++ b/anta/device.py @@ -106,7 +106,7 @@ def _init_cache(self) -> None: @property def cache_statistics(self) -> dict[str, Any] | None: - """Returns the device cache statistics for logging purposes.""" + """Return the device cache statistics for logging purposes.""" # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough # https://github.com/pylint-dev/pylint/issues/7258 if self.cache is not None: @@ -126,6 +126,17 @@ def __rich_repr__(self) -> Iterator[tuple[str, Any]]: yield "established", self.established yield "disable_cache", self.cache is None + def __repr__(self) -> str: + """Return a printable representation of an AntaDevice.""" + return ( + f"AntaDevice({self.name!r}, " + f"tags={self.tags!r}, " + f"hw_model={self.hw_model!r}, " + f"is_online={self.is_online!r}, " + f"established={self.established!r}, " + f"disable_cache={self.cache is None!r})" + ) + @abstractmethod async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: """Collect device command output. @@ -244,7 +255,6 @@ class AsyncEOSDevice(AntaDevice): """ - # pylint: disable=R0913 def __init__( self, host: str, @@ -338,6 +348,22 @@ def __rich_repr__(self) -> Iterator[tuple[str, Any]]: yield ("_session", vars(self._session)) yield ("_ssh_opts", _ssh_opts) + def __repr__(self) -> str: + """Return a printable representation of an AsyncEOSDevice.""" + return ( + f"AsyncEOSDevice({self.name!r}, " + f"tags={self.tags!r}, " + f"hw_model={self.hw_model!r}, " + f"is_online={self.is_online!r}, " + f"established={self.established!r}, " + f"disable_cache={self.cache is None!r}, " + f"host={self._session.host!r}, " + f"eapi_port={self._session.port!r}, " + f"username={self._ssh_opts.username!r}, " + f"enable={self.enable!r}, " + f"insecure={self._ssh_opts.known_hosts is None!r})" + ) + @property def _keys(self) -> tuple[Any, ...]: """Two AsyncEOSDevice objects are equal if the hostname and the port are the same. @@ -346,7 +372,7 @@ def _keys(self) -> tuple[Any, ...]: """ return (self._session.host, self._session.port) - async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: # noqa: C901 function is too complex - because of many required except blocks #pylint: disable=line-too-long + async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: # noqa: C901 function is too complex - because of many required except blocks """Collect device command output from EOS using aio-eapi. Supports outformat `json` and `text` as output structure. diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py index 29450be62..3046d7a66 100644 --- a/anta/inventory/__init__.py +++ b/anta/inventory/__init__.py @@ -171,7 +171,6 @@ def _parse_ranges( anta_log_exception(e, message, logger) raise InventoryIncorrectSchemaError(message) from e - # pylint: disable=too-many-arguments @staticmethod def parse( filename: str | Path, diff --git a/anta/models.py b/anta/models.py index 9a695bcd6..b103a9965 100644 --- a/anta/models.py +++ b/anta/models.py @@ -18,7 +18,7 @@ from anta import GITHUB_SUGGESTION from anta.custom_types import REGEXP_EOS_BLACKLIST_CMDS, Revision from anta.logger import anta_log_exception, exc_to_str -from anta.result_manager.models import TestResult +from anta.result_manager.models import AntaTestStatus, TestResult if TYPE_CHECKING: from collections.abc import Coroutine @@ -71,7 +71,6 @@ def __init__( *, use_cache: bool = True, ) -> None: - # pylint: disable=too-many-arguments self.template = template self.version = version self.revision = revision @@ -430,7 +429,7 @@ def __init__( description=self.description, ) self._init_inputs(inputs) - if self.result.result == "unset": + if self.result.result == AntaTestStatus.UNSET: self._init_commands(eos_data) def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None: @@ -481,7 +480,7 @@ def _init_commands(self, eos_data: list[dict[Any, Any] | str] | None) -> None: except NotImplementedError as e: self.result.is_error(message=e.args[0]) return - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # render() is user-defined code. # We need to catch everything if we want the AntaTest object # to live until the reporting @@ -559,7 +558,7 @@ async def collect(self) -> None: try: if self.blocked is False: await self.device.collect_commands(self.instance_commands, collection_id=self.name) - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # device._collect() is user-defined code. # We need to catch everything if we want the AntaTest object # to live until the reporting @@ -631,7 +630,7 @@ async def wrapper( try: function(self, **kwargs) - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # test() is user-defined code. # We need to catch everything if we want the AntaTest object # to live until the reporting diff --git a/anta/runner.py b/anta/runner.py index e07cba94f..c818d192d 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -147,14 +147,15 @@ def prepare_tests( # Create AntaTestRunner tuples from the tags for device in inventory.devices: if tags: - # If there are CLI tags, only execute tests with matching tags - device_to_tests[device].update(catalog.get_tests_by_tags(tags)) + if not any(tag in device.tags for tag in tags): + # The device does not have any selected tag, skipping + continue else: # If there is no CLI tags, execute all tests that do not have any tags device_to_tests[device].update(catalog.tag_to_tests[None]) - # Then add the tests with matching tags from device tags - device_to_tests[device].update(catalog.get_tests_by_tags(device.tags)) + # Add the tests with matching tags from device tags + device_to_tests[device].update(catalog.get_tests_by_tags(device.tags)) catalog.final_tests_count += len(device_to_tests[device]) @@ -187,12 +188,12 @@ def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinitio try: test_instance = test.test(device=device, inputs=test.inputs) coros.append(test_instance.test()) - except Exception as e: # noqa: PERF203, pylint: disable=broad-exception-caught + except Exception as e: # noqa: PERF203, BLE001 # An AntaTest instance is potentially user-defined code. # We need to catch everything and exit gracefully with an error message. message = "\n".join( [ - f"There is an error when creating test {test.test.module}.{test.test.__name__}.", + f"There is an error when creating test {test.test.__module__}.{test.test.__name__}.", f"If this is not a custom test implementation: {GITHUB_SUGGESTION}", ], ) @@ -212,7 +213,6 @@ async def main( # noqa: PLR0913 established_only: bool = True, dry_run: bool = False, ) -> None: - # pylint: disable=too-many-arguments """Run ANTA. Use this as an entrypoint to the test framework in your script. diff --git a/anta/tests/logging.py b/anta/tests/logging.py index c5202cce1..b32bc99fd 100644 --- a/anta/tests/logging.py +++ b/anta/tests/logging.py @@ -100,13 +100,13 @@ class VerifyLoggingSourceIntf(AntaTest): ``` """ - name = "VerifyLoggingSourceInt" + name = "VerifyLoggingSourceIntf" description = "Verifies logging source-interface for a specified VRF." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] class Input(AntaTest.Input): - """Input model for the VerifyLoggingSourceInt test.""" + """Input model for the VerifyLoggingSourceIntf test.""" interface: str """Source-interface to use as source IP of log messages.""" @@ -115,7 +115,7 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: - """Main test function for VerifyLoggingSourceInt.""" + """Main test function for VerifyLoggingSourceIntf.""" output = self.instance_commands[0].text_output pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}" if re.search(pattern, _get_logging_states(self.logger, output)): diff --git a/anta/tools.py b/anta/tools.py index 00aad5afe..dc4dc12ca 100644 --- a/anta/tools.py +++ b/anta/tools.py @@ -8,10 +8,12 @@ import cProfile import os import pstats +import re from functools import wraps from time import perf_counter from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast +from anta.custom_types import REGEXP_PATH_MARKERS from anta.logger import format_td if TYPE_CHECKING: @@ -82,7 +84,6 @@ def custom_division(numerator: float, denominator: float) -> int | float: return int(result) if result.is_integer() else result -# pylint: disable=too-many-arguments def get_dict_superset( list_of_dicts: list[dict[Any, Any]], input_dict: dict[Any, Any], @@ -142,7 +143,6 @@ def get_dict_superset( return default -# pylint: disable=too-many-arguments def get_value( dictionary: dict[Any, Any], key: str, @@ -199,7 +199,6 @@ def get_value( return value -# pylint: disable=too-many-arguments def get_item( list_of_dicts: list[dict[Any, Any]], key: Any, @@ -357,3 +356,19 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: return cast(F, wrapper) return decorator + + +def safe_command(command: str) -> str: + """Return a sanitized command. + + Parameters + ---------- + command + The command to sanitize. + + Returns + ------- + str + The sanitized command. + """ + return re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command) diff --git a/asynceapi/device.py b/asynceapi/device.py index 394abe40d..ca5a30c25 100644 --- a/asynceapi/device.py +++ b/asynceapi/device.py @@ -54,7 +54,7 @@ class Device(httpx.AsyncClient): EAPI_OFMT_OPTIONS = ("json", "text") EAPI_DEFAULT_OFMT = "json" - def __init__( # pylint: disable=too-many-arguments + def __init__( self, host: str | None = None, username: str | None = None, @@ -115,7 +115,7 @@ async def check_connection(self) -> bool: """ return await port_check_url(self.base_url) - async def cli( # noqa: PLR0913 # pylint: disable=too-many-arguments + async def cli( # noqa: PLR0913 self, command: str | dict[str, Any] | None = None, commands: Sequence[str | dict[str, Any]] | None = None, @@ -189,7 +189,7 @@ async def cli( # noqa: PLR0913 # pylint: disable=too-many-arguments return None raise - def _jsonrpc_command( # noqa: PLR0913 # pylint: disable=too-many-arguments + def _jsonrpc_command( # noqa: PLR0913 self, commands: Sequence[str | dict[str, Any]] | None = None, ofmt: str | None = None, diff --git a/asynceapi/errors.py b/asynceapi/errors.py index 020d3dc2f..e6794b7ef 100644 --- a/asynceapi/errors.py +++ b/asynceapi/errors.py @@ -24,7 +24,7 @@ class EapiCommandError(RuntimeError): not_exec: a list of commands that were not executed """ - def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: # pylint: disable=too-many-arguments + def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: """Initialize for the EapiCommandError exception.""" self.failed = failed self.errmsg = errmsg diff --git a/docs/advanced_usages/as-python-lib.md b/docs/advanced_usages/as-python-lib.md index 08bb818c1..d8790f3ef 100644 --- a/docs/advanced_usages/as-python-lib.md +++ b/docs/advanced_usages/as-python-lib.md @@ -40,8 +40,11 @@ The [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) class is a - The [connect_inventory()](../api/inventory.md#anta.inventory.AntaInventory.connect_inventory) coroutine will execute the [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutines of all the devices in the inventory. - The [parse()](../api/inventory.md#anta.inventory.AntaInventory.parse) static method creates an [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) instance from a YAML file and returns it. The devices are [AsyncEOSDevice](../api/device.md#anta.device.AsyncEOSDevice) instances. +## Examples -To parse a YAML inventory file and print the devices connection status: +##### Parse an ANTA inventory file + +> This script parses an ANTA inventory file, connects to devices and print their status ```python """ @@ -81,7 +84,10 @@ if __name__ == "__main__": ??? note "How to create your inventory file" Please visit this [dedicated section](../usage-inventory-catalog.md) for how to use inventory and catalog files. -To run an EOS commands list on the reachable devices from the inventory: +##### Run EOS commands + +> This script runs a list of EOS commands on reachable devices + ```python """ Example @@ -138,176 +144,3 @@ if __name__ == "__main__": pprint(res) ``` - - -## Use tests from ANTA - -All the test classes inherit from the same abstract Base Class AntaTest. The Class definition indicates which commands are required for the test and the user should focus only on writing the `test` function with optional keywords argument. The instance of the class upon creation instantiates a TestResult object that can be accessed later on to check the status of the test ([unset, skipped, success, failure, error]). - -### Test structure - -All tests are built on a class named `AntaTest` which provides a complete toolset for a test: - -- Object creation -- Test definition -- TestResult definition -- Abstracted method to collect data - -This approach means each time you create a test it will be based on this `AntaTest` class. Besides that, you will have to provide some elements: - -- `name`: Name of the test -- `description`: A human readable description of your test -- `categories`: a list of categories to sort test. -- `commands`: a list of command to run. This list _must_ be a list of `AntaCommand` which is described in the next part of this document. - -Here is an example of a hardware test related to device temperature: - -```python -from __future__ import annotations - -import logging -from typing import Any, Dict, List, Optional, cast - -from anta.models import AntaTest, AntaCommand - - -class VerifyTemperature(AntaTest): - """ - Verifies device temparture is currently OK. - """ - - # The test name - name = "VerifyTemperature" - # A small description of the test, usually the first line of the class docstring - description = "Verifies device temparture is currently OK" - # The category of the test, usually the module name - categories = ["hardware"] - # The command(s) used for the test. Could be a template instead - commands = [AntaCommand(command="show system environment temperature", ofmt="json")] - - # Decorator - @AntaTest.anta_test - # abstract method that must be defined by the child Test class - def test(self) -> None: - """Run VerifyTemperature validation""" - command_output = cast(Dict[str, Dict[Any, Any]], self.instance_commands[0].output) - temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else "" - if temperature_status == "temperatureOk": - self.result.is_success() - else: - self.result.is_failure(f"Device temperature is not OK, systemStatus: {temperature_status }") -``` - -When you run the test, object will automatically call its `anta.models.AntaTest.collect()` method to get device output for each command if no pre-collected data was given to the test. This method does a loop to call `anta.inventory.models.InventoryDevice.collect()` methods which is in charge of managing device connection and how to get data. - -??? info "run test offline" - You can also pass eos data directly to your test if you want to validate data collected in a different workflow. An example is provided below just for information: - - ```python - test = VerifyTemperature(device, eos_data=test_data["eos_data"]) - asyncio.run(test.test()) - ``` - -The `test` function is always the same and __must__ be defined with the `@AntaTest.anta_test` decorator. This function takes at least one argument which is a `anta.inventory.models.InventoryDevice` object. -In some cases a test would rely on some additional inputs from the user, for instance the number of expected peers or some expected numbers. All parameters __must__ come with a default value and the test function __should__ validate the parameters values (at this stage this is the only place where validation can be done but there are future plans to make this better). - -```python -class VerifyTemperature(AntaTest): - ... - @AntaTest.anta_test - def test(self) -> None: - pass - -class VerifyTransceiversManufacturers(AntaTest): - ... - @AntaTest.anta_test - def test(self, manufacturers: Optional[List[str]] = None) -> None: - # validate the manufactures parameter - pass -``` - -The test itself does not return any value, but the result is directly available from your AntaTest object and exposes a `anta.result_manager.models.TestResult` object with result, name of the test and optional messages: - - -- `name` (str): Device name where the test has run. -- `test` (str): Test name runs on the device. -- `categories` (List[str]): List of categories the TestResult belongs to, by default the AntaTest categories. -- `description` (str): TestResult description, by default the AntaTest description. -- `results` (str): Result of the test. Can be one of ["unset", "success", "failure", "error", "skipped"]. -- `message` (str, optional): Message to report after the test if any. -- `custom_field` (str, optional): Custom field to store a string for flexibility in integrating with ANTA - -```python -from anta.tests.hardware import VerifyTemperature - -test = VerifyTemperature(device, eos_data=test_data["eos_data"]) -asyncio.run(test.test()) -assert test.result.result == "success" -``` - -### Classes for commands - -To make it easier to get data, ANTA defines 2 different classes to manage commands to send to devices: - -#### [AntaCommand](../api/models.md#anta.models.AntaCommand) Class - -Represent a command with following information: - -- Command to run -- Output format expected -- eAPI version -- Output of the command - -Usage example: - -```python -from anta.models import AntaCommand - -cmd1 = AntaCommand(command="show zerotouch") -cmd2 = AntaCommand(command="show running-config diffs", ofmt="text") -``` - -!!! tip "Command revision and version" - * Most of EOS commands return a JSON structure according to a model (some commands may not be modeled hence the necessity to use `text` outformat sometimes. - * The model can change across time (adding feature, ... ) and when the model is changed in a non backward-compatible way, the __revision__ number is bumped. The initial model starts with __revision__ 1. - * A __revision__ applies to a particular CLI command whereas a __version__ is global to an eAPI call. The __version__ is internally translated to a specific __revision__ for each CLI command in the RPC call. The currently supported __version__ values are `1` and `latest`. - * A __revision takes precedence over a version__ (e.g. if a command is run with version="latest" and revision=1, the first revision of the model is returned) - * By default, eAPI returns the first revision of each model to ensure that when upgrading, integrations with existing tools are not broken. This is done by using by default `version=1` in eAPI calls. - - By default, ANTA uses `version="latest"` in AntaCommand, but when developing tests, the revision MUST be provided when the outformat of the command is `json`. As explained earlier, this is to ensure that the eAPI always returns the same output model and that the test remains always valid from the day it was created. For some commands, you may also want to run them with a different revision or version. - - For instance, the `VerifyBFDPeersHealth` test leverages the first revision of `show bfd peers`: - - ``` - # revision 1 as later revision introduce additional nesting for type - commands = [AntaCommand(command="show bfd peers", revision=1)] - ``` - -#### [AntaTemplate](../api/models.md#anta.models.AntaTemplate) Class - -Because some command can require more dynamic than just a command with no parameter provided by user, ANTA supports command template: you define a template in your test class and user provide parameters when creating test object. - -```python - -class RunArbitraryTemplateCommand(AntaTest): - """ - Run an EOS command and return result - Based on AntaTest to build relevant output for pytest - """ - - name = "Run aributrary EOS command" - description = "To be used only with anta debug commands" - template = AntaTemplate(template="show interfaces {ifd}") - categories = ["debug"] - - @AntaTest.anta_test - def test(self) -> None: - errdisabled_interfaces = [interface for interface, value in response["interfaceStatuses"].items() if value["linkStatus"] == "errdisabled"] - ... - - -params = [{"ifd": "Ethernet2"}, {"ifd": "Ethernet49/1"}] -run_command1 = RunArbitraryTemplateCommand(device_anta, params) -``` - -In this example, test waits for interfaces to check from user setup and will only check for interfaces in `params` diff --git a/docs/advanced_usages/custom-tests.md b/docs/advanced_usages/custom-tests.md index c6a2fa896..958a05539 100644 --- a/docs/advanced_usages/custom-tests.md +++ b/docs/advanced_usages/custom-tests.md @@ -199,6 +199,22 @@ class (AntaTest): ] ``` +!!! tip "Command revision and version" + * Most of EOS commands return a JSON structure according to a model (some commands may not be modeled hence the necessity to use `text` outformat sometimes. + * The model can change across time (adding feature, ... ) and when the model is changed in a non backward-compatible way, the __revision__ number is bumped. The initial model starts with __revision__ 1. + * A __revision__ applies to a particular CLI command whereas a __version__ is global to an eAPI call. The __version__ is internally translated to a specific __revision__ for each CLI command in the RPC call. The currently supported __version__ values are `1` and `latest`. + * A __revision takes precedence over a version__ (e.g. if a command is run with version="latest" and revision=1, the first revision of the model is returned) + * By default, eAPI returns the first revision of each model to ensure that when upgrading, integrations with existing tools are not broken. This is done by using by default `version=1` in eAPI calls. + + By default, ANTA uses `version="latest"` in AntaCommand, but when developing tests, the revision MUST be provided when the outformat of the command is `json`. As explained earlier, this is to ensure that the eAPI always returns the same output model and that the test remains always valid from the day it was created. For some commands, you may also want to run them with a different revision or version. + + For instance, the `VerifyBFDPeersHealth` test leverages the first revision of `show bfd peers`: + + ``` + # revision 1 as later revision introduce additional nesting for type + commands = [AntaCommand(command="show bfd peers", revision=1)] + ``` + ### Inputs definition If the user needs to provide inputs for your test, you need to define a [pydantic model](https://docs.pydantic.dev/latest/usage/models/) that defines the schema of the test inputs: diff --git a/docs/cli/tag-management.md b/docs/cli/tag-management.md index 8c043d712..0bba29e70 100644 --- a/docs/cli/tag-management.md +++ b/docs/cli/tag-management.md @@ -3,163 +3,198 @@ ~ Use of this source code is governed by the Apache License 2.0 ~ that can be found in the LICENSE file. --> +## Overview -# Tag management +ANTA commands can be used with a `--tags` option. This option **filters the inventory** with the specified tag(s) when running the command. -## Overview +Tags can also be used to **restrict a specific test** to a set of devices when using `anta nrfu`. + +## Defining tags + +### Device tags + +Device tags can be defined in the inventory: + +```yaml +anta_inventory: + hosts: + - name: leaf1 + host: leaf1.anta.arista.com + tags: ["leaf"] + - name: leaf2 + host: leaf2.anta.arista.com + tags: ["leaf"] + - name: spine1 + host: spine1.anta.arista.com + tags: ["spine"] +``` + +Each device also has its own name automatically added as a tag: + +```bash +anta get inventory +Current inventory content is: +{ + 'leaf1': AsyncEOSDevice( + name='leaf1', + tags={'leaf', 'leaf1'}, <-- + [...] + host='leaf1.anta.arista.com', + [...] + ), + 'leaf2': AsyncEOSDevice( + name='leaf2', + tags={'leaf', 'leaf2'}, <-- + [...] + host='leaf2.anta.arista.com', + [...] + ), + 'spine1': AsyncEOSDevice( + name='spine1', + tags={'spine1', 'spine'}, <-- + [...] + host='spine1.anta.arista.com', + [...] + ) +} +``` -Some of the ANTA commands like `anta nrfu` command come with a `--tags` option. +### Test tags + +Tags can be defined in the test catalog to restrict tests to tagged devices: + +```yaml +anta.tests.system: + - VerifyUptime: + minimum: 10 + filters: + tags: ['spine'] + - VerifyUptime: + minimum: 9 + filters: + tags: ['leaf'] + - VerifyReloadCause: + filters: + tags: ['spine', 'leaf'] + - VerifyCoredump: + - VerifyAgentLogs: + - VerifyCPUUtilization: + - VerifyMemoryUtilization: + - VerifyFileSystemUtilization: + - VerifyNTP: + +anta.tests.mlag: + - VerifyMlagStatus: + filters: + tags: ['leaf'] + +anta.tests.interfaces: + - VerifyL3MTU: + mtu: 1500 + filters: + tags: ['spine'] +``` -For `nrfu`, this allows users to specify a set of tests, marked with a given tag, to be run on devices marked with the same tag. For instance, you can run tests dedicated to leaf devices on your leaf devices only and not on other devices. +> A tag used to filter a test can also be a device name -Tags are string defined by the user and can be anything considered as a string by Python. A [default one](#default-tags) is present for all tests and devices. +!!! tip "Use different input values for a specific test" + Leverage tags to define different input values for a specific test. See the `VerifyUptime` example above. -The next table provides a short summary of the scope of tags using CLI +## Using tags | Command | Description | | ------- | ----------- | -| `none` | Run all tests on all devices according `tag` definition in your inventory and test catalog. And tests with no tag are executed on all devices| -| `--tags leaf` | Run all tests marked with `leaf` tag on all devices configured with `leaf` tag.
All other tags are ignored | -| `--tags leaf,spine` | Run all tests marked with `leaf` tag on all devices configured with `leaf` tag.
Run all tests marked with `spine` tag on all devices configured with `spine` tag.
All other tags are ignored | - -## Inventory and Catalog for tests - -All commands in this page are based on the following inventory and test catalog. - -=== "Inventory" - - ```yaml - --- - anta_inventory: - hosts: - - host: 192.168.0.10 - name: spine01 - tags: ['fabric', 'spine'] - - host: 192.168.0.11 - name: spine02 - tags: ['fabric', 'spine'] - - host: 192.168.0.12 - name: leaf01 - tags: ['fabric', 'leaf'] - - host: 192.168.0.13 - name: leaf02 - tags: ['fabric', 'leaf'] - - host: 192.168.0.14 - name: leaf03 - tags: ['fabric', 'leaf'] - - host: 192.168.0.15 - name: leaf04 - tags: ['fabric', 'leaf' - ``` - -=== "Test Catalog" - - ```yaml - anta.tests.system: - - VerifyUptime: - minimum: 10 - filters: - tags: ['fabric'] - - VerifyReloadCause: - tags: ['leaf', spine'] - - VerifyCoredump: - - VerifyAgentLogs: - - VerifyCPUUtilization: - filters: - tags: ['spine', 'leaf'] - - VerifyMemoryUtilization: - - VerifyFileSystemUtilization: - - VerifyNTP: - - anta.tests.mlag: - - VerifyMlagStatus: - - - anta.tests.interfaces: - - VerifyL3MTU: - mtu: 1500 - filters: - tags: ['demo'] - ``` - -## Default tags - -By default, ANTA uses a default tag for both devices and tests. This default tag is `all` and it can be explicit if you want to make it visible in your inventory and also implicit since the framework injects this tag if it is not defined. - -So this command will run all tests from your catalog on all devices. With a mapping for `tags` defined in your inventory and catalog. If no `tags` configured, then tests are executed against all devices. +| No `--tags` option | Run all tests on all devices according to the `tag` definitions in your inventory and test catalog.
Tests without tags are executed on all devices. | +| `--tags leaf` | Run all tests marked with the `leaf` tag on all devices configured with the `leaf` tag.
All other tests are ignored. | +| `--tags leaf,spine` | Run all tests marked with the `leaf` tag on all devices configured with the `leaf` tag.
Run all tests marked with the `spine` tag on all devices configured with the `spine` tag.
All other tests are ignored. | -```bash -$ anta nrfu -c .personal/catalog-class.yml table --group-by device +### Examples + +The following examples use the inventory and test catalog defined above. +##### No `--tags` option + +Tests without tags are run on all devices. +Tests with tags will only run on devices with matching tags. + +```bash +$ anta nrfu table --group-by device ╭────────────────────── Settings ──────────────────────╮ -│ Running ANTA tests: │ -│ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ -│ - Tests catalog contains 10 tests │ +│ - ANTA Inventory contains 3 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 11 tests │ ╰──────────────────────────────────────────────────────╯ -┏━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ Device ┃ # of success ┃ # of skipped ┃ # of failure ┃ # of errors ┃ List of failed or error test cases ┃ -┡━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ -│ spine01 │ 5 │ 1 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ -│ spine02 │ 5 │ 1 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ -│ leaf01 │ 6 │ 0 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ -│ leaf02 │ 6 │ 0 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ -│ leaf03 │ 6 │ 0 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ -│ leaf04 │ 6 │ 0 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ -└─────────┴──────────────┴──────────────┴──────────────┴─────────────┴────────────────────────────────────┘ +--- ANTA NRFU Run Information --- +Number of devices: 3 (3 established) +Total number of selected tests: 27 +--------------------------------- + Summary per device +┏━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ Device ┃ # of success ┃ # of skipped ┃ # of failure ┃ # of errors ┃ List of failed or error test cases ┃ +┡━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ +│ leaf1 │ 9 │ 0 │ 0 │ 0 │ │ +├────────┼──────────────┼──────────────┼──────────────┼─────────────┼────────────────────────────────────┤ +│ leaf2 │ 7 │ 1 │ 1 │ 0 │ VerifyAgentLogs │ +├────────┼──────────────┼──────────────┼──────────────┼─────────────┼────────────────────────────────────┤ +│ spine1 │ 9 │ 0 │ 0 │ 0 │ │ +└────────┴──────────────┴──────────────┴──────────────┴─────────────┴────────────────────────────────────┘ ``` -## Use a single tag in CLI +##### Single tag -The most used approach is to use a single tag in your CLI to filter tests & devices configured with this one. - -In such scenario, ANTA will run tests marked with `$tag` only on devices marked with `$tag`. All other tests and devices will be ignored +With a tag specified, only tests matching this tag will be run on matching devices. ```bash -$ anta nrfu -c .personal/catalog-class.yml --tags leaf text +$ anta nrfu --tags leaf text ╭────────────────────── Settings ──────────────────────╮ -│ Running ANTA tests: │ -│ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ -│ - Tests catalog contains 10 tests │ +│ - ANTA Inventory contains 3 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 11 tests │ ╰──────────────────────────────────────────────────────╯ -leaf01 :: VerifyUptime :: SUCCESS -leaf01 :: VerifyReloadCause :: SUCCESS -leaf01 :: VerifyCPUUtilization :: SUCCESS -leaf02 :: VerifyUptime :: SUCCESS -leaf02 :: VerifyReloadCause :: SUCCESS -leaf02 :: VerifyCPUUtilization :: SUCCESS -leaf03 :: VerifyUptime :: SUCCESS -leaf03 :: VerifyReloadCause :: SUCCESS -leaf03 :: VerifyCPUUtilization :: SUCCESS -leaf04 :: VerifyUptime :: SUCCESS -leaf04 :: VerifyReloadCause :: SUCCESS -leaf04 :: VerifyCPUUtilization :: SUCCESS +--- ANTA NRFU Run Information --- +Number of devices: 3 (2 established) +Total number of selected tests: 6 +--------------------------------- + +leaf1 :: VerifyReloadCause :: SUCCESS +leaf1 :: VerifyUptime :: SUCCESS +leaf1 :: VerifyMlagStatus :: SUCCESS +leaf2 :: VerifyReloadCause :: SUCCESS +leaf2 :: VerifyUptime :: SUCCESS +leaf2 :: VerifyMlagStatus :: SKIPPED (MLAG is disabled) ``` -In this case, only `leaf` devices defined in your [inventory](#inventory-and-catalog-for-tests) are used to run tests marked with `leaf` in your [test catalog](#inventory-and-catalog-for-tests) - -## Use multiple tags in CLI +In this case, only `leaf` devices defined in the inventory are used to run tests marked with the `leaf` in the test catalog. -A more advanced usage of the tag feature is to list multiple tags in your CLI using `--tags $tag1,$tag2` syntax. +##### Multiple tags -In such scenario, all devices marked with `$tag1` will be selected and ANTA will run tests with `$tag1`, then devices with `$tag2` will be selected and will be tested with tests marked with `$tag2` +It is possible to use multiple tags using the `--tags tag1,tag2` syntax. ```bash -anta nrfu -c .personal/catalog-class.yml --tags leaf,fabric text - -spine01 :: VerifyUptime :: SUCCESS -spine02 :: VerifyUptime :: SUCCESS -leaf01 :: VerifyUptime :: SUCCESS -leaf01 :: VerifyReloadCause :: SUCCESS -leaf01 :: VerifyCPUUtilization :: SUCCESS -leaf02 :: VerifyUptime :: SUCCESS -leaf02 :: VerifyReloadCause :: SUCCESS -leaf02 :: VerifyCPUUtilization :: SUCCESS -leaf03 :: VerifyUptime :: SUCCESS -leaf03 :: VerifyReloadCause :: SUCCESS -leaf03 :: VerifyCPUUtilization :: SUCCESS -leaf04 :: VerifyUptime :: SUCCESS -leaf04 :: VerifyReloadCause :: SUCCESS -leaf04 :: VerifyCPUUtilization :: SUCCESS +anta nrfu --tags leaf,spine text +╭────────────────────── Settings ──────────────────────╮ +│ - ANTA Inventory contains 3 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 11 tests │ +╰──────────────────────────────────────────────────────╯ + +--- ANTA NRFU Run Information --- +Number of devices: 3 (3 established) +Total number of selected tests: 15 +--------------------------------- + +leaf1 :: VerifyReloadCause :: SUCCESS +leaf1 :: VerifyMlagStatus :: SUCCESS +leaf1 :: VerifyUptime :: SUCCESS +leaf1 :: VerifyL3MTU :: SUCCESS +leaf1 :: VerifyUptime :: SUCCESS +leaf2 :: VerifyReloadCause :: SUCCESS +leaf2 :: VerifyMlagStatus :: SKIPPED (MLAG is disabled) +leaf2 :: VerifyUptime :: SUCCESS +leaf2 :: VerifyL3MTU :: SUCCESS +leaf2 :: VerifyUptime :: SUCCESS +spine1 :: VerifyReloadCause :: SUCCESS +spine1 :: VerifyMlagStatus :: SUCCESS +spine1 :: VerifyUptime :: SUCCESS +spine1 :: VerifyL3MTU :: SUCCESS +spine1 :: VerifyUptime :: SUCCESS ``` diff --git a/docs/contribution.md b/docs/contribution.md index ac5d026f3..387e2f4c7 100644 --- a/docs/contribution.md +++ b/docs/contribution.md @@ -39,10 +39,10 @@ default environments: clean -> Erase previous coverage reports lint -> Check the code style type -> Check typing -py38 -> Run pytest with py38 py39 -> Run pytest with py39 py310 -> Run pytest with py310 py311 -> Run pytest with py311 +py312 -> Run pytest with py312 report -> Generate coverage report ``` @@ -51,21 +51,22 @@ report -> Generate coverage report ```bash tox -e lint [...] -lint: commands[0]> black --check --diff --color . -All done! ✨ 🍰 ✨ -104 files would be left unchanged. -lint: commands[1]> isort --check --diff --color . -Skipped 7 files -lint: commands[2]> flake8 --max-line-length=165 --config=/dev/null anta -lint: commands[3]> flake8 --max-line-length=165 --config=/dev/null tests -lint: commands[4]> pylint anta +lint: commands[0]> ruff check . +All checks passed! +lint: commands[1]> ruff format . --check +158 files already formatted +lint: commands[2]> pylint anta -------------------------------------------------------------------- Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00) -.pkg: _exit> python /Users/guillaumemulocher/.pyenv/versions/3.8.13/envs/anta/lib/python3.8/site-packages/pyproject_api/_backend.py True setuptools.build_meta - lint: OK (19.26=setup[5.83]+cmd[1.50,0.76,1.19,1.20,8.77] seconds) - congratulations :) (19.56 seconds) +lint: commands[3]> pylint tests + +-------------------------------------------------------------------- +Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00) + + lint: OK (22.69=setup[2.19]+cmd[0.02,0.02,9.71,10.75] seconds) + congratulations :) (22.72 seconds) ``` ### Code Typing @@ -75,10 +76,11 @@ tox -e type [...] type: commands[0]> mypy --config-file=pyproject.toml anta -Success: no issues found in 52 source files -.pkg: _exit> python /Users/guillaumemulocher/.pyenv/versions/3.8.13/envs/anta/lib/python3.8/site-packages/pyproject_api/_backend.py True setuptools.build_meta - type: OK (46.66=setup[24.20]+cmd[22.46] seconds) - congratulations :) (47.01 seconds) +Success: no issues found in 68 source files +type: commands[1]> mypy --config-file=pyproject.toml tests +Success: no issues found in 82 source files + type: OK (31.15=setup[14.62]+cmd[6.05,10.48] seconds) + congratulations :) (31.18 seconds) ``` > NOTE: Typing is configured quite strictly, do not hesitate to reach out if you have any questions, struggles, nightmares. @@ -92,7 +94,7 @@ All submodule should have its own pytest section under `tests/units/anta_tests/< ### How to write a unit test for an AntaTest subclass The Python modules in the `tests/units/anta_tests` folder define test parameters for AntaTest subclasses unit tests. -A generic test function is written for all unit tests in `tests.lib.anta` module. +A generic test function is written for all unit tests in `tests.units.anta_tests` module. The `pytest_generate_tests` function definition in `conftest.py` is called during test collection. @@ -116,7 +118,7 @@ Test example for `anta.tests.system.VerifyUptime` AntaTest. ``` python # Import the generic test function -from tests.lib.anta import test # noqa: F401 +from tests.units.anta_tests import test # Import your AntaTest from anta.tests.system import VerifyUptime @@ -157,19 +159,20 @@ pre-commit install When running a commit or a pre-commit check: ``` bash -❯ echo "import foobaz" > test.py && git add test.py ❯ pre-commit -pylint...................................................................Failed -- hook id: pylint -- exit code: 22 - -************* Module test -test.py:1:0: C0114: Missing module docstring (missing-module-docstring) -test.py:1:0: E0401: Unable to import 'foobaz' (import-error) -test.py:1:0: W0611: Unused import foobaz (unused-import) +trim trailing whitespace.................................................Passed +fix end of files.........................................................Passed +check for added large files..............................................Passed +check for merge conflicts................................................Passed +Check and insert license on Python files.................................Passed +Check and insert license on Markdown files...............................Passed +Run Ruff linter..........................................................Passed +Run Ruff formatter.......................................................Passed +Check code style with pylint.............................................Passed +Checks for common misspellings in text files.............................Passed +Check typing with mypy...................................................Passed ``` -> NOTE: It could happen that pre-commit and tox disagree on something, in that case please open an issue on Github so we can take a look.. It is most probably wrong configuration on our side. ## Configure MYPYPATH diff --git a/docs/scripts/generate_svg.py b/docs/scripts/generate_svg.py index e6bf87abe..f017b243d 100644 --- a/docs/scripts/generate_svg.py +++ b/docs/scripts/generate_svg.py @@ -24,6 +24,7 @@ from rich.console import Console from rich.logging import RichHandler +from rich.progress import Progress from anta.cli.console import console from anta.cli.nrfu.utils import anta_progress_bar @@ -37,7 +38,7 @@ OUTPUT_DIR = pathlib.Path(__file__).parent.parent / "imgs" -def custom_progress_bar() -> None: +def custom_progress_bar() -> Progress: """Set the console of progress_bar to main anta console. Caveat: this capture all steps of the progress bar.. diff --git a/pyproject.toml b/pyproject.toml index cfcbdb6d1..c07dd0e96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dev = [ "pytest-httpx>=0.30.0", "pytest-metadata>=3.0.0", "pytest>=7.4.0", + "respx>=0.21.1", "ruff>=0.5.4,<0.7.0", "tox>=4.10.0,<5.0.0", "types-PyYAML", @@ -168,8 +169,9 @@ addopts = "-ra -q -vv --cov --cov-report term:skip-covered --color yes" log_level = "WARNING" render_collapsed = true testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" filterwarnings = [ - "error", # cvprac is raising the next warning "default:pkg_resources is deprecated:DeprecationWarning", # Need to investigate the following - only occuring when running the full pytest suite @@ -337,7 +339,6 @@ select = ["ALL", # "D417", ] ignore = [ - "ANN101", # Missing type annotation for `self` in method - we know what self is.. "COM812", # Ignoring conflicting rules that may cause conflicts when used with the formatter "ISC001", # Ignoring conflicting rules that may cause conflicts when used with the formatter "TD002", # We don't have require authors in TODO @@ -382,9 +383,9 @@ runtime-evaluated-base-classes = ["pydantic.BaseModel", "anta.models.AntaTest.In "SLF001", # Lots of private member accessed for test purposes ] "tests/units/*" = [ - "BLE001", # Do not catch blind exception: `Exception` - already disabling this in pylint + "ARG002", # Sometimes we need to declare unused arguments when a parameter is not used but declared in @pytest.mark.parametrize "FBT001", # Boolean-typed positional argument in function definition - "PLR0913", # Too many arguments to function call (8 > 5) + "PLR0913", # Too many arguments to function call "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "S105", # Passwords are indeed hardcoded in tests "S106", # Passwords are indeed hardcoded in tests @@ -393,8 +394,10 @@ runtime-evaluated-base-classes = ["pydantic.BaseModel", "anta.models.AntaTest.In "tests/units/anta_tests/test_interfaces.py" = [ "S104", # False positive for 0.0.0.0 bindings in test inputs ] +"tests/units/anta_tests/*" = [ + "F401", # In this module, we import tests.units.anta_tests.test without using it to auto-generate tests +] "anta/*" = [ - "BLE001", # Do not catch blind exception: `Exception` - caught by other linter "TRY400", # Use `logging.exception` instead of `logging.error` - we know what we are doing ] "anta/cli/exec/utils.py" = [ @@ -438,34 +441,33 @@ runtime-evaluated-base-classes = ["pydantic.BaseModel", "anta.models.AntaTest.In ################################ # Pylint ################################ -[tool.pylint.'MESSAGES CONTROL'] -disable = [ +[tool.pylint] +disable = [ # Any rule listed here can be disabled: https://github.com/astral-sh/ruff/issues/970 "invalid-name", - "fixme" + "fixme", + "unused-import", + "unused-argument", + "keyword-arg-before-vararg", + "protected-access", + "too-many-arguments", + "wrong-import-position", + "pointless-statement", + "broad-exception-caught", + "line-too-long", + "unused-variable", + "redefined-builtin", + "abstract-class-instantiated", # Overlap with https://mypy.readthedocs.io/en/stable/error_code_list.html#check-instantiation-of-abstract-classes-abstract + "unexpected-keyword-arg", # Overlap with https://mypy.readthedocs.io/en/stable/error_code_list.html#check-arguments-in-calls-call-arg and other rules + "no-value-for-parameter" # Overlap with https://mypy.readthedocs.io/en/stable/error_code_list.html#check-arguments-in-calls-call-arg ] - -[tool.pylint.DESIGN] max-statements=61 max-returns=8 max-locals=23 - -[tool.pylint.FORMAT] max-line-length=165 max-module-lines=1700 - -[tool.pylint.SIMILARITIES] # making similarity lines limit a bit higher than default 4 min-similarity-lines=10 - -[tool.pylint.TYPECHECK] # https://stackoverflow.com/questions/49680191/click-and-pylint signature-mutators="click.decorators.option" - -[tool.pylint.MAIN] load-plugins="pylint_pydantic" extension-pkg-whitelist="pydantic" -ignore-paths = [ - "^tests/units/anta_tests/.*/data.py$", - "^tests/units/anta_tests/routing/.*/data.py$", - "^docs/scripts/anta_runner.py", -] diff --git a/tests/conftest.py b/tests/conftest.py index e31533840..7347d4430 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,53 +1,56 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""conftest.py - used to store anta specific fixtures used for tests.""" +"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files.""" -from __future__ import annotations - -import logging -from typing import Any +import asyncio +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch import pytest - -# Load fixtures from dedicated file tests/lib/fixture.py -# As well as pytest_asyncio plugin to test co-routines -pytest_plugins = [ - "tests.lib.fixture", - "pytest_asyncio", -] - -# Enable nice assert messages -# https://docs.pytest.org/en/7.1.x/how-to/writing_plugins.html#assertion-rewriting -pytest.register_assert_rewrite("tests.lib.anta") - -# Placeholder to disable logging of some external libs -for _ in ("asyncio", "httpx"): - logging.getLogger(_).setLevel(logging.CRITICAL) - - -def build_test_id(val: dict[str, Any]) -> str: - """Build id for a unit test of an AntaTest subclass. - - { - "name": "meaniful test name", - "test": , - ... - } - """ - return f"{val['test'].module}.{val['test'].__name__}-{val['name']}" - - -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Generate ANTA testts unit tests dynamically during test collection. - - It will parametrize test cases based on the `DATA` data structure defined in `tests.units.anta_tests` modules. - See `tests/units/anta_tests/README.md` for more information on how to use it. - Test IDs are generated using the `build_test_id` function above. - - Checking that only the function "test" is parametrized with data to allow for writing tests for helper functions - in each module. - """ - if "tests.units.anta_tests" in metafunc.module.__package__ and metafunc.function.__name__ == "test": - # This is a unit test for an AntaTest subclass - metafunc.parametrize("data", metafunc.module.DATA, ids=build_test_id) +import respx + +from anta.device import AsyncEOSDevice +from anta.inventory import AntaInventory + +DATA_DIR: Path = Path(__file__).parent.resolve() / "data" + + +@pytest.fixture(params=[{"count": 1}]) +def inventory(request: pytest.FixtureRequest) -> Iterator[AntaInventory]: + """Generate an ANTA inventory.""" + user = "admin" + password = "password" # noqa: S105 + disable_cache = request.param.get("disable_cache", True) + reachable = request.param.get("reachable", True) + if "filename" in request.param: + inv = AntaInventory.parse(DATA_DIR / request.param["filename"], username=user, password=password, disable_cache=disable_cache) + else: + inv = AntaInventory() + for i in range(request.param["count"]): + inv.add_device( + AsyncEOSDevice( + host=f"device-{i}.anta.arista.com", + username=user, + password=password, + name=f"device-{i}", + disable_cache=disable_cache, + ) + ) + if reachable: + # This context manager makes all devices reachable + with patch("asyncio.open_connection", AsyncMock(spec=asyncio.open_connection, return_value=(Mock(), Mock()))), respx.mock: + respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}, json__params__cmds__0__cmd="show version").respond( + json={ + "result": [ + { + "modelName": "pytest", + } + ], + } + ) + yield inv + else: + with patch("asyncio.open_connection", AsyncMock(spec=asyncio.open_connection, side_effect=TimeoutError)): + yield inv diff --git a/tests/data/json_data.py b/tests/data/json_data.py deleted file mode 100644 index 563084065..000000000 --- a/tests/data/json_data.py +++ /dev/null @@ -1,259 +0,0 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. -# Use of this source code is governed by the Apache License 2.0 -# that can be found in the LICENSE file. -# pylint: skip-file -"""JSON Data for unit tests.""" - -INVENTORY_MODEL_HOST_VALID = [ - {"name": "validIPv4", "input": "1.1.1.1", "expected_result": "valid"}, - { - "name": "validIPv6", - "input": "fe80::cc62:a9ff:feef:932a", - }, -] - -INVENTORY_MODEL_HOST_INVALID = [ - { - "name": "invalidIPv4_with_netmask", - "input": "1.1.1.1/32", - }, - { - "name": "invalidIPv6_with_netmask", - "input": "fe80::cc62:a9ff:feef:932a/128", - }, - {"name": "invalidHost_format", "input": "@", "expected_result": "invalid"}, - { - "name": "invalidIPv6_format", - "input": "fe80::cc62:a9ff:feef:", - }, -] - -INVENTORY_MODEL_HOST_CACHE = [ - {"name": "Host cache default", "input": {"host": "1.1.1.1"}, "expected_result": False}, - {"name": "Host cache enabled", "input": {"host": "1.1.1.1", "disable_cache": False}, "expected_result": False}, - {"name": "Host cache disabled", "input": {"host": "1.1.1.1", "disable_cache": True}, "expected_result": True}, -] - -INVENTORY_MODEL_NETWORK_VALID = [ - {"name": "ValidIPv4_Subnet", "input": "1.1.1.0/24", "expected_result": "valid"}, - {"name": "ValidIPv6_Subnet", "input": "2001:db8::/32", "expected_result": "valid"}, -] - -INVENTORY_MODEL_NETWORK_INVALID = [ - {"name": "ValidIPv4_Subnet", "input": "1.1.1.0/17", "expected_result": "invalid"}, - { - "name": "InvalidIPv6_Subnet", - "input": "2001:db8::/16", - "expected_result": "invalid", - }, -] - -INVENTORY_MODEL_NETWORK_CACHE = [ - {"name": "Network cache default", "input": {"network": "1.1.1.0/24"}, "expected_result": False}, - {"name": "Network cache enabled", "input": {"network": "1.1.1.0/24", "disable_cache": False}, "expected_result": False}, - {"name": "Network cache disabled", "input": {"network": "1.1.1.0/24", "disable_cache": True}, "expected_result": True}, -] - -INVENTORY_MODEL_RANGE_VALID = [ - { - "name": "ValidIPv4_Range", - "input": {"start": "10.1.0.1", "end": "10.1.0.10"}, - "expected_result": "valid", - }, -] - -INVENTORY_MODEL_RANGE_INVALID = [ - { - "name": "InvalidIPv4_Range_name", - "input": {"start": "toto", "end": "10.1.0.1"}, - "expected_result": "invalid", - }, -] - -INVENTORY_MODEL_RANGE_CACHE = [ - {"name": "Range cache default", "input": {"start": "1.1.1.1", "end": "1.1.1.10"}, "expected_result": False}, - {"name": "Range cache enabled", "input": {"start": "1.1.1.1", "end": "1.1.1.10", "disable_cache": False}, "expected_result": False}, - {"name": "Range cache disabled", "input": {"start": "1.1.1.1", "end": "1.1.1.10", "disable_cache": True}, "expected_result": True}, -] - -INVENTORY_MODEL_VALID = [ - { - "name": "Valid_Host_Only", - "input": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2"}]}, - "expected_result": "valid", - }, - { - "name": "Valid_Networks_Only", - "input": {"networks": [{"network": "192.168.0.0/16"}, {"network": "192.168.1.0/24"}]}, - "expected_result": "valid", - }, - { - "name": "Valid_Ranges_Only", - "input": { - "ranges": [ - {"start": "10.1.0.1", "end": "10.1.0.10"}, - {"start": "10.2.0.1", "end": "10.2.1.10"}, - ], - }, - "expected_result": "valid", - }, -] - -INVENTORY_MODEL_INVALID = [ - { - "name": "Host_with_Invalid_entry", - "input": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2/32"}]}, - "expected_result": "invalid", - }, -] - -INVENTORY_DEVICE_MODEL_VALID = [ - { - "name": "Valid_Inventory", - "input": [{"host": "1.1.1.1", "username": "arista", "password": "arista123!"}, {"host": "1.1.1.2", "username": "arista", "password": "arista123!"}], - "expected_result": "valid", - }, -] - -INVENTORY_DEVICE_MODEL_INVALID = [ - { - "name": "Invalid_Inventory", - "input": [{"host": "1.1.1.1", "password": "arista123!"}, {"host": "1.1.1.1", "username": "arista"}], - "expected_result": "invalid", - }, -] - -ANTA_INVENTORY_TESTS_VALID = [ - { - "name": "ValidInventory_with_host_only", - "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2"}, {"host": "my.awesome.host.com"}]}}, - "expected_result": "valid", - "parameters": { - "ipaddress_in_scope": "192.168.0.17", - "ipaddress_out_of_scope": "192.168.1.1", - "nb_hosts": 2, - }, - }, - { - "name": "ValidInventory_with_networks_only", - "input": {"anta_inventory": {"networks": [{"network": "192.168.0.0/24"}]}}, - "expected_result": "valid", - "parameters": { - "ipaddress_in_scope": "192.168.0.1", - "ipaddress_out_of_scope": "192.168.1.1", - "nb_hosts": 256, - }, - }, - { - "name": "ValidInventory_with_ranges_only", - "input": { - "anta_inventory": { - "ranges": [ - {"start": "10.0.0.1", "end": "10.0.0.11"}, - {"start": "10.0.0.101", "end": "10.0.0.111"}, - ], - }, - }, - "expected_result": "valid", - "parameters": { - "ipaddress_in_scope": "10.0.0.10", - "ipaddress_out_of_scope": "192.168.1.1", - "nb_hosts": 22, - }, - }, - { - "name": "ValidInventory_with_host_port", - "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17", "port": 443}, {"host": "192.168.0.2", "port": 80}]}}, - "expected_result": "valid", - "parameters": { - "ipaddress_in_scope": "192.168.0.17", - "ipaddress_out_of_scope": "192.168.1.1", - "nb_hosts": 2, - }, - }, - { - "name": "ValidInventory_with_host_tags", - "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17", "tags": ["leaf"]}, {"host": "192.168.0.2", "tags": ["spine"]}]}}, - "expected_result": "valid", - "parameters": { - "ipaddress_in_scope": "192.168.0.17", - "ipaddress_out_of_scope": "192.168.1.1", - "nb_hosts": 2, - }, - }, - { - "name": "ValidInventory_with_networks_tags", - "input": {"anta_inventory": {"networks": [{"network": "192.168.0.0/24", "tags": ["leaf"]}]}}, - "expected_result": "valid", - "parameters": { - "ipaddress_in_scope": "192.168.0.1", - "ipaddress_out_of_scope": "192.168.1.1", - "nb_hosts": 256, - }, - }, - { - "name": "ValidInventory_with_ranges_tags", - "input": { - "anta_inventory": { - "ranges": [ - {"start": "10.0.0.1", "end": "10.0.0.11", "tags": ["leaf"]}, - {"start": "10.0.0.101", "end": "10.0.0.111", "tags": ["spine"]}, - ], - }, - }, - "expected_result": "valid", - "parameters": { - "ipaddress_in_scope": "10.0.0.10", - "ipaddress_out_of_scope": "192.168.1.1", - "nb_hosts": 22, - }, - }, -] - -ANTA_INVENTORY_TESTS_INVALID = [ - { - "name": "InvalidInventory_with_host_only", - "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17/32"}, {"host": "192.168.0.2"}]}}, - "expected_result": "invalid", - }, - { - "name": "InvalidInventory_wrong_network_bits", - "input": {"anta_inventory": {"networks": [{"network": "192.168.42.0/8"}]}}, - "expected_result": "invalid", - }, - { - "name": "InvalidInventory_wrong_network", - "input": {"anta_inventory": {"networks": [{"network": "toto"}]}}, - "expected_result": "invalid", - }, - { - "name": "InvalidInventory_wrong_range", - "input": {"anta_inventory": {"ranges": [{"start": "toto", "end": "192.168.42.42"}]}}, - "expected_result": "invalid", - }, - { - "name": "InvalidInventory_wrong_range_type_mismatch", - "input": {"anta_inventory": {"ranges": [{"start": "fe80::cafe", "end": "192.168.42.42"}]}}, - "expected_result": "invalid", - }, - { - "name": "Invalid_Root_Key", - "input": { - "inventory": { - "ranges": [ - {"start": "10.0.0.1", "end": "10.0.0.11"}, - {"start": "10.0.0.100", "end": "10.0.0.111"}, - ], - }, - }, - "expected_result": "invalid", - }, -] - -TEST_RESULT_SET_STATUS = [ - {"name": "set_success", "target": "success", "message": "success"}, - {"name": "set_error", "target": "error", "message": "error"}, - {"name": "set_failure", "target": "failure", "message": "failure"}, - {"name": "set_skipped", "target": "skipped", "message": "skipped"}, - {"name": "set_unset", "target": "unset", "message": "unset"}, -] diff --git a/tests/data/test_catalog_with_tags.yml b/tests/data/test_catalog_with_tags.yml index 109781eaa..cf2bdffe7 100644 --- a/tests/data/test_catalog_with_tags.yml +++ b/tests/data/test_catalog_with_tags.yml @@ -3,30 +3,28 @@ anta.tests.system: - VerifyUptime: minimum: 10 filters: - tags: ['fabric'] + tags: ['spine'] - VerifyUptime: minimum: 9 filters: tags: ['leaf'] - VerifyReloadCause: filters: - tags: ['leaf', 'spine'] + tags: ['spine', 'leaf'] - VerifyCoredump: - VerifyAgentLogs: - VerifyCPUUtilization: - filters: - tags: ['leaf'] - VerifyMemoryUtilization: - filters: - tags: ['testdevice'] - VerifyFileSystemUtilization: - VerifyNTP: anta.tests.mlag: - VerifyMlagStatus: + filters: + tags: ['leaf'] anta.tests.interfaces: - VerifyL3MTU: mtu: 1500 filters: - tags: ['demo'] + tags: ['spine'] diff --git a/tests/data/test_inventory.yml b/tests/data/test_inventory.yml deleted file mode 100644 index d0ca45719..000000000 --- a/tests/data/test_inventory.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -anta_inventory: - hosts: - - name: dummy - host: dummy.anta.ninja - tags: ["leaf"] - - name: dummy2 - host: dummy2.anta.ninja - tags: ["leaf"] - - name: dummy3 - host: dummy3.anta.ninja - tags: ["spine"] diff --git a/tests/data/test_inventory_with_tags.yml b/tests/data/test_inventory_with_tags.yml new file mode 100644 index 000000000..cbbcd75e6 --- /dev/null +++ b/tests/data/test_inventory_with_tags.yml @@ -0,0 +1,12 @@ +--- +anta_inventory: + hosts: + - name: leaf1 + host: leaf1.anta.arista.com + tags: ["leaf"] + - name: leaf2 + host: leaf2.anta.arista.com + tags: ["leaf"] + - name: spine1 + host: spine1.anta.arista.com + tags: ["spine"] diff --git a/tests/data/toto.yml b/tests/data/toto.yml deleted file mode 100644 index c0f92cb81..000000000 --- a/tests/data/toto.yml +++ /dev/null @@ -1,16 +0,0 @@ -anta_inventory: - hosts: - - host: 10.73.1.238 - name: cv_atd1 - - host: 192.168.0.10 - name: spine1 - - host: 192.168.0.11 - name: spine2 - - host: 192.168.0.12 - name: leaf1 - - host: 192.168.0.13 - name: leaf2 - - host: 192.168.0.14 - name: leaf3 - - host: 192.168.0.15 - name: leaf4 diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py deleted file mode 100644 index cd54f3aac..000000000 --- a/tests/lib/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. -# Use of this source code is governed by the Apache License 2.0 -# that can be found in the LICENSE file. -"""Library for ANTA unit tests.""" diff --git a/tests/lib/anta.py b/tests/lib/anta.py deleted file mode 100644 index cabb27bc2..000000000 --- a/tests/lib/anta.py +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. -# Use of this source code is governed by the Apache License 2.0 -# that can be found in the LICENSE file. -"""generic test function used to generate unit tests for each AntaTest.""" - -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from anta.device import AntaDevice - - -def test(device: AntaDevice, data: dict[str, Any]) -> None: - """Generic test function for AntaTest subclass. - - See `tests/units/anta_tests/README.md` for more information on how to use it. - """ - # Instantiate the AntaTest subclass - test_instance = data["test"](device, inputs=data["inputs"], eos_data=data["eos_data"]) - # Run the test() method - asyncio.run(test_instance.test()) - # Assert expected result - assert test_instance.result.result == data["expected"]["result"], test_instance.result.messages - if "messages" in data["expected"]: - # We expect messages in test result - assert len(test_instance.result.messages) == len(data["expected"]["messages"]) - # Test will pass if the expected message is included in the test result message - for message, expected in zip(test_instance.result.messages, data["expected"]["messages"]): # NOTE: zip(strict=True) has been added in Python 3.10 - assert expected in message - else: - # Test result should not have messages - assert test_instance.result.messages == [] diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py deleted file mode 100644 index 92210acfa..000000000 --- a/tests/lib/fixture.py +++ /dev/null @@ -1,274 +0,0 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. -# Use of this source code is governed by the Apache License 2.0 -# that can be found in the LICENSE file. -"""Fixture for Anta Testing.""" - -from __future__ import annotations - -import json -import logging -import shutil -from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable -from unittest.mock import patch - -import pytest -from click.testing import CliRunner, Result - -import asynceapi -from anta.cli.console import console -from anta.device import AntaDevice, AsyncEOSDevice -from anta.inventory import AntaInventory -from anta.result_manager import ResultManager -from anta.result_manager.models import TestResult -from tests.lib.utils import default_anta_env - -if TYPE_CHECKING: - from collections.abc import Iterator - - from anta.models import AntaCommand - -logger = logging.getLogger(__name__) - -DATA_DIR: Path = Path(__file__).parent.parent.resolve() / "data" - -JSON_RESULTS = "test_md_report_results.json" - -DEVICE_HW_MODEL = "pytest" -DEVICE_NAME = "pytest" -COMMAND_OUTPUT = "retrieved" - -MOCK_CLI_JSON: dict[str, asynceapi.EapiCommandError | dict[str, Any]] = { - "show version": { - "modelName": "DCS-7280CR3-32P4-F", - "version": "4.31.1F", - }, - "enable": {}, - "clear counters": {}, - "clear hardware counter drop": {}, - "undefined": asynceapi.EapiCommandError( - passed=[], - failed="show version", - errors=["Authorization denied for command 'show version'"], - errmsg="Invalid command", - not_exec=[], - ), -} - -MOCK_CLI_TEXT: dict[str, asynceapi.EapiCommandError | str] = { - "show version": "Arista cEOSLab", - "bash timeout 10 ls -1t /mnt/flash/schedule/tech-support": "dummy_tech-support_2023-12-01.1115.log.gz\ndummy_tech-support_2023-12-01.1015.log.gz", - "bash timeout 10 ls -1t /mnt/flash/schedule/tech-support | head -1": "dummy_tech-support_2023-12-01.1115.log.gz", - "show running-config | include aaa authorization exec default": "aaa authorization exec default local", -} - - -@pytest.fixture -def device(request: pytest.FixtureRequest) -> Iterator[AntaDevice]: - """Return an AntaDevice instance with mocked abstract method.""" - - def _collect(command: AntaCommand, *args: Any, **kwargs: Any) -> None: # noqa: ARG001, ANN401 #pylint: disable=unused-argument - command.output = COMMAND_OUTPUT - - kwargs = {"name": DEVICE_NAME, "hw_model": DEVICE_HW_MODEL} - - if hasattr(request, "param"): - # Fixture is parametrized indirectly - kwargs.update(request.param) - with patch.object(AntaDevice, "__abstractmethods__", set()), patch("anta.device.AntaDevice._collect", side_effect=_collect): - # AntaDevice constructor does not have hw_model argument - hw_model = kwargs.pop("hw_model") - dev = AntaDevice(**kwargs) # type: ignore[abstract, arg-type] # pylint: disable=abstract-class-instantiated, unexpected-keyword-arg - dev.hw_model = hw_model - yield dev - - -@pytest.fixture -def test_inventory() -> AntaInventory: - """Return the test_inventory.""" - env = default_anta_env() - assert env["ANTA_INVENTORY"] - assert env["ANTA_USERNAME"] - assert env["ANTA_PASSWORD"] is not None - return AntaInventory.parse( - filename=env["ANTA_INVENTORY"], - username=env["ANTA_USERNAME"], - password=env["ANTA_PASSWORD"], - ) - - -# tests.unit.test_device.py fixture -@pytest.fixture -def async_device(request: pytest.FixtureRequest) -> AsyncEOSDevice: - """Return an AsyncEOSDevice instance.""" - kwargs = { - "name": DEVICE_NAME, - "host": "42.42.42.42", - "username": "anta", - "password": "anta", - } - - if hasattr(request, "param"): - # Fixture is parametrized indirectly - kwargs.update(request.param) - return AsyncEOSDevice(**kwargs) # type: ignore[arg-type] - - -# tests.units.result_manager fixtures -@pytest.fixture -def test_result_factory(device: AntaDevice) -> Callable[[int], TestResult]: - """Return a anta.result_manager.models.TestResult object.""" - # pylint: disable=redefined-outer-name - - def _create(index: int = 0) -> TestResult: - """Actual Factory.""" - return TestResult( - name=device.name, - test=f"VerifyTest{index}", - categories=["test"], - description=f"Verifies Test {index}", - custom_field=None, - ) - - return _create - - -@pytest.fixture -def list_result_factory(test_result_factory: Callable[[int], TestResult]) -> Callable[[int], list[TestResult]]: - """Return a list[TestResult] with 'size' TestResult instantiated using the test_result_factory fixture.""" - # pylint: disable=redefined-outer-name - - def _factory(size: int = 0) -> list[TestResult]: - """Create a factory for list[TestResult] entry of size entries.""" - return [test_result_factory(i) for i in range(size)] - - return _factory - - -@pytest.fixture -def result_manager_factory(list_result_factory: Callable[[int], list[TestResult]]) -> Callable[[int], ResultManager]: - """Return a ResultManager factory that takes as input a number of tests.""" - # pylint: disable=redefined-outer-name - - def _factory(number: int = 0) -> ResultManager: - """Create a factory for list[TestResult] entry of size entries.""" - result_manager = ResultManager() - result_manager.results = list_result_factory(number) - return result_manager - - return _factory - - -@pytest.fixture -def result_manager() -> ResultManager: - """Return a ResultManager with 30 random tests loaded from a JSON file. - - Devices: DC1-SPINE1, DC1-LEAF1A - - - Total tests: 30 - - Success: 7 - - Skipped: 2 - - Failure: 19 - - Error: 2 - - See `tests/data/test_md_report_results.json` and `tests/data/test_md_report_all_tests.md` for details. - """ - manager = ResultManager() - - with (DATA_DIR / JSON_RESULTS).open("r", encoding="utf-8") as f: - results = json.load(f) - - for result in results: - manager.add(TestResult(**result)) - - return manager - - -# tests.units.cli fixtures -@pytest.fixture -def temp_env(tmp_path: Path) -> dict[str, str | None]: - """Fixture that create a temporary ANTA inventory. - - The inventory can be overridden and returns the corresponding environment variables. - """ - env = default_anta_env() - anta_inventory = str(env["ANTA_INVENTORY"]) - temp_inventory = tmp_path / "test_inventory.yml" - shutil.copy(anta_inventory, temp_inventory) - env["ANTA_INVENTORY"] = str(temp_inventory) - return env - - -@pytest.fixture -# Disabling C901 - too complex as we like our runner like this -def click_runner(capsys: pytest.CaptureFixture[str]) -> Iterator[CliRunner]: # noqa: C901 - """Return a click.CliRunner for cli testing.""" - - class AntaCliRunner(CliRunner): - """Override CliRunner to inject specific variables for ANTA.""" - - def invoke( - self, - *args: Any, # noqa: ANN401 - **kwargs: Any, # noqa: ANN401 - ) -> Result: - # Inject default env if not provided - kwargs["env"] = kwargs["env"] if "env" in kwargs else default_anta_env() - # Deterministic terminal width - kwargs["env"]["COLUMNS"] = "165" - - kwargs["auto_envvar_prefix"] = "ANTA" - # Way to fix https://github.com/pallets/click/issues/824 - with capsys.disabled(): - result = super().invoke(*args, **kwargs) - # disabling T201 as we want to print here - print("--- CLI Output ---") # noqa: T201 - print(result.output) # noqa: T201 - return result - - def cli( - command: str | None = None, - commands: list[dict[str, Any]] | None = None, - ofmt: str = "json", - _version: int | str | None = "latest", - **_kwargs: Any, # noqa: ANN401 - ) -> dict[str, Any] | list[dict[str, Any]]: - def get_output(command: str | dict[str, Any]) -> dict[str, Any]: - if isinstance(command, dict): - command = command["cmd"] - mock_cli: dict[str, Any] - if ofmt == "json": - mock_cli = MOCK_CLI_JSON - elif ofmt == "text": - mock_cli = MOCK_CLI_TEXT - for mock_cmd, output in mock_cli.items(): - if command == mock_cmd: - logger.info("Mocking command %s", mock_cmd) - if isinstance(output, asynceapi.EapiCommandError): - raise output - return output - message = f"Command '{command}' is not mocked" - logger.critical(message) - raise NotImplementedError(message) - - res: dict[str, Any] | list[dict[str, Any]] - if command is not None: - logger.debug("Mock input %s", command) - res = get_output(command) - if commands is not None: - logger.debug("Mock input %s", commands) - res = list(map(get_output, commands)) - logger.debug("Mock output %s", res) - return res - - # Patch asynceapi methods used by AsyncEOSDevice. See tests/units/test_device.py - with ( - patch("asynceapi.device.Device.check_connection", return_value=True), - patch("asynceapi.device.Device.cli", side_effect=cli), - patch("asyncssh.connect"), - patch( - "asyncssh.scp", - ), - ): - console._color_system = None # pylint: disable=protected-access - yield AntaCliRunner() diff --git a/tests/lib/utils.py b/tests/lib/utils.py deleted file mode 100644 index ba669c287..000000000 --- a/tests/lib/utils.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. -# Use of this source code is governed by the Apache License 2.0 -# that can be found in the LICENSE file. -"""tests.lib.utils.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - - -def generate_test_ids_dict(val: dict[str, Any], key: str = "name") -> str: - """generate_test_ids Helper to generate test ID for parametrize.""" - return val.get(key, "unamed_test") - - -def generate_test_ids_list(val: list[dict[str, Any]], key: str = "name") -> list[str]: - """generate_test_ids Helper to generate test ID for parametrize.""" - return [entry.get(key, "unamed_test") for entry in val] - - -def generate_test_ids(data: list[dict[str, Any]]) -> list[str]: - """Build id for a unit test of an AntaTest subclass. - - { - "name": "meaniful test name", - "test": , - ... - } - """ - return [f"{val['test'].module}.{val['test'].__name__}-{val['name']}" for val in data] - - -def default_anta_env() -> dict[str, str | None]: - """Return a default_anta_environement which can be passed to a cliRunner.invoke method.""" - return { - "ANTA_USERNAME": "anta", - "ANTA_PASSWORD": "formica", - "ANTA_INVENTORY": str(Path(__file__).parent.parent / "data" / "test_inventory.yml"), - "ANTA_CATALOG": str(Path(__file__).parent.parent / "data" / "test_catalog.yml"), - } diff --git a/tests/mock_data/show_ntp_status_text_synchronised.out b/tests/mock_data/show_ntp_status_text_synchronised.out deleted file mode 100644 index 081a8a834..000000000 --- a/tests/mock_data/show_ntp_status_text_synchronised.out +++ /dev/null @@ -1 +0,0 @@ -[{'output': 'synchronised to NTP server (51.254.83.231) at stratum 3\n time correct to within 82 ms\n polling server every 1024 s\n\n'}] diff --git a/tests/mock_data/show_uptime_json_1000000.out b/tests/mock_data/show_uptime_json_1000000.out deleted file mode 100644 index 754025a53..000000000 --- a/tests/mock_data/show_uptime_json_1000000.out +++ /dev/null @@ -1 +0,0 @@ -[{'upTime': 1000000.68, 'loadAvg': [0.17, 0.21, 0.18], 'users': 1, 'currentTime': 1643761588.030645}] diff --git a/tests/mock_data/show_version_json_4.27.1.1F.out b/tests/mock_data/show_version_json_4.27.1.1F.out deleted file mode 100644 index fc720d41b..000000000 --- a/tests/mock_data/show_version_json_4.27.1.1F.out +++ /dev/null @@ -1 +0,0 @@ -[{'imageFormatVersion': '2.0', 'uptime': 2697.76, 'modelName': 'DCS-7280TRA-48C6-F', 'internalVersion': '4.27.1.1F-25536724.42711F', 'memTotal': 8098984, 'mfgName': 'Arista', 'serialNumber': 'SSJ16376415', 'systemMacAddress': '44:4c:a8:c7:1f:6b', 'bootupTimestamp': 1643715179.0, 'memFree': 6131068, 'version': '4.27.1.1F', 'configMacAddress': '00:00:00:00:00:00', 'isIntlVersion': False, 'internalBuildId': '38c43eab-c660-477a-915b-5a7b28da781d', 'hardwareRevision': '21.02', 'hwMacAddress': '44:4c:a8:c7:1f:6b', 'architecture': 'i686'}] diff --git a/tests/units/__init__.py b/tests/units/__init__.py index 6f96a0d1c..6b2d4ace6 100644 --- a/tests/units/__init__.py +++ b/tests/units/__init__.py @@ -1,4 +1,10 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Unit tests for anta.""" +"""Unit tests for ANTA.""" + +import pytest + +# Enable nice assert messages for tests.units.anta_tests unit tests +# https://docs.pytest.org/en/stable/how-to/writing_plugins.html#assertion-rewriting +pytest.register_assert_rewrite("tests.units.anta_tests") diff --git a/tests/units/anta_tests/__init__.py b/tests/units/anta_tests/__init__.py index 8ca0e8c7c..bfebc6d22 100644 --- a/tests/units/anta_tests/__init__.py +++ b/tests/units/anta_tests/__init__.py @@ -1,4 +1,33 @@ # Copyright (c) 2023-2024 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 for anta.tests submodule.""" +"""Tests for anta.tests module.""" + +import asyncio +from typing import Any + +from anta.device import AntaDevice + + +def test(device: AntaDevice, data: dict[str, Any]) -> None: + """Generic test function for AntaTest subclass. + + Generate unit tests for each AntaTest subclass. + + See `tests/units/anta_tests/README.md` for more information on how to use it. + """ + # Instantiate the AntaTest subclass + test_instance = data["test"](device, inputs=data["inputs"], eos_data=data["eos_data"]) + # Run the test() method + asyncio.run(test_instance.test()) + # Assert expected result + assert test_instance.result.result == data["expected"]["result"], f"Expected '{data['expected']['result']}' result, got '{test_instance.result.result}'" + if "messages" in data["expected"]: + # We expect messages in test result + assert len(test_instance.result.messages) == len(data["expected"]["messages"]) + # Test will pass if the expected message is included in the test result message + for message, expected in zip(test_instance.result.messages, data["expected"]["messages"]): # NOTE: zip(strict=True) has been added in Python 3.10 + assert expected in message + else: + # Test result should not have messages + assert test_instance.result.messages == [] diff --git a/tests/units/anta_tests/conftest.py b/tests/units/anta_tests/conftest.py new file mode 100644 index 000000000..5da7606cc --- /dev/null +++ b/tests/units/anta_tests/conftest.py @@ -0,0 +1,35 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files.""" + +from typing import Any + +import pytest + + +def build_test_id(val: dict[str, Any]) -> str: + """Build id for a unit test of an AntaTest subclass. + + { + "name": "meaniful test name", + "test": , + ... + } + """ + return f"{val['test'].__module__}.{val['test'].__name__}-{val['name']}" + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Generate ANTA testts unit tests dynamically during test collection. + + It will parametrize test cases based on the `DATA` data structure defined in `tests.units.anta_tests` modules. + See `tests/units/anta_tests/README.md` for more information on how to use it. + Test IDs are generated using the `build_test_id` function above. + + Checking that only the function "test" is parametrized with data to allow for writing tests for helper functions + in each module. + """ + if "tests.units.anta_tests" in metafunc.module.__package__ and metafunc.function.__name__ == "test": + # This is a unit test for an AntaTest subclass + metafunc.parametrize("data", metafunc.module.DATA, ids=build_test_id) diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index ae306cdff..e256b04dd 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -8,8 +8,6 @@ from typing import Any -# pylint: disable=C0413 -# because of the patch above from anta.tests.routing.bgp import ( VerifyBGPAdvCommunities, VerifyBGPExchangedRoutes, @@ -27,7 +25,7 @@ VerifyBGPTimers, VerifyEVPNType2Route, ) -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { @@ -4313,31 +4311,6 @@ ], }, }, - { - "name": "failure-not-found", - "test": VerifyBGPPeerUpdateErrors, - "eos_data": [ - { - "vrfs": {}, - }, - { - "vrfs": {}, - }, - ], - "inputs": { - "bgp_peers": [ - {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, - {"peer_address": "10.100.0.9", "vrf": "MGMT", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, - ] - }, - "expected": { - "result": "failure", - "messages": [ - "The following BGP peers are not configured or have non-zero update error counters:\n" - "{'10.100.0.8': {'default': 'Not configured'}, '10.100.0.9': {'MGMT': 'Not configured'}}" - ], - }, - }, { "name": "success-all-error-counters", "test": VerifyBGPPeerUpdateErrors, diff --git a/tests/units/anta_tests/routing/test_generic.py b/tests/units/anta_tests/routing/test_generic.py index 621cf22ad..0ac43f3c5 100644 --- a/tests/units/anta_tests/routing/test_generic.py +++ b/tests/units/anta_tests/routing/test_generic.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.routing.generic import VerifyRoutingProtocolModel, VerifyRoutingTableEntry, VerifyRoutingTableSize -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/routing/test_isis.py b/tests/units/anta_tests/routing/test_isis.py index 2167ea434..84f5bdcf7 100644 --- a/tests/units/anta_tests/routing/test_isis.py +++ b/tests/units/anta_tests/routing/test_isis.py @@ -20,7 +20,7 @@ VerifyISISSegmentRoutingTunnels, _get_interface_data, ) -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/routing/test_ospf.py b/tests/units/anta_tests/routing/test_ospf.py index 81d8010ae..1555af6e6 100644 --- a/tests/units/anta_tests/routing/test_ospf.py +++ b/tests/units/anta_tests/routing/test_ospf.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.routing.ospf import VerifyOSPFMaxLSA, VerifyOSPFNeighborCount, VerifyOSPFNeighborState -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_aaa.py b/tests/units/anta_tests/test_aaa.py index 40bf82e09..119e20696 100644 --- a/tests/units/anta_tests/test_aaa.py +++ b/tests/units/anta_tests/test_aaa.py @@ -16,7 +16,7 @@ VerifyTacacsServers, VerifyTacacsSourceIntf, ) -from tests.lib.anta import test # noqa: F401; pylint: disable=unused-import +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_avt.py b/tests/units/anta_tests/test_avt.py index 7ef6be323..80fbce036 100644 --- a/tests/units/anta_tests/test_avt.py +++ b/tests/units/anta_tests/test_avt.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.avt import VerifyAVTPathHealth, VerifyAVTRole, VerifyAVTSpecificPath -from tests.lib.anta import test # noqa: F401; pylint: disable=unused-import +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py index 3b1b8b86a..9bd64656c 100644 --- a/tests/units/anta_tests/test_bfd.py +++ b/tests/units/anta_tests/test_bfd.py @@ -8,10 +8,8 @@ from typing import Any -# pylint: disable=C0413 -# because of the patch above from anta.tests.bfd import VerifyBFDPeersHealth, VerifyBFDPeersIntervals, VerifyBFDPeersRegProtocols, VerifyBFDSpecificPeers -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_configuration.py b/tests/units/anta_tests/test_configuration.py index 7f198a33c..dbe22d365 100644 --- a/tests/units/anta_tests/test_configuration.py +++ b/tests/units/anta_tests/test_configuration.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.configuration import VerifyRunningConfigDiffs, VerifyRunningConfigLines, VerifyZeroTouch -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_connectivity.py b/tests/units/anta_tests/test_connectivity.py index 4cc57676c..beeaae65c 100644 --- a/tests/units/anta_tests/test_connectivity.py +++ b/tests/units/anta_tests/test_connectivity.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.connectivity import VerifyLLDPNeighbors, VerifyReachability -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_field_notices.py b/tests/units/anta_tests/test_field_notices.py index 3cb7286fd..a30604b8b 100644 --- a/tests/units/anta_tests/test_field_notices.py +++ b/tests/units/anta_tests/test_field_notices.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.field_notices import VerifyFieldNotice44Resolution, VerifyFieldNotice72Resolution -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_flow_tracking.py b/tests/units/anta_tests/test_flow_tracking.py index 21b47222a..f50a76b5d 100644 --- a/tests/units/anta_tests/test_flow_tracking.py +++ b/tests/units/anta_tests/test_flow_tracking.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.flow_tracking import VerifyHardwareFlowTrackerStatus -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_greent.py b/tests/units/anta_tests/test_greent.py index 2c483012d..16f36165e 100644 --- a/tests/units/anta_tests/test_greent.py +++ b/tests/units/anta_tests/test_greent.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.greent import VerifyGreenT, VerifyGreenTCounters -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_hardware.py b/tests/units/anta_tests/test_hardware.py index e601c681a..646ca5829 100644 --- a/tests/units/anta_tests/test_hardware.py +++ b/tests/units/anta_tests/test_hardware.py @@ -16,7 +16,7 @@ VerifyTransceiversManufacturers, VerifyTransceiversTemperature, ) -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_interfaces.py b/tests/units/anta_tests/test_interfaces.py index c38ac89f2..73ef6c6aa 100644 --- a/tests/units/anta_tests/test_interfaces.py +++ b/tests/units/anta_tests/test_interfaces.py @@ -27,7 +27,7 @@ VerifyStormControlDrops, VerifySVI, ) -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_lanz.py b/tests/units/anta_tests/test_lanz.py index bfbf6ae48..03694d4e4 100644 --- a/tests/units/anta_tests/test_lanz.py +++ b/tests/units/anta_tests/test_lanz.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.lanz import VerifyLANZ -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_logging.py b/tests/units/anta_tests/test_logging.py index d46c86581..cfc034c87 100644 --- a/tests/units/anta_tests/test_logging.py +++ b/tests/units/anta_tests/test_logging.py @@ -17,7 +17,7 @@ VerifyLoggingSourceIntf, VerifyLoggingTimestamp, ) -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_mlag.py b/tests/units/anta_tests/test_mlag.py index ae8ff7cf6..1ef547259 100644 --- a/tests/units/anta_tests/test_mlag.py +++ b/tests/units/anta_tests/test_mlag.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.mlag import VerifyMlagConfigSanity, VerifyMlagDualPrimary, VerifyMlagInterfaces, VerifyMlagPrimaryPriority, VerifyMlagReloadDelay, VerifyMlagStatus -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_multicast.py b/tests/units/anta_tests/test_multicast.py index a52a1d2ae..1fdcadd23 100644 --- a/tests/units/anta_tests/test_multicast.py +++ b/tests/units/anta_tests/test_multicast.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.multicast import VerifyIGMPSnoopingGlobal, VerifyIGMPSnoopingVlans -from tests.lib.anta import test # noqa: F401; pylint: disable=unused-import +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_path_selection.py b/tests/units/anta_tests/test_path_selection.py index c5fb07933..d1882d04b 100644 --- a/tests/units/anta_tests/test_path_selection.py +++ b/tests/units/anta_tests/test_path_selection.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.path_selection import VerifyPathsHealth, VerifySpecificPath -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_profiles.py b/tests/units/anta_tests/test_profiles.py index d58e987c2..f822d09d3 100644 --- a/tests/units/anta_tests/test_profiles.py +++ b/tests/units/anta_tests/test_profiles.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.profiles import VerifyTcamProfile, VerifyUnifiedForwardingTableMode -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_ptp.py b/tests/units/anta_tests/test_ptp.py index 8f4c77ff9..fc94480da 100644 --- a/tests/units/anta_tests/test_ptp.py +++ b/tests/units/anta_tests/test_ptp.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.ptp import VerifyPtpGMStatus, VerifyPtpLockStatus, VerifyPtpModeStatus, VerifyPtpOffset, VerifyPtpPortModeStatus -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { @@ -295,14 +295,14 @@ "expected": {"result": "success"}, }, { - "name": "failure", + "name": "failure-no-interfaces", "test": VerifyPtpPortModeStatus, "eos_data": [{"ptpIntfSummaries": {}}], "inputs": None, "expected": {"result": "failure", "messages": ["No interfaces are PTP enabled"]}, }, { - "name": "failure", + "name": "failure-invalid-state", "test": VerifyPtpPortModeStatus, "eos_data": [ { diff --git a/tests/units/anta_tests/test_security.py b/tests/units/anta_tests/test_security.py index eabc40bd8..792b06595 100644 --- a/tests/units/anta_tests/test_security.py +++ b/tests/units/anta_tests/test_security.py @@ -24,7 +24,7 @@ VerifySSHStatus, VerifyTelnetStatus, ) -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_services.py b/tests/units/anta_tests/test_services.py index 61c44d0d6..3f13dfc0b 100644 --- a/tests/units/anta_tests/test_services.py +++ b/tests/units/anta_tests/test_services.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.services import VerifyDNSLookup, VerifyDNSServers, VerifyErrdisableRecovery, VerifyHostname -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py index 64c44382e..f6d964f83 100644 --- a/tests/units/anta_tests/test_snmp.py +++ b/tests/units/anta_tests/test_snmp.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.snmp import VerifySnmpContact, VerifySnmpIPv4Acl, VerifySnmpIPv6Acl, VerifySnmpLocation, VerifySnmpStatus -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_software.py b/tests/units/anta_tests/test_software.py index e46f52659..d2172bb6f 100644 --- a/tests/units/anta_tests/test_software.py +++ b/tests/units/anta_tests/test_software.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.software import VerifyEOSExtensions, VerifyEOSVersion, VerifyTerminAttrVersion -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_stp.py b/tests/units/anta_tests/test_stp.py index 64a116804..a6855aa88 100644 --- a/tests/units/anta_tests/test_stp.py +++ b/tests/units/anta_tests/test_stp.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.stp import VerifySTPBlockedPorts, VerifySTPCounters, VerifySTPForwardingPorts, VerifySTPMode, VerifySTPRootPriority -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_stun.py b/tests/units/anta_tests/test_stun.py index 0c5fdc143..005ae35f8 100644 --- a/tests/units/anta_tests/test_stun.py +++ b/tests/units/anta_tests/test_stun.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.stun import VerifyStunClient, VerifyStunServer -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_system.py b/tests/units/anta_tests/test_system.py index 54849b734..22b9787b2 100644 --- a/tests/units/anta_tests/test_system.py +++ b/tests/units/anta_tests/test_system.py @@ -18,7 +18,7 @@ VerifyReloadCause, VerifyUptime, ) -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_vlan.py b/tests/units/anta_tests/test_vlan.py index 53bf92f94..6bbfac496 100644 --- a/tests/units/anta_tests/test_vlan.py +++ b/tests/units/anta_tests/test_vlan.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.vlan import VerifyVlanInternalPolicy -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { diff --git a/tests/units/anta_tests/test_vxlan.py b/tests/units/anta_tests/test_vxlan.py index f450897a6..4278a5945 100644 --- a/tests/units/anta_tests/test_vxlan.py +++ b/tests/units/anta_tests/test_vxlan.py @@ -8,7 +8,7 @@ from typing import Any from anta.tests.vxlan import VerifyVxlan1ConnSettings, VerifyVxlan1Interface, VerifyVxlanConfigSanity, VerifyVxlanVniBinding, VerifyVxlanVtep -from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 +from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { @@ -26,21 +26,21 @@ "expected": {"result": "skipped", "messages": ["Vxlan1 interface is not configured"]}, }, { - "name": "failure", + "name": "failure-down-up", "test": VerifyVxlan1Interface, "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "down", "interfaceStatus": "up"}}}], "inputs": None, "expected": {"result": "failure", "messages": ["Vxlan1 interface is down/up"]}, }, { - "name": "failure", + "name": "failure-up-down", "test": VerifyVxlan1Interface, "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "up", "interfaceStatus": "down"}}}], "inputs": None, "expected": {"result": "failure", "messages": ["Vxlan1 interface is up/down"]}, }, { - "name": "failure", + "name": "failure-down-down", "test": VerifyVxlan1Interface, "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "down", "interfaceStatus": "down"}}}], "inputs": None, diff --git a/tests/units/asynceapi/test_device.py b/tests/units/asynceapi/test_device.py index 8a140ee3b..2c6375a85 100644 --- a/tests/units/asynceapi/test_device.py +++ b/tests/units/asynceapi/test_device.py @@ -18,7 +18,6 @@ from pytest_httpx import HTTPXMock -@pytest.mark.asyncio @pytest.mark.parametrize( "cmds", [ @@ -44,7 +43,6 @@ async def test_jsonrpc_exec_success( assert result == SUCCESS_EAPI_RESPONSE["result"] -@pytest.mark.asyncio @pytest.mark.parametrize( "cmds", [ @@ -76,7 +74,6 @@ async def test_jsonrpc_exec_eapi_command_error( assert exc_info.value.not_exec == [jsonrpc_request["params"]["cmds"][2]] -@pytest.mark.asyncio async def test_jsonrpc_exec_http_status_error(asynceapi_device: Device, httpx_mock: HTTPXMock) -> None: """Test the Device.jsonrpc_exec method with an HTTPStatusError.""" jsonrpc_request: dict[str, Any] = JSONRPC_REQUEST_TEMPLATE.copy() diff --git a/tests/units/cli/conftest.py b/tests/units/cli/conftest.py new file mode 100644 index 000000000..e63e60eb2 --- /dev/null +++ b/tests/units/cli/conftest.py @@ -0,0 +1,133 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files.""" + +from __future__ import annotations + +import logging +import shutil +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +import pytest +from click.testing import CliRunner, Result + +import asynceapi +from anta.cli.console import console + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + +logger = logging.getLogger(__name__) + + +MOCK_CLI_JSON: dict[str, asynceapi.EapiCommandError | dict[str, Any]] = { + "show version": { + "modelName": "DCS-7280CR3-32P4-F", + "version": "4.31.1F", + }, + "enable": {}, + "clear counters": {}, + "clear hardware counter drop": {}, + "undefined": asynceapi.EapiCommandError( + passed=[], + failed="show version", + errors=["Authorization denied for command 'show version'"], + errmsg="Invalid command", + not_exec=[], + ), +} + +MOCK_CLI_TEXT: dict[str, asynceapi.EapiCommandError | str] = { + "show version": "Arista cEOSLab", + "bash timeout 10 ls -1t /mnt/flash/schedule/tech-support": "dummy_tech-support_2023-12-01.1115.log.gz\ndummy_tech-support_2023-12-01.1015.log.gz", + "bash timeout 10 ls -1t /mnt/flash/schedule/tech-support | head -1": "dummy_tech-support_2023-12-01.1115.log.gz", + "show running-config | include aaa authorization exec default": "aaa authorization exec default local", +} + + +@pytest.fixture +def temp_env(anta_env: dict[str, str], tmp_path: Path) -> dict[str, str]: + """Fixture that create a temporary ANTA inventory. + + The inventory can be overridden and returns the corresponding environment variables. + """ + anta_inventory = str(anta_env["ANTA_INVENTORY"]) + temp_inventory = tmp_path / "test_inventory.yml" + shutil.copy(anta_inventory, temp_inventory) + anta_env["ANTA_INVENTORY"] = str(temp_inventory) + return anta_env + + +@pytest.fixture +# Disabling C901 - too complex as we like our runner like this +def click_runner(capsys: pytest.CaptureFixture[str], anta_env: dict[str, str]) -> Iterator[CliRunner]: # noqa: C901 + """Return a click.CliRunner for cli testing.""" + + class AntaCliRunner(CliRunner): + """Override CliRunner to inject specific variables for ANTA.""" + + def invoke(self, *args: Any, **kwargs: Any) -> Result: # noqa: ANN401 + # Inject default env vars if not provided + kwargs["env"] = anta_env | kwargs.get("env", {}) + # Deterministic terminal width + kwargs["env"]["COLUMNS"] = "165" + + kwargs["auto_envvar_prefix"] = "ANTA" + # Way to fix https://github.com/pallets/click/issues/824 + with capsys.disabled(): + result = super().invoke(*args, **kwargs) + # disabling T201 as we want to print here + print("--- CLI Output ---") # noqa: T201 + print(result.output) # noqa: T201 + return result + + def cli( + command: str | None = None, + commands: list[dict[str, Any]] | None = None, + ofmt: str = "json", + _version: int | str | None = "latest", + **_kwargs: Any, # noqa: ANN401 + ) -> dict[str, Any] | list[dict[str, Any]]: + def get_output(command: str | dict[str, Any]) -> dict[str, Any]: + if isinstance(command, dict): + command = command["cmd"] + mock_cli: dict[str, Any] + if ofmt == "json": + mock_cli = MOCK_CLI_JSON + elif ofmt == "text": + mock_cli = MOCK_CLI_TEXT + for mock_cmd, output in mock_cli.items(): + if command == mock_cmd: + logger.info("Mocking command %s", mock_cmd) + if isinstance(output, asynceapi.EapiCommandError): + raise output + return output + message = f"Command '{command}' is not mocked" + logger.critical(message) + raise NotImplementedError(message) + + res: dict[str, Any] | list[dict[str, Any]] + if command is not None: + logger.debug("Mock input %s", command) + res = get_output(command) + if commands is not None: + logger.debug("Mock input %s", commands) + res = list(map(get_output, commands)) + logger.debug("Mock output %s", res) + return res + + # Patch asynceapi methods used by AsyncEOSDevice. See tests/units/test_device.py + with ( + patch("asynceapi.device.Device.check_connection", return_value=True), + patch("asynceapi.device.Device.cli", side_effect=cli), + patch("asyncssh.connect"), + patch( + "asyncssh.scp", + ), + ): + console._color_system = None + yield AntaCliRunner() diff --git a/tests/units/cli/debug/test_commands.py b/tests/units/cli/debug/test_commands.py index 039e09eb0..c802b0de8 100644 --- a/tests/units/cli/debug/test_commands.py +++ b/tests/units/cli/debug/test_commands.py @@ -19,12 +19,12 @@ @pytest.mark.parametrize( ("command", "ofmt", "version", "revision", "device", "failed"), [ - pytest.param("show version", "json", None, None, "dummy", False, id="json command"), - pytest.param("show version", "text", None, None, "dummy", False, id="text command"), - pytest.param("show version", None, "latest", None, "dummy", False, id="version-latest"), - pytest.param("show version", None, "1", None, "dummy", False, id="version"), - pytest.param("show version", None, None, 3, "dummy", False, id="revision"), - pytest.param("undefined", None, None, None, "dummy", True, id="command fails"), + pytest.param("show version", "json", None, None, "leaf1", False, id="json command"), + pytest.param("show version", "text", None, None, "leaf1", False, id="text command"), + pytest.param("show version", None, "latest", None, "leaf1", False, id="version-latest"), + pytest.param("show version", None, "1", None, "leaf1", False, id="version"), + pytest.param("show version", None, None, 3, "leaf1", False, id="revision"), + pytest.param("undefined", None, None, None, "leaf1", True, id="command fails"), pytest.param("undefined", None, None, None, "doesnotexist", True, id="Device does not exist"), ], ) @@ -38,7 +38,6 @@ def test_run_cmd( failed: bool, ) -> None: """Test `anta debug run-cmd`.""" - # pylint: disable=too-many-arguments cli_args = ["-l", "debug", "debug", "run-cmd", "--command", command, "--device", device] # ofmt diff --git a/tests/units/cli/exec/test_utils.py b/tests/units/cli/exec/test_utils.py index f4c0cc5fd..503327ab7 100644 --- a/tests/units/cli/exec/test_utils.py +++ b/tests/units/cli/exec/test_utils.py @@ -5,17 +5,19 @@ from __future__ import annotations +import logging +from pathlib import Path from typing import TYPE_CHECKING, Any from unittest.mock import call, patch import pytest +import respx -from anta.cli.exec.utils import ( - clear_counters, -) +from anta.cli.exec.utils import clear_counters, collect_commands from anta.models import AntaCommand +from anta.tools import safe_command -# , collect_commands, collect_scheduled_show_tech +# collect_scheduled_show_tech if TYPE_CHECKING: from anta.device import AntaDevice @@ -23,55 +25,59 @@ # TODO: complete test cases -@pytest.mark.asyncio @pytest.mark.parametrize( - ("inventory_state", "per_device_command_output", "tags"), + ("inventory", "inventory_state", "per_device_command_output", "tags"), [ pytest.param( + {"count": 3}, { - "dummy": {"is_online": False}, - "dummy2": {"is_online": False}, - "dummy3": {"is_online": False}, + "device-0": {"is_online": False}, + "device-1": {"is_online": False}, + "device-2": {"is_online": False}, }, {}, None, id="no_connected_device", ), pytest.param( + {"count": 3}, { - "dummy": {"is_online": True, "hw_model": "cEOSLab"}, - "dummy2": {"is_online": True, "hw_model": "vEOS-lab"}, - "dummy3": {"is_online": False}, + "device-0": {"is_online": True, "hw_model": "cEOSLab"}, + "device-1": {"is_online": True, "hw_model": "vEOS-lab"}, + "device-2": {"is_online": False}, }, {}, None, id="cEOSLab and vEOS-lab devices", ), pytest.param( + {"count": 3}, { - "dummy": {"is_online": True}, - "dummy2": {"is_online": True}, - "dummy3": {"is_online": False}, + "device-0": {"is_online": True}, + "device-1": {"is_online": True}, + "device-2": {"is_online": False}, }, - {"dummy": None}, # None means the command failed to collect + {"device-0": None}, # None means the command failed to collect None, id="device with error", ), pytest.param( + {"count": 3}, { - "dummy": {"is_online": True}, - "dummy2": {"is_online": True}, - "dummy3": {"is_online": True}, + "device-0": {"is_online": True}, + "device-1": {"is_online": True}, + "device-2": {"is_online": True}, }, {}, ["spine"], id="tags", ), ], + indirect=["inventory"], ) async def test_clear_counters( caplog: pytest.LogCaptureFixture, - test_inventory: AntaInventory, + inventory: AntaInventory, inventory_state: dict[str, Any], per_device_command_output: dict[str, Any], tags: set[str] | None, @@ -80,12 +86,12 @@ async def test_clear_counters( async def mock_connect_inventory() -> None: """Mock connect_inventory coroutine.""" - for name, device in test_inventory.items(): + for name, device in inventory.items(): device.is_online = inventory_state[name].get("is_online", True) device.established = inventory_state[name].get("established", device.is_online) device.hw_model = inventory_state[name].get("hw_model", "dummy") - async def collect(self: AntaDevice, command: AntaCommand, *args: Any, **kwargs: Any) -> None: # noqa: ARG001, ANN401 #pylint: disable=unused-argument + async def collect(self: AntaDevice, command: AntaCommand, *args: Any, **kwargs: Any) -> None: # noqa: ARG001, ANN401 """Mock collect coroutine.""" command.output = per_device_command_output.get(self.name, "") @@ -97,10 +103,10 @@ async def collect(self: AntaDevice, command: AntaCommand, *args: Any, **kwargs: side_effect=mock_connect_inventory, ) as mocked_connect_inventory, ): - await clear_counters(test_inventory, tags=tags) + await clear_counters(inventory, tags=tags) mocked_connect_inventory.assert_awaited_once() - devices_established = test_inventory.get_inventory(established_only=True, tags=tags).devices + devices_established = inventory.get_inventory(established_only=True, tags=tags).devices if devices_established: # Building the list of calls calls = [] @@ -142,3 +148,172 @@ async def collect(self: AntaDevice, command: AntaCommand, *args: Any, **kwargs: assert f"Could not clear counters on device {key}: []" in caplog.text else: mocked_collect.assert_not_awaited() + + +# TODO: test with changing root_dir, test with failing to write (OSError) +@pytest.mark.parametrize( + ("inventory", "inventory_state", "commands", "tags"), + [ + pytest.param( + {"count": 1}, + { + "device-0": {"is_online": False}, + }, + {"json_format": ["show version"]}, + None, + id="no_connected_device", + ), + pytest.param( + {"count": 3}, + { + "device-0": {"is_online": True}, + "device-1": {"is_online": True}, + "device-2": {"is_online": False}, + }, + {"json_format": ["show version", "show ip interface brief"]}, + None, + id="JSON commands", + ), + pytest.param( + {"count": 3}, + { + "device-0": {"is_online": True}, + "device-1": {"is_online": True}, + "device-2": {"is_online": False}, + }, + {"json_format": ["show version"], "text_format": ["show running-config", "show ip interface"]}, + None, + id="Text commands", + ), + pytest.param( + {"count": 2}, + { + "device-0": {"is_online": True, "tags": {"spine"}}, + "device-1": {"is_online": True}, + }, + {"json_format": ["show version"]}, + {"spine"}, + id="tags", + ), + pytest.param( # TODO: This test should not be there we should catch the wrong user input with pydantic. + {"count": 1}, + { + "device-0": {"is_online": True}, + }, + {"blah_format": ["42"]}, + None, + id="bad-input", + ), + pytest.param( + {"count": 1}, + { + "device-0": {"is_online": True}, + }, + {"json_format": ["undefined command", "show version"]}, + None, + id="command-failed-to-be-collected", + ), + pytest.param( + {"count": 1}, + { + "device-0": {"is_online": True}, + }, + {"json_format": ["uncaught exception"]}, + None, + id="uncaught-exception", + ), + ], + indirect=["inventory"], +) +async def test_collect_commands( + caplog: pytest.LogCaptureFixture, + tmp_path: Path, + inventory: AntaInventory, + inventory_state: dict[str, Any], + commands: dict[str, list[str]], + tags: set[str] | None, +) -> None: + """Test anta.cli.exec.utils.collect_commands.""" + caplog.set_level(logging.INFO) + root_dir = tmp_path + + async def mock_connect_inventory() -> None: + """Mock connect_inventory coroutine.""" + for name, device in inventory.items(): + device.is_online = inventory_state[name].get("is_online", True) + device.established = inventory_state[name].get("established", device.is_online) + device.hw_model = inventory_state[name].get("hw_model", "dummy") + device.tags = inventory_state[name].get("tags", set()) + + # Need to patch the child device class + # ruff: noqa: C901 + with ( + respx.mock, + patch( + "anta.inventory.AntaInventory.connect_inventory", + side_effect=mock_connect_inventory, + ) as mocked_connect_inventory, + ): + # Mocking responses from devices + respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}, json__params__cmds__0__cmd="show version").respond( + json={"result": [{"toto": 42}]} + ) + respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}, json__params__cmds__0__cmd="show ip interface brief").respond( + json={"result": [{"toto": 42}]} + ) + respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}, json__params__cmds__0__cmd="show running-config").respond( + json={"result": [{"output": "blah"}]} + ) + respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}, json__params__cmds__0__cmd="show ip interface").respond( + json={"result": [{"output": "blah"}]} + ) + respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}, json__params__cmds__0__cmd="undefined command").respond( + json={ + "error": { + "code": 1002, + "message": "CLI command 1 of 1 'undefined command' failed: invalid command", + "data": [{"errors": ["Invalid input (at token 0: 'undefined')"]}], + } + } + ) + await collect_commands(inventory, commands, root_dir, tags=tags) + + mocked_connect_inventory.assert_awaited_once() + devices_established = inventory.get_inventory(established_only=True, tags=tags or None).devices + if not devices_established: + assert "INFO" in caplog.text + assert "No online device found. Exiting" in caplog.text + return + + for device in devices_established: + # Verify tags selection + assert device.tags.intersection(tags) != {} if tags else True + json_path = root_dir / device.name / "json" + text_path = root_dir / device.name / "text" + if "json_format" in commands: + # Handle undefined command + if "undefined command" in commands["json_format"]: + assert "ERROR" in caplog.text + assert "Command 'undefined command' failed on device-0: Invalid input (at token 0: 'undefined')" in caplog.text + # Verify we don't claim it was collected + assert f"Collected command 'undefined command' from device {device.name}" not in caplog.text + commands["json_format"].remove("undefined command") + # Handle uncaught exception + elif "uncaught exception" in commands["json_format"]: + assert "ERROR" in caplog.text + assert "Error when collecting commands: " in caplog.text + # Verify we don't claim it was collected + assert f"Collected command 'uncaught exception' from device {device.name}" not in caplog.text + commands["json_format"].remove("uncaught exception") + + assert json_path.is_dir() + assert len(list(Path.iterdir(json_path))) == len(commands["json_format"]) + for command in commands["json_format"]: + assert Path.is_file(json_path / f"{safe_command(command)}.json") + assert f"Collected command '{command}' from device {device.name}" in caplog.text + if "text_format" in commands: + assert text_path.is_dir() + assert len(list(text_path.iterdir())) == len(commands["text_format"]) + for command in commands["text_format"]: + assert Path.is_file(text_path / f"{safe_command(command)}.log") + assert f"Collected command '{command}' from device {device.name}" in caplog.text diff --git a/tests/units/cli/get/test_commands.py b/tests/units/cli/get/test_commands.py index 1e8c6e95d..ff3d922b2 100644 --- a/tests/units/cli/get/test_commands.py +++ b/tests/units/cli/get/test_commands.py @@ -42,7 +42,6 @@ def test_from_cvp( cv_token_failure: bool, cvp_connect_failure: bool, ) -> None: - # pylint: disable=too-many-arguments # ruff: noqa: C901 """Test `anta get from-cvp`. @@ -144,7 +143,6 @@ def test_from_ansible( expected_exit: int, expected_log: str | None, ) -> None: - # pylint: disable=too-many-arguments """Test `anta get from-ansible`. This test verifies: @@ -230,7 +228,6 @@ def test_from_ansible_overwrite( expected_exit: int, expected_log: str | None, ) -> None: - # pylint: disable=too-many-arguments """Test `anta get from-ansible` overwrite mechanism. The test uses a static ansible-inventory and output as these are tested in other functions diff --git a/tests/units/cli/get/test_utils.py b/tests/units/cli/get/test_utils.py index e105f9476..46ce14f93 100644 --- a/tests/units/cli/get/test_utils.py +++ b/tests/units/cli/get/test_utils.py @@ -144,7 +144,6 @@ def test_create_inventory_from_ansible( expected_inv_length: int, ) -> None: """Test anta.get.utils.create_inventory_from_ansible.""" - # pylint: disable=R0913 target_file = tmp_path / "inventory.yml" inventory_file_path = DATA_DIR / inventory_filename diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py index 7227a699f..d08499c6a 100644 --- a/tests/units/cli/nrfu/test__init__.py +++ b/tests/units/cli/nrfu/test__init__.py @@ -9,7 +9,6 @@ from anta.cli import anta from anta.cli.utils import ExitCode -from tests.lib.utils import default_anta_env if TYPE_CHECKING: from click.testing import CliRunner @@ -58,8 +57,7 @@ def test_anta_nrfu_wrong_catalog_format(click_runner: CliRunner) -> None: def test_anta_password_required(click_runner: CliRunner) -> None: """Test that password is provided.""" - env = default_anta_env() - env["ANTA_PASSWORD"] = None + env = {"ANTA_PASSWORD": None} result = click_runner.invoke(anta, ["nrfu"], env=env) assert result.exit_code == ExitCode.USAGE_ERROR @@ -68,8 +66,7 @@ def test_anta_password_required(click_runner: CliRunner) -> None: def test_anta_password(click_runner: CliRunner) -> None: """Test that password can be provided either via --password or --prompt.""" - env = default_anta_env() - env["ANTA_PASSWORD"] = None + env = {"ANTA_PASSWORD": None} result = click_runner.invoke(anta, ["nrfu", "--password", "secret"], env=env) assert result.exit_code == ExitCode.OK result = click_runner.invoke(anta, ["nrfu", "--prompt"], input="password\npassword\n", env=env) diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 27f01a78c..6a2624c1e 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -52,7 +52,7 @@ def test_anta_nrfu_table(click_runner: CliRunner) -> None: """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu", "table"]) assert result.exit_code == ExitCode.OK - assert "dummy │ VerifyEOSVersion │ success" in result.output + assert "leaf1 │ VerifyEOSVersion │ success" in result.output def test_anta_nrfu_table_group_by_device(click_runner: CliRunner) -> None: @@ -73,7 +73,7 @@ def test_anta_nrfu_text(click_runner: CliRunner) -> None: """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu", "text"]) assert result.exit_code == ExitCode.OK - assert "dummy :: VerifyEOSVersion :: SUCCESS" in result.output + assert "leaf1 :: VerifyEOSVersion :: SUCCESS" in result.output def test_anta_nrfu_json(click_runner: CliRunner) -> None: @@ -85,7 +85,7 @@ def test_anta_nrfu_json(click_runner: CliRunner) -> None: assert match is not None result_list = json.loads(match.group()) for res in result_list: - if res["name"] == "dummy": + if res["name"] == "leaf1": assert res["test"] == "VerifyEOSVersion" assert res["result"] == "success" @@ -131,7 +131,7 @@ def test_anta_nrfu_template(click_runner: CliRunner) -> None: """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu", "tpl-report", "--template", str(DATA_DIR / "template.j2")]) assert result.exit_code == ExitCode.OK - assert "* VerifyEOSVersion is SUCCESS for dummy" in result.output + assert "* VerifyEOSVersion is SUCCESS for leaf1" in result.output def test_anta_nrfu_csv(click_runner: CliRunner, tmp_path: Path) -> None: diff --git a/tests/units/conftest.py b/tests/units/conftest.py new file mode 100644 index 000000000..665075c6f --- /dev/null +++ b/tests/units/conftest.py @@ -0,0 +1,85 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +import pytest +import yaml + +from anta.device import AntaDevice, AsyncEOSDevice + +if TYPE_CHECKING: + from collections.abc import Iterator + + from anta.models import AntaCommand + +DEVICE_HW_MODEL = "pytest" +DEVICE_NAME = "pytest" +COMMAND_OUTPUT = "retrieved" + + +@pytest.fixture(name="anta_env") +def anta_env_fixture() -> dict[str, str]: + """Return an ANTA environment for testing.""" + return { + "ANTA_USERNAME": "anta", + "ANTA_PASSWORD": "formica", + "ANTA_INVENTORY": str(Path(__file__).parent.parent / "data" / "test_inventory_with_tags.yml"), + "ANTA_CATALOG": str(Path(__file__).parent.parent / "data" / "test_catalog.yml"), + } + + +@pytest.fixture +def device(request: pytest.FixtureRequest) -> Iterator[AntaDevice]: + """Return an AntaDevice instance with mocked abstract method.""" + + def _collect(command: AntaCommand, *args: Any, **kwargs: Any) -> None: # noqa: ARG001, ANN401 + command.output = COMMAND_OUTPUT + + kwargs = {"name": DEVICE_NAME, "hw_model": DEVICE_HW_MODEL} + + if hasattr(request, "param"): + # Fixture is parametrized indirectly + kwargs.update(request.param) + with patch.object(AntaDevice, "__abstractmethods__", set()), patch("anta.device.AntaDevice._collect", side_effect=_collect): + # AntaDevice constructor does not have hw_model argument + hw_model = kwargs.pop("hw_model") + dev = AntaDevice(**kwargs) # type: ignore[abstract, arg-type] + dev.hw_model = hw_model + yield dev + + +@pytest.fixture +def async_device(request: pytest.FixtureRequest) -> AsyncEOSDevice: + """Return an AsyncEOSDevice instance.""" + kwargs = { + "name": DEVICE_NAME, + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + } + + if hasattr(request, "param"): + # Fixture is parametrized indirectly + kwargs.update(request.param) + return AsyncEOSDevice(**kwargs) # type: ignore[arg-type] + + +@pytest.fixture +def yaml_file(request: pytest.FixtureRequest, tmp_path: Path) -> Path: + """Fixture to create a temporary YAML file and return the path. + + Fixture is indirectly parametrized with the YAML file content. + """ + assert hasattr(request, "param") + file = tmp_path / "test_file.yaml" + assert isinstance(request.param, dict) + content: dict[str, Any] = request.param + file.write_text(yaml.dump(content, allow_unicode=True)) + return file diff --git a/tests/units/inventory/test__init__.py b/tests/units/inventory/test__init__.py new file mode 100644 index 000000000..20a794a76 --- /dev/null +++ b/tests/units/inventory/test__init__.py @@ -0,0 +1,78 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""ANTA Inventory unit tests.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from pydantic import ValidationError + +from anta.inventory import AntaInventory +from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError + +if TYPE_CHECKING: + from _pytest.mark.structures import ParameterSet + +FILE_DIR: Path = Path(__file__).parent.parent.resolve() / "data" / "inventory" + + +INIT_VALID_PARAMS: list[ParameterSet] = [ + pytest.param( + {"anta_inventory": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2"}, {"host": "my.awesome.host.com"}]}}, + id="Inventory_with_host_only", + ), + pytest.param({"anta_inventory": {"networks": [{"network": "192.168.0.0/24"}]}}, id="ValidInventory_with_networks_only"), + pytest.param( + {"anta_inventory": {"ranges": [{"start": "10.0.0.1", "end": "10.0.0.11"}, {"start": "10.0.0.101", "end": "10.0.0.111"}]}}, + id="Inventory_with_ranges_only", + ), + pytest.param( + {"anta_inventory": {"hosts": [{"host": "192.168.0.17", "port": 443}, {"host": "192.168.0.2", "port": 80}]}}, + id="Inventory_with_host_port", + ), + pytest.param( + {"anta_inventory": {"hosts": [{"host": "192.168.0.17", "tags": ["leaf"]}, {"host": "192.168.0.2", "tags": ["spine"]}]}}, + id="Inventory_with_host_tags", + ), + pytest.param({"anta_inventory": {"networks": [{"network": "192.168.0.0/24", "tags": ["leaf"]}]}}, id="ValidInventory_with_networks_tags"), + pytest.param( + { + "anta_inventory": { + "ranges": [{"start": "10.0.0.1", "end": "10.0.0.11", "tags": ["leaf"]}, {"start": "10.0.0.101", "end": "10.0.0.111", "tags": ["spine"]}] + } + }, + id="Inventory_with_ranges_tags", + ), +] + + +INIT_INVALID_PARAMS = [ + pytest.param({"anta_inventory": {"hosts": [{"host": "192.168.0.17/32"}, {"host": "192.168.0.2"}]}}, id="Inventory_with_host_only"), + pytest.param({"anta_inventory": {"networks": [{"network": "192.168.42.0/8"}]}}, id="Inventory_wrong_network_bits"), + pytest.param({"anta_inventory": {"networks": [{"network": "toto"}]}}, id="Inventory_wrong_network"), + pytest.param({"anta_inventory": {"ranges": [{"start": "toto", "end": "192.168.42.42"}]}}, id="Inventory_wrong_range"), + pytest.param({"anta_inventory": {"ranges": [{"start": "fe80::cafe", "end": "192.168.42.42"}]}}, id="Inventory_wrong_range_type_mismatch"), + pytest.param( + {"inventory": {"ranges": [{"start": "10.0.0.1", "end": "10.0.0.11"}, {"start": "10.0.0.100", "end": "10.0.0.111"}]}}, + id="Invalid_Root_Key", + ), +] + + +class TestAntaInventory: + """Tests for anta.inventory.AntaInventory.""" + + @pytest.mark.parametrize("yaml_file", INIT_VALID_PARAMS, indirect=["yaml_file"]) + def test_parse_valid(self, yaml_file: Path) -> None: + """Parse valid YAML file to create ANTA inventory.""" + AntaInventory.parse(filename=yaml_file, username="arista", password="arista123") + + @pytest.mark.parametrize("yaml_file", INIT_INVALID_PARAMS, indirect=["yaml_file"]) + def test_parse_invalid(self, yaml_file: Path) -> None: + """Parse invalid YAML file to create ANTA inventory.""" + with pytest.raises((InventoryIncorrectSchemaError, InventoryRootKeyError, ValidationError)): + AntaInventory.parse(filename=yaml_file, username="arista", password="arista123") diff --git a/tests/units/inventory/test_inventory.py b/tests/units/inventory/test_inventory.py deleted file mode 100644 index 430ca21cd..000000000 --- a/tests/units/inventory/test_inventory.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) 2023-2024 Arista Networks, Inc. -# Use of this source code is governed by the Apache License 2.0 -# that can be found in the LICENSE file. -"""ANTA Inventory unit tests.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -import pytest -import yaml -from pydantic import ValidationError - -from anta.inventory import AntaInventory -from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError -from tests.data.json_data import ANTA_INVENTORY_TESTS_INVALID, ANTA_INVENTORY_TESTS_VALID -from tests.lib.utils import generate_test_ids_dict - -if TYPE_CHECKING: - from pathlib import Path - - -class TestAntaInventory: - """Test AntaInventory class.""" - - def create_inventory(self, content: str, tmp_path: Path) -> str: - """Create fakefs inventory file.""" - tmp_inventory = tmp_path / "mydir/myfile" - tmp_inventory.parent.mkdir() - tmp_inventory.touch() - tmp_inventory.write_text(yaml.dump(content, allow_unicode=True)) - return str(tmp_inventory) - - def check_parameter(self, parameter: str, test_definition: dict[Any, Any]) -> bool: - """Check if parameter is configured in testbed.""" - return "parameters" in test_definition and parameter in test_definition["parameters"] - - @pytest.mark.parametrize("test_definition", ANTA_INVENTORY_TESTS_VALID, ids=generate_test_ids_dict) - def test_init_valid(self, test_definition: dict[str, Any], tmp_path: Path) -> None: - """Test class constructor with valid data. - - Test structure: - --------------- - - { - 'name': 'ValidInventory_with_host_only', - 'input': {"anta_inventory":{"hosts":[{"host":"192.168.0.17"},{"host":"192.168.0.2"}]}}, - 'expected_result': 'valid', - 'parameters': { - 'ipaddress_in_scope': '192.168.0.17', - 'ipaddress_out_of_scope': '192.168.1.1', - } - } - - """ - inventory_file = self.create_inventory(content=test_definition["input"], tmp_path=tmp_path) - try: - AntaInventory.parse(filename=inventory_file, username="arista", password="arista123") - except ValidationError as exc: - raise AssertionError from exc - - @pytest.mark.parametrize("test_definition", ANTA_INVENTORY_TESTS_INVALID, ids=generate_test_ids_dict) - def test_init_invalid(self, test_definition: dict[str, Any], tmp_path: Path) -> None: - """Test class constructor with invalid data. - - Test structure: - --------------- - - { - 'name': 'ValidInventory_with_host_only', - 'input': {"anta_inventory":{"hosts":[{"host":"192.168.0.17"},{"host":"192.168.0.2"}]}}, - 'expected_result': 'invalid', - 'parameters': { - 'ipaddress_in_scope': '192.168.0.17', - 'ipaddress_out_of_scope': '192.168.1.1', - } - } - - """ - inventory_file = self.create_inventory(content=test_definition["input"], tmp_path=tmp_path) - with pytest.raises((InventoryIncorrectSchemaError, InventoryRootKeyError, ValidationError)): - AntaInventory.parse(filename=inventory_file, username="arista", password="arista123") diff --git a/tests/units/inventory/test_models.py b/tests/units/inventory/test_models.py index 0dccfb830..dfe9722d5 100644 --- a/tests/units/inventory/test_models.py +++ b/tests/units/inventory/test_models.py @@ -5,387 +5,162 @@ from __future__ import annotations -import logging -from typing import Any +from typing import TYPE_CHECKING, Any import pytest from pydantic import ValidationError -from anta.device import AsyncEOSDevice -from anta.inventory.models import AntaInventoryHost, AntaInventoryInput, AntaInventoryNetwork, AntaInventoryRange -from tests.data.json_data import ( - INVENTORY_DEVICE_MODEL_INVALID, - INVENTORY_DEVICE_MODEL_VALID, - INVENTORY_MODEL_HOST_CACHE, - INVENTORY_MODEL_HOST_INVALID, - INVENTORY_MODEL_HOST_VALID, - INVENTORY_MODEL_INVALID, - INVENTORY_MODEL_NETWORK_CACHE, - INVENTORY_MODEL_NETWORK_INVALID, - INVENTORY_MODEL_NETWORK_VALID, - INVENTORY_MODEL_RANGE_CACHE, - INVENTORY_MODEL_RANGE_INVALID, - INVENTORY_MODEL_RANGE_VALID, - INVENTORY_MODEL_VALID, -) -from tests.lib.utils import generate_test_ids_dict - - -class TestInventoryUnitModels: - """Test components of AntaInventoryInput model.""" - - @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_VALID, ids=generate_test_ids_dict) - def test_anta_inventory_host_valid(self, test_definition: dict[str, Any]) -> None: - """Test host input model. - - Test structure: - --------------- - - { - 'name': 'ValidIPv4_Host', - 'input': '1.1.1.1', - 'expected_result': 'valid' - } - - """ - try: - host_inventory = AntaInventoryHost(host=test_definition["input"]) - except ValidationError as exc: - logging.warning("Error: %s", str(exc)) - raise AssertionError from exc - assert test_definition["input"] == str(host_inventory.host) - - @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_INVALID, ids=generate_test_ids_dict) - def test_anta_inventory_host_invalid(self, test_definition: dict[str, Any]) -> None: - """Test host input model. - - Test structure: - --------------- - - { - 'name': 'ValidIPv4_Host', - 'input': '1.1.1.1/32', - 'expected_result': 'invalid' - } - - """ - with pytest.raises(ValidationError): - AntaInventoryHost(host=test_definition["input"]) - - @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_CACHE, ids=generate_test_ids_dict) - def test_anta_inventory_host_cache(self, test_definition: dict[str, Any]) -> None: - """Test host disable_cache. - - Test structure: - --------------- - - { - 'name': 'Cache', - 'input': {"host": '1.1.1.1', "disable_cache": True}, - 'expected_result': True - } - - """ - if "disable_cache" in test_definition["input"]: - host_inventory = AntaInventoryHost(host=test_definition["input"]["host"], disable_cache=test_definition["input"]["disable_cache"]) - else: - host_inventory = AntaInventoryHost(host=test_definition["input"]["host"]) - assert test_definition["expected_result"] == host_inventory.disable_cache - - @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_VALID, ids=generate_test_ids_dict) - def test_anta_inventory_network_valid(self, test_definition: dict[str, Any]) -> None: - """Test Network input model with valid data. - - Test structure: - --------------- - - { - 'name': 'ValidIPv4_Subnet', - 'input': '1.1.1.0/24', - 'expected_result': 'valid' - } - - """ - try: - network_inventory = AntaInventoryNetwork(network=test_definition["input"]) - except ValidationError as exc: - logging.warning("Error: %s", str(exc)) - raise AssertionError from exc - assert test_definition["input"] == str(network_inventory.network) - - @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_INVALID, ids=generate_test_ids_dict) - def test_anta_inventory_network_invalid(self, test_definition: dict[str, Any]) -> None: - """Test Network input model with invalid data. - - Test structure: - --------------- - - { - 'name': 'ValidIPv4_Subnet', - 'input': '1.1.1.0/16', - 'expected_result': 'invalid' - } - - """ - try: - AntaInventoryNetwork(network=test_definition["input"]) - except ValidationError as exc: - logging.warning("Error: %s", str(exc)) - else: - raise AssertionError - - @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_CACHE, ids=generate_test_ids_dict) - def test_anta_inventory_network_cache(self, test_definition: dict[str, Any]) -> None: - """Test network disable_cache. - - Test structure: - --------------- - - { - 'name': 'Cache', - 'input': {"network": '1.1.1.1/24', "disable_cache": True}, - 'expected_result': True - } - - """ - if "disable_cache" in test_definition["input"]: - network_inventory = AntaInventoryNetwork(network=test_definition["input"]["network"], disable_cache=test_definition["input"]["disable_cache"]) - else: - network_inventory = AntaInventoryNetwork(network=test_definition["input"]["network"]) - assert test_definition["expected_result"] == network_inventory.disable_cache - - @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_VALID, ids=generate_test_ids_dict) - def test_anta_inventory_range_valid(self, test_definition: dict[str, Any]) -> None: - """Test range input model. - - Test structure: - --------------- - - { - 'name': 'ValidIPv4_Range', - 'input': {'start':'10.1.0.1', 'end':'10.1.0.10'}, - 'expected_result': 'valid' - } - - """ - try: - range_inventory = AntaInventoryRange( - start=test_definition["input"]["start"], - end=test_definition["input"]["end"], - ) - except ValidationError as exc: - logging.warning("Error: %s", str(exc)) - raise AssertionError from exc - assert test_definition["input"]["start"] == str(range_inventory.start) - assert test_definition["input"]["end"] == str(range_inventory.end) - - @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_INVALID, ids=generate_test_ids_dict) - def test_anta_inventory_range_invalid(self, test_definition: dict[str, Any]) -> None: - """Test range input model. - - Test structure: - --------------- - - { - 'name': 'ValidIPv4_Range', - 'input': {'start':'10.1.0.1', 'end':'10.1.0.10/32'}, - 'expected_result': 'invalid' - } - - """ - try: - AntaInventoryRange( - start=test_definition["input"]["start"], - end=test_definition["input"]["end"], - ) - except ValidationError as exc: - logging.warning("Error: %s", str(exc)) +from anta.inventory.models import AntaInventoryHost, AntaInventoryNetwork, AntaInventoryRange + +if TYPE_CHECKING: + from _pytest.mark.structures import ParameterSet + +INVENTORY_HOST_VALID_PARAMS: list[ParameterSet] = [ + pytest.param(None, "1.1.1.1", None, None, None, id="IPv4"), + pytest.param(None, "fe80::cc62:a9ff:feef:932a", None, None, None, id="IPv6"), + pytest.param(None, "1.1.1.1", 666, None, None, id="IPv4_with_port"), + pytest.param(None, "1.1.1.1", None, None, True, id="cache_enabled"), + pytest.param(None, "1.1.1.1", None, None, False, id="cache_disabled"), +] + +INVENTORY_HOST_INVALID_PARAMS: list[ParameterSet] = [ + pytest.param(None, "1.1.1.1/32", None, None, False, id="IPv4_with_netmask"), + pytest.param(None, "1.1.1.1", 66666, None, False, id="IPv4_with_wrong_port"), + pytest.param(None, "fe80::cc62:a9ff:feef:932a/128", None, None, False, id="IPv6_with_netmask"), + pytest.param(None, "fe80::cc62:a9ff:feef:", None, None, False, id="invalid_IPv6"), + pytest.param(None, "@", None, None, False, id="special_char"), + pytest.param(None, "1.1.1.1", None, None, None, id="cache_is_None"), +] + +INVENTORY_NETWORK_VALID_PARAMS: list[ParameterSet] = [ + pytest.param("1.1.1.0/24", None, None, id="IPv4_subnet"), + pytest.param("2001:db8::/32", None, None, id="IPv6_subnet"), + pytest.param("1.1.1.0/24", None, False, id="cache_enabled"), + pytest.param("1.1.1.0/24", None, True, id="cache_disabled"), +] + +INVENTORY_NETWORK_INVALID_PARAMS: list[ParameterSet] = [ + pytest.param("1.1.1.0/17", None, False, id="IPv4_subnet"), + pytest.param("2001:db8::/16", None, False, id="IPv6_subnet"), + pytest.param("1.1.1.0/24", None, None, id="cache_is_None"), +] + +INVENTORY_RANGE_VALID_PARAMS: list[ParameterSet] = [ + pytest.param("10.1.0.1", "10.1.0.10", None, None, id="IPv4_range"), + pytest.param("10.1.0.1", "10.1.0.10", None, True, id="cache_enabled"), + pytest.param("10.1.0.1", "10.1.0.10", None, False, id="cache_disabled"), +] + +INVENTORY_RANGE_INVALID_PARAMS: list[ParameterSet] = [ + pytest.param("toto", "10.1.0.10", None, False, id="IPv4_range"), + pytest.param("10.1.0.1", "10.1.0.10", None, None, id="cache_is_None"), +] + +INVENTORY_MODEL_VALID = [ + { + "name": "Valid_Host_Only", + "input": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2"}]}, + "expected_result": "valid", + }, + { + "name": "Valid_Networks_Only", + "input": {"networks": [{"network": "192.168.0.0/16"}, {"network": "192.168.1.0/24"}]}, + "expected_result": "valid", + }, + { + "name": "Valid_Ranges_Only", + "input": { + "ranges": [ + {"start": "10.1.0.1", "end": "10.1.0.10"}, + {"start": "10.2.0.1", "end": "10.2.1.10"}, + ], + }, + "expected_result": "valid", + }, +] + +INVENTORY_MODEL_INVALID = [ + { + "name": "Host_with_Invalid_entry", + "input": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2/32"}]}, + "expected_result": "invalid", + }, +] + + +class TestAntaInventoryHost: + """Test anta.inventory.models.AntaInventoryHost.""" + + @pytest.mark.parametrize(("name", "host", "port", "tags", "disable_cache"), INVENTORY_HOST_VALID_PARAMS) + def test_valid(self, name: str, host: str, port: int, tags: set[str], disable_cache: bool | None) -> None: + """Valid model parameters.""" + params: dict[str, Any] = {"name": name, "host": host, "port": port, "tags": tags} + if disable_cache is not None: + params = params | {"disable_cache": disable_cache} + inventory_host = AntaInventoryHost.model_validate(params) + assert host == str(inventory_host.host) + assert port == inventory_host.port + assert name == inventory_host.name + assert tags == inventory_host.tags + if disable_cache is None: + # Check cache default value + assert inventory_host.disable_cache is False else: - raise AssertionError + assert inventory_host.disable_cache == disable_cache - @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_CACHE, ids=generate_test_ids_dict) - def test_anta_inventory_range_cache(self, test_definition: dict[str, Any]) -> None: - """Test range disable_cache. - - Test structure: - --------------- - - { - 'name': 'Cache', - 'input': {"start": '1.1.1.1', "end": "1.1.1.10", "disable_cache": True}, - 'expected_result': True - } - - """ - if "disable_cache" in test_definition["input"]: - range_inventory = AntaInventoryRange( - start=test_definition["input"]["start"], - end=test_definition["input"]["end"], - disable_cache=test_definition["input"]["disable_cache"], - ) + @pytest.mark.parametrize(("name", "host", "port", "tags", "disable_cache"), INVENTORY_HOST_INVALID_PARAMS) + def test_invalid(self, name: str, host: str, port: int, tags: set[str], disable_cache: bool | None) -> None: + """Invalid model parameters.""" + with pytest.raises(ValidationError): + AntaInventoryHost.model_validate({"name": name, "host": host, "port": port, "tags": tags, "disable_cache": disable_cache}) + + +class TestAntaInventoryNetwork: + """Test anta.inventory.models.AntaInventoryNetwork.""" + + @pytest.mark.parametrize(("network", "tags", "disable_cache"), INVENTORY_NETWORK_VALID_PARAMS) + def test_valid(self, network: str, tags: set[str], disable_cache: bool | None) -> None: + """Valid model parameters.""" + params: dict[str, Any] = {"network": network, "tags": tags} + if disable_cache is not None: + params = params | {"disable_cache": disable_cache} + inventory_network = AntaInventoryNetwork.model_validate(params) + assert network == str(inventory_network.network) + assert tags == inventory_network.tags + if disable_cache is None: + # Check cache default value + assert inventory_network.disable_cache is False else: - range_inventory = AntaInventoryRange(start=test_definition["input"]["start"], end=test_definition["input"]["end"]) - assert test_definition["expected_result"] == range_inventory.disable_cache - - -class TestAntaInventoryInputModel: - """Unit test of AntaInventoryInput model.""" - - def test_inventory_input_structure(self) -> None: - """Test inventory keys are those expected.""" - inventory = AntaInventoryInput() - logging.info("Inventory keys are: %s", str(inventory.model_dump().keys())) - assert all(elem in inventory.model_dump() for elem in ["hosts", "networks", "ranges"]) - - @pytest.mark.parametrize("inventory_def", INVENTORY_MODEL_VALID, ids=generate_test_ids_dict) - def test_anta_inventory_intput_valid(self, inventory_def: dict[str, Any]) -> None: - """Test loading valid data to inventory class. - - Test structure: - --------------- - - { - "name": "Valid_Host_Only", - "input": { - "hosts": [ - { - "host": "192.168.0.17" - }, - { - "host": "192.168.0.2" - } - ] - }, - "expected_result": "valid" - } - - """ - try: - inventory = AntaInventoryInput(**inventory_def["input"]) - except ValidationError as exc: - logging.warning("Error: %s", str(exc)) - raise AssertionError from exc - logging.info("Checking if all root keys are correctly lodaded") - assert all(elem in inventory.model_dump() for elem in inventory_def["input"]) - - @pytest.mark.parametrize("inventory_def", INVENTORY_MODEL_INVALID, ids=generate_test_ids_dict) - def test_anta_inventory_intput_invalid(self, inventory_def: dict[str, Any]) -> None: - """Test loading invalid data to inventory class. + assert inventory_network.disable_cache == disable_cache - Test structure: - --------------- - - { - "name": "Valid_Host_Only", - "input": { - "hosts": [ - { - "host": "192.168.0.17" - }, - { - "host": "192.168.0.2/32" - } - ] - }, - "expected_result": "invalid" - } - - """ - try: - if "hosts" in inventory_def["input"]: - logging.info( - "Loading %s into AntaInventoryInput hosts section", - str(inventory_def["input"]["hosts"]), - ) - AntaInventoryInput(hosts=inventory_def["input"]["hosts"]) - if "networks" in inventory_def["input"]: - logging.info( - "Loading %s into AntaInventoryInput networks section", - str(inventory_def["input"]["networks"]), - ) - AntaInventoryInput(networks=inventory_def["input"]["networks"]) - if "ranges" in inventory_def["input"]: - logging.info( - "Loading %s into AntaInventoryInput ranges section", - str(inventory_def["input"]["ranges"]), - ) - AntaInventoryInput(ranges=inventory_def["input"]["ranges"]) - except ValidationError as exc: - logging.warning("Error: %s", str(exc)) + @pytest.mark.parametrize(("network", "tags", "disable_cache"), INVENTORY_NETWORK_INVALID_PARAMS) + def test_invalid(self, network: str, tags: set[str], disable_cache: bool | None) -> None: + """Invalid model parameters.""" + with pytest.raises(ValidationError): + AntaInventoryNetwork.model_validate({"network": network, "tags": tags, "disable_cache": disable_cache}) + + +class TestAntaInventoryRange: + """Test anta.inventory.models.AntaInventoryRange.""" + + @pytest.mark.parametrize(("start", "end", "tags", "disable_cache"), INVENTORY_RANGE_VALID_PARAMS) + def test_valid(self, start: str, end: str, tags: set[str], disable_cache: bool | None) -> None: + """Valid model parameters.""" + params: dict[str, Any] = {"start": start, "end": end, "tags": tags} + if disable_cache is not None: + params = params | {"disable_cache": disable_cache} + inventory_range = AntaInventoryRange.model_validate(params) + assert start == str(inventory_range.start) + assert end == str(inventory_range.end) + assert tags == inventory_range.tags + if disable_cache is None: + # Check cache default value + assert inventory_range.disable_cache is False else: - raise AssertionError - - -class TestInventoryDeviceModel: - """Unit test of InventoryDevice model.""" - - @pytest.mark.parametrize("test_definition", INVENTORY_DEVICE_MODEL_VALID, ids=generate_test_ids_dict) - def test_inventory_device_valid(self, test_definition: dict[str, Any]) -> None: - """Test loading valid data to InventoryDevice class. - - Test structure: - --------------- - - { - "name": "Valid_Inventory", - "input": [ - { - 'host': '1.1.1.1', - 'username': 'arista', - 'password': 'arista123!' - }, - { - 'host': '1.1.1.1', - 'username': 'arista', - 'password': 'arista123!' - } - ], - "expected_result": "valid" - } + assert inventory_range.disable_cache == disable_cache - """ - if test_definition["expected_result"] == "invalid": - pytest.skip("Not concerned by the test") - - try: - for entity in test_definition["input"]: - AsyncEOSDevice(**entity) - except TypeError as exc: - logging.warning("Error: %s", str(exc)) - raise AssertionError from exc - - @pytest.mark.parametrize("test_definition", INVENTORY_DEVICE_MODEL_INVALID, ids=generate_test_ids_dict) - def test_inventory_device_invalid(self, test_definition: dict[str, Any]) -> None: - """Test loading invalid data to InventoryDevice class. - - Test structure: - --------------- - - { - "name": "Valid_Inventory", - "input": [ - { - 'host': '1.1.1.1', - 'username': 'arista', - 'password': 'arista123!' - }, - { - 'host': '1.1.1.1', - 'username': 'arista', - 'password': 'arista123!' - } - ], - "expected_result": "valid" - } - - """ - if test_definition["expected_result"] == "valid": - pytest.skip("Not concerned by the test") - - try: - for entity in test_definition["input"]: - AsyncEOSDevice(**entity) - except TypeError as exc: - logging.info("Error: %s", str(exc)) - else: - raise AssertionError + @pytest.mark.parametrize(("start", "end", "tags", "disable_cache"), INVENTORY_RANGE_INVALID_PARAMS) + def test_invalid(self, start: str, end: str, tags: set[str], disable_cache: bool | None) -> None: + """Invalid model parameters.""" + with pytest.raises(ValidationError): + AntaInventoryRange.model_validate({"start": start, "end": end, "tags": tags, "disable_cache": disable_cache}) diff --git a/tests/units/reporter/conftest.py b/tests/units/reporter/conftest.py new file mode 100644 index 000000000..ae7d3dfea --- /dev/null +++ b/tests/units/reporter/conftest.py @@ -0,0 +1,8 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files.""" + +from tests.units.result_manager.conftest import list_result_factory, result_manager, result_manager_factory, test_result_factory + +__all__ = ["result_manager", "result_manager_factory", "list_result_factory", "test_result_factory"] diff --git a/tests/units/reporter/test__init__.py b/tests/units/reporter/test__init__.py index f0e44b41a..af26b54cb 100644 --- a/tests/units/reporter/test__init__.py +++ b/tests/units/reporter/test__init__.py @@ -47,7 +47,6 @@ class TestReportTable: ) def test__split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None, expected_output: str) -> None: """Test _split_list_to_txt_list.""" - # pylint: disable=protected-access report = ReportTable() assert report._split_list_to_txt_list(usr_list, delimiter) == expected_output @@ -61,7 +60,6 @@ def test__split_list_to_txt_list(self, usr_list: list[str], delimiter: str | Non ) def test__build_headers(self, headers: list[str]) -> None: """Test _build_headers.""" - # pylint: disable=protected-access report = ReportTable() table = Table() table_column_before = len(table.columns) @@ -82,7 +80,6 @@ def test__build_headers(self, headers: list[str]) -> None: ) def test__color_result(self, status: AntaTestStatus, expected_status: str) -> None: """Test _build_headers.""" - # pylint: disable=protected-access report = ReportTable() assert report._color_result(status) == expected_status @@ -103,7 +100,6 @@ def test_report_all( expected_length: int, ) -> None: """Test report_all.""" - # pylint: disable=too-many-arguments manager = result_manager_factory(number_of_tests) report = ReportTable() @@ -132,7 +128,6 @@ def test_report_summary_tests( expected_length: int, ) -> None: """Test report_summary_tests.""" - # pylint: disable=too-many-arguments # TODO: refactor this later... this is injecting double test results by modyfing the device name # should be a fixture manager = result_manager_factory(number_of_tests) @@ -167,7 +162,6 @@ def test_report_summary_devices( expected_length: int, ) -> None: """Test report_summary_devices.""" - # pylint: disable=too-many-arguments # TODO: refactor this later... this is injecting double test results by modyfing the device name # should be a fixture manager = result_manager_factory(number_of_tests) diff --git a/tests/units/result_manager/conftest.py b/tests/units/result_manager/conftest.py new file mode 100644 index 000000000..2c5dc8a69 --- /dev/null +++ b/tests/units/result_manager/conftest.py @@ -0,0 +1,85 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files.""" + +import json +from pathlib import Path +from typing import Callable + +import pytest + +from anta.device import AntaDevice +from anta.result_manager import ResultManager +from anta.result_manager.models import TestResult + +TEST_RESULTS: Path = Path(__file__).parent.resolve() / "test_files" / "test_md_report_results.json" + + +@pytest.fixture +def result_manager_factory(list_result_factory: Callable[[int], list[TestResult]]) -> Callable[[int], ResultManager]: + """Return a ResultManager factory that takes as input a number of tests.""" + # pylint: disable=redefined-outer-name + + def _factory(number: int = 0) -> ResultManager: + """Create a factory for list[TestResult] entry of size entries.""" + result_manager = ResultManager() + result_manager.results = list_result_factory(number) + return result_manager + + return _factory + + +@pytest.fixture +def result_manager() -> ResultManager: + """Return a ResultManager with 30 random tests loaded from a JSON file. + + Devices: DC1-SPINE1, DC1-LEAF1A + + - Total tests: 30 + - Success: 7 + - Skipped: 2 + - Failure: 19 + - Error: 2 + + See `tests/units/result_manager/test_md_report_results.json` for details. + """ + manager = ResultManager() + + with TEST_RESULTS.open("r", encoding="utf-8") as f: + results = json.load(f) + + for result in results: + manager.add(TestResult(**result)) + + return manager + + +@pytest.fixture +def test_result_factory(device: AntaDevice) -> Callable[[int], TestResult]: + """Return a anta.result_manager.models.TestResult object.""" + # pylint: disable=redefined-outer-name + + def _create(index: int = 0) -> TestResult: + """Actual Factory.""" + return TestResult( + name=device.name, + test=f"VerifyTest{index}", + categories=["test"], + description=f"Verifies Test {index}", + custom_field=None, + ) + + return _create + + +@pytest.fixture +def list_result_factory(test_result_factory: Callable[[int], TestResult]) -> Callable[[int], list[TestResult]]: + """Return a list[TestResult] with 'size' TestResult instantiated using the test_result_factory fixture.""" + # pylint: disable=redefined-outer-name + + def _factory(size: int = 0) -> list[TestResult]: + """Create a factory for list[TestResult] entry of size entries.""" + return [test_result_factory(i) for i in range(size)] + + return _factory diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py index 802d4a4e3..e1087536f 100644 --- a/tests/units/result_manager/test__init__.py +++ b/tests/units/result_manager/test__init__.py @@ -152,7 +152,6 @@ def test_add( expected_status: str, expected_raise: AbstractContextManager[Exception], ) -> None: - # pylint: disable=too-many-arguments """Test ResultManager_update_status.""" result_manager = ResultManager() result_manager.status = AntaTestStatus(starting_status) diff --git a/tests/data/test_md_report_results.json b/tests/units/result_manager/test_files/test_md_report_results.json similarity index 100% rename from tests/data/test_md_report_results.json rename to tests/units/result_manager/test_files/test_md_report_results.json diff --git a/tests/units/result_manager/test_models.py b/tests/units/result_manager/test_models.py index bc44ccfd8..0561dffd2 100644 --- a/tests/units/result_manager/test_models.py +++ b/tests/units/result_manager/test_models.py @@ -5,58 +5,65 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Callable import pytest from anta.result_manager.models import AntaTestStatus - -# Import as Result to avoid pytest collection -from tests.data.json_data import TEST_RESULT_SET_STATUS -from tests.lib.fixture import DEVICE_NAME -from tests.lib.utils import generate_test_ids_dict +from tests.units.conftest import DEVICE_NAME if TYPE_CHECKING: + from _pytest.mark.structures import ParameterSet + + # Import as Result to avoid pytest collection from anta.result_manager.models import TestResult as Result +TEST_RESULT_SET_STATUS: list[ParameterSet] = [ + pytest.param(AntaTestStatus.SUCCESS, "test success message", id="set_success"), + pytest.param(AntaTestStatus.ERROR, "test error message", id="set_error"), + pytest.param(AntaTestStatus.FAILURE, "test failure message", id="set_failure"), + pytest.param(AntaTestStatus.SKIPPED, "test skipped message", id="set_skipped"), + pytest.param(AntaTestStatus.UNSET, "test unset message", id="set_unset"), +] + class TestTestResultModels: """Test components of anta.result_manager.models.""" - @pytest.mark.parametrize("data", TEST_RESULT_SET_STATUS, ids=generate_test_ids_dict) - def test__is_status_foo(self, test_result_factory: Callable[[int], Result], data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("target", "message"), TEST_RESULT_SET_STATUS) + def test__is_status_foo(self, test_result_factory: Callable[[int], Result], target: AntaTestStatus, message: str) -> None: """Test TestResult.is_foo methods.""" testresult = test_result_factory(1) - assert testresult.result == "unset" + assert testresult.result == AntaTestStatus.UNSET assert len(testresult.messages) == 0 - if data["target"] == "success": - testresult.is_success(data["message"]) - assert testresult.result == data["target"] - assert data["message"] in testresult.messages - if data["target"] == "failure": - testresult.is_failure(data["message"]) - assert testresult.result == data["target"] - assert data["message"] in testresult.messages - if data["target"] == "error": - testresult.is_error(data["message"]) - assert testresult.result == data["target"] - assert data["message"] in testresult.messages - if data["target"] == "skipped": - testresult.is_skipped(data["message"]) - assert testresult.result == data["target"] - assert data["message"] in testresult.messages - # no helper for unset, testing _set_status - if data["target"] == "unset": - testresult._set_status(AntaTestStatus.UNSET, data["message"]) # pylint: disable=W0212 - assert testresult.result == data["target"] - assert data["message"] in testresult.messages - - @pytest.mark.parametrize("data", TEST_RESULT_SET_STATUS, ids=generate_test_ids_dict) - def test____str__(self, test_result_factory: Callable[[int], Result], data: dict[str, Any]) -> None: + if target == AntaTestStatus.SUCCESS: + testresult.is_success(message) + assert testresult.result == "success" + assert message in testresult.messages + if target == AntaTestStatus.FAILURE: + testresult.is_failure(message) + assert testresult.result == "failure" + assert message in testresult.messages + if target == AntaTestStatus.ERROR: + testresult.is_error(message) + assert testresult.result == "error" + assert message in testresult.messages + if target == AntaTestStatus.SKIPPED: + testresult.is_skipped(message) + assert testresult.result == "skipped" + assert message in testresult.messages + if target == AntaTestStatus.UNSET: + # no helper for unset, testing _set_status + testresult._set_status(AntaTestStatus.UNSET, message) + assert testresult.result == "unset" + assert message in testresult.messages + + @pytest.mark.parametrize(("target", "message"), TEST_RESULT_SET_STATUS) + def test____str__(self, test_result_factory: Callable[[int], Result], target: AntaTestStatus, message: str) -> None: """Test TestResult.__str__.""" testresult = test_result_factory(1) - assert testresult.result == "unset" + assert testresult.result == AntaTestStatus.UNSET assert len(testresult.messages) == 0 - testresult._set_status(data["target"], data["message"]) # pylint: disable=W0212 - assert testresult.result == data["target"] - assert str(testresult) == f"Test 'VerifyTest1' (on '{DEVICE_NAME}'): Result '{data['target']}'\nMessages: {[data['message']]}" + testresult._set_status(target, message) + assert testresult.result == target + assert str(testresult) == f"Test 'VerifyTest1' (on '{DEVICE_NAME}'): Result '{target}'\nMessages: {[message]}" diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py index 13046f294..c2bb57c93 100644 --- a/tests/units/test_catalog.py +++ b/tests/units/test_catalog.py @@ -7,7 +7,7 @@ from json import load as json_load from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any, Literal import pytest from pydantic import ValidationError @@ -28,38 +28,25 @@ VerifyReloadCause, VerifyUptime, ) -from tests.lib.utils import generate_test_ids_list from tests.units.test_models import FakeTestWithInput -# Test classes used as expected values +if TYPE_CHECKING: + from _pytest.mark.structures import ParameterSet DATA_DIR: Path = Path(__file__).parent.parent.resolve() / "data" -INIT_CATALOG_DATA: list[dict[str, Any]] = [ - { - "name": "test_catalog", - "filename": "test_catalog.yml", - "tests": [ - (VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"])), - ], - }, - { - "name": "test_catalog", - "filename": "test_catalog.json", - "file_format": "json", - "tests": [ - (VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"])), - ], - }, - { - "name": "test_catalog_with_tags", - "filename": "test_catalog_with_tags.yml", - "tests": [ +INIT_CATALOG_PARAMS: list[ParameterSet] = [ + pytest.param("test_catalog.yml", "yaml", [(VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"]))], id="test_catalog_yaml"), + pytest.param("test_catalog.json", "json", [(VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"]))], id="test_catalog_json"), + pytest.param( + "test_catalog_with_tags.yml", + "yaml", + [ ( VerifyUptime, VerifyUptime.Input( minimum=10, - filters=VerifyUptime.Input.Filters(tags={"fabric"}), + filters=VerifyUptime.Input.Filters(tags={"spine"}), ), ), ( @@ -69,189 +56,143 @@ filters=VerifyUptime.Input.Filters(tags={"leaf"}), ), ), - (VerifyReloadCause, {"filters": {"tags": ["leaf", "spine"]}}), + (VerifyReloadCause, {"filters": {"tags": ["spine", "leaf"]}}), (VerifyCoredump, VerifyCoredump.Input()), (VerifyAgentLogs, AntaTest.Input()), - (VerifyCPUUtilization, VerifyCPUUtilization.Input(filters=VerifyCPUUtilization.Input.Filters(tags={"leaf"}))), - (VerifyMemoryUtilization, VerifyMemoryUtilization.Input(filters=VerifyMemoryUtilization.Input.Filters(tags={"testdevice"}))), + (VerifyCPUUtilization, None), + (VerifyMemoryUtilization, None), (VerifyFileSystemUtilization, None), (VerifyNTP, {}), - (VerifyMlagStatus, None), - (VerifyL3MTU, {"mtu": 1500, "filters": {"tags": ["demo"]}}), + (VerifyMlagStatus, {"filters": {"tags": ["leaf"]}}), + (VerifyL3MTU, {"mtu": 1500, "filters": {"tags": ["spine"]}}), ], - }, - { - "name": "test_empty_catalog", - "filename": "test_empty_catalog.yml", - "tests": [], - }, - { - "name": "test_empty_dict_catalog", - "filename": "test_empty_dict_catalog.yml", - "tests": [], - }, + id="test_catalog_with_tags", + ), + pytest.param("test_empty_catalog.yml", "yaml", [], id="test_empty_catalog"), + pytest.param("test_empty_dict_catalog.yml", "yaml", [], id="test_empty_dict_catalog"), ] -CATALOG_PARSE_FAIL_DATA: list[dict[str, Any]] = [ - { - "name": "undefined_tests", - "filename": "test_catalog_wrong_format.toto", - "file_format": "toto", - "error": "'toto' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported.", - }, - { - "name": "invalid_json", - "filename": "test_catalog_invalid_json.json", - "file_format": "json", - "error": "JSONDecodeError", - }, - { - "name": "undefined_tests", - "filename": "test_catalog_with_undefined_tests.yml", - "error": "FakeTest is not defined in Python module anta.tests.software", - }, - { - "name": "undefined_module", - "filename": "test_catalog_with_undefined_module.yml", - "error": "Module named anta.tests.undefined cannot be imported", - }, - { - "name": "undefined_module", - "filename": "test_catalog_with_undefined_module.yml", - "error": "Module named anta.tests.undefined cannot be imported", - }, - { - "name": "syntax_error", - "filename": "test_catalog_with_syntax_error_module.yml", - "error": "Value error, Module named tests.data.syntax_error cannot be imported. Verify that the module exists and there is no Python syntax issues.", - }, - { - "name": "undefined_module_nested", - "filename": "test_catalog_with_undefined_module_nested.yml", - "error": "Module named undefined from package anta.tests cannot be imported", - }, - { - "name": "not_a_list", - "filename": "test_catalog_not_a_list.yml", - "error": "Value error, Syntax error when parsing: True\nIt must be a list of ANTA tests. Check the test catalog.", - }, - { - "name": "test_definition_not_a_dict", - "filename": "test_catalog_test_definition_not_a_dict.yml", - "error": "Value error, Syntax error when parsing: VerifyEOSVersion\nIt must be a dictionary. Check the test catalog.", - }, - { - "name": "test_definition_multiple_dicts", - "filename": "test_catalog_test_definition_multiple_dicts.yml", - "error": "Value error, Syntax error when parsing: {'VerifyEOSVersion': {'versions': ['4.25.4M', '4.26.1F']}, " - "'VerifyTerminAttrVersion': {'versions': ['4.25.4M']}}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog.", - }, - {"name": "wrong_type_after_parsing", "filename": "test_catalog_wrong_type.yml", "error": "must be a dict, got str"}, +CATALOG_PARSE_FAIL_PARAMS: list[ParameterSet] = [ + pytest.param( + "test_catalog_wrong_format.toto", + "toto", + "'toto' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported.", + id="undefined_tests", + ), + pytest.param("test_catalog_invalid_json.json", "json", "JSONDecodeError", id="invalid_json"), + pytest.param("test_catalog_with_undefined_tests.yml", "yaml", "FakeTest is not defined in Python module anta.tests.software", id="undefined_tests"), + pytest.param("test_catalog_with_undefined_module.yml", "yaml", "Module named anta.tests.undefined cannot be imported", id="undefined_module"), + pytest.param( + "test_catalog_with_syntax_error_module.yml", + "yaml", + "Value error, Module named tests.data.syntax_error cannot be imported. Verify that the module exists and there is no Python syntax issues.", + id="syntax_error", + ), + pytest.param( + "test_catalog_with_undefined_module_nested.yml", + "yaml", + "Module named undefined from package anta.tests cannot be imported", + id="undefined_module_nested", + ), + pytest.param( + "test_catalog_not_a_list.yml", + "yaml", + "Value error, Syntax error when parsing: True\nIt must be a list of ANTA tests. Check the test catalog.", + id="not_a_list", + ), + pytest.param( + "test_catalog_test_definition_not_a_dict.yml", + "yaml", + "Value error, Syntax error when parsing: VerifyEOSVersion\nIt must be a dictionary. Check the test catalog.", + id="test_definition_not_a_dict", + ), + pytest.param( + "test_catalog_test_definition_multiple_dicts.yml", + "yaml", + "Value error, Syntax error when parsing: {'VerifyEOSVersion': {'versions': ['4.25.4M', '4.26.1F']}, 'VerifyTerminAttrVersion': {'versions': ['4.25.4M']}}\n" + "It must be a dictionary with a single entry. Check the indentation in the test catalog.", + id="test_definition_multiple_dicts", + ), + pytest.param("test_catalog_wrong_type.yml", "yaml", "must be a dict, got str", id="wrong_type_after_parsing"), ] -CATALOG_FROM_DICT_FAIL_DATA: list[dict[str, Any]] = [ - { - "name": "undefined_tests", - "filename": "test_catalog_with_undefined_tests.yml", - "error": "FakeTest is not defined in Python module anta.tests.software", - }, - { - "name": "wrong_type", - "filename": "test_catalog_wrong_type.yml", - "error": "Wrong input type for catalog data, must be a dict, got str", - }, +CATALOG_FROM_DICT_FAIL_PARAMS: list[ParameterSet] = [ + pytest.param("test_catalog_with_undefined_tests.yml", "FakeTest is not defined in Python module anta.tests.software", id="undefined_tests"), + pytest.param("test_catalog_wrong_type.yml", "Wrong input type for catalog data, must be a dict, got str", id="wrong_type"), ] -CATALOG_FROM_LIST_FAIL_DATA: list[dict[str, Any]] = [ - { - "name": "wrong_inputs", - "tests": [ - ( - FakeTestWithInput, - AntaTest.Input(), - ), - ], - "error": "Test input has type AntaTest.Input but expected type FakeTestWithInput.Input", - }, - { - "name": "no_test", - "tests": [(None, None)], - "error": "Input should be a subclass of AntaTest", - }, - { - "name": "no_input_when_required", - "tests": [(FakeTestWithInput, None)], - "error": "FakeTestWithInput test inputs are not valid: 1 validation error for Input\n\tstring\n\t Field required", - }, - { - "name": "wrong_input_type", - "tests": [(FakeTestWithInput, {"string": True})], - "error": "FakeTestWithInput test inputs are not valid: 1 validation error for Input\n\tstring\n\t Input should be a valid string", - }, +CATALOG_FROM_LIST_FAIL_PARAMS: list[ParameterSet] = [ + pytest.param([(FakeTestWithInput, AntaTest.Input())], "Test input has type AntaTest.Input but expected type FakeTestWithInput.Input", id="wrong_inputs"), + pytest.param([(None, None)], "Input should be a subclass of AntaTest", id="no_test"), + pytest.param( + [(FakeTestWithInput, None)], + "FakeTestWithInput test inputs are not valid: 1 validation error for Input\n\tstring\n\t Field required", + id="no_input_when_required", + ), + pytest.param( + [(FakeTestWithInput, {"string": True})], + "FakeTestWithInput test inputs are not valid: 1 validation error for Input\n\tstring\n\t Input should be a valid string", + id="wrong_input_type", + ), ] -TESTS_SETTER_FAIL_DATA: list[dict[str, Any]] = [ - { - "name": "not_a_list", - "tests": "not_a_list", - "error": "The catalog must contain a list of tests", - }, - { - "name": "not_a_list_of_test_definitions", - "tests": [42, 43], - "error": "A test in the catalog must be an AntaTestDefinition instance", - }, +TESTS_SETTER_FAIL_PARAMS: list[ParameterSet] = [ + pytest.param("not_a_list", "The catalog must contain a list of tests", id="not_a_list"), + pytest.param([42, 43], "A test in the catalog must be an AntaTestDefinition instance", id="not_a_list_of_test_definitions"), ] class TestAntaCatalog: - """Test for anta.catalog.AntaCatalog.""" + """Tests for anta.catalog.AntaCatalog.""" - @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) - def test_parse(self, catalog_data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("filename", "file_format", "tests"), INIT_CATALOG_PARAMS) + def test_parse(self, filename: str, file_format: Literal["yaml", "json"], tests: list[tuple[type[AntaTest], AntaTest.Input | dict[str, Any] | None]]) -> None: """Instantiate AntaCatalog from a file.""" - catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / catalog_data["filename"], file_format=catalog_data.get("file_format", "yaml")) + catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / filename, file_format=file_format) - assert len(catalog.tests) == len(catalog_data["tests"]) - for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]): + assert len(catalog.tests) == len(tests) + for test_id, (test, inputs_data) in enumerate(tests): assert catalog.tests[test_id].test == test if inputs_data is not None: inputs = test.Input(**inputs_data) if isinstance(inputs_data, dict) else inputs_data assert inputs == catalog.tests[test_id].inputs - @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) - def test_from_list(self, catalog_data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("filename", "file_format", "tests"), INIT_CATALOG_PARAMS) + def test_from_list( + self, filename: str, file_format: Literal["yaml", "json"], tests: list[tuple[type[AntaTest], AntaTest.Input | dict[str, Any] | None]] + ) -> None: """Instantiate AntaCatalog from a list.""" - catalog: AntaCatalog = AntaCatalog.from_list(catalog_data["tests"]) + catalog: AntaCatalog = AntaCatalog.from_list(tests) - assert len(catalog.tests) == len(catalog_data["tests"]) - for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]): + assert len(catalog.tests) == len(tests) + for test_id, (test, inputs_data) in enumerate(tests): assert catalog.tests[test_id].test == test if inputs_data is not None: inputs = test.Input(**inputs_data) if isinstance(inputs_data, dict) else inputs_data assert inputs == catalog.tests[test_id].inputs - @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) - def test_from_dict(self, catalog_data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("filename", "file_format", "tests"), INIT_CATALOG_PARAMS) + def test_from_dict( + self, filename: str, file_format: Literal["yaml", "json"], tests: list[tuple[type[AntaTest], AntaTest.Input | dict[str, Any] | None]] + ) -> None: """Instantiate AntaCatalog from a dict.""" - file = DATA_DIR / catalog_data["filename"] - with file.open(encoding="UTF-8") as file: - file_format = catalog_data.get("file_format", "yaml") - data = safe_load(file) if file_format == "yaml" else json_load(file) + file = DATA_DIR / filename + with file.open(encoding="UTF-8") as f: + data = safe_load(f) if file_format == "yaml" else json_load(f) catalog: AntaCatalog = AntaCatalog.from_dict(data) - assert len(catalog.tests) == len(catalog_data["tests"]) - for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]): + assert len(catalog.tests) == len(tests) + for test_id, (test, inputs_data) in enumerate(tests): assert catalog.tests[test_id].test == test if inputs_data is not None: inputs = test.Input(**inputs_data) if isinstance(inputs_data, dict) else inputs_data assert inputs == catalog.tests[test_id].inputs - @pytest.mark.parametrize("catalog_data", CATALOG_PARSE_FAIL_DATA, ids=generate_test_ids_list(CATALOG_PARSE_FAIL_DATA)) - def test_parse_fail(self, catalog_data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("filename", "file_format", "error"), CATALOG_PARSE_FAIL_PARAMS) + def test_parse_fail(self, filename: str, file_format: Literal["yaml", "json"], error: str) -> None: """Errors when instantiating AntaCatalog from a file.""" with pytest.raises((ValidationError, TypeError, ValueError, OSError)) as exec_info: - AntaCatalog.parse(DATA_DIR / catalog_data["filename"], file_format=catalog_data.get("file_format", "yaml")) + AntaCatalog.parse(DATA_DIR / filename, file_format=file_format) if isinstance(exec_info.value, ValidationError): - assert catalog_data["error"] in exec_info.value.errors()[0]["msg"] + assert error in exec_info.value.errors()[0]["msg"] else: - assert catalog_data["error"] in str(exec_info) + assert error in str(exec_info) def test_parse_fail_parsing(self, caplog: pytest.LogCaptureFixture) -> None: """Errors when instantiating AntaCatalog from a file.""" @@ -263,25 +204,25 @@ def test_parse_fail_parsing(self, caplog: pytest.LogCaptureFixture) -> None: assert "Unable to parse ANTA Test Catalog file" in message assert "FileNotFoundError: [Errno 2] No such file or directory" in message - @pytest.mark.parametrize("catalog_data", CATALOG_FROM_LIST_FAIL_DATA, ids=generate_test_ids_list(CATALOG_FROM_LIST_FAIL_DATA)) - def test_from_list_fail(self, catalog_data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("tests", "error"), CATALOG_FROM_LIST_FAIL_PARAMS) + def test_from_list_fail(self, tests: list[tuple[type[AntaTest], AntaTest.Input | dict[str, Any] | None]], error: str) -> None: """Errors when instantiating AntaCatalog from a list of tuples.""" with pytest.raises(ValidationError) as exec_info: - AntaCatalog.from_list(catalog_data["tests"]) - assert catalog_data["error"] in exec_info.value.errors()[0]["msg"] + AntaCatalog.from_list(tests) + assert error in exec_info.value.errors()[0]["msg"] - @pytest.mark.parametrize("catalog_data", CATALOG_FROM_DICT_FAIL_DATA, ids=generate_test_ids_list(CATALOG_FROM_DICT_FAIL_DATA)) - def test_from_dict_fail(self, catalog_data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("filename", "error"), CATALOG_FROM_DICT_FAIL_PARAMS) + def test_from_dict_fail(self, filename: str, error: str) -> None: """Errors when instantiating AntaCatalog from a list of tuples.""" - file = DATA_DIR / catalog_data["filename"] - with file.open(encoding="UTF-8") as file: - data = safe_load(file) + file = DATA_DIR / filename + with file.open(encoding="UTF-8") as f: + data = safe_load(f) with pytest.raises((ValidationError, TypeError)) as exec_info: AntaCatalog.from_dict(data) if isinstance(exec_info.value, ValidationError): - assert catalog_data["error"] in exec_info.value.errors()[0]["msg"] + assert error in exec_info.value.errors()[0]["msg"] else: - assert catalog_data["error"] in str(exec_info) + assert error in str(exec_info) def test_filename(self) -> None: """Test filename.""" @@ -290,31 +231,36 @@ def test_filename(self) -> None: catalog = AntaCatalog(filename=Path("test")) assert catalog.filename == Path("test") - @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) - def test__tests_setter_success(self, catalog_data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("filename", "file_format", "tests"), INIT_CATALOG_PARAMS) + def test__tests_setter_success( + self, + filename: str, + file_format: Literal["yaml", "json"], + tests: list[tuple[type[AntaTest], AntaTest.Input | dict[str, Any] | None]], + ) -> None: """Success when setting AntaCatalog.tests from a list of tuples.""" catalog = AntaCatalog() - catalog.tests = [AntaTestDefinition(test=test, inputs=inputs) for test, inputs in catalog_data["tests"]] - assert len(catalog.tests) == len(catalog_data["tests"]) - for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]): + catalog.tests = [AntaTestDefinition(test=test, inputs=inputs) for test, inputs in tests] + assert len(catalog.tests) == len(tests) + for test_id, (test, inputs_data) in enumerate(tests): assert catalog.tests[test_id].test == test if inputs_data is not None: inputs = test.Input(**inputs_data) if isinstance(inputs_data, dict) else inputs_data assert inputs == catalog.tests[test_id].inputs - @pytest.mark.parametrize("catalog_data", TESTS_SETTER_FAIL_DATA, ids=generate_test_ids_list(TESTS_SETTER_FAIL_DATA)) - def test__tests_setter_fail(self, catalog_data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("tests", "error"), TESTS_SETTER_FAIL_PARAMS) + def test__tests_setter_fail(self, tests: list[Any], error: str) -> None: """Errors when setting AntaCatalog.tests from a list of tuples.""" catalog = AntaCatalog() with pytest.raises(TypeError) as exec_info: - catalog.tests = catalog_data["tests"] - assert catalog_data["error"] in str(exec_info) + catalog.tests = tests + assert error in str(exec_info) def test_build_indexes_all(self) -> None: """Test AntaCatalog.build_indexes().""" catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_with_tags.yml") catalog.build_indexes() - assert len(catalog.tests_without_tags) == 5 + assert len(catalog.tests_without_tags) == 6 assert "leaf" in catalog.tag_to_tests assert len(catalog.tag_to_tests["leaf"]) == 3 all_unique_tests = catalog.tests_without_tags diff --git a/tests/units/test_device.py b/tests/units/test_device.py index d3c50cc8e..62c16c9ef 100644 --- a/tests/units/test_device.py +++ b/tests/units/test_device.py @@ -10,129 +10,51 @@ from typing import TYPE_CHECKING, Any from unittest.mock import patch -import httpx import pytest from asyncssh import SSHClientConnection, SSHClientConnectionOptions +from httpx import ConnectError, HTTPError from rich import print as rprint -import asynceapi from anta.device import AntaDevice, AsyncEOSDevice from anta.models import AntaCommand -from tests.lib.fixture import COMMAND_OUTPUT -from tests.lib.utils import generate_test_ids_list +from asynceapi import EapiCommandError +from tests.units.conftest import COMMAND_OUTPUT if TYPE_CHECKING: from _pytest.mark.structures import ParameterSet -INIT_DATA: list[dict[str, Any]] = [ - { - "name": "no name, no port", - "device": { - "host": "42.42.42.42", - "username": "anta", - "password": "anta", - }, - "expected": {"name": "42.42.42.42"}, - }, - { - "name": "no name, port", - "device": { - "host": "42.42.42.42", - "username": "anta", - "password": "anta", - "port": 666, - }, - "expected": {"name": "42.42.42.42:666"}, - }, - { - "name": "name", - "device": { - "host": "42.42.42.42", - "username": "anta", - "password": "anta", - "name": "test.anta.ninja", - "disable_cache": True, - }, - "expected": {"name": "test.anta.ninja"}, - }, - { - "name": "insecure", - "device": { - "host": "42.42.42.42", - "username": "anta", - "password": "anta", - "name": "test.anta.ninja", - "insecure": True, - }, - "expected": {"name": "test.anta.ninja"}, - }, +INIT_PARAMS: list[ParameterSet] = [ + pytest.param({"host": "42.42.42.42", "username": "anta", "password": "anta"}, {"name": "42.42.42.42"}, id="no name, no port"), + pytest.param({"host": "42.42.42.42", "username": "anta", "password": "anta", "port": 666}, {"name": "42.42.42.42:666"}, id="no name, port"), + pytest.param( + {"host": "42.42.42.42", "username": "anta", "password": "anta", "name": "test.anta.ninja", "disable_cache": True}, {"name": "test.anta.ninja"}, id="name" + ), + pytest.param( + {"host": "42.42.42.42", "username": "anta", "password": "anta", "name": "test.anta.ninja", "insecure": True}, {"name": "test.anta.ninja"}, id="insecure" + ), ] -EQUALITY_DATA: list[dict[str, Any]] = [ - { - "name": "equal", - "device1": { - "host": "42.42.42.42", - "username": "anta", - "password": "anta", - }, - "device2": { - "host": "42.42.42.42", - "username": "anta", - "password": "blah", - }, - "expected": True, - }, - { - "name": "equals-name", - "device1": { - "host": "42.42.42.42", - "username": "anta", - "password": "anta", - "name": "device1", - }, - "device2": { - "host": "42.42.42.42", - "username": "plop", - "password": "anta", - "name": "device2", - }, - "expected": True, - }, - { - "name": "not-equal-port", - "device1": { - "host": "42.42.42.42", - "username": "anta", - "password": "anta", - }, - "device2": { - "host": "42.42.42.42", - "username": "anta", - "password": "anta", - "port": 666, - }, - "expected": False, - }, - { - "name": "not-equal-host", - "device1": { - "host": "42.42.42.41", - "username": "anta", - "password": "anta", - }, - "device2": { - "host": "42.42.42.42", - "username": "anta", - "password": "anta", - }, - "expected": False, - }, +EQUALITY_PARAMS: list[ParameterSet] = [ + pytest.param({"host": "42.42.42.42", "username": "anta", "password": "anta"}, {"host": "42.42.42.42", "username": "anta", "password": "blah"}, True, id="equal"), + pytest.param( + {"host": "42.42.42.42", "username": "anta", "password": "anta", "name": "device1"}, + {"host": "42.42.42.42", "username": "plop", "password": "anta", "name": "device2"}, + True, + id="equals-name", + ), + pytest.param( + {"host": "42.42.42.42", "username": "anta", "password": "anta"}, + {"host": "42.42.42.42", "username": "anta", "password": "anta", "port": 666}, + False, + id="not-equal-port", + ), + pytest.param( + {"host": "42.42.42.41", "username": "anta", "password": "anta"}, {"host": "42.42.42.42", "username": "anta", "password": "anta"}, False, id="not-equal-host" + ), ] -ASYNCEAPI_COLLECT_DATA: list[dict[str, Any]] = [ - { - "name": "command", - "device": {}, - "command": { +ASYNCEAPI_COLLECT_PARAMS: list[ParameterSet] = [ + pytest.param( + {}, + { "command": "show version", "patch_kwargs": { "return_value": [ @@ -155,11 +77,11 @@ "memTotal": 8099732, "memFree": 4989568, "isIntlVersion": False, - }, - ], + } + ] }, }, - "expected": { + { "output": { "mfgName": "Arista", "modelName": "DCS-7280CR3-32P4-F", @@ -182,11 +104,11 @@ }, "errors": [], }, - }, - { - "name": "enable", - "device": {"enable": True}, - "command": { + id="command", + ), + pytest.param( + {"enable": True}, + { "command": "show version", "patch_kwargs": { "return_value": [ @@ -211,10 +133,10 @@ "memFree": 4989568, "isIntlVersion": False, }, - ], + ] }, }, - "expected": { + { "output": { "mfgName": "Arista", "modelName": "DCS-7280CR3-32P4-F", @@ -237,11 +159,11 @@ }, "errors": [], }, - }, - { - "name": "enable password", - "device": {"enable": True, "enable_password": "anta"}, - "command": { + id="enable", + ), + pytest.param( + {"enable": True, "enable_password": "anta"}, + { "command": "show version", "patch_kwargs": { "return_value": [ @@ -266,10 +188,10 @@ "memFree": 4989568, "isIntlVersion": False, }, - ], + ] }, }, - "expected": { + { "output": { "mfgName": "Arista", "modelName": "DCS-7280CR3-32P4-F", @@ -292,11 +214,11 @@ }, "errors": [], }, - }, - { - "name": "revision", - "device": {}, - "command": { + id="enable password", + ), + pytest.param( + {}, + { "command": "show version", "revision": 3, "patch_kwargs": { @@ -322,10 +244,10 @@ "memFree": 4989568, "isIntlVersion": False, }, - ], + ] }, }, - "expected": { + { "output": { "mfgName": "Arista", "modelName": "DCS-7280CR3-32P4-F", @@ -348,77 +270,47 @@ }, "errors": [], }, - }, - { - "name": "asynceapi.EapiCommandError", - "device": {}, - "command": { + id="revision", + ), + pytest.param( + {}, + { "command": "show version", "patch_kwargs": { - "side_effect": asynceapi.EapiCommandError( + "side_effect": EapiCommandError( passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[], - ), + ) }, }, - "expected": {"output": None, "errors": ["Authorization denied for command 'show version'"]}, - }, - { - "name": "httpx.HTTPError", - "device": {}, - "command": { - "command": "show version", - "patch_kwargs": {"side_effect": httpx.HTTPError(message="404")}, - }, - "expected": {"output": None, "errors": ["HTTPError: 404"]}, - }, - { - "name": "httpx.ConnectError", - "device": {}, - "command": { - "command": "show version", - "patch_kwargs": {"side_effect": httpx.ConnectError(message="Cannot open port")}, - }, - "expected": {"output": None, "errors": ["ConnectError: Cannot open port"]}, - }, + {"output": None, "errors": ["Authorization denied for command 'show version'"]}, + id="asynceapi.EapiCommandError", + ), + pytest.param( + {}, + {"command": "show version", "patch_kwargs": {"side_effect": HTTPError("404")}}, + {"output": None, "errors": ["HTTPError: 404"]}, + id="httpx.HTTPError", + ), + pytest.param( + {}, + {"command": "show version", "patch_kwargs": {"side_effect": ConnectError("Cannot open port")}}, + {"output": None, "errors": ["ConnectError: Cannot open port"]}, + id="httpx.ConnectError", + ), ] -ASYNCEAPI_COPY_DATA: list[dict[str, Any]] = [ - { - "name": "from", - "device": {}, - "copy": { - "sources": [Path("/mnt/flash"), Path("/var/log/agents")], - "destination": Path(), - "direction": "from", - }, - }, - { - "name": "to", - "device": {}, - "copy": { - "sources": [Path("/mnt/flash"), Path("/var/log/agents")], - "destination": Path(), - "direction": "to", - }, - }, - { - "name": "wrong", - "device": {}, - "copy": { - "sources": [Path("/mnt/flash"), Path("/var/log/agents")], - "destination": Path(), - "direction": "wrong", - }, - }, +ASYNCEAPI_COPY_PARAMS: list[ParameterSet] = [ + pytest.param({}, {"sources": [Path("/mnt/flash"), Path("/var/log/agents")], "destination": Path(), "direction": "from"}, id="from"), + pytest.param({}, {"sources": [Path("/mnt/flash"), Path("/var/log/agents")], "destination": Path(), "direction": "to"}, id="to"), + pytest.param({}, {"sources": [Path("/mnt/flash"), Path("/var/log/agents")], "destination": Path(), "direction": "wrong"}, id="wrong"), ] -REFRESH_DATA: list[dict[str, Any]] = [ - { - "name": "established", - "device": {}, - "patch_kwargs": ( +REFRESH_PARAMS: list[ParameterSet] = [ + pytest.param( + {}, + ( {"return_value": True}, { "return_value": [ @@ -442,15 +334,15 @@ "memFree": 4989568, "isIntlVersion": False, } - ], + ] }, ), - "expected": {"is_online": True, "established": True, "hw_model": "DCS-7280CR3-32P4-F"}, - }, - { - "name": "is not online", - "device": {}, - "patch_kwargs": ( + {"is_online": True, "established": True, "hw_model": "DCS-7280CR3-32P4-F"}, + id="established", + ), + pytest.param( + {}, + ( {"return_value": False}, { "return_value": { @@ -472,15 +364,15 @@ "memTotal": 8099732, "memFree": 4989568, "isIntlVersion": False, - }, + } }, ), - "expected": {"is_online": False, "established": False, "hw_model": None}, - }, - { - "name": "cannot parse command", - "device": {}, - "patch_kwargs": ( + {"is_online": False, "established": False, "hw_model": None}, + id="is not online", + ), + pytest.param( + {}, + ( {"return_value": True}, { "return_value": [ @@ -503,108 +395,71 @@ "memFree": 4989568, "isIntlVersion": False, } - ], + ] }, ), - "expected": {"is_online": True, "established": False, "hw_model": None}, - }, - { - "name": "asynceapi.EapiCommandError", - "device": {}, - "patch_kwargs": ( + {"is_online": True, "established": False, "hw_model": None}, + id="cannot parse command", + ), + pytest.param( + {}, + ( {"return_value": True}, { - "side_effect": asynceapi.EapiCommandError( + "side_effect": EapiCommandError( passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[], - ), + ) }, ), - "expected": {"is_online": True, "established": False, "hw_model": None}, - }, - { - "name": "httpx.HTTPError", - "device": {}, - "patch_kwargs": ( - {"return_value": True}, - {"side_effect": httpx.HTTPError(message="404")}, - ), - "expected": {"is_online": True, "established": False, "hw_model": None}, - }, - { - "name": "httpx.ConnectError", - "device": {}, - "patch_kwargs": ( - {"return_value": True}, - {"side_effect": httpx.ConnectError(message="Cannot open port")}, - ), - "expected": {"is_online": True, "established": False, "hw_model": None}, - }, + {"is_online": True, "established": False, "hw_model": None}, + id="asynceapi.EapiCommandError", + ), + pytest.param( + {}, + ({"return_value": True}, {"side_effect": HTTPError("404")}), + {"is_online": True, "established": False, "hw_model": None}, + id="httpx.HTTPError", + ), + pytest.param( + {}, + ({"return_value": True}, {"side_effect": ConnectError("Cannot open port")}), + {"is_online": True, "established": False, "hw_model": None}, + id="httpx.ConnectError", + ), ] -COLLECT_DATA: list[dict[str, Any]] = [ - { - "name": "device cache enabled, command cache enabled, no cache hit", - "device": {"disable_cache": False}, - "command": { - "command": "show version", - "use_cache": True, - }, - "expected": {"cache_hit": False}, - }, - { - "name": "device cache enabled, command cache enabled, cache hit", - "device": {"disable_cache": False}, - "command": { - "command": "show version", - "use_cache": True, - }, - "expected": {"cache_hit": True}, - }, - { - "name": "device cache disabled, command cache enabled", - "device": {"disable_cache": True}, - "command": { - "command": "show version", - "use_cache": True, - }, - "expected": {}, - }, - { - "name": "device cache enabled, command cache disabled, cache has command", - "device": {"disable_cache": False}, - "command": { - "command": "show version", - "use_cache": False, - }, - "expected": {"cache_hit": True}, - }, - { - "name": "device cache enabled, command cache disabled, cache does not have data", - "device": { - "disable_cache": False, - }, - "command": { - "command": "show version", - "use_cache": False, - }, - "expected": {"cache_hit": False}, - }, - { - "name": "device cache disabled, command cache disabled", - "device": { - "disable_cache": True, - }, - "command": { - "command": "show version", - "use_cache": False, - }, - "expected": {}, - }, +COLLECT_PARAMS: list[ParameterSet] = [ + pytest.param( + {"disable_cache": False}, + {"command": "show version", "use_cache": True}, + {"cache_hit": False}, + id="device cache enabled, command cache enabled, no cache hit", + ), + pytest.param( + {"disable_cache": False}, + {"command": "show version", "use_cache": True}, + {"cache_hit": True}, + id="device cache enabled, command cache enabled, cache hit", + ), + pytest.param({"disable_cache": True}, {"command": "show version", "use_cache": True}, {}, id="device cache disabled, command cache enabled"), + pytest.param( + {"disable_cache": False}, + {"command": "show version", "use_cache": False}, + {"cache_hit": True}, + id="device cache enabled, command cache disabled, cache has command", + ), + pytest.param( + {"disable_cache": False}, + {"command": "show version", "use_cache": False}, + {"cache_hit": False}, + id="device cache enabled, command cache disabled, cache does not have data", + ), + pytest.param({"disable_cache": True}, {"command": "show version", "use_cache": False}, {}, id="device cache disabled, command cache disabled"), ] -CACHE_STATS_DATA: list[ParameterSet] = [ +CACHE_STATS_PARAMS: list[ParameterSet] = [ pytest.param({"disable_cache": False}, {"total_commands_sent": 0, "cache_hits": 0, "cache_hit_ratio": "0.00%"}, id="with_cache"), pytest.param({"disable_cache": True}, None, id="without_cache"), ] @@ -613,48 +468,42 @@ class TestAntaDevice: """Test for anta.device.AntaDevice Abstract class.""" - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("device", "command_data", "expected_data"), - ((d["device"], d["command"], d["expected"]) for d in COLLECT_DATA), - indirect=["device"], - ids=generate_test_ids_list(COLLECT_DATA), - ) - async def test_collect(self, device: AntaDevice, command_data: dict[str, Any], expected_data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("device", "command", "expected"), COLLECT_PARAMS, indirect=["device"]) + async def test_collect(self, device: AntaDevice, command: dict[str, Any], expected: dict[str, Any]) -> None: """Test AntaDevice.collect behavior.""" - command = AntaCommand(command=command_data["command"], use_cache=command_data["use_cache"]) + cmd = AntaCommand(command=command["command"], use_cache=command["use_cache"]) # Dummy output for cache hit cached_output = "cached_value" - if device.cache is not None and expected_data["cache_hit"] is True: - await device.cache.set(command.uid, cached_output) + if device.cache is not None and expected["cache_hit"] is True: + await device.cache.set(cmd.uid, cached_output) - await device.collect(command) + await device.collect(cmd) if device.cache is not None: # device_cache is enabled - current_cached_data = await device.cache.get(command.uid) - if command.use_cache is True: # command is allowed to use cache - if expected_data["cache_hit"] is True: - assert command.output == cached_output + current_cached_data = await device.cache.get(cmd.uid) + if cmd.use_cache is True: # command is allowed to use cache + if expected["cache_hit"] is True: + assert cmd.output == cached_output assert current_cached_data == cached_output assert device.cache.hit_miss_ratio["hits"] == 2 else: - assert command.output == COMMAND_OUTPUT + assert cmd.output == COMMAND_OUTPUT assert current_cached_data == COMMAND_OUTPUT assert device.cache.hit_miss_ratio["hits"] == 1 else: # command is not allowed to use cache - device._collect.assert_called_once_with(command=command, collection_id=None) # type: ignore[attr-defined] # pylint: disable=protected-access - assert command.output == COMMAND_OUTPUT - if expected_data["cache_hit"] is True: + device._collect.assert_called_once_with(command=cmd, collection_id=None) # type: ignore[attr-defined] + assert cmd.output == COMMAND_OUTPUT + if expected["cache_hit"] is True: assert current_cached_data == cached_output else: assert current_cached_data is None else: # device is disabled assert device.cache is None - device._collect.assert_called_once_with(command=command, collection_id=None) # type: ignore[attr-defined] # pylint: disable=protected-access + device._collect.assert_called_once_with(command=cmd, collection_id=None) # type: ignore[attr-defined] - @pytest.mark.parametrize(("device", "expected"), CACHE_STATS_DATA, indirect=["device"]) + @pytest.mark.parametrize(("device", "expected"), CACHE_STATS_PARAMS, indirect=["device"]) def test_cache_statistics(self, device: AntaDevice, expected: dict[str, Any] | None) -> None: """Verify that when cache statistics attribute does not exist. @@ -666,42 +515,39 @@ def test_cache_statistics(self, device: AntaDevice, expected: dict[str, Any] | N class TestAsyncEOSDevice: """Test for anta.device.AsyncEOSDevice.""" - @pytest.mark.parametrize("data", INIT_DATA, ids=generate_test_ids_list(INIT_DATA)) - def test__init__(self, data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("device", "expected"), INIT_PARAMS) + def test__init__(self, device: dict[str, Any], expected: dict[str, Any]) -> None: """Test the AsyncEOSDevice constructor.""" - device = AsyncEOSDevice(**data["device"]) + dev = AsyncEOSDevice(**device) - assert device.name == data["expected"]["name"] - if data["device"].get("disable_cache") is True: - assert device.cache is None - assert device.cache_locks is None + assert dev.name == expected["name"] + if device.get("disable_cache") is True: + assert dev.cache is None + assert dev.cache_locks is None else: # False or None - assert device.cache is not None - assert device.cache_locks is not None - hash(device) + assert dev.cache is not None + assert dev.cache_locks is not None + hash(dev) with patch("anta.device.__DEBUG__", new=True): - rprint(device) + rprint(dev) - @pytest.mark.parametrize("data", EQUALITY_DATA, ids=generate_test_ids_list(EQUALITY_DATA)) - def test__eq(self, data: dict[str, Any]) -> None: + @pytest.mark.parametrize(("device1", "device2", "expected"), EQUALITY_PARAMS) + def test__eq(self, device1: dict[str, Any], device2: dict[str, Any], expected: bool) -> None: """Test the AsyncEOSDevice equality.""" - device1 = AsyncEOSDevice(**data["device1"]) - device2 = AsyncEOSDevice(**data["device2"]) - if data["expected"]: - assert device1 == device2 + dev1 = AsyncEOSDevice(**device1) + dev2 = AsyncEOSDevice(**device2) + if expected: + assert dev1 == dev2 else: - assert device1 != device2 + assert dev1 != dev2 - @pytest.mark.asyncio @pytest.mark.parametrize( ("async_device", "patch_kwargs", "expected"), - ((d["device"], d["patch_kwargs"], d["expected"]) for d in REFRESH_DATA), - ids=generate_test_ids_list(REFRESH_DATA), + REFRESH_PARAMS, indirect=["async_device"], ) async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[dict[str, Any]], expected: dict[str, Any]) -> None: - # pylint: disable=protected-access """Test AsyncEOSDevice.refresh().""" with patch.object(async_device._session, "check_connection", **patch_kwargs[0]), patch.object(async_device._session, "cli", **patch_kwargs[1]): await async_device.refresh() @@ -712,15 +558,12 @@ async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[di assert async_device.established == expected["established"] assert async_device.hw_model == expected["hw_model"] - @pytest.mark.asyncio @pytest.mark.parametrize( ("async_device", "command", "expected"), - ((d["device"], d["command"], d["expected"]) for d in ASYNCEAPI_COLLECT_DATA), - ids=generate_test_ids_list(ASYNCEAPI_COLLECT_DATA), + ASYNCEAPI_COLLECT_PARAMS, indirect=["async_device"], ) async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, Any], expected: dict[str, Any]) -> None: - # pylint: disable=protected-access """Test AsyncEOSDevice._collect().""" cmd = AntaCommand(command=command["command"], revision=command["revision"]) if "revision" in command else AntaCommand(command=command["command"]) with patch.object(async_device._session, "cli", **command["patch_kwargs"]): @@ -741,15 +584,13 @@ async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, A commands.append({"cmd": cmd.command, "revision": cmd.revision}) else: commands.append({"cmd": cmd.command}) - async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version, req_id=f"ANTA-{collection_id}-{id(cmd)}") # type: ignore[attr-defined] # asynceapi.Device.cli is patched # pylint: disable=line-too-long + async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version, req_id=f"ANTA-{collection_id}-{id(cmd)}") # type: ignore[attr-defined] # asynceapi.Device.cli is patched assert cmd.output == expected["output"] assert cmd.errors == expected["errors"] - @pytest.mark.asyncio @pytest.mark.parametrize( ("async_device", "copy"), - ((d["device"], d["copy"]) for d in ASYNCEAPI_COPY_DATA), - ids=generate_test_ids_list(ASYNCEAPI_COPY_DATA), + ASYNCEAPI_COPY_PARAMS, indirect=["async_device"], ) async def test_copy(self, async_device: AsyncEOSDevice, copy: dict[str, Any]) -> None: diff --git a/tests/units/test_logger.py b/tests/units/test_logger.py index d9b7c7672..706591fac 100644 --- a/tests/units/test_logger.py +++ b/tests/units/test_logger.py @@ -58,7 +58,6 @@ def test_anta_log_exception( debug_value: bool, expected_message: str, ) -> None: - # pylint: disable=too-many-arguments """Test anta_log_exception.""" if calling_logger is not None: # https://github.com/pytest-dev/pytest/issues/3697 diff --git a/tests/units/test_models.py b/tests/units/test_models.py index 180f6bfe5..d604b4835 100644 --- a/tests/units/test_models.py +++ b/tests/units/test_models.py @@ -8,14 +8,16 @@ from __future__ import annotations import asyncio +import sys from typing import TYPE_CHECKING, Any, ClassVar import pytest from anta.decorators import deprecated_test, skip_on_platforms from anta.models import AntaCommand, AntaTemplate, AntaTest -from tests.lib.fixture import DEVICE_HW_MODEL -from tests.lib.utils import generate_test_ids +from anta.result_manager.models import AntaTestStatus +from tests.units.anta_tests.conftest import build_test_id +from tests.units.conftest import DEVICE_HW_MODEL if TYPE_CHECKING: from anta.device import AntaDevice @@ -302,6 +304,15 @@ def test(self) -> None: self.result.is_success() +class FakeTestWithMissingTest(AntaTest): + """ANTA test with missing test() method implementation.""" + + name = "FakeTestWithMissingTest" + description = "ANTA test with missing test() method implementation" + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] + + ANTATEST_DATA: list[dict[str, Any]] = [ { "name": "no input", @@ -507,17 +518,17 @@ def test(self) -> None: }, ] +BLACKLIST_COMMANDS_PARAMS = ["reload", "reload --force", "write", "wr mem"] + class TestAntaTest: """Test for anta.models.AntaTest.""" - def test__init_subclass__name(self) -> None: + def test__init_subclass__(self) -> None: """Test __init_subclass__.""" - # Pylint detects all the classes in here as unused which is on purpose - # pylint: disable=unused-variable with pytest.raises(NotImplementedError) as exec_info: - class WrongTestNoName(AntaTest): + class _WrongTestNoName(AntaTest): """ANTA test that is missing a name.""" description = "ANTA test that is missing a name" @@ -528,11 +539,11 @@ class WrongTestNoName(AntaTest): def test(self) -> None: self.result.is_success() - assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoName is missing required class attribute name" + assert exec_info.value.args[0] == "Class tests.units.test_models._WrongTestNoName is missing required class attribute name" with pytest.raises(NotImplementedError) as exec_info: - class WrongTestNoDescription(AntaTest): + class _WrongTestNoDescription(AntaTest): """ANTA test that is missing a description.""" name = "WrongTestNoDescription" @@ -543,11 +554,11 @@ class WrongTestNoDescription(AntaTest): def test(self) -> None: self.result.is_success() - assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoDescription is missing required class attribute description" + assert exec_info.value.args[0] == "Class tests.units.test_models._WrongTestNoDescription is missing required class attribute description" with pytest.raises(NotImplementedError) as exec_info: - class WrongTestNoCategories(AntaTest): + class _WrongTestNoCategories(AntaTest): """ANTA test that is missing categories.""" name = "WrongTestNoCategories" @@ -558,11 +569,11 @@ class WrongTestNoCategories(AntaTest): def test(self) -> None: self.result.is_success() - assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoCategories is missing required class attribute categories" + assert exec_info.value.args[0] == "Class tests.units.test_models._WrongTestNoCategories is missing required class attribute categories" with pytest.raises(NotImplementedError) as exec_info: - class WrongTestNoCommands(AntaTest): + class _WrongTestNoCommands(AntaTest): """ANTA test that is missing commands.""" name = "WrongTestNoCommands" @@ -573,22 +584,34 @@ class WrongTestNoCommands(AntaTest): def test(self) -> None: self.result.is_success() - assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoCommands is missing required class attribute commands" + assert exec_info.value.args[0] == "Class tests.units.test_models._WrongTestNoCommands is missing required class attribute commands" + + def test_abc(self) -> None: + """Test that an error is raised if AntaTest is not implemented.""" + with pytest.raises(TypeError) as exec_info: + FakeTestWithMissingTest() # type: ignore[abstract,call-arg] + msg = ( + "Can't instantiate abstract class FakeTestWithMissingTest without an implementation for abstract method 'test'" + if sys.version_info >= (3, 12) + else "Can't instantiate abstract class FakeTestWithMissingTest with abstract method test" + ) + assert exec_info.value.args[0] == msg def _assert_test(self, test: AntaTest, expected: dict[str, Any]) -> None: assert test.result.result == expected["result"] if "messages" in expected: + assert len(test.result.messages) == len(expected["messages"]) for result_msg, expected_msg in zip(test.result.messages, expected["messages"]): # NOTE: zip(strict=True) has been added in Python 3.10 assert expected_msg in result_msg - @pytest.mark.parametrize("data", ANTATEST_DATA, ids=generate_test_ids(ANTATEST_DATA)) + @pytest.mark.parametrize("data", ANTATEST_DATA, ids=build_test_id) def test__init__(self, device: AntaDevice, data: dict[str, Any]) -> None: """Test the AntaTest constructor.""" expected = data["expected"]["__init__"] test = data["test"](device, inputs=data["inputs"]) self._assert_test(test, expected) - @pytest.mark.parametrize("data", ANTATEST_DATA, ids=generate_test_ids(ANTATEST_DATA)) + @pytest.mark.parametrize("data", ANTATEST_DATA, ids=build_test_id) def test_test(self, device: AntaDevice, data: dict[str, Any]) -> None: """Test the AntaTest.test method.""" expected = data["expected"]["test"] @@ -596,38 +619,42 @@ def test_test(self, device: AntaDevice, data: dict[str, Any]) -> None: asyncio.run(test.test()) self._assert_test(test, expected) + @pytest.mark.parametrize("command", BLACKLIST_COMMANDS_PARAMS) + def test_blacklist(self, device: AntaDevice, command: str) -> None: + """Test that blacklisted commands are not collected.""" -ANTATEST_BLACKLIST_DATA = ["reload", "reload --force", "write", "wr mem"] - - -@pytest.mark.parametrize("data", ANTATEST_BLACKLIST_DATA) -def test_blacklist(device: AntaDevice, data: str) -> None: - """Test for blacklisting function.""" + class FakeTestWithBlacklist(AntaTest): + """Fake Test for blacklist.""" - class FakeTestWithBlacklist(AntaTest): - """Fake Test for blacklist.""" + name = "FakeTestWithBlacklist" + description = "ANTA test that has blacklisted command" + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command=command)] - name = "FakeTestWithBlacklist" - description = "ANTA test that has blacklisted command" - categories: ClassVar[list[str]] = [] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command=data)] + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() - @AntaTest.anta_test - def test(self) -> None: - self.result.is_success() - - test_instance = FakeTestWithBlacklist(device) + test = FakeTestWithBlacklist(device) + asyncio.run(test.test()) + assert test.result.result == AntaTestStatus.ERROR + assert f"<{command}> is blocked for security reason" in test.result.messages + assert test.instance_commands[0].collected is False - # Run the test() method - asyncio.run(test_instance.test()) - assert test_instance.result.result == "error" + def test_result_overwrite(self, device: AntaDevice) -> None: + """Test the AntaTest.Input.ResultOverwrite model.""" + test = FakeTest(device, inputs={"result_overwrite": {"categories": ["hardware"], "description": "a description", "custom_field": "a custom field"}}) + asyncio.run(test.test()) + assert test.result.result == AntaTestStatus.SUCCESS + assert "hardware" in test.result.categories + assert test.result.description == "a description" + assert test.result.custom_field == "a custom field" class TestAntaComamnd: """Test for anta.models.AntaCommand.""" # ruff: noqa: B018 - # pylint: disable=pointless-statement def test_empty_output_access(self) -> None: """Test for both json and text ofmt.""" @@ -656,16 +683,20 @@ def test_wrong_format_output_access(self) -> None: text_cmd_2.json_output def test_supported(self) -> None: - """Test if the supported property.""" + """Test the supported property.""" command = AntaCommand(command="show hardware counter drop", errors=["Unavailable command (not supported on this hardware platform) (at token 2: 'counter')"]) assert command.supported is False command = AntaCommand( command="show hardware counter drop", output={"totalAdverseDrops": 0, "totalCongestionDrops": 0, "totalPacketProcessorDrops": 0, "dropEvents": {}} ) assert command.supported is True + command = AntaCommand(command="show hardware counter drop") + with pytest.raises(RuntimeError) as exec_info: + command.supported + assert exec_info.value.args[0] == "Command 'show hardware counter drop' has not been collected and has not returned an error. Call AntaDevice.collect()." def test_requires_privileges(self) -> None: - """Test if the requires_privileges property.""" + """Test the requires_privileges property.""" command = AntaCommand(command="show aaa methods accounting", errors=["Invalid input (privileged mode required)"]) assert command.requires_privileges is True command = AntaCommand( @@ -678,3 +709,7 @@ def test_requires_privileges(self) -> None: }, ) assert command.requires_privileges is False + command = AntaCommand(command="show aaa methods accounting") + with pytest.raises(RuntimeError) as exec_info: + command.requires_privileges + assert exec_info.value.args[0] == "Command 'show aaa methods accounting' has not been collected and has not returned an error. Call AntaDevice.collect()." diff --git a/tests/units/test_runner.py b/tests/units/test_runner.py index 53d0bf758..b80259cc3 100644 --- a/tests/units/test_runner.py +++ b/tests/units/test_runner.py @@ -7,73 +7,62 @@ import logging import resource +import sys from pathlib import Path from unittest.mock import patch import pytest -from anta import logger from anta.catalog import AntaCatalog from anta.inventory import AntaInventory from anta.result_manager import ResultManager from anta.runner import adjust_rlimit_nofile, main, prepare_tests -from .test_models import FakeTest +from .test_models import FakeTest, FakeTestWithMissingTest DATA_DIR: Path = Path(__file__).parent.parent.resolve() / "data" FAKE_CATALOG: AntaCatalog = AntaCatalog.from_list([(FakeTest, None)]) -@pytest.mark.asyncio -async def test_runner_empty_tests(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: - """Test that when the list of tests is empty, a log is raised. - - caplog is the pytest fixture to capture logs - test_inventory is a fixture that gives a default inventory for tests - """ - logger.setup_logging(logger.Log.INFO) +async def test_empty_tests(caplog: pytest.LogCaptureFixture, inventory: AntaInventory) -> None: + """Test that when the list of tests is empty, a log is raised.""" caplog.set_level(logging.INFO) manager = ResultManager() - await main(manager, test_inventory, AntaCatalog()) + await main(manager, inventory, AntaCatalog()) assert len(caplog.record_tuples) == 1 assert "The list of tests is empty, exiting" in caplog.records[0].message -@pytest.mark.asyncio -async def test_runner_empty_inventory(caplog: pytest.LogCaptureFixture) -> None: - """Test that when the Inventory is empty, a log is raised. - - caplog is the pytest fixture to capture logs - """ - logger.setup_logging(logger.Log.INFO) +async def test_empty_inventory(caplog: pytest.LogCaptureFixture) -> None: + """Test that when the Inventory is empty, a log is raised.""" caplog.set_level(logging.INFO) manager = ResultManager() - inventory = AntaInventory() - await main(manager, inventory, FAKE_CATALOG) + await main(manager, AntaInventory(), FAKE_CATALOG) assert len(caplog.record_tuples) == 3 assert "The inventory is empty, exiting" in caplog.records[1].message -@pytest.mark.asyncio -async def test_runner_no_selected_device(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: - """Test that when the list of established device. - - caplog is the pytest fixture to capture logs - test_inventory is a fixture that gives a default inventory for tests - """ - logger.setup_logging(logger.Log.INFO) - caplog.set_level(logging.INFO) +@pytest.mark.parametrize( + ("inventory", "tags", "devices"), + [ + pytest.param({"count": 1, "reachable": False}, None, None, id="not-reachable"), + pytest.param({"filename": "test_inventory_with_tags.yml", "reachable": False}, {"leaf"}, None, id="not-reachable-with-tag"), + pytest.param({"count": 1, "reachable": True}, {"invalid-tag"}, None, id="reachable-with-invalid-tag"), + pytest.param({"filename": "test_inventory_with_tags.yml", "reachable": True}, None, {"invalid-device"}, id="reachable-with-invalid-device"), + pytest.param({"filename": "test_inventory_with_tags.yml", "reachable": False}, None, {"leaf1"}, id="not-reachable-with-device"), + pytest.param({"filename": "test_inventory_with_tags.yml", "reachable": False}, {"leaf"}, {"leaf1"}, id="not-reachable-with-device-and-tag"), + pytest.param({"filename": "test_inventory_with_tags.yml", "reachable": False}, {"invalid"}, {"invalid-device"}, id="reachable-with-invalid-tag-and-device"), + ], + indirect=["inventory"], +) +async def test_no_selected_device(caplog: pytest.LogCaptureFixture, inventory: AntaInventory, tags: set[str], devices: set[str]) -> None: + """Test that when the list of established devices is empty a log is raised.""" + caplog.set_level(logging.WARNING) manager = ResultManager() - await main(manager, test_inventory, FAKE_CATALOG) - - assert "No reachable device was found." in [record.message for record in caplog.records] - - # Reset logs and run with tags - caplog.clear() - await main(manager, test_inventory, FAKE_CATALOG, tags={"toto"}) - - assert "No reachable device matching the tags {'toto'} was found." in [record.message for record in caplog.records] + await main(manager, inventory, FAKE_CATALOG, tags=tags, devices=devices) + msg = f'No reachable device {f"matching the tags {tags} " if tags else ""}was found.{f" Selected devices: {devices} " if devices is not None else ""}' + assert msg in caplog.messages def test_adjust_rlimit_nofile_valid_env(caplog: pytest.LogCaptureFixture) -> None: @@ -140,67 +129,55 @@ def side_effect_setrlimit(resource_id: int, limits: tuple[int, int]) -> None: setrlimit_mock.assert_called_once_with(resource.RLIMIT_NOFILE, (16384, 1048576)) -@pytest.mark.asyncio @pytest.mark.parametrize( - ("tags", "expected_tests_count", "expected_devices_count"), + ("inventory", "tags", "tests", "devices_count", "tests_count"), [ - (None, 22, 3), - ({"leaf"}, 9, 3), - ({"invalid_tag"}, 0, 0), + pytest.param({"filename": "test_inventory_with_tags.yml"}, None, None, 3, 27, id="all-tests"), + pytest.param({"filename": "test_inventory_with_tags.yml"}, {"leaf"}, None, 2, 6, id="1-tag"), + pytest.param({"filename": "test_inventory_with_tags.yml"}, {"leaf", "spine"}, None, 3, 9, id="2-tags"), + pytest.param({"filename": "test_inventory_with_tags.yml"}, None, {"VerifyMlagStatus", "VerifyUptime"}, 3, 5, id="filtered-tests"), + pytest.param({"filename": "test_inventory_with_tags.yml"}, {"leaf"}, {"VerifyMlagStatus", "VerifyUptime"}, 2, 4, id="1-tag-filtered-tests"), + pytest.param({"filename": "test_inventory_with_tags.yml"}, {"invalid"}, None, 0, 0, id="invalid-tag"), ], - ids=["no_tags", "leaf_tag", "invalid_tag"], + indirect=["inventory"], ) async def test_prepare_tests( - caplog: pytest.LogCaptureFixture, - test_inventory: AntaInventory, - tags: set[str] | None, - expected_tests_count: int, - expected_devices_count: int, + caplog: pytest.LogCaptureFixture, inventory: AntaInventory, tags: set[str], tests: set[str], devices_count: int, tests_count: int ) -> None: - """Test the runner prepare_tests function.""" - logger.setup_logging(logger.Log.INFO) - caplog.set_level(logging.INFO) - - catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / "test_catalog_with_tags.yml")) - selected_tests = prepare_tests(inventory=test_inventory, catalog=catalog, tags=tags, tests=None) - - if selected_tests is None: - assert expected_tests_count == 0 - expected_log = f"There are no tests matching the tags {tags} to run in the current test catalog and device inventory, please verify your inputs." - assert expected_log in caplog.text - else: - assert len(selected_tests) == expected_devices_count - assert sum(len(tests) for tests in selected_tests.values()) == expected_tests_count - - -@pytest.mark.asyncio -async def test_prepare_tests_with_specific_tests(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: """Test the runner prepare_tests function with specific tests.""" - logger.setup_logging(logger.Log.INFO) - caplog.set_level(logging.INFO) - + caplog.set_level(logging.WARNING) catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / "test_catalog_with_tags.yml")) - selected_tests = prepare_tests(inventory=test_inventory, catalog=catalog, tags=None, tests={"VerifyMlagStatus", "VerifyUptime"}) - + selected_tests = prepare_tests(inventory=inventory, catalog=catalog, tags=tags, tests=tests) + if selected_tests is None: + msg = f"There are no tests matching the tags {tags} to run in the current test catalog and device inventory, please verify your inputs." + assert msg in caplog.messages + return assert selected_tests is not None - assert len(selected_tests) == 3 - assert sum(len(tests) for tests in selected_tests.values()) == 5 - + assert len(selected_tests) == devices_count + assert sum(len(tests) for tests in selected_tests.values()) == tests_count -@pytest.mark.asyncio -async def test_runner_dry_run(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: - """Test that when dry_run is True, no tests are run. - caplog is the pytest fixture to capture logs - test_inventory is a fixture that gives a default inventory for tests - """ - logger.setup_logging(logger.Log.INFO) +async def test_dry_run(caplog: pytest.LogCaptureFixture, inventory: AntaInventory) -> None: + """Test that when dry_run is True, no tests are run.""" caplog.set_level(logging.INFO) manager = ResultManager() - catalog_path = Path(__file__).parent.parent / "data" / "test_catalog.yml" - catalog = AntaCatalog.parse(catalog_path) + await main(manager, inventory, FAKE_CATALOG, dry_run=True) + assert "Dry-run mode, exiting before running the tests." in caplog.records[-1].message - await main(manager, test_inventory, catalog, dry_run=True) - # Check that the last log contains Dry-run - assert "Dry-run" in caplog.records[-1].message +async def test_cannot_create_test(caplog: pytest.LogCaptureFixture, inventory: AntaInventory) -> None: + """Test that when an Exception is raised during test instantiation, it is caught and a log is raised.""" + caplog.set_level(logging.CRITICAL) + manager = ResultManager() + catalog = AntaCatalog.from_list([(FakeTestWithMissingTest, None)]) # type: ignore[type-abstract] + await main(manager, inventory, catalog) + msg = ( + "There is an error when creating test tests.units.test_models.FakeTestWithMissingTest.\nIf 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.\nTypeError: " + ) + msg += ( + "Can't instantiate abstract class FakeTestWithMissingTest without an implementation for abstract method 'test'" + if sys.version_info >= (3, 12) + else "Can't instantiate abstract class FakeTestWithMissingTest with abstract method test" + ) + assert msg in caplog.messages diff --git a/tests/units/test_tools.py b/tests/units/test_tools.py index c3a57e5af..29abac5e6 100644 --- a/tests/units/test_tools.py +++ b/tests/units/test_tools.py @@ -313,7 +313,6 @@ def test_get_dict_superset( expected_raise: AbstractContextManager[Exception], ) -> None: """Test get_dict_superset.""" - # pylint: disable=too-many-arguments with expected_raise: assert get_dict_superset(list_of_dicts, input_dict, default, var_name, custom_error_msg, required=required) == expected_result @@ -421,7 +420,6 @@ def test_get_value( expected_raise: AbstractContextManager[Exception], ) -> None: """Test get_value.""" - # pylint: disable=too-many-arguments kwargs = { "default": default, "required": required, @@ -485,7 +483,6 @@ def test_get_item( expected_raise: AbstractContextManager[Exception], ) -> None: """Test get_item.""" - # pylint: disable=too-many-arguments with expected_raise: assert get_item(list_of_dicts, key, value, default, var_name, custom_error_msg, required=required, case_sensitive=case_sensitive) == expected_result From ecef2b78fc2c566f4e1c2df99592020f475e3a13 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Wed, 18 Sep 2024 15:51:33 +0200 Subject: [PATCH 68/90] doc: Enable D417 for ruff on top of numpy and fix asynceapi (#829) --- anta/catalog.py | 15 ++- anta/reporter/__init__.py | 10 +- anta/reporter/csv_reporter.py | 2 +- anta/result_manager/__init__.py | 3 +- anta/runner.py | 2 + anta/tests/routing/isis.py | 40 ++++---- asynceapi/aio_portcheck.py | 13 ++- asynceapi/config_session.py | 158 ++++++++++++++++++-------------- asynceapi/device.py | 109 ++++++++++++++++------ pyproject.toml | 2 +- 10 files changed, 226 insertions(+), 128 deletions(-) diff --git a/anta/catalog.py b/anta/catalog.py index 66520f9fa..ee56639f7 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -328,6 +328,10 @@ def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") - file_format Format of the file, either 'yaml' or 'json'. + Returns + ------- + AntaCatalog + An AntaCatalog populated with the file content. """ if file_format not in ["yaml", "json"]: message = f"'{file_format}' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported." @@ -356,8 +360,13 @@ def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> Anta ---------- data Python dictionary used to instantiate the AntaCatalog instance. - filename: value to be set as AntaCatalog instance attribute + filename + value to be set as AntaCatalog instance attribute + Returns + ------- + AntaCatalog + An AntaCatalog populated with the 'data' dictionary content. """ tests: list[AntaTestDefinition] = [] if data is None: @@ -392,6 +401,10 @@ def from_list(data: ListAntaTestTuples) -> AntaCatalog: data Python list used to instantiate the AntaCatalog instance. + Returns + ------- + AntaCatalog + An AntaCatalog populated with the 'data' list content. """ tests: list[AntaTestDefinition] = [] try: diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index 01baf3a6e..73ba83538 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -90,12 +90,13 @@ def _color_result(self, status: AntaTestStatus) -> str: Parameters ---------- - status: AntaTestStatus enum to color. + status + AntaTestStatus enum to color. Returns ------- - The colored string. - + str + The colored string. """ color = RICH_COLOR_THEME.get(str(status), "") return f"[{color}]{status}" if color != "" else str(status) @@ -115,8 +116,7 @@ def report_all(self, manager: ResultManager, title: str = "All tests results") - Returns ------- Table - A fully populated rich `Table` - + A fully populated rich `Table`. """ table = Table(title=title, show_lines=True) headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"] diff --git a/anta/reporter/csv_reporter.py b/anta/reporter/csv_reporter.py index 570da9e6b..4263c1e59 100644 --- a/anta/reporter/csv_reporter.py +++ b/anta/reporter/csv_reporter.py @@ -62,7 +62,7 @@ def convert_to_list(cls, result: TestResult) -> list[str]: Parameters ---------- - results + result A TestResult to convert into list. Returns diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index b1fd9c2d4..f0f4cadfa 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -301,7 +301,8 @@ def filter_by_devices(self, devices: set[str]) -> ResultManager: Parameters ---------- - devices: Set of device names to filter the results. + devices + Set of device names to filter the results. Returns ------- diff --git a/anta/runner.py b/anta/runner.py index c818d192d..6e3290267 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -88,6 +88,8 @@ async def setup_inventory(inventory: AntaInventory, tags: set[str] | None, devic Tags to filter devices from the inventory. devices Devices on which to run tests. None means all devices. + established_only + If True use return only devices where a connection is established. Returns ------- diff --git a/anta/tests/routing/isis.py b/anta/tests/routing/isis.py index 344605d3a..684578ce1 100644 --- a/anta/tests/routing/isis.py +++ b/anta/tests/routing/isis.py @@ -604,10 +604,6 @@ def test(self) -> None: This method performs the main test logic for verifying ISIS Segment Routing tunnels. It checks the command output, initiates defaults, and performs various checks on the tunnels. - - Returns - ------- - None """ command_output = self.instance_commands[0].json_output self.result.is_success() @@ -647,12 +643,15 @@ def _check_tunnel_type(self, via_input: VerifyISISSegmentRoutingTunnels.Input.En Parameters ---------- - via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input tunnel type to check. - eos_entry (dict[str, Any]): The EOS entry containing the tunnel types. + via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias + The input tunnel type to check. + eos_entry : dict[str, Any] + The EOS entry containing the tunnel types. Returns ------- - bool: True if the tunnel type matches any of the tunnel types in `eos_entry`, False otherwise. + bool + True if the tunnel type matches any of the tunnel types in `eos_entry`, False otherwise. """ if via_input.type is not None: return any( @@ -672,12 +671,15 @@ def _check_tunnel_nexthop(self, via_input: VerifyISISSegmentRoutingTunnels.Input Parameters ---------- - via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input via object. - eos_entry (dict[str, Any]): The EOS entry dictionary. + via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias + The input via object. + eos_entry : dict[str, Any] + The EOS entry dictionary. Returns ------- - bool: True if the tunnel nexthop matches, False otherwise. + bool + True if the tunnel nexthop matches, False otherwise. """ if via_input.nexthop is not None: return any( @@ -697,12 +699,15 @@ def _check_tunnel_interface(self, via_input: VerifyISISSegmentRoutingTunnels.Inp Parameters ---------- - via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input via object. - eos_entry (dict[str, Any]): The EOS entry dictionary. + via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias + The input via object. + eos_entry : dict[str, Any] + The EOS entry dictionary. Returns ------- - bool: True if the tunnel interface exists, False otherwise. + bool + True if the tunnel interface exists, False otherwise. """ if via_input.interface is not None: return any( @@ -722,12 +727,15 @@ def _check_tunnel_id(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entr Parameters ---------- - via_input (VerifyISISSegmentRoutingTunnels.Input.Entry.Vias): The input vias to check. - eos_entry (dict[str, Any]): The EOS entry to compare against. + via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias + The input vias to check. + eos_entry : dict[str, Any]) + The EOS entry to compare against. Returns ------- - bool: True if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias, False otherwise. + bool + True if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias, False otherwise. """ if via_input.tunnel_id is not None: return any( diff --git a/asynceapi/aio_portcheck.py b/asynceapi/aio_portcheck.py index 79f4562fa..0cab94cb3 100644 --- a/asynceapi/aio_portcheck.py +++ b/asynceapi/aio_portcheck.py @@ -37,12 +37,17 @@ async def port_check_url(url: URL, timeout: int = 5) -> bool: """ Open the port designated by the URL given the timeout in seconds. - If the port is available then return True; False otherwise. - Parameters ---------- - url: The URL that provides the target system - timeout: Time to await for the port to open in seconds + url + The URL that provides the target system. + timeout + Time to await for the port to open in seconds. + + Returns + ------- + bool + If the port is available then return True; False otherwise. """ port = url.port or socket.getservbyname(url.scheme) diff --git a/asynceapi/config_session.py b/asynceapi/config_session.py index 558b7fed4..df26d7def 100644 --- a/asynceapi/config_session.py +++ b/asynceapi/config_session.py @@ -52,8 +52,10 @@ def __init__(self, device: Device, name: str) -> None: Parameters ---------- - device: The associated device instance - name: The name of the config session + device + The associated device instance. + name + The name of the config session. """ self._device = device self._cli = device.cli @@ -87,30 +89,35 @@ async def status_all(self) -> dict[str, Any]: Returns ------- - Dict object of native EOS eAPI response; see `status` method for + dict[str, Any] + Dictionary of native EOS eAPI response; see `status` method for details. Examples -------- - { - "maxSavedSessions": 1, - "maxOpenSessions": 5, - "sessions": { - "jeremy1": { - "instances": {}, - "state": "pending", - "commitUser": "", - "description": "" - }, - "ansible_167510439362": { - "instances": {}, - "state": "completed", - "commitUser": "joe.bob", - "description": "", - "completedTime": 1675104396.4500246 - } - } + Return example: + + ``` + { + "maxSavedSessions": 1, + "maxOpenSessions": 5, + "sessions": { + "jeremy1": { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + }, + "ansible_167510439362": { + "instances": {}, + "state": "completed", + "commitUser": "joe.bob", + "description": "", + "completedTime": 1675104396.4500246 } + } + } + ``` """ return await self._cli("show configuration sessions detail") # type: ignore[return-value] # json outformat returns dict[str, Any] @@ -126,41 +133,47 @@ async def status(self) -> dict[str, Any] | None: Returns ------- - Dict instance of the session status. If the session does not exist, + dict[str, Any] | None + Dictionary instance of the session status. If the session does not exist, then this method will return None. - The native eAPI results from JSON output, see example: - Examples -------- - all results: - { - "maxSavedSessions": 1, - "maxOpenSessions": 5, - "sessions": { - "jeremy1": { - "instances": {}, - "state": "pending", - "commitUser": "", - "description": "" - }, - "ansible_167510439362": { - "instances": {}, - "state": "completed", - "commitUser": "joe.bob", - "description": "", - "completedTime": 1675104396.4500246 - } + The return is the native eAPI results from JSON output: + + ``` + all results: + { + "maxSavedSessions": 1, + "maxOpenSessions": 5, + "sessions": { + "jeremy1": { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + }, + "ansible_167510439362": { + "instances": {}, + "state": "completed", + "commitUser": "joe.bob", + "description": "", + "completedTime": 1675104396.4500246 } } - - if the session name was 'jeremy1', then this method would return - { - "instances": {}, - "state": "pending", - "commitUser": "", - "description": "" - } + } + ``` + + If the session name was 'jeremy1', then this method would return: + + ``` + { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + } + ``` """ res = await self.status_all() return res["sessions"].get(self.name) @@ -174,15 +187,15 @@ async def push(self, content: list[str] | str, *, replace: bool = False) -> None Parameters ---------- - content: - The text configuration CLI commands, as a list of strings, that - will be sent to the device. If the parameter is a string, and not - a list, then split the string across linebreaks. In either case - any empty lines will be discarded before they are send to the - device. - replace: - When True, the content will replace the existing configuration - on the device. + content + The text configuration CLI commands, as a list of strings, that + will be sent to the device. If the parameter is a string, and not + a list, then split the string across linebreaks. In either case + any empty lines will be discarded before they are send to the + device. + replace + When True, the content will replace the existing configuration + on the device. """ # if given s string, we need to break it up into individual command # lines. @@ -212,10 +225,13 @@ async def commit(self, timer: str | None = None) -> None: # configure session # commit - If the timer is specified, format is "hh:mm:ss", then a commit timer is - started. A second commit action must be made to confirm the config - session before the timer expires; otherwise the config-session is - automatically aborted. + Parameters + ---------- + timer + If the timer is specified, format is "hh:mm:ss", then a commit timer is + started. A second commit action must be made to confirm the config + session before the timer expires; otherwise the config-session is + automatically aborted. """ command = f"{self._cli_config_session} commit" @@ -242,6 +258,7 @@ async def diff(self) -> str: Returns ------- + str Return a string in diff-patch format. References @@ -258,17 +275,18 @@ async def load_file(self, filename: str, *, replace: bool = False) -> None: Parameters ---------- - filename: - The name of the configuration file. The caller is required to - specify the filesystem, for example, the - filename="flash:thisfile.cfg" + filename + The name of the configuration file. The caller is required to + specify the filesystem, for example, the + filename="flash:thisfile.cfg". - replace: - When True, the contents of the file will completely replace the - session config for a load-replace behavior. + replace + When True, the contents of the file will completely replace the + session config for a load-replace behavior. Raises ------ + RuntimeError If there are any issues with loading the configuration file then a RuntimeError is raised with the error messages content. """ diff --git a/asynceapi/device.py b/asynceapi/device.py index ca5a30c25..933ae649c 100644 --- a/asynceapi/device.py +++ b/asynceapi/device.py @@ -71,23 +71,31 @@ def __init__( Parameters ---------- - host: The EOS target device, either hostname (DNS) or ipaddress. - username: The login user-name; requires the password parameter. - password: The login password; requires the username parameter. - proto: The protocol, http or https, to communicate eAPI with the device. - port: If not provided, the proto value is used to look up the associated + host + The EOS target device, either hostname (DNS) or ipaddress. + username + The login user-name; requires the password parameter. + password + The login password; requires the username parameter. + proto + The protocol, http or https, to communicate eAPI with the device. + port + If not provided, the proto value is used to look up the associated port (http=80, https=443). If provided, overrides the port used to communite with the device. + kwargs + Other named keyword arguments, some of them are being used in the function + cf Other Parameters section below, others are just passed as is to the httpx.AsyncClient. Other Parameters ---------------- - base_url: str - If provided, the complete URL to the device eAPI endpoint. + base_url : str + If provided, the complete URL to the device eAPI endpoint. - auth: - If provided, used as the httpx authorization initializer value. If - not provided, then username+password is assumed by the Caller and - used to create a BasicAuth instance. + auth : + If provided, used as the httpx authorization initializer value. If + not provided, then username+password is assumed by the Caller and + used to create a BasicAuth instance. """ self.port = port or getservbyname(proto) self.host = host @@ -111,6 +119,7 @@ async def check_connection(self) -> bool: Returns ------- + bool True when the device eAPI is accessible, False otherwise. """ return await port_check_url(self.base_url) @@ -132,18 +141,18 @@ async def cli( # noqa: PLR0913 Parameters ---------- - command: - A single command to execute; results in a single output response - commands: - A list of commands to execute; results in a list of output responses - ofmt: + command + A single command to execute; results in a single output response. + commands + A list of commands to execute; results in a list of output responses. + ofmt Either 'json' or 'text'; indicates the output format for the CLI commands. - version: + version By default the eAPI will use "version 1" for all API object models. This driver will, by default, always set version to "latest" so that the behavior matches the CLI of the device. The caller can override the "latest" behavior by explicitly setting the version. - suppress_error: + suppress_error When not False, then if the execution of the command would-have raised an EapiCommandError, rather than raising this exception this routine will return the value None. @@ -152,13 +161,13 @@ async def cli( # noqa: PLR0913 EapiCommandError, now response would be set to None instead. response = dev.cli(..., suppress_error=True) - auto_complete: + auto_complete Enabled/disables the command auto-compelete feature of the EAPI. Per the documentation: Allows users to use shorthand commands in eAPI calls. With this parameter included a user can send 'sh ver' via eAPI to get the output of 'show version'. - expand_aliases: + expand_aliases Enables/disables the command use of User defined alias. Per the documentation: Allowed users to provide the expandAliases parameter to eAPI @@ -166,11 +175,12 @@ async def cli( # noqa: PLR0913 For example if an alias is configured as 'sv' for 'show version' then an API call with sv and the expandAliases parameter will return the output of show version. - req_id: + req_id A unique identifier that will be echoed back by the switch. May be a string or number. Returns ------- + list[dict[str, Any] | str] | dict[str, Any] | str | None One or List of output responses, per the description above. """ if not any((command, commands)): @@ -199,7 +209,42 @@ def _jsonrpc_command( # noqa: PLR0913 expand_aliases: bool = False, req_id: int | str | None = None, ) -> dict[str, Any]: - """Create the JSON-RPC command dictionary object.""" + """Create the JSON-RPC command dictionary object. + + Parameters + ---------- + commands + A list of commands to execute; results in a list of output responses. + ofmt + Either 'json' or 'text'; indicates the output format for the CLI commands. + version + By default the eAPI will use "version 1" for all API object models. + This driver will, by default, always set version to "latest" so + that the behavior matches the CLI of the device. The caller can + override the "latest" behavior by explicitly setting the version. + auto_complete + Enabled/disables the command auto-compelete feature of the EAPI. Per the + documentation: + Allows users to use shorthand commands in eAPI calls. With this + parameter included a user can send 'sh ver' via eAPI to get the + output of 'show version'. + expand_aliases + Enables/disables the command use of User defined alias. Per the + documentation: + Allowed users to provide the expandAliases parameter to eAPI + calls. This allows users to use aliased commands via the API. + For example if an alias is configured as 'sv' for 'show version' + then an API call with sv and the expandAliases parameter will + return the output of show version. + req_id + A unique identifier that will be echoed back by the switch. May be a string or number. + + Returns + ------- + dict[str, Any]: + dict containing the JSON payload to run the command. + + """ cmd: dict[str, Any] = { "jsonrpc": "2.0", "method": "runCmds", @@ -224,16 +269,17 @@ async def jsonrpc_exec(self, jsonrpc: dict[str, Any]) -> list[dict[str, Any] | s Parameters ---------- - jsonrpc: - The JSON-RPC as created by the `meth`:_jsonrpc_command(). + jsonrpc + The JSON-RPC as created by the `meth`:_jsonrpc_command(). Raises ------ - EapiCommandError - In the event that a command resulted in an error response. + EapiCommandError + In the event that a command resulted in an error response. Returns ------- + list[dict[str, Any] | str] The list of command results; either dict or text depending on the JSON-RPC format parameter. """ @@ -282,11 +328,16 @@ async def jsonrpc_exec(self, jsonrpc: dict[str, Any]) -> list[dict[str, Any] | s ) def config_session(self, name: str) -> SessionConfig: - """ - return a SessionConfig instance bound to this device with the given session name. + """Return a SessionConfig instance bound to this device with the given session name. Parameters ---------- - name: The config-session name + name + The config-session name. + + Returns + ------- + SessionConfig + SessionConfig instance bound to this device with the given session name. """ return SessionConfig(self, name) diff --git a/pyproject.toml b/pyproject.toml index c07dd0e96..b9dfdad8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -336,7 +336,7 @@ select = ["ALL", # TODO: Augment the numpy convention rules to make sure we add all the params # Uncomment below D417 "D415", - # "D417", + "D417", ] ignore = [ "COM812", # Ignoring conflicting rules that may cause conflicts when used with the formatter From 3c2c66740d68ef4c0b77a195c3bf534c721e69ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Wed, 18 Sep 2024 16:16:42 +0200 Subject: [PATCH 69/90] Add SonarCloud Coverage Badge --- docs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 44b445550..4f11feebe 100755 --- a/docs/README.md +++ b/docs/README.md @@ -6,10 +6,10 @@ # Arista Network Test Automation (ANTA) Framework -| **Code** | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![Numpy](https://img.shields.io/badge/Docstring_format-numpy-blue)](https://numpydoc.readthedocs.io/en/latest/format.html) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=aristanetworks_anta&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=aristanetworks_anta) | +| **Code** | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![Numpy](https://img.shields.io/badge/Docstring_format-numpy-blue)](https://numpydoc.readthedocs.io/en/latest/format.html) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=aristanetworks_anta&metric=alert_status&branch=main)](https://sonarcloud.io/summary/new_code?id=aristanetworks_anta) [![Coverage](https://img.shields.io/sonar/coverage/aristanetworks_anta/main?server=https%3A%2F%2Fsonarcloud.io&logo=sonarcloud&link=https%3A%2F%2Fsonarcloud.io%2Fsummary%2Foverall%3Fid%3Daristanetworks_anta)](https://sonarcloud.io/summary/overall?id=aristanetworks_anta) | | :------------: | :-------| | **License** | [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/aristanetworks/anta/blob/main/LICENSE) | -| **GitHub** | [![CI](https://github.com/aristanetworks/anta/actions/workflows/code-testing.yml/badge.svg)](https://github.com/aristanetworks/anta/actions/workflows/code-testing.yml) ![Coverage](https://raw.githubusercontent.com/aristanetworks/anta/coverage-badge/latest-release-coverage.svg) ![Commit](https://img.shields.io/github/last-commit/aristanetworks/anta) ![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/m/aristanetworks/anta) [![Github release](https://img.shields.io/github/release/aristanetworks/anta.svg)](https://github.com/aristanetworks/anta/releases/) [![Contributors](https://img.shields.io/github/contributors/aristanetworks/anta)](https://github.com/aristanetworks/anta/graphs/contributors) | +| **GitHub** | [![CI](https://github.com/aristanetworks/anta/actions/workflows/code-testing.yml/badge.svg)](https://github.com/aristanetworks/anta/actions/workflows/code-testing.yml) ![Commit](https://img.shields.io/github/last-commit/aristanetworks/anta) ![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/m/aristanetworks/anta) [![Github release](https://img.shields.io/github/release/aristanetworks/anta.svg)](https://github.com/aristanetworks/anta/releases/) [![Contributors](https://img.shields.io/github/contributors/aristanetworks/anta)](https://github.com/aristanetworks/anta/graphs/contributors) | | **PyPi** | ![PyPi Version](https://img.shields.io/pypi/v/anta) ![Python Versions](https://img.shields.io/pypi/pyversions/anta) ![Python format](https://img.shields.io/pypi/format/anta) ![PyPI - Downloads](https://img.shields.io/pypi/dm/anta) | ANTA is Python framework that automates tests for Arista devices. From 0c6b66cca1756f6eb854601ac2172a3e7a301544 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:04:37 +0200 Subject: [PATCH 70/90] ci: pre-commit autoupdate (#833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.5 → v0.6.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.5...v0.6.7) - [github.com/pycqa/pylint: v3.2.7 → v3.3.0](https://github.com/pycqa/pylint/compare/v3.2.7...v3.3.0) * ci: Ignore pylint new R0917 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: gmuloc --- .pre-commit-config.yaml | 4 ++-- pyproject.toml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9944f5bfd..ec89d26e5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.5 + rev: v0.6.7 hooks: - id: ruff name: Run Ruff linter @@ -52,7 +52,7 @@ repos: name: Run Ruff formatter - repo: https://github.com/pycqa/pylint - rev: "v3.2.7" + rev: "v3.3.0" hooks: - id: pylint name: Check code style with pylint diff --git a/pyproject.toml b/pyproject.toml index b9dfdad8c..d874b4edb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -450,6 +450,7 @@ disable = [ # Any rule listed here can be disabled: https://github.com/astral-sh "keyword-arg-before-vararg", "protected-access", "too-many-arguments", + "too-many-positional-arguments", # New in pylint 3.3.0 "wrong-import-position", "pointless-statement", "broad-exception-caught", From 6923396f318ebec60a2b1b967f3fcc00479515e2 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 26 Sep 2024 20:35:09 +0530 Subject: [PATCH 71/90] feat(anta): Added test case to verify Spanning Tree (RPVST) state(stable topology) (#791) --- anta/tests/stp.py | 63 ++++++++++- examples/tests.yaml | 2 + tests/units/anta_tests/test_stp.py | 164 ++++++++++++++++++++++++++++- 3 files changed, 227 insertions(+), 2 deletions(-) diff --git a/anta/tests/stp.py b/anta/tests/stp.py index 7cbfc9cf0..3208f0c40 100644 --- a/anta/tests/stp.py +++ b/anta/tests/stp.py @@ -7,7 +7,7 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from typing import ClassVar, Literal +from typing import Any, ClassVar, Literal from pydantic import Field @@ -259,3 +259,64 @@ def test(self) -> None: self.result.is_failure(f"The following instance(s) have the wrong STP root priority configured: {wrong_priority_instances}") else: self.result.is_success() + + +class VerifyStpTopologyChanges(AntaTest): + """Verifies the number of changes across all interfaces in the Spanning Tree Protocol (STP) topology is below a threshold. + + Expected Results + ---------------- + * Success: The test will pass if the total number of changes across all interfaces is less than the specified threshold. + * Failure: The test will fail if the total number of changes across all interfaces meets or exceeds the specified threshold, + indicating potential instability in the topology. + + Examples + -------- + ```yaml + anta.tests.stp: + - VerifyStpTopologyChanges: + threshold: 10 + ``` + """ + + name = "VerifyStpTopologyChanges" + description = "Verifies the number of changes across all interfaces in the Spanning Tree Protocol (STP) topology is below a threshold." + categories: ClassVar[list[str]] = ["stp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree topology status detail", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyStpTopologyChanges test.""" + + threshold: int + """The threshold number of changes in the STP topology.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyStpTopologyChanges.""" + failures: dict[str, Any] = {"topologies": {}} + + command_output = self.instance_commands[0].json_output + stp_topologies = command_output.get("topologies", {}) + + # verifies all available topologies except the "NoStp" topology. + stp_topologies.pop("NoStp", None) + + # Verify the STP topology(s). + if not stp_topologies: + self.result.is_failure("STP is not configured.") + return + + # Verifies the number of changes across all interfaces + for topology, topology_details in stp_topologies.items(): + interfaces = { + interface: {"Number of changes": num_of_changes} + for interface, details in topology_details.get("interfaces", {}).items() + if (num_of_changes := details.get("numChanges")) > self.inputs.threshold + } + if interfaces: + failures["topologies"][topology] = interfaces + + if failures["topologies"]: + self.result.is_failure(f"The following STP topologies are not configured or number of changes not within the threshold:\n{failures}") + else: + self.result.is_success() diff --git a/examples/tests.yaml b/examples/tests.yaml index 954b5b736..ade4e7640 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -427,6 +427,8 @@ anta.tests.stp: instances: - 10 - 20 + - VerifyStpTopologyChanges: + threshold: 10 anta.tests.stun: - VerifyStunClient: diff --git a/tests/units/anta_tests/test_stp.py b/tests/units/anta_tests/test_stp.py index a6855aa88..37422108b 100644 --- a/tests/units/anta_tests/test_stp.py +++ b/tests/units/anta_tests/test_stp.py @@ -7,7 +7,7 @@ from typing import Any -from anta.tests.stp import VerifySTPBlockedPorts, VerifySTPCounters, VerifySTPForwardingPorts, VerifySTPMode, VerifySTPRootPriority +from anta.tests.stp import VerifySTPBlockedPorts, VerifySTPCounters, VerifySTPForwardingPorts, VerifySTPMode, VerifySTPRootPriority, VerifyStpTopologyChanges from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ @@ -324,4 +324,166 @@ "inputs": {"priority": 32768, "instances": [10, 20, 30]}, "expected": {"result": "failure", "messages": ["The following instance(s) have the wrong STP root priority configured: ['VL20', 'VL30']"]}, }, + { + "name": "success-mstp", + "test": VerifyStpTopologyChanges, + "eos_data": [ + { + "unmappedVlans": [], + "topologies": { + "Cist": { + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 1, "lastChange": 1723990624.735365}, + "Port-Channel5": {"state": "forwarding", "numChanges": 1, "lastChange": 1723990624.7353542}, + } + }, + "NoStp": { + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 1, "lastChange": 1723990624.735365}, + "Ethernet1": {"state": "forwarding", "numChanges": 15, "lastChange": 1723990624.7353542}, + } + }, + }, + }, + ], + "inputs": {"threshold": 10}, + "expected": {"result": "success"}, + }, + { + "name": "success-rstp", + "test": VerifyStpTopologyChanges, + "eos_data": [ + { + "unmappedVlans": [], + "topologies": { + "Cist": { + "interfaces": { + "Vxlan1": {"state": "forwarding", "numChanges": 1, "lastChange": 1723990624.735365}, + "PeerEthernet3": {"state": "forwarding", "numChanges": 1, "lastChange": 1723990624.7353542}, + } + }, + "NoStp": { + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 1, "lastChange": 1723990624.735365}, + "Ethernet1": {"state": "forwarding", "numChanges": 15, "lastChange": 1723990624.7353542}, + } + }, + }, + }, + ], + "inputs": {"threshold": 10}, + "expected": {"result": "success"}, + }, + { + "name": "success-rapid-pvst", + "test": VerifyStpTopologyChanges, + "eos_data": [ + { + "unmappedVlans": [], + "topologies": { + "NoStp": { + "vlans": [4094, 4093, 1006], + "interfaces": { + "PeerEthernet2": {"state": "forwarding", "numChanges": 1, "lastChange": 1727151356.1330667}, + }, + }, + "Vl1": {"vlans": [1], "interfaces": {"Port-Channel5": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0615358}}}, + "Vl10": { + "vlans": [10], + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0673406}, + "Vxlan1": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0677001}, + "Port-Channel5": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0728855}, + "Ethernet3": {"state": "forwarding", "numChanges": 3, "lastChange": 1727326730.255137}, + }, + }, + "Vl1198": { + "vlans": [1198], + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.074386}, + "Vxlan1": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0743902}, + "Port-Channel5": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0743942}, + }, + }, + "Vl1199": { + "vlans": [1199], + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0744}, + "Vxlan1": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.07453}, + "Port-Channel5": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.074535}, + }, + }, + "Vl20": { + "vlans": [20], + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.073489}, + "Vxlan1": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0743747}, + "Port-Channel5": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0743794}, + "Ethernet3": {"state": "forwarding", "numChanges": 3, "lastChange": 1727326730.2551405}, + }, + }, + "Vl3009": { + "vlans": [3009], + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.074541}, + "Port-Channel5": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0745454}, + }, + }, + "Vl3019": { + "vlans": [3019], + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0745502}, + "Port-Channel5": {"state": "forwarding", "numChanges": 1, "lastChange": 1727326710.0745537}, + }, + }, + }, + }, + ], + "inputs": {"threshold": 10}, + "expected": {"result": "success"}, + }, + { + "name": "failure-unstable-topology", + "test": VerifyStpTopologyChanges, + "eos_data": [ + { + "unmappedVlans": [], + "topologies": { + "Cist": { + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 15, "lastChange": 1723990624.735365}, + "Port-Channel5": {"state": "forwarding", "numChanges": 15, "lastChange": 1723990624.7353542}, + } + }, + }, + }, + ], + "inputs": {"threshold": 10}, + "expected": { + "result": "failure", + "messages": [ + "The following STP topologies are not configured or number of changes not within the threshold:\n" + "{'topologies': {'Cist': {'Cpu': {'Number of changes': 15}, 'Port-Channel5': {'Number of changes': 15}}}}" + ], + }, + }, + { + "name": "failure-topologies-not-configured", + "test": VerifyStpTopologyChanges, + "eos_data": [ + { + "unmappedVlans": [], + "topologies": { + "NoStp": { + "interfaces": { + "Cpu": {"state": "forwarding", "numChanges": 1, "lastChange": 1723990624.735365}, + "Ethernet1": {"state": "forwarding", "numChanges": 15, "lastChange": 1723990624.7353542}, + } + } + }, + }, + ], + "inputs": {"threshold": 10}, + "expected": {"result": "failure", "messages": ["STP is not configured."]}, + }, ] From 2214ff05bf20c736e87cff0900b95a3f509a9ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Thu, 26 Sep 2024 18:42:27 +0200 Subject: [PATCH 72/90] ci: add codspeed to benchmark ANTA (#826) * fix(anta.tests): test results should not be an error when it can be a failure --- .github/workflows/code-testing.yml | 17 ++ .pre-commit-config.yaml | 1 + anta/catalog.py | 8 +- anta/tests/field_notices.py | 2 +- anta/tests/interfaces.py | 4 +- anta/tests/mlag.py | 5 +- anta/tests/routing/bgp.py | 18 +- anta/tests/routing/generic.py | 19 +- anta/tests/security.py | 22 ++- anta/tests/system.py | 3 - pyproject.toml | 9 +- tests/benchmark/__init__.py | 4 + tests/benchmark/conftest.py | 40 +++++ tests/benchmark/test_anta.py | 110 ++++++++++++ tests/benchmark/utils.py | 164 ++++++++++++++++++ .../units/anta_tests/routing/test_generic.py | 50 ++++-- tests/units/anta_tests/test_configuration.py | 10 -- tests/units/anta_tests/test_field_notices.py | 4 +- tests/units/anta_tests/test_interfaces.py | 4 +- tests/units/anta_tests/test_mlag.py | 11 -- tests/units/anta_tests/test_security.py | 105 +++++++---- tests/units/anta_tests/test_system.py | 7 - tests/units/test_custom_types.py | 34 ++++ 23 files changed, 538 insertions(+), 113 deletions(-) create mode 100644 tests/benchmark/__init__.py create mode 100644 tests/benchmark/conftest.py create mode 100644 tests/benchmark/test_anta.py create mode 100644 tests/benchmark/utils.py diff --git a/.github/workflows/code-testing.yml b/.github/workflows/code-testing.yml index 4a63f5677..3a66c5cf3 100644 --- a/.github/workflows/code-testing.yml +++ b/.github/workflows/code-testing.yml @@ -133,3 +133,20 @@ jobs: run: pip install .[doc] - name: "Build mkdocs documentation offline" run: mkdocs build + benchmarks: + name: Benchmark ANTA for Python 3.12 + runs-on: ubuntu-latest + needs: [test-python] + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install .[dev] + - name: Run benchmarks + uses: CodSpeedHQ/action@v3 + with: + token: ${{ secrets.CODSPEED_TOKEN }} + run: pytest --codspeed --no-cov --log-cli-level INFO tests/benchmark \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ec89d26e5..ba1e0d8a2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -69,6 +69,7 @@ repos: - types-pyOpenSSL - pylint_pydantic - pytest + - pytest-codspeed - respx - repo: https://github.com/codespell-project/codespell diff --git a/anta/catalog.py b/anta/catalog.py index ee56639f7..b5a77ad25 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -25,8 +25,14 @@ from anta.models import AntaTest if TYPE_CHECKING: + import sys from types import ModuleType + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + logger = logging.getLogger(__name__) # { : [ { : }, ... ] } @@ -123,7 +129,7 @@ def instantiate_inputs( raise ValueError(msg) @model_validator(mode="after") - def check_inputs(self) -> AntaTestDefinition: + def check_inputs(self) -> Self: """Check the `inputs` field typing. The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`. diff --git a/anta/tests/field_notices.py b/anta/tests/field_notices.py index 71a11749f..6f98a2c9a 100644 --- a/anta/tests/field_notices.py +++ b/anta/tests/field_notices.py @@ -196,4 +196,4 @@ def test(self) -> None: self.result.is_success("FN72 is mitigated") return # We should never hit this point - self.result.is_error("Error in running test - FixedSystemvrm1 not found") + self.result.is_failure("Error in running test - Component FixedSystemvrm1 not found in 'show version'") diff --git a/anta/tests/interfaces.py b/anta/tests/interfaces.py index 9ff1cf357..32b85d493 100644 --- a/anta/tests/interfaces.py +++ b/anta/tests/interfaces.py @@ -71,7 +71,7 @@ def test(self) -> None: if ((duplex := (interface := interfaces["interfaces"][intf]).get("duplex", None)) is not None and duplex != duplex_full) or ( (members := interface.get("memberInterfaces", None)) is not None and any(stats["duplex"] != duplex_full for stats in members.values()) ): - self.result.is_error(f"Interface {intf} or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented.") + self.result.is_failure(f"Interface {intf} or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented.") return if (bandwidth := interfaces["interfaces"][intf]["bandwidth"]) == 0: @@ -705,7 +705,7 @@ def test(self) -> None: input_interface_detail = interface break else: - self.result.is_error(f"Could not find `{intf}` in the input interfaces. {GITHUB_SUGGESTION}") + self.result.is_failure(f"Could not find `{intf}` in the input interfaces. {GITHUB_SUGGESTION}") continue input_primary_ip = str(input_interface_detail.primary_ip) diff --git a/anta/tests/mlag.py b/anta/tests/mlag.py index 1d17ab642..c894b98b6 100644 --- a/anta/tests/mlag.py +++ b/anta/tests/mlag.py @@ -123,10 +123,7 @@ class VerifyMlagConfigSanity(AntaTest): def test(self) -> None: """Main test function for VerifyMlagConfigSanity.""" command_output = self.instance_commands[0].json_output - if (mlag_status := get_value(command_output, "mlagActive")) is None: - self.result.is_error(message="Incorrect JSON response - 'mlagActive' state was not found") - return - if mlag_status is False: + if command_output["mlagActive"] is False: self.result.is_skipped("MLAG is disabled") return keys_to_verify = ["globalConfiguration", "interfaceConfiguration"] diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 97f919876..a37328608 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -8,7 +8,7 @@ from __future__ import annotations from ipaddress import IPv4Address, IPv4Network, IPv6Address -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from pydantic import BaseModel, Field, PositiveInt, model_validator from pydantic.v1.utils import deep_update @@ -18,6 +18,14 @@ from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import get_item, get_value +if TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + def _add_bgp_failures(failures: dict[tuple[str, str | None], dict[str, Any]], afi: Afi, safi: Safi | None, vrf: str, issue: str | dict[str, Any]) -> None: """Add a BGP failure entry to the given `failures` dictionary. @@ -235,7 +243,7 @@ class BgpAfi(BaseModel): """Number of expected BGP peer(s).""" @model_validator(mode="after") - def validate_inputs(self: BaseModel) -> BaseModel: + def validate_inputs(self) -> Self: """Validate the inputs provided to the BgpAfi class. If afi is either ipv4 or ipv6, safi must be provided. @@ -375,7 +383,7 @@ class BgpAfi(BaseModel): """ @model_validator(mode="after") - def validate_inputs(self: BaseModel) -> BaseModel: + def validate_inputs(self) -> Self: """Validate the inputs provided to the BgpAfi class. If afi is either ipv4 or ipv6, safi must be provided. @@ -522,7 +530,7 @@ class BgpAfi(BaseModel): """List of BGP IPv4 or IPv6 peer.""" @model_validator(mode="after") - def validate_inputs(self: BaseModel) -> BaseModel: + def validate_inputs(self) -> Self: """Validate the inputs provided to the BgpAfi class. If afi is either ipv4 or ipv6, safi must be provided and vrf must NOT be all. @@ -1485,7 +1493,7 @@ class BgpPeer(BaseModel): """Outbound route map applied, defaults to None.""" @model_validator(mode="after") - def validate_inputs(self: BaseModel) -> BaseModel: + def validate_inputs(self) -> Self: """Validate the inputs provided to the BgpPeer class. At least one of 'inbound' or 'outbound' route-map must be provided. diff --git a/anta/tests/routing/generic.py b/anta/tests/routing/generic.py index cd9cf0d24..d1322a50d 100644 --- a/anta/tests/routing/generic.py +++ b/anta/tests/routing/generic.py @@ -9,12 +9,21 @@ from functools import cache from ipaddress import IPv4Address, IPv4Interface -from typing import ClassVar, Literal +from typing import TYPE_CHECKING, ClassVar, Literal from pydantic import model_validator +from anta.custom_types import PositiveInteger from anta.models import AntaCommand, AntaTemplate, AntaTest +if TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + class VerifyRoutingProtocolModel(AntaTest): """Verifies the configured routing protocol model is the one we expect. @@ -84,13 +93,13 @@ class VerifyRoutingTableSize(AntaTest): class Input(AntaTest.Input): """Input model for the VerifyRoutingTableSize test.""" - minimum: int + minimum: PositiveInteger """Expected minimum routing table size.""" - maximum: int + maximum: PositiveInteger """Expected maximum routing table size.""" - @model_validator(mode="after") # type: ignore[misc] - def check_min_max(self) -> AntaTest.Input: + @model_validator(mode="after") + def check_min_max(self) -> Self: """Validate that maximum is greater than minimum.""" if self.minimum > self.maximum: msg = f"Minimum {self.minimum} is greater than maximum {self.maximum}" diff --git a/anta/tests/security.py b/anta/tests/security.py index ae5b9bebd..007022dc5 100644 --- a/anta/tests/security.py +++ b/anta/tests/security.py @@ -9,7 +9,7 @@ # mypy: disable-error-code=attr-defined from datetime import datetime, timezone from ipaddress import IPv4Address -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar, get_args from pydantic import BaseModel, Field, model_validator @@ -17,6 +17,14 @@ from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import get_failed_logs, get_item, get_value +if TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + class VerifySSHStatus(AntaTest): """Verifies if the SSHD agent is disabled in the default VRF. @@ -47,7 +55,7 @@ def test(self) -> None: try: line = next(line for line in command_output.split("\n") if line.startswith("SSHD status")) except StopIteration: - self.result.is_error("Could not find SSH status in returned output.") + self.result.is_failure("Could not find SSH status in returned output.") return status = line.split("is ")[1] @@ -416,19 +424,19 @@ class APISSLCertificate(BaseModel): """The encryption algorithm key size of the certificate.""" @model_validator(mode="after") - def validate_inputs(self: BaseModel) -> BaseModel: + def validate_inputs(self) -> Self: """Validate the key size provided to the APISSLCertificates class. If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}. If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}. """ - if self.encryption_algorithm == "RSA" and self.key_size not in RsaKeySize.__args__: - msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {RsaKeySize.__args__}." + if self.encryption_algorithm == "RSA" and self.key_size not in get_args(RsaKeySize): + msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {get_args(RsaKeySize)}." raise ValueError(msg) - if self.encryption_algorithm == "ECDSA" and self.key_size not in EcdsaKeySize.__args__: - msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}." + if self.encryption_algorithm == "ECDSA" and self.key_size not in get_args(EcdsaKeySize): + msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}." raise ValueError(msg) return self diff --git a/anta/tests/system.py b/anta/tests/system.py index 486e5e1ed..d620d533b 100644 --- a/anta/tests/system.py +++ b/anta/tests/system.py @@ -89,9 +89,6 @@ class VerifyReloadCause(AntaTest): def test(self) -> None: """Main test function for VerifyReloadCause.""" command_output = self.instance_commands[0].json_output - if "resetCauses" not in command_output: - self.result.is_error(message="No reload causes available") - return if len(command_output["resetCauses"]) == 0: # No reload causes self.result.is_success() diff --git a/pyproject.toml b/pyproject.toml index d874b4edb..80a59e9ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,8 @@ dev = [ "pytest-asyncio>=0.21.1", "pytest-cov>=4.1.0", "pytest-dependency", + "pytest-codspeed>=2.2.0", + "respx", "pytest-html>=3.2.0", "pytest-httpx>=0.30.0", "pytest-metadata>=3.0.0", @@ -171,6 +173,7 @@ render_collapsed = true testpaths = ["tests"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +norecursedirs = ["tests/benchmark"] # Do not run performance testing outside of Codspeed filterwarnings = [ # cvprac is raising the next warning "default:pkg_resources is deprecated:DeprecationWarning", @@ -450,13 +453,17 @@ disable = [ # Any rule listed here can be disabled: https://github.com/astral-sh "keyword-arg-before-vararg", "protected-access", "too-many-arguments", - "too-many-positional-arguments", # New in pylint 3.3.0 + "too-many-positional-arguments", "wrong-import-position", "pointless-statement", "broad-exception-caught", "line-too-long", "unused-variable", "redefined-builtin", + "global-statement", + "reimported", + "wrong-import-order", + "wrong-import-position", "abstract-class-instantiated", # Overlap with https://mypy.readthedocs.io/en/stable/error_code_list.html#check-instantiation-of-abstract-classes-abstract "unexpected-keyword-arg", # Overlap with https://mypy.readthedocs.io/en/stable/error_code_list.html#check-arguments-in-calls-call-arg and other rules "no-value-for-parameter" # Overlap with https://mypy.readthedocs.io/en/stable/error_code_list.html#check-arguments-in-calls-call-arg diff --git a/tests/benchmark/__init__.py b/tests/benchmark/__init__.py new file mode 100644 index 000000000..7714c95e7 --- /dev/null +++ b/tests/benchmark/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Benchmark tests for ANTA.""" diff --git a/tests/benchmark/conftest.py b/tests/benchmark/conftest.py new file mode 100644 index 000000000..c07cc99c2 --- /dev/null +++ b/tests/benchmark/conftest.py @@ -0,0 +1,40 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Fixtures for benchmarking ANTA.""" + +import logging + +import pytest +import respx +from _pytest.terminal import TerminalReporter + +from anta.catalog import AntaCatalog + +from .utils import AntaMockEnvironment + +logger = logging.getLogger(__name__) + +TEST_CASE_COUNT = None + + +@pytest.fixture(name="anta_mock_env", scope="session") # We want this fixture to have a scope set to session to avoid reparsing all the unit tests data. +def anta_mock_env_fixture() -> AntaMockEnvironment: + """Return an AntaMockEnvironment for this test session. Also configure respx to mock eAPI responses.""" + global TEST_CASE_COUNT # noqa: PLW0603 + eapi_route = respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}) + env = AntaMockEnvironment() + TEST_CASE_COUNT = env.tests_count + eapi_route.side_effect = env.eapi_response + return env + + +@pytest.fixture # This fixture should have a scope set to function as the indexing result is stored in this object +def catalog(anta_mock_env: AntaMockEnvironment) -> AntaCatalog: + """Fixture that return an ANTA catalog from the AntaMockEnvironment of this test session.""" + return anta_mock_env.catalog + + +def pytest_terminal_summary(terminalreporter: TerminalReporter) -> None: + """Display the total number of ANTA unit test cases used to benchmark.""" + terminalreporter.write_sep("=", f"{TEST_CASE_COUNT} ANTA test cases") diff --git a/tests/benchmark/test_anta.py b/tests/benchmark/test_anta.py new file mode 100644 index 000000000..82d08cf6e --- /dev/null +++ b/tests/benchmark/test_anta.py @@ -0,0 +1,110 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Benchmark tests for ANTA.""" + +import asyncio +import logging +from unittest.mock import patch + +import pytest +import respx +from pytest_codspeed import BenchmarkFixture + +from anta.catalog import AntaCatalog +from anta.inventory import AntaInventory +from anta.result_manager import ResultManager +from anta.result_manager.models import AntaTestStatus +from anta.runner import main + +from .utils import collect, collect_commands + +logger = logging.getLogger(__name__) + + +@pytest.mark.parametrize( + "inventory", + [ + pytest.param({"count": 1, "disable_cache": True, "reachable": False}, id="1 device"), + pytest.param({"count": 2, "disable_cache": True, "reachable": False}, id="2 devices"), + ], + indirect=True, +) +def test_anta_dry_run(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: AntaInventory) -> None: + """Test and benchmark ANTA in Dry-Run Mode.""" + # Disable logging during ANTA execution to avoid having these function time in benchmarks + logging.disable() + + def bench() -> ResultManager: + """Need to wrap the ANTA Runner to instantiate a new ResultManger for each benchmark run.""" + manager = ResultManager() + asyncio.run(main(manager, inventory, catalog, dry_run=True)) + return manager + + manager = benchmark(bench) + + logging.disable(logging.NOTSET) + if len(manager.results) != 0: + pytest.fail("ANTA Dry-Run mode should not return any result", pytrace=False) + if catalog.final_tests_count != len(inventory) * len(catalog.tests): + pytest.fail(f"Expected {len(inventory) * len(catalog.tests)} selected tests but got {catalog.final_tests_count}", pytrace=False) + bench_info = ( + "\n--- ANTA NRFU Dry-Run Benchmark Information ---\n" f"Selected tests: {catalog.final_tests_count}\n" "-----------------------------------------------" + ) + logger.info(bench_info) + + +@pytest.mark.parametrize( + "inventory", + [ + pytest.param({"count": 1, "disable_cache": True}, id="1 device"), + pytest.param({"count": 2, "disable_cache": True}, id="2 devices"), + ], + indirect=True, +) +@patch("anta.models.AntaTest.collect", collect) +@patch("anta.device.AntaDevice.collect_commands", collect_commands) +@respx.mock # Mock eAPI responses +def test_anta(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: AntaInventory) -> None: + """Test and benchmark ANTA. Mock eAPI responses.""" + # Disable logging during ANTA execution to avoid having these function time in benchmarks + logging.disable() + + def bench() -> ResultManager: + """Need to wrap the ANTA Runner to instantiate a new ResultManger for each benchmark run.""" + manager = ResultManager() + asyncio.run(main(manager, inventory, catalog)) + return manager + + manager = benchmark(bench) + + logging.disable(logging.NOTSET) + + if len(catalog.tests) * len(inventory) != len(manager.results): + # This could mean duplicates exist. + # TODO: consider removing this code and refactor unit test data as a dictionary with tuple keys instead of a list + seen = set() + dupes = [] + for test in catalog.tests: + if test in seen: + dupes.append(test) + else: + seen.add(test) + if dupes: + for test in dupes: + msg = f"Found duplicate in test catalog: {test}" + logger.error(msg) + pytest.fail(f"Expected {len(catalog.tests) * len(inventory)} test results but got {len(manager.results)}", pytrace=False) + bench_info = ( + "\n--- ANTA NRFU Benchmark Information ---\n" + f"Test results: {len(manager.results)}\n" + f"Success: {manager.get_total_results({AntaTestStatus.SUCCESS})}\n" + f"Failure: {manager.get_total_results({AntaTestStatus.FAILURE})}\n" + f"Skipped: {manager.get_total_results({AntaTestStatus.SKIPPED})}\n" + f"Error: {manager.get_total_results({AntaTestStatus.ERROR})}\n" + f"Unset: {manager.get_total_results({AntaTestStatus.UNSET})}\n" + "---------------------------------------" + ) + logger.info(bench_info) + assert manager.get_total_results({AntaTestStatus.ERROR}) == 0 + assert manager.get_total_results({AntaTestStatus.UNSET}) == 0 diff --git a/tests/benchmark/utils.py b/tests/benchmark/utils.py new file mode 100644 index 000000000..1017cfe0a --- /dev/null +++ b/tests/benchmark/utils.py @@ -0,0 +1,164 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Utils for the ANTA benchmark tests.""" + +from __future__ import annotations + +import asyncio +import copy +import importlib +import json +import pkgutil +from typing import TYPE_CHECKING, Any + +import httpx + +from anta.catalog import AntaCatalog, AntaTestDefinition +from anta.models import AntaCommand, AntaTest + +if TYPE_CHECKING: + from collections.abc import Generator + from types import ModuleType + + from anta.device import AntaDevice + + +async def collect(self: AntaTest) -> None: + """Patched anta.models.AntaTest.collect() method. + + When generating the catalog, we inject a unit test case name in the custom_field input to be able to retrieve the eos_data for this specific test. + We use this unit test case name in the eAPI request ID. + """ + if self.inputs.result_overwrite is None or self.inputs.result_overwrite.custom_field is None: + msg = f"The custom_field input is not present for test {self.name}" + raise RuntimeError(msg) + await self.device.collect_commands(self.instance_commands, collection_id=f"{self.name}:{self.inputs.result_overwrite.custom_field}") + + +async def collect_commands(self: AntaDevice, commands: list[AntaCommand], collection_id: str) -> None: + """Patched anta.device.AntaDevice.collect_commands() method. + + For the same reason as above, we inject the command index of the test to the eAPI request ID. + """ + await asyncio.gather(*(self.collect(command=command, collection_id=f"{collection_id}:{idx}") for idx, command in enumerate(commands))) + + +class AntaMockEnvironment: # pylint: disable=too-few-public-methods + """Generate an ANTA test catalog from the unit tests data. It can be accessed using the `catalog` attribute of this class instance. + + Also provide the attribute 'eos_data_catalog` with the output of all the commands used in the test catalog. + + Each module in `tests.units.anta_tests` has a `DATA` constant. + The `DATA` structure is a list of dictionaries used to parametrize the test. The list elements have the following keys: + - `name` (str): Test name as displayed by Pytest. + - `test` (AntaTest): An AntaTest subclass imported in the test module - e.g. VerifyUptime. + - `eos_data` (list[dict]): List of data mocking EOS returned data to be passed to the test. + - `inputs` (dict): Dictionary to instantiate the `test` inputs as defined in the class from `test`. + + The keys of `eos_data_catalog` is the tuple (DATA['test'], DATA['name']). The values are `eos_data`. + """ + + def __init__(self) -> None: + self._catalog, self.eos_data_catalog = self._generate_catalog() + self.tests_count = len(self._catalog.tests) + + @property + def catalog(self) -> AntaCatalog: + """AntaMockEnvironment object will always return a new AntaCatalog object based on the initial parsing. + + This is because AntaCatalog objects store indexes when tests are run and we want a new object each time a test is run. + """ + return copy.deepcopy(self._catalog) + + def _generate_catalog(self) -> tuple[AntaCatalog, dict[tuple[str, str], list[dict[str, Any]]]]: + """Generate the `catalog` and `eos_data_catalog` attributes.""" + + def import_test_modules() -> Generator[ModuleType, None, None]: + """Yield all test modules from the given package.""" + package = importlib.import_module("tests.units.anta_tests") + prefix = package.__name__ + "." + for _, module_name, is_pkg in pkgutil.walk_packages(package.__path__, prefix): + if not is_pkg and module_name.split(".")[-1].startswith("test_"): + module = importlib.import_module(module_name) + if hasattr(module, "DATA"): + yield module + + test_definitions = [] + eos_data_catalog = {} + for module in import_test_modules(): + for test_data in module.DATA: + test = test_data["test"] + result_overwrite = AntaTest.Input.ResultOverwrite(custom_field=test_data["name"]) + if test_data["inputs"] is None: + inputs = test.Input(result_overwrite=result_overwrite) + else: + inputs = test.Input(**test_data["inputs"], result_overwrite=result_overwrite) + test_definition = AntaTestDefinition( + test=test, + inputs=inputs, + ) + eos_data_catalog[(test.__name__, test_data["name"])] = test_data["eos_data"] + test_definitions.append(test_definition) + + return (AntaCatalog(tests=test_definitions), eos_data_catalog) + + def eapi_response(self, request: httpx.Request) -> httpx.Response: + """Mock eAPI response. + + If the eAPI request ID has the format `ANTA-{test name}:{unit test name}:{command index}-{command ID}`, + the function will return the eos_data from the unit test case. + + Otherwise, it will mock 'show version' command or raise an Exception. + """ + words_count = 3 + + def parse_req_id(req_id: str) -> tuple[str, str, int] | None: + """Parse the patched request ID from the eAPI request.""" + req_id = req_id.removeprefix("ANTA-").rpartition("-")[0] + words = req_id.split(":", words_count) + if len(words) == words_count: + test_name, unit_test_name, command_index = words + return test_name, unit_test_name, int(command_index) + return None + + jsonrpc = json.loads(request.content) + assert jsonrpc["method"] == "runCmds" + commands = jsonrpc["params"]["cmds"] + ofmt = jsonrpc["params"]["format"] + req_id: str = jsonrpc["id"] + result = None + + # Extract the test name, unit test name, and command index from the request ID + if (words := parse_req_id(req_id)) is not None: + test_name, unit_test_name, idx = words + + # This should never happen, but better be safe than sorry + if (test_name, unit_test_name) not in self.eos_data_catalog: + msg = f"Error while generating a mock response for unit test {unit_test_name} of test {test_name}: eos_data not found" + raise RuntimeError(msg) + + eos_data = self.eos_data_catalog[(test_name, unit_test_name)] + + # This could happen if the unit test data is not correctly defined + if idx >= len(eos_data): + msg = f"Error while generating a mock response for unit test {unit_test_name} of test {test_name}: missing test case in eos_data" + raise RuntimeError(msg) + result = {"output": eos_data[idx]} if ofmt == "text" else eos_data[idx] + elif {"cmd": "show version"} in commands and ofmt == "json": + # Mock 'show version' request performed during inventory refresh. + result = { + "modelName": "pytest", + } + + if result is not None: + return httpx.Response( + status_code=200, + json={ + "jsonrpc": "2.0", + "id": req_id, + "result": [result], + }, + ) + msg = f"The following eAPI Request has not been mocked: {jsonrpc}" + raise NotImplementedError(msg) diff --git a/tests/units/anta_tests/routing/test_generic.py b/tests/units/anta_tests/routing/test_generic.py index 0ac43f3c5..20f83b92b 100644 --- a/tests/units/anta_tests/routing/test_generic.py +++ b/tests/units/anta_tests/routing/test_generic.py @@ -5,8 +5,12 @@ from __future__ import annotations +import sys from typing import Any +import pytest +from pydantic import ValidationError + from anta.tests.routing.generic import VerifyRoutingProtocolModel, VerifyRoutingTableEntry, VerifyRoutingTableSize from tests.units.anta_tests import test @@ -66,16 +70,6 @@ "inputs": {"minimum": 42, "maximum": 666}, "expected": {"result": "failure", "messages": ["routing-table has 1000 routes and not between min (42) and maximum (666)"]}, }, - { - "name": "error-max-smaller-than-min", - "test": VerifyRoutingTableSize, - "eos_data": [{}], - "inputs": {"minimum": 666, "maximum": 42}, - "expected": { - "result": "error", - "messages": ["Minimum 666 is greater than maximum 42"], - }, - }, { "name": "success", "test": VerifyRoutingTableEntry, @@ -310,11 +304,33 @@ "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"], "collect": "all"}, "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']"]}, }, - { - "name": "collect-input-error", - "test": VerifyRoutingTableEntry, - "eos_data": {}, - "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"], "collect": "not-valid"}, - "expected": {"result": "error", "messages": ["Inputs are not valid"]}, - }, ] + + +class TestVerifyRoutingTableSizeInputs: + """Test anta.tests.routing.generic.VerifyRoutingTableSize.Input.""" + + @pytest.mark.parametrize( + ("minimum", "maximum"), + [ + pytest.param(0, 0, id="zero"), + pytest.param(1, 2, id="1<2"), + pytest.param(0, sys.maxsize, id="max"), + ], + ) + def test_valid(self, minimum: int, maximum: int) -> None: + """Test VerifyRoutingTableSize valid inputs.""" + VerifyRoutingTableSize.Input(minimum=minimum, maximum=maximum) + + @pytest.mark.parametrize( + ("minimum", "maximum"), + [ + pytest.param(-2, -1, id="negative"), + pytest.param(2, 1, id="2<1"), + pytest.param(sys.maxsize, 0, id="max"), + ], + ) + def test_invalid(self, minimum: int, maximum: int) -> None: + """Test VerifyRoutingTableSize invalid inputs.""" + with pytest.raises(ValidationError): + VerifyRoutingTableSize.Input(minimum=minimum, maximum=maximum) diff --git a/tests/units/anta_tests/test_configuration.py b/tests/units/anta_tests/test_configuration.py index dbe22d365..d8f86beaa 100644 --- a/tests/units/anta_tests/test_configuration.py +++ b/tests/units/anta_tests/test_configuration.py @@ -60,14 +60,4 @@ "inputs": {"regex_patterns": ["bla", "bleh"]}, "expected": {"result": "failure", "messages": ["Following patterns were not found: 'bla','bleh'"]}, }, - { - "name": "failure-invalid-regex", - "test": VerifyRunningConfigLines, - "eos_data": ["enable password something\nsome other line"], - "inputs": {"regex_patterns": ["["]}, - "expected": { - "result": "error", - "messages": ["1 validation error for Input\nregex_patterns.0\n Value error, Invalid regex: unterminated character set at position 0"], - }, - }, ] diff --git a/tests/units/anta_tests/test_field_notices.py b/tests/units/anta_tests/test_field_notices.py index a30604b8b..8e7c9d8b3 100644 --- a/tests/units/anta_tests/test_field_notices.py +++ b/tests/units/anta_tests/test_field_notices.py @@ -358,8 +358,8 @@ ], "inputs": None, "expected": { - "result": "error", - "messages": ["Error in running test - FixedSystemvrm1 not found"], + "result": "failure", + "messages": ["Error in running test - Component FixedSystemvrm1 not found in 'show version'"], }, }, ] diff --git a/tests/units/anta_tests/test_interfaces.py b/tests/units/anta_tests/test_interfaces.py index 73ef6c6aa..ea8106e84 100644 --- a/tests/units/anta_tests/test_interfaces.py +++ b/tests/units/anta_tests/test_interfaces.py @@ -652,7 +652,7 @@ ], "inputs": {"threshold": 70.0}, "expected": { - "result": "error", + "result": "failure", "messages": ["Interface Ethernet1/1 or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented."], }, }, @@ -797,7 +797,7 @@ ], "inputs": {"threshold": 70.0}, "expected": { - "result": "error", + "result": "failure", "messages": ["Interface Port-Channel31 or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented."], }, }, diff --git a/tests/units/anta_tests/test_mlag.py b/tests/units/anta_tests/test_mlag.py index 1ef547259..193d69c2d 100644 --- a/tests/units/anta_tests/test_mlag.py +++ b/tests/units/anta_tests/test_mlag.py @@ -110,17 +110,6 @@ "inputs": None, "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, }, - { - "name": "error", - "test": VerifyMlagConfigSanity, - "eos_data": [ - { - "dummy": False, - }, - ], - "inputs": None, - "expected": {"result": "error", "messages": ["Incorrect JSON response - 'mlagActive' state was not found"]}, - }, { "name": "failure-global", "test": VerifyMlagConfigSanity, diff --git a/tests/units/anta_tests/test_security.py b/tests/units/anta_tests/test_security.py index 792b06595..549890ad5 100644 --- a/tests/units/anta_tests/test_security.py +++ b/tests/units/anta_tests/test_security.py @@ -7,6 +7,9 @@ from typing import Any +import pytest +from pydantic import ValidationError + from anta.tests.security import ( VerifyAPIHttpsSSL, VerifyAPIHttpStatus, @@ -39,7 +42,7 @@ "test": VerifySSHStatus, "eos_data": ["SSH per host connection limit is 20\nFIPS status: disabled\n\n"], "inputs": None, - "expected": {"result": "error", "messages": ["Could not find SSH status in returned output."]}, + "expected": {"result": "failure", "messages": ["Could not find SSH status in returned output."]}, }, { "name": "failure-ssh-disabled", @@ -581,40 +584,6 @@ ], }, }, - { - "name": "error-wrong-input-rsa", - "test": VerifyAPISSLCertificate, - "eos_data": [], - "inputs": { - "certificates": [ - { - "certificate_name": "ARISTA_ROOT_CA.crt", - "expiry_threshold": 30, - "common_name": "Arista Networks Internal IT Root Cert Authority", - "encryption_algorithm": "RSA", - "key_size": 256, - }, - ] - }, - "expected": {"result": "error", "messages": ["Allowed sizes are (2048, 3072, 4096)."]}, - }, - { - "name": "error-wrong-input-ecdsa", - "test": VerifyAPISSLCertificate, - "eos_data": [], - "inputs": { - "certificates": [ - { - "certificate_name": "ARISTA_SIGNING_CA.crt", - "expiry_threshold": 30, - "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority", - "encryption_algorithm": "ECDSA", - "key_size": 2048, - }, - ] - }, - "expected": {"result": "error", "messages": ["Allowed sizes are (256, 384, 512)."]}, - }, { "name": "success", "test": VerifyBannerLogin, @@ -1229,3 +1198,69 @@ "expected": {"result": "failure", "messages": ["Hardware entropy generation is disabled."]}, }, ] + + +class TestAPISSLCertificate: + """Test anta.tests.security.VerifyAPISSLCertificate.Input.APISSLCertificate.""" + + @pytest.mark.parametrize( + ("model_params", "error"), + [ + pytest.param( + { + "certificate_name": "ARISTA_ROOT_CA.crt", + "expiry_threshold": 30, + "common_name": "Arista Networks Internal IT Root Cert Authority", + "encryption_algorithm": "RSA", + "key_size": 256, + }, + "Value error, `ARISTA_ROOT_CA.crt` key size 256 is invalid for RSA encryption. Allowed sizes are (2048, 3072, 4096).", + id="RSA_wrong_size", + ), + pytest.param( + { + "certificate_name": "ARISTA_SIGNING_CA.crt", + "expiry_threshold": 30, + "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority", + "encryption_algorithm": "ECDSA", + "key_size": 2048, + }, + "Value error, `ARISTA_SIGNING_CA.crt` key size 2048 is invalid for ECDSA encryption. Allowed sizes are (256, 384, 512).", + id="ECDSA_wrong_size", + ), + ], + ) + def test_invalid(self, model_params: dict[str, Any], error: str) -> None: + """Test invalid inputs for anta.tests.security.VerifyAPISSLCertificate.Input.APISSLCertificate.""" + with pytest.raises(ValidationError) as exec_info: + VerifyAPISSLCertificate.Input.APISSLCertificate.model_validate(model_params) + assert error == exec_info.value.errors()[0]["msg"] + + @pytest.mark.parametrize( + "model_params", + [ + pytest.param( + { + "certificate_name": "ARISTA_SIGNING_CA.crt", + "expiry_threshold": 30, + "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority", + "encryption_algorithm": "ECDSA", + "key_size": 256, + }, + id="ECDSA", + ), + pytest.param( + { + "certificate_name": "ARISTA_ROOT_CA.crt", + "expiry_threshold": 30, + "common_name": "Arista Networks Internal IT Root Cert Authority", + "encryption_algorithm": "RSA", + "key_size": 4096, + }, + id="RSA", + ), + ], + ) + def test_valid(self, model_params: dict[str, Any]) -> None: + """Test valid inputs for anta.tests.security.VerifyAPISSLCertificate.Input.APISSLCertificate.""" + VerifyAPISSLCertificate.Input.APISSLCertificate.model_validate(model_params) diff --git a/tests/units/anta_tests/test_system.py b/tests/units/anta_tests/test_system.py index 22b9787b2..1eda8a1d5 100644 --- a/tests/units/anta_tests/test_system.py +++ b/tests/units/anta_tests/test_system.py @@ -76,13 +76,6 @@ "inputs": None, "expected": {"result": "failure", "messages": ["Reload cause is: 'Reload after crash.'"]}, }, - { - "name": "error", - "test": VerifyReloadCause, - "eos_data": [{}], - "inputs": None, - "expected": {"result": "error", "messages": ["No reload causes available"]}, - }, { "name": "success-without-minidump", "test": VerifyCoredump, diff --git a/tests/units/test_custom_types.py b/tests/units/test_custom_types.py index e3dc09d25..697017105 100644 --- a/tests/units/test_custom_types.py +++ b/tests/units/test_custom_types.py @@ -30,6 +30,7 @@ bgp_multiprotocol_capabilities_abbreviations, interface_autocomplete, interface_case_sensitivity, + validate_regex, ) # ------------------------------------------------------------------------------ @@ -281,3 +282,36 @@ def test_interface_case_sensitivity_uppercase() -> None: assert interface_case_sensitivity("ETHERNET") == "ETHERNET" assert interface_case_sensitivity("VLAN") == "VLAN" assert interface_case_sensitivity("LOOPBACK") == "LOOPBACK" + + +@pytest.mark.parametrize( + "str_input", + [ + REGEX_BGP_IPV4_MPLS_VPN, + REGEX_BGP_IPV4_UNICAST, + REGEX_TYPE_PORTCHANNEL, + REGEXP_BGP_IPV4_MPLS_LABELS, + REGEXP_BGP_L2VPN_AFI, + REGEXP_INTERFACE_ID, + REGEXP_PATH_MARKERS, + REGEXP_TYPE_EOS_INTERFACE, + REGEXP_TYPE_HOSTNAME, + REGEXP_TYPE_VXLAN_SRC_INTERFACE, + ], +) +def test_validate_regex_valid(str_input: str) -> None: + """Test validate_regex with valid regex.""" + assert validate_regex(str_input) == str_input + + +@pytest.mark.parametrize( + ("str_input", "error"), + [ + pytest.param("[", "Invalid regex: unterminated character set at position 0", id="unterminated character"), + pytest.param("\\", r"Invalid regex: bad escape \(end of pattern\) at position 0", id="bad escape"), + ], +) +def test_validate_regex_invalid(str_input: str, error: str) -> None: + """Test validate_regex with invalid regex.""" + with pytest.raises(ValueError, match=error): + validate_regex(str_input) From f5d5e4df7177680417933849d38c1078d65a0825 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 27 Sep 2024 14:53:35 +0200 Subject: [PATCH 73/90] ci: Add workflow to run benchmark manually (#839) --- .github/workflows/codspeed.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/codspeed.yml diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 000000000..7bc7e34ac --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,23 @@ +--- +name: Run benchmarks manually +on: + workflow_dispatch: + +jobs: + benchmarks: + name: Benchmark ANTA for Python 3.12 + runs-on: ubuntu-latest + needs: [test-python] + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install .[dev] + - name: Run benchmarks + uses: CodSpeedHQ/action@v3 + with: + token: ${{ secrets.CODSPEED_TOKEN }} + run: pytest --codspeed --no-cov --log-cli-level INFO tests/benchmark From 74b1ff2e0d7ea4cc7197e05fe2e8d67122a12bcd Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 27 Sep 2024 14:56:38 +0200 Subject: [PATCH 74/90] Update codspeed.yml --- .github/workflows/codspeed.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 7bc7e34ac..c9c232306 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -7,7 +7,6 @@ jobs: benchmarks: name: Benchmark ANTA for Python 3.12 runs-on: ubuntu-latest - needs: [test-python] steps: - uses: actions/checkout@v4 - name: Setup Python From 3408217ab6c9345f518b286d3e7444e252505286 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Mon, 30 Sep 2024 15:37:10 +0200 Subject: [PATCH 75/90] refactor: Remove final-tests-counts from catalog, have dry-run work as expected (#840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor: Remove final-tests-counts from catalog, have dry-run work as expected * refactor: create AntaCatalog.clear_indexes() --------- Co-authored-by: Matthieu Tâche --- anta/catalog.py | 25 +++++++++++++++++-------- anta/runner.py | 21 ++++++++++++--------- tests/benchmark/test_anta.py | 14 ++++++-------- tests/conftest.py | 2 +- tests/units/test_catalog.py | 8 ++++---- 5 files changed, 40 insertions(+), 30 deletions(-) diff --git a/anta/catalog.py b/anta/catalog.py index b5a77ad25..9b752fa05 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -296,11 +296,16 @@ def __init__( else: self._filename = Path(filename) - # Default indexes for faster access - self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]] = defaultdict(set) - self.tests_without_tags: set[AntaTestDefinition] = set() - self.indexes_built: bool = False - self.final_tests_count: int = 0 + self.indexes_built: bool + self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]] + self._tests_without_tags: set[AntaTestDefinition] + self._init_indexes() + + def _init_indexes(self) -> None: + """Init indexes related variables.""" + self.tag_to_tests = defaultdict(set) + self._tests_without_tags = set() + self.indexes_built = False @property def filename(self) -> Path | None: @@ -485,7 +490,7 @@ def build_indexes(self, filtered_tests: set[str] | None = None) -> None: - tag_to_tests: A dictionary mapping each tag to a set of tests that contain it. - - tests_without_tags: A set of tests that do not have any tags. + - _tests_without_tags: A set of tests that do not have any tags. Once the indexes are built, the `indexes_built` attribute is set to True. """ @@ -499,11 +504,15 @@ def build_indexes(self, filtered_tests: set[str] | None = None) -> None: for tag in test_tags: self.tag_to_tests[tag].add(test) else: - self.tests_without_tags.add(test) + self._tests_without_tags.add(test) - self.tag_to_tests[None] = self.tests_without_tags + self.tag_to_tests[None] = self._tests_without_tags self.indexes_built = True + def clear_indexes(self) -> None: + """Clear this AntaCatalog instance indexes.""" + self._init_indexes() + def get_tests_by_tags(self, tags: set[str], *, strict: bool = False) -> set[AntaTestDefinition]: """Return all tests that match a given set of tags, according to the specified strictness. diff --git a/anta/runner.py b/anta/runner.py index 6e3290267..12f549daa 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -147,6 +147,7 @@ def prepare_tests( device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set) # Create AntaTestRunner tuples from the tags + final_tests_count = 0 for device in inventory.devices: if tags: if not any(tag in device.tags for tag in tags): @@ -159,9 +160,9 @@ def prepare_tests( # Add the tests with matching tags from device tags device_to_tests[device].update(catalog.get_tests_by_tags(device.tags)) - catalog.final_tests_count += len(device_to_tests[device]) + final_tests_count += len(device_to_tests[device]) - if catalog.final_tests_count == 0: + if len(device_to_tests.values()) == 0: msg = ( f"There are no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current test catalog and device inventory, please verify your inputs." ) @@ -171,13 +172,15 @@ def prepare_tests( return device_to_tests -def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]]) -> list[Coroutine[Any, Any, TestResult]]: +def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinition]], manager: ResultManager) -> list[Coroutine[Any, Any, TestResult]]: """Get the coroutines for the ANTA run. Parameters ---------- selected_tests A mapping of devices to the tests to run. The selected tests are generated by the `prepare_tests` function. + manager + A ResultManager Returns ------- @@ -189,6 +192,7 @@ def get_coroutines(selected_tests: defaultdict[AntaDevice, set[AntaTestDefinitio for test in test_definitions: try: test_instance = test.test(device=device, inputs=test.inputs) + manager.add(test_instance.result) coros.append(test_instance.test()) except Exception as e: # noqa: PERF203, BLE001 # An AntaTest instance is potentially user-defined code. @@ -256,25 +260,26 @@ async def main( # noqa: PLR0913 selected_tests = prepare_tests(selected_inventory, catalog, tests, tags) if selected_tests is None: return + final_tests_count = sum(len(tests) for tests in selected_tests.values()) run_info = ( "--- ANTA NRFU Run Information ---\n" f"Number of devices: {len(inventory)} ({len(selected_inventory)} established)\n" - f"Total number of selected tests: {catalog.final_tests_count}\n" + f"Total number of selected tests: {final_tests_count}\n" f"Maximum number of open file descriptors for the current ANTA process: {limits[0]}\n" "---------------------------------" ) logger.info(run_info) - if catalog.final_tests_count > limits[0]: + if final_tests_count > limits[0]: logger.warning( "The number of concurrent tests is higher than the open file descriptors limit for this ANTA process.\n" "Errors may occur while running the tests.\n" "Please consult the ANTA FAQ." ) - coroutines = get_coroutines(selected_tests) + coroutines = get_coroutines(selected_tests, manager) if dry_run: logger.info("Dry-run mode, exiting before running the tests.") @@ -286,8 +291,6 @@ async def main( # noqa: PLR0913 AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coroutines)) with Catchtime(logger=logger, message="Running ANTA tests"): - test_results = await asyncio.gather(*coroutines) - for r in test_results: - manager.add(r) + await asyncio.gather(*coroutines) log_cache_statistics(selected_inventory.devices) diff --git a/tests/benchmark/test_anta.py b/tests/benchmark/test_anta.py index 82d08cf6e..6885e2e8f 100644 --- a/tests/benchmark/test_anta.py +++ b/tests/benchmark/test_anta.py @@ -38,19 +38,16 @@ def test_anta_dry_run(benchmark: BenchmarkFixture, catalog: AntaCatalog, invento def bench() -> ResultManager: """Need to wrap the ANTA Runner to instantiate a new ResultManger for each benchmark run.""" manager = ResultManager() + catalog.clear_indexes() asyncio.run(main(manager, inventory, catalog, dry_run=True)) return manager manager = benchmark(bench) logging.disable(logging.NOTSET) - if len(manager.results) != 0: - pytest.fail("ANTA Dry-Run mode should not return any result", pytrace=False) - if catalog.final_tests_count != len(inventory) * len(catalog.tests): - pytest.fail(f"Expected {len(inventory) * len(catalog.tests)} selected tests but got {catalog.final_tests_count}", pytrace=False) - bench_info = ( - "\n--- ANTA NRFU Dry-Run Benchmark Information ---\n" f"Selected tests: {catalog.final_tests_count}\n" "-----------------------------------------------" - ) + if len(manager.results) != len(inventory) * len(catalog.tests): + pytest.fail(f"Expected {len(inventory) * len(catalog.tests)} tests but got {len(manager.results)}", pytrace=False) + bench_info = "\n--- ANTA NRFU Dry-Run Benchmark Information ---\n" f"Test count: {len(manager.results)}\n" "-----------------------------------------------" logger.info(bench_info) @@ -73,6 +70,7 @@ def test_anta(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: Anta def bench() -> ResultManager: """Need to wrap the ANTA Runner to instantiate a new ResultManger for each benchmark run.""" manager = ResultManager() + catalog.clear_indexes() asyncio.run(main(manager, inventory, catalog)) return manager @@ -94,7 +92,7 @@ def bench() -> ResultManager: for test in dupes: msg = f"Found duplicate in test catalog: {test}" logger.error(msg) - pytest.fail(f"Expected {len(catalog.tests) * len(inventory)} test results but got {len(manager.results)}", pytrace=False) + pytest.fail(f"Expected {len(catalog.tests) * len(inventory)} tests but got {len(manager.results)}", pytrace=False) bench_info = ( "\n--- ANTA NRFU Benchmark Information ---\n" f"Test results: {len(manager.results)}\n" diff --git a/tests/conftest.py b/tests/conftest.py index 7347d4430..dd535c104 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ DATA_DIR: Path = Path(__file__).parent.resolve() / "data" -@pytest.fixture(params=[{"count": 1}]) +@pytest.fixture(params=[{"count": 1}], ids=["1-reachable-device-without-cache"]) def inventory(request: pytest.FixtureRequest) -> Iterator[AntaInventory]: """Generate an ANTA inventory.""" user = "admin" diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py index c2bb57c93..ca78a870c 100644 --- a/tests/units/test_catalog.py +++ b/tests/units/test_catalog.py @@ -260,10 +260,10 @@ def test_build_indexes_all(self) -> None: """Test AntaCatalog.build_indexes().""" catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_with_tags.yml") catalog.build_indexes() - assert len(catalog.tests_without_tags) == 6 + assert len(catalog._tests_without_tags) == 6 assert "leaf" in catalog.tag_to_tests assert len(catalog.tag_to_tests["leaf"]) == 3 - all_unique_tests = catalog.tests_without_tags + all_unique_tests = catalog._tests_without_tags for tests in catalog.tag_to_tests.values(): all_unique_tests.update(tests) assert len(all_unique_tests) == 11 @@ -275,8 +275,8 @@ def test_build_indexes_filtered(self) -> None: catalog.build_indexes({"VerifyUptime", "VerifyCoredump", "VerifyL3MTU"}) assert "leaf" in catalog.tag_to_tests assert len(catalog.tag_to_tests["leaf"]) == 1 - assert len(catalog.tests_without_tags) == 1 - all_unique_tests = catalog.tests_without_tags + assert len(catalog._tests_without_tags) == 1 + all_unique_tests = catalog._tests_without_tags for tests in catalog.tag_to_tests.values(): all_unique_tests.update(tests) assert len(all_unique_tests) == 4 From 2a309de542f68fa5f49c8c14b41bc5d148de5bef Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 00:32:13 +0200 Subject: [PATCH 76/90] ci: pre-commit autoupdate (#846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.7 → v0.6.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.7...v0.6.8) - [github.com/pycqa/pylint: v3.3.0 → v3.3.1](https://github.com/pycqa/pylint/compare/v3.3.0...v3.3.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba1e0d8a2..7a895170e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 + rev: v0.6.8 hooks: - id: ruff name: Run Ruff linter @@ -52,7 +52,7 @@ repos: name: Run Ruff formatter - repo: https://github.com/pycqa/pylint - rev: "v3.3.0" + rev: "v3.3.1" hooks: - id: pylint name: Check code style with pylint From fc644a124ee7cfc78fcf3d2ae78fcfeb1bb5d22a Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Wed, 2 Oct 2024 14:47:28 +0200 Subject: [PATCH 77/90] doc: Update doc and lint it (#847) Doc: Update doc and lint it --- .github/markdownlint.yaml | 98 +++++++++++++++++++++++++++ .github/markdownlintignore | 0 .pre-commit-config.yaml | 10 +++ anta/runner.py | 2 +- docs/README.md | 2 + docs/advanced_usages/as-python-lib.md | 13 ++-- docs/advanced_usages/caching.md | 10 ++- docs/advanced_usages/custom-tests.md | 17 ++--- docs/api/catalog.md | 1 + docs/api/device.md | 6 +- docs/api/inventory.md | 1 + docs/api/models.md | 13 ++-- docs/api/result_manager.md | 5 +- docs/api/result_manager_models.md | 5 +- docs/api/runner.md | 1 + docs/api/tests.md | 5 +- docs/api/types.md | 1 + docs/cli/check.md | 5 +- docs/cli/debug.md | 6 +- docs/cli/exec.md | 9 ++- docs/cli/get-inventory-information.md | 6 +- docs/cli/inv-from-ansible.md | 15 ++-- docs/cli/inv-from-cvp.md | 9 +-- docs/cli/nrfu.md | 16 ++++- docs/cli/overview.md | 5 +- docs/cli/tag-management.md | 7 +- docs/contribution.md | 20 +++--- docs/faq.md | 13 ++-- docs/getting-started.md | 4 +- docs/requirements-and-installation.md | 7 +- docs/troubleshooting.md | 15 ++-- docs/usage-inventory-catalog.md | 9 ++- mkdocs.yml | 8 +-- 33 files changed, 237 insertions(+), 107 deletions(-) create mode 100644 .github/markdownlint.yaml create mode 100644 .github/markdownlintignore diff --git a/.github/markdownlint.yaml b/.github/markdownlint.yaml new file mode 100644 index 000000000..1804cf75d --- /dev/null +++ b/.github/markdownlint.yaml @@ -0,0 +1,98 @@ +# markdownlint configuration +# the definitive list of rules for markdownlint can be found: +# https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md +# +# only deviations from the defaults are noted here or where there's an opinion +# being expressed. + +# default state for all rules +default: + true + +# heading style +MD003: + style: "atx" + +# unordered list style +MD004: + style: "dash" + +# unorderd list indentation (2-spaces) +# keep it tight yo! +MD007: + indent: 2 + +# line length +MD013: + false + # a lot of debate whether to wrap or not wrap + +# multiple headings with the same content +# siblings_only is set here to allow for common header values in structured +# documents +MD024: + siblings_only: true + +# Multiple top-level headings in the same document +MD025: + front_matter_title: "" + +# MD029/ol-prefix - Ordered list item prefix +MD029: + # List style + style: "ordered" + +# fenced code should be surrounded by blank lines default: true +MD031: + true + +# lists should be surrounded by blank lines default: true +MD032: + true + +# MD033/no-inline-html - Inline HTML +MD033: + false + +# bare URL - bare URLs should be wrapped in angle brackets +# +MD034: + false + +# horizontal rule style default: consistent +MD035: + style: "---" + +# first line in a file to be a top-level heading +# since we're using front-matter, this +MD041: + false + +# proper-names - proper names to have the correct capitalization +# probably not entirely helpful in a technical writing environment. +MD044: + false + +# block style - disabled to allow for admonitions +MD046: + false + +# MD048/code-fence-style - Code fence style +MD048: + # Code fence style + style: "backtick" + +# MD049/Emphasis style should be consistent +MD049: + # Emphasis style should be consistent + style: "asterisk" + +# MD050/Strong style should be consistent +MD050: + # Strong style should be consistent + style: "asterisk" + +# MD037/no-space-in-emphasis - Spaces inside emphasis markers +# This incorrectly catches stars used in table contents, so *foo | *bar is triggered to remove the space between | and *bar. +MD037: + false diff --git a/.github/markdownlintignore b/.github/markdownlintignore new file mode 100644 index 000000000..e69de29bb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a895170e..244270375 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -95,3 +95,13 @@ repos: - types-pyOpenSSL - pytest files: ^(anta|tests)/ + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.41.0 + hooks: + - id: markdownlint + name: Check Markdown files style. + args: + - --config=.github/markdownlint.yaml + - --ignore-path=.github/markdownlintignore + - --fix diff --git a/anta/runner.py b/anta/runner.py index 12f549daa..dcb2d962e 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -89,7 +89,7 @@ async def setup_inventory(inventory: AntaInventory, tags: set[str] | None, devic devices Devices on which to run tests. None means all devices. established_only - If True use return only devices where a connection is established. + If True use return only devices where a connection is established. Returns ------- diff --git a/docs/README.md b/docs/README.md index 4f11feebe..07ac3d28b 100755 --- a/docs/README.md +++ b/docs/README.md @@ -39,7 +39,9 @@ If you plan to use ANTA only as a CLI tool you can use `pipx` to install it. [`pipx`](https://pipx.pypa.io/stable/) is a tool to install and run python applications in isolated environments. Refer to `pipx` instructions to install on your system. `pipx` installs ANTA in an isolated python environment and makes it available globally. + **This is not recommended if you plan to contribute to ANTA** + ```bash # Install ANTA CLI with pipx diff --git a/docs/advanced_usages/as-python-lib.md b/docs/advanced_usages/as-python-lib.md index d8790f3ef..d64104a95 100644 --- a/docs/advanced_usages/as-python-lib.md +++ b/docs/advanced_usages/as-python-lib.md @@ -32,7 +32,6 @@ It uses the [aio-eapi](https://github.com/jeremyschulman/aio-eapi) eAPI client a The [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) class is a subclass of the standard Python type [dict](https://docs.python.org/3/library/stdtypes.html#dict). The keys of this dictionary are the device names, the values are [AntaDevice](../api/device.md#anta.device.AntaDevice) instances. - [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) provides methods to interact with the ANTA inventory: - The [add_device()](../api/inventory.md#anta.inventory.AntaInventory.add_device) method adds an [AntaDevice](../api/device.md#anta.device.AntaDevice) instance to the inventory. Adding an entry to [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) with a key different from the device name is not allowed. @@ -42,13 +41,11 @@ The [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) class is a ## Examples -##### Parse an ANTA inventory file - -> This script parses an ANTA inventory file, connects to devices and print their status +### Parse an ANTA inventory file ```python """ -Example +This script parses an ANTA inventory file, connects to devices and print their status. """ import asyncio @@ -84,13 +81,11 @@ if __name__ == "__main__": ??? note "How to create your inventory file" Please visit this [dedicated section](../usage-inventory-catalog.md) for how to use inventory and catalog files. -##### Run EOS commands - -> This script runs a list of EOS commands on reachable devices +### Run EOS commands ```python """ -Example +This script runs a list of EOS commands on reachable devices. """ # This is needed to run the script for python < 3.10 for typing annotations from __future__ import annotations diff --git a/docs/advanced_usages/caching.md b/docs/advanced_usages/caching.md index 7de310de7..8b089cec3 100644 --- a/docs/advanced_usages/caching.md +++ b/docs/advanced_usages/caching.md @@ -10,7 +10,7 @@ ANTA is a streamlined Python framework designed for efficient interaction with n By default, ANTA utilizes [aiocache](https://github.com/aio-libs/aiocache)'s memory cache backend, also called [`SimpleMemoryCache`](https://aiocache.aio-libs.org/en/v0.12.2/caches.html#simplememorycache). This library aims for simplicity and supports asynchronous operations to go along with Python `asyncio` used in ANTA. -The `_init_cache()` method of the [AntaDevice](../advanced_usages/as-python-lib.md#antadevice-abstract-class) abstract class initializes the cache. Child classes can override this method to tweak the cache configuration: +The `_init_cache()` method of the [AntaDevice](../api/device.md#anta.device.AntaDevice) abstract class initializes the cache. Child classes can override this method to tweak the cache configuration: ```python def _init_cache(self) -> None: @@ -29,7 +29,7 @@ The cache is initialized per `AntaDevice` and uses the following cache key desig `:` -The `uid` is an attribute of [AntaCommand](../advanced_usages/as-python-lib.md#antacommand-class), which is a unique identifier generated from the command, version, revision and output format. +The `uid` is an attribute of [AntaCommand](../api/models.md#anta.models.AntaCommand), which is a unique identifier generated from the command, version, revision and output format. Each UID has its own asyncio lock. This design allows coroutines that need to access the cache for different UIDs to do so concurrently. The locks are managed by the `self.cache_locks` dictionary. @@ -44,10 +44,13 @@ Caching is enabled by default in ANTA following the previous configuration and m There might be scenarios where caching is not wanted. You can disable caching in multiple ways in ANTA: 1. Caching can be disabled globally, for **ALL** commands on **ALL** devices, using the `--disable-cache` global flag when invoking anta at the [CLI](../cli/overview.md#invoking-anta-cli): + ```bash anta --disable-cache --username arista --password arista nrfu table ``` + 2. Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` when defining the ANTA [Inventory](../usage-inventory-catalog.md#device-inventory) file: + ```yaml anta_inventory: hosts: @@ -69,9 +72,10 @@ There might be scenarios where caching is not wanted. You can disable caching in end: 172.22.22.19 disable_cache: True ``` + This approach effectively disables caching for **ALL** commands sent to devices targeted by the `disable_cache` key. -3. For tests developers, caching can be disabled for a specific [`AntaCommand`](../advanced_usages/as-python-lib.md#antacommand-class) or [`AntaTemplate`](../advanced_usages/as-python-lib.md#antatemplate-class) by setting the `use_cache` attribute to `False`. That means the command output will always be collected on the device and therefore, never use caching. +3. For tests developers, caching can be disabled for a specific [`AntaCommand`](../api/models.md#anta.models.AntaCommand) or [`AntaTemplate`](../api/models.md#anta.models.AntaTemplate) by setting the `use_cache` attribute to `False`. That means the command output will always be collected on the device and therefore, never use caching. ### Disable caching in a child class of `AntaDevice` diff --git a/docs/advanced_usages/custom-tests.md b/docs/advanced_usages/custom-tests.md index 958a05539..d81f32dcf 100644 --- a/docs/advanced_usages/custom-tests.md +++ b/docs/advanced_usages/custom-tests.md @@ -4,7 +4,7 @@ ~ that can be found in the LICENSE file. --> -!!! info "" +!!! info This documentation applies for both creating tests in ANTA or creating your own test package. ANTA is not only a Python library with a CLI and a collection of built-in tests, it is also a framework you can extend by building your own tests. @@ -64,7 +64,7 @@ Full AntaTest API documentation is available in the [API documentation section]( - `name` (`str`): Name of the test. Used during reporting. - `description` (`str`): A human readable description of your test. - `categories` (`list[str]`): A list of categories in which the test belongs. -- `commands` (`[list[AntaCommand | AntaTemplate]]`): A list of command to collect from devices. This list __must__ be a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) or [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances. Rendering [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances will be discussed later. +- `commands` (`[list[AntaCommand | AntaTemplate]]`): A list of command to collect from devices. This list **must** be a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) or [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances. Rendering [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances will be discussed later. !!! info All these class attributes are mandatory. If any attribute is missing, a `NotImplementedError` exception will be raised during class instantiation. @@ -87,7 +87,6 @@ Full AntaTest API documentation is available in the [API documentation section]( show_root_toc_entry: false heading_level: 10 - !!! note "Logger object" ANTA already provides comprehensive logging at every steps of a test execution. The [AntaTest](../api/models.md#anta.models.AntaTest) class also provides a `logger` attribute that is a Python logger specific to the test instance. See [Python documentation](https://docs.python.org/3/library/logging.html) for more information. @@ -140,8 +139,8 @@ Full `ResultOverwrite` model documentation is available in [API documentation se ### Methods -- [test(self) -> None](../api/models.md#anta.models.AntaTest.test): This is an abstract method that __must__ be implemented. It contains the test logic that can access the collected command outputs using the `instance_commands` instance attribute, access the test inputs using the `inputs` instance attribute and __must__ set the `result` instance attribute accordingly. It must be implemented using the `AntaTest.anta_test` decorator that provides logging and will collect commands before executing the `test()` method. -- [render(self, template: AntaTemplate) -> list[AntaCommand]](../api/models.md#anta.models.AntaTest.render): This method only needs to be implemented if [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances are present in the `commands` class attribute. It will be called for every [AntaTemplate](../api/models.md#anta.models.AntaTemplate) occurrence and __must__ return a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) using the [AntaTemplate.render()](../api/models.md#anta.models.AntaTemplate.render) method. It can access test inputs using the `inputs` instance attribute. +- [test(self) -> None](../api/models.md#anta.models.AntaTest.test): This is an abstract method that **must** be implemented. It contains the test logic that can access the collected command outputs using the `instance_commands` instance attribute, access the test inputs using the `inputs` instance attribute and **must** set the `result` instance attribute accordingly. It must be implemented using the `AntaTest.anta_test` decorator that provides logging and will collect commands before executing the `test()` method. +- [render(self, template: AntaTemplate) -> list[AntaCommand]](../api/models.md#anta.models.AntaTest.render): This method only needs to be implemented if [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances are present in the `commands` class attribute. It will be called for every [AntaTemplate](../api/models.md#anta.models.AntaTemplate) occurrence and **must** return a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) using the [AntaTemplate.render()](../api/models.md#anta.models.AntaTemplate.render) method. It can access test inputs using the `inputs` instance attribute. ## Test execution @@ -201,9 +200,9 @@ class (AntaTest): !!! tip "Command revision and version" * Most of EOS commands return a JSON structure according to a model (some commands may not be modeled hence the necessity to use `text` outformat sometimes. - * The model can change across time (adding feature, ... ) and when the model is changed in a non backward-compatible way, the __revision__ number is bumped. The initial model starts with __revision__ 1. - * A __revision__ applies to a particular CLI command whereas a __version__ is global to an eAPI call. The __version__ is internally translated to a specific __revision__ for each CLI command in the RPC call. The currently supported __version__ values are `1` and `latest`. - * A __revision takes precedence over a version__ (e.g. if a command is run with version="latest" and revision=1, the first revision of the model is returned) + * The model can change across time (adding feature, ... ) and when the model is changed in a non backward-compatible way, the **revision** number is bumped. The initial model starts with **revision** 1. + * A **revision** applies to a particular CLI command whereas a **version** is global to an eAPI call. The **version** is internally translated to a specific **revision** for each CLI command in the RPC call. The currently supported **version** values are `1` and `latest`. + * A **revision takes precedence over a version** (e.g. if a command is run with version="latest" and revision=1, the first revision of the model is returned) * By default, eAPI returns the first revision of each model to ensure that when upgrading, integrations with existing tools are not broken. This is done by using by default `version=1` in eAPI calls. By default, ANTA uses `version="latest"` in AntaCommand, but when developing tests, the revision MUST be provided when the outformat of the command is `json`. As explained earlier, this is to ensure that the eAPI always returns the same output model and that the test remains always valid from the day it was created. For some commands, you may also want to run them with a different revision or version. @@ -277,6 +276,7 @@ class (AntaTest): ``` The logic usually includes the following different stages: + 1. Parse the command outputs using the `self.instance_commands` instance attribute. 2. If needed, access the test inputs using the `self.inputs` instance attribute and write your conditional logic. 3. Set the `result` instance attribute to reflect the test result by either calling `self.result.is_success()` or `self.result.is_failure("")`. Sometimes, setting the test result to `skipped` using `self.result.is_skipped("")` can make sense (e.g. testing the OSPF neighbor states but no neighbor was found). However, you should not need to catch any exception and set the test result to `error` since the error handling is done by the framework, see below. @@ -357,6 +357,7 @@ anta_custom.dc_project: - VerifyFeatureX: minimum: 1 ``` + And now you can run your NRFU tests with the CLI: ```bash diff --git a/docs/api/catalog.md b/docs/api/catalog.md index fc719eacc..44cc4dfb7 100644 --- a/docs/api/catalog.md +++ b/docs/api/catalog.md @@ -5,6 +5,7 @@ --> ### ::: anta.catalog.AntaCatalog + options: filters: ["!^_[^_]", "!__str__"] diff --git a/docs/api/device.md b/docs/api/device.md index 9401f59af..136fec75b 100644 --- a/docs/api/device.md +++ b/docs/api/device.md @@ -6,18 +6,20 @@ # AntaDevice base class -![](../imgs/uml/anta.device.AntaDevice.jpeg) +![AntaDevice UML model](../imgs/uml/anta.device.AntaDevice.jpeg) ## ::: anta.device.AntaDevice + options: filters: ["!^_[^_]", "!__(eq|rich_repr)__", "_collect"] # Async EOS device class -![](../imgs/uml/anta.device.AsyncEOSDevice.jpeg) +![AsyncEOSDevice UML model](../imgs/uml/anta.device.AsyncEOSDevice.jpeg) ## ::: anta.device.AsyncEOSDevice + options: filters: ["!^_[^_]", "!__(eq|rich_repr)__", "_collect"] diff --git a/docs/api/inventory.md b/docs/api/inventory.md index 5e4400cfc..b826b9ff5 100644 --- a/docs/api/inventory.md +++ b/docs/api/inventory.md @@ -5,6 +5,7 @@ --> ### ::: anta.inventory.AntaInventory + options: filters: ["!^_[^_]", "!__str__"] diff --git a/docs/api/models.md b/docs/api/models.md index 3175fce54..1b360de98 100644 --- a/docs/api/models.md +++ b/docs/api/models.md @@ -6,17 +6,18 @@ # Test definition -![](../imgs/uml/anta.models.AntaTest.jpeg) +![AntaTest UML model](../imgs/uml/anta.models.AntaTest.jpeg) + +## ::: anta.models.AntaTest -### ::: anta.models.AntaTest options: filters: ["!^_[^_]", "!__init_subclass__", "!update_progress"] # Command definition -![](../imgs/uml/anta.models.AntaCommand.jpeg) +![AntaCommand UML model](../imgs/uml/anta.models.AntaCommand.jpeg) -### ::: anta.models.AntaCommand +## ::: anta.models.AntaCommand !!! warning CLI commands are protected to avoid execution of critical commands such as `reload` or `write erase`. @@ -27,6 +28,6 @@ # Template definition -![](../imgs/uml/anta.models.AntaTemplate.jpeg) +![AntaTemplate UML model](../imgs/uml/anta.models.AntaTemplate.jpeg) -### ::: anta.models.AntaTemplate +## ::: anta.models.AntaTemplate diff --git a/docs/api/result_manager.md b/docs/api/result_manager.md index dca0a19dd..9fc978a0b 100644 --- a/docs/api/result_manager.md +++ b/docs/api/result_manager.md @@ -6,8 +6,9 @@ # Result Manager definition -![](../imgs/uml/anta.result_manager.ResultManager.jpeg) +![ResultManager UML model](../imgs/uml/anta.result_manager.ResultManager.jpeg) + +## ::: anta.result_manager.ResultManager -### ::: anta.result_manager.ResultManager options: filters: ["!^_[^_]", "!^__len__"] diff --git a/docs/api/result_manager_models.md b/docs/api/result_manager_models.md index d0ccc7983..42e264899 100644 --- a/docs/api/result_manager_models.md +++ b/docs/api/result_manager_models.md @@ -6,8 +6,9 @@ # Test Result model -![](../imgs/uml/anta.result_manager.models.TestResult.jpeg) +![TestResult UML model](../imgs/uml/anta.result_manager.models.TestResult.jpeg) + +## ::: anta.result_manager.models.TestResult -### ::: anta.result_manager.models.TestResult options: filters: ["!^_[^_]", "!__str__"] diff --git a/docs/api/runner.md b/docs/api/runner.md index 27fbaa235..a2de00758 100644 --- a/docs/api/runner.md +++ b/docs/api/runner.md @@ -5,5 +5,6 @@ --> ### ::: anta.runner + options: filters: ["!^_[^_]", "!__str__"] diff --git a/docs/api/tests.md b/docs/api/tests.md index 2775a01ee..1ca4cb7b3 100644 --- a/docs/api/tests.md +++ b/docs/api/tests.md @@ -1,11 +1,12 @@ +--- +anta_title: ANTA Tests Landing Page +--- -# ANTA Tests Landing Page - This section describes all the available tests provided by the ANTA package. ## Available Tests diff --git a/docs/api/types.md b/docs/api/types.md index 806ab6308..a633e0430 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -5,6 +5,7 @@ --> ### ::: anta.custom_types + options: show_if_no_docstring: true show_root_full_path: true diff --git a/docs/cli/check.md b/docs/cli/check.md index 257ac73d8..c230722bb 100644 --- a/docs/cli/check.md +++ b/docs/cli/check.md @@ -1,11 +1,12 @@ +--- +anta_title: ANTA check commands +--- -# ANTA check commands - The ANTA check command allow to execute some checks on the ANTA input files. Only checking the catalog is currently supported. diff --git a/docs/cli/debug.md b/docs/cli/debug.md index b0b8a164f..60ac74f69 100644 --- a/docs/cli/debug.md +++ b/docs/cli/debug.md @@ -1,11 +1,12 @@ +--- +anta_title: ANTA debug commands +--- -# ANTA debug commands - The ANTA CLI includes a set of debugging tools, making it easier to build and test ANTA content. This functionality is accessed via the `debug` subcommand and offers the following options: - Executing a command on a device from your inventory and retrieving the result. @@ -158,6 +159,7 @@ Run templated command 'show vlan {vlan_id}' with {'vlan_id': '10'} on DC1-LEAF1A 'sourceDetail': '' } ``` + !!! warning If multiple arguments of the same key are provided, only the last argument value will be kept in the template parameters. diff --git a/docs/cli/exec.md b/docs/cli/exec.md index 061a19e1f..2eb12eec5 100644 --- a/docs/cli/exec.md +++ b/docs/cli/exec.md @@ -1,14 +1,16 @@ +--- +anta_title: Executing Commands on Devices +--- -# Executing Commands on Devices - ANTA CLI provides a set of entrypoints to facilitate remote command execution on EOS devices. -### EXEC Command overview +## EXEC command overview + ```bash anta exec --help Usage: anta exec [OPTIONS] COMMAND [ARGS]... @@ -133,6 +135,7 @@ json_format: text_format: - show bfd peers ``` + ### Example ```bash diff --git a/docs/cli/get-inventory-information.md b/docs/cli/get-inventory-information.md index 1831484a0..6fe9dc94d 100644 --- a/docs/cli/get-inventory-information.md +++ b/docs/cli/get-inventory-information.md @@ -1,11 +1,12 @@ +--- +anta_title: Retrieving Inventory Information +--- -# Retrieving Inventory Information - The ANTA CLI offers multiple entrypoints to access data from your local inventory. ## Inventory used of examples @@ -167,7 +168,6 @@ Options: --help Show this message and exit. ``` - !!! tip In its default mode, `anta get inventory` provides only information that doesn't rely on a device connection. If you are interested in obtaining connection-dependent details, like the hardware model, please use the `--connected` option. diff --git a/docs/cli/inv-from-ansible.md b/docs/cli/inv-from-ansible.md index b2672e20a..6bbaca926 100644 --- a/docs/cli/inv-from-ansible.md +++ b/docs/cli/inv-from-ansible.md @@ -1,14 +1,15 @@ +--- +anta_title: Create an Inventory from Ansible inventory +--- -# Create an Inventory from Ansible inventory - In large setups, it might be beneficial to construct your inventory based on your Ansible inventory. The `from-ansible` entrypoint of the `get` command enables the user to create an ANTA inventory from Ansible. -### Command overview +## Command overview ```bash $ anta get from-ansible --help @@ -32,9 +33,8 @@ Options: !!! warning - `anta get from-ansible` does not support inline vaulted variables, comment them out to generate your inventory. - If the vaulted variable is necessary to build the inventory (e.g. `ansible_host`), it needs to be unvaulted for `from-ansible` command to work." - + `anta get from-ansible` does not support inline vaulted variables, comment them out to generate your inventory. + If the vaulted variable is necessary to build the inventory (e.g. `ansible_host`), it needs to be unvaulted for `from-ansible` command to work." The output is an inventory where the name of the container is added as a tag for each host: @@ -54,8 +54,7 @@ anta_inventory: By default, if user does not provide `--output` file, anta will save output to configured anta inventory (`anta --inventory`). If the output file has content, anta will ask user to overwrite when running in interactive console. This mechanism can be controlled by triggers in case of CI usage: `--overwrite` to force anta to overwrite file. If not set, anta will exit - -### Command output +## Command output `host` value is coming from the `ansible_host` key in your inventory while `name` is the name you defined for your host. Below is an ansible inventory example used to generate previous inventory: diff --git a/docs/cli/inv-from-cvp.md b/docs/cli/inv-from-cvp.md index a37af62f1..9717870ad 100644 --- a/docs/cli/inv-from-cvp.md +++ b/docs/cli/inv-from-cvp.md @@ -1,17 +1,18 @@ +--- +anta_title: Create an Inventory from CloudVision +--- -# Create an Inventory from CloudVision - In large setups, it might be beneficial to construct your inventory based on CloudVision. The `from-cvp` entrypoint of the `get` command enables the user to create an ANTA inventory from CloudVision. !!! info The current implementation only works with on-premises CloudVision instances, not with CloudVision as a Service (CVaaS). -### Command overview +## Command overview ```bash Usage: anta get from-cvp [OPTIONS] @@ -54,7 +55,7 @@ anta_inventory: !!! warning The current implementation only considers devices directly attached to a specific container when using the `--cvp-container` option. -### Creating an inventory from multiple containers +## Creating an inventory from multiple containers If you need to create an inventory from multiple containers, you can use a bash command and then manually concatenate files to create a single inventory file: diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index 579fbdeef..6c360b7f9 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -1,11 +1,12 @@ +--- +anta_title: Execute Network Readiness For Use (NRFU) Testing +--- -# Execute Network Readiness For Use (NRFU) Testing - ANTA provides a set of commands for performing NRFU tests on devices. These commands are under the `anta nrfu` namespace and offer multiple output format options: - [Text view](#performing-nrfu-with-text-rendering) @@ -67,6 +68,7 @@ Options: ```bash anta nrfu --device DC1-LEAF1A text ``` + ![anta nrfu text results](../imgs/anta-nrfu-text-output.png){ loading=lazy width="1600" } ## Performing NRFU with table rendering @@ -92,6 +94,7 @@ The `--group-by` option show a summarized view of the test results per host or p ```bash anta nrfu --tags LEAF table ``` + ![anta nrfu table results](../imgs/anta-nrfu-table-output.png){ loading=lazy width="1600" } For larger setups, you can also group the results by host or test to get a summarized view: @@ -99,11 +102,13 @@ For larger setups, you can also group the results by host or test to get a summa ```bash anta nrfu table --group-by device ``` + ![$1anta nrfu table group_by_host_output](../imgs/anta-nrfu-table-group-by-host-output.png){ loading=lazy width="1600" } ```bash anta nrfu table --group-by test ``` + ![$1anta nrfu table group_by_test_output](../imgs/anta-nrfu-table-group-by-test-output.png){ loading=lazy width="1600" } To get more specific information, it is possible to filter on a single device or a single test: @@ -111,11 +116,13 @@ To get more specific information, it is possible to filter on a single device or ```bash anta nrfu --device spine1 table ``` + ![$1anta nrfu table filter_host_output](../imgs/anta-nrfu-table-filter-host-output.png){ loading=lazy width="1600" } ```bash anta nrfu --test VerifyZeroTouch table ``` + ![$1anta nrfu table filter_test_output](../imgs/anta-nrfu-table-filter-test-output.png){ loading=lazy width="1600" } ## Performing NRFU with JSON rendering @@ -143,9 +150,10 @@ The `--output` option allows you to save the JSON report as a file. If specified ```bash anta nrfu --tags LEAF json ``` + ![$1anta nrfu json results](../imgs/anta-nrfu-json-output.png){ loading=lazy width="1600" } -## Performing NRFU and saving results in a CSV file. +## Performing NRFU and saving results in a CSV file The `csv` command in NRFU testing is useful for generating a CSV file with all tests result. This file can be easily analyzed and filtered by operator for reporting purposes. @@ -209,6 +217,7 @@ Options: ANTA_NRFU_TPL_REPORT_OUTPUT] --help Show this message and exit. ``` + The `--template` option is used to specify the Jinja2 template file for generating the custom report. The `--output` option allows you to choose the path where the final report will be saved. @@ -218,6 +227,7 @@ The `--output` option allows you to choose the path where the final report will ```bash anta nrfu --tags LEAF tpl-report --template ./custom_template.j2 ``` + ![$1anta nrfu tpl_results](../imgs/anta-nrfu-tpl-report-output.png){ loading=lazy width="1600" } The template `./custom_template.j2` is a simple Jinja2 template: diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 5f1540095..f1247b7e2 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -1,11 +1,12 @@ +--- +anta_title: Overview of ANTA's Command-Line Interface (CLI) +--- -# Overview of ANTA's Command-Line Interface (CLI) - ANTA provides a powerful Command-Line Interface (CLI) to perform a wide range of operations. This document provides a comprehensive overview of ANTA CLI usage and its commands. ANTA can also be used as a Python library, allowing you to build your own tools based on it. Visit this [page](../advanced_usages/as-python-lib.md) for more details. diff --git a/docs/cli/tag-management.md b/docs/cli/tag-management.md index 0bba29e70..9a74b7f83 100644 --- a/docs/cli/tag-management.md +++ b/docs/cli/tag-management.md @@ -3,7 +3,6 @@ ~ Use of this source code is governed by the Apache License 2.0 ~ that can be found in the LICENSE file. --> -## Overview ANTA commands can be used with a `--tags` option. This option **filters the inventory** with the specified tag(s) when running the command. @@ -112,7 +111,7 @@ anta.tests.interfaces: The following examples use the inventory and test catalog defined above. -##### No `--tags` option +#### No `--tags` option Tests without tags are run on all devices. Tests with tags will only run on devices with matching tags. @@ -140,7 +139,7 @@ Total number of selected tests: 27 └────────┴──────────────┴──────────────┴──────────────┴─────────────┴────────────────────────────────────┘ ``` -##### Single tag +#### Single tag With a tag specified, only tests matching this tag will be run on matching devices. @@ -166,7 +165,7 @@ leaf2 :: VerifyMlagStatus :: SKIPPED (MLAG is disabled) In this case, only `leaf` devices defined in the inventory are used to run tests marked with the `leaf` in the test catalog. -##### Multiple tags +#### Multiple tags It is possible to use multiple tags using the `--tags tag1,tag2` syntax. diff --git a/docs/contribution.md b/docs/contribution.md index 387e2f4c7..6299139e0 100644 --- a/docs/contribution.md +++ b/docs/contribution.md @@ -1,11 +1,12 @@ +--- +anta_title: How to contribute to ANTA +--- -# How to contribute to ANTA - Contribution model is based on a fork-model. Don't push to aristanetworks/anta directly. Always do a branch in your forked repository and create a PR. To help development, open your PR as soon as possible even in draft mode. It helps other to know on what you are working on and avoid duplicate PRs. @@ -104,14 +105,13 @@ See https://docs.pytest.org/en/7.3.x/how-to/parametrize.html#basic-pytest-genera The `DATA` structure is a list of dictionaries used to parametrize the test. The list elements have the following keys: - - `name` (str): Test name as displayed by Pytest. - - `test` (AntaTest): An AntaTest subclass imported in the test module - e.g. VerifyUptime. - - `eos_data` (list[dict]): List of data mocking EOS returned data to be passed to the test. - - `inputs` (dict): Dictionary to instantiate the `test` inputs as defined in the class from `test`. - - `expected` (dict): Expected test result structure, a dictionary containing a key +- `name` (str): Test name as displayed by Pytest. +- `test` (AntaTest): An AntaTest subclass imported in the test module - e.g. VerifyUptime. +- `eos_data` (list[dict]): List of data mocking EOS returned data to be passed to the test. +- `inputs` (dict): Dictionary to instantiate the `test` inputs as defined in the class from `test`. +- `expected` (dict): Expected test result structure, a dictionary containing a key `result` containing one of the allowed status (`Literal['success', 'failure', 'unset', 'skipped', 'error']`) and optionally a key `messages` which is a list(str) and each message is expected to be a substring of one of the actual messages in the TestResult object. - In order for your unit tests to be correctly collected, you need to import the generic test function even if not used in the Python module. Test example for `anta.tests.system.VerifyUptime` AntaTest. @@ -171,9 +171,9 @@ Run Ruff formatter.......................................................Passed Check code style with pylint.............................................Passed Checks for common misspellings in text files.............................Passed Check typing with mypy...................................................Passed +Check Markdown files style...............................................Passed ``` - ## Configure MYPYPATH In some cases, mypy can complain about not having `MYPYPATH` configured in your shell. It is especially the case when you update both an anta test and its unit test. So you can configure this environment variable with: @@ -232,4 +232,4 @@ muffet -c 2 --color=always http://127.0.0.1:8000 -e fonts.gstatic.com -b 8192 ## Continuous Integration -GitHub actions is used to test git pushes and pull requests. The workflows are defined in this [directory](https://github.com/aristanetworks/anta/tree/main/.github/workflows). We can view the results [here](https://github.com/aristanetworks/anta/actions). +GitHub actions is used to test git pushes and pull requests. The workflows are defined in this [directory](https://github.com/aristanetworks/anta/tree/main/.github/workflows). The results can be viewed [here](https://github.com/aristanetworks/anta/actions). diff --git a/docs/faq.md b/docs/faq.md index a699c8431..2d2ea6544 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -24,6 +24,7 @@ toc_depth: 2 # Frequently Asked Questions (FAQ) ## A local OS error occurred while connecting to a device + ???+ faq "A local OS error occurred while connecting to a device" When running ANTA, you can receive `A local OS error occurred while connecting to ` errors. The underlying [`OSError`](https://docs.python.org/3/library/exceptions.html#OSError) exception can have various reasons: `[Errno 24] Too many open files` or `[Errno 16] Device or resource busy`. @@ -38,13 +39,13 @@ toc_depth: 2 You can get the current hard limit for a user using the command `ulimit -n -H` while logged in. Create the file `/etc/security/limits.d/10-anta.conf` with the following content: ``` - hard nofile + hard nofile ``` The `user` is the one with which the ANTA process is started. The `value` is the new hard limit. The maximum value depends on the system. A hard limit of 16384 should be sufficient for ANTA to run in most high scale scenarios. After creating this file, log out the current session and log in again. - ## `Timeout` error in the logs + ???+ faq "`Timeout` error in the logs" When running ANTA, you can receive `Timeout` errors in the logs (could be ReadTimeout, WriteTimeout, ConnectTimeout or PoolTimeout). More details on the timeouts of the underlying library are available here: https://www.python-httpx.org/advanced/timeouts. @@ -63,8 +64,8 @@ toc_depth: 2 The timeout is increased to 50s to allow ANTA to wait for API calls a little longer. ## `ImportError` related to `urllib3` -???+ faq "`ImportError` related to `urllib3` when running ANTA" +???+ faq "`ImportError` related to `urllib3` when running ANTA" When running the `anta --help` command, some users might encounter the following error: @@ -90,9 +91,9 @@ toc_depth: 2 As per the [urllib3 v2 migration guide](https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html), the root cause of this error is an incompatibility with older OpenSSL versions. For example, users on RHEL7 might consider upgrading to RHEL8, which supports the required OpenSSL version. -##`AttributeError: module 'lib' has no attribute 'OpenSSL_add_all_algorithms'` -???+ faq "`AttributeError: module 'lib' has no attribute 'OpenSSL_add_all_algorithms'` when running ANTA" +## `AttributeError: module 'lib' has no attribute 'OpenSSL_add_all_algorithms'` +???+ faq "`AttributeError: module 'lib' has no attribute 'OpenSSL_add_all_algorithms'` when running ANTA" When running the `anta` commands after installation, some users might encounter the following error: @@ -111,8 +112,8 @@ toc_depth: 2 ``` ## `__NSCFConstantString initialize` error on OSX -???+ faq "`__NSCFConstantString initialize` error on OSX" +???+ faq "`__NSCFConstantString initialize` error on OSX" This error occurs because of added security to restrict multithreading in macOS High Sierra and later versions of macOS. https://www.wefearchange.org/2018/11/forkmacos.rst.html diff --git a/docs/getting-started.md b/docs/getting-started.md index c166ebe78..aac88c640 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,8 +4,6 @@ ~ that can be found in the LICENSE file. --> -# Getting Started - This section shows how to use ANTA with basic configuration. All examples are based on Arista Test Drive (ATD) topology you can access by reaching out to your preferred SE. ## Installation @@ -257,7 +255,7 @@ $ anta nrfu \ ] ``` -You can find more information under the __usage__ section of the website +You can find more information under the **usage** section of the website ### Basic usage in a Python script diff --git a/docs/requirements-and-installation.md b/docs/requirements-and-installation.md index ebe39c482..5f99ac0ac 100644 --- a/docs/requirements-and-installation.md +++ b/docs/requirements-and-installation.md @@ -4,8 +4,6 @@ ~ that can be found in the LICENSE file. --> -# ANTA Requirements - ## Python version Python 3 (`>=3.9`) is required: @@ -21,7 +19,6 @@ This installation will deploy tests collection, scripts and all their Python req The ANTA package and the cli require some packages that are not part of the Python standard library. They are indicated in the [pyproject.toml](https://github.com/aristanetworks/anta/blob/main/pyproject.toml) file, under dependencies. - ### Install library from Pypi server ```bash @@ -36,7 +33,7 @@ pip install anta [`pipx`](https://pipx.pypa.io/stable/) is a tool to install and run python applications in isolated environments. If you plan to use ANTA only as a CLI tool you can use `pipx` to install it. `pipx` installs ANTA in an isolated python environment and makes it available globally. -``` +```bash pipx install anta[cli] ``` @@ -44,7 +41,6 @@ pipx install anta[cli] Please take the time to read through the installation instructions of `pipx` before getting started. - ### Install CLI from Pypi server Alternatively, pip install with `cli` extra is enough to install the ANTA CLI. @@ -55,7 +51,6 @@ pip install anta[cli] ### Install ANTA from github - ```bash pip install git+https://github.com/aristanetworks/anta.git pip install git+https://github.com/aristanetworks/anta.git#egg=anta[cli] diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 40fc07fa0..25b061c84 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4,8 +4,6 @@ ~ that can be found in the LICENSE file. --> -# Troubleshooting ANTA - A couple of things to check when hitting an issue with ANTA: ```mermaid @@ -26,18 +24,18 @@ flowchart LR click B "../faq" "FAQ" click E "https://github.com/aristanetworks/anta/issues" click H "https://github.com/aristanetworks/anta/issues" - style A stroke:#f00,stroke-width:2px + style A stroke:#f00,stroke-width:2px ``` ## Capturing logs -To help document the issue in Github, it is important to capture some logs so the developers can understand what is affecting your system. No logs mean that the first question asked on the issue will probably be _"Can you share some logs please?"_. +To help document the issue in Github, it is important to capture some logs so the developers can understand what is affecting your system. No logs mean that the first question asked on the issue will probably be *"Can you share some logs please?"*. ANTA provides very verbose logs when using the `DEBUG` level. When using DEBUG log level with a log file, the DEBUG logging level is not sent to stdout, but only to the file. !!! danger - On real deployments, do not use DEBUG logging level without setting a log file at the same time. + On real deployments, do not use DEBUG logging level without setting a log file at the same time. To save the logs to a file called `anta.log`, use the following flags: @@ -54,19 +52,17 @@ See `anta --help` for more information. These have to precede the `nrfu` cmd. so the `-l` and `--log-file` MUST be between `anta` and the `ANTA_COMMAND`. similarly, all the `nrfu` options MUST be set between the `nrfu` and the `ANTA_NRFU_SUBCOMMAND` (`json`, `text`, `table` or `tpl-report`). - As an example, for the `nrfu` command, it would look like: ```bash anta -l DEBUG --log-file anta.log nrfu --enable --username username --password arista --inventory inventory.yml -c nrfu.yml text ``` - ### `ANTA_DEBUG` environment variable -??? warning +!!! warning - Do not use this if you do not know why. This produces a lot of logs and can create confusion if you do not know what to look for. + Do not use this if you do not know why. This produces a lot of logs and can create confusion if you do not know what to look for. The environment variable `ANTA_DEBUG=true` enable ANTA Debug Mode. @@ -83,6 +79,7 @@ ANTA_DEBUG=true anta -l DEBUG --log-file anta.log nrfu --enable --username usern ANTA is using a specific ID in eAPI requests towards EOS. This allows for easier eAPI requests debugging on the device using EOS configuration `trace CapiApp setting UwsgiRequestContext/4,CapiUwsgiServer/4` to set up CapiApp agent logs. Then, you can view agent logs using: + ```bash bash tail -f /var/log/agents/CapiApp-* diff --git a/docs/usage-inventory-catalog.md b/docs/usage-inventory-catalog.md index d8a032f26..e180496ee 100644 --- a/docs/usage-inventory-catalog.md +++ b/docs/usage-inventory-catalog.md @@ -4,9 +4,10 @@ ~ that can be found in the LICENSE file. --> -# Inventory and Catalog +The ANTA framework needs 2 important inputs from the user to run: -The ANTA framework needs 2 important inputs from the user to run: a **device inventory** and a **test catalog**. +1. A **device inventory** +2. A **test catalog**. Both inputs can be defined in a file or programmatically. @@ -79,6 +80,7 @@ A test catalog is an instance of the [AntaCatalog](./api/catalog.md#anta.catalog In addition to the inventory file, you also have to define a catalog of tests to execute against your devices. This catalog list all your tests, their inputs and their tags. A valid test catalog file must have the following structure in either YAML or JSON: + ```yaml --- : @@ -156,6 +158,7 @@ or equivalent in JSON: ``` It is also possible to nest Python module definition: + ```yaml anta.tests: connectivity: @@ -204,7 +207,6 @@ anta.tests.system: All tests available as part of the ANTA framework are defined under the `anta.tests` Python module and are categorised per family (Python submodule). The complete list of the tests and their respective inputs is available at the [tests section](api/tests.md) of this website. - To run test to verify the EOS software version, you can do: ```yaml @@ -340,5 +342,6 @@ if __name__ == "__main__": with open(Path('anta-catalog.yml'), "w") as f: f.write(merged_catalog.dump().yaml()) ``` + !!! warning The `AntaCatalog.merge()` method is deprecated and will be removed in ANTA v2.0. Please use the `AntaCatalog.merge_catalogs()` class method instead. diff --git a/mkdocs.yml b/mkdocs.yml index e206ba218..dd417ded1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -170,7 +170,7 @@ nav: - Home: README.md - Getting Started: getting-started.md - Installation: requirements-and-installation.md - - Inventory & Tests catalog: usage-inventory-catalog.md + - Inventory and Test catalog: usage-inventory-catalog.md - ANTA CLI: - Overview: cli/overview.md - NRFU: cli/nrfu.md @@ -178,8 +178,8 @@ nav: - Inventory from CVP: cli/inv-from-cvp.md - Inventory from Ansible: cli/inv-from-ansible.md - Get Inventory Information: cli/get-inventory-information.md - - Check: cli/check.md - - Helpers: cli/debug.md + - Check commands: cli/check.md + - Debug commands: cli/debug.md - Tag Management: cli/tag-management.md - Advanced Usages: - Caching in ANTA: advanced_usages/caching.md @@ -232,6 +232,6 @@ nav: - Result Manager models: api/result_manager_models.md - Report Manager: api/report_manager.md - Runner: api/runner.md - - Troubleshooting: troubleshooting.md + - Troubleshooting ANTA: troubleshooting.md - Contributions: contribution.md - FAQ: faq.md From 29e08cbb073c8eb7cf4a1844a5f9ca14fffd33c1 Mon Sep 17 00:00:00 2001 From: Paul Schriever <100222761+Xel-Naha@users.noreply.github.com> Date: Fri, 4 Oct 2024 16:06:34 +0100 Subject: [PATCH 78/90] fix(anta.tests): allow for positive or negative offset time zones (#842) --------- Co-authored-by: paul --- anta/tests/logging.py | 4 ++-- tests/units/anta_tests/test_logging.py | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/anta/tests/logging.py b/anta/tests/logging.py index b32bc99fd..2972b4ec8 100644 --- a/anta/tests/logging.py +++ b/anta/tests/logging.py @@ -271,7 +271,7 @@ class VerifyLoggingTimestamp(AntaTest): """ name = "VerifyLoggingTimestamp" - description = "Verifies if logs are generated with the riate timestamp." + description = "Verifies if logs are generated with the appropriate timestamp." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"), @@ -282,7 +282,7 @@ class VerifyLoggingTimestamp(AntaTest): def test(self) -> None: """Main test function for VerifyLoggingTimestamp.""" log_pattern = r"ANTA VerifyLoggingTimestamp validation" - timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}" + timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}[+-]\d{2}:\d{2}" output = self.instance_commands[1].text_output lines = output.strip().split("\n")[::-1] last_line_with_pattern = "" diff --git a/tests/units/anta_tests/test_logging.py b/tests/units/anta_tests/test_logging.py index cfc034c87..b4294367e 100644 --- a/tests/units/anta_tests/test_logging.py +++ b/tests/units/anta_tests/test_logging.py @@ -201,7 +201,7 @@ "expected": {"result": "failure", "messages": ["Logs are not generated with the device FQDN"]}, }, { - "name": "success", + "name": "success-negative-offset", "test": VerifyLoggingTimestamp, "eos_data": [ "", @@ -213,6 +213,19 @@ "inputs": None, "expected": {"result": "success"}, }, + { + "name": "success-positive-offset", + "test": VerifyLoggingTimestamp, + "eos_data": [ + "", + "2023-05-10T15:41:44.680813+05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: " + "Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingTimestamp validation\n" + "2023-05-10T15:42:44.680813+05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: " + "Other log\n", + ], + "inputs": None, + "expected": {"result": "success"}, + }, { "name": "failure", "test": VerifyLoggingTimestamp, From daadfb11112ca0bdea6522324390afd435a76340 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 11:08:28 +0200 Subject: [PATCH 79/90] ci: pre-commit autoupdate (#856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.6.0...v5.0.0) - [github.com/astral-sh/ruff-pre-commit: v0.6.8 → v0.6.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.8...v0.6.9) - [github.com/igorshubovych/markdownlint-cli: v0.41.0 → v0.42.0](https://github.com/igorshubovych/markdownlint-cli/compare/v0.41.0...v0.42.0) * ci: Update pre-commit config --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Guillaume Mulocher --- .pre-commit-config.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 244270375..0d6cd1863 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,14 @@ --- # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks +ci: + autoupdate_commit_msg: "ci: pre-commit autoupdate" + files: ^(anta|docs|scripts|tests|asynceapi)/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: trailing-whitespace exclude: docs/.*.svg @@ -43,7 +46,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.8 + rev: v0.6.9 hooks: - id: ruff name: Run Ruff linter @@ -97,7 +100,7 @@ repos: files: ^(anta|tests)/ - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.41.0 + rev: v0.42.0 hooks: - id: markdownlint name: Check Markdown files style. From 93a4b44a79554f4af22780d544025c1a487e52be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Thu, 10 Oct 2024 11:49:28 +0200 Subject: [PATCH 80/90] ci: add prepare_tests and get_coroutines benchmarks (#860) * ci: add prepare_tests and get_coroutines benchmarks * fix: clear indexes * test: fix finventory fixture parametrization * chore: update vscode settings * test: fix test_runner assertion * test: refactor benchmarks * Update tests/benchmark/conftest.py --------- Co-authored-by: Guillaume Mulocher --- .vscode/settings.json | 9 +++---- tests/benchmark/conftest.py | 17 ++++++++++++ tests/benchmark/test_anta.py | 38 +++++++-------------------- tests/benchmark/test_runner.py | 48 ++++++++++++++++++++++++++++++++++ tests/conftest.py | 14 +++++----- 5 files changed, 87 insertions(+), 39 deletions(-) create mode 100644 tests/benchmark/test_runner.py diff --git a/.vscode/settings.json b/.vscode/settings.json index ac8ba0b02..d50caa1dc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,14 @@ { "ruff.enable": true, "ruff.configuration": "pyproject.toml", - "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "python.testing.pytestArgs": [ "tests" ], - "python.languageServer": "Pylance", "githubIssues.issueBranchTitle": "issues/${issueNumber}-${issueTitle}", - "editor.formatOnPaste": true, - "files.trimTrailingWhitespace": true, - "workbench.remoteIndicator.showExtensionRecommendations": true, + "pylint.importStrategy": "fromEnvironment", + "pylint.args": [ + "--rcfile=pyproject.toml" + ], } \ No newline at end of file diff --git a/tests/benchmark/conftest.py b/tests/benchmark/conftest.py index c07cc99c2..61f2fa11c 100644 --- a/tests/benchmark/conftest.py +++ b/tests/benchmark/conftest.py @@ -38,3 +38,20 @@ def catalog(anta_mock_env: AntaMockEnvironment) -> AntaCatalog: def pytest_terminal_summary(terminalreporter: TerminalReporter) -> None: """Display the total number of ANTA unit test cases used to benchmark.""" terminalreporter.write_sep("=", f"{TEST_CASE_COUNT} ANTA test cases") + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Parametrize inventory for benchmark tests.""" + if "inventory" in metafunc.fixturenames: + for marker in metafunc.definition.iter_markers(name="parametrize"): + if "inventory" in marker.args[0]: + # Do not override test function parametrize marker for inventory arg + return + metafunc.parametrize( + "inventory", + [ + pytest.param({"count": 1, "disable_cache": True, "reachable": True}, id="1-device"), + pytest.param({"count": 2, "disable_cache": True, "reachable": True}, id="2-devices"), + ], + indirect=True, + ) diff --git a/tests/benchmark/test_anta.py b/tests/benchmark/test_anta.py index 6885e2e8f..e82de645d 100644 --- a/tests/benchmark/test_anta.py +++ b/tests/benchmark/test_anta.py @@ -22,27 +22,18 @@ logger = logging.getLogger(__name__) -@pytest.mark.parametrize( - "inventory", - [ - pytest.param({"count": 1, "disable_cache": True, "reachable": False}, id="1 device"), - pytest.param({"count": 2, "disable_cache": True, "reachable": False}, id="2 devices"), - ], - indirect=True, -) -def test_anta_dry_run(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: AntaInventory) -> None: - """Test and benchmark ANTA in Dry-Run Mode.""" +def test_anta_dry_run(benchmark: BenchmarkFixture, event_loop: asyncio.AbstractEventLoop, catalog: AntaCatalog, inventory: AntaInventory) -> None: + """Benchmark ANTA in Dry-Run Mode.""" # Disable logging during ANTA execution to avoid having these function time in benchmarks logging.disable() - def bench() -> ResultManager: - """Need to wrap the ANTA Runner to instantiate a new ResultManger for each benchmark run.""" + def _() -> ResultManager: manager = ResultManager() catalog.clear_indexes() - asyncio.run(main(manager, inventory, catalog, dry_run=True)) + event_loop.run_until_complete(main(manager, inventory, catalog, dry_run=True)) return manager - manager = benchmark(bench) + manager = benchmark(_) logging.disable(logging.NOTSET) if len(manager.results) != len(inventory) * len(catalog.tests): @@ -51,30 +42,21 @@ def bench() -> ResultManager: logger.info(bench_info) -@pytest.mark.parametrize( - "inventory", - [ - pytest.param({"count": 1, "disable_cache": True}, id="1 device"), - pytest.param({"count": 2, "disable_cache": True}, id="2 devices"), - ], - indirect=True, -) @patch("anta.models.AntaTest.collect", collect) @patch("anta.device.AntaDevice.collect_commands", collect_commands) @respx.mock # Mock eAPI responses -def test_anta(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: AntaInventory) -> None: - """Test and benchmark ANTA. Mock eAPI responses.""" +def test_anta(benchmark: BenchmarkFixture, event_loop: asyncio.AbstractEventLoop, catalog: AntaCatalog, inventory: AntaInventory) -> None: + """Benchmark ANTA.""" # Disable logging during ANTA execution to avoid having these function time in benchmarks logging.disable() - def bench() -> ResultManager: - """Need to wrap the ANTA Runner to instantiate a new ResultManger for each benchmark run.""" + def _() -> ResultManager: manager = ResultManager() catalog.clear_indexes() - asyncio.run(main(manager, inventory, catalog)) + event_loop.run_until_complete(main(manager, inventory, catalog)) return manager - manager = benchmark(bench) + manager = benchmark(_) logging.disable(logging.NOTSET) diff --git a/tests/benchmark/test_runner.py b/tests/benchmark/test_runner.py new file mode 100644 index 000000000..b020a85d0 --- /dev/null +++ b/tests/benchmark/test_runner.py @@ -0,0 +1,48 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Benchmark tests for anta.runner.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from anta.result_manager import ResultManager +from anta.runner import get_coroutines, prepare_tests + +if TYPE_CHECKING: + from collections import defaultdict + + from pytest_codspeed import BenchmarkFixture + + from anta.catalog import AntaCatalog, AntaTestDefinition + from anta.device import AntaDevice + from anta.inventory import AntaInventory + + +def test_prepare_tests(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: AntaInventory) -> None: + """Benchmark `anta.runner.prepare_tests`.""" + + def _() -> defaultdict[AntaDevice, set[AntaTestDefinition]] | None: + catalog.clear_indexes() + return prepare_tests(inventory=inventory, catalog=catalog, tests=None, tags=None) + + selected_tests = benchmark(_) + + assert selected_tests is not None + assert len(selected_tests) == len(inventory) + assert sum(len(tests) for tests in selected_tests.values()) == len(inventory) * len(catalog.tests) + + +def test_get_coroutines(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: AntaInventory) -> None: + """Benchmark `anta.runner.get_coroutines`.""" + selected_tests = prepare_tests(inventory=inventory, catalog=catalog, tests=None, tags=None) + + assert selected_tests is not None + + coroutines = benchmark(lambda: get_coroutines(selected_tests=selected_tests, manager=ResultManager())) + for coros in coroutines: + coros.close() + + count = sum(len(tests) for tests in selected_tests.values()) + assert count == len(coroutines) diff --git a/tests/conftest.py b/tests/conftest.py index dd535c104..7858e401c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,18 +17,20 @@ DATA_DIR: Path = Path(__file__).parent.resolve() / "data" -@pytest.fixture(params=[{"count": 1}], ids=["1-reachable-device-without-cache"]) +@pytest.fixture def inventory(request: pytest.FixtureRequest) -> Iterator[AntaInventory]: """Generate an ANTA inventory.""" user = "admin" password = "password" # noqa: S105 - disable_cache = request.param.get("disable_cache", True) - reachable = request.param.get("reachable", True) - if "filename" in request.param: - inv = AntaInventory.parse(DATA_DIR / request.param["filename"], username=user, password=password, disable_cache=disable_cache) + params = request.param if hasattr(request, "param") else {} + count = params.get("count", 1) + disable_cache = params.get("disable_cache", True) + reachable = params.get("reachable", True) + if "filename" in params: + inv = AntaInventory.parse(DATA_DIR / params["filename"], username=user, password=password, disable_cache=disable_cache) else: inv = AntaInventory() - for i in range(request.param["count"]): + for i in range(count): inv.add_device( AsyncEOSDevice( host=f"device-{i}.anta.arista.com", From 42476d9cfafa48b4da26ceb68c4344d9328cad13 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Thu, 10 Oct 2024 14:17:49 +0200 Subject: [PATCH 81/90] refactor(anta): Move conversion of categories to reporters (#861) --- anta/reporter/__init__.py | 3 ++- anta/reporter/csv_reporter.py | 3 ++- anta/reporter/md_reporter.py | 7 ++++--- anta/result_manager/__init__.py | 4 ---- anta/tools.py | 23 ++++++++++++++++++++++ tests/units/reporter/test_csv.py | 3 ++- tests/units/result_manager/test__init__.py | 8 ++++---- tests/units/test_tools.py | 16 ++++++++++++++- 8 files changed, 52 insertions(+), 15 deletions(-) diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index 73ba83538..38f2e8e68 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -14,6 +14,7 @@ from rich.table import Table from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME +from anta.tools import convert_categories if TYPE_CHECKING: import pathlib @@ -125,7 +126,7 @@ def report_all(self, manager: ResultManager, title: str = "All tests results") - def add_line(result: TestResult) -> None: state = self._color_result(result.result) message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else "" - categories = ", ".join(result.categories) + categories = ", ".join(convert_categories(result.categories)) table.add_row(str(result.name), result.test, state, message, result.description, categories) for result in manager.results: diff --git a/anta/reporter/csv_reporter.py b/anta/reporter/csv_reporter.py index 4263c1e59..33c50a8f4 100644 --- a/anta/reporter/csv_reporter.py +++ b/anta/reporter/csv_reporter.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING from anta.logger import anta_log_exception +from anta.tools import convert_categories if TYPE_CHECKING: import pathlib @@ -71,7 +72,7 @@ def convert_to_list(cls, result: TestResult) -> list[str]: TestResult converted into a list. """ message = cls.split_list_to_txt_list(result.messages) if len(result.messages) > 0 else "" - categories = cls.split_list_to_txt_list(result.categories) if len(result.categories) > 0 else "None" + categories = cls.split_list_to_txt_list(convert_categories(result.categories)) if len(result.categories) > 0 else "None" return [ str(result.name), result.test, diff --git a/anta/reporter/md_reporter.py b/anta/reporter/md_reporter.py index f4eadb2b5..887fd6689 100644 --- a/anta/reporter/md_reporter.py +++ b/anta/reporter/md_reporter.py @@ -13,6 +13,7 @@ from anta.constants import MD_REPORT_TOC from anta.logger import anta_log_exception from anta.result_manager.models import AntaTestStatus +from anta.tools import convert_categories if TYPE_CHECKING: from collections.abc import Generator @@ -238,8 +239,8 @@ def generate_rows(self) -> Generator[str, None, None]: """Generate the rows of the summary totals device under test table.""" for device, stat in self.results.device_stats.items(): total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count - categories_skipped = ", ".join(sorted(stat.categories_skipped)) - categories_failed = ", ".join(sorted(stat.categories_failed)) + categories_skipped = ", ".join(sorted(convert_categories(list(stat.categories_skipped)))) + categories_failed = ", ".join(sorted(convert_categories(list(stat.categories_failed)))) yield ( f"| {device} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} | {stat.tests_error_count} " f"| {categories_skipped or '-'} | {categories_failed or '-'} |\n" @@ -286,7 +287,7 @@ def generate_rows(self) -> Generator[str, None, None]: """Generate the rows of the all test results table.""" for result in self.results.get_results(sort_by=["name", "test"]): messages = self.safe_markdown(", ".join(result.messages)) - categories = ", ".join(result.categories) + categories = ", ".join(convert_categories(result.categories)) yield ( f"| {result.name or '-'} | {categories or '-'} | {result.test or '-'} " f"| {result.description or '-'} | {self.safe_markdown(result.custom_field) or '-'} | {result.result or '-'} | {messages or '-'} |\n" diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index f0f4cadfa..055a3a179 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -10,7 +10,6 @@ from functools import cached_property from itertools import chain -from anta.constants import ACRONYM_CATEGORIES from anta.result_manager.models import AntaTestStatus, TestResult from .models import CategoryStats, DeviceStats, TestStats @@ -162,9 +161,6 @@ def _update_stats(self, result: TestResult) -> None: result TestResult to update the statistics. """ - result.categories = [ - " ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in result.categories - ] count_attr = f"tests_{result.result}_count" # Update device stats diff --git a/anta/tools.py b/anta/tools.py index dc4dc12ca..4f73db9cb 100644 --- a/anta/tools.py +++ b/anta/tools.py @@ -13,6 +13,7 @@ from time import perf_counter from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast +from anta.constants import ACRONYM_CATEGORIES from anta.custom_types import REGEXP_PATH_MARKERS from anta.logger import format_td @@ -372,3 +373,25 @@ def safe_command(command: str) -> str: The sanitized command. """ return re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command) + + +def convert_categories(categories: list[str]) -> list[str]: + """Convert categories for reports. + + if the category is part of the defined acronym, transform it to upper case + otherwise capitalize the first letter. + + Parameters + ---------- + categories + A list of categories + + Returns + ------- + list[str] + The list of converted categories + """ + if isinstance(categories, list): + return [" ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in categories] + msg = f"Wrong input type '{type(categories)}' for convert_categories." + raise TypeError(msg) diff --git a/tests/units/reporter/test_csv.py b/tests/units/reporter/test_csv.py index e0a9d4e1f..1d59daef5 100644 --- a/tests/units/reporter/test_csv.py +++ b/tests/units/reporter/test_csv.py @@ -13,6 +13,7 @@ from anta.reporter.csv_reporter import ReportCsv from anta.result_manager import ResultManager +from anta.tools import convert_categories class TestReportCsv: @@ -25,7 +26,7 @@ def compare_csv_and_result(self, rows: list[Any], index: int, result_manager: Re assert rows[index + 1][2] == result_manager.results[index].result assert rows[index + 1][3] == ReportCsv().split_list_to_txt_list(result_manager.results[index].messages) assert rows[index + 1][4] == result_manager.results[index].description - assert rows[index + 1][5] == ReportCsv().split_list_to_txt_list(result_manager.results[index].categories) + assert rows[index + 1][5] == ReportCsv().split_list_to_txt_list(convert_categories(result_manager.results[index].categories)) def test_report_csv_generate( self, diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py index e1087536f..1fd51cbe7 100644 --- a/tests/units/result_manager/test__init__.py +++ b/tests/units/result_manager/test__init__.py @@ -85,12 +85,12 @@ def test_sorted_category_stats(self, list_result_factory: Callable[[int], list[T result_manager.results = results - # Check the current categories order and name format - expected_order = ["OSPF", "BGP", "VXLAN", "System"] + # Check the current categories order + expected_order = ["ospf", "bgp", "vxlan", "system"] assert list(result_manager.category_stats.keys()) == expected_order - # Check the sorted categories order and name format - expected_order = ["BGP", "OSPF", "System", "VXLAN"] + # Check the sorted categories order + expected_order = ["bgp", "ospf", "system", "vxlan"] assert list(result_manager.sorted_category_stats.keys()) == expected_order @pytest.mark.parametrize( diff --git a/tests/units/test_tools.py b/tests/units/test_tools.py index 29abac5e6..16f044333 100644 --- a/tests/units/test_tools.py +++ b/tests/units/test_tools.py @@ -11,7 +11,7 @@ import pytest -from anta.tools import custom_division, get_dict_superset, get_failed_logs, get_item, get_value +from anta.tools import convert_categories, custom_division, get_dict_superset, get_failed_logs, get_item, get_value TEST_GET_FAILED_LOGS_DATA = [ {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com"}, @@ -499,3 +499,17 @@ def test_get_item( def test_custom_division(numerator: float, denominator: float, expected_result: str) -> None: """Test custom_division.""" assert custom_division(numerator, denominator) == expected_result + + +@pytest.mark.parametrize( + ("test_input", "expected_raise", "expected_result"), + [ + pytest.param([], does_not_raise(), [], id="empty list"), + pytest.param(["bgp", "system", "vlan", "configuration"], does_not_raise(), ["BGP", "System", "VLAN", "Configuration"], id="list with acronyms and titles"), + pytest.param(42, pytest.raises(TypeError, match="Wrong input type"), None, id="wrong input type"), + ], +) +def test_convert_categories(test_input: list[str], expected_raise: AbstractContextManager[Exception], expected_result: list[str]) -> None: + """Test convert_categories.""" + with expected_raise: + assert convert_categories(test_input) == expected_result From 2541572566f6aed8ad7326ff77ef644027cb0674 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Thu, 10 Oct 2024 15:23:25 +0200 Subject: [PATCH 82/90] feat: Log when show version modelName is an empty string (#850) * Feat: Log when show version modelName is an empty string * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- anta/device.py | 4 ++++ tests/units/test_device.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/anta/device.py b/anta/device.py index 35b7f4c04..d7d2b0de2 100644 --- a/anta/device.py +++ b/anta/device.py @@ -465,6 +465,10 @@ async def refresh(self) -> None: self.hw_model = show_version.json_output.get("modelName", None) if self.hw_model is None: logger.critical("Cannot parse 'show version' returned by device %s", self.name) + # in some cases it is possible that 'modelName' comes back empty + # and it is nice to get a meaninfule error message + elif self.hw_model == "": + logger.critical("Got an empty 'modelName' in the 'show version' returned by device %s", self.name) else: logger.warning("Could not connect to device %s: cannot open eAPI port", self.name) diff --git a/tests/units/test_device.py b/tests/units/test_device.py index 62c16c9ef..faf614481 100644 --- a/tests/units/test_device.py +++ b/tests/units/test_device.py @@ -430,6 +430,22 @@ {"is_online": True, "established": False, "hw_model": None}, id="httpx.ConnectError", ), + pytest.param( + {}, + ( + {"return_value": True}, + { + "return_value": [ + { + "mfgName": "Arista", + "modelName": "", + } + ] + }, + ), + {"is_online": True, "established": False, "hw_model": ""}, + id="modelName empty string", + ), ] COLLECT_PARAMS: list[ParameterSet] = [ pytest.param( From 42bfc3b1987cb1a09367ea7182bc0cc50d54f7a3 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Thu, 10 Oct 2024 18:37:47 +0200 Subject: [PATCH 83/90] fix(anta.cli): Disable env variable for --help and --version (#869) --- anta/cli/_main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/anta/cli/_main.py b/anta/cli/_main.py index d70f1cf56..ae4e050f7 100644 --- a/anta/cli/_main.py +++ b/anta/cli/_main.py @@ -25,7 +25,8 @@ @click.group(cls=AliasedGroup) @click.pass_context -@click.version_option(__version__) +@click.help_option(allow_from_autoenv=False) +@click.version_option(__version__, allow_from_autoenv=False) @click.option( "--log-file", help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.", From 0635a3ff80c11f2232e174784e8634e1d742a327 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Thu, 10 Oct 2024 14:44:17 -0400 Subject: [PATCH 84/90] refactor(anta): Cleanup unused code (#872) --- anta/catalog.py | 11 ++--------- anta/runner.py | 5 +---- tests/units/test_catalog.py | 8 ++++---- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/anta/catalog.py b/anta/catalog.py index 9b752fa05..bc95104fc 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -298,13 +298,11 @@ def __init__( self.indexes_built: bool self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]] - self._tests_without_tags: set[AntaTestDefinition] self._init_indexes() def _init_indexes(self) -> None: """Init indexes related variables.""" self.tag_to_tests = defaultdict(set) - self._tests_without_tags = set() self.indexes_built = False @property @@ -486,11 +484,7 @@ def build_indexes(self, filtered_tests: set[str] | None = None) -> None: If a `filtered_tests` set is provided, only the tests in this set will be indexed. - This method populates two attributes: - - - tag_to_tests: A dictionary mapping each tag to a set of tests that contain it. - - - _tests_without_tags: A set of tests that do not have any tags. + This method populates the tag_to_tests attribute, which is a dictionary mapping tags to sets of tests. Once the indexes are built, the `indexes_built` attribute is set to True. """ @@ -504,9 +498,8 @@ def build_indexes(self, filtered_tests: set[str] | None = None) -> None: for tag in test_tags: self.tag_to_tests[tag].add(test) else: - self._tests_without_tags.add(test) + self.tag_to_tests[None].add(test) - self.tag_to_tests[None] = self._tests_without_tags self.indexes_built = True def clear_indexes(self) -> None: diff --git a/anta/runner.py b/anta/runner.py index dcb2d962e..0147c3ccd 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -146,8 +146,7 @@ def prepare_tests( # Using a set to avoid inserting duplicate tests device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set) - # Create AntaTestRunner tuples from the tags - final_tests_count = 0 + # Create the device to tests mapping from the tags for device in inventory.devices: if tags: if not any(tag in device.tags for tag in tags): @@ -160,8 +159,6 @@ def prepare_tests( # Add the tests with matching tags from device tags device_to_tests[device].update(catalog.get_tests_by_tags(device.tags)) - final_tests_count += len(device_to_tests[device]) - if len(device_to_tests.values()) == 0: msg = ( f"There are no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current test catalog and device inventory, please verify your inputs." diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py index ca78a870c..57a8e2f3b 100644 --- a/tests/units/test_catalog.py +++ b/tests/units/test_catalog.py @@ -260,10 +260,10 @@ def test_build_indexes_all(self) -> None: """Test AntaCatalog.build_indexes().""" catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_with_tags.yml") catalog.build_indexes() - assert len(catalog._tests_without_tags) == 6 + assert len(catalog.tag_to_tests[None]) == 6 assert "leaf" in catalog.tag_to_tests assert len(catalog.tag_to_tests["leaf"]) == 3 - all_unique_tests = catalog._tests_without_tags + all_unique_tests = catalog.tag_to_tests[None] for tests in catalog.tag_to_tests.values(): all_unique_tests.update(tests) assert len(all_unique_tests) == 11 @@ -275,8 +275,8 @@ def test_build_indexes_filtered(self) -> None: catalog.build_indexes({"VerifyUptime", "VerifyCoredump", "VerifyL3MTU"}) assert "leaf" in catalog.tag_to_tests assert len(catalog.tag_to_tests["leaf"]) == 1 - assert len(catalog._tests_without_tags) == 1 - all_unique_tests = catalog._tests_without_tags + assert len(catalog.tag_to_tests[None]) == 1 + all_unique_tests = catalog.tag_to_tests[None] for tests in catalog.tag_to_tests.values(): all_unique_tests.update(tests) assert len(all_unique_tests) == 4 From 86e86027849b66234e384f30efff3e355439fcee Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Thu, 10 Oct 2024 14:46:58 -0400 Subject: [PATCH 85/90] fix(anta.tests): Skip VerifyHardwareFlowTrackerStatus for cEOSLab and vEOS-lab (#873) --- anta/tests/flow_tracking.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/anta/tests/flow_tracking.py b/anta/tests/flow_tracking.py index 5336cf14d..676bdb4f0 100644 --- a/anta/tests/flow_tracking.py +++ b/anta/tests/flow_tracking.py @@ -11,6 +11,7 @@ from pydantic import BaseModel +from anta.decorators import skip_on_platforms from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import get_failed_logs @@ -151,6 +152,7 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: """Render the template for each hardware tracker.""" return [template.render(name=tracker.name) for tracker in self.inputs.trackers] + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyHardwareFlowTrackerStatus.""" From 387c28108c93b1ec3d54ca7bb0b862e7e717a7c2 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Thu, 10 Oct 2024 15:27:14 -0400 Subject: [PATCH 86/90] fix(anta.tests): Fix VerifySSHStatus to support 4.32 text output (#874) Co-authored-by: Guillaume Mulocher --- anta/tests/security.py | 2 +- tests/units/anta_tests/test_security.py | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/anta/tests/security.py b/anta/tests/security.py index 007022dc5..71c9f12ee 100644 --- a/anta/tests/security.py +++ b/anta/tests/security.py @@ -57,7 +57,7 @@ def test(self) -> None: except StopIteration: self.result.is_failure("Could not find SSH status in returned output.") return - status = line.split("is ")[1] + status = line.split()[-1] if status == "disabled": self.result.is_success() diff --git a/tests/units/anta_tests/test_security.py b/tests/units/anta_tests/test_security.py index 549890ad5..0d4a478b0 100644 --- a/tests/units/anta_tests/test_security.py +++ b/tests/units/anta_tests/test_security.py @@ -45,12 +45,32 @@ "expected": {"result": "failure", "messages": ["Could not find SSH status in returned output."]}, }, { - "name": "failure-ssh-disabled", + "name": "failure-ssh-enabled", "test": VerifySSHStatus, "eos_data": ["SSHD status for Default VRF is enabled\nSSH connection limit is 50\nSSH per host connection limit is 20\nFIPS status: disabled\n\n"], "inputs": None, "expected": {"result": "failure", "messages": ["SSHD status for Default VRF is enabled"]}, }, + { + "name": "success-4.32", + "test": VerifySSHStatus, + "eos_data": [ + "User certificate authentication methods: none (neither trusted CA nor SSL profile configured)\n" + "SSHD status for Default VRF: disabled\nSSH connection limit: 50\nSSH per host connection limit: 20\nFIPS status: disabled\n\n" + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure-ssh-enabled-4.32", + "test": VerifySSHStatus, + "eos_data": [ + "User certificate authentication methods: none (neither trusted CA nor SSL profile configured)\n" + "SSHD status for Default VRF: enabled\nSSH connection limit: 50\nSSH per host connection limit: 20\nFIPS status: disabled\n\n" + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["SSHD status for Default VRF: enabled"]}, + }, { "name": "success", "test": VerifySSHIPv4Acl, From 839f70a6dbd1af2c188a9f921d1ff747ba674d42 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:31:40 +0530 Subject: [PATCH 87/90] feat(anta): Added test case to verify SNMP Trap PDUs counters (#828) --- anta/custom_types.py | 1 + anta/tests/snmp.py | 61 ++++++++++++++++++++- examples/tests.yaml | 3 + tests/units/anta_tests/test_snmp.py | 85 ++++++++++++++++++++++++++++- 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/anta/custom_types.py b/anta/custom_types.py index c1e1f6428..e0bcdd3ea 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -204,3 +204,4 @@ def validate_regex(value: str) -> str: ] BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"] BfdProtocol = Literal["bgp", "isis", "lag", "ospf", "ospfv3", "pim", "route-input", "static-bfd", "static-route", "vrrp", "vxlan"] +SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"] diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py index c7329b6d7..ffbae1c02 100644 --- a/anta/tests/snmp.py +++ b/anta/tests/snmp.py @@ -7,9 +7,9 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, get_args -from anta.custom_types import PositiveInteger +from anta.custom_types import PositiveInteger, SnmpPdu from anta.models import AntaCommand, AntaTest from anta.tools import get_value @@ -237,3 +237,60 @@ def test(self) -> None: self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.") else: self.result.is_success() + + +class VerifySnmpPDUCounters(AntaTest): + """Verifies the SNMP PDU counters. + + By default, all SNMP PDU counters will be checked for any non-zero values. + An optional list of specific SNMP PDU(s) can be provided for granular testing. + + Expected Results + ---------------- + * Success: The test will pass if the SNMP PDU counter(s) are non-zero/greater than zero. + * Failure: The test will fail if the SNMP PDU counter(s) are zero/None/Not Found. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpPDUCounters: + pdus: + - outTrapPdus + - inGetNextPdus + ``` + """ + + name = "VerifySnmpPDUCounters" + description = "Verifies the SNMP PDU counters." + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpPDUCounters test.""" + + pdus: list[SnmpPdu] | None = None + """Optional list of SNMP PDU counters to be verified. If not provided, test will verifies all PDU counters.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpPDUCounters.""" + snmp_pdus = self.inputs.pdus + command_output = self.instance_commands[0].json_output + + # Verify SNMP PDU counters. + if not (pdu_counters := get_value(command_output, "counters")): + self.result.is_failure("SNMP counters not found.") + return + + # In case SNMP PDUs not provided, It will check all the update error counters. + if not snmp_pdus: + snmp_pdus = list(get_args(SnmpPdu)) + + failures = {pdu: value for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0} + + # Check if any failures + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters:\n{failures}") diff --git a/examples/tests.yaml b/examples/tests.yaml index ade4e7640..cf961f8e5 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -398,6 +398,9 @@ anta.tests.snmp: location: New York - VerifySnmpContact: contact: Jon@example.com + - VerifySnmpPDUCounters: + pdus: + - outTrapPdus anta.tests.software: - VerifyEOSVersion: diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py index f6d964f83..88f1bcddc 100644 --- a/tests/units/anta_tests/test_snmp.py +++ b/tests/units/anta_tests/test_snmp.py @@ -7,7 +7,7 @@ from typing import Any -from anta.tests.snmp import VerifySnmpContact, VerifySnmpIPv4Acl, VerifySnmpIPv6Acl, VerifySnmpLocation, VerifySnmpStatus +from anta.tests.snmp import VerifySnmpContact, VerifySnmpIPv4Acl, VerifySnmpIPv6Acl, VerifySnmpLocation, VerifySnmpPDUCounters, VerifySnmpStatus from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ @@ -152,4 +152,87 @@ "messages": ["SNMP contact is not configured."], }, }, + { + "name": "success", + "test": VerifySnmpPDUCounters, + "eos_data": [ + { + "counters": { + "inGetPdus": 3, + "inGetNextPdus": 2, + "inSetPdus": 3, + "outGetResponsePdus": 3, + "outTrapPdus": 9, + }, + } + ], + "inputs": {}, + "expected": {"result": "success"}, + }, + { + "name": "success-specific-pdus", + "test": VerifySnmpPDUCounters, + "eos_data": [ + { + "counters": { + "inGetPdus": 3, + "inGetNextPdus": 0, + "inSetPdus": 0, + "outGetResponsePdus": 0, + "outTrapPdus": 9, + }, + } + ], + "inputs": {"pdus": ["inGetPdus", "outTrapPdus"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-counters-not-found", + "test": VerifySnmpPDUCounters, + "eos_data": [ + { + "counters": {}, + } + ], + "inputs": {}, + "expected": {"result": "failure", "messages": ["SNMP counters not found."]}, + }, + { + "name": "failure-incorrect-counters", + "test": VerifySnmpPDUCounters, + "eos_data": [ + { + "counters": { + "inGetPdus": 0, + "inGetNextPdus": 2, + "inSetPdus": 0, + "outGetResponsePdus": 3, + "outTrapPdus": 9, + }, + } + ], + "inputs": {}, + "expected": { + "result": "failure", + "messages": ["The following SNMP PDU counters are not found or have zero PDU counters:\n{'inGetPdus': 0, 'inSetPdus': 0}"], + }, + }, + { + "name": "failure-pdu-not-found", + "test": VerifySnmpPDUCounters, + "eos_data": [ + { + "counters": { + "inGetNextPdus": 0, + "inSetPdus": 0, + "outGetResponsePdus": 0, + }, + } + ], + "inputs": {"pdus": ["inGetPdus", "outTrapPdus"]}, + "expected": { + "result": "failure", + "messages": ["The following SNMP PDU counters are not found or have zero PDU counters:\n{'inGetPdus': 'Not Found', 'outTrapPdus': 'Not Found'}"], + }, + }, ] From 5483b978c287c788032dc8d3f595194f40bd6852 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:48:55 +0530 Subject: [PATCH 88/90] feat(anta): Added the test case to verify SNMP error counters (#836) --- anta/custom_types.py | 3 + anta/tests/snmp.py | 58 ++++++++++++++++++- examples/tests.yaml | 4 ++ tests/units/anta_tests/test_snmp.py | 86 ++++++++++++++++++++++++++++- 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/anta/custom_types.py b/anta/custom_types.py index e0bcdd3ea..c29811826 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -205,3 +205,6 @@ def validate_regex(value: str) -> str: BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"] BfdProtocol = Literal["bgp", "isis", "lag", "ospf", "ospfv3", "pim", "route-input", "static-bfd", "static-route", "vrrp", "vxlan"] SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"] +SnmpErrorCounter = Literal[ + "inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs" +] diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py index ffbae1c02..217e32059 100644 --- a/anta/tests/snmp.py +++ b/anta/tests/snmp.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, ClassVar, get_args -from anta.custom_types import PositiveInteger, SnmpPdu +from anta.custom_types import PositiveInteger, SnmpErrorCounter, SnmpPdu from anta.models import AntaCommand, AntaTest from anta.tools import get_value @@ -294,3 +294,59 @@ def test(self) -> None: self.result.is_success() else: self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters:\n{failures}") + + +class VerifySnmpErrorCounters(AntaTest): + """Verifies the SNMP error counters. + + By default, all error counters will be checked for any non-zero values. + An optional list of specific error counters can be provided for granular testing. + + Expected Results + ---------------- + * Success: The test will pass if the SNMP error counter(s) are zero/None. + * Failure: The test will fail if the SNMP error counter(s) are non-zero/not None/Not Found or is not configured. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpErrorCounters: + error_counters: + - inVersionErrs + - inBadCommunityNames + """ + + name = "VerifySnmpErrorCounters" + description = "Verifies the SNMP error counters." + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpErrorCounters test.""" + + error_counters: list[SnmpErrorCounter] | None = None + """Optional list of SNMP error counters to be verified. If not provided, test will verifies all error counters.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpErrorCounters.""" + error_counters = self.inputs.error_counters + command_output = self.instance_commands[0].json_output + + # Verify SNMP PDU counters. + if not (snmp_counters := get_value(command_output, "counters")): + self.result.is_failure("SNMP counters not found.") + return + + # In case SNMP error counters not provided, It will check all the error counters. + if not error_counters: + error_counters = list(get_args(SnmpErrorCounter)) + + error_counters_not_ok = {counter: value for counter in error_counters if (value := snmp_counters.get(counter))} + + # Check if any failures + if not error_counters_not_ok: + self.result.is_success() + else: + self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters:\n{error_counters_not_ok}") diff --git a/examples/tests.yaml b/examples/tests.yaml index cf961f8e5..d8f3332ae 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -401,6 +401,10 @@ anta.tests.snmp: - VerifySnmpPDUCounters: pdus: - outTrapPdus + - VerifySnmpErrorCounters: + error_counters: + - inVersionErrs + - inBadCommunityNames anta.tests.software: - VerifyEOSVersion: diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py index 88f1bcddc..e7d8da8ba 100644 --- a/tests/units/anta_tests/test_snmp.py +++ b/tests/units/anta_tests/test_snmp.py @@ -7,7 +7,15 @@ from typing import Any -from anta.tests.snmp import VerifySnmpContact, VerifySnmpIPv4Acl, VerifySnmpIPv6Acl, VerifySnmpLocation, VerifySnmpPDUCounters, VerifySnmpStatus +from anta.tests.snmp import ( + VerifySnmpContact, + VerifySnmpErrorCounters, + VerifySnmpIPv4Acl, + VerifySnmpIPv6Acl, + VerifySnmpLocation, + VerifySnmpPDUCounters, + VerifySnmpStatus, +) from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ @@ -235,4 +243,80 @@ "messages": ["The following SNMP PDU counters are not found or have zero PDU counters:\n{'inGetPdus': 'Not Found', 'outTrapPdus': 'Not Found'}"], }, }, + { + "name": "success", + "test": VerifySnmpErrorCounters, + "eos_data": [ + { + "counters": { + "inVersionErrs": 0, + "inBadCommunityNames": 0, + "inBadCommunityUses": 0, + "inParseErrs": 0, + "outTooBigErrs": 0, + "outNoSuchNameErrs": 0, + "outBadValueErrs": 0, + "outGeneralErrs": 0, + }, + } + ], + "inputs": {}, + "expected": {"result": "success"}, + }, + { + "name": "success-specific-counters", + "test": VerifySnmpErrorCounters, + "eos_data": [ + { + "counters": { + "inVersionErrs": 0, + "inBadCommunityNames": 0, + "inBadCommunityUses": 0, + "inParseErrs": 0, + "outTooBigErrs": 5, + "outNoSuchNameErrs": 0, + "outBadValueErrs": 10, + "outGeneralErrs": 1, + }, + } + ], + "inputs": {"error_counters": ["inVersionErrs", "inParseErrs"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-counters-not-found", + "test": VerifySnmpErrorCounters, + "eos_data": [ + { + "counters": {}, + } + ], + "inputs": {}, + "expected": {"result": "failure", "messages": ["SNMP counters not found."]}, + }, + { + "name": "failure-incorrect-counters", + "test": VerifySnmpErrorCounters, + "eos_data": [ + { + "counters": { + "inVersionErrs": 1, + "inBadCommunityNames": 0, + "inBadCommunityUses": 0, + "inParseErrs": 2, + "outTooBigErrs": 0, + "outNoSuchNameErrs": 0, + "outBadValueErrs": 2, + "outGeneralErrs": 0, + }, + } + ], + "inputs": {}, + "expected": { + "result": "failure", + "messages": [ + "The following SNMP error counters are not found or have non-zero error counters:\n{'inVersionErrs': 1, 'inParseErrs': 2, 'outBadValueErrs': 2}" + ], + }, + }, ] From 7880064ac1170b7698e3aaea9c59f2136de3ca9a Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 11 Oct 2024 15:06:18 +0200 Subject: [PATCH 89/90] doc: Clean doc pre-release (#876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --------- Co-authored-by: Carl Baillargeon Co-authored-by: Matthieu Tâche --- anta/reporter/__init__.py | 7 +- anta/reporter/md_reporter.py | 6 +- docs/advanced_usages/as-python-lib.md | 90 +--------- docs/advanced_usages/custom-tests.md | 3 - docs/api/csv_reporter.md | 13 ++ docs/api/md_reporter.md | 13 ++ docs/api/{report_manager.md => reporters.md} | 5 +- docs/api/tests.md | 3 +- docs/api/tests.routing.bgp.md | 1 + docs/api/tests.routing.isis.md | 1 + docs/api/tests.routing.ospf.md | 1 + docs/cli/debug.md | 6 +- docs/cli/get-inventory-information.md | 176 ++++++------------- docs/cli/nrfu.md | 21 +-- docs/cli/tag-management.md | 58 +++++- docs/faq.md | 7 +- docs/stylesheets/extra.material.css | 3 +- docs/usage-inventory-catalog.md | 28 +-- examples/README.md | 24 ++- examples/merge_catalogs.py | 30 ++++ examples/parse_anta_inventory_file.py | 34 ++++ examples/run_eos_commands.py | 59 +++++++ mkdocs.yml | 5 +- pyproject.toml | 11 +- 24 files changed, 329 insertions(+), 276 deletions(-) create mode 100644 docs/api/csv_reporter.md create mode 100644 docs/api/md_reporter.md rename docs/api/{report_manager.md => reporters.md} (63%) create mode 100644 examples/merge_catalogs.py create mode 100644 examples/parse_anta_inventory_file.py create mode 100644 examples/run_eos_commands.py diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index 38f2e8e68..9e5fa1b30 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -105,7 +105,7 @@ def _color_result(self, status: AntaTestStatus) -> str: def report_all(self, manager: ResultManager, title: str = "All tests results") -> Table: """Create a table report with all tests for one or all devices. - Create table with full output: Host / Test / Status / Message + Create table with full output: Device | Test Name | Test Status | Message(s) | Test description | Test category Parameters ---------- @@ -141,7 +141,8 @@ def report_summary_tests( ) -> Table: """Create a table report with result aggregated per test. - Create table with full output: Test | Number of success | Number of failure | Number of error | List of nodes in error or failure + Create table with full output: + Test Name | # of success | # of skipped | # of failure | # of errors | List of failed or error nodes Parameters ---------- @@ -187,7 +188,7 @@ def report_summary_devices( ) -> Table: """Create a table report with result aggregated per device. - Create table with full output: Host | Number of success | Number of failure | Number of error | List of nodes in error or failure + Create table with full output: Device | # of success | # of skipped | # of failure | # of errors | List of failed or error test cases Parameters ---------- diff --git a/anta/reporter/md_reporter.py b/anta/reporter/md_reporter.py index 887fd6689..be3e86faf 100644 --- a/anta/reporter/md_reporter.py +++ b/anta/reporter/md_reporter.py @@ -112,8 +112,8 @@ def generate_heading_name(self) -> str: Example ------- - - `ANTAReport` will become ANTA Report. - - `TestResultsSummary` will become Test Results Summary. + - `ANTAReport` will become `ANTA Report`. + - `TestResultsSummary` will become `Test Results Summary`. """ class_name = self.__class__.__name__ @@ -153,7 +153,7 @@ def write_heading(self, heading_level: int) -> None: Example ------- - ## Test Results Summary + `## Test Results Summary` """ # Ensure the heading level is within the valid range of 1 to 6 heading_level = max(1, min(heading_level, 6)) diff --git a/docs/advanced_usages/as-python-lib.md b/docs/advanced_usages/as-python-lib.md index d64104a95..49c010f80 100644 --- a/docs/advanced_usages/as-python-lib.md +++ b/docs/advanced_usages/as-python-lib.md @@ -44,98 +44,14 @@ The [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) class is a ### Parse an ANTA inventory file ```python -""" -This script parses an ANTA inventory file, connects to devices and print their status. -""" -import asyncio - -from anta.inventory import AntaInventory - - -async def main(inv: AntaInventory) -> None: - """ - Take an AntaInventory and: - 1. try to connect to every device in the inventory - 2. print a message for every device connection status - """ - await inv.connect_inventory() - - for device in inv.values(): - if device.established: - print(f"Device {device.name} is online") - else: - print(f"Could not connect to device {device.name}") - -if __name__ == "__main__": - # Create the AntaInventory instance - inventory = AntaInventory.parse( - filename="inv.yml", - username="arista", - password="@rista123", - ) - - # Run the main coroutine - res = asyncio.run(main(inventory)) +--8<-- "parse_anta_inventory_file.py" ``` -??? note "How to create your inventory file" +!!! note "How to create your inventory file" Please visit this [dedicated section](../usage-inventory-catalog.md) for how to use inventory and catalog files. ### Run EOS commands ```python -""" -This script runs a list of EOS commands on reachable devices. -""" -# This is needed to run the script for python < 3.10 for typing annotations -from __future__ import annotations - -import asyncio -from pprint import pprint - -from anta.inventory import AntaInventory -from anta.models import AntaCommand - - -async def main(inv: AntaInventory, commands: list[str]) -> dict[str, list[AntaCommand]]: - """ - Take an AntaInventory and a list of commands as string and: - 1. try to connect to every device in the inventory - 2. collect the results of the commands from each device - - Returns: - a dictionary where key is the device name and the value is the list of AntaCommand ran towards the device - """ - await inv.connect_inventory() - - # Make a list of coroutine to run commands towards each connected device - coros = [] - # dict to keep track of the commands per device - result_dict = {} - for name, device in inv.get_inventory(established_only=True).items(): - anta_commands = [AntaCommand(command=command, ofmt="json") for command in commands] - result_dict[name] = anta_commands - coros.append(device.collect_commands(anta_commands)) - - # Run the coroutines - await asyncio.gather(*coros) - - return result_dict - - -if __name__ == "__main__": - # Create the AntaInventory instance - inventory = AntaInventory.parse( - filename="inv.yml", - username="arista", - password="@rista123", - ) - - # Create a list of commands with json output - commands = ["show version", "show ip bgp summary"] - - # Run the main asyncio entry point - res = asyncio.run(main(inventory, commands)) - - pprint(res) +--8<-- "run_eos_commands.py" ``` diff --git a/docs/advanced_usages/custom-tests.md b/docs/advanced_usages/custom-tests.md index d81f32dcf..d79fe50e3 100644 --- a/docs/advanced_usages/custom-tests.md +++ b/docs/advanced_usages/custom-tests.md @@ -71,9 +71,6 @@ Full AntaTest API documentation is available in the [API documentation section]( ### Instance Attributes -!!! info - You can access an instance attribute in your code using the `self` reference. E.g. you can access the test input values using `self.inputs`. - ::: anta.models.AntaTest options: show_docstring_attributes: true diff --git a/docs/api/csv_reporter.md b/docs/api/csv_reporter.md new file mode 100644 index 000000000..0432c4590 --- /dev/null +++ b/docs/api/csv_reporter.md @@ -0,0 +1,13 @@ +--- +anta_title: CSV Reporter +--- + + +::: anta.reporter.csv_reporter + options: + show_root_heading: false + show_root_toc_entry: false diff --git a/docs/api/md_reporter.md b/docs/api/md_reporter.md new file mode 100644 index 000000000..7fa1a1537 --- /dev/null +++ b/docs/api/md_reporter.md @@ -0,0 +1,13 @@ +--- +anta_title: Markdown Reporter +--- + + +::: anta.reporter.md_reporter + options: + show_root_heading: false + show_root_toc_entry: false diff --git a/docs/api/report_manager.md b/docs/api/reporters.md similarity index 63% rename from docs/api/report_manager.md rename to docs/api/reporters.md index f0e381805..a72e107e2 100644 --- a/docs/api/report_manager.md +++ b/docs/api/reporters.md @@ -4,4 +4,7 @@ ~ that can be found in the LICENSE file. --> -### ::: anta.reporter.ReportTable +::: anta.reporter + options: + show_root_heading: false + show_root_toc_entry: false diff --git a/docs/api/tests.md b/docs/api/tests.md index 1ca4cb7b3..7dd74c133 100644 --- a/docs/api/tests.md +++ b/docs/api/tests.md @@ -18,7 +18,7 @@ Here are the tests that we currently provide: - [BFD](tests.bfd.md) - [Configuration](tests.configuration.md) - [Connectivity](tests.connectivity.md) -- [Field Notice](tests.field_notices.md) +- [Field Notices](tests.field_notices.md) - [Flow Tracking](tests.flow_tracking.md) - [GreenT](tests.greent.md) - [Hardware](tests.hardware.md) @@ -32,6 +32,7 @@ Here are the tests that we currently provide: - [Router Path Selection](tests.path_selection.md) - [Routing Generic](tests.routing.generic.md) - [Routing BGP](tests.routing.bgp.md) +- [Routing ISIS](tests.routing.isis.md) - [Routing OSPF](tests.routing.ospf.md) - [Security](tests.security.md) - [Services](tests.services.md) diff --git a/docs/api/tests.routing.bgp.md b/docs/api/tests.routing.bgp.md index 5c5467879..4537ec24b 100644 --- a/docs/api/tests.routing.bgp.md +++ b/docs/api/tests.routing.bgp.md @@ -18,3 +18,4 @@ anta_title: ANTA catalog for BGP tests filters: - "!test" - "!render" + - "!^_[^_]" diff --git a/docs/api/tests.routing.isis.md b/docs/api/tests.routing.isis.md index b545f33a7..bf50c72e7 100644 --- a/docs/api/tests.routing.isis.md +++ b/docs/api/tests.routing.isis.md @@ -18,3 +18,4 @@ anta_title: ANTA catalog for IS-IS tests filters: - "!test" - "!render" + - "!^_[^_]" diff --git a/docs/api/tests.routing.ospf.md b/docs/api/tests.routing.ospf.md index a01eb5011..2fd0cd410 100644 --- a/docs/api/tests.routing.ospf.md +++ b/docs/api/tests.routing.ospf.md @@ -18,3 +18,4 @@ anta_title: ANTA catalog for OSPF tests filters: - "!test" - "!render" + - "!^_[^_]" diff --git a/docs/cli/debug.md b/docs/cli/debug.md index 60ac74f69..4c864db0f 100644 --- a/docs/cli/debug.md +++ b/docs/cli/debug.md @@ -15,7 +15,7 @@ The ANTA CLI includes a set of debugging tools, making it easier to build and te These tools are especially helpful in building the tests, as they give a visual access to the output received from the eAPI. They also facilitate the extraction of output content for use in unit tests, as described in our [contribution guide](../contribution.md). !!! warning - The `debug` tools require a device from your inventory. Thus, you MUST use a valid [ANTA Inventory](../usage-inventory-catalog.md#device-inventory). + The `debug` tools require a device from your inventory. Thus, you must use a valid [ANTA Inventory](../usage-inventory-catalog.md#device-inventory). ## Executing an EOS command @@ -160,11 +160,11 @@ Run templated command 'show vlan {vlan_id}' with {'vlan_id': '10'} on DC1-LEAF1A } ``` +### Example of multiple arguments + !!! warning If multiple arguments of the same key are provided, only the last argument value will be kept in the template parameters. -### Example of multiple arguments - ```bash anta -log DEBUG debug run-template --template "ping {dst} source {src}" dst "8.8.8.8" src Loopback0 --device DC1-SPINE1     > {'dst': '8.8.8.8', 'src': 'Loopback0'} diff --git a/docs/cli/get-inventory-information.md b/docs/cli/get-inventory-information.md index 6fe9dc94d..ab1bebcd3 100644 --- a/docs/cli/get-inventory-information.md +++ b/docs/cli/get-inventory-information.md @@ -7,9 +7,55 @@ anta_title: Retrieving Inventory Information ~ that can be found in the LICENSE file. --> -The ANTA CLI offers multiple entrypoints to access data from your local inventory. +The ANTA CLI offers multiple commands to access data from your local inventory. -## Inventory used of examples +## List devices in inventory + +This command will list all devices available in the inventory. Using the `--tags` option, you can filter this list to only include devices with specific tags (visit [this page](tag-management.md) to learn more about tags). The `--connected` option allows to display only the devices where a connection has been established. + +### Command overview + +```bash +Usage: anta get inventory [OPTIONS] + + Show inventory loaded in ANTA. + +Options: + -u, --username TEXT Username to connect to EOS [env var: + ANTA_USERNAME; required] + -p, --password TEXT Password to connect to EOS that must be + provided. It can be prompted using '--prompt' + option. [env var: ANTA_PASSWORD] + --enable-password TEXT Password to access EOS Privileged EXEC mode. + It can be prompted using '--prompt' option. + Requires '--enable' option. [env var: + ANTA_ENABLE_PASSWORD] + --enable Some commands may require EOS Privileged EXEC + mode. This option tries to access this mode + before sending a command to the device. [env + var: ANTA_ENABLE] + -P, --prompt Prompt for passwords if they are not + provided. [env var: ANTA_PROMPT] + --timeout FLOAT Global API timeout. This value will be used + for all devices. [env var: ANTA_TIMEOUT; + default: 30.0] + --insecure Disable SSH Host Key validation. [env var: + ANTA_INSECURE] + --disable-cache Disable cache globally. [env var: + ANTA_DISABLE_CACHE] + -i, --inventory FILE Path to the inventory YAML file. [env var: + ANTA_INVENTORY; required] + --tags TEXT List of tags using comma as separator: + tag1,tag2,tag3. [env var: ANTA_TAGS] + --connected / --not-connected Display inventory after connection has been + created + --help Show this message and exit. +``` + +!!! tip + By default, `anta get inventory` only provides information that doesn't rely on a device connection. If you are interested in obtaining connection-dependent details, like the hardware model, use the `--connected` option. + +### Example Let's consider the following inventory: @@ -66,122 +112,15 @@ anta_inventory: tags: ["BL", "DC2"] ``` -## Obtaining all configured tags - -As most of ANTA's commands accommodate tag filtering, this particular command is useful for enumerating all tags configured in the inventory. Running the `anta get tags` command will return a list of all tags that have been configured in the inventory. - -### Command overview - -```bash -Usage: anta get tags [OPTIONS] - - Get list of configured tags in user inventory. - -Options: - -u, --username TEXT Username to connect to EOS [env var: ANTA_USERNAME; - required] - -p, --password TEXT Password to connect to EOS that must be provided. It - can be prompted using '--prompt' option. [env var: - ANTA_PASSWORD] - --enable-password TEXT Password to access EOS Privileged EXEC mode. It can - be prompted using '--prompt' option. Requires '-- - enable' option. [env var: ANTA_ENABLE_PASSWORD] - --enable Some commands may require EOS Privileged EXEC mode. - This option tries to access this mode before sending - a command to the device. [env var: ANTA_ENABLE] - -P, --prompt Prompt for passwords if they are not provided. [env - var: ANTA_PROMPT] - --timeout FLOAT Global API timeout. This value will be used for all - devices. [env var: ANTA_TIMEOUT; default: 30.0] - --insecure Disable SSH Host Key validation. [env var: - ANTA_INSECURE] - --disable-cache Disable cache globally. [env var: - ANTA_DISABLE_CACHE] - -i, --inventory FILE Path to the inventory YAML file. [env var: - ANTA_INVENTORY; required] - --tags TEXT List of tags using comma as separator: - tag1,tag2,tag3. [env var: ANTA_TAGS] - --help Show this message and exit. -``` - -### Example - -To get the list of all configured tags in the inventory, run the following command: - -```bash -anta get tags -Tags found: -[ - "BL", - "DC1", - "DC2", - "LEAF", - "SPINE" -] - -* note that tag all has been added by anta -``` - -!!! note - Even if you haven't explicitly configured the `all` tag in the inventory, it is automatically added. This default tag allows to execute commands on all devices in the inventory when no tag is specified. - -## List devices in inventory - -This command will list all devices available in the inventory. Using the `--tags` option, you can filter this list to only include devices with specific tags. The `--connected` option allows to display only the devices where a connection has been established. - -### Command overview - -```bash -Usage: anta get inventory [OPTIONS] - - Show inventory loaded in ANTA. - -Options: - -u, --username TEXT Username to connect to EOS [env var: - ANTA_USERNAME; required] - -p, --password TEXT Password to connect to EOS that must be - provided. It can be prompted using '--prompt' - option. [env var: ANTA_PASSWORD] - --enable-password TEXT Password to access EOS Privileged EXEC mode. - It can be prompted using '--prompt' option. - Requires '--enable' option. [env var: - ANTA_ENABLE_PASSWORD] - --enable Some commands may require EOS Privileged EXEC - mode. This option tries to access this mode - before sending a command to the device. [env - var: ANTA_ENABLE] - -P, --prompt Prompt for passwords if they are not - provided. [env var: ANTA_PROMPT] - --timeout FLOAT Global API timeout. This value will be used - for all devices. [env var: ANTA_TIMEOUT; - default: 30.0] - --insecure Disable SSH Host Key validation. [env var: - ANTA_INSECURE] - --disable-cache Disable cache globally. [env var: - ANTA_DISABLE_CACHE] - -i, --inventory FILE Path to the inventory YAML file. [env var: - ANTA_INVENTORY; required] - --tags TEXT List of tags using comma as separator: - tag1,tag2,tag3. [env var: ANTA_TAGS] - --connected / --not-connected Display inventory after connection has been - created - --help Show this message and exit. -``` - -!!! tip - In its default mode, `anta get inventory` provides only information that doesn't rely on a device connection. If you are interested in obtaining connection-dependent details, like the hardware model, please use the `--connected` option. - -### Example - To retrieve a comprehensive list of all devices along with their details, execute the following command. It will provide all the data loaded into the ANTA inventory from your [inventory file](../usage-inventory-catalog.md). ```bash -anta get inventory --tags SPINE +$ anta get inventory --tags SPINE Current inventory content is: { 'DC1-SPINE1': AsyncEOSDevice( name='DC1-SPINE1', - tags=['SPINE', 'DC1'], + tags={'DC1-SPINE1', 'DC1', 'SPINE'}, hw_model=None, is_online=False, established=False, @@ -189,13 +128,12 @@ Current inventory content is: host='172.20.20.101', eapi_port=443, username='arista', - enable=True, - enable_password='arista', + enable=False, insecure=False ), 'DC1-SPINE2': AsyncEOSDevice( name='DC1-SPINE2', - tags=['SPINE', 'DC1'], + tags={'DC1', 'SPINE', 'DC1-SPINE2'}, hw_model=None, is_online=False, established=False, @@ -203,12 +141,12 @@ Current inventory content is: host='172.20.20.102', eapi_port=443, username='arista', - enable=True, + enable=False, insecure=False ), 'DC2-SPINE1': AsyncEOSDevice( name='DC2-SPINE1', - tags=['SPINE', 'DC2'], + tags={'DC2', 'DC2-SPINE1', 'SPINE'}, hw_model=None, is_online=False, established=False, @@ -216,12 +154,12 @@ Current inventory content is: host='172.20.20.201', eapi_port=443, username='arista', - enable=True, + enable=False, insecure=False ), 'DC2-SPINE2': AsyncEOSDevice( name='DC2-SPINE2', - tags=['SPINE', 'DC2'], + tags={'DC2', 'DC2-SPINE2', 'SPINE'}, hw_model=None, is_online=False, established=False, @@ -229,7 +167,7 @@ Current inventory content is: host='172.20.20.202', eapi_port=443, username='arista', - enable=True, + enable=False, insecure=False ) } diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index 6c360b7f9..0f2b42524 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -9,10 +9,12 @@ anta_title: Execute Network Readiness For Use (NRFU) Testing ANTA provides a set of commands for performing NRFU tests on devices. These commands are under the `anta nrfu` namespace and offer multiple output format options: -- [Text view](#performing-nrfu-with-text-rendering) -- [Table view](#performing-nrfu-with-table-rendering) -- [JSON view](#performing-nrfu-with-json-rendering) -- [Custom template view](#performing-nrfu-with-custom-reports) +- [Text report](#performing-nrfu-with-text-rendering) +- [Table report](#performing-nrfu-with-table-rendering) +- [JSON report](#performing-nrfu-with-json-rendering) +- [Custom template report](#performing-nrfu-with-custom-reports) +- [CSV report](#performing-nrfu-and-saving-results-in-a-csv-file) +- [Markdown report](#performing-nrfu-and-saving-results-in-a-markdown-file) ## NRFU Command overview @@ -29,16 +31,7 @@ All commands under the `anta nrfu` namespace require a catalog yaml file specifi ### Tag management -The `--tags` option can be used to target specific devices in your inventory and run only tests configured with this specific tags from your catalog. The default tag is set to `all` and is implicit. Expected behaviour is provided below: - -| Command | Description | -| ------- | ----------- | -| `none` | Run all tests on all devices according `tag` definition in your inventory and test catalog. And tests with no tag are executed on all devices| -| `--tags leaf` | Run all tests marked with `leaf` tag on all devices configured with `leaf` tag.
All other tags are ignored | -| `--tags leaf,spine` | Run all tests marked with `leaf` tag on all devices configured with `leaf` tag.
Run all tests marked with `spine` tag on all devices configured with `spine` tag.
All other tags are ignored | - -!!! info - [More examples](tag-management.md) available on this dedicated page. +The `--tags` option can be used to target specific devices in your inventory and run only tests configured with this specific tags from your catalog. Refer to the [dedicated page](tag-management.md) for more information. ### Device and test filtering diff --git a/docs/cli/tag-management.md b/docs/cli/tag-management.md index 9a74b7f83..ad5ccf3ab 100644 --- a/docs/cli/tag-management.md +++ b/docs/cli/tag-management.md @@ -31,7 +31,7 @@ anta_inventory: Each device also has its own name automatically added as a tag: ```bash -anta get inventory +$ anta get inventory Current inventory content is: { 'leaf1': AsyncEOSDevice( @@ -170,7 +170,7 @@ In this case, only `leaf` devices defined in the inventory are used to run tests It is possible to use multiple tags using the `--tags tag1,tag2` syntax. ```bash -anta nrfu --tags leaf,spine text +$ anta nrfu --tags leaf,spine text ╭────────────────────── Settings ──────────────────────╮ │ - ANTA Inventory contains 3 devices (AsyncEOSDevice) │ │ - Tests catalog contains 11 tests │ @@ -197,3 +197,57 @@ spine1 :: VerifyUptime :: SUCCESS spine1 :: VerifyL3MTU :: SUCCESS spine1 :: VerifyUptime :: SUCCESS ``` + +## Obtaining all configured tags + +As most ANTA commands accommodate tag filtering, this command is useful for enumerating all tags configured in the inventory. Running the `anta get tags` command will return a list of all tags configured in the inventory. + +### Command overview + +```bash +Usage: anta get tags [OPTIONS] + + Get list of configured tags in user inventory. + +Options: + -u, --username TEXT Username to connect to EOS [env var: ANTA_USERNAME; + required] + -p, --password TEXT Password to connect to EOS that must be provided. It + can be prompted using '--prompt' option. [env var: + ANTA_PASSWORD] + --enable-password TEXT Password to access EOS Privileged EXEC mode. It can + be prompted using '--prompt' option. Requires '-- + enable' option. [env var: ANTA_ENABLE_PASSWORD] + --enable Some commands may require EOS Privileged EXEC mode. + This option tries to access this mode before sending + a command to the device. [env var: ANTA_ENABLE] + -P, --prompt Prompt for passwords if they are not provided. [env + var: ANTA_PROMPT] + --timeout FLOAT Global API timeout. This value will be used for all + devices. [env var: ANTA_TIMEOUT; default: 30.0] + --insecure Disable SSH Host Key validation. [env var: + ANTA_INSECURE] + --disable-cache Disable cache globally. [env var: + ANTA_DISABLE_CACHE] + -i, --inventory FILE Path to the inventory YAML file. [env var: + ANTA_INVENTORY; required] + --tags TEXT List of tags using comma as separator: + tag1,tag2,tag3. [env var: ANTA_TAGS] + --help Show this message and exit. +``` + +### Example + +To get the list of all configured tags in the inventory, run the following command: + +```bash +$ anta get tags +Tags found: +[ + "leaf", + "leaf1", + "leaf2", + "spine", + "spine1" +] +``` diff --git a/docs/faq.md b/docs/faq.md index 2d2ea6544..7a5866337 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,5 +1,6 @@ --- -toc_depth: 2 +toc_depth: 3 +anta_title: Frequently Asked Questions (FAQ) --- -# Frequently Asked Questions (FAQ) - ## A local OS error occurred while connecting to a device ???+ faq "A local OS error occurred while connecting to a device" diff --git a/docs/stylesheets/extra.material.css b/docs/stylesheets/extra.material.css index 44e7c1350..2476f8cf0 100644 --- a/docs/stylesheets/extra.material.css +++ b/docs/stylesheets/extra.material.css @@ -133,7 +133,7 @@ } .md-typeset h4 { - font-size: 0.9rem; + font-size: 1.1rem; margin: 1em 0; font-weight: 700; letter-spacing: -.01em; @@ -149,7 +149,6 @@ letter-spacing: -.01em; color: var(--md-default-fg-color--light); font-style: italic; - text-decoration: underline; } .md-typeset table:not([class]) th { diff --git a/docs/usage-inventory-catalog.md b/docs/usage-inventory-catalog.md index e180496ee..e41321ae5 100644 --- a/docs/usage-inventory-catalog.md +++ b/docs/usage-inventory-catalog.md @@ -314,33 +314,7 @@ Once you run `anta nrfu table`, you will see following output: The following script reads all the files in `intended/test_catalogs/` with names `-catalog.yml` and merge them together inside one big catalog `anta-catalog.yml` using the new `AntaCatalog.merge_catalogs()` class method. ```python -#!/usr/bin/env python -from anta.catalog import AntaCatalog - -from pathlib import Path -from anta.models import AntaTest - - -CATALOG_SUFFIX = "-catalog.yml" -CATALOG_DIR = "intended/test_catalogs/" - -if __name__ == "__main__": - catalogs = [] - for file in Path(CATALOG_DIR).glob("*" + CATALOG_SUFFIX): - device = str(file).removesuffix(CATALOG_SUFFIX).removeprefix(CATALOG_DIR) - print(f"Loading test catalog for device {device}") - catalog = AntaCatalog.parse(file) - # Add the device name as a tag to all tests in the catalog - for test in catalog.tests: - test.inputs.filters = AntaTest.Input.Filters(tags={device}) - catalogs.append(catalog) - - # Merge all catalogs - merged_catalog = AntaCatalog.merge_catalogs(catalogs) - - # Save the merged catalog to a file - with open(Path('anta-catalog.yml'), "w") as f: - f.write(merged_catalog.dump().yaml()) +--8<-- "merge_catalogs.py" ``` !!! warning diff --git a/examples/README.md b/examples/README.md index b07cef28b..6fc1f4e9a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # ANTA Example files -This section provides some examples about how to use ANTA as listed in [the documentation](https://anta.arista.com) +This section provides some examples about how to use ANTA as listed in [the documentation](https://anta.arista.com). ## Device Inventory @@ -25,10 +25,28 @@ The file [eos-commands.yaml](eos-commands.yaml) is an example of input given wit - Filename: [`anta_runner.py`](./anta_runner.py) -The file is an example demonstrating how to run ANTA using a python script +The file is an example demonstrating how to run ANTA using a Python script. ## ANTA template for results rendering - Filename: [`template.j2`](./template.j2) -This file is a simple Jinja2 file to customize ANTA CLI output as documented in [anta documentation](https://anta.arista.com/stable/cli/nrfu/#performing-nrfu-with-custom-reports) \ No newline at end of file +This file is a simple Jinja2 file to customize ANTA CLI output as documented in [cli documentation](https://anta.arista.com/stable/cli/nrfu/#performing-nrfu-with-custom-reports). + +## Merge multiple catalogs + +- Filename: [`merge_catalogs.py`](./merge_catalogs.py) + +This file is an example demonstrating how to merge multiple catalogs into a single catalog and save it to a file, as documented in [usage test catalog](https://anta.arista.com/stable/usage-inventory-catalog/#example-script-to-merge-catalogs). + +## Run multiple commands + +- Filename: [`run_eos_commands.py`](./run_eos_commands.py) + +This file is an example demonstrating how to run multiple commands on multiple devices, as documented in [advanced usages](https://anta.arista.com/stable/advanced_usages/as-python-lib/#run-eos-commands). + +## Parse ANTA inventory file + +- Filename: [`parse_anta_inventory_file.py`](./parse_anta_inventory_file.py) + +This file is an example demonstrating how to parse an ANTA inventory file, as documented in [advanced usages](https://anta.arista.com/stable/advanced_usages/as-python-lib/#parse-anta-inventory-file). \ No newline at end of file diff --git a/examples/merge_catalogs.py b/examples/merge_catalogs.py new file mode 100644 index 000000000..1d594be40 --- /dev/null +++ b/examples/merge_catalogs.py @@ -0,0 +1,30 @@ +# Copyright (c) 2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Script that merge a collection of catalogs into one AntaCatalog.""" + +from pathlib import Path + +from anta.catalog import AntaCatalog +from anta.models import AntaTest + +CATALOG_SUFFIX = "-catalog.yml" +CATALOG_DIR = "intended/test_catalogs/" + +if __name__ == "__main__": + catalogs = [] + for file in Path(CATALOG_DIR).glob("*" + CATALOG_SUFFIX): + device = str(file).removesuffix(CATALOG_SUFFIX).removeprefix(CATALOG_DIR) + print(f"Loading test catalog for device {device}") + catalog = AntaCatalog.parse(file) + # Add the device name as a tag to all tests in the catalog + for test in catalog.tests: + test.inputs.filters = AntaTest.Input.Filters(tags={device}) + catalogs.append(catalog) + + # Merge all catalogs + merged_catalog = AntaCatalog.merge_catalogs(catalogs) + + # Save the merged catalog to a file + with Path("anta-catalog.yml").open("w") as f: + f.write(merged_catalog.dump().yaml()) diff --git a/examples/parse_anta_inventory_file.py b/examples/parse_anta_inventory_file.py new file mode 100644 index 000000000..fbbe04286 --- /dev/null +++ b/examples/parse_anta_inventory_file.py @@ -0,0 +1,34 @@ +# Copyright (c) 2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Script that parses an ANTA inventory file, connects to devices and print their status.""" + +import asyncio + +from anta.inventory import AntaInventory + + +async def main(inv: AntaInventory) -> None: + """Read an AntaInventory and try to connect to every device in the inventory. + + Print a message for every device connection status + """ + await inv.connect_inventory() + + for device in inv.values(): + if device.established: + print(f"Device {device.name} is online") + else: + print(f"Could not connect to device {device.name}") + + +if __name__ == "__main__": + # Create the AntaInventory instance + inventory = AntaInventory.parse( + filename="inventory.yaml", + username="arista", + password="@rista123", + ) + + # Run the main coroutine + res = asyncio.run(main(inventory)) diff --git a/examples/run_eos_commands.py b/examples/run_eos_commands.py new file mode 100644 index 000000000..165220f86 --- /dev/null +++ b/examples/run_eos_commands.py @@ -0,0 +1,59 @@ +# Copyright (c) 2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Script that runs a list of EOS commands on reachable devices.""" + +# This is needed to run the script for python < 3.10 for typing annotations +from __future__ import annotations + +import asyncio +from pprint import pprint + +from anta.inventory import AntaInventory +from anta.models import AntaCommand + + +async def main(inv: AntaInventory, commands: list[str]) -> dict[str, list[AntaCommand]]: + """Run a list of commands against each valid device in the inventory. + + Take an AntaInventory and a list of commands as string + 1. try to connect to every device in the inventory + 2. collect the results of the commands from each device + + Returns + ------- + dict[str, list[AntaCommand]] + a dictionary where key is the device name and the value is the list of AntaCommand ran towards the device + """ + await inv.connect_inventory() + + # Make a list of coroutine to run commands towards each connected device + coros = [] + # dict to keep track of the commands per device + result_dict = {} + for name, device in inv.get_inventory(established_only=True).items(): + anta_commands = [AntaCommand(command=command, ofmt="json") for command in commands] + result_dict[name] = anta_commands + coros.append(device.collect_commands(anta_commands)) + + # Run the coroutines + await asyncio.gather(*coros) + + return result_dict + + +if __name__ == "__main__": + # Create the AntaInventory instance + inventory = AntaInventory.parse( + filename="inventory.yaml", + username="arista", + password="@rista123", + ) + + # Create a list of commands with json output + command_list = ["show version", "show ip bgp summary"] + + # Run the main asyncio entry point + res = asyncio.run(main(inventory, command_list)) + + pprint(res) diff --git a/mkdocs.yml b/mkdocs.yml index dd417ded1..100042eac 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -230,7 +230,10 @@ nav: - Result Manager: - Result Manager module: api/result_manager.md - Result Manager models: api/result_manager_models.md - - Report Manager: api/report_manager.md + - Reporter: + - CSV reporter: api/csv_reporter.md + - Markdown reporter: api/md_reporter.md + - Other reporters: api/reporters.md - Runner: api/runner.md - Troubleshooting ANTA: troubleshooting.md - Contributions: contribution.md diff --git a/pyproject.toml b/pyproject.toml index 80a59e9ea..b72ec3a70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,8 @@ doc = [ "mkdocs-material-extensions>=1.3.1", "mkdocs-material>=9.5.34", "mkdocstrings[python]>=0.26.0", - "mkdocstrings-python>=1.11.0" + "mkdocstrings-python>=1.11.0", + "black>=24.10.0" ] [project.urls] @@ -435,10 +436,14 @@ runtime-evaluated-base-classes = ["pydantic.BaseModel", "anta.models.AntaTest.In "anta/inventory/__init__.py" = [ "PLR0913", # Ok to have more than 5 arguments in the AntaInventory class ] -"examples/anta_runner.py" = [ # This is an example script and linked in snippets - "S108", # Probable insecure usage of temporary file or directory +"examples/*.py" = [ # These are example scripts and linked in snippets "S105", # Possible hardcoded password + "S106", # Possible hardcoded password assigned to argument + "S108", # Probable insecure usage of temporary file or directory "INP001", # Implicit packages + "T201", # `print` found + "T203", # `pprint` found + ] ################################ From 92cbcd3255c2a23c26e96984cc8cf669e243dd5e Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 11 Oct 2024 16:16:25 +0200 Subject: [PATCH 90/90] bump: Version 1.0.0 -> 1.1.0 (#878) --- docs/contribution.md | 2 +- docs/requirements-and-installation.md | 2 +- pyproject.toml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/contribution.md b/docs/contribution.md index 6299139e0..88f09c180 100644 --- a/docs/contribution.md +++ b/docs/contribution.md @@ -29,7 +29,7 @@ $ pip install -e .[dev,cli] $ pip list -e Package Version Editable project location ------- ------- ------------------------- -anta 1.0.0 /mnt/lab/projects/anta +anta 1.1.0 /mnt/lab/projects/anta ``` Then, [`tox`](https://tox.wiki/) is configured with few environments to run CI locally: diff --git a/docs/requirements-and-installation.md b/docs/requirements-and-installation.md index 5f99ac0ac..1b3575877 100644 --- a/docs/requirements-and-installation.md +++ b/docs/requirements-and-installation.md @@ -86,7 +86,7 @@ which anta ```bash # Check ANTA version anta --version -anta, version v1.0.0 +anta, version v1.1.0 ``` ## EOS Requirements diff --git a/pyproject.toml b/pyproject.toml index b72ec3a70..bc7ac7127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "anta" -version = "v1.0.0" +version = "v1.1.0" readme = "docs/README.md" authors = [{ name = "Arista Networks ANTA maintainers", email = "anta-dev@arista.com" }] maintainers = [ @@ -119,7 +119,7 @@ namespaces = false # Version ################################ [tool.bumpver] -current_version = "1.0.0" +current_version = "1.1.0" version_pattern = "MAJOR.MINOR.PATCH" commit_message = "bump: Version {old_version} -> {new_version}" commit = true