Merging upstream version 0.12.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
f45bc3d463
commit
8d2f70e3c7
77 changed files with 23610 additions and 2331 deletions
|
@ -30,14 +30,6 @@ __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.
|
||||
"""
|
||||
|
@ -49,8 +41,6 @@ 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."""
|
||||
|
|
8
eos_downloader/cli/__main__.py
Normal file
8
eos_downloader/cli/__main__.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
"""ARDL Module CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .cli import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
|
@ -14,8 +14,8 @@ import click
|
|||
|
||||
from eos_downloader import __version__
|
||||
from eos_downloader.cli.debug import commands as debug_commands
|
||||
from eos_downloader.cli.get import commands as get_commands
|
||||
from eos_downloader.cli.info import commands as info_commands
|
||||
from eos_downloader.cli.get import commands as get_commands
|
||||
|
||||
from eos_downloader.cli.utils import AliasedGroup
|
||||
|
||||
|
@ -29,10 +29,29 @@ from eos_downloader.cli.utils import AliasedGroup
|
|||
default=None,
|
||||
help="Arista Token from your customer account",
|
||||
)
|
||||
def ardl(ctx: click.Context, token: str) -> None:
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"--log",
|
||||
help="Logging level of the command",
|
||||
default="error",
|
||||
type=click.Choice(
|
||||
["debug", "info", "warning", "error", "critical"], case_sensitive=False
|
||||
),
|
||||
)
|
||||
# Boolean triggers
|
||||
@click.option(
|
||||
"--debug-enabled",
|
||||
"--debug",
|
||||
is_flag=True,
|
||||
help="Activate debug mode for ardl cli",
|
||||
default=False,
|
||||
)
|
||||
def ardl(ctx: click.Context, token: str, log_level: str, debug_enabled: bool) -> None:
|
||||
"""Arista Network Download CLI"""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["token"] = token
|
||||
ctx.obj["log_level"] = log_level
|
||||
ctx.obj["debug"] = debug_enabled
|
||||
|
||||
|
||||
@ardl.group(cls=AliasedGroup, no_args_is_help=True)
|
||||
|
@ -61,11 +80,19 @@ def debug(ctx: click.Context, cls: click.Group = AliasedGroup) -> None:
|
|||
|
||||
def cli() -> None:
|
||||
"""Load ANTA CLI"""
|
||||
# Load group commands
|
||||
# Load group commands for get
|
||||
get.add_command(get_commands.eos)
|
||||
get.add_command(get_commands.cvp)
|
||||
info.add_command(info_commands.eos_versions)
|
||||
get.add_command(get_commands.path)
|
||||
|
||||
# Debug
|
||||
debug.add_command(debug_commands.xml)
|
||||
|
||||
# Get info commands
|
||||
info.add_command(info_commands.versions)
|
||||
info.add_command(info_commands.latest)
|
||||
info.add_command(info_commands.mapping)
|
||||
|
||||
# Load CLI
|
||||
ardl(obj={}, auto_envvar_prefix="arista")
|
||||
|
||||
|
|
|
@ -10,14 +10,17 @@
|
|||
Commands for ARDL CLI to get data.
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.dom import minidom
|
||||
|
||||
# Third party imports
|
||||
import click
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
|
||||
import eos_downloader.eos
|
||||
# Local imports
|
||||
import eos_downloader.defaults
|
||||
import eos_downloader.logics.arista_server
|
||||
from eos_downloader.cli.utils import cli_logging
|
||||
|
||||
|
||||
@click.command()
|
||||
|
@ -33,34 +36,46 @@ import eos_downloader.eos
|
|||
"--log-level",
|
||||
"--log",
|
||||
help="Logging level of the command",
|
||||
default=None,
|
||||
default="INFO",
|
||||
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
|
||||
"""Downloads and saves XML data from Arista EOS server.
|
||||
|
||||
This function authenticates with an Arista server, retrieves XML data,
|
||||
and saves it to a file in a prettified format.
|
||||
|
||||
Args:
|
||||
ctx (click.Context): Click context object containing authentication token
|
||||
output (str): File path where the XML output should be saved
|
||||
log_level (str): Logging level to use for output messages
|
||||
|
||||
Raises:
|
||||
Exception: If authentication with the server fails
|
||||
|
||||
Example:
|
||||
>>> xml(ctx, "output.xml", "INFO")
|
||||
INFO: connected to server aaa.bbb.ccc
|
||||
INFO: XML file saved under output.xml
|
||||
"""
|
||||
|
||||
log = cli_logging(log_level)
|
||||
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",
|
||||
server = eos_downloader.logics.arista_server.AristaServer(
|
||||
token=token, session_server=eos_downloader.defaults.DEFAULT_SERVER_SESSION
|
||||
)
|
||||
|
||||
my_download.authenticate()
|
||||
xml_object: ET.ElementTree = (
|
||||
my_download.get_folder_tree()
|
||||
) # pylint: disable=protected-access
|
||||
try:
|
||||
server.authenticate()
|
||||
except Exception as error: # pylint: disable=W0703
|
||||
log.error(f"Cant connect to server: {error}")
|
||||
log.info(f"connected to server {eos_downloader.defaults.DEFAULT_SERVER_SESSION}")
|
||||
xml_data = server.get_xml_data()
|
||||
if xml_data is None:
|
||||
log.error("No XML data received")
|
||||
return
|
||||
xml_object: ET.ElementTree = xml_data # pylint: disable=protected-access
|
||||
xml_content = xml_object.getroot()
|
||||
|
||||
xmlstr = minidom.parseString(ET.tostring(xml_content)).toprettyxml(
|
||||
|
@ -68,5 +83,4 @@ def xml(ctx: click.Context, output: str, log_level: str) -> None:
|
|||
)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(str(xmlstr))
|
||||
|
||||
console.print(f"XML file saved in: { output }")
|
||||
log.info(f"XML file saved under {output}")
|
||||
|
|
|
@ -2,252 +2,385 @@
|
|||
# coding: utf-8 -*-
|
||||
# pylint: disable=no-value-for-parameter
|
||||
# pylint: disable=too-many-arguments
|
||||
# pylint: disable=too-many-positional-arguments
|
||||
# pylint: disable=line-too-long
|
||||
# pylint: disable=redefined-builtin
|
||||
# pylint: disable=broad-exception-caught
|
||||
# flake8: noqa E501
|
||||
|
||||
"""
|
||||
Commands for ARDL CLI to get data.
|
||||
"""
|
||||
"""CLI commands for listing Arista package information."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Union
|
||||
|
||||
import click
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from eos_downloader.models.data import RTYPE_FEATURE
|
||||
from eos_downloader.logics.download import SoftManager
|
||||
from eos_downloader.logics.arista_server import AristaServer
|
||||
from eos_downloader.logics.arista_xml_server import (
|
||||
EosXmlObject,
|
||||
AristaXmlQuerier,
|
||||
CvpXmlObject,
|
||||
)
|
||||
|
||||
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"]
|
||||
from .utils import initialize, search_version, download_files, handle_docker_import
|
||||
|
||||
|
||||
@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.command()
|
||||
@click.option("--format", default="vmdk", help="Image format", 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,
|
||||
show_envvar=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
|
||||
),
|
||||
"--latest",
|
||||
is_flag=True,
|
||||
help="Get latest version. If --branch is not use, get the latest branch with specific release type",
|
||||
default=False,
|
||||
show_envvar=True,
|
||||
)
|
||||
# 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,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--import-docker",
|
||||
is_flag=True,
|
||||
help="Import docker image (only available with --image_type cEOSlab)",
|
||||
help="Import docker image to local docker",
|
||||
default=False,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--skip-download",
|
||||
is_flag=True,
|
||||
help="Skip download process - for debug only",
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--docker-name",
|
||||
default="arista/ceos",
|
||||
help="Docker image name",
|
||||
show_default=True,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--docker-tag",
|
||||
default=None,
|
||||
help="Docker image tag",
|
||||
show_default=True,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--version",
|
||||
default=None,
|
||||
help="EOS version to download",
|
||||
show_default=True,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--release-type",
|
||||
default=RTYPE_FEATURE,
|
||||
help="Release type (M for Maintenance, F for Feature)",
|
||||
show_default=True,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--branch",
|
||||
default=None,
|
||||
help="Branch to download",
|
||||
show_default=True,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
help="Enable dry-run mode: only run code without system changes",
|
||||
default=False,
|
||||
)
|
||||
@click.pass_context
|
||||
def eos(
|
||||
ctx: click.Context,
|
||||
image_type: str,
|
||||
format: str,
|
||||
output: str,
|
||||
log_level: str,
|
||||
eve_ng: bool,
|
||||
disable_ztp: bool,
|
||||
import_docker: bool,
|
||||
skip_download: bool,
|
||||
docker_name: str,
|
||||
version: Union[str, None] = None,
|
||||
release_type: str = RTYPE_FEATURE,
|
||||
latest: bool = False,
|
||||
branch: Union[str, None] = None,
|
||||
docker_tag: str,
|
||||
version: Union[str, None],
|
||||
release_type: str,
|
||||
latest: bool,
|
||||
branch: Union[str, None],
|
||||
dry_run: bool,
|
||||
) -> int:
|
||||
# pylint: disable=R0917
|
||||
"""Download EOS image from Arista website"""
|
||||
console = Console()
|
||||
# Get from Context
|
||||
token = ctx.obj["token"]
|
||||
is_latest: bool = False
|
||||
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...",
|
||||
"""Download EOS image from Arista server."""
|
||||
# pylint: disable=unused-variable
|
||||
console, token, debug, log_level = initialize(ctx)
|
||||
version = search_version(
|
||||
console, token, version, latest, branch, format, release_type
|
||||
)
|
||||
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",
|
||||
if version is None:
|
||||
raise ValueError("Version is not set correctly")
|
||||
try:
|
||||
eos_dl_obj = EosXmlObject(
|
||||
searched_version=version, token=token, image_type=format
|
||||
)
|
||||
my_download.authenticate()
|
||||
except Exception:
|
||||
console.print_exception(show_locals=True)
|
||||
return 1
|
||||
|
||||
elif latest:
|
||||
is_latest = True
|
||||
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"
|
||||
cli = SoftManager(dry_run=dry_run)
|
||||
|
||||
if not skip_download:
|
||||
if not eve_ng:
|
||||
download_files(
|
||||
console, cli, eos_dl_obj, output, rich_interface=True, debug=debug
|
||||
)
|
||||
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)
|
||||
else:
|
||||
try:
|
||||
cli.provision_eve(eos_dl_obj, noztp=True)
|
||||
except Exception as e:
|
||||
if debug:
|
||||
console.print_exception(show_locals=True)
|
||||
else:
|
||||
console.print(f"\n[red]Exception raised: {e}[/red]")
|
||||
return 1
|
||||
|
||||
if import_docker:
|
||||
my_download.docker_import(image_name=docker_name, is_latest=is_latest)
|
||||
console.print("✅ processing done !")
|
||||
sys.exit(0)
|
||||
return handle_docker_import(
|
||||
console, cli, eos_dl_obj, output, docker_name, docker_tag, debug
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@click.command(no_args_is_help=True)
|
||||
@click.pass_context
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--format",
|
||||
default="upgrade",
|
||||
help="CVP Image type",
|
||||
type=click.Choice(CVP_IMAGE_TYPE),
|
||||
required=True,
|
||||
default="ova",
|
||||
help="Image format",
|
||||
show_default=True,
|
||||
show_envvar=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,
|
||||
show_envvar=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
|
||||
),
|
||||
"--latest",
|
||||
is_flag=True,
|
||||
help="Get latest version. If --branch is not use, get the latest branch with specific release type",
|
||||
default=False,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--version",
|
||||
default=None,
|
||||
help="EOS version to download",
|
||||
show_default=True,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--branch",
|
||||
default=None,
|
||||
help="Branch to download",
|
||||
show_default=True,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
help="Enable dry-run mode: only run code without system changes",
|
||||
default=False,
|
||||
)
|
||||
@click.pass_context
|
||||
def cvp(
|
||||
ctx: click.Context, version: str, format: str, output: str, log_level: str
|
||||
ctx: click.Context,
|
||||
latest: bool,
|
||||
format: str,
|
||||
output: str,
|
||||
version: Union[str, None],
|
||||
branch: Union[str, None],
|
||||
dry_run: bool = False,
|
||||
) -> int:
|
||||
"""Download CVP image from Arista website"""
|
||||
console = Console()
|
||||
# Get from Context
|
||||
token = ctx.obj["token"]
|
||||
if token is None or token == "":
|
||||
"""Download CVP image from Arista server."""
|
||||
# pylint: disable=unused-variable
|
||||
console, token, debug, log_level = initialize(ctx)
|
||||
|
||||
if version is not None:
|
||||
console.print(
|
||||
"❗ Token is unset ! Please configure ARISTA_TOKEN or use --token option",
|
||||
style="bold red",
|
||||
f"Searching for EOS version [green]{version}[/green] for [blue]{format}[/blue] format..."
|
||||
)
|
||||
elif latest:
|
||||
console.print(
|
||||
f"Searching for [blue]latest[/blue] EOS version for [blue]{format}[/blue] format..."
|
||||
)
|
||||
elif branch is not None:
|
||||
console.print(
|
||||
f"Searching for EOS [b]latest[/b] version for [blue]{branch}[/blue] branch for [blue]{format}[/blue] format..."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
logger.remove()
|
||||
if log_level is not None:
|
||||
logger.add("eos-downloader.log", rotation="10 MB", level=log_level.upper())
|
||||
if branch is not None or latest:
|
||||
try:
|
||||
querier = AristaXmlQuerier(token=token)
|
||||
version_obj = querier.latest(package="cvp", branch=branch)
|
||||
version = str(version_obj)
|
||||
except Exception as e:
|
||||
console.print(f"Token is set to: {token}")
|
||||
console.print_exception(show_locals=True)
|
||||
return 1
|
||||
|
||||
console.print(
|
||||
"🪐 [bold blue]eos-downloader[/bold blue] is starting...",
|
||||
)
|
||||
console.print(f" - Image Type: {format}")
|
||||
console.print(f" - Version: {version}")
|
||||
console.print(f"version to download is {version}")
|
||||
|
||||
my_download = eos_downloader.eos.EOSDownloader(
|
||||
image=format,
|
||||
software="CloudVision",
|
||||
version=version,
|
||||
token=token,
|
||||
hash_method="md5sum",
|
||||
if version is None:
|
||||
raise ValueError("Version is not set correctly")
|
||||
try:
|
||||
cvp_dl_obj = CvpXmlObject(
|
||||
searched_version=version, token=token, image_type=format
|
||||
)
|
||||
except Exception as e:
|
||||
if debug:
|
||||
console.print_exception(show_locals=True)
|
||||
else:
|
||||
console.print(f"\n[red]Exception raised: {e}[/red]")
|
||||
return 1
|
||||
|
||||
cli = SoftManager(dry_run=dry_run)
|
||||
download_files(
|
||||
console,
|
||||
cli,
|
||||
cvp_dl_obj,
|
||||
output,
|
||||
rich_interface=True,
|
||||
debug=debug,
|
||||
checksum_format="md5sum",
|
||||
)
|
||||
|
||||
my_download.authenticate()
|
||||
console.print(f"CVP file is saved under: {output}")
|
||||
return 0
|
||||
|
||||
my_download.download_local(file_path=output, checksum=False)
|
||||
console.print("✅ processing done !")
|
||||
sys.exit(0)
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--source",
|
||||
"-s",
|
||||
help="Image path to download from Arista Website",
|
||||
type=str,
|
||||
show_default=False,
|
||||
show_envvar=False,
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
default=str(os.path.relpath(os.getcwd(), start=os.curdir)),
|
||||
help="Path to save downloaded package",
|
||||
type=click.Path(),
|
||||
show_default=True,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--import-docker",
|
||||
is_flag=True,
|
||||
help="Import docker image to local docker",
|
||||
default=False,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--docker-name",
|
||||
default="arista/ceos:raw",
|
||||
help="Docker image name",
|
||||
show_default=True,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.option(
|
||||
"--docker-tag",
|
||||
default="dev",
|
||||
help="Docker image tag",
|
||||
show_default=True,
|
||||
show_envvar=True,
|
||||
)
|
||||
@click.pass_context
|
||||
# pylint: disable=too-many-branches
|
||||
def path(
|
||||
ctx: click.Context,
|
||||
output: str,
|
||||
source: str,
|
||||
import_docker: bool,
|
||||
docker_name: str,
|
||||
docker_tag: str,
|
||||
) -> int:
|
||||
"""Download image from Arista server using direct path."""
|
||||
console, token, debug, log_level = initialize(ctx)
|
||||
|
||||
if source is None:
|
||||
console.print("[red]Source is not set correctly ![/red]")
|
||||
return 1
|
||||
|
||||
filename = os.path.basename(source)
|
||||
|
||||
console.print(f"Downloading file {filename} from source: {source}")
|
||||
console.print(f"Saving file to: {output}")
|
||||
|
||||
ar_server = AristaServer(token=token)
|
||||
|
||||
try:
|
||||
file_url = ar_server.get_url(source)
|
||||
if log_level == "debug":
|
||||
console.print(f"URL to download file is: {file_url}")
|
||||
except Exception as e:
|
||||
if debug:
|
||||
console.print_exception(show_locals=True)
|
||||
else:
|
||||
console.print(f"\n[red]Exception raised: {e}[/red]")
|
||||
return 1
|
||||
|
||||
if file_url is None:
|
||||
console.print("File URL is set to None when we expect a string")
|
||||
return 1
|
||||
|
||||
cli = SoftManager(dry_run=False)
|
||||
|
||||
try:
|
||||
cli.download_file(file_url, output, filename=filename)
|
||||
except Exception as e:
|
||||
if debug:
|
||||
console.print_exception(show_locals=True)
|
||||
else:
|
||||
console.print(f"\n[red]Exception raised: {e}[/red]")
|
||||
return 1
|
||||
|
||||
if import_docker:
|
||||
console.print(
|
||||
f"Importing docker image [green]{docker_name}:{docker_tag}[/green] from [blue]{os.path.join(output, filename)}[/blue]..."
|
||||
)
|
||||
|
||||
try:
|
||||
cli.import_docker(
|
||||
local_file_path=os.path.join(output, filename),
|
||||
docker_name=docker_name,
|
||||
docker_tag=docker_tag,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
if debug:
|
||||
console.print_exception(show_locals=True)
|
||||
else:
|
||||
console.print(
|
||||
f"\n[red]File not found: {os.path.join(output, filename)}[/red]"
|
||||
)
|
||||
return 1
|
||||
|
||||
console.print(
|
||||
f"Docker image imported successfully: [green]{docker_name}:{docker_tag}[/green]"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
|
182
eos_downloader/cli/get/utils.py
Normal file
182
eos_downloader/cli/get/utils.py
Normal file
|
@ -0,0 +1,182 @@
|
|||
"""Generic functions for the CLI."""
|
||||
# pylint: disable=too-many-arguments
|
||||
# pylint: disable=too-many-positional-arguments
|
||||
|
||||
import os
|
||||
from typing import cast, Optional, Union, Any
|
||||
import subprocess
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from eos_downloader.cli.utils import cli_logging, console_configuration
|
||||
from eos_downloader.models.data import RTYPE_FEATURE, RTYPES
|
||||
from eos_downloader.models.types import ReleaseType
|
||||
from eos_downloader.logics.arista_xml_server import AristaXmlQuerier, AristaXmlObjects
|
||||
|
||||
|
||||
def initialize(ctx: click.Context) -> tuple[Console, str, bool, str]:
|
||||
"""Initializes the CLI context with necessary configurations.
|
||||
|
||||
Args:
|
||||
ctx (click.Context): The Click context object containing command-line parameters.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing the console configuration, token, debug flag, and log level.
|
||||
"""
|
||||
|
||||
console = console_configuration()
|
||||
token = ctx.obj["token"]
|
||||
debug = ctx.obj["debug"]
|
||||
log_level = ctx.obj["log_level"]
|
||||
cli_logging(log_level)
|
||||
|
||||
return console, token, debug, log_level
|
||||
|
||||
|
||||
def search_version(
|
||||
console: Console,
|
||||
token: str,
|
||||
version: Optional[str],
|
||||
latest: bool,
|
||||
branch: Optional[str],
|
||||
file_format: str,
|
||||
release_type: str,
|
||||
) -> Union[str, None]:
|
||||
"""Searches for the specified EOS version based on the provided parameters.
|
||||
|
||||
Args:
|
||||
console (Console): The console object used for printing messages.
|
||||
token (str): The authentication token for accessing the EOS API.
|
||||
version (str or None): The specific version of EOS to search for. If None, other parameters are used.
|
||||
latest (bool): If True, search for the latest EOS version.
|
||||
branch (str or None): The branch of EOS to search for. If None, the default branch is used.
|
||||
format (str): The format of the EOS version (e.g., 'tar', 'zip').
|
||||
release_type (str): The type of release (e.g., 'feature', 'maintenance').
|
||||
|
||||
Returns:
|
||||
str: The version of EOS found based on the search criteria.
|
||||
"""
|
||||
|
||||
if version is not None:
|
||||
console.print(
|
||||
f"Searching for EOS version [green]{version}[/green] for [blue]{file_format}[/blue] format..."
|
||||
)
|
||||
elif latest:
|
||||
console.print(
|
||||
f"Searching for [blue]latest[/blue] EOS version for [blue]{file_format}[/blue] format..."
|
||||
)
|
||||
elif branch is not None:
|
||||
console.print(
|
||||
f"Searching for EOS [b]latest[/b] version for [blue]{branch}[/blue] branch for [blue]{file_format}[/blue] format..."
|
||||
)
|
||||
|
||||
if branch is not None or latest:
|
||||
querier = AristaXmlQuerier(token=token)
|
||||
rtype: ReleaseType = cast(
|
||||
ReleaseType, release_type if release_type in RTYPES else RTYPE_FEATURE
|
||||
)
|
||||
version_obj = querier.latest(package="eos", branch=branch, rtype=rtype)
|
||||
version = str(version_obj)
|
||||
return version
|
||||
|
||||
|
||||
def download_files(
|
||||
console: Console,
|
||||
cli: Any,
|
||||
arista_dl_obj: AristaXmlObjects,
|
||||
output: str,
|
||||
rich_interface: bool,
|
||||
debug: bool,
|
||||
checksum_format: str = "sha512sum",
|
||||
) -> None:
|
||||
"""Downloads EOS files and verifies their checksums.
|
||||
|
||||
Args:
|
||||
console (Console): The console object for printing messages.
|
||||
cli (CLI): The CLI object used to perform download and checksum operations.
|
||||
arista_dl_obj (AristaPackage): The EOS download object containing version and filename information.
|
||||
output (str): The output directory where the files will be saved.
|
||||
rich_interface (bool): Flag to indicate if rich interface should be used.
|
||||
debug (bool): Flag to indicate if debug information should be printed.
|
||||
checksum_format (str): The checksum format to use for verification.
|
||||
|
||||
Raises:
|
||||
Exception: If there is an error during the checksum verification.
|
||||
"""
|
||||
|
||||
console.print(
|
||||
f"Starting download for EOS version [green]{arista_dl_obj.version}[/green] for [blue]{arista_dl_obj.image_type}[/blue] format."
|
||||
)
|
||||
cli.downloads(arista_dl_obj, file_path=output, rich_interface=rich_interface)
|
||||
try:
|
||||
cli.checksum(checksum_format)
|
||||
except subprocess.CalledProcessError:
|
||||
if debug:
|
||||
console.print_exception(show_locals=True)
|
||||
else:
|
||||
console.print(
|
||||
f"[red]Checksum error for file {arista_dl_obj.filename}[/red]"
|
||||
)
|
||||
console.print(
|
||||
f"Arista file [green]{arista_dl_obj.filename}[/green] downloaded in: [blue]{output}[/blue]"
|
||||
)
|
||||
|
||||
|
||||
def handle_docker_import(
|
||||
console: Console,
|
||||
cli: Any,
|
||||
arista_dl_obj: AristaXmlObjects,
|
||||
output: str,
|
||||
docker_name: str,
|
||||
docker_tag: Optional[str],
|
||||
debug: bool,
|
||||
) -> int:
|
||||
"""Handles the import of a Docker image using the provided CLI tool.
|
||||
|
||||
Args:
|
||||
console: The console object used for printing messages.
|
||||
cli: The CLI tool object that provides the import_docker method.
|
||||
arista_dl_obj: An object containing information about the EOS download, including version and filename.
|
||||
output: The directory where the Docker image file is located.
|
||||
docker_name: The name to assign to the Docker image.
|
||||
docker_tag: The tag to assign to the Docker image. If None, the version from eos_dl_obj is used.
|
||||
debug: A boolean indicating whether to print detailed exception information.
|
||||
|
||||
Returns:
|
||||
int: 0 if the Docker image is imported successfully, 1 if a FileNotFoundError occurs.
|
||||
"""
|
||||
|
||||
console.print("Importing docker image...")
|
||||
|
||||
if docker_tag is None:
|
||||
docker_tag = arista_dl_obj.version
|
||||
|
||||
if arista_dl_obj.filename is None:
|
||||
console.print("[red]Invalid filename[/red]")
|
||||
return 1
|
||||
|
||||
console.print(
|
||||
f"Importing docker image [green]{docker_name}:{docker_tag}[/green] from [blue]{os.path.join(output, arista_dl_obj.filename)}[/blue]..."
|
||||
)
|
||||
|
||||
try:
|
||||
cli.import_docker(
|
||||
local_file_path=os.path.join(output, arista_dl_obj.filename),
|
||||
docker_name=docker_name,
|
||||
docker_tag=docker_tag,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
if debug:
|
||||
console.print_exception(show_locals=True)
|
||||
else:
|
||||
console.print(
|
||||
f"\n[red]File not found: {os.path.join(output, arista_dl_obj.filename)}[/red]"
|
||||
)
|
||||
return 1
|
||||
|
||||
console.print(
|
||||
f"Docker image imported successfully: [green]{docker_name}:{docker_tag}[/green]"
|
||||
)
|
||||
|
||||
return 0
|
|
@ -6,130 +6,235 @@
|
|||
# pylint: disable=redefined-builtin
|
||||
# flake8: noqa E501
|
||||
|
||||
"""
|
||||
Commands for ARDL CLI to list data.
|
||||
"""CLI commands for listing Arista package information.
|
||||
|
||||
This module provides CLI commands to query and display version information for Arista packages (EOS and CVP).
|
||||
It includes commands to:
|
||||
- List all available versions with filtering options
|
||||
- Get the latest version for a given package/branch
|
||||
|
||||
The commands use Click for CLI argument parsing and support both text and JSON output formats.
|
||||
Authentication is handled via a token passed through Click context.
|
||||
|
||||
Commands:
|
||||
versions: Lists all available versions with optional filtering
|
||||
latest: Shows the latest version matching the filter criteria
|
||||
|
||||
Dependencies:
|
||||
click: CLI framework
|
||||
rich: For pretty JSON output
|
||||
eos_downloader.logics.arista_server: Core logic for querying Arista servers
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Union
|
||||
import json
|
||||
|
||||
import click
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.pretty import pprint
|
||||
from rich import print_json
|
||||
from rich.panel import Panel
|
||||
|
||||
import eos_downloader.eos
|
||||
from eos_downloader.models.version import BASE_VERSION_STR, RTYPE_FEATURE, RTYPES
|
||||
from eos_downloader.models.data import software_mapping
|
||||
from eos_downloader.models.types import AristaPackage, ReleaseType, AristaMapping
|
||||
import eos_downloader.logics.arista_xml_server
|
||||
from eos_downloader.cli.utils import console_configuration
|
||||
from eos_downloader.cli.utils import cli_logging
|
||||
|
||||
# """
|
||||
# Commands for ARDL CLI to list data.
|
||||
# """
|
||||
|
||||
|
||||
@click.command(no_args_is_help=True)
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--format",
|
||||
type=click.Choice(["json", "text", "fancy"]),
|
||||
default="fancy",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--package", type=click.Choice(["eos", "cvp"]), default="eos", required=False
|
||||
)
|
||||
@click.option("--branch", "-b", type=str, required=False)
|
||||
@click.option("--release-type", type=str, required=False)
|
||||
@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(
|
||||
def versions(
|
||||
ctx: click.Context,
|
||||
log_level: str,
|
||||
branch: Union[str, None] = None,
|
||||
release_type: str = RTYPE_FEATURE,
|
||||
latest: bool = False,
|
||||
verbose: bool = False,
|
||||
package: AristaPackage,
|
||||
branch: str,
|
||||
release_type: ReleaseType,
|
||||
format: str,
|
||||
) -> None:
|
||||
# pylint: disable = too-many-branches, R0917
|
||||
"""
|
||||
List Available EOS version on Arista.com website.
|
||||
"""List available package versions from Arista server."""
|
||||
|
||||
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
|
||||
console = console_configuration()
|
||||
token = ctx.obj["token"]
|
||||
debug = ctx.obj["debug"]
|
||||
log_level = ctx.obj["log_level"]
|
||||
cli_logging(log_level)
|
||||
|
||||
logger.remove()
|
||||
if log_level is not None:
|
||||
logger.add("eos-downloader.log", rotation="10 MB", level=log_level.upper())
|
||||
querier = eos_downloader.logics.arista_xml_server.AristaXmlQuerier(token=token)
|
||||
|
||||
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}")
|
||||
received_versions = None
|
||||
try:
|
||||
received_versions = querier.available_public_versions(
|
||||
package=package, branch=branch, rtype=release_type
|
||||
)
|
||||
except ValueError:
|
||||
if debug:
|
||||
console.print_exception(show_locals=True)
|
||||
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)}")
|
||||
console.print("[red]No versions found[/red]")
|
||||
return
|
||||
|
||||
if format == "text":
|
||||
console.print("Listing available versions")
|
||||
if received_versions is None:
|
||||
console.print("[red]No versions found[/red]")
|
||||
return
|
||||
for version in received_versions:
|
||||
console.print(f" - version: [blue]{version}[/blue]")
|
||||
elif format == "fancy":
|
||||
lines_output = []
|
||||
if received_versions is None:
|
||||
console.print("[red]No versions found[/red]")
|
||||
return
|
||||
for version in received_versions:
|
||||
lines_output.append(f" - version: [blue]{version}[/blue]")
|
||||
console.print("")
|
||||
console.print(
|
||||
Panel("\n".join(lines_output), title="Available versions", padding=1)
|
||||
)
|
||||
elif format == "json":
|
||||
response = []
|
||||
if received_versions is None:
|
||||
console.print("[red]No versions found[/red]")
|
||||
return
|
||||
for version in received_versions:
|
||||
out = {}
|
||||
out["version"] = str(version)
|
||||
out["branch"] = str(version.branch)
|
||||
response.append(out)
|
||||
response = json.dumps(response) # type: ignore
|
||||
print_json(response)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--format",
|
||||
type=click.Choice(["json", "text", "fancy"]),
|
||||
default="fancy",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--package", type=click.Choice(["eos", "cvp"]), default="eos", required=False
|
||||
)
|
||||
@click.option("--branch", "-b", type=str, required=False)
|
||||
@click.option("--release-type", type=str, required=False)
|
||||
@click.pass_context
|
||||
def latest(
|
||||
ctx: click.Context,
|
||||
package: AristaPackage,
|
||||
branch: str,
|
||||
release_type: ReleaseType,
|
||||
format: str,
|
||||
) -> None:
|
||||
"""List available versions of Arista packages (eos or CVP) packages."""
|
||||
|
||||
console = console_configuration()
|
||||
token = ctx.obj["token"]
|
||||
debug = ctx.obj["debug"]
|
||||
log_level = ctx.obj["log_level"]
|
||||
cli_logging(log_level)
|
||||
querier = eos_downloader.logics.arista_xml_server.AristaXmlQuerier(token=token)
|
||||
received_version = None
|
||||
try:
|
||||
received_version = querier.latest(
|
||||
package=package, branch=branch, rtype=release_type
|
||||
)
|
||||
except ValueError:
|
||||
if debug:
|
||||
console.print_exception(show_locals=True)
|
||||
else:
|
||||
pprint([str(version) for version in versions])
|
||||
console.print("[red]No versions found[/red]")
|
||||
|
||||
if format in ["text", "fancy"]:
|
||||
version_info = f"Latest version for [green]{package}[/green]: [blue]{received_version}[/blue]"
|
||||
if branch:
|
||||
version_info += f" for branch [blue]{branch}[/blue]"
|
||||
|
||||
if format == "text":
|
||||
console.print("")
|
||||
console.print(version_info)
|
||||
else: # fancy format
|
||||
console.print("")
|
||||
console.print(Panel(version_info, title="Latest version", padding=1))
|
||||
else: # json format
|
||||
print_json(json.dumps({"version": str(received_version)}))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--package", type=click.Choice(["eos", "cvp"]), default="eos", required=False
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
type=click.Choice(["json", "text", "fancy"]),
|
||||
default="fancy",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--details",
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
default=False,
|
||||
help="Show details for each flavor",
|
||||
)
|
||||
@click.pass_context
|
||||
def mapping(
|
||||
ctx: click.Context, package: AristaPackage, details: bool, format: str
|
||||
) -> None:
|
||||
"""List available flavors of Arista packages (eos or CVP) packages."""
|
||||
|
||||
mapping_pkg_name: AristaMapping = "EOS"
|
||||
if package == "eos":
|
||||
mapping_pkg_name = "EOS"
|
||||
elif package == "cvp":
|
||||
mapping_pkg_name = "CloudVision"
|
||||
console = console_configuration()
|
||||
log_level = ctx.obj["log_level"]
|
||||
console.print(f"Log Level is: {log_level}")
|
||||
cli_logging(log_level)
|
||||
|
||||
if mapping_pkg_name in software_mapping.model_fields:
|
||||
mapping_entries = getattr(software_mapping, mapping_pkg_name, None)
|
||||
if format == "text":
|
||||
console.print(
|
||||
f"Following flavors for [red]{package}/{mapping_pkg_name}[/red] have been found:"
|
||||
)
|
||||
if mapping_entries is None:
|
||||
console.print("[red]No flavors found[/red]")
|
||||
return
|
||||
for mapping_entry in mapping_entries:
|
||||
console.print(f" * Flavor: [blue]{mapping_entry}[/blue]")
|
||||
if details:
|
||||
console.print(
|
||||
f" - Information: [black]{mapping_entries[mapping_entry]}[/black]"
|
||||
)
|
||||
console.print("\n")
|
||||
elif format == "fancy":
|
||||
lines_output = []
|
||||
if mapping_entries is None:
|
||||
lines_output.append("[red]No flavors found[/red]")
|
||||
console.print("\n".join(lines_output))
|
||||
return
|
||||
for mapping_entry in mapping_entries:
|
||||
lines_output.append(f" * Flavor: [blue]{mapping_entry}[/blue]")
|
||||
if details:
|
||||
lines_output.append(
|
||||
f" - Information: [black]{mapping_entries[mapping_entry]}[/black]"
|
||||
)
|
||||
console.print("")
|
||||
console.print(Panel("\n".join(lines_output), title="Flavors", padding=1))
|
||||
console.print("\n")
|
||||
elif format == "json":
|
||||
mapping_json = software_mapping.model_dump()[package.upper()]
|
||||
print_json(json.dumps(mapping_json))
|
||||
|
|
|
@ -8,23 +8,27 @@ Extension for the python ``click`` module
|
|||
to provide a group or command with aliases.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
import click
|
||||
|
||||
from rich import pretty
|
||||
from rich.logging import RichHandler
|
||||
from rich.console import Console
|
||||
|
||||
|
||||
class AliasedGroup(click.Group):
|
||||
"""
|
||||
Implements a subclass of Group that accepts a prefix for a command.
|
||||
If there were a command called push, it would accept pus as an alias (so long as it was unique)
|
||||
"""
|
||||
|
||||
def get_command(self, ctx: click.Context, cmd_name: str) -> Any:
|
||||
"""Documentation to build"""
|
||||
rv = click.Group.get_command(self, ctx, cmd_name)
|
||||
if rv is not None:
|
||||
return rv
|
||||
matches = [x for x in self.list_commands(ctx)
|
||||
if x.startswith(cmd_name)]
|
||||
matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
|
||||
if not matches:
|
||||
return None
|
||||
if len(matches) == 1:
|
||||
|
@ -36,3 +40,45 @@ class AliasedGroup(click.Group):
|
|||
# always return the full command name
|
||||
_, cmd, args = super().resolve_command(ctx, args)
|
||||
return cmd.name, cmd, args
|
||||
|
||||
|
||||
def cli_logging(level: str = "error") -> logging.Logger:
|
||||
"""
|
||||
Configures and returns a logger with the specified logging level.
|
||||
|
||||
This function sets up the logging configuration using the RichHandler
|
||||
to provide rich formatted log messages. The log messages will include
|
||||
the time and can contain markup and rich tracebacks.
|
||||
|
||||
Args:
|
||||
level (str): The logging level as a string (e.g., 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL').
|
||||
|
||||
Returns:
|
||||
logging.Logger: A configured logger instance.
|
||||
"""
|
||||
|
||||
FORMAT = "%(message)s"
|
||||
logging.basicConfig(
|
||||
level=level.upper(),
|
||||
format=FORMAT,
|
||||
datefmt="[%X]",
|
||||
handlers=[
|
||||
RichHandler(
|
||||
show_path=True,
|
||||
show_time=True,
|
||||
show_level=True,
|
||||
markup=True,
|
||||
rich_tracebacks=True,
|
||||
tracebacks_suppress=[click],
|
||||
)
|
||||
],
|
||||
)
|
||||
log = logging.getLogger("rich")
|
||||
return log
|
||||
|
||||
|
||||
def console_configuration() -> Console:
|
||||
"""Configure Rich Terminal for the CLI."""
|
||||
pretty.install()
|
||||
console = Console()
|
||||
return console
|
||||
|
|
|
@ -1,295 +0,0 @@
|
|||
#!/usr/bin/python
|
||||
# coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
CVP Uploader content
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from cvprac.cvp_client import CvpClient
|
||||
from cvprac.cvp_client_errors import CvpLoginError
|
||||
from loguru import logger
|
||||
|
||||
# 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
|
|
@ -1,33 +0,0 @@
|
|||
#!/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},
|
||||
},
|
||||
}
|
33
eos_downloader/defaults.py
Normal file
33
eos_downloader/defaults.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
# coding: utf-8 -*-
|
||||
"""
|
||||
Default values for eos_downloader.
|
||||
|
||||
This module contains default configuration values used by the eos_downloader package.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
DEFAULT_REQUEST_HEADERS : dict
|
||||
Default HTTP headers used for API requests. Contains Content-Type and User-Agent headers.
|
||||
DEFAULT_SOFTWARE_FOLDER_TREE : str
|
||||
API endpoint URL for retrieving the EOS software folder structure.
|
||||
DEFAULT_DOWNLOAD_URL : str
|
||||
API endpoint URL for getting download links for EOS images.
|
||||
DEFAULT_SERVER_SESSION : str
|
||||
API endpoint URL for obtaining session codes from Arista's servers.
|
||||
EVE_QEMU_FOLDER_PATH : str
|
||||
Path to the folder where the downloaded EOS images will be stored on an EVE-NG server.
|
||||
"""
|
||||
|
||||
DEFAULT_REQUEST_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Chrome/123.0.0.0",
|
||||
}
|
||||
DEFAULT_SOFTWARE_FOLDER_TREE = (
|
||||
"https://www.arista.com/custom_data/api/cvp/getFolderTree/"
|
||||
)
|
||||
|
||||
DEFAULT_DOWNLOAD_URL = "https://www.arista.com/custom_data/api/cvp/getDownloadLink/"
|
||||
|
||||
DEFAULT_SERVER_SESSION = "https://www.arista.com/custom_data/api/cvp/getSessionCode/"
|
||||
|
||||
EVE_QEMU_FOLDER_PATH = "/opt/unetlab/addons/qemu/"
|
|
@ -1,95 +0,0 @@
|
|||
# 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 Any, Iterable
|
||||
|
||||
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()
|
||||
|
||||
REQUEST_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Chrome/123.0.0.0",
|
||||
}
|
||||
|
||||
|
||||
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, headers=REQUEST_HEADERS)
|
||||
# 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)
|
|
@ -1,202 +0,0 @@
|
|||
#!/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)
|
||||
selected_branch = max(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
|
10
eos_downloader/exceptions/__init__.py
Normal file
10
eos_downloader/exceptions/__init__.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
# coding: utf-8 -*-
|
||||
"""Exceptions module for eos_downloader package."""
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Erxception when authentication fails."""
|
||||
|
||||
|
||||
class AristaServerError(Exception):
|
||||
"""Exception returned when an error occured on server side."""
|
196
eos_downloader/helpers/__init__.py
Normal file
196
eos_downloader/helpers/__init__.py
Normal file
|
@ -0,0 +1,196 @@
|
|||
# flake8: noqa: F811
|
||||
"""A module for managing file downloads with progress tracking in the console.
|
||||
|
||||
This module provides functionality for downloading files with visual progress indicators
|
||||
using the Rich library. It includes a signal handler for graceful interruption and
|
||||
a DownloadProgressBar class for concurrent file downloads with progress tracking.
|
||||
|
||||
Classes
|
||||
-------
|
||||
DownloadProgressBar: A class that provides visual progress tracking for file downloads.
|
||||
|
||||
Functions
|
||||
-------
|
||||
handle_sigint: Signal handler for SIGINT (Ctrl+C) to enable graceful termination.
|
||||
console (Console): Rich Console instance for output rendering.
|
||||
done_event (Event): Threading Event used for signaling download interruption.
|
||||
"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
import os.path
|
||||
import signal
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Event
|
||||
from typing import Any, Iterable
|
||||
|
||||
import requests
|
||||
from rich.console import Console
|
||||
|
||||
# from eos_downloader.console.client import DownloadProgressBar
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
DownloadColumn,
|
||||
Progress,
|
||||
TaskID,
|
||||
TextColumn,
|
||||
TimeElapsedColumn,
|
||||
TransferSpeedColumn,
|
||||
)
|
||||
|
||||
import eos_downloader.defaults
|
||||
|
||||
|
||||
console = Console()
|
||||
done_event = Event()
|
||||
|
||||
|
||||
def handle_sigint(signum: Any, frame: Any) -> None:
|
||||
"""
|
||||
Signal handler for SIGINT (Ctrl+C).
|
||||
|
||||
This function sets the done_event flag when SIGINT is received,
|
||||
allowing for graceful termination of the program.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
signum : Any
|
||||
Signal number.
|
||||
frame : Any
|
||||
Current stack frame object.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
done_event.set()
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, handle_sigint)
|
||||
|
||||
|
||||
class DownloadProgressBar:
|
||||
"""A progress bar for downloading files.
|
||||
|
||||
This class provides a visual progress indicator for file downloads using the Rich library.
|
||||
It supports downloading multiple files concurrently with a progress bar showing download
|
||||
speed, completion percentage, and elapsed time.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
progress : Progress
|
||||
A Rich Progress instance configured with custom columns for displaying download information.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> downloader = DownloadProgressBar()
|
||||
>>> urls = ['http://example.com/file1.zip', 'http://example.com/file2.zip']
|
||||
>>> downloader.download(urls, '/path/to/destination')
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
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:
|
||||
"""Download a file from a URL and save it to a local path with progress tracking.
|
||||
|
||||
This method performs a streaming download of a file from a given URL, saving it to the
|
||||
specified local path while updating a progress bar. The download can be interrupted via
|
||||
a done event.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
task_id : TaskID
|
||||
Identifier for the progress tracking task.
|
||||
url : str
|
||||
URL to download the file from.
|
||||
path : str
|
||||
Local path where the file should be saved.
|
||||
block_size : int, optional
|
||||
Size of chunks to download at a time. Defaults to 1024 bytes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if download was interrupted by done_event, False if completed successfully.
|
||||
|
||||
Raises
|
||||
------
|
||||
requests.exceptions.RequestException
|
||||
If the download request fails.
|
||||
IOError
|
||||
If there are issues writing to the local file.
|
||||
KeyError
|
||||
If the response doesn't contain Content-Length header.
|
||||
"""
|
||||
response = requests.get(
|
||||
url,
|
||||
stream=True,
|
||||
timeout=5,
|
||||
headers=eos_downloader.defaults.DEFAULT_REQUEST_HEADERS,
|
||||
)
|
||||
# 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 files from URLs concurrently to a destination directory.
|
||||
|
||||
This method downloads files from the provided URLs in parallel using a thread pool,
|
||||
displaying progress for each download in the console.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
urls : Iterable[str]
|
||||
An iterable of URLs to download files from.
|
||||
dest_dir : str
|
||||
The destination directory where files will be saved.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> downloader = DownloadProgressBar()
|
||||
>>> urls = ["http://example.com/file1.txt", "http://example.com/file2.txt"]
|
||||
>>> downloader.download(urls, "/path/to/destination")
|
||||
"""
|
||||
with self.progress:
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
futures = []
|
||||
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
|
||||
)
|
||||
futures.append(pool.submit(self._copy_url, task_id, url, dest_path))
|
||||
|
||||
for future in futures:
|
||||
future.result() # Wait for all downloads to complete
|
1
eos_downloader/logics/__init__.py
Normal file
1
eos_downloader/logics/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""eos_downloader logics"""
|
272
eos_downloader/logics/arista_server.py
Normal file
272
eos_downloader/logics/arista_server.py
Normal file
|
@ -0,0 +1,272 @@
|
|||
#!/usr/bin/python
|
||||
# coding: utf-8 -*-
|
||||
# pylint: disable=too-many-positional-arguments
|
||||
# pylint: disable=dangerous-default-value
|
||||
|
||||
"""Server module for handling interactions with Arista software download portal.
|
||||
|
||||
This module provides the AristaServer class which manages authentication and
|
||||
file retrieval operations with the Arista software download portal. It handles
|
||||
session management, XML data retrieval, and download URL generation.
|
||||
|
||||
Classes
|
||||
-------
|
||||
AristaServer
|
||||
Main class for interacting with the Arista software portal.
|
||||
|
||||
Dependencies
|
||||
-----------
|
||||
- base64: For encoding authentication tokens
|
||||
- json: For handling JSON data in requests
|
||||
- xml.etree.ElementTree: For parsing XML responses
|
||||
- loguru: For logging
|
||||
- requests: For making HTTP requests
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> from eos_downloader.logics.server import AristaServer
|
||||
>>> server = AristaServer(token='my_auth_token')
|
||||
>>> server.authenticate()
|
||||
>>> xml_data = server.get_xml_data()
|
||||
>>> download_url = server.get_url('/path/to/file')
|
||||
|
||||
Notes
|
||||
-----
|
||||
The module requires valid authentication credentials to interact with the Arista portal.
|
||||
All server interactions are performed over HTTPS and follow Arista's API specifications.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import json
|
||||
from typing import Dict, Union, Any
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from loguru import logger
|
||||
import requests
|
||||
|
||||
import eos_downloader.exceptions
|
||||
import eos_downloader.defaults
|
||||
|
||||
|
||||
class AristaServer:
|
||||
"""AristaServer class to handle authentication and interactions with Arista software download portal.
|
||||
|
||||
This class provides methods to authenticate with the Arista software portal,
|
||||
retrieve XML data containing available software packages, and generate download URLs
|
||||
for specific files.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
token : str, optional
|
||||
Authentication token for Arista portal access
|
||||
timeout : int, default=5
|
||||
Timeout in seconds for HTTP requests
|
||||
session_server : str
|
||||
URL of the authentication server
|
||||
headers : Dict[str, any]
|
||||
HTTP headers to use in requests
|
||||
xml_url : str
|
||||
URL to retrieve software package XML data
|
||||
download_server : str
|
||||
Base URL for file downloads
|
||||
_session_id : str
|
||||
Session ID obtained after authentication
|
||||
|
||||
Methods
|
||||
-------
|
||||
authenticate(token: Union[bool, None] = None) -> bool
|
||||
Authenticates with the Arista portal using provided or stored token
|
||||
get_xml_data() -> ET.ElementTree
|
||||
Retrieves XML data containing available software packages
|
||||
get_url(remote_file_path: str) -> Union[str, None]
|
||||
Generates download URL for a specific file path
|
||||
|
||||
Raises
|
||||
------
|
||||
eos_downloader.exceptions.AuthenticationError
|
||||
When authentication fails due to invalid or expired token
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: Union[str, None] = None,
|
||||
timeout: int = 5,
|
||||
session_server: str = eos_downloader.defaults.DEFAULT_SERVER_SESSION,
|
||||
headers: Dict[str, Any] = eos_downloader.defaults.DEFAULT_REQUEST_HEADERS,
|
||||
xml_url: str = eos_downloader.defaults.DEFAULT_SOFTWARE_FOLDER_TREE,
|
||||
download_server: str = eos_downloader.defaults.DEFAULT_DOWNLOAD_URL,
|
||||
) -> None:
|
||||
"""Initialize the Server class with optional parameters.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token : Union[str, None], optional
|
||||
Authentication token. Defaults to None.
|
||||
timeout : int, optional
|
||||
Request timeout in seconds. Defaults to 5.
|
||||
session_server : str, optional
|
||||
URL of the session server. Defaults to DEFAULT_SERVER_SESSION.
|
||||
headers : Dict[str, any], optional
|
||||
HTTP headers for requests. Defaults to DEFAULT_REQUEST_HEADERS.
|
||||
xml_url : str, optional
|
||||
URL of the software folder tree XML. Defaults to DEFAULT_SOFTWARE_FOLDER_TREE.
|
||||
download_server : str, optional
|
||||
Base URL for downloads. Defaults to DEFAULT_DOWNLOAD_URL.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
self.token: Union[str, None] = token
|
||||
self._session_server = session_server
|
||||
self._headers = headers
|
||||
self._timeout = timeout
|
||||
self._xml_url = xml_url
|
||||
self._download_server = download_server
|
||||
self._session_id = None
|
||||
|
||||
logging.info(f"Initialized AristaServer with headers: {self._headers}")
|
||||
|
||||
def authenticate(self, token: Union[str, None] = None) -> bool:
|
||||
"""Authenticate to the API server using access token.
|
||||
|
||||
The token is encoded in base64 and sent to the server for authentication.
|
||||
A session ID is retrieved from the server response if authentication is successful.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token : Union[str, None], optional
|
||||
Access token for authentication. If None, uses existing token stored in instance. Defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if authentication successful, False otherwise
|
||||
|
||||
Raises
|
||||
------
|
||||
eos_downloader.exceptions.AuthenticationError
|
||||
If access token is invalid or expired
|
||||
"""
|
||||
|
||||
if token is not None:
|
||||
self.token = token
|
||||
if self.token is None:
|
||||
logger.error("No token provided for authentication")
|
||||
return False
|
||||
credentials = (base64.b64encode(self.token.encode())).decode("utf-8")
|
||||
jsonpost = {"accessToken": credentials}
|
||||
result = requests.post(
|
||||
self._session_server,
|
||||
data=json.dumps(jsonpost),
|
||||
timeout=self._timeout,
|
||||
headers=self._headers,
|
||||
)
|
||||
if result.json()["status"]["message"] in [
|
||||
"Access token expired",
|
||||
"Invalid access token",
|
||||
]:
|
||||
logging.critical(
|
||||
f"Authentication failed: {result.json()['status']['message']}"
|
||||
)
|
||||
raise eos_downloader.exceptions.AuthenticationError
|
||||
# return False
|
||||
try:
|
||||
if "data" in result.json():
|
||||
self._session_id = result.json()["data"]["session_code"]
|
||||
logging.info(f"Authenticated with session ID: {self._session_id}")
|
||||
return True
|
||||
except KeyError as error:
|
||||
logger.error(
|
||||
f"Key Error in parsing server response ({result.json()}): {error}"
|
||||
)
|
||||
return False
|
||||
return False
|
||||
|
||||
def get_xml_data(self) -> Union[ET.ElementTree, None]:
|
||||
"""Retrieves XML data from the server.
|
||||
|
||||
This method fetches XML data by making a POST request to the server's XML endpoint.
|
||||
If not already authenticated, it will initiate the authentication process first.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ET.ElementTree
|
||||
An ElementTree object containing the parsed XML data from the server response.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If the server response doesn't contain the expected data structure.
|
||||
|
||||
Notes
|
||||
-----
|
||||
The method requires a valid session ID which is obtained through authentication.
|
||||
The XML data is expected to be in the response JSON under data.xml path.
|
||||
"""
|
||||
|
||||
logging.info(f"Getting XML data from server {self._session_server}")
|
||||
if self._session_id is None:
|
||||
logging.debug("Not authenticated to server, start authentication process")
|
||||
self.authenticate()
|
||||
jsonpost = {"sessionCode": self._session_id}
|
||||
result = requests.post(
|
||||
self._xml_url,
|
||||
data=json.dumps(jsonpost),
|
||||
timeout=self._timeout,
|
||||
headers=self._headers,
|
||||
)
|
||||
try:
|
||||
folder_tree = result.json()["data"]["xml"]
|
||||
logging.debug("XML data received from Arista server")
|
||||
return ET.ElementTree(ET.fromstring(folder_tree))
|
||||
except KeyError as error:
|
||||
logger.error(f"Unkown key in server response: {error}")
|
||||
return None
|
||||
|
||||
def get_url(self, remote_file_path: str) -> Union[str, None]:
|
||||
"""Get download URL for a remote file from server.
|
||||
|
||||
This method retrieves the download URL for a specified remote file by making a POST request
|
||||
to the server. If not authenticated, it will first authenticate before making the request.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
remote_file_path : str
|
||||
Path to the remote file on server to get download URL for
|
||||
|
||||
Returns
|
||||
-------
|
||||
Union[str, None]
|
||||
The download URL if successful, None if request fails or URL not found in response
|
||||
|
||||
Raises
|
||||
------
|
||||
requests.exceptions.RequestException
|
||||
If the request to server fails
|
||||
json.JSONDecodeError
|
||||
If server response is not valid JSON
|
||||
requests.exceptions.Timeout
|
||||
If server request times out
|
||||
"""
|
||||
|
||||
logging.info(f"Getting download URL for {remote_file_path}")
|
||||
if self._session_id is None:
|
||||
logging.debug("Not authenticated to server, start authentication process")
|
||||
self.authenticate()
|
||||
jsonpost = {"sessionCode": self._session_id, "filePath": remote_file_path}
|
||||
result = requests.post(
|
||||
self._download_server,
|
||||
data=json.dumps(jsonpost),
|
||||
timeout=self._timeout,
|
||||
headers=self._headers,
|
||||
)
|
||||
if "data" in result.json() and "url" in result.json()["data"]:
|
||||
# logger.debug('URL to download file is: {}', result.json())
|
||||
logging.info("Download URL received from server")
|
||||
logging.debug(f'URL to download file is: {result.json()["data"]["url"]}')
|
||||
return result.json()["data"]["url"]
|
||||
return None
|
536
eos_downloader/logics/arista_xml_server.py
Normal file
536
eos_downloader/logics/arista_xml_server.py
Normal file
|
@ -0,0 +1,536 @@
|
|||
# coding: utf-8 -*-
|
||||
|
||||
"""This module provides classes for managing and querying Arista XML data.
|
||||
|
||||
Classes:
|
||||
AristaXmlBase: Base class for Arista XML data management.
|
||||
AristaXmlQuerier: Class to query Arista XML data for Software versions.
|
||||
AristaXmlObject: Base class for Arista XML data management with specific software and version.
|
||||
EosXmlObject: Class to query Arista XML data for EOS versions.
|
||||
""" # noqa: E501
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import ClassVar, Union, List, Dict
|
||||
|
||||
import eos_downloader.logics.arista_server
|
||||
import eos_downloader.models.version
|
||||
import eos_downloader.models.data
|
||||
from eos_downloader.models.types import AristaPackage, AristaVersions, AristaMapping
|
||||
|
||||
|
||||
class AristaXmlBase:
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""Base class for Arista XML data management."""
|
||||
|
||||
# File extensions supported to be downloaded from arista server.
|
||||
# Should cover: image file (image) and has files (md5sum and/or sha512sum)
|
||||
supported_role_types: ClassVar[List[str]] = ["image", "md5sum", "sha512sum"]
|
||||
|
||||
def __init__(
|
||||
self, token: Union[str, None] = None, xml_path: Union[str, None] = None
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the AristaXmlBase class.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token : Union[str, None], optional
|
||||
Authentication token. Defaults to None.
|
||||
xml_path : Union[str, None], optional
|
||||
Path to the XML file. Defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
logging.info("Initializing AristXmlBase.")
|
||||
self.server = eos_downloader.logics.arista_server.AristaServer(token=token)
|
||||
if xml_path is not None:
|
||||
try:
|
||||
self.xml_data = ET.parse(xml_path)
|
||||
except ET.ParseError as error:
|
||||
logging.error(f"Error while parsing XML data: {error}")
|
||||
else:
|
||||
if self.server.authenticate():
|
||||
data = self._get_xml_root()
|
||||
if data is None:
|
||||
logging.error("Unable to get XML data from Arista server")
|
||||
raise ValueError("Unable to get XML data from Arista server")
|
||||
self.xml_data = data
|
||||
else:
|
||||
logging.error("Unable to authenticate to Arista server")
|
||||
raise ValueError("Unable to authenticate to Arista server")
|
||||
|
||||
def _get_xml_root(self) -> Union[ET.ElementTree, None]:
|
||||
"""
|
||||
Retrieves the XML root from the Arista server.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Union[ET.ElementTree, None]
|
||||
The XML root element tree if successful, None otherwise.
|
||||
"""
|
||||
logging.info("Getting XML root from Arista server.")
|
||||
try:
|
||||
return self.server.get_xml_data()
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
logging.error(f"Error while getting XML data from Arista server: {error}")
|
||||
return None
|
||||
|
||||
|
||||
class AristaXmlQuerier(AristaXmlBase):
|
||||
"""Class to query Arista XML data for Software versions."""
|
||||
|
||||
def available_public_versions(
|
||||
self,
|
||||
branch: Union[str, None] = None,
|
||||
rtype: Union[str, None] = None,
|
||||
package: AristaPackage = "eos",
|
||||
) -> List[AristaVersions]:
|
||||
"""
|
||||
Get list of available public EOS versions from Arista website.
|
||||
|
||||
This method parses XML data to extract available EOS or CVP versions based on specified criteria.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
branch : Union[str, None], optional
|
||||
Branch number to filter versions (e.g. "4.29"). Defaults to None.
|
||||
rtype : Union[str, None], optional
|
||||
Release type to filter versions. Must be one of the valid release types defined in RTYPES. Defaults to None.
|
||||
package : AristaPackage, optional
|
||||
Type of package to look for - either 'eos' or 'cvp'. Defaults to 'eos'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[AristaVersions]
|
||||
List of version objects (EosVersion or CvpVersion) matching the criteria.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If provided rtype is not in the list of valid release types.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> server.available_public_eos_version(branch="4.29", rtype="INT", package="eos")
|
||||
[EosVersion('4.29.0F-INT'), EosVersion('4.29.1F-INT'), ...]
|
||||
"""
|
||||
|
||||
logging.info(f"Getting available versions for {package} package")
|
||||
|
||||
xpath_query = './/dir[@label="Active Releases"]//dir[@label]'
|
||||
regexp = eos_downloader.models.version.EosVersion.regex_version
|
||||
|
||||
if package == "cvp":
|
||||
xpath_query = './/dir[@label="Active Releases"]//dir[@label]'
|
||||
regexp = eos_downloader.models.version.CvpVersion.regex_version
|
||||
|
||||
package_versions = []
|
||||
|
||||
if rtype is not None and rtype not in eos_downloader.models.data.RTYPES:
|
||||
raise ValueError(
|
||||
f"Invalid release type: {rtype}. Expected one of {eos_downloader.models.data.RTYPES}"
|
||||
)
|
||||
nodes = self.xml_data.findall(xpath_query)
|
||||
for node in nodes:
|
||||
if "label" in node.attrib and node.get("label") is not None:
|
||||
label = node.get("label")
|
||||
if label is not None and regexp.match(label):
|
||||
package_version = None
|
||||
if package == "eos":
|
||||
package_version = (
|
||||
eos_downloader.models.version.EosVersion.from_str(label)
|
||||
)
|
||||
elif package == "cvp":
|
||||
package_version = (
|
||||
eos_downloader.models.version.CvpVersion.from_str(label)
|
||||
)
|
||||
package_versions.append(package_version)
|
||||
if rtype is not None or branch is not None:
|
||||
package_versions = [
|
||||
version
|
||||
for version in package_versions
|
||||
if version is not None
|
||||
and (rtype is None or version.rtype == rtype)
|
||||
and (branch is None or str(version.branch) == branch)
|
||||
]
|
||||
|
||||
return package_versions
|
||||
|
||||
def latest(
|
||||
self,
|
||||
package: eos_downloader.models.types.AristaPackage = "eos",
|
||||
branch: Union[str, None] = None,
|
||||
rtype: Union[eos_downloader.models.types.ReleaseType, None] = None,
|
||||
) -> AristaVersions:
|
||||
"""
|
||||
Get latest branch from semver standpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
package : eos_downloader.models.types.AristaPackage, optional
|
||||
Type of package to look for - either 'eos' or 'cvp'. Defaults to 'eos'.
|
||||
branch : Union[str, None], optional
|
||||
Branch to search for. Defaults to None.
|
||||
rtype : Union[eos_downloader.models.types.ReleaseType, None], optional
|
||||
Release type to search for. Defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AristaVersions
|
||||
Latest version found.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If no versions are found to run the max() function.
|
||||
"""
|
||||
if package == "eos":
|
||||
if rtype is not None and rtype not in eos_downloader.models.data.RTYPES:
|
||||
raise ValueError(
|
||||
f"Invalid release type: {rtype}. Expected {eos_downloader.models.data.RTYPES}"
|
||||
)
|
||||
|
||||
versions = self.available_public_versions(
|
||||
package=package, branch=branch, rtype=rtype
|
||||
)
|
||||
if len(versions) == 0:
|
||||
raise ValueError("No versions found to run the max() function")
|
||||
return max(versions)
|
||||
|
||||
def branches(
|
||||
self,
|
||||
package: eos_downloader.models.types.AristaPackage = "eos",
|
||||
latest: bool = False,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Returns a list of valid EOS version branches.
|
||||
|
||||
The branches are determined based on the available public EOS versions.
|
||||
When latest=True, only the most recent branch is returned.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
package : eos_downloader.models.types.AristaPackage, optional
|
||||
Type of package to look for - either 'eos' or 'cvp'. Defaults to 'eos'.
|
||||
latest : bool, optional
|
||||
If True, returns only the latest branch version. Defaults to False.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[str]
|
||||
A list of branch version strings. Contains single latest version if latest=True,
|
||||
otherwise all available versions sorted descendingly.
|
||||
"""
|
||||
if latest:
|
||||
latest_branch = max(
|
||||
self._get_branches(self.available_public_versions(package=package))
|
||||
)
|
||||
return [str(latest_branch)]
|
||||
return sorted(
|
||||
self._get_branches(self.available_public_versions(package=package)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def _get_branches(
|
||||
self,
|
||||
versions: Union[
|
||||
List[eos_downloader.models.version.EosVersion],
|
||||
List[eos_downloader.models.version.CvpVersion],
|
||||
],
|
||||
) -> List[str]:
|
||||
"""
|
||||
Extracts unique branch names from a list of version objects.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
versions : Union[List[eos_downloader.models.version.EosVersion], List[eos_downloader.models.version.CvpVersion]]
|
||||
A list of version objects, either EosVersion or CvpVersion types.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[str]
|
||||
A list of unique branch names.
|
||||
"""
|
||||
branch = [version.branch for version in versions]
|
||||
return list(set(branch))
|
||||
|
||||
|
||||
class AristaXmlObject(AristaXmlBase):
|
||||
"""Base class for Arista XML data management."""
|
||||
|
||||
software: ClassVar[AristaMapping]
|
||||
base_xpath_active_version: ClassVar[str]
|
||||
base_xpath_filepath: ClassVar[str]
|
||||
checksum_file_extension: ClassVar[str] = "sha512sum"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
searched_version: str,
|
||||
image_type: str,
|
||||
token: Union[str, None] = None,
|
||||
xml_path: Union[str, None] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the AristaXmlObject class.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
searched_version : str
|
||||
The version of the software to search for.
|
||||
image_type : str
|
||||
The type of image to download.
|
||||
token : Union[str, None], optional
|
||||
Authentication token. Defaults to None.
|
||||
xml_path : Union[str, None], optional
|
||||
Path to the XML file. Defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
self.search_version = searched_version
|
||||
self.image_type = image_type
|
||||
super().__init__(token=token, xml_path=xml_path)
|
||||
|
||||
@property
|
||||
def filename(self) -> Union[str, None]:
|
||||
"""
|
||||
Helper to build filename to search on arista.com.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Union[str, None]
|
||||
Filename to search for on Arista.com.
|
||||
"""
|
||||
logging.info(
|
||||
f"Building filename for {self.image_type} package: {self.search_version}."
|
||||
)
|
||||
try:
|
||||
filename = eos_downloader.models.data.software_mapping.filename(
|
||||
self.software, self.image_type, self.search_version
|
||||
)
|
||||
return filename
|
||||
except ValueError as e:
|
||||
logging.error(f"Error: {e}")
|
||||
return None
|
||||
|
||||
def hash_filename(self) -> Union[str, None]:
|
||||
"""
|
||||
Helper to build filename for checksum to search on arista.com.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Union[str, None]
|
||||
Filename to search for on Arista.com.
|
||||
"""
|
||||
|
||||
logging.info(f"Building hash filename for {self.software} package.")
|
||||
|
||||
if self.filename is not None:
|
||||
return f"{self.filename}.{self.checksum_file_extension}"
|
||||
return None
|
||||
|
||||
def path_from_xml(self, search_file: str) -> Union[str, None]:
|
||||
"""
|
||||
Parse XML to find path for a given file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
search_file : str
|
||||
File to search for.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Union[str, None]
|
||||
Path from XML if found, None otherwise.
|
||||
"""
|
||||
|
||||
logging.info(f"Building path from XML for {search_file}.")
|
||||
|
||||
# Build xpath with provided file
|
||||
xpath_query = self.base_xpath_filepath.format(search_file)
|
||||
# Find the element using XPath
|
||||
path_element = self.xml_data.find(xpath_query)
|
||||
|
||||
if path_element is not None:
|
||||
logging.debug(f'found path: {path_element.get("path")} for {search_file}')
|
||||
|
||||
# Return the path if found, otherwise return None
|
||||
return path_element.get("path") if path_element is not None else None
|
||||
|
||||
def _url(self, xml_path: str) -> Union[str, None]:
|
||||
"""
|
||||
Get URL to download a file from Arista server.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
xml_path : str
|
||||
Path to the file in the XML.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Union[str, None]
|
||||
URL to download the file.
|
||||
"""
|
||||
|
||||
logging.info(f"Getting URL for {xml_path}.")
|
||||
|
||||
return self.server.get_url(xml_path)
|
||||
|
||||
@property
|
||||
def urls(self) -> Dict[str, Union[str, None]]:
|
||||
"""
|
||||
Get URLs to download files from Arista server for given software and version.
|
||||
|
||||
This method will return a dictionary with file type as key and URL as value.
|
||||
It returns URL for the following items: 'image', 'md5sum', and 'sha512sum'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Union[str, None]]
|
||||
Dictionary with file type as key and URL as value.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If filename or hash file is not found.
|
||||
"""
|
||||
logging.info(f"Getting URLs for {self.software} package.")
|
||||
|
||||
urls = {}
|
||||
|
||||
if self.filename is None:
|
||||
raise ValueError("Filename not found")
|
||||
|
||||
for role in self.supported_role_types:
|
||||
file_path = None
|
||||
logging.debug(f"working on {role}")
|
||||
hash_filename = self.hash_filename()
|
||||
if hash_filename is None:
|
||||
raise ValueError("Hash file not found")
|
||||
if role == "image":
|
||||
file_path = self.path_from_xml(self.filename)
|
||||
elif role == self.checksum_file_extension:
|
||||
file_path = self.path_from_xml(hash_filename)
|
||||
if file_path is not None:
|
||||
logging.info(f"Adding {role} with {file_path} to urls dict")
|
||||
urls[role] = self._url(file_path)
|
||||
logging.debug(f"URLs dict contains: {urls}")
|
||||
return urls
|
||||
|
||||
|
||||
class EosXmlObject(AristaXmlObject):
|
||||
"""Class to query Arista XML data for EOS versions."""
|
||||
|
||||
software: ClassVar[AristaMapping] = "EOS"
|
||||
base_xpath_active_version: ClassVar[
|
||||
str
|
||||
] = './/dir[@label="Active Releases"]/dir/dir/[@label]'
|
||||
base_xpath_filepath: ClassVar[str] = './/file[.="{}"]'
|
||||
|
||||
# File extensions supported to be downloaded from arista server.
|
||||
# Should cover: image file (image) and has files (md5sum and/or sha512sum)
|
||||
supported_role_types: ClassVar[List[str]] = ["image", "md5sum", "sha512sum"]
|
||||
checksum_file_extension: ClassVar[str] = "sha512sum"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
searched_version: str,
|
||||
image_type: str,
|
||||
token: Union[str, None] = None,
|
||||
xml_path: Union[str, None] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize an instance of the EosXmlObject class.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
searched_version : str
|
||||
The version of the software to search for.
|
||||
image_type : str
|
||||
The type of image to download.
|
||||
token : Union[str, None], optional
|
||||
The authentication token. Defaults to None.
|
||||
xml_path : Union[str, None], optional
|
||||
The path to the XML file. Defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
|
||||
self.search_version = searched_version
|
||||
self.image_type = image_type
|
||||
self.version = eos_downloader.models.version.EosVersion().from_str(
|
||||
searched_version
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
searched_version=searched_version,
|
||||
image_type=image_type,
|
||||
token=token,
|
||||
xml_path=xml_path,
|
||||
)
|
||||
|
||||
|
||||
class CvpXmlObject(AristaXmlObject):
|
||||
"""Class to query Arista XML data for CVP versions."""
|
||||
|
||||
software: ClassVar[AristaMapping] = "CloudVision"
|
||||
base_xpath_active_version: ClassVar[
|
||||
str
|
||||
] = './/dir[@label="Active Releases"]/dir/dir/[@label]'
|
||||
base_xpath_filepath: ClassVar[str] = './/file[.="{}"]'
|
||||
|
||||
# File extensions supported to be downloaded from arista server.
|
||||
# Should cover: image file (image) and has files (md5sum and/or sha512sum)
|
||||
supported_role_types: ClassVar[List[str]] = ["image", "md5"]
|
||||
checksum_file_extension: ClassVar[str] = "md5"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
searched_version: str,
|
||||
image_type: str,
|
||||
token: Union[str, None] = None,
|
||||
xml_path: Union[str, None] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize an instance of the CvpXmlObject class.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
searched_version : str
|
||||
The version of the software to search for.
|
||||
image_type : str
|
||||
The type of image to download.
|
||||
token : Union[str, None], optional
|
||||
The authentication token. Defaults to None.
|
||||
xml_path : Union[str, None], optional
|
||||
The path to the XML file. Defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
|
||||
self.search_version = searched_version
|
||||
self.image_type = image_type
|
||||
self.version = eos_downloader.models.version.CvpVersion().from_str(
|
||||
searched_version
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
searched_version=searched_version,
|
||||
image_type=image_type,
|
||||
token=token,
|
||||
xml_path=xml_path,
|
||||
)
|
||||
|
||||
|
||||
# Create the custom type
|
||||
AristaXmlObjects = Union[CvpXmlObject, EosXmlObject]
|
494
eos_downloader/logics/download.py
Normal file
494
eos_downloader/logics/download.py
Normal file
|
@ -0,0 +1,494 @@
|
|||
# coding: utf-8 -*-
|
||||
"""ObjectDownloader class to manage file downloads with an option to use rich interface.
|
||||
|
||||
This class provides methods to download files from URLs with progress tracking using either
|
||||
tqdm or rich interface. It supports both raw downloads and enhanced visual feedback during
|
||||
the download process.
|
||||
|
||||
Methods
|
||||
--------
|
||||
download_file(url: str, file_path: str, filename: str, rich_interface: bool = True) -> Union[None, str]
|
||||
Downloads a file from the given URL to the specified path with optional rich interface.
|
||||
|
||||
_download_file_raw(url: str, file_path: str) -> str
|
||||
Static method that performs the actual file download with tqdm progress bar.
|
||||
|
||||
Attributes
|
||||
--------
|
||||
None
|
||||
|
||||
Example
|
||||
--------
|
||||
>>> downloader = ObjectDownloader()
|
||||
>>> result = downloader.download_file(
|
||||
... url='http://example.com/file.zip',
|
||||
... file_path='/downloads',
|
||||
... filename='file.zip',
|
||||
... rich_interface=True
|
||||
... )
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import hashlib
|
||||
from typing import Union, Literal, Dict
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
import eos_downloader.models.types
|
||||
import eos_downloader.defaults
|
||||
import eos_downloader.helpers
|
||||
import eos_downloader.logics
|
||||
import eos_downloader.logics.arista_xml_server
|
||||
import eos_downloader.models.version
|
||||
|
||||
|
||||
class SoftManager:
|
||||
"""SoftManager helps to download files from a remote location.
|
||||
|
||||
This class provides methods to download files using either a simple progress bar
|
||||
or a rich interface with enhanced visual feedback.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> downloader = SoftManager()
|
||||
>>> downloader.download_file(
|
||||
... url="http://example.com/file.txt",
|
||||
... file_path="/tmp",
|
||||
... filename="file.txt"
|
||||
... )
|
||||
'/tmp/file.txt'
|
||||
"""
|
||||
|
||||
def __init__(self, dry_run: bool = False) -> None:
|
||||
self.file: Dict[str, Union[str, None]] = {}
|
||||
self.file["name"] = None
|
||||
self.file["md5sum"] = None
|
||||
self.file["sha512sum"] = None
|
||||
self.dry_run = dry_run
|
||||
logging.info("SoftManager initialized%s", " in dry-run mode" if dry_run else "")
|
||||
|
||||
@staticmethod
|
||||
def _download_file_raw(url: str, file_path: str) -> str:
|
||||
"""Downloads a file from a URL and saves it to a local file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
url : str
|
||||
The URL of the file to download.
|
||||
file_path : str
|
||||
The local path where the file will be saved.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The path to the downloaded file.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- Uses requests library to stream download in chunks of 1024 bytes
|
||||
- Shows download progress using tqdm progress bar
|
||||
- Sets timeout of 5 seconds for initial connection
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _create_destination_folder(path: str) -> None:
|
||||
"""Creates a directory path if it doesn't already exist.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : str
|
||||
The directory path to create.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError as e:
|
||||
logging.critical(f"Error creating folder: {e}")
|
||||
|
||||
def _compute_hash_md5sum(self, file: str, hash_expected: str) -> bool:
|
||||
"""
|
||||
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:
|
||||
while True:
|
||||
chunk = f.read(4096)
|
||||
if not chunk:
|
||||
break
|
||||
hash_md5.update(chunk)
|
||||
if hash_md5.hexdigest() == hash_expected:
|
||||
return True
|
||||
logging.warning(
|
||||
f"Downloaded file is corrupt: local md5 ({hash_md5.hexdigest()}) is different to md5 from arista ({hash_expected})"
|
||||
)
|
||||
return False
|
||||
|
||||
def checksum(self, check_type: Literal["md5sum", "sha512sum", "md5"]) -> bool:
|
||||
"""
|
||||
Verifies the integrity of a downloaded file using a specified checksum algorithm.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
check_type : Literal['md5sum', 'sha512sum', 'md5']
|
||||
The type of checksum to perform. Currently supports 'md5sum' or 'sha512sum'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the checksum verification passes.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the calculated checksum does not match the expected checksum.
|
||||
FileNotFoundError
|
||||
If either the checksum file or the target file cannot be found.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> client.checksum('sha512sum') # Returns True if checksum matches
|
||||
"""
|
||||
logging.info(f"Checking checksum for {self.file['name']} using {check_type}")
|
||||
|
||||
if self.dry_run:
|
||||
logging.debug("Dry-run mode enabled, skipping checksum verification")
|
||||
return True
|
||||
|
||||
if check_type == "sha512sum":
|
||||
hash_sha512 = hashlib.sha512()
|
||||
hash512sum = self.file["sha512sum"]
|
||||
file_name = self.file["name"]
|
||||
|
||||
logging.debug(f"checksum sha512sum file is: {hash512sum}")
|
||||
|
||||
if file_name is None or hash512sum is None:
|
||||
logging.error("File or checksum not found")
|
||||
raise ValueError("File or checksum not found")
|
||||
|
||||
with open(hash512sum, "r", encoding="utf-8") as f:
|
||||
hash_expected = f.read().split()[0]
|
||||
with open(file_name, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(4096)
|
||||
if not chunk:
|
||||
break
|
||||
hash_sha512.update(chunk)
|
||||
if hash_sha512.hexdigest() != hash_expected:
|
||||
logging.error(
|
||||
f"Checksum failed for {self.file['name']}: computed {hash_sha512.hexdigest()} - expected {hash_expected}"
|
||||
)
|
||||
raise ValueError("Incorrect checksum")
|
||||
return True
|
||||
|
||||
if check_type in ["md5sum", "md5"]:
|
||||
md5sum_file = self.file["md5sum"]
|
||||
file_name = self.file["name"]
|
||||
|
||||
if md5sum_file is None:
|
||||
raise ValueError(f"md5sum is not found: {md5sum_file}")
|
||||
|
||||
with open(md5sum_file, "r", encoding="utf-8") as f:
|
||||
hash_expected = f.read().split()[0]
|
||||
|
||||
if hash_expected is None:
|
||||
raise ValueError("MD5Sum is empty, cannot compute file.")
|
||||
|
||||
if file_name is None:
|
||||
raise ValueError("Filename is None. Please fix it")
|
||||
|
||||
if not self._compute_hash_md5sum(file_name, hash_expected=hash_expected):
|
||||
logging.error(
|
||||
f"Checksum failed for {self.file['name']}: expected {hash_expected}"
|
||||
)
|
||||
|
||||
raise ValueError("Incorrect checksum")
|
||||
|
||||
return True
|
||||
|
||||
logging.error(f"Checksum type {check_type} not yet supported")
|
||||
raise ValueError(f"Checksum type {check_type} not yet supported")
|
||||
|
||||
def download_file(
|
||||
self, url: str, file_path: str, filename: str, rich_interface: bool = True
|
||||
) -> Union[None, str]:
|
||||
"""
|
||||
Downloads a file from a given URL to a specified location.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
url : str
|
||||
The URL from which to download the file.
|
||||
file_path : str
|
||||
The directory path where the file should be saved.
|
||||
filename : str
|
||||
The name to be given to the downloaded file.
|
||||
rich_interface : bool, optional
|
||||
Whether to use rich progress bar interface. Defaults to True.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Union[None, str]
|
||||
The full path to the downloaded file if successful, None if download fails.
|
||||
"""
|
||||
logging.info(
|
||||
f"{'[DRY-RUN] Would download' if self.dry_run else 'Downloading'} {filename} from {url}"
|
||||
)
|
||||
if self.dry_run:
|
||||
return os.path.join(file_path, filename)
|
||||
|
||||
if url is not False:
|
||||
if not rich_interface:
|
||||
return self._download_file_raw(
|
||||
url=url, file_path=os.path.join(file_path, filename)
|
||||
)
|
||||
rich_downloader = eos_downloader.helpers.DownloadProgressBar()
|
||||
rich_downloader.download(urls=[url], dest_dir=file_path)
|
||||
return os.path.join(file_path, filename)
|
||||
logging.error(f"Cannot download file {file_path}")
|
||||
return None
|
||||
|
||||
def downloads(
|
||||
self,
|
||||
object_arista: eos_downloader.logics.arista_xml_server.AristaXmlObjects,
|
||||
file_path: str,
|
||||
rich_interface: bool = True,
|
||||
) -> Union[None, str]:
|
||||
"""
|
||||
Downloads files from Arista EOS server.
|
||||
|
||||
Downloads the EOS image and optional md5/sha512 files based on the provided EOS XML object.
|
||||
Each file is downloaded to the specified path with appropriate filenames.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
object_arista : eos_downloader.logics.arista_xml_server.AristaXmlObjects
|
||||
Object containing EOS image and hash file URLs.
|
||||
file_path : str
|
||||
Directory path where files should be downloaded.
|
||||
rich_interface : bool, optional
|
||||
Whether to use rich console output. Defaults to True.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Union[None, str]
|
||||
The file path where files were downloaded, or None if download failed.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> client.downloads(eos_obj, "/tmp/downloads")
|
||||
'/tmp/downloads'
|
||||
"""
|
||||
logging.info(f"Downloading files from {object_arista.version}")
|
||||
|
||||
if len(object_arista.urls) == 0:
|
||||
logging.error("No URLs found for download")
|
||||
raise ValueError("No URLs found for download")
|
||||
|
||||
for file_type, url in sorted(object_arista.urls.items(), reverse=True):
|
||||
logging.debug(f"Downloading {file_type} from {url}")
|
||||
if file_type == "image":
|
||||
filename = object_arista.filename
|
||||
self.file["name"] = filename
|
||||
else:
|
||||
filename = object_arista.hash_filename()
|
||||
self.file[file_type] = filename
|
||||
if url is None:
|
||||
logging.error(f"URL not found for {file_type}")
|
||||
raise ValueError(f"URL not found for {file_type}")
|
||||
if filename is None:
|
||||
logging.error(f"Filename not found for {file_type}")
|
||||
raise ValueError(f"Filename not found for {file_type}")
|
||||
if not self.dry_run:
|
||||
logging.info(
|
||||
f"downloading file {filename} for version {object_arista.version}"
|
||||
)
|
||||
self.download_file(url, file_path, filename, rich_interface)
|
||||
else:
|
||||
logging.info(
|
||||
f"[DRY-RUN] - downloading file {filename} for version {object_arista.version}"
|
||||
)
|
||||
|
||||
return file_path
|
||||
|
||||
def import_docker(
|
||||
self,
|
||||
local_file_path: str,
|
||||
docker_name: str = "arista/ceos",
|
||||
docker_tag: str = "latest",
|
||||
) -> None:
|
||||
"""
|
||||
Import a local file into a Docker image.
|
||||
|
||||
This method imports a local file into Docker with a specified image name and tag.
|
||||
It checks for the existence of both the local file and docker binary before proceeding.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
local_file_path : str
|
||||
Path to the local file to import.
|
||||
docker_name : str, optional
|
||||
Name for the Docker image. Defaults to 'arista/ceos'.
|
||||
docker_tag : str, optional
|
||||
Tag for the Docker image. Defaults to 'latest'.
|
||||
|
||||
Raises
|
||||
------
|
||||
FileNotFoundError
|
||||
If the local file doesn't exist or docker binary is not found.
|
||||
Exception
|
||||
If the docker import operation fails.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
|
||||
logging.info(
|
||||
f"Importing {local_file_path} to {docker_name}:{docker_tag} in local docker enginge"
|
||||
)
|
||||
|
||||
if os.path.exists(local_file_path) is False:
|
||||
raise FileNotFoundError(f"File {local_file_path} not found")
|
||||
if not shutil.which("docker"):
|
||||
raise FileNotFoundError(f"File {local_file_path} not found")
|
||||
|
||||
try:
|
||||
cmd = f"$(which docker) import {local_file_path} {docker_name}:{docker_tag}"
|
||||
if self.dry_run:
|
||||
logging.info(f"[DRY-RUN] Would execute: {cmd}")
|
||||
else:
|
||||
logging.debug("running docker import process")
|
||||
os.system(cmd)
|
||||
except Exception as e:
|
||||
logging.error(f"Error importing docker image: {e}")
|
||||
raise e
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def provision_eve(
|
||||
self,
|
||||
object_arista: eos_downloader.logics.arista_xml_server.EosXmlObject,
|
||||
noztp: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Provisions EVE-NG with the specified Arista EOS object.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
object_arista : eos_downloader.logics.arista_xml_server.EosXmlObject
|
||||
The Arista EOS object containing version, filename, and URLs.
|
||||
noztp : bool, optional
|
||||
If True, disables ZTP (Zero Touch Provisioning). Defaults to False.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If no URLs are found for download or if a URL or filename is None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
|
||||
# EVE-NG provisioning page for vEOS
|
||||
# https://www.eve-ng.net/index.php/documentation/howtos/howto-add-arista-veos/
|
||||
|
||||
logging.info(
|
||||
f"Provisioning EVE-NG with {object_arista.version} / {object_arista.filename}"
|
||||
)
|
||||
|
||||
file_path = f"{eos_downloader.defaults.EVE_QEMU_FOLDER_PATH}/veos-{object_arista.version}"
|
||||
|
||||
filename: Union[str, None] = None
|
||||
eos_filename = object_arista.filename
|
||||
|
||||
if len(object_arista.urls) == 0:
|
||||
logging.error("No URLs found for download")
|
||||
raise ValueError("No URLs found for download")
|
||||
|
||||
for file_type, url in sorted(object_arista.urls.items(), reverse=True):
|
||||
logging.debug(f"Downloading {file_type} from {url}")
|
||||
if file_type == "image":
|
||||
fname = object_arista.filename
|
||||
if fname is not None:
|
||||
filename = fname
|
||||
if noztp:
|
||||
filename = f"{os.path.splitext(fname)[0]}-noztp{os.path.splitext(fname)[1]}"
|
||||
eos_filename = filename
|
||||
logging.debug(f"filename is {filename}")
|
||||
self.file["name"] = filename
|
||||
else:
|
||||
filename = object_arista.hash_filename()
|
||||
if filename is not None:
|
||||
self.file[file_type] = filename
|
||||
if url is None:
|
||||
logging.error(f"URL not found for {file_type}")
|
||||
raise ValueError(f"URL not found for {file_type}")
|
||||
if filename is None:
|
||||
logging.error(f"Filename not found for {file_type}")
|
||||
raise ValueError(f"Filename not found for {file_type}")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
logging.warning(f"creating folder on eve-ng server : {file_path}")
|
||||
self._create_destination_folder(path=file_path)
|
||||
|
||||
logging.debug(
|
||||
f"downloading file {filename} for version {object_arista.version}"
|
||||
)
|
||||
self.download_file(url, file_path, filename, rich_interface=True)
|
||||
|
||||
# Convert to QCOW2 format
|
||||
file_qcow2 = os.path.join(file_path, "hda.qcow2")
|
||||
|
||||
if not self.dry_run:
|
||||
os.system(
|
||||
f"$(which qemu-img) convert -f vmdk -O qcow2 {file_path}/{eos_filename} {file_path}/{file_qcow2}"
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
f"{'[DRY-RUN] Would convert' if self.dry_run else 'Converting'} VMDK to QCOW2 format: {file_path}/{eos_filename} to {file_qcow2} "
|
||||
)
|
||||
|
||||
logging.info("Applying unl_wrapper to fix permissions")
|
||||
if not self.dry_run:
|
||||
os.system("/opt/unetlab/wrappers/unl_wrapper -a fixpermissions")
|
||||
else:
|
||||
logging.info("[DRY-RUN] Would execute unl_wrapper to fix permissions")
|
||||
# os.system(f"rm -f {file_downloaded}")
|
||||
|
||||
# if noztp:
|
||||
# self._disable_ztp(file_path=file_path)
|
140
eos_downloader/models/data.py
Normal file
140
eos_downloader/models/data.py
Normal file
|
@ -0,0 +1,140 @@
|
|||
# coding: utf-8 -*-
|
||||
"""This module defines data models and mappings for image types of CloudVision and EOS on Arista.com.
|
||||
|
||||
Classes
|
||||
-------
|
||||
ImageInfo:
|
||||
A Pydantic model representing image information for a specific image type.
|
||||
DataMapping:
|
||||
A Pydantic model representing data mapping for image types of CloudVision and EOS on Arista.com.
|
||||
|
||||
Methods
|
||||
-------
|
||||
DataMapping.filename(software: AristaMapping, image_type: str, version: str) -> str:
|
||||
Generates a filename based on the provided software, image type, and version.
|
||||
|
||||
Constants
|
||||
-------
|
||||
- RTYPE_FEATURE (ReleaseType): Represents a feature release type.
|
||||
- RTYPE_MAINTENANCE (ReleaseType): Represents a maintenance release type.
|
||||
- RTYPES (List[ReleaseType]): A list containing the feature and maintenance release types.
|
||||
|
||||
Variables
|
||||
-------
|
||||
- software_mapping (DataMapping): An instance of DataMapping containing the mappings for CloudVision and EOS image types.
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from eos_downloader.models.types import AristaMapping, ReleaseType
|
||||
|
||||
|
||||
RTYPE_FEATURE: ReleaseType = "F"
|
||||
RTYPE_MAINTENANCE: ReleaseType = "M"
|
||||
RTYPES: List[ReleaseType] = [RTYPE_FEATURE, RTYPE_MAINTENANCE]
|
||||
|
||||
|
||||
class ImageInfo(BaseModel):
|
||||
"""Image information for a specific image type.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
extension : str
|
||||
The file extension for the image type.
|
||||
prepend : str
|
||||
The prefix to prepend to the filename.
|
||||
"""
|
||||
|
||||
extension: str
|
||||
prepend: str
|
||||
|
||||
|
||||
class DataMapping(BaseModel):
|
||||
"""Data mapping for image types of CloudVision and EOS on Arista.com.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
CloudVision : Dict[str, ImageInfo]
|
||||
Mapping of image types to their information for CloudVision.
|
||||
EOS : Dict[str, ImageInfo]
|
||||
Mapping of image types to their information for EOS.
|
||||
|
||||
Methods
|
||||
-------
|
||||
filename(software: AristaMapping, image_type: str, version: str) -> str
|
||||
Generates a filename based on the provided software, image type, and version.
|
||||
"""
|
||||
|
||||
CloudVision: Dict[str, ImageInfo]
|
||||
EOS: Dict[str, ImageInfo]
|
||||
|
||||
def filename(self, software: AristaMapping, image_type: str, version: str) -> str:
|
||||
"""Generates a filename based on the provided software, image type, and version.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
software : AristaMapping
|
||||
The name of the software for which the filename is being generated.
|
||||
image_type : str
|
||||
The type of image for which the filename is being generated.
|
||||
version : str
|
||||
The version of the software or image.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The generated filename.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the software does not have a corresponding mapping.
|
||||
ValueError
|
||||
If no configuration is found for the given image type and no default configuration is available.
|
||||
"""
|
||||
|
||||
if hasattr(self, software):
|
||||
soft_mapping = getattr(self, software)
|
||||
image_config = soft_mapping.get(image_type, None)
|
||||
if image_config is None:
|
||||
image_config = getattr(soft_mapping, "default", None)
|
||||
if image_config is None:
|
||||
raise ValueError(
|
||||
f"No default configuration found for image type {image_type}"
|
||||
)
|
||||
if image_config is not None:
|
||||
return f"{image_config.prepend}-{version}{image_config.extension}"
|
||||
raise ValueError(f"No configuration found for image type {image_type}")
|
||||
raise ValueError(f"Incorrect value for software {software}")
|
||||
|
||||
|
||||
# Data mapping for image types of CloudVision and EOS on Arista.com.
|
||||
software_mapping = DataMapping(
|
||||
CloudVision={
|
||||
"ova": {"extension": ".ova", "prepend": "cvp"},
|
||||
"rpm": {"extension": "", "prepend": "cvp-rpm-installer"},
|
||||
"kvm": {"extension": "-kvm.tgz", "prepend": "cvp"},
|
||||
"upgrade": {"extension": ".tgz", "prepend": "cvp-upgrade"},
|
||||
},
|
||||
EOS={
|
||||
"64": {"extension": ".swi", "prepend": "EOS64"},
|
||||
"INT": {"extension": "-INT.swi", "prepend": "EOS"},
|
||||
"2GB-INT": {"extension": "-INT.swi", "prepend": "EOS-2GB"},
|
||||
"cEOS": {"extension": ".tar.xz", "prepend": "cEOS-lab"},
|
||||
"cEOS64": {"extension": ".tar.xz", "prepend": "cEOS64-lab"},
|
||||
"vEOS": {"extension": ".vmdk", "prepend": "vEOS"},
|
||||
"vEOS-lab": {"extension": ".vmdk", "prepend": "vEOS-lab"},
|
||||
"EOS-2GB": {"extension": ".swi", "prepend": "EOS-2GB"},
|
||||
"RN": {"extension": "-", "prepend": "RN"},
|
||||
"SOURCE": {"extension": "-source.tar", "prepend": "EOS"},
|
||||
"default": {"extension": ".swi", "prepend": "EOS"},
|
||||
},
|
||||
)
|
||||
|
||||
# List of supported format for EOS software packages
|
||||
eos_package_format = software_mapping.EOS.keys()
|
||||
|
||||
# List of supported format for CloudVision software packages
|
||||
cvp_package_format = software_mapping.EOS.keys()
|
52
eos_downloader/models/types.py
Normal file
52
eos_downloader/models/types.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/python
|
||||
# coding: utf-8 -*-
|
||||
"""
|
||||
This module defines type aliases and literals used in the eos_downloader project.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
AristaPackage : Literal
|
||||
Literal type for Arista package types. Can be either "eos" or "cvp".
|
||||
AristaMapping : Literal
|
||||
Literal type for Arista mapping types. Can be either "CloudVision" or "EOS".
|
||||
AristaVersions : Union
|
||||
Union type for supported SemVer object types. Can be either EosVersion or CvpVersion.
|
||||
ReleaseType : Literal
|
||||
Literal type for release types. Can be either "M" (maintenance) or "F" (feature).
|
||||
|
||||
Examples
|
||||
--------
|
||||
# Example usage of AristaPackage
|
||||
def get_package_type(package: AristaPackage):
|
||||
if package == "eos":
|
||||
return "Arista EOS package"
|
||||
elif package == "cvp":
|
||||
return "CloudVision Portal package"
|
||||
|
||||
# Example usage of AristaVersions
|
||||
def print_version(version: AristaVersions):
|
||||
print(f"Version: {version}")
|
||||
|
||||
# Example usage of ReleaseType
|
||||
def is_feature_release(release: ReleaseType) -> bool:
|
||||
return release == "F"
|
||||
"""
|
||||
|
||||
from typing import Literal, Union
|
||||
|
||||
import eos_downloader.logics
|
||||
|
||||
# import eos_downloader.logics.arista_server
|
||||
import eos_downloader.models.version
|
||||
|
||||
# Define the product type using Literal
|
||||
AristaPackage = Literal["eos", "cvp"]
|
||||
AristaMapping = Literal["CloudVision", "EOS"]
|
||||
|
||||
# Define list of support SemVer object type
|
||||
AristaVersions = Union[
|
||||
eos_downloader.models.version.EosVersion, eos_downloader.models.version.CvpVersion
|
||||
]
|
||||
|
||||
# List of supported release codes
|
||||
ReleaseType = Literal["M", "F"]
|
|
@ -1,13 +1,67 @@
|
|||
#!/usr/bin/python
|
||||
# coding: utf-8 -*-
|
||||
"""The module implements version management following semantic versioning principles with custom adaptations for
|
||||
Arista EOS and CloudVision Portal (CVP) software versioning schemes.
|
||||
|
||||
"""Module for EOS version management"""
|
||||
Classes
|
||||
-------
|
||||
SemVer:
|
||||
Base class implementing semantic versioning with comparison and matching capabilities.
|
||||
EosVersion:
|
||||
Specialized version handling for Arista EOS software releases.
|
||||
CvpVersion:
|
||||
Specialized version handling for CloudVision Portal releases.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
major : int
|
||||
Major version number.
|
||||
minor : int
|
||||
Minor version number.
|
||||
patch : int
|
||||
Patch version number.
|
||||
rtype : Optional[str]
|
||||
Release type (e.g., 'M' for maintenance, 'F' for feature).
|
||||
other : Any
|
||||
Additional version information.
|
||||
regex_version : ClassVar[Pattern[str]]
|
||||
Regular expression to extract version information.
|
||||
regex_branch : ClassVar[Pattern[str]]
|
||||
Regular expression to extract branch information.
|
||||
description : str
|
||||
A basic description of this class.
|
||||
|
||||
|
||||
Examples
|
||||
--------
|
||||
# Basic SemVer usage:
|
||||
>>> version = SemVer(major=4, minor=23, patch=3)
|
||||
'4.23.3'
|
||||
|
||||
# EOS version handling:
|
||||
>>> eos = EosVersion.from_str('4.23.3M')
|
||||
>>> eos.branch
|
||||
'4.23'
|
||||
|
||||
# CVP version handling:
|
||||
>>> cvp = CvpVersion.from_str('2024.1.0')
|
||||
>>> str(cvp)
|
||||
|
||||
The module enforces version format validation through regular expressions and provides
|
||||
comprehensive comparison operations (==, !=, <, <=, >, >=) between versions.
|
||||
|
||||
Note:
|
||||
--------
|
||||
- EOS versions follow the format: <major>.<minor>.<patch>[M|F]
|
||||
- CVP versions follow the format: <year>.<minor>.<patch>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing
|
||||
from typing import Any, Optional
|
||||
import logging
|
||||
from typing import Any, Optional, Pattern, ClassVar
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
@ -16,163 +70,188 @@ 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]
|
||||
class SemVer(BaseModel):
|
||||
"""A class to represent a Semantic Version (SemVer) based on pydanntic.
|
||||
|
||||
# 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])*$"
|
||||
)
|
||||
This class provides methods to parse, compare, and manipulate semantic versions.
|
||||
It supports standard semantic versioning with optional release type and additional version information.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> version = SemVer(major=4, minor=23, patch=3, rtype="M")
|
||||
>>> str(version)
|
||||
'4.23.3M'
|
||||
|
||||
class EosVersion(BaseModel):
|
||||
"""
|
||||
EosVersion object to play with version management in code
|
||||
>>> version2 = SemVer.from_str('4.24.1F')
|
||||
>>> version2.branch
|
||||
'4.24'
|
||||
|
||||
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:
|
||||
>>> version < version2
|
||||
True
|
||||
|
||||
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
|
||||
>>> version.match("<=4.24.0")
|
||||
True
|
||||
|
||||
>>> other_version = EosVersion.from_str(other_version_str)
|
||||
>>> print(f'eos_version < other_version: {eos_version < other_version}')
|
||||
eos_version < other_version: True
|
||||
>>> version.is_in_branch("4.23")
|
||||
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
|
||||
Attributes
|
||||
----------
|
||||
major : int
|
||||
Major version number.
|
||||
minor : int
|
||||
Minor version number.
|
||||
patch : int
|
||||
Patch version number.
|
||||
rtype : Optional[str]
|
||||
Release type (e.g., 'M' for maintenance, 'F' for feature).
|
||||
other : Any
|
||||
Additional version information.
|
||||
regex_version : ClassVar[Pattern[str]]
|
||||
Regular expression to extract version information.
|
||||
regex_branch : ClassVar[Pattern[str]]
|
||||
Regular expression to extract branch information.
|
||||
description : str
|
||||
A basic description of this class.
|
||||
"""
|
||||
|
||||
major: int = 4
|
||||
major: int = 0
|
||||
minor: int = 0
|
||||
patch: int = 0
|
||||
rtype: Optional[str] = "F"
|
||||
rtype: Optional[str] = None
|
||||
other: Any = None
|
||||
# Regular Expression to extract version information.
|
||||
regex_version: ClassVar[Pattern[str]] = re.compile(
|
||||
r"^.*(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d{1,2})(?P<other>\.\d*)*(?P<rtype>[M,F])*$"
|
||||
)
|
||||
regex_branch: ClassVar[Pattern[str]] = re.compile(
|
||||
r"^.*(?P<major>\d+)\.(?P<minor>\d+)(\.?P<patch>\d)*(\.\d)*(?P<rtype>[M,F])*$"
|
||||
)
|
||||
# A Basic description of this class
|
||||
description: str = "A Generic SemVer implementation"
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, eos_version: str) -> EosVersion:
|
||||
def from_str(cls, semver: str) -> SemVer:
|
||||
"""Parse a string into a SemVer object.
|
||||
|
||||
This method parses a semantic version string or branch name into a SemVer object.
|
||||
It supports both standard semver format (x.y.z) and branch format.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
semver : str
|
||||
The version string to parse. Can be either a semantic version
|
||||
string (e.g., "1.2.3") or a branch format.
|
||||
|
||||
Returns
|
||||
-------
|
||||
SemVer
|
||||
A SemVer object representing the parsed version.
|
||||
Returns an empty SemVer object if parsing fails.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> SemVer.from_str("1.2.3")
|
||||
SemVer(major=1, minor=2, patch=3)
|
||||
>>> SemVer.from_str("branch-1.2.3")
|
||||
SemVer(major=1, minor=2, patch=3)
|
||||
"""
|
||||
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
|
||||
logging.debug(f"Creating SemVer object from string: {semver}")
|
||||
|
||||
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)
|
||||
if cls.regex_version.match(semver):
|
||||
matches = cls.regex_version.match(semver)
|
||||
# assert matches is not None
|
||||
assert matches is not None
|
||||
logging.debug(f"Matches version: {matches}")
|
||||
return cls(**matches.groupdict())
|
||||
if REGEX_EOS_BRANCH.match(eos_version):
|
||||
matches = REGEX_EOS_BRANCH.match(eos_version)
|
||||
if cls.regex_branch.match(semver):
|
||||
matches = cls.regex_branch.match(semver)
|
||||
# assert matches is not None
|
||||
assert matches is not None
|
||||
logging.debug(f"Matches branch: {matches}")
|
||||
return cls(**matches.groupdict())
|
||||
logger.error(f"Error occured with {eos_version}")
|
||||
return EosVersion()
|
||||
logging.error(f"Error occured with {semver}")
|
||||
return SemVer()
|
||||
|
||||
@property
|
||||
def branch(self) -> str:
|
||||
"""
|
||||
Extract branch of version
|
||||
Extract branch of version.
|
||||
|
||||
Returns:
|
||||
str: branch from version
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Branch from version.
|
||||
"""
|
||||
return f"{self.major}.{self.minor}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Standard str representation
|
||||
Standard str representation.
|
||||
|
||||
Return string for EOS version like 4.23.3M
|
||||
Return string for EOS version like 4.23.3M.
|
||||
|
||||
Returns:
|
||||
str: A standard EOS version string representing <MAJOR>.<MINOR>.<PATCH><RTYPE>
|
||||
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}"
|
||||
return f"{self.major}.{self.minor}.{self.patch}{self.other if self.other is not None else ''}{self.rtype if self.rtype is not None else ''}"
|
||||
|
||||
def _compare(self, other: EosVersion) -> float:
|
||||
def _compare(self, other: SemVer) -> float:
|
||||
"""
|
||||
An internal comparison function to compare 2 EosVersion objects
|
||||
An internal comparison function to compare 2 EosVersion objects.
|
||||
|
||||
Do a deep comparison from Major to Release Type
|
||||
The return value is
|
||||
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
|
||||
- zero if ver1 == ver2,
|
||||
- strictly positive if ver1 > ver2.
|
||||
|
||||
Args:
|
||||
other (EosVersion): An EosVersion to compare with this object
|
||||
Parameters
|
||||
----------
|
||||
other : SemVer
|
||||
An EosVersion to compare with this object.
|
||||
|
||||
Raises:
|
||||
ValueError: Raise ValueError if input is incorrect type
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
Raise ValueError if input is incorrect type.
|
||||
|
||||
Returns:
|
||||
float: -1 if ver1 < ver2, 0 if ver1 == ver2, 1 if ver1 > ver2
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
-1 if ver1 < ver2, 0 if ver1 == ver2, 1 if ver1 > ver2.
|
||||
"""
|
||||
if not isinstance(other, EosVersion):
|
||||
if not isinstance(other, SemVer):
|
||||
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
|
||||
and self.model_dump()[key] is None
|
||||
or other.model_dump()[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]:
|
||||
if (
|
||||
comparison_flag == 0
|
||||
and self.model_dump()[key] < other.model_dump()[key]
|
||||
):
|
||||
comparison_flag = -1
|
||||
if comparison_flag == 0 and self.dict()[key] > other.dict()[key]:
|
||||
if (
|
||||
comparison_flag == 0
|
||||
and self.model_dump()[key] > other.model_dump()[key]
|
||||
):
|
||||
comparison_flag = 1
|
||||
if comparison_flag != 0:
|
||||
logger.info(f"comparison result is {comparison_flag}")
|
||||
logging.debug(
|
||||
f"Comparison flag {self.model_dump()[key]} with {other.model_dump()[key]}: {comparison_flag}"
|
||||
)
|
||||
return comparison_flag
|
||||
logger.info(f"comparison result is {comparison_flag}")
|
||||
return comparison_flag
|
||||
|
||||
@typing.no_type_check
|
||||
|
@ -214,26 +293,33 @@ class EosVersion(BaseModel):
|
|||
"""
|
||||
Compare self to match a match expression.
|
||||
|
||||
Example:
|
||||
Parameters
|
||||
----------
|
||||
match_expr : str
|
||||
Optional operator and version; valid operators are:
|
||||
``<`` smaller than
|
||||
``>`` greater than
|
||||
``>=`` greater 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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> 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 (">=", "<=", "==", "!="):
|
||||
|
@ -251,7 +337,6 @@ class EosVersion(BaseModel):
|
|||
"['<', '>', '==', '<=', '>=', '!=']. "
|
||||
f"You provided: {match_expr}"
|
||||
)
|
||||
logger.debug(f"work on comparison {prefix} with base release {match_version}")
|
||||
possibilities_dict = {
|
||||
">": (1,),
|
||||
"<": (-1,),
|
||||
|
@ -261,27 +346,133 @@ class EosVersion(BaseModel):
|
|||
"<=": (-1, 0),
|
||||
}
|
||||
possibilities = possibilities_dict[prefix]
|
||||
cmp_res = self._compare(EosVersion.from_str(match_version))
|
||||
cmp_res = self._compare(SemVer.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
|
||||
Check if current version is part of a branch version.
|
||||
|
||||
Comparison is done across MAJOR and MINOR
|
||||
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
|
||||
Parameters
|
||||
----------
|
||||
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
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if current version is in provided branch, otherwise False.
|
||||
"""
|
||||
logging.info(f"Checking if {self} is in branch {branch_str}")
|
||||
try:
|
||||
logger.debug(f"reading branch str:{branch_str}")
|
||||
branch = EosVersion.from_str(branch_str)
|
||||
branch = SemVer.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
|
||||
|
||||
|
||||
class EosVersion(SemVer):
|
||||
"""EosVersion object to play with version management in code.
|
||||
|
||||
Since EOS is not using strictly semver approach, this class mimics some functions from the semver library for Arista EOS versions.
|
||||
It is based on Pydantic and provides helpers for comparison.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> version = EosVersion(major=4, minor=21, patch=1, rtype="M")
|
||||
>>> print(version)
|
||||
EosVersion(major=4, minor=21, patch=1, rtype='M', other=None)
|
||||
>>> version = EosVersion.from_str('4.32.1F')
|
||||
>>> print(version)
|
||||
EosVersion(major=4, minor=32, patch=1, rtype='F', other=None)
|
||||
|
||||
Attributes
|
||||
----------
|
||||
major : int
|
||||
Major version number, default is 4.
|
||||
minor : int
|
||||
Minor version number, default is 0.
|
||||
patch : int
|
||||
Patch version number, default is 0.
|
||||
rtype : Optional[str]
|
||||
Release type, default is "F".
|
||||
other : Any
|
||||
Any other version information.
|
||||
regex_version : ClassVar[Pattern[str]]
|
||||
Regular expression to extract version information.
|
||||
regex_branch : ClassVar[Pattern[str]]
|
||||
Regular expression to extract branch information.
|
||||
description : str
|
||||
A basic description of this class, default is "A Generic SemVer implementation".
|
||||
"""
|
||||
|
||||
major: int = 4
|
||||
minor: int = 0
|
||||
patch: int = 0
|
||||
rtype: Optional[str] = "F"
|
||||
other: Any = None
|
||||
# Regular Expression to extract version information.
|
||||
regex_version: ClassVar[Pattern[str]] = re.compile(
|
||||
r"^.*(?P<major>4)\.(?P<minor>\d{1,2})\.(?P<patch>\d{1,2})(?P<other>\.\d*)*(?P<rtype>[M,F])*$"
|
||||
)
|
||||
regex_branch: ClassVar[Pattern[str]] = re.compile(
|
||||
r"^.*(?P<major>4)\.(?P<minor>\d{1,2})(\.?P<patch>\d)*(\.\d)*(?P<rtype>[M,F])*$"
|
||||
)
|
||||
# A Basic description of this class
|
||||
description: str = "A SemVer implementation for EOS"
|
||||
|
||||
|
||||
class CvpVersion(SemVer):
|
||||
"""A CloudVision Portal Version class that inherits from SemVer.
|
||||
|
||||
This class implements version management for CloudVision Portal (CVP) versions
|
||||
following a modified semantic versioning pattern where:
|
||||
- major version represents the year (e.g. 2024)
|
||||
- minor version represents feature releases
|
||||
- patch version represents bug fixes
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> version = CvpVersion(2024, 1, 0)
|
||||
>>> str(version)
|
||||
'2024.1.0'
|
||||
|
||||
Attributes
|
||||
----------
|
||||
major : int
|
||||
The year component of the version (e.g. 2024).
|
||||
minor : int
|
||||
The minor version number.
|
||||
patch : int
|
||||
The patch version number.
|
||||
rtype : Optional[str]
|
||||
Release type if any.
|
||||
other : Any
|
||||
Additional version information if any.
|
||||
regex_version : ClassVar[Pattern[str]]
|
||||
Regular expression to parse version strings.
|
||||
regex_branch : ClassVar[Pattern[str]]
|
||||
Regular expression to parse branch version strings.
|
||||
description : str
|
||||
Brief description of the class purpose.
|
||||
"""
|
||||
|
||||
major: int = 2024
|
||||
minor: int = 0
|
||||
patch: int = 0
|
||||
rtype: Optional[str] = None
|
||||
other: Any = None
|
||||
# Regular Expression to extract version information.
|
||||
regex_version: ClassVar[Pattern[str]] = re.compile(
|
||||
r"^.*(?P<major>\d{4})\.(?P<minor>\d{1,2})\.(?P<patch>\d{1,2})(?P<other>\.\d*)*$"
|
||||
)
|
||||
regex_branch: ClassVar[Pattern[str]] = re.compile(
|
||||
r"^.*(?P<major>\d{4})\.(?P<minor>\d{1,2})\.(?P<patch>\d{1,2})(?P<other>\.\d*)*$"
|
||||
)
|
||||
# A Basic description of this class
|
||||
description: str = "A SemVer implementation for CloudVision"
|
||||
|
|
|
@ -1,582 +0,0 @@
|
|||
#!/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,
|
||||
annotations,
|
||||
division,
|
||||
print_function,
|
||||
unicode_literals,
|
||||
)
|
||||
|
||||
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, REQUEST_HEADERS
|
||||
|
||||
# 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",
|
||||
): # pylint: disable=R0917
|
||||
"""
|
||||
__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,
|
||||
headers=REQUEST_HEADERS,
|
||||
)
|
||||
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,
|
||||
headers=REQUEST_HEADERS,
|
||||
)
|
||||
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,
|
||||
headers=REQUEST_HEADERS,
|
||||
)
|
||||
|
||||
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", is_latest: bool = False
|
||||
) -> 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}")
|
||||
if is_latest:
|
||||
console.print(f"🚀 Configuring {docker_image}:{self.version} to be latest")
|
||||
os.system(f"$(which docker) tag {docker_image} {image_name}:latest")
|
||||
for filename in glob.glob(f"{self.filename}*"):
|
||||
try:
|
||||
os.remove(filename)
|
||||
except FileNotFoundError:
|
||||
console.print(f"File not found: {filename}")
|
Loading…
Add table
Add a link
Reference in a new issue