xref: /aosp_15_r20/external/cronet/build/fuchsia/test/common.py (revision 6777b5387eb2ff775bb5750e3f5d96f37fb7352b)
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