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