From 7ee70c224d95d619ff7400e99f2d891129f6d442 Mon Sep 17 00:00:00 2001 From: BMW Engineer Date: Tue, 11 Mar 2025 16:54:39 +0100 Subject: [PATCH 1/2] ITF target, ssh and sftp --- itf/plugins/base/base_plugin.py | 37 +++ itf/plugins/base/target/base_target.py | 112 +++++++ itf/plugins/base/target/hw_target.py | 34 +++ .../base/target/processors/__init__.py | 12 + .../base/target/processors/qemu_processor.py | 29 ++ .../base/target/processors/qvp_processor.py | 27 ++ .../target/processors/safety_processor.py | 23 ++ .../target/processors/target_processor.py | 120 ++++++++ itf/plugins/base/target/qemu_target.py | 42 +++ itf/plugins/base/target/qvp_target.py | 46 +++ itf/plugins/base/test/__init__.py | 12 + itf/plugins/base/test/utils.py | 24 ++ itf/plugins/com/__init__.py | 12 + itf/plugins/com/ping.py | 59 ++++ itf/plugins/com/sftp.py | 154 ++++++++++ itf/plugins/com/ssh.py | 288 ++++++++++++++++++ itf/plugins/com/ssh_command.py | 62 ++++ 17 files changed, 1093 insertions(+) create mode 100644 itf/plugins/base/target/base_target.py create mode 100644 itf/plugins/base/target/hw_target.py create mode 100644 itf/plugins/base/target/processors/__init__.py create mode 100644 itf/plugins/base/target/processors/qemu_processor.py create mode 100644 itf/plugins/base/target/processors/qvp_processor.py create mode 100644 itf/plugins/base/target/processors/safety_processor.py create mode 100644 itf/plugins/base/target/processors/target_processor.py create mode 100644 itf/plugins/base/target/qemu_target.py create mode 100644 itf/plugins/base/target/qvp_target.py create mode 100644 itf/plugins/base/test/__init__.py create mode 100644 itf/plugins/base/test/utils.py create mode 100644 itf/plugins/com/__init__.py create mode 100644 itf/plugins/com/ping.py create mode 100644 itf/plugins/com/sftp.py create mode 100644 itf/plugins/com/ssh.py create mode 100644 itf/plugins/com/ssh_command.py diff --git a/itf/plugins/base/base_plugin.py b/itf/plugins/base/base_plugin.py index af326b4..9ac9f7b 100644 --- a/itf/plugins/base/base_plugin.py +++ b/itf/plugins/base/base_plugin.py @@ -11,11 +11,16 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* import logging +import socket import pytest from itf.plugins.base.constants import TEST_CONFIG_KEY, TARGET_CONFIG_KEY from itf.plugins.base.target.config import load_configuration, target_ecu_argparse from itf.plugins.base.os.operating_system import OperatingSystem +from itf.plugins.base.target.qemu_target import qemu_target +from itf.plugins.base.target.qvp_target import qvp_target +from itf.plugins.base.target.hw_target import hw_target +from itf.plugins.base.test.utils import pre_tests_phase, post_tests_phase from itf.plugins.utils import padder from itf.plugins.xtf_common.bunch import Bunch @@ -72,6 +77,38 @@ def target_config_fixture(request): yield target_config +@pytest.fixture(scope="session") +def target_fixture(target_config_fixture, test_config_fixture, request): + logger.info("Starting target_fixture in base_plugin.py ...") + logger.info(f"Starting tests on host: {socket.gethostname()}") + + if test_config_fixture.qemu: + with qemu_target(test_config_fixture) as qemu: + try: + pre_tests_phase(qemu, target_config_fixture.ip_address, test_config_fixture, request) + yield qemu + finally: + post_tests_phase(qemu, test_config_fixture) + + elif test_config_fixture.qvp: + with qvp_target(test_config_fixture) as qvp: + try: + pre_tests_phase(qvp, target_config_fixture.ip_address, test_config_fixture, request) + yield qvp + finally: + post_tests_phase(qvp, test_config_fixture) + + elif test_config_fixture.hw: + with hw_target(test_config_fixture) as hardware: + try: + pre_tests_phase(hardware, target_config_fixture.ip_address, test_config_fixture, request) + yield hardware + finally: + post_tests_phase(hardware, test_config_fixture) + else: + raise RuntimeError("QEMU, QVP or HW not specified to use") + + def __make_test_config(config): load_configuration(config.getoption("target_config")) return Bunch( diff --git a/itf/plugins/base/target/base_target.py b/itf/plugins/base/target/base_target.py new file mode 100644 index 0000000..647fe7f --- /dev/null +++ b/itf/plugins/base/target/base_target.py @@ -0,0 +1,112 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import logging + +from itf.plugins.base.target.processors.target_processor import TargetProcessor +from itf.plugins.base.target.processors.safety_processor import TargetSafetyProcessor +from itf.plugins.base.os.operating_system import OperatingSystem +from itf.plugins.base.target.config.ecu import Ecu + +logger = logging.getLogger(__name__) + + +class Target: + """Represents a set of Processors""" + + def __init__( + self, + target_ecu: Ecu, + target_sut_os: OperatingSystem = OperatingSystem.LINUX, + diagnostic_ip: str = None, + ): + """Initializes the Target with the given parameters. + + :param Ecu target_ecu: The ECU type for the target. + :param OperatingSystem target_sut_os: The operating system of the target SUT. Default is LINUX. + :param str diagnostic_ip: The IP address for diagnostic communication. + """ + self.__target_ecu = target_ecu + self.__target_sut_os = target_sut_os + self.__sut = None + self.__safety_core = None + # Other processors + for other_ecu in self.target_ecu.others: + setattr(self, other_ecu.name.lower(), None) # Will be set when registering processors + self.__processors = [] + self.__diagnostic_ip = diagnostic_ip + + def __repr__(self): + return str(self.__dict__) + + def __str__(self): + return str(self.__dict__) + + # pylint: disable=unused-argument + def register_processors(self, process=None, initialize_serial_device=True, initialize_serial_logs=True): + self.__sut = TargetProcessor( + self.__target_ecu.sut, + self.__target_sut_os, + self.__diagnostic_ip, + ) + self.__processors.append(self.sut) + + self.__safety_core = TargetSafetyProcessor( + self.__target_ecu.sc, + OperatingSystem.UNSPECIFIED, + diagnostic_ip=self.__diagnostic_ip, + ) + + for processor in self.target_ecu.others: + other_processor = TargetProcessor(processor, OperatingSystem.UNSPECIFIED) + self.__processors.append(other_processor) + setattr(self, processor.name.lower(), other_processor) + + @property + def target_ecu(self): + return self.__target_ecu + + @property + def target_sut_os(self): + return self.__target_sut_os + + @property + def diagnostic_ip(self): + return self.__diagnostic_ip + + @property + def sut(self): + return self.__sut + + @sut.setter + def sut(self, value): + self.__sut = value + + @property + def safety_core(self): + return self.__safety_core + + @safety_core.setter + def safety_core(self, value): + self.__safety_core = value + + @property + def processors(self): + return self.__processors + + @processors.setter + def processors(self, value): + self.__processors = value + + def teardown(self): + for processor in self.__processors: + processor.teardown() diff --git a/itf/plugins/base/target/hw_target.py b/itf/plugins/base/target/hw_target.py new file mode 100644 index 0000000..0733780 --- /dev/null +++ b/itf/plugins/base/target/hw_target.py @@ -0,0 +1,34 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import logging + +from contextlib import contextmanager, nullcontext +from itf.plugins.base.target.base_target import Target + + +logger = logging.getLogger(__name__) + + +@contextmanager +def hw_target(test_config): + """Context manager for hardware target setup. + + Currently, only ITF tests against an already running hardware instance is supported. + """ + diagnostic_ip = None + + with nullcontext(): + target = Target(test_config.ecu, test_config.os, diagnostic_ip) + target.register_processors() + yield target + target.teardown() diff --git a/itf/plugins/base/target/processors/__init__.py b/itf/plugins/base/target/processors/__init__.py new file mode 100644 index 0000000..6bdeed2 --- /dev/null +++ b/itf/plugins/base/target/processors/__init__.py @@ -0,0 +1,12 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* diff --git a/itf/plugins/base/target/processors/qemu_processor.py b/itf/plugins/base/target/processors/qemu_processor.py new file mode 100644 index 0000000..294a2a8 --- /dev/null +++ b/itf/plugins/base/target/processors/qemu_processor.py @@ -0,0 +1,29 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +from itf.plugins.base.os.operating_system import OperatingSystem +from itf.plugins.base.target.config.base_processor import BaseProcessor +from itf.plugins.base.target.processors.target_processor import TargetProcessor + + +class TargetProcessorQemu(TargetProcessor): + """Target Processor for QEMU.""" + + def __init__(self, processor: BaseProcessor, os: OperatingSystem, process): + self.__process = process + super().__init__(processor, os) + + def kill_process(self): + self.__process.stop() + + def restart_process(self): + self.__process.restart() diff --git a/itf/plugins/base/target/processors/qvp_processor.py b/itf/plugins/base/target/processors/qvp_processor.py new file mode 100644 index 0000000..a167772 --- /dev/null +++ b/itf/plugins/base/target/processors/qvp_processor.py @@ -0,0 +1,27 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +from itf.plugins.base.os.operating_system import OperatingSystem +from itf.plugins.base.target.config.base_processor import BaseProcessor +from itf.plugins.base.target.processors.target_processor import TargetProcessor + + +class TargetProcessorQVP(TargetProcessor): + def __init__(self, processor: BaseProcessor, os: OperatingSystem, process): + self._process = process + super().__init__(processor, os) + + def kill_process(self): + self._process.stop() + + def restart_process(self): + pass diff --git a/itf/plugins/base/target/processors/safety_processor.py b/itf/plugins/base/target/processors/safety_processor.py new file mode 100644 index 0000000..f54d060 --- /dev/null +++ b/itf/plugins/base/target/processors/safety_processor.py @@ -0,0 +1,23 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +from itf.plugins.base.os.operating_system import OperatingSystem +from itf.plugins.base.target.config.base_processor import BaseProcessor +from itf.plugins.base.target.processors.target_processor import TargetProcessor + + +class TargetSafetyProcessor(TargetProcessor): + """Represents the Safety processor of the target ECU.""" + + # pylint: disable=useless-super-delegation + def __init__(self, processor: BaseProcessor, os: OperatingSystem, diagnostic_ip=None): + super().__init__(processor, os, diagnostic_ip) diff --git a/itf/plugins/base/target/processors/target_processor.py b/itf/plugins/base/target/processors/target_processor.py new file mode 100644 index 0000000..a63c4a2 --- /dev/null +++ b/itf/plugins/base/target/processors/target_processor.py @@ -0,0 +1,120 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import logging + +from itf.plugins.base.target.config.base_processor import BaseProcessor +from itf.plugins.base.os.operating_system import OperatingSystem + +from itf.plugins.com.sftp import Sftp +from itf.plugins.com.ssh import Ssh +from itf.plugins.com.ping import ping, ping_lost + + +logger = logging.getLogger(__name__) + + +class TargetProcessor: + """Represents single unit which tests can communicate with.""" + + def __init__(self, processor: BaseProcessor, os: OperatingSystem, diagnostic_ip=None): + self.__type = processor + self.__os = os + self.__config = processor + self.__diagnostic_ip = diagnostic_ip + self.__ip_address = self.__config.ip_address + self.__ext_ip_address = ( + self.__config.ext_ip_address if hasattr(self.__config, "ext_ip_address") else self.__ip_address + ) + + def __repr__(self): + return f"Processor, type: {self.__type.name}, config: {self.__config}" + + @property + def config(self): + return self.__config + + @property + def type(self): + return self.__type + + # pylint: disable=C0103 + @property + def os(self): + return self.__os + + @property + def diagnostic_ip(self): + return self.__diagnostic_ip + + @diagnostic_ip.setter + def diagnostic_ip(self, value): + self.__diagnostic_ip = value + + @property + def diagnostic_ip_address(self): + return self.__config.diagnostic_ip_address + + @property + def diagnostic_address(self): + return self.__config.diagnostic_address + + @property + def ip_address(self): + return self.__ip_address + + @ip_address.setter + def ip_address(self, value): + self.__ip_address = value + + @property + def ext_ip_address(self): + return self.__ext_ip_address + + def uses_doip(self): + return self.__config.use_doip + + def ssh(self, timeout=15, n_retries=5, retry_interval=1, pkey_path="", password="", ext_ip=False): + ssh_ip = self.ext_ip_address if ext_ip else self.ip_address + return Ssh( + target_ip=ssh_ip, + timeout=timeout, + n_retries=n_retries, + retry_interval=retry_interval, + pkey_path=pkey_path, + password=password, + ) + + def sftp(self, ssh_connection=None, ext_ip=False): + ssh_ip = self.ext_ip_address if ext_ip else self.ip_address + return Sftp(ssh_connection, ssh_ip) + + def ping(self, timeout, ext_ip=False, wait_ms_precision=None): + return ping( + address=self.ext_ip_address if ext_ip else self.ip_address, + timeout=timeout, + wait_ms_precision=wait_ms_precision, + ) + + def ping_lost(self, timeout, interval=1, ext_ip=False, wait_ms_precision=None): + return ping_lost( + address=self.ext_ip_address if ext_ip else self.ip_address, + timeout=timeout, + interval=interval, + wait_ms_precision=wait_ms_precision, + ) + + def login(self): + pass + + def teardown(self): + pass diff --git a/itf/plugins/base/target/qemu_target.py b/itf/plugins/base/target/qemu_target.py new file mode 100644 index 0000000..f6de592 --- /dev/null +++ b/itf/plugins/base/target/qemu_target.py @@ -0,0 +1,42 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +from contextlib import contextmanager, nullcontext + +from itf.plugins.base.os.operating_system import OperatingSystem +from itf.plugins.base.target.base_target import Target +from itf.plugins.base.target.config.ecu import Ecu +from itf.plugins.base.target.processors.qemu_processor import TargetProcessorQemu + + +class TargetQemu(Target): + """Target for the Qemu.""" + + def __init__(self, target_ecu: Ecu, target_sut_os: OperatingSystem = OperatingSystem.LINUX): + super().__init__(target_ecu, target_sut_os) + + def register_processors(self, process=None, initialize_serial_device=True, initialize_serial_logs=True): # pylint: disable=unused-argument + self.sut = TargetProcessorQemu(self.target_ecu.sut, self.target_sut_os, process) + self.processors.append(self.sut) + + +@contextmanager +def qemu_target(test_config): + """Context manager for QEMU target setup. + + Currently, only ITF tests against an already running Qemu instance is supported. + """ + with nullcontext() as qemu_process: + target = TargetQemu(test_config.ecu, test_config.os) + target.register_processors(qemu_process) + yield target + target.teardown() diff --git a/itf/plugins/base/target/qvp_target.py b/itf/plugins/base/target/qvp_target.py new file mode 100644 index 0000000..e38a28a --- /dev/null +++ b/itf/plugins/base/target/qvp_target.py @@ -0,0 +1,46 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import logging +from contextlib import contextmanager, nullcontext + +from itf.plugins.base.os.operating_system import OperatingSystem +from itf.plugins.base.target.base_target import Target +from itf.plugins.base.target.config.ecu import Ecu +from itf.plugins.base.target.processors.qvp_processor import TargetProcessorQVP + +logger = logging.getLogger(__name__) + + +class TargetQvp(Target): + """Target for the QVP (QNX Virtual Platform).""" + + def __init__(self, target_ecu: Ecu, target_sut_os: OperatingSystem = OperatingSystem.QNX): + super().__init__(target_ecu, target_sut_os) + + # pylint: disable=unused-argument + def register_processors(self, process=None, initialize_serial_device=True, initialize_serial_logs=True): + self.sut = TargetProcessorQVP(self.target_ecu.sut, self.target_sut_os, process) + self.processors.append(self.sut) + + +@contextmanager +def qvp_target(test_config): + """Context manager for QVP target setup. + + Currently, only ITF tests against an already running QQVP instance is supported. + """ + with nullcontext() as qvp_process: + target = TargetQvp(test_config.ecu, test_config.os) + target.register_processors(qvp_process) + yield target + target.teardown() diff --git a/itf/plugins/base/test/__init__.py b/itf/plugins/base/test/__init__.py new file mode 100644 index 0000000..6bdeed2 --- /dev/null +++ b/itf/plugins/base/test/__init__.py @@ -0,0 +1,12 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* diff --git a/itf/plugins/base/test/utils.py b/itf/plugins/base/test/utils.py new file mode 100644 index 0000000..0ff7ecd --- /dev/null +++ b/itf/plugins/base/test/utils.py @@ -0,0 +1,24 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# pylint: disable=unused-argument + + +def pre_tests_phase(target, ip_address, test_config, request): + # Will be implemented later + pass + + +def post_tests_phase(target, test_config): + # Will be implemented later + pass diff --git a/itf/plugins/com/__init__.py b/itf/plugins/com/__init__.py new file mode 100644 index 0000000..6bdeed2 --- /dev/null +++ b/itf/plugins/com/__init__.py @@ -0,0 +1,12 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* diff --git a/itf/plugins/com/ping.py b/itf/plugins/com/ping.py new file mode 100644 index 0000000..9d168f2 --- /dev/null +++ b/itf/plugins/com/ping.py @@ -0,0 +1,59 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import os +import time + + +def _execute_command(cmd): + return os.system(cmd) + + +def _ping(address, wait_ms_precision=None): + timeout_command = f"timeout {wait_ms_precision} " if wait_ms_precision else "" + return _execute_command(f"{timeout_command}ping -c 1 -W 1 " + address) == 0 + + +def ping(address, timeout=0, interval=1, wait_ms_precision=None): + if timeout == 0: + return _ping(address, wait_ms_precision) + + attempts = int(timeout / interval) + + for _ in range(attempts): + time.sleep(interval) + if _ping(address, wait_ms_precision): + return True + + return False + + +def ping_lost(address, timeout=0, interval=1, wait_ms_precision=None): + if timeout == 0: + return not _ping(address, wait_ms_precision) + + attempts = int(timeout / interval) + + for _ in range(attempts): + time.sleep(interval) + if not _ping(address, wait_ms_precision): + return True + + return False + + +def check_ping_lost(address): + assert ping_lost(address, timeout=60) + + +def check_ping(address): + assert ping(address, timeout=60) diff --git a/itf/plugins/com/sftp.py b/itf/plugins/com/sftp.py new file mode 100644 index 0000000..5bd0373 --- /dev/null +++ b/itf/plugins/com/sftp.py @@ -0,0 +1,154 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import os +import stat +import logging +from itf.plugins.com.ssh import Ssh, execute_command + +# Reduce the logging level of paramiko, from DEBUG to INFO +logging.getLogger("paramiko").setLevel(logging.INFO) + +logger = logging.getLogger(__name__) + + +class Sftp: + def __init__(self, ssh, target_ip): + if not ssh: + self._new_ssh = True + else: + self._new_ssh = False + + self._ssh = ssh or Ssh(target_ip) + self._sftp = None + + def __enter__(self): + """ + Open sftp connection to target given an ssh connection + """ + if self._new_ssh: + self._ssh = self._ssh.__enter__() + self._sftp = self._ssh.open_sftp() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self._sftp.close() + if self._new_ssh: + self._ssh.close() + logger.info("Closed ssh connection.") + + def walk(self, remote_path): + """ + Generate path to all files in directory + """ + path = remote_path + files = [] + folders = [] + for f in sorted(self._sftp.listdir_attr(remote_path), key=lambda x: x.filename): + if stat.S_ISDIR(f.st_mode): + folders.append(f.filename) + else: + files.append(f.filename) + if files: + yield path, files + + for folder in folders: + new_path = os.path.join(remote_path, folder) + for res in self.walk(new_path): + yield res + + def download(self, remote_path, local_path, verbose=True): + if verbose: + logger.debug(f"Downloading '{remote_path}' to '{local_path}'") + os.makedirs(os.path.dirname(local_path), exist_ok=True) + self._sftp.get(remote_path, local_path) + remote_stat = self._sftp.stat(remote_path) + os.utime(local_path, (remote_stat.st_atime, remote_stat.st_mtime)) + + def upload(self, local_path, remote_path, verbose=True): + if verbose: + logger.debug(f"Uploading '{local_path}' to '{remote_path}'") + if not os.path.exists(local_path): + logger.error(f"Missing file '{local_path}' while trying to upload") + remote_dir = os.path.dirname(remote_path) + assert ( + execute_command(self._ssh, f"test -d {remote_dir} || mkdir -p {remote_dir}") == 0 + ), f"Could not create remote path: {os.path.dirname(remote_path)}" + self._sftp.put(local_path, remote_path) + + def list_dirs_and_files(self, remote_path): + return self._sftp.listdir_attr(remote_path) + + def list_dirs_and_files_name(self, remote_path): + return self._sftp.listdir(remote_path) + + def get_directory_size(self, remote_path): + total_size = 0 + for file_name in self._sftp.listdir(remote_path): + stat_info = self._sftp.stat(remote_path + file_name) + total_size += stat_info.st_size + return total_size + + def make_directory(self, remote_path): + self._sftp.mkdir(remote_path) + + def stat(self, remote_path): + return self._sftp.stat(remote_path) + + def file_exists(self, remote_path): + try: + return self._sftp.stat(remote_path) is not None + except FileNotFoundError: + return False + + def remove(self, path): + try: + logger.debug(f"Removing '{path}'") + self._sftp.remove(path) + except EnvironmentError as exc: + raise EnvironmentError(f'SFTP failed. Remote path "{path}".') from exc + + def get_directory_size_excluding_files(self, remote_path, exclude_file_list): + total_size = 0 + for file_name in self._sftp.listdir(remote_path): + if file_name not in exclude_file_list: + stat_info = self._sftp.stat(remote_path + file_name) + total_size += stat_info.st_size + return total_size + + def get_file_size(self, remote_path, file_name): + file_size = 0 + for remote_file_name in self._sftp.listdir(remote_path): + if remote_file_name == file_name: + stat_info = self._sftp.stat(remote_path + file_name) + file_size += stat_info.st_size + break + return file_size + + def rmdir(self, remote_path): + self._sftp.rmdir(remote_path) + + def upload_dir(self, local_path, remote_path, verbose=True): + for dirpath, _, filenames in os.walk(local_path): + dirpath_relative = os.path.relpath(dirpath, local_path) + for filename in filenames: + self.upload( + os.path.join(dirpath, filename), os.path.join(remote_path, dirpath_relative, filename), verbose + ) + + def download_dir(self, remote_path, local_path, verbose=True): + for dirpath, filenames in self.walk(remote_path): + relative_dirpath = os.path.relpath(dirpath, remote_path) + for filename in filenames: + self.download( + os.path.join(dirpath, filename), os.path.join(local_path, relative_dirpath, filename), verbose + ) diff --git a/itf/plugins/com/ssh.py b/itf/plugins/com/ssh.py new file mode 100644 index 0000000..5a4d2bb --- /dev/null +++ b/itf/plugins/com/ssh.py @@ -0,0 +1,288 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import os +import time +import logging +import paramiko + +# Reduce the logging level of paramiko, from DEBUG to INFO +logging.getLogger("paramiko").setLevel(logging.INFO) +logger = logging.getLogger(__name__) + + +class Ssh: + def __init__( + self, + target_ip, + timeout=15, + n_retries=5, + retry_interval=1, + pkey_path="platform/aas/tools/itf/itf/ssh_keys/id_mPAD", + password="", + ): + self._target_ip = target_ip + self._timeout = timeout + self._retries = n_retries + self._retry_interval = retry_interval + self._ssh = None + self._pkey = paramiko.ECDSAKey.from_private_key_file(pkey_path) + self._password = password + + def __enter__(self): + self._ssh = paramiko.SSHClient() + self._ssh.set_missing_host_key_policy(paramiko.client.AutoAddPolicy()) + + for _ in range(self._retries): + try: + self._ssh.connect( + self._target_ip, + timeout=self._timeout, + username="root", + password=self._password, + pkey=self._pkey, + banner_timeout=200, + look_for_keys=False, + ) + break + except Exception: + time.sleep(self._retry_interval) + else: + raise Exception(f"ssh connection to {self._target_ip} failed") + + return self._ssh + + def __exit__(self, exc_type, exc_val, exc_tb): + self._ssh.close() + logger.info("Closed ssh connection.") + + +def command_with_etc(command): + return f"if uname >/dev/null 2>&1; then ({command}); else (if [ -e /etc/profile ]; then (. /etc/profile; {command}); else ({command}); fi;); fi" + + +def _read_output(stream_type, stream, logger_in, log): + """Logs the output from a given stream and returns the lines of output. + + :param stream_type: The type of stream to read the output from (stdout or stderr). + :type stream_type: str + :param stream: The stream to read the output from (stdout or stderr). + :type stream: paramiko.ChannelFile + :param logger_in: The logger object used for logging output. If None, the default logger is used. + :type logger_in: logging.Logger, optional + :param log: Boolean to know if to log the output or not + :type log: bool, optional + + :return: A list of lines read from the stream. + :rtype: list[str] + """ + + if not logger_in and log: + logger_in = logging.getLogger() + + lines = [] + recv_ready_method = stream.channel.recv_ready if stream_type == "stdout" else stream.channel.recv_stderr_ready + if recv_ready_method(): + lines = stream.readlines() + if log: + for line in lines: + logger_in.info(line.strip()) + + return lines + + +def _read_output_with_timeout(stream, logger_in, log, max_exec_time): + """Logs the output from a given stream and returns the lines of output. + + :param stream: The stream to read the output from (should be stdout). + :type stream: paramiko.ChannelFile + :param logger_in: The logger object used for logging output. If None, the default logger is used. + :type logger_in: logging.Logger, optional + :param log: Boolean to know if to log the output or not + :type log: bool, optional + :param max_exec_time: The maximum time (in seconds) to wait for the read operations to occur. + :type max_exec_time: int + + :return: A list of lines read from the stream. + :rtype: list[str] + """ + + if not logger_in and log: + logger_in = logging.getLogger() + + stream.channel.settimeout(max_exec_time) + start_time = time.time() + lines = [] + try: + while not stream.channel.exit_status_ready() or stream.channel.recv_ready(): + line = stream.readline() + lines.append(line) + if log: + logger_in.info(line.strip()) + elapsed_time = time.time() - start_time + stream.channel.settimeout(max_exec_time - elapsed_time) + except Exception as ex: + return lines, ex + return lines, "" + + +def execute_command_merged_output(ssh_connection, cmd, timeout=30, max_exec_time=180, logger_in=None, verbose=True): + """Executes a command on a remote SSH server and captures the output, with both a start timeout and an execution timeout. + + :param ssh_connection: The SSH connection object used to execute the command. + :type ssh_connection: paramiko.SSHClient + :param cmd: The command to be executed on the remote server. + :type cmd: str + :param timeout: The maximum time (in seconds) to wait for the command to begin executing. Defaults to 30 seconds. + :type timeout: int, optional + :param max_exec_time: The maximum time (in seconds) to wait for the command to complete execution. Defaults to 60 seconds. + :type max_exec_time: int, optional + :param logger_in: The logger object used for logging output. If None, the default logger is used. Defaults to None. + :type logger_in: logging.Logger, optional + :param verbose: If True, logs the command output. Defaults to True. + :type verbose: bool, optional + + :return: A tuple containing the exit status, and the merged standard output and standard error lines. + :rtype: tuple(int, list[str]) + """ + + cmd_ipn = command_with_etc(cmd) + stdin, stdout, stderr = ssh_connection.exec_command(cmd_ipn, timeout=timeout) + + output_lines = [] + stdout.channel.set_combine_stderr(True) + output_lines, exception = _read_output_with_timeout(stdout, logger_in, verbose, max_exec_time) + try: + found_exception = False + if exception: + ssh_connection.exec_command(f"pkill -f '{cmd_ipn}'") + logger.error(f"Command '{cmd}' took more than {max_exec_time} seconds to run, process was killed.") + found_exception = True + except Exception as ex: + logger.error(f"Exception: '{ex}'") + found_exception = True + try: + rvalue = -1 if found_exception else stdout.channel.recv_exit_status() + except Exception: + logger.error(f"Could not retrieve exit status of command '{cmd}'.") + rvalue = -1 + finally: + if not stdout.channel.closed: + stdout.channel.close() + if not stderr.channel.closed: + stderr.channel.close() + if not stdin.channel.closed: + stdin.channel.close() + return rvalue, output_lines + + +def execute_command_output(ssh_connection, cmd, timeout=30, max_exec_time=180, logger_in=None, verbose=True): + """Executes a command on a remote SSH server and captures the output, with both a start timeout and an execution timeout. + + :param ssh_connection: The SSH connection object used to execute the command. + :type ssh_connection: paramiko.SSHClient + :param cmd: The command to be executed on the remote server. + :type cmd: str + :param timeout: The maximum time (in seconds) to wait for the command to begin executing. Defaults to 30 seconds. + :type timeout: int, optional + :param max_exec_time: The maximum time (in seconds) to wait for the command to complete execution. Defaults to 60 seconds. + :type max_exec_time: int, optional + :param logger_in: The logger object used for logging output. If None, the default logger is used. Defaults to None. + :type logger_in: logging.Logger, optional + :param verbose: If True, logs the command output. Defaults to True. + :type verbose: bool, optional + + :return: A tuple containing the exit status, the standard output lines, and the standard error lines. + :rtype: tuple(int, list[str], list[str]) + """ + + cmd_ipn = command_with_etc(cmd) + stdin, stdout, stderr = ssh_connection.exec_command(cmd_ipn, timeout=timeout) + + start_time = time.time() + stdout_lines = [] + stderr_lines = [] + + try: + while not stdout.channel.exit_status_ready(): + if time.time() - start_time > max_exec_time: + stdout_lines.extend(_read_output("stdout", stdout, logger_in, verbose)) + stderr_lines.extend(_read_output("stderr", stderr, logger_in, verbose)) + ssh_connection.exec_command(f"pkill -f '{cmd_ipn}'") + logger.error(f"Command '{cmd}' took more than {max_exec_time} seconds to run, process was killed.") + return -1, stdout_lines, stderr_lines + + stdout_lines.extend(_read_output("stdout", stdout, logger_in, verbose)) + stderr_lines.extend(_read_output("stderr", stderr, logger_in, verbose)) + time.sleep(0.1) + + stdout_lines.extend(_read_output("stdout", stdout, logger_in, verbose)) + stderr_lines.extend(_read_output("stderr", stderr, logger_in, verbose)) + + return stdout.channel.recv_exit_status(), stdout_lines, stderr_lines + + finally: + if not stdout.channel.closed: + stdout.channel.close() + if not stderr.channel.closed: + stderr.channel.close() + if not stdin.channel.closed: + stdin.channel.close() + + +def execute_command(ssh_connection, cmd, timeout=30, max_exec_time=180, logger_in=None, verbose=True): + logger.debug(f"Executing command.") + logger.debug(f"cmd: {cmd}") + logger.debug(f"timeout: {timeout}; max_exec_time: {max_exec_time}; logger_in: {logger_in}; verbose: {verbose};") + exit_code, stdout_lines, stderr_lines = execute_command_output( + ssh_connection, cmd, timeout, max_exec_time, logger_in, verbose + ) + if exit_code != 0: + stdout_lines = "\n".join(stdout_lines) + stderr_lines = "\n".join(stderr_lines) + logger.debug(f"Exit code was {exit_code}.") + logger.debug(f"stdout_lines: {stdout_lines}") + logger.debug(f"stderr_lines: {stderr_lines}") + + return exit_code + + +# pylint: disable=too-many-locals +def binary_transfer(ssh_connection, binary, destination, buffer_size=4 * 1024, logger_in=None): + mega_bytes = 1024 * 1024 + if not logger_in: + logger_in = logging.getLogger() + cmd = f"dd of={destination} bs=64k" + cmd_ipn = command_with_etc(cmd) + stdin, stdout, stderr = ssh_connection.exec_command(cmd_ipn) + size = os.stat(binary).st_size + transferred = 0 + peer_address, peer_port = ssh_connection.get_transport().getpeername() + logger_in.info(f"Transferring {binary} to {destination} over {peer_address}:{peer_port}") + time_start = time.time() + with open(binary, "rb") as f: + data = f.read(buffer_size) + while data != b"": + stdin.write(data) + transferred += len(data) + if transferred % (mega_bytes * 10) == 0: + time_end = time.time() + mbps = transferred / (time_end - time_start) / 1024 / 1024 + logger_in.info(f"{transferred // mega_bytes}/{size // mega_bytes} MB transferred, {mbps:.2f} MB/s") + data = f.read(buffer_size) + stdin.close() + exit_status = stdout.channel.recv_exit_status() + if exit_status: + stderr_lines = "\n".join([line.rstrip() for line in stderr.readlines()]) + raise RuntimeError( + "\n".join([f"Transferring {binary} to {destination} failed", "with stderr output:", stderr_lines]) + ) diff --git a/itf/plugins/com/ssh_command.py b/itf/plugins/com/ssh_command.py new file mode 100644 index 0000000..ee9beef --- /dev/null +++ b/itf/plugins/com/ssh_command.py @@ -0,0 +1,62 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import logging + +logger = logging.getLogger(__name__) + + +class SshCommand: + """This class allows to start executing commands via ssh in the + background and retrieve the results at a later point in time.""" + + def __init__(self, ssh_connection, cmd, ssh_connection_timeout=None): + """Immediately executes the command""" + _, self.__stdout, self.__stderr = ssh_connection.exec_command(cmd, timeout=ssh_connection_timeout) + self.__stdout_bytes = None + self.__stderr_bytes = None + self.__exit_status = None + + def wait_until_finished(self, command_result_timeout) -> "SshCommandResult": + """Block until the command terminates.""" + logger.debug("Setting timeout on channel.") + self.__stdout.channel.settimeout(command_result_timeout) + logger.debug("Trying to read stdout.") + self.__stdout_bytes = self.__stdout.read() + logger.debug("Trying to read stderr.") + self.__stderr_bytes = self.__stderr.read() + logger.debug("Trying to read exit_status.") + self.__exit_status = self.__stdout.channel.recv_exit_status() + return SshCommandResult(self.__stdout_bytes, self.__stderr_bytes, self.__exit_status) + + def is_finished(self) -> bool: + """Checks if the ssh command has already exited.""" + return self.__stdout.channel.exit_status_ready() + + +class SshCommandResult: + def __init__(self, stdout, stderr, exit_code): + self.__stdout = stdout + self.__stderr = stderr + self.__exit_code = exit_code + + def get_stdout_bytes(self): + """Return the stdout in bytes.""" + return self.__stdout + + def get_stderr_bytes(self): + """Return the stderr in bytes.""" + return self.__stderr + + def get_exit_code(self): + """Return the exit code""" + return self.__exit_code From d94a84a5ac43561e80a60cc123b4b6bfb1b471f6 Mon Sep 17 00:00:00 2001 From: Dragan Bjedov Date: Fri, 27 Jun 2025 17:04:00 +0200 Subject: [PATCH 2/2] Added paramiko dependency needed for ssh/sftp --- bazel/py_itf_test.bzl | 1 + requirements.in | 3 +- requirements_lock.txt | 193 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 193 insertions(+), 4 deletions(-) diff --git a/bazel/py_itf_test.bzl b/bazel/py_itf_test.bzl index f0b1137..89fffda 100644 --- a/bazel/py_itf_test.bzl +++ b/bazel/py_itf_test.bzl @@ -38,6 +38,7 @@ def py_itf_test(name, srcs, args = [], data = [], plugins = [], **kwargs): deps = [ requirement("docker"), requirement("pytest"), + requirement("paramiko"), "@score_itf//:itf", ], data = [ diff --git a/requirements.in b/requirements.in index 012f2bf..0996dc7 100644 --- a/requirements.in +++ b/requirements.in @@ -1,4 +1,5 @@ --extra-index-url https://pypi.org/simple/ docker==7.1.0 -pytest==8.3.3 +pytest==8.4.1 +paramiko==3.5.1 diff --git a/requirements_lock.txt b/requirements_lock.txt index e29479d..104472e 100644 --- a/requirements_lock.txt +++ b/requirements_lock.txt @@ -6,10 +6,134 @@ # --extra-index-url https://pypi.org/simple/ +bcrypt==4.3.0 \ + --hash=sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f \ + --hash=sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d \ + --hash=sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24 \ + --hash=sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3 \ + --hash=sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c \ + --hash=sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d \ + --hash=sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd \ + --hash=sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f \ + --hash=sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f \ + --hash=sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d \ + --hash=sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe \ + --hash=sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231 \ + --hash=sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef \ + --hash=sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18 \ + --hash=sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f \ + --hash=sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e \ + --hash=sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732 \ + --hash=sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304 \ + --hash=sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0 \ + --hash=sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8 \ + --hash=sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938 \ + --hash=sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62 \ + --hash=sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180 \ + --hash=sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af \ + --hash=sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669 \ + --hash=sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761 \ + --hash=sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51 \ + --hash=sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23 \ + --hash=sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09 \ + --hash=sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505 \ + --hash=sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4 \ + --hash=sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753 \ + --hash=sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59 \ + --hash=sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b \ + --hash=sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d \ + --hash=sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a \ + --hash=sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b \ + --hash=sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a \ + --hash=sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90 \ + --hash=sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492 \ + --hash=sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce \ + --hash=sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb \ + --hash=sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb \ + --hash=sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1 \ + --hash=sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676 \ + --hash=sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b \ + --hash=sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe \ + --hash=sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281 \ + --hash=sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1 \ + --hash=sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef \ + --hash=sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d + # via paramiko certifi==2025.1.31 \ --hash=sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651 \ --hash=sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe # via requests +cffi==1.17.1 \ + --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \ + --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ + --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \ + --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \ + --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \ + --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ + --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \ + --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \ + --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \ + --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \ + --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \ + --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \ + --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ + --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \ + --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \ + --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \ + --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \ + --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \ + --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \ + --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \ + --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \ + --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \ + --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \ + --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ + --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ + --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \ + --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \ + --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \ + --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \ + --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \ + --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \ + --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \ + --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \ + --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \ + --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \ + --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \ + --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \ + --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \ + --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \ + --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \ + --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \ + --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \ + --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ + --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \ + --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \ + --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \ + --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \ + --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \ + --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \ + --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ + --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \ + --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \ + --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ + --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \ + --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ + --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \ + --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ + --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \ + --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \ + --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \ + --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \ + --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ + --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \ + --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \ + --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \ + --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ + --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b + # via + # cryptography + # pynacl charset-normalizer==3.4.1 \ --hash=sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537 \ --hash=sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa \ @@ -104,6 +228,45 @@ charset-normalizer==3.4.1 \ --hash=sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00 \ --hash=sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616 # via requests +cryptography==45.0.4 \ + --hash=sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8 \ + --hash=sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4 \ + --hash=sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6 \ + --hash=sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862 \ + --hash=sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750 \ + --hash=sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2 \ + --hash=sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999 \ + --hash=sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0 \ + --hash=sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069 \ + --hash=sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d \ + --hash=sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c \ + --hash=sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1 \ + --hash=sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036 \ + --hash=sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349 \ + --hash=sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872 \ + --hash=sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22 \ + --hash=sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d \ + --hash=sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad \ + --hash=sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637 \ + --hash=sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b \ + --hash=sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57 \ + --hash=sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507 \ + --hash=sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee \ + --hash=sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6 \ + --hash=sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8 \ + --hash=sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4 \ + --hash=sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723 \ + --hash=sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58 \ + --hash=sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39 \ + --hash=sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2 \ + --hash=sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2 \ + --hash=sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d \ + --hash=sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97 \ + --hash=sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b \ + --hash=sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257 \ + --hash=sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff \ + --hash=sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e + # via paramiko docker==7.1.0 \ --hash=sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c \ --hash=sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0 @@ -120,13 +283,37 @@ packaging==24.2 \ --hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \ --hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f # via pytest +paramiko==3.5.1 \ + --hash=sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61 \ + --hash=sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822 + # via -r requirements.in pluggy==1.5.0 \ --hash=sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1 \ --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 # via pytest -pytest==8.3.3 \ - --hash=sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181 \ - --hash=sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2 +pycparser==2.22 \ + --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ + --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc + # via cffi +pygments==2.19.2 \ + --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ + --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b + # via pytest +pynacl==1.5.0 \ + --hash=sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858 \ + --hash=sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d \ + --hash=sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93 \ + --hash=sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1 \ + --hash=sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92 \ + --hash=sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff \ + --hash=sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba \ + --hash=sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394 \ + --hash=sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b \ + --hash=sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543 + # via paramiko +pytest==8.4.1 \ + --hash=sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 \ + --hash=sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c # via -r requirements.in requests==2.32.3 \ --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \