Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(anta): Add support for JSON catalogs #739

Merged
merged 4 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions anta/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
import math
from collections import defaultdict
from inspect import isclass
from json import load as json_load
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Union
from typing import TYPE_CHECKING, Any, Literal, Optional, Union

import yaml
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_serializer, model_validator
from pydantic.types import ImportString
from pydantic_core import PydanticCustomError
from yaml import YAMLError, safe_load
from yaml import YAMLError, safe_dump, safe_load

from anta.logger import anta_log_exception
from anta.models import AntaTest
Expand Down Expand Up @@ -238,7 +238,16 @@ def yaml(self) -> str:
# This could be improved.
# https://github.com/pydantic/pydantic/issues/1043
# Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml
return yaml.safe_dump(yaml.safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf)
return safe_dump(safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf)

def to_json(self) -> str:
"""Return a JSON representation string of this model.

Returns
-------
The JSON representation string of this model.
"""
return self.model_dump_json(serialize_as_any=True, exclude_unset=True, indent=2)


class AntaCatalog:
Expand Down Expand Up @@ -298,19 +307,24 @@ def tests(self, value: list[AntaTestDefinition]) -> None:
self._tests = value

@staticmethod
def parse(filename: str | Path) -> AntaCatalog:
def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") -> AntaCatalog:
"""Create an AntaCatalog instance from a test catalog file.

Parameters
----------
filename: Path to test catalog YAML file
filename: Path to test catalog YAML or JSON fil
file_format: Format of the file, either 'yaml' or 'json'

"""
if file_format not in ["yaml", "json"]:
message = f"'{file_format}' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported."
raise ValueError(message)

