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: diff --git a/anta/catalog.py b/anta/catalog.py index d608700e5..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__) @@ -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/__init__.py b/anta/cli/__init__.py index 1349c15ff..a6c83f6c6 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 """ @@ -10,76 +9,25 @@ import logging import pathlib -from typing import Any, Literal +import sys 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, parse_catalog, parse_inventory -from anta.logger import setup_logging -from anta.result_manager import ResultManager +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, ExitCode +from anta.logger import Log, LogLevel, anta_log_exception, setup_logging +logger = logging.getLogger(__name__) -@click.group(cls=IgnoreRequiredWithHelp) + +@click.group(cls=AliasedGroup) @click.pass_context @click.version_option(__version__) -@click.option( - "--username", - help="Username to connect to EOS", - show_envvar=True, - 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, - required=True, - type=click.Path(file_okay=True, dir_okay=False, exists=True, 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.", @@ -88,122 +36,36 @@ ) @click.option( "--log-level", - "--log", + "-l", help="ANTA logging level", default=logging.getLevelName(logging.INFO), 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, ), ) -@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: - # pylint: disable=unused-argument +def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None: """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: - 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"] = parse_inventory(ctx, inventory) - ctx.obj["inventory_path"] = ctx.params["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: - """Run NRFU against inventory devices""" - ctx.obj["catalog"] = catalog - ctx.obj["result_manager"] = ResultManager() - - -@anta.group("check", cls=AliasedGroup) -def _check() -> None: - """Check commands for building ANTA""" - - -@anta.group("exec", cls=AliasedGroup) -def _exec() -> None: - """Execute commands to inventory devices""" - - -@anta.group("get", cls=AliasedGroup) -def _get() -> None: - """Get data from/to ANTA""" - - -@anta.group("debug", cls=AliasedGroup) -def _debug() -> None: - """Debug commands for building ANTA""" - - -# 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) + setup_logging(log_level, log_file) -_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.add_command(nrfu_command) +anta.add_command(check_command) +anta.add_command(exec_command) +anta.add_command(get_command) +anta.add_command(debug_command) -# ANTA CLI Execution def cli() -> None: """Entrypoint for pyproject.toml""" - anta(obj={}, auto_envvar_prefix="ANTA") # pragma: no cover + 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 c460d5493..a46642623 100644 --- a/anta/cli/check/__init__.py +++ b/anta/cli/check/__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. +""" +Click commands to validate configuration files +""" +import click + +from anta.cli.check import commands + + +@click.group +def check() -> None: + """Commands to validate configuration files""" + + +check.add_command(commands.catalog) diff --git a/anta/cli/check/commands.py b/anta/cli/check/commands.py index 6ba521b4e..dcaad19ae 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 @@ -14,25 +14,16 @@ 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, -) +@click.command +@catalog_options 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/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/debug/__init__.py b/anta/cli/debug/__init__.py index c460d5493..f190d844b 100644 --- a/anta/cli/debug/__init__.py +++ b/anta/cli/debug/__init__.py @@ -1,3 +1,18 @@ # 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 + + +@click.group +def debug() -> None: + """Commands to execute EOS commands on remote devices""" + + +debug.add_command(commands.run_cmd) +debug.add_command(commands.run_template) diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py index 8386f8342..c253ac3bc 100644 --- a/anta/cli/debug/commands.py +++ b/anta/cli/debug/commands.py @@ -3,47 +3,30 @@ # 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 import asyncio import logging -import sys from typing import Literal import click -from click import Option 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 -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() +@click.command +@debug_options +@click.pass_context @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: +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 @@ -52,21 +35,21 @@ 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": console.print(c.text_output) -@click.command() +@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}'") -@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: +def run_template( + 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. @@ -85,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 new file mode 100644 index 000000000..285fe3494 --- /dev/null +++ b/anta/cli/debug/utils.py @@ -0,0 +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. +""" +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 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") + @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, *args: tuple[Any], inventory: AntaInventory, tags: list[str] | None, device: str, **kwargs: dict[str, Any]) -> Any: + # pylint: disable=unused-argument + try: + d = inventory[device] + except KeyError as e: + message = f"Device {device} does not exist in Inventory" + logger.error(e, message) + ctx.exit(ExitCode.USAGE_ERROR) + return f(*args, device=d, **kwargs) + + return wrapper diff --git a/anta/cli/exec/__init__.py b/anta/cli/exec/__init__.py index c460d5493..253df7e93 100644 --- a/anta/cli/exec/__init__.py +++ b/anta/cli/exec/__init__.py @@ -1,3 +1,19 @@ # 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: # pylint: disable=redefined-builtin + """Commands to execute various scripts on EOS devices""" + + +exec.add_command(commands.clear_counters) +exec.add_command(commands.snapshot) +exec.add_command(commands.collect_tech_support) diff --git a/anta/cli/exec/commands.py b/anta/cli/exec/commands.py index 765aa5906..16e50277d 100644 --- a/anta/cli/exec/commands.py +++ b/anta/cli/exec/commands.py @@ -1,9 +1,8 @@ # 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. +Click commands to execute various scripts on EOS devices """ from __future__ import annotations @@ -17,22 +16,21 @@ 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 +from anta.inventory import AntaInventory logger = logging.getLogger(__name__) -@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) -def clear_counters(ctx: click.Context, tags: list[str] | None) -> None: +@click.command +@inventory_options +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 -@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", @@ -50,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}") @@ -61,11 +59,11 @@ 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) @click.option( @@ -75,7 +73,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: +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/get/__init__.py b/anta/cli/get/__init__.py index c460d5493..3097555e7 100644 --- a/anta/cli/get/__init__.py +++ b/anta/cli/get/__init__.py @@ -1,3 +1,20 @@ # 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 information from or generate inventories +""" +import click + +from anta.cli.get import commands + + +@click.group +def get() -> None: + """Commands to get information from or generate inventories""" + + +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/get/commands.py b/anta/cli/get/commands.py index 56ec5ca92..6008adf6e 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -2,181 +2,113 @@ # 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.. +Click commands to get information from or generate inventories """ from __future__ import annotations import asyncio -import io import json import logging -import os -import sys from pathlib import Path -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 +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 logger = logging.getLogger(__name__) -@click.command(no_args_is_help=True) -@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.command +@click.pass_context +@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) -> None: """ Build ANTA inventory from Cloudvision TODO - handle get_inventory and get_devices_in_container failure """ - # 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) + 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, out_dir, 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) -@click.command(no_args_is_help=True) +@click.command @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=False, - 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), + type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), + required=True, ) -@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}") - - try: - is_tty = os.isatty(sys.stdout.fileno()) - except io.UnsupportedOperation: - is_tty = False - - # Create output directory - 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)") - 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") - sys.exit(ExitCode.USAGE_ERROR) - - 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) -@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) +@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.pass_context -def tags(ctx: click.Context) -> None: +@click.command +@inventory_options +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 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 d1900b057..8b4e26509 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=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 @@ -36,31 +82,34 @@ 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 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 - inv_file = "inventory" if container is None else f"inventory-{container}" - out_file = f"{directory}/{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}") + 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_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: @@ -101,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_file, "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}") + write_inventory_to_file(ansible_hosts, output) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index c460d5493..1ae306615 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -1,3 +1,81 @@ # 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 + +import click + +from anta.catalog import AntaCatalog +from anta.cli.nrfu import commands +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 +from anta.runner import main + +from .utils import anta_progress_bar, print_settings + + +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 +@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 ANTA tests on devices""" + # If help is invoke somewhere, skip the command + if ctx.obj.get("_anta_help"): + return + # We use ctx.obj to pass stuff to the next Click functions + ctx.ensure_object(dict) + ctx.obj["result_manager"] = ResultManager() + ctx.obj["ignore_status"] = ignore_status + ctx.obj["ignore_error"] = ignore_error + print_settings(inventory, catalog) + with anta_progress_bar() as AntaTest.progress: + 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) + + +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 6b32ba0a4..07e4017a4 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -2,47 +2,37 @@ # 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 -import asyncio import logging import pathlib import click -from anta.cli.utils import exit_with_code, parse_tags -from anta.models import AntaTest -from anta.runner import main +from anta.cli.utils import exit_with_code -from .utils import anta_progress_bar, print_jinja, print_json, print_settings, print_table, print_text +from .utils import print_jinja, print_json, print_table, print_text logger = logging.getLogger(__name__) @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: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=tags)) print_table(results=ctx.obj["result_manager"], device=device, group_by=group_by, test=test) exit_with_code(ctx) @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,27 +41,18 @@ 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: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=tags)) print_json(results=ctx.obj["result_manager"], output=output) exit_with_code(ctx) @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: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=tags)) print_text(results=ctx.obj["result_manager"], search=search, skip_error=skip_error) exit_with_code(ctx) @@ -94,13 +75,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: - asyncio.run(main(ctx.obj["result_manager"], ctx.obj["inventory"], ctx.obj["catalog"], tags=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..0b21098d7 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -11,26 +11,26 @@ import pathlib import re -import click 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(context: click.Context, report_template: pathlib.Path | None = None, report_output: pathlib.Path | None = None) -> 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" - if report_template: - message += f"\n- Report template: {report_template}" - if report_output: - message += f"\n- Report output: {report_output}" + 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 98e19cc53..3b30eb824 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -1,14 +1,13 @@ -#!/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 __future__ import annotations import enum +import functools import logging from pathlib import Path from typing import TYPE_CHECKING, Any @@ -21,11 +20,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): """ @@ -35,38 +34,14 @@ class ExitCode(enum.IntEnum): # Tests passed. OK = 0 - #: Tests failed. - TESTS_FAILED = 1 - # Test error - TESTS_ERROR = 2 # An internal error got in the way. - INTERNAL_ERROR = 3 - # pytest was misused. - USAGE_ERROR = 4 - - -def parse_inventory(ctx: click.Context, path: Path) -> AntaInventory: - """ - Helper function 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(path), - 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, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): - ctx.exit(ExitCode.USAGE_ERROR) - return inventory + INTERNAL_ERROR = 1 + # CLI was misused + USAGE_ERROR = 2 + # Test error + TESTS_ERROR = 3 + # Tests failed + TESTS_FAILED = 4 def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | None: @@ -79,24 +54,6 @@ def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | Non 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 - # need to parse the Catalog - return an empty catalog - return AntaCatalog() - try: - catalog: AntaCatalog = AntaCatalog.parse(value) - except (ValidationError, YAMLError, OSError): - ctx.exit(ExitCode.USAGE_ERROR) - return catalog - - def exit_with_code(ctx: click.Context) -> None: """ Exit the Click application with an exit code. @@ -112,12 +69,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) @@ -130,56 +86,6 @@ 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): - """ - 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) - - 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): """ Implements a subclass of Group that accepts a prefix for a command. @@ -205,3 +111,164 @@ def resolve_command(self, ctx: click.Context, args: Any) -> Any: # always return the full command name _, cmd, args = super().resolve_command(ctx, args) 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""" + + @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, + 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, + ) + @click.option( + "--prompt", + "-P", + help="Prompt for passwords if they are not provided.", + default=False, + show_envvar=True, + envvar="ANTA_PROMPT", + is_flag=True, + show_default=True, + ) + @click.option( + "--timeout", + help="Global connection timeout", + default=30, + show_envvar=True, + envvar="ANTA_TIMEOUT", + show_default=True, + ) + @click.option( + "--insecure", + 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, envvar="ANTA_DISABLE_CACHE", show_default=True, is_flag=True, default=False) + @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, exists=True, readable=True, path_type=Path), + ) + @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( + ctx: click.Context, + *args: tuple[Any], + inventory: Path, + tags: list[str] | None, + 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: + # pylint: disable=too-many-arguments + # If help is invoke somewhere, do not parse inventory + if ctx.obj.get("_anta_help"): + return f(*args, inventory=None, tags=tags, **kwargs) + if prompt: + # User asked for a password prompt + 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?"): + enable_password = click.prompt( + "Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True + ) + 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 enable and enable_password: + raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.") + try: + i = 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) + return f(*args, inventory=i, tags=tags, **kwargs) + + return wrapper + + +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, path_type=Path), + required=True, + ) + @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.get("_anta_help"): + return f(*args, catalog=None, **kwargs) + try: + c = AntaCatalog.parse(catalog) + except (ValidationError, TypeError, ValueError, YAMLError, OSError): + ctx.exit(ExitCode.USAGE_ERROR) + return f(*args, catalog=c, **kwargs) + + return wrapper diff --git a/anta/device.py b/anta/device.py index bba80cd1e..bbeca6cde 100644 --- a/anta/device.py +++ b/anta/device.py @@ -250,9 +250,21 @@ 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: + 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: + message = f"'username' is required to instantiate device '{self.name}'" + logger.error(message) + raise ValueError(message) + if password is None: + 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) diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py index fe297d1f4..700452d80 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 @@ -18,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__) @@ -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, @@ -177,12 +178,19 @@ 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: + message = "'username' is required to create an AntaInventory" + logger.error(message) + raise ValueError(message) + if password is None: + 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: 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 diff --git a/anta/logger.py b/anta/logger.py index f8cabdc79..5118e941c 100644 --- a/anta/logger.py +++ b/anta/logger.py @@ -7,16 +7,32 @@ from __future__ import annotations import logging +from enum import Enum from pathlib import Path +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__) -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: @@ -48,7 +64,7 @@ def setup_logging(level: str = logging.getLevelName(logging.INFO), file: Path | 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" @@ -69,3 +85,23 @@ def setup_logging(level: str = logging.getLevelName(logging.INFO), file: Path | 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/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 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/docs/README.md b/docs/README.md index baa42d7a5..74cd776b0 100755 --- a/docs/README.md +++ b/docs/README.md @@ -38,49 +38,24 @@ 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] + -l, --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: - 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 ``` -> `username`, `password`, `enable-password`, `enable`, `timeout` and `insecure` values are the same for all 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 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..8d2b7979b 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: @@ -112,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/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..6b47baf48 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,7 +60,12 @@ Commands: tpl-report ANTA command to check network state with templated report ``` -All commands under the `anta nrfu` namespace require a catalog yaml file specified with the `--catalog` option. +> `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 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 @@ -61,13 +93,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 +122,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 +135,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 +176,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 +186,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 +207,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 +216,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 +233,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..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 @@ -19,48 +22,65 @@ $ anta --help --8<-- "anta_help.txt" ``` -## ANTA Global Parameters +## ANTA 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 -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 +anta nrfu ``` + +!!! 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 | + !!! 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 -ANTA utilizes different exit codes to indicate the status of the test runs. - -For all subcommands, ANTA will return the exit code 0, indicating a successful operation, except for the nrfu command. - -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..0c3302a79 100644 --- a/docs/snippets/anta_help.txt +++ b/docs/snippets/anta_help.txt @@ -4,43 +4,17 @@ 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] + -l, --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: - 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 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: 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/pyproject.toml b/pyproject.toml index f31f0444a..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 = [ @@ -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/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/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/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/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/lib/fixture.py b/tests/lib/fixture.py index ccdc0d930..db3f4d207 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -4,13 +4,18 @@ """Fixture for Anta Testing""" from __future__ import annotations -from os import environ -from typing import Callable, Iterator +import logging +import shutil +from pathlib import Path +from typing import Any, Callable, Iterator from unittest.mock import patch import pytest -from click.testing import CliRunner +from click.testing import CliRunner, Result +from pytest import CaptureFixture +from anta import aioeapi +from anta.cli.console import console from anta.device import AntaDevice, AsyncEOSDevice from anta.inventory import AntaInventory from anta.models import AntaCommand @@ -18,10 +23,32 @@ 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" +MOCK_CLI_JSON: dict[str, aioeapi.EapiCommandError | dict[str, Any]] = { + "show version": { + "modelName": "DCS-7280CR3-32P4-F", + "version": "4.31.1F", + }, + "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, 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", + "show running-config | include aaa authorization exec default": "aaa authorization exec default local", +} + @pytest.fixture def device(request: pytest.FixtureRequest) -> Iterator[AntaDevice]: @@ -47,7 +74,22 @@ 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 """ @@ -61,6 +103,7 @@ 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]: """ @@ -122,33 +165,77 @@ 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() -> CliRunner: +def click_runner(capsys: CaptureFixture[str]) -> Iterator[CliRunner]: """ Convenience fixture to return a click.CliRunner for cli testing """ - return CliRunner() - -@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) + class AntaCliRunner(CliRunner): + """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() + # Deterministic terminal width + kwargs["env"]["COLUMNS"] = "165" + + kwargs["auto_envvar_prefix"] = "ANTA" + # Way to fix https://github.com/pallets/click/issues/824 + with capsys.disabled(): + result = super().invoke(*args, **kwargs) + print("--- CLI Output ---") + print(result.output) + return result + + def cli( + command: str | None = None, commands: list[dict[str, Any]] | None = None, ofmt: str = "json", version: int | str | None = "latest", **kwargs: Any + ) -> 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"] + mock_cli: dict[str, Any] + if ofmt == "json": + mock_cli = MOCK_CLI_JSON + elif ofmt == "text": + mock_cli = MOCK_CLI_TEXT + for mock_cmd, output in mock_cli.items(): + if command == mock_cmd: + logger.info(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) + + 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), patch("aioeapi.device.Device.cli", side_effect=cli), patch("asyncssh.connect"), patch( + "asyncssh.scp" + ): + console._color_system = None # pylint: disable=protected-access + yield AntaCliRunner() diff --git a/tests/lib/utils.py b/tests/lib/utils.py 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/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..d5cee4640 100644 --- a/tests/units/cli/check/test_commands.py +++ b/tests/units/cli/check/test_commands.py @@ -12,11 +12,10 @@ import pytest from anta.cli import anta -from tests.lib.utils import default_anta_env +from anta.cli.utils import ExitCode 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, "Catalog 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/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/debug/test_commands.py b/tests/units/cli/debug/test_commands.py index 2f96bc054..70fd34cd2 100644 --- a/tests/units/cli/debug/test_commands.py +++ b/tests/units/cli/debug/test_commands.py @@ -6,57 +6,26 @@ """ from __future__ import annotations -from contextlib import nullcontext -from typing import TYPE_CHECKING, Any, Literal -from unittest.mock import MagicMock, patch +from typing import TYPE_CHECKING, Literal -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 +from anta.cli.utils import ExitCode 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( "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("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( @@ -66,69 +35,26 @@ 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] + cli_args = ["-l", "debug", "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: - # Need to copy ugly hack here.. - expected_version = "latest" if version == "latest" else 1 + if version is not None: 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 + 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 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/exec/test_commands.py b/tests/units/cli/exec/test_commands.py index 01c0d014e..311b83829 100644 --- a/tests/units/cli/exec/test_commands.py +++ b/tests/units/cli/exec/test_commands.py @@ -9,13 +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 tests.lib.utils import default_anta_env +from anta.cli.utils import ExitCode if TYPE_CHECKING: from click.testing import CliRunner @@ -30,6 +29,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,87 +58,44 @@ 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" @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` """ - env = default_anta_env() - cli_args = ["exec", "snapshot"] - + cli_args = ["exec", "snapshot", "--output", str(tmp_path)] # 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 +112,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 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/get/test_commands.py b/tests/units/cli/get/test_commands.py index 8bd5b58f7..a0c1b0f74 100644 --- a/tests/units/cli/get/test_commands.py +++ b/tests/units/cli/get/test_commands.py @@ -6,10 +6,9 @@ """ from __future__ import annotations -import os -import shutil +import filecmp from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from unittest.mock import ANY, patch import pytest @@ -17,54 +16,38 @@ from cvprac.cvp_client_errors import CvpApiError from anta.cli import anta -from anta.cli.get.commands import from_cvp -from tests.lib.utils import default_anta_env +from anta.cli.utils import ExitCode if TYPE_CHECKING: from click.testing import CliRunner - from pytest import CaptureFixture, LogCaptureFixture DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data" -# Not testing for required parameter, click does this well. @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: """ Test `anta get from-cvp` - """ - 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: - 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 + This test verifies that username and password are NOT mandatory to run this command + """ + 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 @@ -73,153 +56,149 @@ 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, env=env, 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, 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, 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, - 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 - cli_args.extend(["--output", str(out_dir)]) + This test verifies: + * the parsing of an ansible-inventory + * the ansible_group functionaliy - if ansible_inventory is not None: - ansible_inventory_path = DATA_DIR / ansible_inventory - cli_args.extend(["--ansible-inventory", str(ansible_inventory_path)]) + The output path is ALWAYS set to a non existing file. + """ + output: Path = tmp_path / "output.yml" + ansible_inventory_path = DATA_DIR / ansible_inventory + # Init cli_args + 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(): - 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: - assert len(caplog.records) in {2, 3} + + if expected_exit != ExitCode.OK: + assert expected_log + assert expected_log in result.output else: - assert out_dir.exists() + assert output.exists() + # TODO check size of generated inventory to validate the group functionality! @pytest.mark.parametrize( - "ansible_inventory, ansible_group, output_option, expected_exit", + "env_set, overwrite, is_tty, prompt, 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, 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, + None, + 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, 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_default_inventory( +def test_from_ansible_overwrite( tmp_path: Path, - caplog: LogCaptureFixture, - capsys: CaptureFixture[str], click_runner: CliRunner, - ansible_inventory: Path, - ansible_group: str | None, - output_option: str | None, + temp_env: dict[str, str | None], + env_set: bool, + overwrite: bool, + is_tty: bool, + prompt: str | None, expected_exit: int, + expected_log: str | None, ) -> None: + # pylint: disable=too-many-arguments """ - Test `anta get from-ansible` - """ + Test `anta get from-ansible` overwrite mechanism - 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"), - } + The test uses a static ansible-inventory and output as these are tested in other functions - env = custom_anta_env(tmp_path) - shutil.copyfile(str(Path(__file__).parent.parent.parent.parent / "data" / "test_inventory.yml"), env["ANTA_INVENTORY"]) + 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 - cli_args = ["get", "from-ansible"] + The initial content of the ANTA inventory is set using init_anta_inventory, if it is None, no inventory is set. - os.chdir(tmp_path) - if output_option is not None: - cli_args.extend([str(output_option)]) + * 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 + """ + ansible_inventory_path = DATA_DIR / "ansible_inventory.yml" + expected_anta_inventory_path = DATA_DIR / "expected_anta_inventory.yml" + tmp_output = tmp_path / "output.yml" + cli_args = ["get", "from-ansible", "--ansible-inventory", str(ansible_inventory_path)] - if ansible_inventory is not None: - ansible_inventory_path = DATA_DIR / ansible_inventory - cli_args.extend(["--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 ansible_group is not None: - cli_args.extend(["--ansible-group", ansible_group]) + if overwrite: + cli_args.append("--overwrite") - with capsys.disabled(): - print(cli_args) - result = click_runner.invoke(anta, cli_args, env=env, auto_envvar_prefix="ANTA") + # Verify initial content is different + if tmp_inv.exists(): + assert not filecmp.cmp(tmp_inv, expected_anta_inventory_path) - print(f"Runner args: {cli_args}") - print(f"Runner result is: {result}") + 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 - 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 == ExitCode.OK: + assert filecmp.cmp(tmp_inv, expected_anta_inventory_path) + elif expected_exit == ExitCode.INTERNAL_ERROR: + assert expected_log + 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..fb00bcb7a 100644 --- a/tests/units/cli/get/test_utils.py +++ b/tests/units/cli/get/test_utils.py @@ -65,27 +65,23 @@ 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 """ - 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) - 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/__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__init__.py b/tests/units/cli/nrfu/test__init__.py new file mode 100644 index 000000000..a4844cd95 --- /dev/null +++ b/tests/units/cli/nrfu/test__init__.py @@ -0,0 +1,111 @@ +# 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 +""" +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 + +# TODO: write unit tests for ignore-status and ignore-error + + +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 + """ + 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 new file mode 100644 index 000000000..eb6c5db8a --- /dev/null +++ b/tests/units/cli/nrfu/test_commands.py @@ -0,0 +1,97 @@ +# 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 + +import json +import re +from pathlib import Path + +from click.testing import CliRunner + +from anta.cli import anta +from anta.cli.utils import ExitCode + +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 + """ + result = click_runner.invoke(anta, ["nrfu", "table"]) + assert result.exit_code == ExitCode.OK + assert "dummy │ VerifyEOSVersion │ success" in result.output + + +def test_anta_nrfu_text(click_runner: CliRunner) -> None: + """ + Test anta nrfu, catalog is given via env + """ + result = click_runner.invoke(anta, ["nrfu", "text"]) + assert result.exit_code == ExitCode.OK + assert "dummy :: VerifyEOSVersion :: SUCCESS" in result.output + + +def test_anta_nrfu_json(click_runner: CliRunner) -> None: + """ + Test anta nrfu, catalog is given via env + """ + 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_anta_nrfu_template(click_runner: CliRunner) -> None: + """ + Test anta nrfu, catalog is given via env + """ + result = click_runner.invoke(anta, ["nrfu", "tpl-report", "--template", str(DATA_DIR / "template.j2")]) + assert result.exit_code == ExitCode.OK + assert "* VerifyEOSVersion is SUCCESS for dummy" in result.output diff --git a/tests/units/cli/test__init__.py b/tests/units/cli/test__init__.py index 50c2992cf..4ff3907ca 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,25 +27,16 @@ 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 -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 "Usage: anta nrfu" 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 == 0 + assert result.exit_code == ExitCode.OK assert "Usage: anta exec" in result.output @@ -55,7 +45,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 +54,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, ["get", "inventory"], 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 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..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 @@ -33,7 +33,8 @@ 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) + caplog.set_level(logging.INFO) manager = ResultManager() await main(manager, test_inventory, AntaCatalog()) @@ -48,7 +49,8 @@ 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) + caplog.set_level(logging.INFO) manager = ResultManager() inventory = AntaInventory() await main(manager, inventory, FAKE_CATALOG) @@ -64,7 +66,8 @@ 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) + caplog.set_level(logging.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: """