• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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