blob: 427a928873e63c9b5684163ee122d45ee088d1a4 [file]
#!/usr/bin/env python3
#
# Copyright 2026 The Fuchsia Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# Blanket ignores to enable mypy in Antlion
# mypy: disable-error-code="no-untyped-def, attr-defined"
from __future__ import annotations
import logging
import os
import subprocess
import threading
from abc import ABC, abstractmethod
from libs.commands.date import LinuxDateCommand
from libs.ssh.provider import SSHConfig, SSHProvider
from libs.types import ControllerConfig, Json
from libs.validation import MapValidator
MOBLY_CONTROLLER_CONFIG_NAME: str = "IPerfClient"
def create(configs: list[ControllerConfig]) -> list[IPerfClientBase]:
"""Factory method for iperf clients.
The function creates iperf clients based on config.
If configs contain ssh settings, remote iperf clients
over ssh will be started on those devices.
Args:
configs: config parameters for the iperf client
Returns:
A list of iperf client objects connected over SSH.
Raises:
ValueError: If a config entry is missing 'ssh_config'.
"""
results: list[IPerfClientBase] = []
for config in configs:
c = MapValidator(config)
if "ssh_config" in config:
results.append(
IPerfClientOverSsh(
SSHProvider(
SSHConfig.from_config(c.get(dict, "ssh_config"))
),
test_interface=c.get(str, "test_interface"),
sync_date=c.get(bool, "sync_date", True),
)
)
else:
raise ValueError(
f"Config entry {config} in {configs} is missing 'ssh_config'."
)
return results
def destroy(objects: list[IPerfClientBase]) -> None:
# No cleanup needed.
pass
def get_info(objects: list[IPerfClientBase]) -> list[Json]:
return []
class IPerfClientBase(ABC):
"""The Base class for all IPerfClients.
This base class is responsible for synchronizing the logging to prevent
multiple IPerfClients from writing results to the same file, as well
as providing the interface for IPerfClient objects.
"""
# Keeps track of the number of IPerfClient logs to prevent file name
# collisions.
__log_file_counter = 0
__log_file_lock = threading.Lock()
@property
@abstractmethod
def test_interface(self) -> str | None:
"""Find the test interface.
Returns:
Name of the interface used to communicate with server_ap, or None if
not set.
"""
...
@staticmethod
def _get_full_file_path(tag: str = "") -> str:
"""Returns the full file path for the IPerfClient log file.
Note: If the directory for the file path does not exist, it will be
created.
Args:
tag: The tag passed in to the server run.
"""
# Set by Mobly in base_test.BaseTestClass.run.
full_out_dir = os.path.join(
getattr(logging, "log_path"), "iperf_client_files"
)
with IPerfClientBase.__log_file_lock:
os.makedirs(full_out_dir, exist_ok=True)
tags = ["IPerfClient", tag, IPerfClientBase.__log_file_counter]
out_file_name = "%s.log" % (
",".join([str(x) for x in tags if x != "" and x is not None])
)
IPerfClientBase.__log_file_counter += 1
return os.path.join(full_out_dir, out_file_name)
def start(
self,
ip: str,
iperf_args: str,
tag: str,
timeout: int = 3600,
iperf_binary: str | None = None,
) -> str:
"""Starts iperf client, and waits for completion.
Args:
ip: iperf server ip address.
iperf_args: A string representing arguments to start iperf
client. Eg: iperf_args = "-t 10 -p 5001 -w 512k/-u -b 200M -J".
tag: A string to further identify iperf results file
timeout: the maximum amount of time the iperf client can run.
iperf_binary: Location of iperf3 binary. If none, it is assumed
the binary is in the path.
Returns:
full_out_path: iperf result path.
"""
raise NotImplementedError("start() must be implemented.")
class IPerfClientOverSsh(IPerfClientBase):
"""Class that handles iperf3 client operations on remote machines."""
def __init__(
self,
ssh_provider: SSHProvider,
test_interface: str | None = None,
sync_date: bool = True,
):
self._ssh_provider = ssh_provider
self._test_interface = test_interface
if sync_date:
# iperf clients are not given internet access, so their system time
# needs to be manually set to be accurate.
LinuxDateCommand(self._ssh_provider).sync()
@property
def test_interface(self) -> str | None:
return self._test_interface
def start(
self,
ip: str,
iperf_args: str,
tag: str,
timeout: int = 3600,
iperf_binary: str | None = None,
) -> str:
"""Starts iperf client, and waits for completion.
Args:
ip: iperf server ip address.
iperf_args: A string representing arguments to start iperf
client. Eg: iperf_args = "-t 10 -p 5001 -w 512k/-u -b 200M -J".
tag: tag to further identify iperf results file
timeout: the maximum amount of time to allow the iperf client to run
iperf_binary: Location of iperf3 binary. If none, it is assumed
the binary is in the path.
Returns:
full_out_path: iperf result path.
"""
if not iperf_binary:
logging.debug(
"No iperf3 binary specified. "
"Assuming iperf3 is in the path."
)
iperf_binary = "iperf3"
else:
logging.debug(f"Using iperf3 binary located at {iperf_binary}")
iperf_cmd = f"{iperf_binary} -c {ip} {iperf_args}"
full_out_path = self._get_full_file_path(tag)
try:
iperf_process = self._ssh_provider.run(
iperf_cmd, timeout_sec=timeout
)
stdout = iperf_process.stdout or b""
except subprocess.CalledProcessError as err:
# Capture output on non-zero exit (e.g. server busy) so error details
# are preserved in the log file instead of lost.
logging.warning(f"iperf client exited with error: {err}")
stdout = err.stdout or b""
except subprocess.TimeoutExpired as err:
raise TimeoutError(
f"Command execution timed out waiting for remote iperf client after {timeout} seconds: {err}"
) from err
with open(full_out_path, "wb") as out_file:
out_file.write(stdout)
return full_out_path