From 0bf600edabd17e716e1d78a38564c61a6820fd93 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Wed, 8 Nov 2023 18:10:54 +0100 Subject: [PATCH 01/53] WIP --- anta/cli/__init__.py | 4 +- anta/cli/utils.py | 103 ++++++++++++++++++++++++++----------------- 2 files changed, 64 insertions(+), 43 deletions(-) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index 1349c15ff..8957d84c8 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -21,12 +21,12 @@ from anta.cli.exec import commands as exec_commands from anta.cli.get import commands as get_commands from anta.cli.nrfu import commands as nrfu_commands -from anta.cli.utils import AliasedGroup, IgnoreRequiredWithHelp, parse_catalog, parse_inventory +from anta.cli.utils import AliasedGroup, IgnoreRequiredForMainCommand, IgnoreRequiredWithHelp, parse_catalog, parse_inventory from anta.logger import setup_logging from anta.result_manager import ResultManager -@click.group(cls=IgnoreRequiredWithHelp) +@click.group(cls=IgnoreRequiredForMainCommand) @click.pass_context @click.version_option(__version__) @click.option( diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 98e19cc53..3a08d79a4 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -97,6 +97,12 @@ def parse_catalog(ctx: click.Context, param: Option, value: Path) -> AntaCatalog return catalog +def maybe_required_cb(ctx: click.Context, param: Option, value: str) -> Any: + """ + Repace the "required" true + """ + + def exit_with_code(ctx: click.Context) -> None: """ Exit the Click application with an exit code. @@ -130,7 +136,34 @@ def exit_with_code(ctx: click.Context) -> None: raise ValueError(f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github.") -class IgnoreRequiredWithHelp(click.Group): +class AliasedGroup(click.Group): + """ + Implements a subclass of Group that accepts a prefix for a command. + If there were a command called push, it would accept pus as an alias (so long as it was unique) + From Click documentation + """ + + def get_command(self, ctx: click.Context, cmd_name: str) -> Any: + """Todo: document code""" + rv = click.Group.get_command(self, ctx, cmd_name) + if rv is not None: + return rv + matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] + if not matches: + return None + if len(matches) == 1: + return click.Group.get_command(self, ctx, matches[0]) + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") + return None + + def resolve_command(self, ctx: click.Context, args: Any) -> Any: + """Todo: document code""" + # always return the full command name + _, cmd, args = super().resolve_command(ctx, args) + return cmd.name, cmd, args # type: ignore + + +class IgnoreRequiredWithHelp(AliasedGroup): """ https://stackoverflow.com/questions/55818737/python-click-application-required-parameters-have-precedence-over-sub-command-he Solution to allow help without required options on subcommand @@ -160,48 +193,36 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: return super().parse_args(ctx, args) - def get_command(self, ctx: click.Context, cmd_name: str) -> Any: - """Todo: document code""" - rv = click.Group.get_command(self, ctx, cmd_name) - if rv is not None: - return rv - matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] - if not matches: - return None - if len(matches) == 1: - return click.Group.get_command(self, ctx, matches[0]) - ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") - return None - - def resolve_command(self, ctx: click.Context, args: Any) -> Any: - """Todo: document code""" - # always return the full command name - _, cmd, args = super().resolve_command(ctx, args) - return cmd.name, cmd, args # type: ignore - -class AliasedGroup(click.Group): +class IgnoreRequiredForMainCommand(IgnoreRequiredWithHelp): """ - Implements a subclass of Group that accepts a prefix for a command. - If there were a command called push, it would accept pus as an alias (so long as it was unique) - From Click documentation + Custom ANTA ignore knob for required arguments: + * Allow --help without required options on subcommand + * Allow relaxing required arguments for `anta get from-cvp` and `anta get from-ansible` + + This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734 """ - def get_command(self, ctx: click.Context, cmd_name: str) -> Any: - """Todo: document code""" - rv = click.Group.get_command(self, ctx, cmd_name) - if rv is not None: - return rv - matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] - if not matches: - return None - if len(matches) == 1: - return click.Group.get_command(self, ctx, matches[0]) - ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") - return None + 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) + if "--help" in args: + ctx.obj["_anta_help"] = True - def resolve_command(self, ctx: click.Context, args: Any) -> Any: - """Todo: document code""" - # always return the full command name - _, cmd, args = super().resolve_command(ctx, args) - return cmd.name, cmd, args # type: ignore + raise Exception(ctx.__dict__) + + try: + return super().parse_args(ctx, args) + except click.MissingParameter: + if "--help" not in args: + raise + + # remove the required params so that help can display + for param in self.params: + param.required = False + + return super().parse_args(ctx, args) From 8f57ac9eb060cbabda945f82df46e469581cb125 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Thu, 9 Nov 2023 10:26:38 +0100 Subject: [PATCH 02/53] WIP --- anta/cli/__init__.py | 15 +++++++++------ anta/cli/utils.py | 21 +++++++++++++++++++-- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index 8957d84c8..c247b4867 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -21,7 +21,7 @@ from anta.cli.exec import commands as exec_commands from anta.cli.get import commands as get_commands from anta.cli.nrfu import commands as nrfu_commands -from anta.cli.utils import AliasedGroup, IgnoreRequiredForMainCommand, IgnoreRequiredWithHelp, parse_catalog, parse_inventory +from anta.cli.utils import AliasedGroup, IgnoreRequiredForMainCommand, IgnoreRequiredWithHelp, maybe_required_cb, parse_catalog, parse_inventory from anta.logger import setup_logging from anta.result_manager import ResultManager @@ -33,7 +33,8 @@ "--username", help="Username to connect to EOS", show_envvar=True, - required=True, + callback=maybe_required_cb, + # required=True, ) @click.option("--password", help="Password to connect to EOS that must be provided. It can be prompted using '--prompt' option.", show_envvar=True) @click.option( @@ -77,7 +78,8 @@ "-i", help="Path to the inventory YAML file", show_envvar=True, - required=True, + callback=maybe_required_cb, + # required=True, type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=pathlib.Path), ) @click.option( @@ -125,7 +127,7 @@ def anta( ctx.params["enable_password"] = click.prompt( "Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True ) - if ctx.params.get("password") is None: + if ctx.params.get("password") is None and not ctx.obj.get("skip_password"): raise click.BadParameter( f"EOS password needs to be provided by using either the '{anta.params[2].opts[0]}' option or the '{anta.params[5].opts[0]}' option." ) @@ -133,8 +135,9 @@ def anta( raise click.BadParameter(f"Providing a password to access EOS Privileged EXEC mode requires '{anta.params[4].opts[0]}' option.") ctx.ensure_object(dict) - ctx.obj["inventory"] = parse_inventory(ctx, inventory) - ctx.obj["inventory_path"] = ctx.params["inventory"] + if not ctx.obj.get("skip_inventory"): + ctx.obj["inventory"] = parse_inventory(ctx, inventory) + ctx.obj["inventory_path"] = ctx.params["inventory"] @anta.group("nrfu", cls=IgnoreRequiredWithHelp) diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 3a08d79a4..d711ac011 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -99,8 +99,24 @@ def parse_catalog(ctx: click.Context, param: Option, value: Path) -> AntaCatalog def maybe_required_cb(ctx: click.Context, param: Option, value: str) -> Any: """ - Repace the "required" true + Replace the "required" true with a callback to handle our specificies + + TODO: evaluate if moving the options to the groups is not better than this .. """ + if ctx.obj.get("_anta_help"): + # If help then don't do anything + return + if "get" in ctx.obj["args"]: + # the group has put the args from cli in the ctx.obj + # This is a bit convoluted + ctx.obj["skip_password"] = True + if "from-cvp" in ctx.obj["args"] or "from-ansible" in ctx.obj["args"]: + ctx.obj["skip_inventory"] = True + elif param.name == "inventory" and param.value_is_missing(value): + raise click.exceptions.MissingParameter(ctx=ctx, param=param) + return + if param.value_is_missing(value): + raise click.exceptions.MissingParameter(ctx=ctx, param=param) def exit_with_code(ctx: click.Context) -> None: @@ -213,7 +229,8 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: if "--help" in args: ctx.obj["_anta_help"] = True - raise Exception(ctx.__dict__) + # Storing full CLI call in ctx to get it in callbacks + ctx.obj["args"] = args try: return super().parse_args(ctx, args) From 7599dfbd9a36b4b0cc9d2d184271728155f942cf Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 24 Nov 2023 15:31:08 +0100 Subject: [PATCH 03/53] Test: Add tests for get from-ansible --- anta/cli/__init__.py | 12 +- anta/cli/get/commands.py | 56 +++---- anta/cli/utils.py | 71 ++++---- tests/data/empty | 0 tests/data/expected_anta_inventory.yml | 16 ++ tests/data/toto.yml | 16 ++ tests/units/cli/get/test_commands.py | 220 +++++++++++++++++-------- 7 files changed, 242 insertions(+), 149 deletions(-) create mode 100644 tests/data/empty create mode 100644 tests/data/expected_anta_inventory.yml create mode 100644 tests/data/toto.yml diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index c247b4867..28828c472 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -21,19 +21,19 @@ from anta.cli.exec import commands as exec_commands from anta.cli.get import commands as get_commands from anta.cli.nrfu import commands as nrfu_commands -from anta.cli.utils import AliasedGroup, IgnoreRequiredForMainCommand, IgnoreRequiredWithHelp, maybe_required_cb, parse_catalog, parse_inventory +from anta.cli.utils import AliasedGroup, IgnoreRequiredWithHelp, maybe_required_inventory_cb, maybe_required_username_cb, parse_catalog, parse_inventory from anta.logger import setup_logging from anta.result_manager import ResultManager -@click.group(cls=IgnoreRequiredForMainCommand) +@click.group(cls=IgnoreRequiredWithHelp) @click.pass_context @click.version_option(__version__) @click.option( "--username", help="Username to connect to EOS", show_envvar=True, - callback=maybe_required_cb, + callback=maybe_required_username_cb, # required=True, ) @click.option("--password", help="Password to connect to EOS that must be provided. It can be prompted using '--prompt' option.", show_envvar=True) @@ -78,9 +78,9 @@ "-i", help="Path to the inventory YAML file", show_envvar=True, - callback=maybe_required_cb, + callback=maybe_required_inventory_cb, # required=True, - type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=pathlib.Path), + type=click.Path(file_okay=True, dir_okay=False, readable=True, path_type=pathlib.Path), ) @click.option( "--log-file", @@ -135,9 +135,9 @@ def anta( raise click.BadParameter(f"Providing a password to access EOS Privileged EXEC mode requires '{anta.params[4].opts[0]}' option.") ctx.ensure_object(dict) + ctx.obj["inventory_path"] = ctx.params.get("inventory") if not ctx.obj.get("skip_inventory"): ctx.obj["inventory"] = parse_inventory(ctx, inventory) - ctx.obj["inventory_path"] = ctx.params["inventory"] @anta.group("nrfu", cls=IgnoreRequiredWithHelp) diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index 56ec5ca92..4269a0e98 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -2,26 +2,24 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name - """ Commands for Anta CLI to get information / build inventories.. """ from __future__ import annotations import asyncio -import io import json import logging import os import sys from pathlib import Path +from sys import stdin from typing import Optional import click from cvprac.cvp_client import CvpClient from cvprac.cvp_client_errors import CvpApiError from rich.pretty import pretty_repr -from rich.prompt import Confirm from anta.cli.console import console from anta.cli.utils import ExitCode, parse_tags @@ -105,39 +103,31 @@ def from_ansible(ctx: click.Context, output: Path, ansible_inventory: Path, ansi """Build ANTA inventory from an ansible inventory YAML file""" logger.info(f"Building inventory from ansible file {ansible_inventory}") - try: - is_tty = os.isatty(sys.stdout.fileno()) - except io.UnsupportedOperation: - is_tty = False - - # Create output directory + # Verify output directory + # 1. Either ouptut is set and it is used + # 2. Else the inventory_path is retrieved from the context (ANTA_INVENTORY or anta --inventoyr value) + # 3. If None is set, error out. + output = output or ctx.obj.get("inventory_path") if output is None: - if ctx.obj.get("inventory_path") is not None: - output = ctx.obj.get("inventory_path") - else: - logger.error("Inventory output is not set. You should use either anta --inventory or anta get from-ansible --output") - sys.exit(ExitCode.USAGE_ERROR) - - logger.debug(f"output: {output}\noverwrite: {overwrite}\nis tty: {is_tty}") - - # Count number of lines in a file - anta_inventory_number_lines = 0 - if output.exists(): - with open(output, "r", encoding="utf-8") as f: - anta_inventory_number_lines = sum(1 for _ in f) - - # File has content and it is not interactive TTY nor overwrite set to True --> execution stop - if anta_inventory_number_lines > 0 and not is_tty and not overwrite: - logger.critical("conversion aborted since destination file is not empty (not running in interactive TTY)") + logger.error("Inventory output is not set. Either `anta --inventory` or `anta get from-ansible --output` MUST be set.") sys.exit(ExitCode.USAGE_ERROR) - # File has content and it is in an interactive TTY --> Prompt user - if anta_inventory_number_lines > 0 and is_tty and not overwrite: - confirm_overwrite = Confirm.ask(f"Your destination file ({output}) is not empty, continue?") - try: - assert confirm_overwrite is True - except AssertionError: - logger.critical("conversion aborted by user because destination file is not empty") + # Boolean to check if the file is empty + # Mypy complains about st_size because typing is bad in standard library - + # https://github.com/python/mypy/issues/5485 + output_is_not_empty = output.exists() and output.stat().st_size != 0 # type: ignore[misc] + logger.debug(f"output: {output} - overwrite: {overwrite}") + + # Check overwrite when file is not empty + if not overwrite and output_is_not_empty: + is_tty = stdin.isatty() + logger.debug(f"Overwrite is not set and is a tty {is_tty}") + if is_tty: + # File has content and it is in an interactive TTY --> Prompt user + click.confirm(f"Your destination file '{output}' is not empty, continue?", abort=True) + else: + # File has content and it is not interactive TTY nor overwrite set to True --> execution stop + logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)") sys.exit(ExitCode.USAGE_ERROR) output.parent.mkdir(parents=True, exist_ok=True) diff --git a/anta/cli/utils.py b/anta/cli/utils.py index d711ac011..9ef81757d 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -10,6 +10,7 @@ import enum import logging +import os from pathlib import Path from typing import TYPE_CHECKING, Any @@ -97,7 +98,7 @@ def parse_catalog(ctx: click.Context, param: Option, value: Path) -> AntaCatalog return catalog -def maybe_required_cb(ctx: click.Context, param: Option, value: str) -> Any: +def maybe_required_username_cb(ctx: click.Context, param: Option, value: str) -> Any: """ Replace the "required" true with a callback to handle our specificies @@ -105,18 +106,46 @@ def maybe_required_cb(ctx: click.Context, param: Option, value: str) -> Any: """ if ctx.obj.get("_anta_help"): # If help then don't do anything - return + return value if "get" in ctx.obj["args"]: # the group has put the args from cli in the ctx.obj # This is a bit convoluted ctx.obj["skip_password"] = True + return value + if param.value_is_missing(value): + raise click.exceptions.MissingParameter(ctx=ctx, param=param) + return value + + +def maybe_required_inventory_cb(ctx: click.Context, param: Option, value: str) -> Any: + """ + Replace the "required" true with a callback to handle our specificies + + TODO: evaluate if moving the options to the groups is not better than this .. + """ + if ctx.obj.get("_anta_help"): + # If help then don't do anything + return value + if "get" in ctx.obj["args"]: + # the group has put the args from cli in the ctx.obj + # This is a bit convoluted if "from-cvp" in ctx.obj["args"] or "from-ansible" in ctx.obj["args"]: ctx.obj["skip_inventory"] = True - elif param.name == "inventory" and param.value_is_missing(value): - raise click.exceptions.MissingParameter(ctx=ctx, param=param) - return + return value if param.value_is_missing(value): raise click.exceptions.MissingParameter(ctx=ctx, param=param) + # Need to check that the inventory file exist + # TODO, makes this better + try: + os.stat(value) + return value + except OSError as exc: + # We want the file to exist + raise click.exceptions.BadParameter( + f"{param.name} {click.utils.format_filename(value)!r} does not exist.", + ctx, + param, + ) from exc def exit_with_code(ctx: click.Context) -> None: @@ -187,38 +216,6 @@ class IgnoreRequiredWithHelp(AliasedGroup): This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734 """ - 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) - if "--help" in args: - ctx.obj["_anta_help"] = True - - try: - return super().parse_args(ctx, args) - except click.MissingParameter: - if "--help" not in args: - raise - - # remove the required params so that help can display - for param in self.params: - param.required = False - - return super().parse_args(ctx, args) - - -class IgnoreRequiredForMainCommand(IgnoreRequiredWithHelp): - """ - Custom ANTA ignore knob for required arguments: - * Allow --help without required options on subcommand - * Allow relaxing required arguments for `anta get from-cvp` and `anta get from-ansible` - - This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734 - """ - def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: """ Ignore MissingParameter exception when parsing arguments if `--help` diff --git a/tests/data/empty b/tests/data/empty new file mode 100644 index 000000000..e69de29bb diff --git a/tests/data/expected_anta_inventory.yml b/tests/data/expected_anta_inventory.yml new file mode 100644 index 000000000..c0f92cb81 --- /dev/null +++ b/tests/data/expected_anta_inventory.yml @@ -0,0 +1,16 @@ +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/data/toto.yml b/tests/data/toto.yml new file mode 100644 index 000000000..c0f92cb81 --- /dev/null +++ b/tests/data/toto.yml @@ -0,0 +1,16 @@ +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/units/cli/get/test_commands.py b/tests/units/cli/get/test_commands.py index 8bd5b58f7..bce89fe21 100644 --- a/tests/units/cli/get/test_commands.py +++ b/tests/units/cli/get/test_commands.py @@ -6,7 +6,7 @@ """ from __future__ import annotations -import os +import filecmp import shutil from pathlib import Path from typing import TYPE_CHECKING, cast @@ -18,13 +18,13 @@ from anta.cli import anta from anta.cli.get.commands import from_cvp -from tests.lib.utils import default_anta_env if TYPE_CHECKING: from click.testing import CliRunner from pytest import CaptureFixture, LogCaptureFixture DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data" +INIT_ANTA_INVENTORY = DATA_DIR / "test_inventory.yml" # Not testing for required parameter, click does this well. @@ -48,8 +48,9 @@ def test_from_cvp( ) -> None: """ Test `anta get from-cvp` + + This test verifies that username and password are NOT mandatory to run this command """ - env = default_anta_env() cli_args = ["get", "from-cvp", "--cvp-ip", "42.42.42.42", "--cvp-username", "anta", "--cvp-password", "anta"] if inventory_directory is not None: @@ -79,7 +80,7 @@ def mock_cvp_connect(self: CvpClient, *args: str, **kwargs: str) -> None: ) as mocked_get_devices_in_container: # https://github.com/pallets/click/issues/824#issuecomment-1583293065 with capsys.disabled(): - result = click_runner.invoke(anta, cli_args, env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, cli_args, auto_envvar_prefix="ANTA") if not cvp_connect_failure: assert out_file.exists() @@ -101,12 +102,12 @@ def mock_cvp_connect(self: CvpClient, *args: str, **kwargs: str) -> None: @pytest.mark.parametrize( - "ansible_inventory, ansible_group, output, expected_exit", + "ansible_inventory, ansible_group, expected_exit, expected_log", [ - pytest.param("ansible_inventory.yml", None, None, 0, id="no group"), - pytest.param("ansible_inventory.yml", "ATD_LEAFS", None, 0, id="group found"), - pytest.param("ansible_inventory.yml", "DUMMY", None, 4, id="group not found"), - pytest.param("empty_ansible_inventory.yml", None, None, 4, id="empty inventory"), + pytest.param("ansible_inventory.yml", None, 0, None, id="no group"), + pytest.param("ansible_inventory.yml", "ATD_LEAFS", 0, None, id="group found"), + pytest.param("ansible_inventory.yml", "DUMMY", 4, "Group DUMMY not found in Ansible inventory", id="group not found"), + pytest.param("empty_ansible_inventory.yml", None, 4, "is empty", id="empty inventory"), ], ) # pylint: disable-next=too-many-arguments @@ -117,109 +118,182 @@ def test_from_ansible( click_runner: CliRunner, ansible_inventory: Path, ansible_group: str | None, - output: Path | None, expected_exit: int, + expected_log: str | None, ) -> None: """ Test `anta get from-ansible` - """ - env = default_anta_env() - cli_args = ["get", "from-ansible"] - os.chdir(tmp_path) - if output is not None: - cli_args.extend(["--output", str(output)]) - out_dir = Path() / output - else: - # Get inventory-directory default - default_dir: Path = cast(Path, f"{tmp_path}/output.yml") - out_dir = Path() / default_dir + This test verifies: + * the parsing of an ansible-inventory + * the ansible_group functionaliy - cli_args.extend(["--output", str(out_dir)]) + The output path is ALWAYS set to a non existing file. + """ + # Create a default directory + out_inventory: Path = tmp_path / " output.yml" + # Set --ansible-inventory + ansible_inventory_path = DATA_DIR / ansible_inventory - if ansible_inventory is not None: - ansible_inventory_path = DATA_DIR / ansible_inventory - cli_args.extend(["--ansible-inventory", str(ansible_inventory_path)]) + # Init cli_args + cli_args = ["get", "from-ansible", "--output", str(out_inventory), "--ansible-inventory", str(ansible_inventory_path)] + # Set --ansible-group if ansible_group is not None: cli_args.extend(["--ansible-group", ansible_group]) with capsys.disabled(): - print(cli_args) - result = click_runner.invoke(anta, cli_args, env=env, auto_envvar_prefix="ANTA") - - print(f"Runner args: {cli_args}") - print(f"Runner result is: {result}") - print(caplog.records) + result = click_runner.invoke(anta, cli_args) assert result.exit_code == expected_exit + if expected_exit != 0: + print(caplog.records) + assert expected_log in [rec.message for rec in caplog.records][-1] assert len(caplog.records) in {2, 3} else: - assert out_dir.exists() + assert out_inventory.exists() + # TODO check size of generated inventory to validate the group functionality! @pytest.mark.parametrize( - "ansible_inventory, ansible_group, output_option, expected_exit", + "set_output, set_anta_inventory, expected_target, expected_exit, expected_log", [ - pytest.param("ansible_inventory.yml", None, None, 4, id="no group-no-overwrite"), - pytest.param("ansible_inventory.yml", None, "--overwrite", 0, id="no group-overwrite"), - pytest.param("ansible_inventory.yml", "ATD_LEAFS", "--overwrite", 0, id="group found"), - pytest.param("ansible_inventory.yml", "DUMMY", "--overwrite", 4, id="group not found"), - pytest.param("empty_ansible_inventory.yml", None, None, 4, id="empty inventory"), + pytest.param(True, False, "output.yml", 0, None, id="output-only"), + pytest.param(True, True, "output.yml", 0, None, id="output-and-inventory"), + pytest.param(False, True, "inventory.yml", 0, None, id="inventory-only"), + pytest.param( + False, + False, + None, + 4, + "Inventory output is not set. Either `anta --inventory` or `anta get from-ansible --output` MUST be set.", + id="no-output-no-inventory", + ), ], ) # pylint: disable-next=too-many-arguments -def test_from_ansible_default_inventory( +def test_from_ansible_output( tmp_path: Path, caplog: LogCaptureFixture, capsys: CaptureFixture[str], click_runner: CliRunner, - ansible_inventory: Path, - ansible_group: str | None, - output_option: str | None, + set_output: bool, + set_anta_inventory: bool, + expected_target: str, expected_exit: int, + expected_log: str | None, ) -> None: """ - Test `anta get from-ansible` + This test verifies the precedence of target inventory file for `anta get from-ansible`: + 1. output + 2. ANTA_INVENTORY or `anta --inventory ` if `output` is not set + 3. Raise otherwise + + This test DOES NOT handle overwriting behavior so assuming EMPTY target for now """ + # The targeted ansible_inventory is static + ansible_inventory_path = DATA_DIR / "ansible_inventory.yml" + cli_args = ["get", "from-ansible", "--ansible-inventory", str(ansible_inventory_path)] - def custom_anta_env(tmp_path: Path) -> dict[str, str]: - """ - Return a default_anta_environement which can be passed to a cliRunner.invoke method - """ - return { - "ANTA_USERNAME": "anta", - "ANTA_PASSWORD": "formica", - "ANTA_INVENTORY": str(tmp_path / "test_inventory02.yml"), - "ANTA_CATALOG": str(Path(__file__).parent.parent / "data" / "test_catalog.yml"), - } + if set_anta_inventory: + tmp_inv = tmp_path / "inventory.yml" + # preprend + cli_args = ["-i", str(tmp_inv)] + cli_args - env = custom_anta_env(tmp_path) - shutil.copyfile(str(Path(__file__).parent.parent.parent.parent / "data" / "test_inventory.yml"), env["ANTA_INVENTORY"]) + if set_output: + tmp_inv = tmp_path / "output.yml" + cli_args.extend(["--output", str(tmp_inv)]) - cli_args = ["get", "from-ansible"] + with capsys.disabled(): + result = click_runner.invoke(anta, cli_args, auto_envvar_prefix="ANTA") - os.chdir(tmp_path) - if output_option is not None: - cli_args.extend([str(output_option)]) + assert result.exit_code == expected_exit + if expected_exit != 0: + assert expected_log in [rec.message for rec in caplog.records] + else: + expected_inv = tmp_path / expected_target + assert expected_inv.exists() - if ansible_inventory is not None: - ansible_inventory_path = DATA_DIR / ansible_inventory - cli_args.extend(["--ansible-inventory", str(ansible_inventory_path)]) - if ansible_group is not None: - cli_args.extend(["--ansible-group", ansible_group]) +@pytest.mark.parametrize( + "overwrite, is_tty, init_anta_inventory, prompt, expected_exit, expected_log", + [ + pytest.param(False, True, INIT_ANTA_INVENTORY, "y", 0, "", id="no-overwrite-tty-init-prompt-yes"), + pytest.param(False, True, INIT_ANTA_INVENTORY, "N", 1, "Aborted", id="no-overwrite-tty-init-prompt-no"), + pytest.param( + False, + False, + INIT_ANTA_INVENTORY, + None, + 4, + "Conversion aborted since destination file is not empty (not running in interactive TTY)", + id="no-overwrite-no-tty-init", + ), + pytest.param(False, True, None, None, 0, "", id="no-overwrite-tty-no-init"), + pytest.param(False, False, None, None, 0, "", id="no-overwrite-no-tty-no-init"), + pytest.param(True, True, INIT_ANTA_INVENTORY, None, 0, "", id="overwrite-tty-init"), + pytest.param(True, False, INIT_ANTA_INVENTORY, None, 0, "", id="overwrite-no-tty-init"), + pytest.param(True, True, None, None, 0, "", id="overwrite-tty-no-init"), + pytest.param(True, False, None, None, 0, "", id="overwrite-no-tty-no-init"), + ], +) +# pylint: disable-next=too-many-arguments +def test_from_ansible_overwrite( + tmp_path: Path, + caplog: LogCaptureFixture, + capsys: CaptureFixture[str], + click_runner: CliRunner, + overwrite: bool, + is_tty: bool, + prompt: str | None, + init_anta_inventory: Path, + expected_exit: int, + expected_log: str | None, +) -> None: + """ + Test `anta get from-ansible` overwrite mechanism - with capsys.disabled(): - print(cli_args) - result = click_runner.invoke(anta, cli_args, env=env, auto_envvar_prefix="ANTA") + The test uses a static ansible-inventory and output as these are tested in other functions + + This test verifies: + * that overwrite is working as expected with or without init data in the target file + * that when the target file is not empty and a tty is present, the user is prompt with confirmation + * Check the behavior when the prompt is filled + + The initial content of the ANTA inventory is set using init_anta_inventory, if it is None, no inventory is set. + + * With overwrite True, the expectation is that the from-ansible command succeeds + * With no init (init_anta_inventory == None), the expectation is also that command succeeds + """ + # The targeted ansible_inventory is static + ansible_inventory_path = DATA_DIR / "ansible_inventory.yml" + expected_anta_inventory_path = DATA_DIR / "expected_anta_inventory.yml" + tmp_inv = tmp_path / "output.yml" + cli_args = ["get", "from-ansible", "--ansible-inventory", str(ansible_inventory_path), "--output", str(tmp_inv)] - print(f"Runner args: {cli_args}") - print(f"Runner result is: {result}") + if overwrite: + cli_args.append("--overwrite") + + if init_anta_inventory: + shutil.copyfile(init_anta_inventory, tmp_inv) + + print(cli_args) + # Verify initial content is different + if tmp_inv.exists(): + assert not filecmp.cmp(tmp_inv, expected_anta_inventory_path) + + with capsys.disabled(): + # TODO, handle is_tty + with patch("anta.cli.get.commands.stdin") as patched_stdin: + patched_stdin.isatty.return_value = is_tty + result = click_runner.invoke(anta, cli_args, auto_envvar_prefix="ANTA", input=prompt) assert result.exit_code == expected_exit - print(caplog.records) - if expected_exit != 0: - assert len(caplog.records) in {1, 2, 3} - # Path(env["ANTA_INVENTORY"]).unlink(missing_ok=True) + if expected_exit == 0: + assert filecmp.cmp(tmp_inv, expected_anta_inventory_path) + elif expected_exit == 1: + assert expected_log + assert expected_log in result.stdout + elif expected_exit == 4: + assert expected_log in [rec.message for rec in caplog.records] From 621c8ddd07be34e4b662dd4a2b6b89683b8040df Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 24 Nov 2023 15:49:57 +0100 Subject: [PATCH 04/53] Test: Fix now broken test --- tests/units/cli/test__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/units/cli/test__init__.py b/tests/units/cli/test__init__.py index 50c2992cf..e4c9a6c4b 100644 --- a/tests/units/cli/test__init__.py +++ b/tests/units/cli/test__init__.py @@ -86,7 +86,7 @@ def test_anta_password_required(click_runner: CliRunner) -> None: """ env = default_anta_env() env.pop("ANTA_PASSWORD") - result = click_runner.invoke(anta, ["get", "inventory"], env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu", "table"], env=env, auto_envvar_prefix="ANTA") assert result.exit_code == 2 assert "EOS password needs to be provided by using either the '--password' option or the '--prompt' option." in result.output From c01424859b149ad1e78ef1002261ad4e6b4edd61 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 24 Nov 2023 15:53:03 +0100 Subject: [PATCH 05/53] CI: Mypy fun --- anta/cli/get/commands.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index 4269a0e98..2f222fcb3 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -113,9 +113,7 @@ def from_ansible(ctx: click.Context, output: Path, ansible_inventory: Path, ansi sys.exit(ExitCode.USAGE_ERROR) # Boolean to check if the file is empty - # Mypy complains about st_size because typing is bad in standard library - - # https://github.com/python/mypy/issues/5485 - output_is_not_empty = output.exists() and output.stat().st_size != 0 # type: ignore[misc] + output_is_not_empty = output.exists() and output.stat().st_size != 0 logger.debug(f"output: {output} - overwrite: {overwrite}") # Check overwrite when file is not empty From 9c85a73d5c022903d0b80177ebee14ed065dae13 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 24 Nov 2023 15:54:56 +0100 Subject: [PATCH 06/53] CI: Update pre-commit --- .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 a10c6b2b7..cbc805b80 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -82,7 +82,7 @@ repos: # - id: ruff - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.6.1 + rev: v1.7.1 hooks: - id: mypy args: From c807c7d2bdefee979e95bb271c3046091b3b4b93 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 24 Nov 2023 16:16:27 +0100 Subject: [PATCH 07/53] CI: Welcome typing my old friend --- tests/units/cli/get/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/units/cli/get/test_commands.py b/tests/units/cli/get/test_commands.py index bce89fe21..197e179bf 100644 --- a/tests/units/cli/get/test_commands.py +++ b/tests/units/cli/get/test_commands.py @@ -148,7 +148,7 @@ def test_from_ansible( assert result.exit_code == expected_exit if expected_exit != 0: - print(caplog.records) + assert expected_log assert expected_log in [rec.message for rec in caplog.records][-1] assert len(caplog.records) in {2, 3} else: From 50361a0af985df37f065f08542da87b74bfff7c4 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 24 Nov 2023 17:59:14 +0100 Subject: [PATCH 08/53] WIP * fix `tags` for nrfu foo commands * fix CVP * fix tests --- anta/cli/__init__.py | 110 ++++--------------------------- anta/cli/check/commands.py | 13 +--- anta/cli/debug/commands.py | 7 +- anta/cli/exec/commands.py | 11 ++-- anta/cli/get/commands.py | 91 +++++++++++++++---------- anta/cli/get/utils.py | 5 +- anta/cli/nrfu/commands.py | 20 ++---- anta/cli/utils.py | 132 ++++++++++++++++++++++++++++++++++--- 8 files changed, 211 insertions(+), 178 deletions(-) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index 28828c472..b88f37c12 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -10,10 +10,10 @@ import logging import pathlib -from typing import Any, Literal +from typing import Any +from typing import Literal import click - from anta import __version__ from anta.catalog import AntaCatalog from anta.cli.check import commands as check_commands @@ -21,7 +21,10 @@ from anta.cli.exec import commands as exec_commands from anta.cli.get import commands as get_commands from anta.cli.nrfu import commands as nrfu_commands -from anta.cli.utils import AliasedGroup, IgnoreRequiredWithHelp, maybe_required_inventory_cb, maybe_required_username_cb, parse_catalog, parse_inventory +from anta.cli.utils import AliasedGroup +from anta.cli.utils import catalog_options +from anta.cli.utils import IgnoreRequiredWithHelp +from anta.cli.utils import inventory_options from anta.logger import setup_logging from anta.result_manager import ResultManager @@ -29,59 +32,6 @@ @click.group(cls=IgnoreRequiredWithHelp) @click.pass_context @click.version_option(__version__) -@click.option( - "--username", - help="Username to connect to EOS", - show_envvar=True, - callback=maybe_required_username_cb, - # required=True, -) -@click.option("--password", help="Password to connect to EOS that must be provided. It can be prompted using '--prompt' option.", show_envvar=True) -@click.option( - "--enable-password", - help="Password to access EOS Privileged EXEC mode. It can be prompted using '--prompt' option. Requires '--enable' option.", - show_envvar=True, -) -@click.option( - "--enable", - help="Some commands may require EOS Privileged EXEC mode. This option tries to access this mode before sending a command to the device.", - default=False, - show_envvar=True, - is_flag=True, - show_default=True, -) -@click.option( - "--prompt", - "-P", - help="Prompt for passwords if they are not provided.", - default=False, - is_flag=True, - show_default=True, -) -@click.option( - "--timeout", - help="Global connection timeout", - default=30, - show_envvar=True, - show_default=True, -) -@click.option( - "--insecure", - help="Disable SSH Host Key validation", - default=False, - show_envvar=True, - is_flag=True, - show_default=True, -) -@click.option( - "--inventory", - "-i", - help="Path to the inventory YAML file", - show_envvar=True, - callback=maybe_required_inventory_cb, - # required=True, - type=click.Path(file_okay=True, dir_okay=False, readable=True, path_type=pathlib.Path), -) @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.", @@ -106,53 +56,21 @@ case_sensitive=False, ), ) -@click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False) -@click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) -@click.option("--disable-cache", help="Disable cache globally", show_envvar=True, show_default=True, is_flag=True, default=False) -def anta( - ctx: click.Context, inventory: pathlib.Path, log_level: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], log_file: pathlib.Path, **kwargs: Any -) -> None: +# TODO add custom_type for log level +def anta(ctx: click.Context, log_level: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], log_file: pathlib.Path, **kwargs: Any) -> None: # pylint: disable=unused-argument """Arista Network Test Automation (ANTA) CLI""" setup_logging(log_level, log_file) - if not ctx.obj.get("_anta_help"): - if ctx.params.get("prompt"): - # User asked for a password prompt - if ctx.params.get("password") is None: - ctx.params["password"] = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True) - if ctx.params.get("enable"): - if ctx.params.get("enable_password") is None: - if click.confirm("Is a password required to enter EOS privileged EXEC mode?"): - ctx.params["enable_password"] = click.prompt( - "Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True - ) - if ctx.params.get("password") is None and not ctx.obj.get("skip_password"): - raise click.BadParameter( - f"EOS password needs to be provided by using either the '{anta.params[2].opts[0]}' option or the '{anta.params[5].opts[0]}' option." - ) - if not ctx.params.get("enable") and ctx.params.get("enable_password"): - raise click.BadParameter(f"Providing a password to access EOS Privileged EXEC mode requires '{anta.params[4].opts[0]}' option.") - - ctx.ensure_object(dict) - ctx.obj["inventory_path"] = ctx.params.get("inventory") - if not ctx.obj.get("skip_inventory"): - ctx.obj["inventory"] = parse_inventory(ctx, inventory) - @anta.group("nrfu", cls=IgnoreRequiredWithHelp) @click.pass_context -@click.option( - "--catalog", - "-c", - envvar="ANTA_CATALOG", - show_envvar=True, - help="Path to the test catalog YAML file", - type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True), - required=True, - callback=parse_catalog, -) -def _nrfu(ctx: click.Context, catalog: AntaCatalog) -> None: +@inventory_options +@catalog_options +@click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False) +@click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) +@click.option("--disable-cache", help="Disable cache globally", show_envvar=True, show_default=True, is_flag=True, default=False) +def _nrfu(ctx: click.Context, catalog: AntaCatalog, **kwargs) -> None: """Run NRFU against inventory devices""" ctx.obj["catalog"] = catalog ctx.obj["result_manager"] = ResultManager() diff --git a/anta/cli/check/commands.py b/anta/cli/check/commands.py index 6ba521b4e..90bd72098 100644 --- a/anta/cli/check/commands.py +++ b/anta/cli/check/commands.py @@ -14,22 +14,13 @@ from anta.catalog import AntaCatalog from anta.cli.console import console -from anta.cli.utils import parse_catalog +from anta.cli.utils import catalog_options logger = logging.getLogger(__name__) @click.command() -@click.option( - "--catalog", - "-c", - envvar="ANTA_CATALOG", - show_envvar=True, - help="Path to the test catalog YAML file", - type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, resolve_path=True), - required=True, - callback=parse_catalog, -) +@catalog_options def catalog(catalog: AntaCatalog) -> None: """ Check that the catalog is valid diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py index 8386f8342..7e9cc41e6 100644 --- a/anta/cli/debug/commands.py +++ b/anta/cli/debug/commands.py @@ -16,6 +16,7 @@ from click import Option from anta.cli.console import console +from anta.cli.utils import inventory_options from anta.device import AntaDevice from anta.models import AntaCommand, AntaTemplate from anta.tools.misc import anta_log_exception @@ -37,7 +38,8 @@ def get_device(ctx: click.Context, param: Option, value: str) -> list[str]: ctx.fail(message) -@click.command() +@click.command(no_args_is_help=True) +@inventory_options @click.option("--command", "-c", type=str, required=True, help="Command to run") @click.option("--ofmt", type=click.Choice(["json", "text"]), default="json", help="EOS eAPI format to use. can be text or json") @click.option("--version", "-v", type=click.Choice(["1", "latest"]), default="latest", help="EOS eAPI version") @@ -59,7 +61,8 @@ def run_cmd(command: str, ofmt: Literal["json", "text"], version: Literal["1", " console.print(c.text_output) -@click.command() +@click.command(no_args_is_help=True) +@inventory_options @click.option("--template", "-t", type=str, required=True, help="Command template to run. E.g. 'show vlan {vlan_id}'") @click.option("--ofmt", type=click.Choice(["json", "text"]), default="json", help="EOS eAPI format to use. can be text or json") @click.option("--version", "-v", type=click.Choice(["1", "latest"]), default="latest", help="EOS eAPI version") diff --git a/anta/cli/exec/commands.py b/anta/cli/exec/commands.py index 765aa5906..f5e8a9571 100644 --- a/anta/cli/exec/commands.py +++ b/anta/cli/exec/commands.py @@ -1,7 +1,6 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. - """ Commands for Anta CLI to execute EOS commands. """ @@ -17,14 +16,14 @@ from yaml import safe_load from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech -from anta.cli.utils import parse_tags +from anta.cli.utils import inventory_options logger = logging.getLogger(__name__) -@click.command() +@click.command(no_args_is_help=True) @click.pass_context -@click.option("--tags", "-t", help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags) +@inventory_options def clear_counters(ctx: click.Context, tags: list[str] | None) -> None: """Clear counter statistics on EOS devices""" asyncio.run(clear_counters_utils(ctx.obj["inventory"], tags=tags)) @@ -32,7 +31,7 @@ def clear_counters(ctx: click.Context, tags: list[str] | None) -> None: @click.command() @click.pass_context -@click.option("--tags", "-t", help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags) +@inventory_options @click.option( "--commands-list", "-c", @@ -66,6 +65,7 @@ def snapshot(ctx: click.Context, tags: list[str] | None, commands_list: Path, ou @click.command() @click.pass_context +@inventory_options @click.option("--output", "-o", default="./tech-support", show_default=True, help="Path for test catalog", type=click.Path(path_type=Path), required=False) @click.option("--latest", help="Number of scheduled show-tech to retrieve", type=int, required=False) @click.option( @@ -75,7 +75,6 @@ def snapshot(ctx: click.Context, tags: list[str] | None, commands_list: Path, ou is_flag=True, show_default=True, ) -@click.option("--tags", "-t", help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags) def collect_tech_support(ctx: click.Context, tags: list[str] | None, output: Path, latest: int | None, configure: bool) -> None: """Collect scheduled tech-support from EOS devices""" asyncio.run(collect_scheduled_show_tech(ctx.obj["inventory"], output, configure, tags=tags, latest=latest)) diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index 2f222fcb3..382d20be0 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -10,7 +10,6 @@ import asyncio import json import logging -import os import sys from pathlib import Path from sys import stdin @@ -22,7 +21,7 @@ from rich.pretty import pretty_repr from anta.cli.console import console -from anta.cli.utils import ExitCode, parse_tags +from anta.cli.utils import ExitCode, inventory_options from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token @@ -30,28 +29,43 @@ @click.command(no_args_is_help=True) +@click.pass_context @click.option("--cvp-ip", "-ip", default=None, help="CVP IP Address", type=str, required=True) @click.option("--cvp-username", "-u", default=None, help="CVP Username", type=str, required=True) @click.option("--cvp-password", "-p", default=None, help="CVP Password / token", type=str, required=True) @click.option("--cvp-container", "-c", default=None, help="Container where devices are configured", type=str, required=False) -@click.option("--inventory-directory", "-d", default="anta_inventory", help="Path to save inventory file", type=click.Path()) -def from_cvp(inventory_directory: str, cvp_ip: str, cvp_username: str, cvp_password: str, cvp_container: str) -> None: +@click.option( + "--output", + "-o", + required=True, + envvar="ANTA_INVENTORY", + show_envvar=True, + help="Path to save inventory file. If not configured, use anta inventory file", + type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=Path), +) +@click.option( + "--overwrite", + help="Confirm script can overwrite existing inventory file", + default=False, + is_flag=True, + show_default=True, + required=False, + show_envvar=True, +) +def from_cvp(ctx: click.Context, output: Path, cvp_ip: str, cvp_username: str, cvp_password: str, cvp_container: str, overwrite: bool) -> None: """ Build ANTA inventory from Cloudvision TODO - handle get_inventory and get_devices_in_container failure """ + # It is CLI we don't care + # pylint: disable=too-many-arguments + _handle_overwrite(ctx, output, overwrite) + # pylint: disable=too-many-arguments logger.info(f"Getting auth token from {cvp_ip} for user {cvp_username}") token = get_cv_token(cvp_ip=cvp_ip, cvp_username=cvp_username, cvp_password=cvp_password) - # Create output directory - cwd = os.getcwd() - out_dir = os.path.dirname(f"{cwd}/{inventory_directory}/") - if not os.path.exists(out_dir): - logger.info(f"Creating inventory folder {out_dir}") - os.makedirs(out_dir) - clnt = CvpClient() print(clnt) try: @@ -70,7 +84,7 @@ def from_cvp(inventory_directory: str, cvp_ip: str, cvp_username: str, cvp_passw # Get devices under a container logger.info(f"Getting inventory for container {cvp_container} from {cvp_ip}") cvp_inventory = clnt.api.get_devices_in_container(cvp_container) - create_inventory_from_cvp(cvp_inventory, out_dir, cvp_container) + create_inventory_from_cvp(cvp_inventory, output, cvp_container) @click.command(no_args_is_help=True) @@ -85,7 +99,9 @@ def from_cvp(inventory_directory: str, cvp_ip: str, cvp_username: str, cvp_passw @click.option( "--output", "-o", - required=False, + required=True, + envvar="ANTA_INVENTORY", + show_envvar=True, help="Path to save inventory file. If not configured, use anta inventory file", type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=Path), ) @@ -103,15 +119,30 @@ def from_ansible(ctx: click.Context, output: Path, ansible_inventory: Path, ansi """Build ANTA inventory from an ansible inventory YAML file""" logger.info(f"Building inventory from ansible file {ansible_inventory}") - # Verify output directory - # 1. Either ouptut is set and it is used - # 2. Else the inventory_path is retrieved from the context (ANTA_INVENTORY or anta --inventoyr value) - # 3. If None is set, error out. - output = output or ctx.obj.get("inventory_path") - if output is None: - logger.error("Inventory output is not set. Either `anta --inventory` or `anta get from-ansible --output` MUST be set.") - sys.exit(ExitCode.USAGE_ERROR) + _handle_overwrite(ctx, output, overwrite) + + output.parent.mkdir(parents=True, exist_ok=True) + logger.info(f"output anta inventory is: {output}") + try: + create_inventory_from_ansible( + inventory=ansible_inventory, + output_file=output, + ansible_group=ansible_group, + ) + except ValueError as e: + logger.error(str(e)) + ctx.exit(ExitCode.USAGE_ERROR) + ctx.exit(ExitCode.OK) + +def _handle_overwrite(ctx: click.Context, output: Path, overwrite: bool) -> None: + """ + When writing to a file, verify that it is empty, if not: + * if a tty is present, prompt user for confirmation + * else fail miserably + + If prompted and the answer is No, using click Abort mechanism + """ # Boolean to check if the file is empty output_is_not_empty = output.exists() and output.stat().st_size != 0 logger.debug(f"output: {output} - overwrite: {overwrite}") @@ -126,25 +157,12 @@ def from_ansible(ctx: click.Context, output: Path, ansible_inventory: Path, ansi else: # File has content and it is not interactive TTY nor overwrite set to True --> execution stop logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)") - sys.exit(ExitCode.USAGE_ERROR) - - output.parent.mkdir(parents=True, exist_ok=True) - logger.info(f"output anta inventory is: {output}") - try: - create_inventory_from_ansible( - inventory=ansible_inventory, - output_file=output, - ansible_group=ansible_group, - ) - except ValueError as e: - logger.error(str(e)) - ctx.exit(ExitCode.USAGE_ERROR) - ctx.exit(ExitCode.OK) + ctx.exit(ExitCode.USAGE_ERROR) @click.command() @click.pass_context -@click.option("--tags", "-t", help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags) +@inventory_options @click.option("--connected/--not-connected", help="Display inventory after connection has been created", default=False, required=False) def inventory(ctx: click.Context, tags: Optional[list[str]], connected: bool) -> None: """Show inventory loaded in ANTA.""" @@ -160,6 +178,7 @@ def inventory(ctx: click.Context, tags: Optional[list[str]], connected: bool) -> @click.command() +@inventory_options @click.pass_context def tags(ctx: click.Context) -> None: """Get list of configured tags in user inventory.""" diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py index d1900b057..e4e028130 100644 --- a/anta/cli/get/utils.py +++ b/anta/cli/get/utils.py @@ -36,7 +36,7 @@ def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str: return response.json()["sessionId"] -def create_inventory_from_cvp(inv: list[dict[str, Any]], directory: str, container: str | None = None) -> None: +def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path, container: str | None = None) -> None: """ create an inventory file from Arista CloudVision """ @@ -46,8 +46,9 @@ def create_inventory_from_cvp(inv: list[dict[str, Any]], directory: str, contain logger.info(f' * adding entry for {dev["hostname"]}') i[AntaInventory.INVENTORY_ROOT_KEY]["hosts"].append({"host": dev["ipAddress"], "name": dev["hostname"], "tags": [dev["containerName"].lower()]}) # write the devices IP address in a file + # TODO fixme inv_file = "inventory" if container is None else f"inventory-{container}" - out_file = f"{directory}/{inv_file}.yml" + out_file = f"{output}/{inv_file}.yml" with open(out_file, "w", encoding="UTF-8") as out_fd: out_fd.write(yaml.dump(i)) logger.info(f"Inventory file has been created in {out_file}") diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index 6b32ba0a4..2a155344a 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -12,7 +12,7 @@ import click -from anta.cli.utils import exit_with_code, parse_tags +from anta.cli.utils import exit_with_code from anta.models import AntaTest from anta.runner import main @@ -23,13 +23,12 @@ @click.command() @click.pass_context -@click.option("--tags", default=None, help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags, show_default=True) @click.option("--device", "-d", help="Show a summary for this device", type=str, required=False) @click.option("--test", "-t", help="Show a summary for this test", type=str, required=False) @click.option( "--group-by", default=None, type=click.Choice(["device", "test"], case_sensitive=False), help="Group result by test or host. default none", required=False ) -def table(ctx: click.Context, tags: list[str], device: str | None, test: str | None, group_by: str) -> None: +def table(ctx: click.Context, device: str | None, test: str | None, group_by: str) -> None: """ANTA command to check network states with table result""" print_settings(ctx) with anta_progress_bar() as AntaTest.progress: @@ -40,9 +39,6 @@ def table(ctx: click.Context, tags: list[str], device: str | None, test: str | N @click.command() @click.pass_context -@click.option( - "--tags", "-t", default=None, help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags, show_default=True -) @click.option( "--output", "-o", @@ -51,7 +47,7 @@ def table(ctx: click.Context, tags: list[str], device: str | None, test: str | N required=False, help="Path to save report as a file", ) -def json(ctx: click.Context, tags: list[str], output: pathlib.Path | None) -> None: +def json(ctx: click.Context, output: pathlib.Path | None) -> None: """ANTA command to check network state with JSON result""" print_settings(ctx) with anta_progress_bar() as AntaTest.progress: @@ -62,12 +58,9 @@ def json(ctx: click.Context, tags: list[str], output: pathlib.Path | None) -> No @click.command() @click.pass_context -@click.option( - "--tags", "-t", default=None, help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags, show_default=True -) @click.option("--search", "-s", help="Regular expression to search in both name and test", type=str, required=False) @click.option("--skip-error", help="Hide tests in errors due to connectivity issue", default=False, is_flag=True, show_default=True, required=False) -def text(ctx: click.Context, tags: list[str], search: str | None, skip_error: bool) -> None: +def text(ctx: click.Context, search: str | None, skip_error: bool) -> None: """ANTA command to check network states with text result""" print_settings(ctx) with anta_progress_bar() as AntaTest.progress: @@ -94,10 +87,7 @@ def text(ctx: click.Context, tags: list[str], search: str | None, skip_error: bo required=False, help="Path to save report as a file", ) -@click.option( - "--tags", "-t", default=None, help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags, show_default=True -) -def tpl_report(ctx: click.Context, tags: list[str], template: pathlib.Path, output: pathlib.Path | None) -> None: +def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None: """ANTA command to check network state with templated report""" print_settings(ctx, template, output) with anta_progress_bar() as AntaTest.progress: diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 9ef81757d..9a3bd6ee9 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -9,18 +9,20 @@ from __future__ import annotations import enum +import functools import logging import os from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any +from typing import TYPE_CHECKING import click -from pydantic import ValidationError -from yaml import YAMLError - from anta.catalog import AntaCatalog from anta.inventory import AntaInventory -from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError +from anta.inventory.exceptions import InventoryIncorrectSchema +from anta.inventory.exceptions import InventoryRootKeyError +from pydantic import ValidationError +from yaml import YAMLError logger = logging.getLogger(__name__) @@ -108,10 +110,9 @@ def maybe_required_username_cb(ctx: click.Context, param: Option, value: str) -> # If help then don't do anything return value if "get" in ctx.obj["args"]: - # the group has put the args from cli in the ctx.obj - # This is a bit convoluted - ctx.obj["skip_password"] = True - return value + if "from-cvp" in ctx.obj["args"] or "from-ansible" in ctx.obj["args"]: + ctx.obj["skip_password"] = True + return value if param.value_is_missing(value): raise click.exceptions.MissingParameter(ctx=ctx, param=param) return value @@ -131,7 +132,7 @@ def maybe_required_inventory_cb(ctx: click.Context, param: Option, value: str) - # This is a bit convoluted if "from-cvp" in ctx.obj["args"] or "from-ansible" in ctx.obj["args"]: ctx.obj["skip_inventory"] = True - return value + return value if param.value_is_missing(value): raise click.exceptions.MissingParameter(ctx=ctx, param=param) # Need to check that the inventory file exist @@ -240,3 +241,114 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: param.required = False return super().parse_args(ctx, args) + + +def inventory_options(f): + """ + Click common options when manipulating devices + """ + + @click.option( + "--username", + "-u", + help="Username to connect to EOS", + envvar="ANTA_USERNAME", + show_envvar=True, + required=True, + ) + @click.option( + "--password", + "-p", + help="Password to connect to EOS that must be provided. It can be prompted using '--prompt' option.", + show_envvar=True, + envvar="ANTA_PASSWORD", + ) + @click.option( + "--enable-password", + help="Password to access EOS Privileged EXEC mode. It can be prompted using '--prompt' option. Requires '--enable' option.", + show_envvar=True, + ) + @click.option( + "--enable", + help="Some commands may require EOS Privileged EXEC mode. This option tries to access this mode before sending a command to the device.", + default=False, + show_envvar=True, + is_flag=True, + show_default=True, + ) + @click.option( + "--prompt", + "-P", + help="Prompt for passwords if they are not provided.", + default=False, + is_flag=True, + show_default=True, + ) + @click.option( + "--timeout", + help="Global connection timeout", + default=30, + show_envvar=True, + show_default=True, + ) + @click.option( + "--insecure", + help="Disable SSH Host Key validation", + default=False, + show_envvar=True, + is_flag=True, + show_default=True, + ) + @click.option( + "--inventory", + "-i", + help="Path to the inventory YAML file", + envvar="ANTA_INVENTORY", + show_envvar=True, + required=True, + type=click.Path(file_okay=True, dir_okay=False, readable=True, path_type=Path), + ) + @click.option("--tags", "-t", help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags) + @functools.wraps(f) + def wrapper_common_options(ctx: click.Context, *args, **kwargs): + if not ctx.obj.get("_anta_help"): + if ctx.params.get("prompt"): + # User asked for a password prompt + if ctx.params.get("password") is None: + ctx.params["password"] = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True) + if ctx.params.get("enable"): + if ctx.params.get("enable_password") is None: + if click.confirm("Is a password required to enter EOS privileged EXEC mode?"): + ctx.params["enable_password"] = click.prompt( + "Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True + ) + if ctx.params.get("password") is None: + raise click.BadParameter("EOS password needs to be provided by using either the '--password' option or the '--prompt' option.") + if not ctx.params.get("enable") and ctx.params.get("enable_password"): + raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.") + + ctx.ensure_object(dict) + inventory = ctx.params.get("inventory") + ctx.obj["inventory_path"] = inventory + ctx.obj["inventory"] = parse_inventory(ctx, inventory) + return f(ctx, *args, **kwargs) + + return wrapper_common_options + + +def catalog_options(f): + @click.option( + "--catalog", + "-c", + envvar="ANTA_CATALOG", + show_envvar=True, + help="Path to the test catalog YAML file", + type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True), + required=True, + callback=parse_catalog, + ) + @functools.wraps(f) + def wrapper_common_options(ctx: click.Context, *args, **kwargs): + return f(ctx, *args, **kwargs) + + return wrapper_common_options From b23aee330cd8664143877f0d53ead5ea304e3c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 27 Nov 2023 12:16:28 +0100 Subject: [PATCH 09/53] add custom type for loglevel --- anta/cli/__init__.py | 28 ++++------ anta/cli/nrfu/commands.py | 8 +-- anta/cli/utils.py | 107 ++++++++++---------------------------- anta/logger.py | 17 +++++- 4 files changed, 57 insertions(+), 103 deletions(-) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index b88f37c12..0a891b75b 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -11,9 +11,9 @@ import logging import pathlib from typing import Any -from typing import Literal import click + from anta import __version__ from anta.catalog import AntaCatalog from anta.cli.check import commands as check_commands @@ -21,11 +21,9 @@ from anta.cli.exec import commands as exec_commands from anta.cli.get import commands as get_commands from anta.cli.nrfu import commands as nrfu_commands -from anta.cli.utils import AliasedGroup -from anta.cli.utils import catalog_options -from anta.cli.utils import IgnoreRequiredWithHelp -from anta.cli.utils import inventory_options -from anta.logger import setup_logging +from anta.cli.utils import AliasedGroup, IgnoreRequiredWithHelp, catalog_options, inventory_options +from anta.inventory import AntaInventory +from anta.logger import Log, LogLevel, setup_logging from anta.result_manager import ResultManager @@ -46,20 +44,13 @@ show_envvar=True, show_default=True, type=click.Choice( - [ - logging.getLevelName(logging.CRITICAL), - logging.getLevelName(logging.ERROR), - logging.getLevelName(logging.WARNING), - logging.getLevelName(logging.INFO), - logging.getLevelName(logging.DEBUG), - ], + [Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG], case_sensitive=False, ), ) -# TODO add custom_type for log level -def anta(ctx: click.Context, log_level: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], log_file: pathlib.Path, **kwargs: Any) -> None: - # pylint: disable=unused-argument +def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None: """Arista Network Test Automation (ANTA) CLI""" + ctx.ensure_object(dict) setup_logging(log_level, log_file) @@ -69,10 +60,11 @@ def anta(ctx: click.Context, log_level: Literal["CRITICAL", "ERROR", "WARNING", @catalog_options @click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False) @click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) -@click.option("--disable-cache", help="Disable cache globally", show_envvar=True, show_default=True, is_flag=True, default=False) -def _nrfu(ctx: click.Context, catalog: AntaCatalog, **kwargs) -> None: +def _nrfu(ctx: click.Context, inventory: AntaInventory, catalog: AntaCatalog, **kwargs: dict[str, Any]) -> None: + # pylint: disable=unused-argument """Run NRFU against inventory devices""" ctx.obj["catalog"] = catalog + ctx.obj["inventory"] = inventory ctx.obj["result_manager"] = ResultManager() diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index 2a155344a..4c82bd0cd 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -32,7 +32,7 @@ def table(ctx: click.Context, device: str | None, test: str | None, group_by: st """ANTA command to check network states with table result""" print_settings(ctx) with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=tags)) + asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=ctx.params.get("tags"))) print_table(results=ctx.obj["result_manager"], device=device, group_by=group_by, test=test) exit_with_code(ctx) @@ -51,7 +51,7 @@ def json(ctx: click.Context, output: pathlib.Path | None) -> None: """ANTA command to check network state with JSON result""" print_settings(ctx) with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=tags)) + asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=ctx.params.get("tags"))) print_json(results=ctx.obj["result_manager"], output=output) exit_with_code(ctx) @@ -64,7 +64,7 @@ def text(ctx: click.Context, search: str | None, skip_error: bool) -> None: """ANTA command to check network states with text result""" print_settings(ctx) with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=tags)) + asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=ctx.params.get("tags"))) print_text(results=ctx.obj["result_manager"], search=search, skip_error=skip_error) exit_with_code(ctx) @@ -91,6 +91,6 @@ def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path """ANTA command to check network state with templated report""" print_settings(ctx, template, output) with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=tags)) + asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=ctx.params.get("tags"))) print_jinja(results=ctx.obj["result_manager"], template=template, output=output) exit_with_code(ctx) diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 9a3bd6ee9..afd06ddcf 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -11,19 +11,17 @@ import enum import functools import logging -import os from pathlib import Path -from typing import Any -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import click -from anta.catalog import AntaCatalog -from anta.inventory import AntaInventory -from anta.inventory.exceptions import InventoryIncorrectSchema -from anta.inventory.exceptions import InventoryRootKeyError from pydantic import ValidationError from yaml import YAMLError +from anta.catalog import AntaCatalog +from anta.inventory import AntaInventory +from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError + logger = logging.getLogger(__name__) if TYPE_CHECKING: @@ -48,9 +46,10 @@ class ExitCode(enum.IntEnum): USAGE_ERROR = 4 -def parse_inventory(ctx: click.Context, path: Path) -> AntaInventory: +def parse_inventory(ctx: click.Context, param: Option, value: Path) -> AntaInventory: + # pylint: disable=unused-argument """ - Helper function parse an ANTA inventory YAML file + Click option callback to parse an ANTA inventory YAML file """ if ctx.obj.get("_anta_help"): # Currently looking for help for a subcommand so no @@ -58,7 +57,7 @@ def parse_inventory(ctx: click.Context, path: Path) -> AntaInventory: return AntaInventory() try: inventory = AntaInventory.parse( - filename=str(path), + filename=str(value), username=ctx.params["username"], password=ctx.params["password"], enable=ctx.params["enable"], @@ -72,22 +71,10 @@ def parse_inventory(ctx: click.Context, path: Path) -> AntaInventory: return inventory -def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | None: - # pylint: disable=unused-argument - """ - Click option callback to parse an ANTA inventory tags - """ - if value is not None: - return value.split(",") if "," in value else [value] - return None - - def parse_catalog(ctx: click.Context, param: Option, value: Path) -> AntaCatalog: # pylint: disable=unused-argument """ Click option callback to parse an ANTA test catalog YAML file - - Store the orignal value (catalog path) in the ctx.obj """ if ctx.obj.get("_anta_help"): # Currently looking for help for a subcommand so no @@ -100,53 +87,14 @@ def parse_catalog(ctx: click.Context, param: Option, value: Path) -> AntaCatalog return catalog -def maybe_required_username_cb(ctx: click.Context, param: Option, value: str) -> Any: - """ - Replace the "required" true with a callback to handle our specificies - - TODO: evaluate if moving the options to the groups is not better than this .. - """ - if ctx.obj.get("_anta_help"): - # If help then don't do anything - return value - if "get" in ctx.obj["args"]: - if "from-cvp" in ctx.obj["args"] or "from-ansible" in ctx.obj["args"]: - ctx.obj["skip_password"] = True - return value - if param.value_is_missing(value): - raise click.exceptions.MissingParameter(ctx=ctx, param=param) - return value - - -def maybe_required_inventory_cb(ctx: click.Context, param: Option, value: str) -> Any: +def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | None: + # pylint: disable=unused-argument """ - Replace the "required" true with a callback to handle our specificies - - TODO: evaluate if moving the options to the groups is not better than this .. + Click option callback to parse an ANTA inventory tags """ - if ctx.obj.get("_anta_help"): - # If help then don't do anything - return value - if "get" in ctx.obj["args"]: - # the group has put the args from cli in the ctx.obj - # This is a bit convoluted - if "from-cvp" in ctx.obj["args"] or "from-ansible" in ctx.obj["args"]: - ctx.obj["skip_inventory"] = True - return value - if param.value_is_missing(value): - raise click.exceptions.MissingParameter(ctx=ctx, param=param) - # Need to check that the inventory file exist - # TODO, makes this better - try: - os.stat(value) - return value - except OSError as exc: - # We want the file to exist - raise click.exceptions.BadParameter( - f"{param.name} {click.utils.format_filename(value)!r} does not exist.", - ctx, - param, - ) from exc + if value is not None: + return value.split(",") if "," in value else [value] + return None def exit_with_code(ctx: click.Context) -> None: @@ -243,10 +191,8 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: return super().parse_args(ctx, args) -def inventory_options(f): - """ - Click common options when manipulating devices - """ +def inventory_options(f: Any) -> Any: + """Click common options when requiring an inventory to interact with devices""" @click.option( "--username", @@ -299,6 +245,7 @@ def inventory_options(f): is_flag=True, show_default=True, ) + @click.option("--disable-cache", help="Disable cache globally", show_envvar=True, show_default=True, is_flag=True, default=False) @click.option( "--inventory", "-i", @@ -306,11 +253,13 @@ def inventory_options(f): envvar="ANTA_INVENTORY", show_envvar=True, required=True, - type=click.Path(file_okay=True, dir_okay=False, readable=True, path_type=Path), + type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), + callback=parse_inventory, ) @click.option("--tags", "-t", help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags) @functools.wraps(f) - def wrapper_common_options(ctx: click.Context, *args, **kwargs): + def wrapper_common_options(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + ctx.ensure_object(dict) if not ctx.obj.get("_anta_help"): if ctx.params.get("prompt"): # User asked for a password prompt @@ -327,28 +276,26 @@ def wrapper_common_options(ctx: click.Context, *args, **kwargs): if not ctx.params.get("enable") and ctx.params.get("enable_password"): raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.") - ctx.ensure_object(dict) - inventory = ctx.params.get("inventory") - ctx.obj["inventory_path"] = inventory - ctx.obj["inventory"] = parse_inventory(ctx, inventory) return f(ctx, *args, **kwargs) return wrapper_common_options -def catalog_options(f): +def catalog_options(f: Any) -> Any: + """Click common options when requiring a test catalog to execute ANTA tests""" + @click.option( "--catalog", "-c", envvar="ANTA_CATALOG", show_envvar=True, help="Path to the test catalog YAML file", - type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True), + type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), required=True, callback=parse_catalog, ) @functools.wraps(f) - def wrapper_common_options(ctx: click.Context, *args, **kwargs): + def wrapper_common_options(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: return f(ctx, *args, **kwargs) return wrapper_common_options diff --git a/anta/logger.py b/anta/logger.py index f8cabdc79..08ccae151 100644 --- a/anta/logger.py +++ b/anta/logger.py @@ -7,7 +7,9 @@ from __future__ import annotations import logging +from enum import Enum from pathlib import Path +from typing import Literal from rich.logging import RichHandler @@ -16,7 +18,20 @@ logger = logging.getLogger(__name__) -def setup_logging(level: str = logging.getLevelName(logging.INFO), file: Path | None = None) -> None: +class Log(str, Enum): + """Represent log levels from logging module as immutable strings""" + + CRITICAL = logging.getLevelName(logging.CRITICAL) + ERROR = logging.getLevelName(logging.ERROR) + WARNING = logging.getLevelName(logging.WARNING) + INFO = logging.getLevelName(logging.INFO) + DEBUG = logging.getLevelName(logging.DEBUG) + + +LogLevel = Literal[Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG] + + +def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: """ Configure logging for ANTA. By default, the logging level is INFO for all loggers except for httpx and asyncssh which are too verbose: From 7eb2a0d78ef3f16af39439dea3ab5b0edb19f0e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 27 Nov 2023 15:19:00 +0100 Subject: [PATCH 10/53] add error handling for None arguments --- anta/catalog.py | 2 +- anta/cli/utils.py | 5 +++-- anta/device.py | 6 ++++++ anta/inventory/__init__.py | 7 +++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/anta/catalog.py b/anta/catalog.py index d608700e5..8aae5a5e1 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -224,7 +224,7 @@ def parse(filename: str | Path) -> AntaCatalog: try: with open(file=filename, mode="r", encoding="UTF-8") as file: data = safe_load(file) - except (YAMLError, OSError) as e: + except (TypeError, YAMLError, OSError) as e: message = f"Unable to parse ANTA Test Catalog file '{filename}'" anta_log_exception(e, message, logger) raise diff --git a/anta/cli/utils.py b/anta/cli/utils.py index afd06ddcf..4428e414a 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -44,6 +44,7 @@ class ExitCode(enum.IntEnum): INTERNAL_ERROR = 3 # pytest was misused. USAGE_ERROR = 4 + # TODO: when click fails to parse the CLI options, it returns an error code value of 2 when a usage error message. Maybe we should align here ? def parse_inventory(ctx: click.Context, param: Option, value: Path) -> AntaInventory: @@ -66,7 +67,7 @@ def parse_inventory(ctx: click.Context, param: Option, value: Path) -> AntaInven insecure=ctx.params["insecure"], disable_cache=ctx.params["disable_cache"], ) - except (ValidationError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): + except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): ctx.exit(ExitCode.USAGE_ERROR) return inventory @@ -82,7 +83,7 @@ def parse_catalog(ctx: click.Context, param: Option, value: Path) -> AntaCatalog return AntaCatalog() try: catalog: AntaCatalog = AntaCatalog.parse(value) - except (ValidationError, YAMLError, OSError): + except (ValidationError, TypeError, ValueError, YAMLError, OSError): ctx.exit(ExitCode.USAGE_ERROR) return catalog diff --git a/anta/device.py b/anta/device.py index bba80cd1e..a49edb3b1 100644 --- a/anta/device.py +++ b/anta/device.py @@ -250,9 +250,15 @@ def __init__( # pylint: disable=R0913 proto: eAPI protocol. Value can be 'http' or 'https' disable_cache: Disable caching for all commands for this device. Defaults to False. """ + if host is None: + raise ValueError("'host' is required to create an AsyncEOSDevice") if name is None: name = f"{host}{f':{port}' if port else ''}" super().__init__(name, tags, disable_cache) + if username is None: + raise ValueError(f"'username' is required to instantiate device '{self.name}'") + if password is None: + raise ValueError(f"'password' is required to instantiate device '{self.name}'") self.enable = enable self._enable_password = enable_password self._session: aioeapi.Device = aioeapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout) diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py index fe297d1f4..f3e5684a3 100644 --- a/anta/inventory/__init__.py +++ b/anta/inventory/__init__.py @@ -177,12 +177,15 @@ def parse( "insecure": insecure, "disable_cache": disable_cache, } - kwargs = {k: v for k, v in kwargs.items() if v is not None} + if username is None: + raise ValueError("'username' is required to create an AntaInventory") + if password is None: + raise ValueError("'password' is required to create an AntaInventory") try: with open(file=filename, mode="r", encoding="UTF-8") as file: data = safe_load(file) - except (YAMLError, OSError) as e: + except (TypeError, YAMLError, OSError) as e: message = f"Unable to parse ANTA Device Inventory file '{filename}'" anta_log_exception(e, message, logger) raise From 27cb962f8c59dfa2d478a1f8d77069ddb67f43e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Wed, 29 Nov 2023 12:47:08 +0100 Subject: [PATCH 11/53] add logs to anta.device --- anta/device.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/anta/device.py b/anta/device.py index a49edb3b1..bbeca6cde 100644 --- a/anta/device.py +++ b/anta/device.py @@ -251,14 +251,20 @@ def __init__( # pylint: disable=R0913 disable_cache: Disable caching for all commands for this device. Defaults to False. """ if host is None: - raise ValueError("'host' is required to create an AsyncEOSDevice") + message = "'host' is required to create an AsyncEOSDevice" + logger.error(message) + raise ValueError(message) if name is None: name = f"{host}{f':{port}' if port else ''}" super().__init__(name, tags, disable_cache) if username is None: - raise ValueError(f"'username' is required to instantiate device '{self.name}'") + message = f"'username' is required to instantiate device '{self.name}'" + logger.error(message) + raise ValueError(message) if password is None: - raise ValueError(f"'password' is required to instantiate device '{self.name}'") + message = f"'password' is required to instantiate device '{self.name}'" + logger.error(message) + raise ValueError(message) self.enable = enable self._enable_password = enable_password self._session: aioeapi.Device = aioeapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout) From 16f925a15cec23c084e39d60b32ee02b4a5f5828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Wed, 29 Nov 2023 12:48:52 +0100 Subject: [PATCH 12/53] Cleanup anta.cli --- anta/cli/__init__.py | 24 +++++---- anta/cli/utils.py | 113 +++++++++++-------------------------------- 2 files changed, 40 insertions(+), 97 deletions(-) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index 0a891b75b..eca5bda66 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -2,7 +2,6 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -# coding: utf-8 -*- """ ANTA CLI """ @@ -15,19 +14,17 @@ import click from anta import __version__ -from anta.catalog import AntaCatalog from anta.cli.check import commands as check_commands from anta.cli.debug import commands as debug_commands from anta.cli.exec import commands as exec_commands from anta.cli.get import commands as get_commands from anta.cli.nrfu import commands as nrfu_commands -from anta.cli.utils import AliasedGroup, IgnoreRequiredWithHelp, catalog_options, inventory_options -from anta.inventory import AntaInventory +from anta.cli.utils import AliasedGroup, catalog_options, inventory_options from anta.logger import Log, LogLevel, setup_logging from anta.result_manager import ResultManager -@click.group(cls=IgnoreRequiredWithHelp) +@click.group(cls=AliasedGroup) @click.pass_context @click.version_option(__version__) @click.option( @@ -54,36 +51,37 @@ def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> Non setup_logging(log_level, log_file) -@anta.group("nrfu", cls=IgnoreRequiredWithHelp) +@anta.group("nrfu", invoke_without_command=True) @click.pass_context @inventory_options @catalog_options @click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False) @click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) -def _nrfu(ctx: click.Context, inventory: AntaInventory, catalog: AntaCatalog, **kwargs: dict[str, Any]) -> None: +def _nrfu(ctx: click.Context, **kwargs: dict[str, Any]) -> None: # pylint: disable=unused-argument """Run NRFU against inventory devices""" - ctx.obj["catalog"] = catalog - ctx.obj["inventory"] = inventory ctx.obj["result_manager"] = ResultManager() + # Invoke `anta nrfu table` if no command is passed + if ctx.invoked_subcommand is None: + ctx.invoke(nrfu_commands.table) -@anta.group("check", cls=AliasedGroup) +@anta.group("check") def _check() -> None: """Check commands for building ANTA""" -@anta.group("exec", cls=AliasedGroup) +@anta.group("exec") def _exec() -> None: """Execute commands to inventory devices""" -@anta.group("get", cls=AliasedGroup) +@anta.group("get") def _get() -> None: """Get data from/to ANTA""" -@anta.group("debug", cls=AliasedGroup) +@anta.group("debug") def _debug() -> None: """Debug commands for building ANTA""" diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 4428e414a..dbd46c8d0 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -22,11 +22,11 @@ from anta.inventory import AntaInventory from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError -logger = logging.getLogger(__name__) - if TYPE_CHECKING: from click import Option +logger = logging.getLogger(__name__) + class ExitCode(enum.IntEnum): """ @@ -38,54 +38,12 @@ class ExitCode(enum.IntEnum): OK = 0 #: Tests failed. TESTS_FAILED = 1 + # CLI was misused + USAGE_ERROR = 2 # Test error - TESTS_ERROR = 2 + TESTS_ERROR = 3 # An internal error got in the way. - INTERNAL_ERROR = 3 - # pytest was misused. - USAGE_ERROR = 4 - # TODO: when click fails to parse the CLI options, it returns an error code value of 2 when a usage error message. Maybe we should align here ? - - -def parse_inventory(ctx: click.Context, param: Option, value: Path) -> AntaInventory: - # pylint: disable=unused-argument - """ - Click option callback to parse an ANTA inventory YAML file - """ - if ctx.obj.get("_anta_help"): - # Currently looking for help for a subcommand so no - # need to parse the Inventory, return an empty one - return AntaInventory() - try: - inventory = AntaInventory.parse( - filename=str(value), - username=ctx.params["username"], - password=ctx.params["password"], - enable=ctx.params["enable"], - enable_password=ctx.params["enable_password"], - timeout=ctx.params["timeout"], - insecure=ctx.params["insecure"], - disable_cache=ctx.params["disable_cache"], - ) - except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): - ctx.exit(ExitCode.USAGE_ERROR) - return inventory - - -def parse_catalog(ctx: click.Context, param: Option, value: Path) -> AntaCatalog: - # pylint: disable=unused-argument - """ - Click option callback to parse an ANTA test catalog YAML file - """ - if ctx.obj.get("_anta_help"): - # Currently looking for help for a subcommand so no - # need to parse the Catalog - return an empty catalog - return AntaCatalog() - try: - catalog: AntaCatalog = AntaCatalog.parse(value) - except (ValidationError, TypeError, ValueError, YAMLError, OSError): - ctx.exit(ExitCode.USAGE_ERROR) - return catalog + INTERNAL_ERROR = 4 def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | None: @@ -158,40 +116,6 @@ def resolve_command(self, ctx: click.Context, args: Any) -> Any: return cmd.name, cmd, args # type: ignore -class IgnoreRequiredWithHelp(AliasedGroup): - """ - https://stackoverflow.com/questions/55818737/python-click-application-required-parameters-have-precedence-over-sub-command-he - Solution to allow help without required options on subcommand - - This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734 - """ - - 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) - if "--help" in args: - ctx.obj["_anta_help"] = True - - # Storing full CLI call in ctx to get it in callbacks - ctx.obj["args"] = args - - try: - return super().parse_args(ctx, args) - except click.MissingParameter: - if "--help" not in args: - raise - - # remove the required params so that help can display - for param in self.params: - param.required = False - - return super().parse_args(ctx, args) - - def inventory_options(f: Any) -> Any: """Click common options when requiring an inventory to interact with devices""" @@ -255,7 +179,6 @@ def inventory_options(f: Any) -> Any: show_envvar=True, required=True, type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), - callback=parse_inventory, ) @click.option("--tags", "-t", help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags) @functools.wraps(f) @@ -277,6 +200,21 @@ def wrapper_common_options(ctx: click.Context, *args: tuple[Any], **kwargs: dict if not ctx.params.get("enable") and ctx.params.get("enable_password"): raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.") + try: + ctx.obj["inventory"] = AntaInventory.parse( + filename=ctx.params["inventory"], + username=ctx.params["username"], + password=ctx.params["password"], + enable=ctx.params["enable"], + enable_password=ctx.params["enable_password"], + timeout=ctx.params["timeout"], + insecure=ctx.params["insecure"], + disable_cache=ctx.params["disable_cache"], + ) + except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): + ctx.exit(ExitCode.USAGE_ERROR) + else: + ctx.obj["inventory"] = AntaInventory() return f(ctx, *args, **kwargs) return wrapper_common_options @@ -293,10 +231,17 @@ def catalog_options(f: Any) -> Any: help="Path to the test catalog YAML file", type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), required=True, - callback=parse_catalog, ) @functools.wraps(f) def wrapper_common_options(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + ctx.ensure_object(dict) + if not ctx.obj.get("_anta_help"): + try: + ctx.obj["catalog"] = AntaCatalog.parse(ctx.params["catalog"]) + except (ValidationError, TypeError, ValueError, YAMLError, OSError): + ctx.exit(ExitCode.USAGE_ERROR) + else: + ctx.obj["catalog"] = AntaCatalog() return f(ctx, *args, **kwargs) return wrapper_common_options From ece8eede7a4203611f8fcc72776c77711632b74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Wed, 29 Nov 2023 12:49:16 +0100 Subject: [PATCH 13/53] add logs to anta.inventory --- anta/inventory/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py index f3e5684a3..c335f20c1 100644 --- a/anta/inventory/__init__.py +++ b/anta/inventory/__init__.py @@ -10,6 +10,7 @@ import asyncio import logging from ipaddress import ip_address, ip_network +from pathlib import Path from typing import Any, Optional from pydantic import ValidationError @@ -138,7 +139,7 @@ def _parse_ranges(inventory_input: AntaInventoryInput, inventory: AntaInventory, @staticmethod def parse( - filename: str, + filename: str | Path, username: str, password: str, enable: bool = False, @@ -178,9 +179,13 @@ def parse( "disable_cache": disable_cache, } if username is None: - raise ValueError("'username' is required to create an AntaInventory") + message = "'username' is required to create an AntaInventory" + logger.error(message) + raise ValueError(message) if password is None: - raise ValueError("'password' is required to create an AntaInventory") + message = "'password' is required to create an AntaInventory" + logger.error(message) + raise ValueError(message) try: with open(file=filename, mode="r", encoding="UTF-8") as file: From 9a15671f5a92458834ae7cfd82f1911e2598693f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Wed, 29 Nov 2023 12:56:27 +0100 Subject: [PATCH 14/53] update unit tests for anta.cli --- tests/data/test_catalog.yml | 13 +-- tests/lib/fixture.py | 28 ++++++- tests/units/cli/nrfu/__init__.py | 3 + tests/units/cli/nrfu/test_commands.py | 107 +++++++++++++++++++++++++ tests/units/cli/test__init__.py | 110 ++------------------------ 5 files changed, 143 insertions(+), 118 deletions(-) create mode 100644 tests/units/cli/nrfu/__init__.py create mode 100644 tests/units/cli/nrfu/test_commands.py diff --git a/tests/data/test_catalog.yml b/tests/data/test_catalog.yml index 2af3a0c0d..c5b55b007 100644 --- a/tests/data/test_catalog.yml +++ b/tests/data/test_catalog.yml @@ -1,16 +1,5 @@ --- -anta.tests.configuration: - - VerifyZeroTouch: - -anta.tests.hardware: - - VerifyTemperature: - anta.tests.software: - VerifyEOSVersion: versions: - - 4.25.4M - - 4.26.1F - -anta.tests.system: - - VerifyUptime: - minimum: 86400 + - 4.31.1F diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index ccdc0d930..6986fb368 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -5,11 +5,12 @@ from __future__ import annotations from os import environ -from typing import Callable, Iterator +from typing import Any, Callable, Iterator from unittest.mock import patch import pytest from click.testing import CliRunner +from pytest import CaptureFixture from anta.device import AntaDevice, AsyncEOSDevice from anta.inventory import AntaInventory @@ -21,6 +22,10 @@ DEVICE_HW_MODEL = "pytest" DEVICE_NAME = "pytest" COMMAND_OUTPUT = "retrieved" +SHOW_VERSION_OUTPUT = { + "modelName": "DCS-7280CR3-32P4-F", + "version": "4.31.1F", +} @pytest.fixture @@ -136,11 +141,28 @@ def test_inventory() -> AntaInventory: @pytest.fixture -def click_runner() -> CliRunner: +def click_runner(capsys: CaptureFixture[str]) -> CliRunner: """ Convenience fixture to return a click.CliRunner for cli testing """ - return CliRunner() + + def cli( + command: str | None = None, commands: list[dict[str, Any]] | None = None, ofmt: str = "json", **kwargs: dict[str, Any] + ) -> dict[str, Any] | list[dict[str, Any]]: + # pylint: disable=unused-argument + if ofmt != "json": + raise NotImplementedError() + if command == "show version": + return SHOW_VERSION_OUTPUT + if commands is not None and "show version" == commands[0]["cmd"]: + return [SHOW_VERSION_OUTPUT] + raise NotImplementedError() + + # Patch aioeapi methods used by AsyncEOSDevice. See tests/units/test_device.py + with patch("aioeapi.device.Device.check_connection", return_value=True): + with patch("aioeapi.device.Device.cli", side_effect=cli): + with capsys.disabled(): + return CliRunner() @pytest.fixture(autouse=True) diff --git a/tests/units/cli/nrfu/__init__.py b/tests/units/cli/nrfu/__init__.py new file mode 100644 index 000000000..c460d5493 --- /dev/null +++ b/tests/units/cli/nrfu/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py new file mode 100644 index 000000000..3a04b8bb6 --- /dev/null +++ b/tests/units/cli/nrfu/test_commands.py @@ -0,0 +1,107 @@ +# Copyright (c) 2023 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 for anta.cli.nrfu.commands +""" +from __future__ import annotations + +from click.testing import CliRunner + +from anta.cli import anta +from anta.cli.utils import ExitCode +from tests.lib.utils import default_anta_env + + +def test_anta_nrfu(click_runner: CliRunner) -> None: + """ + Test anta nrfu, catalog is given via env + """ + env = default_anta_env() + result = click_runner.invoke(anta, ["nrfu", "text"], env=env, auto_envvar_prefix="ANTA") + assert result.exit_code == ExitCode.OK + assert "ANTA Inventory contains 3 devices" in result.output + # assert "Tests catalog contains 4 tests" in result.output + print(result.output) + + +def test_anta_password_required(click_runner: CliRunner) -> None: + """ + Test that password is provided + """ + env = default_anta_env() + env.pop("ANTA_PASSWORD") + result = click_runner.invoke(anta, ["nrfu", "text"], env=env, auto_envvar_prefix="ANTA") + assert result.exit_code == ExitCode.USAGE_ERROR + assert "EOS password needs to be provided by using either the '--password' option or the '--prompt' option." in result.output + + +def test_anta_password(click_runner: CliRunner) -> None: + """ + Test that password can be provided either via --password or --prompt + """ + env = default_anta_env() + env.pop("ANTA_PASSWORD") + result = click_runner.invoke(anta, ["nrfu", "--password", "secret", "text"], env=env, auto_envvar_prefix="ANTA") + assert result.exit_code == ExitCode.OK + result = click_runner.invoke(anta, ["nrfu", "--prompt", "text"], input="password\npassword\n", env=env, auto_envvar_prefix="ANTA") + assert result.exit_code == ExitCode.OK + + +def test_anta_enable_password(click_runner: CliRunner) -> None: + """ + Test that enable password can be provided either via --enable-password or --prompt + """ + env = default_anta_env() + + # Both enable and enable-password + result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "secret", "text"], env=env, auto_envvar_prefix="ANTA") + assert result.exit_code == ExitCode.OK + + # enable and prompt y + result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt", "text"], input="y\npassword\npassword\n", env=env, auto_envvar_prefix="ANTA") + assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output + assert "Please enter a password to enter EOS privileged EXEC mode" in result.output + assert result.exit_code == ExitCode.OK + + # enable and prompt N + result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt", "text"], input="N\n", env=env, auto_envvar_prefix="ANTA") + assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output + assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output + assert result.exit_code == ExitCode.OK + + # enable and enable-password and prompt (redundant) + result = click_runner.invoke( + anta, ["nrfu", "--enable", "--enable-password", "blah", "--prompt", "text"], input="y\npassword\npassword\n", env=env, auto_envvar_prefix="ANTA" + ) + assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" not in result.output + assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output + assert result.exit_code == ExitCode.OK + + # enabled-password without enable + result = click_runner.invoke(anta, ["nrfu", "--enable-password", "blah", "text"], env=env, auto_envvar_prefix="ANTA") + assert result.exit_code == ExitCode.USAGE_ERROR + assert "Providing a password to access EOS Privileged EXEC mode requires '--enable' option." in result.output + + +def test_anta_enable_alone(click_runner: CliRunner) -> None: + """ + Test that enable can be provided either without enable-password + """ + env = default_anta_env() + result = click_runner.invoke(anta, ["nrfu", "--enable", "text"], env=env, auto_envvar_prefix="ANTA") + assert result.exit_code == ExitCode.OK + + +def test_disable_cache(click_runner: CliRunner) -> None: + """ + Test that disable_cache is working on inventory + """ + env = default_anta_env() + result = click_runner.invoke(anta, ["nrfu", "--disable-cache", "text"], env=env, auto_envvar_prefix="ANTA") + stdout_lines = result.stdout.split("\n") + # All caches should be disabled from the inventory + for line in stdout_lines: + if "disable_cache" in line: + assert "True" in line + assert result.exit_code == ExitCode.OK diff --git a/tests/units/cli/test__init__.py b/tests/units/cli/test__init__.py index e4c9a6c4b..a216208e3 100644 --- a/tests/units/cli/test__init__.py +++ b/tests/units/cli/test__init__.py @@ -8,10 +8,9 @@ from __future__ import annotations from click.testing import CliRunner -from pytest import CaptureFixture from anta.cli import anta -from tests.lib.utils import default_anta_env +from anta.cli.utils import ExitCode def test_anta(click_runner: CliRunner) -> None: @@ -19,7 +18,7 @@ def test_anta(click_runner: CliRunner) -> None: Test anta main entrypoint """ result = click_runner.invoke(anta) - assert result.exit_code == 0 + assert result.exit_code == ExitCode.OK assert "Usage" in result.output @@ -28,7 +27,7 @@ def test_anta_help(click_runner: CliRunner) -> None: Test anta --help """ result = click_runner.invoke(anta, ["--help"]) - assert result.exit_code == 0 + assert result.exit_code == ExitCode.OK assert "Usage" in result.output @@ -37,7 +36,7 @@ def test_anta_nrfu_help(click_runner: CliRunner) -> None: Test anta nrfu --help """ result = click_runner.invoke(anta, ["nrfu", "--help"]) - assert result.exit_code == 0 + assert result.exit_code == ExitCode.OK assert "Usage: anta nrfu" in result.output @@ -46,7 +45,7 @@ def test_anta_exec_help(click_runner: CliRunner) -> None: Test anta exec --help """ result = click_runner.invoke(anta, ["exec", "--help"]) - assert result.exit_code == 0 + assert result.exit_code == ExitCode.OK assert "Usage: anta exec" in result.output @@ -55,7 +54,7 @@ def test_anta_debug_help(click_runner: CliRunner) -> None: Test anta debug --help """ result = click_runner.invoke(anta, ["debug", "--help"]) - assert result.exit_code == 0 + assert result.exit_code == ExitCode.OK assert "Usage: anta debug" in result.output @@ -64,100 +63,5 @@ def test_anta_get_help(click_runner: CliRunner) -> None: Test anta get --help """ result = click_runner.invoke(anta, ["get", "--help"]) - assert result.exit_code == 0 + assert result.exit_code == ExitCode.OK assert "Usage: anta get" in result.output - - -def test_anta_nrfu(capsys: CaptureFixture[str], click_runner: CliRunner) -> None: - """ - Test anta nrfu table, catalog is given via env - """ - # TODO this test should mock device connections... - env = default_anta_env() - with capsys.disabled(): - result = click_runner.invoke(anta, ["nrfu", "table"], env=env, auto_envvar_prefix="ANTA") - assert result.exit_code == 0 - assert "ANTA Inventory contains 3 devices" in result.output - - -def test_anta_password_required(click_runner: CliRunner) -> None: - """ - Test that password is provided - """ - env = default_anta_env() - env.pop("ANTA_PASSWORD") - result = click_runner.invoke(anta, ["nrfu", "table"], env=env, auto_envvar_prefix="ANTA") - assert result.exit_code == 2 - assert "EOS password needs to be provided by using either the '--password' option or the '--prompt' option." in result.output - - -def test_anta_password(click_runner: CliRunner) -> None: - """ - Test that password can be provided either via --password or --prompt - """ - env = default_anta_env() - env.pop("ANTA_PASSWORD") - result = click_runner.invoke(anta, ["--password", "blah", "get", "inventory"], env=env, auto_envvar_prefix="ANTA") - assert result.exit_code == 0 - result = click_runner.invoke(anta, ["--prompt", "get", "inventory"], input="password\npassword\n", env=env, auto_envvar_prefix="ANTA") - assert result.exit_code == 0 - - -def test_anta_enable_password(click_runner: CliRunner) -> None: - """ - Test that enable password can be provided either via --enable-password or --prompt - """ - env = default_anta_env() - - # Both enable and enable-password - result = click_runner.invoke(anta, ["--enable", "--enable-password", "blah", "get", "inventory"], env=env, auto_envvar_prefix="ANTA") - assert result.exit_code == 0 - - # enable and prompt y - result = click_runner.invoke(anta, ["--enable", "--prompt", "get", "inventory"], input="y\npassword\npassword\n", env=env, auto_envvar_prefix="ANTA") - assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output - assert "Please enter a password to enter EOS privileged EXEC mode" in result.output - assert result.exit_code == 0 - - # enable and prompt N - result = click_runner.invoke(anta, ["--enable", "--prompt", "get", "inventory"], input="N\n", env=env, auto_envvar_prefix="ANTA") - assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output - assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output - assert result.exit_code == 0 - - # enable and enable-password and prompt (redundant) - result = click_runner.invoke( - anta, ["--enable", "--enable-password", "blah", "--prompt", "get", "inventory"], input="y\npassword\npassword\n", env=env, auto_envvar_prefix="ANTA" - ) - assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" not in result.output - assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output - assert result.exit_code == 0 - - # enabled-password without enable - result = click_runner.invoke(anta, ["--enable-password", "blah", "get", "inventory"], env=env, auto_envvar_prefix="ANTA") - assert result.exit_code == 2 - assert "Providing a password to access EOS Privileged EXEC mode requires '--enable' option." in result.output - - -def test_anta_enable_alone(click_runner: CliRunner) -> None: - """ - Test that enable can be provided either without enable-password - """ - env = default_anta_env() - # enabled without enable-password and without prompt this is - result = click_runner.invoke(anta, ["--enable", "get", "inventory"], env=env, auto_envvar_prefix="ANTA") - assert result.exit_code == 0 - - -def test_disable_cache(click_runner: CliRunner) -> None: - """ - Test that disable_cache is working on inventory - """ - env = default_anta_env() - result = click_runner.invoke(anta, ["--disable-cache", "get", "inventory"], env=env, auto_envvar_prefix="ANTA") - stdout_lines = result.stdout.split("\n") - # All caches should be disabled from the inventory - for line in stdout_lines: - if "disable_cache" in line: - assert "True" in line - assert result.exit_code == 0 From a98721d9286a390e6c496ff09744d78c21a1c4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Thu, 30 Nov 2023 15:23:45 +0100 Subject: [PATCH 15/53] fix https://github.com/pallets/click/issues/824 --- tests/lib/fixture.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 6986fb368..4c5f083ae 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -9,7 +9,7 @@ from unittest.mock import patch import pytest -from click.testing import CliRunner +from click.testing import CliRunner, Result from pytest import CaptureFixture from anta.device import AntaDevice, AsyncEOSDevice @@ -145,6 +145,12 @@ def click_runner(capsys: CaptureFixture[str]) -> CliRunner: """ Convenience fixture to return a click.CliRunner for cli testing """ + class MyCliRunner(CliRunner): + # NB: Please blame gmuloc for this + # Nice way to fix https://github.com/pallets/click/issues/824 + def invoke(self, *args: list[Any], **kwargs: dict[str, Any]) -> Result: # type: ignore[override] + with capsys.disabled(): + return super().invoke(*args, **kwargs) # type: ignore[arg-type] def cli( command: str | None = None, commands: list[dict[str, Any]] | None = None, ofmt: str = "json", **kwargs: dict[str, Any] @@ -161,8 +167,7 @@ def cli( # Patch aioeapi methods used by AsyncEOSDevice. See tests/units/test_device.py with patch("aioeapi.device.Device.check_connection", return_value=True): with patch("aioeapi.device.Device.cli", side_effect=cli): - with capsys.disabled(): - return CliRunner() + yield MyCliRunner() @pytest.fixture(autouse=True) From 1f4191683d1ffdf8d57acda56203902d0f579329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Thu, 30 Nov 2023 16:08:34 +0100 Subject: [PATCH 16/53] update unit tests for anta.cli.nrfu --- tests/lib/fixture.py | 61 ++++++++++++++++----------- tests/units/cli/nrfu/test_commands.py | 29 +++++-------- 2 files changed, 47 insertions(+), 43 deletions(-) diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 4c5f083ae..3de2bc9fd 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -4,7 +4,7 @@ """Fixture for Anta Testing""" from __future__ import annotations -from os import environ +import logging from typing import Any, Callable, Iterator from unittest.mock import patch @@ -19,12 +19,18 @@ from anta.result_manager.models import TestResult from tests.lib.utils import default_anta_env +logger = logging.getLogger(__name__) + DEVICE_HW_MODEL = "pytest" DEVICE_NAME = "pytest" COMMAND_OUTPUT = "retrieved" -SHOW_VERSION_OUTPUT = { - "modelName": "DCS-7280CR3-32P4-F", - "version": "4.31.1F", + +MOCK_CLI: dict[str, dict[str, Any]] = { + "show version": { + "modelName": "DCS-7280CR3-32P4-F", + "version": "4.31.1F", + }, + "enable": {}, } @@ -145,37 +151,42 @@ def click_runner(capsys: CaptureFixture[str]) -> CliRunner: """ Convenience fixture to return a click.CliRunner for cli testing """ - class MyCliRunner(CliRunner): - # NB: Please blame gmuloc for this - # Nice way to fix https://github.com/pallets/click/issues/824 - def invoke(self, *args: list[Any], **kwargs: dict[str, Any]) -> Result: # type: ignore[override] + + class AntaCliRunner(CliRunner): + def invoke(self, *args, **kwargs) -> Result: # type: ignore[override, no-untyped-def] + # Inject default env if not provided + kwargs["env"] = kwargs["env"] if "env" in kwargs else default_anta_env() + kwargs["auto_envvar_prefix"] = "ANTA" + # Way to fix https://github.com/pallets/click/issues/824 with capsys.disabled(): return super().invoke(*args, **kwargs) # type: ignore[arg-type] def cli( command: str | None = None, commands: list[dict[str, Any]] | None = None, ofmt: str = "json", **kwargs: dict[str, Any] ) -> 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"] + for mock_cmd, output in MOCK_CLI.items(): + if command == mock_cmd: + logger.info(f"Mocking command {mock_cmd}") + return output + raise NotImplementedError(f"Command '{command}' is not mocked") + # pylint: disable=unused-argument if ofmt != "json": raise NotImplementedError() - if command == "show version": - return SHOW_VERSION_OUTPUT - if commands is not None and "show version" == commands[0]["cmd"]: - return [SHOW_VERSION_OUTPUT] - raise NotImplementedError() + res: dict[str, Any] | list[dict[str, Any]] + if command is not None: + logger.debug(f"Mock input {command}") + res = get_output(command) + if commands is not None: + logger.debug(f"Mock input {commands}") + res = list(map(get_output, commands)) + logger.debug(f"Mock output {res}") + return res # Patch aioeapi methods used by AsyncEOSDevice. See tests/units/test_device.py with patch("aioeapi.device.Device.check_connection", return_value=True): with patch("aioeapi.device.Device.cli", side_effect=cli): - yield MyCliRunner() - - -@pytest.fixture(autouse=True) -def clean_anta_env_variables() -> None: - """ - Autouse fixture that cleans the various ANTA_FOO env variables - that could come from the user environment and make some tests fail. - """ - for envvar in environ: - if envvar.startswith("ANTA_"): - environ.pop(envvar) + yield AntaCliRunner() diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 3a04b8bb6..2c52fc609 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -17,8 +17,7 @@ def test_anta_nrfu(click_runner: CliRunner) -> None: """ Test anta nrfu, catalog is given via env """ - env = default_anta_env() - result = click_runner.invoke(anta, ["nrfu", "text"], env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu"]) assert result.exit_code == ExitCode.OK assert "ANTA Inventory contains 3 devices" in result.output # assert "Tests catalog contains 4 tests" in result.output @@ -31,7 +30,7 @@ def test_anta_password_required(click_runner: CliRunner) -> None: """ env = default_anta_env() env.pop("ANTA_PASSWORD") - result = click_runner.invoke(anta, ["nrfu", "text"], env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu"], env=env) assert result.exit_code == ExitCode.USAGE_ERROR assert "EOS password needs to be provided by using either the '--password' option or the '--prompt' option." in result.output @@ -42,9 +41,9 @@ def test_anta_password(click_runner: CliRunner) -> None: """ env = default_anta_env() env.pop("ANTA_PASSWORD") - result = click_runner.invoke(anta, ["nrfu", "--password", "secret", "text"], env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu", "--password", "secret"], env=env) assert result.exit_code == ExitCode.OK - result = click_runner.invoke(anta, ["nrfu", "--prompt", "text"], input="password\npassword\n", env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu", "--prompt"], input="password\npassword\n", env=env) assert result.exit_code == ExitCode.OK @@ -52,34 +51,30 @@ def test_anta_enable_password(click_runner: CliRunner) -> None: """ Test that enable password can be provided either via --enable-password or --prompt """ - env = default_anta_env() - # Both enable and enable-password - result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "secret", "text"], env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "secret"]) assert result.exit_code == ExitCode.OK # enable and prompt y - result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt", "text"], input="y\npassword\npassword\n", env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt"], input="y\npassword\npassword\n") assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output assert "Please enter a password to enter EOS privileged EXEC mode" in result.output assert result.exit_code == ExitCode.OK # enable and prompt N - result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt", "text"], input="N\n", env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt"], input="N\n") assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output assert result.exit_code == ExitCode.OK # enable and enable-password and prompt (redundant) - result = click_runner.invoke( - anta, ["nrfu", "--enable", "--enable-password", "blah", "--prompt", "text"], input="y\npassword\npassword\n", env=env, auto_envvar_prefix="ANTA" - ) + result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "blah", "--prompt"], input="y\npassword\npassword\n") assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" not in result.output assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output assert result.exit_code == ExitCode.OK # enabled-password without enable - result = click_runner.invoke(anta, ["nrfu", "--enable-password", "blah", "text"], env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu", "--enable-password", "blah"]) assert result.exit_code == ExitCode.USAGE_ERROR assert "Providing a password to access EOS Privileged EXEC mode requires '--enable' option." in result.output @@ -88,8 +83,7 @@ def test_anta_enable_alone(click_runner: CliRunner) -> None: """ Test that enable can be provided either without enable-password """ - env = default_anta_env() - result = click_runner.invoke(anta, ["nrfu", "--enable", "text"], env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu", "--enable"]) assert result.exit_code == ExitCode.OK @@ -97,8 +91,7 @@ def test_disable_cache(click_runner: CliRunner) -> None: """ Test that disable_cache is working on inventory """ - env = default_anta_env() - result = click_runner.invoke(anta, ["nrfu", "--disable-cache", "text"], env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["nrfu", "--disable-cache"]) stdout_lines = result.stdout.split("\n") # All caches should be disabled from the inventory for line in stdout_lines: From d6577e5144ebbdb02f77ac133b55c29659dd4504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Thu, 30 Nov 2023 17:05:43 +0100 Subject: [PATCH 17/53] update unit tests for anta.cli.nrfu --- anta/cli/__init__.py | 28 ++------ anta/cli/nrfu/__init__.py | 38 ++++++++++ anta/cli/nrfu/commands.py | 17 +---- anta/cli/nrfu/utils.py | 6 +- tests/data/template.j2 | 3 + tests/lib/fixture.py | 5 +- tests/units/cli/nrfu/__init__.py | 3 - tests/units/cli/nrfu/test__init__.py | 100 ++++++++++++++++++++++++++ tests/units/cli/nrfu/test_commands.py | 95 ++++++++---------------- 9 files changed, 179 insertions(+), 116 deletions(-) create mode 100644 tests/data/template.j2 delete mode 100644 tests/units/cli/nrfu/__init__.py create mode 100644 tests/units/cli/nrfu/test__init__.py diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index eca5bda66..09b87c4dd 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -9,7 +9,6 @@ import logging import pathlib -from typing import Any import click @@ -18,10 +17,9 @@ from anta.cli.debug import commands as debug_commands from anta.cli.exec import commands as exec_commands from anta.cli.get import commands as get_commands -from anta.cli.nrfu import commands as nrfu_commands -from anta.cli.utils import AliasedGroup, catalog_options, inventory_options +from anta.cli.nrfu import nrfu +from anta.cli.utils import AliasedGroup from anta.logger import Log, LogLevel, setup_logging -from anta.result_manager import ResultManager @click.group(cls=AliasedGroup) @@ -51,21 +49,6 @@ def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> Non setup_logging(log_level, log_file) -@anta.group("nrfu", invoke_without_command=True) -@click.pass_context -@inventory_options -@catalog_options -@click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False) -@click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) -def _nrfu(ctx: click.Context, **kwargs: dict[str, Any]) -> None: - # pylint: disable=unused-argument - """Run NRFU against inventory devices""" - ctx.obj["result_manager"] = ResultManager() - # Invoke `anta nrfu table` if no command is passed - if ctx.invoked_subcommand is None: - ctx.invoke(nrfu_commands.table) - - @anta.group("check") def _check() -> None: """Check commands for building ANTA""" @@ -86,6 +69,8 @@ def _debug() -> None: """Debug commands for building ANTA""" +anta.add_command(nrfu) + # Load group commands # Prefixing with `_` for avoiding the confusion when importing anta.cli.debug.commands as otherwise the debug group has # a commands attribute. @@ -105,11 +90,6 @@ def _debug() -> None: _debug.add_command(debug_commands.run_cmd) _debug.add_command(debug_commands.run_template) -_nrfu.add_command(nrfu_commands.table) -_nrfu.add_command(nrfu_commands.json) -_nrfu.add_command(nrfu_commands.text) -_nrfu.add_command(nrfu_commands.tpl_report) - # ANTA CLI Execution def cli() -> None: diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index c460d5493..31688f748 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -1,3 +1,41 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +from __future__ import annotations + +import asyncio +from typing import Any + +import click + +from anta.cli.nrfu import commands +from anta.cli.utils import catalog_options, inventory_options +from anta.models import AntaTest +from anta.result_manager import ResultManager +from anta.runner import main + +from .utils import anta_progress_bar, print_settings + + +@click.group("nrfu", invoke_without_command=True) +@click.pass_context +@inventory_options +@catalog_options +@click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False) +@click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) +def nrfu(ctx: click.Context, **kwargs: dict[str, Any]) -> None: + # pylint: disable=unused-argument + """Run NRFU against inventory devices""" + ctx.obj["result_manager"] = ResultManager() + print_settings(ctx) + with anta_progress_bar() as AntaTest.progress: + asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=ctx.params.get("tags"))) + # Invoke `anta nrfu table` if no command is passed + if ctx.invoked_subcommand is None: + ctx.invoke(commands.table) + + +nrfu.add_command(commands.table) +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 4c82bd0cd..1f9697fee 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -6,17 +6,14 @@ """ from __future__ import annotations -import asyncio import logging import pathlib import click from anta.cli.utils import exit_with_code -from anta.models import AntaTest -from anta.runner import main -from .utils import anta_progress_bar, print_jinja, print_json, print_settings, print_table, print_text +from .utils import anta_progress_bar, print_jinja, print_json, print_table, print_text logger = logging.getLogger(__name__) @@ -30,9 +27,6 @@ ) def table(ctx: click.Context, device: str | None, test: str | None, group_by: str) -> None: """ANTA command to check network states with table result""" - print_settings(ctx) - with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=ctx.params.get("tags"))) print_table(results=ctx.obj["result_manager"], device=device, group_by=group_by, test=test) exit_with_code(ctx) @@ -49,9 +43,6 @@ def table(ctx: click.Context, device: str | None, test: str | None, group_by: st ) def json(ctx: click.Context, output: pathlib.Path | None) -> None: """ANTA command to check network state with JSON result""" - print_settings(ctx) - with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=ctx.params.get("tags"))) print_json(results=ctx.obj["result_manager"], output=output) exit_with_code(ctx) @@ -62,9 +53,6 @@ def json(ctx: click.Context, output: pathlib.Path | None) -> None: @click.option("--skip-error", help="Hide tests in errors due to connectivity issue", default=False, is_flag=True, show_default=True, required=False) def text(ctx: click.Context, search: str | None, skip_error: bool) -> None: """ANTA command to check network states with text result""" - print_settings(ctx) - with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=ctx.params.get("tags"))) print_text(results=ctx.obj["result_manager"], search=search, skip_error=skip_error) exit_with_code(ctx) @@ -89,8 +77,5 @@ def text(ctx: click.Context, search: str | None, skip_error: bool) -> None: ) def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None: """ANTA command to check network state with templated report""" - print_settings(ctx, template, output) - with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=ctx.params.get("tags"))) 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 17aa4352d..90962fedf 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -24,13 +24,9 @@ logger = logging.getLogger(__name__) -def print_settings(context: click.Context, report_template: pathlib.Path | None = None, report_output: pathlib.Path | None = None) -> None: +def print_settings(context: click.Context) -> None: """Print ANTA settings before running tests""" message = f"Running ANTA tests:\n- {context.obj['inventory']}\n- Tests catalog contains {len(context.obj['catalog'].tests)} tests" - if report_template: - message += f"\n- Report template: {report_template}" - if report_output: - message += f"\n- Report output: {report_output}" console.print(Panel.fit(message, style="cyan", title="[green]Settings")) console.print() diff --git a/tests/data/template.j2 b/tests/data/template.j2 new file mode 100644 index 000000000..e8820fe42 --- /dev/null +++ b/tests/data/template.j2 @@ -0,0 +1,3 @@ +{% for d in data %} +* {{ d.test }} is [green]{{ d.result | upper}}[/green] for {{ d.name }} +{% endfor %} diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 3de2bc9fd..573d5cbdb 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -159,7 +159,10 @@ def invoke(self, *args, **kwargs) -> Result: # type: ignore[override, no-untype kwargs["auto_envvar_prefix"] = "ANTA" # Way to fix https://github.com/pallets/click/issues/824 with capsys.disabled(): - return super().invoke(*args, **kwargs) # type: ignore[arg-type] + result = super().invoke(*args, **kwargs) # type: ignore[arg-type] + print("--- CLI Output ---") + print(result.output) + return result def cli( command: str | None = None, commands: list[dict[str, Any]] | None = None, ofmt: str = "json", **kwargs: dict[str, Any] diff --git a/tests/units/cli/nrfu/__init__.py b/tests/units/cli/nrfu/__init__.py deleted file mode 100644 index c460d5493..000000000 --- a/tests/units/cli/nrfu/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2023 Arista Networks, Inc. -# Use of this source code is governed by the Apache License 2.0 -# that can be found in the LICENSE file. diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py new file mode 100644 index 000000000..07c668ac9 --- /dev/null +++ b/tests/units/cli/nrfu/test__init__.py @@ -0,0 +1,100 @@ +# Copyright (c) 2023 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 for anta.cli.nrfu.commands +""" +from __future__ import annotations + +from click.testing import CliRunner + +from anta.cli import anta +from anta.cli.utils import ExitCode +from tests.lib.utils import default_anta_env + + +def test_anta_nrfu(click_runner: CliRunner) -> None: + """ + Test anta nrfu, catalog is given via env + """ + result = click_runner.invoke(anta, ["nrfu"]) + assert result.exit_code == ExitCode.OK + assert "ANTA Inventory contains 3 devices" in result.output + assert "Tests catalog contains 1 tests" in result.output + + +def test_anta_password_required(click_runner: CliRunner) -> None: + """ + Test that password is provided + """ + env = default_anta_env() + env["ANTA_PASSWORD"] = None + result = click_runner.invoke(anta, ["nrfu"], env=env) + + assert result.exit_code == ExitCode.USAGE_ERROR + assert "EOS password needs to be provided by using either the '--password' option or the '--prompt' option." in result.output + + +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 + 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) + assert result.exit_code == ExitCode.OK + + +def test_anta_enable_password(click_runner: CliRunner) -> None: + """ + Test that enable password can be provided either via --enable-password or --prompt + """ + # Both enable and enable-password + result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "secret"]) + assert result.exit_code == ExitCode.OK + + # enable and prompt y + result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt"], input="y\npassword\npassword\n") + assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output + assert "Please enter a password to enter EOS privileged EXEC mode" in result.output + assert result.exit_code == ExitCode.OK + + # enable and prompt N + result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt"], input="N\n") + assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output + assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output + assert result.exit_code == ExitCode.OK + + # enable and enable-password and prompt (redundant) + result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "blah", "--prompt"], input="y\npassword\npassword\n") + assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" not in result.output + assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output + assert result.exit_code == ExitCode.OK + + # enabled-password without enable + result = click_runner.invoke(anta, ["nrfu", "--enable-password", "blah"]) + assert result.exit_code == ExitCode.USAGE_ERROR + assert "Providing a password to access EOS Privileged EXEC mode requires '--enable' option." in result.output + + +def test_anta_enable_alone(click_runner: CliRunner) -> None: + """ + Test that enable can be provided either without enable-password + """ + result = click_runner.invoke(anta, ["nrfu", "--enable"]) + assert result.exit_code == ExitCode.OK + + +def test_disable_cache(click_runner: CliRunner) -> None: + """ + Test that disable_cache is working on inventory + """ + result = click_runner.invoke(anta, ["nrfu", "--disable-cache"]) + stdout_lines = result.stdout.split("\n") + # All caches should be disabled from the inventory + for line in stdout_lines: + if "disable_cache" in line: + assert "True" in line + assert result.exit_code == ExitCode.OK diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 2c52fc609..6f0f6698b 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -5,96 +5,57 @@ Tests for anta.cli.nrfu.commands """ from __future__ import annotations +import json +import re + +from pathlib import Path from click.testing import CliRunner from anta.cli import anta from anta.cli.utils import ExitCode -from tests.lib.utils import default_anta_env - -def test_anta_nrfu(click_runner: CliRunner) -> None: - """ - Test anta nrfu, catalog is given via env - """ - result = click_runner.invoke(anta, ["nrfu"]) - assert result.exit_code == ExitCode.OK - assert "ANTA Inventory contains 3 devices" in result.output - # assert "Tests catalog contains 4 tests" in result.output - print(result.output) +DATA_DIR: Path = Path(__file__).parent.parent.parent.parent.resolve() / "data" -def test_anta_password_required(click_runner: CliRunner) -> None: +def test_anta_nrfu_table(click_runner: CliRunner) -> None: """ - Test that password is provided - """ - env = default_anta_env() - env.pop("ANTA_PASSWORD") - result = click_runner.invoke(anta, ["nrfu"], env=env) - assert result.exit_code == ExitCode.USAGE_ERROR - assert "EOS password needs to be provided by using either the '--password' option or the '--prompt' option." in result.output - - -def test_anta_password(click_runner: CliRunner) -> None: - """ - Test that password can be provided either via --password or --prompt + Test anta nrfu, catalog is given via env """ - env = default_anta_env() - env.pop("ANTA_PASSWORD") - 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) + result = click_runner.invoke(anta, ["nrfu", "table"]) assert result.exit_code == ExitCode.OK + assert "dummy │ VerifyEOSVersion │ success" in result.output -def test_anta_enable_password(click_runner: CliRunner) -> None: +def test_anta_nrfu_text(click_runner: CliRunner) -> None: """ - Test that enable password can be provided either via --enable-password or --prompt + Test anta nrfu, catalog is given via env """ - # Both enable and enable-password - result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "secret"]) - assert result.exit_code == ExitCode.OK - - # enable and prompt y - result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt"], input="y\npassword\npassword\n") - assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output - assert "Please enter a password to enter EOS privileged EXEC mode" in result.output + result = click_runner.invoke(anta, ["nrfu", "text"]) assert result.exit_code == ExitCode.OK + assert "dummy :: VerifyEOSVersion :: SUCCESS" in result.output - # enable and prompt N - result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt"], input="N\n") - assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output - assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output - assert result.exit_code == ExitCode.OK - # enable and enable-password and prompt (redundant) - result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "blah", "--prompt"], input="y\npassword\npassword\n") - assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" not in result.output - assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output - assert result.exit_code == ExitCode.OK - - # enabled-password without enable - result = click_runner.invoke(anta, ["nrfu", "--enable-password", "blah"]) - assert result.exit_code == ExitCode.USAGE_ERROR - assert "Providing a password to access EOS Privileged EXEC mode requires '--enable' option." in result.output - - -def test_anta_enable_alone(click_runner: CliRunner) -> None: +def test_anta_nrfu_json(click_runner: CliRunner) -> None: """ - Test that enable can be provided either without enable-password + Test anta nrfu, catalog is given via env """ - result = click_runner.invoke(anta, ["nrfu", "--enable"]) + result = click_runner.invoke(anta, ["nrfu", "json"]) assert result.exit_code == ExitCode.OK + assert "JSON results of all tests" in result.output + m = re.search(r'\[\n {[\s\S]+ }\n\]', result.output) + assert m is not None + result_list = json.loads(m.group()) + for r in result_list: + if r['name'] == 'dummy': + assert r['test'] == 'VerifyEOSVersion' + assert r['result'] == 'success' -def test_disable_cache(click_runner: CliRunner) -> None: +def test_anta_nrfu_template(click_runner: CliRunner) -> None: """ - Test that disable_cache is working on inventory + Test anta nrfu, catalog is given via env """ - result = click_runner.invoke(anta, ["nrfu", "--disable-cache"]) - stdout_lines = result.stdout.split("\n") - # All caches should be disabled from the inventory - for line in stdout_lines: - if "disable_cache" in line: - assert "True" in line + 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 From 20722e6b7b5a318bb15e40bccf1c0f8f81e6f8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Thu, 30 Nov 2023 17:06:50 +0100 Subject: [PATCH 18/53] linting --- tests/units/cli/nrfu/test_commands.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 6f0f6698b..8f3a03c79 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -5,9 +5,9 @@ Tests for anta.cli.nrfu.commands """ from __future__ import annotations + import json import re - from pathlib import Path from click.testing import CliRunner @@ -43,13 +43,13 @@ def test_anta_nrfu_json(click_runner: CliRunner) -> None: result = click_runner.invoke(anta, ["nrfu", "json"]) assert result.exit_code == ExitCode.OK assert "JSON results of all tests" in result.output - m = re.search(r'\[\n {[\s\S]+ }\n\]', result.output) + m = re.search(r"\[\n {[\s\S]+ }\n\]", result.output) assert m is not None result_list = json.loads(m.group()) for r in result_list: - if r['name'] == 'dummy': - assert r['test'] == 'VerifyEOSVersion' - assert r['result'] == 'success' + if r["name"] == "dummy": + assert r["test"] == "VerifyEOSVersion" + assert r["result"] == "success" def test_anta_nrfu_template(click_runner: CliRunner) -> None: From c4e65a496adcb0044a753430bf972af3f379a4bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Thu, 30 Nov 2023 17:16:10 +0100 Subject: [PATCH 19/53] refactor cli modules --- anta/cli/__init__.py | 52 ++++++-------------------------------- anta/cli/check/__init__.py | 12 +++++++++ anta/cli/debug/__init__.py | 12 +++++++++ anta/cli/exec/__init__.py | 13 ++++++++++ anta/cli/get/__init__.py | 14 ++++++++++ anta/cli/nrfu/__init__.py | 2 +- 6 files changed, 60 insertions(+), 45 deletions(-) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index 09b87c4dd..cd868eb27 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -13,10 +13,10 @@ import click from anta import __version__ -from anta.cli.check import commands as check_commands -from anta.cli.debug import commands as debug_commands -from anta.cli.exec import commands as exec_commands -from anta.cli.get import commands as get_commands +from anta.cli.check import check +from anta.cli.debug import debug +from anta.cli.exec import exec +from anta.cli.get import get from anta.cli.nrfu import nrfu from anta.cli.utils import AliasedGroup from anta.logger import Log, LogLevel, setup_logging @@ -49,49 +49,13 @@ def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> Non setup_logging(log_level, log_file) -@anta.group("check") -def _check() -> None: - """Check commands for building ANTA""" - - -@anta.group("exec") -def _exec() -> None: - """Execute commands to inventory devices""" - - -@anta.group("get") -def _get() -> None: - """Get data from/to ANTA""" - - -@anta.group("debug") -def _debug() -> None: - """Debug commands for building ANTA""" - - anta.add_command(nrfu) - -# Load group commands -# Prefixing with `_` for avoiding the confusion when importing anta.cli.debug.commands as otherwise the debug group has -# a commands attribute. -_check.add_command(check_commands.catalog) -# Inventory cannot be implemented for now as main 'anta' CLI is already parsing it -# _check.add_command(check_commands.inventory) - -_exec.add_command(exec_commands.clear_counters) -_exec.add_command(exec_commands.snapshot) -_exec.add_command(exec_commands.collect_tech_support) - -_get.add_command(get_commands.from_cvp) -_get.add_command(get_commands.from_ansible) -_get.add_command(get_commands.inventory) -_get.add_command(get_commands.tags) - -_debug.add_command(debug_commands.run_cmd) -_debug.add_command(debug_commands.run_template) +anta.add_command(check) +anta.add_command(exec) +anta.add_command(get) +anta.add_command(debug) -# ANTA CLI Execution def cli() -> None: """Entrypoint for pyproject.toml""" anta(obj={}, auto_envvar_prefix="ANTA") # pragma: no cover diff --git a/anta/cli/check/__init__.py b/anta/cli/check/__init__.py index c460d5493..8fec40a1e 100644 --- a/anta/cli/check/__init__.py +++ b/anta/cli/check/__init__.py @@ -1,3 +1,15 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. + +import click + +from anta.cli.check import commands + + +@click.group +def check() -> None: + """Check commands for building ANTA""" + + +check.add_command(commands.catalog) diff --git a/anta/cli/debug/__init__.py b/anta/cli/debug/__init__.py index c460d5493..2953d10a1 100644 --- a/anta/cli/debug/__init__.py +++ b/anta/cli/debug/__init__.py @@ -1,3 +1,15 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +import click + +from anta.cli.debug import commands + + +@click.group +def debug() -> None: + """Debug commands for building ANTA""" + + +debug.add_command(commands.run_cmd) +debug.add_command(commands.run_template) diff --git a/anta/cli/exec/__init__.py b/anta/cli/exec/__init__.py index c460d5493..74b7ef657 100644 --- a/anta/cli/exec/__init__.py +++ b/anta/cli/exec/__init__.py @@ -1,3 +1,16 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +import click + +from anta.cli.exec import commands + + +@click.group +def exec() -> None: + """Execute commands to inventory devices""" + + +exec.add_command(commands.clear_counters) +exec.add_command(commands.snapshot) +exec.add_command(commands.collect_tech_support) diff --git a/anta/cli/get/__init__.py b/anta/cli/get/__init__.py index c460d5493..8774bea5b 100644 --- a/anta/cli/get/__init__.py +++ b/anta/cli/get/__init__.py @@ -1,3 +1,17 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +import click + +from anta.cli.get import commands + + +@click.group +def get() -> None: + """Get data from/to ANTA""" + + +get.add_command(commands.from_cvp) +get.add_command(commands.from_ansible) +get.add_command(commands.inventory) +get.add_command(commands.tags) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 31688f748..ab21a3e09 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -17,7 +17,7 @@ from .utils import anta_progress_bar, print_settings -@click.group("nrfu", invoke_without_command=True) +@click.group(invoke_without_command=True) @click.pass_context @inventory_options @catalog_options From 1434479936ddbe2157a86896bcd44024627db6d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Thu, 30 Nov 2023 18:24:08 +0100 Subject: [PATCH 20/53] refactor stuff --- anta/cli/__init__.py | 2 +- anta/cli/check/commands.py | 4 +- anta/cli/nrfu/__init__.py | 9 +-- anta/cli/nrfu/utils.py | 7 +- anta/cli/utils.py | 90 ++++++++++++-------------- tests/units/cli/check/__init__.py | 3 - tests/units/cli/check/test__init__.py | 30 +++++++++ tests/units/cli/check/test_commands.py | 17 ++--- tests/units/cli/nrfu/test__init__.py | 11 +++- tests/units/cli/test__init__.py | 9 --- 10 files changed, 101 insertions(+), 81 deletions(-) delete mode 100644 tests/units/cli/check/__init__.py create mode 100644 tests/units/cli/check/test__init__.py diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index cd868eb27..f6fb81434 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -58,7 +58,7 @@ def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> Non def cli() -> None: """Entrypoint for pyproject.toml""" - anta(obj={}, auto_envvar_prefix="ANTA") # pragma: no cover + anta(obj={}, auto_envvar_prefix="ANTA") if __name__ == "__main__": diff --git a/anta/cli/check/commands.py b/anta/cli/check/commands.py index 90bd72098..e8fae82a8 100644 --- a/anta/cli/check/commands.py +++ b/anta/cli/check/commands.py @@ -11,15 +11,15 @@ import click from rich.pretty import pretty_repr - from anta.catalog import AntaCatalog + from anta.cli.console import console from anta.cli.utils import catalog_options logger = logging.getLogger(__name__) -@click.command() +@click.command @catalog_options def catalog(catalog: AntaCatalog) -> None: """ diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index ab21a3e09..c57ed7841 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -11,6 +11,8 @@ from anta.cli.nrfu import commands from anta.cli.utils import catalog_options, inventory_options from anta.models import AntaTest +from anta.inventory import AntaInventory +from anta.catalog import AntaCatalog from anta.result_manager import ResultManager from anta.runner import main @@ -23,13 +25,12 @@ @catalog_options @click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False) @click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) -def nrfu(ctx: click.Context, **kwargs: dict[str, Any]) -> None: - # pylint: disable=unused-argument +def nrfu(ctx: click.Context, inventory: AntaInventory, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None: """Run NRFU against inventory devices""" ctx.obj["result_manager"] = ResultManager() - print_settings(ctx) + print_settings(inventory, catalog) with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=ctx.params.get("tags"))) + asyncio.run(main(ctx.obj["result_manager"], inventory, catalog, tags=ctx.params.get("tags"))) # Invoke `anta nrfu table` if no command is passed if ctx.invoked_subcommand is None: ctx.invoke(commands.table) diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index 90962fedf..ae657345f 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -11,7 +11,8 @@ import pathlib import re -import click +from anta.inventory import AntaInventory +from anta.catalog import AntaCatalog import rich from rich.panel import Panel from rich.pretty import pprint @@ -24,9 +25,9 @@ logger = logging.getLogger(__name__) -def print_settings(context: click.Context) -> None: +def print_settings(inventory: AntaInventory, catalog: AntaCatalog,) -> None: """Print ANTA settings before running tests""" - message = f"Running ANTA tests:\n- {context.obj['inventory']}\n- Tests catalog contains {len(context.obj['catalog'].tests)} tests" + message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests" console.print(Panel.fit(message, style="cyan", title="[green]Settings")) console.print() diff --git a/anta/cli/utils.py b/anta/cli/utils.py index dbd46c8d0..bc6cd1fcc 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -181,43 +181,41 @@ def inventory_options(f: Any) -> Any: type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), ) @click.option("--tags", "-t", help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags) + @click.pass_context @functools.wraps(f) - def wrapper_common_options(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: - ctx.ensure_object(dict) - if not ctx.obj.get("_anta_help"): - if ctx.params.get("prompt"): - # User asked for a password prompt - if ctx.params.get("password") is None: - ctx.params["password"] = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True) - if ctx.params.get("enable"): - if ctx.params.get("enable_password") is None: - if click.confirm("Is a password required to enter EOS privileged EXEC mode?"): - ctx.params["enable_password"] = click.prompt( - "Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True - ) + def wrapper(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + if ctx.params.get("prompt"): + # User asked for a password prompt if ctx.params.get("password") is None: - raise click.BadParameter("EOS password needs to be provided by using either the '--password' option or the '--prompt' option.") - if not ctx.params.get("enable") and ctx.params.get("enable_password"): - raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.") - - try: - ctx.obj["inventory"] = AntaInventory.parse( - filename=ctx.params["inventory"], - username=ctx.params["username"], - password=ctx.params["password"], - enable=ctx.params["enable"], - enable_password=ctx.params["enable_password"], - timeout=ctx.params["timeout"], - insecure=ctx.params["insecure"], - disable_cache=ctx.params["disable_cache"], - ) - except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): - ctx.exit(ExitCode.USAGE_ERROR) - else: - ctx.obj["inventory"] = AntaInventory() - return f(ctx, *args, **kwargs) - - return wrapper_common_options + ctx.params["password"] = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True) + if ctx.params.get("enable"): + if ctx.params.get("enable_password") is None: + if click.confirm("Is a password required to enter EOS privileged EXEC mode?"): + ctx.params["enable_password"] = click.prompt( + "Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True + ) + if ctx.params.get("password") is None: + raise click.BadParameter("EOS password needs to be provided by using either the '--password' option or the '--prompt' option.") + if not ctx.params.get("enable") and ctx.params.get("enable_password"): + raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.") + try: + inventory = AntaInventory.parse( + filename=ctx.params["inventory"], + username=ctx.params["username"], + password=ctx.params["password"], + enable=ctx.params["enable"], + enable_password=ctx.params["enable_password"], + timeout=ctx.params["timeout"], + insecure=ctx.params["insecure"], + disable_cache=ctx.params["disable_cache"], + ) + except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): + ctx.exit(ExitCode.USAGE_ERROR) + for arg in ['inventory', 'username', 'password', 'enable', 'prompt', 'timeout', 'insecure', 'enable_password', 'disable_cache', 'tags']: + kwargs.pop(arg) + return f(*args, inventory, **kwargs) + + return wrapper def catalog_options(f: Any) -> Any: @@ -232,16 +230,14 @@ def catalog_options(f: Any) -> Any: type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), required=True, ) + @click.pass_context @functools.wraps(f) - def wrapper_common_options(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: - ctx.ensure_object(dict) - if not ctx.obj.get("_anta_help"): - try: - ctx.obj["catalog"] = AntaCatalog.parse(ctx.params["catalog"]) - except (ValidationError, TypeError, ValueError, YAMLError, OSError): - ctx.exit(ExitCode.USAGE_ERROR) - else: - ctx.obj["catalog"] = AntaCatalog() - return f(ctx, *args, **kwargs) - - return wrapper_common_options + def wrapper(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + try: + catalog = AntaCatalog.parse(ctx.params["catalog"]) + except (ValidationError, TypeError, ValueError, YAMLError, OSError): + ctx.exit(ExitCode.USAGE_ERROR) + kwargs.pop('catalog') + return f(*args, catalog, **kwargs) + + return wrapper diff --git a/tests/units/cli/check/__init__.py b/tests/units/cli/check/__init__.py deleted file mode 100644 index c460d5493..000000000 --- a/tests/units/cli/check/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2023 Arista Networks, Inc. -# Use of this source code is governed by the Apache License 2.0 -# that can be found in the LICENSE file. diff --git a/tests/units/cli/check/test__init__.py b/tests/units/cli/check/test__init__.py new file mode 100644 index 000000000..020b31ff2 --- /dev/null +++ b/tests/units/cli/check/test__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2023 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 for anta.cli.check +""" +from __future__ import annotations + +from click.testing import CliRunner + +from anta.cli import anta +from anta.cli.utils import ExitCode + + +def test_anta_check(click_runner: CliRunner) -> None: + """ + Test anta check + """ + result = click_runner.invoke(anta, ["check"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta check" in result.output + + +def test_anta_check_help(click_runner: CliRunner) -> None: + """ + Test anta check --help + """ + result = click_runner.invoke(anta, ["check", "--help"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta check" in result.output diff --git a/tests/units/cli/check/test_commands.py b/tests/units/cli/check/test_commands.py index 125a319d2..859e91286 100644 --- a/tests/units/cli/check/test_commands.py +++ b/tests/units/cli/check/test_commands.py @@ -10,13 +10,12 @@ from typing import TYPE_CHECKING import pytest +from anta.cli.utils import ExitCode from anta.cli import anta -from tests.lib.utils import default_anta_env if TYPE_CHECKING: from click.testing import CliRunner - from pytest import CaptureFixture DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data" @@ -24,19 +23,15 @@ @pytest.mark.parametrize( "catalog_path, expected_exit, expected_output", [ - pytest.param("ghost_catalog.yml", 2, "Error: Invalid value for '--catalog'", id="catalog does not exist"), - pytest.param("test_catalog_with_undefined_module.yml", 4, "Test catalog is invalid!", id="catalog is not valid"), - pytest.param("test_catalog.yml", 0, f"Catalog {DATA_DIR}/test_catalog.yml is valid", id="catalog valid"), + pytest.param("ghost_catalog.yml", ExitCode.USAGE_ERROR, "Error: Invalid value for '--catalog'", id="catalog does not exist"), + pytest.param("test_catalog_with_undefined_module.yml", ExitCode.USAGE_ERROR, "Test catalog is invalid!", id="catalog is not valid"), + pytest.param("test_catalog.yml", ExitCode.OK, f"Catalog {DATA_DIR}/test_catalog.yml is valid", id="catalog valid"), ], ) -def test_catalog(capsys: CaptureFixture[str], click_runner: CliRunner, catalog_path: Path, expected_exit: int, expected_output: str) -> None: +def test_catalog(click_runner: CliRunner, catalog_path: Path, expected_exit: int, expected_output: str) -> None: """ Test `anta check catalog -c catalog """ - env = default_anta_env() - catalog_full_path = DATA_DIR / catalog_path - cli_args = ["check", "catalog", "-c", str(catalog_full_path)] - with capsys.disabled(): - result = click_runner.invoke(anta, cli_args, env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, ["check", "catalog", "-c", str(DATA_DIR / catalog_path)]) assert result.exit_code == expected_exit assert expected_output in result.output diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py index 07c668ac9..82da67973 100644 --- a/tests/units/cli/nrfu/test__init__.py +++ b/tests/units/cli/nrfu/test__init__.py @@ -2,7 +2,7 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """ -Tests for anta.cli.nrfu.commands +Tests for anta.cli.nrfu """ from __future__ import annotations @@ -13,6 +13,15 @@ from tests.lib.utils import default_anta_env +def test_anta_nrfu_help(click_runner: CliRunner) -> None: + """ + Test anta nrfu --help + """ + result = click_runner.invoke(anta, ["nrfu", "--help"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta nrfu" in result.output + + def test_anta_nrfu(click_runner: CliRunner) -> None: """ Test anta nrfu, catalog is given via env diff --git a/tests/units/cli/test__init__.py b/tests/units/cli/test__init__.py index a216208e3..4ff3907ca 100644 --- a/tests/units/cli/test__init__.py +++ b/tests/units/cli/test__init__.py @@ -31,15 +31,6 @@ def test_anta_help(click_runner: CliRunner) -> None: assert "Usage" in result.output -def test_anta_nrfu_help(click_runner: CliRunner) -> None: - """ - Test anta nrfu --help - """ - result = click_runner.invoke(anta, ["nrfu", "--help"]) - assert result.exit_code == ExitCode.OK - assert "Usage: anta nrfu" in result.output - - def test_anta_exec_help(click_runner: CliRunner) -> None: """ Test anta exec --help From 84e1b1c6cb55c075021157de551cb1027621708d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Thu, 30 Nov 2023 18:29:20 +0100 Subject: [PATCH 21/53] add simple unit tests --- tests/units/cli/check/__init__.py | 3 +++ tests/units/cli/debug/test__init__.py | 30 +++++++++++++++++++++++++++ tests/units/cli/exec/test__init__.py | 30 +++++++++++++++++++++++++++ tests/units/cli/get/test__init__.py | 30 +++++++++++++++++++++++++++ tests/units/cli/nrfu/__init__.py | 3 +++ 5 files changed, 96 insertions(+) create mode 100644 tests/units/cli/check/__init__.py create mode 100644 tests/units/cli/debug/test__init__.py create mode 100644 tests/units/cli/exec/test__init__.py create mode 100644 tests/units/cli/get/test__init__.py create mode 100644 tests/units/cli/nrfu/__init__.py diff --git a/tests/units/cli/check/__init__.py b/tests/units/cli/check/__init__.py new file mode 100644 index 000000000..c460d5493 --- /dev/null +++ b/tests/units/cli/check/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/cli/debug/test__init__.py b/tests/units/cli/debug/test__init__.py new file mode 100644 index 000000000..76f6041c4 --- /dev/null +++ b/tests/units/cli/debug/test__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2023 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 for anta.cli.debug +""" +from __future__ import annotations + +from click.testing import CliRunner + +from anta.cli import anta +from anta.cli.utils import ExitCode + + +def test_anta_debug(click_runner: CliRunner) -> None: + """ + Test anta debug + """ + result = click_runner.invoke(anta, ["debug"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta debug" in result.output + + +def test_anta_debug_help(click_runner: CliRunner) -> None: + """ + Test anta debug --help + """ + result = click_runner.invoke(anta, ["debug", "--help"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta debug" in result.output diff --git a/tests/units/cli/exec/test__init__.py b/tests/units/cli/exec/test__init__.py new file mode 100644 index 000000000..31a66d9f9 --- /dev/null +++ b/tests/units/cli/exec/test__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2023 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 for anta.cli.exec +""" +from __future__ import annotations + +from click.testing import CliRunner + +from anta.cli import anta +from anta.cli.utils import ExitCode + + +def test_anta_exec(click_runner: CliRunner) -> None: + """ + Test anta exec + """ + result = click_runner.invoke(anta, ["exec"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta exec" in result.output + + +def test_anta_exec_help(click_runner: CliRunner) -> None: + """ + Test anta exec --help + """ + result = click_runner.invoke(anta, ["exec", "--help"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta exec" in result.output diff --git a/tests/units/cli/get/test__init__.py b/tests/units/cli/get/test__init__.py new file mode 100644 index 000000000..50915cf6a --- /dev/null +++ b/tests/units/cli/get/test__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2023 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 for anta.cli.get +""" +from __future__ import annotations + +from click.testing import CliRunner + +from anta.cli import anta +from anta.cli.utils import ExitCode + + +def test_anta_get(click_runner: CliRunner) -> None: + """ + Test anta get + """ + result = click_runner.invoke(anta, ["get"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta get" in result.output + + +def test_anta_get_help(click_runner: CliRunner) -> None: + """ + Test anta get --help + """ + result = click_runner.invoke(anta, ["get", "--help"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta get" in result.output diff --git a/tests/units/cli/nrfu/__init__.py b/tests/units/cli/nrfu/__init__.py new file mode 100644 index 000000000..c460d5493 --- /dev/null +++ b/tests/units/cli/nrfu/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. From 58827cdac738d48a8f02da18fe3d1b1d70f2d9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Thu, 30 Nov 2023 18:48:13 +0100 Subject: [PATCH 22/53] update anta.cli.check and anta.cli.debug --- anta/cli/check/commands.py | 2 +- anta/cli/debug/commands.py | 30 ++---------------- anta/cli/debug/utils.py | 42 ++++++++++++++++++++++++++ anta/cli/nrfu/__init__.py | 4 +-- anta/cli/nrfu/utils.py | 9 ++++-- anta/cli/utils.py | 4 +-- tests/units/cli/check/test_commands.py | 4 +-- tests/units/cli/debug/test_commands.py | 31 +------------------ 8 files changed, 59 insertions(+), 67 deletions(-) create mode 100644 anta/cli/debug/utils.py diff --git a/anta/cli/check/commands.py b/anta/cli/check/commands.py index e8fae82a8..0ad281741 100644 --- a/anta/cli/check/commands.py +++ b/anta/cli/check/commands.py @@ -11,8 +11,8 @@ import click from rich.pretty import pretty_repr -from anta.catalog import AntaCatalog +from anta.catalog import AntaCatalog from anta.cli.console import console from anta.cli.utils import catalog_options diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py index 7e9cc41e6..28a919452 100644 --- a/anta/cli/debug/commands.py +++ b/anta/cli/debug/commands.py @@ -13,38 +13,18 @@ from typing import Literal import click -from click import Option from anta.cli.console import console -from anta.cli.utils import inventory_options +from anta.cli.debug.utils import debug_options from anta.device import AntaDevice from anta.models import AntaCommand, AntaTemplate -from anta.tools.misc import anta_log_exception logger = logging.getLogger(__name__) -# pylint: disable-next=inconsistent-return-statements -def get_device(ctx: click.Context, param: Option, value: str) -> list[str]: - """ - Click option callback to get an AntaDevice instance from a string - """ - # pylint: disable=unused-argument - try: - return ctx.obj["inventory"][value] - except KeyError as e: - message = f"Device {value} does not exist in Inventory" - anta_log_exception(e, message, logger) - ctx.fail(message) - - @click.command(no_args_is_help=True) -@inventory_options +@debug_options @click.option("--command", "-c", type=str, required=True, help="Command to run") -@click.option("--ofmt", type=click.Choice(["json", "text"]), default="json", help="EOS eAPI format to use. can be text or json") -@click.option("--version", "-v", type=click.Choice(["1", "latest"]), default="latest", help="EOS eAPI version") -@click.option("--revision", "-r", type=int, help="eAPI command revision", required=False) -@click.option("--device", "-d", type=str, required=True, help="Device from inventory to use", callback=get_device) def run_cmd(command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int, device: AntaDevice) -> None: """Run arbitrary command to an ANTA device""" console.print(f"Run command [green]{command}[/green] on [red]{device.name}[/red]") @@ -62,12 +42,8 @@ def run_cmd(command: str, ofmt: Literal["json", "text"], version: Literal["1", " @click.command(no_args_is_help=True) -@inventory_options +@debug_options @click.option("--template", "-t", type=str, required=True, help="Command template to run. E.g. 'show vlan {vlan_id}'") -@click.option("--ofmt", type=click.Choice(["json", "text"]), default="json", help="EOS eAPI format to use. can be text or json") -@click.option("--version", "-v", type=click.Choice(["1", "latest"]), default="latest", help="EOS eAPI version") -@click.option("--revision", "-r", type=int, help="eAPI command revision", required=False) -@click.option("--device", "-d", type=str, required=True, help="Device from inventory to use", callback=get_device) @click.argument("params", required=True, nargs=-1) def run_template(template: str, params: list[str], ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int, device: AntaDevice) -> None: # pylint: disable=too-many-arguments diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py new file mode 100644 index 000000000..964114310 --- /dev/null +++ b/anta/cli/debug/utils.py @@ -0,0 +1,42 @@ +#!/usr/bin/python +# Copyright (c) 2023 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# coding: utf-8 -*- +""" +Utils functions to use with anta.cli.debug module. +""" +from __future__ import annotations + +import functools +import logging +from typing import Any + +import click + +from anta.cli.utils import ExitCode, inventory_options +from anta.inventory import AntaInventory + +logger = logging.getLogger(__name__) + + +def debug_options(f: Any) -> Any: + """Click common options when requiring a test catalog to execute ANTA tests""" + + @inventory_options + @click.option("--ofmt", type=click.Choice(["json", "text"]), default="json", help="EOS eAPI format to use. can be text or json") + @click.option("--version", "-v", type=click.Choice(["1", "latest"]), default="latest", help="EOS eAPI version") + @click.option("--revision", "-r", type=int, help="eAPI command revision", required=False) + @click.option("--device", "-d", type=str, required=True, help="Device from inventory to use") + @click.pass_context + @functools.wraps(f) + def wrapper(ctx: click.Context, inventory: AntaInventory, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + try: + kwargs["device"] = inventory[ctx.params["device"]] + except KeyError as e: + message = f"Device {ctx.params['device']} does not exist in Inventory" + logger.error(e, message) + ctx.exit(ExitCode.USAGE_ERROR) + return f(*args, **kwargs) + + return wrapper diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index c57ed7841..a48890209 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -8,11 +8,11 @@ import click +from anta.catalog import AntaCatalog from anta.cli.nrfu import commands from anta.cli.utils import catalog_options, inventory_options -from anta.models import AntaTest from anta.inventory import AntaInventory -from anta.catalog import AntaCatalog +from anta.models import AntaTest from anta.result_manager import ResultManager from anta.runner import main diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index ae657345f..0b21098d7 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -11,21 +11,24 @@ import pathlib import re -from anta.inventory import AntaInventory -from anta.catalog import AntaCatalog import rich from rich.panel import Panel from rich.pretty import pprint from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn +from anta.catalog import AntaCatalog from anta.cli.console import console +from anta.inventory import AntaInventory from anta.reporter import ReportJinja, ReportTable from anta.result_manager import ResultManager logger = logging.getLogger(__name__) -def print_settings(inventory: AntaInventory, catalog: AntaCatalog,) -> None: +def print_settings( + inventory: AntaInventory, + catalog: AntaCatalog, +) -> None: """Print ANTA settings before running tests""" message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests" console.print(Panel.fit(message, style="cyan", title="[green]Settings")) diff --git a/anta/cli/utils.py b/anta/cli/utils.py index bc6cd1fcc..9bb37d2a6 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -211,7 +211,7 @@ def wrapper(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> ) except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): ctx.exit(ExitCode.USAGE_ERROR) - for arg in ['inventory', 'username', 'password', 'enable', 'prompt', 'timeout', 'insecure', 'enable_password', 'disable_cache', 'tags']: + for arg in ["inventory", "username", "password", "enable", "prompt", "timeout", "insecure", "enable_password", "disable_cache", "tags"]: kwargs.pop(arg) return f(*args, inventory, **kwargs) @@ -237,7 +237,7 @@ def wrapper(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> catalog = AntaCatalog.parse(ctx.params["catalog"]) except (ValidationError, TypeError, ValueError, YAMLError, OSError): ctx.exit(ExitCode.USAGE_ERROR) - kwargs.pop('catalog') + kwargs.pop("catalog") return f(*args, catalog, **kwargs) return wrapper diff --git a/tests/units/cli/check/test_commands.py b/tests/units/cli/check/test_commands.py index 859e91286..f18d53ad6 100644 --- a/tests/units/cli/check/test_commands.py +++ b/tests/units/cli/check/test_commands.py @@ -10,9 +10,9 @@ from typing import TYPE_CHECKING import pytest -from anta.cli.utils import ExitCode from anta.cli import anta +from anta.cli.utils import ExitCode if TYPE_CHECKING: from click.testing import CliRunner @@ -24,7 +24,7 @@ "catalog_path, expected_exit, expected_output", [ pytest.param("ghost_catalog.yml", ExitCode.USAGE_ERROR, "Error: Invalid value for '--catalog'", id="catalog does not exist"), - pytest.param("test_catalog_with_undefined_module.yml", ExitCode.USAGE_ERROR, "Test catalog is invalid!", id="catalog is not valid"), + pytest.param("test_catalog_with_undefined_module.yml", ExitCode.USAGE_ERROR, "Test catalog is invalid!", id="catalog is not valid"), pytest.param("test_catalog.yml", ExitCode.OK, f"Catalog {DATA_DIR}/test_catalog.yml is valid", id="catalog valid"), ], ) diff --git a/tests/units/cli/debug/test_commands.py b/tests/units/cli/debug/test_commands.py index 2f96bc054..e6714f766 100644 --- a/tests/units/cli/debug/test_commands.py +++ b/tests/units/cli/debug/test_commands.py @@ -6,47 +6,18 @@ """ from __future__ import annotations -from contextlib import nullcontext from typing import TYPE_CHECKING, Any, Literal -from unittest.mock import MagicMock, patch +from unittest.mock import patch -import click import pytest from anta.cli import anta -from anta.cli.debug.commands import get_device -from anta.device import AntaDevice from anta.models import AntaCommand from tests.lib.utils import default_anta_env if TYPE_CHECKING: from click.testing import CliRunner - from anta.inventory import AntaInventory - - -@pytest.mark.parametrize( - "device_name, expected_raise", - [ - pytest.param("dummy", nullcontext(), id="existing device"), - pytest.param("other", pytest.raises(click.exceptions.UsageError), id="non existing device"), - ], -) -def test_get_device(test_inventory: AntaInventory, device_name: str, expected_raise: Any) -> None: - """ - Test get_device - - test_inventory is a fixture that returns an AntaInventory using the content of tests/data/test_inventory.yml - """ - # build click Context - ctx = click.Context(command=MagicMock()) - ctx.ensure_object(dict) - ctx.obj["inventory"] = test_inventory - - with expected_raise: - result = get_device(ctx, MagicMock(auto_spec=click.Option), device_name) - assert isinstance(result, AntaDevice) - # TODO complete test cases @pytest.mark.parametrize( From 7d6df42279c90af516081f0d3008bdd4ba171c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Thu, 30 Nov 2023 19:21:20 +0100 Subject: [PATCH 23/53] refactor stuff --- anta/cli/exec/commands.py | 18 +++--- anta/cli/nrfu/__init__.py | 4 +- anta/cli/utils.py | 2 +- tests/lib/fixture.py | 6 +- tests/units/cli/exec/test_commands.py | 82 +++++++++------------------ 5 files changed, 43 insertions(+), 69 deletions(-) diff --git a/anta/cli/exec/commands.py b/anta/cli/exec/commands.py index f5e8a9571..99403119b 100644 --- a/anta/cli/exec/commands.py +++ b/anta/cli/exec/commands.py @@ -17,20 +17,19 @@ from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech from anta.cli.utils import inventory_options +from anta.inventory import AntaInventory logger = logging.getLogger(__name__) -@click.command(no_args_is_help=True) -@click.pass_context +@click.command @inventory_options -def clear_counters(ctx: click.Context, tags: list[str] | None) -> None: +def clear_counters(inventory: AntaInventory, tags: list[str] | None) -> None: """Clear counter statistics on EOS devices""" - asyncio.run(clear_counters_utils(ctx.obj["inventory"], tags=tags)) + asyncio.run(clear_counters_utils(inventory, tags=tags)) @click.command() -@click.pass_context @inventory_options @click.option( "--commands-list", @@ -49,7 +48,7 @@ def clear_counters(ctx: click.Context, tags: list[str] | None) -> None: default=f"anta_snapshot_{datetime.now().strftime('%Y-%m-%d_%H_%M_%S')}", show_default=True, ) -def snapshot(ctx: click.Context, tags: list[str] | None, commands_list: Path, output: Path) -> None: +def snapshot(inventory: AntaInventory, tags: list[str] | None, commands_list: Path, output: Path) -> None: """Collect commands output from devices in inventory""" print(f"Collecting data for {commands_list}") print(f"Output directory is {output}") @@ -60,11 +59,10 @@ def snapshot(ctx: click.Context, tags: list[str] | None, commands_list: Path, ou except FileNotFoundError: logger.error(f"Error reading {commands_list}") sys.exit(1) - asyncio.run(collect_commands(ctx.obj["inventory"], eos_commands, output, tags=tags)) + asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags)) @click.command() -@click.pass_context @inventory_options @click.option("--output", "-o", default="./tech-support", show_default=True, help="Path for test catalog", type=click.Path(path_type=Path), required=False) @click.option("--latest", help="Number of scheduled show-tech to retrieve", type=int, required=False) @@ -75,6 +73,6 @@ def snapshot(ctx: click.Context, tags: list[str] | None, commands_list: Path, ou is_flag=True, show_default=True, ) -def collect_tech_support(ctx: click.Context, tags: list[str] | None, output: Path, latest: int | None, configure: bool) -> None: +def collect_tech_support(inventory: AntaInventory, tags: list[str] | None, output: Path, latest: int | None, configure: bool) -> None: """Collect scheduled tech-support from EOS devices""" - asyncio.run(collect_scheduled_show_tech(ctx.obj["inventory"], output, configure, tags=tags, latest=latest)) + asyncio.run(collect_scheduled_show_tech(inventory, output, configure, tags=tags, latest=latest)) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index a48890209..06f749ce7 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -25,12 +25,12 @@ @catalog_options @click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False) @click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) -def nrfu(ctx: click.Context, inventory: AntaInventory, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None: +def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None: """Run NRFU against inventory devices""" ctx.obj["result_manager"] = ResultManager() print_settings(inventory, catalog) with anta_progress_bar() as AntaTest.progress: - asyncio.run(main(ctx.obj["result_manager"], inventory, catalog, tags=ctx.params.get("tags"))) + asyncio.run(main(ctx.obj["result_manager"], inventory, catalog, tags=tags)) # Invoke `anta nrfu table` if no command is passed if ctx.invoked_subcommand is None: ctx.invoke(commands.table) diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 9bb37d2a6..9acd6b846 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -213,7 +213,7 @@ def wrapper(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> ctx.exit(ExitCode.USAGE_ERROR) for arg in ["inventory", "username", "password", "enable", "prompt", "timeout", "insecure", "enable_password", "disable_cache", "tags"]: kwargs.pop(arg) - return f(*args, inventory, **kwargs) + return f(*args, inventory, ctx.params["tags"], **kwargs) return wrapper diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 573d5cbdb..9cb6ecf5e 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -31,6 +31,8 @@ "version": "4.31.1F", }, "enable": {}, + "clear counters": {}, + "clear hardware counter drop": {}, } @@ -174,7 +176,9 @@ def get_output(command: str | dict[str, Any]) -> dict[str, Any]: if command == mock_cmd: logger.info(f"Mocking command {mock_cmd}") return output - raise NotImplementedError(f"Command '{command}' is not mocked") + message = f"Command '{command}' is not mocked" + logger.critical(message) + raise NotImplementedError(message) # pylint: disable=unused-argument if ofmt != "json": diff --git a/tests/units/cli/exec/test_commands.py b/tests/units/cli/exec/test_commands.py index 01c0d014e..ed69b68f6 100644 --- a/tests/units/cli/exec/test_commands.py +++ b/tests/units/cli/exec/test_commands.py @@ -15,6 +15,7 @@ from anta.cli import anta from anta.cli.exec.commands import clear_counters, collect_tech_support, snapshot +from anta.cli.utils import ExitCode from tests.lib.utils import default_anta_env if TYPE_CHECKING: @@ -30,6 +31,24 @@ def test_clear_counters_help(click_runner: CliRunner) -> None: assert "Usage" in result.output +def test_snapshot_help(click_runner: CliRunner) -> None: + """ + Test `anta exec snapshot --help` + """ + result = click_runner.invoke(snapshot, ["--help"]) + assert result.exit_code == 0 + assert "Usage" in result.output + + +def test_collect_tech_support_help(click_runner: CliRunner) -> None: + """ + Test `anta exec collect-tech-support --help` + """ + result = click_runner.invoke(collect_tech_support, ["--help"]) + assert result.exit_code == 0 + assert "Usage" in result.output + + @pytest.mark.parametrize( "tags", [ @@ -41,26 +60,11 @@ def test_clear_counters(click_runner: CliRunner, tags: str | None) -> None: """ Test `anta exec clear-counters` """ - env = default_anta_env() cli_args = ["exec", "clear-counters"] - expected_tags = None if tags is not None: cli_args.extend(["--tags", tags]) - expected_tags = tags.split(",") - with patch("anta.cli.exec.commands.clear_counters_utils") as mocked_subcommand: - mocked_subcommand.return_value = None - result = click_runner.invoke(anta, cli_args, env=env, auto_envvar_prefix="ANTA") - mocked_subcommand.assert_called_once_with(ANY, tags=expected_tags) - assert result.exit_code == 0 - - -def test_snapshot_help(click_runner: CliRunner) -> None: - """ - Test `anta exec snapshot --help` - """ - result = click_runner.invoke(snapshot, ["--help"]) - assert result.exit_code == 0 - assert "Usage" in result.output + result = click_runner.invoke(anta, cli_args) + assert result.exit_code == ExitCode.OK COMMAND_LIST_PATH_FILE = Path(__file__).parent.parent.parent.parent / "data" / "test_snapshot_commands.yml" @@ -80,48 +84,24 @@ def test_snapshot(click_runner: CliRunner, output: str | None, commands_path: Pa """ Test `anta exec snapshot` """ - env = default_anta_env() cli_args = ["exec", "snapshot"] # Need to mock datetetime - expected_path = Path("") if output is not None: cli_args.extend(["--output", output]) - expected_path = Path(f"{output}") - expected_commands = None if commands_path is not None: cli_args.extend(["--commands-list", str(commands_path)]) - expected_commands = {"json_format": ["show version"], "text_format": ["show version"]} - expected_tags = None if tags is not None: cli_args.extend(["--tags", tags]) - expected_tags = tags.split(",") - with patch("anta.cli.exec.commands.collect_commands") as mocked_subcommand: - mocked_subcommand.return_value = None - result = click_runner.invoke(anta, cli_args, env=env, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, cli_args) # Failure scenarios if commands_path is None: - assert result.exit_code == 2 + assert result.exit_code == ExitCode.USAGE_ERROR return if not Path.exists(Path(commands_path)): - assert result.exit_code == 2 + assert result.exit_code == ExitCode.USAGE_ERROR return - # Successful scenarios - if output is not None: - mocked_subcommand.assert_called_once_with(ANY, expected_commands, expected_path, tags=expected_tags) - else: - mocked_subcommand.assert_called_once_with(ANY, expected_commands, ANY, tags=expected_tags) - # TODO should add check that path starts with "anta_snapshot_" - assert result.exit_code == 0 - - -def test_collect_tech_support_help(click_runner: CliRunner) -> None: - """ - Test `anta exec collect-tech-support --help` - """ - result = click_runner.invoke(collect_tech_support, ["--help"]) - assert result.exit_code == 0 - assert "Usage" in result.output + assert result.exit_code == ExitCode.OK @pytest.mark.parametrize( @@ -138,22 +118,14 @@ def test_collect_tech_support(click_runner: CliRunner, output: str | None, lates """ Test `anta exec collect-tech-support` """ - env = default_anta_env() cli_args = ["exec", "collect-tech-support"] - expected_path = Path("tech-support") if output is not None: cli_args.extend(["--output", output]) - expected_path = Path(output) if latest is not None: cli_args.extend(["--latest", latest]) if configure is True: cli_args.extend(["--configure"]) - expected_tags = None if tags is not None: cli_args.extend(["--tags", tags]) - expected_tags = tags.split(",") - with patch("anta.cli.exec.commands.collect_scheduled_show_tech") as mocked_subcommand: - mocked_subcommand.return_value = None - result = click_runner.invoke(anta, cli_args, env=env, auto_envvar_prefix="ANTA") - mocked_subcommand.assert_called_once_with(ANY, expected_path, configure, tags=expected_tags, latest=latest) - assert result.exit_code == 0 + result = click_runner.invoke(anta, cli_args) + assert result.exit_code == ExitCode.OK From 7299374fa550ee58505775de9f1376375e4c93c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Fri, 1 Dec 2023 13:11:13 +0100 Subject: [PATCH 24/53] update cli unit tests --- anta/cli/__init__.py | 20 +-- anta/cli/debug/commands.py | 14 +- anta/cli/debug/utils.py | 4 +- anta/cli/get/commands.py | 138 ++++------------ anta/cli/get/utils.py | 63 +++++-- anta/cli/nrfu/__init__.py | 1 - anta/cli/utils.py | 9 +- tests/lib/fixture.py | 32 +++- tests/lib/utils.py | 2 +- tests/units/cli/debug/test_commands.py | 66 +------- tests/units/cli/exec/test_commands.py | 18 +- tests/units/cli/get/test_commands.py | 218 ++++++++----------------- tests/units/cli/get/test_utils.py | 11 +- tests/units/cli/nrfu/test__init__.py | 2 + 14 files changed, 226 insertions(+), 372 deletions(-) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index f6fb81434..19ca23b63 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -13,11 +13,11 @@ import click from anta import __version__ -from anta.cli.check import check -from anta.cli.debug import debug -from anta.cli.exec import exec -from anta.cli.get import get -from anta.cli.nrfu import nrfu +from anta.cli.check import check as check_command +from anta.cli.debug import debug as debug_command +from anta.cli.exec import exec as exec_command +from anta.cli.get import get as get_command +from anta.cli.nrfu import nrfu as nrfu_command from anta.cli.utils import AliasedGroup from anta.logger import Log, LogLevel, setup_logging @@ -49,11 +49,11 @@ def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> Non setup_logging(log_level, log_file) -anta.add_command(nrfu) -anta.add_command(check) -anta.add_command(exec) -anta.add_command(get) -anta.add_command(debug) +anta.add_command(nrfu_command) +anta.add_command(check_command) +anta.add_command(exec_command) +anta.add_command(get_command) +anta.add_command(debug_command) def cli() -> None: diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py index 28a919452..901d9655e 100644 --- a/anta/cli/debug/commands.py +++ b/anta/cli/debug/commands.py @@ -9,13 +9,13 @@ import asyncio import logging -import sys from typing import Literal import click from anta.cli.console import console from anta.cli.debug.utils import debug_options +from anta.cli.utils import ExitCode from anta.device import AntaDevice from anta.models import AntaCommand, AntaTemplate @@ -24,8 +24,9 @@ @click.command(no_args_is_help=True) @debug_options +@click.pass_context @click.option("--command", "-c", type=str, required=True, help="Command to run") -def run_cmd(command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int, device: AntaDevice) -> None: +def run_cmd(ctx: click.Context, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int, device: AntaDevice) -> None: """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 @@ -34,7 +35,7 @@ def run_cmd(command: str, ofmt: Literal["json", "text"], version: Literal["1", " asyncio.run(device.collect(c)) if not c.collected: console.print(f"[bold red] Command '{c.command}' failed to execute!") - sys.exit(1) + ctx.exit(ExitCode.USAGE_ERROR) elif ofmt == "json": console.print(c.json_output) elif ofmt == "text": @@ -43,9 +44,12 @@ def run_cmd(command: str, ofmt: Literal["json", "text"], version: Literal["1", " @click.command(no_args_is_help=True) @debug_options +@click.pass_context @click.option("--template", "-t", type=str, required=True, help="Command template to run. E.g. 'show vlan {vlan_id}'") @click.argument("params", required=True, nargs=-1) -def run_template(template: str, params: list[str], ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int, device: AntaDevice) -> None: +def run_template( + ctx: click.Context, template: str, params: list[str], ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int, device: AntaDevice +) -> None: # pylint: disable=too-many-arguments """Run arbitrary templated command to an ANTA device. @@ -64,7 +68,7 @@ def run_template(template: str, params: list[str], ofmt: Literal["json", "text"] asyncio.run(device.collect(c)) if not c.collected: console.print(f"[bold red] Command '{c.command}' failed to execute!") - sys.exit(1) + ctx.exit(ExitCode.USAGE_ERROR) elif ofmt == "json": console.print(c.json_output) elif ofmt == "text": diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py index 964114310..db0813900 100644 --- a/anta/cli/debug/utils.py +++ b/anta/cli/debug/utils.py @@ -21,7 +21,7 @@ def debug_options(f: Any) -> Any: - """Click common options when requiring a test catalog to execute ANTA tests""" + """Click common options required to execute a command on a specific device""" @inventory_options @click.option("--ofmt", type=click.Choice(["json", "text"]), default="json", help="EOS eAPI format to use. can be text or json") @@ -30,7 +30,7 @@ def debug_options(f: Any) -> Any: @click.option("--device", "-d", type=str, required=True, help="Device from inventory to use") @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, inventory: AntaInventory, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + def wrapper(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: try: kwargs["device"] = inventory[ctx.params["device"]] except KeyError as e: diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index 382d20be0..475591101 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -10,10 +10,7 @@ import asyncio import json import logging -import sys from pathlib import Path -from sys import stdin -from typing import Optional import click from cvprac.cvp_client import CvpClient @@ -21,7 +18,9 @@ from rich.pretty import pretty_repr from anta.cli.console import console +from anta.cli.get.utils import inventory_output_options from anta.cli.utils import ExitCode, inventory_options +from anta.inventory import AntaInventory from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token @@ -30,160 +29,85 @@ @click.command(no_args_is_help=True) @click.pass_context -@click.option("--cvp-ip", "-ip", default=None, help="CVP IP Address", type=str, required=True) -@click.option("--cvp-username", "-u", default=None, help="CVP Username", type=str, required=True) -@click.option("--cvp-password", "-p", default=None, help="CVP Password / token", type=str, required=True) -@click.option("--cvp-container", "-c", default=None, help="Container where devices are configured", type=str, required=False) -@click.option( - "--output", - "-o", - required=True, - envvar="ANTA_INVENTORY", - show_envvar=True, - help="Path to save inventory file. If not configured, use anta inventory file", - type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=Path), -) -@click.option( - "--overwrite", - help="Confirm script can overwrite existing inventory file", - default=False, - is_flag=True, - show_default=True, - required=False, - show_envvar=True, -) -def from_cvp(ctx: click.Context, output: Path, cvp_ip: str, cvp_username: str, cvp_password: str, cvp_container: str, overwrite: bool) -> None: +@inventory_output_options +@click.option("--host", "-host", help="CloudVision instance FQDN or IP", type=str, required=True) +@click.option("--username", "-u", help="CloudVision username", type=str, required=True) +@click.option("--password", "-p", help="CloudVision password", type=str, required=True) +@click.option("--container", "-c", help="CloudVision container where devices are configured", type=str) +def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str) -> None: """ Build ANTA inventory from Cloudvision TODO - handle get_inventory and get_devices_in_container failure """ - # It is CLI we don't care - # pylint: disable=too-many-arguments - _handle_overwrite(ctx, output, overwrite) - - # pylint: disable=too-many-arguments - logger.info(f"Getting auth token from {cvp_ip} for user {cvp_username}") - token = get_cv_token(cvp_ip=cvp_ip, cvp_username=cvp_username, cvp_password=cvp_password) + logger.info(f"Getting authentication token for user '{username}' from CloudVision instance '{host}'") + token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password) clnt = CvpClient() - print(clnt) try: - clnt.connect(nodes=[cvp_ip], username="", password="", api_token=token) + clnt.connect(nodes=[host], username="", password="", api_token=token) except CvpApiError as error: - logger.error(f"Error connecting to cvp: {error}") - sys.exit(1) - logger.info(f"Connected to CVP {cvp_ip}") + logger.error(f"Error connecting to CloudVision: {error}") + ctx.exit(ExitCode.USAGE_ERROR) + logger.info(f"Connected to CloudVision instance '{host}'") cvp_inventory = None - if cvp_container is None: + if container is None: # Get a list of all devices - logger.info(f"Getting full inventory from {cvp_ip}") + logger.info(f"Getting full inventory from CloudVision instance '{host}'") cvp_inventory = clnt.api.get_inventory() else: # Get devices under a container - logger.info(f"Getting inventory for container {cvp_container} from {cvp_ip}") - cvp_inventory = clnt.api.get_devices_in_container(cvp_container) - create_inventory_from_cvp(cvp_inventory, output, cvp_container) + logger.info(f"Getting inventory for container {container} from CloudVision instance '{host}'") + cvp_inventory = clnt.api.get_devices_in_container(container) + create_inventory_from_cvp(cvp_inventory, output, container) @click.command(no_args_is_help=True) @click.pass_context -@click.option("--ansible-group", "-g", help="Ansible group to filter", type=str, required=False, default="all") +@inventory_output_options +@click.option("--ansible-group", "-g", help="Ansible group to filter", type=str, default="all") @click.option( "--ansible-inventory", - default=None, help="Path to your ansible inventory file to read", type=click.Path(file_okay=True, dir_okay=False, exists=True, path_type=Path), ) -@click.option( - "--output", - "-o", - required=True, - envvar="ANTA_INVENTORY", - show_envvar=True, - help="Path to save inventory file. If not configured, use anta inventory file", - type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=Path), -) -@click.option( - "--overwrite", - help="Confirm script can overwrite existing inventory file", - default=False, - is_flag=True, - show_default=True, - required=False, - show_envvar=True, -) -def from_ansible(ctx: click.Context, output: Path, ansible_inventory: Path, ansible_group: str, overwrite: bool) -> None: - # pylint: disable=too-many-arguments +def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None: """Build ANTA inventory from an ansible inventory YAML file""" - logger.info(f"Building inventory from ansible file {ansible_inventory}") - - _handle_overwrite(ctx, output, overwrite) - - output.parent.mkdir(parents=True, exist_ok=True) - logger.info(f"output anta inventory is: {output}") + logger.info(f"Building inventory from ansible file '{ansible_inventory}'") try: create_inventory_from_ansible( inventory=ansible_inventory, - output_file=output, + output=output, ansible_group=ansible_group, ) except ValueError as e: logger.error(str(e)) ctx.exit(ExitCode.USAGE_ERROR) - ctx.exit(ExitCode.OK) - -def _handle_overwrite(ctx: click.Context, output: Path, overwrite: bool) -> None: - """ - When writing to a file, verify that it is empty, if not: - * if a tty is present, prompt user for confirmation - * else fail miserably - If prompted and the answer is No, using click Abort mechanism - """ - # Boolean to check if the file is empty - output_is_not_empty = output.exists() and output.stat().st_size != 0 - logger.debug(f"output: {output} - overwrite: {overwrite}") - - # Check overwrite when file is not empty - if not overwrite and output_is_not_empty: - is_tty = stdin.isatty() - logger.debug(f"Overwrite is not set and is a tty {is_tty}") - if is_tty: - # File has content and it is in an interactive TTY --> Prompt user - click.confirm(f"Your destination file '{output}' is not empty, continue?", abort=True) - else: - # File has content and it is not interactive TTY nor overwrite set to True --> execution stop - logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)") - ctx.exit(ExitCode.USAGE_ERROR) - - -@click.command() -@click.pass_context +@click.command @inventory_options @click.option("--connected/--not-connected", help="Display inventory after connection has been created", default=False, required=False) -def inventory(ctx: click.Context, tags: Optional[list[str]], connected: bool) -> None: +def inventory(inventory: AntaInventory, tags: list[str] | None, connected: bool) -> None: """Show inventory loaded in ANTA.""" logger.debug(f"Requesting devices for tags: {tags}") console.print("Current inventory content is:", style="white on blue") if connected: - asyncio.run(ctx.obj["inventory"].connect_inventory()) + asyncio.run(inventory.connect_inventory()) - inventory_result = ctx.obj["inventory"].get_inventory(tags=tags) + inventory_result = inventory.get_inventory(tags=tags) console.print(pretty_repr(inventory_result)) -@click.command() +@click.command @inventory_options -@click.pass_context -def tags(ctx: click.Context) -> None: +def tags(inventory: AntaInventory, tags: list[str] | None) -> None: """Get list of configured tags in user inventory.""" tags_found = [] - for device in ctx.obj["inventory"].values(): + for device in inventory.values(): tags_found += device.tags tags_found = sorted(set(tags_found)) console.print("Tags found:") diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py index e4e028130..8c472038b 100644 --- a/anta/cli/get/utils.py +++ b/anta/cli/get/utils.py @@ -6,15 +6,19 @@ """ from __future__ import annotations +import functools import json import logging from pathlib import Path +from sys import stdin from typing import Any +import click import requests import urllib3 import yaml +from anta.cli.utils import ExitCode from anta.inventory import AntaInventory from anta.inventory.models import AntaInventoryHost, AntaInventoryInput @@ -23,6 +27,48 @@ logger = logging.getLogger(__name__) +def inventory_output_options(f: Any) -> Any: + """Click common options required when an inventory is being generated""" + + @click.option( + "--output", + "-o", + required=True, + envvar="ANTA_INVENTORY", + show_envvar=True, + help="Path to save inventory file", + type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=Path), + ) + @click.option( + "--overwrite", + help="Do not prompt when overriding current inventory", + default=False, + is_flag=True, + show_default=True, + required=False, + show_envvar=True, + ) + @click.pass_context + @functools.wraps(f) + def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool, **kwargs: dict[str, Any]) -> Any: + # Boolean to check if the file is empty + output_is_not_empty = output.exists() and output.stat().st_size != 0 + # Check overwrite when file is not empty + if not overwrite and output_is_not_empty: + is_tty = stdin.isatty() + if is_tty: + # File has content and it is in an interactive TTY --> Prompt user + click.confirm(f"Your destination file '{output}' is not empty, continue?", abort=True) + else: + # File has content and it is not interactive TTY nor overwrite set to True --> execution stop + logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)") + ctx.exit(ExitCode.USAGE_ERROR) + output.parent.mkdir(parents=True, exist_ok=True) + return f(*args, output, **kwargs) + + return wrapper + + def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str: """Generate AUTH token from CVP using password""" # TODO, need to handle requests eror @@ -46,22 +92,19 @@ def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path, container logger.info(f' * adding entry for {dev["hostname"]}') i[AntaInventory.INVENTORY_ROOT_KEY]["hosts"].append({"host": dev["ipAddress"], "name": dev["hostname"], "tags": [dev["containerName"].lower()]}) # write the devices IP address in a file - # TODO fixme - inv_file = "inventory" if container is None else f"inventory-{container}" - out_file = f"{output}/{inv_file}.yml" - with open(out_file, "w", encoding="UTF-8") as out_fd: + with open(output, "w", encoding="UTF-8") as out_fd: out_fd.write(yaml.dump(i)) - logger.info(f"Inventory file has been created in {out_file}") + logger.info(f"ANTA inventory file has been created: '{output}'") -def create_inventory_from_ansible(inventory: Path, output_file: Path, ansible_group: str = "all") -> None: +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: inventory: Ansible Inventory file to read - output_file: ANTA inventory file to generate. - ansible_root: Ansible group from where to extract data. + output: ANTA inventory file to generate. + ansible_group: Ansible group from where to extract data. """ def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None: @@ -104,6 +147,6 @@ def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | Non ansible_hosts = deep_yaml_parsing(ansible_inventory) i = AntaInventoryInput(hosts=ansible_hosts) # TODO, catch issue - with open(output_file, "w", encoding="UTF-8") as out_fd: + with open(output, "w", encoding="UTF-8") as out_fd: out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)})) - logger.info(f"ANTA device inventory file has been created in {output_file}") + logger.info(f"ANTA inventory file has been created: '{output}'") diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 06f749ce7..760b1223e 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio -from typing import Any import click diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 9acd6b846..7b36ca436 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -36,14 +36,14 @@ class ExitCode(enum.IntEnum): # Tests passed. OK = 0 - #: Tests failed. - TESTS_FAILED = 1 + # An internal error got in the way. + INTERNAL_ERROR = 1 # CLI was misused USAGE_ERROR = 2 # Test error TESTS_ERROR = 3 - # An internal error got in the way. - INTERNAL_ERROR = 4 + # Tests failed + TESTS_FAILED = 4 def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | None: @@ -116,6 +116,7 @@ def resolve_command(self, ctx: click.Context, args: Any) -> Any: return cmd.name, cmd, args # type: ignore +# TODO: check code of click.pass_context that raise mypy errors for types and adapt this decorator def inventory_options(f: Any) -> Any: """Click common options when requiring an inventory to interact with devices""" diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 9cb6ecf5e..534edd43b 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -8,6 +8,7 @@ from typing import Any, Callable, Iterator from unittest.mock import patch +import aioeapi import pytest from click.testing import CliRunner, Result from pytest import CaptureFixture @@ -25,7 +26,7 @@ DEVICE_NAME = "pytest" COMMAND_OUTPUT = "retrieved" -MOCK_CLI: dict[str, dict[str, Any]] = { +MOCK_CLI_JSON: dict[str, dict[str, Any]] = { "show version": { "modelName": "DCS-7280CR3-32P4-F", "version": "4.31.1F", @@ -33,6 +34,16 @@ "enable": {}, "clear counters": {}, "clear hardware counter drop": {}, + "undefined": aioeapi.EapiCommandError( + passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[] + ), +} + +MOCK_CLI_TEXT: dict[str, 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", } @@ -169,20 +180,24 @@ def invoke(self, *args, **kwargs) -> Result: # type: ignore[override, no-untype def cli( command: str | None = None, commands: list[dict[str, Any]] | None = None, ofmt: str = "json", **kwargs: dict[str, Any] ) -> dict[str, Any] | list[dict[str, Any]]: + # pylint: disable=unused-argument def get_output(command: str | dict[str, Any]) -> dict[str, Any]: if isinstance(command, dict): command = command["cmd"] - for mock_cmd, output in MOCK_CLI.items(): + 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(f"Mocking command {mock_cmd}") + if isinstance(output, aioeapi.EapiCommandError): + raise output return output message = f"Command '{command}' is not mocked" logger.critical(message) raise NotImplementedError(message) - # pylint: disable=unused-argument - if ofmt != "json": - raise NotImplementedError() res: dict[str, Any] | list[dict[str, Any]] if command is not None: logger.debug(f"Mock input {command}") @@ -194,6 +209,7 @@ def get_output(command: str | dict[str, Any]) -> dict[str, Any]: return res # Patch aioeapi methods used by AsyncEOSDevice. See tests/units/test_device.py - with patch("aioeapi.device.Device.check_connection", return_value=True): - with patch("aioeapi.device.Device.cli", side_effect=cli): - yield AntaCliRunner() + with patch("aioeapi.device.Device.check_connection", return_value=True), patch("aioeapi.device.Device.cli", side_effect=cli), patch("asyncssh.connect"), patch( + "asyncssh.scp" + ): + yield AntaCliRunner() diff --git a/tests/lib/utils.py b/tests/lib/utils.py index 996e2e412..4973e5e48 100644 --- a/tests/lib/utils.py +++ b/tests/lib/utils.py @@ -37,7 +37,7 @@ def generate_test_ids(data: list[dict[str, Any]]) -> list[str]: return [f"{val['test'].__module__}.{val['test'].__name__}-{val['name']}" for val in data] -def default_anta_env() -> dict[str, str]: +def default_anta_env() -> dict[str, str | None]: """ Return a default_anta_environement which can be passed to a cliRunner.invoke method """ diff --git a/tests/units/cli/debug/test_commands.py b/tests/units/cli/debug/test_commands.py index e6714f766..a78c34402 100644 --- a/tests/units/cli/debug/test_commands.py +++ b/tests/units/cli/debug/test_commands.py @@ -6,14 +6,12 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal -from unittest.mock import patch +from typing import TYPE_CHECKING, Literal import pytest from anta.cli import anta -from anta.models import AntaCommand -from tests.lib.utils import default_anta_env +from anta.cli.utils import ExitCode if TYPE_CHECKING: from click.testing import CliRunner @@ -27,7 +25,7 @@ pytest.param("show version", "text", None, None, "dummy", False, id="text command"), pytest.param("show version", None, "1", None, "dummy", False, id="version"), pytest.param("show version", None, None, 3, "dummy", False, id="revision"), - pytest.param("show version", None, None, None, "dummy", True, id="command fails"), + pytest.param("undefined", None, None, None, "dummy", True, id="command fails"), ], ) def test_run_cmd( @@ -37,69 +35,23 @@ def test_run_cmd( Test `anta debug run-cmd` """ # pylint: disable=too-many-arguments - env = default_anta_env() cli_args = ["debug", "run-cmd", "--command", command, "--device", device] # ofmt - expected_ofmt = ofmt - if ofmt is None: - expected_ofmt = "json" - else: + if ofmt is not None: cli_args.extend(["--ofmt", ofmt]) # version - expected_version: Literal["latest", 1] - if version is None: - expected_version = "latest" - else: + if version is not None: # Need to copy ugly hack here.. - expected_version = "latest" if version == "latest" else 1 cli_args.extend(["--version", version]) # revision if revision is not None: cli_args.extend(["--revision", str(revision)]) - # errors - expected_errors = [] + result = click_runner.invoke(anta, cli_args) if failed: - expected_errors = ["Command failed to run"] - - # exit code - expected_exit_code = 1 if failed else 0 - - def expected_result() -> Any: - """ - Helper to return some dummy payload for collect depending on outformat - """ - if failed: - return None - if expected_ofmt == "json": - return {"dummy": 42} - if expected_ofmt == "text": - return "dummy" - raise ValueError("Unknown format") - - async def dummy_collect(c: AntaCommand) -> None: - """ - mocking collect coroutine - """ - c.output = expected_result() - if c.output is None: - c.errors = expected_errors - - with patch("anta.device.AsyncEOSDevice.collect") as mocked_collect: - mocked_collect.side_effect = dummy_collect - result = click_runner.invoke(anta, cli_args, env=env, auto_envvar_prefix="ANTA") - - mocked_collect.assert_awaited_with( - AntaCommand( - command=command, - version=expected_version, - revision=revision, - ofmt=expected_ofmt, - output=expected_result(), - errors=expected_errors, - ) - ) - assert result.exit_code == expected_exit_code + assert result.exit_code == ExitCode.USAGE_ERROR + else: + assert result.exit_code == ExitCode.OK diff --git a/tests/units/cli/exec/test_commands.py b/tests/units/cli/exec/test_commands.py index ed69b68f6..47a72efd8 100644 --- a/tests/units/cli/exec/test_commands.py +++ b/tests/units/cli/exec/test_commands.py @@ -71,24 +71,20 @@ def test_clear_counters(click_runner: CliRunner, tags: str | None) -> None: @pytest.mark.parametrize( - "output, commands_path, tags", + "commands_path, tags", [ - pytest.param(None, None, None, id="missing command list"), - pytest.param(None, Path("/I/do/not/exist"), None, id="wrong path for command_list"), - pytest.param(None, COMMAND_LIST_PATH_FILE, None, id="command-list only"), - pytest.param("/tmp/dummy", COMMAND_LIST_PATH_FILE, None, id="with output"), - pytest.param(None, COMMAND_LIST_PATH_FILE, "leaf,spine", id="with tags"), + pytest.param(None, None, id="missing command list"), + pytest.param(Path("/I/do/not/exist"), None, id="wrong path for command_list"), + pytest.param(COMMAND_LIST_PATH_FILE, None, id="command-list only"), + pytest.param(COMMAND_LIST_PATH_FILE, "leaf,spine", id="with tags"), ], ) -def test_snapshot(click_runner: CliRunner, output: str | None, commands_path: Path | None, tags: str | None) -> None: +def test_snapshot(tmp_path: Path, click_runner: CliRunner, commands_path: Path | None, tags: str | None) -> None: """ Test `anta exec snapshot` """ - cli_args = ["exec", "snapshot"] - + cli_args = ["exec", "snapshot", "--output", str(tmp_path)] # Need to mock datetetime - if output is not None: - cli_args.extend(["--output", output]) if commands_path is not None: cli_args.extend(["--commands-list", str(commands_path)]) if tags is not None: diff --git a/tests/units/cli/get/test_commands.py b/tests/units/cli/get/test_commands.py index 197e179bf..3e2765b2e 100644 --- a/tests/units/cli/get/test_commands.py +++ b/tests/units/cli/get/test_commands.py @@ -9,7 +9,7 @@ import filecmp import shutil from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from unittest.mock import ANY, patch import pytest @@ -17,33 +17,40 @@ from cvprac.cvp_client_errors import CvpApiError from anta.cli import anta -from anta.cli.get.commands import from_cvp +from anta.cli.get.utils import stdin +from anta.cli.utils import ExitCode +from tests.lib.utils import default_anta_env if TYPE_CHECKING: from click.testing import CliRunner - from pytest import CaptureFixture, LogCaptureFixture DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data" -INIT_ANTA_INVENTORY = DATA_DIR / "test_inventory.yml" -# Not testing for required parameter, click does this well. +@pytest.fixture +def temp_env(tmp_path: Path) -> dict[str, str | None]: + """Fixture that create a temporary ANTA inventory that can be overriden + 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.mark.parametrize( - "cvp_container, inventory_directory, cvp_connect_failure", + "cvp_container, cvp_connect_failure", [ - pytest.param(None, None, True, id="cvp connect failure"), - pytest.param(None, None, False, id="default_directory"), - pytest.param(None, "custom", False, id="custom_directory"), - pytest.param("custom_container", None, False, id="custom_container"), + pytest.param(None, False, id="all devices"), + pytest.param("custom_container", False, id="custom container"), + pytest.param(None, True, id="cvp connect failure"), ], ) -# pylint: disable-next=too-many-arguments def test_from_cvp( - caplog: LogCaptureFixture, - capsys: CaptureFixture[str], + tmp_path: Path, click_runner: CliRunner, cvp_container: str | None, - inventory_directory: str | None, cvp_connect_failure: bool, ) -> None: """ @@ -51,21 +58,11 @@ def test_from_cvp( This test verifies that username and password are NOT mandatory to run this command """ - cli_args = ["get", "from-cvp", "--cvp-ip", "42.42.42.42", "--cvp-username", "anta", "--cvp-password", "anta"] - - if inventory_directory is not None: - cli_args.extend(["--inventory-directory", inventory_directory]) - out_dir = Path() / inventory_directory - else: - # Get inventory-directory default - default_dir: Path = cast(Path, from_cvp.params[4].default) - out_dir = Path() / default_dir + output: Path = tmp_path / "output.yml" + cli_args = ["get", "from-cvp", "--output", str(output), "--host", "42.42.42.42", "--username", "anta", "--password", "anta"] if cvp_container is not None: - cli_args.extend(["--cvp-container", cvp_container]) - out_file = out_dir / f"inventory-{cvp_container}.yml" - else: - out_file = out_dir / "inventory.yml" + cli_args.extend(["--container", cvp_container]) def mock_cvp_connect(self: CvpClient, *args: str, **kwargs: str) -> None: # pylint: disable=unused-argument @@ -74,47 +71,39 @@ def mock_cvp_connect(self: CvpClient, *args: str, **kwargs: str) -> None: # always get a token with patch("anta.cli.get.commands.get_cv_token", return_value="dummy_token"), patch( - "anta.cli.get.commands.CvpClient.connect", autospec=True, side_effect=mock_cvp_connect + "cvprac.cvp_client.CvpClient.connect", autospec=True, side_effect=mock_cvp_connect ) as mocked_cvp_connect, patch("cvprac.cvp_client.CvpApi.get_inventory", autospec=True, return_value=[]) as mocked_get_inventory, patch( "cvprac.cvp_client.CvpApi.get_devices_in_container", autospec=True, return_value=[] ) as mocked_get_devices_in_container: - # https://github.com/pallets/click/issues/824#issuecomment-1583293065 - with capsys.disabled(): - result = click_runner.invoke(anta, cli_args, auto_envvar_prefix="ANTA") + result = click_runner.invoke(anta, cli_args) if not cvp_connect_failure: - assert out_file.exists() - # Remove generated inventory file and directory - out_file.unlink() - out_dir.rmdir() + assert output.exists() mocked_cvp_connect.assert_called_once() if not cvp_connect_failure: - assert "Connected to CVP" in caplog.text + assert "Connected to CloudVision" in result.output if cvp_container is not None: mocked_get_devices_in_container.assert_called_once_with(ANY, cvp_container) else: mocked_get_inventory.assert_called_once() - assert result.exit_code == 0 + assert result.exit_code == ExitCode.OK else: - assert "Error connecting to cvp" in caplog.text - assert result.exit_code == 1 + assert "Error connecting to CloudVision" in result.output + assert result.exit_code == ExitCode.USAGE_ERROR @pytest.mark.parametrize( "ansible_inventory, ansible_group, expected_exit, expected_log", [ - pytest.param("ansible_inventory.yml", None, 0, None, id="no group"), - pytest.param("ansible_inventory.yml", "ATD_LEAFS", 0, None, id="group found"), - pytest.param("ansible_inventory.yml", "DUMMY", 4, "Group DUMMY not found in Ansible inventory", id="group not found"), - pytest.param("empty_ansible_inventory.yml", None, 4, "is empty", id="empty inventory"), + pytest.param("ansible_inventory.yml", None, ExitCode.OK, None, id="no group"), + pytest.param("ansible_inventory.yml", "ATD_LEAFS", ExitCode.OK, None, id="group found"), + pytest.param("ansible_inventory.yml", "DUMMY", ExitCode.USAGE_ERROR, "Group DUMMY not found in Ansible inventory", id="group not found"), + pytest.param("empty_ansible_inventory.yml", None, ExitCode.USAGE_ERROR, "is empty", id="empty inventory"), ], ) -# pylint: disable-next=too-many-arguments def test_from_ansible( tmp_path: Path, - caplog: LogCaptureFixture, - capsys: CaptureFixture[str], click_runner: CliRunner, ansible_inventory: Path, ansible_group: str | None, @@ -130,124 +119,57 @@ def test_from_ansible( The output path is ALWAYS set to a non existing file. """ - # Create a default directory - out_inventory: Path = tmp_path / " output.yml" - # Set --ansible-inventory + output: Path = tmp_path / "output.yml" ansible_inventory_path = DATA_DIR / ansible_inventory - # Init cli_args - cli_args = ["get", "from-ansible", "--output", str(out_inventory), "--ansible-inventory", str(ansible_inventory_path)] + cli_args = ["get", "from-ansible", "--output", str(output), "--ansible-inventory", str(ansible_inventory_path)] # Set --ansible-group if ansible_group is not None: cli_args.extend(["--ansible-group", ansible_group]) - with capsys.disabled(): - result = click_runner.invoke(anta, cli_args) + result = click_runner.invoke(anta, cli_args) assert result.exit_code == expected_exit - if expected_exit != 0: + if expected_exit != ExitCode.OK: assert expected_log - assert expected_log in [rec.message for rec in caplog.records][-1] - assert len(caplog.records) in {2, 3} + assert expected_log in result.output else: - assert out_inventory.exists() + assert output.exists() # TODO check size of generated inventory to validate the group functionality! @pytest.mark.parametrize( - "set_output, set_anta_inventory, expected_target, expected_exit, expected_log", - [ - pytest.param(True, False, "output.yml", 0, None, id="output-only"), - pytest.param(True, True, "output.yml", 0, None, id="output-and-inventory"), - pytest.param(False, True, "inventory.yml", 0, None, id="inventory-only"), - pytest.param( - False, - False, - None, - 4, - "Inventory output is not set. Either `anta --inventory` or `anta get from-ansible --output` MUST be set.", - id="no-output-no-inventory", - ), - ], -) -# pylint: disable-next=too-many-arguments -def test_from_ansible_output( - tmp_path: Path, - caplog: LogCaptureFixture, - capsys: CaptureFixture[str], - click_runner: CliRunner, - set_output: bool, - set_anta_inventory: bool, - expected_target: str, - expected_exit: int, - expected_log: str | None, -) -> None: - """ - This test verifies the precedence of target inventory file for `anta get from-ansible`: - 1. output - 2. ANTA_INVENTORY or `anta --inventory ` if `output` is not set - 3. Raise otherwise - - This test DOES NOT handle overwriting behavior so assuming EMPTY target for now - """ - # The targeted ansible_inventory is static - ansible_inventory_path = DATA_DIR / "ansible_inventory.yml" - cli_args = ["get", "from-ansible", "--ansible-inventory", str(ansible_inventory_path)] - - if set_anta_inventory: - tmp_inv = tmp_path / "inventory.yml" - # preprend - cli_args = ["-i", str(tmp_inv)] + cli_args - - if set_output: - tmp_inv = tmp_path / "output.yml" - cli_args.extend(["--output", str(tmp_inv)]) - - with capsys.disabled(): - result = click_runner.invoke(anta, cli_args, auto_envvar_prefix="ANTA") - - assert result.exit_code == expected_exit - if expected_exit != 0: - assert expected_log in [rec.message for rec in caplog.records] - else: - expected_inv = tmp_path / expected_target - assert expected_inv.exists() - - -@pytest.mark.parametrize( - "overwrite, is_tty, init_anta_inventory, prompt, expected_exit, expected_log", + "env_set, overwrite, is_tty, prompt, expected_exit, expected_log", [ - pytest.param(False, True, INIT_ANTA_INVENTORY, "y", 0, "", id="no-overwrite-tty-init-prompt-yes"), - pytest.param(False, True, INIT_ANTA_INVENTORY, "N", 1, "Aborted", id="no-overwrite-tty-init-prompt-no"), + pytest.param(True, False, True, "y", ExitCode.OK, "", id="no-overwrite-tty-init-prompt-yes"), + pytest.param(True, False, True, "N", ExitCode.INTERNAL_ERROR, "Aborted", id="no-overwrite-tty-init-prompt-no"), pytest.param( + True, False, False, - INIT_ANTA_INVENTORY, None, - 4, + ExitCode.USAGE_ERROR, "Conversion aborted since destination file is not empty (not running in interactive TTY)", id="no-overwrite-no-tty-init", ), - pytest.param(False, True, None, None, 0, "", id="no-overwrite-tty-no-init"), - pytest.param(False, False, None, None, 0, "", id="no-overwrite-no-tty-no-init"), - pytest.param(True, True, INIT_ANTA_INVENTORY, None, 0, "", id="overwrite-tty-init"), - pytest.param(True, False, INIT_ANTA_INVENTORY, None, 0, "", id="overwrite-no-tty-init"), - pytest.param(True, True, None, None, 0, "", id="overwrite-tty-no-init"), - pytest.param(True, False, None, None, 0, "", id="overwrite-no-tty-no-init"), + pytest.param(False, False, True, None, ExitCode.OK, "", id="no-overwrite-tty-no-init"), + pytest.param(False, False, False, None, ExitCode.OK, "", id="no-overwrite-no-tty-no-init"), + pytest.param(True, True, True, None, ExitCode.OK, "", id="overwrite-tty-init"), + pytest.param(True, True, False, None, ExitCode.OK, "", id="overwrite-no-tty-init"), + pytest.param(False, True, True, None, ExitCode.OK, "", id="overwrite-tty-no-init"), + pytest.param(False, True, False, None, ExitCode.OK, "", id="overwrite-no-tty-no-init"), ], ) -# pylint: disable-next=too-many-arguments def test_from_ansible_overwrite( tmp_path: Path, - caplog: LogCaptureFixture, - capsys: CaptureFixture[str], click_runner: CliRunner, + temp_env: dict[str, str | None], + env_set: bool, overwrite: bool, is_tty: bool, prompt: str | None, - init_anta_inventory: Path, expected_exit: int, expected_log: str | None, ) -> None: @@ -266,34 +188,32 @@ def test_from_ansible_overwrite( * With overwrite True, the expectation is that the from-ansible command succeeds * With no init (init_anta_inventory == None), the expectation is also that command succeeds """ - # The targeted ansible_inventory is static ansible_inventory_path = DATA_DIR / "ansible_inventory.yml" expected_anta_inventory_path = DATA_DIR / "expected_anta_inventory.yml" - tmp_inv = tmp_path / "output.yml" - cli_args = ["get", "from-ansible", "--ansible-inventory", str(ansible_inventory_path), "--output", str(tmp_inv)] + tmp_output = tmp_path / "output.yml" + cli_args = ["get", "from-ansible", "--ansible-inventory", str(ansible_inventory_path)] + + if env_set: + tmp_inv = Path(str(temp_env["ANTA_INVENTORY"])) + else: + temp_env["ANTA_INVENTORY"] = None + tmp_inv = tmp_output + cli_args.extend(["--output", str(tmp_output)]) if overwrite: cli_args.append("--overwrite") - if init_anta_inventory: - shutil.copyfile(init_anta_inventory, tmp_inv) - - print(cli_args) # Verify initial content is different if tmp_inv.exists(): assert not filecmp.cmp(tmp_inv, expected_anta_inventory_path) - with capsys.disabled(): - # TODO, handle is_tty - with patch("anta.cli.get.commands.stdin") as patched_stdin: - patched_stdin.isatty.return_value = is_tty - result = click_runner.invoke(anta, cli_args, auto_envvar_prefix="ANTA", input=prompt) + with patch.object(stdin, "isatty") as patched_isatty: + patched_isatty.return_value = is_tty + result = click_runner.invoke(anta, cli_args, env=temp_env, input=prompt) assert result.exit_code == expected_exit - if expected_exit == 0: + if expected_exit == ExitCode.OK: assert filecmp.cmp(tmp_inv, expected_anta_inventory_path) - elif expected_exit == 1: + elif expected_exit == ExitCode.INTERNAL_ERROR: assert expected_log - assert expected_log in result.stdout - elif expected_exit == 4: - assert expected_log in [rec.message for rec in caplog.records] + assert expected_log in result.output diff --git a/tests/units/cli/get/test_utils.py b/tests/units/cli/get/test_utils.py index 36f49354d..49026a9ec 100644 --- a/tests/units/cli/get/test_utils.py +++ b/tests/units/cli/get/test_utils.py @@ -76,16 +76,13 @@ def test_create_inventory_from_cvp(tmp_path: Path, cvp_container: str | None, in """ Test anta.get.utils.create_inventory_from_cvp """ - target_dir = tmp_path / "inventory" - target_dir.mkdir() + output = tmp_path / "output.yml" - create_inventory_from_cvp(inventory, str(target_dir), cvp_container) + create_inventory_from_cvp(inventory, output, cvp_container) - expected_inventory_file_name = "inventory.yml" if cvp_container is None else f"inventory-{cvp_container}.yml" - expected_inventory_path = target_dir / expected_inventory_file_name - assert expected_inventory_path.exists() + assert output.exists() # This validate the file structure ;) - inv = AntaInventory().parse(str(expected_inventory_path), "user", "pass") + inv = AntaInventory.parse(str(output), "user", "pass") assert len(inv) == len(inventory) diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py index 82da67973..a4844cd95 100644 --- a/tests/units/cli/nrfu/test__init__.py +++ b/tests/units/cli/nrfu/test__init__.py @@ -12,6 +12,8 @@ from anta.cli.utils import ExitCode from tests.lib.utils import default_anta_env +# TODO: write unit tests for ignore-status and ignore-error + def test_anta_nrfu_help(click_runner: CliRunner) -> None: """ From 95f3c5a8a261ebd9af5d40edb8f4ba02f874ee2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20Ta=CC=82che?= Date: Fri, 1 Dec 2023 13:13:53 +0100 Subject: [PATCH 25/53] linting --- anta/cli/nrfu/commands.py | 2 +- tests/units/cli/exec/test_commands.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index 1f9697fee..e4c37e6a0 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 anta_progress_bar, print_jinja, print_json, print_table, print_text +from .utils import print_jinja, print_json, print_table, print_text logger = logging.getLogger(__name__) diff --git a/tests/units/cli/exec/test_commands.py b/tests/units/cli/exec/test_commands.py index 47a72efd8..311b83829 100644 --- a/tests/units/cli/exec/test_commands.py +++ b/tests/units/cli/exec/test_commands.py @@ -9,14 +9,12 @@ from pathlib import Path from typing import TYPE_CHECKING -from unittest.mock import ANY, patch import pytest from anta.cli import anta from anta.cli.exec.commands import clear_counters, collect_tech_support, snapshot from anta.cli.utils import ExitCode -from tests.lib.utils import default_anta_env if TYPE_CHECKING: from click.testing import CliRunner From 03ef76ea762c5acd6aec04029d6d3ca59ddc1fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 13:49:36 +0100 Subject: [PATCH 26/53] update unit tests for anta.cli --- anta/catalog.py | 2 +- anta/cli/__init__.py | 15 ++++-- anta/cli/check/__init__.py | 4 +- anta/cli/check/commands.py | 2 +- anta/cli/debug/__init__.py | 3 ++ anta/cli/debug/commands.py | 6 +-- anta/cli/debug/utils.py | 4 +- anta/cli/exec/__init__.py | 5 +- anta/cli/exec/commands.py | 2 +- anta/cli/get/__init__.py | 3 ++ anta/cli/get/commands.py | 10 ++-- anta/cli/get/utils.py | 31 +++++------ anta/cli/nrfu/__init__.py | 3 ++ anta/cli/nrfu/commands.py | 2 +- anta/inventory/__init__.py | 2 +- anta/logger.py | 23 +++++++- anta/models.py | 3 +- anta/runner.py | 2 +- anta/tools/misc.py | 23 -------- pylintrc | 1 + tests/lib/fixture.py | 59 ++++++++++++++------ tests/units/cli/get/test_commands.py | 19 +------ tests/units/cli/get/test_utils.py | 11 ++-- tests/units/test_catalog.py | 7 +-- tests/units/test_logger.py | 80 ++++++++++++++++++++++++++++ tests/units/test_runner.py | 6 +-- tests/units/tools/test_misc.py | 72 +------------------------ 27 files changed, 219 insertions(+), 181 deletions(-) create mode 100644 tests/units/test_logger.py diff --git a/anta/catalog.py b/anta/catalog.py index 8aae5a5e1..14f08f797 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -17,8 +17,8 @@ from pydantic.types import ImportString from yaml import YAMLError, safe_load +from anta.logger import anta_log_exception from anta.models import AntaTest -from anta.tools.misc import anta_log_exception logger = logging.getLogger(__name__) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index 19ca23b63..7dc757879 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -9,17 +9,20 @@ import logging import pathlib +import sys import click -from anta import __version__ +from anta import GITHUB_SUGGESTION, __version__ from anta.cli.check import check as check_command from anta.cli.debug import debug as debug_command from anta.cli.exec import exec as exec_command from anta.cli.get import get as get_command from anta.cli.nrfu import nrfu as nrfu_command -from anta.cli.utils import AliasedGroup -from anta.logger import Log, LogLevel, setup_logging +from anta.cli.utils import AliasedGroup, ExitCode +from anta.logger import Log, LogLevel, anta_log_exception, setup_logging + +logger = logging.getLogger(__name__) @click.group(cls=AliasedGroup) @@ -58,7 +61,11 @@ def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> Non def cli() -> None: """Entrypoint for pyproject.toml""" - anta(obj={}, auto_envvar_prefix="ANTA") + try: + anta(obj={}, auto_envvar_prefix="ANTA") + except Exception as e: # pylint: disable=broad-exception-caught + anta_log_exception(e, f"Uncaught Exception when running ANTA CLI\n{GITHUB_SUGGESTION}", logger) + sys.exit(ExitCode.INTERNAL_ERROR) if __name__ == "__main__": diff --git a/anta/cli/check/__init__.py b/anta/cli/check/__init__.py index 8fec40a1e..5f72bf6e8 100644 --- a/anta/cli/check/__init__.py +++ b/anta/cli/check/__init__.py @@ -1,7 +1,9 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. - +""" +Click commands to validate configuration files +""" import click from anta.cli.check import commands diff --git a/anta/cli/check/commands.py b/anta/cli/check/commands.py index 0ad281741..1460580a0 100644 --- a/anta/cli/check/commands.py +++ b/anta/cli/check/commands.py @@ -3,7 +3,7 @@ # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name """ -Commands for Anta CLI to run check commands. +Click commands to validate configuration files """ from __future__ import annotations diff --git a/anta/cli/debug/__init__.py b/anta/cli/debug/__init__.py index 2953d10a1..e3f973983 100644 --- a/anta/cli/debug/__init__.py +++ b/anta/cli/debug/__init__.py @@ -1,6 +1,9 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +""" +Click commands to execute EOS commands on remote devices +""" import click from anta.cli.debug import commands diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py index 901d9655e..8b9b58587 100644 --- a/anta/cli/debug/commands.py +++ b/anta/cli/debug/commands.py @@ -3,7 +3,7 @@ # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name """ -Commands for Anta CLI to run debug commands. +Click commands to execute EOS commands on remote devices """ from __future__ import annotations @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -@click.command(no_args_is_help=True) +@click.command @debug_options @click.pass_context @click.option("--command", "-c", type=str, required=True, help="Command to run") @@ -42,7 +42,7 @@ def run_cmd(ctx: click.Context, command: str, ofmt: Literal["json", "text"], ver console.print(c.text_output) -@click.command(no_args_is_help=True) +@click.command @debug_options @click.pass_context @click.option("--template", "-t", type=str, required=True, help="Command template to run. E.g. 'show vlan {vlan_id}'") diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py index db0813900..c7cd4a7cb 100644 --- a/anta/cli/debug/utils.py +++ b/anta/cli/debug/utils.py @@ -30,9 +30,9 @@ def debug_options(f: Any) -> Any: @click.option("--device", "-d", type=str, required=True, help="Device from inventory to use") @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + def wrapper(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, device: str, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: try: - kwargs["device"] = inventory[ctx.params["device"]] + kwargs["device"] = inventory[device] except KeyError as e: message = f"Device {ctx.params['device']} does not exist in Inventory" logger.error(e, message) diff --git a/anta/cli/exec/__init__.py b/anta/cli/exec/__init__.py index 74b7ef657..db0ed3d6d 100644 --- a/anta/cli/exec/__init__.py +++ b/anta/cli/exec/__init__.py @@ -1,13 +1,16 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +""" +Click commands to execute various scripts on EOS devices +""" import click from anta.cli.exec import commands @click.group -def exec() -> None: +def exec() -> None: # pylint: disable=redefined-builtin """Execute commands to inventory devices""" diff --git a/anta/cli/exec/commands.py b/anta/cli/exec/commands.py index 99403119b..16e50277d 100644 --- a/anta/cli/exec/commands.py +++ b/anta/cli/exec/commands.py @@ -2,7 +2,7 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """ -Commands for Anta CLI to execute EOS commands. +Click commands to execute various scripts on EOS devices """ from __future__ import annotations diff --git a/anta/cli/get/__init__.py b/anta/cli/get/__init__.py index 8774bea5b..e75c79b35 100644 --- a/anta/cli/get/__init__.py +++ b/anta/cli/get/__init__.py @@ -1,6 +1,9 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +""" +Click commands to get informations from inventories or generate them +""" import click from anta.cli.get import commands diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index 475591101..eb53606bd 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -3,7 +3,7 @@ # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name """ -Commands for Anta CLI to get information / build inventories.. +Click commands to get informations from inventories or generate them """ from __future__ import annotations @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) -@click.command(no_args_is_help=True) +@click.command @click.pass_context @inventory_output_options @click.option("--host", "-host", help="CloudVision instance FQDN or IP", type=str, required=True) @@ -60,10 +60,10 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor # Get devices under a container logger.info(f"Getting inventory for container {container} from CloudVision instance '{host}'") cvp_inventory = clnt.api.get_devices_in_container(container) - create_inventory_from_cvp(cvp_inventory, output, container) + create_inventory_from_cvp(cvp_inventory, output) -@click.command(no_args_is_help=True) +@click.command @click.pass_context @inventory_output_options @click.option("--ansible-group", "-g", help="Ansible group to filter", type=str, default="all") @@ -104,7 +104,7 @@ def inventory(inventory: AntaInventory, tags: list[str] | None, connected: bool) @click.command @inventory_options -def tags(inventory: AntaInventory, tags: list[str] | None) -> None: +def tags(inventory: AntaInventory, tags: list[str] | None) -> None: # pylint: disable=unused-argument """Get list of configured tags in user inventory.""" tags_found = [] for device in inventory.values(): diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py index 8c472038b..7d7a1a879 100644 --- a/anta/cli/get/utils.py +++ b/anta/cli/get/utils.py @@ -82,19 +82,24 @@ def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str: return response.json()["sessionId"] -def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path, container: str | None = None) -> None: +def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None: + """Write a file inventory from pydantic models""" + i = AntaInventoryInput(hosts=hosts) + with open(output, "w", encoding="UTF-8") as out_fd: + out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)})) + logger.info(f"ANTA inventory file has been created: '{output}'") + + +def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path) -> None: """ - create an inventory file from Arista CloudVision + Create an inventory file from Arista CloudVision inventory """ - i: dict[str, dict[str, Any]] = {AntaInventory.INVENTORY_ROOT_KEY: {"hosts": []}} - logger.debug(f"Received {len(inv)} device(s) from CVP") + logger.debug(f"Received {len(inv)} device(s) from CloudVision") + hosts = [] for dev in inv: - logger.info(f' * adding entry for {dev["hostname"]}') - i[AntaInventory.INVENTORY_ROOT_KEY]["hosts"].append({"host": dev["ipAddress"], "name": dev["hostname"], "tags": [dev["containerName"].lower()]}) - # write the devices IP address in a file - with open(output, "w", encoding="UTF-8") as out_fd: - out_fd.write(yaml.dump(i)) - logger.info(f"ANTA inventory file has been created: '{output}'") + logger.info(f" * adding entry for {dev['hostname']}") + hosts.append(AntaInventoryHost(name=dev["hostname"], host=dev["ipAddress"], tags=[dev["containerName"].lower()])) + write_inventory_to_file(hosts, output) def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None: @@ -145,8 +150,4 @@ def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | Non if ansible_inventory is None: raise ValueError(f"Group {ansible_group} not found in Ansible inventory") ansible_hosts = deep_yaml_parsing(ansible_inventory) - i = AntaInventoryInput(hosts=ansible_hosts) - # TODO, catch issue - with open(output, "w", encoding="UTF-8") as out_fd: - out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)})) - logger.info(f"ANTA inventory file has been created: '{output}'") + write_inventory_to_file(ansible_hosts, output) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 760b1223e..077bfd273 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -1,6 +1,9 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +""" +Click commands that run ANTA tests using anta.runner +""" from __future__ import annotations import asyncio diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index e4c37e6a0..07e4017a4 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -2,7 +2,7 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """ -Commands for Anta CLI to run nrfu commands. +Click commands that render ANTA tests results """ from __future__ import annotations diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py index c335f20c1..700452d80 100644 --- a/anta/inventory/__init__.py +++ b/anta/inventory/__init__.py @@ -19,7 +19,7 @@ from anta.device import AntaDevice, AsyncEOSDevice from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError from anta.inventory.models import AntaInventoryInput -from anta.tools.misc import anta_log_exception +from anta.logger import anta_log_exception logger = logging.getLogger(__name__) diff --git a/anta/logger.py b/anta/logger.py index 08ccae151..74b78d08b 100644 --- a/anta/logger.py +++ b/anta/logger.py @@ -9,11 +9,12 @@ import logging from enum import Enum from pathlib import Path -from typing import Literal +from typing import Literal, Optional from rich.logging import RichHandler from anta import __DEBUG__ +from anta.tools.misc import exc_to_str logger = logging.getLogger(__name__) @@ -84,3 +85,23 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: if __DEBUG__: logger.debug("ANTA Debug Mode enabled") + + +def anta_log_exception(exception: BaseException, message: Optional[str] = None, calling_logger: Optional[logging.Logger] = None) -> None: + """ + Helper function to help log exceptions: + * if anta.__DEBUG__ is True then the logger.exception method is called to get the traceback + * otherwise logger.error is called + + Args: + exception (BAseException): The Exception being logged + message (str): An optional message + calling_logger (logging.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: + calling_logger = logger + calling_logger.critical(f"{message}\n{exc_to_str(exception)}" if message else exc_to_str(exception)) + if __DEBUG__: + calling_logger.exception(f"[ANTA Debug Mode]{f' {message}' if message else ''}", exc_info=exception) diff --git a/anta/models.py b/anta/models.py index 182899ec0..9143ae27a 100644 --- a/anta/models.py +++ b/anta/models.py @@ -22,8 +22,9 @@ from rich.progress import Progress, TaskID from anta import GITHUB_SUGGESTION +from anta.logger import anta_log_exception from anta.result_manager.models import TestResult -from anta.tools.misc import anta_log_exception, exc_to_str +from anta.tools.misc import exc_to_str if TYPE_CHECKING: from anta.device import AntaDevice diff --git a/anta/runner.py b/anta/runner.py index 9d77afd54..539a665e9 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -15,9 +15,9 @@ from anta.catalog import AntaCatalog, AntaTestDefinition from anta.device import AntaDevice from anta.inventory import AntaInventory +from anta.logger import anta_log_exception from anta.models import AntaTest from anta.result_manager import ResultManager -from anta.tools.misc import anta_log_exception logger = logging.getLogger(__name__) diff --git a/anta/tools/misc.py b/anta/tools/misc.py index 47074eb5a..8dcf263df 100644 --- a/anta/tools/misc.py +++ b/anta/tools/misc.py @@ -8,33 +8,10 @@ import logging import traceback -from typing import Optional - -from anta import __DEBUG__ logger = logging.getLogger(__name__) -def anta_log_exception(exception: BaseException, message: Optional[str] = None, calling_logger: Optional[logging.Logger] = None) -> None: - """ - Helper function to help log exceptions: - * if anta.__DEBUG__ is True then the logger.exception method is called to get the traceback - * otherwise logger.error is called - - Args: - exception (BAseException): The Exception being logged - message (str): An optional message - calling_logger (logging.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: - calling_logger = logger - calling_logger.critical(f"{message}\n{exc_to_str(exception)}" if message else exc_to_str(exception)) - if __DEBUG__: - calling_logger.exception(f"[ANTA Debug Mode]{f' {message}' if message else ''}", exc_info=exception) - - def exc_to_str(exception: BaseException) -> str: """ Helper function that returns a human readable string from an BaseException object diff --git a/pylintrc b/pylintrc index a02d42c67..76cda5053 100644 --- a/pylintrc +++ b/pylintrc @@ -11,6 +11,7 @@ good-names=runCmds, i, y, t, c, x, e, fd, ip, v max-statements=61 max-returns=8 max-locals=23 +max-args=6 [FORMAT] max-line-length=165 diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 534edd43b..98ce50c44 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -5,14 +5,16 @@ from __future__ import annotations import logging +import shutil +from pathlib import Path from typing import Any, Callable, Iterator from unittest.mock import patch -import aioeapi import pytest from click.testing import CliRunner, Result from pytest import CaptureFixture +from anta import aioeapi from anta.device import AntaDevice, AsyncEOSDevice from anta.inventory import AntaInventory from anta.models import AntaCommand @@ -26,7 +28,7 @@ DEVICE_NAME = "pytest" COMMAND_OUTPUT = "retrieved" -MOCK_CLI_JSON: dict[str, dict[str, Any]] = { +MOCK_CLI_JSON: dict[str, aioeapi.EapiCommandError | dict[str, Any]] = { "show version": { "modelName": "DCS-7280CR3-32P4-F", "version": "4.31.1F", @@ -39,7 +41,7 @@ ), } -MOCK_CLI_TEXT: dict[str, str] = { +MOCK_CLI_TEXT: dict[str, aioeapi.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", @@ -71,7 +73,24 @@ def _collect(command: AntaCommand) -> None: @pytest.fixture -def async_device(request: pytest.FixtureRequest) -> AntaDevice: +def test_inventory() -> AntaInventory: + """ + Return the test_inventory + """ + env = default_anta_env() + assert env["ANTA_INVENTORY"] and env["ANTA_USERNAME"] and 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: """ Returns an AsyncEOSDevice instance """ @@ -85,6 +104,9 @@ def async_device(request: pytest.FixtureRequest) -> AntaDevice: return dev +# tests.units.result_manager fixtures + + @pytest.fixture def test_result_factory(device: AntaDevice) -> Callable[[int], TestResult]: """ @@ -146,33 +168,37 @@ def _factory(number: int = 0) -> ResultManager: return _factory +# tests.units.cli fixtures + + @pytest.fixture -def test_inventory() -> AntaInventory: - """ - Return the test_inventory - """ +def temp_env(tmp_path: Path) -> dict[str, str | None]: + """Fixture that create a temporary ANTA inventory that can be overriden + and returns the corresponding environment variables""" env = default_anta_env() - return AntaInventory.parse( - filename=env["ANTA_INVENTORY"], - username=env["ANTA_USERNAME"], - password=env["ANTA_PASSWORD"], - ) + 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 -def click_runner(capsys: CaptureFixture[str]) -> CliRunner: +def click_runner(capsys: CaptureFixture[str]) -> Iterator[CliRunner]: """ Convenience fixture to return a click.CliRunner for cli testing """ class AntaCliRunner(CliRunner): - def invoke(self, *args, **kwargs) -> Result: # type: ignore[override, no-untyped-def] + """Override CliRunner to inject specific variables for ANTA""" + + def invoke(self, *args, **kwargs) -> Result: # type: ignore[no-untyped-def] # Inject default env if not provided kwargs["env"] = kwargs["env"] if "env" in kwargs else default_anta_env() kwargs["auto_envvar_prefix"] = "ANTA" # Way to fix https://github.com/pallets/click/issues/824 with capsys.disabled(): - result = super().invoke(*args, **kwargs) # type: ignore[arg-type] + result = super().invoke(*args, **kwargs) print("--- CLI Output ---") print(result.output) return result @@ -184,6 +210,7 @@ def cli( 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": diff --git a/tests/units/cli/get/test_commands.py b/tests/units/cli/get/test_commands.py index 3e2765b2e..a0c1b0f74 100644 --- a/tests/units/cli/get/test_commands.py +++ b/tests/units/cli/get/test_commands.py @@ -7,7 +7,6 @@ from __future__ import annotations import filecmp -import shutil from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import ANY, patch @@ -17,9 +16,7 @@ from cvprac.cvp_client_errors import CvpApiError from anta.cli import anta -from anta.cli.get.utils import stdin from anta.cli.utils import ExitCode -from tests.lib.utils import default_anta_env if TYPE_CHECKING: from click.testing import CliRunner @@ -27,18 +24,6 @@ DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data" -@pytest.fixture -def temp_env(tmp_path: Path) -> dict[str, str | None]: - """Fixture that create a temporary ANTA inventory that can be overriden - 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.mark.parametrize( "cvp_container, cvp_connect_failure", [ @@ -173,6 +158,7 @@ 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 @@ -207,8 +193,7 @@ def test_from_ansible_overwrite( if tmp_inv.exists(): assert not filecmp.cmp(tmp_inv, expected_anta_inventory_path) - with patch.object(stdin, "isatty") as patched_isatty: - patched_isatty.return_value = is_tty + with patch("sys.stdin.isatty", return_value=is_tty): result = click_runner.invoke(anta, cli_args, env=temp_env, input=prompt) assert result.exit_code == expected_exit diff --git a/tests/units/cli/get/test_utils.py b/tests/units/cli/get/test_utils.py index 49026a9ec..fb00bcb7a 100644 --- a/tests/units/cli/get/test_utils.py +++ b/tests/units/cli/get/test_utils.py @@ -65,20 +65,19 @@ def test_get_cv_token() -> None: @pytest.mark.parametrize( - "cvp_container, inventory", + "inventory", [ - pytest.param(None, CVP_INVENTORY, id="no container"), - pytest.param("DC1", CVP_INVENTORY, id="some container"), - pytest.param(None, [], id="empty_inventory"), + pytest.param(CVP_INVENTORY, id="some container"), + pytest.param([], id="empty_inventory"), ], ) -def test_create_inventory_from_cvp(tmp_path: Path, cvp_container: str | None, inventory: list[dict[str, Any]]) -> None: +def test_create_inventory_from_cvp(tmp_path: Path, inventory: list[dict[str, Any]]) -> None: """ Test anta.get.utils.create_inventory_from_cvp """ output = tmp_path / "output.yml" - create_inventory_from_cvp(inventory, output, cvp_container) + create_inventory_from_cvp(inventory, output) assert output.exists() # This validate the file structure ;) diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py index 87780f0bc..a8a596535 100644 --- a/tests/units/test_catalog.py +++ b/tests/units/test_catalog.py @@ -15,8 +15,6 @@ from anta.catalog import AntaCatalog, AntaTestDefinition from anta.models import AntaTest -from anta.tests.configuration import VerifyZeroTouch -from anta.tests.hardware import VerifyTemperature from anta.tests.interfaces import VerifyL3MTU from anta.tests.mlag import VerifyMlagStatus from anta.tests.software import VerifyEOSVersion @@ -42,10 +40,7 @@ "name": "test_catalog", "filename": "test_catalog.yml", "tests": [ - (VerifyZeroTouch, None), - (VerifyTemperature, None), - (VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.25.4M", "4.26.1F"])), - (VerifyUptime, {"minimum": 86400}), + (VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"])), ], }, { diff --git a/tests/units/test_logger.py b/tests/units/test_logger.py new file mode 100644 index 000000000..0a8f3c4f1 --- /dev/null +++ b/tests/units/test_logger.py @@ -0,0 +1,80 @@ +# Copyright (c) 2023 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 for anta.logger +""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from anta.logger import anta_log_exception + +if TYPE_CHECKING: + from pytest import LogCaptureFixture + + +@pytest.mark.parametrize( + "exception, message, calling_logger, __DEBUG__value, expected_message", + [ + pytest.param(ValueError("exception message"), None, None, False, "ValueError (exception message)", id="exception only"), + pytest.param(ValueError("exception message"), "custom message", None, False, "custom message\nValueError (exception message)", id="custom message"), + pytest.param( + ValueError("exception message"), + "custom logger", + logging.getLogger("custom"), + False, + "custom logger\nValueError (exception message)", + id="custom logger", + ), + pytest.param( + ValueError("exception message"), "Use with custom message", None, True, "Use with custom message\nValueError (exception message)", id="__DEBUG__ on" + ), + ], +) +def test_anta_log_exception( + caplog: LogCaptureFixture, + exception: Exception, + message: str | None, + calling_logger: logging.Logger | None, + __DEBUG__value: bool, + expected_message: str, +) -> None: + """ + Test anta_log_exception + """ + + if calling_logger is not None: + # https://github.com/pytest-dev/pytest/issues/3697 + calling_logger.propagate = True + caplog.set_level(logging.ERROR, logger=calling_logger.name) + else: + caplog.set_level(logging.ERROR) + # Need to raise to trigger nice stacktrace for __DEBUG__ == True + try: + raise exception + except ValueError as e: + with patch("anta.logger.__DEBUG__", __DEBUG__value): + anta_log_exception(e, message=message, calling_logger=calling_logger) + + # Two log captured + if __DEBUG__value: + assert len(caplog.record_tuples) == 2 + else: + assert len(caplog.record_tuples) == 1 + logger, level, message = caplog.record_tuples[0] + + if calling_logger is not None: + assert calling_logger.name == logger + else: + assert logger == "anta.logger" + + assert level == logging.CRITICAL + assert message == expected_message + # the only place where we can see the stracktrace is in the capture.text + if __DEBUG__value is True: + assert "Traceback" in caplog.text diff --git a/tests/units/test_runner.py b/tests/units/test_runner.py index 46926a889..915bed7ee 100644 --- a/tests/units/test_runner.py +++ b/tests/units/test_runner.py @@ -33,7 +33,7 @@ async def test_runner_empty_tests(caplog: LogCaptureFixture, test_inventory: Ant caplog is the pytest fixture to capture logs test_inventory is a fixture that gives a default inventory for tests """ - logger.setup_logging("INFO") + logger.setup_logging(logger.Log.INFO) manager = ResultManager() await main(manager, test_inventory, AntaCatalog()) @@ -48,7 +48,7 @@ async def test_runner_empty_inventory(caplog: LogCaptureFixture) -> None: caplog is the pytest fixture to capture logs """ - logger.setup_logging("INFO") + logger.setup_logging(logger.Log.INFO) manager = ResultManager() inventory = AntaInventory() await main(manager, inventory, FAKE_CATALOG) @@ -64,7 +64,7 @@ async def test_runner_no_selected_device(caplog: LogCaptureFixture, test_invento caplog is the pytest fixture to capture logs test_inventory is a fixture that gives a default inventory for tests """ - logger.setup_logging("INFO") + logger.setup_logging(logger.Log.INFO) manager = ResultManager() await main(manager, test_inventory, FAKE_CATALOG) diff --git a/tests/units/tools/test_misc.py b/tests/units/tools/test_misc.py index 7ab26909c..ab78e444b 100644 --- a/tests/units/tools/test_misc.py +++ b/tests/units/tools/test_misc.py @@ -4,19 +4,11 @@ """ Tests for anta.tools.misc """ - from __future__ import annotations -import logging -from typing import TYPE_CHECKING -from unittest.mock import patch - import pytest -from anta.tools.misc import anta_log_exception, exc_to_str, tb_to_str - -if TYPE_CHECKING: - from pytest import LogCaptureFixture +from anta.tools.misc import exc_to_str, tb_to_str def my_raising_function(exception: Exception) -> None: @@ -26,68 +18,6 @@ def my_raising_function(exception: Exception) -> None: raise exception -@pytest.mark.parametrize( - "exception, message, calling_logger, __DEBUG__value, expected_message", - [ - pytest.param(ValueError("exception message"), None, None, False, "ValueError (exception message)", id="exception only"), - pytest.param(ValueError("exception message"), "custom message", None, False, "custom message\nValueError (exception message)", id="custom message"), - pytest.param( - ValueError("exception message"), - "custom logger", - logging.getLogger("custom"), - False, - "custom logger\nValueError (exception message)", - id="custom logger", - ), - pytest.param( - ValueError("exception message"), "Use with custom message", None, True, "Use with custom message\nValueError (exception message)", id="__DEBUG__ on" - ), - ], -) -def test_anta_log_exception( - caplog: LogCaptureFixture, - exception: Exception, - message: str | None, - calling_logger: logging.Logger | None, - __DEBUG__value: bool, - expected_message: str, -) -> None: - """ - Test anta_log_exception - """ - - if calling_logger is not None: - # https://github.com/pytest-dev/pytest/issues/3697 - calling_logger.propagate = True - caplog.set_level(logging.ERROR, logger=calling_logger.name) - else: - caplog.set_level(logging.ERROR) - # Need to raise to trigger nice stacktrace for __DEBUG__ == True - try: - my_raising_function(exception) - except ValueError as e: - with patch("anta.tools.misc.__DEBUG__", __DEBUG__value): - anta_log_exception(e, message=message, calling_logger=calling_logger) - - # Two log captured - if __DEBUG__value: - assert len(caplog.record_tuples) == 2 - else: - assert len(caplog.record_tuples) == 1 - logger, level, message = caplog.record_tuples[0] - - if calling_logger is not None: - assert calling_logger.name == logger - else: - assert logger == "anta.tools.misc" - - assert level == logging.CRITICAL - assert message == expected_message - # the only place where we can see the stracktrace is in the capture.text - if __DEBUG__value is True: - assert "Traceback" in caplog.text - - @pytest.mark.parametrize("exception, expected_output", [(ValueError("test"), "ValueError (test)"), (ValueError(), "ValueError")]) def test_exc_to_str(exception: Exception, expected_output: str) -> None: """ From 94c2c693fb705e856155fd17fc5539b43d235340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 13:53:50 +0100 Subject: [PATCH 27/53] small fix for anta get from-ansible --- anta/cli/get/commands.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index eb53606bd..b4cb41417 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -70,7 +70,8 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor @click.option( "--ansible-inventory", help="Path to your ansible inventory file to read", - type=click.Path(file_okay=True, dir_okay=False, exists=True, path_type=Path), + type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), + required=True, ) def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None: """Build ANTA inventory from an ansible inventory YAML file""" From 049e80d551bf34c6573492acf701c08a158337ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 14:07:46 +0100 Subject: [PATCH 28/53] refactor wrapper --- anta/cli/get/commands.py | 2 +- anta/cli/get/utils.py | 2 ++ anta/cli/utils.py | 53 ++++++++++++++++++++++++---------------- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index b4cb41417..5792f1af4 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -34,7 +34,7 @@ @click.option("--username", "-u", help="CloudVision username", type=str, required=True) @click.option("--password", "-p", help="CloudVision password", type=str, required=True) @click.option("--container", "-c", help="CloudVision container where devices are configured", type=str) -def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str) -> None: +def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None) -> None: """ Build ANTA inventory from Cloudvision diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py index 7d7a1a879..ca2c826b9 100644 --- a/anta/cli/get/utils.py +++ b/anta/cli/get/utils.py @@ -51,6 +51,8 @@ def inventory_output_options(f: Any) -> Any: @click.pass_context @functools.wraps(f) def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool, **kwargs: dict[str, Any]) -> Any: + logger.info(args) + logger.info(kwargs) # Boolean to check if the file is empty output_is_not_empty = output.exists() and output.stat().st_size != 0 # Check overwrite when file is not empty diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 7b36ca436..94625300a 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -184,37 +184,48 @@ def inventory_options(f: Any) -> Any: @click.option("--tags", "-t", help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags) @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: - if ctx.params.get("prompt"): + def wrapper( + ctx: click.Context, + *args: tuple[Any], + inventory: str, + username: str, + password: str | None, + enable_password: str | None, + enable: bool, + prompt: bool, + timeout: int, + insecure: bool, + disable_cache: bool, + **kwargs: dict[str, Any], + ) -> Any: + if prompt: # User asked for a password prompt - if ctx.params.get("password") is None: - ctx.params["password"] = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True) - if ctx.params.get("enable"): - if ctx.params.get("enable_password") is None: + if password is None: + password = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True) + if enable: + if enable_password is None: if click.confirm("Is a password required to enter EOS privileged EXEC mode?"): - ctx.params["enable_password"] = click.prompt( + enable_password = click.prompt( "Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True ) - if ctx.params.get("password") is None: + if password is None: raise click.BadParameter("EOS password needs to be provided by using either the '--password' option or the '--prompt' option.") - if not ctx.params.get("enable") and ctx.params.get("enable_password"): + if not enable and enable_password: raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.") try: - inventory = AntaInventory.parse( - filename=ctx.params["inventory"], - username=ctx.params["username"], - password=ctx.params["password"], - enable=ctx.params["enable"], - enable_password=ctx.params["enable_password"], - timeout=ctx.params["timeout"], - insecure=ctx.params["insecure"], - disable_cache=ctx.params["disable_cache"], + inv = AntaInventory.parse( + filename=inventory, + username=username, + password=password, + enable=enable, + enable_password=enable_password, + timeout=timeout, + insecure=insecure, + disable_cache=disable_cache, ) except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): ctx.exit(ExitCode.USAGE_ERROR) - for arg in ["inventory", "username", "password", "enable", "prompt", "timeout", "insecure", "enable_password", "disable_cache", "tags"]: - kwargs.pop(arg) - return f(*args, inventory, ctx.params["tags"], **kwargs) + return f(*args, inv, **kwargs) return wrapper From e58720b5219316b15a6801fbe0771e47bd1de313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 14:38:04 +0100 Subject: [PATCH 29/53] refactor wrappers --- anta/cli/debug/commands.py | 4 ++-- anta/cli/debug/utils.py | 7 ++++--- anta/cli/get/utils.py | 6 ++---- anta/cli/utils.py | 17 +++++++++-------- anta/logger.py | 2 +- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py index 8b9b58587..c253ac3bc 100644 --- a/anta/cli/debug/commands.py +++ b/anta/cli/debug/commands.py @@ -26,7 +26,7 @@ @debug_options @click.pass_context @click.option("--command", "-c", type=str, required=True, help="Command to run") -def run_cmd(ctx: click.Context, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int, device: AntaDevice) -> None: +def run_cmd(ctx: click.Context, device: AntaDevice, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int) -> None: """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 @@ -48,7 +48,7 @@ def run_cmd(ctx: click.Context, command: str, ofmt: Literal["json", "text"], ver @click.option("--template", "-t", type=str, required=True, help="Command template to run. E.g. 'show vlan {vlan_id}'") @click.argument("params", required=True, nargs=-1) def run_template( - ctx: click.Context, template: str, params: list[str], ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int, device: AntaDevice + ctx: click.Context, device: AntaDevice, template: str, params: list[str], ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int ) -> None: # pylint: disable=too-many-arguments """Run arbitrary templated command to an ANTA device. diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py index c7cd4a7cb..f9ffbbcf9 100644 --- a/anta/cli/debug/utils.py +++ b/anta/cli/debug/utils.py @@ -31,12 +31,13 @@ def debug_options(f: Any) -> Any: @click.pass_context @functools.wraps(f) def wrapper(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, device: str, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + # pylint: disable=unused-argument try: - kwargs["device"] = inventory[device] + d = inventory[device] except KeyError as e: - message = f"Device {ctx.params['device']} does not exist in Inventory" + message = f"Device {device} does not exist in Inventory" logger.error(e, message) ctx.exit(ExitCode.USAGE_ERROR) - return f(*args, **kwargs) + return f(*args, device=d, **kwargs) return wrapper diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py index ca2c826b9..a5ae2055c 100644 --- a/anta/cli/get/utils.py +++ b/anta/cli/get/utils.py @@ -50,9 +50,7 @@ def inventory_output_options(f: Any) -> Any: ) @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool, **kwargs: dict[str, Any]) -> Any: - logger.info(args) - logger.info(kwargs) + def wrapper(ctx: click.Context, output: Path, overwrite: bool, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: # Boolean to check if the file is empty output_is_not_empty = output.exists() and output.stat().st_size != 0 # Check overwrite when file is not empty @@ -66,7 +64,7 @@ def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)") ctx.exit(ExitCode.USAGE_ERROR) output.parent.mkdir(parents=True, exist_ok=True) - return f(*args, output, **kwargs) + return f(*args, output=output, **kwargs) return wrapper diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 94625300a..cfaf22c62 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -186,8 +186,8 @@ def inventory_options(f: Any) -> Any: @functools.wraps(f) def wrapper( ctx: click.Context, - *args: tuple[Any], - inventory: str, + inventory: Path, + tags: list[str] | None, username: str, password: str | None, enable_password: str | None, @@ -196,8 +196,10 @@ def wrapper( timeout: int, insecure: bool, disable_cache: bool, + *args: tuple[Any], **kwargs: dict[str, Any], ) -> Any: + # pylint: disable=too-many-arguments if prompt: # User asked for a password prompt if password is None: @@ -213,7 +215,7 @@ def wrapper( if not enable and enable_password: raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.") try: - inv = AntaInventory.parse( + i = AntaInventory.parse( filename=inventory, username=username, password=password, @@ -225,7 +227,7 @@ def wrapper( ) except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): ctx.exit(ExitCode.USAGE_ERROR) - return f(*args, inv, **kwargs) + return f(*args, inventory=i, tags=tags, **kwargs) return wrapper @@ -244,12 +246,11 @@ def catalog_options(f: Any) -> Any: ) @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + def wrapper(ctx: click.Context, catalog: Path, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: try: - catalog = AntaCatalog.parse(ctx.params["catalog"]) + c = AntaCatalog.parse(catalog) except (ValidationError, TypeError, ValueError, YAMLError, OSError): ctx.exit(ExitCode.USAGE_ERROR) - kwargs.pop("catalog") - return f(*args, catalog, **kwargs) + return f(*args, catalog=c, **kwargs) return wrapper diff --git a/anta/logger.py b/anta/logger.py index 74b78d08b..5118e941c 100644 --- a/anta/logger.py +++ b/anta/logger.py @@ -64,7 +64,7 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: logging.getLogger("httpx").setLevel(logging.WARNING) # Add RichHandler for stdout - richHandler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=True) + richHandler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False) # In ANTA debug mode, show Python module in stdout if __DEBUG__: fmt_string = r"[grey58]\[%(name)s][/grey58] %(message)s" From 9f1d208a704b8193bf462e48b68304765557b667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 14:45:24 +0100 Subject: [PATCH 30/53] refactor wrapper AGAIN --- anta/cli/debug/utils.py | 2 +- anta/cli/get/utils.py | 2 +- anta/cli/utils.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py index f9ffbbcf9..650482dce 100644 --- a/anta/cli/debug/utils.py +++ b/anta/cli/debug/utils.py @@ -30,7 +30,7 @@ def debug_options(f: Any) -> Any: @click.option("--device", "-d", type=str, required=True, help="Device from inventory to use") @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, device: str, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + def wrapper(ctx: click.Context, *args: tuple[Any], inventory: AntaInventory, tags: list[str] | None, device: str, **kwargs: dict[str, Any]) -> Any: # pylint: disable=unused-argument try: d = inventory[device] diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py index a5ae2055c..8b4e26509 100644 --- a/anta/cli/get/utils.py +++ b/anta/cli/get/utils.py @@ -50,7 +50,7 @@ def inventory_output_options(f: Any) -> Any: ) @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, output: Path, overwrite: bool, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool, **kwargs: dict[str, Any]) -> Any: # Boolean to check if the file is empty output_is_not_empty = output.exists() and output.stat().st_size != 0 # Check overwrite when file is not empty diff --git a/anta/cli/utils.py b/anta/cli/utils.py index cfaf22c62..a1f48a784 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -186,6 +186,7 @@ def inventory_options(f: Any) -> Any: @functools.wraps(f) def wrapper( ctx: click.Context, + *args: tuple[Any], inventory: Path, tags: list[str] | None, username: str, @@ -196,7 +197,6 @@ def wrapper( timeout: int, insecure: bool, disable_cache: bool, - *args: tuple[Any], **kwargs: dict[str, Any], ) -> Any: # pylint: disable=too-many-arguments @@ -246,7 +246,7 @@ def catalog_options(f: Any) -> Any: ) @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, catalog: Path, *args: tuple[Any], **kwargs: dict[str, Any]) -> Any: + def wrapper(ctx: click.Context, *args: tuple[Any], catalog: Path, **kwargs: dict[str, Any]) -> Any: try: c = AntaCatalog.parse(catalog) except (ValidationError, TypeError, ValueError, YAMLError, OSError): From 583e3c6ced78d64956c92b7bf523d6a2eea39640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 14:56:06 +0100 Subject: [PATCH 31/53] consume ignore_status and ignore_error --- anta/cli/nrfu/__init__.py | 4 ++++ anta/cli/utils.py | 7 +++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 077bfd273..dcca2ed89 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -29,7 +29,11 @@ @click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None: """Run NRFU against inventory devices""" + # 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 print_settings(inventory, catalog) with anta_progress_bar() as AntaTest.progress: asyncio.run(main(ctx.obj["result_manager"], inventory, catalog, tags=tags)) diff --git a/anta/cli/utils.py b/anta/cli/utils.py index a1f48a784..06e56544e 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -71,12 +71,11 @@ def exit_with_code(ctx: click.Context) -> None: Args: ctx: Click Context """ - - if ctx.params.get("ignore_status"): - ctx.exit(0) + if ctx.obj.get("ignore_status"): + ctx.exit(ExitCode.OK) # If ignore_error is True then status can never be "error" - status = ctx.obj["result_manager"].get_status(ignore_error=bool(ctx.params.get("ignore_error"))) + status = ctx.obj["result_manager"].get_status(ignore_error=bool(ctx.obj.get("ignore_error"))) if status in {"unset", "skipped", "success"}: ctx.exit(ExitCode.OK) From 57e1b72a7b396c0469160fbf069267bd1be6aef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 15:06:26 +0100 Subject: [PATCH 32/53] Update tests/lib/fixture.py Co-authored-by: Guillaume Mulocher --- tests/lib/fixture.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 98ce50c44..3d3fe2582 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -169,8 +169,6 @@ def _factory(number: int = 0) -> ResultManager: # tests.units.cli fixtures - - @pytest.fixture def temp_env(tmp_path: Path) -> dict[str, str | None]: """Fixture that create a temporary ANTA inventory that can be overriden From 2de4bcab5326c273585e2003d294ec2af10597dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 15:06:38 +0100 Subject: [PATCH 33/53] Update anta/cli/debug/utils.py Co-authored-by: Guillaume Mulocher --- anta/cli/debug/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py index 650482dce..b85a34671 100644 --- a/anta/cli/debug/utils.py +++ b/anta/cli/debug/utils.py @@ -2,7 +2,6 @@ # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -# coding: utf-8 -*- """ Utils functions to use with anta.cli.debug module. """ From 79880b0d8842ccfc3bd6783821815176774bf7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 15:07:25 +0100 Subject: [PATCH 34/53] Update tests/lib/fixture.py Co-authored-by: Guillaume Mulocher --- tests/lib/fixture.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 3d3fe2582..5023bb765 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -105,8 +105,6 @@ def async_device(request: pytest.FixtureRequest) -> AsyncEOSDevice: # tests.units.result_manager fixtures - - @pytest.fixture def test_result_factory(device: AntaDevice) -> Callable[[int], TestResult]: """ From 596442748ba4377007c81dbc5ea9eda0620c256a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 15:07:35 +0100 Subject: [PATCH 35/53] Update tests/lib/fixture.py Co-authored-by: Guillaume Mulocher --- tests/lib/fixture.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 5023bb765..7940513b2 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -87,8 +87,6 @@ def test_inventory() -> AntaInventory: # tests.unit.test_device.py fixture - - @pytest.fixture def async_device(request: pytest.FixtureRequest) -> AsyncEOSDevice: """ From dcc82af7645f29c3a62039f48e42e40b9ee25f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 15:08:20 +0100 Subject: [PATCH 36/53] Update anta/cli/debug/utils.py Co-authored-by: Guillaume Mulocher --- anta/cli/debug/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py index b85a34671..285fe3494 100644 --- a/anta/cli/debug/utils.py +++ b/anta/cli/debug/utils.py @@ -1,4 +1,3 @@ -#!/usr/bin/python # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. From 4fbd051b4873add497592f4f5983d3d2a10c20c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 15:09:35 +0100 Subject: [PATCH 37/53] remove shebangs --- anta/cli/console.py | 2 -- anta/cli/utils.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/anta/cli/console.py b/anta/cli/console.py index 90285f1d4..9343b3dd5 100644 --- a/anta/cli/console.py +++ b/anta/cli/console.py @@ -1,8 +1,6 @@ -#!/usr/bin/env python # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -# coding: utf-8 -*- """ ANTA Top-level Console https://rich.readthedocs.io/en/stable/console.html#console-api diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 06e56544e..1845a749b 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -1,8 +1,6 @@ -#!/usr/bin/python # Copyright (c) 2023 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -# coding: utf-8 -*- """ Utils functions to use with anta.cli module. """ From 2ea91e3356f864887f13caa7ba97ac7c9956991e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 20:09:02 +0100 Subject: [PATCH 38/53] test revision and version in unit tests --- anta/cli/__init__.py | 2 +- anta/cli/get/__init__.py | 2 +- anta/cli/get/commands.py | 2 +- tests/lib/fixture.py | 2 +- tests/units/cli/debug/test_commands.py | 9 ++++++--- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index 7dc757879..9c89ec06c 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -36,7 +36,7 @@ ) @click.option( "--log-level", - "--log", + "-log", help="ANTA logging level", default=logging.getLevelName(logging.INFO), show_envvar=True, diff --git a/anta/cli/get/__init__.py b/anta/cli/get/__init__.py index e75c79b35..9e9c50d67 100644 --- a/anta/cli/get/__init__.py +++ b/anta/cli/get/__init__.py @@ -2,7 +2,7 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """ -Click commands to get informations from inventories or generate them +Click commands to get information from or generate inventories """ import click diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index 5792f1af4..6008adf6e 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -3,7 +3,7 @@ # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name """ -Click commands to get informations from inventories or generate them +Click commands to get information from or generate inventories """ from __future__ import annotations diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 7940513b2..365f558c1 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -198,7 +198,7 @@ def invoke(self, *args, **kwargs) -> Result: # type: ignore[no-untyped-def] return result def cli( - command: str | None = None, commands: list[dict[str, Any]] | None = None, ofmt: str = "json", **kwargs: dict[str, Any] + command: str | None = None, commands: list[dict[str, Any]] | None = None, ofmt: str = "json", version: int | str | None = "latest", **kwargs: Any ) -> dict[str, Any] | list[dict[str, Any]]: # pylint: disable=unused-argument def get_output(command: str | dict[str, Any]) -> dict[str, Any]: diff --git a/tests/units/cli/debug/test_commands.py b/tests/units/cli/debug/test_commands.py index a78c34402..2c8ba5a1e 100644 --- a/tests/units/cli/debug/test_commands.py +++ b/tests/units/cli/debug/test_commands.py @@ -17,12 +17,12 @@ from click.testing import CliRunner -# TODO complete test cases @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"), @@ -35,7 +35,7 @@ def test_run_cmd( Test `anta debug run-cmd` """ # pylint: disable=too-many-arguments - cli_args = ["debug", "run-cmd", "--command", command, "--device", device] + cli_args = ["-log", "debug", "debug", "run-cmd", "--command", command, "--device", device] # ofmt if ofmt is not None: @@ -43,7 +43,6 @@ def test_run_cmd( # version if version is not None: - # Need to copy ugly hack here.. cli_args.extend(["--version", version]) # revision @@ -55,3 +54,7 @@ def test_run_cmd( assert result.exit_code == ExitCode.USAGE_ERROR else: assert result.exit_code == ExitCode.OK + if revision is not None: + assert f"revision={revision}" in result.output + if version is not None: + assert (f"version='{version}'" if version == "latest" else f"version={version}") in result.output From 6f59697dbd1cc5b6601fa58217c97c27b7dc86a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 20:45:27 +0100 Subject: [PATCH 39/53] fix anta nrfu --help --- anta/cli/nrfu/__init__.py | 37 +++++++++++++++++++++++++-- anta/cli/utils.py | 25 ++++++++++++++++-- tests/units/cli/nrfu/test_commands.py | 36 ++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index dcca2ed89..1cd97f8d0 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -12,7 +12,7 @@ from anta.catalog import AntaCatalog from anta.cli.nrfu import commands -from anta.cli.utils import catalog_options, inventory_options +from anta.cli.utils import AliasedGroup, catalog_options, inventory_options from anta.inventory import AntaInventory from anta.models import AntaTest from anta.result_manager import ResultManager @@ -21,7 +21,37 @@ from .utils import anta_progress_bar, print_settings -@click.group(invoke_without_command=True) +class IgnoreRequiredWithHelp(AliasedGroup): + """ + https://stackoverflow.com/questions/55818737/python-click-application-required-parameters-have-precedence-over-sub-command-he + Solution to allow help without required options on subcommand + This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734 + """ + + 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) + if "--help" in args: + ctx.obj["_anta_help"] = True + + try: + return super().parse_args(ctx, args) + except click.MissingParameter: + if "--help" not in args: + raise + + # remove the required params so that help can display + for param in self.params: + param.required = False + + return super().parse_args(ctx, args) + + +@click.group(invoke_without_command=True, cls=IgnoreRequiredWithHelp) @click.pass_context @inventory_options @catalog_options @@ -29,6 +59,9 @@ @click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None: """Run NRFU against inventory devices""" + # If help is invoke somewhere, skip the command + if ctx.obj["_anta_help"]: + return # We use ctx.obj to pass stuff to the next Click functions ctx.ensure_object(dict) ctx.obj["result_manager"] = ResultManager() diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 1845a749b..f9d9e6ce9 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -136,12 +136,14 @@ def inventory_options(f: Any) -> Any: "--enable-password", help="Password to access EOS Privileged EXEC mode. It can be prompted using '--prompt' option. Requires '--enable' option.", show_envvar=True, + envvar="ANTA_ENABLE_PASSWORD", ) @click.option( "--enable", help="Some commands may require EOS Privileged EXEC mode. This option tries to access this mode before sending a command to the device.", default=False, show_envvar=True, + envvar="ANTA_ENABLE", is_flag=True, show_default=True, ) @@ -150,6 +152,8 @@ def inventory_options(f: Any) -> Any: "-P", help="Prompt for passwords if they are not provided.", default=False, + show_envvar=True, + envvar="ANTA_PROMPT", is_flag=True, show_default=True, ) @@ -158,6 +162,7 @@ def inventory_options(f: Any) -> Any: help="Global connection timeout", default=30, show_envvar=True, + envvar="ANTA_TIMEOUT", show_default=True, ) @click.option( @@ -165,10 +170,11 @@ def inventory_options(f: Any) -> Any: help="Disable SSH Host Key validation", default=False, show_envvar=True, + envvar="ANTA_INSECURE", is_flag=True, show_default=True, ) - @click.option("--disable-cache", help="Disable cache globally", show_envvar=True, show_default=True, is_flag=True, default=False) + @click.option("--disable-cache", help="Disable cache globally", show_envvar=True, envvar="ANTA_DISABLE_CACHE", show_default=True, is_flag=True, default=False) @click.option( "--inventory", "-i", @@ -178,7 +184,16 @@ def inventory_options(f: Any) -> Any: required=True, type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), ) - @click.option("--tags", "-t", help="List of tags using comma as separator: tag1,tag2,tag3", type=str, required=False, callback=parse_tags) + @click.option( + "--tags", + "-t", + 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( @@ -197,6 +212,9 @@ def wrapper( **kwargs: dict[str, Any], ) -> Any: # pylint: disable=too-many-arguments + # If help is invoke somewhere, do not parse inventory + if ctx.obj["_anta_help"]: + return f(*args, inventory=None, tags=tags, **kwargs) if prompt: # User asked for a password prompt if password is None: @@ -244,6 +262,9 @@ def catalog_options(f: Any) -> Any: @click.pass_context @functools.wraps(f) def wrapper(ctx: click.Context, *args: tuple[Any], catalog: Path, **kwargs: dict[str, Any]) -> Any: + # If help is invoke somewhere, do not parse catalog + if ctx.obj["_anta_help"]: + return f(*args, catalog=None, **kwargs) try: c = AntaCatalog.parse(catalog) except (ValidationError, TypeError, ValueError, YAMLError, OSError): diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 8f3a03c79..eb6c5db8a 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -18,6 +18,42 @@ DATA_DIR: Path = Path(__file__).parent.parent.parent.parent.resolve() / "data" +def test_anta_nrfu_table_help(click_runner: CliRunner) -> None: + """ + Test anta nrfu table --help + """ + result = click_runner.invoke(anta, ["nrfu", "table", "--help"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta nrfu table" in result.output + + +def test_anta_nrfu_text_help(click_runner: CliRunner) -> None: + """ + Test anta nrfu text --help + """ + result = click_runner.invoke(anta, ["nrfu", "text", "--help"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta nrfu text" in result.output + + +def test_anta_nrfu_json_help(click_runner: CliRunner) -> None: + """ + Test anta nrfu json --help + """ + result = click_runner.invoke(anta, ["nrfu", "json", "--help"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta nrfu json" in result.output + + +def test_anta_nrfu_template_help(click_runner: CliRunner) -> None: + """ + Test anta nrfu tpl-report --help + """ + result = click_runner.invoke(anta, ["nrfu", "tpl-report", "--help"]) + assert result.exit_code == ExitCode.OK + assert "Usage: anta nrfu tpl-report" in result.output + + def test_anta_nrfu_table(click_runner: CliRunner) -> None: """ Test anta nrfu, catalog is given via env From 152fb1716a00b7967a1cca5a8badaf089cd20e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 20:48:20 +0100 Subject: [PATCH 40/53] fix cli --- anta/cli/nrfu/__init__.py | 2 +- anta/cli/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 1cd97f8d0..6bc9bbd0c 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -60,7 +60,7 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None: """Run NRFU against inventory devices""" # If help is invoke somewhere, skip the command - if ctx.obj["_anta_help"]: + if ctx.obj.get("_anta_help"): return # We use ctx.obj to pass stuff to the next Click functions ctx.ensure_object(dict) diff --git a/anta/cli/utils.py b/anta/cli/utils.py index f9d9e6ce9..3b30eb824 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -213,7 +213,7 @@ def wrapper( ) -> Any: # pylint: disable=too-many-arguments # If help is invoke somewhere, do not parse inventory - if ctx.obj["_anta_help"]: + if ctx.obj.get("_anta_help"): return f(*args, inventory=None, tags=tags, **kwargs) if prompt: # User asked for a password prompt @@ -263,7 +263,7 @@ def catalog_options(f: Any) -> Any: @functools.wraps(f) def wrapper(ctx: click.Context, *args: tuple[Any], catalog: Path, **kwargs: dict[str, Any]) -> Any: # If help is invoke somewhere, do not parse catalog - if ctx.obj["_anta_help"]: + if ctx.obj.get("_anta_help"): return f(*args, catalog=None, **kwargs) try: c = AntaCatalog.parse(catalog) From 4c1f2489f4d281bf2c93d47e2b34566784750c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Mon, 4 Dec 2023 20:55:23 +0100 Subject: [PATCH 41/53] fix ci --- anta/cli/check/commands.py | 2 +- tests/units/cli/check/test_commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/anta/cli/check/commands.py b/anta/cli/check/commands.py index 1460580a0..dcaad19ae 100644 --- a/anta/cli/check/commands.py +++ b/anta/cli/check/commands.py @@ -25,5 +25,5 @@ def catalog(catalog: AntaCatalog) -> None: """ Check that the catalog is valid """ - console.print(f"[bold][green]Catalog {catalog.filename} is valid") + console.print(f"[bold][green]Catalog is valid: {catalog.filename}") console.print(pretty_repr(catalog.tests)) diff --git a/tests/units/cli/check/test_commands.py b/tests/units/cli/check/test_commands.py index f18d53ad6..d5cee4640 100644 --- a/tests/units/cli/check/test_commands.py +++ b/tests/units/cli/check/test_commands.py @@ -25,7 +25,7 @@ [ pytest.param("ghost_catalog.yml", ExitCode.USAGE_ERROR, "Error: Invalid value for '--catalog'", id="catalog does not exist"), pytest.param("test_catalog_with_undefined_module.yml", ExitCode.USAGE_ERROR, "Test catalog is invalid!", id="catalog is not valid"), - pytest.param("test_catalog.yml", ExitCode.OK, f"Catalog {DATA_DIR}/test_catalog.yml is valid", id="catalog valid"), + pytest.param("test_catalog.yml", ExitCode.OK, "Catalog is valid", id="catalog valid"), ], ) def test_catalog(click_runner: CliRunner, catalog_path: Path, expected_exit: int, expected_output: str) -> None: From 8a32bd4ef8dc18ec6a0888fe6c7b98529c747f64 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Wed, 6 Dec 2023 09:48:08 +0100 Subject: [PATCH 42/53] Doc: Update CLI doc --- docs/README.md | 32 +------- docs/cli/check.md | 36 +++++++++ docs/cli/debug.md | 60 ++++++++++++++- docs/cli/exec.md | 102 ++++++++++++++++++++++---- docs/cli/get-inventory-information.md | 50 ++++++++++++- docs/cli/nrfu.md | 50 +++++++++---- docs/cli/overview.md | 40 ++++++---- docs/cli/tag-management.md | 8 +- docs/snippets/anta_help.txt | 30 +------- mkdocs.yml | 1 + 10 files changed, 301 insertions(+), 108 deletions(-) create mode 100644 docs/cli/check.md diff --git a/docs/README.md b/docs/README.md index baa42d7a5..a43037d1e 100755 --- a/docs/README.md +++ b/docs/README.md @@ -38,49 +38,21 @@ Usage: anta [OPTIONS] COMMAND [ARGS]... Options: --version Show the version and exit. - --username TEXT Username to connect to EOS [env var: - ANTA_USERNAME; required] - --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. - --timeout INTEGER Global connection timeout [env var: - ANTA_TIMEOUT; default: 30] - --insecure Disable SSH Host Key validation [env var: - ANTA_INSECURE] - -i, --inventory FILE Path to the inventory YAML file [env var: - ANTA_INVENTORY; required] --log-file FILE Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout. [env var: ANTA_LOG_FILE] - --log-level, --log [CRITICAL|ERROR|WARNING|INFO|DEBUG] + -log, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG] ANTA logging level [env var: ANTA_LOG_LEVEL; default: INFO] - --ignore-status Always exit with success [env var: - ANTA_IGNORE_STATUS] - --ignore-error Only report failures and not errors [env - var: ANTA_IGNORE_ERROR] - --disable-cache Disable cache globally [env var: - ANTA_DISABLE_CACHE] --help Show this message and exit. Commands: + check Check commands for building ANTA debug Debug commands for building ANTA exec Execute commands to inventory devices get Get data from/to ANTA nrfu Run NRFU against inventory devices ``` -> `username`, `password`, `enable-password`, `enable`, `timeout` and `insecure` values are the same for all devices - ## Documentation diff --git a/docs/cli/check.md b/docs/cli/check.md new file mode 100644 index 000000000..dffbeae7c --- /dev/null +++ b/docs/cli/check.md @@ -0,0 +1,36 @@ + + +# ANTA check commands + +The ANTA check command allow to execute some checks on the ANTA input files. +Only checking the catalog is currently supported. + +```bash +anta check --help +Usage: anta check [OPTIONS] COMMAND [ARGS]... + + Check commands for building ANTA + +Options: + --help Show this message and exit. + +Commands: + catalog Check that the catalog is valid +``` + +## Checking the catalog + +```bash +Usage: anta check catalog [OPTIONS] + + 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. +``` diff --git a/docs/cli/debug.md b/docs/cli/debug.md index 3213cc82b..7eb83b404 100644 --- a/docs/cli/debug.md +++ b/docs/cli/debug.md @@ -29,14 +29,41 @@ Usage: anta debug run-cmd [OPTIONS] Run arbitrary command to an ANTA device Options: - -c, --command TEXT Command to run [required] + -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 INTEGER Global connection timeout [env var: ANTA_TIMEOUT; + default: 30] + --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] + -t, --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 -d, --device TEXT Device from inventory to use [required] + -c, --command TEXT Command to run [required] --help Show this message and exit. ``` +> `username`, `password`, `enable-password`, `enable`, `timeout` and `insecure` values are the same for all devices + ### Example This example illustrates how to run the `show interfaces description` command with a `JSON` format (default): @@ -74,15 +101,42 @@ Usage: anta debug run-template [OPTIONS] PARAMS... anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1 Options: - -t, --template TEXT Command template to run. E.g. 'show vlan - {vlan_id}' [required] + -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 INTEGER Global connection timeout [env var: ANTA_TIMEOUT; + default: 30] + --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] + -t, --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 -d, --device TEXT Device from inventory to use [required] + -t, --template TEXT Command template to run. E.g. 'show vlan + {vlan_id}' [required] --help Show this message and exit. ``` +> `username`, `password`, `enable-password`, `enable`, `timeout` and `insecure` values are the same for all devices + ### Example This example uses the `show vlan {vlan_id}` command in a `JSON` format: diff --git a/docs/cli/exec.md b/docs/cli/exec.md index 2fe726193..e0c158875 100644 --- a/docs/cli/exec.md +++ b/docs/cli/exec.md @@ -37,10 +37,34 @@ Usage: anta exec clear-counters [OPTIONS] Clear counter statistics on EOS devices Options: - -t, --tags TEXT List of tags using comma as separator: tag1,tag2,tag3 - --help Show this message and exit. + -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 INTEGER Global connection timeout [env var: ANTA_TIMEOUT; + default: 30] + --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] + -t, --tags TEXT List of tags using comma as separator: + tag1,tag2,tag3 [env var: ANTA_TAGS] + --help Show this message and exit. ``` +> `username`, `password`, `enable-password`, `enable`, `timeout` and `insecure` values are the same for all devices + ### Example ```bash @@ -66,17 +90,41 @@ Usage: anta exec snapshot [OPTIONS] Collect commands output from devices in 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 INTEGER Global connection timeout [env var: ANTA_TIMEOUT; + default: 30] + --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] -t, --tags TEXT List of tags using comma as separator: - tag1,tag2,tag3 + tag1,tag2,tag3 [env var: ANTA_TAGS] -c, --commands-list FILE File with list of commands to collect [env var: ANTA_EXEC_SNAPSHOT_COMMANDS_LIST; required] - -o, --output DIRECTORY Directory to save commands output. Will have a - suffix with the format _YEAR-MONTH-DAY_HOUR- - MINUTES-SECONDS' [env var: - ANTA_EXEC_SNAPSHOT_OUTPUT; default: anta_snapshot] + -o, --output DIRECTORY Directory to save commands output. [env var: + ANTA_EXEC_SNAPSHOT_OUTPUT; default: + anta_snapshot_2023-12-06_09_22_11] --help Show this message and exit. ``` +> `username`, `password`, `enable-password`, `enable`, `timeout` and `insecure` values are the same for all devices + The commands-list file should follow this structure: ```yaml @@ -161,17 +209,39 @@ Usage: anta exec collect-tech-support [OPTIONS] Collect scheduled tech-support from EOS devices Options: - -o, --output PATH Path for test catalog [default: ./tech- - support] - --latest INTEGER Number of scheduled show-tech to retrieve - --configure Ensure devices have 'aaa authorization exec default - local' configured (required for SCP on EOS). THIS WILL - CHANGE THE CONFIGURATION OF YOUR NETWORK. - -t, --tags TEXT List of tags using comma as separator: - tag1,tag2,tag3 - --help Show this message and exit. + -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 INTEGER Global connection timeout [env var: ANTA_TIMEOUT; + default: 30] + --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] + -t, --tags TEXT List of tags using comma as separator: + tag1,tag2,tag3 [env var: ANTA_TAGS] + -o, --output PATH Path for test catalog [default: ./tech-support] + --latest INTEGER Number of scheduled show-tech to retrieve + --configure Ensure devices have 'aaa authorization exec default + local' configured (required for SCP on EOS). THIS + WILL CHANGE THE CONFIGURATION OF YOUR NETWORK. + --help Show this message and exit. ``` +> `username`, `password`, `enable-password`, `enable`, `timeout` and `insecure` values are the same for all devices + When executed, this command fetches tech-support files and downloads them locally into a device-specific subfolder within the designated folder. You can specify the output folder with the `--output` option. ANTA uses SCP to download files from devices and will not trust unknown SSH hosts by default. Add the SSH public keys of your devices to your `known_hosts` file or use the `anta --insecure` option to ignore SSH host keys validation. diff --git a/docs/cli/get-inventory-information.md b/docs/cli/get-inventory-information.md index f482cad66..adfe9f3af 100644 --- a/docs/cli/get-inventory-information.md +++ b/docs/cli/get-inventory-information.md @@ -78,7 +78,30 @@ Usage: anta get tags [OPTIONS] Get list of configured tags in user inventory. Options: - --help Show this message and exit. + -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 INTEGER Global connection timeout [env var: ANTA_TIMEOUT; + default: 30] + --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] + -t, --tags TEXT List of tags using comma as separator: + tag1,tag2,tag3 [env var: ANTA_TAGS] + --help Show this message and exit. ``` ### Example @@ -115,8 +138,31 @@ 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 INTEGER Global connection timeout [env var: + ANTA_TIMEOUT; default: 30] + --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] -t, --tags TEXT List of tags using comma as separator: - tag1,tag2,tag3 + tag1,tag2,tag3 [env var: ANTA_TAGS] --connected / --not-connected Display inventory after connection has been created --help Show this message and exit. diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index 90617d086..cf51ff91d 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -22,9 +22,36 @@ Usage: anta nrfu [OPTIONS] COMMAND [ARGS]... Run NRFU against inventory devices Options: - -c, --catalog FILE Path to the test catalog YAML file [env var: - ANTA_CATALOG; required] - --help Show this message and exit. + -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 INTEGER Global connection timeout [env var: ANTA_TIMEOUT; + default: 30] + --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] + -t, --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] + --ignore-status Always exit with success [env var: + ANTA_NRFU_IGNORE_STATUS] + --ignore-error Only report failures and not errors [env var: + ANTA_NRFU_IGNORE_ERROR] + --help Show this message and exit. Commands: json ANTA command to check network state with JSON result @@ -33,6 +60,8 @@ Commands: tpl-report ANTA command to check network state with templated report ``` +> `username`, `password`, `enable-password`, `enable`, `timeout` and `insecure` values are the same for all devices + All commands under the `anta nrfu` namespace require a catalog yaml file specified with the `--catalog` option. ## Tag management @@ -61,13 +90,12 @@ Usage: anta nrfu text [OPTIONS] ANTA command to check network states with text result Options: - -t, --tags TEXT List of tags using comma as separator: tag1,tag2,tag3 -s, --search TEXT Regular expression to search in both name and test --skip-error Hide tests in errors due to connectivity issue --help Show this message and exit. ``` -The [`--tags` option allows](#tag-management) to target specific devices in your inventory and run tests with the exact same tags from your catalog, while the `--search` option permits filtering based on a regular expression pattern in both the hostname and the test name. +The `--search` option permits filtering based on a regular expression pattern in both the hostname and the test name. The `--skip-error` option can be used to exclude tests that failed due to connectivity issues or unsupported commands. @@ -91,8 +119,6 @@ Usage: anta nrfu table [OPTIONS] ANTA command to check network states with table result Options: - --tags TEXT List of tags using comma as separator: - tag1,tag2,tag3 -d, --device TEXT Show a summary for this device -t, --test TEXT Show a summary for this test --group-by [device|test] Group result by test or host. default none @@ -106,7 +132,7 @@ The `--group-by` option show a summarized view of the test results per host or p ### Examples ```bash -anta nrfu table --tags LEAF +anta nrfu --tags LEAF table ``` [![anta nrfu table results](../imgs/anta-nrfu-table-output.png){ loading=lazy width="1600" }](../imgs/anta-nrfu-table-output.png) @@ -147,7 +173,6 @@ Usage: anta nrfu json [OPTIONS] ANTA command to check network state with JSON result Options: - -t, --tags TEXT List of tags using comma as separator: tag1,tag2,tag3 -o, --output FILE Path to save report as a file [env var: ANTA_NRFU_JSON_OUTPUT] --help Show this message and exit. @@ -158,7 +183,7 @@ The `--output` option allows you to save the JSON report as a file. ### Example ```bash -anta nrfu json --tags LEAF +anta nrfu --tags LEAF json ``` [![anta nrfu json results](../imgs/anta-nrfu-json-output.png){ loading=lazy width="1600" }](../imgs/anta-nrfu-json-output.png) @@ -179,7 +204,6 @@ Options: ANTA_NRFU_TPL_REPORT_TEMPLATE; required] -o, --output FILE Path to save report as a file [env var: ANTA_NRFU_TPL_REPORT_OUTPUT] - -t, --tags TEXT List of tags using comma as separator: tag1,tag2,tag3 --help Show this message and exit. ``` The `--template` option is used to specify the Jinja2 template file for generating the custom report. @@ -189,7 +213,7 @@ The `--output` option allows you to choose the path where the final report will ### Example ```bash -anta nrfu tpl-report --tags LEAF --template ./custom_template.j2 +anta nrfu --tags LEAF tpl-report --template ./custom_template.j2 ``` [![anta nrfu json results](../imgs/anta-nrfu-tpl-report-output.png){ loading=lazy width="1600" }](../imgs/anta-nrfu-tpl-report-output.png) @@ -206,7 +230,7 @@ The Jinja2 template has access to all `TestResult` elements and their values, as You can also save the report result to a file using the `--output` option: ```bash -anta nrfu tpl-report --tags LEAF --template ./custom_template.j2 --output nrfu-tpl-report.txt +anta nrfu --tags LEAF tpl-report --template ./custom_template.j2 --output nrfu-tpl-report.txt ``` The resulting output might look like this: diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 56540cc60..6e9b0ac34 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -19,14 +19,14 @@ $ anta --help --8<-- "anta_help.txt" ``` -## ANTA Global Parameters +## ANTA Parameters as environement variables -Certain parameters are globally required and can be either passed to the ANTA CLI or set as an environment variable (ENV VAR). +Certain parameters are required and can be either passed to the ANTA CLI or set as an environment variable (ENV VAR). To pass the parameters via the CLI: ```bash -anta --username tom --password arista123 --inventory inventory.yml +anta nrfu --username tom --password arista123 --inventory inventory.yml ``` To set them as ENV VAR: @@ -40,27 +40,41 @@ export ANTA_INVENTORY=inventory.yml Then, run the CLI: ```bash -anta +anta nrfu ``` !!! info Caching can be disabled with the global parameter `--disable-cache`. For more details about how caching is implemented in ANTA, please refer to [Caching in ANTA](../advanced_usages/caching.md). -## ANTA Exit Codes +### List of available environment variables + +!!! note + All environement variables may not be needed for every commands. -ANTA utilizes different exit codes to indicate the status of the test runs. +| Variable Name | Purpose | +| ------------- | ------- | +| ANTA_USERNAME | The username to use in the inventory to connect to devices. | +| ANTA_PASSWORD | The password to use in the inventory to connect to devices. | +| ANTA_INVENTORY | The path to the inventory file. | +| ANTA_CATALOG | The path to the catalog file. | +| ANTA_PROMPT | The value to pass to the prompt for password is password is not provided | +| ANTA_INSECURE | Whether or not using insecure mode when connecting to the EOS devices HTTP API. | +| ANTA_DISABLE_CACHE | A variable to disable caching for all ANTA tests (enabled by default). | +| ANTA_ENABLE | Whether it is necessary to go to enable mode on devices. | +| ANTA_ENABLE_PASSWORD | The optional enable password, when this variable is set, ANTA_ENABLE or `--enable` is required. | -For all subcommands, ANTA will return the exit code 0, indicating a successful operation, except for the nrfu command. +## ANTA Exit Codes -For the nrfu command, ANTA uses the following exit codes: +ANTA CLI utilizes the following exit codes: - `Exit code 0` - All tests passed successfully. -- `Exit code 1` - Tests were run, but at least one test returned a failure. -- `Exit code 2` - Tests were run, but at least one test returned an error. -- `Exit code 3` - An internal error occurred while executing tests. +- `Exit code 1` - An internal error occurred while executing ANTA. +- `Exit code 2` - A usage error was raised. +- `Exit code 3` - Tests were run, but at least one test returned an error. +- `Exit code 4` - Tests were run, but at least one test returned a failure. -To ignore the test status, use `anta --ignore-status nrfu`, and the exit code will always be 0. +To ignore the test status, use `anta nrfu --ignore-status`, and the exit code will always be 0. -To ignore errors, use `anta --ignore-error nrfu`, and the exit code will be 0 if all tests succeeded or 1 if any test failed. +To ignore errors, use `anta nrfu --ignore-error`, and the exit code will be 0 if all tests succeeded or 1 if any test failed. ## Shell Completion diff --git a/docs/cli/tag-management.md b/docs/cli/tag-management.md index 3ec24f276..a08cee651 100644 --- a/docs/cli/tag-management.md +++ b/docs/cli/tag-management.md @@ -8,7 +8,9 @@ ## Overview -The `anta nrfu` command comes with a `--tags` option. 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. +Some of the ANTA commands like `anta nrfu` command come with a `--tags` option. + +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. 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. @@ -114,7 +116,7 @@ The most used approach is to use a single tag in your CLI to filter tests & devi In such scenario, ANTA will run tests marked with `$tag` only on devices marked with `$tag`. All other tests and devices will be ignored ```bash -$ anta nrfu -c .personal/catalog-class.yml text --tags leaf +$ anta nrfu -c .personal/catalog-class.yml --tags leaf text ╭────────────────────── Settings ──────────────────────╮ │ Running ANTA tests: │ │ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ @@ -144,7 +146,7 @@ A more advanced usage of the tag feature is to list multiple tags in your CLI us 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` ```bash -anta nrfu -c .personal/catalog-class.yml text --tags leaf,fabric +anta nrfu -c .personal/catalog-class.yml --tags leaf,fabric text spine01 :: VerifyUptime :: SUCCESS spine02 :: VerifyUptime :: SUCCESS diff --git a/docs/snippets/anta_help.txt b/docs/snippets/anta_help.txt index b1aec056a..96c86b586 100644 --- a/docs/snippets/anta_help.txt +++ b/docs/snippets/anta_help.txt @@ -4,42 +4,16 @@ Usage: anta [OPTIONS] COMMAND [ARGS]... Options: --version Show the version and exit. - --username TEXT Username to connect to EOS [env var: - ANTA_USERNAME; required] - --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. - --timeout INTEGER Global connection timeout [env var: - ANTA_TIMEOUT; default: 30] - --insecure Disable SSH Host Key validation [env var: - ANTA_INSECURE] - -i, --inventory FILE Path to the inventory YAML file [env var: - ANTA_INVENTORY; required] --log-file FILE Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout. [env var: ANTA_LOG_FILE] - --log-level, --log [CRITICAL|ERROR|WARNING|INFO|DEBUG] + -log, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG] ANTA logging level [env var: ANTA_LOG_LEVEL; default: INFO] - --ignore-status Always exit with success [env var: - ANTA_IGNORE_STATUS] - --ignore-error Only report failures and not errors [env - var: ANTA_IGNORE_ERROR] - --disable-cache Disable cache globally [env var: - ANTA_DISABLE_CACHE] --help Show this message and exit. Commands: + check Check commands for building ANTA debug Debug commands for building ANTA exec Execute commands to inventory devices get Get data from/to ANTA diff --git a/mkdocs.yml b/mkdocs.yml index 3fd647743..473154b41 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -164,6 +164,7 @@ 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 - Tag Management: cli/tag-management.md - Advanced Usages: From 12172e64a10baa269ca4c16b7fb759917995633e Mon Sep 17 00:00:00 2001 From: gmuloc Date: Wed, 6 Dec 2023 10:39:16 +0100 Subject: [PATCH 43/53] Test: Fix tox coloring issues --- pyproject.toml | 4 +++- tests/lib/fixture.py | 14 ++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f31f0444a..db3679bc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -249,8 +249,10 @@ python = [testenv] description = Run pytest with {basepython} extras = dev +# posargs allows to run only a specific test using +# tox -e -- path/to/my/test::test commands = - pytest + pytest {posargs} [testenv:lint] description = Check the code style diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 365f558c1..fc191c373 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -7,20 +7,25 @@ import logging import shutil from pathlib import Path -from typing import Any, Callable, Iterator +from typing import Any +from typing import Callable +from typing import Iterator from unittest.mock import patch import pytest -from click.testing import CliRunner, Result +from click.testing import CliRunner +from click.testing import Result from pytest import CaptureFixture +from tests.lib.utils import default_anta_env from anta import aioeapi -from anta.device import AntaDevice, AsyncEOSDevice +from anta.cli.console import console +from anta.device import AntaDevice +from anta.device import AsyncEOSDevice from anta.inventory import AntaInventory from anta.models import AntaCommand from anta.result_manager import ResultManager from anta.result_manager.models import TestResult -from tests.lib.utils import default_anta_env logger = logging.getLogger(__name__) @@ -233,4 +238,5 @@ def get_output(command: str | dict[str, Any]) -> dict[str, Any]: with patch("aioeapi.device.Device.check_connection", return_value=True), patch("aioeapi.device.Device.cli", side_effect=cli), patch("asyncssh.connect"), patch( "asyncssh.scp" ): + console._color_system = None yield AntaCliRunner() From 69af63d9fdf2a45df2b05234bcf2f3e46cccaaaa Mon Sep 17 00:00:00 2001 From: gmuloc Date: Wed, 6 Dec 2023 10:40:25 +0100 Subject: [PATCH 44/53] CI: Isort --- tests/lib/fixture.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index fc191c373..e8f141e23 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -7,25 +7,21 @@ import logging import shutil from pathlib import Path -from typing import Any -from typing import Callable -from typing import Iterator +from typing import Any, Callable, Iterator from unittest.mock import patch import pytest -from click.testing import CliRunner -from click.testing import Result +from click.testing import CliRunner, Result from pytest import CaptureFixture -from tests.lib.utils import default_anta_env from anta import aioeapi from anta.cli.console import console -from anta.device import AntaDevice -from anta.device import AsyncEOSDevice +from anta.device import AntaDevice, AsyncEOSDevice from anta.inventory import AntaInventory from anta.models import AntaCommand from anta.result_manager import ResultManager from anta.result_manager.models import TestResult +from tests.lib.utils import default_anta_env logger = logging.getLogger(__name__) From c689ac1a8a68cb6617914de6984730693d493c20 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Wed, 6 Dec 2023 10:43:17 +0100 Subject: [PATCH 45/53] CI: Pylint --- tests/lib/fixture.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index e8f141e23..e1be4ac0d 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -234,5 +234,5 @@ def get_output(command: str | dict[str, Any]) -> dict[str, Any]: with patch("aioeapi.device.Device.check_connection", return_value=True), patch("aioeapi.device.Device.cli", side_effect=cli), patch("asyncssh.connect"), patch( "asyncssh.scp" ): - console._color_system = None + console._color_system = None # pylint: disable=protected-access yield AntaCliRunner() From 117af2cdd1bdfe3e117c87851bfb4d9ce5e6094a Mon Sep 17 00:00:00 2001 From: gmuloc Date: Wed, 6 Dec 2023 11:27:00 +0100 Subject: [PATCH 46/53] Test: Adjust pytest default logging level --- pyproject.toml | 2 +- tests/units/test_runner.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index db3679bc0..d21d2288e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,7 +174,7 @@ warn_untyped_fields = true # TODO - may need cov-append for Tox # When run from anta directory this will read cov-config from pyproject.toml addopts = "-ra -q -s -vv --capture=tee-sys --cov --cov-report term:skip-covered --color yes" -log_level = "DEBUG" +log_level = "WARNING" log_cli = true render_collapsed = true filterwarnings = [ diff --git a/tests/units/test_runner.py b/tests/units/test_runner.py index 915bed7ee..c4df1b4a6 100644 --- a/tests/units/test_runner.py +++ b/tests/units/test_runner.py @@ -4,9 +4,9 @@ """ test anta.runner.py """ - from __future__ import annotations +import logging from typing import TYPE_CHECKING import pytest @@ -34,6 +34,7 @@ async def test_runner_empty_tests(caplog: LogCaptureFixture, test_inventory: Ant test_inventory is a fixture that gives a default inventory for tests """ logger.setup_logging(logger.Log.INFO) + caplog.set_level(logging.INFO) manager = ResultManager() await main(manager, test_inventory, AntaCatalog()) @@ -49,6 +50,7 @@ async def test_runner_empty_inventory(caplog: LogCaptureFixture) -> None: caplog is the pytest fixture to capture logs """ logger.setup_logging(logger.Log.INFO) + caplog.set_level(logging.INFO) manager = ResultManager() inventory = AntaInventory() await main(manager, inventory, FAKE_CATALOG) @@ -65,6 +67,7 @@ async def test_runner_no_selected_device(caplog: LogCaptureFixture, test_invento test_inventory is a fixture that gives a default inventory for tests """ logger.setup_logging(logger.Log.INFO) + caplog.set_level(logging.INFO) manager = ResultManager() await main(manager, test_inventory, FAKE_CATALOG) From 77a5044990f1c4caba208daeb4b5429e4b89114f Mon Sep 17 00:00:00 2001 From: gmuloc Date: Wed, 6 Dec 2023 11:37:03 +0100 Subject: [PATCH 47/53] Refactor(anta): Make sure test name is not truncated in table --- anta/reporter/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index 0fa3303bf..a2a3c4514 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -56,6 +56,9 @@ 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 From c12aee605368b9c60fcffe48b5046339fb20cb76 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Wed, 6 Dec 2023 11:54:53 +0100 Subject: [PATCH 48/53] Test: Set width of CliRunner --- tests/lib/fixture.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index e1be4ac0d..59afee68f 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging +import os import shutil from pathlib import Path from typing import Any, Callable, Iterator @@ -190,6 +191,10 @@ class AntaCliRunner(CliRunner): def invoke(self, *args, **kwargs) -> Result: # type: ignore[no-untyped-def] # Inject default env if not provided kwargs["env"] = kwargs["env"] if "env" in kwargs else default_anta_env() + # Deterministic terminal width + os.environ.pop("COLUMNS", None) + kwargs["env"]["COLUMNS"] = "165" + kwargs["auto_envvar_prefix"] = "ANTA" # Way to fix https://github.com/pallets/click/issues/824 with capsys.disabled(): From 72a14f23f604d10006bd4bd51cafdf7a87b8d718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Thu, 7 Dec 2023 13:29:34 +0100 Subject: [PATCH 49/53] Remove uncessary os.environ.pop. This is cleared by CliRunner --- tests/lib/fixture.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 59afee68f..db3f4d207 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -5,7 +5,6 @@ from __future__ import annotations import logging -import os import shutil from pathlib import Path from typing import Any, Callable, Iterator @@ -192,7 +191,6 @@ def invoke(self, *args, **kwargs) -> Result: # type: ignore[no-untyped-def] # Inject default env if not provided kwargs["env"] = kwargs["env"] if "env" in kwargs else default_anta_env() # Deterministic terminal width - os.environ.pop("COLUMNS", None) kwargs["env"]["COLUMNS"] = "165" kwargs["auto_envvar_prefix"] = "ANTA" From 63cd7b38d451e1a9bb58a479bc3092d24f36765d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Thu, 7 Dec 2023 13:52:11 +0100 Subject: [PATCH 50/53] Update doc --- anta/cli/check/__init__.py | 2 +- anta/cli/debug/__init__.py | 2 +- anta/cli/exec/__init__.py | 2 +- anta/cli/get/__init__.py | 2 +- anta/cli/nrfu/__init__.py | 2 +- docs/README.md | 10 ++++----- docs/cli/nrfu.md | 5 ++++- docs/cli/overview.md | 45 ++++++++++++++++++++----------------- docs/snippets/anta_help.txt | 10 ++++----- 9 files changed, 43 insertions(+), 37 deletions(-) diff --git a/anta/cli/check/__init__.py b/anta/cli/check/__init__.py index 5f72bf6e8..a46642623 100644 --- a/anta/cli/check/__init__.py +++ b/anta/cli/check/__init__.py @@ -11,7 +11,7 @@ @click.group def check() -> None: - """Check commands for building ANTA""" + """Commands to validate configuration files""" check.add_command(commands.catalog) diff --git a/anta/cli/debug/__init__.py b/anta/cli/debug/__init__.py index e3f973983..f190d844b 100644 --- a/anta/cli/debug/__init__.py +++ b/anta/cli/debug/__init__.py @@ -11,7 +11,7 @@ @click.group def debug() -> None: - """Debug commands for building ANTA""" + """Commands to execute EOS commands on remote devices""" debug.add_command(commands.run_cmd) diff --git a/anta/cli/exec/__init__.py b/anta/cli/exec/__init__.py index db0ed3d6d..253df7e93 100644 --- a/anta/cli/exec/__init__.py +++ b/anta/cli/exec/__init__.py @@ -11,7 +11,7 @@ @click.group def exec() -> None: # pylint: disable=redefined-builtin - """Execute commands to inventory devices""" + """Commands to execute various scripts on EOS devices""" exec.add_command(commands.clear_counters) diff --git a/anta/cli/get/__init__.py b/anta/cli/get/__init__.py index 9e9c50d67..3097555e7 100644 --- a/anta/cli/get/__init__.py +++ b/anta/cli/get/__init__.py @@ -11,7 +11,7 @@ @click.group def get() -> None: - """Get data from/to ANTA""" + """Commands to get information from or generate inventories""" get.add_command(commands.from_cvp) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 6bc9bbd0c..1ae306615 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -58,7 +58,7 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: @click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False) @click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None: - """Run NRFU against inventory devices""" + """Run ANTA tests on devices""" # If help is invoke somewhere, skip the command if ctx.obj.get("_anta_help"): return diff --git a/docs/README.md b/docs/README.md index a43037d1e..33846c83c 100755 --- a/docs/README.md +++ b/docs/README.md @@ -47,11 +47,11 @@ Options: --help Show this message and exit. Commands: - check Check commands for building ANTA - debug Debug commands for building ANTA - exec Execute commands to inventory devices - get Get data from/to ANTA - nrfu Run NRFU against inventory devices + check Commands to validate configuration files + debug Commands to execute EOS commands on remote devices + exec Commands to execute various scripts on EOS devices + get Commands to get information from or generate inventories + nrfu Run ANTA tests on devices ``` ## Documentation diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index cf51ff91d..6b47baf48 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -62,7 +62,10 @@ Commands: > `username`, `password`, `enable-password`, `enable`, `timeout` and `insecure` values are the same for all devices -All commands under the `anta nrfu` namespace require a catalog yaml file specified with the `--catalog` option. +All commands under the `anta nrfu` namespace require a catalog yaml file specified with the `--catalog` option and a device inventory file specified with the `--inventory` option. + +!!! info + Issuing the command `anta nrfu` will run `anta nrfu table` without any option. ## Tag management diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 6e9b0ac34..3c9455660 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -19,48 +19,51 @@ $ anta --help --8<-- "anta_help.txt" ``` -## ANTA Parameters as environement variables +## ANTA environement variables Certain parameters are required and can be either passed to the ANTA CLI or set as an environment variable (ENV VAR). To pass the parameters via the CLI: ```bash -anta nrfu --username tom --password arista123 --inventory inventory.yml +anta nrfu -u admin -p arista123 -i inventory.yaml -c tests.yaml ``` -To set them as ENV VAR: +To set them as environment variables: ```bash -export ANTA_USERNAME=tom +export ANTA_USERNAME=admin export ANTA_PASSWORD=arista123 export ANTA_INVENTORY=inventory.yml +export ANTA_INVENTORY=tests.yml ``` -Then, run the CLI: +Then, run the CLI without options: ```bash -anta nrfu +anta nrfu ``` -!!! info - Caching can be disabled with the global parameter `--disable-cache`. For more details about how caching is implemented in ANTA, please refer to [Caching in ANTA](../advanced_usages/caching.md). - -### List of available environment variables !!! note All environement variables may not be needed for every commands. + Refer to ` --help` for the comprehensive environment varibles names. + +Below are the environement variables usable with the `anta nrfu` command: + +| Variable Name | Purpose | Required | +| ------------- | ------- |----------| +| ANTA_USERNAME | The username to use in the inventory to connect to devices. | Yes | +| ANTA_PASSWORD | The password to use in the inventory to connect to devices. | Yes | +| ANTA_INVENTORY | The path to the inventory file. | Yes | +| ANTA_CATALOG | The path to the catalog file. | Yes | +| ANTA_PROMPT | The value to pass to the prompt for password is password is not provided | No | +| ANTA_INSECURE | Whether or not using insecure mode when connecting to the EOS devices HTTP API. | No | +| ANTA_DISABLE_CACHE | A variable to disable caching for all ANTA tests (enabled by default). | No | +| ANTA_ENABLE | Whether it is necessary to go to enable mode on devices. | No | +| ANTA_ENABLE_PASSWORD | The optional enable password, when this variable is set, ANTA_ENABLE or `--enable` is required. | No | -| Variable Name | Purpose | -| ------------- | ------- | -| ANTA_USERNAME | The username to use in the inventory to connect to devices. | -| ANTA_PASSWORD | The password to use in the inventory to connect to devices. | -| ANTA_INVENTORY | The path to the inventory file. | -| ANTA_CATALOG | The path to the catalog file. | -| ANTA_PROMPT | The value to pass to the prompt for password is password is not provided | -| ANTA_INSECURE | Whether or not using insecure mode when connecting to the EOS devices HTTP API. | -| ANTA_DISABLE_CACHE | A variable to disable caching for all ANTA tests (enabled by default). | -| ANTA_ENABLE | Whether it is necessary to go to enable mode on devices. | -| ANTA_ENABLE_PASSWORD | The optional enable password, when this variable is set, ANTA_ENABLE or `--enable` is required. | +!!! info + Caching can be disabled with the global parameter `--disable-cache`. For more details about how caching is implemented in ANTA, please refer to [Caching in ANTA](../advanced_usages/caching.md). ## ANTA Exit Codes diff --git a/docs/snippets/anta_help.txt b/docs/snippets/anta_help.txt index 96c86b586..96842b98b 100644 --- a/docs/snippets/anta_help.txt +++ b/docs/snippets/anta_help.txt @@ -13,8 +13,8 @@ Options: --help Show this message and exit. Commands: - check Check commands for building ANTA - debug Debug commands for building ANTA - exec Execute commands to inventory devices - get Get data from/to ANTA - nrfu Run NRFU against inventory devices + check Commands to validate configuration files + debug Commands to execute EOS commands on remote devices + exec Commands to execute various scripts on EOS devices + get Commands to get information from or generate inventories + nrfu Run ANTA tests on devices From 1bf37f0c6d53d13e47872fd03559a44b56912231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Thu, 7 Dec 2023 13:57:41 +0100 Subject: [PATCH 51/53] Add warning --- docs/README.md | 3 +++ docs/cli/overview.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/docs/README.md b/docs/README.md index 33846c83c..589a84878 100755 --- a/docs/README.md +++ b/docs/README.md @@ -54,6 +54,9 @@ Commands: nrfu Run ANTA tests on devices ``` +!!! warning + The ANTA CLI options have changed after version 0.11 and have moved away from the top level `anta` and are now required at their respective commands (e.g. `anta nrfu`). This breaking change occurs after users feedback on making the CLI more intuitive. This change should not affect user experience when using environment variables. + ## Documentation The documentation is published on [ANTA package website](https://www.anta.ninja). Also, a [demo repository](https://github.com/titom73/atd-anta-demo) is available to facilitate your journey with ANTA. diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 3c9455660..1f29b62f8 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -12,6 +12,9 @@ ANTA can also be used as a Python library, allowing you to build your own tools To start using the ANTA CLI, open your terminal and type `anta`. +!!! warning + The ANTA CLI options have changed after version 0.11 and have moved away from the top level `anta` and are now required at their respective commands (e.g. `anta nrfu`). This breaking change occurs after users feedback on making the CLI more intuitive. This change should not affect user experience when using environment variables. + ## Invoking ANTA CLI ```bash From 99c85654a0ad4a28815e568a842ac801aa9ad1ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Thu, 7 Dec 2023 13:58:51 +0100 Subject: [PATCH 52/53] Update warning --- docs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 589a84878..f5033e25a 100755 --- a/docs/README.md +++ b/docs/README.md @@ -54,8 +54,8 @@ Commands: nrfu Run ANTA tests on devices ``` -!!! warning - The ANTA CLI options have changed after version 0.11 and have moved away from the top level `anta` and are now required at their respective commands (e.g. `anta nrfu`). This breaking change occurs after users feedback on making the CLI more intuitive. This change should not affect user experience when using environment variables. +> [!WARNING] +> The ANTA CLI options have changed after version 0.11 and have moved away from the top level `anta` and are now required at their respective commands (e.g. `anta nrfu`). This breaking change occurs after users feedback on making the CLI more intuitive. This change should not affect user experience when using environment variables. ## Documentation From 584cc0c967d61af113d50f347c540b16f43a0af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Thu, 7 Dec 2023 16:41:31 +0100 Subject: [PATCH 53/53] change -log option to -l --- anta/cli/__init__.py | 2 +- docs/README.md | 2 +- docs/cli/debug.md | 4 ++-- docs/snippets/anta_help.txt | 2 +- tests/units/cli/debug/test_commands.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index 9c89ec06c..a6c83f6c6 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -36,7 +36,7 @@ ) @click.option( "--log-level", - "-log", + "-l", help="ANTA logging level", default=logging.getLevelName(logging.INFO), show_envvar=True, diff --git a/docs/README.md b/docs/README.md index f5033e25a..74cd776b0 100755 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ Options: --log-file FILE Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout. [env var: ANTA_LOG_FILE] - -log, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG] + -l, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG] ANTA logging level [env var: ANTA_LOG_LEVEL; default: INFO] --help Show this message and exit. diff --git a/docs/cli/debug.md b/docs/cli/debug.md index 7eb83b404..8d2b7979b 100644 --- a/docs/cli/debug.md +++ b/docs/cli/debug.md @@ -166,10 +166,10 @@ Run templated command 'show vlan {vlan_id}' with {'vlan_id': '10'} on DC1-LEAF1A ### 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     +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'} -anta --log DEBUG debug run-template --template "ping {dst} source {src}" dst "8.8.8.8" src Loopback0 dst "1.1.1.1" src Loopback1 --device DC1-SPINE1           +anta -log DEBUG debug run-template --template "ping {dst} source {src}" dst "8.8.8.8" src Loopback0 dst "1.1.1.1" src Loopback1 --device DC1-SPINE1           > {'dst': '1.1.1.1', 'src': 'Loopback1'} # Notice how `src` and `dst` keep only the latest value ``` diff --git a/docs/snippets/anta_help.txt b/docs/snippets/anta_help.txt index 96842b98b..0c3302a79 100644 --- a/docs/snippets/anta_help.txt +++ b/docs/snippets/anta_help.txt @@ -7,7 +7,7 @@ Options: --log-file FILE Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout. [env var: ANTA_LOG_FILE] - -log, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG] + -l, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG] ANTA logging level [env var: ANTA_LOG_LEVEL; default: INFO] --help Show this message and exit. diff --git a/tests/units/cli/debug/test_commands.py b/tests/units/cli/debug/test_commands.py index 2c8ba5a1e..70fd34cd2 100644 --- a/tests/units/cli/debug/test_commands.py +++ b/tests/units/cli/debug/test_commands.py @@ -35,7 +35,7 @@ def test_run_cmd( Test `anta debug run-cmd` """ # pylint: disable=too-many-arguments - cli_args = ["-log", "debug", "debug", "run-cmd", "--command", command, "--device", device] + cli_args = ["-l", "debug", "debug", "run-cmd", "--command", command, "--device", device] # ofmt if ofmt is not None: