1# Copyright 2022 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4"""Common methods and variables used by Cr-Fuchsia testing infrastructure.""" 5 6import json 7import logging 8import os 9import signal 10import shutil 11import subprocess 12import sys 13import time 14 15from argparse import ArgumentParser 16from typing import Iterable, List, Optional, Tuple 17 18from compatible_utils import get_ssh_prefix, get_host_arch 19 20 21def _find_src_root() -> str: 22 """Find the root of the src folder.""" 23 if os.environ.get('SRC_ROOT'): 24 return os.environ['SRC_ROOT'] 25 return os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, 26 os.pardir) 27 28 29# The absolute path of the root folder to work on. It may not always be the 30# src folder since there may not be source code at all, but it's expected to 31# have folders like third_party/fuchsia-sdk in it. 32DIR_SRC_ROOT = os.path.abspath(_find_src_root()) 33 34 35def _find_fuchsia_images_root() -> str: 36 """Define the root of the fuchsia images.""" 37 if os.environ.get('FUCHSIA_IMAGES_ROOT'): 38 return os.environ['FUCHSIA_IMAGES_ROOT'] 39 return os.path.join(DIR_SRC_ROOT, 'third_party', 'fuchsia-sdk', 'images') 40 41 42IMAGES_ROOT = os.path.abspath(_find_fuchsia_images_root()) 43 44 45def _find_fuchsia_internal_images_root() -> str: 46 """Define the root of the fuchsia images.""" 47 if os.environ.get('FUCHSIA_INTERNAL_IMAGES_ROOT'): 48 return os.environ['FUCHSIA_INTERNAL_IMAGES_ROOT'] 49 return IMAGES_ROOT + '-internal' 50 51 52INTERNAL_IMAGES_ROOT = os.path.abspath(_find_fuchsia_internal_images_root()) 53 54REPO_ALIAS = 'fuchsia.com' 55 56 57def _find_fuchsia_sdk_root() -> str: 58 """Define the root of the fuchsia sdk.""" 59 if os.environ.get('FUCHSIA_SDK_ROOT'): 60 return os.environ['FUCHSIA_SDK_ROOT'] 61 return os.path.join(DIR_SRC_ROOT, 'third_party', 'fuchsia-sdk', 'sdk') 62 63 64SDK_ROOT = os.path.abspath(_find_fuchsia_sdk_root()) 65 66 67def _find_fuchsia_gn_sdk_root() -> str: 68 """Define the root of the fuchsia sdk.""" 69 if os.environ.get('FUCHSIA_GN_SDK_ROOT'): 70 return os.environ['FUCHSIA_GN_SDK_ROOT'] 71 return os.path.join(DIR_SRC_ROOT, 'third_party', 'fuchsia-gn-sdk', 'src') 72 73 74GN_SDK_ROOT = os.path.abspath(_find_fuchsia_gn_sdk_root()) 75 76SDK_TOOLS_DIR = os.path.join(SDK_ROOT, 'tools', get_host_arch()) 77_FFX_TOOL = os.path.join(SDK_TOOLS_DIR, 'ffx') 78 79 80def set_ffx_isolate_dir(isolate_dir: str) -> None: 81 """Overwrites the global environment so the following ffx calls will have 82 the isolate dir being carried.""" 83 84 os.environ['FFX_ISOLATE_DIR'] = isolate_dir 85 86 87def get_hash_from_sdk(): 88 """Retrieve version info from the SDK.""" 89 90 version_file = os.path.join(SDK_ROOT, 'meta', 'manifest.json') 91 assert os.path.exists(version_file), \ 92 'Could not detect version file. Make sure the SDK is downloaded.' 93 with open(version_file, 'r') as f: 94 return json.load(f)['id'] 95 96 97def get_host_tool_path(tool): 98 """Get a tool from the SDK.""" 99 100 return os.path.join(SDK_TOOLS_DIR, tool) 101 102 103def get_host_os(): 104 """Get host operating system.""" 105 106 host_platform = sys.platform 107 if host_platform.startswith('linux'): 108 return 'linux' 109 if host_platform.startswith('darwin'): 110 return 'mac' 111 raise Exception('Unsupported host platform: %s' % host_platform) 112 113 114def make_clean_directory(directory_name): 115 """If the directory exists, delete it and remake with no contents.""" 116 117 if os.path.exists(directory_name): 118 shutil.rmtree(directory_name) 119 os.makedirs(directory_name) 120 121 122def _get_daemon_status(): 123 """Determines daemon status via `ffx daemon socket`. 124 125 Returns: 126 dict of status of the socket. Status will have a key Running or 127 NotRunning to indicate if the daemon is running. 128 """ 129 status = json.loads( 130 run_ffx_command(cmd=('daemon', 'socket'), 131 check=True, 132 capture_output=True, 133 json_out=True).stdout.strip()) 134 return status.get('pid', {}).get('status', {'NotRunning': True}) 135 136 137def _is_daemon_running(): 138 return 'Running' in _get_daemon_status() 139 140 141def _wait_for_daemon(start=True, timeout_seconds=100): 142 """Waits for daemon to reach desired state in a polling loop. 143 144 Sleeps for 5s between polls. 145 146 Args: 147 start: bool. Indicates to wait for daemon to start up. If False, 148 indicates waiting for daemon to die. 149 timeout_seconds: int. Number of seconds to wait for the daemon to reach 150 the desired status. 151 Raises: 152 TimeoutError: if the daemon does not reach the desired state in time. 153 """ 154 wanted_status = 'start' if start else 'stop' 155 sleep_period_seconds = 5 156 attempts = int(timeout_seconds / sleep_period_seconds) 157 for i in range(attempts): 158 if _is_daemon_running() == start: 159 return 160 if i != attempts: 161 logging.info('Waiting for daemon to %s...', wanted_status) 162 time.sleep(sleep_period_seconds) 163 164 raise TimeoutError(f'Daemon did not {wanted_status} in time.') 165 166 167# The following two functions are the temporary work around before 168# https://fxbug.dev/92296 and https://fxbug.dev/125873 are being fixed. 169def start_ffx_daemon(): 170 """Starts the ffx daemon by using doctor --restart-daemon since daemon start 171 blocks the current shell. 172 173 Note, doctor --restart-daemon usually fails since the timeout in ffx is 174 short and won't be sufficient to wait for the daemon to really start. 175 176 Also, doctor --restart-daemon always restarts the daemon, so this function 177 should be used with caution unless it's really needed to "restart" the 178 daemon by explicitly calling stop daemon first. 179 """ 180 assert not _is_daemon_running(), "Call stop_ffx_daemon first." 181 run_ffx_command(cmd=('doctor', '--restart-daemon'), check=False) 182 _wait_for_daemon(start=True) 183 184 185def stop_ffx_daemon(): 186 """Stops the ffx daemon""" 187 run_ffx_command(cmd=('daemon', 'stop', '-t', '10000')) 188 _wait_for_daemon(start=False) 189 190 191def run_ffx_command(check: bool = True, 192 capture_output: Optional[bool] = None, 193 timeout: Optional[int] = None, 194 **kwargs) -> subprocess.CompletedProcess: 195 """Runs `ffx` with the given arguments, waiting for it to exit. 196 197 ** 198 The arguments below are named after |subprocess.run| arguments. They are 199 overloaded to avoid them from being forwarded to |subprocess.Popen|. 200 ** 201 See run_continuous_ffx_command for additional arguments. 202 Args: 203 check: If True, CalledProcessError is raised if ffx returns a non-zero 204 exit code. 205 capture_output: Whether to capture both stdout/stderr. 206 timeout: Optional timeout (in seconds). Throws TimeoutError if process 207 does not complete in timeout period. 208 Returns: 209 A CompletedProcess instance 210 Raises: 211 CalledProcessError if |check| is true. 212 """ 213 if capture_output: 214 kwargs['stdout'] = subprocess.PIPE 215 kwargs['stderr'] = subprocess.PIPE 216 proc = None 217 try: 218 proc = run_continuous_ffx_command(**kwargs) 219 stdout, stderr = proc.communicate(input=kwargs.get('stdin'), 220 timeout=timeout) 221 completed_proc = subprocess.CompletedProcess( 222 args=proc.args, 223 returncode=proc.returncode, 224 stdout=stdout, 225 stderr=stderr) 226 if check: 227 completed_proc.check_returncode() 228 return completed_proc 229 except subprocess.CalledProcessError as cpe: 230 logging.error('%s %s failed with returncode %s.', 231 os.path.relpath(_FFX_TOOL), 232 subprocess.list2cmdline(proc.args[1:]), cpe.returncode) 233 if cpe.stdout: 234 logging.error('stdout of the command: %s', cpe.stdout) 235 if cpe.stderr: 236 logging.error('stderr or the command: %s', cpe.stderr) 237 raise 238 239 240def run_continuous_ffx_command(cmd: Iterable[str], 241 target_id: Optional[str] = None, 242 configs: Optional[List[str]] = None, 243 json_out: bool = False, 244 encoding: Optional[str] = 'utf-8', 245 **kwargs) -> subprocess.Popen: 246 """Runs `ffx` with the given arguments, returning immediately. 247 248 Args: 249 cmd: A sequence of arguments to ffx. 250 target_id: Whether to execute the command for a specific target. The 251 target_id could be in the form of a nodename or an address. 252 configs: A list of configs to be applied to the current command. 253 json_out: Have command output returned as JSON. Must be parsed by 254 caller. 255 encoding: Optional, desired encoding for output/stderr pipes. 256 Returns: 257 A subprocess.Popen instance 258 """ 259 260 ffx_cmd = [_FFX_TOOL] 261 if json_out: 262 ffx_cmd.extend(('--machine', 'json')) 263 if target_id: 264 ffx_cmd.extend(('--target', target_id)) 265 if configs: 266 for config in configs: 267 ffx_cmd.extend(('--config', config)) 268 ffx_cmd.extend(cmd) 269 270 return subprocess.Popen(ffx_cmd, encoding=encoding, **kwargs) 271 272 273def read_package_paths(out_dir: str, pkg_name: str) -> List[str]: 274 """ 275 Returns: 276 A list of the absolute path to all FAR files the package depends on. 277 """ 278 with open( 279 os.path.join(DIR_SRC_ROOT, out_dir, 'gen', 'package_metadata', 280 f'{pkg_name}.meta')) as meta_file: 281 data = json.load(meta_file) 282 packages = [] 283 for package in data['packages']: 284 packages.append(os.path.join(DIR_SRC_ROOT, out_dir, package)) 285 return packages 286 287 288def register_common_args(parser: ArgumentParser) -> None: 289 """Register commonly used arguments.""" 290 common_args = parser.add_argument_group('common', 'common arguments') 291 common_args.add_argument( 292 '--out-dir', 293 '-C', 294 type=os.path.realpath, 295 help='Path to the directory in which build files are located. ') 296 297 298def register_device_args(parser: ArgumentParser) -> None: 299 """Register device arguments.""" 300 device_args = parser.add_argument_group('device', 'device arguments') 301 device_args.add_argument('--target-id', 302 default=os.environ.get('FUCHSIA_NODENAME'), 303 help=('Specify the target device. This could be ' 304 'a node-name (e.g. fuchsia-emulator) or an ' 305 'an ip address along with an optional port ' 306 '(e.g. [fe80::e1c4:fd22:5ee5:878e]:22222, ' 307 '1.2.3.4, 1.2.3.4:33333). If unspecified, ' 308 'the default target in ffx will be used.')) 309 310 311def register_log_args(parser: ArgumentParser) -> None: 312 """Register commonly used arguments.""" 313 314 log_args = parser.add_argument_group('logging', 'logging arguments') 315 log_args.add_argument('--logs-dir', 316 type=os.path.realpath, 317 help=('Directory to write logs to.')) 318 319 320def get_component_uri(package: str) -> str: 321 """Retrieve the uri for a package.""" 322 # If the input is a full package already, do nothing 323 if package.startswith('fuchsia-pkg://'): 324 return package 325 return f'fuchsia-pkg://{REPO_ALIAS}/{package}#meta/{package}.cm' 326 327 328def resolve_packages(packages: List[str], target_id: Optional[str]) -> None: 329 """Ensure that all |packages| are installed on a device.""" 330 331 ssh_prefix = get_ssh_prefix(get_ssh_address(target_id)) 332 subprocess.run(ssh_prefix + ['--', 'pkgctl', 'gc'], check=False) 333 334 def _retry_command(cmd: List[str], 335 retries: int = 2, 336 **kwargs) -> Optional[subprocess.CompletedProcess]: 337 """Helper function for retrying a subprocess.run command.""" 338 339 for i in range(retries): 340 if i == retries - 1: 341 proc = subprocess.run(cmd, **kwargs, check=True) 342 return proc 343 proc = subprocess.run(cmd, **kwargs, check=False) 344 if proc.returncode == 0: 345 return proc 346 time.sleep(3) 347 return None 348 349 for package in packages: 350 resolve_cmd = [ 351 '--', 'pkgctl', 'resolve', 352 'fuchsia-pkg://%s/%s' % (REPO_ALIAS, package) 353 ] 354 _retry_command(ssh_prefix + resolve_cmd) 355 356 357def get_ssh_address(target_id: Optional[str]) -> str: 358 """Determines SSH address for given target.""" 359 return run_ffx_command(cmd=('target', 'get-ssh-address'), 360 target_id=target_id, 361 capture_output=True).stdout.strip() 362 363 364def find_in_dir(target_name: str, parent_dir: str) -> Optional[str]: 365 """Finds path in SDK. 366 367 Args: 368 target_name: Name of target to find, as a string. 369 parent_dir: Directory to start search in. 370 371 Returns: 372 Full path to the target, None if not found. 373 """ 374 # Doesn't make sense to look for a full path. Only extract the basename. 375 target_name = os.path.basename(target_name) 376 for root, dirs, _ in os.walk(parent_dir): 377 if target_name in dirs: 378 return os.path.abspath(os.path.join(root, target_name)) 379 380 return None 381 382 383def find_image_in_sdk(product_name: str) -> Optional[str]: 384 """Finds image dir in SDK for product given. 385 386 Args: 387 product_name: Name of product's image directory to find. 388 389 Returns: 390 Full path to the target, None if not found. 391 """ 392 top_image_dir = os.path.join(SDK_ROOT, os.pardir, 'images') 393 path = find_in_dir(product_name, parent_dir=top_image_dir) 394 if path: 395 return find_in_dir('images', parent_dir=path) 396 return path 397 398 399def catch_sigterm() -> None: 400 """Catches the kill signal and allows the process to exit cleanly.""" 401 def _sigterm_handler(*_): 402 sys.exit(0) 403 404 signal.signal(signal.SIGTERM, _sigterm_handler) 405 406 407def wait_for_sigterm(extra_msg: str = '') -> None: 408 """ 409 Spin-wait for either ctrl+c or sigterm. Caller can use try-finally 410 statement to perform extra cleanup. 411 412 Args: 413 extra_msg: The extra message to be logged. 414 """ 415 try: 416 while True: 417 # We do expect receiving either ctrl+c or sigterm, so this line 418 # literally means sleep forever. 419 time.sleep(10000) 420 except KeyboardInterrupt: 421 logging.info('Ctrl-C received; %s', extra_msg) 422 except SystemExit: 423 logging.info('SIGTERM received; %s', extra_msg) 424 425 426def get_system_info(target: Optional[str] = None) -> Tuple[str, str]: 427 """Retrieves installed OS version frm device. 428 429 Returns: 430 Tuple of strings, containing {product, version number), or a pair of 431 empty strings to indicate an error. 432 """ 433 info_cmd = run_ffx_command(cmd=('--machine', 'json', 'target', 'show'), 434 target_id=target, 435 capture_output=True, 436 check=False) 437 # If the information was not retrieved, return empty strings to indicate 438 # unknown system info. 439 if info_cmd.returncode != 0: 440 logging.error('ffx target show returns %d', info_cmd.returncode) 441 return ('', '') 442 try: 443 info_json = json.loads(info_cmd.stdout.strip()) 444 except json.decoder.JSONDecodeError as error: 445 logging.error('Unexpected json string: %s, exception: %s', 446 info_cmd.stdout, error) 447 return ('', '') 448 if isinstance(info_json, dict) and 'build' in info_json and isinstance( 449 info_json['build'], dict): 450 # Use default empty string to avoid returning None. 451 return (info_json['build'].get('product', ''), 452 info_json['build'].get('version', '')) 453 return ('', '') 454