try:
file: Path = filename if isinstance(filename, Path) else Path(filename)
with file.open(encoding="UTF-8") as f:
data = safe_load(f)
except (TypeError, YAMLError, OSError) as e:
data = safe_load(f) if file_format == "yaml" else json_load(f)
except (TypeError, YAMLError, OSError, ValueError) as e:
message = f"Unable to parse ANTA Test Catalog file '{filename}'"
anta_log_exception(e, message, logger)
raise
Expand Down
2 changes: 2 additions & 0 deletions anta/cli/nrfu/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ def nrfu(
ignore_status: bool,
ignore_error: bool,
dry_run: bool,
catalog_format: str = "yaml",
) -> None:
"""Run ANTA tests on selected inventory devices."""
# If help is invoke somewhere, skip the command
Expand All @@ -129,6 +130,7 @@ def nrfu(
ctx.obj["ignore_error"] = ignore_error
ctx.obj["hide"] = set(hide) if hide else None
ctx.obj["catalog"] = catalog
ctx.obj["catalog_format"] = catalog_format
ctx.obj["inventory"] = inventory
ctx.obj["tags"] = tags
ctx.obj["device"] = device
Expand Down
14 changes: 12 additions & 2 deletions anta/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
"-c",
envvar="ANTA_CATALOG",
show_envvar=True,
help="Path to the test catalog YAML file",
help="Path to the test catalog file",
type=click.Path(
file_okay=True,
dir_okay=False,
Expand All @@ -278,19 +278,29 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
),
required=True,
)
@click.option(
"--catalog-format",
envvar="ANTA_CATALOG_FORMAT",
show_envvar=True,
help="Format of the catalog file, either 'yaml' or 'json'",
default="yaml",
type=click.Choice(["yaml", "json"], case_sensitive=False),
)
@click.pass_context
@functools.wraps(f)
def wrapper(
ctx: click.Context,
*args: tuple[Any],
catalog: Path,
catalog_format: str,
**kwargs: dict[str, Any],
) -> Any:
# If help is invoke somewhere, do not parse catalog
if ctx.obj.get("_anta_help"):
return f(*args, catalog=None, **kwargs)
try:
c = AntaCatalog.parse(catalog)
file_format = catalog_format.lower()
c = AntaCatalog.parse(catalog, file_format=file_format) # type: ignore[arg-type]
except (TypeError, ValueError, YAMLError, OSError):
ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, catalog=c, **kwargs)
Expand Down
10 changes: 6 additions & 4 deletions docs/cli/check.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ Commands:
```bash
Usage: anta check catalog [OPTIONS]

Check that the catalog is valid
Check that the catalog is valid.

Options:
-c, --catalog FILE Path to the test catalog YAML file [env var:
ANTA_CATALOG; required]
--help Show this message and exit.
-c, --catalog FILE Path to the test catalog file [env var:
ANTA_CATALOG; required]
--catalog-format [yaml|json] Format of the catalog file, either 'yaml' or
'json' [env var: ANTA_CATALOG_FORMAT]
--help Show this message and exit.
```
6 changes: 4 additions & 2 deletions docs/snippets/anta_nrfu_help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ Options:
ANTA_INVENTORY; required]
--tags TEXT List of tags using comma as separator:
tag1,tag2,tag3. [env var: ANTA_TAGS]
-c, --catalog FILE Path to the test catalog YAML file [env
var: ANTA_CATALOG; required]
-c, --catalog FILE Path to the test catalog file [env var:
ANTA_CATALOG; required]
--catalog-format [yaml|json] Format of the catalog file, either 'yaml' or
'json' [env var: ANTA_CATALOG_FORMAT]
-d, --device TEXT Run tests on a specific device. Can be
provided multiple times.
-t, --test TEXT Run a specific test. Can be provided
Expand Down
66 changes: 64 additions & 2 deletions docs/usage-inventory-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,24 @@ A test catalog is an instance of the [AntaCatalog](./api/catalog.md#anta.catalog

In addition to the inventory file, you also have to define a catalog of tests to execute against your devices. This catalog list all your tests, their inputs and their tags.

A valid test catalog file must have the following structure:
A valid test catalog file must have the following structure in either YAML or JSON:
```yaml
---
<Python module>:
- <AntaTest subclass>:
<AntaTest.Input compliant dictionary>
```

```json
{
"<Python module>": [
{
"<AntaTest subclass>": <AntaTest.Input compliant dictionary>
}
]
}
```

### Example

```yaml
Expand All @@ -108,6 +118,43 @@ anta.tests.connectivity:
custom_field: "Test run by John Doe"
```

or equivalent in JSON:

```json
{
"anta.tests.connectivity": [
{
"VerifyReachability": {
"result_overwrite": {
"description": "Test with overwritten description",
"categories": [
"Overwritten category 1"
],
"custom_field": "Test run by John Doe"
},
"filters": {
"tags": [
"leaf"
]
},
"hosts": [
{
"destination": "1.1.1.1",
"source": "Management0",
"vrf": "MGMT"
},
{
"destination": "8.8.8.8",
"source": "Management0",
"vrf": "MGMT"
}
]
}
}
]
}
```

It is also possible to nest Python module definition:
```yaml
anta.tests:
Expand Down Expand Up @@ -165,7 +212,7 @@ anta.tests.software:
- VerifyEOSVersion:
```

It will load the test `VerifyEOSVersion` located in `anta.tests.software`. But since this test has mandatory inputs, we need to provide them as a dictionary in the YAML file:
It will load the test `VerifyEOSVersion` located in `anta.tests.software`. But since this test has mandatory inputs, we need to provide them as a dictionary in the YAML or JSON file:

```yaml
anta.tests.software:
Expand All @@ -176,6 +223,21 @@ anta.tests.software:
- 4.26.1F
```

```json
{
"anta.tests.software": [
{
"VerifyEOSVersion": {
"versions": [
"4.25.4M",
"4.31.1F"
]
}
}
]
}
```

The following example is a very minimal test catalog:

```yaml
Expand Down
11 changes: 11 additions & 0 deletions tests/data/test_catalog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"anta.tests.software": [
{
"VerifyEOSVersion": {
"versions": [
"4.31.1F"
]
}
}
]
}
1 change: 1 addition & 0 deletions tests/data/test_catalog_invalid_json.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{aasas"anta.tests.software":[{"VerifyEOSVersion":{"versions":["4.31.1F"]}}]}
7 changes: 7 additions & 0 deletions tests/units/cli/nrfu/test__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ def test_anta_nrfu_dry_run(click_runner: CliRunner) -> None:
assert "Dry-run" in result.output


def test_anta_nrfu_wrong_catalog_format(click_runner: CliRunner) -> None:
"""Test anta nrfu --dry-run, catalog is given via env."""
result = click_runner.invoke(anta, ["nrfu", "--dry-run", "--catalog-format", "toto"])
assert result.exit_code == ExitCode.USAGE_ERROR
assert "Invalid value for '--catalog-format': 'toto' is not one of 'yaml', 'json'." in result.output


def test_anta_password_required(click_runner: CliRunner) -> None:
"""Test that password is provided."""
env = default_anta_env()
Expand Down
30 changes: 26 additions & 4 deletions tests/units/test_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

from json import load as json_load
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -42,6 +43,14 @@
(VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"])),
],
},
{
"name": "test_catalog",
"filename": "test_catalog.json",
"file_format": "json",
"tests": [
(VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"])),
],
},
{
"name": "test_catalog_with_tags",
"filename": "test_catalog_with_tags.yml",
Expand Down Expand Up @@ -83,6 +92,18 @@
},
]
CATALOG_PARSE_FAIL_DATA: list[dict[str, Any]] = [
{
"name": "undefined_tests",
"filename": "test_catalog_wrong_format.toto",
"file_format": "toto",
"error": "'toto' is not a valid format for an AntaCatalog file. Only 'yaml' and 'json' are supported.",
},
{
"name": "invalid_json",
"filename": "test_catalog_invalid_json.json",
"file_format": "json",
"error": "JSONDecodeError",
},
{
"name": "undefined_tests",
"filename": "test_catalog_with_undefined_tests.yml",
Expand Down Expand Up @@ -185,7 +206,7 @@ class TestAntaCatalog:
@pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA))
def test_parse(self, catalog_data: dict[str, Any]) -> None:
"""Instantiate AntaCatalog from a file."""
catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / catalog_data["filename"])
catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / catalog_data["filename"], file_format=catalog_data.get("file_format", "yaml"))

assert len(catalog.tests) == len(catalog_data["tests"])
for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]):
Expand All @@ -211,7 +232,8 @@ def test_from_dict(self, catalog_data: dict[str, Any]) -> None:
"""Instantiate AntaCatalog from a dict."""
file = DATA_DIR / catalog_data["filename"]
with file.open(encoding="UTF-8") as file:
data = safe_load(file)
file_format = catalog_data.get("file_format", "yaml")
data = safe_load(file) if file_format == "yaml" else json_load(file)
catalog: AntaCatalog = AntaCatalog.from_dict(data)

assert len(catalog.tests) == len(catalog_data["tests"])
Expand All @@ -224,8 +246,8 @@ def test_from_dict(self, catalog_data: dict[str, Any]) -> None:
@pytest.mark.parametrize("catalog_data", CATALOG_PARSE_FAIL_DATA, ids=generate_test_ids_list(CATALOG_PARSE_FAIL_DATA))
def test_parse_fail(self, catalog_data: dict[str, Any]) -> None:
"""Errors when instantiating AntaCatalog from a file."""
with pytest.raises((ValidationError, TypeError)) as exec_info:
AntaCatalog.parse(DATA_DIR / catalog_data["filename"])
with pytest.raises((ValidationError, TypeError, ValueError, OSError)) as exec_info:
AntaCatalog.parse(DATA_DIR / catalog_data["filename"], file_format=catalog_data.get("file_format", "yaml"))
if isinstance(exec_info.value, ValidationError):
assert catalog_data["error"] in exec_info.value.errors()[0]["msg"]
else:
Expand Down
Loading