diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-03-28 06:11:39 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-03-28 06:11:39 +0000 |
commit | 1fd6a618b60d7168fd8f37585d5d39d22d775afd (patch) | |
tree | fbc6d0c213b8acdd0a31deafe5c5fc0d05a3a312 | |
parent | Initial commit. (diff) | |
download | anta-1fd6a618b60d7168fd8f37585d5d39d22d775afd.tar.xz anta-1fd6a618b60d7168fd8f37585d5d39d22d775afd.zip |
Adding upstream version 0.13.0.upstream/0.13.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
272 files changed, 33172 insertions, 0 deletions
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..80633ca --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,39 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm", + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers-contrib/features/direnv:1": {}, + "ghcr.io/devcontainers-contrib/features/pre-commit:2": {} + }, + + // Configure tool-specific properties. + "customizations": { + "vscode": { + "settings": {}, + "extensions": [ + "ms-python.black-formatter", + "ms-python.isort", + "formulahendry.github-actions", + "matangover.mypy", + "ms-python.mypy-type-checker", + "ms-python.pylint", + "LittleFoxTeam.vscode-python-test-adapter", + "njqdev.vscode-python-typehint", + "hbenl.vscode-test-explorer" + ] + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "bash .devcontainer/startup.sh" + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/startup.sh b/.devcontainer/startup.sh new file mode 100644 index 0000000..fb9f6f1 --- /dev/null +++ b/.devcontainer/startup.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# echo "Configure direnv" +# echo "eval \"$(direnv hook bash)\"" >> ~/.bashrc + +echo "Upgrading pip" +pip install --upgrade pip + +echo "Installing ANTA package from git" +pip install -e . + +echo "Installing development tools" +pip install -e ".[dev]" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fbe1ace --- /dev/null +++ b/.dockerignore @@ -0,0 +1,93 @@ +# Git +.git +.gitignore +.gitattributes + + +# CI +.codeclimate.yml +.travis.yml +.taskcluster.yml + +# Docker +docker-compose.yml +Dockerfile +.docker +.dockerignore + +# Byte-compiled / optimized / DLL files +**/__pycache__/ +**/*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Virtual environment +.env +.venv/ +venv/ + +# PyCharm +.idea + +# Python mode for VIM +.ropeproject +**/.ropeproject + +# Vim swap files +**/*.swp + +# VS Code +.vscode/ + +tests/** +examples/** + diff --git a/.github/actions/rn-pr-labeler-action/action.yml b/.github/actions/rn-pr-labeler-action/action.yml new file mode 100644 index 0000000..a685ffc --- /dev/null +++ b/.github/actions/rn-pr-labeler-action/action.yml @@ -0,0 +1,41 @@ +name: "rn-pr-labeler" +author: "@gmuloc" +description: "Parse a conventional commit compliant PR title and add it as a label to the PR with the prefix 'rn: '" +inputs: + auto_create_label: + description: "Boolean to indicate if the label should be auto created" + required: false + default: false +runs: + using: "composite" + steps: + - name: 'Looking up existing "rn:" label' + run: | + echo "OLD_LABEL=$(gh pr view ${{ github.event.pull_request.number }} --json labels -q .labels[].name | grep 'rn: ')" >> $GITHUB_ENV + shell: bash + - name: 'Delete existing "rn:" label if found' + run: gh pr edit ${{ github.event.pull_request.number }} --remove-label "${{ env.OLD_LABEL }}" + shell: bash + if: ${{ env.OLD_LABEL }} + - name: Set Label + # Using toJSON to support ' and " in commit messages + # https://stackoverflow.com/questions/73363167/github-actions-how-to-escape-characters-in-commit-message + run: echo "LABEL=$(echo ${{ toJSON(github.event.pull_request.title) }} | cut -d ':' -f 1 | tr -d ' ')" >> $GITHUB_ENV + shell: bash + # an alternative to verifying if the target label already exist is to + # create the label with --force in the next step, it will keep on changing + # the color of the label though so it may not be desirable. + - name: Check if label exist + run: | + EXIST=$(gh label list -L 100 --search "rn:" --json name -q '.[] | select(.name=="rn: ${{ env.LABEL }}").name') + echo "EXIST=$EXIST" >> $GITHUB_ENV + shell: bash + - name: Create Label if auto-create and label does not exist already + run: | + gh label create "rn: ${{ env.LABEL }}" + shell: bash + if: ${{ inputs.auto_create_label && ! env.EXIST }} + - name: Labelling PR + run: | + gh pr edit ${{ github.event.pull_request.number }} --add-label "rn: ${{ env.LABEL }}" + shell: bash diff --git a/.github/changelog.sh b/.github/changelog.sh new file mode 100644 index 0000000..2d43826 --- /dev/null +++ b/.github/changelog.sh @@ -0,0 +1,435 @@ +#!/usr/bin/env zsh + +############################## +# CHANGELOG SCRIPT CONSTANTS # +############################## + +#* Holds the list of valid types recognized in a commit subject +#* and the display string of such type +local -A TYPES +TYPES=( + BUILD "Build system" + CHORE "Chore" + CI "CI" + CUT "Features removed" + DOC "Documentation" + FEAT "Features" + FIX "Bug fixes" + LICENSE "License update" + MAKE "Build system" + OPTIMIZE "Code optimization" + PERF "Performance" + REFACTOR "Code Refactoring" + REFORMAT "Code Reformating" + REVERT "Revert" + TEST "Testing" +) + +#* Types that will be displayed in their own section, +#* in the order specified here. +local -a MAIN_TYPES +MAIN_TYPES=(FEAT FIX PERF REFACTOR DOCS DOC) + +#* Types that will be displayed under the category of other changes +local -a OTHER_TYPES +OTHER_TYPES=(MAKE TEST STYLE CI OTHER) + +#* Commit types that don't appear in $MAIN_TYPES nor $OTHER_TYPES +#* will not be displayed and will simply be ignored. + + +############################ +# COMMIT PARSING UTILITIES # +############################ + +function parse-commit { + + # This function uses the following globals as output: commits (A), + # subjects (A), scopes (A) and breaking (A). All associative arrays (A) + # have $hash as the key. + # - commits holds the commit type + # - subjects holds the commit subject + # - scopes holds the scope of a commit + # - breaking holds the breaking change warning if a commit does + # make a breaking change + + function commit:type { + local commit_message="$1" + local type="$(sed -E 's/^([a-zA-Z_\-]+)(\(.+\))?!?: .+$/\1/' <<< "$commit_message"| tr '[:lower:]' '[:upper:]')" + # If $type doesn't appear in $TYPES array mark it as 'other' + if [[ -n "${(k)TYPES[(i)${type}]}" ]]; then + echo $type + else + echo other + fi + } + + function commit:scope { + local scope + + # Try to find scope in "type(<scope>):" format + # Scope will be formatted in lower cases + scope=$(sed -nE 's/^[a-zA-Z_\-]+\((.+)\)!?: .+$/\1/p' <<< "$1") + if [[ -n "$scope" ]]; then + echo "$scope" | tr '[:upper:]' '[:lower:]' + return + fi + + # If no scope found, try to find it in "<scope>:" format + # Make sure it's not a type before printing it + scope=$(sed -nE 's/^([a-zA-Z_\-]+): .+$/\1/p' <<< "$1") + if [[ -z "${(k)TYPES[(i)$scope]}" ]]; then + echo "$scope" + fi + } + + function commit:subject { + # Only display the relevant part of the commit, i.e. if it has the format + # type[(scope)!]: subject, where the part between [] is optional, only + # displays subject. If it doesn't match the format, returns the whole string. + sed -E 's/^[a-zA-Z_\-]+(\(.+\))?!?: (.+)$/\2/' <<< "$1" + } + + # Return subject if the body or subject match the breaking change format + function commit:is-breaking { + local subject="$1" body="$2" message + + if [[ "$body" =~ "BREAKING CHANGE: (.*)" || \ + "$subject" =~ '^[^ :\)]+\)?!: (.*)$' ]]; then + message="${match[1]}" + # remove CR characters (might be inserted in GitHub UI commit description form) + message="${message//$'\r'/}" + # skip next paragraphs (separated by two newlines or more) + message="${message%%$'\n\n'*}" + # ... and replace newlines with spaces + echo "${message//$'\n'/ }" + else + return 1 + fi + } + + # Return truncated hash of the reverted commit + function commit:is-revert { + local subject="$1" body="$2" + + if [[ "$subject" = Revert* && \ + "$body" =~ "This reverts commit ([^.]+)\." ]]; then + echo "${match[1]:0:7}" + else + return 1 + fi + } + + # Parse commit with hash $1 + local hash="$1" subject body warning rhash + subject="$(command git show -s --format=%s $hash)" + body="$(command git show -s --format=%b $hash)" + + # Commits following Conventional Commits (https://www.conventionalcommits.org/) + # have the following format, where parts between [] are optional: + # + # type[(scope)][!]: subject + # + # commit body + # [BREAKING CHANGE: warning] + + # commits holds the commit type + commits[$hash]="$(commit:type "$subject")" + # scopes holds the commit scope + scopes[$hash]="$(commit:scope "$subject")" + # subjects holds the commit subject + subjects[$hash]="$(commit:subject "$subject")" + + # breaking holds whether a commit has breaking changes + # and its warning message if it does + if warning=$(commit:is-breaking "$subject" "$body"); then + breaking[$hash]="$warning" + fi + + # reverts holds commits reverted in the same release + if rhash=$(commit:is-revert "$subject" "$body"); then + reverts[$hash]=$rhash + fi +} + +############################# +# RELEASE CHANGELOG DISPLAY # +############################# + +function display-release { + + # This function uses the following globals: output, version, + # commits (A), subjects (A), scopes (A), breaking (A) and reverts (A). + # + # - output is the output format to use when formatting (raw|text|md) + # - version is the version in which the commits are made + # - commits, subjects, scopes, breaking, and reverts are associative arrays + # with commit hashes as keys + + # Remove commits that were reverted + local hash rhash + for hash rhash in ${(kv)reverts}; do + if (( ${+commits[$rhash]} )); then + # Remove revert commit + unset "commits[$hash]" "subjects[$hash]" "scopes[$hash]" "breaking[$hash]" + # Remove reverted commit + unset "commits[$rhash]" "subjects[$rhash]" "scopes[$rhash]" "breaking[$rhash]" + fi + done + + # If no commits left skip displaying the release + if (( $#commits == 0 )); then + return + fi + + ##* Formatting functions + + # Format the hash according to output format + # If no parameter is passed, assume it comes from `$hash` + function fmt:hash { + #* Uses $hash from outer scope + local hash="${1:-$hash}" + case "$output" in + raw) printf "$hash" ;; + text) printf "\e[33m$hash\e[0m" ;; # red + md) printf "[\`$hash\`](https://github.com/aristanetworks/ansible-avd/commit/$hash)" ;; + esac + } + + # Format headers according to output format + # Levels 1 to 2 are considered special, the rest are formatted + # the same, except in md output format. + function fmt:header { + local header="$1" level="$2" + case "$output" in + raw) + case "$level" in + 1) printf "$header\n$(printf '%.0s=' {1..${#header}})\n\n" ;; + 2) printf "$header\n$(printf '%.0s-' {1..${#header}})\n\n" ;; + *) printf "$header:\n\n" ;; + esac ;; + text) + case "$level" in + 1|2) printf "\e[1;4m$header\e[0m\n\n" ;; # bold, underlined + *) printf "\e[1m$header:\e[0m\n\n" ;; # bold + esac ;; + md) printf "$(printf '%.0s#' {1..${level}}) $header\n\n" ;; + esac + } + + function fmt:scope { + #* Uses $scopes (A) and $hash from outer scope + local scope="${1:-${scopes[$hash]}}" + + # Get length of longest scope for padding + local max_scope=0 padding=0 + for hash in ${(k)scopes}; do + max_scope=$(( max_scope < ${#scopes[$hash]} ? ${#scopes[$hash]} : max_scope )) + done + + # If no scopes, exit the function + if [[ $max_scope -eq 0 ]]; then + return + fi + + # Get how much padding is required for this scope + padding=$(( max_scope < ${#scope} ? 0 : max_scope - ${#scope} )) + padding="${(r:$padding:: :):-}" + + # If no scope, print padding and 3 spaces (equivalent to "[] ") + if [[ -z "$scope" ]]; then + printf "${padding} " + return + fi + + # Print [scope] + case "$output" in + raw|md) printf "[$scope]${padding} " ;; + text) printf "[\e[38;5;9m$scope\e[0m]${padding} " ;; # red 9 + esac + } + + # If no parameter is passed, assume it comes from `$subjects[$hash]` + function fmt:subject { + #* Uses $subjects (A) and $hash from outer scope + local subject="${1:-${subjects[$hash]}}" + + # Capitalize first letter of the subject + subject="${(U)subject:0:1}${subject:1}" + + case "$output" in + raw) printf "$subject" ;; + # In text mode, highlight (#<issue>) and dim text between `backticks` + text) sed -E $'s|#([0-9]+)|\e[32m#\\1\e[0m|g;s|`([^`]+)`|`\e[2m\\1\e[0m`|g' <<< "$subject" ;; + # In markdown mode, link to (#<issue>) issues + md) sed -E 's|#([0-9]+)|[#\1](https://github.com/aristanetworks/ansible-avd/issues/\1)|g' <<< "$subject" ;; + esac + } + + function fmt:type { + #* Uses $type from outer scope + local type="${1:-${TYPES[$type]:-${(C)type}}}" + [[ -z "$type" ]] && return 0 + case "$output" in + raw|md) printf "$type: " ;; + text) printf "\e[4m$type\e[24m: " ;; # underlined + esac + } + + ##* Section functions + + function display:version { + fmt:header "$version" 2 + } + + function display:breaking { + (( $#breaking != 0 )) || return 0 + + case "$output" in + raw) fmt:header "BREAKING CHANGES" 3 ;; + text|md) fmt:header "⚠ BREAKING CHANGES" 3 ;; + esac + + local hash subject + for hash message in ${(kv)breaking}; do + echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject "${message}")" + done | sort + echo + } + + function display:type { + local hash type="$1" + + local -a hashes + hashes=(${(k)commits[(R)$type]}) + + # If no commits found of type $type, go to next type + (( $#hashes != 0 )) || return 0 + + fmt:header "${TYPES[$type]}" 3 + for hash in $hashes; do + echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)" + done | sort -k3 # sort by scope + echo + } + + function display:others { + local hash type + + # Commits made under types considered other changes + local -A changes + changes=(${(kv)commits[(R)${(j:|:)OTHER_TYPES}]}) + + # If no commits found under "other" types, don't display anything + (( $#changes != 0 )) || return 0 + + fmt:header "Other changes" 3 + for hash type in ${(kv)changes}; do + case "$type" in + other) echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)" ;; + *) echo " - $(fmt:hash) $(fmt:scope)$(fmt:type)$(fmt:subject)" ;; + esac + done | sort -k3 # sort by scope + echo + } + + ##* Release sections order + + # Display version header + display:version + + # Display breaking changes first + display:breaking + + # Display changes for commit types in the order specified + for type in $MAIN_TYPES; do + display:type "$type" + done + + # Display other changes + display:others +} + +function main { + # $1 = until commit, $2 = since commit + local until="$1" since="$2" + + # $3 = output format (--text|--raw|--md) + # --md: uses markdown formatting + # --raw: outputs without style + # --text: uses ANSI escape codes to style the output + local output=${${3:-"--text"}#--*} + + if [[ -z "$until" ]]; then + until=HEAD + fi + + if [[ -z "$since" ]]; then + # If $since is not specified: + # 1) try to find the version used before updating + # 2) try to find the first version tag before $until + since=$(command git config --get ansible-avd.lastVersion 2>/dev/null) || \ + since=$(command git describe --abbrev=0 --tags "$until^" 2>/dev/null) || \ + unset since + elif [[ "$since" = --all ]]; then + unset since + fi + + # Commit classification arrays + local -A commits subjects scopes breaking reverts + local truncate=0 read_commits=0 + local hash version tag + + # Get the first version name: + # 1) try tag-like version, or + # 2) try name-rev, or + # 3) try branch name, or + # 4) try short hash + version=$(command git describe --tags $until 2>/dev/null) \ + || version=$(command git name-rev --no-undefined --name-only --exclude="remotes/*" $until 2>/dev/null) \ + || version=$(command git symbolic-ref --quiet --short $until 2>/dev/null) \ + || version=$(command git rev-parse --short $until 2>/dev/null) + + # Get commit list from $until commit until $since commit, or until root + # commit if $since is unset, in short hash form. + # --first-parent is used when dealing with merges: it only prints the + # merge commit, not the commits of the merged branch. + command git rev-list --first-parent --abbrev-commit --abbrev=7 ${since:+$since..}$until | while read hash; do + # Truncate list on versions with a lot of commits + if [[ -z "$since" ]] && (( ++read_commits > 35 )); then + truncate=1 + break + fi + + # If we find a new release (exact tag) + if tag=$(command git describe --exact-match --tags $hash 2>/dev/null); then + # Output previous release + display-release + # Reinitialize commit storage + commits=() + subjects=() + scopes=() + breaking=() + reverts=() + # Start work on next release + version="$tag" + read_commits=1 + fi + + parse-commit "$hash" + done + + display-release + + if (( truncate )); then + echo " ...more commits omitted" + echo + fi +} + +# Use raw output if stdout is not a tty +if [[ ! -t 1 && -z "$3" ]]; then + main "$1" "$2" --raw +else + main "$@" +fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..30fd0aa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +# Basic set up for three package managers + +version: 2 +updates: + # Maintain dependencies for Python + # Dependabot supports updates to pyproject.toml files + # if they follow the PEP 621 standard. + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + reviewers: + - "titom73" + - "gmuloc" + - "mtache" + - "carl-baillargeon" + labels: + - 'dependencies' + pull-request-branch-name: + separator: "/" + commit-message: + prefix: "chore: " + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + reviewers: + - "titom73" + - "gmuloc" + labels: + - 'CI' + commit-message: + prefix: "ci: "
\ No newline at end of file diff --git a/.github/generate_release.py b/.github/generate_release.py new file mode 100644 index 0000000..56b6500 --- /dev/null +++ b/.github/generate_release.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +""" +generate_release.py + +This script is used to generate the release.yml file as per +https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes +""" + +import yaml + +SCOPES = [ + "anta", + "anta.tests", + "anta.cli", +] + +# CI and Test are excluded from Release Notes +CATEGORIES = { + "feat": "Features", + "fix": "Bug Fixes", + "cut": "Cut", + "doc": "Documentation", + # "CI": "CI", + "bump": "Bump", + # "test": "Test", + "revert": "Revert", + "refactor": "Refactoring", +} + + +class SafeDumper(yaml.SafeDumper): + """ + Make yamllint happy + https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586 + """ + + # pylint: disable=R0901,W0613,W1113 + + def increase_indent(self, flow=False, *args, **kwargs): + return super().increase_indent(flow=flow, indentless=False) + + +if __name__ == "__main__": + exclude_list = [] + categories_list = [] + + # First add exclude labels + for scope in SCOPES: + exclude_list.append(f"rn: test({scope})") + exclude_list.append(f"rn: ci({scope})") + exclude_list.extend(["rn: test", "rn: ci"]) + + # Then add the categories + # First add Breaking Changes + breaking_label_categories = ["feat", "fix", "cut", "revert", "refactor", "bump"] + breaking_labels = [f"rn: {cc_type}({scope})!" for cc_type in breaking_label_categories for scope in SCOPES] + breaking_labels.extend([f"rn: {cc_type}!" for cc_type in breaking_label_categories]) + + categories_list.append( + { + "title": "Breaking Changes", + "labels": breaking_labels, + } + ) + + # Add new features + feat_labels = [f"rn: feat({scope})" for scope in SCOPES] + feat_labels.append("rn: feat") + + categories_list.append( + { + "title": "New features and enhancements", + "labels": feat_labels, + } + ) + + # Add fixes + fixes_labels = [f"rn: fix({scope})" for scope in SCOPES] + fixes_labels.append("rn: fix") + + categories_list.append( + { + "title": "Fixed issues", + "labels": fixes_labels, + } + ) + + # Add Documentation + doc_labels = [f"rn: doc({scope})" for scope in SCOPES] + doc_labels.append("rn: doc") + + categories_list.append( + { + "title": "Documentation", + "labels": doc_labels, + } + ) + + # Add the catch all + categories_list.append( + { + "title": "Other Changes", + "labels": ["*"], + } + ) + with open(r"release.yml", "w", encoding="utf-8") as release_file: + yaml.dump( + { + "changelog": { + "exclude": {"labels": exclude_list}, + "categories": categories_list, + } + }, + release_file, + Dumper=SafeDumper, + sort_keys=False, + ) diff --git a/.github/license-short.txt b/.github/license-short.txt new file mode 100644 index 0000000..787e7ab --- /dev/null +++ b/.github/license-short.txt @@ -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/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ba76374 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +# Description + +<!-- PR description !--> + +Fixes # (issue id) + +# Checklist: + +<!-- Delete not relevant items !--> + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my code +- [ ] I have run pre-commit for code linting and typing (`pre-commit run`) +- [ ] I have made corresponding changes to the documentation +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes (`tox -e testenv`) diff --git a/.github/release.md b/.github/release.md new file mode 100644 index 0000000..15db226 --- /dev/null +++ b/.github/release.md @@ -0,0 +1,103 @@ +# Notes + +Notes regarding how to release anta package + +## Package requirements + +- `bumpver` +- `build` +- `twine` + +Also, [Github CLI](https://cli.github.com/) can be helpful and is recommended + +## Bumping version + +In a branch specific for this, use the `bumpver` tool. +It is configured to update: +* pyproject.toml +* docs/contribution.md +* docs/requirements-and-installation.md + +For instance to bump a patch version: +``` +bumpver update --patch +``` + +and for a minor version + +``` +bumpver update --minor +``` + +Tip: It is possible to check what the changes would be using `--dry` + +``` +bumpver update --minor --dry +``` + +## Creating release on Github + +Create the release on Github with the appropriate tag `vx.x.x` + +## Release version `x.x.x` + +> [!IMPORTANT] +> TODO - make this a github workflow + +`x.x.x` is the version to be released + +This is to be executed at the top of the repo + +1. Checkout the latest version of `main` with the correct tag for the release +2. Create a new branch for release + + ```bash + git switch -c rel/vx.x.x + ``` +3. [Optional] Clean dist if required +4. Build the package locally + + ```bash + python -m build + ``` +5. Check the package with `twine` (replace with your vesion) + + ```bash + twine check dist/* + ``` +6. Upload the package to test.pypi + + ```bash + twine upload -r testpypi dist/anta-x.x.x.* + ``` +7. Verify the package by installing it in a local venv and checking it installs + and run correctly (run the tests) + + ```bash + # In a brand new venv + pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple --no-cache anta + ``` +8. Push to anta repository and create a Pull Request + + ```bash + git push origin HEAD + gh pr create --title 'bump: ANTA vx.x.x' + ``` +9. Merge PR after review and wait for [workflow](https://github.com/arista-netdevops-community/anta/actions/workflows/release.yml) to be executed. + + ```bash + gh pr merge --squash + ``` + +10. Like 7 but for normal pypi + + ```bash + # In a brand new venv + pip install anta + ``` + +11. Test installed version + + ```bash + anta --version + ```
\ No newline at end of file diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..63657e0 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,59 @@ +changelog: + exclude: + labels: + - 'rn: test(anta)' + - 'rn: ci(anta)' + - 'rn: test(anta.tests)' + - 'rn: ci(anta.tests)' + - 'rn: test(anta.cli)' + - 'rn: ci(anta.cli)' + - 'rn: test' + - 'rn: ci' + categories: + - title: Breaking Changes + labels: + - 'rn: feat(anta)!' + - 'rn: feat(anta.tests)!' + - 'rn: feat(anta.cli)!' + - 'rn: fix(anta)!' + - 'rn: fix(anta.tests)!' + - 'rn: fix(anta.cli)!' + - 'rn: cut(anta)!' + - 'rn: cut(anta.tests)!' + - 'rn: cut(anta.cli)!' + - 'rn: revert(anta)!' + - 'rn: revert(anta.tests)!' + - 'rn: revert(anta.cli)!' + - 'rn: refactor(anta)!' + - 'rn: refactor(anta.tests)!' + - 'rn: refactor(anta.cli)!' + - 'rn: bump(anta)!' + - 'rn: bump(anta.tests)!' + - 'rn: bump(anta.cli)!' + - 'rn: feat!' + - 'rn: fix!' + - 'rn: cut!' + - 'rn: revert!' + - 'rn: refactor!' + - 'rn: bump!' + - title: New features and enhancements + labels: + - 'rn: feat(anta)' + - 'rn: feat(anta.tests)' + - 'rn: feat(anta.cli)' + - 'rn: feat' + - title: Fixed issues + labels: + - 'rn: fix(anta)' + - 'rn: fix(anta.tests)' + - 'rn: fix(anta.cli)' + - 'rn: fix' + - title: Documentation + labels: + - 'rn: doc(anta)' + - 'rn: doc(anta.tests)' + - 'rn: doc(anta.cli)' + - 'rn: doc' + - title: Other Changes + labels: + - '*' diff --git a/.github/workflows/code-testing.yml b/.github/workflows/code-testing.yml new file mode 100644 index 0000000..4d4c0a6 --- /dev/null +++ b/.github/workflows/code-testing.yml @@ -0,0 +1,144 @@ +--- +name: Linting and Testing Anta +on: + push: + branches: + - main + pull_request: + +jobs: + file-changes: + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + docs: ${{ steps.filter.outputs.docs }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + code: + - 'anta/*' + - 'anta/**' + - 'tests/*' + - 'tests/**' + core: + - 'anta/*' + - 'anta/reporter/*' + - 'anta/result_manager/*' + - 'anta/tools/*' + cli: + - 'anta/cli/*' + - 'anta/cli/**' + tests: + - 'anta/tests/*' + - 'anta/tests/**' + docs: + - '.github/workflows/pull-request-management.yml' + - 'mkdocs.yml' + - 'docs/*' + - 'docs/**' + - 'README.md' + check-requirements: + runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + needs: file-changes + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: install requirements + run: | + pip install . + - name: install dev requirements + run: pip install .[dev] + missing-documentation: + name: "Warning documentation is missing" + runs-on: ubuntu-20.04 + needs: [file-changes] + if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false' + steps: + - name: Documentation is missing + uses: GrantBirki/comment@v2.0.9 + with: + body: | + Please consider that documentation is missing under `docs/` folder. + You should update documentation to reflect your change, or maybe not :) + lint-yaml: + name: Run linting for yaml files + runs-on: ubuntu-20.04 + needs: [file-changes, check-requirements] + if: needs.file-changes.outputs.code == 'true' + steps: + - uses: actions/checkout@v4 + - name: yaml-lint + uses: ibiqlik/action-yamllint@v3 + with: + config_file: .yamllint.yml + file_or_dir: . + lint-python: + name: Run isort, black, flake8 and pylint + runs-on: ubuntu-20.04 + needs: file-changes + if: needs.file-changes.outputs.code == 'true' + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: pip install tox + - name: "Run tox linting environment" + run: tox -e lint + type-python: + name: Run mypy + runs-on: ubuntu-20.04 + needs: file-changes + if: needs.file-changes.outputs.code == 'true' + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: pip install tox + - name: "Run tox typing environment" + run: tox -e type + test-python: + name: Pytest across all supported python versions + runs-on: ubuntu-20.04 + needs: [lint-python, type-python] + strategy: + matrix: + python: ["3.8", "3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install dependencies + run: pip install tox tox-gh-actions + - name: "Run pytest via tox for ${{ matrix.python }}" + run: tox + test-documentation: + name: Build offline documentation for testing + runs-on: ubuntu-20.04 + needs: [lint-python, type-python, test-python] + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: pip install .[doc] + - name: "Build mkdocs documentation offline" + run: mkdocs build diff --git a/.github/workflows/main-doc.yml b/.github/workflows/main-doc.yml new file mode 100644 index 0000000..0d46fa9 --- /dev/null +++ b/.github/workflows/main-doc.yml @@ -0,0 +1,37 @@ +--- +# This is deploying the latest commits on main to main documentation +name: Mkdocs +on: + push: + branches: + - main + paths: + # Run only if any of the following paths are changed when pushing to main + # May need to update this + - "docs/**" + - "mkdocs.yml" + workflow_dispatch: + +jobs: + 'build_latest_doc': + name: 'Update Public main documentation' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 'Setup Python 3 on runner' + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Setup Git config + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + + - name: 'Build mkdocs content and deploy to gh-pages to main' + run: | + pip install .[doc] + mike deploy --push main diff --git a/.github/workflows/on-demand.yml b/.github/workflows/on-demand.yml new file mode 100644 index 0000000..85e7c41 --- /dev/null +++ b/.github/workflows/on-demand.yml @@ -0,0 +1,49 @@ +name: 'Build docker on-demand' +on: + workflow_dispatch: + inputs: + tag: + description: 'docker container tag' + required: true + type: string + default: 'dev' + +jobs: + docker: + name: Docker Image Build + runs-on: ubuntu-latest + strategy: + matrix: + platform: + - linux/amd64 + - linux/arm64 + - linux/arm/v7 + - linux/arm/v8 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta for TAG + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=${{ inputs.tag }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + platforms: linux/amd64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/pr-conflicts.yml b/.github/workflows/pr-conflicts.yml new file mode 100644 index 0000000..a126268 --- /dev/null +++ b/.github/workflows/pr-conflicts.yml @@ -0,0 +1,18 @@ +name: "PR Conflicts checker" +on: + pull_request_target: + types: [synchronize] + +jobs: + Conflict_Check: + name: 'Check PR status: conflicts and resolution' + runs-on: ubuntu-latest + steps: + - name: check if PRs are dirty + uses: eps1lon/actions-label-merge-conflict@releases/2.x + with: + dirtyLabel: "state: conflict" + removeOnDirtyLabel: "state: conflict resolved" + repoToken: "${{ secrets.GITHUB_TOKEN }}" + commentOnDirty: "This pull request has conflicts, please resolve those before we can evaluate the pull request." + commentOnClean: "Conflicts have been resolved. A maintainer will review the pull request shortly." diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml new file mode 100644 index 0000000..d60937d --- /dev/null +++ b/.github/workflows/pr-triage.yml @@ -0,0 +1,73 @@ +name: "Pull Request Triage" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +jobs: + assign_author: + name: "Assign Author to PR" + # https://github.com/marketplace/actions/auto-author-assign + runs-on: ubuntu-latest + steps: + - uses: toshimaru/auto-author-assign@v2.1.0 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + + check_pr_semantic: + runs-on: ubuntu-latest + steps: + # Please look up the latest version from + # https://github.com/amannn/action-semantic-pull-request/releases + - uses: amannn/action-semantic-pull-request@v5.4.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Configure which types are allowed. + # Default: https://github.com/commitizen/conventional-commit-types + # Updated as part of PR 1930 + types: | + feat + fix + cut + doc + ci + bump + test + refactor + revert + make + chore + # Configure which scopes are allowed. + scopes: | + anta + anta.tests + anta.cli + # Configure that a scope must always be provided. + requireScope: false + # Configure additional validation for the subject based on a regex. + # This example ensures the subject doesn't start with an uppercase character. + # subjectPattern: ^(?![A-Z]).+$ + # If `subjectPattern` is configured, you can use this property to override + # the default error message that is shown when the pattern doesn't match. + # The variables `subject` and `title` can be used within the message. + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + didn't match the configured pattern. Please ensure that the subject + doesn't start with an uppercase character. + # When using "Squash and merge" on a PR with only one commit, GitHub + # will suggest using that commit message instead of the PR title for the + # merge commit, and it's easy to commit this by mistake. Enable this option + # to also validate the commit message for one commit PRs. + # Update 13-Jul-2022 CH: GitHub now offers a toggle for this behavior. + # We have set that to always use the PR title, so this check is no longer needed. + validateSingleCommit: false + # Related to `validateSingleCommit` you can opt-in to validate that the PR + # title matches a single commit to avoid confusion. + validateSingleCommitMatchesPrTitle: true + ignoreLabels: | + bot + ignore-semantic-pull-request diff --git a/.github/workflows/pull-request-rn-labeler.yml b/.github/workflows/pull-request-rn-labeler.yml new file mode 100644 index 0000000..39da881 --- /dev/null +++ b/.github/workflows/pull-request-rn-labeler.yml @@ -0,0 +1,27 @@ +# This workflow is triggered after a PR is merged or when the title of a PR is +# changed post merge +name: "Label for Release Notes" + + +on: + pull_request_target: + types: + - closed + - edited # interested in post merge title changes + +jobs: + ################################################### + # Assign labels on merge to generate Release Notes + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-workflow-when-a-pull-request-merges + ################################################### + if_merged: + name: "PR was merged" + if: (github.event.pull_request.merged == true) && ( github.event.action == 'closed' || (github.event.action == 'edited' && github.event.changes.title != null) ) + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rn-pr-labeler-action + with: + auto_create_label: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b9088f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,110 @@ +--- +name: "Tag & Release management" +on: + release: + types: + - published + +jobs: + pypi: + name: Publish version to Pypi servers + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel build + - name: Build package + run: | + python -m build + - name: Publish package to Pypi + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + + release-coverage: + name: Updated ANTA release coverage badge + runs-on: ubuntu-20.04 + needs: [pypi] + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: pip install genbadge[coverage] tox tox-gh-actions + - name: "Run pytest via tox for ${{ matrix.python }}" + run: tox + - name: Generate coverage badge + run: genbadge coverage -i .coverage.xml -o badge/latest-release-coverage.svg + - name: Publish coverage badge to gh-pages branch + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: coverage-badge + folder: badge + release-doc: + name: "Publish documentation for release ${{github.ref_name}}" + runs-on: ubuntu-latest + needs: [release-coverage] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: 'Setup Python 3 on runner' + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Setup Git config + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + + - name: 'Build mkdocs content to site folder' + run: | + pip install .[doc] + mike deploy --update-alias --push ${{github.ref_name}} stable + + docker: + name: Docker Image Build + runs-on: ubuntu-latest + needs: [pypi] + strategy: + matrix: + platform: + - linux/amd64 + - linux/arm64 + - linux/arm/v7 + - linux/arm/v8 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta for TAG + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + platforms: linux/amd64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7a0699 --- /dev/null +++ b/.gitignore @@ -0,0 +1,114 @@ +__pycache__ +*.pyc +.pages +.coverage +.pytest_cache +build +dist +*.egg-info +scripts/test*.py +examples/tests_* +.personal/* +*.env +*.swp + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +./lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +.flake8 + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ +report.html + +# Sphinx documentation +docs/_build/ + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.envrc + +# mkdocs documentation +/site + +# VScode settings +.vscode +test.env +tech-support/ +tech-support/* +2* + +**/report.html +.*report.html + +# direnv file +.envrc + +clab-atd-anta/* +clab-atd-anta/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d2a26a4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,105 @@ +--- +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +files: ^(anta|docs|scripts|tests)/ + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files + - id: check-merge-conflict + + - repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.5.4 + hooks: + - name: Check and insert license on Python files + id: insert-license + # exclude: + files: .*\.py$ + args: + - --license-filepath + - .github/license-short.txt + - --use-current-year + - --allow-past-years + - --fuzzy-match-generates-todo + - --no-extra-eol + + - name: Check and insert license on Markdown files + id: insert-license + files: .*\.md$ + # exclude: + args: + - --license-filepath + - .github/license-short.txt + - --use-current-year + - --allow-past-years + - --fuzzy-match-generates-todo + - --comment-style + - '<!--| ~| -->' + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + name: Check for changes when running isort on all python files + + - repo: https://github.com/psf/black + rev: 24.1.1 + hooks: + - id: black + name: Check for changes when running Black on all python files + + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + name: Check for PEP8 error on Python files + args: + - --config=/dev/null + - --max-line-length=165 + + - repo: local # as per https://pylint.pycqa.org/en/latest/user_guide/installation/pre-commit-integration.html + hooks: + - id: pylint + entry: pylint + language: python + name: Check for Linting error on Python files + description: This hook runs pylint. + types: [python] + args: + - -rn # Only display messages + - -sn # Don't display the score + - --rcfile=pylintrc # Link to config file + + # Prepare to turn on ruff + # - repo: https://github.com/astral-sh/ruff-pre-commit + # # Ruff version. + # rev: v0.0.280 + # hooks: + # - id: ruff + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.7.1 + hooks: + - id: mypy + args: + - --config-file=pyproject.toml + additional_dependencies: + - "aio-eapi==0.3.0" + - "click==8.1.3" + - "click-help-colors==0.9.1" + - "cvprac~=1.3" + - "netaddr==0.8.0" + - "pydantic~=2.0" + - "PyYAML==6.0" + - "requests>=2.27" + - "rich~=13.4" + - "asyncssh==2.13.1" + - "Jinja2==3.1.2" + - types-PyYAML + - types-paramiko + - types-requests + files: ^(anta|tests)/ diff --git a/.sourcery.yaml b/.sourcery.yaml new file mode 100644 index 0000000..a966f24 --- /dev/null +++ b/.sourcery.yaml @@ -0,0 +1,61 @@ +# 🪄 This is your project's Sourcery configuration file. + +# You can use it to get Sourcery working in the way you want, such as +# ignoring specific refactorings, skipping directories in your project, +# or writing custom rules. + +# 📚 For a complete reference to this file, see the documentation at +# https://docs.sourcery.ai/Configuration/Project-Settings/ + +# This file was auto-generated by Sourcery on 2022-07-29 at 10:15. + +version: '1' # The schema version of this config file + +ignore: # A list of paths or files which Sourcery will ignore. +- .git +- venv +- .venv +- env +- .env + +refactor: + include: [] + skip: [] # A list of rule IDs Sourcery will never suggest. + rule_types: + - refactoring + - suggestion + - comment + python_version: '3.7' # A string specifying the lowest Python version your project supports. Sourcery will not suggest refactorings requiring a higher Python version. + +# rules: # A list of custom rules Sourcery will include in its analysis. +# - id: no-print-statements +# description: Disallows print statements anywhere in code. +# pattern: print +# replacement: +# explanation: +# paths: +# include: +# - test +# exclude: +# - conftest.py +# tests: [] + +# metrics: +# quality_threshold: 25.0 + +# github: +# labels: [] +# ignore_labels: +# - sourcery-ignore +# request_review: author +# sourcery_branch: sourcery/{base_branch} + +# clone_detection: +# min_lines: 3 +# min_duplicates: 2 +# identical_clones_only: false + +# proxy: +# url: +# ssl_certs_file: +# no_ssl_verify: false diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ff14cc1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,30 @@ +{ + "black-formatter.importStrategy": "fromEnvironment", + "pylint.importStrategy": "fromEnvironment", + "pylint.args": [ + "--rcfile=pylintrc" + ], + "flake8.importStrategy": "fromEnvironment", + "flake8.args": [ + "--config=/dev/null", + "--max-line-length=165" + ], + "mypy-type-checker.importStrategy": "fromEnvironment", + "mypy-type-checker.args": [ + "--config-file=pyproject.toml" + ], + "pylint.severity": { + "refactor": "Warning" + }, + "pylint.args": [ + "--load-plugins pylint_pydantic", + "--rcfile=pylintrc" + ], + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "isort.importStrategy": "fromEnvironment", + "isort.check": true, +}
\ No newline at end of file diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..d211a31 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,4 @@ +rules: + line-length: + max: 350 + truthy: disable diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2a0ef53 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +ARG PYTHON_VER=3.9 +ARG IMG_OPTION=alpine + +### BUILDER + +FROM python:${PYTHON_VER}-${IMG_OPTION} as BUILDER + +RUN pip install --upgrade pip + +WORKDIR /local +COPY . /local + +ENV PYTHONPATH=/local +ENV PATH=$PATH:/root/.local/bin + +RUN pip --no-cache-dir install --user . + +# ----------------------------------- # + +### BASE + +FROM python:${PYTHON_VER}-${IMG_OPTION} as BASE + +# Opencontainer labels +# Labels version and revision will be updating +# during the CI with accurate information +# To configure version and revision, you can use: +# docker build --label org.opencontainers.image.version=<your version> -t ... +# Doc: https://docs.docker.com/engine/reference/commandline/run/#label +LABEL "org.opencontainers.image.title"="anta" \ + "org.opencontainers.artifact.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \ + "org.opencontainers.image.description"="network-test-automation in a Python package and Python scripts to test Arista devices." \ + "org.opencontainers.image.source"="https://github.com/arista-netdevops-community/anta" \ + "org.opencontainers.image.url"="https://www.anta.ninja" \ + "org.opencontainers.image.documentation"="https://www.anta.ninja" \ + "org.opencontainers.image.licenses"="Apache-2.0" \ + "org.opencontainers.image.vendor"="The anta contributors." \ + "org.opencontainers.image.authors"="Khelil Sator, Angélique Phillipps, Colin MacGiollaEáin, Matthieu Tache, Onur Gashi, Paul Lavelle, Guillaume Mulocher, Thomas Grimonet" \ + "org.opencontainers.image.base.name"="python" \ + "org.opencontainers.image.revision"="dev" \ + "org.opencontainers.image.version"="dev" + +COPY --from=BUILDER /root/.local/ /root/.local +ENV PATH=$PATH:/root/.local/bin + +ENTRYPOINT [ "/root/.local/bin/anta" ] @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Arista Networks + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/anta/__init__.py b/anta/__init__.py new file mode 100644 index 0000000..3973288 --- /dev/null +++ b/anta/__init__.py @@ -0,0 +1,47 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Arista Network Test Automation (ANTA) Framework.""" +import importlib.metadata +import os + +__version__ = f"v{importlib.metadata.version('anta')}" +__credits__ = [ + "Angélique Phillipps", + "Colin MacGiollaEáin", + "Khelil Sator", + "Matthieu Tâche", + "Onur Gashi", + "Paul Lavelle", + "Guillaume Mulocher", + "Thomas Grimonet", +] +__copyright__ = "Copyright 2022, Arista EMEA AS" + +# Global ANTA debug mode environment variable +__DEBUG__ = bool(os.environ.get("ANTA_DEBUG", "").lower() == "true") + + +# Source: https://rich.readthedocs.io/en/stable/appendix/colors.html +# pylint: disable=R0903 +class RICH_COLOR_PALETTE: + """Color code for text rendering.""" + + ERROR = "indian_red" + FAILURE = "bold red" + SUCCESS = "green4" + SKIPPED = "bold orange4" + HEADER = "cyan" + UNSET = "grey74" + + +# Dictionary to use in a Rich.Theme: custom_theme = Theme(RICH_COLOR_THEME) +RICH_COLOR_THEME = { + "success": RICH_COLOR_PALETTE.SUCCESS, + "skipped": RICH_COLOR_PALETTE.SKIPPED, + "failure": RICH_COLOR_PALETTE.FAILURE, + "error": RICH_COLOR_PALETTE.ERROR, + "unset": RICH_COLOR_PALETTE.UNSET, +} + +GITHUB_SUGGESTION = "Please reach out to the maintainer team or open an issue on Github: https://github.com/arista-netdevops-community/anta." diff --git a/anta/aioeapi.py b/anta/aioeapi.py new file mode 100644 index 0000000..3ede8a4 --- /dev/null +++ b/anta/aioeapi.py @@ -0,0 +1,108 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Patch for aioeapi waiting for https://github.com/jeremyschulman/aio-eapi/pull/13""" +from __future__ import annotations + +from typing import Any, AnyStr + +import aioeapi + +Device = aioeapi.Device + + +class EapiCommandError(RuntimeError): + """ + Exception class for EAPI command errors + + Attributes + ---------- + failed: str - the failed command + errmsg: str - a description of the failure reason + errors: list[str] - the command failure details + passed: list[dict] - a list of command results of the commands that passed + not_exec: list[str] - a list of commands that were not executed + """ + + # pylint: disable=too-many-arguments + def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]): + """Initializer for the EapiCommandError exception""" + self.failed = failed + self.errmsg = errmsg + self.errors = errors + self.passed = passed + self.not_exec = not_exec + super().__init__() + + def __str__(self) -> str: + """returns the error message associated with the exception""" + return self.errmsg + + +aioeapi.EapiCommandError = EapiCommandError + + +async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ignore + """ + Execute the JSON-RPC dictionary object. + + Parameters + ---------- + jsonrpc: dict + The JSON-RPC as created by the `meth`:jsonrpc_command(). + + Raises + ------ + EapiCommandError + In the event that a command resulted in an error response. + + Returns + ------- + The list of command results; either dict or text depending on the + JSON-RPC format pameter. + """ + res = await self.post("/command-api", json=jsonrpc) + res.raise_for_status() + body = res.json() + + commands = jsonrpc["params"]["cmds"] + ofmt = jsonrpc["params"]["format"] + + get_output = (lambda _r: _r["output"]) if ofmt == "text" else (lambda _r: _r) + + # if there are no errors then return the list of command results. + if (err_data := body.get("error")) is None: + return [get_output(cmd_res) for cmd_res in body["result"]] + + # --------------------------------------------------------------------- + # if we are here, then there were some command errors. Raise a + # EapiCommandError exception with args (commands that failed, passed, + # not-executed). + # --------------------------------------------------------------------- + + # -------------------------- eAPI specification ---------------------- + # On an error, no result object is present, only an error object, which + # is guaranteed to have the following attributes: code, messages, and + # data. Similar to the result object in the successful response, the + # data object is a list of objects corresponding to the results of all + # commands up to, and including, the failed command. If there was a an + # error before any commands were executed (e.g. bad credentials), data + # will be empty. The last object in the data array will always + # correspond to the failed command. The command failure details are + # always stored in the errors array. + + cmd_data = err_data["data"] + len_data = len(cmd_data) + err_at = len_data - 1 + err_msg = err_data["message"] + + raise EapiCommandError( + passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])], + failed=commands[err_at]["cmd"], + errors=cmd_data[err_at]["errors"], + errmsg=err_msg, + not_exec=commands[err_at + 1 :], # noqa: E203 + ) + + +aioeapi.Device.jsonrpc_exec = jsonrpc_exec diff --git a/anta/catalog.py b/anta/catalog.py new file mode 100644 index 0000000..0c58a56 --- /dev/null +++ b/anta/catalog.py @@ -0,0 +1,291 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Catalog related functions +""" +from __future__ import annotations + +import importlib +import logging +from inspect import isclass +from pathlib import Path +from types import ModuleType +from typing import Any, Dict, List, Optional, Tuple, Type, Union + +from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_validator +from pydantic.types import ImportString +from yaml import YAMLError, safe_load + +from anta.logger import anta_log_exception +from anta.models import AntaTest + +logger = logging.getLogger(__name__) + +# { <module_name> : [ { <test_class_name>: <input_as_dict_or_None> }, ... ] } +RawCatalogInput = Dict[str, List[Dict[str, Optional[Dict[str, Any]]]]] + +# [ ( <AntaTest class>, <input_as AntaTest.Input or dict or None > ), ... ] +ListAntaTestTuples = List[Tuple[Type[AntaTest], Optional[Union[AntaTest.Input, Dict[str, Any]]]]] + + +class AntaTestDefinition(BaseModel): + """ + Define a test with its associated inputs. + + test: An AntaTest concrete subclass + inputs: The associated AntaTest.Input subclass instance + """ + + model_config = ConfigDict(frozen=True) + + test: Type[AntaTest] + inputs: AntaTest.Input + + def __init__(self, **data: Any) -> None: + """ + Inject test in the context to allow to instantiate Input in the BeforeValidator + https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization + """ + self.__pydantic_validator__.validate_python( + data, + self_instance=self, + context={"test": data["test"]}, + ) + super(BaseModel, self).__init__() + + @field_validator("inputs", mode="before") + @classmethod + def instantiate_inputs(cls, data: AntaTest.Input | dict[str, Any] | None, info: ValidationInfo) -> AntaTest.Input: + """ + If the test has no inputs, allow the user to omit providing the `inputs` field. + If the test has inputs, allow the user to provide a valid dictionary of the input fields. + This model validator will instantiate an Input class from the `test` class field. + """ + if info.context is None: + raise ValueError("Could not validate inputs as no test class could be identified") + # Pydantic guarantees at this stage that test_class is a subclass of AntaTest because of the ordering + # of fields in the class definition - so no need to check for this + test_class = info.context["test"] + if not (isclass(test_class) and issubclass(test_class, AntaTest)): + raise ValueError(f"Could not validate inputs as no test class {test_class} is not a subclass of AntaTest") + + if data is None: + return test_class.Input() + if isinstance(data, AntaTest.Input): + return data + if isinstance(data, dict): + return test_class.Input(**data) + raise ValueError(f"Coud not instantiate inputs as type {type(data).__name__} is not valid") + + @model_validator(mode="after") + def check_inputs(self) -> "AntaTestDefinition": + """ + The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`. + """ + if not isinstance(self.inputs, self.test.Input): + raise ValueError(f"Test input has type {self.inputs.__class__.__qualname__} but expected type {self.test.Input.__qualname__}") + return self + + +class AntaCatalogFile(RootModel[Dict[ImportString[Any], List[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods + """ + This model represents an ANTA Test Catalog File. + + A valid test catalog file must have the following structure: + <Python module>: + - <AntaTest subclass>: + <AntaTest.Input compliant dictionary> + """ + + root: Dict[ImportString[Any], List[AntaTestDefinition]] + + @model_validator(mode="before") + @classmethod + def check_tests(cls, data: Any) -> Any: + """ + Allow the user to provide a Python data structure that only has string values. + This validator will try to flatten and import Python modules, check if the tests classes + are actually defined in their respective Python module and instantiate Input instances + with provided value to validate test inputs. + """ + + def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]: + """ + Allow the user to provide a data structure with nested Python modules. + + Example: + ``` + anta.tests.routing: + generic: + - <AntaTestDefinition> + bgp: + - <AntaTestDefinition> + ``` + `anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules. + """ + modules: dict[ModuleType, list[Any]] = {} + for module_name, tests in data.items(): + if package and not module_name.startswith("."): + module_name = f".{module_name}" + try: + module: ModuleType = importlib.import_module(name=module_name, package=package) + except Exception as e: # pylint: disable=broad-exception-caught + # A test module is potentially user-defined code. + # We need to catch everything if we want to have meaningful logs + module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}" + message = f"Module named {module_str} cannot be imported. Verify that the module exists and there is no Python syntax issues." + anta_log_exception(e, message, logger) + raise ValueError(message) from e + if isinstance(tests, dict): + # This is an inner Python module + modules.update(flatten_modules(data=tests, package=module.__name__)) + else: + if not isinstance(tests, list): + raise ValueError(f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog.") + # This is a list of AntaTestDefinition + modules[module] = tests + return modules + + if isinstance(data, dict): + typed_data: dict[ModuleType, list[Any]] = flatten_modules(data) + for module, tests in typed_data.items(): + test_definitions: list[AntaTestDefinition] = [] + for test_definition in tests: + if not isinstance(test_definition, dict): + raise ValueError(f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog.") + if len(test_definition) != 1: + raise ValueError( + f"Syntax error when parsing: {test_definition}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog." + ) + for test_name, test_inputs in test_definition.copy().items(): + test: type[AntaTest] | None = getattr(module, test_name, None) + if test is None: + raise ValueError( + f"{test_name} is not defined in Python module {module.__name__}{f' (from {module.__file__})' if module.__file__ is not None else ''}" + ) + test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs)) + typed_data[module] = test_definitions + return typed_data + + +class AntaCatalog: + """ + Class representing an ANTA Catalog. + + It can be instantiated using its contructor or one of the static methods: `parse()`, `from_list()` or `from_dict()` + """ + + def __init__(self, tests: list[AntaTestDefinition] | None = None, filename: str | Path | None = None) -> None: + """ + Constructor of AntaCatalog. + + Args: + tests: A list of AntaTestDefinition instances. + filename: The path from which the catalog is loaded. + """ + self._tests: list[AntaTestDefinition] = [] + if tests is not None: + self._tests = tests + self._filename: Path | None = None + if filename is not None: + if isinstance(filename, Path): + self._filename = filename + else: + self._filename = Path(filename) + + @property + def filename(self) -> Path | None: + """Path of the file used to create this AntaCatalog instance""" + return self._filename + + @property + def tests(self) -> list[AntaTestDefinition]: + """List of AntaTestDefinition in this catalog""" + return self._tests + + @tests.setter + def tests(self, value: list[AntaTestDefinition]) -> None: + if not isinstance(value, list): + raise ValueError("The catalog must contain a list of tests") + for t in value: + if not isinstance(t, AntaTestDefinition): + raise ValueError("A test in the catalog must be an AntaTestDefinition instance") + self._tests = value + + @staticmethod + def parse(filename: str | Path) -> AntaCatalog: + """ + Create an AntaCatalog instance from a test catalog file. + + Args: + filename: Path to test catalog YAML file + """ + try: + with open(file=filename, mode="r", encoding="UTF-8") as file: + data = safe_load(file) + except (TypeError, YAMLError, OSError) as e: + message = f"Unable to parse ANTA Test Catalog file '{filename}'" + anta_log_exception(e, message, logger) + raise + + return AntaCatalog.from_dict(data, filename=filename) + + @staticmethod + def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> AntaCatalog: + """ + Create an AntaCatalog instance from a dictionary data structure. + See RawCatalogInput type alias for details. + It is the data structure returned by `yaml.load()` function of a valid + YAML Test Catalog file. + + Args: + data: Python dictionary used to instantiate the AntaCatalog instance + filename: value to be set as AntaCatalog instance attribute + """ + tests: list[AntaTestDefinition] = [] + if data is None: + logger.warning("Catalog input data is empty") + return AntaCatalog(filename=filename) + + if not isinstance(data, dict): + raise ValueError(f"Wrong input type for catalog data{f' (from {filename})' if filename is not None else ''}, must be a dict, got {type(data).__name__}") + + try: + catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type] + except ValidationError as e: + anta_log_exception(e, f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}", logger) + raise + for t in catalog_data.root.values(): + tests.extend(t) + return AntaCatalog(tests, filename=filename) + + @staticmethod + def from_list(data: ListAntaTestTuples) -> AntaCatalog: + """ + Create an AntaCatalog instance from a list data structure. + See ListAntaTestTuples type alias for details. + + Args: + data: Python list used to instantiate the AntaCatalog instance + """ + tests: list[AntaTestDefinition] = [] + try: + tests.extend(AntaTestDefinition(test=test, inputs=inputs) for test, inputs in data) + except ValidationError as e: + anta_log_exception(e, "Test catalog is invalid!", logger) + raise + return AntaCatalog(tests) + + def get_tests_by_tags(self, tags: list[str], strict: bool = False) -> list[AntaTestDefinition]: + """ + Return all the tests that have matching tags in their input filters. + If strict=True, returns only tests that match all the tags provided as input. + If strict=False, return all the tests that match at least one tag provided as input. + """ + result: list[AntaTestDefinition] = [] + for test in self.tests: + if test.inputs.filters and (f := test.inputs.filters.tags): + if (strict and all(t in tags for t in f)) or (not strict and any(t in tags for t in f)): + result.append(test) + return result diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py new file mode 100644 index 0000000..3eecad0 --- /dev/null +++ b/anta/cli/__init__.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +ANTA CLI +""" +from __future__ import annotations + +import logging +import pathlib +import sys + +import click + +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=AliasedGroup) +@click.pass_context +@click.version_option(__version__) +@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.", + show_envvar=True, + type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path), +) +@click.option( + "--log-level", + "-l", + help="ANTA logging level", + default=logging.getLevelName(logging.INFO), + show_envvar=True, + show_default=True, + type=click.Choice( + [Log.CRITICAL, Log.ERROR, Log.WARNING, Log.INFO, Log.DEBUG], + case_sensitive=False, + ), +) +def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None: + """Arista Network Test Automation (ANTA) CLI""" + ctx.ensure_object(dict) + setup_logging(log_level, log_file) + + +anta.add_command(nrfu_command) +anta.add_command(check_command) +anta.add_command(exec_command) +anta.add_command(get_command) +anta.add_command(debug_command) + + +def cli() -> None: + """Entrypoint for pyproject.toml""" + 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__": + cli() diff --git a/anta/cli/check/__init__.py b/anta/cli/check/__init__.py new file mode 100644 index 0000000..aec80aa --- /dev/null +++ b/anta/cli/check/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +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 new file mode 100644 index 0000000..8208d64 --- /dev/null +++ b/anta/cli/check/commands.py @@ -0,0 +1,29 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# pylint: disable = redefined-outer-name +""" +Click commands to validate configuration files +""" +from __future__ import annotations + +import logging + +import click +from rich.pretty import pretty_repr + +from anta.catalog import AntaCatalog +from anta.cli.console import console +from anta.cli.utils import catalog_options + +logger = logging.getLogger(__name__) + + +@click.command +@catalog_options +def catalog(catalog: AntaCatalog) -> None: + """ + Check that the catalog 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 new file mode 100644 index 0000000..cf4e6fb --- /dev/null +++ b/anta/cli/console.py @@ -0,0 +1,14 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +ANTA Top-level Console +https://rich.readthedocs.io/en/stable/console.html#console-api +""" + +from rich.console import Console +from rich.theme import Theme + +from anta import RICH_COLOR_THEME + +console = Console(theme=Theme(RICH_COLOR_THEME)) diff --git a/anta/cli/debug/__init__.py b/anta/cli/debug/__init__.py new file mode 100644 index 0000000..6c4dbfb --- /dev/null +++ b/anta/cli/debug/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +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 new file mode 100644 index 0000000..7fffc2e --- /dev/null +++ b/anta/cli/debug/commands.py @@ -0,0 +1,75 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# pylint: disable = redefined-outer-name +""" +Click commands to execute EOS commands on remote devices +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Literal + +import click + +from anta.cli.console import console +from anta.cli.debug.utils import debug_options +from anta.cli.utils import ExitCode +from anta.device import AntaDevice +from anta.models import AntaCommand, AntaTemplate + +logger = logging.getLogger(__name__) + + +@click.command +@debug_options +@click.pass_context +@click.option("--command", "-c", type=str, required=True, help="Command to run") +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 + v: Literal[1, "latest"] = version if version == "latest" else 1 + c = AntaCommand(command=command, ofmt=ofmt, version=v, revision=revision) + asyncio.run(device.collect(c)) + if not c.collected: + console.print(f"[bold red] Command '{c.command}' failed to execute!") + ctx.exit(ExitCode.USAGE_ERROR) + elif ofmt == "json": + console.print(c.json_output) + elif ofmt == "text": + console.print(c.text_output) + + +@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.argument("params", required=True, nargs=-1) +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. + + Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters. + Example: + + anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1 + """ + template_params = dict(zip(params[::2], params[1::2])) + + console.print(f"Run templated command [blue]'{template}'[/blue] with [orange]{template_params}[/orange] on [red]{device.name}[/red]") + # I do not assume the following line, but click make me do it + v: Literal[1, "latest"] = version if version == "latest" else 1 + t = AntaTemplate(template=template, ofmt=ofmt, version=v, revision=revision) + c = t.render(**template_params) # type: ignore + asyncio.run(device.collect(c)) + if not c.collected: + console.print(f"[bold red] Command '{c.command}' failed to execute!") + ctx.exit(ExitCode.USAGE_ERROR) + elif ofmt == "json": + console.print(c.json_output) + elif ofmt == "text": + console.print(c.text_output) diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py new file mode 100644 index 0000000..cc2193d --- /dev/null +++ b/anta/cli/debug/utils.py @@ -0,0 +1,41 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Utils 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 new file mode 100644 index 0000000..6be3934 --- /dev/null +++ b/anta/cli/exec/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +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 new file mode 100644 index 0000000..8b80d19 --- /dev/null +++ b/anta/cli/exec/commands.py @@ -0,0 +1,78 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Click commands to execute various scripts on EOS devices +""" +from __future__ import annotations + +import asyncio +import logging +import sys +from datetime import datetime +from pathlib import Path + +import click +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 inventory_options +from anta.inventory import AntaInventory + +logger = logging.getLogger(__name__) + + +@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(inventory, tags=tags)) + + +@click.command() +@inventory_options +@click.option( + "--commands-list", + "-c", + help="File with list of commands to collect", + required=True, + show_envvar=True, + type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), +) +@click.option( + "--output", + "-o", + show_envvar=True, + type=click.Path(file_okay=False, dir_okay=True, exists=False, writable=True, path_type=Path), + help="Directory to save commands output.", + default=f"anta_snapshot_{datetime.now().strftime('%Y-%m-%d_%H_%M_%S')}", + show_default=True, +) +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}") + try: + with open(commands_list, "r", encoding="UTF-8") as file: + file_content = file.read() + eos_commands = safe_load(file_content) + except FileNotFoundError: + logger.error(f"Error reading {commands_list}") + sys.exit(1) + asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags)) + + +@click.command() +@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( + "--configure", + help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.", + default=False, + is_flag=True, + show_default=True, +) +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(inventory, output, configure, tags=tags, latest=latest)) diff --git a/anta/cli/exec/utils.py b/anta/cli/exec/utils.py new file mode 100644 index 0000000..681db17 --- /dev/null +++ b/anta/cli/exec/utils.py @@ -0,0 +1,161 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. + +""" +Exec CLI helpers +""" +from __future__ import annotations + +import asyncio +import itertools +import json +import logging +import re +from pathlib import Path +from typing import Literal + +from aioeapi import EapiCommandError +from httpx import ConnectError, HTTPError + +from anta.device import AntaDevice, AsyncEOSDevice +from anta.inventory import AntaInventory +from anta.models import AntaCommand + +EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support" +INVALID_CHAR = "`~!@#$/" +logger = logging.getLogger(__name__) + + +async def clear_counters_utils(anta_inventory: AntaInventory, tags: list[str] | None = None) -> None: + """ + Clear counters + """ + + async def clear(dev: AntaDevice) -> None: + commands = [AntaCommand(command="clear counters")] + if dev.hw_model not in ["cEOSLab", "vEOS-lab"]: + commands.append(AntaCommand(command="clear hardware counter drop")) + await dev.collect_commands(commands=commands) + for command in commands: + if not command.collected: + logger.error(f"Could not clear counters on device {dev.name}: {command.errors}") + logger.info(f"Cleared counters on {dev.name} ({dev.hw_model})") + + logger.info("Connecting to devices...") + await anta_inventory.connect_inventory() + devices = anta_inventory.get_inventory(established_only=True, tags=tags).values() + logger.info("Clearing counters on remote devices...") + await asyncio.gather(*(clear(device) for device in devices)) + + +async def collect_commands( + inv: AntaInventory, + commands: dict[str, str], + root_dir: Path, + tags: list[str] | None = None, +) -> None: + """ + Collect EOS commands + """ + + async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None: + outdir = Path() / root_dir / dev.name / outformat + outdir.mkdir(parents=True, exist_ok=True) + safe_command = re.sub(r"(/|\|$)", "_", command) + c = AntaCommand(command=command, ofmt=outformat) + await dev.collect(c) + if not c.collected: + logger.error(f"Could not collect commands on device {dev.name}: {c.errors}") + return + if c.ofmt == "json": + outfile = outdir / f"{safe_command}.json" + content = json.dumps(c.json_output, indent=2) + elif c.ofmt == "text": + outfile = outdir / f"{safe_command}.log" + content = c.text_output + with outfile.open(mode="w", encoding="UTF-8") as f: + f.write(content) + logger.info(f"Collected command '{command}' from device {dev.name} ({dev.hw_model})") + + logger.info("Connecting to devices...") + await inv.connect_inventory() + devices = inv.get_inventory(established_only=True, tags=tags).values() + logger.info("Collecting commands from remote devices") + coros = [] + if "json_format" in commands: + coros += [collect(device, command, "json") for device, command in itertools.product(devices, commands["json_format"])] + if "text_format" in commands: + coros += [collect(device, command, "text") for device, command in itertools.product(devices, commands["text_format"])] + res = await asyncio.gather(*coros, return_exceptions=True) + for r in res: + if isinstance(r, Exception): + logger.error(f"Error when collecting commands: {str(r)}") + + +async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, configure: bool, tags: list[str] | None = None, latest: int | None = None) -> None: + """ + Collect scheduled show-tech on devices + """ + + async def collect(device: AntaDevice) -> None: + """ + Collect all the tech-support files stored on Arista switches flash and copy them locally + """ + try: + # Get the tech-support filename to retrieve + cmd = f"bash timeout 10 ls -1t {EOS_SCHEDULED_TECH_SUPPORT}" + if latest: + cmd += f" | head -{latest}" + command = AntaCommand(command=cmd, ofmt="text") + await device.collect(command=command) + if command.collected and command.text_output: + filenames = list(map(lambda f: Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}"), command.text_output.splitlines())) + else: + logger.error(f"Unable to get tech-support filenames on {device.name}: verify that {EOS_SCHEDULED_TECH_SUPPORT} is not empty") + return + + # Create directories + outdir = Path() / root_dir / f"{device.name.lower()}" + outdir.mkdir(parents=True, exist_ok=True) + + # Check if 'aaa authorization exec default local' is present in the running-config + command = AntaCommand(command="show running-config | include aaa authorization exec default", ofmt="text") + await device.collect(command=command) + + if command.collected and not command.text_output: + logger.debug(f"'aaa authorization exec default local' is not configured on device {device.name}") + if configure: + # Otherwise mypy complains about enable + assert isinstance(device, AsyncEOSDevice) + # TODO - @mtache - add `config` field to `AntaCommand` object to handle this use case. + commands = [] + if device.enable and device._enable_password is not None: # pylint: disable=protected-access + commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access + elif device.enable: + commands.append({"cmd": "enable"}) + commands.extend( + [ + {"cmd": "configure terminal"}, + {"cmd": "aaa authorization exec default local"}, + ] + ) + logger.warning(f"Configuring 'aaa authorization exec default local' on device {device.name}") + command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text") + await device._session.cli(commands=commands) # pylint: disable=protected-access + logger.info(f"Configured 'aaa authorization exec default local' on device {device.name}") + else: + logger.error(f"Unable to collect tech-support on {device.name}: configuration 'aaa authorization exec default local' is not present") + return + logger.debug(f"'aaa authorization exec default local' is already configured on device {device.name}") + + await device.copy(sources=filenames, destination=outdir, direction="from") + logger.info(f"Collected {len(filenames)} scheduled tech-support from {device.name}") + + except (EapiCommandError, HTTPError, ConnectError) as e: + logger.error(f"Unable to collect tech-support on {device.name}: {str(e)}") + + logger.info("Connecting to devices...") + await inv.connect_inventory() + devices = inv.get_inventory(established_only=True, tags=tags).values() + await asyncio.gather(*(collect(device) for device in devices)) diff --git a/anta/cli/get/__init__.py b/anta/cli/get/__init__.py new file mode 100644 index 0000000..d822008 --- /dev/null +++ b/anta/cli/get/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +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 new file mode 100644 index 0000000..b0fe76f --- /dev/null +++ b/anta/cli/get/commands.py @@ -0,0 +1,115 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# pylint: disable = redefined-outer-name +""" +Click commands to get information from or generate inventories +""" +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path + +import click +from cvprac.cvp_client import CvpClient +from cvprac.cvp_client_errors import CvpApiError +from rich.pretty import pretty_repr + +from anta.cli.console import console +from anta.cli.get.utils import inventory_output_options +from anta.cli.utils import ExitCode, inventory_options +from anta.inventory import AntaInventory + +from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token + +logger = logging.getLogger(__name__) + + +@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 + """ + 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() + try: + clnt.connect(nodes=[host], username="", password="", api_token=token) + except CvpApiError as error: + 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 container is None: + # Get a list of all devices + 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 {container} from CloudVision instance '{host}'") + cvp_inventory = clnt.api.get_devices_in_container(container) + create_inventory_from_cvp(cvp_inventory, output) + + +@click.command +@click.pass_context +@inventory_output_options +@click.option("--ansible-group", "-g", help="Ansible group to filter", type=str, default="all") +@click.option( + "--ansible-inventory", + help="Path to your ansible inventory file to read", + type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), + required=True, +) +def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None: + """Build ANTA inventory from an ansible inventory YAML file""" + logger.info(f"Building inventory from ansible file '{ansible_inventory}'") + try: + create_inventory_from_ansible( + inventory=ansible_inventory, + output=output, + ansible_group=ansible_group, + ) + except ValueError as e: + logger.error(str(e)) + ctx.exit(ExitCode.USAGE_ERROR) + + +@click.command +@inventory_options +@click.option("--connected/--not-connected", help="Display inventory after connection has been created", default=False, required=False) +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(inventory.connect_inventory()) + + inventory_result = inventory.get_inventory(tags=tags) + console.print(pretty_repr(inventory_result)) + + +@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 inventory.values(): + tags_found += device.tags + tags_found = sorted(set(tags_found)) + console.print("Tags found:") + console.print_json(json.dumps(tags_found, indent=2)) diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py new file mode 100644 index 0000000..179da0c --- /dev/null +++ b/anta/cli/get/utils.py @@ -0,0 +1,153 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Utils functions to use with anta.cli.get.commands module. +""" +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 + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +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 + + # use CVP REST API to generate a token + URL = f"https://{cvp_ip}/cvpservice/login/authenticate.do" + payload = json.dumps({"userId": cvp_username, "password": cvp_password}) + headers = {"Content-Type": "application/json", "Accept": "application/json"} + + response = requests.request("POST", URL, headers=headers, data=payload, verify=False, timeout=10) + return response.json()["sessionId"] + + +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 inventory + """ + logger.debug(f"Received {len(inv)} device(s) from CloudVision") + hosts = [] + for dev in inv: + logger.info(f" * adding entry for {dev['hostname']}") + hosts.append(AntaInventoryHost(name=dev["hostname"], host=dev["ipAddress"], tags=[dev["containerName"].lower()])) + write_inventory_to_file(hosts, output) + + +def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None: + """ + Create an ANTA inventory from an Ansible inventory YAML file + + Args: + inventory: Ansible Inventory file to read + 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: + for k, v in data.items(): + if isinstance(v, dict): + if k == group and ("children" in v.keys() or "hosts" in v.keys()): + return v + d = find_ansible_group(v, group) + if d is not None: + return d + return None + + def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | None = None) -> list[AntaInventoryHost]: + """Deep parsing of YAML file to extract hosts and associated IPs""" + if hosts is None: + hosts = [] + for key, value in data.items(): + if isinstance(value, dict) and "ansible_host" in value.keys(): + logger.info(f" * adding entry for {key}") + hosts.append(AntaInventoryHost(name=key, host=value["ansible_host"])) + elif isinstance(value, dict): + deep_yaml_parsing(value, hosts) + else: + return hosts + return hosts + + try: + with open(inventory, encoding="utf-8") as inv: + ansible_inventory = yaml.safe_load(inv) + except OSError as exc: + raise ValueError(f"Could not parse {inventory}.") from exc + + if not ansible_inventory: + raise ValueError(f"Ansible inventory {inventory} is empty") + + ansible_inventory = find_ansible_group(ansible_inventory, ansible_group) + + if ansible_inventory is None: + raise ValueError(f"Group {ansible_group} not found in Ansible inventory") + ansible_hosts = deep_yaml_parsing(ansible_inventory) + write_inventory_to_file(ansible_hosts, output) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py new file mode 100644 index 0000000..2297bc2 --- /dev/null +++ b/anta/cli/nrfu/__init__.py @@ -0,0 +1,81 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +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 new file mode 100644 index 0000000..a0acfc9 --- /dev/null +++ b/anta/cli/nrfu/commands.py @@ -0,0 +1,81 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Click commands that render ANTA tests results +""" +from __future__ import annotations + +import logging +import pathlib + +import click + +from anta.cli.utils import exit_with_code + +from .utils import print_jinja, print_json, print_table, print_text + +logger = logging.getLogger(__name__) + + +@click.command() +@click.pass_context +@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, device: str | None, test: str | None, group_by: str) -> None: + """ANTA command to check network states with table result""" + 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( + "--output", + "-o", + type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path), + show_envvar=True, + required=False, + help="Path to save report as a file", +) +def json(ctx: click.Context, output: pathlib.Path | None) -> None: + """ANTA command to check network state with JSON result""" + print_json(results=ctx.obj["result_manager"], output=output) + exit_with_code(ctx) + + +@click.command() +@click.pass_context +@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, search: str | None, skip_error: bool) -> None: + """ANTA command to check network states with text result""" + print_text(results=ctx.obj["result_manager"], search=search, skip_error=skip_error) + exit_with_code(ctx) + + +@click.command() +@click.pass_context +@click.option( + "--template", + "-tpl", + type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=pathlib.Path), + show_envvar=True, + required=True, + help="Path to the template to use for the report", +) +@click.option( + "--output", + "-o", + type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path), + show_envvar=True, + required=False, + help="Path to save report as a file", +) +def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None: + """ANTA command to check network state with templated report""" + 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 new file mode 100644 index 0000000..87b89cf --- /dev/null +++ b/anta/cli/nrfu/utils.py @@ -0,0 +1,134 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Utils functions to use with anta.cli.nrfu.commands module. +""" +from __future__ import annotations + +import json +import logging +import pathlib +import re + +import rich +from rich.panel import Panel +from rich.pretty import pprint +from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn + +from anta.catalog import AntaCatalog +from anta.cli.console import console +from anta.inventory import AntaInventory +from anta.reporter import ReportJinja, ReportTable +from anta.result_manager import ResultManager + +logger = logging.getLogger(__name__) + + +def print_settings( + inventory: AntaInventory, + catalog: AntaCatalog, +) -> None: + """Print ANTA settings before running tests""" + message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests" + console.print(Panel.fit(message, style="cyan", title="[green]Settings")) + console.print() + + +def print_table(results: ResultManager, device: str | None = None, test: str | None = None, group_by: str | None = None) -> None: + """Print result in a table""" + reporter = ReportTable() + console.print() + if device: + console.print(reporter.report_all(result_manager=results, host=device)) + elif test: + console.print(reporter.report_all(result_manager=results, testcase=test)) + elif group_by == "device": + console.print(reporter.report_summary_hosts(result_manager=results, host=None)) + elif group_by == "test": + console.print(reporter.report_summary_tests(result_manager=results, testcase=None)) + else: + console.print(reporter.report_all(result_manager=results)) + + +def print_json(results: ResultManager, output: pathlib.Path | None = None) -> None: + """Print result in a json format""" + console.print() + console.print(Panel("JSON results of all tests", style="cyan")) + rich.print_json(results.get_json_results()) + if output is not None: + with open(output, "w", encoding="utf-8") as fout: + fout.write(results.get_json_results()) + + +def print_list(results: ResultManager, output: pathlib.Path | None = None) -> None: + """Print result in a list""" + console.print() + console.print(Panel.fit("List results of all tests", style="cyan")) + pprint(results.get_results()) + if output is not None: + with open(output, "w", encoding="utf-8") as fout: + fout.write(str(results.get_results())) + + +def print_text(results: ResultManager, search: str | None = None, skip_error: bool = False) -> None: + """Print results as simple text""" + console.print() + regexp = re.compile(search or ".*") + for line in results.get_results(): + if any(regexp.match(entry) for entry in [line.name, line.test]) and (not skip_error or line.result != "error"): + message = f" ({str(line.messages[0])})" if len(line.messages) > 0 else "" + console.print(f"{line.name} :: {line.test} :: [{line.result}]{line.result.upper()}[/{line.result}]{message}", highlight=False) + + +def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None: + """Print result based on template.""" + console.print() + reporter = ReportJinja(template_path=template) + json_data = json.loads(results.get_json_results()) + report = reporter.render(json_data) + console.print(report) + if output is not None: + with open(output, "w", encoding="utf-8") as file: + file.write(report) + + +# Adding our own ANTA spinner - overriding rich SPINNERS for our own +# so ignore warning for redefinition +rich.spinner.SPINNERS = { # type: ignore[attr-defined] # noqa: F811 + "anta": { + "interval": 150, + "frames": [ + "( 🐜)", + "( 🐜 )", + "( 🐜 )", + "( 🐜 )", + "( 🐜 )", + "(🐜 )", + "(🐌 )", + "( 🐌 )", + "( 🐌 )", + "( 🐌 )", + "( 🐌 )", + "( 🐌)", + ], + } +} + + +def anta_progress_bar() -> Progress: + """ + Return a customized Progress for progress bar + """ + return Progress( + SpinnerColumn("anta"), + TextColumn("•"), + TextColumn("{task.description}[progress.percentage]{task.percentage:>3.0f}%"), + BarColumn(bar_width=None), + MofNCompleteColumn(), + TextColumn("•"), + TimeElapsedColumn(), + TextColumn("•"), + TimeRemainingColumn(), + expand=True, + ) diff --git a/anta/cli/utils.py b/anta/cli/utils.py new file mode 100644 index 0000000..97a0862 --- /dev/null +++ b/anta/cli/utils.py @@ -0,0 +1,274 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Utils 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 + +import click +from pydantic import ValidationError +from yaml import YAMLError + +from anta.catalog import AntaCatalog +from anta.inventory import AntaInventory +from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError + +if TYPE_CHECKING: + from click import Option + +logger = logging.getLogger(__name__) + + +class ExitCode(enum.IntEnum): + """ + Encodes the valid exit codes by anta + inspired from pytest + """ + + # Tests passed. + OK = 0 + # An internal error got in the way. + 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: + # pylint: disable=unused-argument + """ + Click option callback to parse an ANTA inventory tags + """ + if value is not None: + return value.split(",") if "," in value else [value] + return None + + +def exit_with_code(ctx: click.Context) -> None: + """ + Exit the Click application with an exit code. + This function determines the global test status to be either `unset`, `skipped`, `success` or `error` + from the `ResultManger` instance. + If flag `ignore_error` is set, the `error` status will be ignored in all the tests. + If flag `ignore_status` is set, the exit code will always be 0. + Exit the application with the following exit code: + * 0 if `ignore_status` is `True` or global test status is `unset`, `skipped` or `success` + * 1 if status is `failure` + * 2 if status is `error` + + Args: + ctx: Click Context + """ + 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.obj.get("ignore_error"))) + + if status in {"unset", "skipped", "success"}: + ctx.exit(ExitCode.OK) + if status == "failure": + ctx.exit(ExitCode.TESTS_FAILED) + if status == "error": + ctx.exit(ExitCode.TESTS_ERROR) + + logger.error("Please gather logs and open an issue on Github.") + raise ValueError(f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github.") + + +class AliasedGroup(click.Group): + """ + Implements a subclass of Group that accepts a prefix for a command. + If there were a command called push, it would accept pus as an alias (so long as it was unique) + From Click documentation + """ + + def get_command(self, ctx: click.Context, cmd_name: str) -> Any: + """Todo: document code""" + rv = click.Group.get_command(self, ctx, cmd_name) + if rv is not None: + return rv + matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] + if not matches: + return None + if len(matches) == 1: + return click.Group.get_command(self, ctx, matches[0]) + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") + return None + + def resolve_command(self, ctx: click.Context, args: Any) -> Any: + """Todo: document code""" + # always return the full command name + _, cmd, args = super().resolve_command(ctx, args) + return cmd.name, cmd, args # type: ignore + + +# 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/custom_types.py b/anta/custom_types.py new file mode 100644 index 0000000..d0b9e4b --- /dev/null +++ b/anta/custom_types.py @@ -0,0 +1,122 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Module that provides predefined types for AntaTest.Input instances +""" +import re +from typing import Literal + +from pydantic import Field +from pydantic.functional_validators import AfterValidator, BeforeValidator +from typing_extensions import Annotated + + +def aaa_group_prefix(v: str) -> str: + """Prefix the AAA method with 'group' if it is known""" + built_in_methods = ["local", "none", "logging"] + return f"group {v}" if v not in built_in_methods and not v.startswith("group ") else v + + +def interface_autocomplete(v: str) -> str: + """Allow the user to only provide the beginning of an interface name. + + Supported alias: + - `et`, `eth` will be changed to `Ethernet` + - `po` will be changed to `Port-Channel` + - `lo` will be changed to `Loopback`""" + intf_id_re = re.compile(r"[0-9]+(\/[0-9]+)*(\.[0-9]+)?") + m = intf_id_re.search(v) + if m is None: + raise ValueError(f"Could not parse interface ID in interface '{v}'") + intf_id = m[0] + + alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback"} + + for alias, full_name in alias_map.items(): + if v.lower().startswith(alias): + return f"{full_name}{intf_id}" + + return v + + +def interface_case_sensitivity(v: str) -> str: + """Reformat interface name to match expected case sensitivity. + + Examples: + - ethernet -> Ethernet + - vlan -> Vlan + - loopback -> Loopback + """ + if isinstance(v, str) and len(v) > 0 and not v[0].isupper(): + return f"{v[0].upper()}{v[1:]}" + return v + + +def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str: + """ + Abbreviations for different BGP multiprotocol capabilities. + Examples: + - IPv4 Unicast + - L2vpnEVPN + - ipv4 MPLS Labels + - ipv4Mplsvpn + """ + patterns = { + r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn", + r"\bipv4[\s_-]?mpls[\s_-]?label(s)?\b": "ipv4MplsLabels", + r"\bipv4[\s_-]?mpls[\s_-]?vpn\b": "ipv4MplsVpn", + r"\bipv4[\s_-]?uni[\s_-]?cast\b": "ipv4Unicast", + } + + for pattern, replacement in patterns.items(): + match = re.search(pattern, value, re.IGNORECASE) + if match: + return replacement + + return value + + +# ANTA framework +TestStatus = Literal["unset", "success", "failure", "error", "skipped"] + +# AntaTest.Input types +AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)] +Vlan = Annotated[int, Field(ge=0, le=4094)] +MlagPriority = Annotated[int, Field(ge=1, le=32767)] +Vni = Annotated[int, Field(ge=1, le=16777215)] +Interface = Annotated[ + str, + Field(pattern=r"^(Dps|Ethernet|Fabric|Loopback|Management|Port-Channel|Tunnel|Vlan|Vxlan)[0-9]+(\/[0-9]+)*(\.[0-9]+)?$"), + BeforeValidator(interface_autocomplete), + BeforeValidator(interface_case_sensitivity), +] +VxlanSrcIntf = Annotated[ + str, + Field(pattern=r"^(Loopback)([0-9]|[1-9][0-9]{1,2}|[1-7][0-9]{3}|8[01][0-9]{2}|819[01])$"), + BeforeValidator(interface_autocomplete), + BeforeValidator(interface_case_sensitivity), +] +Afi = Literal["ipv4", "ipv6", "vpn-ipv4", "vpn-ipv6", "evpn", "rt-membership"] +Safi = Literal["unicast", "multicast", "labeled-unicast"] +EncryptionAlgorithm = Literal["RSA", "ECDSA"] +RsaKeySize = Literal[2048, 3072, 4096] +EcdsaKeySize = Literal[256, 384, 521] +MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)] +BfdInterval = Annotated[int, Field(ge=50, le=60000)] +BfdMultiplier = Annotated[int, Field(ge=3, le=50)] +ErrDisableReasons = Literal[ + "acl", + "arp-inspection", + "bpduguard", + "dot1x-session-replace", + "hitless-reload-down", + "lacp-rate-limit", + "link-flap", + "no-internal-vlan", + "portchannelguard", + "portsec", + "tapagg", + "uplink-failure-detection", +] +ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)] diff --git a/anta/decorators.py b/anta/decorators.py new file mode 100644 index 0000000..548f04a --- /dev/null +++ b/anta/decorators.py @@ -0,0 +1,104 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""decorators for tests.""" +from __future__ import annotations + +from functools import wraps +from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast + +from anta.models import AntaTest, logger + +if TYPE_CHECKING: + from anta.result_manager.models import TestResult + +# TODO - should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc +F = TypeVar("F", bound=Callable[..., Any]) + + +def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]: + """ + Return a decorator to log a message of WARNING severity when a test is deprecated. + + Args: + new_tests (Optional[list[str]]): A list of new test classes that should replace the deprecated test. + + Returns: + Callable[[F], F]: A decorator that can be used to wrap test functions. + """ + + def decorator(function: F) -> F: + """ + Actual decorator that logs the message. + + Args: + function (F): The test function to be decorated. + + Returns: + F: The decorated function. + """ + + @wraps(function) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + anta_test = args[0] + if new_tests: + new_test_names = ", ".join(new_tests) + logger.warning(f"{anta_test.name} test is deprecated. Consider using the following new tests: {new_test_names}.") + else: + logger.warning(f"{anta_test.name} test is deprecated.") + return await function(*args, **kwargs) + + return cast(F, wrapper) + + return decorator + + +def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]: + """ + Return a decorator to skip a test based on the device's hardware model. + + This decorator factory generates a decorator that will check the hardware model of the device + the test is run on. If the model is in the list of platforms specified, the test will be skipped. + + Args: + platforms (list[str]): List of hardware models on which the test should be skipped. + + Returns: + Callable[[F], F]: A decorator that can be used to wrap test functions. + """ + + def decorator(function: F) -> F: + """ + Actual decorator that either runs the test or skips it based on the device's hardware model. + + Args: + function (F): The test function to be decorated. + + Returns: + F: The decorated function. + """ + + @wraps(function) + async def wrapper(*args: Any, **kwargs: Any) -> TestResult: + """ + Check the device's hardware model and conditionally run or skip the test. + + This wrapper inspects the hardware model of the device the test is run on. + If the model is in the list of specified platforms, the test is either skipped. + """ + anta_test = args[0] + + if anta_test.result.result != "unset": + AntaTest.update_progress() + return anta_test.result + + if anta_test.device.hw_model in platforms: + anta_test.result.is_skipped(f"{anta_test.__class__.__name__} test is not supported on {anta_test.device.hw_model}.") + AntaTest.update_progress() + return anta_test.result + + return await function(*args, **kwargs) + + return cast(F, wrapper) + + return decorator diff --git a/anta/device.py b/anta/device.py new file mode 100644 index 0000000..d9060c9 --- /dev/null +++ b/anta/device.py @@ -0,0 +1,417 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +ANTA Device Abstraction Module +""" +from __future__ import annotations + +import asyncio +import logging +from abc import ABC, abstractmethod +from collections import defaultdict +from pathlib import Path +from typing import Any, Iterator, Literal, Optional, Union + +import asyncssh +from aiocache import Cache +from aiocache.plugins import HitMissRatioPlugin +from asyncssh import SSHClientConnection, SSHClientConnectionOptions +from httpx import ConnectError, HTTPError + +from anta import __DEBUG__, aioeapi +from anta.models import AntaCommand +from anta.tools.misc import exc_to_str + +logger = logging.getLogger(__name__) + + +class AntaDevice(ABC): + """ + Abstract class representing a device in ANTA. + An implementation of this class must override the abstract coroutines `_collect()` and + `refresh()`. + + Attributes: + name: Device name + is_online: True if the device IP is reachable and a port can be open + established: True if remote command execution succeeds + hw_model: Hardware model of the device + tags: List of tags for this device + cache: In-memory cache from aiocache library for this device (None if cache is disabled) + cache_locks: Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled + """ + + def __init__(self, name: str, tags: Optional[list[str]] = None, disable_cache: bool = False) -> None: + """ + Constructor of AntaDevice + + Args: + name: Device name + tags: List of tags for this device + disable_cache: Disable caching for all commands for this device. Defaults to False. + """ + self.name: str = name + self.hw_model: Optional[str] = None + self.tags: list[str] = tags if tags is not None else [] + # A device always has its own name as tag + self.tags.append(self.name) + self.is_online: bool = False + self.established: bool = False + self.cache: Optional[Cache] = None + self.cache_locks: Optional[defaultdict[str, asyncio.Lock]] = None + + # Initialize cache if not disabled + if not disable_cache: + self._init_cache() + + @property + @abstractmethod + def _keys(self) -> tuple[Any, ...]: + """ + Read-only property to implement hashing and equality for AntaDevice classes. + """ + + def __eq__(self, other: object) -> bool: + """ + Implement equality for AntaDevice objects. + """ + return self._keys == other._keys if isinstance(other, self.__class__) else False + + def __hash__(self) -> int: + """ + Implement hashing for AntaDevice objects. + """ + return hash(self._keys) + + def _init_cache(self) -> None: + """ + Initialize cache for the device, can be overriden by subclasses to manipulate how it works + """ + self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()]) + self.cache_locks = defaultdict(asyncio.Lock) + + @property + def cache_statistics(self) -> dict[str, Any] | None: + """ + Returns the device cache statistics for logging purposes + """ + # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough + # https://github.com/pylint-dev/pylint/issues/7258 + if self.cache is not None: + stats = getattr(self.cache, "hit_miss_ratio", {"total": 0, "hits": 0, "hit_ratio": 0}) + return {"total_commands_sent": stats["total"], "cache_hits": stats["hits"], "cache_hit_ratio": f"{stats['hit_ratio'] * 100:.2f}%"} + return None + + def __rich_repr__(self) -> Iterator[tuple[str, Any]]: + """ + Implements Rich Repr Protocol + https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol + """ + yield "name", self.name + yield "tags", self.tags + yield "hw_model", self.hw_model + yield "is_online", self.is_online + yield "established", self.established + yield "disable_cache", self.cache is None + + @abstractmethod + async def _collect(self, command: AntaCommand) -> None: + """ + Collect device command output. + This abstract coroutine can be used to implement any command collection method + for a device in ANTA. + + The `_collect()` implementation needs to populate the `output` attribute + of the `AntaCommand` object passed as argument. + + If a failure occurs, the `_collect()` implementation is expected to catch the + exception and implement proper logging, the `output` attribute of the + `AntaCommand` object passed as argument would be `None` in this case. + + Args: + command: the command to collect + """ + + async def collect(self, command: AntaCommand) -> None: + """ + Collects the output for a specified command. + + When caching is activated on both the device and the command, + this method prioritizes retrieving the output from the cache. In cases where the output isn't cached yet, + it will be freshly collected and then stored in the cache for future access. + The method employs asynchronous locks based on the command's UID to guarantee exclusive access to the cache. + + When caching is NOT enabled, either at the device or command level, the method directly collects the output + via the private `_collect` method without interacting with the cache. + + Args: + command (AntaCommand): The command to process. + """ + # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough + # https://github.com/pylint-dev/pylint/issues/7258 + if self.cache is not None and self.cache_locks is not None and command.use_cache: + async with self.cache_locks[command.uid]: + cached_output = await self.cache.get(command.uid) # pylint: disable=no-member + + if cached_output is not None: + logger.debug(f"Cache hit for {command.command} on {self.name}") + command.output = cached_output + else: + await self._collect(command=command) + await self.cache.set(command.uid, command.output) # pylint: disable=no-member + else: + await self._collect(command=command) + + async def collect_commands(self, commands: list[AntaCommand]) -> None: + """ + Collect multiple commands. + + Args: + commands: the commands to collect + """ + await asyncio.gather(*(self.collect(command=command) for command in commands)) + + def supports(self, command: AntaCommand) -> bool: + """Returns True if the command is supported on the device hardware platform, False otherwise.""" + unsupported = any("not supported on this hardware platform" in e for e in command.errors) + logger.debug(command) + if unsupported: + logger.debug(f"{command.command} is not supported on {self.hw_model}") + return not unsupported + + @abstractmethod + async def refresh(self) -> None: + """ + Update attributes of an AntaDevice instance. + + This coroutine must update the following attributes of AntaDevice: + - `is_online`: When the device IP is reachable and a port can be open + - `established`: When a command execution succeeds + - `hw_model`: The hardware model of the device + """ + + async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: + """ + Copy files to and from the device, usually through SCP. + It is not mandatory to implement this for a valid AntaDevice subclass. + + Args: + sources: List of files to copy to or from the device. + destination: Local or remote destination when copying the files. Can be a folder. + direction: Defines if this coroutine copies files to or from the device. + """ + raise NotImplementedError(f"copy() method has not been implemented in {self.__class__.__name__} definition") + + +class AsyncEOSDevice(AntaDevice): + """ + Implementation of AntaDevice for EOS using aio-eapi. + + Attributes: + name: Device name + is_online: True if the device IP is reachable and a port can be open + established: True if remote command execution succeeds + hw_model: Hardware model of the device + tags: List of tags for this device + """ + + def __init__( # pylint: disable=R0913 + self, + host: str, + username: str, + password: str, + name: Optional[str] = None, + enable: bool = False, + enable_password: Optional[str] = None, + port: Optional[int] = None, + ssh_port: Optional[int] = 22, + tags: Optional[list[str]] = None, + timeout: Optional[float] = None, + insecure: bool = False, + proto: Literal["http", "https"] = "https", + disable_cache: bool = False, + ) -> None: + """ + Constructor of AsyncEOSDevice + + Args: + host: Device FQDN or IP + username: Username to connect to eAPI and SSH + password: Password to connect to eAPI and SSH + name: Device name + enable: Device needs privileged access + enable_password: Password used to gain privileged access on EOS + port: eAPI port. Defaults to 80 is proto is 'http' or 443 if proto is 'https'. + ssh_port: SSH port + tags: List of tags for this device + timeout: Timeout value in seconds for outgoing connections. Default to 10 secs. + insecure: Disable SSH Host Key validation + proto: eAPI protocol. Value can be 'http' or 'https' + disable_cache: Disable caching for all commands for this device. 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) + ssh_params: dict[str, Any] = {} + if insecure: + ssh_params["known_hosts"] = None + self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions(host=host, port=ssh_port, username=username, password=password, **ssh_params) + + def __rich_repr__(self) -> Iterator[tuple[str, Any]]: + """ + Implements Rich Repr Protocol + https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol + """ + yield from super().__rich_repr__() + yield ("host", self._session.host) + yield ("eapi_port", self._session.port) + yield ("username", self._ssh_opts.username) + yield ("enable", self.enable) + yield ("insecure", self._ssh_opts.known_hosts is None) + if __DEBUG__: + _ssh_opts = vars(self._ssh_opts).copy() + PASSWORD_VALUE = "<removed>" + _ssh_opts["password"] = PASSWORD_VALUE + _ssh_opts["kwargs"]["password"] = PASSWORD_VALUE + yield ("_session", vars(self._session)) + yield ("_ssh_opts", _ssh_opts) + + @property + def _keys(self) -> tuple[Any, ...]: + """ + Two AsyncEOSDevice objects are equal if the hostname and the port are the same. + This covers the use case of port forwarding when the host is localhost and the devices have different ports. + """ + return (self._session.host, self._session.port) + + async def _collect(self, command: AntaCommand) -> None: + """ + Collect device command output from EOS using aio-eapi. + + Supports outformat `json` and `text` as output structure. + Gain privileged access using the `enable_password` attribute + of the `AntaDevice` instance if populated. + + Args: + command: the command to collect + """ + commands = [] + if self.enable and self._enable_password is not None: + commands.append( + { + "cmd": "enable", + "input": str(self._enable_password), + } + ) + elif self.enable: + # No password + commands.append({"cmd": "enable"}) + if command.revision: + commands.append({"cmd": command.command, "revision": command.revision}) + else: + commands.append({"cmd": command.command}) + try: + response: list[dict[str, Any]] = await self._session.cli( + commands=commands, + ofmt=command.ofmt, + version=command.version, + ) + except aioeapi.EapiCommandError as e: + command.errors = e.errors + if self.supports(command): + message = f"Command '{command.command}' failed on {self.name}" + logger.error(message) + except (HTTPError, ConnectError) as e: + command.errors = [str(e)] + message = f"Cannot connect to device {self.name}" + logger.error(message) + else: + # selecting only our command output + command.output = response[-1] + logger.debug(f"{self.name}: {command}") + + async def refresh(self) -> None: + """ + Update attributes of an AsyncEOSDevice instance. + + This coroutine must update the following attributes of AsyncEOSDevice: + - is_online: When a device IP is reachable and a port can be open + - established: When a command execution succeeds + - hw_model: The hardware model of the device + """ + logger.debug(f"Refreshing device {self.name}") + self.is_online = await self._session.check_connection() + if self.is_online: + COMMAND: str = "show version" + HW_MODEL_KEY: str = "modelName" + try: + response = await self._session.cli(command=COMMAND) + except aioeapi.EapiCommandError as e: + logger.warning(f"Cannot get hardware information from device {self.name}: {e.errmsg}") + + except (HTTPError, ConnectError) as e: + logger.warning(f"Cannot get hardware information from device {self.name}: {exc_to_str(e)}") + + else: + if HW_MODEL_KEY in response: + self.hw_model = response[HW_MODEL_KEY] + else: + logger.warning(f"Cannot get hardware information from device {self.name}: cannot parse '{COMMAND}'") + + else: + logger.warning(f"Could not connect to device {self.name}: cannot open eAPI port") + + self.established = bool(self.is_online and self.hw_model) + + async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: + """ + Copy files to and from the device using asyncssh.scp(). + + Args: + sources: List of files to copy to or from the device. + destination: Local or remote destination when copying the files. Can be a folder. + direction: Defines if this coroutine copies files to or from the device. + """ + async with asyncssh.connect( + host=self._ssh_opts.host, + port=self._ssh_opts.port, + tunnel=self._ssh_opts.tunnel, + family=self._ssh_opts.family, + local_addr=self._ssh_opts.local_addr, + options=self._ssh_opts, + ) as conn: + src: Union[list[tuple[SSHClientConnection, Path]], list[Path]] + dst: Union[tuple[SSHClientConnection, Path], Path] + if direction == "from": + src = [(conn, file) for file in sources] + dst = destination + for file in sources: + logger.info(f"Copying '{file}' from device {self.name} to '{destination}' locally") + + elif direction == "to": + src = sources + dst = conn, destination + for file in src: + logger.info(f"Copying '{file}' to device {self.name} to '{destination}' remotely") + + else: + logger.critical(f"'direction' argument to copy() fonction is invalid: {direction}") + + return + await asyncssh.scp(src, dst) diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py new file mode 100644 index 0000000..c5327bd --- /dev/null +++ b/anta/inventory/__init__.py @@ -0,0 +1,282 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Inventory Module for ANTA. +""" + +from __future__ import annotations + +import asyncio +import logging +from ipaddress import ip_address, ip_network +from pathlib import Path +from typing import Any, Optional + +from pydantic import ValidationError +from yaml import YAMLError, safe_load + +from anta.device import AntaDevice, AsyncEOSDevice +from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError +from anta.inventory.models import AntaInventoryInput +from anta.logger import anta_log_exception + +logger = logging.getLogger(__name__) + + +class AntaInventory(dict): # type: ignore + # dict[str, AntaDevice] - not working in python 3.8 hence the ignore + """ + Inventory abstraction for ANTA framework. + """ + + # Root key of inventory part of the inventory file + INVENTORY_ROOT_KEY = "anta_inventory" + # Supported Output format + INVENTORY_OUTPUT_FORMAT = ["native", "json"] + + def __str__(self) -> str: + """Human readable string representing the inventory""" + devs = {} + for dev in self.values(): + if (dev_type := dev.__class__.__name__) not in devs: + devs[dev_type] = 1 + else: + devs[dev_type] += 1 + return f"ANTA Inventory contains {' '.join([f'{n} devices ({t})' for t, n in devs.items()])}" + + @staticmethod + def _update_disable_cache(inventory_disable_cache: bool, kwargs: dict[str, Any]) -> dict[str, Any]: + """ + Return new dictionary, replacing kwargs with added disable_cache value from inventory_value + if disable_cache has not been set by CLI. + + Args: + inventory_disable_cache (bool): The value of disable_cache in the inventory + kwargs: The kwargs to instantiate the device + """ + updated_kwargs = kwargs.copy() + updated_kwargs["disable_cache"] = inventory_disable_cache or kwargs.get("disable_cache") + return updated_kwargs + + @staticmethod + def _parse_hosts(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None: + """ + Parses the host section of an AntaInventoryInput and add the devices to the inventory + + Args: + inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices + inventory (AntaInventory): AntaInventory to add the parsed devices to + """ + if inventory_input.hosts is None: + return + + for host in inventory_input.hosts: + updated_kwargs = AntaInventory._update_disable_cache(host.disable_cache, kwargs) + device = AsyncEOSDevice(name=host.name, host=str(host.host), port=host.port, tags=host.tags, **updated_kwargs) + inventory.add_device(device) + + @staticmethod + def _parse_networks(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None: + """ + Parses the network section of an AntaInventoryInput and add the devices to the inventory. + + Args: + inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices + inventory (AntaInventory): AntaInventory to add the parsed devices to + + Raises: + InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema. + """ + if inventory_input.networks is None: + return + + for network in inventory_input.networks: + try: + updated_kwargs = AntaInventory._update_disable_cache(network.disable_cache, kwargs) + for host_ip in ip_network(str(network.network)): + device = AsyncEOSDevice(host=str(host_ip), tags=network.tags, **updated_kwargs) + inventory.add_device(device) + except ValueError as e: + message = "Could not parse network {network.network} in the inventory" + anta_log_exception(e, message, logger) + raise InventoryIncorrectSchema(message) from e + + @staticmethod + def _parse_ranges(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None: + """ + Parses the range section of an AntaInventoryInput and add the devices to the inventory. + + Args: + inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices + inventory (AntaInventory): AntaInventory to add the parsed devices to + + Raises: + InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema. + """ + if inventory_input.ranges is None: + return + + for range_def in inventory_input.ranges: + try: + updated_kwargs = AntaInventory._update_disable_cache(range_def.disable_cache, kwargs) + range_increment = ip_address(str(range_def.start)) + range_stop = ip_address(str(range_def.end)) + while range_increment <= range_stop: # type: ignore[operator] + # mypy raise an issue about comparing IPv4Address and IPv6Address + # but this is handled by the ipaddress module natively by raising a TypeError + device = AsyncEOSDevice(host=str(range_increment), tags=range_def.tags, **updated_kwargs) + inventory.add_device(device) + range_increment += 1 + except ValueError as e: + message = f"Could not parse the following range in the inventory: {range_def.start} - {range_def.end}" + anta_log_exception(e, message, logger) + raise InventoryIncorrectSchema(message) from e + except TypeError as e: + message = f"A range in the inventory has different address families for start and end: {range_def.start} - {range_def.end}" + anta_log_exception(e, message, logger) + raise InventoryIncorrectSchema(message) from e + + @staticmethod + def parse( + filename: str | Path, + username: str, + password: str, + enable: bool = False, + enable_password: Optional[str] = None, + timeout: Optional[float] = None, + insecure: bool = False, + disable_cache: bool = False, + ) -> AntaInventory: + # pylint: disable=too-many-arguments + """ + Create an AntaInventory instance from an inventory file. + The inventory devices are AsyncEOSDevice instances. + + Args: + filename (str): Path to device inventory YAML file + username (str): Username to use to connect to devices + password (str): Password to use to connect to devices + enable (bool): Whether or not the commands need to be run in enable mode towards the devices + enable_password (str, optional): Enable password to use if required + timeout (float, optional): timeout in seconds for every API call. + insecure (bool): Disable SSH Host Key validation + disable_cache (bool): Disable cache globally + + Raises: + InventoryRootKeyError: Root key of inventory is missing. + InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema. + """ + + inventory = AntaInventory() + kwargs: dict[str, Any] = { + "username": username, + "password": password, + "enable": enable, + "enable_password": enable_password, + "timeout": timeout, + "insecure": insecure, + "disable_cache": disable_cache, + } + 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 (TypeError, YAMLError, OSError) as e: + message = f"Unable to parse ANTA Device Inventory file '{filename}'" + anta_log_exception(e, message, logger) + raise + + if AntaInventory.INVENTORY_ROOT_KEY not in data: + exc = InventoryRootKeyError(f"Inventory root key ({AntaInventory.INVENTORY_ROOT_KEY}) is not defined in your inventory") + anta_log_exception(exc, f"Device inventory is invalid! (from {filename})", logger) + raise exc + + try: + inventory_input = AntaInventoryInput(**data[AntaInventory.INVENTORY_ROOT_KEY]) + except ValidationError as e: + anta_log_exception(e, f"Device inventory is invalid! (from {filename})", logger) + raise + + # Read data from input + AntaInventory._parse_hosts(inventory_input, inventory, **kwargs) + AntaInventory._parse_networks(inventory_input, inventory, **kwargs) + AntaInventory._parse_ranges(inventory_input, inventory, **kwargs) + + return inventory + + ########################################################################### + # Public methods + ########################################################################### + + ########################################################################### + # GET methods + ########################################################################### + + def get_inventory(self, established_only: bool = False, tags: Optional[list[str]] = None) -> AntaInventory: + """ + Returns a filtered inventory. + + Args: + established_only: Whether or not to include only established devices. Default False. + tags: List of tags to filter devices. + + Returns: + AntaInventory: An inventory with filtered AntaDevice objects. + """ + + def _filter_devices(device: AntaDevice) -> bool: + """ + Helper function to select the devices based on the input tags + and the requirement for an established connection. + """ + if tags is not None and all(tag not in tags for tag in device.tags): + return False + return bool(not established_only or device.established) + + devices: list[AntaDevice] = list(filter(_filter_devices, self.values())) + result = AntaInventory() + for device in devices: + result.add_device(device) + return result + + ########################################################################### + # SET methods + ########################################################################### + + def __setitem__(self, key: str, value: AntaDevice) -> None: + if key != value.name: + raise RuntimeError(f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device().") + return super().__setitem__(key, value) + + def add_device(self, device: AntaDevice) -> None: + """Add a device to final inventory. + + Args: + device: Device object to be added + """ + self[device.name] = device + + ########################################################################### + # MISC methods + ########################################################################### + + async def connect_inventory(self) -> None: + """Run `refresh()` coroutines for all AntaDevice objects in this inventory.""" + logger.debug("Refreshing devices...") + results = await asyncio.gather( + *(device.refresh() for device in self.values()), + return_exceptions=True, + ) + for r in results: + if isinstance(r, Exception): + message = "Error when refreshing inventory" + anta_log_exception(r, message, logger) diff --git a/anta/inventory/exceptions.py b/anta/inventory/exceptions.py new file mode 100644 index 0000000..dd5f106 --- /dev/null +++ b/anta/inventory/exceptions.py @@ -0,0 +1,12 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Manage Exception in Inventory module.""" + + +class InventoryRootKeyError(Exception): + """Error raised when inventory root key is not found.""" + + +class InventoryIncorrectSchema(Exception): + """Error when user data does not follow ANTA schema.""" diff --git a/anta/inventory/models.py b/anta/inventory/models.py new file mode 100644 index 0000000..94742e4 --- /dev/null +++ b/anta/inventory/models.py @@ -0,0 +1,92 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Models related to inventory management.""" + +from __future__ import annotations + +import logging +from typing import List, Optional, Union + +# Need to keep List for pydantic in python 3.8 +from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork, conint, constr + +logger = logging.getLogger(__name__) + +# Pydantic models for input validation + +RFC_1123_REGEX = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" + + +class AntaInventoryHost(BaseModel): + """ + Host definition for user's inventory. + + Attributes: + host (IPvAnyAddress): IPv4 or IPv6 address of the device + port (int): (Optional) eAPI port to use Default is 443. + name (str): (Optional) Name to display during tests report. Default is hostname:port + tags (list[str]): List of attached tags read from inventory file. + disable_cache (bool): Disable cache per host. Defaults to False. + """ + + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = None + host: Union[constr(pattern=RFC_1123_REGEX), IPvAnyAddress] # type: ignore + port: Optional[conint(gt=1, lt=65535)] = None # type: ignore + tags: Optional[List[str]] = None + disable_cache: bool = False + + +class AntaInventoryNetwork(BaseModel): + """ + Network definition for user's inventory. + + Attributes: + network (IPvAnyNetwork): Subnet to use for testing. + tags (list[str]): List of attached tags read from inventory file. + disable_cache (bool): Disable cache per network. Defaults to False. + """ + + model_config = ConfigDict(extra="forbid") + + network: IPvAnyNetwork + tags: Optional[List[str]] = None + disable_cache: bool = False + + +class AntaInventoryRange(BaseModel): + """ + IP Range definition for user's inventory. + + Attributes: + start (IPvAnyAddress): IPv4 or IPv6 address for the begining of the range. + stop (IPvAnyAddress): IPv4 or IPv6 address for the end of the range. + tags (list[str]): List of attached tags read from inventory file. + disable_cache (bool): Disable cache per range of hosts. Defaults to False. + """ + + model_config = ConfigDict(extra="forbid") + + start: IPvAnyAddress + end: IPvAnyAddress + tags: Optional[List[str]] = None + disable_cache: bool = False + + +class AntaInventoryInput(BaseModel): + """ + User's inventory model. + + Attributes: + networks (list[AntaInventoryNetwork],Optional): List of AntaInventoryNetwork objects for networks. + hosts (list[AntaInventoryHost],Optional): List of AntaInventoryHost objects for hosts. + range (list[AntaInventoryRange],Optional): List of AntaInventoryRange objects for ranges. + """ + + model_config = ConfigDict(extra="forbid") + + networks: Optional[List[AntaInventoryNetwork]] = None + hosts: Optional[List[AntaInventoryHost]] = None + ranges: Optional[List[AntaInventoryRange]] = None diff --git a/anta/logger.py b/anta/logger.py new file mode 100644 index 0000000..d5eeeca --- /dev/null +++ b/anta/logger.py @@ -0,0 +1,107 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Configure logging for ANTA +""" +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__) + + +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: + their logging level is WARNING. + + If logging level DEBUG is selected, all loggers will be configured with this level. + + In ANTA Debug Mode (environment variable `ANTA_DEBUG=true`), Python tracebacks are logged and logging level is + overwritten to be DEBUG. + + If a file is provided, logs will also be sent to the file in addition to stdout. + If a file is provided and logging level is DEBUG, only the logging level INFO and higher will + be logged to stdout while all levels will be logged in the file. + + Args: + level: ANTA logging level + file: Send logs to a file + """ + # Init root logger + root = logging.getLogger() + # In ANTA debug mode, level is overriden to DEBUG + loglevel = logging.DEBUG if __DEBUG__ else getattr(logging, level.upper()) + root.setLevel(loglevel) + # Silence the logging of chatty Python modules when level is INFO + if loglevel == logging.INFO: + # asyncssh is really chatty + logging.getLogger("asyncssh").setLevel(logging.WARNING) + # httpx as well + logging.getLogger("httpx").setLevel(logging.WARNING) + + # Add RichHandler for stdout + 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" + else: + fmt_string = "%(message)s" + formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]") + richHandler.setFormatter(formatter) + root.addHandler(richHandler) + # Add FileHandler if file is provided + if file: + fileHandler = logging.FileHandler(file) + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + fileHandler.setFormatter(formatter) + root.addHandler(fileHandler) + # If level is DEBUG and file is provided, do not send DEBUG level to stdout + if loglevel == logging.DEBUG: + richHandler.setLevel(logging.INFO) + + 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 new file mode 100644 index 0000000..c8acda3 --- /dev/null +++ b/anta/models.py @@ -0,0 +1,541 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Models to define a TestStructure +""" +from __future__ import annotations + +import hashlib +import logging +import re +import time +from abc import ABC, abstractmethod +from copy import deepcopy +from datetime import timedelta +from functools import wraps + +# Need to keep Dict and List for pydantic in python 3.8 +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, Dict, List, Literal, Optional, TypeVar, Union + +from pydantic import BaseModel, ConfigDict, ValidationError, conint +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 exc_to_str + +if TYPE_CHECKING: + from anta.device import AntaDevice + +F = TypeVar("F", bound=Callable[..., Any]) +# Proper way to type input class - revisit this later if we get any issue @gmuloc +# This would imply overhead to define classes +# https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class +# N = TypeVar("N", bound="AntaTest.Input") + + +# TODO - make this configurable - with an env var maybe? +BLACKLIST_REGEX = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"] + +logger = logging.getLogger(__name__) + + +class AntaMissingParamException(Exception): + """ + This Exception should be used when an expected key in an AntaCommand.params dictionary + was not found. + + This Exception should in general never be raised in normal usage of ANTA. + """ + + def __init__(self, message: str) -> None: + self.message = "\n".join([message, GITHUB_SUGGESTION]) + super().__init__(self.message) + + +class AntaTemplate(BaseModel): + """Class to define a command template as Python f-string. + Can render a command from parameters. + + Attributes: + template: Python f-string. Example: 'show vlan {vlan_id}' + version: eAPI version - valid values are 1 or "latest" - default is "latest" + revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version. + ofmt: eAPI output - json or text - default is json + use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it - default is True + """ + + template: str + version: Literal[1, "latest"] = "latest" + revision: Optional[conint(ge=1, le=99)] = None # type: ignore + ofmt: Literal["json", "text"] = "json" + use_cache: bool = True + + def render(self, **params: dict[str, Any]) -> AntaCommand: + """Render an AntaCommand from an AntaTemplate instance. + Keep the parameters used in the AntaTemplate instance. + + Args: + params: dictionary of variables with string values to render the Python f-string + + Returns: + command: The rendered AntaCommand. + This AntaCommand instance have a template attribute that references this + AntaTemplate instance. + """ + try: + return AntaCommand( + command=self.template.format(**params), + ofmt=self.ofmt, + version=self.version, + revision=self.revision, + template=self, + params=params, + use_cache=self.use_cache, + ) + except KeyError as e: + raise AntaTemplateRenderError(self, e.args[0]) from e + + +class AntaCommand(BaseModel): + """Class to define a command. + + !!! info + eAPI models are revisioned, this means that if a model is modified in a non-backwards compatible way, then its revision will be bumped up + (revisions are numbers, default value is 1). + + By default an eAPI request will return revision 1 of the model instance, + this ensures that older management software will not suddenly stop working when a switch is upgraded. + A **revision** applies to a particular CLI command whereas a **version** is global and is internally + translated to a specific **revision** for each CLI command in the RPC. + + __Revision has precedence over version.__ + + Attributes: + command: Device command + version: eAPI version - valid values are 1 or "latest" - default is "latest" + revision: eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version. + ofmt: eAPI output - json or text - default is json + output: Output of the command populated by the collect() function + template: AntaTemplate object used to render this command + params: Dictionary of variables with string values to render the template + errors: If the command execution fails, eAPI returns a list of strings detailing the error + use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it - default is True + """ + + command: str + version: Literal[1, "latest"] = "latest" + revision: Optional[conint(ge=1, le=99)] = None # type: ignore + ofmt: Literal["json", "text"] = "json" + output: Optional[Union[Dict[str, Any], str]] = None + template: Optional[AntaTemplate] = None + errors: List[str] = [] + params: Dict[str, Any] = {} + use_cache: bool = True + + @property + def uid(self) -> str: + """Generate a unique identifier for this command""" + uid_str = f"{self.command}_{self.version}_{self.revision or 'NA'}_{self.ofmt}" + return hashlib.sha1(uid_str.encode()).hexdigest() + + @property + def json_output(self) -> dict[str, Any]: + """Get the command output as JSON""" + if self.output is None: + raise RuntimeError(f"There is no output for command {self.command}") + if self.ofmt != "json" or not isinstance(self.output, dict): + raise RuntimeError(f"Output of command {self.command} is invalid") + return dict(self.output) + + @property + def text_output(self) -> str: + """Get the command output as a string""" + if self.output is None: + raise RuntimeError(f"There is no output for command {self.command}") + if self.ofmt != "text" or not isinstance(self.output, str): + raise RuntimeError(f"Output of command {self.command} is invalid") + return str(self.output) + + @property + def collected(self) -> bool: + """Return True if the command has been collected""" + return self.output is not None and not self.errors + + +class AntaTemplateRenderError(RuntimeError): + """ + Raised when an AntaTemplate object could not be rendered + because of missing parameters + """ + + def __init__(self, template: AntaTemplate, key: str): + """Constructor for AntaTemplateRenderError + + Args: + template: The AntaTemplate instance that failed to render + key: Key that has not been provided to render the template + """ + self.template = template + self.key = key + super().__init__(f"'{self.key}' was not provided for template '{self.template.template}'") + + +class AntaTest(ABC): + """Abstract class defining a test in ANTA + + The goal of this class is to handle the heavy lifting and make + writing a test as simple as possible. + + Examples: + The following is an example of an AntaTest subclass implementation: + ```python + class VerifyReachability(AntaTest): + name = "VerifyReachability" + description = "Test the network reachability to one or many destination IP(s)." + categories = ["connectivity"] + commands = [AntaTemplate(template="ping vrf {vrf} {dst} source {src} repeat 2")] + + class Input(AntaTest.Input): + hosts: list[Host] + class Host(BaseModel): + dst: IPv4Address + src: IPv4Address + vrf: str = "default" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render({"dst": host.dst, "src": host.src, "vrf": host.vrf}) for host in self.inputs.hosts] + + @AntaTest.anta_test + def test(self) -> None: + failures = [] + for command in self.instance_commands: + if command.params and ("src" and "dst") in command.params: + src, dst = command.params["src"], command.params["dst"] + if "2 received" not in command.json_output["messages"][0]: + failures.append((str(src), str(dst))) + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}") + ``` + Attributes: + device: AntaDevice instance on which this test is run + inputs: AntaTest.Input instance carrying the test inputs + instance_commands: List of AntaCommand instances of this test + result: TestResult instance representing the result of this test + logger: Python logger for this test instance + """ + + # Mandatory class attributes + # TODO - find a way to tell mypy these are mandatory for child classes - maybe Protocol + name: ClassVar[str] + description: ClassVar[str] + categories: ClassVar[list[str]] + commands: ClassVar[list[Union[AntaTemplate, AntaCommand]]] + # Class attributes to handle the progress bar of ANTA CLI + progress: Optional[Progress] = None + nrfu_task: Optional[TaskID] = None + + class Input(BaseModel): + """Class defining inputs for a test in ANTA. + + Examples: + A valid test catalog will look like the following: + ```yaml + <Python module>: + - <AntaTest subclass>: + result_overwrite: + categories: + - "Overwritten category 1" + description: "Test with overwritten description" + custom_field: "Test run by John Doe" + ``` + Attributes: + result_overwrite: Define fields to overwrite in the TestResult object + """ + + model_config = ConfigDict(extra="forbid") + result_overwrite: Optional[ResultOverwrite] = None + filters: Optional[Filters] = None + + def __hash__(self) -> int: + """ + Implement generic hashing for AntaTest.Input. + This will work in most cases but this does not consider 2 lists with different ordering as equal. + """ + return hash(self.model_dump_json()) + + class ResultOverwrite(BaseModel): + """Test inputs model to overwrite result fields + + Attributes: + description: overwrite TestResult.description + categories: overwrite TestResult.categories + custom_field: a free string that will be included in the TestResult object + """ + + model_config = ConfigDict(extra="forbid") + description: Optional[str] = None + categories: Optional[List[str]] = None + custom_field: Optional[str] = None + + class Filters(BaseModel): + """Runtime filters to map tests with list of tags or devices + + Attributes: + tags: List of device's tags for the test. + """ + + model_config = ConfigDict(extra="forbid") + tags: Optional[List[str]] = None + + def __init__( + self, + device: AntaDevice, + inputs: dict[str, Any] | AntaTest.Input | None = None, + eos_data: list[dict[Any, Any] | str] | None = None, + ): + """AntaTest Constructor + + Args: + device: AntaDevice instance on which the test will be run + inputs: dictionary of attributes used to instantiate the AntaTest.Input instance + eos_data: Populate outputs of the test commands instead of collecting from devices. + This list must have the same length and order than the `instance_commands` instance attribute. + """ + self.logger: logging.Logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}") + self.device: AntaDevice = device + self.inputs: AntaTest.Input + self.instance_commands: list[AntaCommand] = [] + self.result: TestResult = TestResult(name=device.name, test=self.name, categories=self.categories, description=self.description) + self._init_inputs(inputs) + if self.result.result == "unset": + self._init_commands(eos_data) + + def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None: + """Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance + to validate test inputs from defined model. + Overwrite result fields based on `ResultOverwrite` input definition. + + Any input validation error will set this test result status as 'error'.""" + try: + if inputs is None: + self.inputs = self.Input() + elif isinstance(inputs, AntaTest.Input): + self.inputs = inputs + elif isinstance(inputs, dict): + self.inputs = self.Input(**inputs) + except ValidationError as e: + message = f"{self.__module__}.{self.__class__.__name__}: Inputs are not valid\n{e}" + self.logger.error(message) + self.result.is_error(message=message) + return + if res_ow := self.inputs.result_overwrite: + if res_ow.categories: + self.result.categories = res_ow.categories + if res_ow.description: + self.result.description = res_ow.description + self.result.custom_field = res_ow.custom_field + + def _init_commands(self, eos_data: Optional[list[dict[Any, Any] | str]]) -> None: + """Instantiate the `instance_commands` instance attribute from the `commands` class attribute. + - Copy of the `AntaCommand` instances + - Render all `AntaTemplate` instances using the `render()` method + + Any template rendering error will set this test result status as 'error'. + Any exception in user code in `render()` will set this test result status as 'error'. + """ + if self.__class__.commands: + for cmd in self.__class__.commands: + if isinstance(cmd, AntaCommand): + self.instance_commands.append(deepcopy(cmd)) + elif isinstance(cmd, AntaTemplate): + try: + self.instance_commands.extend(self.render(cmd)) + except AntaTemplateRenderError as e: + self.result.is_error(message=f"Cannot render template {{{e.template}}}") + return + except NotImplementedError as e: + self.result.is_error(message=e.args[0]) + return + except Exception as e: # pylint: disable=broad-exception-caught + # render() is user-defined code. + # We need to catch everything if we want the AntaTest object + # to live until the reporting + message = f"Exception in {self.__module__}.{self.__class__.__name__}.render()" + anta_log_exception(e, message, self.logger) + self.result.is_error(message=f"{message}: {exc_to_str(e)}") + return + + if eos_data is not None: + self.logger.debug(f"Test {self.name} initialized with input data") + self.save_commands_data(eos_data) + + def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None: + """Populate output of all AntaCommand instances in `instance_commands`""" + if len(eos_data) > len(self.instance_commands): + self.result.is_error(message="Test initialization error: Trying to save more data than there are commands for the test") + return + if len(eos_data) < len(self.instance_commands): + self.result.is_error(message="Test initialization error: Trying to save less data than there are commands for the test") + return + for index, data in enumerate(eos_data or []): + self.instance_commands[index].output = data + + def __init_subclass__(cls) -> None: + """Verify that the mandatory class attributes are defined""" + mandatory_attributes = ["name", "description", "categories", "commands"] + for attr in mandatory_attributes: + if not hasattr(cls, attr): + raise NotImplementedError(f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}") + + @property + def collected(self) -> bool: + """Returns True if all commands for this test have been collected.""" + return all(command.collected for command in self.instance_commands) + + @property + def failed_commands(self) -> list[AntaCommand]: + """Returns a list of all the commands that have failed.""" + return [command for command in self.instance_commands if command.errors] + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render an AntaTemplate instance of this AntaTest using the provided + AntaTest.Input instance at self.inputs. + + This is not an abstract method because it does not need to be implemented if there is + no AntaTemplate for this test.""" + raise NotImplementedError(f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}") + + @property + def blocked(self) -> bool: + """Check if CLI commands contain a blocked keyword.""" + state = False + for command in self.instance_commands: + for pattern in BLACKLIST_REGEX: + if re.match(pattern, command.command): + self.logger.error(f"Command <{command.command}> is blocked for security reason matching {BLACKLIST_REGEX}") + self.result.is_error(f"<{command.command}> is blocked for security reason") + state = True + return state + + async def collect(self) -> None: + """ + Method used to collect outputs of all commands of this test class from the device of this test instance. + """ + try: + if self.blocked is False: + await self.device.collect_commands(self.instance_commands) + except Exception as e: # pylint: disable=broad-exception-caught + # device._collect() is user-defined code. + # We need to catch everything if we want the AntaTest object + # to live until the reporting + message = f"Exception raised while collecting commands for test {self.name} (on device {self.device.name})" + anta_log_exception(e, message, self.logger) + self.result.is_error(message=exc_to_str(e)) + + @staticmethod + def anta_test(function: F) -> Callable[..., Coroutine[Any, Any, TestResult]]: + """ + Decorator for the `test()` method. + + This decorator implements (in this order): + + 1. Instantiate the command outputs if `eos_data` is provided to the `test()` method + 2. Collect the commands from the device + 3. Run the `test()` method + 4. Catches any exception in `test()` user code and set the `result` instance attribute + """ + + @wraps(function) + async def wrapper( + self: AntaTest, + eos_data: list[dict[Any, Any] | str] | None = None, + **kwargs: Any, + ) -> TestResult: + """ + Args: + eos_data: Populate outputs of the test commands instead of collecting from devices. + This list must have the same length and order than the `instance_commands` instance attribute. + + Returns: + result: TestResult instance attribute populated with error status if any + """ + + def format_td(seconds: float, digits: int = 3) -> str: + isec, fsec = divmod(round(seconds * 10**digits), 10**digits) + return f"{timedelta(seconds=isec)}.{fsec:0{digits}.0f}" + + start_time = time.time() + if self.result.result != "unset": + return self.result + + # Data + if eos_data is not None: + self.save_commands_data(eos_data) + self.logger.debug(f"Test {self.name} initialized with input data {eos_data}") + + # If some data is missing, try to collect + if not self.collected: + await self.collect() + if self.result.result != "unset": + return self.result + + if cmds := self.failed_commands: + self.logger.debug(self.device.supports) + unsupported_commands = [f"Skipped because {c.command} is not supported on {self.device.hw_model}" for c in cmds if not self.device.supports(c)] + self.logger.debug(unsupported_commands) + if unsupported_commands: + self.logger.warning(f"Test {self.name} has been skipped because it is not supported on {self.device.hw_model}: {GITHUB_SUGGESTION}") + self.result.is_skipped("\n".join(unsupported_commands)) + return self.result + self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds])) + return self.result + + try: + function(self, **kwargs) + except Exception as e: # pylint: disable=broad-exception-caught + # test() is user-defined code. + # We need to catch everything if we want the AntaTest object + # to live until the reporting + message = f"Exception raised for test {self.name} (on device {self.device.name})" + anta_log_exception(e, message, self.logger) + self.result.is_error(message=exc_to_str(e)) + + test_duration = time.time() - start_time + self.logger.debug(f"Executing test {self.name} on device {self.device.name} took {format_td(test_duration)}") + + AntaTest.update_progress() + return self.result + + return wrapper + + @classmethod + def update_progress(cls) -> None: + """ + Update progress bar for all AntaTest objects if it exists + """ + if cls.progress and (cls.nrfu_task is not None): + cls.progress.update(cls.nrfu_task, advance=1) + + @abstractmethod + def test(self) -> Coroutine[Any, Any, TestResult]: + """ + This abstract method is the core of the test logic. + It must set the correct status of the `result` instance attribute + with the appropriate outcome of the test. + + Examples: + It must be implemented using the `AntaTest.anta_test` decorator: + ```python + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + for command in self.instance_commands: + if not self._test_command(command): # _test_command() is an arbitrary test logic + self.result.is_failure("Failure reson") + ``` + """ diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py new file mode 100644 index 0000000..dda9d9c --- /dev/null +++ b/anta/reporter/__init__.py @@ -0,0 +1,251 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Report management for ANTA. +""" +# pylint: disable = too-few-public-methods +from __future__ import annotations + +import logging +import os.path +import pathlib +from typing import Any, Optional + +from jinja2 import Template +from rich.table import Table + +from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME +from anta.custom_types import TestStatus +from anta.result_manager import ResultManager + +logger = logging.getLogger(__name__) + + +class ReportTable: + """TableReport Generate a Table based on TestResult.""" + + def _split_list_to_txt_list(self, usr_list: list[str], delimiter: Optional[str] = None) -> str: + """ + Split list to multi-lines string + + Args: + usr_list (list[str]): List of string to concatenate + delimiter (str, optional): A delimiter to use to start string. Defaults to None. + + Returns: + str: Multi-lines string + """ + if delimiter is not None: + return "\n".join(f"{delimiter} {line}" for line in usr_list) + return "\n".join(f"{line}" for line in usr_list) + + def _build_headers(self, headers: list[str], table: Table) -> Table: + """ + Create headers for a table. + + First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER + + Args: + headers (list[str]): List of headers + table (Table): A rich Table instance + + Returns: + Table: A rich Table instance with headers + """ + 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 + + def _color_result(self, status: TestStatus) -> str: + """ + Return a colored string based on the status value. + + Args: + status (TestStatus): status value to color + + Returns: + str: the colored string + """ + color = RICH_COLOR_THEME.get(status, "") + return f"[{color}]{status}" if color != "" else str(status) + + def report_all( + self, + result_manager: ResultManager, + host: Optional[str] = None, + testcase: Optional[str] = None, + title: str = "All tests results", + ) -> Table: + """ + Create a table report with all tests for one or all devices. + + Create table with full output: Host / Test / Status / Message + + Args: + result_manager (ResultManager): A manager with a list of tests. + host (str, optional): IP Address of a host to search for. Defaults to None. + testcase (str, optional): A test name to search for. Defaults to None. + title (str, optional): Title for the report. Defaults to 'All tests results'. + + Returns: + Table: A fully populated rich Table + """ + table = Table(title=title, show_lines=True) + headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"] + table = self._build_headers(headers=headers, table=table) + + for result in result_manager.get_results(): + # pylint: disable=R0916 + if (host is None and testcase is None) or (host is not None and str(result.name) == host) or (testcase is not None and testcase == str(result.test)): + state = self._color_result(result.result) + message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else "" + categories = ", ".join(result.categories) + table.add_row(str(result.name), result.test, state, message, result.description, categories) + return table + + def report_summary_tests( + self, + result_manager: ResultManager, + testcase: Optional[str] = None, + title: str = "Summary per test case", + ) -> Table: + """ + Create a table report with result agregated per test. + + Create table with full output: Test / Number of success / Number of failure / Number of error / List of nodes in error or failure + + Args: + result_manager (ResultManager): A manager with a list of tests. + testcase (str, optional): A test name to search for. Defaults to None. + title (str, optional): Title for the report. Defaults to 'All tests results'. + + Returns: + Table: A fully populated rich Table + """ + # sourcery skip: class-extract-method + table = Table(title=title, show_lines=True) + headers = [ + "Test Case", + "# of success", + "# of skipped", + "# of failure", + "# of errors", + "List of failed or error nodes", + ] + table = self._build_headers(headers=headers, table=table) + for testcase_read in result_manager.get_testcases(): + if testcase is None or str(testcase_read) == testcase: + results = result_manager.get_result_by_test(testcase_read) + nb_failure = len([result for result in results if result.result == "failure"]) + nb_error = len([result for result in results if result.result == "error"]) + list_failure = [str(result.name) for result in results if result.result in ["failure", "error"]] + nb_success = len([result for result in results if result.result == "success"]) + nb_skipped = len([result for result in results if result.result == "skipped"]) + table.add_row( + testcase_read, + str(nb_success), + str(nb_skipped), + str(nb_failure), + str(nb_error), + str(list_failure), + ) + return table + + def report_summary_hosts( + self, + result_manager: ResultManager, + host: Optional[str] = None, + title: str = "Summary per host", + ) -> Table: + """ + Create a table report with result agregated per host. + + Create table with full output: Host / Number of success / Number of failure / Number of error / List of nodes in error or failure + + Args: + result_manager (ResultManager): A manager with a list of tests. + host (str, optional): IP Address of a host to search for. Defaults to None. + title (str, optional): Title for the report. Defaults to 'All tests results'. + + Returns: + Table: A fully populated rich Table + """ + table = Table(title=title, show_lines=True) + headers = [ + "Device", + "# of success", + "# of skipped", + "# of failure", + "# of errors", + "List of failed or error test cases", + ] + table = self._build_headers(headers=headers, table=table) + for host_read in result_manager.get_hosts(): + if host is None or str(host_read) == host: + results = result_manager.get_result_by_host(host_read) + logger.debug("data to use for computation") + logger.debug(f"{host}: {results}") + nb_failure = len([result for result in results if result.result == "failure"]) + nb_error = len([result for result in results if result.result == "error"]) + list_failure = [str(result.test) for result in results if result.result in ["failure", "error"]] + nb_success = len([result for result in results if result.result == "success"]) + nb_skipped = len([result for result in results if result.result == "skipped"]) + table.add_row( + str(host_read), + str(nb_success), + str(nb_skipped), + str(nb_failure), + str(nb_error), + str(list_failure), + ) + return table + + +class ReportJinja: + """Report builder based on a Jinja2 template.""" + + def __init__(self, template_path: pathlib.Path) -> None: + if os.path.isfile(template_path): + self.tempalte_path = template_path + else: + raise FileNotFoundError(f"template file is not found: {template_path}") + + def render(self, data: list[dict[str, Any]], trim_blocks: bool = True, lstrip_blocks: bool = True) -> str: + """ + Build a report based on a Jinja2 template + + Report is built based on a J2 template provided by user. + Data structure sent to template is: + + >>> data = ResultManager.get_json_results() + >>> print(data) + [ + { + name: ..., + test: ..., + result: ..., + messages: [...] + categories: ..., + description: ..., + } + ] + + Args: + data (list[dict[str, Any]]): List of results from ResultManager.get_results + trim_blocks (bool, optional): enable trim_blocks for J2 rendering. Defaults to True. + lstrip_blocks (bool, optional): enable lstrip_blocks for J2 rendering. Defaults to True. + + Returns: + str: rendered template + """ + with open(self.tempalte_path, encoding="utf-8") as file_: + template = Template(file_.read(), trim_blocks=trim_blocks, lstrip_blocks=lstrip_blocks) + + return template.render({"data": data}) diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py new file mode 100644 index 0000000..9ce880e --- /dev/null +++ b/anta/result_manager/__init__.py @@ -0,0 +1,211 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Result Manager Module for ANTA. +""" +from __future__ import annotations + +import json +import logging + +from pydantic import TypeAdapter + +from anta.custom_types import TestStatus +from anta.result_manager.models import TestResult + +logger = logging.getLogger(__name__) + + +class ResultManager: + """ + Helper to manage Test Results and generate reports. + + Examples: + + Create Inventory: + + inventory_anta = AntaInventory.parse( + filename='examples/inventory.yml', + username='ansible', + password='ansible', + timeout=0.5 + ) + + Create Result Manager: + + manager = ResultManager() + + Run tests for all connected devices: + + for device in inventory_anta.get_inventory(): + manager.add_test_result( + VerifyNTP(device=device).test() + ) + manager.add_test_result( + VerifyEOSVersion(device=device).test(version='4.28.3M') + ) + + Print result in native format: + + manager.get_results() + [ + TestResult( + host=IPv4Address('192.168.0.10'), + test='VerifyNTP', + result='failure', + message="device is not running NTP correctly" + ), + TestResult( + host=IPv4Address('192.168.0.10'), + test='VerifyEOSVersion', + result='success', + message=None + ), + ] + """ + + def __init__(self) -> None: + """ + Class constructor. + + The status of the class is initialized to "unset" + + Then when adding a test with a status that is NOT 'error' the following + table shows the updated status: + + | Current Status | Added test Status | Updated Status | + | -------------- | ------------------------------- | -------------- | + | unset | Any | Any | + | skipped | unset, skipped | skipped | + | skipped | success | success | + | skipped | failure | failure | + | success | unset, skipped, success | success | + | success | failure | failure | + | failure | unset, skipped success, failure | failure | + + If the status of the added test is error, the status is untouched and the + error_status is set to True. + """ + self._result_entries: list[TestResult] = [] + # Initialize status + self.status: TestStatus = "unset" + self.error_status = False + + def __len__(self) -> int: + """ + Implement __len__ method to count number of results. + """ + return len(self._result_entries) + + def _update_status(self, test_status: TestStatus) -> None: + """ + Update ResultManager status based on the table above. + """ + ResultValidator = TypeAdapter(TestStatus) + ResultValidator.validate_python(test_status) + if test_status == "error": + self.error_status = True + return + if self.status == "unset": + self.status = test_status + elif self.status == "skipped" and test_status in {"success", "failure"}: + self.status = test_status + elif self.status == "success" and test_status == "failure": + self.status = "failure" + + def add_test_result(self, entry: TestResult) -> None: + """Add a result to the list + + Args: + entry (TestResult): TestResult data to add to the report + """ + logger.debug(entry) + self._result_entries.append(entry) + self._update_status(entry.result) + + def add_test_results(self, entries: list[TestResult]) -> None: + """Add a list of results to the list + + Args: + entries (list[TestResult]): List of TestResult data to add to the report + """ + for e in entries: + self.add_test_result(e) + + def get_status(self, ignore_error: bool = False) -> str: + """ + Returns the current status including error_status if ignore_error is False + """ + return "error" if self.error_status and not ignore_error else self.status + + def get_results(self) -> list[TestResult]: + """ + Expose list of all test results in different format + + Returns: + any: List of results. + """ + return self._result_entries + + def get_json_results(self) -> str: + """ + Expose list of all test results in JSON + + Returns: + str: JSON dumps of the list of results + """ + result = [result.model_dump() for result in self._result_entries] + return json.dumps(result, indent=4) + + def get_result_by_test(self, test_name: str) -> list[TestResult]: + """ + Get list of test result for a given test. + + Args: + test_name (str): Test name to use to filter results + output_format (str, optional): format selector. Can be either native/list. Defaults to 'native'. + + Returns: + list[TestResult]: List of results related to the test. + """ + return [result for result in self._result_entries if str(result.test) == test_name] + + def get_result_by_host(self, host_ip: str) -> list[TestResult]: + """ + Get list of test result for a given host. + + Args: + host_ip (str): IP Address of the host to use to filter results. + output_format (str, optional): format selector. Can be either native/list. Defaults to 'native'. + + Returns: + list[TestResult]: List of results related to the host. + """ + return [result for result in self._result_entries if str(result.name) == host_ip] + + def get_testcases(self) -> list[str]: + """ + Get list of name of all test cases in current manager. + + Returns: + list[str]: List of names for all tests. + """ + result_list = [] + for testcase in self._result_entries: + if str(testcase.test) not in result_list: + result_list.append(str(testcase.test)) + return result_list + + def get_hosts(self) -> list[str]: + """ + Get list of IP addresses in current manager. + + Returns: + list[str]: List of IP addresses. + """ + result_list = [] + for testcase in self._result_entries: + if str(testcase.name) not in result_list: + result_list.append(str(testcase.name)) + return result_list diff --git a/anta/result_manager/models.py b/anta/result_manager/models.py new file mode 100644 index 0000000..1531381 --- /dev/null +++ b/anta/result_manager/models.py @@ -0,0 +1,86 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Models related to anta.result_manager module.""" +from __future__ import annotations + +# Need to keep List for pydantic in 3.8 +from typing import List, Optional + +from pydantic import BaseModel + +from anta.custom_types import TestStatus + + +class TestResult(BaseModel): + """ + Describe the result of a test from a single device. + + Attributes: + name: Device name where the test has run. + test: Test name runs on the device. + categories: List of categories the TestResult belongs to, by default the AntaTest categories. + description: TestResult description, by default the AntaTest description. + result: Result of the test. Can be one of "unset", "success", "failure", "error" or "skipped". + messages: Message to report after the test if any. + custom_field: Custom field to store a string for flexibility in integrating with ANTA + """ + + name: str + test: str + categories: List[str] + description: str + result: TestStatus = "unset" + messages: List[str] = [] + custom_field: Optional[str] = None + + def is_success(self, message: str | None = None) -> None: + """ + Helper to set status to success + + Args: + message: Optional message related to the test + """ + self._set_status("success", message) + + def is_failure(self, message: str | None = None) -> None: + """ + Helper to set status to failure + + Args: + message: Optional message related to the test + """ + self._set_status("failure", message) + + def is_skipped(self, message: str | None = None) -> None: + """ + Helper to set status to skipped + + Args: + message: Optional message related to the test + """ + self._set_status("skipped", message) + + def is_error(self, message: str | None = None) -> None: + """ + Helper to set status to error + """ + self._set_status("error", message) + + def _set_status(self, status: TestStatus, message: str | None = None) -> None: + """ + Set status and insert optional message + + Args: + status: status of the test + message: optional message + """ + self.result = status + if message is not None: + self.messages.append(message) + + def __str__(self) -> str: + """ + Returns a human readable string of this TestResult + """ + return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}" diff --git a/anta/runner.py b/anta/runner.py new file mode 100644 index 0000000..ab65c80 --- /dev/null +++ b/anta/runner.py @@ -0,0 +1,109 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# pylint: disable=too-many-branches +""" +ANTA runner function +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Tuple + +from anta import GITHUB_SUGGESTION +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 + +logger = logging.getLogger(__name__) + +AntaTestRunner = Tuple[AntaTestDefinition, AntaDevice] + + +async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCatalog, tags: list[str] | None = None, established_only: bool = True) -> None: + """ + Main coroutine to run ANTA. + Use this as an entrypoint to the test framwork in your script. + + Args: + manager: ResultManager object to populate with the test results. + inventory: AntaInventory object that includes the device(s). + catalog: AntaCatalog object that includes the list of tests. + tags: List of tags to filter devices from the inventory. Defaults to None. + established_only: Include only established device(s). Defaults to True. + + Returns: + any: ResultManager object gets updated with the test results. + """ + if not catalog.tests: + logger.info("The list of tests is empty, exiting") + return + if len(inventory) == 0: + logger.info("The inventory is empty, exiting") + return + await inventory.connect_inventory() + devices: list[AntaDevice] = list(inventory.get_inventory(established_only=established_only, tags=tags).values()) + + if not devices: + logger.info( + f"No device in the established state '{established_only}' " + f"{f'matching the tags {tags} ' if tags else ''}was found. There is no device to run tests against, exiting" + ) + + return + coros = [] + # Using a set to avoid inserting duplicate tests + tests_set: set[AntaTestRunner] = set() + for device in devices: + if tags: + # If there are CLI tags, only execute tests with matching tags + tests_set.update((test, device) for test in catalog.get_tests_by_tags(tags)) + else: + # If there is no CLI tags, execute all tests without filters + tests_set.update((t, device) for t in catalog.tests if t.inputs.filters is None or t.inputs.filters.tags is None) + + # Then add the tests with matching tags from device tags + tests_set.update((t, device) for t in catalog.get_tests_by_tags(device.tags)) + + tests: list[AntaTestRunner] = list(tests_set) + + if not tests: + logger.info(f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run on current inventory. " "Exiting...") + return + + for test_definition, device in tests: + try: + test_instance = test_definition.test(device=device, inputs=test_definition.inputs) + + coros.append(test_instance.test()) + except Exception as e: # pylint: disable=broad-exception-caught + # An AntaTest instance is potentially user-defined code. + # We need to catch everything and exit gracefully with an + # error message + message = "\n".join( + [ + f"There is an error when creating test {test_definition.test.__module__}.{test_definition.test.__name__}.", + f"If this is not a custom test implementation: {GITHUB_SUGGESTION}", + ] + ) + anta_log_exception(e, message, logger) + if AntaTest.progress is not None: + AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coros)) + + logger.info("Running ANTA tests...") + test_results = await asyncio.gather(*coros) + for r in test_results: + manager.add_test_result(r) + for device in devices: + if device.cache_statistics is not None: + logger.info( + f"Cache statistics for '{device.name}': " + f"{device.cache_statistics['cache_hits']} hits / {device.cache_statistics['total_commands_sent']} " + f"command(s) ({device.cache_statistics['cache_hit_ratio']})" + ) + else: + logger.info(f"Caching is not enabled on {device.name}") diff --git a/anta/tests/__init__.py b/anta/tests/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/anta/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/anta/tests/aaa.py b/anta/tests/aaa.py new file mode 100644 index 0000000..84298cf --- /dev/null +++ b/anta/tests/aaa.py @@ -0,0 +1,292 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to the EOS various AAA settings +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from ipaddress import IPv4Address + +# Need to keep List and Set for pydantic in python 3.8 +from typing import List, Literal, Set + +from anta.custom_types import AAAAuthMethod +from anta.models import AntaCommand, AntaTest + + +class VerifyTacacsSourceIntf(AntaTest): + """ + Verifies TACACS source-interface for a specified VRF. + + Expected Results: + * success: The test will pass if the provided TACACS source-interface is configured in the specified VRF. + * failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF. + """ + + name = "VerifyTacacsSourceIntf" + description = "Verifies TACACS source-interface for a specified VRF." + categories = ["aaa"] + commands = [AntaCommand(command="show tacacs")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + intf: str + """Source-interface to use as source IP of TACACS messages""" + vrf: str = "default" + """The name of the VRF to transport TACACS messages""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + try: + if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf: + self.result.is_success() + else: + self.result.is_failure(f"Wrong source-interface configured in VRF {self.inputs.vrf}") + except KeyError: + self.result.is_failure(f"Source-interface {self.inputs.intf} is not configured in VRF {self.inputs.vrf}") + + +class VerifyTacacsServers(AntaTest): + """ + Verifies TACACS servers are configured for a specified VRF. + + Expected Results: + * success: The test will pass if the provided TACACS servers are configured in the specified VRF. + * failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF. + """ + + name = "VerifyTacacsServers" + description = "Verifies TACACS servers are configured for a specified VRF." + categories = ["aaa"] + commands = [AntaCommand(command="show tacacs")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + servers: List[IPv4Address] + """List of TACACS servers""" + vrf: str = "default" + """The name of the VRF to transport TACACS messages""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + tacacs_servers = command_output["tacacsServers"] + if not tacacs_servers: + self.result.is_failure("No TACACS servers are configured") + return + not_configured = [ + str(server) + for server in self.inputs.servers + if not any( + str(server) == tacacs_server["serverInfo"]["hostname"] and self.inputs.vrf == tacacs_server["serverInfo"]["vrf"] for tacacs_server in tacacs_servers + ) + ] + if not not_configured: + self.result.is_success() + else: + self.result.is_failure(f"TACACS servers {not_configured} are not configured in VRF {self.inputs.vrf}") + + +class VerifyTacacsServerGroups(AntaTest): + """ + Verifies if the provided TACACS server group(s) are configured. + + Expected Results: + * success: The test will pass if the provided TACACS server group(s) are configured. + * failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured. + """ + + name = "VerifyTacacsServerGroups" + description = "Verifies if the provided TACACS server group(s) are configured." + categories = ["aaa"] + commands = [AntaCommand(command="show tacacs")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + groups: List[str] + """List of TACACS server group""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + tacacs_groups = command_output["groups"] + if not tacacs_groups: + self.result.is_failure("No TACACS server group(s) are configured") + return + not_configured = [group for group in self.inputs.groups if group not in tacacs_groups] + if not not_configured: + self.result.is_success() + else: + self.result.is_failure(f"TACACS server group(s) {not_configured} are not configured") + + +class VerifyAuthenMethods(AntaTest): + """ + Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x). + + Expected Results: + * success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types. + * failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types. + """ + + name = "VerifyAuthenMethods" + description = "Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x)." + categories = ["aaa"] + commands = [AntaCommand(command="show aaa methods authentication")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + methods: List[AAAAuthMethod] + """List of AAA authentication methods. Methods should be in the right order""" + types: Set[Literal["login", "enable", "dot1x"]] + """List of authentication types to verify""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + not_matching = [] + for k, v in command_output.items(): + auth_type = k.replace("AuthenMethods", "") + if auth_type not in self.inputs.types: + # We do not need to verify this accounting type + continue + if auth_type == "login": + if "login" not in v: + self.result.is_failure("AAA authentication methods are not configured for login console") + return + if v["login"]["methods"] != self.inputs.methods: + self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for login console") + return + for methods in v.values(): + if methods["methods"] != self.inputs.methods: + not_matching.append(auth_type) + if not not_matching: + self.result.is_success() + else: + self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for {not_matching}") + + +class VerifyAuthzMethods(AntaTest): + """ + Verifies the AAA authorization method lists for different authorization types (commands, exec). + + Expected Results: + * success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types. + * failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types. + """ + + name = "VerifyAuthzMethods" + description = "Verifies the AAA authorization method lists for different authorization types (commands, exec)." + categories = ["aaa"] + commands = [AntaCommand(command="show aaa methods authorization")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + methods: List[AAAAuthMethod] + """List of AAA authorization methods. Methods should be in the right order""" + types: Set[Literal["commands", "exec"]] + """List of authorization types to verify""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + not_matching = [] + for k, v in command_output.items(): + authz_type = k.replace("AuthzMethods", "") + if authz_type not in self.inputs.types: + # We do not need to verify this accounting type + continue + for methods in v.values(): + if methods["methods"] != self.inputs.methods: + not_matching.append(authz_type) + if not not_matching: + self.result.is_success() + else: + self.result.is_failure(f"AAA authorization methods {self.inputs.methods} are not matching for {not_matching}") + + +class VerifyAcctDefaultMethods(AntaTest): + """ + Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x). + + Expected Results: + * success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types. + * failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types. + """ + + name = "VerifyAcctDefaultMethods" + description = "Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x)." + categories = ["aaa"] + commands = [AntaCommand(command="show aaa methods accounting")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + methods: List[AAAAuthMethod] + """List of AAA accounting methods. Methods should be in the right order""" + types: Set[Literal["commands", "exec", "system", "dot1x"]] + """List of accounting types to verify""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + not_matching = [] + not_configured = [] + for k, v in command_output.items(): + acct_type = k.replace("AcctMethods", "") + if acct_type not in self.inputs.types: + # We do not need to verify this accounting type + continue + for methods in v.values(): + if "defaultAction" not in methods: + not_configured.append(acct_type) + if methods["defaultMethods"] != self.inputs.methods: + not_matching.append(acct_type) + if not_configured: + self.result.is_failure(f"AAA default accounting is not configured for {not_configured}") + return + if not not_matching: + self.result.is_success() + else: + self.result.is_failure(f"AAA accounting default methods {self.inputs.methods} are not matching for {not_matching}") + + +class VerifyAcctConsoleMethods(AntaTest): + """ + Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x). + + Expected Results: + * success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types. + * failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types. + """ + + name = "VerifyAcctConsoleMethods" + description = "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x)." + categories = ["aaa"] + commands = [AntaCommand(command="show aaa methods accounting")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + methods: List[AAAAuthMethod] + """List of AAA accounting console methods. Methods should be in the right order""" + types: Set[Literal["commands", "exec", "system", "dot1x"]] + """List of accounting console types to verify""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + not_matching = [] + not_configured = [] + for k, v in command_output.items(): + acct_type = k.replace("AcctMethods", "") + if acct_type not in self.inputs.types: + # We do not need to verify this accounting type + continue + for methods in v.values(): + if "consoleAction" not in methods: + not_configured.append(acct_type) + if methods["consoleMethods"] != self.inputs.methods: + not_matching.append(acct_type) + if not_configured: + self.result.is_failure(f"AAA console accounting is not configured for {not_configured}") + return + if not not_matching: + self.result.is_success() + else: + self.result.is_failure(f"AAA accounting console methods {self.inputs.methods} are not matching for {not_matching}") diff --git a/anta/tests/bfd.py b/anta/tests/bfd.py new file mode 100644 index 0000000..aea8d07 --- /dev/null +++ b/anta/tests/bfd.py @@ -0,0 +1,235 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +BFD test functions +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from datetime import datetime +from ipaddress import IPv4Address +from typing import Any, List, Optional + +from pydantic import BaseModel, Field + +from anta.custom_types import BfdInterval, BfdMultiplier +from anta.models import AntaCommand, AntaTest +from anta.tools.get_value import get_value + + +class VerifyBFDSpecificPeers(AntaTest): + """ + This class verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF. + + Expected results: + * success: The test will pass if IPv4 BFD peers are up and remote disc is non-zero in the specified VRF. + * failure: The test will fail if IPv4 BFD peers are not found, the status is not UP or remote disc is zero in the specified VRF. + """ + + name = "VerifyBFDSpecificPeers" + description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF." + categories = ["bfd"] + commands = [AntaCommand(command="show bfd peers")] + + class Input(AntaTest.Input): + """ + This class defines the input parameters of the test case. + """ + + bfd_peers: List[BFDPeers] + """List of IPv4 BFD peers""" + + class BFDPeers(BaseModel): + """ + This class defines the details of an IPv4 BFD peer. + """ + + peer_address: IPv4Address + """IPv4 address of a BFD peer""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + + @AntaTest.anta_test + def test(self) -> None: + failures: dict[Any, Any] = {} + + # Iterating over BFD peers + for bfd_peer in self.inputs.bfd_peers: + peer = str(bfd_peer.peer_address) + vrf = bfd_peer.vrf + bfd_output = get_value(self.instance_commands[0].json_output, f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", separator="..") + + # Check if BFD peer configured + if not bfd_output: + failures[peer] = {vrf: "Not Configured"} + continue + + # Check BFD peer status and remote disc + if not (bfd_output.get("status") == "up" and bfd_output.get("remoteDisc") != 0): + failures[peer] = {vrf: {"status": bfd_output.get("status"), "remote_disc": bfd_output.get("remoteDisc")}} + + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"Following BFD peers are not configured, status is not up or remote disc is zero:\n{failures}") + + +class VerifyBFDPeersIntervals(AntaTest): + """ + This class verifies the timers of the IPv4 BFD peers in the specified VRF. + + Expected results: + * success: The test will pass if the timers of the IPv4 BFD peers are correct in the specified VRF. + * failure: The test will fail if the IPv4 BFD peers are not found or their timers are incorrect in the specified VRF. + """ + + name = "VerifyBFDPeersIntervals" + description = "Verifies the timers of the IPv4 BFD peers in the specified VRF." + categories = ["bfd"] + commands = [AntaCommand(command="show bfd peers detail")] + + class Input(AntaTest.Input): + """ + This class defines the input parameters of the test case. + """ + + bfd_peers: List[BFDPeers] + """List of BFD peers""" + + class BFDPeers(BaseModel): + """ + This class defines the details of an IPv4 BFD peer. + """ + + peer_address: IPv4Address + """IPv4 address of a BFD peer""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + tx_interval: BfdInterval + """Tx interval of BFD peer in milliseconds""" + rx_interval: BfdInterval + """Rx interval of BFD peer in milliseconds""" + multiplier: BfdMultiplier + """Multiplier of BFD peer""" + + @AntaTest.anta_test + def test(self) -> None: + failures: dict[Any, Any] = {} + + # Iterating over BFD peers + for bfd_peers in self.inputs.bfd_peers: + peer = str(bfd_peers.peer_address) + vrf = bfd_peers.vrf + + # Converting milliseconds intervals into actual value + tx_interval = bfd_peers.tx_interval * 1000 + rx_interval = bfd_peers.rx_interval * 1000 + multiplier = bfd_peers.multiplier + bfd_output = get_value(self.instance_commands[0].json_output, f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", separator="..") + + # Check if BFD peer configured + if not bfd_output: + failures[peer] = {vrf: "Not Configured"} + continue + + bfd_details = bfd_output.get("peerStatsDetail", {}) + intervals_ok = ( + bfd_details.get("operTxInterval") == tx_interval and bfd_details.get("operRxInterval") == rx_interval and bfd_details.get("detectMult") == multiplier + ) + + # Check timers of BFD peer + if not intervals_ok: + failures[peer] = { + vrf: { + "tx_interval": bfd_details.get("operTxInterval"), + "rx_interval": bfd_details.get("operRxInterval"), + "multiplier": bfd_details.get("detectMult"), + } + } + + # Check if any failures + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"Following BFD peers are not configured or timers are not correct:\n{failures}") + + +class VerifyBFDPeersHealth(AntaTest): + """ + This class verifies the health of IPv4 BFD peers across all VRFs. + + It checks that no BFD peer is in the down state and that the discriminator value of the remote system is not zero. + Optionally, it can also verify that BFD peers have not been down before a specified threshold of hours. + + Expected results: + * Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero, + and the last downtime of each peer is above the defined threshold. + * Failure: The test will fail if any IPv4 BFD peer is down, the discriminator value of any remote system is zero, + or the last downtime of any peer is below the defined threshold. + """ + + name = "VerifyBFDPeersHealth" + description = "Verifies the health of all IPv4 BFD peers." + categories = ["bfd"] + # revision 1 as later revision introduces additional nesting for type + commands = [AntaCommand(command="show bfd peers", revision=1), AntaCommand(command="show clock")] + + class Input(AntaTest.Input): + """ + This class defines the input parameters of the test case. + """ + + down_threshold: Optional[int] = Field(default=None, gt=0) + """Optional down threshold in hours to check if a BFD peer was down before those hours or not.""" + + @AntaTest.anta_test + def test(self) -> None: + # Initialize failure strings + down_failures = [] + up_failures = [] + + # Extract the current timestamp and command output + clock_output = self.instance_commands[1].json_output + current_timestamp = clock_output["utcTime"] + bfd_output = self.instance_commands[0].json_output + + # set the initial result + self.result.is_success() + + # Check if any IPv4 BFD peer is configured + ipv4_neighbors_exist = any(vrf_data["ipv4Neighbors"] for vrf_data in bfd_output["vrfs"].values()) + if not ipv4_neighbors_exist: + self.result.is_failure("No IPv4 BFD peers are configured for any VRF.") + return + + # Iterate over IPv4 BFD peers + for vrf, vrf_data in bfd_output["vrfs"].items(): + for peer, neighbor_data in vrf_data["ipv4Neighbors"].items(): + for peer_data in neighbor_data["peerStats"].values(): + peer_status = peer_data["status"] + remote_disc = peer_data["remoteDisc"] + remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else "" + last_down = peer_data["lastDown"] + hours_difference = (datetime.fromtimestamp(current_timestamp) - datetime.fromtimestamp(last_down)).total_seconds() / 3600 + + # Check if peer status is not up + if peer_status != "up": + down_failures.append(f"{peer} is {peer_status} in {vrf} VRF{remote_disc_info}.") + + # Check if the last down is within the threshold + elif self.inputs.down_threshold and hours_difference < self.inputs.down_threshold: + up_failures.append(f"{peer} in {vrf} VRF was down {round(hours_difference)} hours ago{remote_disc_info}.") + + # Check if remote disc is 0 + elif remote_disc == 0: + up_failures.append(f"{peer} in {vrf} VRF has remote disc {remote_disc}.") + + # Check if there are any failures + if down_failures: + down_failures_str = "\n".join(down_failures) + self.result.is_failure(f"Following BFD peers are not up:\n{down_failures_str}") + if up_failures: + up_failures_str = "\n".join(up_failures) + self.result.is_failure(f"\nFollowing BFD peers were down:\n{up_failures_str}") diff --git a/anta/tests/configuration.py b/anta/tests/configuration.py new file mode 100644 index 0000000..060782a --- /dev/null +++ b/anta/tests/configuration.py @@ -0,0 +1,51 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to the device configuration +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from anta.models import AntaCommand, AntaTest + + +class VerifyZeroTouch(AntaTest): + """ + Verifies ZeroTouch is disabled + """ + + name = "VerifyZeroTouch" + description = "Verifies ZeroTouch is disabled" + categories = ["configuration"] + commands = [AntaCommand(command="show zerotouch")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].output + assert isinstance(command_output, dict) + if command_output["mode"] == "disabled": + self.result.is_success() + else: + self.result.is_failure("ZTP is NOT disabled") + + +class VerifyRunningConfigDiffs(AntaTest): + """ + Verifies there is no difference between the running-config and the startup-config + """ + + name = "VerifyRunningConfigDiffs" + description = "Verifies there is no difference between the running-config and the startup-config" + categories = ["configuration"] + commands = [AntaCommand(command="show running-config diffs", ofmt="text")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].output + if command_output is None or command_output == "": + self.result.is_success() + else: + self.result.is_failure() + self.result.is_failure(str(command_output)) diff --git a/anta/tests/connectivity.py b/anta/tests/connectivity.py new file mode 100644 index 0000000..7222f56 --- /dev/null +++ b/anta/tests/connectivity.py @@ -0,0 +1,125 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to various connectivity checks +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from ipaddress import IPv4Address + +# Need to keep List for pydantic in python 3.8 +from typing import List, Union + +from pydantic import BaseModel + +from anta.custom_types import Interface +from anta.models import AntaCommand, AntaMissingParamException, AntaTemplate, AntaTest + + +class VerifyReachability(AntaTest): + """ + Test network reachability to one or many destination IP(s). + + Expected Results: + * success: The test will pass if all destination IP(s) are reachable. + * failure: The test will fail if one or many destination IP(s) are unreachable. + """ + + name = "VerifyReachability" + description = "Test the network reachability to one or many destination IP(s)." + categories = ["connectivity"] + commands = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + hosts: List[Host] + """List of hosts to ping""" + + class Host(BaseModel): + """Remote host to ping""" + + destination: IPv4Address + """IPv4 address to ping""" + source: Union[IPv4Address, Interface] + """IPv4 address source IP or Egress interface to use""" + vrf: str = "default" + """VRF context""" + repeat: int = 2 + """Number of ping repetition (default=2)""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts] + + @AntaTest.anta_test + def test(self) -> None: + failures = [] + for command in self.instance_commands: + src = command.params.get("source") + dst = command.params.get("destination") + repeat = command.params.get("repeat") + + if any(elem is None for elem in (src, dst, repeat)): + raise AntaMissingParamException(f"A parameter is missing to execute the test for command {command}") + + if f"{repeat} received" not in command.json_output["messages"][0]: + failures.append((str(src), str(dst))) + + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}") + + +class VerifyLLDPNeighbors(AntaTest): + """ + This test verifies that the provided LLDP neighbors are present and connected with the correct configuration. + + Expected Results: + * success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device. + * failure: The test will fail if any of the following conditions are met: + - The provided LLDP neighbor is not found. + - The system name or port of the LLDP neighbor does not match the provided information. + """ + + name = "VerifyLLDPNeighbors" + description = "Verifies that the provided LLDP neighbors are connected properly." + categories = ["connectivity"] + commands = [AntaCommand(command="show lldp neighbors detail")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + neighbors: List[Neighbor] + """List of LLDP neighbors""" + + class Neighbor(BaseModel): + """LLDP neighbor""" + + port: Interface + """LLDP port""" + neighbor_device: str + """LLDP neighbor device""" + neighbor_port: Interface + """LLDP neighbor port""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + + failures: dict[str, list[str]] = {} + + for neighbor in self.inputs.neighbors: + if neighbor.port not in command_output["lldpNeighbors"]: + failures.setdefault("port_not_configured", []).append(neighbor.port) + elif len(lldp_neighbor_info := command_output["lldpNeighbors"][neighbor.port]["lldpNeighborInfo"]) == 0: + failures.setdefault("no_lldp_neighbor", []).append(neighbor.port) + elif ( + lldp_neighbor_info[0]["systemName"] != neighbor.neighbor_device + or lldp_neighbor_info[0]["neighborInterfaceInfo"]["interfaceId_v2"] != neighbor.neighbor_port + ): + failures.setdefault("wrong_lldp_neighbor", []).append(neighbor.port) + + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"The following port(s) have issues: {failures}") diff --git a/anta/tests/field_notices.py b/anta/tests/field_notices.py new file mode 100644 index 0000000..04fdc4d --- /dev/null +++ b/anta/tests/field_notices.py @@ -0,0 +1,165 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions to flag field notices +""" + +from anta.decorators import skip_on_platforms +from anta.models import AntaCommand, AntaTest + + +class VerifyFieldNotice44Resolution(AntaTest): + """ + Verifies the device is using an Aboot version that fix the bug discussed + in the field notice 44 (Aboot manages system settings prior to EOS initialization). + + https://www.arista.com/en/support/advisories-notices/field-notice/8756-field-notice-44 + """ + + name = "VerifyFieldNotice44Resolution" + description = ( + "Verifies the device is using an Aboot version that fix the bug discussed in the field notice 44 (Aboot manages system settings prior to EOS initialization)" + ) + categories = ["field notices", "software"] + commands = [AntaCommand(command="show version detail")] + + # TODO maybe implement ONLY ON PLATFORMS instead + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + + devices = [ + "DCS-7010T-48", + "DCS-7010T-48-DC", + "DCS-7050TX-48", + "DCS-7050TX-64", + "DCS-7050TX-72", + "DCS-7050TX-72Q", + "DCS-7050TX-96", + "DCS-7050TX2-128", + "DCS-7050SX-64", + "DCS-7050SX-72", + "DCS-7050SX-72Q", + "DCS-7050SX2-72Q", + "DCS-7050SX-96", + "DCS-7050SX2-128", + "DCS-7050QX-32S", + "DCS-7050QX2-32S", + "DCS-7050SX3-48YC12", + "DCS-7050CX3-32S", + "DCS-7060CX-32S", + "DCS-7060CX2-32S", + "DCS-7060SX2-48YC6", + "DCS-7160-48YC6", + "DCS-7160-48TC6", + "DCS-7160-32CQ", + "DCS-7280SE-64", + "DCS-7280SE-68", + "DCS-7280SE-72", + "DCS-7150SC-24-CLD", + "DCS-7150SC-64-CLD", + "DCS-7020TR-48", + "DCS-7020TRA-48", + "DCS-7020SR-24C2", + "DCS-7020SRG-24C2", + "DCS-7280TR-48C6", + "DCS-7280TRA-48C6", + "DCS-7280SR-48C6", + "DCS-7280SRA-48C6", + "DCS-7280SRAM-48C6", + "DCS-7280SR2K-48C6-M", + "DCS-7280SR2-48YC6", + "DCS-7280SR2A-48YC6", + "DCS-7280SRM-40CX2", + "DCS-7280QR-C36", + "DCS-7280QRA-C36S", + ] + variants = ["-SSD-F", "-SSD-R", "-M-F", "-M-R", "-F", "-R"] + + model = command_output["modelName"] + # TODO this list could be a regex + for variant in variants: + model = model.replace(variant, "") + if model not in devices: + self.result.is_skipped("device is not impacted by FN044") + return + + for component in command_output["details"]["components"]: + if component["name"] == "Aboot": + aboot_version = component["version"].split("-")[2] + self.result.is_success() + if aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7: + self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})") + elif aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1: + self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})") + elif aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9: + self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})") + elif aboot_version.startswith("6.1.") and int(aboot_version.split(".")[2]) < 7: + self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})") + + +class VerifyFieldNotice72Resolution(AntaTest): + """ + Checks if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated. + + https://www.arista.com/en/support/advisories-notices/field-notice/17410-field-notice-0072 + """ + + name = "VerifyFieldNotice72Resolution" + description = "Verifies if the device has exposeure to FN72, and if the issue has been mitigated" + categories = ["field notices", "software"] + commands = [AntaCommand(command="show version detail")] + + # TODO maybe implement ONLY ON PLATFORMS instead + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + + devices = ["DCS-7280SR3-48YC8", "DCS-7280SR3K-48YC8"] + variants = ["-SSD-F", "-SSD-R", "-M-F", "-M-R", "-F", "-R"] + model = command_output["modelName"] + + for variant in variants: + model = model.replace(variant, "") + if model not in devices: + self.result.is_skipped("Platform is not impacted by FN072") + return + + serial = command_output["serialNumber"] + number = int(serial[3:7]) + + if "JPE" not in serial and "JAS" not in serial: + self.result.is_skipped("Device not exposed") + return + + if model == "DCS-7280SR3-48YC8" and "JPE" in serial and number >= 2131: + self.result.is_skipped("Device not exposed") + return + + if model == "DCS-7280SR3-48YC8" and "JAS" in serial and number >= 2041: + self.result.is_skipped("Device not exposed") + return + + if model == "DCS-7280SR3K-48YC8" and "JPE" in serial and number >= 2134: + self.result.is_skipped("Device not exposed") + return + + if model == "DCS-7280SR3K-48YC8" and "JAS" in serial and number >= 2041: + self.result.is_skipped("Device not exposed") + return + + # Because each of the if checks above will return if taken, we only run the long + # check if we get this far + for entry in command_output["details"]["components"]: + if entry["name"] == "FixedSystemvrm1": + if int(entry["version"]) < 7: + self.result.is_failure("Device is exposed to FN72") + else: + self.result.is_success("FN72 is mitigated") + return + # We should never hit this point + self.result.is_error(message="Error in running test - FixedSystemvrm1 not found") + return diff --git a/anta/tests/greent.py b/anta/tests/greent.py new file mode 100644 index 0000000..26271cd --- /dev/null +++ b/anta/tests/greent.py @@ -0,0 +1,60 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to GreenT (Postcard Telemetry) in EOS +""" +from __future__ import annotations + +from anta.models import AntaCommand, AntaTest + + +class VerifyGreenTCounters(AntaTest): + """ + Verifies whether GRE packets are sent. + + Expected Results: + * success: if >0 gre packets are sent + * failure: if no gre packets are sent + """ + + name = "VerifyGreenTCounters" + description = "Verifies if the greent counters are incremented." + categories = ["greent"] + commands = [AntaCommand(command="show monitor telemetry postcard counters")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + + if command_output["grePktSent"] > 0: + self.result.is_success() + else: + self.result.is_failure("GRE packets are not sent") + + +class VerifyGreenT(AntaTest): + """ + Verifies whether GreenT policy is created. + + Expected Results: + * success: if there exists any policy other than "default" policy. + * failure: if no policy is created. + """ + + name = "VerifyGreenT" + description = "Verifies whether greent policy is created." + categories = ["greent"] + commands = [AntaCommand(command="show monitor telemetry postcard policy profile")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + + out = [f"{i} policy is created" for i in command_output["profiles"].keys() if "default" not in i] + + if len(out) > 0: + for i in out: + self.result.is_success(f"{i} policy is created") + else: + self.result.is_failure("policy is not created") diff --git a/anta/tests/hardware.py b/anta/tests/hardware.py new file mode 100644 index 0000000..0a149f2 --- /dev/null +++ b/anta/tests/hardware.py @@ -0,0 +1,220 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to the hardware or environment +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +# Need to keep List for pydantic in python 3.8 +from typing import List + +from anta.decorators import skip_on_platforms +from anta.models import AntaCommand, AntaTest + + +class VerifyTransceiversManufacturers(AntaTest): + """ + This test verifies if all the transceivers come from approved manufacturers. + + Expected Results: + * success: The test will pass if all transceivers are from approved manufacturers. + * failure: The test will fail if some transceivers are from unapproved manufacturers. + """ + + name = "VerifyTransceiversManufacturers" + description = "Verifies if all transceivers come from approved manufacturers." + categories = ["hardware"] + commands = [AntaCommand(command="show inventory", ofmt="json")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + manufacturers: List[str] + """List of approved transceivers manufacturers""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + wrong_manufacturers = { + interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers + } + if not wrong_manufacturers: + self.result.is_success() + else: + self.result.is_failure(f"Some transceivers are from unapproved manufacturers: {wrong_manufacturers}") + + +class VerifyTemperature(AntaTest): + """ + This test verifies if the device temperature is within acceptable limits. + + Expected Results: + * success: The test will pass if the device temperature is currently OK: 'temperatureOk'. + * failure: The test will fail if the device temperature is NOT OK. + """ + + name = "VerifyTemperature" + description = "Verifies the device temperature." + categories = ["hardware"] + commands = [AntaCommand(command="show system environment temperature", ofmt="json")] + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else "" + if temperature_status == "temperatureOk": + self.result.is_success() + else: + self.result.is_failure(f"Device temperature exceeds acceptable limits. Current system status: '{temperature_status}'") + + +class VerifyTransceiversTemperature(AntaTest): + """ + This test verifies if all the transceivers are operating at an acceptable temperature. + + Expected Results: + * success: The test will pass if all transceivers status are OK: 'ok'. + * failure: The test will fail if some transceivers are NOT OK. + """ + + name = "VerifyTransceiversTemperature" + description = "Verifies the transceivers temperature." + categories = ["hardware"] + commands = [AntaCommand(command="show system environment temperature transceiver", ofmt="json")] + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + sensors = command_output["tempSensors"] if "tempSensors" in command_output.keys() else "" + wrong_sensors = { + sensor["name"]: { + "hwStatus": sensor["hwStatus"], + "alertCount": sensor["alertCount"], + } + for sensor in sensors + if sensor["hwStatus"] != "ok" or sensor["alertCount"] != 0 + } + if not wrong_sensors: + self.result.is_success() + else: + self.result.is_failure(f"The following sensors are operating outside the acceptable temperature range or have raised alerts: {wrong_sensors}") + + +class VerifyEnvironmentSystemCooling(AntaTest): + """ + This test verifies the device's system cooling. + + Expected Results: + * success: The test will pass if the system cooling status is OK: 'coolingOk'. + * failure: The test will fail if the system cooling status is NOT OK. + """ + + name = "VerifyEnvironmentSystemCooling" + description = "Verifies the system cooling status." + categories = ["hardware"] + commands = [AntaCommand(command="show system environment cooling", ofmt="json")] + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + sys_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else "" + self.result.is_success() + if sys_status != "coolingOk": + self.result.is_failure(f"Device system cooling is not OK: '{sys_status}'") + + +class VerifyEnvironmentCooling(AntaTest): + """ + This test verifies the fans status. + + Expected Results: + * success: The test will pass if the fans status are within the accepted states list. + * failure: The test will fail if some fans status is not within the accepted states list. + """ + + name = "VerifyEnvironmentCooling" + description = "Verifies the status of power supply fans and all fan trays." + categories = ["hardware"] + commands = [AntaCommand(command="show system environment cooling", ofmt="json")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + states: List[str] + """Accepted states list for fan status""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + self.result.is_success() + # First go through power supplies fans + for power_supply in command_output.get("powerSupplySlots", []): + for fan in power_supply.get("fans", []): + if (state := fan["status"]) not in self.inputs.states: + self.result.is_failure(f"Fan {fan['label']} on PowerSupply {power_supply['label']} is: '{state}'") + # Then go through fan trays + for fan_tray in command_output.get("fanTraySlots", []): + for fan in fan_tray.get("fans", []): + if (state := fan["status"]) not in self.inputs.states: + self.result.is_failure(f"Fan {fan['label']} on Fan Tray {fan_tray['label']} is: '{state}'") + + +class VerifyEnvironmentPower(AntaTest): + """ + This test verifies the power supplies status. + + Expected Results: + * success: The test will pass if the power supplies status are within the accepted states list. + * failure: The test will fail if some power supplies status is not within the accepted states list. + """ + + name = "VerifyEnvironmentPower" + description = "Verifies the power supplies status." + categories = ["hardware"] + commands = [AntaCommand(command="show system environment power", ofmt="json")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + states: List[str] + """Accepted states list for power supplies status""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + power_supplies = command_output["powerSupplies"] if "powerSupplies" in command_output.keys() else "{}" + wrong_power_supplies = { + powersupply: {"state": value["state"]} for powersupply, value in dict(power_supplies).items() if value["state"] not in self.inputs.states + } + if not wrong_power_supplies: + self.result.is_success() + else: + self.result.is_failure(f"The following power supplies status are not in the accepted states list: {wrong_power_supplies}") + + +class VerifyAdverseDrops(AntaTest): + """ + This test verifies if there are no adverse drops on DCS7280E and DCS7500E. + + Expected Results: + * success: The test will pass if there are no adverse drops. + * failure: The test will fail if there are adverse drops. + """ + + name = "VerifyAdverseDrops" + description = "Verifies there are no adverse drops on DCS7280E and DCS7500E" + categories = ["hardware"] + commands = [AntaCommand(command="show hardware counter drop", ofmt="json")] + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + total_adverse_drop = command_output["totalAdverseDrops"] if "totalAdverseDrops" in command_output.keys() else "" + if total_adverse_drop == 0: + self.result.is_success() + else: + self.result.is_failure(f"Device totalAdverseDrops counter is: '{total_adverse_drop}'") diff --git a/anta/tests/interfaces.py b/anta/tests/interfaces.py new file mode 100644 index 0000000..4c21a29 --- /dev/null +++ b/anta/tests/interfaces.py @@ -0,0 +1,599 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to the device interfaces +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +import re +from ipaddress import IPv4Network + +# Need to keep Dict and List for pydantic in python 3.8 +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, conint +from pydantic_extra_types.mac_address import MacAddress + +from anta.custom_types import Interface +from anta.decorators import skip_on_platforms +from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools.get_item import get_item +from anta.tools.get_value import get_value + + +class VerifyInterfaceUtilization(AntaTest): + """ + Verifies interfaces utilization is below 75%. + + Expected Results: + * success: The test will pass if all interfaces have a usage below 75%. + * failure: The test will fail if one or more interfaces have a usage above 75%. + """ + + name = "VerifyInterfaceUtilization" + description = "Verifies that all interfaces have a usage below 75%." + categories = ["interfaces"] + # TODO - move from text to json if possible + commands = [AntaCommand(command="show interfaces counters rates", ofmt="text")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].text_output + wrong_interfaces = {} + for line in command_output.split("\n")[1:]: + if len(line) > 0: + if line.split()[-5] == "-" or line.split()[-2] == "-": + pass + elif float(line.split()[-5].replace("%", "")) > 75.0: + wrong_interfaces[line.split()[0]] = line.split()[-5] + elif float(line.split()[-2].replace("%", "")) > 75.0: + wrong_interfaces[line.split()[0]] = line.split()[-2] + if not wrong_interfaces: + self.result.is_success() + else: + self.result.is_failure(f"The following interfaces have a usage > 75%: {wrong_interfaces}") + + +class VerifyInterfaceErrors(AntaTest): + """ + This test verifies that interfaces error counters are equal to zero. + + Expected Results: + * success: The test will pass if all interfaces have error counters equal to zero. + * failure: The test will fail if one or more interfaces have non-zero error counters. + """ + + name = "VerifyInterfaceErrors" + description = "Verifies there are no interface error counters." + categories = ["interfaces"] + commands = [AntaCommand(command="show interfaces counters errors")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + wrong_interfaces: list[dict[str, dict[str, int]]] = [] + for interface, counters in command_output["interfaceErrorCounters"].items(): + if any(value > 0 for value in counters.values()) and all(interface not in wrong_interface for wrong_interface in wrong_interfaces): + wrong_interfaces.append({interface: counters}) + if not wrong_interfaces: + self.result.is_success() + else: + self.result.is_failure(f"The following interface(s) have non-zero error counters: {wrong_interfaces}") + + +class VerifyInterfaceDiscards(AntaTest): + """ + Verifies interfaces packet discard counters are equal to zero. + + Expected Results: + * success: The test will pass if all interfaces have discard counters equal to zero. + * failure: The test will fail if one or more interfaces have non-zero discard counters. + """ + + name = "VerifyInterfaceDiscards" + description = "Verifies there are no interface discard counters." + categories = ["interfaces"] + commands = [AntaCommand(command="show interfaces counters discards")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + wrong_interfaces: list[dict[str, dict[str, int]]] = [] + for interface, outer_v in command_output["interfaces"].items(): + wrong_interfaces.extend({interface: outer_v} for counter, value in outer_v.items() if value > 0) + if not wrong_interfaces: + self.result.is_success() + else: + self.result.is_failure(f"The following interfaces have non 0 discard counter(s): {wrong_interfaces}") + + +class VerifyInterfaceErrDisabled(AntaTest): + """ + Verifies there are no interfaces in errdisabled state. + + Expected Results: + * success: The test will pass if there are no interfaces in errdisabled state. + * failure: The test will fail if there is at least one interface in errdisabled state. + """ + + name = "VerifyInterfaceErrDisabled" + description = "Verifies there are no interfaces in the errdisabled state." + categories = ["interfaces"] + commands = [AntaCommand(command="show interfaces status")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + errdisabled_interfaces = [interface for interface, value in command_output["interfaceStatuses"].items() if value["linkStatus"] == "errdisabled"] + if errdisabled_interfaces: + self.result.is_failure(f"The following interfaces are in error disabled state: {errdisabled_interfaces}") + else: + self.result.is_success() + + +class VerifyInterfacesStatus(AntaTest): + """ + This test verifies if the provided list of interfaces are all in the expected state. + + - If line protocol status is provided, prioritize checking against both status and line protocol status + - If line protocol status is not provided and interface status is "up", expect both status and line protocol to be "up" + - If interface status is not "up", check only the interface status without considering line protocol status + + Expected Results: + * success: The test will pass if the provided interfaces are all in the expected state. + * failure: The test will fail if any interface is not in the expected state. + """ + + name = "VerifyInterfacesStatus" + description = "Verifies the status of the provided interfaces." + categories = ["interfaces"] + commands = [AntaCommand(command="show interfaces description")] + + class Input(AntaTest.Input): + """Input for the VerifyInterfacesStatus test.""" + + interfaces: List[InterfaceState] + """List of interfaces to validate with the expected state.""" + + class InterfaceState(BaseModel): + """Model for the interface state input.""" + + name: Interface + """Interface to validate.""" + status: Literal["up", "down", "adminDown"] + """Expected status of the interface.""" + line_protocol_status: Optional[Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"]] = None + """Expected line protocol status of the interface.""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + + self.result.is_success() + + intf_not_configured = [] + intf_wrong_state = [] + + for interface in self.inputs.interfaces: + if (intf_status := get_value(command_output["interfaceDescriptions"], interface.name, separator="..")) is None: + intf_not_configured.append(interface.name) + continue + + status = "up" if intf_status["interfaceStatus"] in {"up", "connected"} else intf_status["interfaceStatus"] + proto = "up" if intf_status["lineProtocolStatus"] in {"up", "connected"} else intf_status["lineProtocolStatus"] + + # If line protocol status is provided, prioritize checking against both status and line protocol status + if interface.line_protocol_status: + if interface.status != status or interface.line_protocol_status != proto: + intf_wrong_state.append(f"{interface.name} is {status}/{proto}") + + # If line protocol status is not provided and interface status is "up", expect both status and proto to be "up" + # If interface status is not "up", check only the interface status without considering line protocol status + elif (interface.status == "up" and (status != "up" or proto != "up")) or (interface.status != status): + intf_wrong_state.append(f"{interface.name} is {status}/{proto}") + + if intf_not_configured: + self.result.is_failure(f"The following interface(s) are not configured: {intf_not_configured}") + + if intf_wrong_state: + self.result.is_failure(f"The following interface(s) are not in the expected state: {intf_wrong_state}") + + +class VerifyStormControlDrops(AntaTest): + """ + Verifies the device did not drop packets due its to storm-control configuration. + + Expected Results: + * success: The test will pass if there are no storm-control drop counters. + * failure: The test will fail if there is at least one storm-control drop counter. + """ + + name = "VerifyStormControlDrops" + description = "Verifies there are no interface storm-control drop counters." + categories = ["interfaces"] + commands = [AntaCommand(command="show storm-control")] + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + storm_controlled_interfaces: dict[str, dict[str, Any]] = {} + for interface, interface_dict in command_output["interfaces"].items(): + for traffic_type, traffic_type_dict in interface_dict["trafficTypes"].items(): + if "drop" in traffic_type_dict and traffic_type_dict["drop"] != 0: + storm_controlled_interface_dict = storm_controlled_interfaces.setdefault(interface, {}) + storm_controlled_interface_dict.update({traffic_type: traffic_type_dict["drop"]}) + if not storm_controlled_interfaces: + self.result.is_success() + else: + self.result.is_failure(f"The following interfaces have none 0 storm-control drop counters {storm_controlled_interfaces}") + + +class VerifyPortChannels(AntaTest): + """ + Verifies there are no inactive ports in all port channels. + + Expected Results: + * success: The test will pass if there are no inactive ports in all port channels. + * failure: The test will fail if there is at least one inactive port in a port channel. + """ + + name = "VerifyPortChannels" + description = "Verifies there are no inactive ports in all port channels." + categories = ["interfaces"] + commands = [AntaCommand(command="show port-channel")] + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + po_with_invactive_ports: list[dict[str, str]] = [] + for portchannel, portchannel_dict in command_output["portChannels"].items(): + if len(portchannel_dict["inactivePorts"]) != 0: + po_with_invactive_ports.extend({portchannel: portchannel_dict["inactivePorts"]}) + if not po_with_invactive_ports: + self.result.is_success() + else: + self.result.is_failure(f"The following port-channels have inactive port(s): {po_with_invactive_ports}") + + +class VerifyIllegalLACP(AntaTest): + """ + Verifies there are no illegal LACP packets received. + + Expected Results: + * success: The test will pass if there are no illegal LACP packets received. + * failure: The test will fail if there is at least one illegal LACP packet received. + """ + + name = "VerifyIllegalLACP" + description = "Verifies there are no illegal LACP packets in all port channels." + categories = ["interfaces"] + commands = [AntaCommand(command="show lacp counters all-ports")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + po_with_illegal_lacp: list[dict[str, dict[str, int]]] = [] + for portchannel, portchannel_dict in command_output["portChannels"].items(): + po_with_illegal_lacp.extend( + {portchannel: interface} for interface, interface_dict in portchannel_dict["interfaces"].items() if interface_dict["illegalRxCount"] != 0 + ) + if not po_with_illegal_lacp: + self.result.is_success() + else: + self.result.is_failure("The following port-channels have recieved illegal lacp packets on the " f"following ports: {po_with_illegal_lacp}") + + +class VerifyLoopbackCount(AntaTest): + """ + Verifies that the device has the expected number of loopback interfaces and all are operational. + + Expected Results: + * success: The test will pass if the device has the correct number of loopback interfaces and none are down. + * failure: The test will fail if the loopback interface count is incorrect or any are non-operational. + """ + + name = "VerifyLoopbackCount" + description = "Verifies the number of loopback interfaces and their status." + categories = ["interfaces"] + commands = [AntaCommand(command="show ip interface brief")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + number: conint(ge=0) # type: ignore + """Number of loopback interfaces expected to be present""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + loopback_count = 0 + down_loopback_interfaces = [] + for interface in command_output["interfaces"]: + interface_dict = command_output["interfaces"][interface] + if "Loopback" in interface: + loopback_count += 1 + if not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"): + down_loopback_interfaces.append(interface) + if loopback_count == self.inputs.number and len(down_loopback_interfaces) == 0: + self.result.is_success() + else: + self.result.is_failure() + if loopback_count != self.inputs.number: + self.result.is_failure(f"Found {loopback_count} Loopbacks when expecting {self.inputs.number}") + elif len(down_loopback_interfaces) != 0: + self.result.is_failure(f"The following Loopbacks are not up: {down_loopback_interfaces}") + + +class VerifySVI(AntaTest): + """ + Verifies the status of all SVIs. + + Expected Results: + * success: The test will pass if all SVIs are up. + * failure: The test will fail if one or many SVIs are not up. + """ + + name = "VerifySVI" + description = "Verifies the status of all SVIs." + categories = ["interfaces"] + commands = [AntaCommand(command="show ip interface brief")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + down_svis = [] + for interface in command_output["interfaces"]: + interface_dict = command_output["interfaces"][interface] + if "Vlan" in interface: + if not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"): + down_svis.append(interface) + if len(down_svis) == 0: + self.result.is_success() + else: + self.result.is_failure(f"The following SVIs are not up: {down_svis}") + + +class VerifyL3MTU(AntaTest): + """ + Verifies the global layer 3 Maximum Transfer Unit (MTU) for all L3 interfaces. + + Test that L3 interfaces are configured with the correct MTU. It supports Ethernet, Port Channel and VLAN interfaces. + You can define a global MTU to check and also an MTU per interface and also ignored some interfaces. + + Expected Results: + * success: The test will pass if all layer 3 interfaces have the proper MTU configured. + * failure: The test will fail if one or many layer 3 interfaces have the wrong MTU configured. + """ + + name = "VerifyL3MTU" + description = "Verifies the global L3 MTU of all L3 interfaces." + categories = ["interfaces"] + commands = [AntaCommand(command="show interfaces")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + mtu: int = 1500 + """Default MTU we should have configured on all non-excluded interfaces""" + ignored_interfaces: List[str] = ["Management", "Loopback", "Vxlan", "Tunnel"] + """A list of L3 interfaces to ignore""" + specific_mtu: List[Dict[str, int]] = [] + """A list of dictionary of L3 interfaces with their specific MTU configured""" + + @AntaTest.anta_test + def test(self) -> None: + # Parameter to save incorrect interface settings + wrong_l3mtu_intf: list[dict[str, int]] = [] + command_output = self.instance_commands[0].json_output + # Set list of interfaces with specific settings + specific_interfaces: list[str] = [] + if self.inputs.specific_mtu: + for d in self.inputs.specific_mtu: + specific_interfaces.extend(d) + for interface, values in command_output["interfaces"].items(): + if re.findall(r"[a-z]+", interface, re.IGNORECASE)[0] not in self.inputs.ignored_interfaces and values["forwardingModel"] == "routed": + if interface in specific_interfaces: + wrong_l3mtu_intf.extend({interface: values["mtu"]} for custom_data in self.inputs.specific_mtu if values["mtu"] != custom_data[interface]) + # Comparison with generic setting + elif values["mtu"] != self.inputs.mtu: + wrong_l3mtu_intf.append({interface: values["mtu"]}) + if wrong_l3mtu_intf: + self.result.is_failure(f"Some interfaces do not have correct MTU configured:\n{wrong_l3mtu_intf}") + else: + self.result.is_success() + + +class VerifyIPProxyARP(AntaTest): + """ + Verifies if Proxy-ARP is enabled for the provided list of interface(s). + + Expected Results: + * success: The test will pass if Proxy-ARP is enabled on the specified interface(s). + * failure: The test will fail if Proxy-ARP is disabled on the specified interface(s). + """ + + name = "VerifyIPProxyARP" + description = "Verifies if Proxy ARP is enabled." + categories = ["interfaces"] + commands = [AntaTemplate(template="show ip interface {intf}")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + interfaces: List[str] + """list of interfaces to be tested""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(intf=intf) for intf in self.inputs.interfaces] + + @AntaTest.anta_test + def test(self) -> None: + disabled_intf = [] + for command in self.instance_commands: + if "intf" in command.params: + intf = command.params["intf"] + if not command.json_output["interfaces"][intf]["proxyArp"]: + disabled_intf.append(intf) + if disabled_intf: + self.result.is_failure(f"The following interface(s) have Proxy-ARP disabled: {disabled_intf}") + else: + self.result.is_success() + + +class VerifyL2MTU(AntaTest): + """ + Verifies the global layer 2 Maximum Transfer Unit (MTU) for all L2 interfaces. + + Test that L2 interfaces are configured with the correct MTU. It supports Ethernet, Port Channel and VLAN interfaces. + You can define a global MTU to check and also an MTU per interface and also ignored some interfaces. + + Expected Results: + * success: The test will pass if all layer 2 interfaces have the proper MTU configured. + * failure: The test will fail if one or many layer 2 interfaces have the wrong MTU configured. + """ + + name = "VerifyL2MTU" + description = "Verifies the global L2 MTU of all L2 interfaces." + categories = ["interfaces"] + commands = [AntaCommand(command="show interfaces")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + mtu: int = 9214 + """Default MTU we should have configured on all non-excluded interfaces""" + ignored_interfaces: List[str] = ["Management", "Loopback", "Vxlan", "Tunnel"] + """A list of L2 interfaces to ignore""" + specific_mtu: List[Dict[str, int]] = [] + """A list of dictionary of L2 interfaces with their specific MTU configured""" + + @AntaTest.anta_test + def test(self) -> None: + # Parameter to save incorrect interface settings + wrong_l2mtu_intf: list[dict[str, int]] = [] + command_output = self.instance_commands[0].json_output + # Set list of interfaces with specific settings + specific_interfaces: list[str] = [] + if self.inputs.specific_mtu: + for d in self.inputs.specific_mtu: + specific_interfaces.extend(d) + for interface, values in command_output["interfaces"].items(): + if re.findall(r"[a-z]+", interface, re.IGNORECASE)[0] not in self.inputs.ignored_interfaces and values["forwardingModel"] == "bridged": + if interface in specific_interfaces: + wrong_l2mtu_intf.extend({interface: values["mtu"]} for custom_data in self.inputs.specific_mtu if values["mtu"] != custom_data[interface]) + # Comparison with generic setting + elif values["mtu"] != self.inputs.mtu: + wrong_l2mtu_intf.append({interface: values["mtu"]}) + if wrong_l2mtu_intf: + self.result.is_failure(f"Some L2 interfaces do not have correct MTU configured:\n{wrong_l2mtu_intf}") + else: + self.result.is_success() + + +class VerifyInterfaceIPv4(AntaTest): + """ + Verifies if an interface is configured with a correct primary and list of optional secondary IPv4 addresses. + + Expected Results: + * success: The test will pass if an interface is configured with a correct primary and secondary IPv4 address. + * failure: The test will fail if an interface is not found or the primary and secondary IPv4 addresses do not match with the input. + """ + + name = "VerifyInterfaceIPv4" + description = "Verifies the interface IPv4 addresses." + categories = ["interfaces"] + commands = [AntaTemplate(template="show ip interface {interface}")] + + class Input(AntaTest.Input): + """Inputs for the VerifyInterfaceIPv4 test.""" + + interfaces: List[InterfaceDetail] + """list of interfaces to be tested""" + + class InterfaceDetail(BaseModel): + """Detail of an interface""" + + name: Interface + """Name of the interface""" + primary_ip: IPv4Network + """Primary IPv4 address with subnet on interface""" + secondary_ips: Optional[List[IPv4Network]] = None + """Optional list of secondary IPv4 addresses with subnet on interface""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + # Render the template for each interface + return [ + template.render(interface=interface.name, primary_ip=interface.primary_ip, secondary_ips=interface.secondary_ips) for interface in self.inputs.interfaces + ] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + for command in self.instance_commands: + intf = command.params["interface"] + input_primary_ip = str(command.params["primary_ip"]) + failed_messages = [] + + # Check if the interface has an IP address configured + if not (interface_output := get_value(command.json_output, f"interfaces.{intf}.interfaceAddress")): + self.result.is_failure(f"For interface `{intf}`, IP address is not configured.") + continue + + primary_ip = get_value(interface_output, "primaryIp") + + # Combine IP address and subnet for primary IP + actual_primary_ip = f"{primary_ip['address']}/{primary_ip['maskLen']}" + + # Check if the primary IP address matches the input + if actual_primary_ip != input_primary_ip: + failed_messages.append(f"The expected primary IP address is `{input_primary_ip}`, but the actual primary IP address is `{actual_primary_ip}`.") + + if command.params["secondary_ips"] is not None: + input_secondary_ips = sorted([str(network) for network in command.params["secondary_ips"]]) + secondary_ips = get_value(interface_output, "secondaryIpsOrderedList") + + # Combine IP address and subnet for secondary IPs + actual_secondary_ips = sorted([f"{secondary_ip['address']}/{secondary_ip['maskLen']}" for secondary_ip in secondary_ips]) + + # Check if the secondary IP address is configured + if not actual_secondary_ips: + failed_messages.append( + f"The expected secondary IP addresses are `{input_secondary_ips}`, but the actual secondary IP address is not configured." + ) + + # Check if the secondary IP addresses match the input + elif actual_secondary_ips != input_secondary_ips: + failed_messages.append( + f"The expected secondary IP addresses are `{input_secondary_ips}`, but the actual secondary IP addresses are `{actual_secondary_ips}`." + ) + + if failed_messages: + self.result.is_failure(f"For interface `{intf}`, " + " ".join(failed_messages)) + + +class VerifyIpVirtualRouterMac(AntaTest): + """ + Verifies the IP virtual router MAC address. + + Expected Results: + * success: The test will pass if the IP virtual router MAC address matches the input. + * failure: The test will fail if the IP virtual router MAC address does not match the input. + """ + + name = "VerifyIpVirtualRouterMac" + description = "Verifies the IP virtual router MAC address." + categories = ["interfaces"] + commands = [AntaCommand(command="show ip virtual-router")] + + class Input(AntaTest.Input): + """Inputs for the VerifyIpVirtualRouterMac test.""" + + mac_address: MacAddress + """IP virtual router MAC address""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output["virtualMacs"] + mac_address_found = get_item(command_output, "macAddress", self.inputs.mac_address) + + if mac_address_found is None: + self.result.is_failure(f"IP virtual router MAC address `{self.inputs.mac_address}` is not configured.") + else: + self.result.is_success() diff --git a/anta/tests/lanz.py b/anta/tests/lanz.py new file mode 100644 index 0000000..dcbd373 --- /dev/null +++ b/anta/tests/lanz.py @@ -0,0 +1,34 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to LANZ +""" + +from __future__ import annotations + +from anta.models import AntaCommand, AntaTest + + +class VerifyLANZ(AntaTest): + """ + Verifies if LANZ is enabled + + Expected results: + * success: the test will pass if lanz is enabled + * failure: the test will fail if lanz is disabled + """ + + name = "VerifyLANZ" + description = "Verifies if LANZ is enabled." + categories = ["lanz"] + commands = [AntaCommand(command="show queue-monitor length status")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + + if command_output["lanzEnabled"] is not True: + self.result.is_failure("LANZ is not enabled") + else: + self.result.is_success("LANZ is enabled") diff --git a/anta/tests/logging.py b/anta/tests/logging.py new file mode 100644 index 0000000..ef56786 --- /dev/null +++ b/anta/tests/logging.py @@ -0,0 +1,279 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to the EOS various logging settings + +NOTE: 'show logging' does not support json output yet +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +import logging +import re +from ipaddress import IPv4Address + +# Need to keep List for pydantic in python 3.8 +from typing import List + +from anta.models import AntaCommand, AntaTest + + +def _get_logging_states(logger: logging.Logger, command_output: str) -> str: + """ + Parse "show logging" output and gets operational logging states used + in the tests in this module. + + Args: + command_output: The 'show logging' output + """ + log_states = command_output.partition("\n\nExternal configuration:")[0] + logger.debug(f"Device logging states:\n{log_states}") + return log_states + + +class VerifyLoggingPersistent(AntaTest): + """ + Verifies if logging persistent is enabled and logs are saved in flash. + + Expected Results: + * success: The test will pass if logging persistent is enabled and logs are in flash. + * failure: The test will fail if logging persistent is disabled or no logs are saved in flash. + """ + + name = "VerifyLoggingPersistent" + description = "Verifies if logging persistent is enabled and logs are saved in flash." + categories = ["logging"] + commands = [ + AntaCommand(command="show logging", ofmt="text"), + AntaCommand(command="dir flash:/persist/messages", ofmt="text"), + ] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + log_output = self.instance_commands[0].text_output + dir_flash_output = self.instance_commands[1].text_output + if "Persistent logging: disabled" in _get_logging_states(self.logger, log_output): + self.result.is_failure("Persistent logging is disabled") + return + pattern = r"-rw-\s+(\d+)" + persist_logs = re.search(pattern, dir_flash_output) + if not persist_logs or int(persist_logs.group(1)) == 0: + self.result.is_failure("No persistent logs are saved in flash") + + +class VerifyLoggingSourceIntf(AntaTest): + """ + Verifies logging source-interface for a specified VRF. + + Expected Results: + * success: The test will pass if the provided logging source-interface is configured in the specified VRF. + * failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF. + """ + + name = "VerifyLoggingSourceInt" + description = "Verifies logging source-interface for a specified VRF." + categories = ["logging"] + commands = [AntaCommand(command="show logging", ofmt="text")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + interface: str + """Source-interface to use as source IP of log messages""" + vrf: str = "default" + """The name of the VRF to transport log messages""" + + @AntaTest.anta_test + def test(self) -> None: + output = self.instance_commands[0].text_output + pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}" + if re.search(pattern, _get_logging_states(self.logger, output)): + self.result.is_success() + else: + self.result.is_failure(f"Source-interface '{self.inputs.interface}' is not configured in VRF {self.inputs.vrf}") + + +class VerifyLoggingHosts(AntaTest): + """ + Verifies logging hosts (syslog servers) for a specified VRF. + + Expected Results: + * success: The test will pass if the provided syslog servers are configured in the specified VRF. + * failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF. + """ + + name = "VerifyLoggingHosts" + description = "Verifies logging hosts (syslog servers) for a specified VRF." + categories = ["logging"] + commands = [AntaCommand(command="show logging", ofmt="text")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + hosts: List[IPv4Address] + """List of hosts (syslog servers) IP addresses""" + vrf: str = "default" + """The name of the VRF to transport log messages""" + + @AntaTest.anta_test + def test(self) -> None: + output = self.instance_commands[0].text_output + not_configured = [] + for host in self.inputs.hosts: + pattern = rf"Logging to '{str(host)}'.*VRF {self.inputs.vrf}" + if not re.search(pattern, _get_logging_states(self.logger, output)): + not_configured.append(str(host)) + + if not not_configured: + self.result.is_success() + else: + self.result.is_failure(f"Syslog servers {not_configured} are not configured in VRF {self.inputs.vrf}") + + +class VerifyLoggingLogsGeneration(AntaTest): + """ + Verifies if logs are generated. + + Expected Results: + * success: The test will pass if logs are generated. + * failure: The test will fail if logs are NOT generated. + """ + + name = "VerifyLoggingLogsGeneration" + description = "Verifies if logs are generated." + categories = ["logging"] + commands = [ + AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation"), + AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), + ] + + @AntaTest.anta_test + def test(self) -> None: + log_pattern = r"ANTA VerifyLoggingLogsGeneration validation" + output = self.instance_commands[1].text_output + lines = output.strip().split("\n")[::-1] + for line in lines: + if re.search(log_pattern, line): + self.result.is_success() + return + self.result.is_failure("Logs are not generated") + + +class VerifyLoggingHostname(AntaTest): + """ + Verifies if logs are generated with the device FQDN. + + Expected Results: + * success: The test will pass if logs are generated with the device FQDN. + * failure: The test will fail if logs are NOT generated with the device FQDN. + """ + + name = "VerifyLoggingHostname" + description = "Verifies if logs are generated with the device FQDN." + categories = ["logging"] + commands = [ + AntaCommand(command="show hostname"), + AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation"), + AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), + ] + + @AntaTest.anta_test + def test(self) -> None: + output_hostname = self.instance_commands[0].json_output + output_logging = self.instance_commands[2].text_output + fqdn = output_hostname["fqdn"] + lines = output_logging.strip().split("\n")[::-1] + log_pattern = r"ANTA VerifyLoggingHostname validation" + last_line_with_pattern = "" + for line in lines: + if re.search(log_pattern, line): + last_line_with_pattern = line + break + if fqdn in last_line_with_pattern: + self.result.is_success() + else: + self.result.is_failure("Logs are not generated with the device FQDN") + + +class VerifyLoggingTimestamp(AntaTest): + """ + Verifies if logs are generated with the approprate timestamp. + + Expected Results: + * success: The test will pass if logs are generated with the appropriated timestamp. + * failure: The test will fail if logs are NOT generated with the appropriated timestamp. + """ + + name = "VerifyLoggingTimestamp" + description = "Verifies if logs are generated with the appropriate timestamp." + categories = ["logging"] + commands = [ + AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation"), + AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), + ] + + @AntaTest.anta_test + def test(self) -> None: + log_pattern = r"ANTA VerifyLoggingTimestamp validation" + timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}" + output = self.instance_commands[1].text_output + lines = output.strip().split("\n")[::-1] + last_line_with_pattern = "" + for line in lines: + if re.search(log_pattern, line): + last_line_with_pattern = line + break + if re.search(timestamp_pattern, last_line_with_pattern): + self.result.is_success() + else: + self.result.is_failure("Logs are not generated with the appropriate timestamp format") + + +class VerifyLoggingAccounting(AntaTest): + """ + Verifies if AAA accounting logs are generated. + + Expected Results: + * success: The test will pass if AAA accounting logs are generated. + * failure: The test will fail if AAA accounting logs are NOT generated. + """ + + name = "VerifyLoggingAccounting" + description = "Verifies if AAA accounting logs are generated." + categories = ["logging"] + commands = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")] + + @AntaTest.anta_test + def test(self) -> None: + pattern = r"cmd=show aaa accounting logs" + output = self.instance_commands[0].text_output + if re.search(pattern, output): + self.result.is_success() + else: + self.result.is_failure("AAA accounting logs are not generated") + + +class VerifyLoggingErrors(AntaTest): + """ + This test verifies there are no syslog messages with a severity of ERRORS or higher. + + Expected Results: + * success: The test will pass if there are NO syslog messages with a severity of ERRORS or higher. + * failure: The test will fail if ERRORS or higher syslog messages are present. + """ + + name = "VerifyLoggingWarning" + description = "This test verifies there are no syslog messages with a severity of ERRORS or higher." + categories = ["logging"] + commands = [AntaCommand(command="show logging threshold errors", ofmt="text")] + + @AntaTest.anta_test + def test(self) -> None: + """ + Run VerifyLoggingWarning validation + """ + command_output = self.instance_commands[0].text_output + + if len(command_output) == 0: + self.result.is_success() + else: + self.result.is_failure("Device has reported syslog messages with a severity of ERRORS or higher") diff --git a/anta/tests/mlag.py b/anta/tests/mlag.py new file mode 100644 index 0000000..2c2be01 --- /dev/null +++ b/anta/tests/mlag.py @@ -0,0 +1,239 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to Multi-chassis Link Aggregation (MLAG) +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from pydantic import conint + +from anta.custom_types import MlagPriority +from anta.models import AntaCommand, AntaTest +from anta.tools.get_value import get_value + + +class VerifyMlagStatus(AntaTest): + """ + This test verifies the health status of the MLAG configuration. + + Expected Results: + * success: The test will pass if the MLAG state is 'active', negotiation status is 'connected', + peer-link status and local interface status are 'up'. + * failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected', + peer-link status or local interface status are not 'up'. + * skipped: The test will be skipped if MLAG is 'disabled'. + """ + + name = "VerifyMlagStatus" + description = "Verifies the health status of the MLAG configuration." + categories = ["mlag"] + commands = [AntaCommand(command="show mlag", ofmt="json")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if command_output["state"] == "disabled": + self.result.is_skipped("MLAG is disabled") + return + keys_to_verify = ["state", "negStatus", "localIntfStatus", "peerLinkStatus"] + verified_output = {key: get_value(command_output, key) for key in keys_to_verify} + if ( + verified_output["state"] == "active" + and verified_output["negStatus"] == "connected" + and verified_output["localIntfStatus"] == "up" + and verified_output["peerLinkStatus"] == "up" + ): + self.result.is_success() + else: + self.result.is_failure(f"MLAG status is not OK: {verified_output}") + + +class VerifyMlagInterfaces(AntaTest): + """ + This test verifies there are no inactive or active-partial MLAG ports. + + Expected Results: + * success: The test will pass if there are NO inactive or active-partial MLAG ports. + * failure: The test will fail if there are inactive or active-partial MLAG ports. + * skipped: The test will be skipped if MLAG is 'disabled'. + """ + + name = "VerifyMlagInterfaces" + description = "Verifies there are no inactive or active-partial MLAG ports." + categories = ["mlag"] + commands = [AntaCommand(command="show mlag", ofmt="json")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if command_output["state"] == "disabled": + self.result.is_skipped("MLAG is disabled") + return + if command_output["mlagPorts"]["Inactive"] == 0 and command_output["mlagPorts"]["Active-partial"] == 0: + self.result.is_success() + else: + self.result.is_failure(f"MLAG status is not OK: {command_output['mlagPorts']}") + + +class VerifyMlagConfigSanity(AntaTest): + """ + This test verifies there are no MLAG config-sanity inconsistencies. + + Expected Results: + * success: The test will pass if there are NO MLAG config-sanity inconsistencies. + * failure: The test will fail if there are MLAG config-sanity inconsistencies. + * skipped: The test will be skipped if MLAG is 'disabled'. + * error: The test will give an error if 'mlagActive' is not found in the JSON response. + """ + + name = "VerifyMlagConfigSanity" + description = "Verifies there are no MLAG config-sanity inconsistencies." + categories = ["mlag"] + commands = [AntaCommand(command="show mlag config-sanity", ofmt="json")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if (mlag_status := get_value(command_output, "mlagActive")) is None: + self.result.is_error(message="Incorrect JSON response - 'mlagActive' state was not found") + return + if mlag_status is False: + self.result.is_skipped("MLAG is disabled") + return + keys_to_verify = ["globalConfiguration", "interfaceConfiguration"] + verified_output = {key: get_value(command_output, key) for key in keys_to_verify} + if not any(verified_output.values()): + self.result.is_success() + else: + self.result.is_failure(f"MLAG config-sanity returned inconsistencies: {verified_output}") + + +class VerifyMlagReloadDelay(AntaTest): + """ + This test verifies the reload-delay parameters of the MLAG configuration. + + Expected Results: + * success: The test will pass if the reload-delay parameters are configured properly. + * failure: The test will fail if the reload-delay parameters are NOT configured properly. + * skipped: The test will be skipped if MLAG is 'disabled'. + """ + + name = "VerifyMlagReloadDelay" + description = "Verifies the MLAG reload-delay parameters." + categories = ["mlag"] + commands = [AntaCommand(command="show mlag", ofmt="json")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + reload_delay: conint(ge=0) # type: ignore + """Delay (seconds) after reboot until non peer-link ports that are part of an MLAG are enabled""" + reload_delay_non_mlag: conint(ge=0) # type: ignore + """Delay (seconds) after reboot until ports that are not part of an MLAG are enabled""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if command_output["state"] == "disabled": + self.result.is_skipped("MLAG is disabled") + return + keys_to_verify = ["reloadDelay", "reloadDelayNonMlag"] + verified_output = {key: get_value(command_output, key) for key in keys_to_verify} + if verified_output["reloadDelay"] == self.inputs.reload_delay and verified_output["reloadDelayNonMlag"] == self.inputs.reload_delay_non_mlag: + self.result.is_success() + + else: + self.result.is_failure(f"The reload-delay parameters are not configured properly: {verified_output}") + + +class VerifyMlagDualPrimary(AntaTest): + """ + This test verifies the dual-primary detection and its parameters of the MLAG configuration. + + Expected Results: + * success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly. + * failure: The test will fail if the dual-primary detection is NOT enabled or its parameters are NOT configured properly. + * skipped: The test will be skipped if MLAG is 'disabled'. + """ + + name = "VerifyMlagDualPrimary" + description = "Verifies the MLAG dual-primary detection parameters." + categories = ["mlag"] + commands = [AntaCommand(command="show mlag detail", ofmt="json")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + detection_delay: conint(ge=0) # type: ignore + """Delay detection (seconds)""" + errdisabled: bool = False + """Errdisabled all interfaces when dual-primary is detected""" + recovery_delay: conint(ge=0) # type: ignore + """Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled""" + recovery_delay_non_mlag: conint(ge=0) # type: ignore + """Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled""" + + @AntaTest.anta_test + def test(self) -> None: + errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none" + command_output = self.instance_commands[0].json_output + if command_output["state"] == "disabled": + self.result.is_skipped("MLAG is disabled") + return + if command_output["dualPrimaryDetectionState"] == "disabled": + self.result.is_failure("Dual-primary detection is disabled") + return + keys_to_verify = ["detail.dualPrimaryDetectionDelay", "detail.dualPrimaryAction", "dualPrimaryMlagRecoveryDelay", "dualPrimaryNonMlagRecoveryDelay"] + verified_output = {key: get_value(command_output, key) for key in keys_to_verify} + if ( + verified_output["detail.dualPrimaryDetectionDelay"] == self.inputs.detection_delay + and verified_output["detail.dualPrimaryAction"] == errdisabled_action + and verified_output["dualPrimaryMlagRecoveryDelay"] == self.inputs.recovery_delay + and verified_output["dualPrimaryNonMlagRecoveryDelay"] == self.inputs.recovery_delay_non_mlag + ): + self.result.is_success() + else: + self.result.is_failure(f"The dual-primary parameters are not configured properly: {verified_output}") + + +class VerifyMlagPrimaryPriority(AntaTest): + """ + Test class to verify the MLAG (Multi-Chassis Link Aggregation) primary priority. + + Expected Results: + * Success: The test will pass if the MLAG state is set as 'primary' and the priority matches the input. + * Failure: The test will fail if the MLAG state is not 'primary' or the priority doesn't match the input. + * Skipped: The test will be skipped if MLAG is 'disabled'. + """ + + name = "VerifyMlagPrimaryPriority" + description = "Verifies the configuration of the MLAG primary priority." + categories = ["mlag"] + commands = [AntaCommand(command="show mlag detail")] + + class Input(AntaTest.Input): + """Inputs for the VerifyMlagPrimaryPriority test.""" + + primary_priority: MlagPriority + """The expected MLAG primary priority.""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + self.result.is_success() + # Skip the test if MLAG is disabled + if command_output["state"] == "disabled": + self.result.is_skipped("MLAG is disabled") + return + + mlag_state = get_value(command_output, "detail.mlagState") + primary_priority = get_value(command_output, "detail.primaryPriority") + + # Check MLAG state + if mlag_state != "primary": + self.result.is_failure("The device is not set as MLAG primary.") + + # Check primary priority + if primary_priority != self.inputs.primary_priority: + self.result.is_failure( + f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead." + ) diff --git a/anta/tests/multicast.py b/anta/tests/multicast.py new file mode 100644 index 0000000..ecd6ec2 --- /dev/null +++ b/anta/tests/multicast.py @@ -0,0 +1,66 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to multicast +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +# Need to keep Dict for pydantic in python 3.8 +from typing import Dict + +from anta.custom_types import Vlan +from anta.models import AntaCommand, AntaTest + + +class VerifyIGMPSnoopingVlans(AntaTest): + """ + Verifies the IGMP snooping configuration for some VLANs. + """ + + name = "VerifyIGMPSnoopingVlans" + description = "Verifies the IGMP snooping configuration for some VLANs." + categories = ["multicast", "igmp"] + commands = [AntaCommand(command="show ip igmp snooping")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + vlans: Dict[Vlan, bool] + """Dictionary of VLANs with associated IGMP configuration status (True=enabled, False=disabled)""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + self.result.is_success() + for vlan, enabled in self.inputs.vlans.items(): + if str(vlan) not in command_output["vlans"]: + self.result.is_failure(f"Supplied vlan {vlan} is not present on the device.") + continue + + igmp_state = command_output["vlans"][str(vlan)]["igmpSnoopingState"] + if igmp_state != "enabled" if enabled else igmp_state != "disabled": + self.result.is_failure(f"IGMP state for vlan {vlan} is {igmp_state}") + + +class VerifyIGMPSnoopingGlobal(AntaTest): + """ + Verifies the IGMP snooping global configuration. + """ + + name = "VerifyIGMPSnoopingGlobal" + description = "Verifies the IGMP snooping global configuration." + categories = ["multicast", "igmp"] + commands = [AntaCommand(command="show ip igmp snooping")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + enabled: bool + """Expected global IGMP snooping configuration (True=enabled, False=disabled)""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + self.result.is_success() + igmp_state = command_output["igmpSnoopingState"] + if igmp_state != "enabled" if self.inputs.enabled else igmp_state != "disabled": + self.result.is_failure(f"IGMP state is not valid: {igmp_state}") diff --git a/anta/tests/profiles.py b/anta/tests/profiles.py new file mode 100644 index 0000000..a0ed6d7 --- /dev/null +++ b/anta/tests/profiles.py @@ -0,0 +1,62 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to ASIC profiles +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import Literal + +from anta.decorators import skip_on_platforms +from anta.models import AntaCommand, AntaTest + + +class VerifyUnifiedForwardingTableMode(AntaTest): + """ + Verifies the device is using the expected Unified Forwarding Table mode. + """ + + name = "VerifyUnifiedForwardingTableMode" + description = "" + categories = ["profiles"] + commands = [AntaCommand(command="show platform trident forwarding-table partition", ofmt="json")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + mode: Literal[0, 1, 2, 3, 4, "flexible"] + """Expected UFT mode""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if command_output["uftMode"] == str(self.inputs.mode): + self.result.is_success() + else: + self.result.is_failure(f"Device is not running correct UFT mode (expected: {self.inputs.mode} / running: {command_output['uftMode']})") + + +class VerifyTcamProfile(AntaTest): + """ + Verifies the device is using the configured TCAM profile. + """ + + name = "VerifyTcamProfile" + description = "Verify that the assigned TCAM profile is actually running on the device" + categories = ["profiles"] + commands = [AntaCommand(command="show hardware tcam profile", ofmt="json")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + profile: str + """Expected TCAM profile""" + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if command_output["pmfProfiles"]["FixedSystem"]["status"] == command_output["pmfProfiles"]["FixedSystem"]["config"] == self.inputs.profile: + self.result.is_success() + else: + self.result.is_failure(f"Incorrect profile running on device: {command_output['pmfProfiles']['FixedSystem']['status']}") diff --git a/anta/tests/ptp.py b/anta/tests/ptp.py new file mode 100644 index 0000000..9db810d --- /dev/null +++ b/anta/tests/ptp.py @@ -0,0 +1,33 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to PTP (Precision Time Protocol) in EOS +""" +from __future__ import annotations + +from anta.models import AntaCommand, AntaTest + + +class VerifyPtpStatus(AntaTest): + """ + Verifies whether the PTP agent is enabled globally. + + Expected Results: + * success: The test will pass if the PTP agent is enabled globally. + * failure: The test will fail if the PTP agent is enabled globally. + """ + + name = "VerifyPtpStatus" + description = "Verifies if the PTP agent is enabled." + categories = ["ptp"] + commands = [AntaCommand(command="show ptp")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + + if "ptpMode" in command_output.keys(): + self.result.is_success() + else: + self.result.is_failure("PTP agent disabled") diff --git a/anta/tests/routing/__init__.py b/anta/tests/routing/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/anta/tests/routing/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py new file mode 100644 index 0000000..334ac3b --- /dev/null +++ b/anta/tests/routing/bgp.py @@ -0,0 +1,1003 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +BGP test functions +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from ipaddress import IPv4Address, IPv4Network, IPv6Address +from typing import Any, List, Optional, Union, cast + +from pydantic import BaseModel, Field, PositiveInt, model_validator, utils +from pydantic_extra_types.mac_address import MacAddress + +from anta.custom_types import Afi, MultiProtocolCaps, Safi, Vni +from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools.get_item import get_item +from anta.tools.get_value import get_value + +# Need to keep List for pydantic in python 3.8 + + +def _add_bgp_failures(failures: dict[tuple[str, Union[str, None]], dict[str, Any]], afi: Afi, safi: Optional[Safi], vrf: str, issue: Any) -> None: + """ + Add a BGP failure entry to the given `failures` dictionary. + + Note: This function modifies `failures` in-place. + + Parameters: + failures (dict): The dictionary to which the failure will be added. + afi (Afi): The address family identifier. + vrf (str): The VRF name. + safi (Safi, optional): The subsequent address family identifier. + issue (Any): A description of the issue. Can be of any type. + + The `failures` dictionnary will have the following structure: + { + ('afi1', 'safi1'): { + 'afi': 'afi1', + 'safi': 'safi1', + 'vrfs': { + 'vrf1': issue1, + 'vrf2': issue2 + } + }, + ('afi2', None): { + 'afi': 'afi2', + 'vrfs': { + 'vrf1': issue3 + } + } + } + """ + key = (afi, safi) + + if safi: + failure_entry = failures.setdefault(key, {"afi": afi, "safi": safi, "vrfs": {}}) + else: + failure_entry = failures.setdefault(key, {"afi": afi, "vrfs": {}}) + + failure_entry["vrfs"][vrf] = issue + + +def _check_peer_issues(peer_data: Optional[dict[str, Any]]) -> dict[str, Any]: + """ + Check for issues in BGP peer data. + + Parameters: + peer_data (dict, optional): The BGP peer data dictionary nested in the `show bgp <afi> <safi> summary` command. + + Returns: + dict: Dictionary with keys indicating issues or an empty dictionary if no issues. + + Example: + {"peerNotFound": True} + {"peerState": "Idle", "inMsgQueue": 2, "outMsgQueue": 0} + {} + + Raises: + ValueError: If any of the required keys ("peerState", "inMsgQueue", "outMsgQueue") are missing in `peer_data`, i.e. invalid BGP peer data. + """ + if peer_data is None: + return {"peerNotFound": True} + + if any(key not in peer_data for key in ["peerState", "inMsgQueue", "outMsgQueue"]): + raise ValueError("Provided BGP peer data is invalid.") + + if peer_data["peerState"] != "Established" or peer_data["inMsgQueue"] != 0 or peer_data["outMsgQueue"] != 0: + return {"peerState": peer_data["peerState"], "inMsgQueue": peer_data["inMsgQueue"], "outMsgQueue": peer_data["outMsgQueue"]} + + return {} + + +def _add_bgp_routes_failure( + bgp_routes: list[str], bgp_output: dict[str, Any], peer: str, vrf: str, route_type: str = "advertised_routes" +) -> dict[str, dict[str, dict[str, dict[str, list[str]]]]]: + """ + Identifies missing BGP routes and invalid or inactive route entries. + + This function checks the BGP output from the device against the expected routes. + It identifies any missing routes as well as any routes that are invalid or inactive. The results are returned in a dictionary. + + Parameters: + bgp_routes (list[str]): The list of expected routes. + bgp_output (dict[str, Any]): The BGP output from the device. + peer (str): The IP address of the BGP peer. + vrf (str): The name of the VRF for which the routes need to be verified. + route_type (str, optional): The type of BGP routes. Defaults to 'advertised_routes'. + + Returns: + dict[str, dict[str, dict[str, dict[str, list[str]]]]]: A dictionary containing the missing routes and invalid or inactive routes. + """ + + # Prepare the failure routes dictionary + failure_routes: dict[str, dict[str, Any]] = {} + + # Iterate over the expected BGP routes + for route in bgp_routes: + route = str(route) + failure = {"bgp_peers": {peer: {vrf: {route_type: {route: Any}}}}} + + # Check if the route is missing in the BGP output + if route not in bgp_output: + # If missing, add it to the failure routes dictionary + failure["bgp_peers"][peer][vrf][route_type][route] = "Not found" + failure_routes = utils.deep_update(failure_routes, failure) + continue + + # Check if the route is active and valid + is_active = bgp_output[route]["bgpRoutePaths"][0]["routeType"]["valid"] + is_valid = bgp_output[route]["bgpRoutePaths"][0]["routeType"]["active"] + + # If the route is either inactive or invalid, add it to the failure routes dictionary + if not is_active or not is_valid: + failure["bgp_peers"][peer][vrf][route_type][route] = {"valid": is_valid, "active": is_active} + failure_routes = utils.deep_update(failure_routes, failure) + + return failure_routes + + +class VerifyBGPPeerCount(AntaTest): + """ + This test verifies the count of BGP peers for a given address family. + + It supports multiple types of address families (AFI) and subsequent service families (SAFI). + Please refer to the Input class attributes below for details. + + Expected Results: + * success: If the count of BGP peers matches the expected count for each address family and VRF. + * failure: If the count of BGP peers does not match the expected count, or if BGP is not configured for an expected VRF or address family. + """ + + name = "VerifyBGPPeerCount" + description = "Verifies the count of BGP peers." + categories = ["bgp"] + commands = [ + AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}"), + AntaTemplate(template="show bgp {afi} summary"), + ] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + address_families: List[BgpAfi] + """ + List of BGP address families (BgpAfi) + """ + + class BgpAfi(BaseModel): # pylint: disable=missing-class-docstring + afi: Afi + """BGP address family (AFI)""" + safi: Optional[Safi] = None + """Optional BGP subsequent service family (SAFI). + + If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. + """ + vrf: str = "default" + """ + Optional VRF for IPv4 and IPv6. If not provided, it defaults to `default`. + + If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. + """ + num_peers: PositiveInt + """Number of expected BGP peer(s)""" + + @model_validator(mode="after") + def validate_inputs(self: BaseModel) -> BaseModel: + """ + Validate the inputs provided to the BgpAfi class. + + If afi is either ipv4 or ipv6, safi must be provided. + + If afi is not ipv4 or ipv6, safi must not be provided and vrf must be default. + """ + if self.afi in ["ipv4", "ipv6"]: + if self.safi is None: + raise ValueError("'safi' must be provided when afi is ipv4 or ipv6") + elif self.safi is not None: + raise ValueError("'safi' must not be provided when afi is not ipv4 or ipv6") + elif self.vrf != "default": + raise ValueError("'vrf' must be default when afi is not ipv4 or ipv6") + return self + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + commands = [] + for afi in self.inputs.address_families: + if template == VerifyBGPPeerCount.commands[0] and afi.afi in ["ipv4", "ipv6"]: + commands.append(template.render(afi=afi.afi, safi=afi.safi, vrf=afi.vrf, num_peers=afi.num_peers)) + elif template == VerifyBGPPeerCount.commands[1] and afi.afi not in ["ipv4", "ipv6"]: + commands.append(template.render(afi=afi.afi, vrf=afi.vrf, num_peers=afi.num_peers)) + return commands + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + failures: dict[tuple[str, Any], dict[str, Any]] = {} + + for command in self.instance_commands: + peer_count = 0 + command_output = command.json_output + + afi = cast(Afi, command.params.get("afi")) + safi = cast(Optional[Safi], command.params.get("safi")) + afi_vrf = cast(str, command.params.get("vrf")) + num_peers = cast(PositiveInt, command.params.get("num_peers")) + + if not (vrfs := command_output.get("vrfs")): + _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured") + continue + + if afi_vrf == "all": + for vrf_data in vrfs.values(): + peer_count += len(vrf_data["peers"]) + else: + peer_count += len(command_output["vrfs"][afi_vrf]["peers"]) + + if peer_count != num_peers: + _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue=f"Expected: {num_peers}, Actual: {peer_count}") + + if failures: + self.result.is_failure(f"Failures: {list(failures.values())}") + + +class VerifyBGPPeersHealth(AntaTest): + """ + This test verifies the health of BGP peers. + + It will validate that all BGP sessions are established and all message queues for these BGP sessions are empty for a given address family. + + It supports multiple types of address families (AFI) and subsequent service families (SAFI). + Please refer to the Input class attributes below for details. + + Expected Results: + * success: If all BGP sessions are established and all messages queues are empty for each address family and VRF. + * failure: If there are issues with any of the BGP sessions, or if BGP is not configured for an expected VRF or address family. + """ + + name = "VerifyBGPPeersHealth" + description = "Verifies the health of BGP peers" + categories = ["bgp"] + commands = [ + AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}"), + AntaTemplate(template="show bgp {afi} summary"), + ] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + address_families: List[BgpAfi] + """ + List of BGP address families (BgpAfi) + """ + + class BgpAfi(BaseModel): # pylint: disable=missing-class-docstring + afi: Afi + """BGP address family (AFI)""" + safi: Optional[Safi] = None + """Optional BGP subsequent service family (SAFI). + + If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. + """ + vrf: str = "default" + """ + Optional VRF for IPv4 and IPv6. If not provided, it defaults to `default`. + + If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. + """ + + @model_validator(mode="after") + def validate_inputs(self: BaseModel) -> BaseModel: + """ + Validate the inputs provided to the BgpAfi class. + + If afi is either ipv4 or ipv6, safi must be provided. + + If afi is not ipv4 or ipv6, safi must not be provided and vrf must be default. + """ + if self.afi in ["ipv4", "ipv6"]: + if self.safi is None: + raise ValueError("'safi' must be provided when afi is ipv4 or ipv6") + elif self.safi is not None: + raise ValueError("'safi' must not be provided when afi is not ipv4 or ipv6") + elif self.vrf != "default": + raise ValueError("'vrf' must be default when afi is not ipv4 or ipv6") + return self + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + commands = [] + for afi in self.inputs.address_families: + if template == VerifyBGPPeersHealth.commands[0] and afi.afi in ["ipv4", "ipv6"]: + commands.append(template.render(afi=afi.afi, safi=afi.safi, vrf=afi.vrf)) + elif template == VerifyBGPPeersHealth.commands[1] and afi.afi not in ["ipv4", "ipv6"]: + commands.append(template.render(afi=afi.afi, vrf=afi.vrf)) + return commands + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + failures: dict[tuple[str, Any], dict[str, Any]] = {} + + for command in self.instance_commands: + command_output = command.json_output + + afi = cast(Afi, command.params.get("afi")) + safi = cast(Optional[Safi], command.params.get("safi")) + afi_vrf = cast(str, command.params.get("vrf")) + + if not (vrfs := command_output.get("vrfs")): + _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured") + continue + + for vrf, vrf_data in vrfs.items(): + if not (peers := vrf_data.get("peers")): + _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="No Peers") + continue + + peer_issues = {} + for peer, peer_data in peers.items(): + issues = _check_peer_issues(peer_data) + + if issues: + peer_issues[peer] = issues + + if peer_issues: + _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=vrf, issue=peer_issues) + + if failures: + self.result.is_failure(f"Failures: {list(failures.values())}") + + +class VerifyBGPSpecificPeers(AntaTest): + """ + This test verifies the health of specific BGP peer(s). + + It will validate that the BGP session is established and all message queues for this BGP session are empty for the given peer(s). + + It supports multiple types of address families (AFI) and subsequent service families (SAFI). + Please refer to the Input class attributes below for details. + + Expected Results: + * success: If the BGP session is established and all messages queues are empty for each given peer. + * failure: If the BGP session has issues or is not configured, or if BGP is not configured for an expected VRF or address family. + """ + + name = "VerifyBGPSpecificPeers" + description = "Verifies the health of specific BGP peer(s)." + categories = ["bgp"] + commands = [ + AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}"), + AntaTemplate(template="show bgp {afi} summary"), + ] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + address_families: List[BgpAfi] + """ + List of BGP address families (BgpAfi) + """ + + class BgpAfi(BaseModel): # pylint: disable=missing-class-docstring + afi: Afi + """BGP address family (AFI)""" + safi: Optional[Safi] = None + """Optional BGP subsequent service family (SAFI). + + If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. + """ + vrf: str = "default" + """ + Optional VRF for IPv4 and IPv6. If not provided, it defaults to `default`. + + `all` is NOT supported. + + If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. + """ + peers: List[Union[IPv4Address, IPv6Address]] + """List of BGP IPv4 or IPv6 peer""" + + @model_validator(mode="after") + def validate_inputs(self: BaseModel) -> BaseModel: + """ + Validate the inputs provided to the BgpAfi class. + + If afi is either ipv4 or ipv6, safi must be provided and vrf must NOT be all. + + If afi is not ipv4 or ipv6, safi must not be provided and vrf must be default. + """ + if self.afi in ["ipv4", "ipv6"]: + if self.safi is None: + raise ValueError("'safi' must be provided when afi is ipv4 or ipv6") + if self.vrf == "all": + raise ValueError("'all' is not supported in this test. Use VerifyBGPPeersHealth test instead.") + elif self.safi is not None: + raise ValueError("'safi' must not be provided when afi is not ipv4 or ipv6") + elif self.vrf != "default": + raise ValueError("'vrf' must be default when afi is not ipv4 or ipv6") + return self + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + commands = [] + for afi in self.inputs.address_families: + if template == VerifyBGPSpecificPeers.commands[0] and afi.afi in ["ipv4", "ipv6"]: + commands.append(template.render(afi=afi.afi, safi=afi.safi, vrf=afi.vrf, peers=afi.peers)) + elif template == VerifyBGPSpecificPeers.commands[1] and afi.afi not in ["ipv4", "ipv6"]: + commands.append(template.render(afi=afi.afi, vrf=afi.vrf, peers=afi.peers)) + return commands + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + failures: dict[tuple[str, Any], dict[str, Any]] = {} + + for command in self.instance_commands: + command_output = command.json_output + + afi = cast(Afi, command.params.get("afi")) + safi = cast(Optional[Safi], command.params.get("safi")) + afi_vrf = cast(str, command.params.get("vrf")) + afi_peers = cast(List[Union[IPv4Address, IPv6Address]], command.params.get("peers", [])) + + if not (vrfs := command_output.get("vrfs")): + _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured") + continue + + peer_issues = {} + for peer in afi_peers: + peer_ip = str(peer) + peer_data = get_value(dictionary=vrfs, key=f"{afi_vrf}_peers_{peer_ip}", separator="_") + issues = _check_peer_issues(peer_data) + if issues: + peer_issues[peer_ip] = issues + + if peer_issues: + _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue=peer_issues) + + if failures: + self.result.is_failure(f"Failures: {list(failures.values())}") + + +class VerifyBGPExchangedRoutes(AntaTest): + """ + Verifies if the BGP peers have correctly advertised and received routes. + The route type should be 'valid' and 'active' for a specified VRF. + + Expected results: + * success: If the BGP peers have correctly advertised and received routes of type 'valid' and 'active' for a specified VRF. + * failure: If a BGP peer is not found, the expected advertised/received routes are not found, or the routes are not 'valid' or 'active'. + """ + + name = "VerifyBGPExchangedRoutes" + description = "Verifies if BGP peers have correctly advertised/received routes with type as valid and active for a specified VRF." + categories = ["bgp"] + commands = [ + AntaTemplate(template="show bgp neighbors {peer} advertised-routes vrf {vrf}"), + AntaTemplate(template="show bgp neighbors {peer} routes vrf {vrf}"), + ] + + class Input(AntaTest.Input): + """ + Input parameters of the testcase. + """ + + bgp_peers: List[BgpNeighbors] + """List of BGP peers""" + + class BgpNeighbors(BaseModel): + """ + This class defines the details of a BGP peer. + """ + + peer_address: IPv4Address + """IPv4 address of a BGP peer""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + advertised_routes: List[IPv4Network] + """List of advertised routes of a BGP peer.""" + received_routes: List[IPv4Network] + """List of received routes of a BGP peer.""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Renders the template with the provided inputs. Returns a list of commands to be executed.""" + + return [ + template.render(peer=bgp_peer.peer_address, vrf=bgp_peer.vrf, advertised_routes=bgp_peer.advertised_routes, received_routes=bgp_peer.received_routes) + for bgp_peer in self.inputs.bgp_peers + ] + + @AntaTest.anta_test + def test(self) -> None: + failures: dict[str, dict[str, Any]] = {"bgp_peers": {}} + + # Iterating over command output for different peers + for command in self.instance_commands: + peer = str(command.params["peer"]) + vrf = command.params["vrf"] + advertised_routes = command.params["advertised_routes"] + received_routes = command.params["received_routes"] + failure = {vrf: ""} + + # Verify if a BGP peer is configured with the provided vrf + if not (bgp_routes := get_value(command.json_output, f"vrfs.{vrf}.bgpRouteEntries")): + failure[vrf] = "Not configured" + failures["bgp_peers"][peer] = failure + continue + + # Validate advertised routes + if "advertised-routes" in command.command: + failure_routes = _add_bgp_routes_failure(advertised_routes, bgp_routes, peer, vrf) + + # Validate received routes + else: + failure_routes = _add_bgp_routes_failure(received_routes, bgp_routes, peer, vrf, route_type="received_routes") + failures = utils.deep_update(failures, failure_routes) + + if not failures["bgp_peers"]: + self.result.is_success() + else: + self.result.is_failure(f"Following BGP peers are not found or routes are not exchanged properly:\n{failures}") + + +class VerifyBGPPeerMPCaps(AntaTest): + """ + Verifies the multiprotocol capabilities of a BGP peer in a specified VRF. + Expected results: + * success: The test will pass if the BGP peer's multiprotocol capabilities are advertised, received, and enabled in the specified VRF. + * failure: The test will fail if BGP peers are not found or multiprotocol capabilities are not advertised, received, and enabled in the specified VRF. + """ + + name = "VerifyBGPPeerMPCaps" + description = "Verifies the multiprotocol capabilities of a BGP peer in a specified VRF" + categories = ["bgp"] + commands = [AntaCommand(command="show bgp neighbors vrf all")] + + class Input(AntaTest.Input): + """ + Input parameters of the testcase. + """ + + bgp_peers: List[BgpPeers] + """List of BGP peers""" + + class BgpPeers(BaseModel): + """ + This class defines the details of a BGP peer. + """ + + peer_address: IPv4Address + """IPv4 address of a BGP peer""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + capabilities: List[MultiProtocolCaps] + """Multiprotocol capabilities""" + + @AntaTest.anta_test + def test(self) -> None: + failures: dict[str, Any] = {"bgp_peers": {}} + + # Iterate over each bgp peer + for bgp_peer in self.inputs.bgp_peers: + peer = str(bgp_peer.peer_address) + vrf = bgp_peer.vrf + capabilities = bgp_peer.capabilities + failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} + + # Check if BGP output exists + if ( + not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) + or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None + ): + failure["bgp_peers"][peer][vrf] = {"status": "Not configured"} + failures = utils.deep_update(failures, failure) + continue + + # Check each capability + bgp_output = get_value(bgp_output, "neighborCapabilities.multiprotocolCaps") + for capability in capabilities: + capability_output = bgp_output.get(capability) + + # Check if capabilities are missing + if not capability_output: + failure["bgp_peers"][peer][vrf][capability] = "not found" + failures = utils.deep_update(failures, failure) + + # Check if capabilities are not advertised, received, or enabled + elif not all(capability_output.get(prop, False) for prop in ["advertised", "received", "enabled"]): + failure["bgp_peers"][peer][vrf][capability] = capability_output + failures = utils.deep_update(failures, failure) + + # Check if there are any failures + if not failures["bgp_peers"]: + self.result.is_success() + else: + self.result.is_failure(f"Following BGP peer multiprotocol capabilities are not found or not ok:\n{failures}") + + +class VerifyBGPPeerASNCap(AntaTest): + """ + Verifies the four octet asn capabilities of a BGP peer in a specified VRF. + Expected results: + * success: The test will pass if BGP peer's four octet asn capabilities are advertised, received, and enabled in the specified VRF. + * failure: The test will fail if BGP peers are not found or four octet asn capabilities are not advertised, received, and enabled in the specified VRF. + """ + + name = "VerifyBGPPeerASNCap" + description = "Verifies the four octet asn capabilities of a BGP peer in a specified VRF." + categories = ["bgp"] + commands = [AntaCommand(command="show bgp neighbors vrf all")] + + class Input(AntaTest.Input): + """ + Input parameters of the testcase. + """ + + bgp_peers: List[BgpPeers] + """List of BGP peers""" + + class BgpPeers(BaseModel): + """ + This class defines the details of a BGP peer. + """ + + peer_address: IPv4Address + """IPv4 address of a BGP peer""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + + @AntaTest.anta_test + def test(self) -> None: + failures: dict[str, Any] = {"bgp_peers": {}} + + # Iterate over each bgp peer + for bgp_peer in self.inputs.bgp_peers: + peer = str(bgp_peer.peer_address) + vrf = bgp_peer.vrf + failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} + + # Check if BGP output exists + if ( + not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) + or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None + ): + failure["bgp_peers"][peer][vrf] = {"status": "Not configured"} + failures = utils.deep_update(failures, failure) + continue + + bgp_output = get_value(bgp_output, "neighborCapabilities.fourOctetAsnCap") + + # Check if four octet asn capabilities are found + if not bgp_output: + failure["bgp_peers"][peer][vrf] = {"fourOctetAsnCap": "not found"} + failures = utils.deep_update(failures, failure) + + # Check if capabilities are not advertised, received, or enabled + elif not all(bgp_output.get(prop, False) for prop in ["advertised", "received", "enabled"]): + failure["bgp_peers"][peer][vrf] = {"fourOctetAsnCap": bgp_output} + failures = utils.deep_update(failures, failure) + + # Check if there are any failures + if not failures["bgp_peers"]: + self.result.is_success() + else: + self.result.is_failure(f"Following BGP peer four octet asn capabilities are not found or not ok:\n{failures}") + + +class VerifyBGPPeerRouteRefreshCap(AntaTest): + """ + Verifies the route refresh capabilities of a BGP peer in a specified VRF. + Expected results: + * success: The test will pass if the BGP peer's route refresh capabilities are advertised, received, and enabled in the specified VRF. + * failure: The test will fail if BGP peers are not found or route refresh capabilities are not advertised, received, and enabled in the specified VRF. + """ + + name = "VerifyBGPPeerRouteRefreshCap" + description = "Verifies the route refresh capabilities of a BGP peer in a specified VRF." + categories = ["bgp"] + commands = [AntaCommand(command="show bgp neighbors vrf all")] + + class Input(AntaTest.Input): + """ + Input parameters of the testcase. + """ + + bgp_peers: List[BgpPeers] + """List of BGP peers""" + + class BgpPeers(BaseModel): + """ + This class defines the details of a BGP peer. + """ + + peer_address: IPv4Address + """IPv4 address of a BGP peer""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + + @AntaTest.anta_test + def test(self) -> None: + failures: dict[str, Any] = {"bgp_peers": {}} + + # Iterate over each bgp peer + for bgp_peer in self.inputs.bgp_peers: + peer = str(bgp_peer.peer_address) + vrf = bgp_peer.vrf + failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} + + # Check if BGP output exists + if ( + not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) + or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None + ): + failure["bgp_peers"][peer][vrf] = {"status": "Not configured"} + failures = utils.deep_update(failures, failure) + continue + + bgp_output = get_value(bgp_output, "neighborCapabilities.routeRefreshCap") + + # Check if route refresh capabilities are found + if not bgp_output: + failure["bgp_peers"][peer][vrf] = {"routeRefreshCap": "not found"} + failures = utils.deep_update(failures, failure) + + # Check if capabilities are not advertised, received, or enabled + elif not all(bgp_output.get(prop, False) for prop in ["advertised", "received", "enabled"]): + failure["bgp_peers"][peer][vrf] = {"routeRefreshCap": bgp_output} + failures = utils.deep_update(failures, failure) + + # Check if there are any failures + if not failures["bgp_peers"]: + self.result.is_success() + else: + self.result.is_failure(f"Following BGP peer route refresh capabilities are not found or not ok:\n{failures}") + + +class VerifyBGPPeerMD5Auth(AntaTest): + """ + Verifies the MD5 authentication and state of IPv4 BGP peers in a specified VRF. + Expected results: + * success: The test will pass if IPv4 BGP peers are configured with MD5 authentication and state as established in the specified VRF. + * failure: The test will fail if IPv4 BGP peers are not found, state is not as established or MD5 authentication is not enabled in the specified VRF. + """ + + name = "VerifyBGPPeerMD5Auth" + description = "Verifies the MD5 authentication and state of IPv4 BGP peers in a specified VRF" + categories = ["routing", "bgp"] + commands = [AntaCommand(command="show bgp neighbors vrf all")] + + class Input(AntaTest.Input): + """ + Input parameters of the test case. + """ + + bgp_peers: List[BgpPeers] + """List of IPv4 BGP peers""" + + class BgpPeers(BaseModel): + """ + This class defines the details of an IPv4 BGP peer. + """ + + peer_address: IPv4Address + """IPv4 address of BGP peer.""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + + @AntaTest.anta_test + def test(self) -> None: + failures: dict[str, Any] = {"bgp_peers": {}} + + # Iterate over each command + for bgp_peer in self.inputs.bgp_peers: + peer = str(bgp_peer.peer_address) + vrf = bgp_peer.vrf + failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} + + # Check if BGP output exists + if ( + not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) + or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None + ): + failure["bgp_peers"][peer][vrf] = {"status": "Not configured"} + failures = utils.deep_update(failures, failure) + continue + + # Check if BGP peer state and authentication + state = bgp_output.get("state") + md5_auth_enabled = bgp_output.get("md5AuthEnabled") + if state != "Established" or not md5_auth_enabled: + failure["bgp_peers"][peer][vrf] = {"state": state, "md5_auth_enabled": md5_auth_enabled} + failures = utils.deep_update(failures, failure) + + # Check if there are any failures + if not failures["bgp_peers"]: + self.result.is_success() + else: + self.result.is_failure(f"Following BGP peers are not configured, not established or MD5 authentication is not enabled:\n{failures}") + + +class VerifyEVPNType2Route(AntaTest): + """ + This test verifies the EVPN Type-2 routes for a given IPv4 or MAC address and VNI. + + Expected Results: + * success: If all provided VXLAN endpoints have at least one valid and active path to their EVPN Type-2 routes. + * failure: If any of the provided VXLAN endpoints do not have at least one valid and active path to their EVPN Type-2 routes. + """ + + name = "VerifyEVPNType2Route" + description = "Verifies the EVPN Type-2 routes for a given IPv4 or MAC address and VNI." + categories = ["routing", "bgp"] + commands = [AntaTemplate(template="show bgp evpn route-type mac-ip {address} vni {vni}")] + + class Input(AntaTest.Input): + """Inputs for the VerifyEVPNType2Route test.""" + + vxlan_endpoints: List[VxlanEndpoint] + """List of VXLAN endpoints to verify""" + + class VxlanEndpoint(BaseModel): + """VXLAN endpoint input model.""" + + address: Union[IPv4Address, MacAddress] + """IPv4 or MAC address of the VXLAN endpoint""" + vni: Vni + """VNI of the VXLAN endpoint""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(address=endpoint.address, vni=endpoint.vni) for endpoint in self.inputs.vxlan_endpoints] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + no_evpn_routes = [] + bad_evpn_routes = [] + + for command in self.instance_commands: + address = str(command.params["address"]) + vni = command.params["vni"] + # Verify that the VXLAN endpoint is in the BGP EVPN table + evpn_routes = command.json_output["evpnRoutes"] + if not evpn_routes: + no_evpn_routes.append((address, vni)) + continue + # Verify that each EVPN route has at least one valid and active path + for route, route_data in evpn_routes.items(): + has_active_path = False + for path in route_data["evpnRoutePaths"]: + if path["routeType"]["valid"] is True and path["routeType"]["active"] is True: + # At least one path is valid and active, no need to check the other paths + has_active_path = True + break + if not has_active_path: + bad_evpn_routes.append(route) + + if no_evpn_routes: + self.result.is_failure(f"The following VXLAN endpoint do not have any EVPN Type-2 route: {no_evpn_routes}") + if bad_evpn_routes: + self.result.is_failure(f"The following EVPN Type-2 routes do not have at least one valid and active path: {bad_evpn_routes}") + + +class VerifyBGPAdvCommunities(AntaTest): + """ + Verifies if the advertised communities of BGP peers are standard, extended, and large in the specified VRF. + Expected results: + * success: The test will pass if the advertised communities of BGP peers are standard, extended, and large in the specified VRF. + * failure: The test will fail if the advertised communities of BGP peers are not standard, extended, and large in the specified VRF. + """ + + name = "VerifyBGPAdvCommunities" + description = "Verifies if the advertised communities of BGP peers are standard, extended, and large in the specified VRF." + categories = ["routing", "bgp"] + commands = [AntaCommand(command="show bgp neighbors vrf all")] + + class Input(AntaTest.Input): + """ + Input parameters for the test. + """ + + bgp_peers: List[BgpPeers] + """List of BGP peers""" + + class BgpPeers(BaseModel): + """ + This class defines the details of a BGP peer. + """ + + peer_address: IPv4Address + """IPv4 address of a BGP peer.""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + + @AntaTest.anta_test + def test(self) -> None: + failures: dict[str, Any] = {"bgp_peers": {}} + + # Iterate over each bgp peer + for bgp_peer in self.inputs.bgp_peers: + peer = str(bgp_peer.peer_address) + vrf = bgp_peer.vrf + failure: dict[str, dict[str, dict[str, Any]]] = {"bgp_peers": {peer: {vrf: {}}}} + + # Verify BGP peer + if ( + not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) + or (bgp_output := get_item(bgp_output, "peerAddress", peer)) is None + ): + failure["bgp_peers"][peer][vrf] = {"status": "Not configured"} + failures = utils.deep_update(failures, failure) + continue + + # Verify BGP peer's advertised communities + bgp_output = bgp_output.get("advertisedCommunities") + if not bgp_output["standard"] or not bgp_output["extended"] or not bgp_output["large"]: + failure["bgp_peers"][peer][vrf] = {"advertised_communities": bgp_output} + failures = utils.deep_update(failures, failure) + + if not failures["bgp_peers"]: + self.result.is_success() + else: + self.result.is_failure(f"Following BGP peers are not configured or advertised communities are not standard, extended, and large:\n{failures}") + + +class VerifyBGPTimers(AntaTest): + """ + Verifies if the BGP peers are configured with the correct hold and keep-alive timers in the specified VRF. + Expected results: + * success: The test will pass if the hold and keep-alive timers are correct for BGP peers in the specified VRF. + * failure: The test will fail if BGP peers are not found or hold and keep-alive timers are not correct in the specified VRF. + """ + + name = "VerifyBGPTimers" + description = "Verifies if the BGP peers are configured with the correct hold and keep alive timers in the specified VRF." + categories = ["routing", "bgp"] + commands = [AntaCommand(command="show bgp neighbors vrf all")] + + class Input(AntaTest.Input): + """ + Input parameters for the test. + """ + + bgp_peers: List[BgpPeers] + """List of BGP peers""" + + class BgpPeers(BaseModel): + """ + This class defines the details of a BGP peer. + """ + + peer_address: IPv4Address + """IPv4 address of a BGP peer""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + hold_time: int = Field(ge=3, le=7200) + """BGP hold time in seconds""" + keep_alive_time: int = Field(ge=0, le=3600) + """BGP keep-alive time in seconds""" + + @AntaTest.anta_test + def test(self) -> None: + failures: dict[str, Any] = {} + + # Iterate over each bgp peer + for bgp_peer in self.inputs.bgp_peers: + peer_address = str(bgp_peer.peer_address) + vrf = bgp_peer.vrf + hold_time = bgp_peer.hold_time + keep_alive_time = bgp_peer.keep_alive_time + + # Verify BGP peer + if ( + not (bgp_output := get_value(self.instance_commands[0].json_output, f"vrfs.{vrf}.peerList")) + or (bgp_output := get_item(bgp_output, "peerAddress", peer_address)) is None + ): + failures[peer_address] = {vrf: "Not configured"} + continue + + # Verify BGP peer's hold and keep alive timers + if bgp_output.get("holdTime") != hold_time or bgp_output.get("keepaliveTime") != keep_alive_time: + failures[peer_address] = {vrf: {"hold_time": bgp_output.get("holdTime"), "keep_alive_time": bgp_output.get("keepaliveTime")}} + + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"Following BGP peers are not configured or hold and keep-alive timers are not correct:\n{failures}") diff --git a/anta/tests/routing/generic.py b/anta/tests/routing/generic.py new file mode 100644 index 0000000..532b4bb --- /dev/null +++ b/anta/tests/routing/generic.py @@ -0,0 +1,118 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Generic routing test functions +""" +from __future__ import annotations + +from ipaddress import IPv4Address, ip_interface + +# Need to keep List for pydantic in python 3.8 +from typing import List, Literal + +from pydantic import model_validator + +from anta.models import AntaCommand, AntaTemplate, AntaTest + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined + + +class VerifyRoutingProtocolModel(AntaTest): + """ + Verifies the configured routing protocol model is the one we expect. + And if there is no mismatch between the configured and operating routing protocol model. + """ + + name = "VerifyRoutingProtocolModel" + description = "Verifies the configured routing protocol model." + categories = ["routing"] + commands = [AntaCommand(command="show ip route summary", revision=3)] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + model: Literal["multi-agent", "ribd"] = "multi-agent" + """Expected routing protocol model""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + configured_model = command_output["protoModelStatus"]["configuredProtoModel"] + operating_model = command_output["protoModelStatus"]["operatingProtoModel"] + if configured_model == operating_model == self.inputs.model: + self.result.is_success() + else: + self.result.is_failure(f"routing model is misconfigured: configured: {configured_model} - operating: {operating_model} - expected: {self.inputs.model}") + + +class VerifyRoutingTableSize(AntaTest): + """ + Verifies the size of the IP routing table (default VRF). + Should be between the two provided thresholds. + """ + + name = "VerifyRoutingTableSize" + description = "Verifies the size of the IP routing table (default VRF). Should be between the two provided thresholds." + categories = ["routing"] + commands = [AntaCommand(command="show ip route summary", revision=3)] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + minimum: int + """Expected minimum routing table (default VRF) size""" + maximum: int + """Expected maximum routing table (default VRF) size""" + + @model_validator(mode="after") # type: ignore + def check_min_max(self) -> AntaTest.Input: + """Validate that maximum is greater than minimum""" + if self.minimum > self.maximum: + raise ValueError(f"Minimum {self.minimum} is greater than maximum {self.maximum}") + return self + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + total_routes = int(command_output["vrfs"]["default"]["totalRoutes"]) + if self.inputs.minimum <= total_routes <= self.inputs.maximum: + self.result.is_success() + else: + self.result.is_failure(f"routing-table has {total_routes} routes and not between min ({self.inputs.minimum}) and maximum ({self.inputs.maximum})") + + +class VerifyRoutingTableEntry(AntaTest): + """ + This test verifies that the provided routes are present in the routing table of a specified VRF. + + Expected Results: + * success: The test will pass if the provided routes are present in the routing table. + * failure: The test will fail if one or many provided routes are missing from the routing table. + """ + + name = "VerifyRoutingTableEntry" + description = "Verifies that the provided routes are present in the routing table of a specified VRF." + categories = ["routing"] + commands = [AntaTemplate(template="show ip route vrf {vrf} {route}")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + vrf: str = "default" + """VRF context""" + routes: List[IPv4Address] + """Routes to verify""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes] + + @AntaTest.anta_test + def test(self) -> None: + missing_routes = [] + + for command in self.instance_commands: + if "vrf" in command.params and "route" in command.params: + vrf, route = command.params["vrf"], command.params["route"] + if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(list(routes)[0]).ip: + missing_routes.append(str(route)) + + if not missing_routes: + self.result.is_success() + else: + self.result.is_failure(f"The following route(s) are missing from the routing table of VRF {self.inputs.vrf}: {missing_routes}") diff --git a/anta/tests/routing/ospf.py b/anta/tests/routing/ospf.py new file mode 100644 index 0000000..844fcf1 --- /dev/null +++ b/anta/tests/routing/ospf.py @@ -0,0 +1,95 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +OSPF test functions +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import Any + +from anta.models import AntaCommand, AntaTest + + +def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int: + """ + Count the number of OSPF neighbors + """ + count = 0 + for _, vrf_data in ospf_neighbor_json["vrfs"].items(): + for _, instance_data in vrf_data["instList"].items(): + count += len(instance_data.get("ospfNeighborEntries", [])) + return count + + +def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]: + """ + Return the OSPF neighbors whose adjacency state is not "full" + """ + not_full_neighbors = [] + for vrf, vrf_data in ospf_neighbor_json["vrfs"].items(): + for instance, instance_data in vrf_data["instList"].items(): + for neighbor_data in instance_data.get("ospfNeighborEntries", []): + if (state := neighbor_data["adjacencyState"]) != "full": + not_full_neighbors.append( + { + "vrf": vrf, + "instance": instance, + "neighbor": neighbor_data["routerId"], + "state": state, + } + ) + return not_full_neighbors + + +class VerifyOSPFNeighborState(AntaTest): + """ + Verifies all OSPF neighbors are in FULL state. + """ + + name = "VerifyOSPFNeighborState" + description = "Verifies all OSPF neighbors are in FULL state." + categories = ["ospf"] + commands = [AntaCommand(command="show ip ospf neighbor")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if _count_ospf_neighbor(command_output) == 0: + self.result.is_skipped("no OSPF neighbor found") + return + self.result.is_success() + not_full_neighbors = _get_not_full_ospf_neighbors(command_output) + if not_full_neighbors: + self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.") + + +class VerifyOSPFNeighborCount(AntaTest): + """ + Verifies the number of OSPF neighbors in FULL state is the one we expect. + """ + + name = "VerifyOSPFNeighborCount" + description = "Verifies the number of OSPF neighbors in FULL state is the one we expect." + categories = ["ospf"] + commands = [AntaCommand(command="show ip ospf neighbor")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + number: int + """The expected number of OSPF neighbors in FULL state""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if (neighbor_count := _count_ospf_neighbor(command_output)) == 0: + self.result.is_skipped("no OSPF neighbor found") + return + self.result.is_success() + if neighbor_count != self.inputs.number: + self.result.is_failure(f"device has {neighbor_count} neighbors (expected {self.inputs.number})") + not_full_neighbors = _get_not_full_ospf_neighbors(command_output) + print(not_full_neighbors) + if not_full_neighbors: + self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.") diff --git a/anta/tests/security.py b/anta/tests/security.py new file mode 100644 index 0000000..ba7f4e5 --- /dev/null +++ b/anta/tests/security.py @@ -0,0 +1,514 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to the EOS various security settings +""" +from __future__ import annotations + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from datetime import datetime +from typing import List, Union + +from pydantic import BaseModel, Field, conint, model_validator + +from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, RsaKeySize +from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools.get_item import get_item +from anta.tools.get_value import get_value +from anta.tools.utils import get_failed_logs + + +class VerifySSHStatus(AntaTest): + """ + Verifies if the SSHD agent is disabled in the default VRF. + + Expected Results: + * success: The test will pass if the SSHD agent is disabled in the default VRF. + * failure: The test will fail if the SSHD agent is NOT disabled in the default VRF. + """ + + name = "VerifySSHStatus" + description = "Verifies if the SSHD agent is disabled in the default VRF." + categories = ["security"] + commands = [AntaCommand(command="show management ssh", ofmt="text")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].text_output + + line = [line for line in command_output.split("\n") if line.startswith("SSHD status")][0] + status = line.split("is ")[1] + + if status == "disabled": + self.result.is_success() + else: + self.result.is_failure(line) + + +class VerifySSHIPv4Acl(AntaTest): + """ + Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF. + + Expected results: + * success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF. + * failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF. + """ + + name = "VerifySSHIPv4Acl" + description = "Verifies if the SSHD agent has IPv4 ACL(s) configured." + categories = ["security"] + commands = [AntaCommand(command="show management ssh ip access-list summary")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + number: conint(ge=0) # type:ignore + """The number of expected IPv4 ACL(s)""" + vrf: str = "default" + """The name of the VRF in which to check for the SSHD agent""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + ipv4_acl_list = command_output["ipAclList"]["aclList"] + ipv4_acl_number = len(ipv4_acl_list) + not_configured_acl_list = [] + if ipv4_acl_number != self.inputs.number: + self.result.is_failure(f"Expected {self.inputs.number} SSH IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}") + return + for ipv4_acl in ipv4_acl_list: + if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]: + not_configured_acl_list.append(ipv4_acl["name"]) + if not_configured_acl_list: + self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + else: + self.result.is_success() + + +class VerifySSHIPv6Acl(AntaTest): + """ + Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF. + + Expected results: + * success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF. + * failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF. + """ + + name = "VerifySSHIPv6Acl" + description = "Verifies if the SSHD agent has IPv6 ACL(s) configured." + categories = ["security"] + commands = [AntaCommand(command="show management ssh ipv6 access-list summary")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + number: conint(ge=0) # type:ignore + """The number of expected IPv6 ACL(s)""" + vrf: str = "default" + """The name of the VRF in which to check for the SSHD agent""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + ipv6_acl_list = command_output["ipv6AclList"]["aclList"] + ipv6_acl_number = len(ipv6_acl_list) + not_configured_acl_list = [] + if ipv6_acl_number != self.inputs.number: + self.result.is_failure(f"Expected {self.inputs.number} SSH IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}") + return + for ipv6_acl in ipv6_acl_list: + if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]: + not_configured_acl_list.append(ipv6_acl["name"]) + if not_configured_acl_list: + self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + else: + self.result.is_success() + + +class VerifyTelnetStatus(AntaTest): + """ + Verifies if Telnet is disabled in the default VRF. + + Expected Results: + * success: The test will pass if Telnet is disabled in the default VRF. + * failure: The test will fail if Telnet is NOT disabled in the default VRF. + """ + + name = "VerifyTelnetStatus" + description = "Verifies if Telnet is disabled in the default VRF." + categories = ["security"] + commands = [AntaCommand(command="show management telnet")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if command_output["serverState"] == "disabled": + self.result.is_success() + else: + self.result.is_failure("Telnet status for Default VRF is enabled") + + +class VerifyAPIHttpStatus(AntaTest): + """ + Verifies if eAPI HTTP server is disabled globally. + + Expected Results: + * success: The test will pass if eAPI HTTP server is disabled globally. + * failure: The test will fail if eAPI HTTP server is NOT disabled globally. + """ + + name = "VerifyAPIHttpStatus" + description = "Verifies if eAPI HTTP server is disabled globally." + categories = ["security"] + commands = [AntaCommand(command="show management api http-commands")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if command_output["enabled"] and not command_output["httpServer"]["running"]: + self.result.is_success() + else: + self.result.is_failure("eAPI HTTP server is enabled globally") + + +class VerifyAPIHttpsSSL(AntaTest): + """ + Verifies if eAPI HTTPS server SSL profile is configured and valid. + + Expected results: + * success: The test will pass if the eAPI HTTPS server SSL profile is configured and valid. + * failure: The test will fail if the eAPI HTTPS server SSL profile is NOT configured, misconfigured or invalid. + """ + + name = "VerifyAPIHttpsSSL" + description = "Verifies if the eAPI has a valid SSL profile." + categories = ["security"] + commands = [AntaCommand(command="show management api http-commands")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + profile: str + """SSL profile to verify""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + try: + if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid": + self.result.is_success() + else: + self.result.is_failure(f"eAPI HTTPS server SSL profile ({self.inputs.profile}) is misconfigured or invalid") + + except KeyError: + self.result.is_failure(f"eAPI HTTPS server SSL profile ({self.inputs.profile}) is not configured") + + +class VerifyAPIIPv4Acl(AntaTest): + """ + Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF. + + Expected results: + * success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF. + * failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF. + """ + + name = "VerifyAPIIPv4Acl" + description = "Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF." + categories = ["security"] + commands = [AntaCommand(command="show management api http-commands ip access-list summary")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + number: conint(ge=0) # type:ignore + """The number of expected IPv4 ACL(s)""" + vrf: str = "default" + """The name of the VRF in which to check for eAPI""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + ipv4_acl_list = command_output["ipAclList"]["aclList"] + ipv4_acl_number = len(ipv4_acl_list) + not_configured_acl_list = [] + if ipv4_acl_number != self.inputs.number: + self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}") + return + for ipv4_acl in ipv4_acl_list: + if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]: + not_configured_acl_list.append(ipv4_acl["name"]) + if not_configured_acl_list: + self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + else: + self.result.is_success() + + +class VerifyAPIIPv6Acl(AntaTest): + """ + Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF. + + Expected results: + * success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF. + * failure: The test will fail if eAPI has not the right number of IPv6 ACL(s) in the specified VRF. + * skipped: The test will be skipped if the number of IPv6 ACL(s) or VRF parameter is not provided. + """ + + name = "VerifyAPIIPv6Acl" + description = "Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF." + categories = ["security"] + commands = [AntaCommand(command="show management api http-commands ipv6 access-list summary")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + number: conint(ge=0) # type:ignore + """The number of expected IPv6 ACL(s)""" + vrf: str = "default" + """The name of the VRF in which to check for eAPI""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + ipv6_acl_list = command_output["ipv6AclList"]["aclList"] + ipv6_acl_number = len(ipv6_acl_list) + not_configured_acl_list = [] + if ipv6_acl_number != self.inputs.number: + self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}") + return + for ipv6_acl in ipv6_acl_list: + if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]: + not_configured_acl_list.append(ipv6_acl["name"]) + if not_configured_acl_list: + self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + else: + self.result.is_success() + + +class VerifyAPISSLCertificate(AntaTest): + """ + Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size. + + Expected Results: + * success: The test will pass if the certificate's expiry date is greater than the threshold, + and the certificate has the correct name, encryption algorithm, and key size. + * failure: The test will fail if the certificate is expired or is going to expire, + or if the certificate has an incorrect name, encryption algorithm, or key size. + """ + + name = "VerifyAPISSLCertificate" + description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size." + categories = ["security"] + commands = [AntaCommand(command="show management security ssl certificate"), AntaCommand(command="show clock")] + + class Input(AntaTest.Input): + """ + Input parameters for the VerifyAPISSLCertificate test. + """ + + certificates: List[APISSLCertificates] + """List of API SSL certificates""" + + class APISSLCertificates(BaseModel): + """ + This class defines the details of an API SSL certificate. + """ + + certificate_name: str + """The name of the certificate to be verified.""" + expiry_threshold: int + """The expiry threshold of the certificate in days.""" + common_name: str + """The common subject name of the certificate.""" + encryption_algorithm: EncryptionAlgorithm + """The encryption algorithm of the certificate.""" + key_size: Union[RsaKeySize, EcdsaKeySize] + """The encryption algorithm key size of the certificate.""" + + @model_validator(mode="after") + def validate_inputs(self: BaseModel) -> BaseModel: + """ + Validate the key size provided to the APISSLCertificates class. + + If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}. + + If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}. + """ + + if self.encryption_algorithm == "RSA" and self.key_size not in RsaKeySize.__args__: + raise ValueError(f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {RsaKeySize.__args__}.") + + if self.encryption_algorithm == "ECDSA" and self.key_size not in EcdsaKeySize.__args__: + raise ValueError( + f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}." + ) + + return self + + @AntaTest.anta_test + def test(self) -> None: + # Mark the result as success by default + self.result.is_success() + + # Extract certificate and clock output + certificate_output = self.instance_commands[0].json_output + clock_output = self.instance_commands[1].json_output + current_timestamp = clock_output["utcTime"] + + # Iterate over each API SSL certificate + for certificate in self.inputs.certificates: + # Collecting certificate expiry time and current EOS time. + # These times are used to calculate the number of days until the certificate expires. + if not (certificate_data := get_value(certificate_output, f"certificates..{certificate.certificate_name}", separator="..")): + self.result.is_failure(f"SSL certificate '{certificate.certificate_name}', is not configured.\n") + continue + + expiry_time = certificate_data["notAfter"] + day_difference = (datetime.fromtimestamp(expiry_time) - datetime.fromtimestamp(current_timestamp)).days + + # Verify certificate expiry + if 0 < day_difference < certificate.expiry_threshold: + self.result.is_failure(f"SSL certificate `{certificate.certificate_name}` is about to expire in {day_difference} days.\n") + elif day_difference < 0: + self.result.is_failure(f"SSL certificate `{certificate.certificate_name}` is expired.\n") + + # Verify certificate common subject name, encryption algorithm and key size + keys_to_verify = ["subject.commonName", "publicKey.encryptionAlgorithm", "publicKey.size"] + actual_certificate_details = {key: get_value(certificate_data, key) for key in keys_to_verify} + + expected_certificate_details = { + "subject.commonName": certificate.common_name, + "publicKey.encryptionAlgorithm": certificate.encryption_algorithm, + "publicKey.size": certificate.key_size, + } + + if actual_certificate_details != expected_certificate_details: + failed_log = f"SSL certificate `{certificate.certificate_name}` is not configured properly:" + failed_log += get_failed_logs(expected_certificate_details, actual_certificate_details) + self.result.is_failure(f"{failed_log}\n") + + +class VerifyBannerLogin(AntaTest): + """ + Verifies the login banner of a device. + + Expected results: + * success: The test will pass if the login banner matches the provided input. + * failure: The test will fail if the login banner does not match the provided input. + """ + + name = "VerifyBannerLogin" + description = "Verifies the login banner of a device." + categories = ["security"] + commands = [AntaCommand(command="show banner login")] + + class Input(AntaTest.Input): + """Defines the input parameters for this test case.""" + + login_banner: str + """Expected login banner of the device.""" + + @AntaTest.anta_test + def test(self) -> None: + login_banner = self.instance_commands[0].json_output["loginBanner"] + + # Remove leading and trailing whitespaces from each line + cleaned_banner = "\n".join(line.strip() for line in self.inputs.login_banner.split("\n")) + if login_banner != cleaned_banner: + self.result.is_failure(f"Expected `{cleaned_banner}` as the login banner, but found `{login_banner}` instead.") + else: + self.result.is_success() + + +class VerifyBannerMotd(AntaTest): + """ + Verifies the motd banner of a device. + + Expected results: + * success: The test will pass if the motd banner matches the provided input. + * failure: The test will fail if the motd banner does not match the provided input. + """ + + name = "VerifyBannerMotd" + description = "Verifies the motd banner of a device." + categories = ["security"] + commands = [AntaCommand(command="show banner motd")] + + class Input(AntaTest.Input): + """Defines the input parameters for this test case.""" + + motd_banner: str + """Expected motd banner of the device.""" + + @AntaTest.anta_test + def test(self) -> None: + motd_banner = self.instance_commands[0].json_output["motd"] + + # Remove leading and trailing whitespaces from each line + cleaned_banner = "\n".join(line.strip() for line in self.inputs.motd_banner.split("\n")) + if motd_banner != cleaned_banner: + self.result.is_failure(f"Expected `{cleaned_banner}` as the motd banner, but found `{motd_banner}` instead.") + else: + self.result.is_success() + + +class VerifyIPv4ACL(AntaTest): + """ + Verifies the configuration of IPv4 ACLs. + + Expected results: + * success: The test will pass if an IPv4 ACL is configured with the correct sequence entries. + * failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence. + """ + + name = "VerifyIPv4ACL" + description = "Verifies the configuration of IPv4 ACLs." + categories = ["security"] + commands = [AntaTemplate(template="show ip access-lists {acl}")] + + class Input(AntaTest.Input): + """Inputs for the VerifyIPv4ACL test.""" + + ipv4_access_lists: List[IPv4ACL] + """List of IPv4 ACLs to verify""" + + class IPv4ACL(BaseModel): + """Detail of IPv4 ACL""" + + name: str + """Name of IPv4 ACL""" + + entries: List[IPv4ACLEntries] + """List of IPv4 ACL entries""" + + class IPv4ACLEntries(BaseModel): + """IPv4 ACL entries details""" + + sequence: int = Field(ge=1, le=4294967295) + """Sequence number of an ACL entry""" + action: str + """Action of an ACL entry""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(acl=acl.name, entries=acl.entries) for acl in self.inputs.ipv4_access_lists] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + for command_output in self.instance_commands: + # Collecting input ACL details + acl_name = command_output.params["acl"] + acl_entries = command_output.params["entries"] + + # Check if ACL is configured + ipv4_acl_list = command_output.json_output["aclList"] + if not ipv4_acl_list: + self.result.is_failure(f"{acl_name}: Not found") + continue + + # Check if the sequence number is configured and has the correct action applied + failed_log = f"{acl_name}:\n" + for acl_entry in acl_entries: + acl_seq = acl_entry.sequence + acl_action = acl_entry.action + if (actual_entry := get_item(ipv4_acl_list[0]["sequence"], "sequenceNumber", acl_seq)) is None: + failed_log += f"Sequence number `{acl_seq}` is not found.\n" + continue + + if actual_entry["text"] != acl_action: + failed_log += f"Expected `{acl_action}` as sequence number {acl_seq} action but found `{actual_entry['text']}` instead.\n" + + if failed_log != f"{acl_name}:\n": + self.result.is_failure(f"{failed_log}") diff --git a/anta/tests/services.py b/anta/tests/services.py new file mode 100644 index 0000000..a2c2136 --- /dev/null +++ b/anta/tests/services.py @@ -0,0 +1,199 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to the EOS various services settings +""" +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address +from typing import List, Union + +from pydantic import BaseModel, Field + +from anta.custom_types import ErrDisableInterval, ErrDisableReasons +from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools.get_dict_superset import get_dict_superset +from anta.tools.get_item import get_item +from anta.tools.utils import get_failed_logs + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined + + +class VerifyHostname(AntaTest): + """ + Verifies the hostname of a device. + + Expected results: + * success: The test will pass if the hostname matches the provided input. + * failure: The test will fail if the hostname does not match the provided input. + """ + + name = "VerifyHostname" + description = "Verifies the hostname of a device." + categories = ["services"] + commands = [AntaCommand(command="show hostname")] + + class Input(AntaTest.Input): + """Defines the input parameters for this test case.""" + + hostname: str + """Expected hostname of the device.""" + + @AntaTest.anta_test + def test(self) -> None: + hostname = self.instance_commands[0].json_output["hostname"] + + if hostname != self.inputs.hostname: + self.result.is_failure(f"Expected `{self.inputs.hostname}` as the hostname, but found `{hostname}` instead.") + else: + self.result.is_success() + + +class VerifyDNSLookup(AntaTest): + """ + This class verifies the DNS (Domain name service) name to IP address resolution. + + Expected Results: + * success: The test will pass if a domain name is resolved to an IP address. + * failure: The test will fail if a domain name does not resolve to an IP address. + * error: This test will error out if a domain name is invalid. + """ + + name = "VerifyDNSLookup" + description = "Verifies the DNS name to IP address resolution." + categories = ["services"] + commands = [AntaTemplate(template="bash timeout 10 nslookup {domain}")] + + class Input(AntaTest.Input): + """Inputs for the VerifyDNSLookup test.""" + + domain_names: List[str] + """List of domain names""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(domain=domain_name) for domain_name in self.inputs.domain_names] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + failed_domains = [] + for command in self.instance_commands: + domain = command.params["domain"] + output = command.json_output["messages"][0] + if f"Can't find {domain}: No answer" in output: + failed_domains.append(domain) + if failed_domains: + self.result.is_failure(f"The following domain(s) are not resolved to an IP address: {', '.join(failed_domains)}") + + +class VerifyDNSServers(AntaTest): + """ + Verifies if the DNS (Domain Name Service) servers are correctly configured. + + Expected Results: + * success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority. + * failure: The test will fail if the DNS server is not configured or if the VRF and priority of the DNS server do not match the input. + """ + + name = "VerifyDNSServers" + description = "Verifies if the DNS servers are correctly configured." + categories = ["services"] + commands = [AntaCommand(command="show ip name-server")] + + class Input(AntaTest.Input): + """Inputs for the VerifyDNSServers test.""" + + dns_servers: List[DnsServers] + """List of DNS servers to verify.""" + + class DnsServers(BaseModel): + """DNS server details""" + + server_address: Union[IPv4Address, IPv6Address] + """The IPv4/IPv6 address of the DNS server.""" + vrf: str = "default" + """The VRF for the DNS server. Defaults to 'default' if not provided.""" + priority: int = Field(ge=0, le=4) + """The priority of the DNS server from 0 to 4, lower is first.""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output["nameServerConfigs"] + self.result.is_success() + for server in self.inputs.dns_servers: + address = str(server.server_address) + vrf = server.vrf + priority = server.priority + input_dict = {"ipAddr": address, "vrf": vrf} + + if get_item(command_output, "ipAddr", address) is None: + self.result.is_failure(f"DNS server `{address}` is not configured with any VRF.") + continue + + if (output := get_dict_superset(command_output, input_dict)) is None: + self.result.is_failure(f"DNS server `{address}` is not configured with VRF `{vrf}`.") + continue + + if output["priority"] != priority: + self.result.is_failure(f"For DNS server `{address}`, the expected priority is `{priority}`, but `{output['priority']}` was found instead.") + + +class VerifyErrdisableRecovery(AntaTest): + """ + Verifies the errdisable recovery reason, status, and interval. + + Expected Results: + * Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input. + * Failure: The test will fail if the errdisable recovery reason is not found, the status is not enabled, or the interval does not match the input. + """ + + name = "VerifyErrdisableRecovery" + description = "Verifies the errdisable recovery reason, status, and interval." + categories = ["services"] + commands = [AntaCommand(command="show errdisable recovery", ofmt="text")] # Command does not support JSON output hence using text output + + class Input(AntaTest.Input): + """Inputs for the VerifyErrdisableRecovery test.""" + + reasons: List[ErrDisableReason] + """List of errdisable reasons""" + + class ErrDisableReason(BaseModel): + """Details of an errdisable reason""" + + reason: ErrDisableReasons + """Type or name of the errdisable reason""" + interval: ErrDisableInterval + """Interval of the reason in seconds""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].text_output + self.result.is_success() + for error_reason in self.inputs.reasons: + input_reason = error_reason.reason + input_interval = error_reason.interval + reason_found = False + + # Skip header and last empty line + lines = command_output.split("\n")[2:-1] + for line in lines: + # Skip empty lines + if not line.strip(): + continue + # Split by first two whitespaces + reason, status, interval = line.split(None, 2) + if reason != input_reason: + continue + reason_found = True + actual_reason_data = {"interval": interval, "status": status} + expected_reason_data = {"interval": str(input_interval), "status": "Enabled"} + if actual_reason_data != expected_reason_data: + failed_log = get_failed_logs(expected_reason_data, actual_reason_data) + self.result.is_failure(f"`{input_reason}`:{failed_log}\n") + break + + if not reason_found: + self.result.is_failure(f"`{input_reason}`: Not found.\n") diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py new file mode 100644 index 0000000..39d9424 --- /dev/null +++ b/anta/tests/snmp.py @@ -0,0 +1,176 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to the EOS various SNMP settings +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from pydantic import conint + +from anta.models import AntaCommand, AntaTest + + +class VerifySnmpStatus(AntaTest): + """ + Verifies whether the SNMP agent is enabled in a specified VRF. + + Expected Results: + * success: The test will pass if the SNMP agent is enabled in the specified VRF. + * failure: The test will fail if the SNMP agent is disabled in the specified VRF. + """ + + name = "VerifySnmpStatus" + description = "Verifies if the SNMP agent is enabled." + categories = ["snmp"] + commands = [AntaCommand(command="show snmp")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + vrf: str = "default" + """The name of the VRF in which to check for the SNMP agent""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]: + self.result.is_success() + else: + self.result.is_failure(f"SNMP agent disabled in vrf {self.inputs.vrf}") + + +class VerifySnmpIPv4Acl(AntaTest): + """ + Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF. + + Expected results: + * success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF. + * failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF. + """ + + name = "VerifySnmpIPv4Acl" + description = "Verifies if the SNMP agent has IPv4 ACL(s) configured." + categories = ["snmp"] + commands = [AntaCommand(command="show snmp ipv4 access-list summary")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + number: conint(ge=0) # type:ignore + """The number of expected IPv4 ACL(s)""" + vrf: str = "default" + """The name of the VRF in which to check for the SNMP agent""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + ipv4_acl_list = command_output["ipAclList"]["aclList"] + ipv4_acl_number = len(ipv4_acl_list) + not_configured_acl_list = [] + if ipv4_acl_number != self.inputs.number: + self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}") + return + for ipv4_acl in ipv4_acl_list: + if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]: + not_configured_acl_list.append(ipv4_acl["name"]) + if not_configured_acl_list: + self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + else: + self.result.is_success() + + +class VerifySnmpIPv6Acl(AntaTest): + """ + Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF. + + Expected results: + * success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF. + * failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF. + """ + + name = "VerifySnmpIPv6Acl" + description = "Verifies if the SNMP agent has IPv6 ACL(s) configured." + categories = ["snmp"] + commands = [AntaCommand(command="show snmp ipv6 access-list summary")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + number: conint(ge=0) # type:ignore + """The number of expected IPv6 ACL(s)""" + vrf: str = "default" + """The name of the VRF in which to check for the SNMP agent""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + ipv6_acl_list = command_output["ipv6AclList"]["aclList"] + ipv6_acl_number = len(ipv6_acl_list) + not_configured_acl_list = [] + if ipv6_acl_number != self.inputs.number: + self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}") + return + for ipv6_acl in ipv6_acl_list: + if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]: + not_configured_acl_list.append(ipv6_acl["name"]) + if not_configured_acl_list: + self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + else: + self.result.is_success() + + +class VerifySnmpLocation(AntaTest): + """ + This class verifies the SNMP location of a device. + + Expected results: + * success: The test will pass if the SNMP location matches the provided input. + * failure: The test will fail if the SNMP location does not match the provided input. + """ + + name = "VerifySnmpLocation" + description = "Verifies the SNMP location of a device." + categories = ["snmp"] + commands = [AntaCommand(command="show snmp")] + + class Input(AntaTest.Input): + """Defines the input parameters for this test case.""" + + location: str + """Expected SNMP location of the device.""" + + @AntaTest.anta_test + def test(self) -> None: + location = self.instance_commands[0].json_output["location"]["location"] + + if location != self.inputs.location: + self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.") + else: + self.result.is_success() + + +class VerifySnmpContact(AntaTest): + """ + This class verifies the SNMP contact of a device. + + Expected results: + * success: The test will pass if the SNMP contact matches the provided input. + * failure: The test will fail if the SNMP contact does not match the provided input. + """ + + name = "VerifySnmpContact" + description = "Verifies the SNMP contact of a device." + categories = ["snmp"] + commands = [AntaCommand(command="show snmp")] + + class Input(AntaTest.Input): + """Defines the input parameters for this test case.""" + + contact: str + """Expected SNMP contact details of the device.""" + + @AntaTest.anta_test + def test(self) -> None: + contact = self.instance_commands[0].json_output["contact"]["contact"] + + if contact != self.inputs.contact: + self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.") + else: + self.result.is_success() diff --git a/anta/tests/software.py b/anta/tests/software.py new file mode 100644 index 0000000..a75efc5 --- /dev/null +++ b/anta/tests/software.py @@ -0,0 +1,91 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to the EOS software +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +# Need to keep List for pydantic in python 3.8 +from typing import List + +from anta.models import AntaCommand, AntaTest + + +class VerifyEOSVersion(AntaTest): + """ + Verifies the device is running one of the allowed EOS version. + """ + + name = "VerifyEOSVersion" + description = "Verifies the device is running one of the allowed EOS version." + categories = ["software"] + commands = [AntaCommand(command="show version")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + versions: List[str] + """List of allowed EOS versions""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if command_output["version"] in self.inputs.versions: + self.result.is_success() + else: + self.result.is_failure(f'device is running version "{command_output["version"]}" not in expected versions: {self.inputs.versions}') + + +class VerifyTerminAttrVersion(AntaTest): + """ + Verifies the device is running one of the allowed TerminAttr version. + """ + + name = "VerifyTerminAttrVersion" + description = "Verifies the device is running one of the allowed TerminAttr version." + categories = ["software"] + commands = [AntaCommand(command="show version detail")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + versions: List[str] + """List of allowed TerminAttr versions""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"] + if command_output_data in self.inputs.versions: + self.result.is_success() + else: + self.result.is_failure(f"device is running TerminAttr version {command_output_data} and is not in the allowed list: {self.inputs.versions}") + + +class VerifyEOSExtensions(AntaTest): + """ + Verifies all EOS extensions installed on the device are enabled for boot persistence. + """ + + name = "VerifyEOSExtensions" + description = "Verifies all EOS extensions installed on the device are enabled for boot persistence." + categories = ["software"] + commands = [AntaCommand(command="show extensions"), AntaCommand(command="show boot-extensions")] + + @AntaTest.anta_test + def test(self) -> None: + boot_extensions = [] + show_extensions_command_output = self.instance_commands[0].json_output + show_boot_extensions_command_output = self.instance_commands[1].json_output + installed_extensions = [ + extension for extension, extension_data in show_extensions_command_output["extensions"].items() if extension_data["status"] == "installed" + ] + for extension in show_boot_extensions_command_output["extensions"]: + extension = extension.strip("\n") + if extension != "": + boot_extensions.append(extension) + installed_extensions.sort() + boot_extensions.sort() + if installed_extensions == boot_extensions: + self.result.is_success() + else: + self.result.is_failure(f"Missing EOS extensions: installed {installed_extensions} / configured: {boot_extensions}") diff --git a/anta/tests/stp.py b/anta/tests/stp.py new file mode 100644 index 0000000..66f303b --- /dev/null +++ b/anta/tests/stp.py @@ -0,0 +1,198 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to various Spanning Tree Protocol (STP) settings +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +# Need to keep List for pydantic in python 3.8 +from typing import List, Literal + +from anta.custom_types import Vlan +from anta.models import AntaCommand, AntaTemplate, AntaTest +from anta.tools.get_value import get_value + + +class VerifySTPMode(AntaTest): + """ + Verifies the configured STP mode for a provided list of VLAN(s). + + Expected Results: + * success: The test will pass if the STP mode is configured properly in the specified VLAN(s). + * failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s). + """ + + name = "VerifySTPMode" + description = "Verifies the configured STP mode for a provided list of VLAN(s)." + categories = ["stp"] + commands = [AntaTemplate(template="show spanning-tree vlan {vlan}")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + mode: Literal["mstp", "rstp", "rapidPvst"] = "mstp" + """STP mode to verify""" + vlans: List[Vlan] + """List of VLAN on which to verify STP mode""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(vlan=vlan) for vlan in self.inputs.vlans] + + @AntaTest.anta_test + def test(self) -> None: + not_configured = [] + wrong_stp_mode = [] + for command in self.instance_commands: + if "vlan" in command.params: + vlan_id = command.params["vlan"] + if not (stp_mode := get_value(command.json_output, f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol")): + not_configured.append(vlan_id) + elif stp_mode != self.inputs.mode: + wrong_stp_mode.append(vlan_id) + if not_configured: + self.result.is_failure(f"STP mode '{self.inputs.mode}' not configured for the following VLAN(s): {not_configured}") + if wrong_stp_mode: + self.result.is_failure(f"Wrong STP mode configured for the following VLAN(s): {wrong_stp_mode}") + if not not_configured and not wrong_stp_mode: + self.result.is_success() + + +class VerifySTPBlockedPorts(AntaTest): + """ + Verifies there is no STP blocked ports. + + Expected Results: + * success: The test will pass if there are NO ports blocked by STP. + * failure: The test will fail if there are ports blocked by STP. + """ + + name = "VerifySTPBlockedPorts" + description = "Verifies there is no STP blocked ports." + categories = ["stp"] + commands = [AntaCommand(command="show spanning-tree blockedports")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if not (stp_instances := command_output["spanningTreeInstances"]): + self.result.is_success() + else: + for key, value in stp_instances.items(): + stp_instances[key] = value.pop("spanningTreeBlockedPorts") + self.result.is_failure(f"The following ports are blocked by STP: {stp_instances}") + + +class VerifySTPCounters(AntaTest): + """ + Verifies there is no errors in STP BPDU packets. + + Expected Results: + * success: The test will pass if there are NO STP BPDU packet errors under all interfaces participating in STP. + * failure: The test will fail if there are STP BPDU packet errors on one or many interface(s). + """ + + name = "VerifySTPCounters" + description = "Verifies there is no errors in STP BPDU packets." + categories = ["stp"] + commands = [AntaCommand(command="show spanning-tree counters")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + interfaces_with_errors = [ + interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0 + ] + if interfaces_with_errors: + self.result.is_failure(f"The following interfaces have STP BPDU packet errors: {interfaces_with_errors}") + else: + self.result.is_success() + + +class VerifySTPForwardingPorts(AntaTest): + """ + Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s). + + Expected Results: + * success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s). + * failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s). + """ + + name = "VerifySTPForwardingPorts" + description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)." + categories = ["stp"] + commands = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + vlans: List[Vlan] + """List of VLAN on which to verify forwarding states""" + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(vlan=vlan) for vlan in self.inputs.vlans] + + @AntaTest.anta_test + def test(self) -> None: + not_configured = [] + not_forwarding = [] + for command in self.instance_commands: + if "vlan" in command.params: + vlan_id = command.params["vlan"] + if not (topologies := get_value(command.json_output, "topologies")): + not_configured.append(vlan_id) + else: + for value in topologies.values(): + if int(vlan_id) in value["vlans"]: + interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"] + if interfaces_not_forwarding: + not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding}) + if not_configured: + self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}") + if not_forwarding: + self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a fowarding state: {not_forwarding}") + if not not_configured and not interfaces_not_forwarding: + self.result.is_success() + + +class VerifySTPRootPriority(AntaTest): + """ + Verifies the STP root priority for a provided list of VLAN or MST instance ID(s). + + Expected Results: + * success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s). + * failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s). + """ + + name = "VerifySTPRootPriority" + description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)." + categories = ["stp"] + commands = [AntaCommand(command="show spanning-tree root detail")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + priority: int + """STP root priority to verify""" + instances: List[Vlan] = [] + """List of VLAN or MST instance ID(s). If empty, ALL VLAN or MST instance ID(s) will be verified.""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if not (stp_instances := command_output["instances"]): + self.result.is_failure("No STP instances configured") + return + # Checking the type of instances based on first instance + first_name = list(stp_instances)[0] + if first_name.startswith("MST"): + prefix = "MST" + elif first_name.startswith("VL"): + prefix = "VL" + else: + self.result.is_failure(f"Unsupported STP instance type: {first_name}") + return + check_instances = [f"{prefix}{instance_id}" for instance_id in self.inputs.instances] if self.inputs.instances else command_output["instances"].keys() + wrong_priority_instances = [ + instance for instance in check_instances if get_value(command_output, f"instances.{instance}.rootBridge.priority") != self.inputs.priority + ] + if wrong_priority_instances: + self.result.is_failure(f"The following instance(s) have the wrong STP root priority configured: {wrong_priority_instances}") + else: + self.result.is_success() diff --git a/anta/tests/system.py b/anta/tests/system.py new file mode 100644 index 0000000..02ba09e --- /dev/null +++ b/anta/tests/system.py @@ -0,0 +1,227 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to system-level features and protocols +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +import re + +from pydantic import conint + +from anta.models import AntaCommand, AntaTest + + +class VerifyUptime(AntaTest): + """ + This test verifies if the device uptime is higher than the provided minimum uptime value. + + Expected Results: + * success: The test will pass if the device uptime is higher than the provided value. + * failure: The test will fail if the device uptime is lower than the provided value. + """ + + name = "VerifyUptime" + description = "Verifies the device uptime." + categories = ["system"] + commands = [AntaCommand(command="show uptime")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + minimum: conint(ge=0) # type: ignore + """Minimum uptime in seconds""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if command_output["upTime"] > self.inputs.minimum: + self.result.is_success() + else: + self.result.is_failure(f"Device uptime is {command_output['upTime']} seconds") + + +class VerifyReloadCause(AntaTest): + """ + This test verifies the last reload cause of the device. + + Expected results: + * success: The test will pass if there are NO reload causes or if the last reload was caused by the user or after an FPGA upgrade. + * failure: The test will fail if the last reload was NOT caused by the user or after an FPGA upgrade. + * error: The test will report an error if the reload cause is NOT available. + """ + + name = "VerifyReloadCause" + description = "Verifies the last reload cause of the device." + categories = ["system"] + commands = [AntaCommand(command="show reload cause")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if "resetCauses" not in command_output.keys(): + self.result.is_error(message="No reload causes available") + return + if len(command_output["resetCauses"]) == 0: + # No reload causes + self.result.is_success() + return + reset_causes = command_output["resetCauses"] + command_output_data = reset_causes[0].get("description") + if command_output_data in [ + "Reload requested by the user.", + "Reload requested after FPGA upgrade", + ]: + self.result.is_success() + else: + self.result.is_failure(f"Reload cause is: '{command_output_data}'") + + +class VerifyCoredump(AntaTest): + """ + This test verifies if there are core dump files in the /var/core directory. + + Expected Results: + * success: The test will pass if there are NO core dump(s) in /var/core. + * failure: The test will fail if there are core dump(s) in /var/core. + + Note: + * This test will NOT check for minidump(s) generated by certain agents in /var/core/minidump. + """ + + name = "VerifyCoredump" + description = "Verifies there are no core dump files." + categories = ["system"] + commands = [AntaCommand(command="show system coredump", ofmt="json")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + core_files = command_output["coreFiles"] + if "minidump" in core_files: + core_files.remove("minidump") + if not core_files: + self.result.is_success() + else: + self.result.is_failure(f"Core dump(s) have been found: {core_files}") + + +class VerifyAgentLogs(AntaTest): + """ + This test verifies that no agent crash reports are present on the device. + + Expected Results: + * success: The test will pass if there is NO agent crash reported. + * failure: The test will fail if any agent crashes are reported. + """ + + name = "VerifyAgentLogs" + description = "Verifies there are no agent crash reports." + categories = ["system"] + commands = [AntaCommand(command="show agent logs crash", ofmt="text")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].text_output + if len(command_output) == 0: + self.result.is_success() + else: + pattern = re.compile(r"^===> (.*?) <===$", re.MULTILINE) + agents = "\n * ".join(pattern.findall(command_output)) + self.result.is_failure(f"Device has reported agent crashes:\n * {agents}") + + +class VerifyCPUUtilization(AntaTest): + """ + This test verifies whether the CPU utilization is below 75%. + + Expected Results: + * success: The test will pass if the CPU utilization is below 75%. + * failure: The test will fail if the CPU utilization is over 75%. + """ + + name = "VerifyCPUUtilization" + description = "Verifies whether the CPU utilization is below 75%." + categories = ["system"] + commands = [AntaCommand(command="show processes top once")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + command_output_data = command_output["cpuInfo"]["%Cpu(s)"]["idle"] + if command_output_data > 25: + self.result.is_success() + else: + self.result.is_failure(f"Device has reported a high CPU utilization: {100 - command_output_data}%") + + +class VerifyMemoryUtilization(AntaTest): + """ + This test verifies whether the memory utilization is below 75%. + + Expected Results: + * success: The test will pass if the memory utilization is below 75%. + * failure: The test will fail if the memory utilization is over 75%. + """ + + name = "VerifyMemoryUtilization" + description = "Verifies whether the memory utilization is below 75%." + categories = ["system"] + commands = [AntaCommand(command="show version")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + memory_usage = command_output["memFree"] / command_output["memTotal"] + if memory_usage > 0.25: + self.result.is_success() + else: + self.result.is_failure(f"Device has reported a high memory usage: {(1 - memory_usage)*100:.2f}%") + + +class VerifyFileSystemUtilization(AntaTest): + """ + This test verifies that no partition is utilizing more than 75% of its disk space. + + Expected Results: + * success: The test will pass if all partitions are using less than 75% of its disk space. + * failure: The test will fail if any partitions are using more than 75% of its disk space. + """ + + name = "VerifyFileSystemUtilization" + description = "Verifies that no partition is utilizing more than 75% of its disk space." + categories = ["system"] + commands = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].text_output + self.result.is_success() + for line in command_output.split("\n")[1:]: + if "loop" not in line and len(line) > 0 and (percentage := int(line.split()[4].replace("%", ""))) > 75: + self.result.is_failure(f"Mount point {line} is higher than 75%: reported {percentage}%") + + +class VerifyNTP(AntaTest): + """ + This test verifies that the Network Time Protocol (NTP) is synchronized. + + Expected Results: + * success: The test will pass if the NTP is synchronised. + * failure: The test will fail if the NTP is NOT synchronised. + """ + + name = "VerifyNTP" + description = "Verifies if NTP is synchronised." + categories = ["system"] + commands = [AntaCommand(command="show ntp status", ofmt="text")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].text_output + if command_output.split("\n")[0].split(" ")[0] == "synchronised": + self.result.is_success() + else: + data = command_output.split("\n")[0] + self.result.is_failure(f"The device is not synchronized with the configured NTP server(s): '{data}'") diff --git a/anta/tests/vlan.py b/anta/tests/vlan.py new file mode 100644 index 0000000..58c28b6 --- /dev/null +++ b/anta/tests/vlan.py @@ -0,0 +1,59 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to VLAN +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined + +from typing import Literal + +from anta.custom_types import Vlan +from anta.models import AntaCommand, AntaTest +from anta.tools.get_value import get_value +from anta.tools.utils import get_failed_logs + + +class VerifyVlanInternalPolicy(AntaTest): + """ + This class checks if the VLAN internal allocation policy is ascending or descending and + if the VLANs are within the specified range. + + Expected Results: + * Success: The test will pass if the VLAN internal allocation policy is either ascending or descending + and the VLANs are within the specified range. + * Failure: The test will fail if the VLAN internal allocation policy is neither ascending nor descending + or the VLANs are outside the specified range. + """ + + name = "VerifyVlanInternalPolicy" + description = "This test checks the VLAN internal allocation policy and the range of VLANs." + categories = ["vlan"] + commands = [AntaCommand(command="show vlan internal allocation policy")] + + class Input(AntaTest.Input): + """Inputs for the VerifyVlanInternalPolicy test.""" + + policy: Literal["ascending", "descending"] + """The VLAN internal allocation policy.""" + start_vlan_id: Vlan + """The starting VLAN ID in the range.""" + end_vlan_id: Vlan + """The ending VLAN ID in the range.""" + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + + keys_to_verify = ["policy", "startVlanId", "endVlanId"] + actual_policy_output = {key: get_value(command_output, key) for key in keys_to_verify} + expected_policy_output = {"policy": self.inputs.policy, "startVlanId": self.inputs.start_vlan_id, "endVlanId": self.inputs.end_vlan_id} + + # Check if the actual output matches the expected output + if actual_policy_output != expected_policy_output: + failed_log = "The VLAN internal allocation policy is not configured properly:" + failed_log += get_failed_logs(expected_policy_output, actual_policy_output) + self.result.is_failure(failed_log) + else: + self.result.is_success() diff --git a/anta/tests/vxlan.py b/anta/tests/vxlan.py new file mode 100644 index 0000000..e763b8f --- /dev/null +++ b/anta/tests/vxlan.py @@ -0,0 +1,219 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test functions related to VXLAN +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined + +from ipaddress import IPv4Address + +# Need to keep List and Dict for pydantic in python 3.8 +from typing import Dict, List + +from pydantic import Field + +from anta.custom_types import Vlan, Vni, VxlanSrcIntf +from anta.models import AntaCommand, AntaTest +from anta.tools.get_value import get_value + + +class VerifyVxlan1Interface(AntaTest): + """ + This test verifies if the Vxlan1 interface is configured and 'up/up'. + + !!! warning + The name of this test has been updated from 'VerifyVxlan' for better representation. + + Expected Results: + * success: The test will pass if the Vxlan1 interface is configured with line protocol status and interface status 'up'. + * failure: The test will fail if the Vxlan1 interface line protocol status or interface status are not 'up'. + * skipped: The test will be skipped if the Vxlan1 interface is not configured. + """ + + name = "VerifyVxlan1Interface" + description = "Verifies the Vxlan1 interface status." + categories = ["vxlan"] + commands = [AntaCommand(command="show interfaces description", ofmt="json")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if "Vxlan1" not in command_output["interfaceDescriptions"]: + self.result.is_skipped("Vxlan1 interface is not configured") + elif ( + command_output["interfaceDescriptions"]["Vxlan1"]["lineProtocolStatus"] == "up" + and command_output["interfaceDescriptions"]["Vxlan1"]["interfaceStatus"] == "up" + ): + self.result.is_success() + else: + self.result.is_failure( + f"Vxlan1 interface is {command_output['interfaceDescriptions']['Vxlan1']['lineProtocolStatus']}" + f"/{command_output['interfaceDescriptions']['Vxlan1']['interfaceStatus']}" + ) + + +class VerifyVxlanConfigSanity(AntaTest): + """ + This test verifies that no issues are detected with the VXLAN configuration. + + Expected Results: + * success: The test will pass if no issues are detected with the VXLAN configuration. + * failure: The test will fail if issues are detected with the VXLAN configuration. + * skipped: The test will be skipped if VXLAN is not configured on the device. + """ + + name = "VerifyVxlanConfigSanity" + description = "Verifies there are no VXLAN config-sanity inconsistencies." + categories = ["vxlan"] + commands = [AntaCommand(command="show vxlan config-sanity", ofmt="json")] + + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + if "categories" not in command_output or len(command_output["categories"]) == 0: + self.result.is_skipped("VXLAN is not configured") + return + failed_categories = { + category: content + for category, content in command_output["categories"].items() + if category in ["localVtep", "mlag", "pd"] and content["allCheckPass"] is not True + } + if len(failed_categories) > 0: + self.result.is_failure(f"VXLAN config sanity check is not passing: {failed_categories}") + else: + self.result.is_success() + + +class VerifyVxlanVniBinding(AntaTest): + """ + This test verifies the VNI-VLAN bindings of the Vxlan1 interface. + + Expected Results: + * success: The test will pass if the VNI-VLAN bindings provided are properly configured. + * failure: The test will fail if any VNI lacks bindings or if any bindings are incorrect. + * skipped: The test will be skipped if the Vxlan1 interface is not configured. + """ + + name = "VerifyVxlanVniBinding" + description = "Verifies the VNI-VLAN bindings of the Vxlan1 interface." + categories = ["vxlan"] + commands = [AntaCommand(command="show vxlan vni", ofmt="json")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + bindings: Dict[Vni, Vlan] + """VNI to VLAN bindings to verify""" + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + no_binding = [] + wrong_binding = [] + + if (vxlan1 := get_value(self.instance_commands[0].json_output, "vxlanIntfs.Vxlan1")) is None: + self.result.is_skipped("Vxlan1 interface is not configured") + return + + for vni, vlan in self.inputs.bindings.items(): + vni = str(vni) + if vni in vxlan1["vniBindings"]: + retrieved_vlan = vxlan1["vniBindings"][vni]["vlan"] + elif vni in vxlan1["vniBindingsToVrf"]: + retrieved_vlan = vxlan1["vniBindingsToVrf"][vni]["vlan"] + else: + no_binding.append(vni) + retrieved_vlan = None + + if retrieved_vlan and vlan != retrieved_vlan: + wrong_binding.append({vni: retrieved_vlan}) + + if no_binding: + self.result.is_failure(f"The following VNI(s) have no binding: {no_binding}") + + if wrong_binding: + self.result.is_failure(f"The following VNI(s) have the wrong VLAN binding: {wrong_binding}") + + +class VerifyVxlanVtep(AntaTest): + """ + This test verifies the VTEP peers of the Vxlan1 interface. + + Expected Results: + * success: The test will pass if all provided VTEP peers are identified and matching. + * failure: The test will fail if any VTEP peer is missing or there are unexpected VTEP peers. + * skipped: The test will be skipped if the Vxlan1 interface is not configured. + """ + + name = "VerifyVxlanVtep" + description = "Verifies the VTEP peers of the Vxlan1 interface" + categories = ["vxlan"] + commands = [AntaCommand(command="show vxlan vtep", ofmt="json")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + vteps: List[IPv4Address] + """List of VTEP peers to verify""" + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + inputs_vteps = [str(input_vtep) for input_vtep in self.inputs.vteps] + + if (vxlan1 := get_value(self.instance_commands[0].json_output, "interfaces.Vxlan1")) is None: + self.result.is_skipped("Vxlan1 interface is not configured") + return + + difference1 = set(inputs_vteps).difference(set(vxlan1["vteps"])) + difference2 = set(vxlan1["vteps"]).difference(set(inputs_vteps)) + + if difference1: + self.result.is_failure(f"The following VTEP peer(s) are missing from the Vxlan1 interface: {sorted(difference1)}") + + if difference2: + self.result.is_failure(f"Unexpected VTEP peer(s) on Vxlan1 interface: {sorted(difference2)}") + + +class VerifyVxlan1ConnSettings(AntaTest): + """ + Verifies the interface vxlan1 source interface and UDP port. + + Expected Results: + * success: Passes if the interface vxlan1 source interface and UDP port are correct. + * failure: Fails if the interface vxlan1 source interface or UDP port are incorrect. + * skipped: Skips if the Vxlan1 interface is not configured. + """ + + name = "VerifyVxlan1ConnSettings" + description = "Verifies the interface vxlan1 source interface and UDP port." + categories = ["vxlan"] + commands = [AntaCommand(command="show interfaces")] + + class Input(AntaTest.Input): + """Inputs for the VerifyVxlan1ConnSettings test.""" + + source_interface: VxlanSrcIntf + """Source loopback interface of vxlan1 interface""" + udp_port: int = Field(ge=1024, le=65335) + """UDP port used for vxlan1 interface""" + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + command_output = self.instance_commands[0].json_output + + # Skip the test case if vxlan1 interface is not configured + vxlan_output = get_value(command_output, "interfaces.Vxlan1") + if not vxlan_output: + self.result.is_skipped("Vxlan1 interface is not configured.") + return + + src_intf = vxlan_output.get("srcIpIntf") + port = vxlan_output.get("udpPort") + + # Check vxlan1 source interface and udp port + if src_intf != self.inputs.source_interface: + self.result.is_failure(f"Source interface is not correct. Expected `{self.inputs.source_interface}` as source interface but found `{src_intf}` instead.") + if port != self.inputs.udp_port: + self.result.is_failure(f"UDP port is not correct. Expected `{self.inputs.udp_port}` as UDP port but found `{port}` instead.") diff --git a/anta/tools/__init__.py b/anta/tools/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/anta/tools/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/anta/tools/get_dict_superset.py b/anta/tools/get_dict_superset.py new file mode 100644 index 0000000..b3bbde0 --- /dev/null +++ b/anta/tools/get_dict_superset.py @@ -0,0 +1,64 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. + +"""Get one dictionary from a list of dictionaries by matching the given key and values.""" +from __future__ import annotations + +from typing import Any, Optional + + +def get_dict_superset( + list_of_dicts: list[dict[Any, Any]], + input_dict: dict[Any, Any], + default: Optional[Any] = None, + required: bool = False, + var_name: Optional[str] = None, + custom_error_msg: Optional[str] = None, +) -> Any: + """Get the first dictionary from a list of dictionaries that is a superset of the input dict. + + Returns the supplied default value or None if there is no match and "required" is False. + + Will return the first matching item if there are multiple matching items. + + Parameters + ---------- + list_of_dicts: list(dict) + List of Dictionaries to get list items from + input_dict : dict + Dictionary to check subset with a list of dict + default: any + Default value returned if the key and value are not found + required: bool + Fail if there is no match + var_name : str + String used for raising an exception with the full variable name + custom_error_msg : str + Custom error message to raise when required is True and the value is not found + + Returns + ------- + any + Dict or default value + + Raises + ------ + ValueError + If the keys and values are not found and "required" == True + """ + if not isinstance(list_of_dicts, list) or not list_of_dicts or not isinstance(input_dict, dict) or not input_dict: + if required: + error_msg = custom_error_msg or f"{var_name} not found in the provided list." + raise ValueError(error_msg) + return default + + for list_item in list_of_dicts: + if isinstance(list_item, dict) and input_dict.items() <= list_item.items(): + return list_item + + if required: + error_msg = custom_error_msg or f"{var_name} not found in the provided list." + raise ValueError(error_msg) + + return default diff --git a/anta/tools/get_item.py b/anta/tools/get_item.py new file mode 100644 index 0000000..db5695b --- /dev/null +++ b/anta/tools/get_item.py @@ -0,0 +1,83 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. + +"""Get one dictionary from a list of dictionaries by matching the given key and value.""" +from __future__ import annotations + +from typing import Any, Optional + + +# pylint: disable=too-many-arguments +def get_item( + list_of_dicts: list[dict[Any, Any]], + key: Any, + value: Any, + default: Optional[Any] = None, + required: bool = False, + case_sensitive: bool = False, + var_name: Optional[str] = None, + custom_error_msg: Optional[str] = None, +) -> Any: + """Get one dictionary from a list of dictionaries by matching the given key and value. + + Returns the supplied default value or None if there is no match and "required" is False. + + Will return the first matching item if there are multiple matching items. + + Parameters + ---------- + list_of_dicts : list(dict) + List of Dictionaries to get list item from + key : any + Dictionary Key to match on + value : any + Value that must match + default : any + Default value returned if the key and value is not found + required : bool + Fail if there is no match + case_sensitive : bool + If the search value is a string, the comparison will ignore case by default + var_name : str + String used for raising exception with the full variable name + custom_error_msg : str + Custom error message to raise when required is True and the value is not found + + Returns + ------- + any + Dict or default value + + Raises + ------ + ValueError + If the key and value is not found and "required" == True + """ + if var_name is None: + var_name = key + + if (not isinstance(list_of_dicts, list)) or list_of_dicts == [] or value is None or key is None: + if required is True: + raise ValueError(custom_error_msg or var_name) + return default + + for list_item in list_of_dicts: + if not isinstance(list_item, dict): + # List item is not a dict as required. Skip this item + continue + + item_value = list_item.get(key) + + # Perform case-insensitive comparison if value and item_value are strings and case_sensitive is False + if not case_sensitive and isinstance(value, str) and isinstance(item_value, str): + if item_value.casefold() == value.casefold(): + return list_item + elif item_value == value: + # Match. Return this item + return list_item + + # No Match + if required is True: + raise ValueError(custom_error_msg or var_name) + return default diff --git a/anta/tools/get_value.py b/anta/tools/get_value.py new file mode 100644 index 0000000..5e4b84d --- /dev/null +++ b/anta/tools/get_value.py @@ -0,0 +1,56 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Get a value from a dictionary or nested dictionaries. +""" +from __future__ import annotations + +from typing import Any, Optional + + +# pylint: disable=too-many-arguments +def get_value( + dictionary: dict[Any, Any], key: str, default: Optional[Any] = None, required: bool = False, org_key: Optional[str] = None, separator: str = "." +) -> Any: + """ + Get a value from a dictionary or nested dictionaries. + Key supports dot-notation like "foo.bar" to do deeper lookups. + Returns the supplied default value or None if the key is not found and required is False. + Parameters + ---------- + dictionary : dict + Dictionary to get key from + key : str + Dictionary Key - supporting dot-notation for nested dictionaries + default : any + Default value returned if the key is not found + required : bool + Fail if the key is not found + org_key : str + Internal variable used for raising exception with the full key name even when called recursively + separator: str + String to use as the separator parameter in the split function. Useful in cases when the key + can contain variables with "." inside (e.g. hostnames) + Returns + ------- + any + Value or default value + Raises + ------ + ValueError + If the key is not found and required == True + """ + + if org_key is None: + org_key = key + keys = key.split(separator) + value = dictionary.get(keys[0]) + if value is None: + if required: + raise ValueError(org_key) + return default + + if len(keys) > 1: + return get_value(value, separator.join(keys[1:]), default=default, required=required, org_key=org_key, separator=separator) + return value diff --git a/anta/tools/misc.py b/anta/tools/misc.py new file mode 100644 index 0000000..c01f7f4 --- /dev/null +++ b/anta/tools/misc.py @@ -0,0 +1,26 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Toolkit for ANTA. +""" +from __future__ import annotations + +import logging +import traceback + +logger = logging.getLogger(__name__) + + +def exc_to_str(exception: BaseException) -> str: + """ + Helper function that returns a human readable string from an BaseException object + """ + return f"{type(exception).__name__}{f' ({exception})' if str(exception) else ''}" + + +def tb_to_str(exception: BaseException) -> str: + """ + Helper function that returns a traceback string from an BaseException object + """ + return "Traceback (most recent call last):\n" + "".join(traceback.format_tb(exception.__traceback__)) diff --git a/anta/tools/utils.py b/anta/tools/utils.py new file mode 100644 index 0000000..e361d1e --- /dev/null +++ b/anta/tools/utils.py @@ -0,0 +1,34 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Toolkit for ANTA. +""" +from __future__ import annotations + +from typing import Any + + +def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, Any]) -> str: + """ + Get the failed log for a test. + Returns the failed log or an empty string if there is no difference between the expected and actual output. + + Parameters: + expected_output (dict): Expected output of a test. + actual_output (dict): Actual output of a test + + Returns: + str: Failed log of a test. + """ + failed_logs = [] + + for element, expected_data in expected_output.items(): + actual_data = actual_output.get(element) + + if actual_data is None: + failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but it was not found in the actual output.") + elif actual_data != expected_data: + failed_logs.append(f"\nExpected `{expected_data}` as the {element}, but found `{actual_data}` instead.") + + return "".join(failed_logs) diff --git a/docs/README.md b/docs/README.md new file mode 100755 index 0000000..d47d3fe --- /dev/null +++ b/docs/README.md @@ -0,0 +1,70 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +[![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/arista-netdevops-community/anta/blob/main/LICENSE) +[![Linting and Testing Anta](https://github.com/arista-netdevops-community/anta/actions/workflows/code-testing.yml/badge.svg)](https://github.com/arista-netdevops-community/anta/actions/workflows/code-testing.yml) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/m/arista-netdevops-community/anta) +[![github release](https://img.shields.io/github/release/arista-netdevops-community/anta.svg)](https://github.com/arista-netdevops-community/anta/releases/) +![PyPI - Downloads](https://img.shields.io/pypi/dm/anta) +![coverage](https://raw.githubusercontent.com/arista-netdevops-community/anta/coverage-badge/latest-release-coverage.svg) + +# Arista Network Test Automation (ANTA) Framework + +ANTA is Python framework that automates tests for Arista devices. + +- ANTA provides a [set of tests](api/tests.md) to validate the state of your network +- ANTA can be used to: + - Automate NRFU (Network Ready For Use) test on a preproduction network + - Automate tests on a live network (periodically or on demand) +- ANTA can be used with: + - The [ANTA CLI](cli/overview.md) + - As a [Python library](advanced_usages/as-python-lib.md) in your own application + +![anta nrfu](https://raw.githubusercontent.com/arista-netdevops-community/anta/main/docs/imgs/anta-nrfu.svg) + +```bash +# Install ANTA CLI +$ pip install anta + +# Run ANTA CLI +$ anta --help +Usage: anta [OPTIONS] COMMAND [ARGS]... + + Arista Network Test Automation (ANTA) CLI + +Options: + --version Show the version and exit. + --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] + -l, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG] + ANTA logging level [env var: + ANTA_LOG_LEVEL; default: INFO] + --help Show this message and exit. + +Commands: + 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 +``` + +> [!WARNING] +> The ANTA CLI options have changed after version 0.11 and have moved away from the top level `anta` and are now required at their respective commands (e.g. `anta nrfu`). This breaking change occurs after users feedback on making the CLI more intuitive. This change should not affect user experience when using environment variables. + +## Documentation + +The documentation is published on [ANTA package website](https://www.anta.ninja). Also, a [demo repository](https://github.com/titom73/atd-anta-demo) is available to facilitate your journey with ANTA. + +## Contribution guide + +Contributions are welcome. Please refer to the [contribution guide](contribution.md) + +## Credits + +Thank you to [Angélique Phillipps](https://github.com/aphillipps), [Colin MacGiollaEáin](https://github.com/colinmacgiolla), [Khelil Sator](https://github.com/ksator), [Matthieu Tache](https://github.com/mtache), [Onur Gashi](https://github.com/onurgashi), [Paul Lavelle](https://github.com/paullavelle), [Guillaume Mulocher](https://github.com/gmuloc) and [Thomas Grimonet](https://github.com/titom73) for their contributions and guidances. diff --git a/docs/advanced_usages/as-python-lib.md b/docs/advanced_usages/as-python-lib.md new file mode 100644 index 0000000..b4ce654 --- /dev/null +++ b/docs/advanced_usages/as-python-lib.md @@ -0,0 +1,315 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +ANTA is a Python library that can be used in user applications. This section describes how you can leverage ANTA Python modules to help you create your own NRFU solution. + +!!! tip + If you are unfamiliar with asyncio, refer to the Python documentation relevant to your Python version - https://docs.python.org/3/library/asyncio.html + +## [AntaDevice](../api/device.md#anta.device.AntaDevice) Abstract Class + +A device is represented in ANTA as a instance of a subclass of the [AntaDevice](../api/device.md### ::: anta.device.AntaDevice) abstract class. +There are few abstract methods that needs to be implemented by child classes: + +- The [collect()](../api/device.md#anta.device.AntaDevice.collect) coroutine is in charge of collecting outputs of [AntaCommand](../api/models.md#anta.models.AntaCommand) instances. +- The [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutine is in charge of updating attributes of the [AntaDevice](../api/device.md### ::: anta.device.AntaDevice) instance. These attributes are used by [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) to filter out unreachable devices or by [AntaTest](../api/models.md#anta.models.AntaTest) to skip devices based on their hardware models. + +The [copy()](../api/device.md#anta.device.AntaDevice.copy) coroutine is used to copy files to and from the device. It does not need to be implemented if tests are not using it. + +### [AsyncEOSDevice](../api/device.md#anta.device.AsyncEOSDevice) Class + +The [AsyncEOSDevice](../api/device.md#anta.device.AsyncEOSDevice) class is an implementation of [AntaDevice](../api/device.md#anta.device.AntaDevice) for Arista EOS. +It uses the [aio-eapi](https://github.com/jeremyschulman/aio-eapi) eAPI client and the [AsyncSSH](https://github.com/ronf/asyncssh) library. + +- The [collect()](../api/device.md#anta.device.AsyncEOSDevice.collect) coroutine collects [AntaCommand](../api/models.md#anta.models.AntaCommand) outputs using eAPI. +- The [refresh()](../api/device.md#anta.device.AsyncEOSDevice.refresh) coroutine tries to open a TCP connection on the eAPI port and update the `is_online` attribute accordingly. If the TCP connection succeeds, it sends a `show version` command to gather the hardware model of the device and updates the `established` and `hw_model` attributes. +- The [copy()](../api/device.md#anta.device.AsyncEOSDevice.copy) coroutine copies files to and from the device using the SCP protocol. + +## [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) Class + +The [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) class is a subclass of the standard Python type [dict](https://docs.python.org/3/library/stdtypes.html#dict). The keys of this dictionary are the device names, the values are [AntaDevice](../api/device.md#anta.device.AntaDevice) instances. + + +[AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) provides methods to interact with the ANTA inventory: + +- The [add_device()](../api/inventory.md#anta.inventory.AntaInventory.add_device) method adds an [AntaDevice](../api/device.md### ::: anta.device.AntaDevice) instance to the inventory. Adding an entry to [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) with a key different from the device name is not allowed. +- The [get_inventory()](../api/inventory.md#anta.inventory.AntaInventory.get_inventory) returns a new [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) instance with filtered out devices based on the method inputs. +- The [connect_inventory()](../api/inventory.md#anta.inventory.AntaInventory.connect_inventory) coroutine will execute the [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutines of all the devices in the inventory. +- The [parse()](../api/inventory.md#anta.inventory.AntaInventory.parse) static method creates an [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) instance from a YAML file and returns it. The devices are [AsyncEOSDevice](../api/device.md#anta.device.AsyncEOSDevice) instances. + + +To parse a YAML inventory file and print the devices connection status: + +```python +""" +Example +""" +import asyncio + +from anta.inventory import AntaInventory + + +async def main(inv: AntaInventory) -> None: + """ + Take an AntaInventory and: + 1. try to connect to every device in the inventory + 2. print a message for every device connection status + """ + await inv.connect_inventory() + + for device in inv.values(): + if device.established: + print(f"Device {device.name} is online") + else: + print(f"Could not connect to device {device.name}") + +if __name__ == "__main__": + # Create the AntaInventory instance + inventory = AntaInventory.parse( + filename="inv.yml", + username="arista", + password="@rista123", + timeout=15, + ) + + # Run the main coroutine + res = asyncio.run(main(inventory)) +``` + +??? note "How to create your inventory file" + Please visit this [dedicated section](../usage-inventory-catalog.md) for how to use inventory and catalog files. + +To run an EOS commands list on the reachable devices from the inventory: +```python +""" +Example +""" +# This is needed to run the script for python < 3.10 for typing annotations +from __future__ import annotations + +import asyncio +from pprint import pprint + +from anta.inventory import AntaInventory +from anta.models import AntaCommand + + +async def main(inv: AntaInventory, commands: list[str]) -> dict[str, list[AntaCommand]]: + """ + Take an AntaInventory and a list of commands as string and: + 1. try to connect to every device in the inventory + 2. collect the results of the commands from each device + + Returns: + a dictionary where key is the device name and the value is the list of AntaCommand ran towards the device + """ + await inv.connect_inventory() + + # Make a list of coroutine to run commands towards each connected device + coros = [] + # dict to keep track of the commands per device + result_dict = {} + for name, device in inv.get_inventory(established_only=True).items(): + anta_commands = [AntaCommand(command=command, ofmt="json") for command in commands] + result_dict[name] = anta_commands + coros.append(device.collect_commands(anta_commands)) + + # Run the coroutines + await asyncio.gather(*coros) + + return result_dict + + +if __name__ == "__main__": + # Create the AntaInventory instance + inventory = AntaInventory.parse( + filename="inv.yml", + username="arista", + password="@rista123", + timeout=15, + ) + + # Create a list of commands with json output + commands = ["show version", "show ip bgp summary"] + + # Run the main asyncio entry point + res = asyncio.run(main(inventory, commands)) + + pprint(res) +``` + + +## Use tests from ANTA + +All the test classes inherit from the same abstract Base Class AntaTest. The Class definition indicates which commands are required for the test and the user should focus only on writing the `test` function with optional keywords argument. The instance of the class upon creation instantiates a TestResult object that can be accessed later on to check the status of the test ([unset, skipped, success, failure, error]). + +### Test structure + +All tests are built on a class named `AntaTest` which provides a complete toolset for a test: + +- Object creation +- Test definition +- TestResult definition +- Abstracted method to collect data + +This approach means each time you create a test it will be based on this `AntaTest` class. Besides that, you will have to provide some elements: + +- `name`: Name of the test +- `description`: A human readable description of your test +- `categories`: a list of categories to sort test. +- `commands`: a list of command to run. This list _must_ be a list of `AntaCommand` which is described in the next part of this document. + +Here is an example of a hardware test related to device temperature: + +```python +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, cast + +from anta.models import AntaTest, AntaCommand + + +class VerifyTemperature(AntaTest): + """ + Verifies device temparture is currently OK. + """ + + # The test name + name = "VerifyTemperature" + # A small description of the test, usually the first line of the class docstring + description = "Verifies device temparture is currently OK" + # The category of the test, usually the module name + categories = ["hardware"] + # The command(s) used for the test. Could be a template instead + commands = [AntaCommand(command="show system environment temperature", ofmt="json")] + + # Decorator + @AntaTest.anta_test + # abstract method that must be defined by the child Test class + def test(self) -> None: + """Run VerifyTemperature validation""" + command_output = cast(Dict[str, Dict[Any, Any]], self.instance_commands[0].output) + temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else "" + if temperature_status == "temperatureOk": + self.result.is_success() + else: + self.result.is_failure(f"Device temperature is not OK, systemStatus: {temperature_status }") +``` + +When you run the test, object will automatically call its `anta.models.AntaTest.collect()` method to get device output for each command if no pre-collected data was given to the test. This method does a loop to call `anta.inventory.models.InventoryDevice.collect()` methods which is in charge of managing device connection and how to get data. + +??? info "run test offline" + You can also pass eos data directly to your test if you want to validate data collected in a different workflow. An example is provided below just for information: + + ```python + test = VerifyTemperature(device, eos_data=test_data["eos_data"]) + asyncio.run(test.test()) + ``` + +The `test` function is always the same and __must__ be defined with the `@AntaTest.anta_test` decorator. This function takes at least one argument which is a `anta.inventory.models.InventoryDevice` object. +In some cases a test would rely on some additional inputs from the user, for instance the number of expected peers or some expected numbers. All parameters __must__ come with a default value and the test function __should__ validate the parameters values (at this stage this is the only place where validation can be done but there are future plans to make this better). + +```python +class VerifyTemperature(AntaTest): + ... + @AntaTest.anta_test + def test(self) -> None: + pass + +class VerifyTransceiversManufacturers(AntaTest): + ... + @AntaTest.anta_test + def test(self, manufacturers: Optional[List[str]] = None) -> None: + # validate the manufactures parameter + pass +``` + +The test itself does not return any value, but the result is directly availble from your AntaTest object and exposes a `anta.result_manager.models.TestResult` object with result, name of the test and optional messages: + + +- `name` (str): Device name where the test has run. +- `test` (str): Test name runs on the device. +- `categories` (List[str]): List of categories the TestResult belongs to, by default the AntaTest categories. +- `description` (str): TestResult description, by default the AntaTest description. +- `results` (str): Result of the test. Can be one of ["unset", "success", "failure", "error", "skipped"]. +- `message` (str, optional): Message to report after the test if any. +- `custom_field` (str, optional): Custom field to store a string for flexibility in integrating with ANTA + +```python +from anta.tests.hardware import VerifyTemperature + +test = VerifyTemperature(device, eos_data=test_data["eos_data"]) +asyncio.run(test.test()) +assert test.result.result == "success" +``` + +### Classes for commands + +To make it easier to get data, ANTA defines 2 different classes to manage commands to send to devices: + +#### [AntaCommand](../api/models.md#anta.models.AntaCommand) Class + +Represent a command with following information: + +- Command to run +- Ouput format expected +- eAPI version +- Output of the command + +Usage example: + +```python +from anta.models import AntaCommand + +cmd1 = AntaCommand(command="show zerotouch") +cmd2 = AntaCommand(command="show running-config diffs", ofmt="text") +``` + +!!! tip "Command revision and version" + * Most of EOS commands return a JSON structure according to a model (some commands may not be modeled hence the necessity to use `text` outformat sometimes. + * The model can change across time (adding feature, ... ) and when the model is changed in a non backward-compatible way, the __revision__ number is bumped. The initial model starts with __revision__ 1. + * A __revision__ applies to a particular CLI command whereas a __version__ is global to an eAPI call. The __version__ is internally translated to a specific __revision__ for each CLI command in the RPC call. The currently supported __version__ vaues are `1` and `latest`. + * A __revision takes precedence over a version__ (e.g. if a command is run with version="latest" and revision=1, the first revision of the model is returned) + * By default eAPI returns the first revision of each model to ensure that when upgrading, intergation with existing tools is not broken. This is done by using by default `version=1` in eAPI calls. + + ANTA uses by default `version="latest"` in AntaCommand. For some commands, you may want to run them with a different revision or version. + + For instance the `VerifyRoutingTableSize` test leverages the first revision of `show bfd peers`: + + ``` + # revision 1 as later revision introduce additional nesting for type + commands = [AntaCommand(command="show bfd peers", revision=1)] + ``` + +#### [AntaTemplate](../api/models.md#anta.models.AntaTemplate) Class + +Because some command can require more dynamic than just a command with no parameter provided by user, ANTA supports command template: you define a template in your test class and user provide parameters when creating test object. + +```python + +class RunArbitraryTemplateCommand(AntaTest): + """ + Run an EOS command and return result + Based on AntaTest to build relevant output for pytest + """ + + name = "Run aributrary EOS command" + description = "To be used only with anta debug commands" + template = AntaTemplate(template="show interfaces {ifd}") + categories = ["debug"] + + @AntaTest.anta_test + def test(self) -> None: + errdisabled_interfaces = [interface for interface, value in response["interfaceStatuses"].items() if value["linkStatus"] == "errdisabled"] + ... + + +params = [{"ifd": "Ethernet2"}, {"ifd": "Ethernet49/1"}] +run_command1 = RunArbitraryTemplateCommand(device_anta, params) +``` + +In this example, test waits for interfaces to check from user setup and will only check for interfaces in `params` diff --git a/docs/advanced_usages/caching.md b/docs/advanced_usages/caching.md new file mode 100644 index 0000000..cec2467 --- /dev/null +++ b/docs/advanced_usages/caching.md @@ -0,0 +1,87 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +ANTA is a streamlined Python framework designed for efficient interaction with network devices. This section outlines how ANTA incorporates caching mechanisms to collect command outputs from network devices. + +## Configuration + +By default, ANTA utilizes [aiocache](https://github.com/aio-libs/aiocache)'s memory cache backend, also called [`SimpleMemoryCache`](https://aiocache.aio-libs.org/en/v0.12.2/caches.html#simplememorycache). This library aims for simplicity and supports asynchronous operations to go along with Python `asyncio` used in ANTA. + +The `_init_cache()` method of the [AntaDevice](../advanced_usages/as-python-lib.md#antadevice-abstract-class) abstract class initializes the cache. Child classes can override this method to tweak the cache configuration: + +```python +def _init_cache(self) -> None: + """ + Initialize cache for the device, can be overridden by subclasses to manipulate how it works + """ + self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()]) + self.cache_locks = defaultdict(asyncio.Lock) +``` + +The cache is also configured with `aiocache`'s [`HitMissRatioPlugin`](https://aiocache.aio-libs.org/en/v0.12.2/plugins.html#hitmissratioplugin) plugin to calculate the ratio of hits the cache has and give useful statistics for logging purposes in ANTA. + +## Cache key design + +The cache is initialized per `AntaDevice` and uses the following cache key design: + +`<device_name>:<uid>` + +The `uid` is an attribute of [AntaCommand](../advanced_usages/as-python-lib.md#antacommand-class), which is a unique identifier generated from the command, version, revision and output format. + +Each UID has its own asyncio lock. This design allows coroutines that need to access the cache for different UIDs to do so concurrently. The locks are managed by the `self.cache_locks` dictionary. + +## Mechanisms + +By default, once the cache is initialized, it is used in the `collect()` method of `AntaDevice`. The `collect()` method prioritizes retrieving the output of the command from the cache. If the output is not in the cache, the private `_collect()` method will retrieve and then store it for future access. + +## How to disable caching + +Caching is enabled by default in ANTA following the previous configuration and mechanisms. + +There might be scenarios where caching is not wanted. You can disable caching in multiple ways in ANTA: + +1. Caching can be disabled globally, for **ALL** commands on **ALL** devices, using the `--disable-cache` global flag when invoking anta at the [CLI](../cli/overview.md#invoking-anta-cli): + ```bash + anta --disable-cache --username arista --password arista nrfu table + ``` +2. Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` when definining the ANTA [Inventory](../usage-inventory-catalog.md#create-an-inventory-file) file: + ```yaml + anta_inventory: + hosts: + - host: 172.20.20.101 + name: DC1-SPINE1 + tags: ["SPINE", "DC1"] + disable_cache: True # Set this key to True + - host: 172.20.20.102 + name: DC1-SPINE2 + tags: ["SPINE", "DC1"] + disable_cache: False # Optional since it's the default + + networks: + - network: "172.21.21.0/24" + disable_cache: True + + ranges: + - start: 172.22.22.10 + end: 172.22.22.19 + disable_cache: True + ``` + This approach effectively disables caching for **ALL** commands sent to devices targeted by the `disable_cache` key. + +3. For tests developpers, caching can be disabled for a specific [`AntaCommand`](../advanced_usages/as-python-lib.md#antacommand-class) or [`AntaTemplate`](../advanced_usages/as-python-lib.md#antatemplate-class) by setting the `use_cache` attribute to `False`. That means the command output will always be collected on the device and therefore, never use caching. + +### Disable caching in a child class of `AntaDevice` + +Since caching is implemented at the `AntaDevice` abstract class level, all subclasses will inherit that default behavior. As a result, if you need to disable caching in any custom implementation of `AntaDevice` outside of the ANTA framework, you must initialize `AntaDevice` with `disable_cache` set to `True`: + +```python +class AnsibleEOSDevice(AntaDevice): + """ + Implementation of an AntaDevice using Ansible HttpApi plugin for EOS. + """ + def __init__(self, name: str, connection: ConnectionBase, tags: list = None) -> None: + super().__init__(name, tags, disable_cache=True) +``` diff --git a/docs/advanced_usages/custom-tests.md b/docs/advanced_usages/custom-tests.md new file mode 100644 index 0000000..87402c1 --- /dev/null +++ b/docs/advanced_usages/custom-tests.md @@ -0,0 +1,324 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +!!! info "" + This documentation applies for both creating tests in ANTA or creating your own test package. + +ANTA is not only a Python library with a CLI and a collection of built-in tests, it is also a framework you can extend by building your own tests. + +## Generic approach + +A test is a Python class where a test function is defined and will be run by the framework. + +ANTA provides an abstract class [AntaTest](../api/models.md#anta.models.AntaTest). This class does the heavy lifting and provide the logic to define, collect and test data. The code below is an example of a simple test in ANTA, which is an [AntaTest](../api/models.md#anta.models.AntaTest) subclass: + +```python +from anta.models import AntaTest, AntaCommand +from anta.decorators import skip_on_platforms + + +class VerifyTemperature(AntaTest): + """ + This test verifies if the device temperature is within acceptable limits. + + Expected Results: + * success: The test will pass if the device temperature is currently OK: 'temperatureOk'. + * failure: The test will fail if the device temperature is NOT OK. + """ + + name = "VerifyTemperature" + description = "Verifies if the device temperature is within the acceptable range." + categories = ["hardware"] + commands = [AntaCommand(command="show system environment temperature", ofmt="json")] + + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + command_output = self.instance_commands[0].json_output + temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else "" + if temperature_status == "temperatureOk": + self.result.is_success() + else: + self.result.is_failure(f"Device temperature exceeds acceptable limits. Current system status: '{temperature_status}'") +``` + +[AntaTest](../api/models.md#anta.models.AntaTest) also provide more advanced capabilities like [AntaCommand](../api/models.md#anta.models.AntaCommand) templating using the [AntaTemplate](../api/models.md#anta.models.AntaTemplate) class or test inputs definition and validation using [AntaTest.Input](../api/models.md#anta.models.AntaTest.Input) [pydantic](https://docs.pydantic.dev/latest/) model. This will be discussed in the sections below. + +## [AntaTest](../api/models.md#anta.models.AntaTest) structure + +### Class Attributes + +- `name` (`str`): Name of the test. Used during reporting. +- `description` (`str`): A human readable description of your test. +- `categories` (`list[str]`): A list of categories in which the test belongs. +- `commands` (`list[Union[AntaTemplate, AntaCommand]]`): A list of command to collect from devices. This list __must__ be a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) or [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances. Rendering [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances will be discussed later. + +!!! info + All these class attributes are mandatory. If any attribute is missing, a `NotImplementedError` exception will be raised during class instantiation. + +### Instance Attributes + +!!! info + You can access an instance attribute in your code using the `self` reference. E.g. you can access the test input values using `self.inputs`. + +::: anta.models.AntaTest + options: + show_docstring_attributes: true + show_root_heading: false + show_bases: false + show_docstring_description: false + show_docstring_examples: false + show_docstring_parameters: false + members: false + show_source: false + show_root_toc_entry: false + heading_level: 10 + + +!!! note "Logger object" + ANTA already provides comprehensive logging at every steps of a test execution. The [AntaTest](../api/models.md#anta.models.AntaTest) class also provides a `logger` attribute that is a Python logger specific to the test instance. See [Python documentation](https://docs.python.org/3/library/logging.html) for more information. + +!!! note "AntaDevice object" + Even if `device` is not a private attribute, you should not need to access this object in your code. + +### Test Inputs + +[AntaTest.Input](../api/models.md#anta.models.AntaTest.Input) is a [pydantic model](https://docs.pydantic.dev/latest/usage/models/) that allow test developers to define their test inputs. [pydantic](https://docs.pydantic.dev/latest/) provides out of the box [error handling](https://docs.pydantic.dev/latest/usage/models/#error-handling) for test input validation based on the type hints defined by the test developer. + +The base definition of [AntaTest.Input](../api/models.md#anta.models.AntaTest.Input) provides common test inputs for all [AntaTest](../api/models.md#anta.models.AntaTest) instances: + +#### [Input](../api/models.md#anta.models.AntaTest.Input) model + +::: anta.models.AntaTest.Input + options: + show_docstring_attributes: true + show_root_heading: false + show_category_heading: false + show_bases: false + show_docstring_description: false + show_docstring_examples: false + show_docstring_parameters: false + show_source: false + members: false + show_root_toc_entry: false + heading_level: 10 + +#### [ResultOverwrite](../api/models.md#anta.models.AntaTest.Input.ResultOverwrite) model + +::: anta.models.AntaTest.Input.ResultOverwrite + options: + show_docstring_attributes: true + show_root_heading: false + show_category_heading: false + show_bases: false + show_docstring_description: false + show_docstring_examples: false + show_docstring_parameters: false + show_source: false + show_root_toc_entry: false + heading_level: 10 + +!!! note + The pydantic model is configured using the [`extra=forbid`](https://docs.pydantic.dev/latest/usage/model_config/#extra-attributes) that will fail input validation if extra fields are provided. + +### Methods + +- [test(self) -> None](../api/models.md#anta.models.AntaTest.test): This is an abstract method that __must__ be implemented. It contains the test logic that can access the collected command outputs using the `instance_commands` instance attribute, access the test inputs using the `inputs` instance attribute and __must__ set the `result` instance attribute accordingly. It must be implemented using the `AntaTest.anta_test` decorator that provides logging and will collect commands before executing the `test()` method. +- [render(self, template: AntaTemplate) -> list[AntaCommand]](../api/models.md#anta.models.AntaTest.render): This method only needs to be implemented if [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances are present in the `commands` class attribute. It will be called for every [AntaTemplate](../api/models.md#anta.models.AntaTemplate) occurence and __must__ return a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) using the [AntaTemplate.render()](../api/models.md#anta.models.AntaTemplate.render) method. It can access test inputs using the `inputs` instance attribute. + +## Test execution + +Below is a high level description of the test execution flow in ANTA: + +1. ANTA will parse the test catalog to get the list of [AntaTest](../api/models.md#anta.models.AntaTest) subclasses to instantiate and their associated input values. We consider a single [AntaTest](../api/models.md#anta.models.AntaTest) subclass in the following steps. + +2. ANTA will instantiate the [AntaTest](../api/models.md#anta.models.AntaTest) subclass and a single device will be provided to the test instance. The `Input` model defined in the class will also be instantiated at this moment. If any [ValidationError](https://docs.pydantic.dev/latest/errors/errors/) is raised, the test execution will be stopped. + +3. If there is any [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instance in the `commands` class attribute, [render()](../api/models.md#anta.models.AntaTest.render) will be called for every occurrence. At this moment, the `instance_commands` attribute has been initialized. If any rendering error occurs, the test execution will be stopped. + +4. The `AntaTest.anta_test` decorator will collect the commands from the device and update the `instance_commands` attribute with the outputs. If any collection error occurs, the test execution will be stopped. + +5. The [test()](../api/models.md#anta.models.AntaTest.test) method is executed. + +## Writing an AntaTest subclass + +In this section, we will go into all the details of writing an [AntaTest](../api/models.md#anta.models.AntaTest) subclass. + +### Class definition + +Import [anta.models.AntaTest](../api/models.md#anta.models.AntaTest) and define your own class. +Define the mandatory class attributes using [anta.models.AntaCommand](../api/models.md#anta.models.AntaCommand), [anta.models.AntaTemplate](../api/models.md#anta.models.AntaTemplate) or both. + +!!! info + Caching can be disabled per `AntaCommand` or `AntaTemplate` by setting the `use_cache` argument to `False`. For more details about how caching is implemented in ANTA, please refer to [Caching in ANTA](../advanced_usages/caching.md). + +```python +from anta.models import AntaTest, AntaCommand, AntaTemplate + + +class <YourTestName>(AntaTest): + """ + <a docstring description of your test> + """ + + name = "YourTestName" # should be your class name + description = "<test description in human reading format>" + categories = ["<arbitrary category>", "<another arbitrary category>"] + commands = [ + AntaCommand( + command="<EOS command to run>", + ofmt="<command format output>", + version="<eAPI version to use>", + revision="<revision to use for the command>", # revision has precedence over version + use_cache="<Use cache for the command>", + ), + AntaTemplate( + template="<Python f-string to render an EOS command>", + ofmt="<command format output>", + version="<eAPI version to use>", + revision="<revision to use for the command>", # revision has precedence over version + use_cache="<Use cache for the command>", + ) + ] +``` + +### Inputs definition + +If the user needs to provide inputs for your test, you need to define a [pydantic model](https://docs.pydantic.dev/latest/usage/models/) that defines the schema of the test inputs: + +```python +class <YourTestName>(AntaTest): + ... + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + <input field name>: <input field type> + """<input field docstring>""" +``` + +To define an input field type, refer to the [pydantic documentation](https://docs.pydantic.dev/latest/usage/types/types/) about types. +You can also leverage [anta.custom_types](../api/types.md) that provides reusable types defined in ANTA tests. + +Regarding required, optional and nullable fields, refer to this [documentation](https://docs.pydantic.dev/latest/migration/#required-optional-and-nullable-fields) on how to define them. + +!!! note + All the `pydantic` features are supported. For instance you can define [validators](https://docs.pydantic.dev/latest/usage/validators/) for complex input validation. + +### Template rendering + +Define the `render()` method if you have [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances in your `commands` class attribute: + +```python +class <YourTestName>(AntaTest): + ... + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(<template param>=input_value) for input_value in self.inputs.<input_field>] +``` + +You can access test inputs and render as many [AntaCommand](../api/models.md#anta.models.AntaCommand) as desired. + +### Test definition + +Implement the `test()` method with your test logic: + +```python +class <YourTestName>(AntaTest): + ... + @AntaTest.anta_test + def test(self) -> None: + pass +``` + +The logic usually includes the following different stages: +1. Parse the command outputs using the `self.instance_commands` instance attribute. +2. If needed, access the test inputs using the `self.inputs` instance attribute and write your conditional logic. +3. Set the `result` instance attribute to reflect the test result by either calling `self.result.is_success()` or `self.result.is_failure("<FAILURE REASON>")`. Sometimes, setting the test result to `skipped` using `self.result.is_skipped("<SKIPPED REASON>")` can make sense (e.g. testing the OSPF neighbor states but no neighbor was found). However, you should not need to catch any exception and set the test result to `error` since the error handling is done by the framework, see below. + +The example below is based on the [VerifyTemperature](../api/tests.hardware.md#anta.tests.hardware.VerifyTemperature) test. + +```python +class VerifyTemperature(AntaTest): + ... + @AntaTest.anta_test + def test(self) -> None: + # Grab output of the collected command + command_output = self.instance_commands[0].json_output + + # Do your test: In this example we check a specific field of the JSON output from EOS + temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else "" + if temperature_status == "temperatureOk": + self.result.is_success() + else: + self.result.is_failure(f"Device temperature exceeds acceptable limits. Current system status: '{temperature_status}'") +``` + +As you can see there is no error handling to do in your code. Everything is packaged in the `AntaTest.anta_tests` decorator and below is a simple example of error captured when trying to access a dictionary with an incorrect key: + +```python +class VerifyTemperature(AntaTest): + ... + @AntaTest.anta_test + def test(self) -> None: + # Grab output of the collected command + command_output = self.instance_commands[0].json_output + + # Access the dictionary with an incorrect key + command_output['incorrectKey'] +``` + +```bash +ERROR Exception raised for test VerifyTemperature (on device 192.168.0.10) - KeyError ('incorrectKey') +``` + +!!! info "Get stack trace for debugging" + If you want to access to the full exception stack, you can run ANTA in debug mode by setting the `ANTA_DEBUG` environment variable to `true`. Example: + ```bash + $ ANTA_DEBUG=true anta nrfu --catalog test_custom.yml text + ``` + +### Test decorators + +In addition to the required `AntaTest.anta_tests` decorator, ANTA offers a set of optional decorators for further test customization: + +- `anta.decorators.deprecated_test`: Use this to log a message of WARNING severity when a test is deprecated. +- `anta.decorators.skip_on_platforms`: Use this to skip tests for functionalities that are not supported on specific platforms. + +```python +from anta.decorators import skip_on_platforms + +class VerifyTemperature(AntaTest): + ... + @skip_on_platforms(["cEOSLab", "vEOS-lab"]) + @AntaTest.anta_test + def test(self) -> None: + pass +``` + +## Access your custom tests in the test catalog + +!!! warning "" + This section is required only if you are not merging your development into ANTA. Otherwise, just follow [contribution guide](../contribution.md). + +For that, you need to create your own Python package as described in this [hitchhiker's guide](https://the-hitchhikers-guide-to-packaging.readthedocs.io/en/latest/) to package Python code. We assume it is well known and we won't focus on this aspect. Thus, your package must be impartable by ANTA hence available in the module search path `sys.path` (you can use `PYTHONPATH` for example). + +It is very similar to what is documented in [catalog section](../usage-inventory-catalog.md) but you have to use your own package name.2 + +Let say the custom Python package is `anta_titom73` and the test is defined in `anta_titom73.dc_project` Python module, the test catalog would look like: + +```yaml +anta_titom73.dc_project: + - VerifyFeatureX: + minimum: 1 +``` +And now you can run your NRFU tests with the CLI: + +```bash +anta nrfu text --catalog test_custom.yml +spine01 :: verify_dynamic_vlan :: FAILURE (Device has 0 configured, we expect at least 1) +spine02 :: verify_dynamic_vlan :: FAILURE (Device has 0 configured, we expect at least 1) +leaf01 :: verify_dynamic_vlan :: SUCCESS +leaf02 :: verify_dynamic_vlan :: SUCCESS +leaf03 :: verify_dynamic_vlan :: SUCCESS +leaf04 :: verify_dynamic_vlan :: SUCCESS +``` diff --git a/docs/api/catalog.md b/docs/api/catalog.md new file mode 100644 index 0000000..fc719ea --- /dev/null +++ b/docs/api/catalog.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +### ::: anta.catalog.AntaCatalog + options: + filters: ["!^_[^_]", "!__str__"] + +### ::: anta.catalog.AntaTestDefinition + +### ::: anta.catalog.AntaCatalogFile diff --git a/docs/api/device.md b/docs/api/device.md new file mode 100644 index 0000000..03cff19 --- /dev/null +++ b/docs/api/device.md @@ -0,0 +1,25 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# AntaDevice base class + +## UML representation + +![](../imgs/uml/anta.device.AntaDevice.jpeg) + +### ::: anta.device.AntaDevice + options: + filters: ["!^_[^_]", "!__(eq|rich_repr)__"] + +# Async EOS device class + +## UML representation + +![](../imgs/uml/anta.device.AsyncEOSDevice.jpeg) + +### ::: anta.device.AsyncEOSDevice + options: + filters: ["!^_[^_]", "!__(eq|rich_repr)__"] diff --git a/docs/api/inventory.md b/docs/api/inventory.md new file mode 100644 index 0000000..5e4400c --- /dev/null +++ b/docs/api/inventory.md @@ -0,0 +1,11 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +### ::: anta.inventory.AntaInventory + options: + filters: ["!^_[^_]", "!__str__"] + +### ::: anta.inventory.exceptions diff --git a/docs/api/inventory.models.input.md b/docs/api/inventory.models.input.md new file mode 100644 index 0000000..a15c20e --- /dev/null +++ b/docs/api/inventory.models.input.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +### ::: anta.inventory.models.AntaInventoryInput + +### ::: anta.inventory.models.AntaInventoryHost + +### ::: anta.inventory.models.AntaInventoryNetwork + +### ::: anta.inventory.models.AntaInventoryRange diff --git a/docs/api/models.md b/docs/api/models.md new file mode 100644 index 0000000..b0c1e91 --- /dev/null +++ b/docs/api/models.md @@ -0,0 +1,37 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Test definition + +## UML Diagram + +![](../imgs/uml/anta.models.AntaTest.jpeg) + +### ::: anta.models.AntaTest + options: + filters: ["!^_[^_]", "!__init_subclass__", "!update_progress"] + +# Command definition + +## UML Diagram + +![](../imgs/uml/anta.models.AntaCommand.jpeg) +### ::: anta.models.AntaCommand + +!!! warning + CLI commands are protected to avoid execution of critical commands such as `reload` or `write erase`. + + - Reload command: `^reload\s*\w*` + - Configure mode: `^conf\w*\s*(terminal|session)*` + - Write: `^wr\w*\s*\w+` + +# Template definition + +## UML Diagram + +![](../imgs/uml/anta.models.AntaTemplate.jpeg) + +### ::: anta.models.AntaTemplate diff --git a/docs/api/report_manager.md b/docs/api/report_manager.md new file mode 100644 index 0000000..f0e3818 --- /dev/null +++ b/docs/api/report_manager.md @@ -0,0 +1,7 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +### ::: anta.reporter.ReportTable diff --git a/docs/api/result_manager.md b/docs/api/result_manager.md new file mode 100644 index 0000000..72e05aa --- /dev/null +++ b/docs/api/result_manager.md @@ -0,0 +1,15 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Result Manager definition + +## UML Diagram + +![](../imgs/uml/anta.result_manager.ResultManager.jpeg) + +### ::: anta.result_manager.ResultManager + options: + filters: ["!^_[^_]", "!^__len__"] diff --git a/docs/api/result_manager_models.md b/docs/api/result_manager_models.md new file mode 100644 index 0000000..096bd03 --- /dev/null +++ b/docs/api/result_manager_models.md @@ -0,0 +1,15 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Test Result model + +## UML Diagram + +![](../imgs/uml/anta.result_manager.models.TestResult.jpeg) + +### ::: anta.result_manager.models.TestResult + options: + filters: ["!^_[^_]", "!__str__"] diff --git a/docs/api/tests.aaa.md b/docs/api/tests.aaa.md new file mode 100644 index 0000000..bdbe7ec --- /dev/null +++ b/docs/api/tests.aaa.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for interfaces tests + +::: anta.tests.aaa + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.bfd.md b/docs/api/tests.bfd.md new file mode 100644 index 0000000..d28521f --- /dev/null +++ b/docs/api/tests.bfd.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for bfd tests + +::: anta.tests.bfd + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.configuration.md b/docs/api/tests.configuration.md new file mode 100644 index 0000000..aaee1f4 --- /dev/null +++ b/docs/api/tests.configuration.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for configuration tests + +::: anta.tests.configuration + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.connectivity.md b/docs/api/tests.connectivity.md new file mode 100644 index 0000000..8a1b8a2 --- /dev/null +++ b/docs/api/tests.connectivity.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for connectivity tests + +::: anta.tests.connectivity + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.field_notices.md b/docs/api/tests.field_notices.md new file mode 100644 index 0000000..ed0e837 --- /dev/null +++ b/docs/api/tests.field_notices.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for Field Notices tests + +::: anta.tests.field_notices + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.hardware.md b/docs/api/tests.hardware.md new file mode 100644 index 0000000..6e84196 --- /dev/null +++ b/docs/api/tests.hardware.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for hardware tests + +::: anta.tests.hardware + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.interfaces.md b/docs/api/tests.interfaces.md new file mode 100644 index 0000000..b21da40 --- /dev/null +++ b/docs/api/tests.interfaces.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for interfaces tests + +::: anta.tests.interfaces + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.logging.md b/docs/api/tests.logging.md new file mode 100644 index 0000000..e9acc20 --- /dev/null +++ b/docs/api/tests.logging.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for logging tests + +::: anta.tests.logging + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.md b/docs/api/tests.md new file mode 100644 index 0000000..40c7d8a --- /dev/null +++ b/docs/api/tests.md @@ -0,0 +1,37 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA Tests landing page + +This section describes all the available tests provided by ANTA package. + + +- [AAA](tests.aaa.md) +- [BFD](tests.bfd.md) +- [Configuration](tests.configuration.md) +- [Connectivity](tests.connectivity.md) +- [Field Notice](tests.field_notices.md) +- [Hardware](tests.hardware.md) +- [Interfaces](tests.interfaces.md) +- [Logging](tests.logging.md) +- [MLAG](tests.mlag.md) +- [Multicast](tests.multicast.md) +- [Profiles](tests.profiles.md) +- [Routing Generic](tests.routing.generic.md) +- [Routing BGP](tests.routing.bgp.md) +- [Routing OSPF](tests.routing.ospf.md) +- [Security](tests.security.md) +- [Services](tests.services.md) +- [SNMP](tests.snmp.md) +- [Software](tests.software.md) +- [STP](tests.stp.md) +- [System](tests.system.md) +- [VLAN](tests.vlan.md) +- [VXLAN](tests.vxlan.md) + + + +All these tests can be imported in a [catalog](../usage-inventory-catalog.md) to be used by [the anta cli](../cli/nrfu.md) or in your [own framework](../advanced_usages/as-python-lib.md) diff --git a/docs/api/tests.mlag.md b/docs/api/tests.mlag.md new file mode 100644 index 0000000..6ce419b --- /dev/null +++ b/docs/api/tests.mlag.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for mlag tests + +::: anta.tests.mlag + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.multicast.md b/docs/api/tests.multicast.md new file mode 100644 index 0000000..2b03420 --- /dev/null +++ b/docs/api/tests.multicast.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for multicast tests + +::: anta.tests.multicast + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.profiles.md b/docs/api/tests.profiles.md new file mode 100644 index 0000000..c6d06e7 --- /dev/null +++ b/docs/api/tests.profiles.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for profiles tests + +::: anta.tests.profiles + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.routing.bgp.md b/docs/api/tests.routing.bgp.md new file mode 100644 index 0000000..2346866 --- /dev/null +++ b/docs/api/tests.routing.bgp.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for BGP tests + +::: anta.tests.routing.bgp + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.routing.generic.md b/docs/api/tests.routing.generic.md new file mode 100644 index 0000000..3853fb0 --- /dev/null +++ b/docs/api/tests.routing.generic.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for routing-generic tests + +::: anta.tests.routing.generic + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.routing.ospf.md b/docs/api/tests.routing.ospf.md new file mode 100644 index 0000000..c4e6fed --- /dev/null +++ b/docs/api/tests.routing.ospf.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for routing-ospf tests + +::: anta.tests.routing.ospf + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.security.md b/docs/api/tests.security.md new file mode 100644 index 0000000..1186b31 --- /dev/null +++ b/docs/api/tests.security.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for security tests + +::: anta.tests.security + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.services.md b/docs/api/tests.services.md new file mode 100644 index 0000000..82a7b38 --- /dev/null +++ b/docs/api/tests.services.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for services tests + +::: anta.tests.services + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.snmp.md b/docs/api/tests.snmp.md new file mode 100644 index 0000000..a015d04 --- /dev/null +++ b/docs/api/tests.snmp.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for SNMP tests + +::: anta.tests.snmp + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.software.md b/docs/api/tests.software.md new file mode 100644 index 0000000..7a2f0ec --- /dev/null +++ b/docs/api/tests.software.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for software tests + +::: anta.tests.software + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.stp.md b/docs/api/tests.stp.md new file mode 100644 index 0000000..f86dac4 --- /dev/null +++ b/docs/api/tests.stp.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for STP tests + +::: anta.tests.stp + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.system.md b/docs/api/tests.system.md new file mode 100644 index 0000000..621c17b --- /dev/null +++ b/docs/api/tests.system.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for system tests + +::: anta.tests.system + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.vlan.md b/docs/api/tests.vlan.md new file mode 100644 index 0000000..0e1aa15 --- /dev/null +++ b/docs/api/tests.vlan.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for vlan tests + +::: anta.tests.vlan + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/tests.vxlan.md b/docs/api/tests.vxlan.md new file mode 100644 index 0000000..a4dcff3 --- /dev/null +++ b/docs/api/tests.vxlan.md @@ -0,0 +1,13 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA catalog for VXLAN tests + +::: anta.tests.vxlan + options: + show_root_heading: false + show_root_toc_entry: false + merge_init_into_class: false diff --git a/docs/api/types.md b/docs/api/types.md new file mode 100644 index 0000000..806ab63 --- /dev/null +++ b/docs/api/types.md @@ -0,0 +1,10 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +### ::: anta.custom_types + options: + show_if_no_docstring: true + show_root_full_path: true diff --git a/docs/cli/check.md b/docs/cli/check.md new file mode 100644 index 0000000..d7dea62 --- /dev/null +++ b/docs/cli/check.md @@ -0,0 +1,36 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA 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 new file mode 100644 index 0000000..1743c7a --- /dev/null +++ b/docs/cli/debug.md @@ -0,0 +1,175 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA debug commands + +The ANTA CLI includes a set of debugging tools, making it easier to build and test ANTA content. This functionality is accessed via the `debug` subcommand and offers the following options: + +- Executing a command on a device from your inventory and retrieving the result. +- Running a templated command on a device from your inventory and retrieving the result. + +These tools are especially helpful in building the tests, as they give a visual access to the output received from the eAPI. They also facilitate the extraction of output content for use in unit tests, as described in our [contribution guide](../contribution.md). + +!!! warning + The `debug` tools require a device from your inventory. Thus, you MUST use a valid [ANTA Inventory](../usage-inventory-catalog.md#create-an-inventory-file). + +## Executing an EOS command + +You can use the `run-cmd` entrypoint to run a command, which includes the following options: + +### Command overview + +```bash +$ anta debug run-cmd --help +Usage: anta debug run-cmd [OPTIONS] + + Run arbitrary command to an ANTA device + +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 [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): + +```bash +anta debug run-cmd --command "show interfaces description" --device DC1-SPINE1 +Run command show interfaces description on DC1-SPINE1 +{ + 'interfaceDescriptions': { + 'Ethernet1': {'lineProtocolStatus': 'up', 'description': 'P2P_LINK_TO_DC1-LEAF1A_Ethernet1', 'interfaceStatus': 'up'}, + 'Ethernet2': {'lineProtocolStatus': 'up', 'description': 'P2P_LINK_TO_DC1-LEAF1B_Ethernet1', 'interfaceStatus': 'up'}, + 'Ethernet3': {'lineProtocolStatus': 'up', 'description': 'P2P_LINK_TO_DC1-BL1_Ethernet1', 'interfaceStatus': 'up'}, + 'Ethernet4': {'lineProtocolStatus': 'up', 'description': 'P2P_LINK_TO_DC1-BL2_Ethernet1', 'interfaceStatus': 'up'}, + 'Loopback0': {'lineProtocolStatus': 'up', 'description': 'EVPN_Overlay_Peering', 'interfaceStatus': 'up'}, + 'Management0': {'lineProtocolStatus': 'up', 'description': 'oob_management', 'interfaceStatus': 'up'} + } +} +``` + +## Executing an EOS command using templates + +The `run-template` entrypoint allows the user to provide an [`f-string`](https://realpython.com/python-f-strings/#f-strings-a-new-and-improved-way-to-format-strings-in-python) templated command. It is followed by a list of arguments (key-value pairs) that build a dictionary used as template parameters. + +### Command overview + +```bash +$ anta debug run-template --help +Usage: anta debug run-template [OPTIONS] PARAMS... + + Run arbitrary templated command to an ANTA device. + + Takes a list of arguments (keys followed by a value) to build a dictionary + used as template parameters. Example: + + anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1 + +Options: + -u, --username TEXT Username to connect to EOS [env var: + 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: + +```bash +anta debug run-template --template "show vlan {vlan_id}" vlan_id 10 --device DC1-LEAF1A +Run templated command 'show vlan {vlan_id}' with {'vlan_id': '10'} on DC1-LEAF1A +{ + 'vlans': { + '10': { + 'name': 'VRFPROD_VLAN10', + 'dynamic': False, + 'status': 'active', + 'interfaces': { + 'Cpu': {'privatePromoted': False, 'blocked': None}, + 'Port-Channel11': {'privatePromoted': False, 'blocked': None}, + 'Vxlan1': {'privatePromoted': False, 'blocked': None} + } + } + }, + 'sourceDetail': '' +} +``` +!!! warning + If multiple arguments of the same key are provided, only the last argument value will be kept in the template parameters. + +### Example of multiple arguments + +```bash +anta -log DEBUG debug run-template --template "ping {dst} source {src}" dst "8.8.8.8" src Loopback0 --device DC1-SPINE1 +> {'dst': '8.8.8.8', 'src': 'Loopback0'} + +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 new file mode 100644 index 0000000..fe39c12 --- /dev/null +++ b/docs/cli/exec.md @@ -0,0 +1,298 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Executing Commands on Devices + +ANTA CLI provides a set of entrypoints to facilitate remote command execution on EOS devices. + +### EXEC Command overview +```bash +anta exec --help +Usage: anta exec [OPTIONS] COMMAND [ARGS]... + + Execute commands to inventory devices + +Options: + --help Show this message and exit. + +Commands: + clear-counters Clear counter statistics on EOS devices + collect-tech-support Collect scheduled tech-support from EOS devices + snapshot Collect commands output from devices in inventory +``` + +## Clear interfaces counters + +This command clears interface counters on EOS devices specified in your inventory. + +### Command overview + +```bash +anta exec clear-counters --help +Usage: anta exec clear-counters [OPTIONS] + + Clear counter statistics on EOS devices + +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 [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 +anta exec clear-counters --tags SPINE +[20:19:13] INFO Connecting to devices... utils.py:43 + INFO Clearing counters on remote devices... utils.py:46 + INFO Cleared counters on DC1-SPINE2 (cEOSLab) utils.py:41 + INFO Cleared counters on DC2-SPINE1 (cEOSLab) utils.py:41 + INFO Cleared counters on DC1-SPINE1 (cEOSLab) utils.py:41 + INFO Cleared counters on DC2-SPINE2 (cEOSLab) +``` + +## Collect a set of commands + +This command collects all the commands specified in a commands-list file, which can be in either `json` or `text` format. + +### Command overview + +```bash +anta exec snapshot --help +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 [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. [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 +--- +json_format: + - show version +text_format: + - show bfd peers +``` +### Example + +```bash +anta exec snapshot --tags SPINE --commands-list ./commands.yaml --output ./ +[20:25:15] INFO Connecting to devices... utils.py:78 + INFO Collecting commands from remote devices utils.py:81 + INFO Collected command 'show version' from device DC2-SPINE1 (cEOSLab) utils.py:76 + INFO Collected command 'show version' from device DC2-SPINE2 (cEOSLab) utils.py:76 + INFO Collected command 'show version' from device DC1-SPINE1 (cEOSLab) utils.py:76 + INFO Collected command 'show version' from device DC1-SPINE2 (cEOSLab) utils.py:76 +[20:25:16] INFO Collected command 'show bfd peers' from device DC2-SPINE2 (cEOSLab) utils.py:76 + INFO Collected command 'show bfd peers' from device DC2-SPINE1 (cEOSLab) utils.py:76 + INFO Collected command 'show bfd peers' from device DC1-SPINE1 (cEOSLab) utils.py:76 + INFO Collected command 'show bfd peers' from device DC1-SPINE2 (cEOSLab) +``` + +The results of the executed commands will be stored in the output directory specified during command execution: + +```bash +tree _2023-07-14_20_25_15 +_2023-07-14_20_25_15 +├── DC1-SPINE1 +│ ├── json +│ │ └── show version.json +│ └── text +│ └── show bfd peers.log +├── DC1-SPINE2 +│ ├── json +│ │ └── show version.json +│ └── text +│ └── show bfd peers.log +├── DC2-SPINE1 +│ ├── json +│ │ └── show version.json +│ └── text +│ └── show bfd peers.log +└── DC2-SPINE2 + ├── json + │ └── show version.json + └── text + └── show bfd peers.log + +12 directories, 8 files +``` + +## Get Scheduled tech-support + +EOS offers a feature that automatically creates a tech-support archive every hour by default. These archives are stored under `/mnt/flash/schedule/tech-support`. + +```eos +leaf1#show schedule summary +Maximum concurrent jobs 1 +Prepend host name to logfile: Yes +Name At Time Last Interval Timeout Max Max Logfile Location Status + Time (mins) (mins) Log Logs + Files Size +----------------- ------------- ----------- -------------- ------------- ----------- ---------- --------------------------------- ------ +tech-support now 08:37 60 30 100 - flash:schedule/tech-support/ Success + + +leaf1#bash ls /mnt/flash/schedule/tech-support +leaf1_tech-support_2023-03-09.1337.log.gz leaf1_tech-support_2023-03-10.0837.log.gz leaf1_tech-support_2023-03-11.0337.log.gz +``` + +For Network Readiness for Use (NRFU) tests and to keep a comprehensive report of the system state before going live, ANTA provides a command-line interface that efficiently retrieves these files. + +### Command overview + +```bash +anta exec collect-tech-support --help +Usage: anta exec collect-tech-support [OPTIONS] + + Collect scheduled tech-support from EOS devices + +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 [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. + +The configuration `aaa authorization exec default` must be present on devices to be able to use SCP. +ANTA can automatically configure `aaa authorization exec default local` using the `anta exec collect-tech-support --configure` option. +If you require specific AAA configuration for `aaa authorization exec default`, like `aaa authorization exec default none` or `aaa authorization exec default group tacacs+`, you will need to configure it manually. + +The `--latest` option allows retrieval of a specific number of the most recent tech-support files. + +!!! warning + By default **all** the tech-support files present on the devices are retrieved. + +### Example + +```bash +anta --insecure exec collect-tech-support +[15:27:19] INFO Connecting to devices... +INFO Copying '/mnt/flash/schedule/tech-support/spine1_tech-support_2023-06-09.1315.log.gz' from device spine1 to 'tech-support/spine1' locally +INFO Copying '/mnt/flash/schedule/tech-support/leaf3_tech-support_2023-06-09.1315.log.gz' from device leaf3 to 'tech-support/leaf3' locally +INFO Copying '/mnt/flash/schedule/tech-support/leaf1_tech-support_2023-06-09.1315.log.gz' from device leaf1 to 'tech-support/leaf1' locally +INFO Copying '/mnt/flash/schedule/tech-support/leaf2_tech-support_2023-06-09.1315.log.gz' from device leaf2 to 'tech-support/leaf2' locally +INFO Copying '/mnt/flash/schedule/tech-support/spine2_tech-support_2023-06-09.1315.log.gz' from device spine2 to 'tech-support/spine2' locally +INFO Copying '/mnt/flash/schedule/tech-support/leaf4_tech-support_2023-06-09.1315.log.gz' from device leaf4 to 'tech-support/leaf4' locally +INFO Collected 1 scheduled tech-support from leaf2 +INFO Collected 1 scheduled tech-support from spine2 +INFO Collected 1 scheduled tech-support from leaf3 +INFO Collected 1 scheduled tech-support from spine1 +INFO Collected 1 scheduled tech-support from leaf1 +INFO Collected 1 scheduled tech-support from leaf4 +``` + +The output folder structure is as follows: + +```bash +tree tech-support/ +tech-support/ +├── leaf1 +│ └── leaf1_tech-support_2023-06-09.1315.log.gz +├── leaf2 +│ └── leaf2_tech-support_2023-06-09.1315.log.gz +├── leaf3 +│ └── leaf3_tech-support_2023-06-09.1315.log.gz +├── leaf4 +│ └── leaf4_tech-support_2023-06-09.1315.log.gz +├── spine1 +│ └── spine1_tech-support_2023-06-09.1315.log.gz +└── spine2 + └── spine2_tech-support_2023-06-09.1315.log.gz + +6 directories, 6 files +``` + +Each device has its own subdirectory containing the collected tech-support files. diff --git a/docs/cli/get-inventory-information.md b/docs/cli/get-inventory-information.md new file mode 100644 index 0000000..70100fe --- /dev/null +++ b/docs/cli/get-inventory-information.md @@ -0,0 +1,237 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Retrieving Inventory Information + +The ANTA CLI offers multiple entrypoints to access data from your local inventory. + +## Inventory used of examples + +Let's consider the following inventory: + +```yaml +--- +anta_inventory: + hosts: + - host: 172.20.20.101 + name: DC1-SPINE1 + tags: ["SPINE", "DC1"] + + - host: 172.20.20.102 + name: DC1-SPINE2 + tags: ["SPINE", "DC1"] + + - host: 172.20.20.111 + name: DC1-LEAF1A + tags: ["LEAF", "DC1"] + + - host: 172.20.20.112 + name: DC1-LEAF1B + tags: ["LEAF", "DC1"] + + - host: 172.20.20.121 + name: DC1-BL1 + tags: ["BL", "DC1"] + + - host: 172.20.20.122 + name: DC1-BL2 + tags: ["BL", "DC1"] + + - host: 172.20.20.201 + name: DC2-SPINE1 + tags: ["SPINE", "DC2"] + + - host: 172.20.20.202 + name: DC2-SPINE2 + tags: ["SPINE", "DC2"] + + - host: 172.20.20.211 + name: DC2-LEAF1A + tags: ["LEAF", "DC2"] + + - host: 172.20.20.212 + name: DC2-LEAF1B + tags: ["LEAF", "DC2"] + + - host: 172.20.20.221 + name: DC2-BL1 + tags: ["BL", "DC2"] + + - host: 172.20.20.222 + name: DC2-BL2 + tags: ["BL", "DC2"] +``` + +## Obtaining all configured tags + +As most of ANTA's commands accommodate tag filtering, this particular command is useful for enumerating all tags configured in the inventory. Running the `anta get tags` command will return a list of all tags that have been configured in the inventory. + +### Command overview + +```bash +anta get tags --help +Usage: anta get tags [OPTIONS] + + Get list of configured tags in user inventory. + +Options: + -u, --username TEXT Username to connect to EOS [env var: ANTA_USERNAME; + required] + -p, --password TEXT Password to connect to EOS that must be provided. It + can be prompted using '--prompt' option. [env var: + ANTA_PASSWORD] + --enable-password TEXT Password to access EOS Privileged EXEC mode. It can + be prompted using '--prompt' option. Requires '-- + enable' option. [env var: ANTA_ENABLE_PASSWORD] + --enable Some commands may require EOS Privileged EXEC mode. + This option tries to access this mode before sending + a command to the device. [env var: ANTA_ENABLE] + -P, --prompt Prompt for passwords if they are not provided. [env + var: ANTA_PROMPT] + --timeout 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 + +To get the list of all configured tags in the inventory, run the following command: + +```bash +anta get tags +Tags found: +[ + "BL", + "DC1", + "DC2", + "LEAF", + "SPINE" +] + +* note that tag all has been added by anta +``` + +!!! note + Even if you haven't explicitly configured the `all` tag in the inventory, it is automatically added. This default tag allows to execute commands on all devices in the inventory when no tag is specified. + +## List devices in inventory + +This command will list all devices available in the inventory. Using the `--tags` option, you can filter this list to only include devices with specific tags. The `--connected` option allows to display only the devices where a connection has been established. + +### Command overview + +```bash +anta get inventory --help +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 [env var: ANTA_TAGS] + --connected / --not-connected Display inventory after connection has been + created + --help Show this message and exit. +``` + + +!!! tip + In its default mode, `anta get inventory` provides only information that doesn't rely on a device connection. If you are interested in obtaining connection-dependent details, like the hardware model, please use the `--connected` option. + +### Example + +To retrieve a comprehensive list of all devices along with their details, execute the following command. It will provide all the data loaded into the ANTA inventory from your [inventory file](../usage-inventory-catalog.md). + +```bash +anta get inventory --tags SPINE +Current inventory content is: +{ + 'DC1-SPINE1': AsyncEOSDevice( + name='DC1-SPINE1', + tags=['SPINE', 'DC1'], + hw_model=None, + is_online=False, + established=False, + disable_cache=False, + host='172.20.20.101', + eapi_port=443, + username='arista', + enable=True, + enable_password='arista', + insecure=False + ), + 'DC1-SPINE2': AsyncEOSDevice( + name='DC1-SPINE2', + tags=['SPINE', 'DC1'], + hw_model=None, + is_online=False, + established=False, + disable_cache=False, + host='172.20.20.102', + eapi_port=443, + username='arista', + enable=True, + insecure=False + ), + 'DC2-SPINE1': AsyncEOSDevice( + name='DC2-SPINE1', + tags=['SPINE', 'DC2'], + hw_model=None, + is_online=False, + established=False, + disable_cache=False, + host='172.20.20.201', + eapi_port=443, + username='arista', + enable=True, + insecure=False + ), + 'DC2-SPINE2': AsyncEOSDevice( + name='DC2-SPINE2', + tags=['SPINE', 'DC2'], + hw_model=None, + is_online=False, + established=False, + disable_cache=False, + host='172.20.20.202', + eapi_port=443, + username='arista', + enable=True, + insecure=False + ) +} +``` diff --git a/docs/cli/inv-from-ansible.md b/docs/cli/inv-from-ansible.md new file mode 100644 index 0000000..bb944d4 --- /dev/null +++ b/docs/cli/inv-from-ansible.md @@ -0,0 +1,72 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Create an Inventory from Ansible inventory + +In large setups, it might be beneficial to construct your inventory based on your Ansible inventory. The `from-ansible` entrypoint of the `get` command enables the user to create an ANTA inventory from Ansible. + +### Command overview + +```bash +$ anta get from-ansible --help +Usage: anta get from-ansible [OPTIONS] + + Build ANTA inventory from an ansible inventory YAML file + +Options: + -g, --ansible-group TEXT Ansible group to filter + --ansible-inventory FILENAME + Path to your ansible inventory file to read + -o, --output FILENAME Path to save inventory file + -d, --inventory-directory PATH Directory to save inventory file + --help Show this message and exit. +``` + +The output is an inventory where the name of the container is added as a tag for each host: + +```yaml +anta_inventory: + hosts: + - host: 10.73.252.41 + name: srv-pod01 + - host: 10.73.252.42 + name: srv-pod02 + - host: 10.73.252.43 + name: srv-pod03 +``` + +!!! warning + The current implementation only considers devices directly attached to a specific Ansible group and does not support inheritence when using the `--ansible-group` option. + +By default, if user does not provide `--output` file, anta will save output to configured anta inventory (`anta --inventory`). If the output file has content, anta will ask user to overwrite when running in interactive console. This mechanism can be controlled by triggers in case of CI usage: `--overwrite` to force anta to overwrite file. If not set, anta will exit + + +### Command output + +`host` value is coming from the `ansible_host` key in your inventory while `name` is the name you defined for your host. Below is an ansible inventory example used to generate previous inventory: + +```yaml +--- +tooling: + children: + endpoints: + hosts: + srv-pod01: + ansible_httpapi_port: 9023 + ansible_port: 9023 + ansible_host: 10.73.252.41 + type: endpoint + srv-pod02: + ansible_httpapi_port: 9024 + ansible_port: 9024 + ansible_host: 10.73.252.42 + type: endpoint + srv-pod03: + ansible_httpapi_port: 9025 + ansible_port: 9025 + ansible_host: 10.73.252.43 + type: endpoint +``` diff --git a/docs/cli/inv-from-cvp.md b/docs/cli/inv-from-cvp.md new file mode 100644 index 0000000..8897370 --- /dev/null +++ b/docs/cli/inv-from-cvp.md @@ -0,0 +1,72 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Create an Inventory from CloudVision + +In large setups, it might be beneficial to construct your inventory based on CloudVision. The `from-cvp` entrypoint of the `get` command enables the user to create an ANTA inventory from CloudVision. + +### Command overview + +```bash +anta get from-cvp --help +Usage: anta get from-cvp [OPTIONS] + + Build ANTA inventory from Cloudvision + +Options: + -ip, --cvp-ip TEXT CVP IP Address [required] + -u, --cvp-username TEXT CVP Username [required] + -p, --cvp-password TEXT CVP Password / token [required] + -c, --cvp-container TEXT Container where devices are configured + -d, --inventory-directory PATH Path to save inventory file + --help Show this message and exit. +``` + +The output is an inventory where the name of the container is added as a tag for each host: + +```yaml +anta_inventory: + hosts: + - host: 192.168.0.13 + name: leaf2 + tags: + - pod1 + - host: 192.168.0.15 + name: leaf4 + tags: + - pod2 +``` + +!!! warning + The current implementation only considers devices directly attached to a specific container when using the `--cvp-container` option. + +### Creating an inventory from multiple containers + +If you need to create an inventory from multiple containers, you can use a bash command and then manually concatenate files to create a single inventory file: + +```bash +$ for container in pod01 pod02 spines; do anta get from-cvp -ip <cvp-ip> -u cvpadmin -p cvpadmin -c $container -d test-inventory; done + +[12:25:35] INFO Getting auth token from cvp.as73.inetsix.net for user tom +[12:25:36] INFO Creating inventory folder /home/tom/Projects/arista/network-test-automation/test-inventory + WARNING Using the new api_token parameter. This will override usage of the cvaas_token parameter if both are provided. This is because api_token and cvaas_token parameters + are for the same use case and api_token is more generic + INFO Connected to CVP cvp.as73.inetsix.net + + +[12:25:37] INFO Getting auth token from cvp.as73.inetsix.net for user tom +[12:25:38] WARNING Using the new api_token parameter. This will override usage of the cvaas_token parameter if both are provided. This is because api_token and cvaas_token parameters + are for the same use case and api_token is more generic + INFO Connected to CVP cvp.as73.inetsix.net + + +[12:25:38] INFO Getting auth token from cvp.as73.inetsix.net for user tom +[12:25:39] WARNING Using the new api_token parameter. This will override usage of the cvaas_token parameter if both are provided. This is because api_token and cvaas_token parameters + are for the same use case and api_token is more generic + INFO Connected to CVP cvp.as73.inetsix.net + + INFO Inventory file has been created in /home/tom/Projects/arista/network-test-automation/test-inventory/inventory-spines.yml +``` diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md new file mode 100644 index 0000000..6dcc393 --- /dev/null +++ b/docs/cli/nrfu.md @@ -0,0 +1,247 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Execute Network Readiness For Use (NRFU) Testing + +ANTA provides a set of commands for performing NRFU tests on devices. These commands are under the `anta nrfu` namespace and offer multiple output format options: + +- [Text view](#performing-nrfu-with-text-rendering) +- [Table view](#performing-nrfu-with-table-rendering) +- [JSON view](#performing-nrfu-with-json-rendering) +- [Custom template view](#performing-nrfu-with-custom-reports) + +### NRFU Command overview + +```bash +anta nrfu --help +Usage: anta nrfu [OPTIONS] COMMAND [ARGS]... + + Run NRFU against inventory devices + +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 [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 + table ANTA command to check network states with table result + text ANTA command to check network states with text result + tpl-report ANTA command to check network state with templated report +``` + +> `username`, `password`, `enable-password`, `enable`, `timeout` and `insecure` values are the same for all devices + +All commands under the `anta nrfu` namespace require a catalog yaml file specified with the `--catalog` option 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 + +The `--tags` option can be used to target specific devices in your inventory and run only tests configured with this specific tags from your catalog. The default tag is set to `all` and is implicit. Expected behaviour is provided below: + +| Command | Description | +| ------- | ----------- | +| `none` | Run all tests on all devices according `tag` definition in your inventory and test catalog. And tests with no tag are executed on all devices| +| `--tags leaf` | Run all tests marked with `leaf` tag on all devices configured with `leaf` tag.<br/> All other tags are ignored | +| `--tags leaf,spine` | Run all tests marked with `leaf` tag on all devices configured with `leaf` tag.<br/>Run all tests marked with `spine` tag on all devices configured with `spine` tag.<br/> All other tags are ignored | + +!!! info + [More examples](tag-management.md) available on this dedicated page. + +## Performing NRFU with text rendering + +The `text` subcommand provides a straightforward text report for each test executed on all devices in your inventory. + +### Command overview + +```bash +anta nrfu text --help +Usage: anta nrfu text [OPTIONS] + + ANTA command to check network states with text result + +Options: + -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 `--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. + +### Example + +```bash +anta nrfu text --tags LEAF --search DC1-LEAF1A +``` +[![anta nrfu text results](../imgs/anta-nrfu-text-output.png){ loading=lazy width="1600" }](../imgs/anta-nrfu-text-output.png) + +## Performing NRFU with table rendering + +The `table` command under the `anta nrfu` namespace offers a clear and organized table view of the test results, suitable for filtering. It also has its own set of options for better control over the output. + +### Command overview + +```bash +anta nrfu table --help +Usage: anta nrfu table [OPTIONS] + + ANTA command to check network states with table result + +Options: + -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 + --help Show this message and exit. +``` + +The `--device` and `--test` options show a summarized view of the test results for a specific host or test case, respectively. + +The `--group-by` option show a summarized view of the test results per host or per test. + +### Examples + +```bash +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) + +For larger setups, you can also group the results by host or test to get a summarized view: + +```bash +anta nrfu table --group-by device +``` +[![anta nrfu table group_by_host_output](../imgs/anta-nrfu-table-group-by-host-output.png){ loading=lazy width="1600" }](../imgs/anta-nrfu-table-group-by-host-output.png) + +```bash +anta nrfu table --group-by test +``` +[![anta nrfu table group_by_test_output](../imgs/anta-nrfu-table-group-by-test-output.png){ loading=lazy width="1600" }](../imgs/anta-nrfu-table-group-by-test-output.png) + +To get more specific information, it is possible to filter on a single device or a single test: + +```bash +anta nrfu table --device spine1 +``` +[![anta nrfu table filter_host_output](../imgs/anta-nrfu-table-filter-host-output.png){ loading=lazy width="1600" }](../imgs/anta-nrfu-table-filter-host-output.png) + +```bash +anta nrfu table --test VerifyZeroTouch +``` +[![anta nrfu table filter_test_output](../imgs/anta-nrfu-table-filter-test-output.png){ loading=lazy width="1600" }](../imgs/anta-nrfu-table-filter-test-output.png) + +## Performing NRFU with JSON rendering + +The JSON rendering command in NRFU testing is useful in generating a JSON output that can subsequently be passed on to another tool for reporting purposes. + +### Command overview + +```bash +anta nrfu json --help +Usage: anta nrfu json [OPTIONS] + + ANTA command to check network state with JSON result + +Options: + -o, --output FILE Path to save report as a file [env var: + ANTA_NRFU_JSON_OUTPUT] + --help Show this message and exit. +``` + +The `--output` option allows you to save the JSON report as a file. + +### Example + +```bash +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) + +## Performing NRFU with custom reports + +ANTA offers a CLI option for creating custom reports. This leverages the Jinja2 template system, allowing you to tailor reports to your specific needs. + +### Command overview + +```bash +anta nrfu tpl-report --help +Usage: anta nrfu tpl-report [OPTIONS] + + ANTA command to check network state with templated report + +Options: + -tpl, --template FILE Path to the template to use for the report [env var: + ANTA_NRFU_TPL_REPORT_TEMPLATE; required] + -o, --output FILE Path to save report as a file [env var: + ANTA_NRFU_TPL_REPORT_OUTPUT] + --help Show this message and exit. +``` +The `--template` option is used to specify the Jinja2 template file for generating the custom report. + +The `--output` option allows you to choose the path where the final report will be saved. + +### Example + +```bash +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) + +The template `./custom_template.j2` is a simple Jinja2 template: + +```j2 +{% for d in data %} +* {{ d.test }} is [green]{{ d.result | upper}}[/green] for {{ d.name }} +{% endfor %} +``` + +The Jinja2 template has access to all `TestResult` elements and their values, as described in this [documentation](../api/result_manager_models.md#testresult-entry). + +You can also save the report result to a file using the `--output` option: + +```bash +anta nrfu --tags LEAF tpl-report --template ./custom_template.j2 --output nrfu-tpl-report.txt +``` + +The resulting output might look like this: + +```bash +cat nrfu-tpl-report.txt +* VerifyMlagStatus is [green]SUCCESS[/green] for DC1-LEAF1A +* VerifyMlagInterfaces is [green]SUCCESS[/green] for DC1-LEAF1A +* VerifyMlagConfigSanity is [green]SUCCESS[/green] for DC1-LEAF1A +* VerifyMlagReloadDelay is [green]SUCCESS[/green] for DC1-LEAF1A +``` diff --git a/docs/cli/overview.md b/docs/cli/overview.md new file mode 100644 index 0000000..90e70a5 --- /dev/null +++ b/docs/cli/overview.md @@ -0,0 +1,103 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Overview of ANTA's Command-Line Interface (CLI) + +ANTA provides a powerful Command-Line Interface (CLI) to perform a wide range of operations. This document provides a comprehensive overview of ANTA CLI usage and its commands. + +ANTA can also be used as a Python library, allowing you to build your own tools based on it. Visit this [page](../advanced_usages/as-python-lib.md) for more details. + +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 +$ anta --help +--8<-- "anta_help.txt" +``` + +## ANTA environement variables + +Certain parameters are required and can be either passed to the ANTA CLI or set as an environment variable (ENV VAR). + +To pass the parameters via the CLI: + +```bash +anta nrfu -u admin -p arista123 -i inventory.yaml -c tests.yaml +``` + +To set them as environment variables: + +```bash +export ANTA_USERNAME=admin +export ANTA_PASSWORD=arista123 +export ANTA_INVENTORY=inventory.yml +export ANTA_INVENTORY=tests.yml +``` + +Then, run the CLI without options: + +```bash +anta nrfu +``` + +!!! note + All environement variables may not be needed for every commands. + Refer to `<command> --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 CLI utilizes the following exit codes: + +- `Exit code 0` - All tests passed successfully. +- `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 nrfu --ignore-status`, and the exit code will always be 0. + +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 + +You can enable shell completion for the ANTA CLI: + +=== "ZSH" + + If you use ZSH shell, add the following line in your `~/.zshrc`: + + ```bash + eval "$(_ANTA_COMPLETE=zsh_source anta)" > /dev/null + ``` + +=== "BASH" + + With bash, add the following line in your `~/.bashrc`: + + ```bash + eval "$(_ANTA_COMPLETE=bash_source anta)" > /dev/null + ``` diff --git a/docs/cli/tag-management.md b/docs/cli/tag-management.md new file mode 100644 index 0000000..8c043d7 --- /dev/null +++ b/docs/cli/tag-management.md @@ -0,0 +1,165 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Tag management + +## Overview + +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. + +The next table provides a short summary of the scope of tags using CLI + +| Command | Description | +| ------- | ----------- | +| `none` | Run all tests on all devices according `tag` definition in your inventory and test catalog. And tests with no tag are executed on all devices| +| `--tags leaf` | Run all tests marked with `leaf` tag on all devices configured with `leaf` tag.<br/> All other tags are ignored | +| `--tags leaf,spine` | Run all tests marked with `leaf` tag on all devices configured with `leaf` tag.<br/>Run all tests marked with `spine` tag on all devices configured with `spine` tag.<br/> All other tags are ignored | + +## Inventory and Catalog for tests + +All commands in this page are based on the following inventory and test catalog. + +=== "Inventory" + + ```yaml + --- + anta_inventory: + hosts: + - host: 192.168.0.10 + name: spine01 + tags: ['fabric', 'spine'] + - host: 192.168.0.11 + name: spine02 + tags: ['fabric', 'spine'] + - host: 192.168.0.12 + name: leaf01 + tags: ['fabric', 'leaf'] + - host: 192.168.0.13 + name: leaf02 + tags: ['fabric', 'leaf'] + - host: 192.168.0.14 + name: leaf03 + tags: ['fabric', 'leaf'] + - host: 192.168.0.15 + name: leaf04 + tags: ['fabric', 'leaf' + ``` + +=== "Test Catalog" + + ```yaml + anta.tests.system: + - VerifyUptime: + minimum: 10 + filters: + tags: ['fabric'] + - VerifyReloadCause: + tags: ['leaf', spine'] + - VerifyCoredump: + - VerifyAgentLogs: + - VerifyCPUUtilization: + filters: + tags: ['spine', 'leaf'] + - VerifyMemoryUtilization: + - VerifyFileSystemUtilization: + - VerifyNTP: + + anta.tests.mlag: + - VerifyMlagStatus: + + + anta.tests.interfaces: + - VerifyL3MTU: + mtu: 1500 + filters: + tags: ['demo'] + ``` + +## Default tags + +By default, ANTA uses a default tag for both devices and tests. This default tag is `all` and it can be explicit if you want to make it visible in your inventory and also implicit since the framework injects this tag if it is not defined. + +So this command will run all tests from your catalog on all devices. With a mapping for `tags` defined in your inventory and catalog. If no `tags` configured, then tests are executed against all devices. + +```bash +$ anta nrfu -c .personal/catalog-class.yml table --group-by device + +╭────────────────────── Settings ──────────────────────╮ +│ Running ANTA tests: │ +│ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 10 tests │ +╰──────────────────────────────────────────────────────╯ + +┏━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ Device ┃ # of success ┃ # of skipped ┃ # of failure ┃ # of errors ┃ List of failed or error test cases ┃ +┡━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ +│ spine01 │ 5 │ 1 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ +│ spine02 │ 5 │ 1 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ +│ leaf01 │ 6 │ 0 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ +│ leaf02 │ 6 │ 0 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ +│ leaf03 │ 6 │ 0 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ +│ leaf04 │ 6 │ 0 │ 1 │ 0 │ ['VerifyCPUUtilization'] │ +└─────────┴──────────────┴──────────────┴──────────────┴─────────────┴────────────────────────────────────┘ +``` + +## Use a single tag in CLI + +The most used approach is to use a single tag in your CLI to filter tests & devices configured with this one. + +In such scenario, ANTA will run tests marked with `$tag` only on devices marked with `$tag`. All other tests and devices will be ignored + +```bash +$ anta nrfu -c .personal/catalog-class.yml --tags leaf text +╭────────────────────── Settings ──────────────────────╮ +│ Running ANTA tests: │ +│ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 10 tests │ +╰──────────────────────────────────────────────────────╯ + +leaf01 :: VerifyUptime :: SUCCESS +leaf01 :: VerifyReloadCause :: SUCCESS +leaf01 :: VerifyCPUUtilization :: SUCCESS +leaf02 :: VerifyUptime :: SUCCESS +leaf02 :: VerifyReloadCause :: SUCCESS +leaf02 :: VerifyCPUUtilization :: SUCCESS +leaf03 :: VerifyUptime :: SUCCESS +leaf03 :: VerifyReloadCause :: SUCCESS +leaf03 :: VerifyCPUUtilization :: SUCCESS +leaf04 :: VerifyUptime :: SUCCESS +leaf04 :: VerifyReloadCause :: SUCCESS +leaf04 :: VerifyCPUUtilization :: SUCCESS +``` + +In this case, only `leaf` devices defined in your [inventory](#inventory-and-catalog-for-tests) are used to run tests marked with `leaf` in your [test catalog](#inventory-and-catalog-for-tests) + +## Use multiple tags in CLI + +A more advanced usage of the tag feature is to list multiple tags in your CLI using `--tags $tag1,$tag2` syntax. + +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 --tags leaf,fabric text + +spine01 :: VerifyUptime :: SUCCESS +spine02 :: VerifyUptime :: SUCCESS +leaf01 :: VerifyUptime :: SUCCESS +leaf01 :: VerifyReloadCause :: SUCCESS +leaf01 :: VerifyCPUUtilization :: SUCCESS +leaf02 :: VerifyUptime :: SUCCESS +leaf02 :: VerifyReloadCause :: SUCCESS +leaf02 :: VerifyCPUUtilization :: SUCCESS +leaf03 :: VerifyUptime :: SUCCESS +leaf03 :: VerifyReloadCause :: SUCCESS +leaf03 :: VerifyCPUUtilization :: SUCCESS +leaf04 :: VerifyUptime :: SUCCESS +leaf04 :: VerifyReloadCause :: SUCCESS +leaf04 :: VerifyCPUUtilization :: SUCCESS +``` diff --git a/docs/contribution.md b/docs/contribution.md new file mode 100644 index 0000000..49df256 --- /dev/null +++ b/docs/contribution.md @@ -0,0 +1,227 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# How to contribute to ANTA + +Contribution model is based on a fork-model. Don't push to arista-netdevops-community/anta directly. Always do a branch in your forked repository and create a PR. + +To help development, open your PR as soon as possible even in draft mode. It helps other to know on what you are working on and avoid duplicate PRs. + +## Create a development environement + +Run the following commands to create an ANTA development environement: + +```bash +# Clone repository +$ git clone https://github.com/arista-netdevops-community/anta.git +$ cd anta + +# Install ANTA in editable mode and its development tools +$ pip install -e .[dev] + +# Verify installation +$ pip list -e +Package Version Editable project location +------- ------- ------------------------- +anta 0.13.0 /mnt/lab/projects/anta +``` + +Then, [`tox`](https://tox.wiki/) is configued with few environments to run CI locally: + +```bash +$ tox list -d +default environments: +clean -> Erase previous coverage reports +lint -> Check the code style +type -> Check typing +py38 -> Run pytest with py38 +py39 -> Run pytest with py39 +py310 -> Run pytest with py310 +py311 -> Run pytest with py311 +report -> Generate coverage report +``` + +### Code linting + +```bash +tox -e lint +[...] +lint: commands[0]> black --check --diff --color . +All done! ✨ 🍰 ✨ +104 files would be left unchanged. +lint: commands[1]> isort --check --diff --color . +Skipped 7 files +lint: commands[2]> flake8 --max-line-length=165 --config=/dev/null anta +lint: commands[3]> flake8 --max-line-length=165 --config=/dev/null tests +lint: commands[4]> pylint anta + +-------------------------------------------------------------------- +Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00) + +.pkg: _exit> python /Users/guillaumemulocher/.pyenv/versions/3.8.13/envs/anta/lib/python3.8/site-packages/pyproject_api/_backend.py True setuptools.build_meta + lint: OK (19.26=setup[5.83]+cmd[1.50,0.76,1.19,1.20,8.77] seconds) + congratulations :) (19.56 seconds) +``` + +### Code Typing + +```bash +tox -e type + +[...] +type: commands[0]> mypy --config-file=pyproject.toml anta +Success: no issues found in 52 source files +.pkg: _exit> python /Users/guillaumemulocher/.pyenv/versions/3.8.13/envs/anta/lib/python3.8/site-packages/pyproject_api/_backend.py True setuptools.build_meta + type: OK (46.66=setup[24.20]+cmd[22.46] seconds) + congratulations :) (47.01 seconds) +``` + +> NOTE: Typing is configured quite strictly, do not hesitate to reach out if you have any questions, struggles, nightmares. + +## Unit tests + +To keep high quality code, we require to provide a Pytest for every tests implemented in ANTA. + +All submodule should have its own pytest section under `tests/units/anta_tests/<submodule-name>.py`. + +### How to write a unit test for an AntaTest subclass + +The Python modules in the `tests/units/anta_tests` folder define test parameters for AntaTest subclasses unit tests. +A generic test function is written for all unit tests in `tests.lib.anta` module. +The `pytest_generate_tests` function definition in `conftest.py` is called during test collection. +The `pytest_generate_tests` function will parametrize the generic test function based on the `DATA` data structure defined in `tests.units.anta_tests` modules. +See https://docs.pytest.org/en/7.3.x/how-to/parametrize.html#basic-pytest-generate-tests-example + +The `DATA` structure is a list of dictionaries used to parametrize the test. +The list elements have the following keys: +- `name` (str): Test name as displayed by Pytest. +- `test` (AntaTest): An AntaTest subclass imported in the test module - e.g. VerifyUptime. +- `eos_data` (list[dict]): List of data mocking EOS returned data to be passed to the test. +- `inputs` (dict): Dictionary to instantiate the `test` inputs as defined in the class from `test`. +- `expected` (dict): Expected test result structure, a dictionary containing a key + `result` containing one of the allowed status (`Literal['success', 'failure', 'unset', 'skipped', 'error']`) and optionally a key `messages` which is a list(str) and each message is expected to be a substring of one of the actual messages in the TestResult object. + + +In order for your unit tests to be correctly collected, you need to import the generic test function even if not used in the Python module. + +Test example for `anta.tests.system.VerifyUptime` AntaTest. + +``` python +# Import the generic test function +from tests.lib.anta import test # noqa: F401 + +# Import your AntaTest +from anta.tests.system import VerifyUptime + +# Define test parameters +DATA: list[dict[str, Any]] = [ + { + # Arbitrary test name + "name": "success", + # Must be an AntaTest definition + "test": VerifyUptime, + # Data returned by EOS on which the AntaTest is tested + "eos_data": [{"upTime": 1186689.15, "loadAvg": [0.13, 0.12, 0.09], "users": 1, "currentTime": 1683186659.139859}], + # Dictionary to instantiate VerifyUptime.Input + "inputs": {"minimum": 666}, + # Expected test result + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyUptime, + "eos_data": [{"upTime": 665.15, "loadAvg": [0.13, 0.12, 0.09], "users": 1, "currentTime": 1683186659.139859}], + "inputs": {"minimum": 666}, + # If the test returns messages, it needs to be expected otherwise test will fail. + # NB: expected messages only needs to be included in messages returned by the test. Exact match is not required. + "expected": {"result": "failure", "messages": ["Device uptime is 665.15 seconds"]}, + }, +] +``` + +## Git Pre-commit hook + +```bash +pip install pre-commit +pre-commit install +``` + +When running a commit or a pre-commit check: + +``` bash +❯ echo "import foobaz" > test.py && git add test.py +❯ pre-commit +pylint...................................................................Failed +- hook id: pylint +- exit code: 22 + +************* Module test +test.py:1:0: C0114: Missing module docstring (missing-module-docstring) +test.py:1:0: E0401: Unable to import 'foobaz' (import-error) +test.py:1:0: W0611: Unused import foobaz (unused-import) +``` + +> NOTE: It could happen that pre-commit and tox disagree on something, in that case please open an issue on Github so we can take a look.. It is most probably wrong configuration on our side. + +## Configure MYPYPATH + +In some cases, mypy can complain about not having `MYPYPATH` configured in your shell. It is especially the case when you update both an anta test and its unit test. So you can configure this environment variable with: + +```bash +# Option 1: use local folder +export MYPYPATH=. + +# Option 2: use absolute path +export MYPYPATH=/path/to/your/local/anta/repository +``` + +## Documentation + +[`mkdocs`](https://www.mkdocs.org/) is used to generate the documentation. A PR should always update the documentation to avoid documentation debt. + +### Install documentation requirements + +Run pip to install the documentation requirements from the root of the repo: + +```bash +pip install -e .[doc] +``` + +### Testing documentation + +You can then check locally the documentation using the following command from the root of the repo: + +```bash +mkdocs serve +``` + +By default, `mkdocs` listens to http://127.0.0.1:8000/, if you need to expose the documentation to another IP or port (for instance all IPs on port 8080), use the following command: + +```bash +mkdocs serve --dev-addr=0.0.0.0:8080 +``` + +### Build class diagram + +To build class diagram to use in API documentation, you can use `pyreverse` part of `pylint` with [`graphviz`](https://graphviz.org/) installed for jpeg generation. + +```bash +pyreverse anta --colorized -a1 -s1 -o jpeg -m true -k --output-directory docs/imgs/uml/ -c <FQDN anta class> +``` + +Image will be generated under `docs/imgs/uml/` and can be inserted in your documentation. + +### Checking links + +Writing documentation is crucial but managing links can be cumbersome. To be sure there is no dead links, you can use [`muffet`](https://github.com/raviqqe/muffet) with the following command: + +```bash +muffet -c 2 --color=always http://127.0.0.1:8000 -e fonts.gstatic.com +``` + +## Continuous Integration + +GitHub actions is used to test git pushes and pull requests. The workflows are defined in this [directory](https://github.com/arista-netdevops-community/anta/tree/main/.github/workflows). We can view the results [here](https://github.com/arista-netdevops-community/anta/actions). diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..7c995ac --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,67 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Frequently Asked Questions (FAQ) + +## Why am I seeing an `ImportError` related to `urllib3` when running ANTA? + +When running the `anta --help` command, some users might encounter the following error: + +```bash +ImportError: urllib3 v2.0 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'OpenSSL 1.0.2k-fips 26 Jan 2017'. See: https://github.com/urllib3/urllib3/issues/2168 +``` + +This error arises due to a compatibility issue between `urllib3` v2.0 and older versions of OpenSSL. + +#### How can I resolve this error? + +1. _Workaround_: Downgrade `urllib3` + + If you need a quick fix, you can temporarily downgrade the `urllib3` package: + + ```bash + pip3 uninstall urllib3 + + pip3 install urllib3==1.26.15 + ``` + +2. _Recommended_: Upgrade System or Libraries: + + As per the [urllib3 v2 migration guide](https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html), the root cause of this error is an incompatibility with older OpenSSL versions. For example, users on RHEL7 might consider upgrading to RHEL8, which supports the required OpenSSL version. + +## Why am I seeing `AttributeError: module 'lib' has no attribute 'OpenSSL_add_all_algorithms'` when running ANTA + +When running the `anta` commands after installation, some users might encounter the following error: + +```bash +AttributeError: module 'lib' has no attribute 'OpenSSL_add_all_algorithms' +``` + +The error is a result of incompatibility between `cryptography` and `pyopenssl` when installing `asyncssh` which is a requirement of ANTA. + +#### How can I resolve this error? + +1. Upgrade `pyopenssl` + + ```bash + pip install -U pyopenssl>22.0 + ``` + +## `__NSCFConstantString initialize` error on OSX + +This error occurs because of added security to restrict multithreading in macOS High Sierra and later versions of macOS. https://www.wefearchange.org/2018/11/forkmacos.rst.html + +#### How can I resolve this error? + +1. Set the following environment variable + + ```bash + export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES + ``` + +## Still facing issues? + +If you've tried the above solutions and continue to experience problems, please report the issue in our [GitHub repository](https://github.com/arista-netdevops-community/anta). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..fd147e7 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,301 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Getting Started + +This section shows how to use ANTA with basic configuration. All examples are based on Arista Test Drive (ATD) topology you can access by reaching out to your preferred SE. + +## Installation + +The easiest way to intall ANTA package is to run Python (`>=3.8`) and its pip package to install: + +```bash +pip install anta +``` + +For more details about how to install package, please see the [requirements and intallation](./requirements-and-installation.md) section. + +## Configure Arista EOS devices + +For ANTA to be able to connect to your target devices, you need to configure your management interface + +```eos +vrf instance MGMT +! +interface Management0 + description oob_management + vrf MGMT + ip address 192.168.0.10/24 +! +``` + +Then, configure access to eAPI: + +```eos +! +management api http-commands + protocol https port 443 + no shutdown + vrf MGMT + no shutdown + ! +! +``` + +## Create your inventory + +ANTA uses an inventory to list the target devices for the tests. You can create a file manually with this format: + +```yaml +anta_inventory: + hosts: + - host: 192.168.0.10 + name: spine01 + tags: ['fabric', 'spine'] + - host: 192.168.0.11 + name: spine02 + tags: ['fabric', 'spine'] + - host: 192.168.0.12 + name: leaf01 + tags: ['fabric', 'leaf'] + - host: 192.168.0.13 + name: leaf02 + tags: ['fabric', 'leaf'] + - host: 192.168.0.14 + name: leaf03 + tags: ['fabric', 'leaf'] + - host: 192.168.0.15 + name: leaf04 + tags: ['fabric', 'leaf'] +``` + +> You can read more details about how to build your inventory [here](usage-inventory-catalog.md#create-an-inventory-file) + +## Test Catalog + +To test your network, ANTA relies on a test catalog to list all the tests to run against your inventory. A test catalog references python functions into a yaml file. + +The structure to follow is like: + +```yaml +<anta_tests_submodule>: + - <anta_tests_submodule function name>: + <test function option>: + <test function option value> +``` + +> You can read more details about how to build your catalog [here](usage-inventory-catalog.md#test-catalog) + +Here is an example for basic tests: + +```yaml +# Load anta.tests.software +anta.tests.software: + - VerifyEOSVersion: # Verifies the device is running one of the allowed EOS version. + versions: # List of allowed EOS versions. + - 4.25.4M + - 4.26.1F + - '4.28.3M-28837868.4283M (engineering build)' + - VerifyTerminAttrVersion: + versions: + - v1.22.1 + +anta.tests.system: + - VerifyUptime: # Verifies the device uptime is higher than a value. + minimum: 1 + - VerifyNTP: + - VerifySyslog: + +anta.tests.mlag: + - VerifyMlagStatus: + - VerifyMlagInterfaces: + - VerifyMlagConfigSanity: + +anta.tests.configuration: + - VerifyZeroTouch: # Verifies ZeroTouch is disabled. + - VerifyRunningConfigDiffs: +``` + +## Test your network + +ANTA comes with a generic CLI entrypoint to run tests in your network. It requires an inventory file as well as a test catalog. + +This entrypoint has multiple options to manage test coverage and reporting. + +```bash +# Generic ANTA options +$ anta +--8<-- "anta_help.txt" +``` + +```bash +# NRFU part of ANTA +Usage: anta nrfu [OPTIONS] COMMAND [ARGS]... + + Run ANTA tests on devices + +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 [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 + table ANTA command to check network states with table result + text ANTA command to check network states with text result + tpl-report ANTA command to check network state with templated report +``` + +To run the NRFU, you need to select an output format amongst ["json", "table", "text", "tpl-report"]. For a first usage, `table` is recommended. By default all test results for all devices are rendered but it can be changed to a report per test case or per host + +### Default report using table + +```bash +anta nrfu \ + --username tom \ + --password arista123 \ + --enable \ + --enable-password t \ + --inventory .personal/inventory_atd.yml \ + --catalog .personal/tests-bases.yml \ + table --tags leaf + + +╭────────────────────── Settings ──────────────────────╮ +│ Running ANTA tests: │ +│ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 10 tests │ +╰──────────────────────────────────────────────────────╯ +[10:17:24] INFO Running ANTA tests... runner.py:75 + • Running NRFU Tests...100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40/40 • 0:00:02 • 0:00:00 + + All tests results +┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ +┃ Device IP ┃ Test Name ┃ Test Status ┃ Message(s) ┃ Test description ┃ Test category ┃ +┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ +│ leaf01 │ VerifyEOSVersion │ success │ │ Verifies the device is running one of the allowed EOS version. │ software │ +│ leaf01 │ VerifyTerminAttrVersion │ success │ │ Verifies the device is running one of the allowed TerminAttr │ software │ +│ │ │ │ │ version. │ │ +│ leaf01 │ VerifyUptime │ success │ │ Verifies the device uptime is higher than a value. │ system │ +│ leaf01 │ VerifyNTP │ success │ │ Verifies NTP is synchronised. │ system │ +│ leaf01 │ VerifySyslog │ success │ │ Verifies the device had no syslog message with a severity of warning │ system │ +│ │ │ │ │ (or a more severe message) during the last 7 days. │ │ +│ leaf01 │ VerifyMlagStatus │ skipped │ MLAG is disabled │ This test verifies the health status of the MLAG configuration. │ mlag │ +│ leaf01 │ VerifyMlagInterfaces │ skipped │ MLAG is disabled │ This test verifies there are no inactive or active-partial MLAG │ mlag │ +[...] +│ leaf04 │ VerifyMlagConfigSanity │ skipped │ MLAG is disabled │ This test verifies there are no MLAG config-sanity inconsistencies. │ mlag │ +│ leaf04 │ VerifyZeroTouch │ success │ │ Verifies ZeroTouch is disabled. │ configuration │ +│ leaf04 │ VerifyRunningConfigDiffs │ success │ │ │ configuration │ +└───────────┴──────────────────────────┴─────────────┴──────────────────┴──────────────────────────────────────────────────────────────────────┴───────────────┘ +``` + +### Report in text mode + +```bash +$ anta nrfu \ + --username tom \ + --password arista123 \ + --enable \ + --enable-password t \ + --inventory .personal/inventory_atd.yml \ + --catalog .personal/tests-bases.yml \ + text --tags leaf + +╭────────────────────── Settings ──────────────────────╮ +│ Running ANTA tests: │ +│ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 10 tests │ +╰──────────────────────────────────────────────────────╯ +[10:20:47] INFO Running ANTA tests... runner.py:75 + • Running NRFU Tests...100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40/40 • 0:00:01 • 0:00:00 +leaf01 :: VerifyEOSVersion :: SUCCESS +leaf01 :: VerifyTerminAttrVersion :: SUCCESS +leaf01 :: VerifyUptime :: SUCCESS +leaf01 :: VerifyNTP :: SUCCESS +leaf01 :: VerifySyslog :: SUCCESS +leaf01 :: VerifyMlagStatus :: SKIPPED (MLAG is disabled) +leaf01 :: VerifyMlagInterfaces :: SKIPPED (MLAG is disabled) +leaf01 :: VerifyMlagConfigSanity :: SKIPPED (MLAG is disabled) +[...] +``` + +### Report in JSON format + +```bash +$ anta nrfu \ + --username tom \ + --password arista123 \ + --enable \ + --enable-password t \ + --inventory .personal/inventory_atd.yml \ + --catalog .personal/tests-bases.yml \ + json --tags leaf + +╭────────────────────── Settings ──────────────────────╮ +│ Running ANTA tests: │ +│ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 10 tests │ +╰──────────────────────────────────────────────────────╯ +[10:21:51] INFO Running ANTA tests... runner.py:75 + • Running NRFU Tests...100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40/40 • 0:00:02 • 0:00:00 +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ JSON results of all tests │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +[ + { + "name": "leaf01", + "test": "VerifyEOSVersion", + "categories": [ + "software" + ], + "description": "Verifies the device is running one of the allowed EOS version.", + "result": "success", + "messages": [], + "custom_field": "None", + }, + { + "name": "leaf01", + "test": "VerifyTerminAttrVersion", + "categories": [ + "software" + ], + "description": "Verifies the device is running one of the allowed TerminAttr version.", + "result": "success", + "messages": [], + "custom_field": "None", + }, +[...] +] +``` + +You can find more information under the __usage__ section of the website diff --git a/docs/imgs/animated-svg.md b/docs/imgs/animated-svg.md new file mode 100644 index 0000000..6a27a50 --- /dev/null +++ b/docs/imgs/animated-svg.md @@ -0,0 +1,8 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +Repository: https://github.com/marionebl/svg-term-cli +Command: `cat anta-nrfu.cast | svg-term --height 10 --window --out anta.svg` diff --git a/docs/imgs/anta-nrfu-json-output.png b/docs/imgs/anta-nrfu-json-output.png Binary files differnew file mode 100755 index 0000000..7ab22b9 --- /dev/null +++ b/docs/imgs/anta-nrfu-json-output.png diff --git a/docs/imgs/anta-nrfu-table-filter-host-output.png b/docs/imgs/anta-nrfu-table-filter-host-output.png Binary files differnew file mode 100644 index 0000000..bed3796 --- /dev/null +++ b/docs/imgs/anta-nrfu-table-filter-host-output.png diff --git a/docs/imgs/anta-nrfu-table-filter-test-output.png b/docs/imgs/anta-nrfu-table-filter-test-output.png Binary files differnew file mode 100644 index 0000000..c6ae155 --- /dev/null +++ b/docs/imgs/anta-nrfu-table-filter-test-output.png diff --git a/docs/imgs/anta-nrfu-table-group-by-host-output.png b/docs/imgs/anta-nrfu-table-group-by-host-output.png Binary files differnew file mode 100644 index 0000000..8329d2b --- /dev/null +++ b/docs/imgs/anta-nrfu-table-group-by-host-output.png diff --git a/docs/imgs/anta-nrfu-table-group-by-test-output.png b/docs/imgs/anta-nrfu-table-group-by-test-output.png Binary files differnew file mode 100644 index 0000000..f9f8115 --- /dev/null +++ b/docs/imgs/anta-nrfu-table-group-by-test-output.png diff --git a/docs/imgs/anta-nrfu-table-output.png b/docs/imgs/anta-nrfu-table-output.png Binary files differnew file mode 100644 index 0000000..1c9ff62 --- /dev/null +++ b/docs/imgs/anta-nrfu-table-output.png diff --git a/docs/imgs/anta-nrfu-table-per-host-output.png b/docs/imgs/anta-nrfu-table-per-host-output.png Binary files differnew file mode 100755 index 0000000..c82ce4d --- /dev/null +++ b/docs/imgs/anta-nrfu-table-per-host-output.png diff --git a/docs/imgs/anta-nrfu-text-output.png b/docs/imgs/anta-nrfu-text-output.png Binary files differnew file mode 100755 index 0000000..2a4d6be --- /dev/null +++ b/docs/imgs/anta-nrfu-text-output.png diff --git a/docs/imgs/anta-nrfu-tpl-report-output.png b/docs/imgs/anta-nrfu-tpl-report-output.png Binary files differnew file mode 100755 index 0000000..df1c30a --- /dev/null +++ b/docs/imgs/anta-nrfu-tpl-report-output.png diff --git a/docs/imgs/anta-nrfu.cast b/docs/imgs/anta-nrfu.cast new file mode 100644 index 0000000..dcad1ec --- /dev/null +++ b/docs/imgs/anta-nrfu.cast @@ -0,0 +1,64 @@ +{"version": 2, "width": 121, "height": 56, "timestamp": 1689845383, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}} +[0.182046, "o", "\u001b[?2004h❯ "] +[0.930909, "o", "a"] +[1.124462, "o", "n"] +[1.296703, "o", "t"] +[1.467422, "o", "a"] +[1.63869, "o", " "] +[1.822924, "o", "n"] +[1.967039, "o", "r"] +[2.147509, "o", "f"] +[2.323682, "o", "u"] +[2.419445, "o", " "] +[2.638124, "o", "t"] +[2.795029, "o", "a"] +[3.006538, "o", "b"] +[3.151247, "o", "l"] +[3.286674, "o", "e"] +[3.697852, "o", "\r\n\u001b[?2004l\r"] +[3.989809, "o", "\u001b[36m╭─\u001b[0m\u001b[36m─────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[32mSettings\u001b[0m\u001b[36m \u001b[0m\u001b[36m─────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m\u001b[36m \u001b[0m\u001b[36mRunning ANTA tests:\u001b[0m\u001b[36m \u001b[0m\u001b[36m \u001b[0m\u001b[36m│\u001b[0m\r\n\u001b[36m│\u001b[0m\u001b[36m \u001b[0m\u001b[36m- ANTA Inventory contains 6 devices (AsyncEOSDevice)\u001b[0m\u001b[36m \u001b[0m\u001b[36m│\u001b[0m\r\n\u001b[36m│\u001b[0m\u001b[36m \u001b[0m\u001b[36m- Tests catalog contains 89 tests\u001b[0m\u001b[36m \u001b[0m\u001b[36m \u001b[0m\u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[3.989919, "o", "\u001b[?25l"] +[4.033083, "o", "\r\u001b[2K\u001b[32m( 🐜)\u001b[0m • Running NRFU Tests...\u001b[35m 0%\u001b[0m \u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 0/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m-:--:--\u001b[0m"] +[4.035615, "o", "\r\u001b[2K\u001b[2;36m[11:29:47]\u001b[0m\u001b[2;36m \u001b[0m\u001b[34mINFO \u001b[0m Running ANTA tests\u001b[33m...\u001b[0m \u001b]8;id=396032;file:///mnt/lab/projects/anta/anta/runner.py\u001b\\\u001b[2mrunner.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=993683;file:///mnt/lab/projects/anta/anta/runner.py#71\u001b\\\u001b[2m71\u001b[0m\u001b]8;;\u001b\\\r\n\u001b[32m( 🐜)\u001b[0m • Running NRFU Tests...\u001b[35m 0%\u001b[0m \u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 0/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m-:--:--\u001b[0m"] +[4.100083, "o", "\r\u001b[2K\u001b[32m( 🐜)\u001b[0m • Running NRFU Tests...\u001b[35m 15%\u001b[0m \u001b[38;5;197m━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 78/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[4.20303, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 15%\u001b[0m \u001b[38;5;197m━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 78/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[4.304638, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 15%\u001b[0m \u001b[38;5;197m━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 78/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[4.407072, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 15%\u001b[0m \u001b[38;5;197m━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 78/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[4.509771, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 15%\u001b[0m \u001b[38;5;197m━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 78/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[4.612195, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 15%\u001b[0m \u001b[38;5;197m━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 78/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[4.714541, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 15%\u001b[0m \u001b[38;5;197m━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 78/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[4.850439, "o", "\r\u001b[2K\u001b[32m(🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 15%\u001b[0m \u001b[38;5;197m━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 78/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[4.954054, "o", "\r\u001b[2K\u001b[32m(🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 18%\u001b[0m \u001b[38;5;197m━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m 98/534\u001b[0m • \u001b[33m0:00:00\u001b[0m • \u001b[36m0:00:05\u001b[0m"] +[5.056909, "o", "\r\u001b[2K\u001b[32m(🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 35%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m189/534\u001b[0m • \u001b[33m0:00:01\u001b[0m • \u001b[36m0:00:02\u001b[0m"] +[5.158885, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 55%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m293/534\u001b[0m • \u001b[33m0:00:01\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[5.260648, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 58%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m311/534\u001b[0m • \u001b[33m0:00:01\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[5.361906, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 63%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m335/534\u001b[0m • \u001b[33m0:00:01\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[5.463151, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 67%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m360/534\u001b[0m • \u001b[33m0:00:01\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[5.56479, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 71%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m378/534\u001b[0m • \u001b[33m0:00:01\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[5.66611, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 72%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━\u001b[0m \u001b[32m383/534\u001b[0m • \u001b[33m0:00:01\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[5.767251, "o", "\r\u001b[2K\u001b[32m( 🐌)\u001b[0m • Running NRFU Tests...\u001b[35m 72%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━━━\u001b[0m \u001b[32m385/534\u001b[0m • \u001b[33m0:00:01\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[5.868443, "o", "\r\u001b[2K\u001b[32m( 🐜)\u001b[0m • Running NRFU Tests...\u001b[35m 76%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m\u001b[38;5;237m━━━━━━━━━━━━━\u001b[0m \u001b[32m407/534\u001b[0m • \u001b[33m0:00:01\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[5.969691, "o", "\r\u001b[2K\u001b[32m( 🐜)\u001b[0m • Running NRFU Tests...\u001b[35m 81%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m\u001b[38;5;237m━━━━━━━━━━\u001b[0m \u001b[32m433/534\u001b[0m • \u001b[33m0:00:01\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[6.072113, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 84%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━━━\u001b[0m \u001b[32m447/534\u001b[0m • \u001b[33m0:00:02\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[6.173693, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 85%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m\u001b[38;5;237m━━━━━━━━\u001b[0m \u001b[32m456/534\u001b[0m • \u001b[33m0:00:02\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[6.275903, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 87%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━━\u001b[0m \u001b[32m467/534\u001b[0m • \u001b[33m0:00:02\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[6.377199, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 89%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━━\u001b[0m \u001b[32m476/534\u001b[0m • \u001b[33m0:00:02\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[6.478317, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 91%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━━━\u001b[0m \u001b[32m486/534\u001b[0m • \u001b[33m0:00:02\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[6.579526, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 95%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m\u001b[38;5;237m━━\u001b[0m \u001b[32m505/534\u001b[0m • \u001b[33m0:00:02\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[6.680856, "o", "\r\u001b[2K\u001b[32m(🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 98%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m\u001b[38;5;237m━\u001b[0m \u001b[32m521/534\u001b[0m • \u001b[33m0:00:02\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[6.782108, "o", "\r\u001b[2K\u001b[32m(🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;237m╺\u001b[0m \u001b[32m528/534\u001b[0m • \u001b[33m0:00:02\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[6.883283, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:02\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[6.98442, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:02\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[7.085424, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:03\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[7.186472, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:03\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[7.287535, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:03\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[7.388642, "o", "\r\u001b[2K\u001b[32m( 🐌 )\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:03\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[7.489818, "o", "\r\u001b[2K\u001b[32m( 🐌)\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:03\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[7.590902, "o", "\r\u001b[2K\u001b[32m( 🐌)\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:03\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[7.692031, "o", "\r\u001b[2K\u001b[32m( 🐜)\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:03\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[7.793107, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:03\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[7.894156, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:03\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[7.995198, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m 99%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m531/534\u001b[0m • \u001b[33m0:00:03\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[8.096303, "o", "\r\u001b[2K\u001b[32m( 🐜 )\u001b[0m • Running NRFU Tests...\u001b[35m100%\u001b[0m \u001b[38;5;197m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[38;5;197m╸\u001b[0m \u001b[32m533/534\u001b[0m • \u001b[33m0:00:04\u001b[0m • \u001b[36m0:00:01\u001b[0m"] +[8.146442, "o", "\r\u001b[2K • Running NRFU Tests...\u001b[35m100%\u001b[0m \u001b[38;5;70m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m534/534\u001b[0m • \u001b[33m0:00:04\u001b[0m • \u001b[36m0:00:00\u001b[0m\r\n\u001b[?25h"] +[8.22014, "o", "\u001b[?2004h❯ "] diff --git a/docs/imgs/anta-nrfu.svg b/docs/imgs/anta-nrfu.svg new file mode 100644 index 0000000..c01eabb --- /dev/null +++ b/docs/imgs/anta-nrfu.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1250" height="277.1"><rect width="1250" height="277.1" rx="5" ry="5" class="a"/><svg y="0%" x="0%"><circle cx="20" cy="20" r="6" fill="#ff5f58"/><circle cx="40" cy="20" r="6" fill="#ffbd2e"/><circle cx="60" cy="20" r="6" fill="#18c132"/></svg><svg height="217.1" viewBox="0 0 121 21.71" width="1210" x="15" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" y="50"><style>@keyframes q{0%{transform:translateX(0)}2.2%{transform:translateX(-121px)}11.3%{transform:translateX(-242px)}13.7%{transform:translateX(-363px)}15.8%{transform:translateX(-484px)}17.9%{transform:translateX(-605px)}19.9%{transform:translateX(-726px)}22.2%{transform:translateX(-847px)}23.9%{transform:translateX(-968px)}26.1%{transform:translateX(-1089px)}28.3%{transform:translateX(-1210px)}29.4%{transform:translateX(-1331px)}32.1%{transform:translateX(-1452px)}34%{transform:translateX(-1573px)}36.6%{transform:translateX(-1694px)}38.3%{transform:translateX(-1815px)}40%{transform:translateX(-1936px)}45%{transform:translateX(-2057px)}48.5%{transform:translateX(-2299px)}49.1%{transform:translateX(-2541px)}49.9%{transform:translateX(-2662px)}51.1%{transform:translateX(-2783px)}52.4%{transform:translateX(-2904px)}53.6%{transform:translateX(-3025px)}54.9%{transform:translateX(-3146px)}56.1%{transform:translateX(-3267px)}57.4%{transform:translateX(-3388px)}59%{transform:translateX(-3509px)}60.3%{transform:translateX(-3630px)}61.5%{transform:translateX(-3751px)}62.8%{transform:translateX(-3872px)}64%{transform:translateX(-3993px)}65.2%{transform:translateX(-4114px)}66.5%{transform:translateX(-4235px)}67.7%{transform:translateX(-4356px)}68.9%{transform:translateX(-4477px)}70.2%{transform:translateX(-4598px)}71.4%{transform:translateX(-4719px)}72.6%{transform:translateX(-4840px)}73.9%{transform:translateX(-4961px)}75.1%{transform:translateX(-5082px)}76.3%{transform:translateX(-5203px)}77.6%{transform:translateX(-5324px)}78.8%{transform:translateX(-5445px)}80%{transform:translateX(-5566px)}81.3%{transform:translateX(-5687px)}82.5%{transform:translateX(-5808px)}83.7%{transform:translateX(-5929px)}85%{transform:translateX(-6050px)}86.2%{transform:translateX(-6171px)}87.4%{transform:translateX(-6292px)}88.7%{transform:translateX(-6413px)}89.9%{transform:translateX(-6534px)}91.1%{transform:translateX(-6655px)}92.3%{transform:translateX(-6776px)}93.6%{transform:translateX(-6897px)}94.8%{transform:translateX(-7018px)}96%{transform:translateX(-7139px)}97.3%{transform:translateX(-7260px)}98.5%{transform:translateX(-7381px)}99.1%{transform:translateX(-7502px)}to{transform:translateX(-7623px)}}.a{fill:#282d35}.f{fill:#b9c0cb;white-space:pre}.g,.h,.i,.j,.k,.m{fill:#66c2cd;white-space:pre}.h,.i,.j,.k,.m{fill:#a8cc8c}.i,.j,.k,.m{fill:#d290e4}.j,.k,.m{fill:#3a3a3a}.k,.m{fill:#dbab79}.m{fill:#ff005f}</style><g font-family="Monaco,Consolas,Menlo,'Bitstream Vera Sans Mono','Powerline Symbols',monospace" font-size="1.67"><defs><symbol id="1"><text y="1.67" class="f">❯</text></symbol><symbol id="2"><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">anta</text></symbol><symbol id="3"><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">anta</text><text x="7.014" y="1.67" class="f">nrfu</text></symbol><symbol id="4"><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">anta</text><text x="7.014" y="1.67" class="f">nrfu</text><text x="12.024" y="1.67" class="f">table</text></symbol><symbol id="5"><text y="1.67" class="g">╭──────────────────────</text><text x="24.048" y="1.67" class="h">Settings</text><text x="33.066" y="1.67" class="g">──────────────────────╮</text></symbol><symbol id="6"><text y="1.67" class="g">│</text><text x="2.004" y="1.67" class="g">Running</text><text x="10.02" y="1.67" class="g">ANTA</text><text x="15.03" y="1.67" class="g">tests:</text><text x="55.11" y="1.67" class="g">│</text></symbol><symbol id="7"><text y="1.67" class="g">│</text><text x="2.004" y="1.67" class="g">-</text><text x="4.008" y="1.67" class="g">ANTA</text><text x="9.018" y="1.67" class="g">Inventory</text><text x="19.038" y="1.67" class="g">contains</text><text x="28.056" y="1.67" class="g">6</text><text x="30.06" y="1.67" class="g">devices</text><text x="38.076" y="1.67" class="g">(AsyncEOSDevice)</text><text x="55.11" y="1.67" class="g">│</text></symbol><symbol id="8"><text y="1.67" class="g">│</text><text x="2.004" y="1.67" class="g">-</text><text x="4.008" y="1.67" class="g">Tests</text><text x="10.02" y="1.67" class="g">catalog</text><text x="18.036" y="1.67" class="g">contains</text><text x="27.054" y="1.67" class="g">89</text><text x="30.06" y="1.67" class="g">tests</text><text x="55.11" y="1.67" class="g">│</text></symbol><symbol id="9"><text y="1.67" class="g">╰──────────────────────────────────────────────────────╯</text></symbol><symbol id="10"><text y="1.67" class="h">(</text><text x="6.012" y="1.67" class="h">🐜)</text><text x="10.02" y="1.67" class="f">•</text><text x="12.024" y="1.67" class="f">Running</text><text x="20.04" y="1.67" class="f">NRFU</text><text x="25.05" y="1.67" class="f">Tests...</text><text x="35.07" y="1.67" class="i">0%</text><text x="38.076" y="1.67" class="j">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="96.192" y="1.67" class="h">0/534</text><text x="102.204" y="1.67" class="f">•</text><text x="104.208" y="1.67" class="k">0:00:00</text><text x="112.224" y="1.67" class="f">•</text><text x="114.228" y="1.67" class="g">-:--:--</text></symbol><symbol id="11"><text y="1.67" class="g">[11:29:47]</text><text x="11.022" y="1.67" style="white-space:pre" fill="#71bef2">INFO</text><text x="20.04" y="1.67" class="f">Running</text><text x="28.056" y="1.67" class="f">ANTA</text><text x="33.066" y="1.67" class="f">tests</text><text x="38.076" y="1.67" class="k">...</text><text x="109.218" y="1.67" class="f">runner.py:71</text></symbol><symbol id="12"><text y="1.67" class="h">(</text><text x="5.01" y="1.67" class="h">🐜</text><text x="8.016" y="1.67" class="h">)</text><text x="10.02" y="1.67" class="f">•</text><text x="12.024" y="1.67" class="f">Running</text><text x="20.04" y="1.67" class="f">NRFU</text><text x="25.05" y="1.67" class="f">Tests...</text><text x="34.068" y="1.67" class="i">15%</text><text x="38.076" y="1.67" class="m">━━━━━━━━</text><text x="46.092" y="1.67" class="j">╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="95.19" y="1.67" class="h">78/534</text><text x="102.204" y="1.67" class="f">•</text><text x="104.208" y="1.67" class="k">0:00:00</text><text x="112.224" y="1.67" class="f">•</text><text x="114.228" y="1.67" class="g">0:00:01</text></symbol><symbol id="13"><text y="1.67" class="h">(</text><text x="3.006" y="1.67" class="h">🐜</text><text x="8.016" y="1.67" class="h">)</text><text x="10.02" y="1.67" class="f">•</text><text x="12.024" y="1.67" class="f">Running</text><text x="20.04" y="1.67" class="f">NRFU</text><text x="25.05" y="1.67" class="f">Tests...</text><text x="34.068" y="1.67" class="i">15%</text><text x="38.076" y="1.67" class="m">━━━━━━━━</text><text x="46.092" y="1.67" class="j">╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="95.19" y="1.67" class="h">78/534</text><text x="102.204" y="1.67" class="f">•</text><text x="104.208" y="1.67" class="k">0:00:00</text><text x="112.224" y="1.67" class="f">•</text><text x="114.228" y="1.67" class="g">0:00:01</text></symbol><symbol id="14"><text y="1.67" class="h">(</text><text x="2.004" y="1.67" class="h">🐌</text><text x="8.016" y="1.67" class="h">)</text><text x="10.02" y="1.67" class="f">•</text><text x="12.024" y="1.67" class="f">Running</text><text x="20.04" y="1.67" class="f">NRFU</text><text x="25.05" y="1.67" class="f">Tests...</text><text x="34.068" y="1.67" class="i">99%</text><text x="38.076" y="1.67" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="94.188" y="1.67" class="h">531/534</text><text x="102.204" y="1.67" class="f">•</text><text x="104.208" y="1.67" class="k">0:00:02</text><text x="112.224" y="1.67" class="f">•</text><text x="114.228" y="1.67" class="g">0:00:01</text></symbol><symbol id="15"><text y="1.67" class="h">(</text><text x="4.008" y="1.67" class="h">🐌</text><text x="8.016" y="1.67" class="h">)</text><text x="10.02" y="1.67" class="f">•</text><text x="12.024" y="1.67" class="f">Running</text><text x="20.04" y="1.67" class="f">NRFU</text><text x="25.05" y="1.67" class="f">Tests...</text><text x="34.068" y="1.67" class="i">99%</text><text x="38.076" y="1.67" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="94.188" y="1.67" class="h">531/534</text><text x="102.204" y="1.67" class="f">•</text><text x="104.208" y="1.67" class="k">0:00:03</text><text x="112.224" y="1.67" class="f">•</text><text x="114.228" y="1.67" class="g">0:00:01</text></symbol><symbol id="16"><text y="1.67" class="h">(</text><text x="6.012" y="1.67" class="h">🐌)</text><text x="10.02" y="1.67" class="f">•</text><text x="12.024" y="1.67" class="f">Running</text><text x="20.04" y="1.67" class="f">NRFU</text><text x="25.05" y="1.67" class="f">Tests...</text><text x="34.068" y="1.67" class="i">99%</text><text x="38.076" y="1.67" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="94.188" y="1.67" class="h">531/534</text><text x="102.204" y="1.67" class="f">•</text><text x="104.208" y="1.67" class="k">0:00:03</text><text x="112.224" y="1.67" class="f">•</text><text x="114.228" y="1.67" class="g">0:00:01</text></symbol><symbol id="17"><text y="1.67" class="h">(</text><text x="5.01" y="1.67" class="h">🐜</text><text x="8.016" y="1.67" class="h">)</text><text x="10.02" y="1.67" class="f">•</text><text x="12.024" y="1.67" class="f">Running</text><text x="20.04" y="1.67" class="f">NRFU</text><text x="25.05" y="1.67" class="f">Tests...</text><text x="34.068" y="1.67" class="i">99%</text><text x="38.076" y="1.67" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="94.188" y="1.67" class="h">531/534</text><text x="102.204" y="1.67" class="f">•</text><text x="104.208" y="1.67" class="k">0:00:03</text><text x="112.224" y="1.67" class="f">•</text><text x="114.228" y="1.67" class="g">0:00:01</text></symbol><symbol id="18"><text x="2.004" y="1.67" class="f">•</text><text x="4.008" y="1.67" class="f">Running</text><text x="12.024" y="1.67" class="f">NRFU</text><text x="17.034" y="1.67" class="f">Tests...</text><text x="25.05" y="1.67" class="i">100%</text><text x="30.06" y="1.67" style="white-space:pre" fill="#5faf00">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="94.188" y="1.67" class="h">534/534</text><text x="102.204" y="1.67" class="f">•</text><text x="104.208" y="1.67" class="k">0:00:04</text><text x="112.224" y="1.67" class="f">•</text><text x="114.228" y="1.67" class="g">0:00:00</text></symbol><symbol id="a"><path fill="transparent" d="M0 0h121v11H0z"/></symbol><symbol id="b"><path fill="#6f7683" d="M0 0h1.102v2.171H0z"/></symbol></defs><path class="a" d="M0 0h121v21.71H0z"/><g style="animation-duration:8.22014s;animation-iteration-count:infinite;animation-name:q;animation-timing-function:steps(1,end)"><svg width="7744"><svg><use xlink:href="#a"/><use xlink:href="#b" x="-.004"/></svg><svg x="121"><use xlink:href="#a"/><use xlink:href="#b" x="1.996"/><use xlink:href="#1"/></svg><svg x="242"><use xlink:href="#a"/><use xlink:href="#b" x="2.996"/><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">a</text></svg><svg x="363"><use xlink:href="#a"/><use xlink:href="#b" x="3.996"/><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">an</text></svg><svg x="484"><use xlink:href="#a"/><use xlink:href="#b" x="4.996"/><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">ant</text></svg><svg x="605"><use xlink:href="#a"/><use xlink:href="#b" x="5.996"/><use xlink:href="#2"/></svg><svg x="726"><use xlink:href="#a"/><use xlink:href="#b" x="6.996"/><use xlink:href="#2"/></svg><svg x="847"><use xlink:href="#a"/><use xlink:href="#b" x="7.996"/><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">anta</text><text x="7.014" y="1.67" class="f">n</text></svg><svg x="968"><use xlink:href="#a"/><use xlink:href="#b" x="8.996"/><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">anta</text><text x="7.014" y="1.67" class="f">nr</text></svg><svg x="1089"><use xlink:href="#a"/><use xlink:href="#b" x="9.996"/><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">anta</text><text x="7.014" y="1.67" class="f">nrf</text></svg><svg x="1210"><use xlink:href="#a"/><use xlink:href="#b" x="10.996"/><use xlink:href="#3"/></svg><svg x="1331"><use xlink:href="#a"/><use xlink:href="#b" x="11.996"/><use xlink:href="#3"/></svg><svg x="1452"><use xlink:href="#a"/><use xlink:href="#b" x="12.996"/><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">anta</text><text x="7.014" y="1.67" class="f">nrfu</text><text x="12.024" y="1.67" class="f">t</text></svg><svg x="1573"><use xlink:href="#a"/><use xlink:href="#b" x="13.996"/><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">anta</text><text x="7.014" y="1.67" class="f">nrfu</text><text x="12.024" y="1.67" class="f">ta</text></svg><svg x="1694"><use xlink:href="#a"/><use xlink:href="#b" x="14.996"/><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">anta</text><text x="7.014" y="1.67" class="f">nrfu</text><text x="12.024" y="1.67" class="f">tab</text></svg><svg x="1815"><use xlink:href="#a"/><use xlink:href="#b" x="15.996"/><text y="1.67" class="f">❯</text><text x="2.004" y="1.67" class="f">anta</text><text x="7.014" y="1.67" class="f">nrfu</text><text x="12.024" y="1.67" class="f">tabl</text></svg><svg x="1936"><use xlink:href="#a"/><use xlink:href="#b" x="16.996"/><use xlink:href="#4"/></svg><svg x="2057"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="2.146"/><use xlink:href="#4"/></svg><svg x="2178"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="13.001"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/></svg><svg x="2299"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/></svg><svg x="2420"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#10" y="13.026"/></svg><svg x="2541"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#10" y="15.197"/></svg><svg x="2662"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="6.012" y="16.867" class="h">🐜)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">15%</text><text x="38.076" y="16.867" class="m">━━━━━━━━</text><text x="46.092" y="16.867" class="j">╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="95.19" y="16.867" class="h">78/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:00</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="2783"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#12" y="15.197"/></svg><svg x="2904"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#12" y="15.197"/></svg><svg x="3025"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="4.008" y="16.867" class="h">🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">15%</text><text x="38.076" y="16.867" class="m">━━━━━━━━</text><text x="46.092" y="16.867" class="j">╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="95.19" y="16.867" class="h">78/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:00</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="3146"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#13" y="15.197"/></svg><svg x="3267"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#13" y="15.197"/></svg><svg x="3388"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="2.004" y="16.867" class="h">🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">15%</text><text x="38.076" y="16.867" class="m">━━━━━━━━</text><text x="46.092" y="16.867" class="j">╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="95.19" y="16.867" class="h">78/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:00</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="3509"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">15%</text><text x="38.076" y="16.867" class="m">━━━━━━━━</text><text x="46.092" y="16.867" class="j">╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="95.19" y="16.867" class="h">78/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:00</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="3630"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">18%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━</text><text x="48.096" y="16.867" class="j">╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="95.19" y="16.867" class="h">98/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:00</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:05</text></svg><svg x="3751"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">35%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━</text><text x="57.114" y="16.867" class="j">╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="94.188" y="16.867" class="h">189/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:01</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:02</text></svg><svg x="3872"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="2.004" y="16.867" class="h">🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">55%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="68.136" y="16.867" class="j">╺━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="94.188" y="16.867" class="h">293/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:01</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="3993"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="3.006" y="16.867" class="h">🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">58%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="70.14" y="16.867" class="j">╺━━━━━━━━━━━━━━━━━━━━━━</text><text x="94.188" y="16.867" class="h">311/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:01</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="4114"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="3.006" y="16.867" class="h">🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">63%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="73.146" y="16.867" class="j">━━━━━━━━━━━━━━━━━━━━</text><text x="94.188" y="16.867" class="h">335/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:01</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="4235"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="4.008" y="16.867" class="h">🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">67%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="75.15" y="16.867" class="j">╺━━━━━━━━━━━━━━━━━</text><text x="94.188" y="16.867" class="h">360/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:01</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="4356"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="5.01" y="16.867" class="h">🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">71%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="77.154" y="16.867" class="j">━━━━━━━━━━━━━━━━</text><text x="94.188" y="16.867" class="h">378/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:01</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="4477"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="5.01" y="16.867" class="h">🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">72%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="77.154" y="16.867" class="j">╺━━━━━━━━━━━━━━━</text><text x="94.188" y="16.867" class="h">383/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:01</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="4598"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="6.012" y="16.867" class="h">🐌)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">72%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="78.156" y="16.867" class="j">━━━━━━━━━━━━━━━</text><text x="94.188" y="16.867" class="h">385/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:01</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="4719"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="6.012" y="16.867" class="h">🐜)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">76%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="80.16" y="16.867" class="j">━━━━━━━━━━━━━</text><text x="94.188" y="16.867" class="h">407/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:01</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="4840"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="6.012" y="16.867" class="h">🐜)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">81%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="83.166" y="16.867" class="j">━━━━━━━━━━</text><text x="94.188" y="16.867" class="h">433/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:01</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="4961"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="5.01" y="16.867" class="h">🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">84%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="84.168" y="16.867" class="j">╺━━━━━━━━</text><text x="94.188" y="16.867" class="h">447/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:02</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="5082"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="4.008" y="16.867" class="h">🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">85%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="85.17" y="16.867" class="j">━━━━━━━━</text><text x="94.188" y="16.867" class="h">456/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:02</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="5203"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="4.008" y="16.867" class="h">🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">87%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="86.172" y="16.867" class="j">╺━━━━━━</text><text x="94.188" y="16.867" class="h">467/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:02</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="5324"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="3.006" y="16.867" class="h">🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">89%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="87.174" y="16.867" class="j">╺━━━━━</text><text x="94.188" y="16.867" class="h">476/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:02</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="5445"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="2.004" y="16.867" class="h">🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">91%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="88.176" y="16.867" class="j">╺━━━━</text><text x="94.188" y="16.867" class="h">486/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:02</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="5566"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="2.004" y="16.867" class="h">🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">95%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="90.18" y="16.867" class="j">╺━━</text><text x="94.188" y="16.867" class="h">505/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:02</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="5687"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">98%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="92.184" y="16.867" class="j">━</text><text x="94.188" y="16.867" class="h">521/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:02</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="5808"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">99%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text x="92.184" y="16.867" class="j">╺</text><text x="94.188" y="16.867" class="h">528/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:02</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="5929"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#14" y="15.197"/></svg><svg x="6050"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#14" y="15.197"/></svg><svg x="6171"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="3.006" y="16.867" class="h">🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">99%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="94.188" y="16.867" class="h">531/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:03</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="6292"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#15" y="15.197"/></svg><svg x="6413"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#15" y="15.197"/></svg><svg x="6534"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="5.01" y="16.867" class="h">🐌</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">99%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="94.188" y="16.867" class="h">531/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:03</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="6655"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#16" y="15.197"/></svg><svg x="6776"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#16" y="15.197"/></svg><svg x="6897"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="6.012" y="16.867" class="h">🐜)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">99%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="94.188" y="16.867" class="h">531/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:03</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="7018"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#17" y="15.197"/></svg><svg x="7139"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#17" y="15.197"/></svg><svg x="7260"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="4.008" y="16.867" class="h">🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="34.068" y="16.867" class="i">99%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="94.188" y="16.867" class="h">531/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:03</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="7381"><use xlink:href="#a"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><text y="16.867" class="h">(</text><text x="3.006" y="16.867" class="h">🐜</text><text x="8.016" y="16.867" class="h">)</text><text x="10.02" y="16.867" class="f">•</text><text x="12.024" y="16.867" class="f">Running</text><text x="20.04" y="16.867" class="f">NRFU</text><text x="25.05" y="16.867" class="f">Tests...</text><text x="33.066" y="16.867" class="i">100%</text><text x="38.076" y="16.867" class="m">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸</text><text x="94.188" y="16.867" class="h">533/534</text><text x="102.204" y="16.867" class="f">•</text><text x="104.208" y="16.867" class="k">0:00:04</text><text x="112.224" y="16.867" class="f">•</text><text x="114.228" y="16.867" class="g">0:00:01</text></svg><svg x="7502"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="17.343"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#18" y="15.197"/></svg><svg x="7623"><use xlink:href="#a"/><use xlink:href="#b" x="1.996" y="17.343"/><use xlink:href="#4"/><use xlink:href="#5" y="2.171"/><use xlink:href="#6" y="4.342"/><use xlink:href="#7" y="6.513"/><use xlink:href="#8" y="8.684"/><use xlink:href="#9" y="10.855"/><use xlink:href="#11" y="13.026"/><use xlink:href="#18" y="15.197"/><use xlink:href="#1" y="17.368"/></svg></svg></g></g></svg></svg> diff --git a/docs/imgs/favicon.ico b/docs/imgs/favicon.ico Binary files differnew file mode 100644 index 0000000..cf55b0c --- /dev/null +++ b/docs/imgs/favicon.ico diff --git a/docs/imgs/uml/anta.device.AntaDevice.jpeg b/docs/imgs/uml/anta.device.AntaDevice.jpeg Binary files differnew file mode 100644 index 0000000..8d8f91e --- /dev/null +++ b/docs/imgs/uml/anta.device.AntaDevice.jpeg diff --git a/docs/imgs/uml/anta.device.AsyncEOSDevice.jpeg b/docs/imgs/uml/anta.device.AsyncEOSDevice.jpeg Binary files differnew file mode 100644 index 0000000..255b8e4 --- /dev/null +++ b/docs/imgs/uml/anta.device.AsyncEOSDevice.jpeg diff --git a/docs/imgs/uml/anta.models.AntaCommand.jpeg b/docs/imgs/uml/anta.models.AntaCommand.jpeg Binary files differnew file mode 100644 index 0000000..b73b87f --- /dev/null +++ b/docs/imgs/uml/anta.models.AntaCommand.jpeg diff --git a/docs/imgs/uml/anta.models.AntaTemplate.jpeg b/docs/imgs/uml/anta.models.AntaTemplate.jpeg Binary files differnew file mode 100644 index 0000000..2485cb7 --- /dev/null +++ b/docs/imgs/uml/anta.models.AntaTemplate.jpeg diff --git a/docs/imgs/uml/anta.models.AntaTest.jpeg b/docs/imgs/uml/anta.models.AntaTest.jpeg Binary files differnew file mode 100644 index 0000000..36abed4 --- /dev/null +++ b/docs/imgs/uml/anta.models.AntaTest.jpeg diff --git a/docs/imgs/uml/anta.result_manager.ResultManager.jpeg b/docs/imgs/uml/anta.result_manager.ResultManager.jpeg Binary files differnew file mode 100644 index 0000000..7f29943 --- /dev/null +++ b/docs/imgs/uml/anta.result_manager.ResultManager.jpeg diff --git a/docs/imgs/uml/anta.result_manager.models.TestResult.jpeg b/docs/imgs/uml/anta.result_manager.models.TestResult.jpeg Binary files differnew file mode 100644 index 0000000..25ad998 --- /dev/null +++ b/docs/imgs/uml/anta.result_manager.models.TestResult.jpeg diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 0000000..2863221 --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block outdated %} + <div id="current-version"> </div> + <script> + let message = "You're not viewing the latest version." + // There is probably a nicer method retrieving the value of the + // version element but it is loaded after the outdated block + let current = window.location.pathname.split("/")[1] + if (current == "main") { + message = "This is the documentation from the 'main' branch which is not a stable release." + } + message = message + '<a href="{{ "../" ~ base_url }}"> <strong>Click here to go to latest stable release.</strong> </a>' + document.getElementById("current-version").innerHTML = message; + </script> + {{app}} +{% endblock %} diff --git a/docs/requirements-and-installation.md b/docs/requirements-and-installation.md new file mode 100644 index 0000000..9885cbe --- /dev/null +++ b/docs/requirements-and-installation.md @@ -0,0 +1,105 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# ANTA Requirements + +## Python version + +Python 3 (`>=3.8`) is required: + +```bash +python --version +Python 3.9.9 +``` + +## Install ANTA package + +This installation will deploy tests collection, scripts and all their Python requirements. + +The ANTA package and the cli require some packages that are not part of the Python standard library. They are indicated in the [pyproject.toml](https://github.com/arista-netdevops-community/anta/blob/main/pyproject.toml) file, under dependencies. + + +### Install from Pypi server + +```bash +pip install anta +``` + +### Install ANTA from github + + +```bash +pip install git+https://github.com/arista-netdevops-community/anta.git + +# You can even specify the branch, tag or commit: +pip install git+https://github.com/arista-netdevops-community/anta.git@<cool-feature-branch> +pip install git+https://github.com/arista-netdevops-community/anta.git@<cool-tag> +pip install git+https://github.com/arista-netdevops-community/anta.git@<more-or-less-cool-hash> +``` + + +### Check installation + +After installing ANTA, verify the installation with the following commands: + +```bash +# Check ANTA has been installed in your python path +pip list | grep anta + +# Check scripts are in your $PATH +# Path may differ but it means CLI is in your path +which anta +/home/tom/.pyenv/shims/anta +``` + +!!! warning + Before running the `anta --version` command, please be aware that some users have reported issues related to the `urllib3` package. If you encounter an error at this step, please refer to our [FAQ](faq.md) page for guidance on resolving it. + +```bash +# Check ANTA version +anta --version +anta, version v0.13.0 +``` + +## EOS Requirements + +To get ANTA working, the targetted Arista EOS devices must have the following configuration (assuming you connect to the device using Management interface in MGMT VRF): + +```eos +configure +! +vrf instance MGMT +! +interface Management1 + description oob_management + vrf MGMT + ip address 10.73.1.105/24 +! +end +``` + +Enable eAPI on the MGMT vrf: + +```eos +configure +! +management api http-commands + protocol https port 443 + no shutdown + vrf MGMT + no shutdown +! +end +``` + +Now the swicth accepts on port 443 in the MGMT VRF HTTPS requests containing a list of CLI commands. + +Run these EOS commands to verify: + +```eos +show management http-server +show management api http-commands +``` diff --git a/docs/scripts/generate_svg.py b/docs/scripts/generate_svg.py new file mode 100644 index 0000000..19177db --- /dev/null +++ b/docs/scripts/generate_svg.py @@ -0,0 +1,92 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +A script to generate svg files from anta command + +usage: + +python generate_svg.py anta ... +""" + +import io +import os +import pathlib +import sys +from contextlib import redirect_stdout, suppress +from importlib import import_module +from importlib.metadata import entry_points +from unittest.mock import patch + +from rich.console import Console + +from anta.cli.console import console +from anta.cli.nrfu.utils import anta_progress_bar + +OUTPUT_DIR = pathlib.Path(__file__).parent.parent / "imgs" + + +def custom_progress_bar() -> None: + """ + Set the console of progress_bar to main anta console + + Caveat: this capture all steps of the progress bar.. + Disabling refresh to only capture beginning and end + """ + progress = anta_progress_bar() + progress.live.auto_refresh = False + progress.live.console = console + return progress + + +if __name__ == "__main__": + # Sane rich size + os.environ["COLUMNS"] = "165" + + # stolen from https://github.com/ewels/rich-click/blob/main/src/rich_click/cli.py + args = sys.argv[1:] + script_name = args[0] + scripts = {script.name: script for script in entry_points().get("console_scripts")} + + if script_name in scripts: + # A VALID SCRIPT WAS passed + script = scripts[script_name] + module_path, function_name = script.value.split(":", 1) + prog = script_name + elif ":" in script_name: + # the path to a function was passed + module_path, function_name = args[0].split(":", 1) + prog = module_path.split(".", 1)[0] + else: + print("This is supposed to be used with anta only") + print("Usage: python generate_svg.py anta <options>") + sys.exit(1) + + sys.argv = [prog, *args[1:]] + module = import_module(module_path) + function = getattr(module, function_name) + + # Console to captur everything + new_console = Console(record=True) + + # tweaks to record and redirect to a dummy file + pipe = io.StringIO() + console.record = True + console.file = pipe + + # Redirect stdout of the program towards another StringIO to capture help + # that is not part or anta rich console + with redirect_stdout(io.StringIO()) as f: + # redirect potential progress bar output to console by patching + with patch("anta.cli.nrfu.commands.anta_progress_bar", custom_progress_bar): + with suppress(SystemExit): + function() + # print to our new console the output of anta console + new_console.print(console.export_text()) + # print the content of the stdout to our new_console + new_console.print(f.getvalue()) + + filename = f"{'_'.join(map(lambda x: x.replace('/', '_').replace('-', '_').replace('.', '_'), args))}.svg" + filename = f"{OUTPUT_DIR}/{filename}" + print(f"File saved at {filename}") + new_console.save_svg(filename, title=" ".join(args)) diff --git a/docs/snippets/anta_help.txt b/docs/snippets/anta_help.txt new file mode 100644 index 0000000..0c3302a --- /dev/null +++ b/docs/snippets/anta_help.txt @@ -0,0 +1,20 @@ +Usage: anta [OPTIONS] COMMAND [ARGS]... + + Arista Network Test Automation (ANTA) CLI + +Options: + --version Show the version and exit. + --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] + -l, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG] + ANTA logging level [env var: + ANTA_LOG_LEVEL; default: INFO] + --help Show this message and exit. + +Commands: + 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/docs/stylesheets/extra.material.css b/docs/stylesheets/extra.material.css new file mode 100644 index 0000000..b401c9a --- /dev/null +++ b/docs/stylesheets/extra.material.css @@ -0,0 +1,207 @@ +[data-md-color-scheme="slate"] { + --md-hue: 210; +} + +:root { + /* Color schema based on Arista Color Schema */ + /* Default color shades */ + --md-default-fg-color: #000000; + --md-default-fg-color--light: #a1a0a0; + --md-default-fg-color--lighter: #FFFFFF; + --md-default-fg-color--lightest: #FFFFFF; + --md-default-bg-color: #FFFFFF; + --md-default-bg-color--light: #FFFFFF; + --md-default-bg-color--lighter: #FFFFFF; + --md-default-bg-color--lightest: #FFFFFF; + + /* Primary color shades */ + --md-primary-fg-color: #27569B; + --md-primary-fg-color--light: #FFFFFF; + --md-primary-fg-color--dark: #27569B; + --md-primary-bg-color: #FFFFFF; + --md-primary-bg-color--light: #FFFFFF; + + /* Accent color shades */ + --md-accent-fg-color: #27569B; + --md-accent-bg-color: #27569B; + --md-accent-bg-color--light: #27569B; + + /* Link color */ + --md-typeset-a-color: #27569B; + --md-typeset-a-color-fg: #FFFFFF; + --md-typeset-a-color-bg: #27569B; + + /* Code block color shades */ + --md-code-bg-color: #E6E6E6; + --md-code-border-color: #0000004f; + --block-code-bg-color: #e4e4e4; + /* --md-code-fg-color: ...; */ + + font-size: 1.1rem; + /* min-height: 100%; + position: relative; + width: 100%; */ + font-feature-settings: "kern","liga"; + font-family: var(--md-text-font-family,_),-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif; + -webkit-font-smoothing: antialiased; + +} + +[data-md-color-scheme="slate"] { + + /* Link color */ + --md-typeset-a-color: #75aaf8; + --md-typeset-a-color-fg: #FFFFFF; + --md-typeset-a-color-bg: #27569B; + + /* Code block color shades */ + /* --md-code-bg-color: #E6E6E6; */ + --md-code-border-color: #aec6db4f; + /* --block-code-bg-color: #e4e4e4; */ +} + +@media only screen and (min-width: 76.25em) { + .md-main__inner, .md-header__inner { + max-width: 85%; + } + .md-sidebar--primary { + left: 5%; + } + .md-sidebar--secondary { + right: 5%; + margin-left: 0; + -webkit-transform: none; + transform: none; + } +} + +@media only screen { + .md-typeset a:hover { + background-color: var(--md-typeset-a-color-bg); + color: var(--md-typeset-a-color-fg); + } + .md-footer-nav { + background-color: var(--md-default-bg-color--light); + color: var(--md-accent-fg-color--transparent) + } + .md-footer { + height: 2%; + } + .md-footer-nav__direction { + position: absolute; + right: 0; + left: 0; + margin-top: -1rem; + padding: 0 1rem; + color: var(--md-default-fg-color--light); + font-size: .64rem; + } + .md-footer-nav__title { + font-size: 1.2rem; + line-height: 10rem; + color: var(--md-default-fg-color--light); + } + + .md-typeset h4 h5 h6 { + font-size: 1.5rem; + margin: 1em 0; + /* font-weight: 700; */ + letter-spacing: -.01em; + line-height: 3em; + } + + .md-typeset table:not([class]) th { + min-width: 5rem; + padding: .6rem .8rem; + color: var(--md-default-fg-color); + vertical-align: top; + /* background-color: var(--md-accent-bg-color); */ + text-align: left; + /* min-width: 100%; */ + /* display: table; */ + } + .md-typeset table:not([class]) td { + /* padding: .9375em 1.25em; */ + border-collapse: collapse; + vertical-align: center; + text-align: left; + /* border-bottom: 1px solid var(--md-default-fg-color--light); */ + } + .md-typeset code { + padding: 0 .2941176471em; + font-size: 100%; + word-break: break-word; + background-color: var(--md-code-bg-color); + border-radius: .1rem; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + } + .highlight code { + background-color: var(--md-code-bg-color); + font-size: 90%; + border-radius: 2%; + } + .md-typeset .admonition, .md-typeset details { + margin: 1.5625em 0; + padding: 0 .6rem; + overflow: hidden; + font-size: 90%; + page-break-inside: avoid; + border-left: .2rem solid var(--md-accent-bg-color); + border-left-color: var(--md-accent-bg-color); + border-radius: .1rem; + box-shadow: 0 .2rem .5rem rgba(0,0,0,.05),0 0 .05rem rgba(0,0,0,.1); + } + /* .md-typeset .note > .admonition-title, .md-typeset .note > summary { + background-color: var(--md-accent-bg-color); + color: var(--md-default-fg-color--lighter) + } */ + .md-typeset__table { + min-width: 80%; + } + .md-typeset table:not([class]) { + display: table; + } + + .mdx-content__footer { + margin-top: 20px; + text-align: center; + } + .mdx-content__footer a { + display: inline-block; + transition: transform 250ms cubic-bezier(0.1, 0.7, 0.1, 1), color 125ms; + } + .mdx-content__footer a:focus, .mdx-content__footer a:hover { + transform: scale(1.2); + } + + .md-typeset table:not([class]) th { + min-width: 5rem; + padding: .6rem .8rem; + /* color: var(--md-primary-fg-color--light); */ + bg: var(--md-footer-fg-color--lighter); + } + + .md-footer-copyright { + color: var(--md-footer-fg-color--lighter); + font-size: .64rem; + margin: auto 0.6rem; + padding: 0.4rem; + width: 100%; + text-align: center; + } + .img_center { + display: block; + margin-left: auto; + margin-right: auto; + border-radius: 1%; + /* width: 50%; */ + } +} + +/* mkdocstrings css from official repo to indent sub-elements nicely */ +/* Indentation. */ +div.doc-contents { + padding-left: 25px; + border-left: .05rem solid var(--md-typeset-table-color); +} diff --git a/docs/stylesheets/highlight.js b/docs/stylesheets/highlight.js new file mode 100644 index 0000000..86e50b9 --- /dev/null +++ b/docs/stylesheets/highlight.js @@ -0,0 +1,3 @@ +document$.subscribe(() => { + hljs.highlightAll() +}) diff --git a/docs/stylesheets/tables.js b/docs/stylesheets/tables.js new file mode 100644 index 0000000..e848f07 --- /dev/null +++ b/docs/stylesheets/tables.js @@ -0,0 +1,6 @@ +document$.subscribe(function() { + var tables = document.querySelectorAll("article table") + tables.forEach(function(table) { + new Tablesort(table) + }) +}) diff --git a/docs/usage-inventory-catalog.md b/docs/usage-inventory-catalog.md new file mode 100644 index 0000000..39734f3 --- /dev/null +++ b/docs/usage-inventory-catalog.md @@ -0,0 +1,246 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +# Inventory and Catalog + +The ANTA framework needs 2 important inputs from the user to run: a **device inventory** and a **test catalog**. + +Both inputs can be defined in a file or programmatically. + +## Device Inventory + +A device inventory is an instance of the [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) class. + +### Device Inventory File + +The ANTA device inventory can easily be defined as a YAML file. +The file must comply with the following structure: + +```yaml +anta_inventory: + hosts: + - host: < ip address value > + port: < TCP port for eAPI. Default is 443 (Optional)> + name: < name to display in report. Default is host:port (Optional) > + tags: < list of tags to use to filter inventory during tests > + disable_cache: < Disable cache per hosts. Default is False. > + networks: + - network: < network using CIDR notation > + tags: < list of tags to use to filter inventory during tests > + disable_cache: < Disable cache per network. Default is False. > + ranges: + - start: < first ip address value of the range > + end: < last ip address value of the range > + tags: < list of tags to use to filter inventory during tests > + disable_cache: < Disable cache per range. Default is False. > +``` + +The inventory file must start with the `anta_inventory` key then define one or multiple methods: + +- `hosts`: define each device individually +- `networks`: scan a network for devices accesible via eAPI +- `ranges`: scan a range for devices accesible via eAPI + +A full description of the inventory model is available in [API documentation](api/inventory.models.input.md) + +!!! info + Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` in the inventory file. For more details about how caching is implemented in ANTA, please refer to [Caching in ANTA](advanced_usages/caching.md). + +### Example + +```yaml +--- +anta_inventory: + hosts: + - host: 192.168.0.10 + name: spine01 + tags: ['fabric', 'spine'] + - host: 192.168.0.11 + name: spine02 + tags: ['fabric', 'spine'] + networks: + - network: '192.168.110.0/24' + tags: ['fabric', 'leaf'] + ranges: + - start: 10.0.0.9 + end: 10.0.0.11 + tags: ['fabric', 'l2leaf'] +``` + +## Test Catalog + +A test catalog is an instance of the [AntaCatalog](../api/catalog.md#anta.catalog.AntaCatalog) class. + +### Test Catalog File + +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: +```yaml +--- +<Python module>: + - <AntaTest subclass>: + <AntaTest.Input compliant dictionary> +``` + +### Example + +```yaml +--- +anta.tests.connectivity: + - VerifyReachability: + hosts: + - source: Management0 + destination: 1.1.1.1 + vrf: MGMT + - source: Management0 + destination: 8.8.8.8 + vrf: MGMT + filters: + tags: ['leaf'] + result_overwrite: + categories: + - "Overwritten category 1" + description: "Test with overwritten description" + custom_field: "Test run by John Doe" +``` + +It is also possible to nest Python module definition: +```yaml +anta.tests: + connectivity: + - VerifyReachability: + hosts: + - source: Management0 + destination: 1.1.1.1 + vrf: MGMT + - source: Management0 + destination: 8.8.8.8 + vrf: MGMT + filters: + tags: ['leaf'] + result_overwrite: + categories: + - "Overwritten category 1" + description: "Test with overwritten description" + custom_field: "Test run by John Doe" +``` + +[This test catalog example](https://github.com/arista-netdevops-community/anta/blob/main/examples/tests.yaml) is maintained with all the tests defined in the `anta.tests` Python module. + +### Test tags + +All tests can be defined with a list of user defined tags. These tags will be mapped with device tags: when at least one tag is defined for a test, this test will only be executed on devices with the same tag. If a test is defined in the catalog without any tags, the test will be executed on all devices. + +```yaml +anta.tests.system: + - VerifyUptime: + minimum: 10 + filters: + tags: ['demo', 'leaf'] + - VerifyReloadCause: + - VerifyCoredump: + - VerifyAgentLogs: + - VerifyCPUUtilization: + filters: + tags: ['leaf'] +``` + +!!! info + When using the CLI, you can filter the NRFU execution using tags. Refer to [this section](cli/tag-management.md) of the CLI documentation. + +### Tests available in ANTA + +All tests available as part of the ANTA framework are defined under the `anta.tests` Python module and are categorised per family (Python submodule). +The complete list of the tests and their respective inputs is available at the [tests section](api/tests.md) of this website. + + +To run test to verify the EOS software version, you can do: + +```yaml +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: + +```yaml +anta.tests.software: + - VerifyEOSVersion: + # List of allowed EOS versions. + versions: + - 4.25.4M + - 4.26.1F +``` + +The following example is a very minimal test catalog: + +```yaml +--- +# Load anta.tests.software +anta.tests.software: + # Verifies the device is running one of the allowed EOS version. + - VerifyEOSVersion: + # List of allowed EOS versions. + versions: + - 4.25.4M + - 4.26.1F + +# Load anta.tests.system +anta.tests.system: + # Verifies the device uptime is higher than a value. + - VerifyUptime: + minimum: 1 + +# Load anta.tests.configuration +anta.tests.configuration: + # Verifies ZeroTouch is disabled. + - VerifyZeroTouch: + - VerifyRunningConfigDiffs: +``` + +### Catalog with custom tests + +In case you want to leverage your own tests collection, use your own Python package in the test catalog. +So for instance, if my custom tests are defined in the `titom73.tests.system` Python module, the test catalog will be: + +```yaml +titom73.tests.system: + - VerifyPlatform: + type: ['cEOS-LAB'] +``` + +!!! tip "How to create custom tests" + To create your custom tests, you should refer to this [documentation](advanced_usages/custom-tests.md) + +### Customize test description and categories + +It might be interesting to use your own categories and customized test description to build a better report for your environment. ANTA comes with a handy feature to define your own `categories` and `description` in the report. + +In your test catalog, use `result_overwrite` dictionary with `categories` and `description` to just overwrite this values in your report: + +```yaml +anta.tests.configuration: + - VerifyZeroTouch: # Verifies ZeroTouch is disabled. + result_overwrite: + categories: ['demo', 'pr296'] + description: A custom test + - VerifyRunningConfigDiffs: +anta.tests.interfaces: + - VerifyInterfaceUtilization: +``` + +Once you run `anta nrfu table`, you will see following output: + +```bash +┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ +┃ Device IP ┃ Test Name ┃ Test Status ┃ Message(s) ┃ Test description ┃ Test category ┃ +┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ +│ spine01 │ VerifyZeroTouch │ success │ │ A custom test │ demo, pr296 │ +│ spine01 │ VerifyRunningConfigDiffs │ success │ │ │ configuration │ +│ spine01 │ VerifyInterfaceUtilization │ success │ │ Verifies interfaces utilization is below 75%. │ interfaces │ +└───────────┴────────────────────────────┴─────────────┴────────────┴───────────────────────────────────────────────┴───────────────┘ +``` diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5c24087 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,12 @@ +## Device Inventory + +The file [inventory.yaml](inventory.yaml) is an example of [device inventory](https://www.anta.ninja/usage-inventory-catalog/#create-an-inventory-file). + +## Test Catalog + +The file [tests.yaml](tests.yaml) is an example of a [test catalog](https://www.anta.ninja/usage-inventory-catalog/#test-catalog). +This file should contain all the tests implemented in [anta.tests](../anta/tests) with arbitrary parameters. + +## eos-commands.yaml file + +The file [eos-commands.yaml](eos-commands.yaml) is an example of input given with the `--commands-list` option to the [anta exec snapshot](https://www.anta.ninja/cli/exec/#collect-a-set-of-commands) command. diff --git a/examples/eos-commands.yaml b/examples/eos-commands.yaml new file mode 100644 index 0000000..c45d49d --- /dev/null +++ b/examples/eos-commands.yaml @@ -0,0 +1,53 @@ +--- +# list of EOS commands to collect in JSON format +json_format: + - show version + - show bgp evpn summary + - show system environment temperature transceiver + +# list of EOS commands to collect in text format +text_format: + - show version + - show version detail + - show extensions + - show boot-extensions + - show inventory + - show zerotouch + - show running-config diffs + - show agent logs crash + - show logging last 7 days threshold warnings + - show uptime + - show reload cause + - show system environment temperature + - show system environment temperature transceiver + - show system environment cooling + - show system environment power + - show processes top once + - show ntp status + - show platform trident forwarding-table partition + - show hardware tcam profile + - show hardware counter drop + - show interfaces counters rates + - show interfaces counters errors + - show interfaces counters discards + - show interfaces status + - show port-channel + - show lacp counters all-ports + - show spanning-tree blockedports + - show ip interface brief + - show interfaces description + - show ip route summary + - show bfd peers + - show bgp ipv4 unicast summary vrf all + - show bgp ipv6 unicast summary vrf all + - show bgp evpn summary + - show bgp rt-membership summary + - show mlag + - show mlag config-sanity + - show vxlan config-sanity detail + - show vlan dynamic + - show ip igmp snooping vlan 10 + - show vrf + - show ip ospf neighbor + - show running-config + - show lldp neighbors diff --git a/examples/inventory.yaml b/examples/inventory.yaml new file mode 100644 index 0000000..f68a903 --- /dev/null +++ b/examples/inventory.yaml @@ -0,0 +1,28 @@ +--- +anta_inventory: + hosts: + - name: spine1 + host: clab-evpn-vxlan-fabric-spine1 + tags: ['clab', 'spine'] + - name: spine2 + host: clab-evpn-vxlan-fabric-spine2 + tags: ['clab', 'spine'] + - name: leaf1 + host: clab-evpn-vxlan-fabric-leaf1 + tags: ['clab', 'leaf'] + - name: leaf2 + host: clab-evpn-vxlan-fabric-leaf2 + tags: ['clab', 'leaf'] + - name: leaf3 + host: clab-evpn-vxlan-fabric-leaf3 + tags: ['clab', 'leaf'] + - name: leaf4 + host: clab-evpn-vxlan-fabric-leaf4 + tags: ['clab', 'leaf'] + networks: + - network: 192.168.110.0/24 + ranges: + - start: 10.0.0.9 + end: 10.0.0.11 + - start: 10.0.0.100 + end: 10.0.0.101 diff --git a/examples/template.j2 b/examples/template.j2 new file mode 100644 index 0000000..e8820fe --- /dev/null +++ b/examples/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/examples/tests.yaml b/examples/tests.yaml new file mode 100644 index 0000000..6b5875f --- /dev/null +++ b/examples/tests.yaml @@ -0,0 +1,477 @@ +--- +anta.tests.aaa: + - VerifyTacacsSourceIntf: + intf: Management0 + vrf: default + - VerifyTacacsServers: + servers: + - 1.1.1.1 + - 2.2.2.2 + vrf: default + - VerifyTacacsServerGroups: + groups: + - admin + - user + - VerifyAuthenMethods: + methods: + - local + - none + - logging + types: + - login + - enable + - dot1x + - VerifyAuthzMethods: + methods: + - local + - none + - logging + types: + - commands + - exec + - VerifyAcctDefaultMethods: + methods: + - local + - none + - logging + types: + - system + - exec + - commands + - dot1x + - VerifyAcctConsoleMethods: + methods: + - local + - none + - logging + types: + - system + - exec + - commands + - dot1x + +anta.tests.bfd: + - VerifyBFDSpecificPeers: + bfd_peers: + - peer_address: 192.0.255.8 + vrf: default + - peer_address: 192.0.255.7 + vrf: default + - VerifyBFDPeersIntervals: + bfd_peers: + - peer_address: 192.0.255.8 + vrf: default + tx_interval: 1200 + rx_interval: 1200 + multiplier: 3 + - peer_address: 192.0.255.7 + vrf: default + tx_interval: 1200 + rx_interval: 1200 + multiplier: 3 + - VerifyBFDPeersHealth: + down_threshold: 2 + +anta.tests.configuration: + - VerifyZeroTouch: + - VerifyRunningConfigDiffs: + +anta.tests.connectivity: + - VerifyReachability: + hosts: + - source: Management0 + destination: 1.1.1.1 + vrf: MGMT + - source: Management0 + destination: 8.8.8.8 + vrf: MGMT + - VerifyLLDPNeighbors: + neighbors: + - port: Ethernet1 + neighbor_device: DC1-SPINE1 + neighbor_port: Ethernet1 + - port: Ethernet2 + neighbor_device: DC1-SPINE2 + neighbor_port: Ethernet1 + +anta.tests.field_notices: + - VerifyFieldNotice44Resolution: + - VerifyFieldNotice72Resolution: + +anta.tests.hardware: + - VerifyTransceiversManufacturers: + manufacturers: + - Not Present + - Arista Networks + - Arastra, Inc. + - VerifyTemperature: + - VerifyTransceiversTemperature: + - VerifyEnvironmentSystemCooling: + - VerifyEnvironmentCooling: + states: + - ok + - VerifyEnvironmentPower: + states: + - ok + - VerifyAdverseDrops: + +anta.tests.interfaces: + - VerifyInterfaceUtilization: + - VerifyInterfaceErrors: + - VerifyInterfaceDiscards: + - VerifyInterfaceErrDisabled: + - VerifyInterfacesStatus: + interfaces: + - interface: Ethernet1 + state: up + - interface: Port-Channel100 + state: down + line_protocol_status: lowerLayerDown + - interface: Ethernet49/1 + state: adminDown + line_protocol_status: notPresent + - VerifyStormControlDrops: + - VerifyPortChannels: + - VerifyIllegalLACP: + - VerifyLoopbackCount: + number: 3 + - VerifySVI: + - VerifyL3MTU: + mtu: 1500 + ignored_interfaces: + - Vxlan1 + specific_mtu: + - Ethernet1: 2500 + - VerifyIPProxyARP: + interfaces: + - Ethernet1 + - Ethernet2 + - VerifyL2MTU: + mtu: 1500 + ignored_interfaces: + - Management1 + - Vxlan1 + specific_mtu: + - Ethernet1/1: 1500 + - VerifyInterfaceIPv4: + interfaces: + - name: Ethernet2 + primary_ip: 172.30.11.0/31 + secondary_ips: + - 10.10.10.0/31 + - 10.10.10.10/31 + - VerifyIpVirtualRouterMac: + mac_address: 00:1c:73:00:dc:01 + +anta.tests.logging: + - VerifyLoggingPersistent: + - VerifyLoggingSourceIntf: + interface: Management0 + vrf: default + - VerifyLoggingHosts: + hosts: + - 1.1.1.1 + - 2.2.2.2 + vrf: default + - VerifyLoggingLogsGeneration: + - VerifyLoggingHostname: + - VerifyLoggingTimestamp: + - VerifyLoggingAccounting: + - VerifyLoggingErrors: + +anta.tests.mlag: + - VerifyMlagStatus: + - VerifyMlagInterfaces: + - VerifyMlagConfigSanity: + - VerifyMlagReloadDelay: + reload_delay: 300 + reload_delay_non_mlag: 330 + - VerifyMlagDualPrimary: + detection_delay: 200 + errdisabled: True + recovery_delay: 60 + recovery_delay_non_mlag: 0 + - VerifyMlagPrimaryPriority: + primary_priority: 3276 + +anta.tests.multicast: + - VerifyIGMPSnoopingVlans: + vlans: + 10: False + 12: False + - VerifyIGMPSnoopingGlobal: + enabled: True + +anta.tests.profiles: + - VerifyUnifiedForwardingTableMode: + mode: 3 + - VerifyTcamProfile: + profile: vxlan-routing + +anta.tests.security: + - VerifySSHStatus: + - VerifySSHIPv4Acl: + number: 3 + vrf: default + - VerifySSHIPv6Acl: + number: 3 + vrf: default + - VerifyTelnetStatus: + - VerifyAPIHttpStatus: + - VerifyAPIHttpsSSL: + profile: default + - VerifyAPIIPv4Acl: + number: 3 + vrf: default + - VerifyAPIIPv6Acl: + number: 3 + vrf: default + - VerifyAPISSLCertificate: + certificates: + - certificate_name: ARISTA_SIGNING_CA.crt + expiry_threshold: 30 + common_name: AristaIT-ICA ECDSA Issuing Cert Authority + encryption_algorithm: ECDSA + key_size: 256 + - certificate_name: ARISTA_ROOT_CA.crt + expiry_threshold: 30 + common_name: Arista Networks Internal IT Root Cert Authority + encryption_algorithm: RSA + key_size: 4096 + - VerifyBannerLogin: + login_banner: | + # Copyright (c) 2023-2024 Arista Networks, Inc. + # Use of this source code is governed by the Apache License 2.0 + # that can be found in the LICENSE file. + - VerifyBannerMotd: + motd_banner: | + # Copyright (c) 2023-2024 Arista Networks, Inc. + # Use of this source code is governed by the Apache License 2.0 + # that can be found in the LICENSE file. + - VerifyIPv4ACL: + ipv4_access_lists: + - name: default-control-plane-acl + entries: + - sequence: 10 + action: permit icmp any any + - sequence: 20 + action: permit ip any any tracked + - sequence: 30 + action: permit udp any any eq bfd ttl eq 255 + - name: LabTest + entries: + - sequence: 10 + action: permit icmp any any + - sequence: 20 + action: permit tcp any any range 5900 5910 + +anta.tests.services: + - VerifyHostname: + hostname: s1-spine1 + - VerifyDNSLookup: + domain_names: + - arista.com + - www.google.com + - arista.ca + - VerifyDNSServers: + dns_servers: + - server_address: 10.14.0.1 + vrf: default + priority: 1 + - server_address: 10.14.0.11 + vrf: MGMT + priority: 0 + - VerifyErrdisableRecovery: + reasons: + - reason: acl + interval: 30 + - reason: bpduguard + interval: 30 + +anta.tests.snmp: + - VerifySnmpStatus: + vrf: default + - VerifySnmpIPv4Acl: + number: 3 + vrf: default + - VerifySnmpIPv6Acl: + number: 3 + vrf: default + - VerifySnmpLocation: + location: New York + - VerifySnmpContact: + contact: Jon@example.com + +anta.tests.software: + - VerifyEOSVersion: + versions: + - 4.25.4M + - 4.26.1F + - VerifyTerminAttrVersion: + versions: + - v1.13.6 + - v1.8.0 + - VerifyEOSExtensions: + +anta.tests.stp: + - VerifySTPMode: + mode: rapidPvst + vlans: + - 10 + - 20 + - VerifySTPBlockedPorts: + - VerifySTPCounters: + - VerifySTPForwardingPorts: + vlans: + - 10 + - 20 + - VerifySTPRootPriority: + priority: 32768 + instances: + - 10 + - 20 + +anta.tests.system: + - VerifyUptime: + minimum: 86400 + - VerifyReloadCause: + - VerifyCoredump: + - VerifyAgentLogs: + - VerifyCPUUtilization: + - VerifyMemoryUtilization: + - VerifyFileSystemUtilization: + - VerifyNTP: + +anta.tests.vlan: + - VerifyVlanInternalPolicy: + policy: ascending + start_vlan_id: 1006 + end_vlan_id: 4094 + +anta.tests.vxlan: + - VerifyVxlan1Interface: + - VerifyVxlanConfigSanity: + - VerifyVxlanVniBinding: + bindings: + 10010: 10 + 10020: 20 + - VerifyVxlanVtep: + vteps: + - 10.1.1.5 + - 10.1.1.6 + - VerifyVxlan1ConnSettings: + source_interface: Loopback1 + udp_port: 4789 + +anta.tests.routing: + generic: + - VerifyRoutingProtocolModel: + model: multi-agent + - VerifyRoutingTableSize: + minimum: 2 + maximum: 20 + - VerifyRoutingTableEntry: + vrf: default + routes: + - 10.1.0.1 + - 10.1.0.2 + bgp: + - VerifyBGPPeerCount: + address_families: + - afi: "evpn" + num_peers: 2 + - afi: "ipv4" + safi: "unicast" + vrf: "PROD" + num_peers: 2 + - afi: "ipv4" + safi: "unicast" + vrf: "default" + num_peers: 3 + - afi: "ipv4" + safi: "multicast" + vrf: "DEV" + num_peers: 3 + - VerifyBGPPeersHealth: + address_families: + - afi: "evpn" + - afi: "ipv4" + safi: "unicast" + vrf: "default" + - afi: "ipv6" + safi: "unicast" + vrf: "DEV" + - VerifyBGPSpecificPeers: + address_families: + - afi: "evpn" + peers: + - 10.1.0.1 + - 10.1.0.2 + - afi: "ipv4" + safi: "unicast" + peers: + - 10.1.254.1 + - 10.1.255.0 + - 10.1.255.2 + - 10.1.255.4 + - VerifyBGPExchangedRoutes: + bgp_peers: + - peer_address: 172.30.255.5 + vrf: default + advertised_routes: + - 192.0.254.5/32 + received_routes: + - 192.0.255.4/32 + - peer_address: 172.30.255.1 + vrf: default + advertised_routes: + - 192.0.255.1/32 + - 192.0.254.5/32 + received_routes: + - 192.0.254.3/32 + - VerifyBGPPeerMPCaps: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + capabilities: + - ipv4Unicast + - VerifyBGPPeerASNCap: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + - VerifyBGPPeerRouteRefreshCap: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + - VerifyBGPPeerMD5Auth: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + - peer_address: 172.30.11.5 + vrf: default + - VerifyEVPNType2Route: + vxlan_endpoints: + - address: 192.168.20.102 + vni: 10020 + - address: aac1.ab5d.b41e + vni: 10010 + - VerifyBGPAdvCommunities: + bgp_peers: + - peer_address: 172.30.11.17 + vrf: default + - peer_address: 172.30.11.21 + vrf: default + - VerifyBGPTimers: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + hold_time: 180 + keep_alive_time: 60 + - peer_address: 172.30.11.5 + vrf: default + hold_time: 180 + keep_alive_time: 60 + ospf: + - VerifyOSPFNeighborState: + - VerifyOSPFNeighborCount: + number: 3 diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..9dbc60f --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,214 @@ +# Project information +site_name: Arista Network Test Automation - ANTA +site_author: Khelil Sator +site_description: Arista Network Test Automation +copyright: Copyright © 2019 - 2023 Arista Networks + +# Repository +repo_name: ANTA on Github +repo_url: https://github.com/arista-netdevops-community/anta + +# Configuration +use_directory_urls: true +theme: + name: material + features: + - navigation.instant + - navigation.top + - content.tabs.link + - content.code.copy + # - toc.integrate + - toc.follow + - navigation.indexes + - content.tabs.link + highlightjs: true + hljs_languages: + - yaml + - python + - shell + icon: + repo: fontawesome/brands/github + logo: fontawesome/solid/network-wired + favicon: imgs/favicon.ico + font: + code: Fira Mono + language: en + include_search_page: false + search_index_only: true + palette: + # Light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: white + accent: blue + toggle: + icon: material/weather-night + name: Switch to dark mode + # Dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: blue + toggle: + icon: material/weather-sunny + name: Switch to light mode + custom_dir: docs/overrides + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/arista-netdevops-community/anta + - icon: fontawesome/brands/docker + link: https://github.com/arista-netdevops-community/anta/pkgs/container/anta + - icon: fontawesome/brands/python + link: https://pypi.org/project/anta/ + version: + provider: mike + default: + - stable + +extra_css: + - stylesheets/extra.material.css + +extra_javascript: + - https://cdnjs.cloudflare.com/ajax/libs/tablesort/5.2.1/tablesort.min.js + - https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.2/highlight.min.js + +watch: + - docs + # Watch src/ directory to reload on changes to docstrings for mkdocstrings plugin. + - anta + +plugins: + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [anta] + import: + - https://docs.python.org/3/objects.inv + - https://mkdocstrings.github.io/objects.inv + - https://mkdocstrings.github.io/griffe/objects.inv + options: + docstring_options: + ignore_init_summary: true + docstring_section_style: table + heading_level: 2 + inherited_members: false + merge_init_into_class: true + separate_signature: true + show_root_heading: true + show_root_full_path: false + show_signature_annotations: true + # sadly symbols are for insiders only + # https://mkdocstrings.github.io/python/usage/configuration/headings/#show_symbol_type_toc + # show_symbol_type_heading: true + # show_symbol_type_toc: true + # default filters here + filters: ["!^_[^_]"] + - search: + lang: en + - git-revision-date-localized: + type: date + - mike: + +markdown_extensions: + - attr_list + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg + - smarty + - pymdownx.arithmatex + - pymdownx.betterem: + smart_enable: all + - pymdownx.caret + - pymdownx.critic + - pymdownx.details + - pymdownx.inlinehilite + - pymdownx.magiclink + - pymdownx.mark + - pymdownx.smartsymbols + - pymdownx.superfences + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde + # - fontawesome_markdown + - admonition + - codehilite: + guess_lang: true + - toc: + separator: "-" + # permalink: "#" + permalink: true + baselevel: 3 + - pymdownx.highlight + - pymdownx.snippets: + base_path: docs/snippets + - pymdownx.superfences + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + +# TOC +docs_dir: docs/ +nav: + - Home: README.md + - Getting Started: getting-started.md + - Installation: requirements-and-installation.md + - Inventory & Tests catalog: usage-inventory-catalog.md + - Anta CLI: + - Overview: cli/overview.md + - NRFU: cli/nrfu.md + - Execute commands: cli/exec.md + - 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: + - Caching in ANTA: advanced_usages/caching.md + - Developing ANTA tests: advanced_usages/custom-tests.md + - ANTA as a Python Library: advanced_usages/as-python-lib.md + - Test Catalog Documentation: + - Overview: api/tests.md + - AAA: api/tests.aaa.md + - BFD: api/tests.bfd.md + - Configuration: api/tests.configuration.md + - Connectivity: api/tests.connectivity.md + - Field Notices: api/tests.field_notices.md + - Hardware: api/tests.hardware.md + - Interfaces: api/tests.interfaces.md + - Logging: api/tests.logging.md + - MLAG: api/tests.mlag.md + - Multicast: api/tests.multicast.md + - Profiles: api/tests.profiles.md + - Routing: + - Generic: api/tests.routing.generic.md + - BGP: api/tests.routing.bgp.md + - OSPF: api/tests.routing.ospf.md + - Security: api/tests.security.md + - Services: api/tests.services.md + - SNMP: api/tests.snmp.md + - STP: api/tests.stp.md + - Software: api/tests.software.md + - System: api/tests.system.md + - VXLAN: api/tests.vxlan.md + - VLAN: api/tests.vlan.md + - API Documentation: + - Inventory: + - Inventory module: api/inventory.md + - Inventory models: api/inventory.models.input.md + - Test Catalog: api/catalog.md + - Device: api/device.md + - Test: + - Test models: api/models.md + - Input Types: api/types.md + - Result Manager: + - Result Manager module: api/result_manager.md + - Result Manager models: api/result_manager_models.md + - Report Manager: + - Report Manager module: api/report_manager.md + - Report Manager models: api/report_manager_models.md + - Contributions: contribution.md + - FAQ: faq.md diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..76cda50 --- /dev/null +++ b/pylintrc @@ -0,0 +1,32 @@ +[MESSAGES CONTROL] +disable= + invalid-name, + logging-fstring-interpolation, + fixme + +[BASIC] +good-names=runCmds, i, y, t, c, x, e, fd, ip, v + +[DESIGN] +max-statements=61 +max-returns=8 +max-locals=23 +max-args=6 + +[FORMAT] +max-line-length=165 +max-module-lines=1700 + +[SIMILARITIES] +# making similarity lines limit a bit higher than default 4 +min-similarity-lines=10 + +[TYPECHECK] +# https://stackoverflow.com/questions/49680191/click-and-pylint +signature-mutators=click.decorators.option + +[MAIN] +load-plugins=pylint_pydantic +extension-pkg-whitelist=pydantic +ignore-paths = ^tests/units/anta_tests/.*/data.py$, + ^tests/units/anta_tests/routing/.*/data.py$, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9a23db3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,340 @@ +# content of pyproject.toml +[build-system] +requires = ["setuptools>=64.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "anta" +version = "v0.13.0" +readme = "docs/README.md" +authors = [{ name = "Khelil Sator", email = "ksator@arista.com" }] +maintainers = [ + { name = "Khelil Sator", email = "ksator@arista.com" }, + { name = "Matthieu Tâche", email = "mtache@arista.com" }, + { name = "Thomas Grimonet", email = "tgrimonet@arista.com" }, + { name = "Guillaume Mulocher", email = "gmulocher@arista.com" }, +] +description = "Arista Network Test Automation (ANTA) Framework" +license = { file = "LICENSE" } +dependencies = [ + "aiocache~=0.12.2", + "aio-eapi==0.6.3", + "click~=8.1.6", + "click-help-colors~=0.9", + "cvprac~=1.3.1", + "pydantic>=2.1.1,<2.7.0", + "pydantic-extra-types>=2.1.0", + "PyYAML~=6.0", + "requests~=2.31.0", + "rich>=13.5.2,<13.8.0", + "rich>=13.5.2,<13.8.0", + "asyncssh>=2.13.2,<2.15.0", + "Jinja2~=3.1.2", +] +keywords = ["test", "anta", "Arista", "network", "automation", "networking", "devops", "netdevops"] +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Intended Audience :: Information Technology", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: Testing", + "Topic :: System :: Networking", +] +requires-python = ">=3.8" + +[project.optional-dependencies] +dev = [ + "bumpver==2023.1129", + "black==24.2.0", + "flake8==7.0.0", + "isort==5.13.2", + "mypy~=1.7", + "mypy-extensions~=1.0", + "pre-commit>=3.3.3", + "pylint>=2.17.5", + "ruff>=0.0.280", + "pytest>=7.4.0", + "pytest-asyncio>=0.21.1", + "pytest-cov>=4.1.0", + "pytest-dependency", + "pytest-html>=3.2.0", + "pytest-metadata>=3.0.0", + "pylint-pydantic>=0.2.4", + "tox>=4.10.0,<5.0.0", + "types-PyYAML", + "types-paramiko", + "types-requests", + "typing-extensions", + "yamllint>=1.32.0", +] +doc = [ + "mkdocs>=1.3.1", + "mkdocs-autorefs>=0.4.1", + "mkdocs-bootswatch>=1.1", + "mkdocs-git-revision-date-localized-plugin>=1.1.0", + "mkdocs-git-revision-date-plugin>=0.3.2", + "mkdocs-material>=8.3.9", + "mkdocs-material-extensions>=1.0.3", + "mkdocstrings[python]>=0.20.0", + "fontawesome_markdown", + "mike==2.0.0", + "griffe==0.41.1" +] + +[project.urls] +Homepage = "https://www.anta.ninja" +"Bug Tracker" = "https://github.com/arista-netdevops-community/anta/issues" +Contributing = "https://www.anta.ninja/main/contribution/" + +[project.scripts] +anta = "anta.cli:cli" + + +################################ +# Tools +################################ +[tool.setuptools.packages.find] +include = ["anta*"] +namespaces = false + +################################ +# Version +################################ +[tool.bumpver] +current_version = "0.13.0" +version_pattern = "MAJOR.MINOR.PATCH" +commit_message = "bump: Version {old_version} -> {new_version}" +commit = true +# No tag +tag = false +push = false + +[tool.bumpver.file_patterns] +"pyproject.toml" = ['current_version = "{version}"', 'version = "v{version}"'] +"docs/contribution.md" = ["anta {version}"] +"docs/requirements-and-installation.md " = ["anta, version v{version}"] + +################################ +# Linting +################################ +[tool.isort] +profile = "black" +line_length = 165 + +[tool.black] +line-length = 165 +force-exclude = """ +( +.*data.py| +) +""" + +################################ +# Typing +# mypy as per https://pydantic-docs.helpmanual.io/mypy_plugin/#enabling-the-plugin +################################ +[tool.mypy] +plugins = [ + "pydantic.mypy", + ] +# Comment below for better type checking +#follow_imports = "skip" +ignore_missing_imports = true +warn_redundant_casts = true +# Note: tox find some unused type ignore which are required for pre-commit +# To investigate +warn_unused_ignores = true +disallow_any_generics = true +check_untyped_defs = true +no_implicit_reexport = true +strict_optional = true + +# for strict mypy: (this is the tricky one :-)) +disallow_untyped_defs = true + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +################################ +# Testing +################################ +[tool.pytest.ini_options] +# 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 = "WARNING" +log_cli = true +render_collapsed = true +filterwarnings = [ + "ignore::urllib3.exceptions.InsecureRequestWarning" +] +testpaths = ["tests"] + +[tool.coverage.run] +branch = true +source = ["anta"] +parallel = true +omit = [ + # omit aioeapi patch + "anta/aioeapi.py", + ] + +[tool.coverage.report] +# Regexes for lines to exclude from consideration +exclude_lines = [ + # Have to re-enable the standard pragma + "pragma: no cover", + + # Don't complain about missing debug-only code: + "def __repr__", + "if self\\.debug", + + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod", + + # Don't complain about TYPE_CHECKING blocks + "if TYPE_CHECKING:", +] + +ignore_errors = true + +[tool.coverage.html] +directory = "coverage_html_report" + +[tool.coverage.xml] +output = ".coverage.xml" + +################################ +# Tox +################################ +[tool.tox] +legacy_tox_ini = """ +[tox] +min_version = 4.0 +envlist = + clean, + lint, + type, + py{38,39,310,311,312}, + report + +[gh-actions] +python = + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: erase, py311, report + 3.12: py312 + +[testenv] +description = Run pytest with {basepython} +extras = dev +# posargs allows to run only a specific test using +# tox -e <env> -- path/to/my/test::test +commands = + pytest {posargs} + +[testenv:lint] +description = Check the code style +commands = + black --check --diff --color . + isort --check --diff --color . + flake8 --max-line-length=165 --config=/dev/null anta tests + pylint anta + pylint tests + +[testenv:type] +description = Check typing +commands = + mypy --config-file=pyproject.toml anta + mypy --config-file=pyproject.toml tests + +[testenv:clean] +description = Erase previous coverage reports +deps = coverage[toml] +skip_install = true +commands = coverage erase + +[testenv:report] +description = Generate coverage report +deps = coverage[toml] +commands = + coverage --version + coverage html --rcfile=pyproject.toml + coverage xml --rcfile=pyproject.toml +# add the following to make the report fail under some percentage +# commands = coverage report --fail-under=80 +depends = py311 +""" + +# TRYING RUFF - NOT ENABLED IN CI NOR PRE-COMMIT +[tool.ruff] +# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. +# select = ["E", "F"] +# select all cause we like being suffering +select = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] +ignore = [] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", +] + +# Same as Black. +line-length = 165 + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +# Assume Python 3.8 as this is the lowest supported version for ANTA +target-version = "py38" + +[tool.ruff.mccabe] +# Unlike Flake8, default to a complexity level of 10. +max-complexity = 10 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5a40c24 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,55 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +conftest.py - used to store anta specific fixtures used for tests +""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import pytest + +if TYPE_CHECKING: + from pytest import Metafunc + +# Load fixtures from dedicated file tests/lib/fixture.py +# As well as pytest_asyncio plugin to test co-routines +pytest_plugins = [ + "tests.lib.fixture", + "pytest_asyncio", +] + +# Enable nice assert messages +# https://docs.pytest.org/en/7.1.x/how-to/writing_plugins.html#assertion-rewriting +pytest.register_assert_rewrite("tests.lib.anta") + +# Placeholder to disable logging of some external libs +for _ in ("asyncio", "httpx"): + logging.getLogger(_).setLevel(logging.CRITICAL) + + +def build_test_id(val: dict[str, Any]) -> str: + """ + build id for a unit test of an AntaTest subclass + + { + "name": "meaniful test name", + "test": <AntaTest instance>, + ... + } + """ + return f"{val['test'].__module__}.{val['test'].__name__}-{val['name']}" + + +def pytest_generate_tests(metafunc: Metafunc) -> None: + """ + This function is called during test collection. + It will parametrize test cases based on the `DATA` data structure defined in `tests.units.anta_tests` modules. + See `tests/units/anta_tests/README.md` for more information on how to use it. + Test IDs are generated using the `build_test_id` function above. + """ + if "tests.units.anta_tests" in metafunc.module.__package__: + # This is a unit test for an AntaTest subclass + metafunc.parametrize("data", metafunc.module.DATA, ids=build_test_id) diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/data/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/data/ansible_inventory.yml b/tests/data/ansible_inventory.yml new file mode 100644 index 0000000..a958505 --- /dev/null +++ b/tests/data/ansible_inventory.yml @@ -0,0 +1,47 @@ +--- +all: + children: + cv_servers: + hosts: + cv_atd1: + ansible_host: 10.73.1.238 + ansible_user: tom + ansible_password: arista123 + cv_collection: v3 + ATD_LAB: + vars: + ansible_user: arista + ansible_ssh_pass: arista + children: + ATD_FABRIC: + children: + ATD_SPINES: + vars: + type: spine + hosts: + spine1: + ansible_host: 192.168.0.10 + spine2: + ansible_host: 192.168.0.11 + ATD_LEAFS: + vars: + type: l3leaf + children: + pod1: + hosts: + leaf1: + ansible_host: 192.168.0.12 + leaf2: + ansible_host: 192.168.0.13 + pod2: + hosts: + leaf3: + ansible_host: 192.168.0.14 + leaf4: + ansible_host: 192.168.0.15 + ATD_TENANTS_NETWORKS: + children: + ATD_LEAFS: + ATD_SERVERS: + children: + ATD_LEAFS: diff --git a/tests/data/empty b/tests/data/empty new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/data/empty diff --git a/tests/data/empty_ansible_inventory.yml b/tests/data/empty_ansible_inventory.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/tests/data/empty_ansible_inventory.yml @@ -0,0 +1 @@ +--- diff --git a/tests/data/expected_anta_inventory.yml b/tests/data/expected_anta_inventory.yml new file mode 100644 index 0000000..c0f92cb --- /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/json_data.py b/tests/data/json_data.py new file mode 100644 index 0000000..ad2c9ed --- /dev/null +++ b/tests/data/json_data.py @@ -0,0 +1,258 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# pylint: skip-file + +INVENTORY_MODEL_HOST_VALID = [ + {"name": "validIPv4", "input": "1.1.1.1", "expected_result": "valid"}, + { + "name": "validIPv6", + "input": "fe80::cc62:a9ff:feef:932a", + }, +] + +INVENTORY_MODEL_HOST_INVALID = [ + { + "name": "invalidIPv4_with_netmask", + "input": "1.1.1.1/32", + }, + { + "name": "invalidIPv6_with_netmask", + "input": "fe80::cc62:a9ff:feef:932a/128", + }, + {"name": "invalidHost_format", "input": "@", "expected_result": "invalid"}, + { + "name": "invalidIPv6_format", + "input": "fe80::cc62:a9ff:feef:", + }, +] + +INVENTORY_MODEL_HOST_CACHE = [ + {"name": "Host cache default", "input": {"host": "1.1.1.1"}, "expected_result": False}, + {"name": "Host cache enabled", "input": {"host": "1.1.1.1", "disable_cache": False}, "expected_result": False}, + {"name": "Host cache disabled", "input": {"host": "1.1.1.1", "disable_cache": True}, "expected_result": True}, +] + +INVENTORY_MODEL_NETWORK_VALID = [ + {"name": "ValidIPv4_Subnet", "input": "1.1.1.0/24", "expected_result": "valid"}, + {"name": "ValidIPv6_Subnet", "input": "2001:db8::/32", "expected_result": "valid"}, +] + +INVENTORY_MODEL_NETWORK_INVALID = [ + {"name": "ValidIPv4_Subnet", "input": "1.1.1.0/17", "expected_result": "invalid"}, + { + "name": "InvalidIPv6_Subnet", + "input": "2001:db8::/16", + "expected_result": "invalid", + }, +] + +INVENTORY_MODEL_NETWORK_CACHE = [ + {"name": "Network cache default", "input": {"network": "1.1.1.0/24"}, "expected_result": False}, + {"name": "Network cache enabled", "input": {"network": "1.1.1.0/24", "disable_cache": False}, "expected_result": False}, + {"name": "Network cache disabled", "input": {"network": "1.1.1.0/24", "disable_cache": True}, "expected_result": True}, +] + +INVENTORY_MODEL_RANGE_VALID = [ + { + "name": "ValidIPv4_Range", + "input": {"start": "10.1.0.1", "end": "10.1.0.10"}, + "expected_result": "valid", + }, +] + +INVENTORY_MODEL_RANGE_INVALID = [ + { + "name": "InvalidIPv4_Range_name", + "input": {"start": "toto", "end": "10.1.0.1"}, + "expected_result": "invalid", + }, +] + +INVENTORY_MODEL_RANGE_CACHE = [ + {"name": "Range cache default", "input": {"start": "1.1.1.1", "end": "1.1.1.10"}, "expected_result": False}, + {"name": "Range cache enabled", "input": {"start": "1.1.1.1", "end": "1.1.1.10", "disable_cache": False}, "expected_result": False}, + {"name": "Range cache disabled", "input": {"start": "1.1.1.1", "end": "1.1.1.10", "disable_cache": True}, "expected_result": True}, +] + +INVENTORY_MODEL_VALID = [ + { + "name": "Valid_Host_Only", + "input": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2"}]}, + "expected_result": "valid", + }, + { + "name": "Valid_Networks_Only", + "input": {"networks": [{"network": "192.168.0.0/16"}, {"network": "192.168.1.0/24"}]}, + "expected_result": "valid", + }, + { + "name": "Valid_Ranges_Only", + "input": { + "ranges": [ + {"start": "10.1.0.1", "end": "10.1.0.10"}, + {"start": "10.2.0.1", "end": "10.2.1.10"}, + ] + }, + "expected_result": "valid", + }, +] + +INVENTORY_MODEL_INVALID = [ + { + "name": "Host_with_Invalid_entry", + "input": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2/32"}]}, + "expected_result": "invalid", + }, +] + +INVENTORY_DEVICE_MODEL_VALID = [ + { + "name": "Valid_Inventory", + "input": [{"host": "1.1.1.1", "username": "arista", "password": "arista123!"}, {"host": "1.1.1.2", "username": "arista", "password": "arista123!"}], + "expected_result": "valid", + }, +] + +INVENTORY_DEVICE_MODEL_INVALID = [ + { + "name": "Invalid_Inventory", + "input": [{"host": "1.1.1.1", "password": "arista123!"}, {"host": "1.1.1.1", "username": "arista"}], + "expected_result": "invalid", + }, +] + +ANTA_INVENTORY_TESTS_VALID = [ + { + "name": "ValidInventory_with_host_only", + "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2"}, {"host": "my.awesome.host.com"}]}}, + "expected_result": "valid", + "parameters": { + "ipaddress_in_scope": "192.168.0.17", + "ipaddress_out_of_scope": "192.168.1.1", + "nb_hosts": 2, + }, + }, + { + "name": "ValidInventory_with_networks_only", + "input": {"anta_inventory": {"networks": [{"network": "192.168.0.0/24"}]}}, + "expected_result": "valid", + "parameters": { + "ipaddress_in_scope": "192.168.0.1", + "ipaddress_out_of_scope": "192.168.1.1", + "nb_hosts": 256, + }, + }, + { + "name": "ValidInventory_with_ranges_only", + "input": { + "anta_inventory": { + "ranges": [ + {"start": "10.0.0.1", "end": "10.0.0.11"}, + {"start": "10.0.0.101", "end": "10.0.0.111"}, + ] + } + }, + "expected_result": "valid", + "parameters": { + "ipaddress_in_scope": "10.0.0.10", + "ipaddress_out_of_scope": "192.168.1.1", + "nb_hosts": 22, + }, + }, + { + "name": "ValidInventory_with_host_port", + "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17", "port": 443}, {"host": "192.168.0.2", "port": 80}]}}, + "expected_result": "valid", + "parameters": { + "ipaddress_in_scope": "192.168.0.17", + "ipaddress_out_of_scope": "192.168.1.1", + "nb_hosts": 2, + }, + }, + { + "name": "ValidInventory_with_host_tags", + "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17", "tags": ["leaf"]}, {"host": "192.168.0.2", "tags": ["spine"]}]}}, + "expected_result": "valid", + "parameters": { + "ipaddress_in_scope": "192.168.0.17", + "ipaddress_out_of_scope": "192.168.1.1", + "nb_hosts": 2, + }, + }, + { + "name": "ValidInventory_with_networks_tags", + "input": {"anta_inventory": {"networks": [{"network": "192.168.0.0/24", "tags": ["leaf"]}]}}, + "expected_result": "valid", + "parameters": { + "ipaddress_in_scope": "192.168.0.1", + "ipaddress_out_of_scope": "192.168.1.1", + "nb_hosts": 256, + }, + }, + { + "name": "ValidInventory_with_ranges_tags", + "input": { + "anta_inventory": { + "ranges": [ + {"start": "10.0.0.1", "end": "10.0.0.11", "tags": ["leaf"]}, + {"start": "10.0.0.101", "end": "10.0.0.111", "tags": ["spine"]}, + ] + } + }, + "expected_result": "valid", + "parameters": { + "ipaddress_in_scope": "10.0.0.10", + "ipaddress_out_of_scope": "192.168.1.1", + "nb_hosts": 22, + }, + }, +] + +ANTA_INVENTORY_TESTS_INVALID = [ + { + "name": "InvalidInventory_with_host_only", + "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17/32"}, {"host": "192.168.0.2"}]}}, + "expected_result": "invalid", + }, + { + "name": "InvalidInventory_wrong_network_bits", + "input": {"anta_inventory": {"networks": [{"network": "192.168.42.0/8"}]}}, + "expected_result": "invalid", + }, + { + "name": "InvalidInventory_wrong_network", + "input": {"anta_inventory": {"networks": [{"network": "toto"}]}}, + "expected_result": "invalid", + }, + { + "name": "InvalidInventory_wrong_range", + "input": {"anta_inventory": {"ranges": [{"start": "toto", "end": "192.168.42.42"}]}}, + "expected_result": "invalid", + }, + { + "name": "InvalidInventory_wrong_range_type_mismatch", + "input": {"anta_inventory": {"ranges": [{"start": "fe80::cafe", "end": "192.168.42.42"}]}}, + "expected_result": "invalid", + }, + { + "name": "Invalid_Root_Key", + "input": { + "inventory": { + "ranges": [ + {"start": "10.0.0.1", "end": "10.0.0.11"}, + {"start": "10.0.0.100", "end": "10.0.0.111"}, + ] + } + }, + "expected_result": "invalid", + }, +] + +TEST_RESULT_SET_STATUS = [ + {"name": "set_success", "target": "success", "message": "success"}, + {"name": "set_error", "target": "error", "message": "error"}, + {"name": "set_failure", "target": "failure", "message": "failure"}, + {"name": "set_skipped", "target": "skipped", "message": "skipped"}, + {"name": "set_unset", "target": "unset", "message": "unset"}, +] diff --git a/tests/data/syntax_error.py b/tests/data/syntax_error.py new file mode 100644 index 0000000..051ef33 --- /dev/null +++ b/tests/data/syntax_error.py @@ -0,0 +1,7 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +# pylint: skip-file +# flake8: noqa +# type: ignore +typo diff --git a/tests/data/template.j2 b/tests/data/template.j2 new file mode 100644 index 0000000..e8820fe --- /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 new file mode 100644 index 0000000..c5b55b0 --- /dev/null +++ b/tests/data/test_catalog.yml @@ -0,0 +1,5 @@ +--- +anta.tests.software: + - VerifyEOSVersion: + versions: + - 4.31.1F diff --git a/tests/data/test_catalog_not_a_list.yml b/tests/data/test_catalog_not_a_list.yml new file mode 100644 index 0000000..d8c4297 --- /dev/null +++ b/tests/data/test_catalog_not_a_list.yml @@ -0,0 +1,2 @@ +--- +anta.tests.configuration: true diff --git a/tests/data/test_catalog_test_definition_multiple_dicts.yml b/tests/data/test_catalog_test_definition_multiple_dicts.yml new file mode 100644 index 0000000..9287ee6 --- /dev/null +++ b/tests/data/test_catalog_test_definition_multiple_dicts.yml @@ -0,0 +1,9 @@ +--- +anta.tests.software: + - VerifyEOSVersion: + versions: + - 4.25.4M + - 4.26.1F + VerifyTerminAttrVersion: + versions: + - 4.25.4M diff --git a/tests/data/test_catalog_test_definition_not_a_dict.yml b/tests/data/test_catalog_test_definition_not_a_dict.yml new file mode 100644 index 0000000..052ad26 --- /dev/null +++ b/tests/data/test_catalog_test_definition_not_a_dict.yml @@ -0,0 +1,3 @@ +--- +anta.tests.software: + - VerifyEOSVersion diff --git a/tests/data/test_catalog_with_syntax_error_module.yml b/tests/data/test_catalog_with_syntax_error_module.yml new file mode 100644 index 0000000..8b3e00a --- /dev/null +++ b/tests/data/test_catalog_with_syntax_error_module.yml @@ -0,0 +1,2 @@ +--- +tests.data.syntax_error: diff --git a/tests/data/test_catalog_with_tags.yml b/tests/data/test_catalog_with_tags.yml new file mode 100644 index 0000000..0c8f5f6 --- /dev/null +++ b/tests/data/test_catalog_with_tags.yml @@ -0,0 +1,28 @@ +--- +anta.tests.system: + - VerifyUptime: + minimum: 10 + filters: + tags: ['fabric'] + - VerifyReloadCause: + filters: + tags: ['leaf', 'spine'] + - VerifyCoredump: + - VerifyAgentLogs: + - VerifyCPUUtilization: + filters: + tags: ['leaf'] + - VerifyMemoryUtilization: + filters: + tags: ['testdevice'] + - VerifyFileSystemUtilization: + - VerifyNTP: + +anta.tests.mlag: + - VerifyMlagStatus: + +anta.tests.interfaces: + - VerifyL3MTU: + mtu: 1500 + filters: + tags: ['demo'] diff --git a/tests/data/test_catalog_with_undefined_module.yml b/tests/data/test_catalog_with_undefined_module.yml new file mode 100644 index 0000000..f2e116b --- /dev/null +++ b/tests/data/test_catalog_with_undefined_module.yml @@ -0,0 +1,3 @@ +--- +anta.tests.undefined: + - MyTest: diff --git a/tests/data/test_catalog_with_undefined_module_nested.yml b/tests/data/test_catalog_with_undefined_module_nested.yml new file mode 100644 index 0000000..cf0f393 --- /dev/null +++ b/tests/data/test_catalog_with_undefined_module_nested.yml @@ -0,0 +1,4 @@ +--- +anta.tests: + undefined: + - MyTest: diff --git a/tests/data/test_catalog_with_undefined_tests.yml b/tests/data/test_catalog_with_undefined_tests.yml new file mode 100644 index 0000000..8282714 --- /dev/null +++ b/tests/data/test_catalog_with_undefined_tests.yml @@ -0,0 +1,3 @@ +--- +anta.tests.software: + - FakeTest: diff --git a/tests/data/test_catalog_wrong_type.yml b/tests/data/test_catalog_wrong_type.yml new file mode 100644 index 0000000..3f8f043 --- /dev/null +++ b/tests/data/test_catalog_wrong_type.yml @@ -0,0 +1 @@ +"Not a string" diff --git a/tests/data/test_empty_catalog.yml b/tests/data/test_empty_catalog.yml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/data/test_empty_catalog.yml diff --git a/tests/data/test_inventory.yml b/tests/data/test_inventory.yml new file mode 100644 index 0000000..d0ca457 --- /dev/null +++ b/tests/data/test_inventory.yml @@ -0,0 +1,12 @@ +--- +anta_inventory: + hosts: + - name: dummy + host: dummy.anta.ninja + tags: ["leaf"] + - name: dummy2 + host: dummy2.anta.ninja + tags: ["leaf"] + - name: dummy3 + host: dummy3.anta.ninja + tags: ["spine"] diff --git a/tests/data/test_snapshot_commands.yml b/tests/data/test_snapshot_commands.yml new file mode 100644 index 0000000..d2ee8dc --- /dev/null +++ b/tests/data/test_snapshot_commands.yml @@ -0,0 +1,8 @@ +--- +# list of EOS commands to collect in JSON format +json_format: + - show version + +# list of EOS commands to collect in text format +text_format: + - show version diff --git a/tests/data/toto.yml b/tests/data/toto.yml new file mode 100644 index 0000000..c0f92cb --- /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/__init__.py b/tests/lib/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/lib/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/lib/anta.py b/tests/lib/anta.py new file mode 100644 index 0000000..b97d91d --- /dev/null +++ b/tests/lib/anta.py @@ -0,0 +1,34 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +generic test funciton used to generate unit tests for each AntaTest +""" +from __future__ import annotations + +import asyncio +from typing import Any + +from anta.device import AntaDevice + + +def test(device: AntaDevice, data: dict[str, Any]) -> None: + """ + Generic test function for AntaTest subclass. + See `tests/units/anta_tests/README.md` for more information on how to use it. + """ + # Instantiate the AntaTest subclass + test_instance = data["test"](device, inputs=data["inputs"], eos_data=data["eos_data"]) + # Run the test() method + asyncio.run(test_instance.test()) + # Assert expected result + assert test_instance.result.result == data["expected"]["result"], test_instance.result.messages + if "messages" in data["expected"]: + # We expect messages in test result + assert len(test_instance.result.messages) == len(data["expected"]["messages"]) + # Test will pass if the expected message is included in the test result message + for message, expected in zip(test_instance.result.messages, data["expected"]["messages"]): # NOTE: zip(strict=True) has been added in Python 3.10 + assert expected in message + else: + # Test result should not have messages + assert test_instance.result.messages == [] diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py new file mode 100644 index 0000000..68e9e57 --- /dev/null +++ b/tests/lib/fixture.py @@ -0,0 +1,242 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Fixture for Anta Testing""" +from __future__ import annotations + +import 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, 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 +from anta.result_manager import ResultManager +from anta.result_manager.models import TestResult +from tests.lib.utils import default_anta_env + +logger = logging.getLogger(__name__) + +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]: + """ + Returns an AntaDevice instance with mocked abstract method + """ + + def _collect(command: AntaCommand) -> None: + command.output = COMMAND_OUTPUT + + kwargs = {"name": DEVICE_NAME, "hw_model": DEVICE_HW_MODEL} + + if hasattr(request, "param"): + # Fixture is parametrized indirectly + kwargs.update(request.param) + with patch.object(AntaDevice, "__abstractmethods__", set()): + with patch("anta.device.AntaDevice._collect", side_effect=_collect): + # AntaDevice constructor does not have hw_model argument + hw_model = kwargs.pop("hw_model") + dev = AntaDevice(**kwargs) # type: ignore[abstract, arg-type] # pylint: disable=abstract-class-instantiated, unexpected-keyword-arg + dev.hw_model = hw_model + yield dev + + +@pytest.fixture +def test_inventory() -> AntaInventory: + """ + Return the test_inventory + """ + env = default_anta_env() + assert env["ANTA_INVENTORY"] 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 + """ + + kwargs = {"name": DEVICE_NAME, "host": "42.42.42.42", "username": "anta", "password": "anta"} + + if hasattr(request, "param"): + # Fixture is parametrized indirectly + kwargs.update(request.param) + dev = AsyncEOSDevice(**kwargs) # type: ignore[arg-type] + return dev + + +# tests.units.result_manager fixtures +@pytest.fixture +def test_result_factory(device: AntaDevice) -> Callable[[int], TestResult]: + """ + Return a anta.result_manager.models.TestResult object + """ + + # pylint: disable=redefined-outer-name + + def _create(index: int = 0) -> TestResult: + """ + Actual Factory + """ + return TestResult( + name=device.name, + test=f"VerifyTest{index}", + categories=["test"], + description=f"Verifies Test {index}", + custom_field=None, + ) + + return _create + + +@pytest.fixture +def list_result_factory(test_result_factory: Callable[[int], TestResult]) -> Callable[[int], list[TestResult]]: + """ + Return a list[TestResult] with 'size' TestResult instanciated using the test_result_factory fixture + """ + + # pylint: disable=redefined-outer-name + + def _factory(size: int = 0) -> list[TestResult]: + """ + Factory for list[TestResult] entry of size entries + """ + result: list[TestResult] = [] + for i in range(size): + result.append(test_result_factory(i)) + return result + + return _factory + + +@pytest.fixture +def result_manager_factory(list_result_factory: Callable[[int], list[TestResult]]) -> Callable[[int], ResultManager]: + """ + Return a ResultManager factory that takes as input a number of tests + """ + + # pylint: disable=redefined-outer-name + + def _factory(number: int = 0) -> ResultManager: + """ + Factory for list[TestResult] entry of size entries + """ + result_manager = ResultManager() + result_manager.add_test_results(list_result_factory(number)) + return result_manager + + return _factory + + +# tests.units.cli fixtures +@pytest.fixture +def temp_env(tmp_path: Path) -> dict[str, str | None]: + """Fixture that create a temporary ANTA inventory that can be overriden + and returns the corresponding environment variables""" + env = default_anta_env() + anta_inventory = str(env["ANTA_INVENTORY"]) + temp_inventory = tmp_path / "test_inventory.yml" + shutil.copy(anta_inventory, temp_inventory) + env["ANTA_INVENTORY"] = str(temp_inventory) + return env + + +@pytest.fixture +def click_runner(capsys: CaptureFixture[str]) -> Iterator[CliRunner]: + """ + Convenience fixture to return a click.CliRunner for cli testing + """ + + 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 new file mode 100644 index 0000000..460e014 --- /dev/null +++ b/tests/lib/utils.py @@ -0,0 +1,49 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +tests.lib.utils +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +def generate_test_ids_dict(val: dict[str, Any], key: str = "name") -> str: + """ + generate_test_ids Helper to generate test ID for parametrize + """ + return val.get(key, "unamed_test") + + +def generate_test_ids_list(val: list[dict[str, Any]], key: str = "name") -> list[str]: + """ + generate_test_ids Helper to generate test ID for parametrize + """ + return [entry[key] if key in entry.keys() else "unamed_test" for entry in val] + + +def generate_test_ids(data: list[dict[str, Any]]) -> list[str]: + """ + build id for a unit test of an AntaTest subclass + + { + "name": "meaniful test name", + "test": <AntaTest instance>, + ... + } + """ + return [f"{val['test'].__module__}.{val['test'].__name__}-{val['name']}" for val in data] + + +def default_anta_env() -> dict[str, str | None]: + """ + Return a default_anta_environement which can be passed to a cliRunner.invoke method + """ + return { + "ANTA_USERNAME": "anta", + "ANTA_PASSWORD": "formica", + "ANTA_INVENTORY": str(Path(__file__).parent.parent / "data" / "test_inventory.yml"), + "ANTA_CATALOG": str(Path(__file__).parent.parent / "data" / "test_catalog.yml"), + } diff --git a/tests/mock_data/show_ntp_status_text_synchronised.out b/tests/mock_data/show_ntp_status_text_synchronised.out new file mode 100644 index 0000000..081a8a8 --- /dev/null +++ b/tests/mock_data/show_ntp_status_text_synchronised.out @@ -0,0 +1 @@ +[{'output': 'synchronised to NTP server (51.254.83.231) at stratum 3\n time correct to within 82 ms\n polling server every 1024 s\n\n'}] diff --git a/tests/mock_data/show_uptime_json_1000000.out b/tests/mock_data/show_uptime_json_1000000.out new file mode 100644 index 0000000..754025a --- /dev/null +++ b/tests/mock_data/show_uptime_json_1000000.out @@ -0,0 +1 @@ +[{'upTime': 1000000.68, 'loadAvg': [0.17, 0.21, 0.18], 'users': 1, 'currentTime': 1643761588.030645}] diff --git a/tests/mock_data/show_version_json_4.27.1.1F.out b/tests/mock_data/show_version_json_4.27.1.1F.out new file mode 100644 index 0000000..fc720d4 --- /dev/null +++ b/tests/mock_data/show_version_json_4.27.1.1F.out @@ -0,0 +1 @@ +[{'imageFormatVersion': '2.0', 'uptime': 2697.76, 'modelName': 'DCS-7280TRA-48C6-F', 'internalVersion': '4.27.1.1F-25536724.42711F', 'memTotal': 8098984, 'mfgName': 'Arista', 'serialNumber': 'SSJ16376415', 'systemMacAddress': '44:4c:a8:c7:1f:6b', 'bootupTimestamp': 1643715179.0, 'memFree': 6131068, 'version': '4.27.1.1F', 'configMacAddress': '00:00:00:00:00:00', 'isIntlVersion': False, 'internalBuildId': '38c43eab-c660-477a-915b-5a7b28da781d', 'hardwareRevision': '21.02', 'hwMacAddress': '44:4c:a8:c7:1f:6b', 'architecture': 'i686'}] diff --git a/tests/units/__init__.py b/tests/units/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/anta_tests/README.md b/tests/units/anta_tests/README.md new file mode 100644 index 0000000..6e4c5f0 --- /dev/null +++ b/tests/units/anta_tests/README.md @@ -0,0 +1,7 @@ +<!-- + ~ Copyright (c) 2023-2024 Arista Networks, Inc. + ~ Use of this source code is governed by the Apache License 2.0 + ~ that can be found in the LICENSE file. + --> + +A guide explaining how to write the unit test can be found in the [contribution guide](../../../docs/contribution.md#unit-tests) diff --git a/tests/units/anta_tests/__init__.py b/tests/units/anta_tests/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/anta_tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/anta_tests/routing/__init__.py b/tests/units/anta_tests/routing/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/anta_tests/routing/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py new file mode 100644 index 0000000..799f058 --- /dev/null +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -0,0 +1,3385 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.routing.bgp.py +""" +# pylint: disable=C0302 +from __future__ import annotations + +from typing import Any + +# pylint: disable=C0413 +# because of the patch above +from anta.tests.routing.bgp import ( # noqa: E402 + VerifyBGPAdvCommunities, + VerifyBGPExchangedRoutes, + VerifyBGPPeerASNCap, + VerifyBGPPeerCount, + VerifyBGPPeerMD5Auth, + VerifyBGPPeerMPCaps, + VerifyBGPPeerRouteRefreshCap, + VerifyBGPPeersHealth, + VerifyBGPSpecificPeers, + VerifyBGPTimers, + VerifyEVPNType2Route, +) +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyBGPPeerCount, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.255.0": { + "description": "DC1-SPINE1_Ethernet1", + "version": 4, + "msgReceived": 0, + "msgSent": 0, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266295.098931, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.255.2": { + "description": "DC1-SPINE2_Ethernet1", + "version": 4, + "msgReceived": 3759, + "msgSent": 3757, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 14, + "prefixReceived": 14, + "upDownTime": 1694266296.367261, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + } + ], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 2}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-count", + "test": VerifyBGPPeerCount, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.255.0": { + "description": "DC1-SPINE1_Ethernet1", + "version": 4, + "msgReceived": 0, + "msgSent": 0, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266295.098931, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.255.2": { + "description": "DC1-SPINE2_Ethernet1", + "version": 4, + "msgReceived": 3759, + "msgSent": 3757, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 14, + "prefixReceived": 14, + "upDownTime": 1694266296.367261, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + } + ], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 3}]}, + "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': 'Expected: 3, Actual: 2'}}]"]}, + }, + { + "name": "failure-no-peers", + "test": VerifyBGPPeerCount, + "eos_data": [{"vrfs": {"default": {"vrf": "default", "routerId": "10.1.0.3", "asn": "65120", "peers": {}}}}], + "inputs": {"address_families": [{"afi": "ipv6", "safi": "unicast", "vrf": "default", "num_peers": 3}]}, + "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Expected: 3, Actual: 0'}}]"]}, + }, + { + "name": "failure-not-configured", + "test": VerifyBGPPeerCount, + "eos_data": [{"vrfs": {}}], + "inputs": {"address_families": [{"afi": "ipv6", "safi": "multicast", "vrf": "DEV", "num_peers": 3}]}, + "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv6', 'safi': 'multicast', 'vrfs': {'DEV': 'Not Configured'}}]"]}, + }, + { + "name": "success-vrf-all", + "test": VerifyBGPPeerCount, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.255.0": { + "description": "DC1-SPINE1_Ethernet1", + "version": 4, + "msgReceived": 0, + "msgSent": 0, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266295.098931, + "underMaintenance": False, + "peerState": "Established", + }, + }, + }, + "PROD": { + "vrf": "PROD", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.254.1": { + "description": "DC1-LEAF1B", + "version": 4, + "msgReceived": 3777, + "msgSent": 3764, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65120", + "prefixAccepted": 2, + "prefixReceived": 2, + "upDownTime": 1694266296.659891, + "underMaintenance": False, + "peerState": "Established", + }, + "192.168.1.11": { + "description": "K8S-CLUSTER1", + "version": 4, + "msgReceived": 6417, + "msgSent": 7546, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65000", + "prefixAccepted": 1, + "prefixReceived": 1, + "upDownTime": 1694266329.978035, + "underMaintenance": False, + "peerState": "Established", + }, + }, + }, + } + } + ], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all", "num_peers": 3}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-vrf-all", + "test": VerifyBGPPeerCount, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.255.0": { + "description": "DC1-SPINE1_Ethernet1", + "version": 4, + "msgReceived": 0, + "msgSent": 0, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266295.098931, + "underMaintenance": False, + "peerState": "Established", + }, + }, + }, + "PROD": { + "vrf": "PROD", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.254.1": { + "description": "DC1-LEAF1B", + "version": 4, + "msgReceived": 3777, + "msgSent": 3764, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65120", + "prefixAccepted": 2, + "prefixReceived": 2, + "upDownTime": 1694266296.659891, + "underMaintenance": False, + "peerState": "Established", + }, + "192.168.1.11": { + "description": "K8S-CLUSTER1", + "version": 4, + "msgReceived": 6417, + "msgSent": 7546, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65000", + "prefixAccepted": 1, + "prefixReceived": 1, + "upDownTime": 1694266329.978035, + "underMaintenance": False, + "peerState": "Established", + }, + }, + }, + } + } + ], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all", "num_peers": 5}]}, + "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'all': 'Expected: 5, Actual: 3'}}]"]}, + }, + { + "name": "success-multiple-afi", + "test": VerifyBGPPeerCount, + "eos_data": [ + { + "vrfs": { + "PROD": { + "vrf": "PROD", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.254.1": { + "description": "DC1-LEAF1B", + "version": 4, + "msgReceived": 3777, + "msgSent": 3764, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65120", + "prefixAccepted": 2, + "prefixReceived": 2, + "upDownTime": 1694266296.659891, + "underMaintenance": False, + "peerState": "Established", + }, + "192.168.1.11": { + "description": "K8S-CLUSTER1", + "version": 4, + "msgReceived": 6417, + "msgSent": 7546, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65000", + "prefixAccepted": 1, + "prefixReceived": 1, + "upDownTime": 1694266329.978035, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.0.1": { + "description": "DC1-SPINE1", + "version": 4, + "msgReceived": 3894, + "msgSent": 3897, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266296.584472, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.0.2": { + "description": "DC1-SPINE2", + "version": 4, + "msgReceived": 3893, + "msgSent": 3902, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266297.433896, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + }, + ], + "inputs": { + "address_families": [ + {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "num_peers": 2}, + {"afi": "evpn", "num_peers": 2}, + ] + }, + "expected": { + "result": "success", + }, + }, + { + "name": "failure-multiple-afi", + "test": VerifyBGPPeerCount, + "eos_data": [ + { + "vrfs": { + "PROD": { + "vrf": "PROD", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.254.1": { + "description": "DC1-LEAF1B", + "version": 4, + "msgReceived": 3777, + "msgSent": 3764, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65120", + "prefixAccepted": 2, + "prefixReceived": 2, + "upDownTime": 1694266296.659891, + "underMaintenance": False, + "peerState": "Established", + }, + "192.168.1.11": { + "description": "K8S-CLUSTER1", + "version": 4, + "msgReceived": 6417, + "msgSent": 7546, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65000", + "prefixAccepted": 1, + "prefixReceived": 1, + "upDownTime": 1694266329.978035, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + }, + {"vrfs": {}}, + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.0.1": { + "description": "DC1-SPINE1", + "version": 4, + "msgReceived": 3894, + "msgSent": 3897, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266296.584472, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.0.2": { + "description": "DC1-SPINE2", + "version": 4, + "msgReceived": 3893, + "msgSent": 3902, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266297.433896, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + }, + ], + "inputs": { + "address_families": [ + {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "num_peers": 3}, + {"afi": "evpn", "num_peers": 3}, + {"afi": "ipv6", "safi": "unicast", "vrf": "default", "num_peers": 3}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Expected: 3, Actual: 2'}}, " + "{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Not Configured'}}, " + "{'afi': 'evpn', 'vrfs': {'default': 'Expected: 3, Actual: 2'}}" + ], + }, + }, + { + "name": "success", + "test": VerifyBGPPeersHealth, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.255.0": { + "description": "DC1-SPINE1_Ethernet1", + "version": 4, + "msgReceived": 0, + "msgSent": 0, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266295.098931, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.255.2": { + "description": "DC1-SPINE2_Ethernet1", + "version": 4, + "msgReceived": 3759, + "msgSent": 3757, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 14, + "prefixReceived": 14, + "upDownTime": 1694266296.367261, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + } + ], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default"}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-issues", + "test": VerifyBGPPeersHealth, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.255.0": { + "description": "DC1-SPINE1_Ethernet1", + "version": 4, + "msgReceived": 0, + "msgSent": 0, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266295.098931, + "underMaintenance": False, + "peerState": "Idle", + }, + "10.1.255.2": { + "description": "DC1-SPINE2_Ethernet1", + "version": 4, + "msgReceived": 3759, + "msgSent": 3757, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 14, + "prefixReceived": 14, + "upDownTime": 1694266296.367261, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + } + ], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default"}]}, + "expected": { + "result": "failure", + "messages": [ + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}]" + ], + }, + }, + { + "name": "success-vrf-all", + "test": VerifyBGPPeersHealth, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.255.0": { + "description": "DC1-SPINE1_Ethernet1", + "version": 4, + "msgReceived": 0, + "msgSent": 0, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266295.098931, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.255.2": { + "description": "DC1-SPINE2_Ethernet1", + "version": 4, + "msgReceived": 3759, + "msgSent": 3757, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 14, + "prefixReceived": 14, + "upDownTime": 1694266296.367261, + "underMaintenance": False, + "peerState": "Established", + }, + }, + }, + "PROD": { + "vrf": "PROD", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.254.1": { + "description": "DC1-LEAF1B", + "version": 4, + "msgReceived": 3777, + "msgSent": 3764, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65120", + "prefixAccepted": 2, + "prefixReceived": 2, + "upDownTime": 1694266296.659891, + "underMaintenance": False, + "peerState": "Established", + }, + "192.168.1.11": { + "description": "K8S-CLUSTER1", + "version": 4, + "msgReceived": 6417, + "msgSent": 7546, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65000", + "prefixAccepted": 1, + "prefixReceived": 1, + "upDownTime": 1694266329.978035, + "underMaintenance": False, + "peerState": "Established", + }, + }, + }, + } + } + ], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all"}]}, + "expected": { + "result": "success", + }, + }, + { + "name": "failure-issues-vrf-all", + "test": VerifyBGPPeersHealth, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.255.0": { + "description": "DC1-SPINE1_Ethernet1", + "version": 4, + "msgReceived": 0, + "msgSent": 0, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266295.098931, + "underMaintenance": False, + "peerState": "Idle", + }, + "10.1.255.2": { + "description": "DC1-SPINE2_Ethernet1", + "version": 4, + "msgReceived": 3759, + "msgSent": 3757, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 14, + "prefixReceived": 14, + "upDownTime": 1694266296.367261, + "underMaintenance": False, + "peerState": "Established", + }, + }, + }, + "PROD": { + "vrf": "PROD", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.254.1": { + "description": "DC1-LEAF1B", + "version": 4, + "msgReceived": 3777, + "msgSent": 3764, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65120", + "prefixAccepted": 2, + "prefixReceived": 2, + "upDownTime": 1694266296.659891, + "underMaintenance": False, + "peerState": "Established", + }, + "192.168.1.11": { + "description": "K8S-CLUSTER1", + "version": 4, + "msgReceived": 6417, + "msgSent": 7546, + "inMsgQueue": 100, + "outMsgQueue": 200, + "asn": "65000", + "prefixAccepted": 1, + "prefixReceived": 1, + "upDownTime": 1694266329.978035, + "underMaintenance": False, + "peerState": "Established", + }, + }, + }, + } + } + ], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all"}]}, + "expected": { + "result": "failure", + "messages": [ + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}, " + "'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 100, 'outMsgQueue': 200}}}}]" + ], + }, + }, + { + "name": "failure-not-configured", + "test": VerifyBGPPeersHealth, + "eos_data": [{"vrfs": {}}], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "DEV"}]}, + "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'DEV': 'Not Configured'}}]"]}, + }, + { + "name": "failure-no-peers", + "test": VerifyBGPPeersHealth, + "eos_data": [{"vrfs": {"default": {"vrf": "default", "routerId": "10.1.0.3", "asn": "65120", "peers": {}}}}], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "multicast"}]}, + "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'default': 'No Peers'}}]"]}, + }, + { + "name": "success-multiple-afi", + "test": VerifyBGPPeersHealth, + "eos_data": [ + { + "vrfs": { + "PROD": { + "vrf": "PROD", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.254.1": { + "description": "DC1-LEAF1B", + "version": 4, + "msgReceived": 3777, + "msgSent": 3764, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65120", + "prefixAccepted": 2, + "prefixReceived": 2, + "upDownTime": 1694266296.659891, + "underMaintenance": False, + "peerState": "Established", + }, + "192.168.1.11": { + "description": "K8S-CLUSTER1", + "version": 4, + "msgReceived": 6417, + "msgSent": 7546, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65000", + "prefixAccepted": 1, + "prefixReceived": 1, + "upDownTime": 1694266329.978035, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.0.1": { + "description": "DC1-SPINE1", + "version": 4, + "msgReceived": 3894, + "msgSent": 3897, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266296.584472, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.0.2": { + "description": "DC1-SPINE2", + "version": 4, + "msgReceived": 3893, + "msgSent": 3902, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266297.433896, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + }, + ], + "inputs": { + "address_families": [ + {"afi": "ipv4", "safi": "unicast", "vrf": "PROD"}, + {"afi": "evpn"}, + ] + }, + "expected": { + "result": "success", + }, + }, + { + "name": "failure-multiple-afi", + "test": VerifyBGPPeersHealth, + "eos_data": [ + { + "vrfs": { + "PROD": { + "vrf": "PROD", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.254.1": { + "description": "DC1-LEAF1B", + "version": 4, + "msgReceived": 3777, + "msgSent": 3764, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65120", + "prefixAccepted": 2, + "prefixReceived": 2, + "upDownTime": 1694266296.659891, + "underMaintenance": False, + "peerState": "Established", + }, + "192.168.1.11": { + "description": "K8S-CLUSTER1", + "version": 4, + "msgReceived": 6417, + "msgSent": 7546, + "inMsgQueue": 10, + "outMsgQueue": 0, + "asn": "65000", + "prefixAccepted": 1, + "prefixReceived": 1, + "upDownTime": 1694266329.978035, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + }, + {"vrfs": {}}, + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.0.1": { + "description": "DC1-SPINE1", + "version": 4, + "msgReceived": 3894, + "msgSent": 3897, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266296.584472, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.0.2": { + "description": "DC1-SPINE2", + "version": 4, + "msgReceived": 3893, + "msgSent": 3902, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266297.433896, + "underMaintenance": False, + "peerState": "Idle", + }, + }, + } + } + }, + ], + "inputs": { + "address_families": [ + {"afi": "ipv4", "safi": "unicast", "vrf": "PROD"}, + {"afi": "evpn"}, + {"afi": "ipv6", "safi": "unicast", "vrf": "default"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': " + "{'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 10, 'outMsgQueue': 0}}}}, " + "{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Not Configured'}}, " + "{'afi': 'evpn', 'vrfs': {'default': {'10.1.0.2': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyBGPSpecificPeers, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.255.0": { + "description": "DC1-SPINE1_Ethernet1", + "version": 4, + "msgReceived": 0, + "msgSent": 0, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266295.098931, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.255.2": { + "description": "DC1-SPINE2_Ethernet1", + "version": 4, + "msgReceived": 3759, + "msgSent": 3757, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 14, + "prefixReceived": 14, + "upDownTime": 1694266296.367261, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + } + ], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "peers": ["10.1.255.0", "10.1.255.2"]}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-issues", + "test": VerifyBGPSpecificPeers, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.255.0": { + "description": "DC1-SPINE1_Ethernet1", + "version": 4, + "msgReceived": 0, + "msgSent": 0, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266295.098931, + "underMaintenance": False, + "peerState": "Idle", + }, + "10.1.255.2": { + "description": "DC1-SPINE2_Ethernet1", + "version": 4, + "msgReceived": 3759, + "msgSent": 3757, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 14, + "prefixReceived": 14, + "upDownTime": 1694266296.367261, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + } + ], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "peers": ["10.1.255.0", "10.1.255.2"]}]}, + "expected": { + "result": "failure", + "messages": [ + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}]" + ], + }, + }, + { + "name": "failure-not-configured", + "test": VerifyBGPSpecificPeers, + "eos_data": [{"vrfs": {}}], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "DEV", "peers": ["10.1.255.0"]}]}, + "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'DEV': 'Not Configured'}}]"]}, + }, + { + "name": "failure-no-peers", + "test": VerifyBGPSpecificPeers, + "eos_data": [{"vrfs": {"default": {"vrf": "default", "routerId": "10.1.0.3", "asn": "65120", "peers": {}}}}], + "inputs": {"address_families": [{"afi": "ipv4", "safi": "multicast", "peers": ["10.1.255.0"]}]}, + "expected": { + "result": "failure", + "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'default': {'10.1.255.0': {'peerNotFound': True}}}}]"], + }, + }, + { + "name": "success-multiple-afi", + "test": VerifyBGPSpecificPeers, + "eos_data": [ + { + "vrfs": { + "PROD": { + "vrf": "PROD", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.254.1": { + "description": "DC1-LEAF1B", + "version": 4, + "msgReceived": 3777, + "msgSent": 3764, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65120", + "prefixAccepted": 2, + "prefixReceived": 2, + "upDownTime": 1694266296.659891, + "underMaintenance": False, + "peerState": "Established", + }, + "192.168.1.11": { + "description": "K8S-CLUSTER1", + "version": 4, + "msgReceived": 6417, + "msgSent": 7546, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65000", + "prefixAccepted": 1, + "prefixReceived": 1, + "upDownTime": 1694266329.978035, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.0.1": { + "description": "DC1-SPINE1", + "version": 4, + "msgReceived": 3894, + "msgSent": 3897, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266296.584472, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.0.2": { + "description": "DC1-SPINE2", + "version": 4, + "msgReceived": 3893, + "msgSent": 3902, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266297.433896, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + }, + ], + "inputs": { + "address_families": [ + {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "peers": ["10.1.254.1", "192.168.1.11"]}, + {"afi": "evpn", "peers": ["10.1.0.1", "10.1.0.2"]}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-multiple-afi", + "test": VerifyBGPSpecificPeers, + "eos_data": [ + { + "vrfs": { + "PROD": { + "vrf": "PROD", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.254.1": { + "description": "DC1-LEAF1B", + "version": 4, + "msgReceived": 3777, + "msgSent": 3764, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65120", + "prefixAccepted": 2, + "prefixReceived": 2, + "upDownTime": 1694266296.659891, + "underMaintenance": False, + "peerState": "Established", + }, + "192.168.1.11": { + "description": "K8S-CLUSTER1", + "version": 4, + "msgReceived": 6417, + "msgSent": 7546, + "inMsgQueue": 10, + "outMsgQueue": 0, + "asn": "65000", + "prefixAccepted": 1, + "prefixReceived": 1, + "upDownTime": 1694266329.978035, + "underMaintenance": False, + "peerState": "Established", + }, + }, + } + } + }, + {"vrfs": {}}, + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", + "peers": { + "10.1.0.1": { + "description": "DC1-SPINE1", + "version": 4, + "msgReceived": 3894, + "msgSent": 3897, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266296.584472, + "underMaintenance": False, + "peerState": "Established", + }, + "10.1.0.2": { + "description": "DC1-SPINE2", + "version": 4, + "msgReceived": 3893, + "msgSent": 3902, + "inMsgQueue": 0, + "outMsgQueue": 0, + "asn": "65100", + "prefixAccepted": 0, + "prefixReceived": 0, + "upDownTime": 1694266297.433896, + "underMaintenance": False, + "peerState": "Idle", + }, + }, + } + } + }, + ], + "inputs": { + "address_families": [ + {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "peers": ["10.1.254.1", "192.168.1.11"]}, + {"afi": "evpn", "peers": ["10.1.0.1", "10.1.0.2"]}, + {"afi": "ipv6", "safi": "unicast", "vrf": "default", "peers": ["10.1.0.1", "10.1.0.2"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': " + "{'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 10, 'outMsgQueue': 0}}}}, " + "{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Not Configured'}}, " + "{'afi': 'evpn', 'vrfs': {'default': {'10.1.0.2': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyBGPExchangedRoutes, + "eos_data": [ + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + }, + } + } + }, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + "advertised_routes": ["192.0.254.5/32", "192.0.254.3/32"], + "received_routes": ["192.0.254.3/32", "192.0.255.4/32"], + }, + { + "peer_address": "172.30.11.5", + "vrf": "default", + "advertised_routes": ["192.0.254.3/32", "192.0.254.5/32"], + "received_routes": ["192.0.254.3/32", "192.0.255.4/32"], + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-routes", + "test": VerifyBGPExchangedRoutes, + "eos_data": [ + {"vrfs": {"default": {"vrf": "default", "routerId": "192.0.255.1", "asn": "65001", "bgpRouteEntries": {}}}}, + {"vrfs": {"default": {"vrf": "default", "routerId": "192.0.255.1", "asn": "65001", "bgpRouteEntries": {}}}}, + {"vrfs": {"default": {"vrf": "default", "routerId": "192.0.255.1", "asn": "65001", "bgpRouteEntries": {}}}}, + {"vrfs": {"default": {"vrf": "default", "routerId": "192.0.255.1", "asn": "65001", "bgpRouteEntries": {}}}}, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.11", + "vrf": "default", + "advertised_routes": ["192.0.254.3/32"], + "received_routes": ["192.0.255.3/32"], + }, + { + "peer_address": "172.30.11.12", + "vrf": "default", + "advertised_routes": ["192.0.254.31/32"], + "received_routes": ["192.0.255.31/32"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not found or routes are not exchanged properly:\n" + "{'bgp_peers': {'172.30.11.11': {'default': 'Not configured'}, '172.30.11.12': {'default': 'Not configured'}}}" + ], + }, + }, + { + "name": "failure-no-peer", + "test": VerifyBGPExchangedRoutes, + "eos_data": [ + {"vrfs": {}}, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + }, + } + } + }, + {"vrfs": {}}, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + }, + } + } + }, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.11", + "vrf": "MGMT", + "advertised_routes": ["192.0.254.3/32"], + "received_routes": ["192.0.255.3/32"], + }, + { + "peer_address": "172.30.11.5", + "vrf": "default", + "advertised_routes": ["192.0.254.3/32", "192.0.254.5/32"], + "received_routes": ["192.0.254.3/32", "192.0.255.4/32"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": ["Following BGP peers are not found or routes are not exchanged properly:\n{'bgp_peers': {'172.30.11.11': {'MGMT': 'Not configured'}}}"], + }, + }, + { + "name": "failure-missing-routes", + "test": VerifyBGPExchangedRoutes, + "eos_data": [ + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + }, + } + } + }, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + "advertised_routes": ["192.0.254.3/32", "192.0.254.51/32"], + "received_routes": ["192.0.254.31/32", "192.0.255.4/32"], + }, + { + "peer_address": "172.30.11.5", + "vrf": "default", + "advertised_routes": ["192.0.254.31/32", "192.0.254.5/32"], + "received_routes": ["192.0.254.3/32", "192.0.255.41/32"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not found or routes are not exchanged properly:\n{'bgp_peers': " + "{'172.30.11.1': {'default': {'advertised_routes': {'192.0.254.51/32': 'Not found'}, 'received_routes': {'192.0.254.31/32': 'Not found'}}}, " + "'172.30.11.5': {'default': {'advertised_routes': {'192.0.254.31/32': 'Not found'}, 'received_routes': {'192.0.255.41/32': 'Not found'}}}}}" + ], + }, + }, + { + "name": "failure-invalid-or-inactive-routes", + "test": VerifyBGPExchangedRoutes, + "eos_data": [ + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": False, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": False, + }, + } + ] + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": False, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": False, + "active": True, + }, + } + ] + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": False, + "active": False, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": False, + "active": False, + }, + } + ], + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": False, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": False, + }, + } + ], + }, + }, + } + } + }, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + "advertised_routes": ["192.0.254.3/32", "192.0.254.51/32"], + "received_routes": ["192.0.254.31/32", "192.0.255.4/32"], + }, + { + "peer_address": "172.30.11.5", + "vrf": "default", + "advertised_routes": ["192.0.254.31/32", "192.0.254.5/32"], + "received_routes": ["192.0.254.3/32", "192.0.255.41/32"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not found or routes are not exchanged properly:\n{'bgp_peers': " + "{'172.30.11.1': {'default': {'advertised_routes': {'192.0.254.3/32': {'valid': True, 'active': False}, '192.0.254.51/32': 'Not found'}, " + "'received_routes': {'192.0.254.31/32': 'Not found', '192.0.255.4/32': {'valid': False, 'active': False}}}}, " + "'172.30.11.5': {'default': {'advertised_routes': {'192.0.254.31/32': 'Not found', '192.0.254.5/32': {'valid': True, 'active': False}}, " + "'received_routes': {'192.0.254.3/32': {'valid': False, 'active': True}, '192.0.255.41/32': 'Not found'}}}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyBGPPeerMPCaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": { + "ipv4Unicast": {"advertised": True, "received": True, "enabled": True}, + "ipv4MplsLabels": {"advertised": True, "received": True, "enabled": True}, + } + }, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "neighborCapabilities": { + "multiprotocolCaps": { + "ipv4Unicast": {"advertised": True, "received": True, "enabled": True}, + "ipv4MplsVpn": {"advertised": True, "received": True, "enabled": True}, + } + }, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + "capabilities": ["Ipv4 Unicast", "ipv4 Mpls labels"], + }, + { + "peer_address": "172.30.11.10", + "vrf": "MGMT", + "capabilities": ["ipv4 Unicast", "ipv4 MplsVpn"], + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-vrf", + "test": VerifyBGPPeerMPCaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": { + "ipv4Unicast": {"advertised": True, "received": True, "enabled": True}, + "ipv4MplsVpn": {"advertised": True, "received": True, "enabled": True}, + } + }, + } + ] + } + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "MGMT", + "capabilities": ["ipv4 Unicast", "ipv4mplslabels"], + } + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer multiprotocol capabilities are not found or not ok:\n{'bgp_peers': {'172.30.11.1': {'MGMT': {'status': 'Not configured'}}}}" + ], + }, + }, + { + "name": "failure-no-peer", + "test": VerifyBGPPeerMPCaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}, + }, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "neighborCapabilities": { + "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}, + }, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.10", + "vrf": "default", + "capabilities": ["ipv4Unicast", "L2 Vpn EVPN"], + }, + { + "peer_address": "172.30.11.1", + "vrf": "MGMT", + "capabilities": ["ipv4Unicast", "L2 Vpn EVPN"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer multiprotocol capabilities are not found or not ok:\n" + "{'bgp_peers': {'172.30.11.10': {'default': {'status': 'Not configured'}}, '172.30.11.1': {'MGMT': {'status': 'Not configured'}}}}" + ], + }, + }, + { + "name": "failure-missing-capabilities", + "test": VerifyBGPPeerMPCaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}, + }, + } + ] + } + } + } + ], + "inputs": {"bgp_peers": [{"peer_address": "172.30.11.1", "vrf": "default", "capabilities": ["ipv4 Unicast", "L2VpnEVPN"]}]}, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer multiprotocol capabilities are not found or not ok:\n{'bgp_peers': {'172.30.11.1': {'default': {'l2VpnEvpn': 'not found'}}}}" + ], + }, + }, + { + "name": "failure-incorrect-capabilities", + "test": VerifyBGPPeerMPCaps, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": { + "ipv4Unicast": {"advertised": False, "received": False, "enabled": False}, + "ipv4MplsVpn": {"advertised": False, "received": True, "enabled": False}, + }, + }, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "neighborCapabilities": { + "multiprotocolCaps": { + "l2VpnEvpn": {"advertised": True, "received": False, "enabled": False}, + "ipv4MplsVpn": {"advertised": False, "received": False, "enabled": True}, + }, + }, + }, + { + "peerAddress": "172.30.11.11", + "neighborCapabilities": { + "multiprotocolCaps": { + "ipv4Unicast": {"advertised": False, "received": False, "enabled": False}, + "ipv4MplsVpn": {"advertised": False, "received": False, "enabled": False}, + }, + }, + }, + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "172.30.11.1", "vrf": "default", "capabilities": ["ipv4 unicast", "ipv4 mpls vpn", "L2 vpn EVPN"]}, + {"peer_address": "172.30.11.10", "vrf": "MGMT", "capabilities": ["ipv4unicast", "ipv4 mplsvpn", "L2vpnEVPN"]}, + {"peer_address": "172.30.11.11", "vrf": "MGMT", "capabilities": ["Ipv4 Unicast", "ipv4 MPLSVPN", "L2 vpnEVPN"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer multiprotocol capabilities are not found or not ok:\n" + "{'bgp_peers': {'172.30.11.1': {'default': {'ipv4Unicast': {'advertised': False, 'received': False, 'enabled': False}, " + "'ipv4MplsVpn': {'advertised': False, 'received': True, 'enabled': False}, 'l2VpnEvpn': 'not found'}}, " + "'172.30.11.10': {'MGMT': {'ipv4Unicast': 'not found', 'ipv4MplsVpn': {'advertised': False, 'received': False, 'enabled': True}, " + "'l2VpnEvpn': {'advertised': True, 'received': False, 'enabled': False}}}, " + "'172.30.11.11': {'MGMT': {'ipv4Unicast': {'advertised': False, 'received': False, 'enabled': False}, " + "'ipv4MplsVpn': {'advertised': False, 'received': False, 'enabled': False}, 'l2VpnEvpn': 'not found'}}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyBGPPeerASNCap, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "fourOctetAsnCap": {"advertised": True, "received": True, "enabled": True}, + }, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "neighborCapabilities": { + "fourOctetAsnCap": {"advertised": True, "received": True, "enabled": True}, + }, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + }, + { + "peer_address": "172.30.11.10", + "vrf": "MGMT", + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-vrf", + "test": VerifyBGPPeerASNCap, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "fourOctetAsnCap": {"advertised": True, "received": True, "enabled": True}, + }, + } + ] + } + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "neighborCapabilities": { + "fourOctetAsnCap": {"advertised": True, "received": True, "enabled": True}, + }, + } + ] + }, + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "MGMT", + }, + { + "peer_address": "172.30.11.10", + "vrf": "default", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer four octet asn capabilities are not found or not ok:\n" + "{'bgp_peers': {'172.30.11.1': {'MGMT': {'status': 'Not configured'}}, '172.30.11.10': {'default': {'status': 'Not configured'}}}}" + ], + }, + }, + { + "name": "failure-no-peer", + "test": VerifyBGPPeerASNCap, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}, + }, + }, + ] + } + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.10", + "vrf": "default", + } + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer four octet asn capabilities are not found or not ok:\n{'bgp_peers': {'172.30.11.10': {'default': {'status': 'Not configured'}}}}" + ], + }, + }, + { + "name": "failure-missing-capabilities", + "test": VerifyBGPPeerASNCap, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}, + }, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "neighborCapabilities": { + "multiprotocolCaps": {"ipv4MplsLabels": {"advertised": True, "received": True, "enabled": True}}, + }, + } + ] + }, + } + } + ], + "inputs": {"bgp_peers": [{"peer_address": "172.30.11.1", "vrf": "default"}, {"peer_address": "172.30.11.10", "vrf": "MGMT"}]}, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer four octet asn capabilities are not found or not ok:\n" + "{'bgp_peers': {'172.30.11.1': {'default': {'fourOctetAsnCap': 'not found'}}, '172.30.11.10': {'MGMT': {'fourOctetAsnCap': 'not found'}}}}" + ], + }, + }, + { + "name": "failure-incorrect-capabilities", + "test": VerifyBGPPeerASNCap, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "fourOctetAsnCap": {"advertised": False, "received": False, "enabled": False}, + }, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "neighborCapabilities": { + "fourOctetAsnCap": {"advertised": True, "received": False, "enabled": True}, + }, + } + ] + }, + } + } + ], + "inputs": {"bgp_peers": [{"peer_address": "172.30.11.1", "vrf": "default"}, {"peer_address": "172.30.11.10", "vrf": "MGMT"}]}, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer four octet asn capabilities are not found or not ok:\n" + "{'bgp_peers': {'172.30.11.1': {'default': {'fourOctetAsnCap': {'advertised': False, 'received': False, 'enabled': False}}}, " + "'172.30.11.10': {'MGMT': {'fourOctetAsnCap': {'advertised': True, 'received': False, 'enabled': True}}}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyBGPPeerRouteRefreshCap, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "routeRefreshCap": {"advertised": True, "received": True, "enabled": True}, + }, + } + ] + }, + "CS": { + "peerList": [ + { + "peerAddress": "172.30.11.11", + "neighborCapabilities": { + "routeRefreshCap": {"advertised": True, "received": True, "enabled": True}, + }, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + }, + { + "peer_address": "172.30.11.11", + "vrf": "CS", + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-vrf", + "test": VerifyBGPPeerRouteRefreshCap, + "eos_data": [{"vrfs": {}}], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "MGMT", + } + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer route refresh capabilities are not found or not ok:\n{'bgp_peers': {'172.30.11.1': {'MGMT': {'status': 'Not configured'}}}}" + ], + }, + }, + { + "name": "failure-no-peer", + "test": VerifyBGPPeerRouteRefreshCap, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": {"ip4Unicast": {"advertised": True, "received": True, "enabled": True}}, + }, + } + ] + }, + "CS": { + "peerList": [ + { + "peerAddress": "172.30.11.12", + "neighborCapabilities": { + "multiprotocolCaps": {"ip4Unicast": {"advertised": True, "received": True, "enabled": True}}, + }, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.12", + "vrf": "default", + }, + { + "peer_address": "172.30.11.1", + "vrf": "CS", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer route refresh capabilities are not found or not ok:\n" + "{'bgp_peers': {'172.30.11.12': {'default': {'status': 'Not configured'}}, '172.30.11.1': {'CS': {'status': 'Not configured'}}}}" + ], + }, + }, + { + "name": "failure-missing-capabilities", + "test": VerifyBGPPeerRouteRefreshCap, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}, + }, + } + ] + }, + "CS": { + "peerList": [ + { + "peerAddress": "172.30.11.11", + "neighborCapabilities": { + "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}, + }, + } + ] + }, + } + } + ], + "inputs": {"bgp_peers": [{"peer_address": "172.30.11.1", "vrf": "default"}, {"peer_address": "172.30.11.11", "vrf": "CS"}]}, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer route refresh capabilities are not found or not ok:\n" + "{'bgp_peers': {'172.30.11.1': {'default': {'routeRefreshCap': 'not found'}}, '172.30.11.11': {'CS': {'routeRefreshCap': 'not found'}}}}" + ], + }, + }, + { + "name": "failure-incorrect-capabilities", + "test": VerifyBGPPeerRouteRefreshCap, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "neighborCapabilities": { + "routeRefreshCap": {"advertised": False, "received": False, "enabled": False}, + }, + } + ] + }, + "CS": { + "peerList": [ + { + "peerAddress": "172.30.11.11", + "neighborCapabilities": { + "routeRefreshCap": {"advertised": True, "received": True, "enabled": True}, + }, + } + ] + }, + } + } + ], + "inputs": {"bgp_peers": [{"peer_address": "172.30.11.1", "vrf": "default"}, {"peer_address": "172.30.11.11", "vrf": "CS"}]}, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peer route refresh capabilities are not found or not ok:\n" + "{'bgp_peers': {'172.30.11.1': {'default': {'routeRefreshCap': {'advertised': False, 'received': False, 'enabled': False}}}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyBGPPeerMD5Auth, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "state": "Established", + "md5AuthEnabled": True, + } + ] + }, + "CS": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "state": "Established", + "md5AuthEnabled": True, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + }, + { + "peer_address": "172.30.11.10", + "vrf": "CS", + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-vrf", + "test": VerifyBGPPeerMD5Auth, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "state": "Established", + "md5AuthEnabled": True, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "MGMT", + } + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not configured, not established or MD5 authentication is not enabled:\n" + "{'bgp_peers': {'172.30.11.1': {'MGMT': {'status': 'Not configured'}}}}" + ], + }, + }, + { + "name": "failure-no-peer", + "test": VerifyBGPPeerMD5Auth, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "state": "Established", + "md5AuthEnabled": True, + } + ] + }, + "CS": { + "peerList": [ + { + "peerAddress": "172.30.11.11", + "state": "Established", + "md5AuthEnabled": True, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.10", + "vrf": "default", + }, + { + "peer_address": "172.30.11.11", + "vrf": "default", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not configured, not established or MD5 authentication is not enabled:\n" + "{'bgp_peers': {'172.30.11.10': {'default': {'status': 'Not configured'}}, '172.30.11.11': {'default': {'status': 'Not configured'}}}}" + ], + }, + }, + { + "name": "failure-not-established-peer", + "test": VerifyBGPPeerMD5Auth, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "state": "Idle", + "md5AuthEnabled": True, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "state": "Idle", + "md5AuthEnabled": False, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + }, + { + "peer_address": "172.30.11.10", + "vrf": "MGMT", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not configured, not established or MD5 authentication is not enabled:\n" + "{'bgp_peers': {'172.30.11.1': {'default': {'state': 'Idle', 'md5_auth_enabled': True}}, " + "'172.30.11.10': {'MGMT': {'state': 'Idle', 'md5_auth_enabled': False}}}}" + ], + }, + }, + { + "name": "failure-not-md5-peer", + "test": VerifyBGPPeerMD5Auth, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "state": "Established", + }, + {"peerAddress": "172.30.11.10", "state": "Established", "md5AuthEnabled": False}, + ] + }, + "MGMT": {"peerList": [{"peerAddress": "172.30.11.11", "state": "Established", "md5AuthEnabled": False}]}, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + }, + { + "peer_address": "172.30.11.11", + "vrf": "MGMT", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not configured, not established or MD5 authentication is not enabled:\n" + "{'bgp_peers': {'172.30.11.1': {'default': {'state': 'Established', 'md5_auth_enabled': None}}, " + "'172.30.11.11': {'MGMT': {'state': 'Established', 'md5_auth_enabled': False}}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyEVPNType2Route, + "eos_data": [ + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + }, + }, + ] + }, + }, + } + ], + "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-multiple-endpoints", + "test": VerifyEVPNType2Route, + "eos_data": [ + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + }, + }, + ] + }, + }, + }, + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10010 aac1.ab5d.b41e": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + }, + }, + ] + }, + }, + }, + ], + "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}, {"address": "aac1.ab5d.b41e", "vni": 10010}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-multiple-routes-ip", + "test": VerifyEVPNType2Route, + "eos_data": [ + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + }, + }, + ] + }, + "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + }, + }, + ] + }, + }, + }, + ], + "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-multiple-routes-mac", + "test": VerifyEVPNType2Route, + "eos_data": [ + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + }, + }, + ] + }, + "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + }, + }, + ] + }, + }, + }, + ], + "inputs": {"vxlan_endpoints": [{"address": "aac1.ab4e.bec2", "vni": 10020}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-multiple-routes-multiple-paths-ip", + "test": VerifyEVPNType2Route, + "eos_data": [ + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + "ecmp": True, + "ecmpContributor": True, + "ecmpHead": True, + }, + }, + { + "routeType": { + "active": False, + "valid": True, + "ecmp": True, + "ecmpContributor": True, + "ecmpHead": False, + }, + }, + ] + }, + "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + }, + }, + ] + }, + }, + }, + ], + "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-multiple-routes-multiple-paths-mac", + "test": VerifyEVPNType2Route, + "eos_data": [ + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + "ecmp": True, + "ecmpContributor": True, + "ecmpHead": True, + }, + }, + { + "routeType": { + "active": False, + "valid": True, + "ecmp": True, + "ecmpContributor": True, + "ecmpHead": False, + }, + }, + ] + }, + "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + }, + }, + ] + }, + }, + }, + ], + "inputs": {"vxlan_endpoints": [{"address": "aac1.ab4e.bec2", "vni": 10020}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-routes", + "test": VerifyEVPNType2Route, + "eos_data": [{"vrf": "default", "routerId": "10.1.0.3", "asn": 65120, "evpnRoutes": {}}], + "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]}, + "expected": { + "result": "failure", + "messages": ["The following VXLAN endpoint do not have any EVPN Type-2 route: [('192.168.20.102', 10020)]"], + }, + }, + { + "name": "failure-path-not-active", + "test": VerifyEVPNType2Route, + "eos_data": [ + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": False, + "valid": True, + }, + }, + ] + }, + }, + }, + ], + "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]}, + "expected": { + "result": "failure", + "messages": [ + "The following EVPN Type-2 routes do not have at least one valid and active path: ['RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102']" + ], + }, + }, + { + "name": "failure-multiple-routes-not-active", + "test": VerifyEVPNType2Route, + "eos_data": [ + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": False, + "valid": True, + }, + }, + ] + }, + "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": False, + "valid": False, + }, + }, + ] + }, + }, + }, + ], + "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]}, + "expected": { + "result": "failure", + "messages": [ + "The following EVPN Type-2 routes do not have at least one valid and active path: " + "['RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102', " + "'RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102']" + ], + }, + }, + { + "name": "failure-multiple-routes-multiple-paths-not-active", + "test": VerifyEVPNType2Route, + "eos_data": [ + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": True, + "valid": True, + }, + }, + { + "routeType": { + "active": False, + "valid": True, + }, + }, + ] + }, + "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": False, + "valid": False, + }, + }, + { + "routeType": { + "active": False, + "valid": False, + }, + }, + ] + }, + }, + }, + ], + "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]}, + "expected": { + "result": "failure", + "messages": [ + "The following EVPN Type-2 routes do not have at least one valid and active path: ['RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102']" + ], + }, + }, + { + "name": "failure-multiple-endpoints", + "test": VerifyEVPNType2Route, + "eos_data": [ + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": { + "evpnRoutePaths": [ + { + "routeType": { + "active": False, + "valid": False, + }, + }, + ] + }, + }, + }, + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10010 aac1.ab5d.b41e": { + "evpnRoutePaths": [ + { + "routeType": { + "active": False, + "valid": False, + }, + }, + ] + }, + }, + }, + ], + "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}, {"address": "aac1.ab5d.b41e", "vni": 10010}]}, + "expected": { + "result": "failure", + "messages": [ + "The following EVPN Type-2 routes do not have at least one valid and active path: " + "['RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102', " + "'RD: 10.1.0.5:500 mac-ip 10010 aac1.ab5d.b41e']" + ], + }, + }, + { + "name": "failure-multiple-endpoints-one-no-routes", + "test": VerifyEVPNType2Route, + "eos_data": [ + {"vrf": "default", "routerId": "10.1.0.3", "asn": 65120, "evpnRoutes": {}}, + { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": 65120, + "evpnRoutes": { + "RD: 10.1.0.5:500 mac-ip 10010 aac1.ab5d.b41e 192.168.10.101": { + "evpnRoutePaths": [ + { + "routeType": { + "active": False, + "valid": False, + }, + }, + ] + }, + }, + }, + ], + "inputs": {"vxlan_endpoints": [{"address": "aac1.ab4e.bec2", "vni": 10020}, {"address": "192.168.10.101", "vni": 10010}]}, + "expected": { + "result": "failure", + "messages": [ + "The following VXLAN endpoint do not have any EVPN Type-2 route: [('aa:c1:ab:4e:be:c2', 10020)]", + "The following EVPN Type-2 routes do not have at least one valid and active path: " + "['RD: 10.1.0.5:500 mac-ip 10010 aac1.ab5d.b41e 192.168.10.101']", + ], + }, + }, + { + "name": "failure-multiple-endpoints-no-routes", + "test": VerifyEVPNType2Route, + "eos_data": [ + {"vrf": "default", "routerId": "10.1.0.3", "asn": 65120, "evpnRoutes": {}}, + {"vrf": "default", "routerId": "10.1.0.3", "asn": 65120, "evpnRoutes": {}}, + ], + "inputs": {"vxlan_endpoints": [{"address": "aac1.ab4e.bec2", "vni": 10020}, {"address": "192.168.10.101", "vni": 10010}]}, + "expected": { + "result": "failure", + "messages": ["The following VXLAN endpoint do not have any EVPN Type-2 route: [('aa:c1:ab:4e:be:c2', 10020), ('192.168.10.101', 10010)]"], + }, + }, + { + "name": "success", + "test": VerifyBGPAdvCommunities, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "advertisedCommunities": {"standard": True, "extended": True, "large": True}, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "advertisedCommunities": {"standard": True, "extended": True, "large": True}, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + }, + { + "peer_address": "172.30.11.10", + "vrf": "MGMT", + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-vrf", + "test": VerifyBGPAdvCommunities, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "advertisedCommunities": {"standard": True, "extended": True, "large": True}, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.17", + "vrf": "MGMT", + } + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not configured or advertised communities are not standard, extended, and large:\n" + "{'bgp_peers': {'172.30.11.17': {'MGMT': {'status': 'Not configured'}}}}" + ], + }, + }, + { + "name": "failure-no-peer", + "test": VerifyBGPAdvCommunities, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "advertisedCommunities": {"standard": True, "extended": True, "large": True}, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "advertisedCommunities": {"standard": True, "extended": True, "large": True}, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.10", + "vrf": "default", + }, + { + "peer_address": "172.30.11.12", + "vrf": "MGMT", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not configured or advertised communities are not standard, extended, and large:\n" + "{'bgp_peers': {'172.30.11.10': {'default': {'status': 'Not configured'}}, '172.30.11.12': {'MGMT': {'status': 'Not configured'}}}}" + ], + }, + }, + { + "name": "failure-not-correct-communities", + "test": VerifyBGPAdvCommunities, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "advertisedCommunities": {"standard": False, "extended": False, "large": False}, + } + ] + }, + "CS": { + "peerList": [ + { + "peerAddress": "172.30.11.10", + "advertisedCommunities": {"standard": True, "extended": True, "large": False}, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + }, + { + "peer_address": "172.30.11.10", + "vrf": "CS", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not configured or advertised communities are not standard, extended, and large:\n" + "{'bgp_peers': {'172.30.11.1': {'default': {'advertised_communities': {'standard': False, 'extended': False, 'large': False}}}, " + "'172.30.11.10': {'CS': {'advertised_communities': {'standard': True, 'extended': True, 'large': False}}}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyBGPTimers, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "holdTime": 180, + "keepaliveTime": 60, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.11", + "holdTime": 180, + "keepaliveTime": 60, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + "hold_time": 180, + "keep_alive_time": 60, + }, + { + "peer_address": "172.30.11.11", + "vrf": "MGMT", + "hold_time": 180, + "keep_alive_time": 60, + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-peer", + "test": VerifyBGPTimers, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "holdTime": 180, + "keepaliveTime": 60, + } + ] + }, + "MGMT": {"peerList": []}, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "MGMT", + "hold_time": 180, + "keep_alive_time": 60, + }, + { + "peer_address": "172.30.11.11", + "vrf": "MGMT", + "hold_time": 180, + "keep_alive_time": 60, + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not configured or hold and keep-alive timers are not correct:\n" + "{'172.30.11.1': {'MGMT': 'Not configured'}, '172.30.11.11': {'MGMT': 'Not configured'}}" + ], + }, + }, + { + "name": "failure-not-correct-timers", + "test": VerifyBGPTimers, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "172.30.11.1", + "holdTime": 160, + "keepaliveTime": 60, + } + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "172.30.11.11", + "holdTime": 120, + "keepaliveTime": 40, + } + ] + }, + } + } + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + "hold_time": 180, + "keep_alive_time": 60, + }, + { + "peer_address": "172.30.11.11", + "vrf": "MGMT", + "hold_time": 180, + "keep_alive_time": 60, + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BGP peers are not configured or hold and keep-alive timers are not correct:\n" + "{'172.30.11.1': {'default': {'hold_time': 160, 'keep_alive_time': 60}}, " + "'172.30.11.11': {'MGMT': {'hold_time': 120, 'keep_alive_time': 40}}}" + ], + }, + }, +] diff --git a/tests/units/anta_tests/routing/test_generic.py b/tests/units/anta_tests/routing/test_generic.py new file mode 100644 index 0000000..90e70f8 --- /dev/null +++ b/tests/units/anta_tests/routing/test_generic.py @@ -0,0 +1,230 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.routing.generic.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.routing.generic import VerifyRoutingProtocolModel, VerifyRoutingTableEntry, VerifyRoutingTableSize +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyRoutingProtocolModel, + "eos_data": [{"vrfs": {"default": {}}, "protoModelStatus": {"configuredProtoModel": "multi-agent", "operatingProtoModel": "multi-agent"}}], + "inputs": {"model": "multi-agent"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-configured-model", + "test": VerifyRoutingProtocolModel, + "eos_data": [{"vrfs": {"default": {}}, "protoModelStatus": {"configuredProtoModel": "ribd", "operatingProtoModel": "ribd"}}], + "inputs": {"model": "multi-agent"}, + "expected": {"result": "failure", "messages": ["routing model is misconfigured: configured: ribd - operating: ribd - expected: multi-agent"]}, + }, + { + "name": "failure-mismatch-operating-model", + "test": VerifyRoutingProtocolModel, + "eos_data": [{"vrfs": {"default": {}}, "protoModelStatus": {"configuredProtoModel": "multi-agent", "operatingProtoModel": "ribd"}}], + "inputs": {"model": "multi-agent"}, + "expected": {"result": "failure", "messages": ["routing model is misconfigured: configured: multi-agent - operating: ribd - expected: multi-agent"]}, + }, + { + "name": "success", + "test": VerifyRoutingTableSize, + "eos_data": [ + { + "vrfs": { + "default": { + # Output truncated + "maskLen": {"8": 2}, + "totalRoutes": 123, + } + }, + } + ], + "inputs": {"minimum": 42, "maximum": 666}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyRoutingTableSize, + "eos_data": [ + { + "vrfs": { + "default": { + # Output truncated + "maskLen": {"8": 2}, + "totalRoutes": 1000, + } + }, + } + ], + "inputs": {"minimum": 42, "maximum": 666}, + "expected": {"result": "failure", "messages": ["routing-table has 1000 routes and not between min (42) and maximum (666)"]}, + }, + { + "name": "error-max-smaller-than-min", + "test": VerifyRoutingTableSize, + "eos_data": [{}], + "inputs": {"minimum": 666, "maximum": 42}, + "expected": { + "result": "error", + "messages": ["Minimum 666 is greater than maximum 42"], + }, + }, + { + "name": "success", + "test": VerifyRoutingTableEntry, + "eos_data": [ + { + "vrfs": { + "default": { + "routingDisabled": False, + "allRoutesProgrammedHardware": True, + "allRoutesProgrammedKernel": True, + "defaultRouteState": "notSet", + "routes": { + "10.1.0.1/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.4", "interface": "Ethernet1"}], + } + }, + } + } + }, + { + "vrfs": { + "default": { + "routingDisabled": False, + "allRoutesProgrammedHardware": True, + "allRoutesProgrammedKernel": True, + "defaultRouteState": "notSet", + "routes": { + "10.1.0.2/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}], + } + }, + } + } + }, + ], + "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-missing-route", + "test": VerifyRoutingTableEntry, + "eos_data": [ + { + "vrfs": { + "default": { + "routingDisabled": False, + "allRoutesProgrammedHardware": True, + "allRoutesProgrammedKernel": True, + "defaultRouteState": "notSet", + "routes": {}, + } + } + }, + { + "vrfs": { + "default": { + "routingDisabled": False, + "allRoutesProgrammedHardware": True, + "allRoutesProgrammedKernel": True, + "defaultRouteState": "notSet", + "routes": { + "10.1.0.2/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}], + } + }, + } + } + }, + ], + "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]}, + "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: ['10.1.0.1']"]}, + }, + { + "name": "failure-wrong-route", + "test": VerifyRoutingTableEntry, + "eos_data": [ + { + "vrfs": { + "default": { + "routingDisabled": False, + "allRoutesProgrammedHardware": True, + "allRoutesProgrammedKernel": True, + "defaultRouteState": "notSet", + "routes": { + "10.1.0.1/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.4", "interface": "Ethernet1"}], + } + }, + } + } + }, + { + "vrfs": { + "default": { + "routingDisabled": False, + "allRoutesProgrammedHardware": True, + "allRoutesProgrammedKernel": True, + "defaultRouteState": "notSet", + "routes": { + "10.1.0.55/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}], + } + }, + } + } + }, + ], + "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]}, + "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']"]}, + }, +] diff --git a/tests/units/anta_tests/routing/test_ospf.py b/tests/units/anta_tests/routing/test_ospf.py new file mode 100644 index 0000000..fbabee9 --- /dev/null +++ b/tests/units/anta_tests/routing/test_ospf.py @@ -0,0 +1,298 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.routing.ospf.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.routing.ospf import VerifyOSPFNeighborCount, VerifyOSPFNeighborState +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyOSPFNeighborState, + "eos_data": [ + { + "vrfs": { + "default": { + "instList": { + "666": { + "ospfNeighborEntries": [ + { + "routerId": "7.7.7.7", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "full", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + }, + { + "routerId": "9.9.9.9", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "full", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + }, + ] + } + } + }, + "BLAH": { + "instList": { + "777": { + "ospfNeighborEntries": [ + { + "routerId": "8.8.8.8", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "full", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + } + ] + } + } + }, + } + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyOSPFNeighborState, + "eos_data": [ + { + "vrfs": { + "default": { + "instList": { + "666": { + "ospfNeighborEntries": [ + { + "routerId": "7.7.7.7", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "2-way", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + }, + { + "routerId": "9.9.9.9", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "full", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + }, + ] + } + } + }, + "BLAH": { + "instList": { + "777": { + "ospfNeighborEntries": [ + { + "routerId": "8.8.8.8", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "down", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + } + ] + } + } + }, + } + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "Some neighbors are not correctly configured: [{'vrf': 'default', 'instance': '666', 'neighbor': '7.7.7.7', 'state': '2-way'}," + " {'vrf': 'BLAH', 'instance': '777', 'neighbor': '8.8.8.8', 'state': 'down'}]." + ], + }, + }, + { + "name": "skipped", + "test": VerifyOSPFNeighborState, + "eos_data": [ + { + "vrfs": {}, + } + ], + "inputs": None, + "expected": {"result": "skipped", "messages": ["no OSPF neighbor found"]}, + }, + { + "name": "success", + "test": VerifyOSPFNeighborCount, + "eos_data": [ + { + "vrfs": { + "default": { + "instList": { + "666": { + "ospfNeighborEntries": [ + { + "routerId": "7.7.7.7", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "full", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + }, + { + "routerId": "9.9.9.9", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "full", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + }, + ] + } + } + }, + "BLAH": { + "instList": { + "777": { + "ospfNeighborEntries": [ + { + "routerId": "8.8.8.8", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "full", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + } + ] + } + } + }, + } + } + ], + "inputs": {"number": 3}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-number", + "test": VerifyOSPFNeighborCount, + "eos_data": [ + { + "vrfs": { + "default": { + "instList": { + "666": { + "ospfNeighborEntries": [ + { + "routerId": "7.7.7.7", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "full", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + }, + ] + } + } + } + } + } + ], + "inputs": {"number": 3}, + "expected": {"result": "failure", "messages": ["device has 1 neighbors (expected 3)"]}, + }, + { + "name": "failure-good-number-wrong-state", + "test": VerifyOSPFNeighborCount, + "eos_data": [ + { + "vrfs": { + "default": { + "instList": { + "666": { + "ospfNeighborEntries": [ + { + "routerId": "7.7.7.7", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "2-way", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + }, + { + "routerId": "9.9.9.9", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "full", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + }, + ] + } + } + }, + "BLAH": { + "instList": { + "777": { + "ospfNeighborEntries": [ + { + "routerId": "8.8.8.8", + "priority": 1, + "drState": "DR", + "interfaceName": "Ethernet1", + "adjacencyState": "down", + "inactivity": 1683298014.844345, + "interfaceAddress": "10.3.0.1", + } + ] + } + } + }, + } + } + ], + "inputs": {"number": 3}, + "expected": { + "result": "failure", + "messages": [ + "Some neighbors are not correctly configured: [{'vrf': 'default', 'instance': '666', 'neighbor': '7.7.7.7', 'state': '2-way'}," + " {'vrf': 'BLAH', 'instance': '777', 'neighbor': '8.8.8.8', 'state': 'down'}]." + ], + }, + }, + { + "name": "skipped", + "test": VerifyOSPFNeighborCount, + "eos_data": [ + { + "vrfs": {}, + } + ], + "inputs": {"number": 3}, + "expected": {"result": "skipped", "messages": ["no OSPF neighbor found"]}, + }, +] diff --git a/tests/units/anta_tests/test_aaa.py b/tests/units/anta_tests/test_aaa.py new file mode 100644 index 0000000..2992290 --- /dev/null +++ b/tests/units/anta_tests/test_aaa.py @@ -0,0 +1,516 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.aaa.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.aaa import ( + VerifyAcctConsoleMethods, + VerifyAcctDefaultMethods, + VerifyAuthenMethods, + VerifyAuthzMethods, + VerifyTacacsServerGroups, + VerifyTacacsServers, + VerifyTacacsSourceIntf, +) +from tests.lib.anta import test # noqa: F401; pylint: disable=unused-import + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyTacacsSourceIntf, + "eos_data": [ + { + "tacacsServers": [ + { + "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, + } + ], + "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, + "srcIntf": {"MGMT": "Management0"}, + } + ], + "inputs": {"intf": "Management0", "vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-configured", + "test": VerifyTacacsSourceIntf, + "eos_data": [ + { + "tacacsServers": [], + "groups": {}, + "srcIntf": {}, + } + ], + "inputs": {"intf": "Management0", "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Source-interface Management0 is not configured in VRF MGMT"]}, + }, + { + "name": "failure-wrong-intf", + "test": VerifyTacacsSourceIntf, + "eos_data": [ + { + "tacacsServers": [ + { + "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, + } + ], + "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, + "srcIntf": {"MGMT": "Management1"}, + } + ], + "inputs": {"intf": "Management0", "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Wrong source-interface configured in VRF MGMT"]}, + }, + { + "name": "failure-wrong-vrf", + "test": VerifyTacacsSourceIntf, + "eos_data": [ + { + "tacacsServers": [ + { + "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, + } + ], + "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, + "srcIntf": {"PROD": "Management0"}, + } + ], + "inputs": {"intf": "Management0", "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Source-interface Management0 is not configured in VRF MGMT"]}, + }, + { + "name": "success", + "test": VerifyTacacsServers, + "eos_data": [ + { + "tacacsServers": [ + { + "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, + } + ], + "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, + "srcIntf": {"MGMT": "Management0"}, + } + ], + "inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-servers", + "test": VerifyTacacsServers, + "eos_data": [ + { + "tacacsServers": [], + "groups": {}, + "srcIntf": {}, + } + ], + "inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["No TACACS servers are configured"]}, + }, + { + "name": "failure-not-configured", + "test": VerifyTacacsServers, + "eos_data": [ + { + "tacacsServers": [ + { + "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, + } + ], + "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, + "srcIntf": {"MGMT": "Management0"}, + } + ], + "inputs": {"servers": ["10.22.10.91", "10.22.10.92"], "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["TACACS servers ['10.22.10.92'] are not configured in VRF MGMT"]}, + }, + { + "name": "failure-wrong-vrf", + "test": VerifyTacacsServers, + "eos_data": [ + { + "tacacsServers": [ + { + "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "PROD"}, + } + ], + "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, + "srcIntf": {"MGMT": "Management0"}, + } + ], + "inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["TACACS servers ['10.22.10.91'] are not configured in VRF MGMT"]}, + }, + { + "name": "success", + "test": VerifyTacacsServerGroups, + "eos_data": [ + { + "tacacsServers": [ + { + "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, + } + ], + "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, + "srcIntf": {"MGMT": "Management0"}, + } + ], + "inputs": {"groups": ["GROUP1"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-server-groups", + "test": VerifyTacacsServerGroups, + "eos_data": [ + { + "tacacsServers": [], + "groups": {}, + "srcIntf": {}, + } + ], + "inputs": {"groups": ["GROUP1"]}, + "expected": {"result": "failure", "messages": ["No TACACS server group(s) are configured"]}, + }, + { + "name": "failure-not-configured", + "test": VerifyTacacsServerGroups, + "eos_data": [ + { + "tacacsServers": [ + { + "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, + } + ], + "groups": {"GROUP2": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, + "srcIntf": {"MGMT": "Management0"}, + } + ], + "inputs": {"groups": ["GROUP1"]}, + "expected": {"result": "failure", "messages": ["TACACS server group(s) ['GROUP1'] are not configured"]}, + }, + { + "name": "success-login-enable", + "test": VerifyAuthenMethods, + "eos_data": [ + { + "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}, "login": {"methods": ["group tacacs+", "local"]}}, + "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, + "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}}, + } + ], + "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]}, + "expected": {"result": "success"}, + }, + { + "name": "success-dot1x", + "test": VerifyAuthenMethods, + "eos_data": [ + { + "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}, "login": {"methods": ["group tacacs+", "local"]}}, + "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, + "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}}, + } + ], + "inputs": {"methods": ["radius"], "types": ["dot1x"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-login-console", + "test": VerifyAuthenMethods, + "eos_data": [ + { + "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, + "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, + "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}}, + } + ], + "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]}, + "expected": {"result": "failure", "messages": ["AAA authentication methods are not configured for login console"]}, + }, + { + "name": "failure-login-console", + "test": VerifyAuthenMethods, + "eos_data": [ + { + "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}, "login": {"methods": ["group radius", "local"]}}, + "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, + "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}}, + } + ], + "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]}, + "expected": {"result": "failure", "messages": ["AAA authentication methods ['group tacacs+', 'local'] are not matching for login console"]}, + }, + { + "name": "failure-login-default", + "test": VerifyAuthenMethods, + "eos_data": [ + { + "loginAuthenMethods": {"default": {"methods": ["group radius", "local"]}, "login": {"methods": ["group tacacs+", "local"]}}, + "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, + "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}}, + } + ], + "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]}, + "expected": {"result": "failure", "messages": ["AAA authentication methods ['group tacacs+', 'local'] are not matching for ['login']"]}, + }, + { + "name": "success", + "test": VerifyAuthzMethods, + "eos_data": [ + { + "commandsAuthzMethods": {"privilege0-15": {"methods": ["group tacacs+", "local"]}}, + "execAuthzMethods": {"exec": {"methods": ["group tacacs+", "local"]}}, + } + ], + "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-commands", + "test": VerifyAuthzMethods, + "eos_data": [ + { + "commandsAuthzMethods": {"privilege0-15": {"methods": ["group radius", "local"]}}, + "execAuthzMethods": {"exec": {"methods": ["group tacacs+", "local"]}}, + } + ], + "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]}, + "expected": {"result": "failure", "messages": ["AAA authorization methods ['group tacacs+', 'local'] are not matching for ['commands']"]}, + }, + { + "name": "failure-exec", + "test": VerifyAuthzMethods, + "eos_data": [ + { + "commandsAuthzMethods": {"privilege0-15": {"methods": ["group tacacs+", "local"]}}, + "execAuthzMethods": {"exec": {"methods": ["group radius", "local"]}}, + } + ], + "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]}, + "expected": {"result": "failure", "messages": ["AAA authorization methods ['group tacacs+', 'local'] are not matching for ['exec']"]}, + }, + { + "name": "success-commands-exec-system", + "test": VerifyAcctDefaultMethods, + "eos_data": [ + { + "commandsAcctMethods": {"privilege0-15": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, + "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, + "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, + "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, + } + ], + "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, + "expected": {"result": "success"}, + }, + { + "name": "success-dot1x", + "test": VerifyAcctDefaultMethods, + "eos_data": [ + { + "commandsAcctMethods": {"privilege0-15": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, + "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, + "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, + "dot1xAcctMethods": {"dot1x": {"defaultAction": "startStop", "defaultMethods": ["group radius", "logging"], "consoleMethods": []}}, + } + ], + "inputs": {"methods": ["radius", "logging"], "types": ["dot1x"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-configured", + "test": VerifyAcctDefaultMethods, + "eos_data": [ + { + "commandsAcctMethods": {"privilege0-15": {"defaultMethods": [], "consoleMethods": []}}, + "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, + "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, + "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, + } + ], + "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, + "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for ['commands']"]}, + }, + { + "name": "failure-not-configured-empty", + "test": VerifyAcctDefaultMethods, + "eos_data": [ + { + "systemAcctMethods": {"system": {"defaultMethods": [], "consoleMethods": []}}, + "execAcctMethods": {"exec": {"defaultMethods": [], "consoleMethods": []}}, + "commandsAcctMethods": {"privilege0-15": {"defaultMethods": [], "consoleMethods": []}}, + "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, + } + ], + "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, + "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for ['system', 'exec', 'commands']"]}, + }, + { + "name": "failure-not-matching", + "test": VerifyAcctDefaultMethods, + "eos_data": [ + { + "commandsAcctMethods": {"privilege0-15": {"defaultAction": "startStop", "defaultMethods": ["group radius", "logging"], "consoleMethods": []}}, + "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, + "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, + "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, + } + ], + "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, + "expected": {"result": "failure", "messages": ["AAA accounting default methods ['group tacacs+', 'logging'] are not matching for ['commands']"]}, + }, + { + "name": "success-commands-exec-system", + "test": VerifyAcctConsoleMethods, + "eos_data": [ + { + "commandsAcctMethods": { + "privilege0-15": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + "execAcctMethods": { + "exec": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + "systemAcctMethods": { + "system": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, + } + ], + "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, + "expected": {"result": "success"}, + }, + { + "name": "success-dot1x", + "test": VerifyAcctConsoleMethods, + "eos_data": [ + { + "commandsAcctMethods": { + "privilege0-15": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + "execAcctMethods": { + "exec": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + "systemAcctMethods": { + "system": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + "dot1xAcctMethods": { + "dot1x": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + } + ], + "inputs": {"methods": ["tacacs+", "logging"], "types": ["dot1x"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-configured", + "test": VerifyAcctConsoleMethods, + "eos_data": [ + { + "commandsAcctMethods": { + "privilege0-15": { + "defaultMethods": [], + "consoleMethods": [], + } + }, + "execAcctMethods": { + "exec": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + "systemAcctMethods": { + "system": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, + } + ], + "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, + "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for ['commands']"]}, + }, + { + "name": "failure-not-configured-empty", + "test": VerifyAcctConsoleMethods, + "eos_data": [ + { + "systemAcctMethods": {"system": {"defaultMethods": [], "consoleMethods": []}}, + "execAcctMethods": {"exec": {"defaultMethods": [], "consoleMethods": []}}, + "commandsAcctMethods": {"privilege0-15": {"defaultMethods": [], "consoleMethods": []}}, + "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, + } + ], + "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, + "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for ['system', 'exec', 'commands']"]}, + }, + { + "name": "failure-not-matching", + "test": VerifyAcctConsoleMethods, + "eos_data": [ + { + "commandsAcctMethods": { + "privilege0-15": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group radius", "logging"], + } + }, + "execAcctMethods": { + "exec": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + "systemAcctMethods": { + "system": { + "defaultMethods": [], + "consoleAction": "startStop", + "consoleMethods": ["group tacacs+", "logging"], + } + }, + "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, + } + ], + "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, + "expected": {"result": "failure", "messages": ["AAA accounting console methods ['group tacacs+', 'logging'] are not matching for ['commands']"]}, + }, +] diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py new file mode 100644 index 0000000..67bb0b4 --- /dev/null +++ b/tests/units/anta_tests/test_bfd.py @@ -0,0 +1,523 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.bfd.py +""" +# pylint: disable=C0302 +from __future__ import annotations + +from typing import Any + +# pylint: disable=C0413 +# because of the patch above +from anta.tests.bfd import VerifyBFDPeersHealth, VerifyBFDPeersIntervals, VerifyBFDSpecificPeers # noqa: E402 +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyBFDPeersIntervals, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "peerStatsDetail": { + "operTxInterval": 1200000, + "operRxInterval": 1200000, + "detectMult": 3, + } + } + } + } + } + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.70": { + "peerStats": { + "": { + "peerStatsDetail": { + "operTxInterval": 1200000, + "operRxInterval": 1200000, + "detectMult": 3, + } + } + } + } + } + }, + } + } + ], + "inputs": { + "bfd_peers": [ + {"peer_address": "192.0.255.7", "vrf": "default", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3}, + {"peer_address": "192.0.255.70", "vrf": "MGMT", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-peer", + "test": VerifyBFDPeersIntervals, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "peerStatsDetail": { + "operTxInterval": 1200000, + "operRxInterval": 1200000, + "detectMult": 3, + } + } + } + } + } + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.71": { + "peerStats": { + "": { + "peerStatsDetail": { + "operTxInterval": 1200000, + "operRxInterval": 1200000, + "detectMult": 3, + } + } + } + } + } + }, + } + } + ], + "inputs": { + "bfd_peers": [ + {"peer_address": "192.0.255.7", "vrf": "CS", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3}, + {"peer_address": "192.0.255.70", "vrf": "MGMT", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BFD peers are not configured or timers are not correct:\n" + "{'192.0.255.7': {'CS': 'Not Configured'}, '192.0.255.70': {'MGMT': 'Not Configured'}}" + ], + }, + }, + { + "name": "failure-incorrect-timers", + "test": VerifyBFDPeersIntervals, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "peerStatsDetail": { + "operTxInterval": 1300000, + "operRxInterval": 1200000, + "detectMult": 4, + } + } + } + } + } + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.70": { + "peerStats": { + "": { + "peerStatsDetail": { + "operTxInterval": 120000, + "operRxInterval": 120000, + "detectMult": 5, + } + } + } + } + } + }, + } + } + ], + "inputs": { + "bfd_peers": [ + {"peer_address": "192.0.255.7", "vrf": "default", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3}, + {"peer_address": "192.0.255.70", "vrf": "MGMT", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Following BFD peers are not configured or timers are not correct:\n" + "{'192.0.255.7': {'default': {'tx_interval': 1300000, 'rx_interval': 1200000, 'multiplier': 4}}, " + "'192.0.255.70': {'MGMT': {'tx_interval': 120000, 'rx_interval': 120000, 'multiplier': 5}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyBFDSpecificPeers, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 108328132, + } + } + } + } + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.70": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 108328132, + } + } + } + } + }, + } + } + ], + "inputs": {"bfd_peers": [{"peer_address": "192.0.255.7", "vrf": "default"}, {"peer_address": "192.0.255.70", "vrf": "MGMT"}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-peer", + "test": VerifyBFDSpecificPeers, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 108328132, + } + } + } + } + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.71": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 108328132, + } + } + } + } + }, + } + } + ], + "inputs": {"bfd_peers": [{"peer_address": "192.0.255.7", "vrf": "CS"}, {"peer_address": "192.0.255.70", "vrf": "MGMT"}]}, + "expected": { + "result": "failure", + "messages": [ + "Following BFD peers are not configured, status is not up or remote disc is zero:\n" + "{'192.0.255.7': {'CS': 'Not Configured'}, '192.0.255.70': {'MGMT': 'Not Configured'}}" + ], + }, + }, + { + "name": "failure-session-down", + "test": VerifyBFDSpecificPeers, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "Down", + "remoteDisc": 108328132, + } + } + } + } + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.70": { + "peerStats": { + "": { + "status": "Down", + "remoteDisc": 0, + } + } + } + } + }, + } + } + ], + "inputs": {"bfd_peers": [{"peer_address": "192.0.255.7", "vrf": "default"}, {"peer_address": "192.0.255.70", "vrf": "MGMT"}]}, + "expected": { + "result": "failure", + "messages": [ + "Following BFD peers are not configured, status is not up or remote disc is zero:\n" + "{'192.0.255.7': {'default': {'status': 'Down', 'remote_disc': 108328132}}, " + "'192.0.255.70': {'MGMT': {'status': 'Down', 'remote_disc': 0}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyBFDPeersHealth, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 3940685114, + "lastDown": 1703657258.652725, + "l3intf": "", + } + } + }, + }, + "ipv6Neighbors": {}, + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.71": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 3940685114, + "lastDown": 1703657258.652725, + "l3intf": "", + } + } + }, + }, + "ipv6Neighbors": {}, + }, + } + }, + { + "utcTime": 1703667348.111288, + }, + ], + "inputs": {"down_threshold": 2}, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-peer", + "test": VerifyBFDPeersHealth, + "eos_data": [ + { + "vrfs": { + "MGMT": { + "ipv6Neighbors": {}, + "ipv4Neighbors": {}, + }, + "default": { + "ipv6Neighbors": {}, + "ipv4Neighbors": {}, + }, + } + }, + { + "utcTime": 1703658481.8778424, + }, + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": ["No IPv4 BFD peers are configured for any VRF."], + }, + }, + { + "name": "failure-session-down", + "test": VerifyBFDPeersHealth, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "down", + "remoteDisc": 0, + "lastDown": 1703657258.652725, + "l3intf": "", + } + } + }, + "192.0.255.70": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 3940685114, + "lastDown": 1703657258.652725, + "l3intf": "", + } + } + }, + }, + "ipv6Neighbors": {}, + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.71": { + "peerStats": { + "": { + "status": "down", + "remoteDisc": 0, + "lastDown": 1703657258.652725, + "l3intf": "", + } + } + }, + }, + "ipv6Neighbors": {}, + }, + } + }, + { + "utcTime": 1703658481.8778424, + }, + ], + "inputs": {}, + "expected": { + "result": "failure", + "messages": [ + "Following BFD peers are not up:\n192.0.255.7 is down in default VRF with remote disc 0.\n192.0.255.71 is down in MGMT VRF with remote disc 0." + ], + }, + }, + { + "name": "failure-session-up-disc", + "test": VerifyBFDPeersHealth, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 0, + "lastDown": 1703657258.652725, + "l3intf": "Ethernet2", + } + } + }, + "192.0.255.71": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 0, + "lastDown": 1703657258.652725, + "l3intf": "Ethernet2", + } + } + }, + }, + "ipv6Neighbors": {}, + } + } + }, + { + "utcTime": 1703658481.8778424, + }, + ], + "inputs": {}, + "expected": { + "result": "failure", + "messages": ["Following BFD peers were down:\n192.0.255.7 in default VRF has remote disc 0.\n192.0.255.71 in default VRF has remote disc 0."], + }, + }, + { + "name": "failure-last-down", + "test": VerifyBFDPeersHealth, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 3940685114, + "lastDown": 1703657258.652725, + "l3intf": "", + } + } + }, + "192.0.255.71": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 3940685114, + "lastDown": 1703657258.652725, + "l3intf": "", + } + } + }, + "192.0.255.17": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 3940685114, + "lastDown": 1703657258.652725, + "l3intf": "", + } + } + }, + }, + "ipv6Neighbors": {}, + } + } + }, + { + "utcTime": 1703667348.111288, + }, + ], + "inputs": {"down_threshold": 4}, + "expected": { + "result": "failure", + "messages": [ + "Following BFD peers were down:\n192.0.255.7 in default VRF was down 3 hours ago.\n" + "192.0.255.71 in default VRF was down 3 hours ago.\n192.0.255.17 in default VRF was down 3 hours ago." + ], + }, + }, +] diff --git a/tests/units/anta_tests/test_configuration.py b/tests/units/anta_tests/test_configuration.py new file mode 100644 index 0000000..a2ab673 --- /dev/null +++ b/tests/units/anta_tests/test_configuration.py @@ -0,0 +1,35 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Data for testing anta.tests.configuration""" +from __future__ import annotations + +from typing import Any + +from anta.tests.configuration import VerifyRunningConfigDiffs, VerifyZeroTouch +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyZeroTouch, + "eos_data": [{"mode": "disabled"}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyZeroTouch, + "eos_data": [{"mode": "enabled"}], + "inputs": None, + "expected": {"result": "failure", "messages": ["ZTP is NOT disabled"]}, + }, + { + "name": "success", + "test": VerifyRunningConfigDiffs, + "eos_data": [""], + "inputs": None, + "expected": {"result": "success"}, + }, + {"name": "failure", "test": VerifyRunningConfigDiffs, "eos_data": ["blah blah"], "inputs": None, "expected": {"result": "failure", "messages": ["blah blah"]}}, +] diff --git a/tests/units/anta_tests/test_connectivity.py b/tests/units/anta_tests/test_connectivity.py new file mode 100644 index 0000000..f79ce24 --- /dev/null +++ b/tests/units/anta_tests/test_connectivity.py @@ -0,0 +1,369 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.connectivity.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.connectivity import VerifyLLDPNeighbors, VerifyReachability +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success-ip", + "test": VerifyReachability, + "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "10.0.0.5"}, {"destination": "10.0.0.2", "source": "10.0.0.5"}]}, + "eos_data": [ + { + "messages": [ + """PING 10.0.0.1 (10.0.0.1) from 10.0.0.5 : 72(100) bytes of data. + 80 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=0.247 ms + 80 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=0.072 ms + + --- 10.0.0.1 ping statistics --- + 2 packets transmitted, 2 received, 0% packet loss, time 0ms + rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms + + """ + ] + }, + { + "messages": [ + """PING 10.0.0.2 (10.0.0.2) from 10.0.0.5 : 72(100) bytes of data. + 80 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.247 ms + 80 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.072 ms + + --- 10.0.0.2 ping statistics --- + 2 packets transmitted, 2 received, 0% packet loss, time 0ms + rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms + + """ + ] + }, + ], + "expected": {"result": "success"}, + }, + { + "name": "success-interface", + "test": VerifyReachability, + "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "Management0"}, {"destination": "10.0.0.2", "source": "Management0"}]}, + "eos_data": [ + { + "messages": [ + """PING 10.0.0.1 (10.0.0.1) from 10.0.0.5 : 72(100) bytes of data. + 80 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=0.247 ms + 80 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=0.072 ms + + --- 10.0.0.1 ping statistics --- + 2 packets transmitted, 2 received, 0% packet loss, time 0ms + rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms + + """ + ] + }, + { + "messages": [ + """PING 10.0.0.2 (10.0.0.2) from 10.0.0.5 : 72(100) bytes of data. + 80 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.247 ms + 80 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.072 ms + + --- 10.0.0.2 ping statistics --- + 2 packets transmitted, 2 received, 0% packet loss, time 0ms + rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms + + """ + ] + }, + ], + "expected": {"result": "success"}, + }, + { + "name": "success-repeat", + "test": VerifyReachability, + "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "Management0", "repeat": 1}]}, + "eos_data": [ + { + "messages": [ + """PING 10.0.0.1 (10.0.0.1) from 10.0.0.5 : 72(100) bytes of data. + 80 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=0.247 ms + + --- 10.0.0.1 ping statistics --- + 1 packets transmitted, 1 received, 0% packet loss, time 0ms + rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms + + """ + ] + }, + ], + "expected": {"result": "success"}, + }, + { + "name": "failure-ip", + "test": VerifyReachability, + "inputs": {"hosts": [{"destination": "10.0.0.11", "source": "10.0.0.5"}, {"destination": "10.0.0.2", "source": "10.0.0.5"}]}, + "eos_data": [ + { + "messages": [ + """ping: sendmsg: Network is unreachable + ping: sendmsg: Network is unreachable + PING 10.0.0.11 (10.0.0.11) from 10.0.0.5 : 72(100) bytes of data. + + --- 10.0.0.11 ping statistics --- + 2 packets transmitted, 0 received, 100% packet loss, time 10ms + + + """ + ] + }, + { + "messages": [ + """PING 10.0.0.2 (10.0.0.2) from 10.0.0.5 : 72(100) bytes of data. + 80 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.247 ms + 80 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.072 ms + + --- 10.0.0.2 ping statistics --- + 2 packets transmitted, 2 received, 0% packet loss, time 0ms + rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms + + """ + ] + }, + ], + "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('10.0.0.5', '10.0.0.11')]"]}, + }, + { + "name": "failure-interface", + "test": VerifyReachability, + "inputs": {"hosts": [{"destination": "10.0.0.11", "source": "Management0"}, {"destination": "10.0.0.2", "source": "Management0"}]}, + "eos_data": [ + { + "messages": [ + """ping: sendmsg: Network is unreachable + ping: sendmsg: Network is unreachable + PING 10.0.0.11 (10.0.0.11) from 10.0.0.5 : 72(100) bytes of data. + + --- 10.0.0.11 ping statistics --- + 2 packets transmitted, 0 received, 100% packet loss, time 10ms + + + """ + ] + }, + { + "messages": [ + """PING 10.0.0.2 (10.0.0.2) from 10.0.0.5 : 72(100) bytes of data. + 80 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.247 ms + 80 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.072 ms + + --- 10.0.0.2 ping statistics --- + 2 packets transmitted, 2 received, 0% packet loss, time 0ms + rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms + + """ + ] + }, + ], + "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('Management0', '10.0.0.11')]"]}, + }, + { + "name": "success", + "test": VerifyLLDPNeighbors, + "inputs": { + "neighbors": [ + {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, + {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, + ] + }, + "eos_data": [ + { + "lldpNeighbors": { + "Ethernet1": { + "lldpNeighborInfo": [ + { + "chassisIdType": "macAddress", + "chassisId": "001c.73a0.fc18", + "systemName": "DC1-SPINE1", + "neighborInterfaceInfo": { + "interfaceIdType": "interfaceName", + "interfaceId": '"Ethernet1"', + "interfaceId_v2": "Ethernet1", + "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1", + }, + } + ] + }, + "Ethernet2": { + "lldpNeighborInfo": [ + { + "chassisIdType": "macAddress", + "chassisId": "001c.73f7.d138", + "systemName": "DC1-SPINE2", + "neighborInterfaceInfo": { + "interfaceIdType": "interfaceName", + "interfaceId": '"Ethernet1"', + "interfaceId_v2": "Ethernet1", + "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet2", + }, + } + ] + }, + } + } + ], + "expected": {"result": "success"}, + }, + { + "name": "failure-port-not-configured", + "test": VerifyLLDPNeighbors, + "inputs": { + "neighbors": [ + {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, + {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, + ] + }, + "eos_data": [ + { + "lldpNeighbors": { + "Ethernet1": { + "lldpNeighborInfo": [ + { + "chassisIdType": "macAddress", + "chassisId": "001c.73a0.fc18", + "systemName": "DC1-SPINE1", + "neighborInterfaceInfo": { + "interfaceIdType": "interfaceName", + "interfaceId": '"Ethernet1"', + "interfaceId_v2": "Ethernet1", + "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1", + }, + } + ] + }, + } + } + ], + "expected": {"result": "failure", "messages": ["The following port(s) have issues: {'port_not_configured': ['Ethernet2']}"]}, + }, + { + "name": "failure-no-neighbor", + "test": VerifyLLDPNeighbors, + "inputs": { + "neighbors": [ + {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, + {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, + ] + }, + "eos_data": [ + { + "lldpNeighbors": { + "Ethernet1": { + "lldpNeighborInfo": [ + { + "chassisIdType": "macAddress", + "chassisId": "001c.73a0.fc18", + "systemName": "DC1-SPINE1", + "neighborInterfaceInfo": { + "interfaceIdType": "interfaceName", + "interfaceId": '"Ethernet1"', + "interfaceId_v2": "Ethernet1", + "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1", + }, + } + ] + }, + "Ethernet2": {"lldpNeighborInfo": []}, + } + } + ], + "expected": {"result": "failure", "messages": ["The following port(s) have issues: {'no_lldp_neighbor': ['Ethernet2']}"]}, + }, + { + "name": "failure-wrong-neighbor", + "test": VerifyLLDPNeighbors, + "inputs": { + "neighbors": [ + {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, + {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, + ] + }, + "eos_data": [ + { + "lldpNeighbors": { + "Ethernet1": { + "lldpNeighborInfo": [ + { + "chassisIdType": "macAddress", + "chassisId": "001c.73a0.fc18", + "systemName": "DC1-SPINE1", + "neighborInterfaceInfo": { + "interfaceIdType": "interfaceName", + "interfaceId": '"Ethernet1"', + "interfaceId_v2": "Ethernet1", + "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1", + }, + } + ] + }, + "Ethernet2": { + "lldpNeighborInfo": [ + { + "chassisIdType": "macAddress", + "chassisId": "001c.73f7.d138", + "systemName": "DC1-SPINE2", + "neighborInterfaceInfo": { + "interfaceIdType": "interfaceName", + "interfaceId": '"Ethernet2"', + "interfaceId_v2": "Ethernet2", + "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet2", + }, + } + ] + }, + } + } + ], + "expected": {"result": "failure", "messages": ["The following port(s) have issues: {'wrong_lldp_neighbor': ['Ethernet2']}"]}, + }, + { + "name": "failure-multiple", + "test": VerifyLLDPNeighbors, + "inputs": { + "neighbors": [ + {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, + {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, + {"port": "Ethernet3", "neighbor_device": "DC1-SPINE3", "neighbor_port": "Ethernet1"}, + ] + }, + "eos_data": [ + { + "lldpNeighbors": { + "Ethernet1": { + "lldpNeighborInfo": [ + { + "chassisIdType": "macAddress", + "chassisId": "001c.73a0.fc18", + "systemName": "DC1-SPINE1", + "neighborInterfaceInfo": { + "interfaceIdType": "interfaceName", + "interfaceId": '"Ethernet2"', + "interfaceId_v2": "Ethernet2", + "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1", + }, + } + ] + }, + "Ethernet2": {"lldpNeighborInfo": []}, + } + } + ], + "expected": { + "result": "failure", + "messages": [ + "The following port(s) have issues: {'wrong_lldp_neighbor': ['Ethernet1'], 'no_lldp_neighbor': ['Ethernet2'], 'port_not_configured': ['Ethernet3']}" + ], + }, + }, +] diff --git a/tests/units/anta_tests/test_field_notices.py b/tests/units/anta_tests/test_field_notices.py new file mode 100644 index 0000000..7c17f22 --- /dev/null +++ b/tests/units/anta_tests/test_field_notices.py @@ -0,0 +1,291 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Test inputs for anta.tests.field_notices""" +from __future__ import annotations + +from typing import Any + +from anta.tests.field_notices import VerifyFieldNotice44Resolution, VerifyFieldNotice72Resolution +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyFieldNotice44Resolution, + "eos_data": [ + { + "imageFormatVersion": "1.0", + "uptime": 1109144.35, + "modelName": "DCS-7280QRA-C36S", + "details": { + "deviations": [], + "components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}], + }, + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure-4.0", + "test": VerifyFieldNotice44Resolution, + "eos_data": [ + { + "imageFormatVersion": "1.0", + "uptime": 1109144.35, + "modelName": "DCS-7280QRA-C36S", + "details": { + "deviations": [], + "components": [{"name": "Aboot", "version": "Aboot-veos-4.0.1-3255441"}], + }, + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (4.0.1)"]}, + }, + { + "name": "failure-4.1", + "test": VerifyFieldNotice44Resolution, + "eos_data": [ + { + "imageFormatVersion": "1.0", + "uptime": 1109144.35, + "modelName": "DCS-7280QRA-C36S", + "details": { + "deviations": [], + "components": [{"name": "Aboot", "version": "Aboot-veos-4.1.0-3255441"}], + }, + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (4.1.0)"]}, + }, + { + "name": "failure-6.0", + "test": VerifyFieldNotice44Resolution, + "eos_data": [ + { + "imageFormatVersion": "1.0", + "uptime": 1109144.35, + "modelName": "DCS-7280QRA-C36S", + "details": { + "deviations": [], + "components": [{"name": "Aboot", "version": "Aboot-veos-6.0.1-3255441"}], + }, + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (6.0.1)"]}, + }, + { + "name": "failure-6.1", + "test": VerifyFieldNotice44Resolution, + "eos_data": [ + { + "imageFormatVersion": "1.0", + "uptime": 1109144.35, + "modelName": "DCS-7280QRA-C36S", + "details": { + "deviations": [], + "components": [{"name": "Aboot", "version": "Aboot-veos-6.1.1-3255441"}], + }, + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (6.1.1)"]}, + }, + { + "name": "skipped-model", + "test": VerifyFieldNotice44Resolution, + "eos_data": [ + { + "imageFormatVersion": "1.0", + "uptime": 1109144.35, + "modelName": "vEOS-lab", + "details": { + "deviations": [], + "components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}], + }, + } + ], + "inputs": None, + "expected": {"result": "skipped", "messages": ["device is not impacted by FN044"]}, + }, + { + "name": "success-JPE", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7280SR3-48YC8", + "serialNumber": "JPE2130000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm1", "version": "7"}], + }, + } + ], + "inputs": None, + "expected": {"result": "success", "messages": ["FN72 is mitigated"]}, + }, + { + "name": "success-JAS", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7280SR3-48YC8", + "serialNumber": "JAS2040000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm1", "version": "7"}], + }, + } + ], + "inputs": None, + "expected": {"result": "success", "messages": ["FN72 is mitigated"]}, + }, + { + "name": "success-K-JPE", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7280SR3K-48YC8", + "serialNumber": "JPE2133000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm1", "version": "7"}], + }, + } + ], + "inputs": None, + "expected": {"result": "success", "messages": ["FN72 is mitigated"]}, + }, + { + "name": "success-K-JAS", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7280SR3K-48YC8", + "serialNumber": "JAS2040000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm1", "version": "7"}], + }, + } + ], + "inputs": None, + "expected": {"result": "success", "messages": ["FN72 is mitigated"]}, + }, + { + "name": "skipped-Serial", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7280SR3K-48YC8", + "serialNumber": "BAN2040000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm1", "version": "7"}], + }, + } + ], + "inputs": None, + "expected": {"result": "skipped", "messages": ["Device not exposed"]}, + }, + { + "name": "skipped-Platform", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7150-52-CL", + "serialNumber": "JAS0040000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm1", "version": "5"}], + }, + } + ], + "inputs": None, + "expected": {"result": "skipped", "messages": ["Platform is not impacted by FN072"]}, + }, + { + "name": "skipped-range-JPE", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7280SR3-48YC8", + "serialNumber": "JPE2131000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm1", "version": "5"}], + }, + } + ], + "inputs": None, + "expected": {"result": "skipped", "messages": ["Device not exposed"]}, + }, + { + "name": "skipped-range-K-JAS", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7280SR3K-48YC8", + "serialNumber": "JAS2041000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm1", "version": "5"}], + }, + } + ], + "inputs": None, + "expected": {"result": "skipped", "messages": ["Device not exposed"]}, + }, + { + "name": "failed-JPE", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7280SR3K-48YC8", + "serialNumber": "JPE2133000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm1", "version": "5"}], + }, + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Device is exposed to FN72"]}, + }, + { + "name": "failed-JAS", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7280SR3-48YC8", + "serialNumber": "JAS2040000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm1", "version": "5"}], + }, + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Device is exposed to FN72"]}, + }, + { + "name": "error", + "test": VerifyFieldNotice72Resolution, + "eos_data": [ + { + "modelName": "DCS-7280SR3-48YC8", + "serialNumber": "JAS2040000", + "details": { + "deviations": [], + "components": [{"name": "FixedSystemvrm2", "version": "5"}], + }, + } + ], + "inputs": None, + "expected": {"result": "error", "messages": ["Error in running test - FixedSystemvrm1 not found"]}, + }, +] diff --git a/tests/units/anta_tests/test_greent.py b/tests/units/anta_tests/test_greent.py new file mode 100644 index 0000000..65789a2 --- /dev/null +++ b/tests/units/anta_tests/test_greent.py @@ -0,0 +1,47 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Data for testing anta.tests.configuration""" +from __future__ import annotations + +from typing import Any + +from anta.tests.greent import VerifyGreenT, VerifyGreenTCounters + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyGreenTCounters, + "eos_data": [{"sampleRcvd": 0, "sampleDiscarded": 0, "multiDstSampleRcvd": 0, "grePktSent": 1, "sampleSent": 0}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyGreenTCounters, + "eos_data": [{"sampleRcvd": 0, "sampleDiscarded": 0, "multiDstSampleRcvd": 0, "grePktSent": 0, "sampleSent": 0}], + "inputs": None, + "expected": {"result": "failure"}, + }, + { + "name": "success", + "test": VerifyGreenT, + "eos_data": [{"sampleRcvd": 0, "sampleDiscarded": 0, "multiDstSampleRcvd": 0, "grePktSent": 1, "sampleSent": 0}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyGreenT, + "eos_data": [ + { + "profiles": { + "default": {"interfaces": [], "appliedInterfaces": [], "samplePolicy": "default", "failures": {}, "appliedInterfaces6": [], "failures6": {}}, + "testProfile": {"interfaces": [], "appliedInterfaces": [], "samplePolicy": "default", "failures": {}, "appliedInterfaces6": [], "failures6": {}}, + } + } + ], + "inputs": None, + "expected": {"result": "failure"}, + }, +] diff --git a/tests/units/anta_tests/test_hardware.py b/tests/units/anta_tests/test_hardware.py new file mode 100644 index 0000000..5279d89 --- /dev/null +++ b/tests/units/anta_tests/test_hardware.py @@ -0,0 +1,918 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Test inputs for anta.tests.hardware""" +from __future__ import annotations + +from typing import Any + +from anta.tests.hardware import ( + VerifyAdverseDrops, + VerifyEnvironmentCooling, + VerifyEnvironmentPower, + VerifyEnvironmentSystemCooling, + VerifyTemperature, + VerifyTransceiversManufacturers, + VerifyTransceiversTemperature, +) +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyTransceiversManufacturers, + "eos_data": [ + { + "xcvrSlots": { + "1": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501340", "hardwareRev": "21"}, + "2": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501337", "hardwareRev": "21"}, + } + } + ], + "inputs": {"manufacturers": ["Arista Networks"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyTransceiversManufacturers, + "eos_data": [ + { + "xcvrSlots": { + "1": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501340", "hardwareRev": "21"}, + "2": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501337", "hardwareRev": "21"}, + } + } + ], + "inputs": {"manufacturers": ["Arista"]}, + "expected": {"result": "failure", "messages": ["Some transceivers are from unapproved manufacturers: {'1': 'Arista Networks', '2': 'Arista Networks'}"]}, + }, + { + "name": "success", + "test": VerifyTemperature, + "eos_data": [ + { + "powercycleOnOverheat": "False", + "ambientThreshold": 45, + "cardSlots": [], + "shutdownOnOverheat": "True", + "systemStatus": "temperatureOk", + "recoveryModeOnOverheat": "recoveryModeNA", + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyTemperature, + "eos_data": [ + { + "powercycleOnOverheat": "False", + "ambientThreshold": 45, + "cardSlots": [], + "shutdownOnOverheat": "True", + "systemStatus": "temperatureKO", + "recoveryModeOnOverheat": "recoveryModeNA", + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Device temperature exceeds acceptable limits. Current system status: 'temperatureKO'"]}, + }, + { + "name": "success", + "test": VerifyTransceiversTemperature, + "eos_data": [ + { + "tempSensors": [ + { + "maxTemperature": 25.03125, + "maxTemperatureLastChange": 1682509618.2227979, + "hwStatus": "ok", + "alertCount": 0, + "description": "Xcvr54 temp sensor", + "overheatThreshold": 70.0, + "criticalThreshold": 70.0, + "inAlertState": False, + "targetTemperature": 62.0, + "relPos": "54", + "currentTemperature": 24.171875, + "setPointTemperature": 61.8, + "pidDriverCount": 0, + "isPidDriver": False, + "name": "DomTemperatureSensor54", + } + ], + "cardSlots": [], + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure-hwStatus", + "test": VerifyTransceiversTemperature, + "eos_data": [ + { + "tempSensors": [ + { + "maxTemperature": 25.03125, + "maxTemperatureLastChange": 1682509618.2227979, + "hwStatus": "ko", + "alertCount": 0, + "description": "Xcvr54 temp sensor", + "overheatThreshold": 70.0, + "criticalThreshold": 70.0, + "inAlertState": False, + "targetTemperature": 62.0, + "relPos": "54", + "currentTemperature": 24.171875, + "setPointTemperature": 61.8, + "pidDriverCount": 0, + "isPidDriver": False, + "name": "DomTemperatureSensor54", + } + ], + "cardSlots": [], + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "The following sensors are operating outside the acceptable temperature range or have raised alerts: " + "{'DomTemperatureSensor54': " + "{'hwStatus': 'ko', 'alertCount': 0}}" + ], + }, + }, + { + "name": "failure-alertCount", + "test": VerifyTransceiversTemperature, + "eos_data": [ + { + "tempSensors": [ + { + "maxTemperature": 25.03125, + "maxTemperatureLastChange": 1682509618.2227979, + "hwStatus": "ok", + "alertCount": 1, + "description": "Xcvr54 temp sensor", + "overheatThreshold": 70.0, + "criticalThreshold": 70.0, + "inAlertState": False, + "targetTemperature": 62.0, + "relPos": "54", + "currentTemperature": 24.171875, + "setPointTemperature": 61.8, + "pidDriverCount": 0, + "isPidDriver": False, + "name": "DomTemperatureSensor54", + } + ], + "cardSlots": [], + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "The following sensors are operating outside the acceptable temperature range or have raised alerts: " + "{'DomTemperatureSensor54': " + "{'hwStatus': 'ok', 'alertCount': 1}}" + ], + }, + }, + { + "name": "success", + "test": VerifyEnvironmentSystemCooling, + "eos_data": [ + { + "defaultZones": False, + "numCoolingZones": [], + "coolingMode": "automatic", + "ambientTemperature": 24.5, + "shutdownOnInsufficientFans": True, + "airflowDirection": "frontToBackAirflow", + "overrideFanSpeed": 0, + "powerSupplySlots": [], + "fanTraySlots": [], + "minFanSpeed": 0, + "currentZones": 1, + "configuredZones": 0, + "systemStatus": "coolingOk", + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyEnvironmentSystemCooling, + "eos_data": [ + { + "defaultZones": False, + "numCoolingZones": [], + "coolingMode": "automatic", + "ambientTemperature": 24.5, + "shutdownOnInsufficientFans": True, + "airflowDirection": "frontToBackAirflow", + "overrideFanSpeed": 0, + "powerSupplySlots": [], + "fanTraySlots": [], + "minFanSpeed": 0, + "currentZones": 1, + "configuredZones": 0, + "systemStatus": "coolingKo", + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Device system cooling is not OK: 'coolingKo'"]}, + }, + { + "name": "success", + "test": VerifyEnvironmentCooling, + "eos_data": [ + { + "defaultZones": False, + "numCoolingZones": [], + "coolingMode": "automatic", + "ambientTemperature": 24.5, + "shutdownOnInsufficientFans": True, + "airflowDirection": "frontToBackAirflow", + "overrideFanSpeed": 0, + "powerSupplySlots": [ + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498937.0240965, + "maxSpeed": 23000, + "lastSpeedStableChangeTime": 1682499033.0403435, + "configuredSpeed": 30, + "actualSpeed": 33, + "speedHwOverride": True, + "speedStable": True, + "label": "PowerSupply1/1", + } + ], + "speed": 30, + "label": "PowerSupply1", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498935.9121106, + "maxSpeed": 23000, + "lastSpeedStableChangeTime": 1682499092.4665174, + "configuredSpeed": 30, + "actualSpeed": 33, + "speedHwOverride": True, + "speedStable": True, + "label": "PowerSupply2/1", + } + ], + "speed": 30, + "label": "PowerSupply2", + }, + ], + "fanTraySlots": [ + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9303148, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0139885, + "configuredSpeed": 30, + "actualSpeed": 29, + "speedHwOverride": False, + "speedStable": True, + "label": "1/1", + } + ], + "speed": 30, + "label": "1", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9304729, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498939.9329433, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "2/1", + } + ], + "speed": 30, + "label": "2", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9383528, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0140095, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "3/1", + } + ], + "speed": 30, + "label": "3", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9303904, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0140295, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "4/1", + } + ], + "speed": 30, + "label": "4", + }, + ], + "minFanSpeed": 0, + "currentZones": 1, + "configuredZones": 0, + "systemStatus": "coolingOk", + } + ], + "inputs": {"states": ["ok"]}, + "expected": {"result": "success"}, + }, + { + "name": "success-additional-states", + "test": VerifyEnvironmentCooling, + "eos_data": [ + { + "defaultZones": False, + "numCoolingZones": [], + "coolingMode": "automatic", + "ambientTemperature": 24.5, + "shutdownOnInsufficientFans": True, + "airflowDirection": "frontToBackAirflow", + "overrideFanSpeed": 0, + "powerSupplySlots": [ + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498937.0240965, + "maxSpeed": 23000, + "lastSpeedStableChangeTime": 1682499033.0403435, + "configuredSpeed": 30, + "actualSpeed": 33, + "speedHwOverride": True, + "speedStable": True, + "label": "PowerSupply1/1", + } + ], + "speed": 30, + "label": "PowerSupply1", + }, + { + "status": "ok", + "fans": [ + { + "status": "Not Inserted", + "uptime": 1682498935.9121106, + "maxSpeed": 23000, + "lastSpeedStableChangeTime": 1682499092.4665174, + "configuredSpeed": 30, + "actualSpeed": 33, + "speedHwOverride": True, + "speedStable": True, + "label": "PowerSupply2/1", + } + ], + "speed": 30, + "label": "PowerSupply2", + }, + ], + "fanTraySlots": [ + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9303148, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0139885, + "configuredSpeed": 30, + "actualSpeed": 29, + "speedHwOverride": False, + "speedStable": True, + "label": "1/1", + } + ], + "speed": 30, + "label": "1", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9304729, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498939.9329433, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "2/1", + } + ], + "speed": 30, + "label": "2", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9383528, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0140095, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "3/1", + } + ], + "speed": 30, + "label": "3", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9303904, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0140295, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "4/1", + } + ], + "speed": 30, + "label": "4", + }, + ], + "minFanSpeed": 0, + "currentZones": 1, + "configuredZones": 0, + "systemStatus": "coolingOk", + } + ], + "inputs": {"states": ["ok", "Not Inserted"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-fan-tray", + "test": VerifyEnvironmentCooling, + "eos_data": [ + { + "defaultZones": False, + "numCoolingZones": [], + "coolingMode": "automatic", + "ambientTemperature": 24.5, + "shutdownOnInsufficientFans": True, + "airflowDirection": "frontToBackAirflow", + "overrideFanSpeed": 0, + "powerSupplySlots": [ + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498937.0240965, + "maxSpeed": 23000, + "lastSpeedStableChangeTime": 1682499033.0403435, + "configuredSpeed": 30, + "actualSpeed": 33, + "speedHwOverride": True, + "speedStable": True, + "label": "PowerSupply1/1", + } + ], + "speed": 30, + "label": "PowerSupply1", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498935.9121106, + "maxSpeed": 23000, + "lastSpeedStableChangeTime": 1682499092.4665174, + "configuredSpeed": 30, + "actualSpeed": 33, + "speedHwOverride": True, + "speedStable": True, + "label": "PowerSupply2/1", + } + ], + "speed": 30, + "label": "PowerSupply2", + }, + ], + "fanTraySlots": [ + { + "status": "ok", + "fans": [ + { + "status": "down", + "uptime": 1682498923.9303148, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0139885, + "configuredSpeed": 30, + "actualSpeed": 29, + "speedHwOverride": False, + "speedStable": True, + "label": "1/1", + } + ], + "speed": 30, + "label": "1", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9304729, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498939.9329433, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "2/1", + } + ], + "speed": 30, + "label": "2", + }, + { + "status": "ok", + "fans": [ + { + "status": "Not Inserted", + "uptime": 1682498923.9383528, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0140095, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "3/1", + } + ], + "speed": 30, + "label": "3", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9303904, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0140295, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "4/1", + } + ], + "speed": 30, + "label": "4", + }, + ], + "minFanSpeed": 0, + "currentZones": 1, + "configuredZones": 0, + "systemStatus": "CoolingKo", + } + ], + "inputs": {"states": ["ok", "Not Inserted"]}, + "expected": {"result": "failure", "messages": ["Fan 1/1 on Fan Tray 1 is: 'down'"]}, + }, + { + "name": "failure-power-supply", + "test": VerifyEnvironmentCooling, + "eos_data": [ + { + "defaultZones": False, + "numCoolingZones": [], + "coolingMode": "automatic", + "ambientTemperature": 24.5, + "shutdownOnInsufficientFans": True, + "airflowDirection": "frontToBackAirflow", + "overrideFanSpeed": 0, + "powerSupplySlots": [ + { + "status": "ok", + "fans": [ + { + "status": "down", + "uptime": 1682498937.0240965, + "maxSpeed": 23000, + "lastSpeedStableChangeTime": 1682499033.0403435, + "configuredSpeed": 30, + "actualSpeed": 33, + "speedHwOverride": True, + "speedStable": True, + "label": "PowerSupply1/1", + } + ], + "speed": 30, + "label": "PowerSupply1", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498935.9121106, + "maxSpeed": 23000, + "lastSpeedStableChangeTime": 1682499092.4665174, + "configuredSpeed": 30, + "actualSpeed": 33, + "speedHwOverride": True, + "speedStable": True, + "label": "PowerSupply2/1", + } + ], + "speed": 30, + "label": "PowerSupply2", + }, + ], + "fanTraySlots": [ + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9303148, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0139885, + "configuredSpeed": 30, + "actualSpeed": 29, + "speedHwOverride": False, + "speedStable": True, + "label": "1/1", + } + ], + "speed": 30, + "label": "1", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9304729, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498939.9329433, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "2/1", + } + ], + "speed": 30, + "label": "2", + }, + { + "status": "ok", + "fans": [ + { + "status": "Not Inserted", + "uptime": 1682498923.9383528, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0140095, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "3/1", + } + ], + "speed": 30, + "label": "3", + }, + { + "status": "ok", + "fans": [ + { + "status": "ok", + "uptime": 1682498923.9303904, + "maxSpeed": 17500, + "lastSpeedStableChangeTime": 1682498975.0140295, + "configuredSpeed": 30, + "actualSpeed": 30, + "speedHwOverride": False, + "speedStable": True, + "label": "4/1", + } + ], + "speed": 30, + "label": "4", + }, + ], + "minFanSpeed": 0, + "currentZones": 1, + "configuredZones": 0, + "systemStatus": "CoolingKo", + } + ], + "inputs": {"states": ["ok", "Not Inserted"]}, + "expected": {"result": "failure", "messages": ["Fan PowerSupply1/1 on PowerSupply PowerSupply1 is: 'down'"]}, + }, + { + "name": "success", + "test": VerifyEnvironmentPower, + "eos_data": [ + { + "powerSupplies": { + "1": { + "outputPower": 0.0, + "modelName": "PWR-500AC-F", + "capacity": 500.0, + "tempSensors": { + "TempSensorP1/2": {"status": "ok", "temperature": 0.0}, + "TempSensorP1/3": {"status": "ok", "temperature": 0.0}, + "TempSensorP1/1": {"status": "ok", "temperature": 0.0}, + }, + "fans": {"FanP1/1": {"status": "ok", "speed": 33}}, + "state": "ok", + "inputCurrent": 0.0, + "dominant": False, + "inputVoltage": 0.0, + "outputCurrent": 0.0, + "managed": True, + }, + "2": { + "outputPower": 117.375, + "uptime": 1682498935.9121966, + "modelName": "PWR-500AC-F", + "capacity": 500.0, + "tempSensors": { + "TempSensorP2/1": {"status": "ok", "temperature": 39.0}, + "TempSensorP2/3": {"status": "ok", "temperature": 43.0}, + "TempSensorP2/2": {"status": "ok", "temperature": 31.0}, + }, + "fans": {"FanP2/1": {"status": "ok", "speed": 33}}, + "state": "ok", + "inputCurrent": 0.572265625, + "dominant": False, + "inputVoltage": 232.5, + "outputCurrent": 9.828125, + "managed": True, + }, + } + } + ], + "inputs": {"states": ["ok"]}, + "expected": {"result": "success"}, + }, + { + "name": "success-additional-states", + "test": VerifyEnvironmentPower, + "eos_data": [ + { + "powerSupplies": { + "1": { + "outputPower": 0.0, + "modelName": "PWR-500AC-F", + "capacity": 500.0, + "tempSensors": { + "TempSensorP1/2": {"status": "ok", "temperature": 0.0}, + "TempSensorP1/3": {"status": "ok", "temperature": 0.0}, + "TempSensorP1/1": {"status": "ok", "temperature": 0.0}, + }, + "fans": {"FanP1/1": {"status": "ok", "speed": 33}}, + "state": "Not Inserted", + "inputCurrent": 0.0, + "dominant": False, + "inputVoltage": 0.0, + "outputCurrent": 0.0, + "managed": True, + }, + "2": { + "outputPower": 117.375, + "uptime": 1682498935.9121966, + "modelName": "PWR-500AC-F", + "capacity": 500.0, + "tempSensors": { + "TempSensorP2/1": {"status": "ok", "temperature": 39.0}, + "TempSensorP2/3": {"status": "ok", "temperature": 43.0}, + "TempSensorP2/2": {"status": "ok", "temperature": 31.0}, + }, + "fans": {"FanP2/1": {"status": "ok", "speed": 33}}, + "state": "ok", + "inputCurrent": 0.572265625, + "dominant": False, + "inputVoltage": 232.5, + "outputCurrent": 9.828125, + "managed": True, + }, + } + } + ], + "inputs": {"states": ["ok", "Not Inserted"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyEnvironmentPower, + "eos_data": [ + { + "powerSupplies": { + "1": { + "outputPower": 0.0, + "modelName": "PWR-500AC-F", + "capacity": 500.0, + "tempSensors": { + "TempSensorP1/2": {"status": "ok", "temperature": 0.0}, + "TempSensorP1/3": {"status": "ok", "temperature": 0.0}, + "TempSensorP1/1": {"status": "ok", "temperature": 0.0}, + }, + "fans": {"FanP1/1": {"status": "ok", "speed": 33}}, + "state": "powerLoss", + "inputCurrent": 0.0, + "dominant": False, + "inputVoltage": 0.0, + "outputCurrent": 0.0, + "managed": True, + }, + "2": { + "outputPower": 117.375, + "uptime": 1682498935.9121966, + "modelName": "PWR-500AC-F", + "capacity": 500.0, + "tempSensors": { + "TempSensorP2/1": {"status": "ok", "temperature": 39.0}, + "TempSensorP2/3": {"status": "ok", "temperature": 43.0}, + "TempSensorP2/2": {"status": "ok", "temperature": 31.0}, + }, + "fans": {"FanP2/1": {"status": "ok", "speed": 33}}, + "state": "ok", + "inputCurrent": 0.572265625, + "dominant": False, + "inputVoltage": 232.5, + "outputCurrent": 9.828125, + "managed": True, + }, + } + } + ], + "inputs": {"states": ["ok"]}, + "expected": {"result": "failure", "messages": ["The following power supplies status are not in the accepted states list: {'1': {'state': 'powerLoss'}}"]}, + }, + { + "name": "success", + "test": VerifyAdverseDrops, + "eos_data": [{"totalAdverseDrops": 0}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyAdverseDrops, + "eos_data": [{"totalAdverseDrops": 10}], + "inputs": None, + "expected": {"result": "failure", "messages": ["Device totalAdverseDrops counter is: '10'"]}, + }, +] diff --git a/tests/units/anta_tests/test_interfaces.py b/tests/units/anta_tests/test_interfaces.py new file mode 100644 index 0000000..5b0d845 --- /dev/null +++ b/tests/units/anta_tests/test_interfaces.py @@ -0,0 +1,1411 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Test inputs for anta.tests.hardware""" +from __future__ import annotations + +from typing import Any + +from anta.tests.interfaces import ( + VerifyIllegalLACP, + VerifyInterfaceDiscards, + VerifyInterfaceErrDisabled, + VerifyInterfaceErrors, + VerifyInterfaceIPv4, + VerifyInterfacesStatus, + VerifyInterfaceUtilization, + VerifyIPProxyARP, + VerifyIpVirtualRouterMac, + VerifyL2MTU, + VerifyL3MTU, + VerifyLoopbackCount, + VerifyPortChannels, + VerifyStormControlDrops, + VerifySVI, +) +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyInterfaceUtilization, + "eos_data": [ + """Port Name Intvl In Mbps % In Kpps Out Mbps % Out Kpps +Et1 5:00 0.0 0.0% 0 0.0 0.0% 0 +Et4 5:00 0.0 0.0% 0 0.0 0.0% 0 +""" + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyInterfaceUtilization, + "eos_data": [ + """Port Name Intvl In Mbps % In Kpps Out Mbps % Out Kpps +Et1 5:00 0.0 0.0% 0 0.0 80.0% 0 +Et4 5:00 0.0 99.9% 0 0.0 0.0% 0 +""" + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["The following interfaces have a usage > 75%: {'Et1': '80.0%', 'Et4': '99.9%'}"]}, + }, + { + "name": "success", + "test": VerifyInterfaceErrors, + "eos_data": [ + { + "interfaceErrorCounters": { + "Ethernet1": {"inErrors": 0, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0}, + "Ethernet6": {"inErrors": 0, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0}, + } + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure-multiple-intfs", + "test": VerifyInterfaceErrors, + "eos_data": [ + { + "interfaceErrorCounters": { + "Ethernet1": {"inErrors": 42, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0}, + "Ethernet6": {"inErrors": 0, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 666, "symbolErrors": 0}, + } + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "The following interface(s) have non-zero error counters: [{'Ethernet1': {'inErrors': 42, 'frameTooLongs': 0, 'outErrors': 0, 'frameTooShorts': 0," + " 'fcsErrors': 0, 'alignmentErrors': 0, 'symbolErrors': 0}}, {'Ethernet6': {'inErrors': 0, 'frameTooLongs': 0, 'outErrors': 0, 'frameTooShorts':" + " 0, 'fcsErrors': 0, 'alignmentErrors': 666, 'symbolErrors': 0}}]" + ], + }, + }, + { + "name": "failure-multiple-intfs-multiple-errors", + "test": VerifyInterfaceErrors, + "eos_data": [ + { + "interfaceErrorCounters": { + "Ethernet1": {"inErrors": 42, "frameTooLongs": 0, "outErrors": 10, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0}, + "Ethernet6": {"inErrors": 0, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 6, "symbolErrors": 10}, + } + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "The following interface(s) have non-zero error counters: [{'Ethernet1': {'inErrors': 42, 'frameTooLongs': 0, 'outErrors': 10, 'frameTooShorts': 0," + " 'fcsErrors': 0, 'alignmentErrors': 0, 'symbolErrors': 0}}, {'Ethernet6': {'inErrors': 0, 'frameTooLongs': 0, 'outErrors': 0, 'frameTooShorts':" + " 0, 'fcsErrors': 0, 'alignmentErrors': 6, 'symbolErrors': 10}}]" + ], + }, + }, + { + "name": "failure-single-intf-multiple-errors", + "test": VerifyInterfaceErrors, + "eos_data": [ + { + "interfaceErrorCounters": { + "Ethernet1": {"inErrors": 42, "frameTooLongs": 0, "outErrors": 2, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0}, + } + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "The following interface(s) have non-zero error counters: [{'Ethernet1': {'inErrors': 42, 'frameTooLongs': 0, 'outErrors': 2, 'frameTooShorts': 0," + " 'fcsErrors': 0, 'alignmentErrors': 0, 'symbolErrors': 0}}]" + ], + }, + }, + { + "name": "success", + "test": VerifyInterfaceDiscards, + "eos_data": [ + { + "inDiscardsTotal": 0, + "interfaces": { + "Ethernet2": {"outDiscards": 0, "inDiscards": 0}, + "Ethernet1": {"outDiscards": 0, "inDiscards": 0}, + }, + "outDiscardsTotal": 0, + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyInterfaceDiscards, + "eos_data": [ + { + "inDiscardsTotal": 0, + "interfaces": { + "Ethernet2": {"outDiscards": 42, "inDiscards": 0}, + "Ethernet1": {"outDiscards": 0, "inDiscards": 42}, + }, + "outDiscardsTotal": 0, + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "The following interfaces have non 0 discard counter(s): [{'Ethernet2': {'outDiscards': 42, 'inDiscards': 0}}," + " {'Ethernet1': {'outDiscards': 0, 'inDiscards': 42}}]" + ], + }, + }, + { + "name": "success", + "test": VerifyInterfaceErrDisabled, + "eos_data": [ + { + "interfaceStatuses": { + "Management1": { + "linkStatus": "connected", + }, + "Ethernet8": { + "linkStatus": "connected", + }, + } + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyInterfaceErrDisabled, + "eos_data": [ + { + "interfaceStatuses": { + "Management1": { + "linkStatus": "errdisabled", + }, + "Ethernet8": { + "linkStatus": "errdisabled", + }, + } + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["The following interfaces are in error disabled state: ['Management1', 'Ethernet8']"]}, + }, + { + "name": "success", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"}, + "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + } + } + ], + "inputs": {"interfaces": [{"name": "Ethernet2", "status": "adminDown"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-up-with-line-protocol-status", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "down"}, + } + } + ], + "inputs": {"interfaces": [{"name": "Ethernet8", "status": "up", "line_protocol_status": "down"}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-with-line-protocol-status", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet8": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "testing"}, + "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"}, + "Ethernet3.10": {"interfaceStatus": "down", "description": "", "lineProtocolStatus": "dormant"}, + } + } + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "status": "adminDown", "line_protocol_status": "down"}, + {"name": "Ethernet8", "status": "adminDown", "line_protocol_status": "testing"}, + {"name": "Ethernet3.10", "status": "down", "line_protocol_status": "dormant"}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "success-lower", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"}, + "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + } + } + ], + "inputs": {"interfaces": [{"name": "ethernet2", "status": "adminDown"}, {"name": "ethernet8", "status": "up"}, {"name": "ethernet3", "status": "up"}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-eth-name", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"}, + "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + } + } + ], + "inputs": {"interfaces": [{"name": "eth2", "status": "adminDown"}, {"name": "et8", "status": "up"}, {"name": "et3", "status": "up"}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-po-name", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Port-Channel100": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + } + } + ], + "inputs": {"interfaces": [{"name": "po100", "status": "up"}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-sub-interfaces", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet52/1.1963": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + } + } + ], + "inputs": {"interfaces": [{"name": "Ethernet52/1.1963", "status": "up"}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-transceiver-down", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet49/1": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "notPresent"}, + } + } + ], + "inputs": {"interfaces": [{"name": "Ethernet49/1", "status": "adminDown"}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-po-down", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Port-Channel100": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "lowerLayerDown"}, + } + } + ], + "inputs": {"interfaces": [{"name": "PortChannel100", "status": "adminDown"}]}, + "expected": {"result": "success"}, + }, + { + "name": "success-po-lowerlayerdown", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Port-Channel100": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "lowerLayerDown"}, + } + } + ], + "inputs": {"interfaces": [{"name": "Port-Channel100", "status": "adminDown", "line_protocol_status": "lowerLayerDown"}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-configured", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + } + } + ], + "inputs": {"interfaces": [{"name": "Ethernet2", "status": "up"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]}, + "expected": { + "result": "failure", + "messages": ["The following interface(s) are not configured: ['Ethernet8']"], + }, + }, + { + "name": "failure-status-down", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet8": {"interfaceStatus": "down", "description": "", "lineProtocolStatus": "down"}, + "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + } + } + ], + "inputs": {"interfaces": [{"name": "Ethernet2", "status": "up"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]}, + "expected": { + "result": "failure", + "messages": ["The following interface(s) are not in the expected state: ['Ethernet8 is down/down'"], + }, + }, + { + "name": "failure-proto-down", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "down"}, + "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + } + } + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "status": "up"}, + {"name": "Ethernet8", "status": "up"}, + {"name": "Ethernet3", "status": "up"}, + ] + }, + "expected": { + "result": "failure", + "messages": ["The following interface(s) are not in the expected state: ['Ethernet8 is up/down'"], + }, + }, + { + "name": "failure-po-status-down", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Port-Channel100": {"interfaceStatus": "down", "description": "", "lineProtocolStatus": "lowerLayerDown"}, + } + } + ], + "inputs": {"interfaces": [{"name": "PortChannel100", "status": "up"}]}, + "expected": { + "result": "failure", + "messages": ["The following interface(s) are not in the expected state: ['Port-Channel100 is down/lowerLayerDown'"], + }, + }, + { + "name": "failure-proto-unknown", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "down"}, + "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "unknown"}, + "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + } + } + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "status": "up", "line_protocol_status": "down"}, + {"name": "Ethernet8", "status": "up"}, + {"name": "Ethernet3", "status": "up"}, + ] + }, + "expected": { + "result": "failure", + "messages": ["The following interface(s) are not in the expected state: ['Ethernet2 is up/unknown'"], + }, + }, + { + "name": "success", + "test": VerifyStormControlDrops, + "eos_data": [ + { + "aggregateTrafficClasses": {}, + "interfaces": { + "Ethernet1": { + "trafficTypes": {"broadcast": {"level": 100, "thresholdType": "packetsPerSecond", "rate": 0, "drop": 0, "dormant": False}}, + "active": True, + "reason": "", + "errdisabled": False, + } + }, + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyStormControlDrops, + "eos_data": [ + { + "aggregateTrafficClasses": {}, + "interfaces": { + "Ethernet1": { + "trafficTypes": {"broadcast": {"level": 100, "thresholdType": "packetsPerSecond", "rate": 0, "drop": 666, "dormant": False}}, + "active": True, + "reason": "", + "errdisabled": False, + } + }, + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["The following interfaces have none 0 storm-control drop counters {'Ethernet1': {'broadcast': 666}}"]}, + }, + { + "name": "success", + "test": VerifyPortChannels, + "eos_data": [ + { + "portChannels": { + "Port-Channel42": { + "recircFeature": [], + "maxWeight": 16, + "minSpeed": "0 gbps", + "rxPorts": {}, + "currWeight": 0, + "minLinks": 0, + "inactivePorts": {}, + "activePorts": {}, + "inactiveLag": False, + } + } + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyPortChannels, + "eos_data": [ + { + "portChannels": { + "Port-Channel42": { + "recircFeature": [], + "maxWeight": 16, + "minSpeed": "0 gbps", + "rxPorts": {}, + "currWeight": 0, + "minLinks": 0, + "inactivePorts": {"Ethernet8": {"reasonUnconfigured": "waiting for LACP response"}}, + "activePorts": {}, + "inactiveLag": False, + } + } + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["The following port-channels have inactive port(s): ['Port-Channel42']"]}, + }, + { + "name": "success", + "test": VerifyIllegalLACP, + "eos_data": [ + { + "portChannels": { + "Port-Channel42": { + "interfaces": { + "Ethernet8": { + "actorPortStatus": "noAgg", + "illegalRxCount": 0, + "markerResponseTxCount": 0, + "markerResponseRxCount": 0, + "lacpdusRxCount": 0, + "lacpdusTxCount": 454, + "markersTxCount": 0, + "markersRxCount": 0, + } + } + } + }, + "orphanPorts": {}, + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyIllegalLACP, + "eos_data": [ + { + "portChannels": { + "Port-Channel42": { + "interfaces": { + "Ethernet8": { + "actorPortStatus": "noAgg", + "illegalRxCount": 666, + "markerResponseTxCount": 0, + "markerResponseRxCount": 0, + "lacpdusRxCount": 0, + "lacpdusTxCount": 454, + "markersTxCount": 0, + "markersRxCount": 0, + } + } + } + }, + "orphanPorts": {}, + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": ["The following port-channels have recieved illegal lacp packets on the following ports: [{'Port-Channel42': 'Ethernet8'}]"], + }, + }, + { + "name": "success", + "test": VerifyLoopbackCount, + "eos_data": [ + { + "interfaces": { + "Loopback42": { + "name": "Loopback42", + "interfaceStatus": "connected", + "interfaceAddress": {"ipAddr": {"maskLen": 0, "address": "0.0.0.0"}, "unnumberedIntf": "Vlan42"}, + "ipv4Routable240": False, + "lineProtocolStatus": "up", + "mtu": 65535, + }, + "Loopback666": { + "name": "Loopback666", + "interfaceStatus": "connected", + "interfaceAddress": {"ipAddr": {"maskLen": 32, "address": "6.6.6.6"}}, + "ipv4Routable240": False, + "lineProtocolStatus": "up", + "mtu": 65535, + }, + } + } + ], + "inputs": {"number": 2}, + "expected": {"result": "success"}, + }, + { + "name": "failure-loopback-down", + "test": VerifyLoopbackCount, + "eos_data": [ + { + "interfaces": { + "Loopback42": { + "name": "Loopback42", + "interfaceStatus": "connected", + "interfaceAddress": {"ipAddr": {"maskLen": 0, "address": "0.0.0.0"}, "unnumberedIntf": "Vlan42"}, + "ipv4Routable240": False, + "lineProtocolStatus": "up", + "mtu": 65535, + }, + "Loopback666": { + "name": "Loopback666", + "interfaceStatus": "connected", + "interfaceAddress": {"ipAddr": {"maskLen": 32, "address": "6.6.6.6"}}, + "ipv4Routable240": False, + "lineProtocolStatus": "down", + "mtu": 65535, + }, + } + } + ], + "inputs": {"number": 2}, + "expected": {"result": "failure", "messages": ["The following Loopbacks are not up: ['Loopback666']"]}, + }, + { + "name": "failure-count-loopback", + "test": VerifyLoopbackCount, + "eos_data": [ + { + "interfaces": { + "Loopback42": { + "name": "Loopback42", + "interfaceStatus": "connected", + "interfaceAddress": {"ipAddr": {"maskLen": 0, "address": "0.0.0.0"}, "unnumberedIntf": "Vlan42"}, + "ipv4Routable240": False, + "lineProtocolStatus": "up", + "mtu": 65535, + }, + } + } + ], + "inputs": {"number": 2}, + "expected": {"result": "failure", "messages": ["Found 1 Loopbacks when expecting 2"]}, + }, + { + "name": "success", + "test": VerifySVI, + "eos_data": [ + { + "interfaces": { + "Vlan42": { + "name": "Vlan42", + "interfaceStatus": "connected", + "interfaceAddress": {"ipAddr": {"maskLen": 24, "address": "11.11.11.11"}}, + "ipv4Routable240": False, + "lineProtocolStatus": "up", + "mtu": 1500, + } + } + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifySVI, + "eos_data": [ + { + "interfaces": { + "Vlan42": { + "name": "Vlan42", + "interfaceStatus": "notconnect", + "interfaceAddress": {"ipAddr": {"maskLen": 24, "address": "11.11.11.11"}}, + "ipv4Routable240": False, + "lineProtocolStatus": "lowerLayerDown", + "mtu": 1500, + } + } + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["The following SVIs are not up: ['Vlan42']"]}, + }, + { + "name": "success", + "test": VerifyL3MTU, + "eos_data": [ + { + "interfaces": { + "Ethernet2": { + "name": "Ethernet2", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1500, + "l3MtuConfigured": True, + "l2Mru": 0, + }, + "Ethernet10": { + "name": "Ethernet10", + "forwardingModel": "bridged", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1500, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Management1/1": { + "name": "Management0", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1500, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Port-Channel2": { + "name": "Port-Channel2", + "forwardingModel": "bridged", + "lineProtocolStatus": "lowerLayerDown", + "interfaceStatus": "notconnect", + "hardware": "portChannel", + "mtu": 1500, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Loopback0": { + "name": "Loopback0", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "loopback", + "mtu": 65535, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Vxlan1": { + "name": "Vxlan1", + "forwardingModel": "bridged", + "lineProtocolStatus": "down", + "interfaceStatus": "notconnect", + "hardware": "vxlan", + "mtu": 0, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + }, + } + ], + "inputs": {"mtu": 1500}, + "expected": {"result": "success"}, + }, + { + "name": "success", + "test": VerifyL3MTU, + "eos_data": [ + { + "interfaces": { + "Ethernet2": { + "name": "Ethernet2", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1500, + "l3MtuConfigured": True, + "l2Mru": 0, + }, + "Ethernet10": { + "name": "Ethernet10", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1501, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Management0": { + "name": "Management0", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1500, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Port-Channel2": { + "name": "Port-Channel2", + "forwardingModel": "bridged", + "lineProtocolStatus": "lowerLayerDown", + "interfaceStatus": "notconnect", + "hardware": "portChannel", + "mtu": 1500, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Loopback0": { + "name": "Loopback0", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "loopback", + "mtu": 65535, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Vxlan1": { + "name": "Vxlan1", + "forwardingModel": "bridged", + "lineProtocolStatus": "down", + "interfaceStatus": "notconnect", + "hardware": "vxlan", + "mtu": 0, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + }, + } + ], + "inputs": {"mtu": 1500, "ignored_interfaces": ["Loopback", "Port-Channel", "Management", "Vxlan"], "specific_mtu": [{"Ethernet10": 1501}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyL3MTU, + "eos_data": [ + { + "interfaces": { + "Ethernet2": { + "name": "Ethernet2", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1600, + "l3MtuConfigured": True, + "l2Mru": 0, + }, + "Ethernet10": { + "name": "Ethernet10", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1500, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Management0": { + "name": "Management0", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1500, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Port-Channel2": { + "name": "Port-Channel2", + "forwardingModel": "bridged", + "lineProtocolStatus": "lowerLayerDown", + "interfaceStatus": "notconnect", + "hardware": "portChannel", + "mtu": 1500, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Loopback0": { + "name": "Loopback0", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "loopback", + "mtu": 65535, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Vxlan1": { + "name": "Vxlan1", + "forwardingModel": "bridged", + "lineProtocolStatus": "down", + "interfaceStatus": "notconnect", + "hardware": "vxlan", + "mtu": 0, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + }, + } + ], + "inputs": {"mtu": 1500}, + "expected": {"result": "failure", "messages": ["Some interfaces do not have correct MTU configured:\n[{'Ethernet2': 1600}]"]}, + }, + { + "name": "success", + "test": VerifyL2MTU, + "eos_data": [ + { + "interfaces": { + "Ethernet2": { + "name": "Ethernet2", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1500, + "l3MtuConfigured": True, + "l2Mru": 0, + }, + "Ethernet10": { + "name": "Ethernet10", + "forwardingModel": "bridged", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 9214, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Management0": { + "name": "Management0", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1500, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Port-Channel2": { + "name": "Port-Channel2", + "forwardingModel": "bridged", + "lineProtocolStatus": "lowerLayerDown", + "interfaceStatus": "notconnect", + "hardware": "portChannel", + "mtu": 9214, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Loopback0": { + "name": "Loopback0", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "loopback", + "mtu": 65535, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Vxlan1": { + "name": "Vxlan1", + "forwardingModel": "bridged", + "lineProtocolStatus": "down", + "interfaceStatus": "notconnect", + "hardware": "vxlan", + "mtu": 0, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + }, + } + ], + "inputs": {"mtu": 9214}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyL2MTU, + "eos_data": [ + { + "interfaces": { + "Ethernet2": { + "name": "Ethernet2", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1600, + "l3MtuConfigured": True, + "l2Mru": 0, + }, + "Ethernet10": { + "name": "Ethernet10", + "forwardingModel": "bridged", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 9214, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Management0": { + "name": "Management0", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "ethernet", + "mtu": 1500, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Port-Channel2": { + "name": "Port-Channel2", + "forwardingModel": "bridged", + "lineProtocolStatus": "lowerLayerDown", + "interfaceStatus": "notconnect", + "hardware": "portChannel", + "mtu": 9214, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Loopback0": { + "name": "Loopback0", + "forwardingModel": "routed", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "hardware": "loopback", + "mtu": 65535, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + "Vxlan1": { + "name": "Vxlan1", + "forwardingModel": "bridged", + "lineProtocolStatus": "down", + "interfaceStatus": "notconnect", + "hardware": "vxlan", + "mtu": 0, + "l3MtuConfigured": False, + "l2Mru": 0, + }, + }, + } + ], + "inputs": {"mtu": 1500}, + "expected": {"result": "failure", "messages": ["Some L2 interfaces do not have correct MTU configured:\n[{'Ethernet10': 9214}, {'Port-Channel2': 9214}]"]}, + }, + { + "name": "success", + "test": VerifyIPProxyARP, + "eos_data": [ + { + "interfaces": { + "Ethernet1": { + "name": "Ethernet1", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "mtu": 1500, + "interfaceAddressBrief": {"ipAddr": {"address": "10.1.0.0", "maskLen": 31}}, + "ipv4Routable240": False, + "ipv4Routable0": False, + "enabled": True, + "description": "P2P_LINK_TO_NW-CORE_Ethernet1", + "proxyArp": True, + "localProxyArp": False, + "gratuitousArp": False, + "vrf": "default", + "urpf": "disable", + "addresslessForwarding": "isInvalid", + "directedBroadcastEnabled": False, + "maxMssIngress": 0, + "maxMssEgress": 0, + } + } + }, + { + "interfaces": { + "Ethernet2": { + "name": "Ethernet2", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "mtu": 1500, + "interfaceAddressBrief": {"ipAddr": {"address": "10.1.0.2", "maskLen": 31}}, + "ipv4Routable240": False, + "ipv4Routable0": False, + "enabled": True, + "description": "P2P_LINK_TO_SW-CORE_Ethernet1", + "proxyArp": True, + "localProxyArp": False, + "gratuitousArp": False, + "vrf": "default", + "urpf": "disable", + "addresslessForwarding": "isInvalid", + "directedBroadcastEnabled": False, + "maxMssIngress": 0, + "maxMssEgress": 0, + } + } + }, + ], + "inputs": {"interfaces": ["Ethernet1", "Ethernet2"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyIPProxyARP, + "eos_data": [ + { + "interfaces": { + "Ethernet1": { + "name": "Ethernet1", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "mtu": 1500, + "interfaceAddressBrief": {"ipAddr": {"address": "10.1.0.0", "maskLen": 31}}, + "ipv4Routable240": False, + "ipv4Routable0": False, + "enabled": True, + "description": "P2P_LINK_TO_NW-CORE_Ethernet1", + "proxyArp": True, + "localProxyArp": False, + "gratuitousArp": False, + "vrf": "default", + "urpf": "disable", + "addresslessForwarding": "isInvalid", + "directedBroadcastEnabled": False, + "maxMssIngress": 0, + "maxMssEgress": 0, + } + } + }, + { + "interfaces": { + "Ethernet2": { + "name": "Ethernet2", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "mtu": 1500, + "interfaceAddressBrief": {"ipAddr": {"address": "10.1.0.2", "maskLen": 31}}, + "ipv4Routable240": False, + "ipv4Routable0": False, + "enabled": True, + "description": "P2P_LINK_TO_SW-CORE_Ethernet1", + "proxyArp": False, + "localProxyArp": False, + "gratuitousArp": False, + "vrf": "default", + "urpf": "disable", + "addresslessForwarding": "isInvalid", + "directedBroadcastEnabled": False, + "maxMssIngress": 0, + "maxMssEgress": 0, + } + } + }, + ], + "inputs": {"interfaces": ["Ethernet1", "Ethernet2"]}, + "expected": {"result": "failure", "messages": ["The following interface(s) have Proxy-ARP disabled: ['Ethernet2']"]}, + }, + { + "name": "success", + "test": VerifyInterfaceIPv4, + "eos_data": [ + { + "interfaces": { + "Ethernet2": { + "interfaceAddress": { + "primaryIp": {"address": "172.30.11.0", "maskLen": 31}, + "secondaryIpsOrderedList": [{"address": "10.10.10.0", "maskLen": 31}, {"address": "10.10.10.10", "maskLen": 31}], + } + } + } + }, + { + "interfaces": { + "Ethernet12": { + "interfaceAddress": { + "primaryIp": {"address": "172.30.11.10", "maskLen": 31}, + "secondaryIpsOrderedList": [{"address": "10.10.10.10", "maskLen": 31}, {"address": "10.10.10.20", "maskLen": 31}], + } + } + } + }, + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "primary_ip": "172.30.11.0/31", "secondary_ips": ["10.10.10.0/31", "10.10.10.10/31"]}, + {"name": "Ethernet12", "primary_ip": "172.30.11.10/31", "secondary_ips": ["10.10.10.10/31", "10.10.10.20/31"]}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "success-without-secondary-ip", + "test": VerifyInterfaceIPv4, + "eos_data": [ + { + "interfaces": { + "Ethernet2": { + "interfaceAddress": { + "primaryIp": {"address": "172.30.11.0", "maskLen": 31}, + "secondaryIpsOrderedList": [], + } + } + } + }, + { + "interfaces": { + "Ethernet12": { + "interfaceAddress": { + "primaryIp": {"address": "172.30.11.10", "maskLen": 31}, + "secondaryIpsOrderedList": [], + } + } + } + }, + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "primary_ip": "172.30.11.0/31"}, + {"name": "Ethernet12", "primary_ip": "172.30.11.10/31"}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-l3-interface", + "test": VerifyInterfaceIPv4, + "eos_data": [{"interfaces": {"Ethernet2": {"interfaceAddress": {}}}}, {"interfaces": {"Ethernet12": {"interfaceAddress": {}}}}], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "primary_ip": "172.30.11.0/31", "secondary_ips": ["10.10.10.0/31", "10.10.10.10/31"]}, + {"name": "Ethernet12", "primary_ip": "172.30.11.20/31", "secondary_ips": ["10.10.11.0/31", "10.10.11.10/31"]}, + ] + }, + "expected": { + "result": "failure", + "messages": ["For interface `Ethernet2`, IP address is not configured.", "For interface `Ethernet12`, IP address is not configured."], + }, + }, + { + "name": "failure-ip-address-not-configured", + "test": VerifyInterfaceIPv4, + "eos_data": [ + { + "interfaces": { + "Ethernet2": { + "interfaceAddress": { + "primaryIp": {"address": "0.0.0.0", "maskLen": 0}, + "secondaryIpsOrderedList": [], + } + } + } + }, + { + "interfaces": { + "Ethernet12": { + "interfaceAddress": { + "primaryIp": {"address": "0.0.0.0", "maskLen": 0}, + "secondaryIpsOrderedList": [], + } + } + } + }, + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "primary_ip": "172.30.11.0/31", "secondary_ips": ["10.10.10.0/31", "10.10.10.10/31"]}, + {"name": "Ethernet12", "primary_ip": "172.30.11.10/31", "secondary_ips": ["10.10.11.0/31", "10.10.11.10/31"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "For interface `Ethernet2`, The expected primary IP address is `172.30.11.0/31`, but the actual primary IP address is `0.0.0.0/0`. " + "The expected secondary IP addresses are `['10.10.10.0/31', '10.10.10.10/31']`, but the actual secondary IP address is not configured.", + "For interface `Ethernet12`, The expected primary IP address is `172.30.11.10/31`, but the actual primary IP address is `0.0.0.0/0`. " + "The expected secondary IP addresses are `['10.10.11.0/31', '10.10.11.10/31']`, but the actual secondary IP address is not configured.", + ], + }, + }, + { + "name": "failure-ip-address-missmatch", + "test": VerifyInterfaceIPv4, + "eos_data": [ + { + "interfaces": { + "Ethernet2": { + "interfaceAddress": { + "primaryIp": {"address": "172.30.11.0", "maskLen": 31}, + "secondaryIpsOrderedList": [{"address": "10.10.10.0", "maskLen": 31}, {"address": "10.10.10.10", "maskLen": 31}], + } + } + } + }, + { + "interfaces": { + "Ethernet3": { + "interfaceAddress": { + "primaryIp": {"address": "172.30.10.10", "maskLen": 31}, + "secondaryIpsOrderedList": [{"address": "10.10.11.0", "maskLen": 31}, {"address": "10.11.11.10", "maskLen": 31}], + } + } + } + }, + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "primary_ip": "172.30.11.2/31", "secondary_ips": ["10.10.10.20/31", "10.10.10.30/31"]}, + {"name": "Ethernet3", "primary_ip": "172.30.10.2/31", "secondary_ips": ["10.10.11.0/31", "10.10.11.10/31"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "For interface `Ethernet2`, The expected primary IP address is `172.30.11.2/31`, but the actual primary IP address is `172.30.11.0/31`. " + "The expected secondary IP addresses are `['10.10.10.20/31', '10.10.10.30/31']`, but the actual secondary IP addresses are " + "`['10.10.10.0/31', '10.10.10.10/31']`.", + "For interface `Ethernet3`, The expected primary IP address is `172.30.10.2/31`, but the actual primary IP address is `172.30.10.10/31`. " + "The expected secondary IP addresses are `['10.10.11.0/31', '10.10.11.10/31']`, but the actual secondary IP addresses are " + "`['10.10.11.0/31', '10.11.11.10/31']`.", + ], + }, + }, + { + "name": "failure-secondary-ip-address", + "test": VerifyInterfaceIPv4, + "eos_data": [ + { + "interfaces": { + "Ethernet2": { + "interfaceAddress": { + "primaryIp": {"address": "172.30.11.0", "maskLen": 31}, + "secondaryIpsOrderedList": [], + } + } + } + }, + { + "interfaces": { + "Ethernet3": { + "interfaceAddress": { + "primaryIp": {"address": "172.30.10.10", "maskLen": 31}, + "secondaryIpsOrderedList": [{"address": "10.10.11.0", "maskLen": 31}, {"address": "10.11.11.10", "maskLen": 31}], + } + } + } + }, + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "primary_ip": "172.30.11.2/31", "secondary_ips": ["10.10.10.20/31", "10.10.10.30/31"]}, + {"name": "Ethernet3", "primary_ip": "172.30.10.2/31", "secondary_ips": ["10.10.11.0/31", "10.10.11.10/31"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "For interface `Ethernet2`, The expected primary IP address is `172.30.11.2/31`, but the actual primary IP address is `172.30.11.0/31`. " + "The expected secondary IP addresses are `['10.10.10.20/31', '10.10.10.30/31']`, but the actual secondary IP address is not configured.", + "For interface `Ethernet3`, The expected primary IP address is `172.30.10.2/31`, but the actual primary IP address is `172.30.10.10/31`. " + "The expected secondary IP addresses are `['10.10.11.0/31', '10.10.11.10/31']`, but the actual secondary IP addresses are " + "`['10.10.11.0/31', '10.11.11.10/31']`.", + ], + }, + }, + { + "name": "success", + "test": VerifyIpVirtualRouterMac, + "eos_data": [ + { + "virtualMacs": [ + { + "macAddress": "00:1c:73:00:dc:01", + } + ], + } + ], + "inputs": {"mac_address": "00:1c:73:00:dc:01"}, + "expected": {"result": "success"}, + }, + { + "name": "faliure-incorrect-mac-address", + "test": VerifyIpVirtualRouterMac, + "eos_data": [ + { + "virtualMacs": [ + { + "macAddress": "00:00:00:00:00:00", + } + ], + } + ], + "inputs": {"mac_address": "00:1c:73:00:dc:01"}, + "expected": {"result": "failure", "messages": ["IP virtual router MAC address `00:1c:73:00:dc:01` is not configured."]}, + }, +] diff --git a/tests/units/anta_tests/test_lanz.py b/tests/units/anta_tests/test_lanz.py new file mode 100644 index 0000000..932d1ac --- /dev/null +++ b/tests/units/anta_tests/test_lanz.py @@ -0,0 +1,27 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Data for testing anta.tests.configuration""" +from __future__ import annotations + +from typing import Any + +from anta.tests.lanz import VerifyLANZ +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyLANZ, + "eos_data": [{"lanzEnabled": True}], + "inputs": None, + "expected": {"result": "success", "messages": ["LANZ is enabled"]}, + }, + { + "name": "failure", + "test": VerifyLANZ, + "eos_data": [{"lanzEnabled": False}], + "inputs": None, + "expected": {"result": "failure", "messages": ["LANZ is not enabled"]}, + }, +] diff --git a/tests/units/anta_tests/test_logging.py b/tests/units/anta_tests/test_logging.py new file mode 100644 index 0000000..8ac2323 --- /dev/null +++ b/tests/units/anta_tests/test_logging.py @@ -0,0 +1,254 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Data for testing anta.tests.logging""" +from __future__ import annotations + +from typing import Any + +from anta.tests.logging import ( + VerifyLoggingAccounting, + VerifyLoggingErrors, + VerifyLoggingHostname, + VerifyLoggingHosts, + VerifyLoggingLogsGeneration, + VerifyLoggingPersistent, + VerifyLoggingSourceIntf, + VerifyLoggingTimestamp, +) +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyLoggingPersistent, + "eos_data": [ + "Persistent logging: level debugging\n", + """Directory of flash:/persist/messages + + -rw- 9948 May 10 13:54 messages + + 33214693376 bytes total (10081136640 bytes free) + + """, + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure-disabled", + "test": VerifyLoggingPersistent, + "eos_data": [ + "Persistent logging: disabled\n", + """Directory of flash:/persist/messages + + -rw- 0 Apr 13 16:29 messages + + 33214693376 bytes total (10082168832 bytes free) + + """, + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Persistent logging is disabled"]}, + }, + { + "name": "failure-not-saved", + "test": VerifyLoggingPersistent, + "eos_data": [ + "Persistent logging: level debugging\n", + """Directory of flash:/persist/messages + + -rw- 0 Apr 13 16:29 messages + + 33214693376 bytes total (10082168832 bytes free) + + """, + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["No persistent logs are saved in flash"]}, + }, + { + "name": "success", + "test": VerifyLoggingSourceIntf, + "eos_data": [ + """Trap logging: level informational + Logging source-interface 'Management0', IP Address 172.20.20.12 in VRF MGMT + Logging to '10.22.10.92' port 514 in VRF MGMT via udp + Logging to '10.22.10.93' port 514 in VRF MGMT via tcp + Logging to '10.22.10.94' port 911 in VRF MGMT via udp + + """ + ], + "inputs": {"interface": "Management0", "vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-intf", + "test": VerifyLoggingSourceIntf, + "eos_data": [ + """Trap logging: level informational + Logging source-interface 'Management1', IP Address 172.20.20.12 in VRF MGMT + Logging to '10.22.10.92' port 514 in VRF MGMT via udp + Logging to '10.22.10.93' port 514 in VRF MGMT via tcp + Logging to '10.22.10.94' port 911 in VRF MGMT via udp + + """ + ], + "inputs": {"interface": "Management0", "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Source-interface 'Management0' is not configured in VRF MGMT"]}, + }, + { + "name": "failure-vrf", + "test": VerifyLoggingSourceIntf, + "eos_data": [ + """Trap logging: level informational + Logging source-interface 'Management0', IP Address 172.20.20.12 in VRF default + Logging to '10.22.10.92' port 514 in VRF MGMT via udp + Logging to '10.22.10.93' port 514 in VRF MGMT via tcp + Logging to '10.22.10.94' port 911 in VRF MGMT via udp + + """ + ], + "inputs": {"interface": "Management0", "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Source-interface 'Management0' is not configured in VRF MGMT"]}, + }, + { + "name": "success", + "test": VerifyLoggingHosts, + "eos_data": [ + """Trap logging: level informational + Logging source-interface 'Management0', IP Address 172.20.20.12 in VRF MGMT + Logging to '10.22.10.92' port 514 in VRF MGMT via udp + Logging to '10.22.10.93' port 514 in VRF MGMT via tcp + Logging to '10.22.10.94' port 911 in VRF MGMT via udp + + """ + ], + "inputs": {"hosts": ["10.22.10.92", "10.22.10.93", "10.22.10.94"], "vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-hosts", + "test": VerifyLoggingHosts, + "eos_data": [ + """Trap logging: level informational + Logging source-interface 'Management1', IP Address 172.20.20.12 in VRF MGMT + Logging to '10.22.10.92' port 514 in VRF MGMT via udp + Logging to '10.22.10.103' port 514 in VRF MGMT via tcp + Logging to '10.22.10.104' port 911 in VRF MGMT via udp + + """ + ], + "inputs": {"hosts": ["10.22.10.92", "10.22.10.93", "10.22.10.94"], "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Syslog servers ['10.22.10.93', '10.22.10.94'] are not configured in VRF MGMT"]}, + }, + { + "name": "failure-vrf", + "test": VerifyLoggingHosts, + "eos_data": [ + """Trap logging: level informational + Logging source-interface 'Management0', IP Address 172.20.20.12 in VRF MGMT + Logging to '10.22.10.92' port 514 in VRF MGMT via udp + Logging to '10.22.10.93' port 514 in VRF default via tcp + Logging to '10.22.10.94' port 911 in VRF default via udp + + """ + ], + "inputs": {"hosts": ["10.22.10.92", "10.22.10.93", "10.22.10.94"], "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Syslog servers ['10.22.10.93', '10.22.10.94'] are not configured in VRF MGMT"]}, + }, + { + "name": "success", + "test": VerifyLoggingLogsGeneration, + "eos_data": [ + "", + "2023-05-10T13:54:21.463497-05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: " + "Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingLogsGeneration validation\n", + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyLoggingLogsGeneration, + "eos_data": ["", "Log Buffer:\n"], + "inputs": None, + "expected": {"result": "failure", "messages": ["Logs are not generated"]}, + }, + { + "name": "success", + "test": VerifyLoggingHostname, + "eos_data": [ + {"hostname": "NW-CORE", "fqdn": "NW-CORE.example.org"}, + "", + "2023-05-10T15:41:44.701810-05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: " + "Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingHostname validation\n", + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyLoggingHostname, + "eos_data": [ + {"hostname": "NW-CORE", "fqdn": "NW-CORE.example.org"}, + "", + "2023-05-10T13:54:21.463497-05:00 NW-CORE ConfigAgent: %SYS-6-LOGMSG_INFO: " + "Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingLogsHostname validation\n", + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Logs are not generated with the device FQDN"]}, + }, + { + "name": "success", + "test": VerifyLoggingTimestamp, + "eos_data": [ + "", + "2023-05-10T15:41:44.680813-05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: " + "Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingTimestamp validation\n", + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyLoggingTimestamp, + "eos_data": [ + "", + "May 10 13:54:22 NE-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: " + "Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingTimestamp validation\n", + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Logs are not generated with the appropriate timestamp format"]}, + }, + { + "name": "success", + "test": VerifyLoggingAccounting, + "eos_data": ["2023 May 10 15:50:31 arista command-api 10.22.1.107 stop service=shell priv-lvl=15 cmd=show aaa accounting logs | tail\n"], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyLoggingAccounting, + "eos_data": ["2023 May 10 15:52:26 arista vty14 10.22.1.107 stop service=shell priv-lvl=15 cmd=show bgp summary\n"], + "inputs": None, + "expected": {"result": "failure", "messages": ["AAA accounting logs are not generated"]}, + }, + { + "name": "success", + "test": VerifyLoggingErrors, + "eos_data": [""], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyLoggingErrors, + "eos_data": [ + "Aug 2 19:57:42 DC1-LEAF1A Mlag: %FWK-3-SOCKET_CLOSE_REMOTE: Connection to Mlag (pid:27200) at tbt://192.168.0.1:4432/+n closed by peer (EOF)" + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Device has reported syslog messages with a severity of ERRORS or higher"]}, + }, +] diff --git a/tests/units/anta_tests/test_mlag.py b/tests/units/anta_tests/test_mlag.py new file mode 100644 index 0000000..90f3c7a --- /dev/null +++ b/tests/units/anta_tests/test_mlag.py @@ -0,0 +1,343 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.mlag.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.mlag import VerifyMlagConfigSanity, VerifyMlagDualPrimary, VerifyMlagInterfaces, VerifyMlagPrimaryPriority, VerifyMlagReloadDelay, VerifyMlagStatus +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyMlagStatus, + "eos_data": [{"state": "active", "negStatus": "connected", "peerLinkStatus": "up", "localIntfStatus": "up"}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "skipped", + "test": VerifyMlagStatus, + "eos_data": [ + { + "state": "disabled", + } + ], + "inputs": None, + "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, + }, + { + "name": "failure", + "test": VerifyMlagStatus, + "eos_data": [{"state": "active", "negStatus": "connected", "peerLinkStatus": "down", "localIntfStatus": "up"}], + "inputs": None, + "expected": { + "result": "failure", + "messages": ["MLAG status is not OK: {'state': 'active', 'negStatus': 'connected', 'localIntfStatus': 'up', 'peerLinkStatus': 'down'}"], + }, + }, + { + "name": "success", + "test": VerifyMlagInterfaces, + "eos_data": [ + { + "state": "active", + "mlagPorts": {"Disabled": 0, "Configured": 0, "Inactive": 0, "Active-partial": 0, "Active-full": 1}, + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "skipped", + "test": VerifyMlagInterfaces, + "eos_data": [ + { + "state": "disabled", + } + ], + "inputs": None, + "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, + }, + { + "name": "failure-active-partial", + "test": VerifyMlagInterfaces, + "eos_data": [ + { + "state": "active", + "mlagPorts": {"Disabled": 0, "Configured": 0, "Inactive": 0, "Active-partial": 1, "Active-full": 1}, + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": ["MLAG status is not OK: {'Disabled': 0, 'Configured': 0, 'Inactive': 0, 'Active-partial': 1, 'Active-full': 1}"], + }, + }, + { + "name": "failure-inactive", + "test": VerifyMlagInterfaces, + "eos_data": [ + { + "state": "active", + "mlagPorts": {"Disabled": 0, "Configured": 0, "Inactive": 1, "Active-partial": 1, "Active-full": 1}, + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": ["MLAG status is not OK: {'Disabled': 0, 'Configured': 0, 'Inactive': 1, 'Active-partial': 1, 'Active-full': 1}"], + }, + }, + { + "name": "success", + "test": VerifyMlagConfigSanity, + "eos_data": [{"globalConfiguration": {}, "interfaceConfiguration": {}, "mlagActive": True, "mlagConnected": True}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "skipped", + "test": VerifyMlagConfigSanity, + "eos_data": [ + { + "mlagActive": False, + } + ], + "inputs": None, + "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, + }, + { + "name": "error", + "test": VerifyMlagConfigSanity, + "eos_data": [ + { + "dummy": False, + } + ], + "inputs": None, + "expected": {"result": "error", "messages": ["Incorrect JSON response - 'mlagActive' state was not found"]}, + }, + { + "name": "failure-global", + "test": VerifyMlagConfigSanity, + "eos_data": [ + { + "globalConfiguration": {"mlag": {"globalParameters": {"dual-primary-detection-delay": {"localValue": "0", "peerValue": "200"}}}}, + "interfaceConfiguration": {}, + "mlagActive": True, + "mlagConnected": True, + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "MLAG config-sanity returned inconsistencies: " + "{'globalConfiguration': {'mlag': {'globalParameters': " + "{'dual-primary-detection-delay': {'localValue': '0', 'peerValue': '200'}}}}, " + "'interfaceConfiguration': {}}" + ], + }, + }, + { + "name": "failure-interface", + "test": VerifyMlagConfigSanity, + "eos_data": [ + { + "globalConfiguration": {}, + "interfaceConfiguration": {"trunk-native-vlan mlag30": {"interface": {"Port-Channel30": {"localValue": "123", "peerValue": "3700"}}}}, + "mlagActive": True, + "mlagConnected": True, + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "MLAG config-sanity returned inconsistencies: " + "{'globalConfiguration': {}, " + "'interfaceConfiguration': {'trunk-native-vlan mlag30': " + "{'interface': {'Port-Channel30': {'localValue': '123', 'peerValue': '3700'}}}}}" + ], + }, + }, + { + "name": "success", + "test": VerifyMlagReloadDelay, + "eos_data": [{"state": "active", "reloadDelay": 300, "reloadDelayNonMlag": 330}], + "inputs": {"reload_delay": 300, "reload_delay_non_mlag": 330}, + "expected": {"result": "success"}, + }, + { + "name": "skipped-disabled", + "test": VerifyMlagReloadDelay, + "eos_data": [ + { + "state": "disabled", + } + ], + "inputs": {"reload_delay": 300, "reload_delay_non_mlag": 330}, + "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, + }, + { + "name": "failure", + "test": VerifyMlagReloadDelay, + "eos_data": [{"state": "active", "reloadDelay": 400, "reloadDelayNonMlag": 430}], + "inputs": {"reload_delay": 300, "reload_delay_non_mlag": 330}, + "expected": {"result": "failure", "messages": ["The reload-delay parameters are not configured properly: {'reloadDelay': 400, 'reloadDelayNonMlag': 430}"]}, + }, + { + "name": "success", + "test": VerifyMlagDualPrimary, + "eos_data": [ + { + "state": "active", + "dualPrimaryDetectionState": "configured", + "dualPrimaryPortsErrdisabled": False, + "dualPrimaryMlagRecoveryDelay": 60, + "dualPrimaryNonMlagRecoveryDelay": 0, + "detail": {"dualPrimaryDetectionDelay": 200, "dualPrimaryAction": "none"}, + } + ], + "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, + "expected": {"result": "success"}, + }, + { + "name": "skipped-disabled", + "test": VerifyMlagDualPrimary, + "eos_data": [ + { + "state": "disabled", + } + ], + "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, + "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, + }, + { + "name": "failure-disabled", + "test": VerifyMlagDualPrimary, + "eos_data": [ + { + "state": "active", + "dualPrimaryDetectionState": "disabled", + "dualPrimaryPortsErrdisabled": False, + } + ], + "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, + "expected": {"result": "failure", "messages": ["Dual-primary detection is disabled"]}, + }, + { + "name": "failure-wrong-timers", + "test": VerifyMlagDualPrimary, + "eos_data": [ + { + "state": "active", + "dualPrimaryDetectionState": "configured", + "dualPrimaryPortsErrdisabled": False, + "dualPrimaryMlagRecoveryDelay": 160, + "dualPrimaryNonMlagRecoveryDelay": 0, + "detail": {"dualPrimaryDetectionDelay": 300, "dualPrimaryAction": "none"}, + } + ], + "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, + "expected": { + "result": "failure", + "messages": [ + ( + "The dual-primary parameters are not configured properly: " + "{'detail.dualPrimaryDetectionDelay': 300, " + "'detail.dualPrimaryAction': 'none', " + "'dualPrimaryMlagRecoveryDelay': 160, " + "'dualPrimaryNonMlagRecoveryDelay': 0}" + ) + ], + }, + }, + { + "name": "failure-wrong-action", + "test": VerifyMlagDualPrimary, + "eos_data": [ + { + "state": "active", + "dualPrimaryDetectionState": "configured", + "dualPrimaryPortsErrdisabled": False, + "dualPrimaryMlagRecoveryDelay": 60, + "dualPrimaryNonMlagRecoveryDelay": 0, + "detail": {"dualPrimaryDetectionDelay": 200, "dualPrimaryAction": "none"}, + } + ], + "inputs": {"detection_delay": 200, "errdisabled": True, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, + "expected": { + "result": "failure", + "messages": [ + ( + "The dual-primary parameters are not configured properly: " + "{'detail.dualPrimaryDetectionDelay': 200, " + "'detail.dualPrimaryAction': 'none', " + "'dualPrimaryMlagRecoveryDelay': 60, " + "'dualPrimaryNonMlagRecoveryDelay': 0}" + ) + ], + }, + }, + { + "name": "success", + "test": VerifyMlagPrimaryPriority, + "eos_data": [ + { + "state": "active", + "detail": {"mlagState": "primary", "primaryPriority": 32767}, + } + ], + "inputs": { + "primary_priority": 32767, + }, + "expected": {"result": "success"}, + }, + { + "name": "skipped-disabled", + "test": VerifyMlagPrimaryPriority, + "eos_data": [ + { + "state": "disabled", + } + ], + "inputs": {"primary_priority": 32767}, + "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, + }, + { + "name": "failure-not-primary", + "test": VerifyMlagPrimaryPriority, + "eos_data": [ + { + "state": "active", + "detail": {"mlagState": "secondary", "primaryPriority": 32767}, + } + ], + "inputs": {"primary_priority": 32767}, + "expected": { + "result": "failure", + "messages": ["The device is not set as MLAG primary."], + }, + }, + { + "name": "failure-incorrect-priority", + "test": VerifyMlagPrimaryPriority, + "eos_data": [ + { + "state": "active", + "detail": {"mlagState": "secondary", "primaryPriority": 32767}, + } + ], + "inputs": {"primary_priority": 1}, + "expected": { + "result": "failure", + "messages": ["The device is not set as MLAG primary.", "The primary priority does not match expected. Expected `1`, but found `32767` instead."], + }, + }, +] diff --git a/tests/units/anta_tests/test_multicast.py b/tests/units/anta_tests/test_multicast.py new file mode 100644 index 0000000..9276a9f --- /dev/null +++ b/tests/units/anta_tests/test_multicast.py @@ -0,0 +1,175 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Test inputs for anta.tests.multicast""" +from __future__ import annotations + +from typing import Any + +from anta.tests.multicast import VerifyIGMPSnoopingGlobal, VerifyIGMPSnoopingVlans +from tests.lib.anta import test # noqa: F401; pylint: disable=unused-import + +DATA: list[dict[str, Any]] = [ + { + "name": "success-enabled", + "test": VerifyIGMPSnoopingVlans, + "eos_data": [ + { + "reportFlooding": "disabled", + "igmpSnoopingState": "enabled", + "vlans": { + "1": { + "reportFlooding": "disabled", + "proxyActive": False, + "groupsOverrun": False, + "multicastRouterLearningMode": "pim-dvmrp", + "igmpSnoopingState": "enabled", + "pruningActive": False, + "maxGroups": 65534, + "immediateLeave": "default", + "floodingTraffic": True, + }, + "42": { + "reportFlooding": "disabled", + "proxyActive": False, + "groupsOverrun": False, + "multicastRouterLearningMode": "pim-dvmrp", + "igmpSnoopingState": "enabled", + "pruningActive": False, + "maxGroups": 65534, + "immediateLeave": "default", + "floodingTraffic": True, + }, + }, + "robustness": 2, + "immediateLeave": "enabled", + "reportFloodingSwitchPorts": [], + } + ], + "inputs": {"vlans": {1: True, 42: True}}, + "expected": {"result": "success"}, + }, + { + "name": "success-disabled", + "test": VerifyIGMPSnoopingVlans, + "eos_data": [ + { + "reportFlooding": "disabled", + "igmpSnoopingState": "enabled", + "vlans": { + "42": { + "reportFlooding": "disabled", + "proxyActive": False, + "groupsOverrun": False, + "multicastRouterLearningMode": "pim-dvmrp", + "igmpSnoopingState": "disabled", + "pruningActive": False, + "maxGroups": 65534, + "immediateLeave": "default", + "floodingTraffic": True, + } + }, + "robustness": 2, + "immediateLeave": "enabled", + "reportFloodingSwitchPorts": [], + } + ], + "inputs": {"vlans": {42: False}}, + "expected": {"result": "success"}, + }, + { + "name": "failure-missing-vlan", + "test": VerifyIGMPSnoopingVlans, + "eos_data": [ + { + "reportFlooding": "disabled", + "igmpSnoopingState": "enabled", + "vlans": { + "1": { + "reportFlooding": "disabled", + "proxyActive": False, + "groupsOverrun": False, + "multicastRouterLearningMode": "pim-dvmrp", + "igmpSnoopingState": "enabled", + "pruningActive": False, + "maxGroups": 65534, + "immediateLeave": "default", + "floodingTraffic": True, + }, + }, + "robustness": 2, + "immediateLeave": "enabled", + "reportFloodingSwitchPorts": [], + } + ], + "inputs": {"vlans": {1: False, 42: False}}, + "expected": {"result": "failure", "messages": ["IGMP state for vlan 1 is enabled", "Supplied vlan 42 is not present on the device."]}, + }, + { + "name": "failure-wrong-state", + "test": VerifyIGMPSnoopingVlans, + "eos_data": [ + { + "reportFlooding": "disabled", + "igmpSnoopingState": "enabled", + "vlans": { + "1": { + "reportFlooding": "disabled", + "proxyActive": False, + "groupsOverrun": False, + "multicastRouterLearningMode": "pim-dvmrp", + "igmpSnoopingState": "disabled", + "pruningActive": False, + "maxGroups": 65534, + "immediateLeave": "default", + "floodingTraffic": True, + }, + }, + "robustness": 2, + "immediateLeave": "enabled", + "reportFloodingSwitchPorts": [], + } + ], + "inputs": {"vlans": {1: True}}, + "expected": {"result": "failure", "messages": ["IGMP state for vlan 1 is disabled"]}, + }, + { + "name": "success-enabled", + "test": VerifyIGMPSnoopingGlobal, + "eos_data": [ + { + "reportFlooding": "disabled", + "igmpSnoopingState": "enabled", + "robustness": 2, + "immediateLeave": "enabled", + "reportFloodingSwitchPorts": [], + } + ], + "inputs": {"enabled": True}, + "expected": {"result": "success"}, + }, + { + "name": "success-disabled", + "test": VerifyIGMPSnoopingGlobal, + "eos_data": [ + { + "reportFlooding": "disabled", + "igmpSnoopingState": "disabled", + } + ], + "inputs": {"enabled": False}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-state", + "test": VerifyIGMPSnoopingGlobal, + "eos_data": [ + { + "reportFlooding": "disabled", + "igmpSnoopingState": "disabled", + } + ], + "inputs": {"enabled": True}, + "expected": {"result": "failure", "messages": ["IGMP state is not valid: disabled"]}, + }, +] diff --git a/tests/units/anta_tests/test_profiles.py b/tests/units/anta_tests/test_profiles.py new file mode 100644 index 0000000..c0ebb57 --- /dev/null +++ b/tests/units/anta_tests/test_profiles.py @@ -0,0 +1,47 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.profiles.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.profiles import VerifyTcamProfile, VerifyUnifiedForwardingTableMode +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyUnifiedForwardingTableMode, + "eos_data": [{"uftMode": "2", "urpfEnabled": False, "chipModel": "bcm56870", "l2TableSize": 163840, "l3TableSize": 147456, "lpmTableSize": 32768}], + "inputs": {"mode": 2}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyUnifiedForwardingTableMode, + "eos_data": [{"uftMode": "2", "urpfEnabled": False, "chipModel": "bcm56870", "l2TableSize": 163840, "l3TableSize": 147456, "lpmTableSize": 32768}], + "inputs": {"mode": 3}, + "expected": {"result": "failure", "messages": ["Device is not running correct UFT mode (expected: 3 / running: 2)"]}, + }, + { + "name": "success", + "test": VerifyTcamProfile, + "eos_data": [ + {"pmfProfiles": {"FixedSystem": {"config": "test", "configType": "System Profile", "status": "test", "mode": "tcam"}}, "lastProgrammingStatus": {}} + ], + "inputs": {"profile": "test"}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyTcamProfile, + "eos_data": [ + {"pmfProfiles": {"FixedSystem": {"config": "test", "configType": "System Profile", "status": "default", "mode": "tcam"}}, "lastProgrammingStatus": {}} + ], + "inputs": {"profile": "test"}, + "expected": {"result": "failure", "messages": ["Incorrect profile running on device: default"]}, + }, +] diff --git a/tests/units/anta_tests/test_ptp.py b/tests/units/anta_tests/test_ptp.py new file mode 100644 index 0000000..3969c97 --- /dev/null +++ b/tests/units/anta_tests/test_ptp.py @@ -0,0 +1,42 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Data for testing anta.tests.configuration""" +from __future__ import annotations + +from typing import Any + +from anta.tests.ptp import VerifyPtpStatus + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyPtpStatus, + "eos_data": [ + { + "ptpMode": "ptpBoundaryClock", + "ptpProfile": "ptpDefaultProfile", + "ptpClockSummary": { + "clockIdentity": "0xcc:1a:a3:ff:ff:c3:bf:eb", + "gmClockIdentity": "0x00:00:00:00:00:00:00:00", + "numberOfSlavePorts": 0, + "numberOfMasterPorts": 0, + "offsetFromMaster": 0, + "meanPathDelay": 0, + "stepsRemoved": 0, + "skew": 1.0, + }, + "ptpIntfSummaries": {}, + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyPtpStatus, + "eos_data": [{"ptpIntfSummaries": {}}], + "inputs": None, + "expected": {"result": "failure"}, + }, +] diff --git a/tests/units/anta_tests/test_security.py b/tests/units/anta_tests/test_security.py new file mode 100644 index 0000000..17fa04e --- /dev/null +++ b/tests/units/anta_tests/test_security.py @@ -0,0 +1,900 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.security.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.security import ( + VerifyAPIHttpsSSL, + VerifyAPIHttpStatus, + VerifyAPIIPv4Acl, + VerifyAPIIPv6Acl, + VerifyAPISSLCertificate, + VerifyBannerLogin, + VerifyBannerMotd, + VerifyIPv4ACL, + VerifySSHIPv4Acl, + VerifySSHIPv6Acl, + VerifySSHStatus, + VerifyTelnetStatus, +) +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifySSHStatus, + "eos_data": ["SSHD status for Default VRF is disabled\nSSH connection limit is 50\nSSH per host connection limit is 20\nFIPS status: disabled\n\n"], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifySSHStatus, + "eos_data": ["SSHD status for Default VRF is enabled\nSSH connection limit is 50\nSSH per host connection limit is 20\nFIPS status: disabled\n\n"], + "inputs": None, + "expected": {"result": "failure", "messages": ["SSHD status for Default VRF is enabled"]}, + }, + { + "name": "success", + "test": VerifySSHIPv4Acl, + "eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_SSH", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-number", + "test": VerifySSHIPv4Acl, + "eos_data": [{"ipAclList": {"aclList": []}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Expected 1 SSH IPv4 ACL(s) in vrf MGMT but got 0"]}, + }, + { + "name": "failure-wrong-vrf", + "test": VerifySSHIPv4Acl, + "eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_SSH", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["SSH IPv4 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV4_SSH']"]}, + }, + { + "name": "success", + "test": VerifySSHIPv6Acl, + "eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_SSH", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-number", + "test": VerifySSHIPv6Acl, + "eos_data": [{"ipv6AclList": {"aclList": []}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Expected 1 SSH IPv6 ACL(s) in vrf MGMT but got 0"]}, + }, + { + "name": "failure-wrong-vrf", + "test": VerifySSHIPv6Acl, + "eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_SSH", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["SSH IPv6 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV6_SSH']"]}, + }, + { + "name": "success", + "test": VerifyTelnetStatus, + "eos_data": [{"serverState": "disabled", "vrfName": "default", "maxTelnetSessions": 20, "maxTelnetSessionsPerHost": 20}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyTelnetStatus, + "eos_data": [{"serverState": "enabled", "vrfName": "default", "maxTelnetSessions": 20, "maxTelnetSessionsPerHost": 20}], + "inputs": None, + "expected": {"result": "failure", "messages": ["Telnet status for Default VRF is enabled"]}, + }, + { + "name": "success", + "test": VerifyAPIHttpStatus, + "eos_data": [ + { + "enabled": True, + "httpServer": {"configured": False, "running": False, "port": 80}, + "localHttpServer": {"configured": False, "running": False, "port": 8080}, + "httpsServer": {"configured": True, "running": True, "port": 443}, + "unixSocketServer": {"configured": False, "running": False}, + "sslProfile": {"name": "API_SSL_Profile", "configured": True, "state": "valid"}, + "tlsProtocol": ["1.2"], + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyAPIHttpStatus, + "eos_data": [ + { + "enabled": True, + "httpServer": {"configured": True, "running": True, "port": 80}, + "localHttpServer": {"configured": False, "running": False, "port": 8080}, + "httpsServer": {"configured": True, "running": True, "port": 443}, + "unixSocketServer": {"configured": False, "running": False}, + "sslProfile": {"name": "API_SSL_Profile", "configured": True, "state": "valid"}, + "tlsProtocol": ["1.2"], + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["eAPI HTTP server is enabled globally"]}, + }, + { + "name": "success", + "test": VerifyAPIHttpsSSL, + "eos_data": [ + { + "enabled": True, + "httpServer": {"configured": False, "running": False, "port": 80}, + "localHttpServer": {"configured": False, "running": False, "port": 8080}, + "httpsServer": {"configured": True, "running": True, "port": 443}, + "unixSocketServer": {"configured": False, "running": False}, + "sslProfile": {"name": "API_SSL_Profile", "configured": True, "state": "valid"}, + "tlsProtocol": ["1.2"], + } + ], + "inputs": {"profile": "API_SSL_Profile"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-configured", + "test": VerifyAPIHttpsSSL, + "eos_data": [ + { + "enabled": True, + "httpServer": {"configured": True, "running": True, "port": 80}, + "localHttpServer": {"configured": False, "running": False, "port": 8080}, + "httpsServer": {"configured": True, "running": True, "port": 443}, + "unixSocketServer": {"configured": False, "running": False}, + "tlsProtocol": ["1.2"], + } + ], + "inputs": {"profile": "API_SSL_Profile"}, + "expected": {"result": "failure", "messages": ["eAPI HTTPS server SSL profile (API_SSL_Profile) is not configured"]}, + }, + { + "name": "failure-misconfigured-invalid", + "test": VerifyAPIHttpsSSL, + "eos_data": [ + { + "enabled": True, + "httpServer": {"configured": True, "running": True, "port": 80}, + "localHttpServer": {"configured": False, "running": False, "port": 8080}, + "httpsServer": {"configured": True, "running": True, "port": 443}, + "unixSocketServer": {"configured": False, "running": False}, + "sslProfile": {"name": "Wrong_SSL_Profile", "configured": True, "state": "valid"}, + "tlsProtocol": ["1.2"], + } + ], + "inputs": {"profile": "API_SSL_Profile"}, + "expected": {"result": "failure", "messages": ["eAPI HTTPS server SSL profile (API_SSL_Profile) is misconfigured or invalid"]}, + }, + { + "name": "success", + "test": VerifyAPIIPv4Acl, + "eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_API", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-number", + "test": VerifyAPIIPv4Acl, + "eos_data": [{"ipAclList": {"aclList": []}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Expected 1 eAPI IPv4 ACL(s) in vrf MGMT but got 0"]}, + }, + { + "name": "failure-wrong-vrf", + "test": VerifyAPIIPv4Acl, + "eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_API", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["eAPI IPv4 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV4_API']"]}, + }, + { + "name": "success", + "test": VerifyAPIIPv6Acl, + "eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_API", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-number", + "test": VerifyAPIIPv6Acl, + "eos_data": [{"ipv6AclList": {"aclList": []}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Expected 1 eAPI IPv6 ACL(s) in vrf MGMT but got 0"]}, + }, + { + "name": "failure-wrong-vrf", + "test": VerifyAPIIPv6Acl, + "eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_API", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["eAPI IPv6 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV6_API']"]}, + }, + { + "name": "success", + "test": VerifyAPISSLCertificate, + "eos_data": [ + { + "certificates": { + "ARISTA_ROOT_CA.crt": { + "subject": {"commonName": "Arista Networks Internal IT Root Cert Authority"}, + "notAfter": 2127420899, + "publicKey": { + "encryptionAlgorithm": "RSA", + "size": 4096, + }, + }, + "ARISTA_SIGNING_CA.crt": { + "subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"}, + "notAfter": 2127420899, + "publicKey": { + "encryptionAlgorithm": "ECDSA", + "size": 256, + }, + }, + } + }, + { + "utcTime": 1702288467.6736515, + }, + ], + "inputs": { + "certificates": [ + { + "certificate_name": "ARISTA_SIGNING_CA.crt", + "expiry_threshold": 30, + "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority", + "encryption_algorithm": "ECDSA", + "key_size": 256, + }, + { + "certificate_name": "ARISTA_ROOT_CA.crt", + "expiry_threshold": 30, + "common_name": "Arista Networks Internal IT Root Cert Authority", + "encryption_algorithm": "RSA", + "key_size": 4096, + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-certificate-not-configured", + "test": VerifyAPISSLCertificate, + "eos_data": [ + { + "certificates": { + "ARISTA_SIGNING_CA.crt": { + "subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"}, + "notAfter": 2127420899, + "publicKey": { + "encryptionAlgorithm": "ECDSA", + "size": 256, + }, + }, + } + }, + { + "utcTime": 1702288467.6736515, + }, + ], + "inputs": { + "certificates": [ + { + "certificate_name": "ARISTA_SIGNING_CA.crt", + "expiry_threshold": 30, + "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority", + "encryption_algorithm": "ECDSA", + "key_size": 256, + }, + { + "certificate_name": "ARISTA_ROOT_CA.crt", + "expiry_threshold": 30, + "common_name": "Arista Networks Internal IT Root Cert Authority", + "encryption_algorithm": "RSA", + "key_size": 4096, + }, + ] + }, + "expected": { + "result": "failure", + "messages": ["SSL certificate 'ARISTA_ROOT_CA.crt', is not configured.\n"], + }, + }, + { + "name": "failure-certificate-expired", + "test": VerifyAPISSLCertificate, + "eos_data": [ + { + "certificates": { + "ARISTA_ROOT_CA.crt": { + "subject": {"commonName": "Arista Networks Internal IT Root Cert Authority"}, + "notAfter": 1702533518, + "publicKey": { + "encryptionAlgorithm": "RSA", + "size": 4096, + }, + }, + } + }, + { + "utcTime": 1702622372.2240553, + }, + ], + "inputs": { + "certificates": [ + { + "certificate_name": "ARISTA_SIGNING_CA.crt", + "expiry_threshold": 30, + "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority", + "encryption_algorithm": "ECDSA", + "key_size": 256, + }, + { + "certificate_name": "ARISTA_ROOT_CA.crt", + "expiry_threshold": 30, + "common_name": "Arista Networks Internal IT Root Cert Authority", + "encryption_algorithm": "RSA", + "key_size": 4096, + }, + ] + }, + "expected": { + "result": "failure", + "messages": ["SSL certificate 'ARISTA_SIGNING_CA.crt', is not configured.\n", "SSL certificate `ARISTA_ROOT_CA.crt` is expired.\n"], + }, + }, + { + "name": "failure-certificate-about-to-expire", + "test": VerifyAPISSLCertificate, + "eos_data": [ + { + "certificates": { + "ARISTA_ROOT_CA.crt": { + "subject": {"commonName": "Arista Networks Internal IT Root Cert Authority"}, + "notAfter": 1704782709, + "publicKey": { + "encryptionAlgorithm": "RSA", + "size": 4096, + }, + }, + "ARISTA_SIGNING_CA.crt": { + "subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"}, + "notAfter": 1702533518, + "publicKey": { + "encryptionAlgorithm": "ECDSA", + "size": 256, + }, + }, + } + }, + { + "utcTime": 1702622372.2240553, + }, + ], + "inputs": { + "certificates": [ + { + "certificate_name": "ARISTA_SIGNING_CA.crt", + "expiry_threshold": 30, + "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority", + "encryption_algorithm": "ECDSA", + "key_size": 256, + }, + { + "certificate_name": "ARISTA_ROOT_CA.crt", + "expiry_threshold": 30, + "common_name": "Arista Networks Internal IT Root Cert Authority", + "encryption_algorithm": "RSA", + "key_size": 4096, + }, + ] + }, + "expected": { + "result": "failure", + "messages": ["SSL certificate `ARISTA_SIGNING_CA.crt` is expired.\n", "SSL certificate `ARISTA_ROOT_CA.crt` is about to expire in 25 days."], + }, + }, + { + "name": "failure-wrong-subject-name", + "test": VerifyAPISSLCertificate, + "eos_data": [ + { + "certificates": { + "ARISTA_ROOT_CA.crt": { + "subject": {"commonName": "AristaIT-ICA Networks Internal IT Root Cert Authority"}, + "notAfter": 2127420899, + "publicKey": { + "encryptionAlgorithm": "RSA", + "size": 4096, + }, + }, + "ARISTA_SIGNING_CA.crt": { + "subject": {"commonName": "Arista ECDSA Issuing Cert Authority"}, + "notAfter": 2127420899, + "publicKey": { + "encryptionAlgorithm": "ECDSA", + "size": 256, + }, + }, + } + }, + { + "utcTime": 1702288467.6736515, + }, + ], + "inputs": { + "certificates": [ + { + "certificate_name": "ARISTA_SIGNING_CA.crt", + "expiry_threshold": 30, + "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority", + "encryption_algorithm": "ECDSA", + "key_size": 256, + }, + { + "certificate_name": "ARISTA_ROOT_CA.crt", + "expiry_threshold": 30, + "common_name": "Arista Networks Internal IT Root Cert Authority", + "encryption_algorithm": "RSA", + "key_size": 4096, + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "SSL certificate `ARISTA_SIGNING_CA.crt` is not configured properly:\n" + "Expected `AristaIT-ICA ECDSA Issuing Cert Authority` as the subject.commonName, but found " + "`Arista ECDSA Issuing Cert Authority` instead.\n", + "SSL certificate `ARISTA_ROOT_CA.crt` is not configured properly:\n" + "Expected `Arista Networks Internal IT Root Cert Authority` as the subject.commonName, " + "but found `AristaIT-ICA Networks Internal IT Root Cert Authority` instead.\n", + ], + }, + }, + { + "name": "failure-wrong-encryption-type-and-size", + "test": VerifyAPISSLCertificate, + "eos_data": [ + { + "certificates": { + "ARISTA_ROOT_CA.crt": { + "subject": {"commonName": "Arista Networks Internal IT Root Cert Authority"}, + "notAfter": 2127420899, + "publicKey": { + "encryptionAlgorithm": "ECDSA", + "size": 256, + }, + }, + "ARISTA_SIGNING_CA.crt": { + "subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"}, + "notAfter": 2127420899, + "publicKey": { + "encryptionAlgorithm": "RSA", + "size": 4096, + }, + }, + } + }, + { + "utcTime": 1702288467.6736515, + }, + ], + "inputs": { + "certificates": [ + { + "certificate_name": "ARISTA_SIGNING_CA.crt", + "expiry_threshold": 30, + "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority", + "encryption_algorithm": "ECDSA", + "key_size": 256, + }, + { + "certificate_name": "ARISTA_ROOT_CA.crt", + "expiry_threshold": 30, + "common_name": "Arista Networks Internal IT Root Cert Authority", + "encryption_algorithm": "RSA", + "key_size": 4096, + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "SSL certificate `ARISTA_SIGNING_CA.crt` is not configured properly:\n" + "Expected `ECDSA` as the publicKey.encryptionAlgorithm, but found `RSA` instead.\n" + "Expected `256` as the publicKey.size, but found `4096` instead.\n", + "SSL certificate `ARISTA_ROOT_CA.crt` is not configured properly:\n" + "Expected `RSA` as the publicKey.encryptionAlgorithm, but found `ECDSA` instead.\n" + "Expected `4096` as the publicKey.size, but found `256` instead.\n", + ], + }, + }, + { + "name": "failure-missing-actual-output", + "test": VerifyAPISSLCertificate, + "eos_data": [ + { + "certificates": { + "ARISTA_ROOT_CA.crt": { + "subject": {"commonName": "Arista Networks Internal IT Root Cert Authority"}, + "notAfter": 2127420899, + }, + "ARISTA_SIGNING_CA.crt": { + "subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"}, + "notAfter": 2127420899, + }, + } + }, + { + "utcTime": 1702288467.6736515, + }, + ], + "inputs": { + "certificates": [ + { + "certificate_name": "ARISTA_SIGNING_CA.crt", + "expiry_threshold": 30, + "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority", + "encryption_algorithm": "ECDSA", + "key_size": 256, + }, + { + "certificate_name": "ARISTA_ROOT_CA.crt", + "expiry_threshold": 30, + "common_name": "Arista Networks Internal IT Root Cert Authority", + "encryption_algorithm": "RSA", + "key_size": 4096, + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "SSL certificate `ARISTA_SIGNING_CA.crt` is not configured properly:\n" + "Expected `ECDSA` as the publicKey.encryptionAlgorithm, but it was not found in the actual output.\n" + "Expected `256` as the publicKey.size, but it was not found in the actual output.\n", + "SSL certificate `ARISTA_ROOT_CA.crt` is not configured properly:\n" + "Expected `RSA` as the publicKey.encryptionAlgorithm, but it was not found in the actual output.\n" + "Expected `4096` as the publicKey.size, but it was not found in the actual output.\n", + ], + }, + }, + { + "name": "success", + "test": VerifyBannerLogin, + "eos_data": [ + { + "loginBanner": "Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file." + } + ], + "inputs": { + "login_banner": "Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file." + }, + "expected": {"result": "success"}, + }, + { + "name": "success-multiline", + "test": VerifyBannerLogin, + "eos_data": [ + { + "loginBanner": "Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file." + } + ], + "inputs": { + "login_banner": """Copyright (c) 2023-2024 Arista Networks, Inc. + Use of this source code is governed by the Apache License 2.0 + that can be found in the LICENSE file.""" + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-incorrect-login-banner", + "test": VerifyBannerLogin, + "eos_data": [ + { + "loginBanner": "Copyright (c) 2023 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file." + } + ], + "inputs": { + "login_banner": "Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file." + }, + "expected": { + "result": "failure", + "messages": [ + "Expected `Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file.` as the login banner, but found `Copyright (c) 2023 Arista Networks, Inc.\nUse of this source code is " + "governed by the Apache License 2.0\nthat can be found in the LICENSE file.` instead." + ], + }, + }, + { + "name": "success", + "test": VerifyBannerMotd, + "eos_data": [ + { + "motd": "Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file." + } + ], + "inputs": { + "motd_banner": "Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file." + }, + "expected": {"result": "success"}, + }, + { + "name": "success-multiline", + "test": VerifyBannerMotd, + "eos_data": [ + { + "motd": "Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file." + } + ], + "inputs": { + "motd_banner": """Copyright (c) 2023-2024 Arista Networks, Inc. + Use of this source code is governed by the Apache License 2.0 + that can be found in the LICENSE file.""" + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-incorrect-motd-banner", + "test": VerifyBannerMotd, + "eos_data": [ + { + "motd": "Copyright (c) 2023 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file." + } + ], + "inputs": { + "motd_banner": "Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file." + }, + "expected": { + "result": "failure", + "messages": [ + "Expected `Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n" + "that can be found in the LICENSE file.` as the motd banner, but found `Copyright (c) 2023 Arista Networks, Inc.\nUse of this source code is " + "governed by the Apache License 2.0\nthat can be found in the LICENSE file.` instead." + ], + }, + }, + { + "name": "success", + "test": VerifyIPv4ACL, + "eos_data": [ + { + "aclList": [ + { + "sequence": [ + {"text": "permit icmp any any", "sequenceNumber": 10}, + {"text": "permit ip any any tracked", "sequenceNumber": 20}, + {"text": "permit udp any any eq bfd ttl eq 255", "sequenceNumber": 30}, + ], + } + ] + }, + { + "aclList": [ + { + "sequence": [ + {"text": "permit icmp any any", "sequenceNumber": 10}, + {"text": "permit tcp any any range 5900 5910", "sequenceNumber": 20}, + ], + } + ] + }, + ], + "inputs": { + "ipv4_access_lists": [ + { + "name": "default-control-plane-acl", + "entries": [ + {"sequence": 10, "action": "permit icmp any any"}, + {"sequence": 20, "action": "permit ip any any tracked"}, + {"sequence": 30, "action": "permit udp any any eq bfd ttl eq 255"}, + ], + }, + { + "name": "LabTest", + "entries": [{"sequence": 10, "action": "permit icmp any any"}, {"sequence": 20, "action": "permit tcp any any range 5900 5910"}], + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-acl-not-found", + "test": VerifyIPv4ACL, + "eos_data": [ + { + "aclList": [ + { + "sequence": [ + {"text": "permit icmp any any", "sequenceNumber": 10}, + {"text": "permit ip any any tracked", "sequenceNumber": 20}, + {"text": "permit udp any any eq bfd ttl eq 255", "sequenceNumber": 30}, + ], + } + ] + }, + {"aclList": []}, + ], + "inputs": { + "ipv4_access_lists": [ + { + "name": "default-control-plane-acl", + "entries": [ + {"sequence": 10, "action": "permit icmp any any"}, + {"sequence": 20, "action": "permit ip any any tracked"}, + {"sequence": 30, "action": "permit udp any any eq bfd ttl eq 255"}, + ], + }, + { + "name": "LabTest", + "entries": [{"sequence": 10, "action": "permit icmp any any"}, {"sequence": 20, "action": "permit tcp any any range 5900 5910"}], + }, + ] + }, + "expected": {"result": "failure", "messages": ["LabTest: Not found"]}, + }, + { + "name": "failure-sequence-not-found", + "test": VerifyIPv4ACL, + "eos_data": [ + { + "aclList": [ + { + "sequence": [ + {"text": "permit icmp any any", "sequenceNumber": 10}, + {"text": "permit ip any any tracked", "sequenceNumber": 20}, + {"text": "permit udp any any eq bfd ttl eq 255", "sequenceNumber": 40}, + ], + } + ] + }, + { + "aclList": [ + { + "sequence": [ + {"text": "permit icmp any any", "sequenceNumber": 10}, + {"text": "permit tcp any any range 5900 5910", "sequenceNumber": 30}, + ], + } + ] + }, + ], + "inputs": { + "ipv4_access_lists": [ + { + "name": "default-control-plane-acl", + "entries": [ + {"sequence": 10, "action": "permit icmp any any"}, + {"sequence": 20, "action": "permit ip any any tracked"}, + {"sequence": 30, "action": "permit udp any any eq bfd ttl eq 255"}, + ], + }, + { + "name": "LabTest", + "entries": [{"sequence": 10, "action": "permit icmp any any"}, {"sequence": 20, "action": "permit tcp any any range 5900 5910"}], + }, + ] + }, + "expected": { + "result": "failure", + "messages": ["default-control-plane-acl:\nSequence number `30` is not found.\n", "LabTest:\nSequence number `20` is not found.\n"], + }, + }, + { + "name": "failure-action-not-match", + "test": VerifyIPv4ACL, + "eos_data": [ + { + "aclList": [ + { + "sequence": [ + {"text": "permit icmp any any", "sequenceNumber": 10}, + {"text": "permit ip any any tracked", "sequenceNumber": 20}, + {"text": "permit tcp any any range 5900 5910", "sequenceNumber": 30}, + ], + } + ] + }, + { + "aclList": [ + { + "sequence": [ + {"text": "permit icmp any any", "sequenceNumber": 10}, + {"text": "permit udp any any eq bfd ttl eq 255", "sequenceNumber": 20}, + ], + } + ] + }, + ], + "inputs": { + "ipv4_access_lists": [ + { + "name": "default-control-plane-acl", + "entries": [ + {"sequence": 10, "action": "permit icmp any any"}, + {"sequence": 20, "action": "permit ip any any tracked"}, + {"sequence": 30, "action": "permit udp any any eq bfd ttl eq 255"}, + ], + }, + { + "name": "LabTest", + "entries": [{"sequence": 10, "action": "permit icmp any any"}, {"sequence": 20, "action": "permit tcp any any range 5900 5910"}], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "default-control-plane-acl:\n" + "Expected `permit udp any any eq bfd ttl eq 255` as sequence number 30 action but found `permit tcp any any range 5900 5910` instead.\n", + "LabTest:\nExpected `permit tcp any any range 5900 5910` as sequence number 20 action but found `permit udp any any eq bfd ttl eq 255` instead.\n", + ], + }, + }, + { + "name": "failure-all-type", + "test": VerifyIPv4ACL, + "eos_data": [ + { + "aclList": [ + { + "sequence": [ + {"text": "permit icmp any any", "sequenceNumber": 10}, + {"text": "permit ip any any tracked", "sequenceNumber": 40}, + {"text": "permit tcp any any range 5900 5910", "sequenceNumber": 30}, + ], + } + ] + }, + {"aclList": []}, + ], + "inputs": { + "ipv4_access_lists": [ + { + "name": "default-control-plane-acl", + "entries": [ + {"sequence": 10, "action": "permit icmp any any"}, + {"sequence": 20, "action": "permit ip any any tracked"}, + {"sequence": 30, "action": "permit udp any any eq bfd ttl eq 255"}, + ], + }, + { + "name": "LabTest", + "entries": [{"sequence": 10, "action": "permit icmp any any"}, {"sequence": 20, "action": "permit tcp any any range 5900 5910"}], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "default-control-plane-acl:\nSequence number `20` is not found.\n" + "Expected `permit udp any any eq bfd ttl eq 255` as sequence number 30 action but found `permit tcp any any range 5900 5910` instead.\n", + "LabTest: Not found", + ], + }, + }, +] diff --git a/tests/units/anta_tests/test_services.py b/tests/units/anta_tests/test_services.py new file mode 100644 index 0000000..dcd1ee2 --- /dev/null +++ b/tests/units/anta_tests/test_services.py @@ -0,0 +1,218 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.services.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.services import VerifyDNSLookup, VerifyDNSServers, VerifyErrdisableRecovery, VerifyHostname +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyHostname, + "eos_data": [{"hostname": "s1-spine1", "fqdn": "s1-spine1.fun.aristanetworks.com"}], + "inputs": {"hostname": "s1-spine1"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-incorrect-hostname", + "test": VerifyHostname, + "eos_data": [{"hostname": "s1-spine2", "fqdn": "s1-spine1.fun.aristanetworks.com"}], + "inputs": {"hostname": "s1-spine1"}, + "expected": { + "result": "failure", + "messages": ["Expected `s1-spine1` as the hostname, but found `s1-spine2` instead."], + }, + }, + { + "name": "success", + "test": VerifyDNSLookup, + "eos_data": [ + { + "messages": [ + "Server:\t\t127.0.0.1\nAddress:\t127.0.0.1#53\n\nNon-authoritative answer:\nName:\tarista.com\nAddress: 151.101.130.132\nName:\tarista.com\n" + "Address: 151.101.2.132\nName:\tarista.com\nAddress: 151.101.194.132\nName:\tarista.com\nAddress: 151.101.66.132\n\n" + ] + }, + {"messages": ["Server:\t\t127.0.0.1\nAddress:\t127.0.0.1#53\n\nNon-authoritative answer:\nName:\twww.google.com\nAddress: 172.217.12.100\n\n"]}, + ], + "inputs": {"domain_names": ["arista.com", "www.google.com"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyDNSLookup, + "eos_data": [ + {"messages": ["Server:\t\t127.0.0.1\nAddress:\t127.0.0.1#53\n\nNon-authoritative answer:\n*** Can't find arista.ca: No answer\n\n"]}, + {"messages": ["Server:\t\t127.0.0.1\nAddress:\t127.0.0.1#53\n\nNon-authoritative answer:\nName:\twww.google.com\nAddress: 172.217.12.100\n\n"]}, + {"messages": ["Server:\t\t127.0.0.1\nAddress:\t127.0.0.1#53\n\nNon-authoritative answer:\n*** Can't find google.ca: No answer\n\n"]}, + ], + "inputs": {"domain_names": ["arista.ca", "www.google.com", "google.ca"]}, + "expected": {"result": "failure", "messages": ["The following domain(s) are not resolved to an IP address: arista.ca, google.ca"]}, + }, + { + "name": "success", + "test": VerifyDNSServers, + "eos_data": [ + { + "nameServerConfigs": [{"ipAddr": "10.14.0.1", "vrf": "default", "priority": 0}, {"ipAddr": "10.14.0.11", "vrf": "MGMT", "priority": 1}], + } + ], + "inputs": { + "dns_servers": [{"server_address": "10.14.0.1", "vrf": "default", "priority": 0}, {"server_address": "10.14.0.11", "vrf": "MGMT", "priority": 1}] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-dns-missing", + "test": VerifyDNSServers, + "eos_data": [ + { + "nameServerConfigs": [{"ipAddr": "10.14.0.1", "vrf": "default", "priority": 0}, {"ipAddr": "10.14.0.11", "vrf": "MGMT", "priority": 1}], + } + ], + "inputs": { + "dns_servers": [{"server_address": "10.14.0.10", "vrf": "default", "priority": 0}, {"server_address": "10.14.0.21", "vrf": "MGMT", "priority": 1}] + }, + "expected": { + "result": "failure", + "messages": ["DNS server `10.14.0.10` is not configured with any VRF.", "DNS server `10.14.0.21` is not configured with any VRF."], + }, + }, + { + "name": "failure-no-dns-found", + "test": VerifyDNSServers, + "eos_data": [ + { + "nameServerConfigs": [], + } + ], + "inputs": { + "dns_servers": [{"server_address": "10.14.0.10", "vrf": "default", "priority": 0}, {"server_address": "10.14.0.21", "vrf": "MGMT", "priority": 1}] + }, + "expected": { + "result": "failure", + "messages": ["DNS server `10.14.0.10` is not configured with any VRF.", "DNS server `10.14.0.21` is not configured with any VRF."], + }, + }, + { + "name": "failure-incorrect-dns-details", + "test": VerifyDNSServers, + "eos_data": [ + { + "nameServerConfigs": [{"ipAddr": "10.14.0.1", "vrf": "CS", "priority": 1}, {"ipAddr": "10.14.0.11", "vrf": "MGMT", "priority": 1}], + } + ], + "inputs": { + "dns_servers": [ + {"server_address": "10.14.0.1", "vrf": "CS", "priority": 0}, + {"server_address": "10.14.0.11", "vrf": "default", "priority": 0}, + {"server_address": "10.14.0.110", "vrf": "MGMT", "priority": 0}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "For DNS server `10.14.0.1`, the expected priority is `0`, but `1` was found instead.", + "DNS server `10.14.0.11` is not configured with VRF `default`.", + "DNS server `10.14.0.110` is not configured with any VRF.", + ], + }, + }, + { + "name": "success", + "test": VerifyErrdisableRecovery, + "eos_data": [ + """ + Errdisable Reason Timer Status Timer Interval + ------------------------------ ----------------- -------------- + acl Enabled 300 + bpduguard Enabled 300 + arp-inspection Enabled 30 + """ + ], + "inputs": {"reasons": [{"reason": "acl", "interval": 300}, {"reason": "bpduguard", "interval": 300}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-reason-missing", + "test": VerifyErrdisableRecovery, + "eos_data": [ + """ + Errdisable Reason Timer Status Timer Interval + ------------------------------ ----------------- -------------- + acl Enabled 300 + bpduguard Enabled 300 + arp-inspection Enabled 30 + """ + ], + "inputs": {"reasons": [{"reason": "acl", "interval": 300}, {"reason": "arp-inspection", "interval": 30}, {"reason": "tapagg", "interval": 30}]}, + "expected": { + "result": "failure", + "messages": ["`tapagg`: Not found."], + }, + }, + { + "name": "failure-reason-disabled", + "test": VerifyErrdisableRecovery, + "eos_data": [ + """ + Errdisable Reason Timer Status Timer Interval + ------------------------------ ----------------- -------------- + acl Disabled 300 + bpduguard Enabled 300 + arp-inspection Enabled 30 + """ + ], + "inputs": {"reasons": [{"reason": "acl", "interval": 300}, {"reason": "arp-inspection", "interval": 30}]}, + "expected": { + "result": "failure", + "messages": ["`acl`:\nExpected `Enabled` as the status, but found `Disabled` instead."], + }, + }, + { + "name": "failure-interval-not-ok", + "test": VerifyErrdisableRecovery, + "eos_data": [ + """ + Errdisable Reason Timer Status Timer Interval + ------------------------------ ----------------- -------------- + acl Enabled 300 + bpduguard Enabled 300 + arp-inspection Enabled 30 + """ + ], + "inputs": {"reasons": [{"reason": "acl", "interval": 30}, {"reason": "arp-inspection", "interval": 30}]}, + "expected": { + "result": "failure", + "messages": ["`acl`:\nExpected `30` as the interval, but found `300` instead."], + }, + }, + { + "name": "failure-all-type", + "test": VerifyErrdisableRecovery, + "eos_data": [ + """ + Errdisable Reason Timer Status Timer Interval + ------------------------------ ----------------- -------------- + acl Disabled 300 + bpduguard Enabled 300 + arp-inspection Enabled 30 + """ + ], + "inputs": {"reasons": [{"reason": "acl", "interval": 30}, {"reason": "arp-inspection", "interval": 300}, {"reason": "tapagg", "interval": 30}]}, + "expected": { + "result": "failure", + "messages": [ + "`acl`:\nExpected `30` as the interval, but found `300` instead.\nExpected `Enabled` as the status, but found `Disabled` instead.", + "`arp-inspection`:\nExpected `300` as the interval, but found `30` instead.", + "`tapagg`: Not found.", + ], + }, + }, +] diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py new file mode 100644 index 0000000..7009689 --- /dev/null +++ b/tests/units/anta_tests/test_snmp.py @@ -0,0 +1,128 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.snmp.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.snmp import VerifySnmpContact, VerifySnmpIPv4Acl, VerifySnmpIPv6Acl, VerifySnmpLocation, VerifySnmpStatus +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifySnmpStatus, + "eos_data": [{"vrfs": {"snmpVrfs": ["MGMT", "default"]}, "enabled": True}], + "inputs": {"vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-vrf", + "test": VerifySnmpStatus, + "eos_data": [{"vrfs": {"snmpVrfs": ["default"]}, "enabled": True}], + "inputs": {"vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["SNMP agent disabled in vrf MGMT"]}, + }, + { + "name": "failure-disabled", + "test": VerifySnmpStatus, + "eos_data": [{"vrfs": {"snmpVrfs": ["default"]}, "enabled": False}], + "inputs": {"vrf": "default"}, + "expected": {"result": "failure", "messages": ["SNMP agent disabled in vrf default"]}, + }, + { + "name": "success", + "test": VerifySnmpIPv4Acl, + "eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_SNMP", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-number", + "test": VerifySnmpIPv4Acl, + "eos_data": [{"ipAclList": {"aclList": []}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Expected 1 SNMP IPv4 ACL(s) in vrf MGMT but got 0"]}, + }, + { + "name": "failure-wrong-vrf", + "test": VerifySnmpIPv4Acl, + "eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_SNMP", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["SNMP IPv4 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV4_SNMP']"]}, + }, + { + "name": "success", + "test": VerifySnmpIPv6Acl, + "eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_SNMP", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-wrong-number", + "test": VerifySnmpIPv6Acl, + "eos_data": [{"ipv6AclList": {"aclList": []}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["Expected 1 SNMP IPv6 ACL(s) in vrf MGMT but got 0"]}, + }, + { + "name": "failure-wrong-vrf", + "test": VerifySnmpIPv6Acl, + "eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_SNMP", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}], + "inputs": {"number": 1, "vrf": "MGMT"}, + "expected": {"result": "failure", "messages": ["SNMP IPv6 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV6_SNMP']"]}, + }, + { + "name": "success", + "test": VerifySnmpLocation, + "eos_data": [ + { + "location": {"location": "New York"}, + } + ], + "inputs": {"location": "New York"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-incorrect-location", + "test": VerifySnmpLocation, + "eos_data": [ + { + "location": {"location": "Europe"}, + } + ], + "inputs": {"location": "New York"}, + "expected": { + "result": "failure", + "messages": ["Expected `New York` as the location, but found `Europe` instead."], + }, + }, + { + "name": "success", + "test": VerifySnmpContact, + "eos_data": [ + { + "contact": {"contact": "Jon@example.com"}, + } + ], + "inputs": {"contact": "Jon@example.com"}, + "expected": {"result": "success"}, + }, + { + "name": "failure-incorrect-contact", + "test": VerifySnmpContact, + "eos_data": [ + { + "contact": {"contact": "Jon@example.com"}, + } + ], + "inputs": {"contact": "Bob@example.com"}, + "expected": { + "result": "failure", + "messages": ["Expected `Bob@example.com` as the contact, but found `Jon@example.com` instead."], + }, + }, +] diff --git a/tests/units/anta_tests/test_software.py b/tests/units/anta_tests/test_software.py new file mode 100644 index 0000000..6d39c04 --- /dev/null +++ b/tests/units/anta_tests/test_software.py @@ -0,0 +1,101 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Test inputs for anta.tests.hardware""" +from __future__ import annotations + +from typing import Any + +from anta.tests.software import VerifyEOSExtensions, VerifyEOSVersion, VerifyTerminAttrVersion +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyEOSVersion, + "eos_data": [ + { + "modelName": "vEOS-lab", + "internalVersion": "4.27.0F-24305004.4270F", + "version": "4.27.0F", + } + ], + "inputs": {"versions": ["4.27.0F", "4.28.0F"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyEOSVersion, + "eos_data": [ + { + "modelName": "vEOS-lab", + "internalVersion": "4.27.0F-24305004.4270F", + "version": "4.27.0F", + } + ], + "inputs": {"versions": ["4.27.1F"]}, + "expected": {"result": "failure", "messages": ["device is running version \"4.27.0F\" not in expected versions: ['4.27.1F']"]}, + }, + { + "name": "success", + "test": VerifyTerminAttrVersion, + "eos_data": [ + { + "imageFormatVersion": "1.0", + "uptime": 1107543.52, + "modelName": "vEOS-lab", + "details": { + "deviations": [], + "components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}], + "switchType": "fixedSystem", + "packages": { + "TerminAttr-core": {"release": "1", "version": "v1.17.0"}, + }, + }, + } + ], + "inputs": {"versions": ["v1.17.0", "v1.18.1"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyTerminAttrVersion, + "eos_data": [ + { + "imageFormatVersion": "1.0", + "uptime": 1107543.52, + "modelName": "vEOS-lab", + "details": { + "deviations": [], + "components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}], + "switchType": "fixedSystem", + "packages": { + "TerminAttr-core": {"release": "1", "version": "v1.17.0"}, + }, + }, + } + ], + "inputs": {"versions": ["v1.17.1", "v1.18.1"]}, + "expected": {"result": "failure", "messages": ["device is running TerminAttr version v1.17.0 and is not in the allowed list: ['v1.17.1', 'v1.18.1']"]}, + }, + { + "name": "success-no-extensions", + "test": VerifyEOSExtensions, + "eos_data": [ + {"extensions": {}, "extensionStoredDir": "flash:", "warnings": ["No extensions are available"]}, + {"extensions": []}, + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyEOSExtensions, + "eos_data": [ + {"extensions": {}, "extensionStoredDir": "flash:", "warnings": ["No extensions are available"]}, + {"extensions": ["dummy"]}, + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Missing EOS extensions: installed [] / configured: ['dummy']"]}, + }, +] diff --git a/tests/units/anta_tests/test_stp.py b/tests/units/anta_tests/test_stp.py new file mode 100644 index 0000000..26f0b90 --- /dev/null +++ b/tests/units/anta_tests/test_stp.py @@ -0,0 +1,328 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.stp.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.stp import VerifySTPBlockedPorts, VerifySTPCounters, VerifySTPForwardingPorts, VerifySTPMode, VerifySTPRootPriority +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifySTPMode, + "eos_data": [ + {"spanningTreeVlanInstances": {"10": {"spanningTreeVlanInstance": {"protocol": "rstp"}}}}, + {"spanningTreeVlanInstances": {"20": {"spanningTreeVlanInstance": {"protocol": "rstp"}}}}, + ], + "inputs": {"mode": "rstp", "vlans": [10, 20]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-instances", + "test": VerifySTPMode, + "eos_data": [ + {"spanningTreeVlanInstances": {}}, + {"spanningTreeVlanInstances": {}}, + ], + "inputs": {"mode": "rstp", "vlans": [10, 20]}, + "expected": {"result": "failure", "messages": ["STP mode 'rstp' not configured for the following VLAN(s): [10, 20]"]}, + }, + { + "name": "failure-wrong-mode", + "test": VerifySTPMode, + "eos_data": [ + {"spanningTreeVlanInstances": {"10": {"spanningTreeVlanInstance": {"protocol": "mstp"}}}}, + {"spanningTreeVlanInstances": {"20": {"spanningTreeVlanInstance": {"protocol": "mstp"}}}}, + ], + "inputs": {"mode": "rstp", "vlans": [10, 20]}, + "expected": {"result": "failure", "messages": ["Wrong STP mode configured for the following VLAN(s): [10, 20]"]}, + }, + { + "name": "failure-both", + "test": VerifySTPMode, + "eos_data": [ + {"spanningTreeVlanInstances": {}}, + {"spanningTreeVlanInstances": {"20": {"spanningTreeVlanInstance": {"protocol": "mstp"}}}}, + ], + "inputs": {"mode": "rstp", "vlans": [10, 20]}, + "expected": { + "result": "failure", + "messages": ["STP mode 'rstp' not configured for the following VLAN(s): [10]", "Wrong STP mode configured for the following VLAN(s): [20]"], + }, + }, + { + "name": "success", + "test": VerifySTPBlockedPorts, + "eos_data": [{"spanningTreeInstances": {}}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifySTPBlockedPorts, + "eos_data": [{"spanningTreeInstances": {"MST0": {"spanningTreeBlockedPorts": ["Ethernet10"]}, "MST10": {"spanningTreeBlockedPorts": ["Ethernet10"]}}}], + "inputs": None, + "expected": {"result": "failure", "messages": ["The following ports are blocked by STP: {'MST0': ['Ethernet10'], 'MST10': ['Ethernet10']}"]}, + }, + { + "name": "success", + "test": VerifySTPCounters, + "eos_data": [{"interfaces": {"Ethernet10": {"bpduSent": 99, "bpduReceived": 0, "bpduTaggedError": 0, "bpduOtherError": 0, "bpduRateLimitCount": 0}}}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifySTPCounters, + "eos_data": [ + { + "interfaces": { + "Ethernet10": {"bpduSent": 201, "bpduReceived": 0, "bpduTaggedError": 3, "bpduOtherError": 0, "bpduRateLimitCount": 0}, + "Ethernet11": {"bpduSent": 99, "bpduReceived": 0, "bpduTaggedError": 0, "bpduOtherError": 6, "bpduRateLimitCount": 0}, + } + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["The following interfaces have STP BPDU packet errors: ['Ethernet10', 'Ethernet11']"]}, + }, + { + "name": "success", + "test": VerifySTPForwardingPorts, + "eos_data": [ + { + "unmappedVlans": [], + "topologies": {"Mst10": {"vlans": [10], "interfaces": {"Ethernet10": {"state": "forwarding"}, "MplsTrunk1": {"state": "forwarding"}}}}, + }, + { + "unmappedVlans": [], + "topologies": {"Mst20": {"vlans": [20], "interfaces": {"Ethernet10": {"state": "forwarding"}, "MplsTrunk1": {"state": "forwarding"}}}}, + }, + ], + "inputs": {"vlans": [10, 20]}, + "expected": {"result": "success"}, + }, + { + "name": "success-vlan-not-in-topology", # Should it succeed really ? TODO - this output should be impossible + "test": VerifySTPForwardingPorts, + "eos_data": [ + { + "unmappedVlans": [], + "topologies": {"Mst10": {"vlans": [10], "interfaces": {"Ethernet10": {"state": "forwarding"}, "MplsTrunk1": {"state": "forwarding"}}}}, + }, + { + "unmappedVlans": [], + "topologies": {"Mst10": {"vlans": [10], "interfaces": {"Ethernet10": {"state": "forwarding"}, "MplsTrunk1": {"state": "forwarding"}}}}, + }, + ], + "inputs": {"vlans": [10, 20]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-instances", + "test": VerifySTPForwardingPorts, + "eos_data": [{"unmappedVlans": [], "topologies": {}}, {"unmappedVlans": [], "topologies": {}}], + "inputs": {"vlans": [10, 20]}, + "expected": {"result": "failure", "messages": ["STP instance is not configured for the following VLAN(s): [10, 20]"]}, + }, + { + "name": "failure", + "test": VerifySTPForwardingPorts, + "eos_data": [ + { + "unmappedVlans": [], + "topologies": {"Vl10": {"vlans": [10], "interfaces": {"Ethernet10": {"state": "discarding"}, "MplsTrunk1": {"state": "forwarding"}}}}, + }, + { + "unmappedVlans": [], + "topologies": {"Vl20": {"vlans": [20], "interfaces": {"Ethernet10": {"state": "discarding"}, "MplsTrunk1": {"state": "forwarding"}}}}, + }, + ], + "inputs": {"vlans": [10, 20]}, + "expected": { + "result": "failure", + "messages": ["The following VLAN(s) have interface(s) that are not in a fowarding state: [{'VLAN 10': ['Ethernet10']}, {'VLAN 20': ['Ethernet10']}]"], + }, + }, + { + "name": "success-specific-instances", + "test": VerifySTPRootPriority, + "eos_data": [ + { + "instances": { + "VL10": { + "rootBridge": { + "priority": 32768, + "systemIdExtension": 10, + "macAddress": "00:1c:73:27:95:a2", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + }, + "VL20": { + "rootBridge": { + "priority": 32768, + "systemIdExtension": 20, + "macAddress": "00:1c:73:27:95:a2", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + }, + "VL30": { + "rootBridge": { + "priority": 32768, + "systemIdExtension": 30, + "macAddress": "00:1c:73:27:95:a2", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + }, + } + } + ], + "inputs": {"priority": 32768, "instances": [10, 20]}, + "expected": {"result": "success"}, + }, + { + "name": "success-all-instances", + "test": VerifySTPRootPriority, + "eos_data": [ + { + "instances": { + "VL10": { + "rootBridge": { + "priority": 32768, + "systemIdExtension": 10, + "macAddress": "00:1c:73:27:95:a2", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + }, + "VL20": { + "rootBridge": { + "priority": 32768, + "systemIdExtension": 20, + "macAddress": "00:1c:73:27:95:a2", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + }, + "VL30": { + "rootBridge": { + "priority": 32768, + "systemIdExtension": 30, + "macAddress": "00:1c:73:27:95:a2", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + }, + } + } + ], + "inputs": {"priority": 32768}, + "expected": {"result": "success"}, + }, + { + "name": "success-MST", + "test": VerifySTPRootPriority, + "eos_data": [ + { + "instances": { + "MST0": { + "rootBridge": { + "priority": 16384, + "systemIdExtension": 0, + "macAddress": "02:1c:73:8b:93:ac", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + } + } + } + ], + "inputs": {"priority": 16384, "instances": [0]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-instances", + "test": VerifySTPRootPriority, + "eos_data": [ + { + "instances": { + "WRONG0": { + "rootBridge": { + "priority": 16384, + "systemIdExtension": 0, + "macAddress": "02:1c:73:8b:93:ac", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + } + } + } + ], + "inputs": {"priority": 32768, "instances": [0]}, + "expected": {"result": "failure", "messages": ["Unsupported STP instance type: WRONG0"]}, + }, + { + "name": "failure-wrong-instance-type", + "test": VerifySTPRootPriority, + "eos_data": [{"instances": {}}], + "inputs": {"priority": 32768, "instances": [10, 20]}, + "expected": {"result": "failure", "messages": ["No STP instances configured"]}, + }, + { + "name": "failure-wrong-priority", + "test": VerifySTPRootPriority, + "eos_data": [ + { + "instances": { + "VL10": { + "rootBridge": { + "priority": 32768, + "systemIdExtension": 10, + "macAddress": "00:1c:73:27:95:a2", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + }, + "VL20": { + "rootBridge": { + "priority": 8196, + "systemIdExtension": 20, + "macAddress": "00:1c:73:27:95:a2", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + }, + "VL30": { + "rootBridge": { + "priority": 8196, + "systemIdExtension": 30, + "macAddress": "00:1c:73:27:95:a2", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + } + }, + } + } + ], + "inputs": {"priority": 32768, "instances": [10, 20, 30]}, + "expected": {"result": "failure", "messages": ["The following instance(s) have the wrong STP root priority configured: ['VL20', 'VL30']"]}, + }, +] diff --git a/tests/units/anta_tests/test_system.py b/tests/units/anta_tests/test_system.py new file mode 100644 index 0000000..62260fa --- /dev/null +++ b/tests/units/anta_tests/test_system.py @@ -0,0 +1,283 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Test inputs for anta.tests.system""" +from __future__ import annotations + +from typing import Any + +from anta.tests.system import ( + VerifyAgentLogs, + VerifyCoredump, + VerifyCPUUtilization, + VerifyFileSystemUtilization, + VerifyMemoryUtilization, + VerifyNTP, + VerifyReloadCause, + VerifyUptime, +) +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyUptime, + "eos_data": [{"upTime": 1186689.15, "loadAvg": [0.13, 0.12, 0.09], "users": 1, "currentTime": 1683186659.139859}], + "inputs": {"minimum": 666}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyUptime, + "eos_data": [{"upTime": 665.15, "loadAvg": [0.13, 0.12, 0.09], "users": 1, "currentTime": 1683186659.139859}], + "inputs": {"minimum": 666}, + "expected": {"result": "failure", "messages": ["Device uptime is 665.15 seconds"]}, + }, + { + "name": "success-no-reload", + "test": VerifyReloadCause, + "eos_data": [{"kernelCrashData": [], "resetCauses": [], "full": False}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "success-valid-cause", + "test": VerifyReloadCause, + "eos_data": [ + { + "resetCauses": [ + {"recommendedAction": "No action necessary.", "description": "Reload requested by the user.", "timestamp": 1683186892.0, "debugInfoIsDir": False} + ], + "full": False, + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyReloadCause, + # The failure cause is made up + "eos_data": [ + { + "resetCauses": [ + {"recommendedAction": "No action necessary.", "description": "Reload after crash.", "timestamp": 1683186892.0, "debugInfoIsDir": False} + ], + "full": False, + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Reload cause is: 'Reload after crash.'"]}, + }, + { + "name": "error", + "test": VerifyReloadCause, + "eos_data": [{}], + "inputs": None, + "expected": {"result": "error", "messages": ["No reload causes available"]}, + }, + { + "name": "success-without-minidump", + "test": VerifyCoredump, + "eos_data": [{"mode": "compressedDeferred", "coreFiles": []}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "success-with-minidump", + "test": VerifyCoredump, + "eos_data": [{"mode": "compressedDeferred", "coreFiles": ["minidump"]}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure-without-minidump", + "test": VerifyCoredump, + "eos_data": [{"mode": "compressedDeferred", "coreFiles": ["core.2344.1584483862.Mlag.gz", "core.23101.1584483867.Mlag.gz"]}], + "inputs": None, + "expected": {"result": "failure", "messages": ["Core dump(s) have been found: ['core.2344.1584483862.Mlag.gz', 'core.23101.1584483867.Mlag.gz']"]}, + }, + { + "name": "failure-with-minidump", + "test": VerifyCoredump, + "eos_data": [{"mode": "compressedDeferred", "coreFiles": ["minidump", "core.2344.1584483862.Mlag.gz", "core.23101.1584483867.Mlag.gz"]}], + "inputs": None, + "expected": {"result": "failure", "messages": ["Core dump(s) have been found: ['core.2344.1584483862.Mlag.gz', 'core.23101.1584483867.Mlag.gz']"]}, + }, + { + "name": "success", + "test": VerifyAgentLogs, + "eos_data": [""], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyAgentLogs, + "eos_data": [ + """===> /var/log/agents/Test-666 Thu May 4 09:57:02 2023 <=== +CLI Exception: Exception +CLI Exception: Backtrace +===> /var/log/agents/Aaa-855 Fri Jul 7 15:07:00 2023 <=== +===== Output from /usr/bin/Aaa [] (PID=855) started Jul 7 15:06:11.606414 === +EntityManager::doBackoff waiting for remote sysdb version ....ok + +===> /var/log/agents/Acl-830 Fri Jul 7 15:07:00 2023 <=== +===== Output from /usr/bin/Acl [] (PID=830) started Jul 7 15:06:10.871700 === +EntityManager::doBackoff waiting for remote sysdb version ...................ok +""" + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "Device has reported agent crashes:\n" + " * /var/log/agents/Test-666 Thu May 4 09:57:02 2023\n" + " * /var/log/agents/Aaa-855 Fri Jul 7 15:07:00 2023\n" + " * /var/log/agents/Acl-830 Fri Jul 7 15:07:00 2023", + ], + }, + }, + { + "name": "success", + "test": VerifyCPUUtilization, + "eos_data": [ + { + "cpuInfo": {"%Cpu(s)": {"idle": 88.2, "stolen": 0.0, "user": 5.9, "swIrq": 0.0, "ioWait": 0.0, "system": 0.0, "hwIrq": 5.9, "nice": 0.0}}, + "processes": { + "1": { + "userName": "root", + "status": "S", + "memPct": 0.3, + "niceValue": 0, + "cpuPct": 0.0, + "cpuPctType": "{:.1f}", + "cmd": "systemd", + "residentMem": "5096", + "priority": "20", + "activeTime": 360, + "virtMem": "6644", + "sharedMem": "3996", + } + }, + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyCPUUtilization, + "eos_data": [ + { + "cpuInfo": {"%Cpu(s)": {"idle": 24.8, "stolen": 0.0, "user": 5.9, "swIrq": 0.0, "ioWait": 0.0, "system": 0.0, "hwIrq": 5.9, "nice": 0.0}}, + "processes": { + "1": { + "userName": "root", + "status": "S", + "memPct": 0.3, + "niceValue": 0, + "cpuPct": 0.0, + "cpuPctType": "{:.1f}", + "cmd": "systemd", + "residentMem": "5096", + "priority": "20", + "activeTime": 360, + "virtMem": "6644", + "sharedMem": "3996", + } + }, + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Device has reported a high CPU utilization: 75.2%"]}, + }, + { + "name": "success", + "test": VerifyMemoryUtilization, + "eos_data": [ + { + "uptime": 1994.67, + "modelName": "vEOS-lab", + "internalVersion": "4.27.3F-26379303.4273F", + "memTotal": 2004568, + "memFree": 879004, + "version": "4.27.3F", + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyMemoryUtilization, + "eos_data": [ + { + "uptime": 1994.67, + "modelName": "vEOS-lab", + "internalVersion": "4.27.3F-26379303.4273F", + "memTotal": 2004568, + "memFree": 89004, + "version": "4.27.3F", + } + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["Device has reported a high memory usage: 95.56%"]}, + }, + { + "name": "success", + "test": VerifyFileSystemUtilization, + "eos_data": [ + """Filesystem Size Used Avail Use% Mounted on +/dev/sda2 3.9G 988M 2.9G 26% /mnt/flash +none 294M 78M 217M 27% / +none 294M 78M 217M 27% /.overlay +/dev/loop0 461M 461M 0 100% /rootfs-i386 +""" + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyFileSystemUtilization, + "eos_data": [ + """Filesystem Size Used Avail Use% Mounted on +/dev/sda2 3.9G 988M 2.9G 84% /mnt/flash +none 294M 78M 217M 27% / +none 294M 78M 217M 84% /.overlay +/dev/loop0 461M 461M 0 100% /rootfs-i386 +""" + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "Mount point /dev/sda2 3.9G 988M 2.9G 84% /mnt/flash is higher than 75%: reported 84%", + "Mount point none 294M 78M 217M 84% /.overlay is higher than 75%: reported 84%", + ], + }, + }, + { + "name": "success", + "test": VerifyNTP, + "eos_data": [ + """synchronised +poll interval unknown +""" + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyNTP, + "eos_data": [ + """unsynchronised +poll interval unknown +""" + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["The device is not synchronized with the configured NTP server(s): 'unsynchronised'"]}, + }, +] diff --git a/tests/units/anta_tests/test_vlan.py b/tests/units/anta_tests/test_vlan.py new file mode 100644 index 0000000..93398f6 --- /dev/null +++ b/tests/units/anta_tests/test_vlan.py @@ -0,0 +1,37 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.vlan.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.vlan import VerifyVlanInternalPolicy +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyVlanInternalPolicy, + "eos_data": [{"policy": "ascending", "startVlanId": 1006, "endVlanId": 4094}], + "inputs": {"policy": "ascending", "start_vlan_id": 1006, "end_vlan_id": 4094}, + "expected": {"result": "success"}, + }, + { + "name": "failure-incorrect-policy", + "test": VerifyVlanInternalPolicy, + "eos_data": [{"policy": "descending", "startVlanId": 4094, "endVlanId": 1006}], + "inputs": {"policy": "ascending", "start_vlan_id": 1006, "end_vlan_id": 4094}, + "expected": { + "result": "failure", + "messages": [ + "The VLAN internal allocation policy is not configured properly:\n" + "Expected `ascending` as the policy, but found `descending` instead.\n" + "Expected `1006` as the startVlanId, but found `4094` instead.\n" + "Expected `4094` as the endVlanId, but found `1006` instead." + ], + }, + }, +] diff --git a/tests/units/anta_tests/test_vxlan.py b/tests/units/anta_tests/test_vxlan.py new file mode 100644 index 0000000..2a9a875 --- /dev/null +++ b/tests/units/anta_tests/test_vxlan.py @@ -0,0 +1,365 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tests.vxlan.py +""" +from __future__ import annotations + +from typing import Any + +from anta.tests.vxlan import VerifyVxlan1ConnSettings, VerifyVxlan1Interface, VerifyVxlanConfigSanity, VerifyVxlanVniBinding, VerifyVxlanVtep +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyVxlan1Interface, + "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "up", "interfaceStatus": "up"}}}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "skipped", + "test": VerifyVxlan1Interface, + "eos_data": [{"interfaceDescriptions": {"Loopback0": {"lineProtocolStatus": "up", "interfaceStatus": "up"}}}], + "inputs": None, + "expected": {"result": "skipped", "messages": ["Vxlan1 interface is not configured"]}, + }, + { + "name": "failure", + "test": VerifyVxlan1Interface, + "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "down", "interfaceStatus": "up"}}}], + "inputs": None, + "expected": {"result": "failure", "messages": ["Vxlan1 interface is down/up"]}, + }, + { + "name": "failure", + "test": VerifyVxlan1Interface, + "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "up", "interfaceStatus": "down"}}}], + "inputs": None, + "expected": {"result": "failure", "messages": ["Vxlan1 interface is up/down"]}, + }, + { + "name": "failure", + "test": VerifyVxlan1Interface, + "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "down", "interfaceStatus": "down"}}}], + "inputs": None, + "expected": {"result": "failure", "messages": ["Vxlan1 interface is down/down"]}, + }, + { + "name": "success", + "test": VerifyVxlanConfigSanity, + "eos_data": [ + { + "categories": { + "localVtep": { + "description": "Local VTEP Configuration Check", + "allCheckPass": True, + "detail": "", + "hasWarning": False, + "items": [ + {"name": "Loopback IP Address", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "VLAN-VNI Map", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "Flood List", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "Routing", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "VNI VRF ACL", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "VRF-VNI Dynamic VLAN", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "Decap VRF-VNI Map", "checkPass": True, "hasWarning": False, "detail": ""}, + ], + }, + "remoteVtep": { + "description": "Remote VTEP Configuration Check", + "allCheckPass": True, + "detail": "", + "hasWarning": False, + "items": [{"name": "Remote VTEP", "checkPass": True, "hasWarning": False, "detail": ""}], + }, + "pd": { + "description": "Platform Dependent Check", + "allCheckPass": True, + "detail": "", + "hasWarning": False, + "items": [ + {"name": "VXLAN Bridging", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "VXLAN Routing", "checkPass": True, "hasWarning": False, "detail": "VXLAN Routing not enabled"}, + ], + }, + "cvx": { + "description": "CVX Configuration Check", + "allCheckPass": True, + "detail": "", + "hasWarning": False, + "items": [{"name": "CVX Server", "checkPass": True, "hasWarning": False, "detail": "Not in controller client mode"}], + }, + "mlag": { + "description": "MLAG Configuration Check", + "allCheckPass": True, + "detail": "Run 'show mlag config-sanity' to verify MLAG config", + "hasWarning": False, + "items": [ + {"name": "Peer VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "MLAG VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "Virtual VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "Peer VLAN-VNI", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "MLAG Inactive State", "checkPass": True, "hasWarning": False, "detail": ""}, + ], + }, + }, + "warnings": [], + } + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyVxlanConfigSanity, + "eos_data": [ + { + "categories": { + "localVtep": { + "description": "Local VTEP Configuration Check", + "allCheckPass": False, + "detail": "", + "hasWarning": True, + "items": [ + {"name": "Loopback IP Address", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "VLAN-VNI Map", "checkPass": False, "hasWarning": False, "detail": "No VLAN-VNI mapping in Vxlan1"}, + {"name": "Flood List", "checkPass": False, "hasWarning": True, "detail": "No VXLAN VLANs in Vxlan1"}, + {"name": "Routing", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "VNI VRF ACL", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "VRF-VNI Dynamic VLAN", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "Decap VRF-VNI Map", "checkPass": True, "hasWarning": False, "detail": ""}, + ], + }, + "remoteVtep": { + "description": "Remote VTEP Configuration Check", + "allCheckPass": True, + "detail": "", + "hasWarning": False, + "items": [{"name": "Remote VTEP", "checkPass": True, "hasWarning": False, "detail": ""}], + }, + "pd": { + "description": "Platform Dependent Check", + "allCheckPass": True, + "detail": "", + "hasWarning": False, + "items": [ + {"name": "VXLAN Bridging", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "VXLAN Routing", "checkPass": True, "hasWarning": False, "detail": "VXLAN Routing not enabled"}, + ], + }, + "cvx": { + "description": "CVX Configuration Check", + "allCheckPass": True, + "detail": "", + "hasWarning": False, + "items": [{"name": "CVX Server", "checkPass": True, "hasWarning": False, "detail": "Not in controller client mode"}], + }, + "mlag": { + "description": "MLAG Configuration Check", + "allCheckPass": True, + "detail": "Run 'show mlag config-sanity' to verify MLAG config", + "hasWarning": False, + "items": [ + {"name": "Peer VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "MLAG VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "Virtual VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "Peer VLAN-VNI", "checkPass": True, "hasWarning": False, "detail": ""}, + {"name": "MLAG Inactive State", "checkPass": True, "hasWarning": False, "detail": ""}, + ], + }, + }, + "warnings": ["Your configuration contains warnings. This does not mean misconfigurations. But you may wish to re-check your configurations."], + } + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "VXLAN config sanity check is not passing: {'localVtep': {'description': 'Local VTEP Configuration Check', " + "'allCheckPass': False, 'detail': '', 'hasWarning': True, 'items': [{'name': 'Loopback IP Address', 'checkPass': True, " + "'hasWarning': False, 'detail': ''}, {'name': 'VLAN-VNI Map', 'checkPass': False, 'hasWarning': False, 'detail': " + "'No VLAN-VNI mapping in Vxlan1'}, {'name': 'Flood List', 'checkPass': False, 'hasWarning': True, 'detail': " + "'No VXLAN VLANs in Vxlan1'}, {'name': 'Routing', 'checkPass': True, 'hasWarning': False, 'detail': ''}, {'name': " + "'VNI VRF ACL', 'checkPass': True, 'hasWarning': False, 'detail': ''}, {'name': 'VRF-VNI Dynamic VLAN', 'checkPass': True, " + "'hasWarning': False, 'detail': ''}, {'name': 'Decap VRF-VNI Map', 'checkPass': True, 'hasWarning': False, 'detail': ''}]}}" + ], + }, + }, + { + "name": "skipped", + "test": VerifyVxlanConfigSanity, + "eos_data": [{"categories": {}}], + "inputs": None, + "expected": {"result": "skipped", "messages": ["VXLAN is not configured"]}, + }, + { + "name": "success", + "test": VerifyVxlanVniBinding, + "eos_data": [ + { + "vxlanIntfs": { + "Vxlan1": { + "vniBindings": { + "10020": {"vlan": 20, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}} + }, + "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}}, + } + } + } + ], + "inputs": {"bindings": {10020: 20, 500: 1199}}, + "expected": {"result": "success"}, + }, + { + "name": "failure-no-binding", + "test": VerifyVxlanVniBinding, + "eos_data": [ + { + "vxlanIntfs": { + "Vxlan1": { + "vniBindings": { + "10020": {"vlan": 20, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}} + }, + "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}}, + } + } + } + ], + "inputs": {"bindings": {10010: 10, 10020: 20, 500: 1199}}, + "expected": {"result": "failure", "messages": ["The following VNI(s) have no binding: ['10010']"]}, + }, + { + "name": "failure-wrong-binding", + "test": VerifyVxlanVniBinding, + "eos_data": [ + { + "vxlanIntfs": { + "Vxlan1": { + "vniBindings": { + "10020": {"vlan": 30, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}} + }, + "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}}, + } + } + } + ], + "inputs": {"bindings": {10020: 20, 500: 1199}}, + "expected": {"result": "failure", "messages": ["The following VNI(s) have the wrong VLAN binding: [{'10020': 30}]"]}, + }, + { + "name": "failure-no-and-wrong-binding", + "test": VerifyVxlanVniBinding, + "eos_data": [ + { + "vxlanIntfs": { + "Vxlan1": { + "vniBindings": { + "10020": {"vlan": 30, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}} + }, + "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}}, + } + } + } + ], + "inputs": {"bindings": {10010: 10, 10020: 20, 500: 1199}}, + "expected": { + "result": "failure", + "messages": ["The following VNI(s) have no binding: ['10010']", "The following VNI(s) have the wrong VLAN binding: [{'10020': 30}]"], + }, + }, + { + "name": "skipped", + "test": VerifyVxlanVniBinding, + "eos_data": [{"vxlanIntfs": {}}], + "inputs": {"bindings": {10020: 20, 500: 1199}}, + "expected": {"result": "skipped", "messages": ["Vxlan1 interface is not configured"]}, + }, + { + "name": "success", + "test": VerifyVxlanVtep, + "eos_data": [{"vteps": {}, "interfaces": {"Vxlan1": {"vteps": ["10.1.1.5", "10.1.1.6"]}}}], + "inputs": {"vteps": ["10.1.1.5", "10.1.1.6"]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-missing-vtep", + "test": VerifyVxlanVtep, + "eos_data": [{"vteps": {}, "interfaces": {"Vxlan1": {"vteps": ["10.1.1.5", "10.1.1.6"]}}}], + "inputs": {"vteps": ["10.1.1.5", "10.1.1.6", "10.1.1.7"]}, + "expected": {"result": "failure", "messages": ["The following VTEP peer(s) are missing from the Vxlan1 interface: ['10.1.1.7']"]}, + }, + { + "name": "failure-no-vtep", + "test": VerifyVxlanVtep, + "eos_data": [{"vteps": {}, "interfaces": {"Vxlan1": {"vteps": []}}}], + "inputs": {"vteps": ["10.1.1.5", "10.1.1.6"]}, + "expected": {"result": "failure", "messages": ["The following VTEP peer(s) are missing from the Vxlan1 interface: ['10.1.1.5', '10.1.1.6']"]}, + }, + { + "name": "failure-no-input-vtep", + "test": VerifyVxlanVtep, + "eos_data": [{"vteps": {}, "interfaces": {"Vxlan1": {"vteps": ["10.1.1.5"]}}}], + "inputs": {"vteps": []}, + "expected": {"result": "failure", "messages": ["Unexpected VTEP peer(s) on Vxlan1 interface: ['10.1.1.5']"]}, + }, + { + "name": "failure-missmatch", + "test": VerifyVxlanVtep, + "eos_data": [{"vteps": {}, "interfaces": {"Vxlan1": {"vteps": ["10.1.1.6", "10.1.1.7", "10.1.1.8"]}}}], + "inputs": {"vteps": ["10.1.1.5", "10.1.1.6"]}, + "expected": { + "result": "failure", + "messages": [ + "The following VTEP peer(s) are missing from the Vxlan1 interface: ['10.1.1.5']", + "Unexpected VTEP peer(s) on Vxlan1 interface: ['10.1.1.7', '10.1.1.8']", + ], + }, + }, + { + "name": "skipped", + "test": VerifyVxlanVtep, + "eos_data": [{"vteps": {}, "interfaces": {}}], + "inputs": {"vteps": ["10.1.1.5", "10.1.1.6", "10.1.1.7"]}, + "expected": {"result": "skipped", "messages": ["Vxlan1 interface is not configured"]}, + }, + { + "name": "success", + "test": VerifyVxlan1ConnSettings, + "eos_data": [{"interfaces": {"Vxlan1": {"srcIpIntf": "Loopback1", "udpPort": 4789}}}], + "inputs": {"source_interface": "Loopback1", "udp_port": 4789}, + "expected": {"result": "success"}, + }, + { + "name": "skipped", + "test": VerifyVxlan1ConnSettings, + "eos_data": [{"interfaces": {}}], + "inputs": {"source_interface": "Loopback1", "udp_port": 4789}, + "expected": {"result": "skipped", "messages": ["Vxlan1 interface is not configured."]}, + }, + { + "name": "failure-wrong-interface", + "test": VerifyVxlan1ConnSettings, + "eos_data": [{"interfaces": {"Vxlan1": {"srcIpIntf": "Loopback10", "udpPort": 4789}}}], + "inputs": {"source_interface": "lo1", "udp_port": 4789}, + "expected": { + "result": "failure", + "messages": ["Source interface is not correct. Expected `Loopback1` as source interface but found `Loopback10` instead."], + }, + }, + { + "name": "failure-wrong-port", + "test": VerifyVxlan1ConnSettings, + "eos_data": [{"interfaces": {"Vxlan1": {"srcIpIntf": "Loopback10", "udpPort": 4789}}}], + "inputs": {"source_interface": "Lo1", "udp_port": 4780}, + "expected": { + "result": "failure", + "messages": [ + "Source interface is not correct. Expected `Loopback1` as source interface but found `Loopback10` instead.", + "UDP port is not correct. Expected `4780` as UDP port but found `4789` instead.", + ], + }, + }, +] diff --git a/tests/units/cli/__init__.py b/tests/units/cli/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/cli/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/cli/check/__init__.py b/tests/units/cli/check/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/cli/check/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/cli/check/test__init__.py b/tests/units/cli/check/test__init__.py new file mode 100644 index 0000000..a3a770b --- /dev/null +++ b/tests/units/cli/check/test__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests 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 new file mode 100644 index 0000000..746b315 --- /dev/null +++ b/tests/units/cli/check/test_commands.py @@ -0,0 +1,37 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.cli.check.commands +""" +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from anta.cli import anta +from anta.cli.utils import ExitCode + +if TYPE_CHECKING: + from click.testing import CliRunner + +DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data" + + +@pytest.mark.parametrize( + "catalog_path, expected_exit, expected_output", + [ + pytest.param("ghost_catalog.yml", ExitCode.USAGE_ERROR, "Error: Invalid value for '--catalog'", id="catalog does not exist"), + pytest.param("test_catalog_with_undefined_module.yml", ExitCode.USAGE_ERROR, "Test catalog is invalid!", id="catalog is not valid"), + pytest.param("test_catalog.yml", ExitCode.OK, "Catalog is valid", id="catalog valid"), + ], +) +def test_catalog(click_runner: CliRunner, catalog_path: Path, expected_exit: int, expected_output: str) -> None: + """ + Test `anta check catalog -c catalog + """ + 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/__init__.py b/tests/units/cli/debug/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/cli/debug/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/cli/debug/test__init__.py b/tests/units/cli/debug/test__init__.py new file mode 100644 index 0000000..062182d --- /dev/null +++ b/tests/units/cli/debug/test__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests 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 new file mode 100644 index 0000000..6d9ac29 --- /dev/null +++ b/tests/units/cli/debug/test_commands.py @@ -0,0 +1,60 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.cli.debug.commands +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +import pytest + +from anta.cli import anta +from anta.cli.utils import ExitCode + +if TYPE_CHECKING: + from click.testing import CliRunner + + +@pytest.mark.parametrize( + "command, ofmt, version, revision, device, failed", + [ + pytest.param("show version", "json", None, None, "dummy", False, id="json command"), + pytest.param("show version", "text", None, None, "dummy", False, id="text command"), + pytest.param("show version", None, "latest", None, "dummy", False, id="version-latest"), + pytest.param("show version", None, "1", None, "dummy", False, id="version"), + pytest.param("show version", None, None, 3, "dummy", False, id="revision"), + pytest.param("undefined", None, None, None, "dummy", True, id="command fails"), + ], +) +def test_run_cmd( + click_runner: CliRunner, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"] | None, revision: int | None, device: str, failed: bool +) -> None: + """ + Test `anta debug run-cmd` + """ + # pylint: disable=too-many-arguments + cli_args = ["-l", "debug", "debug", "run-cmd", "--command", command, "--device", device] + + # ofmt + if ofmt is not None: + cli_args.extend(["--ofmt", ofmt]) + + # version + if version is not None: + cli_args.extend(["--version", version]) + + # revision + if revision is not None: + cli_args.extend(["--revision", str(revision)]) + + result = click_runner.invoke(anta, cli_args) + if failed: + 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/__init__.py b/tests/units/cli/exec/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/cli/exec/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/cli/exec/test__init__.py b/tests/units/cli/exec/test__init__.py new file mode 100644 index 0000000..f8ad365 --- /dev/null +++ b/tests/units/cli/exec/test__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests 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 new file mode 100644 index 0000000..f96d7f6 --- /dev/null +++ b/tests/units/cli/exec/test_commands.py @@ -0,0 +1,125 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.cli.exec.commands +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from anta.cli import anta +from anta.cli.exec.commands import clear_counters, collect_tech_support, snapshot +from anta.cli.utils import ExitCode + +if TYPE_CHECKING: + from click.testing import CliRunner + + +def test_clear_counters_help(click_runner: CliRunner) -> None: + """ + Test `anta exec clear-counters --help` + """ + result = click_runner.invoke(clear_counters, ["--help"]) + assert result.exit_code == 0 + 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", + [ + pytest.param(None, id="no tags"), + pytest.param("leaf,spine", id="with tags"), + ], +) +def test_clear_counters(click_runner: CliRunner, tags: str | None) -> None: + """ + Test `anta exec clear-counters` + """ + cli_args = ["exec", "clear-counters"] + if tags is not None: + cli_args.extend(["--tags", tags]) + 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( + "commands_path, 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(tmp_path: Path, click_runner: CliRunner, commands_path: Path | None, tags: str | None) -> None: + """ + Test `anta exec snapshot` + """ + cli_args = ["exec", "snapshot", "--output", str(tmp_path)] + # Need to mock datetetime + if commands_path is not None: + cli_args.extend(["--commands-list", str(commands_path)]) + if tags is not None: + cli_args.extend(["--tags", tags]) + result = click_runner.invoke(anta, cli_args) + # Failure scenarios + if commands_path is None: + assert result.exit_code == ExitCode.USAGE_ERROR + return + if not Path.exists(Path(commands_path)): + assert result.exit_code == ExitCode.USAGE_ERROR + return + assert result.exit_code == ExitCode.OK + + +@pytest.mark.parametrize( + "output, latest, configure, tags", + [ + pytest.param(None, None, False, None, id="no params"), + pytest.param("/tmp/dummy", None, False, None, id="with output"), + pytest.param(None, 1, False, None, id="only last show tech"), + pytest.param(None, None, True, None, id="configure"), + pytest.param(None, None, False, "leaf,spine", id="with tags"), + ], +) +def test_collect_tech_support(click_runner: CliRunner, output: str | None, latest: str | None, configure: bool | None, tags: str | None) -> None: + """ + Test `anta exec collect-tech-support` + """ + cli_args = ["exec", "collect-tech-support"] + if output is not None: + cli_args.extend(["--output", output]) + if latest is not None: + cli_args.extend(["--latest", latest]) + if configure is True: + cli_args.extend(["--configure"]) + if tags is not None: + cli_args.extend(["--tags", tags]) + result = click_runner.invoke(anta, cli_args) + assert result.exit_code == ExitCode.OK diff --git a/tests/units/cli/exec/test_utils.py b/tests/units/cli/exec/test_utils.py new file mode 100644 index 0000000..6df1c86 --- /dev/null +++ b/tests/units/cli/exec/test_utils.py @@ -0,0 +1,134 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.cli.exec.utils +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import call, patch + +import pytest + +from anta.cli.exec.utils import clear_counters_utils # , collect_commands, collect_scheduled_show_tech +from anta.device import AntaDevice +from anta.inventory import AntaInventory +from anta.models import AntaCommand + +if TYPE_CHECKING: + from pytest import LogCaptureFixture + + +# TODO complete test cases +@pytest.mark.asyncio +@pytest.mark.parametrize( + "inventory_state, per_device_command_output, tags", + [ + pytest.param( + {"dummy": {"is_online": False}, "dummy2": {"is_online": False}, "dummy3": {"is_online": False}}, + {}, + None, + id="no_connected_device", + ), + pytest.param( + {"dummy": {"is_online": True, "hw_model": "cEOSLab"}, "dummy2": {"is_online": True, "hw_model": "vEOS-lab"}, "dummy3": {"is_online": False}}, + {}, + None, + id="cEOSLab and vEOS-lab devices", + ), + pytest.param( + {"dummy": {"is_online": True}, "dummy2": {"is_online": True}, "dummy3": {"is_online": False}}, + {"dummy": None}, # None means the command failed to collect + None, + id="device with error", + ), + pytest.param( + {"dummy": {"is_online": True}, "dummy2": {"is_online": True}, "dummy3": {"is_online": True}}, + {}, + ["spine"], + id="tags", + ), + ], +) +async def test_clear_counters_utils( + caplog: LogCaptureFixture, + test_inventory: AntaInventory, + inventory_state: dict[str, Any], + per_device_command_output: dict[str, Any], + tags: list[str] | None, +) -> None: + """ + Test anta.cli.exec.utils.clear_counters_utils + """ + + async def mock_connect_inventory() -> None: + """ + mocking connect_inventory coroutine + """ + for name, device in test_inventory.items(): + device.is_online = inventory_state[name].get("is_online", True) + device.established = inventory_state[name].get("established", device.is_online) + device.hw_model = inventory_state[name].get("hw_model", "dummy") + + async def dummy_collect(self: AntaDevice, command: AntaCommand) -> None: + """ + mocking collect coroutine + """ + command.output = per_device_command_output.get(self.name, "") + + # Need to patch the child device class + with patch("anta.device.AsyncEOSDevice.collect", side_effect=dummy_collect, autospec=True) as mocked_collect, patch( + "anta.inventory.AntaInventory.connect_inventory", + side_effect=mock_connect_inventory, + ) as mocked_connect_inventory: + print(mocked_collect) + mocked_collect.side_effect = dummy_collect + await clear_counters_utils(test_inventory, tags=tags) + + mocked_connect_inventory.assert_awaited_once() + devices_established = list(test_inventory.get_inventory(established_only=True, tags=tags).values()) + if devices_established: + # Building the list of calls + calls = [] + for device in devices_established: + calls.append( + call( + device, + **{ + "command": AntaCommand( + command="clear counters", + version="latest", + revision=None, + ofmt="json", + output=per_device_command_output.get(device.name, ""), + errors=[], + ) + }, + ) + ) + if device.hw_model not in ["cEOSLab", "vEOS-lab"]: + calls.append( + call( + device, + **{ + "command": AntaCommand( + command="clear hardware counter drop", + version="latest", + revision=None, + ofmt="json", + output=per_device_command_output.get(device.name, ""), + ) + }, + ) + ) + mocked_collect.assert_has_awaits(calls) + # Check error + for key, value in per_device_command_output.items(): + if value is None: + # means some command failed to collect + assert "ERROR" in caplog.text + assert f"Could not clear counters on device {key}: []" in caplog.text + else: + mocked_collect.assert_not_awaited() diff --git a/tests/units/cli/get/__init__.py b/tests/units/cli/get/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/cli/get/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/cli/get/test__init__.py b/tests/units/cli/get/test__init__.py new file mode 100644 index 0000000..b18ef88 --- /dev/null +++ b/tests/units/cli/get/test__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests 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 new file mode 100644 index 0000000..aa6dc4f --- /dev/null +++ b/tests/units/cli/get/test_commands.py @@ -0,0 +1,204 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.cli.get.commands +""" +from __future__ import annotations + +import filecmp +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import ANY, patch + +import pytest +from cvprac.cvp_client import CvpClient +from cvprac.cvp_client_errors import CvpApiError + +from anta.cli import anta +from anta.cli.utils import ExitCode + +if TYPE_CHECKING: + from click.testing import CliRunner + +DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data" + + +@pytest.mark.parametrize( + "cvp_container, cvp_connect_failure", + [ + pytest.param(None, False, id="all devices"), + pytest.param("custom_container", False, id="custom container"), + pytest.param(None, True, id="cvp connect failure"), + ], +) +def test_from_cvp( + tmp_path: Path, + click_runner: CliRunner, + cvp_container: str | None, + cvp_connect_failure: bool, +) -> None: + """ + Test `anta get from-cvp` + + 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(["--container", cvp_container]) + + def mock_cvp_connect(self: CvpClient, *args: str, **kwargs: str) -> None: + # pylint: disable=unused-argument + if cvp_connect_failure: + raise CvpApiError(msg="mocked CvpApiError") + + # always get a token + with patch("anta.cli.get.commands.get_cv_token", return_value="dummy_token"), patch( + "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: + result = click_runner.invoke(anta, cli_args) + + if not cvp_connect_failure: + assert output.exists() + + mocked_cvp_connect.assert_called_once() + if not cvp_connect_failure: + 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 == ExitCode.OK + else: + assert "Error connecting to CloudVision" in result.output + assert result.exit_code == ExitCode.USAGE_ERROR + + +@pytest.mark.parametrize( + "ansible_inventory, ansible_group, expected_exit, expected_log", + [ + pytest.param("ansible_inventory.yml", None, 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"), + ], +) +def test_from_ansible( + tmp_path: Path, + click_runner: CliRunner, + ansible_inventory: Path, + ansible_group: str | None, + expected_exit: int, + expected_log: str | None, +) -> None: + """ + Test `anta get from-ansible` + + This test verifies: + * the parsing of an ansible-inventory + * the ansible_group functionaliy + + 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]) + + result = click_runner.invoke(anta, cli_args) + + assert result.exit_code == expected_exit + + if expected_exit != ExitCode.OK: + assert expected_log + assert expected_log in result.output + else: + assert output.exists() + # TODO check size of generated inventory to validate the group functionality! + + +@pytest.mark.parametrize( + "env_set, overwrite, is_tty, prompt, expected_exit, expected_log", + [ + 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"), + ], +) +def test_from_ansible_overwrite( + tmp_path: Path, + click_runner: CliRunner, + 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` overwrite mechanism + + The test uses a static ansible-inventory and output as these are tested in other functions + + This test verifies: + * that overwrite is working as expected with or without init data in the target file + * that when the target file is not empty and a tty is present, the user is prompt with confirmation + * Check the behavior when the prompt is filled + + The initial content of the ANTA inventory is set using init_anta_inventory, if it is None, no inventory is set. + + * With overwrite True, the expectation is that the from-ansible command succeeds + * With no init (init_anta_inventory == None), the expectation is also that command succeeds + """ + 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 env_set: + tmp_inv = Path(str(temp_env["ANTA_INVENTORY"])) + else: + temp_env["ANTA_INVENTORY"] = None + tmp_inv = tmp_output + cli_args.extend(["--output", str(tmp_output)]) + + if overwrite: + cli_args.append("--overwrite") + + # Verify initial content is different + if tmp_inv.exists(): + assert not filecmp.cmp(tmp_inv, expected_anta_inventory_path) + + 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 + 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 new file mode 100644 index 0000000..b335880 --- /dev/null +++ b/tests/units/cli/get/test_utils.py @@ -0,0 +1,115 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.cli.get.utils +""" +from __future__ import annotations + +from contextlib import nullcontext +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from anta.cli.get.utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token +from anta.inventory import AntaInventory + +DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data" + + +def test_get_cv_token() -> None: + """ + Test anta.get.utils.get_cv_token + """ + ip = "42.42.42.42" + username = "ant" + password = "formica" + + with patch("anta.cli.get.utils.requests.request") as patched_request: + mocked_ret = MagicMock(autospec=requests.Response) + mocked_ret.json.return_value = {"sessionId": "simple"} + patched_request.return_value = mocked_ret + res = get_cv_token(ip, username, password) + patched_request.assert_called_once_with( + "POST", + "https://42.42.42.42/cvpservice/login/authenticate.do", + headers={"Content-Type": "application/json", "Accept": "application/json"}, + data='{"userId": "ant", "password": "formica"}', + verify=False, + timeout=10, + ) + assert res == "simple" + + +# truncated inventories +CVP_INVENTORY = [ + { + "hostname": "device1", + "containerName": "DC1", + "ipAddress": "10.20.20.97", + }, + { + "hostname": "device2", + "containerName": "DC2", + "ipAddress": "10.20.20.98", + }, + { + "hostname": "device3", + "containerName": "", + "ipAddress": "10.20.20.99", + }, +] + + +@pytest.mark.parametrize( + "inventory", + [ + pytest.param(CVP_INVENTORY, id="some container"), + pytest.param([], id="empty_inventory"), + ], +) +def test_create_inventory_from_cvp(tmp_path: Path, inventory: list[dict[str, Any]]) -> None: + """ + Test anta.get.utils.create_inventory_from_cvp + """ + output = tmp_path / "output.yml" + + create_inventory_from_cvp(inventory, output) + + assert output.exists() + # This validate the file structure ;) + inv = AntaInventory.parse(str(output), "user", "pass") + assert len(inv) == len(inventory) + + +@pytest.mark.parametrize( + "inventory_filename, ansible_group, expected_raise, expected_inv_length", + [ + pytest.param("ansible_inventory.yml", None, nullcontext(), 7, id="no group"), + pytest.param("ansible_inventory.yml", "ATD_LEAFS", nullcontext(), 4, id="group found"), + pytest.param("ansible_inventory.yml", "DUMMY", pytest.raises(ValueError, match="Group DUMMY not found in Ansible inventory"), 0, id="group not found"), + pytest.param("empty_ansible_inventory.yml", None, pytest.raises(ValueError, match="Ansible inventory .* is empty"), 0, id="empty inventory"), + pytest.param("wrong_ansible_inventory.yml", None, pytest.raises(ValueError, match="Could not parse"), 0, id="os error inventory"), + ], +) +def test_create_inventory_from_ansible(tmp_path: Path, inventory_filename: Path, ansible_group: str | None, expected_raise: Any, expected_inv_length: int) -> None: + """ + Test anta.get.utils.create_inventory_from_ansible + """ + target_file = tmp_path / "inventory.yml" + inventory_file_path = DATA_DIR / inventory_filename + + with expected_raise: + if ansible_group: + create_inventory_from_ansible(inventory_file_path, target_file, ansible_group) + else: + create_inventory_from_ansible(inventory_file_path, target_file) + + assert target_file.exists() + inv = AntaInventory().parse(str(target_file), "user", "pass") + assert len(inv) == expected_inv_length + if not isinstance(expected_raise, nullcontext): + assert not target_file.exists() diff --git a/tests/units/cli/nrfu/__init__.py b/tests/units/cli/nrfu/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/cli/nrfu/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py new file mode 100644 index 0000000..fea641c --- /dev/null +++ b/tests/units/cli/nrfu/test__init__.py @@ -0,0 +1,111 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests 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 0000000..4639671 --- /dev/null +++ b/tests/units/cli/nrfu/test_commands.py @@ -0,0 +1,97 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests 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 new file mode 100644 index 0000000..0e84e14 --- /dev/null +++ b/tests/units/cli/test__init__.py @@ -0,0 +1,58 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.cli.__init__ +""" + +from __future__ import annotations + +from click.testing import CliRunner + +from anta.cli import anta +from anta.cli.utils import ExitCode + + +def test_anta(click_runner: CliRunner) -> None: + """ + Test anta main entrypoint + """ + result = click_runner.invoke(anta) + assert result.exit_code == ExitCode.OK + assert "Usage" in result.output + + +def test_anta_help(click_runner: CliRunner) -> None: + """ + Test anta --help + """ + result = click_runner.invoke(anta, ["--help"]) + assert result.exit_code == ExitCode.OK + assert "Usage" 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 + + +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 + + +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/inventory/__init__.py b/tests/units/inventory/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/inventory/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/inventory/test_inventory.py b/tests/units/inventory/test_inventory.py new file mode 100644 index 0000000..7c62b5c --- /dev/null +++ b/tests/units/inventory/test_inventory.py @@ -0,0 +1,81 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""ANTA Inventory unit tests.""" +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import pytest +import yaml +from pydantic import ValidationError + +from anta.inventory import AntaInventory +from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError +from tests.data.json_data import ANTA_INVENTORY_TESTS_INVALID, ANTA_INVENTORY_TESTS_VALID +from tests.lib.utils import generate_test_ids_dict + + +class Test_AntaInventory: + """Test AntaInventory class.""" + + def create_inventory(self, content: str, tmp_path: Path) -> str: + """Create fakefs inventory file.""" + tmp_inventory = tmp_path / "mydir/myfile" + tmp_inventory.parent.mkdir() + tmp_inventory.touch() + tmp_inventory.write_text(yaml.dump(content, allow_unicode=True)) + return str(tmp_inventory) + + def check_parameter(self, parameter: str, test_definition: dict[Any, Any]) -> bool: + """Check if parameter is configured in testbed.""" + return "parameters" in test_definition and parameter in test_definition["parameters"].keys() + + @pytest.mark.parametrize("test_definition", ANTA_INVENTORY_TESTS_VALID, ids=generate_test_ids_dict) + def test_init_valid(self, test_definition: dict[str, Any], tmp_path: Path) -> None: + """Test class constructor with valid data. + + Test structure: + --------------- + + { + 'name': 'ValidInventory_with_host_only', + 'input': {"anta_inventory":{"hosts":[{"host":"192.168.0.17"},{"host":"192.168.0.2"}]}}, + 'expected_result': 'valid', + 'parameters': { + 'ipaddress_in_scope': '192.168.0.17', + 'ipaddress_out_of_scope': '192.168.1.1', + } + } + + """ + inventory_file = self.create_inventory(content=test_definition["input"], tmp_path=tmp_path) + try: + AntaInventory.parse(filename=inventory_file, username="arista", password="arista123") + except ValidationError as exc: + logging.error("Exceptions is: %s", str(exc)) + assert False + + @pytest.mark.parametrize("test_definition", ANTA_INVENTORY_TESTS_INVALID, ids=generate_test_ids_dict) + def test_init_invalid(self, test_definition: dict[str, Any], tmp_path: Path) -> None: + """Test class constructor with invalid data. + + Test structure: + --------------- + + { + 'name': 'ValidInventory_with_host_only', + 'input': {"anta_inventory":{"hosts":[{"host":"192.168.0.17"},{"host":"192.168.0.2"}]}}, + 'expected_result': 'invalid', + 'parameters': { + 'ipaddress_in_scope': '192.168.0.17', + 'ipaddress_out_of_scope': '192.168.1.1', + } + } + + """ + inventory_file = self.create_inventory(content=test_definition["input"], tmp_path=tmp_path) + with pytest.raises((InventoryIncorrectSchema, InventoryRootKeyError, ValidationError)): + AntaInventory.parse(filename=inventory_file, username="arista", password="arista123") diff --git a/tests/units/inventory/test_models.py b/tests/units/inventory/test_models.py new file mode 100644 index 0000000..83f151c --- /dev/null +++ b/tests/units/inventory/test_models.py @@ -0,0 +1,393 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""ANTA Inventory models unit tests.""" +from __future__ import annotations + +import logging +from typing import Any + +import pytest +from pydantic import ValidationError + +from anta.device import AsyncEOSDevice +from anta.inventory.models import AntaInventoryHost, AntaInventoryInput, AntaInventoryNetwork, AntaInventoryRange +from tests.data.json_data import ( + INVENTORY_DEVICE_MODEL_INVALID, + INVENTORY_DEVICE_MODEL_VALID, + INVENTORY_MODEL_HOST_CACHE, + INVENTORY_MODEL_HOST_INVALID, + INVENTORY_MODEL_HOST_VALID, + INVENTORY_MODEL_INVALID, + INVENTORY_MODEL_NETWORK_CACHE, + INVENTORY_MODEL_NETWORK_INVALID, + INVENTORY_MODEL_NETWORK_VALID, + INVENTORY_MODEL_RANGE_CACHE, + INVENTORY_MODEL_RANGE_INVALID, + INVENTORY_MODEL_RANGE_VALID, + INVENTORY_MODEL_VALID, +) +from tests.lib.utils import generate_test_ids_dict + + +class Test_InventoryUnitModels: + """Test components of AntaInventoryInput model.""" + + @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_VALID, ids=generate_test_ids_dict) + def test_anta_inventory_host_valid(self, test_definition: dict[str, Any]) -> None: + """Test host input model. + + Test structure: + --------------- + + { + 'name': 'ValidIPv4_Host', + 'input': '1.1.1.1', + 'expected_result': 'valid' + } + + """ + try: + host_inventory = AntaInventoryHost(host=test_definition["input"]) + except ValidationError as exc: + logging.warning("Error: %s", str(exc)) + assert False + else: + assert test_definition["input"] == str(host_inventory.host) + + @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_INVALID, ids=generate_test_ids_dict) + def test_anta_inventory_host_invalid(self, test_definition: dict[str, Any]) -> None: + """Test host input model. + + Test structure: + --------------- + + { + 'name': 'ValidIPv4_Host', + 'input': '1.1.1.1/32', + 'expected_result': 'invalid' + } + + """ + with pytest.raises(ValidationError): + AntaInventoryHost(host=test_definition["input"]) + + @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_CACHE, ids=generate_test_ids_dict) + def test_anta_inventory_host_cache(self, test_definition: dict[str, Any]) -> None: + """Test host disable_cache. + + Test structure: + --------------- + + { + 'name': 'Cache', + 'input': {"host": '1.1.1.1', "disable_cache": True}, + 'expected_result': True + } + + """ + if "disable_cache" in test_definition["input"]: + host_inventory = AntaInventoryHost(host=test_definition["input"]["host"], disable_cache=test_definition["input"]["disable_cache"]) + else: + host_inventory = AntaInventoryHost(host=test_definition["input"]["host"]) + assert test_definition["expected_result"] == host_inventory.disable_cache + + @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_VALID, ids=generate_test_ids_dict) + def test_anta_inventory_network_valid(self, test_definition: dict[str, Any]) -> None: + """Test Network input model with valid data. + + Test structure: + --------------- + + { + 'name': 'ValidIPv4_Subnet', + 'input': '1.1.1.0/24', + 'expected_result': 'valid' + } + + """ + try: + network_inventory = AntaInventoryNetwork(network=test_definition["input"]) + except ValidationError as exc: + logging.warning("Error: %s", str(exc)) + assert False + else: + assert test_definition["input"] == str(network_inventory.network) + + @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_INVALID, ids=generate_test_ids_dict) + def test_anta_inventory_network_invalid(self, test_definition: dict[str, Any]) -> None: + """Test Network input model with invalid data. + + Test structure: + --------------- + + { + 'name': 'ValidIPv4_Subnet', + 'input': '1.1.1.0/16', + 'expected_result': 'invalid' + } + + """ + try: + AntaInventoryNetwork(network=test_definition["input"]) + except ValidationError as exc: + logging.warning("Error: %s", str(exc)) + else: + assert False + + @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_CACHE, ids=generate_test_ids_dict) + def test_anta_inventory_network_cache(self, test_definition: dict[str, Any]) -> None: + """Test network disable_cache + + Test structure: + --------------- + + { + 'name': 'Cache', + 'input': {"network": '1.1.1.1/24', "disable_cache": True}, + 'expected_result': True + } + + """ + if "disable_cache" in test_definition["input"]: + network_inventory = AntaInventoryNetwork(network=test_definition["input"]["network"], disable_cache=test_definition["input"]["disable_cache"]) + else: + network_inventory = AntaInventoryNetwork(network=test_definition["input"]["network"]) + assert test_definition["expected_result"] == network_inventory.disable_cache + + @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_VALID, ids=generate_test_ids_dict) + def test_anta_inventory_range_valid(self, test_definition: dict[str, Any]) -> None: + """Test range input model. + + Test structure: + --------------- + + { + 'name': 'ValidIPv4_Range', + 'input': {'start':'10.1.0.1', 'end':'10.1.0.10'}, + 'expected_result': 'valid' + } + + """ + try: + range_inventory = AntaInventoryRange( + start=test_definition["input"]["start"], + end=test_definition["input"]["end"], + ) + except ValidationError as exc: + logging.warning("Error: %s", str(exc)) + assert False + else: + assert test_definition["input"]["start"] == str(range_inventory.start) + assert test_definition["input"]["end"] == str(range_inventory.end) + + @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_INVALID, ids=generate_test_ids_dict) + def test_anta_inventory_range_invalid(self, test_definition: dict[str, Any]) -> None: + """Test range input model. + + Test structure: + --------------- + + { + 'name': 'ValidIPv4_Range', + 'input': {'start':'10.1.0.1', 'end':'10.1.0.10/32'}, + 'expected_result': 'invalid' + } + + """ + try: + AntaInventoryRange( + start=test_definition["input"]["start"], + end=test_definition["input"]["end"], + ) + except ValidationError as exc: + logging.warning("Error: %s", str(exc)) + else: + assert False + + @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_CACHE, ids=generate_test_ids_dict) + def test_anta_inventory_range_cache(self, test_definition: dict[str, Any]) -> None: + """Test range disable_cache + + Test structure: + --------------- + + { + 'name': 'Cache', + 'input': {"start": '1.1.1.1', "end": "1.1.1.10", "disable_cache": True}, + 'expected_result': True + } + + """ + if "disable_cache" in test_definition["input"]: + range_inventory = AntaInventoryRange( + start=test_definition["input"]["start"], end=test_definition["input"]["end"], disable_cache=test_definition["input"]["disable_cache"] + ) + else: + range_inventory = AntaInventoryRange(start=test_definition["input"]["start"], end=test_definition["input"]["end"]) + assert test_definition["expected_result"] == range_inventory.disable_cache + + +class Test_AntaInventoryInputModel: + """Unit test of AntaInventoryInput model.""" + + def test_inventory_input_structure(self) -> None: + """Test inventory keys are those expected.""" + + inventory = AntaInventoryInput() + logging.info("Inventory keys are: %s", str(inventory.model_dump().keys())) + assert all(elem in inventory.model_dump().keys() for elem in ["hosts", "networks", "ranges"]) + + @pytest.mark.parametrize("inventory_def", INVENTORY_MODEL_VALID, ids=generate_test_ids_dict) + def test_anta_inventory_intput_valid(self, inventory_def: dict[str, Any]) -> None: + """Test loading valid data to inventory class. + + Test structure: + --------------- + + { + "name": "Valid_Host_Only", + "input": { + "hosts": [ + { + "host": "192.168.0.17" + }, + { + "host": "192.168.0.2" + } + ] + }, + "expected_result": "valid" + } + + """ + try: + inventory = AntaInventoryInput(**inventory_def["input"]) + except ValidationError as exc: + logging.warning("Error: %s", str(exc)) + assert False + else: + logging.info("Checking if all root keys are correctly lodaded") + assert all(elem in inventory.model_dump().keys() for elem in inventory_def["input"].keys()) + + @pytest.mark.parametrize("inventory_def", INVENTORY_MODEL_INVALID, ids=generate_test_ids_dict) + def test_anta_inventory_intput_invalid(self, inventory_def: dict[str, Any]) -> None: + """Test loading invalid data to inventory class. + + Test structure: + --------------- + + { + "name": "Valid_Host_Only", + "input": { + "hosts": [ + { + "host": "192.168.0.17" + }, + { + "host": "192.168.0.2/32" + } + ] + }, + "expected_result": "invalid" + } + + """ + try: + if "hosts" in inventory_def["input"].keys(): + logging.info( + "Loading %s into AntaInventoryInput hosts section", + str(inventory_def["input"]["hosts"]), + ) + AntaInventoryInput(hosts=inventory_def["input"]["hosts"]) + if "networks" in inventory_def["input"].keys(): + logging.info( + "Loading %s into AntaInventoryInput networks section", + str(inventory_def["input"]["networks"]), + ) + AntaInventoryInput(networks=inventory_def["input"]["networks"]) + if "ranges" in inventory_def["input"].keys(): + logging.info( + "Loading %s into AntaInventoryInput ranges section", + str(inventory_def["input"]["ranges"]), + ) + AntaInventoryInput(ranges=inventory_def["input"]["ranges"]) + except ValidationError as exc: + logging.warning("Error: %s", str(exc)) + else: + assert False + + +class Test_InventoryDeviceModel: + """Unit test of InventoryDevice model.""" + + @pytest.mark.parametrize("test_definition", INVENTORY_DEVICE_MODEL_VALID, ids=generate_test_ids_dict) + def test_inventory_device_valid(self, test_definition: dict[str, Any]) -> None: + """Test loading valid data to InventoryDevice class. + + Test structure: + --------------- + + { + "name": "Valid_Inventory", + "input": [ + { + 'host': '1.1.1.1', + 'username': 'arista', + 'password': 'arista123!' + }, + { + 'host': '1.1.1.1', + 'username': 'arista', + 'password': 'arista123!' + } + ], + "expected_result": "valid" + } + + """ + if test_definition["expected_result"] == "invalid": + pytest.skip("Not concerned by the test") + + for entity in test_definition["input"]: + try: + AsyncEOSDevice(**entity) + except TypeError as exc: + logging.warning("Error: %s", str(exc)) + assert False + + @pytest.mark.parametrize("test_definition", INVENTORY_DEVICE_MODEL_INVALID, ids=generate_test_ids_dict) + def test_inventory_device_invalid(self, test_definition: dict[str, Any]) -> None: + """Test loading invalid data to InventoryDevice class. + + Test structure: + --------------- + + { + "name": "Valid_Inventory", + "input": [ + { + 'host': '1.1.1.1', + 'username': 'arista', + 'password': 'arista123!' + }, + { + 'host': '1.1.1.1', + 'username': 'arista', + 'password': 'arista123!' + } + ], + "expected_result": "valid" + } + + """ + if test_definition["expected_result"] == "valid": + pytest.skip("Not concerned by the test") + + for entity in test_definition["input"]: + try: + AsyncEOSDevice(**entity) + except TypeError as exc: + logging.info("Error: %s", str(exc)) + else: + assert False diff --git a/tests/units/reporter/__init__.py b/tests/units/reporter/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/reporter/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/reporter/test__init__.py b/tests/units/reporter/test__init__.py new file mode 100644 index 0000000..259942f --- /dev/null +++ b/tests/units/reporter/test__init__.py @@ -0,0 +1,193 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test anta.report.__init__.py +""" +from __future__ import annotations + +from typing import Callable + +import pytest +from rich.table import Table + +from anta import RICH_COLOR_PALETTE +from anta.custom_types import TestStatus +from anta.reporter import ReportTable +from anta.result_manager import ResultManager + + +class Test_ReportTable: + """ + Test ReportTable class + """ + + # not testing __init__ as nothing is going on there + + @pytest.mark.parametrize( + "usr_list, delimiter, expected_output", + [ + pytest.param([], None, "", id="empty list no delimiter"), + pytest.param([], "*", "", id="empty list with delimiter"), + pytest.param(["elem1"], None, "elem1", id="one elem list no delimiter"), + pytest.param(["elem1"], "*", "* elem1", id="one elem list with delimiter"), + pytest.param(["elem1", "elem2"], None, "elem1\nelem2", id="two elems list no delimiter"), + pytest.param(["elem1", "elem2"], "&", "& elem1\n& elem2", id="two elems list with delimiter"), + ], + ) + def test__split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None, expected_output: str) -> None: + """ + test _split_list_to_txt_list + """ + # pylint: disable=protected-access + report = ReportTable() + assert report._split_list_to_txt_list(usr_list, delimiter) == expected_output + + @pytest.mark.parametrize( + "headers", + [ + pytest.param([], id="empty list"), + pytest.param(["elem1"], id="one elem list"), + pytest.param(["elem1", "elem2"], id="two elemst"), + ], + ) + def test__build_headers(self, headers: list[str]) -> None: + """ + test _build_headers + """ + # pylint: disable=protected-access + report = ReportTable() + table = Table() + table_column_before = len(table.columns) + report._build_headers(headers, table) + assert len(table.columns) == table_column_before + len(headers) + if len(table.columns) > 0: + assert table.columns[table_column_before].style == RICH_COLOR_PALETTE.HEADER + + @pytest.mark.parametrize( + "status, expected_status", + [ + pytest.param("unknown", "unknown", id="unknown status"), + pytest.param("unset", "[grey74]unset", id="unset status"), + pytest.param("skipped", "[bold orange4]skipped", id="skipped status"), + pytest.param("failure", "[bold red]failure", id="failure status"), + pytest.param("error", "[indian_red]error", id="error status"), + pytest.param("success", "[green4]success", id="success status"), + ], + ) + def test__color_result(self, status: TestStatus, expected_status: str) -> None: + """ + test _build_headers + """ + # pylint: disable=protected-access + report = ReportTable() + assert report._color_result(status) == expected_status + + @pytest.mark.parametrize( + "host, testcase, title, number_of_tests, expected_length", + [ + pytest.param(None, None, None, 5, 5, id="all results"), + pytest.param("host1", None, None, 5, 0, id="result for host1 when no host1 test"), + pytest.param(None, "VerifyTest3", None, 5, 1, id="result for test VerifyTest3"), + pytest.param(None, None, "Custom title", 5, 5, id="Change table title"), + ], + ) + def test_report_all( + self, + result_manager_factory: Callable[[int], ResultManager], + host: str | None, + testcase: str | None, + title: str | None, + number_of_tests: int, + expected_length: int, + ) -> None: + """ + test report_all + """ + # pylint: disable=too-many-arguments + rm = result_manager_factory(number_of_tests) + + report = ReportTable() + kwargs = {"host": host, "testcase": testcase, "title": title} + kwargs = {k: v for k, v in kwargs.items() if v is not None} + res = report.report_all(rm, **kwargs) # type: ignore[arg-type] + + assert isinstance(res, Table) + assert res.title == (title or "All tests results") + assert res.row_count == expected_length + + @pytest.mark.parametrize( + "testcase, title, number_of_tests, expected_length", + [ + pytest.param(None, None, 5, 5, id="all results"), + pytest.param("VerifyTest3", None, 5, 1, id="result for test VerifyTest3"), + pytest.param(None, "Custom title", 5, 5, id="Change table title"), + ], + ) + def test_report_summary_tests( + self, + result_manager_factory: Callable[[int], ResultManager], + testcase: str | None, + title: str | None, + number_of_tests: int, + expected_length: int, + ) -> None: + """ + test report_summary_tests + """ + # pylint: disable=too-many-arguments + # TODO refactor this later... this is injecting double test results by modyfing the device name + # should be a fixture + rm = result_manager_factory(number_of_tests) + new_results = [result.model_copy() for result in rm.get_results()] + for result in new_results: + result.name = "test_device" + result.result = "failure" + rm.add_test_results(new_results) + + report = ReportTable() + kwargs = {"testcase": testcase, "title": title} + kwargs = {k: v for k, v in kwargs.items() if v is not None} + res = report.report_summary_tests(rm, **kwargs) # type: ignore[arg-type] + + assert isinstance(res, Table) + assert res.title == (title or "Summary per test case") + assert res.row_count == expected_length + + @pytest.mark.parametrize( + "host, title, number_of_tests, expected_length", + [ + pytest.param(None, None, 5, 2, id="all results"), + pytest.param("host1", None, 5, 1, id="result for host host1"), + pytest.param(None, "Custom title", 5, 2, id="Change table title"), + ], + ) + def test_report_summary_hosts( + self, + result_manager_factory: Callable[[int], ResultManager], + host: str | None, + title: str | None, + number_of_tests: int, + expected_length: int, + ) -> None: + """ + test report_summary_hosts + """ + # pylint: disable=too-many-arguments + # TODO refactor this later... this is injecting double test results by modyfing the device name + # should be a fixture + rm = result_manager_factory(number_of_tests) + new_results = [result.model_copy() for result in rm.get_results()] + for result in new_results: + result.name = host or "test_device" + result.result = "failure" + rm.add_test_results(new_results) + + report = ReportTable() + kwargs = {"host": host, "title": title} + kwargs = {k: v for k, v in kwargs.items() if v is not None} + res = report.report_summary_hosts(rm, **kwargs) # type: ignore[arg-type] + + assert isinstance(res, Table) + assert res.title == (title or "Summary per host") + assert res.row_count == expected_length diff --git a/tests/units/result_manager/__init__.py b/tests/units/result_manager/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/result_manager/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py new file mode 100644 index 0000000..c457c84 --- /dev/null +++ b/tests/units/result_manager/test__init__.py @@ -0,0 +1,204 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Test anta.result_manager.__init__.py +""" +from __future__ import annotations + +import json +from contextlib import nullcontext +from typing import TYPE_CHECKING, Any, Callable + +import pytest + +from anta.custom_types import TestStatus +from anta.result_manager import ResultManager + +if TYPE_CHECKING: + from anta.result_manager.models import TestResult + + +class Test_ResultManager: + """ + Test ResultManager class + """ + + # not testing __init__ as nothing is going on there + + def test__len__(self, list_result_factory: Callable[[int], list[TestResult]]) -> None: + """ + test __len__ + """ + list_result = list_result_factory(3) + result_manager = ResultManager() + assert len(result_manager) == 0 + for i in range(3): + result_manager.add_test_result(list_result[i]) + assert len(result_manager) == i + 1 + + @pytest.mark.parametrize( + "starting_status, test_status, expected_status, expected_raise", + [ + pytest.param("unset", "unset", "unset", nullcontext(), id="unset->unset"), + pytest.param("unset", "success", "success", nullcontext(), id="unset->success"), + pytest.param("unset", "error", "unset", nullcontext(), id="set error"), + pytest.param("skipped", "skipped", "skipped", nullcontext(), id="skipped->skipped"), + pytest.param("skipped", "unset", "skipped", nullcontext(), id="skipped, add unset"), + pytest.param("skipped", "success", "success", nullcontext(), id="skipped, add success"), + pytest.param("skipped", "failure", "failure", nullcontext(), id="skipped, add failure"), + pytest.param("success", "unset", "success", nullcontext(), id="success, add unset"), + pytest.param("success", "skipped", "success", nullcontext(), id="success, add skipped"), + pytest.param("success", "success", "success", nullcontext(), id="success->success"), + pytest.param("success", "failure", "failure", nullcontext(), id="success->failure"), + pytest.param("failure", "unset", "failure", nullcontext(), id="failure->failure"), + pytest.param("failure", "skipped", "failure", nullcontext(), id="failure, add unset"), + pytest.param("failure", "success", "failure", nullcontext(), id="failure, add skipped"), + pytest.param("failure", "failure", "failure", nullcontext(), id="failure, add success"), + pytest.param("unset", "unknown", None, pytest.raises(ValueError), id="wrong status"), + ], + ) + def test__update_status(self, starting_status: TestStatus, test_status: TestStatus, expected_status: str, expected_raise: Any) -> None: + """ + Test ResultManager._update_status + """ + result_manager = ResultManager() + result_manager.status = starting_status + assert result_manager.error_status is False + + with expected_raise: + result_manager._update_status(test_status) # pylint: disable=protected-access + if test_status == "error": + assert result_manager.error_status is True + else: + assert result_manager.status == expected_status + + def test_add_test_result(self, test_result_factory: Callable[[int], TestResult]) -> None: + """ + Test ResultManager.add_test_result + """ + result_manager = ResultManager() + assert result_manager.status == "unset" + assert result_manager.error_status is False + assert len(result_manager) == 0 + + # Add one unset test + unset_test = test_result_factory(0) + unset_test.result = "unset" + result_manager.add_test_result(unset_test) + assert result_manager.status == "unset" + assert result_manager.error_status is False + assert len(result_manager) == 1 + + # Add one success test + success_test = test_result_factory(1) + success_test.result = "success" + result_manager.add_test_result(success_test) + assert result_manager.status == "success" + assert result_manager.error_status is False + assert len(result_manager) == 2 + + # Add one error test + error_test = test_result_factory(1) + error_test.result = "error" + result_manager.add_test_result(error_test) + assert result_manager.status == "success" + assert result_manager.error_status is True + assert len(result_manager) == 3 + + # Add one failure test + failure_test = test_result_factory(1) + failure_test.result = "failure" + result_manager.add_test_result(failure_test) + assert result_manager.status == "failure" + assert result_manager.error_status is True + assert len(result_manager) == 4 + + def test_add_test_results(self, list_result_factory: Callable[[int], list[TestResult]]) -> None: + """ + Test ResultManager.add_test_results + """ + result_manager = ResultManager() + assert result_manager.status == "unset" + assert result_manager.error_status is False + assert len(result_manager) == 0 + + # Add three success tests + success_list = list_result_factory(3) + for test in success_list: + test.result = "success" + result_manager.add_test_results(success_list) + assert result_manager.status == "success" + assert result_manager.error_status is False + assert len(result_manager) == 3 + + # Add one error test and one failure + error_failure_list = list_result_factory(2) + error_failure_list[0].result = "error" + error_failure_list[1].result = "failure" + result_manager.add_test_results(error_failure_list) + assert result_manager.status == "failure" + assert result_manager.error_status is True + assert len(result_manager) == 5 + + @pytest.mark.parametrize( + "status, error_status, ignore_error, expected_status", + [ + pytest.param("success", False, True, "success", id="no error"), + pytest.param("success", True, True, "success", id="error, ignore error"), + pytest.param("success", True, False, "error", id="error, do not ignore error"), + ], + ) + def test_get_status(self, status: TestStatus, error_status: bool, ignore_error: bool, expected_status: str) -> None: + """ + test ResultManager.get_status + """ + result_manager = ResultManager() + result_manager.status = status + result_manager.error_status = error_status + + assert result_manager.get_status(ignore_error=ignore_error) == expected_status + + def test_get_results(self, list_result_factory: Callable[[int], list[TestResult]]) -> None: + """ + test ResultManager.get_results + """ + result_manager = ResultManager() + + success_list = list_result_factory(3) + for test in success_list: + test.result = "success" + result_manager.add_test_results(success_list) + + res = result_manager.get_results() + assert isinstance(res, list) + + def test_get_json_results(self, list_result_factory: Callable[[int], list[TestResult]]) -> None: + """ + test ResultManager.get_json_results + """ + result_manager = ResultManager() + + success_list = list_result_factory(3) + for test in success_list: + test.result = "success" + result_manager.add_test_results(success_list) + + json_res = result_manager.get_json_results() + assert isinstance(json_res, str) + + # Verifies it can be deserialized back to a list of dict with the correct values types + res = json.loads(json_res) + for test in res: + assert isinstance(test, dict) + assert isinstance(test.get("test"), str) + assert isinstance(test.get("categories"), list) + assert isinstance(test.get("description"), str) + assert test.get("custom_field") is None + assert test.get("result") == "success" + + # TODO + # get_result_by_test + # get_result_by_host + # get_testcases + # get_hosts diff --git a/tests/units/result_manager/test_models.py b/tests/units/result_manager/test_models.py new file mode 100644 index 0000000..bc7ba8a --- /dev/null +++ b/tests/units/result_manager/test_models.py @@ -0,0 +1,57 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""ANTA Result Manager models unit tests.""" +from __future__ import annotations + +from typing import Any, Callable + +import pytest + +# Import as Result to avoid pytest collection +from anta.result_manager.models import TestResult as Result +from tests.data.json_data import TEST_RESULT_SET_STATUS +from tests.lib.fixture import DEVICE_NAME +from tests.lib.utils import generate_test_ids_dict + + +class TestTestResultModels: + """Test components of anta.result_manager.models.""" + + @pytest.mark.parametrize("data", TEST_RESULT_SET_STATUS, ids=generate_test_ids_dict) + def test__is_status_foo(self, test_result_factory: Callable[[int], Result], data: dict[str, Any]) -> None: + """Test TestResult.is_foo methods.""" + testresult = test_result_factory(1) + assert testresult.result == "unset" + assert len(testresult.messages) == 0 + if data["target"] == "success": + testresult.is_success(data["message"]) + assert testresult.result == data["target"] + assert data["message"] in testresult.messages + if data["target"] == "failure": + testresult.is_failure(data["message"]) + assert testresult.result == data["target"] + assert data["message"] in testresult.messages + if data["target"] == "error": + testresult.is_error(data["message"]) + assert testresult.result == data["target"] + assert data["message"] in testresult.messages + if data["target"] == "skipped": + testresult.is_skipped(data["message"]) + assert testresult.result == data["target"] + assert data["message"] in testresult.messages + # no helper for unset, testing _set_status + if data["target"] == "unset": + testresult._set_status("unset", data["message"]) # pylint: disable=W0212 + assert testresult.result == data["target"] + assert data["message"] in testresult.messages + + @pytest.mark.parametrize("data", TEST_RESULT_SET_STATUS, ids=generate_test_ids_dict) + def test____str__(self, test_result_factory: Callable[[int], Result], data: dict[str, Any]) -> None: + """Test TestResult.__str__.""" + testresult = test_result_factory(1) + assert testresult.result == "unset" + assert len(testresult.messages) == 0 + testresult._set_status(data["target"], data["message"]) # pylint: disable=W0212 + assert testresult.result == data["target"] + assert str(testresult) == f"Test 'VerifyTest1' (on '{DEVICE_NAME}'): Result '{data['target']}'\nMessages: {[data['message']]}" diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py new file mode 100644 index 0000000..22a2121 --- /dev/null +++ b/tests/units/test_catalog.py @@ -0,0 +1,311 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +test anta.device.py +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError +from yaml import safe_load + +from anta.catalog import AntaCatalog, AntaTestDefinition +from anta.models import AntaTest +from anta.tests.interfaces import VerifyL3MTU +from anta.tests.mlag import VerifyMlagStatus +from anta.tests.software import VerifyEOSVersion +from anta.tests.system import ( + VerifyAgentLogs, + VerifyCoredump, + VerifyCPUUtilization, + VerifyFileSystemUtilization, + VerifyMemoryUtilization, + VerifyNTP, + VerifyReloadCause, + VerifyUptime, +) +from tests.lib.utils import generate_test_ids_list +from tests.units.test_models import FakeTestWithInput + +# Test classes used as expected values + +DATA_DIR: Path = Path(__file__).parent.parent.resolve() / "data" + +INIT_CATALOG_DATA: list[dict[str, Any]] = [ + { + "name": "test_catalog", + "filename": "test_catalog.yml", + "tests": [ + (VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"])), + ], + }, + { + "name": "test_catalog_with_tags", + "filename": "test_catalog_with_tags.yml", + "tests": [ + ( + VerifyUptime, + VerifyUptime.Input( + minimum=10, + filters=VerifyUptime.Input.Filters(tags=["fabric"]), + ), + ), + (VerifyReloadCause, {"filters": {"tags": ["leaf", "spine"]}}), + (VerifyCoredump, VerifyCoredump.Input()), + (VerifyAgentLogs, AntaTest.Input()), + (VerifyCPUUtilization, VerifyCPUUtilization.Input(filters=VerifyCPUUtilization.Input.Filters(tags=["leaf"]))), + (VerifyMemoryUtilization, VerifyMemoryUtilization.Input(filters=VerifyMemoryUtilization.Input.Filters(tags=["testdevice"]))), + (VerifyFileSystemUtilization, None), + (VerifyNTP, {}), + (VerifyMlagStatus, None), + (VerifyL3MTU, {"mtu": 1500, "filters": {"tags": ["demo"]}}), + ], + }, + { + "name": "test_empty_catalog", + "filename": "test_empty_catalog.yml", + "tests": [], + }, +] +CATALOG_PARSE_FAIL_DATA: list[dict[str, Any]] = [ + { + "name": "undefined_tests", + "filename": "test_catalog_with_undefined_tests.yml", + "error": "FakeTest is not defined in Python module anta.tests.software", + }, + { + "name": "undefined_module", + "filename": "test_catalog_with_undefined_module.yml", + "error": "Module named anta.tests.undefined cannot be imported", + }, + { + "name": "undefined_module", + "filename": "test_catalog_with_undefined_module.yml", + "error": "Module named anta.tests.undefined cannot be imported", + }, + { + "name": "syntax_error", + "filename": "test_catalog_with_syntax_error_module.yml", + "error": "Value error, Module named tests.data.syntax_error cannot be imported. Verify that the module exists and there is no Python syntax issues.", + }, + { + "name": "undefined_module_nested", + "filename": "test_catalog_with_undefined_module_nested.yml", + "error": "Module named undefined from package anta.tests cannot be imported", + }, + { + "name": "not_a_list", + "filename": "test_catalog_not_a_list.yml", + "error": "Value error, Syntax error when parsing: True\nIt must be a list of ANTA tests. Check the test catalog.", + }, + { + "name": "test_definition_not_a_dict", + "filename": "test_catalog_test_definition_not_a_dict.yml", + "error": "Value error, Syntax error when parsing: VerifyEOSVersion\nIt must be a dictionary. Check the test catalog.", + }, + { + "name": "test_definition_multiple_dicts", + "filename": "test_catalog_test_definition_multiple_dicts.yml", + "error": "Value error, Syntax error when parsing: {'VerifyEOSVersion': {'versions': ['4.25.4M', '4.26.1F']}, " + "'VerifyTerminAttrVersion': {'versions': ['4.25.4M']}}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog.", + }, + {"name": "wrong_type_after_parsing", "filename": "test_catalog_wrong_type.yml", "error": "must be a dict, got str"}, +] +CATALOG_FROM_DICT_FAIL_DATA: list[dict[str, Any]] = [ + { + "name": "undefined_tests", + "filename": "test_catalog_with_undefined_tests.yml", + "error": "FakeTest is not defined in Python module anta.tests.software", + }, + { + "name": "wrong_type", + "filename": "test_catalog_wrong_type.yml", + "error": "Wrong input type for catalog data, must be a dict, got str", + }, +] +CATALOG_FROM_LIST_FAIL_DATA: list[dict[str, Any]] = [ + { + "name": "wrong_inputs", + "tests": [ + ( + FakeTestWithInput, + AntaTest.Input(), + ), + ], + "error": "Test input has type AntaTest.Input but expected type FakeTestWithInput.Input", + }, + { + "name": "no_test", + "tests": [(None, None)], + "error": "Input should be a subclass of AntaTest", + }, + { + "name": "no_input_when_required", + "tests": [(FakeTestWithInput, None)], + "error": "Field required", + }, + { + "name": "wrong_input_type", + "tests": [(FakeTestWithInput, True)], + "error": "Value error, Coud not instantiate inputs as type bool is not valid", + }, +] + +TESTS_SETTER_FAIL_DATA: list[dict[str, Any]] = [ + { + "name": "not_a_list", + "tests": "not_a_list", + "error": "The catalog must contain a list of tests", + }, + { + "name": "not_a_list_of_test_definitions", + "tests": [42, 43], + "error": "A test in the catalog must be an AntaTestDefinition instance", + }, +] + + +class Test_AntaCatalog: + """ + Test for anta.catalog.AntaCatalog + """ + + @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) + def test_parse(self, catalog_data: dict[str, Any]) -> None: + """ + Instantiate AntaCatalog from a file + """ + catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / catalog_data["filename"])) + + assert len(catalog.tests) == len(catalog_data["tests"]) + for test_id, (test, inputs) in enumerate(catalog_data["tests"]): + assert catalog.tests[test_id].test == test + if inputs is not None: + if isinstance(inputs, dict): + inputs = test.Input(**inputs) + assert inputs == catalog.tests[test_id].inputs + + @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) + def test_from_list(self, catalog_data: dict[str, Any]) -> None: + """ + Instantiate AntaCatalog from a list + """ + catalog: AntaCatalog = AntaCatalog.from_list(catalog_data["tests"]) + + assert len(catalog.tests) == len(catalog_data["tests"]) + for test_id, (test, inputs) in enumerate(catalog_data["tests"]): + assert catalog.tests[test_id].test == test + if inputs is not None: + if isinstance(inputs, dict): + inputs = test.Input(**inputs) + assert inputs == catalog.tests[test_id].inputs + + @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) + def test_from_dict(self, catalog_data: dict[str, Any]) -> None: + """ + Instantiate AntaCatalog from a dict + """ + with open(file=str(DATA_DIR / catalog_data["filename"]), mode="r", encoding="UTF-8") as file: + data = safe_load(file) + catalog: AntaCatalog = AntaCatalog.from_dict(data) + + assert len(catalog.tests) == len(catalog_data["tests"]) + for test_id, (test, inputs) in enumerate(catalog_data["tests"]): + assert catalog.tests[test_id].test == test + if inputs is not None: + if isinstance(inputs, dict): + inputs = test.Input(**inputs) + assert inputs == catalog.tests[test_id].inputs + + @pytest.mark.parametrize("catalog_data", CATALOG_PARSE_FAIL_DATA, ids=generate_test_ids_list(CATALOG_PARSE_FAIL_DATA)) + def test_parse_fail(self, catalog_data: dict[str, Any]) -> None: + """ + Errors when instantiating AntaCatalog from a file + """ + with pytest.raises((ValidationError, ValueError)) as exec_info: + AntaCatalog.parse(str(DATA_DIR / catalog_data["filename"])) + if isinstance(exec_info.value, ValidationError): + assert catalog_data["error"] in exec_info.value.errors()[0]["msg"] + else: + assert catalog_data["error"] in str(exec_info) + + def test_parse_fail_parsing(self, caplog: pytest.LogCaptureFixture) -> None: + """ + Errors when instantiating AntaCatalog from a file + """ + with pytest.raises(Exception) as exec_info: + AntaCatalog.parse(str(DATA_DIR / "catalog_does_not_exist.yml")) + assert "No such file or directory" in str(exec_info) + assert len(caplog.record_tuples) >= 1 + _, _, message = caplog.record_tuples[0] + assert "Unable to parse ANTA Test Catalog file" in message + assert "FileNotFoundError ([Errno 2] No such file or directory" in message + + @pytest.mark.parametrize("catalog_data", CATALOG_FROM_LIST_FAIL_DATA, ids=generate_test_ids_list(CATALOG_FROM_LIST_FAIL_DATA)) + def test_from_list_fail(self, catalog_data: dict[str, Any]) -> None: + """ + Errors when instantiating AntaCatalog from a list of tuples + """ + with pytest.raises(ValidationError) as exec_info: + AntaCatalog.from_list(catalog_data["tests"]) + assert catalog_data["error"] in exec_info.value.errors()[0]["msg"] + + @pytest.mark.parametrize("catalog_data", CATALOG_FROM_DICT_FAIL_DATA, ids=generate_test_ids_list(CATALOG_FROM_DICT_FAIL_DATA)) + def test_from_dict_fail(self, catalog_data: dict[str, Any]) -> None: + """ + Errors when instantiating AntaCatalog from a list of tuples + """ + with open(file=str(DATA_DIR / catalog_data["filename"]), mode="r", encoding="UTF-8") as file: + data = safe_load(file) + with pytest.raises((ValidationError, ValueError)) as exec_info: + AntaCatalog.from_dict(data) + if isinstance(exec_info.value, ValidationError): + assert catalog_data["error"] in exec_info.value.errors()[0]["msg"] + else: + assert catalog_data["error"] in str(exec_info) + + def test_filename(self) -> None: + """ + Test filename + """ + catalog = AntaCatalog(filename="test") + assert catalog.filename == Path("test") + catalog = AntaCatalog(filename=Path("test")) + assert catalog.filename == Path("test") + + @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) + def test__tests_setter_success(self, catalog_data: dict[str, Any]) -> None: + """ + Success when setting AntaCatalog.tests from a list of tuples + """ + catalog = AntaCatalog() + catalog.tests = [AntaTestDefinition(test=test, inputs=inputs) for test, inputs in catalog_data["tests"]] + assert len(catalog.tests) == len(catalog_data["tests"]) + for test_id, (test, inputs) in enumerate(catalog_data["tests"]): + assert catalog.tests[test_id].test == test + if inputs is not None: + if isinstance(inputs, dict): + inputs = test.Input(**inputs) + assert inputs == catalog.tests[test_id].inputs + + @pytest.mark.parametrize("catalog_data", TESTS_SETTER_FAIL_DATA, ids=generate_test_ids_list(TESTS_SETTER_FAIL_DATA)) + def test__tests_setter_fail(self, catalog_data: dict[str, Any]) -> None: + """ + Errors when setting AntaCatalog.tests from a list of tuples + """ + catalog = AntaCatalog() + with pytest.raises(ValueError) as exec_info: + catalog.tests = catalog_data["tests"] + assert catalog_data["error"] in str(exec_info) + + def test_get_tests_by_tags(self) -> None: + """ + Test AntaCatalog.test_get_tests_by_tags() + """ + catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / "test_catalog_with_tags.yml")) + tests: list[AntaTestDefinition] = catalog.get_tests_by_tags(tags=["leaf"]) + assert len(tests) == 2 diff --git a/tests/units/test_device.py b/tests/units/test_device.py new file mode 100644 index 0000000..845da2b --- /dev/null +++ b/tests/units/test_device.py @@ -0,0 +1,777 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +test anta.device.py +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import httpx +import pytest +from _pytest.mark.structures import ParameterSet +from asyncssh import SSHClientConnection, SSHClientConnectionOptions +from rich import print as rprint + +from anta import aioeapi +from anta.device import AntaDevice, AsyncEOSDevice +from anta.models import AntaCommand +from tests.lib.fixture import COMMAND_OUTPUT +from tests.lib.utils import generate_test_ids_list + +INIT_DATA: list[dict[str, Any]] = [ + { + "name": "no name, no port", + "device": { + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + }, + "expected": {"name": "42.42.42.42"}, + }, + { + "name": "no name, port", + "device": { + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + "port": 666, + }, + "expected": {"name": "42.42.42.42:666"}, + }, + { + "name": "name", + "device": { + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + "name": "test.anta.ninja", + "disable_cache": True, + }, + "expected": {"name": "test.anta.ninja"}, + }, + { + "name": "insecure", + "device": { + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + "name": "test.anta.ninja", + "insecure": True, + }, + "expected": {"name": "test.anta.ninja"}, + }, +] +EQUALITY_DATA: list[dict[str, Any]] = [ + { + "name": "equal", + "device1": { + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + }, + "device2": { + "host": "42.42.42.42", + "username": "anta", + "password": "blah", + }, + "expected": True, + }, + { + "name": "equals-name", + "device1": { + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + "name": "device1", + }, + "device2": { + "host": "42.42.42.42", + "username": "plop", + "password": "anta", + "name": "device2", + }, + "expected": True, + }, + { + "name": "not-equal-port", + "device1": { + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + }, + "device2": { + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + "port": 666, + }, + "expected": False, + }, + { + "name": "not-equal-host", + "device1": { + "host": "42.42.42.41", + "username": "anta", + "password": "anta", + }, + "device2": { + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + }, + "expected": False, + }, +] +AIOEAPI_COLLECT_DATA: list[dict[str, Any]] = [ + { + "name": "command", + "device": {}, + "command": { + "command": "show version", + "patch_kwargs": { + "return_value": [ + { + "mfgName": "Arista", + "modelName": "DCS-7280CR3-32P4-F", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + } + ] + }, + }, + "expected": { + "output": { + "mfgName": "Arista", + "modelName": "DCS-7280CR3-32P4-F", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + }, + "errors": [], + }, + }, + { + "name": "enable", + "device": {"enable": True}, + "command": { + "command": "show version", + "patch_kwargs": { + "return_value": [ + {}, + { + "mfgName": "Arista", + "modelName": "DCS-7280CR3-32P4-F", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + }, + ] + }, + }, + "expected": { + "output": { + "mfgName": "Arista", + "modelName": "DCS-7280CR3-32P4-F", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + }, + "errors": [], + }, + }, + { + "name": "enable password", + "device": {"enable": True, "enable_password": "anta"}, + "command": { + "command": "show version", + "patch_kwargs": { + "return_value": [ + {}, + { + "mfgName": "Arista", + "modelName": "DCS-7280CR3-32P4-F", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + }, + ] + }, + }, + "expected": { + "output": { + "mfgName": "Arista", + "modelName": "DCS-7280CR3-32P4-F", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + }, + "errors": [], + }, + }, + { + "name": "revision", + "device": {}, + "command": { + "command": "show version", + "revision": 3, + "patch_kwargs": { + "return_value": [ + {}, + { + "mfgName": "Arista", + "modelName": "DCS-7280CR3-32P4-F", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + }, + ] + }, + }, + "expected": { + "output": { + "mfgName": "Arista", + "modelName": "DCS-7280CR3-32P4-F", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + }, + "errors": [], + }, + }, + { + "name": "aioeapi.EapiCommandError", + "device": {}, + "command": { + "command": "show version", + "patch_kwargs": { + "side_effect": aioeapi.EapiCommandError( + passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[] + ) + }, + }, + "expected": {"output": None, "errors": ["Authorization denied for command 'show version'"]}, + }, + { + "name": "httpx.HTTPError", + "device": {}, + "command": { + "command": "show version", + "patch_kwargs": {"side_effect": httpx.HTTPError(message="404")}, + }, + "expected": {"output": None, "errors": ["404"]}, + }, + { + "name": "httpx.ConnectError", + "device": {}, + "command": { + "command": "show version", + "patch_kwargs": {"side_effect": httpx.ConnectError(message="Cannot open port")}, + }, + "expected": {"output": None, "errors": ["Cannot open port"]}, + }, +] +AIOEAPI_COPY_DATA: list[dict[str, Any]] = [ + { + "name": "from", + "device": {}, + "copy": { + "sources": [Path("/mnt/flash"), Path("/var/log/agents")], + "destination": Path("."), + "direction": "from", + }, + }, + { + "name": "to", + "device": {}, + "copy": { + "sources": [Path("/mnt/flash"), Path("/var/log/agents")], + "destination": Path("."), + "direction": "to", + }, + }, + { + "name": "wrong", + "device": {}, + "copy": { + "sources": [Path("/mnt/flash"), Path("/var/log/agents")], + "destination": Path("."), + "direction": "wrong", + }, + }, +] +REFRESH_DATA: list[dict[str, Any]] = [ + { + "name": "established", + "device": {}, + "patch_kwargs": ( + {"return_value": True}, + { + "return_value": { + "mfgName": "Arista", + "modelName": "DCS-7280CR3-32P4-F", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + } + }, + ), + "expected": {"is_online": True, "established": True, "hw_model": "DCS-7280CR3-32P4-F"}, + }, + { + "name": "is not online", + "device": {}, + "patch_kwargs": ( + {"return_value": False}, + { + "return_value": { + "mfgName": "Arista", + "modelName": "DCS-7280CR3-32P4-F", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + } + }, + ), + "expected": {"is_online": False, "established": False, "hw_model": None}, + }, + { + "name": "cannot parse command", + "device": {}, + "patch_kwargs": ( + {"return_value": True}, + { + "return_value": { + "mfgName": "Arista", + "hardwareRevision": "11.00", + "serialNumber": "JPE19500066", + "systemMacAddress": "fc:bd:67:3d:13:c5", + "hwMacAddress": "fc:bd:67:3d:13:c5", + "configMacAddress": "00:00:00:00:00:00", + "version": "4.31.1F-34361447.fraserrel (engineering build)", + "architecture": "x86_64", + "internalVersion": "4.31.1F-34361447.fraserrel", + "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", + "imageFormatVersion": "3.0", + "imageOptimization": "Default", + "bootupTimestamp": 1700729434.5892005, + "uptime": 20666.78, + "memTotal": 8099732, + "memFree": 4989568, + "isIntlVersion": False, + } + }, + ), + "expected": {"is_online": True, "established": False, "hw_model": None}, + }, + { + "name": "aioeapi.EapiCommandError", + "device": {}, + "patch_kwargs": ( + {"return_value": True}, + { + "side_effect": aioeapi.EapiCommandError( + passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[] + ) + }, + ), + "expected": {"is_online": True, "established": False, "hw_model": None}, + }, + { + "name": "httpx.HTTPError", + "device": {}, + "patch_kwargs": ( + {"return_value": True}, + {"side_effect": httpx.HTTPError(message="404")}, + ), + "expected": {"is_online": True, "established": False, "hw_model": None}, + }, + { + "name": "httpx.ConnectError", + "device": {}, + "patch_kwargs": ( + {"return_value": True}, + {"side_effect": httpx.ConnectError(message="Cannot open port")}, + ), + "expected": {"is_online": True, "established": False, "hw_model": None}, + }, +] +COLLECT_DATA: list[dict[str, Any]] = [ + { + "name": "device cache enabled, command cache enabled, no cache hit", + "device": {"disable_cache": False}, + "command": { + "command": "show version", + "use_cache": True, + }, + "expected": {"cache_hit": False}, + }, + { + "name": "device cache enabled, command cache enabled, cache hit", + "device": {"disable_cache": False}, + "command": { + "command": "show version", + "use_cache": True, + }, + "expected": {"cache_hit": True}, + }, + { + "name": "device cache disabled, command cache enabled", + "device": {"disable_cache": True}, + "command": { + "command": "show version", + "use_cache": True, + }, + "expected": {}, + }, + { + "name": "device cache enabled, command cache disabled, cache has command", + "device": {"disable_cache": False}, + "command": { + "command": "show version", + "use_cache": False, + }, + "expected": {"cache_hit": True}, + }, + { + "name": "device cache enabled, command cache disabled, cache does not have data", + "device": { + "disable_cache": False, + }, + "command": { + "command": "show version", + "use_cache": False, + }, + "expected": {"cache_hit": False}, + }, + { + "name": "device cache disabled, command cache disabled", + "device": { + "disable_cache": True, + }, + "command": { + "command": "show version", + "use_cache": False, + }, + "expected": {}, + }, +] +CACHE_STATS_DATA: list[ParameterSet] = [ + pytest.param({"disable_cache": False}, {"total_commands_sent": 0, "cache_hits": 0, "cache_hit_ratio": "0.00%"}, id="with_cache"), + pytest.param({"disable_cache": True}, None, id="without_cache"), +] + + +class TestAntaDevice: + """ + Test for anta.device.AntaDevice Abstract class + """ + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "device, command_data, expected_data", + map(lambda d: (d["device"], d["command"], d["expected"]), COLLECT_DATA), + indirect=["device"], + ids=generate_test_ids_list(COLLECT_DATA), + ) + async def test_collect(self, device: AntaDevice, command_data: dict[str, Any], expected_data: dict[str, Any]) -> None: + """ + Test AntaDevice.collect behavior + """ + command = AntaCommand(command=command_data["command"], use_cache=command_data["use_cache"]) + + # Dummy output for cache hit + cached_output = "cached_value" + + if device.cache is not None and expected_data["cache_hit"] is True: + await device.cache.set(command.uid, cached_output) + + await device.collect(command) + + if device.cache is not None: # device_cache is enabled + current_cached_data = await device.cache.get(command.uid) + if command.use_cache is True: # command is allowed to use cache + if expected_data["cache_hit"] is True: + assert command.output == cached_output + assert current_cached_data == cached_output + assert device.cache.hit_miss_ratio["hits"] == 2 + else: + assert command.output == COMMAND_OUTPUT + assert current_cached_data == COMMAND_OUTPUT + assert device.cache.hit_miss_ratio["hits"] == 1 + else: # command is not allowed to use cache + device._collect.assert_called_once_with(command=command) # type: ignore[attr-defined] # pylint: disable=protected-access + assert command.output == COMMAND_OUTPUT + if expected_data["cache_hit"] is True: + assert current_cached_data == cached_output + else: + assert current_cached_data is None + else: # device is disabled + assert device.cache is None + device._collect.assert_called_once_with(command=command) # type: ignore[attr-defined] # pylint: disable=protected-access + + @pytest.mark.parametrize("device, expected", CACHE_STATS_DATA, indirect=["device"]) + def test_cache_statistics(self, device: AntaDevice, expected: dict[str, Any] | None) -> None: + """ + Verify that when cache statistics attribute does not exist + TODO add a test where cache has some value + """ + assert device.cache_statistics == expected + + def test_supports(self, device: AntaDevice) -> None: + """ + Test if the supports() method + """ + command = AntaCommand(command="show hardware counter drop", errors=["Unavailable command (not supported on this hardware platform) (at token 2: 'counter')"]) + assert device.supports(command) is False + command = AntaCommand(command="show hardware counter drop") + assert device.supports(command) is True + + +class TestAsyncEOSDevice: + """ + Test for anta.device.AsyncEOSDevice + """ + + @pytest.mark.parametrize("data", INIT_DATA, ids=generate_test_ids_list(INIT_DATA)) + def test__init__(self, data: dict[str, Any]) -> None: + """Test the AsyncEOSDevice constructor""" + device = AsyncEOSDevice(**data["device"]) + + assert device.name == data["expected"]["name"] + if data["device"].get("disable_cache") is True: + assert device.cache is None + assert device.cache_locks is None + else: # False or None + assert device.cache is not None + assert device.cache_locks is not None + hash(device) + + with patch("anta.device.__DEBUG__", True): + rprint(device) + + @pytest.mark.parametrize("data", EQUALITY_DATA, ids=generate_test_ids_list(EQUALITY_DATA)) + def test__eq(self, data: dict[str, Any]) -> None: + """Test the AsyncEOSDevice equality""" + device1 = AsyncEOSDevice(**data["device1"]) + device2 = AsyncEOSDevice(**data["device2"]) + if data["expected"]: + assert device1 == device2 + else: + assert device1 != device2 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "async_device, patch_kwargs, expected", + map(lambda d: (d["device"], d["patch_kwargs"], d["expected"]), REFRESH_DATA), + ids=generate_test_ids_list(REFRESH_DATA), + indirect=["async_device"], + ) + async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[dict[str, Any]], expected: dict[str, Any]) -> None: + # pylint: disable=protected-access + """Test AsyncEOSDevice.refresh()""" + with patch.object(async_device._session, "check_connection", **patch_kwargs[0]): + with patch.object(async_device._session, "cli", **patch_kwargs[1]): + await async_device.refresh() + async_device._session.check_connection.assert_called_once() + if expected["is_online"]: + async_device._session.cli.assert_called_once() + assert async_device.is_online == expected["is_online"] + assert async_device.established == expected["established"] + assert async_device.hw_model == expected["hw_model"] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "async_device, command, expected", + map(lambda d: (d["device"], d["command"], d["expected"]), AIOEAPI_COLLECT_DATA), + ids=generate_test_ids_list(AIOEAPI_COLLECT_DATA), + indirect=["async_device"], + ) + async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, Any], expected: dict[str, Any]) -> None: + # pylint: disable=protected-access + """Test AsyncEOSDevice._collect()""" + if "revision" in command: + cmd = AntaCommand(command=command["command"], revision=command["revision"]) + else: + cmd = AntaCommand(command=command["command"]) + with patch.object(async_device._session, "cli", **command["patch_kwargs"]): + await async_device.collect(cmd) + commands = [] + if async_device.enable and async_device._enable_password is not None: + commands.append( + { + "cmd": "enable", + "input": str(async_device._enable_password), + } + ) + elif async_device.enable: + # No password + commands.append({"cmd": "enable"}) + if cmd.revision: + commands.append({"cmd": cmd.command, "revision": cmd.revision}) + else: + commands.append({"cmd": cmd.command}) + async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version) + assert cmd.output == expected["output"] + assert cmd.errors == expected["errors"] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "async_device, copy", + map(lambda d: (d["device"], d["copy"]), AIOEAPI_COPY_DATA), + ids=generate_test_ids_list(AIOEAPI_COPY_DATA), + indirect=["async_device"], + ) + async def test_copy(self, async_device: AsyncEOSDevice, copy: dict[str, Any]) -> None: + """Test AsyncEOSDevice.copy()""" + conn = SSHClientConnection(asyncio.get_event_loop(), SSHClientConnectionOptions()) + with patch("asyncssh.connect") as connect_mock: + connect_mock.return_value.__aenter__.return_value = conn + with patch("asyncssh.scp") as scp_mock: + await async_device.copy(copy["sources"], copy["destination"], copy["direction"]) + if copy["direction"] == "from": + src = [(conn, file) for file in copy["sources"]] + dst = copy["destination"] + elif copy["direction"] == "to": + src = copy["sources"] + dst = conn, copy["destination"] + else: + scp_mock.assert_not_awaited() + return + scp_mock.assert_awaited_once_with(src, dst) diff --git a/tests/units/test_logger.py b/tests/units/test_logger.py new file mode 100644 index 0000000..6e1e5b4 --- /dev/null +++ b/tests/units/test_logger.py @@ -0,0 +1,80 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests 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_models.py b/tests/units/test_models.py new file mode 100644 index 0000000..c0585a4 --- /dev/null +++ b/tests/units/test_models.py @@ -0,0 +1,472 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +test anta.models.py +""" +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from anta.decorators import deprecated_test, skip_on_platforms +from anta.device import AntaDevice +from anta.models import AntaCommand, AntaTemplate, AntaTest +from tests.lib.fixture import DEVICE_HW_MODEL +from tests.lib.utils import generate_test_ids + + +class FakeTest(AntaTest): + """ANTA test that always succeed""" + + name = "FakeTest" + description = "ANTA test that always succeed" + categories = [] + commands = [] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + +class FakeTestWithFailedCommand(AntaTest): + """ANTA test with a command that failed""" + + name = "FakeTestWithFailedCommand" + description = "ANTA test with a command that failed" + categories = [] + commands = [AntaCommand(command="show version", errors=["failed command"])] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + +class FakeTestWithUnsupportedCommand(AntaTest): + """ANTA test with an unsupported command""" + + name = "FakeTestWithUnsupportedCommand" + description = "ANTA test with an unsupported command" + categories = [] + commands = [AntaCommand(command="show hardware counter drop", errors=["Unavailable command (not supported on this hardware platform) (at token 2: 'counter')"])] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + +class FakeTestWithInput(AntaTest): + """ANTA test with inputs that always succeed""" + + name = "FakeTestWithInput" + description = "ANTA test with inputs that always succeed" + categories = [] + commands = [] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + string: str + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success(self.inputs.string) + + +class FakeTestWithTemplate(AntaTest): + """ANTA test with template that always succeed""" + + name = "FakeTestWithTemplate" + description = "ANTA test with template that always succeed" + categories = [] + commands = [AntaTemplate(template="show interface {interface}")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + interface: str + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(interface=self.inputs.interface)] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success(self.instance_commands[0].command) + + +class FakeTestWithTemplateNoRender(AntaTest): + """ANTA test with template that miss the render() method""" + + name = "FakeTestWithTemplateNoRender" + description = "ANTA test with template that miss the render() method" + categories = [] + commands = [AntaTemplate(template="show interface {interface}")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + interface: str + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success(self.instance_commands[0].command) + + +class FakeTestWithTemplateBadRender1(AntaTest): + """ANTA test with template that raises a AntaTemplateRenderError exception""" + + name = "FakeTestWithTemplateBadRender" + description = "ANTA test with template that raises a AntaTemplateRenderError exception" + categories = [] + commands = [AntaTemplate(template="show interface {interface}")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + interface: str + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + return [template.render(wrong_template_param=self.inputs.interface)] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success(self.instance_commands[0].command) + + +class FakeTestWithTemplateBadRender2(AntaTest): + """ANTA test with template that raises an arbitrary exception""" + + name = "FakeTestWithTemplateBadRender2" + description = "ANTA test with template that raises an arbitrary exception" + categories = [] + commands = [AntaTemplate(template="show interface {interface}")] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + interface: str + + def render(self, template: AntaTemplate) -> list[AntaCommand]: + raise Exception() # pylint: disable=broad-exception-raised + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success(self.instance_commands[0].command) + + +class SkipOnPlatformTest(AntaTest): + """ANTA test that is skipped""" + + name = "SkipOnPlatformTest" + description = "ANTA test that is skipped on a specific platform" + categories = [] + commands = [] + + @skip_on_platforms([DEVICE_HW_MODEL]) + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + +class UnSkipOnPlatformTest(AntaTest): + """ANTA test that is skipped""" + + name = "UnSkipOnPlatformTest" + description = "ANTA test that is skipped on a specific platform" + categories = [] + commands = [] + + @skip_on_platforms(["dummy"]) + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + +class SkipOnPlatformTestWithInput(AntaTest): + """ANTA test skipped on platforms but with Input""" + + name = "SkipOnPlatformTestWithInput" + description = "ANTA test skipped on platforms but with Input" + categories = [] + commands = [] + + class Input(AntaTest.Input): # pylint: disable=missing-class-docstring + string: str + + @skip_on_platforms([DEVICE_HW_MODEL]) + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success(self.inputs.string) + + +class DeprecatedTestWithoutNewTest(AntaTest): + """ANTA test that is deprecated without new test""" + + name = "DeprecatedTestWitouthNewTest" + description = "ANTA test that is deprecated without new test" + categories = [] + commands = [] + + @deprecated_test() + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + +class DeprecatedTestWithNewTest(AntaTest): + """ANTA test that is deprecated with new test.""" + + name = "DeprecatedTestWithNewTest" + description = "ANTA deprecated test with New Test" + categories = [] + commands = [] + + @deprecated_test(new_tests=["NewTest"]) + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + +ANTATEST_DATA: list[dict[str, Any]] = [ + {"name": "no input", "test": FakeTest, "inputs": None, "expected": {"__init__": {"result": "unset"}, "test": {"result": "success"}}}, + { + "name": "extra input", + "test": FakeTest, + "inputs": {"string": "culpa! veniam quas quas veniam molestias, esse"}, + "expected": {"__init__": {"result": "error", "messages": ["Extra inputs are not permitted"]}, "test": {"result": "error"}}, + }, + { + "name": "no input", + "test": FakeTestWithInput, + "inputs": None, + "expected": {"__init__": {"result": "error", "messages": ["Field required"]}, "test": {"result": "error"}}, + }, + { + "name": "wrong input type", + "test": FakeTestWithInput, + "inputs": {"string": 1}, + "expected": {"__init__": {"result": "error", "messages": ["Input should be a valid string"]}, "test": {"result": "error"}}, + }, + { + "name": "good input", + "test": FakeTestWithInput, + "inputs": {"string": "culpa! veniam quas quas veniam molestias, esse"}, + "expected": {"__init__": {"result": "unset"}, "test": {"result": "success", "messages": ["culpa! veniam quas quas veniam molestias, esse"]}}, + }, + { + "name": "good input", + "test": FakeTestWithTemplate, + "inputs": {"interface": "Ethernet1"}, + "expected": {"__init__": {"result": "unset"}, "test": {"result": "success", "messages": ["show interface Ethernet1"]}}, + }, + { + "name": "wrong input type", + "test": FakeTestWithTemplate, + "inputs": {"interface": 1}, + "expected": {"__init__": {"result": "error", "messages": ["Input should be a valid string"]}, "test": {"result": "error"}}, + }, + { + "name": "wrong render definition", + "test": FakeTestWithTemplateNoRender, + "inputs": {"interface": "Ethernet1"}, + "expected": { + "__init__": { + "result": "error", + "messages": ["AntaTemplate are provided but render() method has not been implemented for tests.units.test_models.FakeTestWithTemplateNoRender"], + }, + "test": {"result": "error"}, + }, + }, + { + "name": "AntaTemplateRenderError", + "test": FakeTestWithTemplateBadRender1, + "inputs": {"interface": "Ethernet1"}, + "expected": { + "__init__": { + "result": "error", + "messages": ["Cannot render template {template='show interface {interface}' version='latest' revision=None ofmt='json' use_cache=True}"], + }, + "test": {"result": "error"}, + }, + }, + { + "name": "Exception in render()", + "test": FakeTestWithTemplateBadRender2, + "inputs": {"interface": "Ethernet1"}, + "expected": { + "__init__": { + "result": "error", + "messages": ["Exception in tests.units.test_models.FakeTestWithTemplateBadRender2.render(): Exception"], + }, + "test": {"result": "error"}, + }, + }, + { + "name": "unskip on platforms", + "test": UnSkipOnPlatformTest, + "inputs": None, + "expected": { + "__init__": {"result": "unset"}, + "test": {"result": "success"}, + }, + }, + { + "name": "skip on platforms, unset", + "test": SkipOnPlatformTest, + "inputs": None, + "expected": { + "__init__": {"result": "unset"}, + "test": {"result": "skipped"}, + }, + }, + { + "name": "skip on platforms, not unset", + "test": SkipOnPlatformTestWithInput, + "inputs": None, + "expected": {"__init__": {"result": "error", "messages": ["Field required"]}, "test": {"result": "error"}}, + }, + { + "name": "deprecate test without new test", + "test": DeprecatedTestWithoutNewTest, + "inputs": None, + "expected": { + "__init__": {"result": "unset"}, + "test": {"result": "success"}, + }, + }, + { + "name": "deprecate test with new test", + "test": DeprecatedTestWithNewTest, + "inputs": None, + "expected": { + "__init__": {"result": "unset"}, + "test": {"result": "success"}, + }, + }, + { + "name": "failed command", + "test": FakeTestWithFailedCommand, + "inputs": None, + "expected": {"__init__": {"result": "unset"}, "test": {"result": "error", "messages": ["show version has failed: failed command"]}}, + }, + { + "name": "unsupported command", + "test": FakeTestWithUnsupportedCommand, + "inputs": None, + "expected": { + "__init__": {"result": "unset"}, + "test": {"result": "skipped", "messages": ["Skipped because show hardware counter drop is not supported on pytest"]}, + }, + }, +] + + +class Test_AntaTest: + """ + Test for anta.models.AntaTest + """ + + def test__init_subclass__name(self) -> None: + """Test __init_subclass__""" + # Pylint detects all the classes in here as unused which is on purpose + # pylint: disable=unused-variable + with pytest.raises(NotImplementedError) as exec_info: + + class WrongTestNoName(AntaTest): + """ANTA test that is missing a name""" + + description = "ANTA test that is missing a name" + categories = [] + commands = [] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoName is missing required class attribute name" + + with pytest.raises(NotImplementedError) as exec_info: + + class WrongTestNoDescription(AntaTest): + """ANTA test that is missing a description""" + + name = "WrongTestNoDescription" + categories = [] + commands = [] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoDescription is missing required class attribute description" + + with pytest.raises(NotImplementedError) as exec_info: + + class WrongTestNoCategories(AntaTest): + """ANTA test that is missing categories""" + + name = "WrongTestNoCategories" + description = "ANTA test that is missing categories" + commands = [] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoCategories is missing required class attribute categories" + + with pytest.raises(NotImplementedError) as exec_info: + + class WrongTestNoCommands(AntaTest): + """ANTA test that is missing commands""" + + name = "WrongTestNoCommands" + description = "ANTA test that is missing commands" + categories = [] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoCommands is missing required class attribute commands" + + def _assert_test(self, test: AntaTest, expected: dict[str, Any]) -> None: + assert test.result.result == expected["result"] + if "messages" in expected: + for result_msg, expected_msg in zip(test.result.messages, expected["messages"]): # NOTE: zip(strict=True) has been added in Python 3.10 + assert expected_msg in result_msg + + @pytest.mark.parametrize("data", ANTATEST_DATA, ids=generate_test_ids(ANTATEST_DATA)) + def test__init__(self, device: AntaDevice, data: dict[str, Any]) -> None: + """Test the AntaTest constructor""" + expected = data["expected"]["__init__"] + test = data["test"](device, inputs=data["inputs"]) + self._assert_test(test, expected) + + @pytest.mark.parametrize("data", ANTATEST_DATA, ids=generate_test_ids(ANTATEST_DATA)) + def test_test(self, device: AntaDevice, data: dict[str, Any]) -> None: + """Test the AntaTest.test method""" + expected = data["expected"]["test"] + test = data["test"](device, inputs=data["inputs"]) + asyncio.run(test.test()) + self._assert_test(test, expected) + + +ANTATEST_BLACKLIST_DATA = ["reload", "reload --force", "write", "wr mem"] + + +@pytest.mark.parametrize("data", ANTATEST_BLACKLIST_DATA) +def test_blacklist(device: AntaDevice, data: str) -> None: + """Test for blacklisting function.""" + + class FakeTestWithBlacklist(AntaTest): + """Fake Test for blacklist""" + + name = "FakeTestWithBlacklist" + description = "ANTA test that has blacklisted command" + categories = [] + commands = [AntaCommand(command=data)] + + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() + + test_instance = FakeTestWithBlacklist(device) + + # Run the test() method + asyncio.run(test_instance.test()) + assert test_instance.result.result == "error" diff --git a/tests/units/test_runner.py b/tests/units/test_runner.py new file mode 100644 index 0000000..c353cbe --- /dev/null +++ b/tests/units/test_runner.py @@ -0,0 +1,82 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +test anta.runner.py +""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import pytest + +from anta import logger +from anta.catalog import AntaCatalog +from anta.inventory import AntaInventory +from anta.result_manager import ResultManager +from anta.runner import main + +from .test_models import FakeTest + +if TYPE_CHECKING: + from pytest import LogCaptureFixture + +FAKE_CATALOG: AntaCatalog = AntaCatalog.from_list([(FakeTest, None)]) + + +@pytest.mark.asyncio +async def test_runner_empty_tests(caplog: LogCaptureFixture, test_inventory: AntaInventory) -> None: + """ + Test that when the list of tests is empty, a log is raised + + caplog is the pytest fixture to capture logs + test_inventory is a fixture that gives a default inventory for tests + """ + logger.setup_logging(logger.Log.INFO) + caplog.set_level(logging.INFO) + manager = ResultManager() + await main(manager, test_inventory, AntaCatalog()) + + assert len(caplog.record_tuples) == 1 + assert "The list of tests is empty, exiting" in caplog.records[0].message + + +@pytest.mark.asyncio +async def test_runner_empty_inventory(caplog: LogCaptureFixture) -> None: + """ + Test that when the Inventory is empty, a log is raised + + caplog is the pytest fixture to capture logs + """ + logger.setup_logging(logger.Log.INFO) + caplog.set_level(logging.INFO) + manager = ResultManager() + inventory = AntaInventory() + await main(manager, inventory, FAKE_CATALOG) + assert len(caplog.record_tuples) == 1 + assert "The inventory is empty, exiting" in caplog.records[0].message + + +@pytest.mark.asyncio +async def test_runner_no_selected_device(caplog: LogCaptureFixture, test_inventory: AntaInventory) -> None: + """ + Test that when the list of established device + + caplog is the pytest fixture to capture logs + test_inventory is a fixture that gives a default inventory for tests + """ + logger.setup_logging(logger.Log.INFO) + caplog.set_level(logging.INFO) + manager = ResultManager() + await main(manager, test_inventory, FAKE_CATALOG) + + assert "No device in the established state 'True' was found. There is no device to run tests against, exiting" in [record.message for record in caplog.records] + + # Reset logs and run with tags + caplog.clear() + await main(manager, test_inventory, FAKE_CATALOG, tags=["toto"]) + + assert "No device in the established state 'True' matching the tags ['toto'] was found. There is no device to run tests against, exiting" in [ + record.message for record in caplog.records + ] diff --git a/tests/units/tools/__init__.py b/tests/units/tools/__init__.py new file mode 100644 index 0000000..e772bee --- /dev/null +++ b/tests/units/tools/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. diff --git a/tests/units/tools/test_get_dict_superset.py b/tests/units/tools/test_get_dict_superset.py new file mode 100644 index 0000000..63e08b5 --- /dev/null +++ b/tests/units/tools/test_get_dict_superset.py @@ -0,0 +1,149 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. + +"""Tests for `anta.tools.get_dict_superset`.""" +from __future__ import annotations + +from contextlib import nullcontext as does_not_raise +from typing import Any + +import pytest + +from anta.tools.get_dict_superset import get_dict_superset + +# pylint: disable=duplicate-code +DUMMY_DATA = [ + ("id", 0), + { + "id": 1, + "name": "Alice", + "age": 30, + "email": "alice@example.com", + }, + { + "id": 2, + "name": "Bob", + "age": 35, + "email": "bob@example.com", + }, + { + "id": 3, + "name": "Charlie", + "age": 40, + "email": "charlie@example.com", + }, +] + + +@pytest.mark.parametrize( + "list_of_dicts, input_dict, default, required, var_name, custom_error_msg, expected_result, expected_raise", + [ + pytest.param([], {"id": 1, "name": "Alice"}, None, False, None, None, None, does_not_raise(), id="empty list"), + pytest.param( + [], + {"id": 1, "name": "Alice"}, + None, + True, + None, + None, + None, + pytest.raises(ValueError, match="not found in the provided list."), + id="empty list and required", + ), + pytest.param(DUMMY_DATA, {"id": 10, "name": "Jack"}, None, False, None, None, None, does_not_raise(), id="missing item"), + pytest.param(DUMMY_DATA, {"id": 1, "name": "Alice"}, None, False, None, None, DUMMY_DATA[1], does_not_raise(), id="found item"), + pytest.param(DUMMY_DATA, {"id": 10, "name": "Jack"}, "default_value", False, None, None, "default_value", does_not_raise(), id="default value"), + pytest.param( + DUMMY_DATA, {"id": 10, "name": "Jack"}, None, True, None, None, None, pytest.raises(ValueError, match="not found in the provided list."), id="required" + ), + pytest.param( + DUMMY_DATA, + {"id": 10, "name": "Jack"}, + None, + True, + "custom_var_name", + None, + None, + pytest.raises(ValueError, match="custom_var_name not found in the provided list."), + id="custom var_name", + ), + pytest.param( + DUMMY_DATA, {"id": 1, "name": "Alice"}, None, True, "custom_var_name", "Custom error message", DUMMY_DATA[1], does_not_raise(), id="custom error message" + ), + pytest.param( + DUMMY_DATA, + {"id": 10, "name": "Jack"}, + None, + True, + "custom_var_name", + "Custom error message", + None, + pytest.raises(ValueError, match="Custom error message"), + id="custom error message and required", + ), + pytest.param(DUMMY_DATA, {"id": 1, "name": "Jack"}, None, False, None, None, None, does_not_raise(), id="id ok but name not ok"), + pytest.param( + "not a list", + {"id": 1, "name": "Alice"}, + None, + True, + None, + None, + None, + pytest.raises(ValueError, match="not found in the provided list."), + id="non-list input for list_of_dicts", + ), + pytest.param( + DUMMY_DATA, "not a dict", None, True, None, None, None, pytest.raises(ValueError, match="not found in the provided list."), id="non-dictionary input" + ), + pytest.param(DUMMY_DATA, {}, None, False, None, None, None, does_not_raise(), id="empty dictionary input"), + pytest.param( + DUMMY_DATA, + {"id": 1, "name": "Alice", "extra_key": "extra_value"}, + None, + True, + None, + None, + None, + pytest.raises(ValueError, match="not found in the provided list."), + id="input dictionary with extra keys", + ), + pytest.param( + DUMMY_DATA, + {"id": 1}, + None, + False, + None, + None, + DUMMY_DATA[1], + does_not_raise(), + id="input dictionary is a subset of more than one dictionary in list_of_dicts", + ), + pytest.param( + DUMMY_DATA, + {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com", "extra_key": "extra_value"}, + None, + True, + None, + None, + None, + pytest.raises(ValueError, match="not found in the provided list."), + id="input dictionary is a superset of a dictionary in list_of_dicts", + ), + ], +) +def test_get_dict_superset( + list_of_dicts: list[dict[Any, Any]], + input_dict: Any, + default: Any | None, + required: bool, + var_name: str | None, + custom_error_msg: str | None, + expected_result: str, + expected_raise: Any, +) -> None: + """Test get_dict_superset.""" + # pylint: disable=too-many-arguments + with expected_raise: + assert get_dict_superset(list_of_dicts, input_dict, default, required, var_name, custom_error_msg) == expected_result diff --git a/tests/units/tools/test_get_item.py b/tests/units/tools/test_get_item.py new file mode 100644 index 0000000..7d75e9c --- /dev/null +++ b/tests/units/tools/test_get_item.py @@ -0,0 +1,72 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. + +"""Tests for `anta.tools.get_item`.""" +from __future__ import annotations + +from contextlib import nullcontext as does_not_raise +from typing import Any + +import pytest + +from anta.tools.get_item import get_item + +DUMMY_DATA = [ + ("id", 0), + { + "id": 1, + "name": "Alice", + "age": 30, + "email": "alice@example.com", + }, + { + "id": 2, + "name": "Bob", + "age": 35, + "email": "bob@example.com", + }, + { + "id": 3, + "name": "Charlie", + "age": 40, + "email": "charlie@example.com", + }, +] + + +@pytest.mark.parametrize( + "list_of_dicts, key, value, default, required, case_sensitive, var_name, custom_error_msg, expected_result, expected_raise", + [ + pytest.param([], "name", "Bob", None, False, False, None, None, None, does_not_raise(), id="empty list"), + pytest.param([], "name", "Bob", None, True, False, None, None, None, pytest.raises(ValueError, match="name"), id="empty list and required"), + pytest.param(DUMMY_DATA, "name", "Jack", None, False, False, None, None, None, does_not_raise(), id="missing item"), + pytest.param(DUMMY_DATA, "name", "Alice", None, False, False, None, None, DUMMY_DATA[1], does_not_raise(), id="found item"), + pytest.param(DUMMY_DATA, "name", "Jack", "default_value", False, False, None, None, "default_value", does_not_raise(), id="default value"), + pytest.param(DUMMY_DATA, "name", "Jack", None, True, False, None, None, None, pytest.raises(ValueError, match="name"), id="required"), + pytest.param(DUMMY_DATA, "name", "Bob", None, False, True, None, None, DUMMY_DATA[2], does_not_raise(), id="case sensitive"), + pytest.param(DUMMY_DATA, "name", "charlie", None, False, False, None, None, DUMMY_DATA[3], does_not_raise(), id="case insensitive"), + pytest.param( + DUMMY_DATA, "name", "Jack", None, True, False, "custom_var_name", None, None, pytest.raises(ValueError, match="custom_var_name"), id="custom var_name" + ), + pytest.param( + DUMMY_DATA, "name", "Jack", None, True, False, None, "custom_error_msg", None, pytest.raises(ValueError, match="custom_error_msg"), id="custom error msg" + ), + ], +) +def test_get_item( + list_of_dicts: list[dict[Any, Any]], + key: Any, + value: Any, + default: Any | None, + required: bool, + case_sensitive: bool, + var_name: str | None, + custom_error_msg: str | None, + expected_result: str, + expected_raise: Any, +) -> None: + """Test get_item.""" + # pylint: disable=too-many-arguments + with expected_raise: + assert get_item(list_of_dicts, key, value, default, required, case_sensitive, var_name, custom_error_msg) == expected_result diff --git a/tests/units/tools/test_get_value.py b/tests/units/tools/test_get_value.py new file mode 100644 index 0000000..73344d1 --- /dev/null +++ b/tests/units/tools/test_get_value.py @@ -0,0 +1,50 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tools.get_value +""" + +from __future__ import annotations + +from contextlib import nullcontext as does_not_raise +from typing import Any + +import pytest + +from anta.tools.get_value import get_value + +INPUT_DICT = {"test_value": 42, "nested_test": {"nested_value": 43}} + + +@pytest.mark.parametrize( + "input_dict, key, default, required, org_key, separator, expected_result, expected_raise", + [ + pytest.param({}, "test", None, False, None, None, None, does_not_raise(), id="empty dict"), + pytest.param(INPUT_DICT, "test_value", None, False, None, None, 42, does_not_raise(), id="simple key"), + pytest.param(INPUT_DICT, "nested_test.nested_value", None, False, None, None, 43, does_not_raise(), id="nested_key"), + pytest.param(INPUT_DICT, "missing_value", None, False, None, None, None, does_not_raise(), id="missing_value"), + pytest.param(INPUT_DICT, "missing_value_with_default", "default_value", False, None, None, "default_value", does_not_raise(), id="default"), + pytest.param(INPUT_DICT, "missing_required", None, True, None, None, None, pytest.raises(ValueError), id="required"), + pytest.param(INPUT_DICT, "missing_required", None, True, "custom_org_key", None, None, pytest.raises(ValueError), id="custom org_key"), + pytest.param(INPUT_DICT, "nested_test||nested_value", None, None, None, "||", 43, does_not_raise(), id="custom separator"), + ], +) +def test_get_value( + input_dict: dict[Any, Any], + key: str, + default: str | None, + required: bool, + org_key: str | None, + separator: str | None, + expected_result: str, + expected_raise: Any, +) -> None: + """ + Test get_value + """ + # pylint: disable=too-many-arguments + kwargs = {"default": default, "required": required, "org_key": org_key, "separator": separator} + kwargs = {k: v for k, v in kwargs.items() if v is not None} + with expected_raise: + assert get_value(input_dict, key, **kwargs) == expected_result # type: ignore diff --git a/tests/units/tools/test_misc.py b/tests/units/tools/test_misc.py new file mode 100644 index 0000000..c453c21 --- /dev/null +++ b/tests/units/tools/test_misc.py @@ -0,0 +1,38 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +""" +Tests for anta.tools.misc +""" +from __future__ import annotations + +import pytest + +from anta.tools.misc import exc_to_str, tb_to_str + + +def my_raising_function(exception: Exception) -> None: + """ + dummy function to raise Exception + """ + raise exception + + +@pytest.mark.parametrize("exception, expected_output", [(ValueError("test"), "ValueError (test)"), (ValueError(), "ValueError")]) +def test_exc_to_str(exception: Exception, expected_output: str) -> None: + """ + Test exc_to_str + """ + assert exc_to_str(exception) == expected_output + + +def test_tb_to_str() -> None: + """ + Test tb_to_str + """ + try: + my_raising_function(ValueError("test")) + except ValueError as e: + output = tb_to_str(e) + assert "Traceback" in output + assert 'my_raising_function(ValueError("test"))' in output diff --git a/tests/units/tools/test_utils.py b/tests/units/tools/test_utils.py new file mode 100644 index 0000000..448324f --- /dev/null +++ b/tests/units/tools/test_utils.py @@ -0,0 +1,57 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. + +"""Tests for `anta.tools.utils`.""" +from __future__ import annotations + +from contextlib import nullcontext as does_not_raise +from typing import Any + +import pytest + +from anta.tools.utils import get_failed_logs + +EXPECTED_OUTPUTS = [ + {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com"}, + {"id": 2, "name": "Bob", "age": 35, "email": "bob@example.com"}, + {"id": 3, "name": "Charlie", "age": 40, "email": "charlie@example.com"}, + {"id": 4, "name": "Jon", "age": 25, "email": "Jon@example.com"}, +] + +ACTUAL_OUTPUTS = [ + {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com"}, + {"id": 2, "name": "Bob", "age": 35, "email": "bob@example.com"}, + {"id": 3, "name": "Charlie", "age": 40, "email": "charlie@example.com"}, + {"id": 4, "name": "Rob", "age": 25, "email": "Jon@example.com"}, +] + + +@pytest.mark.parametrize( + "expected_output, actual_output, expected_result, expected_raise", + [ + pytest.param(EXPECTED_OUTPUTS[0], ACTUAL_OUTPUTS[0], "", does_not_raise(), id="no difference"), + pytest.param( + EXPECTED_OUTPUTS[0], + ACTUAL_OUTPUTS[1], + "\nExpected `1` as the id, but found `2` instead.\nExpected `Alice` as the name, but found `Bob` instead.\n" + "Expected `30` as the age, but found `35` instead.\nExpected `alice@example.com` as the email, but found `bob@example.com` instead.", + does_not_raise(), + id="different data", + ), + pytest.param( + EXPECTED_OUTPUTS[0], + {}, + "\nExpected `1` as the id, but it was not found in the actual output.\nExpected `Alice` as the name, but it was not found in the actual output.\n" + "Expected `30` as the age, but it was not found in the actual output.\nExpected `alice@example.com` as the email, but it was not found in " + "the actual output.", + does_not_raise(), + id="empty actual output", + ), + pytest.param(EXPECTED_OUTPUTS[3], ACTUAL_OUTPUTS[3], "\nExpected `Jon` as the name, but found `Rob` instead.", does_not_raise(), id="different name"), + ], +) +def test_get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, Any], expected_result: str, expected_raise: Any) -> None: + """Test get_failed_logs.""" + with expected_raise: + assert get_failed_logs(expected_output, actual_output) == expected_result |