ffx_session.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. #!/usr/bin/env vpython3
  2. # Copyright 2022 The Chromium Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. """A helper tool for running Fuchsia's `ffx`.
  6. """
  7. # Enable use of the print() built-in function.
  8. from __future__ import print_function
  9. import argparse
  10. import contextlib
  11. import errno
  12. import json
  13. import logging
  14. import os
  15. import re
  16. import shutil
  17. import subprocess
  18. import sys
  19. import tempfile
  20. import common
  21. import log_manager
  22. sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__),
  23. 'test')))
  24. from compatible_utils import parse_host_port
  25. def get_ffx_path():
  26. """Returns the full path to `ffx`."""
  27. return os.path.join(common.SDK_ROOT, 'tools',
  28. common.GetHostArchFromPlatform(), 'ffx')
  29. def format_host_port(host, port):
  30. """Formats a host name or IP address and port number into a host:port string.
  31. """
  32. # Wrap `host` in brackets if it looks like an IPv6 address
  33. return ('[%s]:%d' if ':' in host else '%s:%d') % (host, port)
  34. class FfxRunner():
  35. """A helper to run `ffx` commands."""
  36. def __init__(self, log_manager):
  37. self._ffx = get_ffx_path()
  38. self._log_manager = log_manager
  39. def _run_repair_command(self, output):
  40. """Scans `output` for a self-repair command to run and, if found, runs it.
  41. If logging is enabled, `ffx` is asked to emit its own logs to the log
  42. directory.
  43. Returns:
  44. True if a repair command was found and ran successfully. False otherwise.
  45. """
  46. # Check for a string along the lines of:
  47. # "Run `ffx doctor --restart-daemon` for further diagnostics."
  48. match = re.search('`ffx ([^`]+)`', output)
  49. if not match or len(match.groups()) != 1:
  50. return False # No repair command found.
  51. args = match.groups()[0].split()
  52. # Tell ffx to include the configuration file without prompting in case
  53. # logging is enabled.
  54. with self.scoped_config('doctor.record_config', 'true'):
  55. # If the repair command is `ffx doctor` and logging is enabled, add the
  56. # options to emit ffx logs to the logging directory.
  57. if len(args) and args[0] == 'doctor' and \
  58. self._log_manager.IsLoggingEnabled():
  59. args.extend(
  60. ('--record', '--output-dir', self._log_manager.GetLogDirectory()))
  61. try:
  62. self.run_ffx(args, suppress_repair=True)
  63. except subprocess.CalledProcessError as cpe:
  64. return False # Repair failed.
  65. return True # Repair succeeded.
  66. def run_ffx(self, args, check=True, suppress_repair=False):
  67. """Runs `ffx` with the given arguments, waiting for it to exit.
  68. If `ffx` exits with a non-zero exit code, the output is scanned for a
  69. recommended repair command (e.g., "Run `ffx doctor --restart-daemon` for
  70. further diagnostics."). If such a command is found, it is run and then the
  71. original command is retried. This behavior can be suppressed via the
  72. `suppress_repair` argument.
  73. Args:
  74. args: A sequence of arguments to ffx.
  75. check: If True, CalledProcessError is raised if ffx returns a non-zero
  76. exit code.
  77. suppress_repair: If True, do not attempt to find and run a repair command.
  78. Returns:
  79. A string containing combined stdout and stderr.
  80. Raises:
  81. CalledProcessError if `check` is true.
  82. """
  83. log_file = self._log_manager.Open('ffx_log') \
  84. if self._log_manager.IsLoggingEnabled() else None
  85. command = [self._ffx]
  86. command.extend(args)
  87. logging.debug(command)
  88. if log_file:
  89. print(command, file=log_file)
  90. repair_succeeded = False
  91. try:
  92. # TODO(grt): Switch to subprocess.run() with encoding='utf-8' when p3 is
  93. # supported.
  94. process = subprocess.Popen(command,
  95. stdout=subprocess.PIPE,
  96. stderr=subprocess.PIPE)
  97. stdout_data, stderr_data = process.communicate()
  98. stdout_data = stdout_data.decode('utf-8')
  99. stderr_data = stderr_data.decode('utf-8')
  100. if check and process.returncode != 0:
  101. # TODO(grt): Pass stdout and stderr as two args when p2 support is no
  102. # longer needed.
  103. raise subprocess.CalledProcessError(
  104. process.returncode, command, '\n'.join((stdout_data, stderr_data)))
  105. except subprocess.CalledProcessError as cpe:
  106. if log_file:
  107. log_file.write('Process exited with code %d. Output: %s\n' %
  108. (cpe.returncode, cpe.output.strip()))
  109. # Let the exception fly unless a repair command is found and succeeds.
  110. if suppress_repair or not self._run_repair_command(cpe.output):
  111. raise
  112. repair_succeeded = True
  113. # If the original command failed but a repair command was found and
  114. # succeeded, try one more time with the original command.
  115. if repair_succeeded:
  116. return self.run_ffx(args, check, suppress_repair=True)
  117. stripped_stdout = stdout_data.strip()
  118. stripped_stderr = stderr_data.strip()
  119. if log_file:
  120. if process.returncode != 0 or stripped_stderr:
  121. log_file.write('Process exited with code %d.' % process.returncode)
  122. if stripped_stderr:
  123. log_file.write(' Stderr:\n%s\n' % stripped_stderr)
  124. if stripped_stdout:
  125. log_file.write(' Stdout:\n%s\n' % stripped_stdout)
  126. if not stripped_stderr and not stripped_stdout:
  127. log_file.write('\n')
  128. elif stripped_stdout:
  129. log_file.write('%s\n' % stripped_stdout)
  130. logging.debug(
  131. 'ffx command returned %d with %s%s', process.returncode,
  132. ('output "%s"' % stripped_stdout if stripped_stdout else 'no output'),
  133. (' and error "%s".' % stripped_stderr if stripped_stderr else '.'))
  134. return stdout_data
  135. def open_ffx(self, args):
  136. """Runs `ffx` with some arguments.
  137. Args:
  138. args: A sequence of arguments to ffx.
  139. Returns:
  140. A subprocess.Popen object.
  141. """
  142. log_file = self._log_manager.Open('ffx_log') \
  143. if self._log_manager.IsLoggingEnabled() else None
  144. command = [self._ffx]
  145. command.extend(args)
  146. logging.debug(command)
  147. if log_file:
  148. print(command, file=log_file)
  149. try:
  150. # TODO(grt): Add encoding='utf-8' when p3 is supported.
  151. return subprocess.Popen(command,
  152. stdin=open(os.devnull, 'r'),
  153. stdout=subprocess.PIPE,
  154. stderr=subprocess.STDOUT)
  155. except:
  156. logging.exception('Failed to open ffx')
  157. if log_file:
  158. print('Exception caught while opening ffx: %s' % str(sys.exc_info[1]))
  159. raise
  160. @contextlib.contextmanager
  161. def scoped_config(self, name, value):
  162. """Temporarily overrides `ffx` configuration.
  163. Args:
  164. name: The name of the property to set.
  165. value: The value to associate with `name`.
  166. Returns:
  167. Yields nothing. Restores the previous value upon exit.
  168. """
  169. assert value is not None
  170. # Cache the current value.
  171. old_value = None
  172. try:
  173. old_value = self.run_ffx(['config', 'get', name]).strip()
  174. except subprocess.CalledProcessError as cpe:
  175. if cpe.returncode != 2:
  176. raise # The failure was for something other than value not found.
  177. # Set the new value if it is different.
  178. if value != old_value:
  179. self.run_ffx(['config', 'set', name, value])
  180. try:
  181. yield None
  182. finally:
  183. if value == old_value:
  184. return # There is no need to restore an old value.
  185. # Clear the new value.
  186. self.run_ffx(['config', 'remove', name])
  187. if old_value is None:
  188. return
  189. # Did removing the new value restore the original value on account of it
  190. # either being the default or being set in a different scope?
  191. if (self.run_ffx(['config', 'get', name],
  192. check=False).strip() == old_value):
  193. return
  194. # If not, explicitly set the original value.
  195. self.run_ffx(['config', 'set', name, old_value])
  196. def list_targets(self):
  197. """Returns the (possibly empty) list of targets known to ffx.
  198. Returns:
  199. The list of targets parsed from the JSON output of `ffx target list`.
  200. """
  201. json_targets = self.run_ffx(['target', 'list', '-f', 'json'])
  202. if not json_targets:
  203. return []
  204. try:
  205. return json.loads(json_targets)
  206. except ValueError:
  207. # TODO(grt): Change to json.JSONDecodeError once p3 is supported.
  208. return []
  209. def list_active_targets(self):
  210. """Gets the list of targets and filters down to the targets that are active.
  211. Returns:
  212. An iterator over active FfxTargets.
  213. """
  214. targets = [
  215. FfxTarget.from_target_list_json(self, json_target)
  216. for json_target in self.list_targets()
  217. ]
  218. return filter(lambda target: target.get_ssh_address(), targets)
  219. def remove_stale_targets(self, address):
  220. """Removes any targets from ffx that are listening at a given address.
  221. Args:
  222. address: A string representation of the target's ip address.
  223. """
  224. for target in self.list_targets():
  225. if target['rcs_state'] == 'N' and address in target['addresses']:
  226. self.run_ffx(['target', 'remove', address])
  227. @contextlib.contextmanager
  228. def scoped_target_context(self, address, port):
  229. """Temporarily adds a new target.
  230. Args:
  231. address: The IP address at which the target is listening.
  232. port: The port number on which the target is listening.
  233. Yields:
  234. An FfxTarget for interacting with the target.
  235. """
  236. target_id = format_host_port(address, port)
  237. # -n allows `target add` to skip waiting for the device to come up,
  238. # as this can take longer than the default wait period.
  239. self.run_ffx(['target', 'add', '-n', target_id])
  240. try:
  241. yield FfxTarget.from_address(self, address, port)
  242. finally:
  243. self.run_ffx(['target', 'remove', target_id], check=False)
  244. def get_node_name(self, address, port):
  245. """Returns the node name for a target given its SSH address.
  246. Args:
  247. address: The address at which the target's SSH daemon is listening.
  248. port: The port number on which the daemon is listening.
  249. Returns:
  250. The target's node name.
  251. Raises:
  252. Exception: If the target cannot be found.
  253. """
  254. for target in self.list_targets():
  255. if target['nodename'] and address in target['addresses']:
  256. ssh_address = FfxTarget.from_target_list_json(target).get_ssh_address()
  257. if ssh_address and ssh_address[1] == port:
  258. return target['nodename']
  259. raise Exception('Failed to determine node name for target at %s' %
  260. format_host_port(address, port))
  261. def daemon_stop(self):
  262. """Stops the ffx daemon."""
  263. self.run_ffx(['daemon', 'stop'], check=False, suppress_repair=True)
  264. class FfxTarget():
  265. """A helper to run `ffx` commands for a specific target."""
  266. @classmethod
  267. def from_address(cls, ffx_runner, address, port=None):
  268. """Args:
  269. ffx_runner: The runner to use to run ffx.
  270. address: The target's address.
  271. port: The target's port, defaults to None in which case it will target
  272. the first device at the specified address
  273. """
  274. return cls(ffx_runner, format_host_port(address, port) if port else address)
  275. @classmethod
  276. def from_node_name(cls, ffx_runner, node_name):
  277. """Args:
  278. ffx_runner: The runner to use to run ffx.
  279. node_name: The target's node name.
  280. """
  281. return cls(ffx_runner, node_name)
  282. @classmethod
  283. def from_target_list_json(cls, ffx_runner, json_target):
  284. """Args:
  285. ffx_runner: The runner to use to run ffx.
  286. json_target: the json dict as returned from `ffx list targets`
  287. """
  288. # Targets seen via `fx serve-remote` frequently have no name, so fall back
  289. # to using the first address.
  290. if json_target['nodename'].startswith('<unknown'):
  291. return cls.from_address(ffx_runner, json_target['addresses'][0])
  292. return cls.from_node_name(ffx_runner, json_target['nodename'])
  293. def __init__(self, ffx_runner, target_id):
  294. """Args:
  295. ffx_runner: The runner to use to run ffx.
  296. target_id: The target's node name or addr:port string.
  297. """
  298. self._ffx_runner = ffx_runner
  299. self._target_id = target_id
  300. self._target_args = ('--target', target_id)
  301. def format_runner_options(self):
  302. """Returns a string holding options suitable for use with the runner scripts
  303. to run tests on this target."""
  304. try:
  305. # First try extracting host:port from the target_id.
  306. return '-d --host %s --port %d' % parse_host_port(self._target_args[1])
  307. except ValueError:
  308. # Must be a simple node name.
  309. pass
  310. return '-d --node-name %s' % self._target_args[1]
  311. def wait(self, timeout=None):
  312. """Waits for ffx to establish a connection with the target.
  313. Args:
  314. timeout: The number of seconds to wait (60 if not specified).
  315. """
  316. command = list(self._target_args)
  317. command.extend(('target', 'wait'))
  318. if timeout is not None:
  319. command.extend(('-t', '%d' % int(timeout)))
  320. self._ffx_runner.run_ffx(command)
  321. def get_ssh_address(self):
  322. """Returns the host and port of the target's SSH address
  323. Returns:
  324. A tuple of a host address string and a port number integer,
  325. or None if there was an exception
  326. """
  327. command = list(self._target_args)
  328. command.extend(('target', 'get-ssh-address'))
  329. try:
  330. return parse_host_port(self._ffx_runner.run_ffx(command))
  331. except:
  332. return None
  333. def open_ffx(self, command):
  334. """Runs `ffx` for the target with some arguments.
  335. Args:
  336. command: A command and its arguments to run on the target.
  337. Returns:
  338. A subprocess.Popen object.
  339. """
  340. args = list(self._target_args)
  341. args.extend(command)
  342. return self._ffx_runner.open_ffx(args)
  343. def __str__(self):
  344. return self._target_id
  345. def __repr__(self):
  346. return self._target_id
  347. # TODO(grt): Derive from contextlib.AbstractContextManager when p3 is supported.
  348. class FfxSession():
  349. """A context manager that manages a session for running a test via `ffx`.
  350. Upon entry, an instance of this class configures `ffx` to retrieve files
  351. generated by a test and prepares a directory to hold these files either in a
  352. LogManager's log directory or in tmp. On exit, any previous configuration of
  353. `ffx` is restored and the temporary directory, if used, is deleted.
  354. The prepared directory is used when invoking `ffx test run`.
  355. """
  356. def __init__(self, log_manager):
  357. """Args:
  358. log_manager: A Target's LogManager.
  359. """
  360. self._log_manager = log_manager
  361. self._ffx = FfxRunner(log_manager)
  362. self._structured_output_config = None
  363. self._own_output_dir = False
  364. self._output_dir = None
  365. self._run_summary = None
  366. self._suite_summary = None
  367. self._custom_artifact_directory = None
  368. def __enter__(self):
  369. # Enable experimental structured output for ffx.
  370. self._structured_output_config = self._ffx.scoped_config(
  371. 'test.experimental_structured_output', 'true')
  372. self._structured_output_config.__enter__()
  373. if self._log_manager.IsLoggingEnabled():
  374. # Use a subdir of the configured log directory to hold test outputs.
  375. self._output_dir = os.path.join(self._log_manager.GetLogDirectory(),
  376. 'test_outputs')
  377. # TODO(grt): Use exist_ok=True when p3 is supported.
  378. try:
  379. os.makedirs(self._output_dir)
  380. except OSError as ose:
  381. if ose.errno != errno.EEXIST:
  382. raise
  383. else:
  384. # Create a temporary directory to hold test outputs.
  385. # TODO(grt): Switch to tempfile.TemporaryDirectory when p3 is supported.
  386. self._own_output_dir = True
  387. self._output_dir = tempfile.mkdtemp(prefix='ffx_session_tmp_')
  388. return self
  389. def __exit__(self, exc_type, exc_val, exc_tb):
  390. # Restore the previous test.output_path setting.
  391. if self._own_output_dir:
  392. # Clean up the temporary output directory.
  393. shutil.rmtree(self._output_dir, ignore_errors=True)
  394. self._own_output_dir = False
  395. self._output_dir = None
  396. # Restore the previous experimental structured output setting.
  397. self._structured_output_config.__exit__(exc_type, exc_val, exc_tb)
  398. self._structured_output_config = None
  399. return False
  400. def get_output_dir(self):
  401. """Returns the temporary output directory for the session."""
  402. assert self._output_dir, 'FfxSession must be used in a context'
  403. return self._output_dir
  404. def test_run(self, ffx_target, component_uri, package_args):
  405. """Runs a test on a target.
  406. Args:
  407. ffx_target: The target on which to run the test.
  408. component_uri: The test component URI.
  409. package_args: Arguments to the test package.
  410. Returns:
  411. A subprocess.Popen object.
  412. """
  413. command = [
  414. 'test', 'run', '--output-directory', self._output_dir, component_uri,
  415. '--'
  416. ]
  417. command.extend(package_args)
  418. return ffx_target.open_ffx(command)
  419. def _parse_test_outputs(self):
  420. """Parses the output files generated by the test runner.
  421. The instance's `_custom_artifact_directory` member is set to the directory
  422. holding output files emitted by the test.
  423. This function is idempotent, and performs no work if it has already been
  424. called.
  425. """
  426. if self._run_summary:
  427. return # The files have already been parsed.
  428. # Parse the main run_summary.json file.
  429. run_summary_path = os.path.join(self._output_dir, 'run_summary.json')
  430. try:
  431. with open(run_summary_path) as run_summary_file:
  432. self._run_summary = json.load(run_summary_file)
  433. except IOError as io_error:
  434. logging.error('Error reading run summary file: %s', str(io_error))
  435. return
  436. except ValueError as value_error:
  437. logging.error('Error parsing run summary file %s: %s', run_summary_path,
  438. str(value_error))
  439. return
  440. assert self._run_summary['version'] == '0', \
  441. 'Unsupported version found in %s' % run_summary_path
  442. # There should be precisely one suite for the test that ran. Find and parse
  443. # its file.
  444. suite_summary_filename = self._run_summary.get('suites',
  445. [{}])[0].get('summary')
  446. if not suite_summary_filename:
  447. logging.error('Failed to find suite zero\'s summary filename in %s',
  448. run_summary_path)
  449. return
  450. suite_summary_path = os.path.join(self._output_dir, suite_summary_filename)
  451. try:
  452. with open(suite_summary_path) as suite_summary_file:
  453. self._suite_summary = json.load(suite_summary_file)
  454. except IOError as io_error:
  455. logging.error('Error reading suite summary file: %s', str(io_error))
  456. return
  457. except ValueError as value_error:
  458. logging.error('Error parsing suite summary file %s: %s',
  459. suite_summary_path, str(value_error))
  460. return
  461. assert self._suite_summary['version'] == '0', \
  462. 'Unsupported version found in %s' % suite_summary_path
  463. # Get the top-level directory holding all artifacts for this suite.
  464. artifact_dir = self._suite_summary.get('artifact_dir')
  465. if not artifact_dir:
  466. logging.error('Failed to find suite\'s artifact_dir in %s',
  467. suite_summary_path)
  468. return
  469. # Get the path corresponding to the CUSTOM artifact.
  470. for artifact_path, artifact in self._suite_summary['artifacts'].items():
  471. if artifact['artifact_type'] != 'CUSTOM':
  472. continue
  473. self._custom_artifact_directory = os.path.join(self._output_dir,
  474. artifact_dir,
  475. artifact_path)
  476. break
  477. def get_custom_artifact_directory(self):
  478. """Returns the full path to the directory holding custom artifacts emitted
  479. by the test, or None if the path cannot be determined.
  480. """
  481. self._parse_test_outputs()
  482. return self._custom_artifact_directory
  483. def make_arg_parser():
  484. parser = argparse.ArgumentParser(
  485. description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  486. parser.add_argument('--logs-dir', help='Directory to write logs to.')
  487. parser.add_argument('--verbose',
  488. '-v',
  489. action='store_true',
  490. default=False,
  491. help='Enable debug logging')
  492. return parser
  493. def main(args):
  494. args = make_arg_parser().parse_args(args)
  495. logging.basicConfig(format='%(asctime)s:%(levelname)s:%(name)s:%(message)s',
  496. level=logging.DEBUG if args.verbose else logging.INFO)
  497. log_mgr = log_manager.LogManager(args.logs_dir)
  498. with FfxSession(log_mgr) as ffx_session:
  499. logging.info(ffx_session.get_output_dir())
  500. return 0
  501. if __name__ == '__main__':
  502. sys.exit(main(sys.argv[1:]))