blob: 377843f996744ca0a724eec91f3f27339953b5cb [file] [log] [blame]
# Copyright 2023 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Mobly Controller for Fuchsia Device"""
import logging
from copy import deepcopy
from typing import Any
import honeydew
from honeydew.fuchsia_device import fuchsia_device as fuchsia_device_interface
from honeydew.transports.ffx import config as ffx_config
from honeydew.typing import custom_types
from honeydew.utils import properties
MOBLY_CONTROLLER_CONFIG_NAME = "FuchsiaDevice"
_LOGGER: logging.Logger = logging.getLogger(__name__)
_FFX_CONFIG_OBJ: ffx_config.FfxConfig = ffx_config.FfxConfig()
_FFX_CONFIG_LOGS_LEVEL: str = "debug"
_FFX_CONFIG_PROXY_TIMEOUT_SECS: int = 30
def create(
configs: list[dict[str, Any]],
) -> list[fuchsia_device_interface.FuchsiaDevice]:
"""Create Fuchsia device controller(s) and returns them.
Required for Mobly controller registration.
Args:
configs: list of dicts. Each dict representing a configuration for a
Fuchsia device.
Returns:
A list of FuchsiaDevice objects.
"""
_LOGGER.debug(
"FuchsiaDevice controller configs received in testbed yml file is '%s'",
configs,
)
test_logs_dir: str = _get_log_directory()
ffx_config_dict: dict[str, Any] = _get_ffx_config(configs)
# Call `FfxConfig.setup` before calling `create_device` as
# `create_device` results in calling an FFX command and we
# don't want to miss those FFX logs
# Note - As of now same FFX Config is used across all fuchsia devices.
# This means we will have one FFX daemon running which will talk to all
# fuchsia devices in the testbed.
# This is okay for in-tree use cases but may not work for OOT cases where
# each fuchsia device may be running different build that require different
# FFX version.
# This will also not work if you have 2 devices in testbed with one uses
# device_ip and one uses device_name for FFX commands. This should not
# happen in our setups though as we will either have mdns enabled or
# disabled on host.
# Right fix for all such cases is to use separate ffx config per device.
# This support will be added when needed in future.
_FFX_CONFIG_OBJ.setup(
binary_path=ffx_config_dict["path"],
isolate_dir=None,
logs_dir=f"{test_logs_dir}/ffx/",
logs_level=ffx_config_dict["logs_level"],
enable_mdns=ffx_config_dict["enable_mdns"],
subtools_search_path=ffx_config_dict.get("subtools_search_path"),
proxy_timeout_secs=ffx_config_dict["proxy_timeout_secs"],
ssh_keepalive_timeout=ffx_config_dict.get("ssh_keepalive_timeout"),
)
fuchsia_devices: list[fuchsia_device_interface.FuchsiaDevice] = []
for config in configs:
device_config: dict[str, Any] = _parse_device_config(config)
fuchsia_devices.append(
honeydew.create_device(
device_info=custom_types.DeviceInfo(
name=device_config["name"],
ip_port=device_config.get("device_ip_port"),
serial_socket=device_config.get("serial_socket"),
),
ffx_config_data=_FFX_CONFIG_OBJ.get_config(),
config=device_config["honeydew_config"],
)
)
return fuchsia_devices
def destroy(
fuchsia_devices: list[fuchsia_device_interface.FuchsiaDevice],
) -> None:
"""Closes all created fuchsia devices.
Required for Mobly controller registration.
Args:
fuchsia_devices: A list of FuchsiaDevice objects.
"""
for fuchsia_device in fuchsia_devices:
fuchsia_device.close()
# Call `FfxConfig.close` manually even though it's already registered for
# clean up in `FfxConfig.setup` in order to minimize chance of FFX daemon
# leak in the event that SIGKILL/SIGTERM is received between `destroy` and
# test program exit.
_FFX_CONFIG_OBJ.close()
def get_info(
fuchsia_devices: list[fuchsia_device_interface.FuchsiaDevice],
) -> list[dict[str, Any]]:
"""Gets information from a list of FuchsiaDevice objects.
Optional for Mobly controller registration.
Args:
fuchsia_devices: A list of FuchsiaDevice objects.
Returns:
A list of dict, each representing info for an FuchsiaDevice objects.
"""
return [
_get_fuchsia_device_info(fuchsia_device)
for fuchsia_device in fuchsia_devices
]
def _get_fuchsia_device_info(
fuchsia_device: fuchsia_device_interface.FuchsiaDevice,
) -> dict[str, Any]:
"""Returns information of a specific fuchsia device object.
Args:
fuchsia_device: FuchsiaDevice object.
Returns:
dict containing information of a fuchsia device.
"""
_LOGGER.debug("Getting the device info for %s", fuchsia_device.device_name)
device_info: dict[str, Any] = {
"device_class": fuchsia_device.__class__.__name__,
"persistent": {},
"dynamic": {},
}
for attr in dir(fuchsia_device):
if attr.startswith("_"):
continue
try:
attr_type: Any = getattr(type(fuchsia_device), attr, None)
if isinstance(attr_type, properties.DynamicProperty):
_LOGGER.debug("Reading dynamic device property: %s", attr)
device_info["dynamic"][attr] = getattr(fuchsia_device, attr)
elif isinstance(attr_type, properties.PersistentProperty):
_LOGGER.debug("Reading persistent device property: %s", attr)
device_info["persistent"][attr] = getattr(fuchsia_device, attr)
except NotImplementedError:
pass
_LOGGER.debug("Device info complete for %s", fuchsia_device.device_name)
return device_info
def _enable_mdns(configs: list[dict[str, Any]]) -> bool:
for config in configs:
device_config: dict[str, Any] = _parse_device_config(config)
if not device_config.get("device_ip_port"):
return True
return False
# LINT.IfChange
def _parse_device_config(config: dict[str, str]) -> dict[str, Any]:
"""Validates and parses mobly configuration associated with FuchsiaDevice.
Args:
config: The mobly configuration associated with FuchsiaDevice.
Returns:
Validated and parsed mobly configuration associated with FuchsiaDevice.
Raises:
RuntimeError: If the fuchsia device name in the config is missing.
ValueError: If either transport device_ip_port is invalid.
"""
_LOGGER.debug(
"FuchsiaDevice controller config received in testbed yml file is '%s'",
config,
)
# Sample testbed file format for FuchsiaDevice controller used in infra...
# - Controllers:
# FuchsiaDevice:
# - ipv4: ''
# ipv6: fe80::93e3:e3d4:b314:6e9b%qemu
# nodename: botanist-target-qemu
# serial_socket: ''
# ssh_key: private_key
# device_ip_port: [::1]:8022
if "name" not in config:
raise RuntimeError("Missing fuchsia device name in the config")
if "honeydew_config" not in config:
raise RuntimeError("Missing Honeydew config field in the config")
device_config: dict[str, Any] = {}
for config_key, config_value in config.items():
if config_key == "device_ip_port":
try:
device_config[
"device_ip_port"
] = custom_types.IpPort.create_using_ip_and_port(config_value)
except Exception as err: # pylint: disable=broad-except
raise ValueError(
f"Invalid device_ip_port `{config_value}` passed for "
f"{config['name']}"
) from err
elif config_key in ["ipv4", "ipv6"]:
if config.get("ipv4"):
device_config[
"device_ip_port"
] = custom_types.IpPort.create_using_ip(config["ipv4"])
if config.get("ipv6"):
device_config[
"device_ip_port"
] = custom_types.IpPort.create_using_ip(config["ipv6"])
else:
device_config[config_key] = config_value
_LOGGER.debug(
"Updated FuchsiaDevice controller config after the validation is '%s'",
device_config,
)
return device_config
# LINT.ThenChange(//build/bazel_sdk/bazel_rules_fuchsia/fuchsia/tools/run_lacewing_test.py)
def _get_log_directory() -> str:
"""Returns the path to the directory where logs should be stored.
Returns:
Directory path.
"""
# TODO(https://fxbug.dev/42078903): Read log path from config once this issue is fixed
return getattr(
logging,
"log_path", # Set by Mobly in base_test.BaseTestClass.run.
)
def _get_ffx_config(configs: list[dict[str, Any]]) -> dict[str, Any]:
"""Read the FFX configuration from the FuchsiaDevice config and validate the mandatory config.
Args:
configs: list of dicts. Each dict representing a configuration for a
Fuchsia device.
Returns:
Dict pointing to FFX configuration.
"""
ffx_config_dict: dict[str, Any] = {}
# FFX config is currently global and not localized to the individual devices so
# just return the the first ffx config encountered.
for config in configs:
try:
ffx_config_dict = deepcopy(
config["honeydew_config"]["transports"]["ffx"]
)
except KeyError:
pass
if not ffx_config_dict:
raise RuntimeError("No FFX config found in any of the device config")
if "path" not in ffx_config_dict:
raise RuntimeError("No FFX path found in device config")
if "logs_level" not in ffx_config_dict:
ffx_config_dict["logs_level"] = _FFX_CONFIG_LOGS_LEVEL
if "proxy_timeout_secs" not in ffx_config_dict:
ffx_config_dict["proxy_timeout_secs"] = _FFX_CONFIG_PROXY_TIMEOUT_SECS
for ffx_config_key in ["proxy_timeout_secs", "ssh_keepalive_timeout"]:
if ffx_config_key in ffx_config_dict:
try:
ffx_config_dict[ffx_config_key] = int(
ffx_config_dict[ffx_config_key]
)
except (TypeError, ValueError) as err:
raise RuntimeError(
f"Invalid value sent in '{ffx_config_key}'. Please pass a int value"
) from err
ffx_config_dict["enable_mdns"] = _enable_mdns(configs)
return ffx_config_dict