blob: cf72e097ceda282f6868953420fd34b5b974c7fa [file]
#!/usr/bin/env python3
#
# Copyright 2022 The Fuchsia Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import concurrent.futures
import datetime
import ipaddress
import json
import logging
import os
import platform
import random
import re
import signal
import socket
import string
import subprocess
import time
import traceback
import zipfile
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import TYPE_CHECKING
from mobly import signals
from antlion.libs.proc import job
from antlion.runner import CalledProcessError, Runner
if TYPE_CHECKING:
from antlion.controllers.android_device import AndroidDevice
from antlion.controllers.fuchsia_device import FuchsiaDevice
from antlion.controllers.utils_lib.ssh.connection import SshConnection
# File name length is limited to 255 chars on some OS, so we need to make sure
# the file names we output fits within the limit.
MAX_FILENAME_LEN = 255
# All Fuchsia devices use this suffix for link-local mDNS host names.
FUCHSIA_MDNS_TYPE = "_fuchsia._udp.local."
# Default max seconds it takes to Duplicate Address Detection to finish before
# assigning an IPv6 address.
DAD_TIMEOUT_SEC = 30
class ActsUtilsError(Exception):
"""Generic error raised for exceptions in ACTS utils."""
ascii_letters_and_digits = string.ascii_letters + string.digits
valid_filename_chars = f"-_.{ascii_letters_and_digits}"
models = (
"sprout",
"occam",
"hammerhead",
"bullhead",
"razor",
"razorg",
"shamu",
"angler",
"volantis",
"volantisg",
"mantaray",
"fugu",
"ryu",
"marlin",
"sailfish",
)
manufacture_name_to_model = {
"flo": "razor",
"flo_lte": "razorg",
"flounder": "volantis",
"flounder_lte": "volantisg",
"dragon": "ryu",
}
GMT_to_olson = {
"GMT-9": "America/Anchorage",
"GMT-8": "US/Pacific",
"GMT-7": "US/Mountain",
"GMT-6": "US/Central",
"GMT-5": "US/Eastern",
"GMT-4": "America/Barbados",
"GMT-3": "America/Buenos_Aires",
"GMT-2": "Atlantic/South_Georgia",
"GMT-1": "Atlantic/Azores",
"GMT+0": "Africa/Casablanca",
"GMT+1": "Europe/Amsterdam",
"GMT+2": "Europe/Athens",
"GMT+3": "Europe/Moscow",
"GMT+4": "Asia/Baku",
"GMT+5": "Asia/Oral",
"GMT+6": "Asia/Almaty",
"GMT+7": "Asia/Bangkok",
"GMT+8": "Asia/Hong_Kong",
"GMT+9": "Asia/Tokyo",
"GMT+10": "Pacific/Guam",
"GMT+11": "Pacific/Noumea",
"GMT+12": "Pacific/Fiji",
"GMT+13": "Pacific/Tongatapu",
"GMT-11": "Pacific/Midway",
"GMT-10": "Pacific/Honolulu",
}
def abs_path(path):
"""Resolve the '.' and '~' in a path to get the absolute path.
Args:
path: The path to expand.
Returns:
The absolute path of the input path.
"""
return os.path.abspath(os.path.expanduser(path))
def get_current_epoch_time():
"""Current epoch time in milliseconds.
Returns:
An integer representing the current epoch time in milliseconds.
"""
return int(round(time.time() * 1000))
def get_current_human_time():
"""Returns the current time in human readable format.
Returns:
The current time stamp in Month-Day-Year Hour:Min:Sec format.
"""
return time.strftime("%m-%d-%Y %H:%M:%S ")
def epoch_to_human_time(epoch_time):
"""Converts an epoch timestamp to human readable time.
This essentially converts an output of get_current_epoch_time to an output
of get_current_human_time
Args:
epoch_time: An integer representing an epoch timestamp in milliseconds.
Returns:
A time string representing the input time.
None if input param is invalid.
"""
if isinstance(epoch_time, int):
try:
d = datetime.datetime.fromtimestamp(epoch_time / 1000)
return d.strftime("%m-%d-%Y %H:%M:%S ")
except ValueError:
return None
def get_timezone_olson_id():
"""Return the Olson ID of the local (non-DST) timezone.
Returns:
A string representing one of the Olson IDs of the local (non-DST)
timezone.
"""
tzoffset = int(time.timezone / 3600)
gmt = None
if tzoffset <= 0:
gmt = f"GMT+{-tzoffset}"
else:
gmt = f"GMT-{tzoffset}"
return GMT_to_olson[gmt]
def get_next_device(test_bed_controllers, used_devices):
"""Gets the next device in a list of testbed controllers
Args:
test_bed_controllers: A list of testbed controllers of a particular
type, for example a list ACTS Android devices.
used_devices: A list of devices that have been used. This can be a
mix of devices, for example a fuchsia device and an Android device.
Returns:
The next device in the test_bed_controllers list or None if there are
no items that are not in the used devices list.
"""
if test_bed_controllers:
device_list = test_bed_controllers
else:
raise ValueError("test_bed_controllers is empty.")
for used_device in used_devices:
if used_device in device_list:
device_list.remove(used_device)
if device_list:
return device_list[0]
else:
return None
def find_files(paths, file_predicate):
"""Locate files whose names and extensions match the given predicate in
the specified directories.
Args:
paths: A list of directory paths where to find the files.
file_predicate: A function that returns True if the file name and
extension are desired.
Returns:
A list of files that match the predicate.
"""
file_list = []
if not isinstance(paths, list):
paths = [paths]
for path in paths:
p = abs_path(path)
for dirPath, subdirList, fileList in os.walk(p):
for fname in fileList:
name, ext = os.path.splitext(fname)
if file_predicate(name, ext):
file_list.append((dirPath, name, ext))
return file_list
def load_config(file_full_path, log_errors=True):
"""Loads a JSON config file.
Returns:
A JSON object.
"""
with open(file_full_path, "r") as f:
try:
return json.load(f)
except Exception as e:
if log_errors:
logging.error("Exception error to load %s: %s", f, e)
raise
def rand_ascii_str(length: int) -> str:
"""Generates a random string of specified length, composed of ascii letters
and digits.
Args:
length: The number of characters in the string.
Returns:
The random string generated.
"""
letters = [random.choice(ascii_letters_and_digits) for i in range(length)]
return "".join(letters)
def rand_hex_str(length: int) -> str:
"""Generates a random string of specified length, composed of hex digits
Args:
length: The number of characters in the string.
Returns:
The random string generated.
"""
letters = [random.choice(string.hexdigits) for i in range(length)]
return "".join(letters)
# Thead/Process related functions.
def concurrent_exec(func, param_list):
"""Executes a function with different parameters pseudo-concurrently.
This is basically a map function. Each element (should be an iterable) in
the param_list is unpacked and passed into the function. Due to Python's
GIL, there's no true concurrency. This is suited for IO-bound tasks.
Args:
func: The function that parforms a task.
param_list: A list of iterables, each being a set of params to be
passed into the function.
Returns:
A list of return values from each function execution. If an execution
caused an exception, the exception object will be the corresponding
result.
"""
with concurrent.futures.ThreadPoolExecutor(max_workers=30) as executor:
# Start the load operations and mark each future with its params
future_to_params = {executor.submit(func, *p): p for p in param_list}
return_vals = []
for future in concurrent.futures.as_completed(future_to_params):
params = future_to_params[future]
try:
return_vals.append(future.result())
except Exception as exc:
print(f"{params} generated an exception: {traceback.format_exc()}")
return_vals.append(exc)
return return_vals
def exe_cmd(*cmds):
"""Executes commands in a new shell.
Args:
cmds: A sequence of commands and arguments.
Returns:
The output of the command run.
Raises:
OSError is raised if an error occurred during the command execution.
"""
cmd = " ".join(cmds)
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
)
(out, err) = proc.communicate()
if not err:
return out
raise OSError(err)
def require_sl4a(android_devices):
"""Makes sure sl4a connection is established on the given AndroidDevice
objects.
Args:
android_devices: A list of AndroidDevice objects.
Raises:
AssertionError is raised if any given android device does not have SL4A
connection established.
"""
for ad in android_devices:
msg = f"SL4A connection not established properly on {ad.serial}."
assert ad.droid, msg
def _assert_subprocess_running(proc):
"""Checks if a subprocess has terminated on its own.
Args:
proc: A subprocess returned by subprocess.Popen.
Raises:
ActsUtilsError is raised if the subprocess has stopped.
"""
ret = proc.poll()
if ret is not None:
out, err = proc.communicate()
raise ActsUtilsError(
"Process %d has terminated. ret: %d, stderr: %s,"
" stdout: %s" % (proc.pid, ret, err, out)
)
def start_standing_subprocess(cmd, check_health_delay=0, shell=True):
"""Starts a long-running subprocess.
This is not a blocking call and the subprocess started by it should be
explicitly terminated with stop_standing_subprocess.
For short-running commands, you should use exe_cmd, which blocks.
You can specify a health check after the subprocess is started to make sure
it did not stop prematurely.
Args:
cmd: string, the command to start the subprocess with.
check_health_delay: float, the number of seconds to wait after the
subprocess starts to check its health. Default is 0,
which means no check.
Returns:
The subprocess that got started.
"""
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=shell,
preexec_fn=os.setpgrp,
)
logging.debug("Start standing subprocess with cmd: %s", cmd)
if check_health_delay > 0:
time.sleep(check_health_delay)
_assert_subprocess_running(proc)
return proc
def stop_standing_subprocess(proc, kill_signal=signal.SIGTERM):
"""Stops a subprocess started by start_standing_subprocess.
Before killing the process, we check if the process is running, if it has
terminated, ActsUtilsError is raised.
Catches and ignores the PermissionError which only happens on Macs.
Args:
proc: Subprocess to terminate.
"""
pid = proc.pid
logging.debug("Stop standing subprocess %d", pid)
_assert_subprocess_running(proc)
try:
os.killpg(pid, kill_signal)
except PermissionError:
pass
def wait_for_standing_subprocess(proc, timeout=None):
"""Waits for a subprocess started by start_standing_subprocess to finish
or times out.
Propagates the exception raised by the subprocess.wait(.) function.
The subprocess.TimeoutExpired exception is raised if the process timed-out
rather then terminating.
If no exception is raised: the subprocess terminated on its own. No need
to call stop_standing_subprocess() to kill it.
If an exception is raised: the subprocess is still alive - it did not
terminate. Either call stop_standing_subprocess() to kill it, or call
wait_for_standing_subprocess() to keep waiting for it to terminate on its
own.
Args:
p: Subprocess to wait for.
timeout: An integer number of seconds to wait before timing out.
"""
proc.wait(timeout)
def sync_device_time(ad):
"""Sync the time of an android device with the current system time.
Both epoch time and the timezone will be synced.
Args:
ad: The android device to sync time on.
"""
ad.adb.shell("settings put global auto_time 0", ignore_status=True)
ad.adb.shell("settings put global auto_time_zone 0", ignore_status=True)
droid = ad.droid
droid.setTimeZone(get_timezone_olson_id())
droid.setTime(get_current_epoch_time())
def set_ambient_display(ad, new_state):
"""Set "Ambient Display" in Settings->Display
Args:
ad: android device object.
new_state: new state for "Ambient Display". True or False.
"""
ad.adb.shell(f"settings put secure doze_enabled {1 if new_state else 0}")
def set_location_service(ad, new_state):
"""Set Location service on/off in Settings->Location
Args:
ad: android device object.
new_state: new state for "Location service".
If new_state is False, turn off location service.
If new_state if True, set location service to "High accuracy".
"""
ad.adb.shell(
"content insert --uri "
" content://com.google.settings/partner --bind "
"name:s:network_location_opt_in --bind value:s:1"
)
ad.adb.shell(
"content insert --uri "
" content://com.google.settings/partner --bind "
"name:s:use_location_for_services --bind value:s:1"
)
if new_state:
ad.adb.shell("settings put secure location_mode 3")
else:
ad.adb.shell("settings put secure location_mode 0")
def parse_ping_ouput(ad, count, out, loss_tolerance=20):
"""Ping Parsing util.
Args:
ad: Android Device Object.
count: Number of ICMP packets sent
out: shell output text of ping operation
loss_tolerance: Threshold after which flag test as false
Returns:
False: if packet loss is more than loss_tolerance%
True: if all good
"""
result = re.search(
r"(\d+) packets transmitted, (\d+) received, (\d+)% packet loss", out
)
if not result:
ad.log.info("Ping failed with %s", out)
return False
packet_loss = int(result.group(3))
packet_xmit = int(result.group(1))
packet_rcvd = int(result.group(2))
min_packet_xmit_rcvd = (100 - loss_tolerance) * 0.01
if (
packet_loss > loss_tolerance
or packet_xmit < count * min_packet_xmit_rcvd
or packet_rcvd < count * min_packet_xmit_rcvd
):
ad.log.error(
"%s, ping failed with loss more than tolerance %s%%",
result.group(0),
loss_tolerance,
)
return False
ad.log.info("Ping succeed with %s", result.group(0))
return True
def adb_shell_ping(
ad: AndroidDevice,
dest_ip: str,
count: int = 120,
timeout: int = 200,
loss_tolerance: int = 20,
) -> bool:
"""Ping utility using adb shell.
Args:
ad: Android Device Object.
count: Number of ICMP packets to send
dest_ip: hostname or IP address
default www.google.com
timeout: timeout for icmp pings to complete.
"""
ping_cmd = "ping -W 1"
if count:
ping_cmd += f" -c {count}"
if dest_ip:
ping_cmd += f" {dest_ip}"
try:
ad.log.info("Starting ping test to %s using adb command %s", dest_ip, ping_cmd)
out = ad.adb.shell(ping_cmd, timeout=timeout, ignore_status=True)
if not parse_ping_ouput(ad, count, out, loss_tolerance):
return False
return True
except Exception as e:
ad.log.warning("Ping Test to %s failed with exception %s", dest_ip, e)
return False
def zip_directory(zip_name, src_dir):
"""Compress a directory to a .zip file.
This implementation is thread-safe.
Args:
zip_name: str, name of the generated archive
src_dir: str, path to the source directory
"""
with zipfile.ZipFile(zip_name, "w", zipfile.ZIP_DEFLATED) as zip:
for root, dirs, files in os.walk(src_dir):
for file in files:
path = os.path.join(root, file)
zip.write(path, os.path.relpath(path, src_dir))
def unzip_maintain_permissions(zip_path, extract_location):
"""Unzip a .zip file while maintaining permissions.
Args:
zip_path: The path to the zipped file.
extract_location: the directory to extract to.
"""
with zipfile.ZipFile(zip_path, "r") as zip_file:
for info in zip_file.infolist():
_extract_file(zip_file, info, extract_location)
def _extract_file(zip_file, zip_info, extract_location):
"""Extracts a single entry from a ZipFile while maintaining permissions.
Args:
zip_file: A zipfile.ZipFile.
zip_info: A ZipInfo object from zip_file.
extract_location: The directory to extract to.
"""
out_path = zip_file.extract(zip_info.filename, path=extract_location)
perm = zip_info.external_attr >> 16
os.chmod(out_path, perm)
def get_command_uptime(command_regex: str):
"""Returns the uptime for a given command.
Args:
command_regex: A regex that matches the command line given. Must be
pgrep compatible.
"""
pid = job.run(f"pgrep -f {command_regex}").stdout.decode("utf-8")
runtime = ""
if pid:
runtime = job.run(f'ps -o etime= -p "{pid}"').stdout.decode("utf-8")
return runtime
def get_device_process_uptime(adb, process):
"""Returns the uptime of a device process."""
pid = adb.shell(f"pidof {process}", ignore_status=True)
runtime = ""
if pid:
runtime = adb.shell(f'ps -o etime= -p "{pid}"')
return runtime
def is_valid_ipv4_address(address):
try:
socket.inet_pton(socket.AF_INET, address)
except AttributeError: # no inet_pton here, sorry
try:
socket.inet_aton(address)
except socket.error:
return False
return address.count(".") == 3
except socket.error: # not a valid address
return False
return True
def is_valid_ipv6_address(address):
if "%" in address:
address = address.split("%")[0]
try:
socket.inet_pton(socket.AF_INET6, address)
except socket.error: # not a valid address
return False
return True
def get_interface_ip_addresses(
comm_channel: AndroidDevice | SshConnection | FuchsiaDevice,
interface: str,
) -> dict[str, list[str]]:
"""Gets all of the ip addresses, ipv4 and ipv6, associated with a
particular interface name.
Args:
comm_channel: How to send commands to a device. Can be ssh, adb serial,
etc. Must have the run function implemented.
interface: The interface name on the device, ie eth0
Returns:
A list of dictionaries of the the various IP addresses:
ipv4_private: Any 192.168, 172.16, 10, or 169.254 addresses
ipv4_public: Any IPv4 public addresses
ipv6_link_local: Any fe80:: addresses
ipv6_private_local: Any fd00:: addresses
ipv6_public: Any publicly routable addresses
"""
# Local imports are used here to prevent cyclic dependency.
from antlion.controllers.android_device import AndroidDevice
from antlion.controllers.fuchsia_device import FuchsiaDevice
from antlion.controllers.utils_lib.ssh.connection import SshConnection
addrs: list[str] = []
if isinstance(comm_channel, AndroidDevice):
addrs = comm_channel.adb.shell(
f'ip -o addr show {interface} | awk \'{{gsub("/", " "); print $4}}\''
).splitlines()
elif isinstance(comm_channel, SshConnection):
ip = comm_channel.run(["ip", "-o", "addr", "show", interface])
addrs = [
addr.replace("/", " ").split()[3]
for addr in ip.stdout.decode("utf-8").splitlines()
]
elif isinstance(comm_channel, FuchsiaDevice):
for iface in comm_channel.honeydew_fd.netstack.list_interfaces():
if iface.name != interface:
continue
for ipv4_address in iface.ipv4_addresses:
addrs.append(str(ipv4_address))
for ipv6_address in iface.ipv6_addresses:
addrs.append(str(ipv6_address))
else:
raise ValueError("Unsupported method to send command to device.")
ipv4_private_local_addresses = []
ipv4_public_addresses = []
ipv6_link_local_addresses = []
ipv6_private_local_addresses = []
ipv6_public_addresses = []
for addr in addrs:
on_device_ip = ipaddress.ip_address(addr)
if on_device_ip.version == 4:
if on_device_ip.is_private:
ipv4_private_local_addresses.append(str(on_device_ip))
elif on_device_ip.is_global or (
# Carrier private doesn't have a property, so we check if
# all other values are left unset.
not on_device_ip.is_reserved
and not on_device_ip.is_unspecified
and not on_device_ip.is_link_local
and not on_device_ip.is_loopback
and not on_device_ip.is_multicast
):
ipv4_public_addresses.append(str(on_device_ip))
elif on_device_ip.version == 6:
if on_device_ip.is_link_local:
ipv6_link_local_addresses.append(str(on_device_ip))
elif on_device_ip.is_private:
ipv6_private_local_addresses.append(str(on_device_ip))
elif on_device_ip.is_global:
ipv6_public_addresses.append(str(on_device_ip))
return {
"ipv4_private": ipv4_private_local_addresses,
"ipv4_public": ipv4_public_addresses,
"ipv6_link_local": ipv6_link_local_addresses,
"ipv6_private_local": ipv6_private_local_addresses,
"ipv6_public": ipv6_public_addresses,
}
class AddressTimeout(signals.TestError):
pass
class MultipleAddresses(signals.TestError):
pass
def get_addr(
comm_channel: AndroidDevice | SshConnection | FuchsiaDevice,
interface: str,
addr_type: str = "ipv4_private",
timeout_sec: int | None = None,
) -> str:
"""Get the requested type of IP address for an interface; if an address is
not available, retry until the timeout has been reached.
Args:
addr_type: Type of address to get as defined by the return value of
utils.get_interface_ip_addresses.
timeout_sec: Seconds to wait to acquire an address if there isn't one
already available. If fetching an IPv4 address, the default is 3
seconds. If IPv6, the default is 30 seconds for Duplicate Address
Detection.
Returns:
A string containing the requested address.
Raises:
TestAbortClass: timeout_sec is None and invalid addr_type
AddressTimeout: No address is available after timeout_sec
MultipleAddresses: Several addresses are available
"""
if not timeout_sec:
if "ipv4" in addr_type:
timeout_sec = 3
elif "ipv6" in addr_type:
timeout_sec = DAD_TIMEOUT_SEC
else:
raise signals.TestAbortClass(f'Unknown addr_type "{addr_type}"')
timeout = time.time() + timeout_sec
while time.time() < timeout:
ip_addrs = get_interface_ip_addresses(comm_channel, interface)[addr_type]
if len(ip_addrs) > 1:
raise MultipleAddresses(
f'Expected only one "{addr_type}" address, got {ip_addrs}'
)
elif len(ip_addrs) == 1:
return ip_addrs[0]
raise AddressTimeout(f'No available "{addr_type}" address after {timeout_sec}s')
def get_interface_based_on_ip(runner: Runner, desired_ip_address: str) -> str:
"""Gets the interface for a particular IP
Args:
comm_channel: How to send commands to a device. Can be ssh, adb serial,
etc. Must have the run function implemented.
desired_ip_address: The IP address that is being looked for on a device.
Returns:
The name of the test interface.
Raises:
RuntimeError: when desired_ip_address is not found
"""
desired_ip_address = desired_ip_address.split("%", 1)[0]
ip = runner.run(["ip", "-o", "addr", "show"])
for line in ip.stdout.decode("utf-8").splitlines():
if desired_ip_address in line:
return line.split()[1]
raise RuntimeError(
f'IP "{desired_ip_address}" not found in list:\n{ip.stdout.decode("utf-8")}'
)
def renew_linux_ip_address(runner: Runner, interface: str) -> None:
runner.run(f"sudo ip link set {interface} down")
runner.run(f"sudo ip link set {interface} up")
runner.run(f"sudo dhclient -r {interface}")
runner.run(f"sudo dhclient {interface}")
def get_ping_command(
dest_ip: str,
count: int = 3,
interval: int = 1000,
timeout: int = 1000,
size: int = 56,
os_type: str = "Linux",
additional_ping_params: str = "",
) -> str:
"""Builds ping command string based on address type, os, and params.
Args:
dest_ip: string, address to ping (ipv4 or ipv6)
count: int, number of requests to send
interval: int, time in seconds between requests
timeout: int, time in seconds to wait for response
size: int, number of bytes to send,
os_type: string, os type of the source device (supports 'Linux',
'Darwin')
additional_ping_params: string, command option flags to
append to the command string
Returns:
The ping command.
"""
if is_valid_ipv4_address(dest_ip):
ping_binary = "ping"
elif is_valid_ipv6_address(dest_ip):
ping_binary = "ping6"
else:
raise ValueError(f"Invalid ip addr: {dest_ip}")
if os_type == "Darwin":
if is_valid_ipv6_address(dest_ip):
# ping6 on MacOS doesn't support timeout
logging.debug("Ignoring timeout, as ping6 on MacOS does not support it.")
timeout_flag = []
else:
timeout_flag = ["-t", str(timeout / 1000)]
elif os_type == "Linux":
timeout_flag = ["-W", str(timeout / 1000)]
else:
raise ValueError("Invalid OS. Only Linux and MacOS are supported.")
ping_cmd = [
ping_binary,
*timeout_flag,
"-c",
str(count),
"-i",
str(interval / 1000),
"-s",
str(size),
additional_ping_params,
dest_ip,
]
return " ".join(ping_cmd)
def ping(
comm_channel: Runner,
dest_ip: str,
count: int = 3,
interval: int = 1000,
timeout: int = 1000,
size: int = 56,
additional_ping_params: str = "",
) -> PingResult:
"""Generic linux ping function, supports local (acts.libs.proc.job) and
SshConnections (acts.libs.proc.job over ssh) to Linux based OSs and MacOS.
NOTES: This will work with Android over SSH, but does not function over ADB
as that has a unique return format.
Args:
comm_channel: communication channel over which to send ping command.
Must have 'run' function that returns at least command, stdout,
stderr, and exit_status (see acts.libs.proc.job)
dest_ip: address to ping (ipv4 or ipv6)
count: int, number of packets to send
interval: int, time in milliseconds between pings
timeout: int, time in milliseconds to wait for response
size: int, size of packets in bytes
additional_ping_params: string, command option flags to
append to the command string
Returns:
Dict containing:
command: string
exit_status: int (0 or 1)
stdout: string
stderr: string
transmitted: int, number of packets transmitted
received: int, number of packets received
packet_loss: int, percentage packet loss
time: int, time of ping command execution (in milliseconds)
rtt_min: float, minimum round trip time
rtt_avg: float, average round trip time
rtt_max: float, maximum round trip time
rtt_mdev: float, round trip time standard deviation
Any values that cannot be parsed are left as None
"""
from antlion.controllers.utils_lib.ssh.connection import SshConnection
is_local = comm_channel == job
os_type = platform.system() if is_local else "Linux"
ping_cmd = get_ping_command(
dest_ip,
count=count,
interval=interval,
timeout=timeout,
size=size,
os_type=os_type,
additional_ping_params=additional_ping_params,
)
if isinstance(comm_channel, SshConnection) or is_local:
logging.debug(
"Running ping with parameters (count: %s, interval: %s, "
"timeout: %s, size: %s)",
count,
interval,
timeout,
size,
)
try:
ping_result: subprocess.CompletedProcess[
bytes
] | CalledProcessError = comm_channel.run(ping_cmd)
except CalledProcessError as e:
ping_result = e
else:
raise ValueError(f"Unsupported comm_channel: {type(comm_channel)}")
summary = re.search(
"([0-9]+) packets transmitted.*?([0-9]+) received.*?([0-9]+)% packet "
"loss.*?time ([0-9]+)",
ping_result.stdout.decode("utf-8"),
)
rtt_stats = re.search(
"= ([0-9.]+)/([0-9.]+)/([0-9.]+)/([0-9.]+)", ping_result.stdout.decode("utf-8")
)
return PingResult(
exit_status=ping_result.returncode,
stdout=ping_result.stdout.decode("utf-8"),
stderr=ping_result.stderr.decode("utf-8"),
transmitted=int(summary.group(1)) if summary else None,
received=int(summary.group(2)) if summary else None,
time_ms=float(summary.group(4)) / 1000 if summary else None,
rtt_min_ms=float(rtt_stats.group(1)) if rtt_stats else None,
rtt_avg_ms=float(rtt_stats.group(2)) if rtt_stats else None,
rtt_max_ms=float(rtt_stats.group(3)) if rtt_stats else None,
rtt_mdev_ms=float(rtt_stats.group(4)) if rtt_stats else None,
)
@dataclass
class PingResult:
exit_status: int
stdout: str
stderr: str
transmitted: int | None
received: int | None
time_ms: float | None
rtt_min_ms: float | None
rtt_avg_ms: float | None
rtt_max_ms: float | None
rtt_mdev_ms: float | None
@property
def success(self) -> bool:
return self.exit_status == 0
def ip_in_subnet(ip, subnet):
"""Validate that ip is in a given subnet.
Args:
ip: string, ip address to verify (eg. '192.168.42.158')
subnet: string, subnet to check (eg. '192.168.42.0/24')
Returns:
True, if ip in subnet, else False
"""
return ipaddress.ip_address(ip) in ipaddress.ip_network(subnet)
def mac_address_str_to_list(mac_addr_str):
"""Converts mac address string to list of decimal octets.
Args:
mac_addr_string: string, mac address
e.g. '12:34:56:78:9a:bc'
Returns
list, representing mac address octets in decimal
e.g. [18, 52, 86, 120, 154, 188]
"""
return [int(octet, 16) for octet in mac_addr_str.split(":")]
def mac_address_list_to_str(mac_addr_list: bytes) -> str:
"""Converts list of decimal octets representing mac address to string.
Args:
mac_addr_list: list, representing mac address octets in decimal
e.g. [18, 52, 86, 120, 154, 188]
Returns:
string, mac address
e.g. '12:34:56:78:9a:bc'
"""
# Print each octet as hex, right justified, width of 2, and fill with "0".
return ":".join([f"{octet:0>2x}" for octet in mac_addr_list])
def get_fuchsia_mdns_ipv6_address(device_mdns_name):
"""Finds the IPv6 link-local address of a Fuchsia device matching a mDNS
name.
Args:
device_mdns_name: name of Fuchsia device (e.g. gig-clone-sugar-slash)
Returns:
string, IPv6 link-local address
"""
import psutil
from zeroconf import IPVersion, Zeroconf
if not device_mdns_name:
return None
def mdns_query(interface, address):
logging.info(
f'Sending mDNS query for device "{device_mdns_name}" using "{address}"'
)
try:
zeroconf = Zeroconf(ip_version=IPVersion.V6Only, interfaces=[address])
except RuntimeError as e:
if "No adapter found for IP address" in e.args[0]:
# Most likely, a device went offline and its control
# interface was deleted. This is acceptable since the
# device that went offline isn't guaranteed to be the
# device we're searching for.
logging.warning(f'No adapter found for "{address}"')
return None
raise
device_records = zeroconf.get_service_info(
FUCHSIA_MDNS_TYPE, f"{device_mdns_name}.{FUCHSIA_MDNS_TYPE}"
)
if device_records:
for device_address in device_records.parsed_addresses():
device_ip_address = ipaddress.ip_address(device_address)
scoped_address = f"{device_address}%{interface}"
if (
device_ip_address.version == 6
and device_ip_address.is_link_local
and ping(job, dest_ip=scoped_address).success
):
logging.info(
f'Found device "{device_mdns_name}" at "{scoped_address}"'
)
zeroconf.close()
del zeroconf
return scoped_address
zeroconf.close()
del zeroconf
return None
with ThreadPoolExecutor() as executor:
futures = []
interfaces = psutil.net_if_addrs()
for interface in interfaces:
for addr in interfaces[interface]:
address = addr.address.split("%")[0]
if (
addr.family == socket.AF_INET6
and ipaddress.ip_address(address).is_link_local
and address != "fe80::1"
):
futures.append(executor.submit(mdns_query, interface, address))
for future in futures:
addr = future.result()
if addr:
return addr
logging.error(f'Unable to find IP address for device "{device_mdns_name}"')
return None
def get_device(devices, device_type):
"""Finds a unique device with the specified "device_type" attribute from a
list. If none is found, defaults to the first device in the list.
Example:
get_device(android_devices, device_type="DUT")
get_device(fuchsia_devices, device_type="DUT")
get_device(android_devices + fuchsia_devices, device_type="DUT")
Args:
devices: A list of device controller objects.
device_type: (string) Type of device to find, specified by the
"device_type" attribute.
Returns:
The matching device controller object, or the first device in the list
if not found.
Raises:
ValueError is raised if none or more than one device is
matched.
"""
if not devices:
raise ValueError("No devices available")
matches = [
d for d in devices if hasattr(d, "device_type") and d.device_type == device_type
]
if len(matches) == 0:
# No matches for the specified "device_type", use the first device
# declared.
return devices[0]
if len(matches) > 1:
# Specifing multiple devices with the same "device_type" is a
# configuration error.
raise ValueError(
f'More than one device matching "device_type" == "{device_type}"'
)
return matches[0]