| # -*- coding: utf-8 -*- |
| # Copyright (c) 2011 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Module containing methods and classes to interact with a devserver instance. |
| """ |
| |
| from __future__ import print_function |
| |
| import multiprocessing |
| import os |
| import re |
| import socket |
| import subprocess |
| import sys |
| import tempfile |
| |
| from six.moves import http_client as httplib |
| from six.moves import urllib |
| |
| # cherrypy may not be available outside the chroot. |
| try: |
| import cherrypy # pylint: disable=import-error |
| except ImportError: |
| cherrypy = None |
| |
| from chromite.lib import constants |
| from chromite.cli import command |
| from chromite.lib import cros_build_lib |
| from chromite.lib import cros_logging as logging |
| from chromite.lib import gs |
| from chromite.lib import osutils |
| from chromite.lib import path_util |
| from chromite.lib import remote_access |
| from chromite.lib import timeout_util |
| from chromite.lib.xbuddy import build_artifact |
| from chromite.lib.xbuddy import devserver_constants |
| from chromite.lib.xbuddy import xbuddy |
| |
| |
| assert sys.version_info >= (3, 6), 'This module requires Python 3.6+' |
| |
| |
| DEFAULT_PORT = 8080 |
| |
| DEFAULT_STATIC_DIR = path_util.FromChrootPath( |
| os.path.join(constants.CHROOT_SOURCE_ROOT, 'devserver', 'static')) |
| |
| XBUDDY_REMOTE = 'remote' |
| XBUDDY_LOCAL = 'local' |
| |
| |
| class ImagePathError(Exception): |
| """Raised when the provided path can't be resolved to an image.""" |
| |
| |
| class ArtifactDownloadError(Exception): |
| """Raised when the artifact could not be downloaded.""" |
| |
| |
| def GetXbuddyPath(path): |
| """A helper function to parse an xbuddy path. |
| |
| Args: |
| path: Either an xbuddy path, gs path, or a path with no scheme. |
| |
| Returns: |
| path/for/xbuddy if |path| is xbuddy://path/for/xbuddy; |
| path/for/gs if |path| is gs://chromeos-image-archive/path/for/gs/; |
| otherwise, |path|. |
| |
| Raises: |
| ValueError if |path| is an unrecognized scheme, or is a gs path with |
| an unrecognized bucket. |
| """ |
| parsed = urllib.parse.urlparse(path) |
| |
| if parsed.scheme == 'xbuddy': |
| return '%s%s' % (parsed.netloc, parsed.path) |
| elif parsed.scheme == '': |
| logging.debug('Assuming %s is an xbuddy path.', path) |
| return path |
| elif parsed.scheme == 'gs': |
| if parsed.netloc != devserver_constants.GS_IMAGE_BUCKET: |
| raise ValueError('Do not support bucket %s. Only bucket %s is supported.' |
| % (parsed.netloc, devserver_constants.GS_IMAGE_BUCKET)) |
| return '%s%s' % (xbuddy.REMOTE, parsed.path) |
| else: |
| raise ValueError('Do not support scheme %s.' % (parsed.scheme,)) |
| |
| |
| def GetImagePathWithXbuddy(path, board, version, static_dir=DEFAULT_STATIC_DIR, |
| silent=False): |
| """Gets image path and resolved XBuddy path using xbuddy. |
| |
| Ask xbuddy to translate |path|, and if necessary, download and stage the |
| image, then return a translated path to the image. Also returns the resolved |
| XBuddy path, which may be useful for subsequent calls in case the argument is |
| an alias. |
| |
| Args: |
| path: The xbuddy path. |
| board: The default board to use if board is not specified in |path|. |
| version: The default version to use if one is not specified in |path|. |
| static_dir: Static directory to stage the image in. |
| silent: Suppress error messages. |
| |
| Returns: |
| A tuple consisting of the build id and full path to the image. |
| """ |
| # Since xbuddy often wants to use gsutil from $PATH, make sure our local copy |
| # shows up first. |
| upath = os.environ['PATH'].split(os.pathsep) |
| upath.insert(0, os.path.dirname(gs.GSContext.GetDefaultGSUtilBin())) |
| os.environ['PATH'] = os.pathsep.join(upath) |
| |
| # If we are using the progress bar, quiet the logging output of cherrypy. |
| if cherrypy and command.UseProgressBar(): |
| if (hasattr(cherrypy.log, 'access_log') and |
| hasattr(cherrypy.log, 'error_log')): |
| cherrypy.log.access_log.setLevel(logging.NOTICE) |
| cherrypy.log.error_log.setLevel(logging.NOTICE) |
| else: |
| cherrypy.config.update({'server.log_to_screen': False}) |
| |
| xb = xbuddy.XBuddy(board=board, version=version, log_screen=False, |
| static_dir=static_dir) |
| path_list = GetXbuddyPath(path).rsplit(os.path.sep) |
| try: |
| return xb.Get(path_list) |
| except xbuddy.XBuddyException as e: |
| if not silent: |
| logging.error('Locating image "%s" failed. The path might not be valid ' |
| 'or the image might not exist.', path) |
| raise ImagePathError('Cannot locate image %s: %s' % (path, e)) |
| except build_artifact.ArtifactDownloadError as e: |
| if not silent: |
| logging.error('Downloading image "%s" failed.', path) |
| raise ArtifactDownloadError('Cannot download image %s: %s' % (path, e)) |
| |
| |
| def GetIPv4Address(dev=None, global_ip=True): |
| """Returns any global/host IP address or the IP address of the given device. |
| |
| socket.gethostname() is insufficient for machines where the host files are |
| not set up "correctly." Since some of our builders may have this issue, |
| this method gives you a generic way to get the address so you are reachable |
| either via a VM or remote machine on the same network. |
| |
| Args: |
| dev: Get the IP address of the device (e.g. 'eth0'). |
| global_ip: If set True, returns a globally valid IP address. Otherwise, |
| returns a local IP address (default: True). |
| """ |
| cmd = ['ip', 'addr', 'show'] |
| cmd += ['scope', 'global' if global_ip else 'host'] |
| cmd += [] if dev is None else ['dev', dev] |
| |
| result = cros_build_lib.run(cmd, print_cmd=False, capture_output=True, |
| encoding='utf-8') |
| matches = re.findall(r'\binet (\d+\.\d+\.\d+\.\d+).*', result.output) |
| if matches: |
| return matches[0] |
| logging.warning('Failed to find ip address in %r', result.output) |
| return None |
| |
| |
| class DevServerException(Exception): |
| """Base exception class of devserver errors.""" |
| |
| |
| class DevServerStartupError(DevServerException): |
| """Thrown when the devserver fails to start up.""" |
| |
| |
| class DevServerStopError(DevServerException): |
| """Thrown when the devserver fails to stop.""" |
| |
| |
| class DevServerResponseError(DevServerException): |
| """Thrown when the devserver responds with an error.""" |
| |
| |
| class DevServerConnectionError(DevServerException): |
| """Thrown when unable to connect to devserver.""" |
| |
| |
| class DevServerWrapper(multiprocessing.Process): |
| """A Simple wrapper around a dev server instance.""" |
| |
| # Wait up to 15 minutes for the dev server to start. It can take a |
| # while to start when generating payloads in parallel. |
| DEV_SERVER_TIMEOUT = 900 |
| KILL_TIMEOUT = 10 |
| |
| def __init__(self, static_dir=DEFAULT_STATIC_DIR, port=None, log_dir=None, |
| src_image=None, board=None): |
| """Initialize a DevServerWrapper instance. |
| |
| Args: |
| static_dir: The static directory to be used by the devserver. |
| port: The port to used by the devserver. |
| log_dir: Directory to store the log files. |
| src_image: The path to the image to be used as the base to |
| generate delta payloads. |
| board: Override board to pass to the devserver for xbuddy pathing. |
| """ |
| super(DevServerWrapper, self).__init__() |
| self.devserver_bin = 'start_devserver' |
| # Set port if it is given. Otherwise, devserver will start at any |
| # available port. |
| self.port = None if not port else port |
| self.src_image = src_image |
| self.board = board |
| self.tempdir = None |
| self.log_dir = log_dir |
| if not self.log_dir: |
| self.tempdir = osutils.TempDir( |
| base_dir=path_util.FromChrootPath('/tmp'), |
| prefix='devserver_wrapper', |
| sudo_rm=True) |
| self.log_dir = self.tempdir.tempdir |
| self.static_dir = static_dir |
| self.log_file = os.path.join(self.log_dir, 'dev_server.log') |
| self.port_file = os.path.join(self.log_dir, 'dev_server.port') |
| self._pid_file = self._GetPIDFilePath() |
| self._pid = None |
| |
| @classmethod |
| def DownloadFile(cls, url, dest): |
| """Download the file from the URL to a local path.""" |
| if os.path.isdir(dest): |
| dest = os.path.join(dest, os.path.basename(url)) |
| |
| logging.info('Downloading %s to %s', url, dest) |
| osutils.WriteFile(dest, DevServerWrapper.OpenURL(url), mode='wb') |
| |
| def GetURL(self, sub_dir=None): |
| """Returns the URL of this devserver instance.""" |
| return self.GetDevServerURL(port=self.port, sub_dir=sub_dir) |
| |
| @classmethod |
| def GetDevServerURL(cls, ip=None, port=None, sub_dir=None): |
| """Returns the dev server url. |
| |
| Args: |
| ip: IP address of the devserver. If not set, use the IP |
| address of this machine. |
| port: Port number of devserver. |
| sub_dir: The subdirectory of the devserver url. |
| """ |
| ip = GetIPv4Address() if not ip else ip |
| # If port number is not given, assume 8080 for backward |
| # compatibility. |
| port = DEFAULT_PORT if not port else port |
| url = 'http://%(ip)s:%(port)s' % {'ip': ip, 'port': str(port)} |
| if sub_dir: |
| url += '/' + sub_dir |
| |
| return url |
| |
| |
| @classmethod |
| def OpenURL(cls, url, ignore_url_error=False, timeout=60): |
| """Returns the HTTP response of a URL.""" |
| logging.debug('Retrieving %s', url) |
| try: |
| res = urllib.request.urlopen(url, timeout=timeout) |
| except (urllib.error.HTTPError, httplib.HTTPException) as e: |
| logging.error('Devserver responded with HTTP error (%s)', e) |
| raise DevServerResponseError(e) |
| except (urllib.error.URLError, socket.timeout) as e: |
| if not ignore_url_error: |
| logging.error('Cannot connect to devserver (%s)', e) |
| raise DevServerConnectionError(e) |
| else: |
| return res.read() |
| |
| @classmethod |
| def CreateStaticDirectory(cls, static_dir=DEFAULT_STATIC_DIR): |
| """Creates |static_dir|. |
| |
| Args: |
| static_dir: path to the static directory of the devserver instance. |
| """ |
| osutils.SafeMakedirsNonRoot(static_dir) |
| |
| @classmethod |
| def WipeStaticDirectory(cls, static_dir=DEFAULT_STATIC_DIR): |
| """Cleans up |static_dir|. |
| |
| Args: |
| static_dir: path to the static directory of the devserver instance. |
| """ |
| cls.WipePayloadCache(static_dir=static_dir) |
| logging.info('Clearing cache directory %s', static_dir) |
| osutils.RmDir(static_dir, ignore_missing=True, sudo=True) |
| |
| @classmethod |
| def WipePayloadCache(cls, devserver_bin='start_devserver', static_dir=None): |
| """Cleans up devserver cache of payloads. |
| |
| This isn't necessary for chrome checkouts. |
| |
| Args: |
| devserver_bin: path to the devserver binary. |
| static_dir: path to use as the static directory of the devserver instance. |
| """ |
| if path_util.DetermineCheckout().type == path_util.CHECKOUT_TYPE_GCLIENT: |
| return |
| |
| logging.info('Cleaning up previously generated payloads.') |
| cmd = [devserver_bin, '--clear_cache', '--exit'] |
| if static_dir: |
| cmd.append('--static_dir=%s' % path_util.ToChrootPath(static_dir)) |
| |
| cros_build_lib.sudo_run( |
| cmd, enter_chroot=True, print_cmd=False, stderr=subprocess.STDOUT, |
| stdout=True, cwd=constants.SOURCE_ROOT) |
| |
| def _ReadPortNumber(self): |
| """Read port number from file.""" |
| if not self.is_alive(): |
| raise DevServerStartupError('Devserver is dead and has no port number') |
| |
| try: |
| timeout_util.WaitForReturnTrue(os.path.exists, |
| func_args=[self.port_file], |
| timeout=self.DEV_SERVER_TIMEOUT, |
| period=5) |
| except timeout_util.TimeoutError: |
| self.terminate() |
| raise DevServerStartupError('Timeout (%s) waiting for devserver ' |
| 'port_file' % self.DEV_SERVER_TIMEOUT) |
| |
| self.port = int(osutils.ReadFile(self.port_file).strip()) |
| |
| def IsReady(self): |
| """Check if devserver is up and running.""" |
| if not self.is_alive(): |
| raise DevServerStartupError('Devserver is not ready because it died') |
| |
| url = os.path.join('http://%s:%d' % (remote_access.LOCALHOST_IP, self.port), |
| 'check_health') |
| if self.OpenURL(url, ignore_url_error=True, timeout=2): |
| return True |
| |
| return False |
| |
| def _GetPIDFilePath(self): |
| """Returns pid file path.""" |
| return tempfile.NamedTemporaryFile(prefix='devserver_wrapper', |
| dir=self.log_dir, |
| delete=False).name |
| |
| def _GetPID(self): |
| """Returns the pid read from the pid file.""" |
| # Pid file was passed into the chroot. |
| return osutils.ReadFile(self._pid_file).rstrip() |
| |
| def _WaitUntilStarted(self): |
| """Wait until the devserver has started.""" |
| if not self.port: |
| self._ReadPortNumber() |
| |
| try: |
| timeout_util.WaitForReturnTrue(self.IsReady, |
| timeout=self.DEV_SERVER_TIMEOUT, |
| period=5) |
| except timeout_util.TimeoutError: |
| self.terminate() |
| raise DevServerStartupError('Devserver did not start') |
| |
| def run(self): |
| """Kicks off devserver in a separate process and waits for it to finish.""" |
| # Truncate the log file if it already exists. |
| if os.path.exists(self.log_file): |
| osutils.SafeUnlink(self.log_file, sudo=True) |
| |
| path_resolver = path_util.ChrootPathResolver() |
| |
| port = self.port if self.port else 0 |
| cmd = [self.devserver_bin, |
| '--pidfile', path_resolver.ToChroot(self._pid_file), |
| '--logfile', path_resolver.ToChroot(self.log_file), |
| '--port=%d' % port, |
| '--static_dir=%s' % path_resolver.ToChroot(self.static_dir)] |
| |
| if not self.port: |
| cmd.append('--portfile=%s' % path_resolver.ToChroot(self.port_file)) |
| |
| if self.src_image: |
| cmd.append('--src_image=%s' % path_resolver.ToChroot(self.src_image)) |
| |
| if self.board: |
| cmd.append('--board=%s' % self.board) |
| |
| chroot_args = ['--no-ns-pid'] |
| # The chromite bin directory is needed for cros_generate_update_payload. |
| extra_env = { |
| 'PATH': '%s:%s' % (os.environ['PATH'], |
| path_resolver.ToChroot(constants.CHROMITE_BIN_DIR))} |
| result = self._RunCommand( |
| cmd, enter_chroot=True, chroot_args=chroot_args, |
| cwd=constants.SOURCE_ROOT, extra_env=extra_env, check=False, |
| stdout=True, stderr=subprocess.STDOUT, encoding='utf-8') |
| if result.returncode != 0: |
| msg = ('Devserver failed to start!\n' |
| '--- Start output from the devserver startup command ---\n' |
| '%s' |
| '--- End output from the devserver startup command ---' % |
| result.output) |
| logging.error(msg) |
| |
| def Start(self): |
| """Starts a background devserver and waits for it to start. |
| |
| Starts a background devserver and waits for it to start. Will only return |
| once devserver has started and running pid has been read. |
| """ |
| self.start() |
| self._WaitUntilStarted() |
| self._pid = self._GetPID() |
| |
| def Stop(self): |
| """Kills the devserver instance with SIGTERM and SIGKILL if SIGTERM fails""" |
| if not self._pid: |
| logging.debug('No devserver running.') |
| return |
| |
| logging.debug('Stopping devserver instance with pid %s', self._pid) |
| if self.is_alive(): |
| self._RunCommand(['kill', self._pid], check=False) |
| else: |
| logging.debug('Devserver not running') |
| return |
| |
| self.join(self.KILL_TIMEOUT) |
| if self.is_alive(): |
| logging.warning('Devserver is unstoppable. Killing with SIGKILL') |
| try: |
| self._RunCommand(['kill', '-9', self._pid]) |
| except cros_build_lib.RunCommandError as e: |
| raise DevServerStopError('Unable to stop devserver: %s' % e) |
| |
| def PrintLog(self): |
| """Print devserver output to stdout.""" |
| print(self.TailLog(num_lines='+1')) |
| |
| def TailLog(self, num_lines=50): |
| """Returns the most recent |num_lines| lines of the devserver log file.""" |
| fname = self.log_file |
| # We use self._RunCommand here to check the existence of the log file. |
| if self._RunCommand( |
| ['test', '-f', fname], check=False).returncode == 0: |
| result = self._RunCommand(['tail', '-n', str(num_lines), fname], |
| capture_output=True, encoding='utf-8') |
| output = '--- Start output from %s ---' % fname |
| output += result.output |
| output += '--- End output from %s ---' % fname |
| return output |
| |
| def _RunCommand(self, *args, **kwargs): |
| """Runs a shell commmand.""" |
| kwargs.setdefault('debug_level', logging.DEBUG) |
| return cros_build_lib.sudo_run(*args, **kwargs) |