device_target.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. # Copyright 2018 The Chromium Authors. All rights reserved.
  2. # Use of this source code is governed by a BSD-style license that can be
  3. # found in the LICENSE file.
  4. """Implements commands for running and interacting with Fuchsia on devices."""
  5. import itertools
  6. import logging
  7. import os
  8. import pkg_repo
  9. import re
  10. import subprocess
  11. import sys
  12. import target
  13. import time
  14. import ermine_ctl
  15. import ffx_session
  16. from common import ATTACH_RETRY_SECONDS, EnsurePathExists, \
  17. GetHostToolPathFromPlatform, RunGnSdkFunction, \
  18. SubprocessCallWithTimeout
  19. sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__),
  20. 'test')))
  21. from compatible_utils import get_sdk_hash
  22. # The maximum times to attempt mDNS resolution when connecting to a freshly
  23. # booted Fuchsia instance before aborting.
  24. BOOT_DISCOVERY_ATTEMPTS = 30
  25. # Number of failed connection attempts before redirecting system logs to stdout.
  26. CONNECT_RETRY_COUNT_BEFORE_LOGGING = 10
  27. # Number of seconds between each device discovery.
  28. BOOT_DISCOVERY_DELAY_SECS = 4
  29. # Time between a reboot command is issued and when connection attempts from the
  30. # host begin.
  31. _REBOOT_SLEEP_PERIOD = 20
  32. # File on device that indicates Fuchsia version.
  33. _ON_DEVICE_VERSION_FILE = '/config/build-info/version'
  34. # File on device that indicates Fuchsia product.
  35. _ON_DEVICE_PRODUCT_FILE = '/config/build-info/product'
  36. def GetTargetType():
  37. return DeviceTarget
  38. class ProvisionDeviceException(Exception):
  39. def __init__(self, message: str):
  40. super(ProvisionDeviceException, self).__init__(message)
  41. class DeviceTarget(target.Target):
  42. """Prepares a device to be used as a deployment target. Depending on the
  43. command line parameters, it automatically handling a number of preparatory
  44. steps relating to address resolution.
  45. If |_node_name| is unset:
  46. If there is one running device, use it for deployment and execution.
  47. If there are more than one running devices, then abort and instruct the
  48. user to re-run the command with |_node_name|
  49. If |_node_name| is set:
  50. If there is a running device with a matching nodename, then it is used
  51. for deployment and execution.
  52. If |_host| is set:
  53. Deploy to a device at the host IP address as-is."""
  54. def __init__(self, out_dir, target_cpu, host, node_name, port, ssh_config,
  55. fuchsia_out_dir, os_check, logs_dir, system_image_dir):
  56. """out_dir: The directory which will contain the files that are
  57. generated to support the deployment.
  58. target_cpu: The CPU architecture of the deployment target. Can be
  59. "x64" or "arm64".
  60. host: The address of the deployment target device.
  61. node_name: The node name of the deployment target device.
  62. port: The port of the SSH service on the deployment target device.
  63. ssh_config: The path to SSH configuration data.
  64. fuchsia_out_dir: The path to a Fuchsia build output directory, for
  65. deployments to devices paved with local Fuchsia builds.
  66. os_check: If 'check', the target's SDK version must match.
  67. If 'update', the target will be repaved if the SDK versions
  68. mismatch.
  69. If 'ignore', the target's SDK version is ignored.
  70. system_image_dir: The directory which contains the files used to pave the
  71. device."""
  72. super(DeviceTarget, self).__init__(out_dir, target_cpu, logs_dir)
  73. self._host = host
  74. self._port = port
  75. self._fuchsia_out_dir = None
  76. self._node_name = node_name or os.environ.get('FUCHSIA_NODENAME')
  77. self._system_image_dir = system_image_dir
  78. self._os_check = os_check
  79. self._pkg_repo = None
  80. self._target_context = None
  81. self._ffx_target = None
  82. self._ermine_ctl = ermine_ctl.ErmineCtl(self)
  83. if not self._system_image_dir and self._os_check != 'ignore':
  84. raise Exception("Image directory must be provided if a repave is needed.")
  85. if self._host and self._node_name:
  86. raise Exception('Only one of "--host" or "--name" can be specified.')
  87. if fuchsia_out_dir:
  88. if ssh_config:
  89. raise Exception('Only one of "--fuchsia-out-dir" or "--ssh_config" can '
  90. 'be specified.')
  91. self._fuchsia_out_dir = os.path.expanduser(fuchsia_out_dir)
  92. # Use SSH keys from the Fuchsia output directory.
  93. self._ssh_config_path = os.path.join(self._fuchsia_out_dir, 'ssh-keys',
  94. 'ssh_config')
  95. self._os_check = 'ignore'
  96. elif ssh_config:
  97. # Use the SSH config provided via the commandline.
  98. self._ssh_config_path = os.path.expanduser(ssh_config)
  99. else:
  100. return_code, ssh_config_raw, _ = RunGnSdkFunction(
  101. 'fuchsia-common.sh', 'get-fuchsia-sshconfig-file')
  102. if return_code != 0:
  103. raise Exception('Could not get Fuchsia ssh config file.')
  104. self._ssh_config_path = os.path.expanduser(ssh_config_raw.strip())
  105. @staticmethod
  106. def CreateFromArgs(args):
  107. return DeviceTarget(args.out_dir, args.target_cpu, args.host,
  108. args.node_name, args.port, args.ssh_config,
  109. args.fuchsia_out_dir, args.os_check, args.logs_dir,
  110. args.system_image_dir)
  111. @staticmethod
  112. def RegisterArgs(arg_parser):
  113. device_args = arg_parser.add_argument_group(
  114. 'device', 'External device deployment arguments')
  115. device_args.add_argument('--host',
  116. help='The IP of the target device. Optional.')
  117. device_args.add_argument('--node-name',
  118. help='The node-name of the device to boot or '
  119. 'deploy to. Optional, will use the first '
  120. 'discovered device if omitted.')
  121. device_args.add_argument('--port',
  122. '-p',
  123. type=int,
  124. default=None,
  125. help='The port of the SSH service running on the '
  126. 'device. Optional.')
  127. device_args.add_argument('--ssh-config',
  128. '-F',
  129. help='The path to the SSH configuration used for '
  130. 'connecting to the target device.')
  131. device_args.add_argument(
  132. '--os-check',
  133. choices=['check', 'update', 'ignore'],
  134. default='ignore',
  135. help="Sets the OS version enforcement policy. If 'check', then the "
  136. "deployment process will halt if the target\'s version doesn\'t "
  137. "match. If 'update', then the target device will automatically "
  138. "be repaved. If 'ignore', then the OS version won\'t be checked.")
  139. device_args.add_argument('--system-image-dir',
  140. help="Specify the directory that contains the "
  141. "Fuchsia image used to pave the device. Only "
  142. "needs to be specified if 'os_check' is not "
  143. "'ignore'.")
  144. def _Discover(self):
  145. """Queries mDNS for the IP address of a booted Fuchsia instance whose name
  146. matches |_node_name| on the local area network. If |_node_name| is not
  147. specified and there is only one device on the network, |_node_name| is set
  148. to that device's name.
  149. Returns:
  150. True if exactly one device is found, after setting |_host| and |_port| to
  151. its SSH address. False if no devices are found.
  152. Raises:
  153. Exception: If more than one device is found.
  154. """
  155. if self._node_name:
  156. target = ffx_session.FfxTarget.from_node_name(self._ffx_runner,
  157. self._node_name)
  158. else:
  159. # Get the node name of a single attached target.
  160. try:
  161. # Get at most the first 2 valid targets
  162. targets = list(
  163. itertools.islice(self._ffx_runner.list_active_targets(), 2))
  164. except subprocess.CalledProcessError:
  165. # A failure to list targets could mean that the device is in zedboot.
  166. # Return false in this case so that Start() will attempt to provision.
  167. return False
  168. if not targets:
  169. return False
  170. if len(targets) > 1:
  171. raise Exception('More than one device was discovered on the network. '
  172. 'Use --node-name <name> to specify the device to use.'
  173. 'List of devices: {}'.format(targets))
  174. target = targets[0]
  175. # Get the ssh address of the target.
  176. ssh_address = target.get_ssh_address()
  177. if ssh_address:
  178. self._host, self._port = ssh_address
  179. else:
  180. return False
  181. logging.info('Found device "%s" at %s.' %
  182. (self._node_name if self._node_name else '<unknown>',
  183. ffx_session.format_host_port(self._host, self._port)))
  184. # TODO(crbug.com/1307220): Remove this once the telemetry scripts can handle
  185. # specifying the port for a device that is not listening on localhost.
  186. if self._port == 22:
  187. self._port = None
  188. return True
  189. def _Login(self):
  190. """Attempts to log into device, if possible.
  191. This method should not be called from anything other than Start,
  192. though calling it multiple times should have no adverse effect.
  193. """
  194. if self._ermine_ctl.exists:
  195. self._ermine_ctl.TakeToShell()
  196. def Start(self):
  197. if self._host:
  198. self._ConnectToTarget()
  199. self._Login()
  200. elif self._Discover():
  201. self._ConnectToTarget()
  202. if self._os_check == 'ignore':
  203. self._Login()
  204. return
  205. # If accessible, check version.
  206. new_version = get_sdk_hash(self._system_image_dir)
  207. installed_version = self._GetInstalledSdkVersion()
  208. if new_version == installed_version:
  209. logging.info('Fuchsia version installed on device matches Chromium '
  210. 'SDK version. Skipping pave.')
  211. else:
  212. if self._os_check == 'check':
  213. raise Exception('Image and Fuchsia version installed on device '
  214. 'does not match. Abort.')
  215. logging.info('Putting device in recovery mode')
  216. self.RunCommandPiped(['dm', 'reboot-recovery'],
  217. stdout=subprocess.PIPE,
  218. stderr=subprocess.STDOUT)
  219. self._ProvisionDevice()
  220. self._Login()
  221. else:
  222. if self._node_name:
  223. logging.info('Could not detect device %s.' % self._node_name)
  224. if self._os_check == 'update':
  225. logging.info('Assuming it is in zedboot. Continuing with paving...')
  226. self._ProvisionDevice()
  227. self._Login()
  228. return
  229. raise Exception('Could not find device. If the device is connected '
  230. 'to the host remotely, make sure that --host flag '
  231. 'is set and that remote serving is set up.')
  232. def GetFfxTarget(self):
  233. assert self._ffx_target
  234. return self._ffx_target
  235. def _GetInstalledSdkVersion(self):
  236. """Retrieves installed OS version from device.
  237. Returns:
  238. Tuple of strings, containing (product, version number)
  239. """
  240. return (self.GetFileAsString(_ON_DEVICE_PRODUCT_FILE).strip(),
  241. self.GetFileAsString(_ON_DEVICE_VERSION_FILE).strip())
  242. def GetPkgRepo(self):
  243. if not self._pkg_repo:
  244. if self._fuchsia_out_dir:
  245. # Deploy to an already-booted device running a local Fuchsia build.
  246. self._pkg_repo = pkg_repo.ExternalPkgRepo(
  247. os.path.join(self._fuchsia_out_dir, 'amber-files'),
  248. os.path.join(self._fuchsia_out_dir, '.build-id'))
  249. else:
  250. # Create an ephemeral package repository, then start both "pm serve" as
  251. # well as the bootserver.
  252. self._pkg_repo = pkg_repo.ManagedPkgRepo(self)
  253. return self._pkg_repo
  254. def _ParseNodename(self, output):
  255. # Parse the nodename from bootserver stdout.
  256. m = re.search(r'.*Proceeding with nodename (?P<nodename>.*)$', output,
  257. re.MULTILINE)
  258. if not m:
  259. raise Exception('Couldn\'t parse nodename from bootserver output.')
  260. self._node_name = m.groupdict()['nodename']
  261. logging.info('Booted device "%s".' % self._node_name)
  262. # Repeatedly search for a device for |BOOT_DISCOVERY_ATTEMPT|
  263. # number of attempts. If a device isn't found, wait
  264. # |BOOT_DISCOVERY_DELAY_SECS| before searching again.
  265. logging.info('Waiting for device to join network.')
  266. for _ in range(BOOT_DISCOVERY_ATTEMPTS):
  267. if self._Discover():
  268. break
  269. time.sleep(BOOT_DISCOVERY_DELAY_SECS)
  270. if not self._host:
  271. raise Exception('Device %s couldn\'t be discovered via mDNS.' %
  272. self._node_name)
  273. self._ConnectToTarget()
  274. def _GetEndpoint(self):
  275. return (self._host, self._port)
  276. def _ConnectToTarget(self):
  277. logging.info('Connecting to Fuchsia using ffx.')
  278. # Prefer connecting via node name over address:port.
  279. if self._node_name:
  280. # Assume that ffx already knows about the target, so there's no need to
  281. # add/remove it.
  282. self._ffx_target = ffx_session.FfxTarget.from_node_name(
  283. self._ffx_runner, self._node_name)
  284. else:
  285. # The target may not be known by ffx. Probe to see if it has already been
  286. # added.
  287. ffx_target = ffx_session.FfxTarget.from_address(self._ffx_runner,
  288. self._host, self._port)
  289. if ffx_target.get_ssh_address():
  290. # If we could lookup the address, the target must be reachable. Do not
  291. # open a new scoped_target_context, as that will `ffx target add` now
  292. # and then `ffx target remove` later, which will break subsequent
  293. # interactions with a persistent emulator.
  294. self._ffx_target = ffx_target
  295. else:
  296. # The target is not known, so take on responsibility of adding and
  297. # removing it.
  298. self._target_context = self._ffx_runner.scoped_target_context(
  299. self._host, self._port)
  300. self._ffx_target = self._target_context.__enter__()
  301. self._ffx_target.wait(ATTACH_RETRY_SECONDS)
  302. return super(DeviceTarget, self)._ConnectToTarget()
  303. def _DisconnectFromTarget(self):
  304. self._ffx_target = None
  305. if self._target_context:
  306. self._target_context.__exit__(None, None, None)
  307. self._target_context = None
  308. super(DeviceTarget, self)._DisconnectFromTarget()
  309. def _GetSshConfigPath(self):
  310. return self._ssh_config_path
  311. def _ProvisionDevice(self):
  312. _, auth_keys, _ = RunGnSdkFunction('fuchsia-common.sh',
  313. 'get-fuchsia-auth-keys-file')
  314. pave_command = [
  315. os.path.join(self._system_image_dir, 'pave.sh'), '--authorized-keys',
  316. auth_keys.strip()
  317. ]
  318. if self._node_name:
  319. pave_command.extend(['-n', self._node_name, '-1'])
  320. logging.info(' '.join(pave_command))
  321. try:
  322. return_code, stdout, stderr = SubprocessCallWithTimeout(pave_command,
  323. timeout_secs=300)
  324. if return_code != 0:
  325. raise ProvisionDeviceException('Could not pave device.')
  326. except TimeoutError as ex:
  327. raise ProvisionDeviceException('Timed out while paving device.') from ex
  328. self._ParseNodename(stderr)
  329. def Restart(self):
  330. """Restart the device."""
  331. self.RunCommandPiped('dm reboot')
  332. time.sleep(_REBOOT_SLEEP_PERIOD)
  333. self.Start()
  334. def Stop(self):
  335. try:
  336. self._DisconnectFromTarget()
  337. # End multiplexed ssh connection, ensure that ssh logging stops before
  338. # tests/scripts return.
  339. if self.IsStarted():
  340. self.RunCommand(['-O', 'exit'])
  341. finally:
  342. super(DeviceTarget, self).Stop()