1
0
Fork 0

Adding upstream version 0.8.1.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-05 13:43:43 +01:00
parent b16cc97368
commit 55e5e7ac79
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
49 changed files with 4592 additions and 0 deletions

24
.bumpversion.cfg Normal file
View file

@ -0,0 +1,24 @@
[bumpversion]
commit = True
tag = False
tag_message = Bump version: {current_version} → {new_version}
tag_name = v{new_version}
current_version = 0.7.0
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)([-](?P<release>(dev|rc))+(?P<build>\d+))?
serialize =
{major}.{minor}.{patch}-{release}{build}
{major}.{minor}.{patch}
[bumpversion:part:release]
first_value = dev
optional_value = prod
values =
dev
prod
[bumpversion:part:build]
first_value = 1
[bumpversion:file:./eos_downloader/__init__.py]
search = __version__ = '{current_version}'
replace = __version__ = '{new_version}'

8
.coveragerc Normal file
View file

@ -0,0 +1,8 @@
[html]
directory = tests/htmlcov
[tool:pytest]
addopts = --cov=eos_downloader --cov-report html
[run]
omit = tests/*

26
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,26 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.202.3/containers/python-3/.devcontainer/base.Dockerfile
# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster
ARG VARIANT="3.9-bullseye"
FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
ARG NODE_VERSION="none"
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image.
# COPY requirements.txt /tmp/pip-tmp/
# RUN pip3 --disable-pip-version-check --no-cache-dir install -e .
# [Optional] Uncomment this section to install additional OS packages.
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends qemu-kvm qemu-utils libguestfs-tools
RUN mkdir -p /opt/unetlab/wrappers/ \
&& echo "#!/bin/bash" > /opt/unetlab/wrappers/unl_wrapper \
&& chmod 755 /opt/unetlab/wrappers/unl_wrapper \
&& mkdir -p /opt/unetlab/addons/qemu/ \
&& chmod 777 /opt/unetlab/addons/qemu/
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View file

@ -0,0 +1,50 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.202.3/containers/python-3
{
"name": "Python 3",
"runArgs": ["--init"],
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"args": {
// Update 'VARIANT' to pick a Python version: 3, 3.9, 3.8, 3.7, 3.6.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local on arm64/Apple Silicon.
"VARIANT": "3.9",
// Options
"NODE_VERSION": "lts/*"
}
},
// Set *default* container specific settings.json values on container create.
"settings": {
"python.pythonPath": "/usr/local/bin/python",
"python.languageServer": "Pylance",
"python.linting.enabled": true,
"python.linting.pylintEnabled": true,
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
"python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
"python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
"python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
"python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance"
],
// 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": "pip3 install --user poetry",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"
}

435
.github/changelog.sh vendored Normal file
View file

@ -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

28
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,28 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
labels:
- dependabot
schedule:
interval: "weekly"
commit-message:
prefix: "bump"
include: "ci"
open-pull-requests-limit: 10
- package-ecosystem: "pip"
directory: "/"
labels:
schedule:
interval: "weekly"
labels:
- dependabot
commit-message:
prefix: "bump"
include: "requirements"
open-pull-requests-limit: 10

89
.github/workflows/on_demand.yml vendored Normal file
View file

@ -0,0 +1,89 @@
---
name: "Build a docker image on-demand"
on:
workflow_dispatch:
inputs:
tag:
description: "Tag to use during the build (default: dev)"
required: true
default: 'dev'
jobs:
docker:
name: Docker Image Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta for TAG
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ secrets.DOCKER_IMAGE }}
ghcr.io/${{ secrets.DOCKER_IMAGE }}
tags: |
type=raw,value=${{ inputs.tag }}
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
docker_in_docker:
name: Docker Image Build with Docker support
runs-on: ubuntu-latest
needs: [docker]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta for TAG
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ secrets.DOCKER_IMAGE }}
ghcr.io/${{ secrets.DOCKER_IMAGE }}
tags: |
type=raw,value=${{ inputs.tag }}-dind
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile.docker
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

104
.github/workflows/pr-management.yml vendored Normal file
View file

@ -0,0 +1,104 @@
---
name: code-testing
on:
push:
branches:
- main
pull_request_target:
types: [assigned, opened, synchronize, reopened]
jobs:
compiling:
name: Run installation process and code compilation supported Python versions
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: install requirements
run: |
pip install .
- name: install dev requirements
run: pip install .[dev]
- name: validate the syntax of python scripts
run: |
python -m py_compile $(git ls-files '*.py')
linting:
name: Run flake8, pylint for supported Python versions
runs-on: ubuntu-latest
needs: [compiling]
strategy:
matrix:
python: ["3.8", "3.9", "3.10"]
steps:
- uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python }}
- name: Install dependencies
run: pip install tox tox-gh-actions
- name: "Run tox for ${{ matrix.python }}"
run: tox -e lint
typing:
name: Run mypy for supported Python versions
runs-on: ubuntu-latest
needs: [compiling]
strategy:
matrix:
python: ["3.8", "3.9", "3.10"]
steps:
- uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python }}
- name: Install dependencies
run: pip install tox tox-gh-actions
- name: "Run tox for ${{ matrix.python }}"
run: tox -e type
pytest:
name: Run pytest validation
runs-on: ubuntu-latest
needs: [linting, typing]
strategy:
matrix:
python: ["3.8", "3.9", "3.10"]
steps:
- uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python }}
- name: Install dependencies
run: pip install tox tox-gh-actions
- name: "Run tox for ${{ matrix.python }}"
run: tox -e testenv

136
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,136 @@
---
name: "Tag & Release management"
on:
push:
# Sequence of patterns matched against refs/tags
tags:
- 'v[0-9]+.[0-9]+.[0-9]+' # Push events to matching v*, i.e. v1.0, v20.15.10
jobs:
# release:
# name: Create Github Release
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# fetch-depth: 0
# - name: Generate Changelog
# run: |
# sudo apt update && sudo apt install zsh
# export TAG_CURRENT=$(git describe --abbrev=0 --tags)
# export TAG_PREVIOUS=$(git describe --abbrev=0 --tags `git rev-list --tags --skip=1 --max-count=1`)
# echo "Previous tag is: ${TAG_PREVIOUS}"
# echo "Current tag is: ${TAG_CURRENT}"
# zsh .github/changelog.sh ${TAG_CURRENT} ${TAG_PREVIOUS} md > CHANGELOG.md
# cat CHANGELOG.md
# - name: Release on Github
# uses: softprops/action-gh-release@v1
# with:
# draft: false
# body_path: CHANGELOG.md
pypi:
name: Publish version to Pypi servers
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- 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 TestPyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
docker:
name: Docker Image Build
runs-on: ubuntu-latest
needs: [pypi]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta for TAG
id: meta
uses: docker/metadata-action@v4
with:
images:
${{ secrets.DOCKER_IMAGE }}
ghcr.io/${{ secrets.DOCKER_IMAGE }}
tags: |
type=semver,pattern={{raw}}
type=raw,value=latest
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
docker_in_docker:
name: Docker Image Build with Docker support
runs-on: ubuntu-latest
needs: [docker]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta for TAG
id: meta
uses: docker/metadata-action@v4
with:
images:
${{ secrets.DOCKER_IMAGE }}
ghcr.io/${{ secrets.DOCKER_IMAGE }}
tags: |
type=semver,pattern={{raw}}-dind
type=raw,value=latest-dind
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile.docker
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

348
.gitignore vendored Normal file
View file

@ -0,0 +1,348 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# 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
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# 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
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
.vscode/*
.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
*.swi
*.sha512sum
.idea
.python-version
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# 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/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# 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
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# vscode
.vscode/*
*.tar.xz
report.html
*.swp
arista.xml
tester.py

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
ARG PYTHON_VER=3.10
FROM python:${PYTHON_VER}-slim
RUN pip install --upgrade pip
WORKDIR /local
COPY . /local
LABEL maintainer="Thomas Grimonet <tom@inetsix.net>"
LABEL "org.opencontainers.image.title"="eos-downloader" \
"org.opencontainers.image.description"="eos-downloader container" \
"org.opencontainers.artifact.description"="A CLI to manage Arista EOS version download" \
"org.opencontainers.image.source"="https://github.com/titom73/eos-downloader" \
"org.opencontainers.image.url"="https://github.com/titom73/eos-downloader" \
"org.opencontainers.image.documentation"="https://github.com/titom73/eos-downloader" \
"org.opencontainers.image.licenses"="Apache-2.0" \
"org.opencontainers.image.vendor"="N/A" \
"org.opencontainers.image.authors"="Thomas Grimonet <tom@inetsix.net>" \
"org.opencontainers.image.base.name"="python" \
"org.opencontainers.image.revision"="dev" \
"org.opencontainers.image.version"="dev"
ENV PYTHONPATH=/local
RUN pip --no-cache-dir install .
ENTRYPOINT [ "/usr/local/bin/ardl" ]

34
Dockerfile.docker Normal file
View file

@ -0,0 +1,34 @@
ARG PYTHON_VER=3.10
FROM python:${PYTHON_VER}-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
curl \
gnupg \
lsb-release \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian \
$(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli \
&& rm -rf /var/lib/apt/lists/* \
&& rm -Rf /usr/share/doc && rm -Rf /usr/share/man \
&& apt-get clean
RUN pip install --upgrade pip
WORKDIR /local
COPY . /local
LABEL maintainer="Thomas Grimonet <tom@inetsix.net>"
LABEL com.example.version="edge"
LABEL com.example.release-date="2022-04-05"
LABEL com.example.version.is-production="False"
ENV PYTHONPATH=/local
RUN pip --no-cache-dir install .
ENTRYPOINT [ "/usr/local/bin/ardl" ]

204
LICENSE Normal file
View file

@ -0,0 +1,204 @@
Copyright (c) 2019, Arista Networks
All rights reserved.
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 2019 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.

10
Makefile Normal file
View file

@ -0,0 +1,10 @@
CURRENT_DIR = $(shell pwd)
DOCKER_NAME ?= titom73/eos-downloader
DOCKER_TAG ?= dev
DOCKER_FILE ?= Dockerfile
PYTHON_VER ?= 3.9
.PHONY: build
build:
docker build -t $(DOCKER_NAME):$(DOCKER_TAG) --build-arg DOCKER_VERSION=$(DOCKER_VERSION) -f $(DOCKER_FILE) .

183
README.md Normal file
View file

@ -0,0 +1,183 @@
[![code-testing](https://github.com/titom73/eos-downloader/actions/workflows/pr-management.yml/badge.svg?event=push)](https://github.com/titom73/eos-downloader/actions/workflows/pr-management.yml) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/eos-downloader) ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/titom73/arista-downloader) ![PyPI - Downloads/month](https://img.shields.io/pypi/dm/eos-downloader) ![Docker Image Size (tag)](https://img.shields.io/docker/image-size/titom73/eos-downloader/edge)
# Arista Software Downloader
Script to download Arista softwares to local folder, Cloudvision or EVE-NG.
```bash
pip install eos-downloader
```
## CLI commands
A new CLI is available to execute commands. This CLI is going to replace [`eos-download`](./bin/README.md) script which is now marked as __deprecated__
```bash
ardl
Usage: ardl [OPTIONS] COMMAND [ARGS]...
Arista Network Download CLI
Options:
--token TEXT Arista Token from your customer account [env var:
ARISTA_TOKEN]
--help Show this message and exit.
Commands:
debug Debug commands to work with ardl
get Download Arista from Arista website
version Display version of ardl
```
> **Warning**
> To use this CLI you need to get a valid token from your [Arista Account page](https://www.arista.com/en/users/profile).
> For technical reason, it is only available for customers with active maintenance contracts and not for personnal accounts
### Download EOS Package
> Supported packages are: EOS, cEOS, vEOS-lab, cEOS64
You can download EOS packages with following commands:
```bash
# Example for a cEOS package
$ ardl get eos --version 4.28.3M --image-type cEOS
```
Available options are :
```bash
Usage: ardl get eos [OPTIONS]
Download EOS image from Arista website
Options:
--image-type [64|INT|2GB-INT|cEOS|cEOS64|vEOS|vEOS-lab|EOS-2GB|default]
EOS Image type [required]
--version TEXT EOS version
-l, --latest Get latest version in given branch. If
--branch is not use, get the latest branch
with specific release type
-rtype, --release-type [F|M] EOS release type to search
-b, --branch TEXT EOS Branch to list releases
--docker-name TEXT Docker image name (default: arista/ceos)
[default: arista/ceos]
--output PATH Path to save image [default: .]
--log-level, --log [debug|info|warning|error|critical]
Logging level of the command
--eve-ng Run EVE-NG vEOS provisioning (only if CLI
runs on an EVE-NG server)
--disable-ztp Disable ZTP process in vEOS image (only
available with --eve-ng)
--import-docker Import docker image (only available with
--image_type cEOSlab)
--help Show this message and exit.
```
You can use `--latest` and `--release-type` option to get latest EOS version matching a specific release type
```bash
# Get latest M release
ardl get eos --latest -rtype m
🪐 eos-downloader is starting...
- Image Type: default
- Version: None
🔎 Searching file EOS-4.29.3M.swi
-> Found file at /support/download/EOS-USA/Active Releases/4.29/EOS-4.29.3M/EOS-4.29.3M.swi
...
✅ Downloaded file is correct.
✅ processing done !
```
### List available EOS versions from Arista website
You can easily get list of available version using CLI as shown below:
```bash
ardl info eos-versions
Usage: ardl info eos-versions [OPTIONS]
List Available EOS version on Arista.com website.
Comes with some filters to get latest release (F or M) as well as branch
filtering
- To get latest M release available (without any branch): ardl info eos-
versions --latest -rtype m
- To get latest F release available: ardl info eos-versions --latest
-rtype F
Options:
-l, --latest Get latest version in given branch. If
--branch is not use, get the latest branch
with specific release type
-rtype, --release-type [F|M] EOS release type to search
-b, --branch TEXT EOS Branch to list releases
-v, --verbose Human readable output. Default is none to
use output in script)
--log-level, --log [debug|info|warning|error|critical]
Logging level of the command
--help Show this message and exit.
```
__Example__
```bash
ardl info eos-versions -rtype m --branch 4.28
['4.28.6.1M', '4.28.6M', '4.28.5.1M', '4.28.5M', '4.28.4M', '4.28.3M']
```
### Download CVP package
> Supported packages are: OVA, KVM, RPM, Upgrade
```bash
$ ardl get cvp --format upgrade --version 2022.2.1 --log-level debug --output ~/Downloads
```
Available options are :
```bash
--format [ova|rpm|kvm|upgrade] CVP Image type [required]
--version TEXT CVP version [required]
--output PATH Path to save image [default: .]
--log-level, --log [debug|info|warning|error|critical]
Logging level of the command
--help Show this message and exit.
```
## Requirements
Repository requires Python `>=3.6` with following requirements:
```requirements
cvprac
cryptography
paramiko
requests
requests-toolbelt
scp
tqdm
```
On EVE-NG, you may have to install/upgrade __pyOpenSSL__ in version `23.0.0`:
```
# Error when running ardl: AttributeError: module 'lib' has no attribute 'X509_V_FLAG_CB_ISSUER_CHECK'
$ pip install pyopenssl --upgrade
```
## Docker
Please refer to [docker documentation](docs/docker.md)
## Author
From an original idea of [@Mark Rayson](https://github.com/Sparky-python) in [arista-netdevops-community/eos-scripts](https://github.com/arista-netdevops-community/eos-scripts)
## License
Code is under [Apache2](LICENSE) License

111
bin/README.md Normal file
View file

@ -0,0 +1,111 @@
## scripts
These scripts are deprecated and will be removed in a futur version. Please prefer the use of the CLI implemented in the package.
### eos-download
```bash
usage: eos-download [-h]
--version VERSION
[--token TOKEN]
[--image IMAGE]
[--destination DESTINATION]
[--eve]
[--noztp]
[--import_docker]
[--docker_name DOCKER_NAME]
[--verbose VERBOSE]
[--log]
EOS downloader script.
optional arguments:
-h, --help show this help message and exit
--token TOKEN arista.com user API key - can use ENV:ARISTA_TOKEN
--image IMAGE Type of EOS image required
--version VERSION EOS version to download from website
--destination DESTINATION
Path where to save EOS package downloaded
--eve Option to install EOS package to EVE-NG
--noztp Option to deactivate ZTP when used with EVE-NG
--import_docker Option to import cEOS image to docker
--docker_name DOCKER_NAME
Docker image name to use
--verbose VERBOSE Script verbosity
--log Option to activate logging to eos-downloader.log file
```
- Token are read from `ENV:ARISTA_TOKEN` unless you specify a specific token with CLI.
- Supported platforms:
- `INT`: International version
- `64`: 64 bits version
- `2GB` for 2GB flash platform
- `2GB-INT`: for 2GB running International
- `vEOS`: Virtual EOS image
- `vEOS-lab`: Virtual Lab EOS
- `vEOS64-lab`: Virtual Lab EOS running 64B
- `cEOS`: Docker version of EOS
- `cEOS64`: Docker version of EOS running in 64 bits
#### Examples
- Download vEOS-lab image and install in EVE-NG
```bash
$ eos-download --image vEOS-lab --version 4.25.7M --eve --noztp
```
- Download Docker image
```bash
$ eos-download --image cEOS --version 4.27.1F
🪐 eos-downloader is starting...
- Image Type: cEOS
- Version: 4.27.2F
✅ Authenticated on arista.com
🔎 Searching file cEOS-lab-4.27.2F.tar.xz
-> Found file at /support/download/EOS-USA/Active Releases/4.27/EOS-4.27.2F/cEOS-lab/cEOS-lab-4.27.2F.tar.xz
💾 Downloading cEOS-lab-4.27.2F.tar.xz ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.0% • 17.1 MB/s • 451.6/451.6 MB • 0:00:19 •
🚀 Running checksum validation
🔎 Searching file cEOS-lab-4.27.2F.tar.xz.sha512sum
-> Found file at /support/download/EOS-USA/Active
Releases/4.27/EOS-4.27.2F/cEOS-lab/cEOS-lab-4.27.2F.tar.xz.sha512sum
💾 Downloading cEOS-lab-4.27.2F.tar.xz.sha512sum ━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.0% • ? • 154/154 bytes • 0:00:00 •
✅ Downloaded file is correct.
```
__Note:__ `ARISTA_TOKEN` should be set in your .profile and not set for each command. If not set, you can use `--token` knob.
```bash
# Export Token
export ARISTA_TOKEN="xxxxxxx"
```
### Cloudvision Image uploader
Create an image bundle on Cloudvision.
```bash
cvp-upload -h
usage: cvp-upload [-h]
[--token TOKEN]
[--image IMAGE]
--cloudvision CLOUDVISION
[--create_bundle]
[--timeout TIMEOUT]
[--verbose VERBOSE]
Cloudvision Image uploader script.
optional arguments:
-h, --help show this help message and exit
--token TOKEN CVP Authentication token - can use ENV:ARISTA_AVD_CV_TOKEN
--image IMAGE Type of EOS image required
--cloudvision CLOUDVISION
Cloudvision instance where to upload image
--create_bundle Option to create image bundle with new uploaded image
--timeout TIMEOUT Timeout connection. Default is set to 1200sec
--verbose VERBOSE Script verbosity
```

56
bin/cvp-upload Executable file
View file

@ -0,0 +1,56 @@
#!/usr/bin/python
import sys
import os
import argparse
from eos_downloader.cvp import CvFeatureManager, CvpAuthenticationItem
from loguru import logger
ARISTA_AVD_CV_TOKEN = os.getenv('ARISTA_AVD_CV_TOKEN', '')
def read_cli():
parser = argparse.ArgumentParser(description='Cloudvision Image uploader script.')
parser.add_argument('--token', required=False,
default=ARISTA_AVD_CV_TOKEN,
help='CVP Authentication token - can use ENV:ARISTA_AVD_CV_TOKEN')
parser.add_argument('--image', required=False,
default='EOS', help='Type of EOS image required')
parser.add_argument('--cloudvision', required=True,
help='Cloudvision instance where to upload image')
parser.add_argument('--create_bundle', required=False, action='store_true',
help="Option to create image bundle with new uploaded image")
parser.add_argument('--timeout', required=False,
default=1200,
help='Timeout connection. Default is set to 1200sec')
parser.add_argument('--verbose', required=False,
default='info', help='Script verbosity')
return parser.parse_args()
if __name__ == '__main__':
cli_options = read_cli()
logger.remove()
logger.add(sys.stderr, level=str(cli_options.verbose).upper())
cv_authentication = CvpAuthenticationItem(
server=cli_options.cloudvision,
token=cli_options.token,
port=443,
timeout=cli_options.timeout,
validate_cert=False
)
my_cvp_uploader = CvFeatureManager(authentication=cv_authentication)
result_upload = my_cvp_uploader.upload_image(cli_options.image)
if result_upload and cli_options.create_bundle:
bundle_name = os.path.basename(cli_options.image)
logger.info('Creating image bundle {}'.format(bundle_name))
my_cvp_uploader.create_bundle(
name=bundle_name,
images_name=[bundle_name]
)
sys.exit(0)

86
bin/eos-download Executable file
View file

@ -0,0 +1,86 @@
#!/usr/bin/python
import sys
import os
import argparse
import eos_downloader.eos
from loguru import logger
from rich.console import Console
ARISTA_TOKEN = os.getenv('ARISTA_TOKEN', '')
def read_cli():
parser = argparse.ArgumentParser(description='EOS downloader script.')
parser.add_argument('--token', required=False,
default=ARISTA_TOKEN,
help='arista.com user API key - can use ENV:ARISTA_TOKEN')
parser.add_argument('--image', required=False,
default='EOS', help='Type of EOS image required')
parser.add_argument('--version', required=True,
default='', help='EOS version to download from website')
parser.add_argument('--destination', required=False,
default=str(os.getcwd()),
help='Path where to save EOS package downloaded')
parser.add_argument('--eve', required=False, action='store_true',
help="Option to install EOS package to EVE-NG")
parser.add_argument('--noztp', required=False, action='store_true',
help="Option to deactivate ZTP when used with EVE-NG")
parser.add_argument('--import_docker', required=False, action='store_true',
help="Option to import cEOS image to docker")
parser.add_argument('--docker_name', required=False,
default='arista/ceos',
help='Docker image name to use')
parser.add_argument('--verbose', required=False,
default='info', help='Script verbosity')
parser.add_argument('--log', required=False, action='store_true',
help="Option to activate logging to eos-downloader.log file")
return parser.parse_args()
if __name__ == '__main__':
cli_options = read_cli()
console = Console()
console.print('\n[red]WARNING: This script is now deprecated. Please use ardl cli instead[/red]\n\n')
if cli_options.token is None or cli_options.token == '':
console.print('\n❗ Token is unset ! Please configure ARISTA_TOKEN or use --token option', style="bold red")
sys.exit(1)
logger.remove()
if cli_options.log:
logger.add("eos-downloader.log", rotation="10 MB", level=str(cli_options.verbose).upper())
console.print("🪐 [bold blue]eos-downloader[/bold blue] is starting...", )
console.print(f' - Image Type: {cli_options.image}')
console.print(f' - Version: {cli_options.version}')
my_download = eos_downloader.eos.EOSDownloader(
image=cli_options.image,
software='EOS',
version=cli_options.version,
token=cli_options.token,
hash_method='sha512sum')
my_download.authenticate()
if cli_options.eve:
my_download.provision_eve(noztp=cli_options.noztp, checksum=True)
else:
my_download.download_local(file_path=cli_options.destination, checksum=True)
if cli_options.import_docker:
my_download.docker_import(
image_name=cli_options.docker_name
)
console.print('✅ processing done !')
sys.exit(0)

49
docs/docker.md Normal file
View file

@ -0,0 +1,49 @@
# Docker Image
A [docker image](https://hub.docker.com/repository/docker/titom73/eos-downloader/tags?page=1&ordering=last_updated) is also available when Python cannot be used.
## Connect to your docker container
```bash
$ docker pull titom73/eos-downloader:edge
docker run -it --rm --entrypoint bash titom73/eos-downloader:dev
root@a9a8ceb533df:/local# ardl get eos --help
$ cd /download
$ ardl --token xxxx get eos --image-format cEOS --version 4.28.3M
```
## Use CLI with docker
```bash
docker run --rm titom73/eos-downloader:dev get eos --help
Usage: ardl get eos [OPTIONS]
Download EOS image from Arista website
Options:
--image-type [64|INT|2GB-INT|cEOS|cEOS64|vEOS|vEOS-lab|EOS-2GB|default]
EOS Image type [required]
--version TEXT EOS version [required]
--docker-name TEXT Docker image name (default: arista/ceos)
[default: arista/ceos]
--output PATH Path to save image [default: .]
--log-level, --log [debug|info|warning|error|critical]
Logging level of the command
--eve-ng / --no-eve-ng Run EVE-NG vEOS provisioning (only if CLI
runs on an EVE-NG server)
--disable-ztp / --no-disable-ztp
Disable ZTP process in vEOS image (only
available with --eve-ng)
--import-docker / --no-import-docker
Import docker image (only available with
--image_type cEOSlab)
--help Show this message and exit.
```
#### Available TAGS
- `edge`: Latest version built from the main branch
- `latest`: Latest stable Version
- `semver`: Version built from git tag
- `latest-dind`: Latest stable Version with docker CLI
- `semver-dind`: Version built from git tag with docker CLI

View file

@ -0,0 +1,47 @@
#!/usr/bin/python
# coding: utf-8 -*-
"""
EOS Downloader module.
"""
from __future__ import (absolute_import, division,
print_function, unicode_literals, annotations)
import dataclasses
from typing import Any
import json
import importlib.metadata
__author__ = '@titom73'
__email__ = 'tom@inetsix.net'
__date__ = '2022-03-16'
__version__ = importlib.metadata.version("eos-downloader")
# __all__ = ["CvpAuthenticationItem", "CvFeatureManager", "EOSDownloader", "ObjectDownloader", "reverse"]
ARISTA_GET_SESSION = "https://www.arista.com/custom_data/api/cvp/getSessionCode/"
ARISTA_SOFTWARE_FOLDER_TREE = "https://www.arista.com/custom_data/api/cvp/getFolderTree/"
ARISTA_DOWNLOAD_URL = "https://www.arista.com/custom_data/api/cvp/getDownloadLink/"
MSG_TOKEN_EXPIRED = """The API token has expired. Please visit arista.com, click on your profile and
select Regenerate Token then re-run the script with the new token.
"""
MSG_TOKEN_INVALID = """The API token is incorrect. Please visit arista.com, click on your profile and
check the Access Token. Then re-run the script with the correct token.
"""
MSG_INVALID_DATA = """Invalid data returned by server
"""
EVE_QEMU_FOLDER_PATH = '/opt/unetlab/addons/qemu/'
class EnhancedJSONEncoder(json.JSONEncoder):
"""Custom JSon encoder."""
def default(self, o: Any) -> Any:
if dataclasses.is_dataclass(o):
return dataclasses.asdict(o)
return super().default(o)

View file

76
eos_downloader/cli/cli.py Normal file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env python
# coding: utf-8 -*-
# pylint: disable=no-value-for-parameter
# pylint: disable=cyclic-import
# pylint: disable=too-many-arguments
# pylint: disable=unused-argument
"""
ARDL CLI Baseline.
"""
import click
from rich.console import Console
import eos_downloader
from eos_downloader.cli.get import commands as get_commands
from eos_downloader.cli.debug import commands as debug_commands
from eos_downloader.cli.info import commands as info_commands
@click.group()
@click.pass_context
@click.option('--token', show_envvar=True, default=None, help='Arista Token from your customer account')
def ardl(ctx: click.Context, token: str) -> None:
"""Arista Network Download CLI"""
ctx.ensure_object(dict)
ctx.obj['token'] = token
@click.command()
def version() -> None:
"""Display version of ardl"""
console = Console()
console.print(f'ardl is running version {eos_downloader.__version__}')
@ardl.group(no_args_is_help=True)
@click.pass_context
def get(ctx: click.Context) -> None:
# pylint: disable=redefined-builtin
"""Download Arista from Arista website"""
@ardl.group(no_args_is_help=True)
@click.pass_context
def info(ctx: click.Context) -> None:
# pylint: disable=redefined-builtin
"""List information from Arista website"""
@ardl.group(no_args_is_help=True)
@click.pass_context
def debug(ctx: click.Context) -> None:
# pylint: disable=redefined-builtin
"""Debug commands to work with ardl"""
# ANTA CLI Execution
def cli() -> None:
"""Load ANTA CLI"""
# Load group commands
get.add_command(get_commands.eos)
get.add_command(get_commands.cvp)
info.add_command(info_commands.eos_versions)
debug.add_command(debug_commands.xml)
ardl.add_command(version)
# Load CLI
ardl(
obj={},
auto_envvar_prefix='arista'
)
if __name__ == '__main__':
cli()

View file

View file

@ -0,0 +1,53 @@
#!/usr/bin/env python
# coding: utf-8 -*-
# pylint: disable=no-value-for-parameter
# pylint: disable=too-many-arguments
# pylint: disable=line-too-long
# pylint: disable=duplicate-code
# flake8: noqa E501
"""
Commands for ARDL CLI to get data.
"""
import xml.etree.ElementTree as ET
from xml.dom import minidom
import click
from loguru import logger
from rich.console import Console
import eos_downloader.eos
@click.command()
@click.pass_context
@click.option('--output', default=str('arista.xml'), help='Path to save XML file', type=click.Path(), show_default=True)
@click.option('--log-level', '--log', help='Logging level of the command', default=None, type=click.Choice(['debug', 'info', 'warning', 'error', 'critical'], case_sensitive=False))
def xml(ctx: click.Context, output: str, log_level: str) -> None:
# sourcery skip: remove-unnecessary-cast
"""Extract XML directory structure"""
console = Console()
# Get from Context
token = ctx.obj['token']
logger.remove()
if log_level is not None:
logger.add("eos-downloader.log", rotation="10 MB", level=log_level.upper())
my_download = eos_downloader.eos.EOSDownloader(
image='unset',
software='EOS',
version='unset',
token=token,
hash_method='sha512sum')
my_download.authenticate()
xml_object: ET.ElementTree = my_download._get_folder_tree() # pylint: disable=protected-access
xml_content = xml_object.getroot()
xmlstr = minidom.parseString(ET.tostring(xml_content)).toprettyxml(indent=" ", newl='')
with open(output, "w", encoding='utf-8') as f:
f.write(str(xmlstr))
console.print(f'XML file saved in: { output }')

View file

View file

@ -0,0 +1,137 @@
#!/usr/bin/env python
# coding: utf-8 -*-
# pylint: disable=no-value-for-parameter
# pylint: disable=too-many-arguments
# pylint: disable=line-too-long
# pylint: disable=redefined-builtin
# flake8: noqa E501
"""
Commands for ARDL CLI to get data.
"""
import os
import sys
from typing import Union
import click
from loguru import logger
from rich.console import Console
import eos_downloader.eos
from eos_downloader.models.version import BASE_VERSION_STR, RTYPE_FEATURE, RTYPES
EOS_IMAGE_TYPE = ['64', 'INT', '2GB-INT', 'cEOS', 'cEOS64', 'vEOS', 'vEOS-lab', 'EOS-2GB', 'default']
CVP_IMAGE_TYPE = ['ova', 'rpm', 'kvm', 'upgrade']
@click.command(no_args_is_help=True)
@click.pass_context
@click.option('--image-type', default='default', help='EOS Image type', type=click.Choice(EOS_IMAGE_TYPE), required=True)
@click.option('--version', default=None, help='EOS version', type=str, required=False)
@click.option('--latest', '-l', is_flag=True, type=click.BOOL, default=False, help='Get latest version in given branch. If --branch is not use, get the latest branch with specific release type')
@click.option('--release-type', '-rtype', type=click.Choice(RTYPES, case_sensitive=False), default=RTYPE_FEATURE, help='EOS release type to search')
@click.option('--branch', '-b', type=click.STRING, default=None, help='EOS Branch to list releases')
@click.option('--docker-name', default='arista/ceos', help='Docker image name (default: arista/ceos)', type=str, show_default=True)
@click.option('--output', default=str(os.path.relpath(os.getcwd(), start=os.curdir)), help='Path to save image', type=click.Path(),show_default=True)
# Debugging
@click.option('--log-level', '--log', help='Logging level of the command', default=None, type=click.Choice(['debug', 'info', 'warning', 'error', 'critical'], case_sensitive=False))
# Boolean triggers
@click.option('--eve-ng', is_flag=True, help='Run EVE-NG vEOS provisioning (only if CLI runs on an EVE-NG server)', default=False)
@click.option('--disable-ztp', is_flag=True, help='Disable ZTP process in vEOS image (only available with --eve-ng)', default=False)
@click.option('--import-docker', is_flag=True, help='Import docker image (only available with --image_type cEOSlab)', default=False)
def eos(
ctx: click.Context, image_type: str, output: str, log_level: str, eve_ng: bool, disable_ztp: bool,
import_docker: bool, docker_name: str, version: Union[str, None] = None, release_type: str = RTYPE_FEATURE,
latest: bool = False, branch: Union[str,None] = None
) -> int:
"""Download EOS image from Arista website"""
console = Console()
# Get from Context
token = ctx.obj['token']
if token is None or token == '':
console.print('❗ Token is unset ! Please configure ARISTA_TOKEN or use --token option', style="bold red")
sys.exit(1)
logger.remove()
if log_level is not None:
logger.add("eos-downloader.log", rotation="10 MB", level=log_level.upper())
console.print("🪐 [bold blue]eos-downloader[/bold blue] is starting...", )
console.print(f' - Image Type: {image_type}')
console.print(f' - Version: {version}')
if version is not None:
my_download = eos_downloader.eos.EOSDownloader(
image=image_type,
software='EOS',
version=version,
token=token,
hash_method='sha512sum')
my_download.authenticate()
elif latest:
my_download = eos_downloader.eos.EOSDownloader(
image=image_type,
software='EOS',
version='unset',
token=token,
hash_method='sha512sum')
my_download.authenticate()
if branch is None:
branch = str(my_download.latest_branch(rtype=release_type).branch)
latest_version = my_download.latest_eos(branch, rtype=release_type)
if str(latest_version) == BASE_VERSION_STR:
console.print(f'[red]Error[/red], cannot find any version in {branch} for {release_type} release type')
sys.exit(1)
my_download.version = str(latest_version)
if eve_ng:
my_download.provision_eve(noztp=disable_ztp, checksum=True)
else:
my_download.download_local(file_path=output, checksum=True)
if import_docker:
my_download.docker_import(
image_name=docker_name
)
console.print('✅ processing done !')
sys.exit(0)
@click.command(no_args_is_help=True)
@click.pass_context
@click.option('--format', default='upgrade', help='CVP Image type', type=click.Choice(CVP_IMAGE_TYPE), required=True)
@click.option('--version', default=None, help='CVP version', type=str, required=True)
@click.option('--output', default=str(os.path.relpath(os.getcwd(), start=os.curdir)), help='Path to save image', type=click.Path(),show_default=True)
@click.option('--log-level', '--log', help='Logging level of the command', default=None, type=click.Choice(['debug', 'info', 'warning', 'error', 'critical'], case_sensitive=False))
def cvp(ctx: click.Context, version: str, format: str, output: str, log_level: str) -> int:
"""Download CVP image from Arista website"""
console = Console()
# Get from Context
token = ctx.obj['token']
if token is None or token == '':
console.print('❗ Token is unset ! Please configure ARISTA_TOKEN or use --token option', style="bold red")
sys.exit(1)
logger.remove()
if log_level is not None:
logger.add("eos-downloader.log", rotation="10 MB", level=log_level.upper())
console.print("🪐 [bold blue]eos-downloader[/bold blue] is starting...", )
console.print(f' - Image Type: {format}')
console.print(f' - Version: {version}')
my_download = eos_downloader.eos.EOSDownloader(
image=format,
software='CloudVision',
version=version,
token=token,
hash_method='md5sum')
my_download.authenticate()
my_download.download_local(file_path=output, checksum=False)
console.print('✅ processing done !')
sys.exit(0)

View file

View file

@ -0,0 +1,87 @@
#!/usr/bin/env python
# coding: utf-8 -*-
# pylint: disable=no-value-for-parameter
# pylint: disable=too-many-arguments
# pylint: disable=line-too-long
# pylint: disable=redefined-builtin
# flake8: noqa E501
"""
Commands for ARDL CLI to list data.
"""
import sys
from typing import Union
import click
from loguru import logger
from rich.console import Console
from rich.pretty import pprint
import eos_downloader.eos
from eos_downloader.models.version import BASE_VERSION_STR, RTYPE_FEATURE, RTYPES
@click.command(no_args_is_help=True)
@click.pass_context
@click.option('--latest', '-l', is_flag=True, type=click.BOOL, default=False, help='Get latest version in given branch. If --branch is not use, get the latest branch with specific release type')
@click.option('--release-type', '-rtype', type=click.Choice(RTYPES, case_sensitive=False), default=RTYPE_FEATURE, help='EOS release type to search')
@click.option('--branch', '-b', type=click.STRING, default=None, help='EOS Branch to list releases')
@click.option('--verbose', '-v', is_flag=True, type=click.BOOL, default=False, help='Human readable output. Default is none to use output in script)')
@click.option('--log-level', '--log', help='Logging level of the command', default='warning', type=click.Choice(['debug', 'info', 'warning', 'error', 'critical'], case_sensitive=False))
def eos_versions(ctx: click.Context, log_level: str, branch: Union[str,None] = None, release_type: str = RTYPE_FEATURE, latest: bool = False, verbose: bool = False) -> None:
# pylint: disable = too-many-branches
"""
List Available EOS version on Arista.com website.
Comes with some filters to get latest release (F or M) as well as branch filtering
- To get latest M release available (without any branch): ardl info eos-versions --latest -rtype m
- To get latest F release available: ardl info eos-versions --latest -rtype F
"""
console = Console()
# Get from Context
token = ctx.obj['token']
logger.remove()
if log_level is not None:
logger.add("eos-downloader.log", rotation="10 MB", level=log_level.upper())
my_download = eos_downloader.eos.EOSDownloader(
image='unset',
software='EOS',
version='unset',
token=token,
hash_method='sha512sum')
auth = my_download.authenticate()
if verbose and auth:
console.print('✅ Authenticated on arista.com')
if release_type is not None:
release_type = release_type.upper()
if latest:
if branch is None:
branch = str(my_download.latest_branch(rtype=release_type).branch)
latest_version = my_download.latest_eos(branch, rtype=release_type)
if str(latest_version) == BASE_VERSION_STR:
console.print(f'[red]Error[/red], cannot find any version in {branch} for {release_type} release type')
sys.exit(1)
if verbose:
console.print(f'Branch {branch} has been selected with release type {release_type}')
if branch is not None:
console.print(f'Latest release for {branch}: {latest_version}')
else:
console.print(f'Latest EOS release: {latest_version}')
else:
console.print(f'{ latest_version }')
else:
versions = my_download.get_eos_versions(branch=branch, rtype=release_type)
if verbose:
console.print(f'List of available versions for {branch if branch is not None else "all branches"}')
for version in versions:
console.print(f'{str(version)}')
else:
pprint([str(version) for version in versions])

276
eos_downloader/cvp.py Normal file
View file

@ -0,0 +1,276 @@
#!/usr/bin/python
# coding: utf-8 -*-
"""
CVP Uploader content
"""
import os
from typing import List, Optional, Any
from dataclasses import dataclass
from loguru import logger
from cvprac.cvp_client import CvpClient
from cvprac.cvp_client_errors import CvpLoginError
# from eos_downloader.tools import exc_to_str
# logger = logging.getLogger(__name__)
@dataclass
class CvpAuthenticationItem:
"""
Data structure to represent Cloudvision Authentication
"""
server: str
port: int = 443
token: Optional[str] = None
timeout: int = 1200
validate_cert: bool = False
class Filer():
# pylint: disable=too-few-public-methods
"""
Filer Helper for file management
"""
def __init__(self, path: str) -> None:
self.file_exist = False
self.filename = ''
self.absolute_path = ''
self.relative_path = path
if os.path.exists(path):
self.file_exist = True
self.filename = os.path.basename(path)
self.absolute_path = os.path.realpath(path)
def __repr__(self) -> str:
return self.absolute_path if self.file_exist else ''
class CvFeatureManager():
"""
CvFeatureManager Object to interect with Cloudvision
"""
def __init__(self, authentication: CvpAuthenticationItem) -> None:
"""
__init__ Class Creator
Parameters
----------
authentication : CvpAuthenticationItem
Authentication information to use to connect to Cloudvision
"""
self._authentication = authentication
# self._cv_instance = CvpClient()
self._cv_instance = self._connect(authentication=authentication)
self._cv_images = self.__get_images()
# self._cv_bundles = self.__get_bundles()
def _connect(self, authentication: CvpAuthenticationItem) -> CvpClient:
"""
_connect Connection management
Parameters
----------
authentication : CvpAuthenticationItem
Authentication information to use to connect to Cloudvision
Returns
-------
CvpClient
cvprac session to cloudvision
"""
client = CvpClient()
if authentication.token is not None:
try:
client.connect(
nodes=[authentication.server],
username='',
password='',
api_token=authentication.token,
is_cvaas=True,
port=authentication.port,
cert=authentication.validate_cert,
request_timeout=authentication.timeout
)
except CvpLoginError as error_data:
logger.error(f'Cannot connect to Cloudvision server {authentication.server}')
logger.debug(f'Error message: {error_data}')
logger.info('connected to Cloudvision server')
logger.debug(f'Connection info: {authentication}')
return client
def __get_images(self) -> List[Any]:
"""
__get_images Collect information about images on Cloudvision
Returns
-------
dict
Fact returned by Cloudvision
"""
images = []
logger.debug(' -> Collecting images')
images = self._cv_instance.api.get_images()['data']
return images if self.__check_api_result(images) else []
# def __get_bundles(self):
# """
# __get_bundles [Not In use] Collect information about bundles on Cloudvision
# Returns
# -------
# dict
# Fact returned by Cloudvision
# """
# bundles = []
# logger.debug(' -> Collecting images bundles')
# bundles = self._cv_instance.api.get_image_bundles()['data']
# # bundles = self._cv_instance.post(url='/cvpservice/image/getImageBundles.do?queryparam=&startIndex=0&endIndex=0')['data']
# return bundles if self.__check_api_result(bundles) else None
def __check_api_result(self, arg0: Any) -> bool:
"""
__check_api_result Check API calls return content
Parameters
----------
arg0 : any
Element to test
Returns
-------
bool
True if data are correct False in other cases
"""
logger.debug(arg0)
return len(arg0) > 0
def _does_image_exist(self, image_name: str) -> bool:
"""
_does_image_exist Check if an image is referenced in Cloudvision facts
Parameters
----------
image_name : str
Name of the image to search for
Returns
-------
bool
True if present
"""
return any(image_name == image['name'] for image in self._cv_images) if isinstance(self._cv_images, list) else False
def _does_bundle_exist(self, bundle_name: str) -> bool:
# pylint: disable=unused-argument
"""
_does_bundle_exist Check if an image is referenced in Cloudvision facts
Returns
-------
bool
True if present
"""
# return any(bundle_name == bundle['name'] for bundle in self._cv_bundles)
return False
def upload_image(self, image_path: str) -> bool:
"""
upload_image Upload an image to Cloudvision server
Parameters
----------
image_path : str
Path to the local file to upload
Returns
-------
bool
True if succeeds
"""
image_item = Filer(path=image_path)
if image_item.file_exist is False:
logger.error(f'File not found: {image_item.relative_path}')
return False
logger.info(f'File path for image: {image_item}')
if self._does_image_exist(image_name=image_item.filename):
logger.error("Image found in Cloudvision , Please delete it before running this script")
return False
try:
upload_result = self._cv_instance.api.add_image(filepath=image_item.absolute_path)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error('An error occurred during upload, check CV connection')
logger.error(f'Exception message is: {e}')
return False
logger.debug(f'Upload Result is : {upload_result}')
return True
def build_image_list(self, image_list: List[str]) -> List[Any]:
"""
Builds a list of the image data structures, for a given list of image names.
Parameters
----------
image_list : list
List of software image names
Returns
-------
List:
Returns a list of images, with complete data or None in the event of failure
"""
internal_image_list = []
image_data = None
success = True
for entry in image_list:
for image in self._cv_images:
if image["imageFileName"] == entry:
image_data = image
if image_data is not None:
internal_image_list.append(image_data)
image_data = None
else:
success = False
return internal_image_list if success else []
def create_bundle(self, name: str, images_name: List[str]) -> bool:
"""
create_bundle Create a bundle with a list of images.
Parameters
----------
name : str
Name of the bundle
images_name : List[str]
List of images available on Cloudvision
Returns
-------
bool
True if succeeds
"""
logger.debug(f'Init creation of an image bundle {name} with following images {images_name}')
all_images_present: List[bool] = []
self._cv_images = self.__get_images()
all_images_present.extend(
self._does_image_exist(image_name=image_name)
for image_name in images_name
)
# Bundle Create
if self._does_bundle_exist(bundle_name=name) is False:
logger.debug(f'Creating image bundle {name} with following images {images_name}')
images_data = self.build_image_list(image_list=images_name)
if images_data is not None:
logger.debug('Images information: {images_data}')
try:
data = self._cv_instance.api.save_image_bundle(name=name, images=images_data)
except Exception as e: # pylint: disable=broad-exception-caught
logger.critical(f'{e}')
else:
logger.debug(data)
return True
logger.critical('No data found for images')
return False

93
eos_downloader/data.py Normal file
View file

@ -0,0 +1,93 @@
#!/usr/bin/python
# coding: utf-8 -*-
"""
EOS Downloader Information to use in
eos_downloader.object_downloader.ObjectDownloader._build_filename.
Data are built from content of Arista XML file
"""
# [platform][image][version]
DATA_MAPPING = {
"CloudVision": {
"ova": {
"extension": ".ova",
"prepend": "cvp",
"folder_level": 0
},
"rpm": {
"extension": "",
"prepend": "cvp-rpm-installer",
"folder_level": 0
},
"kvm": {
"extension": "-kvm.tgz",
"prepend": "cvp",
"folder_level": 0
},
"upgrade": {
"extension": ".tgz",
"prepend": "cvp-upgrade",
"folder_level": 0
},
},
"EOS": {
"64": {
"extension": ".swi",
"prepend": "EOS64",
"folder_level": 0
},
"INT": {
"extension": "-INT.swi",
"prepend": "EOS",
"folder_level": 1
},
"2GB-INT": {
"extension": "-INT.swi",
"prepend": "EOS-2GB",
"folder_level": 1
},
"cEOS": {
"extension": ".tar.xz",
"prepend": "cEOS-lab",
"folder_level": 0
},
"cEOS64": {
"extension": ".tar.xz",
"prepend": "cEOS64-lab",
"folder_level": 0
},
"vEOS": {
"extension": ".vmdk",
"prepend": "vEOS",
"folder_level": 0
},
"vEOS-lab": {
"extension": ".vmdk",
"prepend": "vEOS-lab",
"folder_level": 0
},
"EOS-2GB": {
"extension": ".swi",
"prepend": "EOS-2GB",
"folder_level": 0
},
"RN": {
"extension": "-",
"prepend": "RN",
"folder_level": 0
},
"SOURCE": {
"extension": "-source.tar",
"prepend": "EOS",
"folder_level": 0
},
"default": {
"extension": ".swi",
"prepend": "EOS",
"folder_level": 0
}
}
}

View file

@ -0,0 +1,77 @@
# flake8: noqa: F811
# pylint: disable=unused-argument
# pylint: disable=too-few-public-methods
"""download module"""
import os.path
import signal
from concurrent.futures import ThreadPoolExecutor
from threading import Event
from typing import Iterable, Any
import requests
import rich
from rich import console
from rich.progress import (BarColumn, DownloadColumn, Progress, TaskID,
TextColumn, TimeElapsedColumn, TransferSpeedColumn)
console = rich.get_console()
done_event = Event()
def handle_sigint(signum: Any, frame: Any) -> None:
"""Progress bar handler"""
done_event.set()
signal.signal(signal.SIGINT, handle_sigint)
class DownloadProgressBar():
"""
Object to manage Download process with Progress Bar from Rich
"""
def __init__(self) -> None:
"""
Class Constructor
"""
self.progress = Progress(
TextColumn("💾 Downloading [bold blue]{task.fields[filename]}", justify="right"),
BarColumn(bar_width=None),
"[progress.percentage]{task.percentage:>3.1f}%",
"",
TransferSpeedColumn(),
"",
DownloadColumn(),
"",
TimeElapsedColumn(),
"",
console=console
)
def _copy_url(self, task_id: TaskID, url: str, path: str, block_size: int = 1024) -> bool:
"""Copy data from a url to a local file."""
response = requests.get(url, stream=True, timeout=5)
# This will break if the response doesn't contain content length
self.progress.update(task_id, total=int(response.headers['Content-Length']))
with open(path, "wb") as dest_file:
self.progress.start_task(task_id)
for data in response.iter_content(chunk_size=block_size):
dest_file.write(data)
self.progress.update(task_id, advance=len(data))
if done_event.is_set():
return True
# console.print(f"Downloaded {path}")
return False
def download(self, urls: Iterable[str], dest_dir: str) -> None:
"""Download multuple files to the given directory."""
with self.progress:
with ThreadPoolExecutor(max_workers=4) as pool:
for url in urls:
filename = url.split("/")[-1].split('?')[0]
dest_path = os.path.join(dest_dir, filename)
task_id = self.progress.add_task("download", filename=filename, start=False)
pool.submit(self._copy_url, task_id, url, dest_path)

177
eos_downloader/eos.py Normal file
View file

@ -0,0 +1,177 @@
#!/usr/bin/python
# coding: utf-8 -*-
# flake8: noqa: F811
"""
Specific EOS inheritance from object_download
"""
import os
import xml.etree.ElementTree as ET
from typing import List, Union
import rich
from loguru import logger
from rich import console
from eos_downloader.models.version import BASE_BRANCH_STR, BASE_VERSION_STR, REGEX_EOS_VERSION, RTYPE_FEATURE, EosVersion
from eos_downloader.object_downloader import ObjectDownloader
# logger = logging.getLogger(__name__)
console = rich.get_console()
class EOSDownloader(ObjectDownloader):
"""
EOSDownloader Object to download EOS images from Arista.com website
Supercharge ObjectDownloader to support EOS specific actions
Parameters
----------
ObjectDownloader : ObjectDownloader
Base object
"""
eos_versions: Union[List[EosVersion], None] = None
@staticmethod
def _disable_ztp(file_path: str) -> None:
"""
_disable_ztp Method to disable ZTP in EOS image
Create a file in the EOS image to disable ZTP process during initial boot
Parameters
----------
file_path : str
Path where EOS image is located
"""
logger.info('Mounting volume to disable ZTP')
console.print('🚀 Mounting volume to disable ZTP')
raw_folder = os.path.join(file_path, "raw")
os.system(f"rm -rf {raw_folder}")
os.system(f"mkdir -p {raw_folder}")
os.system(
f'guestmount -a {os.path.join(file_path, "hda.qcow2")} -m /dev/sda2 {os.path.join(file_path, "raw")}')
ztp_file = os.path.join(file_path, 'raw/zerotouch-config')
with open(ztp_file, 'w', encoding='ascii') as zfile:
zfile.write('DISABLE=True')
logger.info(f'Unmounting volume in {file_path}')
os.system(f"guestunmount {os.path.join(file_path, 'raw')}")
os.system(f"rm -rf {os.path.join(file_path, 'raw')}")
logger.info(f"Volume has been successfully unmounted at {file_path}")
def _parse_xml_for_version(self,root_xml: ET.ElementTree, xpath: str = './/dir[@label="Active Releases"]/dir/dir/[@label]') -> List[EosVersion]:
"""
Extract list of available EOS versions from Arista.com website
Create a list of EosVersion object for all versions available on Arista.com
Args:
root_xml (ET.ElementTree): XML file with all versions available
xpath (str, optional): XPATH to use to extract EOS version. Defaults to './/dir[@label="Active Releases"]/dir/dir/[@label]'.
Returns:
List[EosVersion]: List of EosVersion representing all available EOS versions
"""
# XPATH: .//dir[@label="Active Releases"]/dir/dir/[@label]
if self.eos_versions is None:
logger.debug(f'Using xpath {xpath}')
eos_versions = []
for node in root_xml.findall(xpath):
if 'label' in node.attrib and node.get('label') is not None:
label = node.get('label')
if label is not None and REGEX_EOS_VERSION.match(label):
eos_version = EosVersion.from_str(label)
eos_versions.append(eos_version)
logger.debug(f"Found {label} - {eos_version}")
logger.debug(f'List of versions found on arista.com is: {eos_versions}')
self.eos_versions = eos_versions
else:
logger.debug('receiving instruction to download versions, but already available')
return self.eos_versions
def _get_branches(self, with_rtype: str = RTYPE_FEATURE) -> List[str]:
"""
Extract all EOS branches available from arista.com
Call self._parse_xml_for_version and then build list of available branches
Args:
rtype (str, optional): Release type to find. Can be M or F, default to F
Returns:
List[str]: A lsit of string that represent all availables EOS branches
"""
root = self._get_folder_tree()
versions = self._parse_xml_for_version(root_xml=root)
return list({version.branch for version in versions if version.rtype == with_rtype})
def latest_branch(self, rtype: str = RTYPE_FEATURE) -> EosVersion:
"""
Get latest branch from semver standpoint
Args:
rtype (str, optional): Release type to find. Can be M or F, default to F
Returns:
EosVersion: Latest Branch object
"""
selected_branch = EosVersion.from_str(BASE_BRANCH_STR)
for branch in self._get_branches(with_rtype=rtype):
branch = EosVersion.from_str(branch)
if branch > selected_branch:
selected_branch = branch
return selected_branch
def get_eos_versions(self, branch: Union[str,None] = None, rtype: Union[str,None] = None) -> List[EosVersion]:
"""
Get a list of available EOS version available on arista.com
If a branch is provided, only version in this branch are listed.
Otherwise, all versions are provided.
Args:
branch (str, optional): An EOS branch to filter. Defaults to None.
rtype (str, optional): Release type to find. Can be M or F, default to F
Returns:
List[EosVersion]: A list of versions available
"""
root = self._get_folder_tree()
result = []
for version in self._parse_xml_for_version(root_xml=root):
if branch is None and (version.rtype == rtype or rtype is None):
result.append(version)
elif branch is not None and version.is_in_branch(branch) and version.rtype == rtype:
result.append(version)
return result
def latest_eos(self, branch: Union[str,None] = None, rtype: str = RTYPE_FEATURE) -> EosVersion:
"""
Get latest version of EOS
If a branch is provided, only version in this branch are listed.
Otherwise, all versions are provided.
You can select what type of version to consider: M or F
Args:
branch (str, optional): An EOS branch to filter. Defaults to None.
rtype (str, optional): An EOS version type to filter, Can be M or F. Defaults to None.
Returns:
EosVersion: latest version selected
"""
selected_version = EosVersion.from_str(BASE_VERSION_STR)
if branch is None:
latest_branch = self.latest_branch(rtype=rtype)
else:
latest_branch = EosVersion.from_str(branch)
for version in self.get_eos_versions(branch=str(latest_branch.branch), rtype=rtype):
if version > selected_version:
if rtype is not None and version.rtype == rtype:
selected_version = version
if rtype is None:
selected_version = version
return selected_version

View file

View file

@ -0,0 +1,272 @@
#!/usr/bin/python
# coding: utf-8 -*-
"""Module for EOS version management"""
from __future__ import annotations
import re
import typing
from typing import Any, Optional
from loguru import logger
from pydantic import BaseModel
from eos_downloader.tools import exc_to_str
# logger = logging.getLogger(__name__)
BASE_VERSION_STR = '4.0.0F'
BASE_BRANCH_STR = '4.0'
RTYPE_FEATURE = 'F'
RTYPE_MAINTENANCE = 'M'
RTYPES = [RTYPE_FEATURE, RTYPE_MAINTENANCE]
# Regular Expression to capture multiple EOS version format
# 4.24
# 4.23.0
# 4.21.1M
# 4.28.10.F
# 4.28.6.1M
REGEX_EOS_VERSION = re.compile(r"^.*(?P<major>4)\.(?P<minor>\d{1,2})\.(?P<patch>\d{1,2})(?P<other>\.\d*)*(?P<rtype>[M,F])*$")
REGEX_EOS_BRANCH = re.compile(r"^.*(?P<major>4)\.(?P<minor>\d{1,2})(\.?P<patch>\d)*(\.\d)*(?P<rtype>[M,F])*$")
class EosVersion(BaseModel):
"""
EosVersion object to play with version management in code
Since EOS is not using strictly semver approach, this class mimic some functions from semver lib for Arista EOS versions
It is based on Pydantic and provides helpers for comparison:
Examples:
>>> eos_version_str = '4.23.2F'
>>> eos_version = EosVersion.from_str(eos_version_str)
>>> print(f'str representation is: {str(eos_version)}')
str representation is: 4.23.2F
>>> other_version = EosVersion.from_str(other_version_str)
>>> print(f'eos_version < other_version: {eos_version < other_version}')
eos_version < other_version: True
>>> print(f'Is eos_version match("<=4.23.3M"): {eos_version.match("<=4.23.3M")}')
Is eos_version match("<=4.23.3M"): True
>>> print(f'Is eos_version in branch 4.23: {eos_version.is_in_branch("4.23.0")}')
Is eos_version in branch 4.23: True
Args:
BaseModel (Pydantic): Pydantic Base Model
"""
major: int = 4
minor: int = 0
patch: int = 0
rtype: Optional[str] = 'F'
other: Any
@classmethod
def from_str(cls, eos_version: str) -> EosVersion:
"""
Class constructor from a string representing EOS version
Use regular expresion to extract fields from string.
It supports following formats:
- 4.24
- 4.23.0
- 4.21.1M
- 4.28.10.F
- 4.28.6.1M
Args:
eos_version (str): EOS version in str format
Returns:
EosVersion object
"""
logger.debug(f'receiving version: {eos_version}')
if REGEX_EOS_VERSION.match(eos_version):
matches = REGEX_EOS_VERSION.match(eos_version)
# assert matches is not None
assert matches is not None
return cls(**matches.groupdict())
if REGEX_EOS_BRANCH.match(eos_version):
matches = REGEX_EOS_BRANCH.match(eos_version)
# assert matches is not None
assert matches is not None
return cls(**matches.groupdict())
logger.error(f'Error occured with {eos_version}')
return EosVersion()
@property
def branch(self) -> str:
"""
Extract branch of version
Returns:
str: branch from version
"""
return f'{self.major}.{self.minor}'
def __str__(self) -> str:
"""
Standard str representation
Return string for EOS version like 4.23.3M
Returns:
str: A standard EOS version string representing <MAJOR>.<MINOR>.<PATCH><RTYPE>
"""
if self.other is None:
return f'{self.major}.{self.minor}.{self.patch}{self.rtype}'
return f'{self.major}.{self.minor}.{self.patch}{self.other}{self.rtype}'
def _compare(self, other: EosVersion) -> float:
"""
An internal comparison function to compare 2 EosVersion objects
Do a deep comparison from Major to Release Type
The return value is
- negative if ver1 < ver2,
- zero if ver1 == ver2
- strictly positive if ver1 > ver2
Args:
other (EosVersion): An EosVersion to compare with this object
Raises:
ValueError: Raise ValueError if input is incorrect type
Returns:
float: -1 if ver1 < ver2, 0 if ver1 == ver2, 1 if ver1 > ver2
"""
if not isinstance(other, EosVersion):
raise ValueError(f'could not compare {other} as it is not an EosVersion object')
comparison_flag: float = 0
logger.warning(f'current version {self.__str__()} - other {str(other)}') # pylint: disable = unnecessary-dunder-call
for key, _ in self.dict().items():
if comparison_flag == 0 and self.dict()[key] is None or other.dict()[key] is None:
logger.debug(f'{key}: local None - remote None')
logger.debug(f'{key}: local {self.dict()} - remote {other.dict()}')
return comparison_flag
logger.debug(f'{key}: local {self.dict()[key]} - remote {other.dict()[key]}')
if comparison_flag == 0 and self.dict()[key] < other.dict()[key]:
comparison_flag = -1
if comparison_flag == 0 and self.dict()[key] > other.dict()[key]:
comparison_flag = 1
if comparison_flag != 0:
logger.info(f'comparison result is {comparison_flag}')
return comparison_flag
logger.info(f'comparison result is {comparison_flag}')
return comparison_flag
@typing.no_type_check
def __eq__(self, other):
""" Implement __eq__ function (==) """
return self._compare(other) == 0
@typing.no_type_check
def __ne__(self, other):
# type: ignore
""" Implement __nw__ function (!=) """
return self._compare(other) != 0
@typing.no_type_check
def __lt__(self, other):
# type: ignore
""" Implement __lt__ function (<) """
return self._compare(other) < 0
@typing.no_type_check
def __le__(self, other):
# type: ignore
""" Implement __le__ function (<=) """
return self._compare(other) <= 0
@typing.no_type_check
def __gt__(self, other):
# type: ignore
""" Implement __gt__ function (>) """
return self._compare(other) > 0
@typing.no_type_check
def __ge__(self, other):
# type: ignore
""" Implement __ge__ function (>=) """
return self._compare(other) >= 0
def match(self, match_expr: str) -> bool:
"""
Compare self to match a match expression.
Example:
>>> eos_version.match("<=4.23.3M")
True
>>> eos_version.match("==4.23.3M")
False
Args:
match_expr (str): optional operator and version; valid operators are
``<`` smaller than
``>`` greater than
``>=`` greator or equal than
``<=`` smaller or equal than
``==`` equal
``!=`` not equal
Raises:
ValueError: If input has no match_expr nor match_ver
Returns:
bool: True if the expression matches the version, otherwise False
"""
prefix = match_expr[:2]
if prefix in (">=", "<=", "==", "!="):
match_version = match_expr[2:]
elif prefix and prefix[0] in (">", "<"):
prefix = prefix[0]
match_version = match_expr[1:]
elif match_expr and match_expr[0] in "0123456789":
prefix = "=="
match_version = match_expr
else:
raise ValueError(
"match_expr parameter should be in format <op><ver>, "
"where <op> is one of "
"['<', '>', '==', '<=', '>=', '!=']. "
f"You provided: {match_expr}"
)
logger.debug(f'work on comparison {prefix} with base release {match_version}')
possibilities_dict = {
">": (1,),
"<": (-1,),
"==": (0,),
"!=": (-1, 1),
">=": (0, 1),
"<=": (-1, 0),
}
possibilities = possibilities_dict[prefix]
cmp_res = self._compare(EosVersion.from_str(match_version))
return cmp_res in possibilities
def is_in_branch(self, branch_str: str) -> bool:
"""
Check if current version is part of a branch version
Comparison is done across MAJOR and MINOR
Args:
branch_str (str): a string for EOS branch. It supports following formats 4.23 or 4.23.0
Returns:
bool: True if current version is in provided branch, otherwise False
"""
try:
logger.debug(f'reading branch str:{branch_str}')
branch = EosVersion.from_str(branch_str)
except Exception as error: # pylint: disable = broad-exception-caught
logger.error(exc_to_str(error))
else:
return self.major == branch.major and self.minor == branch.minor
return False

View file

@ -0,0 +1,513 @@
#!/usr/bin/python
# coding: utf-8 -*-
# flake8: noqa: F811
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-arguments
"""
eos_downloader class definition
"""
from __future__ import (absolute_import, division, print_function,
unicode_literals, annotations)
import base64
import glob
import hashlib
import json
import os
import sys
import xml.etree.ElementTree as ET
from typing import Union
import requests
import rich
from loguru import logger
from rich import console
from tqdm import tqdm
from eos_downloader import (ARISTA_DOWNLOAD_URL, ARISTA_GET_SESSION,
ARISTA_SOFTWARE_FOLDER_TREE, EVE_QEMU_FOLDER_PATH,
MSG_INVALID_DATA, MSG_TOKEN_EXPIRED)
from eos_downloader.data import DATA_MAPPING
from eos_downloader.download import DownloadProgressBar
# logger = logging.getLogger(__name__)
console = rich.get_console()
class ObjectDownloader():
"""
ObjectDownloader Generic Object to download from Arista.com
"""
def __init__(self, image: str, version: str, token: str, software: str = 'EOS', hash_method: str = 'md5sum'):
"""
__init__ Class constructor
generic class constructor
Parameters
----------
image : str
Type of image to download
version : str
Version of the package to download
token : str
Arista API token
software : str, optional
Package name to download (vEOS-lab, cEOS, EOS, ...), by default 'EOS'
hash_method : str, optional
Hash protocol to use to check download, by default 'md5sum'
"""
self.software = software
self.image = image
self._version = version
self.token = token
self.folder_level = 0
self.session_id = None
self.filename = self._build_filename()
self.hash_method = hash_method
self.timeout = 5
# Logging
logger.debug(f'Filename built by _build_filename is {self.filename}')
def __str__(self) -> str:
return f'{self.software} - {self.image} - {self.version}'
# def __repr__(self):
# return str(self.__dict__)
@property
def version(self) -> str:
"""Get version."""
return self._version
@version.setter
def version(self, value: str) -> None:
"""Set version."""
self._version = value
self.filename = self._build_filename()
# ------------------------------------------------------------------------ #
# Internal METHODS
# ------------------------------------------------------------------------ #
def _build_filename(self) -> str:
"""
_build_filename Helper to build filename to search on arista.com
Returns
-------
str:
Filename to search for on Arista.com
"""
logger.info('start build')
if self.software in DATA_MAPPING:
logger.info(f'software in data mapping: {self.software}')
if self.image in DATA_MAPPING[self.software]:
logger.info(f'image in data mapping: {self.image}')
return f"{DATA_MAPPING[self.software][self.image]['prepend']}-{self.version}{DATA_MAPPING[self.software][self.image]['extension']}"
return f"{DATA_MAPPING[self.software]['default']['prepend']}-{self.version}{DATA_MAPPING[self.software]['default']['extension']}"
raise ValueError(f'Incorrect value for software {self.software}')
def _parse_xml_for_path(self, root_xml: ET.ElementTree, xpath: str, search_file: str) -> str:
# sourcery skip: remove-unnecessary-cast
"""
_parse_xml Read and extract data from XML using XPATH
Get all interested nodes using XPATH and then get node that match search_file
Parameters
----------
root_xml : ET.ElementTree
XML document
xpath : str
XPATH expression to filter XML
search_file : str
Filename to search for
Returns
-------
str
File Path on Arista server side
"""
logger.debug(f'Using xpath {xpath}')
logger.debug(f'Search for file {search_file}')
console.print(f'🔎 Searching file {search_file}')
for node in root_xml.findall(xpath):
# logger.debug('Found {}', node.text)
if str(node.text).lower() == search_file.lower():
path = node.get('path')
console.print(f' -> Found file at {path}')
logger.info(f'Found {node.text} at {node.get("path")}')
return str(node.get('path')) if node.get('path') is not None else ''
logger.error(f'Requested file ({self.filename}) not found !')
return ''
def _get_hash(self, file_path: str) -> str:
"""
_get_hash Download HASH file from Arista server
Parameters
----------
file_path : str
Path of the HASH file
Returns
-------
str
Hash string read from HASH file downloaded from Arista.com
"""
remote_hash_file = self._get_remote_hashpath(hash_method=self.hash_method)
hash_url = self._get_url(remote_file_path=remote_hash_file)
# hash_downloaded = self._download_file_raw(url=hash_url, file_path=file_path + "/" + os.path.basename(remote_hash_file))
dl_rich_progress_bar = DownloadProgressBar()
dl_rich_progress_bar.download(urls=[hash_url], dest_dir=file_path)
hash_downloaded = f"{file_path}/{os.path.basename(remote_hash_file)}"
hash_content = 'unset'
with open(hash_downloaded, 'r', encoding='utf-8') as f:
hash_content = f.read()
return hash_content.split(' ')[0]
@staticmethod
def _compute_hash_md5sum(file: str, hash_expected: str) -> bool:
"""
_compute_hash_md5sum Compare MD5 sum
Do comparison between local md5 of the file and value provided by arista.com
Parameters
----------
file : str
Local file to use for MD5 sum
hash_expected : str
MD5 from arista.com
Returns
-------
bool
True if both are equal, False if not
"""
hash_md5 = hashlib.md5()
with open(file, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
if hash_md5.hexdigest() == hash_expected:
return True
logger.warning(f'Downloaded file is corrupt: local md5 ({hash_md5.hexdigest()}) is different to md5 from arista ({hash_expected})')
return False
@staticmethod
def _compute_hash_sh512sum(file: str, hash_expected: str) -> bool:
"""
_compute_hash_sh512sum Compare SHA512 sum
Do comparison between local sha512 of the file and value provided by arista.com
Parameters
----------
file : str
Local file to use for MD5 sum
hash_expected : str
SHA512 from arista.com
Returns
-------
bool
True if both are equal, False if not
"""
hash_sha512 = hashlib.sha512()
with open(file, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_sha512.update(chunk)
if hash_sha512.hexdigest() == hash_expected:
return True
logger.warning(f'Downloaded file is corrupt: local sha512 ({hash_sha512.hexdigest()}) is different to sha512 from arista ({hash_expected})')
return False
def _get_folder_tree(self) -> ET.ElementTree:
"""
_get_folder_tree Download XML tree from Arista server
Returns
-------
ET.ElementTree
XML document
"""
if self.session_id is None:
self.authenticate()
jsonpost = {'sessionCode': self.session_id}
result = requests.post(ARISTA_SOFTWARE_FOLDER_TREE, data=json.dumps(jsonpost), timeout=self.timeout)
try:
folder_tree = result.json()["data"]["xml"]
return ET.ElementTree(ET.fromstring(folder_tree))
except KeyError as error:
logger.error(MSG_INVALID_DATA)
logger.error(f'Server returned: {error}')
console.print(f'{MSG_INVALID_DATA}', style="bold red")
sys.exit(1)
def _get_remote_filepath(self) -> str:
"""
_get_remote_filepath Helper to get path of the file to download
Set XPATH and return result of _parse_xml for the file to download
Returns
-------
str
Remote path of the file to download
"""
root = self._get_folder_tree()
logger.debug("GET XML content from ARISTA.com")
xpath = f'.//dir[@label="{self.software}"]//file'
return self._parse_xml_for_path(root_xml=root, xpath=xpath, search_file=self.filename)
def _get_remote_hashpath(self, hash_method: str = 'md5sum') -> str:
"""
_get_remote_hashpath Helper to get path of the hash's file to download
Set XPATH and return result of _parse_xml for the file to download
Returns
-------
str
Remote path of the hash's file to download
"""
root = self._get_folder_tree()
logger.debug("GET XML content from ARISTA.com")
xpath = f'.//dir[@label="{self.software}"]//file'
return self._parse_xml_for_path(
root_xml=root,
xpath=xpath,
search_file=f'{self.filename}.{hash_method}',
)
def _get_url(self, remote_file_path: str) -> str:
"""
_get_url Get URL to use for downloading file from Arista server
Send remote_file_path to get correct URL to use for download
Parameters
----------
remote_file_path : str
Filepath from XML to use to get correct download link
Returns
-------
str
URL link to use for download
"""
if self.session_id is None:
self.authenticate()
jsonpost = {'sessionCode': self.session_id, 'filePath': remote_file_path}
result = requests.post(ARISTA_DOWNLOAD_URL, data=json.dumps(jsonpost), timeout=self.timeout)
if 'data' in result.json() and 'url' in result.json()['data']:
# logger.debug('URL to download file is: {}', result.json())
return result.json()["data"]["url"]
logger.critical(f'Server returns following message: {result.json()}')
return ''
@staticmethod
def _download_file_raw(url: str, file_path: str) -> str:
"""
_download_file Helper to download file from Arista.com
[extended_summary]
Parameters
----------
url : str
URL provided by server for remote_file_path
file_path : str
Location where to save local file
Returns
-------
str
File path
"""
chunkSize = 1024
r = requests.get(url, stream=True, timeout=5)
with open(file_path, 'wb') as f:
pbar = tqdm(unit="B", total=int(r.headers['Content-Length']), unit_scale=True, unit_divisor=1024)
for chunk in r.iter_content(chunk_size=chunkSize):
if chunk:
pbar.update(len(chunk))
f.write(chunk)
return file_path
def _download_file(self, file_path: str, filename: str, rich_interface: bool = True) -> Union[None, str]:
remote_file_path = self._get_remote_filepath()
logger.info(f'File found on arista server: {remote_file_path}')
file_url = self._get_url(remote_file_path=remote_file_path)
if file_url is not False:
if not rich_interface:
return self._download_file_raw(url=file_url, file_path=os.path.join(file_path, filename))
rich_downloader = DownloadProgressBar()
rich_downloader.download(urls=[file_url], dest_dir=file_path)
return os.path.join(file_path, filename)
logger.error(f'Cannot download file {file_path}')
return None
@staticmethod
def _create_destination_folder(path: str) -> None:
# os.makedirs(path, mode, exist_ok=True)
os.system(f'mkdir -p {path}')
@staticmethod
def _disable_ztp(file_path: str) -> None:
pass
# ------------------------------------------------------------------------ #
# Public METHODS
# ------------------------------------------------------------------------ #
def authenticate(self) -> bool:
"""
authenticate Authenticate user on Arista.com server
Send API token and get a session-id from remote server.
Session-id will be used by all other functions.
Returns
-------
bool
True if authentication succeeds=, False in all other situations.
"""
credentials = (base64.b64encode(self.token.encode())).decode("utf-8")
session_code_url = ARISTA_GET_SESSION
jsonpost = {'accessToken': credentials}
result = requests.post(session_code_url, data=json.dumps(jsonpost), timeout=self.timeout)
if result.json()["status"]["message"] in[ 'Access token expired', 'Invalid access token']:
console.print(f'{MSG_TOKEN_EXPIRED}', style="bold red")
logger.error(MSG_TOKEN_EXPIRED)
return False
try:
if 'data' in result.json():
self.session_id = result.json()["data"]["session_code"]
logger.info('Authenticated on arista.com')
return True
logger.debug(f'{result.json()}')
return False
except KeyError as error_arista:
logger.error(f'Error: {error_arista}')
sys.exit(1)
def download_local(self, file_path: str, checksum: bool = False) -> bool:
# sourcery skip: move-assign
"""
download_local Entrypoint for local download feature
Do local downnload feature:
- Get remote file path
- Get URL from Arista.com
- Download file
- Do HASH comparison (optional)
Parameters
----------
file_path : str
Local path to save downloaded file
checksum : bool, optional
Execute checksum or not, by default False
Returns
-------
bool
True if everything went well, False if any problem appears
"""
file_downloaded = str(self._download_file(file_path=file_path, filename=self.filename))
# Check file HASH
hash_result = False
if checksum:
logger.info('🚀 Running checksum validation')
console.print('🚀 Running checksum validation')
if self.hash_method == 'md5sum':
hash_expected = self._get_hash(file_path=file_path)
hash_result = self._compute_hash_md5sum(file=file_downloaded, hash_expected=hash_expected)
elif self.hash_method == 'sha512sum':
hash_expected = self._get_hash(file_path=file_path)
hash_result = self._compute_hash_sh512sum(file=file_downloaded, hash_expected=hash_expected)
if not hash_result:
logger.error('Downloaded file is corrupted, please check your connection')
console.print('❌ Downloaded file is corrupted, please check your connection')
return False
logger.info('Downloaded file is correct.')
console.print('✅ Downloaded file is correct.')
return True
def provision_eve(self, noztp: bool = False, checksum: bool = True) -> None:
# pylint: disable=unused-argument
"""
provision_eve Entrypoint for EVE-NG download and provisioning
Do following actions:
- Get remote file path
- Get URL from file path
- Download file
- Convert file to qcow2 format
- Create new version to EVE-NG
- Disable ZTP (optional)
Parameters
----------
noztp : bool, optional
Flag to deactivate ZTP in EOS image, by default False
checksum : bool, optional
Flag to ask for hash validation, by default True
"""
# Build image name to use in folder path
eos_image_name = self.filename.rstrip(".vmdk").lower()
if noztp:
eos_image_name = f'{eos_image_name}-noztp'
# Create full path for EVE-NG
file_path = os.path.join(EVE_QEMU_FOLDER_PATH, eos_image_name.rstrip())
# Create folders in filesystem
self._create_destination_folder(path=file_path)
# Download file to local destination
file_downloaded = self._download_file(
file_path=file_path, filename=self.filename)
# Convert to QCOW2 format
file_qcow2 = os.path.join(file_path, "hda.qcow2")
logger.info('Converting VMDK to QCOW2 format')
console.print('🚀 Converting VMDK to QCOW2 format...')
os.system(f'$(which qemu-img) convert -f vmdk -O qcow2 {file_downloaded} {file_qcow2}')
logger.info('Applying unl_wrapper to fix permissions')
console.print('Applying unl_wrapper to fix permissions')
os.system('/opt/unetlab/wrappers/unl_wrapper -a fixpermissions')
os.system(f'rm -f {file_downloaded}')
if noztp:
self._disable_ztp(file_path=file_path)
def docker_import(self, image_name: str = "arista/ceos") -> None:
"""
Import docker container to your docker server.
Import downloaded container to your local docker engine.
Args:
version (str):
image_name (str, optional): Image name to use. Defaults to "arista/ceos".
"""
docker_image = f'{image_name}:{self.version}'
logger.info(f'Importing image {self.filename} to {docker_image}')
console.print(f'🚀 Importing image {self.filename} to {docker_image}')
os.system(f'$(which docker) import {self.filename} {docker_image}')
for filename in glob.glob(f'{self.filename}*'):
try:
os.remove(filename)
except FileNotFoundError:
console.print(f'File not found: {filename}')

13
eos_downloader/tools.py Normal file
View file

@ -0,0 +1,13 @@
#!/usr/bin/python
# coding: utf-8 -*-
"""Module for tools related to ardl"""
def exc_to_str(exception: Exception) -> str:
"""
Helper function to parse Exceptions
"""
return (
f"{type(exception).__name__}{f' ({str(exception)})' if str(exception) else ''}"
)

25
pylintrc Normal file
View file

@ -0,0 +1,25 @@
[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
[FORMAT]
max-line-length=165
max-module-lines=1700
[SIMILARITIES]
# making similarity lines limit a bit higher than default 4
min-similarity-lines=10
[MAIN]
load-plugins=pylint_pydantic
extension-pkg-whitelist=pydantic

189
pyproject.toml Normal file
View file

@ -0,0 +1,189 @@
# content of pyproject.toml
[build-system]
requires = ["setuptools>=64.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "eos_downloader"
version = "v0.8.1-dev1"
readme = "README.md"
authors = [{ name = "Thomas Grimonet", email = "thomas.grimonet@gmail.com" }]
maintainers = [
{ name = "Thomas Grimonet", email = "thomas.grimonet@gmail.com" },
]
description = "Arista EOS/CVP downloader script"
license = { file = "LICENSE" }
dependencies = [
"cryptography",
"paramiko",
"requests>=2.20.0",
"requests-toolbelt",
"scp",
"tqdm",
"loguru",
"rich==12.0.1",
"cvprac>=1.0.7",
"click==8.1.3",
"click-help-colors==0.9.1",
"pydantic==1.10.4",
]
keywords = ["eos_downloader", "Arista", "eos", "cvp", "network", "automation", "networking", "devops", "netdevops"]
classifiers = [
'Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'Intended Audience :: Information Technology',
'Topic :: System :: Software Distribution',
'Topic :: Terminals',
'Topic :: Utilities',
'License :: OSI Approved :: Apache Software License',
'Operating System :: OS Independent',
'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 :: Only',
'Programming Language :: Python :: Implementation :: PyPy',
]
requires-python = ">=3.8"
[project.optional-dependencies]
dev = [
"isort==5.12.0",
"mypy==0.991",
"mypy-extensions>=0.4.3",
"pre-commit>=2.20.0",
"pylint",
"pytest>=7.1.2",
"pytest-cov>=2.11.1",
"pytest-dependency",
"pytest-html>=3.1.1",
"pytest-metadata>=1.11.0",
"pylint-pydantic>=0.1.4",
"tox==4.0.11",
"types-PyYAML",
"types-paramiko",
"types-requests",
"typing-extensions",
"yamllint",
"flake8==4.0.1",
"pyflakes==2.4.0"
]
[project.urls]
Homepage = "https://www.github.com/titom73/eos-downloader"
"Bug Tracker" = "https://www.github.com/titom73/eos-downloader/issues"
Contributing = "https://www.github.com/titom73/eos-downloader"
[project.scripts]
ardl = "eos_downloader.cli.cli:cli"
lard = "eos_downloader.cli.cli:cli"
[tool.setuptools.packages.find]
include = ["eos_downloader*"]
namespaces = false
# mypy as per https://pydantic-docs.helpmanual.io/mypy_plugin/#enabling-the-plugin
[tool.mypy]
plugins = [
"pydantic.mypy",
]
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
mypy_path = "eos_downloader"
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
[tool.tox]
legacy_tox_ini = """
[tox]
min_version = 4.0
envlist =
clean,
lint,
type
[tox-full]
min_version = 4.0
envlist =
clean,
py{38,39,310},
lint,
type,
report
[gh-actions]
python =
3.8: lint, type
3.9: lint, type
3.10: lint, type
[gh-actions-full]
python =
3.8: py38
3.9: py39
3.10: py310, lint, type, coverage
[testenv]
description = run the test driver with {basepython}
extras = dev
commands =
pytest -rA -q --cov-report term:skip-covered --cov-report term:skip-covered --html=report.html --self-contained-html --cov-report=html --color yes --cov=eos_downloader
[testenv:lint]
description = check the code style
commands =
flake8 --max-line-length=165 --config=/dev/null eos_downloader
pylint eos_downloader
[testenv:type]
description = check typing
commands =
type: mypy --config-file=pyproject.toml eos_downloader
[testenv:clean]
deps = coverage[toml]
skip_install = true
commands = coverage erase
[testenv:report]
deps = coverage[toml]
commands = coverage report
# add the following to make the report fail under some percentage
# commands = coverage report --fail-under=80
depends = py310
"""
[tool.pytest.ini_options]
addopts = "-ra -q -s -vv --capture=tee-sys --cov --cov-append"
log_level = "INFO"
log_cli = "True"
[tool.coverage.run]
source = ['eos_downloader']
# omit = []
[tool.isort]
profile = "black"
line_length = 165

5
pytest.ini Normal file
View file

@ -0,0 +1,5 @@
[pytest]
markers =
webtest: Tests that require connectivity to Arista.com.
slow: Test that are slow to run and excluded by default.
eos_download: Testing of EOS-DOWNLOAD

0
tests/__init__.py Normal file
View file

0
tests/lib/__init__.py Normal file
View file

116
tests/lib/dataset.py Normal file
View file

@ -0,0 +1,116 @@
#!/usr/bin/python
# coding: utf-8 -*-
# pylint: disable=logger-format-interpolation
# pylint: disable=dangerous-default-value
# flake8: noqa: W503
# flake8: noqa: W1202
from __future__ import (absolute_import, division, print_function)
import os
import eos_downloader
from eos_downloader.eos import EOSDownloader
from eos_downloader.data import DATA_MAPPING
# --------------------------------------------------------------- #
# MOOCK data to use for testing
# --------------------------------------------------------------- #
# Get Auth token
# eos_token = os.getenv('ARISTA_TOKEN')
eos_token = os.getenv('ARISTA_TOKEN', 'invalid_token')
eos_token_invalid = 'invalid_token'
eos_dataset_valid = [
{
'image': 'EOS',
'version': '4.26.3M',
'software': 'EOS',
'filename': 'EOS-4.26.3M.swi',
'expected_hash': 'sha512sum',
'remote_path': '/support/download/EOS-USA/Active Releases/4.26/EOS-4.26.3M/EOS-4.26.3M.swi',
'compute_checksum': True
},
{
'image': 'EOS',
'version': '4.25.6M',
'software': 'EOS',
'filename': 'EOS-4.25.6M.swi',
'expected_hash': 'md5sum',
'remote_path': '/support/download/EOS-USA/Active Releases/4.25/EOS-4.25.6M/EOS-4.25.6M.swi',
'compute_checksum': True
},
{
'image': 'vEOS-lab',
'version': '4.25.6M',
'software': 'EOS',
'filename': 'vEOS-lab-4.25.6M.vmdk',
'expected_hash': 'md5sum',
'remote_path': '/support/download/EOS-USA/Active Releases/4.25/EOS-4.25.6M/vEOS-lab/vEOS-lab-4.25.6M.vmdk',
'compute_checksum': False
}
]
eos_dataset_invalid = [
{
'image': 'default',
'version': '4.26.3M',
'software': 'EOS',
'filename': 'EOS-4.26.3M.swi',
'expected_hash': 'sha512sum',
'remote_path': '/support/download/EOS-USA/Active Releases/4.26/EOS-4.26.3M/EOS-4.26.3M.swi',
'compute_checksum': True
}
]
eos_version = [
{
'version': 'EOS-4.23.1F',
'is_valid': True,
'major': 4,
'minor': 23,
'patch': 1,
'rtype': 'F'
},
{
'version': 'EOS-4.23.0',
'is_valid': True,
'major': 4,
'minor': 23,
'patch': 0,
'rtype': None
},
{
'version': 'EOS-4.23',
'is_valid': True,
'major': 4,
'minor': 23,
'patch': 0,
'rtype': None
},
{
'version': 'EOS-4.23.1M',
'is_valid': True,
'major': 4,
'minor': 23,
'patch': 1,
'rtype': 'M'
},
{
'version': 'EOS-4.23.1.F',
'is_valid': True,
'major': 4,
'minor': 23,
'patch': 1,
'rtype': 'F'
},
{
'version': 'EOS-5.23.1F',
'is_valid': False,
'major': 4,
'minor': 23,
'patch': 1,
'rtype': 'F'
},
]

69
tests/lib/fixtures.py Normal file
View file

@ -0,0 +1,69 @@
#!/usr/bin/python
# coding: utf-8 -*-
# pylint: disable=logger-format-interpolation
# pylint: disable=dangerous-default-value
# flake8: noqa: W503
# flake8: noqa: W1202
from __future__ import (absolute_import, division, print_function)
import os
import pytest
import eos_downloader
from typing import Dict, Any, List
from tests.lib.dataset import eos_dataset_valid, eos_dataset_invalid, eos_token, eos_token_invalid
@pytest.fixture
@pytest.mark.parametrize("DOWNLOAD_INFO", eos_dataset_valid)
def create_download_instance(request, DOWNLOAD_INFO):
# logger.info("Execute fixture to create class elements")
request.cls.eos_downloader = eos_downloader.eos.EOSDownloader(
image=DOWNLOAD_INFO['image'],
software=DOWNLOAD_INFO['software'],
version=DOWNLOAD_INFO['version'],
token=eos_token,
hash_method='sha512sum')
yield
# logger.info('Cleanup test environment')
os.system('rm -f {}*'.format(DOWNLOAD_INFO['filename']))
def generate_test_ids_dict(val: Dict[str, Any], key: str = 'name') -> str:
"""
generate_test_ids Helper to generate test ID for parametrize
Only related to SYSTEM_CONFIGLETS_TESTS structure
Parameters
----------
val : dict
A configlet test structure
Returns
-------
str
Name of the configlet
"""
if key in val.keys():
# note this wouldn't show any hours/minutes/seconds
return val[key]
return "undefined_test"
def generate_test_ids_list(val: List[Dict[str, Any]], key: str = 'name') -> str:
"""
generate_test_ids Helper to generate test ID for parametrize
Only related to SYSTEM_CONFIGLETS_TESTS structure
Parameters
----------
val : dict
A configlet test structure
Returns
-------
str
Name of the configlet
"""
return [ entry[key] if key in entry.keys() else 'unset_entry' for entry in val ]

40
tests/lib/helpers.py Normal file
View file

@ -0,0 +1,40 @@
#!/usr/bin/python
# coding: utf-8 -*-
# pylint: disable=logger-format-interpolation
# pylint: disable=dangerous-default-value
# flake8: noqa: W503
# flake8: noqa: W1202
from __future__ import (absolute_import, division, print_function)
import os
from eos_downloader.data import DATA_MAPPING
def default_filename(version: str, info):
"""
default_filename Helper to build default filename
Parameters
----------
version : str
EOS version
info : dict
TEST Inputs
Returns
-------
str
Filename
"""
if version is None or info is None:
return None
return DATA_MAPPING[info['software']]['default']['prepend'] + '-' + version + '.swi'
def is_on_github_actions():
"""Check if code is running on a CI runner"""
if "CI" not in os.environ or not os.environ["CI"] or "GITHUB_RUN_ID" not in os.environ:
return False

0
tests/system/__init__.py Normal file
View file

View file

@ -0,0 +1,48 @@
#!/usr/bin/python
# coding: utf-8 -*-
# pylint: disable=logger-format-interpolation
# pylint: disable=dangerous-default-value
# flake8: noqa: W503
# flake8: noqa: W1202
from __future__ import (absolute_import, division, print_function)
import sys
import os
import platform
from loguru import logger
import pytest
import eos_downloader
from eos_downloader.eos import EOSDownloader
from eos_downloader.data import DATA_MAPPING
from tests.lib.dataset import eos_dataset_valid, eos_token, eos_token_invalid
from tests.lib.fixtures import create_download_instance
from tests.lib.helpers import default_filename
# --------------------------------------------------------------- #
# TEST CASES
# --------------------------------------------------------------- #
@pytest.mark.usefixtures("create_download_instance")
@pytest.mark.parametrize("DOWNLOAD_INFO", eos_dataset_valid, ids=['EOS-sha512', 'EOS-md5' ,'vEOS-lab-no-hash'])
@pytest.mark.eos_download
class TestEosDownload_valid():
def test_data(self, DOWNLOAD_INFO):
print(str(DOWNLOAD_INFO))
@pytest.mark.dependency(name='authentication')
@pytest.mark.skipif(eos_token == eos_token_invalid, reason="Token is not set correctly")
@pytest.mark.skipif(platform.system() != 'Darwin', reason="Incorrect Hardware")
# @pytest.mark.xfail(reason="Deliberate - CI not set for testing AUTH")
@pytest.mark.webtest
def test_eos_download_authenticate(self):
assert self.eos_downloader.authenticate() is True
@pytest.mark.dependency(depends=["authentication"], scope='class')
@pytest.mark.webtest
@pytest.mark.slow
@pytest.mark.eos_download
def test_download_local(self, DOWNLOAD_INFO):
self.eos_downloader.download_local(file_path='.', checksum=DOWNLOAD_INFO['compute_checksum'])

0
tests/unit/__init__.py Normal file
View file

View file

@ -0,0 +1,130 @@
#!/usr/bin/python
# coding: utf-8 -*-
# pylint: disable=logger-format-interpolation
# pylint: disable=dangerous-default-value
# flake8: noqa: W503
# flake8: noqa: W1202
from __future__ import (absolute_import, division, print_function)
import sys
from loguru import logger
import pytest
from eos_downloader.models.version import EosVersion, BASE_VERSION_STR
from tests.lib.dataset import eos_version
from tests.lib.fixtures import generate_test_ids_list
logger.remove()
logger.add(sys.stderr, level="DEBUG")
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_from_str(EOS_VERSION):
version = EosVersion.from_str(EOS_VERSION['version'])
if EOS_VERSION['is_valid']:
assert version.major == EOS_VERSION['major']
assert version.minor == EOS_VERSION['minor']
assert version.patch == EOS_VERSION['patch']
assert version.rtype == EOS_VERSION['rtype']
else:
assert str(version) == BASE_VERSION_STR
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_to_str(EOS_VERSION):
version = EosVersion(**EOS_VERSION)
if EOS_VERSION['is_valid']:
assert version.major == EOS_VERSION['major']
assert version.minor == EOS_VERSION['minor']
assert version.patch == EOS_VERSION['patch']
assert version.rtype == EOS_VERSION['rtype']
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_branch(EOS_VERSION):
if EOS_VERSION['is_valid']:
version = EosVersion(**EOS_VERSION)
assert version.branch == f'{EOS_VERSION["major"]}.{EOS_VERSION["minor"]}'
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_eq_operator(EOS_VERSION):
if not EOS_VERSION['is_valid']:
pytest.skip('not a valid version to test')
version = EosVersion(**EOS_VERSION)
logger.warning(f'version is: {version.dict()}')
assert version == version
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_ge_operator(EOS_VERSION):
if not EOS_VERSION['is_valid']:
pytest.skip('not a valid version to test')
version = EosVersion(**EOS_VERSION)
version_b = EosVersion.from_str(BASE_VERSION_STR)
assert version >= version_b
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_gs_operator(EOS_VERSION):
if not EOS_VERSION['is_valid']:
pytest.skip('not a valid version to test')
version = EosVersion(**EOS_VERSION)
version_b = EosVersion.from_str(BASE_VERSION_STR)
assert version > version_b
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_le_operator(EOS_VERSION):
if not EOS_VERSION['is_valid']:
pytest.skip('not a valid version to test')
version = EosVersion(**EOS_VERSION)
version_b = EosVersion.from_str(BASE_VERSION_STR)
assert version_b <= version
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_ls_operator(EOS_VERSION):
if not EOS_VERSION['is_valid']:
pytest.skip('not a valid version to test')
version = EosVersion(**EOS_VERSION)
version_b = EosVersion.from_str(BASE_VERSION_STR)
assert version_b < version
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_ne_operator(EOS_VERSION):
if not EOS_VERSION['is_valid']:
pytest.skip('not a valid version to test')
version = EosVersion(**EOS_VERSION)
version_b = EosVersion.from_str(BASE_VERSION_STR)
assert version_b != version
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_match(EOS_VERSION):
if not EOS_VERSION['is_valid']:
pytest.skip('not a valid version to test')
version = EosVersion(**EOS_VERSION)
assert version.match(f'=={EOS_VERSION["version"]}')
assert version.match(f'!={BASE_VERSION_STR}')
assert version.match(f'>={BASE_VERSION_STR}')
assert version.match(f'>{BASE_VERSION_STR}')
assert version.match('<=4.99.0F')
assert version.match('<4.99.0F')
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_is_in_branch(EOS_VERSION):
if not EOS_VERSION['is_valid']:
pytest.skip('not a valid version to test')
version = EosVersion(**EOS_VERSION)
assert version.is_in_branch(f"{EOS_VERSION['major']}.{EOS_VERSION['minor']}")
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_match_exception(EOS_VERSION):
if not EOS_VERSION['is_valid']:
pytest.skip('not a valid version to test')
with pytest.raises(Exception) as e_info:
version = EosVersion(**EOS_VERSION)
assert version.match(f'+={EOS_VERSION["version"]}')
logger.info(f'receive exception: {e_info}')
@pytest.mark.parametrize("EOS_VERSION", eos_version, ids=generate_test_ids_list(eos_version,key='version'))
def test_eos_version_compare_exception(EOS_VERSION):
if not EOS_VERSION['is_valid']:
pytest.skip('not a valid version to test')
with pytest.raises(Exception) as e_info:
version = EosVersion(**EOS_VERSION)
version._compare(BASE_VERSION_STR)
logger.info(f'receive exception: {e_info}')

View file

@ -0,0 +1,141 @@
#!/usr/bin/python
# coding: utf-8 -*-
# pylint: disable=logger-format-interpolation
# pylint: disable=dangerous-default-value
# flake8: noqa: W503
# flake8: noqa: W1202
# import platform
import sys
import pytest
from loguru import logger
import eos_downloader
from eos_downloader.data import DATA_MAPPING
from eos_downloader.eos import EOSDownloader
from tests.lib.dataset import eos_dataset_invalid, eos_dataset_valid, eos_token, eos_token_invalid
from tests.lib.fixtures import create_download_instance
from tests.lib.helpers import default_filename, is_on_github_actions
logger.remove()
logger.add(sys.stderr, level="DEBUG")
@pytest.mark.usefixtures("create_download_instance")
@pytest.mark.parametrize("DOWNLOAD_INFO", eos_dataset_valid, ids=['EOS-sha512', 'EOS-md5' ,'vEOS-lab-no-hash'])
@pytest.mark.eos_download
class TestEosDownload_valid():
def test_data(self, DOWNLOAD_INFO):
logger.info(f'test input: {DOWNLOAD_INFO}')
logger.info(f'test build: {self.eos_downloader.__dict__}')
def test_eos_download_create(self, DOWNLOAD_INFO):
my_download = eos_downloader.eos.EOSDownloader(
image=DOWNLOAD_INFO['image'],
software=DOWNLOAD_INFO['software'],
version=DOWNLOAD_INFO['version'],
token=eos_token,
hash_method='sha512sum')
logger.info(my_download)
assert isinstance(my_download, eos_downloader.eos.EOSDownloader)
def test_eos_download_repr_string(self, DOWNLOAD_INFO):
expected = f"{DOWNLOAD_INFO['software']} - {DOWNLOAD_INFO['image']} - {DOWNLOAD_INFO['version']}"
logger.info(self.eos_downloader)
assert str(self.eos_downloader) == expected
def test_eos_download_build_filename(self, DOWNLOAD_INFO):
assert self.eos_downloader._build_filename() == DOWNLOAD_INFO['filename']
@pytest.mark.dependency(name='authentication')
@pytest.mark.skipif(eos_token == eos_token_invalid, reason="Token is not set correctly")
@pytest.mark.skipif(is_on_github_actions(), reason="Running on Github Runner")
# @pytest.mark.xfail(reason="Deliberate - CI not set for testing AUTH")
@pytest.mark.webtest
def test_eos_download_authenticate(self):
assert self.eos_downloader.authenticate() is True
@pytest.mark.dependency(depends=["authentication"], scope='class')
@pytest.mark.webtest
def test_eos_download_get_remote_file_path(self, DOWNLOAD_INFO):
assert self.eos_downloader._get_remote_filepath() == DOWNLOAD_INFO['remote_path']
@pytest.mark.dependency(depends=["authentication"], scope='class')
@pytest.mark.webtest
def test_eos_download_get_file_url(self, DOWNLOAD_INFO):
url = self.eos_downloader._get_url(remote_file_path = DOWNLOAD_INFO['remote_path'])
logger.info(url)
assert 'https://downloads.arista.com/EOS-USA/Active%20Releases/' in url
@pytest.mark.usefixtures("create_download_instance")
@pytest.mark.parametrize("DOWNLOAD_INFO", eos_dataset_invalid, ids=['EOS-FAKE'])
class TestEosDownload_invalid():
def test_data(self, DOWNLOAD_INFO):
logger.info(f'test input: {dict(DOWNLOAD_INFO)}')
logger.info(f'test build: {self.eos_downloader.__dict__}')
def test_eos_download_login_error(self, DOWNLOAD_INFO):
my_download = eos_downloader.eos.EOSDownloader(
image=DOWNLOAD_INFO['image'],
software=DOWNLOAD_INFO['software'],
version=DOWNLOAD_INFO['version'],
token=eos_token_invalid,
hash_method=DOWNLOAD_INFO['expected_hash'])
assert my_download.authenticate() is False
@pytest.mark.dependency(name='authentication')
@pytest.mark.skipif(eos_token == eos_token_invalid, reason="Token is not set correctly")
@pytest.mark.skipif(is_on_github_actions(), reason="Running on Github Runner")
# @pytest.mark.xfail(reason="Deliberate - CI not set for testing AUTH")
@pytest.mark.webtest
def test_eos_download_authenticate(self):
assert self.eos_downloader.authenticate() is True
# SOFTWARE/PLATFORM TESTING
# @pytest.mark.skip(reason="Not yet implemented in lib")
def test_eos_file_name_with_incorrect_software(self, DOWNLOAD_INFO):
self.eos_downloader.software = 'FAKE'
logger.info(f'test build: {self.eos_downloader.__dict__}')
with pytest.raises(ValueError) as e_info:
result = self.eos_downloader._build_filename()
logger.info(f'receive exception: {e_info}')
self.eos_downloader.software = DOWNLOAD_INFO['software']
@pytest.mark.webtest
@pytest.mark.dependency(depends=["authentication"], scope='class')
def test_eos_download_get_remote_file_path_for_invlaid_software(self, DOWNLOAD_INFO):
self.eos_downloader.software = 'FAKE'
logger.info(f'Platform set to: {self.eos_downloader.software}')
logger.info(f'test build: {self.eos_downloader.__dict__}')
with pytest.raises(ValueError) as e_info:
result = self.eos_downloader._build_filename()
logger.info(f'receive exception: {e_info}')
self.eos_downloader.software = DOWNLOAD_INFO['software']
# IMAGE TESTING
def test_eos_file_name_with_incorrect_image(self, DOWNLOAD_INFO):
self.eos_downloader.image = 'FAKE'
logger.info(f'Image set to: {self.eos_downloader.image}')
assert DOWNLOAD_INFO['filename'] == self.eos_downloader._build_filename()
self.eos_downloader.software == DOWNLOAD_INFO['image']
@pytest.mark.webtest
@pytest.mark.dependency(depends=["authentication"], scope='class')
def test_eos_download_get_remote_file_path_for_invlaid_image(self, DOWNLOAD_INFO):
self.eos_downloader.image = 'FAKE'
logger.info(f'Image set to: {self.eos_downloader.image}')
assert self.eos_downloader.authenticate() is True
assert DOWNLOAD_INFO['filename'] == self.eos_downloader._build_filename()
self.eos_downloader.image = DOWNLOAD_INFO['image']
# VERSION TESTING
@pytest.mark.webtest
@pytest.mark.dependency(depends=["authentication"], scope='class')
def test_eos_download_get_remote_file_path_for_invlaid_version(self, DOWNLOAD_INFO):
self.eos_downloader.version = 'FAKE'
logger.info(f'Version set to: {self.eos_downloader.version}')
assert self.eos_downloader._get_remote_filepath() == ''